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>
517 lines
16 KiB
PHP
517 lines
16 KiB
PHP
<?php
|
|
/**
|
|
* Venue Geocoding Service
|
|
*
|
|
* Handles geocoding for TEC venues to add lat/lng coordinates.
|
|
*
|
|
* @package HVAC_Community_Events
|
|
* @since 2.2.0
|
|
*/
|
|
|
|
if (!defined('ABSPATH')) {
|
|
exit;
|
|
}
|
|
|
|
/**
|
|
* Class HVAC_Venue_Geocoding
|
|
*
|
|
* Manages geocoding of venue addresses using Google Maps Geocoding API.
|
|
* Auto-geocodes new venues and provides batch processing for existing venues.
|
|
*/
|
|
class HVAC_Venue_Geocoding {
|
|
|
|
/**
|
|
* Singleton instance
|
|
*
|
|
* @var HVAC_Venue_Geocoding|null
|
|
*/
|
|
private static ?self $instance = null;
|
|
|
|
/**
|
|
* Google Maps API key
|
|
*
|
|
* @var string
|
|
*/
|
|
private string $api_key = '';
|
|
|
|
/**
|
|
* Rate limit per minute
|
|
*
|
|
* @var int
|
|
*/
|
|
private int $rate_limit = 50;
|
|
|
|
/**
|
|
* Cache duration for geocoding results
|
|
*
|
|
* @var int
|
|
*/
|
|
private int $cache_duration = DAY_IN_SECONDS;
|
|
|
|
/**
|
|
* Get singleton instance
|
|
*
|
|
* @return HVAC_Venue_Geocoding
|
|
*/
|
|
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 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 hooks
|
|
*/
|
|
private function init_hooks(): void {
|
|
// Auto-geocode new venues on save
|
|
add_action('save_post_tribe_venue', [$this, 'maybe_geocode_venue'], 20, 2);
|
|
|
|
// Register geocoding action for async processing
|
|
add_action('hvac_geocode_venue', [$this, 'geocode_venue']);
|
|
|
|
// Admin action for batch geocoding
|
|
add_action('wp_ajax_hvac_batch_geocode_venues', [$this, 'ajax_batch_geocode']);
|
|
|
|
// Clear venue coordinates when address changes
|
|
add_action('updated_post_meta', [$this, 'on_venue_meta_update'], 10, 4);
|
|
}
|
|
|
|
/**
|
|
* Maybe geocode venue on save
|
|
*
|
|
* @param int $venue_id Venue post ID
|
|
* @param WP_Post $post Post object
|
|
*/
|
|
public function maybe_geocode_venue(int $venue_id, WP_Post $post): void {
|
|
// Skip autosaves and revisions
|
|
if (wp_is_post_autosave($venue_id) || wp_is_post_revision($venue_id)) {
|
|
return;
|
|
}
|
|
|
|
// Check if coordinates already exist
|
|
$has_coords = $this->venue_has_coordinates($venue_id);
|
|
|
|
if (!$has_coords) {
|
|
// Schedule geocoding to avoid blocking save
|
|
wp_schedule_single_event(time() + 5, 'hvac_geocode_venue', [$venue_id]);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check if venue already has coordinates
|
|
*
|
|
* @param int $venue_id Venue post ID
|
|
* @return bool
|
|
*/
|
|
public function venue_has_coordinates(int $venue_id): bool {
|
|
// Check custom coordinates
|
|
$lat = get_post_meta($venue_id, 'venue_latitude', true);
|
|
$lng = get_post_meta($venue_id, 'venue_longitude', true);
|
|
|
|
if (!empty($lat) && !empty($lng)) {
|
|
return true;
|
|
}
|
|
|
|
// Check TEC built-in coordinates
|
|
$tec_lat = get_post_meta($venue_id, '_VenueLat', true);
|
|
$tec_lng = get_post_meta($venue_id, '_VenueLng', true);
|
|
|
|
return !empty($tec_lat) && !empty($tec_lng);
|
|
}
|
|
|
|
/**
|
|
* Geocode a venue
|
|
*
|
|
* @param int $venue_id Venue post ID
|
|
* @return bool Success
|
|
*/
|
|
public function geocode_venue(int $venue_id): bool {
|
|
// Rate limiting check
|
|
if (!$this->check_rate_limit()) {
|
|
// Reschedule
|
|
wp_schedule_single_event(time() + 60, 'hvac_geocode_venue', [$venue_id]);
|
|
return false;
|
|
}
|
|
|
|
// Build address
|
|
$address = $this->build_venue_address($venue_id);
|
|
|
|
if (empty($address)) {
|
|
update_post_meta($venue_id, '_venue_geocoding_status', 'no_address');
|
|
return false;
|
|
}
|
|
|
|
update_post_meta($venue_id, '_venue_geocoding_attempt', time());
|
|
|
|
// Check cache first
|
|
$cache_key = 'venue_geo_' . md5($address);
|
|
$cached = get_transient($cache_key);
|
|
|
|
if ($cached !== false) {
|
|
return $this->save_coordinates($venue_id, $cached);
|
|
}
|
|
|
|
// Make API request
|
|
$result = $this->geocode_address($address);
|
|
|
|
if ($result && isset($result['lat'], $result['lng'])) {
|
|
// Cache result
|
|
set_transient($cache_key, $result, $this->cache_duration);
|
|
|
|
return $this->save_coordinates($venue_id, $result);
|
|
}
|
|
|
|
// Handle failure
|
|
$this->handle_geocoding_failure($venue_id, $result);
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Build venue address string from TEC meta
|
|
*
|
|
* @param int $venue_id Venue post ID
|
|
* @return string Full address
|
|
*/
|
|
private function build_venue_address(int $venue_id): string {
|
|
$parts = [];
|
|
|
|
$address = get_post_meta($venue_id, '_VenueAddress', true);
|
|
$city = get_post_meta($venue_id, '_VenueCity', true);
|
|
$state = get_post_meta($venue_id, '_VenueStateProvince', true) ?: get_post_meta($venue_id, '_VenueState', true);
|
|
$zip = get_post_meta($venue_id, '_VenueZip', true);
|
|
$country = get_post_meta($venue_id, '_VenueCountry', true);
|
|
|
|
if (!empty($address)) {
|
|
$parts[] = $address;
|
|
}
|
|
if (!empty($city)) {
|
|
$parts[] = $city;
|
|
}
|
|
if (!empty($state)) {
|
|
$parts[] = $state;
|
|
}
|
|
if (!empty($zip)) {
|
|
$parts[] = $zip;
|
|
}
|
|
if (!empty($country)) {
|
|
$parts[] = $country;
|
|
}
|
|
|
|
return implode(', ', $parts);
|
|
}
|
|
|
|
/**
|
|
* Make geocoding API request
|
|
*
|
|
* @param string $address Address to geocode
|
|
* @return array|null Result with lat/lng or null on failure
|
|
*/
|
|
private function geocode_address(string $address): ?array {
|
|
if (empty($this->api_key)) {
|
|
return ['error' => 'No API key configured'];
|
|
}
|
|
|
|
$url = 'https://maps.googleapis.com/maps/api/geocode/json';
|
|
$params = [
|
|
'address' => $address,
|
|
'key' => $this->api_key,
|
|
'components' => 'country:US|country:CA' // Restrict to North America
|
|
];
|
|
|
|
$response = wp_remote_get($url . '?' . http_build_query($params), [
|
|
'timeout' => 10,
|
|
'user-agent' => 'HVAC Training Directory/1.0'
|
|
]);
|
|
|
|
if (is_wp_error($response)) {
|
|
return ['error' => $response->get_error_message()];
|
|
}
|
|
|
|
$body = wp_remote_retrieve_body($response);
|
|
$data = json_decode($body, true);
|
|
|
|
if (!$data || $data['status'] !== 'OK' || empty($data['results'])) {
|
|
return ['error' => $data['status'] ?? 'Unknown error'];
|
|
}
|
|
|
|
$location = $data['results'][0]['geometry']['location'];
|
|
|
|
return [
|
|
'lat' => $location['lat'],
|
|
'lng' => $location['lng'],
|
|
'formatted_address' => $data['results'][0]['formatted_address'],
|
|
'place_id' => $data['results'][0]['place_id'] ?? ''
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Save coordinates to venue
|
|
*
|
|
* @param int $venue_id Venue post ID
|
|
* @param array $result Geocoding result
|
|
* @return bool Success
|
|
*/
|
|
private function save_coordinates(int $venue_id, array $result): bool {
|
|
if (!isset($result['lat']) || !isset($result['lng'])) {
|
|
return false;
|
|
}
|
|
|
|
// Save to our custom meta
|
|
update_post_meta($venue_id, 'venue_latitude', $result['lat']);
|
|
update_post_meta($venue_id, 'venue_longitude', $result['lng']);
|
|
|
|
// Also update TEC's meta for compatibility
|
|
update_post_meta($venue_id, '_VenueLat', $result['lat']);
|
|
update_post_meta($venue_id, '_VenueLng', $result['lng']);
|
|
|
|
if (!empty($result['formatted_address'])) {
|
|
update_post_meta($venue_id, '_venue_formatted_address', $result['formatted_address']);
|
|
}
|
|
|
|
update_post_meta($venue_id, '_venue_geocoding_status', 'success');
|
|
update_post_meta($venue_id, '_venue_geocoding_date', time());
|
|
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Handle geocoding failure
|
|
*
|
|
* @param int $venue_id Venue post ID
|
|
* @param array|null $result Error result
|
|
*/
|
|
private function handle_geocoding_failure(int $venue_id, ?array $result): void {
|
|
$error = $result['error'] ?? 'Unknown error';
|
|
|
|
update_post_meta($venue_id, '_venue_geocoding_status', 'failed');
|
|
update_post_meta($venue_id, '_venue_geocoding_error', $error);
|
|
|
|
// Handle specific errors
|
|
switch ($error) {
|
|
case 'OVER_QUERY_LIMIT':
|
|
// Retry in 1 hour
|
|
wp_schedule_single_event(time() + HOUR_IN_SECONDS, 'hvac_geocode_venue', [$venue_id]);
|
|
break;
|
|
|
|
case 'ZERO_RESULTS':
|
|
// Try fallback with less specific address
|
|
$this->try_fallback_geocoding($venue_id);
|
|
break;
|
|
|
|
default:
|
|
// Log error
|
|
if (defined('WP_DEBUG') && WP_DEBUG) {
|
|
error_log("HVAC Venue Geocoding failed for venue {$venue_id}: {$error}");
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Try fallback geocoding with city/state only
|
|
*
|
|
* @param int $venue_id Venue post ID
|
|
*/
|
|
private function try_fallback_geocoding(int $venue_id): void {
|
|
$city = get_post_meta($venue_id, '_VenueCity', true);
|
|
$state = get_post_meta($venue_id, '_VenueStateProvince', true) ?: get_post_meta($venue_id, '_VenueState', true);
|
|
$country = get_post_meta($venue_id, '_VenueCountry', true) ?: 'USA';
|
|
|
|
if (empty($city) && empty($state)) {
|
|
return;
|
|
}
|
|
|
|
$address = implode(', ', array_filter([$city, $state, $country]));
|
|
$result = $this->geocode_address($address);
|
|
|
|
if ($result && isset($result['lat'], $result['lng'])) {
|
|
$this->save_coordinates($venue_id, $result);
|
|
update_post_meta($venue_id, '_venue_geocoding_status', 'success_fallback');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check rate limiting
|
|
*
|
|
* @return bool Can make request
|
|
*/
|
|
private function check_rate_limit(): bool {
|
|
$rate_key = 'hvac_venue_geocoding_rate_' . gmdate('Y-m-d-H-i');
|
|
$current = get_transient($rate_key) ?: 0;
|
|
|
|
if ($current >= $this->rate_limit) {
|
|
return false;
|
|
}
|
|
|
|
set_transient($rate_key, $current + 1, 60);
|
|
return true;
|
|
}
|
|
|
|
/**
|
|
* Clear coordinates when venue address changes
|
|
*
|
|
* @param int $meta_id Meta ID
|
|
* @param int $post_id Post ID
|
|
* @param string $meta_key Meta key
|
|
* @param mixed $meta_value Meta value
|
|
*/
|
|
public function on_venue_meta_update(int $meta_id, int $post_id, string $meta_key, $meta_value): void {
|
|
if (get_post_type($post_id) !== 'tribe_venue') {
|
|
return;
|
|
}
|
|
|
|
$address_fields = ['_VenueAddress', '_VenueCity', '_VenueStateProvince', '_VenueState', '_VenueZip'];
|
|
|
|
if (in_array($meta_key, $address_fields, true)) {
|
|
// Address changed - clear coordinates to force re-geocoding
|
|
delete_post_meta($post_id, 'venue_latitude');
|
|
delete_post_meta($post_id, 'venue_longitude');
|
|
delete_post_meta($post_id, '_venue_geocoding_status');
|
|
|
|
// Schedule re-geocoding
|
|
wp_schedule_single_event(time() + 5, 'hvac_geocode_venue', [$post_id]);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* AJAX handler for batch geocoding venues
|
|
*/
|
|
public function ajax_batch_geocode(): void {
|
|
// Check permissions
|
|
if (!current_user_can('manage_options')) {
|
|
wp_send_json_error(['message' => 'Permission denied']);
|
|
return;
|
|
}
|
|
|
|
// Verify nonce
|
|
if (!isset($_POST['nonce']) || !wp_verify_nonce($_POST['nonce'], 'hvac_batch_geocode_venues')) {
|
|
wp_send_json_error(['message' => 'Invalid security token']);
|
|
return;
|
|
}
|
|
|
|
$limit = absint($_POST['limit'] ?? 10);
|
|
$result = $this->batch_geocode_venues($limit);
|
|
|
|
wp_send_json_success($result);
|
|
}
|
|
|
|
/**
|
|
* Batch geocode venues without coordinates
|
|
*
|
|
* @param int $limit Maximum venues to process
|
|
* @return array Results
|
|
*/
|
|
public function batch_geocode_venues(int $limit = 10): array {
|
|
$venues = get_posts([
|
|
'post_type' => 'tribe_venue',
|
|
'posts_per_page' => $limit,
|
|
'post_status' => 'publish',
|
|
'meta_query' => [
|
|
'relation' => 'AND',
|
|
[
|
|
'key' => 'venue_latitude',
|
|
'compare' => 'NOT EXISTS'
|
|
],
|
|
[
|
|
'key' => '_VenueLat',
|
|
'compare' => 'NOT EXISTS'
|
|
]
|
|
]
|
|
]);
|
|
|
|
$results = [
|
|
'processed' => 0,
|
|
'success' => 0,
|
|
'failed' => 0,
|
|
'remaining' => 0
|
|
];
|
|
|
|
foreach ($venues as $venue) {
|
|
if (!$this->check_rate_limit()) {
|
|
break;
|
|
}
|
|
|
|
$results['processed']++;
|
|
|
|
if ($this->geocode_venue($venue->ID)) {
|
|
$results['success']++;
|
|
} else {
|
|
$results['failed']++;
|
|
}
|
|
}
|
|
|
|
// Count remaining
|
|
$remaining_query = new WP_Query([
|
|
'post_type' => 'tribe_venue',
|
|
'posts_per_page' => 1,
|
|
'post_status' => 'publish',
|
|
'fields' => 'ids',
|
|
'meta_query' => [
|
|
'relation' => 'AND',
|
|
[
|
|
'key' => 'venue_latitude',
|
|
'compare' => 'NOT EXISTS'
|
|
],
|
|
[
|
|
'key' => '_VenueLat',
|
|
'compare' => 'NOT EXISTS'
|
|
]
|
|
]
|
|
]);
|
|
|
|
$results['remaining'] = $remaining_query->found_posts;
|
|
|
|
return $results;
|
|
}
|
|
|
|
/**
|
|
* Get geocoding status for a venue
|
|
*
|
|
* @param int $venue_id Venue post ID
|
|
* @return array Status info
|
|
*/
|
|
public function get_geocoding_status(int $venue_id): array {
|
|
return [
|
|
'has_coordinates' => $this->venue_has_coordinates($venue_id),
|
|
'latitude' => get_post_meta($venue_id, 'venue_latitude', true) ?: get_post_meta($venue_id, '_VenueLat', true),
|
|
'longitude' => get_post_meta($venue_id, 'venue_longitude', true) ?: get_post_meta($venue_id, '_VenueLng', true),
|
|
'status' => get_post_meta($venue_id, '_venue_geocoding_status', true),
|
|
'error' => get_post_meta($venue_id, '_venue_geocoding_error', true),
|
|
'last_attempt' => get_post_meta($venue_id, '_venue_geocoding_attempt', true),
|
|
'geocoded_date' => get_post_meta($venue_id, '_venue_geocoding_date', true)
|
|
];
|
|
}
|
|
|
|
/**
|
|
* Clear coordinates for a venue
|
|
*
|
|
* @param int $venue_id Venue post ID
|
|
*/
|
|
public function clear_coordinates(int $venue_id): void {
|
|
delete_post_meta($venue_id, 'venue_latitude');
|
|
delete_post_meta($venue_id, 'venue_longitude');
|
|
delete_post_meta($venue_id, '_VenueLat');
|
|
delete_post_meta($venue_id, '_VenueLng');
|
|
delete_post_meta($venue_id, '_venue_formatted_address');
|
|
delete_post_meta($venue_id, '_venue_geocoding_status');
|
|
delete_post_meta($venue_id, '_venue_geocoding_error');
|
|
delete_post_meta($venue_id, '_venue_geocoding_date');
|
|
}
|
|
}
|