diff --git a/Status.md b/Status.md
index c8a2a2e8..d2bab2ec 100644
--- a/Status.md
+++ b/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.
diff --git a/assets/css/find-training-map.css b/assets/css/find-training-map.css
index fa9496bd..37d2308f 100644
--- a/assets/css/find-training-map.css
+++ b/assets/css/find-training-map.css
@@ -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
========================================================================== */
diff --git a/assets/js/find-training-map.js b/assets/js/find-training-map.js
index 70be8088..26950e13 100644
--- a/assets/js/find-training-map.js
+++ b/assets/js/find-training-map.js
@@ -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('
Failed to load profile.
');
@@ -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 => `
+
${imageHtml}
${this.escapeHtml(trainer.name)}
-
${this.escapeHtml(trainer.city)}, ${this.escapeHtml(trainer.state)}
+
${locationHtml}
${certHtml}
@@ -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();
+ }
+ }
}
};
diff --git a/includes/find-training/class-hvac-training-map-data.php b/includes/find-training/class-hvac-training-map-data.php
index ffa34aee..0df138ee 100644
--- a/includes/find-training/class-hvac-training-map-data.php
+++ b/includes/find-training/class-hvac-training-map-data.php
@@ -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
*/