From a6413da67a4579866f0fd0323ddb23a7b6b9654a Mon Sep 17 00:00:00 2001
From: Jake Vanderwerf <get@jakevanderwerf.ca>
Date: Fri, 18 Sep 2026 17:00:53 +0000
Subject: [PATCH] =Cleaning up the Integrations setup a bit, started the Helcim integration work too

---
 inc/integrations/services/GoogleMyBusiness.php | 3149 ++++++++---------------------------------------------------
 1 files changed, 440 insertions(+), 2,709 deletions(-)

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

--
Gitblit v1.10.0