<?php
|
namespace JVBase\integrations\services;
|
use Exception;
|
use JVBase\integrations\Admin;
|
use JVBase\Instance;
|
use JVBase\integrations\Integrations;
|
use JVBase\integrations\OAuth;
|
use JVBase\integrations\OAuthCredentials;
|
use JVBase\integrations\OAuthURLs;
|
use JVBase\integrations\Requests;
|
use JVBase\integrations\SyncToPost;
|
use JVBase\integrations\UserConnection;
|
use WP_Error;
|
|
|
if (!defined('ABSPATH')) {
|
exit;
|
}
|
|
class GoogleMyBusiness extends Integrations
|
{
|
use Admin, Instance, OAuth, Requests, UserConnection, SyncToPost;
|
|
protected string $readMask = 'name,title,storefrontAddress,metadata,openInfo,storeCode,categories,phoneNumbers,labels,specialHours';
|
protected string $businessV4;
|
protected function __construct()
|
{
|
$this->service_name = 'gmb';
|
$this->title = 'Google My Business';
|
$this->icon = 'storefront';
|
|
$this->canSync = [
|
'initial' => true,
|
'update' => true,
|
'delete' => true,
|
];
|
$this->supportsWebp = false;
|
|
$this->baseUrl = 'https://mybusinessbusinessinformation.googleapis.com';
|
$this->businessV4 = 'https://mybusiness.googleapis.com/v4';
|
|
$this->defineFields();
|
$this->defineInstructions();
|
$this->defineOAuth();
|
$this->defineActions();
|
|
parent::__construct();
|
}
|
protected function defineFields():void
|
{
|
$client_id = $this->addField(
|
'client_id',
|
'OAuth Client ID',
|
'text',
|
'manage_options'
|
);
|
$client_id->setRequired();
|
$client_id->setHint('From your Google Cloud Console OAuth credentials.');
|
$client_id->makeSetupField();
|
$this->updateField('client_id', $client_id);
|
|
$secret = $this->addField(
|
'client_secret',
|
'OAuth Client Secret',
|
'text',
|
'manage_options',
|
);
|
$secret->setSubType('password');
|
$secret->setRequired();
|
$secret->makeSetupField();
|
$this->updateField('client_secret', $client_id);
|
|
if ($this->getAccessToken()) {
|
$accs = $this->addField('accounts', 'Accounts', 'select', [$this, 'testUserCanConnect']);
|
$accs->setHidden();
|
$accs->setOptions($this->getAccountOptions());
|
|
$acc = $this->addField('account_id', 'Set Account', 'select', [$this, 'testUserCanConnect']);
|
$acc->setOptions($this->loadCredentials()['accounts']??[]);
|
$acc->setHint('Select the account to connect to.');
|
|
if ($this->getAccountID()) {
|
$locs = $this->addField('locations', 'Locations', 'select', [$this, 'testUserCanConnect']);
|
$locs->setHidden();
|
$locs->setOptions($this->getLocationOptions());
|
|
$loc = $this->addField('location_id', 'Set Location', 'select', [$this, 'testUserCanConnect']);
|
$loc->setOptions($this->loadCredentials()['locations']??[]);
|
$loc->setHint('Select the location to connect to.');
|
}
|
}
|
|
}
|
public function testUserCanConnect():bool
|
{
|
$user = get_current_user_id();
|
return $this->userCanConnect($user);
|
}
|
protected function defineInstructions():void
|
{
|
$this->instructions = [
|
'Go to <a href="https://console.cloud.google.com/apis/credentials" target="_blank">Google Cloud Console</a>',
|
'Create OAuth 2.0 Client ID credentials',
|
'Enable Google My Business API',
|
'Add redirect URI: <code>'.esc_html($this->getRedirectUri()).'</code>',
|
'Use the "Authorize" button below to complete OAuth flow'
|
];
|
|
$this->instructions = [
|
'Go to <a href="https://console.cloud.google.com/apis/credentials" target="_blank">Google Cloud Console</a>',
|
'Create OAuth 2.0 Client ID credentials',
|
'Enable the following APIs in your project:',
|
[
|
'<a href="https://console.cloud.google.com/apis/library/mybusinessaccountmanagement.googleapis.com" target="_blank">My Business Account Management API</a>',
|
'<a href="https://console.cloud.google.com/apis/library/mybusinessbusinessinformation.googleapis.com" target="_blank">My Business Business Information API</a>',
|
'<a href="https://console.cloud.google.com/apis/library/mybusiness.googleapis.com" target="_blank">Google My Business API</a> (legacy v4, required for reviews)',
|
],
|
'On the OAuth consent screen, add these scopes:',
|
[
|
'<code>https://www.googleapis.com/auth/business.manage</code>',
|
'<code>https://www.googleapis.com/auth/userinfo.email</code>',
|
'<code>https://www.googleapis.com/auth/userinfo.profile</code>',
|
],
|
'Add redirect URI: <code>'.esc_html($this->getRedirectUri()).'</code>',
|
'Use the "Authorize" button below to complete OAuth flow'
|
];
|
}
|
protected function defineOAuth():void
|
{
|
$this->oauth = new OAuthURLs(
|
'https://accounts.google.com/o/oauth2/v2/auth',
|
'https://oauth2.googleapis.com/token',
|
'https://oauth2.googleapis.com/revoke'
|
);
|
|
$this->oathScopes = [
|
'https://www.googleapis.com/auth/business.manage',
|
'https://www.googleapis.com/auth/userinfo.email',
|
'https://www.googleapis.com/auth/userinfo.profile',
|
'https://www.googleapis.com/auth/plus.business.manage'
|
];
|
}
|
protected function defineActions():void
|
{
|
if ($this->hasOAuthCredentials($this->userID)) {
|
$this->addAction(
|
'Update Location',
|
[$this, 'handleUpdateLocation'],
|
'update_location',
|
);
|
}
|
}
|
public function handleUpdateLocation(array $data):array
|
{
|
if (empty($data['location_id']??'')) {
|
return $this->response(false, 'No location selected');
|
}
|
|
$credentials = [
|
'location_id' => sanitize_text_field($data['location_id'])
|
];
|
return $this->saveCredentials($credentials, false);
|
}
|
|
protected function initialize(): void
|
{
|
// TODO: Implement initialize() method.
|
}
|
|
protected function getRequestHeaders(): array
|
{
|
|
return [
|
'Authorization' => 'Bearer ' . $this->getAccessToken(),
|
'Content-Type' => 'application/json',
|
'Accept' => 'application/json'
|
];
|
}
|
|
protected function handleResponse(WP_Error|array $response, string $method, string $endpoint): array
|
{
|
if (is_wp_error($response) && $response->get_error_message() === 'rate_limit_exceeded') {
|
return $this->response(false, 'Rate limit exceeded');
|
}
|
|
return match($endpoint) {
|
'https://mybusinessaccountmanagement.googleapis.com/v1/accounts' => $this->handleGetAccountsResponse($response, $method),
|
sprintf('%s/%s/%s/reviews', $this->businessV4, $this->getAccountID()??'', $this->getLocationID()??'') => $this->handleGetReviewResponse($response, $method),
|
sprintf('https://mybusinessbusinessinformation.googleapis.com/v1/%s/locations', $this->getAccountID()??'') => $this->handleGetLocationsResponse($response, $method),
|
sprintf('%s/v1/%s', $this->baseUrl,$this->getLocationID()??'') => $this->handleGetLocationResponse($response, $method),
|
default => $this->response(false, 'Unhandled response for '.$endpoint.' endpoint, with method: '.$method)
|
};
|
}
|
|
protected function refreshAccessToken(OAuthCredentials $credentials): array
|
{
|
if (empty($this->getClientID()) || empty($this->getClientSecret()) || empty($credentials->refresh_token)) {
|
$this->logError('refreshAccessToken', 'Missing refresh token');
|
return $this->response(false, 'Missing client ID, client Secret, or refresh token');
|
}
|
$response = wp_remote_post(
|
$this->oauth->getToken(),
|
[
|
'timeout' => 15,
|
'body' => [
|
'grant_type' => 'refresh_token',
|
'refresh_token' => $credentials->refresh_token,
|
'client_id' =>$this->getClientID(),
|
'client_secret' =>$this->getClientSecret()
|
],
|
'headers' => $this->getOAuthRequestHeaders()
|
]
|
);
|
if (is_wp_error($response)) {
|
$this->logError(
|
'refreshAccessToken',
|
'Failed to refresh GMB Token',
|
[
|
'error' => $response->get_error_message()
|
]
|
);
|
return $this->response(false, 'Failed to refresh GMB Token');
|
}
|
|
$data = json_decode(wp_remote_retrieve_body($response), true);
|
if (empty($data['access_token'])) {
|
$this->logError('refreshAccessToken', 'No access token in response');
|
return $this->response(false, 'No access token in response');
|
}
|
$credentials = [
|
'access_token' => $data['access_token'],
|
'expires_at' => time() + (int)($data['expires_in']??3600),
|
// Google usually omits refresh_token on refresh — keep the old one
|
'refresh_token' => $body['refresh_token'] ?? $credentials->refresh_token,
|
];
|
$this->saveCredentials($credentials);
|
|
return $this->response(true, 'Successfully refreshed token');
|
}
|
|
/*********************************************************
|
* UTILITY
|
*********************************************************/
|
protected function getClientID():string|false
|
{
|
$switch = false;
|
if ($this->userID !== null) {
|
$switch = $this->userID;
|
$this->switchUser(null);
|
}
|
|
$ID = $this->loadCredentials()['client_id']??false;
|
if ($switch) {
|
$this->switchUser($switch);
|
}
|
return $ID;
|
}
|
protected function getClientSecret():string|false
|
{
|
$switch = false;
|
if ($this->userID !== null) {
|
$switch = $this->userID;
|
$this->switchUser(null);
|
}
|
|
$ID = $this->loadCredentials()['client_secret']??false;
|
if ($switch) {
|
$this->switchUser($switch);
|
}
|
return $ID;
|
}
|
protected function getAccountID():string|false
|
{
|
return $this->loadCredentials()['account_id']??false;
|
}
|
protected function getAccountOptions(bool $force = false):array
|
{
|
$stored = $this->loadCredentials()['accounts']??[];
|
if ($force || empty($stored)) {
|
$stored = $this->getAccounts();
|
}
|
return $stored;
|
}
|
|
protected function getLocation(bool $force = false):?array
|
{
|
$location_name = $this->getLocationID();
|
if (empty($location_name)) {
|
return null;
|
}
|
$params = [
|
'readMask' => $this->readMask
|
];
|
|
return $this->getRequest(
|
sprintf(
|
'%s/v1/%s',
|
$this->baseUrl,
|
$location_name,
|
),
|
$params,
|
$force
|
);
|
|
}
|
protected function handleGetLocationResponse(WP_Error|array $response, string $method):?array
|
{
|
return $response??null;
|
}
|
protected function getLocationID():string|false
|
{
|
return $this->loadCredentials()['location_id']??false;
|
}
|
protected function getLocationOptions(bool $force = false):array
|
{
|
$stored = $this->loadCredentials()['locations']??[];
|
if ($force || empty($stored)) {
|
$stored = $this->getLocations();
|
}
|
return $stored;
|
}
|
protected function getLocations():array|false
|
{
|
if (!$this->getAccessToken() || !$this->getAccountID()) {
|
return false;
|
}
|
try {
|
return $this->getRequest(
|
sprintf(
|
'/v1/%s/locations',
|
$this->getAccountID()
|
),
|
[
|
'readMask' => $this->readMask
|
]
|
);
|
} catch (Exception $e)
|
{
|
$this->logError('getLocations', 'Could not get locations: '.$e->getMessage());
|
return [];
|
}
|
}
|
protected function handleGetLocationsResponse(array $response, string $method):array
|
{
|
$locations = [];
|
if (isset($response['locations'])) {
|
foreach ($response['locations'] as $loc) {
|
if (isset($loc['name']) && isset($loc['title'])) {
|
$locations[$loc['name']] = $loc['title'];
|
}
|
}
|
}
|
return $locations;
|
}
|
|
protected function getAccounts(bool $force = false):array|false
|
{
|
if (!$this->getAccessToken()) {
|
return false;
|
}
|
|
return $this->getRequest(
|
'https://mybusinessaccountmanagement.googleapis.com/v1/accounts',
|
[],
|
$force
|
);
|
}
|
protected function handleGetAccountsResponse(array $response, string $method):array
|
{
|
$accounts = [];
|
if (isset($response['accounts']) && is_array($response['accounts'])) {
|
$accounts = $response['accounts'];
|
} elseif (is_array($response) && isset($response['name'])) {
|
$accounts = [$response];
|
}
|
$options = [];
|
foreach ($accounts as $account) {
|
if (isset($account['name']) && isset($account['accountName'])) {
|
$options[$account['name']] = $account['accountName'];
|
}
|
}
|
return $options;
|
}
|
|
protected function setContentTypes(): void
|
{
|
$this->contentTypes = [
|
'menu_item' => $this->buildMenuItemContentType(),
|
'post' => $this->buildPostContentType(),
|
'event' => $this->buildEventContentType(),
|
'offer' => $this->buildOfferContentType(),
|
];
|
}
|
protected function buildMenuItemContentType():array
|
{
|
return [
|
'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',
|
]
|
];
|
}
|
protected function buildPostContentType():array
|
{
|
return [
|
'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'
|
]
|
]
|
]
|
];
|
}
|
protected function buildEventContentType():array
|
{
|
return [
|
'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'
|
]
|
];
|
}
|
protected function buildOfferContentType():array
|
{
|
return [
|
'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']
|
];
|
}
|
protected function formatForService(int $itemID, string $type = 'post'): array
|
{
|
// TODO: Implement formatForService() method.
|
}
|
|
|
public function getReviews(int $number = 5):array
|
{
|
if (!$this->getLocationID() || !$this->getAccountID()){
|
return [];
|
}
|
return $this->cache->remember(
|
['reviews', $this->getLocationID(), $number],
|
function() use ($number) {
|
try {
|
return $this->getRequest(
|
sprintf(
|
"%s/{$this->getAccountID()}/{$this->getLocationID()}/reviews",
|
$this->businessV4
|
),
|
[
|
'orderBy' => 'updateTime desc'
|
],
|
);
|
} catch (Exception $e) {
|
$this->logError(
|
'getReviews',
|
'Failed to fetch Reviews',
|
[
|
'error' => $e->getMessage()
|
]);
|
return [];
|
}
|
}
|
);
|
}
|
protected function handleGetReviewResponse(WP_Error|array $response, string $method):array
|
{
|
return $response;
|
}
|
|
/**********************************************************************
|
* REVIEW UTILITY
|
**********************************************************************/
|
/**
|
* Get the URL to view all Google reviews for the current location
|
* @return string|null The reviews viewing URL or null if not available
|
*/
|
public function getReviewsViewUrl():?string
|
{
|
$location = $this->getLocation();
|
if (!$location) {
|
return null;
|
}
|
|
if (!empty($location['metadata']['mapsUrl']??'')) {
|
return $location['metadata']['mapsUrl'];
|
}
|
|
if (!empty($location['metadata']['placeId']??'')) {
|
return sprintf(
|
'https://search.google.com/local/reviews?placeid=%s',
|
urlencode($location['metadata']['placeId'])
|
);
|
}
|
|
return null;
|
}
|
|
/**
|
* Get the URL to leave a review for the current location
|
* @return string|null The review URL or null if not available
|
*/
|
public function getReviewUrl():?string
|
{
|
$location = $this->getLocation();
|
if (!$location) {
|
return null;
|
}
|
|
if (!empty($location['metadata']['placeId']??'')) {
|
return sprintf(
|
'https://search.google.com/local/writereview?placeid=%s',
|
urlencode($location['metadata']['placeId'])
|
);
|
}
|
|
if (!empty($location['metadata']['mapsUrl']??'')) {
|
return sprintf(
|
'%s/reviews',
|
$location['metadata']['mapsUrl']
|
);
|
}
|
|
return null;
|
}
|
|
|
/**************************************************************************
|
* FOOD MENU HELPERS
|
**************************************************************************/
|
}
|