upskill-event-manager/includes/find-training/class-hvac-find-training-page.php
ben 21c908af81 feat(find-training): New Google Maps page replacing buggy MapGeo implementation
Implements /find-training page with Google Maps JavaScript API:
- Interactive map showing trainers (teal) and venues (orange) markers
- MarkerClusterer for dense areas
- Filter by State, Certification, Training Format
- Search by name/location
- "Near Me" geolocation with proximity filtering
- Trainer profile modal with contact form
- Venue info modal with upcoming events
- 301 redirect from /find-a-trainer to /find-training
- Auto-geocoding for new TEC venues via Google API

Multi-model code review fixes (GPT-5, Gemini 3, Zen MCP):
- Added missing contact form AJAX handler with rate limiting
- Fixed XSS risk in InfoWindow (DOM creation vs inline onclick)
- Added caching for filter dropdown queries (1-hour TTL)
- Added AJAX abort handling to prevent race conditions
- Replaced alert() with inline error notifications

New files:
- includes/find-training/class-hvac-find-training-page.php
- includes/find-training/class-hvac-training-map-data.php
- includes/find-training/class-hvac-venue-geocoding.php
- templates/page-find-training.php
- assets/js/find-training-map.js
- assets/js/find-training-filters.js
- assets/css/find-training-map.css
- assets/images/marker-trainer.svg
- assets/images/marker-venue.svg

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-31 23:20:34 -04:00

517 lines
18 KiB
PHP

