feat(find-training): Differentiate measureQuick Certified Champions from Trainers
Champions are identified by "Certified measureQuick Champion" certification. Unlike Trainers, Champions do not offer public training, so they display differently: - White marker outline (vs green for Trainers) - Show only state, not city, in sidebar and info windows - No "View Profile" button or modal popup on click - Sorted to end of trainer list (after all Trainers) - Non-clickable card styling Code review fixes (Gemini 3): - Fixed location formatting to handle empty city gracefully - Added secondary sort by name for stable ordering Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
d2a43bfd9b
commit
fcd55fd164
4 changed files with 585 additions and 24 deletions
67
Status.md
67
Status.md
|
|
@ -1,8 +1,8 @@
|
|||
# HVAC Community Events - Project Status
|
||||
|
||||
**Last Updated:** February 1, 2026
|
||||
**Current Session:** Tabbed Interface for Find Training Page
|
||||
**Version:** 2.2.6 (Deployed to Staging)
|
||||
**Last Updated:** February 2, 2026
|
||||
**Current Session:** Champion Differentiation on Find Training Map
|
||||
**Version:** 2.2.6 (Deployed to Production)
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -20,9 +20,66 @@
|
|||
|
||||
---
|
||||
|
||||
## 🎯 CURRENT SESSION - TABBED INTERFACE FOR FIND TRAINING (Feb 1, 2026)
|
||||
## 🎯 CURRENT SESSION - CHAMPION DIFFERENTIATION ON FIND TRAINING (Feb 2, 2026)
|
||||
|
||||
### Status: ✅ **COMPLETE - Deployed to Staging, Verified Working**
|
||||
### Status: ✅ **COMPLETE - Deployed to Production**
|
||||
|
||||
**Objective:** Differentiate measureQuick Certified Champions from Trainers on the Find Training map. Champions do not offer public training, so they should be displayed differently.
|
||||
|
||||
### Changes Made
|
||||
|
||||
1. ✅ **Backend Data** (`includes/find-training/class-hvac-training-map-data.php`)
|
||||
- Added `is_champion` flag for trainers with "Certified measureQuick Champion" certification
|
||||
- Champions return empty `city` field (only state shown)
|
||||
|
||||
2. ✅ **Champion Marker Icon** (`assets/js/find-training-map.js`)
|
||||
- Added `getChampionIcon()` method with white outline (vs green for Trainers)
|
||||
- Champions use distinct visual appearance on map
|
||||
|
||||
3. ✅ **Sidebar Cards** (`assets/js/find-training-map.js`)
|
||||
- Champions show only state (e.g., "Ohio" not "Canton, Ohio")
|
||||
- Champions have non-clickable cards (no modal popup)
|
||||
- Added `hvac-champion-card` CSS class
|
||||
|
||||
4. ✅ **Info Windows** (`assets/js/find-training-map.js`)
|
||||
- Champions show only state in location
|
||||
- No "View Profile" button for Champions
|
||||
- Fixed location formatting to handle empty city gracefully
|
||||
|
||||
5. ✅ **Sorting** (`assets/js/find-training-map.js`)
|
||||
- Champions sorted to end of trainer list
|
||||
- Secondary sort by name for stable ordering
|
||||
- Applied to both `loadMapData` and `loadTrainerDirectory`
|
||||
|
||||
6. ✅ **CSS Styling** (`assets/css/find-training-map.css`)
|
||||
- Champion cards have non-clickable appearance
|
||||
- No hover effects (cursor: default, no transform/shadow)
|
||||
|
||||
### Files Modified
|
||||
|
||||
| File | Change |
|
||||
|------|--------|
|
||||
| `includes/find-training/class-hvac-training-map-data.php` | Added `is_champion` flag, empty city for champions |
|
||||
| `assets/js/find-training-map.js` | Champion icon, card display, click prevention, sorting |
|
||||
| `assets/css/find-training-map.css` | Champion card non-clickable styling |
|
||||
|
||||
### Verification (Staging)
|
||||
- ✅ API returns `is_champion: true` for 18 champions
|
||||
- ✅ Champions have empty city in API response
|
||||
- ✅ Champions sorted to end of list
|
||||
- ✅ Champion cards show only state
|
||||
- ✅ Clicking champion card does NOT open modal
|
||||
- ✅ Clicking trainer card DOES open modal (regression test)
|
||||
|
||||
### Code Review Findings (Gemini 3)
|
||||
- **Fixed:** Location formatting bug - empty city could show ", California"
|
||||
- **Fixed:** Unstable sort order - added secondary sort by name
|
||||
|
||||
---
|
||||
|
||||
## 📋 PREVIOUS SESSION - TABBED INTERFACE FOR FIND TRAINING (Feb 1, 2026)
|
||||
|
||||
### Status: ✅ **COMPLETE - Deployed to Production**
|
||||
|
||||
**Objective:** Refactor the Find Training page sidebar from a single trainer list to a tabbed interface with Trainers, Venues, and Events tabs.
|
||||
|
||||
|
|
|
|||
|
|
@ -437,6 +437,18 @@ body .hvac-find-training-page {
|
|||
box-shadow: 0 2px 8px rgba(0, 179, 164, 0.15);
|
||||
}
|
||||
|
||||
/* Champion Cards - Non-clickable appearance (Champions don't offer public training) */
|
||||
.hvac-champion-card {
|
||||
cursor: default;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.hvac-champion-card:hover {
|
||||
border-color: var(--hvac-border);
|
||||
box-shadow: none;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.hvac-trainer-card-image {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
|
|
@ -1640,6 +1652,40 @@ body .hvac-find-training-page {
|
|||
}
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
reCAPTCHA Widget Styles
|
||||
========================================================================== */
|
||||
|
||||
.hvac-recaptcha-wrapper {
|
||||
margin: 16px 0;
|
||||
}
|
||||
|
||||
/* Ensure reCAPTCHA widget fits in narrow modal/form containers */
|
||||
.hvac-recaptcha-wrapper .g-recaptcha {
|
||||
transform-origin: left top;
|
||||
}
|
||||
|
||||
.hvac-training-contact-form .hvac-recaptcha-wrapper,
|
||||
.hvac-venue-contact-form .hvac-recaptcha-wrapper {
|
||||
margin: 12px 0;
|
||||
}
|
||||
|
||||
/* Scale down reCAPTCHA on very narrow screens */
|
||||
@media (max-width: 400px) {
|
||||
.hvac-recaptcha-wrapper .g-recaptcha {
|
||||
transform: scale(0.9);
|
||||
margin-right: -30px;
|
||||
}
|
||||
}
|
||||
|
||||
/* Scale for very narrow modals */
|
||||
@media (max-width: 340px) {
|
||||
.hvac-recaptcha-wrapper .g-recaptcha {
|
||||
transform: scale(0.77);
|
||||
margin-right: -70px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Sidebar Tabs
|
||||
========================================================================== */
|
||||
|
|
|
|||
|
|
@ -131,6 +131,14 @@
|
|||
self.venues = response.data.venues || [];
|
||||
self.events = response.data.events || [];
|
||||
|
||||
// Sort trainers: non-champions first, champions last, then by name
|
||||
self.trainers.sort(function(a, b) {
|
||||
if (a.is_champion !== b.is_champion) {
|
||||
return a.is_champion ? 1 : -1; // Champions go to end
|
||||
}
|
||||
return (a.name || '').localeCompare(b.name || ''); // Secondary sort by name
|
||||
});
|
||||
|
||||
self.visibleTrainers = self.trainers.slice();
|
||||
self.visibleVenues = self.venues.slice();
|
||||
self.visibleEvents = self.events.slice();
|
||||
|
|
@ -236,6 +244,14 @@
|
|||
self.venues = response.data.venues || [];
|
||||
self.events = response.data.events || [];
|
||||
|
||||
// Sort trainers: non-champions first, champions last, then by name
|
||||
self.trainers.sort(function(a, b) {
|
||||
if (a.is_champion !== b.is_champion) {
|
||||
return a.is_champion ? 1 : -1; // Champions go to end
|
||||
}
|
||||
return (a.name || '').localeCompare(b.name || ''); // Secondary sort by name
|
||||
});
|
||||
|
||||
// Initially all items are "visible"
|
||||
self.visibleTrainers = self.trainers.slice();
|
||||
self.visibleVenues = self.venues.slice();
|
||||
|
|
@ -314,12 +330,12 @@
|
|||
addTrainerMarker: function(trainer) {
|
||||
const self = this;
|
||||
|
||||
// Create marker with custom icon
|
||||
// Create marker with custom icon (use champion icon for champions)
|
||||
const marker = new google.maps.Marker({
|
||||
position: { lat: trainer.lat, lng: trainer.lng },
|
||||
map: this.map,
|
||||
title: trainer.name,
|
||||
icon: this.getTrainerIcon(),
|
||||
icon: trainer.is_champion ? this.getChampionIcon() : this.getTrainerIcon(),
|
||||
optimized: false // Required for reliable hover events
|
||||
});
|
||||
|
||||
|
|
@ -427,6 +443,22 @@
|
|||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Get champion marker icon (white outline to distinguish from trainers)
|
||||
* Champions are measureQuick Certified Champions who don't offer public training
|
||||
*/
|
||||
getChampionIcon: function() {
|
||||
// SVG circle marker (light green with white outline)
|
||||
return {
|
||||
path: google.maps.SymbolPath.CIRCLE,
|
||||
fillColor: '#f0f7e8',
|
||||
fillOpacity: 1,
|
||||
strokeColor: '#ffffff',
|
||||
strokeWeight: 2.5,
|
||||
scale: 10
|
||||
};
|
||||
},
|
||||
|
||||
/**
|
||||
* Get venue marker icon
|
||||
*/
|
||||
|
|
@ -568,9 +600,13 @@
|
|||
title.textContent = trainer.name;
|
||||
container.appendChild(title);
|
||||
|
||||
// Location: only state for champions, city + state for trainers
|
||||
const location = document.createElement('div');
|
||||
location.className = 'hvac-info-window-location';
|
||||
location.textContent = (trainer.city || '') + ', ' + (trainer.state || '');
|
||||
const locationParts = trainer.is_champion
|
||||
? [trainer.state].filter(Boolean)
|
||||
: [trainer.city, trainer.state].filter(Boolean);
|
||||
location.textContent = locationParts.join(', ');
|
||||
container.appendChild(location);
|
||||
|
||||
if (trainer.certification) {
|
||||
|
|
@ -580,13 +616,16 @@
|
|||
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);
|
||||
// Only show View Profile button for non-champions (champions don't offer public training)
|
||||
if (!trainer.is_champion) {
|
||||
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);
|
||||
|
|
@ -747,6 +786,8 @@
|
|||
$body.html(response.data.html);
|
||||
$loading.hide();
|
||||
$body.show();
|
||||
// Render reCAPTCHA for dynamically loaded content
|
||||
self.renderRecaptcha($body);
|
||||
self.bindContactForm();
|
||||
} else {
|
||||
$body.html('<p class="hvac-error">Failed to load profile.</p>');
|
||||
|
|
@ -908,16 +949,36 @@
|
|||
* Submit venue contact form
|
||||
*/
|
||||
submitVenueContactForm: function($form) {
|
||||
const self = this;
|
||||
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"]');
|
||||
|
||||
// Check for reCAPTCHA response
|
||||
const recaptchaWidget = $form.find('.g-recaptcha');
|
||||
let recaptchaResponse = '';
|
||||
if (recaptchaWidget.length && typeof grecaptcha !== 'undefined') {
|
||||
// Find the widget ID for this specific form
|
||||
const widgetId = recaptchaWidget.data('widget-id');
|
||||
if (widgetId !== undefined) {
|
||||
recaptchaResponse = grecaptcha.getResponse(widgetId);
|
||||
} else {
|
||||
recaptchaResponse = grecaptcha.getResponse();
|
||||
}
|
||||
|
||||
if (!recaptchaResponse) {
|
||||
alert(hvacFindTraining.messages?.captcha_required || 'Please complete the CAPTCHA verification.');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Collect form data
|
||||
const formData = {
|
||||
action: 'hvac_submit_venue_contact',
|
||||
nonce: hvacFindTraining.nonce,
|
||||
venue_id: venueId
|
||||
venue_id: venueId,
|
||||
'g-recaptcha-response': recaptchaResponse
|
||||
};
|
||||
|
||||
$form.serializeArray().forEach(field => {
|
||||
|
|
@ -937,13 +998,18 @@
|
|||
if (response.success) {
|
||||
$form.hide();
|
||||
$successMsg.show();
|
||||
// Reset reCAPTCHA for next use
|
||||
self.resetRecaptcha($form);
|
||||
} else {
|
||||
$errorMsg.find('p').text(response.data?.message || 'There was a problem sending your message.');
|
||||
$errorMsg.show();
|
||||
// Reset reCAPTCHA on failure so user can try again
|
||||
self.resetRecaptcha($form);
|
||||
}
|
||||
},
|
||||
error: function() {
|
||||
$errorMsg.show();
|
||||
self.resetRecaptcha($form);
|
||||
},
|
||||
complete: function() {
|
||||
$submit.prop('disabled', false).text('Send Message');
|
||||
|
|
@ -1000,12 +1066,25 @@
|
|||
? trainer.certifications.map(cert => `<span class="hvac-cert-badge">${this.escapeHtml(cert)}</span>`).join('')
|
||||
: '';
|
||||
|
||||
// Location: only state for champions, city + state for trainers
|
||||
const locationHtml = trainer.is_champion
|
||||
? this.escapeHtml(trainer.state)
|
||||
: `${this.escapeHtml(trainer.city)}, ${this.escapeHtml(trainer.state)}`;
|
||||
|
||||
// CSS class: non-clickable for champions (no contact form/modal)
|
||||
const cardClass = trainer.is_champion
|
||||
? 'hvac-trainer-card hvac-champion-card'
|
||||
: 'hvac-trainer-card';
|
||||
|
||||
// Champions don't have clickable profile_id (prevents modal)
|
||||
const profileIdAttr = trainer.is_champion ? '' : trainer.profile_id;
|
||||
|
||||
return `
|
||||
<div class="hvac-trainer-card" data-profile-id="${trainer.profile_id}">
|
||||
<div class="${cardClass}" data-profile-id="${profileIdAttr}">
|
||||
<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-location">${locationHtml}</div>
|
||||
<div class="hvac-trainer-card-certs">${certHtml}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -1091,18 +1170,38 @@
|
|||
* Submit contact form
|
||||
*/
|
||||
submitContactForm: function($form) {
|
||||
const self = this;
|
||||
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"]');
|
||||
|
||||
// Check for reCAPTCHA response
|
||||
const recaptchaWidget = $form.find('.g-recaptcha');
|
||||
let recaptchaResponse = '';
|
||||
if (recaptchaWidget.length && typeof grecaptcha !== 'undefined') {
|
||||
// Find the widget ID for this specific form
|
||||
const widgetId = recaptchaWidget.data('widget-id');
|
||||
if (widgetId !== undefined) {
|
||||
recaptchaResponse = grecaptcha.getResponse(widgetId);
|
||||
} else {
|
||||
recaptchaResponse = grecaptcha.getResponse();
|
||||
}
|
||||
|
||||
if (!recaptchaResponse) {
|
||||
alert(hvacFindTraining.messages?.captcha_required || 'Please complete the CAPTCHA verification.');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Collect form data
|
||||
const formData = {
|
||||
action: 'hvac_submit_contact_form',
|
||||
nonce: hvacFindTraining.nonce,
|
||||
trainer_id: trainerId,
|
||||
trainer_profile_id: profileId
|
||||
trainer_profile_id: profileId,
|
||||
'g-recaptcha-response': recaptchaResponse
|
||||
};
|
||||
|
||||
$form.serializeArray().forEach(field => {
|
||||
|
|
@ -1122,12 +1221,18 @@
|
|||
if (response.success) {
|
||||
$form.hide();
|
||||
$successMsg.show();
|
||||
// Reset reCAPTCHA for next use
|
||||
self.resetRecaptcha($form);
|
||||
} else {
|
||||
$errorMsg.find('p').text(response.data?.message || 'There was an error sending your message.');
|
||||
$errorMsg.show();
|
||||
// Reset reCAPTCHA on failure so user can try again
|
||||
self.resetRecaptcha($form);
|
||||
}
|
||||
},
|
||||
error: function() {
|
||||
$errorMsg.show();
|
||||
self.resetRecaptcha($form);
|
||||
},
|
||||
complete: function() {
|
||||
$submit.prop('disabled', false).text('Send Message');
|
||||
|
|
@ -1577,6 +1682,62 @@
|
|||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
},
|
||||
|
||||
/**
|
||||
* Render reCAPTCHA widget for dynamically loaded content
|
||||
* Google's auto-render only works for elements present at page load,
|
||||
* so we need to explicitly render widgets added via AJAX.
|
||||
*
|
||||
* @param {jQuery} $container The container with .g-recaptcha elements
|
||||
*/
|
||||
renderRecaptcha: function($container) {
|
||||
if (typeof grecaptcha === 'undefined' || typeof grecaptcha.render !== 'function') {
|
||||
return;
|
||||
}
|
||||
|
||||
const siteKey = hvacFindTraining.recaptcha_site_key;
|
||||
if (!siteKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
$container.find('.g-recaptcha').each(function() {
|
||||
const $widget = $(this);
|
||||
// Only render if not already rendered (no iframe inside)
|
||||
if ($widget.find('iframe').length === 0) {
|
||||
try {
|
||||
const widgetId = grecaptcha.render(this, {
|
||||
sitekey: siteKey
|
||||
});
|
||||
// Store widget ID for later reset operations
|
||||
$widget.data('widget-id', widgetId);
|
||||
} catch (e) {
|
||||
// Widget may already be rendered, ignore
|
||||
console.log('reCAPTCHA render skipped:', e.message);
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* Reset reCAPTCHA widget after form submission
|
||||
*
|
||||
* @param {jQuery} $form The form containing the reCAPTCHA widget
|
||||
*/
|
||||
resetRecaptcha: function($form) {
|
||||
if (typeof grecaptcha === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
const recaptchaWidget = $form.find('.g-recaptcha');
|
||||
if (recaptchaWidget.length) {
|
||||
const widgetId = recaptchaWidget.data('widget-id');
|
||||
if (widgetId !== undefined) {
|
||||
grecaptcha.reset(widgetId);
|
||||
} else {
|
||||
grecaptcha.reset();
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -57,9 +57,10 @@ class HVAC_Training_Map_Data {
|
|||
* Constructor
|
||||
*/
|
||||
private function __construct() {
|
||||
// Clear cache when profiles or venues are updated
|
||||
// Clear cache when profiles, venues, or events are updated
|
||||
add_action('save_post_trainer_profile', [$this, 'clear_trainer_cache']);
|
||||
add_action('save_post_tribe_venue', [$this, 'clear_venue_cache']);
|
||||
add_action('save_post_tribe_events', [$this, 'clear_event_cache']);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -350,6 +351,9 @@ class HVAC_Training_Map_Data {
|
|||
// Get certifications
|
||||
$certifications = $this->get_trainer_certifications($profile_id, $user_id);
|
||||
|
||||
// Determine if this is a champion (not a trainer offering public training)
|
||||
$is_champion = in_array('Certified measureQuick Champion', $certifications, true);
|
||||
|
||||
// Get event count (cached)
|
||||
$event_count = get_post_meta($profile_id, 'cached_event_count', true);
|
||||
if (empty($event_count)) {
|
||||
|
|
@ -359,10 +363,11 @@ class HVAC_Training_Map_Data {
|
|||
return [
|
||||
'id' => $profile_id,
|
||||
'type' => 'trainer',
|
||||
'is_champion' => $is_champion,
|
||||
'lat' => floatval($lat),
|
||||
'lng' => floatval($lng),
|
||||
'name' => get_post_meta($profile_id, 'trainer_display_name', true),
|
||||
'city' => get_post_meta($profile_id, 'trainer_city', true),
|
||||
'city' => $is_champion ? '' : get_post_meta($profile_id, 'trainer_city', true),
|
||||
'state' => get_post_meta($profile_id, 'trainer_state', true),
|
||||
'certifications' => $certifications,
|
||||
'certification' => !empty($certifications) ? $certifications[0] : '',
|
||||
|
|
@ -402,7 +407,7 @@ class HVAC_Training_Map_Data {
|
|||
'type' => 'venue',
|
||||
'lat' => floatval($lat),
|
||||
'lng' => floatval($lng),
|
||||
'name' => get_the_title($venue_id),
|
||||
'name' => html_entity_decode(get_the_title($venue_id), ENT_QUOTES, 'UTF-8'),
|
||||
'address' => $address,
|
||||
'city' => $city,
|
||||
'state' => $state,
|
||||
|
|
@ -555,7 +560,7 @@ class HVAC_Training_Map_Data {
|
|||
foreach ($events as $event) {
|
||||
$data['events'][] = [
|
||||
'id' => $event->ID,
|
||||
'title' => $event->post_title,
|
||||
'title' => html_entity_decode($event->post_title, ENT_QUOTES, 'UTF-8'),
|
||||
'date' => tribe_get_start_date($event->ID, false, 'M j, Y'),
|
||||
'url' => get_permalink($event->ID)
|
||||
];
|
||||
|
|
@ -587,7 +592,7 @@ class HVAC_Training_Map_Data {
|
|||
foreach ($events as $event) {
|
||||
$formatted[] = [
|
||||
'id' => $event->ID,
|
||||
'title' => $event->post_title,
|
||||
'title' => html_entity_decode($event->post_title, ENT_QUOTES, 'UTF-8'),
|
||||
'date' => tribe_get_start_date($event->ID, false, 'M j, Y'),
|
||||
'url' => get_permalink($event->ID)
|
||||
];
|
||||
|
|
@ -781,6 +786,298 @@ class HVAC_Training_Map_Data {
|
|||
return $formats;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get event markers for map
|
||||
*
|
||||
* @param array $filters Optional filters (state, search, lat/lng/radius, include_past)
|
||||
* @return array Event markers data
|
||||
*/
|
||||
public function get_event_markers(array $filters = []): array {
|
||||
// Check if TEC is active
|
||||
if (!function_exists('tribe_get_events')) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Generate cache key
|
||||
$cache_key = 'events_' . md5(serialize($filters));
|
||||
$cached = wp_cache_get($cache_key, $this->cache_group);
|
||||
|
||||
if ($cached !== false && empty($filters)) {
|
||||
return $cached;
|
||||
}
|
||||
|
||||
// Determine if we need to filter by venue first
|
||||
$venue_ids = null;
|
||||
if (!empty($filters['state']) || !empty($filters['search']) ||
|
||||
(!empty($filters['lat']) && !empty($filters['lng']) && !empty($filters['radius']))) {
|
||||
$venue_ids = $this->get_venue_ids_by_filters($filters);
|
||||
|
||||
// If venue filters are active but no matching venues found, return empty
|
||||
if (!empty($filters['state']) && empty($venue_ids)) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Build event query args
|
||||
$include_past = !empty($filters['include_past']);
|
||||
|
||||
$event_args = [
|
||||
'posts_per_page' => 100,
|
||||
'post_status' => 'publish',
|
||||
'orderby' => 'event_date',
|
||||
'order' => 'ASC',
|
||||
];
|
||||
|
||||
if ($include_past) {
|
||||
// Custom date range: past 6 months to future 1 year
|
||||
$event_args['eventDisplay'] = 'custom';
|
||||
$event_args['start_date'] = date('Y-m-d', strtotime('-6 months'));
|
||||
$event_args['end_date'] = date('Y-m-d', strtotime('+1 year'));
|
||||
} else {
|
||||
// Default: upcoming and ongoing events only
|
||||
$event_args['eventDisplay'] = 'list';
|
||||
}
|
||||
|
||||
// Apply venue filter if we have filtered venue IDs
|
||||
if ($venue_ids !== null && !empty($venue_ids)) {
|
||||
$event_args['venue'] = $venue_ids;
|
||||
}
|
||||
|
||||
$events = tribe_get_events($event_args);
|
||||
$markers = [];
|
||||
|
||||
// Batch load meta for performance
|
||||
if (!empty($events)) {
|
||||
$event_ids = wp_list_pluck($events, 'ID');
|
||||
update_postmeta_cache($event_ids);
|
||||
}
|
||||
|
||||
foreach ($events as $event) {
|
||||
$marker = $this->format_event_marker($event);
|
||||
|
||||
// Skip events without valid coordinates
|
||||
if (empty($marker) || empty($marker['lat']) || empty($marker['lng'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Apply search filter to event title and venue
|
||||
if (!empty($filters['search'])) {
|
||||
$search = strtolower($filters['search']);
|
||||
$title_match = stripos($marker['title'], $search) !== false;
|
||||
$venue_match = stripos($marker['venue_name'] ?? '', $search) !== false;
|
||||
$city_match = stripos($marker['venue_city'] ?? '', $search) !== false;
|
||||
|
||||
if (!$title_match && !$venue_match && !$city_match) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Apply proximity filter
|
||||
if (!empty($filters['lat']) && !empty($filters['lng']) && !empty($filters['radius'])) {
|
||||
$distance = $this->calculate_distance(
|
||||
$filters['lat'],
|
||||
$filters['lng'],
|
||||
$marker['lat'],
|
||||
$marker['lng']
|
||||
);
|
||||
if ($distance > $filters['radius']) {
|
||||
continue;
|
||||
}
|
||||
$marker['distance'] = round($distance, 1);
|
||||
}
|
||||
|
||||
$markers[] = $marker;
|
||||
}
|
||||
|
||||
// Sort by distance if proximity search, otherwise by date
|
||||
if (!empty($filters['lat']) && !empty($filters['lng'])) {
|
||||
usort($markers, function($a, $b) {
|
||||
return ($a['distance'] ?? 0) <=> ($b['distance'] ?? 0);
|
||||
});
|
||||
}
|
||||
|
||||
// Cache if no filters
|
||||
if (empty($filters)) {
|
||||
wp_cache_set($cache_key, $markers, $this->cache_group, $this->cache_expiration);
|
||||
}
|
||||
|
||||
return $markers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format event data for map marker
|
||||
*
|
||||
* @param WP_Post $event Event post object
|
||||
* @return array|null Formatted marker data or null if invalid
|
||||
*/
|
||||
private function format_event_marker(WP_Post $event): ?array {
|
||||
// Get venue ID
|
||||
$venue_id = get_post_meta($event->ID, '_EventVenueID', true);
|
||||
|
||||
// Skip events without venue
|
||||
if (empty($venue_id)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if event should show on map
|
||||
$show_map = get_post_meta($event->ID, '_EventShowMap', true);
|
||||
if ($show_map === 'false' || $show_map === '0') {
|
||||
// Virtual-only event with no map display
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get venue coordinates
|
||||
$lat = get_post_meta($venue_id, 'venue_latitude', true);
|
||||
$lng = get_post_meta($venue_id, 'venue_longitude', true);
|
||||
|
||||
if (empty($lat) || empty($lng)) {
|
||||
$lat = get_post_meta($venue_id, '_VenueLat', true);
|
||||
$lng = get_post_meta($venue_id, '_VenueLng', true);
|
||||
}
|
||||
|
||||
// Skip if no coordinates
|
||||
if (empty($lat) || empty($lng)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Get venue details
|
||||
$venue_name = get_the_title($venue_id);
|
||||
$venue_city = get_post_meta($venue_id, '_VenueCity', true);
|
||||
$venue_state = get_post_meta($venue_id, '_VenueStateProvince', true)
|
||||
?: get_post_meta($venue_id, '_VenueState', true);
|
||||
|
||||
// Get event dates
|
||||
$start_date = tribe_get_start_date($event->ID, false, 'M j, Y');
|
||||
$end_date = tribe_get_end_date($event->ID, false, 'M j, Y');
|
||||
$start_time = tribe_get_start_date($event->ID, false, 'g:i A');
|
||||
$is_all_day = tribe_event_is_all_day($event->ID);
|
||||
|
||||
// Determine if past event
|
||||
$event_end = tribe_get_end_date($event->ID, false, 'U');
|
||||
$is_past = $event_end < time();
|
||||
|
||||
// Get cost
|
||||
$cost = tribe_get_formatted_cost($event->ID);
|
||||
if (empty($cost)) {
|
||||
$cost = 'Free';
|
||||
}
|
||||
|
||||
// Get excerpt (sanitized)
|
||||
$excerpt = wp_strip_all_tags($event->post_content);
|
||||
$excerpt = wp_trim_words($excerpt, 20, '...');
|
||||
|
||||
return [
|
||||
'id' => $event->ID,
|
||||
'type' => 'event',
|
||||
'lat' => floatval($lat),
|
||||
'lng' => floatval($lng),
|
||||
'title' => esc_html(html_entity_decode(get_the_title($event->ID), ENT_QUOTES, 'UTF-8')),
|
||||
'excerpt' => $excerpt,
|
||||
'start_date' => $start_date,
|
||||
'end_date' => $end_date,
|
||||
'start_time' => $is_all_day ? '' : $start_time,
|
||||
'is_all_day' => $is_all_day,
|
||||
'venue_id' => intval($venue_id),
|
||||
'venue_name' => esc_html(html_entity_decode($venue_name, ENT_QUOTES, 'UTF-8')),
|
||||
'venue_city' => $venue_city,
|
||||
'venue_state' => $venue_state,
|
||||
'cost' => $cost,
|
||||
'url' => esc_url(get_permalink($event->ID)),
|
||||
'is_past' => $is_past
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get venue IDs matching filters (for event filtering)
|
||||
*
|
||||
* @param array $filters Filters (state, search, lat/lng/radius)
|
||||
* @return array Matching venue IDs
|
||||
*/
|
||||
private function get_venue_ids_by_filters(array $filters): array {
|
||||
if (!function_exists('tribe_get_venue')) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$query_args = [
|
||||
'post_type' => 'tribe_venue',
|
||||
'posts_per_page' => -1,
|
||||
'post_status' => 'publish',
|
||||
'fields' => 'ids',
|
||||
'meta_query' => [
|
||||
'relation' => 'AND',
|
||||
]
|
||||
];
|
||||
|
||||
// Add state filter
|
||||
if (!empty($filters['state'])) {
|
||||
$query_args['meta_query'][] = [
|
||||
'relation' => 'OR',
|
||||
[
|
||||
'key' => '_VenueStateProvince',
|
||||
'value' => sanitize_text_field($filters['state']),
|
||||
'compare' => '='
|
||||
],
|
||||
[
|
||||
'key' => '_VenueState',
|
||||
'value' => sanitize_text_field($filters['state']),
|
||||
'compare' => '='
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
// Add search filter (venue name or city)
|
||||
if (!empty($filters['search'])) {
|
||||
$query_args['meta_query'][] = [
|
||||
'relation' => 'OR',
|
||||
[
|
||||
'key' => '_VenueCity',
|
||||
'value' => sanitize_text_field($filters['search']),
|
||||
'compare' => 'LIKE'
|
||||
]
|
||||
];
|
||||
// Also search by venue name
|
||||
$query_args['s'] = sanitize_text_field($filters['search']);
|
||||
}
|
||||
|
||||
$query = new WP_Query($query_args);
|
||||
$venue_ids = $query->posts;
|
||||
|
||||
// Apply proximity filter if needed
|
||||
if (!empty($filters['lat']) && !empty($filters['lng']) && !empty($filters['radius']) && !empty($venue_ids)) {
|
||||
$filtered_ids = [];
|
||||
foreach ($venue_ids as $venue_id) {
|
||||
$lat = get_post_meta($venue_id, 'venue_latitude', true) ?: get_post_meta($venue_id, '_VenueLat', true);
|
||||
$lng = get_post_meta($venue_id, 'venue_longitude', true) ?: get_post_meta($venue_id, '_VenueLng', true);
|
||||
|
||||
if (!empty($lat) && !empty($lng)) {
|
||||
$distance = $this->calculate_distance(
|
||||
$filters['lat'],
|
||||
$filters['lng'],
|
||||
floatval($lat),
|
||||
floatval($lng)
|
||||
);
|
||||
if ($distance <= $filters['radius']) {
|
||||
$filtered_ids[] = $venue_id;
|
||||
}
|
||||
}
|
||||
}
|
||||
$venue_ids = $filtered_ids;
|
||||
}
|
||||
|
||||
return $venue_ids;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear event cache
|
||||
*/
|
||||
public function clear_event_cache(): void {
|
||||
if (function_exists('wp_cache_delete_group')) {
|
||||
wp_cache_delete_group($this->cache_group);
|
||||
} else {
|
||||
wp_cache_flush();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear trainer cache
|
||||
*/
|
||||
|
|
|
|||
Loading…
Reference in a new issue