Compare commits
12 commits
23dcd158ec
...
7b895ad785
| Author | SHA1 | Date | |
|---|---|---|---|
| 7b895ad785 | |||
| 4c22b9db8e | |||
| 03b9bce52d | |||
| 4b53d3eab6 | |||
| fcd55fd164 | |||
| d2a43bfd9b | |||
| ea3031528e | |||
| 17dd3c9bdb | |||
| 5c15b27935 | |||
| 19147d978e | |||
| 21c908af81 | |||
| 9f4667fbb4 |
27 changed files with 9161 additions and 190 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -30,6 +30,7 @@
|
||||||
/includes/*
|
/includes/*
|
||||||
!/includes/admin/
|
!/includes/admin/
|
||||||
!/includes/zoho/
|
!/includes/zoho/
|
||||||
|
!/includes/find-training/
|
||||||
!/includes/**/*.php
|
!/includes/**/*.php
|
||||||
!/templates/
|
!/templates/
|
||||||
/templates/*
|
/templates/*
|
||||||
|
|
@ -186,7 +187,7 @@
|
||||||
# **/.env.*
|
# **/.env.*
|
||||||
.auth/
|
.auth/
|
||||||
# **/.auth/
|
# **/.auth/
|
||||||
# **/zoho-config.php
|
**/zoho-config.php
|
||||||
# **/wp-config.php
|
# **/wp-config.php
|
||||||
# **/wp-tests-config*.php
|
# **/wp-tests-config*.php
|
||||||
memory-bank/mcpServers.md
|
memory-bank/mcpServers.md
|
||||||
|
|
|
||||||
260
MULTI-MODEL-CODE-REVIEW-REPORT.md
Normal file
260
MULTI-MODEL-CODE-REVIEW-REPORT.md
Normal file
|
|
@ -0,0 +1,260 @@
|
||||||
|
# Multi-Agent, Multi-Model Code Review Report
|
||||||
|
|
||||||
|
**Plugin:** HVAC Community Events WordPress Plugin
|
||||||
|
**Date:** 2026-01-31
|
||||||
|
**Models Used:** GPT-5 (Codex), Gemini 3, Kimi K2.5, Zen MCP (secaudit, analyze, codereview)
|
||||||
|
**Files Reviewed:** 11 critical components (~9,000 lines)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Executive Summary
|
||||||
|
|
||||||
|
**Overall Security Posture: MODERATE**
|
||||||
|
|
||||||
|
The HVAC Community Events plugin demonstrates solid security foundations with centralized security helpers, proper nonce verification, prepared SQL statements, and well-designed certificate download security. However, several issues require attention, particularly in sensitive data handling and cryptographic key management.
|
||||||
|
|
||||||
|
**Context:** This is a small local WordPress environment with a single user. Security and scalability concerns are appropriately deprioritized in favor of functionality and maintainability. Findings are rated with this context in mind.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Findings by Consensus Level
|
||||||
|
|
||||||
|
### CONSENSUS (3+ Tools Agree) - Highest Priority
|
||||||
|
|
||||||
|
| ID | Severity | Issue | Location | Tools |
|
||||||
|
|----|----------|-------|----------|-------|
|
||||||
|
| C1 | **CRITICAL** | Plaintext passwords stored in transients | `class-hvac-registration.php:98,196-199` | GPT-5, Zen OWASP, Zen Synthesis |
|
||||||
|
| C2 | **HIGH** | Encryption key stored in database | `class-hvac-secure-storage.php:30-40` | Kimi K2.5, Zen OWASP, Zen Synthesis |
|
||||||
|
| C3 | **HIGH** | IP spoofing undermines rate limiting | `class-hvac-security.php:186-198` | Gemini 3, Zen OWASP, Zen Synthesis |
|
||||||
|
| C4 | **MEDIUM** | Singleton API inconsistency | `class-hvac-plugin.php`, multiple | GPT-5, Zen Architecture, Zen Synthesis |
|
||||||
|
| C5 | **MEDIUM** | Duplicate component initialization | `class-hvac-plugin.php:724-735,900-915` | GPT-5, Zen Architecture |
|
||||||
|
|
||||||
|
### MAJORITY (2 Tools Agree) - High Priority
|
||||||
|
|
||||||
|
| ID | Severity | Issue | Location | Tools | Dissent |
|
||||||
|
|----|----------|-------|----------|-------|---------|
|
||||||
|
| M1 | **HIGH** | Weak CSP (unsafe-inline/unsafe-eval) | `class-hvac-ajax-security.php:88-97` | Gemini 3, Zen OWASP | Zen Synthesis: defer |
|
||||||
|
| M2 | **HIGH** | OAuth refresh token race condition | `class-zoho-crm-auth.php:156-163` | Kimi K2.5, Zen OWASP | Low risk for local |
|
||||||
|
| M3 | **MEDIUM** | Revoked certificates still downloadable | `class-certificate-manager.php:843-855` | Kimi K2.5, Zen Synthesis | |
|
||||||
|
| M4 | **MEDIUM** | Non-atomic Zoho sync operations | `class-zoho-sync.php` | Gemini 3, Zen Architecture | Self-healing |
|
||||||
|
| M5 | **MEDIUM** | Certificate number race condition | `class-certificate-manager.php:61-74` | Kimi K2.5, Zen Architecture | Very low risk |
|
||||||
|
|
||||||
|
### UNIQUE INSIGHTS - Model-Specific Findings
|
||||||
|
|
||||||
|
| ID | Severity | Issue | Location | Model |
|
||||||
|
|----|----------|-------|----------|-------|
|
||||||
|
| U1 | **CRITICAL** | O(expiry) token verification loop (DoS) | `class-hvac-ajax-security.php:498-513` | Zen Synthesis |
|
||||||
|
| U2 | **HIGH** | `remove_all_actions()` breaks WP isolation | `class-hvac-plugin.php:1047-1054` | Zen Architecture |
|
||||||
|
| U3 | **HIGH** | Security headers not applied to AJAX | `class-hvac-ajax-security.php:85-103` | Zen Synthesis |
|
||||||
|
| U4 | **HIGH** | Config file fallback credential exposure | `zoho-config.php` | Kimi K2.5 |
|
||||||
|
| U5 | **MEDIUM** | AES-256-CBC without MAC (no integrity) | `class-hvac-secure-storage.php:54-92` | Zen OWASP |
|
||||||
|
| U6 | **MEDIUM** | Audit log injection via filenames | `class-hvac-security-helpers.php:571` | Gemini 3 |
|
||||||
|
| U7 | **MEDIUM** | Roles treated as capabilities | `class-hvac-ajax-handlers.php:76-81` | GPT-5 |
|
||||||
|
| U8 | **MEDIUM** | Log sanitization regex gaps | `class-zoho-crm-auth.php:504-520` | Kimi K2.5 |
|
||||||
|
| U9 | **MEDIUM** | File-scope side-effect initialization | `class-hvac-trainer-profile-manager.php:1235` | Zen Architecture |
|
||||||
|
| U10 | **LOW** | PII (email) written to error logs | `class-hvac-ajax-handlers.php:1016-1026` | Zen OWASP |
|
||||||
|
| U11 | **LOW** | Timezone inconsistency at year boundary | `class-certificate-manager.php:70` | Kimi K2.5 |
|
||||||
|
|
||||||
|
### VALIDATED AS NON-ISSUES
|
||||||
|
|
||||||
|
| Original Concern | Status | Reason |
|
||||||
|
|------------------|--------|--------|
|
||||||
|
| Path traversal in certificate downloads | **NOT EXPLOITABLE** | Token-based system stores file_path in transients, not user input |
|
||||||
|
| SQL injection in `compile_trainer_stats()` | **SECURE** | Proper `$wpdb->prepare()` usage confirmed |
|
||||||
|
| OAuth state CSRF protection | **PROPERLY IMPLEMENTED** | Uses `hash_equals()` with single-use tokens |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## OWASP Top 10 Mapping
|
||||||
|
|
||||||
|
| Category | Status | Key Issues |
|
||||||
|
|----------|--------|------------|
|
||||||
|
| A01 - Broken Access Control | SECURE | Minor: roles as capabilities |
|
||||||
|
| A02 - Cryptographic Failures | **VULNERABLE** | C1 (passwords), C2 (key storage), U5 (no MAC) |
|
||||||
|
| A03 - Injection | SECURE | Prepared statements throughout |
|
||||||
|
| A04 - Insecure Design | Minor | U1 (O(n) loop) |
|
||||||
|
| A05 - Security Misconfiguration | **VULNERABLE** | M1 (weak CSP) |
|
||||||
|
| A06 - Vulnerable Components | Not Assessed | |
|
||||||
|
| A07 - Authentication Failures | **VULNERABLE** | C3 (IP spoofing) |
|
||||||
|
| A08 - Software/Data Integrity | Minor | M2 (token race) |
|
||||||
|
| A09 - Logging Failures | Minor | U6 (log injection), U10 (PII in logs) |
|
||||||
|
| A10 - SSRF | SECURE | |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Positive Security Patterns Identified
|
||||||
|
|
||||||
|
All models identified strong security practices:
|
||||||
|
|
||||||
|
1. **Centralized AJAX Security** - `HVAC_Ajax_Security::verify_ajax_request()` enforces login + nonce + capability
|
||||||
|
2. **Consistent Input Sanitization** - `sanitize_text_field()`, `absint()`, `wp_kses_post()` throughout
|
||||||
|
3. **Prepared SQL Statements** - `$wpdb->prepare()` with proper placeholders
|
||||||
|
4. **Certificate Download Security** - Time-limited, random tokens with one-time-use
|
||||||
|
5. **OAuth CSRF Protection** - Timing-safe `hash_equals()` comparison
|
||||||
|
6. **Comprehensive Audit Logging** - Security events logged with 30-day retention
|
||||||
|
7. **WordPress Native Password Functions** - No custom password hashing
|
||||||
|
8. **Modern PHP 8+ Features** - Strict types, generators, match expressions
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Recommended Action Plan
|
||||||
|
|
||||||
|
### IMMEDIATE (This Session) - Critical Impact - ✅ ALL IMPLEMENTED
|
||||||
|
|
||||||
|
| Priority | Issue ID | Action | File | Status |
|
||||||
|
|----------|----------|--------|------|--------|
|
||||||
|
| 1 | C1 | Strip password fields before transient storage | `class-hvac-registration.php` | ✅ FIXED |
|
||||||
|
| 2 | U1 | Rewrite token verification to O(1) | `class-hvac-ajax-security.php` | ✅ FIXED |
|
||||||
|
| 3 | U2 | Replace `remove_all_actions()` with targeted removal | `class-hvac-plugin.php` | ✅ FIXED |
|
||||||
|
|
||||||
|
### SHORT-TERM (Before Production) - High Impact - ✅ ALL IMPLEMENTED
|
||||||
|
|
||||||
|
| Priority | Issue ID | Action | File | Status |
|
||||||
|
|----------|----------|--------|------|--------|
|
||||||
|
| 4 | C2 | Move encryption key to wp-config.php constant | `class-hvac-secure-storage.php` | ✅ FIXED |
|
||||||
|
| 5 | M3 | Add revoked check to `get_certificate_url()` | `class-certificate-manager.php` | ✅ FIXED |
|
||||||
|
| 6 | U3 | Fix security headers condition for AJAX | `class-hvac-ajax-security.php` | ✅ FIXED |
|
||||||
|
| 7 | U4 | Ensure `zoho-config.php` is in .gitignore | `.gitignore` | ✅ FIXED |
|
||||||
|
|
||||||
|
### MEDIUM-TERM - Incremental Improvement - ✅ ALL IMPLEMENTED
|
||||||
|
|
||||||
|
| Priority | Issue ID | Action | File | Status |
|
||||||
|
|----------|----------|--------|------|--------|
|
||||||
|
| 8 | C3 | Fix IP spoofing with trusted proxy validation | `class-hvac-security.php` | ✅ FIXED |
|
||||||
|
| 9 | C5 | Remove duplicate init from `initializeFindTrainer()` | `class-hvac-plugin.php` | ✅ FIXED |
|
||||||
|
| 10 | U9 | Remove file-scope side-effect initialization | `class-hvac-trainer-profile-manager.php` | ✅ FIXED |
|
||||||
|
| 11 | M1 | Remove `unsafe-eval` from CSP | `class-hvac-ajax-security.php` | ✅ FIXED |
|
||||||
|
| 12 | U11 | Fix timezone inconsistency in certificate numbers | `class-certificate-manager.php` | ✅ FIXED |
|
||||||
|
|
||||||
|
### DEFERRED (Low Priority for Local Environment)
|
||||||
|
|
||||||
|
- U5: Upgrade to AES-GCM authenticated encryption
|
||||||
|
- M2: OAuth refresh token locking mechanism
|
||||||
|
- M4: Transaction wrapper for sync operations
|
||||||
|
- M5: Atomic certificate number generation
|
||||||
|
- C4: Standardize singleton API to `::instance()` (code style only)
|
||||||
|
- U6: Audit log injection via filenames
|
||||||
|
- U7: Roles treated as capabilities
|
||||||
|
- U8: Log sanitization regex gaps
|
||||||
|
- U10: PII (email) written to error logs
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Code Fixes Reference
|
||||||
|
|
||||||
|
### Fix C1: Password Transient Storage
|
||||||
|
|
||||||
|
```php
|
||||||
|
// File: class-hvac-registration.php, around line 98
|
||||||
|
$submitted_data = $_POST;
|
||||||
|
// ADD THESE LINES:
|
||||||
|
unset(
|
||||||
|
$submitted_data['user_pass'],
|
||||||
|
$submitted_data['confirm_password'],
|
||||||
|
$submitted_data['current_password'],
|
||||||
|
$submitted_data['new_password'],
|
||||||
|
$submitted_data['hvac_registration_nonce']
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Fix U1: O(1) Token Verification
|
||||||
|
|
||||||
|
```php
|
||||||
|
// File: class-hvac-ajax-security.php, replace lines 498-513
|
||||||
|
public static function generate_secure_token($action, $user_id) {
|
||||||
|
$ts = time();
|
||||||
|
$salt = wp_salt('auth');
|
||||||
|
$sig = hash_hmac('sha256', $action . '|' . $user_id . '|' . $ts, $salt);
|
||||||
|
return $ts . '.' . $sig;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function verify_secure_token($token, $action, $user_id, $expiry = 3600) {
|
||||||
|
$parts = explode('.', $token, 2);
|
||||||
|
if (count($parts) !== 2) return false;
|
||||||
|
|
||||||
|
[$ts, $sig] = $parts;
|
||||||
|
$ts = (int) $ts;
|
||||||
|
|
||||||
|
if (!$ts || empty($sig) || (time() - $ts) > $expiry) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$expected = hash_hmac('sha256', $action . '|' . $user_id . '|' . $ts, wp_salt('auth'));
|
||||||
|
return hash_equals($expected, $sig);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Fix U2: Targeted Hook Removal
|
||||||
|
|
||||||
|
```php
|
||||||
|
// File: class-hvac-plugin.php, around line 1052
|
||||||
|
// REPLACE:
|
||||||
|
remove_all_actions('template_redirect', 10);
|
||||||
|
|
||||||
|
// WITH:
|
||||||
|
// Remove only specific HVAC auth callbacks that block registration
|
||||||
|
remove_action('template_redirect', [$this, 'hvac_auth_redirect'], 10);
|
||||||
|
// Or use early-return guard in the auth callback itself
|
||||||
|
```
|
||||||
|
|
||||||
|
### Fix C2: Encryption Key in wp-config.php
|
||||||
|
|
||||||
|
```php
|
||||||
|
// Add to wp-config.php:
|
||||||
|
define('HVAC_ENCRYPTION_KEY', base64_encode(random_bytes(32)));
|
||||||
|
|
||||||
|
// Modify class-hvac-secure-storage.php lines 30-40:
|
||||||
|
private static function get_encryption_key() {
|
||||||
|
if (!defined('HVAC_ENCRYPTION_KEY')) {
|
||||||
|
// Fallback for migration - log warning
|
||||||
|
error_log('HVAC: HVAC_ENCRYPTION_KEY not defined in wp-config.php');
|
||||||
|
$key = get_option('hvac_encryption_key');
|
||||||
|
if (!$key) {
|
||||||
|
$key = base64_encode(random_bytes(32));
|
||||||
|
update_option('hvac_encryption_key', $key);
|
||||||
|
}
|
||||||
|
return base64_decode($key);
|
||||||
|
}
|
||||||
|
return base64_decode(HVAC_ENCRYPTION_KEY);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Files Reviewed
|
||||||
|
|
||||||
|
### Security-Critical (6 files, ~4,000 lines)
|
||||||
|
| File | Lines | Focus |
|
||||||
|
|------|-------|-------|
|
||||||
|
| `class-hvac-security.php` | 234 | Nonce, rate limiting, IP detection |
|
||||||
|
| `class-hvac-ajax-security.php` | 517 | AJAX auth, audit logging, CSP |
|
||||||
|
| `class-hvac-ajax-handlers.php` | 1030 | AJAX endpoints, input validation |
|
||||||
|
| `class-hvac-security-helpers.php` | 644 | Sanitization, file upload validation |
|
||||||
|
| `class-hvac-registration.php` | ~1000 | User registration, file uploads |
|
||||||
|
| `class-zoho-crm-auth.php` | 574 | OAuth2 tokens, credential storage |
|
||||||
|
|
||||||
|
### Business Logic (5 files, ~5,000 lines)
|
||||||
|
| File | Lines | Focus |
|
||||||
|
|------|-------|-------|
|
||||||
|
| `class-hvac-plugin.php` | 1,457 | Main controller, 50+ components |
|
||||||
|
| `class-hvac-event-manager.php` | 1,057 | Event CRUD, TEC integration |
|
||||||
|
| `class-hvac-trainer-profile-manager.php` | 1,236 | Profiles, taxonomies |
|
||||||
|
| `class-zoho-sync.php` | ~1,400 | CRM sync, pagination, hashing |
|
||||||
|
| `class-certificate-manager.php` | 906 | Certificate generation, files |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Review Methodology
|
||||||
|
|
||||||
|
1. **Phase 1 (Parallel):** Security review with GPT-5, Gemini 3, Kimi K2.5
|
||||||
|
2. **Phase 2 (Parallel):** Business logic review with GPT-5, Gemini 3, Kimi K2.5
|
||||||
|
3. **Phase 3 (Parallel):** Zen OWASP audit, Architecture analysis, Code review synthesis
|
||||||
|
4. **Phase 4:** Consolidation by consensus level
|
||||||
|
|
||||||
|
**Total Models:** 4 (GPT-5, Gemini 3, Kimi K2.5, Zen MCP)
|
||||||
|
**Total Agents:** 9 background agents
|
||||||
|
**Consensus Methodology:** Issues flagged by 3+ tools prioritized highest
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Report generated by multi-agent code review system*
|
||||||
621
Status.md
621
Status.md
|
|
@ -1,12 +1,623 @@
|
||||||
# HVAC Community Events - Project Status
|
# HVAC Community Events - Project Status
|
||||||
|
|
||||||
**Last Updated:** January 9, 2026
|
**Last Updated:** February 6, 2026
|
||||||
**Current Session:** Master Trainer Profile Edit Enhancement - Complete
|
**Current Session:** Zoho CRM Sync Production Fix
|
||||||
**Version:** 2.1.12 (Deployed to Production)
|
**Version:** 2.2.11 (Deployed to Production)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 🎯 CURRENT SESSION - MASTER TRAINER PROFILE EDIT ENHANCEMENT (Jan 9, 2026)
|
## 🎯 NEXT SESSION - CAPTCHA IMPLEMENTATION
|
||||||
|
|
||||||
|
### Status: 📋 **PLANNED**
|
||||||
|
|
||||||
|
**Objective:** Add CAPTCHA to all user-facing forms to prevent spam and bot submissions.
|
||||||
|
|
||||||
|
**Forms to Update:**
|
||||||
|
- Training login form
|
||||||
|
- Trainer registration form
|
||||||
|
- Contact forms (trainer, venue)
|
||||||
|
- Any other public-facing forms
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 CURRENT SESSION - ZOHO CRM SYNC PRODUCTION FIX (Feb 6, 2026)
|
||||||
|
|
||||||
|
### Status: ✅ **COMPLETE - 64/64 Trainers Syncing to Zoho CRM**
|
||||||
|
|
||||||
|
**Objective:** Fix Zoho CRM user sync that was silently failing on production - 0/65 users syncing despite "connection successful" status.
|
||||||
|
|
||||||
|
### Issues Found & Fixed (iterative production debugging)
|
||||||
|
|
||||||
|
1. **Version Constant Mismatch** (`includes/class-hvac-plugin.php`)
|
||||||
|
- `HVAC_PLUGIN_VERSION` stuck at `'2.0.0'` while `HVAC_VERSION` was `'2.2.10'`
|
||||||
|
- Admin JS used `HVAC_PLUGIN_VERSION` for cache busting, so browser served old JS without reset handler
|
||||||
|
- Fix: Bumped both constants to `'2.2.11'`
|
||||||
|
|
||||||
|
2. **GET Request Search Criteria Ignored** (`includes/zoho/class-zoho-sync.php`)
|
||||||
|
- `sync_users()` passed search criteria as `$data` parameter to `make_api_request()`
|
||||||
|
- But `make_api_request()` ignores `$data` for GET requests (line 345 only includes body for POST/PUT/PATCH)
|
||||||
|
- Every Zoho contact search returned unfiltered results, causing all syncs to fail
|
||||||
|
- Fix: Moved criteria into URL query string: `/Contacts/search?criteria=(Email:equals:...)`
|
||||||
|
|
||||||
|
3. **Error Reporting Priority** (`includes/zoho/class-zoho-sync.php`)
|
||||||
|
- `validate_api_response()` checked generic `error` key before Zoho-specific `data[0]` errors
|
||||||
|
- Users saw "API error with status code 400" instead of actionable field-level errors
|
||||||
|
- Fix: Reordered to check Zoho `data[0]` errors first, added field-level detail (api_name, expected_data_type, info)
|
||||||
|
|
||||||
|
4. **Phone Field Validation** (`includes/zoho/class-zoho-sync.php`)
|
||||||
|
- Zoho rejects invalid phone formats with HTTP 400
|
||||||
|
- Fix: Strip non-digit chars, require 10+ digits, only include Phone field when valid
|
||||||
|
|
||||||
|
5. **Last_Name Required Field** (`includes/zoho/class-zoho-sync.php`)
|
||||||
|
- Zoho Contacts module requires Last_Name but some WP users had empty last names
|
||||||
|
- Fix: Fallback chain: `last_name` meta → `display_name` → `user_login`
|
||||||
|
|
||||||
|
6. **Spam Account Cleanup** (production WP-CLI)
|
||||||
|
- User 14 (`rigobertohugo19`, `info103@noreply0.com`) was a spam/bot registration with no name
|
||||||
|
- Demoted from `hvac_trainer` to `subscriber`
|
||||||
|
|
||||||
|
### Production Sync Results (iterative)
|
||||||
|
|
||||||
|
| Attempt | Synced | Failed | Issue |
|
||||||
|
|---------|--------|--------|-------|
|
||||||
|
| 1st (pre-fix) | 0 | 65 | Search criteria not sent to Zoho API |
|
||||||
|
| 2nd (search fix) | 63 | 2 | Generic "status code 400" errors |
|
||||||
|
| 3rd (error detail) | 0 | 2 | User 57: invalid Phone, User 14: missing Last_Name |
|
||||||
|
| 4th (data validation) | 31 | 1 | User 57 phone still failing (7-digit threshold too low) |
|
||||||
|
| 5th (10-digit threshold) | 26 | 0 | **All 65 trainers synced** |
|
||||||
|
| Final (User 14 demoted) | **64/64** | **0** | All active trainers syncing |
|
||||||
|
|
||||||
|
### Files Modified
|
||||||
|
|
||||||
|
| File | Change |
|
||||||
|
|------|--------|
|
||||||
|
| `includes/class-hvac-plugin.php` | Synced `HVAC_PLUGIN_VERSION` to `'2.2.11'` (was stuck at `'2.0.0'`) |
|
||||||
|
| `includes/zoho/class-zoho-sync.php` | Search criteria in URL, error priority fix, phone sanitization, Last_Name fallback |
|
||||||
|
|
||||||
|
### Git Commits
|
||||||
|
- `03b9bce5` - fix(zoho): Fix silent sync failures with API response validation and hash reset
|
||||||
|
- `4c22b9db` - fix(zoho): Fix user sync search criteria and improve data validation
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 PREVIOUS SESSION - ZOHO CRM SYNC ARCHITECTURE FIX (Feb 6, 2026)
|
||||||
|
|
||||||
|
### Status: ✅ **COMPLETE - Deployed to Production**
|
||||||
|
|
||||||
|
**Objective:** Fix Zoho CRM integration that appeared connected but was silently failing to sync data (events, attendees, ticket sales not appearing in Zoho CRM).
|
||||||
|
|
||||||
|
### Root Cause Analysis (4-model consensus: GPT-5, Gemini 3, Zen Code Review, Zen Debug)
|
||||||
|
|
||||||
|
The sync pipeline had a "silent failure" architecture:
|
||||||
|
1. **Connection test only uses GET** - bypasses staging write block, giving false confidence
|
||||||
|
2. **Staging mode returned fake `'status' => 'success'`** for blocked writes - appeared successful
|
||||||
|
3. **Sync methods unconditionally updated `_zoho_sync_hash`** after any "sync" - poisoned hashes
|
||||||
|
4. **Subsequent syncs compared hashes, found matches, skipped all records** - permanent data loss
|
||||||
|
5. **No API response validation** - even real Zoho errors were silently ignored
|
||||||
|
|
||||||
|
### Fixes Implemented
|
||||||
|
|
||||||
|
1. **API Response Validation** (`includes/zoho/class-zoho-sync.php`)
|
||||||
|
- Added `validate_api_response()` helper method
|
||||||
|
- Checks for WP_Error, staging mode blocks, HTTP errors, Zoho error codes
|
||||||
|
- Confirms success with ID extraction before treating a sync as successful
|
||||||
|
|
||||||
|
2. **Hash-Only-On-Success** (`includes/zoho/class-zoho-sync.php`)
|
||||||
|
- All 5 sync methods (events, users, attendees, rsvps, purchases) rewritten
|
||||||
|
- `_zoho_sync_hash` only updates when Zoho API confirms the write succeeded
|
||||||
|
- Failed syncs increment `$results['failed']` with error details
|
||||||
|
- Changed `catch (Exception)` to `catch (\Throwable)` for comprehensive error handling
|
||||||
|
|
||||||
|
3. **Staging Mode Detection Fix** (`includes/zoho/class-zoho-crm-auth.php`)
|
||||||
|
- Replaced fragile `strpos()` substring matching with `wp_parse_url()` hostname comparison
|
||||||
|
- Production (`upskillhvac.com` / `www.upskillhvac.com`) explicitly whitelisted
|
||||||
|
- All other hostnames default to staging mode
|
||||||
|
- `HVAC_ZOHO_PRODUCTION_MODE` / `HVAC_ZOHO_STAGING_MODE` constants can override
|
||||||
|
- Staging fake responses now return `'skipped_staging'` instead of misleading `'success'`
|
||||||
|
|
||||||
|
4. **Admin UI: Hash Reset & Staging Warning** (`includes/admin/class-zoho-admin.php`, `assets/js/zoho-admin.js`)
|
||||||
|
- Added `reset_sync_hashes()` AJAX handler - clears all `_zoho_sync_hash` from postmeta and usermeta
|
||||||
|
- Added "Force Full Re-sync (Reset Hashes)" button with confirmation dialog
|
||||||
|
- Connection test now surfaces staging warning when staging mode is active
|
||||||
|
|
||||||
|
5. **Staging Environment Fix** (staging `wp-config.php`)
|
||||||
|
- Removed `HVAC_ZOHO_PRODUCTION_MODE` constant from staging wp-config.php
|
||||||
|
- Staging now correctly blocks all Zoho write operations via hostname detection
|
||||||
|
- GET requests (reads) still pass through for testing
|
||||||
|
|
||||||
|
### Files Modified
|
||||||
|
|
||||||
|
| File | Change |
|
||||||
|
|------|--------|
|
||||||
|
| `includes/zoho/class-zoho-sync.php` | Added `validate_api_response()`, rewrote all 5 sync methods for validated hashing |
|
||||||
|
| `includes/zoho/class-zoho-crm-auth.php` | Rewrote `is_staging_mode()` with hostname parsing, changed fake response status |
|
||||||
|
| `includes/admin/class-zoho-admin.php` | Added `reset_sync_hashes()` handler, reset button HTML, staging warning in connection test |
|
||||||
|
| `assets/js/zoho-admin.js` | Added reset hashes button handler, staging warning display in connection test |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 PREVIOUS SESSION - NEAR ME BUTTON MOBILE FIX (Feb 6, 2026)
|
||||||
|
|
||||||
|
### Status: ✅ **COMPLETE - Deployed to Staging**
|
||||||
|
|
||||||
|
**Objective:** Fix mobile layout issue where the "Near Me" button caused the search bar to shrink when location was granted.
|
||||||
|
|
||||||
|
### Issues Found & Fixed
|
||||||
|
|
||||||
|
1. ✅ **CSS/HTML Button Structure Bug** (`assets/js/find-training-filters.js`)
|
||||||
|
- When the Near Me button state changed, the HTML was replaced without the `.hvac-btn-text` wrapper class
|
||||||
|
- On mobile, CSS hides `.hvac-btn-text`, but unwrapped text was visible causing layout issues
|
||||||
|
- Fixed in 5 locations: loading state, success state, error reset, clear filters, remove location filter
|
||||||
|
|
||||||
|
2. ✅ **No Feedback for Empty Results** (`assets/js/find-training-filters.js`)
|
||||||
|
- When "Near Me" filter returned no results within 100km, user saw empty map with no explanation
|
||||||
|
- Added notification: "No trainers, venues, or events found within 100km of your location. Try removing the 'Near Me' filter to see all results."
|
||||||
|
|
||||||
|
### Files Modified
|
||||||
|
|
||||||
|
| File | Change |
|
||||||
|
|------|--------|
|
||||||
|
| `assets/js/find-training-filters.js` | Fixed button HTML to preserve `.hvac-btn-text` wrapper; added empty results notification |
|
||||||
|
|
||||||
|
### Code Changes
|
||||||
|
|
||||||
|
**Button HTML Fix (5 locations):**
|
||||||
|
```javascript
|
||||||
|
// Before (broken on mobile):
|
||||||
|
$button.html('<span class="dashicons dashicons-yes-alt"></span> Near Me');
|
||||||
|
|
||||||
|
// After (correct):
|
||||||
|
$button.html('<span class="dashicons dashicons-yes-alt"></span><span class="hvac-btn-text">Near Me</span>');
|
||||||
|
```
|
||||||
|
|
||||||
|
**Empty Results Notification:**
|
||||||
|
```javascript
|
||||||
|
if (self.userLocation) {
|
||||||
|
const totalResults = HVACTrainingMap.trainers.length +
|
||||||
|
HVACTrainingMap.venues.length +
|
||||||
|
HVACTrainingMap.events.length;
|
||||||
|
if (totalResults === 0) {
|
||||||
|
self.showLocationError('No trainers, venues, or events found within 100km...');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Mobile Testing Performed
|
||||||
|
- ✅ 375x812 (iPhone X) - Map and markers display correctly
|
||||||
|
- ✅ 320x568 (iPhone SE) - Map and markers display correctly
|
||||||
|
- ✅ Cluster markers expand on click
|
||||||
|
- ✅ Individual trainer markers clickable
|
||||||
|
- ✅ Info windows display correctly
|
||||||
|
- ✅ Profile modal opens and displays correctly
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 PREVIOUS SESSION - CHAMPION DIFFERENTIATION ON FIND TRAINING (Feb 2, 2026)
|
||||||
|
|
||||||
|
### Status: ✅ **COMPLETE - Deployed to Production**
|
||||||
|
|
||||||
|
**Objective:** Differentiate measureQuick Certified Champions from Trainers on the Find Training map. Champions do not offer public training, so they should be displayed differently.
|
||||||
|
|
||||||
|
### Changes Made
|
||||||
|
|
||||||
|
1. ✅ **Backend Data** (`includes/find-training/class-hvac-training-map-data.php`)
|
||||||
|
- Added `is_champion` flag for trainers with "Certified measureQuick Champion" certification
|
||||||
|
- Champions return empty `city` field (only state shown)
|
||||||
|
|
||||||
|
2. ✅ **Champion Marker Icon** (`assets/js/find-training-map.js`)
|
||||||
|
- Added `getChampionIcon()` method with white outline (vs green for Trainers)
|
||||||
|
- Champions use distinct visual appearance on map
|
||||||
|
|
||||||
|
3. ✅ **Sidebar Cards** (`assets/js/find-training-map.js`)
|
||||||
|
- Champions show only state (e.g., "Ohio" not "Canton, Ohio")
|
||||||
|
- Champions have non-clickable cards (no modal popup)
|
||||||
|
- Added `hvac-champion-card` CSS class
|
||||||
|
|
||||||
|
4. ✅ **Info Windows** (`assets/js/find-training-map.js`)
|
||||||
|
- Champions show only state in location
|
||||||
|
- No "View Profile" button for Champions
|
||||||
|
- Fixed location formatting to handle empty city gracefully
|
||||||
|
|
||||||
|
5. ✅ **Sorting** (`assets/js/find-training-map.js`)
|
||||||
|
- Champions sorted to end of trainer list
|
||||||
|
- Secondary sort by name for stable ordering
|
||||||
|
- Applied to both `loadMapData` and `loadTrainerDirectory`
|
||||||
|
|
||||||
|
6. ✅ **CSS Styling** (`assets/css/find-training-map.css`)
|
||||||
|
- Champion cards have non-clickable appearance
|
||||||
|
- No hover effects (cursor: default, no transform/shadow)
|
||||||
|
|
||||||
|
### Files Modified
|
||||||
|
|
||||||
|
| File | Change |
|
||||||
|
|------|--------|
|
||||||
|
| `includes/find-training/class-hvac-training-map-data.php` | Added `is_champion` flag, empty city for champions |
|
||||||
|
| `assets/js/find-training-map.js` | Champion icon, card display, click prevention, sorting |
|
||||||
|
| `assets/css/find-training-map.css` | Champion card non-clickable styling |
|
||||||
|
|
||||||
|
### Verification (Staging)
|
||||||
|
- ✅ API returns `is_champion: true` for 18 champions
|
||||||
|
- ✅ Champions have empty city in API response
|
||||||
|
- ✅ Champions sorted to end of list
|
||||||
|
- ✅ Champion cards show only state
|
||||||
|
- ✅ Clicking champion card does NOT open modal
|
||||||
|
- ✅ Clicking trainer card DOES open modal (regression test)
|
||||||
|
|
||||||
|
### Code Review Findings (Gemini 3)
|
||||||
|
- **Fixed:** Location formatting bug - empty city could show ", California"
|
||||||
|
- **Fixed:** Unstable sort order - added secondary sort by name
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 PREVIOUS SESSION - TABBED INTERFACE FOR FIND TRAINING (Feb 1, 2026)
|
||||||
|
|
||||||
|
### Status: ✅ **COMPLETE - Deployed to Production**
|
||||||
|
|
||||||
|
**Objective:** Refactor the Find Training page sidebar from a single trainer list to a tabbed interface with Trainers, Venues, and Events tabs.
|
||||||
|
|
||||||
|
### Changes Made
|
||||||
|
|
||||||
|
1. ✅ **Tab Navigation System** (`templates/page-find-training.php`)
|
||||||
|
- Replaced "42 trainers" header with three tabs: Trainers | Venues | Events
|
||||||
|
- Each tab displays dynamic count in parentheses
|
||||||
|
- ARIA accessibility attributes (role="tablist", role="tab", aria-selected)
|
||||||
|
- Keyboard navigation (arrow keys, Home, End)
|
||||||
|
|
||||||
|
2. ✅ **Visibility Toggles Relocated**
|
||||||
|
- Moved from map overlay to sidebar header
|
||||||
|
- Colored dots (teal/orange/purple) match marker colors
|
||||||
|
- Only control map marker visibility, not tab content
|
||||||
|
|
||||||
|
3. ✅ **Venue Cards** (`assets/js/find-training-map.js`)
|
||||||
|
- Name with building icon
|
||||||
|
- City, State location
|
||||||
|
- "X upcoming events" count
|
||||||
|
|
||||||
|
4. ✅ **Event Cards** (`assets/js/find-training-map.js`)
|
||||||
|
- Date badge showing month/day
|
||||||
|
- Event title
|
||||||
|
- Venue name
|
||||||
|
- Cost display
|
||||||
|
- "Past" badge for past events
|
||||||
|
|
||||||
|
5. ✅ **Info Modal** (`templates/page-find-training.php`)
|
||||||
|
- "What is Upskill HVAC?" section
|
||||||
|
- "How to Use" instructions
|
||||||
|
- Map Legend (trainer/venue/event markers)
|
||||||
|
|
||||||
|
6. ✅ **Context-Aware Search** (`assets/js/find-training-filters.js`)
|
||||||
|
- Placeholder changes based on active tab
|
||||||
|
- Client-side filtering for instant results (150ms debounce)
|
||||||
|
- Falls back to server-side when filters active
|
||||||
|
|
||||||
|
7. ✅ **CSS Styling** (`assets/css/find-training-map.css`)
|
||||||
|
- Tab navigation container and button styles
|
||||||
|
- Venue card and event card layouts
|
||||||
|
- Info button and modal styling
|
||||||
|
- Responsive adjustments for tablet/mobile
|
||||||
|
|
||||||
|
### Files Modified
|
||||||
|
|
||||||
|
| File | Change |
|
||||||
|
|------|--------|
|
||||||
|
| `templates/page-find-training.php` | Tab navigation, panels, info modal |
|
||||||
|
| `assets/js/find-training-map.js` | Tab switching, card creators, grid renderers |
|
||||||
|
| `assets/js/find-training-filters.js` | Context-aware search, client-side filtering |
|
||||||
|
| `assets/css/find-training-map.css` | ~300 lines new CSS for tabs, cards, modal |
|
||||||
|
| `includes/class-hvac-plugin.php` | Version bump 2.2.5 → 2.2.6 (CDN cache bust) |
|
||||||
|
|
||||||
|
### Issue Resolved: CDN Cache
|
||||||
|
|
||||||
|
**Problem:** After deployment, browser loaded old JavaScript without new `initTabs` method.
|
||||||
|
|
||||||
|
**Root Cause:** CDN cached old assets. Deploy script clears WP/OPcache but not CDN edge cache.
|
||||||
|
|
||||||
|
**Fix:** Bumped `HVAC_VERSION` from 2.2.5 to 2.2.6, changing script URL query string to force cache refresh.
|
||||||
|
|
||||||
|
### Verified on Staging
|
||||||
|
- ✅ 42 trainers tab count
|
||||||
|
- ✅ 9 venues tab count
|
||||||
|
- ✅ 8 events tab count
|
||||||
|
- ✅ Tab switching works with proper ARIA attributes
|
||||||
|
- ✅ Venue cards display correctly
|
||||||
|
- ✅ Event cards with date badges display correctly
|
||||||
|
- ✅ Info modal opens and displays content
|
||||||
|
- ✅ Visibility toggles control map markers
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 PREVIOUS SESSION - MEASUREQUICK APPROVED TRAINING LABS (Feb 1, 2026)
|
||||||
|
|
||||||
|
### Status: ✅ **COMPLETE - Deployed to Staging, Venues Displaying Correctly**
|
||||||
|
|
||||||
|
**Objective:** Transform /find-training to showcase only measureQuick Approved Training Labs with venue categories, equipment/amenities tags, and contact forms.
|
||||||
|
|
||||||
|
1. ✅ **Venue Taxonomies Created** (`includes/class-hvac-venue-categories.php` NEW)
|
||||||
|
- `venue_type` - For lab classification (e.g., "measureQuick Approved Training Lab")
|
||||||
|
- `venue_equipment` - Furnace, Heat Pump, AC, Mini-Split, Boiler, etc.
|
||||||
|
- `venue_amenities` - Coffee, Water, Projector, WiFi, Parking, etc.
|
||||||
|
- All taxonomies registered on `tribe_venue` post type
|
||||||
|
|
||||||
|
2. ✅ **9 Approved Training Labs Configured** (via WP-CLI script)
|
||||||
|
- Fast Track Learning Lab (ID: 6631) - Joe Medosch
|
||||||
|
- Progressive Training Lab (ID: 6284) - Samantha Brazie
|
||||||
|
- NAVAC Technical Training Center (ID: 6476) - Andrew Greaves
|
||||||
|
- Stevens Equipment Supply - Phoenix (ID: 6448) - Robert Cone
|
||||||
|
- San Jacinto College South Campus (ID: 6521) - Terry McWilliams
|
||||||
|
- Johnstone Supply - Live Fire Training Lab (ID: 4864) - Dave Petz
|
||||||
|
- Stevens Equipment Supply - Johnstown (ID: 1648) - Phil Sweren
|
||||||
|
- TruTech Tools Training Center (NEW) - Val Buckles
|
||||||
|
- Auer Steel & Heating Supply (NEW) - Mike Breen
|
||||||
|
|
||||||
|
3. ✅ **Map Data Filtered by Taxonomy**
|
||||||
|
- `get_venue_markers()` now includes `tax_query` for `mq-approved-lab` term
|
||||||
|
- Only approved training labs appear as venue markers
|
||||||
|
- Equipment, amenities, and POC data included in venue info
|
||||||
|
|
||||||
|
4. ✅ **Venue Modal Enhanced**
|
||||||
|
- Equipment badges (teal outline chips)
|
||||||
|
- Amenities badges (gray outline chips)
|
||||||
|
- Contact form with name, email, phone, company, message
|
||||||
|
- POC receives email notification on submission
|
||||||
|
|
||||||
|
5. ✅ **6 POC Trainer Accounts Created**
|
||||||
|
- Samantha Brazie, Andrew Greaves, Terry McWilliams
|
||||||
|
- Phil Sweren, Robert Cone, Dave Petz
|
||||||
|
- All with `hvac_trainer` role and approved status
|
||||||
|
|
||||||
|
### Files Created
|
||||||
|
|
||||||
|
| File | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `includes/class-hvac-venue-categories.php` | Venue taxonomy registration (singleton) |
|
||||||
|
| `scripts/setup-approved-labs.php` | WP-CLI script to configure all labs |
|
||||||
|
| `scripts/check-venue-coordinates.php` | Diagnostic script to verify venue coordinates |
|
||||||
|
| `scripts/geocode-approved-labs.php` | Geocode venues missing coordinates |
|
||||||
|
|
||||||
|
### Files Modified
|
||||||
|
|
||||||
|
| File | Change |
|
||||||
|
|------|--------|
|
||||||
|
| `includes/class-hvac-plugin.php` | Load venue categories class |
|
||||||
|
| `includes/find-training/class-hvac-training-map-data.php` | Add tax_query filter, include equipment/amenities |
|
||||||
|
| `includes/class-hvac-ajax-handlers.php` | Add venue contact form AJAX handler |
|
||||||
|
| `templates/page-find-training.php` | Add equipment/amenities badges, contact form |
|
||||||
|
| `assets/js/find-training-map.js` | Render badges, bind contact form handler |
|
||||||
|
| `assets/css/find-training-map.css` | Equipment/amenities badge styles |
|
||||||
|
|
||||||
|
### Issue Resolved: Venues Now Displaying on Map ✅
|
||||||
|
|
||||||
|
**Root Cause:** `venue_type` taxonomy wasn't being registered when `get_venue_markers()` ran.
|
||||||
|
|
||||||
|
`HVAC_Venue_Categories::instance()` was instantiated at 'init' priority 5, but then tried to add hooks to 'init' priority 5 for taxonomy registration. Since WordPress was already processing priority 5 handlers, the hook never fired.
|
||||||
|
|
||||||
|
**Fix Applied:**
|
||||||
|
1. **`includes/class-hvac-venue-categories.php`** - Added `did_action('init')` check in constructor
|
||||||
|
- If 'init' has already fired, call `register_taxonomies()` and `create_default_terms()` directly
|
||||||
|
- Otherwise, use the standard hook approach
|
||||||
|
|
||||||
|
2. **`includes/find-training/class-hvac-training-map-data.php`** - Fixed HTML entity encoding
|
||||||
|
- Venue names now use `html_entity_decode()` to properly display `&` and `–` characters
|
||||||
|
- Event titles also fixed
|
||||||
|
|
||||||
|
**Result:**
|
||||||
|
- ✅ All 9 approved training labs now display as orange venue markers on the map
|
||||||
|
- ✅ Venue names display correctly (e.g., "Auer Steel & Heating Supply" not "Auer Steel &...")
|
||||||
|
- ✅ Marker clustering works with mixed trainer/venue markers
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 PREVIOUS SESSION - FIND TRAINING PAGE ENHANCEMENTS (Feb 1, 2026)
|
||||||
|
|
||||||
|
### Status: ✅ **COMPLETE - Deployed to Production**
|
||||||
|
|
||||||
|
**Objective:** Improve Find Training page UX with viewport sync and marker hover interactions.
|
||||||
|
|
||||||
|
### Changes Made
|
||||||
|
|
||||||
|
1. ✅ **Viewport Sync** - Sidebar now shows only trainers visible in current map area
|
||||||
|
- Added `visibleTrainers` array to track filtered trainers
|
||||||
|
- Added `syncSidebarWithViewport()` method filtering by map bounds
|
||||||
|
- Map `idle` event triggers sync on pan/zoom
|
||||||
|
- Count shows "X of Y trainers" when zoomed in
|
||||||
|
|
||||||
|
2. ✅ **Marker Hover Interaction** - Info window appears on hover
|
||||||
|
- Added `mouseover` event listener to trainer/venue markers
|
||||||
|
- Set `optimized: false` on markers for reliable hover events
|
||||||
|
- Hover shows info window preview with "View Profile" button
|
||||||
|
- Click on "View Profile" opens full modal with contact form
|
||||||
|
|
||||||
|
3. ✅ **Legacy URL Redirects**
|
||||||
|
- `/find-a-trainer/` → `/find-training/` (301 redirect)
|
||||||
|
- `/find-trainer/` → `/find-training/` (301 redirect)
|
||||||
|
- Removed old page from Page Manager
|
||||||
|
|
||||||
|
### Files Modified
|
||||||
|
|
||||||
|
| File | Change |
|
||||||
|
|------|--------|
|
||||||
|
| `assets/js/find-training-map.js` | Added viewport sync, hover events, optimized:false |
|
||||||
|
| `assets/js/find-training-filters.js` | Updated filter handler for visibleTrainers |
|
||||||
|
| `includes/class-hvac-route-manager.php` | Added legacy URL redirects |
|
||||||
|
| `includes/class-hvac-page-manager.php` | Removed find-a-trainer page definition |
|
||||||
|
| `includes/class-hvac-plugin.php` | Version bumped to 2.2.4 |
|
||||||
|
|
||||||
|
### Verified Behavior
|
||||||
|
- ✅ Hover over marker → Info window appears immediately
|
||||||
|
- ✅ Click "View Profile" → Full modal with trainer details + contact form
|
||||||
|
- ✅ Pan/zoom map → Sidebar updates to show visible trainers only
|
||||||
|
- ✅ Legacy URLs redirect to new page
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 PREVIOUS SESSION - FIND TRAINING PAGE IMPLEMENTATION (Jan 31 - Feb 1, 2026)
|
||||||
|
|
||||||
|
### Status: ✅ **COMPLETE - Deployed to Production**
|
||||||
|
|
||||||
|
**Objective:** Replace the buggy MapGeo-based `/find-a-trainer` page with a new `/find-training` page built from scratch using Google Maps JavaScript API.
|
||||||
|
|
||||||
|
### Why This Change
|
||||||
|
The existing IGM/amCharts implementation had a fundamental bug that corrupted marker coordinates (longitude gets overwritten with latitude). After multiple fix attempts, building fresh with Google Maps API provides:
|
||||||
|
- Full control over marker data
|
||||||
|
- No third-party plugin dependencies
|
||||||
|
- Better long-term maintainability
|
||||||
|
- Ability to show both trainers AND venues
|
||||||
|
|
||||||
|
### Files Created (8 new files)
|
||||||
|
|
||||||
|
| File | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `includes/find-training/class-hvac-find-training-page.php` | Main page handler (singleton), AJAX endpoints, asset enqueuing |
|
||||||
|
| `includes/find-training/class-hvac-training-map-data.php` | Data provider for trainer/venue markers with caching |
|
||||||
|
| `includes/find-training/class-hvac-venue-geocoding.php` | Auto-geocoding for TEC venues via Google API |
|
||||||
|
| `templates/page-find-training.php` | Page template with map, filters, modals |
|
||||||
|
| `assets/js/find-training-map.js` | Google Maps initialization, markers, clustering |
|
||||||
|
| `assets/js/find-training-filters.js` | Filter handling, geolocation, AJAX |
|
||||||
|
| `assets/css/find-training-map.css` | Complete responsive styling |
|
||||||
|
| `assets/images/marker-trainer.svg` | Teal person icon for trainers |
|
||||||
|
| `assets/images/marker-venue.svg` | Orange building icon for venues |
|
||||||
|
|
||||||
|
### Files Modified (3 files)
|
||||||
|
|
||||||
|
| File | Change |
|
||||||
|
|------|--------|
|
||||||
|
| `includes/class-hvac-page-manager.php` | Added find-training page definition |
|
||||||
|
| `includes/class-hvac-plugin.php` | Load new find-training classes |
|
||||||
|
| `includes/class-hvac-ajax-handlers.php` | Added contact form AJAX handler |
|
||||||
|
|
||||||
|
### Multi-Model Code Review Findings & Fixes
|
||||||
|
|
||||||
|
Ran comprehensive code review using GPT-5, Gemini 3, and Zen MCP tools. Found and fixed 6 issues:
|
||||||
|
|
||||||
|
| # | Severity | Issue | Fix |
|
||||||
|
|---|----------|-------|-----|
|
||||||
|
| 1 | **CRITICAL** | Missing `hvac_submit_contact_form` AJAX handler | Added full handler with rate limiting, validation, email |
|
||||||
|
| 2 | **HIGH** | XSS risk in InfoWindow onclick handlers | Replaced with DOM creation + addEventListener |
|
||||||
|
| 3 | **MEDIUM** | Uncached filter dropdown SQL queries | Added wp_cache with 1-hour TTL |
|
||||||
|
| 4 | **MEDIUM** | AJAX race condition on rapid filters | Added request abort handling |
|
||||||
|
| 5 | **LOW** | Hardcoded `/trainer/registration/` URL | Changed to `site_url()` |
|
||||||
|
| 6 | **LOW** | `alert()` for geolocation errors | Added inline dismissible notification |
|
||||||
|
|
||||||
|
### Features Implemented
|
||||||
|
|
||||||
|
- ✅ Google Maps with custom trainer/venue markers
|
||||||
|
- ✅ MarkerClusterer for dense areas
|
||||||
|
- ✅ Filter by State, Certification, Training Format
|
||||||
|
- ✅ Search by name/location
|
||||||
|
- ✅ "Near Me" geolocation button
|
||||||
|
- ✅ Trainer/Venue toggle switches
|
||||||
|
- ✅ Trainer profile modal with contact form
|
||||||
|
- ✅ Venue info modal with upcoming events
|
||||||
|
- ✅ Trainer directory grid below map
|
||||||
|
- ✅ 301 redirect from `/find-a-trainer` to `/find-training`
|
||||||
|
- ✅ Auto-geocoding for new venues
|
||||||
|
- ✅ Rate-limited batch geocoding for existing venues
|
||||||
|
|
||||||
|
### Deployment Status
|
||||||
|
- ✅ Deployed to staging
|
||||||
|
- ✅ Map loads with markers and clustering
|
||||||
|
- ✅ Filters working (state, certification, format)
|
||||||
|
- ✅ Contact form functional
|
||||||
|
- ✅ Deployed to production
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 PREVIOUS SESSION - E2E TESTING & BUG FIXES (Feb 1, 2026)
|
||||||
|
|
||||||
|
### Status: ✅ **COMPLETE - Deployed to Staging, Ready for Production**
|
||||||
|
|
||||||
|
**Objective:** Deploy security fixes to staging, run E2E tests, fix discovered bugs, and validate all functionality.
|
||||||
|
|
||||||
|
### Deployment & Testing Summary
|
||||||
|
|
||||||
|
1. ✅ **Deployed to Staging** - All 12 security fixes from previous session
|
||||||
|
2. ✅ **E2E Tests Passed** - Master trainer pages, security endpoints verified
|
||||||
|
3. ✅ **Discovered & Fixed 2 Critical Bugs** - Trainers table, event pages
|
||||||
|
4. ✅ **Created E2E Testing Skill** - `.claude/commands/e2e-visual-test.md`
|
||||||
|
5. ✅ **Added Staging Email Filter** - Prevents accidental user spam
|
||||||
|
|
||||||
|
### Bugs Found & Fixed
|
||||||
|
|
||||||
|
| Bug | Severity | Root Cause | Fix |
|
||||||
|
|-----|----------|------------|-----|
|
||||||
|
| **Trainers table empty** | HIGH | `ajax_filter_trainers()` used complex SQL that returned empty; `count_trainers_by_status()` only queried `hvac_trainer` role | Rewrote to use `get_trainers_table_data()` (same as working dashboard); fixed role query to include both roles |
|
||||||
|
| **Event pages blank** | HIGH | Template path mismatch: code referenced `page-trainer-event-manage.php` but file is `page-manage-event.php` | Fixed path in `class-hvac-event-manager.php:138` |
|
||||||
|
|
||||||
|
### Files Modified (4 files)
|
||||||
|
|
||||||
|
1. **`includes/class-hvac-master-trainers-overview.php`**
|
||||||
|
- Rewrote `ajax_filter_trainers()` to use reliable `get_trainers_table_data()`
|
||||||
|
- Fixed `count_trainers_by_status()` to include `hvac_master_trainer` role
|
||||||
|
- Now shows 53 trainers, 5 active (was showing 0)
|
||||||
|
|
||||||
|
2. **`includes/class-hvac-event-manager.php`**
|
||||||
|
- Fixed template path: `page-trainer-event-manage.php` → `page-manage-event.php`
|
||||||
|
- Event creation form now fully functional
|
||||||
|
|
||||||
|
3. **`hvac-community-events.php`**
|
||||||
|
- Added staging email filter (only `ben@tealmaker.com` receives emails)
|
||||||
|
- Protects real users from test emails during development
|
||||||
|
|
||||||
|
4. **`.claude/commands/e2e-visual-test.md`** (new)
|
||||||
|
- Created E2E visual testing skill for Playwright MCP browser tools
|
||||||
|
- Documents login procedure, test sequence, credentials
|
||||||
|
|
||||||
|
### E2E Test Results
|
||||||
|
|
||||||
|
| Feature | Status | Notes |
|
||||||
|
|---------|--------|-------|
|
||||||
|
| Master Trainer Login | ✅ PASS | Custom `/training-login/` works |
|
||||||
|
| Master Dashboard | ✅ PASS | Stats, tables, AJAX functional |
|
||||||
|
| Trainers Table | ✅ PASS | 53 trainers displayed correctly |
|
||||||
|
| Announcements | ✅ PASS | Modal opens, form accessible |
|
||||||
|
| Event Creation | ✅ PASS | Full form with all TEC fields |
|
||||||
|
| Certificate Reports | ✅ PASS | Empty state (needs events) |
|
||||||
|
| Security Endpoints | ✅ PASS | 4/4 properly return 401/400 |
|
||||||
|
|
||||||
|
### Staging Email Protection
|
||||||
|
|
||||||
|
Emails on staging are now filtered:
|
||||||
|
- **Allowed:** `ben@tealmaker.com`, `ben@measurequick.com`
|
||||||
|
- **Blocked:** All other recipients (logged for debugging)
|
||||||
|
- **Subject Prefix:** `[STAGING]` added to allowed emails
|
||||||
|
|
||||||
|
### Next Steps
|
||||||
|
1. ⏳ Deploy to production: `./scripts/deploy.sh production`
|
||||||
|
2. ⏳ Verify production functionality
|
||||||
|
3. ⏳ Monitor for any issues
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 PREVIOUS SESSION - MULTI-MODEL SECURITY CODE REVIEW (Jan 31, 2026)
|
||||||
|
|
||||||
|
### Status: ✅ **COMPLETE - Deployed to Staging**
|
||||||
|
|
||||||
|
**Objective:** Comprehensive security and business logic code review using 4 AI models (GPT-5, Gemini 3, Kimi K2.5, Zen MCP) across 11 critical files (~9,000 lines).
|
||||||
|
|
||||||
|
### Critical Issues Found & Fixed (12 total)
|
||||||
|
|
||||||
|
| ID | Severity | Issue | File |
|
||||||
|
|----|----------|-------|------|
|
||||||
|
| C1 | **CRITICAL** | Passwords stored in transients | `class-hvac-registration.php` |
|
||||||
|
| U1 | **CRITICAL** | O(3600) token verification loop (DoS) | `class-hvac-ajax-security.php` |
|
||||||
|
| U2 | **HIGH** | `remove_all_actions()` breaks WP isolation | `class-hvac-plugin.php` |
|
||||||
|
| C2 | **HIGH** | Encryption key in same database as data | `class-hvac-secure-storage.php` |
|
||||||
|
| M3 | **HIGH** | Revoked certificates still downloadable | `class-certificate-manager.php` |
|
||||||
|
| U3 | **HIGH** | Security headers not applied to AJAX | `class-hvac-ajax-security.php` |
|
||||||
|
| C3 | **MEDIUM** | IP spoofing undermines rate limiting | `class-hvac-security.php` |
|
||||||
|
| M1 | **MEDIUM** | Weak CSP with `unsafe-eval` | `class-hvac-ajax-security.php` |
|
||||||
|
| C5 | **MEDIUM** | Duplicate component initialization | `class-hvac-plugin.php` |
|
||||||
|
| U9 | **MEDIUM** | File-scope side-effect initialization | `class-hvac-trainer-profile-manager.php` |
|
||||||
|
| U11 | **LOW** | Timezone inconsistency in cert numbers | `class-certificate-manager.php` |
|
||||||
|
| U4 | **HIGH** | zoho-config.php not in .gitignore | `.gitignore` |
|
||||||
|
|
||||||
|
### Deliverables
|
||||||
|
- ✅ **Full Report:** `MULTI-MODEL-CODE-REVIEW-REPORT.md`
|
||||||
|
- ✅ **12 Security Fixes:** All implemented and deployed to staging
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 PREVIOUS SESSION - MASTER TRAINER PROFILE EDIT ENHANCEMENT (Jan 9, 2026)
|
||||||
|
|
||||||
### Status: ✅ **COMPLETE - Deployed to Production**
|
### Status: ✅ **COMPLETE - Deployed to Production**
|
||||||
|
|
||||||
|
|
@ -436,7 +1047,7 @@ DISPLAY=:1 HEADLESS=false node test-comprehensive-validation.js
|
||||||
|
|
||||||
### Production Environment
|
### Production Environment
|
||||||
**URL:** https://upskillhvac.com
|
**URL:** https://upskillhvac.com
|
||||||
**Version:** 2.1.8 (latest)
|
**Version:** 2.2.11 (latest)
|
||||||
**Server:** Cloudways Shared VPS
|
**Server:** Cloudways Shared VPS
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
|
||||||
2161
assets/css/find-training-map.css
Normal file
2161
assets/css/find-training-map.css
Normal file
File diff suppressed because it is too large
Load diff
BIN
assets/images/marker-trainer.svg
Normal file
BIN
assets/images/marker-trainer.svg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 339 B |
BIN
assets/images/marker-venue.svg
Normal file
BIN
assets/images/marker-venue.svg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 360 B |
604
assets/js/find-training-filters.js
Normal file
604
assets/js/find-training-filters.js
Normal file
|
|
@ -0,0 +1,604 @@
|
||||||
|
/**
|
||||||
|
* Find Training Filters
|
||||||
|
*
|
||||||
|
* Handles filtering, searching, and geolocation functionality
|
||||||
|
* for the Find Training page.
|
||||||
|
*
|
||||||
|
* @package HVAC_Community_Events
|
||||||
|
* @since 2.2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
(function($) {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
// Namespace for filters module
|
||||||
|
window.HVACTrainingFilters = {
|
||||||
|
|
||||||
|
// Debounce timer for search
|
||||||
|
searchTimer: null,
|
||||||
|
|
||||||
|
// Current AJAX request (for aborting)
|
||||||
|
currentRequest: null,
|
||||||
|
|
||||||
|
// Active filters
|
||||||
|
activeFilters: {
|
||||||
|
state: '',
|
||||||
|
certification: '',
|
||||||
|
training_format: '',
|
||||||
|
search: '',
|
||||||
|
include_past: false
|
||||||
|
},
|
||||||
|
|
||||||
|
// User location (if obtained)
|
||||||
|
userLocation: null,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize filters
|
||||||
|
*/
|
||||||
|
init: function() {
|
||||||
|
this.bindEvents();
|
||||||
|
this.initMobileFilterToggle();
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bind event handlers
|
||||||
|
*/
|
||||||
|
bindEvents: function() {
|
||||||
|
const self = this;
|
||||||
|
|
||||||
|
// Search input with debounce - client-side filtering for instant results
|
||||||
|
$('#hvac-training-search').on('input', function() {
|
||||||
|
clearTimeout(self.searchTimer);
|
||||||
|
const value = $(this).val().toLowerCase().trim();
|
||||||
|
|
||||||
|
self.searchTimer = setTimeout(function() {
|
||||||
|
self.activeFilters.search = value;
|
||||||
|
|
||||||
|
// For empty search or server-side filters, use AJAX
|
||||||
|
if (!value || self.hasActiveServerFilters()) {
|
||||||
|
self.applyFilters();
|
||||||
|
} else {
|
||||||
|
// Client-side filtering for instant results
|
||||||
|
self.filterActiveTabList(value);
|
||||||
|
}
|
||||||
|
}, 150); // Faster for client-side
|
||||||
|
});
|
||||||
|
|
||||||
|
// State filter
|
||||||
|
$('#hvac-filter-state').on('change', function() {
|
||||||
|
self.activeFilters.state = $(this).val();
|
||||||
|
self.applyFilters();
|
||||||
|
self.updateActiveFiltersDisplay();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Certification filter
|
||||||
|
$('#hvac-filter-certification').on('change', function() {
|
||||||
|
self.activeFilters.certification = $(this).val();
|
||||||
|
self.applyFilters();
|
||||||
|
self.updateActiveFiltersDisplay();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Training format filter
|
||||||
|
$('#hvac-filter-format').on('change', function() {
|
||||||
|
self.activeFilters.training_format = $(this).val();
|
||||||
|
self.applyFilters();
|
||||||
|
self.updateActiveFiltersDisplay();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Include past events checkbox
|
||||||
|
$('#hvac-include-past').on('change', function() {
|
||||||
|
self.activeFilters.include_past = $(this).is(':checked');
|
||||||
|
self.applyFilters();
|
||||||
|
self.updateActiveFiltersDisplay();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mobile include past events checkbox
|
||||||
|
$('#hvac-include-past-mobile').on('change', function() {
|
||||||
|
const checked = $(this).is(':checked');
|
||||||
|
$('#hvac-include-past').prop('checked', checked);
|
||||||
|
self.activeFilters.include_past = checked;
|
||||||
|
self.applyFilters();
|
||||||
|
self.updateActiveFiltersDisplay();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Near Me button
|
||||||
|
$('#hvac-near-me-btn').on('click', function() {
|
||||||
|
self.handleNearMeClick($(this));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clear all filters
|
||||||
|
$('.hvac-clear-filters').on('click', function() {
|
||||||
|
self.clearAllFilters();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Remove individual filter
|
||||||
|
$(document).on('click', '.hvac-active-filter button', function() {
|
||||||
|
const filterType = $(this).parent().data('filter');
|
||||||
|
self.removeFilter(filterType);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply current filters
|
||||||
|
*/
|
||||||
|
applyFilters: function() {
|
||||||
|
const self = this;
|
||||||
|
|
||||||
|
// Abort any pending request to prevent race conditions
|
||||||
|
if (this.currentRequest && this.currentRequest.readyState !== 4) {
|
||||||
|
this.currentRequest.abort();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build filter data
|
||||||
|
const filterData = {
|
||||||
|
action: 'hvac_filter_training_map',
|
||||||
|
nonce: hvacFindTraining.nonce,
|
||||||
|
state: this.activeFilters.state,
|
||||||
|
certification: this.activeFilters.certification,
|
||||||
|
training_format: this.activeFilters.training_format,
|
||||||
|
search: this.activeFilters.search,
|
||||||
|
show_trainers: $('#hvac-show-trainers').is(':checked'),
|
||||||
|
show_venues: $('#hvac-show-venues').is(':checked'),
|
||||||
|
show_events: $('#hvac-show-events').is(':checked'),
|
||||||
|
include_past: this.activeFilters.include_past
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add user location if available
|
||||||
|
if (this.userLocation) {
|
||||||
|
filterData.lat = this.userLocation.lat;
|
||||||
|
filterData.lng = this.userLocation.lng;
|
||||||
|
filterData.radius = 100; // km
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send filter request and store reference
|
||||||
|
this.currentRequest = $.ajax({
|
||||||
|
url: hvacFindTraining.ajax_url,
|
||||||
|
type: 'POST',
|
||||||
|
data: filterData,
|
||||||
|
success: function(response) {
|
||||||
|
if (response.success) {
|
||||||
|
// Update map data
|
||||||
|
HVACTrainingMap.trainers = response.data.trainers || [];
|
||||||
|
HVACTrainingMap.venues = response.data.venues || [];
|
||||||
|
HVACTrainingMap.events = response.data.events || [];
|
||||||
|
|
||||||
|
// Reset visible arrays to all items
|
||||||
|
HVACTrainingMap.visibleTrainers = HVACTrainingMap.trainers.slice();
|
||||||
|
HVACTrainingMap.visibleVenues = HVACTrainingMap.venues.slice();
|
||||||
|
HVACTrainingMap.visibleEvents = HVACTrainingMap.events.slice();
|
||||||
|
|
||||||
|
// Reset displayed counts
|
||||||
|
HVACTrainingMap.displayedCounts = { trainers: 0, venues: 0, events: 0 };
|
||||||
|
|
||||||
|
// Update map markers
|
||||||
|
HVACTrainingMap.updateMarkers();
|
||||||
|
|
||||||
|
// Update all counts
|
||||||
|
HVACTrainingMap.updateAllCounts();
|
||||||
|
|
||||||
|
// Render the active tab
|
||||||
|
HVACTrainingMap.renderActiveTabList();
|
||||||
|
|
||||||
|
// Show notification if "Near Me" filter returned no results
|
||||||
|
if (self.userLocation) {
|
||||||
|
const totalResults = HVACTrainingMap.trainers.length +
|
||||||
|
HVACTrainingMap.venues.length +
|
||||||
|
HVACTrainingMap.events.length;
|
||||||
|
if (totalResults === 0) {
|
||||||
|
self.showLocationError('No trainers, venues, or events found within 100km of your location. Try removing the "Near Me" filter to see all results.');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Note: syncSidebarWithViewport will be called by map 'idle' event
|
||||||
|
}
|
||||||
|
},
|
||||||
|
complete: function() {
|
||||||
|
self.currentRequest = null;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Show/hide clear button
|
||||||
|
this.updateClearButtonVisibility();
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle Near Me button click
|
||||||
|
*/
|
||||||
|
handleNearMeClick: function($button) {
|
||||||
|
const self = this;
|
||||||
|
|
||||||
|
// Show loading state
|
||||||
|
$button.prop('disabled', true);
|
||||||
|
$button.html('<span class="dashicons dashicons-update-alt hvac-spin"></span><span class="hvac-btn-text">Locating...</span>');
|
||||||
|
|
||||||
|
// Clear any previous error message
|
||||||
|
this.clearLocationError();
|
||||||
|
|
||||||
|
// Get user location
|
||||||
|
HVACTrainingMap.getUserLocation(function(location, error) {
|
||||||
|
if (location) {
|
||||||
|
self.userLocation = location;
|
||||||
|
|
||||||
|
// Center map on user location
|
||||||
|
HVACTrainingMap.centerOnLocation(location.lat, location.lng, 9);
|
||||||
|
|
||||||
|
// Apply filters with location
|
||||||
|
self.applyFilters();
|
||||||
|
|
||||||
|
// Update button state
|
||||||
|
$button.html('<span class="dashicons dashicons-yes-alt"></span><span class="hvac-btn-text">Near Me</span>');
|
||||||
|
$button.addClass('active');
|
||||||
|
|
||||||
|
// Add to active filters display
|
||||||
|
self.addActiveFilter('location', 'Near Me');
|
||||||
|
} else {
|
||||||
|
// Show inline error instead of alert
|
||||||
|
self.showLocationError(error || 'Unable to get your location. Please check browser permissions.');
|
||||||
|
|
||||||
|
// Reset button
|
||||||
|
$button.html('<span class="dashicons dashicons-location-alt"></span><span class="hvac-btn-text">Near Me</span>');
|
||||||
|
$button.prop('disabled', false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show location error message inline
|
||||||
|
*/
|
||||||
|
showLocationError: function(message) {
|
||||||
|
// Remove any existing error
|
||||||
|
this.clearLocationError();
|
||||||
|
|
||||||
|
// Create error element
|
||||||
|
const $error = $('<div class="hvac-location-error">' +
|
||||||
|
'<span class="dashicons dashicons-warning"></span> ' +
|
||||||
|
this.escapeHtml(message) +
|
||||||
|
'<button type="button" class="hvac-dismiss-error" aria-label="Dismiss">×</button>' +
|
||||||
|
'</div>');
|
||||||
|
|
||||||
|
// Insert after Near Me button
|
||||||
|
$('#hvac-near-me-btn').after($error);
|
||||||
|
|
||||||
|
// Auto-dismiss after 5 seconds
|
||||||
|
setTimeout(function() {
|
||||||
|
$error.fadeOut(300, function() { $(this).remove(); });
|
||||||
|
}, 5000);
|
||||||
|
|
||||||
|
// Click to dismiss
|
||||||
|
$error.find('.hvac-dismiss-error').on('click', function() {
|
||||||
|
$error.remove();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear location error message
|
||||||
|
*/
|
||||||
|
clearLocationError: function() {
|
||||||
|
$('.hvac-location-error').remove();
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear all filters
|
||||||
|
*/
|
||||||
|
clearAllFilters: function() {
|
||||||
|
// Reset filter values
|
||||||
|
this.activeFilters = {
|
||||||
|
state: '',
|
||||||
|
certification: '',
|
||||||
|
training_format: '',
|
||||||
|
search: '',
|
||||||
|
include_past: false
|
||||||
|
};
|
||||||
|
|
||||||
|
// Reset user location
|
||||||
|
this.userLocation = null;
|
||||||
|
|
||||||
|
// Reset form elements
|
||||||
|
$('#hvac-filter-state').val('');
|
||||||
|
$('#hvac-filter-certification').val('');
|
||||||
|
$('#hvac-filter-format').val('');
|
||||||
|
$('#hvac-training-search').val('');
|
||||||
|
$('#hvac-include-past').prop('checked', false);
|
||||||
|
$('#hvac-include-past-mobile').prop('checked', false);
|
||||||
|
|
||||||
|
// Reset Near Me button
|
||||||
|
$('#hvac-near-me-btn')
|
||||||
|
.removeClass('active')
|
||||||
|
.html('<span class="dashicons dashicons-location-alt"></span><span class="hvac-btn-text">Near Me</span>')
|
||||||
|
.prop('disabled', false);
|
||||||
|
|
||||||
|
// Clear active filters display
|
||||||
|
$('.hvac-active-filters').empty();
|
||||||
|
|
||||||
|
// Hide clear button
|
||||||
|
$('.hvac-clear-filters').hide();
|
||||||
|
|
||||||
|
// Reset map to default view
|
||||||
|
HVACTrainingMap.map.setCenter(HVACTrainingMap.config.defaultCenter);
|
||||||
|
HVACTrainingMap.map.setZoom(HVACTrainingMap.config.defaultZoom);
|
||||||
|
|
||||||
|
// Reload data without filters
|
||||||
|
HVACTrainingMap.loadMapData();
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a specific filter
|
||||||
|
*/
|
||||||
|
removeFilter: function(filterType) {
|
||||||
|
switch (filterType) {
|
||||||
|
case 'state':
|
||||||
|
this.activeFilters.state = '';
|
||||||
|
$('#hvac-filter-state').val('');
|
||||||
|
break;
|
||||||
|
case 'certification':
|
||||||
|
this.activeFilters.certification = '';
|
||||||
|
$('#hvac-filter-certification').val('');
|
||||||
|
break;
|
||||||
|
case 'training_format':
|
||||||
|
this.activeFilters.training_format = '';
|
||||||
|
$('#hvac-filter-format').val('');
|
||||||
|
break;
|
||||||
|
case 'search':
|
||||||
|
this.activeFilters.search = '';
|
||||||
|
$('#hvac-training-search').val('');
|
||||||
|
break;
|
||||||
|
case 'location':
|
||||||
|
this.userLocation = null;
|
||||||
|
$('#hvac-near-me-btn')
|
||||||
|
.removeClass('active')
|
||||||
|
.html('<span class="dashicons dashicons-location-alt"></span><span class="hvac-btn-text">Near Me</span>')
|
||||||
|
.prop('disabled', false);
|
||||||
|
break;
|
||||||
|
case 'include_past':
|
||||||
|
this.activeFilters.include_past = false;
|
||||||
|
$('#hvac-include-past').prop('checked', false);
|
||||||
|
$('#hvac-include-past-mobile').prop('checked', false);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.applyFilters();
|
||||||
|
this.updateActiveFiltersDisplay();
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update active filters display
|
||||||
|
*/
|
||||||
|
updateActiveFiltersDisplay: function() {
|
||||||
|
const $container = $('.hvac-active-filters');
|
||||||
|
$container.empty();
|
||||||
|
|
||||||
|
// State filter
|
||||||
|
if (this.activeFilters.state) {
|
||||||
|
this.addActiveFilter('state', `State: ${this.activeFilters.state}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Certification filter
|
||||||
|
if (this.activeFilters.certification) {
|
||||||
|
this.addActiveFilter('certification', this.activeFilters.certification);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Training format filter
|
||||||
|
if (this.activeFilters.training_format) {
|
||||||
|
this.addActiveFilter('training_format', this.activeFilters.training_format);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search filter
|
||||||
|
if (this.activeFilters.search) {
|
||||||
|
this.addActiveFilter('search', `"${this.activeFilters.search}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Location filter
|
||||||
|
if (this.userLocation) {
|
||||||
|
this.addActiveFilter('location', 'Near Me');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Include past events filter
|
||||||
|
if (this.activeFilters.include_past) {
|
||||||
|
this.addActiveFilter('include_past', 'Including Past Events');
|
||||||
|
}
|
||||||
|
|
||||||
|
this.updateClearButtonVisibility();
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add an active filter chip
|
||||||
|
*/
|
||||||
|
addActiveFilter: function(type, label) {
|
||||||
|
const $container = $('.hvac-active-filters');
|
||||||
|
const $chip = $(`
|
||||||
|
<span class="hvac-active-filter" data-filter="${type}">
|
||||||
|
${this.escapeHtml(label)}
|
||||||
|
<button type="button" aria-label="Remove filter">×</button>
|
||||||
|
</span>
|
||||||
|
`);
|
||||||
|
$container.append($chip);
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update clear button visibility
|
||||||
|
*/
|
||||||
|
updateClearButtonVisibility: function() {
|
||||||
|
const hasFilters = this.activeFilters.state ||
|
||||||
|
this.activeFilters.certification ||
|
||||||
|
this.activeFilters.training_format ||
|
||||||
|
this.activeFilters.search ||
|
||||||
|
this.activeFilters.include_past ||
|
||||||
|
this.userLocation;
|
||||||
|
|
||||||
|
if (hasFilters) {
|
||||||
|
$('.hvac-clear-filters').show();
|
||||||
|
} else {
|
||||||
|
$('.hvac-clear-filters').hide();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if any server-side filters are active
|
||||||
|
*/
|
||||||
|
hasActiveServerFilters: function() {
|
||||||
|
return this.activeFilters.state ||
|
||||||
|
this.activeFilters.certification ||
|
||||||
|
this.activeFilters.training_format ||
|
||||||
|
this.activeFilters.include_past ||
|
||||||
|
this.userLocation;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filter the active tab's list client-side for instant results
|
||||||
|
*/
|
||||||
|
filterActiveTabList: function(searchTerm) {
|
||||||
|
const activeTab = HVACTrainingMap.activeTab;
|
||||||
|
let items, filterFn;
|
||||||
|
|
||||||
|
switch (activeTab) {
|
||||||
|
case 'trainers':
|
||||||
|
items = HVACTrainingMap.visibleTrainers.length > 0
|
||||||
|
? HVACTrainingMap.trainers
|
||||||
|
: HVACTrainingMap.trainers;
|
||||||
|
filterFn = (trainer) => {
|
||||||
|
const searchFields = [
|
||||||
|
trainer.name,
|
||||||
|
trainer.city,
|
||||||
|
trainer.state,
|
||||||
|
trainer.company,
|
||||||
|
...(trainer.certifications || [])
|
||||||
|
].filter(Boolean).join(' ').toLowerCase();
|
||||||
|
return searchFields.includes(searchTerm);
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'venues':
|
||||||
|
items = HVACTrainingMap.venues;
|
||||||
|
filterFn = (venue) => {
|
||||||
|
const searchFields = [
|
||||||
|
venue.name,
|
||||||
|
venue.city,
|
||||||
|
venue.state,
|
||||||
|
venue.address
|
||||||
|
].filter(Boolean).join(' ').toLowerCase();
|
||||||
|
return searchFields.includes(searchTerm);
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'events':
|
||||||
|
items = HVACTrainingMap.events;
|
||||||
|
filterFn = (event) => {
|
||||||
|
const searchFields = [
|
||||||
|
event.title,
|
||||||
|
event.venue_name,
|
||||||
|
event.venue_city,
|
||||||
|
event.venue_state
|
||||||
|
].filter(Boolean).join(' ').toLowerCase();
|
||||||
|
return searchFields.includes(searchTerm);
|
||||||
|
};
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply filter
|
||||||
|
const filteredItems = searchTerm ? items.filter(filterFn) : items;
|
||||||
|
|
||||||
|
// Update the visible items for the active tab
|
||||||
|
switch (activeTab) {
|
||||||
|
case 'trainers':
|
||||||
|
HVACTrainingMap.visibleTrainers = filteredItems;
|
||||||
|
HVACTrainingMap.displayedCounts.trainers = 0;
|
||||||
|
HVACTrainingMap.updateTrainerGrid();
|
||||||
|
break;
|
||||||
|
case 'venues':
|
||||||
|
HVACTrainingMap.visibleVenues = filteredItems;
|
||||||
|
HVACTrainingMap.displayedCounts.venues = 0;
|
||||||
|
HVACTrainingMap.updateVenueGrid();
|
||||||
|
break;
|
||||||
|
case 'events':
|
||||||
|
HVACTrainingMap.visibleEvents = filteredItems;
|
||||||
|
HVACTrainingMap.displayedCounts.events = 0;
|
||||||
|
HVACTrainingMap.updateEventGrid();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update all counts
|
||||||
|
HVACTrainingMap.updateAllCounts();
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Escape HTML for safe output
|
||||||
|
*/
|
||||||
|
escapeHtml: function(text) {
|
||||||
|
if (!text) return '';
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = text;
|
||||||
|
return div.innerHTML;
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize mobile filter toggle
|
||||||
|
*/
|
||||||
|
initMobileFilterToggle: function() {
|
||||||
|
const self = this;
|
||||||
|
|
||||||
|
// Mobile filter panel toggle
|
||||||
|
$(document).on('click', '.hvac-mobile-filter-toggle', function() {
|
||||||
|
const $toggle = $(this);
|
||||||
|
const $panel = $('#hvac-mobile-filter-panel');
|
||||||
|
const isExpanded = $toggle.attr('aria-expanded') === 'true';
|
||||||
|
|
||||||
|
if (isExpanded) {
|
||||||
|
$panel.attr('hidden', '');
|
||||||
|
$toggle.attr('aria-expanded', 'false');
|
||||||
|
} else {
|
||||||
|
$panel.removeAttr('hidden');
|
||||||
|
$toggle.attr('aria-expanded', 'true');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sync mobile filter selects with desktop selects
|
||||||
|
$('#hvac-filter-state-mobile').on('change', function() {
|
||||||
|
const value = $(this).val();
|
||||||
|
$('#hvac-filter-state').val(value);
|
||||||
|
self.activeFilters.state = value;
|
||||||
|
self.applyFilters();
|
||||||
|
self.updateActiveFiltersDisplay();
|
||||||
|
});
|
||||||
|
|
||||||
|
$('#hvac-filter-certification-mobile').on('change', function() {
|
||||||
|
const value = $(this).val();
|
||||||
|
$('#hvac-filter-certification').val(value);
|
||||||
|
self.activeFilters.certification = value;
|
||||||
|
self.applyFilters();
|
||||||
|
self.updateActiveFiltersDisplay();
|
||||||
|
});
|
||||||
|
|
||||||
|
$('#hvac-filter-format-mobile').on('change', function() {
|
||||||
|
const value = $(this).val();
|
||||||
|
$('#hvac-filter-format').val(value);
|
||||||
|
self.activeFilters.training_format = value;
|
||||||
|
self.applyFilters();
|
||||||
|
self.updateActiveFiltersDisplay();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Also sync desktop to mobile when desktop changes
|
||||||
|
$('#hvac-filter-state').on('change', function() {
|
||||||
|
$('#hvac-filter-state-mobile').val($(this).val());
|
||||||
|
});
|
||||||
|
|
||||||
|
$('#hvac-filter-certification').on('change', function() {
|
||||||
|
$('#hvac-filter-certification-mobile').val($(this).val());
|
||||||
|
});
|
||||||
|
|
||||||
|
$('#hvac-filter-format').on('change', function() {
|
||||||
|
$('#hvac-filter-format-mobile').val($(this).val());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Initialize when document is ready
|
||||||
|
$(document).ready(function() {
|
||||||
|
if ($('#hvac-training-map').length) {
|
||||||
|
HVACTrainingFilters.init();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
})(jQuery);
|
||||||
1754
assets/js/find-training-map.js
Normal file
1754
assets/js/find-training-map.js
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -220,6 +220,13 @@ jQuery(document).ready(function ($) {
|
||||||
successHtml += '<p>Refresh Token: ❌ Missing (OAuth required)</p>';
|
successHtml += '<p>Refresh Token: ❌ Missing (OAuth required)</p>';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Show staging warning if present
|
||||||
|
if (response.data.staging_warning) {
|
||||||
|
successHtml += '<div style="margin-top: 10px; padding: 8px 12px; background: #fff8e5; border-left: 3px solid #ffb900;">';
|
||||||
|
successHtml += '<p style="margin: 0; color: #826200;"><strong>Warning:</strong> ' + response.data.staging_warning + '</p>';
|
||||||
|
successHtml += '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
// Show debug info if available
|
// Show debug info if available
|
||||||
if (response.data.debug) {
|
if (response.data.debug) {
|
||||||
successHtml += '<details style="margin-top: 10px;">';
|
successHtml += '<details style="margin-top: 10px;">';
|
||||||
|
|
@ -622,6 +629,47 @@ jQuery(document).ready(function ($) {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// =====================================================
|
||||||
|
// Reset Sync Hashes Handler
|
||||||
|
// =====================================================
|
||||||
|
$('#reset-sync-hashes').on('click', function () {
|
||||||
|
if (!confirm('This will clear all sync hashes and force every record to re-sync on the next run. Continue?')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var $button = $(this);
|
||||||
|
var $status = $('#reset-hashes-status');
|
||||||
|
|
||||||
|
$button.prop('disabled', true).text('Resetting...');
|
||||||
|
$status.text('');
|
||||||
|
|
||||||
|
$.ajax({
|
||||||
|
url: hvacZoho.ajaxUrl,
|
||||||
|
method: 'POST',
|
||||||
|
data: {
|
||||||
|
action: 'hvac_zoho_reset_sync_hashes',
|
||||||
|
nonce: hvacZoho.nonce
|
||||||
|
},
|
||||||
|
success: function (response) {
|
||||||
|
if (response.success) {
|
||||||
|
$status.html(
|
||||||
|
'<span style="color: #46b450;">' +
|
||||||
|
response.data.posts_cleared + ' post hashes and ' +
|
||||||
|
response.data.users_cleared + ' user hashes cleared.</span>'
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
$status.html('<span style="color: #dc3232;">Error: ' + response.data.message + '</span>');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
error: function () {
|
||||||
|
$status.html('<span style="color: #dc3232;">Network error - please try again</span>');
|
||||||
|
},
|
||||||
|
complete: function () {
|
||||||
|
$button.prop('disabled', false).text('Force Full Re-sync (Reset Hashes)');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// =====================================================
|
// =====================================================
|
||||||
// Diagnostic Test Handler
|
// Diagnostic Test Handler
|
||||||
// =====================================================
|
// =====================================================
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,8 @@ class HVAC_Zoho_Admin {
|
||||||
add_action('wp_ajax_hvac_zoho_run_scheduled_sync', array($this, 'run_scheduled_sync_now'));
|
add_action('wp_ajax_hvac_zoho_run_scheduled_sync', array($this, 'run_scheduled_sync_now'));
|
||||||
// Add simple test handler
|
// Add simple test handler
|
||||||
add_action('wp_ajax_hvac_zoho_simple_test', array($this, 'simple_test'));
|
add_action('wp_ajax_hvac_zoho_simple_test', array($this, 'simple_test'));
|
||||||
|
// Add hash reset handler
|
||||||
|
add_action('wp_ajax_hvac_zoho_reset_sync_hashes', array($this, 'reset_sync_hashes'));
|
||||||
// Add OAuth callback handler - only use one method to prevent duplicates
|
// Add OAuth callback handler - only use one method to prevent duplicates
|
||||||
add_action('init', array($this, 'add_oauth_rewrite_rule'), 5);
|
add_action('init', array($this, 'add_oauth_rewrite_rule'), 5);
|
||||||
add_filter('query_vars', array($this, 'add_oauth_query_vars'), 10, 1);
|
add_filter('query_vars', array($this, 'add_oauth_query_vars'), 10, 1);
|
||||||
|
|
@ -297,6 +299,13 @@ class HVAC_Zoho_Admin {
|
||||||
<div class="hvac-zoho-sync">
|
<div class="hvac-zoho-sync">
|
||||||
<h2>Data Sync</h2>
|
<h2>Data Sync</h2>
|
||||||
|
|
||||||
|
<div class="sync-maintenance" style="margin-bottom: 20px; padding: 12px 15px; background: #fff8e5; border-left: 4px solid #ffb900;">
|
||||||
|
<p style="margin: 0 0 8px 0;"><strong>Sync Maintenance</strong></p>
|
||||||
|
<p style="margin: 0 0 8px 0; font-size: 13px;">If records aren't syncing (e.g. after a failed sync or configuration change), reset sync hashes to force all records to re-sync on the next run.</p>
|
||||||
|
<button class="button" id="reset-sync-hashes">Force Full Re-sync (Reset Hashes)</button>
|
||||||
|
<span id="reset-hashes-status" style="margin-left: 10px;"></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="sync-section">
|
<div class="sync-section">
|
||||||
<h3>Events → Campaigns</h3>
|
<h3>Events → Campaigns</h3>
|
||||||
<p>Sync events from The Events Calendar to Zoho CRM Campaigns</p>
|
<p>Sync events from The Events Calendar to Zoho CRM Campaigns</p>
|
||||||
|
|
@ -897,20 +906,28 @@ class HVAC_Zoho_Admin {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Success!
|
// Success!
|
||||||
// Success!
|
$mode_info = HVAC_Zoho_CRM_Auth::get_debug_mode_info();
|
||||||
wp_send_json_success(array(
|
$response_data = array(
|
||||||
'message' => 'Connection successful!',
|
'message' => 'Connection successful!',
|
||||||
'modules' => isset($response['modules']) ? count($response['modules']) . ' modules available' : 'API connected',
|
'modules' => isset($response['modules']) ? count($response['modules']) . ' modules available' : 'API connected',
|
||||||
'client_id' => substr($client_id, 0, 10) . '...',
|
'client_id' => substr($client_id, 0, 10) . '...',
|
||||||
'client_secret_exists' => true,
|
'client_secret_exists' => true,
|
||||||
'refresh_token_exists' => true,
|
'refresh_token_exists' => true,
|
||||||
|
'is_staging' => $mode_info['is_staging'],
|
||||||
|
'mode_info' => $mode_info,
|
||||||
'credentials_status' => array(
|
'credentials_status' => array(
|
||||||
'client_id' => substr($client_id, 0, 10) . '...',
|
'client_id' => substr($client_id, 0, 10) . '...',
|
||||||
'client_secret_exists' => true,
|
'client_secret_exists' => true,
|
||||||
'refresh_token_exists' => true,
|
'refresh_token_exists' => true,
|
||||||
'api_working' => true
|
'api_working' => true
|
||||||
)
|
)
|
||||||
));
|
);
|
||||||
|
|
||||||
|
if ($mode_info['is_staging']) {
|
||||||
|
$response_data['staging_warning'] = 'WARNING: Staging mode is active. All write operations (sync) are blocked. Hostname: ' . ($mode_info['parsed_host'] ?? 'unknown');
|
||||||
|
}
|
||||||
|
|
||||||
|
wp_send_json_success($response_data);
|
||||||
} catch (Exception $e) {
|
} catch (Exception $e) {
|
||||||
$error_response = array(
|
$error_response = array(
|
||||||
'message' => 'Connection test failed due to exception',
|
'message' => 'Connection test failed due to exception',
|
||||||
|
|
@ -1072,5 +1089,42 @@ class HVAC_Zoho_Admin {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reset all Zoho sync hashes to force a full re-sync
|
||||||
|
*/
|
||||||
|
public function reset_sync_hashes() {
|
||||||
|
check_ajax_referer('hvac_zoho_nonce', 'nonce');
|
||||||
|
|
||||||
|
if (!current_user_can('manage_options')) {
|
||||||
|
wp_send_json_error(array('message' => 'Unauthorized access'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
global $wpdb;
|
||||||
|
|
||||||
|
// Delete all _zoho_sync_hash post meta
|
||||||
|
$posts_cleared = $wpdb->query(
|
||||||
|
"DELETE FROM {$wpdb->postmeta} WHERE meta_key = '_zoho_sync_hash'"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Delete all _zoho_sync_hash user meta
|
||||||
|
$users_cleared = $wpdb->query(
|
||||||
|
"DELETE FROM {$wpdb->usermeta} WHERE meta_key = '_zoho_sync_hash'"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Also clear last sync time so scheduled sync does a full run
|
||||||
|
delete_option('hvac_zoho_last_sync_time');
|
||||||
|
|
||||||
|
if (class_exists('HVAC_Logger')) {
|
||||||
|
HVAC_Logger::info("Sync hashes reset: {$posts_cleared} post hashes, {$users_cleared} user hashes cleared", 'ZohoAdmin');
|
||||||
|
}
|
||||||
|
|
||||||
|
wp_send_json_success(array(
|
||||||
|
'message' => 'Sync hashes reset successfully',
|
||||||
|
'posts_cleared' => (int) $posts_cleared,
|
||||||
|
'users_cleared' => (int) $users_cleared,
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
?>
|
?>
|
||||||
|
|
@ -67,7 +67,10 @@ class HVAC_Certificate_Manager {
|
||||||
update_option('hvac_certificate_counter', $counter);
|
update_option('hvac_certificate_counter', $counter);
|
||||||
|
|
||||||
// Format: PREFIX-YEAR-SEQUENTIAL (e.g., HVAC-2023-00001)
|
// Format: PREFIX-YEAR-SEQUENTIAL (e.g., HVAC-2023-00001)
|
||||||
$year = date('Y');
|
// FIX (U11): Use WordPress current_time() for consistent timezone handling
|
||||||
|
// Previous code used PHP date('Y') which uses server timezone, while
|
||||||
|
// date_generated uses WordPress timezone, causing year boundary inconsistencies
|
||||||
|
$year = date('Y', current_time('timestamp'));
|
||||||
$formatted_counter = str_pad($counter, 5, '0', STR_PAD_LEFT);
|
$formatted_counter = str_pad($counter, 5, '0', STR_PAD_LEFT);
|
||||||
|
|
||||||
return $prefix . $year . '-' . $formatted_counter;
|
return $prefix . $year . '-' . $formatted_counter;
|
||||||
|
|
@ -836,11 +839,25 @@ class HVAC_Certificate_Manager {
|
||||||
/**
|
/**
|
||||||
* Get certificate file URL.
|
* Get certificate file URL.
|
||||||
*
|
*
|
||||||
|
* SECURITY FIX (M3): Check if certificate is revoked before generating URL.
|
||||||
|
* Revoked certificates should not be downloadable.
|
||||||
|
*
|
||||||
* @param int $certificate_id The certificate ID.
|
* @param int $certificate_id The certificate ID.
|
||||||
*
|
*
|
||||||
* @return string|false The file URL if found, false otherwise.
|
* @return string|false The file URL if found, false if not found or revoked.
|
||||||
*/
|
*/
|
||||||
public function get_certificate_url($certificate_id) {
|
public function get_certificate_url($certificate_id) {
|
||||||
|
// Verify certificate exists and is not revoked
|
||||||
|
$certificate = $this->get_certificate($certificate_id);
|
||||||
|
if (!$certificate) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if certificate has been revoked
|
||||||
|
if (!empty($certificate->revoked)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
// Create a secure URL with nonce for downloading
|
// Create a secure URL with nonce for downloading
|
||||||
$url = add_query_arg(
|
$url = add_query_arg(
|
||||||
array(
|
array(
|
||||||
|
|
|
||||||
|
|
@ -65,6 +65,14 @@ 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'));
|
||||||
|
|
||||||
|
// Contact venue form (Find Training page - Approved Labs)
|
||||||
|
add_action('wp_ajax_hvac_submit_venue_contact', array($this, 'submit_venue_contact_form'));
|
||||||
|
add_action('wp_ajax_nopriv_hvac_submit_venue_contact', array($this, 'submit_venue_contact_form'));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -1024,6 +1032,343 @@ 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
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle venue contact form submission from Find Training page
|
||||||
|
*
|
||||||
|
* Sends an email to the venue POC (Point of Contact) with the visitor's inquiry.
|
||||||
|
* Available to both logged-in and anonymous users.
|
||||||
|
*/
|
||||||
|
public function submit_venue_contact_form() {
|
||||||
|
// Verify nonce
|
||||||
|
if (!isset($_POST['nonce']) || !wp_verify_nonce($_POST['nonce'], 'hvac_find_training')) {
|
||||||
|
wp_send_json_error(['message' => 'Invalid security token'], 403);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rate limiting - max 5 submissions per IP per hour
|
||||||
|
$ip = $this->get_client_ip();
|
||||||
|
$rate_key = 'hvac_venue_contact_rate_' . md5($ip);
|
||||||
|
$submissions = get_transient($rate_key) ?: 0;
|
||||||
|
|
||||||
|
if ($submissions >= 5) {
|
||||||
|
wp_send_json_error(['message' => 'Too many submissions. Please try again later.'], 429);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate required fields
|
||||||
|
$required_fields = ['first_name', 'last_name', 'email', 'venue_id'];
|
||||||
|
foreach ($required_fields as $field) {
|
||||||
|
if (empty($_POST[$field])) {
|
||||||
|
wp_send_json_error(['message' => "Missing required field: {$field}"], 400);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sanitize inputs
|
||||||
|
$first_name = sanitize_text_field($_POST['first_name']);
|
||||||
|
$last_name = sanitize_text_field($_POST['last_name']);
|
||||||
|
$email = sanitize_email($_POST['email']);
|
||||||
|
$phone = sanitize_text_field($_POST['phone'] ?? '');
|
||||||
|
$company = sanitize_text_field($_POST['company'] ?? '');
|
||||||
|
$message = sanitize_textarea_field($_POST['message'] ?? '');
|
||||||
|
$venue_id = absint($_POST['venue_id']);
|
||||||
|
|
||||||
|
// Validate email
|
||||||
|
if (!is_email($email)) {
|
||||||
|
wp_send_json_error(['message' => 'Invalid email address'], 400);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get venue data
|
||||||
|
$venue = get_post($venue_id);
|
||||||
|
if (!$venue || $venue->post_type !== 'tribe_venue') {
|
||||||
|
wp_send_json_error(['message' => 'Venue not found'], 404);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$venue_name = $venue->post_title;
|
||||||
|
|
||||||
|
// Get POC information from venue meta
|
||||||
|
$poc_user_id = get_post_meta($venue_id, '_venue_poc_user_id', true);
|
||||||
|
$poc_email = get_post_meta($venue_id, '_venue_poc_email', true);
|
||||||
|
$poc_name = get_post_meta($venue_id, '_venue_poc_name', true);
|
||||||
|
|
||||||
|
// Fallback to post author if no POC meta
|
||||||
|
if (empty($poc_user_id)) {
|
||||||
|
$poc_user_id = $venue->post_author;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($poc_email)) {
|
||||||
|
$author = get_userdata($poc_user_id);
|
||||||
|
if ($author) {
|
||||||
|
$poc_email = $author->user_email;
|
||||||
|
$poc_name = $poc_name ?: $author->display_name;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($poc_email)) {
|
||||||
|
wp_send_json_error(['message' => 'Unable to find contact for this venue'], 500);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build email content
|
||||||
|
$subject = sprintf(
|
||||||
|
'[Upskill HVAC] Training Lab Inquiry - %s',
|
||||||
|
$venue_name
|
||||||
|
);
|
||||||
|
|
||||||
|
$body = sprintf(
|
||||||
|
"Hello %s,\n\n" .
|
||||||
|
"You have received an inquiry about your training lab through the Upskill HVAC directory.\n\n" .
|
||||||
|
"--- Training Lab ---\n" .
|
||||||
|
"%s\n\n" .
|
||||||
|
"--- Contact Details ---\n" .
|
||||||
|
"Name: %s %s\n" .
|
||||||
|
"Email: %s\n" .
|
||||||
|
"%s" . // Phone (optional)
|
||||||
|
"%s" . // Company (optional)
|
||||||
|
"\n--- Message ---\n%s\n\n" .
|
||||||
|
"---\n" .
|
||||||
|
"This message was sent via the Find Training page at %s\n" .
|
||||||
|
"Please respond directly to the sender's email address.\n",
|
||||||
|
$poc_name ?: 'Training Lab Contact',
|
||||||
|
$venue_name,
|
||||||
|
$first_name,
|
||||||
|
$last_name,
|
||||||
|
$email,
|
||||||
|
$phone ? "Phone: {$phone}\n" : '',
|
||||||
|
$company ? "Company: {$company}\n" : '',
|
||||||
|
$message ?: '(No message provided)',
|
||||||
|
home_url('/find-training/')
|
||||||
|
);
|
||||||
|
|
||||||
|
// Email headers
|
||||||
|
$headers = [
|
||||||
|
'Content-Type: text/plain; charset=UTF-8',
|
||||||
|
sprintf('Reply-To: %s %s <%s>', $first_name, $last_name, $email),
|
||||||
|
sprintf('From: Upskill HVAC <noreply@%s>', parse_url(home_url(), PHP_URL_HOST))
|
||||||
|
];
|
||||||
|
|
||||||
|
// Send email to POC
|
||||||
|
$sent = wp_mail($poc_email, $subject, $body, $headers);
|
||||||
|
|
||||||
|
if (!$sent) {
|
||||||
|
// Log failure
|
||||||
|
if (class_exists('HVAC_Logger')) {
|
||||||
|
HVAC_Logger::error('Failed to send venue contact email', 'AJAX', [
|
||||||
|
'venue_id' => $venue_id,
|
||||||
|
'poc_email' => $poc_email,
|
||||||
|
'sender_email' => $email
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
wp_send_json_error(['message' => 'Failed to send message. Please try again.'], 500);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update rate limit
|
||||||
|
set_transient($rate_key, $submissions + 1, HOUR_IN_SECONDS);
|
||||||
|
|
||||||
|
// Log success
|
||||||
|
if (class_exists('HVAC_Logger')) {
|
||||||
|
HVAC_Logger::info('Venue contact form submitted', 'AJAX', [
|
||||||
|
'venue_id' => $venue_id,
|
||||||
|
'venue_name' => $venue_name,
|
||||||
|
'poc_email' => $poc_email,
|
||||||
|
'sender_email' => $email,
|
||||||
|
'has_message' => !empty($message)
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store lead if training leads system exists
|
||||||
|
if (class_exists('HVAC_Training_Leads')) {
|
||||||
|
$leads = HVAC_Training_Leads::instance();
|
||||||
|
if (method_exists($leads, 'create_lead')) {
|
||||||
|
$leads->create_lead([
|
||||||
|
'first_name' => $first_name,
|
||||||
|
'last_name' => $last_name,
|
||||||
|
'email' => $email,
|
||||||
|
'phone' => $phone,
|
||||||
|
'company' => $company,
|
||||||
|
'message' => $message,
|
||||||
|
'venue_id' => $venue_id,
|
||||||
|
'venue_name' => $venue_name,
|
||||||
|
'source' => 'find_training_venue_contact'
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
wp_send_json_success([
|
||||||
|
'message' => 'Your message has been sent to the training lab.',
|
||||||
|
'venue_name' => $venue_name
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get client IP address safely
|
||||||
|
*
|
||||||
|
* @return string IP address
|
||||||
|
*/
|
||||||
|
private function get_client_ip(): string {
|
||||||
|
// Use REMOTE_ADDR only to prevent IP spoofing
|
||||||
|
return sanitize_text_field($_SERVER['REMOTE_ADDR'] ?? '127.0.0.1');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize the handlers
|
// Initialize the handlers
|
||||||
|
|
|
||||||
|
|
@ -81,11 +81,17 @@ class HVAC_Ajax_Security {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Send security headers
|
* Send security headers
|
||||||
|
*
|
||||||
|
* SECURITY FIX (U3): Fixed condition to apply headers to AJAX requests.
|
||||||
|
* is_admin() returns true for admin-ajax.php, so previous condition never matched.
|
||||||
|
* Also removed 'unsafe-eval' from CSP (M1) - rarely needed and weakens security.
|
||||||
*/
|
*/
|
||||||
public function send_security_headers() {
|
public function send_security_headers() {
|
||||||
if (!is_admin() && wp_doing_ajax()) {
|
// Apply to AJAX requests (wp_doing_ajax covers admin-ajax.php)
|
||||||
// Content Security Policy
|
// Skip if headers already sent
|
||||||
header("Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline';");
|
if (wp_doing_ajax() && !headers_sent()) {
|
||||||
|
// Content Security Policy - removed unsafe-eval (M1 fix)
|
||||||
|
header("Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline';");
|
||||||
|
|
||||||
// Additional security headers
|
// Additional security headers
|
||||||
header('X-Content-Type-Options: nosniff');
|
header('X-Content-Type-Options: nosniff');
|
||||||
|
|
@ -478,39 +484,60 @@ class HVAC_Ajax_Security {
|
||||||
*
|
*
|
||||||
* @param string $action Action identifier
|
* @param string $action Action identifier
|
||||||
* @param int $user_id User ID
|
* @param int $user_id User ID
|
||||||
* @return string Secure token
|
* @return string Secure token with embedded timestamp (format: timestamp.signature)
|
||||||
*/
|
*/
|
||||||
public static function generate_secure_token($action, $user_id) {
|
public static function generate_secure_token($action, $user_id) {
|
||||||
$salt = wp_salt('auth');
|
$salt = wp_salt('auth');
|
||||||
$data = $action . '|' . $user_id . '|' . time();
|
$timestamp = time();
|
||||||
return hash_hmac('sha256', $data, $salt);
|
$data = $action . '|' . $user_id . '|' . $timestamp;
|
||||||
|
$signature = hash_hmac('sha256', $data, $salt);
|
||||||
|
|
||||||
|
// Return timestamp.signature format for O(1) verification
|
||||||
|
return $timestamp . '.' . $signature;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Verify secure token
|
* Verify secure token - O(1) complexity
|
||||||
*
|
*
|
||||||
* @param string $token Token to verify
|
* SECURITY FIX (U1): Rewritten to O(1) verification instead of O(expiry) loop.
|
||||||
|
* Previous implementation looped up to 3600 times computing HMACs, creating
|
||||||
|
* a DoS vulnerability. Now extracts timestamp from token for single HMAC check.
|
||||||
|
*
|
||||||
|
* @param string $token Token to verify (format: timestamp.signature)
|
||||||
* @param string $action Action identifier
|
* @param string $action Action identifier
|
||||||
* @param int $user_id User ID
|
* @param int $user_id User ID
|
||||||
* @param int $expiry Token expiry in seconds (default 1 hour)
|
* @param int $expiry Token expiry in seconds (default 1 hour)
|
||||||
* @return bool
|
* @return bool
|
||||||
*/
|
*/
|
||||||
public static function verify_secure_token($token, $action, $user_id, $expiry = 3600) {
|
public static function verify_secure_token($token, $action, $user_id, $expiry = 3600) {
|
||||||
$salt = wp_salt('auth');
|
// Parse token format: timestamp.signature
|
||||||
|
$parts = explode('.', $token, 2);
|
||||||
// Generate tokens for last $expiry seconds
|
if (count($parts) !== 2) {
|
||||||
$current_time = time();
|
|
||||||
for ($i = 0; $i <= $expiry; $i++) {
|
|
||||||
$data = $action . '|' . $user_id . '|' . ($current_time - $i);
|
|
||||||
$expected_token = hash_hmac('sha256', $data, $salt);
|
|
||||||
|
|
||||||
if (hash_equals($expected_token, $token)) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
list($timestamp, $signature) = $parts;
|
||||||
|
$timestamp = (int) $timestamp;
|
||||||
|
|
||||||
|
// Validate timestamp exists and signature is not empty
|
||||||
|
if (!$timestamp || empty($signature)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if token has expired
|
||||||
|
$current_time = time();
|
||||||
|
if (($current_time - $timestamp) > $expiry) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single HMAC computation for verification
|
||||||
|
$salt = wp_salt('auth');
|
||||||
|
$data = $action . '|' . $user_id . '|' . $timestamp;
|
||||||
|
$expected_signature = hash_hmac('sha256', $data, $salt);
|
||||||
|
|
||||||
|
// Timing-safe comparison
|
||||||
|
return hash_equals($expected_signature, $signature);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize the security handler
|
// Initialize the security handler
|
||||||
|
|
|
||||||
|
|
@ -31,9 +31,10 @@ class HVAC_Page_Manager {
|
||||||
'parent' => null,
|
'parent' => null,
|
||||||
'capability' => null
|
'capability' => null
|
||||||
],
|
],
|
||||||
'find-a-trainer' => [
|
// Note: find-a-trainer removed - redirects to find-training (see HVAC_Route_Manager)
|
||||||
'title' => 'Find a Trainer',
|
'find-training' => [
|
||||||
'template' => 'page-find-trainer.php',
|
'title' => 'Find Training',
|
||||||
|
'template' => 'page-find-training.php',
|
||||||
'public' => true,
|
'public' => true,
|
||||||
'parent' => null,
|
'parent' => null,
|
||||||
'capability' => null
|
'capability' => null
|
||||||
|
|
|
||||||
|
|
@ -112,10 +112,10 @@ final class HVAC_Plugin {
|
||||||
*/
|
*/
|
||||||
private function defineConstants(): void {
|
private function defineConstants(): void {
|
||||||
if (!defined('HVAC_PLUGIN_VERSION')) {
|
if (!defined('HVAC_PLUGIN_VERSION')) {
|
||||||
define('HVAC_PLUGIN_VERSION', '2.0.0');
|
define('HVAC_PLUGIN_VERSION', '2.2.11');
|
||||||
}
|
}
|
||||||
if (!defined('HVAC_VERSION')) {
|
if (!defined('HVAC_VERSION')) {
|
||||||
define('HVAC_VERSION', '2.1.7');
|
define('HVAC_VERSION', '2.2.11');
|
||||||
}
|
}
|
||||||
if (!defined('HVAC_PLUGIN_FILE')) {
|
if (!defined('HVAC_PLUGIN_FILE')) {
|
||||||
define('HVAC_PLUGIN_FILE', dirname(__DIR__) . '/hvac-community-events.php');
|
define('HVAC_PLUGIN_FILE', dirname(__DIR__) . '/hvac-community-events.php');
|
||||||
|
|
@ -176,6 +176,9 @@ final class HVAC_Plugin {
|
||||||
require_once HVAC_PLUGIN_DIR . 'includes/class-hvac-browser-detection.php';
|
require_once HVAC_PLUGIN_DIR . 'includes/class-hvac-browser-detection.php';
|
||||||
require_once HVAC_PLUGIN_DIR . 'includes/class-hvac-find-trainer-assets.php';
|
require_once HVAC_PLUGIN_DIR . 'includes/class-hvac-find-trainer-assets.php';
|
||||||
|
|
||||||
|
// reCAPTCHA integration for contact forms
|
||||||
|
require_once HVAC_PLUGIN_DIR . 'includes/class-hvac-recaptcha.php';
|
||||||
|
|
||||||
require_once HVAC_PLUGIN_DIR . 'includes/class-hvac-safari-debugger.php';
|
require_once HVAC_PLUGIN_DIR . 'includes/class-hvac-safari-debugger.php';
|
||||||
require_once HVAC_PLUGIN_DIR . 'includes/class-hvac-shortcodes.php';
|
require_once HVAC_PLUGIN_DIR . 'includes/class-hvac-shortcodes.php';
|
||||||
// DISABLED - Using TEC Community Events 5.x instead
|
// DISABLED - Using TEC Community Events 5.x instead
|
||||||
|
|
@ -264,6 +267,16 @@ 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',
|
||||||
|
];
|
||||||
|
|
||||||
|
// Venue Categories (taxonomies for training labs)
|
||||||
|
require_once HVAC_PLUGIN_DIR . 'includes/class-hvac-venue-categories.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 +291,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',
|
||||||
|
|
@ -579,6 +599,11 @@ final class HVAC_Plugin {
|
||||||
HVAC_Venues::instance();
|
HVAC_Venues::instance();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Initialize venue categories (taxonomies for training labs)
|
||||||
|
if (class_exists('HVAC_Venue_Categories')) {
|
||||||
|
HVAC_Venue_Categories::instance();
|
||||||
|
}
|
||||||
|
|
||||||
// Initialize trainer profile manager
|
// Initialize trainer profile manager
|
||||||
if (class_exists('HVAC_Trainer_Profile_Manager')) {
|
if (class_exists('HVAC_Trainer_Profile_Manager')) {
|
||||||
HVAC_Trainer_Profile_Manager::get_instance();
|
HVAC_Trainer_Profile_Manager::get_instance();
|
||||||
|
|
@ -877,12 +902,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,22 +922,31 @@ final class HVAC_Plugin {
|
||||||
HVAC_Trainer_Directory_Query::get_instance();
|
HVAC_Trainer_Directory_Query::get_instance();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize master trainer manager components
|
// Initialize Find Training page (new Google Maps-based)
|
||||||
if (class_exists('HVAC_Master_Trainers_Overview')) {
|
if (class_exists('HVAC_Find_Training_Page')) {
|
||||||
HVAC_Master_Trainers_Overview::instance();
|
HVAC_Find_Training_Page::get_instance();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (class_exists('HVAC_Announcements_Manager')) {
|
// Initialize Training Map Data provider
|
||||||
HVAC_Announcements_Manager::get_instance();
|
if (class_exists('HVAC_Training_Map_Data')) {
|
||||||
|
HVAC_Training_Map_Data::get_instance();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (class_exists('HVAC_Master_Pending_Approvals')) {
|
// Initialize Venue Geocoding service
|
||||||
HVAC_Master_Pending_Approvals::instance();
|
if (class_exists('HVAC_Venue_Geocoding')) {
|
||||||
|
HVAC_Venue_Geocoding::get_instance();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (class_exists('HVAC_Master_Events_Overview')) {
|
// ARCHITECTURE FIX (C5): Master Trainer components are already initialized
|
||||||
HVAC_Master_Events_Overview::instance();
|
// in initializeSecondaryComponents() at priority 5. Removed duplicate
|
||||||
}
|
// initialization here (priority 20) to prevent confusion and potential
|
||||||
|
// double hook registration issues.
|
||||||
|
//
|
||||||
|
// Components initialized in initializeSecondaryComponents():
|
||||||
|
// - HVAC_Master_Events_Overview
|
||||||
|
// - HVAC_Master_Pending_Approvals
|
||||||
|
// - HVAC_Master_Trainers_Overview
|
||||||
|
// - HVAC_Announcements_Display / HVAC_Announcements_Admin
|
||||||
|
|
||||||
// Fix master trainer pages if needed
|
// Fix master trainer pages if needed
|
||||||
if (class_exists('HVAC_Master_Pages_Fixer')) {
|
if (class_exists('HVAC_Master_Pages_Fixer')) {
|
||||||
|
|
@ -1048,8 +1082,14 @@ final class HVAC_Plugin {
|
||||||
// If we're on the trainer registration page, don't apply any authentication checks
|
// If we're on the trainer registration page, don't apply any authentication checks
|
||||||
$current_path = trim(parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH), '/');
|
$current_path = trim(parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH), '/');
|
||||||
if ($current_path === 'trainer/registration' || is_page('registration') || is_page('trainer-registration')) {
|
if ($current_path === 'trainer/registration' || is_page('registration') || is_page('trainer-registration')) {
|
||||||
// Remove any potential authentication hooks that might be added by other code
|
// SECURITY FIX (U2): Remove only HVAC-specific auth redirects, not ALL hooks
|
||||||
remove_all_actions('template_redirect', 10);
|
// Previous code used remove_all_actions() which broke WordPress core,
|
||||||
|
// theme, and other plugin functionality at priority 10
|
||||||
|
remove_action('template_redirect', array($this, 'restrict_trainer_pages'), 10);
|
||||||
|
remove_action('template_redirect', array($this, 'check_trainer_access'), 10);
|
||||||
|
|
||||||
|
// If other HVAC auth hooks are added in the future, remove them here specifically
|
||||||
|
// DO NOT use remove_all_actions() as it breaks WordPress isolation
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -189,11 +189,23 @@ class HVAC_Registration {
|
||||||
* @param string $redirect_url The URL to redirect back to.
|
* @param string $redirect_url The URL to redirect back to.
|
||||||
*/
|
*/
|
||||||
private function redirect_with_errors($errors, $data, $redirect_url) {
|
private function redirect_with_errors($errors, $data, $redirect_url) {
|
||||||
$transient_id = uniqid(); // Generate unique ID for transient key
|
$transient_id = bin2hex(random_bytes(16)); // Cryptographically secure token
|
||||||
$transient_key = self::TRANSIENT_PREFIX . $transient_id;
|
$transient_key = self::TRANSIENT_PREFIX . $transient_id;
|
||||||
|
|
||||||
|
// SECURITY FIX (C1): Strip password fields before storing in transient
|
||||||
|
// Passwords should never be persisted to database, even temporarily
|
||||||
|
$safe_data = $data;
|
||||||
|
unset(
|
||||||
|
$safe_data['user_pass'],
|
||||||
|
$safe_data['confirm_password'],
|
||||||
|
$safe_data['current_password'],
|
||||||
|
$safe_data['new_password'],
|
||||||
|
$safe_data['hvac_registration_nonce']
|
||||||
|
);
|
||||||
|
|
||||||
$transient_data = [
|
$transient_data = [
|
||||||
'errors' => $errors,
|
'errors' => $errors,
|
||||||
'data' => $data, // Store submitted data to repopulate form
|
'data' => $safe_data, // Store submitted data to repopulate form (sans passwords)
|
||||||
];
|
];
|
||||||
// Store for 5 minutes
|
// Store for 5 minutes
|
||||||
set_transient($transient_key, $transient_data, MINUTE_IN_SECONDS * 5);
|
set_transient($transient_key, $transient_data, MINUTE_IN_SECONDS * 5);
|
||||||
|
|
|
||||||
|
|
@ -81,7 +81,8 @@ class HVAC_Route_Manager {
|
||||||
'communication-templates' => 'trainer/communication-templates',
|
'communication-templates' => 'trainer/communication-templates',
|
||||||
'communication-schedules' => 'trainer/communication-schedules',
|
'communication-schedules' => 'trainer/communication-schedules',
|
||||||
'trainer-registration' => 'trainer/registration',
|
'trainer-registration' => 'trainer/registration',
|
||||||
'find-trainer' => 'find-a-trainer', // Fix E2E testing URL mismatch
|
'find-trainer' => 'find-training', // Legacy URL redirect
|
||||||
|
'find-a-trainer' => 'find-training', // Old page redirect to new Google Maps page
|
||||||
);
|
);
|
||||||
|
|
||||||
// Parent pages that redirect to dashboards
|
// Parent pages that redirect to dashboards
|
||||||
|
|
|
||||||
|
|
@ -25,9 +25,24 @@ class HVAC_Secure_Storage {
|
||||||
/**
|
/**
|
||||||
* Get encryption key
|
* Get encryption key
|
||||||
*
|
*
|
||||||
|
* SECURITY FIX (C2): Prefer wp-config.php constant over database storage.
|
||||||
|
* Storing encryption key in the same database as encrypted data is "key under doormat".
|
||||||
|
* Define HVAC_ENCRYPTION_KEY in wp-config.php for proper key separation.
|
||||||
|
*
|
||||||
* @return string
|
* @return string
|
||||||
*/
|
*/
|
||||||
private static function get_encryption_key() {
|
private static function get_encryption_key() {
|
||||||
|
// Prefer wp-config.php constant (recommended for production)
|
||||||
|
if (defined('HVAC_ENCRYPTION_KEY') && HVAC_ENCRYPTION_KEY) {
|
||||||
|
return base64_decode(HVAC_ENCRYPTION_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to database storage (legacy/development)
|
||||||
|
// Log warning in debug mode to encourage migration
|
||||||
|
if (WP_DEBUG) {
|
||||||
|
error_log('HVAC Security Warning: HVAC_ENCRYPTION_KEY not defined in wp-config.php. Using database-stored key (less secure).');
|
||||||
|
}
|
||||||
|
|
||||||
$key = get_option('hvac_encryption_key');
|
$key = get_option('hvac_encryption_key');
|
||||||
|
|
||||||
if (!$key) {
|
if (!$key) {
|
||||||
|
|
|
||||||
|
|
@ -181,17 +181,41 @@ class HVAC_Security {
|
||||||
/**
|
/**
|
||||||
* Get user IP address
|
* Get user IP address
|
||||||
*
|
*
|
||||||
|
* SECURITY FIX (C3): Only trust proxy headers when behind a known trusted proxy.
|
||||||
|
* Previous implementation trusted user-controllable headers unconditionally,
|
||||||
|
* allowing attackers to spoof IPs and bypass rate limiting.
|
||||||
|
*
|
||||||
|
* For most deployments, use REMOTE_ADDR directly. Only trust X-Forwarded-For
|
||||||
|
* when behind Cloudflare, AWS ALB, or other known reverse proxies.
|
||||||
|
*
|
||||||
* @return string
|
* @return string
|
||||||
*/
|
*/
|
||||||
public static function get_user_ip() {
|
public static function get_user_ip() {
|
||||||
$ip = '';
|
// Primary: Use REMOTE_ADDR (cannot be spoofed at network level)
|
||||||
|
$ip = isset($_SERVER['REMOTE_ADDR']) ? $_SERVER['REMOTE_ADDR'] : '';
|
||||||
|
|
||||||
if ( ! empty( $_SERVER['HTTP_CLIENT_IP'] ) ) {
|
// Only trust proxy headers if HVAC_TRUSTED_PROXIES is defined
|
||||||
$ip = $_SERVER['HTTP_CLIENT_IP'];
|
// Define as comma-separated list of trusted proxy IPs in wp-config.php
|
||||||
} elseif ( ! empty( $_SERVER['HTTP_X_FORWARDED_FOR'] ) ) {
|
// Example: define('HVAC_TRUSTED_PROXIES', '10.0.0.1,10.0.0.2');
|
||||||
$ip = $_SERVER['HTTP_X_FORWARDED_FOR'];
|
if (defined('HVAC_TRUSTED_PROXIES') && HVAC_TRUSTED_PROXIES) {
|
||||||
} elseif ( ! empty( $_SERVER['REMOTE_ADDR'] ) ) {
|
$trusted_proxies = array_map('trim', explode(',', HVAC_TRUSTED_PROXIES));
|
||||||
$ip = $_SERVER['REMOTE_ADDR'];
|
|
||||||
|
if (in_array($ip, $trusted_proxies, true)) {
|
||||||
|
// Behind trusted proxy - check forwarded headers
|
||||||
|
if (!empty($_SERVER['HTTP_X_FORWARDED_FOR'])) {
|
||||||
|
// Take first IP (original client) from comma-separated list
|
||||||
|
$forwarded = explode(',', $_SERVER['HTTP_X_FORWARDED_FOR']);
|
||||||
|
$client_ip = trim($forwarded[0]);
|
||||||
|
if (filter_var($client_ip, FILTER_VALIDATE_IP)) {
|
||||||
|
$ip = $client_ip;
|
||||||
|
}
|
||||||
|
} elseif (!empty($_SERVER['HTTP_CLIENT_IP'])) {
|
||||||
|
$client_ip = $_SERVER['HTTP_CLIENT_IP'];
|
||||||
|
if (filter_var($client_ip, FILTER_VALIDATE_IP)) {
|
||||||
|
$ip = $client_ip;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return sanitize_text_field($ip);
|
return sanitize_text_field($ip);
|
||||||
|
|
|
||||||
|
|
@ -1232,5 +1232,9 @@ class HVAC_Trainer_Profile_Manager {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize the manager
|
// ARCHITECTURE FIX (U9): Removed file-scope side-effect initialization.
|
||||||
HVAC_Trainer_Profile_Manager::get_instance();
|
// Component initialization should be controlled by HVAC_Plugin orchestrator,
|
||||||
|
// not triggered automatically at file-include time. HVAC_Plugin already calls
|
||||||
|
// get_instance() at line 584 during initializeSecondaryComponents().
|
||||||
|
//
|
||||||
|
// Previous code: HVAC_Trainer_Profile_Manager::get_instance();
|
||||||
|
|
|
||||||
282
includes/class-hvac-venue-categories.php
Normal file
282
includes/class-hvac-venue-categories.php
Normal file
|
|
@ -0,0 +1,282 @@
|
||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Venue Categories - Taxonomy Registration for Training Labs
|
||||||
|
*
|
||||||
|
* Registers custom taxonomies for TEC venues:
|
||||||
|
* - venue_type: Training lab types (e.g., "measureQuick Approved Training Lab")
|
||||||
|
* - venue_equipment: Equipment available at venues
|
||||||
|
* - venue_amenities: Amenities available at venues
|
||||||
|
*
|
||||||
|
* @package HVAC_Community_Events
|
||||||
|
* @since 2.3.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
if (!defined('ABSPATH')) {
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class HVAC_Venue_Categories
|
||||||
|
*
|
||||||
|
* Manages venue taxonomies for categorizing training labs.
|
||||||
|
*/
|
||||||
|
class HVAC_Venue_Categories {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Singleton instance
|
||||||
|
*
|
||||||
|
* @var HVAC_Venue_Categories|null
|
||||||
|
*/
|
||||||
|
private static ?self $instance = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get singleton instance
|
||||||
|
*
|
||||||
|
* @return HVAC_Venue_Categories
|
||||||
|
*/
|
||||||
|
public static function instance(): self {
|
||||||
|
if (self::$instance === null) {
|
||||||
|
self::$instance = new self();
|
||||||
|
}
|
||||||
|
return self::$instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructor
|
||||||
|
*/
|
||||||
|
private function __construct() {
|
||||||
|
add_action('init', [$this, 'register_taxonomies'], 5);
|
||||||
|
add_action('init', [$this, 'create_default_terms'], 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register venue taxonomies
|
||||||
|
*/
|
||||||
|
public function register_taxonomies(): void {
|
||||||
|
// Venue Type taxonomy (e.g., "measureQuick Approved Training Lab")
|
||||||
|
register_taxonomy('venue_type', 'tribe_venue', [
|
||||||
|
'labels' => [
|
||||||
|
'name' => __('Venue Types', 'hvac-community-events'),
|
||||||
|
'singular_name' => __('Venue Type', 'hvac-community-events'),
|
||||||
|
'search_items' => __('Search Venue Types', 'hvac-community-events'),
|
||||||
|
'all_items' => __('All Venue Types', 'hvac-community-events'),
|
||||||
|
'parent_item' => __('Parent Venue Type', 'hvac-community-events'),
|
||||||
|
'parent_item_colon' => __('Parent Venue Type:', 'hvac-community-events'),
|
||||||
|
'edit_item' => __('Edit Venue Type', 'hvac-community-events'),
|
||||||
|
'update_item' => __('Update Venue Type', 'hvac-community-events'),
|
||||||
|
'add_new_item' => __('Add New Venue Type', 'hvac-community-events'),
|
||||||
|
'new_item_name' => __('New Venue Type Name', 'hvac-community-events'),
|
||||||
|
'menu_name' => __('Venue Types', 'hvac-community-events'),
|
||||||
|
],
|
||||||
|
'hierarchical' => true,
|
||||||
|
'public' => true,
|
||||||
|
'show_ui' => true,
|
||||||
|
'show_in_menu' => true,
|
||||||
|
'show_admin_column' => true,
|
||||||
|
'show_in_rest' => true,
|
||||||
|
'query_var' => true,
|
||||||
|
'rewrite' => ['slug' => 'venue-type'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Venue Equipment taxonomy
|
||||||
|
register_taxonomy('venue_equipment', 'tribe_venue', [
|
||||||
|
'labels' => [
|
||||||
|
'name' => __('Equipment', 'hvac-community-events'),
|
||||||
|
'singular_name' => __('Equipment', 'hvac-community-events'),
|
||||||
|
'search_items' => __('Search Equipment', 'hvac-community-events'),
|
||||||
|
'all_items' => __('All Equipment', 'hvac-community-events'),
|
||||||
|
'parent_item' => __('Parent Equipment', 'hvac-community-events'),
|
||||||
|
'parent_item_colon' => __('Parent Equipment:', 'hvac-community-events'),
|
||||||
|
'edit_item' => __('Edit Equipment', 'hvac-community-events'),
|
||||||
|
'update_item' => __('Update Equipment', 'hvac-community-events'),
|
||||||
|
'add_new_item' => __('Add New Equipment', 'hvac-community-events'),
|
||||||
|
'new_item_name' => __('New Equipment Name', 'hvac-community-events'),
|
||||||
|
'menu_name' => __('Equipment', 'hvac-community-events'),
|
||||||
|
],
|
||||||
|
'hierarchical' => true,
|
||||||
|
'public' => true,
|
||||||
|
'show_ui' => true,
|
||||||
|
'show_in_menu' => true,
|
||||||
|
'show_admin_column' => true,
|
||||||
|
'show_in_rest' => true,
|
||||||
|
'query_var' => true,
|
||||||
|
'rewrite' => ['slug' => 'venue-equipment'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Venue Amenities taxonomy
|
||||||
|
register_taxonomy('venue_amenities', 'tribe_venue', [
|
||||||
|
'labels' => [
|
||||||
|
'name' => __('Amenities', 'hvac-community-events'),
|
||||||
|
'singular_name' => __('Amenity', 'hvac-community-events'),
|
||||||
|
'search_items' => __('Search Amenities', 'hvac-community-events'),
|
||||||
|
'all_items' => __('All Amenities', 'hvac-community-events'),
|
||||||
|
'parent_item' => __('Parent Amenity', 'hvac-community-events'),
|
||||||
|
'parent_item_colon' => __('Parent Amenity:', 'hvac-community-events'),
|
||||||
|
'edit_item' => __('Edit Amenity', 'hvac-community-events'),
|
||||||
|
'update_item' => __('Update Amenity', 'hvac-community-events'),
|
||||||
|
'add_new_item' => __('Add New Amenity', 'hvac-community-events'),
|
||||||
|
'new_item_name' => __('New Amenity Name', 'hvac-community-events'),
|
||||||
|
'menu_name' => __('Amenities', 'hvac-community-events'),
|
||||||
|
],
|
||||||
|
'hierarchical' => true,
|
||||||
|
'public' => true,
|
||||||
|
'show_ui' => true,
|
||||||
|
'show_in_menu' => true,
|
||||||
|
'show_admin_column' => true,
|
||||||
|
'show_in_rest' => true,
|
||||||
|
'query_var' => true,
|
||||||
|
'rewrite' => ['slug' => 'venue-amenities'],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create default taxonomy terms
|
||||||
|
*/
|
||||||
|
public function create_default_terms(): void {
|
||||||
|
// Only run once
|
||||||
|
if (get_option('hvac_venue_categories_initialized')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Venue Types
|
||||||
|
$venue_types = [
|
||||||
|
'measureQuick Approved Training Lab' => 'mq-approved-lab',
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($venue_types as $name => $slug) {
|
||||||
|
if (!term_exists($slug, 'venue_type')) {
|
||||||
|
wp_insert_term($name, 'venue_type', ['slug' => $slug]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Equipment
|
||||||
|
$equipment = [
|
||||||
|
'Furnace',
|
||||||
|
'Heat Pump',
|
||||||
|
'Air Conditioner',
|
||||||
|
'Mini-Split',
|
||||||
|
'Boiler',
|
||||||
|
'Gas Meter',
|
||||||
|
'TrueFlow Grid',
|
||||||
|
'Flow Hood',
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($equipment as $name) {
|
||||||
|
$slug = sanitize_title($name);
|
||||||
|
if (!term_exists($slug, 'venue_equipment')) {
|
||||||
|
wp_insert_term($name, 'venue_equipment', ['slug' => $slug]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Amenities
|
||||||
|
$amenities = [
|
||||||
|
'Coffee',
|
||||||
|
'Water',
|
||||||
|
'Tea',
|
||||||
|
'Soda',
|
||||||
|
'Snacks',
|
||||||
|
'Projector',
|
||||||
|
'Whiteboard',
|
||||||
|
'WiFi',
|
||||||
|
'Parking',
|
||||||
|
'Vending Machines',
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($amenities as $name) {
|
||||||
|
$slug = sanitize_title($name);
|
||||||
|
if (!term_exists($slug, 'venue_amenities')) {
|
||||||
|
wp_insert_term($name, 'venue_amenities', ['slug' => $slug]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
update_option('hvac_venue_categories_initialized', true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get venues by type
|
||||||
|
*
|
||||||
|
* @param string $type_slug Type slug (e.g., 'mq-approved-lab')
|
||||||
|
* @return array Venue IDs
|
||||||
|
*/
|
||||||
|
public function get_venues_by_type(string $type_slug): array {
|
||||||
|
$query = new WP_Query([
|
||||||
|
'post_type' => 'tribe_venue',
|
||||||
|
'posts_per_page' => -1,
|
||||||
|
'post_status' => 'publish',
|
||||||
|
'fields' => 'ids',
|
||||||
|
'tax_query' => [
|
||||||
|
[
|
||||||
|
'taxonomy' => 'venue_type',
|
||||||
|
'field' => 'slug',
|
||||||
|
'terms' => $type_slug,
|
||||||
|
],
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
return $query->posts;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if venue is an approved training lab
|
||||||
|
*
|
||||||
|
* @param int $venue_id Venue post ID
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public function is_approved_training_lab(int $venue_id): bool {
|
||||||
|
return has_term('mq-approved-lab', 'venue_type', $venue_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get venue equipment
|
||||||
|
*
|
||||||
|
* @param int $venue_id Venue post ID
|
||||||
|
* @return array Equipment names
|
||||||
|
*/
|
||||||
|
public function get_venue_equipment(int $venue_id): array {
|
||||||
|
$terms = wp_get_post_terms($venue_id, 'venue_equipment', ['fields' => 'names']);
|
||||||
|
return is_wp_error($terms) ? [] : $terms;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get venue amenities
|
||||||
|
*
|
||||||
|
* @param int $venue_id Venue post ID
|
||||||
|
* @return array Amenity names
|
||||||
|
*/
|
||||||
|
public function get_venue_amenities(int $venue_id): array {
|
||||||
|
$terms = wp_get_post_terms($venue_id, 'venue_amenities', ['fields' => 'names']);
|
||||||
|
return is_wp_error($terms) ? [] : $terms;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set venue as approved training lab
|
||||||
|
*
|
||||||
|
* @param int $venue_id Venue post ID
|
||||||
|
* @return bool|WP_Error
|
||||||
|
*/
|
||||||
|
public function set_as_approved_lab(int $venue_id) {
|
||||||
|
return wp_set_post_terms($venue_id, ['mq-approved-lab'], 'venue_type', false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set venue equipment
|
||||||
|
*
|
||||||
|
* @param int $venue_id Venue post ID
|
||||||
|
* @param array $equipment Equipment slugs or names
|
||||||
|
* @return bool|WP_Error
|
||||||
|
*/
|
||||||
|
public function set_venue_equipment(int $venue_id, array $equipment) {
|
||||||
|
return wp_set_post_terms($venue_id, $equipment, 'venue_equipment', false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set venue amenities
|
||||||
|
*
|
||||||
|
* @param int $venue_id Venue post ID
|
||||||
|
* @param array $amenities Amenity slugs or names
|
||||||
|
* @return bool|WP_Error
|
||||||
|
*/
|
||||||
|
public function set_venue_amenities(int $venue_id, array $amenities) {
|
||||||
|
return wp_set_post_terms($venue_id, $amenities, 'venue_amenities', false);
|
||||||
|
}
|
||||||
|
}
|
||||||
517
includes/find-training/class-hvac-find-training-page.php
Normal file
517
includes/find-training/class-hvac-find-training-page.php
Normal file
|
|
@ -0,0 +1,517 @@
|
||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Find Training Page Handler
|
||||||
|
*
|
||||||
|
* Manages the Find Training page with Google Maps integration
|
||||||
|
* showing trainers and training venues on an interactive map.
|
||||||
|
*
|
||||||
|
* @package HVAC_Community_Events
|
||||||
|
* @since 2.2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
if (!defined('ABSPATH')) {
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class HVAC_Find_Training_Page
|
||||||
|
*
|
||||||
|
* Main controller for the Find Training page functionality.
|
||||||
|
* Uses singleton pattern consistent with other HVAC plugin classes.
|
||||||
|
*/
|
||||||
|
class HVAC_Find_Training_Page {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Singleton instance
|
||||||
|
*
|
||||||
|
* @var HVAC_Find_Training_Page|null
|
||||||
|
*/
|
||||||
|
private static ?self $instance = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Page slug
|
||||||
|
*
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
private string $page_slug = 'find-training';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Google Maps API key
|
||||||
|
*
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
private string $api_key = '';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get singleton instance
|
||||||
|
*
|
||||||
|
* @return HVAC_Find_Training_Page
|
||||||
|
*/
|
||||||
|
public static function get_instance(): self {
|
||||||
|
if (self::$instance === null) {
|
||||||
|
self::$instance = new self();
|
||||||
|
}
|
||||||
|
return self::$instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructor
|
||||||
|
*/
|
||||||
|
private function __construct() {
|
||||||
|
$this->load_api_key();
|
||||||
|
$this->init_hooks();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load Google Maps API key from secure storage
|
||||||
|
*/
|
||||||
|
private function load_api_key(): void {
|
||||||
|
if (class_exists('HVAC_Secure_Storage')) {
|
||||||
|
$this->api_key = HVAC_Secure_Storage::get_credential('hvac_google_maps_api_key', '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize WordPress hooks
|
||||||
|
*/
|
||||||
|
private function init_hooks(): void {
|
||||||
|
// Page registration
|
||||||
|
add_action('init', [$this, 'register_page'], 15);
|
||||||
|
|
||||||
|
// Asset enqueuing
|
||||||
|
add_action('wp_enqueue_scripts', [$this, 'enqueue_assets']);
|
||||||
|
|
||||||
|
// Body classes
|
||||||
|
add_filter('body_class', [$this, 'add_body_classes']);
|
||||||
|
|
||||||
|
// AJAX handlers
|
||||||
|
add_action('wp_ajax_hvac_get_training_map_data', [$this, 'ajax_get_map_data']);
|
||||||
|
add_action('wp_ajax_nopriv_hvac_get_training_map_data', [$this, 'ajax_get_map_data']);
|
||||||
|
|
||||||
|
add_action('wp_ajax_hvac_filter_training_map', [$this, 'ajax_filter_map']);
|
||||||
|
add_action('wp_ajax_nopriv_hvac_filter_training_map', [$this, 'ajax_filter_map']);
|
||||||
|
|
||||||
|
add_action('wp_ajax_hvac_get_trainer_profile_modal', [$this, 'ajax_get_trainer_profile']);
|
||||||
|
add_action('wp_ajax_nopriv_hvac_get_trainer_profile_modal', [$this, 'ajax_get_trainer_profile']);
|
||||||
|
|
||||||
|
add_action('wp_ajax_hvac_get_venue_info', [$this, 'ajax_get_venue_info']);
|
||||||
|
add_action('wp_ajax_nopriv_hvac_get_venue_info', [$this, 'ajax_get_venue_info']);
|
||||||
|
|
||||||
|
// Redirect from old page
|
||||||
|
add_action('template_redirect', [$this, 'maybe_redirect_from_old_page']);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register the Find Training page
|
||||||
|
*/
|
||||||
|
public function register_page(): void {
|
||||||
|
$page = get_page_by_path($this->page_slug);
|
||||||
|
|
||||||
|
if (!$page) {
|
||||||
|
$this->create_page();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create the Find Training page in WordPress
|
||||||
|
*/
|
||||||
|
private function create_page(): void {
|
||||||
|
$page_data = [
|
||||||
|
'post_title' => 'Find Training',
|
||||||
|
'post_name' => $this->page_slug,
|
||||||
|
'post_content' => '<!-- This page uses a custom template -->',
|
||||||
|
'post_status' => 'publish',
|
||||||
|
'post_type' => 'page',
|
||||||
|
'post_author' => 1,
|
||||||
|
'comment_status' => 'closed',
|
||||||
|
'ping_status' => 'closed',
|
||||||
|
'meta_input' => [
|
||||||
|
'_wp_page_template' => 'page-find-training.php',
|
||||||
|
'ast-site-content-layout' => 'page-builder',
|
||||||
|
'site-post-title' => 'disabled',
|
||||||
|
'site-sidebar-layout' => 'no-sidebar',
|
||||||
|
'theme-transparent-header-meta' => 'disabled'
|
||||||
|
]
|
||||||
|
];
|
||||||
|
|
||||||
|
$page_id = wp_insert_post($page_data);
|
||||||
|
|
||||||
|
if ($page_id && !is_wp_error($page_id)) {
|
||||||
|
update_option('hvac_find_training_page_id', $page_id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if current page is the Find Training page
|
||||||
|
*
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public function is_find_training_page(): bool {
|
||||||
|
return is_page($this->page_slug) || is_page(get_option('hvac_find_training_page_id'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enqueue page assets
|
||||||
|
*/
|
||||||
|
public function enqueue_assets(): void {
|
||||||
|
if (!$this->is_find_training_page()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enqueue CSS
|
||||||
|
wp_enqueue_style(
|
||||||
|
'hvac-find-training',
|
||||||
|
HVAC_PLUGIN_URL . 'assets/css/find-training-map.css',
|
||||||
|
['astra-theme-css'],
|
||||||
|
HVAC_VERSION
|
||||||
|
);
|
||||||
|
|
||||||
|
// Enqueue Google Maps API with MarkerClusterer
|
||||||
|
if (!empty($this->api_key)) {
|
||||||
|
wp_enqueue_script(
|
||||||
|
'google-maps-api',
|
||||||
|
'https://maps.googleapis.com/maps/api/js?key=' . esc_attr($this->api_key) . '&libraries=places&callback=Function.prototype',
|
||||||
|
[],
|
||||||
|
null,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
|
||||||
|
// MarkerClusterer library
|
||||||
|
wp_enqueue_script(
|
||||||
|
'google-maps-markerclusterer',
|
||||||
|
'https://unpkg.com/@googlemaps/markerclusterer/dist/index.min.js',
|
||||||
|
['google-maps-api'],
|
||||||
|
'2.5.3',
|
||||||
|
true
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enqueue main map JavaScript
|
||||||
|
wp_enqueue_script(
|
||||||
|
'hvac-find-training-map',
|
||||||
|
HVAC_PLUGIN_URL . 'assets/js/find-training-map.js',
|
||||||
|
['jquery', 'google-maps-api', 'google-maps-markerclusterer'],
|
||||||
|
HVAC_VERSION,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
|
||||||
|
// Enqueue filter JavaScript
|
||||||
|
wp_enqueue_script(
|
||||||
|
'hvac-find-training-filters',
|
||||||
|
HVAC_PLUGIN_URL . 'assets/js/find-training-filters.js',
|
||||||
|
['jquery', 'hvac-find-training-map'],
|
||||||
|
HVAC_VERSION,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
|
||||||
|
// Localize script with data
|
||||||
|
wp_localize_script('hvac-find-training-map', 'hvacFindTraining', [
|
||||||
|
'ajax_url' => admin_url('admin-ajax.php'),
|
||||||
|
'nonce' => wp_create_nonce('hvac_find_training'),
|
||||||
|
'api_key' => !empty($this->api_key) ? 'configured' : '', // Don't expose actual key
|
||||||
|
'map_center' => [
|
||||||
|
'lat' => 39.8283, // US center
|
||||||
|
'lng' => -98.5795
|
||||||
|
],
|
||||||
|
'default_zoom' => 4,
|
||||||
|
'cluster_zoom' => 8,
|
||||||
|
'messages' => [
|
||||||
|
'loading' => __('Loading...', 'hvac-community-events'),
|
||||||
|
'error' => __('An error occurred. Please try again.', 'hvac-community-events'),
|
||||||
|
'no_results' => __('No trainers or venues found matching your criteria.', 'hvac-community-events'),
|
||||||
|
'geolocation_error' => __('Unable to get your location. Please check your browser settings.', 'hvac-community-events'),
|
||||||
|
'geolocation_unsupported' => __('Geolocation is not supported by your browser.', 'hvac-community-events')
|
||||||
|
],
|
||||||
|
'marker_icons' => [
|
||||||
|
'trainer' => HVAC_PLUGIN_URL . 'assets/images/marker-trainer.svg',
|
||||||
|
'venue' => HVAC_PLUGIN_URL . 'assets/images/marker-venue.svg'
|
||||||
|
]
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add body classes for the page
|
||||||
|
*
|
||||||
|
* @param array $classes Existing body classes
|
||||||
|
* @return array Modified body classes
|
||||||
|
*/
|
||||||
|
public function add_body_classes(array $classes): array {
|
||||||
|
if ($this->is_find_training_page()) {
|
||||||
|
$classes[] = 'hvac-find-training-page';
|
||||||
|
$classes[] = 'hvac-full-width';
|
||||||
|
$classes[] = 'hvac-page';
|
||||||
|
}
|
||||||
|
return $classes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Redirect from old /find-a-trainer page to /find-training
|
||||||
|
*/
|
||||||
|
public function maybe_redirect_from_old_page(): void {
|
||||||
|
if (is_page('find-a-trainer')) {
|
||||||
|
wp_safe_redirect(home_url('/find-training/'), 301);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AJAX: Get all map data (trainers and venues)
|
||||||
|
*/
|
||||||
|
public function ajax_get_map_data(): void {
|
||||||
|
// Verify nonce
|
||||||
|
if (!isset($_POST['nonce']) || !wp_verify_nonce($_POST['nonce'], 'hvac_find_training')) {
|
||||||
|
wp_send_json_error(['message' => 'Invalid security token']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$data_provider = HVAC_Training_Map_Data::get_instance();
|
||||||
|
|
||||||
|
$trainers = $data_provider->get_trainer_markers();
|
||||||
|
$venues = $data_provider->get_venue_markers();
|
||||||
|
|
||||||
|
wp_send_json_success([
|
||||||
|
'trainers' => $trainers,
|
||||||
|
'venues' => $venues,
|
||||||
|
'total_trainers' => count($trainers),
|
||||||
|
'total_venues' => count($venues)
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AJAX: Filter map markers
|
||||||
|
*/
|
||||||
|
public function ajax_filter_map(): void {
|
||||||
|
// Verify nonce
|
||||||
|
if (!isset($_POST['nonce']) || !wp_verify_nonce($_POST['nonce'], 'hvac_find_training')) {
|
||||||
|
wp_send_json_error(['message' => 'Invalid security token']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$filters = [
|
||||||
|
'state' => sanitize_text_field($_POST['state'] ?? ''),
|
||||||
|
'certification' => sanitize_text_field($_POST['certification'] ?? ''),
|
||||||
|
'training_format' => sanitize_text_field($_POST['training_format'] ?? ''),
|
||||||
|
'search' => sanitize_text_field($_POST['search'] ?? ''),
|
||||||
|
'show_trainers' => filter_var($_POST['show_trainers'] ?? true, FILTER_VALIDATE_BOOLEAN),
|
||||||
|
'show_venues' => filter_var($_POST['show_venues'] ?? true, FILTER_VALIDATE_BOOLEAN),
|
||||||
|
'lat' => isset($_POST['lat']) ? floatval($_POST['lat']) : null,
|
||||||
|
'lng' => isset($_POST['lng']) ? floatval($_POST['lng']) : null,
|
||||||
|
'radius' => isset($_POST['radius']) ? intval($_POST['radius']) : 100 // km
|
||||||
|
];
|
||||||
|
|
||||||
|
$data_provider = HVAC_Training_Map_Data::get_instance();
|
||||||
|
|
||||||
|
$result = [
|
||||||
|
'trainers' => [],
|
||||||
|
'venues' => []
|
||||||
|
];
|
||||||
|
|
||||||
|
if ($filters['show_trainers']) {
|
||||||
|
$result['trainers'] = $data_provider->get_trainer_markers($filters);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($filters['show_venues']) {
|
||||||
|
$result['venues'] = $data_provider->get_venue_markers($filters);
|
||||||
|
}
|
||||||
|
|
||||||
|
$result['total_trainers'] = count($result['trainers']);
|
||||||
|
$result['total_venues'] = count($result['venues']);
|
||||||
|
$result['filters_applied'] = array_filter($filters, function($v) {
|
||||||
|
return !empty($v) && $v !== true;
|
||||||
|
});
|
||||||
|
|
||||||
|
wp_send_json_success($result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AJAX: Get trainer profile for modal
|
||||||
|
*/
|
||||||
|
public function ajax_get_trainer_profile(): void {
|
||||||
|
// Verify nonce
|
||||||
|
if (!isset($_POST['nonce']) || !wp_verify_nonce($_POST['nonce'], 'hvac_find_training')) {
|
||||||
|
wp_send_json_error(['message' => 'Invalid security token']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$profile_id = absint($_POST['profile_id'] ?? 0);
|
||||||
|
|
||||||
|
if (!$profile_id) {
|
||||||
|
wp_send_json_error(['message' => 'Invalid profile ID']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$data_provider = HVAC_Training_Map_Data::get_instance();
|
||||||
|
$trainer_data = $data_provider->get_trainer_full_profile($profile_id);
|
||||||
|
|
||||||
|
if (!$trainer_data) {
|
||||||
|
wp_send_json_error(['message' => 'Trainer not found']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate modal HTML
|
||||||
|
ob_start();
|
||||||
|
$this->render_trainer_modal_content($trainer_data);
|
||||||
|
$html = ob_get_clean();
|
||||||
|
|
||||||
|
wp_send_json_success([
|
||||||
|
'trainer' => $trainer_data,
|
||||||
|
'html' => $html
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AJAX: Get venue info for info window
|
||||||
|
*/
|
||||||
|
public function ajax_get_venue_info(): void {
|
||||||
|
// Verify nonce
|
||||||
|
if (!isset($_POST['nonce']) || !wp_verify_nonce($_POST['nonce'], 'hvac_find_training')) {
|
||||||
|
wp_send_json_error(['message' => 'Invalid security token']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$venue_id = absint($_POST['venue_id'] ?? 0);
|
||||||
|
|
||||||
|
if (!$venue_id) {
|
||||||
|
wp_send_json_error(['message' => 'Invalid venue ID']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$data_provider = HVAC_Training_Map_Data::get_instance();
|
||||||
|
$venue_data = $data_provider->get_venue_full_info($venue_id);
|
||||||
|
|
||||||
|
if (!$venue_data) {
|
||||||
|
wp_send_json_error(['message' => 'Venue not found']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
wp_send_json_success(['venue' => $venue_data]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render trainer modal content
|
||||||
|
*
|
||||||
|
* @param array $trainer Trainer data
|
||||||
|
*/
|
||||||
|
private function render_trainer_modal_content(array $trainer): void {
|
||||||
|
?>
|
||||||
|
<div class="hvac-training-modal-header">
|
||||||
|
<h2><?php echo esc_html($trainer['name']); ?></h2>
|
||||||
|
<button class="hvac-modal-close" aria-label="Close modal">×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="hvac-training-modal-body">
|
||||||
|
<div class="hvac-training-profile-section">
|
||||||
|
<div class="hvac-training-profile-header">
|
||||||
|
<?php if (!empty($trainer['image'])): ?>
|
||||||
|
<img src="<?php echo esc_url($trainer['image']); ?>" alt="<?php echo esc_attr($trainer['name']); ?>" class="hvac-training-profile-image">
|
||||||
|
<?php else: ?>
|
||||||
|
<div class="hvac-training-profile-avatar">
|
||||||
|
<span class="dashicons dashicons-businessperson"></span>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<div class="hvac-training-profile-info">
|
||||||
|
<p class="hvac-training-location">
|
||||||
|
<?php echo esc_html($trainer['city'] . ', ' . $trainer['state']); ?>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<?php if (!empty($trainer['certifications'])): ?>
|
||||||
|
<div class="hvac-training-certifications">
|
||||||
|
<?php foreach ($trainer['certifications'] as $cert): ?>
|
||||||
|
<span class="hvac-cert-badge"><?php echo esc_html($cert); ?></span>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php if (!empty($trainer['company'])): ?>
|
||||||
|
<p class="hvac-training-company"><?php echo esc_html($trainer['company']); ?></p>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<p class="hvac-training-events-count">
|
||||||
|
Total Training Events: <strong><?php echo intval($trainer['event_count']); ?></strong>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php if (!empty($trainer['training_formats'])): ?>
|
||||||
|
<div class="hvac-training-detail">
|
||||||
|
<strong>Training Formats:</strong> <?php echo esc_html(implode(', ', $trainer['training_formats'])); ?>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php if (!empty($trainer['upcoming_events'])): ?>
|
||||||
|
<div class="hvac-training-events">
|
||||||
|
<h4>Upcoming Events</h4>
|
||||||
|
<ul class="hvac-events-list">
|
||||||
|
<?php foreach (array_slice($trainer['upcoming_events'], 0, 5) as $event): ?>
|
||||||
|
<li>
|
||||||
|
<a href="<?php echo esc_url($event['url']); ?>" target="_blank">
|
||||||
|
<?php echo esc_html($event['title']); ?>
|
||||||
|
</a>
|
||||||
|
<span class="hvac-event-date"><?php echo esc_html($event['date']); ?></span>
|
||||||
|
</li>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="hvac-training-contact-section">
|
||||||
|
<h4>Contact Trainer</h4>
|
||||||
|
<form class="hvac-training-contact-form" data-trainer-id="<?php echo esc_attr($trainer['user_id']); ?>" data-profile-id="<?php echo esc_attr($trainer['profile_id']); ?>">
|
||||||
|
<div class="hvac-form-row">
|
||||||
|
<input type="text" name="first_name" placeholder="First Name" required>
|
||||||
|
<input type="text" name="last_name" placeholder="Last Name" required>
|
||||||
|
</div>
|
||||||
|
<div class="hvac-form-row">
|
||||||
|
<input type="email" name="email" placeholder="Email" required>
|
||||||
|
<input type="tel" name="phone" placeholder="Phone Number">
|
||||||
|
</div>
|
||||||
|
<div class="hvac-form-row">
|
||||||
|
<input type="text" name="city" placeholder="City">
|
||||||
|
<input type="text" name="state_province" placeholder="State/Province">
|
||||||
|
</div>
|
||||||
|
<div class="hvac-form-field">
|
||||||
|
<input type="text" name="company" placeholder="Company">
|
||||||
|
</div>
|
||||||
|
<div class="hvac-form-field">
|
||||||
|
<textarea name="message" placeholder="Message" rows="3"></textarea>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="hvac-btn-primary">Send Message</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div class="hvac-form-message hvac-form-success" style="display: none;">
|
||||||
|
Your message has been sent! Check your inbox for more details.
|
||||||
|
</div>
|
||||||
|
<div class="hvac-form-message hvac-form-error" style="display: none;">
|
||||||
|
There was an error sending your message. Please try again.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get filter options for dropdowns
|
||||||
|
*
|
||||||
|
* @return array Filter options
|
||||||
|
*/
|
||||||
|
public function get_filter_options(): array {
|
||||||
|
$data_provider = HVAC_Training_Map_Data::get_instance();
|
||||||
|
|
||||||
|
return [
|
||||||
|
'states' => $data_provider->get_state_options(),
|
||||||
|
'certifications' => $data_provider->get_certification_options(),
|
||||||
|
'training_formats' => $data_provider->get_training_format_options()
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get page slug
|
||||||
|
*
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
public function get_page_slug(): string {
|
||||||
|
return $this->page_slug;
|
||||||
|
}
|
||||||
|
}
|
||||||
1102
includes/find-training/class-hvac-training-map-data.php
Normal file
1102
includes/find-training/class-hvac-training-map-data.php
Normal file
File diff suppressed because it is too large
Load diff
517
includes/find-training/class-hvac-venue-geocoding.php
Normal file
517
includes/find-training/class-hvac-venue-geocoding.php
Normal file
|
|
@ -0,0 +1,517 @@
|
||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Venue Geocoding Service
|
||||||
|
*
|
||||||
|
* Handles geocoding for TEC venues to add lat/lng coordinates.
|
||||||
|
*
|
||||||
|
* @package HVAC_Community_Events
|
||||||
|
* @since 2.2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
if (!defined('ABSPATH')) {
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class HVAC_Venue_Geocoding
|
||||||
|
*
|
||||||
|
* Manages geocoding of venue addresses using Google Maps Geocoding API.
|
||||||
|
* Auto-geocodes new venues and provides batch processing for existing venues.
|
||||||
|
*/
|
||||||
|
class HVAC_Venue_Geocoding {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Singleton instance
|
||||||
|
*
|
||||||
|
* @var HVAC_Venue_Geocoding|null
|
||||||
|
*/
|
||||||
|
private static ?self $instance = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Google Maps API key
|
||||||
|
*
|
||||||
|
* @var string
|
||||||
|
*/
|
||||||
|
private string $api_key = '';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rate limit per minute
|
||||||
|
*
|
||||||
|
* @var int
|
||||||
|
*/
|
||||||
|
private int $rate_limit = 50;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cache duration for geocoding results
|
||||||
|
*
|
||||||
|
* @var int
|
||||||
|
*/
|
||||||
|
private int $cache_duration = DAY_IN_SECONDS;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get singleton instance
|
||||||
|
*
|
||||||
|
* @return HVAC_Venue_Geocoding
|
||||||
|
*/
|
||||||
|
public static function get_instance(): self {
|
||||||
|
if (self::$instance === null) {
|
||||||
|
self::$instance = new self();
|
||||||
|
}
|
||||||
|
return self::$instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Constructor
|
||||||
|
*/
|
||||||
|
private function __construct() {
|
||||||
|
$this->load_api_key();
|
||||||
|
$this->init_hooks();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load API key from secure storage
|
||||||
|
*/
|
||||||
|
private function load_api_key(): void {
|
||||||
|
if (class_exists('HVAC_Secure_Storage')) {
|
||||||
|
$this->api_key = HVAC_Secure_Storage::get_credential('hvac_google_maps_api_key', '');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize hooks
|
||||||
|
*/
|
||||||
|
private function init_hooks(): void {
|
||||||
|
// Auto-geocode new venues on save
|
||||||
|
add_action('save_post_tribe_venue', [$this, 'maybe_geocode_venue'], 20, 2);
|
||||||
|
|
||||||
|
// Register geocoding action for async processing
|
||||||
|
add_action('hvac_geocode_venue', [$this, 'geocode_venue']);
|
||||||
|
|
||||||
|
// Admin action for batch geocoding
|
||||||
|
add_action('wp_ajax_hvac_batch_geocode_venues', [$this, 'ajax_batch_geocode']);
|
||||||
|
|
||||||
|
// Clear venue coordinates when address changes
|
||||||
|
add_action('updated_post_meta', [$this, 'on_venue_meta_update'], 10, 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maybe geocode venue on save
|
||||||
|
*
|
||||||
|
* @param int $venue_id Venue post ID
|
||||||
|
* @param WP_Post $post Post object
|
||||||
|
*/
|
||||||
|
public function maybe_geocode_venue(int $venue_id, WP_Post $post): void {
|
||||||
|
// Skip autosaves and revisions
|
||||||
|
if (wp_is_post_autosave($venue_id) || wp_is_post_revision($venue_id)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if coordinates already exist
|
||||||
|
$has_coords = $this->venue_has_coordinates($venue_id);
|
||||||
|
|
||||||
|
if (!$has_coords) {
|
||||||
|
// Schedule geocoding to avoid blocking save
|
||||||
|
wp_schedule_single_event(time() + 5, 'hvac_geocode_venue', [$venue_id]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if venue already has coordinates
|
||||||
|
*
|
||||||
|
* @param int $venue_id Venue post ID
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public function venue_has_coordinates(int $venue_id): bool {
|
||||||
|
// Check custom coordinates
|
||||||
|
$lat = get_post_meta($venue_id, 'venue_latitude', true);
|
||||||
|
$lng = get_post_meta($venue_id, 'venue_longitude', true);
|
||||||
|
|
||||||
|
if (!empty($lat) && !empty($lng)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check TEC built-in coordinates
|
||||||
|
$tec_lat = get_post_meta($venue_id, '_VenueLat', true);
|
||||||
|
$tec_lng = get_post_meta($venue_id, '_VenueLng', true);
|
||||||
|
|
||||||
|
return !empty($tec_lat) && !empty($tec_lng);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Geocode a venue
|
||||||
|
*
|
||||||
|
* @param int $venue_id Venue post ID
|
||||||
|
* @return bool Success
|
||||||
|
*/
|
||||||
|
public function geocode_venue(int $venue_id): bool {
|
||||||
|
// Rate limiting check
|
||||||
|
if (!$this->check_rate_limit()) {
|
||||||
|
// Reschedule
|
||||||
|
wp_schedule_single_event(time() + 60, 'hvac_geocode_venue', [$venue_id]);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build address
|
||||||
|
$address = $this->build_venue_address($venue_id);
|
||||||
|
|
||||||
|
if (empty($address)) {
|
||||||
|
update_post_meta($venue_id, '_venue_geocoding_status', 'no_address');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
update_post_meta($venue_id, '_venue_geocoding_attempt', time());
|
||||||
|
|
||||||
|
// Check cache first
|
||||||
|
$cache_key = 'venue_geo_' . md5($address);
|
||||||
|
$cached = get_transient($cache_key);
|
||||||
|
|
||||||
|
if ($cached !== false) {
|
||||||
|
return $this->save_coordinates($venue_id, $cached);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make API request
|
||||||
|
$result = $this->geocode_address($address);
|
||||||
|
|
||||||
|
if ($result && isset($result['lat'], $result['lng'])) {
|
||||||
|
// Cache result
|
||||||
|
set_transient($cache_key, $result, $this->cache_duration);
|
||||||
|
|
||||||
|
return $this->save_coordinates($venue_id, $result);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle failure
|
||||||
|
$this->handle_geocoding_failure($venue_id, $result);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build venue address string from TEC meta
|
||||||
|
*
|
||||||
|
* @param int $venue_id Venue post ID
|
||||||
|
* @return string Full address
|
||||||
|
*/
|
||||||
|
private function build_venue_address(int $venue_id): string {
|
||||||
|
$parts = [];
|
||||||
|
|
||||||
|
$address = get_post_meta($venue_id, '_VenueAddress', true);
|
||||||
|
$city = get_post_meta($venue_id, '_VenueCity', true);
|
||||||
|
$state = get_post_meta($venue_id, '_VenueStateProvince', true) ?: get_post_meta($venue_id, '_VenueState', true);
|
||||||
|
$zip = get_post_meta($venue_id, '_VenueZip', true);
|
||||||
|
$country = get_post_meta($venue_id, '_VenueCountry', true);
|
||||||
|
|
||||||
|
if (!empty($address)) {
|
||||||
|
$parts[] = $address;
|
||||||
|
}
|
||||||
|
if (!empty($city)) {
|
||||||
|
$parts[] = $city;
|
||||||
|
}
|
||||||
|
if (!empty($state)) {
|
||||||
|
$parts[] = $state;
|
||||||
|
}
|
||||||
|
if (!empty($zip)) {
|
||||||
|
$parts[] = $zip;
|
||||||
|
}
|
||||||
|
if (!empty($country)) {
|
||||||
|
$parts[] = $country;
|
||||||
|
}
|
||||||
|
|
||||||
|
return implode(', ', $parts);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Make geocoding API request
|
||||||
|
*
|
||||||
|
* @param string $address Address to geocode
|
||||||
|
* @return array|null Result with lat/lng or null on failure
|
||||||
|
*/
|
||||||
|
private function geocode_address(string $address): ?array {
|
||||||
|
if (empty($this->api_key)) {
|
||||||
|
return ['error' => 'No API key configured'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$url = 'https://maps.googleapis.com/maps/api/geocode/json';
|
||||||
|
$params = [
|
||||||
|
'address' => $address,
|
||||||
|
'key' => $this->api_key,
|
||||||
|
'components' => 'country:US|country:CA' // Restrict to North America
|
||||||
|
];
|
||||||
|
|
||||||
|
$response = wp_remote_get($url . '?' . http_build_query($params), [
|
||||||
|
'timeout' => 10,
|
||||||
|
'user-agent' => 'HVAC Training Directory/1.0'
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (is_wp_error($response)) {
|
||||||
|
return ['error' => $response->get_error_message()];
|
||||||
|
}
|
||||||
|
|
||||||
|
$body = wp_remote_retrieve_body($response);
|
||||||
|
$data = json_decode($body, true);
|
||||||
|
|
||||||
|
if (!$data || $data['status'] !== 'OK' || empty($data['results'])) {
|
||||||
|
return ['error' => $data['status'] ?? 'Unknown error'];
|
||||||
|
}
|
||||||
|
|
||||||
|
$location = $data['results'][0]['geometry']['location'];
|
||||||
|
|
||||||
|
return [
|
||||||
|
'lat' => $location['lat'],
|
||||||
|
'lng' => $location['lng'],
|
||||||
|
'formatted_address' => $data['results'][0]['formatted_address'],
|
||||||
|
'place_id' => $data['results'][0]['place_id'] ?? ''
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save coordinates to venue
|
||||||
|
*
|
||||||
|
* @param int $venue_id Venue post ID
|
||||||
|
* @param array $result Geocoding result
|
||||||
|
* @return bool Success
|
||||||
|
*/
|
||||||
|
private function save_coordinates(int $venue_id, array $result): bool {
|
||||||
|
if (!isset($result['lat']) || !isset($result['lng'])) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save to our custom meta
|
||||||
|
update_post_meta($venue_id, 'venue_latitude', $result['lat']);
|
||||||
|
update_post_meta($venue_id, 'venue_longitude', $result['lng']);
|
||||||
|
|
||||||
|
// Also update TEC's meta for compatibility
|
||||||
|
update_post_meta($venue_id, '_VenueLat', $result['lat']);
|
||||||
|
update_post_meta($venue_id, '_VenueLng', $result['lng']);
|
||||||
|
|
||||||
|
if (!empty($result['formatted_address'])) {
|
||||||
|
update_post_meta($venue_id, '_venue_formatted_address', $result['formatted_address']);
|
||||||
|
}
|
||||||
|
|
||||||
|
update_post_meta($venue_id, '_venue_geocoding_status', 'success');
|
||||||
|
update_post_meta($venue_id, '_venue_geocoding_date', time());
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle geocoding failure
|
||||||
|
*
|
||||||
|
* @param int $venue_id Venue post ID
|
||||||
|
* @param array|null $result Error result
|
||||||
|
*/
|
||||||
|
private function handle_geocoding_failure(int $venue_id, ?array $result): void {
|
||||||
|
$error = $result['error'] ?? 'Unknown error';
|
||||||
|
|
||||||
|
update_post_meta($venue_id, '_venue_geocoding_status', 'failed');
|
||||||
|
update_post_meta($venue_id, '_venue_geocoding_error', $error);
|
||||||
|
|
||||||
|
// Handle specific errors
|
||||||
|
switch ($error) {
|
||||||
|
case 'OVER_QUERY_LIMIT':
|
||||||
|
// Retry in 1 hour
|
||||||
|
wp_schedule_single_event(time() + HOUR_IN_SECONDS, 'hvac_geocode_venue', [$venue_id]);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'ZERO_RESULTS':
|
||||||
|
// Try fallback with less specific address
|
||||||
|
$this->try_fallback_geocoding($venue_id);
|
||||||
|
break;
|
||||||
|
|
||||||
|
default:
|
||||||
|
// Log error
|
||||||
|
if (defined('WP_DEBUG') && WP_DEBUG) {
|
||||||
|
error_log("HVAC Venue Geocoding failed for venue {$venue_id}: {$error}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Try fallback geocoding with city/state only
|
||||||
|
*
|
||||||
|
* @param int $venue_id Venue post ID
|
||||||
|
*/
|
||||||
|
private function try_fallback_geocoding(int $venue_id): void {
|
||||||
|
$city = get_post_meta($venue_id, '_VenueCity', true);
|
||||||
|
$state = get_post_meta($venue_id, '_VenueStateProvince', true) ?: get_post_meta($venue_id, '_VenueState', true);
|
||||||
|
$country = get_post_meta($venue_id, '_VenueCountry', true) ?: 'USA';
|
||||||
|
|
||||||
|
if (empty($city) && empty($state)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$address = implode(', ', array_filter([$city, $state, $country]));
|
||||||
|
$result = $this->geocode_address($address);
|
||||||
|
|
||||||
|
if ($result && isset($result['lat'], $result['lng'])) {
|
||||||
|
$this->save_coordinates($venue_id, $result);
|
||||||
|
update_post_meta($venue_id, '_venue_geocoding_status', 'success_fallback');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check rate limiting
|
||||||
|
*
|
||||||
|
* @return bool Can make request
|
||||||
|
*/
|
||||||
|
private function check_rate_limit(): bool {
|
||||||
|
$rate_key = 'hvac_venue_geocoding_rate_' . gmdate('Y-m-d-H-i');
|
||||||
|
$current = get_transient($rate_key) ?: 0;
|
||||||
|
|
||||||
|
if ($current >= $this->rate_limit) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
set_transient($rate_key, $current + 1, 60);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear coordinates when venue address changes
|
||||||
|
*
|
||||||
|
* @param int $meta_id Meta ID
|
||||||
|
* @param int $post_id Post ID
|
||||||
|
* @param string $meta_key Meta key
|
||||||
|
* @param mixed $meta_value Meta value
|
||||||
|
*/
|
||||||
|
public function on_venue_meta_update(int $meta_id, int $post_id, string $meta_key, $meta_value): void {
|
||||||
|
if (get_post_type($post_id) !== 'tribe_venue') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$address_fields = ['_VenueAddress', '_VenueCity', '_VenueStateProvince', '_VenueState', '_VenueZip'];
|
||||||
|
|
||||||
|
if (in_array($meta_key, $address_fields, true)) {
|
||||||
|
// Address changed - clear coordinates to force re-geocoding
|
||||||
|
delete_post_meta($post_id, 'venue_latitude');
|
||||||
|
delete_post_meta($post_id, 'venue_longitude');
|
||||||
|
delete_post_meta($post_id, '_venue_geocoding_status');
|
||||||
|
|
||||||
|
// Schedule re-geocoding
|
||||||
|
wp_schedule_single_event(time() + 5, 'hvac_geocode_venue', [$post_id]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AJAX handler for batch geocoding venues
|
||||||
|
*/
|
||||||
|
public function ajax_batch_geocode(): void {
|
||||||
|
// Check permissions
|
||||||
|
if (!current_user_can('manage_options')) {
|
||||||
|
wp_send_json_error(['message' => 'Permission denied']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify nonce
|
||||||
|
if (!isset($_POST['nonce']) || !wp_verify_nonce($_POST['nonce'], 'hvac_batch_geocode_venues')) {
|
||||||
|
wp_send_json_error(['message' => 'Invalid security token']);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
$limit = absint($_POST['limit'] ?? 10);
|
||||||
|
$result = $this->batch_geocode_venues($limit);
|
||||||
|
|
||||||
|
wp_send_json_success($result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Batch geocode venues without coordinates
|
||||||
|
*
|
||||||
|
* @param int $limit Maximum venues to process
|
||||||
|
* @return array Results
|
||||||
|
*/
|
||||||
|
public function batch_geocode_venues(int $limit = 10): array {
|
||||||
|
$venues = get_posts([
|
||||||
|
'post_type' => 'tribe_venue',
|
||||||
|
'posts_per_page' => $limit,
|
||||||
|
'post_status' => 'publish',
|
||||||
|
'meta_query' => [
|
||||||
|
'relation' => 'AND',
|
||||||
|
[
|
||||||
|
'key' => 'venue_latitude',
|
||||||
|
'compare' => 'NOT EXISTS'
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'key' => '_VenueLat',
|
||||||
|
'compare' => 'NOT EXISTS'
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]);
|
||||||
|
|
||||||
|
$results = [
|
||||||
|
'processed' => 0,
|
||||||
|
'success' => 0,
|
||||||
|
'failed' => 0,
|
||||||
|
'remaining' => 0
|
||||||
|
];
|
||||||
|
|
||||||
|
foreach ($venues as $venue) {
|
||||||
|
if (!$this->check_rate_limit()) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
$results['processed']++;
|
||||||
|
|
||||||
|
if ($this->geocode_venue($venue->ID)) {
|
||||||
|
$results['success']++;
|
||||||
|
} else {
|
||||||
|
$results['failed']++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count remaining
|
||||||
|
$remaining_query = new WP_Query([
|
||||||
|
'post_type' => 'tribe_venue',
|
||||||
|
'posts_per_page' => 1,
|
||||||
|
'post_status' => 'publish',
|
||||||
|
'fields' => 'ids',
|
||||||
|
'meta_query' => [
|
||||||
|
'relation' => 'AND',
|
||||||
|
[
|
||||||
|
'key' => 'venue_latitude',
|
||||||
|
'compare' => 'NOT EXISTS'
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'key' => '_VenueLat',
|
||||||
|
'compare' => 'NOT EXISTS'
|
||||||
|
]
|
||||||
|
]
|
||||||
|
]);
|
||||||
|
|
||||||
|
$results['remaining'] = $remaining_query->found_posts;
|
||||||
|
|
||||||
|
return $results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get geocoding status for a venue
|
||||||
|
*
|
||||||
|
* @param int $venue_id Venue post ID
|
||||||
|
* @return array Status info
|
||||||
|
*/
|
||||||
|
public function get_geocoding_status(int $venue_id): array {
|
||||||
|
return [
|
||||||
|
'has_coordinates' => $this->venue_has_coordinates($venue_id),
|
||||||
|
'latitude' => get_post_meta($venue_id, 'venue_latitude', true) ?: get_post_meta($venue_id, '_VenueLat', true),
|
||||||
|
'longitude' => get_post_meta($venue_id, 'venue_longitude', true) ?: get_post_meta($venue_id, '_VenueLng', true),
|
||||||
|
'status' => get_post_meta($venue_id, '_venue_geocoding_status', true),
|
||||||
|
'error' => get_post_meta($venue_id, '_venue_geocoding_error', true),
|
||||||
|
'last_attempt' => get_post_meta($venue_id, '_venue_geocoding_attempt', true),
|
||||||
|
'geocoded_date' => get_post_meta($venue_id, '_venue_geocoding_date', true)
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear coordinates for a venue
|
||||||
|
*
|
||||||
|
* @param int $venue_id Venue post ID
|
||||||
|
*/
|
||||||
|
public function clear_coordinates(int $venue_id): void {
|
||||||
|
delete_post_meta($venue_id, 'venue_latitude');
|
||||||
|
delete_post_meta($venue_id, 'venue_longitude');
|
||||||
|
delete_post_meta($venue_id, '_VenueLat');
|
||||||
|
delete_post_meta($venue_id, '_VenueLng');
|
||||||
|
delete_post_meta($venue_id, '_venue_formatted_address');
|
||||||
|
delete_post_meta($venue_id, '_venue_geocoding_status');
|
||||||
|
delete_post_meta($venue_id, '_venue_geocoding_error');
|
||||||
|
delete_post_meta($venue_id, '_venue_geocoding_date');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -222,18 +222,21 @@ class HVAC_Zoho_CRM_Auth {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 3. Parse hostname from site URL for accurate comparison
|
||||||
$site_url = get_site_url();
|
$site_url = get_site_url();
|
||||||
|
$host = wp_parse_url($site_url, PHP_URL_HOST);
|
||||||
|
|
||||||
// 3. Check for specific staging domains or keywords
|
if (empty($host)) {
|
||||||
if (strpos($site_url, 'staging') !== false ||
|
return true; // Can't determine host, default to staging for safety
|
||||||
strpos($site_url, 'dev') !== false ||
|
|
||||||
strpos($site_url, 'test') !== false ||
|
|
||||||
strpos($site_url, 'cloudwaysapps.com') !== false) {
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. Default check: Production only on upskillhvac.com
|
// 4. Production: upskillhvac.com or www.upskillhvac.com
|
||||||
return strpos($site_url, 'upskillhvac.com') === false;
|
if ($host === 'upskillhvac.com' || $host === 'www.upskillhvac.com') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Everything else is staging (including staging subdomains, cloudwaysapps, localhost, etc.)
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -242,8 +245,12 @@ class HVAC_Zoho_CRM_Auth {
|
||||||
* @return array Debug information
|
* @return array Debug information
|
||||||
*/
|
*/
|
||||||
public static function get_debug_mode_info() {
|
public static function get_debug_mode_info() {
|
||||||
|
$site_url = get_site_url();
|
||||||
|
$host = wp_parse_url($site_url, PHP_URL_HOST);
|
||||||
|
|
||||||
$info = array(
|
$info = array(
|
||||||
'site_url' => get_site_url(),
|
'site_url' => $site_url,
|
||||||
|
'parsed_host' => $host,
|
||||||
'is_staging' => self::is_staging_mode(),
|
'is_staging' => self::is_staging_mode(),
|
||||||
'forced_production' => defined('HVAC_ZOHO_PRODUCTION_MODE') && HVAC_ZOHO_PRODUCTION_MODE,
|
'forced_production' => defined('HVAC_ZOHO_PRODUCTION_MODE') && HVAC_ZOHO_PRODUCTION_MODE,
|
||||||
'forced_staging' => defined('HVAC_ZOHO_STAGING_MODE') && HVAC_ZOHO_STAGING_MODE,
|
'forced_staging' => defined('HVAC_ZOHO_STAGING_MODE') && HVAC_ZOHO_STAGING_MODE,
|
||||||
|
|
@ -255,20 +262,12 @@ class HVAC_Zoho_CRM_Auth {
|
||||||
$info['detection_logic'][] = 'Forced PRODUCTION via HVAC_ZOHO_PRODUCTION_MODE constant';
|
$info['detection_logic'][] = 'Forced PRODUCTION via HVAC_ZOHO_PRODUCTION_MODE constant';
|
||||||
} elseif ($info['forced_staging']) {
|
} elseif ($info['forced_staging']) {
|
||||||
$info['detection_logic'][] = 'Forced STAGING via HVAC_ZOHO_STAGING_MODE constant';
|
$info['detection_logic'][] = 'Forced STAGING via HVAC_ZOHO_STAGING_MODE constant';
|
||||||
|
} elseif (empty($host)) {
|
||||||
|
$info['detection_logic'][] = 'STAGING: Could not parse hostname from URL';
|
||||||
|
} elseif ($host === 'upskillhvac.com' || $host === 'www.upskillhvac.com') {
|
||||||
|
$info['detection_logic'][] = 'PRODUCTION: Hostname matches upskillhvac.com';
|
||||||
} else {
|
} else {
|
||||||
$site_url = $info['site_url'];
|
$info['detection_logic'][] = 'STAGING: Hostname "' . $host . '" is not upskillhvac.com';
|
||||||
if (strpos($site_url, 'staging') !== false) $info['detection_logic'][] = 'Matched "staging" in URL';
|
|
||||||
if (strpos($site_url, 'dev') !== false) $info['detection_logic'][] = 'Matched "dev" in URL';
|
|
||||||
if (strpos($site_url, 'test') !== false) $info['detection_logic'][] = 'Matched "test" in URL';
|
|
||||||
if (strpos($site_url, 'cloudwaysapps.com') !== false) $info['detection_logic'][] = 'Matched "cloudwaysapps.com" in URL';
|
|
||||||
|
|
||||||
if (empty($info['detection_logic'])) {
|
|
||||||
if (strpos($site_url, 'upskillhvac.com') === false) {
|
|
||||||
$info['detection_logic'][] = 'Default STAGING: URL does not contain "upskillhvac.com"';
|
|
||||||
} else {
|
|
||||||
$info['detection_logic'][] = 'Default PRODUCTION: URL contains "upskillhvac.com"';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return $info;
|
return $info;
|
||||||
|
|
@ -283,7 +282,7 @@ class HVAC_Zoho_CRM_Auth {
|
||||||
|
|
||||||
// In staging mode, only allow read operations, no writes
|
// In staging mode, only allow read operations, no writes
|
||||||
if ($is_staging && in_array($method, array('POST', 'PUT', 'DELETE', 'PATCH'))) {
|
if ($is_staging && in_array($method, array('POST', 'PUT', 'DELETE', 'PATCH'))) {
|
||||||
$this->log_debug('STAGING MODE: Simulating ' . $method . ' request to ' . $endpoint);
|
$this->log_debug('STAGING MODE: Blocked ' . $method . ' request to ' . $endpoint);
|
||||||
return array(
|
return array(
|
||||||
'data' => array(
|
'data' => array(
|
||||||
array(
|
array(
|
||||||
|
|
@ -291,8 +290,8 @@ class HVAC_Zoho_CRM_Auth {
|
||||||
'details' => array(
|
'details' => array(
|
||||||
'message' => 'Staging mode active. Write operations are disabled.'
|
'message' => 'Staging mode active. Write operations are disabled.'
|
||||||
),
|
),
|
||||||
'message' => 'This would have been a ' . $method . ' request to: ' . $endpoint,
|
'message' => 'Blocked ' . $method . ' request to: ' . $endpoint,
|
||||||
'status' => 'success'
|
'status' => 'skipped_staging'
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -56,6 +56,99 @@ class HVAC_Zoho_Sync {
|
||||||
return !HVAC_Zoho_CRM_Auth::is_staging_mode();
|
return !HVAC_Zoho_CRM_Auth::is_staging_mode();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate a Zoho API response to determine if the operation succeeded
|
||||||
|
*
|
||||||
|
* @param mixed $response Response from make_api_request()
|
||||||
|
* @return array ['success' => bool, 'id' => string|null, 'error' => string|null]
|
||||||
|
*/
|
||||||
|
private function validate_api_response($response) {
|
||||||
|
// Check for WP_Error
|
||||||
|
if (is_wp_error($response)) {
|
||||||
|
return array(
|
||||||
|
'success' => false,
|
||||||
|
'id' => null,
|
||||||
|
'error' => $response->get_error_message()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for staging mode simulation
|
||||||
|
if (isset($response['data'][0]['code']) && $response['data'][0]['code'] === 'STAGING_MODE') {
|
||||||
|
return array(
|
||||||
|
'success' => false,
|
||||||
|
'id' => null,
|
||||||
|
'error' => 'Staging mode: write operations blocked'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for Zoho API error codes FIRST (more specific than generic HTTP errors)
|
||||||
|
if (isset($response['data'][0]['code']) && !in_array($response['data'][0]['code'], array('SUCCESS', 'DUPLICATE_DATA'))) {
|
||||||
|
$error_msg = isset($response['data'][0]['message']) ? $response['data'][0]['message'] : $response['data'][0]['code'];
|
||||||
|
// Include field-level details if available
|
||||||
|
if (isset($response['data'][0]['details'])) {
|
||||||
|
$details = $response['data'][0]['details'];
|
||||||
|
if (isset($details['api_name'])) {
|
||||||
|
$error_msg .= ' (field: ' . $details['api_name'] . ')';
|
||||||
|
}
|
||||||
|
if (isset($details['expected_data_type'])) {
|
||||||
|
$error_msg .= ' (expected: ' . $details['expected_data_type'] . ')';
|
||||||
|
}
|
||||||
|
if (isset($details['info'])) {
|
||||||
|
$error_msg .= ' - ' . $details['info'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return array(
|
||||||
|
'success' => false,
|
||||||
|
'id' => null,
|
||||||
|
'error' => $error_msg
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for HTTP-level errors (generic fallback)
|
||||||
|
if (isset($response['error'])) {
|
||||||
|
return array(
|
||||||
|
'success' => false,
|
||||||
|
'id' => null,
|
||||||
|
'error' => $response['error']
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for successful response with ID
|
||||||
|
if (isset($response['data'][0]['details']['id'])) {
|
||||||
|
return array(
|
||||||
|
'success' => true,
|
||||||
|
'id' => $response['data'][0]['details']['id'],
|
||||||
|
'error' => null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for duplicate record handling (also a success case)
|
||||||
|
if (isset($response['data'][0]['code']) && $response['data'][0]['code'] === 'DUPLICATE_DATA'
|
||||||
|
&& isset($response['data'][0]['details']['duplicate_record']['id'])) {
|
||||||
|
return array(
|
||||||
|
'success' => true,
|
||||||
|
'id' => $response['data'][0]['details']['duplicate_record']['id'],
|
||||||
|
'error' => null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for success status without ID (e.g., PUT updates)
|
||||||
|
if (isset($response['data'][0]['status']) && $response['data'][0]['status'] === 'success') {
|
||||||
|
return array(
|
||||||
|
'success' => true,
|
||||||
|
'id' => isset($response['data'][0]['details']['id']) ? $response['data'][0]['details']['id'] : null,
|
||||||
|
'error' => null
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unknown response structure - treat as failure
|
||||||
|
return array(
|
||||||
|
'success' => false,
|
||||||
|
'id' => null,
|
||||||
|
'error' => 'Unexpected API response: ' . json_encode(array_slice($response, 0, 3))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Generate a hash for sync data to detect changes
|
* Generate a hash for sync data to detect changes
|
||||||
*
|
*
|
||||||
|
|
@ -210,6 +303,7 @@ class HVAC_Zoho_Sync {
|
||||||
}
|
}
|
||||||
|
|
||||||
$campaign_id = null;
|
$campaign_id = null;
|
||||||
|
$sync_succeeded = false;
|
||||||
|
|
||||||
// FIRST: Check if we already have a stored Zoho Campaign ID
|
// FIRST: Check if we already have a stored Zoho Campaign ID
|
||||||
$stored_campaign_id = get_post_meta($event->ID, '_zoho_campaign_id', true);
|
$stored_campaign_id = get_post_meta($event->ID, '_zoho_campaign_id', true);
|
||||||
|
|
@ -219,20 +313,24 @@ class HVAC_Zoho_Sync {
|
||||||
$update_response = $this->auth->make_api_request("/Campaigns/{$stored_campaign_id}", 'PUT', array(
|
$update_response = $this->auth->make_api_request("/Campaigns/{$stored_campaign_id}", 'PUT', array(
|
||||||
'data' => array($campaign_data)
|
'data' => array($campaign_data)
|
||||||
));
|
));
|
||||||
|
$validated = $this->validate_api_response($update_response);
|
||||||
|
|
||||||
// Check if update failed due to invalid ID (e.g. campaign deleted in Zoho)
|
// Check if update failed due to invalid ID (e.g. campaign deleted in Zoho)
|
||||||
if (isset($update_response['code']) && $update_response['code'] === 'INVALID_DATA') {
|
if (!$validated['success'] && isset($update_response['code']) && $update_response['code'] === 'INVALID_DATA') {
|
||||||
// Fallback: Create new campaign
|
// Fallback: Create new campaign
|
||||||
$create_response = $this->auth->make_api_request('/Campaigns', 'POST', array(
|
$create_response = $this->auth->make_api_request('/Campaigns', 'POST', array(
|
||||||
'data' => array($campaign_data)
|
'data' => array($campaign_data)
|
||||||
));
|
));
|
||||||
|
$validated = $this->validate_api_response($create_response);
|
||||||
$results['responses'][] = array('type' => 'create_fallback', 'id' => $event->ID, 'response' => $create_response);
|
$results['responses'][] = array('type' => 'create_fallback', 'id' => $event->ID, 'response' => $create_response);
|
||||||
|
|
||||||
if (!empty($create_response['data'][0]['details']['id'])) {
|
if ($validated['success'] && $validated['id']) {
|
||||||
$campaign_id = $create_response['data'][0]['details']['id'];
|
$campaign_id = $validated['id'];
|
||||||
|
$sync_succeeded = true;
|
||||||
}
|
}
|
||||||
} else {
|
} elseif ($validated['success']) {
|
||||||
$campaign_id = $stored_campaign_id;
|
$campaign_id = $stored_campaign_id;
|
||||||
|
$sync_succeeded = true;
|
||||||
$results['responses'][] = array('type' => 'update', 'id' => $event->ID, 'response' => $update_response);
|
$results['responses'][] = array('type' => 'update', 'id' => $event->ID, 'response' => $update_response);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -240,25 +338,32 @@ class HVAC_Zoho_Sync {
|
||||||
$create_response = $this->auth->make_api_request('/Campaigns', 'POST', array(
|
$create_response = $this->auth->make_api_request('/Campaigns', 'POST', array(
|
||||||
'data' => array($campaign_data)
|
'data' => array($campaign_data)
|
||||||
));
|
));
|
||||||
|
$validated = $this->validate_api_response($create_response);
|
||||||
$results['responses'][] = array('type' => 'create', 'id' => $event->ID, 'response' => $create_response);
|
$results['responses'][] = array('type' => 'create', 'id' => $event->ID, 'response' => $create_response);
|
||||||
|
|
||||||
// Extract campaign ID from create response
|
if ($validated['success'] && $validated['id']) {
|
||||||
if (!empty($create_response['data'][0]['details']['id'])) {
|
$campaign_id = $validated['id'];
|
||||||
$campaign_id = $create_response['data'][0]['details']['id'];
|
$sync_succeeded = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($sync_succeeded) {
|
||||||
$results['synced']++;
|
$results['synced']++;
|
||||||
|
|
||||||
// Update event meta with Zoho Campaign ID and sync hash
|
// Only update hash and Zoho ID on confirmed success
|
||||||
if (!empty($campaign_id)) {
|
if (!empty($campaign_id)) {
|
||||||
update_post_meta($event->ID, '_zoho_campaign_id', $campaign_id);
|
update_post_meta($event->ID, '_zoho_campaign_id', $campaign_id);
|
||||||
}
|
}
|
||||||
update_post_meta($event->ID, '_zoho_sync_hash', $this->generate_sync_hash($campaign_data));
|
update_post_meta($event->ID, '_zoho_sync_hash', $this->generate_sync_hash($campaign_data));
|
||||||
|
} else {
|
||||||
} catch (Exception $e) {
|
|
||||||
$results['failed']++;
|
$results['failed']++;
|
||||||
$results['errors'][] = sprintf('Event %s: %s', $event->ID, $e->getMessage());
|
$error_msg = isset($validated['error']) ? $validated['error'] : 'Unknown API error';
|
||||||
|
$results['errors'][] = sprintf('Event %s: %s', $event->ID, $error_msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$results['failed']++;
|
||||||
|
$results['errors'][] = sprintf('Event %s: [%s] %s', $event->ID, get_class($e), $e->getMessage());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -358,10 +463,14 @@ class HVAC_Zoho_Sync {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$contact_id = null;
|
||||||
|
$sync_succeeded = false;
|
||||||
|
|
||||||
// Check if contact already exists in Zoho
|
// Check if contact already exists in Zoho
|
||||||
$search_response = $this->auth->make_api_request('/Contacts/search', 'GET', array(
|
$search_response = $this->auth->make_api_request(
|
||||||
'criteria' => "(Email:equals:{$contact_data['Email']})"
|
'/Contacts/search?criteria=(Email:equals:' . urlencode($contact_data['Email']) . ')',
|
||||||
));
|
'GET'
|
||||||
|
);
|
||||||
|
|
||||||
if (!empty($search_response['data'])) {
|
if (!empty($search_response['data'])) {
|
||||||
// Update existing contact
|
// Update existing contact
|
||||||
|
|
@ -369,28 +478,38 @@ class HVAC_Zoho_Sync {
|
||||||
$update_response = $this->auth->make_api_request("/Contacts/{$contact_id}", 'PUT', array(
|
$update_response = $this->auth->make_api_request("/Contacts/{$contact_id}", 'PUT', array(
|
||||||
'data' => array($contact_data)
|
'data' => array($contact_data)
|
||||||
));
|
));
|
||||||
|
$validated = $this->validate_api_response($update_response);
|
||||||
|
$sync_succeeded = $validated['success'];
|
||||||
} else {
|
} else {
|
||||||
// Create new contact
|
// Create new contact
|
||||||
$create_response = $this->auth->make_api_request('/Contacts', 'POST', array(
|
$create_response = $this->auth->make_api_request('/Contacts', 'POST', array(
|
||||||
'data' => array($contact_data)
|
'data' => array($contact_data)
|
||||||
));
|
));
|
||||||
|
$validated = $this->validate_api_response($create_response);
|
||||||
|
$sync_succeeded = $validated['success'];
|
||||||
|
|
||||||
if (!empty($create_response['data'][0]['details']['id'])) {
|
if ($validated['success'] && $validated['id']) {
|
||||||
$contact_id = $create_response['data'][0]['details']['id'];
|
$contact_id = $validated['id'];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($sync_succeeded) {
|
||||||
$results['synced']++;
|
$results['synced']++;
|
||||||
|
|
||||||
// Update user meta with Zoho ID and sync hash
|
// Only update hash and Zoho ID on confirmed success
|
||||||
if (isset($contact_id)) {
|
if (!empty($contact_id)) {
|
||||||
update_user_meta($user->ID, '_zoho_contact_id', $contact_id);
|
update_user_meta($user->ID, '_zoho_contact_id', $contact_id);
|
||||||
}
|
}
|
||||||
update_user_meta($user->ID, '_zoho_sync_hash', $this->generate_sync_hash($contact_data));
|
update_user_meta($user->ID, '_zoho_sync_hash', $this->generate_sync_hash($contact_data));
|
||||||
|
} else {
|
||||||
} catch (Exception $e) {
|
|
||||||
$results['failed']++;
|
$results['failed']++;
|
||||||
$results['errors'][] = sprintf('User %s: %s', $user->ID, $e->getMessage());
|
$error_msg = isset($validated['error']) ? $validated['error'] : 'Unknown API error';
|
||||||
|
$results['errors'][] = sprintf('User %s: %s', $user->ID, $error_msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$results['failed']++;
|
||||||
|
$results['errors'][] = sprintf('User %s: [%s] %s', $user->ID, get_class($e), $e->getMessage());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -496,6 +615,9 @@ class HVAC_Zoho_Sync {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$invoice_id = null;
|
||||||
|
$sync_succeeded = false;
|
||||||
|
|
||||||
// Check if invoice already exists in Zoho (by WordPress Order ID)
|
// Check if invoice already exists in Zoho (by WordPress Order ID)
|
||||||
$search_response = $this->auth->make_api_request(
|
$search_response = $this->auth->make_api_request(
|
||||||
'/Invoices/search?criteria=(WordPress_Order_ID:equals:' . $order->ID . ')',
|
'/Invoices/search?criteria=(WordPress_Order_ID:equals:' . $order->ID . ')',
|
||||||
|
|
@ -505,31 +627,41 @@ class HVAC_Zoho_Sync {
|
||||||
if (!empty($search_response['data'])) {
|
if (!empty($search_response['data'])) {
|
||||||
// Update existing invoice
|
// Update existing invoice
|
||||||
$invoice_id = $search_response['data'][0]['id'];
|
$invoice_id = $search_response['data'][0]['id'];
|
||||||
$this->auth->make_api_request("/Invoices/{$invoice_id}", 'PUT', array(
|
$update_response = $this->auth->make_api_request("/Invoices/{$invoice_id}", 'PUT', array(
|
||||||
'data' => array($invoice_data)
|
'data' => array($invoice_data)
|
||||||
));
|
));
|
||||||
|
$validated = $this->validate_api_response($update_response);
|
||||||
|
$sync_succeeded = $validated['success'];
|
||||||
} else {
|
} else {
|
||||||
// Create new invoice
|
// Create new invoice
|
||||||
$create_response = $this->auth->make_api_request('/Invoices', 'POST', array(
|
$create_response = $this->auth->make_api_request('/Invoices', 'POST', array(
|
||||||
'data' => array($invoice_data)
|
'data' => array($invoice_data)
|
||||||
));
|
));
|
||||||
|
$validated = $this->validate_api_response($create_response);
|
||||||
|
$sync_succeeded = $validated['success'];
|
||||||
|
|
||||||
if (!empty($create_response['data'][0]['details']['id'])) {
|
if ($validated['success'] && $validated['id']) {
|
||||||
$invoice_id = $create_response['data'][0]['details']['id'];
|
$invoice_id = $validated['id'];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($sync_succeeded) {
|
||||||
$results['synced']++;
|
$results['synced']++;
|
||||||
|
|
||||||
// Update order meta with Zoho ID and sync hash
|
// Only update hash and Zoho ID on confirmed success
|
||||||
if (isset($invoice_id)) {
|
if (!empty($invoice_id)) {
|
||||||
update_post_meta($order->ID, '_zoho_invoice_id', $invoice_id);
|
update_post_meta($order->ID, '_zoho_invoice_id', $invoice_id);
|
||||||
}
|
}
|
||||||
update_post_meta($order->ID, '_zoho_sync_hash', $this->generate_sync_hash($invoice_data));
|
update_post_meta($order->ID, '_zoho_sync_hash', $this->generate_sync_hash($invoice_data));
|
||||||
|
} else {
|
||||||
} catch (Exception $e) {
|
|
||||||
$results['failed']++;
|
$results['failed']++;
|
||||||
$results['errors'][] = sprintf('Order %s: %s', $order->ID, $e->getMessage());
|
$error_msg = isset($validated['error']) ? $validated['error'] : 'Unknown API error';
|
||||||
|
$results['errors'][] = sprintf('Order %s: %s', $order->ID, $error_msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$results['failed']++;
|
||||||
|
$results['errors'][] = sprintf('Order %s: [%s] %s', $order->ID, get_class($e), $e->getMessage());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -687,8 +819,18 @@ class HVAC_Zoho_Sync {
|
||||||
$results['responses'][] = array('type' => 'debug', 'msg' => "Debug: Attendee {$attendee->ID} found Contact ID: " . $cid_debug);
|
$results['responses'][] = array('type' => 'debug', 'msg' => "Debug: Attendee {$attendee->ID} found Contact ID: " . $cid_debug);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If contact creation failed, count as failed and skip hash update
|
||||||
|
if (!$contact_id) {
|
||||||
|
$results['failed']++;
|
||||||
|
$error_detail = $this->last_contact_error ?: 'Unknown error';
|
||||||
|
$results['errors'][] = sprintf('Attendee %s: No contact_id created. %s', $attendee->ID, $error_detail);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$attendee_sync_ok = true;
|
||||||
|
|
||||||
// Step 2: Create Campaign Member (link Contact to Campaign)
|
// Step 2: Create Campaign Member (link Contact to Campaign)
|
||||||
if ($contact_id && !empty($attendee_data['event_id'])) {
|
if (!empty($attendee_data['event_id'])) {
|
||||||
$campaign_id = get_post_meta($attendee_data['event_id'], '_zoho_campaign_id', true);
|
$campaign_id = get_post_meta($attendee_data['event_id'], '_zoho_campaign_id', true);
|
||||||
|
|
||||||
// Debug: Log event_id and campaign_id for troubleshooting
|
// Debug: Log event_id and campaign_id for troubleshooting
|
||||||
|
|
@ -732,9 +874,6 @@ class HVAC_Zoho_Sync {
|
||||||
} else {
|
} else {
|
||||||
$results['errors'][] = sprintf('Attendee %s: Event %s has no Zoho Campaign ID', $attendee->ID, $attendee_data['event_id']);
|
$results['errors'][] = sprintf('Attendee %s: Event %s has no Zoho Campaign ID', $attendee->ID, $attendee_data['event_id']);
|
||||||
}
|
}
|
||||||
} elseif (!$contact_id) {
|
|
||||||
$error_detail = $this->last_contact_error ?: 'Unknown error';
|
|
||||||
$results['errors'][] = sprintf('Attendee %s: No contact_id created. %s', $attendee->ID, $error_detail);
|
|
||||||
} elseif (empty($attendee_data['event_id'])) {
|
} elseif (empty($attendee_data['event_id'])) {
|
||||||
// Debug: Log when event_id is missing
|
// Debug: Log when event_id is missing
|
||||||
if (count($results['responses']) < 10) {
|
if (count($results['responses']) < 10) {
|
||||||
|
|
@ -742,9 +881,10 @@ class HVAC_Zoho_Sync {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Contact was created/found successfully - count as synced
|
||||||
$results['synced']++;
|
$results['synced']++;
|
||||||
|
|
||||||
// Update attendee meta with Zoho Contact ID and sync hash
|
// Only update hash on confirmed contact success
|
||||||
update_post_meta($attendee->ID, '_zoho_contact_id', $contact_id);
|
update_post_meta($attendee->ID, '_zoho_contact_id', $contact_id);
|
||||||
update_post_meta($attendee->ID, '_zoho_sync_hash', $this->generate_sync_hash($attendee_data));
|
update_post_meta($attendee->ID, '_zoho_sync_hash', $this->generate_sync_hash($attendee_data));
|
||||||
|
|
||||||
|
|
@ -866,12 +1006,17 @@ class HVAC_Zoho_Sync {
|
||||||
|
|
||||||
// Step 1: Create/Update Lead
|
// Step 1: Create/Update Lead
|
||||||
$lead_id = $this->ensure_lead_exists($rsvp_data);
|
$lead_id = $this->ensure_lead_exists($rsvp_data);
|
||||||
if ($lead_id) {
|
|
||||||
$results['leads_created']++;
|
if (!$lead_id) {
|
||||||
|
$results['failed']++;
|
||||||
|
$results['errors'][] = sprintf('RSVP %s: Failed to create/find lead', $rsvp->ID);
|
||||||
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$results['leads_created']++;
|
||||||
|
|
||||||
// Step 2: Create Campaign Member (link Lead to Campaign)
|
// Step 2: Create Campaign Member (link Lead to Campaign)
|
||||||
if ($lead_id && !empty($rsvp_data['event_id'])) {
|
if (!empty($rsvp_data['event_id'])) {
|
||||||
$campaign_id = get_post_meta($rsvp_data['event_id'], '_zoho_campaign_id', true);
|
$campaign_id = get_post_meta($rsvp_data['event_id'], '_zoho_campaign_id', true);
|
||||||
if ($campaign_id) {
|
if ($campaign_id) {
|
||||||
$assoc_response = $this->create_campaign_member($lead_id, $campaign_id, 'Responded', 'Leads');
|
$assoc_response = $this->create_campaign_member($lead_id, $campaign_id, 'Responded', 'Leads');
|
||||||
|
|
@ -892,13 +1037,13 @@ class HVAC_Zoho_Sync {
|
||||||
|
|
||||||
$results['synced']++;
|
$results['synced']++;
|
||||||
|
|
||||||
// Update RSVP meta with Zoho Lead ID and sync hash
|
// Only update hash on confirmed lead creation success
|
||||||
update_post_meta($rsvp->ID, '_zoho_lead_id', $lead_id);
|
update_post_meta($rsvp->ID, '_zoho_lead_id', $lead_id);
|
||||||
update_post_meta($rsvp->ID, '_zoho_sync_hash', $this->generate_sync_hash($rsvp_data));
|
update_post_meta($rsvp->ID, '_zoho_sync_hash', $this->generate_sync_hash($rsvp_data));
|
||||||
|
|
||||||
} catch (Exception $e) {
|
} catch (\Throwable $e) {
|
||||||
$results['failed']++;
|
$results['failed']++;
|
||||||
$results['errors'][] = sprintf('RSVP %s: %s', $rsvp->ID, $e->getMessage());
|
$results['errors'][] = sprintf('RSVP %s: [%s] %s', $rsvp->ID, get_class($e), $e->getMessage());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1203,11 +1348,25 @@ class HVAC_Zoho_Sync {
|
||||||
$role = 'Trainee';
|
$role = 'Trainee';
|
||||||
}
|
}
|
||||||
|
|
||||||
return array(
|
// Last_Name is required by Zoho - fallback to display_name or username
|
||||||
|
$last_name = html_entity_decode(get_user_meta($user->ID, 'last_name', true), ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
||||||
|
if (empty(trim($last_name))) {
|
||||||
|
$last_name = $user->display_name ?: $user->user_login;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sanitize phone: strip to digits and leading +, require 10+ digits for validity
|
||||||
|
$raw_phone = get_user_meta($user->ID, 'phone_number', true);
|
||||||
|
$phone_digits = preg_replace('/\D/', '', $raw_phone);
|
||||||
|
$phone = '';
|
||||||
|
if (strlen($phone_digits) >= 10) {
|
||||||
|
// Preserve leading + if present, otherwise just use digits
|
||||||
|
$phone = (strpos(trim($raw_phone), '+') === 0 ? '+' : '') . $phone_digits;
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = array(
|
||||||
'First_Name' => html_entity_decode(get_user_meta($user->ID, 'first_name', true), ENT_QUOTES | ENT_HTML5, 'UTF-8'),
|
'First_Name' => html_entity_decode(get_user_meta($user->ID, 'first_name', true), ENT_QUOTES | ENT_HTML5, 'UTF-8'),
|
||||||
'Last_Name' => html_entity_decode(get_user_meta($user->ID, 'last_name', true), ENT_QUOTES | ENT_HTML5, 'UTF-8'),
|
'Last_Name' => $last_name,
|
||||||
'Email' => $user->user_email,
|
'Email' => $user->user_email,
|
||||||
'Phone' => get_user_meta($user->ID, 'phone_number', true),
|
|
||||||
'Title' => html_entity_decode(get_user_meta($user->ID, 'hvac_professional_title', true), ENT_QUOTES | ENT_HTML5, 'UTF-8'),
|
'Title' => html_entity_decode(get_user_meta($user->ID, 'hvac_professional_title', true), ENT_QUOTES | ENT_HTML5, 'UTF-8'),
|
||||||
'Company' => html_entity_decode(get_user_meta($user->ID, 'hvac_company_name', true), ENT_QUOTES | ENT_HTML5, 'UTF-8'),
|
'Company' => html_entity_decode(get_user_meta($user->ID, 'hvac_company_name', true), ENT_QUOTES | ENT_HTML5, 'UTF-8'),
|
||||||
'Lead_Source' => 'HVAC Community Events',
|
'Lead_Source' => 'HVAC Community Events',
|
||||||
|
|
@ -1217,6 +1376,13 @@ class HVAC_Zoho_Sync {
|
||||||
'Years_Experience' => get_user_meta($user->ID, 'hvac_years_experience', true),
|
'Years_Experience' => get_user_meta($user->ID, 'hvac_years_experience', true),
|
||||||
'Certification' => get_user_meta($user->ID, 'hvac_certifications', true)
|
'Certification' => get_user_meta($user->ID, 'hvac_certifications', true)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Only include Phone if we have a valid value (10+ digits)
|
||||||
|
if (!empty($phone)) {
|
||||||
|
$data['Phone'] = $phone;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $data;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
409
templates/page-find-training.php
Normal file
409
templates/page-find-training.php
Normal file
|
|
@ -0,0 +1,409 @@
|
||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* Template Name: Find Training
|
||||||
|
*
|
||||||
|
* Google Maps-style full-screen layout with left sidebar panel
|
||||||
|
* for trainer directory and compact filter toolbar.
|
||||||
|
*
|
||||||
|
* @package HVAC_Community_Events
|
||||||
|
* @since 2.2.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
defined('ABSPATH') || exit;
|
||||||
|
define('HVAC_IN_PAGE_TEMPLATE', true);
|
||||||
|
|
||||||
|
get_header();
|
||||||
|
|
||||||
|
// Get page handler instance
|
||||||
|
$find_training = HVAC_Find_Training_Page::get_instance();
|
||||||
|
$filter_options = $find_training->get_filter_options();
|
||||||
|
$api_key_configured = $find_training->is_api_key_configured();
|
||||||
|
?>
|
||||||
|
|
||||||
|
<div class="hvac-find-training-page">
|
||||||
|
<!-- Skip link for accessibility -->
|
||||||
|
<a href="#hvac-trainer-grid" class="hvac-skip-link">Skip to trainer results</a>
|
||||||
|
|
||||||
|
<!-- Compact Filter Bar -->
|
||||||
|
<div class="hvac-filter-bar" role="search" aria-label="Filter trainers and venues">
|
||||||
|
<div class="hvac-filter-bar-inner">
|
||||||
|
<!-- Search -->
|
||||||
|
<div class="hvac-filter-item hvac-filter-search">
|
||||||
|
<span class="dashicons dashicons-search" aria-hidden="true"></span>
|
||||||
|
<input type="text" id="hvac-training-search" placeholder="Search trainers..." aria-label="Search trainers">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Info Button -->
|
||||||
|
<button type="button" id="hvac-info-btn" class="hvac-info-btn" aria-label="How to use this page">
|
||||||
|
<span class="dashicons dashicons-info-outline" aria-hidden="true"></span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Filter Dropdowns (hidden on mobile, shown in panel) -->
|
||||||
|
<div class="hvac-filter-dropdowns">
|
||||||
|
<select id="hvac-filter-state" class="hvac-filter-select" aria-label="Filter by state">
|
||||||
|
<option value="">All States</option>
|
||||||
|
<?php foreach ($filter_options['states'] as $state): ?>
|
||||||
|
<option value="<?php echo esc_attr($state); ?>"><?php echo esc_html($state); ?></option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select id="hvac-filter-certification" class="hvac-filter-select" aria-label="Filter by certification">
|
||||||
|
<option value="">All Certifications</option>
|
||||||
|
<?php foreach ($filter_options['certifications'] as $cert): ?>
|
||||||
|
<option value="<?php echo esc_attr($cert); ?>"><?php echo esc_html($cert); ?></option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select id="hvac-filter-format" class="hvac-filter-select" aria-label="Filter by training format">
|
||||||
|
<option value="">All Formats</option>
|
||||||
|
<?php foreach ($filter_options['training_formats'] as $format): ?>
|
||||||
|
<option value="<?php echo esc_attr($format); ?>"><?php echo esc_html($format); ?></option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Near Me Button -->
|
||||||
|
<button type="button" id="hvac-near-me-btn" class="hvac-near-me-btn">
|
||||||
|
<span class="dashicons dashicons-location-alt" aria-hidden="true"></span>
|
||||||
|
<span class="hvac-btn-text">Near Me</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Include Past Events Checkbox -->
|
||||||
|
<label class="hvac-filter-checkbox">
|
||||||
|
<input type="checkbox" id="hvac-include-past">
|
||||||
|
<span>Include Past</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<!-- Clear Filters -->
|
||||||
|
<button type="button" class="hvac-clear-filters" style="display: none;">
|
||||||
|
Clear
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<!-- Mobile Filter Toggle -->
|
||||||
|
<button type="button"
|
||||||
|
class="hvac-mobile-filter-toggle"
|
||||||
|
aria-expanded="false"
|
||||||
|
aria-controls="hvac-mobile-filter-panel">
|
||||||
|
<span class="dashicons dashicons-filter" aria-hidden="true"></span>
|
||||||
|
<span class="hvac-btn-text">Filters</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Active Filters Chips -->
|
||||||
|
<div class="hvac-active-filters" aria-live="polite"></div>
|
||||||
|
|
||||||
|
<!-- Mobile Filter Panel (hidden by default) -->
|
||||||
|
<div id="hvac-mobile-filter-panel" class="hvac-mobile-filter-panel" hidden>
|
||||||
|
<div class="hvac-mobile-filter-group">
|
||||||
|
<label for="hvac-filter-state-mobile">State / Province</label>
|
||||||
|
<select id="hvac-filter-state-mobile" class="hvac-filter-select" aria-label="Filter by state">
|
||||||
|
<option value="">All States</option>
|
||||||
|
<?php foreach ($filter_options['states'] as $state): ?>
|
||||||
|
<option value="<?php echo esc_attr($state); ?>"><?php echo esc_html($state); ?></option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="hvac-mobile-filter-group">
|
||||||
|
<label for="hvac-filter-certification-mobile">Certification</label>
|
||||||
|
<select id="hvac-filter-certification-mobile" class="hvac-filter-select" aria-label="Filter by certification">
|
||||||
|
<option value="">All Certifications</option>
|
||||||
|
<?php foreach ($filter_options['certifications'] as $cert): ?>
|
||||||
|
<option value="<?php echo esc_attr($cert); ?>"><?php echo esc_html($cert); ?></option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="hvac-mobile-filter-group">
|
||||||
|
<label for="hvac-filter-format-mobile">Training Format</label>
|
||||||
|
<select id="hvac-filter-format-mobile" class="hvac-filter-select" aria-label="Filter by training format">
|
||||||
|
<option value="">All Formats</option>
|
||||||
|
<?php foreach ($filter_options['training_formats'] as $format): ?>
|
||||||
|
<option value="<?php echo esc_attr($format); ?>"><?php echo esc_html($format); ?></option>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="hvac-mobile-filter-group">
|
||||||
|
<label class="hvac-filter-checkbox">
|
||||||
|
<input type="checkbox" id="hvac-include-past-mobile">
|
||||||
|
<span>Include Past Events</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Main Content: Sidebar + Map -->
|
||||||
|
<div class="hvac-map-layout">
|
||||||
|
<!-- Left Sidebar -->
|
||||||
|
<aside class="hvac-sidebar" role="region" aria-label="Training directory">
|
||||||
|
<div class="hvac-sidebar-header">
|
||||||
|
<!-- Tab Navigation -->
|
||||||
|
<div class="hvac-sidebar-tabs" role="tablist" aria-label="Browse by category">
|
||||||
|
<button role="tab" class="hvac-tab active" data-tab="trainers" aria-selected="true" aria-controls="hvac-panel-trainers">
|
||||||
|
Trainers (<span data-count="trainers">0</span>)
|
||||||
|
</button>
|
||||||
|
<button role="tab" class="hvac-tab" data-tab="venues" aria-selected="false" aria-controls="hvac-panel-venues">
|
||||||
|
Venues (<span data-count="venues">0</span>)
|
||||||
|
</button>
|
||||||
|
<button role="tab" class="hvac-tab" data-tab="events" aria-selected="false" aria-controls="hvac-panel-events">
|
||||||
|
Events (<span data-count="events">0</span>)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Visibility Toggles (moved from map overlay) -->
|
||||||
|
<div class="hvac-visibility-toggles">
|
||||||
|
<label class="hvac-visibility-toggle" title="Show trainers on map">
|
||||||
|
<input type="checkbox" id="hvac-show-trainers" checked>
|
||||||
|
<span class="hvac-toggle-dot hvac-toggle-trainer"></span>
|
||||||
|
</label>
|
||||||
|
<label class="hvac-visibility-toggle" title="Show venues on map">
|
||||||
|
<input type="checkbox" id="hvac-show-venues" checked>
|
||||||
|
<span class="hvac-toggle-dot hvac-toggle-venue"></span>
|
||||||
|
</label>
|
||||||
|
<label class="hvac-visibility-toggle" title="Show events on map">
|
||||||
|
<input type="checkbox" id="hvac-show-events" checked>
|
||||||
|
<span class="hvac-toggle-dot hvac-toggle-event"></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Mobile collapse toggle -->
|
||||||
|
<button type="button"
|
||||||
|
class="hvac-sidebar-toggle"
|
||||||
|
aria-expanded="true"
|
||||||
|
aria-controls="hvac-sidebar-content"
|
||||||
|
aria-label="Toggle directory list">
|
||||||
|
<span class="dashicons dashicons-arrow-down-alt2" aria-hidden="true"></span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="hvac-sidebar-content" class="hvac-sidebar-content">
|
||||||
|
<!-- Trainers Panel -->
|
||||||
|
<div role="tabpanel" id="hvac-panel-trainers" class="hvac-tab-panel active" aria-labelledby="tab-trainers">
|
||||||
|
<div id="hvac-trainer-grid" class="hvac-item-list">
|
||||||
|
<div class="hvac-grid-loading">
|
||||||
|
<span class="dashicons dashicons-update-alt hvac-spin" aria-hidden="true"></span>
|
||||||
|
Loading trainers...
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Venues Panel -->
|
||||||
|
<div role="tabpanel" id="hvac-panel-venues" class="hvac-tab-panel" aria-labelledby="tab-venues" hidden>
|
||||||
|
<div id="hvac-venue-grid" class="hvac-item-list"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Events Panel -->
|
||||||
|
<div role="tabpanel" id="hvac-panel-events" class="hvac-tab-panel" aria-labelledby="tab-events" hidden>
|
||||||
|
<div id="hvac-event-grid" class="hvac-item-list"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Load More Button -->
|
||||||
|
<div class="hvac-load-more-wrapper" style="display: none;">
|
||||||
|
<button type="button" id="hvac-load-more" class="hvac-btn-secondary">
|
||||||
|
Load More
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- CTA -->
|
||||||
|
<div class="hvac-sidebar-cta">
|
||||||
|
<p>Want to be listed in our directory?</p>
|
||||||
|
<a href="<?php echo esc_url(site_url('/trainer/registration/')); ?>" class="hvac-btn-primary hvac-btn-small">
|
||||||
|
Become A Trainer
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- Map Container -->
|
||||||
|
<div class="hvac-map-container" role="region" aria-label="Training locations map">
|
||||||
|
<div id="hvac-training-map" class="hvac-google-map">
|
||||||
|
<?php if (!$api_key_configured): ?>
|
||||||
|
<div class="hvac-map-error">
|
||||||
|
<span class="dashicons dashicons-warning" aria-hidden="true"></span>
|
||||||
|
<p><strong>Map Configuration Required</strong></p>
|
||||||
|
<p>The Google Maps API key is not configured. <?php if (current_user_can('manage_options')): ?>Please configure it in <a href="<?php echo admin_url('admin.php?page=hvac-trainer-profile-settings'); ?>">Trainer Profile Settings</a>.<?php else: ?>Please contact the site administrator.<?php endif; ?></p>
|
||||||
|
</div>
|
||||||
|
<?php else: ?>
|
||||||
|
<div class="hvac-map-loading">
|
||||||
|
<span class="dashicons dashicons-location" aria-hidden="true"></span>
|
||||||
|
<p>Loading map...</p>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Map Legend Overlay -->
|
||||||
|
<div class="hvac-map-legend">
|
||||||
|
<div class="hvac-legend-item">
|
||||||
|
<span class="hvac-legend-marker hvac-legend-trainer" aria-hidden="true"></span>
|
||||||
|
<span>Trainer</span>
|
||||||
|
</div>
|
||||||
|
<div class="hvac-legend-item">
|
||||||
|
<span class="hvac-legend-marker hvac-legend-venue" aria-hidden="true"></span>
|
||||||
|
<span>Venue</span>
|
||||||
|
</div>
|
||||||
|
<div class="hvac-legend-item">
|
||||||
|
<span class="hvac-legend-marker hvac-legend-event" aria-hidden="true"></span>
|
||||||
|
<span>Event</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Trainer Profile Modal -->
|
||||||
|
<div id="hvac-trainer-modal" class="hvac-training-modal" style="display: none;" role="dialog" aria-modal="true" aria-labelledby="trainer-modal-title">
|
||||||
|
<div class="hvac-modal-overlay"></div>
|
||||||
|
<div class="hvac-modal-content">
|
||||||
|
<div class="hvac-modal-loading">
|
||||||
|
<span class="dashicons dashicons-update-alt hvac-spin" aria-hidden="true"></span>
|
||||||
|
Loading...
|
||||||
|
</div>
|
||||||
|
<div class="hvac-modal-body"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Venue Info Modal -->
|
||||||
|
<div id="hvac-venue-modal" class="hvac-training-modal" style="display: none;" role="dialog" aria-modal="true" aria-labelledby="venue-modal-title">
|
||||||
|
<div class="hvac-modal-overlay"></div>
|
||||||
|
<div class="hvac-modal-content hvac-venue-modal-content">
|
||||||
|
<div class="hvac-venue-modal-header">
|
||||||
|
<h2 id="venue-modal-title"></h2>
|
||||||
|
<button class="hvac-modal-close" aria-label="Close modal">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="hvac-venue-modal-body">
|
||||||
|
<p class="hvac-venue-address"></p>
|
||||||
|
<p class="hvac-venue-phone"></p>
|
||||||
|
<p class="hvac-venue-capacity"></p>
|
||||||
|
|
||||||
|
<div class="hvac-venue-description"></div>
|
||||||
|
|
||||||
|
<!-- Equipment Badges -->
|
||||||
|
<div class="hvac-venue-equipment" style="display: none;">
|
||||||
|
<h4>Equipment</h4>
|
||||||
|
<div class="hvac-badge-list hvac-equipment-badges"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Amenities Badges -->
|
||||||
|
<div class="hvac-venue-amenities" style="display: none;">
|
||||||
|
<h4>Amenities</h4>
|
||||||
|
<div class="hvac-badge-list hvac-amenities-badges"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Upcoming Events -->
|
||||||
|
<div class="hvac-venue-events">
|
||||||
|
<h4>Upcoming Events</h4>
|
||||||
|
<ul class="hvac-venue-events-list"></ul>
|
||||||
|
<p class="hvac-venue-no-events" style="display: none;">No upcoming events scheduled.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Actions -->
|
||||||
|
<div class="hvac-venue-actions">
|
||||||
|
<a href="#" class="hvac-venue-directions hvac-btn-secondary" target="_blank" rel="noopener">
|
||||||
|
<span class="dashicons dashicons-location" aria-hidden="true"></span>
|
||||||
|
Get Directions
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Contact Form -->
|
||||||
|
<div class="hvac-venue-contact-section">
|
||||||
|
<h4>Contact This Training Lab</h4>
|
||||||
|
<form class="hvac-venue-contact-form" data-venue-id="">
|
||||||
|
<div class="hvac-form-row">
|
||||||
|
<div class="hvac-form-group">
|
||||||
|
<label for="venue-contact-first-name">First Name <span class="required">*</span></label>
|
||||||
|
<input type="text" id="venue-contact-first-name" name="first_name" required>
|
||||||
|
</div>
|
||||||
|
<div class="hvac-form-group">
|
||||||
|
<label for="venue-contact-last-name">Last Name <span class="required">*</span></label>
|
||||||
|
<input type="text" id="venue-contact-last-name" name="last_name" required>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="hvac-form-row">
|
||||||
|
<div class="hvac-form-group">
|
||||||
|
<label for="venue-contact-email">Email <span class="required">*</span></label>
|
||||||
|
<input type="email" id="venue-contact-email" name="email" required>
|
||||||
|
</div>
|
||||||
|
<div class="hvac-form-group">
|
||||||
|
<label for="venue-contact-phone">Phone</label>
|
||||||
|
<input type="tel" id="venue-contact-phone" name="phone">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="hvac-form-group">
|
||||||
|
<label for="venue-contact-company">Company</label>
|
||||||
|
<input type="text" id="venue-contact-company" name="company">
|
||||||
|
</div>
|
||||||
|
<div class="hvac-form-group">
|
||||||
|
<label for="venue-contact-message">Message</label>
|
||||||
|
<textarea id="venue-contact-message" name="message" rows="4" placeholder="Tell us about your training needs..."></textarea>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="hvac-btn-primary">Send Message</button>
|
||||||
|
</form>
|
||||||
|
<div class="hvac-form-success" style="display: none;">
|
||||||
|
<span class="dashicons dashicons-yes-alt" aria-hidden="true"></span>
|
||||||
|
<p>Your message has been sent! The training lab will be in touch soon.</p>
|
||||||
|
</div>
|
||||||
|
<div class="hvac-form-error" style="display: none;">
|
||||||
|
<span class="dashicons dashicons-warning" aria-hidden="true"></span>
|
||||||
|
<p>There was a problem sending your message. Please try again.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Info Modal -->
|
||||||
|
<div id="hvac-info-modal" class="hvac-training-modal" style="display: none;" role="dialog" aria-modal="true" aria-labelledby="info-modal-title">
|
||||||
|
<div class="hvac-modal-overlay"></div>
|
||||||
|
<div class="hvac-modal-content hvac-info-modal-content">
|
||||||
|
<div class="hvac-info-modal-header">
|
||||||
|
<h2 id="info-modal-title">Find Training Near You</h2>
|
||||||
|
<button class="hvac-modal-close" aria-label="Close modal">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="hvac-info-modal-body">
|
||||||
|
<section class="hvac-info-section">
|
||||||
|
<h3>What is Upskill HVAC?</h3>
|
||||||
|
<p>Upskill HVAC is a community of certified trainers and training facilities dedicated to advancing skills in the HVAC industry. Our platform connects technicians with professional training opportunities across the country.</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="hvac-info-section">
|
||||||
|
<h3>How to Use This Page</h3>
|
||||||
|
<ul class="hvac-info-list">
|
||||||
|
<li><strong>Browse by Category:</strong> Use the tabs above the list to switch between Trainers, Venues, and Events</li>
|
||||||
|
<li><strong>Search:</strong> Type in the search bar to filter results within the current tab</li>
|
||||||
|
<li><strong>Filter:</strong> Use the dropdown filters to narrow by state, certification, or format</li>
|
||||||
|
<li><strong>Near Me:</strong> Click the "Near Me" button to find training options close to your location</li>
|
||||||
|
<li><strong>Map Markers:</strong> Click on any marker to see details, or hover to preview</li>
|
||||||
|
<li><strong>Toggle Visibility:</strong> Use the colored dots in the header to show/hide marker types on the map</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="hvac-info-section">
|
||||||
|
<h3>Map Legend</h3>
|
||||||
|
<div class="hvac-info-legend">
|
||||||
|
<div class="hvac-info-legend-item">
|
||||||
|
<span class="hvac-legend-marker hvac-legend-trainer"></span>
|
||||||
|
<div>
|
||||||
|
<strong>Trainer</strong>
|
||||||
|
<p>Certified HVAC trainers who conduct training sessions</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="hvac-info-legend-item">
|
||||||
|
<span class="hvac-legend-marker hvac-legend-venue"></span>
|
||||||
|
<div>
|
||||||
|
<strong>Training Lab</strong>
|
||||||
|
<p>measureQuick Approved facilities with professional equipment</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="hvac-info-legend-item">
|
||||||
|
<span class="hvac-legend-marker hvac-legend-event"></span>
|
||||||
|
<div>
|
||||||
|
<strong>Event</strong>
|
||||||
|
<p>Scheduled training sessions you can register for</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php get_footer(); ?>
|
||||||
Loading…
Reference in a new issue