diff --git a/.gitignore b/.gitignore index 301c1485..27d0914a 100644 --- a/.gitignore +++ b/.gitignore @@ -30,6 +30,7 @@ /includes/* !/includes/admin/ !/includes/zoho/ +!/includes/find-training/ !/includes/**/*.php !/templates/ /templates/* diff --git a/Status.md b/Status.md index f070ac72..9db8f2f5 100644 --- a/Status.md +++ b/Status.md @@ -1,78 +1,176 @@ # HVAC Community Events - Project Status -**Last Updated:** January 31, 2026 -**Current Session:** Multi-Model Security Code Review - Complete -**Version:** 2.1.13 (Pending Staging Deployment) +**Last Updated:** February 1, 2026 +**Current Session:** Find Training Page Implementation - Complete +**Version:** 2.2.0 (Ready for Staging Deployment) --- -## 🎯 CURRENT SESSION - MULTI-MODEL SECURITY CODE REVIEW (Jan 31, 2026) +## 🎯 CURRENT SESSION - FIND TRAINING PAGE IMPLEMENTATION (Jan 31 - Feb 1, 2026) ### Status: ✅ **COMPLETE - Ready for Staging Deployment & E2E Testing** -**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). +**Objective:** Replace the buggy MapGeo-based `/find-a-trainer` page with a new `/find-training` page built from scratch using Google Maps JavaScript API. -### Review Methodology -- **Phase 1:** Security review with GPT-5, Gemini 3, Kimi K2.5 (parallel) -- **Phase 2:** Business logic review with GPT-5, Gemini 3, Kimi K2.5 (parallel) -- **Phase 3:** Zen OWASP audit, Architecture analysis, Code review synthesis (parallel) -- **Phase 4:** Consolidated findings report with consensus-based prioritization +### 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 -### Critical Issues Found & Fixed +### Files Created (8 new files) -| ID | Severity | Issue | File | Fix | -|----|----------|-------|------|-----| -| C1 | **CRITICAL** | Passwords stored in transients | `class-hvac-registration.php` | ✅ Strip passwords before storing | -| U1 | **CRITICAL** | O(3600) token verification loop (DoS) | `class-hvac-ajax-security.php` | ✅ Rewrote to O(1) with timestamp | -| U2 | **HIGH** | `remove_all_actions()` breaks WP isolation | `class-hvac-plugin.php` | ✅ Targeted hook removal only | -| C2 | **HIGH** | Encryption key in same database as data | `class-hvac-secure-storage.php` | ✅ Prefer wp-config.php constant | -| M3 | **HIGH** | Revoked certificates still downloadable | `class-certificate-manager.php` | ✅ Added revocation check | -| U3 | **HIGH** | Security headers not applied to AJAX | `class-hvac-ajax-security.php` | ✅ Fixed condition for AJAX | -| C3 | **MEDIUM** | IP spoofing undermines rate limiting | `class-hvac-security.php` | ✅ Trusted proxy validation | -| M1 | **MEDIUM** | Weak CSP with `unsafe-eval` | `class-hvac-ajax-security.php` | ✅ Removed `unsafe-eval` | -| C5 | **MEDIUM** | Duplicate component initialization | `class-hvac-plugin.php` | ✅ Removed duplicates | -| U9 | **MEDIUM** | File-scope side-effect initialization | `class-hvac-trainer-profile-manager.php` | ✅ Removed auto-init | -| U11 | **LOW** | Timezone inconsistency in cert numbers | `class-certificate-manager.php` | ✅ Use `current_time()` | -| U4 | **HIGH** | zoho-config.php not in .gitignore | `.gitignore` | ✅ Added pattern | +| 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 (8 files) -- `includes/class-hvac-registration.php` - Password stripping, secure token -- `includes/class-hvac-ajax-security.php` - O(1) tokens, AJAX headers, CSP -- `includes/class-hvac-plugin.php` - Targeted hooks, no duplicate init -- `includes/class-hvac-secure-storage.php` - wp-config.php key preference -- `includes/certificates/class-certificate-manager.php` - Revoked check, timezone -- `includes/class-hvac-security.php` - Trusted proxy IP validation -- `includes/class-hvac-trainer-profile-manager.php` - No file-scope init -- `.gitignore` - Added zoho-config.php +### Files Modified (3 files) -### Positive Security Patterns Confirmed -- Centralized AJAX security middleware -- Consistent input sanitization throughout -- Prepared SQL statements with `$wpdb->prepare()` -- WordPress native password functions -- Comprehensive audit logging +| 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 | -### Validated as Non-Issues -- **Path traversal in certificates** - Token-based system prevents exploitation -- **SQL injection** - Proper `$wpdb->prepare()` throughout -- **OAuth CSRF** - Correctly implemented with `hash_equals()` +### Multi-Model Code Review Findings & Fixes -### Deferred Items (Low Priority for Local Environment) -- AES-GCM authenticated encryption upgrade -- OAuth refresh token locking mechanism -- Atomic certificate number generation -- Singleton API naming standardization +Ran comprehensive code review using GPT-5, Gemini 3, and Zen MCP tools. Found and fixed 6 issues: -### Deliverables -1. ✅ **Full Report:** `MULTI-MODEL-CODE-REVIEW-REPORT.md` -2. ✅ **12 Security Fixes:** All implemented and syntax-verified -3. ✅ **PHP Syntax Validation:** All 7 modified files pass `php -l` +| # | 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 ### Next Steps -1. Deploy to staging: `./scripts/deploy.sh staging` -2. Run E2E tests: `HEADLESS=true node test-comprehensive-validation.js` -3. Verify all functionality works correctly -4. Deploy to production after E2E validation +1. ⏳ Deploy to staging: `./scripts/deploy.sh staging` +2. ⏳ Run E2E tests on Find Training page +3. ⏳ Verify map loads with markers +4. ⏳ Test filters and geolocation +5. ⏳ Verify contact form sends email +6. ⏳ Deploy to production after validation + +--- + +## 📋 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 --- diff --git a/assets/css/find-training-map.css b/assets/css/find-training-map.css new file mode 100644 index 00000000..37631692 --- /dev/null +++ b/assets/css/find-training-map.css @@ -0,0 +1,973 @@ +/** + * Find Training Page Styles + * + * Styles for the Find Training page with Google Maps integration. + * + * @package HVAC_Community_Events + * @since 2.2.0 + */ + +/* ========================================================================== + Page Layout + ========================================================================== */ + +.hvac-find-training-page { + padding: 30px 0 60px; +} + +.hvac-find-training-page .ast-container { + max-width: 1400px; +} + +.hvac-find-training-page .hvac-page-title { + color: #164B60; + font-size: 2.5rem; + font-weight: 700; + margin-bottom: 20px; + text-align: center; +} + +/* Intro Section */ +.hvac-find-training-intro { + max-width: 900px; + margin: 0 auto 30px; + text-align: center; + color: #333; + line-height: 1.7; +} + +.hvac-find-training-intro p { + margin-bottom: 12px; +} + +/* ========================================================================== + Map and Filters Layout + ========================================================================== */ + +.hvac-map-filters-wrapper { + display: grid; + grid-template-columns: 2fr 1fr; + gap: 30px; + margin-bottom: 40px; +} + +@media (max-width: 991px) { + .hvac-map-filters-wrapper { + grid-template-columns: 1fr; + } +} + +/* ========================================================================== + Map Section + ========================================================================== */ + +.hvac-map-section { + position: relative; +} + +.hvac-google-map { + width: 100%; + height: 500px; + background: #f5f5f5; + border-radius: 8px; + overflow: hidden; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); +} + +.hvac-map-loading { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; + color: #666; +} + +.hvac-map-loading .dashicons { + font-size: 48px; + width: 48px; + height: 48px; + margin-bottom: 15px; + color: #00b3a4; +} + +/* Map Legend */ +.hvac-map-legend { + display: flex; + gap: 20px; + padding: 12px 16px; + background: #fff; + border-radius: 0 0 8px 8px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.08); +} + +.hvac-legend-item { + display: flex; + align-items: center; + gap: 8px; + font-size: 14px; + color: #555; +} + +.hvac-legend-marker { + width: 16px; + height: 16px; + border-radius: 50%; +} + +.hvac-legend-trainer { + background: #00b3a4; +} + +.hvac-legend-venue { + background: #f5a623; +} + +/* ========================================================================== + Filters Section + ========================================================================== */ + +.hvac-filters-section { + background: #fff; + border-radius: 8px; + padding: 24px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1); +} + +/* Search Box */ +.hvac-search-box { + position: relative; + margin-bottom: 16px; +} + +.hvac-search-input { + width: 100%; + padding: 12px 40px 12px 16px; + font-size: 15px; + border: 1px solid #ddd; + border-radius: 6px; + transition: border-color 0.2s, box-shadow 0.2s; +} + +.hvac-search-input:focus { + outline: none; + border-color: #00b3a4; + box-shadow: 0 0 0 3px rgba(0, 179, 164, 0.1); +} + +.hvac-search-box .dashicons { + position: absolute; + right: 12px; + top: 50%; + transform: translateY(-50%); + color: #999; +} + +/* Near Me Button */ +.hvac-near-me-btn { + width: 100%; + padding: 12px 16px; + background: #164B60; + color: #fff; + border: none; + border-radius: 6px; + font-size: 15px; + font-weight: 600; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + margin-bottom: 20px; + transition: background 0.2s; +} + +.hvac-near-me-btn:hover { + background: #1a5a73; +} + +.hvac-near-me-btn:disabled { + background: #ccc; + cursor: not-allowed; +} + +.hvac-near-me-btn .dashicons { + font-size: 18px; +} + +/* Filters Header */ +.hvac-filters-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 16px; +} + +.hvac-filters-label { + font-weight: 600; + color: #333; +} + +.hvac-clear-filters { + background: none; + border: none; + color: #00b3a4; + font-size: 13px; + cursor: pointer; + padding: 0; +} + +.hvac-clear-filters:hover { + text-decoration: underline; +} + +/* Filter Groups */ +.hvac-filter-group { + margin-bottom: 16px; +} + +.hvac-filter-group label { + display: block; + font-size: 13px; + font-weight: 500; + color: #555; + margin-bottom: 6px; +} + +.hvac-filter-select { + width: 100%; + padding: 10px 12px; + font-size: 14px; + border: 1px solid #ddd; + border-radius: 6px; + background: #fff; + cursor: pointer; +} + +.hvac-filter-select:focus { + outline: none; + border-color: #00b3a4; +} + +/* Marker Toggles */ +.hvac-marker-toggles { + margin: 20px 0; + padding: 16px 0; + border-top: 1px solid #eee; + border-bottom: 1px solid #eee; +} + +.hvac-toggle { + display: flex; + align-items: center; + cursor: pointer; + margin-bottom: 10px; +} + +.hvac-toggle:last-child { + margin-bottom: 0; +} + +.hvac-toggle input { + position: absolute; + opacity: 0; + pointer-events: none; +} + +.hvac-toggle-slider { + width: 40px; + height: 22px; + background: #ccc; + border-radius: 11px; + position: relative; + transition: background 0.2s; + margin-right: 10px; + flex-shrink: 0; +} + +.hvac-toggle-slider::after { + content: ''; + position: absolute; + top: 2px; + left: 2px; + width: 18px; + height: 18px; + background: #fff; + border-radius: 50%; + transition: transform 0.2s; +} + +.hvac-toggle input:checked + .hvac-toggle-slider { + background: #00b3a4; +} + +.hvac-toggle input:checked + .hvac-toggle-slider::after { + transform: translateX(18px); +} + +.hvac-toggle-label { + font-size: 14px; + color: #333; +} + +/* Active Filters */ +.hvac-active-filters { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 16px; +} + +.hvac-active-filter { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 6px 10px; + background: #e8f5f4; + color: #00736a; + border-radius: 20px; + font-size: 13px; +} + +.hvac-active-filter button { + background: none; + border: none; + color: inherit; + cursor: pointer; + padding: 0; + line-height: 1; +} + +/* Results Count */ +.hvac-results-count { + text-align: center; + color: #666; + font-size: 14px; + padding-top: 16px; + border-top: 1px solid #eee; +} + +/* ========================================================================== + Trainer Directory Grid + ========================================================================== */ + +.hvac-trainer-directory-section { + margin-bottom: 40px; +} + +.hvac-trainer-directory-section h2 { + color: #164B60; + font-size: 1.75rem; + margin-bottom: 24px; +} + +.hvac-trainer-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 24px; +} + +.hvac-grid-loading { + grid-column: 1 / -1; + text-align: center; + padding: 40px; + color: #666; +} + +.hvac-spin { + animation: hvac-spin 1s linear infinite; +} + +@keyframes hvac-spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} + +/* Trainer Card */ +.hvac-trainer-card { + background: #fff; + border-radius: 8px; + padding: 20px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08); + cursor: pointer; + transition: transform 0.2s, box-shadow 0.2s; + display: flex; + gap: 16px; +} + +.hvac-trainer-card:hover { + transform: translateY(-2px); + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.12); +} + +.hvac-trainer-card-image { + width: 80px; + height: 80px; + flex-shrink: 0; + position: relative; +} + +.hvac-trainer-card-image img { + width: 100%; + height: 100%; + object-fit: cover; + border-radius: 50%; +} + +.hvac-trainer-card-avatar { + width: 100%; + height: 100%; + background: #e8f5f4; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; +} + +.hvac-trainer-card-avatar .dashicons { + font-size: 32px; + color: #00b3a4; +} + +.hvac-mq-badge-small { + position: absolute; + bottom: 0; + right: 0; + width: 24px; + height: 24px; +} + +.hvac-trainer-card-info { + flex: 1; + min-width: 0; +} + +.hvac-trainer-card-name { + font-weight: 600; + color: #164B60; + margin-bottom: 4px; + font-size: 16px; +} + +.hvac-trainer-card-location { + color: #666; + font-size: 14px; + margin-bottom: 8px; +} + +.hvac-trainer-card-certs { + display: flex; + flex-wrap: wrap; + gap: 6px; +} + +.hvac-cert-badge { + display: inline-block; + padding: 4px 8px; + background: #e8f5f4; + color: #00736a; + border-radius: 4px; + font-size: 12px; + font-weight: 500; +} + +/* Load More */ +.hvac-load-more-wrapper { + text-align: center; + margin-top: 24px; +} + +/* ========================================================================== + CTA Section + ========================================================================== */ + +.hvac-cta-section { + text-align: center; + padding: 40px; + background: #f8fafa; + border-radius: 8px; +} + +.hvac-cta-section p { + font-size: 18px; + color: #333; + margin-bottom: 20px; +} + +/* ========================================================================== + Buttons + ========================================================================== */ + +.hvac-btn-primary { + display: inline-block; + padding: 14px 28px; + background: #00b3a4; + color: #fff; + border: none; + border-radius: 6px; + font-size: 16px; + font-weight: 600; + text-decoration: none; + cursor: pointer; + transition: background 0.2s; +} + +.hvac-btn-primary:hover { + background: #009688; + color: #fff; +} + +.hvac-btn-secondary { + display: inline-block; + padding: 12px 24px; + background: #fff; + color: #164B60; + border: 2px solid #164B60; + border-radius: 6px; + font-size: 15px; + font-weight: 600; + text-decoration: none; + cursor: pointer; + transition: all 0.2s; +} + +.hvac-btn-secondary:hover { + background: #164B60; + color: #fff; +} + +/* ========================================================================== + Modal Styles + ========================================================================== */ + +.hvac-training-modal { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + z-index: 99999; + display: flex; + align-items: center; + justify-content: center; + padding: 20px; +} + +.hvac-modal-overlay { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: rgba(0, 0, 0, 0.5); +} + +.hvac-modal-content { + position: relative; + background: #fff; + border-radius: 12px; + max-width: 600px; + width: 100%; + max-height: 90vh; + overflow-y: auto; + box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2); +} + +.hvac-modal-loading { + padding: 60px; + text-align: center; + color: #666; +} + +.hvac-modal-close { + position: absolute; + top: 16px; + right: 16px; + background: #f5f5f5; + border: none; + width: 36px; + height: 36px; + border-radius: 50%; + font-size: 24px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + z-index: 10; +} + +.hvac-modal-close:hover { + background: #eee; +} + +/* Trainer Modal Content */ +.hvac-training-modal-header { + padding: 24px 24px 0; +} + +.hvac-training-modal-header h2 { + color: #164B60; + font-size: 1.5rem; + margin: 0; + padding-right: 40px; +} + +.hvac-training-modal-body { + padding: 24px; +} + +.hvac-training-profile-section { + margin-bottom: 24px; +} + +.hvac-training-profile-header { + display: flex; + gap: 20px; + margin-bottom: 20px; +} + +.hvac-training-profile-image { + width: 100px; + height: 100px; + border-radius: 50%; + object-fit: cover; +} + +.hvac-training-profile-avatar { + width: 100px; + height: 100px; + background: #e8f5f4; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; +} + +.hvac-training-profile-avatar .dashicons { + font-size: 48px; + color: #00b3a4; +} + +.hvac-training-profile-info { + flex: 1; +} + +.hvac-training-location { + color: #666; + margin-bottom: 8px; +} + +.hvac-training-certifications { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-bottom: 8px; +} + +.hvac-training-company { + color: #555; + margin-bottom: 8px; +} + +.hvac-training-events-count { + color: #333; +} + +.hvac-training-detail { + padding: 10px 0; + border-bottom: 1px solid #eee; +} + +.hvac-training-events { + margin-top: 20px; +} + +.hvac-training-events h4 { + color: #164B60; + margin-bottom: 12px; +} + +.hvac-events-list { + list-style: none; + padding: 0; + margin: 0; +} + +.hvac-events-list li { + padding: 8px 0; + border-bottom: 1px solid #eee; +} + +.hvac-events-list li:last-child { + border-bottom: none; +} + +.hvac-events-list a { + color: #00b3a4; + text-decoration: none; +} + +.hvac-events-list a:hover { + text-decoration: underline; +} + +.hvac-event-date { + display: block; + font-size: 13px; + color: #666; + margin-top: 2px; +} + +/* Contact Form in Modal */ +.hvac-training-contact-section { + border-top: 1px solid #eee; + padding-top: 24px; +} + +.hvac-training-contact-section h4 { + color: #164B60; + margin-bottom: 16px; +} + +.hvac-training-contact-form .hvac-form-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; + margin-bottom: 12px; +} + +.hvac-training-contact-form .hvac-form-field { + margin-bottom: 12px; +} + +.hvac-training-contact-form input, +.hvac-training-contact-form textarea { + width: 100%; + padding: 10px 12px; + font-size: 14px; + border: 1px solid #ddd; + border-radius: 6px; +} + +.hvac-training-contact-form input:focus, +.hvac-training-contact-form textarea:focus { + outline: none; + border-color: #00b3a4; +} + +.hvac-form-message { + padding: 12px 16px; + border-radius: 6px; + margin-top: 16px; +} + +.hvac-form-success { + background: #d4edda; + color: #155724; +} + +.hvac-form-error { + background: #f8d7da; + color: #721c24; +} + +/* Venue Modal */ +.hvac-venue-modal-header { + padding: 24px 24px 0; +} + +.hvac-venue-modal-header h2 { + color: #164B60; + font-size: 1.5rem; + margin: 0; + padding-right: 40px; +} + +.hvac-venue-modal-body { + padding: 24px; +} + +.hvac-venue-address { + color: #666; + margin-bottom: 20px; +} + +.hvac-venue-events h4 { + color: #164B60; + margin-bottom: 12px; +} + +.hvac-venue-events-list { + list-style: none; + padding: 0; + margin: 0 0 20px; +} + +.hvac-venue-events-list li { + padding: 8px 0; + border-bottom: 1px solid #eee; +} + +.hvac-venue-directions { + display: inline-flex; + align-items: center; + gap: 6px; +} + +/* ========================================================================== + Google Maps Info Windows + ========================================================================== */ + +.hvac-info-window { + padding: 12px; + max-width: 280px; +} + +.hvac-info-window-title { + font-weight: 600; + color: #164B60; + margin-bottom: 6px; + font-size: 15px; +} + +.hvac-info-window-location { + color: #666; + font-size: 13px; + margin-bottom: 8px; +} + +.hvac-info-window-cert { + display: inline-block; + padding: 3px 8px; + background: #e8f5f4; + color: #00736a; + border-radius: 4px; + font-size: 12px; + margin-bottom: 10px; +} + +.hvac-info-window-btn { + display: inline-block; + padding: 8px 16px; + background: #00b3a4; + color: #fff; + border: none; + border-radius: 4px; + font-size: 13px; + font-weight: 500; + cursor: pointer; + text-decoration: none; +} + +.hvac-info-window-btn:hover { + background: #009688; +} + +/* Venue Info Window */ +.hvac-info-window-venue { + padding: 12px; + max-width: 250px; +} + +.hvac-info-window-address { + color: #666; + font-size: 13px; + margin-bottom: 8px; +} + +.hvac-info-window-events-count { + font-size: 13px; + color: #00736a; + margin-bottom: 10px; +} + +/* ========================================================================== + Responsive Styles + ========================================================================== */ + +@media (max-width: 768px) { + .hvac-find-training-page { + padding: 20px 0 40px; + } + + .hvac-find-training-page .hvac-page-title { + font-size: 1.75rem; + } + + .hvac-google-map { + height: 350px; + } + + .hvac-trainer-card { + flex-direction: column; + align-items: center; + text-align: center; + } + + .hvac-trainer-card-certs { + justify-content: center; + } + + .hvac-training-profile-header { + flex-direction: column; + align-items: center; + text-align: center; + } + + .hvac-training-contact-form .hvac-form-row { + grid-template-columns: 1fr; + } + + .hvac-modal-content { + max-height: 85vh; + } +} + +@media (max-width: 480px) { + .hvac-map-legend { + flex-direction: column; + gap: 10px; + } + + .hvac-trainer-grid { + grid-template-columns: 1fr; + } +} + +/* ========================================================================== + Location Error Message + ========================================================================== */ + +.hvac-location-error { + display: flex; + align-items: center; + gap: 8px; + margin-top: 10px; + padding: 10px 14px; + background: #fef2f2; + border: 1px solid #fecaca; + border-radius: 6px; + color: #991b1b; + font-size: 0.875rem; +} + +.hvac-location-error .dashicons { + color: #dc2626; + flex-shrink: 0; +} + +.hvac-location-error .hvac-dismiss-error { + margin-left: auto; + background: none; + border: none; + color: #991b1b; + cursor: pointer; + font-size: 1.25rem; + line-height: 1; + padding: 0; + opacity: 0.7; +} + +.hvac-location-error .hvac-dismiss-error:hover { + opacity: 1; +} diff --git a/assets/images/marker-trainer.svg b/assets/images/marker-trainer.svg new file mode 100644 index 00000000..507fdbd5 Binary files /dev/null and b/assets/images/marker-trainer.svg differ diff --git a/assets/images/marker-venue.svg b/assets/images/marker-venue.svg new file mode 100644 index 00000000..4ca8337b Binary files /dev/null and b/assets/images/marker-venue.svg differ diff --git a/assets/js/find-training-filters.js b/assets/js/find-training-filters.js new file mode 100644 index 00000000..23638985 --- /dev/null +++ b/assets/js/find-training-filters.js @@ -0,0 +1,388 @@ +/** + * 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: '' + }, + + // User location (if obtained) + userLocation: null, + + /** + * Initialize filters + */ + init: function() { + this.bindEvents(); + }, + + /** + * Bind event handlers + */ + bindEvents: function() { + const self = this; + + // Search input with debounce + $('#hvac-training-search').on('input', function() { + clearTimeout(self.searchTimer); + const value = $(this).val(); + + self.searchTimer = setTimeout(function() { + self.activeFilters.search = value; + self.applyFilters(); + }, 300); + }); + + // 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(); + }); + + // 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') + }; + + // 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.updateMarkers(); + HVACTrainingMap.updateCounts( + response.data.total_trainers, + response.data.total_venues + ); + HVACTrainingMap.updateTrainerGrid(); + } + }, + 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(' Locating...'); + + // 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(' Near Me'); + $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(' Near Me'); + $button.prop('disabled', false); + } + }); + }, + + /** + * Show location error message inline + */ + showLocationError: function(message) { + // Remove any existing error + this.clearLocationError(); + + // Create error element + const $error = $('
' + + ' ' + + this.escapeHtml(message) + + '' + + '
'); + + // 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: '' + }; + + // 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(''); + + // Reset Near Me button + $('#hvac-near-me-btn') + .removeClass('active') + .html(' Near Me') + .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(' Near Me') + .prop('disabled', 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'); + } + + this.updateClearButtonVisibility(); + }, + + /** + * Add an active filter chip + */ + addActiveFilter: function(type, label) { + const $container = $('.hvac-active-filters'); + const $chip = $(` + + ${this.escapeHtml(label)} + + + `); + $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.userLocation; + + if (hasFilters) { + $('.hvac-clear-filters').show(); + } else { + $('.hvac-clear-filters').hide(); + } + }, + + /** + * Escape HTML for safe output + */ + escapeHtml: function(text) { + if (!text) return ''; + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; + } + }; + + // Initialize when document is ready + $(document).ready(function() { + if ($('#hvac-training-map').length) { + HVACTrainingFilters.init(); + } + }); + +})(jQuery); diff --git a/assets/js/find-training-map.js b/assets/js/find-training-map.js new file mode 100644 index 00000000..695189d3 --- /dev/null +++ b/assets/js/find-training-map.js @@ -0,0 +1,833 @@ +/** + * Find Training Map - Google Maps Integration + * + * Handles Google Maps initialization, markers, clustering, + * and interaction for the Find Training page. + * + * @package HVAC_Community_Events + * @since 2.2.0 + */ + +(function($) { + 'use strict'; + + // Namespace for the module + window.HVACTrainingMap = { + + // Map instance + map: null, + + // Marker collections + trainerMarkers: [], + venueMarkers: [], + + // MarkerClusterer instance + markerClusterer: null, + + // Info window instance (reused) + infoWindow: null, + + // Current data + trainers: [], + venues: [], + + // Configuration + config: { + mapElementId: 'hvac-training-map', + defaultCenter: { lat: 39.8283, lng: -98.5795 }, + defaultZoom: 4, + clusterZoom: 8 + }, + + /** + * Initialize the map + */ + init: function() { + const self = this; + + // Check if Google Maps is loaded + if (typeof google === 'undefined' || typeof google.maps === 'undefined') { + console.error('Google Maps API not loaded'); + this.showMapError('Google Maps failed to load. Please refresh the page.'); + return; + } + + // Override config with localized data + if (typeof hvacFindTraining !== 'undefined') { + if (hvacFindTraining.map_center) { + this.config.defaultCenter = hvacFindTraining.map_center; + } + if (hvacFindTraining.default_zoom) { + this.config.defaultZoom = parseInt(hvacFindTraining.default_zoom); + } + } + + // Create the map + this.createMap(); + + // Create info window + this.infoWindow = new google.maps.InfoWindow(); + + // Load initial data + this.loadMapData(); + + // Bind events + this.bindEvents(); + }, + + /** + * Create the Google Map instance + */ + createMap: function() { + const mapElement = document.getElementById(this.config.mapElementId); + + if (!mapElement) { + console.error('Map element not found'); + return; + } + + // Remove loading indicator + mapElement.innerHTML = ''; + + // Create map with options + this.map = new google.maps.Map(mapElement, { + center: this.config.defaultCenter, + zoom: this.config.defaultZoom, + mapTypeControl: true, + mapTypeControlOptions: { + position: google.maps.ControlPosition.TOP_RIGHT + }, + streetViewControl: false, + fullscreenControl: true, + zoomControl: true, + zoomControlOptions: { + position: google.maps.ControlPosition.RIGHT_CENTER + }, + styles: this.getMapStyles() + }); + + // Close info window on map click + this.map.addListener('click', () => { + this.infoWindow.close(); + }); + }, + + /** + * Get custom map styles + */ + getMapStyles: function() { + return [ + { + featureType: 'poi', + elementType: 'labels', + stylers: [{ visibility: 'off' }] + }, + { + featureType: 'transit', + elementType: 'labels', + stylers: [{ visibility: 'off' }] + } + ]; + }, + + /** + * Load map data via AJAX + */ + loadMapData: function(filters) { + const self = this; + + const data = { + action: 'hvac_get_training_map_data', + nonce: hvacFindTraining.nonce + }; + + // Add filters if provided + if (filters) { + Object.assign(data, filters); + } + + $.ajax({ + url: hvacFindTraining.ajax_url, + type: 'POST', + data: data, + beforeSend: function() { + self.showLoading(); + }, + success: function(response) { + if (response.success) { + self.trainers = response.data.trainers || []; + self.venues = response.data.venues || []; + + self.updateMarkers(); + self.updateCounts(response.data.total_trainers, response.data.total_venues); + self.updateTrainerGrid(); + } else { + self.showMapError(response.data?.message || 'Failed to load data'); + } + }, + error: function() { + self.showMapError('Network error. Please try again.'); + }, + complete: function() { + self.hideLoading(); + } + }); + }, + + /** + * Update markers on the map + */ + updateMarkers: function() { + // Clear existing markers + this.clearMarkers(); + + // Check toggle states + const showTrainers = $('#hvac-show-trainers').is(':checked'); + const showVenues = $('#hvac-show-venues').is(':checked'); + + // Add trainer markers + if (showTrainers && this.trainers.length > 0) { + this.trainers.forEach(trainer => { + if (trainer.lat && trainer.lng) { + this.addTrainerMarker(trainer); + } + }); + } + + // Add venue markers + if (showVenues && this.venues.length > 0) { + this.venues.forEach(venue => { + if (venue.lat && venue.lng) { + this.addVenueMarker(venue); + } + }); + } + + // Initialize clustering + this.initClustering(); + + // Fit bounds if we have markers + this.fitBounds(); + }, + + /** + * Add a trainer marker + */ + addTrainerMarker: function(trainer) { + const self = this; + + // Create marker with custom icon + const marker = new google.maps.Marker({ + position: { lat: trainer.lat, lng: trainer.lng }, + map: this.map, + title: trainer.name, + icon: this.getTrainerIcon(), + optimized: true + }); + + // Store trainer data on marker + marker.trainerData = trainer; + marker.markerType = 'trainer'; + + // Add click listener + marker.addListener('click', function() { + self.showTrainerInfoWindow(this); + }); + + this.trainerMarkers.push(marker); + }, + + /** + * Add a venue marker + */ + addVenueMarker: function(venue) { + const self = this; + + // Create marker with custom icon + const marker = new google.maps.Marker({ + position: { lat: venue.lat, lng: venue.lng }, + map: this.map, + title: venue.name, + icon: this.getVenueIcon(), + optimized: true + }); + + // Store venue data on marker + marker.venueData = venue; + marker.markerType = 'venue'; + + // Add click listener + marker.addListener('click', function() { + self.showVenueInfoWindow(this); + }); + + this.venueMarkers.push(marker); + }, + + /** + * Get trainer marker icon + */ + getTrainerIcon: function() { + // Use custom icon if available, otherwise use SVG circle + if (hvacFindTraining.marker_icons?.trainer) { + return { + url: hvacFindTraining.marker_icons.trainer, + scaledSize: new google.maps.Size(32, 32) + }; + } + + // SVG circle marker (teal) + return { + path: google.maps.SymbolPath.CIRCLE, + fillColor: '#00b3a4', + fillOpacity: 1, + strokeColor: '#ffffff', + strokeWeight: 2, + scale: 10 + }; + }, + + /** + * Get venue marker icon + */ + getVenueIcon: function() { + // Use custom icon if available, otherwise use SVG marker + if (hvacFindTraining.marker_icons?.venue) { + return { + url: hvacFindTraining.marker_icons.venue, + scaledSize: new google.maps.Size(32, 32) + }; + } + + // SVG marker (orange) + return { + path: google.maps.SymbolPath.BACKWARD_CLOSED_ARROW, + fillColor: '#f5a623', + fillOpacity: 1, + strokeColor: '#ffffff', + strokeWeight: 2, + scale: 6 + }; + }, + + /** + * Initialize marker clustering + */ + initClustering: function() { + // Clear existing clusterer + if (this.markerClusterer) { + this.markerClusterer.clearMarkers(); + } + + // Combine all markers + const allMarkers = [...this.trainerMarkers, ...this.venueMarkers]; + + if (allMarkers.length === 0) { + return; + } + + // Check if MarkerClusterer is available + if (typeof markerClusterer !== 'undefined' && markerClusterer.MarkerClusterer) { + this.markerClusterer = new markerClusterer.MarkerClusterer({ + map: this.map, + markers: allMarkers, + algorithmOptions: { + maxZoom: this.config.clusterZoom + } + }); + } + }, + + /** + * Clear all markers from the map + */ + clearMarkers: function() { + // Clear trainer markers + this.trainerMarkers.forEach(marker => marker.setMap(null)); + this.trainerMarkers = []; + + // Clear venue markers + this.venueMarkers.forEach(marker => marker.setMap(null)); + this.venueMarkers = []; + + // Clear clusterer + if (this.markerClusterer) { + this.markerClusterer.clearMarkers(); + } + }, + + /** + * Fit map bounds to show all markers + */ + fitBounds: function() { + const allMarkers = [...this.trainerMarkers, ...this.venueMarkers]; + + if (allMarkers.length === 0) { + // Reset to default view + this.map.setCenter(this.config.defaultCenter); + this.map.setZoom(this.config.defaultZoom); + return; + } + + if (allMarkers.length === 1) { + // Single marker - center on it + this.map.setCenter(allMarkers[0].getPosition()); + this.map.setZoom(10); + return; + } + + // Calculate bounds + const bounds = new google.maps.LatLngBounds(); + allMarkers.forEach(marker => { + bounds.extend(marker.getPosition()); + }); + + this.map.fitBounds(bounds, { padding: 50 }); + }, + + /** + * Show trainer info window + */ + showTrainerInfoWindow: function(marker) { + const self = this; + const trainer = marker.trainerData; + + // Create DOM elements safely to avoid XSS + const container = document.createElement('div'); + container.className = 'hvac-info-window'; + + const title = document.createElement('div'); + title.className = 'hvac-info-window-title'; + title.textContent = trainer.name; + container.appendChild(title); + + const location = document.createElement('div'); + location.className = 'hvac-info-window-location'; + location.textContent = (trainer.city || '') + ', ' + (trainer.state || ''); + container.appendChild(location); + + if (trainer.certification) { + const certBadge = document.createElement('span'); + certBadge.className = 'hvac-info-window-cert'; + certBadge.textContent = trainer.certification; + container.appendChild(certBadge); + } + + const button = document.createElement('button'); + button.className = 'hvac-info-window-btn'; + button.textContent = 'View Profile'; + button.addEventListener('click', function() { + self.openTrainerModal(trainer.profile_id); + }); + container.appendChild(button); + + this.infoWindow.setContent(container); + this.infoWindow.open(this.map, marker); + }, + + /** + * Show venue info window + */ + showVenueInfoWindow: function(marker) { + const self = this; + const venue = marker.venueData; + + const eventsText = venue.upcoming_events > 0 + ? venue.upcoming_events + ' upcoming event' + (venue.upcoming_events > 1 ? 's' : '') + : 'No upcoming events'; + + // Create DOM elements safely to avoid XSS + const container = document.createElement('div'); + container.className = 'hvac-info-window-venue'; + + const title = document.createElement('div'); + title.className = 'hvac-info-window-title'; + title.textContent = venue.name; + container.appendChild(title); + + const address = document.createElement('div'); + address.className = 'hvac-info-window-address'; + address.innerHTML = this.escapeHtml(venue.address || '') + '
' + + this.escapeHtml(venue.city || '') + ', ' + this.escapeHtml(venue.state || ''); + container.appendChild(address); + + const eventsCount = document.createElement('div'); + eventsCount.className = 'hvac-info-window-events-count'; + eventsCount.textContent = eventsText; + container.appendChild(eventsCount); + + const button = document.createElement('button'); + button.className = 'hvac-info-window-btn'; + button.textContent = 'View Details'; + button.addEventListener('click', function() { + self.openVenueModal(venue.id); + }); + container.appendChild(button); + + this.infoWindow.setContent(container); + this.infoWindow.open(this.map, marker); + }, + + /** + * Open trainer profile modal + */ + openTrainerModal: function(profileId) { + const self = this; + const $modal = $('#hvac-trainer-modal'); + const $body = $modal.find('.hvac-modal-body'); + const $loading = $modal.find('.hvac-modal-loading'); + + // Show modal with loading + $modal.show(); + $loading.show(); + $body.hide(); + + // Close info window + this.infoWindow.close(); + + // Fetch profile data + $.ajax({ + url: hvacFindTraining.ajax_url, + type: 'POST', + data: { + action: 'hvac_get_trainer_profile_modal', + nonce: hvacFindTraining.nonce, + profile_id: profileId + }, + success: function(response) { + if (response.success) { + $body.html(response.data.html); + $loading.hide(); + $body.show(); + self.bindContactForm(); + } else { + $body.html('

Failed to load profile.

'); + $loading.hide(); + $body.show(); + } + }, + error: function() { + $body.html('

Network error. Please try again.

'); + $loading.hide(); + $body.show(); + } + }); + }, + + /** + * Open venue details modal + */ + openVenueModal: function(venueId) { + const self = this; + const $modal = $('#hvac-venue-modal'); + + // Close info window + this.infoWindow.close(); + + // Fetch venue data + $.ajax({ + url: hvacFindTraining.ajax_url, + type: 'POST', + data: { + action: 'hvac_get_venue_info', + nonce: hvacFindTraining.nonce, + venue_id: venueId + }, + success: function(response) { + if (response.success) { + const venue = response.data.venue; + self.populateVenueModal(venue); + $modal.show(); + } + } + }); + }, + + /** + * Populate venue modal with data + */ + populateVenueModal: function(venue) { + const $modal = $('#hvac-venue-modal'); + + // Title + $modal.find('#venue-modal-title').text(venue.name); + + // Address + const addressParts = [venue.address, venue.city, venue.state].filter(Boolean); + $modal.find('.hvac-venue-address').text(addressParts.join(', ')); + + // Events list + const $eventsList = $modal.find('.hvac-venue-events-list'); + $eventsList.empty(); + + if (venue.events && venue.events.length > 0) { + venue.events.forEach(event => { + $eventsList.append(` +
  • + ${this.escapeHtml(event.title)} + ${this.escapeHtml(event.date)} +
  • + `); + }); + } else { + $eventsList.html('
  • No upcoming events at this venue.
  • '); + } + + // Directions link + const directionsUrl = `https://www.google.com/maps/dir/?api=1&destination=${venue.lat},${venue.lng}`; + $modal.find('.hvac-venue-directions').attr('href', directionsUrl); + }, + + /** + * Update trainer directory grid + */ + updateTrainerGrid: function() { + const $grid = $('#hvac-trainer-grid'); + $grid.empty(); + + if (this.trainers.length === 0) { + $grid.html('

    No trainers found matching your criteria.

    '); + return; + } + + // Display trainers (show first 12, load more on request) + const displayCount = Math.min(this.trainers.length, 12); + + for (let i = 0; i < displayCount; i++) { + const trainer = this.trainers[i]; + $grid.append(this.createTrainerCard(trainer)); + } + + // Show load more if there are more trainers + if (this.trainers.length > 12) { + $('.hvac-load-more-wrapper').show(); + } else { + $('.hvac-load-more-wrapper').hide(); + } + }, + + /** + * Create trainer card HTML + */ + createTrainerCard: function(trainer) { + const imageHtml = trainer.image + ? `${this.escapeHtml(trainer.name)}` + : '
    '; + + const certHtml = trainer.certifications && trainer.certifications.length > 0 + ? trainer.certifications.map(cert => `${this.escapeHtml(cert)}`).join('') + : ''; + + return ` +
    +
    ${imageHtml}
    +
    +
    ${this.escapeHtml(trainer.name)}
    +
    ${this.escapeHtml(trainer.city)}, ${this.escapeHtml(trainer.state)}
    +
    ${certHtml}
    +
    +
    + `; + }, + + /** + * Update counts display + */ + updateCounts: function(trainers, venues) { + $('#hvac-trainer-count').text(trainers || 0); + $('#hvac-venue-count').text(venues || 0); + }, + + /** + * Bind event handlers + */ + bindEvents: function() { + const self = this; + + // Trainer card click + $(document).on('click', '.hvac-trainer-card', function() { + const profileId = $(this).data('profile-id'); + if (profileId) { + self.openTrainerModal(profileId); + } + }); + + // Modal close + $(document).on('click', '.hvac-modal-close, .hvac-modal-overlay', function() { + $('.hvac-training-modal').hide(); + }); + + // ESC key to close modal + $(document).on('keydown', function(e) { + if (e.key === 'Escape') { + $('.hvac-training-modal').hide(); + } + }); + + // Marker toggles + $('#hvac-show-trainers, #hvac-show-venues').on('change', function() { + self.updateMarkers(); + }); + + // Load more button + $('#hvac-load-more').on('click', function() { + self.loadMoreTrainers(); + }); + }, + + /** + * Bind contact form in modal + */ + bindContactForm: function() { + const self = this; + + $('.hvac-training-contact-form').off('submit').on('submit', function(e) { + e.preventDefault(); + self.submitContactForm($(this)); + }); + }, + + /** + * Submit contact form + */ + submitContactForm: function($form) { + const trainerId = $form.data('trainer-id'); + const profileId = $form.data('profile-id'); + const $successMsg = $form.siblings('.hvac-form-success'); + const $errorMsg = $form.siblings('.hvac-form-error'); + const $submit = $form.find('button[type="submit"]'); + + // Collect form data + const formData = { + action: 'hvac_submit_contact_form', + nonce: hvacFindTraining.nonce, + trainer_id: trainerId, + trainer_profile_id: profileId + }; + + $form.serializeArray().forEach(field => { + formData[field.name] = field.value; + }); + + // Submit + $submit.prop('disabled', true).text('Sending...'); + $successMsg.hide(); + $errorMsg.hide(); + + $.ajax({ + url: hvacFindTraining.ajax_url, + type: 'POST', + data: formData, + success: function(response) { + if (response.success) { + $form.hide(); + $successMsg.show(); + } else { + $errorMsg.show(); + } + }, + error: function() { + $errorMsg.show(); + }, + complete: function() { + $submit.prop('disabled', false).text('Send Message'); + } + }); + }, + + /** + * Load more trainers + */ + loadMoreTrainers: function() { + const $grid = $('#hvac-trainer-grid'); + const currentCount = $grid.find('.hvac-trainer-card').length; + const loadMore = 12; + + for (let i = currentCount; i < currentCount + loadMore && i < this.trainers.length; i++) { + $grid.append(this.createTrainerCard(this.trainers[i])); + } + + if ($grid.find('.hvac-trainer-card').length >= this.trainers.length) { + $('.hvac-load-more-wrapper').hide(); + } + }, + + /** + * Get user's location + */ + getUserLocation: function(callback) { + if (!navigator.geolocation) { + callback(null, hvacFindTraining.messages.geolocation_unsupported); + return; + } + + navigator.geolocation.getCurrentPosition( + function(position) { + callback({ + lat: position.coords.latitude, + lng: position.coords.longitude + }); + }, + function(error) { + callback(null, hvacFindTraining.messages.geolocation_error); + } + ); + }, + + /** + * Center map on location + */ + centerOnLocation: function(lat, lng, zoom) { + this.map.setCenter({ lat: lat, lng: lng }); + this.map.setZoom(zoom || 10); + }, + + /** + * Show loading state + */ + showLoading: function() { + $('#hvac-trainer-grid').html('
    Loading trainers...
    '); + }, + + /** + * Hide loading state + */ + hideLoading: function() { + $('#hvac-trainer-grid .hvac-grid-loading').remove(); + }, + + /** + * Show map error + */ + showMapError: function(message) { + const $map = $('#' + this.config.mapElementId); + $map.html(` +
    + +

    ${this.escapeHtml(message)}

    +
    + `); + }, + + /** + * Escape HTML for safe output + */ + escapeHtml: function(text) { + if (!text) return ''; + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; + } + }; + + // Initialize when document is ready + $(document).ready(function() { + // Wait a moment for Google Maps to fully load + if ($('#hvac-training-map').length) { + setTimeout(function() { + HVACTrainingMap.init(); + }, 100); + } + }); + +})(jQuery); diff --git a/includes/class-hvac-ajax-handlers.php b/includes/class-hvac-ajax-handlers.php index 631b3db9..be3bcd5c 100644 --- a/includes/class-hvac-ajax-handlers.php +++ b/includes/class-hvac-ajax-handlers.php @@ -65,6 +65,10 @@ 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')); } /** @@ -1024,6 +1028,172 @@ 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 ', 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 + ]); + } + + /** + * 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 diff --git a/includes/class-hvac-page-manager.php b/includes/class-hvac-page-manager.php index 8d8acd1b..f98504b3 100644 --- a/includes/class-hvac-page-manager.php +++ b/includes/class-hvac-page-manager.php @@ -38,6 +38,13 @@ class HVAC_Page_Manager { 'parent' => null, 'capability' => null ], + 'find-training' => [ + 'title' => 'Find Training', + 'template' => 'page-find-training.php', + 'public' => true, + 'parent' => null, + 'capability' => null + ], // Trainer pages 'trainer' => [ diff --git a/includes/class-hvac-plugin.php b/includes/class-hvac-plugin.php index 555be42d..0a0b1123 100644 --- a/includes/class-hvac-plugin.php +++ b/includes/class-hvac-plugin.php @@ -263,6 +263,13 @@ final class HVAC_Plugin { 'find-trainer/class-hvac-trainer-directory-query.php', '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', + ]; // Load feature files with memory-efficient generator foreach ($this->loadFeatureFiles($featureFiles) as $file => $status) { @@ -277,7 +284,14 @@ final class HVAC_Plugin { $this->componentStatus["find_trainer_{$file}"] = true; } } - + + // 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', @@ -873,30 +887,45 @@ final class HVAC_Plugin { /** * Initialize Find a Trainer feature components - * + * * Loads trainer directory functionality with proper error handling. */ public function initializeFindTrainer(): void { - // Initialize Find a Trainer page + // Initialize Find a Trainer page (legacy MapGeo-based) if (class_exists('HVAC_Find_Trainer_Page')) { HVAC_Find_Trainer_Page::get_instance(); } - - // Initialize MapGeo integration + + // Initialize MapGeo integration (legacy) if (class_exists('HVAC_MapGeo_Integration')) { HVAC_MapGeo_Integration::get_instance(); } - + // Initialize contact form handler if (class_exists('HVAC_Contact_Form_Handler')) { HVAC_Contact_Form_Handler::get_instance(); } - + // Initialize trainer directory query if (class_exists('HVAC_Trainer_Directory_Query')) { 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 Training Map Data provider + if (class_exists('HVAC_Training_Map_Data')) { + HVAC_Training_Map_Data::get_instance(); + } + + // Initialize Venue Geocoding service + if (class_exists('HVAC_Venue_Geocoding')) { + HVAC_Venue_Geocoding::get_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 diff --git a/includes/find-training/class-hvac-find-training-page.php b/includes/find-training/class-hvac-find-training-page.php new file mode 100644 index 00000000..95263e3e --- /dev/null +++ b/includes/find-training/class-hvac-find-training-page.php @@ -0,0 +1,517 @@ +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' => '', + '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 { + ?> +
    +

    + +
    + +
    +
    +
    + + <?php echo esc_attr($trainer['name']); ?> + +
    + +
    + + +
    +

    + +

    + + +
    + + + +
    + + + +

    + + +

    + Total Training Events: +

    +
    +
    + + +
    + Training Formats: +
    + + + +
    +

    Upcoming Events

    + +
    + +
    + +
    +

    Contact Trainer

    +
    +
    + + +
    +
    + + +
    +
    + + +
    +
    + +
    +
    + +
    + +
    + + + +
    +
    + $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; + } +} diff --git a/includes/find-training/class-hvac-training-map-data.php b/includes/find-training/class-hvac-training-map-data.php new file mode 100644 index 00000000..913f4457 --- /dev/null +++ b/includes/find-training/class-hvac-training-map-data.php @@ -0,0 +1,771 @@ +cache_group); + + if ($cached !== false && empty($filters)) { + return $cached; + } + + // Get approved user IDs + $approved_user_ids = $this->get_approved_user_ids(); + + if (empty($approved_user_ids)) { + return []; + } + + // Build query args + $query_args = [ + 'post_type' => 'trainer_profile', + 'posts_per_page' => -1, + 'post_status' => 'publish', + 'meta_query' => [ + 'relation' => 'AND', + [ + 'key' => 'is_public_profile', + 'value' => '1', + 'compare' => '=' + ], + [ + 'key' => 'user_id', + 'value' => $approved_user_ids, + 'compare' => 'IN' + ], + [ + 'key' => 'latitude', + 'compare' => 'EXISTS' + ], + [ + 'key' => 'longitude', + 'compare' => 'EXISTS' + ], + [ + 'key' => 'latitude', + 'value' => '', + 'compare' => '!=' + ], + [ + 'key' => 'longitude', + 'value' => '', + 'compare' => '!=' + ] + ] + ]; + + // Add state filter + if (!empty($filters['state'])) { + $query_args['meta_query'][] = [ + 'key' => 'trainer_state', + 'value' => sanitize_text_field($filters['state']), + 'compare' => '=' + ]; + } + + // Add search filter + if (!empty($filters['search'])) { + $search = sanitize_text_field($filters['search']); + $query_args['meta_query'][] = [ + 'relation' => 'OR', + [ + 'key' => 'trainer_display_name', + 'value' => $search, + 'compare' => 'LIKE' + ], + [ + 'key' => 'trainer_city', + 'value' => $search, + 'compare' => 'LIKE' + ], + [ + 'key' => 'company_name', + 'value' => $search, + 'compare' => 'LIKE' + ] + ]; + } + + $query = new WP_Query($query_args); + $markers = []; + + if ($query->have_posts()) { + while ($query->have_posts()) { + $query->the_post(); + $profile_id = get_the_ID(); + $marker = $this->format_trainer_marker($profile_id); + + // Apply certification filter + if (!empty($filters['certification'])) { + $cert_match = false; + foreach ($marker['certifications'] as $cert) { + if (stripos($cert, $filters['certification']) !== false) { + $cert_match = true; + break; + } + } + if (!$cert_match) { + continue; + } + } + + // Apply proximity filter + if (!empty($filters['lat']) && !empty($filters['lng']) && !empty($filters['radius'])) { + $distance = $this->calculate_distance( + $filters['lat'], + $filters['lng'], + $marker['lat'], + $marker['lng'] + ); + if ($distance > $filters['radius']) { + continue; + } + $marker['distance'] = round($distance, 1); + } + + $markers[] = $marker; + } + } + + wp_reset_postdata(); + + // Sort by distance if proximity search + if (!empty($filters['lat']) && !empty($filters['lng'])) { + usort($markers, function($a, $b) { + return ($a['distance'] ?? 0) <=> ($b['distance'] ?? 0); + }); + } + + // Cache if no filters + if (empty($filters)) { + wp_cache_set($cache_key, $markers, $this->cache_group, $this->cache_expiration); + } + + return $markers; + } + + /** + * Get venue markers for map + * + * @param array $filters Optional filters + * @return array Venue markers data + */ + public function get_venue_markers(array $filters = []): array { + // Check if TEC is active + if (!function_exists('tribe_get_venue')) { + return []; + } + + // Generate cache key + $cache_key = 'venues_' . md5(serialize($filters)); + $cached = wp_cache_get($cache_key, $this->cache_group); + + if ($cached !== false && empty($filters)) { + return $cached; + } + + // Build query args + $query_args = [ + 'post_type' => 'tribe_venue', + 'posts_per_page' => -1, + 'post_status' => 'publish', + 'meta_query' => [ + 'relation' => 'AND', + [ + 'relation' => 'OR', + // Check for our custom venue coordinates + [ + 'key' => 'venue_latitude', + 'compare' => 'EXISTS' + ], + // Also check TEC's built-in coordinates + [ + 'key' => '_VenueLat', + 'compare' => 'EXISTS' + ] + ] + ] + ]; + + // Add state filter + if (!empty($filters['state'])) { + $query_args['meta_query'][] = [ + 'relation' => 'OR', + [ + 'key' => '_VenueStateProvince', + 'value' => sanitize_text_field($filters['state']), + 'compare' => '=' + ], + [ + 'key' => '_VenueState', + 'value' => sanitize_text_field($filters['state']), + 'compare' => '=' + ] + ]; + } + + // Add search filter + if (!empty($filters['search'])) { + $query_args['s'] = sanitize_text_field($filters['search']); + } + + $query = new WP_Query($query_args); + $markers = []; + + if ($query->have_posts()) { + while ($query->have_posts()) { + $query->the_post(); + $venue_id = get_the_ID(); + $marker = $this->format_venue_marker($venue_id); + + // Skip venues without valid coordinates + if (empty($marker['lat']) || empty($marker['lng'])) { + continue; + } + + // Apply proximity filter + if (!empty($filters['lat']) && !empty($filters['lng']) && !empty($filters['radius'])) { + $distance = $this->calculate_distance( + $filters['lat'], + $filters['lng'], + $marker['lat'], + $marker['lng'] + ); + if ($distance > $filters['radius']) { + continue; + } + $marker['distance'] = round($distance, 1); + } + + $markers[] = $marker; + } + } + + wp_reset_postdata(); + + // Sort by distance if proximity search + if (!empty($filters['lat']) && !empty($filters['lng'])) { + usort($markers, function($a, $b) { + return ($a['distance'] ?? 0) <=> ($b['distance'] ?? 0); + }); + } + + // Cache if no filters + if (empty($filters)) { + wp_cache_set($cache_key, $markers, $this->cache_group, $this->cache_expiration); + } + + return $markers; + } + + /** + * Format trainer data for map marker + * + * @param int $profile_id Trainer profile post ID + * @return array Formatted marker data + */ + private function format_trainer_marker(int $profile_id): array { + $user_id = get_post_meta($profile_id, 'user_id', true); + $lat = get_post_meta($profile_id, 'latitude', true); + $lng = get_post_meta($profile_id, 'longitude', true); + + // Get certifications + $certifications = $this->get_trainer_certifications($profile_id, $user_id); + + // Get event count (cached) + $event_count = get_post_meta($profile_id, 'cached_event_count', true); + if (empty($event_count)) { + $event_count = 0; + } + + return [ + 'id' => $profile_id, + 'type' => 'trainer', + 'lat' => floatval($lat), + 'lng' => floatval($lng), + 'name' => get_post_meta($profile_id, 'trainer_display_name', true), + 'city' => get_post_meta($profile_id, 'trainer_city', true), + 'state' => get_post_meta($profile_id, 'trainer_state', true), + 'certifications' => $certifications, + 'certification' => !empty($certifications) ? $certifications[0] : '', + 'image' => get_post_meta($profile_id, 'profile_image_url', true), + 'profile_id' => $profile_id, + 'user_id' => intval($user_id), + 'event_count' => intval($event_count) + ]; + } + + /** + * Format venue data for map marker + * + * @param int $venue_id Venue post ID + * @return array Formatted marker data + */ + private function format_venue_marker(int $venue_id): array { + // Try our custom coordinates first, then TEC's + $lat = get_post_meta($venue_id, 'venue_latitude', true); + $lng = get_post_meta($venue_id, 'venue_longitude', true); + + if (empty($lat) || empty($lng)) { + $lat = get_post_meta($venue_id, '_VenueLat', true); + $lng = get_post_meta($venue_id, '_VenueLng', true); + } + + // Get venue details from TEC + $city = get_post_meta($venue_id, '_VenueCity', true); + $state = get_post_meta($venue_id, '_VenueStateProvince', true) ?: get_post_meta($venue_id, '_VenueState', true); + $address = get_post_meta($venue_id, '_VenueAddress', true); + + // Count upcoming events at this venue + $upcoming_events_count = $this->count_venue_upcoming_events($venue_id); + + return [ + 'id' => $venue_id, + 'type' => 'venue', + 'lat' => floatval($lat), + 'lng' => floatval($lng), + 'name' => get_the_title($venue_id), + 'address' => $address, + 'city' => $city, + 'state' => $state, + 'upcoming_events' => $upcoming_events_count + ]; + } + + /** + * Get trainer certifications + * + * @param int $profile_id Profile post ID + * @param int $user_id User ID + * @return array List of certification names + */ + private function get_trainer_certifications(int $profile_id, int $user_id): array { + $certifications = []; + + // Try new certification system + if (class_exists('HVAC_Trainer_Certification_Manager')) { + $cert_manager = HVAC_Trainer_Certification_Manager::instance(); + $trainer_certs = $cert_manager->get_trainer_certifications($user_id); + + foreach ($trainer_certs as $cert) { + $cert_type = get_post_meta($cert->ID, 'certification_type', true); + $status = get_post_meta($cert->ID, 'status', true) ?: 'active'; + $expiration = get_post_meta($cert->ID, 'expiration_date', true); + + // Only include active, non-expired certifications + $is_expired = $expiration && strtotime($expiration) < time(); + if ($status === 'active' && !$is_expired && !empty($cert_type)) { + $certifications[] = $cert_type; + } + } + } + + // Fallback to legacy certification + if (empty($certifications)) { + $legacy = get_post_meta($profile_id, 'certification_type', true); + if (!empty($legacy)) { + $certifications[] = $legacy; + } + } + + return array_unique($certifications); + } + + /** + * Count upcoming events at a venue + * + * @param int $venue_id Venue post ID + * @return int Number of upcoming events + */ + private function count_venue_upcoming_events(int $venue_id): int { + if (!function_exists('tribe_get_events')) { + return 0; + } + + $events = tribe_get_events([ + 'eventDisplay' => 'upcoming', + 'posts_per_page' => -1, + 'venue' => $venue_id, + 'fields' => 'ids' + ]); + + return is_array($events) ? count($events) : 0; + } + + /** + * Get full trainer profile for modal + * + * @param int $profile_id Profile post ID + * @return array|null Trainer data or null if not found + */ + public function get_trainer_full_profile(int $profile_id): ?array { + $profile = get_post($profile_id); + if (!$profile || $profile->post_type !== 'trainer_profile') { + return null; + } + + $user_id = get_post_meta($profile_id, 'user_id', true); + + // Get basic marker data + $data = $this->format_trainer_marker($profile_id); + + // Add additional details for modal + $data['company'] = get_post_meta($profile_id, 'company_name', true); + $data['bio'] = get_post_meta($profile_id, 'trainer_bio', true); + $data['training_formats'] = $this->get_meta_array($profile_id, 'training_formats'); + $data['training_locations'] = $this->get_meta_array($profile_id, 'training_locations'); + + // Get upcoming events + $data['upcoming_events'] = $this->get_trainer_upcoming_events($user_id); + + return $data; + } + + /** + * Get full venue info + * + * @param int $venue_id Venue post ID + * @return array|null Venue data or null if not found + */ + public function get_venue_full_info(int $venue_id): ?array { + $venue = get_post($venue_id); + if (!$venue || $venue->post_type !== 'tribe_venue') { + return null; + } + + $data = $this->format_venue_marker($venue_id); + + // Add additional details + $data['zip'] = get_post_meta($venue_id, '_VenueZip', true); + $data['country'] = get_post_meta($venue_id, '_VenueCountry', true); + $data['phone'] = get_post_meta($venue_id, '_VenuePhone', true); + $data['website'] = get_post_meta($venue_id, '_VenueURL', true); + + // Get upcoming events list + if (function_exists('tribe_get_events')) { + $events = tribe_get_events([ + 'eventDisplay' => 'upcoming', + 'posts_per_page' => 5, + 'venue' => $venue_id + ]); + + $data['events'] = []; + foreach ($events as $event) { + $data['events'][] = [ + 'id' => $event->ID, + 'title' => $event->post_title, + 'date' => tribe_get_start_date($event->ID, false, 'M j, Y'), + 'url' => get_permalink($event->ID) + ]; + } + } + + return $data; + } + + /** + * Get trainer's upcoming events + * + * @param int $user_id User ID + * @param int $limit Maximum events to return + * @return array Upcoming events + */ + private function get_trainer_upcoming_events(int $user_id, int $limit = 5): array { + if (!function_exists('tribe_get_events')) { + return []; + } + + $events = tribe_get_events([ + 'author' => $user_id, + 'eventDisplay' => 'upcoming', + 'posts_per_page' => $limit + ]); + + $formatted = []; + foreach ($events as $event) { + $formatted[] = [ + 'id' => $event->ID, + 'title' => $event->post_title, + 'date' => tribe_get_start_date($event->ID, false, 'M j, Y'), + 'url' => get_permalink($event->ID) + ]; + } + + return $formatted; + } + + /** + * Get approved user IDs for filtering trainer profiles + * + * @return array User IDs + */ + private function get_approved_user_ids(): array { + $user_query = new WP_User_Query([ + 'meta_query' => [ + [ + 'key' => 'account_status', + 'value' => ['approved', 'active', 'inactive'], + 'compare' => 'IN' + ] + ], + 'fields' => 'ID' + ]); + + return $user_query->get_results(); + } + + /** + * Get meta value as array (handles comma-separated or serialized) + * + * @param int $post_id Post ID + * @param string $meta_key Meta key + * @return array Values + */ + private function get_meta_array(int $post_id, string $meta_key): array { + $value = get_post_meta($post_id, $meta_key, true); + + if (empty($value)) { + return []; + } + + if (is_array($value)) { + return $value; + } + + // Handle comma-separated + if (strpos($value, ',') !== false) { + return array_map('trim', explode(',', $value)); + } + + return [$value]; + } + + /** + * Calculate distance between two coordinates using Haversine formula + * + * @param float $lat1 First latitude + * @param float $lng1 First longitude + * @param float $lat2 Second latitude + * @param float $lng2 Second longitude + * @return float Distance in kilometers + */ + private function calculate_distance(float $lat1, float $lng1, float $lat2, float $lng2): float { + $earth_radius = 6371; // km + + $lat1_rad = deg2rad($lat1); + $lat2_rad = deg2rad($lat2); + $delta_lat = deg2rad($lat2 - $lat1); + $delta_lng = deg2rad($lng2 - $lng1); + + $a = sin($delta_lat / 2) * sin($delta_lat / 2) + + cos($lat1_rad) * cos($lat2_rad) * + sin($delta_lng / 2) * sin($delta_lng / 2); + + $c = 2 * atan2(sqrt($a), sqrt(1 - $a)); + + return $earth_radius * $c; + } + + /** + * Get unique state options from trainers + * + * @return array State options + */ + public function get_state_options(): array { + // Check cache first + $cache_key = 'filter_state_options'; + $cached = wp_cache_get($cache_key, $this->cache_group); + + if ($cached !== false) { + return $cached; + } + + global $wpdb; + + $states = $wpdb->get_col(" + SELECT DISTINCT pm.meta_value + FROM {$wpdb->postmeta} pm + INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id + WHERE pm.meta_key = 'trainer_state' + AND p.post_type = 'trainer_profile' + AND p.post_status = 'publish' + AND pm.meta_value != '' + ORDER BY pm.meta_value ASC + "); + + $states = array_filter($states); + + // Cache for 1 hour + wp_cache_set($cache_key, $states, $this->cache_group, $this->cache_expiration); + + return $states; + } + + /** + * Get certification type options + * + * @return array Certification options + */ + public function get_certification_options(): array { + // Check cache first + $cache_key = 'filter_certification_options'; + $cached = wp_cache_get($cache_key, $this->cache_group); + + if ($cached !== false) { + return $cached; + } + + global $wpdb; + + $certs = $wpdb->get_col(" + SELECT DISTINCT pm.meta_value + FROM {$wpdb->postmeta} pm + INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id + WHERE pm.meta_key = 'certification_type' + AND p.post_type = 'trainer_profile' + AND p.post_status = 'publish' + AND pm.meta_value != '' + ORDER BY pm.meta_value ASC + "); + + $certs = array_filter($certs); + + // Cache for 1 hour + wp_cache_set($cache_key, $certs, $this->cache_group, $this->cache_expiration); + + return $certs; + } + + /** + * Get training format options + * + * @return array Training format options + */ + public function get_training_format_options(): array { + // Check cache first + $cache_key = 'filter_format_options'; + $cached = wp_cache_get($cache_key, $this->cache_group); + + if ($cached !== false) { + return $cached; + } + + global $wpdb; + + $formats_raw = $wpdb->get_col(" + SELECT DISTINCT pm.meta_value + FROM {$wpdb->postmeta} pm + INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id + WHERE pm.meta_key = 'training_formats' + AND p.post_type = 'trainer_profile' + AND p.post_status = 'publish' + AND pm.meta_value != '' + "); + + // Process comma-separated values + $formats = []; + foreach ($formats_raw as $format_string) { + if (empty($format_string)) continue; + $individual = array_map('trim', explode(',', $format_string)); + $formats = array_merge($formats, $individual); + } + + $formats = array_unique(array_filter($formats)); + sort($formats); + + // Cache for 1 hour + wp_cache_set($cache_key, $formats, $this->cache_group, $this->cache_expiration); + + return $formats; + } + + /** + * Clear trainer cache + */ + public function clear_trainer_cache(): void { + if (function_exists('wp_cache_delete_group')) { + wp_cache_delete_group($this->cache_group); + } else { + wp_cache_flush(); + } + } + + /** + * Clear venue cache + */ + public function clear_venue_cache(): void { + if (function_exists('wp_cache_delete_group')) { + wp_cache_delete_group($this->cache_group); + } else { + wp_cache_flush(); + } + } +} diff --git a/includes/find-training/class-hvac-venue-geocoding.php b/includes/find-training/class-hvac-venue-geocoding.php new file mode 100644 index 00000000..26b2d904 --- /dev/null +++ b/includes/find-training/class-hvac-venue-geocoding.php @@ -0,0 +1,517 @@ +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'); + } +} diff --git a/templates/page-find-training.php b/templates/page-find-training.php new file mode 100644 index 00000000..1a7b8df7 --- /dev/null +++ b/templates/page-find-training.php @@ -0,0 +1,200 @@ +get_filter_options(); +?> + +
    +
    + + +

    Find Training

    + + +
    +

    Upskill HVAC is proud to be the only training body offering Certified measureQuick training.

    +

    Certified measureQuick Trainers have demonstrated their skills and mastery of HVAC science and the measureQuick app, and are authorized to provide measureQuick training to the industry.

    +

    Use the interactive map and filters below to discover trainers and training venues near you. Click on any marker to view details.

    +
    + + +
    + + +
    +
    +
    + +

    Loading map...

    +
    +
    + + +
    +
    + + Trainer +
    +
    + + Training Venue +
    +
    +
    + + +
    + + + + + + + +
    + Filters: + +
    + + +
    + + +
    + + +
    + + +
    + + +
    + + +
    + + +
    + + +
    + + +
    + + +
    + 0 trainers, 0 venues +
    +
    +
    + + +
    +

    Trainers Directory

    +
    +
    + + Loading trainers... +
    +
    + + + +
    + + +
    +

    Are you an HVAC Trainer that wants to be listed in our directory?

    + Become A Trainer +
    + +
    +
    + + + + + + + +