Compare commits

..

No commits in common. "7b895ad785244014df7e8a2970db49fa8c741bc2" and "23dcd158ecf64d52b57db23150938ffebf81b86a" have entirely different histories.

27 changed files with 190 additions and 9161 deletions

3
.gitignore vendored
View file

@ -30,7 +30,6 @@
/includes/*
!/includes/admin/
!/includes/zoho/
!/includes/find-training/
!/includes/**/*.php
!/templates/
/templates/*
@ -187,7 +186,7 @@
# **/.env.*
.auth/
# **/.auth/
**/zoho-config.php
# **/zoho-config.php
# **/wp-config.php
# **/wp-tests-config*.php
memory-bank/mcpServers.md

View file

@ -1,260 +0,0 @@
# Multi-Agent, Multi-Model Code Review Report
**Plugin:** HVAC Community Events WordPress Plugin
**Date:** 2026-01-31
**Models Used:** GPT-5 (Codex), Gemini 3, Kimi K2.5, Zen MCP (secaudit, analyze, codereview)
**Files Reviewed:** 11 critical components (~9,000 lines)
---
## Executive Summary
**Overall Security Posture: MODERATE**
The HVAC Community Events plugin demonstrates solid security foundations with centralized security helpers, proper nonce verification, prepared SQL statements, and well-designed certificate download security. However, several issues require attention, particularly in sensitive data handling and cryptographic key management.
**Context:** This is a small local WordPress environment with a single user. Security and scalability concerns are appropriately deprioritized in favor of functionality and maintainability. Findings are rated with this context in mind.
---
## Findings by Consensus Level
### CONSENSUS (3+ Tools Agree) - Highest Priority
| ID | Severity | Issue | Location | Tools |
|----|----------|-------|----------|-------|
| C1 | **CRITICAL** | Plaintext passwords stored in transients | `class-hvac-registration.php:98,196-199` | GPT-5, Zen OWASP, Zen Synthesis |
| C2 | **HIGH** | Encryption key stored in database | `class-hvac-secure-storage.php:30-40` | Kimi K2.5, Zen OWASP, Zen Synthesis |
| C3 | **HIGH** | IP spoofing undermines rate limiting | `class-hvac-security.php:186-198` | Gemini 3, Zen OWASP, Zen Synthesis |
| C4 | **MEDIUM** | Singleton API inconsistency | `class-hvac-plugin.php`, multiple | GPT-5, Zen Architecture, Zen Synthesis |
| C5 | **MEDIUM** | Duplicate component initialization | `class-hvac-plugin.php:724-735,900-915` | GPT-5, Zen Architecture |
### MAJORITY (2 Tools Agree) - High Priority
| ID | Severity | Issue | Location | Tools | Dissent |
|----|----------|-------|----------|-------|---------|
| M1 | **HIGH** | Weak CSP (unsafe-inline/unsafe-eval) | `class-hvac-ajax-security.php:88-97` | Gemini 3, Zen OWASP | Zen Synthesis: defer |
| M2 | **HIGH** | OAuth refresh token race condition | `class-zoho-crm-auth.php:156-163` | Kimi K2.5, Zen OWASP | Low risk for local |
| M3 | **MEDIUM** | Revoked certificates still downloadable | `class-certificate-manager.php:843-855` | Kimi K2.5, Zen Synthesis | |
| M4 | **MEDIUM** | Non-atomic Zoho sync operations | `class-zoho-sync.php` | Gemini 3, Zen Architecture | Self-healing |
| M5 | **MEDIUM** | Certificate number race condition | `class-certificate-manager.php:61-74` | Kimi K2.5, Zen Architecture | Very low risk |
### UNIQUE INSIGHTS - Model-Specific Findings
| ID | Severity | Issue | Location | Model |
|----|----------|-------|----------|-------|
| U1 | **CRITICAL** | O(expiry) token verification loop (DoS) | `class-hvac-ajax-security.php:498-513` | Zen Synthesis |
| U2 | **HIGH** | `remove_all_actions()` breaks WP isolation | `class-hvac-plugin.php:1047-1054` | Zen Architecture |
| U3 | **HIGH** | Security headers not applied to AJAX | `class-hvac-ajax-security.php:85-103` | Zen Synthesis |
| U4 | **HIGH** | Config file fallback credential exposure | `zoho-config.php` | Kimi K2.5 |
| U5 | **MEDIUM** | AES-256-CBC without MAC (no integrity) | `class-hvac-secure-storage.php:54-92` | Zen OWASP |
| U6 | **MEDIUM** | Audit log injection via filenames | `class-hvac-security-helpers.php:571` | Gemini 3 |
| U7 | **MEDIUM** | Roles treated as capabilities | `class-hvac-ajax-handlers.php:76-81` | GPT-5 |
| U8 | **MEDIUM** | Log sanitization regex gaps | `class-zoho-crm-auth.php:504-520` | Kimi K2.5 |
| U9 | **MEDIUM** | File-scope side-effect initialization | `class-hvac-trainer-profile-manager.php:1235` | Zen Architecture |
| U10 | **LOW** | PII (email) written to error logs | `class-hvac-ajax-handlers.php:1016-1026` | Zen OWASP |
| U11 | **LOW** | Timezone inconsistency at year boundary | `class-certificate-manager.php:70` | Kimi K2.5 |
### VALIDATED AS NON-ISSUES
| Original Concern | Status | Reason |
|------------------|--------|--------|
| Path traversal in certificate downloads | **NOT EXPLOITABLE** | Token-based system stores file_path in transients, not user input |
| SQL injection in `compile_trainer_stats()` | **SECURE** | Proper `$wpdb->prepare()` usage confirmed |
| OAuth state CSRF protection | **PROPERLY IMPLEMENTED** | Uses `hash_equals()` with single-use tokens |
---
## OWASP Top 10 Mapping
| Category | Status | Key Issues |
|----------|--------|------------|
| A01 - Broken Access Control | SECURE | Minor: roles as capabilities |
| A02 - Cryptographic Failures | **VULNERABLE** | C1 (passwords), C2 (key storage), U5 (no MAC) |
| A03 - Injection | SECURE | Prepared statements throughout |
| A04 - Insecure Design | Minor | U1 (O(n) loop) |
| A05 - Security Misconfiguration | **VULNERABLE** | M1 (weak CSP) |
| A06 - Vulnerable Components | Not Assessed | |
| A07 - Authentication Failures | **VULNERABLE** | C3 (IP spoofing) |
| A08 - Software/Data Integrity | Minor | M2 (token race) |
| A09 - Logging Failures | Minor | U6 (log injection), U10 (PII in logs) |
| A10 - SSRF | SECURE | |
---
## Positive Security Patterns Identified
All models identified strong security practices:
1. **Centralized AJAX Security** - `HVAC_Ajax_Security::verify_ajax_request()` enforces login + nonce + capability
2. **Consistent Input Sanitization** - `sanitize_text_field()`, `absint()`, `wp_kses_post()` throughout
3. **Prepared SQL Statements** - `$wpdb->prepare()` with proper placeholders
4. **Certificate Download Security** - Time-limited, random tokens with one-time-use
5. **OAuth CSRF Protection** - Timing-safe `hash_equals()` comparison
6. **Comprehensive Audit Logging** - Security events logged with 30-day retention
7. **WordPress Native Password Functions** - No custom password hashing
8. **Modern PHP 8+ Features** - Strict types, generators, match expressions
---
## Recommended Action Plan
### IMMEDIATE (This Session) - Critical Impact - ✅ ALL IMPLEMENTED
| Priority | Issue ID | Action | File | Status |
|----------|----------|--------|------|--------|
| 1 | C1 | Strip password fields before transient storage | `class-hvac-registration.php` | ✅ FIXED |
| 2 | U1 | Rewrite token verification to O(1) | `class-hvac-ajax-security.php` | ✅ FIXED |
| 3 | U2 | Replace `remove_all_actions()` with targeted removal | `class-hvac-plugin.php` | ✅ FIXED |
### SHORT-TERM (Before Production) - High Impact - ✅ ALL IMPLEMENTED
| Priority | Issue ID | Action | File | Status |
|----------|----------|--------|------|--------|
| 4 | C2 | Move encryption key to wp-config.php constant | `class-hvac-secure-storage.php` | ✅ FIXED |
| 5 | M3 | Add revoked check to `get_certificate_url()` | `class-certificate-manager.php` | ✅ FIXED |
| 6 | U3 | Fix security headers condition for AJAX | `class-hvac-ajax-security.php` | ✅ FIXED |
| 7 | U4 | Ensure `zoho-config.php` is in .gitignore | `.gitignore` | ✅ FIXED |
### MEDIUM-TERM - Incremental Improvement - ✅ ALL IMPLEMENTED
| Priority | Issue ID | Action | File | Status |
|----------|----------|--------|------|--------|
| 8 | C3 | Fix IP spoofing with trusted proxy validation | `class-hvac-security.php` | ✅ FIXED |
| 9 | C5 | Remove duplicate init from `initializeFindTrainer()` | `class-hvac-plugin.php` | ✅ FIXED |
| 10 | U9 | Remove file-scope side-effect initialization | `class-hvac-trainer-profile-manager.php` | ✅ FIXED |
| 11 | M1 | Remove `unsafe-eval` from CSP | `class-hvac-ajax-security.php` | ✅ FIXED |
| 12 | U11 | Fix timezone inconsistency in certificate numbers | `class-certificate-manager.php` | ✅ FIXED |
### DEFERRED (Low Priority for Local Environment)
- U5: Upgrade to AES-GCM authenticated encryption
- M2: OAuth refresh token locking mechanism
- M4: Transaction wrapper for sync operations
- M5: Atomic certificate number generation
- C4: Standardize singleton API to `::instance()` (code style only)
- U6: Audit log injection via filenames
- U7: Roles treated as capabilities
- U8: Log sanitization regex gaps
- U10: PII (email) written to error logs
---
## Code Fixes Reference
### Fix C1: Password Transient Storage
```php
// File: class-hvac-registration.php, around line 98
$submitted_data = $_POST;
// ADD THESE LINES:
unset(
$submitted_data['user_pass'],
$submitted_data['confirm_password'],
$submitted_data['current_password'],
$submitted_data['new_password'],
$submitted_data['hvac_registration_nonce']
);
```
### Fix U1: O(1) Token Verification
```php
// File: class-hvac-ajax-security.php, replace lines 498-513
public static function generate_secure_token($action, $user_id) {
$ts = time();
$salt = wp_salt('auth');
$sig = hash_hmac('sha256', $action . '|' . $user_id . '|' . $ts, $salt);
return $ts . '.' . $sig;
}
public static function verify_secure_token($token, $action, $user_id, $expiry = 3600) {
$parts = explode('.', $token, 2);
if (count($parts) !== 2) return false;
[$ts, $sig] = $parts;
$ts = (int) $ts;
if (!$ts || empty($sig) || (time() - $ts) > $expiry) {
return false;
}
$expected = hash_hmac('sha256', $action . '|' . $user_id . '|' . $ts, wp_salt('auth'));
return hash_equals($expected, $sig);
}
```
### Fix U2: Targeted Hook Removal
```php
// File: class-hvac-plugin.php, around line 1052
// REPLACE:
remove_all_actions('template_redirect', 10);
// WITH:
// Remove only specific HVAC auth callbacks that block registration
remove_action('template_redirect', [$this, 'hvac_auth_redirect'], 10);
// Or use early-return guard in the auth callback itself
```
### Fix C2: Encryption Key in wp-config.php
```php
// Add to wp-config.php:
define('HVAC_ENCRYPTION_KEY', base64_encode(random_bytes(32)));
// Modify class-hvac-secure-storage.php lines 30-40:
private static function get_encryption_key() {
if (!defined('HVAC_ENCRYPTION_KEY')) {
// Fallback for migration - log warning
error_log('HVAC: HVAC_ENCRYPTION_KEY not defined in wp-config.php');
$key = get_option('hvac_encryption_key');
if (!$key) {
$key = base64_encode(random_bytes(32));
update_option('hvac_encryption_key', $key);
}
return base64_decode($key);
}
return base64_decode(HVAC_ENCRYPTION_KEY);
}
```
---
## Files Reviewed
### Security-Critical (6 files, ~4,000 lines)
| File | Lines | Focus |
|------|-------|-------|
| `class-hvac-security.php` | 234 | Nonce, rate limiting, IP detection |
| `class-hvac-ajax-security.php` | 517 | AJAX auth, audit logging, CSP |
| `class-hvac-ajax-handlers.php` | 1030 | AJAX endpoints, input validation |
| `class-hvac-security-helpers.php` | 644 | Sanitization, file upload validation |
| `class-hvac-registration.php` | ~1000 | User registration, file uploads |
| `class-zoho-crm-auth.php` | 574 | OAuth2 tokens, credential storage |
### Business Logic (5 files, ~5,000 lines)
| File | Lines | Focus |
|------|-------|-------|
| `class-hvac-plugin.php` | 1,457 | Main controller, 50+ components |
| `class-hvac-event-manager.php` | 1,057 | Event CRUD, TEC integration |
| `class-hvac-trainer-profile-manager.php` | 1,236 | Profiles, taxonomies |
| `class-zoho-sync.php` | ~1,400 | CRM sync, pagination, hashing |
| `class-certificate-manager.php` | 906 | Certificate generation, files |
---
## Review Methodology
1. **Phase 1 (Parallel):** Security review with GPT-5, Gemini 3, Kimi K2.5
2. **Phase 2 (Parallel):** Business logic review with GPT-5, Gemini 3, Kimi K2.5
3. **Phase 3 (Parallel):** Zen OWASP audit, Architecture analysis, Code review synthesis
4. **Phase 4:** Consolidation by consensus level
**Total Models:** 4 (GPT-5, Gemini 3, Kimi K2.5, Zen MCP)
**Total Agents:** 9 background agents
**Consensus Methodology:** Issues flagged by 3+ tools prioritized highest
---
*Report generated by multi-agent code review system*

621
Status.md
View file

