| | |
| | | <?php |
| | | namespace JVBase\integrations\services; |
| | | use Exception; |
| | | use JVBase\integrations\Admin; |
| | | use JVBase\Instance; |
| | | use JVBase\integrations\Integrations; |
| | | use JVBase\meta\Meta; |
| | | use JVBase\integrations\OAuth; |
| | | use JVBase\integrations\OAuthCredentials; |
| | | use JVBase\integrations\OAuthURLs; |
| | | use JVBase\integrations\Requests; |
| | | use JVBase\integrations\SyncToPost; |
| | | use JVBase\integrations\UserConnection; |
| | | use WP_Error; |
| | | |
| | | |
| | | if (!defined('ABSPATH')) { |
| | | exit; |
| | |
| | | |
| | | class GoogleMyBusiness extends Integrations |
| | | { |
| | | protected array $allowedContent = [ |
| | | 'menu_item', |
| | | 'post', |
| | | 'event', |
| | | ]; |
| | | private ?string $access_token = null; |
| | | use Admin, Instance, OAuth, Requests, UserConnection, SyncToPost; |
| | | |
| | | protected string $readMask = 'name,title,storefrontAddress,metadata,openInfo,storeCode,categories,phoneNumbers,labels,specialHours'; |
| | | private ?string $location = null; |
| | | private ?string $refresh_token = null; |
| | | private ?string $client_id = null; |
| | | private ?string $client_secret = null; |
| | | private ?string $account_id = null; |
| | | |
| | | protected static array $instances = []; |
| | | public static function getInstance(?int $userID = null):self |
| | | { |
| | | $key = is_null($userID) ? 'base' : $userID; |
| | | if (!array_key_exists($key, self::$instances)) { |
| | | self::$instances[$key] = new self($userID); |
| | | } |
| | | return self::$instances[$key]; |
| | | } |
| | | |
| | | protected function __construct(?int $userID = null) |
| | | protected string $businessV4; |
| | | protected function __construct() |
| | | { |
| | | $this->service_name = 'gmb'; |
| | | $this->title = 'Google My Business'; |
| | | $this->icon = 'storefront'; |
| | | $this->cacheName = ($userID)? 'gmb_'.$userID : 'gmb'; |
| | | |
| | | $this->canSync = [ |
| | | 'initial' => true, |
| | | 'update' => true, |
| | | 'delete' => true, |
| | | ]; |
| | | $this->apiBase = [ |
| | | 'base' => 'https://mybusinessbusinessinformation.googleapis.com', |
| | | 'accounts' => 'https://mybusinessaccountmanagement.googleapis.com', |
| | | 'posts' => 'https://mybusiness.googleapis.com', |
| | | 'performance'=> 'https://businessprofileperformance.googleapis.com', |
| | | 'v4' => 'https://mybusiness.googleapis.com/v4', |
| | | ]; |
| | | $this->supportsWebp = false; |
| | | |
| | | $this->apiEndpoints = [ |
| | | '/accounts/[^/]+/locations/[^/]+/reviews', |
| | | '/accounts/[^/]+/locations/[^/]+/foodMenus', |
| | | '/v4/accounts/[^/]+/locations/[^/]+/media', |
| | | '/v4/accounts/[^/]+/locations/[^/]+/localPosts', |
| | | '/v4/accounts/[^/]+/locations/[^/]+/localPosts/[^/]+', |
| | | '/accounts/[^/]+/locations/[^/]+', |
| | | '/v1/accounts/[^/]+/locations', // Matches /v1/accounts/{accountId}/locations |
| | | '/v1/accounts', |
| | | // Add pattern matching for dynamic location endpoints |
| | | '/v1/locations/[^/]+', // Matches /v1/locations/{locationId} |
| | | ]; |
| | | $this->baseUrl = 'https://mybusinessbusinessinformation.googleapis.com'; |
| | | $this->businessV4 = 'https://mybusiness.googleapis.com/v4'; |
| | | |
| | | $this->isOAuthService = true; |
| | | $this->oauth = [ |
| | | 'authorize' => 'https://accounts.google.com/o/oauth2/v2/auth', |
| | | 'token' => 'https://oauth2.googleapis.com/token', |
| | | 'revoke' => 'https://oauth2.googleapis.com/revoke', |
| | | 'scopes' => [ |
| | | $this->defineFields(); |
| | | $this->defineInstructions(); |
| | | $this->defineOAuth(); |
| | | $this->defineActions(); |
| | | |
| | | parent::__construct(); |
| | | } |
| | | protected function defineFields():void |
| | | { |
| | | $client_id = $this->addField( |
| | | 'client_id', |
| | | 'OAuth Client ID', |
| | | 'text', |
| | | 'manage_options' |
| | | ); |
| | | $client_id->setRequired(); |
| | | $client_id->setHint('From your Google Cloud Console OAuth credentials.'); |
| | | $client_id->makeSetupField(); |
| | | $this->updateField('client_id', $client_id); |
| | | |
| | | $secret = $this->addField( |
| | | 'client_secret', |
| | | 'OAuth Client Secret', |
| | | 'text', |
| | | 'manage_options', |
| | | ); |
| | | $secret->setSubType('password'); |
| | | $secret->setRequired(); |
| | | $secret->makeSetupField(); |
| | | $this->updateField('client_secret', $client_id); |
| | | |
| | | if ($this->getAccessToken()) { |
| | | $accs = $this->addField('accounts', 'Accounts', 'select', [$this, 'testUserCanConnect']); |
| | | $accs->setHidden(); |
| | | $accs->setOptions($this->getAccountOptions()); |
| | | |
| | | $acc = $this->addField('account_id', 'Set Account', 'select', [$this, 'testUserCanConnect']); |
| | | $acc->setOptions($this->loadCredentials()['accounts']??[]); |
| | | $acc->setHint('Select the account to connect to.'); |
| | | |
| | | if ($this->getAccountID()) { |
| | | $locs = $this->addField('locations', 'Locations', 'select', [$this, 'testUserCanConnect']); |
| | | $locs->setHidden(); |
| | | $locs->setOptions($this->getLocationOptions()); |
| | | |
| | | $loc = $this->addField('location_id', 'Set Location', 'select', [$this, 'testUserCanConnect']); |
| | | $loc->setOptions($this->loadCredentials()['locations']??[]); |
| | | $loc->setHint('Select the location to connect to.'); |
| | | } |
| | | } |
| | | |
| | | } |
| | | public function testUserCanConnect():bool |
| | | { |
| | | $user = get_current_user_id(); |
| | | return $this->userCanConnect($user); |
| | | } |
| | | protected function defineInstructions():void |
| | | { |
| | | $this->instructions = [ |
| | | 'Go to <a href="https://console.cloud.google.com/apis/credentials" target="_blank">Google Cloud Console</a>', |
| | | 'Create OAuth 2.0 Client ID credentials', |
| | | 'Enable Google My Business API', |
| | | 'Add redirect URI: <code>'.esc_html($this->getRedirectUri()).'</code>', |
| | | 'Use the "Authorize" button below to complete OAuth flow' |
| | | ]; |
| | | |
| | | $this->instructions = [ |
| | | 'Go to <a href="https://console.cloud.google.com/apis/credentials" target="_blank">Google Cloud Console</a>', |
| | | 'Create OAuth 2.0 Client ID credentials', |
| | | 'Enable the following APIs in your project:', |
| | | [ |
| | | '<a href="https://console.cloud.google.com/apis/library/mybusinessaccountmanagement.googleapis.com" target="_blank">My Business Account Management API</a>', |
| | | '<a href="https://console.cloud.google.com/apis/library/mybusinessbusinessinformation.googleapis.com" target="_blank">My Business Business Information API</a>', |
| | | '<a href="https://console.cloud.google.com/apis/library/mybusiness.googleapis.com" target="_blank">Google My Business API</a> (legacy v4, required for reviews)', |
| | | ], |
| | | 'On the OAuth consent screen, add these scopes:', |
| | | [ |
| | | '<code>https://www.googleapis.com/auth/business.manage</code>', |
| | | '<code>https://www.googleapis.com/auth/userinfo.email</code>', |
| | | '<code>https://www.googleapis.com/auth/userinfo.profile</code>', |
| | | ], |
| | | 'Add redirect URI: <code>'.esc_html($this->getRedirectUri()).'</code>', |
| | | 'Use the "Authorize" button below to complete OAuth flow' |
| | | ]; |
| | | } |
| | | protected function defineOAuth():void |
| | | { |
| | | $this->oauth = new OAuthURLs( |
| | | 'https://accounts.google.com/o/oauth2/v2/auth', |
| | | 'https://oauth2.googleapis.com/token', |
| | | 'https://oauth2.googleapis.com/revoke' |
| | | ); |
| | | |
| | | $this->oathScopes = [ |
| | | 'https://www.googleapis.com/auth/business.manage', |
| | | 'https://www.googleapis.com/auth/userinfo.email', |
| | | 'https://www.googleapis.com/auth/userinfo.profile', |
| | | 'https://www.googleapis.com/auth/plus.business.manage' |
| | | ] |
| | | ]; |
| | | |
| | | $this->supportsWebp = false; |
| | | |
| | | $this->rate_limits = [ |
| | | 'per_second' => 15, // Allow 15 requests in burst window |
| | | 'per_minute' => 60, // 1-minute burst window |
| | | 'per_hour' => 500 // 500 requests per hour (well below Google's limits) |
| | | ]; |
| | | |
| | | $this->fields = [ |
| | | 'client_id' => [ |
| | | 'type' => 'text', |
| | | 'label' => 'OAuth Client ID', |
| | | 'placeholder'=> 'Enter Google OAuth Client ID', |
| | | 'hint' => 'From your Google Cloud Console OAuth credentials.', |
| | | 'required' => true, |
| | | ], |
| | | 'client_secret' => [ |
| | | 'type' => 'text', |
| | | 'subtype' => 'password', |
| | | 'label' => 'OAuth Client Secret', |
| | | 'required' => true, |
| | | ], |
| | | // 'access_token' => [ |
| | | // 'type' => 'text', |
| | | // 'subtype' => 'password', |
| | | // 'label' => 'Access Token', |
| | | // 'hint' => 'Generated automagically after OAuth authorization.' |
| | | // ], |
| | | // 'refresh_token' => [ |
| | | // 'type' => 'text', |
| | | // 'subtype' => 'password', |
| | | // 'label' => 'Refresh Token', |
| | | // 'hint' => 'Generated automagically after OAuth authorization.' |
| | | // ] |
| | | ]; |
| | | |
| | | $this->advanced = [ |
| | | |
| | | ]; |
| | | |
| | | $this->instructions = [ |
| | | 'Go to <a href="https://console.cloud.google.com/apis/credentials" target="_blank">Google Cloud Console</a>', |
| | | 'Create OAuth 2.0 Client ID credentials', |
| | | 'Enable Google My Business API', |
| | | 'Add redirect URI: <code>'.esc_html($this->getRedirectUri()).'</code>', |
| | | 'Use the "Authorize" button below to complete OAuth flow' |
| | | ]; |
| | | |
| | | parent::__construct(); |
| | | |
| | | $this->actions = array_merge( |
| | | $this->actions, |
| | | [ |
| | | 'update_location' => 'handleUpdateLocation', |
| | | 'select_account' => 'handleSelectAccount', |
| | | 'select_location' => 'handleSelectLocation', |
| | | 'check_oauth_status'=> 'checkOAuthStatus' |
| | | ] |
| | | ); |
| | | $this->buttons = array_merge( |
| | | $this->buttons, |
| | | [ |
| | | 'check_oauth_status' => 'Check OAuth Status' |
| | | ] |
| | | ); |
| | | |
| | | if (JVB_TESTING) { |
| | | $this->cache->flush(); |
| | | ]; |
| | | } |
| | | } |
| | | protected function defineActions():void |
| | | { |
| | | if ($this->hasOAuthCredentials($this->userID)) { |
| | | $this->addAction( |
| | | 'Update Location', |
| | | [$this, 'handleUpdateLocation'], |
| | | 'update_location', |
| | | ); |
| | | } |
| | | } |
| | | public function handleUpdateLocation(array $data):array |
| | | { |
| | | if (empty($data['location_id']??'')) { |
| | | return $this->response(false, 'No location selected'); |
| | | } |
| | | |
| | | $credentials = [ |
| | | 'location_id' => sanitize_text_field($data['location_id']) |
| | | ]; |
| | | return $this->saveCredentials($credentials, false); |
| | | } |
| | | |
| | | protected function initialize(): void |
| | | { |
| | | if (empty($this->credentials)) { |
| | | $this->loadCredentials(); |
| | | } |
| | | $this->access_token = (array_key_exists('access_token', $this->credentials)) ? $this->credentials['access_token'] : null; |
| | | $this->refresh_token = (array_key_exists('refresh_token', $this->credentials)) ? $this->credentials['refresh_token'] : null; |
| | | $this->client_id = (array_key_exists('client_id', $this->credentials)) ? $this->credentials['client_id'] : null; |
| | | $this->client_secret = (array_key_exists('client_secret', $this->credentials)) ? $this->credentials['client_secret'] : null; |
| | | $this->location = (array_key_exists('location', $this->credentials)) ? $this->credentials['location'] : null; |
| | | $this->account_id = (array_key_exists('account', $this->credentials)) ? $this->credentials['account'] : null; |
| | | |
| | | if ($this->account_id) { |
| | | $this->apiEndpoints[] = "/v1/{$this->account_id}/locations"; |
| | | } |
| | | if ($this->location) { |
| | | $locationName = $this->getSelectedLocationResourceName(); |
| | | $this->apiEndpoints[] = "/v4/{$locationName}/localPosts"; |
| | | } |
| | | } |
| | | |
| | | protected function performConnectionTest(): bool |
| | | { |
| | | try { |
| | | $accounts = $this->getAccounts(true); |
| | | return !empty($accounts); |
| | | } catch (\Exception $e) { |
| | | return false; |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Check if response contains an error - Google-specific |
| | | */ |
| | | protected function isErrorResponse(array $response): bool |
| | | { |
| | | // Google APIs return errors in this format: |
| | | // {"error": {"code": 401, "message": "...", "status": "UNAUTHENTICATED"}} |
| | | return isset($response['error']) && isset($response['error']['code']); |
| | | // TODO: Implement initialize() method. |
| | | } |
| | | |
| | | protected function getRequestHeaders(): array |
| | | { |
| | | |
| | | return [ |
| | | 'Authorization' => 'Bearer ' . $this->access_token, |
| | | 'Authorization' => 'Bearer ' . $this->getAccessToken(), |
| | | 'Content-Type' => 'application/json', |
| | | 'Accept' => 'application/json' |
| | | ]; |
| | | } |
| | | |
| | | protected function addOAuthParams(array $params): array |
| | | protected function handleResponse(WP_Error|array $response, string $method, string $endpoint): array |
| | | { |
| | | $params['access_type'] = 'offline'; |
| | | $params['prompt'] = 'consent'; |
| | | return $params; |
| | | if (is_wp_error($response) && $response->get_error_message() === 'rate_limit_exceeded') { |
| | | return $this->response(false, 'Rate limit exceeded'); |
| | | } |
| | | |
| | | return match($endpoint) { |
| | | 'https://mybusinessaccountmanagement.googleapis.com/v1/accounts' => $this->handleGetAccountsResponse($response, $method), |
| | | sprintf('%s/%s/%s/reviews', $this->businessV4, $this->getAccountID()??'', $this->getLocationID()??'') => $this->handleGetReviewResponse($response, $method), |
| | | sprintf('https://mybusinessbusinessinformation.googleapis.com/v1/%s/locations', $this->getAccountID()??'') => $this->handleGetLocationsResponse($response, $method), |
| | | sprintf('%s/v1/%s', $this->baseUrl,$this->getLocationID()??'') => $this->handleGetLocationResponse($response, $method), |
| | | default => $this->response(false, 'Unhandled response for '.$endpoint.' endpoint, with method: '.$method) |
| | | }; |
| | | } |
| | | |
| | | protected function addCredentialData(array $credentials, array $tokens): array |
| | | protected function refreshAccessToken(OAuthCredentials $credentials): array |
| | | { |
| | | // Preserve GMB-specific settings |
| | | $existing = $this->credentials; |
| | | $credentials['location'] = $existing['location'] ?? null; |
| | | $credentials['account'] = $existing['account'] ?? null; |
| | | return $credentials; |
| | | } |
| | | |
| | | public function handleTheSavePost(int $postID, \WP_Post $post, bool $update, array $settings):void |
| | | { |
| | | |
| | | if (!$this->hasOAuthCredentials()) { |
| | | error_log('OAuth Not set up for '.$this->service_name); |
| | | return; |
| | | if (empty($this->getClientID()) || empty($this->getClientSecret()) || empty($credentials->refresh_token)) { |
| | | $this->logError('refreshAccessToken', 'Missing refresh token'); |
| | | return $this->response(false, 'Missing client ID, client Secret, or refresh token'); |
| | | } |
| | | |
| | | $this->queueOperation(self::$syncTo, [ |
| | | 'items' => [$postID], |
| | | 'user' => user_can($post->post_author, 'manage_options') ? null : $post->post_author |
| | | ], [ |
| | | 'delay' => 60 |
| | | ]); |
| | | Meta::forPost($postID)->set('_'.$this->service_name.'_sync_status', 'queued'); |
| | | } |
| | | |
| | | public function processOperation(\WP_Error|array $result, object $operation, array $data):\WP_Error|array |
| | | { |
| | | try { |
| | | $gmb = (array_key_exists('user', $data) && $data['user'] !== 0) ? $this->getInstance((int)$data['user']) : $this; |
| | | $gmb->ensureInitialized(); |
| | | switch ($operation->type) { |
| | | case 'gmb_create_menu_item': |
| | | case 'gmb_update_menu_item': |
| | | case 'gmb_menu_update': |
| | | case 'gmb_menu_update_' . $this->userID: |
| | | return $gmb->processMenuUpdate($operation, $data); |
| | | case 'gmb_create_event': |
| | | case 'gmb_update_event': |
| | | return $gmb->syncEventPost($operation, $data); |
| | | case 'gmb_create_offer': |
| | | case 'gmb_update_offer': |
| | | return $gmb->syncOfferPost($operation, $data); |
| | | case 'gmb_create_post': |
| | | case 'gmb_update_post': |
| | | return $gmb->syncStandardPost($operation, $data); |
| | | case 'gmb_specialHours': |
| | | return $gmb->setSpecialHours($data['date'], $data['data']); |
| | | case 'gmb_business_hours': |
| | | return $gmb->updateBusinessHours($data); |
| | | case 'gmb_business_info': |
| | | return $gmb->updateBusinessInfo($data); |
| | | case 'gmb_update_attributes': |
| | | return $gmb->updateAttributes($data); |
| | | default: |
| | | return $result; |
| | | } |
| | | } catch (\Exception $e) { |
| | | error_log('[GMB] Operation processing error: ' . $e->getMessage()); |
| | | return [ |
| | | 'success' => false, |
| | | 'result' => $e->getMessage() |
| | | ]; |
| | | } |
| | | } |
| | | |
| | | protected function syncEventPost(object $operation, array $data):\WP_Error|array |
| | | { |
| | | $location = $this->getSelectedLocationResourceName(); |
| | | |
| | | if (!$location) { |
| | | return [ |
| | | 'success' => false, |
| | | 'result' => 'No location Name set.' |
| | | ]; |
| | | } |
| | | |
| | | $postID = $data['post_id']; |
| | | $meta = Meta::forPost($postID); |
| | | $fields = [ |
| | | 'start_date', |
| | | 'end_date', |
| | | 'start_time', |
| | | 'end_time', |
| | | 'post_excerpt', |
| | | 'post_title', |
| | | 'post_thumbnail', |
| | | "_{$this->service_name}_item_id" |
| | | ]; |
| | | |
| | | $data = [ |
| | | 'summary' => $fields['post_excerpt'], |
| | | 'topic_type' => 'EVENT', |
| | | 'event' => [ |
| | | 'title' => $fields['post_title'], |
| | | 'start_date' => $this->formatDate($fields['start_date']), |
| | | 'end_date' => $this->formatDate(($fields['end_date'] === '') ? $fields['start_date'] : $fields['end_date']), |
| | | 'start_time' => $this->formatTime($fields['start_time']), |
| | | 'end_time' => $this->formatTime($fields['end_time']), |
| | | ] |
| | | ]; |
| | | if ($fields['post_thumbnail'] !== '') { |
| | | $url = wp_get_attachment_url($fields['post_thumbnail']); |
| | | if ($url) { |
| | | $data['media'] = [ |
| | | ['mediaFormat' => 'PHOTO', 'sourceUrl' => $url] |
| | | ]; |
| | | } |
| | | } |
| | | |
| | | if ($fields["_{$this->service_name}_item_id"] !== '') { |
| | | $result = $this->updatePost($fields["_{$this->service_name}_item_id"], $data); |
| | | } else { |
| | | $result = $this->createPost($data); |
| | | $meta->set("_{$this->service_name}_item_id", $result['name']); |
| | | } |
| | | |
| | | return [ |
| | | 'success' => !empty($result), |
| | | 'result' => $result |
| | | ]; |
| | | } |
| | | |
| | | protected function syncOfferPost(object $operation, array $data):\WP_Error|array |
| | | { |
| | | $location = $this->getSelectedLocationResourceName(); |
| | | |
| | | if (!$location) { |
| | | return [ |
| | | 'success' => false, |
| | | 'result' => 'No location Name set.' |
| | | ]; |
| | | } |
| | | |
| | | $postID = $data['post_id']; |
| | | $meta = Meta::forPost($postID); |
| | | $fields = [ |
| | | 'post_excerpt', |
| | | 'post_title', |
| | | 'coupon_code', |
| | | 'termsConditions', |
| | | 'post_thumbnail', |
| | | "_{$this->service_name}_item_id" |
| | | ]; |
| | | |
| | | $data = [ |
| | | 'summary' => $fields['post_excerpt'], |
| | | 'topic_type' => 'OFFER', |
| | | 'offer' => [ |
| | | 'couponCode' => ($fields['coupon_code'] === '') ? $fields['post_title'] : $fields['coupon_code'], |
| | | 'redeemOnlineUrl'=> get_the_permalink($postID), |
| | | // 'termsConditions'=> get_bloginfo('terms_condition') TODO: setup terms and Conditions |
| | | ] |
| | | ]; |
| | | if ($fields['post_thumbnail'] !== '') { |
| | | $url = wp_get_attachment_url($fields['post_thumbnail']); |
| | | if ($url) { |
| | | $data['media'] = [ |
| | | ['mediaFormat' => 'PHOTO', 'sourceUrl' => $url] |
| | | ]; |
| | | } |
| | | } |
| | | |
| | | if ($fields["_{$this->service_name}_item_id"] !== '') { |
| | | $result = $this->updatePost($fields["_{$this->service_name}_item_id"], $data); |
| | | } else { |
| | | $result = $this->createPost($data); |
| | | $meta->set("_{$this->service_name}_item_id", $result['name']); |
| | | } |
| | | |
| | | return [ |
| | | 'success' => !empty($result), |
| | | 'result' => $result |
| | | ]; |
| | | } |
| | | |
| | | |
| | | protected function syncStandardPost(object $operation, array $data):\WP_Error|array |
| | | { |
| | | $location = $this->getSelectedLocationResourceName(); |
| | | |
| | | if (!$location) { |
| | | return [ |
| | | 'success' => false, |
| | | 'result' => 'No location Name set.' |
| | | ]; |
| | | } |
| | | |
| | | $postID = $data['post_id']; |
| | | $meta = Meta::forPost($postID); |
| | | $fields = [ |
| | | 'post_excerpt', |
| | | 'post_title', |
| | | 'post_thumbnail', |
| | | "_{$this->service_name}_item_id" |
| | | ]; |
| | | |
| | | $data = [ |
| | | 'summary' => $fields['post_excerpt'], |
| | | 'topic_type' => 'STANDARD', |
| | | 'call_to_action' => [ |
| | | 'type' => 'learn_more', |
| | | 'url'=> get_the_permalink($postID), |
| | | ] |
| | | ]; |
| | | if ($fields['post_thumbnail'] !== '') { |
| | | $url = wp_get_attachment_url($fields['post_thumbnail']); |
| | | if ($url) { |
| | | $data['media'] = [ |
| | | ['mediaFormat' => 'PHOTO', 'sourceUrl' => $url] |
| | | ]; |
| | | } |
| | | } |
| | | |
| | | if ($fields["_{$this->service_name}_item_id"] !== '') { |
| | | $result = $this->updatePost($fields["_{$this->service_name}_item_id"], $data); |
| | | } else { |
| | | $result = $this->createPost($data); |
| | | $meta->set("_{$this->service_name}_item_id", $result['name']); |
| | | } |
| | | |
| | | return [ |
| | | 'success' => !empty($result), |
| | | 'result' => $result |
| | | ]; |
| | | } |
| | | |
| | | protected function processMenuUpdate($operation, $data) { |
| | | try { |
| | | |
| | | $postID = $data['post_id']; |
| | | if (!$postID) { |
| | | return [ |
| | | 'success' => false, |
| | | 'result' => 'No post ID set' |
| | | ]; |
| | | } |
| | | $post = get_post($postID); |
| | | $postType = jvbNoBase($post->post_type); |
| | | |
| | | $args = [ |
| | | 'post_type' => jvbCheckBase($postType), |
| | | 'post_status' => 'publish', |
| | | 'posts_per_page' => -1, |
| | | 'meta_query' => [ |
| | | [ |
| | | 'key' => BASE.'_keep_synced_' . $this->service_name, |
| | | 'value' => '1', |
| | | 'compare' => '=' |
| | | ] |
| | | ] |
| | | ]; |
| | | |
| | | $menu_items = get_posts($args); |
| | | |
| | | if (empty($menu_items)) { |
| | | return [ |
| | | 'success' => true, |
| | | 'result' => 'No menu items to sync' |
| | | ]; |
| | | } |
| | | |
| | | $gmb = self::getInstance($data['userID']??null); |
| | | |
| | | // Build the complete menu structure |
| | | $menu_data = $gmb->collectMenu($menu_items); |
| | | |
| | | // Send to Google My Business API |
| | | $result = $this->updateFoodMenus($menu_data); |
| | | return [ |
| | | 'success' => !is_null($result), |
| | | 'result' => (is_array($result)) ? array_merge($result, ['menu'=>$menu_data]) : ['menu'=>$menu_data], |
| | | ]; |
| | | |
| | | } catch (\Exception $e) { |
| | | error_log('GMB Menu Update Error: ' . $e->getMessage()); |
| | | return [ |
| | | 'success' => false, |
| | | 'result' => $e->getMessage() |
| | | ]; |
| | | } |
| | | } |
| | | |
| | | /*************************************************************** |
| | | API |
| | | ***************************************************************/ |
| | | private function getSelectedLocationResourceName(): ?string |
| | | { |
| | | if (empty($this->credentials['location'])) { |
| | | return null; |
| | | } |
| | | |
| | | // If already a full resource name, return as-is |
| | | if (str_starts_with($this->credentials['location'], 'locations/')) { |
| | | return $this->credentials['location']; |
| | | } |
| | | |
| | | // Otherwise, it's a storeCode - need to find the full resource name |
| | | try { |
| | | $accounts = $this->getAccounts(); |
| | | foreach ($accounts as $account) { |
| | | $locations = $this->getLocations($account['name']); |
| | | |
| | | foreach ($locations as $location) { |
| | | if ($location['storeCode'] === $this->credentials['location']) { |
| | | // Auto-migrate: update stored location to use full resource name |
| | | $this->setSelectedLocation($location['name']); |
| | | return $location['name']; // This is the full resource name |
| | | } |
| | | } |
| | | } |
| | | } catch (\Exception $e) { |
| | | error_log('[GMB] Error finding location resource name: ' . $e->getMessage()); |
| | | } |
| | | |
| | | return null; |
| | | } |
| | | /** |
| | | * Update business information |
| | | */ |
| | | /** |
| | | * @param array $updates |
| | | * @return array As expected by OperationQueue.php processOperation |
| | | */ |
| | | public function updateBusinessInfo(array $updates): array |
| | | { |
| | | /** |
| | | SEE ALSO: https://developers.google.com/my-business/content/attributes |
| | | |
| | | MENU EXAMPLE |
| | | You can add structured menu data, like a food menu, to a location with the use of the PriceList object. |
| | | |
| | | The following JSON request shows how to publish a breakfast menu to a location. The response contains an instance of the updated Location object. |
| | | |
| | | HTTP |
| | | |
| | | PATCH |
| | | https://mybusiness.googleapis.com/v4/accounts/{accountId}/locations/{locationId}?updateMask=priceLists |
| | | { |
| | | "priceLists": [ |
| | | { |
| | | "priceListId": "Breakfast", |
| | | "labels": [ |
| | | { |
| | | "displayName": "Breakfast", |
| | | "description": "Tasty Google Breakfast", |
| | | "languageCode": "en" |
| | | } |
| | | ], |
| | | "sourceUrl": "http://www.google.com/todays_menu", |
| | | "sections": [ |
| | | { |
| | | "sectionId": "entree_menu", |
| | | "sectionType":"FOOD", |
| | | "labels": [ |
| | | { |
| | | "displayName": "Entrées", |
| | | "description": "Breakfast Entrées", |
| | | "languageCode": "en" |
| | | } |
| | | ], |
| | | "items": [ |
| | | { |
| | | "itemId": "scramble", |
| | | "labels": [ |
| | | { |
| | | "displayName": "Big Scramble", |
| | | "description": "A delicious scramble filled with Potatoes, Eggs, |
| | | Bell Peppers, and Sausage", |
| | | "languageCode": "en" |
| | | } |
| | | ], |
| | | "price": { |
| | | "currencyCode": "USD", |
| | | "units": "12", |
| | | "nanos": "200000000" |
| | | } |
| | | }, |
| | | { |
| | | "itemId": "steak_omelette", |
| | | "labels": [ |
| | | { |
| | | "displayName": "Steak Omelette", |
| | | "description": "Three egg omelette with grilled prime rib, |
| | | fire-roasted bell peppers and onions, saut\u00e9ed mushrooms |
| | | and melted Swiss cheese", |
| | | "languageCode": "en" |
| | | } |
| | | ], |
| | | "price": { |
| | | "currencyCode": "USD", |
| | | "units": "15", |
| | | "nanos": "750000000" |
| | | } |
| | | } |
| | | ] |
| | | } |
| | | ] |
| | | } |
| | | ] |
| | | } |
| | | |
| | | |
| | | SERVICE DATA |
| | | If your business offers different service options, you can add structured services data to a location with the use of the PriceList object. |
| | | |
| | | The following JSON request shows how to publish a service offering to a location. The response contains an instance of the updated Location object. |
| | | |
| | | HTTP |
| | | |
| | | PATCH |
| | | https://mybusiness.googleapis.com/v4/accounts/{accountId}/locations/{locationId}?updateMask=priceLists |
| | | { |
| | | "priceLists": [ |
| | | { |
| | | "priceListId": "Oil Change", |
| | | "labels": [ |
| | | { |
| | | "displayName": "Oil Change", |
| | | "description": "Caseys Qwik Oil Change", |
| | | "languageCode": "en" |
| | | } |
| | | ], |
| | | "sourceUrl": "http://www.google.com/todays_services", |
| | | "sections": [ |
| | | { |
| | | "sectionId": "oil_services", |
| | | "sectionType":”SERVICES”, |
| | | "labels": [ |
| | | { |
| | | "displayName": "Services", |
| | | "description": "Oil Changes", |
| | | "languageCode": "en" |
| | | } |
| | | ], |
| | | "items": [ |
| | | { |
| | | "itemId": "20-minute-oil-change", |
| | | "labels": [ |
| | | { |
| | | "displayName": "20 Minute Oil Change", |
| | | "description": "Quick oil change and filter service.", |
| | | "languageCode": "en" |
| | | } |
| | | ], |
| | | "price": { |
| | | "currencyCode": "USD", |
| | | "units": "30", |
| | | "nanos": "200000000" |
| | | } |
| | | }, |
| | | { |
| | | "itemId": "full_service_oil_change", |
| | | "labels": [ |
| | | { |
| | | "displayName": "Full Service Oil Change", |
| | | "description": "Quick oil change, filter service, and brake inspection.", |
| | | "languageCode": "en" |
| | | } |
| | | ], |
| | | "price": { |
| | | "currencyCode": "USD", |
| | | "units": "45", |
| | | "nanos": "750000000" |
| | | } |
| | | } |
| | | ] |
| | | } |
| | | ] |
| | | } |
| | | ] |
| | | } |
| | | **/ |
| | | |
| | | try { |
| | | $allowed_fields = [ |
| | | 'title', // Business name |
| | | 'phoneNumbers', // Primary phone |
| | | 'websiteUri', // Website URL |
| | | 'categories', // Business categories |
| | | 'storefrontAddress', // Physical address |
| | | 'serviceArea', // Service area for service businesses |
| | | 'profile', // Business description |
| | | 'openInfo', // Opening date info |
| | | 'regularHours', // Business hours |
| | | 'specialHours', // Special hours |
| | | ]; |
| | | |
| | | // Filter to only allowed fields |
| | | $patch_data = array_intersect_key($updates,$allowed_fields); |
| | | |
| | | $location_name = $this->getSelectedLocationResourceName(); |
| | | if (empty($patch_data)) { |
| | | throw new \Exception('No valid fields to update'); |
| | | } |
| | | |
| | | // Create updateMask from the fields being updated |
| | | $update_mask = implode(',', array_keys($patch_data)); |
| | | |
| | | $response = $this->makeRequest( |
| | | 'PATCH', |
| | | "/v1/{$location_name}?updateMask={$update_mask}", |
| | | $patch_data, |
| | | 'base' |
| | | ); |
| | | |
| | | return [ |
| | | 'success' => !empty($response), |
| | | 'result' => $response |
| | | ]; |
| | | |
| | | } catch (\Exception $e) { |
| | | $this->logError($e->getMessage(), [ |
| | | 'method' => 'updateBusinessInfo' |
| | | ]); |
| | | return [ |
| | | 'success' => false, |
| | | 'result' => $e->getMessage(), |
| | | ]; |
| | | } |
| | | } |
| | | |
| | | |
| | | /** |
| | | * Update business attributes (amenities, services, etc.) |
| | | */ |
| | | /** |
| | | * @param string $location_name |
| | | * @param array $attributes |
| | | * @return array As expected by OperationQueue.php processOperation |
| | | */ |
| | | public function updateAttributes(array $attributes): array |
| | | { |
| | | try { |
| | | // Attributes like: wheelchair_accessible, wifi, parking, etc. |
| | | //TODO: sanitize data |
| | | $attributes_data = [ |
| | | 'attributes' => $attributes |
| | | ]; |
| | | $location_name = $this->getSelectedLocationResourceName(); |
| | | |
| | | $response = $this->makeRequest( |
| | | 'PATCH', |
| | | "/v1/accounts/{$location_name}/attributes", |
| | | $attributes_data, |
| | | 'base' |
| | | ); |
| | | |
| | | return [ |
| | | 'success' => !empty($response), |
| | | 'result' => $response |
| | | ]; |
| | | |
| | | } catch (\Exception $e) { |
| | | $this->logError($e->getMessage(), [ |
| | | 'method' => 'updateAttributes' |
| | | ]); |
| | | return [ |
| | | 'success' => false, |
| | | 'result' => $e->getMessage() |
| | | ]; |
| | | } |
| | | } |
| | | /***************** |
| | | * POSTS |
| | | ****************/ |
| | | /** |
| | | * Create a new GMB post |
| | | * |
| | | * @param string $location_name The location resource name |
| | | * @param array $post_data Post content including summary, callToAction, media, etc. |
| | | * @return array|null Created post data or null on failure |
| | | */ |
| | | |
| | | /** |
| | | FROM THE DOCS: |
| | | EVENT POST |
| | | Notify your customers about the next event at your business with a Post. Your Post for an event includes start and end dates and times, which display prominently on the Post. |
| | | |
| | | To make a Post to an account associated with a user, use the accounts.locations.localPosts API. |
| | | |
| | | To create a Post for an authenticated user, use the following: |
| | | |
| | | $ POST |
| | | https://mybusiness.googleapis.com/v4/accounts/{accountId}/locations/{locationId}/localPosts |
| | | { |
| | | "languageCode": "en-US", |
| | | "summary": "Come in for our spooky Halloween event!", |
| | | "event": { |
| | | "title": "Halloween Spook-tacular!", |
| | | "schedule": { |
| | | "startDate": { |
| | | "year": 2017, |
| | | "month": 10, |
| | | "day": 31, |
| | | }, |
| | | "startTime": { |
| | | "hours": 9, |
| | | "minutes": 0, |
| | | "seconds": 0, |
| | | "nanos": 0, |
| | | }, |
| | | "endDate": { |
| | | "year": 2017, |
| | | "month": 10, |
| | | "day": 31, |
| | | }, |
| | | "endTime": { |
| | | "hours": 17, |
| | | "minutes": 0, |
| | | "seconds": 0, |
| | | "nanos": 0, |
| | | }, |
| | | } |
| | | }, |
| | | "media": [ |
| | | { |
| | | "mediaFormat": "PHOTO", |
| | | "sourceUrl": "https://www.google.com/real-image.jpg", |
| | | } |
| | | ], |
| | | "topicType": "EVENT" |
| | | } |
| | | |
| | | CALL TO ACTION POST |
| | | Posts with a call to action include a button. The text on the call to action button is determined by the actionType field of the Post. A link to a user-provided URL is added to the button. |
| | | |
| | | To create a Post with a call to action button, use the following: |
| | | |
| | | $ POST |
| | | https://mybusiness.googleapis.com/v4/accounts/{accountId}/locations/{locationId}/localPosts |
| | | { |
| | | "languageCode": "en-US", |
| | | "summary": "Order your Thanksgiving turkeys now!!", |
| | | "callToAction": { |
| | | "actionType": "ORDER", |
| | | "url": "http://google.com/order_turkeys_here", |
| | | }, |
| | | "media": [ |
| | | { |
| | | "mediaFormat": "PHOTO", |
| | | "sourceUrl": "https://www.google.com/real-turkey-photo.jpg", |
| | | } |
| | | ], |
| | | "topicType": "OFFER" |
| | | } |
| | | Action types |
| | | The call to action Posts can have different action types that determine the type of call to action Post. |
| | | |
| | | The following are the supported call to action types: |
| | | |
| | | Action types |
| | | BOOK Creates a Post that prompts a user to book an appointment, table, or something similar. |
| | | ORDER Creates a Post that prompts a user to order something. |
| | | SHOP Creates a Post that prompts a user to browse a product catalog. |
| | | LEARN_MORE Creates a Post that prompts a user to see additional details on a website. |
| | | SIGN_UP Creates a Post that prompts a user to register, sign up, or join something. |
| | | CALL Creates a Post that prompts a user to call a business. |
| | | |
| | | |
| | | OFFER POST |
| | | To create an Offer Post, use the following: |
| | | |
| | | HTTP |
| | | |
| | | $ POST |
| | | https://mybusiness.googleapis.com/v4/accounts/{accountId}/locations/{locationId}/localPosts |
| | | { |
| | | "languageCode": "en-US", |
| | | "summary": "Buy one Google jetpack, get a second one free!!", |
| | | "offer": { |
| | | "couponCode": “BOGO-JET-CODE”, |
| | | "redeemOnlineUrl": “https://www.google.com/redeem”, |
| | | "termsConditions": “Offer only valid if you can prove you are a time traveler” |
| | | }, |
| | | "media": [ |
| | | { |
| | | "mediaFormat": "PHOTO", |
| | | "sourceUrl": "https://www.google.com/real-jetpack-photo.jpg", |
| | | } |
| | | ], |
| | | "topicType": "OFFER" |
| | | } |
| | | |
| | | EDIT POST |
| | | Once a post is created, you can edit it with a PATCH request. |
| | | |
| | | To edit a Post, use the following: |
| | | |
| | | HTTP |
| | | |
| | | $ PATCH |
| | | https://mybusiness.googleapis.com/v4/accounts/{accountId}/locations/{locationId}/localPosts/{localPostId}?updateMask=summary |
| | | { |
| | | "summary": "Order your Christmas turkeys now!!" |
| | | } |
| | | |
| | | |
| | | DELETE POSTS |
| | | After a Post is created, you can delete it with a DELETE request. |
| | | |
| | | To delete a Post, use the following: |
| | | |
| | | HTTP |
| | | |
| | | $ DELETE |
| | | https://mybusiness.googleapis.com/v4/accounts/{accountId}/locations/{locationId}/localPosts/{localPostId} |
| | | **/ |
| | | public function createPost(array $post_data, ?string $location_name = null): ?array |
| | | { |
| | | $location_name = (!$location_name) ? $this->getSelectedLocationResourceName() : $location_name; |
| | | try { |
| | | // Validate required fields |
| | | if (empty($post_data['summary'])) { |
| | | throw new \Exception('Post summary is required'); |
| | | } |
| | | |
| | | // Build the post object according to API docs |
| | | $post = [ |
| | | 'languageCode' => $post_data['language_code'] ?? 'en-US', |
| | | 'summary' => $post_data['summary'], |
| | | 'topicType' => $post_data['topic_type'] ?? 'STANDARD' |
| | | ]; |
| | | |
| | | // Add optional fields |
| | | if (!empty($post_data['call_to_action'])) { |
| | | $post['callToAction'] = [ |
| | | 'actionType' => $post_data['call_to_action']['type'] ?? 'LEARN_MORE', |
| | | 'url' => $post_data['call_to_action']['url'] |
| | | ]; |
| | | } |
| | | |
| | | if (!empty($post_data['media'])) { |
| | | $post['media'] = $post_data['media']; |
| | | } |
| | | |
| | | if ($post_data['topic_type'] === 'EVENT' && !empty($post_data['event'])) { |
| | | $post['event'] = $post_data['event']; |
| | | } |
| | | |
| | | if ($post_data['topic_type'] === 'OFFER' && !empty($post_data['offer'])) { |
| | | $post['offer'] = $post_data['offer']; |
| | | } |
| | | |
| | | $response = $this->makeRequest( |
| | | 'POST', |
| | | "/v4/{$location_name}/localPosts", |
| | | $post, |
| | | 'posts' |
| | | ); |
| | | |
| | | $this->cache->invalidate("posts_{$location_name}"); |
| | | |
| | | return $response; |
| | | |
| | | } catch (\Exception $e) { |
| | | $this->logError($e->getMessage(), [ |
| | | 'method' => 'createPost' |
| | | ]); |
| | | return null; |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Update an existing GMB post |
| | | */ |
| | | public function updatePost(string $post_name, array $post_data): bool |
| | | { |
| | | try { |
| | | $update_fields = []; |
| | | $update_mask = []; |
| | | |
| | | if (isset($post_data['summary'])) { |
| | | $update_fields['summary'] = $post_data['summary']; |
| | | $update_mask[] = 'summary'; |
| | | } |
| | | |
| | | if (isset($post_data['call_to_action'])) { |
| | | $update_fields['callToAction'] = $post_data['call_to_action']; |
| | | $update_mask[] = 'callToAction'; |
| | | } |
| | | |
| | | if (empty($update_fields)) { |
| | | return true; // Nothing to update |
| | | } |
| | | |
| | | $response = $this->makeRequest( |
| | | 'PATCH', |
| | | "/v4/{$post_name}?updateMask=" . implode(',', $update_mask), |
| | | $update_fields, |
| | | 'posts' // Use the posts API base |
| | | ); |
| | | |
| | | return !empty($response); |
| | | |
| | | } catch (\Exception $e) { |
| | | |
| | | $this->logError($e->getMessage(), [ |
| | | 'method' => 'updatePost' |
| | | ]); |
| | | return false; |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Delete a post |
| | | */ |
| | | public function deletePost(string $post_name): bool |
| | | { |
| | | try { |
| | | $this->makeRequest('DELETE', "/v1/accounts/{$post_name}", [], 'posts'); |
| | | return true; |
| | | } catch (\Exception $e) { |
| | | |
| | | $this->logError($e->getMessage(), [ |
| | | 'method' => 'deletePost' |
| | | ]); |
| | | return false; |
| | | } |
| | | } |
| | | /**************** |
| | | * HOURS |
| | | ***************/ |
| | | /** |
| | | * Update regular business hours |
| | | * |
| | | * @param array $hours Array of periods with open/close times per day |
| | | * @return array Success status, according to OperationQueue.php |
| | | */ |
| | | public function updateBusinessHours(array $hours): array |
| | | { |
| | | if(!$this->isSetUp()) { |
| | | return []; |
| | | } |
| | | $location_name = $this->credentials['location']; |
| | | |
| | | if (empty($location_name)) { |
| | | error_log('[GMB] ERROR: No location selected or found'); |
| | | throw new \Exception('No location selected. Please select a location first.'); |
| | | } |
| | | |
| | | try { |
| | | // Validate and format hours |
| | | $periods = $this->validateAndFormatHours($hours); |
| | | |
| | | if (empty($periods)) { |
| | | error_log('[GMB] ERROR: No valid periods found after validation'); |
| | | throw new \Exception('No valid business hours provided'); |
| | | } |
| | | |
| | | $update_data = [ |
| | | 'regularHours' => [ |
| | | 'periods' => $periods |
| | | ] |
| | | ]; |
| | | |
| | | $endpoint = "/v1/{$location_name}?updateMask=regularHours"; |
| | | |
| | | // Make the API request |
| | | $response = $this->makeRequest( |
| | | 'PATCH', |
| | | $endpoint, |
| | | $update_data, |
| | | 'base' |
| | | ); |
| | | |
| | | $success = $response !== null; |
| | | |
| | | // Additional validation - check if the response contains the updated hours |
| | | if ($success && $response) { |
| | | if (isset($response['regularHours'])) { |
| | | // error_log('[GMB] SUCCESS: Updated hours confirmed in response: ' . print_r($response['regularHours'], true)); |
| | | } else { |
| | | // error_log('[GMB] WARNING: No regularHours in response, but API call succeeded'); |
| | | // error_log('[GMB] Full response keys: ' . implode(', ', array_keys($response))); |
| | | } |
| | | } |
| | | |
| | | return [ |
| | | 'success' => $success, |
| | | 'result' => $response |
| | | ]; |
| | | |
| | | } catch (\Exception $e) { |
| | | $this->logError($e->getMessage(), [ |
| | | 'method' => 'updateBusinessHours' |
| | | ]); |
| | | return [ |
| | | 'success' => false, |
| | | 'result' => $e->getMessage() |
| | | ]; |
| | | } |
| | | } |
| | | |
| | | private function validateAndFormatHours(array $hours): array |
| | | { |
| | | $valid_days = ['MONDAY', 'TUESDAY', 'WEDNESDAY', 'THURSDAY', 'FRIDAY', 'SATURDAY', 'SUNDAY']; |
| | | $periods = []; |
| | | |
| | | foreach ($hours as $day => $times) { |
| | | $formatted_day = strtoupper($day); |
| | | |
| | | // Convert day names if needed |
| | | $day_mapping = [ |
| | | 'MON' => 'MONDAY', |
| | | 'TUE' => 'TUESDAY', |
| | | 'TUES' => 'TUESDAY', |
| | | 'WED' => 'WEDNESDAY', |
| | | 'THU' => 'THURSDAY', |
| | | 'THUR' => 'THURSDAY', |
| | | 'THURS' => 'THURSDAY', |
| | | 'FRI' => 'FRIDAY', |
| | | 'SAT' => 'SATURDAY', |
| | | 'SUN' => 'SUNDAY' |
| | | ]; |
| | | |
| | | if (isset($day_mapping[$formatted_day])) { |
| | | $formatted_day = $day_mapping[$formatted_day]; |
| | | } |
| | | |
| | | if (!in_array($formatted_day, $valid_days)) { |
| | | error_log('[GMB] Invalid day: ' . $day . ' (formatted: ' . $formatted_day . ')'); |
| | | continue; |
| | | } |
| | | |
| | | // Check if day is open with flexible comparison |
| | | $is_open = false; |
| | | if (isset($times['open'])) { |
| | | // Convert to boolean - handles string "1", integer 1, boolean true, etc. |
| | | $is_open = (bool)(int)$times['open']; |
| | | } |
| | | |
| | | if (!$is_open) { |
| | | error_log('[GMB] Day ' . $formatted_day . ' is closed (open value: ' . ($times['open'] ?? 'not set') . ')'); |
| | | continue; |
| | | } |
| | | |
| | | error_log('[GMB] Day ' . $formatted_day . ' is OPEN'); |
| | | |
| | | $open_time = $this->formatTime($times['time_opens'] ?? ''); |
| | | $close_time = $this->formatTime($times['time_closes'] ?? ''); |
| | | |
| | | if (empty($open_time) || empty($close_time)) { |
| | | error_log('[GMB] Missing or invalid times for ' . $formatted_day); |
| | | error_log('[GMB] Raw time data - time_opens: "' . ($times['time_opens'] ?? 'not set') . '", time_closes: "' . ($times['time_closes'] ?? 'not set') . '"'); |
| | | continue; |
| | | } |
| | | |
| | | $period = [ |
| | | 'openDay' => $formatted_day, |
| | | 'openTime' => $open_time, // Now a proper Google TimeOfDay object |
| | | 'closeDay' => $formatted_day, |
| | | 'closeTime' => $close_time // Now a proper Google TimeOfDay object |
| | | ]; |
| | | |
| | | $periods[] = $period; |
| | | } |
| | | return $periods; |
| | | } |
| | | |
| | | /** |
| | | * Set special/temporary hours (for holidays, special events, etc.) |
| | | * |
| | | * @param string $date |
| | | * @param array{open?: float, closed?:float, isClosed?: bool, location?: string} $options |
| | | * @return array Operation Queue [ 'success' => true/false, 'result' => $results] |
| | | */ |
| | | public function setSpecialHours(string $date, array $options = []):array |
| | | { |
| | | // Get the full location resource name |
| | | $location_name = $this->getSelectedLocationResourceName(); |
| | | |
| | | if (empty($location_name)) { |
| | | throw new \Exception('No location selected. Please select a location first.'); |
| | | } |
| | | |
| | | try { |
| | | // Format the date correctly for Google API |
| | | $date_obj = [ |
| | | 'year' => (int)date('Y', strtotime($date)), |
| | | 'month' => (int)date('n', strtotime($date)), |
| | | 'day' => (int)date('j', strtotime($date)) |
| | | ]; |
| | | |
| | | $special_hour_period = [ |
| | | 'startDate' => $date_obj, |
| | | 'endDate' => $date_obj, // Same day for single-day special hours |
| | | ]; |
| | | |
| | | if (array_key_exists('isClosed', $options) && $options['isClosed']) { |
| | | $special_hour_period['closed'] = true; |
| | | } else { |
| | | $special_hour_period['closed'] = false; |
| | | |
| | | // Format times as TimeOfDay objects |
| | | if (!empty($options['open'])) { |
| | | $open_time = $this->formatTime($options['open']); |
| | | if ($open_time) { |
| | | $special_hour_period['openTime'] = $open_time; |
| | | } |
| | | } |
| | | |
| | | if (!empty($options['close'])) { |
| | | $close_time = $this->formatTime($options['close']); |
| | | if ($close_time) { |
| | | $special_hour_period['closeTime'] = $close_time; |
| | | } |
| | | } |
| | | } |
| | | |
| | | // Get existing special hours and their periods |
| | | $existing_special_hours = $location['specialHours'] ?? []; |
| | | $existing_periods = $existing_special_hours['specialHourPeriods'] ?? []; |
| | | |
| | | // Add or update the special hour period |
| | | $found = false; |
| | | foreach ($existing_periods as &$existing_period) { |
| | | if (isset($existing_period['startDate']) && $existing_period['startDate'] == $date_obj) { |
| | | $existing_period = $special_hour_period; |
| | | $found = true; |
| | | break; |
| | | } |
| | | } |
| | | |
| | | if (!$found) { |
| | | $existing_periods[] = $special_hour_period; |
| | | } |
| | | |
| | | // Prepare the update data with correct structure |
| | | $update_data = [ |
| | | 'specialHours' => [ |
| | | 'specialHourPeriods' => $existing_periods |
| | | ] |
| | | ]; |
| | | |
| | | // Try the PATCH request |
| | | $response = $this->makeRequest( |
| | | 'PATCH', |
| | | "/v1/{$location_name}?updateMask=specialHours", |
| | | $update_data, |
| | | 'base' |
| | | ); |
| | | |
| | | return [ |
| | | 'success' => $response !== null, |
| | | 'results' => $response |
| | | ]; |
| | | |
| | | } catch (\Exception $e) { |
| | | $this->logError($e->getMessage(), [ |
| | | 'method' => 'setSpecialHours' |
| | | ]); |
| | | return [ |
| | | 'success' => false, |
| | | 'results' => $e->getMessage(), |
| | | ]; |
| | | } |
| | | } |
| | | /** |
| | | * Clear all special hours |
| | | */ |
| | | public function clearSpecialHours(string $location_name): bool |
| | | { |
| | | try { |
| | | $update_data = [ |
| | | 'specialHours' => [] |
| | | ]; |
| | | |
| | | $response = $this->makeRequest( |
| | | 'PATCH', |
| | | "/v1/{$location_name}?updateMask=specialHours", |
| | | $update_data, |
| | | 'base' |
| | | ); |
| | | |
| | | return $response !== null; |
| | | |
| | | } catch (\Exception $e) { |
| | | |
| | | $this->logError($e->getMessage(), [ |
| | | 'method' => 'clearSpecialHours' |
| | | ]); |
| | | return false; |
| | | } |
| | | } |
| | | /*************************************************************** |
| | | HOURS |
| | | ***************************************************************/ |
| | | /** |
| | | FROM THE DOCS: |
| | | UPLOAD FROM URL |
| | | To upload photos from a URL , make the following call to Media.Create. Use the relevant category as needed. |
| | | |
| | | |
| | | POST https://mybusiness.googleapis.com/v4/accounts/{accountId}/locations/{locationId}/media |
| | | { |
| | | "mediaFormat": "PHOTO", |
| | | "locationAssociation": { |
| | | "category": "COVER" |
| | | }, |
| | | "sourceUrl": “http://example.com/biz/image.jpg", |
| | | } |
| | | To upload videos from a URL with the Google My Business API, make the following call to Media.Create: |
| | | |
| | | |
| | | POST https://mybusiness.googleapis.com/v4/accounts/{accountId}/locations/{locationId}/media |
| | | { |
| | | "mediaFormat": "VIDEO", |
| | | "locationAssociation": { |
| | | "category": "ADDITIONAL" |
| | | }, |
| | | "sourceUrl": “http://example.com/biz/video.mp4", |
| | | } |
| | | **/ |
| | | public function uploadImageToGMB(int $imgID, string $category = 'ADDITIONAL'): ?string |
| | | { |
| | | if ($imgID === 0) { |
| | | return null; |
| | | } |
| | | if (empty($this->credentials)) { |
| | | $this->loadCredentials(); |
| | | } |
| | | |
| | | try { |
| | | $imgID = $this->getSupportedImage($imgID); |
| | | $valid_categories = [ |
| | | 'COVER', 'PROFILE', 'LOGO', 'EXTERIOR', 'INTERIOR', |
| | | 'PRODUCT', 'AT_WORK', 'FOOD_AND_DRINK', 'MENU', |
| | | 'COMMON_AREA', 'ROOMS', 'TEAMS', 'ADDITIONAL', |
| | | 'FOOD_AND_DRINK' |
| | | ]; |
| | | |
| | | if (!in_array($category, $valid_categories)) { |
| | | throw new \Exception('Invalid photo category: ' . $category); |
| | | } |
| | | $mediaKey = $this->getImageMediaKey($imgID); |
| | | if ($mediaKey) { |
| | | return $mediaKey; |
| | | } |
| | | $image_url = wp_get_attachment_image_url($imgID, 'full'); |
| | | |
| | | $media_data = [ |
| | | 'mediaFormat' => 'PHOTO', |
| | | 'locationAssociation' => [ |
| | | 'category' => $category |
| | | $response = wp_remote_post( |
| | | $this->oauth->getToken(), |
| | | [ |
| | | 'timeout' => 15, |
| | | 'body' => [ |
| | | 'grant_type' => 'refresh_token', |
| | | 'refresh_token' => $credentials->refresh_token, |
| | | 'client_id' =>$this->getClientID(), |
| | | 'client_secret' =>$this->getClientSecret() |
| | | ], |
| | | 'sourceUrl' => $image_url |
| | | ]; |
| | | $response = $this->makeRequest( |
| | | 'POST', |
| | | "/v4/{$this->credentials['account']}/{$this->credentials['location']}/media", |
| | | $media_data, |
| | | 'posts' |
| | | ); |
| | | |
| | | if (!is_wp_error($response) && array_key_exists('name', $response)) { |
| | | $mediaKey = $this->extractMediaKey($response['name']); |
| | | $this->setImageMediaKey($imgID, $mediaKey); |
| | | return $mediaKey; |
| | | } |
| | | return null; |
| | | } catch (\Exception $e) { |
| | | $this->logError($e->getMessage(), [ |
| | | 'method' => 'uploadPhotoFromUrl' |
| | | ]); |
| | | return null; |
| | | } |
| | | } |
| | | |
| | | public function getImageMediaKey(int $imgID):string|false |
| | | { |
| | | $mediaKey = get_post_meta($imgID, BASE.'gmb_mediakey', true); |
| | | if ($mediaKey !== '') { |
| | | return $this->testGMBImage($mediaKey); |
| | | } |
| | | return false; |
| | | } |
| | | public function testGMBImage(string $mediaKey):string|false |
| | | { |
| | | $existing = $this->getLocationMedia(); |
| | | $exists = array_filter($existing, function ($item) use ($mediaKey) { |
| | | return $this->extractMediaKey($item['name']) === $mediaKey; |
| | | }); |
| | | return (empty($exists)) ? false : $mediaKey; |
| | | } |
| | | public function setImageMediaKey(int $imgID, string $key):bool |
| | | { |
| | | return update_post_meta($imgID, BASE.'gmb_mediakey', $key); |
| | | } |
| | | /*************************************************************** |
| | | CREDENTIALS |
| | | ***************************************************************/ |
| | | /** |
| | | * Get user's GMB accounts |
| | | */ |
| | | public function getAccounts(bool $force = false): array |
| | | { |
| | | $ttl = 7 * 24 * 60 * 60; // week in seconds |
| | | $response = $this->getRequest('/v1/accounts', [], 'accounts', $ttl, $force)??[]; |
| | | |
| | | if (isset($response['accounts']) && is_array($response['accounts'])) { |
| | | return $response['accounts']; |
| | | } |
| | | |
| | | if (is_array($response) && isset($response['name'])) { |
| | | return [$response]; |
| | | } |
| | | |
| | | return []; |
| | | } |
| | | |
| | | /** |
| | | * Get reviews for the current location |
| | | * @param int $page_size Number of reviews to fetch (max 50) |
| | | * @return array|null |
| | | */ |
| | | public function getReviews(int $page_size = 5): ?array |
| | | { |
| | | $this->ensureInitialized(); |
| | | if (!$this->location) { |
| | | throw new \Exception('No location selected'); |
| | | } |
| | | if (!$this->account_id) { |
| | | throw new \Exception('No account configured'); |
| | | } |
| | | |
| | | $location = $this->getSelectedLocationResourceName(); |
| | | $account = $this->account_id; |
| | | |
| | | |
| | | // Check cache first (weekly refresh = 604800 seconds) |
| | | $cache_key = ['reviews', $location, $page_size]; |
| | | $cached = $this->cache->get($cache_key); |
| | | if ($cached !== false) { |
| | | return $cached; |
| | | } |
| | | |
| | | try { |
| | | // Reviews endpoint from My Business Account Management API |
| | | $response = $this->getRequest( |
| | | "/{$account}/{$location}/reviews", |
| | | [ |
| | | 'orderBy' => 'updateTime desc' |
| | | ], |
| | | 'v4' |
| | | ); |
| | | |
| | | $reviews = $response ?? []; |
| | | |
| | | // Cache for 1 week (604800 seconds) |
| | | $this->cache->set($cache_key, $reviews, WEEK_IN_SECONDS); |
| | | |
| | | return $reviews; |
| | | |
| | | } catch (\Exception $e) { |
| | | $this->logError($e->getMessage(), [ |
| | | 'method' => 'getReviews' |
| | | ]); |
| | | return null; |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Get the URL to view all Google reviews for the current location |
| | | * @return string|null The reviews viewing URL or null if not available |
| | | */ |
| | | public function getReviewsViewUrl(): ?string |
| | | { |
| | | $this->ensureInitialized(); |
| | | try { |
| | | $location = $this->getLocation(); |
| | | |
| | | if (empty($location)) { |
| | | return null; |
| | | } |
| | | |
| | | // Prefer maps URL as it shows all reviews directly |
| | | if (!empty($location['metadata']['mapsUrl'])) { |
| | | return $location['metadata']['mapsUrl']; |
| | | } |
| | | |
| | | // Fallback: construct from Place ID |
| | | if (!empty($location['metadata']['placeId'])) { |
| | | return 'https://search.google.com/local/reviews?placeid=' . |
| | | urlencode($location['metadata']['placeId']); |
| | | } |
| | | |
| | | return null; |
| | | |
| | | } catch (\Exception $e) { |
| | | $this->logError('Failed to get reviews view URL: ' . $e->getMessage(), [ |
| | | 'method' => 'getReviewsViewUrl' |
| | | ]); |
| | | return null; |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Get the URL to leave a review for the current location |
| | | * @return string|null The review URL or null if not available |
| | | */ |
| | | public function getReviewUrl(): ?string |
| | | { |
| | | $this->ensureInitialized(); |
| | | try { |
| | | $location = $this->getLocation(); |
| | | |
| | | if (empty($location)) { |
| | | return null; |
| | | } |
| | | |
| | | // Try to use Place ID for write review |
| | | if (!empty($location['metadata']['placeId'])) { |
| | | return 'https://search.google.com/local/writereview?placeid=' . |
| | | urlencode($location['metadata']['placeId']); |
| | | } |
| | | |
| | | // Fallback to maps URL |
| | | if (!empty($location['metadata']['mapsUrl'])) { |
| | | return $location['metadata']['mapsUrl'] . '/reviews'; |
| | | } |
| | | |
| | | return null; |
| | | |
| | | } catch (\Exception $e) { |
| | | $this->logError('Failed to get review URL: ' . $e->getMessage(), [ |
| | | 'method' => 'getReviewUrl' |
| | | ]); |
| | | return null; |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Get locations for an account (with persistent storage) |
| | | * Allowed Fields: https://developers.google.com/my-business/content/location-data#list_of_all_supported_filter_fields |
| | | */ |
| | | public function getLocations(?string $account_name = null, bool $force = false): array |
| | | { |
| | | if (!$this->isSetUp()) { |
| | | return []; |
| | | } |
| | | |
| | | $account_name = $account_name ?: $this->account_id; |
| | | |
| | | if (empty($account_name) || !str_starts_with($account_name, 'accounts/')) { |
| | | return []; |
| | | } |
| | | $params = ['readMask' => $this->readMask]; |
| | | $ttl = 7 * 24 * 60 * 60; // Week in seconds |
| | | |
| | | try { |
| | | $response = $this->getRequest("/v1/{$account_name}/locations", $params,'accounts', $ttl, $force); |
| | | return isset($response['locations']) ? $response['locations'] : []; |
| | | |
| | | } catch (\Exception $e) { |
| | | error_log('[GMB] Error getting locations for ' . $account_name . ': ' . $e->getMessage()); |
| | | return []; |
| | | } |
| | | } |
| | | |
| | | public function handleRefreshAccessToken():WP_Error|array |
| | | { |
| | | if ($this->refreshOAuthToken()) { |
| | | return [ |
| | | 'success' => true, |
| | | 'message' => 'Successfully refreshed OAuth Token' |
| | | ]; |
| | | } |
| | | return new WP_Error('failure', 'Failed to Refresh token'); |
| | | } |
| | | |
| | | /** |
| | | * Manually refresh accounts and locations data from API |
| | | */ |
| | | public function refreshStoredData(): array |
| | | { |
| | | try { |
| | | // Fetch fresh accounts data from API |
| | | $accounts = $this->getAccounts(true); |
| | | if (!empty($accounts)) { |
| | | // Fetch and store locations for each account |
| | | $locationsProcessed = 0; |
| | | $accountsProcessed = count($accounts); |
| | | foreach ($accounts as $account) { |
| | | $locations = $this->getLocations($account['name'], true); |
| | | if (!empty($locations)) { |
| | | $locationsProcessed += count($locations); |
| | | } |
| | | } |
| | | |
| | | return [ |
| | | 'success' => true, |
| | | 'accounts' => $accountsProcessed, |
| | | 'locations' => $locationsProcessed, |
| | | 'message' => sprintf( |
| | | 'Successfully refreshed %d accounts with %d total locations', |
| | | $accountsProcessed, |
| | | $locationsProcessed |
| | | ) |
| | | ]; |
| | | } |
| | | |
| | | return [ |
| | | 'success' => false, |
| | | 'message' => 'No accounts found' |
| | | ]; |
| | | |
| | | } catch (\Exception $e) { |
| | | error_log('[GMB] Error refreshing stored data: ' . $e->getMessage()); |
| | | return [ |
| | | 'success' => false, |
| | | 'message' => 'Error: ' . $e->getMessage() |
| | | ]; |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Get a specific location details |
| | | */ |
| | | public function getLocation(?string $location_name = null, bool $force = false):?array |
| | | { |
| | | if (!$this->isSetUp()) { |
| | | return null; |
| | | } |
| | | $location_name = $location_name?:$this->credentials['location']; |
| | | if (empty($location_name)) { |
| | | error_log('[GMB] No location name provided for getLocation'); |
| | | return null; |
| | | } |
| | | |
| | | $params = [ |
| | | 'readMask' => $this->readMask |
| | | ]; |
| | | $endpoint = "/v1/{$location_name}?".http_build_query($params); |
| | | |
| | | $location = $this->getRequest($endpoint, [], 'base', 'moderate', $force); |
| | | |
| | | return $location??null; |
| | | } |
| | | |
| | | /** |
| | | * Get the currently selected location |
| | | */ |
| | | public function getSelectedLocation(): ?string |
| | | { |
| | | return $this->credentials['location']; |
| | | } |
| | | |
| | | /** |
| | | * Set the selected location |
| | | */ |
| | | public function setSelectedLocation(string $location): bool |
| | | { |
| | | $this->credentials['location'] = $location; |
| | | |
| | | if (preg_match('/accounts\/([^\/]+)\//', $location, $matches)) { |
| | | $this->account_id = 'accounts/' . $matches[1]; |
| | | } |
| | | |
| | | $credentials = $this->credentials; |
| | | $credentials['location'] = $location; |
| | | $credentials['account'] = $this->account_id; |
| | | |
| | | |
| | | return $this->saveCredentials($credentials)['success']; |
| | | } |
| | | |
| | | /************************************************************* |
| | | RENDER |
| | | *************************************************************/ |
| | | /** |
| | | * Render business list for connected users |
| | | */ |
| | | private function renderBusinessList(): void |
| | | { |
| | | try { |
| | | $accounts = $this->getAccounts(); |
| | | if (empty($accounts)) { |
| | | echo '<p>No business accounts found.</p>'; |
| | | return; |
| | | } |
| | | ?> |
| | | <div class="gmb-business-list"> |
| | | <h4>Your Business Locations</h4> |
| | | <?php |
| | | foreach ($accounts as $account) { |
| | | $locations = $this->getLocations($account['name']); |
| | | foreach ($locations as $location) { |
| | | ?> |
| | | <div class="business-location"> |
| | | <strong><?php echo esc_html($location['title']); ?></strong> |
| | | <?php if ($this->isSelectedLocation($location)): ?> |
| | | <span class="selected-badge">✓ Selected</span> |
| | | <?php endif; ?> |
| | | </div> |
| | | <?php |
| | | } |
| | | } |
| | | ?> |
| | | </div> |
| | | <?php |
| | | } catch (\Exception $e) { |
| | | echo '<p>Error loading business locations.</p>'; |
| | | } |
| | | } |
| | | |
| | | private function isSelectedLocation(array $location): bool |
| | | { |
| | | if (empty($this->credentials['location'])) { |
| | | return false; |
| | | } |
| | | |
| | | return ($location['name'] === $this->credentials['location']); |
| | | } |
| | | |
| | | /*********************************************************** |
| | | UTILITY |
| | | ***********************************************************/ |
| | | /** |
| | | * Helper method to strip HTML tags from text |
| | | * Preserves plain text content while removing all HTML formatting |
| | | */ |
| | | protected function stripHtmlTags(string $text): string |
| | | { |
| | | // Remove HTML tags |
| | | $text = wp_strip_all_tags($text); |
| | | |
| | | // Decode HTML entities |
| | | $text = html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8'); |
| | | |
| | | // Clean up whitespace |
| | | $text = preg_replace('/\s+/', ' ', $text); |
| | | |
| | | // Trim |
| | | return trim($text); |
| | | } |
| | | /** |
| | | * Format date for GMB API |
| | | */ |
| | | protected function formatDate(string $date): array |
| | | { |
| | | $timestamp = strtotime($date); |
| | | return [ |
| | | 'year' => (int)date('Y', $timestamp), |
| | | 'month' => (int)date('n', $timestamp), |
| | | 'day' => (int)date('j', $timestamp) |
| | | ]; |
| | | } |
| | | /** |
| | | * Format time for Google My Business API (HH:MM format) |
| | | */ |
| | | private function formatTime(string $time): ?array |
| | | { |
| | | if (empty($time)) { |
| | | return null; |
| | | } |
| | | |
| | | // Remove any extra whitespace |
| | | $time = trim($time); |
| | | |
| | | // Handle different time formats and convert to 24-hour format |
| | | if (preg_match('/^(\d{1,2}):(\d{2})(?:\s*(AM|PM))?$/i', $time, $matches)) { |
| | | $hour = (int)$matches[1]; |
| | | $minute = (int)$matches[2]; |
| | | $ampm = isset($matches[3]) ? strtoupper($matches[3]) : ''; |
| | | |
| | | // Convert 12-hour to 24-hour format if needed |
| | | if ($ampm === 'PM' && $hour !== 12) { |
| | | $hour += 12; |
| | | } elseif ($ampm === 'AM' && $hour === 12) { |
| | | $hour = 0; |
| | | } |
| | | |
| | | // Validate hour and minute ranges |
| | | if ($hour >= 0 && $hour <= 23 && $minute >= 0 && $minute <= 59) { |
| | | return [ |
| | | 'hours' => $hour, |
| | | 'minutes' => $minute |
| | | ]; |
| | | } |
| | | } |
| | | |
| | | error_log('[GMB] Invalid time format: ' . $time); |
| | | return null; |
| | | } |
| | | |
| | | |
| | | |
| | | /***************************************************************************** |
| | | * |
| | | * OAUTH IMPLEMENTATION |
| | | * |
| | | *****************************************************************************/ |
| | | private function getScopes(): string |
| | | { |
| | | return implode(' ', [ |
| | | 'https://www.googleapis.com/auth/business.manage', |
| | | 'https://www.googleapis.com/auth/userinfo.email', |
| | | 'https://www.googleapis.com/auth/userinfo.profile' |
| | | ]); |
| | | } |
| | | /** |
| | | * Handle account selection |
| | | */ |
| | | public function handleSelectAccount($data = null): array |
| | | { |
| | | try { |
| | | $account_name = null; |
| | | |
| | | // Extract account name from various sources |
| | | if (is_array($data)) { |
| | | $account_name = $data['account'] ?? $data['account'] ?? null; |
| | | } elseif (isset($_POST['account'])) { |
| | | $account_name = sanitize_text_field($_POST['account']); |
| | | } |
| | | |
| | | if (empty($account_name)) { |
| | | return [ |
| | | 'success' => false, |
| | | 'message' => 'No Account Selected' |
| | | ]; |
| | | } |
| | | |
| | | // Get account details |
| | | $accounts = $this->getAccounts(); |
| | | $selected_account = null; |
| | | |
| | | foreach ($accounts as $account) { |
| | | if ($account['name'] === $account_name) { |
| | | $selected_account = $account; |
| | | break; |
| | | } |
| | | } |
| | | |
| | | if (!$selected_account) { |
| | | throw new \Exception('Invalid account selected'); |
| | | } |
| | | |
| | | // Update credentials with selected account |
| | | $this->credentials['account'] = $account_name; |
| | | $this->credentials['selected_account_id'] = $selected_account['accountId'] ?? ''; |
| | | |
| | | $this->saveCredentials($this->credentials); |
| | | |
| | | return [ |
| | | 'success' => true, |
| | | 'message' => 'Account selected successfully', |
| | | 'reload' => true |
| | | ]; |
| | | |
| | | } catch (\Exception $e) { |
| | | $this->logError('Failed to select account', [ |
| | | 'error' => $e->getMessage(), |
| | | 'data' => $data |
| | | ]); |
| | | |
| | | return [ |
| | | 'success' => false, |
| | | 'message' => $e->getMessage() |
| | | ]; |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Handle location selection |
| | | */ |
| | | public function handleSelectLocation($data = null): array |
| | | { |
| | | try { |
| | | $location_name = null; |
| | | |
| | | // Extract location name from various sources |
| | | if (is_array($data)) { |
| | | $location_name = $data['location'] ?? $data['location'] ?? null; |
| | | } elseif (isset($_POST['location'])) { |
| | | $location_name = sanitize_text_field($_POST['location']); |
| | | } |
| | | |
| | | if (empty($location_name)) { |
| | | throw new \Exception('No location selected'); |
| | | } |
| | | |
| | | // Verify this is a valid location for the selected account |
| | | if (empty($this->credentials['account'])) { |
| | | throw new \Exception('No account selected. Please select an account first.'); |
| | | } |
| | | |
| | | $locations = $this->getLocations($this->credentials['account']); |
| | | $valid_location = false; |
| | | |
| | | foreach ($locations as $location) { |
| | | if ($location['name'] === $location_name) { |
| | | $valid_location = true; |
| | | $this->credentials['location_id'] = $location['locationId'] ?? ''; |
| | | break; |
| | | } |
| | | } |
| | | |
| | | if (!$valid_location) { |
| | | throw new \Exception('Invalid location selected'); |
| | | } |
| | | |
| | | // Update credentials with selected location |
| | | $this->credentials['location'] = $location_name; |
| | | |
| | | // Save updated credentials |
| | | $this->saveCredentials($this->credentials); |
| | | |
| | | // Initialize with new location |
| | | $this->credentials['location'] = $location_name; |
| | | |
| | | return [ |
| | | 'success' => true, |
| | | 'message' => 'Location selected successfully', |
| | | 'reload' => false // No need to reload for location selection |
| | | ]; |
| | | |
| | | } catch (\Exception $e) { |
| | | $this->logError('Failed to select location', [ |
| | | 'error' => $e->getMessage(), |
| | | 'data' => $data |
| | | ]); |
| | | |
| | | return [ |
| | | 'success' => false, |
| | | 'message' => $e->getMessage() |
| | | ]; |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Check OAuth status |
| | | */ |
| | | public function checkOAuthStatus(): array |
| | | { |
| | | try { |
| | | $valid = $this->isOAuthValid(); |
| | | |
| | | return [ |
| | | 'success' => true, |
| | | 'valid' => $valid, |
| | | 'expires_at' => $this->credentials['expires_at'] ?? null, |
| | | 'has_refresh_token' => !empty($this->credentials['refresh_token']) |
| | | ]; |
| | | |
| | | } catch (\Exception $e) { |
| | | return [ |
| | | 'success' => false, |
| | | 'valid' => false, |
| | | 'message' => $e->getMessage() |
| | | ]; |
| | | } |
| | | } |
| | | |
| | | |
| | | /******************************************************************** |
| | | * |
| | | * ADMIN UI |
| | | * |
| | | ********************************************************************/ |
| | | /** |
| | | * Updated renderOAuthConnectedOptions for better UI |
| | | */ |
| | | protected function renderOAuthConnectedOptions(): void |
| | | { |
| | | if (!$this->isSetUp()) { |
| | | return; |
| | | } |
| | | |
| | | // Check OAuth status |
| | | $oauth_valid = $this->isOAuthValid(); |
| | | |
| | | if (!$oauth_valid) { |
| | | ?> |
| | | <div class="oauth-status"> |
| | | <span class="status-invalid">⚠️ OAuth token expired - please reconnect</span> |
| | | </div> |
| | | <?php |
| | | } |
| | | |
| | | // Show account selection |
| | | $accounts = $this->getAccounts(); |
| | | if (!empty($accounts)) { |
| | | ?> |
| | | <div class="form-field" data-service="gmb"> |
| | | <label for="gmb_account">Google Business Account</label> |
| | | <select name="account" |
| | | id="gmb_account" |
| | | class="jvb-ajax-update" |
| | | data-action="select_account"> |
| | | <option value="">Select an account...</option> |
| | | <?php foreach ($accounts as $account): ?> |
| | | <option value="<?php echo esc_attr($account['name']); ?>" |
| | | <?php selected($credentials['account'] ?? '', $account['name']); ?>> |
| | | <?php echo esc_html($account['accountName'] ?? $account['name']); ?> |
| | | </option> |
| | | <?php endforeach; ?> |
| | | </select> |
| | | </div> |
| | | <?php |
| | | |
| | | // Show location selection if account is selected |
| | | if (!empty($this->credentials['account'])) { |
| | | $locations = $this->getLocations($this->credentials['account']); |
| | | |
| | | if (!empty($locations)) { |
| | | ?> |
| | | <div class="form-field location-field visible" data-service="gmb"> |
| | | <label for="gmb_location">Business Location</label> |
| | | <select name="location" |
| | | id="gmb_location" |
| | | class="jvb-ajax-update" |
| | | data-action="select_location"> |
| | | <option value="">Select a location...</option> |
| | | <?php foreach ($locations as $location): ?> |
| | | <option value="<?php echo esc_attr($location['name']); ?>" |
| | | <?php selected($this->credentials['location'] ?? '', $location['name']); ?>> |
| | | <?php echo esc_html($location['title'] ?? $location['name']); ?> |
| | | </option> |
| | | <?php endforeach; ?> |
| | | </select> |
| | | </div> |
| | | <?php |
| | | } |
| | | } |
| | | |
| | | // Show current selection status |
| | | if (!empty($this->credentials['location'])) { |
| | | ?> |
| | | <div class="connection-status connected"> |
| | | ✅ Connected to: <?php echo esc_html($this->credentials['location']); ?> |
| | | </div> |
| | | <?php |
| | | } |
| | | } else { |
| | | ?> |
| | | <div class="notice notice-warning"> |
| | | <p>No Google Business accounts found. Please ensure your Google account has access to Google My Business.</p> |
| | | </div> |
| | | <?php |
| | | } |
| | | } |
| | | |
| | | public function getServiceDescription(): string |
| | | { |
| | | return "Manage your Google My Business listings, posts, hours, and reviews."; |
| | | } |
| | | |
| | | /***************************************************************************** |
| | | * |
| | | * FOOD MENU IMPLEMENTATION |
| | | * |
| | | *****************************************************************************/ |
| | | |
| | | /** |
| | | * Get food menus for the current location |
| | | */ |
| | | public function getFoodMenus(): ?array |
| | | { |
| | | if (!$this->credentials['location']) { |
| | | throw new \Exception('No location selected. Please select a location first.'); |
| | | } |
| | | |
| | | $response = $this->getRequest( |
| | | "/v4/{$this->credentials['location']}/foodMenu", |
| | | [], |
| | | 'posts' |
| | | 'headers' => $this->getOAuthRequestHeaders() |
| | | ] |
| | | ); |
| | | return $response['foodMenus'] ?? []; |
| | | } |
| | | |
| | | /** |
| | | * Update food menus for the current location |
| | | */ |
| | | public function updateFoodMenus(array $menus): bool |
| | | { |
| | | if (!$this->credentials['location']) { |
| | | throw new \Exception('No location selected. Please select a location first.'); |
| | | } |
| | | |
| | | try { |
| | | $food_menus = ['menus' => $menus]; |
| | | |
| | | $endpoint = "/{$this->credentials['account']}/{$this->credentials['location']}/foodMenus"; |
| | | |
| | | // Use dedicated Food Menu API |
| | | $response = $this->makeRequest( |
| | | 'PATCH', |
| | | $endpoint, |
| | | $food_menus, |
| | | 'v4' |
| | | if (is_wp_error($response)) { |
| | | $this->logError( |
| | | 'refreshAccessToken', |
| | | 'Failed to refresh GMB Token', |
| | | [ |
| | | 'error' => $response->get_error_message() |
| | | ] |
| | | ); |
| | | |
| | | $this->logDebug('[GMB]Finished updating food menu', [ |
| | | 'method' => 'updateFoodMenus', |
| | | 'response' => $response, |
| | | ]); |
| | | return !empty($response); |
| | | |
| | | } catch (\Exception $e) { |
| | | $this->logError($e->getMessage(), [ |
| | | 'method' => 'updateFoodMenus' |
| | | ]); |
| | | return false; |
| | | return $this->response(false, 'Failed to refresh GMB Token'); |
| | | } |
| | | |
| | | $data = json_decode(wp_remote_retrieve_body($response), true); |
| | | if (empty($data['access_token'])) { |
| | | $this->logError('refreshAccessToken', 'No access token in response'); |
| | | return $this->response(false, 'No access token in response'); |
| | | } |
| | | $credentials = [ |
| | | 'access_token' => $data['access_token'], |
| | | 'expires_at' => time() + (int)($data['expires_in']??3600), |
| | | // Google usually omits refresh_token on refresh — keep the old one |
| | | 'refresh_token' => $body['refresh_token'] ?? $credentials->refresh_token, |
| | | ]; |
| | | $this->saveCredentials($credentials); |
| | | |
| | | return $this->response(true, 'Successfully refreshed token'); |
| | | } |
| | | |
| | | /** |
| | | * Upload menu images and return a map of identifiers to mediaKeys |
| | | */ |
| | | protected function uploadAndMapMenuImages(array $menus): array |
| | | /********************************************************* |
| | | * UTILITY |
| | | *********************************************************/ |
| | | protected function getClientID():string|false |
| | | { |
| | | $mediaKeyMap = []; |
| | | |
| | | // Process all menu items for images |
| | | foreach ($menus as $menu) { |
| | | foreach ($menu['sections'] ?? [] as $section) { |
| | | // Upload section images if needed (though API doesn't support section images yet) |
| | | if (!empty($section['image']) && is_numeric($section['image'])) { |
| | | $mediaKey = $this->uploadImageToGMB($section['image'], 'FOOD_AND_DRINK'); |
| | | if ($mediaKey) { |
| | | $mediaKeyMap['section_' . $section['id']] = $mediaKey; |
| | | } |
| | | } |
| | | |
| | | // Process menu items |
| | | foreach ($section['items'] ?? [] as $item) { |
| | | // Upload featured image |
| | | if (!empty($item['post_id'])) { |
| | | $featured_image_id = get_post_thumbnail_id($item['post_id']); |
| | | if ($featured_image_id) { |
| | | $mediaKey = $this->uploadImageToGMB($featured_image_id, 'FOOD_AND_DRINK'); |
| | | if ($mediaKey) { |
| | | $mediaKeyMap['item_' . $item['post_id']] = $mediaKey; |
| | | } |
| | | } |
| | | |
| | | // Upload gallery images if available |
| | | if (!empty($item['gallery_ids'])) { |
| | | foreach ($item['gallery_ids'] as $gallery_id) { |
| | | $mediaKey = $this->uploadImageToGMB($gallery_id, 'FOOD_AND_DRINK'); |
| | | if ($mediaKey) { |
| | | $mediaKeyMap['gallery_'.$gallery_id] = $mediaKey; |
| | | } |
| | | } |
| | | } |
| | | } |
| | | } |
| | | } |
| | | $switch = false; |
| | | if ($this->userID !== null) { |
| | | $switch = $this->userID; |
| | | $this->switchUser(null); |
| | | } |
| | | |
| | | return $mediaKeyMap; |
| | | $ID = $this->loadCredentials()['client_id']??false; |
| | | if ($switch) { |
| | | $this->switchUser($switch); |
| | | } |
| | | return $ID; |
| | | } |
| | | protected function getClientSecret():string|false |
| | | { |
| | | $switch = false; |
| | | if ($this->userID !== null) { |
| | | $switch = $this->userID; |
| | | $this->switchUser(null); |
| | | } |
| | | |
| | | $ID = $this->loadCredentials()['client_secret']??false; |
| | | if ($switch) { |
| | | $this->switchUser($switch); |
| | | } |
| | | return $ID; |
| | | } |
| | | protected function getAccountID():string|false |
| | | { |
| | | return $this->loadCredentials()['account_id']??false; |
| | | } |
| | | protected function getAccountOptions(bool $force = false):array |
| | | { |
| | | $stored = $this->loadCredentials()['accounts']??[]; |
| | | if ($force || empty($stored)) { |
| | | $stored = $this->getAccounts(); |
| | | } |
| | | return $stored; |
| | | } |
| | | |
| | | /** |
| | | * Extract mediaKey from the full media resource name |
| | | */ |
| | | protected function extractMediaKey(string $mediaResourceName): string |
| | | protected function getLocation(bool $force = false):?array |
| | | { |
| | | // Extract mediaKey from format: accounts/{accountId}/locations/{locationId}/media/{mediaKey} |
| | | $parts = explode('/media/', $mediaResourceName); |
| | | return isset($parts[1]) ? $parts[1] : ''; |
| | | } |
| | | |
| | | /** |
| | | * Get all media items for the current location |
| | | */ |
| | | public function getLocationMedia(): array |
| | | { |
| | | if (!$this->credentials['location']) { |
| | | throw new \Exception('No location selected. Please select a location first.'); |
| | | } |
| | | |
| | | try { |
| | | $response = $this->getRequest( |
| | | "/{$this->credentials['account']}/{$this->credentials['location']}/media", |
| | | [], |
| | | 'v4' |
| | | ); |
| | | |
| | | return $response['mediaItems'] ?? []; |
| | | } catch (\Exception $e) { |
| | | $this->logError('Failed to get location media: ' . $e->getMessage()); |
| | | return []; |
| | | } |
| | | } |
| | | |
| | | public function handleGetAllLocations():WP_Error|array |
| | | { |
| | | try { |
| | | // Check if we should force refresh |
| | | $force = isset($_POST['force']) && $_POST['force'] === 'true'; |
| | | |
| | | // Get accounts (use cache unless forced) |
| | | $accounts = $this->getAccounts($force); |
| | | $all_locations = []; |
| | | |
| | | foreach ($accounts as $account) { |
| | | // Get locations for each account (use cache unless forced) |
| | | $locations = $this->getLocations($account['name'], $force); |
| | | $all_locations = array_merge($all_locations, $locations); |
| | | } |
| | | |
| | | return [ |
| | | 'success' => true, |
| | | 'locations' => $all_locations, |
| | | 'cached' => !$force |
| | | ]; |
| | | |
| | | } catch (\Exception $e) { |
| | | return new WP_Error('failure', 'Something went wrong: '.$e->getMessage()); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * AJAX handler for updating selected location |
| | | */ |
| | | public function handleUpdateLocation($data = null): array |
| | | { |
| | | try { |
| | | // Handle both AJAX and REST API calls |
| | | $selected_account = null; |
| | | |
| | | if (is_array($data)) { |
| | | $selected_account = $data['account'] ?? $data['location'] ?? null; |
| | | } elseif (isset($_POST['account'])) { |
| | | $selected_account = sanitize_text_field($_POST['account']); |
| | | } |
| | | |
| | | if (empty($selected_account)) { |
| | | throw new \Exception('No account selected'); |
| | | } |
| | | |
| | | // Save the selected account |
| | | $credentials = $this->credentials; |
| | | $credentials['account'] = $selected_account; |
| | | |
| | | // Get locations for this account |
| | | $locations = $this->getLocations($selected_account); |
| | | |
| | | // Auto-select first location if only one exists |
| | | if (count($locations) === 1) { |
| | | $credentials['location'] = $locations[0]['name']; |
| | | } |
| | | |
| | | $this->saveCredentials($credentials); |
| | | |
| | | return [ |
| | | 'success' => true, |
| | | 'message' => 'Account selected successfully', |
| | | 'locations' => $locations, |
| | | 'reload' => true // Trigger page refresh to show location dropdown |
| | | ]; |
| | | |
| | | } catch (\Exception $e) { |
| | | $this->logError('Failed to update location', [ |
| | | 'error' => $e->getMessage(), |
| | | 'data' => $data |
| | | ]); |
| | | |
| | | return [ |
| | | 'success' => false, |
| | | 'message' => $e->getMessage() |
| | | ]; |
| | | } |
| | | } |
| | | |
| | | public function clearStoredData(): bool |
| | | { |
| | | try { |
| | | // Use the static method to clear the entire cache group |
| | | $this->cache->flush(); |
| | | return true; |
| | | |
| | | } catch (\Exception $e) { |
| | | error_log('[GMB] Error clearing stored data: ' . $e->getMessage()); |
| | | return false; |
| | | } |
| | | } |
| | | |
| | | public function getPriceLists(): ?array |
| | | { |
| | | if (!$this->credentials['location']) { |
| | | throw new \Exception('No location selected. Please select a location first.'); |
| | | } |
| | | |
| | | try { |
| | | $location_data = $this->getLocation($this->credentials['location']); |
| | | return $location_data['priceLists'] ?? []; |
| | | } catch (\Exception $e) { |
| | | |
| | | $this->logError($e->getMessage(), [ |
| | | 'method' => 'getPriceLists' |
| | | ]); |
| | | $location_name = $this->getLocationID(); |
| | | if (empty($location_name)) { |
| | | return null; |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Get insights/analytics |
| | | */ |
| | | public function getInsights(string $location_name, string $start_date, string $end_date): ?array |
| | | { |
| | | // From the Docs: |
| | | // Basic insights |
| | | // Retrieves basic insights for a given list of locations. Use the accounts.locations.reportInsights API to return the insights that are associated with a location. |
| | | // |
| | | // To return the basic insights associated with a location, use the following: |
| | | // |
| | | // HTTP |
| | | // |
| | | // POST |
| | | // https://mybusiness.googleapis.com/v4/accounts/{accountId}/locations:reportInsights |
| | | // { |
| | | // "locationNames": [ |
| | | // "accounts/{accountId}/locations/{locationId}" |
| | | // ], |
| | | // "basicRequest": { |
| | | // "metricRequests": [ |
| | | // { |
| | | // "metric": "QUERIES_DIRECT" |
| | | // }, |
| | | // { |
| | | // "metric": "QUERIES_INDIRECT" |
| | | // } |
| | | // ], |
| | | // "timeRange": { |
| | | // "startTime": "2016-10-12T01:01:23.045123456Z", |
| | | // "endTime": "2017-01-10T23:59:59.045123456Z" |
| | | // } |
| | | // } |
| | | // } |
| | | |
| | | $params = [ |
| | | 'dailyMetrics' => 'BUSINESS_IMPRESSIONS_DESKTOP_MAPS,BUSINESS_IMPRESSIONS_DESKTOP_SEARCH,BUSINESS_IMPRESSIONS_MOBILE_MAPS,BUSINESS_IMPRESSIONS_MOBILE_SEARCH,BUSINESS_DIRECTION_REQUESTS,CALL_CLICKS,WEBSITE_CLICKS', |
| | | 'dailyRange.startDate.year' => date('Y', strtotime($start_date)), |
| | | 'dailyRange.startDate.month' => date('n', strtotime($start_date)), |
| | | 'dailyRange.startDate.day' => date('j', strtotime($start_date)), |
| | | 'dailyRange.endDate.year' => date('Y', strtotime($end_date)), |
| | | 'dailyRange.endDate.month' => date('n', strtotime($end_date)), |
| | | 'dailyRange.endDate.day' => date('j', strtotime($end_date)) |
| | | ]; |
| | | $params = [ |
| | | 'readMask' => $this->readMask |
| | | ]; |
| | | |
| | | return $this->getRequest( |
| | | "/v1/{$location_name}:fetchMultiDailyMetricsTimeSeries?" . http_build_query($params), |
| | | sprintf( |
| | | '%s/v1/%s', |
| | | $this->baseUrl, |
| | | $location_name, |
| | | ), |
| | | $params, |
| | | 'performance' |
| | | $force |
| | | ); |
| | | |
| | | } |
| | | protected function handleGetLocationResponse(WP_Error|array $response, string $method):?array |
| | | { |
| | | return $response??null; |
| | | } |
| | | protected function getLocationID():string|false |
| | | { |
| | | return $this->loadCredentials()['location_id']??false; |
| | | } |
| | | protected function getLocationOptions(bool $force = false):array |
| | | { |
| | | $stored = $this->loadCredentials()['locations']??[]; |
| | | if ($force || empty($stored)) { |
| | | $stored = $this->getLocations(); |
| | | } |
| | | return $stored; |
| | | } |
| | | protected function getLocations():array|false |
| | | { |
| | | if (!$this->getAccessToken() || !$this->getAccountID()) { |
| | | return false; |
| | | } |
| | | try { |
| | | return $this->getRequest( |
| | | sprintf( |
| | | '/v1/%s/locations', |
| | | $this->getAccountID() |
| | | ), |
| | | [ |
| | | 'readMask' => $this->readMask |
| | | ] |
| | | ); |
| | | } catch (Exception $e) |
| | | { |
| | | $this->logError('getLocations', 'Could not get locations: '.$e->getMessage()); |
| | | return []; |
| | | } |
| | | } |
| | | protected function handleGetLocationsResponse(array $response, string $method):array |
| | | { |
| | | $locations = []; |
| | | if (isset($response['locations'])) { |
| | | foreach ($response['locations'] as $loc) { |
| | | if (isset($loc['name']) && isset($loc['title'])) { |
| | | $locations[$loc['name']] = $loc['title']; |
| | | } |
| | | } |
| | | } |
| | | return $locations; |
| | | } |
| | | |
| | | protected function getAccounts(bool $force = false):array|false |
| | | { |
| | | if (!$this->getAccessToken()) { |
| | | return false; |
| | | } |
| | | |
| | | return $this->getRequest( |
| | | 'https://mybusinessaccountmanagement.googleapis.com/v1/accounts', |
| | | [], |
| | | $force |
| | | ); |
| | | } |
| | | |
| | | /** |
| | | * Helper: Format hours for GMB API |
| | | */ |
| | | private function formatHoursPeriods(array $hours): array |
| | | { |
| | | $periods = []; |
| | | $days = ['SUNDAY', 'MONDAY', 'TUESDAY', 'WEDNESDAY', 'THURSDAY', 'FRIDAY', 'SATURDAY']; |
| | | |
| | | foreach ($hours as $day => $times) { |
| | | if (empty($times['open']) || empty($times['close'])) { |
| | | continue; |
| | | protected function handleGetAccountsResponse(array $response, string $method):array |
| | | { |
| | | $accounts = []; |
| | | if (isset($response['accounts']) && is_array($response['accounts'])) { |
| | | $accounts = $response['accounts']; |
| | | } elseif (is_array($response) && isset($response['name'])) { |
| | | $accounts = [$response]; |
| | | } |
| | | |
| | | $periods[] = [ |
| | | 'openDay' => $days[$day] ?? $day, |
| | | 'openTime' => $times['open'], // Format: "09:00" |
| | | 'closeDay' => $days[$day] ?? $day, |
| | | 'closeTime' => $times['close'] // Format: "17:00" |
| | | ]; |
| | | } |
| | | |
| | | return $periods; |
| | | } |
| | | |
| | | |
| | | /** |
| | | * HELPER: Easy interface method for creating a simple post |
| | | * JVB()->connect('gmb')->createSimplePost("Check out our new menu!", "https://example.com/menu") |
| | | */ |
| | | public function createSimplePost(string $message, ?string $cta_url = null, string $cta_type = 'LEARN_MORE'): ?array |
| | | { |
| | | $location_name = $this->getSelectedLocationResourceName(); |
| | | |
| | | if (empty($location_name)) { |
| | | throw new \Exception('No location selected. Please select a location first.'); |
| | | } |
| | | |
| | | $post_data = [ |
| | | 'summary' => $message, |
| | | 'topic_type' => 'STANDARD' |
| | | ]; |
| | | |
| | | if ($cta_url) { |
| | | $post_data['call_to_action'] = [ |
| | | 'type' => $cta_type, |
| | | 'url' => $cta_url |
| | | ]; |
| | | } |
| | | |
| | | return $this->createPost($location_name, $post_data); |
| | | } |
| | | |
| | | /** |
| | | * HELPER: Easy interface method for updating basic business info |
| | | * JVB()->connect('gmb')->updateInfo(['title' => 'New Business Name', 'websiteUri' => 'https://newsite.com']) |
| | | */ |
| | | public function updateInfo(array $updates): bool |
| | | { |
| | | $location_name = $this->getSelectedLocationResourceName(); |
| | | |
| | | if (empty($location_name)) { |
| | | throw new \Exception('No location selected. Please select a location first.'); |
| | | } |
| | | |
| | | return $this->updateBusinessInfo($location_name, $updates); |
| | | } |
| | | |
| | | |
| | | /** |
| | | * HELPER: Easy interface for clearing special hours |
| | | * JVB()->connect('gmb')->clearAllSpecialHours() |
| | | */ |
| | | public function clearAllSpecialHours(): bool |
| | | { |
| | | $location_name = $this->getSelectedLocationResourceName(); |
| | | |
| | | if (empty($location_name)) { |
| | | throw new \Exception('No location selected. Please select a location first.'); |
| | | } |
| | | |
| | | return $this->clearSpecialHours($location_name); |
| | | } |
| | | |
| | | |
| | | protected function mappedMenuFields(string $postType):array |
| | | { |
| | | /** |
| | | * @param array $fields |
| | | * @param string $fields.name Thing |
| | | */ |
| | | return apply_filters( |
| | | 'jvbGMBFields', |
| | | [ |
| | | 'name' => 'post_title', |
| | | 'price' => 'price', |
| | | 'description' => 'post_excerpt', |
| | | 'nutritionFacts'=> 'nutritionFacts', |
| | | 'servesNumPeople'=>'servesNumPeople', |
| | | 'portionSize' => 'portionSize', |
| | | 'preparationMethods' => 'preparationMethods', |
| | | 'cuisines' => 'cuisines', |
| | | 'spiciness' => 'spiciness', |
| | | 'allergen' => 'allergen', |
| | | 'dietaryRestriction'=>'dietaryRestriction' |
| | | ], |
| | | jvbNoBase($postType)); |
| | | } |
| | | |
| | | |
| | | |
| | | /** |
| | | * Build menu structure from WordPress posts |
| | | */ |
| | | protected function collectMenu(array $menu_items): array |
| | | { |
| | | |
| | | $defaultMeta = Meta::forOptions($this->userID.'_integrations'); |
| | | $defaults = ['menu_name', 'menu_description', 'default_section', 'cuisines', 'source_url', 'language', 'default_currency']; |
| | | $defaults = $defaultMeta->getAll($defaults); |
| | | |
| | | // Group items by section |
| | | $sections_map = []; |
| | | foreach ($menu_items as $item) { |
| | | |
| | | $gmb_item = $this->buildMenuItem($item, $defaults); |
| | | |
| | | // Get section for this item |
| | | $categories = wp_get_post_terms($item->ID, BASE.'section'); |
| | | $section_slug = !empty($categories) ? |
| | | $categories[0]->slug : |
| | | 'menu-items'; |
| | | $section_name = !empty($categories) ? |
| | | $categories[0]->name : |
| | | ($defaults['default_section'] ?? 'Menu Items'); |
| | | |
| | | if (!isset($sections_map[$section_slug])) { |
| | | $sections_map[$section_slug] = [ |
| | | 'labels' => [ |
| | | 'displayName' => $section_name, |
| | | 'languageCode' => $defaults['language'] ?? 'en', |
| | | ], |
| | | 'items' => [], |
| | | ]; |
| | | if (!empty($section['description'])) { |
| | | $sections_map[$section_slug]['labels']['description'] = $this->stripHtmlTags($section['description']); |
| | | $options = []; |
| | | foreach ($accounts as $account) { |
| | | if (isset($account['name']) && isset($account['accountName'])) { |
| | | $options[$account['name']] = $account['accountName']; |
| | | } |
| | | } |
| | | |
| | | $sections_map[$section_slug]['items'][] = $gmb_item; |
| | | return $options; |
| | | } |
| | | |
| | | $gmb_sections = $this->getMenuSectionsOrder($sections_map); |
| | | |
| | | // Build complete menu |
| | | $gmb_menu = [ |
| | | 'cuisines' => $this->getMenuCuisines($menu_items), |
| | | 'labels' => [ |
| | | 'displayName' => $defaults['menu_name'] ?? 'Our Menu', |
| | | 'languageCode' => $defaults['language'] ?? 'en', |
| | | ], |
| | | 'sections' => $gmb_sections |
| | | ]; |
| | | |
| | | if (array_key_exists('menu_description', $defaults)) { |
| | | $gmb_menu['labels']['description'] = $this->stripHtmlTags($defaults['menu_description']); |
| | | } |
| | | |
| | | // Add source URL if configured |
| | | if (!empty($defaults['source_url'])) { |
| | | $gmb_menu['source_url'] = $defaults['source_url']; |
| | | } |
| | | |
| | | return [$gmb_menu]; // GMB expects an array of menus |
| | | } |
| | | |
| | | protected function buildMenuItem(\WP_Post $item, array $defaults):array |
| | | { |
| | | $meta = Meta::forPost($item->ID); |
| | | $fields = $this->mappedMenuFields($item->post_type); |
| | | $values = $meta->getAll(array_values($fields)); |
| | | |
| | | |
| | | // Build GMB-formatted item |
| | | $gmb_item = [ |
| | | 'labels' => [ |
| | | 'displayName' => $item->post_title, |
| | | 'description' => !empty($item->post_excerpt) ? |
| | | $this->stripHtmlTags($item->post_excerpt) : |
| | | $this->stripHtmlTags(wp_trim_words($item->post_content, 20)), |
| | | 'languageCode' => $defaults['language'] ?? 'en' |
| | | ], |
| | | 'attributes' => [] |
| | | ]; |
| | | |
| | | // Process mapped fields |
| | | foreach ($fields as $googleField => $wpField) { |
| | | if (in_array($wpField, ['post_title', 'post_excerpt'])) { |
| | | continue; |
| | | } |
| | | |
| | | $value = $values[$wpField] ?? null; |
| | | if (empty($value)) { |
| | | continue; |
| | | } |
| | | |
| | | // Process field based on type |
| | | $processed = $this->processMenuField($googleField, $value, $values); |
| | | if ($processed !== null) { |
| | | $gmb_item['attributes'] = array_merge($gmb_item['attributes'], $processed); |
| | | } |
| | | } |
| | | $featuredImage = get_post_thumbnail_id($item->ID); |
| | | $mediaKey = $this->uploadImageToGMB($featuredImage, 'FOOD_AND_DRINK'); |
| | | if ($mediaKey) { |
| | | $gmb_item['attributes'] = array_merge($gmb_item['attributes'], ['mediaKeys' => $mediaKey]); |
| | | } |
| | | if (empty($gmb_item['attributes'])) { |
| | | unset($gmb_item['attributes']); |
| | | } |
| | | |
| | | return $gmb_item; |
| | | } |
| | | |
| | | /** |
| | | * Process a single menu field value |
| | | */ |
| | | protected function processMenuField(string $fieldName, $value, array $allValues): ?array |
| | | { |
| | | switch ($fieldName) { |
| | | case 'price': |
| | | if (is_numeric($value)) { |
| | | return [ |
| | | 'price' => [ |
| | | 'currencyCode' => $allValues['price_currency'] ?? 'CAD', |
| | | 'units' => intval($value), |
| | | 'nanos' => (($value - intval($value)) * 1000000000) |
| | | ] |
| | | ]; |
| | | } |
| | | break; |
| | | |
| | | case 'nutritionFacts': |
| | | if (is_array($value)) { |
| | | return ['nutritionFacts' => $this->processNutritionData($value)]; |
| | | } |
| | | break; |
| | | |
| | | case 'allergen': |
| | | $allergens = $this->processListField($value); |
| | | return $allergens ? ['allergens' => $allergens] : null; |
| | | |
| | | case 'dietaryRestriction': |
| | | $restrictions = $this->processListField($value); |
| | | return $restrictions ? ['dietaryRestrictions' => $restrictions] : null; |
| | | |
| | | case 'servesNumPeople': |
| | | return ['servesNumPeople' => intval($value)]; |
| | | |
| | | case 'portionSize': |
| | | return ['portionSize' => $value]; |
| | | |
| | | case 'preparationMethods': |
| | | $methods = $this->processListField($value); |
| | | return $methods ? ['preparationMethods' => $methods] : null; |
| | | |
| | | case 'cuisines': |
| | | $cuisines = $this->processListField($value, 'strtoupper'); |
| | | return $cuisines ? ['cuisines' => $cuisines] : null; |
| | | |
| | | case 'spiciness': |
| | | return ['spiciness' => strtoupper($value)]; |
| | | |
| | | default: |
| | | return [$fieldName => $value]; |
| | | } |
| | | |
| | | return null; |
| | | } |
| | | |
| | | /** |
| | | * Process list fields (arrays or comma-separated strings) |
| | | */ |
| | | protected function processListField($value, ?callable $transform = null): ?array |
| | | { |
| | | if (empty($value)) { |
| | | return null; |
| | | } |
| | | |
| | | // Convert to array if string |
| | | if (is_string($value)) { |
| | | $value = array_map('trim', explode(',', $value)); |
| | | } |
| | | |
| | | // Filter empty values |
| | | $value = array_values(array_filter($value)); |
| | | |
| | | // Apply transformation if provided |
| | | if ($transform && !empty($value)) { |
| | | $value = array_map($transform, $value); |
| | | } |
| | | |
| | | return !empty($value) ? $value : null; |
| | | } |
| | | |
| | | protected function getMenuSectionsOrder(array $sections_map):array |
| | | { |
| | | $optionsMeta = Meta::forOptions('options'); |
| | | $sectionOrder = $optionsMeta->get('menu_section_order'); |
| | | |
| | | // Build final GMB menu structure |
| | | $ordered = []; |
| | | |
| | | // First add sections in configured order |
| | | foreach ($sectionOrder as $section) { |
| | | $slug = sanitize_title($section['name']); |
| | | if (isset($sections_map[$slug]) && !empty($sections_map[$slug]['items'])) { |
| | | $ordered[$slug] = $sections_map[$slug]; |
| | | unset($sections_map[$slug]); |
| | | } |
| | | } |
| | | |
| | | // Add any remaining sections |
| | | foreach ($sections_map as $slug => $section) { |
| | | if (!empty($section['items'])) { |
| | | $ordered[$slug] = $section; |
| | | } |
| | | } |
| | | |
| | | return array_values($ordered); |
| | | } |
| | | private function getMenuCuisines(array $menu_items, array $defaults = []): array |
| | | { |
| | | $cuisines = []; |
| | | |
| | | // Check if there's a default cuisine |
| | | if (!empty($defaults['cuisines'])) { |
| | | $cuisines = is_array($defaults['cuisines']) ? |
| | | $defaults['cuisines'] : |
| | | [$defaults['cuisines']]; |
| | | } |
| | | |
| | | // Collect cuisines from individual items if specified |
| | | foreach ($menu_items as $item) { |
| | | $meta = Meta::forPost($item->ID); |
| | | $item_cuisines = $meta->get('cuisines'); |
| | | |
| | | if (!empty($item_cuisines)) { |
| | | $item_cuisines = is_array($item_cuisines) ? |
| | | $item_cuisines : |
| | | array_map('trim', explode(',', $item_cuisines)); |
| | | |
| | | foreach ($item_cuisines as $cuisine) { |
| | | $cuisine = strtoupper(trim($cuisine)); |
| | | if (!in_array($cuisine, $cuisines)) { |
| | | $cuisines[] = $cuisine; |
| | | } |
| | | } |
| | | } |
| | | } |
| | | |
| | | // Ensure we have at least one cuisine (GMB requires it) |
| | | if (empty($cuisines)) { |
| | | $cuisines = ['AMERICAN']; |
| | | } |
| | | |
| | | return array_map('strtoupper', $cuisines); |
| | | } |
| | | |
| | | /** |
| | | * Format nutrition facts for GMB API |
| | | */ |
| | | protected function processNutritionData(array $nutritionData): array |
| | | { |
| | | $processed = []; |
| | | |
| | | // Handle both repeater format and simple array format |
| | | if (isset($nutritionData[0]) && is_array($nutritionData[0])) { |
| | | // Repeater format |
| | | foreach ($nutritionData as $fact) { |
| | | if (!empty($fact['name']) && isset($fact['amount'])) { |
| | | $processed[] = [ |
| | | 'name' => $fact['name'], |
| | | 'amount' => floatval($fact['amount']), |
| | | 'unit' => $fact['unit'] ?? 'GRAM' |
| | | ]; |
| | | } |
| | | } |
| | | } else { |
| | | // Simple associative array format |
| | | foreach ($nutritionData as $name => $value) { |
| | | if (!empty($value)) { |
| | | $unit = 'GRAM'; |
| | | if ($name === 'calories') { |
| | | $unit = 'CALORIE'; |
| | | } elseif (in_array($name, ['cholesterol', 'sodium', 'totalCarbohydrate', 'protein'])) { |
| | | $unit = 'MILLIGRAM'; |
| | | } |
| | | |
| | | $processed[] = [ |
| | | 'name' => $name, |
| | | 'amount' => floatval($value), |
| | | 'unit' => $unit |
| | | ]; |
| | | } |
| | | } |
| | | } |
| | | |
| | | return $processed; |
| | | } |
| | | |
| | | protected function setContentTypes():void |
| | | protected function setContentTypes(): void |
| | | { |
| | | $this->contentTypes = [ |
| | | 'menu_item' => [ |
| | | 'menu_item' => $this->buildMenuItemContentType(), |
| | | 'post' => $this->buildPostContentType(), |
| | | 'event' => $this->buildEventContentType(), |
| | | 'offer' => $this->buildOfferContentType(), |
| | | ]; |
| | | } |
| | | protected function buildMenuItemContentType():array |
| | | { |
| | | return [ |
| | | 'nutritionFacts' => [ |
| | | 'type' => 'repeater', |
| | | 'label' => 'Nutrition', |
| | |
| | | 'bulkEdit' => true, |
| | | 'section' => 'gmb', |
| | | ] |
| | | ], |
| | | 'post' => [ |
| | | ]; |
| | | } |
| | | protected function buildPostContentType():array |
| | | { |
| | | return [ |
| | | 'summary' => [ |
| | | 'type' => 'textarea', |
| | | 'label' => 'Post Summary', |
| | |
| | | ] |
| | | ] |
| | | ] |
| | | ], |
| | | 'event' => [ |
| | | ]; |
| | | } |
| | | protected function buildEventContentType():array |
| | | { |
| | | return [ |
| | | 'summary' => [ |
| | | 'type' => 'textarea', |
| | | 'label' => 'Event Description', |
| | |
| | | 'type' => 'image', |
| | | 'label' => 'Event Photo' |
| | | ] |
| | | ], |
| | | 'offer' => [ |
| | | ]; |
| | | } |
| | | protected function buildOfferContentType():array |
| | | { |
| | | return [ |
| | | 'summary' => [ |
| | | 'type' => 'textarea', |
| | | 'label' => 'Offer Description', |
| | |
| | | 'label' => 'Terms & Conditions'] |
| | | ], |
| | | 'media' => ['type' => 'image', 'label' => 'Offer Photo'] |
| | | ] |
| | | ]; |
| | | ]; |
| | | } |
| | | protected function formatForService(int $itemID, string $type = 'post'): array |
| | | { |
| | | // TODO: Implement formatForService() method. |
| | | } |
| | | |
| | | |
| | | public function getReviews(int $number = 5):array |
| | | { |
| | | if (!$this->getLocationID() || !$this->getAccountID()){ |
| | | return []; |
| | | } |
| | | return $this->cache->remember( |
| | | ['reviews', $this->getLocationID(), $number], |
| | | function() use ($number) { |
| | | try { |
| | | return $this->getRequest( |
| | | sprintf( |
| | | "%s/{$this->getAccountID()}/{$this->getLocationID()}/reviews", |
| | | $this->businessV4 |
| | | ), |
| | | [ |
| | | 'orderBy' => 'updateTime desc' |
| | | ], |
| | | ); |
| | | } catch (Exception $e) { |
| | | $this->logError( |
| | | 'getReviews', |
| | | 'Failed to fetch Reviews', |
| | | [ |
| | | 'error' => $e->getMessage() |
| | | ]); |
| | | return []; |
| | | } |
| | | } |
| | | ); |
| | | } |
| | | protected function handleGetReviewResponse(WP_Error|array $response, string $method):array |
| | | { |
| | | return $response; |
| | | } |
| | | |
| | | /********************************************************************** |
| | | * REVIEW UTILITY |
| | | **********************************************************************/ |
| | | /** |
| | | * Get the URL to view all Google reviews for the current location |
| | | * @return string|null The reviews viewing URL or null if not available |
| | | */ |
| | | public function getReviewsViewUrl():?string |
| | | { |
| | | $location = $this->getLocation(); |
| | | if (!$location) { |
| | | return null; |
| | | } |
| | | |
| | | if (!empty($location['metadata']['mapsUrl']??'')) { |
| | | return $location['metadata']['mapsUrl']; |
| | | } |
| | | |
| | | if (!empty($location['metadata']['placeId']??'')) { |
| | | return sprintf( |
| | | 'https://search.google.com/local/reviews?placeid=%s', |
| | | urlencode($location['metadata']['placeId']) |
| | | ); |
| | | } |
| | | |
| | | return null; |
| | | } |
| | | |
| | | /** |
| | | * Get the URL to leave a review for the current location |
| | | * @return string|null The review URL or null if not available |
| | | */ |
| | | public function getReviewUrl():?string |
| | | { |
| | | $location = $this->getLocation(); |
| | | if (!$location) { |
| | | return null; |
| | | } |
| | | |
| | | if (!empty($location['metadata']['placeId']??'')) { |
| | | return sprintf( |
| | | 'https://search.google.com/local/writereview?placeid=%s', |
| | | urlencode($location['metadata']['placeId']) |
| | | ); |
| | | } |
| | | |
| | | if (!empty($location['metadata']['mapsUrl']??'')) { |
| | | return sprintf( |
| | | '%s/reviews', |
| | | $location['metadata']['mapsUrl'] |
| | | ); |
| | | } |
| | | |
| | | return null; |
| | | } |
| | | |
| | | |
| | | /************************************************************************** |
| | | * FOOD MENU HELPERS |
| | | **************************************************************************/ |
| | | } |