From a8ff85042ffbfe94b678fd818c02deb613bfd116 Mon Sep 17 00:00:00 2001
From: Jake Vanderwerf <get@jakevanderwerf.ca>
Date: Fri, 07 Aug 2026 20:42:54 +0000
Subject: [PATCH] =Continuing on with Integrations setup. Working on PostMark now
---
inc/integrations/Field.php | 31
inc/integrations/Requests.php | 11
inc/integrations/services/Square.php | 33
inc/integrations/Integrations.php | 15
inc/integrations/services/GoogleMyBusiness.php | 3067 +-------------------
inc/integrations/services/Cloudflare.php | 186
inc/integrations/services/Facebook.php | 817 +----
inc/integrations/services/PostMark.php | 914 ++---
inc/integrations/AdminActions.php | 1
inc/integrations/_Base.php | 28
inc/integrations/services/GoogleMaps.php | 986 +----
inc/integrations/UserConnection.php | 1
inc/integrations/services/Umami.php | 793 -----
inc/integrations/_setup.php | 18
inc/integrations/services/Helcim.php | 1332 --------
inc/integrations/services/Instagram.php | 712 ----
inc/integrations/Admin.php | 8
inc/integrations/OAuth.php | 41
18 files changed, 1,209 insertions(+), 7,785 deletions(-)
diff --git a/inc/integrations/Admin.php b/inc/integrations/Admin.php
index 3f321a5..11273b8 100644
--- a/inc/integrations/Admin.php
+++ b/inc/integrations/Admin.php
@@ -26,7 +26,7 @@
[
'name' => 'Clear Credentials',
'icon' => 'broom',
- 'action' => [$this, 'clearCredentials']
+ 'action' => [$this, 'deleteCredentials']
],
];
foreach ($actions as $a) {
@@ -148,7 +148,7 @@
$form .= $this->renderOAuthConnectionForm();
}
- if (!empty($this->optionalFields) || $this->hasExtraOptions) {
+ if (!empty($this->optionalFields) || $this->hasExtraOptions()) {
$form .= '<div class="integration-content">';
if (!empty($this->optionalFields)) {
$form .= sprintf(
@@ -157,7 +157,7 @@
$this->outputFields($this->optionalFields)
);
}
- if ($this->hasExtraOptions) {
+ if ($this->hasExtraOptions()) {
$form .= sprintf(
'<a href="%s" class="btn">More Settings</a>',
$this->determineOptionPage()
@@ -227,4 +227,6 @@
return true;
}
+
+
}
diff --git a/inc/integrations/AdminActions.php b/inc/integrations/AdminActions.php
index 93e4e7f..edd52e6 100644
--- a/inc/integrations/AdminActions.php
+++ b/inc/integrations/AdminActions.php
@@ -21,6 +21,7 @@
{
$this->name = $name;
$this->setSlug();
+ $this->setCallback($callback);
}
public function setSlug(?string $slug = null):void
diff --git a/inc/integrations/Field.php b/inc/integrations/Field.php
index 788400b..61cfc55 100644
--- a/inc/integrations/Field.php
+++ b/inc/integrations/Field.php
@@ -17,14 +17,17 @@
protected array $options;
protected mixed $default;
protected string $subtype;
+ protected string $placeholder;
protected bool $hidden = false;
+ protected int $min;
+ protected int $max;
public function __construct(string $name, string $label, string $type, callable|string|bool $permission)
{
$this->name = $name;
$this->label = $label;
$this->type = $type;
- $this->permission = $permission;
+ $this->permission = is_callable($permission) ? Closure::fromCallable($permission) : $permission;
}
public function checkPermission():bool
@@ -69,7 +72,21 @@
public function setSubType(string $type):void
{
- $this->subType = $type;
+ $this->subtype = $type;
+ }
+
+ public function setMin(int $min):void
+ {
+ $this->min = $min;
+ }
+ public function setMax(int $max):void
+ {
+ $this->max = $max;
+ }
+
+ public function setPlaceholder(string $string):void
+ {
+ $this->placeholder = sanitize_text_field($string);
}
public function getConfig():array
@@ -97,6 +114,16 @@
if ($this->hidden) {
$conf['hidden'] = true;
}
+ if (isset($this->min)) {
+ $conf['min'] = $this->min;
+ }
+ if (isset($this->max)) {
+ $conf['max'] = $this->max;
+ }
+ if (isset($this->placeholder)) {
+ $conf['placeholder'] = $this->placeholder;
+ }
return $conf;
}
+
}
diff --git a/inc/integrations/Integrations.php b/inc/integrations/Integrations.php
index f055894..262f798 100644
--- a/inc/integrations/Integrations.php
+++ b/inc/integrations/Integrations.php
@@ -59,6 +59,7 @@
$this->userID = $userID;
$this->cache = Cache::for('integrations_' . $this->cacheName);
+
$this->getPostTypes();
$this->getTaxonomies();
$this->getUsers();
@@ -262,7 +263,7 @@
}
try {
- if ($this->isOAuthService && !$this->hasOAuthCredentials()){
+ if (!$this->hasOAuthCredentials()){
//If this is an OAuth service, we might only be saving the app credentials first
$result = true;
} else {
@@ -400,7 +401,10 @@
if (!empty($this->syncPostTypes)) {
- $this->addSavePost();
+ if (method_exists($this, 'addSavePost')) {
+ $this->addSavePost();
+ }
+
add_action('transition_post_status', [$this, 'handlePostStatusTransition'], 10, 3);
if ($this->canSync['delete']) {
@@ -489,10 +493,7 @@
public function hasOAuthCredentials(?int $userID = null): bool
{
- if ($userID !== $this->userID) {
- $this->switchUser($userID);
- }
- return $this->hasOAuth && $this->hasValidOAuth();
+ return false;
}
@@ -546,7 +547,7 @@
public static function hasExtraOptions():bool
{
- return static::getInstance()::$hasExtraOptions;
+ return self::$hasExtraOptions;
}
/**
diff --git a/inc/integrations/OAuth.php b/inc/integrations/OAuth.php
index 3bc410c..dcf89aa 100644
--- a/inc/integrations/OAuth.php
+++ b/inc/integrations/OAuth.php
@@ -11,7 +11,6 @@
trait OAuth
{
use _Base, Admin;
- protected bool $hasOAuth = true;
protected OAuthURLs $oauth;
/**
* Child classes must set this (e.g. 'https://api.example.com/v1/')
@@ -21,7 +20,17 @@
protected int $oauthTTL = 300;
protected array $oathScopes = [];
+ protected ?RateLimits $oauthLimiter;
+ protected function initializeOAuthRateLimiter():void
+ {
+ $key = $this->service_name;
+ if (!is_null($this->userID)) {
+ $key .= '_'.$this->userID;
+ }
+ $key .= '_oauth';
+ $this->oauthLimiter = new RateLimits($key, ['s' => 1, 'm' => 10, 'h' => 100]);
+ }
/**
* @return array Defaults to the base getRequestHeaders, but can optionally be overridden
*/
@@ -67,11 +76,15 @@
$this->addAction($a['name'], $a['action'], $a['slug']??null, $a['icon']??null, $a['label']??null);
}
}
+ public function hasOAuthCredentials(?int $userID = null):bool
+ {
+ if ($userID !== $this->userID) {
+ $this->switchUser($userID);
+ }
+ return $this->hasValidOAuth();
+ }
public function handleOAuthConnect():array
{
- if (!$this->hasOAuth) {
- return $this->response(false, 'Not an OAuth service');
- }
$url = $this->getOAuthUrl();
if ($url) {
return [
@@ -92,9 +105,6 @@
public function revokeOAuth():bool
{
- if (!$this->hasOAuth) {
- return false;
- }
if (empty($this->oauth['revoke'])) {
error_log('No revoke url set for '.$this->service_name);
return false;
@@ -362,9 +372,7 @@
public function getOAuthURL(?string $return_url = null):string
{
- if (!$this->hasOAuth) {
- return '';
- }
+
$credentials = $this->loadCredentials();
if (empty($this->oauth['authorize'])) {
$this->logError('getOAuthUrl', 'OAuth authorize URL not configured');
@@ -409,9 +417,14 @@
protected function getRedirectUri():string
{
- if ($this->hasOAuth) {
- return admin_url('admin-ajax.php?action='.BASE . $this->service_name . '_oauth_callback');
- }
- return rest_url('jvb/v1/oauth/callback?service=' . $this->service_name);
+ return admin_url('admin-ajax.php?action='.BASE . $this->service_name . '_oauth_callback');
+ }
+
+ /****************************************************
+ * UTILITY
+ ****************************************************/
+ protected function getAccessToken():string
+ {
+ return $this->loadCredentials()['access_token']??'';
}
}
diff --git a/inc/integrations/Requests.php b/inc/integrations/Requests.php
index ad9ebfc..633c6af 100644
--- a/inc/integrations/Requests.php
+++ b/inc/integrations/Requests.php
@@ -7,12 +7,12 @@
trait Requests
{
- protected bool $hasRequests = true;
/**
* Child classes must set this (e.g. 'https://api.example.com/v1/')
*/
protected ?string $baseUrl = null;
protected int $ttl = 300; //5 minutes default
+ protected ?RateLimits $requestLimiter = null;
/**
* Child classes define their auth headers (Bearer token, API key, etc.)
@@ -25,6 +25,15 @@
*/
abstract protected function handleResponse(array|\WP_Error $response, string $method, string $endpoint): array;
+ protected function initializeRequestLimiter():void
+ {
+ $key = $this->service_name;
+ if (!is_null($this->userID)) {
+ $key .= '_'.$this->userID;
+ }
+ $this->requestLimiter = new RateLimits($key);
+ }
+
public function getRequest(string $endpoint, array $data = [], bool $force = false, ?int $ttl = null): array
{
$key = [$this->service_name, $this->userID, $endpoint, $data];
diff --git a/inc/integrations/UserConnection.php b/inc/integrations/UserConnection.php
index 5202e6e..b6ae00e 100644
--- a/inc/integrations/UserConnection.php
+++ b/inc/integrations/UserConnection.php
@@ -37,7 +37,6 @@
protected function clearUser():void
{
$this->userID = false;
- $this->credentials = [];
if (property_exists($this, 'rateLimiter')) {
$this->requestLimiter?->saveHistory();
$this->requestLimiter = null;
diff --git a/inc/integrations/_Base.php b/inc/integrations/_Base.php
index 1697e69..0f96c90 100644
--- a/inc/integrations/_Base.php
+++ b/inc/integrations/_Base.php
@@ -31,14 +31,7 @@
protected string $apiVersion;
protected Cache $cache;
protected ?string $cacheName = null;
- //Use Trait "Requests" to set this
- protected bool $hasRequests = false;
- protected ?RateLimits $requestLimiter = null;
- //Use Trait "OAuth" to set this
- protected ?RateLimits $oauthLimiter = null;
- protected bool $hasOAuth = false;
- //Use trait "Webhooks" to set this true
- protected bool $hasWebhooks = false;
+
/**
* @var array Each item in the array becomes a li element in the ordered list of instructions
@@ -61,7 +54,7 @@
* @var bool Flag to indicate there are extra options that warrant a dedicated options page for this service
* Usually used for default field values for syncable services
*/
- public bool $hasExtraOptions = false;
+ public static bool $hasExtraOptions = false;
public bool $hasBatchCreate = false;
public bool $hasBatchUpdate = false;
public bool $hasBatchDelete = false;
@@ -93,6 +86,8 @@
*/
protected array $contentTypes = [];
+ protected array $credentials;
+
/**
* Check if integration is properly configured
@@ -104,7 +99,10 @@
public function loadCredentials():array
{
- return Auth::getInstance()->getCredentials($this->service_name, $this->userID);
+ if (!isset($this->credentials)) {
+ $this->credentials = Auth::getInstance()->getCredentials($this->service_name, $this->userID);
+ }
+ return $this->credentials;
}
protected function logError(string $method, string $message, array $context = []):void
@@ -143,13 +141,11 @@
if (!is_null($this->userID)) {
$key .= '_'.$this->userID;
}
-
- if ($this->hasRequests && !isset($this->requestLimiter) && !is_null($this->oauthLimiter)) {
- $this->requestLimiter = new RateLimits($key);
+ if (method_exists($this, 'initializeRequestLimiter')) {
+ $this->initializeRequestLimiter();
}
- if ($this->hasOAuth && !isset($this->oauthLimiter) && !is_null($this->oauthLimiter)) {
- $key .= '_oauth';
- $this->oauthLimiter = new RateLimits($key, ['s' => 1,'m' => 10, 'h' => 100]);
+ if (method_exists($this, 'intializeOAuthRateLimiter')) {
+ $this->initializeOAuthRateLimiter();
}
}
diff --git a/inc/integrations/_setup.php b/inc/integrations/_setup.php
index 7b0179c..733900f 100644
--- a/inc/integrations/_setup.php
+++ b/inc/integrations/_setup.php
@@ -5,18 +5,28 @@
require(JVB_DIR . '/inc/integrations/AdminActionResult.php');
require(JVB_DIR . '/inc/integrations/AdminActions.php');
require(JVB_DIR . '/inc/integrations/Ajax.php');
+require(JVB_DIR . '/inc/integrations/APIEndpoint.php');
require(JVB_DIR . '/inc/integrations/Auth.php');
+require(JVB_DIR . '/inc/integrations/Endpoint.php');
+require(JVB_DIR . '/inc/integrations/EndpointField.php');
require(JVB_DIR . '/inc/integrations/Field.php');
+require(JVB_DIR . '/inc/integrations/Instance.php');
require(JVB_DIR . '/inc/integrations/OAuth.php');
require(JVB_DIR . '/inc/integrations/OAuthCredentials.php');
require(JVB_DIR . '/inc/integrations/OAuthURLs.php');
require(JVB_DIR . '/inc/integrations/RateLimits.php');
require(JVB_DIR . '/inc/integrations/Requests.php');
-require(JVB_DIR . '/inc/integrations/SyncDefaults.php');
- require(JVB_DIR . '/inc/integrations/SyncFrom.php');
- require(JVB_DIR . '/inc/integrations/SyncHelpers.php');
- require(JVB_DIR . '/inc/integrations/SyncTo.php');
require(JVB_DIR . '/inc/integrations/UserConnection.php');
+require(JVB_DIR . '/inc/integrations/SyncDefaults.php');
+ require(JVB_DIR . '/inc/integrations/SyncHelpers.php');
+ require(JVB_DIR . '/inc/integrations/SyncFrom.php');
+ require(JVB_DIR . '/inc/integrations/SyncFromPost.php');
+ require(JVB_DIR . '/inc/integrations/SyncFromTerm.php');
+ require(JVB_DIR . '/inc/integrations/SyncFromUser.php');
+ require(JVB_DIR . '/inc/integrations/SyncTo.php');
+ require(JVB_DIR . '/inc/integrations/SyncToPost.php');
+ require(JVB_DIR . '/inc/integrations/SyncToTerm.php');
+ require(JVB_DIR . '/inc/integrations/SyncToUser.php');
require(JVB_DIR . '/inc/integrations/Webhooks.php');
require(JVB_DIR . '/inc/integrations/Integrations.php');
diff --git a/inc/integrations/services/Cloudflare.php b/inc/integrations/services/Cloudflare.php
index 1425725..168590b 100644
--- a/inc/integrations/services/Cloudflare.php
+++ b/inc/integrations/services/Cloudflare.php
@@ -1,95 +1,72 @@
<?php
namespace JVBase\integrations\services;
+use JVBase\integrations\Admin;
+use JVBase\integrations\Instance;
use JVBase\integrations\Integrations;
+
if (!defined('ABSPATH')) {
exit;
}
class Cloudflare extends Integrations
{
- private string $site_key;
- private string $secret_key;
+ use Admin, Instance;
private string $theme = 'light';
private string $size = 'normal';
- 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()
{
$this->service_name = 'cloudflare';
- $this->title = 'Cloudflare Turnstile';
+ $this->title = 'Cloudflare';
$this->icon = 'cloud';
- $this->apiBase = 'https://challenges.cloudflare.com';
- $this->apiEndpoints = ['turnstile/v0/siteverify'];
- $this->fields = [
- 'site_key' => [
- 'label' => 'Site Key',
- 'type' => 'text',
- 'placeholder' => 'Enter Public key for Turnstile widget display.',
- 'hint' => 'Public key for Turnstile widget',
- 'required' => true
- ],
- 'secret_key' => [
- 'label' => 'Secret Key',
- 'type' => 'text',
- 'subtype' => 'password',
- 'placeholder' => 'Enter Cloudflare Turnstile Secret Key',
- 'required' => true,
- 'hint' => 'Secret key for server-side verification'
- ]
- ];
+ $this->defineFields();
+ $this->defineInstructions();
+ parent::__construct();
+ }
+ protected function defineFields():void
+ {
+ $site_key = $this->addField('site_key', 'Site Key', 'text', 'manage_options');
+ $site_key->setRequired();
+ $site_key->setHint('Public key for Turnstile Widget');
+ $secret_key = $this->addField('secret_key', 'Secret Key', 'text', 'manage_options');
+ $secret_key->setRequired();
+ $secret_key->setSubType('password');
+ $secret_key->setHint('Secret key for server-side verification of Turnstile Widget');
+ }
+
+ protected function defineInstructions():void
+ {
$this->instructions = [
'Go to <a href="https://dash.cloudflare.com/?to=/:account/turnstile" target="_blank">Cloudflare Dashboard</a>',
'Create a new Turnstile widget',
'Add your domain to the widget settings',
'Copy the Site Key and Secret Key'
];
-
- $this->advanced = [];
- parent::__construct();
}
-
protected function initialize(): void
{
- $this->site_key = $this->credentials['site_key'] ?? '';
- $this->secret_key = $this->credentials['secret_key'] ?? '';
- $this->theme = $this->credentials['theme'] ?? 'light';
- $this->size = $this->credentials['size'] ?? 'normal';
-
+ // TODO: Implement initialize() method.
}
- /**
- * Check if scripts should be enqueued
- */
- public function shouldEnqueueScripts(): bool
+ public function getServiceDescription():string
{
- return $this->isSetUp() && !is_admin();
+ return "Add Turnstile captcha protection to your forms and login pages to prevent spam and bot submissions.";
}
- /**
- * Test connection by attempting a verification with an invalid token
- */
public function performConnectionTest(): bool
{
- if (empty($this->site_key) || empty($this->secret_key)) {
+ $credentials = $this->loadCredentials();
+ if (!array_key_exists('site_key', $credentials) || !array_key_exists('secret_key', $credentials)) {
return false;
}
// Test with a dummy token to verify API connectivity
$response = wp_remote_post('https://challenges.cloudflare.com/turnstile/v0/siteverify', [
'body' => [
- 'secret' => $this->secret_key,
+ 'secret' => $credentials['secret_key'],
'response' => 'test-token-connection-check'
],
'timeout' => 10
@@ -97,46 +74,14 @@
// We expect this to fail with success:false, but not with a connection error
if (is_wp_error($response)) {
- $this->logError('Connection test failed: ', [
- 'message' => $response->get_error_message()
- ]);
return false;
}
$result = json_decode(wp_remote_retrieve_body($response), true);
-
// If we get a response with success field (even if false), connection works
return array_key_exists('success', $result);
}
- protected function validateCredentials(array $credentials): bool
- {
- if (empty($credentials['site_key'])) {
- $this->logError('Missing Site Key', [
- 'method' => 'validateCredentials'
- ]);
- return false;
- }
-
- if (empty($credentials['secret_key'])) {
- $this->logError('Missing Secret Key', [
- 'method' => 'validateCredentials'
- ]);
- return false;
- }
-
- // Validate format
- if (!preg_match('/^[0-9a-zA-Z._-]+$/', $credentials['site_key'])) {
-
- $this->logError('Invalid Site Key', [
- 'method' => 'validateCredentials'
- ]);
- return false;
- }
-
- return true;
- }
-
/**
* Enqueue Turnstile script
*/
@@ -156,10 +101,10 @@
/**
* Render Turnstile widget
* Can be called directly: jvbConnect('cloudflare')->renderTurnstile()
+ * Or Cloudflare::getInstance()->renderTurnstile()
*/
public function renderTurnstile(array|string $options = []): void
{
- $this->ensureInitialized();
if (!$this->isSetUp()) {
return;
}
@@ -175,29 +120,36 @@
'wrapper_style' => 'margin: 1em 0;'
];
+ $cred = $this->loadCredentials();
$options = wp_parse_args($options, $defaults);
- echo '<div class="' . esc_attr($options['wrapper_class']) . '" style="' . esc_attr($options['wrapper_style']) . '">';
- echo '<div class="cf-turnstile" ';
- echo 'data-sitekey="' . esc_attr($this->site_key) . '" ';
- echo 'data-theme="' . esc_attr($options['theme']) . '" ';
- echo 'data-size="' . esc_attr($options['size']) . '">';
- echo '</div>';
- echo '</div>';
+ echo sprintf(
+ '<div class="%s" style="%s">
+ <div class="cf-turnstile" data-sitekey="%s" data-theme="%s" data-size="%s"></div>
+ </div>',
+ esc_attr($options['wrapper_class']),
+ esc_attr($options['wrapper_style']),
+ $cred['site_key'],
+ $options['theme'],
+ $options['size']
+ );
}
/**
- * Verify Turnstile response
- * Can be called directly: JVB()->connect('cloudflare')->verifyTurnstile($token)
+ * Verify the Turnstile response
+ * Can be called directly via JVB()->connect('cloudflare')->verifyTurnstile($token)
+ * or Cloudflare::getInstance()->verifyTurnstile($token)
+ * @param string|null $token
+ * @param string $remote_ip
+ * @return bool
*/
- public function verifyTurnstile(?string $token = null, string $remote_ip = ''): bool
+ public function verifyTurnstile(?string $token = null, string $remote_ip = ''):bool
{
- $this->ensureInitialized();
if (!$this->isSetUp()) {
- return false;
+ return true;
}
- // If no token provided, try to get from POST
+ // If no token provided, try to get it from $_POST
if (!$token && isset($_POST['cf-turnstile-response'])) {
$token = sanitize_text_field($_POST['cf-turnstile-response']);
}
@@ -206,61 +158,35 @@
return false;
}
+ $cred = $this->loadCredentials();
$data = [
- 'secret' => $this->secret_key,
- 'response' => $token
+ 'secret' => $cred['secret_key'],
+ 'response' => $token,
];
if (empty($remote_ip) && isset($_SERVER['REMOTE_ADDR'])) {
- $remote_ip = $_SERVER['REMOTE_ADDR'];
+ $remote_ip = sanitize_text_field($_SERVER['REMOTE_ADDR']);
}
if (!empty($remote_ip)) {
$data['remoteip'] = $remote_ip;
}
-
$response = wp_remote_post('https://challenges.cloudflare.com/turnstile/v0/siteverify', [
'body' => $data,
'timeout' => 10
]);
if (is_wp_error($response)) {
- $this->logError('Turnstile verification failed: ' . $response->get_error_message());
+ $this->logError('verifyTurnstile', 'Turnstile verification failed: ' . $response->get_error_message());
return false;
}
$result = json_decode(wp_remote_retrieve_body($response), true);
- if (!($result['success'] ?? false)) {
+ if (!$result['success']??false) {
$error_codes = implode(', ', $result['error-codes'] ?? ['Unknown error']);
- $this->logError('Turnstile verification failed: ' . $error_codes);
+ $this->logError('verifyTurnstile', 'Turnstile verification failed: ' . $error_codes);
}
-
return $result['success'] ?? false;
}
-
- /**
- * Get site key for frontend use
- */
- public function getSiteKey(): string
- {
- return $this->site_key;
- }
-
- /**
- * Get service description
- */
- public function getServiceDescription(): string
- {
- return "Add Turnstile captcha protection to your forms and login pages to prevent spam and bot submissions.";
- }
-
- /**
- * These methods are not used by Cloudflare but must be implemented
- */
- protected function getRequestHeaders(): array
- {
- // Cloudflare Turnstile uses POST parameters, not headers
- return [];
- }
}
diff --git a/inc/integrations/services/Facebook.php b/inc/integrations/services/Facebook.php
index 842cc96..04c03f7 100644
--- a/inc/integrations/services/Facebook.php
+++ b/inc/integrations/services/Facebook.php
@@ -5,9 +5,18 @@
*/
namespace JVBase\integrations\services;
+use Exception;
+use JVBase\integrations\Admin;
+use JVBase\integrations\Instance;
use JVBase\integrations\Integrations;
-use JVBase\meta\Meta;
-use WP_Post;
+use JVBase\integrations\OAuth;
+use JVBase\integrations\OAuthCredentials;
+use JVBase\integrations\OAuthURLs;
+use JVBase\integrations\Requests;
+use JVBase\integrations\SyncDefaults;
+use JVBase\integrations\SyncFrom;
+use JVBase\integrations\SyncTo;
+use JVBase\integrations\UserConnection;
use WP_Error;
if (!defined('ABSPATH')) {
@@ -16,686 +25,254 @@
class Facebook extends Integrations
{
- // Facebook-specific properties
- protected array $allowedContent = [
- 'post',
- 'photo',
- 'video',
- 'event',
- 'offer',
- 'note',
- 'milestone'
- ];
- private string $page_id = '';
- private string $page_access_token = '';
- private array $permissions = [];
- private array $pages = [];
+ use Admin,
+ Instance,
+ OAuth,
+ UserConnection,
+ Requests,
+ SyncFrom,
+ SyncTo,
+ SyncDefaults;
- // Post type mapping for Facebook
- private const FB_POST_TYPES = [
- 'post' => 'feed',
- 'photo' => 'photos',
- 'video' => 'videos',
- 'event' => 'events',
- 'offer' => 'offers',
- 'note' => 'notes',
- 'milestone' => 'milestones'
- ];
-
- 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 function __construct()
{
$this->service_name = 'facebook';
$this->title = 'Facebook';
$this->icon = 'facebook-logo';
- // API Configuration
- $this->apiBase = [
- 'graph' => 'https://graph.facebook.com/v18.0',
- 'auth' => 'https://www.facebook.com/v18.0'
- ];
+ $this->apiVersion = 'v25.0';
- $this->apiVersion = 'v18.0';
-
- $this->isOAuthService = true;
- // OAuth Configuration
- $this->oauth = [
- 'authorize' => 'https://www.facebook.com/v18.0/dialog/oauth',
- 'token' => 'https://graph.facebook.com/v18.0/oauth/access_token',
- 'revoke' => 'https://graph.facebook.com/v18.0/me/permissions',
- 'scopes' => [
- 'pages_show_list',
- 'pages_read_engagement',
- 'pages_manage_posts',
- 'pages_manage_metadata',
- 'pages_manage_engagement',
- 'pages_read_user_content',
- 'business_management',
- 'instagram_basic',
- 'instagram_content_publish'
- ]
- ];
-
- // Sync capabilities
$this->canSync = [
- 'initial' => true,
- 'update' => true,
- 'delete' => true
+ 'create' => true,
+ 'update' => true,
+ 'delete' => true
];
- // Rate limits for Facebook Graph API
- $this->rate_limits = [
- 'per_second' => 10,
- 'per_minute' => 600,
- 'per_hour' => 200000
- ];
+ $this->defineFields();
+ $this->defineInstructions();
+ $this->defineOAuth();
+ $this->defineActions();
- $this->fields = [
- 'client_id' => [
- 'type' => 'text',
- 'placeholder' => 'Enter Facebook App ID',
- 'hint' => 'Your Facebook App ID from developers.facebook.com',
- 'label' => 'App ID',
- 'required' => true,
- ],
- 'client_secret' => [
- 'type' => 'text',
- 'subtype'=> 'password',
- 'label' => 'App Secret',
- 'required' => true,
- 'hint' => 'Your Facebook App Secret (keep this secure!)'
- ],
- ];
-
- $this->advanced = [
-
- ];
-
- $this->instructions = [
-
- ];
-
- $this->defaults = [
-
- ];
- $this->hasWebhooks = true;
+ $this->baseUrl = 'https://graph.facebook.com/'.$this->apiVersion;
+ $this->baseOAuthUrl = 'https://www.facebook.com/'.$this->apiVersion;
parent::__construct();
-
- $this->actions = array_merge($this->actions, [
- 'sync_pages' => 'getUserPages'
- ]);
}
- /**
- * Initialize Facebook-specific properties
- */
- protected function initialize(): void
- {
- $this->page_id = $this->credentials['page_id'] ?? '';
- $this->page_access_token = $this->credentials['page_access_token'] ?? '';
- $this->permissions = $this->credentials['permissions'] ?? [];
+ protected function defineFields():void
+ {
+ $client_id = $this->addField(
+ 'client_id',
+ 'App ID',
+ 'text',
+ 'manage_options'
+ );
+ $client_id->setRequired();
+ $client_id->setHint('Your Facebook App ID from developers.facebook.com');
- // Build dynamic endpoints based on page
- if ($this->page_id) {
- $this->apiEndpoints = [
- "/{$this->page_id}/feed",
- "/{$this->page_id}/photos",
- "/{$this->page_id}/videos",
- "/{$this->page_id}/events",
- "/{$this->page_id}/insights",
- "/{$this->page_id}/conversations",
- "/{$this->page_id}",
- "/me/accounts"
+ $client_secret = $this->addField(
+ 'client_secret',
+ 'App Secret',
+ 'text',
+ 'manage_options'
+ );
+ $client_secret->setRequired();
+ $client_secret->setSubType('password');
+
+ $pages = $this->addField('pages', 'Pages', 'select', [$this, 'testUserCanConnect']);
+ $pages->setHidden();
+ $pages->setOptions($this->getPageOptions());
+
+ $page = $this->addField('page_id', 'Connect to Page', 'select', [$this, 'testUserCanConnect']);
+ $page->setOptions($this->loadCredentials()['pages']??[]);
+ $page->setHint('Select the page you want to connect this site to.');
+ }
+ public function testUserCanConnect():bool
+ {
+ $user = get_current_user_id();
+ return $this->userCanConnect($user);
+ }
+ protected function getPageOptions():array
+ {
+ return $this->loadCredentials()['pages']??[];
+ }
+ protected function defineInstructions():void
+ {
+
+ }
+ protected function defineActions():void
+ {
+ $this->addAction(
+ 'Select Page',
+ [$this, 'handleSelectPage'],
+ 'select_page',
+ 'plugs-connected'
+ );
+
+ $this->addAction(
+ 'Fetch Pages',
+ [$this, 'getPages'],
+ 'fetch_pages',
+ 'download-simple'
+ );
+ }
+ public function defineOAuth():void
+ {
+ $this->oauth = new OAuthURLs(
+ 'https://www.facebook.com/'.$this->apiVersion.'/dialog/oauth',
+ 'https://graph.facebook.com/'.$this->apiVersion.'/oauth/access_token',
+ 'https://graph.facebook.com/'.$this->apiVersion.'/me/permissions'
+ );
+ $this->oathScopes = [
+ 'pages_show_list',
+ 'business_management',
+ 'pages_manage_metadata',
+ 'pages_manage_posts'
];
}
- }
-
- /**
- * Get service description
- */
- public function getServiceDescription(): string
+ public function getServiceDescription():string
+ {
+ return 'Connect your Facebook Page to share posts, events, and engage with your audience.';
+ }
+ protected function initialize(): void
{
- return "Connect your Facebook Page to share posts, events, and engage with your audience.";
+ // TODO: Implement initialize() method.
}
- /**
- * Get request headers for Facebook API
- */
+ protected function refreshAccessToken(OAuthCredentials $credentials): array
+ {
+ return $this->response(false, 'Not set up yet');
+ }
+
protected function getRequestHeaders(): array
{
- $headers = [
+ return [
'Content-Type' => 'application/json',
'Accept' => 'application/json'
];
-
- // Use page access token if available, otherwise user token
- if (!empty($this->page_access_token)) {
- // Token is sent in URL params for Facebook, not headers
- }
-
- return $headers;
}
- /**
- * Add Facebook-specific OAuth parameters
- */
- protected function addOAuthParams(array $params): array
+
+ protected function handleResponse(\WP_Error|array $response, string $method, string $endpoint): array
{
- $params['display'] = 'popup';
- $params['auth_type'] = 'rerequest';
- $params['return_scopes'] = 'true';
- return $params;
- }
-
- /**
- * Add Facebook-specific credential data after OAuth
- */
- protected function addCredentialData(array $credentials, array $tokens): array
- {
- // Get user's pages after OAuth
- try {
- $pages = $this->getUserPages($tokens['access_token']);
- if (!empty($pages)) {
- $credentials['pages'] = $pages;
- // Auto-select first page if not already selected
- if (empty($credentials['page_id']) && !empty($pages[0])) {
- $credentials['page_id'] = $pages[0]['id'];
- $credentials['page_access_token'] = $pages[0]['access_token'];
- $credentials['page_name'] = $pages[0]['name'];
- }
- }
-
- // Store permissions granted
- $permissions = $this->getGrantedPermissions($tokens['access_token']);
- $credentials['permissions'] = $permissions;
-
- } catch (Exception $e) {
- $this->logError('Failed to fetch Facebook pages', ['error' => $e->getMessage()]);
+ if (is_wp_error($response) && $response->get_error_message() === 'rate_limit_exceeded') {
+ return $this->response(false, 'Rate limit exceeded');
}
- return $credentials;
+ $endpoint = str_replace($this->baseUrl, '', $endpoint);
+ return match ($endpoint) {
+ '/me/accounts?access_token='.$this->getAccessToken() => $this->handleGetPagesResponse($response, $method),
+ default => $this->response(false, 'Unhandled Response for ' . $endpoint . ' endpoint, with method: ' . $method),
+ };
+ }
+ /************************************************************
+ * UTILITY METHODS
+ ************************************************************/
+ protected function getPageID():string|false
+ {
+ return $this->loadCredentials()['page_id']??false;
+ }
+ protected function getClientID():string|false
+ {
+ return $this->loadCredentials()['client_id']??false;
+ }
+ protected function getClientSecret():string|false
+ {
+ return $this->loadCredentials()['client_secret']??false;
}
- /**
- * Test Facebook connection
- */
- protected function performConnectionTest(): bool
+ protected function getFBUserID():string|false
{
- if (empty($this->credentials['access_token'])) {
- return false;
- }
-
- try {
- // Test by fetching user info
- $response = wp_remote_get(
- $this->apiBase['graph'] . '/me?access_token=' . $this->credentials['access_token']
+ $cred = $this->loadCredentials();
+ if ($this->isSetUp() && !array_key_exists('user_id', $cred)) {
+ $resp = $this->getRequest(
+ $this->baseUrl.'/me?access_token='.$cred['access_token'],
);
+ if ($resp && !is_wp_error($resp)) {
+ $tmp['user_id'] = $resp['id'];
+ $tmp['user_name'] = $resp['name'];
- if (is_wp_error($response)) {
- return false;
+ $this->saveCredentials($tmp, false);
}
-
- $code = wp_remote_retrieve_response_code($response);
- return $code === 200;
-
- } catch (Exception $e) {
- $this->logError('Connection test failed', ['error' => $e->getMessage()]);
- return false;
+ $cred = array_merge($cred, $tmp);
}
+ return $cred['user_id']??false;
}
-
- /**
- * Render Additional OAuth settings
- */
- protected function renderOAuthConnectedOptions(): void
+ public function getPages():array|false
{
- // Page selection dropdown
- if (!empty($this->credentials['pages'])) {
- ?>
- <div class="form-field">
- <label for="fb_page">Active Facebook Page</label>
- <select name="credentials[page_id]" id="fb_page">
- <option value="">Select a page...</option>
- <?php foreach ($this->credentials['pages'] as $page): ?>
- <option value="<?php echo esc_attr($page['id']); ?>"
- <?php selected($this->credentials['page_id'] ?? '', $page['id']); ?>>
- <?php echo esc_html($page['name']); ?>
- </option>
- <?php endforeach; ?>
- </select>
- </div>
- <?php
- }
- }
-
- /**
- * Validate webhook from Facebook
- */
- protected function validateWebhook(array $payload): bool
- {
- // Handle webhook verification challenge
- if (isset($payload['hub_mode']) && $payload['hub_mode'] === 'subscribe') {
- $verify_token = $this->credentials['webhook_verify_token'] ?? '';
- if ($payload['hub_verify_token'] === $verify_token) {
- // Echo the challenge for verification
- echo $payload['hub_challenge'];
- return true;
- }
+ if (!$this->getAccessToken()) {
return false;
}
- // Validate webhook signature for actual events
- $headers = $payload['_headers'] ?? [];
- $signature = $headers['x-hub-signature-256'] ?? '';
-
- if (empty($signature)) {
- $this->logError('Missing webhook signature');
- return false;
- }
-
- // Calculate expected signature
- $app_secret = $this->credentials['client_secret'] ?? '';
- $expected = 'sha256=' . hash_hmac('sha256', json_encode($payload), $app_secret);
-
- return hash_equals($expected, $signature);
- }
-
- /**
- * Process webhook from Facebook
- */
- protected function processWebhook(array $payload): bool
- {
try {
- $object = $payload['object'] ?? '';
- $entry = $payload['entry'] ?? [];
-
- foreach ($entry as $item) {
- $changes = $item['changes'] ?? [];
-
- foreach ($changes as $change) {
- $field = $change['field'] ?? '';
- $value = $change['value'] ?? [];
-
- switch ($field) {
- case 'feed':
- $this->handleFeedUpdate($value);
- break;
-
- case 'conversations':
- $this->handleMessageUpdate($value);
- break;
-
- case 'photos':
- $this->handlePhotoUpdate($value);
- break;
-
- default:
- $this->logDebug('Unhandled webhook field', ['field' => $field]);
- }
+ return $this->getRequest(
+ $this->apiBase.'/me/accounts?access_token='.$this->getAccessToken(),
+ );
+ } catch (Exception $e) {
+ $this->logError(
+ 'getPages',
+ 'Failed to load pages',
+ [
+ 'error' => $e->getMessage(),
+ ]
+ );
+ }
+ return [];
+ }
+ protected function handleGetPagesResponse(WP_Error|array $response, string $method):array
+ {
+ $options = [];
+ foreach ($response as $page) {
+ if (array_key_exists('id', $page) && array_key_exists('name', $page)) {
+ $options[$page['id']] = $page['name'];
}
}
-
- return true;
-
- } catch (Exception $e) {
- $this->logError('Webhook processing failed', [
- 'error' => $e->getMessage(),
- 'payload' => $payload
- ]);
- return false;
+ if (!empty($options)) {
+ $this->saveCredentials(['pages' => $options]);
+ }
+ return $this->response(true, 'Found pages', $options);
}
- }
- /**
- * Handle post save for Facebook sync
- */
- protected function handleTheSavePost(int $postID, WP_Post $post, bool $update, array $settings): void
+ /************************************************************
+ * ACTIONS
+ ************************************************************/
+ public function handleSelectPage(array $data):array
{
-
- if (!$this->hasOAuthCredentials()) {
- error_log('OAuth Not set up for '.$this->service_name);
- return;
+ if (empty($data['page_id']??'')) {
+ return $this->response(false, 'No page selected.');
}
- $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');
+
+ $cred = [
+ 'page_id' => sanitize_text_field($data['page_id']),
+ ];
+ return $this->saveCredentials($cred, false);
}
- /**
- * Process queued operations
- */
- public function processOperation(WP_Error|array $result, object $operation, array $data): WP_Error|array
+ public function handleFetchPages():array
{
try {
- $fb = (array_key_exists('user', $data) && $data['user'] !== 0) ? $this->getInstance((int)$data['user']) : $this;
- switch ($operation->type) {
- case 'facebook_create_post':
- return $fb->createFacebookPost($data);
-
- case 'facebook_update_post':
- return $fb->updateFacebookPost($data);
-
- case 'facebook_delete_post':
- return $fb->deleteFacebookPost($data);
-
- case 'facebook_create_event':
- return $fb->createFacebookEvent($data);
-
- default:
- return $result;
+ $pages = $this->getPages();
+ if (!$pages) {
+ return $this->response(false, 'No pages found');
}
+
+ return $this->response(
+ true,
+ sprintf('Found %d page(s)', count($pages)),
+ $pages
+ );
} catch (Exception $e) {
- $this->logError('Operation failed', [
- 'type' => $operation->type,
- 'error' => $e->getMessage()
- ]);
- return new WP_Error('operation_failed', $e->getMessage());
+ return $this->response(false, 'Failed to fetch pages: '.$e->getMessage());
}
}
- /**
- * Create a Facebook post
- */
- private function createFacebookPost(array $data): array
+ protected function setContentTypes(): void
{
- $post = get_post($data['post_id']);
- if (!$post) {
- return ['success' => false, 'error' => 'Post not found'];
- }
-
- // Build Facebook post data
- $fb_data = [
- 'message' => $this->formatPostContent($post),
- 'access_token' => $this->page_access_token
- ];
-
- // Add link if present
- $link = get_post_meta($post->ID, 'link_url', true);
- if ($link) {
- $fb_data['link'] = $link;
- }
-
- // Handle featured image
- if (has_post_thumbnail($post->ID)) {
- $image_url = get_the_post_thumbnail_url($post->ID, 'large');
- $fb_data['picture'] = $image_url;
- }
-
- // Determine endpoint
- $endpoint = $data['endpoint'] ?? 'feed';
- $url = $this->apiBase['graph'] . '/' . $this->page_id . '/' . $endpoint;
-
- // Make the API request
- $response = wp_remote_post($url, [
- 'body' => $fb_data,
- 'timeout' => 30
- ]);
-
- if (is_wp_error($response)) {
- return ['success' => false, 'error' => $response->get_error_message()];
- }
-
- $body = json_decode(wp_remote_retrieve_body($response), true);
-
- if (isset($body['id'])) {
- // Store Facebook post ID
- update_post_meta($post->ID, BASE . '_facebook_post_id', $body['id']);
- update_post_meta($post->ID, BASE . '_facebook_sync_time', time());
-
- return [
- 'success' => true,
- 'facebook_id' => $body['id'],
- 'message' => 'Posted to Facebook successfully'
- ];
- }
-
- return ['success' => false, 'error' => $body['error']['message'] ?? 'Unknown error'];
+ // TODO: Implement setContentTypes() method.
}
- /**
- * Update a Facebook post
- */
- private function updateFacebookPost(array $data): array
+ protected function formatForService(int $itemID, string $type = 'post'): array
{
- $post = get_post($data['post_id']);
- $fb_post_id = get_post_meta($post->ID, BASE . '_facebook_post_id', true);
-
- if (!$fb_post_id) {
- // No existing post, create new one
- return $this->createFacebookPost($data);
- }
-
- // Facebook doesn't allow editing most post types after creation
- // We can only update certain fields like message
- $fb_data = [
- 'message' => $this->formatPostContent($post),
- 'access_token' => $this->page_access_token
- ];
-
- $url = $this->apiBase['graph'] . '/' . $fb_post_id;
-
- $response = wp_remote_request($url, [
- 'method' => 'POST',
- 'body' => $fb_data
- ]);
-
- if (is_wp_error($response)) {
- return ['success' => false, 'error' => $response->get_error_message()];
- }
-
- update_post_meta($post->ID, BASE . '_facebook_sync_time', time());
-
- return ['success' => true, 'message' => 'Updated on Facebook'];
- }
-
- /**
- * Delete a Facebook post
- */
- private function deleteFacebookPost(array $data): array
- {
- $fb_post_id = get_post_meta($data['post_id'], BASE . '_facebook_post_id', true);
-
- if (!$fb_post_id) {
- return ['success' => true, 'message' => 'No Facebook post to delete'];
- }
-
- $url = $this->apiBase['graph'] . '/' . $fb_post_id . '?access_token=' . $this->page_access_token;
-
- $response = wp_remote_request($url, ['method' => 'DELETE']);
-
- if (!is_wp_error($response)) {
- delete_post_meta($data['post_id'], BASE . '_facebook_post_id');
- delete_post_meta($data['post_id'], BASE . '_facebook_sync_time');
- }
-
- return ['success' => true, 'message' => 'Deleted from Facebook'];
- }
-
- /**
- * Create a Facebook event
- */
- private function createFacebookEvent(array $data): array
- {
- $post = get_post($data['post_id']);
- $meta = Meta::forPost($post->ID);
-
- $event_data = [
- 'name' => $post->post_title,
- 'description' => $this->formatPostContent($post),
- 'start_time' => $meta->get('event_start_date'),
- 'end_time' => $meta->get('event_end_date'),
- 'access_token' => $this->page_access_token
- ];
-
- // Add location if available
- $location = $meta->get('event_location');
- if ($location) {
- $event_data['location'] = $location;
- }
-
- $url = $this->apiBase['graph'] . '/' . $this->page_id . '/events';
-
- $response = wp_remote_post($url, [
- 'body' => $event_data
- ]);
-
- if (is_wp_error($response)) {
- return ['success' => false, 'error' => $response->get_error_message()];
- }
-
- $body = json_decode(wp_remote_retrieve_body($response), true);
-
- if (isset($body['id'])) {
- update_post_meta($post->ID, BASE . '_facebook_event_id', $body['id']);
- return ['success' => true, 'event_id' => $body['id']];
- }
-
- return ['success' => false, 'error' => $body['error']['message'] ?? 'Unknown error'];
- }
-
- /**
- * Helper: Get user's Facebook pages
- */
- private function getUserPages(?string $access_token = null): array
- {
- $token = $access_token ?: $this->credentials['access_token'];
-
- $url = $this->apiBase['graph'] . '/me/accounts?access_token=' . $token;
-
- $response = wp_remote_get($url);
-
- if (is_wp_error($response)) {
- throw new Exception($response->get_error_message());
- }
-
- $body = json_decode(wp_remote_retrieve_body($response), true);
-
- if (isset($body['error'])) {
- throw new Exception($body['error']['message']);
- }
-
- return $body['data'] ?? [];
- }
-
- /**
- * Helper: Get granted permissions
- */
- private function getGrantedPermissions(string $access_token): array
- {
- $url = $this->apiBase['graph'] . '/me/permissions?access_token=' . $access_token;
-
- $response = wp_remote_get($url);
-
- if (is_wp_error($response)) {
- return [];
- }
-
- $body = json_decode(wp_remote_retrieve_body($response), true);
- $permissions = [];
-
- foreach (($body['data'] ?? []) as $permission) {
- if ($permission['status'] === 'granted') {
- $permissions[] = $permission['permission'];
- }
- }
-
- return $permissions;
- }
-
- /**
- * Helper: Format post content for Facebook
- */
- private function formatPostContent(WP_Post $post): string
- {
- $content = $post->post_content;
-
- // Strip shortcodes
- $content = strip_shortcodes($content);
-
- // Convert to plain text
- $content = wp_strip_all_tags($content);
-
- // Add link to full post
- $permalink = get_permalink($post->ID);
- $content .= "\n\nRead more: " . $permalink;
-
- // Trim to Facebook's character limit (63,206 characters)
- if (strlen($content) > 63000) {
- $content = substr($content, 0, 63000) . '...';
- }
-
- return $content;
- }
-
- /**
- * Helper: Handle feed updates from webhook
- */
- private function handleFeedUpdate(array $data): void
- {
- // Process incoming feed updates
- // Could sync comments, reactions, etc. back to WordPress
- $this->logDebug('Feed update received', $data);
- }
-
- /**
- * Helper: Handle message updates from webhook
- */
- private function handleMessageUpdate(array $data): void
- {
- // Process incoming messages
- // Could create notifications or queue responses
- $this->logDebug('Message received', $data);
- }
-
- /**
- * Helper: Handle photo updates from webhook
- */
- private function handlePhotoUpdate(array $data): void
- {
- // Process photo updates
- $this->logDebug('Photo update received', $data);
- }
-
- /**
- * Send test post to Facebook
- */
- private function sendTestPost(): array
- {
- $test_data = [
- 'message' => 'Test post from ' . get_bloginfo('name') . ' at ' . current_time('mysql'),
- 'access_token' => $this->page_access_token
- ];
-
- $url = $this->apiBase['graph'] . '/' . $this->page_id . '/feed';
-
- $response = wp_remote_post($url, [
- 'body' => $test_data
- ]);
-
- if (is_wp_error($response)) {
- return ['success' => false, 'error' => $response->get_error_message()];
- }
-
- $body = json_decode(wp_remote_retrieve_body($response), true);
-
- if (isset($body['id'])) {
- return ['success' => true, 'post_id' => $body['id']];
- }
-
- return ['success' => false, 'error' => $body['error']['message'] ?? 'Unknown error'];
+ return [];
}
}
diff --git a/inc/integrations/services/GoogleMaps.php b/inc/integrations/services/GoogleMaps.php
index 6477a0d..f1e986a 100644
--- a/inc/integrations/services/GoogleMaps.php
+++ b/inc/integrations/services/GoogleMaps.php
@@ -5,8 +5,11 @@
*/
namespace JVBase\integrations\services;
+use JVBase\integrations\Admin;
+use JVBase\integrations\Instance;
use JVBase\integrations\Integrations;
-use JVBase\registrar\Registrar;
+use JVBase\integrations\Requests;
+use WP_Error;
if (!defined('ABSPATH')) {
exit;
@@ -14,468 +17,101 @@
class GoogleMaps extends Integrations
{
- private string $public_key;
- private string $private_key;
- private string $map_id;
- private array $enabled_apis = [];
-
- // Edmonton default coordinates
- private const DEFAULT_LAT = 53.5461;
- private const DEFAULT_LNG = -113.4938;
- private const DEFAULT_ZOOM = 11; // City-wide view
- private const DEFAULT_MAP_ID = '873aed4ca260b4203e45b6e3';
-
- protected static self $instance;
- public static function getInstance():self
- {
-
- if (!isset(self::$instance)) {
- self::$instance = new self();
- }
- return self::$instance;
- }
-
+ use Admin, Instance, Requests;
protected function __construct()
{
$this->service_name = 'maps';
$this->title = 'Google Maps';
$this->icon = 'map-pin';
+
$this->apiBase = 'https://maps.googleapis.com/maps/api/';
- $this->apiEndpoints = [
- 'geocode/json',
- 'place/details/json',
- 'place/nearbysearch/json',
- 'distancematrix/json',
- 'js'
- ];
+ $this->defineFields();
+ $this->defineInstructions();
+ $this->defineActions();
- $this->fields = [
- 'public_key' => [
- 'type' => 'text',
- 'subtype' => 'password',
- 'label' => 'Maps Platform API Key (Referrer-Restricted)',
- 'hint' => 'Restrict this key by <b>HTTP referrers</b> for security. Used for displaying maps in browsers.',
- 'required' => true,
- ],
- 'private_key' => [
- 'type' => 'text',
- 'subtype' => 'password',
- 'label' => 'Geocoding & Places API Key (IP-Restricted)',
- 'hint' => 'Restrict this key by <br>IP Addresses</b>. Used for geocoding and server-side operations.',
- 'required' => true,
- ]
- ];
- $this->advanced = [
- 'map_id' => [
- 'type' => 'text',
- 'label' => 'Map ID',
- 'hint' => 'Required for custom styling. Create a Map ID in <a href="https://console.cloud.google.com/google/maps-apis/studio/maps" target="_blank">Google Cloud Console → Maps → Map Management</a>'
- ],
- 'default_zoom' => [
- 'type' => 'text',
- 'subtype' => 'number',
- 'min' => 1,
- 'max' => 20,
- 'label' => 'Default Zoom',
- 'default' => 11
- ],
- ];
- $this->instructions = [
- 'Create two API keys in <a href="https://console.cloud.google.com/apis/credentials" target="_blank">Google Cloud Console</a>',
- 'Enable <strong>Geocoding API + Places API</strong> for frontend key, restricted by your server IP address',
- 'Enable <strong>Maps Platform API</strong> for server key, restricted by your website domain',
- ];
- $this->defaults = [
-
- ];
parent::__construct();
-
- $this->actions = array_merge(
- $this->actions,
- [
- 'reverse_geocode' => 'processReverseGeocode',
- 'geocode' => 'processGeocode'
- ]
- );
// Auto-enqueue maps script when needed
add_action('wp_enqueue_scripts', [$this, 'maybeEnqueueMapsScript']);
-// add_action('admin_enqueue_scripts', [$this, 'maybeEnqueueMapsScript']);
}
+ protected function defineFields():void
+ {
+ $pub = $this->addField('public_key', 'Maps Platform API Key', 'text', 'manage_options');
+ $pub->setHint('Restrict this key by HTTP referrers for security. Used for displaying maps in browsers.');
+ $pub->setSubType('password');
+ $pub->setRequired();
+ $priv = $this->addField('private_key', 'Geocoding & Places API Key', 'text', 'manage_options');
+ $priv->setHint('Restrict this key by IP Addresses. Used for geocoding and server-side operations.');
+ $priv->setRequired();
+ $priv->setSubType('password');
+
+ $map_id = $this->addField('map_id', 'Map ID', 'text', 'manage_options');
+ $map_id->setHint('Required for custom styling. Create a Map ID in Google Cloud Console -> Maps -> Map Management.');
+
+ $zoom = $this->addField('default_zoom', 'Default Zoom', 'text', 'manage_options');
+ $zoom->setSubType('number');
+ $zoom->setDefault(11);
+ $zoom->setMin(1);
+ $zoom->setMax(20);
+
+ $lng = $this->addField('default_lng', 'Default Longitude', 'text', 'manage_options');
+ $lng->setSubType('number');
+ $lng->setDefault(-113.4938);
+
+ $lat = $this->addField('default_lat', 'Default Latitude', 'text', 'manage_options');
+ $lng->setSubType('number');
+ $lng->setDefault(53.5461);
+ }
+
+ protected function defineInstructions():void
+ {
+ $this->instructions = [
+ 'Create two API keys in <a href="https://console.cloud.google.com/apis/credentials" target="_blank">Google Cloud Console</a>',
+ 'Enable <strong>Geocoding API + Places API</strong> for frontend key, restricted by your server IP address',
+ 'Enable <strong>Maps Platform API</strong> for server key, restricted by your website domain',
+ ];
+ }
+
+ protected function defineActions():void
+ {
+ $this->addAction(
+ 'reverse_geocode',
+ [$this, 'processReverseGeocode']
+ );
+
+ $this->addAction(
+ 'geocode',
+ [$this, 'processGeocode']
+ );
+ }
+ public function processReverseGeocode(array $data):array
+ {
+ if (!array_key_exists('lat', $data) || !array_key_exists('lng', $data)) {
+ return $this->response(false, 'Invalid coordinates provided', $data);
+ }
+ $lat = floatval($data['lat']);
+ $lng = floatval($data['lng']);
+
+ $result = $this->reverseGeocode($lat, $lng);
+ if ($result) {
+ return $this->response(true, 'Successfully reversed Geocode', ['data'=> $result]);
+ }
+ return $this->response(false, 'Reverse geocoding failed');
+ }
+
+ public function processGeocode(array $data):array
+ {
+ if (!array_key_exists('address', $data)) {
+ return $this->response(false, 'No address provided');
+ }
+ $result = $this->geocodeAddress(sanitize_text_field($data['address']));
+ if ($result) {
+ return $this->response(true, 'Successfully geocoded address', ['data' => $result]);
+ }
+ return $this->response(false, 'Geocoding failed');
+ }
protected function initialize(): void
{
- $this->private_key = $this->credentials['private_key'] ?? '';
- $this->public_key = $this->credentials['public_key'] ?? '';
- $this->map_id = $this->credentials['map_id'] ?? self::DEFAULT_MAP_ID;
- $this->rate_limits = ['min_interval' => 0]; // Google Maps has generous limits
- }
-
- /**
- * Get service description
- */
- public function getServiceDescription(): string
- {
- return "Enable location features and embedded maps throughout your site.";
- }
-
- /**
- * Get request headers (required by base class)
- */
- protected function getRequestHeaders(): array
- {
- // Google Maps API uses API key in URL params, not headers
- return [
- 'Accept' => 'application/json',
- 'Content-Type' => 'application/json'
- ];
- }
-
-
- /**
- * Process test connection request
- */
- protected function performConnectionTest(): bool
- {
- if (!$this->isSetUp()) {
- return false;
- }
-
- // Test with a simple geocoding request
- $test_address = 'Edmonton, Alberta, Canada';
- $result = $this->geocodeAddress($test_address);
-
- return $result !== null;
- }
-
- /**
- * Process reverse geocode request
- */
- private function processReverseGeocode(array $data): WP_Error|array
- {
- $lat = floatval($data['lat'] ?? 0);
- $lng = floatval($data['lng'] ?? 0);
-
- if (!$lat || !$lng) {
- return new WP_Error('invalid_coordinates', 'Invalid coordinates provided');
- }
-
- $result = $this->reverseGeocode($lat, $lng);
-
- if ($result) {
- return ['success' => true, 'data' => $result];
- }
-
- return new WP_Error('geocoding_failed', 'Reverse geocoding failed');
- }
-
- /**
- * Process geocode request
- */
- private function processGeocode(array $data): WP_Error|array
- {
- $address = sanitize_text_field($data['address'] ?? '');
-
- if (empty($address)) {
- return new WP_Error('invalid_address', 'No address provided');
- }
-
- $result = $this->geocodeAddress($address);
-
- if ($result) {
- return ['success' => true, 'data' => $result];
- }
-
- return new WP_Error('geocoding_failed', 'Geocoding failed');
- }
-
- /**
- * Geocode an address to coordinates
- */
- public function geocodeAddress(string $address): ?array
- {
- if (!$this->isSetUp()) {
- return null;
- }
-
- $params = [
- 'address' => $address,
- 'key' => $this->private_key
- ];
-
- $key = $this->cache->generateKey(['geocode', $address]);
- $cached = $this->cache->get($key);
- if ($cached) {
- return $cached;
- }
-
- $url = $this->apiBase . 'geocode/json?' . http_build_query($params);
- $response = wp_remote_get($url);
-
- if (is_wp_error($response)) {
- $this->logError('Geocoding request failed', [
- 'error' => $response->get_error_message(),
- 'address' => $address
- ]);
- return null;
- }
-
- $data = json_decode(wp_remote_retrieve_body($response), true);
-
- if ($data['status'] === 'OK' && !empty($data['results'])) {
- $result = $data['results'][0];
- $result = [
- 'lat' => $result['geometry']['location']['lat'],
- 'lng' => $result['geometry']['location']['lng'],
- 'formatted_address' => $result['formatted_address'],
- 'place_id' => $result['place_id'] ?? '',
- 'components' => $this->parseAddressComponents($result['address_components'] ?? [])
- ];
- $this->cache->set($key, $result);
- } else {
- $this->logError('Google Maps API error', [
- 'status' => $data['status'] ?? 'unknown',
- 'address' => $address
- ], 'warning');
- $result = null;
- }
-
- return $result;
- }
-
- /**
- * Reverse geocode coordinates to address
- */
- public function reverseGeocode(float $lat, float $lng): ?array
- {
- if (!$this->isSetUp()) {
- return null;
- }
-
- $params = [
- 'latlng' => "{$lat},{$lng}",
- 'key' => $this->private_key
- ];
-
- $key = $this->cache->generateKey(['reverse', $lat, $lng]);
- $cached = $this->cache->get($key);
- if ($cached) {
- return $cached;
- }
-
- $url = $this->apiBase . 'geocode/json?' . http_build_query($params);
- $response = wp_remote_get($url);
-
- if (is_wp_error($response)) {
- $this->logError('Reverse geocoding request failed', [
- 'error' => $response->get_error_message(),
- 'lat' => $lat,
- 'lng' => $lng
- ]);
- return null;
- }
-
- $data = json_decode(wp_remote_retrieve_body($response), true);
-
- if ($data['status'] === 'OK' && !empty($data['results'])) {
- $result = $data['results'][0];
- $return = [
- 'formatted_address' => $result['formatted_address'],
- 'place_id' => $result['place_id'] ?? '',
- 'components' => $this->parseAddressComponents($result['address_components'] ?? [])
- ];
- $this->cache->set($key, $return);
- return $return;
- }
-
- return null;
- }
-
- /**
- * Get place details by place ID
- */
- public function getPlaceDetails(string $place_id, array $fields = []): ?array
- {
- if (!$this->isSetUp()) {
- return null;
- }
-
- $default_fields = ['name', 'formatted_address', 'geometry', 'rating', 'formatted_phone_number'];
- $fields = empty($fields) ? $default_fields : $fields;
-
- $key = $this->cache->generateKey(['place', $place_id, $fields]);
- $cached = $this->cache->get($key);
- if ($cached) {
- return $cached;
- }
-
- $params = [
- 'place_id' => $place_id,
- 'fields' => implode(',', $fields),
- 'key' => $this->private_key
- ];
-
- $url = $this->apiBase . 'place/details/json?' . http_build_query($params);
- $response = wp_remote_get($url);
-
- if (is_wp_error($response)) {
- $this->logError('Place details request failed', [
- 'error' => $response->get_error_message(),
- 'place_id' => $place_id
- ]);
- return null;
- }
-
- $data = json_decode(wp_remote_retrieve_body($response), true);
-
- if ($data['status'] === 'OK' && !empty($data['result'])) {
- $result = $data['result'];
- $this->cache->set($key, $result);
- return $result;
- }
-
- return null;
- }
-
- /**
- * Search for places nearby
- */
- public function searchNearby(float $lat, float $lng, int $radius = 1000, string $type = ''): array
- {
- if (!$this->isSetUp()) {
- return [];
- }
-
- $params = [
- 'location' => "{$lat},{$lng}",
- 'radius' => $radius,
- 'key' => $this->private_key
- ];
-
- if (!empty($type)) {
- $params['type'] = $type;
- }
-
- $key = $this->cache->generateKey(['nearby', $lat, $lng, $radius, $type]);
- $cached = $this->cache->get($key);
- if ($cached) {
- return $cached;
- }
-
- $url = $this->apiBase . 'place/nearbysearch/json?' . http_build_query($params);
- $response = wp_remote_get($url);
-
- if (is_wp_error($response)) {
- $this->logError('Nearby search request failed', [
- 'error' => $response->get_error_message(),
- 'lat' => $lat,
- 'lng' => $lng,
- 'radius' => $radius
- ]);
- return [];
- }
-
- $data = json_decode(wp_remote_retrieve_body($response), true);
-
- if ($data['status'] === 'OK' && !empty($data['results'])) {
- $return = $data['results'];
- $this->cache->set($key, $return);
- return $return;
- }
-
- return [];
- }
-
- /**
- * Calculate distance between two points
- */
- public function calculateDistance(array $origin, array $destination, string $mode = 'driving'): ?array
- {
- if (!$this->isSetUp()) {
- return null;
- }
-
- // Handle different input formats
- $origin_str = is_array($origin) ? "{$origin['lat']},{$origin['lng']}" : $origin;
- $dest_str = is_array($destination) ? "{$destination['lat']},{$destination['lng']}" : $destination;
-
- $params = [
- 'origins' => $origin_str,
- 'destinations' => $dest_str,
- 'mode' => $mode,
- 'units' => 'metric',
- 'key' => $this->private_key
- ];
-
- $key = $this->cache->generateKey(['distance', $origin_str, $dest_str, $mode]);
- $cached = $this->cache->get($key);
- if ($cached) {
- return $cached;
- }
-
- $url = $this->apiBase . 'distancematrix/json?' . http_build_query($params);
- $response = wp_remote_get($url);
-
- if (is_wp_error($response)) {
- $this->logError('Distance calculation request failed', [
- 'error' => $response->get_error_message(),
- 'origin' => $origin_str,
- 'destination' => $dest_str
- ]);
- return null;
- }
-
- $data = json_decode(wp_remote_retrieve_body($response), true);
-
- if ($data['status'] === 'OK' && !empty($data['rows'][0]['elements'][0])) {
- $element = $data['rows'][0]['elements'][0];
- if ($element['status'] === 'OK') {
- $return = [
- 'distance' => $element['distance'],
- 'duration' => $element['duration'],
- 'mode' => $mode
- ];
- $this->cache->set($key, $return);
- return $return;
- }
- }
-
- return null;
- }
-
- /**
- * Get the JavaScript API URL with key
- */
- public function getJavaScriptApiUrl(array $libraries = ['places', 'marker', 'geometry']): string
- {
- if (empty($this->public_key)) {
- return '';
- }
-
- $params = [
- 'key' => $this->public_key,
- 'libraries' => implode(',', $libraries),
- 'callback' => 'initGoogleMaps',
- ];
-
- return $this->apiBase . 'js?' . http_build_query($params);
- }
-
- /**
- * Enqueue Google Maps script when needed
- */
- public function maybeEnqueueMapsScript(): void
- {
- // Only enqueue if we're on a page that needs maps
- if (!$this->shouldEnqueueMaps()) {
- return;
- }
-
- if (!$this->isSetUp()) {
- return;
- }
-
- $this->ensureInitialized();
-
// Register Google Maps API
wp_register_script(
'google-maps-api',
@@ -499,36 +135,69 @@
'strategy' => 'defer'
]
);
+ }
+ public function getJavaScriptApiUrl(array $libraries = ['places', 'marker', 'geometry']): string
+ {
+ if (empty($this->getPublicKey())) {
+ return '';
+ }
+
+ $params = [
+ 'key' => $this->getPublicKey(),
+ 'libraries' => implode(',', $libraries),
+ 'callback' => 'initGoogleMaps',
+ ];
+
+ return $this->apiBase . 'js?' . http_build_query($params);
+ }
+
+
+ public function maybeEnqueueMapsScript():void
+ {
+ if (!$this->isSetUp()) {
+ return;
+ }
+ if (!$this->shouldEnqueueMaps()) {
+ return;
+ }
// Add initialization script
- wp_add_inline_script('google-maps-api', '
- // Set map defaults
- window.jvbMapDefaults = {
- lat: ' . self::DEFAULT_LAT . ',
- lng: ' . self::DEFAULT_LNG . ',
- zoom: ' . self::DEFAULT_ZOOM . ',
- mapId: "' . esc_js($this->map_id) . '"
- };
+ wp_add_inline_script('google-maps-api',
+ sprintf(
+ '
+ // Set map defaults
+ window.jvbMapDefaults = {
+ lat: %s,
+ lng: %s,
+ zoom: %s,
+ mapId: "%s"
+ };
- // Google Maps initialization callback
- window.initGoogleMaps = function() {
- // Dispatch ready event
- document.dispatchEvent(new Event("googleMapsReady"));
+ // Google Maps initialization callback
+ window.initGoogleMaps = function() {
+ // Dispatch ready event
+ document.dispatchEvent(new Event("googleMapsReady"));
- // Call any registered callbacks
- if (window.googleMapsCallbacks && Array.isArray(window.googleMapsCallbacks)) {
- window.googleMapsCallbacks.forEach(function(callback) {
- if (typeof callback === "function") {
- callback();
+ // Call any registered callbacks
+ if (window.googleMapsCallbacks && Array.isArray(window.googleMapsCallbacks)) {
+ window.googleMapsCallbacks.forEach(function(callback) {
+ if (typeof callback === "function") {
+ callback();
+ }
+ });
+ window.googleMapsCallbacks = [];
}
- });
- window.googleMapsCallbacks = [];
- }
- };
+ };
- // Initialize callback queue
- window.googleMapsCallbacks = window.googleMapsCallbacks || [];
- ', 'before');
+ // Initialize callback queue
+ window.googleMapsCallbacks = window.googleMapsCallbacks || [];
+ ',
+ $this->getDefaultLat(),
+ $this->getDefaultLng(),
+ $this->getDefaultZoom(),
+ esc_js($this->getMapID())
+ ),
+ 'before');
// Enqueue scripts
wp_enqueue_script('google-maps-api');
@@ -541,134 +210,53 @@
'service' => $this->service_name
]);
}
-
- /**
- * Check if maps should be enqueued on current page
- */
- private function shouldEnqueueMaps(): bool
- {
- // Check for shortcodes
- if (is_singular() && get_post_meta(get_the_ID(), BASE . 'has_map', true) === true) {
- return true;
- }
-
- if (!empty(Registrar::withFeature('is_content', 'term')) && get_term_meta(get_queried_object_id(), BASE . 'has_map', true) === true) {
- return true;
- }
-
- // Check for specific page templates or post types
- if (is_singular(['shop', 'artist', 'location'])) {
- return true;
- }
-
- // Check for admin pages that need maps
- if (is_admin()) {
- $screen = get_current_screen();
- if ($screen && in_array($screen->post_type, ['shop', 'artist', 'location'])) {
+ protected function shouldEnqueueMaps():bool
+ {
+ //See if it's a singular post/page that has the map feature flagged
+ if (is_singular() && get_post_meta(get_the_ID(), BASE.'has_map', true) === true) {
return true;
}
+ if (is_tax() && get_term_meta(get_queried_object_id(), BASE . 'has_map', true) === true) {
+ return true;
+ }
+
+ // Allow filtering
+ return apply_filters('jvb_should_enqueue_maps', false);
}
- // Allow filtering
- return apply_filters('jvb_should_enqueue_maps', false);
- }
-
- /**
- * Parse address components from Google response
- */
- private function parseAddressComponents(array $components): array
+ public function getServiceDescription():string
{
- $parsed = [
- 'street_number' => '',
- 'street' => '',
- 'city' => '',
- 'province' => '',
- 'country' => '',
- 'postal_code' => ''
- ];
-
- foreach ($components as $component) {
- $types = $component['types'];
-
- if (in_array('street_number', $types)) {
- $parsed['street_number'] = $component['long_name'];
- }
- if (in_array('route', $types)) {
- $parsed['street'] = $component['long_name'];
- }
- if (in_array('locality', $types)) {
- $parsed['city'] = $component['long_name'];
- }
- if (in_array('administrative_area_level_1', $types)) {
- $parsed['province'] = $component['short_name'];
- }
- if (in_array('country', $types)) {
- $parsed['country'] = $component['short_name'];
- }
- if (in_array('postal_code', $types)) {
- $parsed['postal_code'] = $component['long_name'];
- }
- }
-
- // Combine street number and street name
- if ($parsed['street_number'] && $parsed['street']) {
- $parsed['street'] = $parsed['street_number'] . ' ' . $parsed['street'];
- }
-
- return $parsed;
+ return 'Allows for a map field, utilizing Google Maps';
}
-
- /**
- * Get Map ID
- */
- public function getMapId(): string
+ /**********************************************************************
+ * UTILITY
+ **********************************************************************/
+ protected function getPrivateKey():string|false
{
- return $this->map_id;
+ return $this->loadCredentials()['private_key']??false;
}
-
- /**
- * Get default map settings
- */
- public function getDefaultMapSettings(): array
+ protected function getPublicKey():string|false
{
- return [
- 'center' => [
- 'lat' => self::DEFAULT_LAT,
- 'lng' => self::DEFAULT_LNG
- ],
- 'zoom' => $this->credentials['default_zoom'] ?? self::DEFAULT_ZOOM,
- 'mapId' => $this->map_id,
- 'styles' => $this->getMapStyles()
- ];
+ return $this->loadCredentials()['public_key']??false;
}
- /**
- * Get map styles based on theme
- */
- private function getMapStyles(): array
+ protected function getMapID():string|false
{
- $theme = $this->credentials['theme'] ?? 'default';
-
- switch ($theme) {
- case 'dark':
- return [
- ['elementType' => 'geometry', 'stylers' => [['color' => '#242f3e']]],
- ['elementType' => 'labels.text.fill', 'stylers' => [['color' => '#746855']]],
- ['elementType' => 'labels.text.stroke', 'stylers' => [['color' => '#242f3e']]],
- ['featureType' => 'road', 'elementType' => 'geometry', 'stylers' => [['color' => '#38414e']]],
- ['featureType' => 'water', 'elementType' => 'geometry', 'stylers' => [['color' => '#17263c']]]
- ];
-
- case 'minimal':
- return [
- ['featureType' => 'poi', 'stylers' => [['visibility' => 'off']]],
- ['featureType' => 'transit', 'stylers' => [['visibility' => 'off']]]
- ];
-
- default:
- return [];
- }
+ return $this->loadCredentials()['map_id']??false;
}
+ protected function getDefaultZoom():string|false
+ {
+ return $this->loadCredentials()['default_zoom']??false;
+ }
+ protected function getDefaultLng():string|false
+ {
+ return $this->loadCredentials()['default_lng']??false;
+ }
+ protected function getDefaultLat():string|false
+ {
+ return $this->loadCredentials()['default_lat']??false;
+ }
+
/**
* Generate map application links
@@ -683,152 +271,108 @@
'waze' => "https://waze.com/ul?ll={$lat},{$lng}&navigate=yes"
];
}
-
- /**
- * Render a frontend display map
- */
- public function renderDisplayMap(array $location, array $options = []): string
+ private function parseAddressComponents(array $components):array
{
- if (!$this->isSetUp() || empty($location['lat']) || empty($location['lng'])) {
- return '';
- }
-
- $map_id = 'map-' . uniqid();
- $lat = (float)$location['lat'];
- $lng = (float)$location['lng'];
- $address = $location['address'] ?? '';
-
- $map_options = array_merge([
- 'height' => '250px',
- 'zoom' => 15,
- 'show_marker' => true,
- 'show_info_window' => true,
- 'info_content' => $address,
- 'interactive' => true
- ], $options);
-
- ob_start();
- ?>
- <div id="<?= esc_attr($map_id); ?>"
- class="jvb-location-map-display"
- style="height: <?= esc_attr($map_options['height']); ?>; border-radius: 8px; overflow: hidden;">
- </div>
-
- <script>
- document.addEventListener('DOMContentLoaded', function() {
- function initDisplayMap() {
- if (!window.jvbMaps || !window.jvbMaps.ready) {
- setTimeout(initDisplayMap, 100);
- return;
- }
-
- window.jvbMaps.createDisplayMap('<?= esc_js($map_id); ?>', {
- lat: <?= $lat; ?>,
- lng: <?= $lng; ?>,
- zoom: <?= intval($map_options['zoom']); ?>,
- show_marker: <?= $map_options['show_marker'] ? 'true' : 'false'; ?>,
- show_info_window: <?= $map_options['show_info_window'] ? 'true' : 'false'; ?>,
- info_content: <?= json_encode($map_options['info_content']); ?>,
- interactive: <?= $map_options['interactive'] ? 'true' : 'false'; ?>
- });
+ $parsed = [];
+ $check = [
+ 'street_number' => 'long_name',
+ 'street' => 'long_name',
+ 'city' => 'long_name',
+ 'province' => 'short_name',
+ 'country' => 'short_name',
+ 'postal_code' => 'long_name'
+ ];
+ foreach ($components as $c) {
+ $types = $c['types'];
+ foreach ($check as $ch => $index) {
+ if (in_array($ch, $types)) {
+ $parsed[$ch] = $c[$index];
}
-
- if (typeof google !== 'undefined' && google.maps) {
- initDisplayMap();
- } else {
- document.addEventListener('googleMapsReady', initDisplayMap);
- }
- });
- </script>
- <?php
-
- return ob_get_clean();
- }
-
- /**
- * Render map application links
- */
- public function renderMapLinks(float $lat, float $lng, ?string $address = null, array $options = []): string
- {
- $links = $this->generateMapLinks($lat, $lng, $address);
-
- $link_options = array_merge([
- 'show_icons' => true,
- 'style' => 'buttons', // 'buttons' or 'text'
- 'include' => ['google', 'apple'], // which links to show
- 'target' => '_blank'
- ], $options);
-
- $output = '<div class="jvb-map-links' . ($link_options['style'] === 'buttons' ? ' button-style' : ' text-style') . '">';
-
- foreach ($link_options['include'] as $provider) {
- if (!isset($links[$provider])) continue;
-
- $icon = '';
- $label = '';
-
- switch ($provider) {
- case 'google':
- $icon = $link_options['show_icons'] ? jvbIcon('google') : '';
- $label = 'Google Maps';
- break;
- case 'apple':
- $icon = $link_options['show_icons'] ? jvbIcon('apple') : '';
- $label = 'Apple Maps';
- break;
- case 'waze':
- $icon = $link_options['show_icons'] ? jvbIcon('waze') : '';
- $label = 'Waze';
- break;
}
-
- $output .= sprintf(
- '<a href="%s" target="%s" class="map-link %s-maps-link">%s<span>%s</span></a>',
- esc_url($links[$provider]),
- esc_attr($link_options['target']),
- esc_attr($provider),
- $icon,
- esc_html($label)
- );
}
-
- $output .= '</div>';
-
- return $output;
+ if (!empty($parsed['street_number']) && !empty($parsed['street'])) {
+ $parsed['street'] = $parsed['street_number'] . ' ' . $parsed['street'];
+ }
+ return $parsed;
}
- /**
- * Format address for display
- */
- public function formatAddress(array $location, string $format = 'full'): string
+ public function reverseGeocode(float $lat, float $lng):array|false
{
- if (!empty($location['address'])) {
- return $location['address'];
+ if (!$this->isSetUp()) {
+ return false;
+ }
+ $params = [
+ 'latlng' => "{$lat},{$lng}",
+ 'key' => $this->getPrivateKey()
+ ];
+ return $this->getRequest(
+ sprintf(
+ '%s/geocode/json',
+ $this->apiBase,
+ ),
+ $params
+ );
+ }
+ protected function handleGeocodeResponse(array $data, string $method):array
+ {
+ if (!array_key_exists('status', $data) || $data['status'] !== 'OK' || !array_key_exists('results', $data) || empty($data['results'])) {
+ return $this->response(false, 'Invalid response provided');
+ }
+ $result = $data['results'][0];
+ return $this->response(true, 'Successfully reversed geocode',[
+ 'lat' => $result['geometry']['location']['lat'],
+ 'lng' => $result['geometry']['location']['lng'],
+ 'formatted_address' => $result['formatted_address'],
+ 'place_id' => $result['place_id']??'',
+ 'components' => $this->parseAddressComponents($result['address_compenents']??[])
+ ]);
}
- // Build address from components if no formatted address
- $parts = [];
+ public function geocodeAddress(string $address):array|false
+ {
+ if (!$this->isSetUp()) {
+ return false;
+ }
+ $params = [
+ 'address' => $address,
+ 'key' => $this->getPrivateKey()
+ ];
+ return $this->getRequest(
+ sprintf(
+ '%s/geocode/json',
+ $this->apiBase
+ )
+ );
+ }
- switch ($format) {
- case 'full':
- if (!empty($location['street'])) $parts[] = $location['street'];
- if (!empty($location['city'])) $parts[] = $location['city'];
- if (!empty($location['province'])) $parts[] = $location['province'];
- if (!empty($location['postal_code'])) $parts[] = $location['postal_code'];
- if (!empty($location['country'])) $parts[] = $location['country'];
- break;
+ protected function getRequestHeaders(): array
+ {
+ return [
+ 'Accept' => 'application/json',
+ 'Content-Type' => 'application/json'
+ ];
+ }
- case 'compact':
- if (!empty($location['city']) && !empty($location['province'])) {
- $parts[] = $location['city'] . ', ' . $location['province'];
- }
- break;
+ protected function handleResponse(\WP_Error|array $response, string $method, string $endpoint): array
+ {
+ if (is_wp_error($response) && $response->get_error_message() === 'rate_limit_exceeded') {
+ return $this->response(false, 'Rate limit exceeded');
+ }
+ $endpoint = str_replace($this->apiBase, '', $endpoint);
+ return match ($endpoint) {
+ '/geocode/json' => $this->handleGeocodeResponse($response, $method),
+ default => $this->response(false, 'Unhandled response for '.$endpoint.' endpoint, with method: '.$method),
+ };
+ }
- case 'city_only':
- if (!empty($location['city'])) $parts[] = $location['city'];
- break;
+ protected function performConnectionTest():bool
+ {
+ if (!$this->isSetUp()) {
+ return false;
}
- return implode(', ', $parts);
+ $test = 'Edmonton, Alberta, Canada';
+ $result = $this->geocodeAddress($test);
+ return $result['success'];
}
}
diff --git a/inc/integrations/services/GoogleMyBusiness.php b/inc/integrations/services/GoogleMyBusiness.php
index 157181e..89f4f58 100644
--- a/inc/integrations/services/GoogleMyBusiness.php
+++ b/inc/integrations/services/GoogleMyBusiness.php
@@ -1,7 +1,15 @@
<?php
namespace JVBase\integrations\services;
+use Exception;
+use JVBase\integrations\Admin;
+use JVBase\integrations\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\UserConnection;
+
if (!defined('ABSPATH')) {
exit;
@@ -9,2966 +17,237 @@
class GoogleMyBusiness extends Integrations
{
- protected array $allowedContent = [
- 'menu_item',
- 'post',
- 'event',
- ];
- private ?string $access_token = null;
+ use Admin, Instance, OAuth, Requests, UserConnection;
+
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 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->defineFields();
+ $this->defineInstructions();
+ $this->defineOAuth();
+ $this->defineActions();
- $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' => [
+ 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.');
+
+ $secret = $this->addField(
+ 'client_secret',
+ 'OAuth Client Secret',
+ 'text',
+ 'manage_options',
+ );
+ $secret->setSubType('password');
+ $secret->setRequired();
+
+ 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'
+ ];
+ }
+ 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
+ {
+ $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,
- 'Content-Type' => 'application/json',
- 'Accept' => 'application/json'
- ];
+ // TODO: Implement getRequestHeaders() method.
}
- 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('https://mybusinessbusinessinformation.googleapis.com/v1/%s/locations', $this->getAccountID()??'') => $this->handleGetLocationsResponse($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;
+ // TODO: Implement refreshAccessToken() method.
}
- public function handleTheSavePost(int $postID, \WP_Post $post, bool $update, array $settings):void
+ /*********************************************************
+ * UTILITY
+ *********************************************************/
+ protected function getAccountID():string|false
{
-
- if (!$this->hasOAuthCredentials()) {
- error_log('OAuth Not set up for '.$this->service_name);
- return;
- }
-
- $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');
+ return $this->loadCredentials()['account_id']??false;
}
-
- public function processOperation(\WP_Error|array $result, object $operation, array $data):\WP_Error|array
+ protected function getAccountOptions(bool $force = false):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()
- ];
+ $stored = $this->loadCredentials()['accounts']??[];
+ if ($force || empty($stored)) {
+ $stored = $this->getAccounts();
}
+ return $stored;
}
- protected function syncEventPost(object $operation, array $data):\WP_Error|array
+ protected function getLocationID():string|false
{
- $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
- ];
+ return $this->loadCredentials()['location_id']??false;
}
-
- protected function syncOfferPost(object $operation, array $data):\WP_Error|array
+ protected function getLocationOptions(bool $force = false):array
{
- $location = $this->getSelectedLocationResourceName();
-
- if (!$location) {
- return [
- 'success' => false,
- 'result' => 'No location Name set.'
- ];
+ $stored = $this->loadCredentials()['locations']??[];
+ if ($force || empty($stored)) {
+ $stored = $this->getLocations();
}
-
- $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
- ];
+ return $stored;
}
-
-
- protected function syncStandardPost(object $operation, array $data):\WP_Error|array
+ protected function getLocations():array|false
{
- $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'
- ]);
+ if (!$this->getAccessToken() || !$this->getAccountID()) {
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",
+ return $this->getRequest(
+ sprintf(
+ '/v1/%s/locations',
+ $this->getAccountID()
+ ),
[
- 'orderBy' => 'updateTime desc'
- ],
- 'v4'
+ 'readMask' => $this->readMask
+ ]
);
-
- $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());
+ } catch (Exception $e)
+ {
+ $this->logError('getLocations', 'Could not get locations: '.$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);
+ 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 [
- '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;
+ return $locations;
}
- $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
+ protected function getAccounts(bool $force = false):array|false
{
- 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'])) {
+ if (!$this->getAccessToken()) {
return false;
}
- return ($location['name'] === $this->credentials['location']);
- }
-
- /***********************************************************
- UTILITY
- ***********************************************************/
- /**
- * Helper method to strip HTML tags from text
- * Preserves plain text content while removing all HTML formatting
- */
- protected function stripHtmlTags(string $text): string
- {
- // Remove HTML tags
- $text = wp_strip_all_tags($text);
-
- // Decode HTML entities
- $text = html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8');
-
- // Clean up whitespace
- $text = preg_replace('/\s+/', ' ', $text);
-
- // Trim
- return trim($text);
- }
- /**
- * Format date for GMB API
- */
- protected function formatDate(string $date): array
- {
- $timestamp = strtotime($date);
- return [
- 'year' => (int)date('Y', $timestamp),
- 'month' => (int)date('n', $timestamp),
- 'day' => (int)date('j', $timestamp)
- ];
- }
- /**
- * Format time for Google My Business API (HH:MM format)
- */
- private function formatTime(string $time): ?array
- {
- if (empty($time)) {
- return null;
- }
-
- // Remove any extra whitespace
- $time = trim($time);
-
- // Handle different time formats and convert to 24-hour format
- if (preg_match('/^(\d{1,2}):(\d{2})(?:\s*(AM|PM))?$/i', $time, $matches)) {
- $hour = (int)$matches[1];
- $minute = (int)$matches[2];
- $ampm = isset($matches[3]) ? strtoupper($matches[3]) : '';
-
- // Convert 12-hour to 24-hour format if needed
- if ($ampm === 'PM' && $hour !== 12) {
- $hour += 12;
- } elseif ($ampm === 'AM' && $hour === 12) {
- $hour = 0;
- }
-
- // Validate hour and minute ranges
- if ($hour >= 0 && $hour <= 23 && $minute >= 0 && $minute <= 59) {
- return [
- 'hours' => $hour,
- 'minutes' => $minute
- ];
- }
- }
-
- error_log('[GMB] Invalid time format: ' . $time);
- return null;
- }
-
-
-
- /*****************************************************************************
- *
- * OAUTH IMPLEMENTATION
- *
- *****************************************************************************/
- private function getScopes(): string
- {
- return implode(' ', [
- 'https://www.googleapis.com/auth/business.manage',
- 'https://www.googleapis.com/auth/userinfo.email',
- 'https://www.googleapis.com/auth/userinfo.profile'
- ]);
- }
- /**
- * Handle account selection
- */
- public function handleSelectAccount($data = null): array
- {
- try {
- $account_name = null;
-
- // Extract account name from various sources
- if (is_array($data)) {
- $account_name = $data['account'] ?? $data['account'] ?? null;
- } elseif (isset($_POST['account'])) {
- $account_name = sanitize_text_field($_POST['account']);
- }
-
- if (empty($account_name)) {
- return [
- 'success' => false,
- 'message' => 'No Account Selected'
- ];
- }
-
- // Get account details
- $accounts = $this->getAccounts();
- $selected_account = null;
-
- foreach ($accounts as $account) {
- if ($account['name'] === $account_name) {
- $selected_account = $account;
- break;
- }
- }
-
- if (!$selected_account) {
- throw new \Exception('Invalid account selected');
- }
-
- // Update credentials with selected account
- $this->credentials['account'] = $account_name;
- $this->credentials['selected_account_id'] = $selected_account['accountId'] ?? '';
-
- $this->saveCredentials($this->credentials);
-
- return [
- 'success' => true,
- 'message' => 'Account selected successfully',
- 'reload' => true
- ];
-
- } catch (\Exception $e) {
- $this->logError('Failed to select account', [
- 'error' => $e->getMessage(),
- 'data' => $data
- ]);
-
- return [
- 'success' => false,
- 'message' => $e->getMessage()
- ];
- }
- }
-
- /**
- * Handle location selection
- */
- public function handleSelectLocation($data = null): array
- {
- try {
- $location_name = null;
-
- // Extract location name from various sources
- if (is_array($data)) {
- $location_name = $data['location'] ?? $data['location'] ?? null;
- } elseif (isset($_POST['location'])) {
- $location_name = sanitize_text_field($_POST['location']);
- }
-
- if (empty($location_name)) {
- throw new \Exception('No location selected');
- }
-
- // Verify this is a valid location for the selected account
- if (empty($this->credentials['account'])) {
- throw new \Exception('No account selected. Please select an account first.');
- }
-
- $locations = $this->getLocations($this->credentials['account']);
- $valid_location = false;
-
- foreach ($locations as $location) {
- if ($location['name'] === $location_name) {
- $valid_location = true;
- $this->credentials['location_id'] = $location['locationId'] ?? '';
- break;
- }
- }
-
- if (!$valid_location) {
- throw new \Exception('Invalid location selected');
- }
-
- // Update credentials with selected location
- $this->credentials['location'] = $location_name;
-
- // Save updated credentials
- $this->saveCredentials($this->credentials);
-
- // Initialize with new location
- $this->credentials['location'] = $location_name;
-
- return [
- 'success' => true,
- 'message' => 'Location selected successfully',
- 'reload' => false // No need to reload for location selection
- ];
-
- } catch (\Exception $e) {
- $this->logError('Failed to select location', [
- 'error' => $e->getMessage(),
- 'data' => $data
- ]);
-
- return [
- 'success' => false,
- 'message' => $e->getMessage()
- ];
- }
- }
-
- /**
- * Check OAuth status
- */
- public function checkOAuthStatus(): array
- {
- try {
- $valid = $this->isOAuthValid();
-
- return [
- 'success' => true,
- 'valid' => $valid,
- 'expires_at' => $this->credentials['expires_at'] ?? null,
- 'has_refresh_token' => !empty($this->credentials['refresh_token'])
- ];
-
- } catch (\Exception $e) {
- return [
- 'success' => false,
- 'valid' => false,
- 'message' => $e->getMessage()
- ];
- }
- }
-
-
- /********************************************************************
- *
- * ADMIN UI
- *
- ********************************************************************/
- /**
- * Updated renderOAuthConnectedOptions for better UI
- */
- protected function renderOAuthConnectedOptions(): void
- {
- if (!$this->isSetUp()) {
- return;
- }
-
- // Check OAuth status
- $oauth_valid = $this->isOAuthValid();
-
- if (!$oauth_valid) {
- ?>
- <div class="oauth-status">
- <span class="status-invalid">⚠️ OAuth token expired - please reconnect</span>
- </div>
- <?php
- }
-
- // Show account selection
- $accounts = $this->getAccounts();
- if (!empty($accounts)) {
- ?>
- <div class="form-field" data-service="gmb">
- <label for="gmb_account">Google Business Account</label>
- <select name="account"
- id="gmb_account"
- class="jvb-ajax-update"
- data-action="select_account">
- <option value="">Select an account...</option>
- <?php foreach ($accounts as $account): ?>
- <option value="<?php echo esc_attr($account['name']); ?>"
- <?php selected($credentials['account'] ?? '', $account['name']); ?>>
- <?php echo esc_html($account['accountName'] ?? $account['name']); ?>
- </option>
- <?php endforeach; ?>
- </select>
- </div>
- <?php
-
- // Show location selection if account is selected
- if (!empty($this->credentials['account'])) {
- $locations = $this->getLocations($this->credentials['account']);
-
- if (!empty($locations)) {
- ?>
- <div class="form-field location-field visible" data-service="gmb">
- <label for="gmb_location">Business Location</label>
- <select name="location"
- id="gmb_location"
- class="jvb-ajax-update"
- data-action="select_location">
- <option value="">Select a location...</option>
- <?php foreach ($locations as $location): ?>
- <option value="<?php echo esc_attr($location['name']); ?>"
- <?php selected($this->credentials['location'] ?? '', $location['name']); ?>>
- <?php echo esc_html($location['title'] ?? $location['name']); ?>
- </option>
- <?php endforeach; ?>
- </select>
- </div>
- <?php
- }
- }
-
- // Show current selection status
- if (!empty($this->credentials['location'])) {
- ?>
- <div class="connection-status connected">
- ✅ Connected to: <?php echo esc_html($this->credentials['location']); ?>
- </div>
- <?php
- }
- } else {
- ?>
- <div class="notice notice-warning">
- <p>No Google Business accounts found. Please ensure your Google account has access to Google My Business.</p>
- </div>
- <?php
- }
- }
-
- public function getServiceDescription(): string
- {
- return "Manage your Google My Business listings, posts, hours, and reviews.";
- }
-
- /*****************************************************************************
- *
- * FOOD MENU IMPLEMENTATION
- *
- *****************************************************************************/
-
- /**
- * Get food menus for the current location
- */
- public function getFoodMenus(): ?array
- {
- if (!$this->credentials['location']) {
- throw new \Exception('No location selected. Please select a location first.');
- }
-
- $response = $this->getRequest(
- "/v4/{$this->credentials['location']}/foodMenu",
- [],
- 'posts'
- );
- return $response['foodMenus'] ?? [];
- }
-
- /**
- * Update food menus for the current location
- */
- public function updateFoodMenus(array $menus): bool
- {
- if (!$this->credentials['location']) {
- throw new \Exception('No location selected. Please select a location first.');
- }
-
- try {
- $food_menus = ['menus' => $menus];
-
- $endpoint = "/{$this->credentials['account']}/{$this->credentials['location']}/foodMenus";
-
- // Use dedicated Food Menu API
- $response = $this->makeRequest(
- 'PATCH',
- $endpoint,
- $food_menus,
- 'v4'
- );
-
- $this->logDebug('[GMB]Finished updating food menu', [
- 'method' => 'updateFoodMenus',
- 'response' => $response,
- ]);
- return !empty($response);
-
- } catch (\Exception $e) {
- $this->logError($e->getMessage(), [
- 'method' => 'updateFoodMenus'
- ]);
- return false;
- }
- }
-
- /**
- * Upload menu images and return a map of identifiers to mediaKeys
- */
- protected function uploadAndMapMenuImages(array $menus): array
- {
- $mediaKeyMap = [];
-
- // Process all menu items for images
- foreach ($menus as $menu) {
- foreach ($menu['sections'] ?? [] as $section) {
- // Upload section images if needed (though API doesn't support section images yet)
- if (!empty($section['image']) && is_numeric($section['image'])) {
- $mediaKey = $this->uploadImageToGMB($section['image'], 'FOOD_AND_DRINK');
- if ($mediaKey) {
- $mediaKeyMap['section_' . $section['id']] = $mediaKey;
- }
- }
-
- // Process menu items
- foreach ($section['items'] ?? [] as $item) {
- // Upload featured image
- if (!empty($item['post_id'])) {
- $featured_image_id = get_post_thumbnail_id($item['post_id']);
- if ($featured_image_id) {
- $mediaKey = $this->uploadImageToGMB($featured_image_id, 'FOOD_AND_DRINK');
- if ($mediaKey) {
- $mediaKeyMap['item_' . $item['post_id']] = $mediaKey;
- }
- }
-
- // Upload gallery images if available
- if (!empty($item['gallery_ids'])) {
- foreach ($item['gallery_ids'] as $gallery_id) {
- $mediaKey = $this->uploadImageToGMB($gallery_id, 'FOOD_AND_DRINK');
- if ($mediaKey) {
- $mediaKeyMap['gallery_'.$gallery_id] = $mediaKey;
- }
- }
- }
- }
- }
- }
- }
-
- return $mediaKeyMap;
- }
-
- /**
- * Extract mediaKey from the full media resource name
- */
- protected function extractMediaKey(string $mediaResourceName): string
- {
- // Extract mediaKey from format: accounts/{accountId}/locations/{locationId}/media/{mediaKey}
- $parts = explode('/media/', $mediaResourceName);
- return isset($parts[1]) ? $parts[1] : '';
- }
-
- /**
- * Get all media items for the current location
- */
- public function getLocationMedia(): array
- {
- if (!$this->credentials['location']) {
- throw new \Exception('No location selected. Please select a location first.');
- }
-
- try {
- $response = $this->getRequest(
- "/{$this->credentials['account']}/{$this->credentials['location']}/media",
- [],
- 'v4'
- );
-
- return $response['mediaItems'] ?? [];
- } catch (\Exception $e) {
- $this->logError('Failed to get location media: ' . $e->getMessage());
- return [];
- }
- }
-
- public function handleGetAllLocations():WP_Error|array
- {
- try {
- // Check if we should force refresh
- $force = isset($_POST['force']) && $_POST['force'] === 'true';
-
- // Get accounts (use cache unless forced)
- $accounts = $this->getAccounts($force);
- $all_locations = [];
-
- foreach ($accounts as $account) {
- // Get locations for each account (use cache unless forced)
- $locations = $this->getLocations($account['name'], $force);
- $all_locations = array_merge($all_locations, $locations);
- }
-
- return [
- 'success' => true,
- 'locations' => $all_locations,
- 'cached' => !$force
- ];
-
- } catch (\Exception $e) {
- return new WP_Error('failure', 'Something went wrong: '.$e->getMessage());
- }
- }
-
- /**
- * AJAX handler for updating selected location
- */
- public function handleUpdateLocation($data = null): array
- {
- try {
- // Handle both AJAX and REST API calls
- $selected_account = null;
-
- if (is_array($data)) {
- $selected_account = $data['account'] ?? $data['location'] ?? null;
- } elseif (isset($_POST['account'])) {
- $selected_account = sanitize_text_field($_POST['account']);
- }
-
- if (empty($selected_account)) {
- throw new \Exception('No account selected');
- }
-
- // Save the selected account
- $credentials = $this->credentials;
- $credentials['account'] = $selected_account;
-
- // Get locations for this account
- $locations = $this->getLocations($selected_account);
-
- // Auto-select first location if only one exists
- if (count($locations) === 1) {
- $credentials['location'] = $locations[0]['name'];
- }
-
- $this->saveCredentials($credentials);
-
- return [
- 'success' => true,
- 'message' => 'Account selected successfully',
- 'locations' => $locations,
- 'reload' => true // Trigger page refresh to show location dropdown
- ];
-
- } catch (\Exception $e) {
- $this->logError('Failed to update location', [
- 'error' => $e->getMessage(),
- 'data' => $data
- ]);
-
- return [
- 'success' => false,
- 'message' => $e->getMessage()
- ];
- }
- }
-
- public function clearStoredData(): bool
- {
- try {
- // Use the static method to clear the entire cache group
- $this->cache->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'
+ '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
- {
- $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->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']
- ]
- ];
- }
-
-
}
diff --git a/inc/integrations/services/Helcim.php b/inc/integrations/services/Helcim.php
index 355e9c2..6c41266 100644
--- a/inc/integrations/services/Helcim.php
+++ b/inc/integrations/services/Helcim.php
@@ -1,6 +1,8 @@
<?php
namespace JVBase\integrations\services;
+use JVBase\integrations\Admin;
+use JVBase\integrations\Instance;
use JVBase\integrations\Integrations;
use JVBase\managers\queue\executors\IntegrationExecutor;
use JVBase\managers\queue\TypeConfig;
@@ -24,1336 +26,10 @@
*/
class Helcim extends Integrations
{
- protected static string $syncCustomer = 'helcim_sync_customer';
- protected string $service_name = 'helcim';
- protected string|array $apiBase = 'https://api.helcim.com/v2';
-
- protected bool $isOAuthService = false;
-
- /**
- * Helcim API rate limits
- * @see https://devdocs.helcim.com/docs/api-rate-limits
- */
- protected array $rate_limits = [
- 'per_second' => 5,
- 'per_minute' => 60,
- 'per_hour' => 1000
- ];
-
- 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)
- {
- $this->title = 'Helcim';
- $this->icon = 'credit-card';
-
- $this->fields = [
- 'api_token' => [
- 'type' => 'text',
- 'subtype' => 'password',
- 'label' => 'API Token',
- 'hint' => 'Found in Helcim Dashboard → Settings → API Access',
- 'required' => true,
- ],
- 'currency' => [
- 'type' => 'select',
- 'label' => 'Currency',
- 'options' => [
- 'CAD' => 'CAD',
- 'USD' => 'USD',
- ],
- 'default' => 'CAD',
- ],
- ];
-
- $this->advanced = [
- 'fee_saver' => [
- 'type' => 'true_false',
- 'label' => 'Fee Saver',
- 'hint' => 'Pass processing fees to customers (not compatible with Google Pay)',
- ],
- 'allow_ach' => [
- 'type' => 'true_false',
- 'label' => 'Allow ACH/Bank Payments',
- 'hint' => 'Enable bank account payments alongside credit card',
- ],
- ];
-
- $this->instructions = [
- 'Go to <a href="https://my.helcim.com" target="_blank">Helcim Dashboard</a>',
- 'Navigate to Settings → API Access',
- 'Create a new API Access Configuration',
- 'Enable permissions: General (Customers, Invoices, Products), Transaction Processing',
- 'Copy the API Token and paste it below',
- ];
-
- $this->canSync = [
- 'create' => true,
- 'update' => true,
- 'delete' => false,
- ];
-
- $this->hasWebhooks = false;
-
- parent::__construct();
-
- // Helcim-specific actions (processAction dispatches these)
- $this->actions = array_merge($this->actions, [
- 'initialize_checkout' => 'initializeCheckout',
- 'get_invoices' => 'handleGetInvoices',
- 'get_invoice' => 'handleGetInvoice',
- 'get_customer_cards' => 'handleGetCustomerCards',
- ]);
-
- $this->buttons = array_merge($this->buttons, [
- 'import_from_helcim' => 'Import Products from Helcim',
- 'sync_to_helcim' => 'Sync Site to Helcim',
- ]);
- }
-
- /*****************************************************************
- * ABSTRACT IMPLEMENTATIONS
- *****************************************************************/
+ use Admin, Instance;
protected function initialize(): void
{
- if (empty($this->credentials)) {
- $this->loadCredentials();
- }
-
- $this->apiEndpoints = [
- 'connection-test',
- 'helcim-pay/initialize',
- 'invoices',
- 'customers',
- 'payment/purchase',
- 'payment/preauth',
- 'payment/capture',
- 'payment/refund',
- 'payment/verify',
- 'card-transactions',
- 'card-batches',
- ];
- }
-
- protected function getRequestHeaders(): array
- {
- return [
- 'api-token' => $this->credentials['api_token'] ?? '',
- 'Content-Type' => 'application/json',
- 'Accept' => 'application/json',
- ];
- }
-
- protected function performConnectionTest(): bool
- {
- try {
- $response = $this->getRequest('connection-test', [], null, 'none', true);
- return !is_wp_error($response) && !$this->isErrorResponse($response ?? []);
- } catch (Exception $e) {
- $this->logError('Connection test failed', ['error' => $e->getMessage()]);
- return false;
- }
- }
-
- /*****************************************************************
- * CONTENT TYPES — product field definitions
- *****************************************************************/
-
- protected function setContentTypes(): void
- {
- $types = ['REGULAR', 'SERVICE', 'DIGITAL', 'FOOD_AND_BEV', 'EVENT', 'SUBSCRIPTION', 'DONATION'];
-
- foreach ($types as $type) {
- $t = $type === 'REGULAR' ? null : $type;
- $this->contentTypes[$type] = $this->getHelcimMeta($t);
- }
- }
-
- public function getAdditionalFields(?string $content_type = null):array
- {
- if ($content_type === 'customer') {
- $array = $this->getCustomerFields();
- return array_combine(
- array_map(fn($k) => '_square_' . $k, array_keys($array)),
- $array
- );
- }
- return array_combine(
- array_map(fn($k) => 'hc_' . $k, array_keys($this->getHelcimMeta($content_type))),
- $this->getHelcimMeta($content_type)
- );
- }
-
- protected function getCustomerFields():array
- {
- return [];
- }
-
- /**
- * Get Helcim product meta fields by type.
- *
- * Used by Registrar.php when helcim is configured
- */
- public function getHelcimMeta(?string $type = null): array
- {
- $fields = [
- // Basic Product Fields
- 'price' => [
- 'type' => 'number',
- 'bulkEdit' => true,
- 'label' => 'Price',
- 'step' => 0.01,
- 'max' => 99999,
- 'description' => 'Product price'
- ],
-
- 'product_type' => [
- 'type' => 'select',
- 'label' => 'Product Type',
- 'options' => [
- 'REGULAR' => 'Regular Product',
- 'SERVICE' => 'Service',
- 'DIGITAL' => 'Digital Product',
- 'FOOD_AND_BEV' => 'Food & Beverage',
- 'EVENT' => 'Event/Ticket',
- 'SUBSCRIPTION' => 'Subscription',
- 'DONATION' => 'Donation'
- ],
- 'default' => $type ?? 'REGULAR'
- ],
-
- 'cart_quantity' => [
- 'type' => 'number',
- 'label' => 'Quantity',
- 'hidden' => true,
- ],
-
- // Tax & Shipping
- 'tax_exempt' => [
- 'type' => 'true_false',
- 'label' => 'Tax Exempt',
- 'section' => 'helcim-tax'
- ],
-
- 'shipping_required' => [
- 'type' => 'true_false',
- 'label' => 'Shipping Required',
- 'section' => 'helcim-shipping'
- ],
-
- 'shipping_weight' => [
- 'type' => 'number',
- 'label' => 'Shipping Weight (kg)',
- 'step' => 0.01,
- 'section' => 'helcim-shipping',
- 'condition' => [
- 'field' => 'shipping_required',
- 'operator' => '==',
- 'value' => true
- ]
- ],
-
- // Availability
- 'available_online' => [
- 'type' => 'true_false',
- 'label' => 'Available Online',
- 'section' => 'helcim-availability',
- 'default' => true
- ],
-
- 'available_for_pickup' => [
- 'type' => 'true_false',
- 'label' => 'Available for Pickup',
- 'section' => 'helcim-availability',
- 'default' => true
- ],
-
- 'available_for_delivery' => [
- 'type' => 'true_false',
- 'label' => 'Available for Delivery',
- 'section' => 'helcim-availability',
- 'default' => false
- ],
-
- '_helcim_sku' => [
- 'type' => 'text',
- 'label' => 'SKU',
- 'description' => 'Stock keeping unit',
- 'section' => 'helcim-config'
- ],
-
- // Product Variations
- 'product_variations' => [
- 'type' => 'repeater',
- 'label' => 'Product Variations',
- 'description' => 'Different versions of this product',
- 'add_label' => 'Add Variation',
- 'section' => 'variations',
- 'fields' => $this->getHelcimVariationMeta($type)
- ],
-
- // Product Options
- 'options' => [
- 'type' => 'group',
- 'label' => 'Product Options',
- 'section'=> 'helcim-options',
- 'fields' => [
- 'max_order' => [
- 'type' => 'number',
- 'label' => 'Maximum per order',
- 'default' => 50
- ],
- 'min_order' => [
- 'type' => 'number',
- 'label' => 'Minimum per order',
- 'default' => 0,
- ],
- 'step' => [
- 'type' => 'number',
- 'label' => 'Order increment',
- 'default' => 1,
- ],
- 'preparation_time' => [
- 'type' => 'number',
- 'label' => 'Preparation time (minutes)',
- 'description' => 'Time needed to prepare this item',
- 'condition' => [
- 'field' => 'product_type',
- 'operator' => 'in',
- 'value' => ['FOOD_AND_BEV', 'SERVICE']
- ]
- ]
- ]
- ],
-
- // Subscription Fields
- 'subscription_settings' => [
- 'type' => 'group',
- 'label' => 'Subscription Settings',
- 'section' => 'helcim-subscription',
- 'condition' => [
- 'field' => 'product_type',
- 'operator' => '==',
- 'value' => 'SUBSCRIPTION'
- ],
- 'fields' => [
- 'billing_cycle' => [
- 'type' => 'select',
- 'label' => 'Billing Cycle',
- 'options' => [
- 'daily' => 'Daily',
- 'weekly' => 'Weekly',
- 'monthly' => 'Monthly',
- 'quarterly' => 'Quarterly',
- 'yearly' => 'Yearly'
- ],
- 'default' => 'monthly'
- ],
- 'trial_period' => [
- 'type' => 'number',
- 'label' => 'Trial Period (days)',
- 'description' => 'Free trial period before billing starts',
- 'default' => 0
- ],
- 'setup_fee' => [
- 'type' => 'number',
- 'label' => 'Setup Fee',
- 'step' => 0.01,
- 'description' => 'One-time setup fee'
- ]
- ]
- ],
-
- // Food & Beverage Specific
- 'food_settings' => [
- 'type' => 'group',
- 'label' => 'Food & Beverage Settings',
- 'section' => 'helcim-food',
- 'condition' => [
- 'field' => 'product_type',
- 'operator' => '==',
- 'value' => 'FOOD_AND_BEV'
- ],
- 'fields' => [
- 'ingredients' => [
- 'type' => 'textarea',
- 'label' => 'Ingredients',
- 'description' => 'List ingredients (comma separated)'
- ],
- 'allergens' => [
- 'type' => 'checkbox_list',
- 'label' => 'Allergens',
- 'options' => [
- 'gluten' => 'Contains Gluten',
- 'dairy' => 'Contains Dairy',
- 'nuts' => 'Contains Nuts',
- 'soy' => 'Contains Soy',
- 'eggs' => 'Contains Eggs',
- 'seafood' => 'Contains Seafood'
- ]
- ],
- 'dietary_options' => [
- 'type' => 'checkbox_list',
- 'label' => 'Dietary Options',
- 'options' => [
- 'vegetarian' => 'Vegetarian',
- 'vegan' => 'Vegan',
- 'gluten_free' => 'Gluten Free',
- 'dairy_free' => 'Dairy Free',
- 'keto' => 'Keto Friendly',
- 'halal' => 'Halal',
- 'kosher' => 'Kosher'
- ]
- ],
- 'spice_level' => [
- 'type' => 'range',
- 'label' => 'Spice Level',
- 'min' => 0,
- 'max' => 5,
- 'default' => 0
- ],
- 'serving_size' => [
- 'type' => 'text',
- 'label' => 'Serving Size',
- 'description' => 'e.g., "Serves 2-3"'
- ]
- ]
- ],
-
- // Service Specific
- 'service_settings' => [
- 'type' => 'group',
- 'label' => 'Service Settings',
- 'section' => 'helcim-service',
- 'condition' => [
- 'field' => 'product_type',
- 'operator' => '==',
- 'value' => 'SERVICE'
- ],
- 'fields' => [
- 'service_duration' => [
- 'type' => 'number',
- 'label' => 'Duration (minutes)',
- 'description' => 'Service duration in minutes'
- ],
- 'booking_required' => [
- 'type' => 'true_false',
- 'label' => 'Booking Required'
- ],
- 'capacity' => [
- 'type' => 'number',
- 'label' => 'Service Capacity',
- 'description' => 'Maximum number of customers per service'
- ],
- 'staff_required' => [
- 'type' => 'number',
- 'label' => 'Staff Required',
- 'description' => 'Number of staff needed',
- 'default' => 1
- ]
- ]
- ],
-
- // Event Specific
- 'event_settings' => [
- 'type' => 'group',
- 'label' => 'Event Settings',
- 'section' => 'helcim-event',
- 'condition' => [
- 'field' => 'product_type',
- 'operator' => '==',
- 'value' => 'EVENT'
- ],
- 'fields' => [
- 'event_date' => [
- 'type' => 'datetime',
- 'label' => 'Event Date & Time'
- ],
- 'event_location' => [
- 'type' => 'text',
- 'label' => 'Event Location'
- ],
- 'max_attendees' => [
- 'type' => 'number',
- 'label' => 'Maximum Attendees'
- ],
- 'early_bird_price' => [
- 'type' => 'number',
- 'label' => 'Early Bird Price',
- 'step' => 0.01,
- 'description' => 'Discounted price for early registrations'
- ],
- 'early_bird_deadline' => [
- 'type' => 'date',
- 'label' => 'Early Bird Deadline'
- ]
- ]
- ]
- ];
-
- // Add inventory fields if configured
- if ($config['hasInventory'] ?? false) {
- $fields['_helcim_inventory'] = [
- 'type' => 'number',
- 'label' => 'Inventory',
- 'bulkEdit' => true,
- 'section' => 'inventory'
- ];
-
- $fields['track_inventory'] = [
- 'type' => 'true_false',
- 'label' => 'Track Inventory',
- 'section' => 'inventory',
- 'default' => true
- ];
-
- $fields['low_stock_threshold'] = [
- 'type' => 'number',
- 'label' => 'Low Stock Alert',
- 'description' => 'Alert when stock falls below this level',
- 'section' => 'inventory',
- 'default' => 5
- ];
-
- $fields['product_variations']['fields']['inventory'] = [
- 'type' => 'number',
- 'label' => 'Stock Quantity',
- 'description' => 'Current stock for this variation'
- ];
- }
-
- return $fields;
- }
-
- public function getHelcimVariationMeta(?string $type = null):array
- {
-
- $base = [
- 'name' => [
- 'type' => 'text',
- 'label' => 'Variation Name',
- 'description' => 'e.g., "Small", "Large", "Red"'
- ],
- 'price' => [
- 'type' => 'number',
- 'label' => 'Price',
- 'step' => 0.01,
- 'max' => 99999,
- 'description' => 'Price for this variation'
- ],
- 'sku' => [
- 'type' => 'text',
- 'label' => 'SKU',
- 'description' => 'Stock keeping unit for this variation'
- ],
- 'track_inventory' => [
- 'type' => 'true_false',
- 'label' => 'Track Inventory',
- ],
- '_helcim_variation_id' => [
- 'type' => 'text',
- 'label' => 'Helcim Variation ID',
- 'description' => 'Helcim ID for this variation',
- 'hidden' => true
- ],
- '_helcim_last_sync' => [
- 'type' => 'datetime',
- 'label' => 'Last Sync',
- 'hidden' => true
- ],
- 'options' => [
- 'type' => 'group',
- 'label' => 'Variation Options',
- 'collapsible' => true,
- 'fields' => [
- 'color' => [
- 'type' => 'color',
- 'label' => 'Color',
- 'description' => 'Visual color for this variation'
- ],
- 'size' => [
- 'type' => 'select',
- 'label' => 'Size',
- 'options' => [
- '' => 'N/A',
- 'xs' => 'Extra Small',
- 's' => 'Small',
- 'm' => 'Medium',
- 'l' => 'Large',
- 'xl' => 'Extra Large',
- 'xxl' => '2X Large',
- 'custom'=> 'Custom'
- ]
- ],
- 'custom_size' => [
- 'type' => 'text',
- 'label' => 'Custom Size',
- 'condition' => [
- 'field' => 'size',
- 'operator' => '==',
- 'value' => 'custom'
- ]
- ],
- 'weight' => [
- 'type' => 'number',
- 'label' => 'Weight (kg)',
- 'step' => 0.01,
- 'description' => 'Weight of this variation'
- ],
- 'dimensions' => [
- 'type' => 'group',
- 'label' => 'Dimensions',
- 'fields' => [
- 'length' => [
- 'type' => 'number',
- 'label' => 'Length (cm)',
- 'step' => 0.1
- ],
- 'width' => [
- 'type' => 'number',
- 'label' => 'Width (cm)',
- 'step' => 0.1
- ],
- 'height' => [
- 'type' => 'number',
- 'label' => 'Height (cm)',
- 'step' => 0.1
- ]
- ]
- ]
- ]
- ]
- ];
-
- $extras = [
- 'SERVICE' => [
- 'service_duration' => [
- 'type' => 'number',
- 'label' => 'Duration (minutes)',
- 'description' => 'Duration for this service variation'
- ],
- 'available_for_booking' => [
- 'type' => 'true_false',
- 'label' => 'Available for Booking'
- ]
- ],
-
- 'FOOD_AND_BEV' => [
- 'portion_size' => [
- 'type' => 'select',
- 'label' => 'Portion Size',
- 'options' => [
- 'small' => 'Small',
- 'regular' => 'Regular',
- 'large' => 'Large',
- 'family' => 'Family Size'
- ]
- ],
- 'calories' => [
- 'type' => 'number',
- 'label' => 'Calories',
- 'description' => 'Calorie count for this variation'
- ]
- ],
-
- 'DIGITAL' => [
- 'download_limit' => [
- 'type' => 'number',
- 'label' => 'Download Limit',
- 'description' => 'Maximum number of downloads',
- 'default' => -1
- ],
- 'expiry_days' => [
- 'type' => 'number',
- 'label' => 'Access Duration (days)',
- 'description' => 'Days until download expires',
- 'default' => 0
- ]
- ]
- ];
- if ($type && array_key_exists($type, $extras)){
- $base = array_merge($base, $extras[$type]);
- }
- return $base;
- }
-
- /*****************************************************************
- * HELCIMPAY.JS — Frontend Scripts & Checkout Initialization
- *****************************************************************/
-
- protected function registerAdditionalHooks(): void
- {
- if (!$this->isSetUp()) {
- return;
- }
-
- add_action('wp_enqueue_scripts', [$this, 'enqueueScripts']);
-
- // Shared checkout UI (replaces provider-specific outputCheckout)
- add_filter('jvbAdditionalActions', [Checkout::class, 'render']);
-
- // Checkout description filter
- add_filter('jvb_checkout_description', function (string $desc, string $provider) {
- if ($provider === 'helcim') {
- return 'Securely checkout with your name, email, and payments processed by Helcim.';
- }
- return $desc;
- }, 10, 2);
-
- // Register webhook endpoint (handled by parent)
- $this->registerWebhookEndpoint();
- }
-
- public function enqueueScripts(): void
- {
- // HelcimPay.js SDK
- wp_enqueue_script(
- 'helcim-pay-sdk',
- 'https://secure.helcim.app/helcim-pay/services/start.js',
- [],
- null,
- true
- );
-
- // Base cart checkout (shared with Square)
- wp_register_script(
- 'jvb-checkout',
- JVB_URL . 'assets/js/min/checkout.min.js',
- ['jvb-utility', 'jvb-queue', 'jvb-a11y', 'jvb-cache', 'jvb-tabs', 'jvb-popup'],
- '1.1.31',
- true
- );
-
- // Helcim checkout (extends CartCheckout)
- wp_register_script(
- 'jvb-helcim-checkout',
- JVB_URL . 'assets/js/min/helcim.min.js',
- ['jvb-checkout', 'helcim-pay-sdk'],
- '1.1.31',
- true
- );
-
- wp_localize_script('jvb-helcim-checkout', 'helcimConfig', [
- 'api_url' => rest_url('jvb/v1/helcim/'),
- 'nonce' => wp_create_nonce('wp_rest'),
- 'currency' => $this->credentials['currency'] ?? 'CAD',
- 'is_logged_in' => is_user_logged_in(),
- 'user_email' => is_user_logged_in() ? wp_get_current_user()->user_email : '',
- 'isOpen' => apply_filters('jvb_store_is_open', '1'),
- ]);
-
- wp_enqueue_script('jvb-helcim-checkout');
- }
-
-
- protected function registerAdditionalQueueTypes(IntegrationExecutor $executor): void
- {
- $queue = JVB()->queue();
-
- $queue->registry()->register(self::$import, new TypeConfig(
- executor: $executor,
- maxRetries: 3
- ));
-
- $queue->registry()->register(self::$syncCustomer, new TypeConfig(
- executor: $executor,
- maxRetries: 2
- ));
- }
-
- /**
- * Initialize a HelcimPay.js checkout session.
- *
- * Server-side: POST /helcim-pay/initialize → returns checkoutToken + secretToken.
- * Client-side: appendHelcimPayIframe(checkoutToken) renders the payment modal.
- * Tokens are valid for 60 minutes.
- *
- * @param array $data [
- * 'amount' => float, // Required
- * 'invoiceId' => string, // Optional — pay a specific invoice
- * 'customerId' => int, // Optional — associate with Helcim customer
- * 'paymentType' => string, // purchase|preauth|verify (default: purchase)
- * ]
- */
- public function initializeCheckout(array $data): array
- {
- if (empty($data['amount']) || (float) $data['amount'] <= 0) {
- return ['success' => false, 'message' => 'Invalid amount'];
- }
-
- $paymentMethod = !empty($this->credentials['allow_ach']) ? 'cc-ach' : 'cc';
-
- $body = [
- 'paymentType' => $data['paymentType'] ?? 'purchase',
- 'amount' => (float) $data['amount'],
- 'currency' => $this->credentials['currency'] ?? 'CAD',
- 'paymentMethod' => $paymentMethod,
- ];
-
- if (!empty($data['invoiceId'])) {
- $body['invoiceNumber'] = $data['invoiceId'];
- }
-
- if (!empty($data['customerId'])) {
- $body['customerId'] = (int) $data['customerId'];
- }
-
- if (!empty($this->credentials['fee_saver'])) {
- $body['hasConvenienceFee'] = true;
- }
-
- $response = $this->postRequest('helcim-pay/initialize', $body);
-
- if (is_wp_error($response)) {
- return ['success' => false, 'message' => $response->get_error_message()];
- }
-
- if (empty($response['checkoutToken'])) {
- return ['success' => false, 'message' => 'Failed to initialize checkout'];
- }
-
- return [
- 'success' => true,
- 'checkoutToken' => $response['checkoutToken'],
- 'secretToken' => $response['secretToken'] ?? '',
- ];
- }
-
- /*****************************************************************
- * INVOICES — Helcim is source of truth
- *****************************************************************/
-
- /**
- * Get invoices for a customer.
- *
- * @param array $data ['email' => string] or ['customerId' => int]
- */
- public function handleGetInvoices(array $data): array
- {
- $customerId = $data['customerId'] ?? null;
-
- if (empty($customerId) && !empty($data['email'])) {
- $customerId = $this->getCustomerIdByEmail($data['email']);
- }
-
- if (!$customerId) {
- return ['success' => true, 'invoices' => []];
- }
-
- $response = $this->getRequest('invoices', ['customerId' => $customerId], null, 'minimal');
-
- if (is_wp_error($response) || !is_array($response)) {
- return ['success' => false, 'message' => 'Failed to fetch invoices'];
- }
-
- return ['success' => true, 'invoices' => $response];
- }
-
- /**
- * Get a single invoice by ID.
- */
- public function handleGetInvoice(array $data): array
- {
- $invoiceId = $data['invoiceId'] ?? null;
-
- if (!$invoiceId) {
- return ['success' => false, 'message' => 'Invoice ID required'];
- }
-
- $response = $this->getRequest("invoices/{$invoiceId}", [], null, 'minimal');
-
- if (is_wp_error($response) || !is_array($response)) {
- return ['success' => false, 'message' => 'Failed to fetch invoice'];
- }
-
- return ['success' => true, 'invoice' => $response];
- }
-
- /*****************************************************************
- * CUSTOMERS & CARDS
- *****************************************************************/
-
- /**
- * Find Helcim customer ID by email.
- */
- public function getCustomerIdByEmail(string $email): ?int
- {
- $cacheKey = 'customer_email_' . md5($email);
- $cached = $this->cache->get($cacheKey);
-
- if ($cached !== false) {
- return (int) $cached;
- }
-
- $response = $this->getRequest('customers', ['search' => $email], null, 'none', true);
-
- if (is_wp_error($response) || empty($response)) {
- return null;
- }
-
- $customers = is_array($response) ? $response : [];
- $emailLower = strtolower($email);
-
- foreach ($customers as $customer) {
- $contactEmail = strtolower($customer['contactEmail'] ?? $customer['email'] ?? '');
- if ($contactEmail === $emailLower) {
- $this->cache->set($cacheKey, $customer['id'], $this->cacheStrategy['aggressive']);
- return (int) $customer['id'];
- }
- }
-
- return null;
- }
-
- /**
- * Get or create a Helcim customer.
- */
- public function getOrCreateCustomer(array $info): ?int
- {
- if (empty($info['email'])) {
- return null;
- }
-
- $existing = $this->getCustomerIdByEmail($info['email']);
- if ($existing) {
- return $existing;
- }
-
- $response = $this->postRequest('customers', [
- 'contactName' => $info['name'] ?? '',
- 'contactEmail' => $info['email'],
- 'cellphone' => $info['phone'] ?? '',
- ]);
-
- if (is_wp_error($response) || empty($response['id'])) {
- return null;
- }
-
- return (int) $response['id'];
- }
-
- /**
- * Get saved cards for a customer.
- */
- public function handleGetCustomerCards(array $data): array
- {
- $customerId = $data['customerId'] ?? null;
-
- if (empty($customerId) && !empty($data['email'])) {
- $customerId = $this->getCustomerIdByEmail($data['email']);
- }
-
- if (!$customerId) {
- return ['success' => true, 'cards' => []];
- }
-
- $response = $this->getRequest("customers/{$customerId}/cards", [], null, 'moderate');
-
- if (is_wp_error($response) || !is_array($response)) {
- return ['success' => false, 'message' => 'Failed to fetch cards'];
- }
-
- return ['success' => true, 'cards' => $response];
- }
-
- /**
- * Get bank accounts for a customer.
- */
- public function getCustomerBankAccounts(int $customerId): array
- {
- $response = $this->getRequest("customers/{$customerId}/bank-accounts", [], null, 'moderate');
- return (!is_wp_error($response) && is_array($response)) ? $response : [];
- }
-
- /*****************************************************************
- * TRANSACTIONS
- *****************************************************************/
-
- public function getTransactions(array $params = []): array
- {
- $response = $this->getRequest('card-transactions', $params, null, 'minimal');
- return (!is_wp_error($response) && is_array($response)) ? $response : [];
- }
-
- public function refundPayment(array $data): array
- {
- $response = $this->postRequest('payment/refund', $data);
-
- if (is_wp_error($response)) {
- return ['success' => false, 'message' => $response->get_error_message()];
- }
-
- return ['success' => true, 'transaction' => $response];
- }
-
- /*****************************************************************
- * PRODUCT SYNC
- *****************************************************************/
-
- protected function handleTheSavePost(int $postID, \WP_Post $post, bool $update, array $settings): void
- {
-
- error_log('==== [Helcim]::handleTheSavePost ====');
- $this->queueOperation(self::$syncTo, [
- 'items' => [$postID],
- 'user' => user_can($post->post_author, 'manage_options') ? null : $post->post_author
- ], [
- 'priority' => 'high',
- 'delay' => 30,
- ]);
- Meta::forPost($postID)->set('_'.$this->service_name.'_sync_status', 'queued');
- }
- /**
- * Handle post deletion
- */
- public function handleDeletePost(int $postID): void
- {
- $item_id = Meta::forPost($postID)->get("_{$this->service_name}_item_id");
-
- if (empty($item_id)) {
- return;
- }
- $this->queueOperation(self::$deleteFrom, [
- 'external_ids' => [$item_id],
- 'post_id' => $postID,
- ], [
- 'priority' => 'high',
- ]);
-
- }
-
- protected function handleImportFromHelcim(): array
- {
- $this->queueOperation('import_products', [
- 'user_id' => $this->userID,
- ], ['priority' => 'normal']);
-
- return ['success' => true, 'message' => 'Import from Helcim queued'];
- }
-
- /*****************************************************************
- * USER ↔ CUSTOMER LINKING
- *****************************************************************/
-
- public function linkUserToCustomer(int $userId, int $helcimCustomerId): void
- {
- update_user_meta($userId, BASE . '_helcim_customer_id', $helcimCustomerId);
- }
-
- public function getUserCustomerId(int $userId): ?int
- {
- $id = get_user_meta($userId, BASE . '_helcim_customer_id', true);
- return $id ? (int) $id : null;
- }
-
- /**
- * Resolve customer ID from user meta, falling back to email lookup + auto-link.
- */
- public function resolveCustomerId(int $userId): ?int
- {
- $id = $this->getUserCustomerId($userId);
- if ($id) {
- return $id;
- }
-
- $user = get_userdata($userId);
- if (!$user || empty($user->user_email)) {
- return null;
- }
-
- $id = $this->getCustomerIdByEmail($user->user_email);
- if ($id) {
- $this->linkUserToCustomer($userId, $id);
- }
-
- return $id;
- }
-
- /*****************************************************************
- * VALIDATION
- *****************************************************************/
-
- /**
- * Validate a HelcimPay.js transaction using the secret token.
- *
- * After the frontend receives a SUCCESS message event, call this
- * server-side to verify the transaction hash.
- */
- public function validateTransaction(string $secretToken, array $transactionData): bool
- {
- $hash = hash('sha256', $secretToken . json_encode($transactionData));
- return hash_equals($hash, $transactionData['hash'] ?? '');
- }
- /*******************************************************************
- * WEBHOOKS
- *******************************************************************/
- /**
- * Validate Helcim webhook signature.
- *
- * Helcim signs webhooks with HMAC-SHA256 using:
- * signedContent = "{webhook-id}.{webhook-timestamp}.{body}"
- * key = base64_decode(verifierToken)
- *
- * Headers: webhook-id, webhook-timestamp, webhook-signature (v1,{base64hash})
- *
- * @see https://devdocs.helcim.com/docs/enabling-webhooks-for-transactions
- */
- protected function validateWebhook(array $payload): bool
- {
- $headers = $payload['_headers'] ?? [];
-
- $webhookId = $headers['webhook_id'][0] ?? $headers['webhook-id'] ?? '';
- $webhookTimestamp = $headers['webhook_timestamp'][0] ?? $headers['webhook-timestamp'] ?? '';
- $webhookSignature = $headers['webhook_signature'][0] ?? $headers['webhook-signature'] ?? '';
-
- if (empty($webhookId) || empty($webhookTimestamp) || empty($webhookSignature)) {
- $this->logError('Webhook missing required headers', [], 'warning');
- return false;
- }
-
- // Verify timestamp is within 5 minutes (prevent replay attacks)
- $now = time();
- if (abs($now - (int)$webhookTimestamp) > 300) {
- $this->logError('Webhook timestamp too old', [
- 'webhook_timestamp' => $webhookTimestamp,
- 'server_time' => $now,
- ], 'warning');
- return false;
- }
-
- $secret = $this->getAdvancedSetting('webhook_signature_key');
- if (empty($secret)) {
- // If no signature key configured, allow webhook but log warning
- $this->logDebug('No webhook signature key configured — skipping verification');
- return true;
- }
-
- // Reconstruct the raw body from the payload (minus our injected _headers)
- $body = $payload['_raw_body'] ?? json_encode(
- array_diff_key($payload, ['_headers' => 1, '_raw_body' => 1])
- );
-
- $signedContent = "{$webhookId}.{$webhookTimestamp}.{$body}";
- $secretBytes = base64_decode($secret);
- $expectedHash = base64_encode(
- hash_hmac('sha256', $signedContent, $secretBytes, true)
- );
-
- // webhook-signature may contain multiple signatures: "v1,hash1 v2,hash2"
- $signatures = explode(' ', $webhookSignature);
- foreach ($signatures as $sig) {
- $parts = explode(',', $sig, 2);
- if (count($parts) === 2 && hash_equals($expectedHash, $parts[1])) {
- return true;
- }
- }
-
- $this->logError('Webhook signature mismatch', [
- 'webhook_id' => $webhookId,
- ], 'warning');
-
- return false;
- }
-
- /**
- * Process a validated Helcim webhook event.
- *
- * Helcim sends minimal payloads: {"id": "12345", "type": "cardTransaction"}
- * We fetch the full transaction details from the API.
- */
- protected function processWebhook(array $payload): bool
- {
- $type = $payload['type'] ?? '';
- $id = $payload['id'] ?? '';
-
- if (empty($type) || empty($id)) {
- $this->logError('Webhook missing type or id', $payload, 'warning');
- return false;
- }
-
- return match ($type) {
- 'cardTransaction' => $this->handleTransactionWebhook($id),
- 'terminalCancel' => $this->handleTerminalCancelWebhook($payload),
- default => $this->handleUnknownWebhook($type, $id),
- };
- }
-
- /**
- * Handle a cardTransaction webhook — fetch full transaction, update records.
- */
- protected function handleTransactionWebhook(string $transactionId): bool
- {
- // Fetch full transaction from Helcim API
- $transaction = $this->getRequest("card-transactions/{$transactionId}");
-
- if (is_wp_error($transaction) || empty($transaction)) {
- $this->logError('Failed to fetch transaction for webhook', [
- 'transaction_id' => $transactionId,
- ]);
- return false;
- }
-
- $status = $transaction['status'] ?? '';
-
- // Fire action for other parts of the system to react
- do_action('jvb_helcim_transaction', $transaction, $status);
-
- // If linked to an invoice, update invoice cache
- $invoiceNumber = $transaction['invoiceNumber'] ?? '';
- if (!empty($invoiceNumber)) {
- $this->cache->delete("invoice_{$invoiceNumber}");
- do_action('jvb_helcim_invoice_updated', $invoiceNumber, $transaction);
- }
-
- // Log for debugging
- $this->logDebug('Transaction webhook processed', [
- 'transaction_id' => $transactionId,
- 'status' => $status,
- 'amount' => $transaction['amount'] ?? 0,
- 'type' => $transaction['type'] ?? '',
- ]);
-
- return true;
- }
-
- /**
- * Handle Smart Terminal cancel webhook
- */
- protected function handleTerminalCancelWebhook(array $payload): bool
- {
- do_action('jvb_helcim_terminal_cancel', $payload);
-
- $this->logDebug('Terminal cancel webhook processed', [
- 'payload' => $payload,
- ]);
-
- return true;
- }
-
- /**
- * Handle unknown webhook types (future-proofing)
- */
- protected function handleUnknownWebhook(string $type, string $id): bool
- {
- $this->logDebug('Unknown webhook type received', [
- 'type' => $type,
- 'id' => $id,
- ]);
-
- do_action("jvb_helcim_webhook_{$type}", $id);
- return true;
- }
-
- /**
- * Extract unique webhook ID for deduplication
- */
- protected function extractWebhookId(array $payload): ?string
- {
- $headers = $payload['_headers'] ?? [];
- return $headers['webhook_id'][0] ?? $headers['webhook-id'] ?? $payload['id'] ?? null;
- }
-
- /**
- * Override the webhook request handler to capture raw body for signature verification
- */
- public function handleWebhookRequest(\WP_REST_Request $request): \WP_REST_Response
- {
- $payload = $request->get_params();
- $payload['_headers'] = $request->get_headers();
- $payload['_raw_body'] = $request->get_body();
-
- $success = $this->handleWebhook($payload);
-
- return new \WP_REST_Response([
- 'success' => $success,
- ], $success ? 200 : 400);
- }
- /***********************************************************************
- * POST HOOKS
- ***********************************************************************/
- /**
- * Sync a WordPress post to Helcim as a product.
- * Called by IntegrationExecutor::processSyncTo()
- */
- public function syncPostToService(int $postID): array|\WP_Error
- {
- $post = get_post($postID);
- if (!$post) {
- return new \WP_Error('not_found', "Post {$postID} not found");
- }
-
- $helcimProductId = get_post_meta($postID, BASE . '_helcim_item_id', true);
- $productData = $this->buildProductPayload($postID);
-
- if (is_wp_error($productData)) {
- return $productData;
- }
-
- if ($helcimProductId) {
- // Update existing
- $response = $this->patchRequest("products/{$helcimProductId}", $productData);
- } else {
- // Create new
- $response = $this->postRequest('products', $productData);
- }
-
- if (is_wp_error($response)) {
- update_post_meta($postID, BASE . '_helcim_sync_status', 'error');
- return $response;
- }
-
- // Store Helcim product ID
- $newId = $response['id'] ?? $response['productId'] ?? $helcimProductId;
- update_post_meta($postID, BASE . '_helcim_item_id', $newId);
- update_post_meta($postID, BASE . '_helcim_sync_status', 'synced');
- update_post_meta($postID, BASE . '_helcim_last_sync', current_time('mysql'));
-
- return ['success' => true, 'helcim_id' => $newId];
- }
-
- /**
- * Delete a product from Helcim.
- * Called by IntegrationExecutor::processDeleteFrom()
- */
- public function deleteFromService(string $externalId): array|\WP_Error
- {
- $response = $this->deleteRequest("products/{$externalId}");
-
- if (is_wp_error($response)) {
- return $response;
- }
-
- return ['success' => true, 'deleted' => $externalId];
- }
-
- /**
- * Build Helcim product payload from a WordPress post.
- */
- protected function buildProductPayload(int $postID): array|\WP_Error
- {
- $meta = \JVBase\meta\Meta::forPost($postID);
- $post = get_post($postID);
-
- $price = $meta->get('price');
- if (empty($price) || !is_numeric($price)) {
- return new \WP_Error('invalid_price', "Post {$postID} has no valid price");
- }
-
- return [
- 'name' => $post->post_title,
- 'description' => wp_strip_all_tags($post->post_content),
- 'sku' => $meta->get('sku') ?: "wp-{$postID}",
- 'price' => (float) $price,
- 'taxExempt' => (bool) $meta->get('tax_exempt'),
- ];
+ // TODO: Implement initialize() method.
}
}
diff --git a/inc/integrations/services/Instagram.php b/inc/integrations/services/Instagram.php
index 838246f..183679f 100644
--- a/inc/integrations/services/Instagram.php
+++ b/inc/integrations/services/Instagram.php
@@ -2,11 +2,19 @@
/**
* Instagram Integration
* File: /inc/integrations/Instagram.php
+ *
+ * Limits:
+ * - 50 uploads (including carousels) per user per 24-hour period
*/
namespace JVBase\integrations\services;
+use JVBase\integrations\Admin;
+use JVBase\integrations\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;
if (!defined('ABSPATH')) {
exit;
@@ -14,692 +22,56 @@
class Instagram extends Integrations
{
- protected array $allowedContent = [
- 'post'
- ];
- private string $ig_user_id = '';
- private string $page_id = '';
- private string $page_access_token = '';
- private array $business_account = [];
- private bool $auto_post = false;
- private array $post_settings = [];
-
- // Instagram API endpoints
- private const API_VERSION = 'v18.0';
- private const GRAPH_API_BASE = 'https://graph.facebook.com/';
- private const INSTAGRAM_BASE = 'https://graph.instagram.com/';
-
- 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)
+ use Admin, Instance, OAuth, Requests;
+ protected function __construct()
{
$this->service_name = 'instagram';
$this->title = 'Instagram';
$this->icon = 'instagram-logo';
- $this->apiBase = [
- 'graph' => self::GRAPH_API_BASE . self::API_VERSION,
- 'instagram' => self::INSTAGRAM_BASE . self::API_VERSION
- ];
- $this->apiEndpoints = [
- 'me/accounts',
- 'media',
- 'media_publish',
- 'content_publishing_limit',
- 'insights'
- ];
- $this->isOAuthService = true;
- // OAuth configuration for Facebook/Instagram
- $this->oauth = [
- 'authorize' => 'https://www.facebook.com/' . self::API_VERSION . '/dialog/oauth',
- 'token' => self::GRAPH_API_BASE . self::API_VERSION . '/oauth/access_token',
- 'redirect_uri' => rest_url('jvb/v1/oauth/instagram'),
- 'scopes' => [
- 'instagram_basic',
- 'instagram_content_publish',
- 'instagram_manage_insights',
- 'pages_show_list',
- 'pages_read_engagement',
- 'business_management'
- ]
- ];
+ $this->apiVersion = 'v25.0';
+ $this->supportsWebp = false;
- // Define sync capabilities
- $this->canSync = [
- 'create' => true,
- 'update' => false, // Instagram doesn't allow editing posts
- 'delete' => true
- ];
+// $this->defineFields();
+ $this->defineOAuth();
+// $this->defineActions();
- $this->fields = [
-
- ];
-
- $this->advanced = [
-
- ];
-
- $this->instructions = [
- 'Setup Facebook in order to use Instagram.'
- ];
-
- $this->defaults = [
-
- ];
- $this->hasWebhooks = true;
- parent::__construct($userID);
-
- $this->actions = array_merge(
- $this->actions,
- [
- 'switch_account' => 'switchInstagramAccount'
- ]
- );
+ $this->baseUrl = 'https://graph.instagram.com/'.$this->apiVersion;
+ parent::__construct();
}
+ public function defineOAuth():void
+ {
+ $this->oauth = new OAuthURLs(
+ 'https://www.facebook.com/'.$this->apiVersion.'/dialog/oauth',
+ 'https://graph.facebook.com/'.$this->apiVersion.'/oauth/access_token',
+ 'https://graph.facebook.com/'.$this->apiVersion.'/me/permissions'
+ );
+ $this->oathScopes = [
+ 'instagram_basic',
+ 'instagram_content_publish',
+ ];
+ }
protected function initialize(): void
{
- $this->page_access_token = $this->credentials['page_access_token'] ?? '';
- $this->ig_user_id = $this->credentials['ig_user_id'] ?? '';
- $this->page_id = $this->credentials['page_id'] ?? '';
- $this->business_account = $this->credentials['business_account'] ?? [];
- $this->auto_post = $this->credentials['auto_post'] ?? false;
- $this->post_settings = $this->credentials['post_settings'] ?? [
- 'include_caption' => true,
- 'include_hashtags' => true,
- 'include_location' => false,
- 'default_hashtags' => ''
- ];
-
- // Set rate limits
- $this->rate_limits = [
- 'min_interval' => 30,
- 'daily_limit' => 25, // Instagram content publishing limit
- 'hourly_limit' => 5
- ];
+ // TODO: Implement initialize() method.
}
- /**
- * Get request headers for Instagram API
- */
+ protected function refreshAccessToken(OAuthCredentials $credentials): array
+ {
+ // TODO: Implement refreshAccessToken() method.
+ return [];
+ }
+
protected function getRequestHeaders(): array
{
- return [
- 'Content-Type' => 'application/json',
- 'Accept' => 'application/json'
- ];
+ // TODO: Implement getRequestHeaders() method.
+ return [];
}
- /**
- * Build Instagram API URL
- */
- protected function getApiUrl(string $endpoint, ?string $baseKey = null): string|false
+ protected function handleResponse(\WP_Error|array $response, string $method, string $endpoint): array
{
- $base = ($baseKey === 'instagram') ? $this->apiBase['instagram'] : $this->apiBase['graph'];
-
- // Handle Instagram-specific endpoints
- if (in_array($endpoint, ['media', 'media_publish'])) {
- return "{$base}/{$this->ig_user_id}/{$endpoint}";
- }
-
- return "{$base}/{$endpoint}";
- }
-
- /**
- * Add OAuth parameters specific to Facebook/Instagram
- */
- protected function addOAuthParams(array $params): array
- {
- $params['display'] = 'popup';
- $params['auth_type'] = 'rerequest';
- return $params;
- }
-
- public function isSetUp():bool {
- $fb = JVB()->connect('facebook');
- return $fb->isSetUp();
- }
-
- protected function getOAuthCredentials(): array
- {
- // Get Facebook integration
- $facebook = JVB()->connect('facebook');
-
- if (!$facebook || !$facebook->isSetUp()) {
- return [];
- }
-
- $fbCredentials = $facebook->getCredentials();
-
- return [
- 'client_id' => $fbCredentials['app_id'] ?? $fbCredentials['client_id'] ?? '',
- 'client_secret' => $fbCredentials['app_secret'] ?? $fbCredentials['client_secret'] ?? '',
- 'access_token' => $fbCredentials['access_token'] ?? '',
- ];
- }
-
- public function getCredentials():array {
- $credentials = parent::getCredentials();
- if (empty($credentials)) {
- $facebook = JVB()->connect('facebook');
-
- if (!$facebook || !$facebook->isSetUp()) {
- return [];
- }
-
- $fbCredentials = $facebook->getCredentials();
-
- return [
- 'client_id' => $fbCredentials['client_id'],
- 'client_secret' => $fbCredentials['client_secret'],
- ];
- }
- return $credentials;
- }
-
- private function switchInstagramAccount(array $data): WP_Error|array
- {
- $page_id = $data['page_id'] ?? '';
-
- if (empty($page_id)) {
- return [
- 'success' => false,
- 'message' => 'Page ID is required'
- ];
- }
-
- // Find the selected page in available pages
- $selected_page = null;
- foreach ($this->credentials['available_pages'] ?? [] as $page) {
- if ($page['id'] === $page_id) {
- $selected_page = $page;
- break;
- }
- }
-
- if (!$selected_page || empty($selected_page['instagram_business_account'])) {
- return [
- 'success' => false,
- 'message' => 'Invalid page or no Instagram account'
- ];
- }
-
- // Update credentials with new page
- $this->credentials['page_id'] = $selected_page['id'];
- $this->credentials['page_access_token'] = $selected_page['access_token'];
- $this->credentials['ig_user_id'] = $selected_page['instagram_business_account']['id'];
- $this->credentials['business_account'] = $this->getBusinessProfile(
- $selected_page['instagram_business_account']['id'],
- $selected_page['access_token']
- );
-
- $this->saveCredentials($this->credentials);
-
- // Reinitialize with new credentials
- $this->initialize();
-
- return [
- 'success' => true,
- 'message' => 'Switched to @' . ($this->credentials['business_account']['username'] ?? 'Instagram account'),
- 'account' => $this->credentials['business_account']
- ];
- }
- /**
- * Handle post save - queue Instagram post creation
- */
- 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 (!has_post_thumbnail($postID)) {
- return;
- }
-
- $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');
- }
-
- /**
- * Process queued operations
- */
- public function processOperation(\WP_Error|array $result, object $operation, array $data): \WP_Error|array
- {
- $base = strtolower($this->service_name) . '_';
- $insta = (array_key_exists('user', $data)) ? new self((int)$data['user']) : $this;
- switch ($operation->type) {
- case $base . 'create_post':
- return $insta->processCreatePost($data);
-
- case $base . 'create_story':
- return $insta->processCreateStory($data);
-
- case $base . 'delete_post':
- return $insta->processDeletePost($data);
-
- default:
- return $result;
- }
- }
-
- private function processDeletePost(array $data): array
- {
- return [
- 'success' => true,
- 'message' => 'Instagram sync data removed (post remains on Instagram)',
- 'note' => 'Instagram API does not support deleting posts programmatically'
- ];
- }
-
- /**
- * Process post creation for Instagram
- */
- private function processCreatePost(array $data): array
- {
- $post_id = $data['post_id'] ?? 0;
- $post = get_post($post_id);
-
- if (!$post) {
- return ['success' => false, 'message' => 'Post not found'];
- }
-
- try {
- // Get image URL
- $image_url = get_the_post_thumbnail_url($post_id, 'full');
- if (!$image_url) {
- throw new Exception('No featured image found');
- }
-
- // Build caption
- $caption = $this->buildCaption($post);
-
- // Create media container
- $container = $this->createMediaContainer($image_url, $caption);
-
- if (!$container || empty($container['id'])) {
- throw new Exception('Failed to create media container');
- }
-
- // Wait for processing (Instagram needs time to process the image)
- sleep(10);
-
- // Publish the media
- $published = $this->publishMedia($container['id']);
-
- if ($published && !empty($published['id'])) {
- // Store Instagram media ID
- update_post_meta($post_id, BASE . '_instagram_media_id', $published['id']);
- update_post_meta($post_id, BASE . '_instagram_sync_status', 'synced');
- update_post_meta($post_id, BASE . '_instagram_sync_date', current_time('mysql'));
-
- // Get permalink if available
- if (!empty($published['permalink'])) {
- update_post_meta($post_id, BASE . '_instagram_permalink', $published['permalink']);
- }
-
- return [
- 'success' => true,
- 'message' => 'Posted to Instagram successfully',
- 'instagram_id' => $published['id']
- ];
- }
-
- throw new Exception('Failed to publish media');
-
- } catch (Exception $e) {
- $this->logError('Instagram post creation failed', [
- 'post_id' => $post_id,
- 'error' => $e->getMessage()
- ]);
-
- update_post_meta($post_id, BASE . '_instagram_sync_status', 'failed');
- update_post_meta($post_id, BASE . '_instagram_sync_error', $e->getMessage());
-
- return [
- 'success' => false,
- 'message' => $e->getMessage()
- ];
- }
- }
-
- /**
- * Build caption for Instagram post
- */
- private function buildCaption(\WP_Post $post): string
- {
- $caption_parts = [];
-
- // Add title and description if enabled
- if ($this->post_settings['include_caption'] ?? true) {
- $caption_parts[] = $post->post_title;
-
- if (!empty($post->post_excerpt)) {
- $caption_parts[] = "\n\n" . $post->post_excerpt;
- }
- }
-
- // Add hashtags
- if ($this->post_settings['include_hashtags'] ?? true) {
- $hashtags = [];
-
- // Get tags from post
- $tags = wp_get_post_tags($post->ID, ['fields' => 'names']);
- foreach ($tags as $tag) {
- $hashtags[] = '#' . str_replace(' ', '', $tag);
- }
-
- // Add default hashtags
- if (!empty($this->post_settings['default_hashtags'])) {
- $default = preg_split('/[\s,]+/', $this->post_settings['default_hashtags']);
- foreach ($default as $tag) {
- $tag = trim($tag);
- if (strpos($tag, '#') !== 0) {
- $tag = '#' . $tag;
- }
- $hashtags[] = $tag;
- }
- }
-
- if (!empty($hashtags)) {
- $caption_parts[] = "\n\n" . implode(' ', array_unique($hashtags));
- }
- }
-
- // Add website link
- $caption_parts[] = "\n\n🔗 " . get_permalink($post);
-
- return implode('', $caption_parts);
- }
-
- /**
- * Webhook validation
- */
- protected function validateWebhook(array $payload): bool
- {
- // Facebook webhook verification
- if (isset($payload['hub_mode']) && $payload['hub_mode'] === 'subscribe') {
- $verify_token = $this->credentials['webhook_verify_token'] ?? 'jvb_instagram_webhook';
-
- if ($payload['hub_verify_token'] === $verify_token) {
- // Echo the challenge for verification
- echo $payload['hub_challenge'];
- exit;
- }
-
- return false;
- }
-
- // Validate webhook signature for actual events
- $signature = $_SERVER['HTTP_X_HUB_SIGNATURE_256'] ?? '';
- if (!$signature) {
- return false;
- }
-
- $app_secret = $this->credentials['app_secret'] ?? '';
- if (!$app_secret) {
- return true; // Allow if no secret configured
- }
-
- $expected = 'sha256=' . hash_hmac('sha256', file_get_contents('php://input'), $app_secret);
- return hash_equals($expected, $signature);
- }
-
- /**
- * Process webhook
- */
- protected function processWebhook(array $payload): bool
- {
- $entry = $payload['entry'][0] ?? [];
- $changes = $entry['changes'] ?? [];
-
- foreach ($changes as $change) {
- $field = $change['field'] ?? '';
- $value = $change['value'] ?? [];
-
- switch ($field) {
- case 'comments':
- $this->handleCommentWebhook($value);
- break;
-
- case 'mentions':
- $this->handleMentionWebhook($value);
- break;
-
- default:
- $this->logDebug('Unhandled webhook field', ['field' => $field]);
- }
- }
-
- return true;
- }
-
- /**
- * API Helper Methods
- */
-
- private function getFacebookPages(string $access_token): array
- {
- $response = wp_remote_get(
- $this->apiBase['graph'] . '/me/accounts?' . http_build_query([
- 'access_token' => $access_token,
- 'fields' => 'id,name,access_token,instagram_business_account'
- ])
- );
-
- if (is_wp_error($response)) {
- $this->logError('Failed to get Facebook pages', ['error' => $response->get_error_message()]);
- return [];
- }
-
- $data = json_decode(wp_remote_retrieve_body($response), true);
- return $data['data'] ?? [];
- }
-
- private function getBusinessProfile(string $ig_user_id, string $access_token): array
- {
- $response = wp_remote_get(
- $this->apiBase['graph'] . '/' . $ig_user_id . '?' . http_build_query([
- 'access_token' => $access_token,
- 'fields' => 'id,username,name,profile_picture_url,followers_count,media_count,biography,website'
- ])
- );
-
- if (is_wp_error($response)) {
- return [];
- }
-
- return json_decode(wp_remote_retrieve_body($response), true) ?: [];
- }
-
- private function createMediaContainer(string $image_url, string $caption): ?array
- {
- $response = wp_remote_post(
- $this->getApiUrl('media', 'instagram'),
- [
- 'body' => [
- 'image_url' => $image_url,
- 'caption' => $caption,
- 'access_token' => $this->page_access_token
- ]
- ]
- );
-
- if (is_wp_error($response)) {
- $this->logError('Failed to create media container', ['error' => $response->get_error_message()]);
- return null;
- }
-
- return json_decode(wp_remote_retrieve_body($response), true);
- }
-
- private function publishMedia(string $creation_id): ?array
- {
- $response = wp_remote_post(
- $this->getApiUrl('media_publish', 'instagram'),
- [
- 'body' => [
- 'creation_id' => $creation_id,
- 'access_token' => $this->page_access_token
- ]
- ]
- );
-
- if (is_wp_error($response)) {
- $this->logError('Failed to publish media', ['error' => $response->get_error_message()]);
- return null;
- }
-
- return json_decode(wp_remote_retrieve_body($response), true);
- }
-
- private function getPublishingLimit(): array
- {
- $response = wp_remote_get(
- $this->getApiUrl('content_publishing_limit', 'instagram') . '?' . http_build_query([
- 'access_token' => $this->page_access_token
- ])
- );
-
- if (is_wp_error($response)) {
- return new WP_Error('api_error', $response->get_error_message());
- }
-
- $data = json_decode(wp_remote_retrieve_body($response), true);
-
- return [
- 'success' => true,
- 'data' => [
- 'quota_usage' => $data['data'][0]['quota_usage'] ?? 0,
- 'quota_total' => $data['data'][0]['config']['quota_total'] ?? 25,
- 'quota_duration' => $data['data'][0]['config']['quota_duration'] ?? 86400
- ]
- ];
- }
-
- protected function performConnectionTest(): bool
- {
- if (empty($this->ig_user_id) || empty($this->page_access_token)) {
- return false;
- }
-
- // Test by getting account info
- $profile = $this->getBusinessProfile($this->ig_user_id, $this->page_access_token);
-
- if (!empty($profile['id'])) {
- return true;
- }
-
- return false;
- }
-
- private function createStory(array $data): WP_Error|array
- {
- $image_url = $data['image_url'] ?? '';
-
- if (empty($image_url)) {
- return new WP_Error('missing_image', 'Image URL required for story');
- }
-
- // Queue story creation
- $this->queueOperation('create_story', [
- 'image_url' => $image_url,
- 'sticker_url' => $data['sticker_url'] ?? ''
- ], [
- 'priority' => 'high'
- ]);
-
- return [
- 'success' => true,
- 'message' => 'Story creation queued'
- ];
- }
-
- private function processCreateStory(array $data): array
- {
- $params = [
- 'image_url' => $data['image_url'],
- 'media_type' => 'STORIES',
- 'access_token' => $this->page_access_token
- ];
-
- if (!empty($data['sticker_url'])) {
- $params['sticker_url'] = $data['sticker_url'];
- }
-
- // Create story container
- $response = wp_remote_post(
- $this->getApiUrl('media', 'instagram'),
- ['body' => $params]
- );
-
- if (is_wp_error($response)) {
- return [
- 'success' => false,
- 'message' => $response->get_error_message()
- ];
- }
-
- $container = json_decode(wp_remote_retrieve_body($response), true);
-
- if (empty($container['id'])) {
- return [
- 'success' => false,
- 'message' => 'Failed to create story container'
- ];
- }
-
- // Wait for processing
- sleep(5);
-
- // Publish story
- $published = $this->publishMedia($container['id']);
-
- return [
- 'success' => !empty($published['id']),
- 'message' => !empty($published['id']) ? 'Story published' : 'Failed to publish story',
- 'story_id' => $published['id'] ?? null
- ];
- }
-
- private function handleCommentWebhook(array $value): void
- {
- // Log new comments for moderation
- $this->logDebug('New Instagram comment', $value);
-
- // Could trigger notifications or store for moderation
- do_action(BASE . 'instagram_comment_received', $value);
- }
-
- private function handleMentionWebhook(array $value): void
- {
- // Log mentions
- $this->logDebug('New Instagram mention', $value);
-
- // Could trigger notifications
- do_action(BASE . 'instagram_mention_received', $value);
- }
-
- /**
- * Get service description
- */
- public function getServiceDescription(): string
- {
- return "Connect your Instagram Business account to automatically share your content and manage your Instagram presence.";
+ // TODO: Implement handleResponse() method.
+ return [];
}
}
diff --git a/inc/integrations/services/PostMark.php b/inc/integrations/services/PostMark.php
index 4f6f2ee..c8a4956 100644
--- a/inc/integrations/services/PostMark.php
+++ b/inc/integrations/services/PostMark.php
@@ -1,7 +1,12 @@
<?php
namespace JVBase\integrations\services;
+use JVBase\integrations\Admin;
+use JVBase\integrations\Instance;
use JVBase\integrations\Integrations;
+use JVBase\integrations\Requests;
+use WP_Error;
+
if (!defined('ABSPATH')) {
exit;
}
@@ -14,123 +19,69 @@
*/
class PostMark extends Integrations
{
- protected ?string $server_token = null;
- protected string $message_stream = 'outbound';
- protected string $from_email;
- protected string $from_name;
- protected bool $track_open;
- protected bool $track_links;
- protected ?string $lastMessageId = null;
+ use Admin, Instance, Requests;
- 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 static($userID);
- }
- return self::$instances[$key];
- }
- /**
- * Constructor
- */
- protected function __construct(?int $userID = null)
+ protected function __construct()
{
$this->service_name = 'postmark';
$this->title = 'PostMark';
$this->icon = 'envelope-simple';
-
- $this->cacheName = ($userID)? 'gmb_'.$userID : 'postmark';
-
- // PostMark API configuration
- $this->apiBase = 'https://api.postmarkapp.com';
$this->apiVersion = 'v1';
+ $this->baseUrl = 'https://api.postmarkapp.com';
- // Define available endpoints
- $this->apiEndpoints = [
- 'email',
- 'email/batch',
- 'email/withTemplate',
- 'bounces',
- 'deliverystats',
- 'messages/outbound',
- 'messages/outbound/[^/]+',
- 'messages/outbound/opens',
- 'stats/outbound',
- 'stats/outbound/opens',
- 'stats/outbound/bounces',
- 'templates',
- 'templates/[^/]+',
- 'server'
- ];
+ $this->defineFields();
+ $this->defineInstructions();
- // Required credentials
- $this->fields = [
- 'server_token' => [
- 'label' => 'Server API Token',
- 'type' => 'text',
- 'subtype'=> 'password',
- 'placeholder' => 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx',
- 'hint' => 'Your PostMark Server API Token'
- ],
- 'message_stream' => [
- 'label' => 'Message Stream ID',
- 'type' => 'text',
- 'placeholder' => 'outbound',
- 'default' => 'outbound',
- 'description' => 'Message stream to use (default: outbound)'
- ],
- 'from_email' => [
- 'label' => 'From Email',
- 'type' => 'email',
- 'placeholder' => 'noreply@yourdomain.com',
- 'description' => 'Default sender email address (must be verified in PostMark)'
- ],
- 'from_name' => [
- 'label' => 'From Name',
- 'type' => 'text',
- 'placeholder' => get_bloginfo('name'),
- 'default' => get_bloginfo('name'),
- 'description' => 'Default sender name'
- ],
- 'track_opens' => [
- 'label' => 'Track Opens',
- 'type' => 'select',
- 'options' => [
- 'false' => 'Disabled',
- 'true' => 'Enabled'
- ],
- 'default' => 'false',
- 'description' => 'Track when emails are opened'
- ],
- 'track_links' => [
- 'label' => 'Track Links',
- 'type' => 'select',
- 'options' => [
+ parent::__construct();
+ $this->defineActions();
+ }
+
+ protected function defineFields():void
+ {
+ $token = $this->addField('service_token', 'Server API Token', 'text', 'manage_options');
+ $token->setSubType('password');
+
+ $stream = $this->addField('message_stream', 'Message Stream ID', 'text', 'manage_options');
+ $stream->setDefault('outbound');
+ $stream->setHint('Message stream to use (default: outbound)');
+
+ $from = $this->addField('from_email', 'From Email', 'email', 'manage_options');
+ $from->setPlaceholder('noreply@yourdomain.com');
+ $from->setDefault(get_option('admin_email'));
+
+ $name = $this->addField('from_name', 'From Name', 'text', 'manage_options');
+ $name->setDefault(get_bloginfo('name'));
+ $name->setHint('Default sender name');
+
+ $opens = $this->addField('track_opens', 'Track Opens', 'select', 'manage_options');
+ $opens->setOptions([
+ 'false' => 'Disabled',
+ 'true' => 'Enabled',
+ ]);
+ $opens->setDefault('false');
+ $opens->setHint('Track when emails are opened');
+
+ $links = $this->addField('track_links', 'Track Links', 'select', 'manage_options');
+ $links->setOptions([
'None' => 'Disabled',
'HtmlOnly' => 'HTML Only',
'HtmlAndText' => 'HTML and Text',
- ],
- 'default' => 'None',
- 'description' => 'Track link clicks in emails'
- ]
- ];
-
- parent::__construct();
-
- // Hook into WordPress mail system if integration is healthy
- if ($this->is_healthy && $this->isSetUp()) {
- $this->initializeMailHooks();
+ ]);
+ $links->setDefault('None');
+ $links->setHint('Track link clicks in emails');
}
- }
+ protected function defineInstructions():void
+ {
- /**
- * Initialize WordPress mail hooks
- */
- protected function initializeMailHooks(): void
+ }
+ protected function defineActions():void
+ {
+
+ }
+ protected function initialize(): void
{
// Replace wp_mail with PostMark
- add_filter('wp_mail', [$this, 'sendViaPostMark'], 10, 1);
+ add_filter('wp_mail', [$this, 'wp_mail'], 10, 1);
// Optional: Hook for logging sent emails
add_action('postmark_email_sent', [$this, 'logSentEmail'], 10, 2);
@@ -138,289 +89,224 @@
}
/**
- * Send email via PostMark API
- * Hooks into wp_mail filter
- *
- * @param array $args Email arguments from wp_mail
- * @return array Modified args (or original if sending via PostMark)
+ * Fired on the wp_mail filter
+ * @param array $args Email arguments from wp_mail, including 'to', 'subject', 'message', 'headers', 'attachments'
+ * @return array
*/
- public function sendViaPostMark(array $args): array
- {
- // Extract email components
- $to = $args['to'];
- $subject = $args['subject'];
- $message = $args['message'];
- $headers = $args['headers'] ?? '';
- $attachments = $args['attachments'] ?? [];
+ public function wp_mail(array $args):array
+ {
+ // Extract email components
+ $to = $args['to'];
+ $subject = $args['subject'];
+ $message = $args['message'];
+ $headers = $args['headers']??'';
+ $attachments = $args['attachments'] ?? [];
- // Parse headers
- $parsed_headers = $this->parseMailHeaders($headers);
+ $headers = $this->parseMailHeaders($headers);
- // Determine content type
- $is_html = $parsed_headers['content-type'] === 'text/html' ||
- strpos($message, '<html') !== false ||
- strpos($message, '<body') !== false;
+ $is_html = $headers['content-type'] === 'text/html' || str_contains($message, '<html') || str_contains($message, '<body');
- // Build PostMark payload
- $payload = $this->buildEmailPayload(
- $to,
- $subject,
- $message,
- $is_html,
- $parsed_headers,
- $attachments
- );
-
- // Send via PostMark
- $result = $this->sendEmail($payload);
-
- if ($result === true) {
- error_log('================================ Email sent! ================================');
- // Prevent default wp_mail from sending
- add_filter('pre_wp_mail', '__return_true');
- do_action('postmark_email_sent', $args, $payload);
- } else {
- error_log('=-======================[POSTMARK]Something went wrong... ================================');
- // Log failure but allow fallback to default mail
- do_action('postmark_email_failed', $args, $result);
-
- // Optionally fall back to default wp_mail
- if ($this->shouldFallback()) {
- return $args;
- }
- }
-
- return $args;
- }
-
- /**
- * Build email payload for PostMark API
- */
- protected function buildEmailPayload(
- $to,
- string $subject,
- string $message,
- bool $is_html,
- array $headers,
- array $attachments
- ): array {
- $credentials = $this->getCredentials();
-
- // Handle multiple recipients
- $to_addresses = is_array($to) ? implode(',', $to) : $to;
-
- // Build base payload
- $payload = [
- 'From' => sprintf(
- '%s <%s>',
- $headers['from-name'] ?? $credentials['from_name'],
- $headers['from'] ?? $credentials['from_email']
- ),
- 'To' => $to_addresses,
- 'Subject' => $subject,
- 'TrackOpens' => $credentials['track_opens'] === 'true',
- 'TrackLinks' => $credentials['track_links'],
- 'MessageStream' => $credentials['message_stream'] ?? 'outbound'
- ];
-
- // Set content based on type
- if ($is_html) {
- $payload['HtmlBody'] = $message;
- // Generate text version from HTML
- $payload['TextBody'] = $this->generateTextFromHtml($message);
- } else {
- $payload['TextBody'] = $message;
- }
-
- // Add CC/BCC if present
- if (!empty($headers['cc'])) {
- $payload['Cc'] = is_array($headers['cc']) ?
- implode(',', $headers['cc']) : $headers['cc'];
- }
-
- if (!empty($headers['bcc'])) {
- $payload['Bcc'] = is_array($headers['bcc']) ?
- implode(',', $headers['bcc']) : $headers['bcc'];
- }
-
- // Add Reply-To if present
- if (!empty($headers['reply-to'])) {
- $payload['ReplyTo'] = $headers['reply-to'];
- }
-
- // Handle attachments
- if (!empty($attachments)) {
- $payload['Attachments'] = $this->processAttachments($attachments);
- }
-
- // Add custom headers if needed
- if (!empty($headers['x-headers'])) {
- $payload['Headers'] = $headers['x-headers'];
- }
-
- // Add tags for tracking (optional)
- $payload['Tag'] = $this->determineEmailTag($subject, $headers);
-
- return $payload;
- }
-
- /**
- * Send email via PostMark API
- */
- protected function sendEmail(array $payload): bool|WP_Error
- {
- if (!$this->isSetUp()) {
- return false;
- }
- try {
- $response = $this->postRequest('email', $payload);
- error_log('================================ POSTMARK RESPONSE: ================================'.print_r($response, true));
- if (is_wp_error($response)) {
- return $response;
- }
-
- // Store message ID for tracking
- if (!empty($response['MessageID'])) {
- $this->lastMessageId = $response['MessageID'];
-
- // Cache for potential status checking
- $this->cache->set(
- 'postmark_message_' . $response['MessageID'],
- $payload,
- 3600 // 1 hour
- );
- }
-
- return true;
-
- } catch (\Exception $e) {
- $this->logError('[POSTMARK]', ['method' => 'sendEmail', 'error' => $e->getMessage()]);
- return new WP_Error(
- 'postmark_send_failed',
- $e->getMessage()
+ $response = $this->sendEmail(
+ $to,
+ $subject,
+ $message,
+ $is_html,
+ $headers,
+ $attachments
);
- }
- }
- /**
- * Parse mail headers from various formats
- */
- protected function parseMailHeaders($headers): array
+ if ($response['success']) {
+ error_log('================================ Email sent! ================================');
+ // Prevent default wp_mail from sending
+ add_filter('pre_wp_mail', '__return_true');
+ do_action('postmark_email_sent', $args, $response);
+ } else {
+ error_log('=-======================[POSTMARK]Something went wrong... ================================');
+ // Log failure but allow fallback to default mail
+ do_action('postmark_email_failed', $args, $response);
+ }
+ return $args;
+ }
+
+ protected function parseMailHeaders(string|array $headers):array
+ {
+ $parsed = [
+ 'content-type' => 'text/plain',
+ 'from' => null,
+ 'from-name' => null,
+ 'reply-top' => null,
+ 'cc' => [],
+ 'bcc' => [],
+ 'x-headers' => [],
+ ];
+
+ if (empty($headers)) {
+ return $parsed;
+ }
+
+ if (is_string($headers)) {
+ $headers = explode("\n", $headers);
+ }
+
+ foreach ($headers as $line) {
+ $this->parseHeaderLine($line, $parsed);
+ }
+
+ return $parsed;
+ }
+ protected function parseHeaderLine(string $line, array &$parsed):void
+ {
+ if (empty($line)) {
+ return;
+ }
+ $parts = explode(':', $line, 2);
+ if (count($parts) !== 2) {
+ return;
+ }
+
+ $name = strtolower(trim($parts[0]));
+ $value = trim($parts[1]);
+ switch ($name) {
+ case 'content-type':
+ if (str_contains($value, 'text/html')) {
+ $parsed['content-type'] = $value;
+ }
+ break;
+
+ case 'from':
+ // Parse "Name <email>" format
+ if (preg_match('/^(.+?)\s*<(.+)>$/', $value, $matches)) {
+ $parsed['from-name'] = trim($matches[1], '"\'');
+ $parsed['from'] = $matches[2];
+ } else {
+ $parsed['from'] = $value;
+ }
+ break;
+
+ case 'cc':
+ case 'bcc':
+ $parsed[$name][] = $value;
+ break;
+
+ default:
+ //TODO: Verify we need this. This feels extra clunky
+ //Why not just $parsed['x-headers'][$name] = $value?
+ if (str_contains($name, 'x-')) {
+ $parsed['x-headers'][] = [
+ 'Name' => $name,
+ 'Value' => $value
+ ];
+ } else {
+ $parsed[$name] = $value;
+ }
+ }
+ }
+
+ protected function getRequestHeaders(): array
{
- $parsed = [
- 'content-type' => 'text/plain',
- 'from' => null,
- 'from-name' => null,
- 'reply-to' => null,
- 'cc' => [],
- 'bcc' => [],
- 'x-headers' => []
+ return [
+ 'Accept' => 'application/json',
+ 'Content-Type' => 'application/json',
+ 'X-Postmark-Server-Token' => $this->getServerToken()
];
-
- if (empty($headers)) {
- return $parsed;
- }
-
- // Handle array format
- if (is_array($headers)) {
- foreach ($headers as $header) {
- $this->parseHeaderLine($header, $parsed);
- }
- } else {
- // Handle string format (multiple lines)
- $lines = explode("\n", $headers);
- foreach ($lines as $line) {
- $this->parseHeaderLine($line, $parsed);
- }
- }
-
- return $parsed;
}
- /**
- * Parse individual header line
- */
- protected function parseHeaderLine(string $line, array &$parsed): void
+ protected function handleResponse(\WP_Error|array $response, string $method, string $endpoint): array
{
- if (empty($line)) {
- return;
+ if (is_wp_error($response) && $response->get_error_message() === 'rate_limit_exceeded') {
+ return $this->response(false, 'Rate limit exceeded');
}
-
- // Split header name and value
- $parts = explode(':', $line, 2);
- if (count($parts) !== 2) {
- return;
- }
-
- $name = strtolower(trim($parts[0]));
- $value = trim($parts[1]);
-
- switch ($name) {
- case 'content-type':
- if (strpos($value, 'text/html') !== false) {
- $parsed['content-type'] = 'text/html';
- }
- break;
-
- case 'from':
- // Parse "Name <email>" format
- if (preg_match('/^(.+?)\s*<(.+)>$/', $value, $matches)) {
- $parsed['from-name'] = trim($matches[1], '"\'');
- $parsed['from'] = $matches[2];
- } else {
- $parsed['from'] = $value;
- }
- break;
-
- case 'reply-to':
- $parsed['reply-to'] = $value;
- break;
-
- case 'cc':
- $parsed['cc'][] = $value;
- break;
-
- case 'bcc':
- $parsed['bcc'][] = $value;
- break;
-
- default:
- // Store custom headers
- if (strpos($name, 'x-') === 0) {
- $parsed['x-headers'][] = [
- 'Name' => $parts[0], // Original case
- 'Value' => $value
- ];
- }
- }
- }
-
- /**
- * Process attachments for PostMark
- */
- protected function processAttachments(array $attachments): array
- {
- $processed = [];
-
- foreach ($attachments as $file) {
- if (!file_exists($file)) {
- continue;
+ $endpoint = str_replace($this->apiBase, '', $endpoint);
+ $error = $response['ErrorCode'] ?? false;
+ if ($error) {
+ switch ($error) {
+ case '0':
+ //Do nothing, no error found
+ break;
+ //Bad or missing API Token
+ case '10':
+ //Multiple errors
+ case '11':
+ //Resource Not found
+ case '12':
+ //Invalid Pagination Key
+ case '13':
+ //Maintenance; PostMark API down for a sec
+ case '100':
+ //Invalid Email Request
+ case '300':
+ //Sender Signature not found
+ case '400':
+ //Sender signature not confirmed
+ case '401':
+ //Invalid JSON
+ case '402':
+ //Invalid request field(s) - {FIELD_NAME}
+ case '403':
+ //Not allowed to send (no more credits in account)
+ case '405':
+ //Inactive recipient. Recipient hard bounces, or filed spam complaint
+ case '406':
+ //JSON required
+ case '409':
+ //Too many requests (500 max)
+ case '410':
+ //Forbidden Attachment type
+ case '411':
+ //Account is pending
+ case '412':
+ //Account may not exist
+ case '413':
+ //Invalid query string parameter
+ case '500':
+ default:
+ $this->logError(
+ 'handleResponse',
+ 'Something went wrong: '.$response['Message']??'',
+ $response
+ );
+ return $this->response(false, 'Something went wrong: '.$response['Message']??'');
}
-
- $processed[] = [
- 'Name' => basename($file),
- 'Content' => base64_encode(file_get_contents($file)),
- 'ContentType' => mime_content_type($file) ?: 'application/octet-stream'
- ];
}
-
- return $processed;
+ return match ($endpoint) {
+ '/email' => $this->handleEmailResponse($response, $method),
+ '/email/bulk' => $this->handleBulkEmailResponse($response, $method),
+ default => $this->response(false, 'Unhandled Response for '. $endpoint . ' endpoint, with method: '. $method),
+ };
}
- /**
- * Generate plain text from HTML content
- */
- protected function generateTextFromHtml(string $html): string
+ /**********************************************************
+ * UTILITY
+ **********************************************************/
+ protected function getServerToken():string|false
+ {
+ return $this->loadCredentials()['server_token']??false;
+ }
+ protected function getMessageStream():string
+ {
+ return $this->loadCredentials()['message_stream']??'outbound';
+ }
+ protected function getFromEmail():string
+ {
+ return $this->loadCredentials()['from_email']??get_option('admin_email');
+ }
+ protected function getFromName():string
+ {
+ return $this->loadCredentials()['from_name']??get_bloginfo('name');
+ }
+ protected function getTrackOpens():string
+ {
+ return $this->loadCredentials()['track_opens']??'false';
+ }
+ protected function getTrackLinks():string
+ {
+ return $this->loadCredentials()['track_links']??'None';
+ }
+
+ /****************************************************************************
+ * HELPERS
+ ****************************************************************************/
+ protected function textFromHtml(string $html):string
{
// Remove HTML tags
$text = strip_tags($html);
@@ -434,228 +320,124 @@
}
/**
- * Determine email tag based on content
+ * @param array $attachments Either an array of file paths, or attachment IDs
+ * @return array
*/
- protected function determineEmailTag(string $subject, array $headers): string
+ protected function processAttachments(array $attachments):array
{
- // Password reset emails
- if (stripos($subject, 'password') !== false && stripos($subject, 'reset') !== false) {
+ $processed = [];
+ foreach ($attachments as $file) {
+ if (is_int($file)) {
+ $file = get_attached_file($file);
+ }
+ if (!$file || !file_exists($file)) {
+ continue;
+ }
+ $processed[] = [
+ 'Name' => basename($file),
+ 'Content' => base64_encode(file_get_contents($file)),
+ 'ContentType' => mime_content_type($file) ?? 'application/octet-stream'
+ ];
+ }
+
+ return $processed;
+ }
+
+ protected function determineEmailTag(string $subject, array $headers):string
+ {
+ if (array_key_exists('email-tag', $headers)) {
+ return $headers['email-tag'];
+ }
+ $tmp = strtolower($subject);
+ if (str_contains($tmp, 'password')) {
return 'password-reset';
}
- // New user registration
- if (stripos($subject, 'welcome') !== false || stripos($subject, 'registration') !== false) {
+ if (str_contains($tmp, 'welcome') || str_contains($tmp, 'registr') || str_contains($tmp, 'register')) {
return 'registration';
}
- // Account notifications
- if (stripos($subject, 'account') !== false) {
+ if (str_contains($tmp, 'account')) {
return 'account';
}
- // Order/transaction emails
- if (stripos($subject, 'order') !== false || stripos($subject, 'receipt') !== false) {
+ if (str_contains($tmp, 'order') || str_contains($tmp, 'receipt')) {
return 'transaction';
}
- // Default
return 'general';
}
- /**
- * Check if should fallback to default mail
- */
- protected function shouldFallback(): bool
+ /****************************************************************************
+ * MAILERS
+ ****************************************************************************/
+ public function sendEmail(string $to, string $subject, string $message, bool $is_html = true, array $headers = [], array $attachments = []):array
{
- // Don't fallback if we've had too many errors
- if ($this->error_stats['consecutive_errors'] >= 3) {
- return false;
+ if (!$this->isSetUp()) {
+ return $this->response(false, 'Not set up');
}
- // Check if fallback is enabled in settings
- $credentials = $this->getCredentials();
- return ($credentials['enable_fallback'] ?? 'true') === 'true';
+ $payload = $this->buildPayload($to, $subject, $message, $is_html, $headers, $attachments);
+
+ $response = $this->postRequest(
+ sprintf('%s/email', $this->apiBase),
+ $payload
+ );
}
+ protected function handleEmailResponse(WP_Error|array $response, string $method):array
+ {
- /**
- * Override makeRequest to add PostMark headers
- */
- protected function getRequestHeaders(): array {
- $credentials = $this->getCredentials();
-
- // Add PostMark specific headers
- return [
- 'Accept' => 'application/json',
- 'Content-Type' => 'application/json',
- 'X-Postmark-Server-Token' => $credentials['server_token'] ?? ''
- ];
- }
-
- /**
- * Get email statistics
- */
- public function getEmailStats(int $days = 30): array
- {
- $cache_key = 'postmark_stats_' . $days;
- $cached = $this->cache->get($cache_key);
-
- if ($cached !== false) {
- return $cached;
}
+ protected function buildPayload(string|array $to, string $subject, string $message, bool $is_html = true, array $headers = [], array $attachments = []):array
+ {
+ $to = is_array($to) ? implode(',',$to) : $to;
- try {
- $stats = $this->getRequest('stats/outbound', [
- 'fromdate' => date('Y-m-d', strtotime("-{$days} days")),
- 'todate' => date('Y-m-d')
- ]);
+ $payload = [
+ 'From' => sprintf(
+ '%s <%s>',
+ $headers['from-name'] ?? $this->getFromName(),
+ $headers['from'] ?? $this->getFromEmail()
+ ),
+ 'To' => $to,
+ 'Subject' => sanitize_text_field($subject),
+ 'TrackOpens' => $this->getTrackOpens() === 'true',
+ 'TrackLinks' => $this->getTrackLinks(),
+ 'MessageStream' => $this->getMessageStream()
+ ];
- if (!is_wp_error($stats)) {
- $this->cache->set($cache_key, $stats, 3600); // Cache for 1 hour
- return $stats;
+ if ($is_html) {
+ $payload['HtmlBody'] = $message;
+ $payload['TextBody'] = $this->textFromHtml($message);
+ } else {
+ $payload['TextBody'] = $message;
}
- } catch (\Exception $e) {
- $this->logError('stats/outbound', ['error' => $e->getMessage()]);
+
+ if (!empty($headers['cc'])) {
+ $payload['Cc'] = is_array($headers['cc']) ?
+ implode(',',$headers['cc']) :
+ $headers['cc'];
+ }
+
+ if (!empty($headers['bcc'])) {
+ $payload['Bcc'] = is_array($headers['bcc']) ?
+ implode(',',$headers['bcc']) :
+ $headers['bcc'];
+ }
+
+ if (!empty($headers['reply-to'])) {
+ $payload['ReplyTo'] = $headers['reply-to'];
+ }
+
+ if (!empty($attachments)) {
+ $payload['Attachments'] = $this->processAttachments($attachments);
+ }
+
+ if (!empty($headers['x-headers'])) {
+ $payload['Headers'] = $headers['x-headers'];
+ }
+
+ $payload['Tag'] = $this->determineEmailTag($subject, $headers);
+
+ return $payload;
}
-
- return [];
- }
-
- /**
- * Get bounce information
- */
- public function getBounces(int $count = 25, int $offset = 0): array
- {
- try {
- return $this->getRequest('bounces', [
- 'count' => $count,
- 'offset' => $offset
- ]);
- } catch (\Exception $e) {
- $this->logError('[POSTMARK]', ['method' => 'getBounces', 'error' => $e->getMessage()]);
- return [];
- }
- }
-
- /**
- * Log sent emails for debugging/tracking
- */
- public function logSentEmail(array $args, array $payload): void
- {
- if (!defined('WP_DEBUG') || !WP_DEBUG) {
- return;
- }
-
- $this->logDebug('Email sent via PostMark', [
- 'to' => $args['to'],
- 'subject' => $args['subject'],
- 'message_id' => $this->lastMessageId ?? 'unknown',
- 'tag' => $payload['Tag'] ?? 'none'
- ]);
- }
-
- /**
- * Log failed email attempts
- */
- public function logFailedEmail(array $args, $error): void
- {
- $error_message = is_wp_error($error) ? $error->get_error_message() : 'Unknown error';
-
- $this->logError('Failed to send email via PostMark', [
- 'to' => $args['to'],
- 'subject' => $args['subject'],
- 'error' => $error_message
- ]);
- }
-
- /**
- * Test email configuration
- */
- public function sendTestEmail(string $to_email): bool|WP_Error
- {
- $payload = [
- 'From' => $this->getCredentials()['from_email'] . ' <' . $this->getCredentials()['from_email'] . '>',
- 'To' => $to_email,
- 'Subject' => 'PostMark Test Email - ' . get_bloginfo('name'),
- 'TextBody' => 'This is a test email from your PostMark integration.',
- 'HtmlBody' => '<p>This is a <strong>test email</strong> from your PostMark integration.</p>',
- 'Tag' => 'test',
- 'TrackOpens' => false,
- 'TrackLinks' => 'None'
- ];
-
- return $this->sendEmail($payload);
- }
-
- /**
- * Render additional options in dashboard
- */
- public function renderAdditionalOptions(): void
- {
- $stats = $this->getEmailStats(7);
-
- ?>
- <div class="postmark-dashboard">
- <h3>Email Statistics (Last 7 Days)</h3>
-
- <?php if (!empty($stats)): ?>
- <div class="stats-grid">
- <div class="stat-item">
- <span class="stat-value"><?= number_format($stats['Sent'] ?? 0) ?></span>
- <span class="stat-label">Emails Sent</span>
- </div>
- <div class="stat-item">
- <span class="stat-value"><?= number_format($stats['Bounced'] ?? 0) ?></span>
- <span class="stat-label">Bounced</span>
- </div>
- <div class="stat-item">
- <span class="stat-value"><?= $stats['Opens'] ?? 0 ?>%</span>
- <span class="stat-label">Open Rate</span>
- </div>
- </div>
- <?php else: ?>
- <p>No statistics available. Check your API configuration.</p>
- <?php endif; ?>
-
- <div class="test-email-section">
- <h4>Send Test Email</h4>
- <form id="postmark-test-email" method="post">
- <input type="email"
- name="test_email"
- placeholder="test@example.com"
- value="<?= esc_attr(wp_get_current_user()->user_email) ?>"
- required>
- <button type="submit" class="button">Send Test</button>
- </form>
- </div>
-
- <?php if ($bounces = $this->getBounces(5)): ?>
- <div class="recent-bounces">
- <h4>Recent Bounces</h4>
- <ul>
- <?php foreach ($bounces as $bounce): ?>
- <li>
- <?= esc_html($bounce['Email']) ?> -
- <?= esc_html($bounce['Type']) ?>
- <small>(<?= esc_html($bounce['BouncedAt']) ?>)</small>
- </li>
- <?php endforeach; ?>
- </ul>
- </div>
- <?php endif; ?>
- </div>
- <?php
- }
-
- protected function initialize(): void
- {
- if (empty($this->credentials)) {
- $this->loadCredentials();
- }
- $this->server_token = (array_key_exists('server_token', $this->credentials)) ? $this->credentials['server_token'] : null;
- $this->from_email = (array_key_exists('from_email', $this->credentials)) ? $this->credentials['from_email'] : get_bloginfo('admin_email');
- $this->from_name = (array_key_exists('from_name', $this->credentials)) ? $this->credentials['from_name'] : get_bloginfo('name');
- $this->message_stream = (array_key_exists('message_stream', $this->credentials)) ? $this->credentials['message_stream'] : 'outbound';
- $this->track_open = (array_key_exists('track_open', $this->credentials)) ? $this->credentials['track_open'] : false;
- $this->track_links = (array_key_exists('track_links', $this->credentials)) ? $this->credentials['track_links'] : false;
- }
}
diff --git a/inc/integrations/services/Square.php b/inc/integrations/services/Square.php
index a2b6c9a..aeb001b 100644
--- a/inc/integrations/services/Square.php
+++ b/inc/integrations/services/Square.php
@@ -36,18 +36,16 @@
Webhooks;
protected string $orderPostType = '_square_order';
- protected string $apiVersion = '2026-07-15';
- public bool $hasBatchCreate = true;
- public bool $hasBatchUpdate = true;
- public bool $hasBatchDelete = true;
public array $apiEndpoints = [
];
protected function __construct()
{
+ $this->service_name = 'square';
$this->title = 'Square';
$this->icon = 'square-logo';
+ $this->apiVersion = '2026-07-15';
$this->supportsWebp = false;
$this->canSync = [
@@ -55,6 +53,9 @@
'update' => true,
'delete' => true,
];
+ $this->hasBatchCreate = true;
+ $this->hasBatchUpdate = true;
+ $this->hasBatchDelete = true;
$this->defineFields();
$this->defineInstructions();
@@ -89,8 +90,8 @@
$locs->setHidden();
$locs->setOptions($this->getLocationOptions());
- $loc = $this->addField('location_id', 'Default Location', 'select', 'manage_options');
- $loc->setOptions($this->loadCredentials()['locations']);
+ $loc = $this->addField('location_id', 'Set Location', 'select', 'manage_options');
+ $loc->setOptions($this->loadCredentials()['locations']??[]);
$loc->setHint('Select default Square location for transactions');
}
@@ -271,15 +272,12 @@
return $this->response(false, 'Rate limit exceeded');
}
$endpoint = str_replace($this->getBaseURL(), '', $endpoint);
- switch ($endpoint) {
- case '/v2/customers/search':
- return $this->handleEmailSearchResponse($response, $method);
- case '/v2/customers':
- return $this->handleCreateUserResponse($response, $method);
- case '/v2/locations':
- return $this->handleGetLocationResponse($response, $method);
- }
- return $this->response(false, 'Unhandled Response for '.$endpoint.' endpoint, with method: '.$method);
+ return match ($endpoint) {
+ '/v2/customers/search' => $this->handleEmailSearchResponse($response, $method),
+ '/v2/customers' => $this->handleCreateUserResponse($response, $method),
+ '/v2/locations' => $this->handleGetLocationResponse($response, $method),
+ default => $this->response(false, 'Unhandled Response for ' . $endpoint . ' endpoint, with method: ' . $method),
+ };
}
protected function setContentTypes(): void
@@ -914,10 +912,7 @@
? 'https://connect.squareup.com'
: 'https://connect.squareupsandbox.com';
}
- protected function getAccessToken():string
- {
- return $this->loadCredentials()['access_token']??'';
- }
+
/***************************************************************
* ACTIONS
***************************************************************/
diff --git a/inc/integrations/services/Umami.php b/inc/integrations/services/Umami.php
index 807ec3b..ebc22df 100644
--- a/inc/integrations/services/Umami.php
+++ b/inc/integrations/services/Umami.php
@@ -1,5 +1,7 @@
<?php
namespace JVBase\integrations\services;
+use JVBase\integrations\Admin;
+use JVBase\integrations\Instance;
use JVBase\integrations\Integrations;
if (!defined('ABSPATH')) {
@@ -8,796 +10,9 @@
class Umami extends Integrations
{
- private string $website_id;
- private string $api_url;
- private string $api_token;
-
- // Tracking configuration
- private array $valid_events = [
- 'view_feed',
- 'view_taxonomy',
- 'view_profile',
- 'view_shop',
- 'view_content',
- 'toggle_favourite',
- 'click_profile',
- 'click_content',
- 'click_taxonomy',
- 'click_shop',
- 'user_session'
- ];
-
- // Database tables
- private string $events_table;
- private string $metrics_table;
-
- 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 static($userID);
- }
- return self::$instances[$key];
- }
-
- protected function __construct(?int $userID = null)
- {
- $this->service_name = 'umami';
- $this->title = 'Umami.js';
- $this->icon = 'chart-line';
- $this->apiBase = ''; // Set dynamically based on credentials
- $this->apiEndpoints = [
- 'api/websites',
- 'api/websites/{websiteId}/stats',
- 'api/websites/{websiteId}/pageviews',
- 'api/websites/{websiteId}/events'
- ];
-
- $this->fields = [
- 'website_id' => [
- 'label' => 'Website ID',
- 'type' => 'text',
- 'placeholder'=> 'Enter Umami Website ID',
- 'hint' => 'Found in your Umami dashboard under Settings → Websites.',
- 'required' => true
- ],
- ];
-
- $this->advanced = [
- 'api_url' => [
- 'label' => 'API URL',
- 'type' => 'text',
- 'placeholder'=> 'https://analytics.yourdomain.com',
- 'hint' => 'Your Umami server URL. Leave blank if using umami.is cloud service.'
- ],
- 'api_token' => [
- 'label' => 'API Token (Optional)',
- 'type' => 'text',
- 'subtype' => 'password',
- 'placeholder'=> 'Enter API Token for analytics data',
- 'hint' => 'Required only if you want to retrieve analytics data for dashboards.'
- ]
- ];
-
- $this->instructions = [
- 'Login to your <a href="https://cloud.umami.is/settings/websites" target="_blank">umami.is</a> account, select your website, and copy the website ID'
- ];
-
- parent::__construct();
-
- $this->actions = array_merge(
- $this->actions,
- [
- 'refresh_data' => 'handleRefreshData'
- ]
- );
-
- global $wpdb;
- $this->events_table = $wpdb->prefix . BASE . 'umami_events';
- $this->metrics_table = $wpdb->prefix . BASE . 'performance_metrics';
- }
-
- /**
- * Initialize service-specific properties
- */
+ use Admin, Instance;
protected function initialize(): void
{
- $this->website_id = $this->credentials['website_id'] ?? '';
- $this->api_url = $this->credentials['api_url'] ?? '';
- $this->api_token = $this->credentials['api_token'] ?? '';
- $this->ttl = intval($this->credentials['cache_duration'] ?? 60) * 60;
-
- // Set dynamic API base if provided
- if (!empty($this->api_url)) {
- $this->apiBase = rtrim($this->api_url, '/');
- }
- }
-
- /**
- * Register additional WordPress hooks
- */
- protected function registerAdditionalHooks(): void
- {
- // Add tracking script to frontend
- add_action('wp_head', [$this, 'renderTrackingScript'], 5);
-
- // Add user session tracking
- add_action('wp_footer', [$this, 'trackUserSession'], 100);
-
- // Schedule data collection if API is configured
- if (!empty($this->api_token)) {
- add_action('jvb_daily_umami_collection', [$this, 'collectDailyData']);
-
- if (!wp_next_scheduled('jvb_daily_umami_collection')) {
- wp_schedule_event(time(), 'daily', 'jvb_daily_umami_collection');
- }
- }
- }
-
- /***************************************************************************
- * TRACKING SCRIPT METHODS
- ***************************************************************************/
-
- /**
- * Render tracking script in head
- */
- public function renderTrackingScript(): void
- {
- // Skip on local environments
- if (JVB_TESTING) {
- return;
- }
- if (!$this->isSetUp() || is_admin()) {
- return;
- }
-
-
- $script_url = $this->getTrackingScriptUrl();
- $website_id = $this->getWebsiteId();
-
- if (!$website_id || !$script_url) {
- return;
- }
-
- // Preconnect for performance
- $domain = parse_url($script_url, PHP_URL_HOST);
- echo '<link rel="preconnect" href="https://' . esc_attr($domain) . '"/>' . "\n";
-
- // Add tracking script
- $attributes = [
- 'defer',
- 'src="' . esc_url($script_url) . '"',
- 'data-website-id="' . esc_attr($website_id) . '"'
- ];
-
- // Add optional configuration
- if (!empty($this->credentials['respect_dnt']) && $this->credentials['respect_dnt']) {
- $attributes[] = 'data-do-not-track="true"';
- }
-
- if (!empty($this->credentials['domains'])) {
- $attributes[] = 'data-domains="' . esc_attr($this->credentials['domains']) . '"';
- }
-
- echo '<script ' . implode(' ', $attributes) . '></script>' . "\n";
- }
-
- /**
- * Track user session information
- */
- public function trackUserSession(): void
- {
- if (!$this->isSetUp() || is_admin()) {
- return;
- }
-
- $attributes = $this->buildTrackingAttributes('user_session', 'pageview', [
- 'logged_in' => is_user_logged_in() ? 'true' : 'false',
- 'user_type' => $this->getCurrentUserType()
- ]);
-
- if (empty($attributes)) {
- return;
- }
-
- echo '<div ' . $this->attributesToString($attributes) . ' style="display:none"></div>' . "\n";
-
- // Add page-specific tracking
- $this->trackPageView();
- }
-
- /**
- * Track specific page views
- */
- private function trackPageView(): void
- {
- $tracker_args = [];
- $event = '';
- $type = '';
-
- if (is_singular([BASE . 'artist', BASE . 'partner'])) {
- // Profile view
- global $post;
- $event = 'view_profile';
- $type = get_post_type();
- $tracker_args = [
- 'id' => get_the_ID(),
- 'from' => $this->getTrafficSource(),
- 'owner_id' => $post->post_author
- ];
-
- // Check for specific content in query
- foreach (['tattoo', 'piercing', 'artwork'] as $content_type) {
- if (isset($_GET[$content_type])) {
- $tracker_args['item'] = sanitize_text_field($_GET[$content_type]);
- break;
- }
- }
-
- } elseif (is_tax(BASE . 'shop')) {
- // Shop view
- $event = 'view_shop';
- $type = BASE . 'shop';
- $tracker_args = [
- 'id' => get_queried_object_id(),
- 'from' => $this->getTrafficSource()
- ];
-
- } elseif (is_tax()) {
- // Taxonomy view
- $obj = get_queried_object();
- $event = 'view_taxonomy';
- $type = $obj->taxonomy;
- $tracker_args = [
- 'id' => $obj->term_id,
- 'from' => $this->getTrafficSource()
- ];
- }
-
- if ($event && $type) {
- $attributes = $this->buildTrackingAttributes($event, $type, $tracker_args);
- echo '<div ' . $this->attributesToString($attributes) . ' style="display:none"></div>' . "\n";
- }
- }
-
- /***************************************************************************
- * TRACKING ATTRIBUTE BUILDER METHODS
- ***************************************************************************/
-
- /**
- * Build tracking attributes for events
- */
- public function buildTrackingAttributes(string $event, string $type, array $args = []): array
- {
- if (!in_array($event, $this->valid_events)) {
- return [];
- }
-
- // Normalize type
- $normalized_type = str_replace(BASE, '', $type);
-
- // Build base attributes
- $attributes = [
- 'data-umami-event' => esc_attr($event),
- 'data-umami-event-type' => esc_attr($normalized_type)
- ];
-
- // Add ID if provided
- if (!empty($args['id'])) {
- $attributes['data-umami-event-id'] = esc_attr($args['id']);
- }
-
- // Add source information
- if (!empty($args['source_id'])) {
- $attributes['data-umami-event-source'] = esc_attr($args['source_id']);
- if (!empty($args['source_type'])) {
- $attributes['data-umami-event-source-type'] = esc_attr(
- str_replace(BASE, '', $args['source_type'])
- );
- }
- }
-
- // Add owner information
- if (!empty($args['owner_id'])) {
- $attributes['data-umami-event-owner'] = esc_attr($args['owner_id']);
- if (!empty($args['owner_type'])) {
- $attributes['data-umami-event-owner-type'] = esc_attr(
- str_replace(BASE, '', $args['owner_type'])
- );
- }
- }
-
- // Add traffic source
- if (!empty($args['from'])) {
- $attributes['data-umami-event-from'] = esc_attr($args['from']);
- }
-
- // Add item reference
- if (!empty($args['item'])) {
- $attributes['data-umami-event-item'] = esc_attr($args['item']);
- }
-
- // Add metadata
- foreach ($args as $key => $value) {
- if (in_array($key, ['id', 'source_id', 'source_type', 'owner_id', 'owner_type', 'from', 'item'])) {
- continue;
- }
- $clean_key = 'data-umami-event-' . str_replace('_', '-', sanitize_key($key));
- $attributes[$clean_key] = esc_attr($value);
- }
-
- return $attributes;
- }
-
- /**
- * Convert attributes array to HTML string
- */
- public function attributesToString(array $attributes): string
- {
- $attr_strings = [];
- foreach ($attributes as $key => $value) {
- $attr_strings[] = $key . '="' . $value . '"';
- }
- return implode(' ', $attr_strings);
- }
-
- /**
- * Public tracking methods for use in templates
- */
- public function trackContentClick(int $id, string $type, array $args = []): string
- {
- $args['id'] = $id;
-
- // Auto-detect owner for content types
- if (empty($args['owner_id']) && in_array($type, [BASE . 'tattoo', BASE . 'artwork', BASE . 'piercing'])) {
- $post = get_post($id);
- if ($post && !empty($post->post_author)) {
- $args['owner_id'] = $post->post_author;
- $args['owner_type'] = 'user';
- }
- }
-
- return $this->attributesToString(
- $this->buildTrackingAttributes('click_content', $type, $args)
- );
- }
-
- public function trackProfileClick(int $id, string $type, array $args = []): string
- {
- $args['id'] = $id;
- return $this->attributesToString(
- $this->buildTrackingAttributes('click_profile', $type, $args)
- );
- }
-
- public function trackTaxonomyClick(int $id, string $taxonomy, array $args = []): string
- {
- $args['id'] = $id;
- return $this->attributesToString(
- $this->buildTrackingAttributes('click_taxonomy', $taxonomy, $args)
- );
- }
-
- public function trackContentTaxonomyClick(int $id, string $taxonomy, array $args = []): string
- {
- $args['id'] = $id;
- return $this->attributesToString(
- $this->buildTrackingAttributes('click_' . $taxonomy, $taxonomy, $args)
- );
- }
-
- public function trackFavouriteToggle(int $id, string $type, bool $is_favourite, array $args = []): string
- {
- $args['id'] = $id;
- $args['action'] = $is_favourite ? 'add' : 'remove';
- return $this->attributesToString(
- $this->buildTrackingAttributes('toggle_favourite', $type, $args)
- );
- }
-
- public function trackFeedView(int $id, string $type, array $args = []): array
- {
- $args['id'] = $id;
- return $this->buildTrackingAttributes('view_feed', $type, $args);
- }
-
- /***************************************************************************
- * API DATA COLLECTION METHODS
- ***************************************************************************/
-
- /**
- * Get analytics data from Umami API
- */
- public function getAnalytics(string $start_date, string $end_date, bool $use_cache = true): ?array
- {
- if (empty($this->api_url) || empty($this->api_token)) {
- return null;
- }
-
- // Check cache first
- if ($use_cache && $this->cache) {
- $cache_key = md5("analytics_{$start_date}_{$end_date}");
- $cached = $this->cache->get($cache_key);
- if ($cached !== false) {
- return $cached;
- }
- }
-
- try {
- $url = rtrim($this->api_url, '/') . "/api/websites/{$this->website_id}/stats";
- $params = [
- 'startAt' => strtotime($start_date) * 1000,
- 'endAt' => strtotime($end_date) * 1000
- ];
-
- $response = wp_remote_get($url . '?' . http_build_query($params), [
- 'headers' => [
- 'Authorization' => 'Bearer ' . $this->api_token,
- 'Accept' => 'application/json'
- ],
- 'timeout' => 30
- ]);
-
- if (is_wp_error($response)) {
- $this->logError('API request failed', ['error' => $response->get_error_message()]);
- return null;
- }
-
- $body = wp_remote_retrieve_body($response);
- $data = json_decode($body, true);
-
- if (json_last_error() !== JSON_ERROR_NONE) {
- $this->logError('Invalid JSON response from API');
- return null;
- }
-
- // Cache the result
- if ($use_cache && $this->cache) {
- $this->cache->set($cache_key, $data, $this->ttl);
- }
-
- return $data;
-
- } catch (Exception $e) {
- $this->logError('Exception during API call', ['error' => $e->getMessage()]);
- return null;
- }
- }
-
- /**
- * Get page views for a specific URL
- */
- public function getPageViews(string $url, string $start_date, string $end_date): ?array
- {
- if (empty($this->api_url) || empty($this->api_token)) {
- return null;
- }
-
- $api_endpoint = rtrim($this->api_url, '/') . "/api/websites/{$this->website_id}/pageviews";
- $params = [
- 'startAt' => strtotime($start_date) * 1000,
- 'endAt' => strtotime($end_date) * 1000,
- 'url' => $url,
- 'unit' => 'day'
- ];
-
- $response = wp_remote_get($api_endpoint . '?' . http_build_query($params), [
- 'headers' => [
- 'Authorization' => 'Bearer ' . $this->api_token,
- 'Accept' => 'application/json'
- ],
- 'timeout' => 30
- ]);
-
- if (is_wp_error($response)) {
- return null;
- }
-
- return json_decode(wp_remote_retrieve_body($response), true);
- }
-
- /**
- * Get custom events data
- */
- public function getEvents(string $event_name = '', string $start_date = '', string $end_date = ''): ?array
- {
- if (empty($this->api_url) || empty($this->api_token)) {
- return null;
- }
-
- $api_endpoint = rtrim($this->api_url, '/') . "/api/websites/{$this->website_id}/events";
-
- $params = [];
- if ($start_date && $end_date) {
- $params['startAt'] = strtotime($start_date) * 1000;
- $params['endAt'] = strtotime($end_date) * 1000;
- }
- if ($event_name) {
- $params['event'] = $event_name;
- }
-
- $url = $api_endpoint;
- if (!empty($params)) {
- $url .= '?' . http_build_query($params);
- }
-
- $response = wp_remote_get($url, [
- 'headers' => [
- 'Authorization' => 'Bearer ' . $this->api_token,
- 'Accept' => 'application/json'
- ],
- 'timeout' => 30
- ]);
-
- if (is_wp_error($response)) {
- return null;
- }
-
- return json_decode(wp_remote_retrieve_body($response), true);
- }
-
- /**
- * Collect daily analytics data and store in database
- */
- public function collectDailyData(): void
- {
- if (!$this->isSetUp() || empty($this->api_token)) {
- return;
- }
-
- $yesterday = date('Y-m-d', strtotime('-1 day'));
- $data = $this->getAnalytics($yesterday, $yesterday, false);
-
- if (!$data) {
- $this->logError('Failed to collect daily data', ['date' => $yesterday]);
- return;
- }
-
- global $wpdb;
-
- // Store basic metrics
- $wpdb->insert($this->metrics_table, [
- 'date' => $yesterday,
- 'pageviews' => $data['pageviews']['value'] ?? 0,
- 'visitors' => $data['visitors']['value'] ?? 0,
- 'visits' => $data['visits']['value'] ?? 0,
- 'bounces' => $data['bounces']['value'] ?? 0,
- 'total_time' => $data['totaltime']['value'] ?? 0,
- 'created_at' => current_time('mysql')
- ]);
-
- // Collect and store events
- $events = $this->getEvents('', $yesterday, $yesterday);
- if ($events) {
- foreach ($events as $event) {
- $wpdb->insert($this->events_table, [
- 'date' => $yesterday,
- 'event_name' => $event['x'] ?? '',
- 'event_count' => $event['y'] ?? 0,
- 'created_at' => current_time('mysql')
- ]);
- }
- }
-
- $this->logDebug('Successfully collected daily data', ['date' => $yesterday]);
- }
-
- /***************************************************************************
- * HELPER METHODS
- ***************************************************************************/
-
- /**
- * Get current user type for tracking
- */
- private function getCurrentUserType(): string
- {
- if (!is_user_logged_in()) {
- return 'guest';
- }
-
- $user = wp_get_current_user();
- $roles = $user->roles;
-
- if (empty($roles)) {
- return 'user';
- }
-
- $role = array_values($roles)[0];
- return str_replace(BASE, '', $role);
- }
-
- /**
- * Determine traffic source
- */
- private function getTrafficSource(): string
- {
- if (isset($_GET['utm_source'])) {
- return sanitize_text_field($_GET['utm_source']);
- }
-
- if (isset($_SERVER['HTTP_REFERER'])) {
- $referer = $_SERVER['HTTP_REFERER'];
- $site_url = get_site_url();
-
- if (strpos($referer, $site_url) === false) {
- $domain = parse_url($referer, PHP_URL_HOST);
- return $domain ?: 'external';
- }
-
- return 'internal';
- }
-
- return 'direct';
- }
-
- /**
- * Get website ID
- */
- public function getWebsiteId(): string
- {
- $this->ensureInitialized();
- return $this->website_id;
- }
-
- /**
- * Get tracking script URL
- */
- public function getTrackingScriptUrl(): string
- {
- if (!empty($this->api_url)) {
- return rtrim($this->api_url, '/') . '/script.js';
- }
-
- // Default to Umami cloud
- return 'https://cloud.umami.is/script.js';
- }
-
- /***************************************************************************
- * REQUIRED ABSTRACT METHOD IMPLEMENTATIONS
- ***************************************************************************/
-
- /**
- * Get request headers for API calls
- */
- protected function getRequestHeaders(): array
- {
- $headers = [
- 'Accept' => 'application/json',
- 'Content-Type' => 'application/json'
- ];
-
- if (!empty($this->api_token)) {
- $headers['Authorization'] = 'Bearer ' . $this->api_token;
- }
-
- return $headers;
- }
-
- protected function handleRefreshData():array
- {
- // Collect today's data
- $today = date('Y-m-d');
- $data = $this->getAnalytics($today, $today, false);
-
- if ($data) {
- // Clear cache for today
- $cache_key = md5("analytics_{$today}_{$today}");
- $this->cache->forget($cache_key);
-
- return [
- 'success' => true,
- 'message' => 'Data refreshed successfully',
- 'data' => $data
- ];
- }
-
- return [
- 'success' => false,
- 'message' => 'Something went wrong...'
- ];
- }
-
- /**
- * Process queued operations
- */
- public function processOperation(array|\WP_Error $result, object $operation, array $data): array|\WP_Error
- {
- $prefix = strtolower($this->service_name) . '_';
-
- switch ($operation->type) {
- case $prefix . 'collect_analytics':
- $this->collectDailyData();
- return ['success' => true, 'message' => 'Analytics collected'];
-
- default:
- return $result;
- }
- }
-
- /**
- * Test API connection
- */
- protected function performConnectionTest(): bool
- {
- // Basic tracking only needs website ID
- if (empty($this->website_id)) {
- return false;
- }
-
- // If API credentials provided, test them
- if (!empty($this->api_url) && !empty($this->api_token)) {
- try {
- $response = wp_remote_get(rtrim($this->api_url, '/') . '/api/websites', [
- 'headers' => [
- 'Authorization' => 'Bearer ' . $this->api_token,
- 'Accept' => 'application/json'
- ],
- 'timeout' => 10
- ]);
-
- if (is_wp_error($response)) {
- return false;
- }
-
- return wp_remote_retrieve_response_code($response) === 200;
-
- } catch (Exception $e) {
- $this->logError('Connection test failed', ['error' => $e->getMessage()]);
- return false;
- }
- }
-
- // Website ID exists, basic tracking will work
- return true;
- }
-
- /**
- * Get service description
- */
- public function getServiceDescription(): string
- {
- return "Privacy-focused analytics to understand your website traffic.";
- }
-
- /**
- * Create database tables for metrics storage
- */
- public static function createTables(): void
- {
- global $wpdb;
- $charset_collate = $wpdb->get_charset_collate();
-
- // Events table
- $events_table = $wpdb->prefix . BASE . 'umami_events';
- $events_sql = "CREATE TABLE IF NOT EXISTS $events_table (
- id bigint(20) UNSIGNED NOT NULL AUTO_INCREMENT,
- date date NOT NULL,
- event_name varchar(255) NOT NULL,
- event_count int(11) NOT NULL DEFAULT 0,
- event_data longtext,
- created_at datetime NOT NULL,
- PRIMARY KEY (id),
- KEY date_event (date, event_name)
- ) $charset_collate;";
-
- // Metrics table
- $metrics_table = $wpdb->prefix . BASE . 'performance_metrics';
- $metrics_sql = "CREATE TABLE IF NOT EXISTS $metrics_table (
- id bigint(20) UNSIGNED NOT NULL AUTO_INCREMENT,
- date date NOT NULL,
- pageviews int(11) NOT NULL DEFAULT 0,
- visitors int(11) NOT NULL DEFAULT 0,
- visits int(11) NOT NULL DEFAULT 0,
- bounces int(11) NOT NULL DEFAULT 0,
- total_time int(11) NOT NULL DEFAULT 0,
- created_at datetime NOT NULL,
- PRIMARY KEY (id),
- UNIQUE KEY date_unique (date)
- ) $charset_collate;";
-
- require_once(ABSPATH . 'wp-admin/includes/upgrade.php');
- dbDelta($events_sql);
- dbDelta($metrics_sql);
+ // TODO: Implement initialize() method.
}
}
--
Gitblit v1.10.0