@ -1,623 +1,12 @@
# HVAC Community Events - Project Status
**Last Updated:** February 6, 2026
**Current Session:** Zoho CRM Sync Production Fix
**Version:** 2.2.11 (Deployed to Production)
**Last Updated:** January 9, 2026
**Current Session:** Master Trainer Profile Edit Enhancement - Complete
**Version:** 2.1.12 (Deployed to Production)
---
## 🎯 NEXT SESSION - CAPTCHA IMPLEMENTATION
### Status: 📋 **PLANNED**
**Objective:** Add CAPTCHA to all user-facing forms to prevent spam and bot submissions.
**Forms to Update:**
- Training login form
- Trainer registration form
- Contact forms (trainer, venue)
- Any other public-facing forms
---
## 🎯 CURRENT SESSION - ZOHO CRM SYNC PRODUCTION FIX (Feb 6, 2026)
### Status: ✅ **COMPLETE - 64/64 Trainers Syncing to Zoho CRM**
**Objective:** Fix Zoho CRM user sync that was silently failing on production - 0/65 users syncing despite "connection successful" status.
### Issues Found & Fixed (iterative production debugging)
1. **Version Constant Mismatch** (`includes/class-hvac-plugin.php`)
- `HVAC_PLUGIN_VERSION` stuck at `'2.0.0'` while `HVAC_VERSION` was `'2.2.10'`
- Admin JS used `HVAC_PLUGIN_VERSION` for cache busting, so browser served old JS without reset handler
- Fix: Bumped both constants to `'2.2.11'`
2. **GET Request Search Criteria Ignored** (`includes/zoho/class-zoho-sync.php`)
- `sync_users()` passed search criteria as `$data` parameter to `make_api_request()`
- But `make_api_request()` ignores `$data` for GET requests (line 345 only includes body for POST/PUT/PATCH)
- Every Zoho contact search returned unfiltered results, causing all syncs to fail
- Fix: Moved criteria into URL query string: `/Contacts/search?criteria=(Email:equals:...)`
3. **Error Reporting Priority** (`includes/zoho/class-zoho-sync.php`)
- `validate_api_response()` checked generic `error` key before Zoho-specific `data[0]` errors
- Users saw "API error with status code 400" instead of actionable field-level errors
- Fix: Reordered to check Zoho `data[0]` errors first, added field-level detail (api_name, expected_data_type, info)
4. **Phone Field Validation** (`includes/zoho/class-zoho-sync.php`)
- Zoho rejects invalid phone formats with HTTP 400
- Fix: Strip non-digit chars, require 10+ digits, only include Phone field when valid
5. **Last_Name Required Field** (`includes/zoho/class-zoho-sync.php`)
- Zoho Contacts module requires Last_Name but some WP users had empty last names
- Fix: Fallback chain: `last_name` meta → `display_name``user_login`
6. **Spam Account Cleanup** (production WP-CLI)
- User 14 (`rigobertohugo19`, `info103@noreply0.com`) was a spam/bot registration with no name
- Demoted from `hvac_trainer` to `subscriber`
### Production Sync Results (iterative)
| Attempt | Synced | Failed | Issue |
|---------|--------|--------|-------|
| 1st (pre-fix) | 0 | 65 | Search criteria not sent to Zoho API |
| 2nd (search fix) | 63 | 2 | Generic "status code 400" errors |
| 3rd (error detail) | 0 | 2 | User 57: invalid Phone, User 14: missing Last_Name |
| 4th (data validation) | 31 | 1 | User 57 phone still failing (7-digit threshold too low) |
| 5th (10-digit threshold) | 26 | 0 | **All 65 trainers synced** |
| Final (User 14 demoted) | **64/64** | **0** | All active trainers syncing |
### Files Modified
| File | Change |
|------|--------|
| `includes/class-hvac-plugin.php` | Synced `HVAC_PLUGIN_VERSION` to `'2.2.11'` (was stuck at `'2.0.0'`) |
| `includes/zoho/class-zoho-sync.php` | Search criteria in URL, error priority fix, phone sanitization, Last_Name fallback |
### Git Commits
- `03b9bce5` - fix(zoho): Fix silent sync failures with API response validation and hash reset
- `4c22b9db` - fix(zoho): Fix user sync search criteria and improve data validation
---
## 📋 PREVIOUS SESSION - ZOHO CRM SYNC ARCHITECTURE FIX (Feb 6, 2026)
### Status: ✅ **COMPLETE - Deployed to Production**
**Objective:** Fix Zoho CRM integration that appeared connected but was silently failing to sync data (events, attendees, ticket sales not appearing in Zoho CRM).
### Root Cause Analysis (4-model consensus: GPT-5, Gemini 3, Zen Code Review, Zen Debug)
The sync pipeline had a "silent failure" architecture:
1. **Connection test only uses GET** - bypasses staging write block, giving false confidence
2. **Staging mode returned fake `'status' => 'success'`** for blocked writes - appeared successful
3. **Sync methods unconditionally updated `_zoho_sync_hash`** after any "sync" - poisoned hashes
4. **Subsequent syncs compared hashes, found matches, skipped all records** - permanent data loss
5. **No API response validation** - even real Zoho errors were silently ignored
### Fixes Implemented
1. **API Response Validation** (`includes/zoho/class-zoho-sync.php`)
- Added `validate_api_response()` helper method
- Checks for WP_Error, staging mode blocks, HTTP errors, Zoho error codes
- Confirms success with ID extraction before treating a sync as successful
2. **Hash-Only-On-Success** (`includes/zoho/class-zoho-sync.php`)
- All 5 sync methods (events, users, attendees, rsvps, purchases) rewritten
- `_zoho_sync_hash` only updates when Zoho API confirms the write succeeded
- Failed syncs increment `$results['failed']` with error details
- Changed `catch (Exception)` to `catch (\Throwable)` for comprehensive error handling
3. **Staging Mode Detection Fix** (`includes/zoho/class-zoho-crm-auth.php`)
- Replaced fragile `strpos()` substring matching with `wp_parse_url()` hostname comparison
- Production (`upskillhvac.com` / `www.upskillhvac.com`) explicitly whitelisted
- All other hostnames default to staging mode
- `HVAC_ZOHO_PRODUCTION_MODE` / `HVAC_ZOHO_STAGING_MODE` constants can override
- Staging fake responses now return `'skipped_staging'` instead of misleading `'success'`
4. **Admin UI: Hash Reset & Staging Warning** (`includes/admin/class-zoho-admin.php`, `assets/js/zoho-admin.js`)
- Added `reset_sync_hashes()` AJAX handler - clears all `_zoho_sync_hash` from postmeta and usermeta
- Added "Force Full Re-sync (Reset Hashes)" button with confirmation dialog
- Connection test now surfaces staging warning when staging mode is active
5. **Staging Environment Fix** (staging `wp-config.php`)
- Removed `HVAC_ZOHO_PRODUCTION_MODE` constant from staging wp-config.php
- Staging now correctly blocks all Zoho write operations via hostname detection
- GET requests (reads) still pass through for testing
### Files Modified
| File | Change |
|------|--------|
| `includes/zoho/class-zoho-sync.php` | Added `validate_api_response()`, rewrote all 5 sync methods for validated hashing |
| `includes/zoho/class-zoho-crm-auth.php` | Rewrote `is_staging_mode()` with hostname parsing, changed fake response status |
| `includes/admin/class-zoho-admin.php` | Added `reset_sync_hashes()` handler, reset button HTML, staging warning in connection test |
| `assets/js/zoho-admin.js` | Added reset hashes button handler, staging warning display in connection test |
---
## 📋 PREVIOUS SESSION - NEAR ME BUTTON MOBILE FIX (Feb 6, 2026)
### Status: ✅ **COMPLETE - Deployed to Staging**
**Objective:** Fix mobile layout issue where the "Near Me" button caused the search bar to shrink when location was granted.
### Issues Found & Fixed
1. ✅ **CSS/HTML Button Structure Bug** (`assets/js/find-training-filters.js`)
- When the Near Me button state changed, the HTML was replaced without the `.hvac-btn-text` wrapper class
- On mobile, CSS hides `.hvac-btn-text`, but unwrapped text was visible causing layout issues
- Fixed in 5 locations: loading state, success state, error reset, clear filters, remove location filter
2. ✅ **No Feedback for Empty Results** (`assets/js/find-training-filters.js`)
- When "Near Me" filter returned no results within 100km, user saw empty map with no explanation
- Added notification: "No trainers, venues, or events found within 100km of your location. Try removing the 'Near Me' filter to see all results."
### Files Modified
| File | Change |
|------|--------|
| `assets/js/find-training-filters.js` | Fixed button HTML to preserve `.hvac-btn-text` wrapper; added empty results notification |
### Code Changes
**Button HTML Fix (5 locations):**
```javascript
// Before (broken on mobile):
$button.html('<span class="dashicons dashicons-yes-alt"></span> Near Me');
// After (correct):
$button.html('<span class="dashicons dashicons-yes-alt"></span><span class="hvac-btn-text">Near Me</span>');
```
**Empty Results Notification:**
```javascript
if (self.userLocation) {
const totalResults = HVACTrainingMap.trainers.length +
HVACTrainingMap.venues.length +
HVACTrainingMap.events.length;
if (totalResults === 0) {
self.showLocationError('No trainers, venues, or events found within 100km...');
}
}
```
### Mobile Testing Performed
- ✅ 375x812 (iPhone X) - Map and markers display correctly
- ✅ 320x568 (iPhone SE) - Map and markers display correctly
- ✅ Cluster markers expand on click
- ✅ Individual trainer markers clickable
- ✅ Info windows display correctly
- ✅ Profile modal opens and displays correctly
---
## 📋 PREVIOUS SESSION - CHAMPION DIFFERENTIATION ON FIND TRAINING (Feb 2, 2026)
### Status: ✅ **COMPLETE - Deployed to Production**
**Objective:** Differentiate measureQuick Certified Champions from Trainers on the Find Training map. Champions do not offer public training, so they should be displayed differently.
### Changes Made
1. ✅ **Backend Data** (`includes/find-training/class-hvac-training-map-data.php`)
- Added `is_champion` flag for trainers with "Certified measureQuick Champion" certification
- Champions return empty `city` field (only state shown)
2. ✅ **Champion Marker Icon** (`assets/js/find-training-map.js`)
- Added `getChampionIcon()` method with white outline (vs green for Trainers)
- Champions use distinct visual appearance on map
3. ✅ **Sidebar Cards** (`assets/js/find-training-map.js`)
- Champions show only state (e.g., "Ohio" not "Canton, Ohio")
- Champions have non-clickable cards (no modal popup)
- Added `hvac-champion-card` CSS class
4. ✅ **Info Windows** (`assets/js/find-training-map.js`)
- Champions show only state in location
- No "View Profile" button for Champions
- Fixed location formatting to handle empty city gracefully
5. ✅ **Sorting** (`assets/js/find-training-map.js`)
- Champions sorted to end of trainer list
- Secondary sort by name for stable ordering
- Applied to both `loadMapData` and `loadTrainerDirectory`
6. ✅ **CSS Styling** (`assets/css/find-training-map.css`)
- Champion cards have non-clickable appearance
- No hover effects (cursor: default, no transform/shadow)
### Files Modified
| File | Change |
|------|--------|
| `includes/find-training/class-hvac-training-map-data.php` | Added `is_champion` flag, empty city for champions |
| `assets/js/find-training-map.js` | Champion icon, card display, click prevention, sorting |
| `assets/css/find-training-map.css` | Champion card non-clickable styling |
### Verification (Staging)
- ✅ API returns `is_champion: true` for 18 champions
- ✅ Champions have empty city in API response
- ✅ Champions sorted to end of list
- ✅ Champion cards show only state
- ✅ Clicking champion card does NOT open modal
- ✅ Clicking trainer card DOES open modal (regression test)
### Code Review Findings (Gemini 3)
- **Fixed:** Location formatting bug - empty city could show ", California"
- **Fixed:** Unstable sort order - added secondary sort by name
---
## 📋 PREVIOUS SESSION - TABBED INTERFACE FOR FIND TRAINING (Feb 1, 2026)
### Status: ✅ **COMPLETE - Deployed to Production**
**Objective:** Refactor the Find Training page sidebar from a single trainer list to a tabbed interface with Trainers, Venues, and Events tabs.
### Changes Made
1. ✅ **Tab Navigation System** (`templates/page-find-training.php`)
- Replaced "42 trainers" header with three tabs: Trainers | Venues | Events
- Each tab displays dynamic count in parentheses
- ARIA accessibility attributes (role="tablist", role="tab", aria-selected)
- Keyboard navigation (arrow keys, Home, End)
2. ✅ **Visibility Toggles Relocated**
- Moved from map overlay to sidebar header
- Colored dots (teal/orange/purple) match marker colors
- Only control map marker visibility, not tab content
3. ✅ **Venue Cards** (`assets/js/find-training-map.js`)
- Name with building icon
- City, State location
- "X upcoming events" count
4. ✅ **Event Cards** (`assets/js/find-training-map.js`)
- Date badge showing month/day
- Event title
- Venue name
- Cost display
- "Past" badge for past events
5. ✅ **Info Modal** (`templates/page-find-training.php`)
- "What is Upskill HVAC?" section
- "How to Use" instructions
- Map Legend (trainer/venue/event markers)
6. ✅ **Context-Aware Search** (`assets/js/find-training-filters.js`)
- Placeholder changes based on active tab
- Client-side filtering for instant results (150ms debounce)
- Falls back to server-side when filters active
7. ✅ **CSS Styling** (`assets/css/find-training-map.css`)
- Tab navigation container and button styles
- Venue card and event card layouts
- Info button and modal styling
- Responsive adjustments for tablet/mobile
### Files Modified
| File | Change |
|------|--------|
| `templates/page-find-training.php` | Tab navigation, panels, info modal |
| `assets/js/find-training-map.js` | Tab switching, card creators, grid renderers |
| `assets/js/find-training-filters.js` | Context-aware search, client-side filtering |
| `assets/css/find-training-map.css` | ~300 lines new CSS for tabs, cards, modal |
| `includes/class-hvac-plugin.php` | Version bump 2.2.5 → 2.2.6 (CDN cache bust) |
### Issue Resolved: CDN Cache
**Problem:** After deployment, browser loaded old JavaScript without new `initTabs` method.
**Root Cause:** CDN cached old assets. Deploy script clears WP/OPcache but not CDN edge cache.
**Fix:** Bumped `HVAC_VERSION` from 2.2.5 to 2.2.6, changing script URL query string to force cache refresh.
### Verified on Staging
- ✅ 42 trainers tab count
- ✅ 9 venues tab count
- ✅ 8 events tab count
- ✅ Tab switching works with proper ARIA attributes
- ✅ Venue cards display correctly
- ✅ Event cards with date badges display correctly
- ✅ Info modal opens and displays content
- ✅ Visibility toggles control map markers
---
## 📋 PREVIOUS SESSION - MEASUREQUICK APPROVED TRAINING LABS (Feb 1, 2026)
### Status: ✅ **COMPLETE - Deployed to Staging, Venues Displaying Correctly**
**Objective:** Transform /find-training to showcase only measureQuick Approved Training Labs with venue categories, equipment/amenities tags, and contact forms.
1. ✅ **Venue Taxonomies Created** (`includes/class-hvac-venue-categories.php` NEW)
- `venue_type` - For lab classification (e.g., "measureQuick Approved Training Lab")
- `venue_equipment` - Furnace, Heat Pump, AC, Mini-Split, Boiler, etc.
- `venue_amenities` - Coffee, Water, Projector, WiFi, Parking, etc.
- All taxonomies registered on `tribe_venue` post type
2. ✅ **9 Approved Training Labs Configured** (via WP-CLI script)
- Fast Track Learning Lab (ID: 6631) - Joe Medosch
- Progressive Training Lab (ID: 6284) - Samantha Brazie
- NAVAC Technical Training Center (ID: 6476) - Andrew Greaves
- Stevens Equipment Supply - Phoenix (ID: 6448) - Robert Cone
- San Jacinto College South Campus (ID: 6521) - Terry McWilliams
- Johnstone Supply - Live Fire Training Lab (ID: 4864) - Dave Petz
- Stevens Equipment Supply - Johnstown (ID: 1648) - Phil Sweren
- TruTech Tools Training Center (NEW) - Val Buckles
- Auer Steel & Heating Supply (NEW) - Mike Breen
3. ✅ **Map Data Filtered by Taxonomy**
- `get_venue_markers()` now includes `tax_query` for `mq-approved-lab` term
- Only approved training labs appear as venue markers
- Equipment, amenities, and POC data included in venue info
4. ✅ **Venue Modal Enhanced**
- Equipment badges (teal outline chips)
- Amenities badges (gray outline chips)
- Contact form with name, email, phone, company, message
- POC receives email notification on submission
5. ✅ **6 POC Trainer Accounts Created**
- Samantha Brazie, Andrew Greaves, Terry McWilliams
- Phil Sweren, Robert Cone, Dave Petz
- All with `hvac_trainer` role and approved status
### Files Created
| File | Description |
|------|-------------|
| `includes/class-hvac-venue-categories.php` | Venue taxonomy registration (singleton) |
| `scripts/setup-approved-labs.php` | WP-CLI script to configure all labs |
| `scripts/check-venue-coordinates.php` | Diagnostic script to verify venue coordinates |
| `scripts/geocode-approved-labs.php` | Geocode venues missing coordinates |
### Files Modified
| File | Change |
|------|--------|
| `includes/class-hvac-plugin.php` | Load venue categories class |
| `includes/find-training/class-hvac-training-map-data.php` | Add tax_query filter, include equipment/amenities |
| `includes/class-hvac-ajax-handlers.php` | Add venue contact form AJAX handler |
| `templates/page-find-training.php` | Add equipment/amenities badges, contact form |
| `assets/js/find-training-map.js` | Render badges, bind contact form handler |
| `assets/css/find-training-map.css` | Equipment/amenities badge styles |
### Issue Resolved: Venues Now Displaying on Map ✅
**Root Cause:** `venue_type` taxonomy wasn't being registered when `get_venue_markers()` ran.
`HVAC_Venue_Categories::instance()` was instantiated at 'init' priority 5, but then tried to add hooks to 'init' priority 5 for taxonomy registration. Since WordPress was already processing priority 5 handlers, the hook never fired.
**Fix Applied:**
1. **`includes/class-hvac-venue-categories.php`** - Added `did_action('init')` check in constructor
- If 'init' has already fired, call `register_taxonomies()` and `create_default_terms()` directly
- Otherwise, use the standard hook approach
2. **`includes/find-training/class-hvac-training-map-data.php`** - Fixed HTML entity encoding
- Venue names now use `html_entity_decode()` to properly display `&` and `` characters
- Event titles also fixed
**Result:**
- ✅ All 9 approved training labs now display as orange venue markers on the map
- ✅ Venue names display correctly (e.g., "Auer Steel & Heating Supply" not "Auer Steel &#038;...")
- ✅ Marker clustering works with mixed trainer/venue markers
---
## 📋 PREVIOUS SESSION - FIND TRAINING PAGE ENHANCEMENTS (Feb 1, 2026)
### Status: ✅ **COMPLETE - Deployed to Production**
**Objective:** Improve Find Training page UX with viewport sync and marker hover interactions.
### Changes Made
1. ✅ **Viewport Sync** - Sidebar now shows only trainers visible in current map area
- Added `visibleTrainers` array to track filtered trainers
- Added `syncSidebarWithViewport()` method filtering by map bounds
- Map `idle` event triggers sync on pan/zoom
- Count shows "X of Y trainers" when zoomed in
2. ✅ **Marker Hover Interaction** - Info window appears on hover
- Added `mouseover` event listener to trainer/venue markers
- Set `optimized: false` on markers for reliable hover events
- Hover shows info window preview with "View Profile" button
- Click on "View Profile" opens full modal with contact form
3. ✅ **Legacy URL Redirects**
- `/find-a-trainer/``/find-training/` (301 redirect)
- `/find-trainer/``/find-training/` (301 redirect)
- Removed old page from Page Manager
### Files Modified
| File | Change |
|------|--------|
| `assets/js/find-training-map.js` | Added viewport sync, hover events, optimized:false |
| `assets/js/find-training-filters.js` | Updated filter handler for visibleTrainers |
| `includes/class-hvac-route-manager.php` | Added legacy URL redirects |
| `includes/class-hvac-page-manager.php` | Removed find-a-trainer page definition |
| `includes/class-hvac-plugin.php` | Version bumped to 2.2.4 |
### Verified Behavior
- ✅ Hover over marker → Info window appears immediately
- ✅ Click "View Profile" → Full modal with trainer details + contact form
- ✅ Pan/zoom map → Sidebar updates to show visible trainers only
- ✅ Legacy URLs redirect to new page
---
## 📋 PREVIOUS SESSION - FIND TRAINING PAGE IMPLEMENTATION (Jan 31 - Feb 1, 2026)
### Status: ✅ **COMPLETE - Deployed to Production**
**Objective:** Replace the buggy MapGeo-based `/find-a-trainer` page with a new `/find-training` page built from scratch using Google Maps JavaScript API.
### Why This Change
The existing IGM/amCharts implementation had a fundamental bug that corrupted marker coordinates (longitude gets overwritten with latitude). After multiple fix attempts, building fresh with Google Maps API provides:
- Full control over marker data
- No third-party plugin dependencies
- Better long-term maintainability
- Ability to show both trainers AND venues
### Files Created (8 new files)
| File | Description |
|------|-------------|
| `includes/find-training/class-hvac-find-training-page.php` | Main page handler (singleton), AJAX endpoints, asset enqueuing |
| `includes/find-training/class-hvac-training-map-data.php` | Data provider for trainer/venue markers with caching |
| `includes/find-training/class-hvac-venue-geocoding.php` | Auto-geocoding for TEC venues via Google API |
| `templates/page-find-training.php` | Page template with map, filters, modals |
| `assets/js/find-training-map.js` | Google Maps initialization, markers, clustering |
| `assets/js/find-training-filters.js` | Filter handling, geolocation, AJAX |
| `assets/css/find-training-map.css` | Complete responsive styling |
| `assets/images/marker-trainer.svg` | Teal person icon for trainers |
| `assets/images/marker-venue.svg` | Orange building icon for venues |
### Files Modified (3 files)
| File | Change |
|------|--------|
| `includes/class-hvac-page-manager.php` | Added find-training page definition |
| `includes/class-hvac-plugin.php` | Load new find-training classes |
| `includes/class-hvac-ajax-handlers.php` | Added contact form AJAX handler |
### Multi-Model Code Review Findings & Fixes
Ran comprehensive code review using GPT-5, Gemini 3, and Zen MCP tools. Found and fixed 6 issues:
| # | Severity | Issue | Fix |
|---|----------|-------|-----|
| 1 | **CRITICAL** | Missing `hvac_submit_contact_form` AJAX handler | Added full handler with rate limiting, validation, email |
| 2 | **HIGH** | XSS risk in InfoWindow onclick handlers | Replaced with DOM creation + addEventListener |
| 3 | **MEDIUM** | Uncached filter dropdown SQL queries | Added wp_cache with 1-hour TTL |
| 4 | **MEDIUM** | AJAX race condition on rapid filters | Added request abort handling |
| 5 | **LOW** | Hardcoded `/trainer/registration/` URL | Changed to `site_url()` |
| 6 | **LOW** | `alert()` for geolocation errors | Added inline dismissible notification |
### Features Implemented
- ✅ Google Maps with custom trainer/venue markers
- ✅ MarkerClusterer for dense areas
- ✅ Filter by State, Certification, Training Format
- ✅ Search by name/location
- ✅ "Near Me" geolocation button
- ✅ Trainer/Venue toggle switches
- ✅ Trainer profile modal with contact form
- ✅ Venue info modal with upcoming events
- ✅ Trainer directory grid below map
- ✅ 301 redirect from `/find-a-trainer` to `/find-training`
- ✅ Auto-geocoding for new venues
- ✅ Rate-limited batch geocoding for existing venues
### Deployment Status
- ✅ Deployed to staging
- ✅ Map loads with markers and clustering
- ✅ Filters working (state, certification, format)
- ✅ Contact form functional
- ✅ Deployed to production
---
## 📋 PREVIOUS SESSION - E2E TESTING & BUG FIXES (Feb 1, 2026)
### Status: ✅ **COMPLETE - Deployed to Staging, Ready for Production**
**Objective:** Deploy security fixes to staging, run E2E tests, fix discovered bugs, and validate all functionality.
### Deployment & Testing Summary
1. ✅ **Deployed to Staging** - All 12 security fixes from previous session
2. ✅ **E2E Tests Passed** - Master trainer pages, security endpoints verified
3. ✅ **Discovered & Fixed 2 Critical Bugs** - Trainers table, event pages
4. ✅ **Created E2E Testing Skill** - `.claude/commands/e2e-visual-test.md`
5. ✅ **Added Staging Email Filter** - Prevents accidental user spam
### Bugs Found & Fixed
| Bug | Severity | Root Cause | Fix |
|-----|----------|------------|-----|
| **Trainers table empty** | HIGH | `ajax_filter_trainers()` used complex SQL that returned empty; `count_trainers_by_status()` only queried `hvac_trainer` role | Rewrote to use `get_trainers_table_data()` (same as working dashboard); fixed role query to include both roles |
| **Event pages blank** | HIGH | Template path mismatch: code referenced `page-trainer-event-manage.php` but file is `page-manage-event.php` | Fixed path in `class-hvac-event-manager.php:138` |
### Files Modified (4 files)
1. **`includes/class-hvac-master-trainers-overview.php`**
- Rewrote `ajax_filter_trainers()` to use reliable `get_trainers_table_data()`
- Fixed `count_trainers_by_status()` to include `hvac_master_trainer` role
- Now shows 53 trainers, 5 active (was showing 0)
2. **`includes/class-hvac-event-manager.php`**
- Fixed template path: `page-trainer-event-manage.php``page-manage-event.php`
- Event creation form now fully functional
3. **`hvac-community-events.php`**
- Added staging email filter (only `ben@tealmaker.com` receives emails)
- Protects real users from test emails during development
4. **`.claude/commands/e2e-visual-test.md`** (new)
- Created E2E visual testing skill for Playwright MCP browser tools
- Documents login procedure, test sequence, credentials
### E2E Test Results
| Feature | Status | Notes |
|---------|--------|-------|
| Master Trainer Login | ✅ PASS | Custom `/training-login/` works |
| Master Dashboard | ✅ PASS | Stats, tables, AJAX functional |
| Trainers Table | ✅ PASS | 53 trainers displayed correctly |
| Announcements | ✅ PASS | Modal opens, form accessible |
| Event Creation | ✅ PASS | Full form with all TEC fields |
| Certificate Reports | ✅ PASS | Empty state (needs events) |
| Security Endpoints | ✅ PASS | 4/4 properly return 401/400 |
### Staging Email Protection
Emails on staging are now filtered:
- **Allowed:** `ben@tealmaker.com`, `ben@measurequick.com`
- **Blocked:** All other recipients (logged for debugging)
- **Subject Prefix:** `[STAGING]` added to allowed emails
### Next Steps
1. ⏳ Deploy to production: `./scripts/deploy.sh production`
2. ⏳ Verify production functionality
3. ⏳ Monitor for any issues
---
## 📋 PREVIOUS SESSION - MULTI-MODEL SECURITY CODE REVIEW (Jan 31, 2026)
### Status: ✅ **COMPLETE - Deployed to Staging**
**Objective:** Comprehensive security and business logic code review using 4 AI models (GPT-5, Gemini 3, Kimi K2.5, Zen MCP) across 11 critical files (~9,000 lines).
### Critical Issues Found & Fixed (12 total)
| ID | Severity | Issue | File |
|----|----------|-------|------|
| C1 | **CRITICAL** | Passwords stored in transients | `class-hvac-registration.php` |
| U1 | **CRITICAL** | O(3600) token verification loop (DoS) | `class-hvac-ajax-security.php` |
| U2 | **HIGH** | `remove_all_actions()` breaks WP isolation | `class-hvac-plugin.php` |
| C2 | **HIGH** | Encryption key in same database as data | `class-hvac-secure-storage.php` |
| M3 | **HIGH** | Revoked certificates still downloadable | `class-certificate-manager.php` |
| U3 | **HIGH** | Security headers not applied to AJAX | `class-hvac-ajax-security.php` |
| C3 | **MEDIUM** | IP spoofing undermines rate limiting | `class-hvac-security.php` |
| M1 | **MEDIUM** | Weak CSP with `unsafe-eval` | `class-hvac-ajax-security.php` |
| C5 | **MEDIUM** | Duplicate component initialization | `class-hvac-plugin.php` |
| U9 | **MEDIUM** | File-scope side-effect initialization | `class-hvac-trainer-profile-manager.php` |
| U11 | **LOW** | Timezone inconsistency in cert numbers | `class-certificate-manager.php` |
| U4 | **HIGH** | zoho-config.php not in .gitignore | `.gitignore` |
### Deliverables
- ✅ **Full Report:** `MULTI-MODEL-CODE-REVIEW-REPORT.md`
- ✅ **12 Security Fixes:** All implemented and deployed to staging
---
## 📋 PREVIOUS SESSION - MASTER TRAINER PROFILE EDIT ENHANCEMENT (Jan 9, 2026)
## 🎯 CURRENT SESSION - MASTER TRAINER PROFILE EDIT ENHANCEMENT (Jan 9, 2026)
### Status: ✅ **COMPLETE - Deployed to Production**
@ -1047,7 +436,7 @@ DISPLAY=:1 HEADLESS=false node test-comprehensive-validation.js
### Production Environment
**URL:** https://upskillhvac.com
**Version:** 2.2.11 (latest)
**Version:** 2.1.8 (latest)
**Server:** Cloudways Shared VPS
---

