feat(find-training): New Google Maps page replacing buggy MapGeo implementation
Implements /find-training page with Google Maps JavaScript API: - Interactive map showing trainers (teal) and venues (orange) markers - MarkerClusterer for dense areas - Filter by State, Certification, Training Format - Search by name/location - "Near Me" geolocation with proximity filtering - Trainer profile modal with contact form - Venue info modal with upcoming events - 301 redirect from /find-a-trainer to /find-training - Auto-geocoding for new TEC venues via Google API Multi-model code review fixes (GPT-5, Gemini 3, Zen MCP): - Added missing contact form AJAX handler with rate limiting - Fixed XSS risk in InfoWindow (DOM creation vs inline onclick) - Added caching for filter dropdown queries (1-hour TTL) - Added AJAX abort handling to prevent race conditions - Replaced alert() with inline error notifications New files: - includes/find-training/class-hvac-find-training-page.php - includes/find-training/class-hvac-training-map-data.php - includes/find-training/class-hvac-venue-geocoding.php - templates/page-find-training.php - assets/js/find-training-map.js - assets/js/find-training-filters.js - assets/css/find-training-map.css - assets/images/marker-trainer.svg - assets/images/marker-venue.svg Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
9f4667fbb4
commit
21c908af81
14 changed files with 4569 additions and 65 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -30,6 +30,7 @@
|
||||||
/includes/*
|
/includes/*
|
||||||
!/includes/admin/
|
!/includes/admin/
|
||||||
!/includes/zoho/
|
!/includes/zoho/
|
||||||
|
!/includes/find-training/
|
||||||
!/includes/**/*.php
|
!/includes/**/*.php
|
||||||
!/templates/
|
!/templates/
|
||||||
/templates/*
|
/templates/*
|
||||||
|
|
|
||||||
212
Status.md
212
Status.md
|
|
@ -1,78 +1,176 @@
|
||||||
# HVAC Community Events - Project Status
|
# HVAC Community Events - Project Status
|
||||||
|
|
||||||
**Last Updated:** January 31, 2026
|
**Last Updated:** February 1, 2026
|
||||||
**Current Session:** Multi-Model Security Code Review - Complete
|
**Current Session:** Find Training Page Implementation - Complete
|
||||||
**Version:** 2.1.13 (Pending Staging Deployment)
|
**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**
|
### 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
|
### Why This Change
|
||||||
- **Phase 1:** Security review with GPT-5, Gemini 3, Kimi K2.5 (parallel)
|
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:
|
||||||
- **Phase 2:** Business logic review with GPT-5, Gemini 3, Kimi K2.5 (parallel)
|
- Full control over marker data
|
||||||
- **Phase 3:** Zen OWASP audit, Architecture analysis, Code review synthesis (parallel)
|
- No third-party plugin dependencies
|
||||||
- **Phase 4:** Consolidated findings report with consensus-based prioritization
|
- 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 |
|
| File | Description |
|
||||||
|----|----------|-------|------|-----|
|
|------|-------------|
|
||||||
| C1 | **CRITICAL** | Passwords stored in transients | `class-hvac-registration.php` | ✅ Strip passwords before storing |
|
| `includes/find-training/class-hvac-find-training-page.php` | Main page handler (singleton), AJAX endpoints, asset enqueuing |
|
||||||
| U1 | **CRITICAL** | O(3600) token verification loop (DoS) | `class-hvac-ajax-security.php` | ✅ Rewrote to O(1) with timestamp |
|
| `includes/find-training/class-hvac-training-map-data.php` | Data provider for trainer/venue markers with caching |
|
||||||
| U2 | **HIGH** | `remove_all_actions()` breaks WP isolation | `class-hvac-plugin.php` | ✅ Targeted hook removal only |
|
| `includes/find-training/class-hvac-venue-geocoding.php` | Auto-geocoding for TEC venues via Google API |
|
||||||
| C2 | **HIGH** | Encryption key in same database as data | `class-hvac-secure-storage.php` | ✅ Prefer wp-config.php constant |
|
| `templates/page-find-training.php` | Page template with map, filters, modals |
|
||||||
| M3 | **HIGH** | Revoked certificates still downloadable | `class-certificate-manager.php` | ✅ Added revocation check |
|
| `assets/js/find-training-map.js` | Google Maps initialization, markers, clustering |
|
||||||
| U3 | **HIGH** | Security headers not applied to AJAX | `class-hvac-ajax-security.php` | ✅ Fixed condition for AJAX |
|
| `assets/js/find-training-filters.js` | Filter handling, geolocation, AJAX |
|
||||||
| C3 | **MEDIUM** | IP spoofing undermines rate limiting | `class-hvac-security.php` | ✅ Trusted proxy validation |
|
| `assets/css/find-training-map.css` | Complete responsive styling |
|
||||||
| M1 | **MEDIUM** | Weak CSP with `unsafe-eval` | `class-hvac-ajax-security.php` | ✅ Removed `unsafe-eval` |
|
| `assets/images/marker-trainer.svg` | Teal person icon for trainers |
|
||||||
| C5 | **MEDIUM** | Duplicate component initialization | `class-hvac-plugin.php` | ✅ Removed duplicates |
|
| `assets/images/marker-venue.svg` | Orange building icon for venues |
|
||||||
| 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 |
|
|
||||||
|
|
||||||
### Files Modified (8 files)
|
### Files Modified (3 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
|
|
||||||
|
|
||||||
### Positive Security Patterns Confirmed
|
| File | Change |
|
||||||
- Centralized AJAX security middleware
|
|------|--------|
|
||||||
- Consistent input sanitization throughout
|
| `includes/class-hvac-page-manager.php` | Added find-training page definition |
|
||||||
- Prepared SQL statements with `$wpdb->prepare()`
|
| `includes/class-hvac-plugin.php` | Load new find-training classes |
|
||||||
- WordPress native password functions
|
| `includes/class-hvac-ajax-handlers.php` | Added contact form AJAX handler |
|
||||||
- Comprehensive audit logging
|
|
||||||
|
|
||||||
### Validated as Non-Issues
|
### Multi-Model Code Review Findings & Fixes
|
||||||
- **Path traversal in certificates** - Token-based system prevents exploitation
|
|
||||||
- **SQL injection** - Proper `$wpdb->prepare()` throughout
|
|
||||||
- **OAuth CSRF** - Correctly implemented with `hash_equals()`
|
|
||||||
|
|
||||||
### Deferred Items (Low Priority for Local Environment)
|
Ran comprehensive code review using GPT-5, Gemini 3, and Zen MCP tools. Found and fixed 6 issues:
|
||||||
- AES-GCM authenticated encryption upgrade
|
|
||||||
- OAuth refresh token locking mechanism
|
|
||||||
- Atomic certificate number generation
|
|
||||||
- Singleton API naming standardization
|
|
||||||
|
|
||||||
### Deliverables
|
| # | Severity | Issue | Fix |
|
||||||
1. ✅ **Full Report:** `MULTI-MODEL-CODE-REVIEW-REPORT.md`
|
|---|----------|-------|-----|
|
||||||
2. ✅ **12 Security Fixes:** All implemented and syntax-verified
|
| 1 | **CRITICAL** | Missing `hvac_submit_contact_form` AJAX handler | Added full handler with rate limiting, validation, email |
|
||||||
3. ✅ **PHP Syntax Validation:** All 7 modified files pass `php -l`
|
| 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
|
### Next Steps
|
||||||
1. Deploy to staging: `./scripts/deploy.sh staging`
|
1. ⏳ Deploy to staging: `./scripts/deploy.sh staging`
|
||||||
2. Run E2E tests: `HEADLESS=true node test-comprehensive-validation.js`
|
2. ⏳ Run E2E tests on Find Training page
|
||||||
3. Verify all functionality works correctly
|
3. ⏳ Verify map loads with markers
|
||||||
4. Deploy to production after E2E validation
|
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
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
973
assets/css/find-training-map.css
Normal file
973
assets/css/find-training-map.css
Normal file
|
|
@ -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;
|
||||||
|
}
|
||||||
BIN
assets/images/marker-trainer.svg
Normal file
BIN
assets/images/marker-trainer.svg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 339 B |
BIN
assets/images/marker-venue.svg
Normal file
BIN
assets/images/marker-venue.svg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 360 B |
388
assets/js/find-training-filters.js
Normal file
388
assets/js/find-training-filters.js
Normal file
|
|
@ -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('<span class="dashicons dashicons-update-alt hvac-spin"></span> 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('<span class="dashicons dashicons-yes-alt"></span> 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('<span class="dashicons dashicons-location-alt"></span> 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 = $('<div class="hvac-location-error">' +
|
||||||
|
'<span class="dashicons dashicons-warning"></span> ' +
|
||||||
|
this.escapeHtml(message) +
|
||||||
|
'<button type="button" class="hvac-dismiss-error" aria-label="Dismiss">×</button>' +
|
||||||
|
'</div>');
|
||||||
|
|
||||||
|
// Insert after Near Me button
|
||||||
|
$('#hvac-near-me-btn').after($error);
|
||||||
|
|
||||||
|
// Auto-dismiss after 5 seconds
|
||||||
|
setTimeout(function() {
|
||||||
|
$error.fadeOut(300, function() { $(this).remove(); });
|
||||||
|
}, 5000);
|
||||||
|
|
||||||
|
// Click to dismiss
|
||||||
|
$error.find('.hvac-dismiss-error').on('click', function() {
|
||||||
|
$error.remove();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear location error message
|
||||||
|
*/
|
||||||
|
clearLocationError: function() {
|
||||||
|
$('.hvac-location-error').remove();
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear all filters
|
||||||
|
*/
|
||||||
|
clearAllFilters: function() {
|
||||||
|
// Reset filter values
|
||||||
|
this.activeFilters = {
|
||||||
|
state: '',
|
||||||
|
certification: '',
|
||||||
|
training_format: '',
|
||||||
|
search: ''
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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('<span class="dashicons dashicons-location-alt"></span> 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('<span class="dashicons dashicons-location-alt"></span> 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 = $(`
|
||||||
|
<span class="hvac-active-filter" data-filter="${type}">
|
||||||
|
${this.escapeHtml(label)}
|
||||||
|
<button type="button" aria-label="Remove filter">×</button>
|
||||||
|
</span>
|
||||||
|
`);
|
||||||
|
$container.append($chip);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update clear button visibility
|
||||||
|
*/
|
||||||
|
updateClearButtonVisibility: function() {
|
||||||
|
const hasFilters = this.activeFilters.state ||
|
||||||
|
this.activeFilters.certification ||
|
||||||
|
this.activeFilters.training_format ||
|
||||||
|
this.activeFilters.search ||
|
||||||
|
this.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);
|
||||||
833
assets/js/find-training-map.js
Normal file
833
assets/js/find-training-map.js
Normal file
|
|
@ -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 || '') + '<br>' +
|
||||||
|
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('<p class="hvac-error">Failed to load profile.</p>');
|
||||||
|
$loading.hide();
|
||||||
|
$body.show();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error: function() {
|
||||||
|
$body.html('<p class="hvac-error">Network error. Please try again.</p>');
|
||||||
|
$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(`
|
||||||
|
<li>
|
||||||
|
<a href="${this.escapeHtml(event.url)}" target="_blank">${this.escapeHtml(event.title)}</a>
|
||||||
|
<span class="hvac-event-date">${this.escapeHtml(event.date)}</span>
|
||||||
|
</li>
|
||||||
|
`);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
$eventsList.html('<li>No upcoming events at this venue.</li>');
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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('<div class="hvac-no-results"><p>No trainers found matching your criteria.</p></div>');
|
||||||
|
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
|
||||||
|
? `<img src="${this.escapeHtml(trainer.image)}" alt="${this.escapeHtml(trainer.name)}">`
|
||||||
|
: '<div class="hvac-trainer-card-avatar"><span class="dashicons dashicons-businessperson"></span></div>';
|
||||||
|
|
||||||
|
const certHtml = trainer.certifications && trainer.certifications.length > 0
|
||||||
|
? trainer.certifications.map(cert => `<span class="hvac-cert-badge">${this.escapeHtml(cert)}</span>`).join('')
|
||||||
|
: '';
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="hvac-trainer-card" data-profile-id="${trainer.profile_id}">
|
||||||
|
<div class="hvac-trainer-card-image">${imageHtml}</div>
|
||||||
|
<div class="hvac-trainer-card-info">
|
||||||
|
<div class="hvac-trainer-card-name">${this.escapeHtml(trainer.name)}</div>
|
||||||
|
<div class="hvac-trainer-card-location">${this.escapeHtml(trainer.city)}, ${this.escapeHtml(trainer.state)}</div>
|
||||||
|
<div class="hvac-trainer-card-certs">${certHtml}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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('<div class="hvac-grid-loading"><span class="dashicons dashicons-update-alt hvac-spin"></span> Loading trainers...</div>');
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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(`
|
||||||
|
<div class="hvac-map-loading">
|
||||||
|
<span class="dashicons dashicons-warning"></span>
|
||||||
|
<p>${this.escapeHtml(message)}</p>
|
||||||
|
</div>
|
||||||
|
`);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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);
|
||||||
|
|
@ -65,6 +65,10 @@ class HVAC_Ajax_Handlers {
|
||||||
// Password reset endpoint for master trainers
|
// Password reset endpoint for master trainers
|
||||||
add_action('wp_ajax_hvac_send_password_reset', array($this, 'send_password_reset'));
|
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'));
|
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);
|
wp_send_json_success('Password reset email sent to ' . $user->user_email);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle trainer contact form submission from Find Training page
|
||||||
|
*
|
||||||
|
* Sends an email to the trainer with the visitor's inquiry.
|
||||||
|
* Available to both logged-in and anonymous users.
|
||||||
|
*/
|
||||||
|
public function submit_trainer_contact_form() {
|
||||||
|
// Verify nonce
|
||||||
|
if (!isset($_POST['nonce']) || !wp_verify_nonce($_POST['nonce'], 'hvac_find_training')) {
|
||||||
|
wp_send_json_error(['message' => 'Invalid security token'], 403);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rate limiting - max 5 submissions per IP per hour
|
||||||
|
$ip = $this->get_client_ip();
|
||||||
|
$rate_key = 'hvac_contact_rate_' . md5($ip);
|
||||||
|
$submissions = get_transient($rate_key) ?: 0;
|
||||||
|
|
||||||
|
if ($submissions >= 5) {
|
||||||
|
wp_send_json_error(['message' => 'Too many submissions. Please try again later.'], 429);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate required fields
|
||||||
|
$required_fields = ['first_name', 'last_name', 'email', 'trainer_id'];
|
||||||
|
foreach ($required_fields as $field) {
|
||||||
|
if (empty($_POST[$field])) {
|
||||||
|
wp_send_json_error(['message' => "Missing required field: {$field}"], 400);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sanitize inputs
|
||||||
|
$first_name = sanitize_text_field($_POST['first_name']);
|
||||||
|
$last_name = sanitize_text_field($_POST['last_name']);
|
||||||
|
$email = sanitize_email($_POST['email']);
|
||||||
|
$phone = sanitize_text_field($_POST['phone'] ?? '');
|
||||||
|
$city = sanitize_text_field($_POST['city'] ?? '');
|
||||||
|
$state = sanitize_text_field($_POST['state_province'] ?? '');
|
||||||
|
$company = sanitize_text_field($_POST['company'] ?? '');
|
||||||
|
$message = sanitize_textarea_field($_POST['message'] ?? '');
|
||||||
|
$trainer_id = absint($_POST['trainer_id']);
|
||||||
|
$profile_id = absint($_POST['trainer_profile_id'] ?? 0);
|
||||||
|
|
||||||
|
// Validate email
|
||||||
|
if (!is_email($email)) {
|
||||||
|
wp_send_json_error(['message' => 'Invalid email address'], 400);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get trainer data
|
||||||
|
$trainer = get_userdata($trainer_id);
|
||||||
|
if (!$trainer) {
|
||||||
|
wp_send_json_error(['message' => 'Trainer not found'], 404);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get trainer's display name from profile if available
|
||||||
|
$trainer_name = $trainer->display_name;
|
||||||
|
if ($profile_id) {
|
||||||
|
$profile_name = get_post_meta($profile_id, 'trainer_display_name', true);
|
||||||
|
if ($profile_name) {
|
||||||
|
$trainer_name = $profile_name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build email content
|
||||||
|
$subject = sprintf(
|
||||||
|
'[Upskill HVAC] Training Inquiry from %s %s',
|
||||||
|
$first_name,
|
||||||
|
$last_name
|
||||||
|
);
|
||||||
|
|
||||||
|
$body = sprintf(
|
||||||
|
"Hello %s,\n\n" .
|
||||||
|
"You have received a training inquiry through the Upskill HVAC directory.\n\n" .
|
||||||
|
"--- Contact Details ---\n" .
|
||||||
|
"Name: %s %s\n" .
|
||||||
|
"Email: %s\n" .
|
||||||
|
"%s" . // Phone (optional)
|
||||||
|
"%s" . // Location (optional)
|
||||||
|
"%s" . // Company (optional)
|
||||||
|
"\n--- Message ---\n%s\n\n" .
|
||||||
|
"---\n" .
|
||||||
|
"This message was sent via the Find Training page at %s\n" .
|
||||||
|
"Please respond directly to the sender's email address.\n",
|
||||||
|
$trainer_name,
|
||||||
|
$first_name,
|
||||||
|
$last_name,
|
||||||
|
$email,
|
||||||
|
$phone ? "Phone: {$phone}\n" : '',
|
||||||
|
($city || $state) ? "Location: " . trim("{$city}, {$state}", ', ') . "\n" : '',
|
||||||
|
$company ? "Company: {$company}\n" : '',
|
||||||
|
$message ?: '(No message provided)',
|
||||||
|
home_url('/find-training/')
|
||||||
|
);
|
||||||
|
|
||||||
|
// Email headers
|
||||||
|
$headers = [
|
||||||
|
'Content-Type: text/plain; charset=UTF-8',
|
||||||
|
sprintf('Reply-To: %s %s <%s>', $first_name, $last_name, $email),
|
||||||
|
sprintf('From: Upskill HVAC <noreply@%s>', parse_url(home_url(), PHP_URL_HOST))
|
||||||
|
];
|
||||||
|
|
||||||
|
// Send email to trainer
|
||||||
|
$sent = wp_mail($trainer->user_email, $subject, $body, $headers);
|
||||||
|
|
||||||
|
if (!$sent) {
|
||||||
|
// Log failure
|
||||||
|
if (class_exists('HVAC_Logger')) {
|
||||||
|
HVAC_Logger::error('Failed to send trainer contact email', 'AJAX', [
|
||||||
|
'trainer_id' => $trainer_id,
|
||||||
|
'sender_email' => $email
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
wp_send_json_error(['message' => 'Failed to send message. Please try again.'], 500);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update rate limit
|
||||||
|
set_transient($rate_key, $submissions + 1, HOUR_IN_SECONDS);
|
||||||
|
|
||||||
|
// Log success
|
||||||
|
if (class_exists('HVAC_Logger')) {
|
||||||
|
HVAC_Logger::info('Trainer contact form submitted', 'AJAX', [
|
||||||
|
'trainer_id' => $trainer_id,
|
||||||
|
'sender_email' => $email,
|
||||||
|
'has_message' => !empty($message)
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store lead if training leads system exists
|
||||||
|
if (class_exists('HVAC_Training_Leads')) {
|
||||||
|
$leads = HVAC_Training_Leads::instance();
|
||||||
|
if (method_exists($leads, 'create_lead')) {
|
||||||
|
$leads->create_lead([
|
||||||
|
'first_name' => $first_name,
|
||||||
|
'last_name' => $last_name,
|
||||||
|
'email' => $email,
|
||||||
|
'phone' => $phone,
|
||||||
|
'city' => $city,
|
||||||
|
'state' => $state,
|
||||||
|
'company' => $company,
|
||||||
|
'message' => $message,
|
||||||
|
'trainer_id' => $trainer_id,
|
||||||
|
'source' => 'find_training_page'
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
wp_send_json_success([
|
||||||
|
'message' => 'Your message has been sent to the trainer.',
|
||||||
|
'trainer_name' => $trainer_name
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
// Initialize the handlers
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,13 @@ class HVAC_Page_Manager {
|
||||||
'parent' => null,
|
'parent' => null,
|
||||||
'capability' => null
|
'capability' => null
|
||||||
],
|
],
|
||||||
|
'find-training' => [
|
||||||
|
'title' => 'Find Training',
|
||||||
|
'template' => 'page-find-training.php',
|
||||||
|
'public' => true,
|
||||||
|
'parent' => null,
|
||||||
|
'capability' => null
|
||||||
|
],
|
||||||
|
|
||||||
// Trainer pages
|
// Trainer pages
|
||||||
'trainer' => [
|
'trainer' => [
|
||||||
|
|
|
||||||
|
|
@ -263,6 +263,13 @@ final class HVAC_Plugin {
|
||||||
'find-trainer/class-hvac-trainer-directory-query.php',
|
'find-trainer/class-hvac-trainer-directory-query.php',
|
||||||
'class-hvac-mapgeo-safety.php', // MapGeo safety wrapper
|
'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
|
// Load feature files with memory-efficient generator
|
||||||
foreach ($this->loadFeatureFiles($featureFiles) as $file => $status) {
|
foreach ($this->loadFeatureFiles($featureFiles) as $file => $status) {
|
||||||
|
|
@ -277,7 +284,14 @@ final class HVAC_Plugin {
|
||||||
$this->componentStatus["find_trainer_{$file}"] = true;
|
$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
|
// Load community system files
|
||||||
$communityFiles = [
|
$communityFiles = [
|
||||||
'community/class-login-handler.php',
|
'community/class-login-handler.php',
|
||||||
|
|
@ -873,30 +887,45 @@ final class HVAC_Plugin {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize Find a Trainer feature components
|
* Initialize Find a Trainer feature components
|
||||||
*
|
*
|
||||||
* Loads trainer directory functionality with proper error handling.
|
* Loads trainer directory functionality with proper error handling.
|
||||||
*/
|
*/
|
||||||
public function initializeFindTrainer(): void {
|
public function initializeFindTrainer(): void {
|
||||||
// Initialize Find a Trainer page
|
// Initialize Find a Trainer page (legacy MapGeo-based)
|
||||||
if (class_exists('HVAC_Find_Trainer_Page')) {
|
if (class_exists('HVAC_Find_Trainer_Page')) {
|
||||||
HVAC_Find_Trainer_Page::get_instance();
|
HVAC_Find_Trainer_Page::get_instance();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize MapGeo integration
|
// Initialize MapGeo integration (legacy)
|
||||||
if (class_exists('HVAC_MapGeo_Integration')) {
|
if (class_exists('HVAC_MapGeo_Integration')) {
|
||||||
HVAC_MapGeo_Integration::get_instance();
|
HVAC_MapGeo_Integration::get_instance();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize contact form handler
|
// Initialize contact form handler
|
||||||
if (class_exists('HVAC_Contact_Form_Handler')) {
|
if (class_exists('HVAC_Contact_Form_Handler')) {
|
||||||
HVAC_Contact_Form_Handler::get_instance();
|
HVAC_Contact_Form_Handler::get_instance();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize trainer directory query
|
// Initialize trainer directory query
|
||||||
if (class_exists('HVAC_Trainer_Directory_Query')) {
|
if (class_exists('HVAC_Trainer_Directory_Query')) {
|
||||||
HVAC_Trainer_Directory_Query::get_instance();
|
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
|
// ARCHITECTURE FIX (C5): Master Trainer components are already initialized
|
||||||
// in initializeSecondaryComponents() at priority 5. Removed duplicate
|
// in initializeSecondaryComponents() at priority 5. Removed duplicate
|
||||||
// initialization here (priority 20) to prevent confusion and potential
|
// initialization here (priority 20) to prevent confusion and potential
|
||||||
|
|
|
||||||
517
includes/find-training/class-hvac-find-training-page.php
Normal file
517
includes/find-training/class-hvac-find-training-page.php
Normal file
|
|
@ -0,0 +1,517 @@
|
||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Find Training Page Handler
|
||||||
|
*
|
||||||
|
* Manages the Find Training page with Google Maps integration
|
||||||
|
* showing trainers and training venues on an interactive map.
|
||||||
|
*
|
||||||
|
* @package HVAC_Community_Events
|
||||||
|
* @since 2.2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
if (!defined('ABSPATH')) {
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class HVAC_Find_Training_Page
|
||||||
|
*
|
||||||
|
* Main controller for the Find Training page functionality.
|
||||||
|
* Uses singleton pattern consistent with other HVAC plugin classes.
|
||||||
|
*/
|
||||||
|
class HVAC_Find_Training_Page {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Singleton instance
|
||||||
|
*
|
||||||
|
* @var HVAC_Find_Training_Page|null
|
||||||
|
*/
|
||||||
|
private static ?self $instance = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Page slug
|
||||||
|
*
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
private string $page_slug = 'find-training';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Google Maps API key
|
||||||
|
*
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
private string $api_key = '';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get singleton instance
|
||||||
|
*
|
||||||
|
* @return HVAC_Find_Training_Page
|
||||||
|
*/
|
||||||
|
public static function get_instance(): self {
|
||||||
|
if (self::$instance === null) {
|
||||||
|
self::$instance = new self();
|
||||||
|
}
|
||||||
|
return self::$instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructor
|
||||||
|
*/
|
||||||
|
private function __construct() {
|
||||||
|
$this->load_api_key();
|
||||||
|
$this->init_hooks();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load Google Maps API key from secure storage
|
||||||
|
*/
|
||||||
|
private function load_api_key(): void {
|
||||||
|
if (class_exists('HVAC_Secure_Storage')) {
|
||||||
|
$this->api_key = HVAC_Secure_Storage::get_credential('hvac_google_maps_api_key', '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize WordPress hooks
|
||||||
|
*/
|
||||||
|
private function init_hooks(): void {
|
||||||
|
// Page registration
|
||||||
|
add_action('init', [$this, 'register_page'], 15);
|
||||||
|
|
||||||
|
// Asset enqueuing
|
||||||
|
add_action('wp_enqueue_scripts', [$this, 'enqueue_assets']);
|
||||||
|
|
||||||
|
// Body classes
|
||||||
|
add_filter('body_class', [$this, 'add_body_classes']);
|
||||||
|
|
||||||
|
// AJAX handlers
|
||||||
|
add_action('wp_ajax_hvac_get_training_map_data', [$this, 'ajax_get_map_data']);
|
||||||
|
add_action('wp_ajax_nopriv_hvac_get_training_map_data', [$this, 'ajax_get_map_data']);
|
||||||
|
|
||||||
|
add_action('wp_ajax_hvac_filter_training_map', [$this, 'ajax_filter_map']);
|
||||||
|
add_action('wp_ajax_nopriv_hvac_filter_training_map', [$this, 'ajax_filter_map']);
|
||||||
|
|
||||||
|
add_action('wp_ajax_hvac_get_trainer_profile_modal', [$this, 'ajax_get_trainer_profile']);
|
||||||
|
add_action('wp_ajax_nopriv_hvac_get_trainer_profile_modal', [$this, 'ajax_get_trainer_profile']);
|
||||||
|
|
||||||
|
add_action('wp_ajax_hvac_get_venue_info', [$this, 'ajax_get_venue_info']);
|
||||||
|
add_action('wp_ajax_nopriv_hvac_get_venue_info', [$this, 'ajax_get_venue_info']);
|
||||||
|
|
||||||
|
// Redirect from old page
|
||||||
|
add_action('template_redirect', [$this, 'maybe_redirect_from_old_page']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register the Find Training page
|
||||||
|
*/
|
||||||
|
public function register_page(): void {
|
||||||
|
$page = get_page_by_path($this->page_slug);
|
||||||
|
|
||||||
|
if (!$page) {
|
||||||
|
$this->create_page();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create the Find Training page in WordPress
|
||||||
|
*/
|
||||||
|
private function create_page(): void {
|
||||||
|
$page_data = [
|
||||||
|
'post_title' => 'Find Training',
|
||||||
|
'post_name' => $this->page_slug,
|
||||||
|
'post_content' => '<!-- This page uses a custom template -->',
|
||||||
|
'post_status' => 'publish',
|
||||||
|
'post_type' => 'page',
|
||||||
|
'post_author' => 1,
|
||||||
|
'comment_status' => 'closed',
|
||||||
|
'ping_status' => 'closed',
|
||||||
|
'meta_input' => [
|
||||||
|
'_wp_page_template' => 'page-find-training.php',
|
||||||
|
'ast-site-content-layout' => 'page-builder',
|
||||||
|
'site-post-title' => 'disabled',
|
||||||
|
'site-sidebar-layout' => 'no-sidebar',
|
||||||
|
'theme-transparent-header-meta' => 'disabled'
|
||||||
|
]
|
||||||
|
];
|
||||||
|
|
||||||
|
$page_id = wp_insert_post($page_data);
|
||||||
|
|
||||||
|
if ($page_id && !is_wp_error($page_id)) {
|
||||||
|
update_option('hvac_find_training_page_id', $page_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if current page is the Find Training page
|
||||||
|
*
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public function is_find_training_page(): bool {
|
||||||
|
return is_page($this->page_slug) || is_page(get_option('hvac_find_training_page_id'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enqueue page assets
|
||||||
|
*/
|
||||||
|
public function enqueue_assets(): void {
|
||||||
|
if (!$this->is_find_training_page()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enqueue CSS
|
||||||
|
wp_enqueue_style(
|
||||||
|
'hvac-find-training',
|
||||||
|
HVAC_PLUGIN_URL . 'assets/css/find-training-map.css',
|
||||||
|
['astra-theme-css'],
|
||||||
|
HVAC_VERSION
|
||||||
|
);
|
||||||
|
|
||||||
|
// Enqueue Google Maps API with MarkerClusterer
|
||||||
|
if (!empty($this->api_key)) {
|
||||||
|
wp_enqueue_script(
|
||||||
|
'google-maps-api',
|
||||||
|
'https://maps.googleapis.com/maps/api/js?key=' . esc_attr($this->api_key) . '&libraries=places&callback=Function.prototype',
|
||||||
|
[],
|
||||||
|
null,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
|
||||||
|
// MarkerClusterer library
|
||||||
|
wp_enqueue_script(
|
||||||
|
'google-maps-markerclusterer',
|
||||||
|
'https://unpkg.com/@googlemaps/markerclusterer/dist/index.min.js',
|
||||||
|
['google-maps-api'],
|
||||||
|
'2.5.3',
|
||||||
|
true
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enqueue main map JavaScript
|
||||||
|
wp_enqueue_script(
|
||||||
|
'hvac-find-training-map',
|
||||||
|
HVAC_PLUGIN_URL . 'assets/js/find-training-map.js',
|
||||||
|
['jquery', 'google-maps-api', 'google-maps-markerclusterer'],
|
||||||
|
HVAC_VERSION,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
|
||||||
|
// Enqueue filter JavaScript
|
||||||
|
wp_enqueue_script(
|
||||||
|
'hvac-find-training-filters',
|
||||||
|
HVAC_PLUGIN_URL . 'assets/js/find-training-filters.js',
|
||||||
|
['jquery', 'hvac-find-training-map'],
|
||||||
|
HVAC_VERSION,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
|
||||||
|
// Localize script with data
|
||||||
|
wp_localize_script('hvac-find-training-map', 'hvacFindTraining', [
|
||||||
|
'ajax_url' => admin_url('admin-ajax.php'),
|
||||||
|
'nonce' => wp_create_nonce('hvac_find_training'),
|
||||||
|
'api_key' => !empty($this->api_key) ? 'configured' : '', // Don't expose actual key
|
||||||
|
'map_center' => [
|
||||||
|
'lat' => 39.8283, // US center
|
||||||
|
'lng' => -98.5795
|
||||||
|
],
|
||||||
|
'default_zoom' => 4,
|
||||||
|
'cluster_zoom' => 8,
|
||||||
|
'messages' => [
|
||||||
|
'loading' => __('Loading...', 'hvac-community-events'),
|
||||||
|
'error' => __('An error occurred. Please try again.', 'hvac-community-events'),
|
||||||
|
'no_results' => __('No trainers or venues found matching your criteria.', 'hvac-community-events'),
|
||||||
|
'geolocation_error' => __('Unable to get your location. Please check your browser settings.', 'hvac-community-events'),
|
||||||
|
'geolocation_unsupported' => __('Geolocation is not supported by your browser.', 'hvac-community-events')
|
||||||
|
],
|
||||||
|
'marker_icons' => [
|
||||||
|
'trainer' => HVAC_PLUGIN_URL . 'assets/images/marker-trainer.svg',
|
||||||
|
'venue' => HVAC_PLUGIN_URL . 'assets/images/marker-venue.svg'
|
||||||
|
]
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add body classes for the page
|
||||||
|
*
|
||||||
|
* @param array $classes Existing body classes
|
||||||
|
* @return array Modified body classes
|
||||||
|
*/
|
||||||
|
public function add_body_classes(array $classes): array {
|
||||||
|
if ($this->is_find_training_page()) {
|
||||||
|
$classes[] = 'hvac-find-training-page';
|
||||||
|
$classes[] = 'hvac-full-width';
|
||||||
|
$classes[] = 'hvac-page';
|
||||||
|
}
|
||||||
|
return $classes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Redirect from old /find-a-trainer page to /find-training
|
||||||
|
*/
|
||||||
|
public function maybe_redirect_from_old_page(): void {
|
||||||
|
if (is_page('find-a-trainer')) {
|
||||||
|
wp_safe_redirect(home_url('/find-training/'), 301);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AJAX: Get all map data (trainers and venues)
|
||||||
|
*/
|
||||||
|
public function ajax_get_map_data(): void {
|
||||||
|
// Verify nonce
|
||||||
|
if (!isset($_POST['nonce']) || !wp_verify_nonce($_POST['nonce'], 'hvac_find_training')) {
|
||||||
|
wp_send_json_error(['message' => 'Invalid security token']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$data_provider = HVAC_Training_Map_Data::get_instance();
|
||||||
|
|
||||||
|
$trainers = $data_provider->get_trainer_markers();
|
||||||
|
$venues = $data_provider->get_venue_markers();
|
||||||
|
|
||||||
|
wp_send_json_success([
|
||||||
|
'trainers' => $trainers,
|
||||||
|
'venues' => $venues,
|
||||||
|
'total_trainers' => count($trainers),
|
||||||
|
'total_venues' => count($venues)
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AJAX: Filter map markers
|
||||||
|
*/
|
||||||
|
public function ajax_filter_map(): void {
|
||||||
|
// Verify nonce
|
||||||
|
if (!isset($_POST['nonce']) || !wp_verify_nonce($_POST['nonce'], 'hvac_find_training')) {
|
||||||
|
wp_send_json_error(['message' => 'Invalid security token']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$filters = [
|
||||||
|
'state' => sanitize_text_field($_POST['state'] ?? ''),
|
||||||
|
'certification' => sanitize_text_field($_POST['certification'] ?? ''),
|
||||||
|
'training_format' => sanitize_text_field($_POST['training_format'] ?? ''),
|
||||||
|
'search' => sanitize_text_field($_POST['search'] ?? ''),
|
||||||
|
'show_trainers' => filter_var($_POST['show_trainers'] ?? true, FILTER_VALIDATE_BOOLEAN),
|
||||||
|
'show_venues' => filter_var($_POST['show_venues'] ?? true, FILTER_VALIDATE_BOOLEAN),
|
||||||
|
'lat' => isset($_POST['lat']) ? floatval($_POST['lat']) : null,
|
||||||
|
'lng' => isset($_POST['lng']) ? floatval($_POST['lng']) : null,
|
||||||
|
'radius' => isset($_POST['radius']) ? intval($_POST['radius']) : 100 // km
|
||||||
|
];
|
||||||
|
|
||||||
|
$data_provider = HVAC_Training_Map_Data::get_instance();
|
||||||
|
|
||||||
|
$result = [
|
||||||
|
'trainers' => [],
|
||||||
|
'venues' => []
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($filters['show_trainers']) {
|
||||||
|
$result['trainers'] = $data_provider->get_trainer_markers($filters);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($filters['show_venues']) {
|
||||||
|
$result['venues'] = $data_provider->get_venue_markers($filters);
|
||||||
|
}
|
||||||
|
|
||||||
|
$result['total_trainers'] = count($result['trainers']);
|
||||||
|
$result['total_venues'] = count($result['venues']);
|
||||||
|
$result['filters_applied'] = array_filter($filters, function($v) {
|
||||||
|
return !empty($v) && $v !== true;
|
||||||
|
});
|
||||||
|
|
||||||
|
wp_send_json_success($result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AJAX: Get trainer profile for modal
|
||||||
|
*/
|
||||||
|
public function ajax_get_trainer_profile(): void {
|
||||||
|
// Verify nonce
|
||||||
|
if (!isset($_POST['nonce']) || !wp_verify_nonce($_POST['nonce'], 'hvac_find_training')) {
|
||||||
|
wp_send_json_error(['message' => 'Invalid security token']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$profile_id = absint($_POST['profile_id'] ?? 0);
|
||||||
|
|
||||||
|
if (!$profile_id) {
|
||||||
|
wp_send_json_error(['message' => 'Invalid profile ID']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$data_provider = HVAC_Training_Map_Data::get_instance();
|
||||||
|
$trainer_data = $data_provider->get_trainer_full_profile($profile_id);
|
||||||
|
|
||||||
|
if (!$trainer_data) {
|
||||||
|
wp_send_json_error(['message' => 'Trainer not found']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate modal HTML
|
||||||
|
ob_start();
|
||||||
|
$this->render_trainer_modal_content($trainer_data);
|
||||||
|
$html = ob_get_clean();
|
||||||
|
|
||||||
|
wp_send_json_success([
|
||||||
|
'trainer' => $trainer_data,
|
||||||
|
'html' => $html
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AJAX: Get venue info for info window
|
||||||
|
*/
|
||||||
|
public function ajax_get_venue_info(): void {
|
||||||
|
// Verify nonce
|
||||||
|
if (!isset($_POST['nonce']) || !wp_verify_nonce($_POST['nonce'], 'hvac_find_training')) {
|
||||||
|
wp_send_json_error(['message' => 'Invalid security token']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$venue_id = absint($_POST['venue_id'] ?? 0);
|
||||||
|
|
||||||
|
if (!$venue_id) {
|
||||||
|
wp_send_json_error(['message' => 'Invalid venue ID']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$data_provider = HVAC_Training_Map_Data::get_instance();
|
||||||
|
$venue_data = $data_provider->get_venue_full_info($venue_id);
|
||||||
|
|
||||||
|
if (!$venue_data) {
|
||||||
|
wp_send_json_error(['message' => 'Venue not found']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
wp_send_json_success(['venue' => $venue_data]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render trainer modal content
|
||||||
|
*
|
||||||
|
* @param array $trainer Trainer data
|
||||||
|
*/
|
||||||
|
private function render_trainer_modal_content(array $trainer): void {
|
||||||
|
?>
|
||||||
|
<div class="hvac-training-modal-header">
|
||||||
|
<h2><?php echo esc_html($trainer['name']); ?></h2>
|
||||||
|
<button class="hvac-modal-close" aria-label="Close modal">×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="hvac-training-modal-body">
|
||||||
|
<div class="hvac-training-profile-section">
|
||||||
|
<div class="hvac-training-profile-header">
|
||||||
|
<?php if (!empty($trainer['image'])): ?>
|
||||||
|
<img src="<?php echo esc_url($trainer['image']); ?>" alt="<?php echo esc_attr($trainer['name']); ?>" class="hvac-training-profile-image">
|
||||||
|
<?php else: ?>
|
||||||
|
<div class="hvac-training-profile-avatar">
|
||||||
|
<span class="dashicons dashicons-businessperson"></span>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<div class="hvac-training-profile-info">
|
||||||
|
<p class="hvac-training-location">
|
||||||
|
<?php echo esc_html($trainer['city'] . ', ' . $trainer['state']); ?>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<?php if (!empty($trainer['certifications'])): ?>
|
||||||
|
<div class="hvac-training-certifications">
|
||||||
|
<?php foreach ($trainer['certifications'] as $cert): ?>
|
||||||
|
<span class="hvac-cert-badge"><?php echo esc_html($cert); ?></span>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php if (!empty($trainer['company'])): ?>
|
||||||
|
<p class="hvac-training-company"><?php echo esc_html($trainer['company']); ?></p>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<p class="hvac-training-events-count">
|
||||||
|
Total Training Events: <strong><?php echo intval($trainer['event_count']); ?></strong>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php if (!empty($trainer['training_formats'])): ?>
|
||||||
|
<div class="hvac-training-detail">
|
||||||
|
<strong>Training Formats:</strong> <?php echo esc_html(implode(', ', $trainer['training_formats'])); ?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php if (!empty($trainer['upcoming_events'])): ?>
|
||||||
|
<div class="hvac-training-events">
|
||||||
|
<h4>Upcoming Events</h4>
|
||||||
|
<ul class="hvac-events-list">
|
||||||
|
<?php foreach (array_slice($trainer['upcoming_events'], 0, 5) as $event): ?>
|
||||||
|
<li>
|
||||||
|
<a href="<?php echo esc_url($event['url']); ?>" target="_blank">
|
||||||
|
<?php echo esc_html($event['title']); ?>
|
||||||
|
</a>
|
||||||
|
<span class="hvac-event-date"><?php echo esc_html($event['date']); ?></span>
|
||||||
|
</li>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="hvac-training-contact-section">
|
||||||
|
<h4>Contact Trainer</h4>
|
||||||
|
<form class="hvac-training-contact-form" data-trainer-id="<?php echo esc_attr($trainer['user_id']); ?>" data-profile-id="<?php echo esc_attr($trainer['profile_id']); ?>">
|
||||||
|
<div class="hvac-form-row">
|
||||||
|
<input type="text" name="first_name" placeholder="First Name" required>
|
||||||
|
<input type="text" name="last_name" placeholder="Last Name" required>
|
||||||
|
</div>
|
||||||
|
<div class="hvac-form-row">
|
||||||
|
<input type="email" name="email" placeholder="Email" required>
|
||||||
|
<input type="tel" name="phone" placeholder="Phone Number">
|
||||||
|
</div>
|
||||||
|
<div class="hvac-form-row">
|
||||||
|
<input type="text" name="city" placeholder="City">
|
||||||
|
<input type="text" name="state_province" placeholder="State/Province">
|
||||||
|
</div>
|
||||||
|
<div class="hvac-form-field">
|
||||||
|
<input type="text" name="company" placeholder="Company">
|
||||||
|
</div>
|
||||||
|
<div class="hvac-form-field">
|
||||||
|
<textarea name="message" placeholder="Message" rows="3"></textarea>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="hvac-btn-primary">Send Message</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="hvac-form-message hvac-form-success" style="display: none;">
|
||||||
|
Your message has been sent! Check your inbox for more details.
|
||||||
|
</div>
|
||||||
|
<div class="hvac-form-message hvac-form-error" style="display: none;">
|
||||||
|
There was an error sending your message. Please try again.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get filter options for dropdowns
|
||||||
|
*
|
||||||
|
* @return array Filter options
|
||||||
|
*/
|
||||||
|
public function get_filter_options(): array {
|
||||||
|
$data_provider = HVAC_Training_Map_Data::get_instance();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'states' => $data_provider->get_state_options(),
|
||||||
|
'certifications' => $data_provider->get_certification_options(),
|
||||||
|
'training_formats' => $data_provider->get_training_format_options()
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get page slug
|
||||||
|
*
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
public function get_page_slug(): string {
|
||||||
|
return $this->page_slug;
|
||||||
|
}
|
||||||
|
}
|
||||||
771
includes/find-training/class-hvac-training-map-data.php
Normal file
771
includes/find-training/class-hvac-training-map-data.php
Normal file
|
|
@ -0,0 +1,771 @@
|
||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Training Map Data Provider
|
||||||
|
*
|
||||||
|
* Provides marker data for trainers and venues on the Find Training map.
|
||||||
|
*
|
||||||
|
* @package HVAC_Community_Events
|
||||||
|
* @since 2.2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
if (!defined('ABSPATH')) {
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class HVAC_Training_Map_Data
|
||||||
|
*
|
||||||
|
* Data provider for the Find Training Google Maps integration.
|
||||||
|
* Queries trainers and venues with coordinates for map display.
|
||||||
|
*/
|
||||||
|
class HVAC_Training_Map_Data {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Singleton instance
|
||||||
|
*
|
||||||
|
* @var HVAC_Training_Map_Data|null
|
||||||
|
*/
|
||||||
|
private static ?self $instance = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cache group for queries
|
||||||
|
*
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
private string $cache_group = 'hvac_training_map';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cache expiration (1 hour)
|
||||||
|
*
|
||||||
|
* @var int
|
||||||
|
*/
|
||||||
|
private int $cache_expiration = 3600;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get singleton instance
|
||||||
|
*
|
||||||
|
* @return HVAC_Training_Map_Data
|
||||||
|
*/
|
||||||
|
public static function get_instance(): self {
|
||||||
|
if (self::$instance === null) {
|
||||||
|
self::$instance = new self();
|
||||||
|
}
|
||||||
|
return self::$instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructor
|
||||||
|
*/
|
||||||
|
private function __construct() {
|
||||||
|
// Clear cache when profiles or venues are updated
|
||||||
|
add_action('save_post_trainer_profile', [$this, 'clear_trainer_cache']);
|
||||||
|
add_action('save_post_tribe_venue', [$this, 'clear_venue_cache']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get trainer markers for map
|
||||||
|
*
|
||||||
|
* @param array $filters Optional filters
|
||||||
|
* @return array Trainer markers data
|
||||||
|
*/
|
||||||
|
public function get_trainer_markers(array $filters = []): array {
|
||||||
|
// Generate cache key based on filters
|
||||||
|
$cache_key = 'trainers_' . md5(serialize($filters));
|
||||||
|
$cached = wp_cache_get($cache_key, $this->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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
517
includes/find-training/class-hvac-venue-geocoding.php
Normal file
517
includes/find-training/class-hvac-venue-geocoding.php
Normal file
|
|
@ -0,0 +1,517 @@
|
||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Venue Geocoding Service
|
||||||
|
*
|
||||||
|
* Handles geocoding for TEC venues to add lat/lng coordinates.
|
||||||
|
*
|
||||||
|
* @package HVAC_Community_Events
|
||||||
|
* @since 2.2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
if (!defined('ABSPATH')) {
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class HVAC_Venue_Geocoding
|
||||||
|
*
|
||||||
|
* Manages geocoding of venue addresses using Google Maps Geocoding API.
|
||||||
|
* Auto-geocodes new venues and provides batch processing for existing venues.
|
||||||
|
*/
|
||||||
|
class HVAC_Venue_Geocoding {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Singleton instance
|
||||||
|
*
|
||||||
|
* @var HVAC_Venue_Geocoding|null
|
||||||
|
*/
|
||||||
|
private static ?self $instance = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Google Maps API key
|
||||||
|
*
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
private string $api_key = '';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rate limit per minute
|
||||||
|
*
|
||||||
|
* @var int
|
||||||
|
*/
|
||||||
|
private int $rate_limit = 50;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cache duration for geocoding results
|
||||||
|
*
|
||||||
|
* @var int
|
||||||
|
*/
|
||||||
|
private int $cache_duration = DAY_IN_SECONDS;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get singleton instance
|
||||||
|
*
|
||||||
|
* @return HVAC_Venue_Geocoding
|
||||||
|
*/
|
||||||
|
public static function get_instance(): self {
|
||||||
|
if (self::$instance === null) {
|
||||||
|
self::$instance = new self();
|
||||||
|
}
|
||||||
|
return self::$instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructor
|
||||||
|
*/
|
||||||
|
private function __construct() {
|
||||||
|
$this->load_api_key();
|
||||||
|
$this->init_hooks();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load API key from secure storage
|
||||||
|
*/
|
||||||
|
private function load_api_key(): void {
|
||||||
|
if (class_exists('HVAC_Secure_Storage')) {
|
||||||
|
$this->api_key = HVAC_Secure_Storage::get_credential('hvac_google_maps_api_key', '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize hooks
|
||||||
|
*/
|
||||||
|
private function init_hooks(): void {
|
||||||
|
// Auto-geocode new venues on save
|
||||||
|
add_action('save_post_tribe_venue', [$this, 'maybe_geocode_venue'], 20, 2);
|
||||||
|
|
||||||
|
// Register geocoding action for async processing
|
||||||
|
add_action('hvac_geocode_venue', [$this, 'geocode_venue']);
|
||||||
|
|
||||||
|
// Admin action for batch geocoding
|
||||||
|
add_action('wp_ajax_hvac_batch_geocode_venues', [$this, 'ajax_batch_geocode']);
|
||||||
|
|
||||||
|
// Clear venue coordinates when address changes
|
||||||
|
add_action('updated_post_meta', [$this, 'on_venue_meta_update'], 10, 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maybe geocode venue on save
|
||||||
|
*
|
||||||
|
* @param int $venue_id Venue post ID
|
||||||
|
* @param WP_Post $post Post object
|
||||||
|
*/
|
||||||
|
public function maybe_geocode_venue(int $venue_id, WP_Post $post): void {
|
||||||
|
// Skip autosaves and revisions
|
||||||
|
if (wp_is_post_autosave($venue_id) || wp_is_post_revision($venue_id)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if coordinates already exist
|
||||||
|
$has_coords = $this->venue_has_coordinates($venue_id);
|
||||||
|
|
||||||
|
if (!$has_coords) {
|
||||||
|
// Schedule geocoding to avoid blocking save
|
||||||
|
wp_schedule_single_event(time() + 5, 'hvac_geocode_venue', [$venue_id]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if venue already has coordinates
|
||||||
|
*
|
||||||
|
* @param int $venue_id Venue post ID
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public function venue_has_coordinates(int $venue_id): bool {
|
||||||
|
// Check custom coordinates
|
||||||
|
$lat = get_post_meta($venue_id, 'venue_latitude', true);
|
||||||
|
$lng = get_post_meta($venue_id, 'venue_longitude', true);
|
||||||
|
|
||||||
|
if (!empty($lat) && !empty($lng)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check TEC built-in coordinates
|
||||||
|
$tec_lat = get_post_meta($venue_id, '_VenueLat', true);
|
||||||
|
$tec_lng = get_post_meta($venue_id, '_VenueLng', true);
|
||||||
|
|
||||||
|
return !empty($tec_lat) && !empty($tec_lng);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Geocode a venue
|
||||||
|
*
|
||||||
|
* @param int $venue_id Venue post ID
|
||||||
|
* @return bool Success
|
||||||
|
*/
|
||||||
|
public function geocode_venue(int $venue_id): bool {
|
||||||
|
// Rate limiting check
|
||||||
|
if (!$this->check_rate_limit()) {
|
||||||
|
// Reschedule
|
||||||
|
wp_schedule_single_event(time() + 60, 'hvac_geocode_venue', [$venue_id]);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build address
|
||||||
|
$address = $this->build_venue_address($venue_id);
|
||||||
|
|
||||||
|
if (empty($address)) {
|
||||||
|
update_post_meta($venue_id, '_venue_geocoding_status', 'no_address');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
update_post_meta($venue_id, '_venue_geocoding_attempt', time());
|
||||||
|
|
||||||
|
// Check cache first
|
||||||
|
$cache_key = 'venue_geo_' . md5($address);
|
||||||
|
$cached = get_transient($cache_key);
|
||||||
|
|
||||||
|
if ($cached !== false) {
|
||||||
|
return $this->save_coordinates($venue_id, $cached);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make API request
|
||||||
|
$result = $this->geocode_address($address);
|
||||||
|
|
||||||
|
if ($result && isset($result['lat'], $result['lng'])) {
|
||||||
|
// Cache result
|
||||||
|
set_transient($cache_key, $result, $this->cache_duration);
|
||||||
|
|
||||||
|
return $this->save_coordinates($venue_id, $result);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle failure
|
||||||
|
$this->handle_geocoding_failure($venue_id, $result);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build venue address string from TEC meta
|
||||||
|
*
|
||||||
|
* @param int $venue_id Venue post ID
|
||||||
|
* @return string Full address
|
||||||
|
*/
|
||||||
|
private function build_venue_address(int $venue_id): string {
|
||||||
|
$parts = [];
|
||||||
|
|
||||||
|
$address = get_post_meta($venue_id, '_VenueAddress', true);
|
||||||
|
$city = get_post_meta($venue_id, '_VenueCity', true);
|
||||||
|
$state = get_post_meta($venue_id, '_VenueStateProvince', true) ?: get_post_meta($venue_id, '_VenueState', true);
|
||||||
|
$zip = get_post_meta($venue_id, '_VenueZip', true);
|
||||||
|
$country = get_post_meta($venue_id, '_VenueCountry', true);
|
||||||
|
|
||||||
|
if (!empty($address)) {
|
||||||
|
$parts[] = $address;
|
||||||
|
}
|
||||||
|
if (!empty($city)) {
|
||||||
|
$parts[] = $city;
|
||||||
|
}
|
||||||
|
if (!empty($state)) {
|
||||||
|
$parts[] = $state;
|
||||||
|
}
|
||||||
|
if (!empty($zip)) {
|
||||||
|
$parts[] = $zip;
|
||||||
|
}
|
||||||
|
if (!empty($country)) {
|
||||||
|
$parts[] = $country;
|
||||||
|
}
|
||||||
|
|
||||||
|
return implode(', ', $parts);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Make geocoding API request
|
||||||
|
*
|
||||||
|
* @param string $address Address to geocode
|
||||||
|
* @return array|null Result with lat/lng or null on failure
|
||||||
|
*/
|
||||||
|
private function geocode_address(string $address): ?array {
|
||||||
|
if (empty($this->api_key)) {
|
||||||
|
return ['error' => 'No API key configured'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$url = 'https://maps.googleapis.com/maps/api/geocode/json';
|
||||||
|
$params = [
|
||||||
|
'address' => $address,
|
||||||
|
'key' => $this->api_key,
|
||||||
|
'components' => 'country:US|country:CA' // Restrict to North America
|
||||||
|
];
|
||||||
|
|
||||||
|
$response = wp_remote_get($url . '?' . http_build_query($params), [
|
||||||
|
'timeout' => 10,
|
||||||
|
'user-agent' => 'HVAC Training Directory/1.0'
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (is_wp_error($response)) {
|
||||||
|
return ['error' => $response->get_error_message()];
|
||||||
|
}
|
||||||
|
|
||||||
|
$body = wp_remote_retrieve_body($response);
|
||||||
|
$data = json_decode($body, true);
|
||||||
|
|
||||||
|
if (!$data || $data['status'] !== 'OK' || empty($data['results'])) {
|
||||||
|
return ['error' => $data['status'] ?? 'Unknown error'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$location = $data['results'][0]['geometry']['location'];
|
||||||
|
|
||||||
|
return [
|
||||||
|
'lat' => $location['lat'],
|
||||||
|
'lng' => $location['lng'],
|
||||||
|
'formatted_address' => $data['results'][0]['formatted_address'],
|
||||||
|
'place_id' => $data['results'][0]['place_id'] ?? ''
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save coordinates to venue
|
||||||
|
*
|
||||||
|
* @param int $venue_id Venue post ID
|
||||||
|
* @param array $result Geocoding result
|
||||||
|
* @return bool Success
|
||||||
|
*/
|
||||||
|
private function save_coordinates(int $venue_id, array $result): bool {
|
||||||
|
if (!isset($result['lat']) || !isset($result['lng'])) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save to our custom meta
|
||||||
|
update_post_meta($venue_id, 'venue_latitude', $result['lat']);
|
||||||
|
update_post_meta($venue_id, 'venue_longitude', $result['lng']);
|
||||||
|
|
||||||
|
// Also update TEC's meta for compatibility
|
||||||
|
update_post_meta($venue_id, '_VenueLat', $result['lat']);
|
||||||
|
update_post_meta($venue_id, '_VenueLng', $result['lng']);
|
||||||
|
|
||||||
|
if (!empty($result['formatted_address'])) {
|
||||||
|
update_post_meta($venue_id, '_venue_formatted_address', $result['formatted_address']);
|
||||||
|
}
|
||||||
|
|
||||||
|
update_post_meta($venue_id, '_venue_geocoding_status', 'success');
|
||||||
|
update_post_meta($venue_id, '_venue_geocoding_date', time());
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle geocoding failure
|
||||||
|
*
|
||||||
|
* @param int $venue_id Venue post ID
|
||||||
|
* @param array|null $result Error result
|
||||||
|
*/
|
||||||
|
private function handle_geocoding_failure(int $venue_id, ?array $result): void {
|
||||||
|
$error = $result['error'] ?? 'Unknown error';
|
||||||
|
|
||||||
|
update_post_meta($venue_id, '_venue_geocoding_status', 'failed');
|
||||||
|
update_post_meta($venue_id, '_venue_geocoding_error', $error);
|
||||||
|
|
||||||
|
// Handle specific errors
|
||||||
|
switch ($error) {
|
||||||
|
case 'OVER_QUERY_LIMIT':
|
||||||
|
// Retry in 1 hour
|
||||||
|
wp_schedule_single_event(time() + HOUR_IN_SECONDS, 'hvac_geocode_venue', [$venue_id]);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'ZERO_RESULTS':
|
||||||
|
// Try fallback with less specific address
|
||||||
|
$this->try_fallback_geocoding($venue_id);
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
// Log error
|
||||||
|
if (defined('WP_DEBUG') && WP_DEBUG) {
|
||||||
|
error_log("HVAC Venue Geocoding failed for venue {$venue_id}: {$error}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Try fallback geocoding with city/state only
|
||||||
|
*
|
||||||
|
* @param int $venue_id Venue post ID
|
||||||
|
*/
|
||||||
|
private function try_fallback_geocoding(int $venue_id): void {
|
||||||
|
$city = get_post_meta($venue_id, '_VenueCity', true);
|
||||||
|
$state = get_post_meta($venue_id, '_VenueStateProvince', true) ?: get_post_meta($venue_id, '_VenueState', true);
|
||||||
|
$country = get_post_meta($venue_id, '_VenueCountry', true) ?: 'USA';
|
||||||
|
|
||||||
|
if (empty($city) && empty($state)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$address = implode(', ', array_filter([$city, $state, $country]));
|
||||||
|
$result = $this->geocode_address($address);
|
||||||
|
|
||||||
|
if ($result && isset($result['lat'], $result['lng'])) {
|
||||||
|
$this->save_coordinates($venue_id, $result);
|
||||||
|
update_post_meta($venue_id, '_venue_geocoding_status', 'success_fallback');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check rate limiting
|
||||||
|
*
|
||||||
|
* @return bool Can make request
|
||||||
|
*/
|
||||||
|
private function check_rate_limit(): bool {
|
||||||
|
$rate_key = 'hvac_venue_geocoding_rate_' . gmdate('Y-m-d-H-i');
|
||||||
|
$current = get_transient($rate_key) ?: 0;
|
||||||
|
|
||||||
|
if ($current >= $this->rate_limit) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
set_transient($rate_key, $current + 1, 60);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear coordinates when venue address changes
|
||||||
|
*
|
||||||
|
* @param int $meta_id Meta ID
|
||||||
|
* @param int $post_id Post ID
|
||||||
|
* @param string $meta_key Meta key
|
||||||
|
* @param mixed $meta_value Meta value
|
||||||
|
*/
|
||||||
|
public function on_venue_meta_update(int $meta_id, int $post_id, string $meta_key, $meta_value): void {
|
||||||
|
if (get_post_type($post_id) !== 'tribe_venue') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$address_fields = ['_VenueAddress', '_VenueCity', '_VenueStateProvince', '_VenueState', '_VenueZip'];
|
||||||
|
|
||||||
|
if (in_array($meta_key, $address_fields, true)) {
|
||||||
|
// Address changed - clear coordinates to force re-geocoding
|
||||||
|
delete_post_meta($post_id, 'venue_latitude');
|
||||||
|
delete_post_meta($post_id, 'venue_longitude');
|
||||||
|
delete_post_meta($post_id, '_venue_geocoding_status');
|
||||||
|
|
||||||
|
// Schedule re-geocoding
|
||||||
|
wp_schedule_single_event(time() + 5, 'hvac_geocode_venue', [$post_id]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AJAX handler for batch geocoding venues
|
||||||
|
*/
|
||||||
|
public function ajax_batch_geocode(): void {
|
||||||
|
// Check permissions
|
||||||
|
if (!current_user_can('manage_options')) {
|
||||||
|
wp_send_json_error(['message' => 'Permission denied']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify nonce
|
||||||
|
if (!isset($_POST['nonce']) || !wp_verify_nonce($_POST['nonce'], 'hvac_batch_geocode_venues')) {
|
||||||
|
wp_send_json_error(['message' => 'Invalid security token']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$limit = absint($_POST['limit'] ?? 10);
|
||||||
|
$result = $this->batch_geocode_venues($limit);
|
||||||
|
|
||||||
|
wp_send_json_success($result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Batch geocode venues without coordinates
|
||||||
|
*
|
||||||
|
* @param int $limit Maximum venues to process
|
||||||
|
* @return array Results
|
||||||
|
*/
|
||||||
|
public function batch_geocode_venues(int $limit = 10): array {
|
||||||
|
$venues = get_posts([
|
||||||
|
'post_type' => 'tribe_venue',
|
||||||
|
'posts_per_page' => $limit,
|
||||||
|
'post_status' => 'publish',
|
||||||
|
'meta_query' => [
|
||||||
|
'relation' => 'AND',
|
||||||
|
[
|
||||||
|
'key' => 'venue_latitude',
|
||||||
|
'compare' => 'NOT EXISTS'
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'key' => '_VenueLat',
|
||||||
|
'compare' => 'NOT EXISTS'
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]);
|
||||||
|
|
||||||
|
$results = [
|
||||||
|
'processed' => 0,
|
||||||
|
'success' => 0,
|
||||||
|
'failed' => 0,
|
||||||
|
'remaining' => 0
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($venues as $venue) {
|
||||||
|
if (!$this->check_rate_limit()) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
$results['processed']++;
|
||||||
|
|
||||||
|
if ($this->geocode_venue($venue->ID)) {
|
||||||
|
$results['success']++;
|
||||||
|
} else {
|
||||||
|
$results['failed']++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count remaining
|
||||||
|
$remaining_query = new WP_Query([
|
||||||
|
'post_type' => 'tribe_venue',
|
||||||
|
'posts_per_page' => 1,
|
||||||
|
'post_status' => 'publish',
|
||||||
|
'fields' => 'ids',
|
||||||
|
'meta_query' => [
|
||||||
|
'relation' => 'AND',
|
||||||
|
[
|
||||||
|
'key' => 'venue_latitude',
|
||||||
|
'compare' => 'NOT EXISTS'
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'key' => '_VenueLat',
|
||||||
|
'compare' => 'NOT EXISTS'
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]);
|
||||||
|
|
||||||
|
$results['remaining'] = $remaining_query->found_posts;
|
||||||
|
|
||||||
|
return $results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get geocoding status for a venue
|
||||||
|
*
|
||||||
|
* @param int $venue_id Venue post ID
|
||||||
|
* @return array Status info
|
||||||
|
*/
|
||||||
|
public function get_geocoding_status(int $venue_id): array {
|
||||||
|
return [
|
||||||
|
'has_coordinates' => $this->venue_has_coordinates($venue_id),
|
||||||
|
'latitude' => get_post_meta($venue_id, 'venue_latitude', true) ?: get_post_meta($venue_id, '_VenueLat', true),
|
||||||
|
'longitude' => get_post_meta($venue_id, 'venue_longitude', true) ?: get_post_meta($venue_id, '_VenueLng', true),
|
||||||
|
'status' => get_post_meta($venue_id, '_venue_geocoding_status', true),
|
||||||
|
'error' => get_post_meta($venue_id, '_venue_geocoding_error', true),
|
||||||
|
'last_attempt' => get_post_meta($venue_id, '_venue_geocoding_attempt', true),
|
||||||
|
'geocoded_date' => get_post_meta($venue_id, '_venue_geocoding_date', true)
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear coordinates for a venue
|
||||||
|
*
|
||||||
|
* @param int $venue_id Venue post ID
|
||||||
|
*/
|
||||||
|
public function clear_coordinates(int $venue_id): void {
|
||||||
|
delete_post_meta($venue_id, 'venue_latitude');
|
||||||
|
delete_post_meta($venue_id, 'venue_longitude');
|
||||||
|
delete_post_meta($venue_id, '_VenueLat');
|
||||||
|
delete_post_meta($venue_id, '_VenueLng');
|
||||||
|
delete_post_meta($venue_id, '_venue_formatted_address');
|
||||||
|
delete_post_meta($venue_id, '_venue_geocoding_status');
|
||||||
|
delete_post_meta($venue_id, '_venue_geocoding_error');
|
||||||
|
delete_post_meta($venue_id, '_venue_geocoding_date');
|
||||||
|
}
|
||||||
|
}
|
||||||
200
templates/page-find-training.php
Normal file
200
templates/page-find-training.php
Normal file
|
|
@ -0,0 +1,200 @@
|
||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Template Name: Find Training
|
||||||
|
*
|
||||||
|
* Template for displaying the Find Training page with Google Maps
|
||||||
|
* showing trainers and venues on an interactive map.
|
||||||
|
*
|
||||||
|
* @package HVAC_Community_Events
|
||||||
|
* @since 2.2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
defined('ABSPATH') || exit;
|
||||||
|
define('HVAC_IN_PAGE_TEMPLATE', true);
|
||||||
|
|
||||||
|
get_header();
|
||||||
|
|
||||||
|
// Get page handler instance
|
||||||
|
$find_training = HVAC_Find_Training_Page::get_instance();
|
||||||
|
$filter_options = $find_training->get_filter_options();
|
||||||
|
?>
|
||||||
|
|
||||||
|
<div class="hvac-find-training-page">
|
||||||
|
<div class="ast-container">
|
||||||
|
|
||||||
|
<!-- Page Title -->
|
||||||
|
<h1 class="hvac-page-title">Find Training</h1>
|
||||||
|
|
||||||
|
<!-- Intro Section -->
|
||||||
|
<div class="hvac-find-training-intro">
|
||||||
|
<p>Upskill HVAC is proud to be the only training body offering Certified measureQuick training.</p>
|
||||||
|
<p><strong>Certified measureQuick Trainers</strong> have demonstrated their skills and mastery of HVAC science and the measureQuick app, and are authorized to provide measureQuick training to the industry.</p>
|
||||||
|
<p>Use the interactive map and filters below to discover trainers and training venues near you. Click on any marker to view details.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Map and Filters Container -->
|
||||||
|
<div class="hvac-map-filters-wrapper">
|
||||||
|
|
||||||
|
<!-- Map Section -->
|
||||||
|
<div class="hvac-map-section">
|
||||||
|
<div id="hvac-training-map" class="hvac-google-map">
|
||||||
|
<div class="hvac-map-loading">
|
||||||
|
<span class="dashicons dashicons-location"></span>
|
||||||
|
<p>Loading map...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Map Legend -->
|
||||||
|
<div class="hvac-map-legend">
|
||||||
|
<div class="hvac-legend-item">
|
||||||
|
<span class="hvac-legend-marker hvac-legend-trainer"></span>
|
||||||
|
<span>Trainer</span>
|
||||||
|
</div>
|
||||||
|
<div class="hvac-legend-item">
|
||||||
|
<span class="hvac-legend-marker hvac-legend-venue"></span>
|
||||||
|
<span>Training Venue</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Filters Section -->
|
||||||
|
<div class="hvac-filters-section">
|
||||||
|
<!-- Search Box -->
|
||||||
|
<div class="hvac-search-box">
|
||||||
|
<input type="text" id="hvac-training-search" class="hvac-search-input" placeholder="Search trainers or venues..." aria-label="Search">
|
||||||
|
<span class="dashicons dashicons-search"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Near Me Button -->
|
||||||
|
<button type="button" id="hvac-near-me-btn" class="hvac-near-me-btn">
|
||||||
|
<span class="dashicons dashicons-location-alt"></span>
|
||||||
|
Near Me
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Filters Header -->
|
||||||
|
<div class="hvac-filters-header">
|
||||||
|
<span class="hvac-filters-label">Filters:</span>
|
||||||
|
<button type="button" class="hvac-clear-filters" style="display: none;">
|
||||||
|
Clear All
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- State Filter -->
|
||||||
|
<div class="hvac-filter-group">
|
||||||
|
<label for="hvac-filter-state">State / Province</label>
|
||||||
|
<select id="hvac-filter-state" class="hvac-filter-select">
|
||||||
|
<option value="">All States</option>
|
||||||
|
<?php foreach ($filter_options['states'] as $state): ?>
|
||||||
|
<option value="<?php echo esc_attr($state); ?>"><?php echo esc_html($state); ?></option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Certification Filter -->
|
||||||
|
<div class="hvac-filter-group">
|
||||||
|
<label for="hvac-filter-certification">Certification</label>
|
||||||
|
<select id="hvac-filter-certification" class="hvac-filter-select">
|
||||||
|
<option value="">All Certifications</option>
|
||||||
|
<?php foreach ($filter_options['certifications'] as $cert): ?>
|
||||||
|
<option value="<?php echo esc_attr($cert); ?>"><?php echo esc_html($cert); ?></option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Training Format Filter -->
|
||||||
|
<div class="hvac-filter-group">
|
||||||
|
<label for="hvac-filter-format">Training Format</label>
|
||||||
|
<select id="hvac-filter-format" class="hvac-filter-select">
|
||||||
|
<option value="">All Formats</option>
|
||||||
|
<?php foreach ($filter_options['training_formats'] as $format): ?>
|
||||||
|
<option value="<?php echo esc_attr($format); ?>"><?php echo esc_html($format); ?></option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Marker Type Toggles -->
|
||||||
|
<div class="hvac-marker-toggles">
|
||||||
|
<label class="hvac-toggle">
|
||||||
|
<input type="checkbox" id="hvac-show-trainers" checked>
|
||||||
|
<span class="hvac-toggle-slider"></span>
|
||||||
|
<span class="hvac-toggle-label">Show Trainers</span>
|
||||||
|
</label>
|
||||||
|
<label class="hvac-toggle">
|
||||||
|
<input type="checkbox" id="hvac-show-venues" checked>
|
||||||
|
<span class="hvac-toggle-slider"></span>
|
||||||
|
<span class="hvac-toggle-label">Show Venues</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Active Filters -->
|
||||||
|
<div class="hvac-active-filters"></div>
|
||||||
|
|
||||||
|
<!-- Results Count -->
|
||||||
|
<div class="hvac-results-count">
|
||||||
|
<span id="hvac-trainer-count">0</span> trainers, <span id="hvac-venue-count">0</span> venues
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Trainer Directory Grid -->
|
||||||
|
<div class="hvac-trainer-directory-section">
|
||||||
|
<h2>Trainers Directory</h2>
|
||||||
|
<div id="hvac-trainer-grid" class="hvac-trainer-grid">
|
||||||
|
<div class="hvac-grid-loading">
|
||||||
|
<span class="dashicons dashicons-update-alt hvac-spin"></span>
|
||||||
|
Loading trainers...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Load More Button -->
|
||||||
|
<div class="hvac-load-more-wrapper" style="display: none;">
|
||||||
|
<button type="button" id="hvac-load-more" class="hvac-btn-secondary">
|
||||||
|
Load More
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- CTA Section -->
|
||||||
|
<div class="hvac-cta-section">
|
||||||
|
<p>Are you an HVAC Trainer that wants to be listed in our directory?</p>
|
||||||
|
<a href="<?php echo esc_url(site_url('/trainer/registration/')); ?>" class="hvac-btn-primary">Become A Trainer</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Trainer Profile Modal -->
|
||||||
|
<div id="hvac-trainer-modal" class="hvac-training-modal" style="display: none;" role="dialog" aria-modal="true" aria-labelledby="trainer-modal-title">
|
||||||
|
<div class="hvac-modal-overlay"></div>
|
||||||
|
<div class="hvac-modal-content">
|
||||||
|
<div class="hvac-modal-loading">
|
||||||
|
<span class="dashicons dashicons-update-alt hvac-spin"></span>
|
||||||
|
Loading...
|
||||||
|
</div>
|
||||||
|
<div class="hvac-modal-body"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Venue Info Modal -->
|
||||||
|
<div id="hvac-venue-modal" class="hvac-training-modal" style="display: none;" role="dialog" aria-modal="true" aria-labelledby="venue-modal-title">
|
||||||
|
<div class="hvac-modal-overlay"></div>
|
||||||
|
<div class="hvac-modal-content">
|
||||||
|
<div class="hvac-venue-modal-header">
|
||||||
|
<h2 id="venue-modal-title"></h2>
|
||||||
|
<button class="hvac-modal-close" aria-label="Close modal">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="hvac-venue-modal-body">
|
||||||
|
<p class="hvac-venue-address"></p>
|
||||||
|
<div class="hvac-venue-events">
|
||||||
|
<h4>Upcoming Events at this Venue</h4>
|
||||||
|
<ul class="hvac-venue-events-list"></ul>
|
||||||
|
</div>
|
||||||
|
<a href="#" class="hvac-venue-directions hvac-btn-secondary" target="_blank">
|
||||||
|
<span class="dashicons dashicons-location"></span>
|
||||||
|
Get Directions
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php get_footer(); ?>
|
||||||
Loading…
Reference in a new issue