From a8ff85042ffbfe94b678fd818c02deb613bfd116 Mon Sep 17 00:00:00 2001
From: Jake Vanderwerf <get@jakevanderwerf.ca>
Date: Fri, 07 Aug 2026 20:42:54 +0000
Subject: [PATCH] =Continuing on with Integrations setup. Working on PostMark now
---
inc/integrations/services/GoogleMyBusiness.php | 3067 +++-------------------------------------------------------
1 files changed, 173 insertions(+), 2,894 deletions(-)
diff --git a/inc/integrations/services/GoogleMyBusiness.php b/inc/integrations/services/GoogleMyBusiness.php
index 157181e..89f4f58 100644
--- a/inc/integrations/services/GoogleMyBusiness.php
+++ b/inc/integrations/services/GoogleMyBusiness.php
@@ -1,7 +1,15 @@
<?php
namespace JVBase\integrations\services;
+use Exception;
+use JVBase\integrations\Admin;
+use JVBase\integrations\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\UserConnection;
+
if (!defined('ABSPATH')) {
exit;
@@ -9,2966 +17,237 @@
class GoogleMyBusiness extends Integrations
{
- protected array $allowedContent = [
- 'menu_item',
- 'post',
- 'event',
- ];
- private ?string $access_token = null;
+ use Admin, Instance, OAuth, Requests, UserConnection;
+
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 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->defineFields();
+ $this->defineInstructions();
+ $this->defineOAuth();
+ $this->defineActions();
- $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' => [
+ 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.');
+
+ $secret = $this->addField(
+ 'client_secret',
+ 'OAuth Client Secret',
+ 'text',
+ 'manage_options',
+ );
+ $secret->setSubType('password');
+ $secret->setRequired();
+
+ 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'
+ ];
+ }
+ 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
+ {
+ $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,
- 'Content-Type' => 'application/json',
- 'Accept' => 'application/json'
- ];
+ // TODO: Implement getRequestHeaders() method.
}
- 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('https://mybusinessbusinessinformation.googleapis.com/v1/%s/locations', $this->getAccountID()??'') => $this->handleGetLocationsResponse($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;
+ // TODO: Implement refreshAccessToken() method.
}
- public function handleTheSavePost(int $postID, \WP_Post $post, bool $update, array $settings):void
+ /*********************************************************
+ * UTILITY
+ *********************************************************/
+ protected function getAccountID():string|false
{
-
- if (!$this->hasOAuthCredentials()) {
- error_log('OAuth Not set up for '.$this->service_name);
- return;
- }
-
- $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');
+ return $this->loadCredentials()['account_id']??false;
}
-
- public function processOperation(\WP_Error|array $result, object $operation, array $data):\WP_Error|array
+ protected function getAccountOptions(bool $force = false):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()
- ];
+ $stored = $this->loadCredentials()['accounts']??[];
+ if ($force || empty($stored)) {
+ $stored = $this->getAccounts();
}
+ return $stored;
}
- protected function syncEventPost(object $operation, array $data):\WP_Error|array
+ protected function getLocationID():string|false
{
- $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
- ];
+ return $this->loadCredentials()['location_id']??false;
}
-
- protected function syncOfferPost(object $operation, array $data):\WP_Error|array
+ protected function getLocationOptions(bool $force = false):array
{
- $location = $this->getSelectedLocationResourceName();
-
- if (!$location) {
- return [
- 'success' => false,
- 'result' => 'No location Name set.'
- ];
+ $stored = $this->loadCredentials()['locations']??[];
+ if ($force || empty($stored)) {
+ $stored = $this->getLocations();
}
-
- $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
- ];
+ return $stored;
}
-
-
- protected function syncStandardPost(object $operation, array $data):\WP_Error|array
+ protected function getLocations():array|false
{
- $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'
- ]);
+ if (!$this->getAccessToken() || !$this->getAccountID()) {
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
- ],
- '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",
+ return $this->getRequest(
+ sprintf(
+ '/v1/%s/locations',
+ $this->getAccountID()
+ ),
[
- 'orderBy' => 'updateTime desc'
- ],
- 'v4'
+ 'readMask' => $this->readMask
+ ]
);
-
- $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());
+ } catch (Exception $e)
+ {
+ $this->logError('getLocations', 'Could not get locations: '.$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);
+ 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 [
- '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;
+ return $locations;
}
- $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
+ protected function getAccounts(bool $force = false):array|false
{
- 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'])) {
+ if (!$this->getAccessToken()) {
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'
- );
- 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'
- );
-
- $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;
- }
- }
-
- /**
- * Upload menu images and return a map of identifiers to mediaKeys
- */
- protected function uploadAndMapMenuImages(array $menus): array
- {
- $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;
- }
- }
- }
- }
- }
- }
- }
-
- return $mediaKeyMap;
- }
-
- /**
- * Extract mediaKey from the full media resource name
- */
- protected function extractMediaKey(string $mediaResourceName): string
- {
- // 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'
- ]);
- 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))
- ];
-
return $this->getRequest(
- "/v1/{$location_name}:fetchMultiDailyMetricsTimeSeries?" . http_build_query($params),
- $params,
- 'performance'
+ '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
- {
- $this->contentTypes = [
- 'menu_item' => [
- 'nutritionFacts' => [
- 'type' => 'repeater',
- 'label' => 'Nutrition',
- 'fields' => [
- 'name' => [
- 'type' => 'select',
- 'label' => 'Name',
- 'options' => [
- 'calories' => 'Calories',
- 'totalFat' => 'Total Fat',
- 'cholesterol' => 'Cholesterol',
- 'sodium' => 'Sodium',
- 'totalCarbohydrate' => 'Total Carbohydrates',
- 'protein' => 'Protein'
- ]
- ],
- 'amount' => [
- 'type' => 'number',
- 'label' => 'Amount',
- ],
- 'unit' => [
- 'type' => 'select',
- 'label' => 'Unit',
- 'options' => [
- 'MILLIGRAM' => 'Milligram',
- 'GRAM' => 'Gram',
- 'CALORIE' => 'Calorie'
- ]
- ]
- ],
- 'description' => 'Recommended. Example: Total Fat: 3g',
- 'section' => 'gmb',
- ],
- 'servesNumPeople' => [
- 'type' => 'number',
- 'label' => 'Number of People this Serves',
- 'bulkEdit' => true,
- 'section' => 'gmb',
- ],
- 'portionSize' => [
- 'type' => 'text',
- 'label' => 'Portion size',
- 'description' => 'Example: 8-piece of nuggets',
- 'bulkEdit' => true,
- 'section' => 'gmb',
- ],
- 'preparationMethods' => [
- 'type' => 'textarea',
- 'label' => 'Preparation Methods',
- 'description' => 'Specific methods that the food can be prepared in',
- 'bulkEdit' => true,
- 'section' => 'gmb',
- ],
- 'cuisines' => [
- 'type' => 'text',
- 'label' => 'Cuisines',
- 'description' => 'Recommended. The specific cuisine of the food item',
- 'bulkEdit' => true,
- 'section' => 'gmb',
- ],
- 'spiciness' => [
- 'type' => 'select',
- 'label' => 'Spiciness',
- 'options' => [
- 'none' => 'None',
- 'mild' => 'Mild',
- 'medium' => 'Medium',
- 'hot' => 'Hot',
- ],
- 'bulkEdit' => true,
- 'section' => 'gmb',
- ],
- 'allergen' => [
- 'type' => 'checkbox',
- 'label' => 'Allergens',
- 'options' => [
- 'dairy' => 'Dairy',
- 'egg' => 'Egg',
- 'fish' => 'Fish',
- 'peanut' => 'Peanut',
- 'shellfish' => 'Shellfish',
- 'soy' => 'Soy',
- 'tree nut' => 'Tree Nut',
- 'wheat' => 'Wheat'
- ],
- 'description' => 'Recommended',
- 'bulkEdit' => true,
- 'section' => 'gmb',
- ],
- 'dietaryRestriction' => [
- 'type' => 'checkbox',
- 'label' => 'Dietary Restrictions',
- 'description' => 'Recommended.',
- 'options' => [
- 'halal' => 'Halal',
- 'kosher' => 'Kosher',
- 'organic' => 'Organic',
- 'vegan' => 'Vegan',
- 'vegetarian' => 'Vegetarian',
- 'gluten-free' => 'Gluten Free'
- ],
- 'bulkEdit' => true,
- 'section' => 'gmb',
- ]
- ],
- 'post' => [
- 'summary' => [
- 'type' => 'textarea',
- 'label' => 'Post Summary',
- 'required' => true
- ],
- 'language_code' => [
- 'type' => 'text',
- 'label' => 'Language',
- 'default' => 'en-CA'
- ],
- 'media' => [
- 'type' => 'image',
- 'label' => 'Photo',
- ],
- 'call_to_action' => [
- 'type' => 'group',
- 'fields' => [
- 'type' => [
- 'type' => 'select',
- 'options' =>
- [
- 'BOOK' => 'Book Now',
- 'ORDER' => 'Order Now',
- 'SHOP' => 'Shop Now',
- 'LEARN_MORE' => 'Learn More',
- 'SIGN_UP' => 'Sign up',
- 'CALL' => 'Call'
- ]
- ],
- 'url' => [
- 'type' => 'url',
- 'label' => 'Action URL'
- ]
- ]
- ]
- ],
- 'event' => [
- 'summary' => [
- 'type' => 'textarea',
- 'label' => 'Event Description',
- 'required' => true
- ],
- 'event' => [
- 'title' => [
- 'type' => 'text',
- 'label' => 'Event Title',
- 'required' => true
- ],
- 'start_date' => [
- 'type' => 'date',
- 'label' => 'Start Date',
- 'required' => true
- ],
- 'end_date' => [
- 'type' => 'date',
- 'label' => 'End Date'
- ],
- 'start_time' => [
- 'type' => 'time',
- 'label' => 'Start Time'
- ],
- 'end_time' => [
- 'type' => 'time',
- 'label' => 'End Time'
- ]
- ],
- 'media' => [
- 'type' => 'image',
- 'label' => 'Event Photo'
- ]
- ],
- 'offer' => [
- 'summary' => [
- 'type' => 'textarea',
- 'label' => 'Offer Description',
- 'required' => true
- ],
- 'offer' => [
- 'coupon_code' => [
- 'type' => 'text',
- 'label' => 'Coupon Code'
- ],
- 'redeem_online_url' => [
- 'type' => 'url',
- 'label' => 'Redemption URL'
- ],
- 'terms_conditions' => [
- 'type' => 'textarea',
- 'label' => 'Terms & Conditions']
- ],
- 'media' => ['type' => 'image', 'label' => 'Offer Photo']
- ]
- ];
- }
-
-
}
--
Gitblit v1.10.0