From fcd55fd1649ddfb3389ab96d8f53a94ec2a9df80 Mon Sep 17 00:00:00 2001 From: ben Date: Mon, 2 Feb 2026 02:01:15 -0400 Subject: [PATCH] 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 --- Status.md | 67 +++- assets/css/find-training-map.css | 46 +++ assets/js/find-training-map.js | 189 ++++++++++- .../class-hvac-training-map-data.php | 307 +++++++++++++++++- 4 files changed, 585 insertions(+), 24 deletions(-) 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 => `${this.escapeHtml(cert)}`).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 ` -
+
${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 */