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:
ben 2026-01-31 23:20:34 -04:00
parent 9f4667fbb4
commit 21c908af81
14 changed files with 4569 additions and 65 deletions

1
.gitignore vendored
View file

@ -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
View file

@ -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
--- ---

View 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;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 339 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 360 B

View 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">&times;</button>' +
'</div>');
// Insert after Near Me button
$('#hvac-near-me-btn').after($error);
// Auto-dismiss after 5 seconds
setTimeout(function() {
$error.fadeOut(300, function() { $(this).remove(); });
}, 5000);
// Click to dismiss
$error.find('.hvac-dismiss-error').on('click', function() {
$error.remove();
});
},
/**
* Clear location error message
*/
clearLocationError: function() {
$('.hvac-location-error').remove();
},
/**
* Clear all filters
*/
clearAllFilters: function() {
// Reset filter values
this.activeFilters = {
state: '',
certification: '',
training_format: '',
search: ''
};
// 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">&times;</button>
</span>
`);
$container.append($chip);
},
/**
* Update clear button visibility
*/
updateClearButtonVisibility: function() {
const hasFilters = this.activeFilters.state ||
this.activeFilters.certification ||
this.activeFilters.training_format ||
this.activeFilters.search ||
this.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);

View 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);

View file

@ -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

View file

@ -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' => [

View file

@ -264,6 +264,13 @@ final class HVAC_Plugin {
'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) {
if ($status === 'loaded') { if ($status === 'loaded') {
@ -278,6 +285,13 @@ final class HVAC_Plugin {
} }
} }
// Load Find Training feature files (Google Maps based)
foreach ($this->loadFeatureFiles($findTrainingFiles) as $file => $status) {
if ($status === 'loaded') {
$this->componentStatus["find_training_{$file}"] = true;
}
}
// Load community system files // Load community system files
$communityFiles = [ $communityFiles = [
'community/class-login-handler.php', 'community/class-login-handler.php',
@ -877,12 +891,12 @@ final class HVAC_Plugin {
* 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();
} }
@ -897,6 +911,21 @@ final class HVAC_Plugin {
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

View 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">&times;</button>
</div>
<div class="hvac-training-modal-body">
<div class="hvac-training-profile-section">
<div class="hvac-training-profile-header">
<?php if (!empty($trainer['image'])): ?>
<img src="<?php echo esc_url($trainer['image']); ?>" alt="<?php echo esc_attr($trainer['name']); ?>" class="hvac-training-profile-image">
<?php else: ?>
<div class="hvac-training-profile-avatar">
<span class="dashicons dashicons-businessperson"></span>
</div>
<?php endif; ?>
<div class="hvac-training-profile-info">
<p class="hvac-training-location">
<?php echo esc_html($trainer['city'] . ', ' . $trainer['state']); ?>
</p>
<?php if (!empty($trainer['certifications'])): ?>
<div class="hvac-training-certifications">
<?php foreach ($trainer['certifications'] as $cert): ?>
<span class="hvac-cert-badge"><?php echo esc_html($cert); ?></span>
<?php endforeach; ?>
</div>
<?php endif; ?>
<?php if (!empty($trainer['company'])): ?>
<p class="hvac-training-company"><?php echo esc_html($trainer['company']); ?></p>
<?php endif; ?>
<p class="hvac-training-events-count">
Total Training Events: <strong><?php echo intval($trainer['event_count']); ?></strong>
</p>
</div>
</div>
<?php if (!empty($trainer['training_formats'])): ?>
<div class="hvac-training-detail">
<strong>Training Formats:</strong> <?php echo esc_html(implode(', ', $trainer['training_formats'])); ?>
</div>
<?php endif; ?>
<?php if (!empty($trainer['upcoming_events'])): ?>
<div class="hvac-training-events">
<h4>Upcoming Events</h4>
<ul class="hvac-events-list">
<?php foreach (array_slice($trainer['upcoming_events'], 0, 5) as $event): ?>
<li>
<a href="<?php echo esc_url($event['url']); ?>" target="_blank">
<?php echo esc_html($event['title']); ?>
</a>
<span class="hvac-event-date"><?php echo esc_html($event['date']); ?></span>
</li>
<?php endforeach; ?>
</ul>
</div>
<?php endif; ?>
</div>
<div class="hvac-training-contact-section">
<h4>Contact Trainer</h4>
<form class="hvac-training-contact-form" data-trainer-id="<?php echo esc_attr($trainer['user_id']); ?>" data-profile-id="<?php echo esc_attr($trainer['profile_id']); ?>">
<div class="hvac-form-row">
<input type="text" name="first_name" placeholder="First Name" required>
<input type="text" name="last_name" placeholder="Last Name" required>
</div>
<div class="hvac-form-row">
<input type="email" name="email" placeholder="Email" required>
<input type="tel" name="phone" placeholder="Phone Number">
</div>
<div class="hvac-form-row">
<input type="text" name="city" placeholder="City">
<input type="text" name="state_province" placeholder="State/Province">
</div>
<div class="hvac-form-field">
<input type="text" name="company" placeholder="Company">
</div>
<div class="hvac-form-field">
<textarea name="message" placeholder="Message" rows="3"></textarea>
</div>
<button type="submit" class="hvac-btn-primary">Send Message</button>
</form>
<div class="hvac-form-message hvac-form-success" style="display: none;">
Your message has been sent! Check your inbox for more details.
</div>
<div class="hvac-form-message hvac-form-error" style="display: none;">
There was an error sending your message. Please try again.
</div>
</div>
</div>
<?php
}
/**
* Get filter options for dropdowns
*
* @return array Filter options
*/
public function get_filter_options(): array {
$data_provider = HVAC_Training_Map_Data::get_instance();
return [
'states' => $data_provider->get_state_options(),
'certifications' => $data_provider->get_certification_options(),
'training_formats' => $data_provider->get_training_format_options()
];
}
/**
* Get page slug
*
* @return string
*/
public function get_page_slug(): string {
return $this->page_slug;
}
}

View 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();
}
}
}

View 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');
}
}

View 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">&times;</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(); ?>