<?php
|
/**
|
* Google Maps Integration
|
* File: /inc/integrations/GoogleMapsIntegration.php
|
*/
|
namespace JVBase\integrations;
|
|
use JVBase\registrar\Registrar;
|
use WP_Error;
|
|
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';
|
|
public 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->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 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();
|
?>
|
<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'; ?>
|
});
|
}
|
|
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;
|
}
|
|
/**
|
* 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);
|
}
|
}
|