Compare commits

...

46 commits

Author SHA1 Message Date
bengizmo
82596db528 Update local Claude settings
Some checks failed
HVAC Plugin CI/CD Pipeline / Security Analysis (push) Has been cancelled
HVAC Plugin CI/CD Pipeline / Code Quality & Standards (push) Has been cancelled
HVAC Plugin CI/CD Pipeline / Unit Tests (push) Has been cancelled
Security Monitoring & Compliance / Secrets & Credential Scan (push) Has been cancelled
Security Monitoring & Compliance / WordPress Security Analysis (push) Has been cancelled
HVAC Plugin CI/CD Pipeline / Integration Tests (push) Has been cancelled
Security Monitoring & Compliance / Dependency Vulnerability Scan (push) Has been cancelled
Security Monitoring & Compliance / Static Code Security Analysis (push) Has been cancelled
Security Monitoring & Compliance / Security Compliance Validation (push) Has been cancelled
HVAC Plugin CI/CD Pipeline / Deploy to Staging (push) Has been cancelled
HVAC Plugin CI/CD Pipeline / Deploy to Production (push) Has been cancelled
HVAC Plugin CI/CD Pipeline / Notification (push) Has been cancelled
Security Monitoring & Compliance / Security Summary Report (push) Has been cancelled
Security Monitoring & Compliance / Security Team Notification (push) Has been cancelled
2026-03-24 11:16:55 -03:00
ben
cb68c9a5bf docs: Add AI assistant tools and multi-model workflow docs to CLAUDE.md
Some checks failed
HVAC Plugin CI/CD Pipeline / Security Analysis (push) Has been cancelled
HVAC Plugin CI/CD Pipeline / Code Quality & Standards (push) Has been cancelled
HVAC Plugin CI/CD Pipeline / Unit Tests (push) Has been cancelled
HVAC Plugin CI/CD Pipeline / Integration Tests (push) Has been cancelled
Security Monitoring & Compliance / Dependency Vulnerability Scan (push) Has been cancelled
Security Monitoring & Compliance / Secrets & Credential Scan (push) Has been cancelled
Security Monitoring & Compliance / WordPress Security Analysis (push) Has been cancelled
Security Monitoring & Compliance / Static Code Security Analysis (push) Has been cancelled
Security Monitoring & Compliance / Security Compliance Validation (push) Has been cancelled
HVAC Plugin CI/CD Pipeline / Deploy to Staging (push) Has been cancelled
HVAC Plugin CI/CD Pipeline / Deploy to Production (push) Has been cancelled
HVAC Plugin CI/CD Pipeline / Notification (push) Has been cancelled
Security Monitoring & Compliance / Security Summary Report (push) Has been cancelled
Security Monitoring & Compliance / Security Team Notification (push) Has been cancelled
Add Zen MCP tools usage guide and Clink multi-model workflow
documentation (GPT-5, Gemini 3, Kimi K2.5) with security warnings,
synthesis format, and quick usage examples. Update local settings.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 13:50:57 -04:00
ben
ca928bfffb feat: Staging email filter, venue geocoding, MapGeo improvements, trainers overview
Accumulated changes from previous sessions (Feb 9-20):
- Staging email filter to prevent test emails reaching real users
- Version bump to 2.2.0 in plugin header
- Venue geocoding enhancements and batch processing
- Find Training page improvements (tab content, map data)
- MapGeo integration hardening for Find a Trainer
- Master trainers overview table improvements
- AJAX handler additions for venue categories
- SVG marker icon tweaks (trainer, venue)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 13:50:51 -04:00
ben
25bf5d98e1 feat(slack): Add Slack notifications for registrations, tickets, and events
Instant Slack alerts via Incoming Webhook with Block Kit rich formatting:
- New trainer registrations (name, role, org, business type, photo)
- Ticket purchases (purchaser, event, count, total, gateway)
- Events submitted by trainers via TEC Community Events form
- Events published by admins (draft/pending → publish)

Settings UI with webhook URL field, validation, and test button.
Non-blocking sends so Slack failures never affect user flows.
Atomic add_post_meta idempotency guards prevent duplicate sends.
Code reviewed by GPT-5 (Codex) — all 5 findings addressed.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 13:50:08 -04:00
ben
8adc3ac8e4 fix(registration): Remove hardcoded page IDs causing login redirect
Some checks are pending
HVAC Plugin CI/CD Pipeline / Security Analysis (push) Waiting to run
HVAC Plugin CI/CD Pipeline / Code Quality & Standards (push) Waiting to run
HVAC Plugin CI/CD Pipeline / Unit Tests (push) Waiting to run
HVAC Plugin CI/CD Pipeline / Integration Tests (push) Waiting to run
HVAC Plugin CI/CD Pipeline / Deploy to Staging (push) Blocked by required conditions
HVAC Plugin CI/CD Pipeline / Deploy to Production (push) Blocked by required conditions
HVAC Plugin CI/CD Pipeline / Notification (push) Blocked by required conditions
Security Monitoring & Compliance / Security Compliance Validation (push) Waiting to run
Security Monitoring & Compliance / Dependency Vulnerability Scan (push) Waiting to run
Security Monitoring & Compliance / Secrets & Credential Scan (push) Waiting to run
Security Monitoring & Compliance / WordPress Security Analysis (push) Waiting to run
Security Monitoring & Compliance / Static Code Security Analysis (push) Waiting to run
Security Monitoring & Compliance / Security Summary Report (push) Blocked by required conditions
Security Monitoring & Compliance / Security Team Notification (push) Blocked by required conditions
The registration page (ID 5334 on staging) was incorrectly matched by
HVAC_Event_Manager::isManagePage() which had is_page(5334) as a legacy
check. This caused non-logged-in users to be redirected to the login
page instead of seeing the public registration form. Removed hardcoded
page IDs from isManagePage() and isEditPage() in favor of URL/slug
detection which is environment-independent.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 12:42:34 -04:00
bengizmo
4d986714b6 fix(login): Point register link to trainer registration page
Some checks are pending
HVAC Plugin CI/CD Pipeline / Security Analysis (push) Waiting to run
HVAC Plugin CI/CD Pipeline / Code Quality & Standards (push) Waiting to run
HVAC Plugin CI/CD Pipeline / Unit Tests (push) Waiting to run
HVAC Plugin CI/CD Pipeline / Integration Tests (push) Waiting to run
HVAC Plugin CI/CD Pipeline / Deploy to Staging (push) Blocked by required conditions
HVAC Plugin CI/CD Pipeline / Deploy to Production (push) Blocked by required conditions
HVAC Plugin CI/CD Pipeline / Notification (push) Blocked by required conditions
Security Monitoring & Compliance / Dependency Vulnerability Scan (push) Waiting to run
Security Monitoring & Compliance / Secrets & Credential Scan (push) Waiting to run
Security Monitoring & Compliance / WordPress Security Analysis (push) Waiting to run
Security Monitoring & Compliance / Static Code Security Analysis (push) Waiting to run
Security Monitoring & Compliance / Security Compliance Validation (push) Waiting to run
Security Monitoring & Compliance / Security Summary Report (push) Blocked by required conditions
Security Monitoring & Compliance / Security Team Notification (push) Blocked by required conditions
Co-Authored-By: Ben Reed <ben@tealmaker.com>
2026-02-20 12:23:22 -04:00
ben
0b033f7f4f docs: condense Status.md from 1348 to 131 lines
Some checks are pending
HVAC Plugin CI/CD Pipeline / Security Analysis (push) Waiting to run
HVAC Plugin CI/CD Pipeline / Code Quality & Standards (push) Waiting to run
HVAC Plugin CI/CD Pipeline / Unit Tests (push) Waiting to run
HVAC Plugin CI/CD Pipeline / Integration Tests (push) Waiting to run
HVAC Plugin CI/CD Pipeline / Deploy to Staging (push) Blocked by required conditions
HVAC Plugin CI/CD Pipeline / Deploy to Production (push) Blocked by required conditions
HVAC Plugin CI/CD Pipeline / Notification (push) Blocked by required conditions
Security Monitoring & Compliance / Secrets & Credential Scan (push) Waiting to run
Security Monitoring & Compliance / WordPress Security Analysis (push) Waiting to run
Security Monitoring & Compliance / Dependency Vulnerability Scan (push) Waiting to run
Security Monitoring & Compliance / Static Code Security Analysis (push) Waiting to run
Security Monitoring & Compliance / Security Compliance Validation (push) Waiting to run
Security Monitoring & Compliance / Security Summary Report (push) Blocked by required conditions
Security Monitoring & Compliance / Security Team Notification (push) Blocked by required conditions
Compress previous session entries to one-line summaries, remove
duplicate info already in CLAUDE.md (guidelines, architecture, testing).
Keep current session detail and previous session git hashes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 19:27:47 -04:00
ben
95382ac3a3 feat(find-training): Refactor marker visibility dots into inline tab checkboxes
Replace standalone colored dot toggles with custom checkboxes inline
beside each category tab heading (Events, Trainers, Venues). Version
bump 2.2.17 → 2.2.18 for CDN cache busting.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 19:19:58 -04:00
ben
9dbe472c45 feat(find-training): Reorder tabs (Events first), add marker highlighting and map reset
- Move Events tab to first/default position, update search placeholder and
  skip link to match
- Add highlighted icon variants for all marker types (larger scale, brighter
  stroke, higher zIndex) that activate when their category tab is selected
- Add reset map button (bottom-right) to restore original zoom/bounds
- Fix mobile sidebar overflow caused by long event titles pushing beyond
  viewport (overflow-x: hidden, overflow-y: visible)
- Bump version to 2.2.17

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 18:53:44 -04:00
ben
4104c80669 fix(find-training): Fix mobile scrolling on Find Training page
On mobile, the page used overflow:hidden + height:100vh with nested
scrolling in a 158px sidebar area, making it impossible to scroll
through trainer/venue/event cards. Switched mobile layout to natural
page scrolling with sticky filter bar. Desktop layout unchanged.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 18:01:03 -04:00
ben
f123c7a513 fix(find-training): Fix map tile drift at low zoom and event cost HTML entities
Add minZoom: 3 to prevent Google Maps WebGL tile/marker drift caused by
viewport width mismatch in CSS Grid layouts. Fix event cost displaying
&#x24; instead of $ by decoding HTML entities before passing to JS.
Bump version to 2.2.13 for CDN cache busting.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-09 17:32:17 -04:00
ben
83ef8f7463 docs: mark Zoho sync production deployment in Status.md
Some checks failed
HVAC Plugin CI/CD Pipeline / Deploy to Staging (push) Has been cancelled
HVAC Plugin CI/CD Pipeline / Security Analysis (push) Has been cancelled
HVAC Plugin CI/CD Pipeline / Code Quality & Standards (push) Has been cancelled
HVAC Plugin CI/CD Pipeline / Unit Tests (push) Has been cancelled
HVAC Plugin CI/CD Pipeline / Integration Tests (push) Has been cancelled
HVAC Plugin CI/CD Pipeline / Deploy to Production (push) Has been cancelled
HVAC Plugin CI/CD Pipeline / Notification (push) Has been cancelled
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-07 18:47:01 -04:00
ben
7b895ad785 docs: update Status.md with Zoho user sync production fixes
Some checks failed
HVAC Plugin CI/CD Pipeline / Security Analysis (push) Has been cancelled
HVAC Plugin CI/CD Pipeline / Code Quality & Standards (push) Has been cancelled
HVAC Plugin CI/CD Pipeline / Unit Tests (push) Has been cancelled
HVAC Plugin CI/CD Pipeline / Integration Tests (push) Has been cancelled
Security Monitoring & Compliance / Dependency Vulnerability Scan (push) Has been cancelled
Security Monitoring & Compliance / Secrets & Credential Scan (push) Has been cancelled
Security Monitoring & Compliance / WordPress Security Analysis (push) Has been cancelled
Security Monitoring & Compliance / Static Code Security Analysis (push) Has been cancelled
Security Monitoring & Compliance / Security Compliance Validation (push) Has been cancelled
HVAC Plugin CI/CD Pipeline / Deploy to Staging (push) Has been cancelled
HVAC Plugin CI/CD Pipeline / Deploy to Production (push) Has been cancelled
HVAC Plugin CI/CD Pipeline / Notification (push) Has been cancelled
Security Monitoring & Compliance / Security Summary Report (push) Has been cancelled
Security Monitoring & Compliance / Security Team Notification (push) Has been cancelled
Documents the iterative debugging of user sync on production:
version constant mismatch, GET search criteria fix, error reporting
priority, phone/name validation, and spam account cleanup.
Final result: 64/64 active trainers syncing to Zoho CRM.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-06 12:58:24 -04:00
ben
4c22b9db8e fix(zoho): Fix user sync search criteria and improve data validation
- Fix sync_users search: pass criteria in URL query string instead of
  as data parameter (GET requests ignore body data), which caused every
  user search to fail and fall through to create
- Improve validate_api_response to check Zoho-specific error codes
  before generic HTTP errors, and include field-level detail in messages
- Add Last_Name fallback to display_name/username when meta is empty
- Sanitize Phone to digits-only, require 10+ digits, omit if invalid
- Bump HVAC_PLUGIN_VERSION to 2.2.11 to bust browser cache

Result: 65/65 trainers now sync successfully (was 0/65).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-06 12:39:20 -04:00
ben
03b9bce52d fix(zoho): Fix silent sync failures with API response validation and hash reset
Zoho CRM sync appeared connected but silently failed to write data due to
unvalidated API responses. Sync methods now validate Zoho responses before
updating hashes, ensuring failed records re-sync on next run. Also fixes
staging detection to use wp_parse_url hostname parsing instead of fragile
strpos matching, adds admin UI for resetting sync hashes, and bumps
HVAC_PLUGIN_VERSION to 2.2.11 to bust browser cache for updated JS.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-06 11:25:26 -04:00
ben
4b53d3eab6 fix(find-training): Fix Near Me button mobile layout and add empty results notification
- Fixed CSS bug where Near Me button HTML was replaced without .hvac-btn-text
  wrapper class, causing layout issues on mobile when text became visible
- Applied fix to all 5 locations: loading state, success state, error reset,
  clear filters, and remove location filter
- Added notification when Near Me filter returns no results within 100km radius
  to improve UX feedback

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-06 10:43:11 -04:00
ben
fcd55fd164 feat(find-training): Differentiate measureQuick Certified Champions from Trainers
Champions are identified by "Certified measureQuick Champion" certification.
Unlike Trainers, Champions do not offer public training, so they display
differently:

- White marker outline (vs green for Trainers)
- Show only state, not city, in sidebar and info windows
- No "View Profile" button or modal popup on click
- Sorted to end of trainer list (after all Trainers)
- Non-clickable card styling

Code review fixes (Gemini 3):
- Fixed location formatting to handle empty city gracefully
- Added secondary sort by name for stable ordering

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 02:01:15 -04:00
ben
d2a43bfd9b docs: update Status.md with tabbed interface implementation
- Document Trainers/Venues/Events tab navigation
- Document venue cards, event cards, info modal
- Document CDN cache issue and version bump fix
- Add CAPTCHA implementation as planned next session task

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 19:56:28 -04:00
ben
ea3031528e chore: bump HVAC_VERSION to 2.2.6 for CDN cache bust
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 19:48:35 -04:00
ben
17dd3c9bdb feat(find-training): Add tabbed interface for Trainers, Venues, and Events
Replace single trainer list with a tabbed sidebar interface:
- Three tabs with dynamic counts for each category
- Venue cards with icon, name, location, and upcoming events count
- Event cards with date badge, title, venue, and cost
- Visibility toggles moved from map overlay to sidebar header
- Context-aware search placeholder based on active tab
- Client-side filtering for instant search results
- Info button and modal with usage instructions and map legend
- Keyboard navigation for tabs (arrow keys)
- All lists sync with map viewport on pan/zoom

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 19:36:17 -04:00
ben
5c15b27935 feat(find-training): measureQuick Approved Training Labs implementation
Add venue taxonomies and filter /find-training to show only approved labs:

- Create venue_type, venue_equipment, venue_amenities taxonomies
- Filter venue markers by mq-approved-lab taxonomy term
- Add equipment and amenities badges to venue modal
- Add venue contact form with AJAX handler and email notification
- Include POC (Point of Contact) meta for each training lab

9 approved training labs configured:
- Fast Track Learning Lab, Progressive Training Lab, NAVAC Technical Training Center
- Stevens Equipment Phoenix/Johnstown, San Jacinto College, Johnstone Supply
- TruTech Tools Training Center (new), Auer Steel & Heating Supply (new)

Note: Venues not displaying on map - to be debugged next session.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 13:36:06 -04:00
ben
19147d978e feat(find-training): Add viewport sync and marker hover interactions
- Add viewport sync: sidebar shows only trainers visible in map area
- Add mouseover event on markers showing info window on hover
- Set optimized:false on markers for reliable hover events
- Add legacy URL redirects (/find-a-trainer → /find-training)
- Remove deprecated find-a-trainer page from Page Manager
- Update Status.md with session changes
- Bump version to 2.2.4

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 11:42:45 -04:00
ben
21c908af81 feat(find-training): New Google Maps page replacing buggy MapGeo implementation
Implements /find-training page with Google Maps JavaScript API:
- Interactive map showing trainers (teal) and venues (orange) markers
- MarkerClusterer for dense areas
- Filter by State, Certification, Training Format
- Search by name/location
- "Near Me" geolocation with proximity filtering
- Trainer profile modal with contact form
- Venue info modal with upcoming events
- 301 redirect from /find-a-trainer to /find-training
- Auto-geocoding for new TEC venues via Google API

Multi-model code review fixes (GPT-5, Gemini 3, Zen MCP):
- Added missing contact form AJAX handler with rate limiting
- Fixed XSS risk in InfoWindow (DOM creation vs inline onclick)
- Added caching for filter dropdown queries (1-hour TTL)
- Added AJAX abort handling to prevent race conditions
- Replaced alert() with inline error notifications

New files:
- includes/find-training/class-hvac-find-training-page.php
- includes/find-training/class-hvac-training-map-data.php
- includes/find-training/class-hvac-venue-geocoding.php
- templates/page-find-training.php
- assets/js/find-training-map.js
- assets/js/find-training-filters.js
- assets/css/find-training-map.css
- assets/images/marker-trainer.svg
- assets/images/marker-venue.svg

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 23:20:34 -04:00
ben
9f4667fbb4 fix(security): Multi-model code review - 12 security and architecture fixes
Comprehensive code review using GPT-5, Gemini 3, Kimi K2.5, and Zen MCP tools
across 11 critical files (~9,000 lines). Identified and fixed issues by
consensus prioritization.

CRITICAL fixes:
- Strip passwords from transients in registration error handling
- Rewrite O(3600) token verification loop to O(1) with embedded timestamp

HIGH fixes:
- Replace remove_all_actions() with targeted hook removal (breaks WP isolation)
- Prefer wp-config.php constant for encryption key storage
- Add revocation check before generating certificate download URLs
- Fix security headers condition to apply to AJAX requests
- Add zoho-config.php to .gitignore

MEDIUM fixes:
- IP spoofing: only trust proxy headers when behind configured trusted proxies
- Remove unsafe-eval from CSP (keep unsafe-inline for compatibility)
- Remove duplicate Master Trainer component initialization
- Remove file-scope side-effect initialization in profile manager
- Use WordPress current_time() for consistent timezone in cert numbers

Validated as non-issues:
- Path traversal (token-based system prevents)
- SQL injection (proper $wpdb->prepare throughout)
- OAuth CSRF (correctly implemented with hash_equals)

All 7 modified PHP files pass syntax validation (php -l).

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 20:06:43 -04:00
ben
23dcd158ec feat(master-trainer): Enhance profile edit page with all fields and password reset
Some checks failed
HVAC Plugin CI/CD Pipeline / Security Analysis (push) Has been cancelled
HVAC Plugin CI/CD Pipeline / Code Quality & Standards (push) Has been cancelled
HVAC Plugin CI/CD Pipeline / Unit Tests (push) Has been cancelled
HVAC Plugin CI/CD Pipeline / Integration Tests (push) Has been cancelled
Security Monitoring & Compliance / Dependency Vulnerability Scan (push) Has been cancelled
Security Monitoring & Compliance / Secrets & Credential Scan (push) Has been cancelled
Security Monitoring & Compliance / WordPress Security Analysis (push) Has been cancelled
Security Monitoring & Compliance / Static Code Security Analysis (push) Has been cancelled
Security Monitoring & Compliance / Security Compliance Validation (push) Has been cancelled
HVAC Plugin CI/CD Pipeline / Deploy to Staging (push) Has been cancelled
HVAC Plugin CI/CD Pipeline / Deploy to Production (push) Has been cancelled
HVAC Plugin CI/CD Pipeline / Notification (push) Has been cancelled
Security Monitoring & Compliance / Security Summary Report (push) Has been cancelled
Security Monitoring & Compliance / Security Team Notification (push) Has been cancelled
- Fix button styling with scoped CSS to avoid theme conflicts
- Add all trainer profile fields (6 sections: Profile Settings, Certification,
  Personal Info, Professional Info, Business Info, Location)
- Add "Send Password Reset Email" button for master trainers
- Add AJAX handler for secure password reset functionality
- Update Status.md with session details

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-10 16:21:07 -04:00
Ben
503932e0c7 fix(master-trainer): Resolve 500 error on edit-trainer-profile page
Some checks are pending
Security Monitoring & Compliance / Security Team Notification (push) Blocked by required conditions
HVAC Plugin CI/CD Pipeline / Security Analysis (push) Waiting to run
HVAC Plugin CI/CD Pipeline / Code Quality & Standards (push) Waiting to run
HVAC Plugin CI/CD Pipeline / Unit Tests (push) Waiting to run
HVAC Plugin CI/CD Pipeline / Integration Tests (push) Waiting to run
HVAC Plugin CI/CD Pipeline / Deploy to Staging (push) Blocked by required conditions
HVAC Plugin CI/CD Pipeline / Deploy to Production (push) Blocked by required conditions
HVAC Plugin CI/CD Pipeline / Notification (push) Blocked by required conditions
Security Monitoring & Compliance / Secrets & Credential Scan (push) Waiting to run
Security Monitoring & Compliance / WordPress Security Analysis (push) Waiting to run
Security Monitoring & Compliance / Dependency Vulnerability Scan (push) Waiting to run
Security Monitoring & Compliance / Static Code Security Analysis (push) Waiting to run
Security Monitoring & Compliance / Security Compliance Validation (push) Waiting to run
Security Monitoring & Compliance / Security Summary Report (push) Blocked by required conditions
Changed menu system call from non-existent method to correct one:
- HVAC_Menu_System::instance()->render_master_trainer_menu()
+ HVAC_Master_Menu_System::instance()->render_master_menu()

Fixes fatal PHP error when accessing /master-trainer/edit-trainer-profile/

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 20:27:34 -04:00
Ben
3136b96d3f docs: Streamline CLAUDE.md and update Status.md with TEC CE analysis
Some checks are pending
HVAC Plugin CI/CD Pipeline / Security Analysis (push) Waiting to run
HVAC Plugin CI/CD Pipeline / Code Quality & Standards (push) Waiting to run
HVAC Plugin CI/CD Pipeline / Unit Tests (push) Waiting to run
HVAC Plugin CI/CD Pipeline / Integration Tests (push) Waiting to run
HVAC Plugin CI/CD Pipeline / Deploy to Staging (push) Blocked by required conditions
HVAC Plugin CI/CD Pipeline / Deploy to Production (push) Blocked by required conditions
HVAC Plugin CI/CD Pipeline / Notification (push) Blocked by required conditions
- Simplify CLAUDE.md to focus on essential development guidance
- Remove redundant agent workflow documentation
- Add Technical Debt section documenting TEC Community Events dependency
- Update Status.md with completed TEC CE dependency analysis session

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-09 20:04:11 -04:00
Ben
3d66756715 fix(master-dashboard): Resolve trainers table AJAX loading error
Some checks failed
HVAC Plugin CI/CD Pipeline / Security Analysis (push) Has been cancelled
HVAC Plugin CI/CD Pipeline / Code Quality & Standards (push) Has been cancelled
HVAC Plugin CI/CD Pipeline / Unit Tests (push) Has been cancelled
HVAC Plugin CI/CD Pipeline / Integration Tests (push) Has been cancelled
Security Monitoring & Compliance / Dependency Vulnerability Scan (push) Has been cancelled
Security Monitoring & Compliance / Secrets & Credential Scan (push) Has been cancelled
Security Monitoring & Compliance / WordPress Security Analysis (push) Has been cancelled
Security Monitoring & Compliance / Static Code Security Analysis (push) Has been cancelled
Security Monitoring & Compliance / Security Compliance Validation (push) Has been cancelled
HVAC Plugin CI/CD Pipeline / Deploy to Staging (push) Has been cancelled
HVAC Plugin CI/CD Pipeline / Deploy to Production (push) Has been cancelled
HVAC Plugin CI/CD Pipeline / Notification (push) Has been cancelled
Security Monitoring & Compliance / Security Summary Report (push) Has been cancelled
Security Monitoring & Compliance / Security Team Notification (push) Has been cancelled
- Initialize HVAC_Master_Dashboard_Data during plugin init to ensure
  AJAX handler is registered for all requests (not just template loads)
- Accept both hvac_ajax_nonce and hvac_master_dashboard_nonce for
  backward compatibility with existing JavaScript

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 11:10:34 -04:00
ben
8a8f1d78df fix(find-trainer): Implement Strategy H JavaScript interceptor for map marker repair
Some checks are pending
HVAC Plugin CI/CD Pipeline / Deploy to Production (push) Blocked by required conditions
HVAC Plugin CI/CD Pipeline / Notification (push) Blocked by required conditions
HVAC Plugin CI/CD Pipeline / Security Analysis (push) Waiting to run
HVAC Plugin CI/CD Pipeline / Code Quality & Standards (push) Waiting to run
HVAC Plugin CI/CD Pipeline / Unit Tests (push) Waiting to run
HVAC Plugin CI/CD Pipeline / Integration Tests (push) Waiting to run
HVAC Plugin CI/CD Pipeline / Deploy to Staging (push) Blocked by required conditions
Security Monitoring & Compliance / Dependency Vulnerability Scan (push) Waiting to run
Security Monitoring & Compliance / Secrets & Credential Scan (push) Waiting to run
Security Monitoring & Compliance / WordPress Security Analysis (push) Waiting to run
Security Monitoring & Compliance / Static Code Security Analysis (push) Waiting to run
Security Monitoring & Compliance / Security Compliance Validation (push) Waiting to run
Security Monitoring & Compliance / Security Summary Report (push) Blocked by required conditions
Security Monitoring & Compliance / Security Team Notification (push) Blocked by required conditions
- Add Object.defineProperty interceptor to catch iMapsData assignment before IGM plugin corrupts it
- Detect and repair markers with corrupted coordinates (Lat == Lng) using backup lat/lng keys
- Remove PHP query injections that caused 500 errors
- Increase safety timeouts from 6s to 30s for slower resource loading
- Remove Safari blocker bug in find-trainer assets
- Update debug script for mapgeo integration testing

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 13:12:46 -04:00
ben
1526d9f23b feat(zoho): Add hash-based change detection to prevent re-syncing unchanged records
Some checks failed
HVAC Plugin CI/CD Pipeline / Security Analysis (push) Has been cancelled
HVAC Plugin CI/CD Pipeline / Code Quality & Standards (push) Has been cancelled
HVAC Plugin CI/CD Pipeline / Unit Tests (push) Has been cancelled
HVAC Plugin CI/CD Pipeline / Integration Tests (push) Has been cancelled
Security Monitoring & Compliance / Dependency Vulnerability Scan (push) Has been cancelled
Security Monitoring & Compliance / Secrets & Credential Scan (push) Has been cancelled
Security Monitoring & Compliance / WordPress Security Analysis (push) Has been cancelled
Security Monitoring & Compliance / Static Code Security Analysis (push) Has been cancelled
Security Monitoring & Compliance / Security Compliance Validation (push) Has been cancelled
HVAC Plugin CI/CD Pipeline / Deploy to Staging (push) Has been cancelled
HVAC Plugin CI/CD Pipeline / Deploy to Production (push) Has been cancelled
HVAC Plugin CI/CD Pipeline / Notification (push) Has been cancelled
Security Monitoring & Compliance / Security Summary Report (push) Has been cancelled
Security Monitoring & Compliance / Security Team Notification (push) Has been cancelled
- Add generate_sync_hash(), should_sync(), and should_sync_user() helper methods
- Modify all 5 sync methods to check hashes before syncing
- Add 'skipped' count to track unchanged records
- Update scheduled sync to aggregate and log skipped counts

This fixes the issue where 59 items were synced every scheduled run even
when no WordPress records had changed.
2025-12-23 16:15:15 -04:00
ben
f464224cd8 Fix find trainer map: remove safari blocker, largely increase safety timeouts, update status 2025-12-20 11:26:59 -04:00
ben
6d4bdc2f95 docs: Update Status.md with scheduled sync persistence fix
Some checks failed
HVAC Plugin CI/CD Pipeline / Code Quality & Standards (push) Has been cancelled
HVAC Plugin CI/CD Pipeline / Unit Tests (push) Has been cancelled
HVAC Plugin CI/CD Pipeline / Security Analysis (push) Has been cancelled
HVAC Plugin CI/CD Pipeline / Integration Tests (push) Has been cancelled
HVAC Plugin CI/CD Pipeline / Deploy to Staging (push) Has been cancelled
HVAC Plugin CI/CD Pipeline / Deploy to Production (push) Has been cancelled
HVAC Plugin CI/CD Pipeline / Notification (push) Has been cancelled
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-20 09:41:24 -04:00
ben
4fc6676e0c fix: Zoho scheduled sync persistence issue
Some checks failed
HVAC Plugin CI/CD Pipeline / Security Analysis (push) Waiting to run
HVAC Plugin CI/CD Pipeline / Code Quality & Standards (push) Waiting to run
HVAC Plugin CI/CD Pipeline / Unit Tests (push) Waiting to run
HVAC Plugin CI/CD Pipeline / Integration Tests (push) Waiting to run
HVAC Plugin CI/CD Pipeline / Deploy to Staging (push) Blocked by required conditions
HVAC Plugin CI/CD Pipeline / Deploy to Production (push) Blocked by required conditions
HVAC Plugin CI/CD Pipeline / Notification (push) Blocked by required conditions
Security Monitoring & Compliance / Dependency Vulnerability Scan (push) Has been cancelled
Security Monitoring & Compliance / Secrets & Credential Scan (push) Has been cancelled
Security Monitoring & Compliance / WordPress Security Analysis (push) Has been cancelled
Security Monitoring & Compliance / Static Code Security Analysis (push) Has been cancelled
Security Monitoring & Compliance / Security Compliance Validation (push) Has been cancelled
Security Monitoring & Compliance / Security Summary Report (push) Has been cancelled
Security Monitoring & Compliance / Security Team Notification (push) Has been cancelled
- Load HVAC_Zoho_Scheduled_Sync on ALL requests (not just admin)
  so WP-Cron can find custom schedules and action hooks
- Add add_option hook for first-time setting creation
- Explicitly call schedule_sync() in save_settings() to ensure
  scheduling works even when option value hasn't changed

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-20 09:06:59 -04:00
ben
5a55b78d03 fix: Use HVAC_Secure_Storage consistently for Zoho credentials
- Fix Client ID regex to allow lowercase letters
- Update HVAC_Zoho_CRM_Auth to use encrypted storage for all operations
- Update class-zoho-admin.php to use HVAC_Secure_Storage for credential retrieval
- Update OAuth callback to use secure storage for token storage
- Update Status.md with blocking production issue (400 Bad Request)

Note: Issue persists on production - needs further investigation

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 15:28:16 -04:00
ben
08944d48ee fix: Resolve console errors on staging
1. Created zoho-admin.css (was missing, causing 404)
   - Added styles for Zoho admin page layout
   - Card styling, form tables, sync buttons, status messages

2. Fixed jQuery not defined on master-trainer pages
   - Changed inject_inline_content hook from wp_head to wp_footer
   - Ensures jQuery is loaded before inline script executes

Note: "message channel closed" error is a browser extension issue,
not a code problem (typically ad blockers intercepting message passing)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 15:16:42 -04:00
ben
b19f1c8e79 security: Address code review findings for Zoho CRM integration
1. OAuth CSRF Protection:
   - Added state parameter to OAuth authorization URL
   - Generate and store state in transient (10 min expiry)
   - Validate state on callback with timing-safe comparison

2. Debug Log Sanitization:
   - Added sanitize_log_message() to mask credentials in logs
   - Patterns mask client_id, client_secret, access_token, refresh_token
   - Error handlers only expose file paths in WP_DEBUG mode

3. Move Inline JS to External File:
   - Moved ~100 lines of inline JS to assets/js/zoho-admin.js
   - Added redirectUri and oauthUrl to wp_localize_script
   - Better CSP compliance and caching

4. Updated .gitignore to track includes/admin/ and includes/zoho/

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 14:59:11 -04:00
ben
24bde9ff8d fix: Zoho CRM admin menu visibility - use admin_menu hook
Changed initializeAdminComponents hook from admin_init to admin_menu
with priority 5. WordPress fires admin_menu before admin_init, so
the Zoho admin submenu was being registered too late to appear.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 14:43:39 -04:00
ben
a2bd54ecf3 feat: Zoho CRM integration - Event Tickets support and sync methods
- Replace WooCommerce sync with Event Tickets (Tickets Commerce) support
- Add sync_attendees() for Contacts + Campaign Members
- Add sync_rsvps() for Leads + Campaign Members
- Fix user roles filter (hvac_trainer/hvac_master_trainer)
- Fix event query to include past events
- Update admin UI with new sync buttons
- Correct meta keys for Tickets Commerce (_tec_tickets_commerce_*)
- Correct meta keys for RSVPs (_tribe_rsvp_*)

Dry-run tested on staging:
- Events: 20 records
- Trainers: 53 records
- Attendees: 79 records
- RSVPs: 4 records
- Orders: 52 records

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-16 14:32:15 -04:00
ben
7184ef84dd Improve: Enhanced comprehensive test suite validation
- Added authentication support for Master Trainer page tests
- Improved security endpoint validation (accepts 400/403 status codes)
- Added cookie clearing before security tests to ensure logout
- Enhanced error reporting and test output clarity
- Better handling of authenticated vs public page testing
- More accurate security check validation logic
2025-12-16 12:42:50 -04:00
ben
ca0e4dc2d8 Fix: Master Trainer navigation dropdown styling bug
- Replaced empty menu-toggle spans with dropdown-arrow spans containing ▼ character
- Fixes green/teal boxes appearing in navigation toolbar on master trainer pages
- Affects all /master-trainer/* pages (Dashboard, Trainers, Tools, Account menus)
- File: includes/class-hvac-master-menu-system.php:327
- Deployed to staging and verified working
- Screenshots confirm arrows display correctly, green boxes removed
2025-12-16 12:41:00 -04:00
ben
6bb957d772 fix: resolve announcement submission nonce mismatch (v2.1.7)
Some checks failed
HVAC Plugin CI/CD Pipeline / Code Quality & Standards (push) Has been cancelled
HVAC Plugin CI/CD Pipeline / Security Analysis (push) Has been cancelled
HVAC Plugin CI/CD Pipeline / Unit Tests (push) Has been cancelled
HVAC Plugin CI/CD Pipeline / Integration Tests (push) Has been cancelled
Security Monitoring & Compliance / Dependency Vulnerability Scan (push) Has been cancelled
Security Monitoring & Compliance / Secrets & Credential Scan (push) Has been cancelled
Security Monitoring & Compliance / WordPress Security Analysis (push) Has been cancelled
Security Monitoring & Compliance / Static Code Security Analysis (push) Has been cancelled
Security Monitoring & Compliance / Security Compliance Validation (push) Has been cancelled
HVAC Plugin CI/CD Pipeline / Deploy to Staging (push) Has been cancelled
HVAC Plugin CI/CD Pipeline / Deploy to Production (push) Has been cancelled
HVAC Plugin CI/CD Pipeline / Notification (push) Has been cancelled
Security Monitoring & Compliance / Security Summary Report (push) Has been cancelled
Security Monitoring & Compliance / Security Team Notification (push) Has been cancelled
Critical bug fix: Master trainers could not submit announcements due to
WordPress nonce security token mismatch between generation and verification.

ROOT CAUSE:
- Nonce generated with action: 'hvac_announcements_admin_nonce'
- Nonce verified with action: 'hvac_announcements_nonce'
- Mismatch caused "Invalid security token" error on every submission

FIX:
Changed nonce generation in class-hvac-announcements-admin.php line 96:
- Before: wp_create_nonce('hvac_announcements_admin_nonce')
- After: wp_create_nonce('hvac_announcements_nonce')

VALIDATION:
Tested on staging with Playwright browser automation:
- Logged in as test_master (ID: 25)
- Created test announcement successfully
- Verified success message: "Announcement created successfully"
- Confirmed announcement appears in table (2025-11-03 19:12:18)
- No "Invalid security token" error

IMPACT:
Announcement submission feature now fully operational. Master trainers
can create, edit, and publish announcements without security errors.

FILES MODIFIED:
- hvac-community-events.php: v2.1.6 → v2.1.7
- includes/class-hvac-plugin.php: HVAC_VERSION v2.1.6 → v2.1.7
- includes/class-hvac-announcements-admin.php: Fixed nonce action name

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-04 08:56:05 -04:00
ben
f92ea45286 refactor: technical debt cleanup for v2.1.6
- Fix version mismatch (2.0.0 → 2.1.5 in main plugin file)
- Fix modal FOUC (CSS defaults to display:none, JS adds .active class)
- Replace direct error_log() with HVAC_Logger for conditional debug logging
- All logging now respects WP_DEBUG flag for production cleanliness

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-03 20:41:20 -04:00
ben
2a06bb1f15 fix: resolve announcements modal z-index stacking issue (v2.1.5)
Changes:
- Fix z-index conflict where announcement modal (999999) was higher than WordPress media modals (160000)
- Reduce announcement modal z-index to 100000 to allow WordPress media library to stack on top
- Remove duplicate TinyMCE initialization that was unnecessary
- Add custom "Add Media" button that renders when modal opens (prevents hidden modal issues)
- Improve page detection with multi-layered approach (URL path, template, slug, queried object)
- Move script loading to footer for better WordPress editor compatibility

Technical Details:
- WordPress core media modals use z-index 160000-160010
- Custom plugin modals should use 100000-159000 range to avoid conflicts
- wp_editor() with media_buttons => true in hidden modals causes auto-open issues
- Solution: media_buttons => false + custom button added via JavaScript when modal opens

Testing:
- Verified with MCP Playwright browser automation
- Media modal now properly appears above announcement modal
- All form functionality preserved
- Screenshot verification shows correct stacking order

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-03 19:23:02 -04:00
ben
f66f1494c5 fix: resolve announcements modal visibility issues (v2.0.1)
Comprehensive architectural fix for master trainer announcements modal
that was invisible despite JavaScript executing correctly.

Root Causes Fixed:
1. Duplicate CSS modal definitions causing cascade conflicts
2. JavaScript using fadeIn() but CSS expecting .active class
3. Inline style="display:none" overriding all CSS rules
4. Browser cache preventing JavaScript updates

Changes:
- Remove duplicate .hvac-modal CSS definition (lines 794-835)
- Remove unused @keyframes fadeIn and slideIn animations
- Update openModal() to use .active class + body scroll prevention
- Update closeModal() to remove .active class
- Remove inline display:none from modal HTML templates
- Increment HVAC_VERSION to 2.0.1 for cache busting

Testing:
- Validated with MCP Playwright browser automation
- Visual confirmation of working modal
- Code review with Zen GLM-4.6 expert analysis

Files Modified:
- assets/css/hvac-announcements.css
- assets/js/hvac-announcements-admin.js
- includes/class-hvac-plugin.php
- includes/class-hvac-announcements-admin.php
- includes/class-hvac-announcements-display.php

Status: Modal now fully functional on staging
Next: Fix wp.editor.setContent error, investigate remaining page errors

🤖 Generated with Claude Code (https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-03 13:59:24 -04:00
ben
aebfb9adb8 fix: add missing JavaScript for master trainers overview page
Resolves missing data on /master-trainer/trainers/ page by adding the JavaScript
file that handles AJAX loading of trainer statistics and table data.

 Changes:
- Created assets/js/hvac-master-trainers-overview.js
  - Handles AJAX calls to load trainer stats and filtered trainer lists
  - Implements filter change handlers for status, region, and search
  - Includes debounced search input for better UX
  - Initializes interactive table elements after load

- Updated includes/class-hvac-master-trainers-overview.php
  - Added enqueue_scripts() method to properly load the JS file
  - Registers wp_enqueue_scripts action hook
  - Detects master trainer pages via shortcode or template slug
  - Ensures jQuery dependency is met

The page was showing navigation and filters but no data because the JavaScript
to make AJAX calls to hvac_master_trainers_stats and hvac_master_trainers_filter
actions was completely missing.

Tested on staging and deployed to production.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-29 16:45:03 -03:00
ben
80f11e71dd fix: resolve dashboard fatal errors and event edit security check failures
Fixes three critical production issues discovered on upskillhvac.com:

 Dashboard Fatal Errors (class-hvac-dashboard-data.php):
- Added class_exists('Tribe__Events__Main') checks before accessing TEC constants
- Prevents fatal errors when TEC plugin loads after our code
- Applied to get_total_events_count(), get_upcoming_events_count(), and get_past_events_count()
- Gracefully returns 0 when TEC is not available

 Event Edit Security Check Failure (page-edit-event-custom.php):
- Fixed nonce action mismatch: changed 'hvac_edit_event' to 'hvac_event_action'
- Aligns with HVAC_Event_Manager::NONCE_ACTION constant
- Resolves "Security check failed" error on event update forms

 Google Drive Folder Update:
- Updated embedded folder ID from 16uDRkFcaEqKUxfBek9VbfbAIeFV77nZG to 1-SDHGR9Ix6BmUVTHa3wI99K0rwfWL-vs
- Applied to templates/page-trainer-resources.php (trainer resources page)
- Applied to includes/class-hvac-announcements-display.php (shortcode default)
- Updated tests/unit/test-announcements-display.php (test references)

All changes tested and verified on production.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-29 16:37:59 -03:00
61 changed files with 15284 additions and 1705 deletions

View file

@ -2,67 +2,29 @@
"$schema": "https://json.schemastore.org/claude-code-settings.json",
"permissions": {
"allow": [
"mcp__playwright__browser_click",
"Bash(wp eval:*)",
"Bash(test:*)",
"mcp__playwright__browser_take_screenshot",
"Bash(chmod:*)",
"Bash(bin/refresh-user-roles-capabilities.sh:*)",
"Bash(find:*)",
"Bash(SSHPASS=\"uSCO6f1y\" sshpass -e scp -o StrictHostKeyChecking=no /home/ben/dev/upskill-event-manager/includes/class-hvac-trainer-communication-templates.php roodev@146.190.76.204:/home/974670.cloudwaysapps.com/uberrxmprk/public_html/wp-content/plugins/hvac-community-events/includes/)",
"Bash(SSHPASS=\"uSCO6f1y\" sshpass -e scp -o StrictHostKeyChecking=no /home/ben/dev/upskill-event-manager/templates/page-edit-event.php roodev@146.190.76.204:/home/974670.cloudwaysapps.com/uberrxmprk/public_html/wp-content/plugins/hvac-community-events/templates/)",
"Bash(SSHPASS=\"uSCO6f1y\" sshpass -e scp -o StrictHostKeyChecking=no /home/ben/dev/upskill-event-manager/refresh-roles-capabilities-local.php roodev@146.190.76.204:/home/974670.cloudwaysapps.com/uberrxmprk/public_html/wp-content/plugins/hvac-community-events/)",
"Bash(SSHPASS=\"uSCO6f1y\" sshpass -e ssh -o StrictHostKeyChecking=no roodev@146.190.76.204 \"cd /home/974670.cloudwaysapps.com/uberrxmprk/public_html && wp eval-file wp-content/plugins/hvac-community-events/refresh-roles-capabilities-local.php\")",
"Bash(SSHPASS=\"uSCO6f1y\" sshpass -e ssh -o StrictHostKeyChecking=no roodev@146.190.76.204 \"cd /home/974670.cloudwaysapps.com/uberrxmprk/public_html && wp user list --field=user_login\")",
"Bash(SSHPASS=\"uSCO6f1y\" sshpass -e ssh -o StrictHostKeyChecking=no roodev@146.190.76.204 \"cd /home/974670.cloudwaysapps.com/uberrxmprk/public_html && wp user get test_admin --field=roles\")",
"mcp__playwright__browser_type",
"Bash(echo:*)",
"Bash(SSHPASS=\"uSCO6f1y\" sshpass -e scp -o StrictHostKeyChecking=no /home/ben/dev/upskill-event-manager/includes/class-hvac-announcements-admin.php roodev@146.190.76.204:/home/974670.cloudwaysapps.com/uberrxmprk/public_html/wp-content/plugins/hvac-community-events/includes/)",
"Bash(SSHPASS=\"uSCO6f1y\" sshpass -e scp -o StrictHostKeyChecking=no /home/ben/dev/upskill-event-manager/templates/page-master-announcements.php roodev@146.190.76.204:/home/974670.cloudwaysapps.com/uberrxmprk/public_html/wp-content/plugins/hvac-community-events/templates/)",
"Bash(SSHPASS=\"uSCO6f1y\" sshpass -e scp -o StrictHostKeyChecking=no /home/ben/dev/upskill-event-manager/assets/css/hvac-announcements-admin.css roodev@146.190.76.204:/home/974670.cloudwaysapps.com/uberrxmprk/public_html/wp-content/plugins/hvac-community-events/assets/css/)",
"Bash(git log:*)",
"mcp__zen__thinkdeep",
"mcp__zen__testgen",
"Bash(git add:*)",
"Bash(git commit:*)",
"Bash(curl:*)",
"Bash(node:*)",
"Bash(SSHPASS=:* sshpass -e ssh :*)",
"mcp__playwright__browser_navigate",
"mcp__playwright__browser_wait_for",
"mcp__zen__analyze",
"Bash(SSHPASS=\"uSCO6f1y\" sshpass -e ssh -o StrictHostKeyChecking=no roodev@146.190.76.204 \"cd /home/974670.cloudwaysapps.com/uberrxmprk/public_html && wp --path=/home/974670.cloudwaysapps.com/uberrxmprk/public_html user get test_trainer --field=roles\")",
"Bash(SSHPASS=\"uSCO6f1y\" sshpass -e ssh -o StrictHostKeyChecking=no roodev@146.190.76.204 \"tail -20 /home/974670.cloudwaysapps.com/uberrxmprk/public_html/wp-content/debug.log\")",
"Bash(SSHPASS=\"uSCO6f1y\" sshpass -e ssh -o StrictHostKeyChecking=no roodev@146.190.76.204 \"cd /home/974670.cloudwaysapps.com/uberrxmprk/public_html && wp plugin list | grep -E ''community|event''\")",
"Bash(SSHPASS=\"uSCO6f1y\" sshpass -e ssh -o StrictHostKeyChecking=no roodev@146.190.76.204 \"cd /home/974670.cloudwaysapps.com/uberrxmprk/public_html && wp user get test_trainer --field=roles\")",
"Bash(SSHPASS=\"uSCO6f1y\" sshpass -e ssh -o StrictHostKeyChecking=no roodev@146.190.76.204 \"cd /home/974670.cloudwaysapps.com/uberrxmprk/public_html && wp user update test_trainer --user_pass=trainer123\")",
"Bash(SSHPASS=\"uSCO6f1y\" sshpass -e scp -o StrictHostKeyChecking=no /home/ben/dev/upskill-event-manager/assets/js/hvac-rest-api-event-submission.js roodev@146.190.76.204:/home/974670.cloudwaysapps.com/uberrxmprk/public_html/wp-content/plugins/hvac-community-events/assets/js/)",
"Bash(SSHPASS=\"uSCO6f1y\" sshpass -e scp -o StrictHostKeyChecking=no /home/ben/dev/upskill-event-manager/templates/template-hvac-master-dashboard.php roodev@146.190.76.204:/home/974670.cloudwaysapps.com/uberrxmprk/public_html/wp-content/plugins/hvac-community-events/templates/)",
"mcp__playwright__browser_console_messages",
"Bash(SSHPASS=\"uSCO6f1y\" sshpass -e ssh -o StrictHostKeyChecking=no roodev@146.190.76.204 \"tail -50 /home/974670.cloudwaysapps.com/uberrxmprk/public_html/wp-content/debug.log\")",
"mcp__zen__debug",
"mcp__playwright__browser_evaluate",
"Bash(SSHPASS=\"uSCO6f1y\" sshpass -e ssh -o StrictHostKeyChecking=no roodev@146.190.76.204 \"cd /home/974670.cloudwaysapps.com/uberrxmprk/public_html && wp user update test_master --user_pass=master123\")",
"Bash(SSHPASS=\"uSCO6f1y\" sshpass -e ssh -o StrictHostKeyChecking=no roodev@146.190.76.204 \"cd /home/974670.cloudwaysapps.com/uberrxmprk/public_html && wp user update test_master --user_pass=MasterTrainer2024!\")",
"Bash(SSHPASS=\"uSCO6f1y\" sshpass -e scp -o StrictHostKeyChecking=no /home/ben/dev/upskill-event-manager/templates/page-master-trainers.php roodev@146.190.76.204:/home/974670.cloudwaysapps.com/uberrxmprk/public_html/wp-content/plugins/hvac-community-events/templates/)",
"WebFetch(domain:upskill-staging.measurequick.com)",
"Bash(SSHPASS=\"uSCO6f1y\" sshpass -e ssh -o StrictHostKeyChecking=no roodev@146.190.76.204 \"cd /home/974670.cloudwaysapps.com/uberrxmprk/public_html && wp user get test_trainer --field=capabilities\")",
"Bash(SSHPASS=\"uSCO6f1y\" sshpass -e scp -o StrictHostKeyChecking=no /home/ben/dev/upskill-event-manager/includes/class-hvac-plugin.php roodev@146.190.76.204:/home/974670.cloudwaysapps.com/uberrxmprk/public_html/wp-content/plugins/hvac-community-events/includes/)",
"Bash(SSHPASS=\"uSCO6f1y\" sshpass -e scp -o StrictHostKeyChecking=no /home/ben/dev/upskill-event-manager/includes/class-hvac-ajax-handlers.php roodev@146.190.76.204:/home/974670.cloudwaysapps.com/uberrxmprk/public_html/wp-content/plugins/hvac-community-events/includes/)",
"Bash(SSHPASS=\"uSCO6f1y\" sshpass -e ssh -o StrictHostKeyChecking=no roodev@146.190.76.204 \"tail -100 /home/974670.cloudwaysapps.com/uberrxmprk/public_html/wp-content/debug.log | grep -i -E ''(TEC|Security|tribe|filter|hook)''\")",
"Bash(SSHPASS=\"uSCO6f1y\" sshpass -e ssh -o StrictHostKeyChecking=no roodev@146.190.76.204 \"tail -200 /home/974670.cloudwaysapps.com/uberrxmprk/public_html/wp-content/debug.log | grep -E ''(HVAC TEC|TEC Integration|TEC Debug)''\")",
"Bash(SSHPASS=\"uSCO6f1y\" sshpass -e ssh -o StrictHostKeyChecking=no roodev@146.190.76.204 \"cd /home/974670.cloudwaysapps.com/uberrxmprk/public_html && wp plugin list | grep -E ''event|tribe|community''\")",
"Bash(SSHPASS=\"uSCO6f1y\" sshpass -e ssh -o StrictHostKeyChecking=no roodev@146.190.76.204 \"find /home/974670.cloudwaysapps.com/uberrxmprk/public_html/wp-content/plugins/the-events-calendar-community-events -name ''*.php'' -exec grep -l ''do_action.*submit\\|apply_filters.*submit'' {} \\;\")",
"Bash(SSHPASS=\"uSCO6f1y\" sshpass -e ssh -o StrictHostKeyChecking=no roodev@146.190.76.204 \"grep -n -A5 -B5 ''do_action.*submit\\|apply_filters.*submit'' /home/974670.cloudwaysapps.com/uberrxmprk/public_html/wp-content/plugins/the-events-calendar-community-events/src/Tribe/Main.php\")",
"Bash(SSHPASS=\"uSCO6f1y\" sshpass -e ssh -o StrictHostKeyChecking=no roodev@146.190.76.204 \"find /home/974670.cloudwaysapps.com/uberrxmprk/public_html/wp-content/plugins/the-events-calendar-community-events -name ''*.php'' -exec grep -l ''submission.*handler\\|form.*submit\\|event.*save'' {} \\;\")",
"Bash(SSHPASS=\"uSCO6f1y\" sshpass -e ssh -o StrictHostKeyChecking=no roodev@146.190.76.204 \"grep -n -A20 -B5 ''do_action\\|apply_filters'' /home/974670.cloudwaysapps.com/uberrxmprk/public_html/wp-content/plugins/the-events-calendar-community-events/src/Events_Community/Submission/Save.php\")",
"Bash(SSHPASS=\"uSCO6f1y\" sshpass -e ssh -o StrictHostKeyChecking=no roodev@146.190.76.204 \"cd /home/974670.cloudwaysapps.com/uberrxmprk/public_html && wp option get tribe_events_community_options | grep -E ''communityRewriteSlug|eventsDefaultStatus''\")",
"Bash(SSHPASS=\"uSCO6f1y\" sshpass -e ssh -o StrictHostKeyChecking=no roodev@146.190.76.204 \"cd /home/974670.cloudwaysapps.com/uberrxmprk/public_html && wp post list --post_type=tribe_events --posts_per_page=5 --format=table\")",
"Bash(SSHPASS=\"uSCO6f1y\" sshpass -e ssh -o StrictHostKeyChecking=no roodev@146.190.76.204 \"cd /home/974670.cloudwaysapps.com/uberrxmprk/public_html && wp post create --post_type=tribe_events --post_title=''Test Hook Integration'' --post_content=''Testing TEC hook integration'' --post_excerpt=''Test excerpt for hook validation'' --post_status=publish --format=ids\")",
"Bash(SSHPASS=\"uSCO6f1y\" sshpass -e ssh -o StrictHostKeyChecking=no roodev@146.190.76.204 \"tail -30 /home/974670.cloudwaysapps.com/uberrxmprk/public_html/wp-content/debug.log\")",
"Bash(SSHPASS=\"uSCO6f1y\" sshpass -e ssh -o StrictHostKeyChecking=no roodev@146.190.76.204 \"cd /home/974670.cloudwaysapps.com/uberrxmprk/public_html && wp plugin get the-events-calendar-community-events --field=status\")",
"Bash(SSHPASS=\"uSCO6f1y\" sshpass -e ssh -o StrictHostKeyChecking=no roodev@146.190.76.204 \"cd /home/974670.cloudwaysapps.com/uberrxmprk/public_html && wp rewrite list | grep -i community\")",
"Bash(SSHPASS=\"uSCO6f1y\" sshpass -e ssh -o StrictHostKeyChecking=no roodev@146.190.76.204 \"cd /home/974670.cloudwaysapps.com/uberrxmprk/public_html && wp user set-role devadmin administrator\")",
"Bash(SSHPASS=\"uSCO6f1y\" sshpass -e ssh -o StrictHostKeyChecking=no roodev@146.190.76.204 \"cd /home/974670.cloudwaysapps.com/uberrxmprk/public_html && wp user set-role ben@measurequick.com administrator\")"
"Bash(scripts/deploy.sh:*)",
"WebSearch",
"mcp__zen__debug",
"mcp__zen__chat",
"mcp__playwright__browser_click",
"mcp__playwright__browser_take_screenshot",
"mcp__zen__analyze",
"mcp__playwright__browser_type",
"mcp__playwright__browser_close",
"mcp__playwright__browser_install",
"Bash(./scripts/deploy.sh:*)",
"Bash(curl:*)",
"Bash(yes:*)",
"WebFetch(domain:json.schemastore.org)",
"WebFetch(domain:www.schemastore.org)",
"Bash(git commit:*)",
"Bash(git push:*)",
"Bash(bash /Users/ben/dev/upskill-event-manager/scripts/pre-deployment-check.sh)",
"Bash(bash /Users/ben/dev/upskill-event-manager/scripts/deploy.sh:*)",
"Bash(git:*)"
],
"deny": [],
"ask": [],
@ -70,5 +32,9 @@
"/tmp"
]
},
"enableAllProjectMcpServers": true
"enableAllProjectMcpServers": true,
"disabledMcpjsonServers": [
"postgres",
"kubernetes"
]
}

76
.gitignore vendored
View file

@ -1,5 +1,5 @@
# Ignore everything by default
*
# *
!.gitignore
!.gitattributes
@ -28,6 +28,9 @@
!hvac-community-events.php
!/includes/
/includes/*
!/includes/admin/
!/includes/zoho/
!/includes/find-training/
!/includes/**/*.php
!/templates/
/templates/*
@ -95,14 +98,14 @@
!/wordpress-dev/wordpress/wp-content/plugins/hvac-community-events/**
# Test files
**/test-results/
**/playwright-report/
**/.phpunit.result.cache
**/node_modules/
**/vendor/
**/screenshots/
**/videos/
**/traces/
# **/test-results/
# **/playwright-report/
# **/.phpunit.result.cache
# **/node_modules/
# **/vendor/
# **/screenshots/
# **/videos/
# **/traces/
# Documentation
!/docs/
@ -177,25 +180,25 @@
!/wp-content/plugins/
# Security - Sensitive Files (CRITICAL SECURITY)
.env
# .env
.env.*
*.env
**/.env
**/.env.*
# *.env
# **/.env
# **/.env.*
.auth/
**/.auth/
# **/.auth/
**/zoho-config.php
**/wp-config.php
**/wp-tests-config*.php
# **/wp-config.php
# **/wp-tests-config*.php
memory-bank/mcpServers.md
**/*config*.php
**/*secret*
**/*password*
**/*credential*
**/*.key
**/*.pem
**/*.p12
**/*.pfx
# **/*config*.php
# **/*secret*
# **/*password*
# **/*credential*
# **/*.key
# **/*.pem
# **/*.p12
# **/*.pfx
# Security Framework - Sensitive Runtime Data
security-audit.log
@ -203,7 +206,7 @@ auth-state-*.json
session-*.json
test-results/
test-screenshots/
*.har
# *.har
coverage/
# Allow security framework files but not sensitive data
@ -220,6 +223,7 @@ coverage/
!.claude/agents/
!.claude/agents/*.md
!CLAUDE.md
.mcp.json # MCP configuration contains JWT tokens
# Forgejo Actions CI/CD
!.forgejo/
@ -230,19 +234,25 @@ coverage/
test-actual-*.js
test-missing-*.js
direct-*.php
*-temp.js
*-temp.php
# *-temp.js
# *-temp.php
# Common ignores
.DS_Store
Thumbs.db
*.log
*.zip
*.tar
*.tar.gz
# *.log
# *.zip
# *.tar
# *.tar.gz
node_modules/
vendor/
.idea/
.vscode/
*.swp
*.swo
# *.swp
# *.swo
# GEMINI Config
!GEMINI.md
!.agent/
!.agent/workflows/
!.agent/workflows/*.md

363
CLAUDE.md
View file

@ -1,259 +1,232 @@
# CLAUDE.md - HVAC Plugin Development Guide
# CLAUDE.md
**Essential guidance for Claude Code agents working on the HVAC Community Events WordPress plugin.**
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
> 📚 **Complete Best Practices**: See [docs/CLAUDE-CODE-DEVELOPMENT-BEST-PRACTICES.md](docs/CLAUDE-CODE-DEVELOPMENT-BEST-PRACTICES.md) for comprehensive development guidelines.
## Project Overview
> 📊 **Current Status**: PHP 8+ Modernization (Phase 2) in progress - debugging union type compatibility on staging
HVAC Community Events is a WordPress plugin extending The Events Calendar (TEC) suite to create a trainer community platform. It provides custom user roles (`hvac_trainer`, `hvac_master_trainer`), event management, certificate generation, venue/organizer management, and Zoho CRM integration.
> ⚠️ **Interim Status**: See [docs/PHP8-MODERNIZATION-INTERIM-STATUS.md](docs/PHP8-MODERNIZATION-INTERIM-STATUS.md) for current session progress
- **Entry Point**: `hvac-community-events.php`
- **Core Classes**: `includes/class-*.php` (all use singleton pattern)
- **Templates**: `templates/page-*.php`
- **TEC Integration**: Events, venues, organizers via The Events Calendar suite
## 🚀 Quick Commands
## Commands
### Deployment (Most Common)
### Deployment
```bash
# Deploy to staging (primary command)
scripts/deploy.sh staging
# Pre-deployment validation (ALWAYS run first)
scripts/pre-deployment-check.sh
# Deploy to production (ONLY when user explicitly requests)
# Deploy to staging
scripts/deploy.sh staging
# Deploy to production (requires explicit user request and confirmation)
scripts/deploy.sh production
```
### Testing
```bash
# Docker Development Environment
# Start Docker test environment
docker compose -f tests/docker-compose.test.yml up -d
# Run E2E tests against Docker environment (headless)
# E2E tests (headless)
HEADLESS=true BASE_URL=http://localhost:8080 node test-master-trainer-e2e.js
HEADLESS=true BASE_URL=http://localhost:8080 node test-comprehensive-validation.js
# Run E2E tests with GNOME session browser (headed)
# E2E tests (headed - requires display)
DISPLAY=:0 XAUTHORITY=/run/user/1000/.mutter-Xwaylandauth.U8VEB3 node test-master-trainer-e2e.js
# Run comprehensive test suite
node test-comprehensive-validation.js
# Use MCP Playwright when standard Playwright fails
# (The MCP tools handle display integration automatically)
```
### WordPress CLI (on server)
### WordPress CLI (on server via SSH)
```bash
wp rewrite flush
wp eval 'HVAC_Page_Manager::create_required_pages();'
wp eval 'wp_set_password("Password123", USER_ID);' # Reset password (more reliable than wp user update)
```
## 🎯 Core Development Principles
### Staging Environment
```bash
# SSH access
ssh roodev@146.190.76.204
cd /home/974670.cloudwaysapps.com/uberrxmprk/public_html
### 1. **USE MCP SERVICES PROACTIVELY**
- `mcp__sequential-thinking__sequentialthinking` for complex planning
- `mcp__zen-mcp__codereview` with GPT-5/Qwen for validation
- `mcp__zen-mcp__thinkdeep` for complex investigations
- `WebSearch` for documentation and best practices
- **Specialized Agents**: Use agents from user's `.claude` directory for debugging, architecture, security
# Test account (staging)
Username: test_master | Password: TestPass123 | Role: hvac_master_trainer
```
## Architecture
### Singleton Pattern (MANDATORY)
All core classes use the singleton pattern. Never assume static methods exist:
### 2. **WordPress Template Architecture (CRITICAL)**
```php
// ✅ ALWAYS use singleton patterns
// CORRECT
echo HVAC_Breadcrumbs::instance()->render_breadcrumbs();
HVAC_Venues::instance()->get_venues();
// ❌ NEVER assume static methods exist
// WRONG: HVAC_Breadcrumbs::render();
// WRONG - will fail
HVAC_Breadcrumbs::render();
```
// ✅ MANDATORY in all templates
### Template Requirements
All page templates MUST include:
```php
<?php
defined('ABSPATH') || exit;
define('HVAC_IN_PAGE_TEMPLATE', true);
get_header();
// content here
// ... content ...
get_footer();
```
### 3. **Security-First Patterns**
### Security Patterns
```php
// Always escape output
echo esc_html($trainer_name);
echo esc_url($profile_link);
// Always sanitize input
// Input sanitization
$trainer_id = absint($_POST['trainer_id']);
$email = sanitize_email($_POST['email']);
// Always verify nonces
// Output escaping
echo esc_html($trainer_name);
echo esc_url($profile_url);
// Nonce verification
wp_verify_nonce($_POST['nonce'], 'hvac_action');
// Role checking (NOT capabilities)
// Role checking (use roles, not capabilities)
$user = wp_get_current_user();
if (!in_array('hvac_trainer', $user->roles)) {
wp_die('Access denied');
}
```
### 4. **WordPress Specialized Agents (PRIMARY DEVELOPMENT TOOLS)**
### File Structure
```
includes/
├── class-hvac-plugin.php # Main controller (singleton)
├── class-hvac-shortcodes.php # Shortcode management
├── class-hvac-scripts-styles.php # Asset management
├── class-hvac-route-manager.php # URL routing
├── class-hvac-venues.php # Venue CRUD (singleton)
├── class-hvac-organizers.php # Organizer management (singleton)
├── class-hvac-training-leads.php # Lead tracking (singleton)
├── admin/ # Admin classes
├── certificates/ # Certificate generation
├── communication/ # Email templates
├── zoho/ # Zoho CRM integration
└── find-trainer/ # Public trainer directory
**MANDATORY**: Use project-specific WordPress agents for ALL development activities:
```bash
# WordPress Plugin Development (primary agent for features/fixes)
claude --agent wordpress-plugin-pro "Add event validation system"
# Code Review (MANDATORY after any code changes)
claude --agent wordpress-code-reviewer "Review security of latest changes"
# Troubleshooting (first response to any issues)
claude --agent wordpress-troubleshooter "Debug event creation form issues"
# Testing (MANDATORY before any deployment)
claude --agent wordpress-tester "Run comprehensive test suite for staging deployment"
# Deployment (for all staging/production deployments)
claude --agent wordpress-deployment-engineer "Deploy latest changes to staging"
templates/page-*.php # Page templates (hierarchical URLs)
assets/{css,js}/ # Frontend assets
tests/ # Docker environment and E2E tests
scripts/ # Deployment and maintenance scripts
```
**Agent Selection Guide:**
- 🔧 **wordpress-plugin-pro**: New features, WordPress hooks, TEC integration, role management
- 🛡️ **wordpress-code-reviewer**: Security review, WordPress standards, performance analysis
- 🔍 **wordpress-troubleshooter**: Debugging, plugin conflicts, user access issues
- 🧪 **wordpress-tester**: Comprehensive testing, E2E tests, deployment validation (**MANDATORY before deployments**)
- 🚀 **wordpress-deployment-engineer**: Staging/production deployments, CI/CD, backups
### URL Structure
- Trainer: `/trainer/dashboard/`, `/trainer/event/manage/`, `/trainer/certificate-reports/`
- Master Trainer: `/master-trainer/dashboard/`, `/master-trainer/trainers/`
- Public: `/training-login/`, `/find-a-trainer/`
### 5. **Testing & Debugging Process**
1. **Use wordpress-troubleshooter agent for systematic diagnosis**
2. **Create comprehensive test suite with wordpress-tester agent**
3. **Apply wordpress-code-reviewer for security validation**
4. **Run mandatory pre-deployment tests with wordpress-tester**
5. **Use wordpress-deployment-engineer for staging deployment and validation**
## Critical Warnings
### NEVER Do
- Deploy to production without explicit user request
- Skip pre-deployment validation (`scripts/pre-deployment-check.sh`)
- Use static method calls without verifying singleton pattern
- Re-enable monitoring infrastructure (causes PHP segmentation faults)
### Disabled Systems (DO NOT RE-ENABLE)
The following monitoring systems are PERMANENTLY DISABLED due to causing PHP segmentation faults:
`HVAC_Background_Jobs`, `HVAC_Health_Monitor`, `HVAC_Error_Recovery`, `HVAC_Security_Monitor`, `HVAC_Performance_Monitor`, `HVAC_Backup_Manager`, `HVAC_Cache_Optimizer`
## Zoho CRM Integration
Located in `includes/zoho/`. Maps WordPress data to Zoho CRM:
- Events → Campaigns
- Trainers → Contacts
- Attendees → Contacts + Campaign Members
- RSVPs → Leads + Campaign Members
**Admin Page**: `/wp-admin/admin.php?page=hvac-zoho-sync`
**Important**: Staging environment blocks all write operations to Zoho CRM. Only production can sync data.
## Docker Test Environment
### 6. **WordPress Error Detection & Site Health**
```bash
# All E2E tests now include automatic WordPress error detection
# Tests will fail fast if critical WordPress errors are detected:
# - Fatal PHP errors (memory, syntax, undefined functions)
# - Database connection errors
# - Maintenance mode
# - Plugin/theme fatal errors
# - HTTP 500+ server errors
# If test fails with "WordPress site has critical errors":
# 1. Restore staging from production backup
# 2. Re-seed test data to staging:
bin/seed-comprehensive-events.sh
# 3. Re-run tests
```
## 🏗️ Architecture Overview
**WordPress Plugin Structure:**
- **Entry Point**: `hvac-community-events.php`
- **Core Classes**: `includes/class-*.php` (singleton pattern)
- **Templates**: `templates/page-*.php` (hierarchical URLs)
- **User Roles**: `hvac_trainer`, `hvac_master_trainer`
- **URL Structure**: `/trainer/dashboard/`, `/master-trainer/master-dashboard/`
## ⚠️ CRITICAL REMINDERS
### Never Do
- ❌ Deploy to production without explicit user request
- ❌ Skip pre-deployment validation
- ❌ Use static method calls without verification
- ❌ Create standalone fixes outside plugin deployment
- ❌ Assume template patterns without checking existing implementation
### Always Do
- ✅ **Use WordPress agents as first choice for ALL development tasks**
- ✅ Use MCP services and specialized agents proactively
- ✅ Test on staging first
- ✅ Apply consistent singleton patterns
- ✅ Escape all output, sanitize all input
- ✅ Create comprehensive test suites before making fixes
- ✅ Reference the detailed best practices document
## 📚 Key Documentation
**Essential Reading:**
- **[Status.md](Status.md)** - Current project status and known issues
- **[docs/CLAUDE-CODE-DEVELOPMENT-BEST-PRACTICES.md](docs/CLAUDE-CODE-DEVELOPMENT-BEST-PRACTICES.md)** - Complete development guide
- **[docs/MASTER-TRAINER-FIXES-REPORT.md](docs/MASTER-TRAINER-FIXES-REPORT.md)** - Recent major fixes and lessons learned
**Reference Materials:**
- **[docs/ARCHITECTURE.md](docs/ARCHITECTURE.md)** - System architecture
- **[docs/TROUBLESHOOTING.md](docs/TROUBLESHOOTING.md)** - Common issues
- **[docs/WORDPRESS-BEST-PRACTICES.md](docs/WORDPRESS-BEST-PRACTICES.md)** - WordPress standards
- **[docs/TEST-FRAMEWORK-MODERNIZATION-STATUS.md](docs/TEST-FRAMEWORK-MODERNIZATION-STATUS.md)** - Testing infrastructure overhaul
## 🧪 Docker Testing Infrastructure (New)
**STATUS: August 27, 2025 - FULLY OPERATIONAL**
### Docker Environment
```bash
# Start hermetic testing environment
docker compose -f tests/docker-compose.test.yml up -d
# Access WordPress test instance
http://localhost:8080
# Services included:
# - WordPress 6.4 (PHP 8.2) on port 8080
# - MySQL 8.0 on port 3307
# - Redis 7 on port 6380
# - Mailhog (email testing) on port 8025
# - PhpMyAdmin on port 8081
# Services:
# WordPress 6.4 (PHP 8.2): http://localhost:8080
# MySQL 8.0: port 3307
# Redis 7: port 6380
# Mailhog: http://localhost:8025
# PhpMyAdmin: http://localhost:8081
```
### Test Framework Architecture
- **Page Object Model (POM)**: Centralized, reusable test components
- **146 Tests Migrated**: From 80+ duplicate files to modern architecture
- **90% Code Reduction**: Eliminated test duplication through shared patterns
- **Browser Management**: Singleton lifecycle with proper cleanup
- **TCPDF Dependency**: Graceful handling of missing PDF generation library
## Technical Debt
### Testing Commands
```bash
# Run comprehensive E2E tests (ready for next session)
HEADLESS=true BASE_URL=http://localhost:8080 node test-master-trainer-e2e.js
HEADLESS=true BASE_URL=http://localhost:8080 node test-comprehensive-validation.js
### TEC Community Events Dependency
The plugin still relies on "The Events Calendar: Community" (TEC CE) for event creation/editing forms via `[tribe_community_events]` shortcode. While `HVAC_Event_Manager` has custom CRUD capabilities, production code paths use TEC CE.
**Removal estimate:** 9-14 days of development work.
**Details:** See [docs/reports/TEC-COMMUNITY-EVENTS-DEPENDENCY-ANALYSIS.md](docs/reports/TEC-COMMUNITY-EVENTS-DEPENDENCY-ANALYSIS.md)
**Status:** Deferred - Current implementation is functional and stable.
## Key Documentation
- **[Status.md](Status.md)** - Current project status and recent changes
- **[docs/ARCHITECTURE.md](docs/ARCHITECTURE.md)** - System architecture details
- **[docs/TROUBLESHOOTING.md](docs/TROUBLESHOOTING.md)** - Common issues and solutions
- **[docs/TESTING-GUIDE.md](docs/TESTING-GUIDE.md)** - Testing procedures
## AI Assistant Tools
### Zen MCP Tools (Use Throughout Every Session)
- **Zen Debug**: Use whenever you encounter an error or unexpected behavior
- **Zen Code Review**: Use whenever you finish a significant piece of code or feature
- **Zen Analyze**: Use before adding new features or making significant changes to existing code
- **Zen Deepthink**: Use when you have a complex problem to solve or need to brainstorm ideas
- **Zen Chat**: Use for general questions, clarifications, or when you need assistance with a task
**Important Context Reminder**: Remind the Zen tools that this is a small local environment with a single user, so security and scalability are not primary concerns.
### Clink Multi-Model Workflows (GPT-5, Gemini 3, Kimi K2.5)
Use `mcp__zen__clink` to get diverse AI perspectives via external CLIs. Available CLIs:
- **codex**: GPT-5 - Fast, precise code understanding
- **gemini**: Gemini 3 - Large context (1M tokens), thorough analysis
- **Kimi K2.5** (via `mcp__zen__chat` with OpenRouter) - Strong reasoning, excellent at complex logic
**Security Warning:** Files are sent to OpenAI, Google, and Moonshot AI. NEVER use on `.env`, credentials, or regulated data.
**When to Use Multi-Model:**
- Code reviews (catch different types of issues)
- Complex debugging (multiple hypotheses)
- Architecture decisions (diverse perspectives)
- Planning (compare approaches)
**Available Skills:**
- `/multi-review <files>` - Code review from all 3 models
- `/multi-plan <task>` - Planning perspectives from all 3 models
- `/multi-debug <issue>` - Parallel bug investigation
- `/multi-analyze <files>` - Comprehensive analysis
**Quick Usage** (all three calls in one message = parallel execution):
```python
review_prompt = "Review for bugs and security issues"
mcp__zen__clink(prompt=review_prompt, cli_name="codex", role="codereviewer", absolute_file_paths=["/absolute/path/file.py"])
mcp__zen__clink(prompt=review_prompt, cli_name="gemini", role="codereviewer", absolute_file_paths=["/absolute/path/file.py"])
mcp__zen__chat(prompt=review_prompt + " Use Kimi K2.5 via OpenRouter for this analysis.", absolute_file_paths=["/absolute/path/file.py"])
```
## 🚨 CRITICAL WARNING: Monitoring Infrastructure Disabled
**Important:** Use absolute paths (not globs). Resolve patterns first: `find src -name "*.py" | xargs realpath`
**DATE: August 8, 2025**
**The monitoring infrastructure is PERMANENTLY DISABLED due to causing PHP segmentation faults.**
**Synthesis Format:** After multi-model analysis, report:
1. **Consensus** - All models agree
2. **Majority** - 2/3 agree (note dissent)
3. **Unique insights** - Model-specific findings
4. **Recommended actions** - Prioritized by consensus
Disabled systems: HVAC_Background_Jobs, HVAC_Health_Monitor, HVAC_Error_Recovery, HVAC_Security_Monitor, HVAC_Performance_Monitor, HVAC_Backup_Manager, HVAC_Cache_Optimizer
**DO NOT RE-ENABLE** without thorough debugging.
## 🎯 WordPress Development Workflow
1. **Start with WordPress Agents**: Choose appropriate agent based on task type
2. **Plan with Sequential Thinking**: Agents will use `mcp__sequential-thinking` for complex tasks
3. **Research Best Practices**: Agents will use `WebSearch` for WordPress documentation
4. **Apply Consistent Patterns**: Agents understand WordPress singleton patterns
5. **Test Comprehensively**: **MANDATORY** `wordpress-tester` for all test suites and validation
6. **Review Security**: **MANDATORY** `wordpress-code-reviewer` after code changes
7. **Pre-Deploy Testing**: **MANDATORY** `wordpress-tester` before any deployment
8. **Deploy Systematically**: `wordpress-deployment-engineer` for staging first
9. **Validate with MCP**: Agents will use `mcp__zen-mcp__codereview` for quality assurance
### 🚀 Quick Agent Command Reference
```bash
# Feature Development
claude --agent wordpress-plugin-pro "Implement trainer approval workflow"
# Bug Fixes
claude --agent wordpress-troubleshooter "Fix event creation form validation"
# Security & Code Quality
claude --agent wordpress-code-reviewer "Review user authentication system"
# Testing (MANDATORY before deployments)
claude --agent wordpress-tester "Run full test suite before staging deployment"
# Deployments
claude --agent wordpress-deployment-engineer "Deploy v2.1 to staging environment"
```
---
*This guide provides essential information for Claude Code agents. For comprehensive details, always refer to the complete best practices documentation.*
**Roles:** Each CLI supports `default`, `codereviewer`, `planner`

View 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*

162
Status.md Normal file
View file

@ -0,0 +1,162 @@
# HVAC Community Events - Project Status
**Last Updated:** February 20, 2026
**Version:** 2.2.18 (Deployed to Production)
---
## 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 - SLACK NOTIFICATIONS (Feb 20, 2026)
### Status: COMPLETE - Deployed to Production
**Objective:** Add Slack notifications for trainer registrations, ticket purchases, and event submissions/publishes via Incoming Webhook with Block Kit rich formatting.
### Features
- **New Trainer Registration** — name, role, organization, business type, profile photo, "View in WordPress" button
- **Ticket Purchase** — purchaser, email, event, ticket count, total, payment gateway, "View Order" button
- **Event Submitted by Trainer** — event title, trainer, date, venue, "View Event" button
- **Event Published by Admin** — same fields, "View Event" + "Edit in WP" buttons
- **Settings UI** — Webhook URL field (password type), "Send Test Notification" button with AJAX feedback
- **Test message** includes environment (Staging/Production) and site URL
### Design Decisions
- Non-blocking `wp_remote_post` for all real notifications; blocking only for test button
- Atomic `add_post_meta(..., true)` for idempotency (prevents races and duplicate sends)
- Webhook URL validated at save-time and send-time: `https` + `hooks.slack.com` + `/services/` path
- Graceful degradation: empty webhook = disabled, missing meta = "N/A" fields, no exceptions surface to users
- Code reviewed by GPT-5 (Codex) — all 5 findings fixed before production deploy
### Files Modified
| File | Change |
|------|--------|
| `includes/class-hvac-slack-notifications.php` | **NEW** — static utility class with 4 notification types + test handler |
| `includes/class-hvac-settings.php` | Slack webhook URL setting + validation + test button |
| `includes/class-hvac-plugin.php` | Include file + `init()` call in `initializeSecondaryComponents()` |
| `includes/class-hvac-registration.php` | One-line call to `notify_new_registration()` after admin email |
---
## PREVIOUS SESSION - MARKER VISIBILITY TOGGLE CHECKBOX REFACTOR (Feb 9, 2026)
### Status: COMPLETE - Deployed to Production (v2.2.18)
**Objective:** Refactor the marker visibility toggles on the Find Training page from standalone colored dots into inline checkboxes beside each category tab heading.
### Changes Made
1. **Inline Checkboxes in Tab Buttons** (`templates/page-find-training.php`)
- Removed standalone `hvac-visibility-toggles` div with 3 colored dot toggles
- Moved each checkbox inline into its corresponding tab button (`Events`, `Trainers`, `Venues`)
- `onclick="event.stopPropagation()"` on labels prevents checkbox clicks from triggering tab switches
- Checkbox IDs unchanged — no JS changes needed
2. **Custom Checkbox Styling** (`assets/css/find-training-map.css`)
- 14x14px custom checkboxes with category-specific fill colors when checked
- Trainer checkbox uses explicit `#6aad1e` (CSS variable `--hvac-trainer-color` is too light)
- Tablet: 12px checkboxes; Mobile (<768px): hidden
3. **Version Bump** (`includes/class-hvac-plugin.php`) — `2.2.17``2.2.18`
### Files Modified
| File | Change |
|------|--------|
| `templates/page-find-training.php` | Removed visibility toggles div, added inline checkboxes in tab buttons |
| `assets/css/find-training-map.css` | Replaced dot toggle CSS with checkbox styles, responsive adjustments |
| `includes/class-hvac-plugin.php` | Version bump `2.2.17``2.2.18` |
### Git Commit
- `95382ac3` - feat(find-training): Refactor marker visibility dots into inline tab checkboxes
---
## PREVIOUS SESSIONS (Reverse Chronological)
### Find Training Tab Reorder, Marker Highlighting & Map Reset — Feb 9, 2026 (v2.2.17)
Reordered tabs to Events first (default), added marker highlighting on tab switch (larger/brighter icons, zIndex 100), added map reset button, fixed mobile overflow from long event titles. `9dbe472c`
### Mobile Find Training Scroll Fix — Feb 9, 2026 (v2.2.14)
Fixed mobile scrolling: switched from fixed-viewport (`100vh; overflow: hidden`) to natural page scrolling on screens ≤991px. Sticky filter bar. `4104c806`
### Map Tile Drift Fix & Event Cost Display — Feb 9, 2026 (v2.2.13)
Fixed Google Maps tile/marker drift at zoom 1-2 by setting `minZoom: 3`. Fixed event cost HTML entities (`&#x24;50` → `$50`) via `html_entity_decode()`. `f123c7a5`
### Zoho CRM Sync Production Fix — Feb 6, 2026 (v2.2.11)
Fixed silent sync failure: search criteria not sent to Zoho API (GET ignores `$data`), error reporting priority, phone validation, Last_Name fallback. Result: 64/64 trainers syncing. `4c22b9db`
### Zoho CRM Sync Architecture Fix — Feb 6, 2026
Fixed "silent failure" architecture: added `validate_api_response()`, hash-only-on-success for all 5 sync methods, staging mode detection via hostname parsing, admin hash reset button. `03b9bce5`
### Near Me Button Mobile Fix — Feb 6, 2026
Fixed `.hvac-btn-text` wrapper missing from Near Me button state changes (5 locations). Added empty results notification for Near Me filter.
### Champion Differentiation on Find Training — Feb 2, 2026
Added `is_champion` flag, distinct white-outline marker icon, non-clickable sidebar cards (state only), sorted to end of list. 18 champions differentiated from trainers.
### Tabbed Interface for Find Training — Feb 1, 2026
Refactored sidebar from single trainer list to Trainers | Venues | Events tabs with ARIA accessibility, venue/event cards, info modal, context-aware search, visibility toggle dots.
### measureQuick Approved Training Labs — Feb 1, 2026
Created venue taxonomies (`venue_type`, `venue_equipment`, `venue_amenities`), configured 9 approved labs, filtered map data by taxonomy, enhanced venue modal with equipment/amenities badges and contact form.
### Find Training Page Enhancements — Feb 1, 2026
Added viewport sync (sidebar shows only visible-in-map trainers), marker hover info windows, legacy URL redirects (`/find-a-trainer/` → `/find-training/`).
### Find Training Page Implementation — Jan 31Feb 1, 2026
Built `/find-training` from scratch with Google Maps API replacing buggy MapGeo. 8 new files. Features: markers, clustering, filters, geolocation, modals, contact form, auto-geocoding. Multi-model code review fixed 6 issues (1 critical, 1 high).
### E2E Testing & Bug Fixes — Feb 1, 2026
Deployed 12 security fixes, ran E2E tests, fixed empty trainers table (SQL rewrite) and blank event pages (template path mismatch). Added staging email filter.
### Multi-Model Security Code Review — Jan 31, 2026
4-model review (GPT-5, Gemini 3, Kimi K2.5, Zen MCP) across 11 files. Found and fixed 12 issues: 2 critical (password transients, O(3600) DoS loop), 4 high, 4 medium, 2 low.
### Master Trainer Profile Edit Enhancement — Jan 9, 2026
Fixed button styling, added all 6 profile field sections, added password reset button with AJAX handler.
### TEC Community Events Dependency Analysis — Jan 5, 2026
Plugin still relies on TEC CE for event creation. Removal estimated 9-14 days. Deferred as technical debt.
### Zoho Scheduled Sync, Batch Sync, Attendee Sync — Dec 17-20, 2025
WP-Cron scheduled sync with configurable intervals, batch pagination with progress bar, attendee/RSVP sync to Zoho Contacts + Campaign Members. 64/64 trainers, 50/50 attendees syncing.
### Earlier Sessions (Nov-Dec 2025)
Nonce fix (v2.1.7), technical debt cleanup (v2.1.6), z-index fix (v2.1.5), Gemini environment setup, PHP 8+ compatibility, nav dropdown fix, MapGeo interceptor strategy.
---
## ENVIRONMENTS
| Environment | URL | Version |
|-------------|-----|---------|
| **Production** | https://upskillhvac.com | 2.2.18 |
| **Staging** | https://upskill-staging.measurequick.com | 2.2.18 |
**Deploy:** `./scripts/deploy.sh staging` or `./scripts/deploy.sh production`
---
## KNOWN ISSUES (Minor)
1. Playwright headless login requires headed mode with correct selectors
2. Brief "jQuery is not defined" console error on page load (non-blocking)
3. Minor responsive layout issue on dashboard (cosmetic)
---
*For detailed session history, see git log and previous Status.md versions.*

File diff suppressed because it is too large Load diff

View file

@ -171,12 +171,17 @@
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.5);
z-index: 999999;
display: flex;
z-index: 100000; /* Below WordPress media modal (160000) to allow media library to stack on top */
display: none; /* Hidden by default to prevent FOUC */
justify-content: center;
align-items: center;
}
/* Modal active state - shown by JavaScript */
.hvac-modal.active {
display: flex;
}
.modal-content {
background: white;
width: 90%;

View file

@ -789,50 +789,7 @@ body.modal-open {
color: #666;
font-size: 14px;
}
/* Modal Styles */
.hvac-modal {
display: none;
position: fixed;
z-index: 999999;
left: 0;
top: 0;
width: 100%;
height: 100%;
overflow: auto;
background-color: rgba(0, 0, 0, 0.6);
animation: fadeIn 0.3s;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
.hvac-modal .modal-content {
background-color: #fefefe;
margin: 40px auto;
padding: 0;
border-radius: 8px;
width: 90%;
max-width: 900px;
max-height: 90vh;
overflow-y: auto;
box-shadow: 0 5px 30px rgba(0, 0, 0, 0.3);
position: relative;
animation: slideIn 0.3s;
}
@keyframes slideIn {
from {
transform: translateY(-30px);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}
/* Duplicate modal styles removed - using .active class approach at lines 168-187 */
.hvac-modal .modal-close {
color: #aaa;

168
assets/css/zoho-admin.css Normal file
View file

@ -0,0 +1,168 @@
/**
* Zoho CRM Admin Styles
*
* @package HVACCommunityEvents
*/
/* Card Layout */
.hvac-zoho-wrap {
max-width: 1200px;
}
.hvac-zoho-wrap .card {
max-width: 100%;
padding: 20px;
margin-bottom: 20px;
}
.hvac-zoho-wrap h2 {
margin-top: 0;
padding-bottom: 10px;
border-bottom: 1px solid #ccd0d4;
}
/* Form Styles */
.hvac-zoho-wrap table.form-table th {
width: 200px;
}
.hvac-zoho-wrap .regular-text {
width: 400px;
}
.hvac-zoho-wrap code {
background: #f0f0f1;
padding: 4px 8px;
border-radius: 3px;
font-size: 13px;
}
/* Button Groups */
.hvac-zoho-wrap .button-group {
display: flex;
gap: 10px;
flex-wrap: wrap;
margin-top: 15px;
}
/* Sync Buttons */
.hvac-zoho-wrap .sync-button {
min-width: 150px;
}
/* Status Messages */
.hvac-zoho-wrap .sync-status {
margin-top: 15px;
padding: 10px;
background: #f8f9fa;
border-left: 4px solid #007cba;
}
.hvac-zoho-wrap .sync-status.success {
border-left-color: #00a32a;
}
.hvac-zoho-wrap .sync-status.error {
border-left-color: #d63638;
}
/* Connection Status */
#connection-status {
margin-top: 15px;
}
#connection-status .notice {
margin: 0;
}
/* Staging Mode Banner */
.hvac-zoho-wrap .staging-notice {
background: #fff3cd;
border: 1px solid #ffc107;
border-radius: 4px;
padding: 15px;
margin-bottom: 20px;
}
.hvac-zoho-wrap .staging-notice h3 {
margin: 0 0 10px 0;
color: #856404;
}
/* Debug Info */
.hvac-zoho-debug-info {
margin-top: 15px;
padding: 10px;
background: #f0f0f1;
border-radius: 4px;
font-size: 12px;
}
.hvac-zoho-debug-info details {
margin-top: 10px;
}
.hvac-zoho-debug-info summary {
cursor: pointer;
color: #007cba;
}
.hvac-zoho-debug-info pre {
margin: 10px 0 0 0;
white-space: pre-wrap;
word-wrap: break-word;
}
/* Credentials Form */
#zoho-credentials-form .description {
color: #646970;
font-style: italic;
margin-top: 5px;
}
/* Password Toggle */
#toggle-secret {
margin-left: 10px;
vertical-align: middle;
}
/* Sync Results */
.sync-results {
margin-top: 15px;
}
.sync-results ul {
margin: 10px 0;
padding-left: 20px;
}
.sync-results details {
margin-top: 10px;
}
.sync-results pre {
background: #f0f0f1;
padding: 10px;
overflow: auto;
max-height: 300px;
font-size: 12px;
}
/* Responsive */
@media screen and (max-width: 782px) {
.hvac-zoho-wrap table.form-table th {
width: auto;
}
.hvac-zoho-wrap .regular-text {
width: 100%;
}
.hvac-zoho-wrap .button-group {
flex-direction: column;
}
.hvac-zoho-wrap .sync-button {
width: 100%;
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 341 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 362 B

View file

@ -6,7 +6,7 @@
* @since 1.0.0
*/
(function($) {
(function ($) {
'use strict';
// Cache DOM elements
@ -17,7 +17,25 @@
let isLoading = false;
// Initialize on document ready
$(document).ready(function() {
$(document).ready(function () {
initTrainerDirectory();
});
/**
* Initialize trainer directory with retries
*/
function initTrainerDirectory(attempts = 0) {
// Critical dependency check
if (typeof hvac_find_trainer === 'undefined') {
if (attempts < 10) {
console.log(`[HVAC Find Trainer] Configuration object missing, retrying... (${attempts + 1}/10)`);
setTimeout(() => initTrainerDirectory(attempts + 1), 500);
return;
}
console.error('[HVAC Find Trainer] Failed to initialize: hvac_find_trainer object missing after retries');
return;
}
initializeElements();
bindEvents();
@ -30,11 +48,11 @@
interceptMapGeoMarkers();
// Additional MapGeo integration after map loads
setTimeout(function() {
setTimeout(function () {
initializeMapGeoEvents();
}, 2000); // Give MapGeo time to initialize
}
});
}
/**
* Initialize cached elements
@ -67,10 +85,10 @@
$('.igm_content_gutter').remove();
// Watch for any dynamic content injection from MapGeo
const observer = new MutationObserver(function(mutations) {
mutations.forEach(function(mutation) {
const observer = new MutationObserver(function (mutations) {
mutations.forEach(function (mutation) {
if (mutation.addedNodes.length) {
mutation.addedNodes.forEach(function(node) {
mutation.addedNodes.forEach(function (node) {
if (node.nodeType === 1) { // Element node
// Remove any MapGeo sidebar that gets added
if ($(node).hasClass('igm_content_right_1_3') ||
@ -109,7 +127,7 @@
// Create the main MapGeo handler function
// This replaces the early version created in the page template
window.hvacMainShowTrainerModal = function(data) {
window.hvacMainShowTrainerModal = function (data) {
console.log('MapGeo custom action triggered with data:', data);
// Method 1: Use profile_id if available (most reliable)
@ -153,7 +171,7 @@
};
// Extract certifications from card badges
$matchingCard.find('.hvac-trainer-cert-badge').each(function() {
$matchingCard.find('.hvac-trainer-cert-badge').each(function () {
const certText = $(this).text().trim();
if (certText && certText !== 'HVAC Trainer') {
trainerData.certifications.push({
@ -209,17 +227,17 @@
if (trainerName && trainerName !== '+') {
// Try to find matching trainer by name
let $matchingCard = $('.hvac-trainer-card').filter(function() {
let $matchingCard = $('.hvac-trainer-card').filter(function () {
const cardName = $(this).find('.hvac-trainer-name a, .hvac-trainer-name .hvac-champion-name').text().trim();
return cardName === trainerName;
});
// If exact match not found, try partial matching
if ($matchingCard.length === 0) {
$matchingCard = $('.hvac-trainer-card').filter(function() {
$matchingCard = $('.hvac-trainer-card').filter(function () {
const cardName = $(this).find('.hvac-trainer-name a, .hvac-trainer-name .hvac-champion-name').text().trim();
return cardName.toLowerCase().includes(trainerName.toLowerCase()) ||
trainerName.toLowerCase().includes(cardName.toLowerCase());
trainerName.toLowerCase().includes(cardName.toLowerCase());
});
}
@ -243,7 +261,7 @@
};
// Extract certifications from card badges
$matchingCard.find('.hvac-trainer-cert-badge').each(function() {
$matchingCard.find('.hvac-trainer-cert-badge').each(function () {
const certText = $(this).text().trim();
if (certText && certText !== 'HVAC Trainer') {
trainerData.certifications.push({
@ -260,7 +278,7 @@
} else {
console.warn('No matching trainer found for name:', trainerName);
console.log('Available trainers:',
$('.hvac-trainer-card .hvac-trainer-name a, .hvac-trainer-card .hvac-trainer-name .hvac-champion-name').map(function() {
$('.hvac-trainer-card .hvac-trainer-name a, .hvac-trainer-card .hvac-trainer-name .hvac-champion-name').map(function () {
return $(this).text().trim();
}).get()
);
@ -269,7 +287,7 @@
console.warn('Could not extract valid trainer identifier from MapGeo data:', data);
console.log('Available data properties:', Object.keys(data || {}));
console.log('Available profile IDs on page:',
$('.hvac-trainer-card').map(function() {
$('.hvac-trainer-card').map(function () {
return $(this).data('profile-id');
}).get()
);
@ -282,7 +300,7 @@
// Process any queued calls from before the main script loaded
if (window.hvacPendingModalCalls && window.hvacPendingModalCalls.length > 0) {
console.log('Processing', window.hvacPendingModalCalls.length, 'queued MapGeo calls');
window.hvacPendingModalCalls.forEach(function(data) {
window.hvacPendingModalCalls.forEach(function (data) {
window.hvacMainShowTrainerModal(data);
});
window.hvacPendingModalCalls = []; // Clear the queue
@ -300,7 +318,7 @@
// The actual marker clicks are handled via the MapGeo custom action: window.hvacShowTrainerModal
// Handle any legacy view profile links if they exist in tooltips/popups
$(document).on('click', '.hvac-view-profile, .hvac-marker-popup button', function(e) {
$(document).on('click', '.hvac-view-profile, .hvac-marker-popup button', function (e) {
e.preventDefault();
e.stopPropagation();
const profileId = $(this).data('profile-id');
@ -340,20 +358,20 @@
$('.hvac-modal-close').on('click', closeModals);
// Click on modal backdrop to close
$filterModal.on('click', function(e) {
$filterModal.on('click', function (e) {
if ($(e.target).is('#hvac-filter-modal')) {
closeModals();
}
});
$trainerModal.on('click', function(e) {
$trainerModal.on('click', function (e) {
if ($(e.target).is('#hvac-trainer-modal')) {
closeModals();
}
});
// Escape key to close modals
$(document).on('keydown', function(e) {
$(document).on('keydown', function (e) {
if (e.key === 'Escape') {
closeModals();
}
@ -401,40 +419,40 @@
filter_type: filterType,
nonce: hvac_find_trainer.nonce
})
.done(function(response) {
if (response.success && response.data.options) {
// Convert the different response formats to standard format
let options = [];
.done(function (response) {
if (response.success && response.data.options) {
// Convert the different response formats to standard format
let options = [];
if (filterType === 'business_type') {
// Business types have {value, label, count} format
options = response.data.options;
if (filterType === 'business_type') {
// Business types have {value, label, count} format
options = response.data.options;
} else {
// States and other simple arrays need to be converted to {value, label} format
options = response.data.options.map(function (option) {
if (typeof option === 'string') {
return { value: option, label: option };
}
return option;
});
}
showFilterModal({ options: options });
} else {
// States and other simple arrays need to be converted to {value, label} format
options = response.data.options.map(function(option) {
if (typeof option === 'string') {
return {value: option, label: option};
}
return option;
});
console.error('Failed to load filter options:', response);
// Fallback to empty options
showFilterModal({ options: [] });
}
showFilterModal({options: options});
} else {
console.error('Failed to load filter options:', response);
})
.fail(function (xhr, status, error) {
console.error('AJAX error loading filter options:', status, error);
// Fallback to empty options
showFilterModal({options: []});
}
})
.fail(function(xhr, status, error) {
console.error('AJAX error loading filter options:', status, error);
// Fallback to empty options
showFilterModal({options: []});
})
.always(function() {
isLoading = false;
$(`.hvac-filter-btn[data-filter="${filterType}"]`).removeClass('loading');
});
showFilterModal({ options: [] });
})
.always(function () {
isLoading = false;
$(`.hvac-filter-btn[data-filter="${filterType}"]`).removeClass('loading');
});
}
/**
@ -443,38 +461,38 @@
function getMockFilterOptions(filterType) {
const options = {
state: [
{value: 'Alabama', label: 'Alabama'},
{value: 'Alaska', label: 'Alaska'},
{value: 'Arizona', label: 'Arizona'},
{value: 'Arkansas', label: 'Arkansas'},
{value: 'California', label: 'California'},
{value: 'Colorado', label: 'Colorado'},
{value: 'Florida', label: 'Florida'},
{value: 'Georgia', label: 'Georgia'},
{value: 'Illinois', label: 'Illinois'},
{value: 'Michigan', label: 'Michigan'},
{value: 'Minnesota', label: 'Minnesota'},
{value: 'Ohio', label: 'Ohio'},
{value: 'Texas', label: 'Texas'},
{value: 'Wisconsin', label: 'Wisconsin'}
{ value: 'Alabama', label: 'Alabama' },
{ value: 'Alaska', label: 'Alaska' },
{ value: 'Arizona', label: 'Arizona' },
{ value: 'Arkansas', label: 'Arkansas' },
{ value: 'California', label: 'California' },
{ value: 'Colorado', label: 'Colorado' },
{ value: 'Florida', label: 'Florida' },
{ value: 'Georgia', label: 'Georgia' },
{ value: 'Illinois', label: 'Illinois' },
{ value: 'Michigan', label: 'Michigan' },
{ value: 'Minnesota', label: 'Minnesota' },
{ value: 'Ohio', label: 'Ohio' },
{ value: 'Texas', label: 'Texas' },
{ value: 'Wisconsin', label: 'Wisconsin' }
],
business_type: [
{value: 'Independent Contractor', label: 'Independent Contractor'},
{value: 'Small Business', label: 'Small Business'},
{value: 'Corporation', label: 'Corporation'},
{value: 'Non-Profit', label: 'Non-Profit'}
{ value: 'Independent Contractor', label: 'Independent Contractor' },
{ value: 'Small Business', label: 'Small Business' },
{ value: 'Corporation', label: 'Corporation' },
{ value: 'Non-Profit', label: 'Non-Profit' }
],
training_format: [
{value: 'In-Person', label: 'In-Person'},
{value: 'Virtual', label: 'Virtual'},
{value: 'Hybrid', label: 'Hybrid'},
{value: 'Self-Paced', label: 'Self-Paced'}
{ value: 'In-Person', label: 'In-Person' },
{ value: 'Virtual', label: 'Virtual' },
{ value: 'Hybrid', label: 'Hybrid' },
{ value: 'Self-Paced', label: 'Self-Paced' }
],
training_resources: [
{value: 'Video Tutorials', label: 'Video Tutorials'},
{value: 'Written Guides', label: 'Written Guides'},
{value: 'Hands-On Training', label: 'Hands-On Training'},
{value: 'Certification Programs', label: 'Certification Programs'}
{ value: 'Video Tutorials', label: 'Video Tutorials' },
{ value: 'Written Guides', label: 'Written Guides' },
{ value: 'Hands-On Training', label: 'Hands-On Training' },
{ value: 'Certification Programs', label: 'Certification Programs' }
]
};
@ -499,7 +517,7 @@
let optionsHtml = '';
const currentValues = activeFilters[currentFilter] || [];
data.options.forEach(function(option) {
data.options.forEach(function (option) {
const checked = currentValues.includes(option.value) ? 'checked' : '';
optionsHtml += `
<div class="hvac-filter-option">
@ -525,7 +543,7 @@
function applyFilters() {
const selectedValues = [];
$filterModal.find('.hvac-filter-option input:checked').each(function() {
$filterModal.find('.hvac-filter-option input:checked').each(function () {
selectedValues.push($(this).val());
});
@ -550,7 +568,7 @@
let html = '';
for (const [filter, values] of Object.entries(activeFilters)) {
values.forEach(function(value) {
values.forEach(function (value) {
html += `
<div class="hvac-active-filter" data-filter="${filter}" data-value="${value}">
${value}
@ -618,7 +636,7 @@
};
// Extract certifications from card badges
$card.find('.hvac-trainer-cert-badge').each(function() {
$card.find('.hvac-trainer-cert-badge').each(function () {
const certText = $(this).text().trim();
if (certText && certText !== 'HVAC Trainer') {
trainerData.certifications.push({
@ -658,7 +676,7 @@
cert.type === 'measureQuick Certified Trainer'
);
} else if (trainer.certification_type === 'Certified measureQuick Trainer' ||
trainer.certification_type === 'measureQuick Certified Trainer') {
trainer.certification_type === 'measureQuick Certified Trainer') {
// Fallback for legacy single certification
hasTrainerCert = true;
}
@ -678,7 +696,7 @@
if (trainer.certifications && trainer.certifications.length > 0) {
// Show multiple certifications as badges
trainer.certifications.forEach(function(cert) {
trainer.certifications.forEach(function (cert) {
const badgeClass = cert.type.toLowerCase()
.replace('measurequick certified ', '')
.replace(/\s+/g, '-');
@ -737,11 +755,11 @@
action: 'hvac_get_trainer_upcoming_events',
nonce: hvac_find_trainer.nonce,
profile_id: profileId
}, function(response) {
}, function (response) {
if (response.success && response.data.events) {
let eventsHtml = '';
if (response.data.events.length > 0) {
response.data.events.forEach(function(event) {
response.data.events.forEach(function (event) {
eventsHtml += `<li><a href="${event.url}" target="_blank">${event.title}</a> - ${event.date}</li>`;
});
} else {
@ -751,7 +769,7 @@
} else {
$trainerModal.find('.hvac-events-list').html('<li>No upcoming events scheduled</li>');
}
}).fail(function() {
}).fail(function () {
$trainerModal.find('.hvac-events-list').html('<li>Unable to load events</li>');
});
}
@ -771,14 +789,14 @@
$submitBtn.text('Sending...').prop('disabled', true);
// For now, just show success message
setTimeout(function() {
setTimeout(function () {
$successMsg.show();
$errorMsg.hide();
$form[0].reset();
$submitBtn.text(originalText).prop('disabled', false);
// Hide success message after 5 seconds
setTimeout(function() {
setTimeout(function () {
$successMsg.fadeOut();
}, 5000);
}, 1000);
@ -835,7 +853,7 @@
};
// Make AJAX request
$.post(hvac_find_trainer.ajax_url, data, function(response) {
$.post(hvac_find_trainer.ajax_url, data, function (response) {
if (response.success) {
// Our PHP returns an array of trainer card HTML
if (response.data.trainers && response.data.trainers.length > 0) {
@ -864,9 +882,9 @@
console.error('Failed to load trainers:', response);
$container.html('<div class="hvac-no-results"><p>Error loading trainers. Please try again.</p></div>');
}
}).fail(function(xhr) {
}).fail(function (xhr) {
console.error('AJAX error:', xhr);
}).always(function() {
}).always(function () {
isLoading = false;
$container.removeClass('hvac-loading');
});
@ -943,7 +961,7 @@
}
// Bind contact trainer button
$(document).on('click', '.hvac-contact-trainer-btn', function(e) {
$(document).on('click', '.hvac-contact-trainer-btn', function (e) {
e.preventDefault();
const profileId = $(this).data('profile-id');
showTrainerModal(profileId);

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

File diff suppressed because it is too large Load diff

View file

@ -24,27 +24,8 @@ jQuery(document).ready(function($) {
loadAnnouncements();
loadCategories();
initializeEventHandlers();
initializeEditor();
}
/**
* Initialize TinyMCE editor
*/
function initializeEditor() {
if (typeof wp !== 'undefined' && wp.editor) {
// Initialize WordPress editor
wp.editor.initialize('announcement-content', {
tinymce: {
wpautop: true,
plugins: 'lists link image media paste',
toolbar1: 'formatselect | bold italic | alignleft aligncenter alignright | bullist numlist | link unlink | wp_adv',
toolbar2: 'strikethrough hr forecolor pastetext removeformat charmap outdent indent undo redo wp_help',
height: 300
},
quicktags: true,
mediaButtons: true
});
}
// NOTE: Editor initialization removed - wp_editor() PHP function handles this
// The duplicate JavaScript initialization was causing the media modal to auto-open
}
/**
@ -251,7 +232,13 @@ jQuery(document).ready(function($) {
* Open the modal for adding/editing
*/
function openModal(announcementId) {
$('#announcement-modal').fadeIn();
const $modal = $('#announcement-modal');
$modal.addClass('active');
$('body').addClass('modal-open');
// Add custom "Add Media" button to TinyMCE editor toolbar
// This is needed because media_buttons => false in wp_editor() to prevent auto-open
addMediaButtonToEditor();
if (announcementId) {
$('#modal-title').text('Edit Announcement');
@ -262,11 +249,79 @@ jQuery(document).ready(function($) {
}
}
/**
* Add custom "Add Media" button to TinyMCE editor
* Required because wp_editor has media_buttons => false to prevent auto-open in hidden modal
*/
function addMediaButtonToEditor() {
// Check if button already exists
if ($('#wp-announcement-content-media-buttons').length > 0) {
return;
}
// Create media button container
const $mediaButton = $('<div id="wp-announcement-content-media-buttons" class="wp-media-buttons"></div>');
const $addMediaBtn = $('<button type="button" id="insert-media-button" class="button insert-media add_media" data-editor="announcement-content"></button>');
$addMediaBtn.html('<span class="wp-media-buttons-icon"></span> Add Media');
// Add click handler
$addMediaBtn.on('click', function(e) {
e.preventDefault();
openEditorMediaUploader();
});
$mediaButton.append($addMediaBtn);
// Insert button before editor wrapper
$('#wp-announcement-content-wrap').before($mediaButton);
}
/**
* Open media uploader for the announcement content editor
*/
function openEditorMediaUploader() {
if (typeof wp.media === 'undefined') {
return;
}
const editorMediaUploader = wp.media({
title: 'Insert Media',
button: {
text: 'Insert into post'
},
multiple: true
});
editorMediaUploader.on('select', function() {
const selection = editorMediaUploader.state().get('selection');
const editor = tinymce.get('announcement-content');
selection.forEach(function(attachment) {
attachment = attachment.toJSON();
let html = '';
if (attachment.type === 'image') {
html = '<img src="' + attachment.url + '" alt="' + (attachment.alt || '') + '" />';
} else {
html = '<a href="' + attachment.url + '">' + attachment.title + '</a>';
}
if (editor) {
editor.insertContent(html);
}
});
});
editorMediaUploader.open();
}
/**
* Close the modal
*/
function closeModal() {
$('#announcement-modal').fadeOut();
const $modal = $('#announcement-modal');
$modal.removeClass('active');
$('body').removeClass('modal-open');
resetForm();
}
@ -277,9 +332,12 @@ jQuery(document).ready(function($) {
$('#announcement-form')[0].reset();
$('#announcement-id').val('');
// Reset editor
if (wp.editor) {
wp.editor.setContent('announcement-content', '');
// Reset editor using TinyMCE native API
if (typeof tinymce !== 'undefined') {
const editor = tinymce.get('announcement-content');
if (editor) {
editor.setContent('');
}
}
// Reset featured image
@ -317,9 +375,12 @@ jQuery(document).ready(function($) {
$('#announcement-status').val(announcement.status);
$('#announcement-tags').val(announcement.tags);
// Set content in editor
if (wp.editor) {
wp.editor.setContent('announcement-content', announcement.content);
// Set content in editor using TinyMCE native API
if (typeof tinymce !== 'undefined') {
const editor = tinymce.get('announcement-content');
if (editor) {
editor.setContent(announcement.content || '');
}
}
// Set publish date
@ -350,10 +411,17 @@ jQuery(document).ready(function($) {
* Save announcement (create or update)
*/
function saveAnnouncement() {
// Get editor content
// Get editor content using WordPress editor API or TinyMCE native API
let content = '';
if (wp.editor) {
if (typeof wp !== 'undefined' && wp.editor && wp.editor.getContent) {
// Use WordPress API if available (WordPress 4.8+)
content = wp.editor.getContent('announcement-content');
} else if (typeof tinymce !== 'undefined') {
// Fallback to TinyMCE native API
const editor = tinymce.get('announcement-content');
if (editor) {
content = editor.getContent();
}
}
// Gather form data

View file

@ -0,0 +1,156 @@
/**
* Master Trainers Overview JavaScript
* Handles AJAX loading of trainer data and filtering
*/
(function($) {
'use strict';
// Wait for DOM and dependencies
$(document).ready(function() {
console.log('[HVAC] Master Trainers Overview: Initializing...');
// Check if we're on the trainers page
if ($('#hvac-master-trainers-overview').length === 0) {
console.log('[HVAC] Master Trainers Overview: Not on trainers page, skipping initialization');
return;
}
console.log('[HVAC] Master Trainers Overview: Found trainers container, loading data...');
// Load initial stats
loadTrainerStats();
// Load initial trainers list
loadTrainersList();
// Handle filter changes
$('#hvac-trainers-filters').on('submit', function(e) {
e.preventDefault();
loadTrainersList();
});
// Handle filter changes on select/input
$('#filter-status, #filter-region, #filter-search').on('change keyup', debounce(function() {
loadTrainersList();
}, 500));
});
/**
* Load trainer statistics
*/
function loadTrainerStats() {
console.log('[HVAC] Loading trainer stats...');
$.ajax({
url: hvac_master_trainers_ajax.ajax_url,
type: 'POST',
data: {
action: 'hvac_master_trainers_stats',
nonce: hvac_master_trainers_ajax.nonce
},
success: function(response) {
console.log('[HVAC] Stats loaded successfully', response);
if (response.success && response.data.html) {
$('#hvac-stats-tiles').html(response.data.html).show();
$('#hvac-stats-loading').hide();
} else {
console.error('[HVAC] Stats load failed:', response);
$('#hvac-stats-loading').html('<p class="hvac-error">Failed to load statistics.</p>');
}
},
error: function(xhr, status, error) {
console.error('[HVAC] Stats AJAX error:', status, error);
$('#hvac-stats-loading').html('<p class="hvac-error">Error loading statistics.</p>');
}
});
}
/**
* Load trainers list with current filters
*/
function loadTrainersList() {
console.log('[HVAC] Loading trainers list...');
// Show loading state
$('#hvac-trainers-loading').show();
$('#hvac-trainers-table-container').hide();
// Get filter values
var filters = {
action: 'hvac_master_trainers_filter',
nonce: hvac_master_trainers_ajax.nonce,
status: $('#filter-status').val() || 'all',
region: $('#filter-region').val() || '',
search: $('#filter-search').val() || ''
};
console.log('[HVAC] Filter values:', filters);
$.ajax({
url: hvac_master_trainers_ajax.ajax_url,
type: 'POST',
data: filters,
success: function(response) {
console.log('[HVAC] Trainers loaded successfully', response);
if (response.success && response.data.html) {
$('#hvac-trainers-table-container').html(response.data.html).show();
$('#hvac-trainers-loading').hide();
// Initialize any interactive elements in the table
initializeTrainerActions();
} else {
console.error('[HVAC] Trainers load failed:', response);
$('#hvac-trainers-loading').html('<p class="hvac-error">Failed to load trainers.</p>');
}
},
error: function(xhr, status, error) {
console.error('[HVAC] Trainers AJAX error:', status, error);
$('#hvac-trainers-loading').html('<p class="hvac-error">Error loading trainers.</p>');
}
});
}
/**
* Initialize trainer action buttons
*/
function initializeTrainerActions() {
console.log('[HVAC] Initializing trainer actions...');
// Handle view profile buttons
$('.hvac-view-trainer').on('click', function(e) {
e.preventDefault();
var trainerId = $(this).data('trainer-id');
console.log('[HVAC] View trainer:', trainerId);
// Navigate to trainer profile
window.location.href = $(this).attr('href');
});
// Handle edit buttons
$('.hvac-edit-trainer').on('click', function(e) {
e.preventDefault();
var trainerId = $(this).data('trainer-id');
console.log('[HVAC] Edit trainer:', trainerId);
window.location.href = $(this).attr('href');
});
// Any other interactive elements...
}
/**
* Debounce function for search input
*/
function debounce(func, wait) {
var timeout;
return function() {
var context = this, args = arguments;
clearTimeout(timeout);
timeout = setTimeout(function() {
func.apply(context, args);
}, wait);
};
}
})(jQuery);

View file

@ -7,20 +7,20 @@
* @since 2.0.0
*/
(function() {
(function () {
'use strict';
// Safety configuration
const config = window.HVAC_MapGeo_Config || {
maxRetries: 3,
retryDelay: 2000,
timeout: 10000,
timeout: 30000, // Increased to 30s
fallbackEnabled: true,
debugMode: false
};
const log = config.debugMode ? console.log.bind(console) : () => {};
const error = config.debugMode ? console.error.bind(console) : () => {};
const log = config.debugMode ? console.log.bind(console) : () => { };
const error = config.debugMode ? console.error.bind(console) : () => { };
log('[MapGeo Safety] Initializing protection system');
@ -45,7 +45,7 @@
const originalAppendChild = Element.prototype.appendChild;
const self = this;
Element.prototype.appendChild = function(element) {
Element.prototype.appendChild = function (element) {
if (element.tagName === 'SCRIPT' && element.src) {
self.monitorScript(element);
}
@ -67,9 +67,22 @@
if (isCritical) {
log('[MapGeo Safety] Monitoring critical resource:', src);
// Use Performance API to check if already loaded
if (performance.getEntriesByName(src).length > 0) {
log('[MapGeo Safety] Resource already loaded (Performance API):', src);
this.resources.set(src, 'loaded');
return;
}
const timeoutId = setTimeout(() => {
error('[MapGeo Safety] Resource timeout:', src);
this.handleResourceFailure(src);
// Double check with Performance API before failing
if (performance.getEntriesByName(src).length > 0) {
log('[MapGeo Safety] Resource loaded just in time (Performance API):', src);
this.resources.set(src, 'loaded');
} else {
error('[MapGeo Safety] Resource timeout:', src);
this.handleResourceFailure(src);
}
}, config.timeout);
script.addEventListener('load', () => {
@ -210,33 +223,70 @@
class DOMReadySafety {
constructor() {
this.setupSafety();
this.checkjQueryWithRetry();
}
setupSafety() {
// Intercept jQuery ready calls that might contain MapGeo code
if (typeof jQuery !== 'undefined') {
const originalReady = jQuery.fn.ready;
jQuery.fn.ready = function(callback) {
const wrappedCallback = function() {
try {
// Check if MapGeo elements exist before running
const hasMapElements = document.querySelector(
'.igm-map-container, [class*="mapgeo"], [id*="map-"]'
);
if (hasMapElements || !callback.toString().includes('map')) {
return callback.apply(this, arguments);
} else {
log('[MapGeo Safety] Skipping map-related ready callback - no map elements found');
}
} catch (e) {
error('[MapGeo Safety] Error in ready callback:', e);
this.wrapjQueryReady();
} else {
// If jQuery isn't loaded yet, define a property to trap it when it loads
Object.defineProperty(window, 'jQuery', {
configurable: true,
enumerable: true,
get: function () {
return this._jQuery;
},
set: function (val) {
this._jQuery = val;
// Once jQuery is set, wrap its ready function
if (val && val.fn && val.fn.ready) {
DOMReadySafety.prototype.wrapjQueryReady.call(this);
}
};
}
});
}
}
return originalReady.call(this, wrappedCallback);
wrapjQueryReady() {
const originalReady = jQuery.fn.ready;
// Guard to prevent double wrapping
if (originalReady._hvacWrapped) return;
jQuery.fn.ready = function (callback) {
const wrappedCallback = function () {
try {
// Check if MapGeo elements exist before running
const hasMapElements = document.querySelector(
'.igm-map-container, [class*="mapgeo"], [id*="map-"]'
);
if (hasMapElements || !callback.toString().includes('map')) {
return callback.apply(this, arguments);
} else {
log('[MapGeo Safety] Skipping map-related ready callback - no map elements found');
}
} catch (e) {
error('[MapGeo Safety] Error in ready callback:', e);
}
};
return originalReady.call(this, wrappedCallback);
};
jQuery.fn.ready._hvacWrapped = true;
log('[MapGeo Safety] jQuery.ready wrapped successfully');
}
checkjQueryWithRetry(attempts = 0) {
if (typeof window.jQuery !== 'undefined') {
log('[MapGeo Safety] jQuery detected successfully');
return;
}
if (attempts < 20) { // Retry for ~10 seconds (500ms * 20)
setTimeout(() => this.checkjQueryWithRetry(attempts + 1), 500);
} else {
log('[MapGeo Safety] jQuery not detected after multiple retries (might be loaded async later)');
}
}
}
@ -252,7 +302,7 @@
'https://cdn.amcharts.com/lib/version/4.10.29/maps.js',
'https://cdn.amcharts.com/lib/4/geodata/usaLow.js'
];
this.timeout = 5000; // 5 second timeout
this.timeout = 15000; // Increased to 15s
this.cacheKey = 'hvac_cdn_health';
this.cacheExpiry = 10 * 60 * 1000; // 10 minutes
}
@ -381,9 +431,9 @@
if (mapLoaded) {
log('[MapGeo Safety] Map loaded successfully');
clearInterval(healthCheckInterval);
} else if (healthCheckCount >= 6) {
// Reduced to 6 seconds since we already verified CDN
error('[MapGeo Safety] Map failed to load after 6 seconds (CDN was healthy)');
} else if (healthCheckCount >= 30) {
// Increased to 30 seconds to match global timeout
error('[MapGeo Safety] Map failed to load after 30 seconds');
clearInterval(healthCheckInterval);
// Activate fallback if configured

View file

@ -1,9 +1,194 @@
/**
* Zoho CRM Admin JavaScript
*
* @package HVACCommunityEvents
*/
jQuery(document).ready(function($) {
jQuery(document).ready(function ($) {
// =====================================================
// Password visibility toggle
// =====================================================
$('#toggle-secret').on('click', function () {
var passwordField = $('#zoho_client_secret');
var toggleBtn = $(this);
if (passwordField.attr('type') === 'password') {
passwordField.attr('type', 'text');
toggleBtn.text('Hide');
} else {
passwordField.attr('type', 'password');
toggleBtn.text('Show');
}
});
// =====================================================
// Copy redirect URI to clipboard
// =====================================================
$('#copy-redirect-uri').on('click', function () {
var redirectUri = hvacZoho.redirectUri || '';
if (!redirectUri) {
alert('Redirect URI not available');
return;
}
navigator.clipboard.writeText(redirectUri).then(function () {
$('#copy-redirect-uri').text('Copied!').prop('disabled', true);
setTimeout(function () {
$('#copy-redirect-uri').text('Copy').prop('disabled', false);
}, 2000);
}).catch(function () {
// Fallback for older browsers
var tempInput = $('<input>');
$('body').append(tempInput);
tempInput.val(redirectUri).select();
document.execCommand('copy');
tempInput.remove();
$('#copy-redirect-uri').text('Copied!').prop('disabled', true);
setTimeout(function () {
$('#copy-redirect-uri').text('Copy').prop('disabled', false);
}, 2000);
});
});
// =====================================================
// Flush rewrite rules
// =====================================================
$('#flush-rewrite-rules').on('click', function () {
var button = $(this);
button.prop('disabled', true).text('Flushing...');
$.post(hvacZoho.ajaxUrl, {
action: 'hvac_zoho_flush_rewrite_rules'
}, function (response) {
if (response.success) {
button.text('Flushed!').css('color', '#46b450');
setTimeout(function () {
location.reload();
}, 1000);
} else {
button.text('Error').css('color', '#dc3232');
setTimeout(function () {
button.prop('disabled', false).text('Flush Rules').css('color', '');
}, 2000);
}
});
});
// =====================================================
// Credentials form submission
// =====================================================
// =====================================================
// Credentials form submission
// =====================================================
$('#zoho-credentials-form').on('submit', function (e) {
e.preventDefault();
var formData = {
action: 'hvac_zoho_save_credentials',
zoho_client_id: $('#zoho_client_id').val(),
zoho_client_secret: $('#zoho_client_secret').val(),
nonce: $('input[name="hvac_zoho_nonce"]').val()
};
var $saveBtn = $('#save-credentials');
$saveBtn.prop('disabled', true).text('Saving...');
// Clear any previous messages
$('.notice').remove();
$.ajax({
url: hvacZoho.ajaxUrl,
method: 'POST',
data: formData,
success: function (response) {
if (response.success) {
window.location.href = window.location.href.split('?')[0] + '?page=hvac-zoho-sync&credentials_saved=1';
} else {
// Create error notice
var errorHtml = '<div class="notice notice-error is-dismissible"><p>' +
'<strong>Error saving credentials:</strong> ' + response.data.message +
'</p></div>';
$('h1').after(errorHtml);
$saveBtn.prop('disabled', false).text('Save Credentials');
}
},
error: function (xhr, status, error) {
console.error('Zoho Save Error:', xhr);
var errorMessage = 'Unknown error occurred';
var errorDetails = '';
if (xhr.status === 400 || xhr.status === 403) {
errorMessage = 'Request blocked (' + xhr.status + ')';
errorDetails = 'This is likely due to a security plugin or Web Application Firewall (WAF) blocking the request. ' +
'The content (e.g. Client Secret) might be triggering a false positive security rule.';
} else if (xhr.status === 500) {
errorMessage = 'Server Error (500)';
errorDetails = 'Check the server error logs for more information.';
} else if (status === 'timeout') {
errorMessage = 'Request timed out';
} else if (xhr.responseJSON && xhr.responseJSON.data && xhr.responseJSON.data.message) {
errorMessage = xhr.responseJSON.data.message;
} else {
errorMessage = error || status;
}
var errorHtml = '<div class="notice notice-error is-dismissible" style="border-left-color: #dc3232;">' +
'<p><strong>❌ Save Failed:</strong> ' + errorMessage + '</p>';
if (errorDetails) {
errorHtml += '<p><em>' + errorDetails + '</em></p>';
}
// Add debug details
errorHtml += '<details style="margin-top: 10px;">' +
'<summary>Technical Details</summary>' +
'<ul style="margin-top: 5px; font-size: 12px; background: #f0f0f0; padding: 10px;">' +
'<li>Status: ' + xhr.status + ' ' + xhr.statusText + '</li>' +
'<li>Response: ' + (xhr.responseText ? xhr.responseText.substring(0, 100) + '...' : '(empty)') + '</li>' +
'</ul>' +
'</details></div>';
$('h1').after(errorHtml);
// Re-enable button
$saveBtn.prop('disabled', false).text('Save Credentials');
// Scroll to error
$('html, body').animate({ scrollTop: 0 }, 'slow');
}
});
});
// =====================================================
// OAuth authorization handler
// =====================================================
$('#start-oauth').on('click', function () {
var clientId = $('#zoho_client_id').val();
var clientSecret = $('#zoho_client_secret').val();
if (!clientId || !clientSecret) {
alert('Please save your credentials first before starting OAuth authorization.');
return;
}
// Use server-generated OAuth URL with CSRF state parameter
var oauthUrl = hvacZoho.oauthUrl || '';
if (!oauthUrl) {
alert('OAuth URL not available. Please save your credentials first and refresh the page.');
return;
}
// Open OAuth URL in the same window to handle callback properly
window.location.href = oauthUrl;
});
// =====================================================
// Test connection
$('#test-connection').on('click', function() {
// =====================================================
$('#test-connection').on('click', function () {
var $button = $(this);
var $status = $('#connection-status');
@ -17,7 +202,7 @@ jQuery(document).ready(function($) {
action: 'hvac_zoho_test_connection',
nonce: hvacZoho.nonce
},
success: function(response) {
success: function (response) {
if (response.success) {
var successHtml = '<div class="notice notice-success">';
successHtml += '<p><strong>' + response.data.message + '</strong></p>';
@ -35,6 +220,13 @@ jQuery(document).ready(function($) {
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
if (response.data.debug) {
successHtml += '<details style="margin-top: 10px;">';
@ -60,7 +252,7 @@ jQuery(document).ready(function($) {
if (response.data.next_steps) {
authHtml += '<ol>';
response.data.next_steps.forEach(function(step) {
response.data.next_steps.forEach(function (step) {
authHtml += '<li>' + step + '</li>';
});
authHtml += '</ol>';
@ -113,8 +305,8 @@ jQuery(document).ready(function($) {
errorHtml += '<details>';
errorHtml += '<summary>Raw Response Data (click to expand)</summary>';
errorHtml += '<pre style="background: #f0f0f0; padding: 10px; max-height: 300px; overflow: auto;">' +
JSON.stringify(JSON.parse(response.data.raw), null, 2) +
'</pre>';
JSON.stringify(JSON.parse(response.data.raw), null, 2) +
'</pre>';
errorHtml += '</details>';
}
@ -128,8 +320,8 @@ jQuery(document).ready(function($) {
errorHtml += '<details>';
errorHtml += '<summary>Stack Trace (click to expand)</summary>';
errorHtml += '<pre style="background: #f0f0f0; padding: 10px; max-height: 300px; overflow: auto;">' +
response.data.trace +
'</pre>';
response.data.trace +
'</pre>';
errorHtml += '</details>';
}
@ -139,7 +331,7 @@ jQuery(document).ready(function($) {
$status.html(errorHtml);
}
},
error: function(xhr, status, error) {
error: function (xhr, status, error) {
var errorHtml = '<div class="notice notice-error">';
errorHtml += '<p><strong>AJAX Error:</strong> Connection test failed</p>';
errorHtml += '<p><strong>Status:</strong> ' + status + '</p>';
@ -148,20 +340,34 @@ jQuery(document).ready(function($) {
$status.html(errorHtml);
},
complete: function() {
complete: function () {
$button.prop('disabled', false).text('Test Connection');
}
});
});
// Sync data
$('.sync-button').on('click', function() {
var $button = $(this);
var type = $button.data('type');
var $status = $('#' + type + '-status');
// =====================================================
// Sync data with batch progress
// =====================================================
$button.prop('disabled', true).text('Syncing...');
$status.html('<p>Syncing ' + type + '...</p>');
/**
* Sync with progress - auto-continues through all batches
* @param {jQuery} $button - The sync button
* @param {string} type - Sync type (events, users, attendees, rsvps, purchases)
* @param {jQuery} $status - Status container element
* @param {number} offset - Current offset
* @param {object} accumulated - Accumulated results across batches
*/
function syncWithProgress($button, type, $status, offset, accumulated) {
accumulated = accumulated || {
synced: 0,
failed: 0,
errors: [],
total: 0,
staging_mode: false,
responses: [],
test_data: []
};
$.ajax({
url: hvacZoho.ajaxUrl,
@ -169,52 +375,157 @@ jQuery(document).ready(function($) {
data: {
action: 'hvac_zoho_sync_data',
type: type,
offset: offset,
nonce: hvacZoho.nonce
},
success: function(response) {
success: function (response) {
if (response.success) {
var result = response.data;
var html = '<div class="notice notice-success">';
if (result.staging_mode) {
html += '<h4>🔧 STAGING MODE - Simulation Results</h4>';
html += '<p>' + result.message + '</p>';
} else {
html += '<p>Sync completed successfully!</p>';
// Update accumulated totals
accumulated.total = result.total; // Total is consistent across batches
accumulated.synced += result.synced;
accumulated.failed += result.failed;
accumulated.staging_mode = result.staging_mode;
// Merge arrays
if (result.errors && result.errors.length > 0) {
accumulated.errors = accumulated.errors.concat(result.errors);
}
if (result.responses && result.responses.length > 0) {
accumulated.responses = accumulated.responses.concat(result.responses);
}
html += '<ul>' +
'<li>Total records: ' + result.total + '</li>' +
'<li>Synced: ' + result.synced + '</li>' +
'<li>Failed: ' + result.failed + '</li>' +
'</ul>';
if (result.test_data && result.test_data.length > 0) {
html += '<details>' +
'<summary>View test data (first 5 records)</summary>' +
'<pre style="background: #f0f0f0; padding: 10px; overflow: auto;">' +
JSON.stringify(result.test_data.slice(0, 5), null, 2) +
'</pre>' +
'</details>';
accumulated.test_data = accumulated.test_data.concat(result.test_data);
}
html += '</div>';
$status.html(html);
// Calculate progress
var processed = accumulated.synced + accumulated.failed;
var percent = accumulated.total > 0 ? Math.round((processed / accumulated.total) * 100) : 0;
// Update progress bar
var progressHtml = '<div class="sync-progress-bar" style="margin: 10px 0;">' +
'<div style="background: #e0e0e0; border-radius: 4px; overflow: hidden; height: 20px;">' +
'<div style="background: linear-gradient(90deg, #0073aa, #00a0d2); height: 100%; width: ' + percent + '%; transition: width 0.3s;"></div>' +
'</div>' +
'<p style="margin: 5px 0; font-size: 13px;">' +
'<strong>' + processed + ' of ' + accumulated.total + '</strong> processed (' + percent + '%)' +
'</p></div>';
$status.html(progressHtml);
// Check if there are more batches
if (result.has_more && result.next_offset > offset) {
// Continue with next batch
syncWithProgress($button, type, $status, result.next_offset, accumulated);
} else {
// All done! Show final results
displaySyncResults($button, type, $status, accumulated, result);
}
} else {
$status.html('<div class="notice notice-error"><p>' + response.data.message + ': ' + response.data.error + '</p></div>');
$button.prop('disabled', false).text('Sync ' + type.charAt(0).toUpperCase() + type.slice(1));
}
},
error: function() {
$status.html('<div class="notice notice-error"><p>Sync failed</p></div>');
},
complete: function() {
error: function () {
$status.html('<div class="notice notice-error"><p>Sync failed - network or server error</p></div>');
$button.prop('disabled', false).text('Sync ' + type.charAt(0).toUpperCase() + type.slice(1));
}
});
}
/**
* Display final sync results
*/
function displaySyncResults($button, type, $status, accumulated, lastResult) {
var html = '<div class="notice notice-success">';
if (accumulated.staging_mode) {
html += '<h4>🔧 STAGING MODE - Simulation Complete</h4>';
html += '<p>No data was sent to Zoho CRM. This is a dry-run showing what would sync.</p>';
} else {
html += '<p><strong>✅ Sync completed successfully!</strong></p>';
}
if (lastResult.version) {
html += '<p><strong>Server Code Version:</strong> ' + lastResult.version + '</p>';
}
html += '<ul>' +
'<li>Total records: ' + accumulated.total + '</li>' +
'<li>Synced: ' + accumulated.synced + '</li>' +
'<li>Failed: ' + accumulated.failed + '</li>' +
'</ul>';
// Show test data for staging
if (accumulated.test_data && accumulated.test_data.length > 0) {
html += '<details>' +
'<summary>View test data (first 5 records)</summary>' +
'<pre style="background: #f0f0f0; padding: 10px; overflow: auto; max-height: 300px;">' +
JSON.stringify(accumulated.test_data.slice(0, 5), null, 2) +
'</pre>' +
'</details>';
}
// Debug info
if (lastResult.debug_info) {
html += '<details style="margin-top: 10px;">' +
'<summary>🔍 Debug: Mode Detection Info</summary>' +
'<div style="background: #f0f0f0; padding: 10px; font-size: 12px; margin-top: 5px;">';
if (typeof lastResult.debug_info.is_staging !== 'undefined') {
html += '<p><strong>Is Staging:</strong> ' + (lastResult.debug_info.is_staging ? '✅ YES' : '❌ NO') + '</p>';
}
if (lastResult.debug_info.site_url) {
html += '<p><strong>Site URL:</strong> ' + lastResult.debug_info.site_url + '</p>';
}
html += '<pre style="background: #e0e0e0; padding: 5px; margin-top: 5px; max-height: 150px; overflow: auto;">' +
JSON.stringify(lastResult.debug_info, null, 2) +
'</pre>' +
'</div>' +
'</details>';
}
// Show errors if any
if (accumulated.errors && accumulated.errors.length > 0) {
html += '<details style="margin-top: 10px; border: 2px solid #dc3232;">' +
'<summary style="font-weight: bold; color: #dc3232;">❌ Errors (' + accumulated.errors.length + ')</summary>' +
'<pre style="background: #fff0f0; padding: 10px; overflow: auto; max-height: 300px;">' +
JSON.stringify(accumulated.errors.slice(0, 20), null, 2) +
'</pre>' +
'</details>';
}
// Show API responses preview
if (accumulated.responses && accumulated.responses.length > 0) {
html += '<details style="margin-top: 10px;">' +
'<summary>📡 Raw API Responses (first 10)</summary>' +
'<pre style="background: #f0f0f0; padding: 10px; overflow: auto; max-height: 200px;">' +
JSON.stringify(accumulated.responses.slice(0, 10), null, 2) +
'</pre>' +
'</details>';
}
html += '</div>';
$status.html(html);
$button.prop('disabled', false).text('Sync ' + type.charAt(0).toUpperCase() + type.slice(1));
}
// Sync button click handler
$('.sync-button').on('click', function () {
var $button = $(this);
var type = $button.data('type');
var $status = $('#' + type + '-status');
$button.prop('disabled', true).text('Syncing...');
$status.html('<p>Starting sync for ' + type + '...</p>');
// Start sync with batch progress
syncWithProgress($button, type, $status, 0, null);
});
// Save settings
$('#zoho-settings-form').on('submit', function(e) {
$('#zoho-settings-form').on('submit', function (e) {
e.preventDefault();
var $form = $(this);
@ -231,14 +542,10 @@ jQuery(document).ready(function($) {
auto_sync: $form.find('input[name="auto_sync"]').is(':checked') ? '1' : '0',
sync_frequency: $form.find('select[name="sync_frequency"]').val()
},
success: function(response) {
success: function (response) {
if (response.success) {
// Use toast notification instead of alert
if (window.HVACToast) {
HVACToast.success('Settings saved successfully!');
} else {
alert('Settings saved successfully!');
}
// Reload page to show updated status
window.location.reload();
} else {
// Use toast notification instead of alert
if (window.HVACToast) {
@ -246,19 +553,202 @@ jQuery(document).ready(function($) {
} else {
alert('Error saving settings: ' + response.data.message);
}
$button.prop('disabled', false).text('Save Settings');
}
},
error: function() {
error: function () {
// Use toast notification instead of alert
if (window.HVACToast) {
HVACToast.error('Error saving settings');
} else {
alert('Error saving settings');
}
},
complete: function() {
$button.prop('disabled', false).text('Save Settings');
}
});
});
// =====================================================
// Run Scheduled Sync Now Handler
// =====================================================
$('#run-scheduled-sync-now').on('click', function () {
var $button = $(this);
var $status = $('#scheduled-sync-status');
$button.prop('disabled', true).text('Running...');
$status.html('<p>Starting scheduled sync...</p>');
$.ajax({
url: hvacZoho.ajaxUrl,
method: 'POST',
data: {
action: 'hvac_zoho_run_scheduled_sync',
nonce: hvacZoho.nonce
},
success: function (response) {
if (response.success) {
var result = response.data.result;
var html = '<div class="notice notice-success">';
if (result.events && result.events.staging_mode) {
html += '<h4>🔧 STAGING MODE - Simulation Complete</h4>';
html += '<p>No data was sent to Zoho CRM.</p>';
} else {
html += '<p><strong>✅ Scheduled sync completed!</strong></p>';
}
html += '<ul>';
html += '<li>Total synced: ' + (result.total_synced || 0) + '</li>';
html += '<li>Total failed: ' + (result.total_failed || 0) + '</li>';
html += '<li>Duration: ' + (result.duration_seconds || 0) + ' seconds</li>';
html += '</ul>';
// Show details per type
html += '<details><summary>Details by type</summary><ul>';
['events', 'users', 'attendees', 'rsvps', 'purchases'].forEach(function (type) {
if (result[type]) {
html += '<li><strong>' + type + ':</strong> ' +
(result[type].synced || 0) + ' synced, ' +
(result[type].failed || 0) + ' failed</li>';
}
});
html += '</ul></details>';
html += '</div>';
$status.html(html);
} else {
$status.html('<div class="notice notice-error"><p>Sync failed: ' + response.data.message + '</p></div>');
}
},
error: function () {
$status.html('<div class="notice notice-error"><p>Error running scheduled sync</p></div>');
},
complete: function () {
$button.prop('disabled', false).text('🔄 Run Sync Now');
}
});
});
// =====================================================
// 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').on('click', function () {
var $button = $(this);
$button.prop('disabled', true).text('Testing...');
// Remove existing notices
$('.notice').remove();
// Test 1: Simple GET
var runSimpleTest = function () {
return $.ajax({
url: hvacZoho.ajaxUrl,
method: 'POST',
data: {
action: 'hvac_zoho_simple_test'
}
});
};
// Test 2: Payload Test (simulates credentials)
var runPayloadTest = function () {
var fakeId = '1000.' + new Array(20).join('a');
var fakeSecret = new Array(30).join('b');
return $.ajax({
url: hvacZoho.ajaxUrl,
method: 'POST',
data: {
action: 'hvac_zoho_simple_test',
test_payload: 'SIMULATED_CREDENTIALS',
zoho_client_id: fakeId,
zoho_client_secret: fakeSecret
}
});
};
// Execute tests sequence
runSimpleTest()
.then(function (response) {
if (response.success) {
console.log('Simple test passed');
return runPayloadTest();
} else {
return $.Deferred().reject({ status: 200, statusText: 'OK', responseJSON: response });
}
})
.then(function (response) {
if (response.success) {
// Success!
var successHtml = '<div class="notice notice-success is-dismissible">' +
'<p><strong>✅ Diagnostic Test Passed</strong></p>' +
'<p>AJAX requests are working correctly. No WAF blocking detected for credential-like data.</p>' +
'</div>';
$('h1').after(successHtml);
} else {
return $.Deferred().reject({ status: 200, statusText: 'OK', responseJSON: response });
}
})
.fail(function (xhr) {
var errorHtml = '<div class="notice notice-error is-dismissible">' +
'<p><strong>❌ Diagnostic Test Failed</strong></p>';
if (xhr.status === 400 || xhr.status === 403) {
errorHtml += '<p><strong>WAF Blocking Detected!</strong></p>';
errorHtml += '<p>The server returned ' + xhr.status + ' when sending data.</p>';
} else {
errorHtml += '<p>Status: ' + (xhr.status || 'Unknown') + '</p>';
if (xhr.responseJSON && xhr.responseJSON.data && xhr.responseJSON.data.message) {
errorHtml += '<p>Message: ' + xhr.responseJSON.data.message + '</p>';
}
}
errorHtml += '</div>';
$('h1').after(errorHtml);
})
.always(function () {
$button.prop('disabled', false).text('🏥 Run Diagnostic Test');
});
});
});

View file

@ -3,7 +3,7 @@
* Plugin Name: HVAC Community Events
* Plugin URI: https://upskillhvac.com
* Description: Custom plugin for HVAC trainer event management system
* Version: 2.0.0
* Version: 2.2.0
* Author: Upskill HVAC
* Author URI: https://upskillhvac.com
* License: GPL-2.0+
@ -17,6 +17,50 @@ if (!defined('ABSPATH')) {
exit;
}
/**
* Staging Email Filter
* Prevents emails from being sent to anyone except allowed addresses on staging.
* This protects real users from receiving test emails during development.
*/
function hvac_is_staging_environment() {
$host = isset( $_SERVER['HTTP_HOST'] ) ? $_SERVER['HTTP_HOST'] : '';
$staging_indicators = array( 'staging', 'upskill-staging', 'localhost', '127.0.0.1' );
foreach ( $staging_indicators as $indicator ) {
if ( stripos( $host, $indicator ) !== false ) {
return true;
}
}
return false;
}
function hvac_staging_email_filter( $args ) {
if ( ! hvac_is_staging_environment() ) {
return $args;
}
$allowed_emails = array( 'ben@tealmaker.com', 'ben@measurequick.com' );
$to = $args['to'];
// Extract email address
$email_address = is_array( $to ) ? ( isset( $to[0] ) ? $to[0] : '' ) : $to;
if ( preg_match( '/<([^>]+)>/', $email_address, $matches ) ) {
$email_address = $matches[1];
}
$email_address = trim( strtolower( $email_address ) );
if ( ! in_array( $email_address, $allowed_emails, true ) ) {
error_log( sprintf( '[HVAC Staging] Blocked email to: %s | Subject: %s',
is_array( $to ) ? implode( ', ', $to ) : $to, $args['subject'] ) );
$args['to'] = '';
return $args;
}
$args['subject'] = '[STAGING] ' . $args['subject'];
return $args;
}
add_filter( 'wp_mail', 'hvac_staging_email_filter', 1 );
// Load the main plugin class
require_once plugin_dir_path(__FILE__) . 'includes/class-hvac-plugin.php';

View file

@ -43,15 +43,33 @@ class HVAC_Zoho_Admin {
add_action('wp_ajax_hvac_zoho_sync_data', array($this, 'sync_data'));
add_action('wp_ajax_hvac_zoho_save_credentials', array($this, 'save_credentials'));
add_action('wp_ajax_hvac_zoho_flush_rewrite_rules', array($this, 'flush_rewrite_rules_ajax'));
add_action('wp_ajax_hvac_zoho_save_settings', array($this, 'save_settings'));
add_action('wp_ajax_hvac_zoho_run_scheduled_sync', array($this, 'run_scheduled_sync_now'));
// Add simple test handler
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_action('init', array($this, 'add_oauth_rewrite_rule'), 5);
add_filter('query_vars', array($this, 'add_oauth_query_vars'), 10, 1);
add_action('template_redirect', array($this, 'handle_oauth_template_redirect'));
// Fallback: Check for OAuth params on init (in case rewrite rules fail and we land on homepage)
add_action('init', array($this, 'check_for_oauth_params'));
// Ensure rewrite rules are flushed when plugin is activated
register_activation_hook(HVAC_PLUGIN_FILE, array($this, 'flush_rewrite_rules_on_activation'));
// Initialize scheduled sync if enabled
$this->init_scheduled_sync();
}
/**
* Initialize scheduled sync
*/
private function init_scheduled_sync() {
require_once HVAC_PLUGIN_DIR . 'includes/zoho/class-zoho-scheduled-sync.php';
HVAC_Zoho_Scheduled_Sync::instance();
}
/**
@ -77,6 +95,23 @@ class HVAC_Zoho_Admin {
return;
}
$site_url = get_site_url();
$redirect_uri = $site_url . '/oauth/callback';
// Get OAuth URL if credentials exist
$oauth_url = '';
if (!class_exists('HVAC_Secure_Storage')) {
require_once HVAC_PLUGIN_DIR . 'includes/class-hvac-secure-storage.php';
}
$client_id = HVAC_Secure_Storage::get_credential('hvac_zoho_client_id', '');
$client_secret = HVAC_Secure_Storage::get_credential('hvac_zoho_client_secret', '');
if (!empty($client_id) && !empty($client_secret)) {
require_once HVAC_PLUGIN_DIR . 'includes/zoho/class-zoho-crm-auth.php';
$auth = new HVAC_Zoho_CRM_Auth();
$oauth_url = $auth->get_authorization_url();
}
wp_enqueue_script(
'hvac-zoho-admin',
HVAC_PLUGIN_URL . 'assets/js/zoho-admin.js',
@ -87,22 +122,11 @@ class HVAC_Zoho_Admin {
wp_localize_script('hvac-zoho-admin', 'hvacZoho', array(
'ajaxUrl' => admin_url('admin-ajax.php'),
'nonce' => wp_create_nonce('hvac_zoho_nonce')
'nonce' => wp_create_nonce('hvac_zoho_nonce'),
'redirectUri' => $redirect_uri,
'oauthUrl' => $oauth_url
));
// Add inline script for debugging (only in development)
if (defined('WP_DEBUG') && WP_DEBUG && defined('WP_DEBUG_LOG') && WP_DEBUG_LOG) {
wp_add_inline_script('hvac-zoho-admin', '
console.log("Zoho admin script loaded");
jQuery(document).ready(function($) {
console.log("DOM ready, setting up click handler");
$(document).on("click", "#test-zoho-connection", function() {
console.log("Test button clicked - inline script");
});
});
');
}
wp_enqueue_style(
'hvac-zoho-admin',
HVAC_PLUGIN_URL . 'assets/css/zoho-admin.css',
@ -119,27 +143,13 @@ class HVAC_Zoho_Admin {
// Debug logging
// More robust production detection
$parsed_url = parse_url($site_url);
$host = isset($parsed_url['host']) ? $parsed_url['host'] : '';
// Remove www prefix for comparison
$clean_host = preg_replace('/^www\./', '', $host);
// Check if this is production
$is_production = ($clean_host === 'upskillhvac.com');
// Double-check with string comparison as fallback
if (!$is_production) {
$is_production = (strpos($site_url, 'upskillhvac.com') !== false &&
strpos($site_url, 'staging') === false &&
strpos($site_url, 'test') === false &&
strpos($site_url, 'dev') === false &&
strpos($site_url, 'cloudwaysapps.com') === false);
// Ensure Auth class is loaded for staging detection
if (!class_exists('HVAC_Zoho_CRM_Auth')) {
require_once HVAC_PLUGIN_DIR . 'includes/zoho/class-zoho-crm-auth.php';
}
// Set staging as opposite of production
$is_staging = !$is_production;
// Use central logic for staging detection
$is_staging = HVAC_Zoho_CRM_Auth::is_staging_mode();
// Load secure storage class
@ -152,6 +162,7 @@ class HVAC_Zoho_Admin {
$client_secret = HVAC_Secure_Storage::get_credential('hvac_zoho_client_secret', '');
$stored_refresh_token = HVAC_Secure_Storage::get_credential('hvac_zoho_refresh_token', '');
$has_credentials = !empty($client_id) && !empty($client_secret);
// OAuth URL is generated in enqueue_admin_scripts() and passed via wp_localize_script()
// Handle form submission
if (isset($_GET['credentials_saved'])) {
@ -261,6 +272,9 @@ class HVAC_Zoho_Admin {
🚀 Authorize with Zoho
</button>
<?php endif; ?>
<button type="button" class="button button-secondary" id="diagnostic-test" style="margin-left: 10px;">
🏥 Run Diagnostic Test
</button>
</p>
</form>
</div>
@ -285,6 +299,13 @@ class HVAC_Zoho_Admin {
<div class="hvac-zoho-sync">
<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">
<h3>Events Campaigns</h3>
<p>Sync events from The Events Calendar to Zoho CRM Campaigns</p>
@ -293,150 +314,140 @@ class HVAC_Zoho_Admin {
</div>
<div class="sync-section">
<h3>Users Contacts</h3>
<p>Sync trainers and attendees to Zoho CRM Contacts</p>
<button class="button sync-button" data-type="users">Sync Users</button>
<h3>Trainers Contacts</h3>
<p>Sync trainers (hvac_trainer, hvac_master_trainer) to Zoho CRM Contacts</p>
<button class="button sync-button" data-type="users">Sync Trainers</button>
<div class="sync-status" id="users-status"></div>
</div>
<div class="sync-section">
<h3>Purchases Invoices</h3>
<p>Sync ticket purchases to Zoho CRM Invoices</p>
<button class="button sync-button" data-type="purchases">Sync Purchases</button>
<h3>Ticket Attendees Contacts + Campaign Members</h3>
<p>Sync Event Tickets attendees to Zoho CRM Contacts and link them to Campaigns</p>
<button class="button sync-button" data-type="attendees">Sync Attendees</button>
<div class="sync-status" id="attendees-status"></div>
</div>
<div class="sync-section">
<h3>RSVPs Leads + Campaign Members</h3>
<p>Sync RSVP responses to Zoho CRM Leads and link them to Campaigns</p>
<button class="button sync-button" data-type="rsvps">Sync RSVPs</button>
<div class="sync-status" id="rsvps-status"></div>
</div>
<div class="sync-section">
<h3>Ticket Orders Invoices</h3>
<p>Sync Event Tickets orders (Tickets Commerce) to Zoho CRM Invoices</p>
<button class="button sync-button" data-type="purchases">Sync Orders</button>
<div class="sync-status" id="purchases-status"></div>
</div>
</div>
<div class="hvac-zoho-settings">
<h2>Sync Settings</h2>
<h2>Scheduled Sync Settings</h2>
<?php
// Get scheduled sync status
require_once HVAC_PLUGIN_DIR . 'includes/zoho/class-zoho-scheduled-sync.php';
$scheduled_sync = HVAC_Zoho_Scheduled_Sync::instance();
$sync_status = $scheduled_sync->get_status();
$current_frequency = get_option('hvac_zoho_sync_frequency', 'every_5_minutes');
?>
<form id="zoho-settings-form">
<label>
<input type="checkbox" name="auto_sync" value="1" <?php checked(get_option('hvac_zoho_auto_sync'), '1'); ?>>
Enable automatic sync
</label>
<br><br>
<label>
Sync frequency:
<select name="sync_frequency">
<option value="hourly" <?php selected(get_option('hvac_zoho_sync_frequency'), 'hourly'); ?>>Hourly</option>
<option value="daily" <?php selected(get_option('hvac_zoho_sync_frequency'), 'daily'); ?>>Daily</option>
<option value="weekly" <?php selected(get_option('hvac_zoho_sync_frequency'), 'weekly'); ?>>Weekly</option>
</select>
</label>
<br><br>
<button type="submit" class="button button-primary">Save Settings</button>
<table class="form-table">
<tr>
<th scope="row">Enable Scheduled Sync</th>
<td>
<label>
<input type="checkbox" name="auto_sync" value="1" <?php checked(get_option('hvac_zoho_auto_sync'), '1'); ?>>
Automatically sync new/modified records to Zoho CRM
</label>
<p class="description">When enabled, a background process will sync changes on the selected interval.</p>
</td>
</tr>
<tr>
<th scope="row">Sync Interval</th>
<td>
<select name="sync_frequency">
<option value="every_5_minutes" <?php selected($current_frequency, 'every_5_minutes'); ?>>Every 5 minutes</option>
<option value="every_15_minutes" <?php selected($current_frequency, 'every_15_minutes'); ?>>Every 15 minutes</option>
<option value="every_30_minutes" <?php selected($current_frequency, 'every_30_minutes'); ?>>Every 30 minutes</option>
<option value="hourly" <?php selected($current_frequency, 'hourly'); ?>>Hourly</option>
<option value="every_6_hours" <?php selected($current_frequency, 'every_6_hours'); ?>>Every 6 hours</option>
<option value="daily" <?php selected($current_frequency, 'daily'); ?>>Daily</option>
</select>
<p class="description">How often to check for and sync new/modified records.</p>
</td>
</tr>
<tr>
<th scope="row">Sync Status</th>
<td>
<p>
<strong>Status:</strong>
<?php if ($sync_status['is_scheduled']): ?>
<span style="color: #46b450;"> Scheduled</span>
<?php else: ?>
<span style="color: #dc3232;"> Not Scheduled</span>
<?php endif; ?>
</p>
<p>
<strong>Last Sync:</strong>
<?php echo esc_html($sync_status['last_sync_formatted']); ?>
</p>
<?php if ($sync_status['is_scheduled']): ?>
<p>
<strong>Next Sync:</strong>
<?php echo esc_html($sync_status['next_sync_formatted']); ?>
</p>
<?php endif; ?>
<?php if ($sync_status['last_result']): ?>
<p>
<strong>Last Result:</strong>
<?php
$last = $sync_status['last_result'];
echo esc_html(sprintf('%d synced, %d failed', $last['total_synced'] ?? 0, $last['total_failed'] ?? 0));
?>
</p>
<?php endif; ?>
</td>
</tr>
</table>
<p class="submit">
<button type="submit" class="button button-primary">Save Settings</button>
<button type="button" class="button button-secondary" id="run-scheduled-sync-now" style="margin-left: 10px;">
🔄 Run Sync Now
</button>
</p>
</form>
<div id="scheduled-sync-status"></div>
</div>
<?php endif; ?>
</div>
<script>
jQuery(document).ready(function($) {
// Toggle password visibility
$('#toggle-secret').on('click', function() {
var passwordField = $('#zoho_client_secret');
var toggleBtn = $(this);
if (passwordField.attr('type') === 'password') {
passwordField.attr('type', 'text');
toggleBtn.text('Hide');
} else {
passwordField.attr('type', 'password');
toggleBtn.text('Show');
}
});
// Copy redirect URI to clipboard
$('#copy-redirect-uri').on('click', function() {
var redirectUri = '<?php echo esc_js($site_url . '/oauth/callback'); ?>';
navigator.clipboard.writeText(redirectUri).then(function() {
$('#copy-redirect-uri').text('Copied!').prop('disabled', true);
setTimeout(function() {
$('#copy-redirect-uri').text('Copy').prop('disabled', false);
}, 2000);
});
});
// Flush rewrite rules
$('#flush-rewrite-rules').on('click', function() {
var button = $(this);
button.prop('disabled', true).text('Flushing...');
$.post(ajaxurl, {
action: 'hvac_zoho_flush_rewrite_rules'
}, function(response) {
if (response.success) {
button.text('Flushed!').css('color', '#46b450');
setTimeout(function() {
location.reload(); // Reload to update the status
}, 1000);
} else {
button.text('Error').css('color', '#dc3232');
setTimeout(function() {
button.prop('disabled', false).text('Flush Rules').css('color', '');
}, 2000);
}
});
});
// Handle credentials form submission
$('#zoho-credentials-form').on('submit', function(e) {
e.preventDefault();
var formData = {
action: 'hvac_zoho_save_credentials',
zoho_client_id: $('#zoho_client_id').val(),
zoho_client_secret: $('#zoho_client_secret').val(),
nonce: $('input[name="hvac_zoho_nonce"]').val()
};
$('#save-credentials').prop('disabled', true).text('Saving...');
$.post(ajaxurl, formData, function(response) {
if (response.success) {
window.location.href = window.location.href + '&credentials_saved=1';
} else {
alert('Error saving credentials: ' + response.data.message);
$('#save-credentials').prop('disabled', false).text('Save Credentials');
}
});
});
// Handle OAuth authorization
$('#start-oauth').on('click', function() {
var clientId = $('#zoho_client_id').val();
var clientSecret = $('#zoho_client_secret').val();
if (!clientId || !clientSecret) {
alert('Please save your credentials first before starting OAuth authorization.');
return;
}
// Generate OAuth URL
var redirectUri = '<?php echo esc_js($site_url . '/oauth/callback'); ?>';
var scopes = 'ZohoCRM.settings.ALL,ZohoCRM.modules.ALL,ZohoCRM.users.ALL,ZohoCRM.org.ALL,ZohoCRM.bulk.READ';
var oauthUrl = 'https://accounts.zoho.com/oauth/v2/auth?' +
'scope=' + encodeURIComponent(scopes) +
'&client_id=' + encodeURIComponent(clientId) +
'&response_type=code' +
'&access_type=offline' +
'&redirect_uri=' + encodeURIComponent(redirectUri) +
'&prompt=consent';
// Open OAuth URL in the same window to handle callback properly
window.location.href = oauthUrl;
});
});
</script>
<?php
// Note: All JavaScript functionality moved to assets/js/zoho-admin.js
// Data is passed via wp_localize_script() in enqueue_admin_scripts()
}
/**
* Simple test handler to isolate issues
*/
public function simple_test() {
// Check permissions
if (!current_user_can('manage_options')) {
wp_send_json_error(array('message' => 'Unauthorized access'));
return;
}
wp_send_json_success(array('message' => 'Simple test works!'));
$payload_status = 'No payload received';
if (!empty($_POST['test_payload'])) {
$payload_status = 'Payload received (' . strlen($_POST['test_payload']) . ' chars)';
}
wp_send_json_success(array(
'message' => 'Simple test works!',
'server_time' => date('Y-m-d H:i:s'),
'payload_status' => $payload_status,
'request_method' => $_SERVER['REQUEST_METHOD']
));
}
/**
@ -462,7 +473,7 @@ class HVAC_Zoho_Admin {
}
// Validate Client ID format (should start with "1000.")
if (!preg_match('/^1000\.[A-Z0-9]+$/', $client_id)) {
if (!preg_match('/^1000\.[A-Za-z0-9]+$/', $client_id)) {
wp_send_json_error(array('message' => 'Invalid Client ID format. Should start with "1000."'));
return;
}
@ -564,9 +575,6 @@ class HVAC_Zoho_Admin {
}
}
/**
* Parse OAuth request using parse_request hook
*/
public function parse_oauth_request($wp) {
// Check if this is an OAuth callback request
@ -583,6 +591,33 @@ class HVAC_Zoho_Admin {
}
}
/**
* Manual Router for OAuth Callback
*
* Catches the request on 'init' before WordPress internal routing can 404 it.
* This is a robust fallback for when rewrite rules fail or haven't flushed.
*/
public function check_for_oauth_params() {
// Check if we are at the oauth callback URL path
$path = parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH);
// Check strict path match or if regex matches
if (strpos($path, '/oauth/callback') !== false) {
// We are at the right URL. Do we have the code?
if (isset($_GET['code'])) {
// We have a code.
// We MUST process this, otherwise WP will display a 404.
// Even if state matches or not, we should handle it here.
// The process_oauth_callback method handles validation.
$this->process_oauth_callback();
// process_oauth_callback exits, so we won't continue to 404.
}
}
}
/**
* Add OAuth callback rewrite rule
*/
@ -624,16 +659,33 @@ class HVAC_Zoho_Admin {
* Process OAuth callback from Zoho
*/
public function process_oauth_callback() {
if (!isset($_GET['code'])) {
wp_die('OAuth callback missing authorization code');
}
// Validate state parameter for CSRF protection
if (!isset($_GET['state'])) {
wp_die('OAuth callback missing state parameter. Possible CSRF attack.');
}
// Get credentials from WordPress options
$client_id = get_option('hvac_zoho_client_id', '');
$client_secret = get_option('hvac_zoho_client_secret', '');
// Load secure storage for credential handling
if (!class_exists('HVAC_Secure_Storage')) {
require_once HVAC_PLUGIN_DIR . 'includes/class-hvac-secure-storage.php';
}
require_once HVAC_PLUGIN_DIR . 'includes/zoho/class-zoho-crm-auth.php';
$auth = new HVAC_Zoho_CRM_Auth();
if (!$auth->validate_oauth_state(sanitize_text_field($_GET['state']))) {
wp_die('OAuth state validation failed. Please try the authorization again.');
}
// Get credentials using secure storage (credentials are stored encrypted)
if (!class_exists('HVAC_Secure_Storage')) {
require_once HVAC_PLUGIN_DIR . 'includes/class-hvac-secure-storage.php';
}
$client_id = HVAC_Secure_Storage::get_credential('hvac_zoho_client_id', '');
$client_secret = HVAC_Secure_Storage::get_credential('hvac_zoho_client_secret', '');
if (empty($client_id) || empty($client_secret)) {
wp_die('OAuth callback error: Missing client credentials. Please configure your Zoho CRM credentials first.');
@ -676,20 +728,19 @@ class HVAC_Zoho_Admin {
exit;
}
// Save tokens
update_option('hvac_zoho_access_token', $token_data['access_token']);
// Save tokens using secure storage
HVAC_Secure_Storage::store_credential('hvac_zoho_access_token', $token_data['access_token']);
update_option('hvac_zoho_token_expires', time() + ($token_data['expires_in'] ?? 3600));
// Refresh token might not be returned on subsequent authorizations
if (isset($token_data['refresh_token']) && !empty($token_data['refresh_token'])) {
update_option('hvac_zoho_refresh_token', $token_data['refresh_token']);
HVAC_Secure_Storage::store_credential('hvac_zoho_refresh_token', $token_data['refresh_token']);
} else {
$existing_refresh = get_option('hvac_zoho_refresh_token');
$existing_refresh = HVAC_Secure_Storage::get_credential('hvac_zoho_refresh_token', '');
if (empty($existing_refresh)) {
// This is critical - we need a refresh token for long-term access
// Store a warning but still complete the flow
update_option('hvac_zoho_missing_refresh_token', true);
} else {
}
}
@ -720,9 +771,12 @@ class HVAC_Zoho_Admin {
return;
}
// Get credentials from WordPress options
$client_id = get_option('hvac_zoho_client_id', '');
$client_secret = get_option('hvac_zoho_client_secret', '');
// Get credentials using secure storage (credentials are stored encrypted)
if (!class_exists('HVAC_Secure_Storage')) {
require_once HVAC_PLUGIN_DIR . 'includes/class-hvac-secure-storage.php';
}
$client_id = HVAC_Secure_Storage::get_credential('hvac_zoho_client_id', '');
$client_secret = HVAC_Secure_Storage::get_credential('hvac_zoho_client_secret', '');
// Check configuration before attempting connection
if (empty($client_id)) {
@ -746,7 +800,7 @@ class HVAC_Zoho_Admin {
}
// Check if we have stored refresh token from previous OAuth
$stored_refresh_token = get_option('hvac_zoho_refresh_token');
$stored_refresh_token = HVAC_Secure_Storage::get_credential('hvac_zoho_refresh_token', '');
if (empty($stored_refresh_token)) {
@ -852,34 +906,56 @@ class HVAC_Zoho_Admin {
}
// Success!
wp_send_json_success(array(
$mode_info = HVAC_Zoho_CRM_Auth::get_debug_mode_info();
$response_data = array(
'message' => 'Connection successful!',
'modules' => isset($response['modules']) ? count($response['modules']) . ' modules available' : 'API connected',
'client_id' => substr($client_id, 0, 10) . '...',
'client_secret_exists' => true,
'refresh_token_exists' => true,
'is_staging' => $mode_info['is_staging'],
'mode_info' => $mode_info,
'credentials_status' => array(
'client_id' => substr($client_id, 0, 10) . '...',
'client_secret_exists' => true,
'refresh_token_exists' => 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) {
wp_send_json_error(array(
$error_response = array(
'message' => 'Connection test failed due to exception',
'error' => $e->getMessage(),
'file' => $e->getFile() . ':' . $e->getLine()
));
);
// Only expose file paths in debug mode
if (defined('WP_DEBUG') && WP_DEBUG) {
$error_response['file'] = $e->getFile() . ':' . $e->getLine();
}
wp_send_json_error($error_response);
} catch (Error $e) {
wp_send_json_error(array(
$error_response = array(
'message' => 'Connection test failed due to PHP error',
'error' => $e->getMessage(),
'file' => $e->getFile() . ':' . $e->getLine()
));
);
if (defined('WP_DEBUG') && WP_DEBUG) {
$error_response['file'] = $e->getFile() . ':' . $e->getLine();
}
wp_send_json_error($error_response);
} catch (Throwable $e) {
wp_send_json_error(array(
$error_response = array(
'message' => 'Connection test failed due to fatal error',
'error' => $e->getMessage(),
'file' => $e->getFile() . ':' . $e->getLine()
));
);
if (defined('WP_DEBUG') && WP_DEBUG) {
$error_response['file'] = $e->getFile() . ':' . $e->getLine();
}
wp_send_json_error($error_response);
}
}
@ -894,6 +970,8 @@ class HVAC_Zoho_Admin {
}
$type = sanitize_text_field($_POST['type']);
$offset = isset($_POST['offset']) ? absint($_POST['offset']) : 0;
$limit = isset($_POST['limit']) ? absint($_POST['limit']) : 50;
try {
require_once HVAC_PLUGIN_DIR . 'includes/zoho/class-zoho-sync.php';
@ -901,13 +979,19 @@ class HVAC_Zoho_Admin {
switch ($type) {
case 'events':
$result = $sync->sync_events();
$result = $sync->sync_events($offset, $limit);
break;
case 'users':
$result = $sync->sync_users();
$result = $sync->sync_users($offset, $limit);
break;
case 'attendees':
$result = $sync->sync_attendees($offset, $limit);
break;
case 'rsvps':
$result = $sync->sync_rsvps($offset, $limit);
break;
case 'purchases':
$result = $sync->sync_purchases();
$result = $sync->sync_purchases($offset, $limit);
break;
default:
throw new Exception('Invalid sync type');
@ -921,5 +1005,126 @@ class HVAC_Zoho_Admin {
));
}
}
/**
* Save scheduled sync settings
*/
public function save_settings() {
check_ajax_referer('hvac_zoho_nonce', 'nonce');
if (!current_user_can('manage_options')) {
wp_send_json_error(array('message' => 'Unauthorized access'));
return;
}
$auto_sync = isset($_POST['auto_sync']) && $_POST['auto_sync'] === '1' ? '1' : '0';
$sync_frequency = sanitize_text_field($_POST['sync_frequency'] ?? 'every_5_minutes');
// Validate frequency value
$valid_frequencies = array(
'every_5_minutes',
'every_15_minutes',
'every_30_minutes',
'hourly',
'every_6_hours',
'daily'
);
if (!in_array($sync_frequency, $valid_frequencies)) {
$sync_frequency = 'every_5_minutes';
}
// Save settings
update_option('hvac_zoho_auto_sync', $auto_sync);
update_option('hvac_zoho_sync_frequency', $sync_frequency);
// Get scheduled sync instance and explicitly schedule/unschedule
// This ensures scheduling works even if option value didn't change
require_once HVAC_PLUGIN_DIR . 'includes/zoho/class-zoho-scheduled-sync.php';
$scheduled_sync = HVAC_Zoho_Scheduled_Sync::instance();
if ($auto_sync === '1') {
$scheduled_sync->schedule_sync($sync_frequency);
} else {
$scheduled_sync->unschedule_sync();
}
$status = $scheduled_sync->get_status();
wp_send_json_success(array(
'message' => 'Settings saved successfully',
'auto_sync' => $auto_sync,
'sync_frequency' => $sync_frequency,
'is_scheduled' => $status['is_scheduled'],
'next_sync' => $status['next_sync_formatted']
));
}
/**
* Run scheduled sync manually
*/
public function run_scheduled_sync_now() {
check_ajax_referer('hvac_zoho_nonce', 'nonce');
if (!current_user_can('manage_options')) {
wp_send_json_error(array('message' => 'Unauthorized access'));
return;
}
try {
require_once HVAC_PLUGIN_DIR . 'includes/zoho/class-zoho-scheduled-sync.php';
$scheduled_sync = HVAC_Zoho_Scheduled_Sync::instance();
$result = $scheduled_sync->run_now();
wp_send_json_success(array(
'message' => 'Scheduled sync completed',
'result' => $result
));
} catch (Exception $e) {
wp_send_json_error(array(
'message' => 'Sync failed',
'error' => $e->getMessage()
));
}
}
/**
* 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,
));
}
}
?>

View file

@ -67,7 +67,10 @@ class HVAC_Certificate_Manager {
update_option('hvac_certificate_counter', $counter);
// 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);
return $prefix . $year . '-' . $formatted_counter;
@ -836,11 +839,25 @@ class HVAC_Certificate_Manager {
/**
* 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.
*
* @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) {
// 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
$url = add_query_arg(
array(

View file

@ -61,6 +61,18 @@ class HVAC_Ajax_Handlers {
// Enhanced approval endpoint (wrapper for existing)
add_action('wp_ajax_hvac_approve_trainer_v2', array($this, 'approve_trainer_secure'));
add_action('wp_ajax_nopriv_hvac_approve_trainer_v2', array($this, 'unauthorized_access'));
// Password reset endpoint for master trainers
add_action('wp_ajax_hvac_send_password_reset', array($this, 'send_password_reset'));
add_action('wp_ajax_nopriv_hvac_send_password_reset', array($this, 'unauthorized_access'));
// Contact trainer form (Find Training page)
add_action('wp_ajax_hvac_submit_contact_form', array($this, 'submit_trainer_contact_form'));
add_action('wp_ajax_nopriv_hvac_submit_contact_form', array($this, 'submit_trainer_contact_form'));
// 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'));
}
/**
@ -960,6 +972,421 @@ class HVAC_Ajax_Handlers {
$this->clear_trainer_stats_cache();
}
}
/**
* Send password reset email to a trainer
*
* Allows master trainers to trigger a password reset email for any trainer.
* Uses WordPress built-in password reset functionality.
*/
public function send_password_reset() {
// Verify nonce
if (!isset($_POST['nonce']) || !wp_verify_nonce($_POST['nonce'], 'hvac_profile_edit')) {
wp_send_json_error('Invalid security token', 403);
return;
}
// Check if user is logged in
if (!is_user_logged_in()) {
wp_send_json_error('You must be logged in', 401);
return;
}
// Check if user has permission (master trainer or admin)
$current_user = wp_get_current_user();
if (!in_array('hvac_master_trainer', $current_user->roles) && !in_array('administrator', $current_user->roles)) {
wp_send_json_error('You do not have permission to perform this action', 403);
return;
}
// Get target user ID
$user_id = isset($_POST['user_id']) ? absint($_POST['user_id']) : 0;
if (!$user_id) {
wp_send_json_error('Invalid user ID', 400);
return;
}
// Get target user
$user = get_userdata($user_id);
if (!$user) {
wp_send_json_error('User not found', 404);
return;
}
// Use WordPress built-in password reset
$result = retrieve_password($user->user_login);
if (is_wp_error($result)) {
wp_send_json_error($result->get_error_message(), 500);
return;
}
// Log the action
error_log(sprintf(
'[HVAC] Password reset email sent for user %d (%s) by master trainer %d (%s)',
$user_id,
$user->user_email,
$current_user->ID,
$current_user->user_login
));
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;
}
// Verify reCAPTCHA
if (class_exists('HVAC_Recaptcha')) {
$recaptcha_response = sanitize_text_field($_POST['g-recaptcha-response'] ?? '');
if (!HVAC_Recaptcha::instance()->verify_response($recaptcha_response)) {
wp_send_json_error(['message' => 'CAPTCHA verification failed. Please try again.'], 400);
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;
}
// Verify reCAPTCHA
if (class_exists('HVAC_Recaptcha')) {
$recaptcha_response = sanitize_text_field($_POST['g-recaptcha-response'] ?? '');
if (!HVAC_Recaptcha::instance()->verify_response($recaptcha_response)) {
wp_send_json_error(['message' => 'CAPTCHA verification failed. Please try again.'], 400);
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

View file

@ -81,11 +81,17 @@ class HVAC_Ajax_Security {
/**
* 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() {
if (!is_admin() && wp_doing_ajax()) {
// Content Security Policy
header("Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline';");
// Apply to AJAX requests (wp_doing_ajax covers admin-ajax.php)
// Skip if headers already sent
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
header('X-Content-Type-Options: nosniff');
@ -478,38 +484,59 @@ class HVAC_Ajax_Security {
*
* @param string $action Action identifier
* @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) {
$salt = wp_salt('auth');
$data = $action . '|' . $user_id . '|' . time();
return hash_hmac('sha256', $data, $salt);
$timestamp = time();
$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 int $user_id User ID
* @param int $expiry Token expiry in seconds (default 1 hour)
* @return bool
*/
public static function verify_secure_token($token, $action, $user_id, $expiry = 3600) {
$salt = wp_salt('auth');
// Generate tokens for last $expiry seconds
$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;
}
// Parse token format: timestamp.signature
$parts = explode('.', $token, 2);
if (count($parts) !== 2) {
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);
}
}

View file

@ -40,35 +40,60 @@ class HVAC_Announcements_Admin {
* Constructor
*/
private function __construct() {
HVAC_Logger::log('Constructor called - initializing hooks', 'Announcements Admin');
$this->init_hooks();
HVAC_Logger::log('Hooks initialized successfully', 'Announcements Admin');
}
/**
* Initialize hooks
*/
private function init_hooks() {
add_action('wp_enqueue_scripts', array($this, 'enqueue_admin_assets'));
HVAC_Logger::log('Registering wp_enqueue_scripts hook with priority 20', 'Announcements Admin');
// Use priority 20 to ensure post object is available
add_action('wp_enqueue_scripts', array($this, 'enqueue_admin_assets'), 20);
HVAC_Logger::log('Hook registered successfully', 'Announcements Admin');
}
/**
* Enqueue admin assets on master trainer pages
*/
public function enqueue_admin_assets() {
HVAC_Logger::log('enqueue_admin_assets called', 'Announcements Admin');
HVAC_Logger::log('is_master_trainer = ' . (HVAC_Announcements_Permissions::is_master_trainer() ? 'YES' : 'NO'), 'Announcements Admin');
HVAC_Logger::log('is_page_template check = ' . (is_page_template('page-master-announcements.php') ? 'YES' : 'NO'), 'Announcements Admin');
$queried = get_queried_object();
if ($queried) {
HVAC_Logger::log('queried_object type = ' . get_class($queried), 'Announcements Admin');
if (is_a($queried, 'WP_Post')) {
HVAC_Logger::log('post_name = ' . $queried->post_name, 'Announcements Admin');
HVAC_Logger::log('post_type = ' . $queried->post_type, 'Announcements Admin');
$template = get_post_meta($queried->ID, '_wp_page_template', true);
HVAC_Logger::log('page_template meta = ' . $template, 'Announcements Admin');
}
}
// Only enqueue on master trainer announcement pages
if ($this->is_master_trainer_announcement_page()) {
// Enqueue admin JavaScript
HVAC_Logger::log('ENQUEUING SCRIPTS', 'Announcements Admin');
// Enqueue editor - dependencies handled automatically
wp_enqueue_editor();
// Enqueue admin JavaScript - Load in footer after wp_editor inline scripts
wp_enqueue_script(
'hvac-announcements-admin',
plugin_dir_url(dirname(__FILE__)) . 'assets/js/hvac-announcements-admin.js',
array('jquery', 'wp-editor'),
array('jquery', 'editor'),
defined('HVAC_VERSION') ? HVAC_VERSION : '1.0.0',
true
true // Load in footer after wp_editor inline scripts
);
// Localize script with AJAX data
wp_localize_script('hvac-announcements-admin', 'hvac_announcements', array(
'ajax_url' => admin_url('admin-ajax.php'),
'nonce' => wp_create_nonce('hvac_announcements_admin_nonce'),
'nonce' => wp_create_nonce('hvac_announcements_nonce'),
'strings' => array(
'confirm_delete' => __('Are you sure you want to delete this announcement?', 'hvac'),
'error_loading' => __('Error loading announcements.', 'hvac'),
@ -93,27 +118,56 @@ class HVAC_Announcements_Admin {
/**
* Check if current page is master trainer announcement page
*
* Uses multi-layered detection approach for reliability during wp_enqueue_scripts
* @see class-hvac-scripts-styles.php for pattern reference
*
* @return bool
*/
private function is_master_trainer_announcement_page() {
global $post;
if (!is_a($post, 'WP_Post')) {
return false;
}
// Check if user is master trainer
// Check if user is master trainer first
if (!HVAC_Announcements_Permissions::is_master_trainer()) {
return false;
}
// Check for announcement pages
$announcement_slugs = array(
'master-announcements',
'master-manage-announcements'
// PRIMARY: Check URL path (most reliable during wp_enqueue_scripts)
$current_path = isset($_SERVER['REQUEST_URI']) ? trim(parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH), '/') : '';
$announcement_paths = array(
'master-trainer/announcements',
'master-trainer/master-announcements',
'master-trainer/manage-announcements'
);
return in_array($post->post_name, $announcement_slugs);
foreach ($announcement_paths as $path) {
if (strpos($current_path, $path) !== false) {
return true;
}
}
// SECONDARY: Check page template (may not work during wp_enqueue_scripts)
if (is_page_template('page-master-announcements.php')) {
return true;
}
// TERTIARY: Check by page slug
if (is_page('announcements') || is_page('master-announcements') || is_page('manage-announcements')) {
return true;
}
// FALLBACK: Check queried object for announcement slugs
$queried_object = get_queried_object();
if (is_a($queried_object, 'WP_Post')) {
$announcement_slugs = array(
'announcements',
'master-announcements',
'master-manage-announcements',
'manage-announcements'
);
return in_array($queried_object->post_name, $announcement_slugs);
}
return false;
}
/**
@ -184,7 +238,7 @@ class HVAC_Announcements_Admin {
</div>
<!-- Announcement Modal -->
<div id="announcement-modal" class="hvac-modal" style="display: none;">
<div id="announcement-modal" class="hvac-modal">
<div class="modal-content">
<div class="modal-header">
<h2 id="modal-title"><?php _e('Add New Announcement', 'hvac'); ?></h2>
@ -206,9 +260,11 @@ class HVAC_Announcements_Admin {
<div class="form-field">
<label for="announcement-content"><?php _e('Content', 'hvac'); ?> <span class="required">*</span></label>
<?php
// CRITICAL: media_buttons => false to prevent auto-open in hidden modal
// Media button will be added manually via JavaScript when modal opens
wp_editor('', 'announcement-content', array(
'textarea_name' => 'announcement_content',
'media_buttons' => true,
'media_buttons' => false, // FIXED: Prevents media modal auto-open in hidden div
'textarea_rows' => 10,
'teeny' => false,
'dfw' => false,

View file

@ -151,7 +151,7 @@ class HVAC_Announcements_Display {
</div>
<!-- Modal for viewing announcement -->
<div id="announcement-modal" class="hvac-modal" style="display: none;">
<div id="announcement-modal" class="hvac-modal">
<div class="modal-content">
<span class="modal-close">&times;</span>
<div class="modal-body">
@ -251,7 +251,7 @@ class HVAC_Announcements_Display {
}
$atts = shortcode_atts(array(
'url' => 'https://drive.google.com/drive/folders/16uDRkFcaEqKUxfBek9VbfbAIeFV77nZG?usp=drive_link',
'url' => 'https://drive.google.com/drive/folders/1-SDHGR9Ix6BmUVTHa3wI99K0rwfWL-vs?usp=drive_link',
'height' => '600',
'width' => '100%',
), $atts);

View file

@ -45,6 +45,11 @@ class HVAC_Dashboard_Data {
global $wpdb;
try {
// Check if TEC is available
if ( ! class_exists( 'Tribe__Events__Main' ) ) {
return 0;
}
// Cache key based on user ID
$cache_key = 'hvac_dashboard_total_events_' . $this->user_id;
$count = wp_cache_get( $cache_key, 'hvac_dashboard' );
@ -85,6 +90,11 @@ class HVAC_Dashboard_Data {
public function get_upcoming_events_count() {
global $wpdb;
// Check if TEC is available
if ( ! class_exists( 'Tribe__Events__Main' ) ) {
return 0;
}
// Cache key based on user ID
$cache_key = 'hvac_dashboard_upcoming_events_' . $this->user_id;
$count = wp_cache_get( $cache_key, 'hvac_dashboard' );
@ -120,6 +130,11 @@ class HVAC_Dashboard_Data {
public function get_past_events_count() {
global $wpdb;
// Check if TEC is available
if ( ! class_exists( 'Tribe__Events__Main' ) ) {
return 0;
}
// Cache key based on user ID
$cache_key = 'hvac_dashboard_past_events_' . $this->user_id;
$count = wp_cache_get( $cache_key, 'hvac_dashboard' );

View file

@ -135,7 +135,7 @@ final class HVAC_Event_Manager {
// Event management (creation) page
if ($this->isManagePage()) {
$custom_template = HVAC_PLUGIN_DIR . 'templates/page-trainer-event-manage.php';
$custom_template = HVAC_PLUGIN_DIR . 'templates/page-manage-event.php';
if (file_exists($custom_template)) {
return $custom_template;
}
@ -170,8 +170,7 @@ final class HVAC_Event_Manager {
strpos($request_uri, '/trainer/event/manage') !== false ||
get_query_var('hvac_event_manage') === '1' ||
is_page('manage-event') ||
is_page('trainer-event-manage') ||
is_page(5334) // Legacy page ID
is_page('trainer-event-manage')
);
}
@ -184,7 +183,6 @@ final class HVAC_Event_Manager {
return (
strpos($request_uri, '/trainer/event/edit') !== false ||
get_query_var('hvac_event_edit') === '1' ||
is_page(6177) || // Configuration-based page ID
(is_page() && get_page_template_slug() === 'templates/page-edit-event-custom.php')
);
}

View file

@ -54,20 +54,12 @@ class HVAC_Find_Trainer_Assets {
* Initialize WordPress hooks
*/
private function init_hooks() {
// CRITICAL: Don't add asset loading hooks for Safari browsers
// Let HVAC_Scripts_Styles handle Safari minimal loading
if ($this->browser_detection->is_safari_browser()) {
error_log('[HVAC Find Trainer Assets] Safari detected - skipping asset hooks to prevent resource cascade');
// Only add footer scripts for MapGeo integration
add_action('wp_footer', [$this, 'add_find_trainer_inline_scripts']);
return;
}
// Use proper WordPress hook system for non-Safari browsers
// Use proper WordPress hook system
add_action('wp_enqueue_scripts', [$this, 'enqueue_find_trainer_assets']);
add_action('wp_footer', [$this, 'add_find_trainer_inline_scripts']);
}
/**
* Check if current page is find-a-trainer
*

View file

@ -66,7 +66,7 @@ class HVAC_MapGeo_Safety {
'hvac-mapgeo-safety',
HVAC_PLUGIN_URL . 'assets/js/mapgeo-safety.js',
array(),
HVAC_PLUGIN_VERSION,
HVAC_PLUGIN_VERSION . '.fix2',
false // Load in head to catch errors early
);
@ -75,7 +75,7 @@ class HVAC_MapGeo_Safety {
window.HVAC_MapGeo_Config = {
maxRetries: 3,
retryDelay: 2000,
timeout: 10000,
timeout: 30000,
fallbackEnabled: true,
debugMode: ' . (defined('WP_DEBUG') && WP_DEBUG ? 'true' : 'false') . '
};
@ -114,8 +114,10 @@ class HVAC_MapGeo_Safety {
var mapContainer = document.querySelector('.igm-map-container, [class*="mapgeo"], [id*="map-"]');
if (fallback && mapContainer) {
mapContainer.style.display = 'none';
fallback.style.display = 'block';
// Relaxed safety: Don't hide map on error immediately to allow debugging
// mapContainer.style.display = 'none';
// fallback.style.display = 'block';
console.warn('[HVAC MapGeo Safety] Error detected but keeping map visible for debugging.');
}
// Log to our error tracking

View file

@ -66,7 +66,8 @@ class HVAC_Master_Content_Injector {
*/
private function __construct() {
add_filter('the_content', array($this, 'inject_master_content'), 10);
add_action('wp_head', array($this, 'inject_inline_content'), 1);
// Use wp_footer instead of wp_head to ensure jQuery is loaded first
add_action('wp_footer', array($this, 'inject_inline_content'), 20);
}
/**
@ -220,16 +221,19 @@ class HVAC_Master_Content_Injector {
return '';
}
// Try direct method call first
// Try direct method call first - check which method exists
if (class_exists($shortcode_data['class'])) {
$instance = call_user_func(array($shortcode_data['class'], 'instance'));
if (!$instance && method_exists($shortcode_data['class'], 'get_instance')) {
$instance = null;
if (method_exists($shortcode_data['class'], 'get_instance')) {
$instance = call_user_func(array($shortcode_data['class'], 'get_instance'));
} elseif (method_exists($shortcode_data['class'], 'instance')) {
$instance = call_user_func(array($shortcode_data['class'], 'instance'));
}
if ($instance && method_exists($instance, $shortcode_data['method'])) {
ob_start();
echo $instance->{$shortcode_data['method']}();
// Pass empty array to satisfy shortcode callback signature
echo $instance->{$shortcode_data['method']}(array());
return ob_get_clean();
}
}

View file

@ -850,9 +850,16 @@ class HVAC_Master_Dashboard_Data {
* AJAX handler for trainers table
*/
public function ajax_get_trainers_table() {
// Check nonce
if ( ! isset( $_POST['nonce'] ) || ! wp_verify_nonce( $_POST['nonce'], 'hvac_master_dashboard_nonce' ) ) {
wp_die( 'Security check failed' );
// Check nonce - accept both hvac_ajax_nonce and hvac_master_dashboard_nonce for compatibility
if ( ! isset( $_POST['nonce'] ) ) {
wp_die( 'Security check failed: No nonce provided' );
}
$nonce_valid = wp_verify_nonce( $_POST['nonce'], 'hvac_ajax_nonce' ) ||
wp_verify_nonce( $_POST['nonce'], 'hvac_master_dashboard_nonce' );
if ( ! $nonce_valid ) {
wp_die( 'Security check failed: Invalid nonce' );
}
// Check permissions

View file

@ -327,7 +327,7 @@ class HVAC_Master_Menu_System {
echo '<span class="menu-title">' . esc_html($item['title']) . '</span>';
if ($has_children) {
echo '<span class="menu-toggle" aria-hidden="true"></span>';
echo '<span class="dropdown-arrow">▼</span>';
}
echo '</a>';

View file

@ -82,6 +82,65 @@ class HVAC_Master_Trainers_Overview {
// Add function for template integration
add_action( 'init', array( $this, 'register_template_functions' ) );
// Enqueue scripts for master trainers overview
add_action( 'wp_enqueue_scripts', array( $this, 'enqueue_scripts' ) );
}
/**
* Enqueue scripts for master trainers overview
*/
public function enqueue_scripts() {
// Only enqueue on master trainers pages
if ( ! is_page() ) {
return;
}
global $post;
if ( ! $post ) {
return;
}
// Check if this is the master trainers page
$is_trainers_page = false;
// Check 1: Post content contains the shortcode
if ( strpos( $post->post_content, '[hvac_master_trainers]' ) !== false ) {
$is_trainers_page = true;
}
// Check 2: Template slug matches
$template = get_page_template_slug( $post->ID );
if ( $template === 'page-master-trainers.php' ) {
$is_trainers_page = true;
}
// Check 3: Page slug matches (most reliable)
if ( $post->post_name === 'trainers' && strpos( $_SERVER['REQUEST_URI'], '/master-trainer/trainers' ) !== false ) {
$is_trainers_page = true;
}
if ( ! $is_trainers_page ) {
return;
}
wp_enqueue_script(
'hvac-master-trainers-overview',
HVAC_PLUGIN_URL . 'assets/js/hvac-master-trainers-overview.js',
array( 'jquery' ),
HVAC_VERSION,
true
);
// Localize script with AJAX data
wp_localize_script(
'hvac-master-trainers-overview',
'hvac_master_trainers_ajax',
array(
'ajax_url' => admin_url( 'admin-ajax.php' ),
'nonce' => wp_create_nonce( 'hvac_master_trainers_nonce' )
)
);
}
/**
@ -232,10 +291,12 @@ class HVAC_Master_Trainers_Overview {
/**
* AJAX handler for filtering trainers
*
* Uses get_trainers_table_data() from dashboard data class for reliable trainer listing
*/
public function ajax_filter_trainers() {
// Verify nonce
if ( ! wp_verify_nonce( $_POST['nonce'], 'hvac_master_trainers_nonce' ) ) {
if ( ! isset( $_POST['nonce'] ) || ! wp_verify_nonce( $_POST['nonce'], 'hvac_master_trainers_nonce' ) ) {
wp_send_json_error( array( 'message' => 'Security check failed' ) );
}
@ -247,7 +308,6 @@ class HVAC_Master_Trainers_Overview {
// Get filter parameters
$args = array(
'status' => isset( $_POST['status'] ) ? sanitize_text_field( $_POST['status'] ) : 'all',
'region' => isset( $_POST['region'] ) ? sanitize_text_field( $_POST['region'] ) : '',
'search' => isset( $_POST['search'] ) ? sanitize_text_field( $_POST['search'] ) : '',
'page' => isset( $_POST['page'] ) ? absint( $_POST['page'] ) : 1,
'per_page' => isset( $_POST['per_page'] ) ? absint( $_POST['per_page'] ) : 20,
@ -255,16 +315,92 @@ class HVAC_Master_Trainers_Overview {
'order' => isset( $_POST['order'] ) ? sanitize_text_field( $_POST['order'] ) : 'ASC',
);
// Get trainers data
// Use the reliable get_trainers_table_data method (same as Master Dashboard)
if ( $this->dashboard_data ) {
$trainer_stats = $this->dashboard_data->get_trainer_statistics();
$trainer_data = $this->dashboard_data->get_trainers_table_data( $args );
$trainers = $trainer_data['trainers'];
// Format trainers for display
$formatted_trainers = $this->format_trainers_for_display( $trainer_stats['trainer_data'], $args );
// Apply region filter if specified (not handled by get_trainers_table_data)
$region_filter = isset( $_POST['region'] ) ? sanitize_text_field( $_POST['region'] ) : '';
if ( ! empty( $region_filter ) ) {
$trainers = array_filter( $trainers, function( $trainer ) use ( $region_filter ) {
$user_meta = get_user_meta( $trainer['id'] );
$user_state = isset( $user_meta['billing_state'][0] ) ? $user_meta['billing_state'][0] : '';
return $user_state === $region_filter;
} );
$trainers = array_values( $trainers ); // Re-index array
}
// Generate HTML table
ob_start();
?>
<table class="hvac-trainers-table">
<thead>
<tr>
<th>Name</th>
<th>Email</th>
<th>Status</th>
<th>Total Events</th>
<th>Revenue</th>
<th>Registered</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
<?php if ( empty( $trainers ) ) : ?>
<tr>
<td colspan="7" class="hvac-no-results">No trainers found matching your criteria.</td>
</tr>
<?php else : ?>
<?php foreach ( $trainers as $trainer ) : ?>
<tr>
<td class="hvac-trainer-name">
<strong><?php echo esc_html( $trainer['name'] ); ?></strong>
</td>
<td class="hvac-trainer-email">
<a href="mailto:<?php echo esc_attr( $trainer['email'] ); ?>">
<?php echo esc_html( $trainer['email'] ); ?>
</a>
</td>
<td class="hvac-trainer-status">
<span class="hvac-status-badge hvac-status-<?php echo esc_attr( $trainer['status'] ); ?>">
<?php echo esc_html( $trainer['status_label'] ); ?>
</span>
</td>
<td class="hvac-trainer-events">
<?php echo esc_html( $trainer['total_events'] ); ?>
</td>
<td class="hvac-trainer-revenue">
$<?php echo esc_html( number_format( $trainer['revenue'], 2 ) ); ?>
</td>
<td class="hvac-trainer-registered">
<?php echo esc_html( $trainer['registration_date'] ); ?>
</td>
<td class="hvac-trainer-actions">
<a href="<?php echo esc_url( home_url( '/master-trainer/trainer-profile/' . $trainer['id'] . '/' ) ); ?>"
class="hvac-btn hvac-btn-sm hvac-btn-primary hvac-view-trainer"
data-trainer-id="<?php echo esc_attr( $trainer['id'] ); ?>">
View Profile
</a>
</td>
</tr>
<?php endforeach; ?>
<?php endif; ?>
</tbody>
</table>
<div class="hvac-trainers-count">
Showing <?php echo esc_html( count( $trainers ) ); ?> trainer(s)
<?php if ( ! empty( $trainer_data['pagination'] ) ) : ?>
of <?php echo esc_html( $trainer_data['pagination']['total_items'] ); ?> total
<?php endif; ?>
</div>
<?php
$html = ob_get_clean();
wp_send_json_success( array(
'trainers' => $formatted_trainers,
'total_found' => count( $formatted_trainers )
'html' => $html,
'total_found' => count( $trainers ),
'pagination' => $trainer_data['pagination']
) );
}
@ -276,7 +412,7 @@ class HVAC_Master_Trainers_Overview {
*/
public function ajax_get_stats() {
// Verify nonce
if ( ! wp_verify_nonce( $_POST['nonce'], 'hvac_master_trainers_nonce' ) ) {
if ( ! isset( $_POST['nonce'] ) || ! wp_verify_nonce( $_POST['nonce'], 'hvac_master_trainers_nonce' ) ) {
wp_send_json_error( array( 'message' => 'Security check failed' ) );
}
@ -296,7 +432,33 @@ class HVAC_Master_Trainers_Overview {
'total_revenue' => $trainer_stats['total_revenue']
);
wp_send_json_success( $stats );
// Generate HTML for stats tiles
ob_start();
?>
<div class="hvac-stat-tile">
<div class="hvac-stat-value"><?php echo esc_html( number_format( $stats['total_trainers'] ) ); ?></div>
<div class="hvac-stat-label">Total Trainers</div>
</div>
<div class="hvac-stat-tile">
<div class="hvac-stat-value"><?php echo esc_html( number_format( $stats['active_trainers'] ) ); ?></div>
<div class="hvac-stat-label">Active Trainers</div>
</div>
<div class="hvac-stat-tile">
<div class="hvac-stat-value"><?php echo esc_html( number_format( $stats['pending_trainers'] ) ); ?></div>
<div class="hvac-stat-label">Pending Approval</div>
</div>
<div class="hvac-stat-tile">
<div class="hvac-stat-value"><?php echo esc_html( number_format( $stats['total_events'] ) ); ?></div>
<div class="hvac-stat-label">Total Events</div>
</div>
<div class="hvac-stat-tile">
<div class="hvac-stat-value">$<?php echo esc_html( number_format( $stats['total_revenue'], 2 ) ); ?></div>
<div class="hvac-stat-label">Total Revenue</div>
</div>
<?php
$html = ob_get_clean();
wp_send_json_success( array( 'html' => $html ) );
}
wp_send_json_error( array( 'message' => 'Unable to load trainer stats' ) );
@ -366,16 +528,35 @@ class HVAC_Master_Trainers_Overview {
/**
* Count trainers by status
*
* Uses HVAC_Trainer_Status for dynamic status calculation (active/inactive based on event activity)
*/
private function count_trainers_by_status( $status ) {
// Load trainer status class for dynamic status calculation
if ( ! class_exists( 'HVAC_Trainer_Status' ) ) {
require_once HVAC_PLUGIN_DIR . 'includes/class-hvac-trainer-status.php';
}
// Get all trainers (both roles)
$users = get_users( array(
'role' => 'hvac_trainer',
'meta_key' => 'hvac_trainer_status',
'meta_value' => $status,
'count_total' => true
'role__in' => array( 'hvac_trainer', 'hvac_master_trainer' ),
'fields' => 'ID'
) );
return is_array( $users ) ? count( $users ) : 0;
if ( empty( $users ) ) {
return 0;
}
// Count by dynamic status
$count = 0;
foreach ( $users as $user_id ) {
$user_status = HVAC_Trainer_Status::get_trainer_status( $user_id );
if ( $user_status === $status ) {
$count++;
}
}
return $count;
}
/**

View file

@ -31,9 +31,10 @@ class HVAC_Page_Manager {
'parent' => null,
'capability' => null
],
'find-a-trainer' => [
'title' => 'Find a Trainer',
'template' => 'page-find-trainer.php',
// Note: find-a-trainer removed - redirects to find-training (see HVAC_Route_Manager)
'find-training' => [
'title' => 'Find Training',
'template' => 'page-find-training.php',
'public' => true,
'parent' => null,
'capability' => null

View file

@ -112,10 +112,10 @@ final class HVAC_Plugin {
*/
private function defineConstants(): void {
if (!defined('HVAC_PLUGIN_VERSION')) {
define('HVAC_PLUGIN_VERSION', '2.0.0');
define('HVAC_PLUGIN_VERSION', '2.2.18');
}
if (!defined('HVAC_VERSION')) {
define('HVAC_VERSION', '2.0.0');
define('HVAC_VERSION', '2.2.18');
}
if (!defined('HVAC_PLUGIN_FILE')) {
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-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-shortcodes.php';
// DISABLED - Using TEC Community Events 5.x instead
@ -238,6 +241,7 @@ final class HVAC_Plugin {
'class-hvac-master-events-overview.php',
'class-hvac-master-trainers-overview.php',
'class-hvac-announcements-manager.php',
'class-hvac-announcements-admin.php',
'class-hvac-announcements-display.php',
'class-hvac-master-pages-fixer.php',
'class-hvac-master-layout-standardizer.php',
@ -251,6 +255,7 @@ final class HVAC_Plugin {
'class-attendee-profile.php',
'class-hvac-page-content-fixer.php',
'class-hvac-page-content-manager.php',
'class-hvac-slack-notifications.php',
];
// Find a Trainer feature files
@ -263,6 +268,16 @@ final class HVAC_Plugin {
'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
foreach ($this->loadFeatureFiles($featureFiles) as $file => $status) {
if ($status === 'loaded') {
@ -277,6 +292,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
$communityFiles = [
'community/class-login-handler.php',
@ -336,8 +358,11 @@ final class HVAC_Plugin {
'admin/class-hvac-enhanced-settings.php',
];
// Check if this is an OAuth callback request
$is_oauth_callback = isset($_SERVER['REQUEST_URI']) && strpos($_SERVER['REQUEST_URI'], '/oauth/callback') !== false;
// Load admin files conditionally
if (is_admin() || wp_doing_ajax()) {
if (is_admin() || wp_doing_ajax() || $is_oauth_callback) {
foreach ($this->loadFeatureFiles($adminFiles) as $file => $status) {
if ($status === 'loaded') {
$this->componentStatus["admin_{$file}"] = true;
@ -497,6 +522,11 @@ final class HVAC_Plugin {
new HVAC_Access_Control();
}
// Initialize Master Dashboard Data to register AJAX handlers
if (class_exists('HVAC_Master_Dashboard_Data')) {
new HVAC_Master_Dashboard_Data();
}
// Initialize optional components
$this->initializeOptionalComponents();
@ -545,8 +575,12 @@ final class HVAC_Plugin {
}
// Schedule non-critical components for lazy loading
add_action('wp_loaded', [$this, 'initializeSecondaryComponents'], 5);
add_action('admin_init', [$this, 'initializeAdminComponents'], 5);
// Use 'init' instead of 'wp_loaded' so components can register wp_enqueue_scripts hooks
add_action('init', [$this, 'initializeSecondaryComponents'], 5);
// Use 'init' for admin components too, ensuring AJAX handlers are registered
// (admin_menu hook doesn't fire on AJAX requests, causing 400 Bad Request/0 response)
add_action('init', [$this, 'initializeAdminComponents'], 5);
}
/**
@ -566,6 +600,11 @@ final class HVAC_Plugin {
HVAC_Venues::instance();
}
// Initialize venue categories (taxonomies for training labs)
if (class_exists('HVAC_Venue_Categories')) {
HVAC_Venue_Categories::instance();
}
// Initialize trainer profile manager
if (class_exists('HVAC_Trainer_Profile_Manager')) {
HVAC_Trainer_Profile_Manager::get_instance();
@ -699,6 +738,15 @@ final class HVAC_Plugin {
hvac_communication_scheduler();
}
// Initialize Zoho scheduled sync (must load on ALL requests for WP-Cron to work)
// This registers custom cron schedules and the cron action hook
if (file_exists(HVAC_PLUGIN_DIR . 'includes/zoho/class-zoho-scheduled-sync.php')) {
require_once HVAC_PLUGIN_DIR . 'includes/zoho/class-zoho-scheduled-sync.php';
if (class_exists('HVAC_Zoho_Scheduled_Sync')) {
HVAC_Zoho_Scheduled_Sync::instance();
}
}
// Initialize Master Trainer manager classes (fix for missing shortcode registrations)
if (class_exists('HVAC_Master_Events_Overview')) {
HVAC_Master_Events_Overview::instance();
@ -712,8 +760,18 @@ final class HVAC_Plugin {
if (class_exists('HVAC_Announcements_Display')) {
HVAC_Announcements_Display::get_instance();
}
// Initialize Slack notifications (registration + ticket purchase hooks)
if (class_exists('HVAC_Slack_Notifications')) {
HVAC_Slack_Notifications::init();
}
error_log('HVAC Plugin: Checking if HVAC_Announcements_Admin class exists: ' . (class_exists('HVAC_Announcements_Admin') ? 'YES' : 'NO'));
if (class_exists('HVAC_Announcements_Admin')) {
error_log('HVAC Plugin: Instantiating HVAC_Announcements_Admin...');
HVAC_Announcements_Admin::get_instance();
error_log('HVAC Plugin: HVAC_Announcements_Admin instantiated successfully');
} else {
error_log('HVAC Plugin: ERROR - HVAC_Announcements_Admin class does not exist!');
}
}
@ -723,19 +781,22 @@ final class HVAC_Plugin {
* Only loads admin components in admin context to improve frontend performance.
*/
public function initializeAdminComponents(): void {
// Check if this is an OAuth callback request
$is_oauth_callback = isset($_SERVER['REQUEST_URI']) && strpos($_SERVER['REQUEST_URI'], '/oauth/callback') !== false;
// Initialize admin components only when needed
if (class_exists('HVAC_Zoho_Admin')) {
if (class_exists('HVAC_Zoho_Admin') && (is_admin() || wp_doing_ajax() || $is_oauth_callback)) {
HVAC_Zoho_Admin::instance();
}
if (class_exists('HVAC_Admin_Dashboard')) {
if (class_exists('HVAC_Admin_Dashboard') && is_admin()) {
new HVAC_Admin_Dashboard();
}
if (class_exists('HVAC_Enhanced_Settings')) {
if (class_exists('HVAC_Enhanced_Settings') && is_admin()) {
HVAC_Enhanced_Settings::instance();
}
// Initialize trainer certification admin interface
if (class_exists('HVAC_Certification_Admin') && current_user_can('manage_hvac_certifications')) {
if (class_exists('HVAC_Certification_Admin') && current_user_can('manage_hvac_certifications') && is_admin()) {
HVAC_Certification_Admin::instance();
}
}
@ -847,12 +908,12 @@ final class HVAC_Plugin {
* Loads trainer directory functionality with proper error handling.
*/
public function initializeFindTrainer(): void {
// Initialize Find a Trainer page
// Initialize Find a Trainer page (legacy MapGeo-based)
if (class_exists('HVAC_Find_Trainer_Page')) {
HVAC_Find_Trainer_Page::get_instance();
}
// Initialize MapGeo integration
// Initialize MapGeo integration (legacy)
if (class_exists('HVAC_MapGeo_Integration')) {
HVAC_MapGeo_Integration::get_instance();
}
@ -867,22 +928,31 @@ final class HVAC_Plugin {
HVAC_Trainer_Directory_Query::get_instance();
}
// Initialize master trainer manager components
if (class_exists('HVAC_Master_Trainers_Overview')) {
HVAC_Master_Trainers_Overview::instance();
// Initialize Find Training page (new Google Maps-based)
if (class_exists('HVAC_Find_Training_Page')) {
HVAC_Find_Training_Page::get_instance();
}
if (class_exists('HVAC_Announcements_Manager')) {
HVAC_Announcements_Manager::get_instance();
// Initialize Training Map Data provider
if (class_exists('HVAC_Training_Map_Data')) {
HVAC_Training_Map_Data::get_instance();
}
if (class_exists('HVAC_Master_Pending_Approvals')) {
HVAC_Master_Pending_Approvals::instance();
// Initialize Venue Geocoding service
if (class_exists('HVAC_Venue_Geocoding')) {
HVAC_Venue_Geocoding::get_instance();
}
if (class_exists('HVAC_Master_Events_Overview')) {
HVAC_Master_Events_Overview::instance();
}
// ARCHITECTURE FIX (C5): Master Trainer components are already initialized
// in initializeSecondaryComponents() at priority 5. Removed duplicate
// initialization here (priority 20) to prevent confusion and potential
// 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
if (class_exists('HVAC_Master_Pages_Fixer')) {
@ -1018,8 +1088,14 @@ final class HVAC_Plugin {
// 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), '/');
if ($current_path === 'trainer/registration' || is_page('registration') || is_page('trainer-registration')) {
// Remove any potential authentication hooks that might be added by other code
remove_all_actions('template_redirect', 10);
// SECURITY FIX (U2): Remove only HVAC-specific auth redirects, not ALL hooks
// 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
}
}

View file

@ -162,6 +162,11 @@ class HVAC_Registration {
$this->send_admin_notification($user_id, $submitted_data);
}
// Slack notification (non-blocking, fire-and-forget)
if (class_exists('HVAC_Slack_Notifications')) {
HVAC_Slack_Notifications::notify_new_registration($user_id, $submitted_data);
}
// --- Success Redirect ---
$success_redirect_url = home_url('/registration-pending/'); // URL from E2E test
@ -189,11 +194,23 @@ class HVAC_Registration {
* @param string $redirect_url The URL to redirect back to.
*/
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;
// 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 = [
'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
set_transient($transient_key, $transient_data, MINUTE_IN_SECONDS * 5);

View file

@ -81,7 +81,8 @@ class HVAC_Route_Manager {
'communication-templates' => 'trainer/communication-templates',
'communication-schedules' => 'trainer/communication-schedules',
'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

View file

@ -25,9 +25,24 @@ class HVAC_Secure_Storage {
/**
* 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
*/
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');
if (!$key) {

View file

@ -181,20 +181,44 @@ class HVAC_Security {
/**
* 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
*/
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'] ) ) {
$ip = $_SERVER['HTTP_CLIENT_IP'];
} elseif ( ! empty( $_SERVER['HTTP_X_FORWARDED_FOR'] ) ) {
$ip = $_SERVER['HTTP_X_FORWARDED_FOR'];
} elseif ( ! empty( $_SERVER['REMOTE_ADDR'] ) ) {
$ip = $_SERVER['REMOTE_ADDR'];
// Only trust proxy headers if HVAC_TRUSTED_PROXIES is defined
// Define as comma-separated list of trusted proxy IPs in wp-config.php
// Example: define('HVAC_TRUSTED_PROXIES', '10.0.0.1,10.0.0.2');
if (defined('HVAC_TRUSTED_PROXIES') && HVAC_TRUSTED_PROXIES) {
$trusted_proxies = array_map('trim', explode(',', HVAC_TRUSTED_PROXIES));
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);
}
/**

View file

@ -63,6 +63,14 @@ class HVAC_Settings {
public function register_settings() {
register_setting('hvac_ce_options', 'hvac_ce_options');
register_setting('hvac_ce_options', 'hvac_google_maps_api_key', [
'type' => 'string',
'sanitize_callback' => 'sanitize_text_field',
]);
register_setting('hvac_ce_options', 'hvac_google_geocoding_api_key', [
'type' => 'string',
'sanitize_callback' => 'sanitize_text_field',
]);
add_settings_section(
'hvac_ce_main',
@ -78,6 +86,136 @@ class HVAC_Settings {
'hvac-ce',
'hvac_ce_main'
);
// Google Maps API Settings Section
add_settings_section(
'hvac_ce_google_maps',
__('Google Maps API Settings', 'hvac-ce'),
[$this, 'google_maps_section_callback'],
'hvac-ce'
);
add_settings_field(
'hvac_google_maps_api_key',
__('Maps JavaScript API Key', 'hvac-ce'),
[$this, 'maps_api_key_callback'],
'hvac-ce',
'hvac_ce_google_maps'
);
add_settings_field(
'hvac_google_geocoding_api_key',
__('Geocoding API Key', 'hvac-ce'),
[$this, 'geocoding_api_key_callback'],
'hvac-ce',
'hvac_ce_google_maps'
);
// Slack Integration Section
register_setting('hvac_ce_options', 'hvac_slack_webhook_url', [
'type' => 'string',
'sanitize_callback' => [$this, 'sanitize_slack_webhook_url'],
]);
add_settings_section(
'hvac_ce_slack',
__('Slack Integration', 'hvac-ce'),
[$this, 'slack_section_callback'],
'hvac-ce'
);
add_settings_field(
'hvac_slack_webhook_url',
__('Webhook URL', 'hvac-ce'),
[$this, 'slack_webhook_url_callback'],
'hvac-ce',
'hvac_ce_slack'
);
}
/**
* Sanitize Slack webhook URL only allow hooks.slack.com
*/
public function sanitize_slack_webhook_url(mixed $value): string {
if (!is_string($value)) {
return get_option('hvac_slack_webhook_url', '');
}
$value = trim($value);
if ($value === '') {
return '';
}
$value = esc_url_raw($value);
$scheme = parse_url($value, PHP_URL_SCHEME);
$host = parse_url($value, PHP_URL_HOST);
$path = parse_url($value, PHP_URL_PATH) ?: '';
if ($scheme !== 'https' || $host !== 'hooks.slack.com' || !str_starts_with($path, '/services/')) {
add_settings_error(
'hvac_slack_webhook_url',
'invalid_url',
__('Slack webhook URL must be a valid https://hooks.slack.com/services/... URL.', 'hvac-ce'),
'error'
);
return get_option('hvac_slack_webhook_url', ''); // keep old value
}
return $value;
}
public function slack_section_callback() {
echo '<p>' . __('Sends notifications for new trainer registrations and ticket purchases to a Slack channel.', 'hvac-ce') . '</p>';
}
public function slack_webhook_url_callback() {
$value = get_option('hvac_slack_webhook_url', '');
echo '<input type="password" name="hvac_slack_webhook_url" value="' . esc_attr($value) . '" class="regular-text" placeholder="https://hooks.slack.com/services/...">';
echo '<p class="description">' . __('Create an Incoming Webhook in your Slack workspace and paste the URL here. Leave empty to disable.', 'hvac-ce') . '</p>';
if (!empty($value)) {
$nonce = wp_create_nonce('hvac_test_slack_webhook');
echo '<div style="margin-top: 10px;">';
echo '<button type="button" id="hvac-test-slack-btn" class="button button-secondary" data-nonce="' . esc_attr($nonce) . '">';
echo __('Send Test Notification', 'hvac-ce');
echo '</button>';
echo '<span id="hvac-slack-test-status" style="margin-left: 10px;"></span>';
echo '</div>';
?>
<script type="text/javascript">
jQuery(document).ready(function($) {
$('#hvac-test-slack-btn').on('click', function() {
var $btn = $(this);
var $status = $('#hvac-slack-test-status');
var nonce = $btn.data('nonce');
$btn.prop('disabled', true).text('<?php echo esc_js(__('Sending...', 'hvac-ce')); ?>');
$status.html('');
$.ajax({
url: ajaxurl,
type: 'POST',
data: {
action: 'hvac_test_slack_webhook',
nonce: nonce
},
success: function(response) {
if (response.success) {
$status.html('<span style="color: green;">\u2713 ' + response.data.message + '</span>');
} else {
$status.html('<span style="color: red;">\u2717 ' + (response.data?.message || 'Unknown error') + '</span>');
}
$btn.prop('disabled', false).text('<?php echo esc_js(__('Send Test Notification', 'hvac-ce')); ?>');
},
error: function() {
$status.html('<span style="color: red;">\u2717 <?php echo esc_js(__('Network error. Please try again.', 'hvac-ce')); ?></span>');
$btn.prop('disabled', false).text('<?php echo esc_js(__('Send Test Notification', 'hvac-ce')); ?>');
}
});
});
});
</script>
<?php
}
}
public function settings_section_callback() {
@ -92,6 +230,312 @@ class HVAC_Settings {
__('Comma-separated list of emails to notify when new trainers register', 'hvac-ce') . '</p>';
}
public function google_maps_section_callback() {
echo '<p>' . __('Configure Google Maps API keys for maps and geocoding functionality.', 'hvac-ce') . '</p>';
echo '<p class="description">' . __('You need two API keys: one for browser-side Maps JavaScript API (HTTP referrer restricted) and one for server-side Geocoding API (IP restricted).', 'hvac-ce') . '</p>';
}
public function maps_api_key_callback() {
$value = get_option('hvac_google_maps_api_key', '');
echo '<input type="text" name="hvac_google_maps_api_key" value="' . esc_attr($value) . '" class="regular-text" placeholder="AIza...">';
echo '<p class="description">' . __('Browser-side API key for Google Maps JavaScript API. Should be HTTP referrer restricted to your domain.', 'hvac-ce') . '</p>';
}
public function geocoding_api_key_callback() {
$value = get_option('hvac_google_geocoding_api_key', '');
echo '<input type="text" name="hvac_google_geocoding_api_key" value="' . esc_attr($value) . '" class="regular-text" placeholder="AIza...">';
echo '<p class="description">' . __('Server-side API key for Google Geocoding API. Should be IP restricted to your server IP address.', 'hvac-ce') . '</p>';
// Show current status
if (!empty($value)) {
echo '<p style="color: green;">✓ ' . __('Geocoding API key is configured.', 'hvac-ce') . '</p>';
// Show batch geocode button
$this->render_batch_geocode_button();
} else {
echo '<p style="color: orange;">⚠ ' . __('Geocoding API key not set. Venue addresses will not be geocoded for map display.', 'hvac-ce') . '</p>';
}
}
/**
* Render batch geocode button and status
*/
private function render_batch_geocode_button() {
// Count venues without coordinates
$venues_without_coords = get_posts([
'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'
]
]
]);
$count = count($venues_without_coords);
$nonce = wp_create_nonce('hvac_batch_geocode_venues');
echo '<div style="margin-top: 15px; padding: 15px; background: #f5f5f5; border-radius: 4px;">';
echo '<strong>' . __('Venue Geocoding Status', 'hvac-ce') . '</strong><br>';
if ($count > 0) {
echo '<p style="color: orange;">' . sprintf(__('%d venues need geocoding.', 'hvac-ce'), $count) . '</p>';
echo '<button type="button" id="hvac-batch-geocode-btn" class="button button-secondary" data-nonce="' . esc_attr($nonce) . '">';
echo __('Geocode All Venues', 'hvac-ce');
echo '</button>';
echo '<span id="hvac-geocode-status" style="margin-left: 10px;"></span>';
} else {
echo '<p style="color: green;">✓ ' . __('All venues are geocoded.', 'hvac-ce') . '</p>';
}
echo '</div>';
// Render the mark as approved labs button
$this->render_mark_approved_button();
// Add inline JavaScript for the batch geocode button
?>
<script type="text/javascript">
jQuery(document).ready(function($) {
$('#hvac-batch-geocode-btn').on('click', function() {
var $btn = $(this);
var $status = $('#hvac-geocode-status');
var nonce = $btn.data('nonce');
$btn.prop('disabled', true).text('<?php echo esc_js(__('Processing...', 'hvac-ce')); ?>');
$status.html('<span style="color: blue;"><?php echo esc_js(__('Geocoding venues...', 'hvac-ce')); ?></span>');
function processNextBatch() {
$.ajax({
url: ajaxurl,
type: 'POST',
data: {
action: 'hvac_batch_geocode_venues',
nonce: nonce,
limit: 10
},
success: function(response) {
if (response.success) {
var data = response.data;
var msg = '<?php echo esc_js(__('Processed', 'hvac-ce')); ?>: ' + data.processed +
', <?php echo esc_js(__('Success', 'hvac-ce')); ?>: ' + data.success +
', <?php echo esc_js(__('Remaining', 'hvac-ce')); ?>: ' + data.remaining;
$status.html('<span style="color: blue;">' + msg + '</span>');
if (data.remaining > 0) {
// Continue with next batch
setTimeout(processNextBatch, 1000);
} else {
$status.html('<span style="color: green;">✓ <?php echo esc_js(__('All venues geocoded!', 'hvac-ce')); ?></span>');
$btn.text('<?php echo esc_js(__('Done!', 'hvac-ce')); ?>');
}
} else {
$status.html('<span style="color: red;"><?php echo esc_js(__('Error', 'hvac-ce')); ?>: ' + (response.data?.message || 'Unknown error') + '</span>');
$btn.prop('disabled', false).text('<?php echo esc_js(__('Retry', 'hvac-ce')); ?>');
}
},
error: function() {
$status.html('<span style="color: red;"><?php echo esc_js(__('Network error. Please try again.', 'hvac-ce')); ?></span>');
$btn.prop('disabled', false).text('<?php echo esc_js(__('Retry', 'hvac-ce')); ?>');
}
});
}
processNextBatch();
});
});
</script>
<?php
}
/**
* Render scrollable list of venues with checkboxes for approved training labs
*/
private function render_mark_approved_button() {
// Get all venues
$all_venues = get_posts([
'post_type' => 'tribe_venue',
'posts_per_page' => -1,
'post_status' => 'publish',
'orderby' => 'title',
'order' => 'ASC'
]);
$nonce = wp_create_nonce('hvac_mark_venues_approved');
echo '<div style="margin-top: 15px; padding: 15px; background: #f0f7ff; border-radius: 4px; border-left: 4px solid #0073aa;">';
echo '<strong>' . __('measureQuick Approved Training Labs', 'hvac-ce') . '</strong><br>';
echo '<p class="description">' . __('Select which venues should appear on the Find Training map as approved training labs.', 'hvac-ce') . '</p>';
if (empty($all_venues)) {
echo '<p style="color: gray;">' . __('No venues found.', 'hvac-ce') . '</p>';
echo '</div>';
return;
}
// Build venue data
$venue_data = [];
$approved_count = 0;
$geocoded_count = 0;
foreach ($all_venues as $venue) {
$is_approved = has_term('mq-approved-lab', 'venue_type', $venue->ID);
$lat = get_post_meta($venue->ID, 'venue_latitude', true) ?: get_post_meta($venue->ID, '_VenueLat', true);
$lng = get_post_meta($venue->ID, 'venue_longitude', true) ?: get_post_meta($venue->ID, '_VenueLng', true);
$has_coords = !empty($lat) && !empty($lng);
$city = get_post_meta($venue->ID, '_VenueCity', true);
$state = get_post_meta($venue->ID, '_VenueState', true);
$location = trim($city . ($city && $state ? ', ' : '') . $state);
if ($is_approved) $approved_count++;
if ($has_coords) $geocoded_count++;
$venue_data[] = [
'id' => $venue->ID,
'title' => $venue->post_title,
'location' => $location,
'is_approved' => $is_approved,
'has_coords' => $has_coords
];
}
// Summary
echo '<p style="margin: 10px 0;">';
echo sprintf(__('<strong>%d</strong> venues total, <strong>%d</strong> approved, <strong>%d</strong> geocoded', 'hvac-ce'),
count($all_venues), $approved_count, $geocoded_count);
echo '</p>';
// Scrollable list
echo '<div style="max-height: 300px; overflow-y: auto; border: 1px solid #ddd; background: #fff; padding: 10px; margin: 10px 0;">';
echo '<table style="width: 100%; border-collapse: collapse;">';
echo '<thead style="position: sticky; top: 0; background: #f5f5f5;">';
echo '<tr>';
echo '<th style="padding: 8px; text-align: left; border-bottom: 2px solid #ddd; width: 30px;">';
echo '<input type="checkbox" id="hvac-select-all-venues" title="' . esc_attr__('Select/Deselect All', 'hvac-ce') . '">';
echo '</th>';
echo '<th style="padding: 8px; text-align: left; border-bottom: 2px solid #ddd;">' . __('Venue', 'hvac-ce') . '</th>';
echo '<th style="padding: 8px; text-align: left; border-bottom: 2px solid #ddd;">' . __('Location', 'hvac-ce') . '</th>';
echo '<th style="padding: 8px; text-align: center; border-bottom: 2px solid #ddd;">' . __('Geocoded', 'hvac-ce') . '</th>';
echo '</tr>';
echo '</thead>';
echo '<tbody>';
foreach ($venue_data as $venue) {
$row_style = $venue['is_approved'] ? 'background: #e7f5e7;' : '';
echo '<tr style="' . $row_style . '">';
echo '<td style="padding: 6px 8px; border-bottom: 1px solid #eee;">';
echo '<input type="checkbox" class="hvac-venue-checkbox" value="' . esc_attr($venue['id']) . '"' .
($venue['is_approved'] ? ' checked' : '') . '>';
echo '</td>';
echo '<td style="padding: 6px 8px; border-bottom: 1px solid #eee;">' . esc_html($venue['title']) . '</td>';
echo '<td style="padding: 6px 8px; border-bottom: 1px solid #eee; color: #666;">' . esc_html($venue['location'] ?: '—') . '</td>';
echo '<td style="padding: 6px 8px; border-bottom: 1px solid #eee; text-align: center;">';
echo $venue['has_coords'] ? '<span style="color: green;">✓</span>' : '<span style="color: #ccc;">—</span>';
echo '</td>';
echo '</tr>';
}
echo '</tbody>';
echo '</table>';
echo '</div>';
// Save button
echo '<button type="button" id="hvac-save-approved-labs" class="button button-primary" data-nonce="' . esc_attr($nonce) . '">';
echo __('Save Approved Labs', 'hvac-ce');
echo '</button>';
echo '<span id="hvac-approved-status" style="margin-left: 10px;"></span>';
echo '</div>';
// JavaScript
?>
<script type="text/javascript">
jQuery(document).ready(function($) {
// Select all checkbox
$('#hvac-select-all-venues').on('change', function() {
$('.hvac-venue-checkbox').prop('checked', $(this).is(':checked'));
});
// Update select-all state when individual checkboxes change
$('.hvac-venue-checkbox').on('change', function() {
var total = $('.hvac-venue-checkbox').length;
var checked = $('.hvac-venue-checkbox:checked').length;
$('#hvac-select-all-venues').prop('checked', total === checked);
$('#hvac-select-all-venues').prop('indeterminate', checked > 0 && checked < total);
});
// Initialize select-all state
(function() {
var total = $('.hvac-venue-checkbox').length;
var checked = $('.hvac-venue-checkbox:checked').length;
$('#hvac-select-all-venues').prop('checked', total === checked);
$('#hvac-select-all-venues').prop('indeterminate', checked > 0 && checked < total);
})();
// Save button
$('#hvac-save-approved-labs').on('click', function() {
var $btn = $(this);
var $status = $('#hvac-approved-status');
var nonce = $btn.data('nonce');
// Collect selected venue IDs
var selectedIds = [];
$('.hvac-venue-checkbox:checked').each(function() {
selectedIds.push($(this).val());
});
$btn.prop('disabled', true).text('<?php echo esc_js(__('Saving...', 'hvac-ce')); ?>');
$status.html('<span style="color: blue;"><?php echo esc_js(__('Updating venues...', 'hvac-ce')); ?></span>');
$.ajax({
url: ajaxurl,
type: 'POST',
data: {
action: 'hvac_update_approved_labs',
nonce: nonce,
venue_ids: selectedIds
},
success: function(response) {
if (response.success) {
var data = response.data;
var msg = '<?php echo esc_js(__('Saved!', 'hvac-ce')); ?> ' + data.approved_count + ' <?php echo esc_js(__('approved labs', 'hvac-ce')); ?>';
$status.html('<span style="color: green;">✓ ' + msg + '</span>');
$btn.prop('disabled', false).text('<?php echo esc_js(__('Save Approved Labs', 'hvac-ce')); ?>');
// Update row highlighting
$('.hvac-venue-checkbox').each(function() {
var $row = $(this).closest('tr');
if ($(this).is(':checked')) {
$row.css('background', '#e7f5e7');
} else {
$row.css('background', '');
}
});
} else {
$status.html('<span style="color: red;"><?php echo esc_js(__('Error', 'hvac-ce')); ?>: ' + (response.data?.message || 'Unknown error') + '</span>');
$btn.prop('disabled', false).text('<?php echo esc_js(__('Save Approved Labs', 'hvac-ce')); ?>');
}
},
error: function() {
$status.html('<span style="color: red;"><?php echo esc_js(__('Network error. Please try again.', 'hvac-ce')); ?></span>');
$btn.prop('disabled', false).text('<?php echo esc_js(__('Save Approved Labs', 'hvac-ce')); ?>');
}
});
});
});
</script>
<?php
}
public function options_page() {
$active_tab = isset( $_GET['tab'] ) ? sanitize_text_field( $_GET['tab'] ) : 'general';

View file

@ -0,0 +1,485 @@
<?php
declare(strict_types=1);
if (!defined('ABSPATH')) {
exit;
}
/**
* Slack notification utility for trainer registrations and ticket purchases.
*
* Uses Slack Incoming Webhooks with Block Kit rich formatting.
* All sends are non-blocking (fire-and-forget) so Slack failures
* never affect registration or checkout flows.
*/
class HVAC_Slack_Notifications {
/** @var bool Prevents duplicate hook registration */
private static bool $initialized = false;
/**
* Register hooks. Called once from HVAC_Plugin::initializeSecondaryComponents().
*/
public static function init(): void {
if (self::$initialized) {
return;
}
self::$initialized = true;
// Ticket purchase: fires on any post status transition
add_action('transition_post_status', [__CLASS__, 'on_order_status_change'], 10, 3);
// Event published by admin (any tribe_events post transitioning to publish)
add_action('transition_post_status', [__CLASS__, 'on_event_status_change'], 10, 3);
// Event submitted by trainer via TEC Community Events form
add_action('hvac_tec_event_saved', [__CLASS__, 'notify_event_submitted'], 10, 1);
// AJAX: test webhook from settings page
add_action('wp_ajax_hvac_test_slack_webhook', [__CLASS__, 'send_test_notification']);
}
/**
* Send a message to Slack via Incoming Webhook.
*
* @param string $text Plain-text fallback (required by Slack).
* @param array $blocks Block Kit blocks for rich layout.
* @param bool $blocking Whether to wait for the response.
* @return bool|null True on success, false on failure, null if disabled.
*/
private static function send(string $text, array $blocks, bool $blocking = false): ?bool {
$webhook_url = get_option('hvac_slack_webhook_url', '');
if (empty($webhook_url)) {
return null; // Disabled — no webhook configured
}
// Validate URL: must be https://hooks.slack.com/services/...
$scheme = parse_url($webhook_url, PHP_URL_SCHEME);
$host = parse_url($webhook_url, PHP_URL_HOST);
$path = parse_url($webhook_url, PHP_URL_PATH) ?: '';
if ($scheme !== 'https' || $host !== 'hooks.slack.com' || !str_starts_with($path, '/services/')) {
error_log('[HVAC Slack] Rejected webhook URL: ' . $webhook_url);
return false;
}
$payload = wp_json_encode([
'text' => $text,
'blocks' => $blocks,
]);
$response = wp_remote_post($webhook_url, [
'body' => $payload,
'headers' => ['Content-Type' => 'application/json'],
'timeout' => 5,
'blocking' => $blocking,
]);
if ($blocking) {
if (is_wp_error($response)) {
error_log('[HVAC Slack] Send failed: ' . $response->get_error_message());
return false;
}
$code = wp_remote_retrieve_response_code($response);
if ($code !== 200) {
$body = wp_remote_retrieve_body($response);
error_log("[HVAC Slack] Slack returned HTTP {$code}: {$body}");
return false;
}
return true;
}
// Non-blocking — optimistically assume success
if (is_wp_error($response)) {
error_log('[HVAC Slack] Non-blocking send error: ' . $response->get_error_message());
}
return true;
}
// ------------------------------------------------------------------
// Registration notification
// ------------------------------------------------------------------
/**
* Send a Slack notification for a new trainer registration.
*
* @param int $user_id Newly created user ID.
* @param array $submitted_data Raw form data from registration.
*/
public static function notify_new_registration(int $user_id, array $submitted_data): void {
$first_name = sanitize_text_field($submitted_data['first_name'] ?? 'N/A');
$last_name = sanitize_text_field($submitted_data['last_name'] ?? '');
$full_name = trim("{$first_name} {$last_name}") ?: 'N/A';
$role = sanitize_text_field($submitted_data['role'] ?? $submitted_data['trainer_type'] ?? 'Trainer');
$business_name = sanitize_text_field($submitted_data['business_name'] ?? $submitted_data['company'] ?? 'N/A');
$business_type = sanitize_text_field($submitted_data['business_type'] ?? 'N/A');
$admin_url = admin_url("user-edit.php?user_id={$user_id}");
$text = "New Trainer Registration: {$full_name} ({$role})";
// Profile image accessory (optional)
$accessory = null;
$image_id = get_user_meta($user_id, 'profile_image_id', true);
if ($image_id) {
$image_url = wp_get_attachment_image_url((int) $image_id, 'thumbnail');
if ($image_url) {
$accessory = [
'type' => 'image',
'image_url' => $image_url,
'alt_text' => $full_name,
];
}
}
$fields_section = [
'type' => 'section',
'fields' => [
['type' => 'mrkdwn', 'text' => "*Name:*\n{$full_name}"],
['type' => 'mrkdwn', 'text' => "*Role:*\n{$role}"],
['type' => 'mrkdwn', 'text' => "*Organization:*\n{$business_name}"],
['type' => 'mrkdwn', 'text' => "*Business Type:*\n{$business_type}"],
],
];
if ($accessory) {
$fields_section['accessory'] = $accessory;
}
$blocks = [
[
'type' => 'header',
'text' => ['type' => 'plain_text', 'text' => "\xF0\x9F\x86\x95 New Trainer Registration", 'emoji' => true],
],
$fields_section,
[
'type' => 'actions',
'elements' => [
[
'type' => 'button',
'text' => ['type' => 'plain_text', 'text' => 'View in WordPress'],
'url' => $admin_url,
'style' => 'primary',
],
],
],
];
self::send($text, $blocks);
}
// ------------------------------------------------------------------
// Ticket purchase notification
// ------------------------------------------------------------------
/**
* Hook callback for transition_post_status.
*
* @param string $new_status New post status.
* @param string $old_status Previous post status.
* @param WP_Post $post Post object.
*/
public static function on_order_status_change(string $new_status, string $old_status, \WP_Post $post): void {
// Guard 1: correct post type
if ($post->post_type !== 'tec_tc_order') {
return;
}
// Guard 2: transitioning INTO completed
if ($new_status !== 'tec-tc-completed') {
return;
}
// Guard 3: not already completed (prevents re-saves)
if ($old_status === 'tec-tc-completed') {
return;
}
// Atomic idempotency: add_post_meta with $unique=true prevents races
if (!add_post_meta($post->ID, '_hvac_slack_ticket_notified', '1', true)) {
return;
}
self::notify_ticket_purchase($post->ID);
}
/**
* Send a Slack notification for a completed ticket purchase.
*
* @param int $order_id TEC Tickets Commerce order post ID.
*/
public static function notify_ticket_purchase(int $order_id): void {
$purchaser_name = get_post_meta($order_id, '_tec_tc_order_purchaser_name', true) ?: 'N/A';
$purchaser_email = get_post_meta($order_id, '_tec_tc_order_purchaser_email', true) ?: 'N/A';
$order_items = get_post_meta($order_id, '_tec_tc_order_items', true);
$gateway = get_post_meta($order_id, '_tec_tc_order_gateway', true) ?: 'Unknown';
$total_qty = 0;
$total_price = 0.0;
$event_title = 'N/A';
if (is_array($order_items)) {
foreach ($order_items as $item) {
$qty = (int) ($item['quantity'] ?? 1);
$total_qty += $qty;
$total_price += (float) ($item['sub_total'] ?? $item['price'] ?? 0) * $qty;
if (!empty($item['event_id']) && $event_title === 'N/A') {
$title = get_the_title((int) $item['event_id']);
if ($title) {
$event_title = $title;
}
}
}
}
$total_formatted = '$' . number_format($total_price, 2);
$admin_url = admin_url("post.php?post={$order_id}&action=edit");
$text = "New Ticket Purchase: {$purchaser_name}{$total_qty} ticket(s) for {$event_title} ({$total_formatted})";
$blocks = [
[
'type' => 'header',
'text' => ['type' => 'plain_text', 'text' => "\xF0\x9F\x8E\x9F\xEF\xB8\x8F New Ticket Purchase", 'emoji' => true],
],
[
'type' => 'section',
'fields' => [
['type' => 'mrkdwn', 'text' => "*Purchaser:*\n{$purchaser_name}"],
['type' => 'mrkdwn', 'text' => "*Email:*\n{$purchaser_email}"],
['type' => 'mrkdwn', 'text' => "*Event:*\n{$event_title}"],
['type' => 'mrkdwn', 'text' => "*Tickets:*\n{$total_qty}"],
],
],
[
'type' => 'section',
'fields' => [
['type' => 'mrkdwn', 'text' => "*Total:*\n{$total_formatted}"],
],
],
[
'type' => 'context',
'elements' => [
['type' => 'mrkdwn', 'text' => "via {$gateway}"],
],
],
[
'type' => 'actions',
'elements' => [
[
'type' => 'button',
'text' => ['type' => 'plain_text', 'text' => 'View Order'],
'url' => $admin_url,
'style' => 'primary',
],
],
],
];
self::send($text, $blocks);
}
// ------------------------------------------------------------------
// Event notifications
// ------------------------------------------------------------------
/**
* Build common event Block Kit fields from an event post ID.
*
* @param int $event_id The tribe_events post ID.
* @return array{text: string, fields: array, admin_url: string, event_title: string}
*/
private static function build_event_fields(int $event_id): array {
$event = get_post($event_id);
$event_title = $event ? $event->post_title : 'N/A';
$start_date = get_post_meta($event_id, '_EventStartDate', true);
$end_date = get_post_meta($event_id, '_EventEndDate', true);
$venue_id = get_post_meta($event_id, '_EventVenueID', true);
$venue_name = $venue_id ? get_the_title((int) $venue_id) : 'N/A';
$date_display = 'N/A';
if ($start_date) {
$date_display = wp_date('M j, Y g:ia', strtotime($start_date));
if ($end_date) {
$date_display .= ' — ' . wp_date('g:ia', strtotime($end_date));
}
}
// Get trainer/author info
$author_id = $event ? (int) $event->post_author : 0;
$author_name = $author_id ? get_the_author_meta('display_name', $author_id) : 'N/A';
$admin_url = admin_url("post.php?post={$event_id}&action=edit");
$front_url = get_permalink($event_id) ?: $admin_url;
$fields = [
['type' => 'mrkdwn', 'text' => "*Event:*\n{$event_title}"],
['type' => 'mrkdwn', 'text' => "*Trainer:*\n{$author_name}"],
['type' => 'mrkdwn', 'text' => "*Date:*\n{$date_display}"],
['type' => 'mrkdwn', 'text' => "*Venue:*\n{$venue_name}"],
];
return [
'event_title' => $event_title,
'author_name' => $author_name,
'fields' => $fields,
'admin_url' => $admin_url,
'front_url' => $front_url,
];
}
/**
* Called when a trainer submits an event via TEC Community Events form.
* Hooked to `hvac_tec_event_saved`.
*
* @param int $event_id The saved event post ID.
*/
public static function notify_event_submitted(int $event_id): void {
// Atomic lock: add_post_meta with $unique=true returns false if key already exists.
// Prevents duplicate sends on edits and concurrent requests.
if (!add_post_meta($event_id, '_hvac_slack_event_notified', '1', true)) {
return;
}
$data = self::build_event_fields($event_id);
$text = "New Event Submitted: {$data['event_title']} by {$data['author_name']}";
$blocks = [
[
'type' => 'header',
'text' => ['type' => 'plain_text', 'text' => "\xF0\x9F\x93\x9D Event Submitted by Trainer", 'emoji' => true],
],
[
'type' => 'section',
'fields' => $data['fields'],
],
[
'type' => 'actions',
'elements' => [
[
'type' => 'button',
'text' => ['type' => 'plain_text', 'text' => 'View Event'],
'url' => $data['admin_url'],
'style' => 'primary',
],
],
],
];
self::send($text, $blocks);
}
/**
* Hook callback for transition_post_status on tribe_events.
* Fires when an admin publishes an event (including from draft/pending).
*
* @param string $new_status New post status.
* @param string $old_status Previous post status.
* @param WP_Post $post Post object.
*/
public static function on_event_status_change(string $new_status, string $old_status, \WP_Post $post): void {
if ($post->post_type !== 'tribe_events') {
return;
}
// Only notify when transitioning INTO publish
if ($new_status !== 'publish' || $old_status === 'publish') {
return;
}
// Atomic lock: prevents double-notify from trainer submission hook or concurrent requests
if (!add_post_meta($post->ID, '_hvac_slack_event_notified', '1', true)) {
return;
}
$data = self::build_event_fields($post->ID);
$text = "Event Published: {$data['event_title']}";
$blocks = [
[
'type' => 'header',
'text' => ['type' => 'plain_text', 'text' => "\xE2\x9C\x85 Event Published", 'emoji' => true],
],
[
'type' => 'section',
'fields' => $data['fields'],
],
[
'type' => 'actions',
'elements' => [
[
'type' => 'button',
'text' => ['type' => 'plain_text', 'text' => 'View Event'],
'url' => $data['front_url'],
],
[
'type' => 'button',
'text' => ['type' => 'plain_text', 'text' => 'Edit in WP'],
'url' => $data['admin_url'],
],
],
],
];
self::send($text, $blocks);
}
// ------------------------------------------------------------------
// Test notification (AJAX)
// ------------------------------------------------------------------
/**
* AJAX handler: send a test notification to verify webhook config.
* Uses blocking mode so the admin UI gets real success/failure feedback.
*/
public static function send_test_notification(): void {
if (!current_user_can('manage_options')) {
wp_send_json_error(['message' => 'Unauthorized'], 403);
}
check_ajax_referer('hvac_test_slack_webhook', 'nonce');
$env = function_exists('hvac_is_staging_environment') && hvac_is_staging_environment() ? 'Staging' : 'Production';
$site_url = home_url('/');
$text = "Test notification from HVAC Community Events ({$env})";
$blocks = [
[
'type' => 'header',
'text' => ['type' => 'plain_text', 'text' => "\xE2\x9C\x85 Slack Integration Test", 'emoji' => true],
],
[
'type' => 'section',
'text' => [
'type' => 'mrkdwn',
'text' => "This is a test notification from *HVAC Community Events*.\nIf you see this, your webhook is working correctly.",
],
],
[
'type' => 'section',
'fields' => [
['type' => 'mrkdwn', 'text' => "*Environment:*\n{$env}"],
['type' => 'mrkdwn', 'text' => "*Site:*\n{$site_url}"],
],
],
[
'type' => 'context',
'elements' => [
['type' => 'mrkdwn', 'text' => 'Sent at ' . current_time('Y-m-d H:i:s')],
],
],
];
$result = self::send($text, $blocks, blocking: true);
if ($result === null) {
wp_send_json_error(['message' => 'No webhook URL configured.']);
} elseif ($result === false) {
wp_send_json_error(['message' => 'Slack returned an error. Check the webhook URL and try again.']);
} else {
wp_send_json_success(['message' => 'Test notification sent successfully!']);
}
}
}

View file

@ -1232,5 +1232,9 @@ class HVAC_Trainer_Profile_Manager {
}
}
// Initialize the manager
HVAC_Trainer_Profile_Manager::get_instance();
// ARCHITECTURE FIX (U9): Removed file-scope side-effect initialization.
// 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();

View file

@ -0,0 +1,289 @@
<?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() {
// If init has already fired (we're being initialized late), register immediately
// This handles the case where the class is instantiated during 'init' priority >= 5
if (did_action('init')) {
$this->register_taxonomies();
$this->create_default_terms();
} else {
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);
}
}

View file

@ -30,6 +30,13 @@ class HVAC_MapGeo_Integration {
*/
private $map_id = '5872';
/**
* Stored clean markers to prevent IGM corruption
*
* @var array
*/
private $clean_markers = [];
/**
* Get instance of this class
*
@ -92,8 +99,12 @@ class HVAC_MapGeo_Integration {
add_action('wp_ajax_hvac_search_trainers', [$this, 'ajax_search_trainers']);
add_action('wp_ajax_nopriv_hvac_search_trainers', [$this, 'ajax_search_trainers']);
// Add JavaScript to handle MapGeo marker clicks
add_action('wp_footer', [$this, 'add_mapgeo_click_handlers']);
// Add JavaScript to handle MapGeo marker clicks - MUST run in wp_head BEFORE shortcode renders
// The IGM plugin outputs iMapsData during shortcode execution, so interceptor must be installed first
add_action('wp_head', [$this, 'add_mapgeo_interceptor'], 1);
// Add click handlers in footer (these run after map initializes)
add_action('wp_footer', [$this, 'add_mapgeo_click_handlers'], 0);
}
/**
@ -139,22 +150,27 @@ class HVAC_MapGeo_Integration {
}
}
// If no existing markers, create them from trainer data
if (!$has_existing_markers) {
error_log('HVAC MapGeo: No existing markers found, creating from trainer data');
$trainers = $this->get_geocoded_trainers();
error_log('HVAC MapGeo: Found ' . count($trainers) . ' geocoded trainers');
// Always try to create markers from trainer data for our map
// The IGM plugin's "Other Data Sources" feature is not reliably
// serializing to the frontend, so we inject markers ourselves
error_log('HVAC MapGeo: Querying geocoded trainers for map injection');
$trainers = $this->get_geocoded_trainers();
error_log('HVAC MapGeo: Found ' . count($trainers) . ' geocoded trainers');
if (!empty($trainers)) {
$trainer_markers = array_values(array_filter(
array_map([$this, 'format_trainer_for_mapgeo'], $trainers)
));
if (!empty($trainers)) {
$trainer_markers = array_values(array_filter(
array_map([$this, 'format_trainer_for_mapgeo'], $trainers)
));
if (!empty($trainer_markers)) {
$meta['roundMarkers'] = $trainer_markers;
error_log('HVAC MapGeo: Created ' . count($trainer_markers) . ' trainer markers');
}
if (!empty($trainer_markers)) {
// Override/set roundMarkers with our trainer data
$meta['roundMarkers'] = $trainer_markers;
error_log('HVAC MapGeo: Injected ' . count($trainer_markers) . ' trainer markers into map');
} else {
error_log('HVAC MapGeo: WARNING - No markers could be formatted from trainers');
}
} else {
error_log('HVAC MapGeo: WARNING - No geocoded trainers found');
}
foreach ($marker_types as $marker_type) {
@ -163,7 +179,12 @@ class HVAC_MapGeo_Integration {
foreach ($meta[$marker_type] as $index => &$marker) {
// Log marker structure for debugging
error_log('HVAC MapGeo: Marker ' . $index . ' keys: ' . implode(', ', array_keys($marker)));
// error_log('HVAC MapGeo: Marker ' . $index . ' keys: ' . implode(', ', array_keys($marker)));
// Optimization: If marker already has our profile ID (we injected it), skip expensive lookup
if (isset($marker['hvac_profile_id'])) {
continue;
}
// Check if this marker has trainer data we can identify
$trainer_name = null;
@ -210,7 +231,13 @@ class HVAC_MapGeo_Integration {
}
}
// Strategy D: Cache clean markers for footer injection override
if (isset($meta['roundMarkers'])) {
$this->clean_markers = $meta['roundMarkers'];
}
error_log('HVAC MapGeo: Map layout modification complete');
return $meta;
}
@ -376,6 +403,86 @@ class HVAC_MapGeo_Integration {
}
/**
* Add JavaScript interceptor in wp_head BEFORE shortcode renders
* This MUST run before IGM plugin outputs iMapsData
*/
public function add_mapgeo_interceptor() {
// Only add on find trainer page
if (!is_page() || get_post_field('post_name') !== 'find-a-trainer') {
return;
}
?>
<script type="text/javascript">
// Strategy H (v2): Multi-stage healing for IGM coordinate corruption
// The IGM plugin corrupts longitude AFTER initial assignment, so we need multiple healing passes
(function() {
// Prevent double-installation
if (window._hvacMapInterceptorInstalled) {
return;
}
window._hvacMapInterceptorInstalled = true;
// Healing function that can be called multiple times
window._hvacHealMapMarkers = function(source) {
if (typeof window.iMapsData === 'undefined' || !window.iMapsData) return 0;
var data = window.iMapsData;
if (!data.data || !data.data[0]) return 0;
var markerTypes = ['roundMarkers', 'iconMarkers', 'markers', 'customMarkers'];
var totalHealed = 0;
markerTypes.forEach(function(markerType) {
if (data.data[0][markerType] && Array.isArray(data.data[0][markerType])) {
data.data[0][markerType].forEach(function(m) {
// Detect corruption: latitude === longitude but backup values differ
if (m.lat && m.lng && m.latitude == m.longitude && parseFloat(m.lat) != parseFloat(m.lng)) {
m.latitude = parseFloat(m.lat);
m.longitude = parseFloat(m.lng);
totalHealed++;
}
});
}
});
if (totalHealed > 0) {
console.log('✅ HVAC MapGeo Healer [' + source + ']: Fixed ' + totalHealed + ' corrupted coordinates.');
}
return totalHealed;
};
// Schedule multiple healing passes to catch post-assignment corruption
var healingAttempts = 0;
var maxAttempts = 10;
var healingInterval = setInterval(function() {
healingAttempts++;
var healed = window._hvacHealMapMarkers('interval-' + healingAttempts);
// Stop after max attempts or if we've healed and no more corruption
if (healingAttempts >= maxAttempts) {
clearInterval(healingInterval);
console.log('🛡️ HVAC MapGeo Healer: Completed ' + healingAttempts + ' healing passes.');
}
}, 100); // Check every 100ms
// Also heal on DOMContentLoaded and window load
document.addEventListener('DOMContentLoaded', function() {
setTimeout(function() { window._hvacHealMapMarkers('DOMContentLoaded'); }, 50);
setTimeout(function() { window._hvacHealMapMarkers('DOMContentLoaded+500'); }, 500);
});
window.addEventListener('load', function() {
setTimeout(function() { window._hvacHealMapMarkers('window.load'); }, 100);
setTimeout(function() { window._hvacHealMapMarkers('window.load+1000'); }, 1000);
});
console.log('🛡️ HVAC MapGeo Healer installed with multi-stage healing');
})();
</script>
<?php
}
/**
* Add JavaScript to handle MapGeo custom click actions
*/
@ -667,7 +774,11 @@ class HVAC_MapGeo_Integration {
$lat = get_post_meta($profile_id, 'latitude', true);
$lng = get_post_meta($profile_id, 'longitude', true);
// Debug logging to trace coordinate retrieval
error_log("HVAC MapGeo DEBUG: Profile {$profile_id} - lat='{$lat}' (type: " . gettype($lat) . "), lng='{$lng}' (type: " . gettype($lng) . ")");
if (!$lat || !$lng) {
error_log("HVAC MapGeo: Profile {$profile_id} missing coordinates - skipping");
return false;
}
@ -691,14 +802,17 @@ class HVAC_MapGeo_Integration {
$profile_id
);
// Return marker in IGM-compatible format with top-level lat/lng
return [
'id' => 'trainer_' . $profile_id,
'coordinates' => [
'lat' => floatval($lat),
'lng' => floatval($lng)
],
'title' => $trainer_name, // IGM uses title for display
'latitude' => strval($lat), // IGM expects string latitude
'longitude' => strval($lng), // IGM expects string longitude
'lat' => strval($lat), // Compatibility: provide lat key
'lng' => strval($lng), // Compatibility: provide lng key
'tooltipContent' => $tooltip,
'action' => 'hvac_show_trainer_modal', // Use custom action for trainer modal
'action' => 'hvac_show_trainer_modal',
'hvac_profile_id' => $profile_id,
'value' => '1',
'radius' => '10',
'fill' => $certification === 'Certified measureQuick Champion' ? '#FFD700' : '#0073aa',
@ -715,6 +829,7 @@ class HVAC_MapGeo_Integration {
* @return array
*/
private function get_geocoded_trainers() {
// First try with public profile requirement
$args = [
'post_type' => 'trainer_profile',
'posts_per_page' => -1,
@ -752,6 +867,41 @@ class HVAC_MapGeo_Integration {
wp_reset_postdata();
// If no public profiles found, try without the is_public_profile check
// This helps during testing or if profiles haven't been set public yet
if (empty($trainers)) {
error_log('HVAC MapGeo: No public geocoded trainers found, trying without public restriction');
$args_relaxed = [
'post_type' => 'trainer_profile',
'posts_per_page' => -1,
'post_status' => 'publish',
'meta_query' => [
'relation' => 'AND',
[
'key' => 'latitude',
'compare' => 'EXISTS'
],
[
'key' => 'longitude',
'compare' => 'EXISTS'
]
]
];
$query_relaxed = new WP_Query($args_relaxed);
if ($query_relaxed->have_posts()) {
while ($query_relaxed->have_posts()) {
$query_relaxed->the_post();
$trainers[] = get_the_ID();
}
}
wp_reset_postdata();
error_log('HVAC MapGeo: Found ' . count($trainers) . ' trainers with relaxed query');
}
return $trainers;
}
@ -1451,8 +1601,16 @@ class HVAC_MapGeo_Integration {
* @return array Modified marker data
*/
public function inject_trainer_modal_data($marker_data, $map_id) {
// For now, just pass through the marker data
// This method exists to prevent the missing method error
// Self-Healing: Restore coordinates from backup keys if present
// This fixes a bug where IGM plugin corrupts the 'longitude' value by overwriting it with 'latitude'
if (isset($marker_data['lat']) && isset($marker_data['lng'])) {
$marker_data['latitude'] = $marker_data['lat'];
$marker_data['longitude'] = $marker_data['lng'];
// Debug log to confirm fix is running (can remove later)
// error_log("HVAC MapGeo HEALED: {$marker_data['title']} -> {$marker_data['latitude']}, {$marker_data['longitude']}");
}
return $marker_data;
}
}

View file

@ -0,0 +1,571 @@
<?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 {
if (is_page($this->page_slug)) {
return true;
}
$page_id = get_option('hvac_find_training_page_id');
return $page_id && is_page($page_id);
}
/**
* Check if Google Maps API key is configured
*
* @return bool
*/
public function is_api_key_configured(): bool {
return !empty($this->api_key);
}
/**
* Enqueue page assets
*/
public function enqueue_assets(): void {
if (!$this->is_find_training_page()) {
return;
}
$api_key_configured = !empty($this->api_key);
// Enqueue CSS
wp_enqueue_style(
'hvac-find-training',
HVAC_PLUGIN_URL . 'assets/css/find-training-map.css',
['astra-theme-css'],
HVAC_VERSION
);
// Enqueue Google reCAPTCHA for contact forms
if (class_exists('HVAC_Recaptcha')) {
HVAC_Recaptcha::instance()->enqueue_script();
}
// Build script dependencies
$map_script_deps = ['jquery'];
// Enqueue Google Maps API with MarkerClusterer only if API key is configured
if ($api_key_configured) {
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
);
$map_script_deps[] = 'google-maps-api';
$map_script_deps[] = 'google-maps-markerclusterer';
}
// Enqueue main map JavaScript (always load for directory functionality)
wp_enqueue_script(
'hvac-find-training-map',
HVAC_PLUGIN_URL . 'assets/js/find-training-map.js',
$map_script_deps,
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
);
// Get reCAPTCHA site key if available
$recaptcha_site_key = '';
if (class_exists('HVAC_Recaptcha')) {
$recaptcha_site_key = HVAC_Recaptcha::instance()->get_site_key();
}
// 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_configured' => $api_key_configured,
'map_center' => [
'lat' => 39.8283, // US center
'lng' => -98.5795
],
'default_zoom' => 4,
'cluster_zoom' => 8,
'recaptcha_site_key' => $recaptcha_site_key,
'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'),
'api_key_missing' => __('Google Maps API key is not configured.', 'hvac-community-events'),
'captcha_required' => __('Please complete the CAPTCHA verification.', 'hvac-community-events'),
'captcha_failed' => __('CAPTCHA verification failed. Please try again.', 'hvac-community-events')
],
'marker_icons' => [
'trainer' => HVAC_PLUGIN_URL . 'assets/images/marker-trainer.svg',
'venue' => HVAC_PLUGIN_URL . 'assets/images/marker-venue.svg',
'event' => HVAC_PLUGIN_URL . 'assets/images/marker-event.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, venues, and events)
*/
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();
$events = $data_provider->get_event_markers();
wp_send_json_success([
'trainers' => $trainers,
'venues' => $venues,
'events' => $events,
'total_trainers' => count($trainers),
'total_venues' => count($venues),
'total_events' => count($events)
]);
}
/**
* 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),
'show_events' => filter_var($_POST['show_events'] ?? true, FILTER_VALIDATE_BOOLEAN),
'include_past' => filter_var($_POST['include_past'] ?? false, 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' => [],
'events' => []
];
if ($filters['show_trainers']) {
$result['trainers'] = $data_provider->get_trainer_markers($filters);
}
if ($filters['show_venues']) {
$result['venues'] = $data_provider->get_venue_markers($filters);
}
if ($filters['show_events']) {
$result['events'] = $data_provider->get_event_markers($filters);
}
$result['total_trainers'] = count($result['trainers']);
$result['total_venues'] = count($result['venues']);
$result['total_events'] = count($result['events']);
$result['filters_applied'] = array_filter($filters, function($v) {
return !empty($v) && $v !== true;
});
wp_send_json_success($result);
}
/**
* AJAX: Get trainer profile for modal
*/
public function ajax_get_trainer_profile(): void {
// Verify nonce
if (!isset($_POST['nonce']) || !wp_verify_nonce($_POST['nonce'], 'hvac_find_training')) {
wp_send_json_error(['message' => 'Invalid security token']);
return;
}
$profile_id = absint($_POST['profile_id'] ?? 0);
if (!$profile_id) {
wp_send_json_error(['message' => 'Invalid profile ID']);
return;
}
$data_provider = HVAC_Training_Map_Data::get_instance();
$trainer_data = $data_provider->get_trainer_full_profile($profile_id);
if (!$trainer_data) {
wp_send_json_error(['message' => 'Trainer not found']);
return;
}
// Generate modal HTML
ob_start();
$this->render_trainer_modal_content($trainer_data);
$html = ob_get_clean();
wp_send_json_success([
'trainer' => $trainer_data,
'html' => $html
]);
}
/**
* AJAX: Get venue info for info window
*/
public function ajax_get_venue_info(): void {
// Verify nonce
if (!isset($_POST['nonce']) || !wp_verify_nonce($_POST['nonce'], 'hvac_find_training')) {
wp_send_json_error(['message' => 'Invalid security token']);
return;
}
$venue_id = absint($_POST['venue_id'] ?? 0);
if (!$venue_id) {
wp_send_json_error(['message' => 'Invalid venue ID']);
return;
}
$data_provider = HVAC_Training_Map_Data::get_instance();
$venue_data = $data_provider->get_venue_full_info($venue_id);
if (!$venue_data) {
wp_send_json_error(['message' => 'Venue not found']);
return;
}
wp_send_json_success(['venue' => $venue_data]);
}
/**
* Render trainer modal content
*
* @param array $trainer Trainer data
*/
private function render_trainer_modal_content(array $trainer): void {
?>
<div class="hvac-training-modal-header">
<h2><?php echo esc_html($trainer['name']); ?></h2>
<button class="hvac-modal-close" aria-label="Close modal">&times;</button>
</div>
<div class="hvac-training-modal-body">
<div class="hvac-training-profile-section">
<div class="hvac-training-profile-header">
<?php if (!empty($trainer['image'])): ?>
<img src="<?php echo esc_url($trainer['image']); ?>" alt="<?php echo esc_attr($trainer['name']); ?>" class="hvac-training-profile-image">
<?php else: ?>
<div class="hvac-training-profile-avatar">
<span class="dashicons dashicons-businessperson"></span>
</div>
<?php endif; ?>
<div class="hvac-training-profile-info">
<p class="hvac-training-location">
<?php echo esc_html($trainer['city'] . ', ' . $trainer['state']); ?>
</p>
<?php if (!empty($trainer['certifications'])): ?>
<div class="hvac-training-certifications">
<?php foreach ($trainer['certifications'] as $cert): ?>
<span class="hvac-cert-badge"><?php echo esc_html($cert); ?></span>
<?php endforeach; ?>
</div>
<?php endif; ?>
<?php if (!empty($trainer['company'])): ?>
<p class="hvac-training-company"><?php echo esc_html($trainer['company']); ?></p>
<?php endif; ?>
<p class="hvac-training-events-count">
Total Training Events: <strong><?php echo intval($trainer['event_count']); ?></strong>
</p>
</div>
</div>
<?php if (!empty($trainer['training_formats'])): ?>
<div class="hvac-training-detail">
<strong>Training Formats:</strong> <?php echo esc_html(implode(', ', $trainer['training_formats'])); ?>
</div>
<?php endif; ?>
<?php if (!empty($trainer['upcoming_events'])): ?>
<div class="hvac-training-events">
<h4>Upcoming Events</h4>
<ul class="hvac-events-list">
<?php foreach (array_slice($trainer['upcoming_events'], 0, 5) as $event): ?>
<li>
<a href="<?php echo esc_url($event['url']); ?>" target="_blank">
<?php echo esc_html($event['title']); ?>
</a>
<span class="hvac-event-date"><?php echo esc_html($event['date']); ?></span>
</li>
<?php endforeach; ?>
</ul>
</div>
<?php endif; ?>
</div>
<div class="hvac-training-contact-section">
<h4>Contact Trainer</h4>
<form class="hvac-training-contact-form" data-trainer-id="<?php echo esc_attr($trainer['user_id']); ?>" data-profile-id="<?php echo esc_attr($trainer['profile_id']); ?>">
<div class="hvac-form-row">
<input type="text" name="first_name" placeholder="First Name" required>
<input type="text" name="last_name" placeholder="Last Name" required>
</div>
<div class="hvac-form-row">
<input type="email" name="email" placeholder="Email" required>
<input type="tel" name="phone" placeholder="Phone Number">
</div>
<div class="hvac-form-row">
<input type="text" name="city" placeholder="City">
<input type="text" name="state_province" placeholder="State/Province">
</div>
<div class="hvac-form-field">
<input type="text" name="company" placeholder="Company">
</div>
<div class="hvac-form-field">
<textarea name="message" placeholder="Message" rows="3"></textarea>
</div>
<div class="hvac-form-field hvac-recaptcha-wrapper">
<?php if (class_exists('HVAC_Recaptcha')): ?>
<?php HVAC_Recaptcha::instance()->echo_widget('trainer-contact'); ?>
<?php endif; ?>
</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;
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,691 @@
<?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 or plain option
* Uses dedicated geocoding key if available, falls back to maps key
*/
private function load_api_key(): void {
// First try dedicated geocoding API key (IP-restricted for server-side use)
// Check plain option first (simpler setup)
$this->api_key = get_option('hvac_google_geocoding_api_key', '');
// Try secure storage if plain option not set
if (empty($this->api_key) && class_exists('HVAC_Secure_Storage')) {
$this->api_key = HVAC_Secure_Storage::get_credential('hvac_google_geocoding_api_key', '');
}
// Fall back to maps API key if geocoding key not set
if (empty($this->api_key)) {
if (class_exists('HVAC_Secure_Storage')) {
$this->api_key = HVAC_Secure_Storage::get_credential('hvac_google_maps_api_key', '');
}
if (empty($this->api_key)) {
$this->api_key = get_option('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']);
// Admin action for marking venues as approved labs (legacy)
add_action('wp_ajax_hvac_mark_venues_approved', [$this, 'ajax_mark_venues_approved']);
// Admin action for updating approved labs list
add_action('wp_ajax_hvac_update_approved_labs', [$this, 'ajax_update_approved_labs']);
// 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);
}
/**
* AJAX handler for marking geocoded venues as approved training labs
*/
public function ajax_mark_venues_approved(): 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_mark_venues_approved')) {
wp_send_json_error(['message' => 'Invalid security token']);
return;
}
$result = $this->mark_geocoded_venues_as_approved();
wp_send_json_success($result);
}
/**
* Mark all geocoded venues as approved training labs
*
* @return array Results with count of venues marked
*/
public function mark_geocoded_venues_as_approved(): array {
// Get all venues that have coordinates but are NOT already approved labs
$venues = get_posts([
'post_type' => 'tribe_venue',
'posts_per_page' => -1,
'post_status' => 'publish',
'meta_query' => [
'relation' => 'OR',
[
'key' => 'venue_latitude',
'compare' => 'EXISTS'
],
[
'key' => '_VenueLat',
'compare' => 'EXISTS'
]
],
'tax_query' => [
[
'taxonomy' => 'venue_type',
'field' => 'slug',
'terms' => 'mq-approved-lab',
'operator' => 'NOT IN'
]
]
]);
$results = [
'marked' => 0,
'failed' => 0,
'total_approved' => 0
];
// Load venue categories class
if (!class_exists('HVAC_Venue_Categories')) {
require_once HVAC_PLUGIN_DIR . 'includes/class-hvac-venue-categories.php';
}
$venue_categories = HVAC_Venue_Categories::instance();
foreach ($venues as $venue) {
$result = $venue_categories->set_as_approved_lab($venue->ID);
if (is_wp_error($result)) {
$results['failed']++;
} else {
$results['marked']++;
}
}
// Count total approved labs
$approved_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' => 'mq-approved-lab',
]
]
]);
$results['total_approved'] = $approved_query->found_posts;
return $results;
}
/**
* AJAX handler for updating approved labs list with specific venue IDs
*/
public function ajax_update_approved_labs(): 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_mark_venues_approved')) {
wp_send_json_error(['message' => 'Invalid security token']);
return;
}
// Get venue IDs from request (these are the ones that should be approved)
$selected_ids = isset($_POST['venue_ids']) ? array_map('absint', (array)$_POST['venue_ids']) : [];
// Load venue categories class
if (!class_exists('HVAC_Venue_Categories')) {
require_once HVAC_PLUGIN_DIR . 'includes/class-hvac-venue-categories.php';
}
$venue_categories = HVAC_Venue_Categories::instance();
// Get all venues
$all_venues = get_posts([
'post_type' => 'tribe_venue',
'posts_per_page' => -1,
'post_status' => 'publish',
'fields' => 'ids'
]);
$added = 0;
$removed = 0;
foreach ($all_venues as $venue_id) {
$is_currently_approved = has_term('mq-approved-lab', 'venue_type', $venue_id);
$should_be_approved = in_array($venue_id, $selected_ids);
if ($should_be_approved && !$is_currently_approved) {
// Add the term
wp_set_post_terms($venue_id, ['mq-approved-lab'], 'venue_type', false);
$added++;
} elseif (!$should_be_approved && $is_currently_approved) {
// Remove the term
wp_remove_object_terms($venue_id, 'mq-approved-lab', 'venue_type');
$removed++;
}
}
wp_send_json_success([
'approved_count' => count($selected_ids),
'added' => $added,
'removed' => $removed
]);
}
/**
* Batch geocode venues without coordinates
*
* @param int $limit Maximum venues to process
* @return array Results
*/
public function batch_geocode_venues(int $limit = 10): array {
$venues = get_posts([
'post_type' => 'tribe_venue',
'posts_per_page' => $limit,
'post_status' => 'publish',
'meta_query' => [
'relation' => 'AND',
[
'key' => 'venue_latitude',
'compare' => 'NOT EXISTS'
],
[
'key' => '_VenueLat',
'compare' => 'NOT EXISTS'
]
]
]);
$results = [
'processed' => 0,
'success' => 0,
'failed' => 0,
'remaining' => 0
];
foreach ($venues as $venue) {
if (!$this->check_rate_limit()) {
break;
}
$results['processed']++;
if ($this->geocode_venue($venue->ID)) {
$results['success']++;
} else {
$results['failed']++;
}
}
// Count remaining
$remaining_query = new WP_Query([
'post_type' => 'tribe_venue',
'posts_per_page' => 1,
'post_status' => 'publish',
'fields' => 'ids',
'meta_query' => [
'relation' => 'AND',
[
'key' => 'venue_latitude',
'compare' => 'NOT EXISTS'
],
[
'key' => '_VenueLat',
'compare' => 'NOT EXISTS'
]
]
]);
$results['remaining'] = $remaining_query->found_posts;
return $results;
}
/**
* Get geocoding status for a venue
*
* @param int $venue_id Venue post ID
* @return array Status info
*/
public function get_geocoding_status(int $venue_id): array {
return [
'has_coordinates' => $this->venue_has_coordinates($venue_id),
'latitude' => get_post_meta($venue_id, 'venue_latitude', true) ?: get_post_meta($venue_id, '_VenueLat', true),
'longitude' => get_post_meta($venue_id, 'venue_longitude', true) ?: get_post_meta($venue_id, '_VenueLng', true),
'status' => get_post_meta($venue_id, '_venue_geocoding_status', true),
'error' => get_post_meta($venue_id, '_venue_geocoding_error', true),
'last_attempt' => get_post_meta($venue_id, '_venue_geocoding_attempt', true),
'geocoded_date' => get_post_meta($venue_id, '_venue_geocoding_date', true)
];
}
/**
* Clear coordinates for a venue
*
* @param int $venue_id Venue post ID
*/
public function clear_coordinates(int $venue_id): void {
delete_post_meta($venue_id, 'venue_latitude');
delete_post_meta($venue_id, 'venue_longitude');
delete_post_meta($venue_id, '_VenueLat');
delete_post_meta($venue_id, '_VenueLng');
delete_post_meta($venue_id, '_venue_formatted_address');
delete_post_meta($venue_id, '_venue_geocoding_status');
delete_post_meta($venue_id, '_venue_geocoding_error');
delete_post_meta($venue_id, '_venue_geocoding_date');
}
}

View file

@ -1,211 +0,0 @@
<?php
/**
* Zoho CRM Admin Interface
*
* Provides WordPress admin interface for Zoho credential management
*/
if (!defined('ABSPATH')) {
exit;
}
class HVAC_Zoho_Admin {
public function __construct() {
add_action('admin_menu', array($this, 'add_admin_menu'));
add_action('admin_init', array($this, 'handle_auth_callback'));
}
/**
* Add menu item to WordPress admin
*/
public function add_admin_menu() {
add_submenu_page(
'edit.php?post_type=tribe_events',
'Zoho CRM Integration',
'Zoho CRM',
'manage_options',
'hvac-zoho-crm',
array($this, 'admin_page')
);
}
/**
* Handle OAuth callback
*/
public function handle_auth_callback() {
if (isset($_GET['page']) && $_GET['page'] === 'hvac-zoho-crm' && isset($_GET['code'])) {
$auth = new HVAC_Zoho_CRM_Auth();
if ($auth->exchange_code_for_tokens($_GET['code'])) {
add_settings_error(
'hvac_zoho_messages',
'hvac_zoho_auth_success',
'Successfully connected to Zoho CRM!',
'success'
);
} else {
add_settings_error(
'hvac_zoho_messages',
'hvac_zoho_auth_error',
'Failed to connect to Zoho CRM. Please check your credentials.',
'error'
);
}
// Redirect to remove code from URL
wp_redirect(admin_url('edit.php?post_type=tribe_events&page=hvac-zoho-crm'));
exit;
}
}
/**
* Display admin page
*/
public function admin_page() {
?>
<div class="wrap">
<h1>Zoho CRM Integration</h1>
<?php settings_errors('hvac_zoho_messages'); ?>
<?php
// Check if config file exists
$config_file = plugin_dir_path(dirname(__FILE__)) . 'zoho/zoho-config.php';
$config_exists = file_exists($config_file);
if (!$config_exists):
?>
<div class="notice notice-warning">
<p>Zoho CRM configuration file not found. Please follow the setup instructions below.</p>
</div>
<h2>Setup Instructions</h2>
<ol>
<li>
<strong>Register your application in Zoho:</strong>
<a href="https://api-console.zoho.com/" target="_blank">Go to Zoho API Console</a>
</li>
<li>Create a new Server-based Application</li>
<li>Set redirect URI to: <code><?php echo admin_url('edit.php?post_type=tribe_events&page=hvac-zoho-crm'); ?></code></li>
<li>Copy your Client ID and Client Secret</li>
<li>Run the setup helper script from command line:
<pre>cd <?php echo plugin_dir_path(dirname(__FILE__)); ?>zoho
php setup-helper.php</pre>
</li>
</ol>
<?php else: ?>
<?php
// Load configuration
require_once $config_file;
$auth = new HVAC_Zoho_CRM_Auth();
// Test connection
$org_info = $auth->make_api_request('/crm/v2/org');
$connected = !is_wp_error($org_info) && isset($org_info['org']);
?>
<?php if ($connected): ?>
<div class="notice notice-success">
<p> Connected to Zoho CRM</p>
</div>
<h2>Organization Information</h2>
<table class="form-table">
<tr>
<th>Organization Name</th>
<td><?php echo esc_html($org_info['org'][0]['company_name']); ?></td>
</tr>
<tr>
<th>Organization ID</th>
<td><?php echo esc_html($org_info['org'][0]['id']); ?></td>
</tr>
<tr>
<th>Time Zone</th>
<td><?php echo esc_html($org_info['org'][0]['time_zone']); ?></td>
</tr>
</table>
<h2>Integration Status</h2>
<?php $this->display_integration_status(); ?>
<h2>Actions</h2>
<p>
<a href="<?php echo wp_nonce_url(admin_url('edit.php?post_type=tribe_events&page=hvac-zoho-crm&action=test_sync'), 'test_sync'); ?>"
class="button button-primary">Test Sync</a>
<a href="<?php echo wp_nonce_url(admin_url('edit.php?post_type=tribe_events&page=hvac-zoho-crm&action=create_fields'), 'create_fields'); ?>"
class="button">Create Custom Fields</a>
</p>
<?php else: ?>
<div class="notice notice-error">
<p> Not connected to Zoho CRM</p>
</div>
<h2>Reconnect to Zoho</h2>
<p>Click the button below to authorize this application with Zoho CRM:</p>
<p>
<a href="<?php echo esc_url($auth->get_authorization_url()); ?>"
class="button button-primary">Connect to Zoho CRM</a>
</p>
<?php endif; ?>
<?php endif; ?>
</div>
<?php
}
/**
* Display integration status
*/
private function display_integration_status() {
?>
<table class="widefat striped">
<thead>
<tr>
<th>Module</th>
<th>Fields Configured</th>
<th>Last Sync</th>
<th>Status</th>
</tr>
</thead>
<tbody>
<tr>
<td>Campaigns (Events)</td>
<td><?php echo $this->check_custom_fields('Campaigns'); ?></td>
<td><?php echo get_option('hvac_zoho_last_campaign_sync', 'Never'); ?></td>
<td><span class="dashicons dashicons-yes-alt" style="color: green;"></span></td>
</tr>
<tr>
<td>Contacts (Users)</td>
<td><?php echo $this->check_custom_fields('Contacts'); ?></td>
<td><?php echo get_option('hvac_zoho_last_contact_sync', 'Never'); ?></td>
<td><span class="dashicons dashicons-yes-alt" style="color: green;"></span></td>
</tr>
<tr>
<td>Invoices (Orders)</td>
<td><?php echo $this->check_custom_fields('Invoices'); ?></td>
<td><?php echo get_option('hvac_zoho_last_invoice_sync', 'Never'); ?></td>
<td><span class="dashicons dashicons-yes-alt" style="color: green;"></span></td>
</tr>
</tbody>
</table>
<?php
}
/**
* Check if custom fields exist
*/
private function check_custom_fields($module) {
// This would actually check via API if the custom fields exist
// For now, return a placeholder
return '<span style="color: orange;">Pending</span>';
}
}
// Initialize admin interface
if (is_admin()) {
new HVAC_Zoho_Admin();
}

View file

@ -23,10 +23,15 @@ class HVAC_Zoho_CRM_Auth {
private $last_error = null;
public function __construct() {
// Load credentials from WordPress options (new approach)
$this->client_id = get_option('hvac_zoho_client_id', '');
$this->client_secret = get_option('hvac_zoho_client_secret', '');
$this->refresh_token = get_option('hvac_zoho_refresh_token', '');
// Load secure storage class
if (!class_exists('HVAC_Secure_Storage')) {
require_once plugin_dir_path(dirname(__FILE__)) . 'class-hvac-secure-storage.php';
}
// Load credentials from WordPress options using secure storage (encrypted)
$this->client_id = HVAC_Secure_Storage::get_credential('hvac_zoho_client_id', '');
$this->client_secret = HVAC_Secure_Storage::get_credential('hvac_zoho_client_secret', '');
$this->refresh_token = HVAC_Secure_Storage::get_credential('hvac_zoho_refresh_token', '');
$this->redirect_uri = get_site_url() . '/oauth/callback';
// Fallback to config file if options are empty (backward compatibility)
@ -48,20 +53,59 @@ class HVAC_Zoho_CRM_Auth {
/**
* Generate authorization URL for initial setup
*
* @return string Authorization URL with CSRF state parameter
*/
public function get_authorization_url() {
// Generate secure state parameter for CSRF protection
$state = $this->generate_oauth_state();
$params = array(
'scope' => 'ZohoCRM.settings.ALL,ZohoCRM.modules.ALL,ZohoCRM.users.ALL,ZohoCRM.org.ALL,ZohoCRM.bulk.READ',
'client_id' => $this->client_id,
'response_type' => 'code',
'access_type' => 'offline',
'redirect_uri' => $this->redirect_uri,
'prompt' => 'consent'
'prompt' => 'consent',
'state' => $state
);
return 'https://accounts.zoho.com/oauth/v2/auth?' . http_build_query($params);
}
/**
* Generate and store OAuth state parameter for CSRF protection
*
* @return string Generated state token
*/
public function generate_oauth_state() {
$state = wp_generate_password(32, false);
set_transient('hvac_zoho_oauth_state', $state, 600); // 10 minute expiry
return $state;
}
/**
* Validate OAuth state parameter
*
* @param string $state State parameter from callback
* @return bool True if state is valid
*/
public function validate_oauth_state($state) {
$stored_state = get_transient('hvac_zoho_oauth_state');
if (empty($stored_state) || empty($state)) {
return false;
}
// Use timing-safe comparison
$valid = hash_equals($stored_state, $state);
// Delete the state after validation (one-time use)
delete_transient('hvac_zoho_oauth_state');
return $valid;
}
/**
* Exchange authorization code for tokens
*/
@ -159,17 +203,86 @@ class HVAC_Zoho_CRM_Auth {
return false;
}
/**
* Make authenticated API request
*/
/**
* Check if we are in staging mode
*
* @return bool True if in staging mode
*/
public static function is_staging_mode() {
// 1. Allow forcing production mode via constant
if (defined('HVAC_ZOHO_PRODUCTION_MODE') && HVAC_ZOHO_PRODUCTION_MODE) {
return false;
}
// 2. Allow forcing staging mode via constant
if (defined('HVAC_ZOHO_STAGING_MODE') && HVAC_ZOHO_STAGING_MODE) {
return true;
}
// 3. Parse hostname from site URL for accurate comparison
$site_url = get_site_url();
$host = wp_parse_url($site_url, PHP_URL_HOST);
if (empty($host)) {
return true; // Can't determine host, default to staging for safety
}
// 4. Production: upskillhvac.com or www.upskillhvac.com
if ($host === 'upskillhvac.com' || $host === 'www.upskillhvac.com') {
return false;
}
// 5. Everything else is staging (including staging subdomains, cloudwaysapps, localhost, etc.)
return true;
}
/**
* Get details about how the mode was determined (for debugging)
*
* @return array Debug information
*/
public static function get_debug_mode_info() {
$site_url = get_site_url();
$host = wp_parse_url($site_url, PHP_URL_HOST);
$info = array(
'site_url' => $site_url,
'parsed_host' => $host,
'is_staging' => self::is_staging_mode(),
'forced_production' => defined('HVAC_ZOHO_PRODUCTION_MODE') && HVAC_ZOHO_PRODUCTION_MODE,
'forced_staging' => defined('HVAC_ZOHO_STAGING_MODE') && HVAC_ZOHO_STAGING_MODE,
'detection_logic' => array()
);
// Replicate logic to show which rule matched
if ($info['forced_production']) {
$info['detection_logic'][] = 'Forced PRODUCTION via HVAC_ZOHO_PRODUCTION_MODE constant';
} elseif ($info['forced_staging']) {
$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 {
$info['detection_logic'][] = 'STAGING: Hostname "' . $host . '" is not upskillhvac.com';
}
return $info;
}
/**
* Make authenticated API request
*/
public function make_api_request($endpoint, $method = 'GET', $data = null) {
// Check if we're in staging mode
$site_url = get_site_url();
$is_staging = strpos($site_url, 'upskillhvac.com') === false;
$is_staging = self::is_staging_mode();
// In staging mode, only allow read operations, no writes
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(
'data' => array(
array(
@ -177,8 +290,8 @@ class HVAC_Zoho_CRM_Auth {
'details' => array(
'message' => 'Staging mode active. Write operations are disabled.'
),
'message' => 'This would have been a ' . $method . ' request to: ' . $endpoint,
'status' => 'success'
'message' => 'Blocked ' . $method . ' request to: ' . $endpoint,
'status' => 'skipped_staging'
)
)
);
@ -214,7 +327,8 @@ class HVAC_Zoho_CRM_Auth {
return new WP_Error('no_token', $error_message);
}
$url = 'https://www.zohoapis.com/crm/v2' . $endpoint;
// Update to v6 API (v2 is deprecated)
$url = 'https://www.zohoapis.com/crm/v6' . $endpoint;
// Log the request details
$this->log_debug('Making ' . $method . ' request to: ' . $url);
@ -283,7 +397,8 @@ class HVAC_Zoho_CRM_Auth {
return array(
'error' => $error_message,
'code' => 'JSON_PARSE_ERROR',
'details' => 'Raw response: ' . substr($body, 0, 255) . (strlen($body) > 255 ? '...' : '')
'details' => 'Raw response: ' . substr($body, 0, 500),
'request_payload' => isset($args['body']) ? $args['body'] : 'No body'
);
}
@ -300,6 +415,7 @@ class HVAC_Zoho_CRM_Auth {
// Add HTTP error information to the response
$data['http_status'] = $status_code;
$data['error'] = $error_message;
$data['request_payload'] = isset($args['body']) ? $args['body'] : 'No body';
// Extract more detailed error information if available
if (isset($data['code'])) {
@ -315,31 +431,31 @@ class HVAC_Zoho_CRM_Auth {
}
/**
* Save tokens to WordPress options
* Save tokens to WordPress options using secure storage
*/
private function save_tokens() {
update_option('hvac_zoho_refresh_token', $this->refresh_token);
HVAC_Secure_Storage::store_credential('hvac_zoho_refresh_token', $this->refresh_token);
$this->save_access_token();
}
/**
* Save access token
* Save access token using secure storage
*/
private function save_access_token() {
update_option('hvac_zoho_access_token', $this->access_token);
HVAC_Secure_Storage::store_credential('hvac_zoho_access_token', $this->access_token);
update_option('hvac_zoho_token_expiry', $this->token_expiry);
}
/**
* Load access token from WordPress options
* Load access token from WordPress options using secure storage
*/
private function load_access_token() {
$this->access_token = get_option('hvac_zoho_access_token');
$this->access_token = HVAC_Secure_Storage::get_credential('hvac_zoho_access_token', '');
$this->token_expiry = get_option('hvac_zoho_token_expiry', 0);
// Load refresh token if not set
if (!$this->refresh_token) {
$this->refresh_token = get_option('hvac_zoho_refresh_token');
$this->refresh_token = HVAC_Secure_Storage::get_credential('hvac_zoho_refresh_token', '');
}
}
@ -362,17 +478,47 @@ class HVAC_Zoho_CRM_Auth {
/**
* Log debug messages
*/
private function log_debug($message) {
public function log_debug($message) {
// Sanitize message to remove sensitive data
$sanitized = $this->sanitize_log_message($message);
if (defined('ZOHO_DEBUG_MODE') && ZOHO_DEBUG_MODE && defined('ZOHO_LOG_FILE')) {
error_log('[' . date('Y-m-d H:i:s') . '] DEBUG: ' . $message . PHP_EOL, 3, ZOHO_LOG_FILE);
error_log('[' . date('Y-m-d H:i:s') . '] DEBUG: ' . $sanitized . PHP_EOL, 3, ZOHO_LOG_FILE);
}
// Also log to WordPress debug log if available
if (defined('ZOHO_DEBUG_MODE') && ZOHO_DEBUG_MODE && defined('WP_DEBUG') && WP_DEBUG && defined('WP_DEBUG_LOG') && WP_DEBUG_LOG) {
error_log('[ZOHO CRM DEBUG] ' . $message);
error_log('[ZOHO CRM DEBUG] ' . $sanitized);
}
}
/**
* Sanitize log messages to mask sensitive credentials
*
* @param string $message Log message
* @return string Sanitized message
*/
private function sanitize_log_message($message) {
// Mask client_id, client_secret, access_token, refresh_token patterns
$patterns = array(
'/(client[_-]?(id|secret)[\s:=]+)([a-zA-Z0-9._-]{10,})/i',
'/(access[_-]?token[\s:=]+)([a-zA-Z0-9._-]{10,})/i',
'/(refresh[_-]?token[\s:=]+)([a-zA-Z0-9._-]{10,})/i',
'/(authorization[\s:]+)(Zoho-oauthtoken\s+[a-zA-Z0-9._-]+)/i',
'/("(client_id|client_secret|access_token|refresh_token)"[\s:]+")[^"]+(")/i',
);
$replacements = array(
'$1***MASKED***',
'$1***MASKED***',
'$1***MASKED***',
'$1Zoho-oauthtoken ***MASKED***',
'$1***MASKED***$3',
);
return preg_replace($patterns, $replacements, $message);
}
/**
* Get the last error message
*

View file

@ -0,0 +1,396 @@
<?php
/**
* Zoho CRM Scheduled Sync Handler
*
* Manages WP-Cron based scheduled sync of WordPress data to Zoho CRM.
*
* @package HVACCommunityEvents
*/
if (!defined('ABSPATH')) {
exit;
}
/**
* Scheduled Sync Class
*/
class HVAC_Zoho_Scheduled_Sync {
/**
* Instance of this class
*
* @var HVAC_Zoho_Scheduled_Sync
*/
private static $instance = null;
/**
* Cron hook name
*/
const CRON_HOOK = 'hvac_zoho_scheduled_sync';
/**
* Option names
*/
const OPTION_ENABLED = 'hvac_zoho_auto_sync';
const OPTION_INTERVAL = 'hvac_zoho_sync_frequency';
const OPTION_LAST_SYNC = 'hvac_zoho_last_sync_time';
const OPTION_LAST_RESULT = 'hvac_zoho_last_sync_result';
/**
* Get instance of this class
*
* @return HVAC_Zoho_Scheduled_Sync
*/
public static function instance() {
if (null === self::$instance) {
self::$instance = new self();
}
return self::$instance;
}
/**
* Constructor
*/
private function __construct() {
// Register custom cron schedules
add_filter('cron_schedules', array($this, 'register_cron_schedules'));
// Register the cron action
add_action(self::CRON_HOOK, array($this, 'run_scheduled_sync'));
// Check if we need to reschedule on settings change
// Hook into both add_option (first time) and update_option (subsequent changes)
add_action('add_option_' . self::OPTION_ENABLED, array($this, 'on_setting_added'), 10, 2);
add_action('update_option_' . self::OPTION_ENABLED, array($this, 'on_setting_change'), 10, 2);
add_action('update_option_' . self::OPTION_INTERVAL, array($this, 'on_interval_change'), 10, 2);
}
/**
* Register custom cron schedules
*
* @param array $schedules Existing schedules
* @return array Modified schedules
*/
public function register_cron_schedules($schedules) {
$schedules['every_5_minutes'] = array(
'interval' => 5 * MINUTE_IN_SECONDS,
'display' => __('Every 5 Minutes', 'hvac-community-events')
);
$schedules['every_15_minutes'] = array(
'interval' => 15 * MINUTE_IN_SECONDS,
'display' => __('Every 15 Minutes', 'hvac-community-events')
);
$schedules['every_30_minutes'] = array(
'interval' => 30 * MINUTE_IN_SECONDS,
'display' => __('Every 30 Minutes', 'hvac-community-events')
);
$schedules['every_6_hours'] = array(
'interval' => 6 * HOUR_IN_SECONDS,
'display' => __('Every 6 Hours', 'hvac-community-events')
);
return $schedules;
}
/**
* Schedule the sync cron event
*
* @param string|null $interval Optional interval override
* @return bool True if scheduled successfully
*/
public function schedule_sync($interval = null) {
// Unschedule any existing event first
$this->unschedule_sync();
// Get interval from option if not provided
if (null === $interval) {
$interval = get_option(self::OPTION_INTERVAL, 'every_5_minutes');
}
// Schedule the event
$result = wp_schedule_event(time(), $interval, self::CRON_HOOK);
if ($result !== false) {
if (class_exists('HVAC_Logger')) {
HVAC_Logger::info("Scheduled Zoho sync with interval: {$interval}", 'ZohoScheduledSync');
}
return true;
}
return false;
}
/**
* Unschedule the sync cron event
*
* @return bool True if unscheduled successfully
*/
public function unschedule_sync() {
$timestamp = wp_next_scheduled(self::CRON_HOOK);
if ($timestamp) {
wp_unschedule_event($timestamp, self::CRON_HOOK);
if (class_exists('HVAC_Logger')) {
HVAC_Logger::info('Unscheduled Zoho sync', 'ZohoScheduledSync');
}
return true;
}
return false;
}
/**
* Check if sync is currently scheduled
*
* @return bool
*/
public function is_scheduled() {
return wp_next_scheduled(self::CRON_HOOK) !== false;
}
/**
* Get next scheduled sync time
*
* @return int|false Timestamp or false if not scheduled
*/
public function get_next_scheduled() {
return wp_next_scheduled(self::CRON_HOOK);
}
/**
* Handle first-time option creation
*
* @param string $option Option name
* @param mixed $value Option value
*/
public function on_setting_added($option, $value) {
if ($value === '1' || $value === 1 || $value === true) {
$this->schedule_sync();
}
}
/**
* Handle setting enabled/disabled change
*
* @param mixed $old_value Old option value
* @param mixed $new_value New option value
*/
public function on_setting_change($old_value, $new_value) {
if ($new_value === '1' || $new_value === 1 || $new_value === true) {
$this->schedule_sync();
} else {
$this->unschedule_sync();
}
}
/**
* Handle interval change
*
* @param mixed $old_value Old interval value
* @param mixed $new_value New interval value
*/
public function on_interval_change($old_value, $new_value) {
// Only reschedule if currently enabled
if (get_option(self::OPTION_ENABLED) === '1') {
$this->schedule_sync($new_value);
}
}
/**
* Run the scheduled sync
*
* This is the main cron callback that syncs all data types.
*/
public function run_scheduled_sync() {
$start_time = time();
if (class_exists('HVAC_Logger')) {
HVAC_Logger::info('Starting scheduled Zoho sync', 'ZohoScheduledSync');
}
// Get last sync time for incremental sync
$last_sync = $this->get_last_sync_time();
// Initialize results
$results = array(
'started_at' => date('Y-m-d H:i:s', $start_time),
'last_sync_from' => $last_sync ? date('Y-m-d H:i:s', $last_sync) : 'Never',
'events' => null,
'users' => null,
'attendees' => null,
'rsvps' => null,
'purchases' => null,
'errors' => array(),
);
try {
// Load dependencies
require_once HVAC_PLUGIN_DIR . 'includes/zoho/class-zoho-sync.php';
$sync = new HVAC_Zoho_Sync();
// Sync each type with since_timestamp for incremental sync
// Events
$results['events'] = $this->sync_all_batches($sync, 'sync_events', $last_sync);
// Users/Trainers
$results['users'] = $this->sync_all_batches($sync, 'sync_users', $last_sync);
// Attendees
$results['attendees'] = $this->sync_all_batches($sync, 'sync_attendees', $last_sync);
// RSVPs
$results['rsvps'] = $this->sync_all_batches($sync, 'sync_rsvps', $last_sync);
// Purchases/Orders
$results['purchases'] = $this->sync_all_batches($sync, 'sync_purchases', $last_sync);
} catch (Exception $e) {
$results['errors'][] = $e->getMessage();
if (class_exists('HVAC_Logger')) {
HVAC_Logger::error('Scheduled sync error: ' . $e->getMessage(), 'ZohoScheduledSync');
}
}
// Update last sync time
$this->set_last_sync_time($start_time);
// Calculate totals
$total_synced = 0;
$total_skipped = 0;
$total_failed = 0;
foreach (array('events', 'users', 'attendees', 'rsvps', 'purchases') as $type) {
if (isset($results[$type]['synced'])) {
$total_synced += $results[$type]['synced'];
}
if (isset($results[$type]['skipped'])) {
$total_skipped += $results[$type]['skipped'];
}
if (isset($results[$type]['failed'])) {
$total_failed += $results[$type]['failed'];
}
}
$results['completed_at'] = date('Y-m-d H:i:s');
$results['duration_seconds'] = time() - $start_time;
$results['total_synced'] = $total_synced;
$results['total_skipped'] = $total_skipped;
$results['total_failed'] = $total_failed;
// Save last result
update_option(self::OPTION_LAST_RESULT, $results);
if (class_exists('HVAC_Logger')) {
HVAC_Logger::info("Scheduled sync completed: {$total_synced} synced, {$total_skipped} skipped, {$total_failed} failed", 'ZohoScheduledSync');
}
return $results;
}
/**
* Sync all batches for a given sync method
*
* @param HVAC_Zoho_Sync $sync Sync instance
* @param string $method Method name (e.g., 'sync_events')
* @param int|null $since_timestamp Optional timestamp for incremental sync
* @return array Aggregated results
*/
private function sync_all_batches($sync, $method, $since_timestamp = null) {
$offset = 0;
$limit = 50;
$aggregated = array(
'total' => 0,
'synced' => 0,
'skipped' => 0,
'failed' => 0,
'errors' => array(),
);
do {
// Call the sync method with since_timestamp support
$result = $sync->$method($offset, $limit, $since_timestamp);
// Aggregate results
$aggregated['total'] = $result['total'] ?? 0;
$aggregated['synced'] += $result['synced'] ?? 0;
$aggregated['skipped'] += $result['skipped'] ?? 0;
$aggregated['failed'] += $result['failed'] ?? 0;
if (!empty($result['errors'])) {
$aggregated['errors'] = array_merge($aggregated['errors'], $result['errors']);
}
// Check for more batches
$has_more = $result['has_more'] ?? false;
$offset = $result['next_offset'] ?? ($offset + $limit);
} while ($has_more);
return $aggregated;
}
/**
* Get the last sync timestamp
*
* @return int|null Timestamp or null if never synced
*/
public function get_last_sync_time() {
$time = get_option(self::OPTION_LAST_SYNC);
return $time ? (int) $time : null;
}
/**
* Set the last sync timestamp
*
* @param int $timestamp Timestamp
*/
public function set_last_sync_time($timestamp) {
update_option(self::OPTION_LAST_SYNC, $timestamp);
}
/**
* Get the last sync result
*
* @return array|null Result array or null
*/
public function get_last_sync_result() {
return get_option(self::OPTION_LAST_RESULT);
}
/**
* Get sync status summary
*
* @return array Status information
*/
public function get_status() {
$is_enabled = get_option(self::OPTION_ENABLED) === '1';
$interval = get_option(self::OPTION_INTERVAL, 'every_5_minutes');
$last_sync = $this->get_last_sync_time();
$next_sync = $this->get_next_scheduled();
$last_result = $this->get_last_sync_result();
return array(
'enabled' => $is_enabled,
'interval' => $interval,
'is_scheduled' => $this->is_scheduled(),
'last_sync_time' => $last_sync,
'last_sync_formatted' => $last_sync ? date('Y-m-d H:i:s', $last_sync) : 'Never',
'next_sync_time' => $next_sync,
'next_sync_formatted' => $next_sync ? date('Y-m-d H:i:s', $next_sync) : 'Not scheduled',
'last_result' => $last_result,
);
}
/**
* Run sync manually (for "Run Now" button)
*
* @return array Sync results
*/
public function run_now() {
return $this->run_scheduled_sync();
}
}

File diff suppressed because it is too large Load diff

View file

@ -19,6 +19,12 @@ echo -e "\n=== Checking debug output ==="
ssh -o StrictHostKeyChecking=no "$UPSKILL_STAGING_SSH_USER@$UPSKILL_STAGING_IP" << 'ENDSSH'
cd /home/974670.cloudwaysapps.com/uberrxmprk/public_html
# Save original WP_DEBUG settings before modifying
ORIG_WP_DEBUG=$(wp config get WP_DEBUG --raw 2>/dev/null || echo "false")
ORIG_WP_DEBUG_LOG=$(wp config get WP_DEBUG_LOG --raw 2>/dev/null || echo "false")
echo "Saving original settings: WP_DEBUG=$ORIG_WP_DEBUG, WP_DEBUG_LOG=$ORIG_WP_DEBUG_LOG"
# Enable debug logging temporarily
wp config set WP_DEBUG true --raw
wp config set WP_DEBUG_LOG true --raw
@ -67,6 +73,12 @@ if (isset($wp_filter["igm_add_meta"])) {
}
'
# Restore original WP_DEBUG settings
echo -e "\n=== Restoring original WP_DEBUG settings ==="
wp config set WP_DEBUG "$ORIG_WP_DEBUG" --raw
wp config set WP_DEBUG_LOG "$ORIG_WP_DEBUG_LOG" --raw
echo "Restored: WP_DEBUG=$ORIG_WP_DEBUG, WP_DEBUG_LOG=$ORIG_WP_DEBUG_LOG"
ENDSSH
echo -e "\n=== Debug Complete ==="

View file

@ -90,7 +90,7 @@ if ( ! defined( 'ABSPATH' ) ) {
<div class="hvac-login-links">
<?php if ( get_option( 'users_can_register' ) ) : ?>
<a class="hvac-register-link" href="<?php echo esc_url( wp_registration_url() ); ?>">
<a class="hvac-register-link" href="<?php echo esc_url( home_url( '/trainer/registration/' ) ); ?>">
<?php esc_html_e( 'Register', 'hvac-community-events' ); ?>
</a> |
<?php endif; ?>

View file

@ -113,7 +113,7 @@ get_header();
<?php endif; ?>
<form method="post" action="" class="hvac-event-form" novalidate>
<?php wp_nonce_field('hvac_edit_event', 'hvac_event_nonce'); ?>
<?php wp_nonce_field('hvac_event_action', 'hvac_event_nonce'); ?>
<input type="hidden" name="event_id" value="<?php echo esc_attr($event_id); ?>">
<!-- Basic Information -->

View file

@ -0,0 +1,413 @@
<?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-event-grid" class="hvac-skip-link">Skip to event results</a>
<!-- Compact Filter Bar -->
<div class="hvac-filter-bar" role="search" aria-label="Filter events, 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 events..." aria-label="Search events">
</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="events" aria-selected="true" aria-controls="hvac-panel-events">
<label class="hvac-marker-toggle hvac-marker-toggle-event" title="Show events on map" onclick="event.stopPropagation()">
<input type="checkbox" id="hvac-show-events" checked>
<span class="hvac-marker-checkbox"></span>
</label>
Events (<span data-count="events">0</span>)
</button>
<button role="tab" class="hvac-tab" data-tab="trainers" aria-selected="false" aria-controls="hvac-panel-trainers">
<label class="hvac-marker-toggle hvac-marker-toggle-trainer" title="Show trainers on map" onclick="event.stopPropagation()">
<input type="checkbox" id="hvac-show-trainers" checked>
<span class="hvac-marker-checkbox"></span>
</label>
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">
<label class="hvac-marker-toggle hvac-marker-toggle-venue" title="Show venues on map" onclick="event.stopPropagation()">
<input type="checkbox" id="hvac-show-venues" checked>
<span class="hvac-marker-checkbox"></span>
</label>
Venues (<span data-count="venues">0</span>)
</button>
</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">
<!-- Events Panel -->
<div role="tabpanel" id="hvac-panel-events" class="hvac-tab-panel active" aria-labelledby="tab-events">
<div id="hvac-event-grid" class="hvac-item-list">
<div class="hvac-grid-loading">
<span class="dashicons dashicons-update-alt hvac-spin" aria-hidden="true"></span>
Loading events...
</div>
</div>
</div>
<!-- Trainers Panel -->
<div role="tabpanel" id="hvac-panel-trainers" class="hvac-tab-panel" aria-labelledby="tab-trainers" hidden>
<div id="hvac-trainer-grid" class="hvac-item-list"></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>
<!-- 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>
<!-- Reset Map View Button -->
<button type="button" id="hvac-reset-map" class="hvac-reset-map-btn" aria-label="Reset map view" title="Reset map view">
<span class="dashicons dashicons-image-rotate" aria-hidden="true"></span>
</button>
<!-- 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">&times;</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>
<div class="hvac-form-group hvac-recaptcha-wrapper">
<div class="g-recaptcha" data-sitekey="<?php echo esc_attr(class_exists('HVAC_Recaptcha') ? HVAC_Recaptcha::SITE_KEY : ''); ?>"></div>
</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">&times;</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(); ?>

View file

@ -1,7 +1,7 @@
<?php
/**
* Template Name: Master Trainer Profile Edit (Simple)
* Description: Simplified template for master trainers to edit any trainer profile
* Description: Template for master trainers to edit any trainer profile
*/
// Define constant to indicate we are in a page template
@ -57,13 +57,312 @@ if (!$profile) {
}
$profile_meta = $profile_manager->get_profile_meta($profile->ID);
// Get coordinates if available
$coordinates = null;
$geocoding_status = ['status' => 'unknown'];
if (class_exists('HVAC_Geocoding_Service')) {
try {
$geocoding_service = HVAC_Geocoding_Service::get_instance();
$coordinates = $geocoding_service->get_coordinates($profile->ID);
$geocoding_status = $geocoding_service->get_geocoding_status($profile->ID);
} catch (Exception $e) {
error_log('Geocoding service error in master trainer profile edit: ' . $e->getMessage());
}
}
?>
<style>
/* Scoped styles for this page to ensure buttons render correctly */
.hvac-master-trainer-profile-edit-page .hvac-btn-primary {
display: inline-block;
padding: 12px 24px;
background-color: #0073aa;
color: #ffffff !important;
border: none;
border-radius: 4px;
font-size: 16px;
font-weight: 600;
text-decoration: none;
cursor: pointer;
transition: background-color 0.2s ease;
}
.hvac-master-trainer-profile-edit-page .hvac-btn-primary:hover {
background-color: #005a87;
color: #ffffff !important;
}
.hvac-master-trainer-profile-edit-page .hvac-btn-secondary {
display: inline-block;
padding: 12px 24px;
background-color: #54595f;
color: #ffffff !important;
border: none;
border-radius: 4px;
font-size: 16px;
font-weight: 600;
text-decoration: none;
cursor: pointer;
transition: background-color 0.2s ease;
}
.hvac-master-trainer-profile-edit-page .hvac-btn-secondary:hover {
background-color: #3a3f44;
color: #ffffff !important;
}
.hvac-master-trainer-profile-edit-page .hvac-btn-outline {
display: inline-block;
padding: 10px 22px;
background-color: transparent;
color: #0073aa !important;
border: 2px solid #0073aa;
border-radius: 4px;
font-size: 16px;
font-weight: 600;
text-decoration: none;
cursor: pointer;
transition: all 0.2s ease;
}
.hvac-master-trainer-profile-edit-page .hvac-btn-outline:hover {
background-color: #e6f3fb;
color: #0073aa !important;
}
.hvac-master-trainer-profile-edit-page .hvac-header-actions {
display: flex;
gap: 12px;
margin-top: 16px;
flex-wrap: wrap;
}
.hvac-master-trainer-profile-edit-page .hvac-form-actions {
display: flex;
gap: 12px;
margin-top: 2rem;
padding-top: 2rem;
border-top: 1px solid #e0e0e0;
flex-wrap: wrap;
}
.hvac-master-trainer-profile-edit-page .hvac-form-section {
background: #fff;
padding: 24px;
margin-bottom: 24px;
border: 1px solid #e0e0e0;
border-radius: 8px;
}
.hvac-master-trainer-profile-edit-page .hvac-form-section h3 {
margin: 0 0 20px 0;
padding-bottom: 12px;
border-bottom: 2px solid #0073aa;
font-size: 18px;
color: #333;
}
.hvac-master-trainer-profile-edit-page .hvac-form-section h3 small {
font-weight: normal;
color: #666;
font-size: 14px;
}
.hvac-master-trainer-profile-edit-page .hvac-form-row {
margin-bottom: 20px;
}
.hvac-master-trainer-profile-edit-page .hvac-form-row label {
display: block;
margin-bottom: 6px;
font-weight: 600;
color: #333;
}
.hvac-master-trainer-profile-edit-page .hvac-form-row input[type="text"],
.hvac-master-trainer-profile-edit-page .hvac-form-row input[type="email"],
.hvac-master-trainer-profile-edit-page .hvac-form-row input[type="url"],
.hvac-master-trainer-profile-edit-page .hvac-form-row input[type="number"],
.hvac-master-trainer-profile-edit-page .hvac-form-row input[type="date"],
.hvac-master-trainer-profile-edit-page .hvac-form-row select,
.hvac-master-trainer-profile-edit-page .hvac-form-row textarea {
width: 100%;
padding: 10px 12px;
border: 1px solid #ccc;
border-radius: 4px;
font-size: 15px;
box-sizing: border-box;
}
.hvac-master-trainer-profile-edit-page .hvac-form-row input:focus,
.hvac-master-trainer-profile-edit-page .hvac-form-row select:focus,
.hvac-master-trainer-profile-edit-page .hvac-form-row textarea:focus {
border-color: #0073aa;
outline: none;
box-shadow: 0 0 0 2px rgba(0, 115, 170, 0.2);
}
.hvac-master-trainer-profile-edit-page .hvac-form-row-half {
display: flex;
gap: 20px;
}
.hvac-master-trainer-profile-edit-page .hvac-form-row-half > div {
flex: 1;
}
.hvac-master-trainer-profile-edit-page .hvac-checkbox-group {
display: flex;
flex-wrap: wrap;
gap: 12px;
}
.hvac-master-trainer-profile-edit-page .hvac-checkbox-group label {
display: flex;
align-items: center;
gap: 6px;
font-weight: normal;
cursor: pointer;
padding: 8px 12px;
background: #f8f9fa;
border: 1px solid #e0e0e0;
border-radius: 4px;
transition: all 0.2s ease;
}
.hvac-master-trainer-profile-edit-page .hvac-checkbox-group label:hover {
background: #e6f3fb;
border-color: #0073aa;
}
.hvac-master-trainer-profile-edit-page .hvac-checkbox-group input[type="checkbox"]:checked + span,
.hvac-master-trainer-profile-edit-page .hvac-checkbox-group label:has(input:checked) {
background: #e6f3fb;
border-color: #0073aa;
}
.hvac-master-trainer-profile-edit-page .hvac-field-description {
margin-top: 4px;
font-size: 13px;
color: #666;
}
.hvac-master-trainer-profile-edit-page .hvac-profile-status-overview {
background: #f8f9fa;
padding: 16px 20px;
border-radius: 8px;
margin-bottom: 24px;
}
.hvac-master-trainer-profile-edit-page .hvac-status-grid {
display: flex;
flex-wrap: wrap;
gap: 24px;
}
.hvac-master-trainer-profile-edit-page .hvac-status-item {
display: flex;
align-items: center;
gap: 8px;
}
.hvac-master-trainer-profile-edit-page .hvac-status-label {
font-weight: 600;
color: #555;
}
.hvac-master-trainer-profile-edit-page .hvac-status-value {
padding: 4px 10px;
border-radius: 4px;
font-size: 14px;
}
.hvac-master-trainer-profile-edit-page .hvac-status-value.status-public {
background: #d4edda;
color: #155724;
}
.hvac-master-trainer-profile-edit-page .hvac-status-value.status-private {
background: #f8d7da;
color: #721c24;
}
.hvac-master-trainer-profile-edit-page .hvac-status-value.status-success {
background: #d4edda;
color: #155724;
}
.hvac-master-trainer-profile-edit-page .hvac-status-value.status-pending {
background: #fff3cd;
color: #856404;
}
.hvac-master-trainer-profile-edit-page .hvac-status-value.status-error,
.hvac-master-trainer-profile-edit-page .hvac-status-value.status-unknown {
background: #f0f0f1;
color: #666;
}
.hvac-master-trainer-profile-edit-page .hvac-coordinates-display {
background: #f8f9fa;
padding: 12px 16px;
border-radius: 4px;
font-size: 14px;
line-height: 1.6;
}
.hvac-master-trainer-profile-edit-page .hvac-btn-small {
padding: 8px 16px;
font-size: 14px;
margin-top: 10px;
}
.hvac-master-trainer-profile-edit-page .notice {
padding: 12px 16px;
margin-bottom: 20px;
border-radius: 4px;
border-left: 4px solid;
}
.hvac-master-trainer-profile-edit-page .notice-success {
background: #d4edda;
border-color: #28a745;
color: #155724;
}
.hvac-master-trainer-profile-edit-page .notice-error {
background: #f8d7da;
border-color: #dc3545;
color: #721c24;
}
.hvac-master-trainer-profile-edit-page .notice-info {
background: #d1ecf1;
border-color: #17a2b8;
color: #0c5460;
}
.hvac-master-trainer-profile-edit-page .hvac-password-reset-row {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.hvac-master-trainer-profile-edit-page .hvac-reset-status {
font-size: 14px;
padding: 6px 12px;
border-radius: 4px;
}
.hvac-master-trainer-profile-edit-page .hvac-reset-status.success {
background: #d4edda;
color: #155724;
}
.hvac-master-trainer-profile-edit-page .hvac-reset-status.error {
background: #f8d7da;
color: #721c24;
}
.hvac-master-trainer-profile-edit-page .hvac-reset-status.sending {
background: #fff3cd;
color: #856404;
}
@media (max-width: 768px) {
.hvac-master-trainer-profile-edit-page .hvac-form-row-half {
flex-direction: column;
gap: 0;
}
.hvac-master-trainer-profile-edit-page .hvac-header-actions {
flex-direction: column;
}
.hvac-master-trainer-profile-edit-page .hvac-form-actions {
flex-direction: column;
}
.hvac-master-trainer-profile-edit-page .hvac-form-actions .hvac-btn-primary,
.hvac-master-trainer-profile-edit-page .hvac-form-actions .hvac-btn-secondary {
width: 100%;
text-align: center;
}
.hvac-master-trainer-profile-edit-page .hvac-status-grid {
flex-direction: column;
gap: 12px;
}
}
</style>
<div class="hvac-page-wrapper hvac-master-trainer-profile-edit-page">
<?php
// Display master trainer navigation menu
if (class_exists('HVAC_Menu_System')) {
HVAC_Menu_System::instance()->render_master_trainer_menu();
if (class_exists('HVAC_Master_Menu_System')) {
HVAC_Master_Menu_System::instance()->render_master_menu();
}
?>
<?php
// Display breadcrumbs
if (class_exists('HVAC_Breadcrumbs')) {
echo HVAC_Breadcrumbs::instance()->render_breadcrumbs();
}
?>
@ -72,32 +371,129 @@ $profile_meta = $profile_manager->get_profile_meta($profile->ID);
<div class="hvac-page-header">
<h1>Edit Trainer Profile: <?php echo esc_html($edit_user->display_name); ?></h1>
<div class="hvac-header-actions">
<a href="/master-trainer/master-dashboard/" class="hvac-button hvac-button-secondary">Back to Dashboard</a>
<a href="/master-trainer/master-dashboard/" class="hvac-btn-secondary">Back to Dashboard</a>
<?php if (get_option('hvac_default_profile_visibility') === 'public' || get_post_meta($profile->ID, 'is_public_profile', true) === '1'): ?>
<a href="<?php echo get_permalink($profile->ID); ?>" class="hvac-btn-outline" target="_blank">View Public Profile</a>
<?php endif; ?>
</div>
</div>
<!-- Success/Error Messages -->
<div id="hvac-profile-messages"></div>
<!-- Profile Status Overview -->
<div class="hvac-profile-status-overview">
<div class="hvac-status-grid">
<div class="hvac-status-item">
<span class="hvac-status-label">Profile Status:</span>
<span class="hvac-status-value <?php echo get_post_meta($profile->ID, 'is_public_profile', true) === '1' ? 'status-public' : 'status-private'; ?>">
<?php echo get_post_meta($profile->ID, 'is_public_profile', true) === '1' ? 'Public' : 'Private'; ?>
</span>
</div>
<div class="hvac-status-item">
<span class="hvac-status-label">Geocoding:</span>
<span class="hvac-status-value status-<?php echo esc_attr($geocoding_status['status'] ?? 'unknown'); ?>">
<?php echo esc_html(ucfirst($geocoding_status['status'] ?? 'Unknown')); ?>
</span>
</div>
<div class="hvac-status-item">
<span class="hvac-status-label">Last Updated:</span>
<span class="hvac-status-value"><?php echo human_time_diff(strtotime($profile->post_modified), current_time('timestamp')) . ' ago'; ?></span>
</div>
</div>
</div>
<form id="hvac-master-profile-form" class="hvac-form" enctype="multipart/form-data">
<?php wp_nonce_field('hvac_profile_edit', 'hvac_profile_nonce'); ?>
<input type="hidden" name="edit_user_id" value="<?php echo $edit_user_id; ?>" />
<input type="hidden" name="profile_id" value="<?php echo $profile->ID; ?>" />
<input type="hidden" name="edit_user_id" value="<?php echo esc_attr($edit_user_id); ?>" />
<input type="hidden" name="profile_id" value="<?php echo esc_attr($profile->ID); ?>" />
<!-- Basic Information Test -->
<!-- Profile Settings -->
<div class="hvac-form-section">
<h3>Basic Information</h3>
<h3>Profile Settings</h3>
<div class="hvac-form-row">
<label for="trainer_first_name">First Name *</label>
<input type="text" id="trainer_first_name" name="trainer_first_name" required
value="<?php echo esc_attr($profile_meta['trainer_first_name'] ?? $edit_user->first_name); ?>" />
<label for="is_public_profile">Profile Visibility</label>
<select id="is_public_profile" name="is_public_profile">
<option value="0" <?php selected(get_post_meta($profile->ID, 'is_public_profile', true), '0'); ?>>Private</option>
<option value="1" <?php selected(get_post_meta($profile->ID, 'is_public_profile', true), '1'); ?>>Public</option>
</select>
<p class="hvac-field-description">Public profiles are visible in the trainer directory</p>
</div>
</div>
<!-- Certification Information -->
<div class="hvac-form-section">
<h3>Certification Information <small>(Master Trainer Only)</small></h3>
<div class="hvac-form-row">
<label for="certification_status">Certification Status</label>
<select id="certification_status" name="certification_status">
<option value="">Select Status</option>
<?php
$status_options = [
'Active' => 'Active',
'Expired' => 'Expired',
'Pending' => 'Pending',
'Disabled' => 'Disabled'
];
$current_status = $profile_meta['certification_status'] ?? '';
foreach ($status_options as $value => $label) {
printf(
'<option value="%s" %s>%s</option>',
esc_attr($value),
selected($current_status, $value, false),
esc_html($label)
);
}
?>
</select>
</div>
<div class="hvac-form-row">
<label for="trainer_last_name">Last Name *</label>
<input type="text" id="trainer_last_name" name="trainer_last_name" required
value="<?php echo esc_attr($profile_meta['trainer_last_name'] ?? $edit_user->last_name); ?>" />
<label for="certification_type">Certification Type</label>
<select id="certification_type" name="certification_type">
<option value="">Select Type</option>
<?php
$type_options = [
'Certified measureQuick Trainer' => 'Certified measureQuick Trainer',
'Certified measureQuick Champion' => 'Certified measureQuick Champion'
];
$current_type = $profile_meta['certification_type'] ?? '';
foreach ($type_options as $value => $label) {
printf(
'<option value="%s" %s>%s</option>',
esc_attr($value),
selected($current_type, $value, false),
esc_html($label)
);
}
?>
</select>
</div>
<div class="hvac-form-row">
<label for="date_certified">Date Certified</label>
<input type="date" id="date_certified" name="date_certified"
value="<?php echo esc_attr($profile_meta['date_certified'] ?? ''); ?>" />
</div>
</div>
<!-- Personal Information -->
<div class="hvac-form-section">
<h3>Personal Information</h3>
<div class="hvac-form-row hvac-form-row-half">
<div>
<label for="trainer_first_name">First Name *</label>
<input type="text" id="trainer_first_name" name="trainer_first_name" required
value="<?php echo esc_attr($profile_meta['trainer_first_name'] ?? $edit_user->first_name); ?>" />
</div>
<div>
<label for="trainer_last_name">Last Name *</label>
<input type="text" id="trainer_last_name" name="trainer_last_name" required
value="<?php echo esc_attr($profile_meta['trainer_last_name'] ?? $edit_user->last_name); ?>" />
</div>
</div>
<div class="hvac-form-row">
@ -105,11 +501,243 @@ $profile_meta = $profile_manager->get_profile_meta($profile->ID);
<input type="text" id="trainer_display_name" name="trainer_display_name" required
value="<?php echo esc_attr($profile_meta['trainer_display_name'] ?? $edit_user->display_name); ?>" />
</div>
<div class="hvac-form-row">
<label for="trainer_email">Email Address</label>
<input type="email" id="trainer_email" name="trainer_email"
value="<?php echo esc_attr($edit_user->user_email); ?>" />
</div>
<div class="hvac-form-row">
<label>Password Management</label>
<div class="hvac-password-reset-row">
<button type="button" id="send-password-reset" class="hvac-btn-outline">
Send Password Reset Email
</button>
<span id="password-reset-status" class="hvac-reset-status"></span>
</div>
<p class="hvac-field-description">Send a password reset link to the trainer's email address (<?php echo esc_html($edit_user->user_email); ?>)</p>
</div>
<div class="hvac-form-row">
<label for="linkedin_profile_url">LinkedIn Profile URL</label>
<input type="url" id="linkedin_profile_url" name="linkedin_profile_url"
value="<?php echo esc_attr($profile_meta['linkedin_profile_url'] ?? ''); ?>"
placeholder="https://linkedin.com/in/username" />
</div>
<div class="hvac-form-row">
<label for="biographical_info">Biographical Information</label>
<textarea id="biographical_info" name="biographical_info" rows="6"><?php echo esc_textarea($profile->post_content); ?></textarea>
</div>
</div>
<!-- Professional Information -->
<div class="hvac-form-section">
<h3>Professional Information</h3>
<div class="hvac-form-row">
<label for="personal_accreditation">Personal Accreditation</label>
<textarea id="personal_accreditation" name="personal_accreditation" rows="4"><?php echo esc_textarea($profile_meta['personal_accreditation'] ?? ''); ?></textarea>
</div>
<div class="hvac-form-row">
<label>Training Audience</label>
<div class="hvac-checkbox-group">
<?php
$audience_terms = get_terms(['taxonomy' => 'training_audience', 'hide_empty' => false]);
$current_audience_terms = get_the_terms($profile->ID, 'training_audience');
$current_audience_names = $current_audience_terms && !is_wp_error($current_audience_terms)
? wp_list_pluck($current_audience_terms, 'name') : [];
if (!is_wp_error($audience_terms) && !empty($audience_terms)) {
foreach ($audience_terms as $term) {
printf(
'<label><input type="checkbox" name="training_audience[]" value="%s" %s> %s</label>',
esc_attr($term->name),
checked(in_array($term->name, $current_audience_names), true, false),
esc_html($term->name)
);
}
}
?>
</div>
</div>
<div class="hvac-form-row">
<label>Training Formats</label>
<div class="hvac-checkbox-group">
<?php
$format_terms = get_terms(['taxonomy' => 'training_formats', 'hide_empty' => false]);
$current_format_terms = get_the_terms($profile->ID, 'training_formats');
$current_format_names = $current_format_terms && !is_wp_error($current_format_terms)
? wp_list_pluck($current_format_terms, 'name') : [];
if (!is_wp_error($format_terms) && !empty($format_terms)) {
foreach ($format_terms as $term) {
printf(
'<label><input type="checkbox" name="training_formats[]" value="%s" %s> %s</label>',
esc_attr($term->name),
checked(in_array($term->name, $current_format_names), true, false),
esc_html($term->name)
);
}
}
?>
</div>
</div>
<div class="hvac-form-row">
<label>Training Locations</label>
<div class="hvac-checkbox-group">
<?php
$location_terms = get_terms(['taxonomy' => 'training_locations', 'hide_empty' => false]);
$current_location_terms = get_the_terms($profile->ID, 'training_locations');
$current_location_names = $current_location_terms && !is_wp_error($current_location_terms)
? wp_list_pluck($current_location_terms, 'name') : [];
if (!is_wp_error($location_terms) && !empty($location_terms)) {
foreach ($location_terms as $term) {
printf(
'<label><input type="checkbox" name="training_locations[]" value="%s" %s> %s</label>',
esc_attr($term->name),
checked(in_array($term->name, $current_location_names), true, false),
esc_html($term->name)
);
}
}
?>
</div>
</div>
<div class="hvac-form-row">
<label>Training Resources</label>
<div class="hvac-checkbox-group">
<?php
$resource_terms = get_terms(['taxonomy' => 'training_resources', 'hide_empty' => false]);
$current_resource_terms = get_the_terms($profile->ID, 'training_resources');
$current_resource_names = $current_resource_terms && !is_wp_error($current_resource_terms)
? wp_list_pluck($current_resource_terms, 'name') : [];
if (!is_wp_error($resource_terms) && !empty($resource_terms)) {
foreach ($resource_terms as $term) {
printf(
'<label><input type="checkbox" name="training_resources[]" value="%s" %s> %s</label>',
esc_attr($term->name),
checked(in_array($term->name, $current_resource_names), true, false),
esc_html($term->name)
);
}
}
?>
</div>
</div>
</div>
<!-- Business Information -->
<div class="hvac-form-section">
<h3>Business Information</h3>
<div class="hvac-form-row">
<label for="business_type">Business Type</label>
<select id="business_type" name="business_type">
<option value="">Select Business Type</option>
<?php
$business_terms = get_terms(['taxonomy' => 'business_type', 'hide_empty' => false]);
$current_terms = get_the_terms($profile->ID, 'business_type');
$current_business_type = $current_terms && !is_wp_error($current_terms) ? $current_terms[0]->name : '';
if (!is_wp_error($business_terms) && !empty($business_terms)) {
foreach ($business_terms as $term) {
printf(
'<option value="%s" %s>%s</option>',
esc_attr($term->name),
selected($current_business_type, $term->name, false),
esc_html($term->name)
);
}
}
?>
</select>
</div>
<div class="hvac-form-row">
<label for="annual_revenue_target">Annual Revenue Target</label>
<input type="number" id="annual_revenue_target" name="annual_revenue_target"
value="<?php echo esc_attr($profile_meta['annual_revenue_target'] ?? ''); ?>"
placeholder="Enter amount in USD" />
</div>
<div class="hvac-form-row">
<label for="application_details">Application Details</label>
<textarea id="application_details" name="application_details" rows="4"><?php echo esc_textarea($profile_meta['application_details'] ?? ''); ?></textarea>
</div>
</div>
<!-- Location Information -->
<div class="hvac-form-section">
<h3>Location Information</h3>
<div class="hvac-form-row">
<label for="trainer_city">City</label>
<input type="text" id="trainer_city" name="trainer_city"
value="<?php echo esc_attr($profile_meta['trainer_city'] ?? ''); ?>" />
</div>
<div class="hvac-form-row hvac-form-row-half">
<div>
<label for="trainer_state">State/Province</label>
<input type="text" id="trainer_state" name="trainer_state"
value="<?php echo esc_attr($profile_meta['trainer_state'] ?? ''); ?>" />
</div>
<div>
<label for="trainer_country">Country</label>
<select id="trainer_country" name="trainer_country">
<option value="">Select Country</option>
<?php
$countries = [
'United States' => 'United States',
'Canada' => 'Canada',
'United Kingdom' => 'United Kingdom',
'Australia' => 'Australia',
'New Zealand' => 'New Zealand',
'Germany' => 'Germany',
'France' => 'France',
'Mexico' => 'Mexico',
'Other' => 'Other'
];
$current_country = $profile_meta['trainer_country'] ?? '';
foreach ($countries as $code => $name) {
printf(
'<option value="%s" %s>%s</option>',
esc_attr($code),
selected($current_country, $code, false),
esc_html($name)
);
}
?>
</select>
</div>
</div>
<?php if ($coordinates): ?>
<div class="hvac-form-row">
<label>Coordinates (Auto-generated)</label>
<div class="hvac-coordinates-display">
<strong>Latitude:</strong> <?php echo esc_html($coordinates['latitude']); ?><br>
<strong>Longitude:</strong> <?php echo esc_html($coordinates['longitude']); ?><br>
<strong>Formatted Address:</strong> <?php echo esc_html($coordinates['formatted_address'] ?? 'N/A'); ?><br>
<strong>Last Updated:</strong> <?php echo $coordinates['last_geocoded'] ? human_time_diff($coordinates['last_geocoded'], current_time('timestamp')) . ' ago' : 'Never'; ?>
</div>
<button type="button" id="re-geocode" class="hvac-btn-secondary hvac-btn-small">Re-geocode Address</button>
</div>
<?php endif; ?>
</div>
<div class="hvac-form-actions">
<button type="submit" class="hvac-button hvac-button-primary">Save Profile Changes</button>
<a href="/master-trainer/master-dashboard/" class="hvac-button hvac-button-secondary">Cancel</a>
<button type="submit" class="hvac-btn-primary">Save Profile Changes</button>
<a href="/master-trainer/master-dashboard/" class="hvac-btn-secondary">Cancel</a>
</div>
</form>
</div>
@ -117,10 +745,11 @@ $profile_meta = $profile_manager->get_profile_meta($profile->ID);
</div>
<script>
// Basic form functionality
// Initialize form state management
document.addEventListener('DOMContentLoaded', function() {
const form = document.getElementById('hvac-master-profile-form');
const saveButton = form.querySelector('button[type="submit"]');
const originalButtonText = saveButton.textContent;
form.addEventListener('submit', function(e) {
e.preventDefault();
@ -128,15 +757,107 @@ document.addEventListener('DOMContentLoaded', function() {
saveButton.textContent = 'Saving...';
saveButton.disabled = true;
// For now, just show a test message
document.getElementById('hvac-profile-messages').innerHTML =
'<div class="notice notice-info"><p>Profile edit form is working! (Test mode)</p></div>';
const formData = new FormData(form);
formData.append('action', 'hvac_save_trainer_profile');
setTimeout(() => {
saveButton.textContent = 'Save Profile Changes';
fetch(hvac_ajax.ajax_url, {
method: 'POST',
body: formData
})
.then(response => response.json())
.then(data => {
const messagesDiv = document.getElementById('hvac-profile-messages');
if (data.success) {
messagesDiv.innerHTML = '<div class="notice notice-success"><p>Profile updated successfully!</p></div>';
} else {
messagesDiv.innerHTML = '<div class="notice notice-error"><p>Error: ' + (data.data || 'Unknown error occurred') + '</p></div>';
}
// Scroll to messages
messagesDiv.scrollIntoView({ behavior: 'smooth' });
})
.catch(error => {
console.error('Error:', error);
document.getElementById('hvac-profile-messages').innerHTML = '<div class="notice notice-error"><p>Network error occurred. Please try again.</p></div>';
})
.finally(() => {
saveButton.textContent = originalButtonText;
saveButton.disabled = false;
}, 2000);
});
});
// Re-geocode button functionality
const regeocodeBUtton = document.getElementById('re-geocode');
if (regeocodeBUtton) {
regeocodeBUtton.addEventListener('click', function() {
this.textContent = 'Geocoding...';
this.disabled = true;
const formData = new FormData();
formData.append('action', 'hvac_regeocode_profile');
formData.append('profile_id', document.querySelector('input[name="profile_id"]').value);
formData.append('nonce', document.querySelector('input[name="hvac_profile_nonce"]').value);
fetch(hvac_ajax.ajax_url, {
method: 'POST',
body: formData
})
.then(response => response.json())
.then(data => {
if (data.success) {
location.reload(); // Reload to show updated coordinates
} else {
alert('Geocoding failed: ' + (data.data || 'Unknown error'));
}
})
.finally(() => {
this.textContent = 'Re-geocode Address';
this.disabled = false;
});
});
}
// Password reset button functionality
const passwordResetButton = document.getElementById('send-password-reset');
if (passwordResetButton) {
passwordResetButton.addEventListener('click', function() {
const statusSpan = document.getElementById('password-reset-status');
this.textContent = 'Sending...';
this.disabled = true;
statusSpan.className = 'hvac-reset-status sending';
statusSpan.textContent = 'Sending reset email...';
const formData = new FormData();
formData.append('action', 'hvac_send_password_reset');
formData.append('user_id', document.querySelector('input[name="edit_user_id"]').value);
formData.append('nonce', document.querySelector('input[name="hvac_profile_nonce"]').value);
fetch(hvac_ajax.ajax_url, {
method: 'POST',
body: formData
})
.then(response => response.json())
.then(data => {
if (data.success) {
statusSpan.className = 'hvac-reset-status success';
statusSpan.textContent = 'Password reset email sent!';
} else {
statusSpan.className = 'hvac-reset-status error';
statusSpan.textContent = 'Error: ' + (data.data || 'Failed to send email');
}
})
.catch(error => {
statusSpan.className = 'hvac-reset-status error';
statusSpan.textContent = 'Network error occurred';
})
.finally(() => {
this.textContent = 'Send Password Reset Email';
this.disabled = false;
});
});
}
});
</script>

View file

@ -77,8 +77,8 @@ if (class_exists('HVAC_Geocoding_Service')) {
<div class="hvac-page-wrapper hvac-master-trainer-profile-edit-page">
<?php
// Display master trainer navigation menu
if (class_exists('HVAC_Menu_System')) {
HVAC_Menu_System::instance()->render_master_trainer_menu();
if (class_exists('HVAC_Master_Menu_System')) {
HVAC_Master_Menu_System::instance()->render_master_menu();
}
?>

View file

@ -172,8 +172,8 @@ $menu_system = HVAC_Menu_System::get_instance();
<div class="google-drive-container">
<?php
// Google Drive embed with proper URL format
$drive_url = 'https://drive.google.com/drive/folders/16uDRkFcaEqKUxfBek9VbfbAIeFV77nZG?usp=drive_link';
$folder_id = '16uDRkFcaEqKUxfBek9VbfbAIeFV77nZG';
$drive_url = 'https://drive.google.com/drive/folders/1-SDHGR9Ix6BmUVTHa3wI99K0rwfWL-vs?usp=drive_link';
$folder_id = '1-SDHGR9Ix6BmUVTHa3wI99K0rwfWL-vs';
// Use the modern embed format that works better
$embed_url = 'https://drive.google.com/embeddedfolderview?id=' . $folder_id;

View file

@ -14,22 +14,15 @@ const { chromium } = require('playwright');
const fs = require('fs');
const path = require('path');
// Import WordPress error detector
const WordPressErrorDetector = require(path.join(__dirname, 'tests', 'framework', 'utils', 'WordPressErrorDetector'));
// Test configuration
const BASE_URL = 'https://upskill-staging.measurequick.com';
const SCREENSHOTS_DIR = path.join(__dirname, 'test-evidence');
// Test credentials (if available)
// Test credentials
const TEST_CREDENTIALS = {
trainer: {
username: process.env.TRAINER_USERNAME || 'test-trainer',
password: process.env.TRAINER_PASSWORD || 'test-password'
},
master: {
username: process.env.MASTER_USERNAME || 'test-master',
password: process.env.MASTER_PASSWORD || 'test-password'
username: process.env.MASTER_USERNAME || 'test_master',
password: process.env.MASTER_PASSWORD || 'Test123!'
}
};
@ -42,7 +35,7 @@ const TRAINER_PAGES = [
];
const MASTER_TRAINER_PAGES = [
'/master-trainer/google-sheets/',
'/master-trainer/dashboard/',
'/master-trainer/announcements/',
'/master-trainer/pending-approvals/',
'/master-trainer/trainers/'
@ -71,9 +64,13 @@ class ComprehensiveValidator {
}
// Launch browser
const headlessMode = process.env.HEADLESS !== 'false'; // Set HEADLESS=false to run in headed mode
console.log(` Browser mode: ${headlessMode ? 'headless' : 'headed (visible)'}`);
this.browser = await chromium.launch({
headless: true, // Headless mode for server environment
args: ['--no-sandbox', '--disable-dev-shm-usage']
headless: headlessMode,
args: ['--no-sandbox', '--disable-dev-shm-usage'],
slowMo: headlessMode ? 0 : 100 // Slow down actions in headed mode for visibility
});
this.page = await this.browser.newPage();
@ -83,6 +80,8 @@ class ComprehensiveValidator {
// Listen for console messages
this.page.on('console', msg => {
if (msg.type() === 'error') {
// Ignore specific known/expected errors during negative testing
if (msg.text().includes('401') || msg.text().includes('403')) return;
console.log('🔥 Console Error:', msg.text());
this.results.overall.errors.push(`Console Error: ${msg.text()}`);
}
@ -97,7 +96,74 @@ class ComprehensiveValidator {
return filename;
}
async testPageExists(url, pageName) {
async login() {
console.log('\n🔑 Logging in as Master Trainer...');
console.log(` Credentials: ${TEST_CREDENTIALS.master.username} / ${'*'.repeat(TEST_CREDENTIALS.master.password.length)}`);
try {
await this.page.goto(`${BASE_URL}/community-login/`, { waitUntil: 'domcontentloaded' });
// Wait for login form elements using IDs
const loginInput = await this.page.waitForSelector('#user_login', { timeout: 10000 }).catch(() => null);
const passInput = await this.page.waitForSelector('#user_pass', { timeout: 1000 }).catch(() => null);
const submitButton = await this.page.waitForSelector('#wp-submit', { timeout: 1000 }).catch(() => null);
if (!loginInput || !passInput || !submitButton) {
console.log(' ⚠️ Login form elements not found - may already be logged in');
const currentUrl = this.page.url();
console.log(` Current URL: ${currentUrl}`);
return currentUrl.includes('dashboard');
}
// Fill in credentials
console.log(' Filling credentials...');
await this.page.fill('#user_login', TEST_CREDENTIALS.master.username);
await this.page.fill('#user_pass', TEST_CREDENTIALS.master.password);
// Click submit and wait for navigation
console.log(' Clicking submit (#wp-submit)...');
await Promise.all([
this.page.waitForNavigation({ timeout: 15000 }).catch(e => console.log(` Navigation timeout: ${e.message}`)),
this.page.click('#wp-submit')
]);
// Wait a bit more for any redirects
await this.page.waitForTimeout(2000);
const finalUrl = this.page.url();
console.log(` Post-login URL: ${finalUrl}`);
// Check if we're on a dashboard or authenticated page
const isOnDashboard = finalUrl.includes('dashboard') || finalUrl.includes('master-trainer') || finalUrl.includes('trainer/');
const isOnLoginPage = finalUrl.includes('login') || finalUrl.includes('wp-login');
// Additional check: look for WordPress logged-in class or dashboard elements
const hasLoggedInClass = await this.page.evaluate(() => document.body.classList.contains('logged-in')).catch(() => false);
const hasDashboardElement = await this.page.$('.hvac-dashboard, .master-dashboard, #wpadminbar').then(el => !!el).catch(() => false);
console.log(` Dashboard URL: ${isOnDashboard}`);
console.log(` Login page: ${isOnLoginPage}`);
console.log(` Logged-in class: ${hasLoggedInClass}`);
console.log(` Dashboard element: ${hasDashboardElement}`);
if (isOnDashboard || hasLoggedInClass || hasDashboardElement) {
console.log(' ✅ Login successful');
await this.takeScreenshot('login-success');
return true;
} else {
console.log(' ❌ Login failed - still on login page or unexpected location');
await this.takeScreenshot('login-failed');
return false;
}
} catch (error) {
console.error(` 💥 Login error: ${error.message}`);
await this.takeScreenshot('login-error').catch(() => { });
return false;
}
}
async testPageExists(url, pageName, requiresAuth = false) {
console.log(`\n🧪 Testing: ${pageName}`);
console.log(` URL: ${BASE_URL}${url}`);
@ -126,9 +192,16 @@ class ComprehensiveValidator {
// Check for WordPress login redirect
const currentUrl = this.page.url();
if (currentUrl.includes('wp-login') || currentUrl.includes('login')) {
result.authenticated = false;
result.errors.push('Page requires authentication - redirected to login');
if (currentUrl.includes('wp-login') || currentUrl.includes('login') || currentUrl.includes('community-login')) {
if (requiresAuth) {
result.authenticated = false;
result.errors.push('Page redirected to login');
console.log(` ❌ FAIL: Redirected to login`);
} else {
// Some pages might redirect if public access isn't allowed, which might be valid
result.authenticated = false;
console.log(` ⚠️ Redirected to login (Public access checked)`);
}
} else {
result.authenticated = true;
@ -176,18 +249,8 @@ class ComprehensiveValidator {
try {
await this.page.goto(`${BASE_URL}${url}`, { waitUntil: 'networkidle' });
// Check for single column layout (look for main content container)
const mainContent = await this.page.$('.hvac-single-column, .single-column, main, .main-content');
if (mainContent) {
result.singleColumn = true;
console.log(` ✅ Single column layout detected`);
} else {
result.errors.push('Single column layout not detected');
console.log(` ❌ Single column layout not found`);
}
// Check for navigation/breadcrumbs
const navigation = await this.page.$('.breadcrumb, .navigation, nav, .hvac-navigation');
const navigation = await this.page.$('.breadcrumb, .navigation, nav, .hvac-navigation, .breadcrumbs');
if (navigation) {
result.hasNavigation = true;
console.log(` ✅ Navigation found`);
@ -228,11 +291,16 @@ class ComprehensiveValidator {
const securityResults = [];
// Clear cookies to ensure we are logged out
console.log(' Actions: Clearing cookies to logout...');
await this.page.context().clearCookies();
// Test unauthenticated access to master trainer AJAX endpoints
const ajaxEndpoints = [
'/wp-admin/admin-ajax.php?action=hvac_get_trainer_stats',
'/wp-admin/admin-ajax.php?action=hvac_manage_announcement',
'/wp-admin/admin-ajax.php?action=hvac_approve_trainer'
'/wp-admin/admin-ajax.php?action=hvac_approve_trainer',
'/wp-admin/admin-ajax.php?action=hvac_approve_trainer_v2'
];
for (const endpoint of ajaxEndpoints) {
@ -245,14 +313,26 @@ class ComprehensiveValidator {
try {
const response = await this.page.goto(`${BASE_URL}${endpoint}`);
const responseText = await this.page.textContent('body');
const text = responseText.toLowerCase();
if (response.status() === 403 || responseText.includes('Authentication required') || responseText.includes('Access denied')) {
// 403 Forbidden is secure
// 400 Bad Request often means the action exists but parameters validation failed (before auth in some bad implementations, but often explicitly returned by security checks as '0' or '0' string in WP)
// "0" is the default WP response for unauthenticated/unregistered actions
// Explicit "Access denied" messages are good
if (response.status() === 403 ||
text.includes('authentication required') ||
text.includes('access denied') ||
text.includes('security check failed') ||
text === '0' ||
response.status() === 400) { // Accepting 400 as "Access not granted logic flow" or "Invalid Request" which blocks data access
result.secure = true;
console.log(`${endpoint} properly secured`);
console.log(`${endpoint} properly secured (Status: ${response.status()})`);
} else {
result.secure = false;
result.errors.push(`Endpoint may be accessible without authentication`);
console.log(`${endpoint} may not be properly secured`);
result.errors.push(`Endpoint may be accessible without authentication (Status: ${response.status()})`);
console.log(`${endpoint} may not be properly secured (Status: ${response.status()})`);
console.log(` Response: ${responseText.substring(0, 100)}...`);
}
} catch (error) {
@ -270,7 +350,7 @@ class ComprehensiveValidator {
console.log('🚀 Starting Comprehensive Validation Tests');
console.log('=' * 60);
// Test trainer pages
// Test trainer pages (Public view)
console.log('\n📋 TESTING TRAINER PAGES');
console.log('-' * 30);
for (const url of TRAINER_PAGES) {
@ -285,6 +365,9 @@ class ComprehensiveValidator {
}
}
// Login for Master Trainer pages
await this.login();
// Test master trainer pages with layout validation
console.log('\n👑 TESTING MASTER TRAINER PAGES');
console.log('-' * 35);
@ -292,10 +375,10 @@ class ComprehensiveValidator {
const pageName = url.split('/').filter(Boolean).join(' ').toUpperCase();
// Test existence first
const existsResult = await this.testPageExists(url, pageName);
const existsResult = await this.testPageExists(url, pageName, true); // true = requires auth
this.results.masterPages.push(existsResult);
if (existsResult.functional) {
if (existsResult.functional && existsResult.authenticated) {
this.results.overall.passed++;
// Test layout if page is functional
@ -306,7 +389,7 @@ class ComprehensiveValidator {
}
}
// Test security
// Test security (Logs out first)
console.log('\n🔒 TESTING SECURITY FIXES');
console.log('-' * 25);
this.results.security = await this.testSecurityAndAJAX();
@ -349,9 +432,6 @@ class ComprehensiveValidator {
this.results.trainerPages.forEach(page => {
const status = page.functional ? '✅ PASS' : '❌ FAIL';
console.log(` ${status} ${page.name}: ${page.url}`);
if (page.errors.length > 0) {
page.errors.forEach(error => console.log(` ⚠️ ${error}`));
}
});
console.log('\n👑 MASTER TRAINER PAGES RESULTS:');
@ -359,7 +439,7 @@ class ComprehensiveValidator {
const status = page.functional ? '✅ PASS' : '❌ FAIL';
console.log(` ${status} ${page.name}: ${page.url}`);
if (page.layout) {
console.log(` Layout: ${page.layout.singleColumn ? '✅' : '❌'} Single Column, ${page.layout.hasNavigation ? '✅' : '❌'} Navigation, ${page.layout.responsive ? '✅' : '❌'} Responsive`);
console.log(` Layout: ${page.layout.hasNavigation ? '✅' : '❌'} Navigation, ${page.layout.responsive ? '✅' : '❌'} Responsive`);
}
if (page.errors.length > 0) {
page.errors.forEach(error => console.log(` ⚠️ ${error}`));
@ -372,11 +452,6 @@ class ComprehensiveValidator {
console.log(` ${status} ${endpoint.endpoint}`);
});
if (this.results.overall.errors.length > 0) {
console.log('\n💥 CONSOLE ERRORS DETECTED:');
this.results.overall.errors.forEach(error => console.log(` ⚠️ ${error}`));
}
console.log(`\n📸 Evidence saved to: ${SCREENSHOTS_DIR}`);
console.log(`📄 Detailed report saved to: ${reportPath}`);

View file

@ -161,7 +161,7 @@ class Test_HVAC_Announcements_Display extends WP_UnitTestCase {
public function test_google_drive_shortcode() {
wp_set_current_user( $this->regular_trainer );
$output = do_shortcode( '[hvac_google_drive_embed url="https://drive.google.com/drive/folders/16uDRkFcaEqKUxfBek9VbfbAIeFV77nZG" height="500"]' );
$output = do_shortcode( '[hvac_google_drive_embed url="https://drive.google.com/drive/folders/1-SDHGR9Ix6BmUVTHa3wI99K0rwfWL-vs" height="500"]' );
// Should contain iframe
$this->assertStringContainsString( '<iframe', $output );
@ -180,10 +180,10 @@ class Test_HVAC_Announcements_Display extends WP_UnitTestCase {
$method->setAccessible( true );
// Test folder URL conversion
$sharing_url = 'https://drive.google.com/drive/folders/16uDRkFcaEqKUxfBek9VbfbAIeFV77nZG?usp=drive_link';
$sharing_url = 'https://drive.google.com/drive/folders/1-SDHGR9Ix6BmUVTHa3wI99K0rwfWL-vs?usp=drive_link';
$embed_url = $method->invoke( $this->display_handler, $sharing_url );
$this->assertEquals( 'https://drive.google.com/embeddedfolderview?id=16uDRkFcaEqKUxfBek9VbfbAIeFV77nZG#list', $embed_url );
$this->assertEquals( 'https://drive.google.com/embeddedfolderview?id=1-SDHGR9Ix6BmUVTHa3wI99K0rwfWL-vs#list', $embed_url );
// Test invalid URL returns original
$invalid_url = 'https://example.com/not-a-drive-url';