Jake Vanderwerf
10 days ago de317675a8069b747cb253ba3e2b5dc394ca36ef
=Base Integrations mainly done - doing a test with setting up the Square integration to follow
12 files added
15 files modified
18596 ■■■■ changed files
inc/integrations/Admin.php 13 ●●●●● patch | view | raw | blame | history
inc/integrations/Field.php 38 ●●●●● patch | view | raw | blame | history
inc/integrations/Instance.php 17 ●●●●● patch | view | raw | blame | history
inc/integrations/Integrations.php 1656 ●●●●● patch | view | raw | blame | history
inc/integrations/OAuth.php 94 ●●●● patch | view | raw | blame | history
inc/integrations/OAuthCredentials.php 3 ●●●● patch | view | raw | blame | history
inc/integrations/OAuthURLs.php 26 ●●●●● patch | view | raw | blame | history
inc/integrations/SyncDefaults.php 171 ●●●●● patch | view | raw | blame | history
inc/integrations/SyncHelpers.php 47 ●●●●● patch | view | raw | blame | history
inc/integrations/SyncTo.php 455 ●●●●● patch | view | raw | blame | history
inc/integrations/UserConnection.php 8 ●●●●● patch | view | raw | blame | history
inc/integrations/_Base.php 59 ●●●●● patch | view | raw | blame | history
inc/integrations/_setup.php 12 ●●●●● patch | view | raw | blame | history
inc/integrations/services/BlueSkyOld.php 411 ●●●●● patch | view | raw | blame | history
inc/integrations/services/CloudflareOld.php 266 ●●●●● patch | view | raw | blame | history
inc/integrations/services/FacebookOld.php 701 ●●●●● patch | view | raw | blame | history
inc/integrations/services/GoogleMapsOld.php 834 ●●●●● patch | view | raw | blame | history
inc/integrations/services/GoogleMyBusiness.php 1 ●●●● patch | view | raw | blame | history
inc/integrations/services/GoogleMyBusinessOld.php 2974 ●●●●● patch | view | raw | blame | history
inc/integrations/services/Helcim.php 2 ●●●●● patch | view | raw | blame | history
inc/integrations/services/HelcimOld.php 1359 ●●●●● patch | view | raw | blame | history
inc/integrations/services/InstagramOld.php 705 ●●●●● patch | view | raw | blame | history
inc/integrations/services/PostMarkOld.php 661 ●●●●● patch | view | raw | blame | history
inc/integrations/services/Square.php 3772 ●●●● patch | view | raw | blame | history
inc/integrations/services/SquareOld.php 3506 ●●●●● patch | view | raw | blame | history
inc/integrations/services/UmamiOld.php 803 ●●●●● patch | view | raw | blame | history
inc/registrar/helpers/AddIntegrationFields.php 2 ●●● patch | view | raw | blame | history
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
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;
    }
}
inc/integrations/Instance.php
New file
@@ -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;
    }
}
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;
    }
}
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
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,
        ];
    }
}
inc/integrations/OAuthURLs.php
New file
@@ -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;
    }
}
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);
        }
    }
}
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;
}
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'
            ]
        );
    }
}
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;
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;
    }
}
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')) {
inc/integrations/services/BlueSkyOld.php
New file
@@ -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 [];
    }
}
inc/integrations/services/CloudflareOld.php
New file
@@ -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 [];
    }
}
inc/integrations/services/FacebookOld.php
New file
@@ -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'];
    }
}
inc/integrations/services/GoogleMapsOld.php
New file
@@ -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);
    }
}
inc/integrations/services/GoogleMyBusiness.php
@@ -2767,7 +2767,6 @@
    protected function setContentTypes():void
    {
        $this->has_content = true;
        $this->contentTypes = [
            'menu_item' => [
                'nutritionFacts'    => [
inc/integrations/services/GoogleMyBusinessOld.php
New file
@@ -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']
            ]
        ];
    }
}
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) {
inc/integrations/services/HelcimOld.php
New file
@@ -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'),
        ];
    }
}
inc/integrations/services/InstagramOld.php
New file
@@ -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.";
    }
}
inc/integrations/services/PostMarkOld.php
New file
@@ -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;
    }
}
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);
    }
}
inc/integrations/services/SquareOld.php
New file
@@ -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);
    }
}
inc/integrations/services/UmamiOld.php
New file
@@ -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);
    }
}
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' => [