service_name = 'gmb'; $this->title = 'Google My Business'; $this->icon = 'storefront'; $this->cacheName = ($userID)? 'gmb_'.$userID : 'gmb'; $this->canSync = [ 'initial' => true, 'update' => true, 'delete' => true, ]; $this->apiBase = [ 'base' => 'https://mybusinessbusinessinformation.googleapis.com', 'accounts' => 'https://mybusinessaccountmanagement.googleapis.com', 'posts' => 'https://mybusiness.googleapis.com', 'performance'=> 'https://businessprofileperformance.googleapis.com', 'v4' => 'https://mybusiness.googleapis.com/v4', ]; $this->apiEndpoints = [ '/accounts/[^/]+/locations/[^/]+/reviews', '/accounts/[^/]+/locations/[^/]+/foodMenus', '/v4/accounts/[^/]+/locations/[^/]+/media', '/v4/accounts/[^/]+/locations/[^/]+/localPosts', '/v4/accounts/[^/]+/locations/[^/]+/localPosts/[^/]+', '/accounts/[^/]+/locations/[^/]+', '/v1/accounts/[^/]+/locations', // Matches /v1/accounts/{accountId}/locations '/v1/accounts', // Add pattern matching for dynamic location endpoints '/v1/locations/[^/]+', // Matches /v1/locations/{locationId} ]; $this->isOAuthService = true; $this->oauth = [ 'authorize' => 'https://accounts.google.com/o/oauth2/v2/auth', 'token' => 'https://oauth2.googleapis.com/token', 'revoke' => 'https://oauth2.googleapis.com/revoke', 'scopes' => [ 'https://www.googleapis.com/auth/business.manage', 'https://www.googleapis.com/auth/userinfo.email', 'https://www.googleapis.com/auth/userinfo.profile', 'https://www.googleapis.com/auth/plus.business.manage' ] ]; $this->supportsWebp = false; $this->rate_limits = [ 'per_second' => 15, // Allow 15 requests in burst window 'per_minute' => 60, // 1-minute burst window 'per_hour' => 500 // 500 requests per hour (well below Google's limits) ]; $this->fields = [ 'client_id' => [ 'type' => 'text', 'label' => 'OAuth Client ID', 'placeholder'=> 'Enter Google OAuth Client ID', 'hint' => 'From your Google Cloud Console OAuth credentials.', 'required' => true, ], 'client_secret' => [ 'type' => 'text', 'subtype' => 'password', 'label' => 'OAuth Client Secret', 'required' => true, ], // 'access_token' => [ // 'type' => 'text', // 'subtype' => 'password', // 'label' => 'Access Token', // 'hint' => 'Generated automagically after OAuth authorization.' // ], // 'refresh_token' => [ // 'type' => 'text', // 'subtype' => 'password', // 'label' => 'Refresh Token', // 'hint' => 'Generated automagically after OAuth authorization.' // ] ]; $this->advanced = [ ]; $this->instructions = [ 'Go to Google Cloud Console', 'Create OAuth 2.0 Client ID credentials', 'Enable Google My Business API', 'Add redirect URI: '.esc_html($this->getRedirectUri()).'', 'Use the "Authorize" button below to complete OAuth flow' ]; parent::__construct($userID); $this->actions = array_merge( $this->actions, [ 'update_location' => 'handleUpdateLocation', 'select_account' => 'handleSelectAccount', 'select_location' => 'handleSelectLocation', 'check_oauth_status'=> 'checkOAuthStatus' ] ); $this->buttons = array_merge( $this->buttons, [ 'check_oauth_status' => 'Check OAuth Status' ] ); if (JVB_TESTING) { $this->cache->flush(); } } protected function initialize(): void { if (empty($this->credentials)) { $this->loadCredentials(); } $this->access_token = (array_key_exists('access_token', $this->credentials)) ? $this->credentials['access_token'] : null; $this->refresh_token = (array_key_exists('refresh_token', $this->credentials)) ? $this->credentials['refresh_token'] : null; $this->client_id = (array_key_exists('client_id', $this->credentials)) ? $this->credentials['client_id'] : null; $this->client_secret = (array_key_exists('client_secret', $this->credentials)) ? $this->credentials['client_secret'] : null; $this->location = (array_key_exists('location', $this->credentials)) ? $this->credentials['location'] : null; $this->account_id = (array_key_exists('account', $this->credentials)) ? $this->credentials['account'] : null; if ($this->account_id) { $this->apiEndpoints[] = "/v1/{$this->account_id}/locations"; } if ($this->location) { $locationName = $this->getSelectedLocationResourceName(); $this->apiEndpoints[] = "/v4/{$locationName}/localPosts"; } } protected function performConnectionTest(): bool { try { $accounts = $this->getAccounts(true); return !empty($accounts); } catch (\Exception $e) { return false; } } /** * Check if response contains an error - Google-specific */ protected function isErrorResponse(array $response): bool { // Google APIs return errors in this format: // {"error": {"code": 401, "message": "...", "status": "UNAUTHENTICATED"}} return isset($response['error']) && isset($response['error']['code']); } protected function getRequestHeaders(): array { return [ 'Authorization' => 'Bearer ' . $this->access_token, 'Content-Type' => 'application/json', 'Accept' => 'application/json' ]; } protected function addOAuthParams(array $params): array { $params['access_type'] = 'offline'; $params['prompt'] = 'consent'; return $params; } protected function addCredentialData(array $credentials, array $tokens): array { // Preserve GMB-specific settings $existing = $this->credentials; $credentials['location'] = $existing['location'] ?? null; $credentials['account'] = $existing['account'] ?? null; return $credentials; } public function handleTheSavePost(int $postID, \WP_Post $post, bool $update, array $settings):void { $type = $settings['content_type']??'post'; //can be 'post', 'offer', 'event', 'hours', 'info', $initial = $settings['initial']?? false; $syncOnUpdate = $settings['update']??false; if ($update && !$syncOnUpdate) { return; } if (!$initial) { return; } $options = $data = []; switch ($type) { case 'menu_item': $options = ['delay' => 60]; break; } if (!user_can($post->post_author, 'manage_options')) { $data['user'] = $post->post_author; } $data['post_id'] = $postID; $operation = ($update) ? 'update_' : 'create_'; $this->queueOperation( $operation.$type, $data, $options ); } public function processOperation(\WP_Error|array $result, object $operation, array $data):\WP_Error|array { try { $gmb = (array_key_exists('user', $data)) ? new self((int)$data['user']) : $this; $gmb->ensureInitialized(); switch ($operation->type) { case 'gmb_create_menu_item': case 'gmb_update_menu_item': case 'gmb_menu_update': case 'gmb_menu_update_' . $this->userID: return $gmb->processMenuUpdate($operation, $data); case 'gmb_create_event': case 'gmb_update_event': return $gmb->syncEventPost($operation, $data); case 'gmb_create_offer': case 'gmb_update_offer': return $gmb->syncOfferPost($operation, $data); case 'gmb_create_post': case 'gmb_update_post': return $gmb->syncStandardPost($operation, $data); case 'gmb_specialHours': return $gmb->setSpecialHours($data['date'], $data['data']); case 'gmb_business_hours': return $gmb->updateBusinessHours($data); case 'gmb_business_info': return $gmb->updateBusinessInfo($data); case 'gmb_update_attributes': return $gmb->updateAttributes($data); default: return $result; } } catch (\Exception $e) { error_log('[GMB] Operation processing error: ' . $e->getMessage()); return [ 'success' => false, 'result' => $e->getMessage() ]; } } protected function syncEventPost(object $operation, array $data):\WP_Error|array { $location = $this->getSelectedLocationResourceName(); if (!$location) { return [ 'success' => false, 'result' => 'No location Name set.' ]; } $postID = $data['post_id']; $meta = Meta::forPost($postID); $fields = [ 'start_date', 'end_date', 'start_time', 'end_time', 'post_excerpt', 'post_title', 'post_thumbnail', "_{$this->service_name}_item_id" ]; $data = [ 'summary' => $fields['post_excerpt'], 'topic_type' => 'EVENT', 'event' => [ 'title' => $fields['post_title'], 'start_date' => $this->formatDate($fields['start_date']), 'end_date' => $this->formatDate(($fields['end_date'] === '') ? $fields['start_date'] : $fields['end_date']), 'start_time' => $this->formatTime($fields['start_time']), 'end_time' => $this->formatTime($fields['end_time']), ] ]; if ($fields['post_thumbnail'] !== '') { $url = wp_get_attachment_url($fields['post_thumbnail']); if ($url) { $data['media'] = [ ['mediaFormat' => 'PHOTO', 'sourceUrl' => $url] ]; } } if ($fields["_{$this->service_name}_item_id"] !== '') { $result = $this->updatePost($fields["_{$this->service_name}_item_id"], $data); } else { $result = $this->createPost($data); $meta->set("_{$this->service_name}_item_id", $result['name']); $meta->save(); } return [ 'success' => !empty($result), 'result' => $result ]; } protected function syncOfferPost(object $operation, array $data):\WP_Error|array { $location = $this->getSelectedLocationResourceName(); if (!$location) { return [ 'success' => false, 'result' => 'No location Name set.' ]; } $postID = $data['post_id']; $meta = Meta::forPost($postID); $fields = [ 'post_excerpt', 'post_title', 'coupon_code', 'termsConditions', 'post_thumbnail', "_{$this->service_name}_item_id" ]; $data = [ 'summary' => $fields['post_excerpt'], 'topic_type' => 'OFFER', 'offer' => [ 'couponCode' => ($fields['coupon_code'] === '') ? $fields['post_title'] : $fields['coupon_code'], 'redeemOnlineUrl'=> get_the_permalink($postID), // 'termsConditions'=> get_bloginfo('terms_condition') TODO: setup terms and Conditions ] ]; if ($fields['post_thumbnail'] !== '') { $url = wp_get_attachment_url($fields['post_thumbnail']); if ($url) { $data['media'] = [ ['mediaFormat' => 'PHOTO', 'sourceUrl' => $url] ]; } } if ($fields["_{$this->service_name}_item_id"] !== '') { $result = $this->updatePost($fields["_{$this->service_name}_item_id"], $data); } else { $result = $this->createPost($data); $meta->set("_{$this->service_name}_item_id", $result['name']); $meta->save(); } return [ 'success' => !empty($result), 'result' => $result ]; } protected function syncStandardPost(object $operation, array $data):\WP_Error|array { $location = $this->getSelectedLocationResourceName(); if (!$location) { return [ 'success' => false, 'result' => 'No location Name set.' ]; } $postID = $data['post_id']; $meta = Meta::forPost($postID); $fields = [ 'post_excerpt', 'post_title', 'post_thumbnail', "_{$this->service_name}_item_id" ]; $data = [ 'summary' => $fields['post_excerpt'], 'topic_type' => 'STANDARD', 'call_to_action' => [ 'type' => 'learn_more', 'url'=> get_the_permalink($postID), ] ]; if ($fields['post_thumbnail'] !== '') { $url = wp_get_attachment_url($fields['post_thumbnail']); if ($url) { $data['media'] = [ ['mediaFormat' => 'PHOTO', 'sourceUrl' => $url] ]; } } if ($fields["_{$this->service_name}_item_id"] !== '') { $result = $this->updatePost($fields["_{$this->service_name}_item_id"], $data); } else { $result = $this->createPost($data); $meta->set("_{$this->service_name}_item_id", $result['name']); $meta->save(); } return [ 'success' => !empty($result), 'result' => $result ]; } protected function processMenuUpdate($operation, $data) { try { $postID = $data['post_id']; if (!$postID) { return [ 'success' => false, 'result' => 'No post ID set' ]; } $post = get_post($postID); $postType = jvbNoBase($post->post_type); $args = [ 'post_type' => jvbCheckBase($postType), 'post_status' => 'publish', 'posts_per_page' => -1, 'meta_query' => [ [ 'key' => BASE.'_keep_synced_' . $this->service_name, 'value' => '1', 'compare' => '=' ] ] ]; $menu_items = get_posts($args); if (empty($menu_items)) { return [ 'success' => true, 'result' => 'No menu items to sync' ]; } $gmb = new self($data['userID']??null); // Build the complete menu structure $menu_data = $gmb->collectMenu($menu_items); // Send to Google My Business API $result = $this->updateFoodMenus($menu_data); return [ 'success' => !is_null($result), 'result' => (is_array($result)) ? array_merge($result, ['menu'=>$menu_data]) : ['menu'=>$menu_data], ]; } catch (\Exception $e) { error_log('GMB Menu Update Error: ' . $e->getMessage()); return [ 'success' => false, 'result' => $e->getMessage() ]; } } /*************************************************************** API ***************************************************************/ private function getSelectedLocationResourceName(): ?string { if (empty($this->credentials['location'])) { return null; } // If already a full resource name, return as-is if (str_starts_with($this->credentials['location'], 'locations/')) { return $this->credentials['location']; } // Otherwise, it's a storeCode - need to find the full resource name try { $accounts = $this->getAccounts(); foreach ($accounts as $account) { $locations = $this->getLocations($account['name']); foreach ($locations as $location) { if ($location['storeCode'] === $this->credentials['location']) { // Auto-migrate: update stored location to use full resource name $this->setSelectedLocation($location['name']); return $location['name']; // This is the full resource name } } } } catch (\Exception $e) { error_log('[GMB] Error finding location resource name: ' . $e->getMessage()); } return null; } /** * Update business information */ /** * @param array $updates * @return array As expected by OperationQueue.php processOperation */ public function updateBusinessInfo(array $updates): array { /** SEE ALSO: https://developers.google.com/my-business/content/attributes MENU EXAMPLE You can add structured menu data, like a food menu, to a location with the use of the PriceList object. The following JSON request shows how to publish a breakfast menu to a location. The response contains an instance of the updated Location object. HTTP PATCH https://mybusiness.googleapis.com/v4/accounts/{accountId}/locations/{locationId}?updateMask=priceLists { "priceLists": [ { "priceListId": "Breakfast", "labels": [ { "displayName": "Breakfast", "description": "Tasty Google Breakfast", "languageCode": "en" } ], "sourceUrl": "http://www.google.com/todays_menu", "sections": [ { "sectionId": "entree_menu", "sectionType":"FOOD", "labels": [ { "displayName": "Entrées", "description": "Breakfast Entrées", "languageCode": "en" } ], "items": [ { "itemId": "scramble", "labels": [ { "displayName": "Big Scramble", "description": "A delicious scramble filled with Potatoes, Eggs, Bell Peppers, and Sausage", "languageCode": "en" } ], "price": { "currencyCode": "USD", "units": "12", "nanos": "200000000" } }, { "itemId": "steak_omelette", "labels": [ { "displayName": "Steak Omelette", "description": "Three egg omelette with grilled prime rib, fire-roasted bell peppers and onions, saut\u00e9ed mushrooms and melted Swiss cheese", "languageCode": "en" } ], "price": { "currencyCode": "USD", "units": "15", "nanos": "750000000" } } ] } ] } ] } SERVICE DATA If your business offers different service options, you can add structured services data to a location with the use of the PriceList object. The following JSON request shows how to publish a service offering to a location. The response contains an instance of the updated Location object. HTTP PATCH https://mybusiness.googleapis.com/v4/accounts/{accountId}/locations/{locationId}?updateMask=priceLists { "priceLists": [ { "priceListId": "Oil Change", "labels": [ { "displayName": "Oil Change", "description": "Caseys Qwik Oil Change", "languageCode": "en" } ], "sourceUrl": "http://www.google.com/todays_services", "sections": [ { "sectionId": "oil_services", "sectionType":”SERVICES”, "labels": [ { "displayName": "Services", "description": "Oil Changes", "languageCode": "en" } ], "items": [ { "itemId": "20-minute-oil-change", "labels": [ { "displayName": "20 Minute Oil Change", "description": "Quick oil change and filter service.", "languageCode": "en" } ], "price": { "currencyCode": "USD", "units": "30", "nanos": "200000000" } }, { "itemId": "full_service_oil_change", "labels": [ { "displayName": "Full Service Oil Change", "description": "Quick oil change, filter service, and brake inspection.", "languageCode": "en" } ], "price": { "currencyCode": "USD", "units": "45", "nanos": "750000000" } } ] } ] } ] } **/ try { $allowed_fields = [ 'title', // Business name 'phoneNumbers', // Primary phone 'websiteUri', // Website URL 'categories', // Business categories 'storefrontAddress', // Physical address 'serviceArea', // Service area for service businesses 'profile', // Business description 'openInfo', // Opening date info 'regularHours', // Business hours 'specialHours', // Special hours ]; // Filter to only allowed fields $patch_data = array_intersect_key($updates,$allowed_fields); $location_name = $this->getSelectedLocationResourceName(); if (empty($patch_data)) { throw new \Exception('No valid fields to update'); } // Create updateMask from the fields being updated $update_mask = implode(',', array_keys($patch_data)); $response = $this->makeRequest( 'PATCH', "/v1/{$location_name}?updateMask={$update_mask}", $patch_data, 'base' ); return [ 'success' => !empty($response), 'result' => $response ]; } catch (\Exception $e) { $this->logError($e->getMessage(), [ 'method' => 'updateBusinessInfo' ]); return [ 'success' => false, 'result' => $e->getMessage(), ]; } } /** * Update business attributes (amenities, services, etc.) */ /** * @param string $location_name * @param array $attributes * @return array As expected by OperationQueue.php processOperation */ public function updateAttributes(array $attributes): array { try { // Attributes like: wheelchair_accessible, wifi, parking, etc. //TODO: sanitize data $attributes_data = [ 'attributes' => $attributes ]; $location_name = $this->getSelectedLocationResourceName(); $response = $this->makeRequest( 'PATCH', "/v1/accounts/{$location_name}/attributes", $attributes_data, 'base' ); return [ 'success' => !empty($response), 'result' => $response ]; } catch (\Exception $e) { $this->logError($e->getMessage(), [ 'method' => 'updateAttributes' ]); return [ 'success' => false, 'result' => $e->getMessage() ]; } } /***************** * POSTS ****************/ /** * Create a new GMB post * * @param string $location_name The location resource name * @param array $post_data Post content including summary, callToAction, media, etc. * @return array|null Created post data or null on failure */ /** FROM THE DOCS: EVENT POST Notify your customers about the next event at your business with a Post. Your Post for an event includes start and end dates and times, which display prominently on the Post. To make a Post to an account associated with a user, use the accounts.locations.localPosts API. To create a Post for an authenticated user, use the following: $ POST https://mybusiness.googleapis.com/v4/accounts/{accountId}/locations/{locationId}/localPosts { "languageCode": "en-US", "summary": "Come in for our spooky Halloween event!", "event": { "title": "Halloween Spook-tacular!", "schedule": { "startDate": { "year": 2017, "month": 10, "day": 31, }, "startTime": { "hours": 9, "minutes": 0, "seconds": 0, "nanos": 0, }, "endDate": { "year": 2017, "month": 10, "day": 31, }, "endTime": { "hours": 17, "minutes": 0, "seconds": 0, "nanos": 0, }, } }, "media": [ { "mediaFormat": "PHOTO", "sourceUrl": "https://www.google.com/real-image.jpg", } ], "topicType": "EVENT" } CALL TO ACTION POST Posts with a call to action include a button. The text on the call to action button is determined by the actionType field of the Post. A link to a user-provided URL is added to the button. To create a Post with a call to action button, use the following: $ POST https://mybusiness.googleapis.com/v4/accounts/{accountId}/locations/{locationId}/localPosts { "languageCode": "en-US", "summary": "Order your Thanksgiving turkeys now!!", "callToAction": { "actionType": "ORDER", "url": "http://google.com/order_turkeys_here", }, "media": [ { "mediaFormat": "PHOTO", "sourceUrl": "https://www.google.com/real-turkey-photo.jpg", } ], "topicType": "OFFER" } Action types The call to action Posts can have different action types that determine the type of call to action Post. The following are the supported call to action types: Action types BOOK Creates a Post that prompts a user to book an appointment, table, or something similar. ORDER Creates a Post that prompts a user to order something. SHOP Creates a Post that prompts a user to browse a product catalog. LEARN_MORE Creates a Post that prompts a user to see additional details on a website. SIGN_UP Creates a Post that prompts a user to register, sign up, or join something. CALL Creates a Post that prompts a user to call a business. OFFER POST To create an Offer Post, use the following: HTTP $ POST https://mybusiness.googleapis.com/v4/accounts/{accountId}/locations/{locationId}/localPosts { "languageCode": "en-US", "summary": "Buy one Google jetpack, get a second one free!!", "offer": { "couponCode": “BOGO-JET-CODE”, "redeemOnlineUrl": “https://www.google.com/redeem”, "termsConditions": “Offer only valid if you can prove you are a time traveler” }, "media": [ { "mediaFormat": "PHOTO", "sourceUrl": "https://www.google.com/real-jetpack-photo.jpg", } ], "topicType": "OFFER" } EDIT POST Once a post is created, you can edit it with a PATCH request. To edit a Post, use the following: HTTP $ PATCH https://mybusiness.googleapis.com/v4/accounts/{accountId}/locations/{locationId}/localPosts/{localPostId}?updateMask=summary { "summary": "Order your Christmas turkeys now!!" } DELETE POSTS After a Post is created, you can delete it with a DELETE request. To delete a Post, use the following: HTTP $ DELETE https://mybusiness.googleapis.com/v4/accounts/{accountId}/locations/{locationId}/localPosts/{localPostId} **/ public function createPost(array $post_data, ?string $location_name = null): ?array { $location_name = (!$location_name) ? $this->getSelectedLocationResourceName() : $location_name; try { // Validate required fields if (empty($post_data['summary'])) { throw new \Exception('Post summary is required'); } // Build the post object according to API docs $post = [ 'languageCode' => $post_data['language_code'] ?? 'en-US', 'summary' => $post_data['summary'], 'topicType' => $post_data['topic_type'] ?? 'STANDARD' ]; // Add optional fields if (!empty($post_data['call_to_action'])) { $post['callToAction'] = [ 'actionType' => $post_data['call_to_action']['type'] ?? 'LEARN_MORE', 'url' => $post_data['call_to_action']['url'] ]; } if (!empty($post_data['media'])) { $post['media'] = $post_data['media']; } if ($post_data['topic_type'] === 'EVENT' && !empty($post_data['event'])) { $post['event'] = $post_data['event']; } if ($post_data['topic_type'] === 'OFFER' && !empty($post_data['offer'])) { $post['offer'] = $post_data['offer']; } $response = $this->makeRequest( 'POST', "/v4/{$location_name}/localPosts", $post, 'posts' ); $this->cache->invalidate("posts_{$location_name}"); return $response; } catch (\Exception $e) { $this->logError($e->getMessage(), [ 'method' => 'createPost' ]); return null; } } /** * Update an existing GMB post */ public function updatePost(string $post_name, array $post_data): bool { try { $update_fields = []; $update_mask = []; if (isset($post_data['summary'])) { $update_fields['summary'] = $post_data['summary']; $update_mask[] = 'summary'; } if (isset($post_data['call_to_action'])) { $update_fields['callToAction'] = $post_data['call_to_action']; $update_mask[] = 'callToAction'; } if (empty($update_fields)) { return true; // Nothing to update } $response = $this->makeRequest( 'PATCH', "/v4/{$post_name}?updateMask=" . implode(',', $update_mask), $update_fields, 'posts' // Use the posts API base ); return !empty($response); } catch (\Exception $e) { $this->logError($e->getMessage(), [ 'method' => 'updatePost' ]); return false; } } /** * Delete a post */ public function deletePost(string $post_name): bool { try { $this->makeRequest('DELETE', "/v1/accounts/{$post_name}", [], 'posts'); return true; } catch (\Exception $e) { $this->logError($e->getMessage(), [ 'method' => 'deletePost' ]); return false; } } /**************** * HOURS ***************/ /** * Update regular business hours * * @param array $hours Array of periods with open/close times per day * @return array Success status, according to OperationQueue.php */ public function updateBusinessHours(array $hours): array { if(!$this->isSetUp()) { return []; } $location_name = $this->credentials['location']; if (empty($location_name)) { error_log('[GMB] ERROR: No location selected or found'); throw new \Exception('No location selected. Please select a location first.'); } try { // Validate and format hours $periods = $this->validateAndFormatHours($hours); if (empty($periods)) { error_log('[GMB] ERROR: No valid periods found after validation'); throw new \Exception('No valid business hours provided'); } $update_data = [ 'regularHours' => [ 'periods' => $periods ] ]; $endpoint = "/v1/{$location_name}?updateMask=regularHours"; // Make the API request $response = $this->makeRequest( 'PATCH', $endpoint, $update_data, 'base' ); $success = $response !== null; // Additional validation - check if the response contains the updated hours if ($success && $response) { if (isset($response['regularHours'])) { // error_log('[GMB] SUCCESS: Updated hours confirmed in response: ' . print_r($response['regularHours'], true)); } else { // error_log('[GMB] WARNING: No regularHours in response, but API call succeeded'); // error_log('[GMB] Full response keys: ' . implode(', ', array_keys($response))); } } return [ 'success' => $success, 'result' => $response ]; } catch (\Exception $e) { $this->logError($e->getMessage(), [ 'method' => 'updateBusinessHours' ]); return [ 'success' => false, 'result' => $e->getMessage() ]; } } private function validateAndFormatHours(array $hours): array { $valid_days = ['MONDAY', 'TUESDAY', 'WEDNESDAY', 'THURSDAY', 'FRIDAY', 'SATURDAY', 'SUNDAY']; $periods = []; foreach ($hours as $day => $times) { $formatted_day = strtoupper($day); // Convert day names if needed $day_mapping = [ 'MON' => 'MONDAY', 'TUE' => 'TUESDAY', 'TUES' => 'TUESDAY', 'WED' => 'WEDNESDAY', 'THU' => 'THURSDAY', 'THUR' => 'THURSDAY', 'THURS' => 'THURSDAY', 'FRI' => 'FRIDAY', 'SAT' => 'SATURDAY', 'SUN' => 'SUNDAY' ]; if (isset($day_mapping[$formatted_day])) { $formatted_day = $day_mapping[$formatted_day]; } if (!in_array($formatted_day, $valid_days)) { error_log('[GMB] Invalid day: ' . $day . ' (formatted: ' . $formatted_day . ')'); continue; } // Check if day is open with flexible comparison $is_open = false; if (isset($times['open'])) { // Convert to boolean - handles string "1", integer 1, boolean true, etc. $is_open = (bool)(int)$times['open']; } if (!$is_open) { error_log('[GMB] Day ' . $formatted_day . ' is closed (open value: ' . ($times['open'] ?? 'not set') . ')'); continue; } error_log('[GMB] Day ' . $formatted_day . ' is OPEN'); $open_time = $this->formatTime($times['time_opens'] ?? ''); $close_time = $this->formatTime($times['time_closes'] ?? ''); if (empty($open_time) || empty($close_time)) { error_log('[GMB] Missing or invalid times for ' . $formatted_day); error_log('[GMB] Raw time data - time_opens: "' . ($times['time_opens'] ?? 'not set') . '", time_closes: "' . ($times['time_closes'] ?? 'not set') . '"'); continue; } $period = [ 'openDay' => $formatted_day, 'openTime' => $open_time, // Now a proper Google TimeOfDay object 'closeDay' => $formatted_day, 'closeTime' => $close_time // Now a proper Google TimeOfDay object ]; $periods[] = $period; } return $periods; } /** * Set special/temporary hours (for holidays, special events, etc.) * * @param string $date * @param array{open?: float, closed?:float, isClosed?: bool, location?: string} $options * @return array Operation Queue [ 'success' => true/false, 'result' => $results] */ public function setSpecialHours(string $date, array $options = []):array { // Get the full location resource name $location_name = $this->getSelectedLocationResourceName(); if (empty($location_name)) { throw new \Exception('No location selected. Please select a location first.'); } try { // Format the date correctly for Google API $date_obj = [ 'year' => (int)date('Y', strtotime($date)), 'month' => (int)date('n', strtotime($date)), 'day' => (int)date('j', strtotime($date)) ]; $special_hour_period = [ 'startDate' => $date_obj, 'endDate' => $date_obj, // Same day for single-day special hours ]; if (array_key_exists('isClosed', $options) && $options['isClosed']) { $special_hour_period['closed'] = true; } else { $special_hour_period['closed'] = false; // Format times as TimeOfDay objects if (!empty($options['open'])) { $open_time = $this->formatTime($options['open']); if ($open_time) { $special_hour_period['openTime'] = $open_time; } } if (!empty($options['close'])) { $close_time = $this->formatTime($options['close']); if ($close_time) { $special_hour_period['closeTime'] = $close_time; } } } // Get existing special hours and their periods $existing_special_hours = $location['specialHours'] ?? []; $existing_periods = $existing_special_hours['specialHourPeriods'] ?? []; // Add or update the special hour period $found = false; foreach ($existing_periods as &$existing_period) { if (isset($existing_period['startDate']) && $existing_period['startDate'] == $date_obj) { $existing_period = $special_hour_period; $found = true; break; } } if (!$found) { $existing_periods[] = $special_hour_period; } // Prepare the update data with correct structure $update_data = [ 'specialHours' => [ 'specialHourPeriods' => $existing_periods ] ]; // Try the PATCH request $response = $this->makeRequest( 'PATCH', "/v1/{$location_name}?updateMask=specialHours", $update_data, 'base' ); return [ 'success' => $response !== null, 'results' => $response ]; } catch (\Exception $e) { $this->logError($e->getMessage(), [ 'method' => 'setSpecialHours' ]); return [ 'success' => false, 'results' => $e->getMessage(), ]; } } /** * Clear all special hours */ public function clearSpecialHours(string $location_name): bool { try { $update_data = [ 'specialHours' => [] ]; $response = $this->makeRequest( 'PATCH', "/v1/{$location_name}?updateMask=specialHours", $update_data, 'base' ); return $response !== null; } catch (\Exception $e) { $this->logError($e->getMessage(), [ 'method' => 'clearSpecialHours' ]); return false; } } /*************************************************************** HOURS ***************************************************************/ /** FROM THE DOCS: UPLOAD FROM URL To upload photos from a URL , make the following call to Media.Create. Use the relevant category as needed. POST https://mybusiness.googleapis.com/v4/accounts/{accountId}/locations/{locationId}/media { "mediaFormat": "PHOTO", "locationAssociation": { "category": "COVER" }, "sourceUrl": “http://example.com/biz/image.jpg", } To upload videos from a URL with the Google My Business API, make the following call to Media.Create: POST https://mybusiness.googleapis.com/v4/accounts/{accountId}/locations/{locationId}/media { "mediaFormat": "VIDEO", "locationAssociation": { "category": "ADDITIONAL" }, "sourceUrl": “http://example.com/biz/video.mp4", } **/ public function uploadImageToGMB(int $imgID, string $category = 'ADDITIONAL'): ?string { if ($imgID === 0) { return null; } if (empty($this->credentials)) { $this->loadCredentials(); } try { $imgID = $this->getSupportedImage($imgID); $valid_categories = [ 'COVER', 'PROFILE', 'LOGO', 'EXTERIOR', 'INTERIOR', 'PRODUCT', 'AT_WORK', 'FOOD_AND_DRINK', 'MENU', 'COMMON_AREA', 'ROOMS', 'TEAMS', 'ADDITIONAL', 'FOOD_AND_DRINK' ]; if (!in_array($category, $valid_categories)) { throw new \Exception('Invalid photo category: ' . $category); } $mediaKey = $this->getImageMediaKey($imgID); if ($mediaKey) { return $mediaKey; } $image_url = wp_get_attachment_image_url($imgID, 'full'); $media_data = [ 'mediaFormat' => 'PHOTO', 'locationAssociation' => [ 'category' => $category ], 'sourceUrl' => $image_url ]; $response = $this->makeRequest( 'POST', "/v4/{$this->credentials['account']}/{$this->credentials['location']}/media", $media_data, 'posts' ); if (!is_wp_error($response) && array_key_exists('name', $response)) { $mediaKey = $this->extractMediaKey($response['name']); $this->setImageMediaKey($imgID, $mediaKey); return $mediaKey; } return null; } catch (\Exception $e) { $this->logError($e->getMessage(), [ 'method' => 'uploadPhotoFromUrl' ]); return null; } } public function getImageMediaKey(int $imgID):string|false { $mediaKey = get_post_meta($imgID, BASE.'gmb_mediakey', true); if ($mediaKey !== '') { return $this->testGMBImage($mediaKey); } return false; } public function testGMBImage(string $mediaKey):string|false { $existing = $this->getLocationMedia(); $exists = array_filter($existing, function ($item) use ($mediaKey) { return $this->extractMediaKey($item['name']) === $mediaKey; }); return (empty($exists)) ? false : $mediaKey; } public function setImageMediaKey(int $imgID, string $key):bool { return update_post_meta($imgID, BASE.'gmb_mediakey', $key); } /*************************************************************** CREDENTIALS ***************************************************************/ /** * Get user's GMB accounts */ public function getAccounts(bool $force = false): array { $ttl = 7 * 24 * 60 * 60; // week in seconds $response = $this->getRequest('/v1/accounts', [], 'accounts', $ttl, $force)??[]; if (isset($response['accounts']) && is_array($response['accounts'])) { return $response['accounts']; } if (is_array($response) && isset($response['name'])) { return [$response]; } return []; } /** * Get reviews for the current location * @param int $page_size Number of reviews to fetch (max 50) * @return array|null */ public function getReviews(int $page_size = 5): ?array { $this->ensureInitialized(); if (!$this->location) { throw new \Exception('No location selected'); } if (!$this->account_id) { throw new \Exception('No account configured'); } $location = $this->getSelectedLocationResourceName(); $account = $this->account_id; // Check cache first (weekly refresh = 604800 seconds) $cache_key = ['reviews', $location, $page_size]; $cached = $this->cache->get($cache_key); if ($cached !== false) { return $cached; } try { // Reviews endpoint from My Business Account Management API $response = $this->getRequest( "/{$account}/{$location}/reviews", [ 'orderBy' => 'updateTime desc' ], 'v4' ); $reviews = $response ?? []; // Cache for 1 week (604800 seconds) $this->cache->set($cache_key, $reviews, WEEK_IN_SECONDS); return $reviews; } catch (\Exception $e) { $this->logError($e->getMessage(), [ 'method' => 'getReviews' ]); return null; } } /** * Get the URL to view all Google reviews for the current location * @return string|null The reviews viewing URL or null if not available */ public function getReviewsViewUrl(): ?string { $this->ensureInitialized(); try { $location = $this->getLocation(); if (empty($location)) { return null; } // Prefer maps URL as it shows all reviews directly if (!empty($location['metadata']['mapsUrl'])) { return $location['metadata']['mapsUrl']; } // Fallback: construct from Place ID if (!empty($location['metadata']['placeId'])) { return 'https://search.google.com/local/reviews?placeid=' . urlencode($location['metadata']['placeId']); } return null; } catch (\Exception $e) { $this->logError('Failed to get reviews view URL: ' . $e->getMessage(), [ 'method' => 'getReviewsViewUrl' ]); return null; } } /** * Get the URL to leave a review for the current location * @return string|null The review URL or null if not available */ public function getReviewUrl(): ?string { $this->ensureInitialized(); try { $location = $this->getLocation(); if (empty($location)) { return null; } // Try to use Place ID for write review if (!empty($location['metadata']['placeId'])) { return 'https://search.google.com/local/writereview?placeid=' . urlencode($location['metadata']['placeId']); } // Fallback to maps URL if (!empty($location['metadata']['mapsUrl'])) { return $location['metadata']['mapsUrl'] . '/reviews'; } return null; } catch (\Exception $e) { $this->logError('Failed to get review URL: ' . $e->getMessage(), [ 'method' => 'getReviewUrl' ]); return null; } } /** * Get locations for an account (with persistent storage) * Allowed Fields: https://developers.google.com/my-business/content/location-data#list_of_all_supported_filter_fields */ public function getLocations(?string $account_name = null, bool $force = false): array { if (!$this->isSetUp()) { return []; } $account_name = $account_name ?: $this->account_id; if (empty($account_name) || !str_starts_with($account_name, 'accounts/')) { return []; } $params = ['readMask' => $this->readMask]; $ttl = 7 * 24 * 60 * 60; // Week in seconds try { $response = $this->getRequest("/v1/{$account_name}/locations", $params,'accounts', $ttl, $force); return isset($response['locations']) ? $response['locations'] : []; } catch (\Exception $e) { error_log('[GMB] Error getting locations for ' . $account_name . ': ' . $e->getMessage()); return []; } } public function handleRefreshAccessToken():WP_Error|array { if ($this->refreshOAuthToken()) { return [ 'success' => true, 'message' => 'Successfully refreshed OAuth Token' ]; } return new WP_Error('failure', 'Failed to Refresh token'); } /** * Manually refresh accounts and locations data from API */ public function refreshStoredData(): array { try { // Fetch fresh accounts data from API $accounts = $this->getAccounts(true); if (!empty($accounts)) { // Fetch and store locations for each account $locationsProcessed = 0; $accountsProcessed = count($accounts); foreach ($accounts as $account) { $locations = $this->getLocations($account['name'], true); if (!empty($locations)) { $locationsProcessed += count($locations); } } return [ 'success' => true, 'accounts' => $accountsProcessed, 'locations' => $locationsProcessed, 'message' => sprintf( 'Successfully refreshed %d accounts with %d total locations', $accountsProcessed, $locationsProcessed ) ]; } return [ 'success' => false, 'message' => 'No accounts found' ]; } catch (\Exception $e) { error_log('[GMB] Error refreshing stored data: ' . $e->getMessage()); return [ 'success' => false, 'message' => 'Error: ' . $e->getMessage() ]; } } /** * Get a specific location details */ public function getLocation(?string $location_name = null, bool $force = false):?array { if (!$this->isSetUp()) { return null; } $location_name = $location_name?:$this->credentials['location']; if (empty($location_name)) { error_log('[GMB] No location name provided for getLocation'); return null; } $params = [ 'readMask' => $this->readMask ]; $endpoint = "/v1/{$location_name}?".http_build_query($params); $location = $this->getRequest($endpoint, [], 'base', 'moderate', $force); return $location??null; } /** * Get the currently selected location */ public function getSelectedLocation(): ?string { return $this->credentials['location']; } /** * Set the selected location */ public function setSelectedLocation(string $location): bool { $this->credentials['location'] = $location; if (preg_match('/accounts\/([^\/]+)\//', $location, $matches)) { $this->account_id = 'accounts/' . $matches[1]; } $credentials = $this->credentials; $credentials['location'] = $location; $credentials['account'] = $this->account_id; return $this->saveCredentials($credentials)['success']; } /************************************************************* RENDER *************************************************************/ /** * Render business list for connected users */ private function renderBusinessList(): void { try { $accounts = $this->getAccounts(); if (empty($accounts)) { echo '

No business accounts found.

'; return; } ?>

Your Business Locations

getLocations($account['name']); foreach ($locations as $location) { ?>
isSelectedLocation($location)): ?> ✓ Selected
Error loading business locations.

'; } } 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) { ?>
⚠️ OAuth token expired - please reconnect
getAccounts(); if (!empty($accounts)) { ?>
credentials['account'])) { $locations = $this->getLocations($this->credentials['account']); if (!empty($locations)) { ?>
credentials['location'])) { ?>
✅ Connected to: credentials['location']); ?>

