From 19147d978ec420a6f981081c3a973654e684529e Mon Sep 17 00:00:00 2001 From: ben Date: Sun, 1 Feb 2026 11:42:45 -0400 Subject: [PATCH] feat(find-training): Add viewport sync and marker hover interactions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- Status.md | 64 ++++++-- assets/js/find-training-filters.js | 68 ++++++++- assets/js/find-training-map.js | 205 ++++++++++++++++++++++---- includes/class-hvac-page-manager.php | 8 +- includes/class-hvac-plugin.php | 2 +- includes/class-hvac-route-manager.php | 3 +- 6 files changed, 301 insertions(+), 49 deletions(-) diff --git a/Status.md b/Status.md index 9db8f2f5..e4bdb8cc 100644 --- a/Status.md +++ b/Status.md @@ -1,14 +1,57 @@ # HVAC Community Events - Project Status **Last Updated:** February 1, 2026 -**Current Session:** Find Training Page Implementation - Complete -**Version:** 2.2.0 (Ready for Staging Deployment) +**Current Session:** Find Training Page Enhancements - Complete +**Version:** 2.2.4 (Deployed to Production) --- -## 🎯 CURRENT SESSION - FIND TRAINING PAGE IMPLEMENTATION (Jan 31 - Feb 1, 2026) +## 🎯 CURRENT SESSION - FIND TRAINING PAGE ENHANCEMENTS (Feb 1, 2026) -### Status: ✅ **COMPLETE - Ready for Staging Deployment & E2E Testing** +### Status: ✅ **COMPLETE - Deployed to Production** + +**Objective:** Improve Find Training page UX with viewport sync and marker hover interactions. + +### Changes Made + +1. ✅ **Viewport Sync** - Sidebar now shows only trainers visible in current map area + - Added `visibleTrainers` array to track filtered trainers + - Added `syncSidebarWithViewport()` method filtering by map bounds + - Map `idle` event triggers sync on pan/zoom + - Count shows "X of Y trainers" when zoomed in + +2. ✅ **Marker Hover Interaction** - Info window appears on hover + - Added `mouseover` event listener to trainer/venue markers + - Set `optimized: false` on markers for reliable hover events + - Hover shows info window preview with "View Profile" button + - Click on "View Profile" opens full modal with contact form + +3. ✅ **Legacy URL Redirects** + - `/find-a-trainer/` → `/find-training/` (301 redirect) + - `/find-trainer/` → `/find-training/` (301 redirect) + - Removed old page from Page Manager + +### Files Modified + +| File | Change | +|------|--------| +| `assets/js/find-training-map.js` | Added viewport sync, hover events, optimized:false | +| `assets/js/find-training-filters.js` | Updated filter handler for visibleTrainers | +| `includes/class-hvac-route-manager.php` | Added legacy URL redirects | +| `includes/class-hvac-page-manager.php` | Removed find-a-trainer page definition | +| `includes/class-hvac-plugin.php` | Version bumped to 2.2.4 | + +### Verified Behavior +- ✅ Hover over marker → Info window appears immediately +- ✅ Click "View Profile" → Full modal with trainer details + contact form +- ✅ Pan/zoom map → Sidebar updates to show visible trainers only +- ✅ Legacy URLs redirect to new page + +--- + +## 📋 PREVIOUS SESSION - FIND TRAINING PAGE IMPLEMENTATION (Jan 31 - Feb 1, 2026) + +### Status: ✅ **COMPLETE - Deployed to Production** **Objective:** Replace the buggy MapGeo-based `/find-a-trainer` page with a new `/find-training` page built from scratch using Google Maps JavaScript API. @@ -69,13 +112,12 @@ Ran comprehensive code review using GPT-5, Gemini 3, and Zen MCP tools. Found an - ✅ Auto-geocoding for new venues - ✅ Rate-limited batch geocoding for existing venues -### Next Steps -1. ⏳ Deploy to staging: `./scripts/deploy.sh staging` -2. ⏳ Run E2E tests on Find Training page -3. ⏳ Verify map loads with markers -4. ⏳ Test filters and geolocation -5. ⏳ Verify contact form sends email -6. ⏳ Deploy to production after validation +### Deployment Status +- ✅ Deployed to staging +- ✅ Map loads with markers and clustering +- ✅ Filters working (state, certification, format) +- ✅ Contact form functional +- ✅ Deployed to production --- diff --git a/assets/js/find-training-filters.js b/assets/js/find-training-filters.js index 23638985..a24c5a6f 100644 --- a/assets/js/find-training-filters.js +++ b/assets/js/find-training-filters.js @@ -36,6 +36,7 @@ */ init: function() { this.bindEvents(); + this.initMobileFilterToggle(); }, /** @@ -133,12 +134,11 @@ // Update map data HVACTrainingMap.trainers = response.data.trainers || []; HVACTrainingMap.venues = response.data.venues || []; + HVACTrainingMap.visibleTrainers = HVACTrainingMap.trainers.slice(); // Reset to all HVACTrainingMap.updateMarkers(); - HVACTrainingMap.updateCounts( - response.data.total_trainers, - response.data.total_venues - ); + HVACTrainingMap.updateCounts(HVACTrainingMap.trainers.length); HVACTrainingMap.updateTrainerGrid(); + // Note: syncSidebarWithViewport will be called by map 'idle' event } }, complete: function() { @@ -375,6 +375,66 @@ 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()); + }); } }; diff --git a/assets/js/find-training-map.js b/assets/js/find-training-map.js index 695189d3..47ee79a0 100644 --- a/assets/js/find-training-map.js +++ b/assets/js/find-training-map.js @@ -31,6 +31,9 @@ trainers: [], venues: [], + // Visible trainers (filtered by map bounds) + visibleTrainers: [], + // Configuration config: { mapElementId: 'hvac-training-map', @@ -45,21 +48,29 @@ init: function() { const self = this; + // Check if API key is configured + if (typeof hvacFindTraining === 'undefined' || !hvacFindTraining.api_key_configured) { + console.warn('Google Maps API key not configured'); + // Still load trainer directory data + this.loadTrainerDirectory(); + return; + } + // Check if Google Maps is loaded if (typeof google === 'undefined' || typeof google.maps === 'undefined') { console.error('Google Maps API not loaded'); this.showMapError('Google Maps failed to load. Please refresh the page.'); + // Still load trainer directory data + this.loadTrainerDirectory(); return; } // Override config with localized data - if (typeof hvacFindTraining !== 'undefined') { - if (hvacFindTraining.map_center) { - this.config.defaultCenter = hvacFindTraining.map_center; - } - if (hvacFindTraining.default_zoom) { - this.config.defaultZoom = parseInt(hvacFindTraining.default_zoom); - } + if (hvacFindTraining.map_center) { + this.config.defaultCenter = hvacFindTraining.map_center; + } + if (hvacFindTraining.default_zoom) { + this.config.defaultZoom = parseInt(hvacFindTraining.default_zoom); } // Create the map @@ -73,6 +84,36 @@ // Bind events this.bindEvents(); + + // Initialize responsive features + this.handleWindowResize(); + this.initSidebarToggle(); + }, + + /** + * Load just the trainer directory (no map) + */ + loadTrainerDirectory: function() { + const self = this; + + $.ajax({ + url: hvacFindTraining.ajax_url, + type: 'POST', + data: { + action: 'hvac_get_training_map_data', + nonce: hvacFindTraining.nonce + }, + success: function(response) { + if (response.success && response.data) { + self.trainers = response.data.trainers || []; + self.updateTrainerGrid(self.trainers); + self.updateCounts(self.trainers.length, 0); + } + }, + error: function() { + self.hideLoading(); + } + }); }, /** @@ -110,6 +151,12 @@ this.map.addListener('click', () => { this.infoWindow.close(); }); + + // Sync sidebar with map viewport on pan/zoom + const self = this; + this.map.addListener('idle', function() { + self.syncSidebarWithViewport(); + }); }, /** @@ -157,10 +204,13 @@ if (response.success) { self.trainers = response.data.trainers || []; self.venues = response.data.venues || []; + self.visibleTrainers = self.trainers.slice(); // Initially all trainers are "visible" self.updateMarkers(); - self.updateCounts(response.data.total_trainers, response.data.total_venues); + self.updateCounts(self.trainers.length); self.updateTrainerGrid(); + // Note: syncSidebarWithViewport will be called by map 'idle' event + // to filter trainers to current viewport } else { self.showMapError(response.data?.message || 'Failed to load data'); } @@ -222,14 +272,19 @@ map: this.map, title: trainer.name, icon: this.getTrainerIcon(), - optimized: true + optimized: false // Required for reliable hover events }); // Store trainer data on marker marker.trainerData = trainer; marker.markerType = 'trainer'; - // Add click listener + // Add hover listener to show info window preview + marker.addListener('mouseover', function() { + self.showTrainerInfoWindow(this); + }); + + // Add click listener (also shows info window, for touch devices) marker.addListener('click', function() { self.showTrainerInfoWindow(this); }); @@ -249,14 +304,19 @@ map: this.map, title: venue.name, icon: this.getVenueIcon(), - optimized: true + optimized: false // Required for reliable hover events }); // Store venue data on marker marker.venueData = venue; marker.markerType = 'venue'; - // Add click listener + // Add hover listener to show info window preview + marker.addListener('mouseover', function() { + self.showVenueInfoWindow(this); + }); + + // Add click listener (also shows info window, for touch devices) marker.addListener('click', function() { self.showVenueInfoWindow(this); }); @@ -585,21 +645,30 @@ const $grid = $('#hvac-trainer-grid'); $grid.empty(); - if (this.trainers.length === 0) { - $grid.html('

