From 17dd3c9bdb8ebd644bc53820f19e97aaccdaf8a3 Mon Sep 17 00:00:00 2001 From: ben Date: Sun, 1 Feb 2026 19:36:17 -0400 Subject: [PATCH] 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 --- assets/css/find-training-map.css | 637 ++++++++++++++++++++++++++++- assets/js/find-training-filters.js | 166 +++++++- assets/js/find-training-map.js | 576 +++++++++++++++++++++++--- templates/page-find-training.php | 146 ++++++- 4 files changed, 1430 insertions(+), 95 deletions(-) diff --git a/assets/css/find-training-map.css b/assets/css/find-training-map.css index 7549737e..fa9496bd 100644 --- a/assets/css/find-training-map.css +++ b/assets/css/find-training-map.css @@ -17,7 +17,9 @@ --hvac-primary-dark: #009688; --hvac-secondary: #164B60; --hvac-secondary-dark: #1a5a73; - --hvac-venue-color: #f5a623; + --hvac-venue-color: #89c92e; + --hvac-trainer-color: #f0f7e8; + --hvac-event-color: #0ebaa6; --hvac-text: #333; --hvac-text-muted: #666; --hvac-border: #e0e0e0; @@ -633,13 +635,18 @@ body .hvac-find-training-page { } .hvac-legend-trainer { - background: var(--hvac-primary); + background: var(--hvac-trainer-color); + border: 2px solid #5a8a1a; } .hvac-legend-venue { background: var(--hvac-venue-color); } +.hvac-legend-event { + background: var(--hvac-event-color); +} + /* Map Toggles Overlay */ .hvac-map-toggles { position: absolute; @@ -1057,6 +1064,103 @@ body .hvac-find-training-page { margin-bottom: 10px; } +/* Event Info Window */ +.hvac-info-window-event { + padding: 12px; + max-width: 280px; +} + +.hvac-info-window-event .hvac-info-window-title { + font-weight: 600; + color: var(--hvac-secondary); + margin-bottom: 6px; + font-size: 15px; +} + +.hvac-info-window-date { + color: var(--hvac-text); + font-size: 13px; + margin-bottom: 4px; +} + +.hvac-info-window-date .dashicons { + font-size: 14px; + width: 14px; + height: 14px; + vertical-align: middle; + margin-right: 4px; + color: var(--hvac-event-color); +} + +.hvac-info-window-venue-name { + color: var(--hvac-text-muted); + font-size: 13px; + margin-bottom: 8px; +} + +.hvac-info-window-cost { + display: inline-block; + padding: 3px 8px; + background: #e8f5f4; + color: #00736a; + border-radius: 4px; + font-size: 12px; + font-weight: 500; + margin-bottom: 10px; +} + +.hvac-info-window-cost.hvac-free { + background: #d1fae5; + color: #065f46; +} + +.hvac-info-window-past-badge { + display: inline-block; + padding: 3px 8px; + background: #f3f4f6; + color: #6b7280; + border-radius: 4px; + font-size: 11px; + font-weight: 500; + margin-left: 6px; +} + +.hvac-info-window-event-link { + display: inline-block; + padding: 8px 16px; + background: var(--hvac-event-color); + color: #fff; + border: none; + border-radius: 4px; + font-size: 13px; + font-weight: 500; + cursor: pointer; + text-decoration: none; +} + +.hvac-info-window-event-link:hover { + background: #0ca696; + color: #fff; +} + +/* Filter checkbox for Include Past Events */ +.hvac-filter-checkbox { + display: flex; + align-items: center; + gap: 6px; + padding: 10px 0; + font-size: 14px; + color: var(--hvac-text); + cursor: pointer; + white-space: nowrap; +} + +.hvac-filter-checkbox input[type="checkbox"] { + width: 16px; + height: 16px; + accent-color: var(--hvac-event-color); +} + /* ========================================================================== Location Error Message ========================================================================== */ @@ -1126,7 +1230,7 @@ body .hvac-find-training-page { /* Collapsible sidebar */ .hvac-sidebar.collapsed { - max-height: 52px; + max-height: 80px; overflow: hidden; } @@ -1156,12 +1260,35 @@ body .hvac-find-training-page { font-size: 12px; } - .hvac-map-toggles { - top: 8px; - left: 8px; - padding: 6px 10px; + /* Tabs on tablet */ + .hvac-sidebar-header { + flex-direction: row; + align-items: center; + flex-wrap: wrap; + gap: 8px; + } + + .hvac-sidebar-tabs { + flex: 1; + min-width: 0; + margin: 0; + padding: 0; + } + + .hvac-tab { + padding: 8px 4px; font-size: 12px; } + + .hvac-visibility-toggles { + gap: 8px; + padding: 4px 0; + } + + .hvac-toggle-dot { + width: 14px; + height: 14px; + } } /* ========================================================================== @@ -1218,30 +1345,66 @@ body .hvac-find-training-page { font-size: 13px; } + .hvac-info-btn { + width: 32px; + height: 32px; + } + /* Sidebar adjustments */ .hvac-sidebar-header { - padding: 12px 14px; + padding: 10px 12px; } .hvac-sidebar-content { padding: 12px; } - .hvac-trainer-card { + /* Tab adjustments */ + .hvac-sidebar-tabs { + margin: 0 -12px; + padding: 0 12px; + } + + .hvac-tab { + padding: 6px 2px; + font-size: 11px; + } + + .hvac-visibility-toggles { + display: none; + } + + .hvac-trainer-card, + .hvac-venue-card, + .hvac-event-card { padding: 12px; } - .hvac-trainer-card-image { - width: 48px; - height: 48px; + .hvac-trainer-card-image, + .hvac-venue-card-icon, + .hvac-event-card-date { + width: 44px; + height: 44px; } - .hvac-trainer-card-name { - font-size: 14px; + .hvac-trainer-card-name, + .hvac-venue-card-name, + .hvac-event-card-title { + font-size: 13px; } - .hvac-trainer-card-location { - font-size: 12px; + .hvac-trainer-card-location, + .hvac-venue-card-location, + .hvac-event-card-venue { + font-size: 11px; + } + + .hvac-event-card-month { + font-size: 9px; + } + + .hvac-event-card-day { + font-size: 16px; } /* Modal adjustments */ @@ -1477,6 +1640,448 @@ body .hvac-find-training-page { } } +/* ========================================================================== + Sidebar Tabs + ========================================================================== */ + +.hvac-sidebar-header { + flex-direction: column; + align-items: stretch; + gap: 10px; +} + +.hvac-sidebar-tabs { + display: flex; + gap: 0; + border-bottom: 2px solid var(--hvac-border); + margin: 0 -16px; + padding: 0 16px; +} + +.hvac-tab { + flex: 1; + padding: 10px 8px; + background: transparent; + border: none; + border-bottom: 2px solid transparent; + margin-bottom: -2px; + font-size: 13px; + font-weight: 500; + color: var(--hvac-text-muted); + cursor: pointer; + transition: all 0.2s; + white-space: nowrap; +} + +.hvac-tab:hover { + color: var(--hvac-secondary); + background: #f5f5f5; +} + +.hvac-tab.active { + color: var(--hvac-primary); + border-bottom-color: var(--hvac-primary); +} + +.hvac-tab:focus { + outline: none; + box-shadow: inset 0 0 0 2px rgba(0, 179, 164, 0.3); +} + +.hvac-tab [data-count] { + font-weight: 600; +} + +/* Visibility Toggles */ +.hvac-visibility-toggles { + display: flex; + align-items: center; + gap: 12px; + padding: 8px 0; +} + +.hvac-visibility-toggle { + display: flex; + align-items: center; + cursor: pointer; +} + +.hvac-visibility-toggle input { + position: absolute; + opacity: 0; + width: 0; + height: 0; +} + +.hvac-toggle-dot { + width: 18px; + height: 18px; + border-radius: 50%; + border: 2px solid; + transition: all 0.2s; +} + +.hvac-toggle-trainer { + background: var(--hvac-trainer-color); + border-color: #5a8a1a; +} + +.hvac-toggle-venue { + background: var(--hvac-venue-color); + border-color: #6fa024; +} + +.hvac-toggle-event { + background: var(--hvac-event-color); + border-color: #0a9a8a; +} + +.hvac-visibility-toggle input:not(:checked) + .hvac-toggle-dot { + background: #f5f5f5; + border-color: #ccc; +} + +.hvac-visibility-toggle:hover .hvac-toggle-dot { + transform: scale(1.1); +} + +/* Tab Panels */ +.hvac-tab-panel { + display: none; +} + +.hvac-tab-panel.active { + display: block; +} + +.hvac-item-list { + display: flex; + flex-direction: column; + gap: 12px; +} + +/* ========================================================================== + Venue Cards + ========================================================================== */ + +.hvac-venue-card { + background: #fff; + border: 1px solid var(--hvac-border); + border-radius: 8px; + padding: 14px; + cursor: pointer; + transition: border-color 0.2s, box-shadow 0.2s; + display: flex; + gap: 12px; +} + +.hvac-venue-card:hover { + border-color: var(--hvac-venue-color); + box-shadow: 0 2px 8px rgba(137, 201, 46, 0.15); +} + +.hvac-venue-card-icon { + width: 48px; + height: 48px; + flex-shrink: 0; + background: #f0f9e8; + border-radius: 8px; + display: flex; + align-items: center; + justify-content: center; +} + +.hvac-venue-card-icon .dashicons { + font-size: 24px; + width: 24px; + height: 24px; + color: var(--hvac-venue-color); +} + +.hvac-venue-card-info { + flex: 1; + min-width: 0; +} + +.hvac-venue-card-name { + font-weight: 600; + color: var(--hvac-secondary); + margin-bottom: 3px; + font-size: 14px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.hvac-venue-card-location { + color: var(--hvac-text-muted); + font-size: 12px; + margin-bottom: 4px; +} + +.hvac-venue-card-events { + font-size: 12px; + color: var(--hvac-venue-color); + font-weight: 500; +} + +/* ========================================================================== + Event Cards + ========================================================================== */ + +.hvac-event-card { + background: #fff; + border: 1px solid var(--hvac-border); + border-radius: 8px; + padding: 14px; + cursor: pointer; + transition: border-color 0.2s, box-shadow 0.2s; + display: flex; + gap: 12px; +} + +.hvac-event-card:hover { + border-color: var(--hvac-event-color); + box-shadow: 0 2px 8px rgba(14, 186, 166, 0.15); +} + +.hvac-event-card.hvac-event-past { + opacity: 0.7; +} + +.hvac-event-card-date { + width: 48px; + flex-shrink: 0; + background: var(--hvac-event-color); + border-radius: 8px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 6px; + color: #fff; +} + +.hvac-event-card.hvac-event-past .hvac-event-card-date { + background: #9ca3af; +} + +.hvac-event-card-month { + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.hvac-event-card-day { + font-size: 18px; + font-weight: 700; + line-height: 1; +} + +.hvac-event-card-info { + flex: 1; + min-width: 0; +} + +.hvac-event-card-title { + font-weight: 600; + color: var(--hvac-secondary); + margin-bottom: 3px; + font-size: 14px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.hvac-event-card-venue { + color: var(--hvac-text-muted); + font-size: 12px; + margin-bottom: 4px; +} + +.hvac-event-card-meta { + display: flex; + align-items: center; + gap: 8px; +} + +.hvac-event-card-cost { + font-size: 12px; + font-weight: 600; + color: var(--hvac-primary); +} + +.hvac-event-card-cost.hvac-free { + color: #059669; +} + +.hvac-event-card-past-badge { + font-size: 10px; + padding: 2px 6px; + background: #f3f4f6; + color: #6b7280; + border-radius: 3px; + font-weight: 500; +} + +/* ========================================================================== + Info Button & Modal + ========================================================================== */ + +.hvac-info-btn { + display: flex; + align-items: center; + justify-content: center; + width: 36px; + height: 36px; + background: #f5f5f5; + border: 1px solid var(--hvac-border); + border-radius: 50%; + cursor: pointer; + transition: all 0.2s; + flex-shrink: 0; +} + +.hvac-info-btn:hover { + background: var(--hvac-primary); + border-color: var(--hvac-primary); + color: #fff; +} + +.hvac-info-btn .dashicons { + font-size: 18px; + width: 18px; + height: 18px; + color: var(--hvac-text-muted); +} + +.hvac-info-btn:hover .dashicons { + color: #fff; +} + +/* Info Modal Content */ +.hvac-info-modal-content { + max-width: 560px; +} + +.hvac-info-modal-header { + padding: 24px 24px 0; + position: relative; +} + +.hvac-info-modal-header h2 { + color: var(--hvac-secondary); + font-size: 1.5rem; + margin: 0; + padding-right: 40px; +} + +.hvac-info-modal-body { + padding: 24px; +} + +.hvac-info-section { + margin-bottom: 24px; +} + +.hvac-info-section:last-child { + margin-bottom: 0; +} + +.hvac-info-section h3 { + font-size: 1rem; + font-weight: 600; + color: var(--hvac-secondary); + margin: 0 0 12px; +} + +.hvac-info-section p { + font-size: 14px; + line-height: 1.6; + color: var(--hvac-text); + margin: 0; +} + +.hvac-info-list { + list-style: none; + padding: 0; + margin: 0; +} + +.hvac-info-list li { + font-size: 14px; + line-height: 1.5; + color: var(--hvac-text); + padding: 8px 0; + border-bottom: 1px solid #f3f4f6; +} + +.hvac-info-list li:last-child { + border-bottom: none; +} + +.hvac-info-list li strong { + color: var(--hvac-secondary); +} + +/* Info Modal Legend */ +.hvac-info-legend { + display: flex; + flex-direction: column; + gap: 12px; +} + +.hvac-info-legend-item { + display: flex; + align-items: flex-start; + gap: 12px; + padding: 10px; + background: #f9fafb; + border-radius: 8px; +} + +.hvac-info-legend-item .hvac-legend-marker { + margin-top: 4px; + flex-shrink: 0; +} + +.hvac-info-legend-item strong { + display: block; + font-size: 14px; + color: var(--hvac-secondary); + margin-bottom: 2px; +} + +.hvac-info-legend-item p { + font-size: 13px; + color: var(--hvac-text-muted); + margin: 0; +} + +/* ========================================================================== + Empty States + ========================================================================== */ + +.hvac-empty-state { + text-align: center; + padding: 40px 20px; + color: var(--hvac-text-muted); +} + +.hvac-empty-state .dashicons { + font-size: 48px; + width: 48px; + height: 48px; + color: #ddd; + margin-bottom: 12px; +} + +.hvac-empty-state p { + font-size: 14px; + margin: 0; +} + /* ========================================================================== Print Styles ========================================================================== */ diff --git a/assets/js/find-training-filters.js b/assets/js/find-training-filters.js index a24c5a6f..6b3f9961 100644 --- a/assets/js/find-training-filters.js +++ b/assets/js/find-training-filters.js @@ -25,7 +25,8 @@ state: '', certification: '', training_format: '', - search: '' + search: '', + include_past: false }, // User location (if obtained) @@ -45,15 +46,22 @@ bindEvents: function() { const self = this; - // Search input with debounce + // Search input with debounce - client-side filtering for instant results $('#hvac-training-search').on('input', function() { clearTimeout(self.searchTimer); - const value = $(this).val(); + const value = $(this).val().toLowerCase().trim(); self.searchTimer = setTimeout(function() { self.activeFilters.search = value; - self.applyFilters(); - }, 300); + + // 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 @@ -77,6 +85,22 @@ 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)); @@ -114,7 +138,9 @@ 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_venues: $('#hvac-show-venues').is(':checked'), + show_events: $('#hvac-show-events').is(':checked'), + include_past: this.activeFilters.include_past }; // Add user location if available @@ -134,10 +160,25 @@ // Update map data HVACTrainingMap.trainers = response.data.trainers || []; HVACTrainingMap.venues = response.data.venues || []; - HVACTrainingMap.visibleTrainers = HVACTrainingMap.trainers.slice(); // Reset to all + 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(); - HVACTrainingMap.updateCounts(HVACTrainingMap.trainers.length); - HVACTrainingMap.updateTrainerGrid(); + + // Update all counts + HVACTrainingMap.updateAllCounts(); + + // Render the active tab + HVACTrainingMap.renderActiveTabList(); + // Note: syncSidebarWithViewport will be called by map 'idle' event } }, @@ -235,7 +276,8 @@ state: '', certification: '', training_format: '', - search: '' + search: '', + include_past: false }; // Reset user location @@ -246,6 +288,8 @@ $('#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') @@ -295,6 +339,11 @@ .html(' Near Me') .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(); @@ -333,6 +382,11 @@ this.addActiveFilter('location', 'Near Me'); } + // Include past events filter + if (this.activeFilters.include_past) { + this.addActiveFilter('include_past', 'Including Past Events'); + } + this.updateClearButtonVisibility(); }, @@ -358,6 +412,7 @@ this.activeFilters.certification || this.activeFilters.training_format || this.activeFilters.search || + this.activeFilters.include_past || this.userLocation; if (hasFilters) { @@ -367,6 +422,97 @@ } }, + /** + * 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 */ diff --git a/assets/js/find-training-map.js b/assets/js/find-training-map.js index 76848415..70be8088 100644 --- a/assets/js/find-training-map.js +++ b/assets/js/find-training-map.js @@ -20,6 +20,7 @@ // Marker collections trainerMarkers: [], venueMarkers: [], + eventMarkers: [], // MarkerClusterer instance markerClusterer: null, @@ -30,9 +31,23 @@ // Current data trainers: [], venues: [], + events: [], - // Visible trainers (filtered by map bounds) + // Visible items (filtered by map bounds) visibleTrainers: [], + visibleVenues: [], + visibleEvents: [], + + // Active tab + activeTab: 'trainers', + + // Items per page for load more + itemsPerPage: 6, + displayedCounts: { + trainers: 0, + venues: 0, + events: 0 + }, // Configuration config: { @@ -51,6 +66,8 @@ // Check if API key is configured if (typeof hvacFindTraining === 'undefined' || !hvacFindTraining.api_key_configured) { console.warn('Google Maps API key not configured'); + // Initialize tabs even without map + this.initTabs(); // Still load trainer directory data this.loadTrainerDirectory(); return; @@ -60,6 +77,8 @@ 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.'); + // Initialize tabs even without map + this.initTabs(); // Still load trainer directory data this.loadTrainerDirectory(); return; @@ -85,6 +104,9 @@ // Bind events this.bindEvents(); + // Initialize tabs + this.initTabs(); + // Initialize responsive features this.handleWindowResize(); this.initSidebarToggle(); @@ -106,8 +128,16 @@ success: function(response) { if (response.success && response.data) { self.trainers = response.data.trainers || []; - self.updateTrainerGrid(self.trainers); - self.updateCounts(self.trainers.length, 0); + self.venues = response.data.venues || []; + self.events = response.data.events || []; + + self.visibleTrainers = self.trainers.slice(); + self.visibleVenues = self.venues.slice(); + self.visibleEvents = self.events.slice(); + + self.displayedCounts = { trainers: 0, venues: 0, events: 0 }; + self.updateAllCounts(); + self.renderActiveTabList(); } }, error: function() { @@ -204,13 +234,21 @@ if (response.success) { self.trainers = response.data.trainers || []; self.venues = response.data.venues || []; - self.visibleTrainers = self.trainers.slice(); // Initially all trainers are "visible" + self.events = response.data.events || []; + + // Initially all items are "visible" + self.visibleTrainers = self.trainers.slice(); + self.visibleVenues = self.venues.slice(); + self.visibleEvents = self.events.slice(); + + // Reset displayed counts + self.displayedCounts = { trainers: 0, venues: 0, events: 0 }; self.updateMarkers(); - self.updateCounts(self.trainers.length); - self.updateTrainerGrid(); + self.updateAllCounts(); + self.renderActiveTabList(); // Note: syncSidebarWithViewport will be called by map 'idle' event - // to filter trainers to current viewport + // to filter items to current viewport } else { self.showMapError(response.data?.message || 'Failed to load data'); } @@ -234,6 +272,7 @@ // Check toggle states const showTrainers = $('#hvac-show-trainers').is(':checked'); const showVenues = $('#hvac-show-venues').is(':checked'); + const showEvents = $('#hvac-show-events').is(':checked'); // Add trainer markers if (showTrainers && this.trainers.length > 0) { @@ -253,6 +292,15 @@ }); } + // Add event markers + if (showEvents && this.events.length > 0) { + this.events.forEach(event => { + if (event.lat && event.lng) { + this.addEventMarker(event); + } + }); + } + // Initialize clustering this.initClustering(); @@ -324,6 +372,38 @@ this.venueMarkers.push(marker); }, + /** + * Add an event marker + */ + addEventMarker: function(event) { + const self = this; + + // Create marker with custom icon + const marker = new google.maps.Marker({ + position: { lat: event.lat, lng: event.lng }, + map: this.map, + title: event.title, + icon: this.getEventIcon(), + optimized: false // Required for reliable hover events + }); + + // Store event data on marker + marker.eventData = event; + marker.markerType = 'event'; + + // Add hover listener to show info window preview + marker.addListener('mouseover', function() { + self.showEventInfoWindow(this); + }); + + // Add click listener (also shows info window, for touch devices) + marker.addListener('click', function() { + self.showEventInfoWindow(this); + }); + + this.eventMarkers.push(marker); + }, + /** * Get trainer marker icon */ @@ -336,12 +416,12 @@ }; } - // SVG circle marker (teal) + // SVG circle marker (light green with dark outline) return { path: google.maps.SymbolPath.CIRCLE, - fillColor: '#00b3a4', + fillColor: '#f0f7e8', fillOpacity: 1, - strokeColor: '#ffffff', + strokeColor: '#5a8a1a', strokeWeight: 2, scale: 10 }; @@ -359,10 +439,10 @@ }; } - // SVG marker (orange) + // SVG marker (green for mQ Approved) return { path: google.maps.SymbolPath.BACKWARD_CLOSED_ARROW, - fillColor: '#f5a623', + fillColor: '#89c92e', fillOpacity: 1, strokeColor: '#ffffff', strokeWeight: 2, @@ -370,6 +450,29 @@ }; }, + /** + * Get event marker icon + */ + getEventIcon: function() { + // Use custom icon if available, otherwise use SVG circle + if (hvacFindTraining.marker_icons?.event) { + return { + url: hvacFindTraining.marker_icons.event, + scaledSize: new google.maps.Size(32, 32) + }; + } + + // SVG circle marker (teal for events) + return { + path: google.maps.SymbolPath.CIRCLE, + fillColor: '#0ebaa6', + fillOpacity: 1, + strokeColor: '#ffffff', + strokeWeight: 2, + scale: 8 + }; + }, + /** * Initialize marker clustering */ @@ -380,7 +483,7 @@ } // Combine all markers - const allMarkers = [...this.trainerMarkers, ...this.venueMarkers]; + const allMarkers = [...this.trainerMarkers, ...this.venueMarkers, ...this.eventMarkers]; if (allMarkers.length === 0) { return; @@ -410,6 +513,10 @@ this.venueMarkers.forEach(marker => marker.setMap(null)); this.venueMarkers = []; + // Clear event markers + this.eventMarkers.forEach(marker => marker.setMap(null)); + this.eventMarkers = []; + // Clear clusterer if (this.markerClusterer) { this.markerClusterer.clearMarkers(); @@ -420,7 +527,7 @@ * Fit map bounds to show all markers */ fitBounds: function() { - const allMarkers = [...this.trainerMarkers, ...this.venueMarkers]; + const allMarkers = [...this.trainerMarkers, ...this.venueMarkers, ...this.eventMarkers]; if (allMarkers.length === 0) { // Reset to default view @@ -528,6 +635,87 @@ this.infoWindow.open(this.map, marker); }, + /** + * Show event info window + */ + showEventInfoWindow: function(marker) { + const event = marker.eventData; + + // Build date/time string + let dateTimeStr = event.start_date; + if (event.end_date && event.end_date !== event.start_date) { + dateTimeStr += ' - ' + event.end_date; + } + if (event.is_all_day) { + dateTimeStr += ' (All Day)'; + } else if (event.start_time) { + dateTimeStr += ' at ' + event.start_time; + } + + // Build venue location string + const venueLocation = [event.venue_city, event.venue_state].filter(Boolean).join(', '); + + // Create DOM elements safely to avoid XSS + const container = document.createElement('div'); + container.className = 'hvac-info-window-event'; + + const title = document.createElement('div'); + title.className = 'hvac-info-window-title'; + title.textContent = event.title; + container.appendChild(title); + + const dateDiv = document.createElement('div'); + dateDiv.className = 'hvac-info-window-date'; + const calIcon = document.createElement('span'); + calIcon.className = 'dashicons dashicons-calendar-alt'; + dateDiv.appendChild(calIcon); + dateDiv.appendChild(document.createTextNode(' ' + dateTimeStr)); + container.appendChild(dateDiv); + + if (event.venue_name) { + const venueDiv = document.createElement('div'); + venueDiv.className = 'hvac-info-window-venue-name'; + venueDiv.textContent = event.venue_name; + if (venueLocation) { + venueDiv.textContent += ' (' + venueLocation + ')'; + } + container.appendChild(venueDiv); + } + + // Cost badge + const costSpan = document.createElement('span'); + costSpan.className = 'hvac-info-window-cost'; + if (event.cost === 'Free' || event.cost.toLowerCase() === 'free') { + costSpan.classList.add('hvac-free'); + } + costSpan.textContent = event.cost; + container.appendChild(costSpan); + + // Past event badge + if (event.is_past) { + const pastBadge = document.createElement('span'); + pastBadge.className = 'hvac-info-window-past-badge'; + pastBadge.textContent = 'Past Event'; + container.appendChild(pastBadge); + } + + // Line break before link + container.appendChild(document.createElement('br')); + container.appendChild(document.createElement('br')); + + // View event link (opens in new tab) + const link = document.createElement('a'); + link.className = 'hvac-info-window-event-link'; + link.href = event.url; + link.target = '_blank'; + link.rel = 'noopener'; + link.textContent = 'View Event Details'; + container.appendChild(link); + + this.infoWindow.setContent(container); + this.infoWindow.open(this.map, marker); + }, + /** * Open trainer profile modal */ @@ -779,21 +967,21 @@ 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 + '

