Jake Vanderwerf
2026-08-28 a9654f00c8fbc9934289d761843ddd071025013f
inc/integrations/services/GoogleMaps.php
@@ -5,8 +5,10 @@
 */
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;
@@ -14,468 +16,101 @@
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->defineActions();
      $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',
@@ -499,36 +134,69 @@
            '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');
@@ -541,134 +209,53 @@
         '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
@@ -683,152 +270,108 @@
         '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'];
   }
}