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->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->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' => [
'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 Google Cloud Console',
'Create OAuth 2.0 Client ID credentials',
'Enable Google My Business API',
'Add redirect URI: '.esc_html($this->getRedirectUri()).'',
'Use the "Authorize" button below to complete OAuth flow'
];
parent::__construct($userID);
$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 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']);
}
protected function getRequestHeaders(): array
{
return [
'Authorization' => 'Bearer ' . $this->access_token,
'Content-Type' => 'application/json',
'Accept' => 'application/json'
];
}
protected function addOAuthParams(array $params): array
{
$params['access_type'] = 'offline';
$params['prompt'] = 'consent';
return $params;
}
protected function addCredentialData(array $credentials, array $tokens): 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
{
$type = $settings['content_type']??'post'; //can be 'post', 'offer', 'event', 'hours', 'info',
$initial = $settings['initial']?? false;
$syncOnUpdate = $settings['update']??false;
if ($update && !$syncOnUpdate) {
return;
}
if (!$initial) {
return;
}
$options = $data = [];
switch ($type) {
case 'menu_item':
$options = ['delay' => 60];
break;
}
if (!user_can($post->post_author, 'manage_options')) {
$data['user'] = $post->post_author;
}
$data['post_id'] = $postID;
$operation = ($update) ? 'update_' : 'create_';
$this->queueOperation(
$operation.$type,
$data,
$options
);
}
public function processOperation(\WP_Error|array $result, object $operation, array $data):\WP_Error|array
{
try {
$gmb = (array_key_exists('user', $data)) ? new self((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']);
$meta->save();
}
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']);
$meta->save();
}
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']);
$meta->save();
}
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 = new self($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
],
'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 '
No business accounts found.
'; return; } ?>No Google Business accounts found. Please ensure your Google account has access to Google My Business.