<?php
|
namespace JVBase\integrations;
|
|
use JVBase\meta\MetaManager;
|
use JVBase\managers\CacheManager;
|
use WP_Error;
|
if (!defined('ABSPATH')) {
|
exit;
|
}
|
|
class GoogleMyBusiness extends Integrations
|
{
|
private ?string $access_token = null;
|
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;
|
|
public function __construct(?int $userID = null)
|
{
|
$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->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 <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($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->clear();
|
}
|
}
|
|
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 = new MetaManager($postID, 'post');
|
$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->updateValue("_{$this->service_name}_item_id", $result['name']);
|
}
|
|
return [
|
'success' => !empty($result),
|
'result' => $result
|
];
|
}
|
|
protected function syncOfferPost(object $operation, array $data):\WP_Error|array
|
{
|
$location = $this->getSelectedLocationResourceName();
|
|
if (!$location) {
|
return [
|
'success' => false,
|
'result' => 'No location Name set.'
|
];
|
}
|
|
$postID = $data['post_id'];
|
$meta = new MetaManager($postID, 'post');
|
$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->updateValue("_{$this->service_name}_item_id", $result['name']);
|
}
|
|
return [
|
'success' => !empty($result),
|
'result' => $result
|
];
|
}
|
|
|
protected function syncStandardPost(object $operation, array $data):\WP_Error|array
|
{
|
$location = $this->getSelectedLocationResourceName();
|
|
if (!$location) {
|
return [
|
'success' => false,
|
'result' => 'No location Name set.'
|
];
|
}
|
|
$postID = $data['post_id'];
|
$meta = new MetaManager($postID, 'post');
|
$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->updateValue("_{$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 = 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 '<p>No business accounts found.</p>';
|
return;
|
}
|
?>
|
<div class="gmb-business-list">
|
<h4>Your Business Locations</h4>
|
<?php
|
foreach ($accounts as $account) {
|
$locations = $this->getLocations($account['name']);
|
foreach ($locations as $location) {
|
?>
|
<div class="business-location">
|
<strong><?php echo esc_html($location['title']); ?></strong>
|
<?php if ($this->isSelectedLocation($location)): ?>
|
<span class="selected-badge">✓ Selected</span>
|
<?php endif; ?>
|
</div>
|
<?php
|
}
|
}
|
?>
|
</div>
|
<?php
|
} catch (\Exception $e) {
|
echo '<p>Error loading business locations.</p>';
|
}
|
}
|
|
private function isSelectedLocation(array $location): bool
|
{
|
if (empty($this->credentials['location'])) {
|
return false;
|
}
|
|
return ($location['name'] === $this->credentials['location']);
|
}
|
|
/***********************************************************
|
UTILITY
|
***********************************************************/
|
/**
|
* Helper method to strip HTML tags from text
|
* Preserves plain text content while removing all HTML formatting
|
*/
|
protected function stripHtmlTags(string $text): string
|
{
|
// Remove HTML tags
|
$text = wp_strip_all_tags($text);
|
|
// Decode HTML entities
|
$text = html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8');
|
|
// Clean up whitespace
|
$text = preg_replace('/\s+/', ' ', $text);
|
|
// Trim
|
return trim($text);
|
}
|
/**
|
* Format date for GMB API
|
*/
|
protected function formatDate(string $date): array
|
{
|
$timestamp = strtotime($date);
|
return [
|
'year' => (int)date('Y', $timestamp),
|
'month' => (int)date('n', $timestamp),
|
'day' => (int)date('j', $timestamp)
|
];
|
}
|
/**
|
* Format time for Google My Business API (HH:MM format)
|
*/
|
private function formatTime(string $time): ?array
|
{
|
if (empty($time)) {
|
return null;
|
}
|
|
// Remove any extra whitespace
|
$time = trim($time);
|
|
// Handle different time formats and convert to 24-hour format
|
if (preg_match('/^(\d{1,2}):(\d{2})(?:\s*(AM|PM))?$/i', $time, $matches)) {
|
$hour = (int)$matches[1];
|
$minute = (int)$matches[2];
|
$ampm = isset($matches[3]) ? strtoupper($matches[3]) : '';
|
|
// Convert 12-hour to 24-hour format if needed
|
if ($ampm === 'PM' && $hour !== 12) {
|
$hour += 12;
|
} elseif ($ampm === 'AM' && $hour === 12) {
|
$hour = 0;
|
}
|
|
// Validate hour and minute ranges
|
if ($hour >= 0 && $hour <= 23 && $minute >= 0 && $minute <= 59) {
|
return [
|
'hours' => $hour,
|
'minutes' => $minute
|
];
|
}
|
}
|
|
error_log('[GMB] Invalid time format: ' . $time);
|
return null;
|
}
|
|
|
|
/*****************************************************************************
|
*
|
* OAUTH IMPLEMENTATION
|
*
|
*****************************************************************************/
|
private function getScopes(): string
|
{
|
return implode(' ', [
|
'https://www.googleapis.com/auth/business.manage',
|
'https://www.googleapis.com/auth/userinfo.email',
|
'https://www.googleapis.com/auth/userinfo.profile'
|
]);
|
}
|
/**
|
* Handle account selection
|
*/
|
public function handleSelectAccount($data = null): array
|
{
|
try {
|
$account_name = null;
|
|
// Extract account name from various sources
|
if (is_array($data)) {
|
$account_name = $data['account'] ?? $data['account'] ?? null;
|
} elseif (isset($_POST['account'])) {
|
$account_name = sanitize_text_field($_POST['account']);
|
}
|
|
if (empty($account_name)) {
|
return [
|
'success' => false,
|
'message' => 'No Account Selected'
|
];
|
}
|
|
// Get account details
|
$accounts = $this->getAccounts();
|
$selected_account = null;
|
|
foreach ($accounts as $account) {
|
if ($account['name'] === $account_name) {
|
$selected_account = $account;
|
break;
|
}
|
}
|
|
if (!$selected_account) {
|
throw new \Exception('Invalid account selected');
|
}
|
|
// Update credentials with selected account
|
$this->credentials['account'] = $account_name;
|
$this->credentials['selected_account_id'] = $selected_account['accountId'] ?? '';
|
|
$this->saveCredentials($this->credentials);
|
|
return [
|
'success' => true,
|
'message' => 'Account selected successfully',
|
'reload' => true
|
];
|
|
} catch (\Exception $e) {
|
$this->logError('Failed to select account', [
|
'error' => $e->getMessage(),
|
'data' => $data
|
]);
|
|
return [
|
'success' => false,
|
'message' => $e->getMessage()
|
];
|
}
|
}
|
|
/**
|
* Handle location selection
|
*/
|
public function handleSelectLocation($data = null): array
|
{
|
try {
|
$location_name = null;
|
|
// Extract location name from various sources
|
if (is_array($data)) {
|
$location_name = $data['location'] ?? $data['location'] ?? null;
|
} elseif (isset($_POST['location'])) {
|
$location_name = sanitize_text_field($_POST['location']);
|
}
|
|
if (empty($location_name)) {
|
throw new \Exception('No location selected');
|
}
|
|
// Verify this is a valid location for the selected account
|
if (empty($this->credentials['account'])) {
|
throw new \Exception('No account selected. Please select an account first.');
|
}
|
|
$locations = $this->getLocations($this->credentials['account']);
|
$valid_location = false;
|
|
foreach ($locations as $location) {
|
if ($location['name'] === $location_name) {
|
$valid_location = true;
|
$this->credentials['location_id'] = $location['locationId'] ?? '';
|
break;
|
}
|
}
|
|
if (!$valid_location) {
|
throw new \Exception('Invalid location selected');
|
}
|
|
// Update credentials with selected location
|
$this->credentials['location'] = $location_name;
|
|
// Save updated credentials
|
$this->saveCredentials($this->credentials);
|
|
// Initialize with new location
|
$this->credentials['location'] = $location_name;
|
|
return [
|
'success' => true,
|
'message' => 'Location selected successfully',
|
'reload' => false // No need to reload for location selection
|
];
|
|
} catch (\Exception $e) {
|
$this->logError('Failed to select location', [
|
'error' => $e->getMessage(),
|
'data' => $data
|
]);
|
|
return [
|
'success' => false,
|
'message' => $e->getMessage()
|
];
|
}
|
}
|
|
/**
|
* Check OAuth status
|
*/
|
public function checkOAuthStatus(): array
|
{
|
try {
|
$valid = $this->isOAuthValid();
|
|
return [
|
'success' => true,
|
'valid' => $valid,
|
'expires_at' => $this->credentials['expires_at'] ?? null,
|
'has_refresh_token' => !empty($this->credentials['refresh_token'])
|
];
|
|
} catch (\Exception $e) {
|
return [
|
'success' => false,
|
'valid' => false,
|
'message' => $e->getMessage()
|
];
|
}
|
}
|
|
|
/********************************************************************
|
*
|
* ADMIN UI
|
*
|
********************************************************************/
|
/**
|
* Updated renderOAuthConnectedOptions for better UI
|
*/
|
protected function renderOAuthConnectedOptions(): void
|
{
|
if (!$this->isSetUp()) {
|
return;
|
}
|
|
// Check OAuth status
|
$oauth_valid = $this->isOAuthValid();
|
|
if (!$oauth_valid) {
|
?>
|
<div class="oauth-status">
|
<span class="status-invalid">⚠️ OAuth token expired - please reconnect</span>
|
</div>
|
<?php
|
}
|
|
// Show account selection
|
$accounts = $this->getAccounts();
|
if (!empty($accounts)) {
|
?>
|
<div class="form-field" data-service="gmb">
|
<label for="gmb_account">Google Business Account</label>
|
<select name="account"
|
id="gmb_account"
|
class="jvb-ajax-update"
|
data-action="select_account">
|
<option value="">Select an account...</option>
|
<?php foreach ($accounts as $account): ?>
|
<option value="<?php echo esc_attr($account['name']); ?>"
|
<?php selected($credentials['account'] ?? '', $account['name']); ?>>
|
<?php echo esc_html($account['accountName'] ?? $account['name']); ?>
|
</option>
|
<?php endforeach; ?>
|
</select>
|
</div>
|
<?php
|
|
// Show location selection if account is selected
|
if (!empty($this->credentials['account'])) {
|
$locations = $this->getLocations($this->credentials['account']);
|
|
if (!empty($locations)) {
|
?>
|
<div class="form-field location-field visible" data-service="gmb">
|
<label for="gmb_location">Business Location</label>
|
<select name="location"
|
id="gmb_location"
|
class="jvb-ajax-update"
|
data-action="select_location">
|
<option value="">Select a location...</option>
|
<?php foreach ($locations as $location): ?>
|
<option value="<?php echo esc_attr($location['name']); ?>"
|
<?php selected($this->credentials['location'] ?? '', $location['name']); ?>>
|
<?php echo esc_html($location['title'] ?? $location['name']); ?>
|
</option>
|
<?php endforeach; ?>
|
</select>
|
</div>
|
<?php
|
}
|
}
|
|
// Show current selection status
|
if (!empty($this->credentials['location'])) {
|
?>
|
<div class="connection-status connected">
|
✅ Connected to: <?php echo esc_html($this->credentials['location']); ?>
|
</div>
|
<?php
|
}
|
} else {
|
?>
|
<div class="notice notice-warning">
|
<p>No Google Business accounts found. Please ensure your Google account has access to Google My Business.</p>
|
</div>
|
<?php
|
}
|
}
|
|
public function getServiceDescription(): string
|
{
|
return "Manage your Google My Business listings, posts, hours, and reviews.";
|
}
|
|
/*****************************************************************************
|
*
|
* FOOD MENU IMPLEMENTATION
|
*
|
*****************************************************************************/
|
|
/**
|
* Get food menus for the current location
|
*/
|
public function getFoodMenus(): ?array
|
{
|
if (!$this->credentials['location']) {
|
throw new \Exception('No location selected. Please select a location first.');
|
}
|
|
$response = $this->getRequest(
|
"/v4/{$this->credentials['location']}/foodMenu",
|
[],
|
'posts'
|
);
|
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->clear();
|
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'
|
);
|
}
|
|
/**
|
* 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;
|
}
|
|
$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 = new MetaManager($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']);
|
}
|
}
|
|
$sections_map[$section_slug]['items'][] = $gmb_item;
|
}
|
|
$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 = new MetaManager($item->ID, 'post');
|
$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 = new MetaManager(null, 'options');
|
$sectionOrder = $optionsMeta->getValue('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 = new MetaManager($item->ID, 'post');
|
$item_cuisines = $meta->getValue('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->has_content = true;
|
$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']
|
]
|
];
|
}
|
|
|
}
|