No Google Business accounts found. Please ensure your Google account has access to Google My Business.

credentials['location']) { throw new \Exception('No location selected. Please select a location first.'); } $response = $this->getRequest( "/v4/{$this->credentials['location']}/foodMenu", [], 'posts' ); return $response['foodMenus'] ?? []; } /** * Update food menus for the current location */ public function updateFoodMenus(array $menus): bool { if (!$this->credentials['location']) { throw new \Exception('No location selected. Please select a location first.'); } try { $food_menus = ['menus' => $menus]; $endpoint = "/{$this->credentials['account']}/{$this->credentials['location']}/foodMenus"; // Use dedicated Food Menu API $response = $this->makeRequest( 'PATCH', $endpoint, $food_menus, 'v4' ); $this->logDebug('[GMB]Finished updating food menu', [ 'method' => 'updateFoodMenus', 'response' => $response, ]); return !empty($response); } catch (\Exception $e) { $this->logError($e->getMessage(), [ 'method' => 'updateFoodMenus' ]); return false; } } /** * Upload menu images and return a map of identifiers to mediaKeys */ protected function uploadAndMapMenuImages(array $menus): array { $mediaKeyMap = []; // Process all menu items for images foreach ($menus as $menu) { foreach ($menu['sections'] ?? [] as $section) { // Upload section images if needed (though API doesn't support section images yet) if (!empty($section['image']) && is_numeric($section['image'])) { $mediaKey = $this->uploadImageToGMB($section['image'], 'FOOD_AND_DRINK'); if ($mediaKey) { $mediaKeyMap['section_' . $section['id']] = $mediaKey; } } // Process menu items foreach ($section['items'] ?? [] as $item) { // Upload featured image if (!empty($item['post_id'])) { $featured_image_id = get_post_thumbnail_id($item['post_id']); if ($featured_image_id) { $mediaKey = $this->uploadImageToGMB($featured_image_id, 'FOOD_AND_DRINK'); if ($mediaKey) { $mediaKeyMap['item_' . $item['post_id']] = $mediaKey; } } // Upload gallery images if available if (!empty($item['gallery_ids'])) { foreach ($item['gallery_ids'] as $gallery_id) { $mediaKey = $this->uploadImageToGMB($gallery_id, 'FOOD_AND_DRINK'); if ($mediaKey) { $mediaKeyMap['gallery_'.$gallery_id] = $mediaKey; } } } } } } } return $mediaKeyMap; } /** * Extract mediaKey from the full media resource name */ protected function extractMediaKey(string $mediaResourceName): string { // Extract mediaKey from format: accounts/{accountId}/locations/{locationId}/media/{mediaKey} $parts = explode('/media/', $mediaResourceName); return isset($parts[1]) ? $parts[1] : ''; } /** * Get all media items for the current location */ public function getLocationMedia(): array { if (!$this->credentials['location']) { throw new \Exception('No location selected. Please select a location first.'); } try { $response = $this->getRequest( "/{$this->credentials['account']}/{$this->credentials['location']}/media", [], 'v4' ); return $response['mediaItems'] ?? []; } catch (\Exception $e) { $this->logError('Failed to get location media: ' . $e->getMessage()); return []; } } public function handleGetAllLocations():WP_Error|array { try { // Check if we should force refresh $force = isset($_POST['force']) && $_POST['force'] === 'true'; // Get accounts (use cache unless forced) $accounts = $this->getAccounts($force); $all_locations = []; foreach ($accounts as $account) { // Get locations for each account (use cache unless forced) $locations = $this->getLocations($account['name'], $force); $all_locations = array_merge($all_locations, $locations); } return [ 'success' => true, 'locations' => $all_locations, 'cached' => !$force ]; } catch (\Exception $e) { return new WP_Error('failure', 'Something went wrong: '.$e->getMessage()); } } /** * AJAX handler for updating selected location */ public function handleUpdateLocation($data = null): array { try { // Handle both AJAX and REST API calls $selected_account = null; if (is_array($data)) { $selected_account = $data['account'] ?? $data['location'] ?? null; } elseif (isset($_POST['account'])) { $selected_account = sanitize_text_field($_POST['account']); } if (empty($selected_account)) { throw new \Exception('No account selected'); } // Save the selected account $credentials = $this->credentials; $credentials['account'] = $selected_account; // Get locations for this account $locations = $this->getLocations($selected_account); // Auto-select first location if only one exists if (count($locations) === 1) { $credentials['location'] = $locations[0]['name']; } $this->saveCredentials($credentials); return [ 'success' => true, 'message' => 'Account selected successfully', 'locations' => $locations, 'reload' => true // Trigger page refresh to show location dropdown ]; } catch (\Exception $e) { $this->logError('Failed to update location', [ 'error' => $e->getMessage(), 'data' => $data ]); return [ 'success' => false, 'message' => $e->getMessage() ]; } } public function clearStoredData(): bool { try { // Use the static method to clear the entire cache group $this->cache->flush(); return true; } catch (\Exception $e) { error_log('[GMB] Error clearing stored data: ' . $e->getMessage()); return false; } } public function getPriceLists(): ?array { if (!$this->credentials['location']) { throw new \Exception('No location selected. Please select a location first.'); } try { $location_data = $this->getLocation($this->credentials['location']); return $location_data['priceLists'] ?? []; } catch (\Exception $e) { $this->logError($e->getMessage(), [ 'method' => 'getPriceLists' ]); return null; } } /** * Get insights/analytics */ public function getInsights(string $location_name, string $start_date, string $end_date): ?array { // From the Docs: // Basic insights // Retrieves basic insights for a given list of locations. Use the accounts.locations.reportInsights API to return the insights that are associated with a location. // // To return the basic insights associated with a location, use the following: // // HTTP // // POST // https://mybusiness.googleapis.com/v4/accounts/{accountId}/locations:reportInsights // { // "locationNames": [ // "accounts/{accountId}/locations/{locationId}" // ], // "basicRequest": { // "metricRequests": [ // { // "metric": "QUERIES_DIRECT" // }, // { // "metric": "QUERIES_INDIRECT" // } // ], // "timeRange": { // "startTime": "2016-10-12T01:01:23.045123456Z", // "endTime": "2017-01-10T23:59:59.045123456Z" // } // } // } $params = [ 'dailyMetrics' => 'BUSINESS_IMPRESSIONS_DESKTOP_MAPS,BUSINESS_IMPRESSIONS_DESKTOP_SEARCH,BUSINESS_IMPRESSIONS_MOBILE_MAPS,BUSINESS_IMPRESSIONS_MOBILE_SEARCH,BUSINESS_DIRECTION_REQUESTS,CALL_CLICKS,WEBSITE_CLICKS', 'dailyRange.startDate.year' => date('Y', strtotime($start_date)), 'dailyRange.startDate.month' => date('n', strtotime($start_date)), 'dailyRange.startDate.day' => date('j', strtotime($start_date)), 'dailyRange.endDate.year' => date('Y', strtotime($end_date)), 'dailyRange.endDate.month' => date('n', strtotime($end_date)), 'dailyRange.endDate.day' => date('j', strtotime($end_date)) ]; return $this->getRequest( "/v1/{$location_name}:fetchMultiDailyMetricsTimeSeries?" . http_build_query($params), $params, 'performance' ); } /** * Helper: Format hours for GMB API */ private function formatHoursPeriods(array $hours): array { $periods = []; $days = ['SUNDAY', 'MONDAY', 'TUESDAY', 'WEDNESDAY', 'THURSDAY', 'FRIDAY', 'SATURDAY']; foreach ($hours as $day => $times) { if (empty($times['open']) || empty($times['close'])) { continue; } $periods[] = [ 'openDay' => $days[$day] ?? $day, 'openTime' => $times['open'], // Format: "09:00" 'closeDay' => $days[$day] ?? $day, 'closeTime' => $times['close'] // Format: "17:00" ]; } return $periods; } /** * HELPER: Easy interface method for creating a simple post * JVB()->connect('gmb')->createSimplePost("Check out our new menu!", "https://example.com/menu") */ public function createSimplePost(string $message, ?string $cta_url = null, string $cta_type = 'LEARN_MORE'): ?array { $location_name = $this->getSelectedLocationResourceName(); if (empty($location_name)) { throw new \Exception('No location selected. Please select a location first.'); } $post_data = [ 'summary' => $message, 'topic_type' => 'STANDARD' ]; if ($cta_url) { $post_data['call_to_action'] = [ 'type' => $cta_type, 'url' => $cta_url ]; } return $this->createPost($location_name, $post_data); } /** * HELPER: Easy interface method for updating basic business info * JVB()->connect('gmb')->updateInfo(['title' => 'New Business Name', 'websiteUri' => 'https://newsite.com']) */ public function updateInfo(array $updates): bool { $location_name = $this->getSelectedLocationResourceName(); if (empty($location_name)) { throw new \Exception('No location selected. Please select a location first.'); } return $this->updateBusinessInfo($location_name, $updates); } /** * HELPER: Easy interface for clearing special hours * JVB()->connect('gmb')->clearAllSpecialHours() */ public function clearAllSpecialHours(): bool { $location_name = $this->getSelectedLocationResourceName(); if (empty($location_name)) { throw new \Exception('No location selected. Please select a location first.'); } return $this->clearSpecialHours($location_name); } protected function mappedMenuFields(string $postType):array { /** * @param array $fields * @param string $fields.name Thing */ return apply_filters( 'jvbGMBFields', [ 'name' => 'post_title', 'price' => 'price', 'description' => 'post_excerpt', 'nutritionFacts'=> 'nutritionFacts', 'servesNumPeople'=>'servesNumPeople', 'portionSize' => 'portionSize', 'preparationMethods' => 'preparationMethods', 'cuisines' => 'cuisines', 'spiciness' => 'spiciness', 'allergen' => 'allergen', 'dietaryRestriction'=>'dietaryRestriction' ], jvbNoBase($postType)); } /** * Build menu structure from WordPress posts */ protected function collectMenu(array $menu_items): array { $defaultMeta = 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']); } } $sections_map[$section_slug]['items'][] = $gmb_item; } $gmb_sections = $this->getMenuSectionsOrder($sections_map); // Build complete menu $gmb_menu = [ 'cuisines' => $this->getMenuCuisines($menu_items), 'labels' => [ 'displayName' => $defaults['menu_name'] ?? 'Our Menu', 'languageCode' => $defaults['language'] ?? 'en', ], 'sections' => $gmb_sections ]; if (array_key_exists('menu_description', $defaults)) { $gmb_menu['labels']['description'] = $this->stripHtmlTags($defaults['menu_description']); } // Add source URL if configured if (!empty($defaults['source_url'])) { $gmb_menu['source_url'] = $defaults['source_url']; } return [$gmb_menu]; // GMB expects an array of menus } protected function buildMenuItem(\WP_Post $item, array $defaults):array { $meta = 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' => [ 'nutritionFacts' => [ 'type' => 'repeater', 'label' => 'Nutrition', 'fields' => [ 'name' => [ 'type' => 'select', 'label' => 'Name', 'options' => [ 'calories' => 'Calories', 'totalFat' => 'Total Fat', 'cholesterol' => 'Cholesterol', 'sodium' => 'Sodium', 'totalCarbohydrate' => 'Total Carbohydrates', 'protein' => 'Protein' ] ], 'amount' => [ 'type' => 'number', 'label' => 'Amount', ], 'unit' => [ 'type' => 'select', 'label' => 'Unit', 'options' => [ 'MILLIGRAM' => 'Milligram', 'GRAM' => 'Gram', 'CALORIE' => 'Calorie' ] ] ], 'description' => 'Recommended. Example: Total Fat: 3g', 'section' => 'gmb', ], 'servesNumPeople' => [ 'type' => 'number', 'label' => 'Number of People this Serves', 'bulkEdit' => true, 'section' => 'gmb', ], 'portionSize' => [ 'type' => 'text', 'label' => 'Portion size', 'description' => 'Example: 8-piece of nuggets', 'bulkEdit' => true, 'section' => 'gmb', ], 'preparationMethods' => [ 'type' => 'textarea', 'label' => 'Preparation Methods', 'description' => 'Specific methods that the food can be prepared in', 'bulkEdit' => true, 'section' => 'gmb', ], 'cuisines' => [ 'type' => 'text', 'label' => 'Cuisines', 'description' => 'Recommended. The specific cuisine of the food item', 'bulkEdit' => true, 'section' => 'gmb', ], 'spiciness' => [ 'type' => 'select', 'label' => 'Spiciness', 'options' => [ 'none' => 'None', 'mild' => 'Mild', 'medium' => 'Medium', 'hot' => 'Hot', ], 'bulkEdit' => true, 'section' => 'gmb', ], 'allergen' => [ 'type' => 'checkbox', 'label' => 'Allergens', 'options' => [ 'dairy' => 'Dairy', 'egg' => 'Egg', 'fish' => 'Fish', 'peanut' => 'Peanut', 'shellfish' => 'Shellfish', 'soy' => 'Soy', 'tree nut' => 'Tree Nut', 'wheat' => 'Wheat' ], 'description' => 'Recommended', 'bulkEdit' => true, 'section' => 'gmb', ], 'dietaryRestriction' => [ 'type' => 'checkbox', 'label' => 'Dietary Restrictions', 'description' => 'Recommended.', 'options' => [ 'halal' => 'Halal', 'kosher' => 'Kosher', 'organic' => 'Organic', 'vegan' => 'Vegan', 'vegetarian' => 'Vegetarian', 'gluten-free' => 'Gluten Free' ], 'bulkEdit' => true, 'section' => 'gmb', ] ], 'post' => [ 'summary' => [ 'type' => 'textarea', 'label' => 'Post Summary', 'required' => true ], 'language_code' => [ 'type' => 'text', 'label' => 'Language', 'default' => 'en-CA' ], 'media' => [ 'type' => 'image', 'label' => 'Photo', ], 'call_to_action' => [ 'type' => 'group', 'fields' => [ 'type' => [ 'type' => 'select', 'options' => [ 'BOOK' => 'Book Now', 'ORDER' => 'Order Now', 'SHOP' => 'Shop Now', 'LEARN_MORE' => 'Learn More', 'SIGN_UP' => 'Sign up', 'CALL' => 'Call' ] ], 'url' => [ 'type' => 'url', 'label' => 'Action URL' ] ] ] ], 'event' => [ 'summary' => [ 'type' => 'textarea', 'label' => 'Event Description', 'required' => true ], 'event' => [ 'title' => [ 'type' => 'text', 'label' => 'Event Title', 'required' => true ], 'start_date' => [ 'type' => 'date', 'label' => 'Start Date', 'required' => true ], 'end_date' => [ 'type' => 'date', 'label' => 'End Date' ], 'start_time' => [ 'type' => 'time', 'label' => 'Start Time' ], 'end_time' => [ 'type' => 'time', 'label' => 'End Time' ] ], 'media' => [ 'type' => 'image', 'label' => 'Event Photo' ] ], 'offer' => [ 'summary' => [ 'type' => 'textarea', 'label' => 'Offer Description', 'required' => true ], 'offer' => [ 'coupon_code' => [ 'type' => 'text', 'label' => 'Coupon Code' ], 'redeem_online_url' => [ 'type' => 'url', 'label' => 'Redemption URL' ], 'terms_conditions' => [ 'type' => 'textarea', 'label' => 'Terms & Conditions'] ], 'media' => ['type' => 'image', 'label' => 'Offer Photo'] ] ]; } }