No trainers found matching your criteria.

'); + // Use visible trainers if available (viewport sync), otherwise use all trainers + const trainersToShow = this.visibleTrainers.length > 0 || this.map + ? this.visibleTrainers + : this.trainers; + + if (trainersToShow.length === 0) { + const message = this.trainers.length > 0 + ? 'No trainers visible in this area. Zoom out or pan the map to see more.' + : 'No trainers found matching your criteria.'; + $grid.html('

' + message + '

'); + $('.hvac-load-more-wrapper').hide(); return; } - // Display trainers (show first 12, load more on request) - const displayCount = Math.min(this.trainers.length, 12); + // Display trainers (show first 6 for compact sidebar, load more on request) + const displayCount = Math.min(trainersToShow.length, 6); for (let i = 0; i < displayCount; i++) { - const trainer = this.trainers[i]; + const trainer = trainersToShow[i]; $grid.append(this.createTrainerCard(trainer)); } // Show load more if there are more trainers - if (this.trainers.length > 12) { + if (trainersToShow.length > 6) { $('.hvac-load-more-wrapper').show(); } else { $('.hvac-load-more-wrapper').hide(); @@ -632,10 +701,19 @@ /** * Update counts display + * @param {number} visible - Number of visible trainers (or total if no viewport filtering) + * @param {number} total - Total number of trainers (optional, for "X of Y" format) */ - updateCounts: function(trainers, venues) { - $('#hvac-trainer-count').text(trainers || 0); - $('#hvac-venue-count').text(venues || 0); + updateCounts: function(visible, total) { + const $count = $('#hvac-trainer-count'); + + if (total && total !== visible) { + // Show "X of Y" format when viewport is filtering + $count.text(visible + ' of ' + total); + } else { + // Show just the count + $count.text(visible || 0); + } }, /** @@ -741,17 +819,64 @@ loadMoreTrainers: function() { const $grid = $('#hvac-trainer-grid'); const currentCount = $grid.find('.hvac-trainer-card').length; - const loadMore = 12; + const loadMore = 6; // Load 6 more at a time for sidebar - for (let i = currentCount; i < currentCount + loadMore && i < this.trainers.length; i++) { - $grid.append(this.createTrainerCard(this.trainers[i])); + // Use visible trainers if available (viewport sync), otherwise use all trainers + const trainersToShow = this.visibleTrainers.length > 0 || this.map + ? this.visibleTrainers + : this.trainers; + + for (let i = currentCount; i < currentCount + loadMore && i < trainersToShow.length; i++) { + $grid.append(this.createTrainerCard(trainersToShow[i])); } - if ($grid.find('.hvac-trainer-card').length >= this.trainers.length) { + if ($grid.find('.hvac-trainer-card').length >= trainersToShow.length) { $('.hvac-load-more-wrapper').hide(); } }, + /** + * Handle window resize for map + */ + handleWindowResize: function() { + const self = this; + let resizeTimer; + + window.addEventListener('resize', function() { + clearTimeout(resizeTimer); + resizeTimer = setTimeout(function() { + if (self.map) { + google.maps.event.trigger(self.map, 'resize'); + } + }, 250); + }); + }, + + /** + * Initialize sidebar toggle for mobile + */ + initSidebarToggle: function() { + const self = this; + + $(document).on('click', '.hvac-sidebar-toggle', function() { + const $sidebar = $('.hvac-sidebar'); + const $toggle = $(this); + const isCollapsed = $sidebar.hasClass('collapsed'); + + $sidebar.toggleClass('collapsed'); + + // Update ARIA attributes + $toggle.attr('aria-expanded', isCollapsed ? 'true' : 'false'); + + // Trigger map resize after sidebar animation + setTimeout(function() { + if (self.map) { + google.maps.event.trigger(self.map, 'resize'); + } + }, 300); + }); + }, + /** * Get user's location */ @@ -782,6 +907,36 @@ this.map.setZoom(zoom || 10); }, + /** + * Sync sidebar trainer list with visible map viewport + */ + syncSidebarWithViewport: function() { + if (!this.map || this.trainers.length === 0) { + return; + } + + // Get current map bounds + const bounds = this.map.getBounds(); + if (!bounds) { + return; + } + + // Filter trainers to those visible in current viewport + this.visibleTrainers = this.trainers.filter(trainer => { + if (!trainer.lat || !trainer.lng) { + return false; + } + const position = new google.maps.LatLng(trainer.lat, trainer.lng); + return bounds.contains(position); + }); + + // Update the sidebar grid with visible trainers + this.updateTrainerGrid(); + + // Update count to show visible vs total + this.updateCounts(this.visibleTrainers.length, this.trainers.length); + }, + /** * Show loading state */ diff --git a/includes/class-hvac-page-manager.php b/includes/class-hvac-page-manager.php index f98504b3..8f406373 100644 --- a/includes/class-hvac-page-manager.php +++ b/includes/class-hvac-page-manager.php @@ -31,13 +31,7 @@ class HVAC_Page_Manager { 'parent' => null, 'capability' => null ], - 'find-a-trainer' => [ - 'title' => 'Find a Trainer', - 'template' => 'page-find-trainer.php', - 'public' => true, - 'parent' => null, - 'capability' => null - ], + // Note: find-a-trainer removed - redirects to find-training (see HVAC_Route_Manager) 'find-training' => [ 'title' => 'Find Training', 'template' => 'page-find-training.php', diff --git a/includes/class-hvac-plugin.php b/includes/class-hvac-plugin.php index 0a0b1123..3ed8fe06 100644 --- a/includes/class-hvac-plugin.php +++ b/includes/class-hvac-plugin.php @@ -115,7 +115,7 @@ final class HVAC_Plugin { define('HVAC_PLUGIN_VERSION', '2.0.0'); } if (!defined('HVAC_VERSION')) { - define('HVAC_VERSION', '2.1.7'); + define('HVAC_VERSION', '2.2.4'); } if (!defined('HVAC_PLUGIN_FILE')) { define('HVAC_PLUGIN_FILE', dirname(__DIR__) . '/hvac-community-events.php'); diff --git a/includes/class-hvac-route-manager.php b/includes/class-hvac-route-manager.php index 1c498daa..5b32f9cf 100644 --- a/includes/class-hvac-route-manager.php +++ b/includes/class-hvac-route-manager.php @@ -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