/** * Find Training Map - Google Maps Integration * * Handles Google Maps initialization, markers, clustering, * and interaction for the Find Training page. * * @package HVAC_Community_Events * @since 2.2.0 */ (function($) { 'use strict'; // Namespace for the module window.HVACTrainingMap = { // Map instance map: null, // Marker collections trainerMarkers: [], venueMarkers: [], // MarkerClusterer instance markerClusterer: null, // Info window instance (reused) infoWindow: null, // Current data trainers: [], venues: [], // Visible trainers (filtered by map bounds) visibleTrainers: [], // Configuration config: { mapElementId: 'hvac-training-map', defaultCenter: { lat: 39.8283, lng: -98.5795 }, defaultZoom: 4, clusterZoom: 8 }, /** * Initialize the map */ init: function() { const self = this; // Check if 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 (hvacFindTraining.map_center) { this.config.defaultCenter = hvacFindTraining.map_center; } if (hvacFindTraining.default_zoom) { this.config.defaultZoom = parseInt(hvacFindTraining.default_zoom); } // Create the map this.createMap(); // Create info window this.infoWindow = new google.maps.InfoWindow(); // Load initial data this.loadMapData(); // Bind events this.bindEvents(); // 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(); } }); }, /** * Create the Google Map instance */ createMap: function() { const mapElement = document.getElementById(this.config.mapElementId); if (!mapElement) { console.error('Map element not found'); return; } // Remove loading indicator mapElement.innerHTML = ''; // Create map with options this.map = new google.maps.Map(mapElement, { center: this.config.defaultCenter, zoom: this.config.defaultZoom, mapTypeControl: true, mapTypeControlOptions: { position: google.maps.ControlPosition.TOP_RIGHT }, streetViewControl: false, fullscreenControl: true, zoomControl: true, zoomControlOptions: { position: google.maps.ControlPosition.RIGHT_CENTER }, styles: this.getMapStyles() }); // Close info window on map click this.map.addListener('click', () => { this.infoWindow.close(); }); // Sync sidebar with map viewport on pan/zoom const self = this; this.map.addListener('idle', function() { self.syncSidebarWithViewport(); }); }, /** * Get custom map styles */ getMapStyles: function() { return [ { featureType: 'poi', elementType: 'labels', stylers: [{ visibility: 'off' }] }, { featureType: 'transit', elementType: 'labels', stylers: [{ visibility: 'off' }] } ]; }, /** * Load map data via AJAX */ loadMapData: function(filters) { const self = this; const data = { action: 'hvac_get_training_map_data', nonce: hvacFindTraining.nonce }; // Add filters if provided if (filters) { Object.assign(data, filters); } $.ajax({ url: hvacFindTraining.ajax_url, type: 'POST', data: data, beforeSend: function() { self.showLoading(); }, success: function(response) { if (response.success) { self.trainers = response.data.trainers || []; self.venues = response.data.venues || []; self.visibleTrainers = self.trainers.slice(); // Initially all trainers are "visible" self.updateMarkers(); 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'); } }, error: function() { self.showMapError('Network error. Please try again.'); }, complete: function() { self.hideLoading(); } }); }, /** * Update markers on the map */ updateMarkers: function() { // Clear existing markers this.clearMarkers(); // Check toggle states const showTrainers = $('#hvac-show-trainers').is(':checked'); const showVenues = $('#hvac-show-venues').is(':checked'); // Add trainer markers if (showTrainers && this.trainers.length > 0) { this.trainers.forEach(trainer => { if (trainer.lat && trainer.lng) { this.addTrainerMarker(trainer); } }); } // Add venue markers if (showVenues && this.venues.length > 0) { this.venues.forEach(venue => { if (venue.lat && venue.lng) { this.addVenueMarker(venue); } }); } // Initialize clustering this.initClustering(); // Fit bounds if we have markers this.fitBounds(); }, /** * Add a trainer marker */ addTrainerMarker: function(trainer) { const self = this; // Create marker with custom icon const marker = new google.maps.Marker({ position: { lat: trainer.lat, lng: trainer.lng }, map: this.map, title: trainer.name, icon: this.getTrainerIcon(), optimized: false // Required for reliable hover events }); // Store trainer data on marker marker.trainerData = trainer; marker.markerType = 'trainer'; // 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); }); this.trainerMarkers.push(marker); }, /** * Add a venue marker */ addVenueMarker: function(venue) { const self = this; // Create marker with custom icon const marker = new google.maps.Marker({ position: { lat: venue.lat, lng: venue.lng }, map: this.map, title: venue.name, icon: this.getVenueIcon(), optimized: false // Required for reliable hover events }); // Store venue data on marker marker.venueData = venue; marker.markerType = 'venue'; // 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); }); this.venueMarkers.push(marker); }, /** * Get trainer marker icon */ getTrainerIcon: function() { // Use custom icon if available, otherwise use SVG circle if (hvacFindTraining.marker_icons?.trainer) { return { url: hvacFindTraining.marker_icons.trainer, scaledSize: new google.maps.Size(32, 32) }; } // SVG circle marker (teal) return { path: google.maps.SymbolPath.CIRCLE, fillColor: '#00b3a4', fillOpacity: 1, strokeColor: '#ffffff', strokeWeight: 2, scale: 10 }; }, /** * Get venue marker icon */ getVenueIcon: function() { // Use custom icon if available, otherwise use SVG marker if (hvacFindTraining.marker_icons?.venue) { return { url: hvacFindTraining.marker_icons.venue, scaledSize: new google.maps.Size(32, 32) }; } // SVG marker (orange) return { path: google.maps.SymbolPath.BACKWARD_CLOSED_ARROW, fillColor: '#f5a623', fillOpacity: 1, strokeColor: '#ffffff', strokeWeight: 2, scale: 6 }; }, /** * Initialize marker clustering */ initClustering: function() { // Clear existing clusterer if (this.markerClusterer) { this.markerClusterer.clearMarkers(); } // Combine all markers const allMarkers = [...this.trainerMarkers, ...this.venueMarkers]; if (allMarkers.length === 0) { return; } // Check if MarkerClusterer is available if (typeof markerClusterer !== 'undefined' && markerClusterer.MarkerClusterer) { this.markerClusterer = new markerClusterer.MarkerClusterer({ map: this.map, markers: allMarkers, algorithmOptions: { maxZoom: this.config.clusterZoom } }); } }, /** * Clear all markers from the map */ clearMarkers: function() { // Clear trainer markers this.trainerMarkers.forEach(marker => marker.setMap(null)); this.trainerMarkers = []; // Clear venue markers this.venueMarkers.forEach(marker => marker.setMap(null)); this.venueMarkers = []; // Clear clusterer if (this.markerClusterer) { this.markerClusterer.clearMarkers(); } }, /** * Fit map bounds to show all markers */ fitBounds: function() { const allMarkers = [...this.trainerMarkers, ...this.venueMarkers]; if (allMarkers.length === 0) { // Reset to default view this.map.setCenter(this.config.defaultCenter); this.map.setZoom(this.config.defaultZoom); return; } if (allMarkers.length === 1) { // Single marker - center on it this.map.setCenter(allMarkers[0].getPosition()); this.map.setZoom(10); return; } // Calculate bounds const bounds = new google.maps.LatLngBounds(); allMarkers.forEach(marker => { bounds.extend(marker.getPosition()); }); this.map.fitBounds(bounds, { padding: 50 }); }, /** * Show trainer info window */ showTrainerInfoWindow: function(marker) { const self = this; const trainer = marker.trainerData; // Create DOM elements safely to avoid XSS const container = document.createElement('div'); container.className = 'hvac-info-window'; const title = document.createElement('div'); title.className = 'hvac-info-window-title'; title.textContent = trainer.name; container.appendChild(title); const location = document.createElement('div'); location.className = 'hvac-info-window-location'; location.textContent = (trainer.city || '') + ', ' + (trainer.state || ''); container.appendChild(location); if (trainer.certification) { const certBadge = document.createElement('span'); certBadge.className = 'hvac-info-window-cert'; certBadge.textContent = trainer.certification; container.appendChild(certBadge); } const button = document.createElement('button'); button.className = 'hvac-info-window-btn'; button.textContent = 'View Profile'; button.addEventListener('click', function() { self.openTrainerModal(trainer.profile_id); }); container.appendChild(button); this.infoWindow.setContent(container); this.infoWindow.open(this.map, marker); }, /** * Show venue info window */ showVenueInfoWindow: function(marker) { const self = this; const venue = marker.venueData; const eventsText = venue.upcoming_events > 0 ? venue.upcoming_events + ' upcoming event' + (venue.upcoming_events > 1 ? 's' : '') : 'No upcoming events'; // Create DOM elements safely to avoid XSS const container = document.createElement('div'); container.className = 'hvac-info-window-venue'; const title = document.createElement('div'); title.className = 'hvac-info-window-title'; title.textContent = venue.name; container.appendChild(title); const address = document.createElement('div'); address.className = 'hvac-info-window-address'; address.innerHTML = this.escapeHtml(venue.address || '') + '
' + this.escapeHtml(venue.city || '') + ', ' + this.escapeHtml(venue.state || ''); container.appendChild(address); const eventsCount = document.createElement('div'); eventsCount.className = 'hvac-info-window-events-count'; eventsCount.textContent = eventsText; container.appendChild(eventsCount); const button = document.createElement('button'); button.className = 'hvac-info-window-btn'; button.textContent = 'View Details'; button.addEventListener('click', function() { self.openVenueModal(venue.id); }); container.appendChild(button); this.infoWindow.setContent(container); this.infoWindow.open(this.map, marker); }, /** * Open trainer profile modal */ openTrainerModal: function(profileId) { const self = this; const $modal = $('#hvac-trainer-modal'); const $body = $modal.find('.hvac-modal-body'); const $loading = $modal.find('.hvac-modal-loading'); // Show modal with loading $modal.show(); $loading.show(); $body.hide(); // Close info window this.infoWindow.close(); // Fetch profile data $.ajax({ url: hvacFindTraining.ajax_url, type: 'POST', data: { action: 'hvac_get_trainer_profile_modal', nonce: hvacFindTraining.nonce, profile_id: profileId }, success: function(response) { if (response.success) { $body.html(response.data.html); $loading.hide(); $body.show(); self.bindContactForm(); } else { $body.html('

Failed to load profile.

'); $loading.hide(); $body.show(); } }, error: function() { $body.html('

Network error. Please try again.

'); $loading.hide(); $body.show(); } }); }, /** * Open venue details modal */ openVenueModal: function(venueId) { const self = this; const $modal = $('#hvac-venue-modal'); // Close info window this.infoWindow.close(); // Fetch venue data $.ajax({ url: hvacFindTraining.ajax_url, type: 'POST', data: { action: 'hvac_get_venue_info', nonce: hvacFindTraining.nonce, venue_id: venueId }, success: function(response) { if (response.success) { const venue = response.data.venue; self.populateVenueModal(venue); $modal.show(); } } }); }, /** * Populate venue modal with data */ populateVenueModal: function(venue) { const self = this; const $modal = $('#hvac-venue-modal'); // Title $modal.find('#venue-modal-title').text(venue.name); // Address const addressParts = [venue.address, venue.city, venue.state, venue.zip].filter(Boolean); $modal.find('.hvac-venue-address').text(addressParts.join(', ')); // Phone if (venue.phone) { $modal.find('.hvac-venue-phone').html('Phone: ' + this.escapeHtml(venue.phone)).show(); } else { $modal.find('.hvac-venue-phone').hide(); } // Capacity if (venue.capacity) { $modal.find('.hvac-venue-capacity').html('Capacity: ' + this.escapeHtml(venue.capacity)).show(); } else { $modal.find('.hvac-venue-capacity').hide(); } // Description if (venue.description) { $modal.find('.hvac-venue-description').html(venue.description).show(); } else { $modal.find('.hvac-venue-description').hide(); } // Equipment badges const $equipmentSection = $modal.find('.hvac-venue-equipment'); const $equipmentBadges = $modal.find('.hvac-equipment-badges'); $equipmentBadges.empty(); if (venue.equipment && venue.equipment.length > 0) { venue.equipment.forEach(item => { $equipmentBadges.append(`${this.escapeHtml(item)}`); }); $equipmentSection.show(); } else { $equipmentSection.hide(); } // Amenities badges const $amenitiesSection = $modal.find('.hvac-venue-amenities'); const $amenitiesBadges = $modal.find('.hvac-amenities-badges'); $amenitiesBadges.empty(); if (venue.amenities && venue.amenities.length > 0) { venue.amenities.forEach(item => { $amenitiesBadges.append(`${this.escapeHtml(item)}`); }); $amenitiesSection.show(); } else { $amenitiesSection.hide(); } // Events list const $eventsList = $modal.find('.hvac-venue-events-list'); const $noEvents = $modal.find('.hvac-venue-no-events'); $eventsList.empty(); if (venue.events && venue.events.length > 0) { venue.events.forEach(event => { $eventsList.append(`
  • ${this.escapeHtml(event.title)} ${this.escapeHtml(event.date)}
  • `); }); $eventsList.show(); $noEvents.hide(); } else { $eventsList.hide(); $noEvents.show(); } // Directions link const directionsUrl = `https://www.google.com/maps/dir/?api=1&destination=${venue.lat},${venue.lng}`; $modal.find('.hvac-venue-directions').attr('href', directionsUrl); // Set up contact form const $form = $modal.find('.hvac-venue-contact-form'); $form.data('venue-id', venue.id); $form.attr('data-venue-id', venue.id); $form.show(); $modal.find('.hvac-form-success').hide(); $modal.find('.hvac-form-error').hide(); $form[0].reset(); // Bind contact form submission this.bindVenueContactForm(); }, /** * Bind venue contact form submission */ bindVenueContactForm: function() { const self = this; $('.hvac-venue-contact-form').off('submit').on('submit', function(e) { e.preventDefault(); self.submitVenueContactForm($(this)); }); }, /** * Submit venue contact form */ submitVenueContactForm: function($form) { const venueId = $form.data('venue-id'); const $successMsg = $form.siblings('.hvac-form-success'); const $errorMsg = $form.siblings('.hvac-form-error'); const $submit = $form.find('button[type="submit"]'); // Collect form data const formData = { action: 'hvac_submit_venue_contact', nonce: hvacFindTraining.nonce, venue_id: venueId }; $form.serializeArray().forEach(field => { formData[field.name] = field.value; }); // Submit $submit.prop('disabled', true).text('Sending...'); $successMsg.hide(); $errorMsg.hide(); $.ajax({ url: hvacFindTraining.ajax_url, type: 'POST', data: formData, success: function(response) { if (response.success) { $form.hide(); $successMsg.show(); } else { $errorMsg.find('p').text(response.data?.message || 'There was a problem sending your message.'); $errorMsg.show(); } }, error: function() { $errorMsg.show(); }, complete: function() { $submit.prop('disabled', false).text('Send Message'); } }); }, /** * Update trainer directory grid */ updateTrainerGrid: function() { const $grid = $('#hvac-trainer-grid'); $grid.empty(); // 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 6 for compact sidebar, load more on request) const displayCount = Math.min(trainersToShow.length, 6); for (let i = 0; i < displayCount; i++) { const trainer = trainersToShow[i]; $grid.append(this.createTrainerCard(trainer)); } // Show load more if there are more trainers if (trainersToShow.length > 6) { $('.hvac-load-more-wrapper').show(); } else { $('.hvac-load-more-wrapper').hide(); } }, /** * Create trainer card HTML */ createTrainerCard: function(trainer) { const imageHtml = trainer.image ? `${this.escapeHtml(trainer.name)}` : '
    '; const certHtml = trainer.certifications && trainer.certifications.length > 0 ? trainer.certifications.map(cert => `${this.escapeHtml(cert)}`).join('') : ''; return `
    ${imageHtml}
    ${this.escapeHtml(trainer.name)}
    ${this.escapeHtml(trainer.city)}, ${this.escapeHtml(trainer.state)}
    ${certHtml}
    `; }, /** * 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(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); } }, /** * Bind event handlers */ bindEvents: function() { const self = this; // Trainer card click $(document).on('click', '.hvac-trainer-card', function() { const profileId = $(this).data('profile-id'); if (profileId) { self.openTrainerModal(profileId); } }); // Modal close $(document).on('click', '.hvac-modal-close, .hvac-modal-overlay', function() { $('.hvac-training-modal').hide(); }); // ESC key to close modal $(document).on('keydown', function(e) { if (e.key === 'Escape') { $('.hvac-training-modal').hide(); } }); // Marker toggles $('#hvac-show-trainers, #hvac-show-venues').on('change', function() { self.updateMarkers(); }); // Load more button $('#hvac-load-more').on('click', function() { self.loadMoreTrainers(); }); }, /** * Bind contact form in modal */ bindContactForm: function() { const self = this; $('.hvac-training-contact-form').off('submit').on('submit', function(e) { e.preventDefault(); self.submitContactForm($(this)); }); }, /** * Submit contact form */ submitContactForm: function($form) { const trainerId = $form.data('trainer-id'); const profileId = $form.data('profile-id'); const $successMsg = $form.siblings('.hvac-form-success'); const $errorMsg = $form.siblings('.hvac-form-error'); const $submit = $form.find('button[type="submit"]'); // Collect form data const formData = { action: 'hvac_submit_contact_form', nonce: hvacFindTraining.nonce, trainer_id: trainerId, trainer_profile_id: profileId }; $form.serializeArray().forEach(field => { formData[field.name] = field.value; }); // Submit $submit.prop('disabled', true).text('Sending...'); $successMsg.hide(); $errorMsg.hide(); $.ajax({ url: hvacFindTraining.ajax_url, type: 'POST', data: formData, success: function(response) { if (response.success) { $form.hide(); $successMsg.show(); } else { $errorMsg.show(); } }, error: function() { $errorMsg.show(); }, complete: function() { $submit.prop('disabled', false).text('Send Message'); } }); }, /** * Load more trainers */ loadMoreTrainers: function() { const $grid = $('#hvac-trainer-grid'); const currentCount = $grid.find('.hvac-trainer-card').length; const loadMore = 6; // Load 6 more at a time for sidebar // 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 >= 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 */ getUserLocation: function(callback) { if (!navigator.geolocation) { callback(null, hvacFindTraining.messages.geolocation_unsupported); return; } navigator.geolocation.getCurrentPosition( function(position) { callback({ lat: position.coords.latitude, lng: position.coords.longitude }); }, function(error) { callback(null, hvacFindTraining.messages.geolocation_error); } ); }, /** * Center map on location */ centerOnLocation: function(lat, lng, zoom) { this.map.setCenter({ lat: lat, lng: lng }); this.map.setZoom(zoom || 10); }, /** * 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 */ showLoading: function() { $('#hvac-trainer-grid').html('
    Loading trainers...
    '); }, /** * Hide loading state */ hideLoading: function() { $('#hvac-trainer-grid .hvac-grid-loading').remove(); }, /** * Show map error */ showMapError: function(message) { const $map = $('#' + this.config.mapElementId); $map.html(`

    ${this.escapeHtml(message)}

    `); }, /** * Escape HTML for safe output */ escapeHtml: function(text) { if (!text) return ''; const div = document.createElement('div'); div.textContent = text; return div.innerHTML; } }; // Initialize when document is ready $(document).ready(function() { // Wait a moment for Google Maps to fully load if ($('#hvac-training-map').length) { setTimeout(function() { HVACTrainingMap.init(); }, 100); } }); })(jQuery);