From de317675a8069b747cb253ba3e2b5dc394ca36ef Mon Sep 17 00:00:00 2001
From: Jake Vanderwerf <get@jakevanderwerf.ca>
Date: Sat, 18 Jul 2026 21:44:51 +0000
Subject: [PATCH] =Base Integrations mainly done - doing a test with setting up the Square integration to follow
---
inc/integrations/services/HelcimOld.php | 1359 ++++
inc/integrations/services/UmamiOld.php | 803 ++
inc/integrations/Instance.php | 17
inc/integrations/services/GoogleMyBusiness.php | 1
inc/integrations/services/FacebookOld.php | 701 ++
inc/integrations/_Base.php | 59
inc/integrations/services/InstagramOld.php | 705 ++
inc/integrations/services/PostMarkOld.php | 661 ++
inc/integrations/SyncTo.php | 455 +
inc/integrations/services/CloudflareOld.php | 266
inc/integrations/_setup.php | 12
inc/integrations/services/BlueSkyOld.php | 411 +
inc/integrations/services/Helcim.php | 2
inc/integrations/Admin.php | 13
inc/integrations/OAuth.php | 94
inc/integrations/SyncDefaults.php | 171
inc/registrar/helpers/AddIntegrationFields.php | 2
inc/integrations/Field.php | 38
inc/integrations/services/Square.php | 3772 +----------
inc/integrations/Integrations.php | 1656 -----
inc/integrations/services/SquareOld.php | 3506 +++++++++++
inc/integrations/services/GoogleMyBusinessOld.php | 2974 +++++++++
inc/integrations/OAuthURLs.php | 26
inc/integrations/SyncHelpers.php | 47
inc/integrations/OAuthCredentials.php | 3
inc/integrations/UserConnection.php | 8
inc/integrations/services/GoogleMapsOld.php | 834 ++
27 files changed, 13,606 insertions(+), 4,990 deletions(-)
diff --git a/inc/integrations/Admin.php b/inc/integrations/Admin.php
index 122a459..3f321a5 100644
--- a/inc/integrations/Admin.php
+++ b/inc/integrations/Admin.php
@@ -78,15 +78,8 @@
return $result;
}
- public function response(bool $success, string $message):array
- {
- return [
- 'success' => $success,
- 'message' => $message
- ];
- }
- public function renderConnection():string
+ public function renderConnection(bool $return = false):string
{
if ($this->userID && !JVB()->userCanConnect($this->service_name, $this->userID)) {
return '';
@@ -176,6 +169,10 @@
$form .= $this->outputActionButtons();
$form .= '</form>';
+ if (!$return) {
+ echo $form;
+ }
+
return $form;
}
protected function outputActionButtons():string
diff --git a/inc/integrations/Field.php b/inc/integrations/Field.php
index 9217387..788400b 100644
--- a/inc/integrations/Field.php
+++ b/inc/integrations/Field.php
@@ -11,9 +11,13 @@
public string $name;
public string $label;
public string $type;
+ public string $hint;
public bool $required;
public string|Closure|bool $permission;
protected array $options;
+ protected mixed $default;
+ protected string $subtype;
+ protected bool $hidden = false;
public function __construct(string $name, string $label, string $type, callable|string|bool $permission)
{
@@ -49,6 +53,24 @@
{
$this->options = $options;
}
+ public function setDefault(mixed $default):void
+ {
+ $this->default = $default;
+ }
+
+ public function setHint(string $hint):void
+ {
+ $this->hint = $hint;
+ }
+ public function setHidden(bool $hidden = true):void
+ {
+ $this->hidden = $hidden;
+ }
+
+ public function setSubType(string $type):void
+ {
+ $this->subType = $type;
+ }
public function getConfig():array
{
@@ -57,12 +79,24 @@
'label' => $this->label,
'type' => $this->type,
];
- if ($this->required) {
+ if (isset($this->required)) {
$conf['required'] = true;
}
- if ($this->options) {
+ if (isset($this->options)) {
$conf['options'] = $this->options;
}
+ if (isset($this->default)) {
+ $conf['default'] = $this->default;
+ }
+ if (isset($this->hint)) {
+ $conf['hint'] = $this->hint;
+ }
+ if (isset($this->subType)) {
+ $conf['subtype'] = $this->subType;
+ }
+ if ($this->hidden) {
+ $conf['hidden'] = true;
+ }
return $conf;
}
}
diff --git a/inc/integrations/Instance.php b/inc/integrations/Instance.php
new file mode 100644
index 0000000..28d5e66
--- /dev/null
+++ b/inc/integrations/Instance.php
@@ -0,0 +1,17 @@
+<?php
+namespace JVBase\integrations;
+
+if (!defined('ABSPATH')) {
+ exit;
+}
+
+trait Instance {
+ protected static self $instance;
+ public static function getInstance():self
+ {
+ if (!isset(self::$instance)) {
+ self::$instance = new self();
+ }
+ return self::$instance;
+ }
+}
diff --git a/inc/integrations/Integrations.php b/inc/integrations/Integrations.php
index dd8ede2..f9a34ef 100644
--- a/inc/integrations/Integrations.php
+++ b/inc/integrations/Integrations.php
@@ -3,17 +3,9 @@
use Exception;
use JVBase\managers\Cache;
-use JVBase\managers\ErrorHandler;
-use JVBase\managers\queue\executors\IntegrationExecutor;
-use JVBase\managers\queue\mergers\DefaultMerger;
-use JVBase\managers\queue\TypeConfig;
use JVBase\managers\UploadManager;
-use JVBase\meta\Form;
-use JVBase\meta\Meta;
-use JVBase\registrar\helpers\AddIntegrationFields;
use JVBase\registrar\Registrar;
use WP_Error;
-use WP_Post;
if (!defined('ABSPATH')) {
exit;
@@ -32,13 +24,6 @@
{
use _Base;
- //Flag to allow for custom settings (defaults, etc) for an integration in the dashboard
- public bool $hasExtraOptions = false;
- public bool $hasBatchCreate = false;
- public bool $hasBatchUpdate = false;
- public bool $hasBatchDelete = false;
- public bool $canCreateOnUpdate = false;
-
/**
* API Configuration
* These properties define how the integration connects to external services
@@ -52,16 +37,11 @@
/**
* Credentials & State
*/
-
- protected string $defaultContent = 'post'; //Default integration content type, is none is set. MUST EXIST as array key in $this->>getContentTypes
-
protected array $allowedContent = [];
/**
* Caching Configuration
*/
- protected ?string $cacheName = null;
- protected Cache $cache;
protected array $cacheStrategy = [
'aggressive' => 3600, // 1 hour for stable data (e.g., profile info)
'moderate' => 300, // 5 minutes for semi-dynamic data (e.g., posts)
@@ -69,26 +49,6 @@
'none' => 0 // No caching for real-time data
];
- /**
- * Post Syncing Capabilities
- * Define what sync operations this integration supports
- */
- protected array $canSync = [
- 'initial' => false, // Can share new posts to the service
- 'update' => false, // Can update already-shared posts
- 'delete' => false, // Can remove posts from the service
- ];
- protected array $syncPostTypes = []; // Post types that can be synced (e.g., ['artwork', 'tattoo']): usually built by Registrar.php if the integration name exists as a key in [ 'integrations' => []]
- protected array $syncTaxonomies = []; // Post types that can be synced (e.g., ['artwork', 'tattoo']): usually built by Registrar.php if the integration name exists as a key in [ 'integrations' => []]
- protected array $contentTypes = []; // Integration's available content types. Set by child classes' getContentTypes
- protected bool $has_content = false; // Whether integration has content that can sync
- /**
- * Error Handling Configuration
- */
- protected array $lastError = [];
- protected array $retryDelays = [1, 2, 5]; // Exponential backoff in seconds
-
-
protected function __construct(?int $userID = null)
{
@@ -98,7 +58,11 @@
$this->getPostTypes();
$this->getTaxonomies();
- $this->setContentTypes();
+ $this->getUsers();
+
+ if (method_exists($this, 'setContentTypes')) {
+ $this->setContentTypes();
+ }
$this->registerHooks();
if (method_exists($this, 'setQueueTypes')) {
@@ -106,193 +70,30 @@
}
$this->initializeRateLimiters();
+
if (method_exists($this, 'initializeActions')) {
$this->initializeActions();
}
if (method_exists($this, 'addOAuthActions')) {
$this->addOAuthActions();
}
-
- add_filter('jvbShouldRenderMeta', [$this, 'checkRenderField'], 10, 4);
- }
- protected function initializeRateLimiters():void
- {
- $key = $this->service_name;
- if (!is_null($this->userID)) {
- $key .= '_'.$this->userID;
- }
-
- if ($this->hasRequests && !isset($this->requestLimiter) && !is_null($this->oauthLimiter)) {
- $this->requestLimiter = new RateLimits($key);
- }
- if ($this->hasOAuth && !isset($this->oauthLimiter) && !is_null($this->oauthLimiter)) {
- $key .= '_oauth';
- $this->oauthLimiter = new RateLimits($key, ['s' => 1,'m' => 10, 'h' => 100]);
- }
- }
-
-
- protected function setContentTypes():void
- {
-
- }
- public function getContentTypes(string $type):array
- {
- return (array_key_exists($type, $this->contentTypes)) ? $this->contentTypes[$type] : $this->contentTypes;
- }
-
- public function checkRenderField($shouldRender, $name, $type, $objectType):bool
- {
- if ($type !== 'form') {
- return $shouldRender;
- }
-
- if (!$this->isSetUp() && str_contains($name, $this->service_name)) {
- return false;
- }
- return $shouldRender;
- }
-
- public function handleOAuthConnect(): array
- {
- if (!$this->hasOAuth) {
- return ['success' => false, 'message' => 'Not an OAuth service'];
- }
-
- // This would typically redirect to OAuth provider
- // Child classes can override for specific behavior
- $auth_url = $this->getOAuthUrl();
-
- if ($auth_url) {
- return [
- 'success' => true,
- 'redirect' => $auth_url,
- ];
- }
-
- return ['success' => false, 'message' => 'Failed to generate OAuth URL'];
- }
-
- /**
- * Check if OAuth token is valid and not expired
- * @return bool
- */
- public function isOAuthValid(): bool
- {
- if (!$this->isOAuthService) {
- return false;
- }
-
- // Check if we have tokens
- if (empty($this->credentials['access_token'])) {
- return false;
- }
-
- // Check token expiry if stored
- if (!empty($this->credentials['expires_at'])) {
- $expires_at = intval($this->credentials['expires_at']);
- if ($expires_at <= time()) {
- return false;
- }
- }
-
- // For services without expiry info, do a test API call
- return true;
-// return $this->testConnection();
- }
-
-
-
- public function getOAuthUrlAction(array $data = []): array
- {
- if (!$this->isOAuthService) {
- return ['success' => false, 'message' => 'Not an OAuth service'];
- }
-
- $return_url = $data['return_url'] ?? null;
- $auth_url = $this->getOAuthUrl($return_url);
-
- if ($auth_url) {
- return [
- 'success' => true,
- 'auth_url' => $auth_url,
- 'popup' => true
- ];
- }
-
- return ['success' => false, 'message' => 'Failed to generate OAuth URL'];
}
protected function getPostTypes(): void
{
- $this->syncPostTypes = Registrar::withIntegration($this->service_name);
+ $this->syncPostTypes = Registrar::withIntegration($this->service_name, 'post');
+ }
+
+ protected function getUsers():void
+ {
+ $this->syncUsers = Registrar::withIntegration($this->service_name, 'user');
}
protected function getTaxonomies():void
{
- $key = BASE . $this->service_name . '_sync_taxonomies';
- $taxonomies = get_option($key, false);
-
- if (!$taxonomies) {
- // Combine both content and taxonomy filtering
- $taxonomies = [];
- foreach (Registrar::withFeature('is_content', 'term') as $type) {
- $registrar = Registrar::getInstance($type);
- if ($registrar->hasIntegration($this->service_name)) {
- $taxonomies[] = $registrar->getSlug();
- }
- }
-
- update_option($key, $taxonomies);
- }
-
- $this->syncTaxonomies = $taxonomies;
+ $this->syncTaxonomies = Registrar::withIntegration($this->service_name, 'term');
}
- protected function getRedirectUri(): string
- {
-// if (!empty($this->oauth['redirect_uri'])) {
-// return $this->oauth['redirect_uri'];
-// }
- if ($this->hasOAuth) {
- return admin_url('admin-ajax.php?action=' . BASE . $this->service_name . '_oauth_callback');
- }
- // Changed from admin-ajax.php to REST endpoint
- return rest_url('jvb/v1/oauth/callback?service=' . $this->service_name);
- }
-
- /**
- * Used by IntegrationsRoutes.php
- * @param string $code
- * @param string $state
- * @return array
- */
- public function handleOAuthCode(string $code, string $state): array
- {
- try {
- $this->loadCredentials();
- $tokens = $this->exchangeOAuthCode($code);
-
- if (!$tokens) {
- return ['success' => false, 'message' => 'Failed to exchange authorization code'];
- }
-
- $credentials = array_merge($this->credentials, $tokens);
- $credentials = $this->addCredentialData($credentials, $tokens);
- $saved = $this->saveCredentials($credentials, false);
-
- if ($saved) {
- return ['success' => true, 'message' => 'Successfully connected'];
- }
-
- return ['success' => false, 'message' => 'Failed to save credentials'];
- } catch (Exception $e) {
- $this->logError('OAuth code exchange failed', ['error' => $e->getMessage()]);
- return ['success' => false, 'message' => 'OAuth error: ' . $e->getMessage()];
- }
- }
-
-
/*********************************************************************
* ABSTRACT METHODS - MUST BE IMPLEMENTED BY CHILD CLASSES
*********************************************************************/
@@ -338,33 +139,20 @@
try {
// Process and validate credentials
if (!$this->validateCredentials($credentials)) {
- $this->logError('Invalid credentials');
- return [
- 'success' => false,
- 'message' => 'Credentials not formatted correctly'
- ];
+ $this->logError('saveCredentials', 'Invalid credentials');
+ return $this->response(false, 'Credentials not formatted correctly');
}
+ //Merge the new credentials with the old ones (new stuff overriding any old stuff)
$old = $this->getCredentials();
$credentials = array_merge($old, $credentials);
- // Temporarily set credentials for testing
- $this->credentials = $credentials;
- $this->initialize();
-
// Test the connection before saving
if ($test) {
if (!$this->testConnection(true)) {
- $this->logError('Connection test failed');
- // Revert to old credentials
- $this->credentials = $old;
- $this->initialize();
+ $this->logError('saveCredentials', 'Connection test failed');
- return [
- 'success' => false,
- 'message' => 'Connection failed. Please check your credentials.',
- 'test_failed' => true
- ];
+ return $this->response(false, 'Connection failed. Please check your credentials');
}
}
@@ -380,17 +168,10 @@
$this->updateLastTestedTime();
$this->clearCache();
- return [
- 'success' => true,
- 'message' => 'Credentials validated and saved successfully',
- 'reload' => true
- ];
+ return $this->response(true, 'Credentials validated and saved successfully');
}
- return [
- 'success' => false,
- 'message' => 'Failed to save credentials to database'
- ];
+ return $this->response(false, 'Failed to save credentials to database');
} catch (\Exception $e) {
// Revert to old credentials on any error
@@ -398,13 +179,7 @@
$this->service_name,
$this->userID
);
- $this->credentials = $old;
- $this->initialize();
-
- return [
- 'success' => false,
- 'message' => 'Validation error: ' . $e->getMessage()
- ];
+ return $this->response(false, 'Validation error: '.print_r($e->getMessage(), true));
}
}
@@ -414,9 +189,7 @@
public function deleteCredentials():array
{
$success = Auth::getInstance()->deleteCredentials($this->service_name, $this->userID);
- return [
- 'success' => $success
- ];
+ return $this->response(true, 'Deleted successfully');
}
/**
* Validate credentials before storing
@@ -543,52 +316,6 @@
/******************************************************************
POST SYNC
******************************************************************/
- /**
- * Handle post save for syncing
- *
- * Override to implement custom sync logic when posts are saved.
- * Check the $settings array for post type specific configuration.
- *
- * @param int $postID The post ID
- * @param WP_Post $post The post object
- * @param bool $update Whether this is an update
- * @param array $settings Post type integration settings
- * @return void
- */
- protected function handleTheSavePost(int $postID, WP_Post $post, bool $update, array $settings): void
- {
- // Override in child classes that support post syncing
- // Example implementation:
- /*
- $fields = $this->getSyncFields($postID, 'post', ['schedule_' . $this->service_name]);
- $options = $fields['schedule_' . $this->service_name] !== ''
- ? ['scheduled' => strtotime($fields['schedule_' . $this->service_name])]
- : [];
-
- $this->queueOperation(
- 'sync_post',
- ['post_id' => $postID, 'is_update' => $update],
- $options
- );
- */
- }
-
- /*********************************************************************
- * API REQUEST METHODS
- *********************************************************************/
-
-
-
-
-
-
-
-
-
-
-
-
-
/*********************************************************************
* SYNC METHODS
@@ -668,7 +395,8 @@
// Let child classes register additional hooks if needed
$this->registerAdditionalHooks();
- if (!empty($this->syncPostTypes) && is_null($this->userID)) {
+
+ if (!empty($this->syncPostTypes)) {
$this->addSavePost();
add_action('transition_post_status', [$this, 'handlePostStatusTransition'], 10, 3);
@@ -676,64 +404,21 @@
add_action('before_delete_post', [$this, 'handleDeletePost'], 10, 1);
}
}
- if (!empty($this->syncTaxonomies) && is_null($this->userID)) {
+ if (!empty($this->syncTaxonomies)) {
add_action('saved_term', [$this, 'handleSaveTerm'], 20, 5);
if ($this->canSync['delete']) {
add_action('pre_delete_term', [$this, 'handleDeleteTerm'], 10, 2);
}
}
- add_action('init', [$this, 'registerQueueTypes'], 10);
+ if (method_exists($this, 'registerQueueTypes')) {
+ add_action('init', [$this, 'registerQueueTypes'], 10);
+ }
}
- public function addSavePost():void
- {
- if (!has_action('save_post', [$this, 'handleSavePost'])) {
- add_action('save_post', [$this, 'handleSavePost'], 20, 3);
- }
- }
- public function removeSavePost():void
- {
- remove_action('save_post', [$this, 'handleSavePost'], 20, 3);
- }
- public function registerQueueTypes():void
- {
- if (empty($this->syncTaxonomies) && empty($this->syncPostTypes)) {
- return;
- }
- $queue = JVB()->queue();
- $executor = new IntegrationExecutor();
- $queue->registry()->register(self::$syncTo, new TypeConfig(
- mergeable: new DefaultMerger('items'),
- executor: $executor,
- chunkKey: 'items',
- chunkSize: 50,
- maxRetries: 3,
- ));
- if ($this->canSync['delete']) {
- $queue->registry()->register(self::$deleteFrom, new TypeConfig(
- mergeable: new DefaultMerger('external_ids'),
- executor: $executor,
- chunkKey: 'external_ids',
- chunkSize: 200,
- maxRetries: 2
- ));
- }
-
- $queue->registry()->register(self::$syncFrom, new TypeConfig(
- executor: $executor,
- maxRetries: 3
- ));
-
- $this->registerAdditionalQueueTypes($executor);
- }
- protected function registerAdditionalQueueTypes(IntegrationExecutor $executor):void
- {
- //Empty. Integration extensions can register additional operation types from here.
- }
/**
* Handle connection setup and credential storage
@@ -755,11 +440,11 @@
}
// Store credentials
- $this->credentials = array_merge($this->credentials ?? [], $sanitized);
- $this->credentials['last_updated'] = time();
+ $credentials = array_merge($this->loadCredentials() ?? [], $sanitized);
+ $credentials['last_updated'] = time();
// Save to database
- $saved = $this->saveCredentials($this->credentials);
+ $saved = $this->saveCredentials($credentials);
if (!$saved) {
return [
@@ -810,6 +495,7 @@
/**
* Ensure service is initialized
+ * TODO: I don't think this is necessary anymore.
*/
protected function ensureInitialized(): void
{
@@ -819,404 +505,6 @@
$this->initialize();
}
-
- /***************************************************************
- ERROR HANDLING
- ***************************************************************/
- /**
- * Handle error responses
- */
- protected function handleApiError(int $code, string $body, string $endpoint): void
- {
- $message = "API Error ({$code}): ";
- $decoded = json_decode($body, true);
-
- // Extract error details
- $error_details = $this->extractErrorDetails($decoded, $body);
- $message .= $error_details['message'];
-
- // Determine error severity based on HTTP code
- $severity = $this->getErrorSeverity($code);
-
- // Build comprehensive error context
- $error_context = [
- 'service' => $this->service_name,
- 'endpoint' => $endpoint,
- 'http_code' => $code,
- 'error_type' => $this->categorizeApiError($code),
- 'error_details' => $error_details,
- 'user_id' => $this->userID,
- 'request_time' => time(),
- 'consecutive_errors' => $this->error_stats['consecutive_errors'],
- 'is_oauth' => $this->isOAuthService,
- 'api_version' => $this->apiVersion,
- 'integration_healthy' => $this->is_healthy
- ];
-
- // Add rate limit information if present
- if ($code === 429) {
- $error_context['rate_limit'] = $this->extractRateLimitInfo($decoded, $body);
- }
-
- // Log to ErrorHandler with proper severity
- $this->logError($message, $error_context, $severity);
-
- // Update error statistics
- $this->updateErrorStats($code, $endpoint);
-
- // Store last error for debugging
- $this->lastError = [
- 'code' => $code,
- 'message' => $message,
- 'endpoint' => $endpoint,
- 'timestamp' => time(),
- 'context' => $error_context
- ];
- }
-
-
- /*****************************************************************
- OAUTH
- *****************************************************************/
-
- /**
- * Get OAuth authorization URL
- */
- public function getOAuthUrl(?string $return_url = null): string
- {
-
- if (!$this->hasOAuth) {
- return '';
- }
-
- if (empty($this->credentials)) {
- $this->ensureInitialized();
- }
-
- if (empty($this->oauth['authorize'])) {
- $this->logError('OAuth authorize URL not configured');
- return '';
- }
-
- // Build base parameters
- $params = [
- 'client_id' => $this->credentials['client_id'] ?? $this->credentials['app_id'] ?? '',
- 'redirect_uri' => $this->getRedirectUri(),
- 'response_type' => 'code',
- 'scope' => implode(' ', $this->oauth['scopes'] ?? [])
- ];
- $state_key = wp_generate_password(32, false);
- $user_id = $this->userID??0;
-
- // Store state data in transient (expires in 10 minutes)
- set_transient(
- 'oauth_state_' . $state_key,
- [
- 'service' => $this->service_name,
- 'user_id' => $user_id,
- 'created' => time()
- ],
- 600
- );
-
- $state_parts = [
- $state_key,
- $user_id
- ];
-
- // Add return URL if provided
- if ($return_url) {
- $state_parts[] = base64_encode($return_url);
- } else {
- $state_parts[] = base64_encode(admin_url('admin.php?page=jvb-integrations'));
- }
-
- $params['state'] = implode('|', $state_parts);
-
- // Allow child classes to modify params (they can override/remove as needed)
- if (method_exists($this, 'addOAuthParams')) {
- $params = $this->addOAuthParams($params);
- }
-
- return $this->oauth['authorize'] . '?' . http_build_query($params);
- }
-
- /**
- * Add service-specific OAuth parameters
- */
- protected function addOAuthParams(array $params): array
- {
- // Override in child classes to add service-specific params
- // e.g., access_type, prompt, etc.
- return $params;
- }
-
-
-
- /**
- * Extract retry-after header from response
- *
- * @param array|WP_Error $response WordPress HTTP response
- * @return int Seconds to wait before retry
- */
- protected function extractRetryAfter($response): int
- {
- if (is_wp_error($response)) {
- return 5;
- }
-
- $headers = wp_remote_retrieve_headers($response);
-
- if (isset($headers['retry-after'])) {
- // Could be seconds or HTTP date
- $retry_after = $headers['retry-after'];
-
- if (is_numeric($retry_after)) {
- return (int) $retry_after;
- }
-
- // Try to parse as date
- $timestamp = strtotime($retry_after);
- if ($timestamp !== false) {
- return max(0, $timestamp - time());
- }
- }
-
- return 5; // Default wait time
- }
-
- /**
- * Exchange OAuth code for tokens
- */
- protected function exchangeOAuthCode(string $code): ?array
- {
- $this->ensureInitialized();
-
- // Build request data
- $request_data = [
- 'client_id' => $this->credentials['client_id'] ?? '',
- 'client_secret' => $this->credentials['client_secret'] ?? '',
- 'code' => $code,
- 'grant_type' => 'authorization_code',
- 'redirect_uri' => $this->getRedirectUri()
- ];
-
- $oauth_endpoint = $this->oauth['token'];
- $response = $this->makeOAuthRequest('POST', $oauth_endpoint, $request_data);
-
- if (is_wp_error($response)) {
- $this->logError('OAuth token exchange failed', [
- 'error' => $response->get_error_message(),
- 'code' => $response->get_error_code()
- ]);
- return null;
- }
-
- // Parse response
- if (isset($response['access_token'])) {
- $expires_in = $response['expires_in'] ?? 2592000; // 30 days default
-
- return [
- 'access_token' => $response['access_token'],
- 'refresh_token' => $response['refresh_token'] ?? '',
- 'expires_in' => $expires_in,
- 'expires_at' => time() + $expires_in, // Calculate expiry timestamp
- 'token_type' => $response['token_type'] ?? 'Bearer',
- 'merchant_id' => $response['merchant_id'] ?? '',
- 'scope' => $response['scope'] ?? ''
- ];
- }
-
- $this->logError('Failed to obtain access token', ['response' => $response]);
- return null;
- }
-
- /**
- * Add service-specific credential data
- */
- protected function addCredentialData(array $credentials, array $tokens): array
- {
- // Override in child classes to add service-specific data
- return $credentials;
- }
-
- /**
- * Check if token should be proactively refreshed
- * Different from isOAuthValid() which checks if token is actually expired
- */
- protected function shouldRefreshToken(): bool
- {
- if (!$this->hasOAuth || $this->refresh_interval === 0) {
- return false;
- }
-
- // If no expiry info, we can't proactively refresh
- if (empty($this->credentials['expires_at'])) {
- return false;
- }
-
- $expires_at = intval($this->credentials['expires_at']);
- $time_until_expiry = $expires_at - time();
-
- // Refresh if we're within the refresh interval window
- return $time_until_expiry > 0 && $time_until_expiry <= $this->refresh_interval;
- }
- /**
- * Get time until token refresh is recommended
- * Useful for displaying in admin UI
- */
- public function getTimeUntilRefresh(): ?int
- {
- if ($this->refresh_interval === 0 || empty($this->credentials['expires_at'])) {
- return null;
- }
-
- $expires_at = intval($this->credentials['expires_at']);
- $refresh_at = $expires_at - $this->refresh_interval;
- $time_until_refresh = $refresh_at - time();
-
- return max(0, $time_until_refresh);
- }
-
- /**
- * Get token freshness status
- * Returns: 'fresh', 'should_refresh', 'expired', or 'no_expiry_info'
- */
- public function getTokenStatus(): string
- {
- if (!$this->hasOAuth) {
- return 'not_oauth';
- }
-
- if (empty($this->credentials['access_token'])) {
- return 'no_token';
- }
-
- if (empty($this->credentials['expires_at'])) {
- return 'no_expiry_info';
- }
-
- $expires_at = intval($this->credentials['expires_at']);
- $now = time();
-
- if ($expires_at <= $now) {
- return 'expired';
- }
-
- if ($this->shouldRefreshToken()) {
- return 'should_refresh';
- }
-
- return 'fresh';
- }
- /**
- * Refresh OAuth token
- */
- protected function refreshOAuthToken(): bool
- {
- if (!$this->hasOAuth || empty($this->credentials['refresh_token'])) {
- return false;
- }
-
- $request_data = [
- 'client_id' => $this->credentials['client_id'],
- 'client_secret' => $this->credentials['client_secret'],
- 'refresh_token' => $this->credentials['refresh_token'],
- 'grant_type' => 'refresh_token'
- ];
-
- $response = $this->makeOAuthRequest('POST', $this->oauth['token'], $request_data);
-
- if (is_wp_error($response)) {
- $error_message = $response->get_error_message();
-
- if (str_contains($error_message, 'invalid_grant')) {
- $this->logError('OAuth refresh token is invalid - user must re-authorize', [
- 'error' => $error_message
- ], 'critical');
-
- // Mark unhealthy immediately
- $this->error_stats['consecutive_errors'] = $this->error_threshold;
- $this->is_healthy = false;
- $this->saveErrorStats();
- }
-
- $this->logError('Failed to refresh OAuth token for '.$this->service_name, [
- 'error' => $error_message
- ]);
- return false;
- }
-
- if (isset($response['access_token'])) {
- $this->credentials['access_token'] = $response['access_token'];
- $this->credentials['expires_at'] = time() + ($response['expires_in'] ?? 2592000); // 30 days
-
- // Note: Some services return the SAME refresh token
- if (isset($response['refresh_token'])) {
- $this->credentials['refresh_token'] = $response['refresh_token'];
- }
-
- $this->saveCredentials($this->credentials);
- return true;
- }
-
- return false;
- }
-
- /**
- * Revoke OAuth access
- */
- public function revokeOAuthAccess(): bool
- {
- if (!$this->hasOAuth || empty($this->oauth['revoke'])) {
- return false;
- }
-
- if (!empty($this->credentials['access_token'])) {
- wp_remote_post($this->oauth['revoke'], [
- 'body' => ['token' => $this->credentials['access_token']]
- ]);
- }
-
- return Auth::getInstance()->deleteCredentials($this->service_name, $this->userID);
-
- }
-
- public function handleOAuthDisconnect(): array
- {
- try {
- // Revoke the token with Square using centralized request
- if (!empty($this->credentials['access_token'])) {
- $revoke_data = [
- 'client_id' => $this->credentials['client_id'] ?? '',
- 'access_token' => $this->credentials['access_token']
- ];
-
- // Make revoke request (ignore response as revoke often returns empty)
- $this->makeOAuthRequest('POST', $this->oauth['revoke'], $revoke_data);
- }
-
- // Clear stored credentials (preserve app credentials)
- $this->credentials = [
- 'client_id' => $this->credentials['client_id'] ?? '',
- 'client_secret' => $this->credentials['client_secret'] ?? '',
- 'environment' => $this->credentials['environment'] ?? 'sandbox'
- ];
-
- $this->saveCredentials($this->credentials);
- $this->clearCache();
-
- return [
- 'success' => true,
- 'message' => 'Successfully disconnected from Square'
- ];
- } catch (Exception $e) {
- return [
- 'success' => false,
- 'message' => 'Failed to disconnect: ' . $e->getMessage()
- ];
- }
- }
/**
* Generate webhook signature key for services that require it
* @return string
@@ -1225,268 +513,6 @@
{
return wp_generate_password(32, false);
}
- /**
- * Generate OAuth state parameter
- */
- protected function generateOAuthState(?int $user_id, ?string $return_url = null): string
- {
- $user_id = $user_id ?? 0;
- $state = wp_create_nonce($this->service_name . '_oauth_' . $user_id) . '|' . $user_id;
-
- if ($return_url) {
- $state .= '|' . base64_encode($return_url);
- }
-
- return $state;
- }
-
- protected function getNonce(): string
- {
- return wp_create_nonce($this->service_name . '_oauth_' . $this->userID);
- }
-
- /**
- * Determine return URL after OAuth
- */
- protected function determineReturnUrl(?int $user_id): string
- {
- if ($user_id > 0) {
- return home_url('/dash/integrations/#' . $this->service_name);
- }
-
- return admin_url('admin.php?page=jvb-integrations');
- }
-
-
- /****************************************************************
- POST SYNC
- ****************************************************************/
- /**
- * Get field mapping for a post type
- */
- protected function getFieldMapping(string $post_type): array
- {
- // Apply filter for custom mapping
- return apply_filters(
- "jvb_{$this->service_name}_field_mapping",
- [],
- $post_type,
- $this
- );
- }
-
-
- /**
- * Map WordPress fields to service fields
- */
- protected function mapFieldsToService(int $postID, array $mapping): array
- {
- $meta_manager = Meta::forPost($postID);
- $service_data = [];
-
- foreach ($mapping as $wp_field => $service_field) {
- $value = $meta_manager->get($wp_field);
-
- if ($value !== null && $value !== '') {
- $this->setNestedValue($service_data, $service_field, $value);
- }
- }
-
- return apply_filters(
- "jvb_{$this->service_name}_mapped_data",
- $service_data,
- $postID,
- $mapping
- );
- }
-
- /**
- * Set nested array value using dot notation
- */
- protected function setNestedValue(array &$array, string $path, $value): void
- {
- $keys = explode('.', $path);
- $current = &$array;
-
- foreach ($keys as $i => $key) {
- if ($i === count($keys) - 1) {
- $current[$key] = $value;
- } else {
- if (!isset($current[$key])) {
- $current[$key] = [];
- }
- $current = &$current[$key];
- }
- }
- }
- /**
- * Handle post save
- */
- public function handleSavePost(int $postID, WP_Post $post, bool $update): void
- {
- if (!is_null($this->userID)) {
- return;
- }
- error_log('=== ['.$this->service_name.']::handleSavePost called');
-
- if (!$postID || $postID === 0) {
- return;
- }
- $postType = jvbNoBase($post->post_type);
-
- if (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE) return;
- if (wp_is_post_revision($postID)) return;
-
-
- if (empty($this->syncPostTypes) || !in_array(jvbNoBase($postType), $this->syncPostTypes)) {
- error_log('Not handling save for '.$this->service_name.' because there are no syncPostTypes: '.print_r($this->syncPostTypes, true));
- return;
- }
-
- $registrar = Registrar::getInstance($postType);
- if (!$registrar){
- return;
- }
-
- $settings = $registrar->hasIntegration($this->service_name)??null;
- if (!$settings) {
- error_log('Not handling save for '.$this->service_name.' because of no registrar settings');
- return;
- }
-
- $settings = $registrar->getIntegrationConfig($this->service_name);
- if (!$settings){
- error_log('Not handling save for '.$this->service_name.' because of no integration config '.print_r($settings, true));
- return;
- }
-
-
- $fields = $this->getSyncFields($postID, 'post', ['schedule_'.$this->service_name]);
- if (!$fields['share_to_'.$this->service_name]) {
- error_log('Not handling save for '.$this->service_name.' because of no share_to_'.$this->service_name.' '.print_r($fields, true));
- return;
- }
-
- $isShared = isset($fields["_{$this->service_name}_item_id"]);
- if ($update && $isShared && !$fields['_keep_synced_'.$this->service_name]) {
- error_log('Not handling save for '.$this->service_name.' because it is already shared, and not set to keep synced. ');
- return;
- }
-
- if ($post->post_status !== 'publish' && !$isShared) {
- error_log('Not handling save for '.$this->service_name.' because post status is not publish, and it is not already shared.');
- return;
- }
- error_log('==== Sending to integration\'s handleTheSavePost '.$this->service_name.' ====');
- $this->removeSavePost();
- $this->handleTheSavePost($postID, $post, $update, $settings);
- $this->addSavePost();
- }
-
-
- protected function getSyncFields(int $postID, string $type, array $additional = []):array
- {
- $meta = new Meta($postID, $type);
- $fieldsToCheck = [
- 'share_to_' . $this->service_name,
- '_keep_synced_' . $this->service_name,
- "_{$this->service_name}_item_id",
- "_{$this->service_name}_last_sync",
- "_{$this->service_name}_shared_at",
- "_{$this->service_name}_sync_status",
- ... $additional
- ];
- return $meta->getAll($fieldsToCheck);
- }
-
- /**
- * Handle post status transitions
- */
- public function handlePostStatusTransition(string $new_status, string $old_status, WP_Post $post): void
- {
- if (empty($this->syncPostTypes)) {
- return;
- }
-
- if (!in_array(jvbNoBase($post->post_type), $this->syncPostTypes)) {
- return;
- }
-
- //Map fields from our custom post types to the fields expected by the integration
- $mappedFields = $this->getFieldMapping($post->post_type);
-
- $fields = $this->getSyncFields($post->ID, 'post', $mappedFields);
-
- // Handle unpublish action
- if ($old_status === 'publish' && $new_status !== 'publish') {
-
- if ($fields["_{$this->service_name}_item_id"] && $this->canSync['delete']) {
- try {
- $this->queueOperation(
- 'delete_post',
- [
- $post,
- $fields["_{$this->service_name}_item_id"]
- ]
- );
- } catch (Exception $e) {
- $this->logError("Failed to handle unpublish for post {$post->ID}: " . $e->getMessage());
- }
- }
- }
- }
-
- /**
- * Handle post deletion
- */
- public function handleDeletePost(int $postID): void
- {
- if (!$this->canSync['delete']) {
- return;
- }
-
- $post = get_post($postID);
- if (!$post || !in_array($post->post_type, $this->syncPostTypes)) {
- return;
- }
-
- $fields = $this->getSyncFields($postID, 'post');
-
- if ($fields["_{$this->service_name}_item_id"] !== '') {
-
- $this->queueOperation(
- 'delete_post',
- [
- 'post_id' => $post->ID,
- ]
- );
- }
- }
-
- protected function handleSaveTerm($term_id, $tt_id, $taxonomy, $update, $args): void
- {
- $noBase = jvbNoBase($taxonomy);
- if (!in_array($noBase, $this->syncTaxonomies)) {
- return;
- }
- $registrar = Registrar::getInstance($noBase);
- if (!$registrar->hasFeature('is_content')) {
- return;
- }
-
-
- $settings = $registrar->getIntegrationConfig($this->service_name);
- if (!$settings) {
- return;
- }
-
- // Similar sync logic as handleSavePost but for terms
- $this->handleTheTermSave($term_id, $taxonomy, $update, $settings);
- }
-
- protected function handleTheTermSave($term_id, $taxonomy, $update, $settings) {
-
- }
/*******************************************************************
@@ -1494,183 +520,6 @@
*******************************************************************/
/**
- * Get API URL for endpoint
- */
- protected function getApiUrl(string $endpoint, ?string $baseKey = null): string|false
- {
- if ($this->hasOAuth && in_array($endpoint, $this->oauth)) {
- return $endpoint;
- }
- if (is_array($this->apiBase)) {
- if ($baseKey && array_key_exists($baseKey, $this->apiBase)) {
- $base = $this->apiBase[$baseKey];
- } else {
- $base = ($this->apiBase['base'] ?? reset($this->apiBase));
- }
- } else {
- $base = $this->apiBase;
- }
-
- if (!$base || $base === '') {
- $this->logError('API base URL not configured for {$this->>service_name}');
- return false;
- }
-
- // Handle named endpoints
- if (!$this->isValidEndpoint($endpoint)) {
- $this->logError("{$endpoint} is not a valid endpoint for {$this->service_name}");
- return false;
- }
-
- // Build full URL
- $base = rtrim($base, '/');
- $endpoint = ltrim($endpoint, '/');
-
- return "{$base}/{$endpoint}";
- }
-
- /**
- * Check if an endpoint is valid, supporting both exact matches and patterns
- *
- * @param string $endpoint
- * @return bool
- */
- protected function isValidEndpoint(string $endpoint): bool
- {
- // Remove query parameters for validation
- $endpointPath = parse_url($endpoint, PHP_URL_PATH);
- if ($endpointPath === false) {
- $endpointPath = $endpoint;
- }
-
- foreach ($this->apiEndpoints as $pattern) {
- // Check for exact match first
- if ($endpointPath === $pattern) {
- return true;
- }
-
- if (str_starts_with($endpointPath, $pattern)) {
- return true;
- }
-
- // Check if pattern contains wildcards (indicated by square brackets)
- if (strpos($pattern, '[') !== false) {
- // Convert the pattern to a regex
- $regexPattern = '#^' . str_replace(['[^/]+'], ['[^/]+'], $pattern) . '(?:\?.*)?$#';
- if (preg_match($regexPattern, $endpoint)) {
- return true;
- }
- }
- }
-
- return false;
- }
-
-
-
- /**
- * Categorize API errors for better tracking
- */
- protected function categorizeApiError(int $code): string
- {
- return match(true) {
- $code >= 400 && $code < 404 => 'client_error',
- $code === 404 => 'not_found',
- $code === 401 => 'authentication',
- $code === 403 => 'authorization',
- $code === 429 => 'rate_limit',
- $code >= 500 && $code < 600 => 'server_error',
- default => 'unknown'
- };
- }
-
- /**
- * Determine error severity based on HTTP code
- */
- protected function getErrorSeverity(int $code): string
- {
- return match(true) {
- $code === 429 => 'warning', // Rate limiting
- $code >= 400 && $code < 500 => 'error', // Client errors
- $code >= 500 => 'critical', // Server errors
- default => 'error'
- };
- }
-
- /**
- * Extract error details from response
- */
- protected function extractErrorDetails($decoded, string $body): array
- {
- $details = [
- 'message' => 'Unknown error',
- 'code' => null,
- 'details' => null
- ];
-
- if ($decoded && isset($decoded['error'])) {
- if (is_array($decoded['error'])) {
- $details['message'] = $decoded['error']['message'] ?? json_encode($decoded['error']);
- $details['code'] = $decoded['error']['code'] ?? null;
- $details['details'] = $decoded['error']['details'] ?? null;
- } else {
- $details['message'] = $decoded['error'];
- }
- } elseif ($decoded && isset($decoded['message'])) {
- $details['message'] = $decoded['message'];
- } elseif (!empty($body)) {
- $details['message'] = $body;
- }
-
- return $details;
- }
-
- /**
- * Extract rate limit information from response
- */
- protected function extractRateLimitInfo($decoded, string $body): array
- {
- $info = [
- 'retry_after' => null,
- 'limit' => null,
- 'remaining' => null,
- 'reset' => null
- ];
-
- // Try to extract from decoded response
- if ($decoded) {
- $info['retry_after'] = $decoded['retry_after'] ?? $decoded['retry-after'] ?? null;
- $info['limit'] = $decoded['x-rate-limit-limit'] ?? null;
- $info['remaining'] = $decoded['x-rate-limit-remaining'] ?? null;
- $info['reset'] = $decoded['x-rate-limit-reset'] ?? null;
- }
-
- return $info;
- }
-
- /**
- * Update error statistics
- */
- protected function updateErrorStats(int $code, string $endpoint): void
- {
- $this->error_stats['total_errors']++;
- $this->error_stats['consecutive_errors']++;
-
- // Track error types
- $error_type = $this->categorizeApiError($code);
- if (!isset($this->error_stats['error_types'][$error_type])) {
- $this->error_stats['error_types'][$error_type] = 0;
- }
- $this->error_stats['error_types'][$error_type]++;
-
- // Save stats to cache
- $this->saveErrorStats();
- }
-
-
-
-
- /**
* Get service name
*/
public function getServiceName(): string
@@ -1697,24 +546,6 @@
return static::getInstance()::$hasExtraOptions;
}
- /*********************************************************************
- RENDERING
- *********************************************************************/
-// public function renderAdditionalOptions()
-// {
-// //Default: nothing.
-// }
-
- /**
- * Render additional action buttons (optional)
- * Override in integration classes that need extra actions
- */
-// public function renderAdditionalActions(): void
-// {
-// // Default: no additional actions
-// // Override in extensions for service-specific actions
-// }
-
/**
* Get service description (optional)
* Override in integration classes for custom descriptions
@@ -1746,236 +577,6 @@
}
-
-
-
-
- public function renderConnection(bool $return = false):string
- {
-
- if ($this->userID && !JVB()->userCanConnect($this->service_name, $this->userID)) {
- return '';
- }
-
- $meta = Meta::forOptions($this->userID.'_integrations');
- $is_connected = $this->isSetUp();
- $credentials = $this->getCredentials();
-
- $admin_only = $this->isOAuthService ? [
- 'client_id',
- 'client_secret',
- ] : [];
-
- ob_start();
- ?>
- <form id="<?=$this->service_name?>" class="integration <?php echo $is_connected ? 'connected' : 'disconnected'; ?>"
- data-service="<?php echo esc_attr($this->service_name); ?>">
- <div class="header row x-btw">
- <h3><?php echo esc_html($this->title); ?></h3>
- <div class="setup">
- <?php if ($is_connected): ?>
- <span class="indicator connected">●</span>
- <span class="text">Set Up</span>
- <?php else: ?>
- <span class="indicator disconnected">●</span>
- <span class="text">Not Set Up</span>
- <?php endif; ?>
- </div>
- </div>
-
- <?php if ($is_connected && array_key_exists('updated_at', $credentials) && $credentials['updated_at'] > 0): ?>
- <div class="meta">
- <small>Last updated: <?php echo human_time_diff($credentials['updated_at']) . ' ago'; ?></small>
- </div>
- <?php endif; ?>
-
- <?php
- if (!empty($this->instructions)) {
- ?>
- <details>
- <summary>
- Instructions
- </summary>
- <ol>
- <?php
- foreach ($this->instructions as $instruction) {
- echo '<li>'.$instruction.'</li>';
- }
- ?>
- </ol>
- </details>
- <?php
- }
-?>
- <details class="initial-setup"<?= $is_connected?'' : ' open'?>>
- <summary>Initial Setup</summary>
- <?php
- foreach ($this->fields as $name => $config) {
- if ($is_connected && !empty($credentials[$name])) {
- if (in_array($name, $admin_only) && !current_user_can('manage_options')) {
- continue;
- }
- ?>
- <span class="label"><?=$config['label']?>:</span>
- <code>
- <?php
- if (str_contains($name, 'secret')) {
- for ($i = 1; $i<=strlen($credentials[$name]) - 8; $i++) {
- echo '*';
- }
- echo substr($credentials[$name], -8);
- } else {
- echo $credentials[$name];
- }
- ?>
- </code>
- <?php
- } else {
- $config['value'] = $credentials[$name]??'';
- $config['autocomplete'] = 'off';
- $config['base'] = $this->service_name.'_';
- echo Form::render($name, '', $config);
- }
- }
- if ($this->hasWebhooks) {
- echo $this->renderWebhookUrl();
- }
- ?>
- </details>
- <?php
-
- if ($this->isOAuthService) {
- $this->renderConnectedOAuthStatus();
- }
-
- ?>
-
- <div class="integration-content">
-
- <?php
-
-
-
- if (!empty($this->advanced)) {
- ?>
- <details>
- <summary>Advanced Settings</summary>
- <?php
- foreach ($this->advanced as $name => $config) {
- $config['value'] = $credentials[$name]??'';
- $config['base'] = $this->service_name.'_';
- $config['autocomplete'] = 'off';
- Form::render($name,null, $config);
- }
- ?>
- </details>
- <?php
- }
- if (!empty($this->defaults)) {
- ?>
- <a href="<?php echo admin_url('admin.php?page=jvb-integration-' . $this->service_name); ?>"
- class="button">
- More Settings
- </a>
- <?php
- }
- ?>
- </div>
- <div class="actions row x-btw wrap">
- <?php
- foreach ($this->buttons as $action => $label) {
- if (!$is_connected && $action !== 'save_credentials') {
- continue;
- }
- $title = $confirm = '';
- switch ($action) {
- case 'save_credentials':
- $title = $label;
- $label = jvbIcon('floppy-disk');
- break;
- case 'clear_credentials':
- $title = $label;
- $label = jvbIcon('plugs');
- $confirm = ' data-confirm="Are you sure you want to delete these credentials?"';
- break;
- case 'clear_cache':
- $title = $label;
- $label = jvbIcon('arrows-clockwise');
- break;
- }
- $title = $title === '' ? '' : ' title ="'.$title.'"';
- ?>
- <button type="button" data-action="<?=$action?>"<?=$title?><?=$confirm?>><?=$label?></button>
- <?php
- }
- ?>
- </div>
- </form>
- <?php
- $result = ob_get_clean();
- if(!$return) {
- echo $result;
- }
- return $result;
- }
-
- protected function renderConnectedOAuthStatus(): void
- {
- if (!$this->isSetup()) {
- return;
- }
- $credentials = $this->getCredentials();
- $hasCredentials = $this->hasOAuthCredentials();
- $returnURL = is_admin() ? admin_url('admin.php?page=jvb-integrations') : (get_the_permalink() ?: home_url());
- ?>
-
- <details <?= $hasCredentials?' open':''?>>
- <summary>
- <?php if ($hasCredentials) { ?>
- Connected Account
- <?php } else { ?>
- <div class="oauth-connect">
- <a href="<?php echo esc_url($this->getOAuthUrl($returnURL)); ?>"
- class="button button-primary jvb-oauth-connect"
- data-service="<?php echo esc_attr($this->service_name); ?>">
- <?php echo jvbIcon($this->icon); ?>
- Authorize Connection
- </a>
- </div>
- <?php } ?>
- <div class="connection-status <?= $hasCredentials ? 'connected' : 'disconnected' ?>">
- <span class="status-indicator">●</span>
- <span><?= $hasCredentials ? 'Connected' : 'Not Connected' ?></span>
- </div>
- </summary>
- <label>OAuth Redirect URL:</label>
- <code>
- <?= $this->getRedirectUri(); ?>
- </code>
-
-
- <?php if (!empty($credentials['updated_at'])): ?>
- <div class="oauth-meta">
- <small>Token expires: <?php
- echo isset($credentials['expires_at'])
- ? human_time_diff($credentials['expires_at'])
- : 'Never';
- ?></small>
- </div>
- <?php endif;
- // Allow child classes to add service-specific connected UI
- $this->renderOAuthConnectedOptions();
- ?>
- </details>
- <?php
- }
-
- protected function renderOAuthConnectedOptions():void
- {
-
- }
-
-
/**
* Update last tested time
*/
@@ -1985,180 +586,6 @@
$cred->updateTested($this->service_name, $this->userID);
}
- public function handleAdminPost(): void
- {
- if (!current_user_can('manage_options')) {
- wp_die('Insufficient permissions');
- }
-
- $service = $this->getServiceName();
-
- // Verify nonce
- $nonce_field = 'jvb_integration_nonce_' . $service;
- $nonce_action = 'jvb_integration_save_' . $service;
- if (!isset($_POST[$nonce_field]) || !wp_verify_nonce($_POST[$nonce_field], $nonce_action)) {
- wp_die('Security check failed');
- }
-
- // Get the action type
- $action_type = sanitize_text_field($_POST['action_type'] ?? 'save');
-
- // Prepare the request in the format handleAjaxRequest expects
- $_REQUEST['action'] = $action_type;
- $_REQUEST['service'] = $service;
-
- // Copy all POST data to REQUEST
- foreach ($_POST as $key => $value) {
- $_REQUEST[$key] = $value;
- }
-
- // Set up for JSON response capture
- ob_start();
-
- try {
- // Call the existing AJAX handler
- $this->handleAjaxRequest();
- $response = ob_get_clean();
-
- // Parse the JSON response
- $result = json_decode($response, true);
-
- if ($result && isset($result['success'])) {
- if ($result['success']) {
- $message = $result['message'] ?? ucfirst($service) . ' settings saved successfully!';
- $this->setAdminNotice($message, 'success');
- } else {
- $message = $result['message'] ?? 'Failed to save ' . ucfirst($service) . ' settings.';
- $this->setAdminNotice($message, 'error');
- }
- } else {
- // If no proper JSON response, check if connection worked
- if ($action_type === 'test') {
- $connected = $this->testConnection();
- $message = $connected ? 'Connection successful!' : 'Connection failed. Please check your credentials.';
- $this->setAdminNotice($message, $connected ? 'success' : 'error');
- }
- }
- } catch (Exception $e) {
- ob_end_clean();
- $this->setAdminNotice('Error: ' . $e->getMessage(), 'error');
- }
-
- // Redirect back to integrations page
- wp_redirect(admin_url('admin.php?page=jvb-integrations'));
- exit;
- }
-
- /**
- * Set admin notice using transients for redirect
- */
- protected function setAdminNotice(string $message, string $type = 'info'): void
- {
- $notices = get_transient('jvb_admin_notices') ?: [];
- $notices[] = [
- 'message' => $message,
- 'type' => $type === 'success' ? 'updated' : 'error'
- ];
- set_transient('jvb_admin_notices', $notices, 30);
- }
-
- /**
- * Display admin notices from transient
- */
- public static function displayAdminNotices(): void
- {
- $notices = get_transient('jvb_admin_notices');
-
- if ($notices) {
- foreach ($notices as $notice) {
- ?>
- <div class="notice notice-<?php echo esc_attr($notice['type']); ?> is-dismissible">
- <p><?php echo esc_html($notice['message']); ?></p>
- </div>
- <?php
- }
- delete_transient('jvb_admin_notices');
- }
- }
-
- public function hasDefaults():bool
- {
- return !empty($this->defaults);
- }
-
- public function renderDefaults():void
- {
- $types = $this->enabledContentTypes();
- if (empty($types)) {
- return;
- }
- $meta = Meta::forOptions($this->userID.'_integrations');
- ?>
- <form>
- <h1><?= $this->title?> Defaults:</h1>
- <p>Find yourself constantly repeating yourself?</p>
- <p>Set defaults for different content types and <?=$this->title?>!</p>
- <?php
- foreach ($this->defaults as $name => $config) {
- $config['required'] = false;
-
- $config['base'] = $this->service_name.'_';
- $config['autocomplete'] = 'off';
- echo Form::render($name, null, $config);
- }
- foreach ($this->syncPostTypes as $type) {
- $registrar = Registrar::getInstance($type);
-
- $icon = $registrar->getIcon();
- $icon = $icon === '' ? jvbDefaultIcon() : $icon;
- ?>
- <details>
- <summary><?= jvbIcon($icon) ?><?= $registrar->getSingular()?> Defaults</summary>
- <?php
- $fields = new AddIntegrationFields($this->service_name);
- $fields = $fields->getIntegrationFields();
- foreach($fields as $name=>$c) {
- $c['required'] = false;
- if ($c['type'] === 'number') {
- $c['type'] = 'text';
- $c['subtype'] = 'number';
- }
- if (array_key_exists('description', $c)) {
- $c['hint'] = $c['description'];
- unset($c['description']);
- }
- echo Form::render($name, null, $c);
- }
- ?>
- </details>
- <?php
- }
- ?>
- </form>
- <?php
- }
-
- public function hasContent():bool
- {
- return $this->has_content;
- }
- public function getDefaultContentType():string
- {
- return $this->defaultContent;
- }
-
- public function enabledContentTypes():array
- {
- if (!$this->has_content) {
- return [];
- }
-
- return array_filter(array_map(function($registrar) {
- $registrar = Registrar::getInstance($registrar);
- return $registrar->getIntegration($this->service_name)->getContentType();
- }, Registrar::withIntegration($this->service_name)));
- }
-
protected function getSupportedImage(int $imgID):int
{
//If this integration supports webp, we can just send the original image id
@@ -2187,11 +614,6 @@
}
- public function getAllowedContent():array
- {
- return $this->allowedContent;
- }
-
/**
* Used by JVBase\registrar\helpers\AddIntegrationFields.php
* @return array
@@ -2206,18 +628,4 @@
return $this->icon;
}
-
-
- public function canBatchUpdate():bool
- {
- return $this->hasBatchUpdate;
- }
- public function canBatchCreate():bool
- {
- return $this->hasBatchCreate;
- }
- public function canBatchDelete():bool
- {
- return $this->hasBatchDelete;
- }
}
diff --git a/inc/integrations/OAuth.php b/inc/integrations/OAuth.php
index 5f8085f..3bc410c 100644
--- a/inc/integrations/OAuth.php
+++ b/inc/integrations/OAuth.php
@@ -12,6 +12,7 @@
{
use _Base, Admin;
protected bool $hasOAuth = true;
+ protected OAuthURLs $oauth;
/**
* Child classes must set this (e.g. 'https://api.example.com/v1/')
*/
@@ -21,8 +22,25 @@
protected array $oathScopes = [];
- abstract protected function getOAuthRequestHeaders(): array;
- abstract protected function handleOAuthResponse(array|WP_Error $response, string $method, string $endpoint): array;
+ /**
+ * @return array Defaults to the base getRequestHeaders, but can optionally be overridden
+ */
+ protected function getOAuthRequestHeaders(): array
+ {
+ return $this->getRequestHeaders();
+ }
+
+ /**
+ * Default to the base handleResponse method, but extensions can modify accordingly
+ * @param array|WP_Error $response
+ * @param string $method
+ * @param string $endpoint
+ * @return array
+ */
+ protected function handleOAuthResponse(array|WP_Error $response, string $method, string $endpoint): array
+ {
+ return $this->handleResponse($response, $method, $endpoint);
+ }
abstract protected function refreshAccessToken(OAuthCredentials $credentials): array;
function addOAuthActions():void
@@ -39,16 +57,68 @@
'icon' => 'plugs-disconnected',
'action' => [$this, 'handleOAuthDisconnect']
],
- [
- 'name' => 'Refresh Token',
- 'icon' => 'arrows-clockwise',
- 'action' => [$this, 'handleOAuthRefreshToken']
- ]
+// [
+// 'name' => 'Refresh Token',
+// 'icon' => 'arrows-clockwise',
+// 'action' => [$this, 'handleOAuthRefreshToken']
+// ]
];
foreach ($actions as $a) {
$this->addAction($a['name'], $a['action'], $a['slug']??null, $a['icon']??null, $a['label']??null);
}
}
+ public function handleOAuthConnect():array
+ {
+ if (!$this->hasOAuth) {
+ return $this->response(false, 'Not an OAuth service');
+ }
+ $url = $this->getOAuthUrl();
+ if ($url) {
+ return [
+ 'success' => true,
+ 'redirect' => $url,
+ ];
+ }
+ return $this->response(false, 'Failed to generate OAuth URL');
+ }
+
+ public function handleOAuthDisconnect():array
+ {
+ $revoked = $this->revokeOAuth();
+ return $this->response($revoked, $revoked ?
+ 'Successfully disconnected from '.$this->title :
+ 'Failed to disconnect from '.$this->title);
+ }
+
+ 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;
+ }
+ $credentials = $this->loadCredentials();
+ if (empty($credentials['access_token'])) {
+ error_log('Could not send revoke request, no access_token for '.$this->service_name);
+ } else {
+ $response = $this->postOAuthRequest($this->oauth['revoke'], [
+ 'body' => [
+ 'token' => $credentials['access_token']
+ ]
+ ]);
+ $empty = new OAuthCredentials([]);
+ foreach ($empty->toArray() as $key => $value) {
+ if (array_key_exists($key, $credentials)) {
+ unset($credentials[$key]);
+ }
+ }
+
+ return Auth::getInstance()->storeCredentials($this->service_name, $credentials, $this->userID);
+ }
+ return false;
+ }
protected function registerOAuthCallbacks():void
{
@@ -105,10 +175,6 @@
return $this->handleOAuthResponse($error, $method, $endpoint);
}
- if (!empty($this->requiredScopes) && !$credentials->hasScopes($this->requiredScopes)) {
- $error = new WP_Error('insufficient_scope', "Missing required scopes for {$this->service_name}");
- return $this->handleOAuthResponse($error, $method, $endpoint);
- }
$url = rtrim($this->baseOAuthUrl, '/') . '/' . ltrim($endpoint, '/');
@@ -300,7 +366,7 @@
return '';
}
$credentials = $this->loadCredentials();
- if (empty($credentials['authorize'])) {
+ if (empty($this->oauth['authorize'])) {
$this->logError('getOAuthUrl', 'OAuth authorize URL not configured');
return '';
}
@@ -308,7 +374,7 @@
'client_id' => $credentials['client_id']??$credentials['application_id']??$credentials['app_id']??'',
'redirect_url' => $this->getRedirectUri(),
'response_type' => 'code',
- 'scope' => implode(' ', $credentials['scopes']??[])
+ 'scope' => implode(' ', $this->oauthScopes??[])
];
$state_key = wp_generate_password(32, false);
$user_id = $this->userID??0;
@@ -338,7 +404,7 @@
$params = $this->addOAuthParams($params);
}
- return $credentials['authorize'] . '?' . http_build_query($params);
+ return $this->oauth['authorize'] . '?' . http_build_query($params);
}
protected function getRedirectUri():string
diff --git a/inc/integrations/OAuthCredentials.php b/inc/integrations/OAuthCredentials.php
index 4664ab8..ff59d7d 100644
--- a/inc/integrations/OAuthCredentials.php
+++ b/inc/integrations/OAuthCredentials.php
@@ -10,6 +10,7 @@
public string $access_token;
public int $expires_at;
public ?string $refresh_token;
+ public ?string $revoke;
public array $scopes;
public function __construct(array $data)
@@ -41,7 +42,7 @@
'access_token' => $this->access_token,
'expires_at' => $this->expires_at,
'refresh_token' => $this->refresh_token,
- 'scopes' => $this->scopes,
+// 'scopes' => $this->scopes,
];
}
}
diff --git a/inc/integrations/OAuthURLs.php b/inc/integrations/OAuthURLs.php
new file mode 100644
index 0000000..13a361a
--- /dev/null
+++ b/inc/integrations/OAuthURLs.php
@@ -0,0 +1,26 @@
+<?php
+namespace JVBase\integrations;
+
+if (!defined('ABSPATH')) {
+ exit;
+}
+
+class OAuthURLs {
+ protected string $authorize;
+ protected string $token;
+ protected string $revoke;
+ public function __construct(string $authorize, string $token, string $revoke) {
+ $this->authorize = filter_var($authorize, FILTER_SANITIZE_URL);
+ $this->token = filter_var($token, FILTER_SANITIZE_URL);
+ $this->revoke = filter_var($revoke, FILTER_SANITIZE_URL);
+ }
+ public function getAuthorize():string {
+ return $this->authorize;
+ }
+ public function getToken():string {
+ return $this->token;
+ }
+ public function getRevoke():string{
+ return $this->revoke;
+ }
+}
diff --git a/inc/integrations/SyncDefaults.php b/inc/integrations/SyncDefaults.php
index 383aa7b..4c9ad2b 100644
--- a/inc/integrations/SyncDefaults.php
+++ b/inc/integrations/SyncDefaults.php
@@ -1,6 +1,12 @@
<?php
namespace JVBase\integrations;
+use JVBase\base\Options;
+use JVBase\meta\Form;
+use JVBase\meta\Meta;
+use JVBase\registrar\Registrar;
+use JVBase\ui\Tabs;
+
if (!defined('ABSPATH')) {
exit;
}
@@ -9,5 +15,170 @@
* Handles the getting and setting of default values for service fields.
*/
trait SyncDefaults {
+ use _Base;
+ protected array $allContent;
+ protected array $enabledTaxonomies;
+ protected array $enabledContent;
+ protected array $enabledUsers;
+ public function enabledContentTypes():array
+ {
+ //Build an array of post type/taxonomy slugs => integration content type
+ if (!isset($this->allContent)) {
+ $content = Registrar::withIntegration($this->service_name);
+ $this->allContent = [];
+ foreach ($content as $c) {
+ $registrar = Registrar::getInstance($c);
+ if ($registrar){
+ $this->allContent[$c] = $registrar->getIntegration($this->service_name)->getContentType();
+ }
+ }
+ }
+ return $this->allContent;
+ }
+ public function enabledContent():array
+ {
+ if (!isset($this->enabledContent)) {
+ $content = Registrar::withIntegration($this->service_name, 'post');
+ $this->enabledContent = [];
+ foreach ($content as $c) {
+ $registrar = Registrar::getInstance($c);
+ if ($registrar){
+ $this->enabledContent[$c] = $registrar->getIntegration($this->service_name)->getContentType();
+ }
+ }
+ }
+ return $this->enabledContent;
+ }
+ public function enabledTaxonomies():array
+ {
+ if (!isset($this->enabledTaxonomies)) {
+ $content = Registrar::withIntegration($this->service_name, 'term');
+ $this->enabledTaxonomies = [];
+ foreach ($content as $c) {
+ $registrar = Registrar::getInstance($c);
+ if ($registrar){
+ $this->enabledTaxonomies[$c] = $registrar->getIntegration($this->service_name)->getContentType();
+ }
+ }
+ }
+ return $this->enabledTaxonomies;
+ }
+ public function enabledUsers():array
+ {
+ if (!isset($this->enabledUsers)) {
+ $content = Registrar::withIntegration($this->service_name, 'user');
+ $this->enabledUsers = [];
+ foreach ($content as $c) {
+ $registrar = Registrar::getInstance($c);
+ if ($registrar){
+ $this->enabledUsers[$c] = $registrar->getIntegration($this->service_name)->getContentType();
+ }
+ }
+ }
+ return $this->enabledUsers;
+ }
+ public function renderDefaults():void
+ {
+ $tabs = new Tabs();
+
+ $types = [
+ 'enabledContent' => [
+ 'title' => 'Content',
+ 'slug' => 'content',
+ 'icon' => 'note-blank',
+ 'description' => 'Set default values for the meta for different content types here.'
+ ],
+ 'enabledTaxonomies' => [
+ 'title' => 'Taxonomies',
+ 'slug' => 'taxonomies',
+ 'icon' => 'folder',
+ 'description' => 'Set default values for the meta for different taxonomies here.'
+ ],
+ 'enabledUsers' => [
+ 'title' => 'Users',
+ 'slug' => 'users',
+ 'icon' => 'user',
+ 'description' => 'Set default values for the meta for different user types here.'
+ ]
+ ];
+
+ foreach ($types as $method => $options) {
+ $registered = $this->$method();
+ if (empty($registered)) {
+ continue;
+ }
+ $role = !is_null($this->userID) ? jvbUserRole($this->userID) : '';
+ $userRegistrar = null;
+ if (!empty($role)) {
+ $userRegistrar = Registrar::getInstance($role);
+ }
+ $allowed = is_null($this->userID) ?
+ array_keys($registered) :
+ array_filter(array_keys($registered), function ($type) use ($userRegistrar) {
+ return in_array($type, $userRegistrar->getCreatable());
+ });
+ if (empty($allowed)) {
+ continue;
+ }
+
+ $tab = $tabs->addTab($options['slug'])
+ ->icon($options['icon'])
+ ->title($options['title'])
+ ->description($options['description'])
+ ->content($this->getContentTypeContent($allowed));
+ }
+ $tabs->render(false);
+ }
+ public function getContentTypeContent(array $content):string
+ {
+ $tabs = new Tabs();
+ foreach ($content as $c) {
+ $registrar = Registrar::getInstance($c);
+ $additionalFields = $registrar->getIntegrationFields($this->service_name);
+ $fields = $additionalFields->getIntegrationFields();
+ if (empty($fields)) {
+ continue;
+ }
+ $form = '';
+ foreach ($fields as $name => $config) {
+ $form .= Form::render(
+ $this->getFieldName($name, $c),
+ $this->getDefaultValue($name, $c, $this->userID),
+ $config
+ );
+ }
+
+ $tab = $tabs->addTab($c)
+ ->icon($registrar->getIcon())
+ ->title($registrar->getPlural())
+ ->description('Set default values for the meta for '.$registrar->getPlural().' here.')
+ ->content($form);
+ }
+ return $tabs->render();
+ }
+
+ protected function getFieldName(string $fieldName, string $type):string
+ {
+ return $type.'_defaults_'.$fieldName;
+ }
+
+ public function getDefaultValue(string $fieldName, string $type, ?int $userID = null):mixed
+ {
+ $name = $this->getFieldName($fieldName, $type);
+ if (is_null($userID)) {
+ return Options::get($name);
+ } else {
+ return Meta::forUser($userID)->get($name);
+ }
+ }
+ public function setDefaultMetaValue(string $fieldName, mixed $value, string $type, ?int $userID = null):void
+ {
+ $name = $this->getFieldName($fieldName, $type);
+ if (is_null($userID)) {
+ Options::set($name, $value);
+ } else {
+ Meta::forUser($userID)->set($name, $value);
+ }
+ }
}
diff --git a/inc/integrations/SyncHelpers.php b/inc/integrations/SyncHelpers.php
index f2d0ca0..778902a 100644
--- a/inc/integrations/SyncHelpers.php
+++ b/inc/integrations/SyncHelpers.php
@@ -1,6 +1,9 @@
<?php
namespace JVBase\integrations;
+use JVBase\managers\queue\executors\IntegrationExecutor;
+use JVBase\managers\queue\mergers\DefaultMerger;
+use JVBase\managers\queue\TypeConfig;
use JVBase\meta\Meta;
use WP_Query;
@@ -20,6 +23,7 @@
protected static string $syncCustomer;
protected static string $import;
+
protected function setQueueTypes():void
{
self::$syncTo = $this->service_name.'_sync_to';
@@ -141,4 +145,47 @@
]);
return is_wp_error($get) || empty($get) ? false : [$get[0], 'user'];
}
+
+ public function registerQueueTypes():void
+ {
+ if (empty($this->syncTaxonomies) && empty($this->syncPostTypes)) {
+ return;
+ }
+ $queue = JVB()->queue();
+ $executor = new IntegrationExecutor();
+
+ $queue->registry()->register(self::$syncTo, new TypeConfig(
+ mergeable: new DefaultMerger('items'),
+ executor: $executor,
+ chunkKey: 'items',
+ chunkSize: 50,
+ maxRetries: 3,
+ ));
+
+ if ($this->canSync['delete']) {
+ $queue->registry()->register(self::$deleteFrom, new TypeConfig(
+ mergeable: new DefaultMerger('external_ids'),
+ executor: $executor,
+ chunkKey: 'external_ids',
+ chunkSize: 200,
+ maxRetries: 2
+ ));
+ }
+
+ $queue->registry()->register(self::$syncFrom, new TypeConfig(
+ executor: $executor,
+ maxRetries: 3
+ ));
+
+ $this->registerAdditionalQueueTypes($executor);
+ }
+ protected function registerAdditionalQueueTypes(IntegrationExecutor $executor):void
+ {
+ //Empty. Integration extensions can register additional operation types from here.
+ }
+
+ /**
+ * @return void Sets the $this->contentTypes
+ */
+ abstract protected function setContentTypes():void;
}
diff --git a/inc/integrations/SyncTo.php b/inc/integrations/SyncTo.php
index dd1e9ca..223a7db 100644
--- a/inc/integrations/SyncTo.php
+++ b/inc/integrations/SyncTo.php
@@ -2,14 +2,20 @@
namespace JVBase\integrations;
use Exception;
+use JVBase\meta\Meta;
+use JVBase\meta\Sanitizer;
+use JVBase\registrar\Registrar;
use WP_Error;
+use WP_Post;
+use WP_Term;
+use WP_User;
if (!defined('ABSPATH')) {
exit;
}
trait SyncTo {
- use SyncHelpers;
+ use SyncHelpers, UserConnection;
/**
* Must be defined according to how each service needs it to be
* @param int $itemID
@@ -17,10 +23,7 @@
* @return array
* @throws Exception
*/
- protected function formatForService(int $itemID, string $type = 'post'):array
- {
- throw new Exception('formatForService must be implemented by child class');
- }
+ abstract protected function formatForService(int $itemID, string $type = 'post'):array;
/**
* Defaults to an array of formatted items. Can be overridden in child classes
@@ -35,7 +38,7 @@
try {
$items[] = $this->formatForService($ID, $type);
} catch (Exception $e){
- $this->logError('Could not format Item for service',[
+ $this->logError('formatItems', 'Could not format Item for service',[
'itemID' => $ID,
'type' => $type,
'message' => $e->getMessage(),
@@ -74,18 +77,12 @@
return $this->noCreatedItems($updated);
}
- $response = [
- 'outcome' => 'failed',
- 'result' => [
- 'message' => 'No result'
- ]
- ];
if ($this->hasBatchCreate) {
$response = $this->sendBatchCreate($items);
if (!is_wp_error($response)) {
$result = $this->processBatchCreateResponse($data, $response);
} else {
- $this->logError('Batch create failed',[
+ $this->logError('createBatchToService','Batch create failed',[
'method' => 'createBatchToService',
'item_ids' => $data['items'],
'error' => $response
@@ -198,7 +195,7 @@
if (!is_wp_error($response)) {
$result = $this->processBatchUpdateResponse($data, $response);
} else {
- $this->logError('Batch update failed',[
+ $this->logError('updateBatchToService','Batch update failed',[
'method' => 'updateBatchToService',
'item_ids' => $data['items'],
'error' => $response
@@ -307,4 +304,434 @@
}
return $result;
}
+
+ /****************************************************************
+ * UTILITY
+ ****************************************************************/
+ protected function getSyncFields(int $itemID, string $type, array $additionalFields = []):array
+ {
+ $meta = match ($type) {
+ 'post' => Meta::forPost($itemID),
+ 'term' => Meta::forTerm($itemID),
+ 'user' => Meta::forUser($itemID),
+ default => false
+ };
+ if (!$meta) {
+ return [];
+ }
+
+ $fields = [
+ 'share_to_' . $this->service_name,
+ '_keep_synced_' . $this->service_name,
+ "_{$this->service_name}_item_id",
+ "_{$this->service_name}_last_sync",
+ "_{$this->service_name}_shared_at",
+ "_{$this->service_name}_sync_status",
+ "_{$this->service_name}_scheduled_at",
+ ... $additionalFields
+ ];
+ return $meta->getAll($fields);
+ }
+ /****************************************************************
+ * POST SYNC
+ ****************************************************************/
+ public function addSavePost():void
+ {
+ if (!has_action('save_post', [$this, 'handleSavePost'])) {
+ add_action('save_post', [$this, 'handleSavePost'], 20, 3);
+ }
+ }
+ public function removeSavePost():void
+ {
+ remove_action('save_post', [$this, 'handleSavePost'], 20, 3);
+ }
+ public function handleSavePost(int $postID, WP_Post $post, bool $update):void
+ {
+ if (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE) return;
+ if (wp_is_post_revision($postID)) return;
+
+ error_log('=== ['.$this->service_name.']::handleSavePost called');
+
+ $postType = jvbNoBase($post->post_type);
+ if (!in_array($postType, $this->syncPostTypes)) {
+ error_log('Not handling save for '.$this->service_name.' because there are no syncPostTypes: '.print_r($this->syncPostTypes, true));
+ return;
+ }
+
+ $registrar = Registrar::getInstance($postType);
+ //Should not happen, as syncPostTypes is defined by Registrar instances
+ if (!$registrar) {
+ return;
+ }
+
+ $settings = $registrar->getIntegrationConfig($this->service_name);
+ if (!$settings) {
+ error_log('Not handling save for '.$this->service_name.' because of no integration config '.print_r($settings, true));
+ return;
+ }
+
+ $fields = $this->getSyncFields($postID, 'post');
+ if (!$fields['share_to_'.$this->service_name]) {
+ error_log('Not handling save for '.$this->service_name.' because of no share_to_'.$this->service_name.' '.print_r($fields, true));
+ return;
+ }
+
+ $isShared = array_key_exists("_{$this->service_name}_item_id", $fields) && !empty($fields["_{$this->service_name}_item_id"]);
+
+ if ($post->post_status !== 'publish' && !$isShared) {
+ error_log('Not handling save for '.$this->service_name.' because post status is not publish, and it is not already shared.');
+ return;
+ }
+
+ if ($isShared && $update && !$fields['_keep_synced_'.$this->service_name]) {
+ error_log('Not handling save for '.$this->service_name.' because it is already shared, and not set to keep synced. ');
+ return;
+ }
+ error_log('==== Sending to integration\'s handleTheSavePost '.$this->service_name.' ====');
+ $this->removeSavePost();
+ $this->handleTheSavePost($postID, $post, $update, $settings);
+ $this->addSavePost();
+ }
+ /**
+ * Handle post save for syncing
+ *
+ * Override to implement custom sync logic when posts are saved.
+ * Check the $settings array for post type specific configuration.
+ *
+ * @param int $postID The post ID
+ * @param WP_Post $post The post object
+ * @param bool $update Whether this is an update
+ * @param array $settings Post type integration settings
+ * @return void
+ */
+ protected function handleTheSavePost(int $postID, WP_Post $post, bool $update, array $settings):void
+ {
+ error_log('==== ['.$this->title.']::handleTheSavePost ====');
+ $this->queueOperation(self::$syncTo, [
+ 'posts' => [$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');
+ }
+
+ public function addDeletePost():void
+ {
+ if (!has_action('before_delete_post', [$this, 'handleDeletePost'])) {
+ add_action('before_delete_post', [$this, 'handleDeletePost'], 20, 3);
+ }
+ }
+ public function removeDeletePost():void
+ {
+ remove_action('before_delete_post', [$this, 'handleSavePost'], 20, 3);
+ }
+ public function handleDeletePost(int $postID):void
+ {
+ if (!$this->canSync['delete']) {
+ return;
+ }
+ $postType = get_post_type($postID);
+ if (!in_array(jvbNoBase($postType), $this->syncPostTypes)) {
+ return;
+ }
+ $fields = $this->getSyncFields($postID, 'post');
+ if (empty($fields["_{$this->service_name}_item_id"])) {
+ return;
+ }
+ $post = get_post($postID);
+ if (!$post) {
+ return;
+ }
+ $userID = $this->determineUserID($post->post_author);
+ if (!$userID) {
+ return;
+ }
+
+ JVB()->queue()->add(
+ self::$deleteFrom,
+ $userID,
+ [
+ 'fields' => [$postID => $fields],
+ 'service' => $this->service_name,
+ 'type' => 'post'
+ ]
+ );
+ }
+ /****************************************************************
+ * TERM SYNC
+ ****************************************************************/
+ public function addSaveTerm():void
+ {
+ if (empty($this->syncTaxonomies)) {
+ return;
+ }
+ if (!has_action('saved_term', [$this, 'handleSaveTerm'])) {
+ add_action('saved_term', [$this, 'handleSaveTerm'], 20, 3);
+ }
+ }
+ public function removeSaveTerm():void
+ {
+ if (empty($this->syncTaxonomies)) {
+ return;
+ }
+ remove_action('saved_term', [$this, 'handleSaveTerm'], 20, 3);
+ }
+ protected function handleSaveTerm(int $termID, int $tt_id, string $taxonomy, bool $update, array $args):void
+ {
+ $tax = jvbNoBase($taxonomy);
+ if (!in_array($tax, $this->syncTaxonomies)) {
+ return;
+ }
+ $registrar = Registrar::getInstance($tax);
+ if (!$registrar) {
+ return;
+ }
+
+ $settings = $registrar->getIntegrationConfig($this->service_name);
+ if (!$settings) {
+ return;
+ }
+
+ $fields = $this->getSyncFields($termID, 'term');
+ if (!$fields['share_to_'.$this->service_name]) {
+ return;
+ }
+
+ $isShared = array_key_exists("_{$this->service_name}_item_id", $fields) && !empty($fields["_{$this->service_name}_item_id"]);
+
+ if ($isShared && $update && !$fields['_keep_synced_'.$this->service_name]) {
+ return;
+ }
+ $this->removeSaveTerm();
+ $this->handleTheSaveTerm($termID, $update, $taxonomy, $settings);
+ $this->addSaveTerm();
+ }
+
+ /**
+ * @param int $termID
+ * @param bool $update
+ * @param array $settings
+ * @return void
+ */
+ protected function handleTheSaveTerm(int $termID, bool $update, string $taxonomy, array $settings):void
+ {
+ error_log('==== ['.$this->title.']::handleTheSaveTerm ====');
+ //TODO: Figure out some sort of permissions for if the user can share this term, particularly for content types
+ //TODO: If this is a content type, it likely has its own integration. This is an edmonton.ink problem, so I'm offloading it for now
+ $this->queueOperation(self::$syncTo, [
+ 'terms' => [$termID],
+ 'user' => get_current_user_id(),
+ ], [
+ 'priority' => 'high',
+ 'delay' => 30,
+ ]);
+ Meta::forTerm($termID)->set('_'.$this->service_name.'_sync_status', 'queued');
+ }
+
+ public function addDeleteTerm():void
+ {
+ if (!has_action('pre_delete_term', [$this, 'handleDeleteTerm'])) {
+ add_action('pre_delete_term', [$this, 'handleDeleteTerm']);
+ }
+ }
+ public function removeDeleteTerm():void
+ {
+ remove_action('pre_delete_term', [$this, 'handleDeleteTerm']);
+ }
+ public function handleDeleteTerm(int $termID, string $taxonomy):void
+ {
+ if (!$this->canSync['delete']) {
+ return;
+ }
+ $tax = jvbNoBase($taxonomy);
+ if (!in_array($tax, $this->syncTaxonomies)) {
+ return;
+ }
+ $fields = $this->getSyncFields($termID, 'term');
+ if (empty($fields["_{$this->service_name}_item_id"])) {
+ return;
+ }
+ JVB()->queue()->add(
+ self::$deleteFrom,
+ 0,
+ [
+ 'fields' => [$termID => $fields],
+ 'service' => $this->service_name,
+ 'type' => 'term'
+ ]
+ );
+ }
+
+ /****************************************************************
+ * USER SYNC
+ ****************************************************************/
+ public function addSaveUser():void
+ {
+ if (empty($this->syncUsers)) {
+ return;
+ }
+ if (!has_action('profile_update', [$this, 'handleUpdateUser'])) {
+ add_action('profile_update', [$this, 'handleUpdateUser'], 20, 1);
+ }
+ if (!has_action('user_register', [$this, 'handleUpdateUser'])) {
+ add_action('user_register', [$this, 'handleUpdateUser'], 20, 1);
+ }
+ }
+ public function removeSaveUser():void
+ {
+ if (empty($this->syncUsers)) {
+ return;
+ }
+ remove_action('profile_update', [$this, 'handleUpdateUser'], 20);
+ remove_action('user_register', [$this, 'handleUpdateUser'], 20);
+ }
+ protected function handleUpdateUser(int $userID):void
+ {
+ $user = $this->getOrCreateUser($userID);
+ if (!$user) {
+ return;
+ }
+
+ $fields = $this->getSyncFields($userID, 'user');
+ if (!empty($fields["_{$this->service_name}_item_id"])) {
+ return;
+ }
+
+ $this->removeSaveUser();
+ $this->handleTheSaveUser($userID);
+ $this->addSaveTerm();
+ }
+
+ protected function getOrCreateUser(int $userID):string|false
+ {
+ $user = get_userdata($userID);
+ if (!$user || is_wp_error($user)) {
+ return false;
+ }
+ $role = jvbUserRole($userID);
+ if (!in_array(jvbNoBase($role), $this->syncUsers)) {
+ return false;
+ }
+ $fields = $this->getSyncFields($userID, 'user');
+ if (!empty($fields["_{$this->service_name}_item_id"])) {
+ return $fields["_{$this->service_name}_item_id"];
+ }
+
+ $meta = Meta::forUser($userID);
+ $serviceUserID = $this->searchServiceForUser($user->user_email??'');
+ if ($serviceUserID) {
+ $meta->set("{$this->service_name}_item_id", $serviceUserID);
+ return $serviceUserID;
+ }
+
+ $created = $this->createServiceUser($userID, $fields);
+ if ($created) {
+ return $created;
+ }
+ return false;
+ }
+ public function searchServiceForUser(string $email):string|false
+ {
+ if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
+ return false;
+ }
+ return $this->handleEmailSearch($email);
+ }
+
+ /**
+ * Searches for existing user from sanitized email
+ * @param string $email
+ * @return string|false The found service's User ID or false on failure
+ */
+ abstract public function handleEmailSearch(string $email):string|false;
+
+ public function createServiceUser(int $userID, array $fields):string|false
+ {
+ $checked = $this->validateUserFields($userID, $fields);
+ $response = $this->handleCreateUser($checked);
+ if ($response['success']) {
+ $meta = Meta::forUser($userID);
+ $meta->set("{$this->service_name}_item_id", $response['result']['id']);
+ }
+ return $response['success'] ? $response['customer']??false : false;
+ }
+
+ protected function validateUserFields(int $userID, array $fields):array|false
+ {
+ foreach ($fields as $f => $v) {
+ $v = match ($f) {
+ 'email' => filter_var($v, FILTER_SANITIZE_EMAIL),
+ 'phone' => Sanitizer::sanitizePhone($v),
+ default => sanitize_text_field($v),
+ };
+ if (empty($v) && in_array($f, $this->requiredUserFields())) {
+ return false;
+ }
+ $fields[$f] = $v;
+ }
+ return $fields;
+ }
+ protected function requiredUserFields():array
+ {
+ return [];
+ }
+
+ /**
+ * @param array $data User Data
+ * @return array The created user ID or false on failure
+ */
+ abstract protected function handleCreateUser(array $data):array;
+
+ /**
+ * @param int $userID
+ * @return void
+ */
+ protected function handleTheSaveUser(int $userID):void
+ {
+ error_log('==== ['.$this->title.']::handleTheSaveUser ====');
+ //TODO: This is likely only for stuff like customers.
+ //If we have multiple stores connected, we may have to queue operations to update every connection's customer account if they made an order with that store
+ $role = jvbUserRole($userID);
+ $registrar = Registrar::getInstance($role);
+ if (!$registrar || !$registrar->hasIntegration($this->service_name)) {
+ return;
+ }
+
+ $this->queueOperation(self::$syncTo, [
+ 'users' => [$userID],
+ ]);
+ }
+
+ public function addDeleteUser():void
+ {
+ if (!has_action('delete_user', [$this, 'handleDeleteTerm'])) {
+ add_action('delete_user', [$this, 'handleDeleteTerm']);
+ }
+ }
+ public function removeDeleteUser():void
+ {
+ remove_action('delete_user', [$this, 'handleDeleteTerm']);
+ }
+ public function handleDeleteUser(int $userID):void
+ {
+ if (!$this->canSync['delete']) {
+ return;
+ }
+
+ $fields = $this->getSyncFields($userID, 'user');
+ if (empty($fields["_{$this->service_name}_item_id"])) {
+ return;
+ }
+ JVB()->queue()->add(
+ self::$deleteFrom,
+ 0,
+ [
+ 'fields' => [$userID => $fields],
+ 'service' => $this->service_name,
+ 'type' => 'user'
+ ]
+ );
+ }
}
diff --git a/inc/integrations/UserConnection.php b/inc/integrations/UserConnection.php
index f1f61d1..5202e6e 100644
--- a/inc/integrations/UserConnection.php
+++ b/inc/integrations/UserConnection.php
@@ -9,6 +9,7 @@
}
trait UserConnection {
+ use _Base;
protected function determineUser():void
{
if (current_user_can('manage_options')) {
@@ -26,6 +27,13 @@
$this->initializeRateLimiters();
return true;
}
+ protected function determineUserID(int $userID):bool|int
+ {
+ if (!$this->userCanConnect($userID)) {
+ return false;
+ }
+ return $userID;
+ }
protected function clearUser():void
{
$this->userID = false;
diff --git a/inc/integrations/_Base.php b/inc/integrations/_Base.php
index 2d62970..80e71f8 100644
--- a/inc/integrations/_Base.php
+++ b/inc/integrations/_Base.php
@@ -30,6 +30,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;
@@ -61,8 +62,36 @@
* Usually used for default field values for syncable services
*/
public bool $hasExtraOptions = false;
+ public bool $hasBatchCreate = false;
+ public bool $hasBatchUpdate = false;
+ public bool $hasBatchDelete = false;
+ public bool $canCreateOnUpdate = false;
+ /**
+ * Post Syncing Capabilities
+ * Define what sync operations this integration supports
+ */
+ protected array $canSync = [
+ 'initial' => false, // Can share new posts to the service
+ 'update' => false, // Can update already-shared posts
+ 'delete' => false, // Can remove posts from the service
+ ];
-
+ /**
+ * @var array // Post types that can be synced (e.g., ['artwork', 'tattoo']): usually built by Registrar.php if the integration name exists as a key in [ 'integrations' => []]
+ */
+ protected array $syncPostTypes = [];
+ /**
+ * @var array Taxonomies that can be synced (e.g., ['category', 'section']): usually built by Registrar.php if the integration name exists as a key in [ 'integrations' => []]
+ */
+ protected array $syncTaxonomies = [];
+ /**
+ * @var array User roles that can be synced (e.g., ['customer']): usually built by Registrar.php if the integration name exists as a key in [ 'integrations' => []]
+ */
+ protected array $syncUsers = [];
+ /**
+ * @var array Integration's specific content types. Set by child classes' setContentTypes
+ */
+ protected array $contentTypes = [];
/**
@@ -107,4 +136,32 @@
}
return jvbDashIcon($icon);
}
+
+ protected function initializeRateLimiters():void
+ {
+ $key = $this->service_name;
+ if (!is_null($this->userID)) {
+ $key .= '_'.$this->userID;
+ }
+
+ if ($this->hasRequests && !isset($this->requestLimiter) && !is_null($this->oauthLimiter)) {
+ $this->requestLimiter = new RateLimits($key);
+ }
+ if ($this->hasOAuth && !isset($this->oauthLimiter) && !is_null($this->oauthLimiter)) {
+ $key .= '_oauth';
+ $this->oauthLimiter = new RateLimits($key, ['s' => 1,'m' => 10, 'h' => 100]);
+ }
+ }
+
+ public function response(bool $success, string $message, array $data = []):array
+ {
+ $response = [
+ 'success' => $success,
+ 'message' => $message
+ ];
+ if (!empty($data)) {
+ $response['data'] = $data;
+ }
+ return $response;
+ }
}
diff --git a/inc/integrations/_setup.php b/inc/integrations/_setup.php
index 9739293..7b0179c 100644
--- a/inc/integrations/_setup.php
+++ b/inc/integrations/_setup.php
@@ -1,11 +1,23 @@
<?php
use JVBase\base\Site;
+require(JVB_DIR . '/inc/integrations/_Base.php');
require(JVB_DIR . '/inc/integrations/Admin.php');
+ 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/Auth.php');
+require(JVB_DIR . '/inc/integrations/Field.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/Webhooks.php');
require(JVB_DIR . '/inc/integrations/Integrations.php');
if (Site::hasIntegration('maps')) {
diff --git a/inc/integrations/services/BlueSkyOld.php b/inc/integrations/services/BlueSkyOld.php
new file mode 100644
index 0000000..f39e341
--- /dev/null
+++ b/inc/integrations/services/BlueSkyOld.php
@@ -0,0 +1,411 @@
+<?php
+namespace JVBase\integrations\services;
+use Exception;
+use JVBase\integrations\Integrations;
+if (!defined('ABSPATH')) {
+ exit;
+}
+
+class BlueSkyOld extends Integrations
+{
+ protected string $api_base = 'https://bsky.social/xrpc/';
+ protected int $max_description_length = 300;
+ protected ?string $access_token = null;
+ protected ?string $did = 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 $user_id = null) {
+ $this->service_name = 'bluesky';
+ $this->title = 'BlueSky';
+ $this->icon = 'fediverse-logo';
+ $this->canSync = [
+ 'initial' => true,
+ 'schedule' => true,
+ ];
+
+ $this->fields = [
+ 'handle' => [
+ 'type' => 'text',
+ 'placeholder'=> 'your-handle.bsky.social',
+ 'required' => true,
+ 'label' => 'Handle',
+ ],
+ 'password' => [
+ 'type' => 'text',
+ 'subtype'=>'password',
+ 'label' => 'Application Password',
+ 'required' => true,
+ 'hint' => '<strong>Security:</strong> Generate an App Password in your BlueSky settings for better security.'
+ ]
+ ];
+ parent::__construct($user_id);
+ }
+ protected function initialize(): void
+ {
+ }
+
+
+ /**
+ * Setup BlueSky client with credentials
+ */
+ protected function setupClient(?int $user_id = null): bool
+ {
+ $creds = $this->credentials;
+
+ if (empty($creds['identifier']) || empty($creds['password'])) {
+ return false;
+ }
+
+ try {
+ $session = $this->createSession($creds['identifier'], $creds['password']);
+
+ if ($session) {
+ $this->access_token = $session['accessJwt'];
+ $this->did = $session['did'];
+ return true;
+ }
+ } catch (Exception $e) {
+ $this->handleError($e);
+ }
+
+ return false;
+ }
+
+ /**
+ * Create BlueSky session
+ */
+ private function createSession(string $identifier, string $password): ?array
+ {
+ $response = wp_remote_post($this->api_base . 'com.atproto.server.createSession', [
+ 'headers' => [
+ 'Content-Type' => 'application/json',
+ ],
+ 'body' => json_encode([
+ 'identifier' => $identifier,
+ 'password' => $password
+ ]),
+ 'timeout' => 30
+ ]);
+
+ if (is_wp_error($response)) {
+ throw new Exception('Failed to connect to BlueSky: ' . $response->get_error_message());
+ }
+
+ $body = json_decode(wp_remote_retrieve_body($response), true);
+ $status_code = wp_remote_retrieve_response_code($response);
+
+ if ($status_code !== 200) {
+ throw new Exception('BlueSky authentication failed: ' . ($body['message'] ?? 'Unknown error'));
+ }
+
+ return $body;
+ }
+
+ /**
+ * Publish a post to BlueSky
+ */
+ public function publishPost(array $content, ?int $user_id = null): array
+ {
+ try {
+ // Validate content
+ $errors = $this->validateContent($content);
+ if (!empty($errors)) {
+ return [
+ 'success' => false,
+ 'error' => implode(', ', $errors)
+ ];
+ }
+
+ // Setup client for this request
+ if (!$this->setupClient($user_id)) {
+ return [
+ 'success' => false,
+ 'error' => 'Failed to authenticate with BlueSky'
+ ];
+ }
+
+ // Check rate limiting
+ if (!$this->checkRateLimit()) {
+ return [
+ 'success' => false,
+ 'error' => 'Rate limit exceeded'
+ ];
+ }
+
+ // Prepare post data
+ $post_data = [
+ 'repo' => $this->did,
+ 'collection' => 'app.bsky.feed.post',
+ 'record' => [
+ 'text' => $this->prepareDescription($content['description'] ?? ''),
+ 'createdAt' => date('c'),
+ '$type' => 'app.bsky.feed.post'
+ ]
+ ];
+
+ // Add link if post URL provided
+ if (!empty($content['post_url'])) {
+ $post_data['record']['text'] .= "\n\n" . $content['post_url'];
+ }
+
+ // Upload and attach image if provided
+ if (!empty($content['image_path']) && file_exists($content['image_path'])) {
+ $image_result = $this->uploadImage($content['image_path']);
+
+ if ($image_result['success']) {
+ $post_data['record']['embed'] = [
+ '$type' => 'app.bsky.embed.images',
+ 'images' => [
+ [
+ 'alt' => $content['title'] ?? 'Image',
+ 'image' => $image_result['blob']
+ ]
+ ]
+ ];
+ }
+ }
+
+ // Post to BlueSky
+ $response = $this->makeApiRequest('com.atproto.repo.createRecord', $post_data);
+
+ if ($response['success']) {
+ $this->log("Successfully posted to BlueSky", 'info');
+
+ return [
+ 'success' => true,
+ 'platform_id' => $response['data']['uri'] ?? null,
+ 'post_url' => $this->getPostUrl($response['data']['uri'] ?? ''),
+ 'data' => $response['data']
+ ];
+ }
+
+ return [
+ 'success' => false,
+ 'error' => $response['error'] ?? 'Unknown error'
+ ];
+
+ } catch (Exception $e) {
+ $this->handleError($e);
+ return [
+ 'success' => false,
+ 'error' => $e->getMessage()
+ ];
+ }
+ }
+
+ /**
+ * Validate content before posting
+ */
+ protected function validateContent(array $content): array
+ {
+ $errors = [];
+
+ // Check description length
+ if (isset($content['description']) && strlen($content['description']) > $this->max_description_length) {
+ $errors[] = "Description exceeds {$this->max_description_length} characters";
+ }
+
+ // Check image if provided
+ if (isset($content['image_path']) && $content['image_path']) {
+ if (!file_exists($content['image_path'])) {
+ $errors[] = "Image file does not exist";
+ } else {
+ $mime_type = mime_content_type($content['image_path']);
+ $supported_types = ['image/jpeg', 'image/png', 'image/gif'];
+
+ if (!in_array($mime_type, $supported_types)) {
+ $errors[] = "Unsupported image type: {$mime_type}";
+ }
+
+ $file_size = filesize($content['image_path']);
+ $max_size = 5242880; // 5MB
+ if ($file_size > $max_size) {
+ $errors[] = "Image too large: " . round($file_size / 1048576, 2) . "MB (max: 5MB)";
+ }
+ }
+ }
+
+ return $errors;
+ }
+
+ /**
+ * Prepare description with length limits
+ */
+ protected function prepareDescription(string $description): string
+ {
+ if (strlen($description) <= $this->max_description_length) {
+ return $description;
+ }
+
+ // Truncate intelligently at word boundaries
+ $truncated = substr($description, 0, $this->max_description_length - 3);
+ $last_space = strrpos($truncated, ' ');
+
+ if ($last_space !== false) {
+ $truncated = substr($truncated, 0, $last_space);
+ }
+
+ return $truncated . '...';
+ }
+
+ /**
+ * Upload image to BlueSky
+ */
+ protected function uploadImage(string $image_path): array
+ {
+ try {
+ $mime_type = mime_content_type($image_path);
+ $image_data = file_get_contents($image_path);
+
+ $response = wp_remote_post($this->api_base . 'com.atproto.repo.uploadBlob', [
+ 'headers' => [
+ 'Authorization' => 'Bearer ' . $this->access_token,
+ 'Content-Type' => $mime_type,
+ ],
+ 'body' => $image_data,
+ 'timeout' => 60
+ ]);
+
+ if (is_wp_error($response)) {
+ throw new Exception('Failed to upload image: ' . $response->get_error_message());
+ }
+
+ $body = json_decode(wp_remote_retrieve_body($response), true);
+ $status_code = wp_remote_retrieve_response_code($response);
+
+ if ($status_code === 200 && isset($body['blob'])) {
+ return [
+ 'success' => true,
+ 'blob' => $body['blob']
+ ];
+ }
+
+ return [
+ 'success' => false,
+ 'error' => $body['message'] ?? 'Image upload failed'
+ ];
+
+ } catch (Exception $e) {
+ return [
+ 'success' => false,
+ 'error' => $e->getMessage()
+ ];
+ }
+ }
+
+ /**
+ * Make API request to BlueSky
+ */
+ private function makeApiRequest(string $endpoint, array $data): array
+ {
+ $response = wp_remote_post($this->api_base . $endpoint, [
+ 'headers' => [
+ 'Authorization' => 'Bearer ' . $this->access_token,
+ 'Content-Type' => 'application/json',
+ ],
+ 'body' => json_encode($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);
+ $status_code = wp_remote_retrieve_response_code($response);
+
+ if ($status_code === 200) {
+ return [
+ 'success' => true,
+ 'data' => $body
+ ];
+ }
+
+ return [
+ 'success' => false,
+ 'error' => $body['message'] ?? "HTTP {$status_code} error"
+ ];
+ }
+
+ /**
+ * Get post URL from BlueSky URI
+ */
+ private function getPostUrl(string $uri): string
+ {
+ if (preg_match('/at:\/\/([^\/]+)\/app\.bsky\.feed\.post\/(.+)/', $uri, $matches)) {
+ $did = $matches[1];
+ $rkey = $matches[2];
+ return "https://bsky.app/profile/{$did}/post/{$rkey}";
+ }
+
+ return '';
+ }
+
+ /**
+ * Disconnect user from this platform
+ */
+ public function disconnectUser(int $user_id): bool
+ {
+ $key = "user_{$user_id}_{$this->service_name}";
+ $credentials_manager = Auth::getInstance();
+ return $credentials_manager->deleteCredentials($key);
+ }
+
+ /**
+ * Get display name for this platform
+ */
+ public function getDisplayName(): string
+ {
+ return 'BlueSky';
+ }
+
+ /**
+ * Get icon name for this platform
+ */
+ public function getIconName(): string
+ {
+ return 'fediverse'; // Using existing fediverse icon
+ }
+
+ /**
+ * Get supported post types
+ */
+ public function getSupportedPostTypes(): array
+ {
+ return ['text', 'image', 'link'];
+ }
+
+
+ public function getServiceDescription(): string
+ {
+ return "Cross-post content to the BlueSky social network.";
+ }
+
+ protected function getRequestHeaders(): array
+ {
+ // TODO: Implement getRequestHeaders() method.
+ return [];
+ }
+
+ protected function getApiUrl(string $endpoint, ?string $baseUrl = null): string
+ {
+ return 'https://bsky.social/xrpc/';
+ }
+
+ protected function processIntegrationAction(string $action, array $data):\WP_Error|array
+ {
+ return [];
+ }
+}
diff --git a/inc/integrations/services/CloudflareOld.php b/inc/integrations/services/CloudflareOld.php
new file mode 100644
index 0000000..96cb9b0
--- /dev/null
+++ b/inc/integrations/services/CloudflareOld.php
@@ -0,0 +1,266 @@
+<?php
+
+namespace JVBase\integrations\services;
+use JVBase\integrations\Integrations;
+if (!defined('ABSPATH')) {
+ exit;
+}
+
+class CloudflareOld extends Integrations
+{
+ private string $site_key;
+ private string $secret_key;
+ 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->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->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';
+
+ }
+
+ /**
+ * Check if scripts should be enqueued
+ */
+ public function shouldEnqueueScripts(): bool
+ {
+ return $this->isSetUp() && !is_admin();
+ }
+
+ /**
+ * Test connection by attempting a verification with an invalid token
+ */
+ public function performConnectionTest(): bool
+ {
+ if (empty($this->site_key) || empty($this->secret_key)) {
+ 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,
+ 'response' => 'test-token-connection-check'
+ ],
+ 'timeout' => 10
+ ]);
+
+ // 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
+ */
+ public function enqueueTurnstileScripts(): void
+ {
+ wp_enqueue_script(
+ 'cloudflare-turnstile',
+ 'https://challenges.cloudflare.com/turnstile/v0/api.js',
+ [],
+ null,
+ true
+ );
+ wp_script_add_data('cloudflare-turnstile', 'async', true);
+ wp_script_add_data('cloudflare-turnstile', 'defer', true);
+ }
+
+ /**
+ * Render Turnstile widget
+ * Can be called directly: jvbConnect('cloudflare')->renderTurnstile()
+ */
+ public function renderTurnstile(array|string $options = []): void
+ {
+ $this->ensureInitialized();
+ if (!$this->isSetUp()) {
+ return;
+ }
+
+ if (!is_array($options)) {
+ $options = [];
+ }
+
+ $defaults = [
+ 'theme' => $this->theme,
+ 'size' => $this->size,
+ 'wrapper_class' => 'cf-turnstile-wrapper',
+ 'wrapper_style' => 'margin: 1em 0;'
+ ];
+
+ $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>';
+ }
+
+ /**
+ * Verify Turnstile response
+ * Can be called directly: JVB()->connect('cloudflare')->verifyTurnstile($token)
+ */
+ public function verifyTurnstile(?string $token = null, string $remote_ip = ''): bool
+ {
+ $this->ensureInitialized();
+ if (!$this->isSetUp()) {
+ return false;
+ }
+
+ // If no token provided, try to get from POST
+ if (!$token && isset($_POST['cf-turnstile-response'])) {
+ $token = sanitize_text_field($_POST['cf-turnstile-response']);
+ }
+
+ if (empty($token)) {
+ return false;
+ }
+
+ $data = [
+ 'secret' => $this->secret_key,
+ 'response' => $token
+ ];
+
+ if (empty($remote_ip) && isset($_SERVER['REMOTE_ADDR'])) {
+ $remote_ip = $_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());
+ return false;
+ }
+
+ $result = json_decode(wp_remote_retrieve_body($response), true);
+
+ if (!($result['success'] ?? false)) {
+ $error_codes = implode(', ', $result['error-codes'] ?? ['Unknown error']);
+ $this->logError('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/FacebookOld.php b/inc/integrations/services/FacebookOld.php
new file mode 100644
index 0000000..5e046ea
--- /dev/null
+++ b/inc/integrations/services/FacebookOld.php
@@ -0,0 +1,701 @@
+<?php
+/**
+ * Facebook Integration
+ * Handles Facebook posts, page information, events, and OAuth authentication
+ */
+
+namespace JVBase\integrations\services;
+use JVBase\integrations\Integrations;
+use JVBase\meta\Meta;
+use WP_Post;
+use WP_Error;
+
+if (!defined('ABSPATH')) {
+ exit;
+}
+
+class FacebookOld 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 = [];
+
+ // 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)
+ {
+ $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 = '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
+ ];
+
+ // Rate limits for Facebook Graph API
+ $this->rate_limits = [
+ 'per_second' => 10,
+ 'per_minute' => 600,
+ 'per_hour' => 200000
+ ];
+
+ $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;
+
+ 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'] ?? [];
+
+ // 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"
+ ];
+ }
+ }
+
+ /**
+ * Get service description
+ */
+ public function getServiceDescription(): string
+ {
+ return "Connect your Facebook Page to share posts, events, and engage with your audience.";
+ }
+
+ /**
+ * Get request headers for Facebook API
+ */
+ protected function getRequestHeaders(): array
+ {
+ $headers = [
+ '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
+ {
+ $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()]);
+ }
+
+ return $credentials;
+ }
+
+ /**
+ * Test Facebook connection
+ */
+ protected function performConnectionTest(): bool
+ {
+ 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']
+ );
+
+ if (is_wp_error($response)) {
+ return false;
+ }
+
+ $code = wp_remote_retrieve_response_code($response);
+ return $code === 200;
+
+ } catch (Exception $e) {
+ $this->logError('Connection test failed', ['error' => $e->getMessage()]);
+ return false;
+ }
+ }
+
+ /**
+ * Render Additional OAuth settings
+ */
+ protected function renderOAuthConnectedOptions(): void
+ {
+ // 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;
+ }
+ 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 true;
+
+ } catch (Exception $e) {
+ $this->logError('Webhook processing failed', [
+ 'error' => $e->getMessage(),
+ 'payload' => $payload
+ ]);
+ return false;
+ }
+ }
+
+ /**
+ * Handle post save for Facebook sync
+ */
+ protected 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;
+ }
+ $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
+ {
+ 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;
+ }
+ } catch (Exception $e) {
+ $this->logError('Operation failed', [
+ 'type' => $operation->type,
+ 'error' => $e->getMessage()
+ ]);
+ return new WP_Error('operation_failed', $e->getMessage());
+ }
+ }
+
+ /**
+ * Create a Facebook post
+ */
+ private function createFacebookPost(array $data): array
+ {
+ $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'];
+ }
+
+ /**
+ * Update a Facebook post
+ */
+ private function updateFacebookPost(array $data): 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'];
+ }
+}
diff --git a/inc/integrations/services/GoogleMapsOld.php b/inc/integrations/services/GoogleMapsOld.php
new file mode 100644
index 0000000..aebaf87
--- /dev/null
+++ b/inc/integrations/services/GoogleMapsOld.php
@@ -0,0 +1,834 @@
+<?php
+/**
+ * Google Maps Integration
+ * File: /inc/integrations/GoogleMapsIntegration.php
+ */
+
+namespace JVBase\integrations\services;
+use JVBase\integrations\Integrations;
+use JVBase\registrar\Registrar;
+
+if (!defined('ABSPATH')) {
+ exit;
+}
+
+class GoogleMapsOld 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;
+ }
+
+ 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->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 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',
+ $this->getJavaScriptApiUrl(['places', 'geometry', 'marker']),
+ [],
+ null,
+ [
+ 'in_footer' => true,
+ 'strategy' => 'async'
+ ]
+ );
+
+ // Register our maps handler
+ wp_register_script(
+ 'jvb-maps',
+ JVB_URL . 'assets/js/min/maps.min.js',
+ ['google-maps-api'],
+ '1.0.0',
+ [
+ 'in_footer' => true,
+ 'strategy' => 'defer'
+ ]
+ );
+
+ // 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) . '"
+ };
+
+ // 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();
+ }
+ });
+ window.googleMapsCallbacks = [];
+ }
+ };
+
+ // Initialize callback queue
+ window.googleMapsCallbacks = window.googleMapsCallbacks || [];
+ ', 'before');
+
+ // Enqueue scripts
+ wp_enqueue_script('google-maps-api');
+ wp_enqueue_script('jvb-maps');
+
+ // Add AJAX URL and nonce for JavaScript AJAX calls
+ wp_localize_script('jvb-maps', 'jvbMapsAjax', [
+ 'ajaxurl' => admin_url('admin-ajax.php'),
+ 'nonce' => wp_create_nonce('jvb_integration_' . $this->service_name),
+ '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'])) {
+ return true;
+ }
+ }
+
+ // Allow filtering
+ return apply_filters('jvb_should_enqueue_maps', false);
+ }
+
+ /**
+ * Parse address components from Google response
+ */
+ private function parseAddressComponents(array $components): array
+ {
+ $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;
+ }
+
+ /**
+ * Get Map ID
+ */
+ public function getMapId(): string
+ {
+ return $this->map_id;
+ }
+
+ /**
+ * Get default map settings
+ */
+ public function getDefaultMapSettings(): array
+ {
+ 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()
+ ];
+ }
+
+ /**
+ * Get map styles based on theme
+ */
+ private function getMapStyles(): array
+ {
+ $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 [];
+ }
+ }
+
+ /**
+ * Generate map application links
+ */
+ public function generateMapLinks(float $lat, float $lng, ?string $address = null): array
+ {
+ $query = $address ? urlencode($address) : "{$lat},{$lng}";
+
+ return [
+ 'google' => "https://www.google.com/maps/search/?api=1&query={$query}",
+ 'apple' => "https://maps.apple.com/?ll={$lat},{$lng}",
+ 'waze' => "https://waze.com/ul?ll={$lat},{$lng}&navigate=yes"
+ ];
+ }
+
+ /**
+ * Render a frontend display map
+ */
+ public function renderDisplayMap(array $location, array $options = []): string
+ {
+ 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'; ?>
+ });
+ }
+
+ 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;
+ }
+
+ /**
+ * Format address for display
+ */
+ public function formatAddress(array $location, string $format = 'full'): string
+ {
+ if (!empty($location['address'])) {
+ return $location['address'];
+ }
+
+ // Build address from components if no formatted address
+ $parts = [];
+
+ 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;
+
+ case 'compact':
+ if (!empty($location['city']) && !empty($location['province'])) {
+ $parts[] = $location['city'] . ', ' . $location['province'];
+ }
+ break;
+
+ case 'city_only':
+ if (!empty($location['city'])) $parts[] = $location['city'];
+ break;
+ }
+
+ return implode(', ', $parts);
+ }
+}
diff --git a/inc/integrations/services/GoogleMyBusiness.php b/inc/integrations/services/GoogleMyBusiness.php
index cd80f51..157181e 100644
--- a/inc/integrations/services/GoogleMyBusiness.php
+++ b/inc/integrations/services/GoogleMyBusiness.php
@@ -2767,7 +2767,6 @@
protected function setContentTypes():void
{
- $this->has_content = true;
$this->contentTypes = [
'menu_item' => [
'nutritionFacts' => [
diff --git a/inc/integrations/services/GoogleMyBusinessOld.php b/inc/integrations/services/GoogleMyBusinessOld.php
new file mode 100644
index 0000000..348700f
--- /dev/null
+++ b/inc/integrations/services/GoogleMyBusinessOld.php
@@ -0,0 +1,2974 @@
+<?php
+namespace JVBase\integrations\services;
+use JVBase\integrations\Integrations;
+use JVBase\meta\Meta;
+
+if (!defined('ABSPATH')) {
+ exit;
+}
+
+class GoogleMyBusinessOld extends Integrations
+{
+ protected array $allowedContent = [
+ 'menu_item',
+ 'post',
+ 'event',
+ ];
+ private ?string $access_token = null;
+ protected string $readMask = 'name,title,storefrontAddress,metadata,openInfo,storeCode,categories,phoneNumbers,labels,specialHours';
+ private ?string $location = null;
+ private ?string $refresh_token = null;
+ private ?string $client_id = null;
+ private ?string $client_secret = null;
+ private ?string $account_id = null;
+
+ 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->service_name = 'gmb';
+ $this->title = 'Google My Business';
+ $this->icon = 'storefront';
+ $this->cacheName = ($userID)? 'gmb_'.$userID : 'gmb';
+ $this->canSync = [
+ 'initial' => true,
+ 'update' => true,
+ 'delete' => true,
+ ];
+ $this->apiBase = [
+ 'base' => 'https://mybusinessbusinessinformation.googleapis.com',
+ 'accounts' => 'https://mybusinessaccountmanagement.googleapis.com',
+ 'posts' => 'https://mybusiness.googleapis.com',
+ 'performance'=> 'https://businessprofileperformance.googleapis.com',
+ 'v4' => 'https://mybusiness.googleapis.com/v4',
+ ];
+
+ $this->apiEndpoints = [
+ '/accounts/[^/]+/locations/[^/]+/reviews',
+ '/accounts/[^/]+/locations/[^/]+/foodMenus',
+ '/v4/accounts/[^/]+/locations/[^/]+/media',
+ '/v4/accounts/[^/]+/locations/[^/]+/localPosts',
+ '/v4/accounts/[^/]+/locations/[^/]+/localPosts/[^/]+',
+ '/accounts/[^/]+/locations/[^/]+',
+ '/v1/accounts/[^/]+/locations', // Matches /v1/accounts/{accountId}/locations
+ '/v1/accounts',
+ // Add pattern matching for dynamic location endpoints
+ '/v1/locations/[^/]+', // Matches /v1/locations/{locationId}
+ ];
+
+ $this->isOAuthService = true;
+ $this->oauth = [
+ 'authorize' => 'https://accounts.google.com/o/oauth2/v2/auth',
+ 'token' => 'https://oauth2.googleapis.com/token',
+ 'revoke' => 'https://oauth2.googleapis.com/revoke',
+ 'scopes' => [
+ 'https://www.googleapis.com/auth/business.manage',
+ 'https://www.googleapis.com/auth/userinfo.email',
+ 'https://www.googleapis.com/auth/userinfo.profile',
+ 'https://www.googleapis.com/auth/plus.business.manage'
+ ]
+ ];
+
+ $this->supportsWebp = false;
+
+ $this->rate_limits = [
+ 'per_second' => 15, // Allow 15 requests in burst window
+ 'per_minute' => 60, // 1-minute burst window
+ 'per_hour' => 500 // 500 requests per hour (well below Google's limits)
+ ];
+
+ $this->fields = [
+ 'client_id' => [
+ 'type' => 'text',
+ 'label' => 'OAuth Client ID',
+ 'placeholder'=> 'Enter Google OAuth Client ID',
+ 'hint' => 'From your Google Cloud Console OAuth credentials.',
+ 'required' => true,
+ ],
+ 'client_secret' => [
+ 'type' => 'text',
+ 'subtype' => 'password',
+ 'label' => 'OAuth Client Secret',
+ 'required' => true,
+ ],
+// 'access_token' => [
+// 'type' => 'text',
+// 'subtype' => 'password',
+// 'label' => 'Access Token',
+// 'hint' => 'Generated automagically after OAuth authorization.'
+// ],
+// 'refresh_token' => [
+// 'type' => 'text',
+// 'subtype' => 'password',
+// 'label' => 'Refresh Token',
+// 'hint' => 'Generated automagically after OAuth authorization.'
+// ]
+ ];
+
+ $this->advanced = [
+
+ ];
+
+ $this->instructions = [
+ 'Go to <a href="https://console.cloud.google.com/apis/credentials" target="_blank">Google Cloud Console</a>',
+ 'Create OAuth 2.0 Client ID credentials',
+ 'Enable Google My Business API',
+ 'Add redirect URI: <code>'.esc_html($this->getRedirectUri()).'</code>',
+ 'Use the "Authorize" button below to complete OAuth flow'
+ ];
+
+ parent::__construct();
+
+ $this->actions = array_merge(
+ $this->actions,
+ [
+ 'update_location' => 'handleUpdateLocation',
+ 'select_account' => 'handleSelectAccount',
+ 'select_location' => 'handleSelectLocation',
+ 'check_oauth_status'=> 'checkOAuthStatus'
+ ]
+ );
+ $this->buttons = array_merge(
+ $this->buttons,
+ [
+ 'check_oauth_status' => 'Check OAuth Status'
+ ]
+ );
+
+ if (JVB_TESTING) {
+ $this->cache->flush();
+ }
+ }
+
+ protected function initialize(): void
+ {
+ if (empty($this->credentials)) {
+ $this->loadCredentials();
+ }
+ $this->access_token = (array_key_exists('access_token', $this->credentials)) ? $this->credentials['access_token'] : null;
+ $this->refresh_token = (array_key_exists('refresh_token', $this->credentials)) ? $this->credentials['refresh_token'] : null;
+ $this->client_id = (array_key_exists('client_id', $this->credentials)) ? $this->credentials['client_id'] : null;
+ $this->client_secret = (array_key_exists('client_secret', $this->credentials)) ? $this->credentials['client_secret'] : null;
+ $this->location = (array_key_exists('location', $this->credentials)) ? $this->credentials['location'] : null;
+ $this->account_id = (array_key_exists('account', $this->credentials)) ? $this->credentials['account'] : null;
+
+ if ($this->account_id) {
+ $this->apiEndpoints[] = "/v1/{$this->account_id}/locations";
+ }
+ if ($this->location) {
+ $locationName = $this->getSelectedLocationResourceName();
+ $this->apiEndpoints[] = "/v4/{$locationName}/localPosts";
+ }
+ }
+
+ protected function performConnectionTest(): bool
+ {
+ try {
+ $accounts = $this->getAccounts(true);
+ return !empty($accounts);
+ } catch (\Exception $e) {
+ return false;
+ }
+ }
+
+ /**
+ * Check if response contains an error - Google-specific
+ */
+ protected function isErrorResponse(array $response): bool
+ {
+ // Google APIs return errors in this format:
+ // {"error": {"code": 401, "message": "...", "status": "UNAUTHENTICATED"}}
+ return isset($response['error']) && isset($response['error']['code']);
+ }
+
+ protected function getRequestHeaders(): array
+ {
+ return [
+ 'Authorization' => 'Bearer ' . $this->access_token,
+ 'Content-Type' => 'application/json',
+ 'Accept' => 'application/json'
+ ];
+ }
+
+ protected function addOAuthParams(array $params): array
+ {
+ $params['access_type'] = 'offline';
+ $params['prompt'] = 'consent';
+ return $params;
+ }
+
+ protected function addCredentialData(array $credentials, array $tokens): array
+ {
+ // Preserve GMB-specific settings
+ $existing = $this->credentials;
+ $credentials['location'] = $existing['location'] ?? null;
+ $credentials['account'] = $existing['account'] ?? null;
+ return $credentials;
+ }
+
+ public function handleTheSavePost(int $postID, \WP_Post $post, bool $update, array $settings):void
+ {
+
+ 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');
+ }
+
+ public function processOperation(\WP_Error|array $result, object $operation, array $data):\WP_Error|array
+ {
+ try {
+ $gmb = (array_key_exists('user', $data) && $data['user'] !== 0) ? $this->getInstance((int)$data['user']) : $this;
+ $gmb->ensureInitialized();
+ switch ($operation->type) {
+ case 'gmb_create_menu_item':
+ case 'gmb_update_menu_item':
+ case 'gmb_menu_update':
+ case 'gmb_menu_update_' . $this->userID:
+ return $gmb->processMenuUpdate($operation, $data);
+ case 'gmb_create_event':
+ case 'gmb_update_event':
+ return $gmb->syncEventPost($operation, $data);
+ case 'gmb_create_offer':
+ case 'gmb_update_offer':
+ return $gmb->syncOfferPost($operation, $data);
+ case 'gmb_create_post':
+ case 'gmb_update_post':
+ return $gmb->syncStandardPost($operation, $data);
+ case 'gmb_specialHours':
+ return $gmb->setSpecialHours($data['date'], $data['data']);
+ case 'gmb_business_hours':
+ return $gmb->updateBusinessHours($data);
+ case 'gmb_business_info':
+ return $gmb->updateBusinessInfo($data);
+ case 'gmb_update_attributes':
+ return $gmb->updateAttributes($data);
+ default:
+ return $result;
+ }
+ } catch (\Exception $e) {
+ error_log('[GMB] Operation processing error: ' . $e->getMessage());
+ return [
+ 'success' => false,
+ 'result' => $e->getMessage()
+ ];
+ }
+ }
+
+ protected function syncEventPost(object $operation, array $data):\WP_Error|array
+ {
+ $location = $this->getSelectedLocationResourceName();
+
+ if (!$location) {
+ return [
+ 'success' => false,
+ 'result' => 'No location Name set.'
+ ];
+ }
+
+ $postID = $data['post_id'];
+ $meta = Meta::forPost($postID);
+ $fields = [
+ 'start_date',
+ 'end_date',
+ 'start_time',
+ 'end_time',
+ 'post_excerpt',
+ 'post_title',
+ 'post_thumbnail',
+ "_{$this->service_name}_item_id"
+ ];
+
+ $data = [
+ 'summary' => $fields['post_excerpt'],
+ 'topic_type' => 'EVENT',
+ 'event' => [
+ 'title' => $fields['post_title'],
+ 'start_date' => $this->formatDate($fields['start_date']),
+ 'end_date' => $this->formatDate(($fields['end_date'] === '') ? $fields['start_date'] : $fields['end_date']),
+ 'start_time' => $this->formatTime($fields['start_time']),
+ 'end_time' => $this->formatTime($fields['end_time']),
+ ]
+ ];
+ if ($fields['post_thumbnail'] !== '') {
+ $url = wp_get_attachment_url($fields['post_thumbnail']);
+ if ($url) {
+ $data['media'] = [
+ ['mediaFormat' => 'PHOTO', 'sourceUrl' => $url]
+ ];
+ }
+ }
+
+ if ($fields["_{$this->service_name}_item_id"] !== '') {
+ $result = $this->updatePost($fields["_{$this->service_name}_item_id"], $data);
+ } else {
+ $result = $this->createPost($data);
+ $meta->set("_{$this->service_name}_item_id", $result['name']);
+ }
+
+ return [
+ 'success' => !empty($result),
+ 'result' => $result
+ ];
+ }
+
+ protected function syncOfferPost(object $operation, array $data):\WP_Error|array
+ {
+ $location = $this->getSelectedLocationResourceName();
+
+ if (!$location) {
+ return [
+ 'success' => false,
+ 'result' => 'No location Name set.'
+ ];
+ }
+
+ $postID = $data['post_id'];
+ $meta = Meta::forPost($postID);
+ $fields = [
+ 'post_excerpt',
+ 'post_title',
+ 'coupon_code',
+ 'termsConditions',
+ 'post_thumbnail',
+ "_{$this->service_name}_item_id"
+ ];
+
+ $data = [
+ 'summary' => $fields['post_excerpt'],
+ 'topic_type' => 'OFFER',
+ 'offer' => [
+ 'couponCode' => ($fields['coupon_code'] === '') ? $fields['post_title'] : $fields['coupon_code'],
+ 'redeemOnlineUrl'=> get_the_permalink($postID),
+// 'termsConditions'=> get_bloginfo('terms_condition') TODO: setup terms and Conditions
+ ]
+ ];
+ if ($fields['post_thumbnail'] !== '') {
+ $url = wp_get_attachment_url($fields['post_thumbnail']);
+ if ($url) {
+ $data['media'] = [
+ ['mediaFormat' => 'PHOTO', 'sourceUrl' => $url]
+ ];
+ }
+ }
+
+ if ($fields["_{$this->service_name}_item_id"] !== '') {
+ $result = $this->updatePost($fields["_{$this->service_name}_item_id"], $data);
+ } else {
+ $result = $this->createPost($data);
+ $meta->set("_{$this->service_name}_item_id", $result['name']);
+ }
+
+ return [
+ 'success' => !empty($result),
+ 'result' => $result
+ ];
+ }
+
+
+ protected function syncStandardPost(object $operation, array $data):\WP_Error|array
+ {
+ $location = $this->getSelectedLocationResourceName();
+
+ if (!$location) {
+ return [
+ 'success' => false,
+ 'result' => 'No location Name set.'
+ ];
+ }
+
+ $postID = $data['post_id'];
+ $meta = Meta::forPost($postID);
+ $fields = [
+ 'post_excerpt',
+ 'post_title',
+ 'post_thumbnail',
+ "_{$this->service_name}_item_id"
+ ];
+
+ $data = [
+ 'summary' => $fields['post_excerpt'],
+ 'topic_type' => 'STANDARD',
+ 'call_to_action' => [
+ 'type' => 'learn_more',
+ 'url'=> get_the_permalink($postID),
+ ]
+ ];
+ if ($fields['post_thumbnail'] !== '') {
+ $url = wp_get_attachment_url($fields['post_thumbnail']);
+ if ($url) {
+ $data['media'] = [
+ ['mediaFormat' => 'PHOTO', 'sourceUrl' => $url]
+ ];
+ }
+ }
+
+ if ($fields["_{$this->service_name}_item_id"] !== '') {
+ $result = $this->updatePost($fields["_{$this->service_name}_item_id"], $data);
+ } else {
+ $result = $this->createPost($data);
+ $meta->set("_{$this->service_name}_item_id", $result['name']);
+ }
+
+ return [
+ 'success' => !empty($result),
+ 'result' => $result
+ ];
+ }
+
+ protected function processMenuUpdate($operation, $data) {
+ try {
+
+ $postID = $data['post_id'];
+ if (!$postID) {
+ return [
+ 'success' => false,
+ 'result' => 'No post ID set'
+ ];
+ }
+ $post = get_post($postID);
+ $postType = jvbNoBase($post->post_type);
+
+ $args = [
+ 'post_type' => jvbCheckBase($postType),
+ 'post_status' => 'publish',
+ 'posts_per_page' => -1,
+ 'meta_query' => [
+ [
+ 'key' => BASE.'_keep_synced_' . $this->service_name,
+ 'value' => '1',
+ 'compare' => '='
+ ]
+ ]
+ ];
+
+ $menu_items = get_posts($args);
+
+ if (empty($menu_items)) {
+ return [
+ 'success' => true,
+ 'result' => 'No menu items to sync'
+ ];
+ }
+
+ $gmb = self::getInstance($data['userID']??null);
+
+ // Build the complete menu structure
+ $menu_data = $gmb->collectMenu($menu_items);
+
+ // Send to Google My Business API
+ $result = $this->updateFoodMenus($menu_data);
+ return [
+ 'success' => !is_null($result),
+ 'result' => (is_array($result)) ? array_merge($result, ['menu'=>$menu_data]) : ['menu'=>$menu_data],
+ ];
+
+ } catch (\Exception $e) {
+ error_log('GMB Menu Update Error: ' . $e->getMessage());
+ return [
+ 'success' => false,
+ 'result' => $e->getMessage()
+ ];
+ }
+ }
+
+ /***************************************************************
+ API
+ ***************************************************************/
+ private function getSelectedLocationResourceName(): ?string
+ {
+ if (empty($this->credentials['location'])) {
+ return null;
+ }
+
+ // If already a full resource name, return as-is
+ if (str_starts_with($this->credentials['location'], 'locations/')) {
+ return $this->credentials['location'];
+ }
+
+ // Otherwise, it's a storeCode - need to find the full resource name
+ try {
+ $accounts = $this->getAccounts();
+ foreach ($accounts as $account) {
+ $locations = $this->getLocations($account['name']);
+
+ foreach ($locations as $location) {
+ if ($location['storeCode'] === $this->credentials['location']) {
+ // Auto-migrate: update stored location to use full resource name
+ $this->setSelectedLocation($location['name']);
+ return $location['name']; // This is the full resource name
+ }
+ }
+ }
+ } catch (\Exception $e) {
+ error_log('[GMB] Error finding location resource name: ' . $e->getMessage());
+ }
+
+ return null;
+ }
+ /**
+ * Update business information
+ */
+ /**
+ * @param array $updates
+ * @return array As expected by OperationQueue.php processOperation
+ */
+ public function updateBusinessInfo(array $updates): array
+ {
+ /**
+ SEE ALSO: https://developers.google.com/my-business/content/attributes
+
+ MENU EXAMPLE
+ You can add structured menu data, like a food menu, to a location with the use of the PriceList object.
+
+ The following JSON request shows how to publish a breakfast menu to a location. The response contains an instance of the updated Location object.
+
+ HTTP
+
+ PATCH
+ https://mybusiness.googleapis.com/v4/accounts/{accountId}/locations/{locationId}?updateMask=priceLists
+ {
+ "priceLists": [
+ {
+ "priceListId": "Breakfast",
+ "labels": [
+ {
+ "displayName": "Breakfast",
+ "description": "Tasty Google Breakfast",
+ "languageCode": "en"
+ }
+ ],
+ "sourceUrl": "http://www.google.com/todays_menu",
+ "sections": [
+ {
+ "sectionId": "entree_menu",
+ "sectionType":"FOOD",
+ "labels": [
+ {
+ "displayName": "Entrées",
+ "description": "Breakfast Entrées",
+ "languageCode": "en"
+ }
+ ],
+ "items": [
+ {
+ "itemId": "scramble",
+ "labels": [
+ {
+ "displayName": "Big Scramble",
+ "description": "A delicious scramble filled with Potatoes, Eggs,
+ Bell Peppers, and Sausage",
+ "languageCode": "en"
+ }
+ ],
+ "price": {
+ "currencyCode": "USD",
+ "units": "12",
+ "nanos": "200000000"
+ }
+ },
+ {
+ "itemId": "steak_omelette",
+ "labels": [
+ {
+ "displayName": "Steak Omelette",
+ "description": "Three egg omelette with grilled prime rib,
+ fire-roasted bell peppers and onions, saut\u00e9ed mushrooms
+ and melted Swiss cheese",
+ "languageCode": "en"
+ }
+ ],
+ "price": {
+ "currencyCode": "USD",
+ "units": "15",
+ "nanos": "750000000"
+ }
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+
+
+ SERVICE DATA
+ If your business offers different service options, you can add structured services data to a location with the use of the PriceList object.
+
+ The following JSON request shows how to publish a service offering to a location. The response contains an instance of the updated Location object.
+
+ HTTP
+
+ PATCH
+ https://mybusiness.googleapis.com/v4/accounts/{accountId}/locations/{locationId}?updateMask=priceLists
+ {
+ "priceLists": [
+ {
+ "priceListId": "Oil Change",
+ "labels": [
+ {
+ "displayName": "Oil Change",
+ "description": "Caseys Qwik Oil Change",
+ "languageCode": "en"
+ }
+ ],
+ "sourceUrl": "http://www.google.com/todays_services",
+ "sections": [
+ {
+ "sectionId": "oil_services",
+ "sectionType":”SERVICES”,
+ "labels": [
+ {
+ "displayName": "Services",
+ "description": "Oil Changes",
+ "languageCode": "en"
+ }
+ ],
+ "items": [
+ {
+ "itemId": "20-minute-oil-change",
+ "labels": [
+ {
+ "displayName": "20 Minute Oil Change",
+ "description": "Quick oil change and filter service.",
+ "languageCode": "en"
+ }
+ ],
+ "price": {
+ "currencyCode": "USD",
+ "units": "30",
+ "nanos": "200000000"
+ }
+ },
+ {
+ "itemId": "full_service_oil_change",
+ "labels": [
+ {
+ "displayName": "Full Service Oil Change",
+ "description": "Quick oil change, filter service, and brake inspection.",
+ "languageCode": "en"
+ }
+ ],
+ "price": {
+ "currencyCode": "USD",
+ "units": "45",
+ "nanos": "750000000"
+ }
+ }
+ ]
+ }
+ ]
+ }
+ ]
+ }
+ **/
+
+ try {
+ $allowed_fields = [
+ 'title', // Business name
+ 'phoneNumbers', // Primary phone
+ 'websiteUri', // Website URL
+ 'categories', // Business categories
+ 'storefrontAddress', // Physical address
+ 'serviceArea', // Service area for service businesses
+ 'profile', // Business description
+ 'openInfo', // Opening date info
+ 'regularHours', // Business hours
+ 'specialHours', // Special hours
+ ];
+
+ // Filter to only allowed fields
+ $patch_data = array_intersect_key($updates,$allowed_fields);
+
+ $location_name = $this->getSelectedLocationResourceName();
+ if (empty($patch_data)) {
+ throw new \Exception('No valid fields to update');
+ }
+
+ // Create updateMask from the fields being updated
+ $update_mask = implode(',', array_keys($patch_data));
+
+ $response = $this->makeRequest(
+ 'PATCH',
+ "/v1/{$location_name}?updateMask={$update_mask}",
+ $patch_data,
+ 'base'
+ );
+
+ return [
+ 'success' => !empty($response),
+ 'result' => $response
+ ];
+
+ } catch (\Exception $e) {
+ $this->logError($e->getMessage(), [
+ 'method' => 'updateBusinessInfo'
+ ]);
+ return [
+ 'success' => false,
+ 'result' => $e->getMessage(),
+ ];
+ }
+ }
+
+
+ /**
+ * Update business attributes (amenities, services, etc.)
+ */
+ /**
+ * @param string $location_name
+ * @param array $attributes
+ * @return array As expected by OperationQueue.php processOperation
+ */
+ public function updateAttributes(array $attributes): array
+ {
+ try {
+ // Attributes like: wheelchair_accessible, wifi, parking, etc.
+ //TODO: sanitize data
+ $attributes_data = [
+ 'attributes' => $attributes
+ ];
+ $location_name = $this->getSelectedLocationResourceName();
+
+ $response = $this->makeRequest(
+ 'PATCH',
+ "/v1/accounts/{$location_name}/attributes",
+ $attributes_data,
+ 'base'
+ );
+
+ return [
+ 'success' => !empty($response),
+ 'result' => $response
+ ];
+
+ } catch (\Exception $e) {
+ $this->logError($e->getMessage(), [
+ 'method' => 'updateAttributes'
+ ]);
+ return [
+ 'success' => false,
+ 'result' => $e->getMessage()
+ ];
+ }
+ }
+ /*****************
+ * POSTS
+ ****************/
+ /**
+ * Create a new GMB post
+ *
+ * @param string $location_name The location resource name
+ * @param array $post_data Post content including summary, callToAction, media, etc.
+ * @return array|null Created post data or null on failure
+ */
+
+ /**
+ FROM THE DOCS:
+ EVENT POST
+ Notify your customers about the next event at your business with a Post. Your Post for an event includes start and end dates and times, which display prominently on the Post.
+
+ To make a Post to an account associated with a user, use the accounts.locations.localPosts API.
+
+ To create a Post for an authenticated user, use the following:
+
+ $ POST
+ https://mybusiness.googleapis.com/v4/accounts/{accountId}/locations/{locationId}/localPosts
+ {
+ "languageCode": "en-US",
+ "summary": "Come in for our spooky Halloween event!",
+ "event": {
+ "title": "Halloween Spook-tacular!",
+ "schedule": {
+ "startDate": {
+ "year": 2017,
+ "month": 10,
+ "day": 31,
+ },
+ "startTime": {
+ "hours": 9,
+ "minutes": 0,
+ "seconds": 0,
+ "nanos": 0,
+ },
+ "endDate": {
+ "year": 2017,
+ "month": 10,
+ "day": 31,
+ },
+ "endTime": {
+ "hours": 17,
+ "minutes": 0,
+ "seconds": 0,
+ "nanos": 0,
+ },
+ }
+ },
+ "media": [
+ {
+ "mediaFormat": "PHOTO",
+ "sourceUrl": "https://www.google.com/real-image.jpg",
+ }
+ ],
+ "topicType": "EVENT"
+ }
+
+ CALL TO ACTION POST
+ Posts with a call to action include a button. The text on the call to action button is determined by the actionType field of the Post. A link to a user-provided URL is added to the button.
+
+ To create a Post with a call to action button, use the following:
+
+ $ POST
+ https://mybusiness.googleapis.com/v4/accounts/{accountId}/locations/{locationId}/localPosts
+ {
+ "languageCode": "en-US",
+ "summary": "Order your Thanksgiving turkeys now!!",
+ "callToAction": {
+ "actionType": "ORDER",
+ "url": "http://google.com/order_turkeys_here",
+ },
+ "media": [
+ {
+ "mediaFormat": "PHOTO",
+ "sourceUrl": "https://www.google.com/real-turkey-photo.jpg",
+ }
+ ],
+ "topicType": "OFFER"
+ }
+ Action types
+ The call to action Posts can have different action types that determine the type of call to action Post.
+
+ The following are the supported call to action types:
+
+ Action types
+ BOOK Creates a Post that prompts a user to book an appointment, table, or something similar.
+ ORDER Creates a Post that prompts a user to order something.
+ SHOP Creates a Post that prompts a user to browse a product catalog.
+ LEARN_MORE Creates a Post that prompts a user to see additional details on a website.
+ SIGN_UP Creates a Post that prompts a user to register, sign up, or join something.
+ CALL Creates a Post that prompts a user to call a business.
+
+
+ OFFER POST
+ To create an Offer Post, use the following:
+
+ HTTP
+
+ $ POST
+ https://mybusiness.googleapis.com/v4/accounts/{accountId}/locations/{locationId}/localPosts
+ {
+ "languageCode": "en-US",
+ "summary": "Buy one Google jetpack, get a second one free!!",
+ "offer": {
+ "couponCode": “BOGO-JET-CODE”,
+ "redeemOnlineUrl": “https://www.google.com/redeem”,
+ "termsConditions": “Offer only valid if you can prove you are a time traveler”
+ },
+ "media": [
+ {
+ "mediaFormat": "PHOTO",
+ "sourceUrl": "https://www.google.com/real-jetpack-photo.jpg",
+ }
+ ],
+ "topicType": "OFFER"
+ }
+
+ EDIT POST
+ Once a post is created, you can edit it with a PATCH request.
+
+ To edit a Post, use the following:
+
+ HTTP
+
+ $ PATCH
+ https://mybusiness.googleapis.com/v4/accounts/{accountId}/locations/{locationId}/localPosts/{localPostId}?updateMask=summary
+ {
+ "summary": "Order your Christmas turkeys now!!"
+ }
+
+
+ DELETE POSTS
+ After a Post is created, you can delete it with a DELETE request.
+
+ To delete a Post, use the following:
+
+ HTTP
+
+ $ DELETE
+ https://mybusiness.googleapis.com/v4/accounts/{accountId}/locations/{locationId}/localPosts/{localPostId}
+ **/
+ public function createPost(array $post_data, ?string $location_name = null): ?array
+ {
+ $location_name = (!$location_name) ? $this->getSelectedLocationResourceName() : $location_name;
+ try {
+ // Validate required fields
+ if (empty($post_data['summary'])) {
+ throw new \Exception('Post summary is required');
+ }
+
+ // Build the post object according to API docs
+ $post = [
+ 'languageCode' => $post_data['language_code'] ?? 'en-US',
+ 'summary' => $post_data['summary'],
+ 'topicType' => $post_data['topic_type'] ?? 'STANDARD'
+ ];
+
+ // Add optional fields
+ if (!empty($post_data['call_to_action'])) {
+ $post['callToAction'] = [
+ 'actionType' => $post_data['call_to_action']['type'] ?? 'LEARN_MORE',
+ 'url' => $post_data['call_to_action']['url']
+ ];
+ }
+
+ if (!empty($post_data['media'])) {
+ $post['media'] = $post_data['media'];
+ }
+
+ if ($post_data['topic_type'] === 'EVENT' && !empty($post_data['event'])) {
+ $post['event'] = $post_data['event'];
+ }
+
+ if ($post_data['topic_type'] === 'OFFER' && !empty($post_data['offer'])) {
+ $post['offer'] = $post_data['offer'];
+ }
+
+ $response = $this->makeRequest(
+ 'POST',
+ "/v4/{$location_name}/localPosts",
+ $post,
+ 'posts'
+ );
+
+ $this->cache->invalidate("posts_{$location_name}");
+
+ return $response;
+
+ } catch (\Exception $e) {
+ $this->logError($e->getMessage(), [
+ 'method' => 'createPost'
+ ]);
+ return null;
+ }
+ }
+
+ /**
+ * Update an existing GMB post
+ */
+ public function updatePost(string $post_name, array $post_data): bool
+ {
+ try {
+ $update_fields = [];
+ $update_mask = [];
+
+ if (isset($post_data['summary'])) {
+ $update_fields['summary'] = $post_data['summary'];
+ $update_mask[] = 'summary';
+ }
+
+ if (isset($post_data['call_to_action'])) {
+ $update_fields['callToAction'] = $post_data['call_to_action'];
+ $update_mask[] = 'callToAction';
+ }
+
+ if (empty($update_fields)) {
+ return true; // Nothing to update
+ }
+
+ $response = $this->makeRequest(
+ 'PATCH',
+ "/v4/{$post_name}?updateMask=" . implode(',', $update_mask),
+ $update_fields,
+ 'posts' // Use the posts API base
+ );
+
+ return !empty($response);
+
+ } catch (\Exception $e) {
+
+ $this->logError($e->getMessage(), [
+ 'method' => 'updatePost'
+ ]);
+ return false;
+ }
+ }
+
+ /**
+ * Delete a post
+ */
+ public function deletePost(string $post_name): bool
+ {
+ try {
+ $this->makeRequest('DELETE', "/v1/accounts/{$post_name}", [], 'posts');
+ return true;
+ } catch (\Exception $e) {
+
+ $this->logError($e->getMessage(), [
+ 'method' => 'deletePost'
+ ]);
+ return false;
+ }
+ }
+ /****************
+ * HOURS
+ ***************/
+ /**
+ * Update regular business hours
+ *
+ * @param array $hours Array of periods with open/close times per day
+ * @return array Success status, according to OperationQueue.php
+ */
+ public function updateBusinessHours(array $hours): array
+ {
+ if(!$this->isSetUp()) {
+ return [];
+ }
+ $location_name = $this->credentials['location'];
+
+ if (empty($location_name)) {
+ error_log('[GMB] ERROR: No location selected or found');
+ throw new \Exception('No location selected. Please select a location first.');
+ }
+
+ try {
+ // Validate and format hours
+ $periods = $this->validateAndFormatHours($hours);
+
+ if (empty($periods)) {
+ error_log('[GMB] ERROR: No valid periods found after validation');
+ throw new \Exception('No valid business hours provided');
+ }
+
+ $update_data = [
+ 'regularHours' => [
+ 'periods' => $periods
+ ]
+ ];
+
+ $endpoint = "/v1/{$location_name}?updateMask=regularHours";
+
+ // Make the API request
+ $response = $this->makeRequest(
+ 'PATCH',
+ $endpoint,
+ $update_data,
+ 'base'
+ );
+
+ $success = $response !== null;
+
+ // Additional validation - check if the response contains the updated hours
+ if ($success && $response) {
+ if (isset($response['regularHours'])) {
+// error_log('[GMB] SUCCESS: Updated hours confirmed in response: ' . print_r($response['regularHours'], true));
+ } else {
+// error_log('[GMB] WARNING: No regularHours in response, but API call succeeded');
+// error_log('[GMB] Full response keys: ' . implode(', ', array_keys($response)));
+ }
+ }
+
+ return [
+ 'success' => $success,
+ 'result' => $response
+ ];
+
+ } catch (\Exception $e) {
+ $this->logError($e->getMessage(), [
+ 'method' => 'updateBusinessHours'
+ ]);
+ return [
+ 'success' => false,
+ 'result' => $e->getMessage()
+ ];
+ }
+ }
+
+ private function validateAndFormatHours(array $hours): array
+ {
+ $valid_days = ['MONDAY', 'TUESDAY', 'WEDNESDAY', 'THURSDAY', 'FRIDAY', 'SATURDAY', 'SUNDAY'];
+ $periods = [];
+
+ foreach ($hours as $day => $times) {
+ $formatted_day = strtoupper($day);
+
+ // Convert day names if needed
+ $day_mapping = [
+ 'MON' => 'MONDAY',
+ 'TUE' => 'TUESDAY',
+ 'TUES' => 'TUESDAY',
+ 'WED' => 'WEDNESDAY',
+ 'THU' => 'THURSDAY',
+ 'THUR' => 'THURSDAY',
+ 'THURS' => 'THURSDAY',
+ 'FRI' => 'FRIDAY',
+ 'SAT' => 'SATURDAY',
+ 'SUN' => 'SUNDAY'
+ ];
+
+ if (isset($day_mapping[$formatted_day])) {
+ $formatted_day = $day_mapping[$formatted_day];
+ }
+
+ if (!in_array($formatted_day, $valid_days)) {
+ error_log('[GMB] Invalid day: ' . $day . ' (formatted: ' . $formatted_day . ')');
+ continue;
+ }
+
+ // Check if day is open with flexible comparison
+ $is_open = false;
+ if (isset($times['open'])) {
+ // Convert to boolean - handles string "1", integer 1, boolean true, etc.
+ $is_open = (bool)(int)$times['open'];
+ }
+
+ if (!$is_open) {
+ error_log('[GMB] Day ' . $formatted_day . ' is closed (open value: ' . ($times['open'] ?? 'not set') . ')');
+ continue;
+ }
+
+ error_log('[GMB] Day ' . $formatted_day . ' is OPEN');
+
+ $open_time = $this->formatTime($times['time_opens'] ?? '');
+ $close_time = $this->formatTime($times['time_closes'] ?? '');
+
+ if (empty($open_time) || empty($close_time)) {
+ error_log('[GMB] Missing or invalid times for ' . $formatted_day);
+ error_log('[GMB] Raw time data - time_opens: "' . ($times['time_opens'] ?? 'not set') . '", time_closes: "' . ($times['time_closes'] ?? 'not set') . '"');
+ continue;
+ }
+
+ $period = [
+ 'openDay' => $formatted_day,
+ 'openTime' => $open_time, // Now a proper Google TimeOfDay object
+ 'closeDay' => $formatted_day,
+ 'closeTime' => $close_time // Now a proper Google TimeOfDay object
+ ];
+
+ $periods[] = $period;
+ }
+ return $periods;
+ }
+
+ /**
+ * Set special/temporary hours (for holidays, special events, etc.)
+ *
+ * @param string $date
+ * @param array{open?: float, closed?:float, isClosed?: bool, location?: string} $options
+ * @return array Operation Queue [ 'success' => true/false, 'result' => $results]
+ */
+ public function setSpecialHours(string $date, array $options = []):array
+ {
+ // Get the full location resource name
+ $location_name = $this->getSelectedLocationResourceName();
+
+ if (empty($location_name)) {
+ throw new \Exception('No location selected. Please select a location first.');
+ }
+
+ try {
+ // Format the date correctly for Google API
+ $date_obj = [
+ 'year' => (int)date('Y', strtotime($date)),
+ 'month' => (int)date('n', strtotime($date)),
+ 'day' => (int)date('j', strtotime($date))
+ ];
+
+ $special_hour_period = [
+ 'startDate' => $date_obj,
+ 'endDate' => $date_obj, // Same day for single-day special hours
+ ];
+
+ if (array_key_exists('isClosed', $options) && $options['isClosed']) {
+ $special_hour_period['closed'] = true;
+ } else {
+ $special_hour_period['closed'] = false;
+
+ // Format times as TimeOfDay objects
+ if (!empty($options['open'])) {
+ $open_time = $this->formatTime($options['open']);
+ if ($open_time) {
+ $special_hour_period['openTime'] = $open_time;
+ }
+ }
+
+ if (!empty($options['close'])) {
+ $close_time = $this->formatTime($options['close']);
+ if ($close_time) {
+ $special_hour_period['closeTime'] = $close_time;
+ }
+ }
+ }
+
+ // Get existing special hours and their periods
+ $existing_special_hours = $location['specialHours'] ?? [];
+ $existing_periods = $existing_special_hours['specialHourPeriods'] ?? [];
+
+ // Add or update the special hour period
+ $found = false;
+ foreach ($existing_periods as &$existing_period) {
+ if (isset($existing_period['startDate']) && $existing_period['startDate'] == $date_obj) {
+ $existing_period = $special_hour_period;
+ $found = true;
+ break;
+ }
+ }
+
+ if (!$found) {
+ $existing_periods[] = $special_hour_period;
+ }
+
+ // Prepare the update data with correct structure
+ $update_data = [
+ 'specialHours' => [
+ 'specialHourPeriods' => $existing_periods
+ ]
+ ];
+
+ // Try the PATCH request
+ $response = $this->makeRequest(
+ 'PATCH',
+ "/v1/{$location_name}?updateMask=specialHours",
+ $update_data,
+ 'base'
+ );
+
+ return [
+ 'success' => $response !== null,
+ 'results' => $response
+ ];
+
+ } catch (\Exception $e) {
+ $this->logError($e->getMessage(), [
+ 'method' => 'setSpecialHours'
+ ]);
+ return [
+ 'success' => false,
+ 'results' => $e->getMessage(),
+ ];
+ }
+ }
+ /**
+ * Clear all special hours
+ */
+ public function clearSpecialHours(string $location_name): bool
+ {
+ try {
+ $update_data = [
+ 'specialHours' => []
+ ];
+
+ $response = $this->makeRequest(
+ 'PATCH',
+ "/v1/{$location_name}?updateMask=specialHours",
+ $update_data,
+ 'base'
+ );
+
+ return $response !== null;
+
+ } catch (\Exception $e) {
+
+ $this->logError($e->getMessage(), [
+ 'method' => 'clearSpecialHours'
+ ]);
+ return false;
+ }
+ }
+ /***************************************************************
+ HOURS
+ ***************************************************************/
+ /**
+ FROM THE DOCS:
+ UPLOAD FROM URL
+ To upload photos from a URL , make the following call to Media.Create. Use the relevant category as needed.
+
+
+ POST https://mybusiness.googleapis.com/v4/accounts/{accountId}/locations/{locationId}/media
+ {
+ "mediaFormat": "PHOTO",
+ "locationAssociation": {
+ "category": "COVER"
+ },
+ "sourceUrl": “http://example.com/biz/image.jpg",
+ }
+ To upload videos from a URL with the Google My Business API, make the following call to Media.Create:
+
+
+ POST https://mybusiness.googleapis.com/v4/accounts/{accountId}/locations/{locationId}/media
+ {
+ "mediaFormat": "VIDEO",
+ "locationAssociation": {
+ "category": "ADDITIONAL"
+ },
+ "sourceUrl": “http://example.com/biz/video.mp4",
+ }
+ **/
+ public function uploadImageToGMB(int $imgID, string $category = 'ADDITIONAL'): ?string
+ {
+ if ($imgID === 0) {
+ return null;
+ }
+ if (empty($this->credentials)) {
+ $this->loadCredentials();
+ }
+
+ try {
+ $imgID = $this->getSupportedImage($imgID);
+ $valid_categories = [
+ 'COVER', 'PROFILE', 'LOGO', 'EXTERIOR', 'INTERIOR',
+ 'PRODUCT', 'AT_WORK', 'FOOD_AND_DRINK', 'MENU',
+ 'COMMON_AREA', 'ROOMS', 'TEAMS', 'ADDITIONAL',
+ 'FOOD_AND_DRINK'
+ ];
+
+ if (!in_array($category, $valid_categories)) {
+ throw new \Exception('Invalid photo category: ' . $category);
+ }
+ $mediaKey = $this->getImageMediaKey($imgID);
+ if ($mediaKey) {
+ return $mediaKey;
+ }
+ $image_url = wp_get_attachment_image_url($imgID, 'full');
+
+ $media_data = [
+ 'mediaFormat' => 'PHOTO',
+ 'locationAssociation' => [
+ 'category' => $category
+ ],
+ 'sourceUrl' => $image_url
+ ];
+ $response = $this->makeRequest(
+ 'POST',
+ "/v4/{$this->credentials['account']}/{$this->credentials['location']}/media",
+ $media_data,
+ 'posts'
+ );
+
+ if (!is_wp_error($response) && array_key_exists('name', $response)) {
+ $mediaKey = $this->extractMediaKey($response['name']);
+ $this->setImageMediaKey($imgID, $mediaKey);
+ return $mediaKey;
+ }
+ return null;
+ } catch (\Exception $e) {
+ $this->logError($e->getMessage(), [
+ 'method' => 'uploadPhotoFromUrl'
+ ]);
+ return null;
+ }
+ }
+
+ public function getImageMediaKey(int $imgID):string|false
+ {
+ $mediaKey = get_post_meta($imgID, BASE.'gmb_mediakey', true);
+ if ($mediaKey !== '') {
+ return $this->testGMBImage($mediaKey);
+ }
+ return false;
+ }
+ public function testGMBImage(string $mediaKey):string|false
+ {
+ $existing = $this->getLocationMedia();
+ $exists = array_filter($existing, function ($item) use ($mediaKey) {
+ return $this->extractMediaKey($item['name']) === $mediaKey;
+ });
+ return (empty($exists)) ? false : $mediaKey;
+ }
+ public function setImageMediaKey(int $imgID, string $key):bool
+ {
+ return update_post_meta($imgID, BASE.'gmb_mediakey', $key);
+ }
+ /***************************************************************
+ CREDENTIALS
+ ***************************************************************/
+ /**
+ * Get user's GMB accounts
+ */
+ public function getAccounts(bool $force = false): array
+ {
+ $ttl = 7 * 24 * 60 * 60; // week in seconds
+ $response = $this->getRequest('/v1/accounts', [], 'accounts', $ttl, $force)??[];
+
+ if (isset($response['accounts']) && is_array($response['accounts'])) {
+ return $response['accounts'];
+ }
+
+ if (is_array($response) && isset($response['name'])) {
+ return [$response];
+ }
+
+ return [];
+ }
+
+ /**
+ * Get reviews for the current location
+ * @param int $page_size Number of reviews to fetch (max 50)
+ * @return array|null
+ */
+ public function getReviews(int $page_size = 5): ?array
+ {
+ $this->ensureInitialized();
+ if (!$this->location) {
+ throw new \Exception('No location selected');
+ }
+ if (!$this->account_id) {
+ throw new \Exception('No account configured');
+ }
+
+ $location = $this->getSelectedLocationResourceName();
+ $account = $this->account_id;
+
+
+ // Check cache first (weekly refresh = 604800 seconds)
+ $cache_key = ['reviews', $location, $page_size];
+ $cached = $this->cache->get($cache_key);
+ if ($cached !== false) {
+ return $cached;
+ }
+
+ try {
+ // Reviews endpoint from My Business Account Management API
+ $response = $this->getRequest(
+ "/{$account}/{$location}/reviews",
+ [
+ 'orderBy' => 'updateTime desc'
+ ],
+ 'v4'
+ );
+
+ $reviews = $response ?? [];
+
+ // Cache for 1 week (604800 seconds)
+ $this->cache->set($cache_key, $reviews, WEEK_IN_SECONDS);
+
+ return $reviews;
+
+ } catch (\Exception $e) {
+ $this->logError($e->getMessage(), [
+ 'method' => 'getReviews'
+ ]);
+ return null;
+ }
+ }
+
+ /**
+ * Get the URL to view all Google reviews for the current location
+ * @return string|null The reviews viewing URL or null if not available
+ */
+ public function getReviewsViewUrl(): ?string
+ {
+ $this->ensureInitialized();
+ try {
+ $location = $this->getLocation();
+
+ if (empty($location)) {
+ return null;
+ }
+
+ // Prefer maps URL as it shows all reviews directly
+ if (!empty($location['metadata']['mapsUrl'])) {
+ return $location['metadata']['mapsUrl'];
+ }
+
+ // Fallback: construct from Place ID
+ if (!empty($location['metadata']['placeId'])) {
+ return 'https://search.google.com/local/reviews?placeid=' .
+ urlencode($location['metadata']['placeId']);
+ }
+
+ return null;
+
+ } catch (\Exception $e) {
+ $this->logError('Failed to get reviews view URL: ' . $e->getMessage(), [
+ 'method' => 'getReviewsViewUrl'
+ ]);
+ return null;
+ }
+ }
+
+ /**
+ * Get the URL to leave a review for the current location
+ * @return string|null The review URL or null if not available
+ */
+ public function getReviewUrl(): ?string
+ {
+ $this->ensureInitialized();
+ try {
+ $location = $this->getLocation();
+
+ if (empty($location)) {
+ return null;
+ }
+
+ // Try to use Place ID for write review
+ if (!empty($location['metadata']['placeId'])) {
+ return 'https://search.google.com/local/writereview?placeid=' .
+ urlencode($location['metadata']['placeId']);
+ }
+
+ // Fallback to maps URL
+ if (!empty($location['metadata']['mapsUrl'])) {
+ return $location['metadata']['mapsUrl'] . '/reviews';
+ }
+
+ return null;
+
+ } catch (\Exception $e) {
+ $this->logError('Failed to get review URL: ' . $e->getMessage(), [
+ 'method' => 'getReviewUrl'
+ ]);
+ return null;
+ }
+ }
+
+ /**
+ * Get locations for an account (with persistent storage)
+ * Allowed Fields: https://developers.google.com/my-business/content/location-data#list_of_all_supported_filter_fields
+ */
+ public function getLocations(?string $account_name = null, bool $force = false): array
+ {
+ if (!$this->isSetUp()) {
+ return [];
+ }
+
+ $account_name = $account_name ?: $this->account_id;
+
+ if (empty($account_name) || !str_starts_with($account_name, 'accounts/')) {
+ return [];
+ }
+ $params = ['readMask' => $this->readMask];
+ $ttl = 7 * 24 * 60 * 60; // Week in seconds
+
+ try {
+ $response = $this->getRequest("/v1/{$account_name}/locations", $params,'accounts', $ttl, $force);
+ return isset($response['locations']) ? $response['locations'] : [];
+
+ } catch (\Exception $e) {
+ error_log('[GMB] Error getting locations for ' . $account_name . ': ' . $e->getMessage());
+ return [];
+ }
+ }
+
+ public function handleRefreshAccessToken():WP_Error|array
+ {
+ if ($this->refreshOAuthToken()) {
+ return [
+ 'success' => true,
+ 'message' => 'Successfully refreshed OAuth Token'
+ ];
+ }
+ return new WP_Error('failure', 'Failed to Refresh token');
+ }
+
+ /**
+ * Manually refresh accounts and locations data from API
+ */
+ public function refreshStoredData(): array
+ {
+ try {
+ // Fetch fresh accounts data from API
+ $accounts = $this->getAccounts(true);
+ if (!empty($accounts)) {
+ // Fetch and store locations for each account
+ $locationsProcessed = 0;
+ $accountsProcessed = count($accounts);
+ foreach ($accounts as $account) {
+ $locations = $this->getLocations($account['name'], true);
+ if (!empty($locations)) {
+ $locationsProcessed += count($locations);
+ }
+ }
+
+ return [
+ 'success' => true,
+ 'accounts' => $accountsProcessed,
+ 'locations' => $locationsProcessed,
+ 'message' => sprintf(
+ 'Successfully refreshed %d accounts with %d total locations',
+ $accountsProcessed,
+ $locationsProcessed
+ )
+ ];
+ }
+
+ return [
+ 'success' => false,
+ 'message' => 'No accounts found'
+ ];
+
+ } catch (\Exception $e) {
+ error_log('[GMB] Error refreshing stored data: ' . $e->getMessage());
+ return [
+ 'success' => false,
+ 'message' => 'Error: ' . $e->getMessage()
+ ];
+ }
+ }
+
+ /**
+ * Get a specific location details
+ */
+ public function getLocation(?string $location_name = null, bool $force = false):?array
+ {
+ if (!$this->isSetUp()) {
+ return null;
+ }
+ $location_name = $location_name?:$this->credentials['location'];
+ if (empty($location_name)) {
+ error_log('[GMB] No location name provided for getLocation');
+ return null;
+ }
+
+ $params = [
+ 'readMask' => $this->readMask
+ ];
+ $endpoint = "/v1/{$location_name}?".http_build_query($params);
+
+ $location = $this->getRequest($endpoint, [], 'base', 'moderate', $force);
+
+ return $location??null;
+ }
+
+ /**
+ * Get the currently selected location
+ */
+ public function getSelectedLocation(): ?string
+ {
+ return $this->credentials['location'];
+ }
+
+ /**
+ * Set the selected location
+ */
+ public function setSelectedLocation(string $location): bool
+ {
+ $this->credentials['location'] = $location;
+
+ if (preg_match('/accounts\/([^\/]+)\//', $location, $matches)) {
+ $this->account_id = 'accounts/' . $matches[1];
+ }
+
+ $credentials = $this->credentials;
+ $credentials['location'] = $location;
+ $credentials['account'] = $this->account_id;
+
+
+ return $this->saveCredentials($credentials)['success'];
+ }
+
+ /*************************************************************
+ RENDER
+ *************************************************************/
+ /**
+ * Render business list for connected users
+ */
+ private function renderBusinessList(): void
+ {
+ try {
+ $accounts = $this->getAccounts();
+ if (empty($accounts)) {
+ echo '<p>No business accounts found.</p>';
+ return;
+ }
+ ?>
+ <div class="gmb-business-list">
+ <h4>Your Business Locations</h4>
+ <?php
+ foreach ($accounts as $account) {
+ $locations = $this->getLocations($account['name']);
+ foreach ($locations as $location) {
+ ?>
+ <div class="business-location">
+ <strong><?php echo esc_html($location['title']); ?></strong>
+ <?php if ($this->isSelectedLocation($location)): ?>
+ <span class="selected-badge">✓ Selected</span>
+ <?php endif; ?>
+ </div>
+ <?php
+ }
+ }
+ ?>
+ </div>
+ <?php
+ } catch (\Exception $e) {
+ echo '<p>Error loading business locations.</p>';
+ }
+ }
+
+ private function isSelectedLocation(array $location): bool
+ {
+ if (empty($this->credentials['location'])) {
+ return false;
+ }
+
+ return ($location['name'] === $this->credentials['location']);
+ }
+
+ /***********************************************************
+ UTILITY
+ ***********************************************************/
+ /**
+ * Helper method to strip HTML tags from text
+ * Preserves plain text content while removing all HTML formatting
+ */
+ protected function stripHtmlTags(string $text): string
+ {
+ // Remove HTML tags
+ $text = wp_strip_all_tags($text);
+
+ // Decode HTML entities
+ $text = html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8');
+
+ // Clean up whitespace
+ $text = preg_replace('/\s+/', ' ', $text);
+
+ // Trim
+ return trim($text);
+ }
+ /**
+ * Format date for GMB API
+ */
+ protected function formatDate(string $date): array
+ {
+ $timestamp = strtotime($date);
+ return [
+ 'year' => (int)date('Y', $timestamp),
+ 'month' => (int)date('n', $timestamp),
+ 'day' => (int)date('j', $timestamp)
+ ];
+ }
+ /**
+ * Format time for Google My Business API (HH:MM format)
+ */
+ private function formatTime(string $time): ?array
+ {
+ if (empty($time)) {
+ return null;
+ }
+
+ // Remove any extra whitespace
+ $time = trim($time);
+
+ // Handle different time formats and convert to 24-hour format
+ if (preg_match('/^(\d{1,2}):(\d{2})(?:\s*(AM|PM))?$/i', $time, $matches)) {
+ $hour = (int)$matches[1];
+ $minute = (int)$matches[2];
+ $ampm = isset($matches[3]) ? strtoupper($matches[3]) : '';
+
+ // Convert 12-hour to 24-hour format if needed
+ if ($ampm === 'PM' && $hour !== 12) {
+ $hour += 12;
+ } elseif ($ampm === 'AM' && $hour === 12) {
+ $hour = 0;
+ }
+
+ // Validate hour and minute ranges
+ if ($hour >= 0 && $hour <= 23 && $minute >= 0 && $minute <= 59) {
+ return [
+ 'hours' => $hour,
+ 'minutes' => $minute
+ ];
+ }
+ }
+
+ error_log('[GMB] Invalid time format: ' . $time);
+ return null;
+ }
+
+
+
+ /*****************************************************************************
+ *
+ * OAUTH IMPLEMENTATION
+ *
+ *****************************************************************************/
+ private function getScopes(): string
+ {
+ return implode(' ', [
+ 'https://www.googleapis.com/auth/business.manage',
+ 'https://www.googleapis.com/auth/userinfo.email',
+ 'https://www.googleapis.com/auth/userinfo.profile'
+ ]);
+ }
+ /**
+ * Handle account selection
+ */
+ public function handleSelectAccount($data = null): array
+ {
+ try {
+ $account_name = null;
+
+ // Extract account name from various sources
+ if (is_array($data)) {
+ $account_name = $data['account'] ?? $data['account'] ?? null;
+ } elseif (isset($_POST['account'])) {
+ $account_name = sanitize_text_field($_POST['account']);
+ }
+
+ if (empty($account_name)) {
+ return [
+ 'success' => false,
+ 'message' => 'No Account Selected'
+ ];
+ }
+
+ // Get account details
+ $accounts = $this->getAccounts();
+ $selected_account = null;
+
+ foreach ($accounts as $account) {
+ if ($account['name'] === $account_name) {
+ $selected_account = $account;
+ break;
+ }
+ }
+
+ if (!$selected_account) {
+ throw new \Exception('Invalid account selected');
+ }
+
+ // Update credentials with selected account
+ $this->credentials['account'] = $account_name;
+ $this->credentials['selected_account_id'] = $selected_account['accountId'] ?? '';
+
+ $this->saveCredentials($this->credentials);
+
+ return [
+ 'success' => true,
+ 'message' => 'Account selected successfully',
+ 'reload' => true
+ ];
+
+ } catch (\Exception $e) {
+ $this->logError('Failed to select account', [
+ 'error' => $e->getMessage(),
+ 'data' => $data
+ ]);
+
+ return [
+ 'success' => false,
+ 'message' => $e->getMessage()
+ ];
+ }
+ }
+
+ /**
+ * Handle location selection
+ */
+ public function handleSelectLocation($data = null): array
+ {
+ try {
+ $location_name = null;
+
+ // Extract location name from various sources
+ if (is_array($data)) {
+ $location_name = $data['location'] ?? $data['location'] ?? null;
+ } elseif (isset($_POST['location'])) {
+ $location_name = sanitize_text_field($_POST['location']);
+ }
+
+ if (empty($location_name)) {
+ throw new \Exception('No location selected');
+ }
+
+ // Verify this is a valid location for the selected account
+ if (empty($this->credentials['account'])) {
+ throw new \Exception('No account selected. Please select an account first.');
+ }
+
+ $locations = $this->getLocations($this->credentials['account']);
+ $valid_location = false;
+
+ foreach ($locations as $location) {
+ if ($location['name'] === $location_name) {
+ $valid_location = true;
+ $this->credentials['location_id'] = $location['locationId'] ?? '';
+ break;
+ }
+ }
+
+ if (!$valid_location) {
+ throw new \Exception('Invalid location selected');
+ }
+
+ // Update credentials with selected location
+ $this->credentials['location'] = $location_name;
+
+ // Save updated credentials
+ $this->saveCredentials($this->credentials);
+
+ // Initialize with new location
+ $this->credentials['location'] = $location_name;
+
+ return [
+ 'success' => true,
+ 'message' => 'Location selected successfully',
+ 'reload' => false // No need to reload for location selection
+ ];
+
+ } catch (\Exception $e) {
+ $this->logError('Failed to select location', [
+ 'error' => $e->getMessage(),
+ 'data' => $data
+ ]);
+
+ return [
+ 'success' => false,
+ 'message' => $e->getMessage()
+ ];
+ }
+ }
+
+ /**
+ * Check OAuth status
+ */
+ public function checkOAuthStatus(): array
+ {
+ try {
+ $valid = $this->isOAuthValid();
+
+ return [
+ 'success' => true,
+ 'valid' => $valid,
+ 'expires_at' => $this->credentials['expires_at'] ?? null,
+ 'has_refresh_token' => !empty($this->credentials['refresh_token'])
+ ];
+
+ } catch (\Exception $e) {
+ return [
+ 'success' => false,
+ 'valid' => false,
+ 'message' => $e->getMessage()
+ ];
+ }
+ }
+
+
+ /********************************************************************
+ *
+ * ADMIN UI
+ *
+ ********************************************************************/
+ /**
+ * Updated renderOAuthConnectedOptions for better UI
+ */
+ protected function renderOAuthConnectedOptions(): void
+ {
+ if (!$this->isSetUp()) {
+ return;
+ }
+
+ // Check OAuth status
+ $oauth_valid = $this->isOAuthValid();
+
+ if (!$oauth_valid) {
+ ?>
+ <div class="oauth-status">
+ <span class="status-invalid">⚠️ OAuth token expired - please reconnect</span>
+ </div>
+ <?php
+ }
+
+ // Show account selection
+ $accounts = $this->getAccounts();
+ if (!empty($accounts)) {
+ ?>
+ <div class="form-field" data-service="gmb">
+ <label for="gmb_account">Google Business Account</label>
+ <select name="account"
+ id="gmb_account"
+ class="jvb-ajax-update"
+ data-action="select_account">
+ <option value="">Select an account...</option>
+ <?php foreach ($accounts as $account): ?>
+ <option value="<?php echo esc_attr($account['name']); ?>"
+ <?php selected($credentials['account'] ?? '', $account['name']); ?>>
+ <?php echo esc_html($account['accountName'] ?? $account['name']); ?>
+ </option>
+ <?php endforeach; ?>
+ </select>
+ </div>
+ <?php
+
+ // Show location selection if account is selected
+ if (!empty($this->credentials['account'])) {
+ $locations = $this->getLocations($this->credentials['account']);
+
+ if (!empty($locations)) {
+ ?>
+ <div class="form-field location-field visible" data-service="gmb">
+ <label for="gmb_location">Business Location</label>
+ <select name="location"
+ id="gmb_location"
+ class="jvb-ajax-update"
+ data-action="select_location">
+ <option value="">Select a location...</option>
+ <?php foreach ($locations as $location): ?>
+ <option value="<?php echo esc_attr($location['name']); ?>"
+ <?php selected($this->credentials['location'] ?? '', $location['name']); ?>>
+ <?php echo esc_html($location['title'] ?? $location['name']); ?>
+ </option>
+ <?php endforeach; ?>
+ </select>
+ </div>
+ <?php
+ }
+ }
+
+ // Show current selection status
+ if (!empty($this->credentials['location'])) {
+ ?>
+ <div class="connection-status connected">
+ ✅ Connected to: <?php echo esc_html($this->credentials['location']); ?>
+ </div>
+ <?php
+ }
+ } else {
+ ?>
+ <div class="notice notice-warning">
+ <p>No Google Business accounts found. Please ensure your Google account has access to Google My Business.</p>
+ </div>
+ <?php
+ }
+ }
+
+ public function getServiceDescription(): string
+ {
+ return "Manage your Google My Business listings, posts, hours, and reviews.";
+ }
+
+ /*****************************************************************************
+ *
+ * FOOD MENU IMPLEMENTATION
+ *
+ *****************************************************************************/
+
+ /**
+ * Get food menus for the current location
+ */
+ public function getFoodMenus(): ?array
+ {
+ if (!$this->credentials['location']) {
+ throw new \Exception('No location selected. Please select a location first.');
+ }
+
+ $response = $this->getRequest(
+ "/v4/{$this->credentials['location']}/foodMenu",
+ [],
+ 'posts'
+ );
+ return $response['foodMenus'] ?? [];
+ }
+
+ /**
+ * Update food menus for the current location
+ */
+ public function updateFoodMenus(array $menus): bool
+ {
+ if (!$this->credentials['location']) {
+ throw new \Exception('No location selected. Please select a location first.');
+ }
+
+ try {
+ $food_menus = ['menus' => $menus];
+
+ $endpoint = "/{$this->credentials['account']}/{$this->credentials['location']}/foodMenus";
+
+ // Use dedicated Food Menu API
+ $response = $this->makeRequest(
+ 'PATCH',
+ $endpoint,
+ $food_menus,
+ 'v4'
+ );
+
+ $this->logDebug('[GMB]Finished updating food menu', [
+ 'method' => 'updateFoodMenus',
+ 'response' => $response,
+ ]);
+ return !empty($response);
+
+ } catch (\Exception $e) {
+ $this->logError($e->getMessage(), [
+ 'method' => 'updateFoodMenus'
+ ]);
+ return false;
+ }
+ }
+
+ /**
+ * Upload menu images and return a map of identifiers to mediaKeys
+ */
+ protected function uploadAndMapMenuImages(array $menus): array
+ {
+ $mediaKeyMap = [];
+
+ // Process all menu items for images
+ foreach ($menus as $menu) {
+ foreach ($menu['sections'] ?? [] as $section) {
+ // Upload section images if needed (though API doesn't support section images yet)
+ if (!empty($section['image']) && is_numeric($section['image'])) {
+ $mediaKey = $this->uploadImageToGMB($section['image'], 'FOOD_AND_DRINK');
+ if ($mediaKey) {
+ $mediaKeyMap['section_' . $section['id']] = $mediaKey;
+ }
+ }
+
+ // Process menu items
+ foreach ($section['items'] ?? [] as $item) {
+ // Upload featured image
+ if (!empty($item['post_id'])) {
+ $featured_image_id = get_post_thumbnail_id($item['post_id']);
+ if ($featured_image_id) {
+ $mediaKey = $this->uploadImageToGMB($featured_image_id, 'FOOD_AND_DRINK');
+ if ($mediaKey) {
+ $mediaKeyMap['item_' . $item['post_id']] = $mediaKey;
+ }
+ }
+
+ // Upload gallery images if available
+ if (!empty($item['gallery_ids'])) {
+ foreach ($item['gallery_ids'] as $gallery_id) {
+ $mediaKey = $this->uploadImageToGMB($gallery_id, 'FOOD_AND_DRINK');
+ if ($mediaKey) {
+ $mediaKeyMap['gallery_'.$gallery_id] = $mediaKey;
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ return $mediaKeyMap;
+ }
+
+ /**
+ * Extract mediaKey from the full media resource name
+ */
+ protected function extractMediaKey(string $mediaResourceName): string
+ {
+ // Extract mediaKey from format: accounts/{accountId}/locations/{locationId}/media/{mediaKey}
+ $parts = explode('/media/', $mediaResourceName);
+ return isset($parts[1]) ? $parts[1] : '';
+ }
+
+ /**
+ * Get all media items for the current location
+ */
+ public function getLocationMedia(): array
+ {
+ if (!$this->credentials['location']) {
+ throw new \Exception('No location selected. Please select a location first.');
+ }
+
+ try {
+ $response = $this->getRequest(
+ "/{$this->credentials['account']}/{$this->credentials['location']}/media",
+ [],
+ 'v4'
+ );
+
+ return $response['mediaItems'] ?? [];
+ } catch (\Exception $e) {
+ $this->logError('Failed to get location media: ' . $e->getMessage());
+ return [];
+ }
+ }
+
+ public function handleGetAllLocations():WP_Error|array
+ {
+ try {
+ // Check if we should force refresh
+ $force = isset($_POST['force']) && $_POST['force'] === 'true';
+
+ // Get accounts (use cache unless forced)
+ $accounts = $this->getAccounts($force);
+ $all_locations = [];
+
+ foreach ($accounts as $account) {
+ // Get locations for each account (use cache unless forced)
+ $locations = $this->getLocations($account['name'], $force);
+ $all_locations = array_merge($all_locations, $locations);
+ }
+
+ return [
+ 'success' => true,
+ 'locations' => $all_locations,
+ 'cached' => !$force
+ ];
+
+ } catch (\Exception $e) {
+ return new WP_Error('failure', 'Something went wrong: '.$e->getMessage());
+ }
+ }
+
+ /**
+ * AJAX handler for updating selected location
+ */
+ public function handleUpdateLocation($data = null): array
+ {
+ try {
+ // Handle both AJAX and REST API calls
+ $selected_account = null;
+
+ if (is_array($data)) {
+ $selected_account = $data['account'] ?? $data['location'] ?? null;
+ } elseif (isset($_POST['account'])) {
+ $selected_account = sanitize_text_field($_POST['account']);
+ }
+
+ if (empty($selected_account)) {
+ throw new \Exception('No account selected');
+ }
+
+ // Save the selected account
+ $credentials = $this->credentials;
+ $credentials['account'] = $selected_account;
+
+ // Get locations for this account
+ $locations = $this->getLocations($selected_account);
+
+ // Auto-select first location if only one exists
+ if (count($locations) === 1) {
+ $credentials['location'] = $locations[0]['name'];
+ }
+
+ $this->saveCredentials($credentials);
+
+ return [
+ 'success' => true,
+ 'message' => 'Account selected successfully',
+ 'locations' => $locations,
+ 'reload' => true // Trigger page refresh to show location dropdown
+ ];
+
+ } catch (\Exception $e) {
+ $this->logError('Failed to update location', [
+ 'error' => $e->getMessage(),
+ 'data' => $data
+ ]);
+
+ return [
+ 'success' => false,
+ 'message' => $e->getMessage()
+ ];
+ }
+ }
+
+ public function clearStoredData(): bool
+ {
+ try {
+ // Use the static method to clear the entire cache group
+ $this->cache->flush();
+ return true;
+
+ } catch (\Exception $e) {
+ error_log('[GMB] Error clearing stored data: ' . $e->getMessage());
+ return false;
+ }
+ }
+
+ public function getPriceLists(): ?array
+ {
+ if (!$this->credentials['location']) {
+ throw new \Exception('No location selected. Please select a location first.');
+ }
+
+ try {
+ $location_data = $this->getLocation($this->credentials['location']);
+ return $location_data['priceLists'] ?? [];
+ } catch (\Exception $e) {
+
+ $this->logError($e->getMessage(), [
+ 'method' => 'getPriceLists'
+ ]);
+ return null;
+ }
+ }
+
+ /**
+ * Get insights/analytics
+ */
+ public function getInsights(string $location_name, string $start_date, string $end_date): ?array
+ {
+// From the Docs:
+// Basic insights
+// Retrieves basic insights for a given list of locations. Use the accounts.locations.reportInsights API to return the insights that are associated with a location.
+//
+// To return the basic insights associated with a location, use the following:
+//
+// HTTP
+//
+// POST
+// https://mybusiness.googleapis.com/v4/accounts/{accountId}/locations:reportInsights
+// {
+// "locationNames": [
+// "accounts/{accountId}/locations/{locationId}"
+// ],
+// "basicRequest": {
+// "metricRequests": [
+// {
+// "metric": "QUERIES_DIRECT"
+// },
+// {
+// "metric": "QUERIES_INDIRECT"
+// }
+// ],
+// "timeRange": {
+// "startTime": "2016-10-12T01:01:23.045123456Z",
+// "endTime": "2017-01-10T23:59:59.045123456Z"
+// }
+// }
+// }
+
+ $params = [
+ 'dailyMetrics' => 'BUSINESS_IMPRESSIONS_DESKTOP_MAPS,BUSINESS_IMPRESSIONS_DESKTOP_SEARCH,BUSINESS_IMPRESSIONS_MOBILE_MAPS,BUSINESS_IMPRESSIONS_MOBILE_SEARCH,BUSINESS_DIRECTION_REQUESTS,CALL_CLICKS,WEBSITE_CLICKS',
+ 'dailyRange.startDate.year' => date('Y', strtotime($start_date)),
+ 'dailyRange.startDate.month' => date('n', strtotime($start_date)),
+ 'dailyRange.startDate.day' => date('j', strtotime($start_date)),
+ 'dailyRange.endDate.year' => date('Y', strtotime($end_date)),
+ 'dailyRange.endDate.month' => date('n', strtotime($end_date)),
+ 'dailyRange.endDate.day' => date('j', strtotime($end_date))
+ ];
+
+ return $this->getRequest(
+ "/v1/{$location_name}:fetchMultiDailyMetricsTimeSeries?" . http_build_query($params),
+ $params,
+ 'performance'
+ );
+ }
+
+ /**
+ * Helper: Format hours for GMB API
+ */
+ private function formatHoursPeriods(array $hours): array
+ {
+ $periods = [];
+ $days = ['SUNDAY', 'MONDAY', 'TUESDAY', 'WEDNESDAY', 'THURSDAY', 'FRIDAY', 'SATURDAY'];
+
+ foreach ($hours as $day => $times) {
+ if (empty($times['open']) || empty($times['close'])) {
+ continue;
+ }
+
+ $periods[] = [
+ 'openDay' => $days[$day] ?? $day,
+ 'openTime' => $times['open'], // Format: "09:00"
+ 'closeDay' => $days[$day] ?? $day,
+ 'closeTime' => $times['close'] // Format: "17:00"
+ ];
+ }
+
+ return $periods;
+ }
+
+
+ /**
+ * HELPER: Easy interface method for creating a simple post
+ * JVB()->connect('gmb')->createSimplePost("Check out our new menu!", "https://example.com/menu")
+ */
+ public function createSimplePost(string $message, ?string $cta_url = null, string $cta_type = 'LEARN_MORE'): ?array
+ {
+ $location_name = $this->getSelectedLocationResourceName();
+
+ if (empty($location_name)) {
+ throw new \Exception('No location selected. Please select a location first.');
+ }
+
+ $post_data = [
+ 'summary' => $message,
+ 'topic_type' => 'STANDARD'
+ ];
+
+ if ($cta_url) {
+ $post_data['call_to_action'] = [
+ 'type' => $cta_type,
+ 'url' => $cta_url
+ ];
+ }
+
+ return $this->createPost($location_name, $post_data);
+ }
+
+ /**
+ * HELPER: Easy interface method for updating basic business info
+ * JVB()->connect('gmb')->updateInfo(['title' => 'New Business Name', 'websiteUri' => 'https://newsite.com'])
+ */
+ public function updateInfo(array $updates): bool
+ {
+ $location_name = $this->getSelectedLocationResourceName();
+
+ if (empty($location_name)) {
+ throw new \Exception('No location selected. Please select a location first.');
+ }
+
+ return $this->updateBusinessInfo($location_name, $updates);
+ }
+
+
+ /**
+ * HELPER: Easy interface for clearing special hours
+ * JVB()->connect('gmb')->clearAllSpecialHours()
+ */
+ public function clearAllSpecialHours(): bool
+ {
+ $location_name = $this->getSelectedLocationResourceName();
+
+ if (empty($location_name)) {
+ throw new \Exception('No location selected. Please select a location first.');
+ }
+
+ return $this->clearSpecialHours($location_name);
+ }
+
+
+ protected function mappedMenuFields(string $postType):array
+ {
+ /**
+ * @param array $fields
+ * @param string $fields.name Thing
+ */
+ return apply_filters(
+ 'jvbGMBFields',
+ [
+ 'name' => 'post_title',
+ 'price' => 'price',
+ 'description' => 'post_excerpt',
+ 'nutritionFacts'=> 'nutritionFacts',
+ 'servesNumPeople'=>'servesNumPeople',
+ 'portionSize' => 'portionSize',
+ 'preparationMethods' => 'preparationMethods',
+ 'cuisines' => 'cuisines',
+ 'spiciness' => 'spiciness',
+ 'allergen' => 'allergen',
+ 'dietaryRestriction'=>'dietaryRestriction'
+ ],
+ jvbNoBase($postType));
+ }
+
+
+
+ /**
+ * Build menu structure from WordPress posts
+ */
+ protected function collectMenu(array $menu_items): array
+ {
+
+ $defaultMeta = Meta::forOptions($this->userID.'_integrations');
+ $defaults = ['menu_name', 'menu_description', 'default_section', 'cuisines', 'source_url', 'language', 'default_currency'];
+ $defaults = $defaultMeta->getAll($defaults);
+
+ // Group items by section
+ $sections_map = [];
+ foreach ($menu_items as $item) {
+
+ $gmb_item = $this->buildMenuItem($item, $defaults);
+
+ // Get section for this item
+ $categories = wp_get_post_terms($item->ID, BASE.'section');
+ $section_slug = !empty($categories) ?
+ $categories[0]->slug :
+ 'menu-items';
+ $section_name = !empty($categories) ?
+ $categories[0]->name :
+ ($defaults['default_section'] ?? 'Menu Items');
+
+ if (!isset($sections_map[$section_slug])) {
+ $sections_map[$section_slug] = [
+ 'labels' => [
+ 'displayName' => $section_name,
+ 'languageCode' => $defaults['language'] ?? 'en',
+ ],
+ 'items' => [],
+ ];
+ if (!empty($section['description'])) {
+ $sections_map[$section_slug]['labels']['description'] = $this->stripHtmlTags($section['description']);
+ }
+ }
+
+ $sections_map[$section_slug]['items'][] = $gmb_item;
+ }
+
+ $gmb_sections = $this->getMenuSectionsOrder($sections_map);
+
+ // Build complete menu
+ $gmb_menu = [
+ 'cuisines' => $this->getMenuCuisines($menu_items),
+ 'labels' => [
+ 'displayName' => $defaults['menu_name'] ?? 'Our Menu',
+ 'languageCode' => $defaults['language'] ?? 'en',
+ ],
+ 'sections' => $gmb_sections
+ ];
+
+ if (array_key_exists('menu_description', $defaults)) {
+ $gmb_menu['labels']['description'] = $this->stripHtmlTags($defaults['menu_description']);
+ }
+
+ // Add source URL if configured
+ if (!empty($defaults['source_url'])) {
+ $gmb_menu['source_url'] = $defaults['source_url'];
+ }
+
+ return [$gmb_menu]; // GMB expects an array of menus
+ }
+
+ protected function buildMenuItem(\WP_Post $item, array $defaults):array
+ {
+ $meta = Meta::forPost($item->ID);
+ $fields = $this->mappedMenuFields($item->post_type);
+ $values = $meta->getAll(array_values($fields));
+
+
+ // Build GMB-formatted item
+ $gmb_item = [
+ 'labels' => [
+ 'displayName' => $item->post_title,
+ 'description' => !empty($item->post_excerpt) ?
+ $this->stripHtmlTags($item->post_excerpt) :
+ $this->stripHtmlTags(wp_trim_words($item->post_content, 20)),
+ 'languageCode' => $defaults['language'] ?? 'en'
+ ],
+ 'attributes' => []
+ ];
+
+ // Process mapped fields
+ foreach ($fields as $googleField => $wpField) {
+ if (in_array($wpField, ['post_title', 'post_excerpt'])) {
+ continue;
+ }
+
+ $value = $values[$wpField] ?? null;
+ if (empty($value)) {
+ continue;
+ }
+
+ // Process field based on type
+ $processed = $this->processMenuField($googleField, $value, $values);
+ if ($processed !== null) {
+ $gmb_item['attributes'] = array_merge($gmb_item['attributes'], $processed);
+ }
+ }
+ $featuredImage = get_post_thumbnail_id($item->ID);
+ $mediaKey = $this->uploadImageToGMB($featuredImage, 'FOOD_AND_DRINK');
+ if ($mediaKey) {
+ $gmb_item['attributes'] = array_merge($gmb_item['attributes'], ['mediaKeys' => $mediaKey]);
+ }
+ if (empty($gmb_item['attributes'])) {
+ unset($gmb_item['attributes']);
+ }
+
+ return $gmb_item;
+ }
+
+ /**
+ * Process a single menu field value
+ */
+ protected function processMenuField(string $fieldName, $value, array $allValues): ?array
+ {
+ switch ($fieldName) {
+ case 'price':
+ if (is_numeric($value)) {
+ return [
+ 'price' => [
+ 'currencyCode' => $allValues['price_currency'] ?? 'CAD',
+ 'units' => intval($value),
+ 'nanos' => (($value - intval($value)) * 1000000000)
+ ]
+ ];
+ }
+ break;
+
+ case 'nutritionFacts':
+ if (is_array($value)) {
+ return ['nutritionFacts' => $this->processNutritionData($value)];
+ }
+ break;
+
+ case 'allergen':
+ $allergens = $this->processListField($value);
+ return $allergens ? ['allergens' => $allergens] : null;
+
+ case 'dietaryRestriction':
+ $restrictions = $this->processListField($value);
+ return $restrictions ? ['dietaryRestrictions' => $restrictions] : null;
+
+ case 'servesNumPeople':
+ return ['servesNumPeople' => intval($value)];
+
+ case 'portionSize':
+ return ['portionSize' => $value];
+
+ case 'preparationMethods':
+ $methods = $this->processListField($value);
+ return $methods ? ['preparationMethods' => $methods] : null;
+
+ case 'cuisines':
+ $cuisines = $this->processListField($value, 'strtoupper');
+ return $cuisines ? ['cuisines' => $cuisines] : null;
+
+ case 'spiciness':
+ return ['spiciness' => strtoupper($value)];
+
+ default:
+ return [$fieldName => $value];
+ }
+
+ return null;
+ }
+
+ /**
+ * Process list fields (arrays or comma-separated strings)
+ */
+ protected function processListField($value, ?callable $transform = null): ?array
+ {
+ if (empty($value)) {
+ return null;
+ }
+
+ // Convert to array if string
+ if (is_string($value)) {
+ $value = array_map('trim', explode(',', $value));
+ }
+
+ // Filter empty values
+ $value = array_values(array_filter($value));
+
+ // Apply transformation if provided
+ if ($transform && !empty($value)) {
+ $value = array_map($transform, $value);
+ }
+
+ return !empty($value) ? $value : null;
+ }
+
+ protected function getMenuSectionsOrder(array $sections_map):array
+ {
+ $optionsMeta = Meta::forOptions('options');
+ $sectionOrder = $optionsMeta->get('menu_section_order');
+
+ // Build final GMB menu structure
+ $ordered = [];
+
+ // First add sections in configured order
+ foreach ($sectionOrder as $section) {
+ $slug = sanitize_title($section['name']);
+ if (isset($sections_map[$slug]) && !empty($sections_map[$slug]['items'])) {
+ $ordered[$slug] = $sections_map[$slug];
+ unset($sections_map[$slug]);
+ }
+ }
+
+ // Add any remaining sections
+ foreach ($sections_map as $slug => $section) {
+ if (!empty($section['items'])) {
+ $ordered[$slug] = $section;
+ }
+ }
+
+ return array_values($ordered);
+ }
+ private function getMenuCuisines(array $menu_items, array $defaults = []): array
+ {
+ $cuisines = [];
+
+ // Check if there's a default cuisine
+ if (!empty($defaults['cuisines'])) {
+ $cuisines = is_array($defaults['cuisines']) ?
+ $defaults['cuisines'] :
+ [$defaults['cuisines']];
+ }
+
+ // Collect cuisines from individual items if specified
+ foreach ($menu_items as $item) {
+ $meta = Meta::forPost($item->ID);
+ $item_cuisines = $meta->get('cuisines');
+
+ if (!empty($item_cuisines)) {
+ $item_cuisines = is_array($item_cuisines) ?
+ $item_cuisines :
+ array_map('trim', explode(',', $item_cuisines));
+
+ foreach ($item_cuisines as $cuisine) {
+ $cuisine = strtoupper(trim($cuisine));
+ if (!in_array($cuisine, $cuisines)) {
+ $cuisines[] = $cuisine;
+ }
+ }
+ }
+ }
+
+ // Ensure we have at least one cuisine (GMB requires it)
+ if (empty($cuisines)) {
+ $cuisines = ['AMERICAN'];
+ }
+
+ return array_map('strtoupper', $cuisines);
+ }
+
+ /**
+ * Format nutrition facts for GMB API
+ */
+ protected function processNutritionData(array $nutritionData): array
+ {
+ $processed = [];
+
+ // Handle both repeater format and simple array format
+ if (isset($nutritionData[0]) && is_array($nutritionData[0])) {
+ // Repeater format
+ foreach ($nutritionData as $fact) {
+ if (!empty($fact['name']) && isset($fact['amount'])) {
+ $processed[] = [
+ 'name' => $fact['name'],
+ 'amount' => floatval($fact['amount']),
+ 'unit' => $fact['unit'] ?? 'GRAM'
+ ];
+ }
+ }
+ } else {
+ // Simple associative array format
+ foreach ($nutritionData as $name => $value) {
+ if (!empty($value)) {
+ $unit = 'GRAM';
+ if ($name === 'calories') {
+ $unit = 'CALORIE';
+ } elseif (in_array($name, ['cholesterol', 'sodium', 'totalCarbohydrate', 'protein'])) {
+ $unit = 'MILLIGRAM';
+ }
+
+ $processed[] = [
+ 'name' => $name,
+ 'amount' => floatval($value),
+ 'unit' => $unit
+ ];
+ }
+ }
+ }
+
+ return $processed;
+ }
+
+ protected function setContentTypes():void
+ {
+ $this->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 47d97fd..355e9c2 100644
--- a/inc/integrations/services/Helcim.php
+++ b/inc/integrations/services/Helcim.php
@@ -170,8 +170,6 @@
protected function setContentTypes(): void
{
- $this->has_content = true;
- $this->defaultContent = 'REGULAR';
$types = ['REGULAR', 'SERVICE', 'DIGITAL', 'FOOD_AND_BEV', 'EVENT', 'SUBSCRIPTION', 'DONATION'];
foreach ($types as $type) {
diff --git a/inc/integrations/services/HelcimOld.php b/inc/integrations/services/HelcimOld.php
new file mode 100644
index 0000000..22c7dff
--- /dev/null
+++ b/inc/integrations/services/HelcimOld.php
@@ -0,0 +1,1359 @@
+<?php
+
+namespace JVBase\integrations\services;
+use JVBase\integrations\Integrations;
+use JVBase\managers\queue\executors\IntegrationExecutor;
+use JVBase\managers\queue\TypeConfig;
+use JVBase\meta\Meta;
+use JVBase\ui\Checkout;
+
+if (!defined('ABSPATH')) {
+ exit;
+}
+
+/**
+ * Helcim Integration Class
+ *
+ * Handles HelcimPay.js checkout, invoice retrieval, customer/card management,
+ * and bidirectional product sync via API token authentication.
+ *
+ * Helcim is the source of truth for invoices and orders.
+ * Products sync bidirectionally through the standard integration field flow.
+ *
+ * @since 1.0.0
+ */
+class HelcimOld 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
+ *****************************************************************/
+
+ 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'),
+ ];
+ }
+}
diff --git a/inc/integrations/services/InstagramOld.php b/inc/integrations/services/InstagramOld.php
new file mode 100644
index 0000000..a09aa47
--- /dev/null
+++ b/inc/integrations/services/InstagramOld.php
@@ -0,0 +1,705 @@
+<?php
+/**
+ * Instagram Integration
+ * File: /inc/integrations/Instagram.php
+ */
+
+namespace JVBase\integrations\services;
+use JVBase\integrations\Integrations;
+use JVBase\meta\Meta;
+
+if (!defined('ABSPATH')) {
+ exit;
+}
+
+class InstagramOld 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)
+ {
+ $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'
+ ]
+ ];
+
+ // Define sync capabilities
+ $this->canSync = [
+ 'create' => true,
+ 'update' => false, // Instagram doesn't allow editing posts
+ 'delete' => true
+ ];
+
+ $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'
+ ]
+ );
+ }
+
+ 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
+ ];
+ }
+
+ /**
+ * Get request headers for Instagram API
+ */
+ protected function getRequestHeaders(): array
+ {
+ return [
+ 'Content-Type' => 'application/json',
+ 'Accept' => 'application/json'
+ ];
+ }
+
+ /**
+ * Build Instagram API URL
+ */
+ protected function getApiUrl(string $endpoint, ?string $baseKey = null): string|false
+ {
+ $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.";
+ }
+}
diff --git a/inc/integrations/services/PostMarkOld.php b/inc/integrations/services/PostMarkOld.php
new file mode 100644
index 0000000..9f7c7fd
--- /dev/null
+++ b/inc/integrations/services/PostMarkOld.php
@@ -0,0 +1,661 @@
+<?php
+
+namespace JVBase\integrations\services;
+use JVBase\integrations\Integrations;
+if (!defined('ABSPATH')) {
+ exit;
+}
+
+/**
+ * PostMark Email Service Integration
+ *
+ * Provides integration with PostMark for transactional email delivery
+ * Hooks into WordPress's wp_mail() function for seamless integration
+ */
+class PostMarkOld 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;
+
+ 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)
+ {
+ $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';
+
+ // 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'
+ ];
+
+ // 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' => [
+ '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();
+ }
+ }
+
+ /**
+ * Initialize WordPress mail hooks
+ */
+ protected function initializeMailHooks(): void
+ {
+ // Replace wp_mail with PostMark
+ add_filter('wp_mail', [$this, 'sendViaPostMark'], 10, 1);
+
+ // Optional: Hook for logging sent emails
+ add_action('postmark_email_sent', [$this, 'logSentEmail'], 10, 2);
+ add_action('postmark_email_failed', [$this, 'logFailedEmail'], 10, 2);
+ }
+
+ /**
+ * 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)
+ */
+ public function sendViaPostMark(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);
+
+ // Determine content type
+ $is_html = $parsed_headers['content-type'] === 'text/html' ||
+ strpos($message, '<html') !== false ||
+ strpos($message, '<body') !== false;
+
+ // 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()
+ );
+ }
+ }
+
+ /**
+ * Parse mail headers from various formats
+ */
+ protected function parseMailHeaders($headers): array
+ {
+ $parsed = [
+ 'content-type' => 'text/plain',
+ 'from' => null,
+ 'from-name' => null,
+ 'reply-to' => null,
+ 'cc' => [],
+ 'bcc' => [],
+ 'x-headers' => []
+ ];
+
+ 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
+ {
+ if (empty($line)) {
+ return;
+ }
+
+ // 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;
+ }
+
+ $processed[] = [
+ 'Name' => basename($file),
+ 'Content' => base64_encode(file_get_contents($file)),
+ 'ContentType' => mime_content_type($file) ?: 'application/octet-stream'
+ ];
+ }
+
+ return $processed;
+ }
+
+ /**
+ * Generate plain text from HTML content
+ */
+ protected function generateTextFromHtml(string $html): string
+ {
+ // Remove HTML tags
+ $text = strip_tags($html);
+
+ // Convert entities
+ $text = html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8');
+
+ // Clean up whitespace
+ $text = preg_replace('/\s+/', ' ', $text);
+ return trim($text);
+ }
+
+ /**
+ * Determine email tag based on content
+ */
+ protected function determineEmailTag(string $subject, array $headers): string
+ {
+ // Password reset emails
+ if (stripos($subject, 'password') !== false && stripos($subject, 'reset') !== false) {
+ return 'password-reset';
+ }
+
+ // New user registration
+ if (stripos($subject, 'welcome') !== false || stripos($subject, 'registration') !== false) {
+ return 'registration';
+ }
+
+ // Account notifications
+ if (stripos($subject, 'account') !== false) {
+ return 'account';
+ }
+
+ // Order/transaction emails
+ if (stripos($subject, 'order') !== false || stripos($subject, 'receipt') !== false) {
+ return 'transaction';
+ }
+
+ // Default
+ return 'general';
+ }
+
+ /**
+ * Check if should fallback to default mail
+ */
+ protected function shouldFallback(): bool
+ {
+ // Don't fallback if we've had too many errors
+ if ($this->error_stats['consecutive_errors'] >= 3) {
+ return false;
+ }
+
+ // Check if fallback is enabled in settings
+ $credentials = $this->getCredentials();
+ return ($credentials['enable_fallback'] ?? 'true') === 'true';
+ }
+
+ /**
+ * 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;
+ }
+
+ try {
+ $stats = $this->getRequest('stats/outbound', [
+ 'fromdate' => date('Y-m-d', strtotime("-{$days} days")),
+ 'todate' => date('Y-m-d')
+ ]);
+
+ if (!is_wp_error($stats)) {
+ $this->cache->set($cache_key, $stats, 3600); // Cache for 1 hour
+ return $stats;
+ }
+ } catch (\Exception $e) {
+ $this->logError('stats/outbound', ['error' => $e->getMessage()]);
+ }
+
+ 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 0d18f51..e3dbfff 100644
--- a/inc/integrations/services/Square.php
+++ b/inc/integrations/services/Square.php
@@ -2,177 +2,93 @@
namespace JVBase\integrations\services;
use Exception;
+use JVBase\integrations\Admin;
+use JVBase\integrations\Instance;
use JVBase\integrations\Integrations;
+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\Webhooks;
use JVBase\managers\queue\executors\IntegrationExecutor;
use JVBase\managers\queue\TypeConfig;
-use JVBase\meta\Form;
-use JVBase\meta\Meta;
+use JVBase\meta\Sanitizer;
use JVBase\registrar\Registrar;
-use JVBase\ui\Checkout;
-use JVBase\ui\CRUDSkeleton;
use WP_Error;
-use WP_Post;
-use WP_Query;
-use WP_User;
if (!defined('ABSPATH')) {
exit;
}
-/**
- * Square Integration Class
- *
- * Handles OAuth 2.0 authentication and API interactions with Square
- * Uses the latest Square API version with OAuth code flow
- *
- * @since 1.0.0
- */
class Square extends Integrations
{
- protected static string $syncCustomer = 'square_sync_customer';
- protected array $allowedContent = [
- 'REGULAR',
- 'FOOD_AND_BEV',
- 'APPOINTMENTS_SERVICE',
- 'DIGITAL',
- 'EVENT',
- 'DONATION'
- ];
- /**
- * Square API Configuration
- */
- protected string $service_name = 'square';
- protected array|string $apiBase = [
- 'production' => 'https://connect.squareup.com',
- 'sandbox' => 'https://connect.squareupsandbox.com'
- ];
-
- protected string $apiVersion = '2025-09-24';
-
- protected string $environment = 'sandbox';
-
- private const PASSWORD_RESET_INTERVAL = 3; // Reset password every 3 logins, for customers
-
- /**
- * OAuth Configuration
- */
- protected bool $isOAuthService = true;
+ use Admin,
+ Instance,
+ Requests,
+ OAuth,
+ SyncFrom,
+ SyncTo,
+ SyncDefaults,
+ Webhooks;
protected string $orderPostType = '_square_order';
- protected array $newOrder = [];
- protected array $oauth = [
- 'authorize' => '',
- 'token' => '',
- 'revoke' => '',
- 'scopes' => [
- 'MERCHANT_PROFILE_READ',
- 'MERCHANT_PROFILE_WRITE',
- 'PAYMENTS_WRITE',
- 'PAYMENTS_READ',
- 'CUSTOMERS_WRITE',
- 'CUSTOMERS_READ',
- 'INVENTORY_READ',
- 'INVENTORY_WRITE',
- 'ITEMS_READ',
- 'ITEMS_WRITE',
- 'ORDERS_READ',
- 'ORDERS_WRITE'
- ]
- ];
+ protected string $apiVersion = '2025-09-24';
- /**
- * Square-specific properties
- */
- protected string $merchantId = '';
- protected string $locationId = '';
- protected array $locations = [];
-
- protected static Square $instance;
-
- public static function getInstance():self
+ protected function __construct()
{
- if (!isset(self::$instance)) {
- self::$instance = new self();
- }
- return self::$instance;
- }
-
- protected function __construct(?int $userID = null)
- {
-
- // Display properties
$this->title = 'Square';
$this->icon = 'square-logo';
+ $this->supportsWebp = false;
- $this->refresh_interval = 7 * DAY_IN_SECONDS;
-
- $this->newOrder = [
- 'post_type' => $this->orderPostType,
- 'post_status' => 'PROPOSED',
+ $this->canSync = [
+ 'create' => true,
+ 'update' => true,
+ 'delete' => true,
];
- // Define credential fields
- $this->fields = [
- 'environment' => [
- 'type' => 'select',
- 'label' => 'Environment',
- 'options' => [
- 'sandbox' => 'Sandbox',
- 'production'=> 'Production',
- ],
- 'default' => 'sandbox',
- ],
- 'client_id' => [
- 'type' => 'text',
- 'label' => 'Application ID',
- 'hint' => 'Found in Square Developer Dashboard',
- 'required' => true,
- ],
- 'client_secret' => [
- 'type' => 'text',
- 'subtype' => 'password',
- 'label' => 'Application Secret',
- 'hint' => 'Found in OAuth section of Square Developer Dashboard',
- 'required' => true,
- ],
- 'location_id' => [
- 'type' => 'select',
- 'label' => 'Default Location',
- 'options' => [],
- 'hint' => 'Select default Square location for transactions',
- ]
- ];
+ $this->defineFields();
+ $this->defineInstructions();
+ $this->defineOAuth();
- // Advanced settings
- $this->advanced = [
- 'webhook_signature_key' => [
- 'type' => 'text',
- 'subtype' => 'password',
- 'label' => 'Webhook Signature Key',
- 'hint' => 'Used to verify webhook authenticity',
- ],
- 'access_token' => [
- 'type' => 'text',
- 'subtype' => 'password',
- 'label' => 'Access Token',
- 'hint' => 'Generated automatically after OAuth authorization',
- 'readonly' => true,
- ],
- 'refresh_token' => [
- 'type' => 'text',
- 'subtype' => 'password',
- 'label' => 'Refresh Token',
- 'hint' => 'Used to refresh expired access tokens',
- 'readonly' => true,
- ],
- 'merchant_id' => [
- 'type' => 'text',
- 'label' => 'Merchant ID',
- 'hint' => 'Square Merchant ID (obtained after authorization)',
- 'readonly' => true,
- ],
- ];
+ $this->baseUrl = $this->getBaseURL();
+ parent::__construct();
+
+ $this->defineActions();
+ }
+
+ protected function defineFields():void
+ {
+ $env = $this->addField('environment', 'Environment','select','manage_options');
+ $env->setOptions([
+ 'sandbox' => 'Sandbox',
+ 'production'=> 'Production'
+ ]);
+ $env->setDefault('sandbox');
+ $id = $this->addField('client_id', 'Application ID', 'text', 'manage_options');
+ $id->setRequired();
+ $id->setHint('Found in Square Developer Dashboard');
+
+ $sec = $this->addField('client_secret', 'Application Secret', 'text', 'manage_options');
+ $sec->setSubType('password');
+ $sec->setRequired();
+ $sec->setHint('Found in OAuth section of Square Developer Dashboard');
+
+ //Locations are set from the first response from the integration
+ $locs = $this->addField('locations', 'Locations', 'select','manage_options');
+ $locs->setHidden();
+ $locs->setOptions($this->getLocationOptions());
+
+ $loc = $this->addField('location_id', 'Default Location', 'select', 'manage_options');
+ $loc->setOptions($this->loadCredentials()['locations']);
+ $loc->setHint('Select default Square location for transactions');
+ }
+
+ protected function defineInstructions():void
+ {
$this->instructions = [
'Go to <a href="https://developer.squareup.com/apps" target="_blank">Square Developer Dashboard</a>',
'Create a new application or select an existing one',
@@ -181,79 +97,329 @@
'Copy the Application ID and Application Secret',
'Click "Authorize Connection" below to complete OAuth flow'
];
+ }
-
- $this->canSync = [
- 'create' => true,
- 'update' => true,
- 'delete' => true
- ];
- $this->supportsWebp = false;
- $this->hasWebhooks = true;
-
- parent::__construct();
-
- // Add Square-specific actions
- $this->actions = array_merge(
- $this->actions,
- [
- 'select_location' => 'handleSelectLocation',
- 'fetch_locations' => 'handleFetchLocations',
- 'verify_webhook' => 'handleVerifyWebhook',
- 'test_payment' => 'handleTestPayment'
- ]
+ protected function defineActions():void
+ {
+ $this->addAction(
+ 'Select Location',
+ [$this, 'handleSelectLocation'],
+ 'select_location',
+ 'building-office',
);
- $this->buttons = array_merge(
- $this->buttons,
- [
- 'import_from_square' => 'Import Catalog from Square',
- 'sync_to_square' => 'Sync Site to Square',
- ]
+ $this->addAction(
+ 'Fetch Locations',
+ [$this, 'getLocations'],
+ 'fetch_locations',
+ 'building-office',
);
+ $this->addAction(
+ 'Import From Square',
+ [$this, 'handleImportFromSquare'],
+ 'import_from_square',
+ 'download-simple'
+ );
+
+ $this->addAction(
+ 'Sync to Square',
+ [$this, 'handleSyncToSquare'],
+ 'sync_to_square',
+ 'export',
+ );
+
+// $this->addAction(
+// 'Verify Webhook',
+// [$this, 'handleVerifyWebhook'],
+// 'verify_webhook',
+// );
+ }
+
+ protected function defineOAuth():void
+ {
+ $this->baseOAuthUrl = $this->getBaseURL();
+ $base = $this->getBaseURL();
+
+ $this->oauth = new OAuthURLs(
+ sprintf('%s/oauth2/authorize', $base),
+ sprintf('%s/oauth2/token', $base),
+ sprintf('%s/oauth2/revoke', $base)
+ );
+ }
+
+ protected function registerAdditionalHooks():void
+ {
add_action('init', [$this, 'registerSquarePostTypes'], 5);
add_action('init', [$this, 'addDashboardPages'], 10);
add_action(BASE.'dashboard_page_orders', [$this, 'renderDashPage']);
}
- /**
- * Initialize the integration with loaded credentials
- */
protected function initialize(): void
{
- if (empty($this->credentials)) {
- $this->loadCredentials();
- }
-
- $this->merchantId = $this->credentials['merchant_id'] ?? '';
- $this->locationId = $this->credentials['location_id'] ?? '';
- $this->environment = $this->credentials['environment'] ?? 'sandbox';
-
- // Set OAuth URLs based on environment
- $baseUrl = ($this->environment === 'production')
- ? 'https://connect.squareup.com'
- : 'https://connect.squareupsandbox.com';
-
- $this->oauth['authorize'] = $baseUrl . '/oauth2/authorize';
- $this->oauth['token'] = $baseUrl . '/oauth2/token';
- $this->oauth['revoke'] = $baseUrl . '/oauth2/revoke';
-
- // Set up API endpoints with version
- $this->apiEndpoints = [
- 'locations' => '/v2/locations',
- 'customers' => '/v2/customers',
- 'catalog' => '/v2/catalog',
- 'payments' => '/v2/payments',
- 'orders' => '/v2/orders',
- 'inventory' => '/v2/inventory'
- ];
-
+ // TODO: Implement initialize() method.
}
- public function getOrderFields():array
+ protected function refreshAccessToken(OAuthCredentials $credentials): array
+ {
+ if (!$this->getClientID() || !$this->getClientSecret())
+ $response = wp_remote_post(
+ $this->oauth->getToken(),
+ [
+ 'body' => [
+ 'client_id' => $this->getClientID(),
+ 'client_secret' => $this->getClientSecret(),
+ 'refresh_token' => $credentials->refresh_token,
+ 'grant_type' => 'refresh_token'
+ ],
+ 'headers' => $this->getOAuthRequestHeaders()
+ ]
+ );
+ if (is_wp_error($response)) {
+ $this->logError(
+ 'refreshAccessToken',
+ 'Failed to refresh Square token',
+ [
+ 'error' => $response->get_error_message()
+ ]
+ );
+ return $this->response(false, 'Could not refresh token');
+ }
+
+ $data = json_decode(wp_remote_retrieve_body($response));
+ if (isset($data['access_token'])) {
+ $credentials = [
+ 'access_token' => $data['access_token'],
+ 'expires_at' => time() + ($data['expires_in'] ?? 2592000) // 30 days default
+ ];
+
+ $this->saveCredentials($credentials);
+ return $this->response(true, 'Successfully refreshed access token');
+ }
+ return $this->response(false, 'Something went wrong that wasn\'t caught - could not refresh access token');
+ }
+
+ protected function getRequestHeaders(): array
{
return [
+ 'Authorization' => 'Bearer ' . ($this->getAccessToken()),
+ 'Square-Version' => $this->apiVersion,
+ 'Content-Type' => 'application/json'
+ ];
+ }
+
+ protected function handleResponse(\WP_Error|array $response, string $method, string $endpoint): array
+ {
+ if (is_wp_error($response) && $response->get_error_message() === 'rate_limit_exceeded') {
+ return $this->response(false, 'Rate limit exceeded');
+ }
+ $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);
+ }
+
+ protected function setContentTypes(): void
+ {
+ // TODO: Implement setContentTypes() method.
+ }
+
+ protected function formatForService(int $itemID, string $type = 'post'): array
+ {
+ return match ($type) {
+ 'post' => $this->formatPostItem($itemID),
+ 'user' => $this->formatUserItem($itemID),
+ 'term' => $this->formatTermItem($itemID),
+ default => []
+ };
+ }
+
+ public function handleEmailSearch(string $email): string|false
+ {
+ try {
+ $response = $this->postRequest(
+ sprintf('%s/v2/customers/search', $this->getBaseURL()),
+ [
+ 'query' => [
+ 'filter' => [
+ 'email_address' => [
+ 'exact' => $email
+ ]
+ ]
+ ]
+ ]
+ );
+ return $response['id'];
+ } catch (Exception $e) {
+ return false;
+ }
+ }
+ protected function handleEmailSearchResponse(WP_Error|array $response, string $method):array
+ {
+
+ }
+
+ protected function handleCreateUser(array $data): array
+ {
+ try {
+ return $this->postRequest(
+ sprintf('%s/v2/customers',$this->getBaseURL()),
+ $data
+ );
+ } catch (Exception $e) {
+ return $this->response(false, 'Failed to create customer: '.$e->getMessage());
+ }
+ }
+ protected function handleCreateUserResponse(WP_Error|array $response, string $method):array
+ {
+
+ }
+ protected function validateUserFields(int $userID, array $fields):array|false
+ {
+ $allowed = [
+ 'given_name',
+ 'family_name',
+ 'company_name',
+ 'email_address',
+ 'phone_number',
+ 'idempotency_key',
+ 'nickname',
+ 'address',
+ 'reference_id', // The WP user id, in this case
+ 'note',
+ 'birthday'
+ ];
+ $fields = array_filter($fields, function ($f) use ($allowed, $fields) {
+ if (!in_array($f, $allowed)) {
+ $this->logError('validateUserFields', 'Stripped attempted field for user: '.$f.' with value: '.print_r($fields[$f], true));
+ }
+ return in_array($f, $allowed) && !empty($fields[$f]);
+ }, ARRAY_FILTER_USE_KEY);
+ if (empty($fields)) {
+ $this->logError('validateUserFields', 'No valid fields found');
+ return false;
+ }
+
+
+ //Square requires at least one of these fields
+ $any = [
+ 'given_name',
+ 'family_name',
+ 'company_name',
+ 'email_address',
+ 'phone_number'
+ ];
+
+ $doIt = false;
+ foreach ($any as $a) {
+ if (array_key_exists($a, $fields) && !empty($fields[$a])) {
+ $doIt = true;
+ }
+ }
+ if (!$doIt) {
+ $this->logError('validateUserFields', 'We need at least one of the following fields: '.print_r($any, true));
+ return false;
+ }
+
+ $addressAllowed = [
+ 'address_line_1',
+ 'address_line_2',
+ 'address_line_3',
+ 'locality', // city
+ 'sublocality', //
+ 'sublocality_2', //
+ 'sublocality_3', //
+ 'administrative_district_1', //Province, in our case
+ 'administrative_district_2', //
+ 'administrative_district_3', //
+ 'postal_code',
+ 'country',
+ 'first_name',
+ 'last_name',
+ ];
+ if (array_key_exists('address', $fields)) {
+ $fields['address'] = array_filter($fields['address'], function ($f) use ($addressAllowed, $fields){
+ return array_key_exists($f, $addressAllowed) && !empty($fields['address'][$f]);
+ }, ARRAY_FILTER_USE_KEY);
+ }
+
+ foreach ($fields as $f => $v) {
+ $v = match ($f) {
+ 'email' => filter_var($v, FILTER_SANITIZE_EMAIL),
+ 'phone' => Sanitizer::sanitizePhone($v),
+ default => sanitize_text_field($v),
+ };
+ if (empty($v) && in_array($f, $this->requiredUserFields())) {
+ return false;
+ }
+ $fields[$f] = $v;
+ }
+ //Add the WP User ID as the reference
+ $fields['reference_id'] = $userID;
+ return $fields;
+ }
+
+
+ protected function validateWebhook(array $payload): bool
+ {
+ // TODO: Implement validateWebhook() method.
+ }
+
+ protected function processWebhook(array $payload): bool
+ {
+ // TODO: Implement processWebhook() method.
+ }
+
+ protected function extractWebhookId(array $payload): string
+ {
+ // TODO: Implement extractWebhookId() method.
+ }
+
+ /********************************************************************
+ * CUSTOM POST TYPES
+ ********************************************************************/
+ protected function registerAdditionalQueueTypes(IntegrationExecutor $executor): void
+ {
+ $queue = JVB()->queue();
+ $queue->registry()->register(
+ self::$syncCustomer,
+ new TypeConfig(
+ executor: $executor,
+ maxRetries: 1,
+ )
+ );
+
+ $queue->registry()->register(
+ self::$import,
+ new TypeConfig(
+ executor: $executor,
+ maxRetries: 2
+ )
+ );
+ }
+
+ public function registerSquarePostTypes():void
+ {
+ $orders = Registrar::forPost($this->orderPostType, 'Square Order', 'Square Orders');
+ $orders->make([
+ 'public' => true
+ ]);
+ $orders->setAll(['system']);
+
+ $fields = $orders->fields();
+ foreach ($this->getOrderFields() as $fieldName => $config) {
+ $fields->addField($fieldName, $config);
+ }
+ }
+ public function getOrderFields():array
+ {
+ return [
'post_title' => [
'type' => 'text',
'label' => 'Order Number'
@@ -341,3152 +507,114 @@
'label' => 'Last Updated',
]
];
- }
-
- public function registerSquarePostTypes():void
- {
- $orders = Registrar::forPost($this->orderPostType, 'Square Order', 'Square Orders');
- $orders->make([
- 'public' => true
- ]);
- $orders->setAll(['system']);
-
- $fields = $orders->fields();
- foreach ($this->getOrderFields() as $fieldName => $config) {
- $fields->addField($fieldName, $config);
- }
- }
-
- /**
- * Get request headers for API calls
- */
- protected function getRequestHeaders(): array
- {
- return [
- 'Authorization' => 'Bearer ' . ($this->credentials['access_token'] ?? ''),
- 'Square-Version' => $this->apiVersion,
- 'Content-Type' => 'application/json'
- ];
- }
-
- /**
- * Add Square-specific OAuth parameters
- */
- protected function addOAuthParams(array $params): array
- {
- // Load current environment setting
- $this->ensureInitialized();
-
- // For production, add session=false
- // For sandbox, session parameter is not supported and should be omitted
- if ($this->environment === 'production') {
- $params['session'] = 'false';
- } else {
- // Ensure session is not included for sandbox
- unset($params['session']);
- }
-
- return $params;
- }
-
- /**
- * Exchange OAuth code for tokens
- * Override to handle Square's specific response format
- */
- protected function exchangeOAuthCode(string $code): ?array
- {
- $this->ensureInitialized();
-
- // Prepare the request body as an array
- $body = [
- 'client_id' => $this->credentials['client_id'] ?? '',
- 'client_secret' => $this->credentials['client_secret'] ?? '',
- 'code' => $code,
- 'grant_type' => 'authorization_code',
- 'redirect_uri' => $this->getRedirectUri()
- ];
-
- $response = wp_remote_post($this->oauth['token'], [
- 'body' => json_encode($body),
- 'headers' => [
- 'Content-Type' => 'application/json',
- 'Square-Version' => $this->apiVersion
- ]
- ]);
-
- if (is_wp_error($response)) {
- $this->logError('OAuth token exchange failed', ['error' => $response->get_error_message()]);
- return null;
- }
-
- $data = json_decode(wp_remote_retrieve_body($response), true);
- if (isset($data['access_token'])) {
- return [
- 'access_token' => $data['access_token'],
- 'refresh_token' => $data['refresh_token'] ?? '',
- 'expires_in' => $data['expires_in'] ?? 2592000, // 30 days default
- 'token_type' => $data['token_type'] ?? 'Bearer',
- 'merchant_id' => $data['merchant_id'] ?? '',
- 'scope' => $data['scope'] ?? ''
- ];
- }
-
- $this->logError('Failed to obtain access token', ['response' => $data]);
- return null;
- }
-
- /**
- * Add Square-specific credential data after OAuth
- */
- protected function addCredentialData(array $credentials, array $tokens): array
- {
- // Store Square-specific data
- $credentials['merchant_id'] = $tokens['merchant_id'] ?? '';
-
- // Preserve selected location if it exists
- $credentials['location_id'] = $this->credentials['location_id'] ?? '';
-
- // Preserve sandbox setting
- $credentials['environment'] = $this->credentials['environment'] ?? 'sandbox';
-
- return $credentials;
- }
-
- /**
- * Refresh OAuth token
- * Override to handle Square's refresh token flow
- */
- protected function refreshOAuthToken(): bool
- {
- if (!$this->isOAuthService || empty($this->credentials['refresh_token'])) {
- return false;
- }
-
- $response = wp_remote_post($this->oauth['token'], [
- 'body' => [
- 'client_id' => $this->credentials['client_id'],
- 'client_secret' => $this->credentials['client_secret'],
- 'refresh_token' => $this->credentials['refresh_token'],
- 'grant_type' => 'refresh_token'
- ],
- 'headers' => $this->getRequestHeaders()
- ]);
-
- if (is_wp_error($response)) {
- $this->logError('Failed to refresh Square token', [
- 'error' => $response->get_error_message()
- ]);
- return false;
- }
-
- $data = json_decode(wp_remote_retrieve_body($response), true);
-
- if (isset($data['access_token'])) {
- $this->credentials['access_token'] = $data['access_token'];
- $this->credentials['expires_at'] = time() + ($data['expires_in'] ?? 2592000); // 30 days
- // Note: Square returns the SAME refresh token
- if (isset($data['refresh_token'])) {
- $this->credentials['refresh_token'] = $data['refresh_token'];
}
-
- $this->saveCredentials($this->credentials);
- return true;
- }
-
- return false;
- }
-
- /**
- * Load Square locations
- */
- protected function loadLocations(): void
+ /********************************************************************
+ * UTILITY METHODS
+ ********************************************************************/
+ protected function getEnvironment():string|false
{
- // Skip if we don't have credentials yet (during OAuth flow)
- if (empty($this->credentials['access_token'])) {
- return;
+ return $this->loadCredentials()['environment']??false;
+ }
+ protected function getClientID():string|false
+ {
+ return $this->loadCredentials()['client_id']??false;
+ }
+ protected function getClientSecret():string|false
+ {
+ return $this->loadCredentials()['client_secret']??false;
+ }
+ protected function getMerchantID():string|false
+ {
+ return $this->loadCredentials()['merchant_id']??false;
+ }
+ protected function getLocationID():string|false
+ {
+ return $this->loadCredentials()['location_id']??false;
+ }
+ protected function getLocations():array|false
+ {
+ if (!$this->getAccessToken()) {
+ return false;
}
try {
- $response = $this->getRequest(
- '/v2/locations',
- [],
- $this->environment
+ return $this->getRequest(
+ sprintf('%s/v2/locations', $this->getBaseURL()),
);
+ } catch (Exception $e) {
+ $this->logError(
+ 'getLocations',
+ 'Failed to load locations',
+ [
+ 'error' => $e->getMessage()
+ ]
+ );
+ }
+ return [];
+ }
+ protected function handleGetLocationResponse(WP_Error|array $response, string $method):array
+ {
if (isset($response['locations'])) {
- $this->locations = $response['locations'];
+ $locations = $response['locations'];
- // Update location options in fields
- $locationOptions = [];
- foreach ($this->locations as $location) {
- if ($location['status'] === 'ACTIVE') {
- $locationOptions[$location['id']] = $location['name'] ?? $location['id'];
+ $options=[];
+ foreach ($locations as $l) {
+ if ($l['status'] === 'ACTIVE') {
+ $options[$l['id']] = $l['name']??$l['id'];
}
}
-
- if (!empty($locationOptions)) {
- $this->fields['location_id']['options'] = $locationOptions;
+ if (!empty($options)) {
+ $this->saveCredentials(['locations' => $options], false);
}
+ return $this->response(true, 'Found locations. Stored in table for reference.', $locations);
}
- } catch (Exception $e) {
- $this->logError('Failed to load locations', ['error' => $e->getMessage()]);
- // Set empty locations array so we don't retry constantly
- $this->locations = [];
+ return $this->response(false, 'No locations found.');
}
- }
-
- protected function renderOAuthConnectedOptions(): void
+ protected function getLocationOptions():array
+ {
+ return $this->loadCredentials()['locations']??[];
+ }
+ protected function getBaseURL():string
{
- 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
- }
-
- $this->loadLocations();
- if (!empty($this->locations)) {
- ?>
- <div class="form-field" data-service="square">
- <label for="square_location_id">Square Location</label>
- <select name="location_id"
- id="square_location_id"
- class="jvb-ajax-update"
- data-action="select_location">
- <option value="">Select a location...</option>
- <?php foreach ($this->locations as $location): ?>
- <option value="<?php echo esc_attr($location['id']); ?>"
- <?php selected($this->credentials['location_id'] ?? '', $location['id']); ?>>
- <?php echo esc_html($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_id']); ?>
- </div>
- <?php
- }
- } else {
- ?>
- <div class="notice notice-warning">
- <p>No Square locations found. Please ensure your Square credentials are correct.</p>
- </div>
- <?php
- }
+ return $this->getEnvironment() === 'production'
+ ? 'https://connect.squareup.com'
+ : 'https://connect.squareupsandbox.com';
}
-
- /**
- * Handle location selection
- */
- public function handleSelectLocation($data): array
+ protected function getAccessToken():string
{
- if (empty($data['location_id'])) {
- return [
- 'success' => false,
- 'message' => 'No location selected'
- ];
- }
-
- $this->credentials['location_id'] = sanitize_text_field($data['location_id']);
- $this->locationId = $this->credentials['location_id'];
-
- $result = $this->saveCredentials($this->credentials);
-
- if ($result['success']) {
- return [
- 'success' => true,
- 'message' => 'Location updated successfully'
- ];
- }
-
- return $result;
+ return $this->loadCredentials()['access_token']??'';
}
-
- /**
- * Handle fetching locations
- */
- public function handleFetchLocations(): array
+ /***************************************************************
+ * ACTIONS
+ ***************************************************************/
+ public function handleSelectLocation(array $data):array
{
- try {
- $this->loadLocations();
-
- if (empty($this->locations)) {
- return [
- 'success' => false,
- 'message' => 'No locations found'
- ];
- }
-
- return [
- 'success' => true,
- 'data' => $this->locations,
- 'message' => sprintf('Found %d location(s)', count($this->locations))
- ];
- } catch (Exception $e) {
- return [
- 'success' => false,
- 'message' => 'Failed to fetch locations: ' . $e->getMessage()
- ];
- }
- }
-
- /**
- * Handle webhook verification
- */
- public function handleVerifyWebhook($data): array
- {
- if (empty($data['signature']) || empty($data['body'])) {
- return [
- 'success' => false,
- 'message' => 'Missing signature or body'
- ];
+ if (empty($data['location_id']??'')) {
+ return $this->response(false, 'No location selected');
}
- $signatureKey = $this->credentials['webhook_signature_key'] ?? '';
-
- if (empty($signatureKey)) {
- return [
- 'success' => false,
- 'message' => 'Webhook signature key not configured'
- ];
- }
-
- // Calculate expected signature
- $stringToSign = $data['url'] . $data['body'];
- $expectedSignature = base64_encode(
- hash_hmac('sha256', $stringToSign, $signatureKey, true)
- );
-
- $isValid = hash_equals($expectedSignature, $data['signature']);
-
- return [
- 'success' => $isValid,
- 'message' => $isValid ? 'Valid webhook signature' : 'Invalid webhook signature'
+ $credentials = [
+ 'location_id' => sanitize_text_field($data['location_id'])
];
+ return $this->saveCredentials($credentials, false);
}
- /**
- * Test payment/connection
- */
- public function handleTestPayment(): array
+ public function handleFetchLocations():array
{
try {
- // Test with a simple API call to verify connection
- $response = $this->getRequest(
- '/v2/locations/'.$this->locationId,
- [],
- $this->environment
+ $locations = $this->getLocations();
+ if (!$locations) {
+ return $this->response(false, 'No locations found');
+ }
+ return $this->response(
+ true,
+ sprintf('Found %d location(s)', count($locations)),
+ $locations
);
- if (!empty($response['location'])) {
- return [
- 'success' => true,
- 'message' => 'Successfully connected to Square',
- 'data' => [
- 'location_name' => $response['location']['name'] ?? 'Unknown',
- 'merchant_id' => $this->merchantId,
- 'environment' => $this->environment
- ]
- ];
- }
-
- return [
- 'success' => false,
- 'message' => 'Connection test failed'
- ];
} catch (Exception $e) {
- return [
- 'success' => false,
- 'message' => 'Connection test failed: ' . $e->getMessage()
- ];
- }
- }
-
- /**
- * Create a Square customer
- */
- public function createCustomer(array $customerData): array
- {
- try {
- $response = $this->postRequest('/v2/customers', [
- 'given_name' => $customerData['first_name'] ?? '',
- 'family_name' => $customerData['last_name'] ?? '',
- 'email_address' => $customerData['email'] ?? '',
- 'phone_number' => $customerData['phone'] ?? '',
- 'reference_id' => $customerData['reference_id'] ?? '',
- 'note' => $customerData['note'] ?? ''
- ], $this->environment);
-
- return [
- 'success' => true,
- 'data' => $response['customer'] ?? []
- ];
- } catch (Exception $e) {
- return [
- 'success' => false,
- 'message' => 'Failed to create customer: ' . $e->getMessage()
- ];
- }
- }
-
- /**
- * Create a catalog item
- */
- public function createCatalogItem(array $itemData): array
- {
- try {
- $response = $this->postRequest('/v2/catalog/object', [
- 'idempotency_key' => wp_generate_uuid4(),
- 'object' => [
- 'type' => 'ITEM',
- 'id' => '#' . sanitize_title($itemData['name']),
- 'item_data' => [
- 'name' => $itemData['name'],
- 'description' => $itemData['description'] ?? '',
- 'variations' => $this->buildItemVariations($itemData)
- ]
- ]
- ], $this->environment);
-
- return [
- 'success' => true,
- 'data' => $response['catalog_object'] ?? []
- ];
- } catch (Exception $e) {
- return [
- 'success' => false,
- 'message' => 'Failed to create catalog item: ' . $e->getMessage()
- ];
- }
- }
-
- /**
- * Build item variations for catalog
- */
- protected function buildItemVariations(array $itemData): array
- {
- $variations = [];
-
- if (isset($itemData['variations']) && is_array($itemData['variations'])) {
- foreach ($itemData['variations'] as $variation) {
- $variations[] = [
- 'type' => 'ITEM_VARIATION',
- 'id' => '#' . sanitize_title($variation['name']),
- 'item_variation_data' => [
- 'name' => $variation['name'],
- 'pricing_type' => 'FIXED_PRICING',
- 'price_money' => [
- 'amount' => $this->formatPrice($variation['_square_price']??0),
- 'currency' => $this->getCurrency()
- ]
- ]
- ];
- }
- } else {
- // Default single variation
- $variations[] = [
- 'type' => 'ITEM_VARIATION',
- 'id' => '#regular',
- 'item_variation_data' => [
- 'name' => 'Regular',
- 'pricing_type' => 'FIXED_PRICING',
- 'price_money' => [
- 'amount' => $this->formatPrice($itemData['_square_price'] ?? 0),
- 'currency' => $this->getCurrency()
- ]
- ]
- ];
- }
-
- return $variations;
- }
-
- /**
- * Handle OAuth disconnect
- */
- public function handleOAuthDisconnect(): array
- {
- try {
- // Revoke the token with Square
- if (!empty($this->credentials['access_token'])) {
- wp_remote_post($this->oauth['revoke'], [
- 'body' => [
- 'client_id' => $this->credentials['client_id'] ?? '',
- 'access_token' => $this->credentials['access_token']
- ],
- 'headers' => $this->getRequestHeaders()
- ]);
- }
-
- // Clear stored credentials
- $this->credentials = [
- 'client_id' => $this->credentials['client_id'] ?? '',
- 'client_secret' => $this->credentials['client_secret'] ?? ''
- ];
-
- $this->saveCredentials($this->credentials);
- $this->clearCache();
-
- return [
- 'success' => true,
- 'message' => 'Successfully disconnected from Square'
- ];
- } catch (Exception $e) {
- return [
- 'success' => false,
- 'message' => 'Failed to disconnect: ' . $e->getMessage()
- ];
- }
- }
-
- /**
- * Register additional WordPress hooks
- */
- protected function registerAdditionalHooks(): void
- {
- if (!$this->isSetUp()) {
- error_log('Square is not setup');
- return;
- }
-
- add_action('wp_enqueue_scripts', [$this, 'enqueueScripts']);
-
- add_filter('jvbAdditionalActions', [Checkout::class, 'render']);
-
- add_filter('jvb_checkout_description', function (string $desc, string $provider) {
- if ($provider === 'square') {
- return 'Securely checkout with your name, email, and payments processed by Square.';
- }
- return $desc;
- }, 10, 2);
-
- // Square-specific pickup fields (extracted from old outputCheckout)
- add_filter('jvb_checkout_fields', [$this, 'addPickupFields'], 10, 2);
-
- // Browse URL for this client (restaurant menu)
- add_filter('jvb_checkout_browse_url', function () {
- return get_post_type_archive_link(BASE . 'menu_item');
- });
- add_filter('jvb_checkout_browse_text', function () {
- return 'browse our menu';
- });
- }
-
- /**
- * Pickup/ordering fields for the shared checkout form.
- * Specific to this Square client's food ordering use case.
- */
- public function addPickupFields(string $html, string $provider): string
- {
- if ($provider !== 'square') {
- return $html;
- }
-
- return $html
- . '<h3>Pickup Details</h3>'
- . Form::render('pickup_time', null, [
- 'type' => 'datetime',
- 'label' => 'Pickup Time',
- 'min' => '11:00',
- 'max' => '20:00',
- 'required' => true,
- ])
- . Form::render('special_instructions', null, [
- 'type' => 'textarea',
- 'label' => 'Special Instructions',
- 'quill' => true,
- ]);
- }
-
- protected function registerAdditionalQueueTypes(IntegrationExecutor $executor): void
- {
- $queue = JVB()->queue();
-
- $queue->registry()->register(self::$syncCustomer, new TypeConfig(
- executor: $executor,
- maxRetries: 2
- ));
-
- $queue->registry()->register(self::$import, new TypeConfig(
- executor: $executor,
- maxRetries: 3
- ));
- }
-
- /******************************************************************
- * POST SYNC METHODS
- ******************************************************************/
-
- /**
- * Handle post save for Square sync
- */
- protected function handleTheSavePost(int $postID, WP_Post $post, bool $update, array $settings): void
- {
- error_log('==== [Square]::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 = $this->getServiceItemID($postID);
-
- if (empty($item_id)) {
- return;
- }
- $this->queueOperation(self::$deleteFrom, [
- 'external_ids' => [$item_id],
- 'post_id' => $postID,
- ], [
- 'priority' => 'high',
- ]);
-
- }
-
- /**
- * Process sync to Square
- */
- private function processSyncToSquare(array $data): array
- {
- $items = $data['items'] ?? [];
- $success = [];
- $errors = [];
-
- if (empty($items)) {
- return [
- 'success' => true,
- 'result' => [
- 'synced' => [],
- 'errors' => ['No items to sync']
- ]
- ];
- }
-
- //Check images are already uploaded first
- $image_mappings = $this->batchUploadImages($items);
-
- $catalog = [];
- $map = [];
- foreach ($items as $postID) {
- $post = get_post($postID);
- if (!$post) {
- $errors[] = "Post $postID not found";
- continue;
- }
-
- $square_image_id = $image_mappings[$postID] ?? null;
- $catalog_object = $this->buildCatalogObject($postID, $square_image_id);
- if (is_wp_error($catalog_object)) {
- $errors[] = $catalog_object->get_error_message();
- continue;
- }
- $map[$catalog_object['id']] = $postID;
- $catalog[] = $catalog_object;
- }
-
- if (!empty($catalog)) {
- $response = $this->postRequest('catalog/batch-upsert', [
- 'idempotency_key' => wp_generate_uuid4(),
- 'batches' => [[
- 'objects' => $catalog
- ]]
- ]);
-
- if (!is_wp_error($response)) {
- $this->processBatchSyncResponse($response, $map, $success, $errors);
- } else {
- // Handle batch request failure
- $error_message = 'Batch sync failed';
- $this->logError($error_message, [
- 'method' => 'processSyncToSquare',
- 'post_ids' => $items,
- 'error' => $response
- ]);
-
- // Mark all items as failed
- foreach ($items as $postID) {
- if (get_post($postID)) { // Only if post exists
- $errors[] = "Failed to sync post $postID";
- update_post_meta($postID, BASE . '_square_sync_status', 'error');
- }
- }
- }
- }
-
- return [
- 'success' => count($success) > 0,
- 'result' => [
- 'synced' => $success,
- 'errors' => $errors
- ]
- ];
- }
-
- private function processBatchSyncResponse(array $response, array $map, array &$success, array &$errors):void
- {
- error_log('==== SQUARE::processBatchSyncResponse =====');
- error_log('Full response: '.print_r($response, true));
-
- // Handle successful objects
- if (!empty($response['objects'])) {
- foreach ($response['objects'] as $object) {
- $object_id = $object['id'];
- $post_id = $map[$object_id] ?? null;
-
- if (!$post_id) {
- // Try to find post ID from the object ID pattern
- if (preg_match('/#\w+_(\d+)/', $object_id, $matches)) {
- $post_id = (int)$matches[1];
- }
- }
-
- if ($post_id && get_post($post_id)) {
- // Update post meta with Square data
- update_post_meta($post_id, BASE . '_square_catalog_id', $object['id']);
- update_post_meta($post_id, BASE . '_square_sync_status', 'synced');
- update_post_meta($post_id, BASE . '_square_last_sync', current_time('mysql'));
-
- // Save variation IDs if present
- if (!empty($object['item_data']['variations'])) {
- foreach ($object['item_data']['variations'] as $index => $variation) {
- update_post_meta($post_id, BASE . '_square_variation_' . $index . '_id', $variation['id']);
- }
- }
-
- $success[] = $post_id;
- }
- }
- }
-
- // Handle errors
- if (!empty($response['errors'])) {
- foreach ($response['errors'] as $error) {
- $error_detail = $error['detail'] ?? 'Unknown error';
- $error_field = $error['field'] ?? '';
-
- // Try to extract post ID from error field if possible
- $post_id = null;
- if (preg_match('/batches\[0\]\.objects\[(\d+)\]/', $error_field, $matches)) {
- $object_index = (int)$matches[1];
- // Get post ID from the object at this index
- if (isset($catalog_objects[$object_index])) {
- $object_id = $catalog_objects[$object_index]['id'];
- $post_id = $map[$object_id] ?? null;
- }
- }
-
- $error_message = $post_id
- ? "Post $post_id: $error_detail"
- : "Batch error: $error_detail";
-
- $errors[] = $error_message;
-
- if ($post_id) {
- update_post_meta($post_id, BASE . '_square_sync_status', 'error');
- }
- }
- }
-
- // Handle any posts that weren't in the response (shouldn't happen but good to check)
- foreach ($map as $object_id => $post_id) {
- if (!in_array($post_id, $success)) {
- $found_in_errors = false;
- foreach ($errors as $error) {
- if (str_contains($error, "Post $post_id")) {
- $found_in_errors = true;
- break;
- }
- }
-
- if (!$found_in_errors) {
- $errors[] = "Post $post_id: No response from Square";
- update_post_meta($post_id, BASE . '_square_sync_status', 'error');
- }
- }
- }
- }
-
- /**
- * Build catalog object from WordPress post
- *
- * @param int $postID WordPress post ID
- * @param string|null $square_image_id Previously uploaded Square image ID
- * @return array|WP_Error Catalog object or error
- */
- //TODO: Get to work with Registrar settings
- protected function buildCatalogObject(int $postID, ?string $square_image_id = null): array|WP_Error
- {
- $post = get_post($postID);
- if (!$post) {
- return new WP_Error('post_not_found', "Post $postID not found");
- }
-
- $meta = Meta::forPost($postID);
- $post_type = get_post_type($postID);
-
- // Get existing Square catalog ID if it exists
- $existing_square_id = get_post_meta($postID, BASE . '_square_catalog_id', true);
- $registrar = Registrar::getInstance($post_type);
- $product_type = 'FOOD_AND_BEV';
- if ($registrar) {
- $conf = $registrar->getIntegration($this->service_name);
- if ($conf) {
- $product_type = $conf->getContentType();
- }
- }
-
- // Build the base catalog object
- $catalog_object = [
- 'type' => 'ITEM',
- 'id' => $existing_square_id ?: '#menu_item_item_' . $postID,
- 'item_data' => [
- 'name' => $post->post_title,
- 'description' => wp_strip_all_tags($post->post_content),
- 'product_type' => $product_type,
- 'variations' => [],
- 'is_taxable' => true,
- ]
- ];
-
- // Add image ID if provided (must be already uploaded to Square)
- if (!empty($square_image_id)) {
- $catalog_object['item_data']['image_ids'] = [$square_image_id];
- }
-
- // Add variations
- $variations = $meta->get('_square_product_variations');
- if (empty($variations)) {
- // Create default variation if none exist
- $catalog_object['item_data']['variations'][] = [
- 'type' => 'ITEM_VARIATION',
- 'id' => $existing_square_id ? null : '#'.BASE.'menu_item_' . $postID . '_var_default',
- 'item_variation_data' => [
- 'name' => 'Regular',
- 'ordinal' => 0,
- 'pricing_type' => 'FIXED_PRICING',
- 'price_money' => [
- 'amount' => $this->formatPrice($meta->get('_square_price')),
- 'currency' => $this->getCurrency()
- ],
- 'sellable' => true,
- 'stockable' => true
- ]
- ];
- } else {
- $resetVariations = false;
- foreach ($variations as $index => $variation) {
- $id = '#'.BASE.'menu_item_' . $postID . '_var_' . $index;
- if (empty($variation['item_id'])) {
- $resetVariations = true;
- $variations[$index]['item_id'] = $id;
- $variation['item_id'] = $id;
- }
- $catalog_object['item_data']['variations'][] = [
- 'type' => 'ITEM_VARIATION',
- 'id' => $variation['item_id'],
- 'item_variation_data' => [
- 'name' => $variation['name'] ?? 'Variation ' . ($index + 1),
- 'ordinal' => $index,
- 'pricing_type' => 'FIXED_PRICING',
- 'price_money' => [
- 'amount' =>$this->formatPrice($variation['price'] ?? 0),
- 'currency' => $this->getCurrency()
- ],
- 'sellable' => true,
- 'stockable' => true
- ]
- ];
- }
- if ($resetVariations) {
- $meta->set('_square_product_variations', $variations);
- }
- }
-
- // Add categories if they exist
- $categories = wp_get_post_terms($postID, $post_type . '_category', ['fields' => 'ids']);
- if (!empty($categories)) {
- $category_ids = [];
- foreach ($categories as $term_id) {
- $square_cat_id = get_term_meta($term_id, BASE . '_square_category_id', true);
- if ($square_cat_id) {
- $category_ids[] = $square_cat_id;
- }
- }
- if (!empty($category_ids)) {
- $catalog_object['item_data']['category_ids'] = $category_ids;
- }
- }
-
- // Add modifiers if they exist
- $modifiers = $meta->get('modifiers');
- if (!empty($modifiers)) {
- $modifier_ids = [];
- foreach ($modifiers as $modifier) {
- if (!empty($modifier['square_id'])) {
- $modifier_ids[] = $modifier['square_id'];
- }
- }
- if (!empty($modifier_ids)) {
- $catalog_object['item_data']['modifier_list_info'] = array_map(function($id) {
- return ['modifier_list_id' => $id];
- }, $modifier_ids);
- }
- }
-
- // Add tax settings
- $tax_ids = $meta->get('tax_ids');
- if (!empty($tax_ids)) {
- $catalog_object['item_data']['tax_ids'] = $tax_ids;
- }
-
- return $catalog_object;
- }
-
-
-
- /**
- * Get variation mapping for post type
- */
- protected function getVariationMapping(string $post_type): array
- {
- $registrar = Registrar::getInstance($post_type);
- if (!$registrar) {
- return [];
- }
- $config = $registrar->getIntegrationConfig($this->service_name);
- $product_type = $config['content_type']??'REGULAR';
- $valid_fields = $this->getValidFieldsForProductType($product_type);
-
- $defaults = [
- 'name' => 'name',
- 'id' => '_square_item_id',
- 'sku' => '_square_sku',
- 'price' => '_square_price',
- 'track_inventory' => '_square_track_inventory',
- 'service_duration' => '_square_service_duration',
- 'available_for_booking' => '_square_available_for_booking',
- 'gift_card_type' => '_square_gift_card_type',
- 'ingredients' => '_square_ingredients',
- 'preparation_time_duration' => '_square_preparation_time_duration'
- ];
-
- $extended = apply_filters(
- BASE . $this->service_name . '_variation_mapping',
- $defaults,
- $post_type,
- $this
- );
-
- return array_filter($extended, function($key) use ($valid_fields) {
- return in_array($key, $valid_fields);
- }, ARRAY_FILTER_USE_KEY);
- }
-
-
-
- /**
- * Get or create Square category
- */
- private function getOrCreateSquareCategory(string $name): ?string
- {
- // Check cached mapping
- $cached_id = get_option(BASE . 'square_category_' . sanitize_title($name));
- if ($cached_id) {
- return $cached_id;
- }
-
- // Create new category
- $response = $this->postRequest('catalog/batch-upsert', [
- 'idempotency_key' => wp_generate_uuid4(),
- 'batches' => [[
- 'objects' => [[
- 'type' => 'CATEGORY',
- 'id' => '#category_' . sanitize_title($name),
- 'category_data' => [
- 'name' => $name
- ]
- ]]
- ]]
- ]);
-
- if (!is_wp_error($response) && !empty($response['objects'][0]['id'])) {
- $category_id = $response['objects'][0]['id'];
- update_option(BASE . 'square_category_' . sanitize_title($name), $category_id);
- return $category_id;
- }
-
- return null;
- }
-
- /**
- * Get field mapping for post type
- */
- protected function getFieldMapping(string $post_type): array
- {
- $registrar = Registrar::getInstance($post_type);
- if (!$registrar) {
- return [];
- }
- $config = $registrar->getIntegrationConfig($this->service_name);
- $product_type = $config['content_type']??'REGULAR';
- $valid_fields = $this->getValidFieldsForProductType($product_type);
-
- $defaults = [
- 'name' => 'post_title',
- 'description_html' => 'post_content',
- 'abbreviation' => 'abbreviation',
- 'id' => '_square_catalog_id',
- 'sku' => '_square_sku',
- 'category_id' => 'category',
- 'image_ids' => 'post_thumbnail',
- 'price' => '_square_price',
- 'tax_ids' => 'tax_ids',
- // Availability
- 'available_online' => '_square_available_online',
- 'available_for_pickup' => '_square_available_for_pickup',
- 'available_electronically' => '_square_available_electronically',
- // Modifiers
- 'modifier_list_info' => 'modifiers',
- // Item options
- 'item_options' => 'options',
- // Reporting
- 'reporting_category' => 'reporting_category',
- ];
-
- $extended = apply_filters(
- BASE . $this->service_name . '_field_mapping',
- $defaults,
- $post_type,
- $this
- );
-
- return array_filter($extended, function($key) use ($valid_fields) {
- return in_array($key, $valid_fields);
- }, ARRAY_FILTER_USE_KEY);
- }
-
- /**
- * Get valid fields for Square product type
- */
- //TODO: This feels redundant now, with how we've defined fields in getAdditionalFields
- private function getValidFieldsForProductType(string $product_type): array
- {
- $fields = ['name', 'description_html', 'sku', 'price', 'image_ids', 'category_id'];
-
- $type_specific = [
- 'FOOD_AND_BEV' => [
- 'ingredients',
- 'preparation_time_duration',
- 'dietary_preferences',
- 'calories_text'
- ],
- 'APPOINTMENTS_SERVICE' => [
- 'service_duration',
- 'available_for_booking',
- 'team_member_ids',
- 'booking_availability'
- ],
- 'EVENT' => [
- 'start_date',
- 'end_date',
- 'venue_details',
- 'capacity'
- ],
- 'GIFT_CARD' => [
- 'gift_card_type',
- 'allowed_locations'
- ]
- ];
-
- if (isset($type_specific[$product_type])) {
- $fields = array_merge($fields, $type_specific[$product_type]);
- }
-
- return $fields;
- }
-
- /******************************************************************
- * CUSTOMER MANAGEMENT
- ******************************************************************/
-
- /**
- * Handle customer authentication during checkout
- */
- //TODO: Is this necessary?
- public function handleCustomerAuth($data):WP_Error|array
- {
- $email = sanitize_email($data['email'] ?? '');
- $action = sanitize_text_field($data['action_type'] ?? 'check');
-
- if (!$email) {
- return new WP_Error('error', 'Email required');
- }
-
- switch ($action) {
- case 'check':
- return $this->checkCustomerExists($email);
- case 'create':
- return $this->createCustomerAccount($email);
- default:
- $this->logError('No action set for customer auth: '.$action, [
- 'method' => 'handleCustomerAuth'
- ]);
- return new WP_Error('error', 'No action set for customer auth');
- }
-
- }
-
-
-
- /**
- * Check if customer exists
- */
- private function checkCustomerExists(string $email):WP_Error|array
- {
- // Check WordPress user
- $user = get_user_by('email', $email);
-
- if ($user) {
- // Check if user has Square customer integration
- $square_customer_id = get_user_meta($user->ID, BASE . '_square_customer_id', true);
-
- if ($square_customer_id) {
- return [
- 'exists' => true,
- 'has_account' => true,
- 'message' => 'Account found. Please enter your password to continue'
- ];
- } else {
- return [
- 'exists' => true,
- 'has_account' => false,
- 'message' => 'Email found. Would you like to create an account to save your order history?'
- ];
- }
- }
-
- // Check Square for customer
- $response = $this->postRequest('customers/search', [
- 'filter' => [
- 'email_address' => [
- 'exact' => $email
- ]
- ]
- ]);
-
- if (!is_wp_error($response) && !empty($response['customers'])) {
- return [
- 'exists' => true,
- 'has_account' => false,
- 'square_only' => true,
- 'message' => 'Found your previous orders. Create an account to access them?'
- ];
- } else {
- return [
- 'exists' => false,
- 'message' => 'New customer'
- ];
- }
- }
-
- private function createCustomerAccount(string $email):WP_Error|array
- {
- // Validate email format
- if (!is_email($email)) {
- return new WP_Error('error', 'Invalid email address');
- }
-
- // Check if user already exists
- if (email_exists($email)) {
- return [
- 'success' => false,
- 'exists' => true,
- 'message' => 'An account with this email already exists. Please log in instead.'
- ];
- }
-
- // Create user account without password (they'll set it via email)
- $user_id = wp_create_user(
- $email,
- wp_generate_password(20, true, true), // Temporary random password
- $email
- );
-
- if (is_wp_error($user_id)) {
- $this->logError('Failed to create customer account', [
- 'email' => $email,
- 'error' => $user_id->get_error_message()
- ]);
- return new WP_Error('error', 'Failed to create account. Please try again.');
- }
-
- // Set user role (assuming you have a customer role defined)
- $user = new WP_User($user_id);
- $user->set_role(BASE.'foodie');
-
- // Generate password reset key
- $reset_key = get_password_reset_key($user);
-
- if (is_wp_error($reset_key)) {
- return new WP_Error('error', 'Account created, but couldn\'t send email. Please use password reset.');
- }
-
-
- // Send welcome email with password setup link
- $this->sendWelcomeEmail($user, $reset_key);
-
- // Link to Square customer if exists
- $square_customer_id = $this->getOrCreateSquareCustomer([
- 'email' => $email,
- 'name' => $email
- ]);
-
- if ($square_customer_id) {
- update_user_meta($user_id, BASE . '_square_customer_id', $square_customer_id);
- }
-
- return [
- 'success' => true,
- 'message' => 'Account created! Check your email to set your password.',
- 'user_id' => $user_id
- ];
- }
-
- /**
- * Send welcome email with password setup
- */
- private function sendWelcomeEmail(WP_User $user, string $reset_key): void
- {
- $site_name = get_bloginfo('name');
- $reset_url = get_home_url(null, "wp-login.php?action=rp&key=$reset_key&login=" . rawurlencode($user->user_login), 'login');
-
- $message = sprintf(
- "Welcome to %s!\n\n" .
- "Your account has been created. Please click the button below to set your password:\n\n" .
- "%s\n\n" .
- "Or, copy and paste the link below:\n\n".
- "%s\n\n" .
- "Once you've set your password, you can:\n" .
- "- View your order history\n" .
-// "- Save your favorite items\n" .
- "- Speed up checkout with saved payment methods\n\n" .
- "If you didn't create this account, please ignore this email.\n\n" .
- "Thanks,\n",
- $site_name,
- JVB()->email()->button('Reset Password', $reset_url),
- JVB()->email()->link($reset_url),
- );
-
- JVB()->email()->sendEmail(
- $user->user_email,
- sprintf('[%s] Welcome! Set Your Password', $site_name),
- $message
- );
- }
-
- /******************************************************************
- * WEBHOOK HANDLING
- ******************************************************************/
-
- /**
- * Validate webhook signature
- */
- protected function validateWebhook(array $payload): bool
- {
- // Get signature from headers
- $signature = $_SERVER['HTTP_X_SQUARE_SIGNATURE'] ?? '';
-
- // If no signature provided by Square, validation fails
- if (empty($signature)) {
- $this->logDebug('No webhook signature provided by Square');
- return false;
- }
-
- // If webhook signature key is not configured, we can't validate
- // but we might want to process the webhook anyway (less secure)
- if (empty($this->webhook_signature_key)) {
- $this->logDebug('Webhook signature key not configured - processing without validation');
- // You might want to return false here for stricter security
- // return false;
- return true; // Process webhook but log that it's unverified
- }
-
- // Get the raw request body
- $body = file_get_contents('php://input');
-
- // Calculate expected signature
- $expected = base64_encode(hash_hmac('sha256', $body, $this->webhook_signature_key, true));
-
- // Use timing-safe comparison
- $is_valid = hash_equals($expected, $signature);
-
- if (!$is_valid) {
- $this->logError('Invalid webhook signature', [
- 'provided' => substr($signature, 0, 10) . '...',
- 'expected' => substr($expected, 0, 10) . '...'
- ]);
- }
-
- return $is_valid;
- }
-
- /**
- * Process webhook event
- */
- protected function processWebhook(array $payload): bool
- {
- $event_type = $payload['type'] ?? '';
- $data = $payload['data'] ?? [];
-
- switch ($event_type) {
- case 'payment.created':
- case 'payment.updated':
- return $this->handlePaymentWebhook($data);
-
- case 'order.created':
- case 'order.updated':
- case 'order.fulfillment.updated':
- return $this->handleOrderWebhook($data);
-
- case 'catalog.version.updated':
- return $this->handleCatalogWebhook($data);
-
- case 'customer.created':
- case 'customer.updated':
- return $this->handleCustomerWebhook($data);
-
- default:
- $this->logDebug('Unhandled webhook type', ['type' => $event_type]);
- return true;
- }
- }
-
- /**
- * Handle order status webhook
- */
- private function handleOrderWebhook(array $data): bool
- {
- $order_id = $data['object']['order']['id'] ?? '';
- $state = $data['object']['order']['state'] ?? '';
- $fulfillments = $data['object']['order']['fulfillments'] ?? [];
-
- if (!$order_id) {
- return false;
- }
-
- // Find the WP post for this order
- $wp_order_id = $this->getOrderPost($order_id);
-
- if ($wp_order_id) {
- // Update the post meta
- $meta = Meta::forPost($wp_order_id);
- $updates = [
- 'status' => $state,
- 'updated_at' => current_time('mysql')
- ];
-
- // Extract fulfillment status and pickup time
- if (!empty($fulfillments[0])) {
- $fulfillment = $fulfillments[0];
- $updates['fulfillment_status'] = $fulfillment['state'] ?? $state;
-
- if (!empty($fulfillment['pickup_details']['pickup_at'])) {
- $updates['pickup_time'] = $fulfillment['pickup_details']['pickup_at'];
- }
- }
-
- $meta->setAll($updates);
-
- // Trigger notification to customer if order is ready
- if ($state === 'PREPARED') {
- do_action(BASE . 'square_order_ready', $wp_order_id, $order_id);
- }
- }
- // Trigger action for other integrations
- do_action(BASE . 'square_order_updated', $order_id, $state, $data);
-
- return true;
- }
-
- /******************************************************************
- * CONNECTION TESTING
- ******************************************************************/
- protected function performConnectionTest(): bool
- {
- try {
- $response = $this->getRequest('/v2/merchants/me');
- if (is_wp_error($response)) {
- $this->logError('Connection test failed', ['error' => $response->get_error_message()]);
- return false;
- }
-
- // Check if we got valid merchant data
- return !empty($response['merchant']);
- } catch (Exception $e) {
- $this->logError('Connection test failed', ['error' => $e->getMessage()]);
- return false;
- }
- }
-
- /**
- * Check OAuth connection status
- */
- protected function checkOAuthStatus(): array
- {
- if (!$this->hasOAuthCredentials()) {
- return [
- 'success' => false,
- 'message' => 'OAuth not configured. Please authorize with Square.',
- 'authorized' => false
- ];
- }
-
- // Test the connection
- if ($this->performConnectionTest()) {
- $merchant = $this->getRequest('merchants/me');
- return [
- 'success' => true,
- 'message' => 'Successfully connected to Square',
- 'authorized' => true,
- 'merchant' => $merchant['merchant'] ?? []
- ];
- }
-
- return [
- 'success' => false,
- 'message' => 'OAuth token may be expired. Please re-authorize.',
- 'authorized' => false
- ];
- }
-
- /******************************************************************
- * ADMIN UI
- ******************************************************************/
- /**
- * Enqueue checkout scripts with Square configuration
- */
- public function enqueueScripts(): void
- {
- jvbInlineStyles('forms');
- $this->loadCredentials();
- $sdk_url = $this->environment === 'production'
- ? 'https://web.squarecdn.com/v1/square.js'
- : 'https://sandbox.web.squarecdn.com/v1/square.js';
-
- wp_enqueue_script(
- 'square-payments-sdk',
- $sdk_url,
- [],
- null,
- ['strategy' => 'defer', 'in_footer' => true]
- );
-
- // Shared cart checkout base class
- 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', 'jvb-login'],
- '1.1.32',
- ['strategy' => 'defer', 'in_footer' => true]
- );
-
- // Square checkout extends CartCheckout
- wp_register_script(
- 'jvb-square-checkout',
- JVB_URL . 'assets/js/min/square.min.js',
- ['jvb-checkout', 'square-payments-sdk'],
- '1.1.32',
- ['strategy' => 'defer', 'in_footer' => true]
- );
-
- wp_enqueue_script('jvb-square-checkout');
-
- wp_localize_script('jvb-square-checkout', 'squareConfig', [
- 'isOpen' => jvbIsOpen()?'1':'0',
- 'application_id' => $this->credentials['client_id'] ?? '',
- 'location_id' => $this->locationId,
- 'environment' => $this->environment,
- 'currency' => $this->getCurrency(),
- 'is_logged_in' => is_user_logged_in(),
- 'user_email' => is_user_logged_in() ? wp_get_current_user()->user_email : '',
- ]);
- }
-
- /******************************************************************
- * HELPER METHODS
- ******************************************************************/
-
- /**
- * Get or create Square customer
- */
- public function getOrCreateSquareCustomer(array $customer_info): ?string
- {
- if (empty($customer_info['email'])) {
- return null;
- }
-
- // Search for existing customer
- $search_response = $this->postRequest('customers/search', [
- 'filter' => [
- 'email_address' => [
- 'exact' => $customer_info['email']
- ]
- ]
- ]);
-
- if (!is_wp_error($search_response) && !empty($search_response['customers'])) {
- return $search_response['customers'][0]['id'];
- }
-
- // Create new customer
- $create_response = $this->postRequest('customers', [
- 'given_name' => $customer_info['name'] ?? '',
- 'email_address' => $customer_info['email'],
- 'phone_number' => $customer_info['phone'] ?? ''
- ]);
-
- if (!is_wp_error($create_response) && !empty($create_response['customer']['id'])) {
- return $create_response['customer']['id'];
- }
-
- return null;
- }
-
-
- /**
- * Process delete from Square
- */
- private function processDeleteFromSquare(array $data): array
- {
- $square_ids = $data['square_ids'] ?? [];
-
- if (empty($square_ids)) {
- return [
- 'success' => false,
- 'result' => ['error' => 'No Square IDs provided']
- ];
- }
-
- $response = $this->postRequest('catalog/batch-delete', [
- 'object_ids' => $square_ids
- ]);
-
- if (is_wp_error($response)) {
- return [
- 'success' => false,
- 'result' => ['error' => $response->get_error_message()]
- ];
- }
-
- // Clean up post meta
- if (!empty($data['post_id'])) {
- delete_post_meta($data['post_id'], BASE . '_square_catalog_id');
- delete_post_meta($data['post_id'], BASE . '_square_sync_status');
- }
-
- return [
- 'success' => true,
- 'result' => ['deleted' => $square_ids]
- ];
- }
-
- /**
- * Process sync from Square
- */
- private function processSyncFromSquare(array $data): array
- {
- $cursor = $data['cursor'] ?? null;
- $types = $data['types'] ?? ['ITEM'];
-
- $request_data = [
- 'types' => implode(',', $types),
- 'limit' => 100
- ];
-
- if ($cursor) {
- $request_data['cursor'] = $cursor;
- }
-
- $response = $this->getRequest('catalog/list?' . http_build_query($request_data));
-
- if (is_wp_error($response)) {
- return [
- 'success' => false,
- 'result' => ['error' => $response->get_error_message()]
- ];
- }
-
- $imported = 0;
- $errors = [];
-
- foreach ($response['objects'] ?? [] as $object) {
- if ($object['type'] === 'ITEM') {
- $result = $this->importSquareItem($object);
- if ($result) {
- $imported++;
- } else {
- $errors[] = $object['id'];
- }
- }
- }
-
- // Queue next batch if cursor exists
- if (!empty($response['cursor'])) {
- $this->queueOperation('sync_from_square', [
- 'cursor' => $response['cursor'],
- 'types' => $types
- ]);
- }
-
- return [
- 'success' => true,
- 'result' => [
- 'imported' => $imported,
- 'errors' => $errors,
- 'has_more' => !empty($response['cursor'])
- ]
- ];
- }
-
- /**
- * Import Square item to WordPress
- */
- private function importSquareItem(array $item): bool|int
- {
- //TODO: We need to add the post type to custom meta for Square, this is not good if we have multiple post types with the same product type
- // Find matching content type
- $product_type = $item['item_data']['product_type'] ?? 'REGULAR';
- $post_type = null;
-
- foreach (Registrar::getRegistered() as $registrar) {
- if (!$registrar->hasIntegration($this->service_name)) {
- continue;
- }
- $config = $registrar->getIntegration($this->service_name);
- if ($config->getContent_type() && $config->getContent_type() === $product_type) {
- $post_type = jvbCheckBase($registrar->getSlug());
- break;
- }
-
- }
-
- if (!$post_type) {
- return false;
- }
-
- // Check if item already exists
- $existing = get_posts([
- 'post_type' => $post_type,
- 'meta_key' => BASE . '_square_catalog_id',
- 'meta_value' => $item['id'],
- 'posts_per_page' => 1
- ]);
-
- $post_data = [
- 'post_title' => $item['item_data']['name'] ?? '',
- 'post_content' => $item['item_data']['description'] ?? '',
- 'post_type' => $post_type,
- 'post_status' => 'publish'
- ];
-
- if (!empty($existing)) {
- $post_data['ID'] = $existing[0]->ID;
- $post_id = wp_update_post($post_data);
- } else {
- $post_id = wp_insert_post($post_data);
- }
-
- if (!$post_id || is_wp_error($post_id)) {
- return false;
- }
-
- // Map and save meta fields
- $this->mapSquareFieldsToWordPress($post_id, $item);
-
- return $post_id;
- }
-
- /**
- * Map Square fields to WordPress meta
- */
- private function mapSquareFieldsToWordPress(int $post_id, array $item): void
- {
- $meta = Meta::forPost($post_id);
- $field_map = $this->getFieldMapping(get_post_type($post_id));
-
- $values_to_save = [];
-
- // Save Square ID
- $values_to_save['_square_catalog_id'] = $item['id'];
- $values_to_save['_square_last_sync'] = current_time('mysql');
-
- // Handle variations if present
- if (!empty($item['item_data']['variations'])) {
- $variations_data = [];
-
- foreach ($item['item_data']['variations'] as $index => $variation) {
- $var_data = [
- 'name' => $variation['item_variation_data']['name'] ?? '',
- 'sku' => $variation['item_variation_data']['_square_sku'] ?? '',
- ];
-
- // Extract price
- if (!empty($variation['item_variation_data']['price_money'])) {
- $var_data['price'] = $variation['item_variation_data']['price_money']['amount'] / 100;
- }
-
- // Extract other variation fields
- $variation_map = $this->getVariationMapping(get_post_type($post_id));
- foreach ($variation_map as $square_field => $wp_field) {
- if (isset($variation['item_variation_data'][$square_field])) {
- $var_data[$wp_field] = $variation['item_variation_data'][$square_field];
- }
- }
-
- $variations_data[] = $var_data;
-
- // Save variation Square ID
- update_post_meta($post_id, BASE . '_square_variation_' . $index . '_id', $variation['id']);
- }
-
- $values_to_save['product_variations'] = $variations_data;
- }
-
- // Map other fields
- foreach ($field_map as $square_field => $wp_field) {
- if ($square_field !== 'price' && isset($item['item_data'][$square_field])) {
- $values_to_save[$wp_field] = $item['item_data'][$square_field];
- }
- }
-
- // Save all values at once
- $meta->setAll($values_to_save);
- }
-
- /**
- * Process customer sync
- */
- private function processSyncCustomer(array $data): array
- {
- $user_id = $data['user_id'] ?? 0;
-
- if (!$user_id) {
- return [
- 'success' => false,
- 'result' => ['error' => 'No user ID provided']
- ];
- }
-
- $user = get_user_by('ID', $user_id);
- if (!$user) {
- return [
- 'success' => false,
- 'result' => ['error' => 'User not found']
- ];
- }
-
- // Get or create Square customer
- $square_customer_id = $this->getOrCreateSquareCustomer([
- 'email' => $user->user_email,
- 'name' => $user->display_name
- ]);
-
- if ($square_customer_id) {
- update_user_meta($user_id, BASE . '_square_customer_id', $square_customer_id);
-
- return [
- 'success' => true,
- 'result' => ['customer_id' => $square_customer_id]
- ];
- }
-
- return [
- 'success' => false,
- 'result' => ['error' => 'Could not sync customer']
- ];
- }
-
- /**
- * Handle catalog webhook
- */
- private function handleCatalogWebhook(array $data): bool
- {
- // Queue sync from Square for updated items
- $this->queueOperation('import_catalog', [
- 'types' => ['ITEM'],
- 'updated_at' => $data['object']['catalog_version']['updated_at'] ?? null
- ], [
- 'delay' => 60 // Wait a minute to batch multiple updates
- ]);
-
- return true;
- }
-
- /**
- * Handle customer webhook
- */
- private function handleCustomerWebhook(array $data): bool
- {
- $square_customer_id = $data['object']['customer']['id'] ?? '';
- $email = $data['object']['customer']['email_address'] ?? '';
-
- if (!$square_customer_id || !$email) {
- return false;
- }
-
- // Find WordPress user with this Square customer ID
- $users = get_users([
- 'meta_key' => BASE . '_square_customer_id',
- 'meta_value' => $square_customer_id,
- 'number' => 1
- ]);
-
- if (!empty($users)) {
- // Update user meta with latest Square data
- $user = $users[0];
- update_user_meta($user->ID, BASE . '_square_customer_updated', current_time('mysql'));
-
- // Clear cached customer data
- $this->cache->forget('square_customer_' . $user->ID);
- }
-
- return true;
- }
-
- /**
- * Handle payment webhook
- */
- private function handlePaymentWebhook(array $data): bool
- {
- $payment_id = $data['object']['payment']['id'] ?? '';
- $order_id = $data['object']['payment']['order_id'] ?? '';
- $status = $data['object']['payment']['status'] ?? '';
-
- if (!$payment_id) {
- return false;
- }
-
- if ($order_id) {
- $order = $this->getOrderPost($order_id);
- if ($order) {
- Meta::forPost($order)->set('square_payment_status', $status);
- }
- }
-
- // Trigger action for other integrations
- do_action(BASE . 'square_payment_updated', $payment_id, $status, $order_id, $data);
-
- return true;
- }
-
- /**
- * Get the appropriate API base URL
- */
- protected function getApiUrl(string $endpoint, ?string $baseKey = null): string
- {
- return rtrim($this->apiBase[$this->environment], '/') . '/' . ltrim($endpoint, '/');
- }
-
- /**
- * Handle importing catalog from Square
- */
- protected function handleImportFromSquare(): array
- {
- if (!$this->locationId) {
- return [
- 'success' => false,
- 'message' => 'Please select a location first'
- ];
- }
-
- $this->queueOperation('import_catalog', [
- 'types' => ['ITEM'],
- 'user_id' => $this->userID
- ], [
- 'priority' => 'normal'
- ]);
-
- return [
- 'success' => true,
- 'message' => 'Import from Square queued'
- ];
- }
- /**
- * Handle syncing to Square
- */
- protected function handleSyncToSquare(): array
- {
- if (!$this->locationId) {
- return [
- 'success' => false,
- 'message' => 'Please select a location first'
- ];
- }
-
- $post_types = array_map(function($type) {
- return jvbCheckBase($type);
- }, $this->syncPostTypes);
-
- // Get all posts to sync
- $posts = get_posts([
- 'post_type' => $post_types,
- 'posts_per_page' => -1,
- 'post_status' => 'publish'
- ]);
-
- $post_ids = wp_list_pluck($posts, 'ID');
-
- // Queue sync operation
- $this->queueOperation('sync_to_square', [
- 'items' => $post_ids,
- 'user_id' => $this->userID
- ], [
- 'priority' => 'normal'
- ]);
-
- return [
- 'success' => true,
- 'message' => sprintf('Queued %d items for sync to Square', count($post_ids))
- ];
- }
- /**
- * Refresh category mappings from Square
- */
- protected function handleRefreshCategories(): array
- {
- // Refresh category mappings from Square
- $response = $this->getRequest('catalog/list?types=CATEGORY');
-
- if (!is_wp_error($response) && isset($response['objects'])) {
- $count = 0;
- foreach ($response['objects'] as $category) {
- if ($category['type'] === 'CATEGORY') {
- $name = $category['category_data']['name'] ?? '';
- if ($name) {
- update_option(
- BASE . 'square_category_' . sanitize_title($name),
- $category['id']
- );
- $count++;
- }
- }
- }
- return [
- 'success' => true,
- 'message' => sprintf('Refreshed %d categories', $count),
- 'count' => $count
- ];
- } else {
- return [
- 'success' => false,
- 'message' => 'Failed to refresh categories'
- ];
- }
- }
- /**
- * Get available Square locations
- */
- protected function handleGetLocations(): array
- {
- // Make sure we have an access token
- if (empty($this->access_token)) {
- return [
- 'success' => false,
- 'message' => 'Access token required to fetch locations'
- ];
- }
-
- // Fetch available Square locations
- $response = $this->getRequest('locations');
-
- if (!is_wp_error($response) && isset($response['locations'])) {
- $locations = array_map(function($loc) {
- return [
- 'id' => $loc['id'],
- 'name' => $loc['name'] ?? 'Unnamed Location',
- 'address' => $loc['address']['address_line_1'] ?? '',
- 'status' => $loc['status'] ?? 'ACTIVE'
- ];
- }, $response['locations']);
-
- // Filter to only active locations
- $active_locations = array_filter($locations, function($loc) {
- return $loc['status'] === 'ACTIVE';
- });
-
- // Format for select field
- if (isset($_REQUEST['for_select'])) {
- $options = [];
- foreach ($active_locations as $location) {
- $label = $location['name'];
- if ($location['address']) {
- $label .= ' - ' . $location['address'];
- }
- $options[$location['id']] = $label;
- }
-
- return [
- 'success' => true,
- 'options' => $options
- ];
- }
-
- return [
- 'success' => true,
- 'locations' => array_values($active_locations)
- ];
- }
-
- return [
- 'success' => false,
- 'message' => 'Failed to fetch locations. Please check your access token and environment settings.'
- ];
- }
-
- protected function validateCredentials(array $credentials): bool
- {
- // For OAuth services, we need different validation based on setup stage
- if ($this->isOAuthService) {
- // Stage 1: Initial setup (need app credentials)
- $hasAppCredentials = !empty($credentials['client_id'])
- && !empty($credentials['client_secret']);
-
- if (!$hasAppCredentials) {
- $this->logError('Missing required Square application credentials', [
- 'has_app_id' => !empty($credentials['client_id']),
- 'has_app_secret' => !empty($credentials['client_secret'])
- ]);
- return false;
- }
-
- // Stage 2: After OAuth (should have access token)
- // Access token might not exist yet if we're just saving app credentials
- // before OAuth flow, so we only validate format if it exists
- if (!empty($credentials['access_token'])) {
- // Validate token format (Square tokens are long alphanumeric strings)
- if (!is_string($credentials['access_token']) ||
- strlen($credentials['access_token']) < 20) {
- $this->logError('Invalid access token format');
- return false;
- }
-
- // Validate merchant_id if present (should be set after OAuth)
- if (!empty($credentials['merchant_id'])) {
- if (!is_string($credentials['merchant_id']) ||
- strlen($credentials['merchant_id']) < 10) {
- $this->logError('Invalid merchant ID format');
- return false;
- }
- }
- }
-
- // Validate environment setting
- if (isset($credentials['environment'])) {
- $validEnvironments = ['sandbox', 'production'];
- if (!in_array($credentials['environment'], $validEnvironments)) {
- $this->logError('Invalid environment setting', [
- 'provided' => $credentials['environment'],
- 'valid' => $validEnvironments
- ]);
- return false;
- }
- }
-
- return true;
- }
-
- // Fallback for non-OAuth (shouldn't happen for Square, but good practice)
- return !empty($credentials);
- }
-
-
- protected function sanitizeCredentials(array $credentials): array
- {
- $sanitized = [];
-
- // Define which fields should be sanitized and how
- $text_fields = [
- 'client_id',
- 'client_secret',
- 'access_token',
- 'refresh_token',
- 'merchant_id',
- 'location_id',
- 'webhook_signature_key'
- ];
-
- foreach ($credentials as $key => $value) {
- // Handle text fields - trim whitespace but preserve case
- if (in_array($key, $text_fields)) {
- if (is_string($value)) {
- $sanitized[$key] = trim($value);
- } else {
- $sanitized[$key] = $value;
- }
- }
- // Handle environment field
- elseif ($key === 'environment') {
- $sanitized[$key] = sanitize_key($value);
- }
- // Handle boolean fields
- elseif ($key === 'use_sandbox' || str_starts_with($key, 'enable_')) {
- $sanitized[$key] = (bool) $value;
- }
- // Handle numeric fields
- elseif ($key === 'expires_at' || $key === 'updated_at' || $key === 'expires_in') {
- $sanitized[$key] = is_numeric($value) ? (int) $value : $value;
- }
- // Handle arrays (like locations)
- elseif (is_array($value)) {
- $sanitized[$key] = array_map('sanitize_text_field', $value);
- }
- // Default sanitization for other string fields
- else {
- if (is_string($value)) {
- $sanitized[$key] = sanitize_text_field($value);
- } else {
- $sanitized[$key] = $value;
- }
- }
- }
-
- return $sanitized;
- }
-
- public function getServiceDescription(): string
- {
- return "Connect with Square for payment processing, inventory management, and customer synchronization.";
- }
-
- public function setContentTypes(): void
- {
- $this->has_content = true;
- $this->defaultContent = 'REGULAR';
- $base = $this->setBaseFields();
- $this->contentTypes = [
- 'REGULAR' => $base,
- 'FOOD_AND_BEV' => array_merge($base, $this->setFoodAndBevFields()),
- 'APPOINTMENTS_SERVICE' => array_merge($this->setAppointmentServiceFields()),
- 'EVENT' => array_merge($this->setEventFields()),
- 'GIFT_CARD' => array_merge($this->setGiftCardFields())
- ];
- }
- public function getAdditionalFields(?string $content_type = null):array {
- if ($content_type === 'customer') {
- return $this->getCustomerFields();
- }
- if ($content_type && array_key_exists($content_type, $this->contentTypes)){
- $array = $this->contentTypes[$content_type];
- return array_combine(
- array_map(fn($k) => '_square_' . $k, array_keys($array)),
- $array
- );
- } else if ($content_type && !array_key_exists($content_type, $this->contentTypes)) {
- error_log('Could not get default fields for '.$this->service_name.' content type: '.$content_type);
- return [];
- }
- $array = $this->setBaseFields();
- return array_combine(
- array_map(fn($k) => '_square_' . $k, array_keys($array)),
- $array
- );
- }
-
- protected function getCustomerFields():array
- {
- return [
- 'customer_id' => [
- 'type' => 'text',
- 'label' => 'Square Customer ID',
- 'hidden'=> true,
- 'section'=> 'your-account'
- ],
- 'address_line_1' => [
- 'type' => 'text',
- 'label' => 'Address Line 1',
- 'hint' => 'ex: 6551 111 St NW',
- 'required' => true,
- 'section' => 'address'
- ],
- 'address_line_2' => [
- 'type' => 'text',
- 'label' => 'Address Line 2',
- 'hint' => 'ex: Unit 2',
- 'section' => 'address'
- ],
- 'city' => [
- 'type' => 'text',
- 'label'=> 'City',
- 'section' => 'address',
- 'required' => true,
- ],
- 'state' => [
- 'type' => 'text',
- 'label' => 'Province',
- 'hint' => 'The two-character code, example: AB',
- 'default'=> 'AB',
- 'section' => 'address',
- 'required' => true,
- ],
- 'zip_code' => [
- 'type' => 'text',
- 'label' => 'Postal Code',
- 'section'=> 'address'
- ],
- 'countryCode' => [
- 'type' => 'text',
- 'label' => 'Country Code',
- 'hint' => 'The tw-character country code, example: CA',
- 'default' => 'CA',
- 'section' => 'address',
- 'required' => true,
- ]
- ];
- }
- protected function setBaseFields():array
- {
- return [
- 'price' => [
- 'type' => 'number',
- 'label' => 'Price',
- 'step' => 0.01,
- 'max' => 99999,
- 'description' => 'Price for this variation'
- ],
- 'sku' => [
- 'type' => 'text',
- 'label' => 'SKU',
- 'description' => 'Stock keeping unit'
- ],
- 'cart_quantity' => [
- 'type' => 'number',
- 'label' => 'Quantity',
- 'hidden' => true,
- ],
- 'available_for_pickup' => [
- 'type' => 'true_false',
- 'label' => 'Available for Pick Up',
- 'section'=> 'square-advanced'
- ],
- 'available_online' => [
- 'type' => 'true_false',
- 'label' => 'Available online',
- 'section'=> 'square-advanced'
- ],
- 'product_variations' => [
- 'type' => 'repeater',
- 'label' => 'Product Variations',
- 'description' => 'Different versions of this product (sizes, colors, etc.)',
- 'add_label' => 'Add Variation',
- 'section' => 'variations',
- 'fields' => $this->setVariationFields()
- ],
- ];
- }
-
- protected function setVariationFields(?string $type = null):array
- {
- $fields = [
- 'name' => [
- 'type' => 'text',
- 'label' => 'Variation Name',
- 'description' => 'e.g., "Small", "Large", "Red", etc.'
- ],
- 'sku' => [
- 'type' => 'text',
- 'label' => 'SKU',
- 'description' => 'Stock keeping unit'
- ],
- 'price' => [
- 'type' => 'number',
- 'label' => 'Price',
- 'step' => 0.01,
- 'max' => 99999,
- 'description' => 'Price for this variation'
- ],
- 'track_inventory' => [
- 'type' => 'true_false',
- 'label' => 'Track Inventory',
- ],
- 'item_id' => [
- 'type' => 'text',
- 'label' => 'Square Variation ID',
- 'description' => 'Square catalog ID for this variation',
- 'hidden' => true
- ],
- 'last_sync' => [
- 'type' => 'datetime',
- 'label' => 'Last Sync',
- 'hidden' => true
- ]
- ];
-
- switch ($type) {
- case 'FOOD_AND_BEV':
- $fields['ingredients'] = [
- 'type' => 'textarea',
- 'label' => 'Ingredients List',
- 'description' => 'Separate ingredients with commas',
- ];
- $fields['preparation_time_duration'] = [
- 'type' => 'number',
- 'label' => 'Preparation time (in minutes)',
- ];
- break;
- case 'GIFT_CARD':
- $fields['gift_card_type'] = [
- 'type' => 'select',
- 'label' => 'Gift Card Type',
- 'options' => [
- 'PHYSICAL' => 'Physical',
- 'DIGITAL' => 'Digital',
- ],
- 'default' => 'DIGITAL',
- ];
- break;
- case 'APPOINTMENTS_SERVICE':
- $fields['service_duration'] = [
- 'type' => 'number',
- 'label' => 'Duration of Service in Minutes'
- ];
- $fields['available_for_booking'] = [
- 'type' => 'true_false',
- 'label' => 'Available for Booking'
- ];
- break;
- }
- return array_combine(
- array_map(fn($k) => '_square_' . $k, array_keys($fields)),
- $fields
- );
- }
- protected function setFoodAndBevFields():array
- {
- return [
- 'ingredients' => [
- 'type' => 'textarea',
- 'label' => 'Ingredients',
- 'hint' => 'A comma separated list of ingredients'
- ],
- 'preparation_time_duration' => [
- 'label' => 'Preparation Time',
- 'type' => 'number',
- 'hint' => 'Preparation time in minutes'
- ],
- 'product_variations' => [
- 'type' => 'repeater',
- 'label' => 'Product Variations',
- 'description' => 'Different versions of this product (sizes, colors, etc.)',
- 'add_label' => 'Add Variation',
- 'section' => 'variations',
- 'fields' => $this->setVariationFields('FOOD_AND_BEV')
- ],
- ];
- /* TODO: Set definitions for:
- 'dietary_preferences' => [
-
- ],
- 'calories_text' => [
-
- ],
- */
- }
- protected function setAppointmentServiceFields():array
- {
- return [
-
- 'product_variations' => [
- 'type' => 'repeater',
- 'label' => 'Product Variations',
- 'description' => 'Different versions of this product (sizes, colors, etc.)',
- 'add_label' => 'Add Variation',
- 'section' => 'variations',
- 'fields' => $this->setVariationFields('APPOINTMENTS_SERVICE')
- ],
- ];
- }
- protected function setEventFields():array
- {
- return [
- 'venue_details' => [
- 'type' => 'textarea',
- 'label' => 'Venue Details',
- ],
- 'capacity' => [
- 'type' => 'number',
- 'label' => 'Capacity'
- ],
- 'product_variations' => [
- 'type' => 'repeater',
- 'label' => 'Product Variations',
- 'description' => 'Different versions of this product (sizes, colors, etc.)',
- 'add_label' => 'Add Variation',
- 'section' => 'variations',
- 'fields' => $this->setVariationFields('EVENT')
- ],
- ];
- }
- protected function setGiftCardFields():array
- {
- return [
-// 'allowed_locations' => [
-//
-// ],
- 'product_variations' => [
- 'type' => 'repeater',
- 'label' => 'Product Variations',
- 'description' => 'Different versions of this product (sizes, colors, etc.)',
- 'add_label' => 'Add Variation',
- 'section' => 'variations',
- 'fields' => $this->setVariationFields('GIFT_CARD')
- ],
- ];
- }
-
- /*********************************************************
- IMAGE PROCESSING
- *********************************************************/
- /**
- * Upload image file to Square
- *
- * @param int $imgID WordPress attachment ID
- * @return array|WP_Error Upload result or error
- */
- protected function uploadImageToSquare(int $imgID): array|WP_Error
- {
- $supported_image_id = $this->getSupportedImage($imgID);
-
- // Check if already uploaded
- $existing_square_image_id = $this->getSquareImageId($supported_image_id);
- if ($existing_square_image_id) {
- return [
- 'success' => true,
- 'image_id' => $existing_square_image_id
- ];
- }
-
- $file_path = get_attached_file($supported_image_id);
- if (!file_exists($file_path)) {
- return new WP_Error('file_not_found', 'Image file not found');
- }
-
- // Verify file type
- $mime_type = get_post_mime_type($supported_image_id);
- if (!in_array($mime_type, ['image/jpeg', 'image/png', 'image/gif'])) {
- return new WP_Error('invalid_type', 'Square only supports JPEG, PNG, and GIF images');
- }
-
- $image_title = get_the_title($supported_image_id);
- $alt_text = get_post_meta($supported_image_id, '_wp_attachment_image_alt', true);
-
- // Build multipart request - SINGLE STEP
- $boundary = wp_generate_password(24);
- $headers = $this->getRequestHeaders();
- $headers['Content-Type'] = 'multipart/form-data; boundary=' . $boundary;
-
- // Request JSON part
- $request_json = [
- 'idempotency_key' => wp_generate_uuid4(),
- 'image' => [
- 'type' => 'IMAGE',
- 'id' => '#IMAGE_' . $supported_image_id . '_' . time(),
- 'image_data' => [
- 'name' => $image_title ?: 'Image',
- 'caption' => $alt_text ?: ''
- ]
- ],
- 'object_id' => $supported_image_id
- ];
-
- $body = $this->buildMultipartBody($file_path, $request_json, $boundary);
-
- $response = wp_remote_post(
- $this->getApiUrl('v2/catalog/images'),
- [
- 'headers' => $headers,
- 'body' => $body,
- 'timeout' => 60
- ]
- );
-
- if (is_wp_error($response)) {
- return $response;
- }
-
- $data = json_decode(wp_remote_retrieve_body($response), true);
-
- if (!empty($data['errors'])) {
- return new WP_Error('upload_error', $data['errors'][0]['detail'] ?? 'Unknown error');
- }
-
- if (!empty($data['image']['id'])) {
- $this->setSquareImageId($supported_image_id, $data['image']['id']);
- return [
- 'success' => true,
- 'image_id' => $data['image']['id']
- ];
- }
-
- return new WP_Error('upload_failed', 'Failed to upload image');
- }
-
- protected function buildMultipartBody(string $file_path, array $request_json, string $boundary): string
- {
- $eol = "\r\n";
- $body = '';
-
- // Add request JSON part
- $body .= '--' . $boundary . $eol;
- $body .= 'Content-Disposition: form-data; name="request"' . $eol;
- $body .= 'Content-Type: application/json' . $eol . $eol;
- $body .= json_encode($request_json) . $eol;
-
- // Add image file part
- $filename = basename($file_path);
- $file_contents = file_get_contents($file_path);
- $mime_type = mime_content_type($file_path);
-
- $body .= '--' . $boundary . $eol;
- $body .= 'Content-Disposition: form-data; name="file"; filename="' . $filename . '"' . $eol;
- $body .= 'Content-Type: ' . $mime_type . $eol . $eol;
- $body .= $file_contents . $eol;
- $body .= '--' . $boundary . '--' . $eol;
-
- return $body;
- }
-
- /**
- * Attach image to catalog item
- *
- * @param int $postID WordPress post ID
- * @param string $square_image_id Square image ID
- * @return array|WP_Error Result or error
- */
- public function attachImageToCatalogItem(int $postID, string $square_image_id): array|WP_Error
- {
- $square_catalog_id = get_post_meta($postID, BASE . '_square_catalog_id', true);
-
- if (!$square_catalog_id) {
- return new WP_Error('no_catalog_id', 'Post has not been synced to Square');
- }
-
- // Get the current catalog item
- $response = $this->getRequest('catalog/object/' . $square_catalog_id);
-
- if (is_wp_error($response)) {
- return $response;
- }
-
- if (empty($response['object'])) {
- return new WP_Error('item_not_found', 'Catalog item not found in Square');
- }
-
- $catalog_object = $response['object'];
-
- // Add image to item data
- $catalog_object['item_data']['image_ids'] = [$square_image_id];
-
- // Update the catalog item
- $update_response = $this->postRequest('catalog/batch-upsert', [
- 'idempotency_key' => wp_generate_uuid4(),
- 'batches' => [[
- 'objects' => [$catalog_object]
- ]]
- ]);
-
- if (is_wp_error($update_response)) {
- return $update_response;
- }
-
- return [
- 'success' => true,
- 'message' => 'Image attached to catalog item'
- ];
- }
-
- /**
- * Get stored Square image ID for an attachment
- *
- * @param int $attachment_id WordPress attachment ID
- * @return string|false Square image ID or false if not found
- */
- public function getSquareImageId(int $attachment_id): string|false
- {
- $image_id = get_post_meta($attachment_id, BASE . 'square_image_id', true);
-
- if ($image_id) {
- // Verify it still exists in Square
- return $this->verifySquareImage($image_id) ? $image_id : false;
- }
-
- return false;
- }
-
- /**
- * Store Square image ID for an attachment
- *
- * @param int $attachment_id WordPress attachment ID
- * @param string $square_image_id Square image ID
- * @return bool Success
- */
- public function setSquareImageId(int $attachment_id, string $square_image_id): bool
- {
- return update_post_meta($attachment_id, BASE . 'square_image_id', $square_image_id);
- }
-
- /**
- * Verify image exists in Square catalog
- *
- * @param string $square_image_id Square image ID
- * @return bool Whether image exists
- */
- protected function verifySquareImage(string $square_image_id): bool
- {
- $response = $this->getRequest('catalog/object/' . $square_image_id);
-
- if (is_wp_error($response)) {
- return false;
- }
-
- return !empty($response['object']) && $response['object']['type'] === 'IMAGE';
- }
-
- /**
- * Batch upload images for multiple posts
- *
- * @param array $post_ids Array of WordPress post IDs
- * @return array Results for each post
- */
- private function batchUploadImages(array $post_ids): array
- {
- $image_mappings = [];
- $image_objects = [];
-
- // First pass: collect images that need uploading
- foreach ($post_ids as $post_id) {
- $thumbnail_id = get_post_thumbnail_id($post_id);
- if (!$thumbnail_id) {
- $image_mappings[$post_id] = null;
- continue;
- }
-
- // Check if already uploaded and still exists in Square
- $existing_square_id = get_post_meta($thumbnail_id, BASE . '_square_image_id', true);
- if ($existing_square_id && $this->verifySquareImage($existing_square_id)) {
- $image_mappings[$post_id] = $existing_square_id;
- continue;
- }
-
- // Get supported image format (handles WebP conversion)
- $supported_image_id = $this->getSupportedImage($thumbnail_id);
- if (!$supported_image_id) {
- $image_mappings[$post_id] = null;
- continue;
- }
-
- // Upload the image to Square using the proper endpoint
- $square_image_id = $this->uploadSingleImageToSquare($supported_image_id, $post_id);
-
- if ($square_image_id && !is_wp_error($square_image_id)) {
- $image_mappings[$post_id] = $square_image_id;
- // Store the Square image ID for future reference
- update_post_meta($thumbnail_id, BASE . '_square_image_id', $square_image_id);
- } else {
- $this->logError('Failed to upload image for post', [
- 'post_id' => $post_id,
- 'thumbnail_id' => $thumbnail_id,
- 'error' => is_wp_error($square_image_id) ? $square_image_id->get_error_message() : 'Unknown error'
- ]);
- $image_mappings[$post_id] = null;
- }
- }
-
- return $image_mappings;
- }
-
- /**
- * Upload a single image to Square using the CreateCatalogImage endpoint
- *
- * @param int $attachment_id WordPress attachment ID
- * @param int $post_id Associated post ID
- * @return string|WP_Error Square image ID or error
- */
- private function uploadSingleImageToSquare(int $attachment_id, int $post_id): string|WP_Error
- {
- $file_path = get_attached_file($attachment_id);
-
- if (!file_exists($file_path)) {
- return new WP_Error('file_not_found', 'Image file not found');
- }
-
- // Verify file type
- $mime_type = get_post_mime_type($attachment_id);
- $allowed_types = ['image/jpeg', 'image/png', 'image/gif'];
-
- if (!in_array($mime_type, $allowed_types)) {
- return new WP_Error('invalid_type', 'Square only supports JPEG, PNG, and GIF images');
- }
-
- // Prepare multipart form data
- $boundary = wp_generate_password(24);
- $headers = $this->getRequestHeaders();
- $headers['Content-Type'] = 'multipart/form-data; boundary=' . $boundary;
-
- // Build the request body
- $eol = "\r\n";
- $body = '';
-
- // Add request JSON
- $request_data = [
- 'idempotency_key' => wp_generate_uuid4(),
- 'image' => [
- 'type' => 'IMAGE',
- 'id' => '#TEMP_IMAGE_' . $attachment_id . '_' . time(),
- 'image_data' => [
- 'name' => get_the_title($attachment_id) ?: basename($file_path),
- 'caption' => get_post_meta($attachment_id, '_wp_attachment_image_alt', true) ?:
- sprintf('Image for %s', get_the_title($post_id))
- ]
- ]
- ];
-
- $body .= '--' . $boundary . $eol;
- $body .= 'Content-Disposition: form-data; name="request"' . $eol;
- $body .= 'Content-Type: application/json' . $eol . $eol;
- $body .= json_encode($request_data) . $eol;
-
- // Add image file
- $filename = basename($file_path);
- $file_contents = file_get_contents($file_path);
-
- $body .= '--' . $boundary . $eol;
- $body .= 'Content-Disposition: form-data; name="image"; filename="' . $filename . '"' . $eol;
- $body .= 'Content-Type: ' . $mime_type . $eol . $eol;
- $body .= $file_contents . $eol;
-
- // End boundary
- $body .= '--' . $boundary . '--' . $eol;
-
- // Make the request to the CreateCatalogImage endpoint
- $response = wp_remote_post(
- $this->getApiUrl('catalog/images'),
- [
- 'headers' => $headers,
- 'body' => $body,
- 'timeout' => 60
- ]
- );
-
- if (is_wp_error($response)) {
- return $response;
- }
-
- $response_body = wp_remote_retrieve_body($response);
- $data = json_decode($response_body, true);
-
- if (!empty($data['errors'])) {
- $error_message = $data['errors'][0]['detail'] ?? 'Unknown error';
- return new WP_Error('upload_error', $error_message);
- }
-
- if (!empty($data['image']['id'])) {
- $this->logDebug('Successfully uploaded image to Square', [
- 'post_id' => $post_id,
- 'attachment_id' => $attachment_id,
- 'square_image_id' => $data['image']['id']
- ]);
- return $data['image']['id'];
- }
-
- return new WP_Error('upload_failed', 'Failed to get image ID from Square response');
- }
-
- protected function handleApiError(int $code, string $body, string $endpoint): void
- {
- parent::handleApiError($code, $body, $endpoint);
-
- $decoded = json_decode($body, true);
- $errorCode = $decoded['errors'][0]['code'] ?? '';
-
- // Handle Square-specific OAuth errors
- if ($errorCode === 'ACCESS_TOKEN_EXPIRED') {
- $this->logDebug('Access token expired, attempting refresh');
- if ($this->refreshOAuthToken()) {
- // Token refreshed, could retry the request
- $this->logDebug('Token refreshed successfully');
- }
- } elseif ($errorCode === 'UNAUTHORIZED' || $errorCode === 'ACCESS_TOKEN_REVOKED') {
- $this->logError('Square authorization revoked or invalid', [
- 'error_code' => $errorCode,
- 'user_id' => $this->userID
- ]);
- // Clear invalid credentials
- $this->deleteCredentials();
- }
- }
-
- public function createSquareOrder(array $items, ?string $customer_id, array $data): array|WP_Error
- {
- // Build line items for Square
- $line_items = [];
- foreach ($items as $item) {
- $line_item = [
- 'quantity' => (string)$item['quantity'], // MUST be string!
- ];
-
- // Use catalog_object_id if available (recommended)
- if (!empty($item['catalog_object_id'])) {
- $line_item['catalog_object_id'] = $item['catalog_object_id'];
- $line_item['catalog_version'] = $item['catalog_version'] ?? null;
- } else {
- // Ad-hoc line item (not recommended - no tax/inventory automation)
- $line_item['name'] = $item['name'];
- $line_item['base_price_money'] = [
- 'amount' => (int)$item['_square_price'],
- 'currency' => $this->getCurrency()
- ];
- }
-
- if (!empty($item['note'])) {
- $line_item['note'] = $item['note'];
- }
-
- $line_items[] = $line_item;
- }
-
- $order_data = [
- 'idempotency_key' => wp_generate_uuid4(), // Different from payment idempotency key
- 'order' => [
- 'location_id' => $this->locationId,
- 'line_items' => $line_items,
- 'state' => 'OPEN'
- ]
- ];
-
- // Add customer if available
- if ($customer_id) {
- $order_data['order']['customer_id'] = $customer_id;
- }
-
- // Add metadata
- if (!empty($data['note'])) {
- $order_data['order']['metadata'] = [
- 'special_instructions' => $data['note']
- ];
- }
-
- if (!empty($data['pickup_time'])) {
- $order_data['order']['metadata']['pickup_time'] = $data['pickup_time'];
- }
-
- return $this->postRequest('orders', $order_data);
- }
-
- public function createSquarePayment(
- string $source_id,
- string $idempotency_key,
- int $amount_cents,
- string $order_id,
- ?string $customer_id
- ): array|WP_Error
- {
- $payment_data = [
- 'idempotency_key' => $idempotency_key,
- 'source_id' => $source_id,
- 'amount_money' => [
- 'amount' => $amount_cents, // Already in cents!
- 'currency' => $this->getCurrency()
- ],
- 'order_id' => $order_id,
- 'location_id' => $this->locationId,
- 'autocomplete' => true, // Capture immediately
- ];
-
- // Add customer if available
- if ($customer_id) {
- $payment_data['customer_id'] = $customer_id;
- }
-
- // Add reference ID for tracking
- $payment_data['reference_id'] = 'WP_' . time();
-
- return $this->postRequest('payments', $payment_data);
- }
-
- public function saveOrderToWordPress(array $order_data): int
- {
- // Extract customer info
- $customer_email = $order_data['customer']['email'] ?? '';
- $customer_name = $order_data['customer']['name'] ?? '';
-
- // Find or create WP user for logged-in association
- $user_id = 0;
- if ($customer_email) {
- $user = get_user_by('email', $customer_email);
- if ($user) {
- $user_id = $user->ID;
- // Store Square customer ID on user
- if (!empty($order_data['square_customer_id'])) {
- update_user_meta($user_id, BASE . '_square_customer_id', $order_data['square_customer_id']);
- }
- }
- }
-
- // Create order post
- $order_post_id = wp_insert_post([
- 'post_type' => BASE.$this->orderPostType,
- 'post_title' => 'Order #' . $order_data['square_order_id'],
- 'post_status' => 'publish',
- 'post_author' => $user_id // Associate with user if logged in
- ]);
-
- if (!$order_post_id || is_wp_error($order_post_id)) {
- $this->logError('Failed to create order post', ['order_data' => $order_data]);
- return 0;
- }
-
- // Save all order meta
- $meta = Meta::forPost($order_post_id);
- $fields = $this->getOrderFields();
- unset($fields['post_title']);
-
- $meta->setAll([
- 'square_order_id' => $order_data['square_order_id'],
- 'square_payment_id' => $order_data['square_payment_id'] ?? '',
- 'square_customer_id' => $order_data['square_customer_id'] ?? '',
- 'amount' => $order_data['amount'],
- 'status' => $order_data['status'],
- 'fulfillment_status' => $order_data['fulfillment_status'] ?? 'PROPOSED',
- 'pickup_time' => $order_data['pickup_time'] ?? '',
- 'customer_email' => $customer_email,
- 'customer_name' => $customer_name,
- 'customer_phone' => $order_data['customer']['phone'] ?? '',
- 'special_instructions' => $order_data['note'] ?? '',
- 'items' => $order_data['items'],
- 'receipt_url' => $order_data['receipt_url'] ?? '',
- 'created_at' => current_time('mysql'),
- 'updated_at' => current_time('mysql')
- ]);
-
- return $order_post_id;
- }
-
- /**
- * Get currency code
- */
- private function getCurrency(): string
- {
- return get_option(BASE . 'currency', 'CAD');
- }
-
- /**
- * Get customer with saved cards (2025-compliant)
- */
- public function getUserCards(string $customer_id): array
- {
- $response = $this->getRequest('cards?customer_id=' . $customer_id);
- return $response['cards'] ?? [];
- }
-
-
- public function getUserOrders(string $customer_email): array
- {
- // First get Square customer ID
- $customer_response = $this->postRequest('customers/search', [
- 'filter' => [
- 'email_address' => ['exact' => $customer_email]
- ]
- ]);
-
- $customer_id = $customer_response['customers'][0]['id'] ?? null;
- if (!$customer_id) {
- return [];
- }
-
- // Get their orders
- $orders_response = $this->postRequest('orders/search', [
- 'filter' => [
- 'customer_filter' => [
- 'customer_ids' => [$customer_id]
- ]
- ],
- 'sort' => [
- 'sort_field' => 'CREATED_AT',
- 'sort_order' => 'DESC'
- ],
- 'limit' => 50
- ]);
-
- return $orders_response['orders'] ?? [];
- }
-
- public function checkOrderStatus(string $order_id): ?string
- {
- // Fetch from Square
- $response = $this->getRequest('orders/' . $order_id);
- if (!is_wp_error($response)) {
- return $response['order']['state'] ?? null;
- }
-
- return null;
- }
-
- /**
- * Single-item sync. Called by IntegrationExecutor::processSyncTo().
- * Delegates to syncBatchToService since Square uses batch-upsert.
- */
- public function syncPostToService(int $postID): array|WP_Error
- {
- return $this->syncBatchToService(['items' => [$postID]]);
- }
-
- /**
- * Batch sync — preferred by IntegrationExecutor when available.
- * Wraps existing processSyncToSquare which already handles batches.
- */
- public function syncBatchToService(array $data): array|WP_Error
- {
- $result = $this->processSyncToSquare($data);
-
- if (empty($result['success'])) {
- $errors = implode(', ', $result['result']['errors'] ?? ['Sync failed']);
- return new WP_Error('square_sync_failed', $errors);
- }
-
- return $result;
- }
-
- /**
- * Delete catalog object from Square.
- * Called by IntegrationExecutor::processDeleteFrom().
- */
- public function deleteFromService(string $externalId): array|WP_Error
- {
- $result = $this->processDeleteFromSquare(['square_ids' => [$externalId]]);
-
- if (empty($result['success'])) {
- return new WP_Error('square_delete_failed', $result['result']['error'] ?? 'Delete failed');
- }
-
- return $result;
- }
-
- /**
- * Import from Square catalog → WordPress.
- * Called by IntegrationExecutor::processImport().
- */
- public function importFromService(array $data): array|WP_Error
- {
- $result = $this->processSyncFromSquare($data);
-
- if (empty($result['success'])) {
- return new WP_Error('square_import_failed', $result['result']['error'] ?? 'Import failed');
- }
-
- return $result;
- }
-
- /**
- * Sync customer to Square.
- * Called by IntegrationExecutor::processSyncCustomer().
- */
- public function syncCustomer(array $data): array|WP_Error
- {
- $result = $this->processSyncCustomer($data);
-
- if (empty($result['success'])) {
- return new WP_Error('square_customer_sync_failed', $result['result']['error'] ?? 'Customer sync failed');
+ return $this->response(false, 'Failed to fetch locations: '.$e->getMessage());
}
-
- return $result;
- }
-
- public function addDashboardPages():void
- {
- $page = JVB()->dashboard()->addPage('Your Orders', 'orders', 'receipt');
- $page->setScripts(['jvb-crud']);
- }
- public function renderDashPage():void
- {
- $this->determineUser();
-
- $crud = new CRUDSkeleton();
- $crud->icon('receipt');
- $crud->title('Your Orders','Here you can see your past orders, and reorder from there if you\'d like');
- $crud->content($this->orderPostType, 'Order', 'Orders');
- $crud->addSearch();
- $crud->addCapabilities(['view']);
- $crud->setEmptyState(sprintf(
- '<div class="empty-state">
- <h3>%sNothing here%s</h3>
- <p>It doesn\'t look like you have any orders yet.</p>
- <p>Head on over to <a href="%s">our menu</a> and make your first order!</p>
- </div>',
- jvbDashIcon($crud->getIcon()),
- jvbDashIcon($crud->getIcon()),
- get_post_type_archive_link(BASE.'menu_item')
- ));
-
-
- $crud->render();
- }
-
-
- public function getOrderPost(string $order_id):int|false
- {
- $posts = new WP_Query([
- 'post_type' => BASE.$this->orderPostType,
- 'posts_per_page' => 1,
- 'meta_key' => BASE.'square_order_id',
- 'meta_value' => $order_id,
- 'fields' => 'ids',
- ]);
- wp_reset_postdata();
- return $posts->have_posts() ? $posts[0] : false;
}
- public function getOrderHistory(int $user_id):array
- {
- $posts = new WP_Query([
- 'post_type' => BASE.$this->orderPostType,
- 'posts_per_page' => 25,
- 'author' => $user_id,
- 'orderby' => 'date',
- 'order' => 'desc',
- 'fields' => 'ids',
- ]);
- wp_reset_postdata();
- return array_map(function ($post) {
- $fields = Meta::forPost($post);
- $fields['wp_order_id'] = $post;
- return $fields;
- }, $posts->posts);
- }
- public function formatPrice(string|float|int $priceValue):int
- {
- //Convert dollars to cents
- return intval(floatval($priceValue) * 100);
- }
}
diff --git a/inc/integrations/services/SquareOld.php b/inc/integrations/services/SquareOld.php
new file mode 100644
index 0000000..d037f42
--- /dev/null
+++ b/inc/integrations/services/SquareOld.php
@@ -0,0 +1,3506 @@
+<?php
+
+namespace JVBase\integrations\services;
+use Exception;
+use JVBase\integrations\Integrations;
+use JVBase\managers\queue\executors\IntegrationExecutor;
+use JVBase\managers\queue\TypeConfig;
+use JVBase\meta\Form;
+use JVBase\meta\Meta;
+use JVBase\registrar\Registrar;
+use JVBase\ui\Checkout;
+use JVBase\ui\CRUDSkeleton;
+use WP_Error;
+use WP_Post;
+use WP_Query;
+use WP_User;
+
+if (!defined('ABSPATH')) {
+ exit;
+}
+
+/**
+ * Square Integration Class
+ *
+ * Handles OAuth 2.0 authentication and API interactions with Square
+ * Uses the latest Square API version with OAuth code flow
+ *
+ * @since 1.0.0
+ */
+class SquareOld extends Integrations
+{
+ protected static string $syncCustomer = 'square_sync_customer';
+ protected array $allowedContent = [
+ 'REGULAR',
+ 'FOOD_AND_BEV',
+ 'APPOINTMENTS_SERVICE',
+ 'DIGITAL',
+ 'EVENT',
+ 'DONATION'
+ ];
+ /**
+ * Square API Configuration
+ */
+ protected string $service_name = 'square';
+ protected array|string $apiBase = [
+ 'production' => 'https://connect.squareup.com',
+ 'sandbox' => 'https://connect.squareupsandbox.com'
+ ];
+
+ protected string $apiVersion = '2025-09-24';
+
+ protected string $environment = 'sandbox';
+
+ private const PASSWORD_RESET_INTERVAL = 3; // Reset password every 3 logins, for customers
+
+ /**
+ * OAuth Configuration
+ */
+ protected bool $isOAuthService = true;
+
+ protected string $orderPostType = '_square_order';
+ protected array $newOrder = [];
+ protected array $oauth = [
+ 'authorize' => '',
+ 'token' => '',
+ 'revoke' => '',
+ 'scopes' => [
+ 'MERCHANT_PROFILE_READ',
+ 'MERCHANT_PROFILE_WRITE',
+ 'PAYMENTS_WRITE',
+ 'PAYMENTS_READ',
+ 'CUSTOMERS_WRITE',
+ 'CUSTOMERS_READ',
+ 'INVENTORY_READ',
+ 'INVENTORY_WRITE',
+ 'ITEMS_READ',
+ 'ITEMS_WRITE',
+ 'ORDERS_READ',
+ 'ORDERS_WRITE'
+ ]
+ ];
+
+ /**
+ * Square-specific properties
+ */
+ protected string $merchantId = '';
+ protected string $locationId = '';
+ protected array $locations = [];
+
+ protected static SquareOld $instance;
+
+ public static function getInstance():self
+ {
+ if (!isset(self::$instance)) {
+ self::$instance = new self();
+ }
+ return self::$instance;
+ }
+
+ protected function __construct(?int $userID = null)
+ {
+
+ // Display properties
+ $this->title = 'Square';
+ $this->icon = 'square-logo';
+
+ $this->refresh_interval = 7 * DAY_IN_SECONDS;
+
+ $this->newOrder = [
+ 'post_type' => $this->orderPostType,
+ 'post_status' => 'PROPOSED',
+ ];
+
+ // Define credential fields
+ $this->fields = [
+ 'environment' => [
+ 'type' => 'select',
+ 'label' => 'Environment',
+ 'options' => [
+ 'sandbox' => 'Sandbox',
+ 'production'=> 'Production',
+ ],
+ 'default' => 'sandbox',
+ ],
+ 'client_id' => [
+ 'type' => 'text',
+ 'label' => 'Application ID',
+ 'hint' => 'Found in Square Developer Dashboard',
+ 'required' => true,
+ ],
+ 'client_secret' => [
+ 'type' => 'text',
+ 'subtype' => 'password',
+ 'label' => 'Application Secret',
+ 'hint' => 'Found in OAuth section of Square Developer Dashboard',
+ 'required' => true,
+ ],
+ 'location_id' => [
+ 'type' => 'select',
+ 'label' => 'Default Location',
+ 'options' => [],
+ 'hint' => 'Select default Square location for transactions',
+ ]
+ ];
+
+ // Advanced settings
+ $this->advanced = [
+ 'webhook_signature_key' => [
+ 'type' => 'text',
+ 'subtype' => 'password',
+ 'label' => 'Webhook Signature Key',
+ 'hint' => 'Used to verify webhook authenticity',
+ ],
+ 'access_token' => [
+ 'type' => 'text',
+ 'subtype' => 'password',
+ 'label' => 'Access Token',
+ 'hint' => 'Generated automatically after OAuth authorization',
+ 'readonly' => true,
+ ],
+ 'refresh_token' => [
+ 'type' => 'text',
+ 'subtype' => 'password',
+ 'label' => 'Refresh Token',
+ 'hint' => 'Used to refresh expired access tokens',
+ 'readonly' => true,
+ ],
+ 'merchant_id' => [
+ 'type' => 'text',
+ 'label' => 'Merchant ID',
+ 'hint' => 'Square Merchant ID (obtained after authorization)',
+ 'readonly' => true,
+ ],
+ ];
+
+ $this->instructions = [
+ 'Go to <a href="https://developer.squareup.com/apps" target="_blank">Square Developer Dashboard</a>',
+ 'Create a new application or select an existing one',
+ 'Navigate to the OAuth section',
+ 'Add this redirect URL: <code>' . esc_html($this->getRedirectUri()) . '</code>',
+ 'Copy the Application ID and Application Secret',
+ 'Click "Authorize Connection" below to complete OAuth flow'
+ ];
+
+
+ $this->canSync = [
+ 'create' => true,
+ 'update' => true,
+ 'delete' => true
+ ];
+ $this->supportsWebp = false;
+ $this->hasWebhooks = true;
+
+ parent::__construct();
+
+ // Add Square-specific actions
+ $this->actions = array_merge(
+ $this->actions,
+ [
+ 'select_location' => 'handleSelectLocation',
+ 'fetch_locations' => 'handleFetchLocations',
+ 'verify_webhook' => 'handleVerifyWebhook',
+ 'test_payment' => 'handleTestPayment'
+ ]
+ );
+
+ $this->buttons = array_merge(
+ $this->buttons,
+ [
+ 'import_from_square' => 'Import Catalog from Square',
+ 'sync_to_square' => 'Sync Site to Square',
+ ]
+ );
+
+ add_action('init', [$this, 'registerSquarePostTypes'], 5);
+ add_action('init', [$this, 'addDashboardPages'], 10);
+ add_action(BASE.'dashboard_page_orders', [$this, 'renderDashPage']);
+ }
+
+ /**
+ * Initialize the integration with loaded credentials
+ */
+ protected function initialize(): void
+ {
+ if (empty($this->credentials)) {
+ $this->loadCredentials();
+ }
+
+ $this->merchantId = $this->credentials['merchant_id'] ?? '';
+ $this->locationId = $this->credentials['location_id'] ?? '';
+ $this->environment = $this->credentials['environment'] ?? 'sandbox';
+
+ // Set OAuth URLs based on environment
+ $baseUrl = ($this->environment === 'production')
+ ? 'https://connect.squareup.com'
+ : 'https://connect.squareupsandbox.com';
+
+ $this->oauth['authorize'] = $baseUrl . '/oauth2/authorize';
+ $this->oauth['token'] = $baseUrl . '/oauth2/token';
+ $this->oauth['revoke'] = $baseUrl . '/oauth2/revoke';
+
+ // Set up API endpoints with version
+ $this->apiEndpoints = [
+ 'locations' => '/v2/locations',
+ 'customers' => '/v2/customers',
+ 'catalog' => '/v2/catalog',
+ 'payments' => '/v2/payments',
+ 'orders' => '/v2/orders',
+ 'inventory' => '/v2/inventory'
+ ];
+
+ }
+
+ public function getOrderFields():array
+ {
+ return [
+ 'post_title' => [
+ 'type' => 'text',
+ 'label' => 'Order Number'
+ ],
+ 'square_order_id' => [
+ 'type' => 'text',
+ 'label' => 'Square Order ID',
+ ],
+ 'square_payment_id' => [
+ 'type' => 'text',
+ 'label' => 'Square Payment ID',
+ ],
+ 'square_customer_id' => [
+ 'type' => 'text',
+ 'label' => 'Square Customer ID',
+ ],
+ 'amount' => [
+ 'type' => 'number',
+ 'label' => 'Total Amount (cents)',
+ ],
+ 'square_payment_status' => [
+ 'type' => 'select',
+ 'label' => 'Order Status',
+ 'options' => [
+ 'PROPOSED' => 'Proposed',
+ 'RESERVED' => 'Reserved',
+ 'PREPARED' => 'Prepared (Ready for Pickup)',
+ 'COMPLETED' => 'Completed',
+ 'CANCELED' => 'Canceled'
+ ],
+ ],
+ 'fulfillment_status' => [
+ 'type' => 'select',
+ 'label' => 'Fulfillment Status',
+ 'options' => [
+ 'PROPOSED' => 'Proposed',
+ 'RESERVED' => 'Reserved',
+ 'PREPARED' => 'Prepared',
+ 'COMPLETED' => 'Completed',
+ 'CANCELED' => 'Canceled',
+ 'FAILED' => 'Failed'
+ ],
+ ],
+ 'pickup_time' => [
+ 'type' => 'datetime',
+ 'label' => 'Pickup Time'
+ ],
+ 'customer_email' => [
+ 'type' => 'email',
+ 'label' => 'Customer Email',
+ ],
+ 'customer_name' => [
+ 'type' => 'text',
+ 'label' => 'Customer Name',
+ ],
+ 'customer_phone' => [
+ 'type' => 'phone',
+ 'label' => 'Customer Phone',
+ 'section'=> 'your-account'
+ ],
+ 'special_instructions' => [
+ 'type' => 'textarea',
+ 'label' => 'Special Instructions',
+ ],
+ 'items' => [
+ 'type' => 'repeater',
+ 'label' => 'Order Items',
+ 'fields' => [
+ 'name' => ['type' => 'text', 'label' => 'Item Name'],
+ 'quantity' => ['type' => 'number', 'label' => 'Quantity'],
+ 'price' => ['type' => 'number', 'label' => 'Price'],
+ 'note' => ['type' => 'text', 'label' => 'Note']
+ ]
+ ],
+ 'receipt_url' => [
+ 'type' => 'url',
+ 'label' => 'Receipt URL',
+ ],
+ 'created_at' => [
+ 'type' => 'datetime',
+ 'label' => 'Created At',
+ ],
+ 'updated_at' => [
+ 'type' => 'datetime',
+ 'label' => 'Last Updated',
+ ]
+ ];
+ }
+
+ public function registerSquarePostTypes():void
+ {
+ $orders = Registrar::forPost($this->orderPostType, 'Square Order', 'Square Orders');
+ $orders->make([
+ 'public' => true
+ ]);
+ $orders->setAll(['system']);
+
+ $fields = $orders->fields();
+ foreach ($this->getOrderFields() as $fieldName => $config) {
+ $fields->addField($fieldName, $config);
+ }
+ }
+
+ /**
+ * Get request headers for API calls
+ */
+ protected function getRequestHeaders(): array
+ {
+ return [
+ 'Authorization' => 'Bearer ' . ($this->credentials['access_token'] ?? ''),
+ 'Square-Version' => $this->apiVersion,
+ 'Content-Type' => 'application/json'
+ ];
+ }
+
+ /**
+ * Add Square-specific OAuth parameters
+ */
+ protected function addOAuthParams(array $params): array
+ {
+ // Load current environment setting
+ $this->ensureInitialized();
+
+ // For production, add session=false
+ // For sandbox, session parameter is not supported and should be omitted
+ if ($this->environment === 'production') {
+ $params['session'] = 'false';
+ } else {
+ // Ensure session is not included for sandbox
+ unset($params['session']);
+ }
+
+ return $params;
+ }
+
+ /**
+ * Exchange OAuth code for tokens
+ * Override to handle Square's specific response format
+ */
+ protected function exchangeOAuthCode(string $code): ?array
+ {
+ $this->ensureInitialized();
+
+ // Prepare the request body as an array
+ $body = [
+ 'client_id' => $this->credentials['client_id'] ?? '',
+ 'client_secret' => $this->credentials['client_secret'] ?? '',
+ 'code' => $code,
+ 'grant_type' => 'authorization_code',
+ 'redirect_uri' => $this->getRedirectUri()
+ ];
+
+ $response = wp_remote_post($this->oauth['token'], [
+ 'body' => json_encode($body),
+ 'headers' => [
+ 'Content-Type' => 'application/json',
+ 'Square-Version' => $this->apiVersion
+ ]
+ ]);
+
+ if (is_wp_error($response)) {
+ $this->logError('OAuth token exchange failed', ['error' => $response->get_error_message()]);
+ return null;
+ }
+
+ $data = json_decode(wp_remote_retrieve_body($response), true);
+ if (isset($data['access_token'])) {
+ return [
+ 'access_token' => $data['access_token'],
+ 'refresh_token' => $data['refresh_token'] ?? '',
+ 'expires_in' => $data['expires_in'] ?? 2592000, // 30 days default
+ 'token_type' => $data['token_type'] ?? 'Bearer',
+ 'merchant_id' => $data['merchant_id'] ?? '',
+ 'scope' => $data['scope'] ?? ''
+ ];
+ }
+
+ $this->logError('Failed to obtain access token', ['response' => $data]);
+ return null;
+ }
+
+ /**
+ * Add Square-specific credential data after OAuth
+ */
+ protected function addCredentialData(array $credentials, array $tokens): array
+ {
+ // Store Square-specific data
+ $credentials['merchant_id'] = $tokens['merchant_id'] ?? '';
+
+ // Preserve selected location if it exists
+ $credentials['location_id'] = $this->credentials['location_id'] ?? '';
+
+ // Preserve sandbox setting
+ $credentials['environment'] = $this->credentials['environment'] ?? 'sandbox';
+
+ return $credentials;
+ }
+
+ /**
+ * Refresh OAuth token
+ * Override to handle Square's refresh token flow
+ */
+ protected function refreshOAuthToken(): bool
+ {
+ if (!$this->isOAuthService || empty($this->credentials['refresh_token'])) {
+ return false;
+ }
+
+ $response = wp_remote_post($this->oauth['token'], [
+ 'body' => [
+ 'client_id' => $this->credentials['client_id'],
+ 'client_secret' => $this->credentials['client_secret'],
+ 'refresh_token' => $this->credentials['refresh_token'],
+ 'grant_type' => 'refresh_token'
+ ],
+ 'headers' => $this->getRequestHeaders()
+ ]);
+
+ if (is_wp_error($response)) {
+ $this->logError('Failed to refresh Square token', [
+ 'error' => $response->get_error_message()
+ ]);
+ return false;
+ }
+
+ $data = json_decode(wp_remote_retrieve_body($response), true);
+
+ if (isset($data['access_token'])) {
+ $this->credentials['access_token'] = $data['access_token'];
+ $this->credentials['expires_at'] = time() + ($data['expires_in'] ?? 2592000); // 30 days
+ // Note: Square returns the SAME refresh token
+ if (isset($data['refresh_token'])) {
+ $this->credentials['refresh_token'] = $data['refresh_token'];
+ }
+
+ $this->saveCredentials($this->credentials);
+ return true;
+ }
+
+ return false;
+ }
+
+ /**
+ * Load Square locations
+ */
+ protected function loadLocations(): void
+ {
+ // Skip if we don't have credentials yet (during OAuth flow)
+ if (empty($this->credentials['access_token'])) {
+ return;
+ }
+ try {
+ $response = $this->getRequest(
+ '/v2/locations',
+ [],
+ $this->environment
+ );
+
+ if (isset($response['locations'])) {
+ $this->locations = $response['locations'];
+
+ // Update location options in fields
+ $locationOptions = [];
+ foreach ($this->locations as $location) {
+ if ($location['status'] === 'ACTIVE') {
+ $locationOptions[$location['id']] = $location['name'] ?? $location['id'];
+ }
+ }
+
+ if (!empty($locationOptions)) {
+ $this->fields['location_id']['options'] = $locationOptions;
+ }
+ }
+ } catch (Exception $e) {
+ $this->logError('Failed to load locations', ['error' => $e->getMessage()]);
+ // Set empty locations array so we don't retry constantly
+ $this->locations = [];
+ }
+ }
+
+ 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
+ }
+
+ $this->loadLocations();
+ if (!empty($this->locations)) {
+ ?>
+ <div class="form-field" data-service="square">
+ <label for="square_location_id">Square Location</label>
+ <select name="location_id"
+ id="square_location_id"
+ class="jvb-ajax-update"
+ data-action="select_location">
+ <option value="">Select a location...</option>
+ <?php foreach ($this->locations as $location): ?>
+ <option value="<?php echo esc_attr($location['id']); ?>"
+ <?php selected($this->credentials['location_id'] ?? '', $location['id']); ?>>
+ <?php echo esc_html($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_id']); ?>
+ </div>
+ <?php
+ }
+ } else {
+ ?>
+ <div class="notice notice-warning">
+ <p>No Square locations found. Please ensure your Square credentials are correct.</p>
+ </div>
+ <?php
+ }
+ }
+
+ /**
+ * Handle location selection
+ */
+ public function handleSelectLocation($data): array
+ {
+ if (empty($data['location_id'])) {
+ return [
+ 'success' => false,
+ 'message' => 'No location selected'
+ ];
+ }
+
+ $this->credentials['location_id'] = sanitize_text_field($data['location_id']);
+ $this->locationId = $this->credentials['location_id'];
+
+ $result = $this->saveCredentials($this->credentials);
+
+ if ($result['success']) {
+ return [
+ 'success' => true,
+ 'message' => 'Location updated successfully'
+ ];
+ }
+
+ return $result;
+ }
+
+ /**
+ * Handle fetching locations
+ */
+ public function handleFetchLocations(): array
+ {
+ try {
+ $this->loadLocations();
+
+ if (empty($this->locations)) {
+ return [
+ 'success' => false,
+ 'message' => 'No locations found'
+ ];
+ }
+
+ return [
+ 'success' => true,
+ 'data' => $this->locations,
+ 'message' => sprintf('Found %d location(s)', count($this->locations))
+ ];
+ } catch (Exception $e) {
+ return [
+ 'success' => false,
+ 'message' => 'Failed to fetch locations: ' . $e->getMessage()
+ ];
+ }
+ }
+
+ /**
+ * Handle webhook verification
+ */
+ public function handleVerifyWebhook($data): array
+ {
+ if (empty($data['signature']) || empty($data['body'])) {
+ return [
+ 'success' => false,
+ 'message' => 'Missing signature or body'
+ ];
+ }
+
+ $signatureKey = $this->credentials['webhook_signature_key'] ?? '';
+
+ if (empty($signatureKey)) {
+ return [
+ 'success' => false,
+ 'message' => 'Webhook signature key not configured'
+ ];
+ }
+
+ // Calculate expected signature
+ $stringToSign = $data['url'] . $data['body'];
+ $expectedSignature = base64_encode(
+ hash_hmac('sha256', $stringToSign, $signatureKey, true)
+ );
+
+ $isValid = hash_equals($expectedSignature, $data['signature']);
+
+ return [
+ 'success' => $isValid,
+ 'message' => $isValid ? 'Valid webhook signature' : 'Invalid webhook signature'
+ ];
+ }
+
+ /**
+ * Test payment/connection
+ */
+ public function handleTestPayment(): array
+ {
+ try {
+ // Test with a simple API call to verify connection
+ $response = $this->getRequest(
+ '/v2/locations/'.$this->locationId,
+ [],
+ $this->environment
+ );
+ if (!empty($response['location'])) {
+ return [
+ 'success' => true,
+ 'message' => 'Successfully connected to Square',
+ 'data' => [
+ 'location_name' => $response['location']['name'] ?? 'Unknown',
+ 'merchant_id' => $this->merchantId,
+ 'environment' => $this->environment
+ ]
+ ];
+ }
+
+ return [
+ 'success' => false,
+ 'message' => 'Connection test failed'
+ ];
+ } catch (Exception $e) {
+ return [
+ 'success' => false,
+ 'message' => 'Connection test failed: ' . $e->getMessage()
+ ];
+ }
+ }
+
+ /**
+ * Create a Square customer
+ */
+ public function createCustomer(array $customerData): array
+ {
+ try {
+ $response = $this->postRequest('/v2/customers', [
+ 'given_name' => $customerData['first_name'] ?? '',
+ 'family_name' => $customerData['last_name'] ?? '',
+ 'email_address' => $customerData['email'] ?? '',
+ 'phone_number' => $customerData['phone'] ?? '',
+ 'reference_id' => $customerData['reference_id'] ?? '',
+ 'note' => $customerData['note'] ?? ''
+ ], $this->environment);
+
+ return [
+ 'success' => true,
+ 'data' => $response['customer'] ?? []
+ ];
+ } catch (Exception $e) {
+ return [
+ 'success' => false,
+ 'message' => 'Failed to create customer: ' . $e->getMessage()
+ ];
+ }
+ }
+
+ /**
+ * Create a catalog item
+ */
+ public function createCatalogItem(array $itemData): array
+ {
+ try {
+ $response = $this->postRequest('/v2/catalog/object', [
+ 'idempotency_key' => wp_generate_uuid4(),
+ 'object' => [
+ 'type' => 'ITEM',
+ 'id' => '#' . sanitize_title($itemData['name']),
+ 'item_data' => [
+ 'name' => $itemData['name'],
+ 'description' => $itemData['description'] ?? '',
+ 'variations' => $this->buildItemVariations($itemData)
+ ]
+ ]
+ ], $this->environment);
+
+ return [
+ 'success' => true,
+ 'data' => $response['catalog_object'] ?? []
+ ];
+ } catch (Exception $e) {
+ return [
+ 'success' => false,
+ 'message' => 'Failed to create catalog item: ' . $e->getMessage()
+ ];
+ }
+ }
+
+ /**
+ * Build item variations for catalog
+ */
+ protected function buildItemVariations(array $itemData): array
+ {
+ $variations = [];
+
+ if (isset($itemData['variations']) && is_array($itemData['variations'])) {
+ foreach ($itemData['variations'] as $variation) {
+ $variations[] = [
+ 'type' => 'ITEM_VARIATION',
+ 'id' => '#' . sanitize_title($variation['name']),
+ 'item_variation_data' => [
+ 'name' => $variation['name'],
+ 'pricing_type' => 'FIXED_PRICING',
+ 'price_money' => [
+ 'amount' => $this->formatPrice($variation['_square_price']??0),
+ 'currency' => $this->getCurrency()
+ ]
+ ]
+ ];
+ }
+ } else {
+ // Default single variation
+ $variations[] = [
+ 'type' => 'ITEM_VARIATION',
+ 'id' => '#regular',
+ 'item_variation_data' => [
+ 'name' => 'Regular',
+ 'pricing_type' => 'FIXED_PRICING',
+ 'price_money' => [
+ 'amount' => $this->formatPrice($itemData['_square_price'] ?? 0),
+ 'currency' => $this->getCurrency()
+ ]
+ ]
+ ];
+ }
+
+ return $variations;
+ }
+
+ /**
+ * Handle OAuth disconnect
+ */
+ public function handleOAuthDisconnect(): array
+ {
+ try {
+ // Revoke the token with Square
+ if (!empty($this->credentials['access_token'])) {
+ wp_remote_post($this->oauth['revoke'], [
+ 'body' => [
+ 'client_id' => $this->credentials['client_id'] ?? '',
+ 'access_token' => $this->credentials['access_token']
+ ],
+ 'headers' => $this->getRequestHeaders()
+ ]);
+ }
+
+ // Clear stored credentials
+ $this->credentials = [
+ 'client_id' => $this->credentials['client_id'] ?? '',
+ 'client_secret' => $this->credentials['client_secret'] ?? ''
+ ];
+
+ $this->saveCredentials($this->credentials);
+ $this->clearCache();
+
+ return [
+ 'success' => true,
+ 'message' => 'Successfully disconnected from Square'
+ ];
+ } catch (Exception $e) {
+ return [
+ 'success' => false,
+ 'message' => 'Failed to disconnect: ' . $e->getMessage()
+ ];
+ }
+ }
+
+ /**
+ * Register additional WordPress hooks
+ */
+ protected function registerAdditionalHooks(): void
+ {
+ if (!$this->isSetUp()) {
+ error_log('Square is not setup');
+ return;
+ }
+
+ add_action('wp_enqueue_scripts', [$this, 'enqueueScripts']);
+
+ add_filter('jvbAdditionalActions', [Checkout::class, 'render']);
+
+ add_filter('jvb_checkout_description', function (string $desc, string $provider) {
+ if ($provider === 'square') {
+ return 'Securely checkout with your name, email, and payments processed by Square.';
+ }
+ return $desc;
+ }, 10, 2);
+
+ // Square-specific pickup fields (extracted from old outputCheckout)
+ add_filter('jvb_checkout_fields', [$this, 'addPickupFields'], 10, 2);
+
+ // Browse URL for this client (restaurant menu)
+ add_filter('jvb_checkout_browse_url', function () {
+ return get_post_type_archive_link(BASE . 'menu_item');
+ });
+ add_filter('jvb_checkout_browse_text', function () {
+ return 'browse our menu';
+ });
+ }
+
+ /**
+ * Pickup/ordering fields for the shared checkout form.
+ * Specific to this Square client's food ordering use case.
+ */
+ public function addPickupFields(string $html, string $provider): string
+ {
+ if ($provider !== 'square') {
+ return $html;
+ }
+
+ return $html
+ . '<h3>Pickup Details</h3>'
+ . Form::render('pickup_time', null, [
+ 'type' => 'datetime',
+ 'label' => 'Pickup Time',
+ 'min' => '11:00',
+ 'max' => '20:00',
+ 'required' => true,
+ ])
+ . Form::render('special_instructions', null, [
+ 'type' => 'textarea',
+ 'label' => 'Special Instructions',
+ 'quill' => true,
+ ]);
+ }
+
+ protected function registerAdditionalQueueTypes(IntegrationExecutor $executor): void
+ {
+ $queue = JVB()->queue();
+
+ $queue->registry()->register(self::$syncCustomer, new TypeConfig(
+ executor: $executor,
+ maxRetries: 2
+ ));
+
+ $queue->registry()->register(self::$import, new TypeConfig(
+ executor: $executor,
+ maxRetries: 3
+ ));
+ }
+
+ /******************************************************************
+ * POST SYNC METHODS
+ ******************************************************************/
+
+ /**
+ * Handle post save for Square sync
+ */
+ protected function handleTheSavePost(int $postID, WP_Post $post, bool $update, array $settings): void
+ {
+ error_log('==== [Square]::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 = $this->getServiceItemID($postID);
+
+ if (empty($item_id)) {
+ return;
+ }
+ $this->queueOperation(self::$deleteFrom, [
+ 'external_ids' => [$item_id],
+ 'post_id' => $postID,
+ ], [
+ 'priority' => 'high',
+ ]);
+
+ }
+
+ /**
+ * Process sync to Square
+ */
+ private function processSyncToSquare(array $data): array
+ {
+ $items = $data['items'] ?? [];
+ $success = [];
+ $errors = [];
+
+ if (empty($items)) {
+ return [
+ 'success' => true,
+ 'result' => [
+ 'synced' => [],
+ 'errors' => ['No items to sync']
+ ]
+ ];
+ }
+
+ //Check images are already uploaded first
+ $image_mappings = $this->batchUploadImages($items);
+
+ $catalog = [];
+ $map = [];
+ foreach ($items as $postID) {
+ $post = get_post($postID);
+ if (!$post) {
+ $errors[] = "Post $postID not found";
+ continue;
+ }
+
+ $square_image_id = $image_mappings[$postID] ?? null;
+ $catalog_object = $this->buildCatalogObject($postID, $square_image_id);
+ if (is_wp_error($catalog_object)) {
+ $errors[] = $catalog_object->get_error_message();
+ continue;
+ }
+ $map[$catalog_object['id']] = $postID;
+ $catalog[] = $catalog_object;
+ }
+
+ if (!empty($catalog)) {
+ $response = $this->postRequest('catalog/batch-upsert', [
+ 'idempotency_key' => wp_generate_uuid4(),
+ 'batches' => [[
+ 'objects' => $catalog
+ ]]
+ ]);
+
+ if (!is_wp_error($response)) {
+ $this->processBatchSyncResponse($response, $map, $success, $errors);
+ } else {
+ // Handle batch request failure
+ $error_message = 'Batch sync failed';
+ $this->logError($error_message, [
+ 'method' => 'processSyncToSquare',
+ 'post_ids' => $items,
+ 'error' => $response
+ ]);
+
+ // Mark all items as failed
+ foreach ($items as $postID) {
+ if (get_post($postID)) { // Only if post exists
+ $errors[] = "Failed to sync post $postID";
+ update_post_meta($postID, BASE . '_square_sync_status', 'error');
+ }
+ }
+ }
+ }
+
+ return [
+ 'success' => count($success) > 0,
+ 'result' => [
+ 'synced' => $success,
+ 'errors' => $errors
+ ]
+ ];
+ }
+
+ private function processBatchSyncResponse(array $response, array $map, array &$success, array &$errors):void
+ {
+ error_log('==== SQUARE::processBatchSyncResponse =====');
+ error_log('Full response: '.print_r($response, true));
+
+ // Handle successful objects
+ if (!empty($response['objects'])) {
+ foreach ($response['objects'] as $object) {
+ $object_id = $object['id'];
+ $post_id = $map[$object_id] ?? null;
+
+ if (!$post_id) {
+ // Try to find post ID from the object ID pattern
+ if (preg_match('/#\w+_(\d+)/', $object_id, $matches)) {
+ $post_id = (int)$matches[1];
+ }
+ }
+
+ if ($post_id && get_post($post_id)) {
+ // Update post meta with Square data
+ update_post_meta($post_id, BASE . '_square_catalog_id', $object['id']);
+ update_post_meta($post_id, BASE . '_square_sync_status', 'synced');
+ update_post_meta($post_id, BASE . '_square_last_sync', current_time('mysql'));
+
+ // Save variation IDs if present
+ if (!empty($object['item_data']['variations'])) {
+ foreach ($object['item_data']['variations'] as $index => $variation) {
+ update_post_meta($post_id, BASE . '_square_variation_' . $index . '_id', $variation['id']);
+ }
+ }
+
+ $success[] = $post_id;
+ }
+ }
+ }
+
+ // Handle errors
+ if (!empty($response['errors'])) {
+ foreach ($response['errors'] as $error) {
+ $error_detail = $error['detail'] ?? 'Unknown error';
+ $error_field = $error['field'] ?? '';
+
+ // Try to extract post ID from error field if possible
+ $post_id = null;
+ if (preg_match('/batches\[0\]\.objects\[(\d+)\]/', $error_field, $matches)) {
+ $object_index = (int)$matches[1];
+ // Get post ID from the object at this index
+ if (isset($catalog_objects[$object_index])) {
+ $object_id = $catalog_objects[$object_index]['id'];
+ $post_id = $map[$object_id] ?? null;
+ }
+ }
+
+ $error_message = $post_id
+ ? "Post $post_id: $error_detail"
+ : "Batch error: $error_detail";
+
+ $errors[] = $error_message;
+
+ if ($post_id) {
+ update_post_meta($post_id, BASE . '_square_sync_status', 'error');
+ }
+ }
+ }
+
+ // Handle any posts that weren't in the response (shouldn't happen but good to check)
+ foreach ($map as $object_id => $post_id) {
+ if (!in_array($post_id, $success)) {
+ $found_in_errors = false;
+ foreach ($errors as $error) {
+ if (str_contains($error, "Post $post_id")) {
+ $found_in_errors = true;
+ break;
+ }
+ }
+
+ if (!$found_in_errors) {
+ $errors[] = "Post $post_id: No response from Square";
+ update_post_meta($post_id, BASE . '_square_sync_status', 'error');
+ }
+ }
+ }
+ }
+
+ /**
+ * Build catalog object from WordPress post
+ *
+ * @param int $postID WordPress post ID
+ * @param string|null $square_image_id Previously uploaded Square image ID
+ * @return array|WP_Error Catalog object or error
+ */
+ //TODO: Get to work with Registrar settings
+ protected function buildCatalogObject(int $postID, ?string $square_image_id = null): array|WP_Error
+ {
+ $post = get_post($postID);
+ if (!$post) {
+ return new WP_Error('post_not_found', "Post $postID not found");
+ }
+
+ $meta = Meta::forPost($postID);
+ $post_type = get_post_type($postID);
+
+ // Get existing Square catalog ID if it exists
+ $existing_square_id = get_post_meta($postID, BASE . '_square_catalog_id', true);
+ $registrar = Registrar::getInstance($post_type);
+ $product_type = 'FOOD_AND_BEV';
+ if ($registrar) {
+ $conf = $registrar->getIntegration($this->service_name);
+ if ($conf) {
+ $product_type = $conf->getContentType();
+ }
+ }
+
+ // Build the base catalog object
+ $catalog_object = [
+ 'type' => 'ITEM',
+ 'id' => $existing_square_id ?: '#menu_item_item_' . $postID,
+ 'item_data' => [
+ 'name' => $post->post_title,
+ 'description' => wp_strip_all_tags($post->post_content),
+ 'product_type' => $product_type,
+ 'variations' => [],
+ 'is_taxable' => true,
+ ]
+ ];
+
+ // Add image ID if provided (must be already uploaded to Square)
+ if (!empty($square_image_id)) {
+ $catalog_object['item_data']['image_ids'] = [$square_image_id];
+ }
+
+ // Add variations
+ $variations = $meta->get('_square_product_variations');
+ if (empty($variations)) {
+ // Create default variation if none exist
+ $catalog_object['item_data']['variations'][] = [
+ 'type' => 'ITEM_VARIATION',
+ 'id' => $existing_square_id ? null : '#'.BASE.'menu_item_' . $postID . '_var_default',
+ 'item_variation_data' => [
+ 'name' => 'Regular',
+ 'ordinal' => 0,
+ 'pricing_type' => 'FIXED_PRICING',
+ 'price_money' => [
+ 'amount' => $this->formatPrice($meta->get('_square_price')),
+ 'currency' => $this->getCurrency()
+ ],
+ 'sellable' => true,
+ 'stockable' => true
+ ]
+ ];
+ } else {
+ $resetVariations = false;
+ foreach ($variations as $index => $variation) {
+ $id = '#'.BASE.'menu_item_' . $postID . '_var_' . $index;
+ if (empty($variation['item_id'])) {
+ $resetVariations = true;
+ $variations[$index]['item_id'] = $id;
+ $variation['item_id'] = $id;
+ }
+ $catalog_object['item_data']['variations'][] = [
+ 'type' => 'ITEM_VARIATION',
+ 'id' => $variation['item_id'],
+ 'item_variation_data' => [
+ 'name' => $variation['name'] ?? 'Variation ' . ($index + 1),
+ 'ordinal' => $index,
+ 'pricing_type' => 'FIXED_PRICING',
+ 'price_money' => [
+ 'amount' =>$this->formatPrice($variation['price'] ?? 0),
+ 'currency' => $this->getCurrency()
+ ],
+ 'sellable' => true,
+ 'stockable' => true
+ ]
+ ];
+ }
+ if ($resetVariations) {
+ $meta->set('_square_product_variations', $variations);
+ }
+ }
+
+ // Add categories if they exist
+ $categories = wp_get_post_terms($postID, $post_type . '_category', ['fields' => 'ids']);
+ if (!empty($categories)) {
+ $category_ids = [];
+ foreach ($categories as $term_id) {
+ $square_cat_id = get_term_meta($term_id, BASE . '_square_category_id', true);
+ if ($square_cat_id) {
+ $category_ids[] = $square_cat_id;
+ }
+ }
+ if (!empty($category_ids)) {
+ $catalog_object['item_data']['category_ids'] = $category_ids;
+ }
+ }
+
+ // Add modifiers if they exist
+ $modifiers = $meta->get('modifiers');
+ if (!empty($modifiers)) {
+ $modifier_ids = [];
+ foreach ($modifiers as $modifier) {
+ if (!empty($modifier['square_id'])) {
+ $modifier_ids[] = $modifier['square_id'];
+ }
+ }
+ if (!empty($modifier_ids)) {
+ $catalog_object['item_data']['modifier_list_info'] = array_map(function($id) {
+ return ['modifier_list_id' => $id];
+ }, $modifier_ids);
+ }
+ }
+
+ // Add tax settings
+ $tax_ids = $meta->get('tax_ids');
+ if (!empty($tax_ids)) {
+ $catalog_object['item_data']['tax_ids'] = $tax_ids;
+ }
+
+ return $catalog_object;
+ }
+
+
+
+ /**
+ * Get variation mapping for post type
+ */
+ protected function getVariationMapping(string $post_type): array
+ {
+ $registrar = Registrar::getInstance($post_type);
+ if (!$registrar) {
+ return [];
+ }
+ $config = $registrar->getIntegrationConfig($this->service_name);
+ $product_type = $config['content_type']??'REGULAR';
+ $valid_fields = $this->getValidFieldsForProductType($product_type);
+
+ $defaults = [
+ 'name' => 'name',
+ 'id' => '_square_item_id',
+ 'sku' => '_square_sku',
+ 'price' => '_square_price',
+ 'track_inventory' => '_square_track_inventory',
+ 'service_duration' => '_square_service_duration',
+ 'available_for_booking' => '_square_available_for_booking',
+ 'gift_card_type' => '_square_gift_card_type',
+ 'ingredients' => '_square_ingredients',
+ 'preparation_time_duration' => '_square_preparation_time_duration'
+ ];
+
+ $extended = apply_filters(
+ BASE . $this->service_name . '_variation_mapping',
+ $defaults,
+ $post_type,
+ $this
+ );
+
+ return array_filter($extended, function($key) use ($valid_fields) {
+ return in_array($key, $valid_fields);
+ }, ARRAY_FILTER_USE_KEY);
+ }
+
+
+
+ /**
+ * Get or create Square category
+ */
+ private function getOrCreateSquareCategory(string $name): ?string
+ {
+ // Check cached mapping
+ $cached_id = get_option(BASE . 'square_category_' . sanitize_title($name));
+ if ($cached_id) {
+ return $cached_id;
+ }
+
+ // Create new category
+ $response = $this->postRequest('catalog/batch-upsert', [
+ 'idempotency_key' => wp_generate_uuid4(),
+ 'batches' => [[
+ 'objects' => [[
+ 'type' => 'CATEGORY',
+ 'id' => '#category_' . sanitize_title($name),
+ 'category_data' => [
+ 'name' => $name
+ ]
+ ]]
+ ]]
+ ]);
+
+ if (!is_wp_error($response) && !empty($response['objects'][0]['id'])) {
+ $category_id = $response['objects'][0]['id'];
+ update_option(BASE . 'square_category_' . sanitize_title($name), $category_id);
+ return $category_id;
+ }
+
+ return null;
+ }
+
+ /**
+ * Get field mapping for post type
+ */
+ protected function getFieldMapping(string $post_type): array
+ {
+ $registrar = Registrar::getInstance($post_type);
+ if (!$registrar) {
+ return [];
+ }
+ $config = $registrar->getIntegrationConfig($this->service_name);
+ $product_type = $config['content_type']??'REGULAR';
+ $valid_fields = $this->getValidFieldsForProductType($product_type);
+
+ $defaults = [
+ 'name' => 'post_title',
+ 'description_html' => 'post_content',
+ 'abbreviation' => 'abbreviation',
+ 'id' => '_square_catalog_id',
+ 'sku' => '_square_sku',
+ 'category_id' => 'category',
+ 'image_ids' => 'post_thumbnail',
+ 'price' => '_square_price',
+ 'tax_ids' => 'tax_ids',
+ // Availability
+ 'available_online' => '_square_available_online',
+ 'available_for_pickup' => '_square_available_for_pickup',
+ 'available_electronically' => '_square_available_electronically',
+ // Modifiers
+ 'modifier_list_info' => 'modifiers',
+ // Item options
+ 'item_options' => 'options',
+ // Reporting
+ 'reporting_category' => 'reporting_category',
+ ];
+
+ $extended = apply_filters(
+ BASE . $this->service_name . '_field_mapping',
+ $defaults,
+ $post_type,
+ $this
+ );
+
+ return array_filter($extended, function($key) use ($valid_fields) {
+ return in_array($key, $valid_fields);
+ }, ARRAY_FILTER_USE_KEY);
+ }
+
+ /**
+ * Get valid fields for Square product type
+ */
+ //TODO: This feels redundant now, with how we've defined fields in getAdditionalFields
+ private function getValidFieldsForProductType(string $product_type): array
+ {
+ $fields = ['name', 'description_html', 'sku', 'price', 'image_ids', 'category_id'];
+
+ $type_specific = [
+ 'FOOD_AND_BEV' => [
+ 'ingredients',
+ 'preparation_time_duration',
+ 'dietary_preferences',
+ 'calories_text'
+ ],
+ 'APPOINTMENTS_SERVICE' => [
+ 'service_duration',
+ 'available_for_booking',
+ 'team_member_ids',
+ 'booking_availability'
+ ],
+ 'EVENT' => [
+ 'start_date',
+ 'end_date',
+ 'venue_details',
+ 'capacity'
+ ],
+ 'GIFT_CARD' => [
+ 'gift_card_type',
+ 'allowed_locations'
+ ]
+ ];
+
+ if (isset($type_specific[$product_type])) {
+ $fields = array_merge($fields, $type_specific[$product_type]);
+ }
+
+ return $fields;
+ }
+
+ /******************************************************************
+ * CUSTOMER MANAGEMENT
+ ******************************************************************/
+
+ /**
+ * Handle customer authentication during checkout
+ */
+ //TODO: Is this necessary?
+ public function handleCustomerAuth($data):WP_Error|array
+ {
+ $email = sanitize_email($data['email'] ?? '');
+ $action = sanitize_text_field($data['action_type'] ?? 'check');
+
+ if (!$email) {
+ return new WP_Error('error', 'Email required');
+ }
+
+ switch ($action) {
+ case 'check':
+ return $this->checkCustomerExists($email);
+ case 'create':
+ return $this->createCustomerAccount($email);
+ default:
+ $this->logError('No action set for customer auth: '.$action, [
+ 'method' => 'handleCustomerAuth'
+ ]);
+ return new WP_Error('error', 'No action set for customer auth');
+ }
+
+ }
+
+
+
+ /**
+ * Check if customer exists
+ */
+ private function checkCustomerExists(string $email):WP_Error|array
+ {
+ // Check WordPress user
+ $user = get_user_by('email', $email);
+
+ if ($user) {
+ // Check if user has Square customer integration
+ $square_customer_id = get_user_meta($user->ID, BASE . '_square_customer_id', true);
+
+ if ($square_customer_id) {
+ return [
+ 'exists' => true,
+ 'has_account' => true,
+ 'message' => 'Account found. Please enter your password to continue'
+ ];
+ } else {
+ return [
+ 'exists' => true,
+ 'has_account' => false,
+ 'message' => 'Email found. Would you like to create an account to save your order history?'
+ ];
+ }
+ }
+
+ // Check Square for customer
+ $response = $this->postRequest('customers/search', [
+ 'filter' => [
+ 'email_address' => [
+ 'exact' => $email
+ ]
+ ]
+ ]);
+
+ if (!is_wp_error($response) && !empty($response['customers'])) {
+ return [
+ 'exists' => true,
+ 'has_account' => false,
+ 'square_only' => true,
+ 'message' => 'Found your previous orders. Create an account to access them?'
+ ];
+ } else {
+ return [
+ 'exists' => false,
+ 'message' => 'New customer'
+ ];
+ }
+ }
+
+ private function createCustomerAccount(string $email):WP_Error|array
+ {
+ // Validate email format
+ if (!is_email($email)) {
+ return new WP_Error('error', 'Invalid email address');
+ }
+
+ // Check if user already exists
+ if (email_exists($email)) {
+ return [
+ 'success' => false,
+ 'exists' => true,
+ 'message' => 'An account with this email already exists. Please log in instead.'
+ ];
+ }
+
+ // Create user account without password (they'll set it via email)
+ $user_id = wp_create_user(
+ $email,
+ wp_generate_password(20, true, true), // Temporary random password
+ $email
+ );
+
+ if (is_wp_error($user_id)) {
+ $this->logError('Failed to create customer account', [
+ 'email' => $email,
+ 'error' => $user_id->get_error_message()
+ ]);
+ return new WP_Error('error', 'Failed to create account. Please try again.');
+ }
+
+ // Set user role (assuming you have a customer role defined)
+ $user = new WP_User($user_id);
+ $user->set_role(BASE.'foodie');
+
+ // Generate password reset key
+ $reset_key = get_password_reset_key($user);
+
+ if (is_wp_error($reset_key)) {
+ return new WP_Error('error', 'Account created, but couldn\'t send email. Please use password reset.');
+ }
+
+
+ // Send welcome email with password setup link
+ $this->sendWelcomeEmail($user, $reset_key);
+
+ // Link to Square customer if exists
+ $square_customer_id = $this->getOrCreateSquareCustomer([
+ 'email' => $email,
+ 'name' => $email
+ ]);
+
+ if ($square_customer_id) {
+ update_user_meta($user_id, BASE . '_square_customer_id', $square_customer_id);
+ }
+
+ return [
+ 'success' => true,
+ 'message' => 'Account created! Check your email to set your password.',
+ 'user_id' => $user_id
+ ];
+ }
+
+ /**
+ * Send welcome email with password setup
+ */
+ private function sendWelcomeEmail(WP_User $user, string $reset_key): void
+ {
+ $site_name = get_bloginfo('name');
+ $reset_url = get_home_url(null, "wp-login.php?action=rp&key=$reset_key&login=" . rawurlencode($user->user_login), 'login');
+
+ $message = sprintf(
+ "Welcome to %s!\n\n" .
+ "Your account has been created. Please click the button below to set your password:\n\n" .
+ "%s\n\n" .
+ "Or, copy and paste the link below:\n\n".
+ "%s\n\n" .
+ "Once you've set your password, you can:\n" .
+ "- View your order history\n" .
+// "- Save your favorite items\n" .
+ "- Speed up checkout with saved payment methods\n\n" .
+ "If you didn't create this account, please ignore this email.\n\n" .
+ "Thanks,\n",
+ $site_name,
+ JVB()->email()->button('Reset Password', $reset_url),
+ JVB()->email()->link($reset_url),
+ );
+
+ JVB()->email()->sendEmail(
+ $user->user_email,
+ sprintf('[%s] Welcome! Set Your Password', $site_name),
+ $message
+ );
+ }
+
+ /******************************************************************
+ * WEBHOOK HANDLING
+ ******************************************************************/
+
+ /**
+ * Validate webhook signature
+ */
+ protected function validateWebhook(array $payload): bool
+ {
+ // Get signature from headers
+ $signature = $_SERVER['HTTP_X_SQUARE_SIGNATURE'] ?? '';
+
+ // If no signature provided by Square, validation fails
+ if (empty($signature)) {
+ $this->logDebug('No webhook signature provided by Square');
+ return false;
+ }
+
+ // If webhook signature key is not configured, we can't validate
+ // but we might want to process the webhook anyway (less secure)
+ if (empty($this->webhook_signature_key)) {
+ $this->logDebug('Webhook signature key not configured - processing without validation');
+ // You might want to return false here for stricter security
+ // return false;
+ return true; // Process webhook but log that it's unverified
+ }
+
+ // Get the raw request body
+ $body = file_get_contents('php://input');
+
+ // Calculate expected signature
+ $expected = base64_encode(hash_hmac('sha256', $body, $this->webhook_signature_key, true));
+
+ // Use timing-safe comparison
+ $is_valid = hash_equals($expected, $signature);
+
+ if (!$is_valid) {
+ $this->logError('Invalid webhook signature', [
+ 'provided' => substr($signature, 0, 10) . '...',
+ 'expected' => substr($expected, 0, 10) . '...'
+ ]);
+ }
+
+ return $is_valid;
+ }
+
+ /**
+ * Process webhook event
+ */
+ protected function processWebhook(array $payload): bool
+ {
+ $event_type = $payload['type'] ?? '';
+ $data = $payload['data'] ?? [];
+
+ switch ($event_type) {
+ case 'payment.created':
+ case 'payment.updated':
+ return $this->handlePaymentWebhook($data);
+
+ case 'order.created':
+ case 'order.updated':
+ case 'order.fulfillment.updated':
+ return $this->handleOrderWebhook($data);
+
+ case 'catalog.version.updated':
+ return $this->handleCatalogWebhook($data);
+
+ case 'customer.created':
+ case 'customer.updated':
+ return $this->handleCustomerWebhook($data);
+
+ default:
+ $this->logDebug('Unhandled webhook type', ['type' => $event_type]);
+ return true;
+ }
+ }
+
+ /**
+ * Handle order status webhook
+ */
+ private function handleOrderWebhook(array $data): bool
+ {
+ $order_id = $data['object']['order']['id'] ?? '';
+ $state = $data['object']['order']['state'] ?? '';
+ $fulfillments = $data['object']['order']['fulfillments'] ?? [];
+
+ if (!$order_id) {
+ return false;
+ }
+
+ // Find the WP post for this order
+ $wp_order_id = $this->getOrderPost($order_id);
+
+ if ($wp_order_id) {
+ // Update the post meta
+ $meta = Meta::forPost($wp_order_id);
+ $updates = [
+ 'status' => $state,
+ 'updated_at' => current_time('mysql')
+ ];
+
+ // Extract fulfillment status and pickup time
+ if (!empty($fulfillments[0])) {
+ $fulfillment = $fulfillments[0];
+ $updates['fulfillment_status'] = $fulfillment['state'] ?? $state;
+
+ if (!empty($fulfillment['pickup_details']['pickup_at'])) {
+ $updates['pickup_time'] = $fulfillment['pickup_details']['pickup_at'];
+ }
+ }
+
+ $meta->setAll($updates);
+
+ // Trigger notification to customer if order is ready
+ if ($state === 'PREPARED') {
+ do_action(BASE . 'square_order_ready', $wp_order_id, $order_id);
+ }
+ }
+ // Trigger action for other integrations
+ do_action(BASE . 'square_order_updated', $order_id, $state, $data);
+
+ return true;
+ }
+
+ /******************************************************************
+ * CONNECTION TESTING
+ ******************************************************************/
+ protected function performConnectionTest(): bool
+ {
+ try {
+ $response = $this->getRequest('/v2/merchants/me');
+ if (is_wp_error($response)) {
+ $this->logError('Connection test failed', ['error' => $response->get_error_message()]);
+ return false;
+ }
+
+ // Check if we got valid merchant data
+ return !empty($response['merchant']);
+ } catch (Exception $e) {
+ $this->logError('Connection test failed', ['error' => $e->getMessage()]);
+ return false;
+ }
+ }
+
+ /**
+ * Check OAuth connection status
+ */
+ protected function checkOAuthStatus(): array
+ {
+ if (!$this->hasOAuthCredentials()) {
+ return [
+ 'success' => false,
+ 'message' => 'OAuth not configured. Please authorize with Square.',
+ 'authorized' => false
+ ];
+ }
+
+ // Test the connection
+ if ($this->performConnectionTest()) {
+ $merchant = $this->getRequest('merchants/me');
+ return [
+ 'success' => true,
+ 'message' => 'Successfully connected to Square',
+ 'authorized' => true,
+ 'merchant' => $merchant['merchant'] ?? []
+ ];
+ }
+
+ return [
+ 'success' => false,
+ 'message' => 'OAuth token may be expired. Please re-authorize.',
+ 'authorized' => false
+ ];
+ }
+
+ /******************************************************************
+ * ADMIN UI
+ ******************************************************************/
+ /**
+ * Enqueue checkout scripts with Square configuration
+ */
+ public function enqueueScripts(): void
+ {
+ jvbInlineStyles('forms');
+ $this->loadCredentials();
+ $sdk_url = $this->environment === 'production'
+ ? 'https://web.squarecdn.com/v1/square.js'
+ : 'https://sandbox.web.squarecdn.com/v1/square.js';
+
+ wp_enqueue_script(
+ 'square-payments-sdk',
+ $sdk_url,
+ [],
+ null,
+ ['strategy' => 'defer', 'in_footer' => true]
+ );
+
+ // Shared cart checkout base class
+ 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', 'jvb-login'],
+ '1.1.32',
+ ['strategy' => 'defer', 'in_footer' => true]
+ );
+
+ // Square checkout extends CartCheckout
+ wp_register_script(
+ 'jvb-square-checkout',
+ JVB_URL . 'assets/js/min/square.min.js',
+ ['jvb-checkout', 'square-payments-sdk'],
+ '1.1.32',
+ ['strategy' => 'defer', 'in_footer' => true]
+ );
+
+ wp_enqueue_script('jvb-square-checkout');
+
+ wp_localize_script('jvb-square-checkout', 'squareConfig', [
+ 'isOpen' => jvbIsOpen()?'1':'0',
+ 'application_id' => $this->credentials['client_id'] ?? '',
+ 'location_id' => $this->locationId,
+ 'environment' => $this->environment,
+ 'currency' => $this->getCurrency(),
+ 'is_logged_in' => is_user_logged_in(),
+ 'user_email' => is_user_logged_in() ? wp_get_current_user()->user_email : '',
+ ]);
+ }
+
+ /******************************************************************
+ * HELPER METHODS
+ ******************************************************************/
+
+ /**
+ * Get or create Square customer
+ */
+ public function getOrCreateSquareCustomer(array $customer_info): ?string
+ {
+ if (empty($customer_info['email'])) {
+ return null;
+ }
+
+ // Search for existing customer
+ $search_response = $this->postRequest('customers/search', [
+ 'filter' => [
+ 'email_address' => [
+ 'exact' => $customer_info['email']
+ ]
+ ]
+ ]);
+
+ if (!is_wp_error($search_response) && !empty($search_response['customers'])) {
+ return $search_response['customers'][0]['id'];
+ }
+
+ // Create new customer
+ $create_response = $this->postRequest('customers', [
+ 'given_name' => $customer_info['name'] ?? '',
+ 'email_address' => $customer_info['email'],
+ 'phone_number' => $customer_info['phone'] ?? ''
+ ]);
+
+ if (!is_wp_error($create_response) && !empty($create_response['customer']['id'])) {
+ return $create_response['customer']['id'];
+ }
+
+ return null;
+ }
+
+
+ /**
+ * Process delete from Square
+ */
+ private function processDeleteFromSquare(array $data): array
+ {
+ $square_ids = $data['square_ids'] ?? [];
+
+ if (empty($square_ids)) {
+ return [
+ 'success' => false,
+ 'result' => ['error' => 'No Square IDs provided']
+ ];
+ }
+
+ $response = $this->postRequest('catalog/batch-delete', [
+ 'object_ids' => $square_ids
+ ]);
+
+ if (is_wp_error($response)) {
+ return [
+ 'success' => false,
+ 'result' => ['error' => $response->get_error_message()]
+ ];
+ }
+
+ // Clean up post meta
+ if (!empty($data['post_id'])) {
+ delete_post_meta($data['post_id'], BASE . '_square_catalog_id');
+ delete_post_meta($data['post_id'], BASE . '_square_sync_status');
+ }
+
+ return [
+ 'success' => true,
+ 'result' => ['deleted' => $square_ids]
+ ];
+ }
+
+ /**
+ * Process sync from Square
+ */
+ private function processSyncFromSquare(array $data): array
+ {
+ $cursor = $data['cursor'] ?? null;
+ $types = $data['types'] ?? ['ITEM'];
+
+ $request_data = [
+ 'types' => implode(',', $types),
+ 'limit' => 100
+ ];
+
+ if ($cursor) {
+ $request_data['cursor'] = $cursor;
+ }
+
+ $response = $this->getRequest('catalog/list?' . http_build_query($request_data));
+
+ if (is_wp_error($response)) {
+ return [
+ 'success' => false,
+ 'result' => ['error' => $response->get_error_message()]
+ ];
+ }
+
+ $imported = 0;
+ $errors = [];
+
+ foreach ($response['objects'] ?? [] as $object) {
+ if ($object['type'] === 'ITEM') {
+ $result = $this->importSquareItem($object);
+ if ($result) {
+ $imported++;
+ } else {
+ $errors[] = $object['id'];
+ }
+ }
+ }
+
+ // Queue next batch if cursor exists
+ if (!empty($response['cursor'])) {
+ $this->queueOperation('sync_from_square', [
+ 'cursor' => $response['cursor'],
+ 'types' => $types
+ ]);
+ }
+
+ return [
+ 'success' => true,
+ 'result' => [
+ 'imported' => $imported,
+ 'errors' => $errors,
+ 'has_more' => !empty($response['cursor'])
+ ]
+ ];
+ }
+
+ /**
+ * Import Square item to WordPress
+ */
+ private function importSquareItem(array $item): bool|int
+ {
+ //TODO: We need to add the post type to custom meta for Square, this is not good if we have multiple post types with the same product type
+ // Find matching content type
+ $product_type = $item['item_data']['product_type'] ?? 'REGULAR';
+ $post_type = null;
+
+ foreach (Registrar::getRegistered() as $registrar) {
+ if (!$registrar->hasIntegration($this->service_name)) {
+ continue;
+ }
+ $config = $registrar->getIntegration($this->service_name);
+ if ($config->getContent_type() && $config->getContent_type() === $product_type) {
+ $post_type = jvbCheckBase($registrar->getSlug());
+ break;
+ }
+
+ }
+
+ if (!$post_type) {
+ return false;
+ }
+
+ // Check if item already exists
+ $existing = get_posts([
+ 'post_type' => $post_type,
+ 'meta_key' => BASE . '_square_catalog_id',
+ 'meta_value' => $item['id'],
+ 'posts_per_page' => 1
+ ]);
+
+ $post_data = [
+ 'post_title' => $item['item_data']['name'] ?? '',
+ 'post_content' => $item['item_data']['description'] ?? '',
+ 'post_type' => $post_type,
+ 'post_status' => 'publish'
+ ];
+
+ if (!empty($existing)) {
+ $post_data['ID'] = $existing[0]->ID;
+ $post_id = wp_update_post($post_data);
+ } else {
+ $post_id = wp_insert_post($post_data);
+ }
+
+ if (!$post_id || is_wp_error($post_id)) {
+ return false;
+ }
+
+ // Map and save meta fields
+ $this->mapSquareFieldsToWordPress($post_id, $item);
+
+ return $post_id;
+ }
+
+ /**
+ * Map Square fields to WordPress meta
+ */
+ private function mapSquareFieldsToWordPress(int $post_id, array $item): void
+ {
+ $meta = Meta::forPost($post_id);
+ $field_map = $this->getFieldMapping(get_post_type($post_id));
+
+ $values_to_save = [];
+
+ // Save Square ID
+ $values_to_save['_square_catalog_id'] = $item['id'];
+ $values_to_save['_square_last_sync'] = current_time('mysql');
+
+ // Handle variations if present
+ if (!empty($item['item_data']['variations'])) {
+ $variations_data = [];
+
+ foreach ($item['item_data']['variations'] as $index => $variation) {
+ $var_data = [
+ 'name' => $variation['item_variation_data']['name'] ?? '',
+ 'sku' => $variation['item_variation_data']['_square_sku'] ?? '',
+ ];
+
+ // Extract price
+ if (!empty($variation['item_variation_data']['price_money'])) {
+ $var_data['price'] = $variation['item_variation_data']['price_money']['amount'] / 100;
+ }
+
+ // Extract other variation fields
+ $variation_map = $this->getVariationMapping(get_post_type($post_id));
+ foreach ($variation_map as $square_field => $wp_field) {
+ if (isset($variation['item_variation_data'][$square_field])) {
+ $var_data[$wp_field] = $variation['item_variation_data'][$square_field];
+ }
+ }
+
+ $variations_data[] = $var_data;
+
+ // Save variation Square ID
+ update_post_meta($post_id, BASE . '_square_variation_' . $index . '_id', $variation['id']);
+ }
+
+ $values_to_save['product_variations'] = $variations_data;
+ }
+
+ // Map other fields
+ foreach ($field_map as $square_field => $wp_field) {
+ if ($square_field !== 'price' && isset($item['item_data'][$square_field])) {
+ $values_to_save[$wp_field] = $item['item_data'][$square_field];
+ }
+ }
+
+ // Save all values at once
+ $meta->setAll($values_to_save);
+ }
+
+ /**
+ * Process customer sync
+ */
+ private function processSyncCustomer(array $data): array
+ {
+ $user_id = $data['user_id'] ?? 0;
+
+ if (!$user_id) {
+ return [
+ 'success' => false,
+ 'result' => ['error' => 'No user ID provided']
+ ];
+ }
+
+ $user = get_user_by('ID', $user_id);
+ if (!$user) {
+ return [
+ 'success' => false,
+ 'result' => ['error' => 'User not found']
+ ];
+ }
+
+ // Get or create Square customer
+ $square_customer_id = $this->getOrCreateSquareCustomer([
+ 'email' => $user->user_email,
+ 'name' => $user->display_name
+ ]);
+
+ if ($square_customer_id) {
+ update_user_meta($user_id, BASE . '_square_customer_id', $square_customer_id);
+
+ return [
+ 'success' => true,
+ 'result' => ['customer_id' => $square_customer_id]
+ ];
+ }
+
+ return [
+ 'success' => false,
+ 'result' => ['error' => 'Could not sync customer']
+ ];
+ }
+
+ /**
+ * Handle catalog webhook
+ */
+ private function handleCatalogWebhook(array $data): bool
+ {
+ // Queue sync from Square for updated items
+ $this->queueOperation('import_catalog', [
+ 'types' => ['ITEM'],
+ 'updated_at' => $data['object']['catalog_version']['updated_at'] ?? null
+ ], [
+ 'delay' => 60 // Wait a minute to batch multiple updates
+ ]);
+
+ return true;
+ }
+
+ /**
+ * Handle customer webhook
+ */
+ private function handleCustomerWebhook(array $data): bool
+ {
+ $square_customer_id = $data['object']['customer']['id'] ?? '';
+ $email = $data['object']['customer']['email_address'] ?? '';
+
+ if (!$square_customer_id || !$email) {
+ return false;
+ }
+
+ // Find WordPress user with this Square customer ID
+ $users = get_users([
+ 'meta_key' => BASE . '_square_customer_id',
+ 'meta_value' => $square_customer_id,
+ 'number' => 1
+ ]);
+
+ if (!empty($users)) {
+ // Update user meta with latest Square data
+ $user = $users[0];
+ update_user_meta($user->ID, BASE . '_square_customer_updated', current_time('mysql'));
+
+ // Clear cached customer data
+ $this->cache->forget('square_customer_' . $user->ID);
+ }
+
+ return true;
+ }
+
+ /**
+ * Handle payment webhook
+ */
+ private function handlePaymentWebhook(array $data): bool
+ {
+ $payment_id = $data['object']['payment']['id'] ?? '';
+ $order_id = $data['object']['payment']['order_id'] ?? '';
+ $status = $data['object']['payment']['status'] ?? '';
+
+ if (!$payment_id) {
+ return false;
+ }
+
+ if ($order_id) {
+ $order = $this->getOrderPost($order_id);
+ if ($order) {
+ Meta::forPost($order)->set('square_payment_status', $status);
+ }
+ }
+
+ // Trigger action for other integrations
+ do_action(BASE . 'square_payment_updated', $payment_id, $status, $order_id, $data);
+
+ return true;
+ }
+
+ /**
+ * Get the appropriate API base URL
+ */
+ protected function getApiUrl(string $endpoint, ?string $baseKey = null): string
+ {
+ return rtrim($this->apiBase[$this->environment], '/') . '/' . ltrim($endpoint, '/');
+ }
+
+ /**
+ * Handle importing catalog from Square
+ */
+ protected function handleImportFromSquare(): array
+ {
+ if (!$this->locationId) {
+ return [
+ 'success' => false,
+ 'message' => 'Please select a location first'
+ ];
+ }
+
+ $this->queueOperation('import_catalog', [
+ 'types' => ['ITEM'],
+ 'user_id' => $this->userID
+ ], [
+ 'priority' => 'normal'
+ ]);
+
+ return [
+ 'success' => true,
+ 'message' => 'Import from Square queued'
+ ];
+ }
+ /**
+ * Handle syncing to Square
+ */
+ protected function handleSyncToSquare(): array
+ {
+ if (!$this->locationId) {
+ return [
+ 'success' => false,
+ 'message' => 'Please select a location first'
+ ];
+ }
+
+ $post_types = array_map(function($type) {
+ return jvbCheckBase($type);
+ }, $this->syncPostTypes);
+
+ // Get all posts to sync
+ $posts = get_posts([
+ 'post_type' => $post_types,
+ 'posts_per_page' => -1,
+ 'post_status' => 'publish'
+ ]);
+
+ $post_ids = wp_list_pluck($posts, 'ID');
+
+ // Queue sync operation
+ $this->queueOperation('sync_to_square', [
+ 'items' => $post_ids,
+ 'user_id' => $this->userID
+ ], [
+ 'priority' => 'normal'
+ ]);
+
+ return [
+ 'success' => true,
+ 'message' => sprintf('Queued %d items for sync to Square', count($post_ids))
+ ];
+ }
+ /**
+ * Refresh category mappings from Square
+ */
+ protected function handleRefreshCategories(): array
+ {
+ // Refresh category mappings from Square
+ $response = $this->getRequest('catalog/list?types=CATEGORY');
+
+ if (!is_wp_error($response) && isset($response['objects'])) {
+ $count = 0;
+ foreach ($response['objects'] as $category) {
+ if ($category['type'] === 'CATEGORY') {
+ $name = $category['category_data']['name'] ?? '';
+ if ($name) {
+ update_option(
+ BASE . 'square_category_' . sanitize_title($name),
+ $category['id']
+ );
+ $count++;
+ }
+ }
+ }
+ return [
+ 'success' => true,
+ 'message' => sprintf('Refreshed %d categories', $count),
+ 'count' => $count
+ ];
+ } else {
+ return [
+ 'success' => false,
+ 'message' => 'Failed to refresh categories'
+ ];
+ }
+ }
+ /**
+ * Get available Square locations
+ */
+ protected function handleGetLocations(): array
+ {
+ // Make sure we have an access token
+ if (empty($this->access_token)) {
+ return [
+ 'success' => false,
+ 'message' => 'Access token required to fetch locations'
+ ];
+ }
+
+ // Fetch available Square locations
+ $response = $this->getRequest('locations');
+
+ if (!is_wp_error($response) && isset($response['locations'])) {
+ $locations = array_map(function($loc) {
+ return [
+ 'id' => $loc['id'],
+ 'name' => $loc['name'] ?? 'Unnamed Location',
+ 'address' => $loc['address']['address_line_1'] ?? '',
+ 'status' => $loc['status'] ?? 'ACTIVE'
+ ];
+ }, $response['locations']);
+
+ // Filter to only active locations
+ $active_locations = array_filter($locations, function($loc) {
+ return $loc['status'] === 'ACTIVE';
+ });
+
+ // Format for select field
+ if (isset($_REQUEST['for_select'])) {
+ $options = [];
+ foreach ($active_locations as $location) {
+ $label = $location['name'];
+ if ($location['address']) {
+ $label .= ' - ' . $location['address'];
+ }
+ $options[$location['id']] = $label;
+ }
+
+ return [
+ 'success' => true,
+ 'options' => $options
+ ];
+ }
+
+ return [
+ 'success' => true,
+ 'locations' => array_values($active_locations)
+ ];
+ }
+
+ return [
+ 'success' => false,
+ 'message' => 'Failed to fetch locations. Please check your access token and environment settings.'
+ ];
+ }
+
+ protected function validateCredentials(array $credentials): bool
+ {
+ // For OAuth services, we need different validation based on setup stage
+ if ($this->isOAuthService) {
+ // Stage 1: Initial setup (need app credentials)
+ $hasAppCredentials = !empty($credentials['client_id'])
+ && !empty($credentials['client_secret']);
+
+ if (!$hasAppCredentials) {
+ $this->logError('Missing required Square application credentials', [
+ 'has_app_id' => !empty($credentials['client_id']),
+ 'has_app_secret' => !empty($credentials['client_secret'])
+ ]);
+ return false;
+ }
+
+ // Stage 2: After OAuth (should have access token)
+ // Access token might not exist yet if we're just saving app credentials
+ // before OAuth flow, so we only validate format if it exists
+ if (!empty($credentials['access_token'])) {
+ // Validate token format (Square tokens are long alphanumeric strings)
+ if (!is_string($credentials['access_token']) ||
+ strlen($credentials['access_token']) < 20) {
+ $this->logError('Invalid access token format');
+ return false;
+ }
+
+ // Validate merchant_id if present (should be set after OAuth)
+ if (!empty($credentials['merchant_id'])) {
+ if (!is_string($credentials['merchant_id']) ||
+ strlen($credentials['merchant_id']) < 10) {
+ $this->logError('Invalid merchant ID format');
+ return false;
+ }
+ }
+ }
+
+ // Validate environment setting
+ if (isset($credentials['environment'])) {
+ $validEnvironments = ['sandbox', 'production'];
+ if (!in_array($credentials['environment'], $validEnvironments)) {
+ $this->logError('Invalid environment setting', [
+ 'provided' => $credentials['environment'],
+ 'valid' => $validEnvironments
+ ]);
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ // Fallback for non-OAuth (shouldn't happen for Square, but good practice)
+ return !empty($credentials);
+ }
+
+
+ protected function sanitizeCredentials(array $credentials): array
+ {
+ $sanitized = [];
+
+ // Define which fields should be sanitized and how
+ $text_fields = [
+ 'client_id',
+ 'client_secret',
+ 'access_token',
+ 'refresh_token',
+ 'merchant_id',
+ 'location_id',
+ 'webhook_signature_key'
+ ];
+
+ foreach ($credentials as $key => $value) {
+ // Handle text fields - trim whitespace but preserve case
+ if (in_array($key, $text_fields)) {
+ if (is_string($value)) {
+ $sanitized[$key] = trim($value);
+ } else {
+ $sanitized[$key] = $value;
+ }
+ }
+ // Handle environment field
+ elseif ($key === 'environment') {
+ $sanitized[$key] = sanitize_key($value);
+ }
+ // Handle boolean fields
+ elseif ($key === 'use_sandbox' || str_starts_with($key, 'enable_')) {
+ $sanitized[$key] = (bool) $value;
+ }
+ // Handle numeric fields
+ elseif ($key === 'expires_at' || $key === 'updated_at' || $key === 'expires_in') {
+ $sanitized[$key] = is_numeric($value) ? (int) $value : $value;
+ }
+ // Handle arrays (like locations)
+ elseif (is_array($value)) {
+ $sanitized[$key] = array_map('sanitize_text_field', $value);
+ }
+ // Default sanitization for other string fields
+ else {
+ if (is_string($value)) {
+ $sanitized[$key] = sanitize_text_field($value);
+ } else {
+ $sanitized[$key] = $value;
+ }
+ }
+ }
+
+ return $sanitized;
+ }
+
+ public function getServiceDescription(): string
+ {
+ return "Connect with Square for payment processing, inventory management, and customer synchronization.";
+ }
+
+ public function setContentTypes(): void
+ {
+ $base = $this->setBaseFields();
+ $this->contentTypes = [
+ 'REGULAR' => $base,
+ 'FOOD_AND_BEV' => array_merge($base, $this->setFoodAndBevFields()),
+ 'APPOINTMENTS_SERVICE' => array_merge($this->setAppointmentServiceFields()),
+ 'EVENT' => array_merge($this->setEventFields()),
+ 'GIFT_CARD' => array_merge($this->setGiftCardFields())
+ ];
+ }
+ public function getAdditionalFields(?string $content_type = null):array {
+ if ($content_type === 'customer') {
+ return $this->getCustomerFields();
+ }
+ if ($content_type && array_key_exists($content_type, $this->contentTypes)){
+ $array = $this->contentTypes[$content_type];
+ return array_combine(
+ array_map(fn($k) => '_square_' . $k, array_keys($array)),
+ $array
+ );
+ } else if ($content_type && !array_key_exists($content_type, $this->contentTypes)) {
+ error_log('Could not get default fields for '.$this->service_name.' content type: '.$content_type);
+ return [];
+ }
+ $array = $this->setBaseFields();
+ return array_combine(
+ array_map(fn($k) => '_square_' . $k, array_keys($array)),
+ $array
+ );
+ }
+
+ protected function getCustomerFields():array
+ {
+ return [
+ 'customer_id' => [
+ 'type' => 'text',
+ 'label' => 'Square Customer ID',
+ 'hidden'=> true,
+ 'section'=> 'your-account'
+ ],
+ 'address' => [
+ 'type' => 'group',
+ 'fields' => [
+ 'address_line_1' => [
+ 'type' => 'text',
+ 'label' => 'Address Line 1',
+ 'hint' => 'ex: 6551 111 St NW',
+ 'required' => true,
+ 'section' => 'address'
+ ],
+ 'address_line_2' => [
+ 'type' => 'text',
+ 'label' => 'Address Line 2',
+ 'hint' => 'ex: Unit 2',
+ 'section' => 'address'
+ ],
+ 'locality' => [
+ 'type' => 'text',
+ 'label'=> 'City',
+ 'section' => 'address',
+ 'required' => true,
+ ],
+ 'administrative_district_level_1' => [
+ 'type' => 'text',
+ 'label' => 'Province',
+ 'hint' => 'The two-character code, example: AB',
+ 'default'=> 'AB',
+ 'section' => 'address',
+ 'required' => true,
+ ],
+ 'postal_code' => [
+ 'type' => 'text',
+ 'label' => 'Postal Code',
+ 'section'=> 'address'
+ ],
+ 'country' => [
+ 'type' => 'text',
+ 'label' => 'Country Code',
+ 'hint' => 'The tw-character country code, example: CA',
+ 'default' => 'CA',
+ 'section' => 'address',
+ 'required' => true,
+ ],
+ 'first_name' => [
+ 'type' => 'text',
+ 'label' => 'First Name (if different)',
+ 'hint' => 'Optional field to save the address to someone that isn\'t you'
+ ],
+ 'last_name' => [
+ 'type' => 'text',
+ 'label' => 'Last Name (if different)',
+ 'hint' => 'Optional field to save the address to someone that isn\'t you'
+ ]
+ ]
+ ]
+
+ ];
+ }
+ protected function setBaseFields():array
+ {
+ return [
+ 'price' => [
+ 'type' => 'number',
+ 'label' => 'Price',
+ 'step' => 0.01,
+ 'max' => 99999,
+ 'description' => 'Price for this variation'
+ ],
+ 'sku' => [
+ 'type' => 'text',
+ 'label' => 'SKU',
+ 'description' => 'Stock keeping unit'
+ ],
+ 'cart_quantity' => [
+ 'type' => 'number',
+ 'label' => 'Quantity',
+ 'hidden' => true,
+ ],
+ 'available_for_pickup' => [
+ 'type' => 'true_false',
+ 'label' => 'Available for Pick Up',
+ 'section'=> 'square-advanced'
+ ],
+ 'available_online' => [
+ 'type' => 'true_false',
+ 'label' => 'Available online',
+ 'section'=> 'square-advanced'
+ ],
+ 'product_variations' => [
+ 'type' => 'repeater',
+ 'label' => 'Product Variations',
+ 'description' => 'Different versions of this product (sizes, colors, etc.)',
+ 'add_label' => 'Add Variation',
+ 'section' => 'variations',
+ 'fields' => $this->setVariationFields()
+ ],
+ ];
+ }
+
+ protected function setVariationFields(?string $type = null):array
+ {
+ $fields = [
+ 'name' => [
+ 'type' => 'text',
+ 'label' => 'Variation Name',
+ 'description' => 'e.g., "Small", "Large", "Red", etc.'
+ ],
+ 'sku' => [
+ 'type' => 'text',
+ 'label' => 'SKU',
+ 'description' => 'Stock keeping unit'
+ ],
+ 'price' => [
+ 'type' => 'number',
+ 'label' => 'Price',
+ 'step' => 0.01,
+ 'max' => 99999,
+ 'description' => 'Price for this variation'
+ ],
+ 'track_inventory' => [
+ 'type' => 'true_false',
+ 'label' => 'Track Inventory',
+ ],
+ 'item_id' => [
+ 'type' => 'text',
+ 'label' => 'Square Variation ID',
+ 'description' => 'Square catalog ID for this variation',
+ 'hidden' => true
+ ],
+ 'last_sync' => [
+ 'type' => 'datetime',
+ 'label' => 'Last Sync',
+ 'hidden' => true
+ ]
+ ];
+
+ switch ($type) {
+ case 'FOOD_AND_BEV':
+ $fields['ingredients'] = [
+ 'type' => 'textarea',
+ 'label' => 'Ingredients List',
+ 'description' => 'Separate ingredients with commas',
+ ];
+ $fields['preparation_time_duration'] = [
+ 'type' => 'number',
+ 'label' => 'Preparation time (in minutes)',
+ ];
+ break;
+ case 'GIFT_CARD':
+ $fields['gift_card_type'] = [
+ 'type' => 'select',
+ 'label' => 'Gift Card Type',
+ 'options' => [
+ 'PHYSICAL' => 'Physical',
+ 'DIGITAL' => 'Digital',
+ ],
+ 'default' => 'DIGITAL',
+ ];
+ break;
+ case 'APPOINTMENTS_SERVICE':
+ $fields['service_duration'] = [
+ 'type' => 'number',
+ 'label' => 'Duration of Service in Minutes'
+ ];
+ $fields['available_for_booking'] = [
+ 'type' => 'true_false',
+ 'label' => 'Available for Booking'
+ ];
+ break;
+ }
+ return array_combine(
+ array_map(fn($k) => '_square_' . $k, array_keys($fields)),
+ $fields
+ );
+ }
+ protected function setFoodAndBevFields():array
+ {
+ return [
+ 'ingredients' => [
+ 'type' => 'textarea',
+ 'label' => 'Ingredients',
+ 'hint' => 'A comma separated list of ingredients'
+ ],
+ 'preparation_time_duration' => [
+ 'label' => 'Preparation Time',
+ 'type' => 'number',
+ 'hint' => 'Preparation time in minutes'
+ ],
+ 'product_variations' => [
+ 'type' => 'repeater',
+ 'label' => 'Product Variations',
+ 'description' => 'Different versions of this product (sizes, colors, etc.)',
+ 'add_label' => 'Add Variation',
+ 'section' => 'variations',
+ 'fields' => $this->setVariationFields('FOOD_AND_BEV')
+ ],
+ ];
+ /* TODO: Set definitions for:
+ 'dietary_preferences' => [
+
+ ],
+ 'calories_text' => [
+
+ ],
+ */
+ }
+ protected function setAppointmentServiceFields():array
+ {
+ return [
+
+ 'product_variations' => [
+ 'type' => 'repeater',
+ 'label' => 'Product Variations',
+ 'description' => 'Different versions of this product (sizes, colors, etc.)',
+ 'add_label' => 'Add Variation',
+ 'section' => 'variations',
+ 'fields' => $this->setVariationFields('APPOINTMENTS_SERVICE')
+ ],
+ ];
+ }
+ protected function setEventFields():array
+ {
+ return [
+ 'venue_details' => [
+ 'type' => 'textarea',
+ 'label' => 'Venue Details',
+ ],
+ 'capacity' => [
+ 'type' => 'number',
+ 'label' => 'Capacity'
+ ],
+ 'product_variations' => [
+ 'type' => 'repeater',
+ 'label' => 'Product Variations',
+ 'description' => 'Different versions of this product (sizes, colors, etc.)',
+ 'add_label' => 'Add Variation',
+ 'section' => 'variations',
+ 'fields' => $this->setVariationFields('EVENT')
+ ],
+ ];
+ }
+ protected function setGiftCardFields():array
+ {
+ return [
+// 'allowed_locations' => [
+//
+// ],
+ 'product_variations' => [
+ 'type' => 'repeater',
+ 'label' => 'Product Variations',
+ 'description' => 'Different versions of this product (sizes, colors, etc.)',
+ 'add_label' => 'Add Variation',
+ 'section' => 'variations',
+ 'fields' => $this->setVariationFields('GIFT_CARD')
+ ],
+ ];
+ }
+
+ /*********************************************************
+ IMAGE PROCESSING
+ *********************************************************/
+ /**
+ * Upload image file to Square
+ *
+ * @param int $imgID WordPress attachment ID
+ * @return array|WP_Error Upload result or error
+ */
+ protected function uploadImageToSquare(int $imgID): array|WP_Error
+ {
+ $supported_image_id = $this->getSupportedImage($imgID);
+
+ // Check if already uploaded
+ $existing_square_image_id = $this->getSquareImageId($supported_image_id);
+ if ($existing_square_image_id) {
+ return [
+ 'success' => true,
+ 'image_id' => $existing_square_image_id
+ ];
+ }
+
+ $file_path = get_attached_file($supported_image_id);
+ if (!file_exists($file_path)) {
+ return new WP_Error('file_not_found', 'Image file not found');
+ }
+
+ // Verify file type
+ $mime_type = get_post_mime_type($supported_image_id);
+ if (!in_array($mime_type, ['image/jpeg', 'image/png', 'image/gif'])) {
+ return new WP_Error('invalid_type', 'Square only supports JPEG, PNG, and GIF images');
+ }
+
+ $image_title = get_the_title($supported_image_id);
+ $alt_text = get_post_meta($supported_image_id, '_wp_attachment_image_alt', true);
+
+ // Build multipart request - SINGLE STEP
+ $boundary = wp_generate_password(24);
+ $headers = $this->getRequestHeaders();
+ $headers['Content-Type'] = 'multipart/form-data; boundary=' . $boundary;
+
+ // Request JSON part
+ $request_json = [
+ 'idempotency_key' => wp_generate_uuid4(),
+ 'image' => [
+ 'type' => 'IMAGE',
+ 'id' => '#IMAGE_' . $supported_image_id . '_' . time(),
+ 'image_data' => [
+ 'name' => $image_title ?: 'Image',
+ 'caption' => $alt_text ?: ''
+ ]
+ ],
+ 'object_id' => $supported_image_id
+ ];
+
+ $body = $this->buildMultipartBody($file_path, $request_json, $boundary);
+
+ $response = wp_remote_post(
+ $this->getApiUrl('v2/catalog/images'),
+ [
+ 'headers' => $headers,
+ 'body' => $body,
+ 'timeout' => 60
+ ]
+ );
+
+ if (is_wp_error($response)) {
+ return $response;
+ }
+
+ $data = json_decode(wp_remote_retrieve_body($response), true);
+
+ if (!empty($data['errors'])) {
+ return new WP_Error('upload_error', $data['errors'][0]['detail'] ?? 'Unknown error');
+ }
+
+ if (!empty($data['image']['id'])) {
+ $this->setSquareImageId($supported_image_id, $data['image']['id']);
+ return [
+ 'success' => true,
+ 'image_id' => $data['image']['id']
+ ];
+ }
+
+ return new WP_Error('upload_failed', 'Failed to upload image');
+ }
+
+ protected function buildMultipartBody(string $file_path, array $request_json, string $boundary): string
+ {
+ $eol = "\r\n";
+ $body = '';
+
+ // Add request JSON part
+ $body .= '--' . $boundary . $eol;
+ $body .= 'Content-Disposition: form-data; name="request"' . $eol;
+ $body .= 'Content-Type: application/json' . $eol . $eol;
+ $body .= json_encode($request_json) . $eol;
+
+ // Add image file part
+ $filename = basename($file_path);
+ $file_contents = file_get_contents($file_path);
+ $mime_type = mime_content_type($file_path);
+
+ $body .= '--' . $boundary . $eol;
+ $body .= 'Content-Disposition: form-data; name="file"; filename="' . $filename . '"' . $eol;
+ $body .= 'Content-Type: ' . $mime_type . $eol . $eol;
+ $body .= $file_contents . $eol;
+ $body .= '--' . $boundary . '--' . $eol;
+
+ return $body;
+ }
+
+ /**
+ * Attach image to catalog item
+ *
+ * @param int $postID WordPress post ID
+ * @param string $square_image_id Square image ID
+ * @return array|WP_Error Result or error
+ */
+ public function attachImageToCatalogItem(int $postID, string $square_image_id): array|WP_Error
+ {
+ $square_catalog_id = get_post_meta($postID, BASE . '_square_catalog_id', true);
+
+ if (!$square_catalog_id) {
+ return new WP_Error('no_catalog_id', 'Post has not been synced to Square');
+ }
+
+ // Get the current catalog item
+ $response = $this->getRequest('catalog/object/' . $square_catalog_id);
+
+ if (is_wp_error($response)) {
+ return $response;
+ }
+
+ if (empty($response['object'])) {
+ return new WP_Error('item_not_found', 'Catalog item not found in Square');
+ }
+
+ $catalog_object = $response['object'];
+
+ // Add image to item data
+ $catalog_object['item_data']['image_ids'] = [$square_image_id];
+
+ // Update the catalog item
+ $update_response = $this->postRequest('catalog/batch-upsert', [
+ 'idempotency_key' => wp_generate_uuid4(),
+ 'batches' => [[
+ 'objects' => [$catalog_object]
+ ]]
+ ]);
+
+ if (is_wp_error($update_response)) {
+ return $update_response;
+ }
+
+ return [
+ 'success' => true,
+ 'message' => 'Image attached to catalog item'
+ ];
+ }
+
+ /**
+ * Get stored Square image ID for an attachment
+ *
+ * @param int $attachment_id WordPress attachment ID
+ * @return string|false Square image ID or false if not found
+ */
+ public function getSquareImageId(int $attachment_id): string|false
+ {
+ $image_id = get_post_meta($attachment_id, BASE . 'square_image_id', true);
+
+ if ($image_id) {
+ // Verify it still exists in Square
+ return $this->verifySquareImage($image_id) ? $image_id : false;
+ }
+
+ return false;
+ }
+
+ /**
+ * Store Square image ID for an attachment
+ *
+ * @param int $attachment_id WordPress attachment ID
+ * @param string $square_image_id Square image ID
+ * @return bool Success
+ */
+ public function setSquareImageId(int $attachment_id, string $square_image_id): bool
+ {
+ return update_post_meta($attachment_id, BASE . 'square_image_id', $square_image_id);
+ }
+
+ /**
+ * Verify image exists in Square catalog
+ *
+ * @param string $square_image_id Square image ID
+ * @return bool Whether image exists
+ */
+ protected function verifySquareImage(string $square_image_id): bool
+ {
+ $response = $this->getRequest('catalog/object/' . $square_image_id);
+
+ if (is_wp_error($response)) {
+ return false;
+ }
+
+ return !empty($response['object']) && $response['object']['type'] === 'IMAGE';
+ }
+
+ /**
+ * Batch upload images for multiple posts
+ *
+ * @param array $post_ids Array of WordPress post IDs
+ * @return array Results for each post
+ */
+ private function batchUploadImages(array $post_ids): array
+ {
+ $image_mappings = [];
+ $image_objects = [];
+
+ // First pass: collect images that need uploading
+ foreach ($post_ids as $post_id) {
+ $thumbnail_id = get_post_thumbnail_id($post_id);
+ if (!$thumbnail_id) {
+ $image_mappings[$post_id] = null;
+ continue;
+ }
+
+ // Check if already uploaded and still exists in Square
+ $existing_square_id = get_post_meta($thumbnail_id, BASE . '_square_image_id', true);
+ if ($existing_square_id && $this->verifySquareImage($existing_square_id)) {
+ $image_mappings[$post_id] = $existing_square_id;
+ continue;
+ }
+
+ // Get supported image format (handles WebP conversion)
+ $supported_image_id = $this->getSupportedImage($thumbnail_id);
+ if (!$supported_image_id) {
+ $image_mappings[$post_id] = null;
+ continue;
+ }
+
+ // Upload the image to Square using the proper endpoint
+ $square_image_id = $this->uploadSingleImageToSquare($supported_image_id, $post_id);
+
+ if ($square_image_id && !is_wp_error($square_image_id)) {
+ $image_mappings[$post_id] = $square_image_id;
+ // Store the Square image ID for future reference
+ update_post_meta($thumbnail_id, BASE . '_square_image_id', $square_image_id);
+ } else {
+ $this->logError('Failed to upload image for post', [
+ 'post_id' => $post_id,
+ 'thumbnail_id' => $thumbnail_id,
+ 'error' => is_wp_error($square_image_id) ? $square_image_id->get_error_message() : 'Unknown error'
+ ]);
+ $image_mappings[$post_id] = null;
+ }
+ }
+
+ return $image_mappings;
+ }
+
+ /**
+ * Upload a single image to Square using the CreateCatalogImage endpoint
+ *
+ * @param int $attachment_id WordPress attachment ID
+ * @param int $post_id Associated post ID
+ * @return string|WP_Error Square image ID or error
+ */
+ private function uploadSingleImageToSquare(int $attachment_id, int $post_id): string|WP_Error
+ {
+ $file_path = get_attached_file($attachment_id);
+
+ if (!file_exists($file_path)) {
+ return new WP_Error('file_not_found', 'Image file not found');
+ }
+
+ // Verify file type
+ $mime_type = get_post_mime_type($attachment_id);
+ $allowed_types = ['image/jpeg', 'image/png', 'image/gif'];
+
+ if (!in_array($mime_type, $allowed_types)) {
+ return new WP_Error('invalid_type', 'Square only supports JPEG, PNG, and GIF images');
+ }
+
+ // Prepare multipart form data
+ $boundary = wp_generate_password(24);
+ $headers = $this->getRequestHeaders();
+ $headers['Content-Type'] = 'multipart/form-data; boundary=' . $boundary;
+
+ // Build the request body
+ $eol = "\r\n";
+ $body = '';
+
+ // Add request JSON
+ $request_data = [
+ 'idempotency_key' => wp_generate_uuid4(),
+ 'image' => [
+ 'type' => 'IMAGE',
+ 'id' => '#TEMP_IMAGE_' . $attachment_id . '_' . time(),
+ 'image_data' => [
+ 'name' => get_the_title($attachment_id) ?: basename($file_path),
+ 'caption' => get_post_meta($attachment_id, '_wp_attachment_image_alt', true) ?:
+ sprintf('Image for %s', get_the_title($post_id))
+ ]
+ ]
+ ];
+
+ $body .= '--' . $boundary . $eol;
+ $body .= 'Content-Disposition: form-data; name="request"' . $eol;
+ $body .= 'Content-Type: application/json' . $eol . $eol;
+ $body .= json_encode($request_data) . $eol;
+
+ // Add image file
+ $filename = basename($file_path);
+ $file_contents = file_get_contents($file_path);
+
+ $body .= '--' . $boundary . $eol;
+ $body .= 'Content-Disposition: form-data; name="image"; filename="' . $filename . '"' . $eol;
+ $body .= 'Content-Type: ' . $mime_type . $eol . $eol;
+ $body .= $file_contents . $eol;
+
+ // End boundary
+ $body .= '--' . $boundary . '--' . $eol;
+
+ // Make the request to the CreateCatalogImage endpoint
+ $response = wp_remote_post(
+ $this->getApiUrl('catalog/images'),
+ [
+ 'headers' => $headers,
+ 'body' => $body,
+ 'timeout' => 60
+ ]
+ );
+
+ if (is_wp_error($response)) {
+ return $response;
+ }
+
+ $response_body = wp_remote_retrieve_body($response);
+ $data = json_decode($response_body, true);
+
+ if (!empty($data['errors'])) {
+ $error_message = $data['errors'][0]['detail'] ?? 'Unknown error';
+ return new WP_Error('upload_error', $error_message);
+ }
+
+ if (!empty($data['image']['id'])) {
+ $this->logDebug('Successfully uploaded image to Square', [
+ 'post_id' => $post_id,
+ 'attachment_id' => $attachment_id,
+ 'square_image_id' => $data['image']['id']
+ ]);
+ return $data['image']['id'];
+ }
+
+ return new WP_Error('upload_failed', 'Failed to get image ID from Square response');
+ }
+
+ protected function handleApiError(int $code, string $body, string $endpoint): void
+ {
+ parent::handleApiError($code, $body, $endpoint);
+
+ $decoded = json_decode($body, true);
+ $errorCode = $decoded['errors'][0]['code'] ?? '';
+
+ // Handle Square-specific OAuth errors
+ if ($errorCode === 'ACCESS_TOKEN_EXPIRED') {
+ $this->logDebug('Access token expired, attempting refresh');
+ if ($this->refreshOAuthToken()) {
+ // Token refreshed, could retry the request
+ $this->logDebug('Token refreshed successfully');
+ }
+ } elseif ($errorCode === 'UNAUTHORIZED' || $errorCode === 'ACCESS_TOKEN_REVOKED') {
+ $this->logError('Square authorization revoked or invalid', [
+ 'error_code' => $errorCode,
+ 'user_id' => $this->userID
+ ]);
+ // Clear invalid credentials
+ $this->deleteCredentials();
+ }
+ }
+
+ public function createSquareOrder(array $items, ?string $customer_id, array $data): array|WP_Error
+ {
+ // Build line items for Square
+ $line_items = [];
+ foreach ($items as $item) {
+ $line_item = [
+ 'quantity' => (string)$item['quantity'], // MUST be string!
+ ];
+
+ // Use catalog_object_id if available (recommended)
+ if (!empty($item['catalog_object_id'])) {
+ $line_item['catalog_object_id'] = $item['catalog_object_id'];
+ $line_item['catalog_version'] = $item['catalog_version'] ?? null;
+ } else {
+ // Ad-hoc line item (not recommended - no tax/inventory automation)
+ $line_item['name'] = $item['name'];
+ $line_item['base_price_money'] = [
+ 'amount' => (int)$item['_square_price'],
+ 'currency' => $this->getCurrency()
+ ];
+ }
+
+ if (!empty($item['note'])) {
+ $line_item['note'] = $item['note'];
+ }
+
+ $line_items[] = $line_item;
+ }
+
+ $order_data = [
+ 'idempotency_key' => wp_generate_uuid4(), // Different from payment idempotency key
+ 'order' => [
+ 'location_id' => $this->locationId,
+ 'line_items' => $line_items,
+ 'state' => 'OPEN'
+ ]
+ ];
+
+ // Add customer if available
+ if ($customer_id) {
+ $order_data['order']['customer_id'] = $customer_id;
+ }
+
+ // Add metadata
+ if (!empty($data['note'])) {
+ $order_data['order']['metadata'] = [
+ 'special_instructions' => $data['note']
+ ];
+ }
+
+ if (!empty($data['pickup_time'])) {
+ $order_data['order']['metadata']['pickup_time'] = $data['pickup_time'];
+ }
+
+ return $this->postRequest('orders', $order_data);
+ }
+
+ public function createSquarePayment(
+ string $source_id,
+ string $idempotency_key,
+ int $amount_cents,
+ string $order_id,
+ ?string $customer_id
+ ): array|WP_Error
+ {
+ $payment_data = [
+ 'idempotency_key' => $idempotency_key,
+ 'source_id' => $source_id,
+ 'amount_money' => [
+ 'amount' => $amount_cents, // Already in cents!
+ 'currency' => $this->getCurrency()
+ ],
+ 'order_id' => $order_id,
+ 'location_id' => $this->locationId,
+ 'autocomplete' => true, // Capture immediately
+ ];
+
+ // Add customer if available
+ if ($customer_id) {
+ $payment_data['customer_id'] = $customer_id;
+ }
+
+ // Add reference ID for tracking
+ $payment_data['reference_id'] = 'WP_' . time();
+
+ return $this->postRequest('payments', $payment_data);
+ }
+
+ public function saveOrderToWordPress(array $order_data): int
+ {
+ // Extract customer info
+ $customer_email = $order_data['customer']['email'] ?? '';
+ $customer_name = $order_data['customer']['name'] ?? '';
+
+ // Find or create WP user for logged-in association
+ $user_id = 0;
+ if ($customer_email) {
+ $user = get_user_by('email', $customer_email);
+ if ($user) {
+ $user_id = $user->ID;
+ // Store Square customer ID on user
+ if (!empty($order_data['square_customer_id'])) {
+ update_user_meta($user_id, BASE . '_square_customer_id', $order_data['square_customer_id']);
+ }
+ }
+ }
+
+ // Create order post
+ $order_post_id = wp_insert_post([
+ 'post_type' => BASE.$this->orderPostType,
+ 'post_title' => 'Order #' . $order_data['square_order_id'],
+ 'post_status' => 'publish',
+ 'post_author' => $user_id // Associate with user if logged in
+ ]);
+
+ if (!$order_post_id || is_wp_error($order_post_id)) {
+ $this->logError('Failed to create order post', ['order_data' => $order_data]);
+ return 0;
+ }
+
+ // Save all order meta
+ $meta = Meta::forPost($order_post_id);
+ $fields = $this->getOrderFields();
+ unset($fields['post_title']);
+
+ $meta->setAll([
+ 'square_order_id' => $order_data['square_order_id'],
+ 'square_payment_id' => $order_data['square_payment_id'] ?? '',
+ 'square_customer_id' => $order_data['square_customer_id'] ?? '',
+ 'amount' => $order_data['amount'],
+ 'status' => $order_data['status'],
+ 'fulfillment_status' => $order_data['fulfillment_status'] ?? 'PROPOSED',
+ 'pickup_time' => $order_data['pickup_time'] ?? '',
+ 'customer_email' => $customer_email,
+ 'customer_name' => $customer_name,
+ 'customer_phone' => $order_data['customer']['phone'] ?? '',
+ 'special_instructions' => $order_data['note'] ?? '',
+ 'items' => $order_data['items'],
+ 'receipt_url' => $order_data['receipt_url'] ?? '',
+ 'created_at' => current_time('mysql'),
+ 'updated_at' => current_time('mysql')
+ ]);
+
+ return $order_post_id;
+ }
+
+ /**
+ * Get currency code
+ */
+ private function getCurrency(): string
+ {
+ return get_option(BASE . 'currency', 'CAD');
+ }
+
+ /**
+ * Get customer with saved cards (2025-compliant)
+ */
+ public function getUserCards(string $customer_id): array
+ {
+ $response = $this->getRequest('cards?customer_id=' . $customer_id);
+ return $response['cards'] ?? [];
+ }
+
+
+ public function getUserOrders(string $customer_email): array
+ {
+ // First get Square customer ID
+ $customer_response = $this->postRequest('customers/search', [
+ 'filter' => [
+ 'email_address' => ['exact' => $customer_email]
+ ]
+ ]);
+
+ $customer_id = $customer_response['customers'][0]['id'] ?? null;
+ if (!$customer_id) {
+ return [];
+ }
+
+ // Get their orders
+ $orders_response = $this->postRequest('orders/search', [
+ 'filter' => [
+ 'customer_filter' => [
+ 'customer_ids' => [$customer_id]
+ ]
+ ],
+ 'sort' => [
+ 'sort_field' => 'CREATED_AT',
+ 'sort_order' => 'DESC'
+ ],
+ 'limit' => 50
+ ]);
+
+ return $orders_response['orders'] ?? [];
+ }
+
+ public function checkOrderStatus(string $order_id): ?string
+ {
+ // Fetch from Square
+ $response = $this->getRequest('orders/' . $order_id);
+ if (!is_wp_error($response)) {
+ return $response['order']['state'] ?? null;
+ }
+
+ return null;
+ }
+
+ /**
+ * Single-item sync. Called by IntegrationExecutor::processSyncTo().
+ * Delegates to syncBatchToService since Square uses batch-upsert.
+ */
+ public function syncPostToService(int $postID): array|WP_Error
+ {
+ return $this->syncBatchToService(['items' => [$postID]]);
+ }
+
+ /**
+ * Batch sync — preferred by IntegrationExecutor when available.
+ * Wraps existing processSyncToSquare which already handles batches.
+ */
+ public function syncBatchToService(array $data): array|WP_Error
+ {
+ $result = $this->processSyncToSquare($data);
+
+ if (empty($result['success'])) {
+ $errors = implode(', ', $result['result']['errors'] ?? ['Sync failed']);
+ return new WP_Error('square_sync_failed', $errors);
+ }
+
+ return $result;
+ }
+
+ /**
+ * Delete catalog object from Square.
+ * Called by IntegrationExecutor::processDeleteFrom().
+ */
+ public function deleteFromService(string $externalId): array|WP_Error
+ {
+ $result = $this->processDeleteFromSquare(['square_ids' => [$externalId]]);
+
+ if (empty($result['success'])) {
+ return new WP_Error('square_delete_failed', $result['result']['error'] ?? 'Delete failed');
+ }
+
+ return $result;
+ }
+
+ /**
+ * Import from Square catalog → WordPress.
+ * Called by IntegrationExecutor::processImport().
+ */
+ public function importFromService(array $data): array|WP_Error
+ {
+ $result = $this->processSyncFromSquare($data);
+
+ if (empty($result['success'])) {
+ return new WP_Error('square_import_failed', $result['result']['error'] ?? 'Import failed');
+ }
+
+ return $result;
+ }
+
+ /**
+ * Sync customer to Square.
+ * Called by IntegrationExecutor::processSyncCustomer().
+ */
+ public function syncCustomer(array $data): array|WP_Error
+ {
+ $result = $this->processSyncCustomer($data);
+
+ if (empty($result['success'])) {
+ return new WP_Error('square_customer_sync_failed', $result['result']['error'] ?? 'Customer sync failed');
+ }
+
+ return $result;
+ }
+
+ public function addDashboardPages():void
+ {
+ $page = JVB()->dashboard()->addPage('Your Orders', 'orders', 'receipt');
+ $page->setScripts(['jvb-crud']);
+ }
+ public function renderDashPage():void
+ {
+ $this->determineUser();
+
+ $crud = new CRUDSkeleton();
+ $crud->icon('receipt');
+ $crud->title('Your Orders','Here you can see your past orders, and reorder from there if you\'d like');
+ $crud->content($this->orderPostType, 'Order', 'Orders');
+ $crud->addSearch();
+ $crud->addCapabilities(['view']);
+ $crud->setEmptyState(sprintf(
+ '<div class="empty-state">
+ <h3>%sNothing here%s</h3>
+ <p>It doesn\'t look like you have any orders yet.</p>
+ <p>Head on over to <a href="%s">our menu</a> and make your first order!</p>
+ </div>',
+ jvbDashIcon($crud->getIcon()),
+ jvbDashIcon($crud->getIcon()),
+ get_post_type_archive_link(BASE.'menu_item')
+ ));
+
+
+ $crud->render();
+ }
+
+
+ public function getOrderPost(string $order_id):int|false
+ {
+ $posts = new WP_Query([
+ 'post_type' => BASE.$this->orderPostType,
+ 'posts_per_page' => 1,
+ 'meta_key' => BASE.'square_order_id',
+ 'meta_value' => $order_id,
+ 'fields' => 'ids',
+ ]);
+ wp_reset_postdata();
+ return $posts->have_posts() ? $posts[0] : false;
+ }
+ public function getOrderHistory(int $user_id):array
+ {
+ $posts = new WP_Query([
+ 'post_type' => BASE.$this->orderPostType,
+ 'posts_per_page' => 25,
+ 'author' => $user_id,
+ 'orderby' => 'date',
+ 'order' => 'desc',
+ 'fields' => 'ids',
+ ]);
+ wp_reset_postdata();
+ return array_map(function ($post) {
+ $fields = Meta::forPost($post);
+ $fields['wp_order_id'] = $post;
+ return $fields;
+ }, $posts->posts);
+
+ }
+
+ public function formatPrice(string|float|int $priceValue):int
+ {
+ //Convert dollars to cents
+ return intval(floatval($priceValue) * 100);
+ }
+}
diff --git a/inc/integrations/services/UmamiOld.php b/inc/integrations/services/UmamiOld.php
new file mode 100644
index 0000000..5c02071
--- /dev/null
+++ b/inc/integrations/services/UmamiOld.php
@@ -0,0 +1,803 @@
+<?php
+namespace JVBase\integrations\services;
+use JVBase\integrations\Integrations;
+
+if (!defined('ABSPATH')) {
+ exit;
+}
+
+class UmamiOld 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
+ */
+ 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);
+ }
+}
diff --git a/inc/registrar/helpers/AddIntegrationFields.php b/inc/registrar/helpers/AddIntegrationFields.php
index 6aad5fa..952cd51 100644
--- a/inc/registrar/helpers/AddIntegrationFields.php
+++ b/inc/registrar/helpers/AddIntegrationFields.php
@@ -123,7 +123,7 @@
}
if ($this->config->getCanSchedule()) {
- $fields['schedule_'.$this->service_name] = [
+ $fields['_'.$this->service_name.'_scheduled_at'] = [
'type' => 'datetime',
'label' => 'Schedule for later?',
'condition' => [
--
Gitblit v1.10.0