upskill-event-manager/includes/find-training/class-hvac-training-map-data.php
ben 5c15b27935 feat(find-training): measureQuick Approved Training Labs implementation
Add venue taxonomies and filter /find-training to show only approved labs:

- Create venue_type, venue_equipment, venue_amenities taxonomies
- Filter venue markers by mq-approved-lab taxonomy term
- Add equipment and amenities badges to venue modal
- Add venue contact form with AJAX handler and email notification
- Include POC (Point of Contact) meta for each training lab

9 approved training labs configured:
- Fast Track Learning Lab, Progressive Training Lab, NAVAC Technical Training Center
- Stevens Equipment Phoenix/Johnstown, San Jacinto College, Johnstone Supply
- TruTech Tools Training Center (new), Auer Steel & Heating Supply (new)

Note: Venues not displaying on map - to be debugged next session.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-01 13:36:06 -04:00

805 lines
25 KiB
PHP

<?php
/**
* Training Map Data Provider
*
* Provides marker data for trainers and venues on the Find Training map.
*
* @package HVAC_Community_Events
* @since 2.2.0
*/
if (!defined('ABSPATH')) {
exit;
}
/**
* Class HVAC_Training_Map_Data
*
* Data provider for the Find Training Google Maps integration.
* Queries trainers and venues with coordinates for map display.
*/
class HVAC_Training_Map_Data {
/**
* Singleton instance
*
* @var HVAC_Training_Map_Data|null
*/
private static ?self $instance = null;
/**
* Cache group for queries
*
* @var string
*/
private string $cache_group = 'hvac_training_map';
/**
* Cache expiration (1 hour)
*
* @var int
*/
private int $cache_expiration = 3600;
/**
* Get singleton instance
*
* @return HVAC_Training_Map_Data
*/
public static function get_instance(): self {
if (self::$instance === null) {
self::$instance = new self();
}
return self::$instance;
}
/**
* Constructor
*/
private function __construct() {
// Clear cache when profiles or venues are updated
add_action('save_post_trainer_profile', [$this, 'clear_trainer_cache']);
add_action('save_post_tribe_venue', [$this, 'clear_venue_cache']);
}
/**
* Get trainer markers for map
*
* @param array $filters Optional filters
* @return array Trainer markers data
*/
public function get_trainer_markers(array $filters = []): array {
// Generate cache key based on filters
$cache_key = 'trainers_' . md5(serialize($filters));
$cached = wp_cache_get($cache_key, $this->cache_group);
if ($cached !== false && empty($filters)) {
return $cached;
}
// Get approved user IDs
$approved_user_ids = $this->get_approved_user_ids();
if (empty($approved_user_ids)) {
return [];
}
// Build query args
$query_args = [
'post_type' => 'trainer_profile',
'posts_per_page' => -1,
'post_status' => 'publish',
'meta_query' => [
'relation' => 'AND',
[
'key' => 'is_public_profile',
'value' => '1',
'compare' => '='
],
[
'key' => 'user_id',
'value' => $approved_user_ids,
'compare' => 'IN'
],
[
'key' => 'latitude',
'compare' => 'EXISTS'
],
[
'key' => 'longitude',
'compare' => 'EXISTS'
],
[
'key' => 'latitude',
'value' => '',
'compare' => '!='
],
[
'key' => 'longitude',
'value' => '',
'compare' => '!='
]
]
];
// Add state filter
if (!empty($filters['state'])) {
$query_args['meta_query'][] = [
'key' => 'trainer_state',
'value' => sanitize_text_field($filters['state']),
'compare' => '='
];
}
// Add search filter
if (!empty($filters['search'])) {
$search = sanitize_text_field($filters['search']);
$query_args['meta_query'][] = [
'relation' => 'OR',
[
'key' => 'trainer_display_name',
'value' => $search,
'compare' => 'LIKE'
],
[
'key' => 'trainer_city',
'value' => $search,
'compare' => 'LIKE'
],
[
'key' => 'company_name',
'value' => $search,
'compare' => 'LIKE'
]
];
}
$query = new WP_Query($query_args);
$markers = [];
if ($query->have_posts()) {
while ($query->have_posts()) {
$query->the_post();
$profile_id = get_the_ID();
$marker = $this->format_trainer_marker($profile_id);
// Apply certification filter
if (!empty($filters['certification'])) {
$cert_match = false;
foreach ($marker['certifications'] as $cert) {
if (stripos($cert, $filters['certification']) !== false) {
$cert_match = true;
break;
}
}
if (!$cert_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;
}
}
wp_reset_postdata();
// Sort by distance if proximity search
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;
}
/**
* Get venue markers for map
*
* Filters to show only measureQuick Approved Training Labs.
*
* @param array $filters Optional filters
* @return array Venue markers data
*/
public function get_venue_markers(array $filters = []): array {
// Check if TEC is active
if (!function_exists('tribe_get_venue')) {
return [];
}
// Generate cache key
$cache_key = 'venues_approved_labs_' . md5(serialize($filters));
$cached = wp_cache_get($cache_key, $this->cache_group);
if ($cached !== false && empty($filters)) {
return $cached;
}
// Build query args - filter for approved training labs only
$query_args = [
'post_type' => 'tribe_venue',
'posts_per_page' => -1,
'post_status' => 'publish',
// Only show venues tagged as measureQuick Approved Training Labs
'tax_query' => [
[
'taxonomy' => 'venue_type',
'field' => 'slug',
'terms' => 'mq-approved-lab',
],
],
'meta_query' => [
'relation' => 'AND',
[
'relation' => 'OR',
// Check for our custom venue coordinates
[
'key' => 'venue_latitude',
'compare' => 'EXISTS'
],
// Also check TEC's built-in coordinates
[
'key' => '_VenueLat',
'compare' => 'EXISTS'
]
]
]
];
// 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
if (!empty($filters['search'])) {
$query_args['s'] = sanitize_text_field($filters['search']);
}
$query = new WP_Query($query_args);
$markers = [];
if ($query->have_posts()) {
while ($query->have_posts()) {
$query->the_post();
$venue_id = get_the_ID();
$marker = $this->format_venue_marker($venue_id);
// Skip venues without valid coordinates
if (empty($marker['lat']) || empty($marker['lng'])) {
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;
}
}
wp_reset_postdata();
// Sort by distance if proximity search
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 trainer data for map marker
*
* @param int $profile_id Trainer profile post ID
* @return array Formatted marker data
*/
private function format_trainer_marker(int $profile_id): array {
$user_id = get_post_meta($profile_id, 'user_id', true);
$lat = get_post_meta($profile_id, 'latitude', true);
$lng = get_post_meta($profile_id, 'longitude', true);
// Get certifications
$certifications = $this->get_trainer_certifications($profile_id, $user_id);
// Get event count (cached)
$event_count = get_post_meta($profile_id, 'cached_event_count', true);
if (empty($event_count)) {
$event_count = 0;
}
return [
'id' => $profile_id,
'type' => 'trainer',
'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),
'state' => get_post_meta($profile_id, 'trainer_state', true),
'certifications' => $certifications,
'certification' => !empty($certifications) ? $certifications[0] : '',
'image' => get_post_meta($profile_id, 'profile_image_url', true),
'profile_id' => $profile_id,
'user_id' => intval($user_id),
'event_count' => intval($event_count)
];
}
/**
* Format venue data for map marker
*
* @param int $venue_id Venue post ID
* @return array Formatted marker data
*/
private function format_venue_marker(int $venue_id): array {
// Try our custom coordinates first, then TEC's
$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);
}
// Get venue details from TEC
$city = get_post_meta($venue_id, '_VenueCity', true);
$state = get_post_meta($venue_id, '_VenueStateProvince', true) ?: get_post_meta($venue_id, '_VenueState', true);
$address = get_post_meta($venue_id, '_VenueAddress', true);
// Count upcoming events at this venue
$upcoming_events_count = $this->count_venue_upcoming_events($venue_id);
return [
'id' => $venue_id,
'type' => 'venue',
'lat' => floatval($lat),
'lng' => floatval($lng),
'name' => get_the_title($venue_id),
'address' => $address,
'city' => $city,
'state' => $state,
'upcoming_events' => $upcoming_events_count
];
}
/**
* Get trainer certifications
*
* @param int $profile_id Profile post ID
* @param int $user_id User ID
* @return array List of certification names
*/
private function get_trainer_certifications(int $profile_id, int $user_id): array {
$certifications = [];
// Try new certification system
if (class_exists('HVAC_Trainer_Certification_Manager')) {
$cert_manager = HVAC_Trainer_Certification_Manager::instance();
$trainer_certs = $cert_manager->get_trainer_certifications($user_id);
foreach ($trainer_certs as $cert) {
$cert_type = get_post_meta($cert->ID, 'certification_type', true);
$status = get_post_meta($cert->ID, 'status', true) ?: 'active';
$expiration = get_post_meta($cert->ID, 'expiration_date', true);
// Only include active, non-expired certifications
$is_expired = $expiration && strtotime($expiration) < time();
if ($status === 'active' && !$is_expired && !empty($cert_type)) {
$certifications[] = $cert_type;
}
}
}
// Fallback to legacy certification
if (empty($certifications)) {
$legacy = get_post_meta($profile_id, 'certification_type', true);
if (!empty($legacy)) {
$certifications[] = $legacy;
}
}
return array_unique($certifications);
}
/**
* Count upcoming events at a venue
*
* @param int $venue_id Venue post ID
* @return int Number of upcoming events
*/
private function count_venue_upcoming_events(int $venue_id): int {
if (!function_exists('tribe_get_events')) {
return 0;
}
$events = tribe_get_events([
'eventDisplay' => 'upcoming',
'posts_per_page' => -1,
'venue' => $venue_id,
'fields' => 'ids'
]);
return is_array($events) ? count($events) : 0;
}
/**
* Get full trainer profile for modal
*
* @param int $profile_id Profile post ID
* @return array|null Trainer data or null if not found
*/
public function get_trainer_full_profile(int $profile_id): ?array {
$profile = get_post($profile_id);
if (!$profile || $profile->post_type !== 'trainer_profile') {
return null;
}
$user_id = get_post_meta($profile_id, 'user_id', true);
// Get basic marker data
$data = $this->format_trainer_marker($profile_id);
// Add additional details for modal
$data['company'] = get_post_meta($profile_id, 'company_name', true);
$data['bio'] = get_post_meta($profile_id, 'trainer_bio', true);
$data['training_formats'] = $this->get_meta_array($profile_id, 'training_formats');
$data['training_locations'] = $this->get_meta_array($profile_id, 'training_locations');
// Get upcoming events
$data['upcoming_events'] = $this->get_trainer_upcoming_events($user_id);
return $data;
}
/**
* Get full venue info
*
* @param int $venue_id Venue post ID
* @return array|null Venue data or null if not found
*/
public function get_venue_full_info(int $venue_id): ?array {
$venue = get_post($venue_id);
if (!$venue || $venue->post_type !== 'tribe_venue') {
return null;
}
$data = $this->format_venue_marker($venue_id);
// Add additional details
$data['zip'] = get_post_meta($venue_id, '_VenueZip', true);
$data['country'] = get_post_meta($venue_id, '_VenueCountry', true);
$data['phone'] = get_post_meta($venue_id, '_VenuePhone', true);
$data['website'] = get_post_meta($venue_id, '_VenueURL', true);
$data['description'] = $venue->post_content;
$data['capacity'] = get_post_meta($venue_id, '_VenueCapacity', true);
// Get equipment and amenities taxonomies
$equipment = wp_get_post_terms($venue_id, 'venue_equipment', ['fields' => 'names']);
$data['equipment'] = is_wp_error($equipment) ? [] : $equipment;
$amenities = wp_get_post_terms($venue_id, 'venue_amenities', ['fields' => 'names']);
$data['amenities'] = is_wp_error($amenities) ? [] : $amenities;
// Get POC (Point of Contact) information
$data['poc_user_id'] = get_post_meta($venue_id, '_venue_poc_user_id', true);
$data['poc_name'] = get_post_meta($venue_id, '_venue_poc_name', true);
$data['poc_email'] = get_post_meta($venue_id, '_venue_poc_email', true);
// If no POC meta, use post author as fallback
if (empty($data['poc_user_id'])) {
$data['poc_user_id'] = $venue->post_author;
$author = get_userdata($venue->post_author);
if ($author) {
$data['poc_name'] = $author->display_name;
$data['poc_email'] = $author->user_email;
}
}
// Get upcoming events list
if (function_exists('tribe_get_events')) {
$events = tribe_get_events([
'eventDisplay' => 'upcoming',
'posts_per_page' => 5,
'venue' => $venue_id
]);
$data['events'] = [];
foreach ($events as $event) {
$data['events'][] = [
'id' => $event->ID,
'title' => $event->post_title,
'date' => tribe_get_start_date($event->ID, false, 'M j, Y'),
'url' => get_permalink($event->ID)
];
}
}
return $data;
}
/**
* Get trainer's upcoming events
*
* @param int $user_id User ID
* @param int $limit Maximum events to return
* @return array Upcoming events
*/
private function get_trainer_upcoming_events(int $user_id, int $limit = 5): array {
if (!function_exists('tribe_get_events')) {
return [];
}
$events = tribe_get_events([
'author' => $user_id,
'eventDisplay' => 'upcoming',
'posts_per_page' => $limit
]);
$formatted = [];
foreach ($events as $event) {
$formatted[] = [
'id' => $event->ID,
'title' => $event->post_title,
'date' => tribe_get_start_date($event->ID, false, 'M j, Y'),
'url' => get_permalink($event->ID)
];
}
return $formatted;
}
/**
* Get approved user IDs for filtering trainer profiles
*
* @return array User IDs
*/
private function get_approved_user_ids(): array {
$user_query = new WP_User_Query([
'meta_query' => [
[
'key' => 'account_status',
'value' => ['approved', 'active', 'inactive'],
'compare' => 'IN'
]
],
'fields' => 'ID'
]);
return $user_query->get_results();
}
/**
* Get meta value as array (handles comma-separated or serialized)
*
* @param int $post_id Post ID
* @param string $meta_key Meta key
* @return array Values
*/
private function get_meta_array(int $post_id, string $meta_key): array {
$value = get_post_meta($post_id, $meta_key, true);
if (empty($value)) {
return [];
}
if (is_array($value)) {
return $value;
}
// Handle comma-separated
if (strpos($value, ',') !== false) {
return array_map('trim', explode(',', $value));
}
return [$value];
}
/**
* Calculate distance between two coordinates using Haversine formula
*
* @param float $lat1 First latitude
* @param float $lng1 First longitude
* @param float $lat2 Second latitude
* @param float $lng2 Second longitude
* @return float Distance in kilometers
*/
private function calculate_distance(float $lat1, float $lng1, float $lat2, float $lng2): float {
$earth_radius = 6371; // km
$lat1_rad = deg2rad($lat1);
$lat2_rad = deg2rad($lat2);
$delta_lat = deg2rad($lat2 - $lat1);
$delta_lng = deg2rad($lng2 - $lng1);
$a = sin($delta_lat / 2) * sin($delta_lat / 2) +
cos($lat1_rad) * cos($lat2_rad) *
sin($delta_lng / 2) * sin($delta_lng / 2);
$c = 2 * atan2(sqrt($a), sqrt(1 - $a));
return $earth_radius * $c;
}
/**
* Get unique state options from trainers
*
* @return array State options
*/
public function get_state_options(): array {
// Check cache first
$cache_key = 'filter_state_options';
$cached = wp_cache_get($cache_key, $this->cache_group);
if ($cached !== false) {
return $cached;
}
global $wpdb;
$states = $wpdb->get_col("
SELECT DISTINCT pm.meta_value
FROM {$wpdb->postmeta} pm
INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id
WHERE pm.meta_key = 'trainer_state'
AND p.post_type = 'trainer_profile'
AND p.post_status = 'publish'
AND pm.meta_value != ''
ORDER BY pm.meta_value ASC
");
$states = array_filter($states);
// Cache for 1 hour
wp_cache_set($cache_key, $states, $this->cache_group, $this->cache_expiration);
return $states;
}
/**
* Get certification type options
*
* @return array Certification options
*/
public function get_certification_options(): array {
// Check cache first
$cache_key = 'filter_certification_options';
$cached = wp_cache_get($cache_key, $this->cache_group);
if ($cached !== false) {
return $cached;
}
global $wpdb;
$certs = $wpdb->get_col("
SELECT DISTINCT pm.meta_value
FROM {$wpdb->postmeta} pm
INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id
WHERE pm.meta_key = 'certification_type'
AND p.post_type = 'trainer_profile'
AND p.post_status = 'publish'
AND pm.meta_value != ''
ORDER BY pm.meta_value ASC
");
$certs = array_filter($certs);
// Cache for 1 hour
wp_cache_set($cache_key, $certs, $this->cache_group, $this->cache_expiration);
return $certs;
}
/**
* Get training format options
*
* @return array Training format options
*/
public function get_training_format_options(): array {
// Check cache first
$cache_key = 'filter_format_options';
$cached = wp_cache_get($cache_key, $this->cache_group);
if ($cached !== false) {
return $cached;
}
global $wpdb;
$formats_raw = $wpdb->get_col("
SELECT DISTINCT pm.meta_value
FROM {$wpdb->postmeta} pm
INNER JOIN {$wpdb->posts} p ON p.ID = pm.post_id
WHERE pm.meta_key = 'training_formats'
AND p.post_type = 'trainer_profile'
AND p.post_status = 'publish'
AND pm.meta_value != ''
");
// Process comma-separated values
$formats = [];
foreach ($formats_raw as $format_string) {
if (empty($format_string)) continue;
$individual = array_map('trim', explode(',', $format_string));
$formats = array_merge($formats, $individual);
}
$formats = array_unique(array_filter($formats));
sort($formats);
// Cache for 1 hour
wp_cache_set($cache_key, $formats, $this->cache_group, $this->cache_expiration);
return $formats;
}
/**
* Clear trainer cache
*/
public function clear_trainer_cache(): void {
if (function_exists('wp_cache_delete_group')) {
wp_cache_delete_group($this->cache_group);
} else {
wp_cache_flush();
}
}
/**
* Clear venue cache
*/
public function clear_venue_cache(): void {
if (function_exists('wp_cache_delete_group')) {
wp_cache_delete_group($this->cache_group);
} else {
wp_cache_flush();
}
}
}