service_name = 'maps'; $this->title = 'Google Maps'; $this->icon = 'map-pin'; $this->apiBase = 'https://maps.googleapis.com/maps/api/'; $this->apiEndpoints = [ 'geocode/json', 'place/details/json', 'place/nearbysearch/json', 'distancematrix/json', 'js' ]; $this->fields = [ 'public_key' => [ 'type' => 'text', 'subtype' => 'password', 'label' => 'Maps Platform API Key (Referrer-Restricted)', 'hint' => 'Restrict this key by HTTP referrers for security. Used for displaying maps in browsers.', 'required' => true, ], 'private_key' => [ 'type' => 'text', 'subtype' => 'password', 'label' => 'Geocoding & Places API Key (IP-Restricted)', 'hint' => 'Restrict this key by
IP Addresses. Used for geocoding and server-side operations.', 'required' => true, ] ]; $this->advanced = [ 'map_id' => [ 'type' => 'text', 'label' => 'Map ID', 'hint' => 'Required for custom styling. Create a Map ID in Google Cloud Console → Maps → Map Management' ], 'default_zoom' => [ 'type' => 'text', 'subtype' => 'number', 'min' => 1, 'max' => 20, 'label' => 'Default Zoom', 'default' => 11 ], ]; $this->instructions = [ 'Create two API keys in Google Cloud Console', 'Enable Geocoding API + Places API for frontend key, restricted by your server IP address', 'Enable Maps Platform API for server key, restricted by your website domain', ]; $this->defaults = [ ]; parent::__construct(); $this->actions = array_merge( $this->actions, [ 'reverse_geocode' => 'processReverseGeocode', 'geocode' => 'processGeocode' ] ); // Auto-enqueue maps script when needed add_action('wp_enqueue_scripts', [$this, 'maybeEnqueueMapsScript']); // add_action('admin_enqueue_scripts', [$this, 'maybeEnqueueMapsScript']); } protected function initialize(): void { $this->private_key = $this->credentials['private_key'] ?? ''; $this->public_key = $this->credentials['public_key'] ?? ''; $this->map_id = $this->credentials['map_id'] ?? self::DEFAULT_MAP_ID; $this->rate_limits = ['min_interval' => 0]; // Google Maps has generous limits } /** * Get service description */ public function getServiceDescription(): string { return "Enable location features and embedded maps throughout your site."; } /** * Get request headers (required by base class) */ protected function getRequestHeaders(): array { // Google Maps API uses API key in URL params, not headers return [ 'Accept' => 'application/json', 'Content-Type' => 'application/json' ]; } /** * Process test connection request */ protected function performConnectionTest(): bool { if (!$this->isSetUp()) { return false; } // Test with a simple geocoding request $test_address = 'Edmonton, Alberta, Canada'; $result = $this->geocodeAddress($test_address); return $result !== null; } /** * Process reverse geocode request */ private function processReverseGeocode(array $data): WP_Error|array { $lat = floatval($data['lat'] ?? 0); $lng = floatval($data['lng'] ?? 0); if (!$lat || !$lng) { return new WP_Error('invalid_coordinates', 'Invalid coordinates provided'); } $result = $this->reverseGeocode($lat, $lng); if ($result) { return ['success' => true, 'data' => $result]; } return new WP_Error('geocoding_failed', 'Reverse geocoding failed'); } /** * Process geocode request */ private function processGeocode(array $data): WP_Error|array { $address = sanitize_text_field($data['address'] ?? ''); if (empty($address)) { return new WP_Error('invalid_address', 'No address provided'); } $result = $this->geocodeAddress($address); if ($result) { return ['success' => true, 'data' => $result]; } return new WP_Error('geocoding_failed', 'Geocoding failed'); } /** * Geocode an address to coordinates */ public function geocodeAddress(string $address): ?array { if (!$this->isSetUp()) { return null; } $params = [ 'address' => $address, 'key' => $this->private_key ]; $key = $this->cache->generateKey(['geocode', $address]); $cached = $this->cache->get($key); if ($cached) { return $cached; } $url = $this->apiBase . 'geocode/json?' . http_build_query($params); $response = wp_remote_get($url); if (is_wp_error($response)) { $this->logError('Geocoding request failed', [ 'error' => $response->get_error_message(), 'address' => $address ]); return null; } $data = json_decode(wp_remote_retrieve_body($response), true); if ($data['status'] === 'OK' && !empty($data['results'])) { $result = $data['results'][0]; $result = [ 'lat' => $result['geometry']['location']['lat'], 'lng' => $result['geometry']['location']['lng'], 'formatted_address' => $result['formatted_address'], 'place_id' => $result['place_id'] ?? '', 'components' => $this->parseAddressComponents($result['address_components'] ?? []) ]; $this->cache->set($key, $result); } else { $this->logError('Google Maps API error', [ 'status' => $data['status'] ?? 'unknown', 'address' => $address ], 'warning'); $result = null; } return $result; } /** * Reverse geocode coordinates to address */ public function reverseGeocode(float $lat, float $lng): ?array { if (!$this->isSetUp()) { return null; } $params = [ 'latlng' => "{$lat},{$lng}", 'key' => $this->private_key ]; $key = $this->cache->generateKey(['reverse', $lat, $lng]); $cached = $this->cache->get($key); if ($cached) { return $cached; } $url = $this->apiBase . 'geocode/json?' . http_build_query($params); $response = wp_remote_get($url); if (is_wp_error($response)) { $this->logError('Reverse geocoding request failed', [ 'error' => $response->get_error_message(), 'lat' => $lat, 'lng' => $lng ]); return null; } $data = json_decode(wp_remote_retrieve_body($response), true); if ($data['status'] === 'OK' && !empty($data['results'])) { $result = $data['results'][0]; $return = [ 'formatted_address' => $result['formatted_address'], 'place_id' => $result['place_id'] ?? '', 'components' => $this->parseAddressComponents($result['address_components'] ?? []) ]; $this->cache->set($key, $return); return $return; } return null; } /** * Get place details by place ID */ public function getPlaceDetails(string $place_id, array $fields = []): ?array { if (!$this->isSetUp()) { return null; } $default_fields = ['name', 'formatted_address', 'geometry', 'rating', 'formatted_phone_number']; $fields = empty($fields) ? $default_fields : $fields; $key = $this->cache->generateKey(['place', $place_id, $fields]); $cached = $this->cache->get($key); if ($cached) { return $cached; } $params = [ 'place_id' => $place_id, 'fields' => implode(',', $fields), 'key' => $this->private_key ]; $url = $this->apiBase . 'place/details/json?' . http_build_query($params); $response = wp_remote_get($url); if (is_wp_error($response)) { $this->logError('Place details request failed', [ 'error' => $response->get_error_message(), 'place_id' => $place_id ]); return null; } $data = json_decode(wp_remote_retrieve_body($response), true); if ($data['status'] === 'OK' && !empty($data['result'])) { $result = $data['result']; $this->cache->set($key, $result); return $result; } return null; } /** * Search for places nearby */ public function searchNearby(float $lat, float $lng, int $radius = 1000, string $type = ''): array { if (!$this->isSetUp()) { return []; } $params = [ 'location' => "{$lat},{$lng}", 'radius' => $radius, 'key' => $this->private_key ]; if (!empty($type)) { $params['type'] = $type; } $key = $this->cache->generateKey(['nearby', $lat, $lng, $radius, $type]); $cached = $this->cache->get($key); if ($cached) { return $cached; } $url = $this->apiBase . 'place/nearbysearch/json?' . http_build_query($params); $response = wp_remote_get($url); if (is_wp_error($response)) { $this->logError('Nearby search request failed', [ 'error' => $response->get_error_message(), 'lat' => $lat, 'lng' => $lng, 'radius' => $radius ]); return []; } $data = json_decode(wp_remote_retrieve_body($response), true); if ($data['status'] === 'OK' && !empty($data['results'])) { $return = $data['results']; $this->cache->set($key, $return); return $return; } return []; } /** * Calculate distance between two points */ public function calculateDistance(array $origin, array $destination, string $mode = 'driving'): ?array { if (!$this->isSetUp()) { return null; } // Handle different input formats $origin_str = is_array($origin) ? "{$origin['lat']},{$origin['lng']}" : $origin; $dest_str = is_array($destination) ? "{$destination['lat']},{$destination['lng']}" : $destination; $params = [ 'origins' => $origin_str, 'destinations' => $dest_str, 'mode' => $mode, 'units' => 'metric', 'key' => $this->private_key ]; $key = $this->cache->generateKey(['distance', $origin_str, $dest_str, $mode]); $cached = $this->cache->get($key); if ($cached) { return $cached; } $url = $this->apiBase . 'distancematrix/json?' . http_build_query($params); $response = wp_remote_get($url); if (is_wp_error($response)) { $this->logError('Distance calculation request failed', [ 'error' => $response->get_error_message(), 'origin' => $origin_str, 'destination' => $dest_str ]); return null; } $data = json_decode(wp_remote_retrieve_body($response), true); if ($data['status'] === 'OK' && !empty($data['rows'][0]['elements'][0])) { $element = $data['rows'][0]['elements'][0]; if ($element['status'] === 'OK') { $return = [ 'distance' => $element['distance'], 'duration' => $element['duration'], 'mode' => $mode ]; $this->cache->set($key, $return); return $return; } } return null; } /** * Get the JavaScript API URL with key */ public function getJavaScriptApiUrl(array $libraries = ['places', 'marker', 'geometry']): string { if (empty($this->public_key)) { return ''; } $params = [ 'key' => $this->public_key, 'libraries' => implode(',', $libraries), 'callback' => 'initGoogleMaps', ]; return $this->apiBase . 'js?' . http_build_query($params); } /** * Enqueue Google Maps script when needed */ public function maybeEnqueueMapsScript(): void { // Only enqueue if we're on a page that needs maps if (!$this->shouldEnqueueMaps()) { return; } if (!$this->isSetUp()) { return; } $this->ensureInitialized(); // Register Google Maps API wp_register_script( 'google-maps-api', $this->getJavaScriptApiUrl(['places', 'geometry', 'marker']), [], null, [ 'in_footer' => true, 'strategy' => 'async' ] ); // Register our maps handler wp_register_script( 'jvb-maps', JVB_URL . 'assets/js/min/maps.min.js', ['google-maps-api'], '1.0.0', [ 'in_footer' => true, 'strategy' => 'defer' ] ); // Add initialization script wp_add_inline_script('google-maps-api', ' // Set map defaults window.jvbMapDefaults = { lat: ' . self::DEFAULT_LAT . ', lng: ' . self::DEFAULT_LNG . ', zoom: ' . self::DEFAULT_ZOOM . ', mapId: "' . esc_js($this->map_id) . '" }; // Google Maps initialization callback window.initGoogleMaps = function() { // Dispatch ready event document.dispatchEvent(new Event("googleMapsReady")); // Call any registered callbacks if (window.googleMapsCallbacks && Array.isArray(window.googleMapsCallbacks)) { window.googleMapsCallbacks.forEach(function(callback) { if (typeof callback === "function") { callback(); } }); window.googleMapsCallbacks = []; } }; // Initialize callback queue window.googleMapsCallbacks = window.googleMapsCallbacks || []; ', 'before'); // Enqueue scripts wp_enqueue_script('google-maps-api'); wp_enqueue_script('jvb-maps'); // Add AJAX URL and nonce for JavaScript AJAX calls wp_localize_script('jvb-maps', 'jvbMapsAjax', [ 'ajaxurl' => admin_url('admin-ajax.php'), 'nonce' => wp_create_nonce('jvb_integration_' . $this->service_name), 'service' => $this->service_name ]); } /** * Check if maps should be enqueued on current page */ private function shouldEnqueueMaps(): bool { // Check for shortcodes if (is_singular() && get_post_meta(get_the_ID(), BASE . 'has_map', true) === true) { return true; } if (!empty(Registrar::getFeatured('is_content', 'term')) && get_term_meta(get_queried_object_id(), BASE . 'has_map', true) === true) { return true; } // Check for specific page templates or post types if (is_singular(['shop', 'artist', 'location'])) { return true; } // Check for admin pages that need maps if (is_admin()) { $screen = get_current_screen(); if ($screen && in_array($screen->post_type, ['shop', 'artist', 'location'])) { return true; } } // Allow filtering return apply_filters('jvb_should_enqueue_maps', false); } /** * Parse address components from Google response */ private function parseAddressComponents(array $components): array { $parsed = [ 'street_number' => '', 'street' => '', 'city' => '', 'province' => '', 'country' => '', 'postal_code' => '' ]; foreach ($components as $component) { $types = $component['types']; if (in_array('street_number', $types)) { $parsed['street_number'] = $component['long_name']; } if (in_array('route', $types)) { $parsed['street'] = $component['long_name']; } if (in_array('locality', $types)) { $parsed['city'] = $component['long_name']; } if (in_array('administrative_area_level_1', $types)) { $parsed['province'] = $component['short_name']; } if (in_array('country', $types)) { $parsed['country'] = $component['short_name']; } if (in_array('postal_code', $types)) { $parsed['postal_code'] = $component['long_name']; } } // Combine street number and street name if ($parsed['street_number'] && $parsed['street']) { $parsed['street'] = $parsed['street_number'] . ' ' . $parsed['street']; } return $parsed; } /** * Get Map ID */ public function getMapId(): string { return $this->map_id; } /** * Get default map settings */ public function getDefaultMapSettings(): array { return [ 'center' => [ 'lat' => self::DEFAULT_LAT, 'lng' => self::DEFAULT_LNG ], 'zoom' => $this->credentials['default_zoom'] ?? self::DEFAULT_ZOOM, 'mapId' => $this->map_id, 'styles' => $this->getMapStyles() ]; } /** * Get map styles based on theme */ private function getMapStyles(): array { $theme = $this->credentials['theme'] ?? 'default'; switch ($theme) { case 'dark': return [ ['elementType' => 'geometry', 'stylers' => [['color' => '#242f3e']]], ['elementType' => 'labels.text.fill', 'stylers' => [['color' => '#746855']]], ['elementType' => 'labels.text.stroke', 'stylers' => [['color' => '#242f3e']]], ['featureType' => 'road', 'elementType' => 'geometry', 'stylers' => [['color' => '#38414e']]], ['featureType' => 'water', 'elementType' => 'geometry', 'stylers' => [['color' => '#17263c']]] ]; case 'minimal': return [ ['featureType' => 'poi', 'stylers' => [['visibility' => 'off']]], ['featureType' => 'transit', 'stylers' => [['visibility' => 'off']]] ]; default: return []; } } /** * Generate map application links */ public function generateMapLinks(float $lat, float $lng, ?string $address = null): array { $query = $address ? urlencode($address) : "{$lat},{$lng}"; return [ 'google' => "https://www.google.com/maps/search/?api=1&query={$query}", 'apple' => "https://maps.apple.com/?ll={$lat},{$lng}", 'waze' => "https://waze.com/ul?ll={$lat},{$lng}&navigate=yes" ]; } /** * Render a frontend display map */ public function renderDisplayMap(array $location, array $options = []): string { if (!$this->isSetUp() || empty($location['lat']) || empty($location['lng'])) { return ''; } $map_id = 'map-' . uniqid(); $lat = (float)$location['lat']; $lng = (float)$location['lng']; $address = $location['address'] ?? ''; $map_options = array_merge([ 'height' => '250px', 'zoom' => 15, 'show_marker' => true, 'show_info_window' => true, 'info_content' => $address, 'interactive' => true ], $options); ob_start(); ?>
generateMapLinks($lat, $lng, $address); $link_options = array_merge([ 'show_icons' => true, 'style' => 'buttons', // 'buttons' or 'text' 'include' => ['google', 'apple'], // which links to show 'target' => '_blank' ], $options); $output = ''; return $output; } /** * Format address for display */ public function formatAddress(array $location, string $format = 'full'): string { if (!empty($location['address'])) { return $location['address']; } // Build address from components if no formatted address $parts = []; switch ($format) { case 'full': if (!empty($location['street'])) $parts[] = $location['street']; if (!empty($location['city'])) $parts[] = $location['city']; if (!empty($location['province'])) $parts[] = $location['province']; if (!empty($location['postal_code'])) $parts[] = $location['postal_code']; if (!empty($location['country'])) $parts[] = $location['country']; break; case 'compact': if (!empty($location['city']) && !empty($location['province'])) { $parts[] = $location['city'] . ', ' . $location['province']; } break; case 'city_only': if (!empty($location['city'])) $parts[] = $location['city']; break; } return implode(', ', $parts); } }