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
+ ? ` `
+ : '
';
+
+ 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 {
+ ?>
+
+
+
+
+
+
+
+
+ Training Formats:
+
+
+
+
+
+
+
+
+
+
+ $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.
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Trainer
+
+
+
+ Training Venue
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Near Me
+
+
+
+
+
+
+
+ State / Province
+
+ All States
+
+
+
+
+
+
+
+
+ Certification
+
+ All Certifications
+
+
+
+
+
+
+
+
+ Training Format
+
+ All Formats
+
+
+
+
+
+
+
+
+
+
+
+ Show Trainers
+
+
+
+
+ Show Venues
+
+
+
+
+
+
+
+
+ 0 trainers, 0 venues
+
+
+
+
+
+
+
Trainers Directory
+
+
+
+ Loading trainers...
+
+
+
+
+
+
+ Load More
+
+
+
+
+
+
+
Are you an HVAC Trainer that wants to be listed in our directory?
+
Become A Trainer
+
+
+
+
+
+
+
+
+
+
+
+