| | |
| | | */ |
| | | |
| | | namespace JVBase\integrations\services; |
| | | use JVBase\integrations\Admin; |
| | | use JVBase\Instance; |
| | | use JVBase\integrations\Integrations; |
| | | use JVBase\registrar\Registrar; |
| | | use JVBase\integrations\Requests; |
| | | |
| | | if (!defined('ABSPATH')) { |
| | | exit; |
| | |
| | | |
| | | class GoogleMaps extends Integrations |
| | | { |
| | | private string $public_key; |
| | | private string $private_key; |
| | | private string $map_id; |
| | | private array $enabled_apis = []; |
| | | |
| | | // Edmonton default coordinates |
| | | private const DEFAULT_LAT = 53.5461; |
| | | private const DEFAULT_LNG = -113.4938; |
| | | private const DEFAULT_ZOOM = 11; // City-wide view |
| | | private const DEFAULT_MAP_ID = '873aed4ca260b4203e45b6e3'; |
| | | |
| | | protected static self $instance; |
| | | public static function getInstance():self |
| | | { |
| | | |
| | | if (!isset(self::$instance)) { |
| | | self::$instance = new self(); |
| | | } |
| | | return self::$instance; |
| | | } |
| | | |
| | | use Admin, Instance, Requests; |
| | | protected function __construct() |
| | | { |
| | | $this->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->defineFields(); |
| | | $this->defineInstructions(); |
| | | |
| | | $this->fields = [ |
| | | 'public_key' => [ |
| | | 'type' => 'text', |
| | | 'subtype' => 'password', |
| | | 'label' => 'Maps Platform API Key (Referrer-Restricted)', |
| | | 'hint' => 'Restrict this key by <b>HTTP referrers</b> 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 <br>IP Addresses</b>. 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 <a href="https://console.cloud.google.com/google/maps-apis/studio/maps" target="_blank">Google Cloud Console → Maps → Map Management</a>' |
| | | ], |
| | | 'default_zoom' => [ |
| | | 'type' => 'text', |
| | | 'subtype' => 'number', |
| | | 'min' => 1, |
| | | 'max' => 20, |
| | | 'label' => 'Default Zoom', |
| | | 'default' => 11 |
| | | ], |
| | | ]; |
| | | $this->instructions = [ |
| | | 'Create two API keys in <a href="https://console.cloud.google.com/apis/credentials" target="_blank">Google Cloud Console</a>', |
| | | 'Enable <strong>Geocoding API + Places API</strong> for frontend key, restricted by your server IP address', |
| | | 'Enable <strong>Maps Platform API</strong> 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 defineFields():void |
| | | { |
| | | $pub = $this->addField('public_key', 'Maps Platform API Key', 'text', 'manage_options'); |
| | | $pub->setHint('Restrict this key by HTTP referrers for security. Used for displaying maps in browsers.'); |
| | | $pub->setSubType('password'); |
| | | $pub->setRequired(); |
| | | |
| | | $priv = $this->addField('private_key', 'Geocoding & Places API Key', 'text', 'manage_options'); |
| | | $priv->setHint('Restrict this key by IP Addresses. Used for geocoding and server-side operations.'); |
| | | $priv->setRequired(); |
| | | $priv->setSubType('password'); |
| | | |
| | | $map_id = $this->addField('map_id', 'Map ID', 'text', 'manage_options'); |
| | | $map_id->setHint('Required for custom styling. Create a Map ID in Google Cloud Console -> Maps -> Map Management.'); |
| | | |
| | | $zoom = $this->addField('default_zoom', 'Default Zoom', 'text', 'manage_options'); |
| | | $zoom->setSubType('number'); |
| | | $zoom->setDefault(11); |
| | | $zoom->setMin(1); |
| | | $zoom->setMax(20); |
| | | |
| | | $lng = $this->addField('default_lng', 'Default Longitude', 'text', 'manage_options'); |
| | | $lng->setSubType('number'); |
| | | $lng->setDefault(-113.4938); |
| | | |
| | | $lat = $this->addField('default_lat', 'Default Latitude', 'text', 'manage_options'); |
| | | $lng->setSubType('number'); |
| | | $lng->setDefault(53.5461); |
| | | } |
| | | |
| | | protected function defineInstructions():void |
| | | { |
| | | $this->instructions = [ |
| | | 'Create two API keys in <a href="https://console.cloud.google.com/apis/credentials" target="_blank">Google Cloud Console</a>', |
| | | 'Enable <strong>Geocoding API + Places API</strong> for frontend key, restricted by your server IP address', |
| | | 'Enable <strong>Maps Platform API</strong> for server key, restricted by your website domain', |
| | | ]; |
| | | } |
| | | |
| | | protected function defineActions():void |
| | | { |
| | | $this->addAction( |
| | | 'reverse_geocode', |
| | | [$this, 'processReverseGeocode'] |
| | | ); |
| | | |
| | | $this->addAction( |
| | | 'geocode', |
| | | [$this, 'processGeocode'] |
| | | ); |
| | | } |
| | | public function processReverseGeocode(array $data):array |
| | | { |
| | | if (!array_key_exists('lat', $data) || !array_key_exists('lng', $data)) { |
| | | return $this->response(false, 'Invalid coordinates provided', $data); |
| | | } |
| | | $lat = floatval($data['lat']); |
| | | $lng = floatval($data['lng']); |
| | | |
| | | $result = $this->reverseGeocode($lat, $lng); |
| | | if ($result) { |
| | | return $this->response(true, 'Successfully reversed Geocode', ['data'=> $result]); |
| | | } |
| | | return $this->response(false, 'Reverse geocoding failed'); |
| | | } |
| | | |
| | | public function processGeocode(array $data):array |
| | | { |
| | | if (!array_key_exists('address', $data)) { |
| | | return $this->response(false, 'No address provided'); |
| | | } |
| | | $result = $this->geocodeAddress(sanitize_text_field($data['address'])); |
| | | if ($result) { |
| | | return $this->response(true, 'Successfully geocoded address', ['data' => $result]); |
| | | } |
| | | return $this->response(false, 'Geocoding failed'); |
| | | } |
| | | 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', |
| | |
| | | 'strategy' => 'defer' |
| | | ] |
| | | ); |
| | | } |
| | | public function getJavaScriptApiUrl(array $libraries = ['places', 'marker', 'geometry']): string |
| | | { |
| | | if (empty($this->getPublicKey())) { |
| | | return ''; |
| | | } |
| | | |
| | | $params = [ |
| | | 'key' => $this->getPublicKey(), |
| | | 'libraries' => implode(',', $libraries), |
| | | 'callback' => 'initGoogleMaps', |
| | | ]; |
| | | |
| | | return $this->apiBase . 'js?' . http_build_query($params); |
| | | } |
| | | |
| | | |
| | | public function maybeEnqueueMapsScript():void |
| | | { |
| | | if (!$this->isSetUp()) { |
| | | return; |
| | | } |
| | | if (!$this->shouldEnqueueMaps()) { |
| | | return; |
| | | } |
| | | |
| | | // 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) . '" |
| | | }; |
| | | wp_add_inline_script('google-maps-api', |
| | | sprintf( |
| | | ' |
| | | // Set map defaults |
| | | window.jvbMapDefaults = { |
| | | lat: %s, |
| | | lng: %s, |
| | | zoom: %s, |
| | | mapId: "%s" |
| | | }; |
| | | |
| | | // Google Maps initialization callback |
| | | window.initGoogleMaps = function() { |
| | | // Dispatch ready event |
| | | document.dispatchEvent(new Event("googleMapsReady")); |
| | | // 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(); |
| | | // Call any registered callbacks |
| | | if (window.googleMapsCallbacks && Array.isArray(window.googleMapsCallbacks)) { |
| | | window.googleMapsCallbacks.forEach(function(callback) { |
| | | if (typeof callback === "function") { |
| | | callback(); |
| | | } |
| | | }); |
| | | window.googleMapsCallbacks = []; |
| | | } |
| | | }); |
| | | window.googleMapsCallbacks = []; |
| | | } |
| | | }; |
| | | }; |
| | | |
| | | // Initialize callback queue |
| | | window.googleMapsCallbacks = window.googleMapsCallbacks || []; |
| | | ', 'before'); |
| | | // Initialize callback queue |
| | | window.googleMapsCallbacks = window.googleMapsCallbacks || []; |
| | | ', |
| | | $this->getDefaultLat(), |
| | | $this->getDefaultLng(), |
| | | $this->getDefaultZoom(), |
| | | esc_js($this->getMapID()) |
| | | ), |
| | | 'before'); |
| | | |
| | | // Enqueue scripts |
| | | wp_enqueue_script('google-maps-api'); |
| | |
| | | '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::withFeature('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'])) { |
| | | protected function shouldEnqueueMaps():bool |
| | | { |
| | | //See if it's a singular post/page that has the map feature flagged |
| | | if (is_singular() && get_post_meta(get_the_ID(), BASE.'has_map', true) === true) { |
| | | return true; |
| | | } |
| | | if (is_tax() && get_term_meta(get_queried_object_id(), BASE . 'has_map', true) === true) { |
| | | return true; |
| | | } |
| | | |
| | | // Allow filtering |
| | | return apply_filters('jvb_should_enqueue_maps', false); |
| | | } |
| | | |
| | | // Allow filtering |
| | | return apply_filters('jvb_should_enqueue_maps', false); |
| | | } |
| | | |
| | | /** |
| | | * Parse address components from Google response |
| | | */ |
| | | private function parseAddressComponents(array $components): array |
| | | public function getServiceDescription():string |
| | | { |
| | | $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; |
| | | return 'Allows for a map field, utilizing Google Maps'; |
| | | } |
| | | |
| | | /** |
| | | * Get Map ID |
| | | */ |
| | | public function getMapId(): string |
| | | /********************************************************************** |
| | | * UTILITY |
| | | **********************************************************************/ |
| | | protected function getPrivateKey():string|false |
| | | { |
| | | return $this->map_id; |
| | | return $this->loadCredentials()['private_key']??false; |
| | | } |
| | | |
| | | /** |
| | | * Get default map settings |
| | | */ |
| | | public function getDefaultMapSettings(): array |
| | | protected function getPublicKey():string|false |
| | | { |
| | | 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() |
| | | ]; |
| | | return $this->loadCredentials()['public_key']??false; |
| | | } |
| | | |
| | | /** |
| | | * Get map styles based on theme |
| | | */ |
| | | private function getMapStyles(): array |
| | | protected function getMapID():string|false |
| | | { |
| | | $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 []; |
| | | } |
| | | return $this->loadCredentials()['map_id']??false; |
| | | } |
| | | protected function getDefaultZoom():string|false |
| | | { |
| | | return $this->loadCredentials()['default_zoom']??false; |
| | | } |
| | | protected function getDefaultLng():string|false |
| | | { |
| | | return $this->loadCredentials()['default_lng']??false; |
| | | } |
| | | protected function getDefaultLat():string|false |
| | | { |
| | | return $this->loadCredentials()['default_lat']??false; |
| | | } |
| | | |
| | | |
| | | /** |
| | | * Generate map application links |
| | |
| | | 'waze' => "https://waze.com/ul?ll={$lat},{$lng}&navigate=yes" |
| | | ]; |
| | | } |
| | | |
| | | /** |
| | | * Render a frontend display map |
| | | */ |
| | | public function renderDisplayMap(array $location, array $options = []): string |
| | | private function parseAddressComponents(array $components):array |
| | | { |
| | | 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(); |
| | | ?> |
| | | <div id="<?= esc_attr($map_id); ?>" |
| | | class="jvb-location-map-display" |
| | | style="height: <?= esc_attr($map_options['height']); ?>; border-radius: 8px; overflow: hidden;"> |
| | | </div> |
| | | |
| | | <script> |
| | | document.addEventListener('DOMContentLoaded', function() { |
| | | function initDisplayMap() { |
| | | if (!window.jvbMaps || !window.jvbMaps.ready) { |
| | | setTimeout(initDisplayMap, 100); |
| | | return; |
| | | } |
| | | |
| | | window.jvbMaps.createDisplayMap('<?= esc_js($map_id); ?>', { |
| | | lat: <?= $lat; ?>, |
| | | lng: <?= $lng; ?>, |
| | | zoom: <?= intval($map_options['zoom']); ?>, |
| | | show_marker: <?= $map_options['show_marker'] ? 'true' : 'false'; ?>, |
| | | show_info_window: <?= $map_options['show_info_window'] ? 'true' : 'false'; ?>, |
| | | info_content: <?= json_encode($map_options['info_content']); ?>, |
| | | interactive: <?= $map_options['interactive'] ? 'true' : 'false'; ?> |
| | | }); |
| | | $parsed = []; |
| | | $check = [ |
| | | 'street_number' => 'long_name', |
| | | 'street' => 'long_name', |
| | | 'city' => 'long_name', |
| | | 'province' => 'short_name', |
| | | 'country' => 'short_name', |
| | | 'postal_code' => 'long_name' |
| | | ]; |
| | | foreach ($components as $c) { |
| | | $types = $c['types']; |
| | | foreach ($check as $ch => $index) { |
| | | if (in_array($ch, $types)) { |
| | | $parsed[$ch] = $c[$index]; |
| | | } |
| | | |
| | | if (typeof google !== 'undefined' && google.maps) { |
| | | initDisplayMap(); |
| | | } else { |
| | | document.addEventListener('googleMapsReady', initDisplayMap); |
| | | } |
| | | }); |
| | | </script> |
| | | <?php |
| | | |
| | | return ob_get_clean(); |
| | | } |
| | | |
| | | /** |
| | | * Render map application links |
| | | */ |
| | | public function renderMapLinks(float $lat, float $lng, ?string $address = null, array $options = []): string |
| | | { |
| | | $links = $this->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 = '<div class="jvb-map-links' . ($link_options['style'] === 'buttons' ? ' button-style' : ' text-style') . '">'; |
| | | |
| | | foreach ($link_options['include'] as $provider) { |
| | | if (!isset($links[$provider])) continue; |
| | | |
| | | $icon = ''; |
| | | $label = ''; |
| | | |
| | | switch ($provider) { |
| | | case 'google': |
| | | $icon = $link_options['show_icons'] ? jvbIcon('google') : ''; |
| | | $label = 'Google Maps'; |
| | | break; |
| | | case 'apple': |
| | | $icon = $link_options['show_icons'] ? jvbIcon('apple') : ''; |
| | | $label = 'Apple Maps'; |
| | | break; |
| | | case 'waze': |
| | | $icon = $link_options['show_icons'] ? jvbIcon('waze') : ''; |
| | | $label = 'Waze'; |
| | | break; |
| | | } |
| | | |
| | | $output .= sprintf( |
| | | '<a href="%s" target="%s" class="map-link %s-maps-link">%s<span>%s</span></a>', |
| | | esc_url($links[$provider]), |
| | | esc_attr($link_options['target']), |
| | | esc_attr($provider), |
| | | $icon, |
| | | esc_html($label) |
| | | ); |
| | | } |
| | | |
| | | $output .= '</div>'; |
| | | |
| | | return $output; |
| | | if (!empty($parsed['street_number']) && !empty($parsed['street'])) { |
| | | $parsed['street'] = $parsed['street_number'] . ' ' . $parsed['street']; |
| | | } |
| | | return $parsed; |
| | | } |
| | | |
| | | /** |
| | | * Format address for display |
| | | */ |
| | | public function formatAddress(array $location, string $format = 'full'): string |
| | | public function reverseGeocode(float $lat, float $lng):array|false |
| | | { |
| | | if (!empty($location['address'])) { |
| | | return $location['address']; |
| | | if (!$this->isSetUp()) { |
| | | return false; |
| | | } |
| | | $params = [ |
| | | 'latlng' => "{$lat},{$lng}", |
| | | 'key' => $this->getPrivateKey() |
| | | ]; |
| | | return $this->getRequest( |
| | | sprintf( |
| | | '%s/geocode/json', |
| | | $this->apiBase, |
| | | ), |
| | | $params |
| | | ); |
| | | } |
| | | protected function handleGeocodeResponse(array $data, string $method):array |
| | | { |
| | | if (!array_key_exists('status', $data) || $data['status'] !== 'OK' || !array_key_exists('results', $data) || empty($data['results'])) { |
| | | return $this->response(false, 'Invalid response provided'); |
| | | } |
| | | $result = $data['results'][0]; |
| | | return $this->response(true, 'Successfully reversed geocode',[ |
| | | '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_compenents']??[]) |
| | | ]); |
| | | } |
| | | |
| | | // Build address from components if no formatted address |
| | | $parts = []; |
| | | public function geocodeAddress(string $address):array|false |
| | | { |
| | | if (!$this->isSetUp()) { |
| | | return false; |
| | | } |
| | | $params = [ |
| | | 'address' => $address, |
| | | 'key' => $this->getPrivateKey() |
| | | ]; |
| | | return $this->getRequest( |
| | | sprintf( |
| | | '%s/geocode/json', |
| | | $this->apiBase |
| | | ) |
| | | ); |
| | | } |
| | | |
| | | 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; |
| | | protected function getRequestHeaders(): array |
| | | { |
| | | return [ |
| | | 'Accept' => 'application/json', |
| | | 'Content-Type' => 'application/json' |
| | | ]; |
| | | } |
| | | |
| | | case 'compact': |
| | | if (!empty($location['city']) && !empty($location['province'])) { |
| | | $parts[] = $location['city'] . ', ' . $location['province']; |
| | | } |
| | | break; |
| | | protected function handleResponse(\WP_Error|array $response, string $method, string $endpoint): array |
| | | { |
| | | if (is_wp_error($response) && $response->get_error_message() === 'rate_limit_exceeded') { |
| | | return $this->response(false, 'Rate limit exceeded'); |
| | | } |
| | | $endpoint = str_replace($this->apiBase, '', $endpoint); |
| | | return match ($endpoint) { |
| | | '/geocode/json' => $this->handleGeocodeResponse($response, $method), |
| | | default => $this->response(false, 'Unhandled response for '.$endpoint.' endpoint, with method: '.$method), |
| | | }; |
| | | } |
| | | |
| | | case 'city_only': |
| | | if (!empty($location['city'])) $parts[] = $location['city']; |
| | | break; |
| | | protected function performConnectionTest():bool |
| | | { |
| | | if (!$this->isSetUp()) { |
| | | return false; |
| | | } |
| | | |
| | | return implode(', ', $parts); |
| | | $test = 'Edmonton, Alberta, Canada'; |
| | | $result = $this->geocodeAddress($test); |
| | | return $result['success']; |
| | | } |
| | | } |