'); + $grid.html(`

${message}

`); $('.hvac-load-more-wrapper').hide(); return; } - // Display trainers (show first 6 for compact sidebar, load more on request) - const displayCount = Math.min(trainersToShow.length, 6); + // Display trainers (show first batch for compact sidebar, load more on request) + const displayCount = Math.min(trainersToShow.length, this.displayedCounts.trainers + this.itemsPerPage); + this.displayedCounts.trainers = displayCount; for (let i = 0; i < displayCount; i++) { - const trainer = trainersToShow[i]; - $grid.append(this.createTrainerCard(trainer)); + $grid.append(this.createTrainerCard(trainersToShow[i])); } // Show load more if there are more trainers - if (trainersToShow.length > 6) { + if (trainersToShow.length > displayCount) { $('.hvac-load-more-wrapper').show(); } else { $('.hvac-load-more-wrapper').hide(); @@ -825,20 +1013,13 @@ }, /** - * Update counts display + * Update counts display (legacy method - now uses updateAllCounts) * @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(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); - } + // Update all tab counts + this.updateAllCounts(); }, /** @@ -855,6 +1036,22 @@ } }); + // Venue card click + $(document).on('click', '.hvac-venue-card', function() { + const venueId = $(this).data('venue-id'); + if (venueId) { + self.openVenueModal(venueId); + } + }); + + // Event card click - open in new tab + $(document).on('click', '.hvac-event-card', function() { + const eventUrl = $(this).data('event-url'); + if (eventUrl) { + window.open(eventUrl, '_blank', 'noopener'); + } + }); + // Modal close $(document).on('click', '.hvac-modal-close, .hvac-modal-overlay', function() { $('.hvac-training-modal').hide(); @@ -868,7 +1065,7 @@ }); // Marker toggles - $('#hvac-show-trainers, #hvac-show-venues').on('change', function() { + $('#hvac-show-trainers, #hvac-show-venues, #hvac-show-events').on('change', function() { self.updateMarkers(); }); @@ -939,23 +1136,46 @@ }, /** - * Load more trainers + * Load more items for the active tab */ loadMoreTrainers: function() { - const $grid = $('#hvac-trainer-grid'); - const currentCount = $grid.find('.hvac-trainer-card').length; - const loadMore = 6; // Load 6 more at a time for sidebar + switch (this.activeTab) { + case 'trainers': + this.loadMoreForTab('trainers', this.visibleTrainers.length > 0 ? this.visibleTrainers : this.trainers, '#hvac-trainer-grid', '.hvac-trainer-card'); + break; + case 'venues': + this.loadMoreForTab('venues', this.visibleVenues.length > 0 ? this.visibleVenues : this.venues, '#hvac-venue-grid', '.hvac-venue-card'); + break; + case 'events': + this.loadMoreForTab('events', this.visibleEvents.length > 0 ? this.visibleEvents : this.events, '#hvac-event-grid', '.hvac-event-card'); + break; + } + }, - // Use visible trainers if available (viewport sync), otherwise use all trainers - const trainersToShow = this.visibleTrainers.length > 0 || this.map - ? this.visibleTrainers - : this.trainers; + /** + * Load more items for a specific tab + */ + loadMoreForTab: function(tabType, items, gridSelector, cardSelector) { + const $grid = $(gridSelector); + const currentCount = $grid.find(cardSelector).length; - for (let i = currentCount; i < currentCount + loadMore && i < trainersToShow.length; i++) { - $grid.append(this.createTrainerCard(trainersToShow[i])); + for (let i = currentCount; i < currentCount + this.itemsPerPage && i < items.length; i++) { + switch (tabType) { + case 'trainers': + $grid.append(this.createTrainerCard(items[i])); + break; + case 'venues': + $grid.append(this.createVenueCard(items[i])); + break; + case 'events': + $grid.append(this.createEventCard(items[i])); + break; + } } - if ($grid.find('.hvac-trainer-card').length >= trainersToShow.length) { + this.displayedCounts[tabType] = $grid.find(cardSelector).length; + + if ($grid.find(cardSelector).length >= items.length) { $('.hvac-load-more-wrapper').hide(); } }, @@ -1033,10 +1253,10 @@ }, /** - * Sync sidebar trainer list with visible map viewport + * Sync sidebar lists with visible map viewport */ syncSidebarWithViewport: function() { - if (!this.map || this.trainers.length === 0) { + if (!this.map) { return; } @@ -1055,11 +1275,32 @@ return bounds.contains(position); }); - // Update the sidebar grid with visible trainers - this.updateTrainerGrid(); + // Filter venues to those visible in current viewport + this.visibleVenues = this.venues.filter(venue => { + if (!venue.lat || !venue.lng) { + return false; + } + const position = new google.maps.LatLng(venue.lat, venue.lng); + return bounds.contains(position); + }); - // Update count to show visible vs total - this.updateCounts(this.visibleTrainers.length, this.trainers.length); + // Filter events to those visible in current viewport + this.visibleEvents = this.events.filter(event => { + if (!event.lat || !event.lng) { + return false; + } + const position = new google.maps.LatLng(event.lat, event.lng); + return bounds.contains(position); + }); + + // Reset displayed counts when viewport changes + this.displayedCounts = { trainers: 0, venues: 0, events: 0 }; + + // Update all counts + this.updateAllCounts(); + + // Render the active tab's list + this.renderActiveTabList(); }, /** @@ -1089,6 +1330,245 @@ `); }, + /** + * Initialize tab navigation + */ + initTabs: function() { + const self = this; + + // Tab click handlers + $(document).on('click', '.hvac-tab', function() { + const tab = $(this).data('tab'); + self.switchTab(tab); + }); + + // Keyboard navigation for tabs + $(document).on('keydown', '.hvac-tab', function(e) { + const $tabs = $('.hvac-tab'); + const currentIndex = $tabs.index(this); + let newIndex = currentIndex; + + if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') { + newIndex = currentIndex > 0 ? currentIndex - 1 : $tabs.length - 1; + e.preventDefault(); + } else if (e.key === 'ArrowRight' || e.key === 'ArrowDown') { + newIndex = currentIndex < $tabs.length - 1 ? currentIndex + 1 : 0; + e.preventDefault(); + } else if (e.key === 'Home') { + newIndex = 0; + e.preventDefault(); + } else if (e.key === 'End') { + newIndex = $tabs.length - 1; + e.preventDefault(); + } + + if (newIndex !== currentIndex) { + $tabs.eq(newIndex).focus().click(); + } + }); + + // Info button handler + $(document).on('click', '#hvac-info-btn', function() { + $('#hvac-info-modal').show(); + }); + + // Info modal close + $('#hvac-info-modal').on('click', '.hvac-modal-close, .hvac-modal-overlay', function() { + $('#hvac-info-modal').hide(); + }); + }, + + /** + * Switch to a different tab + */ + switchTab: function(tab) { + if (tab === this.activeTab) return; + + this.activeTab = tab; + + // Update tab buttons + $('.hvac-tab').removeClass('active').attr('aria-selected', 'false'); + $(`.hvac-tab[data-tab="${tab}"]`).addClass('active').attr('aria-selected', 'true'); + + // Update panels + $('.hvac-tab-panel').removeClass('active').attr('hidden', ''); + $(`#hvac-panel-${tab}`).addClass('active').removeAttr('hidden'); + + // Update search placeholder + this.updateSearchPlaceholder(tab); + + // Reset displayed count for current tab + this.displayedCounts[tab] = 0; + + // Render active tab list + this.renderActiveTabList(); + }, + + /** + * Update search placeholder based on active tab + */ + updateSearchPlaceholder: function(tab) { + const placeholders = { + trainers: 'Search trainers...', + venues: 'Search venues...', + events: 'Search events...' + }; + $('#hvac-training-search').attr('placeholder', placeholders[tab] || 'Search...'); + }, + + /** + * Render the active tab's list + */ + renderActiveTabList: function() { + switch (this.activeTab) { + case 'trainers': + this.updateTrainerGrid(); + break; + case 'venues': + this.updateVenueGrid(); + break; + case 'events': + this.updateEventGrid(); + break; + } + }, + + /** + * Update venue grid + */ + updateVenueGrid: function() { + const $grid = $('#hvac-venue-grid'); + $grid.empty(); + + const venuesToShow = this.visibleVenues.length > 0 || this.map + ? this.visibleVenues + : this.venues; + + if (venuesToShow.length === 0) { + const message = this.venues.length > 0 + ? 'No venues visible in this area. Zoom out or pan the map to see more.' + : 'No venues found matching your criteria.'; + $grid.html(`

${message}

`); + $('.hvac-load-more-wrapper').hide(); + return; + } + + const displayCount = Math.min(venuesToShow.length, this.displayedCounts.venues + this.itemsPerPage); + this.displayedCounts.venues = displayCount; + + for (let i = 0; i < displayCount; i++) { + $grid.append(this.createVenueCard(venuesToShow[i])); + } + + if (venuesToShow.length > displayCount) { + $('.hvac-load-more-wrapper').show(); + } else { + $('.hvac-load-more-wrapper').hide(); + } + }, + + /** + * Create venue card HTML + */ + createVenueCard: function(venue) { + const location = [venue.city, venue.state].filter(Boolean).join(', '); + const eventsText = venue.upcoming_events > 0 + ? `${venue.upcoming_events} upcoming event${venue.upcoming_events > 1 ? 's' : ''}` + : 'No upcoming events'; + + return ` +
+
+ +
+
+
${this.escapeHtml(venue.name)}
+
${this.escapeHtml(location)}
+
${eventsText}
+
+
+ `; + }, + + /** + * Update event grid + */ + updateEventGrid: function() { + const $grid = $('#hvac-event-grid'); + $grid.empty(); + + const eventsToShow = this.visibleEvents.length > 0 || this.map + ? this.visibleEvents + : this.events; + + if (eventsToShow.length === 0) { + const message = this.events.length > 0 + ? 'No events visible in this area. Zoom out or pan the map to see more.' + : 'No events found matching your criteria.'; + $grid.html(`

${message}

`); + $('.hvac-load-more-wrapper').hide(); + return; + } + + const displayCount = Math.min(eventsToShow.length, this.displayedCounts.events + this.itemsPerPage); + this.displayedCounts.events = displayCount; + + for (let i = 0; i < displayCount; i++) { + $grid.append(this.createEventCard(eventsToShow[i])); + } + + if (eventsToShow.length > displayCount) { + $('.hvac-load-more-wrapper').show(); + } else { + $('.hvac-load-more-wrapper').hide(); + } + }, + + /** + * Create event card HTML + */ + createEventCard: function(event) { + // Parse date for month/day display + const dateObj = new Date(event.start_date); + const month = dateObj.toLocaleString('en-US', { month: 'short' }).toUpperCase(); + const day = dateObj.getDate(); + + const venueText = event.venue_name || ''; + const costClass = (event.cost === 'Free' || event.cost.toLowerCase() === 'free') ? 'hvac-free' : ''; + const pastClass = event.is_past ? 'hvac-event-past' : ''; + const pastBadge = event.is_past ? 'Past' : ''; + + return ` +
+
+ ${month} + ${day} +
+
+
${this.escapeHtml(event.title)}
+
${venueText ? 'at ' + this.escapeHtml(venueText) : ''}
+
+ ${this.escapeHtml(event.cost)} + ${pastBadge} +
+
+
+ `; + }, + + /** + * Update all tab counts + */ + updateAllCounts: function() { + const trainerCount = this.visibleTrainers.length || this.trainers.length; + const venueCount = this.visibleVenues.length || this.venues.length; + const eventCount = this.visibleEvents.length || this.events.length; + + $('[data-count="trainers"]').text(trainerCount); + $('[data-count="venues"]').text(venueCount); + $('[data-count="events"]').text(eventCount); + }, + /** * Escape HTML for safe output */ diff --git a/templates/page-find-training.php b/templates/page-find-training.php index 4418000b..f8d8ebf2 100644 --- a/templates/page-find-training.php +++ b/templates/page-find-training.php @@ -33,6 +33,11 @@ $api_key_configured = $find_training->is_api_key_configured(); + + +
+ Include Past + +
+
+ +
-
@@ -302,4 +350,60 @@ $api_key_configured = $find_training->is_api_key_configured(); + + +