upskill-event-manager/assets/js/find-training-map.js
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

1593 lines
55 KiB
JavaScript

/**
* 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: [],
eventMarkers: [],
// MarkerClusterer instance
markerClusterer: null,
// Info window instance (reused)
infoWindow: null,
// Current data
trainers: [],
venues: [],
events: [],
// 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: {
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');
// Initialize tabs even without map
this.initTabs();
// 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.');
// Initialize tabs even without map
this.initTabs();
// 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 tabs
this.initTabs();
// 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.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() {
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.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.updateAllCounts();
self.renderActiveTabList();
// Note: syncSidebarWithViewport will be called by map 'idle' event
// to filter items 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');
const showEvents = $('#hvac-show-events').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);
}
});
}
// 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();
// 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);
},
/**
* 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
*/
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 (light green with dark outline)
return {
path: google.maps.SymbolPath.CIRCLE,
fillColor: '#f0f7e8',
fillOpacity: 1,
strokeColor: '#5a8a1a',
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 (green for mQ Approved)
return {
path: google.maps.SymbolPath.BACKWARD_CLOSED_ARROW,
fillColor: '#89c92e',
fillOpacity: 1,
strokeColor: '#ffffff',
strokeWeight: 2,
scale: 6
};
},
/**
* 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
*/
initClustering: function() {
// Clear existing clusterer
if (this.markerClusterer) {
this.markerClusterer.clearMarkers();
}
// Combine all markers
const allMarkers = [...this.trainerMarkers, ...this.venueMarkers, ...this.eventMarkers];
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 event markers
this.eventMarkers.forEach(marker => marker.setMap(null));
this.eventMarkers = [];
// Clear clusterer
if (this.markerClusterer) {
this.markerClusterer.clearMarkers();
}
},
/**
* Fit map bounds to show all markers
*/
fitBounds: function() {
const allMarkers = [...this.trainerMarkers, ...this.venueMarkers, ...this.eventMarkers];
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 || '') + '<br>' +
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);
},
/**
* 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
*/
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('<p class="hvac-error">Failed to load profile.</p>');
$loading.hide();
$body.show();
}
},
error: function() {
$body.html('<p class="hvac-error">Network error. Please try again.</p>');
$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('<strong>Phone:</strong> ' + this.escapeHtml(venue.phone)).show();
} else {
$modal.find('.hvac-venue-phone').hide();
}
// Capacity
if (venue.capacity) {
$modal.find('.hvac-venue-capacity').html('<strong>Capacity:</strong> ' + 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(`<span class="hvac-badge hvac-badge-equipment">${this.escapeHtml(item)}</span>`);
});
$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(`<span class="hvac-badge hvac-badge-amenity">${this.escapeHtml(item)}</span>`);
});
$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(`
<li>
<a href="${this.escapeHtml(event.url)}" target="_blank">${this.escapeHtml(event.title)}</a>
<span class="hvac-event-date">${this.escapeHtml(event.date)}</span>
</li>
`);
});
$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(`<div class="hvac-empty-state"><span class="dashicons dashicons-businessperson"></span><p>${message}</p></div>`);
$('.hvac-load-more-wrapper').hide();
return;
}
// 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++) {
$grid.append(this.createTrainerCard(trainersToShow[i]));
}
// Show load more if there are more trainers
if (trainersToShow.length > displayCount) {
$('.hvac-load-more-wrapper').show();
} else {
$('.hvac-load-more-wrapper').hide();
}
},
/**
* Create trainer card HTML
*/
createTrainerCard: function(trainer) {
const imageHtml = trainer.image
? `<img src="${this.escapeHtml(trainer.image)}" alt="${this.escapeHtml(trainer.name)}">`
: '<div class="hvac-trainer-card-avatar"><span class="dashicons dashicons-businessperson"></span></div>';
const certHtml = trainer.certifications && trainer.certifications.length > 0
? trainer.certifications.map(cert => `<span class="hvac-cert-badge">${this.escapeHtml(cert)}</span>`).join('')
: '';
return `
<div class="hvac-trainer-card" data-profile-id="${trainer.profile_id}">
<div class="hvac-trainer-card-image">${imageHtml}</div>
<div class="hvac-trainer-card-info">
<div class="hvac-trainer-card-name">${this.escapeHtml(trainer.name)}</div>
<div class="hvac-trainer-card-location">${this.escapeHtml(trainer.city)}, ${this.escapeHtml(trainer.state)}</div>
<div class="hvac-trainer-card-certs">${certHtml}</div>
</div>
</div>
`;
},
/**
* 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) {
// Update all tab counts
this.updateAllCounts();
},
/**
* 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);
}
});
// 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();
});
// 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, #hvac-show-events').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 items for the active tab
*/
loadMoreTrainers: function() {
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;
}
},
/**
* 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 + 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;
}
}
this.displayedCounts[tabType] = $grid.find(cardSelector).length;
if ($grid.find(cardSelector).length >= items.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 lists with visible map viewport
*/
syncSidebarWithViewport: function() {
if (!this.map) {
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);
});
// 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);
});
// 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();
},
/**
* Show loading state
*/
showLoading: function() {
$('#hvac-trainer-grid').html('<div class="hvac-grid-loading"><span class="dashicons dashicons-update-alt hvac-spin"></span> Loading trainers...</div>');
},
/**
* 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(`
<div class="hvac-map-loading">
<span class="dashicons dashicons-warning"></span>
<p>${this.escapeHtml(message)}</p>
</div>
`);
},
/**
* 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(`<div class="hvac-empty-state"><span class="dashicons dashicons-building"></span><p>${message}</p></div>`);
$('.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 `
<div class="hvac-venue-card" data-venue-id="${venue.id}">
<div class="hvac-venue-card-icon">
<span class="dashicons dashicons-building"></span>
</div>
<div class="hvac-venue-card-info">
<div class="hvac-venue-card-name">${this.escapeHtml(venue.name)}</div>
<div class="hvac-venue-card-location">${this.escapeHtml(location)}</div>
<div class="hvac-venue-card-events">${eventsText}</div>
</div>
</div>
`;
},
/**
* 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(`<div class="hvac-empty-state"><span class="dashicons dashicons-calendar-alt"></span><p>${message}</p></div>`);
$('.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 ? '<span class="hvac-event-card-past-badge">Past</span>' : '';
return `
<div class="hvac-event-card ${pastClass}" data-event-url="${this.escapeHtml(event.url)}">
<div class="hvac-event-card-date">
<span class="hvac-event-card-month">${month}</span>
<span class="hvac-event-card-day">${day}</span>
</div>
<div class="hvac-event-card-info">
<div class="hvac-event-card-title">${this.escapeHtml(event.title)}</div>
<div class="hvac-event-card-venue">${venueText ? 'at ' + this.escapeHtml(venueText) : ''}</div>
<div class="hvac-event-card-meta">
<span class="hvac-event-card-cost ${costClass}">${this.escapeHtml(event.cost)}</span>
${pastBadge}
</div>
</div>
</div>
`;
},
/**
* 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
*/
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);