File diff suppressed because it is too large Load diff

Binary file not shown.

Before

Width:  |  Height:  |  Size: 339 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 360 B

View file

@ -1,604 +0,0 @@
/**
* Find Training Filters
*
* Handles filtering, searching, and geolocation functionality
* for the Find Training page.
*
* @package HVAC_Community_Events
* @since 2.2.0
*/
(function($) {
'use strict';
// Namespace for filters module
window.HVACTrainingFilters = {
// Debounce timer for search
searchTimer: null,
// Current AJAX request (for aborting)
currentRequest: null,
// Active filters
activeFilters: {
state: '',
certification: '',
training_format: '',
search: '',
include_past: false
},
// User location (if obtained)
userLocation: null,
/**
* Initialize filters
*/
init: function() {
this.bindEvents();
this.initMobileFilterToggle();
},
/**
* Bind event handlers
*/
bindEvents: function() {
const self = this;
// Search input with debounce - client-side filtering for instant results
$('#hvac-training-search').on('input', function() {
clearTimeout(self.searchTimer);
const value = $(this).val().toLowerCase().trim();
self.searchTimer = setTimeout(function() {
self.activeFilters.search = value;
// For empty search or server-side filters, use AJAX
if (!value || self.hasActiveServerFilters()) {
self.applyFilters();
} else {
// Client-side filtering for instant results
self.filterActiveTabList(value);
}
}, 150); // Faster for client-side
});
// State filter
$('#hvac-filter-state').on('change', function() {
self.activeFilters.state = $(this).val();
self.applyFilters();
self.updateActiveFiltersDisplay();
});
// Certification filter
$('#hvac-filter-certification').on('change', function() {
self.activeFilters.certification = $(this).val();
self.applyFilters();
self.updateActiveFiltersDisplay();
});
// Training format filter
$('#hvac-filter-format').on('change', function() {
self.activeFilters.training_format = $(this).val();
self.applyFilters();
self.updateActiveFiltersDisplay();
});
// Include past events checkbox
$('#hvac-include-past').on('change', function() {
self.activeFilters.include_past = $(this).is(':checked');
self.applyFilters();
self.updateActiveFiltersDisplay();
});
// Mobile include past events checkbox
$('#hvac-include-past-mobile').on('change', function() {
const checked = $(this).is(':checked');
$('#hvac-include-past').prop('checked', checked);
self.activeFilters.include_past = checked;
self.applyFilters();
self.updateActiveFiltersDisplay();
});
// Near Me button
$('#hvac-near-me-btn').on('click', function() {
self.handleNearMeClick($(this));
});
// Clear all filters
$('.hvac-clear-filters').on('click', function() {
self.clearAllFilters();
});
// Remove individual filter
$(document).on('click', '.hvac-active-filter button', function() {
const filterType = $(this).parent().data('filter');
self.removeFilter(filterType);
});
},
/**
* Apply current filters
*/
applyFilters: function() {
const self = this;
// Abort any pending request to prevent race conditions
if (this.currentRequest && this.currentRequest.readyState !== 4) {
this.currentRequest.abort();
}
// Build filter data
const filterData = {
action: 'hvac_filter_training_map',
nonce: hvacFindTraining.nonce,
state: this.activeFilters.state,
certification: this.activeFilters.certification,
training_format: this.activeFilters.training_format,
search: this.activeFilters.search,
show_trainers: $('#hvac-show-trainers').is(':checked'),
show_venues: $('#hvac-show-venues').is(':checked'),
show_events: $('#hvac-show-events').is(':checked'),
include_past: this.activeFilters.include_past
};
// Add user location if available
if (this.userLocation) {
filterData.lat = this.userLocation.lat;
filterData.lng = this.userLocation.lng;
filterData.radius = 100; // km
}
// Send filter request and store reference
this.currentRequest = $.ajax({
url: hvacFindTraining.ajax_url,
type: 'POST',
data: filterData,
success: function(response) {
if (response.success) {
// Update map data
HVACTrainingMap.trainers = response.data.trainers || [];
HVACTrainingMap.venues = response.data.venues || [];
HVACTrainingMap.events = response.data.events || [];
// Reset visible arrays to all items
HVACTrainingMap.visibleTrainers = HVACTrainingMap.trainers.slice();
HVACTrainingMap.visibleVenues = HVACTrainingMap.venues.slice();
HVACTrainingMap.visibleEvents = HVACTrainingMap.events.slice();
// Reset displayed counts
HVACTrainingMap.displayedCounts = { trainers: 0, venues: 0, events: 0 };
// Update map markers
HVACTrainingMap.updateMarkers();
// Update all counts
HVACTrainingMap.updateAllCounts();
// Render the active tab
HVACTrainingMap.renderActiveTabList();
// Show notification if "Near Me" filter returned no results
if (self.userLocation) {
const totalResults = HVACTrainingMap.trainers.length +
HVACTrainingMap.venues.length +
HVACTrainingMap.events.length;
if (totalResults === 0) {
self.showLocationError('No trainers, venues, or events found within 100km of your location. Try removing the "Near Me" filter to see all results.');
}
}
// Note: syncSidebarWithViewport will be called by map 'idle' event
}
},
complete: function() {
self.currentRequest = null;
}
});
// Show/hide clear button
this.updateClearButtonVisibility();
},
/**
* Handle Near Me button click
*/
handleNearMeClick: function($button) {
const self = this;
// Show loading state
$button.prop('disabled', true);
$button.html('<span class="dashicons dashicons-update-alt hvac-spin"></span><span class="hvac-btn-text">Locating...</span>');
// Clear any previous error message
this.clearLocationError();
// Get user location
HVACTrainingMap.getUserLocation(function(location, error) {
if (location) {
self.userLocation = location;
// Center map on user location
HVACTrainingMap.centerOnLocation(location.lat, location.lng, 9);
// Apply filters with location
self.applyFilters();
// Update button state
$button.html('<span class="dashicons dashicons-yes-alt"></span><span class="hvac-btn-text">Near Me</span>');
$button.addClass('active');
// Add to active filters display
self.addActiveFilter('location', 'Near Me');
} else {
// Show inline error instead of alert
self.showLocationError(error || 'Unable to get your location. Please check browser permissions.');
// Reset button
$button.html('<span class="dashicons dashicons-location-alt"></span><span class="hvac-btn-text">Near Me</span>');
$button.prop('disabled', false);
}
});
},
/**
* Show location error message inline
*/
showLocationError: function(message) {
// Remove any existing error
this.clearLocationError();
// Create error element
const $error = $('<div class="hvac-location-error">' +
'<span class="dashicons dashicons-warning"></span> ' +
this.escapeHtml(message) +
'<button type="button" class="hvac-dismiss-error" aria-label="Dismiss">&times;</button>' +
'</div>');
// Insert after Near Me button
$('#hvac-near-me-btn').after($error);
// Auto-dismiss after 5 seconds
setTimeout(function() {
$error.fadeOut(300, function() { $(this).remove(); });
}, 5000);
// Click to dismiss
$error.find('.hvac-dismiss-error').on('click', function() {
$error.remove();
});
},
/**
* Clear location error message
*/
clearLocationError: function() {
$('.hvac-location-error').remove();
},
/**
* Clear all filters
*/
clearAllFilters: function() {
// Reset filter values
this.activeFilters = {
state: '',
certification: '',
training_format: '',
search: '',
include_past: false
};
// Reset user location
this.userLocation = null;
// Reset form elements
$('#hvac-filter-state').val('');
$('#hvac-filter-certification').val('');
$('#hvac-filter-format').val('');
$('#hvac-training-search').val('');
$('#hvac-include-past').prop('checked', false);
$('#hvac-include-past-mobile').prop('checked', false);
// Reset Near Me button
$('#hvac-near-me-btn')
.removeClass('active')
.html('<span class="dashicons dashicons-location-alt"></span><span class="hvac-btn-text">Near Me</span>')
.prop('disabled', false);
// Clear active filters display
$('.hvac-active-filters').empty();
// Hide clear button
$('.hvac-clear-filters').hide();
// Reset map to default view
HVACTrainingMap.map.setCenter(HVACTrainingMap.config.defaultCenter);
HVACTrainingMap.map.setZoom(HVACTrainingMap.config.defaultZoom);
// Reload data without filters
HVACTrainingMap.loadMapData();
},
/**
* Remove a specific filter
*/
removeFilter: function(filterType) {
switch (filterType) {
case 'state':
this.activeFilters.state = '';
$('#hvac-filter-state').val('');
break;
case 'certification':
this.activeFilters.certification = '';
$('#hvac-filter-certification').val('');
break;
case 'training_format':
this.activeFilters.training_format = '';
$('#hvac-filter-format').val('');
break;
case 'search':
this.activeFilters.search = '';
$('#hvac-training-search').val('');
break;
case 'location':
this.userLocation = null;
$('#hvac-near-me-btn')
.removeClass('active')
.html('<span class="dashicons dashicons-location-alt"></span><span class="hvac-btn-text">Near Me</span>')
.prop('disabled', false);
break;
case 'include_past':
this.activeFilters.include_past = false;
$('#hvac-include-past').prop('checked', false);
$('#hvac-include-past-mobile').prop('checked', false);
break;
}
this.applyFilters();
this.updateActiveFiltersDisplay();
},
/**
* Update active filters display
*/
updateActiveFiltersDisplay: function() {
const $container = $('.hvac-active-filters');
$container.empty();
// State filter
if (this.activeFilters.state) {
this.addActiveFilter('state', `State: ${this.activeFilters.state}`);
}
// Certification filter
if (this.activeFilters.certification) {
this.addActiveFilter('certification', this.activeFilters.certification);
}
// Training format filter
if (this.activeFilters.training_format) {
this.addActiveFilter('training_format', this.activeFilters.training_format);
}
// Search filter
if (this.activeFilters.search) {
this.addActiveFilter('search', `"${this.activeFilters.search}"`);
}
// Location filter
if (this.userLocation) {
this.addActiveFilter('location', 'Near Me');
}
// Include past events filter
if (this.activeFilters.include_past) {
this.addActiveFilter('include_past', 'Including Past Events');
}
this.updateClearButtonVisibility();
},
/**
* Add an active filter chip
*/
addActiveFilter: function(type, label) {
const $container = $('.hvac-active-filters');
const $chip = $(`
<span class="hvac-active-filter" data-filter="${type}">
${this.escapeHtml(label)}
<button type="button" aria-label="Remove filter">&times;</button>
</span>
`);
$container.append($chip);
},
/**
* Update clear button visibility
*/
updateClearButtonVisibility: function() {
const hasFilters = this.activeFilters.state ||
this.activeFilters.certification ||
this.activeFilters.training_format ||
this.activeFilters.search ||
this.activeFilters.include_past ||
this.userLocation;
if (hasFilters) {
$('.hvac-clear-filters').show();
} else {
$('.hvac-clear-filters').hide();
}
},
/**
* Check if any server-side filters are active
*/
hasActiveServerFilters: function() {
return this.activeFilters.state ||
this.activeFilters.certification ||
this.activeFilters.training_format ||
this.activeFilters.include_past ||
this.userLocation;
},
/**
* Filter the active tab's list client-side for instant results
*/
filterActiveTabList: function(searchTerm) {
const activeTab = HVACTrainingMap.activeTab;
let items, filterFn;
switch (activeTab) {
case 'trainers':
items = HVACTrainingMap.visibleTrainers.length > 0
? HVACTrainingMap.trainers
: HVACTrainingMap.trainers;
filterFn = (trainer) => {
const searchFields = [
trainer.name,
trainer.city,
trainer.state,
trainer.company,
...(trainer.certifications || [])
].filter(Boolean).join(' ').toLowerCase();
return searchFields.includes(searchTerm);
};
break;
case 'venues':
items = HVACTrainingMap.venues;
filterFn = (venue) => {
const searchFields = [
venue.name,
venue.city,
venue.state,
venue.address
].filter(Boolean).join(' ').toLowerCase();
return searchFields.includes(searchTerm);
};
break;
case 'events':
items = HVACTrainingMap.events;
filterFn = (event) => {
const searchFields = [
event.title,
event.venue_name,
event.venue_city,
event.venue_state
].filter(Boolean).join(' ').toLowerCase();
return searchFields.includes(searchTerm);
};
break;
default:
return;
}
// Apply filter
const filteredItems = searchTerm ? items.filter(filterFn) : items;
// Update the visible items for the active tab
switch (activeTab) {
case 'trainers':
HVACTrainingMap.visibleTrainers = filteredItems;
HVACTrainingMap.displayedCounts.trainers = 0;
HVACTrainingMap.updateTrainerGrid();
break;
case 'venues':
HVACTrainingMap.visibleVenues = filteredItems;
HVACTrainingMap.displayedCounts.venues = 0;
HVACTrainingMap.updateVenueGrid();
break;
case 'events':
HVACTrainingMap.visibleEvents = filteredItems;
HVACTrainingMap.displayedCounts.events = 0;
HVACTrainingMap.updateEventGrid();
break;
}
// Update all counts
HVACTrainingMap.updateAllCounts();
},
/**
* Escape HTML for safe output
*/
escapeHtml: function(text) {
if (!text) return '';
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
},
/**
* Initialize mobile filter toggle
*/
initMobileFilterToggle: function() {
const self = this;
// Mobile filter panel toggle
$(document).on('click', '.hvac-mobile-filter-toggle', function() {
const $toggle = $(this);
const $panel = $('#hvac-mobile-filter-panel');
const isExpanded = $toggle.attr('aria-expanded') === 'true';
if (isExpanded) {
$panel.attr('hidden', '');
$toggle.attr('aria-expanded', 'false');
} else {
$panel.removeAttr('hidden');
$toggle.attr('aria-expanded', 'true');
}
});
// Sync mobile filter selects with desktop selects
$('#hvac-filter-state-mobile').on('change', function() {
const value = $(this).val();
$('#hvac-filter-state').val(value);
self.activeFilters.state = value;
self.applyFilters();
self.updateActiveFiltersDisplay();
});
$('#hvac-filter-certification-mobile').on('change', function() {
const value = $(this).val();
$('#hvac-filter-certification').val(value);
self.activeFilters.certification = value;
self.applyFilters();
self.updateActiveFiltersDisplay();
});
$('#hvac-filter-format-mobile').on('change', function() {
const value = $(this).val();
$('#hvac-filter-format').val(value);
self.activeFilters.training_format = value;
self.applyFilters();
self.updateActiveFiltersDisplay();
});
// Also sync desktop to mobile when desktop changes
$('#hvac-filter-state').on('change', function() {
$('#hvac-filter-state-mobile').val($(this).val());
});
$('#hvac-filter-certification').on('change', function() {
$('#hvac-filter-certification-mobile').val($(this).val());
});
$('#hvac-filter-format').on('change', function() {
$('#hvac-filter-format-mobile').val($(this).val());
});
}
};
// Initialize when document is ready
$(document).ready(function() {
if ($('#hvac-training-map').length) {
HVACTrainingFilters.init();
}
});
})(jQuery);

File diff suppressed because it is too large Load diff

View file

@ -220,13 +220,6 @@ jQuery(document).ready(function ($) {
successHtml += '<p>Refresh Token: ❌ Missing (OAuth required)</p>';
}
// Show staging warning if present
if (response.data.staging_warning) {
successHtml += '<div style="margin-top: 10px; padding: 8px 12px; background: #fff8e5; border-left: 3px solid #ffb900;">';
successHtml += '<p style="margin: 0; color: #826200;"><strong>Warning:</strong> ' + response.data.staging_warning + '</p>';
successHtml += '</div>';
}
// Show debug info if available
if (response.data.debug) {
successHtml += '<details style="margin-top: 10px;">';
@ -629,47 +622,6 @@ jQuery(document).ready(function ($) {
});
});
// =====================================================
// Reset Sync Hashes Handler
// =====================================================
$('#reset-sync-hashes').on('click', function () {
if (!confirm('This will clear all sync hashes and force every record to re-sync on the next run. Continue?')) {
return;
}
var $button = $(this);
var $status = $('#reset-hashes-status');
$button.prop('disabled', true).text('Resetting...');
$status.text('');
$.ajax({
url: hvacZoho.ajaxUrl,
method: 'POST',
data: {
action: 'hvac_zoho_reset_sync_hashes',
nonce: hvacZoho.nonce
},
success: function (response) {
if (response.success) {
$status.html(
'<span style="color: #46b450;">' +
response.data.posts_cleared + ' post hashes and ' +
response.data.users_cleared + ' user hashes cleared.</span>'
);
} else {
$status.html('<span style="color: #dc3232;">Error: ' + response.data.message + '</span>');
}
},
error: function () {
$status.html('<span style="color: #dc3232;">Network error - please try again</span>');
},
complete: function () {
$button.prop('disabled', false).text('Force Full Re-sync (Reset Hashes)');
}
});
});
// =====================================================
// Diagnostic Test Handler
// =====================================================

View file