<?php
/**
* Find Training Page Handler
*
* Manages the Find Training page with Google Maps integration
* showing trainers and training venues on an interactive map.
*
* @package HVAC_Community_Events
* @since 2.2.0
*/
if (!defined('ABSPATH')) {
exit;
}
/**
* Class HVAC_Find_Training_Page
*
* Main controller for the Find Training page functionality.
* Uses singleton pattern consistent with other HVAC plugin classes.
*/
class HVAC_Find_Training_Page {
/**
* Singleton instance
*
* @var HVAC_Find_Training_Page|null
*/
private static ?self $instance = null;
/**
* Page slug
*
* @var string
*/
private string $page_slug = 'find-training';
/**
* Google Maps API key
*
* @var string
*/
private string $api_key = '';
/**
* Get singleton instance
*
* @return HVAC_Find_Training_Page
*/
public static function get_instance(): self {
if (self::$instance === null) {
self::$instance = new self();
}
return self::$instance;
}
/**
* Constructor
*/
private function __construct() {
$this->load_api_key();
$this->init_hooks();
}
/**
* Load Google Maps API key from secure storage
*/
private function load_api_key(): void {
if (class_exists('HVAC_Secure_Storage')) {
$this->api_key = HVAC_Secure_Storage::get_credential('hvac_google_maps_api_key', '');
}
}
/**
* Initialize WordPress hooks
*/
private function init_hooks(): void {
// Page registration
add_action('init', [$this, 'register_page'], 15);
// Asset enqueuing
add_action('wp_enqueue_scripts', [$this, 'enqueue_assets']);
// Body classes
add_filter('body_class', [$this, 'add_body_classes']);
// AJAX handlers
add_action('wp_ajax_hvac_get_training_map_data', [$this, 'ajax_get_map_data']);
add_action('wp_ajax_nopriv_hvac_get_training_map_data', [$this, 'ajax_get_map_data']);
add_action('wp_ajax_hvac_filter_training_map', [$this, 'ajax_filter_map']);
add_action('wp_ajax_nopriv_hvac_filter_training_map', [$this, 'ajax_filter_map']);
add_action('wp_ajax_hvac_get_trainer_profile_modal', [$this, 'ajax_get_trainer_profile']);
add_action('wp_ajax_nopriv_hvac_get_trainer_profile_modal', [$this, 'ajax_get_trainer_profile']);
add_action('wp_ajax_hvac_get_venue_info', [$this, 'ajax_get_venue_info']);
add_action('wp_ajax_nopriv_hvac_get_venue_info', [$this, 'ajax_get_venue_info']);
// Redirect from old page
add_action('template_redirect', [$this, 'maybe_redirect_from_old_page']);
}
/**
* Register the Find Training page
*/
public function register_page(): void {
$page = get_page_by_path($this->page_slug);
if (!$page) {
$this->create_page();
}
}
/**
* Create the Find Training page in WordPress
*/
private function create_page(): void {
$page_data = [
'post_title' => 'Find Training',
'post_name' => $this->page_slug,
'post_content' => '<!-- This page uses a custom template -->',
'post_status' => 'publish',
'post_type' => 'page',
'post_author' => 1,
'comment_status' => 'closed',
'ping_status' => 'closed',
'meta_input' => [
'_wp_page_template' => 'page-find-training.php',
'ast-site-content-layout' => 'page-builder',
'site-post-title' => 'disabled',
'site-sidebar-layout' => 'no-sidebar',
'theme-transparent-header-meta' => 'disabled'
]
];
$page_id = wp_insert_post($page_data);
if ($page_id && !is_wp_error($page_id)) {
update_option('hvac_find_training_page_id', $page_id);
}
}
/**
* Check if current page is the Find Training page
*
* @return bool
*/
public function is_find_training_page(): bool {
return is_page($this->page_slug) || is_page(get_option('hvac_find_training_page_id'));
}
/**
* Enqueue page assets
*/
public function enqueue_assets(): void {
if (!$this->is_find_training_page()) {
return;
}
// Enqueue CSS
wp_enqueue_style(
'hvac-find-training',
HVAC_PLUGIN_URL . 'assets/css/find-training-map.css',
['astra-theme-css'],
HVAC_VERSION
);
// Enqueue Google Maps API with MarkerClusterer
if (!empty($this->api_key)) {
wp_enqueue_script(
'google-maps-api',
'https://maps.googleapis.com/maps/api/js?key=' . esc_attr($this->api_key) . '&libraries=places&callback=Function.prototype',
[],
null,
true
);
// MarkerClusterer library
wp_enqueue_script(
'google-maps-markerclusterer',
'https://unpkg.com/@googlemaps/markerclusterer/dist/index.min.js',
['google-maps-api'],
'2.5.3',
true
);
}
// Enqueue main map JavaScript
wp_enqueue_script(
'hvac-find-training-map',
HVAC_PLUGIN_URL . 'assets/js/find-training-map.js',
['jquery', 'google-maps-api', 'google-maps-markerclusterer'],
HVAC_VERSION,
true
);
// Enqueue filter JavaScript
wp_enqueue_script(
'hvac-find-training-filters',
HVAC_PLUGIN_URL . 'assets/js/find-training-filters.js',
['jquery', 'hvac-find-training-map'],
HVAC_VERSION,
true
);
// Localize script with data
wp_localize_script('hvac-find-training-map', 'hvacFindTraining', [
'ajax_url' => admin_url('admin-ajax.php'),
'nonce' => wp_create_nonce('hvac_find_training'),
'api_key' => !empty($this->api_key) ? 'configured' : '', // Don't expose actual key
'map_center' => [
'lat' => 39.8283, // US center
'lng' => -98.5795
],
'default_zoom' => 4,
'cluster_zoom' => 8,
'messages' => [
'loading' => __('Loading...', 'hvac-community-events'),
'error' => __('An error occurred. Please try again.', 'hvac-community-events'),
'no_results' => __('No trainers or venues found matching your criteria.', 'hvac-community-events'),
'geolocation_error' => __('Unable to get your location. Please check your browser settings.', 'hvac-community-events'),
'geolocation_unsupported' => __('Geolocation is not supported by your browser.', 'hvac-community-events')
],
'marker_icons' => [
'trainer' => HVAC_PLUGIN_URL . 'assets/images/marker-trainer.svg',
'venue' => HVAC_PLUGIN_URL . 'assets/images/marker-venue.svg'
]
]);
}
/**
* Add body classes for the page
*
* @param array $classes Existing body classes
* @return array Modified body classes
*/
public function add_body_classes(array $classes): array {
if ($this->is_find_training_page()) {
$classes[] = 'hvac-find-training-page';
$classes[] = 'hvac-full-width';
$classes[] = 'hvac-page';
}
return $classes;
}
/**
* Redirect from old /find-a-trainer page to /find-training
*/
public function maybe_redirect_from_old_page(): void {
if (is_page('find-a-trainer')) {
wp_safe_redirect(home_url('/find-training/'), 301);
exit;
}
}
/**
* AJAX: Get all map data (trainers and venues)
*/
public function ajax_get_map_data(): void {
// Verify nonce
if (!isset($_POST['nonce']) || !wp_verify_nonce($_POST['nonce'], 'hvac_find_training')) {
wp_send_json_error(['message' => 'Invalid security token']);
return;
}
$data_provider = HVAC_Training_Map_Data::get_instance();
$trainers = $data_provider->get_trainer_markers();
$venues = $data_provider->get_venue_markers();
wp_send_json_success([
'trainers' => $trainers,
'venues' => $venues,
'total_trainers' => count($trainers),
'total_venues' => count($venues)
]);
}
/**
* AJAX: Filter map markers
*/
public function ajax_filter_map(): void {
// Verify nonce
if (!isset($_POST['nonce']) || !wp_verify_nonce($_POST['nonce'], 'hvac_find_training')) {
wp_send_json_error(['message' => 'Invalid security token']);
return;
}
$filters = [
'state' => sanitize_text_field($_POST['state'] ?? ''),
'certification' => sanitize_text_field($_POST['certification'] ?? ''),
'training_format' => sanitize_text_field($_POST['training_format'] ?? ''),
'search' => sanitize_text_field($_POST['search'] ?? ''),
'show_trainers' => filter_var($_POST['show_trainers'] ?? true, FILTER_VALIDATE_BOOLEAN),
'show_venues' => filter_var($_POST['show_venues'] ?? true, FILTER_VALIDATE_BOOLEAN),
'lat' => isset($_POST['lat']) ? floatval($_POST['lat']) : null,
'lng' => isset($_POST['lng']) ? floatval($_POST['lng']) : null,
'radius' => isset($_POST['radius']) ? intval($_POST['radius']) : 100 // km
];
$data_provider = HVAC_Training_Map_Data::get_instance();
$result = [
'trainers' => [],
'venues' => []
];
if ($filters['show_trainers']) {
$result['trainers'] = $data_provider->get_trainer_markers($filters);
}
if ($filters['show_venues']) {
$result['venues'] = $data_provider->get_venue_markers($filters);
}
$result['total_trainers'] = count($result['trainers']);
$result['total_venues'] = count($result['venues']);
$result['filters_applied'] = array_filter($filters, function($v) {
return !empty($v) && $v !== true;
});
wp_send_json_success($result);
}
/**
* AJAX: Get trainer profile for modal
*/
public function ajax_get_trainer_profile(): void {
// Verify nonce
if (!isset($_POST['nonce']) || !wp_verify_nonce($_POST['nonce'], 'hvac_find_training')) {
wp_send_json_error(['message' => 'Invalid security token']);
return;
}
$profile_id = absint($_POST['profile_id'] ?? 0);
if (!$profile_id) {
wp_send_json_error(['message' => 'Invalid profile ID']);
return;
}
$data_provider = HVAC_Training_Map_Data::get_instance();
$trainer_data = $data_provider->get_trainer_full_profile($profile_id);
if (!$trainer_data) {
wp_send_json_error(['message' => 'Trainer not found']);
return;
}
// Generate modal HTML
ob_start();
$this->render_trainer_modal_content($trainer_data);
$html = ob_get_clean();
wp_send_json_success([
'trainer' => $trainer_data,
'html' => $html
]);
}
/**
* AJAX: Get venue info for info window
*/
public function ajax_get_venue_info(): void {
// Verify nonce
if (!isset($_POST['nonce']) || !wp_verify_nonce($_POST['nonce'], 'hvac_find_training')) {
wp_send_json_error(['message' => 'Invalid security token']);
return;
}
$venue_id = absint($_POST['venue_id'] ?? 0);
if (!$venue_id) {
wp_send_json_error(['message' => 'Invalid venue ID']);
return;
}
$data_provider = HVAC_Training_Map_Data::get_instance();
$venue_data = $data_provider->get_venue_full_info($venue_id);
if (!$venue_data) {
wp_send_json_error(['message' => 'Venue not found']);
return;
}
wp_send_json_success(['venue' => $venue_data]);
}
/**
* Render trainer modal content
*
* @param array $trainer Trainer data
*/
private function render_trainer_modal_content(array $trainer): void {
?>
<div class="hvac-training-modal-header">
<h2><?php echo esc_html($trainer['name']); ?></h2>
<button class="hvac-modal-close" aria-label="Close modal">&times;</button>
</div>
<div class="hvac-training-modal-body">
<div class="hvac-training-profile-section">
<div class="hvac-training-profile-header">
<?php if (!empty($trainer['image'])): ?>
<img src="<?php echo esc_url($trainer['image']); ?>" alt="<?php echo esc_attr($trainer['name']); ?>" class="hvac-training-profile-image">
<?php else: ?>
<div class="hvac-training-profile-avatar">
<span class="dashicons dashicons-businessperson"></span>
</div>
<?php endif; ?>
<div class="hvac-training-profile-info">
<p class="hvac-training-location">
<?php echo esc_html($trainer['city'] . ', ' . $trainer['state']); ?>
</p>
<?php if (!empty($trainer['certifications'])): ?>
<div class="hvac-training-certifications">
<?php foreach ($trainer['certifications'] as $cert): ?>
<span class="hvac-cert-badge"><?php echo esc_html($cert); ?></span>
<?php endforeach; ?>
</div>
<?php endif; ?>
<?php if (!empty($trainer['company'])): ?>
<p class="hvac-training-company"><?php echo esc_html($trainer['company']); ?></p>
<?php endif; ?>
<p class="hvac-training-events-count">
Total Training Events: <strong><?php echo intval($trainer['event_count']); ?></strong>
</p>
</div>
</div>
<?php if (!empty($trainer['training_formats'])): ?>
<div class="hvac-training-detail">
<strong>Training Formats:</strong> <?php echo esc_html(implode(', ', $trainer['training_formats'])); ?>
</div>
<?php endif; ?>
<?php if (!empty($trainer['upcoming_events'])): ?>
<div class="hvac-training-events">
<h4>Upcoming Events</h4>
<ul class="hvac-events-list">
<?php foreach (array_slice($trainer['upcoming_events'], 0, 5) as $event): ?>
<li>
<a href="<?php echo esc_url($event['url']); ?>" target="_blank">
<?php echo esc_html($event['title']); ?>
</a>
<span class="hvac-event-date"><?php echo esc_html($event['date']); ?></span>
</li>
<?php endforeach; ?>
</ul>
</div>
<?php endif; ?>
</div>
<div class="hvac-training-contact-section">
<h4>Contact Trainer</h4>
<form class="hvac-training-contact-form" data-trainer-id="<?php echo esc_attr($trainer['user_id']); ?>" data-profile-id="<?php echo esc_attr($trainer['profile_id']); ?>">
<div class="hvac-form-row">
<input type="text" name="first_name" placeholder="First Name" required>
<input type="text" name="last_name" placeholder="Last Name" required>
</div>
<div class="hvac-form-row">
<input type="email" name="email" placeholder="Email" required>
<input type="tel" name="phone" placeholder="Phone Number">
</div>
<div class="hvac-form-row">
<input type="text" name="city" placeholder="City">
<input type="text" name="state_province" placeholder="State/Province">
</div>
<div class="hvac-form-field">
<input type="text" name="company" placeholder="Company">
</div>
<div class="hvac-form-field">
<textarea name="message" placeholder="Message" rows="3"></textarea>
</div>
<button type="submit" class="hvac-btn-primary">Send Message</button>
</form>
<div class="hvac-form-message hvac-form-success" style="display: none;">
Your message has been sent! Check your inbox for more details.
</div>
<div class="hvac-form-message hvac-form-error" style="display: none;">
There was an error sending your message. Please try again.
</div>
</div>
</div>
<?php
}
/**
* Get filter options for dropdowns
*
* @return array Filter options
*/
public function get_filter_options(): array {
$data_provider = HVAC_Training_Map_Data::get_instance();
return [
'states' => $data_provider->get_state_options(),
'certifications' => $data_provider->get_certification_options(),
'training_formats' => $data_provider->get_training_format_options()
];
}
/**
* Get page slug
*
* @return string
*/
public function get_page_slug(): string {
return $this->page_slug;
}
}