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:
ben 2026-02-02 02:01:15 -04:00
parent d2a43bfd9b
commit fcd55fd164
4 changed files with 585 additions and 24 deletions

View file

@ -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.

View file

@ -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
========================================================================== */

View file

@ -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();
}
}
}
};

View file

@ -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
*/