@ -47,8 +47,6 @@ class HVAC_Zoho_Admin {
add_action('wp_ajax_hvac_zoho_run_scheduled_sync', array($this, 'run_scheduled_sync_now'));
// Add simple test handler
add_action('wp_ajax_hvac_zoho_simple_test', array($this, 'simple_test'));
// Add hash reset handler
add_action('wp_ajax_hvac_zoho_reset_sync_hashes', array($this, 'reset_sync_hashes'));
// Add OAuth callback handler - only use one method to prevent duplicates
add_action('init', array($this, 'add_oauth_rewrite_rule'), 5);
add_filter('query_vars', array($this, 'add_oauth_query_vars'), 10, 1);
@ -299,13 +297,6 @@ class HVAC_Zoho_Admin {
<div class="hvac-zoho-sync">
<h2>Data Sync</h2>
<div class="sync-maintenance" style="margin-bottom: 20px; padding: 12px 15px; background: #fff8e5; border-left: 4px solid #ffb900;">
<p style="margin: 0 0 8px 0;"><strong>Sync Maintenance</strong></p>
<p style="margin: 0 0 8px 0; font-size: 13px;">If records aren't syncing (e.g. after a failed sync or configuration change), reset sync hashes to force all records to re-sync on the next run.</p>
<button class="button" id="reset-sync-hashes">Force Full Re-sync (Reset Hashes)</button>
<span id="reset-hashes-status" style="margin-left: 10px;"></span>
</div>
<div class="sync-section">
<h3>Events Campaigns</h3>
<p>Sync events from The Events Calendar to Zoho CRM Campaigns</p>
@ -906,28 +897,20 @@ class HVAC_Zoho_Admin {
}
// Success!
$mode_info = HVAC_Zoho_CRM_Auth::get_debug_mode_info();
$response_data = array(
// Success!
wp_send_json_success(array(
'message' => 'Connection successful!',
'modules' => isset($response['modules']) ? count($response['modules']) . ' modules available' : 'API connected',
'client_id' => substr($client_id, 0, 10) . '...',
'client_secret_exists' => true,
'refresh_token_exists' => true,
'is_staging' => $mode_info['is_staging'],
'mode_info' => $mode_info,
'credentials_status' => array(
'client_id' => substr($client_id, 0, 10) . '...',
'client_secret_exists' => true,
'refresh_token_exists' => true,
'api_working' => true
)
);
if ($mode_info['is_staging']) {
$response_data['staging_warning'] = 'WARNING: Staging mode is active. All write operations (sync) are blocked. Hostname: ' . ($mode_info['parsed_host'] ?? 'unknown');
}
wp_send_json_success($response_data);
));
} catch (Exception $e) {
$error_response = array(
'message' => 'Connection test failed due to exception',
@ -1089,42 +1072,5 @@ class HVAC_Zoho_Admin {
}
}
/**
* Reset all Zoho sync hashes to force a full re-sync
*/
public function reset_sync_hashes() {
check_ajax_referer('hvac_zoho_nonce', 'nonce');
if (!current_user_can('manage_options')) {
wp_send_json_error(array('message' => 'Unauthorized access'));
return;
}
global $wpdb;
// Delete all _zoho_sync_hash post meta
$posts_cleared = $wpdb->query(
"DELETE FROM {$wpdb->postmeta} WHERE meta_key = '_zoho_sync_hash'"
);
// Delete all _zoho_sync_hash user meta
$users_cleared = $wpdb->query(
"DELETE FROM {$wpdb->usermeta} WHERE meta_key = '_zoho_sync_hash'"
);
// Also clear last sync time so scheduled sync does a full run
delete_option('hvac_zoho_last_sync_time');
if (class_exists('HVAC_Logger')) {
HVAC_Logger::info("Sync hashes reset: {$posts_cleared} post hashes, {$users_cleared} user hashes cleared", 'ZohoAdmin');
}
wp_send_json_success(array(
'message' => 'Sync hashes reset successfully',
'posts_cleared' => (int) $posts_cleared,
'users_cleared' => (int) $users_cleared,
));
}
}
?>

View file

@ -67,10 +67,7 @@ class HVAC_Certificate_Manager {
update_option('hvac_certificate_counter', $counter);
// Format: PREFIX-YEAR-SEQUENTIAL (e.g., HVAC-2023-00001)
// FIX (U11): Use WordPress current_time() for consistent timezone handling
// Previous code used PHP date('Y') which uses server timezone, while
// date_generated uses WordPress timezone, causing year boundary inconsistencies
$year = date('Y', current_time('timestamp'));
$year = date('Y');
$formatted_counter = str_pad($counter, 5, '0', STR_PAD_LEFT);
return $prefix . $year . '-' . $formatted_counter;
@ -839,25 +836,11 @@ class HVAC_Certificate_Manager {
/**
* Get certificate file URL.
*
* SECURITY FIX (M3): Check if certificate is revoked before generating URL.
* Revoked certificates should not be downloadable.
*
* @param int $certificate_id The certificate ID.
*
* @return string|false The file URL if found, false if not found or revoked.
* @return string|false The file URL if found, false otherwise.
*/
public function get_certificate_url($certificate_id) {
// Verify certificate exists and is not revoked
$certificate = $this->get_certificate($certificate_id);
if (!$certificate) {
return false;
}
// Check if certificate has been revoked
if (!empty($certificate->revoked)) {
return false;
}
// Create a secure URL with nonce for downloading
$url = add_query_arg(
array(

View file

@ -65,14 +65,6 @@ class HVAC_Ajax_Handlers {
// Password reset endpoint for master trainers
add_action('wp_ajax_hvac_send_password_reset', array($this, 'send_password_reset'));
add_action('wp_ajax_nopriv_hvac_send_password_reset', array($this, 'unauthorized_access'));
// Contact trainer form (Find Training page)
add_action('wp_ajax_hvac_submit_contact_form', array($this, 'submit_trainer_contact_form'));
add_action('wp_ajax_nopriv_hvac_submit_contact_form', array($this, 'submit_trainer_contact_form'));
// Contact venue form (Find Training page - Approved Labs)
add_action('wp_ajax_hvac_submit_venue_contact', array($this, 'submit_venue_contact_form'));
add_action('wp_ajax_nopriv_hvac_submit_venue_contact', array($this, 'submit_venue_contact_form'));
}
/**
@ -1032,343 +1024,6 @@ class HVAC_Ajax_Handlers {
wp_send_json_success('Password reset email sent to ' . $user->user_email);
}
/**
* Handle trainer contact form submission from Find Training page
*
* Sends an email to the trainer with the visitor's inquiry.
* Available to both logged-in and anonymous users.
*/
public function submit_trainer_contact_form() {
// Verify nonce
if (!isset($_POST['nonce']) || !wp_verify_nonce($_POST['nonce'], 'hvac_find_training')) {
wp_send_json_error(['message' => 'Invalid security token'], 403);
return;
}
// Rate limiting - max 5 submissions per IP per hour
$ip = $this->get_client_ip();
$rate_key = 'hvac_contact_rate_' . md5($ip);
$submissions = get_transient($rate_key) ?: 0;
if ($submissions >= 5) {
wp_send_json_error(['message' => 'Too many submissions. Please try again later.'], 429);
return;
}
// Validate required fields
$required_fields = ['first_name', 'last_name', 'email', 'trainer_id'];
foreach ($required_fields as $field) {
if (empty($_POST[$field])) {
wp_send_json_error(['message' => "Missing required field: {$field}"], 400);
return;
}
}
// Sanitize inputs
$first_name = sanitize_text_field($_POST['first_name']);
$last_name = sanitize_text_field($_POST['last_name']);
$email = sanitize_email($_POST['email']);
$phone = sanitize_text_field($_POST['phone'] ?? '');
$city = sanitize_text_field($_POST['city'] ?? '');
$state = sanitize_text_field($_POST['state_province'] ?? '');
$company = sanitize_text_field($_POST['company'] ?? '');
$message = sanitize_textarea_field($_POST['message'] ?? '');
$trainer_id = absint($_POST['trainer_id']);
$profile_id = absint($_POST['trainer_profile_id'] ?? 0);
// Validate email
if (!is_email($email)) {
wp_send_json_error(['message' => 'Invalid email address'], 400);
return;
}
// Get trainer data
$trainer = get_userdata($trainer_id);
if (!$trainer) {
wp_send_json_error(['message' => 'Trainer not found'], 404);
return;
}
// Get trainer's display name from profile if available
$trainer_name = $trainer->display_name;
if ($profile_id) {
$profile_name = get_post_meta($profile_id, 'trainer_display_name', true);
if ($profile_name) {
$trainer_name = $profile_name;
}
}
// Build email content
$subject = sprintf(
'[Upskill HVAC] Training Inquiry from %s %s',
$first_name,
$last_name
);
$body = sprintf(
"Hello %s,\n\n" .
"You have received a training inquiry through the Upskill HVAC directory.\n\n" .
"--- Contact Details ---\n" .
"Name: %s %s\n" .
"Email: %s\n" .
"%s" . // Phone (optional)
"%s" . // Location (optional)
"%s" . // Company (optional)
"\n--- Message ---\n%s\n\n" .
"---\n" .
"This message was sent via the Find Training page at %s\n" .
"Please respond directly to the sender's email address.\n",
$trainer_name,
$first_name,
$last_name,
$email,
$phone ? "Phone: {$phone}\n" : '',
($city || $state) ? "Location: " . trim("{$city}, {$state}", ', ') . "\n" : '',
$company ? "Company: {$company}\n" : '',
$message ?: '(No message provided)',
home_url('/find-training/')
);
// Email headers
$headers = [
'Content-Type: text/plain; charset=UTF-8',
sprintf('Reply-To: %s %s <%s>', $first_name, $last_name, $email),
sprintf('From: Upskill HVAC <noreply@%s>', parse_url(home_url(), PHP_URL_HOST))
];
// Send email to trainer
$sent = wp_mail($trainer->user_email, $subject, $body, $headers);
if (!$sent) {
// Log failure
if (class_exists('HVAC_Logger')) {
HVAC_Logger::error('Failed to send trainer contact email', 'AJAX', [
'trainer_id' => $trainer_id,
'sender_email' => $email
]);
}
wp_send_json_error(['message' => 'Failed to send message. Please try again.'], 500);
return;
}
// Update rate limit
set_transient($rate_key, $submissions + 1, HOUR_IN_SECONDS);
// Log success
if (class_exists('HVAC_Logger')) {
HVAC_Logger::info('Trainer contact form submitted', 'AJAX', [
'trainer_id' => $trainer_id,
'sender_email' => $email,
'has_message' => !empty($message)
]);
}
// Store lead if training leads system exists
if (class_exists('HVAC_Training_Leads')) {
$leads = HVAC_Training_Leads::instance();
if (method_exists($leads, 'create_lead')) {
$leads->create_lead([
'first_name' => $first_name,
'last_name' => $last_name,
'email' => $email,
'phone' => $phone,
'city' => $city,
'state' => $state,
'company' => $company,
'message' => $message,
'trainer_id' => $trainer_id,
'source' => 'find_training_page'
]);
}
}
wp_send_json_success([
'message' => 'Your message has been sent to the trainer.',
'trainer_name' => $trainer_name
]);
}
/**
* Handle venue contact form submission from Find Training page
*
* Sends an email to the venue POC (Point of Contact) with the visitor's inquiry.
* Available to both logged-in and anonymous users.
*/
public function submit_venue_contact_form() {
// Verify nonce
if (!isset($_POST['nonce']) || !wp_verify_nonce($_POST['nonce'], 'hvac_find_training')) {
wp_send_json_error(['message' => 'Invalid security token'], 403);
return;
}
// Rate limiting - max 5 submissions per IP per hour
$ip = $this->get_client_ip();
$rate_key = 'hvac_venue_contact_rate_' . md5($ip);
$submissions = get_transient($rate_key) ?: 0;
if ($submissions >= 5) {
wp_send_json_error(['message' => 'Too many submissions. Please try again later.'], 429);
return;
}
// Validate required fields
$required_fields = ['first_name', 'last_name', 'email', 'venue_id'];
foreach ($required_fields as $field) {
if (empty($_POST[$field])) {
wp_send_json_error(['message' => "Missing required field: {$field}"], 400);
return;
}
}
// Sanitize inputs
$first_name = sanitize_text_field($_POST['first_name']);
$last_name = sanitize_text_field($_POST['last_name']);
$email = sanitize_email($_POST['email']);
$phone = sanitize_text_field($_POST['phone'] ?? '');
$company = sanitize_text_field($_POST['company'] ?? '');
$message = sanitize_textarea_field($_POST['message'] ?? '');
$venue_id = absint($_POST['venue_id']);
// Validate email
if (!is_email($email)) {
wp_send_json_error(['message' => 'Invalid email address'], 400);
return;
}
// Get venue data
$venue = get_post($venue_id);
if (!$venue || $venue->post_type !== 'tribe_venue') {
wp_send_json_error(['message' => 'Venue not found'], 404);
return;
}
$venue_name = $venue->post_title;
// Get POC information from venue meta
$poc_user_id = get_post_meta($venue_id, '_venue_poc_user_id', true);
$poc_email = get_post_meta($venue_id, '_venue_poc_email', true);
$poc_name = get_post_meta($venue_id, '_venue_poc_name', true);
// Fallback to post author if no POC meta
if (empty($poc_user_id)) {
$poc_user_id = $venue->post_author;
}
if (empty($poc_email)) {
$author = get_userdata($poc_user_id);
if ($author) {
$poc_email = $author->user_email;
$poc_name = $poc_name ?: $author->display_name;
}
}
if (empty($poc_email)) {
wp_send_json_error(['message' => 'Unable to find contact for this venue'], 500);
return;
}
// Build email content
$subject = sprintf(
'[Upskill HVAC] Training Lab Inquiry - %s',
$venue_name
);
$body = sprintf(
"Hello %s,\n\n" .
"You have received an inquiry about your training lab through the Upskill HVAC directory.\n\n" .
"--- Training Lab ---\n" .
"%s\n\n" .
"--- Contact Details ---\n" .
"Name: %s %s\n" .
"Email: %s\n" .
"%s" . // Phone (optional)
"%s" . // Company (optional)
"\n--- Message ---\n%s\n\n" .
"---\n" .
"This message was sent via the Find Training page at %s\n" .
"Please respond directly to the sender's email address.\n",
$poc_name ?: 'Training Lab Contact',
$venue_name,
$first_name,
$last_name,
$email,
$phone ? "Phone: {$phone}\n" : '',
$company ? "Company: {$company}\n" : '',
$message ?: '(No message provided)',
home_url('/find-training/')
);
// Email headers
$headers = [
'Content-Type: text/plain; charset=UTF-8',
sprintf('Reply-To: %s %s <%s>', $first_name, $last_name, $email),
sprintf('From: Upskill HVAC <noreply@%s>', parse_url(home_url(), PHP_URL_HOST))
];
// Send email to POC
$sent = wp_mail($poc_email, $subject, $body, $headers);
if (!$sent) {
// Log failure
if (class_exists('HVAC_Logger')) {
HVAC_Logger::error('Failed to send venue contact email', 'AJAX', [
'venue_id' => $venue_id,
'poc_email' => $poc_email,
'sender_email' => $email
]);
}
wp_send_json_error(['message' => 'Failed to send message. Please try again.'], 500);
return;
}
// Update rate limit
set_transient($rate_key, $submissions + 1, HOUR_IN_SECONDS);
// Log success
if (class_exists('HVAC_Logger')) {
HVAC_Logger::info('Venue contact form submitted', 'AJAX', [
'venue_id' => $venue_id,
'venue_name' => $venue_name,
'poc_email' => $poc_email,
'sender_email' => $email,
'has_message' => !empty($message)
]);
}
// Store lead if training leads system exists
if (class_exists('HVAC_Training_Leads')) {
$leads = HVAC_Training_Leads::instance();
if (method_exists($leads, 'create_lead')) {
$leads->create_lead([
'first_name' => $first_name,
'last_name' => $last_name,
'email' => $email,
'phone' => $phone,
'company' => $company,
'message' => $message,
'venue_id' => $venue_id,
'venue_name' => $venue_name,
'source' => 'find_training_venue_contact'
]);
}
}
wp_send_json_success([
'message' => 'Your message has been sent to the training lab.',
'venue_name' => $venue_name
]);
}
/**
* Get client IP address safely
*
* @return string IP address
*/
private function get_client_ip(): string {
// Use REMOTE_ADDR only to prevent IP spoofing
return sanitize_text_field($_SERVER['REMOTE_ADDR'] ?? '127.0.0.1');
}
}
// Initialize the handlers

View file

@ -81,17 +81,11 @@ class HVAC_Ajax_Security {
/**
* Send security headers
*
* SECURITY FIX (U3): Fixed condition to apply headers to AJAX requests.
* is_admin() returns true for admin-ajax.php, so previous condition never matched.
* Also removed 'unsafe-eval' from CSP (M1) - rarely needed and weakens security.
*/
public function send_security_headers() {
// Apply to AJAX requests (wp_doing_ajax covers admin-ajax.php)
// Skip if headers already sent
if (wp_doing_ajax() && !headers_sent()) {
// Content Security Policy - removed unsafe-eval (M1 fix)
header("Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline';");
if (!is_admin() && wp_doing_ajax()) {
// Content Security Policy
header("Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline';");
// Additional security headers
header('X-Content-Type-Options: nosniff');
@ -484,59 +478,38 @@ class HVAC_Ajax_Security {
*
* @param string $action Action identifier
* @param int $user_id User ID
* @return string Secure token with embedded timestamp (format: timestamp.signature)
* @return string Secure token
*/
public static function generate_secure_token($action, $user_id) {
$salt = wp_salt('auth');
$timestamp = time();
$data = $action . '|' . $user_id . '|' . $timestamp;
$signature = hash_hmac('sha256', $data, $salt);
// Return timestamp.signature format for O(1) verification
return $timestamp . '.' . $signature;
$data = $action . '|' . $user_id . '|' . time();
return hash_hmac('sha256', $data, $salt);
}
/**
* Verify secure token - O(1) complexity
* Verify secure token
*
* SECURITY FIX (U1): Rewritten to O(1) verification instead of O(expiry) loop.
* Previous implementation looped up to 3600 times computing HMACs, creating
* a DoS vulnerability. Now extracts timestamp from token for single HMAC check.
*
* @param string $token Token to verify (format: timestamp.signature)
* @param string $token Token to verify
* @param string $action Action identifier
* @param int $user_id User ID
* @param int $expiry Token expiry in seconds (default 1 hour)
* @return bool
*/
public static function verify_secure_token($token, $action, $user_id, $expiry = 3600) {
// Parse token format: timestamp.signature
$parts = explode('.', $token, 2);
if (count($parts) !== 2) {
return false;
}
list($timestamp, $signature) = $parts;
$timestamp = (int) $timestamp;
// Validate timestamp exists and signature is not empty
if (!$timestamp || empty($signature)) {
return false;
}
// Check if token has expired
$current_time = time();
if (($current_time - $timestamp) > $expiry) {
return false;
}
// Single HMAC computation for verification
$salt = wp_salt('auth');
$data = $action . '|' . $user_id . '|' . $timestamp;
$expected_signature = hash_hmac('sha256', $data, $salt);
// Timing-safe comparison
return hash_equals($expected_signature, $signature);
// Generate tokens for last $expiry seconds
$current_time = time();
for ($i = 0; $i <= $expiry; $i++) {
$data = $action . '|' . $user_id . '|' . ($current_time - $i);
$expected_token = hash_hmac('sha256', $data, $salt);
if (hash_equals($expected_token, $token)) {
return true;
}
}
return false;
}
}

View file

@ -31,10 +31,9 @@ class HVAC_Page_Manager {
'parent' => null,
'capability' => null
],
// Note: find-a-trainer removed - redirects to find-training (see HVAC_Route_Manager)
'find-training' => [
'title' => 'Find Training',
'template' => 'page-find-training.php',
'find-a-trainer' => [
'title' => 'Find a Trainer',
'template' => 'page-find-trainer.php',
'public' => true,
'parent' => null,
'capability' => null

View file

@ -112,10 +112,10 @@ final class HVAC_Plugin {
*/
private function defineConstants(): void {
if (!defined('HVAC_PLUGIN_VERSION')) {
define('HVAC_PLUGIN_VERSION', '2.2.11');
define('HVAC_PLUGIN_VERSION', '2.0.0');
}
if (!defined('HVAC_VERSION')) {
define('HVAC_VERSION', '2.2.11');
define('HVAC_VERSION', '2.1.7');
}
if (!defined('HVAC_PLUGIN_FILE')) {
define('HVAC_PLUGIN_FILE', dirname(__DIR__) . '/hvac-community-events.php');
@ -176,9 +176,6 @@ final class HVAC_Plugin {
require_once HVAC_PLUGIN_DIR . 'includes/class-hvac-browser-detection.php';
require_once HVAC_PLUGIN_DIR . 'includes/class-hvac-find-trainer-assets.php';
// reCAPTCHA integration for contact forms
require_once HVAC_PLUGIN_DIR . 'includes/class-hvac-recaptcha.php';
require_once HVAC_PLUGIN_DIR . 'includes/class-hvac-safari-debugger.php';
require_once HVAC_PLUGIN_DIR . 'includes/class-hvac-shortcodes.php';
// DISABLED - Using TEC Community Events 5.x instead
@ -267,16 +264,6 @@ final class HVAC_Plugin {
'class-hvac-mapgeo-safety.php', // MapGeo safety wrapper
];
// Find Training feature files (Google Maps based - replaces MapGeo)
$findTrainingFiles = [
'find-training/class-hvac-find-training-page.php',
'find-training/class-hvac-training-map-data.php',
'find-training/class-hvac-venue-geocoding.php',
];
// Venue Categories (taxonomies for training labs)
require_once HVAC_PLUGIN_DIR . 'includes/class-hvac-venue-categories.php';
// Load feature files with memory-efficient generator
foreach ($this->loadFeatureFiles($featureFiles) as $file => $status) {
if ($status === 'loaded') {
@ -291,13 +278,6 @@ final class HVAC_Plugin {
}
}
// Load Find Training feature files (Google Maps based)
foreach ($this->loadFeatureFiles($findTrainingFiles) as $file => $status) {
if ($status === 'loaded') {
$this->componentStatus["find_training_{$file}"] = true;
}
}
// Load community system files
$communityFiles = [
'community/class-login-handler.php',
@ -599,11 +579,6 @@ final class HVAC_Plugin {
HVAC_Venues::instance();
}
// Initialize venue categories (taxonomies for training labs)
if (class_exists('HVAC_Venue_Categories')) {
HVAC_Venue_Categories::instance();
}
// Initialize trainer profile manager
if (class_exists('HVAC_Trainer_Profile_Manager')) {
HVAC_Trainer_Profile_Manager::get_instance();
@ -902,12 +877,12 @@ final class HVAC_Plugin {
* Loads trainer directory functionality with proper error handling.
*/
public function initializeFindTrainer(): void {
// Initialize Find a Trainer page (legacy MapGeo-based)
// Initialize Find a Trainer page
if (class_exists('HVAC_Find_Trainer_Page')) {
HVAC_Find_Trainer_Page::get_instance();
}
// Initialize MapGeo integration (legacy)
// Initialize MapGeo integration
if (class_exists('HVAC_MapGeo_Integration')) {
HVAC_MapGeo_Integration::get_instance();
}
@ -922,31 +897,22 @@ final class HVAC_Plugin {
HVAC_Trainer_Directory_Query::get_instance();
}
// Initialize Find Training page (new Google Maps-based)
if (class_exists('HVAC_Find_Training_Page')) {
HVAC_Find_Training_Page::get_instance();
// Initialize master trainer manager components
if (class_exists('HVAC_Master_Trainers_Overview')) {
HVAC_Master_Trainers_Overview::instance();
}
// Initialize Training Map Data provider
if (class_exists('HVAC_Training_Map_Data')) {
HVAC_Training_Map_Data::get_instance();
if (class_exists('HVAC_Announcements_Manager')) {
HVAC_Announcements_Manager::get_instance();
}
// Initialize Venue Geocoding service
if (class_exists('HVAC_Venue_Geocoding')) {
HVAC_Venue_Geocoding::get_instance();
if (class_exists('HVAC_Master_Pending_Approvals')) {
HVAC_Master_Pending_Approvals::instance();
}
// ARCHITECTURE FIX (C5): Master Trainer components are already initialized
// in initializeSecondaryComponents() at priority 5. Removed duplicate
// initialization here (priority 20) to prevent confusion and potential
// double hook registration issues.
//
// Components initialized in initializeSecondaryComponents():
// - HVAC_Master_Events_Overview
// - HVAC_Master_Pending_Approvals
// - HVAC_Master_Trainers_Overview
// - HVAC_Announcements_Display / HVAC_Announcements_Admin
if (class_exists('HVAC_Master_Events_Overview')) {
HVAC_Master_Events_Overview::instance();
}
// Fix master trainer pages if needed
if (class_exists('HVAC_Master_Pages_Fixer')) {
@ -1082,14 +1048,8 @@ final class HVAC_Plugin {
// If we're on the trainer registration page, don't apply any authentication checks
$current_path = trim(parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH), '/');
if ($current_path === 'trainer/registration' || is_page('registration') || is_page('trainer-registration')) {
// SECURITY FIX (U2): Remove only HVAC-specific auth redirects, not ALL hooks
// Previous code used remove_all_actions() which broke WordPress core,
// theme, and other plugin functionality at priority 10
remove_action('template_redirect', array($this, 'restrict_trainer_pages'), 10);
remove_action('template_redirect', array($this, 'check_trainer_access'), 10);
// If other HVAC auth hooks are added in the future, remove them here specifically
// DO NOT use remove_all_actions() as it breaks WordPress isolation
// Remove any potential authentication hooks that might be added by other code
remove_all_actions('template_redirect', 10);
}
}

View file

@ -189,23 +189,11 @@ class HVAC_Registration {
* @param string $redirect_url The URL to redirect back to.
*/
private function redirect_with_errors($errors, $data, $redirect_url) {
$transient_id = bin2hex(random_bytes(16)); // Cryptographically secure token
$transient_id = uniqid(); // Generate unique ID for transient key
$transient_key = self::TRANSIENT_PREFIX . $transient_id;
// SECURITY FIX (C1): Strip password fields before storing in transient
// Passwords should never be persisted to database, even temporarily
$safe_data = $data;
unset(
$safe_data['user_pass'],
$safe_data['confirm_password'],
$safe_data['current_password'],
$safe_data['new_password'],
$safe_data['hvac_registration_nonce']
);
$transient_data = [
'errors' => $errors,
'data' => $safe_data, // Store submitted data to repopulate form (sans passwords)
'data' => $data, // Store submitted data to repopulate form
];
// Store for 5 minutes
set_transient($transient_key, $transient_data, MINUTE_IN_SECONDS * 5);

View file

@ -81,8 +81,7 @@ class HVAC_Route_Manager {
'communication-templates' => 'trainer/communication-templates',
'communication-schedules' => 'trainer/communication-schedules',
'trainer-registration' => 'trainer/registration',
'find-trainer' => 'find-training', // Legacy URL redirect
'find-a-trainer' => 'find-training', // Old page redirect to new Google Maps page
'find-trainer' => 'find-a-trainer', // Fix E2E testing URL mismatch
);
// Parent pages that redirect to dashboards

View file

@ -25,24 +25,9 @@ class HVAC_Secure_Storage {
/**
* Get encryption key
*
* SECURITY FIX (C2): Prefer wp-config.php constant over database storage.
* Storing encryption key in the same database as encrypted data is "key under doormat".
* Define HVAC_ENCRYPTION_KEY in wp-config.php for proper key separation.
*
* @return string
*/
private static function get_encryption_key() {
// Prefer wp-config.php constant (recommended for production)
if (defined('HVAC_ENCRYPTION_KEY') && HVAC_ENCRYPTION_KEY) {
return base64_decode(HVAC_ENCRYPTION_KEY);
}
// Fallback to database storage (legacy/development)
// Log warning in debug mode to encourage migration
if (WP_DEBUG) {
error_log('HVAC Security Warning: HVAC_ENCRYPTION_KEY not defined in wp-config.php. Using database-stored key (less secure).');
}
$key = get_option('hvac_encryption_key');
if (!$key) {

View file

@ -181,44 +181,20 @@ class HVAC_Security {
/**
* Get user IP address
*
* SECURITY FIX (C3): Only trust proxy headers when behind a known trusted proxy.
* Previous implementation trusted user-controllable headers unconditionally,
* allowing attackers to spoof IPs and bypass rate limiting.
*
* For most deployments, use REMOTE_ADDR directly. Only trust X-Forwarded-For
* when behind Cloudflare, AWS ALB, or other known reverse proxies.
*
* @return string
*/
public static function get_user_ip() {
// Primary: Use REMOTE_ADDR (cannot be spoofed at network level)
$ip = isset($_SERVER['REMOTE_ADDR']) ? $_SERVER['REMOTE_ADDR'] : '';
$ip = '';
// Only trust proxy headers if HVAC_TRUSTED_PROXIES is defined
// Define as comma-separated list of trusted proxy IPs in wp-config.php
// Example: define('HVAC_TRUSTED_PROXIES', '10.0.0.1,10.0.0.2');
if (defined('HVAC_TRUSTED_PROXIES') && HVAC_TRUSTED_PROXIES) {
$trusted_proxies = array_map('trim', explode(',', HVAC_TRUSTED_PROXIES));
if (in_array($ip, $trusted_proxies, true)) {
// Behind trusted proxy - check forwarded headers
if (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) {
// Take first IP (original client) from comma-separated list
$forwarded = explode(',', $_SERVER['HTTP_X_FORWARDED_FOR']);
$client_ip = trim($forwarded[0]);
if (filter_var($client_ip, FILTER_VALIDATE_IP)) {
$ip = $client_ip;
}
} elseif (!empty($_SERVER['HTTP_CLIENT_IP'])) {
$client_ip = $_SERVER['HTTP_CLIENT_IP'];
if (filter_var($client_ip, FILTER_VALIDATE_IP)) {
$ip = $client_ip;
}
}
}
if ( ! empty( $_SERVER['HTTP_CLIENT_IP'] ) ) {
$ip = $_SERVER['HTTP_CLIENT_IP'];
} elseif ( ! empty( $_SERVER['HTTP_X_FORWARDED_FOR'] ) ) {
$ip = $_SERVER['HTTP_X_FORWARDED_FOR'];
} elseif ( ! empty( $_SERVER['REMOTE_ADDR'] ) ) {
$ip = $_SERVER['REMOTE_ADDR'];
}
return sanitize_text_field($ip);
return sanitize_text_field( $ip );
}
/**

View file

@ -1232,9 +1232,5 @@ class HVAC_Trainer_Profile_Manager {
}
}
// ARCHITECTURE FIX (U9): Removed file-scope side-effect initialization.
// Component initialization should be controlled by HVAC_Plugin orchestrator,
// not triggered automatically at file-include time. HVAC_Plugin already calls
// get_instance() at line 584 during initializeSecondaryComponents().
//
// Previous code: HVAC_Trainer_Profile_Manager::get_instance();
// Initialize the manager
HVAC_Trainer_Profile_Manager::get_instance();

View file

@ -1,282 +0,0 @@
<?php
/**
* Venue Categories - Taxonomy Registration for Training Labs
*
* Registers custom taxonomies for TEC venues:
* - venue_type: Training lab types (e.g., "measureQuick Approved Training Lab")
* - venue_equipment: Equipment available at venues
* - venue_amenities: Amenities available at venues
*
* @package HVAC_Community_Events
* @since 2.3.0
*/
if (!defined('ABSPATH')) {
exit;
}
/**
* Class HVAC_Venue_Categories
*
* Manages venue taxonomies for categorizing training labs.
*/
class HVAC_Venue_Categories {
/**
* Singleton instance
*
* @var HVAC_Venue_Categories|null
*/
private static ?self $instance = null;
/**
* Get singleton instance
*
* @return HVAC_Venue_Categories
*/
public static function instance(): self {
if (self::$instance === null) {
self::$instance = new self();
}
return self::$instance;
}
/**
* Constructor
*/
private function __construct() {
add_action('init', [$this, 'register_taxonomies'], 5);
add_action('init', [$this, 'create_default_terms'], 10);
}
/**
* Register venue taxonomies
*/
public function register_taxonomies(): void {
// Venue Type taxonomy (e.g., "measureQuick Approved Training Lab")
register_taxonomy('venue_type', 'tribe_venue', [
'labels' => [
'name' => __('Venue Types', 'hvac-community-events'),
'singular_name' => __('Venue Type', 'hvac-community-events'),
'search_items' => __('Search Venue Types', 'hvac-community-events'),
'all_items' => __('All Venue Types', 'hvac-community-events'),
'parent_item' => __('Parent Venue Type', 'hvac-community-events'),
'parent_item_colon' => __('Parent Venue Type:', 'hvac-community-events'),
'edit_item' => __('Edit Venue Type', 'hvac-community-events'),
'update_item' => __('Update Venue Type', 'hvac-community-events'),
'add_new_item' => __('Add New Venue Type', 'hvac-community-events'),
'new_item_name' => __('New Venue Type Name', 'hvac-community-events'),
'menu_name' => __('Venue Types', 'hvac-community-events'),
],
'hierarchical' => true,
'public' => true,
'show_ui' => true,
'show_in_menu' => true,
'show_admin_column' => true,
'show_in_rest' => true,
'query_var' => true,
'rewrite' => ['slug' => 'venue-type'],
]);
// Venue Equipment taxonomy
register_taxonomy('venue_equipment', 'tribe_venue', [
'labels' => [
'name' => __('Equipment', 'hvac-community-events'),
'singular_name' => __('Equipment', 'hvac-community-events'),
'search_items' => __('Search Equipment', 'hvac-community-events'),
'all_items' => __('All Equipment', 'hvac-community-events'),
'parent_item' => __('Parent Equipment', 'hvac-community-events'),
'parent_item_colon' => __('Parent Equipment:', 'hvac-community-events'),
'edit_item' => __('Edit Equipment', 'hvac-community-events'),
'update_item' => __('Update Equipment', 'hvac-community-events'),
'add_new_item' => __('Add New Equipment', 'hvac-community-events'),
'new_item_name' => __('New Equipment Name', 'hvac-community-events'),
'menu_name' => __('Equipment', 'hvac-community-events'),
],
'hierarchical' => true,
'public' => true,
'show_ui' => true,
'show_in_menu' => true,
'show_admin_column' => true,
'show_in_rest' => true,
'query_var' => true,
'rewrite' => ['slug' => 'venue-equipment'],
]);
// Venue Amenities taxonomy
register_taxonomy('venue_amenities', 'tribe_venue', [
'labels' => [
'name' => __('Amenities', 'hvac-community-events'),
'singular_name' => __('Amenity', 'hvac-community-events'),
'search_items' => __('Search Amenities', 'hvac-community-events'),
'all_items' => __('All Amenities', 'hvac-community-events'),
'parent_item' => __('Parent Amenity', 'hvac-community-events'),
'parent_item_colon' => __('Parent Amenity:', 'hvac-community-events'),
'edit_item' => __('Edit Amenity', 'hvac-community-events'),
'update_item' => __('Update Amenity', 'hvac-community-events'),
'add_new_item' => __('Add New Amenity', 'hvac-community-events'),
'new_item_name' => __('New Amenity Name', 'hvac-community-events'),
'menu_name' => __('Amenities', 'hvac-community-events'),
],
'hierarchical' => true,
'public' => true,
'show_ui' => true,
'show_in_menu' => true,
'show_admin_column' => true,
'show_in_rest' => true,
'query_var' => true,
'rewrite' => ['slug' => 'venue-amenities'],
]);
}
/**
* Create default taxonomy terms
*/
public function create_default_terms(): void {
// Only run once
if (get_option('hvac_venue_categories_initialized')) {
return;
}
// Venue Types
$venue_types = [
'measureQuick Approved Training Lab' => 'mq-approved-lab',
];
foreach ($venue_types as $name => $slug) {
if (!term_exists($slug, 'venue_type')) {
wp_insert_term($name, 'venue_type', ['slug' => $slug]);
}
}
// Equipment
$equipment = [
'Furnace',
'Heat Pump',
'Air Conditioner',
'Mini-Split',
'Boiler',
'Gas Meter',
'TrueFlow Grid',
'Flow Hood',
];
foreach ($equipment as $name) {
$slug = sanitize_title($name);
if (!term_exists($slug, 'venue_equipment')) {
wp_insert_term($name, 'venue_equipment', ['slug' => $slug]);
}
}
// Amenities
$amenities = [
'Coffee',
'Water',
'Tea',
'Soda',
'Snacks',
'Projector',
'Whiteboard',
'WiFi',
'Parking',
'Vending Machines',
];
foreach ($amenities as $name) {
$slug = sanitize_title($name);
if (!term_exists($slug, 'venue_amenities')) {
wp_insert_term($name, 'venue_amenities', ['slug' => $slug]);
}
}
update_option('hvac_venue_categories_initialized', true);
}
/**
* Get venues by type
*
* @param string $type_slug Type slug (e.g., 'mq-approved-lab')
* @return array Venue IDs
*/
public function get_venues_by_type(string $type_slug): array {
$query = new WP_Query([
'post_type' => 'tribe_venue',
'posts_per_page' => -1,
'post_status' => 'publish',
'fields' => 'ids',
'tax_query' => [
[
'taxonomy' => 'venue_type',
'field' => 'slug',
'terms' => $type_slug,
],
],
]);
return $query->posts;
}
/**
* Check if venue is an approved training lab
*
* @param int $venue_id Venue post ID
* @return bool
*/
public function is_approved_training_lab(int $venue_id): bool {
return has_term('mq-approved-lab', 'venue_type', $venue_id);
}
/**
* Get venue equipment
*
* @param int $venue_id Venue post ID
* @return array Equipment names
*/
public function get_venue_equipment(int $venue_id): array {
$terms = wp_get_post_terms($venue_id, 'venue_equipment', ['fields' => 'names']);
return is_wp_error($terms) ? [] : $terms;
}
/**
* Get venue amenities
*
* @param int $venue_id Venue post ID
* @return array Amenity names
*/
public function get_venue_amenities(int $venue_id): array {
$terms = wp_get_post_terms($venue_id, 'venue_amenities', ['fields' => 'names']);
return is_wp_error($terms) ? [] : $terms;
}
/**
* Set venue as approved training lab
*
* @param int $venue_id Venue post ID
* @return bool|WP_Error
*/
public function set_as_approved_lab(int $venue_id) {
return wp_set_post_terms($venue_id, ['mq-approved-lab'], 'venue_type', false);
}
/**
* Set venue equipment
*
* @param int $venue_id Venue post ID
* @param array $equipment Equipment slugs or names
* @return bool|WP_Error
*/
public function set_venue_equipment(int $venue_id, array $equipment) {
return wp_set_post_terms($venue_id, $equipment, 'venue_equipment', false);
}
/**
* Set venue amenities
*
* @param int $venue_id Venue post ID
* @param array $amenities Amenity slugs or names
* @return bool|WP_Error
*/
public function set_venue_amenities(int $venue_id, array $amenities) {
return wp_set_post_terms($venue_id, $amenities, 'venue_amenities', false);
}
}

View file

@ -1,517 +0,0 @@
<?php
/**
* Find Training Page Handler
*
* Manages the Find Training page with Google Maps integration
* showing trainers and training venues on an interactive map.
*
* @package HVAC_Community_Events
* @since 2.2.0
*/
if (!defined('ABSPATH')) {
exit;
}
/**
* Class HVAC_Find_Training_Page
*
* Main controller for the Find Training page functionality.
* Uses singleton pattern consistent with other HVAC plugin classes.
*/
class HVAC_Find_Training_Page {
/**
* Singleton instance
*
* @var HVAC_Find_Training_Page|null
*/
private static ?self $instance = null;
/**
* Page slug
*
* @var string
*/
private string $page_slug = 'find-training';
/**
* Google Maps API key
*
* @var string
*/
private string $api_key = '';
/**
* Get singleton instance
*
* @return HVAC_Find_Training_Page
*/
public static function get_instance(): self {
if (self::$instance === null) {
self::$instance = new self();
}
return self::$instance;
}
/**
* Constructor
*/
private function __construct() {
$this->load_api_key();
$this->init_hooks();
}
/**
* Load Google Maps API key from secure storage
*/
private function load_api_key(): void {
if (class_exists('HVAC_Secure_Storage')) {
$this->api_key = HVAC_Secure_Storage::get_credential('hvac_google_maps_api_key', '');
}
}
/**
* Initialize WordPress hooks
*/
private function init_hooks(): void {
// Page registration
add_action('init', [$this, 'register_page'], 15);
// Asset enqueuing
add_action('wp_enqueue_scripts', [$this, 'enqueue_assets']);
// Body classes
add_filter('body_class', [$this, 'add_body_classes']);
// AJAX handlers
add_action('wp_ajax_hvac_get_training_map_data', [$this, 'ajax_get_map_data']);
add_action('wp_ajax_nopriv_hvac_get_training_map_data', [$this, 'ajax_get_map_data']);
add_action('wp_ajax_hvac_filter_training_map', [$this, 'ajax_filter_map']);
add_action('wp_ajax_nopriv_hvac_filter_training_map', [$this, 'ajax_filter_map']);
add_action('wp_ajax_hvac_get_trainer_profile_modal', [$this, 'ajax_get_trainer_profile']);
add_action('wp_ajax_nopriv_hvac_get_trainer_profile_modal', [$this, 'ajax_get_trainer_profile']);
add_action('wp_ajax_hvac_get_venue_info', [$this, 'ajax_get_venue_info']);
add_action('wp_ajax_nopriv_hvac_get_venue_info', [$this, 'ajax_get_venue_info']);
// Redirect from old page
add_action('template_redirect', [$this, 'maybe_redirect_from_old_page']);
}
/**
* Register the Find Training page
*/
public function register_page(): void {
$page = get_page_by_path($this->page_slug);
if (!$page) {
$this->create_page();
}
}
/**
* Create the Find Training page in WordPress
*/
private function create_page(): void {
$page_data = [
'post_title' => 'Find Training',
'post_name' => $this->page_slug,
'post_content' => '<!-- This page uses a custom template -->',
'post_status' => 'publish',
'post_type' => 'page',
'post_author' => 1,
'comment_status' => 'closed',
'ping_status' => 'closed',
'meta_input' => [
'_wp_page_template' => 'page-find-training.php',
'ast-site-content-layout' => 'page-builder',
'site-post-title' => 'disabled',
'site-sidebar-layout' => 'no-sidebar',
'theme-transparent-header-meta' => 'disabled'
]
];
$page_id = wp_insert_post($page_data);
if ($page_id && !is_wp_error($page_id)) {
update_option('hvac_find_training_page_id', $page_id);
}
}
/**
* Check if current page is the Find Training page
*
* @return bool
*/
public function is_find_training_page(): bool {
return is_page($this->page_slug) || is_page(get_option('hvac_find_training_page_id'));
}
/**
* Enqueue page assets
*/
public function enqueue_assets(): void {
if (!$this->is_find_training_page()) {
return;
}
// Enqueue CSS
wp_enqueue_style(
'hvac-find-training',
HVAC_PLUGIN_URL . 'assets/css/find-training-map.css',
['astra-theme-css'],
HVAC_VERSION
);
// Enqueue Google Maps API with MarkerClusterer
if (!empty($this->api_key)) {
wp_enqueue_script(
'google-maps-api',
'https://maps.googleapis.com/maps/api/js?key=' . esc_attr($this->api_key) . '&libraries=places&callback=Function.prototype',
[],
null,
true
);
// MarkerClusterer library
wp_enqueue_script(
'google-maps-markerclusterer',
'https://unpkg.com/@googlemaps/markerclusterer/dist/index.min.js',
['google-maps-api'],
'2.5.3',
true
);
}
// Enqueue main map JavaScript
wp_enqueue_script(
'hvac-find-training-map',
HVAC_PLUGIN_URL . 'assets/js/find-training-map.js',
['jquery', 'google-maps-api', 'google-maps-markerclusterer'],
HVAC_VERSION,
true
);
// Enqueue filter JavaScript
wp_enqueue_script(
'hvac-find-training-filters',
HVAC_PLUGIN_URL . 'assets/js/find-training-filters.js',
['jquery', 'hvac-find-training-map'],
HVAC_VERSION,
true
);
// Localize script with data
wp_localize_script('hvac-find-training-map', 'hvacFindTraining', [
'ajax_url' => admin_url('admin-ajax.php'),
'nonce' => wp_create_nonce('hvac_find_training'),
'api_key' => !empty($this->api_key) ? 'configured' : '', // Don't expose actual key
'map_center' => [
'lat' => 39.8283, // US center
'lng' => -98.5795
],
'default_zoom' => 4,
'cluster_zoom' => 8,
'messages' => [
'loading' => __('Loading...', 'hvac-community-events'),
'error' => __('An error occurred. Please try again.', 'hvac-community-events'),
'no_results' => __('No trainers or venues found matching your criteria.', 'hvac-community-events'),
'geolocation_error' => __('Unable to get your location. Please check your browser settings.', 'hvac-community-events'),
'geolocation_unsupported' => __('Geolocation is not supported by your browser.', 'hvac-community-events')
],
'marker_icons' => [
'trainer' => HVAC_PLUGIN_URL . 'assets/images/marker-trainer.svg',
'venue' => HVAC_PLUGIN_URL . 'assets/images/marker-venue.svg'
]
]);
}
/**
* Add body classes for the page
*
* @param array $classes Existing body classes
* @return array Modified body classes
*/
public function add_body_classes(array $classes): array {
if ($this->is_find_training_page()) {
$classes[] = 'hvac-find-training-page';
$classes[] = 'hvac-full-width';
$classes[] = 'hvac-page';
}
return $classes;
}
/**
* Redirect from old /find-a-trainer page to /find-training
*/
public function maybe_redirect_from_old_page(): void {
if (is_page('find-a-trainer')) {
wp_safe_redirect(home_url('/find-training/'), 301);
exit;
}
}
/**
* AJAX: Get all map data (trainers and venues)
*/
public function ajax_get_map_data(): void {
// Verify nonce
if (!isset($_POST['nonce']) || !wp_verify_nonce($_POST['nonce'], 'hvac_find_training')) {
wp_send_json_error(['message' => 'Invalid security token']);
return;
}
$data_provider = HVAC_Training_Map_Data::get_instance();
$trainers = $data_provider->get_trainer_markers();
$venues = $data_provider->get_venue_markers();
wp_send_json_success([
'trainers' => $trainers,
'venues' => $venues,
'total_trainers' => count($trainers),
'total_venues' => count($venues)
]);
}
/**
* AJAX: Filter map markers
*/
public function ajax_filter_map(): void {
// Verify nonce
if (!isset($_POST['nonce']) || !wp_verify_nonce($_POST['nonce'], 'hvac_find_training')) {
wp_send_json_error(['message' => 'Invalid security token']);
return;
}
$filters = [
'state' => sanitize_text_field($_POST['state'] ?? ''),
'certification' => sanitize_text_field($_POST['certification'] ?? ''),
'training_format' => sanitize_text_field($_POST['training_format'] ?? ''),
'search' => sanitize_text_field($_POST['search'] ?? ''),
'show_trainers' => filter_var($_POST['show_trainers'] ?? true, FILTER_VALIDATE_BOOLEAN),
'show_venues' => filter_var($_POST['show_venues'] ?? true, FILTER_VALIDATE_BOOLEAN),
'lat' => isset($_POST['lat']) ? floatval($_POST['lat']) : null,
'lng' => isset($_POST['lng']) ? floatval($_POST['lng']) : null,
'radius' => isset($_POST['radius']) ? intval($_POST['radius']) : 100 // km
];
$data_provider = HVAC_Training_Map_Data::get_instance();
$result = [
'trainers' => [],
'venues' => []
];
if ($filters['show_trainers']) {
$result['trainers'] = $data_provider->get_trainer_markers($filters);
}
if ($filters['show_venues']) {
$result['venues'] = $data_provider->get_venue_markers($filters);
}
$result['total_trainers'] = count($result['trainers']);
$result['total_venues'] = count($result['venues']);
$result['filters_applied'] = array_filter($filters, function($v) {
return !empty($v) && $v !== true;
});
wp_send_json_success($result);
}
/**
* AJAX: Get trainer profile for modal
*/
public function ajax_get_trainer_profile(): void {
// Verify nonce
if (!isset($_POST['nonce']) || !wp_verify_nonce($_POST['nonce'], 'hvac_find_training')) {
wp_send_json_error(['message' => 'Invalid security token']);
return;
}
$profile_id = absint($_POST['profile_id'] ?? 0);
if (!$profile_id) {
wp_send_json_error(['message' => 'Invalid profile ID']);
return;
}
$data_provider = HVAC_Training_Map_Data::get_instance();
$trainer_data = $data_provider->get_trainer_full_profile($profile_id);
if (!$trainer_data) {
wp_send_json_error(['message' => 'Trainer not found']);
return;
}
// Generate modal HTML
ob_start();
$this->render_trainer_modal_content($trainer_data);
$html = ob_get_clean();
wp_send_json_success([
'trainer' => $trainer_data,
'html' => $html
]);
}
/**
* AJAX: Get venue info for info window
*/
public function ajax_get_venue_info(): void {
// Verify nonce
if (!isset($_POST['nonce']) || !wp_verify_nonce($_POST['nonce'], 'hvac_find_training')) {
wp_send_json_error(['message' => 'Invalid security token']);
return;
}
$venue_id = absint($_POST['venue_id'] ?? 0);
if (!$venue_id) {
wp_send_json_error(['message' => 'Invalid venue ID']);
return;
}
$data_provider = HVAC_Training_Map_Data::get_instance();
$venue_data = $data_provider->get_venue_full_info($venue_id);
if (!$venue_data) {
wp_send_json_error(['message' => 'Venue not found']);
return;
}
wp_send_json_success(['venue' => $venue_data]);
}
/**
* Render trainer modal content
*
* @param array $trainer Trainer data
*/
private function render_trainer_modal_content(array $trainer): void {
?>
<div class="hvac-training-modal-header">
<h2><?php echo esc_html($trainer['name']); ?></h2>
<button class="hvac-modal-close" aria-label="Close modal">&times;</button>
</div>
<div class="hvac-training-modal-body">
<div class="hvac-training-profile-section">
<div class="hvac-training-profile-header">
<?php if (!empty($trainer['image'])): ?>
<img src="<?php echo esc_url($trainer['image']); ?>" alt="<?php echo esc_attr($trainer['name']); ?>" class="hvac-training-profile-image">
<?php else: ?>
<div class="hvac-training-profile-avatar">
<span class="dashicons dashicons-businessperson"></span>
</div>
<?php endif; ?>
<div class="hvac-training-profile-info">
<p class="hvac-training-location">
<?php echo esc_html($trainer['city'] . ', ' . $trainer['state']); ?>
</p>
<?php if (!empty($trainer['certifications'])): ?>
<div class="hvac-training-certifications">
<?php foreach ($trainer['certifications'] as $cert): ?>
<span class="hvac-cert-badge"><?php echo esc_html($cert); ?></span>
<?php endforeach; ?>
</div>
<?php endif; ?>
<?php if (!empty($trainer['company'])): ?>
<p class="hvac-training-company"><?php echo esc_html($trainer['company']); ?></p>
<?php endif; ?>
<p class="hvac-training-events-count">
Total Training Events: <strong><?php echo intval($trainer['event_count']); ?></strong>
</p>
</div>
</div>
<?php if (!empty($trainer['training_formats'])): ?>
<div class="hvac-training-detail">
<strong>Training Formats:</strong> <?php echo esc_html(implode(', ', $trainer['training_formats'])); ?>
</div>
<?php endif; ?>
<?php if (!empty($trainer['upcoming_events'])): ?>
<div class="hvac-training-events">
<h4>Upcoming Events</h4>
<ul class="hvac-events-list">
<?php foreach (array_slice($trainer['upcoming_events'], 0, 5) as $event): ?>
<li>
<a href="<?php echo esc_url($event['url']); ?>" target="_blank">
<?php echo esc_html($event['title']); ?>
</a>
<span class="hvac-event-date"><?php echo esc_html($event['date']); ?></span>
</li>
<?php endforeach; ?>
</ul>
</div>
<?php endif; ?>
</div>
<div class="hvac-training-contact-section">
<h4>Contact Trainer</h4>
<form class="hvac-training-contact-form" data-trainer-id="<?php echo esc_attr($trainer['user_id']); ?>" data-profile-id="<?php echo esc_attr($trainer['profile_id']); ?>">
<div class="hvac-form-row">
<input type="text" name="first_name" placeholder="First Name" required>
<input type="text" name="last_name" placeholder="Last Name" required>
</div>
<div class="hvac-form-row">
<input type="email" name="email" placeholder="Email" required>
<input type="tel" name="phone" placeholder="Phone Number">
</div>
<div class="hvac-form-row">
<input type="text" name="city" placeholder="City">
<input type="text" name="state_province" placeholder="State/Province">
</div>
<div class="hvac-form-field">
<input type="text" name="company" placeholder="Company">
</div>
<div class="hvac-form-field">
<textarea name="message" placeholder="Message" rows="3"></textarea>
</div>
<button type="submit" class="hvac-btn-primary">Send Message</button>
</form>
<div class="hvac-form-message hvac-form-success" style="display: none;">
Your message has been sent! Check your inbox for more details.
</div>
<div class="hvac-form-message hvac-form-error" style="display: none;">
There was an error sending your message. Please try again.
</div>
</div>
</div>
<?php
}
/**
* Get filter options for dropdowns
*
* @return array Filter options
*/
public function get_filter_options(): array {
$data_provider = HVAC_Training_Map_Data::get_instance();
return [
'states' => $data_provider->get_state_options(),
'certifications' => $data_provider->get_certification_options(),
'training_formats' => $data_provider->get_training_format_options()
];
}
/**
* Get page slug
*
* @return string
*/
public function get_page_slug(): string {
return $this->page_slug;
}
}

File diff suppressed because it is too large Load diff

View file

@ -1,517 +0,0 @@
<?php
/**
* Venue Geocoding Service
*
* Handles geocoding for TEC venues to add lat/lng coordinates.
*
* @package HVAC_Community_Events
* @since 2.2.0
*/
if (!defined('ABSPATH')) {
exit;
}
/**
* Class HVAC_Venue_Geocoding
*
* Manages geocoding of venue addresses using Google Maps Geocoding API.
* Auto-geocodes new venues and provides batch processing for existing venues.
*/
class HVAC_Venue_Geocoding {
/**
* Singleton instance
*
* @var HVAC_Venue_Geocoding|null
*/
private static ?self $instance = null;
/**
* Google Maps API key
*
* @var string
*/
private string $api_key = '';
/**
* Rate limit per minute
*
* @var int
*/
private int $rate_limit = 50;
/**
* Cache duration for geocoding results
*
* @var int
*/
private int $cache_duration = DAY_IN_SECONDS;
/**
* Get singleton instance
*
* @return HVAC_Venue_Geocoding
*/
public static function get_instance(): self {
if (self::$instance === null) {
self::$instance = new self();
}
return self::$instance;
}
/**
* Constructor
*/
private function __construct() {
$this->load_api_key();
$this->init_hooks();
}
/**
* Load API key from secure storage
*/
private function load_api_key(): void {
if (class_exists('HVAC_Secure_Storage')) {
$this->api_key = HVAC_Secure_Storage::get_credential('hvac_google_maps_api_key', '');
}
}
/**
* Initialize hooks
*/
private function init_hooks(): void {
// Auto-geocode new venues on save
add_action('save_post_tribe_venue', [$this, 'maybe_geocode_venue'], 20, 2);
// Register geocoding action for async processing
add_action('hvac_geocode_venue', [$this, 'geocode_venue']);
// Admin action for batch geocoding
add_action('wp_ajax_hvac_batch_geocode_venues', [$this, 'ajax_batch_geocode']);
// Clear venue coordinates when address changes
add_action('updated_post_meta', [$this, 'on_venue_meta_update'], 10, 4);
}
/**
* Maybe geocode venue on save
*
* @param int $venue_id Venue post ID
* @param WP_Post $post Post object
*/
public function maybe_geocode_venue(int $venue_id, WP_Post $post): void {
// Skip autosaves and revisions
if (wp_is_post_autosave($venue_id) || wp_is_post_revision($venue_id)) {
return;
}
// Check if coordinates already exist
$has_coords = $this->venue_has_coordinates($venue_id);
if (!$has_coords) {
// Schedule geocoding to avoid blocking save
wp_schedule_single_event(time() + 5, 'hvac_geocode_venue', [$venue_id]);
}
}
/**
* Check if venue already has coordinates
*
* @param int $venue_id Venue post ID
* @return bool
*/
public function venue_has_coordinates(int $venue_id): bool {
// Check custom coordinates
$lat = get_post_meta($venue_id, 'venue_latitude', true);
$lng = get_post_meta($venue_id, 'venue_longitude', true);
if (!empty($lat) && !empty($lng)) {
return true;
}
// Check TEC built-in coordinates
$tec_lat = get_post_meta($venue_id, '_VenueLat', true);
$tec_lng = get_post_meta($venue_id, '_VenueLng', true);
return !empty($tec_lat) && !empty($tec_lng);
}
/**
* Geocode a venue
*
* @param int $venue_id Venue post ID
* @return bool Success
*/
public function geocode_venue(int $venue_id): bool {
// Rate limiting check
if (!$this->check_rate_limit()) {
// Reschedule
wp_schedule_single_event(time() + 60, 'hvac_geocode_venue', [$venue_id]);
return false;
}
// Build address
$address = $this->build_venue_address($venue_id);
if (empty($address)) {
update_post_meta($venue_id, '_venue_geocoding_status', 'no_address');
return false;
}
update_post_meta($venue_id, '_venue_geocoding_attempt', time());
// Check cache first
$cache_key = 'venue_geo_' . md5($address);
$cached = get_transient($cache_key);
if ($cached !== false) {
return $this->save_coordinates($venue_id, $cached);
}
// Make API request
$result = $this->geocode_address($address);
if ($result && isset($result['lat'], $result['lng'])) {
// Cache result
set_transient($cache_key, $result, $this->cache_duration);
return $this->save_coordinates($venue_id, $result);
}
// Handle failure
$this->handle_geocoding_failure($venue_id, $result);
return false;
}
/**
* Build venue address string from TEC meta
*
* @param int $venue_id Venue post ID
* @return string Full address
*/
private function build_venue_address(int $venue_id): string {
$parts = [];
$address = get_post_meta($venue_id, '_VenueAddress', true);
$city = get_post_meta($venue_id, '_VenueCity', true);
$state = get_post_meta($venue_id, '_VenueStateProvince', true) ?: get_post_meta($venue_id, '_VenueState', true);
$zip = get_post_meta($venue_id, '_VenueZip', true);
$country = get_post_meta($venue_id, '_VenueCountry', true);
if (!empty($address)) {
$parts[] = $address;
}
if (!empty($city)) {
$parts[] = $city;
}
if (!empty($state)) {
$parts[] = $state;
}
if (!empty($zip)) {
$parts[] = $zip;
}
if (!empty($country)) {
$parts[] = $country;
}
return implode(', ', $parts);
}
/**
* Make geocoding API request
*
* @param string $address Address to geocode
* @return array|null Result with lat/lng or null on failure
*/
private function geocode_address(string $address): ?array {
if (empty($this->api_key)) {
return ['error' => 'No API key configured'];
}
$url = 'https://maps.googleapis.com/maps/api/geocode/json';
$params = [
'address' => $address,
'key' => $this->api_key,
'components' => 'country:US|country:CA' // Restrict to North America
];
$response = wp_remote_get($url . '?' . http_build_query($params), [
'timeout' => 10,
'user-agent' => 'HVAC Training Directory/1.0'
]);
if (is_wp_error($response)) {
return ['error' => $response->get_error_message()];
}
$body = wp_remote_retrieve_body($response);
$data = json_decode($body, true);
if (!$data || $data['status'] !== 'OK' || empty($data['results'])) {
return ['error' => $data['status'] ?? 'Unknown error'];
}
$location = $data['results'][0]['geometry']['location'];
return [
'lat' => $location['lat'],
'lng' => $location['lng'],
'formatted_address' => $data['results'][0]['formatted_address'],
'place_id' => $data['results'][0]['place_id'] ?? ''
];
}
/**
* Save coordinates to venue
*
* @param int $venue_id Venue post ID
* @param array $result Geocoding result
* @return bool Success
*/
private function save_coordinates(int $venue_id, array $result): bool {
if (!isset($result['lat']) || !isset($result['lng'])) {
return false;
}
// Save to our custom meta
update_post_meta($venue_id, 'venue_latitude', $result['lat']);
update_post_meta($venue_id, 'venue_longitude', $result['lng']);
// Also update TEC's meta for compatibility
update_post_meta($venue_id, '_VenueLat', $result['lat']);
update_post_meta($venue_id, '_VenueLng', $result['lng']);
if (!empty($result['formatted_address'])) {
update_post_meta($venue_id, '_venue_formatted_address', $result['formatted_address']);
}
update_post_meta($venue_id, '_venue_geocoding_status', 'success');
update_post_meta($venue_id, '_venue_geocoding_date', time());
return true;
}
/**
* Handle geocoding failure
*
* @param int $venue_id Venue post ID
* @param array|null $result Error result
*/
private function handle_geocoding_failure(int $venue_id, ?array $result): void {
$error = $result['error'] ?? 'Unknown error';
update_post_meta($venue_id, '_venue_geocoding_status', 'failed');
update_post_meta($venue_id, '_venue_geocoding_error', $error);
// Handle specific errors
switch ($error) {
case 'OVER_QUERY_LIMIT':
// Retry in 1 hour
wp_schedule_single_event(time() + HOUR_IN_SECONDS, 'hvac_geocode_venue', [$venue_id]);
break;
case 'ZERO_RESULTS':
// Try fallback with less specific address
$this->try_fallback_geocoding($venue_id);
break;
default:
// Log error
if (defined('WP_DEBUG') && WP_DEBUG) {
error_log("HVAC Venue Geocoding failed for venue {$venue_id}: {$error}");
}
}
}
/**
* Try fallback geocoding with city/state only
*
* @param int $venue_id Venue post ID
*/
private function try_fallback_geocoding(int $venue_id): void {
$city = get_post_meta($venue_id, '_VenueCity', true);
$state = get_post_meta($venue_id, '_VenueStateProvince', true) ?: get_post_meta($venue_id, '_VenueState', true);
$country = get_post_meta($venue_id, '_VenueCountry', true) ?: 'USA';
if (empty($city) && empty($state)) {
return;
}
$address = implode(', ', array_filter([$city, $state, $country]));
$result = $this->geocode_address($address);
if ($result && isset($result['lat'], $result['lng'])) {
$this->save_coordinates($venue_id, $result);
update_post_meta($venue_id, '_venue_geocoding_status', 'success_fallback');
}
}
/**
* Check rate limiting
*
* @return bool Can make request
*/
private function check_rate_limit(): bool {
$rate_key = 'hvac_venue_geocoding_rate_' . gmdate('Y-m-d-H-i');
$current = get_transient($rate_key) ?: 0;
if ($current >= $this->rate_limit) {
return false;
}
set_transient($rate_key, $current + 1, 60);
return true;
}
/**
* Clear coordinates when venue address changes
*
* @param int $meta_id Meta ID
* @param int $post_id Post ID
* @param string $meta_key Meta key
* @param mixed $meta_value Meta value
*/
public function on_venue_meta_update(int $meta_id, int $post_id, string $meta_key, $meta_value): void {
if (get_post_type($post_id) !== 'tribe_venue') {
return;
}
$address_fields = ['_VenueAddress', '_VenueCity', '_VenueStateProvince', '_VenueState', '_VenueZip'];
if (in_array($meta_key, $address_fields, true)) {
// Address changed - clear coordinates to force re-geocoding
delete_post_meta($post_id, 'venue_latitude');
delete_post_meta($post_id, 'venue_longitude');
delete_post_meta($post_id, '_venue_geocoding_status');
// Schedule re-geocoding
wp_schedule_single_event(time() + 5, 'hvac_geocode_venue', [$post_id]);
}
}
/**
* AJAX handler for batch geocoding venues
*/
public function ajax_batch_geocode(): void {
// Check permissions
if (!current_user_can('manage_options')) {
wp_send_json_error(['message' => 'Permission denied']);
return;
}
// Verify nonce
if (!isset($_POST['nonce']) || !wp_verify_nonce($_POST['nonce'], 'hvac_batch_geocode_venues')) {
wp_send_json_error(['message' => 'Invalid security token']);
return;
}
$limit = absint($_POST['limit'] ?? 10);
$result = $this->batch_geocode_venues($limit);
wp_send_json_success($result);
}
/**
* Batch geocode venues without coordinates
*
* @param int $limit Maximum venues to process
* @return array Results
*/
public function batch_geocode_venues(int $limit = 10): array {
$venues = get_posts([
'post_type' => 'tribe_venue',
'posts_per_page' => $limit,
'post_status' => 'publish',
'meta_query' => [
'relation' => 'AND',
[
'key' => 'venue_latitude',
'compare' => 'NOT EXISTS'
],
[
'key' => '_VenueLat',
'compare' => 'NOT EXISTS'
]
]
]);
$results = [
'processed' => 0,
'success' => 0,
'failed' => 0,
'remaining' => 0
];
foreach ($venues as $venue) {
if (!$this->check_rate_limit()) {
break;
}
$results['processed']++;
if ($this->geocode_venue($venue->ID)) {
$results['success']++;
} else {
$results['failed']++;
}
}
// Count remaining
$remaining_query = new WP_Query([
'post_type' => 'tribe_venue',
'posts_per_page' => 1,
'post_status' => 'publish',
'fields' => 'ids',
'meta_query' => [
'relation' => 'AND',
[
'key' => 'venue_latitude',
'compare' => 'NOT EXISTS'
],
[
'key' => '_VenueLat',
'compare' => 'NOT EXISTS'
]
]
]);
$results['remaining'] = $remaining_query->found_posts;
return $results;
}
/**
* Get geocoding status for a venue
*
* @param int $venue_id Venue post ID
* @return array Status info
*/
public function get_geocoding_status(int $venue_id): array {
return [
'has_coordinates' => $this->venue_has_coordinates($venue_id),
'latitude' => get_post_meta($venue_id, 'venue_latitude', true) ?: get_post_meta($venue_id, '_VenueLat', true),
'longitude' => get_post_meta($venue_id, 'venue_longitude', true) ?: get_post_meta($venue_id, '_VenueLng', true),
'status' => get_post_meta($venue_id, '_venue_geocoding_status', true),
'error' => get_post_meta($venue_id, '_venue_geocoding_error', true),
'last_attempt' => get_post_meta($venue_id, '_venue_geocoding_attempt', true),
'geocoded_date' => get_post_meta($venue_id, '_venue_geocoding_date', true)
];
}
/**
* Clear coordinates for a venue
*
* @param int $venue_id Venue post ID
*/
public function clear_coordinates(int $venue_id): void {
delete_post_meta($venue_id, 'venue_latitude');
delete_post_meta($venue_id, 'venue_longitude');
delete_post_meta($venue_id, '_VenueLat');
delete_post_meta($venue_id, '_VenueLng');
delete_post_meta($venue_id, '_venue_formatted_address');
delete_post_meta($venue_id, '_venue_geocoding_status');
delete_post_meta($venue_id, '_venue_geocoding_error');
delete_post_meta($venue_id, '_venue_geocoding_date');
}
}

View file

@ -222,35 +222,28 @@ class HVAC_Zoho_CRM_Auth {
return true;
}
// 3. Parse hostname from site URL for accurate comparison
$site_url = get_site_url();
$host = wp_parse_url($site_url, PHP_URL_HOST);
if (empty($host)) {
return true; // Can't determine host, default to staging for safety
}
// 4. Production: upskillhvac.com or www.upskillhvac.com
if ($host === 'upskillhvac.com' || $host === 'www.upskillhvac.com') {
return false;
}
// 5. Everything else is staging (including staging subdomains, cloudwaysapps, localhost, etc.)
// 3. Check for specific staging domains or keywords
if (strpos($site_url, 'staging') !== false ||
strpos($site_url, 'dev') !== false ||
strpos($site_url, 'test') !== false ||
strpos($site_url, 'cloudwaysapps.com') !== false) {
return true;
}
// 4. Default check: Production only on upskillhvac.com
return strpos($site_url, 'upskillhvac.com') === false;
}
/**
* Get details about how the mode was determined (for debugging)
*
* @return array Debug information
*/
public static function get_debug_mode_info() {
$site_url = get_site_url();
$host = wp_parse_url($site_url, PHP_URL_HOST);
$info = array(
'site_url' => $site_url,
'parsed_host' => $host,
'site_url' => get_site_url(),
'is_staging' => self::is_staging_mode(),
'forced_production' => defined('HVAC_ZOHO_PRODUCTION_MODE') && HVAC_ZOHO_PRODUCTION_MODE,
'forced_staging' => defined('HVAC_ZOHO_STAGING_MODE') && HVAC_ZOHO_STAGING_MODE,
@ -262,12 +255,20 @@ class HVAC_Zoho_CRM_Auth {
$info['detection_logic'][] = 'Forced PRODUCTION via HVAC_ZOHO_PRODUCTION_MODE constant';
} elseif ($info['forced_staging']) {
$info['detection_logic'][] = 'Forced STAGING via HVAC_ZOHO_STAGING_MODE constant';
} elseif (empty($host)) {
$info['detection_logic'][] = 'STAGING: Could not parse hostname from URL';
} elseif ($host === 'upskillhvac.com' || $host === 'www.upskillhvac.com') {
$info['detection_logic'][] = 'PRODUCTION: Hostname matches upskillhvac.com';
} else {
$info['detection_logic'][] = 'STAGING: Hostname "' . $host . '" is not upskillhvac.com';
$site_url = $info['site_url'];
if (strpos($site_url, 'staging') !== false) $info['detection_logic'][] = 'Matched "staging" in URL';
if (strpos($site_url, 'dev') !== false) $info['detection_logic'][] = 'Matched "dev" in URL';
if (strpos($site_url, 'test') !== false) $info['detection_logic'][] = 'Matched "test" in URL';
if (strpos($site_url, 'cloudwaysapps.com') !== false) $info['detection_logic'][] = 'Matched "cloudwaysapps.com" in URL';
if (empty($info['detection_logic'])) {
if (strpos($site_url, 'upskillhvac.com') === false) {
$info['detection_logic'][] = 'Default STAGING: URL does not contain "upskillhvac.com"';
} else {
$info['detection_logic'][] = 'Default PRODUCTION: URL contains "upskillhvac.com"';
}
}
}
return $info;
@ -282,7 +283,7 @@ class HVAC_Zoho_CRM_Auth {
// In staging mode, only allow read operations, no writes
if ($is_staging && in_array($method, array('POST', 'PUT', 'DELETE', 'PATCH'))) {
$this->log_debug('STAGING MODE: Blocked ' . $method . ' request to ' . $endpoint);
$this->log_debug('STAGING MODE: Simulating ' . $method . ' request to ' . $endpoint);
return array(
'data' => array(
array(
@ -290,8 +291,8 @@ class HVAC_Zoho_CRM_Auth {
'details' => array(
'message' => 'Staging mode active. Write operations are disabled.'
),
'message' => 'Blocked ' . $method . ' request to: ' . $endpoint,
'status' => 'skipped_staging'
'message' => 'This would have been a ' . $method . ' request to: ' . $endpoint,
'status' => 'success'
)
)
);

View file

@ -56,99 +56,6 @@ class HVAC_Zoho_Sync {
return !HVAC_Zoho_CRM_Auth::is_staging_mode();
}
/**
* Validate a Zoho API response to determine if the operation succeeded
*
* @param mixed $response Response from make_api_request()
* @return array ['success' => bool, 'id' => string|null, 'error' => string|null]
*/
private function validate_api_response($response) {
// Check for WP_Error
if (is_wp_error($response)) {
return array(
'success' => false,
'id' => null,
'error' => $response->get_error_message()
);
}
// Check for staging mode simulation
if (isset($response['data'][0]['code']) && $response['data'][0]['code'] === 'STAGING_MODE') {
return array(
'success' => false,
'id' => null,
'error' => 'Staging mode: write operations blocked'
);
}
// Check for Zoho API error codes FIRST (more specific than generic HTTP errors)
if (isset($response['data'][0]['code']) && !in_array($response['data'][0]['code'], array('SUCCESS', 'DUPLICATE_DATA'))) {
$error_msg = isset($response['data'][0]['message']) ? $response['data'][0]['message'] : $response['data'][0]['code'];
// Include field-level details if available
if (isset($response['data'][0]['details'])) {
$details = $response['data'][0]['details'];
if (isset($details['api_name'])) {
$error_msg .= ' (field: ' . $details['api_name'] . ')';
}
if (isset($details['expected_data_type'])) {
$error_msg .= ' (expected: ' . $details['expected_data_type'] . ')';
}
if (isset($details['info'])) {
$error_msg .= ' - ' . $details['info'];
}
}
return array(
'success' => false,
'id' => null,
'error' => $error_msg
);
}
// Check for HTTP-level errors (generic fallback)
if (isset($response['error'])) {
return array(
'success' => false,
'id' => null,
'error' => $response['error']
);
}
// Check for successful response with ID
if (isset($response['data'][0]['details']['id'])) {
return array(
'success' => true,
'id' => $response['data'][0]['details']['id'],
'error' => null
);
}
// Check for duplicate record handling (also a success case)
if (isset($response['data'][0]['code']) && $response['data'][0]['code'] === 'DUPLICATE_DATA'
&& isset($response['data'][0]['details']['duplicate_record']['id'])) {
return array(
'success' => true,
'id' => $response['data'][0]['details']['duplicate_record']['id'],
'error' => null
);
}
// Check for success status without ID (e.g., PUT updates)
if (isset($response['data'][0]['status']) && $response['data'][0]['status'] === 'success') {
return array(
'success' => true,
'id' => isset($response['data'][0]['details']['id']) ? $response['data'][0]['details']['id'] : null,
'error' => null
);
}
// Unknown response structure - treat as failure
return array(
'success' => false,
'id' => null,
'error' => 'Unexpected API response: ' . json_encode(array_slice($response, 0, 3))
);
}
/**
* Generate a hash for sync data to detect changes
*
@ -303,7 +210,6 @@ class HVAC_Zoho_Sync {
}
$campaign_id = null;
$sync_succeeded = false;
// FIRST: Check if we already have a stored Zoho Campaign ID
$stored_campaign_id = get_post_meta($event->ID, '_zoho_campaign_id', true);
@ -313,24 +219,20 @@ class HVAC_Zoho_Sync {
$update_response = $this->auth->make_api_request("/Campaigns/{$stored_campaign_id}", 'PUT', array(
'data' => array($campaign_data)
));
$validated = $this->validate_api_response($update_response);
// Check if update failed due to invalid ID (e.g. campaign deleted in Zoho)
if (!$validated['success'] && isset($update_response['code']) && $update_response['code'] === 'INVALID_DATA') {
if (isset($update_response['code']) && $update_response['code'] === 'INVALID_DATA') {
// Fallback: Create new campaign
$create_response = $this->auth->make_api_request('/Campaigns', 'POST', array(
'data' => array($campaign_data)
));
$validated = $this->validate_api_response($create_response);
$results['responses'][] = array('type' => 'create_fallback', 'id' => $event->ID, 'response' => $create_response);
if ($validated['success'] && $validated['id']) {
$campaign_id = $validated['id'];
$sync_succeeded = true;
if (!empty($create_response['data'][0]['details']['id'])) {
$campaign_id = $create_response['data'][0]['details']['id'];
}
} elseif ($validated['success']) {
} else {
$campaign_id = $stored_campaign_id;
$sync_succeeded = true;
$results['responses'][] = array('type' => 'update', 'id' => $event->ID, 'response' => $update_response);
}
} else {
@ -338,32 +240,25 @@ class HVAC_Zoho_Sync {
$create_response = $this->auth->make_api_request('/Campaigns', 'POST', array(
'data' => array($campaign_data)
));
$validated = $this->validate_api_response($create_response);
$results['responses'][] = array('type' => 'create', 'id' => $event->ID, 'response' => $create_response);
if ($validated['success'] && $validated['id']) {
$campaign_id = $validated['id'];
$sync_succeeded = true;
// Extract campaign ID from create response
if (!empty($create_response['data'][0]['details']['id'])) {
$campaign_id = $create_response['data'][0]['details']['id'];
}
}
if ($sync_succeeded) {
$results['synced']++;
// Only update hash and Zoho ID on confirmed success
// Update event meta with Zoho Campaign ID and sync hash
if (!empty($campaign_id)) {
update_post_meta($event->ID, '_zoho_campaign_id', $campaign_id);
}
update_post_meta($event->ID, '_zoho_sync_hash', $this->generate_sync_hash($campaign_data));
} else {
$results['failed']++;
$error_msg = isset($validated['error']) ? $validated['error'] : 'Unknown API error';
$results['errors'][] = sprintf('Event %s: %s', $event->ID, $error_msg);
}
} catch (\Throwable $e) {
} catch (Exception $e) {
$results['failed']++;
$results['errors'][] = sprintf('Event %s: [%s] %s', $event->ID, get_class($e), $e->getMessage());
$results['errors'][] = sprintf('Event %s: %s', $event->ID, $e->getMessage());
}
}
@ -463,14 +358,10 @@ class HVAC_Zoho_Sync {
continue;
}
$contact_id = null;
$sync_succeeded = false;
// Check if contact already exists in Zoho
$search_response = $this->auth->make_api_request(
'/Contacts/search?criteria=(Email:equals:' . urlencode($contact_data['Email']) . ')',
'GET'
);
$search_response = $this->auth->make_api_request('/Contacts/search', 'GET', array(
'criteria' => "(Email:equals:{$contact_data['Email']})"
));
if (!empty($search_response['data'])) {
// Update existing contact
@ -478,38 +369,28 @@ class HVAC_Zoho_Sync {
$update_response = $this->auth->make_api_request("/Contacts/{$contact_id}", 'PUT', array(
'data' => array($contact_data)
));
$validated = $this->validate_api_response($update_response);
$sync_succeeded = $validated['success'];
} else {
// Create new contact
$create_response = $this->auth->make_api_request('/Contacts', 'POST', array(
'data' => array($contact_data)
));
$validated = $this->validate_api_response($create_response);
$sync_succeeded = $validated['success'];
if ($validated['success'] && $validated['id']) {
$contact_id = $validated['id'];
if (!empty($create_response['data'][0]['details']['id'])) {
$contact_id = $create_response['data'][0]['details']['id'];
}
}
if ($sync_succeeded) {
$results['synced']++;
// Only update hash and Zoho ID on confirmed success
if (!empty($contact_id)) {
// Update user meta with Zoho ID and sync hash
if (isset($contact_id)) {
update_user_meta($user->ID, '_zoho_contact_id', $contact_id);
}
update_user_meta($user->ID, '_zoho_sync_hash', $this->generate_sync_hash($contact_data));
} else {
$results['failed']++;
$error_msg = isset($validated['error']) ? $validated['error'] : 'Unknown API error';
$results['errors'][] = sprintf('User %s: %s', $user->ID, $error_msg);
}
} catch (\Throwable $e) {
} catch (Exception $e) {
$results['failed']++;
$results['errors'][] = sprintf('User %s: [%s] %s', $user->ID, get_class($e), $e->getMessage());
$results['errors'][] = sprintf('User %s: %s', $user->ID, $e->getMessage());
}
}
@ -615,9 +496,6 @@ class HVAC_Zoho_Sync {
continue;
}
$invoice_id = null;
$sync_succeeded = false;
// Check if invoice already exists in Zoho (by WordPress Order ID)
$search_response = $this->auth->make_api_request(
'/Invoices/search?criteria=(WordPress_Order_ID:equals:' . $order->ID . ')',
@ -627,41 +505,31 @@ class HVAC_Zoho_Sync {
if (!empty($search_response['data'])) {
// Update existing invoice
$invoice_id = $search_response['data'][0]['id'];
$update_response = $this->auth->make_api_request("/Invoices/{$invoice_id}", 'PUT', array(
$this->auth->make_api_request("/Invoices/{$invoice_id}", 'PUT', array(
'data' => array($invoice_data)
));
$validated = $this->validate_api_response($update_response);
$sync_succeeded = $validated['success'];
} else {
// Create new invoice
$create_response = $this->auth->make_api_request('/Invoices', 'POST', array(
'data' => array($invoice_data)
));
$validated = $this->validate_api_response($create_response);
$sync_succeeded = $validated['success'];
if ($validated['success'] && $validated['id']) {
$invoice_id = $validated['id'];
if (!empty($create_response['data'][0]['details']['id'])) {
$invoice_id = $create_response['data'][0]['details']['id'];
}
}
if ($sync_succeeded) {
$results['synced']++;
// Only update hash and Zoho ID on confirmed success
if (!empty($invoice_id)) {
// Update order meta with Zoho ID and sync hash
if (isset($invoice_id)) {
update_post_meta($order->ID, '_zoho_invoice_id', $invoice_id);
}
update_post_meta($order->ID, '_zoho_sync_hash', $this->generate_sync_hash($invoice_data));
} else {
$results['failed']++;
$error_msg = isset($validated['error']) ? $validated['error'] : 'Unknown API error';
$results['errors'][] = sprintf('Order %s: %s', $order->ID, $error_msg);
}
} catch (\Throwable $e) {
} catch (Exception $e) {
$results['failed']++;
$results['errors'][] = sprintf('Order %s: [%s] %s', $order->ID, get_class($e), $e->getMessage());
$results['errors'][] = sprintf('Order %s: %s', $order->ID, $e->getMessage());
}
}
@ -819,18 +687,8 @@ class HVAC_Zoho_Sync {
$results['responses'][] = array('type' => 'debug', 'msg' => "Debug: Attendee {$attendee->ID} found Contact ID: " . $cid_debug);
}
// If contact creation failed, count as failed and skip hash update
if (!$contact_id) {
$results['failed']++;
$error_detail = $this->last_contact_error ?: 'Unknown error';
$results['errors'][] = sprintf('Attendee %s: No contact_id created. %s', $attendee->ID, $error_detail);
continue;
}
$attendee_sync_ok = true;
// Step 2: Create Campaign Member (link Contact to Campaign)
if (!empty($attendee_data['event_id'])) {
if ($contact_id && !empty($attendee_data['event_id'])) {
$campaign_id = get_post_meta($attendee_data['event_id'], '_zoho_campaign_id', true);
// Debug: Log event_id and campaign_id for troubleshooting
@ -874,6 +732,9 @@ class HVAC_Zoho_Sync {
} else {
$results['errors'][] = sprintf('Attendee %s: Event %s has no Zoho Campaign ID', $attendee->ID, $attendee_data['event_id']);
}
} elseif (!$contact_id) {
$error_detail = $this->last_contact_error ?: 'Unknown error';
$results['errors'][] = sprintf('Attendee %s: No contact_id created. %s', $attendee->ID, $error_detail);
} elseif (empty($attendee_data['event_id'])) {
// Debug: Log when event_id is missing
if (count($results['responses']) < 10) {
@ -881,10 +742,9 @@ class HVAC_Zoho_Sync {
}
}
// Contact was created/found successfully - count as synced
$results['synced']++;
// Only update hash on confirmed contact success
// Update attendee meta with Zoho Contact ID and sync hash
update_post_meta($attendee->ID, '_zoho_contact_id', $contact_id);
update_post_meta($attendee->ID, '_zoho_sync_hash', $this->generate_sync_hash($attendee_data));
@ -1006,17 +866,12 @@ class HVAC_Zoho_Sync {
// Step 1: Create/Update Lead
$lead_id = $this->ensure_lead_exists($rsvp_data);
if (!$lead_id) {
$results['failed']++;
$results['errors'][] = sprintf('RSVP %s: Failed to create/find lead', $rsvp->ID);
continue;
if ($lead_id) {
$results['leads_created']++;
}
$results['leads_created']++;
// Step 2: Create Campaign Member (link Lead to Campaign)
if (!empty($rsvp_data['event_id'])) {
if ($lead_id && !empty($rsvp_data['event_id'])) {
$campaign_id = get_post_meta($rsvp_data['event_id'], '_zoho_campaign_id', true);
if ($campaign_id) {
$assoc_response = $this->create_campaign_member($lead_id, $campaign_id, 'Responded', 'Leads');
@ -1037,13 +892,13 @@ class HVAC_Zoho_Sync {
$results['synced']++;
// Only update hash on confirmed lead creation success
// Update RSVP meta with Zoho Lead ID and sync hash
update_post_meta($rsvp->ID, '_zoho_lead_id', $lead_id);
update_post_meta($rsvp->ID, '_zoho_sync_hash', $this->generate_sync_hash($rsvp_data));
} catch (\Throwable $e) {
} catch (Exception $e) {
$results['failed']++;
$results['errors'][] = sprintf('RSVP %s: [%s] %s', $rsvp->ID, get_class($e), $e->getMessage());
$results['errors'][] = sprintf('RSVP %s: %s', $rsvp->ID, $e->getMessage());
}
}
@ -1348,25 +1203,11 @@ class HVAC_Zoho_Sync {
$role = 'Trainee';
}
// Last_Name is required by Zoho - fallback to display_name or username
$last_name = html_entity_decode(get_user_meta($user->ID, 'last_name', true), ENT_QUOTES | ENT_HTML5, 'UTF-8');
if (empty(trim($last_name))) {
$last_name = $user->display_name ?: $user->user_login;
}
// Sanitize phone: strip to digits and leading +, require 10+ digits for validity
$raw_phone = get_user_meta($user->ID, 'phone_number', true);
$phone_digits = preg_replace('/\D/', '', $raw_phone);
$phone = '';
if (strlen($phone_digits) >= 10) {
// Preserve leading + if present, otherwise just use digits
$phone = (strpos(trim($raw_phone), '+') === 0 ? '+' : '') . $phone_digits;
}
$data = array(
return array(
'First_Name' => html_entity_decode(get_user_meta($user->ID, 'first_name', true), ENT_QUOTES | ENT_HTML5, 'UTF-8'),
'Last_Name' => $last_name,
'Last_Name' => html_entity_decode(get_user_meta($user->ID, 'last_name', true), ENT_QUOTES | ENT_HTML5, 'UTF-8'),
'Email' => $user->user_email,
'Phone' => get_user_meta($user->ID, 'phone_number', true),
'Title' => html_entity_decode(get_user_meta($user->ID, 'hvac_professional_title', true), ENT_QUOTES | ENT_HTML5, 'UTF-8'),
'Company' => html_entity_decode(get_user_meta($user->ID, 'hvac_company_name', true), ENT_QUOTES | ENT_HTML5, 'UTF-8'),
'Lead_Source' => 'HVAC Community Events',
@ -1376,13 +1217,6 @@ class HVAC_Zoho_Sync {
'Years_Experience' => get_user_meta($user->ID, 'hvac_years_experience', true),
'Certification' => get_user_meta($user->ID, 'hvac_certifications', true)
);
// Only include Phone if we have a valid value (10+ digits)
if (!empty($phone)) {
$data['Phone'] = $phone;
}
return $data;
}
/**

View file

@ -1,409 +0,0 @@
<?php
/**
* Template Name: Find Training
*
* Google Maps-style full-screen layout with left sidebar panel
* for trainer directory and compact filter toolbar.
*
* @package HVAC_Community_Events
* @since 2.2.0
*/
defined('ABSPATH') || exit;
define('HVAC_IN_PAGE_TEMPLATE', true);
get_header();
// Get page handler instance
$find_training = HVAC_Find_Training_Page::get_instance();
$filter_options = $find_training->get_filter_options();
$api_key_configured = $find_training->is_api_key_configured();
?>
<div class="hvac-find-training-page">
<!-- Skip link for accessibility -->
<a href="#hvac-trainer-grid" class="hvac-skip-link">Skip to trainer results</a>
<!-- Compact Filter Bar -->
<div class="hvac-filter-bar" role="search" aria-label="Filter trainers and venues">
<div class="hvac-filter-bar-inner">
<!-- Search -->
<div class="hvac-filter-item hvac-filter-search">
<span class="dashicons dashicons-search" aria-hidden="true"></span>
<input type="text" id="hvac-training-search" placeholder="Search trainers..." aria-label="Search trainers">
</div>
<!-- Info Button -->
<button type="button" id="hvac-info-btn" class="hvac-info-btn" aria-label="How to use this page">
<span class="dashicons dashicons-info-outline" aria-hidden="true"></span>
</button>
<!-- Filter Dropdowns (hidden on mobile, shown in panel) -->
<div class="hvac-filter-dropdowns">
<select id="hvac-filter-state" class="hvac-filter-select" aria-label="Filter by state">
<option value="">All States</option>
<?php foreach ($filter_options['states'] as $state): ?>
<option value="<?php echo esc_attr($state); ?>"><?php echo esc_html($state); ?></option>
<?php endforeach; ?>
</select>
<select id="hvac-filter-certification" class="hvac-filter-select" aria-label="Filter by certification">
<option value="">All Certifications</option>
<?php foreach ($filter_options['certifications'] as $cert): ?>
<option value="<?php echo esc_attr($cert); ?>"><?php echo esc_html($cert); ?></option>
<?php endforeach; ?>
</select>
<select id="hvac-filter-format" class="hvac-filter-select" aria-label="Filter by training format">
<option value="">All Formats</option>
<?php foreach ($filter_options['training_formats'] as $format): ?>
<option value="<?php echo esc_attr($format); ?>"><?php echo esc_html($format); ?></option>
<?php endforeach; ?>
</select>
</div>
<!-- Near Me Button -->
<button type="button" id="hvac-near-me-btn" class="hvac-near-me-btn">
<span class="dashicons dashicons-location-alt" aria-hidden="true"></span>
<span class="hvac-btn-text">Near Me</span>
</button>
<!-- Include Past Events Checkbox -->
<label class="hvac-filter-checkbox">
<input type="checkbox" id="hvac-include-past">
<span>Include Past</span>
</label>
<!-- Clear Filters -->
<button type="button" class="hvac-clear-filters" style="display: none;">
Clear
</button>
<!-- Mobile Filter Toggle -->
<button type="button"
class="hvac-mobile-filter-toggle"
aria-expanded="false"
aria-controls="hvac-mobile-filter-panel">
<span class="dashicons dashicons-filter" aria-hidden="true"></span>
<span class="hvac-btn-text">Filters</span>
</button>
</div>
<!-- Active Filters Chips -->
<div class="hvac-active-filters" aria-live="polite"></div>
<!-- Mobile Filter Panel (hidden by default) -->
<div id="hvac-mobile-filter-panel" class="hvac-mobile-filter-panel" hidden>
<div class="hvac-mobile-filter-group">
<label for="hvac-filter-state-mobile">State / Province</label>
<select id="hvac-filter-state-mobile" class="hvac-filter-select" aria-label="Filter by state">
<option value="">All States</option>
<?php foreach ($filter_options['states'] as $state): ?>
<option value="<?php echo esc_attr($state); ?>"><?php echo esc_html($state); ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="hvac-mobile-filter-group">
<label for="hvac-filter-certification-mobile">Certification</label>
<select id="hvac-filter-certification-mobile" class="hvac-filter-select" aria-label="Filter by certification">
<option value="">All Certifications</option>
<?php foreach ($filter_options['certifications'] as $cert): ?>
<option value="<?php echo esc_attr($cert); ?>"><?php echo esc_html($cert); ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="hvac-mobile-filter-group">
<label for="hvac-filter-format-mobile">Training Format</label>
<select id="hvac-filter-format-mobile" class="hvac-filter-select" aria-label="Filter by training format">
<option value="">All Formats</option>
<?php foreach ($filter_options['training_formats'] as $format): ?>
<option value="<?php echo esc_attr($format); ?>"><?php echo esc_html($format); ?></option>
<?php endforeach; ?>
</select>
</div>
<div class="hvac-mobile-filter-group">
<label class="hvac-filter-checkbox">
<input type="checkbox" id="hvac-include-past-mobile">
<span>Include Past Events</span>
</label>
</div>
</div>
</div>
<!-- Main Content: Sidebar + Map -->
<div class="hvac-map-layout">
<!-- Left Sidebar -->
<aside class="hvac-sidebar" role="region" aria-label="Training directory">
<div class="hvac-sidebar-header">
<!-- Tab Navigation -->
<div class="hvac-sidebar-tabs" role="tablist" aria-label="Browse by category">
<button role="tab" class="hvac-tab active" data-tab="trainers" aria-selected="true" aria-controls="hvac-panel-trainers">
Trainers (<span data-count="trainers">0</span>)
</button>
<button role="tab" class="hvac-tab" data-tab="venues" aria-selected="false" aria-controls="hvac-panel-venues">
Venues (<span data-count="venues">0</span>)
</button>
<button role="tab" class="hvac-tab" data-tab="events" aria-selected="false" aria-controls="hvac-panel-events">
Events (<span data-count="events">0</span>)
</button>
</div>
<!-- Visibility Toggles (moved from map overlay) -->
<div class="hvac-visibility-toggles">
<label class="hvac-visibility-toggle" title="Show trainers on map">
<input type="checkbox" id="hvac-show-trainers" checked>
<span class="hvac-toggle-dot hvac-toggle-trainer"></span>
</label>
<label class="hvac-visibility-toggle" title="Show venues on map">
<input type="checkbox" id="hvac-show-venues" checked>
<span class="hvac-toggle-dot hvac-toggle-venue"></span>
</label>
<label class="hvac-visibility-toggle" title="Show events on map">
<input type="checkbox" id="hvac-show-events" checked>
<span class="hvac-toggle-dot hvac-toggle-event"></span>
</label>
</div>
<!-- Mobile collapse toggle -->
<button type="button"
class="hvac-sidebar-toggle"
aria-expanded="true"
aria-controls="hvac-sidebar-content"
aria-label="Toggle directory list">
<span class="dashicons dashicons-arrow-down-alt2" aria-hidden="true"></span>
</button>
</div>
<div id="hvac-sidebar-content" class="hvac-sidebar-content">
<!-- Trainers Panel -->
<div role="tabpanel" id="hvac-panel-trainers" class="hvac-tab-panel active" aria-labelledby="tab-trainers">
<div id="hvac-trainer-grid" class="hvac-item-list">
<div class="hvac-grid-loading">
<span class="dashicons dashicons-update-alt hvac-spin" aria-hidden="true"></span>
Loading trainers...
</div>
</div>
</div>
<!-- Venues Panel -->
<div role="tabpanel" id="hvac-panel-venues" class="hvac-tab-panel" aria-labelledby="tab-venues" hidden>
<div id="hvac-venue-grid" class="hvac-item-list"></div>
</div>
<!-- Events Panel -->
<div role="tabpanel" id="hvac-panel-events" class="hvac-tab-panel" aria-labelledby="tab-events" hidden>
<div id="hvac-event-grid" class="hvac-item-list"></div>
</div>
<!-- Load More Button -->
<div class="hvac-load-more-wrapper" style="display: none;">
<button type="button" id="hvac-load-more" class="hvac-btn-secondary">
Load More
</button>
</div>
<!-- CTA -->
<div class="hvac-sidebar-cta">
<p>Want to be listed in our directory?</p>
<a href="<?php echo esc_url(site_url('/trainer/registration/')); ?>" class="hvac-btn-primary hvac-btn-small">
Become A Trainer
</a>
</div>
</div>
</aside>
<!-- Map Container -->
<div class="hvac-map-container" role="region" aria-label="Training locations map">
<div id="hvac-training-map" class="hvac-google-map">
<?php if (!$api_key_configured): ?>
<div class="hvac-map-error">
<span class="dashicons dashicons-warning" aria-hidden="true"></span>
<p><strong>Map Configuration Required</strong></p>
<p>The Google Maps API key is not configured. <?php if (current_user_can('manage_options')): ?>Please configure it in <a href="<?php echo admin_url('admin.php?page=hvac-trainer-profile-settings'); ?>">Trainer Profile Settings</a>.<?php else: ?>Please contact the site administrator.<?php endif; ?></p>
</div>
<?php else: ?>
<div class="hvac-map-loading">
<span class="dashicons dashicons-location" aria-hidden="true"></span>
<p>Loading map...</p>
</div>
<?php endif; ?>
</div>
<!-- Map Legend Overlay -->
<div class="hvac-map-legend">
<div class="hvac-legend-item">
<span class="hvac-legend-marker hvac-legend-trainer" aria-hidden="true"></span>
<span>Trainer</span>
</div>
<div class="hvac-legend-item">
<span class="hvac-legend-marker hvac-legend-venue" aria-hidden="true"></span>
<span>Venue</span>
</div>
<div class="hvac-legend-item">
<span class="hvac-legend-marker hvac-legend-event" aria-hidden="true"></span>
<span>Event</span>
</div>
</div>
</div>
</div>
</div>
<!-- Trainer Profile Modal -->
<div id="hvac-trainer-modal" class="hvac-training-modal" style="display: none;" role="dialog" aria-modal="true" aria-labelledby="trainer-modal-title">
<div class="hvac-modal-overlay"></div>
<div class="hvac-modal-content">
<div class="hvac-modal-loading">
<span class="dashicons dashicons-update-alt hvac-spin" aria-hidden="true"></span>
Loading...
</div>
<div class="hvac-modal-body"></div>
</div>
</div>
<!-- Venue Info Modal -->
<div id="hvac-venue-modal" class="hvac-training-modal" style="display: none;" role="dialog" aria-modal="true" aria-labelledby="venue-modal-title">
<div class="hvac-modal-overlay"></div>
<div class="hvac-modal-content hvac-venue-modal-content">
<div class="hvac-venue-modal-header">
<h2 id="venue-modal-title"></h2>
<button class="hvac-modal-close" aria-label="Close modal">&times;</button>
</div>
<div class="hvac-venue-modal-body">
<p class="hvac-venue-address"></p>
<p class="hvac-venue-phone"></p>
<p class="hvac-venue-capacity"></p>
<div class="hvac-venue-description"></div>
<!-- Equipment Badges -->
<div class="hvac-venue-equipment" style="display: none;">
<h4>Equipment</h4>
<div class="hvac-badge-list hvac-equipment-badges"></div>
</div>
<!-- Amenities Badges -->
<div class="hvac-venue-amenities" style="display: none;">
<h4>Amenities</h4>
<div class="hvac-badge-list hvac-amenities-badges"></div>
</div>
<!-- Upcoming Events -->
<div class="hvac-venue-events">
<h4>Upcoming Events</h4>
<ul class="hvac-venue-events-list"></ul>
<p class="hvac-venue-no-events" style="display: none;">No upcoming events scheduled.</p>
</div>
<!-- Actions -->
<div class="hvac-venue-actions">
<a href="#" class="hvac-venue-directions hvac-btn-secondary" target="_blank" rel="noopener">
<span class="dashicons dashicons-location" aria-hidden="true"></span>
Get Directions
</a>
</div>
<!-- Contact Form -->
<div class="hvac-venue-contact-section">
<h4>Contact This Training Lab</h4>
<form class="hvac-venue-contact-form" data-venue-id="">
<div class="hvac-form-row">
<div class="hvac-form-group">
<label for="venue-contact-first-name">First Name <span class="required">*</span></label>
<input type="text" id="venue-contact-first-name" name="first_name" required>
</div>
<div class="hvac-form-group">
<label for="venue-contact-last-name">Last Name <span class="required">*</span></label>
<input type="text" id="venue-contact-last-name" name="last_name" required>
</div>
</div>
<div class="hvac-form-row">
<div class="hvac-form-group">
<label for="venue-contact-email">Email <span class="required">*</span></label>
<input type="email" id="venue-contact-email" name="email" required>
</div>
<div class="hvac-form-group">
<label for="venue-contact-phone">Phone</label>
<input type="tel" id="venue-contact-phone" name="phone">
</div>
</div>
<div class="hvac-form-group">
<label for="venue-contact-company">Company</label>
<input type="text" id="venue-contact-company" name="company">
</div>
<div class="hvac-form-group">
<label for="venue-contact-message">Message</label>
<textarea id="venue-contact-message" name="message" rows="4" placeholder="Tell us about your training needs..."></textarea>
</div>
<button type="submit" class="hvac-btn-primary">Send Message</button>
</form>
<div class="hvac-form-success" style="display: none;">
<span class="dashicons dashicons-yes-alt" aria-hidden="true"></span>
<p>Your message has been sent! The training lab will be in touch soon.</p>
</div>
<div class="hvac-form-error" style="display: none;">
<span class="dashicons dashicons-warning" aria-hidden="true"></span>
<p>There was a problem sending your message. Please try again.</p>
</div>
</div>
</div>
</div>
</div>
<!-- Info Modal -->
<div id="hvac-info-modal" class="hvac-training-modal" style="display: none;" role="dialog" aria-modal="true" aria-labelledby="info-modal-title">
<div class="hvac-modal-overlay"></div>
<div class="hvac-modal-content hvac-info-modal-content">
<div class="hvac-info-modal-header">
<h2 id="info-modal-title">Find Training Near You</h2>
<button class="hvac-modal-close" aria-label="Close modal">&times;</button>
</div>
<div class="hvac-info-modal-body">
<section class="hvac-info-section">
<h3>What is Upskill HVAC?</h3>
<p>Upskill HVAC is a community of certified trainers and training facilities dedicated to advancing skills in the HVAC industry. Our platform connects technicians with professional training opportunities across the country.</p>
</section>
<section class="hvac-info-section">
<h3>How to Use This Page</h3>
<ul class="hvac-info-list">
<li><strong>Browse by Category:</strong> Use the tabs above the list to switch between Trainers, Venues, and Events</li>
<li><strong>Search:</strong> Type in the search bar to filter results within the current tab</li>
<li><strong>Filter:</strong> Use the dropdown filters to narrow by state, certification, or format</li>
<li><strong>Near Me:</strong> Click the "Near Me" button to find training options close to your location</li>
<li><strong>Map Markers:</strong> Click on any marker to see details, or hover to preview</li>
<li><strong>Toggle Visibility:</strong> Use the colored dots in the header to show/hide marker types on the map</li>
</ul>
</section>
<section class="hvac-info-section">
<h3>Map Legend</h3>
<div class="hvac-info-legend">
<div class="hvac-info-legend-item">
<span class="hvac-legend-marker hvac-legend-trainer"></span>
<div>
<strong>Trainer</strong>
<p>Certified HVAC trainers who conduct training sessions</p>
</div>
</div>
<div class="hvac-info-legend-item">
<span class="hvac-legend-marker hvac-legend-venue"></span>
<div>
<strong>Training Lab</strong>
<p>measureQuick Approved facilities with professional equipment</p>
</div>
</div>
<div class="hvac-info-legend-item">
<span class="hvac-legend-marker hvac-legend-event"></span>
<div>
<strong>Event</strong>
<p>Scheduled training sessions you can register for</p>
</div>
</div>
</div>
</section>
</div>
</div>
</div>
<?php get_footer(); ?>