Jake Vanderwerf
2026-08-07 a8ff85042ffbfe94b678fd818c02deb613bfd116
=Continuing on with Integrations setup. Working on PostMark now
18 files modified
8726 ■■■■■ changed files
inc/integrations/Admin.php 8 ●●●●● patch | view | raw | blame | history
inc/integrations/AdminActions.php 1 ●●●●● patch | view | raw | blame | history
inc/integrations/Field.php 31 ●●●●● patch | view | raw | blame | history
inc/integrations/Integrations.php 13 ●●●●● patch | view | raw | blame | history
inc/integrations/OAuth.php 37 ●●●●● patch | view | raw | blame | history
inc/integrations/Requests.php 11 ●●●●● patch | view | raw | blame | history
inc/integrations/UserConnection.php 1 ●●●●● patch | view | raw | blame | history
inc/integrations/_Base.php 28 ●●●●● patch | view | raw | blame | history
inc/integrations/_setup.php 18 ●●●●● patch | view | raw | blame | history
inc/integrations/services/Cloudflare.php 184 ●●●●● patch | view | raw | blame | history
inc/integrations/services/Facebook.php 809 ●●●●● patch | view | raw | blame | history
inc/integrations/services/GoogleMaps.php 906 ●●●●● patch | view | raw | blame | history
inc/integrations/services/GoogleMyBusiness.php 3069 ●●●●● patch | view | raw | blame | history
inc/integrations/services/Helcim.php 1332 ●●●●● patch | view | raw | blame | history
inc/integrations/services/Instagram.php 710 ●●●●● patch | view | raw | blame | history
inc/integrations/services/PostMark.php 742 ●●●●● patch | view | raw | blame | history
inc/integrations/services/Square.php 33 ●●●●● patch | view | raw | blame | history
inc/integrations/services/Umami.php 793 ●●●●● patch | view | raw | blame | history
inc/integrations/Admin.php
@@ -26,7 +26,7 @@
            [
                'name'      => 'Clear Credentials',
                'icon'      => 'broom',
                'action'    => [$this, 'clearCredentials']
                'action'    => [$this, 'deleteCredentials']
            ],
        ];
        foreach ($actions as $a) {
@@ -148,7 +148,7 @@
            $form .= $this->renderOAuthConnectionForm();
        }
        if (!empty($this->optionalFields) || $this->hasExtraOptions) {
        if (!empty($this->optionalFields) || $this->hasExtraOptions()) {
            $form .= '<div class="integration-content">';
            if (!empty($this->optionalFields)) {
                $form .= sprintf(
@@ -157,7 +157,7 @@
                    $this->outputFields($this->optionalFields)
                );
            }
            if ($this->hasExtraOptions) {
            if ($this->hasExtraOptions()) {
                $form .= sprintf(
                    '<a href="%s" class="btn">More Settings</a>',
                    $this->determineOptionPage()
@@ -227,4 +227,6 @@
            return true;
        }
}
inc/integrations/AdminActions.php
@@ -21,6 +21,7 @@
    {
        $this->name = $name;
        $this->setSlug();
        $this->setCallback($callback);
    }
    public function setSlug(?string $slug = null):void
inc/integrations/Field.php
@@ -17,14 +17,17 @@
    protected array $options;
    protected mixed $default;
    protected string $subtype;
    protected string $placeholder;
    protected bool $hidden = false;
    protected int $min;
    protected int $max;
    public function __construct(string $name, string $label, string $type, callable|string|bool $permission)
    {
        $this->name = $name;
        $this->label = $label;
        $this->type = $type;
        $this->permission = $permission;
        $this->permission = is_callable($permission) ? Closure::fromCallable($permission) : $permission;
    }
    public function checkPermission():bool
@@ -69,7 +72,21 @@
    public function setSubType(string $type):void
    {
        $this->subType = $type;
        $this->subtype = $type;
    }
    public function setMin(int $min):void
    {
        $this->min = $min;
    }
    public function setMax(int $max):void
    {
        $this->max = $max;
    }
    public function setPlaceholder(string $string):void
    {
        $this->placeholder = sanitize_text_field($string);
    }
    public function getConfig():array
@@ -97,6 +114,16 @@
        if ($this->hidden) {
            $conf['hidden'] = true;
        }
        if (isset($this->min)) {
            $conf['min'] = $this->min;
        }
        if (isset($this->max)) {
            $conf['max'] = $this->max;
        }
        if (isset($this->placeholder)) {
            $conf['placeholder'] = $this->placeholder;
        }
        return $conf;
    }
}
inc/integrations/Integrations.php
@@ -59,6 +59,7 @@
        $this->userID = $userID;
        $this->cache = Cache::for('integrations_' . $this->cacheName);
        $this->getPostTypes();
        $this->getTaxonomies();
        $this->getUsers();
@@ -262,7 +263,7 @@
        }
        try {
            if ($this->isOAuthService && !$this->hasOAuthCredentials()){
            if (!$this->hasOAuthCredentials()){
                //If this is an OAuth service, we might only be saving the app credentials first
                $result = true;
            } else {
@@ -400,7 +401,10 @@
        if (!empty($this->syncPostTypes)) {
            if (method_exists($this, 'addSavePost')) {
            $this->addSavePost();
            }
            add_action('transition_post_status', [$this, 'handlePostStatusTransition'], 10, 3);
            if ($this->canSync['delete']) {
@@ -489,10 +493,7 @@
    public function hasOAuthCredentials(?int $userID = null): bool
    {
        if ($userID !== $this->userID) {
            $this->switchUser($userID);
        }
        return $this->hasOAuth && $this->hasValidOAuth();
        return false;
    }
@@ -546,7 +547,7 @@
    public static function hasExtraOptions():bool
    {
        return static::getInstance()::$hasExtraOptions;
        return self::$hasExtraOptions;
    }
    /**
inc/integrations/OAuth.php
@@ -11,7 +11,6 @@
trait OAuth
{
    use _Base, Admin;
    protected bool $hasOAuth = true;
    protected OAuthURLs $oauth;
    /**
     * Child classes must set this (e.g. 'https://api.example.com/v1/')
@@ -21,7 +20,17 @@
    protected int $oauthTTL = 300;
    protected array $oathScopes = [];
    protected ?RateLimits $oauthLimiter;
    protected function initializeOAuthRateLimiter():void
    {
        $key = $this->service_name;
        if (!is_null($this->userID)) {
            $key .= '_'.$this->userID;
        }
        $key .= '_oauth';
        $this->oauthLimiter = new RateLimits($key, ['s' => 1, 'm' => 10, 'h' => 100]);
    }
    /**
     * @return array Defaults to the base getRequestHeaders, but can optionally be overridden
     */
@@ -67,11 +76,15 @@
            $this->addAction($a['name'], $a['action'], $a['slug']??null, $a['icon']??null, $a['label']??null);
        }
    }
        public function hasOAuthCredentials(?int $userID = null):bool
        {
            if ($userID !== $this->userID) {
                $this->switchUser($userID);
            }
            return $this->hasValidOAuth();
        }
        public function handleOAuthConnect():array
        {
            if (!$this->hasOAuth) {
                return $this->response(false, 'Not an OAuth service');
            }
            $url = $this->getOAuthUrl();
            if ($url) {
                return [
@@ -92,9 +105,6 @@
        public function revokeOAuth():bool
        {
            if (!$this->hasOAuth) {
                return false;
            }
            if (empty($this->oauth['revoke'])) {
                error_log('No revoke url set for '.$this->service_name);
                return false;
@@ -362,9 +372,7 @@
    public function getOAuthURL(?string $return_url = null):string
    {
        if (!$this->hasOAuth) {
            return '';
        }
        $credentials = $this->loadCredentials();
        if (empty($this->oauth['authorize'])) {
            $this->logError('getOAuthUrl', 'OAuth authorize URL not configured');
@@ -409,9 +417,14 @@
    protected function getRedirectUri():string
    {
        if ($this->hasOAuth) {
            return admin_url('admin-ajax.php?action='.BASE . $this->service_name . '_oauth_callback');
        }
        return rest_url('jvb/v1/oauth/callback?service=' . $this->service_name);
    /****************************************************
     * UTILITY
    ****************************************************/
    protected function getAccessToken():string
    {
        return $this->loadCredentials()['access_token']??'';
    }
}
inc/integrations/Requests.php
@@ -7,12 +7,12 @@
trait Requests
{
    protected bool $hasRequests = true;
    /**
     * Child classes must set this (e.g. 'https://api.example.com/v1/')
     */
    protected ?string $baseUrl = null;
    protected int $ttl = 300; //5 minutes default
    protected ?RateLimits $requestLimiter = null;
    /**
     * Child classes define their auth headers (Bearer token, API key, etc.)
@@ -25,6 +25,15 @@
     */
    abstract protected function handleResponse(array|\WP_Error $response, string $method, string $endpoint): array;
    protected function initializeRequestLimiter():void
    {
        $key = $this->service_name;
        if (!is_null($this->userID)) {
            $key .= '_'.$this->userID;
        }
        $this->requestLimiter = new RateLimits($key);
    }
    public function getRequest(string $endpoint, array $data = [], bool $force = false, ?int $ttl = null): array
    {
        $key = [$this->service_name, $this->userID, $endpoint, $data];
inc/integrations/UserConnection.php
@@ -37,7 +37,6 @@
    protected function clearUser():void
    {
        $this->userID = false;
        $this->credentials = [];
        if (property_exists($this, 'rateLimiter')) {
            $this->requestLimiter?->saveHistory();
            $this->requestLimiter = null;
inc/integrations/_Base.php
@@ -31,14 +31,7 @@
    protected string $apiVersion;
    protected Cache $cache;
    protected ?string $cacheName = null;
    //Use Trait "Requests" to set this
    protected bool $hasRequests = false;
    protected ?RateLimits $requestLimiter = null;
    //Use Trait "OAuth" to set this
    protected ?RateLimits $oauthLimiter = null;
    protected bool $hasOAuth = false;
    //Use trait "Webhooks" to set this true
    protected bool $hasWebhooks = false;
    /**
     * @var array Each item in the array becomes a li element in the ordered list of instructions
@@ -61,7 +54,7 @@
     * @var bool Flag to indicate there are extra options that warrant a dedicated options page for this service
     * Usually used for default field values for syncable services
     */
    public bool $hasExtraOptions = false;
    public static bool $hasExtraOptions = false;
    public bool $hasBatchCreate = false;
    public bool $hasBatchUpdate = false;
    public bool $hasBatchDelete = false;
@@ -93,6 +86,8 @@
     */
    protected array $contentTypes = [];
    protected array $credentials;
    /**
     * Check if integration is properly configured
@@ -104,7 +99,10 @@
    public function loadCredentials():array
    {
        return Auth::getInstance()->getCredentials($this->service_name, $this->userID);
        if (!isset($this->credentials)) {
            $this->credentials = Auth::getInstance()->getCredentials($this->service_name, $this->userID);
        }
        return $this->credentials;
    }
    protected function logError(string $method, string $message, array $context = []):void
@@ -143,13 +141,11 @@
        if (!is_null($this->userID)) {
            $key .= '_'.$this->userID;
        }
        if ($this->hasRequests && !isset($this->requestLimiter) && !is_null($this->oauthLimiter)) {
            $this->requestLimiter = new RateLimits($key);
        if (method_exists($this, 'initializeRequestLimiter')) {
            $this->initializeRequestLimiter();
        }
        if ($this->hasOAuth && !isset($this->oauthLimiter) && !is_null($this->oauthLimiter)) {
            $key .= '_oauth';
            $this->oauthLimiter = new RateLimits($key, ['s' => 1,'m' => 10, 'h' => 100]);
        if (method_exists($this, 'intializeOAuthRateLimiter')) {
            $this->initializeOAuthRateLimiter();
        }
    }
inc/integrations/_setup.php
@@ -5,18 +5,28 @@
    require(JVB_DIR . '/inc/integrations/AdminActionResult.php');
    require(JVB_DIR . '/inc/integrations/AdminActions.php');
require(JVB_DIR . '/inc/integrations/Ajax.php');
require(JVB_DIR . '/inc/integrations/APIEndpoint.php');
require(JVB_DIR . '/inc/integrations/Auth.php');
require(JVB_DIR . '/inc/integrations/Endpoint.php');
require(JVB_DIR . '/inc/integrations/EndpointField.php');
require(JVB_DIR . '/inc/integrations/Field.php');
require(JVB_DIR . '/inc/integrations/Instance.php');
require(JVB_DIR . '/inc/integrations/OAuth.php');
    require(JVB_DIR . '/inc/integrations/OAuthCredentials.php');
    require(JVB_DIR . '/inc/integrations/OAuthURLs.php');
require(JVB_DIR . '/inc/integrations/RateLimits.php');
require(JVB_DIR . '/inc/integrations/Requests.php');
require(JVB_DIR . '/inc/integrations/SyncDefaults.php');
    require(JVB_DIR . '/inc/integrations/SyncFrom.php');
    require(JVB_DIR . '/inc/integrations/SyncHelpers.php');
    require(JVB_DIR . '/inc/integrations/SyncTo.php');
require(JVB_DIR . '/inc/integrations/UserConnection.php');
require(JVB_DIR . '/inc/integrations/SyncDefaults.php');
    require(JVB_DIR . '/inc/integrations/SyncHelpers.php');
    require(JVB_DIR . '/inc/integrations/SyncFrom.php');
        require(JVB_DIR . '/inc/integrations/SyncFromPost.php');
        require(JVB_DIR . '/inc/integrations/SyncFromTerm.php');
        require(JVB_DIR . '/inc/integrations/SyncFromUser.php');
    require(JVB_DIR . '/inc/integrations/SyncTo.php');
        require(JVB_DIR . '/inc/integrations/SyncToPost.php');
        require(JVB_DIR . '/inc/integrations/SyncToTerm.php');
        require(JVB_DIR . '/inc/integrations/SyncToUser.php');
require(JVB_DIR . '/inc/integrations/Webhooks.php');
require(JVB_DIR . '/inc/integrations/Integrations.php');
inc/integrations/services/Cloudflare.php
@@ -1,95 +1,72 @@
<?php
namespace JVBase\integrations\services;
use JVBase\integrations\Admin;
use JVBase\integrations\Instance;
use JVBase\integrations\Integrations;
if (!defined('ABSPATH')) {
    exit;
}
class Cloudflare extends Integrations
{
    private string $site_key;
    private string $secret_key;
    use Admin, Instance;
    private string $theme = 'light';
    private string $size = 'normal';
    protected static array $instances = [];
    public static function getInstance(?int $userID = null):self
    {
        $key = is_null($userID) ? 'base' : $userID;
        if (!array_key_exists($key, self::$instances)) {
            self::$instances[$key] = new self($userID);
        }
        return self::$instances[$key];
    }
    protected function __construct()
    {
        $this->service_name = 'cloudflare';
        $this->title = 'Cloudflare Turnstile';
        $this->title = 'Cloudflare';
        $this->icon = 'cloud';
        $this->apiBase = 'https://challenges.cloudflare.com';
        $this->apiEndpoints = ['turnstile/v0/siteverify'];
        $this->fields = [
            'site_key'  => [
                'label' => 'Site Key',
                'type'  => 'text',
                'placeholder'   => 'Enter Public key for Turnstile widget display.',
                'hint'  => 'Public key for Turnstile widget',
                'required'  => true
            ],
            'secret_key'    => [
                'label' => 'Secret Key',
                'type'  => 'text',
                'subtype'   => 'password',
                'placeholder'   => 'Enter Cloudflare Turnstile Secret Key',
                'required'  => true,
                'hint'  => 'Secret key for server-side verification'
            ]
        ];
        $this->defineFields();
        $this->defineInstructions();
        parent::__construct();
    }
    protected function defineFields():void
    {
        $site_key = $this->addField('site_key', 'Site Key', 'text', 'manage_options');
            $site_key->setRequired();
            $site_key->setHint('Public key for Turnstile Widget');
        $secret_key = $this->addField('secret_key', 'Secret Key', 'text', 'manage_options');
            $secret_key->setRequired();
            $secret_key->setSubType('password');
            $secret_key->setHint('Secret key for server-side verification of Turnstile Widget');
    }
    protected function defineInstructions():void
    {
        $this->instructions = [
            'Go to <a href="https://dash.cloudflare.com/?to=/:account/turnstile" target="_blank">Cloudflare Dashboard</a>',
            'Create a new Turnstile widget',
            'Add your domain to the widget settings',
            'Copy the Site Key and Secret Key'
        ];
        $this->advanced = [];
        parent::__construct();
    }
    protected function initialize(): void
    {
        $this->site_key = $this->credentials['site_key'] ?? '';
        $this->secret_key = $this->credentials['secret_key'] ?? '';
        $this->theme = $this->credentials['theme'] ?? 'light';
        $this->size = $this->credentials['size'] ?? 'normal';
        // TODO: Implement initialize() method.
    }
    /**
     * Check if scripts should be enqueued
     */
    public function shouldEnqueueScripts(): bool
    public function getServiceDescription():string
    {
        return $this->isSetUp() && !is_admin();
        return "Add Turnstile captcha protection to your forms and login pages to prevent spam and bot submissions.";
    }
    /**
     * Test connection by attempting a verification with an invalid token
     */
    public function performConnectionTest(): bool
    {
        if (empty($this->site_key) || empty($this->secret_key)) {
        $credentials = $this->loadCredentials();
        if (!array_key_exists('site_key', $credentials) || !array_key_exists('secret_key', $credentials)) {
            return false;
        }
        // Test with a dummy token to verify API connectivity
        $response = wp_remote_post('https://challenges.cloudflare.com/turnstile/v0/siteverify', [
            'body' => [
                'secret' => $this->secret_key,
                'secret' => $credentials['secret_key'],
                'response' => 'test-token-connection-check'
            ],
            'timeout' => 10
@@ -97,46 +74,14 @@
        // We expect this to fail with success:false, but not with a connection error
        if (is_wp_error($response)) {
            $this->logError('Connection test failed: ', [
                'message'  => $response->get_error_message()
            ]);
            return false;
        }
        $result = json_decode(wp_remote_retrieve_body($response), true);
        // If we get a response with success field (even if false), connection works
        return array_key_exists('success', $result);
    }
    protected function validateCredentials(array $credentials): bool
    {
        if (empty($credentials['site_key'])) {
            $this->logError('Missing Site Key', [
                'method'    => 'validateCredentials'
            ]);
            return false;
        }
        if (empty($credentials['secret_key'])) {
            $this->logError('Missing Secret Key', [
                'method'    => 'validateCredentials'
            ]);
            return false;
        }
        // Validate format
        if (!preg_match('/^[0-9a-zA-Z._-]+$/', $credentials['site_key'])) {
            $this->logError('Invalid Site Key', [
                'method'    => 'validateCredentials'
            ]);
            return false;
        }
        return true;
    }
    /**
     * Enqueue Turnstile script
     */
@@ -156,10 +101,10 @@
    /**
     * Render Turnstile widget
     * Can be called directly: jvbConnect('cloudflare')->renderTurnstile()
     * Or Cloudflare::getInstance()->renderTurnstile()
     */
    public function renderTurnstile(array|string $options = []): void
    {
        $this->ensureInitialized();
        if (!$this->isSetUp()) {
            return;
        }
@@ -175,29 +120,36 @@
            'wrapper_style' => 'margin: 1em 0;'
        ];
        $cred = $this->loadCredentials();
        $options = wp_parse_args($options, $defaults);
        echo '<div class="' . esc_attr($options['wrapper_class']) . '" style="' . esc_attr($options['wrapper_style']) . '">';
        echo '<div class="cf-turnstile" ';
        echo 'data-sitekey="' . esc_attr($this->site_key) . '" ';
        echo 'data-theme="' . esc_attr($options['theme']) . '" ';
        echo 'data-size="' . esc_attr($options['size']) . '">';
        echo '</div>';
        echo '</div>';
        echo sprintf(
            '<div class="%s" style="%s">
                <div class="cf-turnstile" data-sitekey="%s" data-theme="%s" data-size="%s"></div>
            </div>',
            esc_attr($options['wrapper_class']),
            esc_attr($options['wrapper_style']),
            $cred['site_key'],
            $options['theme'],
            $options['size']
        );
    }
    /**
     * Verify Turnstile response
     * Can be called directly: JVB()->connect('cloudflare')->verifyTurnstile($token)
     * Verify the Turnstile response
     * Can be called directly via JVB()->connect('cloudflare')->verifyTurnstile($token)
     *   or Cloudflare::getInstance()->verifyTurnstile($token)
     * @param string|null $token
     * @param string $remote_ip
     * @return bool
     */
    public function verifyTurnstile(?string $token = null, string $remote_ip = ''): bool
    {
        $this->ensureInitialized();
        if (!$this->isSetUp()) {
            return false;
            return true;
        }
        // If no token provided, try to get from POST
        // If no token provided, try to get it from $_POST
        if (!$token && isset($_POST['cf-turnstile-response'])) {
            $token = sanitize_text_field($_POST['cf-turnstile-response']);
        }
@@ -206,61 +158,35 @@
            return false;
        }
        $cred = $this->loadCredentials();
        $data = [
            'secret' => $this->secret_key,
            'response' => $token
            'secret'    => $cred['secret_key'],
            'response'  => $token,
        ];
        if (empty($remote_ip) && isset($_SERVER['REMOTE_ADDR'])) {
            $remote_ip = $_SERVER['REMOTE_ADDR'];
            $remote_ip = sanitize_text_field($_SERVER['REMOTE_ADDR']);
        }
        if (!empty($remote_ip)) {
            $data['remoteip'] = $remote_ip;
        }
        $response = wp_remote_post('https://challenges.cloudflare.com/turnstile/v0/siteverify', [
            'body' => $data,
            'timeout' => 10
        ]);
        if (is_wp_error($response)) {
            $this->logError('Turnstile verification failed: ' . $response->get_error_message());
            $this->logError('verifyTurnstile', 'Turnstile verification failed: ' . $response->get_error_message());
            return false;
        }
        $result = json_decode(wp_remote_retrieve_body($response), true);
        if (!($result['success'] ?? false)) {
        if (!$result['success']??false) {
            $error_codes = implode(', ', $result['error-codes'] ?? ['Unknown error']);
            $this->logError('Turnstile verification failed: ' . $error_codes);
            $this->logError('verifyTurnstile', 'Turnstile verification failed: ' . $error_codes);
        }
        return $result['success'] ?? false;
    }
    /**
     * Get site key for frontend use
     */
    public function getSiteKey(): string
    {
        return $this->site_key;
    }
    /**
     * Get service description
     */
    public function getServiceDescription(): string
    {
        return "Add Turnstile captcha protection to your forms and login pages to prevent spam and bot submissions.";
    }
    /**
     * These methods are not used by Cloudflare but must be implemented
     */
    protected function getRequestHeaders(): array
    {
        // Cloudflare Turnstile uses POST parameters, not headers
        return [];
    }
}
inc/integrations/services/Facebook.php
@@ -5,9 +5,18 @@
 */
namespace JVBase\integrations\services;
use Exception;
use JVBase\integrations\Admin;
use JVBase\integrations\Instance;
use JVBase\integrations\Integrations;
use JVBase\meta\Meta;
use WP_Post;
use JVBase\integrations\OAuth;
use JVBase\integrations\OAuthCredentials;
use JVBase\integrations\OAuthURLs;
use JVBase\integrations\Requests;
use JVBase\integrations\SyncDefaults;
use JVBase\integrations\SyncFrom;
use JVBase\integrations\SyncTo;
use JVBase\integrations\UserConnection;
use WP_Error;
if (!defined('ABSPATH')) {
@@ -16,686 +25,254 @@
class Facebook extends Integrations
{
    // Facebook-specific properties
    protected array $allowedContent = [
        'post',
        'photo',
        'video',
        'event',
        'offer',
        'note',
        'milestone'
    ];
    private string $page_id = '';
    private string $page_access_token = '';
    private array $permissions = [];
    private array $pages = [];
    use Admin,
        Instance,
        OAuth,
        UserConnection,
        Requests,
        SyncFrom,
        SyncTo,
        SyncDefaults;
    // Post type mapping for Facebook
    private const FB_POST_TYPES = [
        'post' => 'feed',
        'photo' => 'photos',
        'video' => 'videos',
        'event' => 'events',
        'offer' => 'offers',
        'note' => 'notes',
        'milestone' => 'milestones'
    ];
    protected static array $instances = [];
    public static function getInstance(?int $userID = null):self
    {
        $key = is_null($userID) ? 'base' : $userID;
        if (!array_key_exists($key, self::$instances)) {
            self::$instances[$key] = new self($userID);
        }
        return self::$instances[$key];
    }
    protected function __construct(?int $userID = null)
    protected function __construct()
    {
        $this->service_name = 'facebook';
        $this->title = 'Facebook';
        $this->icon = 'facebook-logo';
        // API Configuration
        $this->apiBase = [
            'graph' => 'https://graph.facebook.com/v18.0',
            'auth' => 'https://www.facebook.com/v18.0'
        ];
        $this->apiVersion = 'v25.0';
        $this->apiVersion = 'v18.0';
        $this->isOAuthService = true;
        // OAuth Configuration
        $this->oauth = [
            'authorize' => 'https://www.facebook.com/v18.0/dialog/oauth',
            'token' => 'https://graph.facebook.com/v18.0/oauth/access_token',
            'revoke' => 'https://graph.facebook.com/v18.0/me/permissions',
            'scopes' => [
                'pages_show_list',
                'pages_read_engagement',
                'pages_manage_posts',
                'pages_manage_metadata',
                'pages_manage_engagement',
                'pages_read_user_content',
                'business_management',
                'instagram_basic',
                'instagram_content_publish'
            ]
        ];
        // Sync capabilities
        $this->canSync = [
            'initial' => true,
            'create'    => true,
            'update' => true,
            'delete' => true
        ];
        // Rate limits for Facebook Graph API
        $this->rate_limits = [
            'per_second' => 10,
            'per_minute' => 600,
            'per_hour' => 200000
        ];
        $this->defineFields();
        $this->defineInstructions();
        $this->defineOAuth();
        $this->defineActions();
        $this->fields = [
            'client_id' => [
                'type'  => 'text',
                'placeholder' => 'Enter Facebook App ID',
                'hint'  => 'Your Facebook App ID from developers.facebook.com',
                'label' => 'App ID',
                'required'  => true,
            ],
            'client_secret' => [
                'type'  => 'text',
                'subtype'=> 'password',
                'label' => 'App Secret',
                'required'  => true,
                'hint'  => 'Your Facebook App Secret (keep this secure!)'
            ],
        ];
        $this->advanced = [
        ];
        $this->instructions = [
        ];
        $this->defaults = [
        ];
        $this->hasWebhooks = true;
        $this->baseUrl = 'https://graph.facebook.com/'.$this->apiVersion;
        $this->baseOAuthUrl = 'https://www.facebook.com/'.$this->apiVersion;
        parent::__construct();
        $this->actions = array_merge($this->actions, [
            'sync_pages'    => 'getUserPages'
        ]);
    }
    /**
     * Initialize Facebook-specific properties
     */
    protected function initialize(): void
        protected function defineFields():void
    {
        $this->page_id = $this->credentials['page_id'] ?? '';
        $this->page_access_token = $this->credentials['page_access_token'] ?? '';
        $this->permissions = $this->credentials['permissions'] ?? [];
            $client_id = $this->addField(
                'client_id',
                'App ID',
                'text',
                'manage_options'
            );
                $client_id->setRequired();
                $client_id->setHint('Your Facebook App ID from developers.facebook.com');
        // Build dynamic endpoints based on page
        if ($this->page_id) {
            $this->apiEndpoints = [
                "/{$this->page_id}/feed",
                "/{$this->page_id}/photos",
                "/{$this->page_id}/videos",
                "/{$this->page_id}/events",
                "/{$this->page_id}/insights",
                "/{$this->page_id}/conversations",
                "/{$this->page_id}",
                "/me/accounts"
            $client_secret = $this->addField(
                'client_secret',
                'App Secret',
                'text',
                'manage_options'
            );
                $client_secret->setRequired();
                $client_secret->setSubType('password');
            $pages = $this->addField('pages', 'Pages', 'select', [$this, 'testUserCanConnect']);
                $pages->setHidden();
                $pages->setOptions($this->getPageOptions());
            $page = $this->addField('page_id', 'Connect to Page', 'select', [$this, 'testUserCanConnect']);
                $page->setOptions($this->loadCredentials()['pages']??[]);
                $page->setHint('Select the page you want to connect this site to.');
        }
            public function testUserCanConnect():bool
            {
                $user = get_current_user_id();
                return $this->userCanConnect($user);
            }
            protected function getPageOptions():array
            {
                return $this->loadCredentials()['pages']??[];
            }
        protected function defineInstructions():void
        {
        }
        protected function defineActions():void
        {
            $this->addAction(
                'Select Page',
                [$this, 'handleSelectPage'],
                'select_page',
                'plugs-connected'
            );
            $this->addAction(
                'Fetch Pages',
                [$this, 'getPages'],
                'fetch_pages',
                'download-simple'
            );
        }
        public function defineOAuth():void
        {
            $this->oauth = new OAuthURLs(
                'https://www.facebook.com/'.$this->apiVersion.'/dialog/oauth',
                'https://graph.facebook.com/'.$this->apiVersion.'/oauth/access_token',
                'https://graph.facebook.com/'.$this->apiVersion.'/me/permissions'
            );
            $this->oathScopes = [
                'pages_show_list',
                'business_management',
                'pages_manage_metadata',
                'pages_manage_posts'
            ];
        }
    }
    /**
     * Get service description
     */
    public function getServiceDescription(): string
    {
        return "Connect your Facebook Page to share posts, events, and engage with your audience.";
            return 'Connect your Facebook Page to share posts, events, and engage with your audience.';
        }
    protected function initialize(): void
    {
        // TODO: Implement initialize() method.
    }
    /**
     * Get request headers for Facebook API
     */
    protected function refreshAccessToken(OAuthCredentials $credentials): array
    {
        return $this->response(false, 'Not set up yet');
    }
    protected function getRequestHeaders(): array
    {
        $headers = [
        return [
            'Content-Type' => 'application/json',
            'Accept' => 'application/json'
        ];
        // Use page access token if available, otherwise user token
        if (!empty($this->page_access_token)) {
            // Token is sent in URL params for Facebook, not headers
        }
        return $headers;
    }
    /**
     * Add Facebook-specific OAuth parameters
     */
    protected function addOAuthParams(array $params): array
    protected function handleResponse(\WP_Error|array $response, string $method, string $endpoint): array
    {
        $params['display'] = 'popup';
        $params['auth_type'] = 'rerequest';
        $params['return_scopes'] = 'true';
        return $params;
        if (is_wp_error($response) && $response->get_error_message() === 'rate_limit_exceeded') {
            return $this->response(false, 'Rate limit exceeded');
    }
    /**
     * Add Facebook-specific credential data after OAuth
     */
    protected function addCredentialData(array $credentials, array $tokens): array
        $endpoint = str_replace($this->baseUrl, '', $endpoint);
        return match ($endpoint) {
            '/me/accounts?access_token='.$this->getAccessToken()    => $this->handleGetPagesResponse($response, $method),
            default => $this->response(false, 'Unhandled Response for ' . $endpoint . ' endpoint, with method: ' . $method),
        };
    }
    /************************************************************
     * UTILITY METHODS
    ************************************************************/
    protected function getPageID():string|false
    {
        // 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'];
        return $this->loadCredentials()['page_id']??false;
                }
            }
            // 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
    protected function getClientID():string|false
    {
        if (empty($this->credentials['access_token'])) {
            return false;
        return $this->loadCredentials()['client_id']??false;
    }
    protected function getClientSecret():string|false
    {
        return $this->loadCredentials()['client_secret']??false;
        }
        try {
            // Test by fetching user info
            $response = wp_remote_get(
                $this->apiBase['graph'] . '/me?access_token=' . $this->credentials['access_token']
    protected function getFBUserID():string|false
    {
        $cred = $this->loadCredentials();
        if ($this->isSetUp() && !array_key_exists('user_id', $cred)) {
            $resp = $this->getRequest(
                $this->baseUrl.'/me?access_token='.$cred['access_token'],
            );
            if ($resp && !is_wp_error($resp)) {
                $tmp['user_id'] = $resp['id'];
                $tmp['user_name'] = $resp['name'];
            if (is_wp_error($response)) {
                return false;
                $this->saveCredentials($tmp, false);
            }
            $code = wp_remote_retrieve_response_code($response);
            return $code === 200;
        } catch (Exception $e) {
            $this->logError('Connection test failed', ['error' => $e->getMessage()]);
            return false;
            $cred = array_merge($cred, $tmp);
        }
        return $cred['user_id']??false;
    }
    /**
     * Render Additional OAuth settings
     */
    protected function renderOAuthConnectedOptions(): void
    public function getPages():array|false
    {
        // Page selection dropdown
        if (!empty($this->credentials['pages'])) {
            ?>
            <div class="form-field">
                <label for="fb_page">Active Facebook Page</label>
                <select name="credentials[page_id]" id="fb_page">
                    <option value="">Select a page...</option>
                    <?php foreach ($this->credentials['pages'] as $page): ?>
                        <option value="<?php echo esc_attr($page['id']); ?>"
                            <?php selected($this->credentials['page_id'] ?? '', $page['id']); ?>>
                            <?php echo esc_html($page['name']); ?>
                        </option>
                    <?php endforeach; ?>
                </select>
            </div>
            <?php
        }
    }
    /**
     * Validate webhook from Facebook
     */
    protected function validateWebhook(array $payload): bool
    {
        // Handle webhook verification challenge
        if (isset($payload['hub_mode']) && $payload['hub_mode'] === 'subscribe') {
            $verify_token = $this->credentials['webhook_verify_token'] ?? '';
            if ($payload['hub_verify_token'] === $verify_token) {
                // Echo the challenge for verification
                echo $payload['hub_challenge'];
                return true;
            }
        if (!$this->getAccessToken()) {
            return false;
        }
        // Validate webhook signature for actual events
        $headers = $payload['_headers'] ?? [];
        $signature = $headers['x-hub-signature-256'] ?? '';
        if (empty($signature)) {
            $this->logError('Missing webhook signature');
            return false;
        }
        // Calculate expected signature
        $app_secret = $this->credentials['client_secret'] ?? '';
        $expected = 'sha256=' . hash_hmac('sha256', json_encode($payload), $app_secret);
        return hash_equals($expected, $signature);
    }
    /**
     * Process webhook from Facebook
     */
    protected function processWebhook(array $payload): bool
    {
        try {
            $object = $payload['object'] ?? '';
            $entry = $payload['entry'] ?? [];
            foreach ($entry as $item) {
                $changes = $item['changes'] ?? [];
                foreach ($changes as $change) {
                    $field = $change['field'] ?? '';
                    $value = $change['value'] ?? [];
                    switch ($field) {
                        case 'feed':
                            $this->handleFeedUpdate($value);
                            break;
                        case 'conversations':
                            $this->handleMessageUpdate($value);
                            break;
                        case 'photos':
                            $this->handlePhotoUpdate($value);
                            break;
                        default:
                            $this->logDebug('Unhandled webhook field', ['field' => $field]);
                    }
                }
            }
            return true;
            return $this->getRequest(
                $this->apiBase.'/me/accounts?access_token='.$this->getAccessToken(),
            );
        } catch (Exception $e) {
            $this->logError('Webhook processing failed', [
            $this->logError(
                'getPages',
                'Failed to load pages',
                [
                '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
        protected function handleGetPagesResponse(WP_Error|array $response, string $method):array
    {
        $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) . '...';
            $options = [];
            foreach ($response as $page) {
                if (array_key_exists('id', $page) && array_key_exists('name', $page)) {
                    $options[$page['id']] = $page['name'];
                }
            }
            if (!empty($options)) {
                $this->saveCredentials(['pages' => $options]);
            }
            return $this->response(true, 'Found pages', $options);
        }
        return $content;
    }
    /**
     * Helper: Handle feed updates from webhook
     */
    private function handleFeedUpdate(array $data): void
    /************************************************************
     * ACTIONS
    ************************************************************/
    public function handleSelectPage(array $data):array
    {
        // Process incoming feed updates
        // Could sync comments, reactions, etc. back to WordPress
        $this->logDebug('Feed update received', $data);
        if (empty($data['page_id']??'')) {
            return $this->response(false, 'No page selected.');
    }
    /**
     * 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
        $cred = [
            'page_id'   => sanitize_text_field($data['page_id']),
        ];
        $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()];
        return $this->saveCredentials($cred, false);
        }
        $body = json_decode(wp_remote_retrieve_body($response), true);
        if (isset($body['id'])) {
            return ['success' => true, 'post_id' => $body['id']];
    public function handleFetchPages():array
    {
        try {
            $pages = $this->getPages();
            if (!$pages) {
                return $this->response(false, 'No pages found');
        }
        return ['success' => false, 'error' => $body['error']['message'] ?? 'Unknown error'];
            return $this->response(
                true,
                sprintf('Found %d page(s)', count($pages)),
                $pages
            );
        } catch (Exception $e) {
            return $this->response(false, 'Failed to fetch pages: '.$e->getMessage());
        }
    }
    protected function setContentTypes(): void
    {
        // TODO: Implement setContentTypes() method.
    }
    protected function formatForService(int $itemID, string $type = 'post'): array
    {
        return [];
    }
}
inc/integrations/services/GoogleMaps.php
@@ -5,8 +5,11 @@
 */
namespace JVBase\integrations\services;
use JVBase\integrations\Admin;
use JVBase\integrations\Instance;
use JVBase\integrations\Integrations;
use JVBase\registrar\Registrar;
use JVBase\integrations\Requests;
use WP_Error;
if (!defined('ABSPATH')) {
    exit;
@@ -14,468 +17,101 @@
class GoogleMaps extends Integrations
{
    private string $public_key;
    private string $private_key;
    private string $map_id;
    private array $enabled_apis = [];
    // Edmonton default coordinates
    private const DEFAULT_LAT = 53.5461;
    private const DEFAULT_LNG = -113.4938;
    private const DEFAULT_ZOOM = 11; // City-wide view
    private const DEFAULT_MAP_ID = '873aed4ca260b4203e45b6e3';
    protected static self $instance;
    public static function getInstance():self
    {
        if (!isset(self::$instance)) {
            self::$instance = new self();
        }
        return self::$instance;
    }
    use Admin, Instance, Requests;
    protected function __construct()
    {
        $this->service_name = 'maps';
        $this->title = 'Google Maps';
        $this->icon = 'map-pin';
        $this->apiBase = 'https://maps.googleapis.com/maps/api/';
        $this->apiEndpoints = [
            'geocode/json',
            'place/details/json',
            'place/nearbysearch/json',
            'distancematrix/json',
            'js'
        ];
        $this->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->apiBase = 'https://maps.googleapis.com/maps/api/';
        $this->defineFields();
        $this->defineInstructions();
        $this->defineActions();
        parent::__construct();
        // Auto-enqueue maps script when needed
        add_action('wp_enqueue_scripts', [$this, 'maybeEnqueueMapsScript']);
    }
        protected function defineFields():void
        {
            $pub = $this->addField('public_key', 'Maps Platform API Key', 'text', 'manage_options');
                $pub->setHint('Restrict this key by HTTP referrers for security. Used for displaying maps in browsers.');
                $pub->setSubType('password');
                $pub->setRequired();
            $priv = $this->addField('private_key', 'Geocoding & Places API Key', 'text', 'manage_options');
                $priv->setHint('Restrict this key by IP Addresses. Used for geocoding and server-side operations.');
                $priv->setRequired();
                $priv->setSubType('password');
            $map_id = $this->addField('map_id', 'Map ID', 'text', 'manage_options');
                $map_id->setHint('Required for custom styling. Create a Map ID in Google Cloud Console -> Maps -> Map Management.');
            $zoom = $this->addField('default_zoom', 'Default Zoom', 'text', 'manage_options');
                $zoom->setSubType('number');
                $zoom->setDefault(11);
                $zoom->setMin(1);
                $zoom->setMax(20);
            $lng = $this->addField('default_lng', 'Default Longitude', 'text', 'manage_options');
                $lng->setSubType('number');
                $lng->setDefault(-113.4938);
            $lat = $this->addField('default_lat', 'Default Latitude', 'text', 'manage_options');
                $lng->setSubType('number');
                $lng->setDefault(53.5461);
        }
        protected function defineInstructions():void
        {
        $this->instructions = [
            'Create two API keys in <a href="https://console.cloud.google.com/apis/credentials" target="_blank">Google Cloud Console</a>',
            'Enable <strong>Geocoding API + Places API</strong> for frontend key, restricted by your server IP address',
            'Enable <strong>Maps Platform API</strong> for server key, restricted by your website domain',
        ];
        $this->defaults = [
        }
        ];
        parent::__construct();
        $this->actions = array_merge(
            $this->actions,
            [
                'reverse_geocode' => 'processReverseGeocode',
                'geocode' => 'processGeocode'
            ]
        protected function defineActions():void
        {
            $this->addAction(
                'reverse_geocode',
                [$this, 'processReverseGeocode']
        );
        // 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->addAction(
                'geocode',
                [$this, 'processGeocode']
            );
        }
            public function processReverseGeocode(array $data):array
    {
        $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
                if (!array_key_exists('lat', $data) || !array_key_exists('lng', $data)) {
                    return $this->response(false, 'Invalid coordinates provided', $data);
    }
    /**
     * 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');
        }
                $lat = floatval($data['lat']);
                $lng = floatval($data['lng']);
        $result = $this->reverseGeocode($lat, $lng);
        if ($result) {
            return ['success' => true, 'data' => $result];
                    return $this->response(true, 'Successfully reversed Geocode', ['data'=> $result]);
                }
                return $this->response(false, 'Reverse geocoding failed');
        }
        return new WP_Error('geocoding_failed', 'Reverse geocoding failed');
    }
    /**
     * Process geocode request
     */
    private function processGeocode(array $data): WP_Error|array
            public function processGeocode(array $data):array
    {
        $address = sanitize_text_field($data['address'] ?? '');
        if (empty($address)) {
            return new WP_Error('invalid_address', 'No address provided');
                if (!array_key_exists('address', $data)) {
                    return $this->response(false, 'No address provided');
        }
        $result = $this->geocodeAddress($address);
                $result = $this->geocodeAddress(sanitize_text_field($data['address']));
        if ($result) {
            return ['success' => true, 'data' => $result];
                    return $this->response(true, 'Successfully geocoded address', ['data' => $result]);
        }
        return new WP_Error('geocoding_failed', 'Geocoding failed');
                return $this->response(false, 'Geocoding failed');
    }
    /**
     * Geocode an address to coordinates
     */
    public function geocodeAddress(string $address): ?array
    protected function initialize(): void
    {
        if (!$this->isSetUp()) {
            return null;
        }
        $params = [
            'address' => $address,
            'key' => $this->private_key
        ];
        $key = $this->cache->generateKey(['geocode', $address]);
        $cached = $this->cache->get($key);
        if ($cached) {
            return $cached;
        }
        $url = $this->apiBase . 'geocode/json?' . http_build_query($params);
        $response = wp_remote_get($url);
        if (is_wp_error($response)) {
            $this->logError('Geocoding request failed', [
                'error' => $response->get_error_message(),
                'address' => $address
            ]);
            return null;
        }
        $data = json_decode(wp_remote_retrieve_body($response), true);
        if ($data['status'] === 'OK' && !empty($data['results'])) {
            $result = $data['results'][0];
            $result = [
                'lat' => $result['geometry']['location']['lat'],
                'lng' => $result['geometry']['location']['lng'],
                'formatted_address' => $result['formatted_address'],
                'place_id' => $result['place_id'] ?? '',
                'components' => $this->parseAddressComponents($result['address_components'] ?? [])
            ];
            $this->cache->set($key, $result);
        } else {
            $this->logError('Google Maps API error', [
                'status' => $data['status'] ?? 'unknown',
                'address' => $address
            ], 'warning');
            $result = null;
        }
        return $result;
    }
    /**
     * Reverse geocode coordinates to address
     */
    public function reverseGeocode(float $lat, float $lng): ?array
    {
        if (!$this->isSetUp()) {
            return null;
        }
        $params = [
            'latlng' => "{$lat},{$lng}",
            'key' => $this->private_key
        ];
        $key = $this->cache->generateKey(['reverse', $lat, $lng]);
        $cached = $this->cache->get($key);
        if ($cached) {
            return $cached;
        }
        $url = $this->apiBase . 'geocode/json?' . http_build_query($params);
        $response = wp_remote_get($url);
        if (is_wp_error($response)) {
            $this->logError('Reverse geocoding request failed', [
                'error' => $response->get_error_message(),
                'lat' => $lat,
                'lng' => $lng
            ]);
            return null;
        }
        $data = json_decode(wp_remote_retrieve_body($response), true);
        if ($data['status'] === 'OK' && !empty($data['results'])) {
            $result = $data['results'][0];
            $return = [
                'formatted_address' => $result['formatted_address'],
                'place_id' => $result['place_id'] ?? '',
                'components' => $this->parseAddressComponents($result['address_components'] ?? [])
            ];
            $this->cache->set($key, $return);
            return $return;
        }
        return null;
    }
    /**
     * Get place details by place ID
     */
    public function getPlaceDetails(string $place_id, array $fields = []): ?array
    {
        if (!$this->isSetUp()) {
            return null;
        }
        $default_fields = ['name', 'formatted_address', 'geometry', 'rating', 'formatted_phone_number'];
        $fields = empty($fields) ? $default_fields : $fields;
        $key = $this->cache->generateKey(['place', $place_id, $fields]);
        $cached = $this->cache->get($key);
        if ($cached) {
            return $cached;
        }
        $params = [
            'place_id' => $place_id,
            'fields' => implode(',', $fields),
            'key' => $this->private_key
        ];
        $url = $this->apiBase . 'place/details/json?' . http_build_query($params);
        $response = wp_remote_get($url);
        if (is_wp_error($response)) {
            $this->logError('Place details request failed', [
                'error' => $response->get_error_message(),
                'place_id' => $place_id
            ]);
            return null;
        }
        $data = json_decode(wp_remote_retrieve_body($response), true);
        if ($data['status'] === 'OK' && !empty($data['result'])) {
            $result = $data['result'];
            $this->cache->set($key, $result);
            return $result;
        }
        return null;
    }
    /**
     * Search for places nearby
     */
    public function searchNearby(float $lat, float $lng, int $radius = 1000, string $type = ''): array
    {
        if (!$this->isSetUp()) {
            return [];
        }
        $params = [
            'location' => "{$lat},{$lng}",
            'radius' => $radius,
            'key' => $this->private_key
        ];
        if (!empty($type)) {
            $params['type'] = $type;
        }
        $key = $this->cache->generateKey(['nearby', $lat, $lng, $radius, $type]);
        $cached = $this->cache->get($key);
        if ($cached) {
            return $cached;
        }
        $url = $this->apiBase . 'place/nearbysearch/json?' . http_build_query($params);
        $response = wp_remote_get($url);
        if (is_wp_error($response)) {
            $this->logError('Nearby search request failed', [
                'error' => $response->get_error_message(),
                'lat' => $lat,
                'lng' => $lng,
                'radius' => $radius
            ]);
            return [];
        }
        $data = json_decode(wp_remote_retrieve_body($response), true);
        if ($data['status'] === 'OK' && !empty($data['results'])) {
            $return = $data['results'];
            $this->cache->set($key, $return);
            return $return;
        }
        return [];
    }
    /**
     * Calculate distance between two points
     */
    public function calculateDistance(array $origin, array $destination, string $mode = 'driving'): ?array
    {
        if (!$this->isSetUp()) {
            return null;
        }
        // Handle different input formats
        $origin_str = is_array($origin) ? "{$origin['lat']},{$origin['lng']}" : $origin;
        $dest_str = is_array($destination) ? "{$destination['lat']},{$destination['lng']}" : $destination;
        $params = [
            'origins' => $origin_str,
            'destinations' => $dest_str,
            'mode' => $mode,
            'units' => 'metric',
            'key' => $this->private_key
        ];
        $key = $this->cache->generateKey(['distance', $origin_str, $dest_str, $mode]);
        $cached = $this->cache->get($key);
        if ($cached) {
            return $cached;
        }
        $url = $this->apiBase . 'distancematrix/json?' . http_build_query($params);
        $response = wp_remote_get($url);
        if (is_wp_error($response)) {
            $this->logError('Distance calculation request failed', [
                'error' => $response->get_error_message(),
                'origin' => $origin_str,
                'destination' => $dest_str
            ]);
            return null;
        }
        $data = json_decode(wp_remote_retrieve_body($response), true);
        if ($data['status'] === 'OK' && !empty($data['rows'][0]['elements'][0])) {
            $element = $data['rows'][0]['elements'][0];
            if ($element['status'] === 'OK') {
                $return = [
                    'distance' => $element['distance'],
                    'duration' => $element['duration'],
                    'mode' => $mode
                ];
                $this->cache->set($key, $return);
                return $return;
            }
        }
        return null;
    }
    /**
     * Get the JavaScript API URL with key
     */
    public function getJavaScriptApiUrl(array $libraries = ['places', 'marker', 'geometry']): string
    {
        if (empty($this->public_key)) {
            return '';
        }
        $params = [
            'key' => $this->public_key,
            'libraries' => implode(',', $libraries),
            'callback' => 'initGoogleMaps',
        ];
        return $this->apiBase . 'js?' . http_build_query($params);
    }
    /**
     * Enqueue Google Maps script when needed
     */
    public function maybeEnqueueMapsScript(): void
    {
        // Only enqueue if we're on a page that needs maps
        if (!$this->shouldEnqueueMaps()) {
            return;
        }
        if (!$this->isSetUp()) {
            return;
        }
        $this->ensureInitialized();
        // Register Google Maps API
        wp_register_script(
            'google-maps-api',
@@ -499,15 +135,42 @@
                'strategy' => 'defer'
            ]
        );
    }
        public function getJavaScriptApiUrl(array $libraries = ['places', 'marker', 'geometry']): string
        {
            if (empty($this->getPublicKey())) {
                return '';
            }
            $params = [
                'key' => $this->getPublicKey(),
                'libraries' => implode(',', $libraries),
                'callback' => 'initGoogleMaps',
            ];
            return $this->apiBase . 'js?' . http_build_query($params);
        }
    public function maybeEnqueueMapsScript():void
    {
        if (!$this->isSetUp()) {
            return;
        }
        if (!$this->shouldEnqueueMaps()) {
            return;
        }
        // Add initialization script
        wp_add_inline_script('google-maps-api', '
        wp_add_inline_script('google-maps-api',
            sprintf(
            '
        // Set map defaults
        window.jvbMapDefaults = {
            lat: ' . self::DEFAULT_LAT . ',
            lng: ' . self::DEFAULT_LNG . ',
            zoom: ' . self::DEFAULT_ZOOM . ',
            mapId: "' . esc_js($this->map_id) . '"
                    lat: %s,
                    lng: %s,
                    zoom: %s,
                    mapId: "%s"
        };
        // Google Maps initialization callback
@@ -528,7 +191,13 @@
        // Initialize callback queue
        window.googleMapsCallbacks = window.googleMapsCallbacks || [];
        ', 'before');
                ',
                $this->getDefaultLat(),
                $this->getDefaultLng(),
                $this->getDefaultZoom(),
                esc_js($this->getMapID())
            ),
            'before');
        // Enqueue scripts
        wp_enqueue_script('google-maps-api');
@@ -541,134 +210,53 @@
            'service' => $this->service_name
        ]);
    }
    /**
     * Check if maps should be enqueued on current page
     */
    private function shouldEnqueueMaps(): bool
        protected function shouldEnqueueMaps():bool
    {
        // Check for shortcodes
            //See if it's a singular post/page that has the map feature flagged
        if (is_singular() && get_post_meta(get_the_ID(), BASE . 'has_map', true) === true) {
            return true;
        }
        if (!empty(Registrar::withFeature('is_content', 'term')) && get_term_meta(get_queried_object_id(), BASE . 'has_map', true) === true) {
            if (is_tax() && 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
    public function getServiceDescription():string
    {
        $parsed = [
            'street_number' => '',
            'street' => '',
            'city' => '',
            'province' => '',
            'country' => '',
            'postal_code' => ''
        ];
        foreach ($components as $component) {
            $types = $component['types'];
            if (in_array('street_number', $types)) {
                $parsed['street_number'] = $component['long_name'];
        return 'Allows for a map field, utilizing Google Maps';
            }
            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
    /**********************************************************************
     * UTILITY
    **********************************************************************/
    protected function getPrivateKey():string|false
    {
        return $this->map_id;
        return $this->loadCredentials()['private_key']??false;
    }
    /**
     * Get default map settings
     */
    public function getDefaultMapSettings(): array
    protected function getPublicKey():string|false
    {
        return [
            'center' => [
                'lat' => self::DEFAULT_LAT,
                'lng' => self::DEFAULT_LNG
            ],
            'zoom' => $this->credentials['default_zoom'] ?? self::DEFAULT_ZOOM,
            'mapId' => $this->map_id,
            'styles' => $this->getMapStyles()
        ];
        return $this->loadCredentials()['public_key']??false;
    }
    /**
     * Get map styles based on theme
     */
    private function getMapStyles(): array
    protected function getMapID():string|false
    {
        $theme = $this->credentials['theme'] ?? 'default';
        switch ($theme) {
            case 'dark':
                return [
                    ['elementType' => 'geometry', 'stylers' => [['color' => '#242f3e']]],
                    ['elementType' => 'labels.text.fill', 'stylers' => [['color' => '#746855']]],
                    ['elementType' => 'labels.text.stroke', 'stylers' => [['color' => '#242f3e']]],
                    ['featureType' => 'road', 'elementType' => 'geometry', 'stylers' => [['color' => '#38414e']]],
                    ['featureType' => 'water', 'elementType' => 'geometry', 'stylers' => [['color' => '#17263c']]]
                ];
            case 'minimal':
                return [
                    ['featureType' => 'poi', 'stylers' => [['visibility' => 'off']]],
                    ['featureType' => 'transit', 'stylers' => [['visibility' => 'off']]]
                ];
            default:
                return [];
        return $this->loadCredentials()['map_id']??false;
        }
    protected function getDefaultZoom():string|false
    {
        return $this->loadCredentials()['default_zoom']??false;
    }
    protected function getDefaultLng():string|false
    {
        return $this->loadCredentials()['default_lng']??false;
    }
    protected function getDefaultLat():string|false
    {
        return $this->loadCredentials()['default_lat']??false;
    }
    /**
     * Generate map application links
@@ -683,152 +271,108 @@
            'waze' => "https://waze.com/ul?ll={$lat},{$lng}&navigate=yes"
        ];
    }
    /**
     * Render a frontend display map
     */
    public function renderDisplayMap(array $location, array $options = []): string
    private function parseAddressComponents(array $components):array
    {
        if (!$this->isSetUp() || empty($location['lat']) || empty($location['lng'])) {
            return '';
        $parsed = [];
        $check = [
            'street_number' => 'long_name',
            'street'        => 'long_name',
            'city'          => 'long_name',
            'province'      => 'short_name',
            'country'       => 'short_name',
            'postal_code'   => 'long_name'
        ];
        foreach ($components as $c) {
            $types = $c['types'];
            foreach ($check as $ch => $index) {
                if (in_array($ch, $types)) {
                    $parsed[$ch] = $c[$index];
                }
            }
        }
        if (!empty($parsed['street_number']) && !empty($parsed['street'])) {
            $parsed['street'] = $parsed['street_number'] . ' ' . $parsed['street'];
        }
        return $parsed;
        }
        $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
    public function reverseGeocode(float $lat, float $lng):array|false
    {
        $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;
        if (!$this->isSetUp()) {
            return false;
        }
        $params = [
            'latlng'    => "{$lat},{$lng}",
            'key'       => $this->getPrivateKey()
        ];
        return $this->getRequest(
            sprintf(
                '%s/geocode/json',
                $this->apiBase,
            ),
            $params
        );
    }
        protected function handleGeocodeResponse(array $data, string $method):array
        {
            if (!array_key_exists('status', $data) || $data['status'] !== 'OK' || !array_key_exists('results', $data) || empty($data['results'])) {
                return $this->response(false, 'Invalid response provided');
            }
            $result = $data['results'][0];
            return $this->response(true, 'Successfully reversed geocode',[
                'lat'               => $result['geometry']['location']['lat'],
                'lng'               => $result['geometry']['location']['lng'],
                'formatted_address' => $result['formatted_address'],
                'place_id'          => $result['place_id']??'',
                'components'        => $this->parseAddressComponents($result['address_compenents']??[])
            ]);
            }
            $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)
    public function geocodeAddress(string $address):array|false
    {
        if (!$this->isSetUp()) {
            return false;
        }
        $params = [
            'address'   => $address,
            'key'       => $this->getPrivateKey()
        ];
        return $this->getRequest(
            sprintf(
                '%s/geocode/json',
                $this->apiBase
            )
            );
        }
        $output .= '</div>';
        return $output;
    }
    /**
     * Format address for display
     */
    public function formatAddress(array $location, string $format = 'full'): string
    protected function getRequestHeaders(): array
    {
        if (!empty($location['address'])) {
            return $location['address'];
        return [
            'Accept' => 'application/json',
            'Content-Type' => 'application/json'
        ];
        }
        // 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'];
    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');
                }
                break;
            case 'city_only':
                if (!empty($location['city'])) $parts[] = $location['city'];
                break;
        $endpoint = str_replace($this->apiBase, '', $endpoint);
        return  match ($endpoint) {
            '/geocode/json' => $this->handleGeocodeResponse($response, $method),
            default => $this->response(false, 'Unhandled response for '.$endpoint.' endpoint, with method: '.$method),
        };
        }
        return implode(', ', $parts);
    protected function performConnectionTest():bool
    {
        if (!$this->isSetUp()) {
            return false;
        }
        $test = 'Edmonton, Alberta, Canada';
        $result = $this->geocodeAddress($test);
        return $result['success'];
    }
}
inc/integrations/services/GoogleMyBusiness.php
@@ -1,7 +1,15 @@
<?php
namespace JVBase\integrations\services;
use Exception;
use JVBase\integrations\Admin;
use JVBase\integrations\Instance;
use JVBase\integrations\Integrations;
use JVBase\meta\Meta;
use JVBase\integrations\OAuth;
use JVBase\integrations\OAuthCredentials;
use JVBase\integrations\OAuthURLs;
use JVBase\integrations\Requests;
use JVBase\integrations\UserConnection;
if (!defined('ABSPATH')) {
    exit;
@@ -9,114 +17,78 @@
class GoogleMyBusiness extends Integrations
{
    protected array $allowedContent = [
        'menu_item',
        'post',
        'event',
    ];
    private ?string $access_token = null;
    use Admin, Instance, OAuth, Requests, UserConnection;
    protected string $readMask = 'name,title,storefrontAddress,metadata,openInfo,storeCode,categories,phoneNumbers,labels,specialHours';
    private ?string $location = null;
    private ?string $refresh_token = null;
    private ?string $client_id = null;
    private ?string $client_secret = null;
    private ?string $account_id = null;
    protected static array $instances = [];
    public static function getInstance(?int $userID = null):self
    {
        $key = is_null($userID) ? 'base' : $userID;
        if (!array_key_exists($key, self::$instances)) {
            self::$instances[$key] = new self($userID);
        }
        return self::$instances[$key];
    }
    protected function __construct(?int $userID = null)
    protected function __construct()
    {
        $this->service_name = 'gmb';
        $this->title = 'Google My Business';
        $this->icon = 'storefront';
        $this->cacheName = ($userID)? 'gmb_'.$userID : 'gmb';
        $this->canSync = [
            'initial'   => true,
            'update'    => true,
            'delete'    => true,
        ];
        $this->apiBase = [
            'base' => 'https://mybusinessbusinessinformation.googleapis.com',
            'accounts' => 'https://mybusinessaccountmanagement.googleapis.com',
            'posts' => 'https://mybusiness.googleapis.com',
            'performance'=> 'https://businessprofileperformance.googleapis.com',
            'v4' => 'https://mybusiness.googleapis.com/v4',
        ];
        $this->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->defineFields();
        $this->defineInstructions();
        $this->defineOAuth();
        $this->defineActions();
        $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.'
//          ]
        ];
        parent::__construct();
    }
        protected function defineFields():void
        {
            $client_id = $this->addField(
                'client_id',
                'OAuth Client ID',
                'text',
                'manage_options'
            );
                $client_id->setRequired();
                $client_id->setHint('From your Google Cloud Console OAuth credentials.');
        $this->advanced = [
            $secret = $this->addField(
                'client_secret',
                'OAuth Client Secret',
                'text',
                'manage_options',
            );
                $secret->setSubType('password');
                $secret->setRequired();
        ];
            if ($this->getAccessToken()) {
                $accs = $this->addField('accounts', 'Accounts', 'select', [$this, 'testUserCanConnect']);
                    $accs->setHidden();
                    $accs->setOptions($this->getAccountOptions());
                $acc = $this->addField('account_id', 'Set Account', 'select', [$this, 'testUserCanConnect']);
                    $acc->setOptions($this->loadCredentials()['accounts']??[]);
                    $acc->setHint('Select the account to connect to.');
                if ($this->getAccountID()) {
                    $locs = $this->addField('locations', 'Locations', 'select', [$this, 'testUserCanConnect']);
                    $locs->setHidden();
                    $locs->setOptions($this->getLocationOptions());
                    $loc = $this->addField('location_id', 'Set Location', 'select', [$this, 'testUserCanConnect']);
                    $loc->setOptions($this->loadCredentials()['locations']??[]);
                    $loc->setHint('Select the location to connect to.');
                }
            }
        }
            public function testUserCanConnect():bool
            {
                $user = get_current_user_id();
                return $this->userCanConnect($user);
            }
        protected function defineInstructions():void
        {
        $this->instructions = [
            'Go to <a href="https://console.cloud.google.com/apis/credentials" target="_blank">Google Cloud Console</a>',
            'Create OAuth 2.0 Client ID credentials',
@@ -124,2851 +96,158 @@
            '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 defineOAuth():void
        {
            $this->oauth = new OAuthURLs(
                'https://accounts.google.com/o/oauth2/v2/auth',
                'https://oauth2.googleapis.com/token',
                'https://oauth2.googleapis.com/revoke'
            );
            $this->oathScopes = [
                'https://www.googleapis.com/auth/business.manage',
                'https://www.googleapis.com/auth/userinfo.email',
                'https://www.googleapis.com/auth/userinfo.profile',
                'https://www.googleapis.com/auth/plus.business.manage'
            ];
        }
        protected function defineActions():void
        {
            $this->addAction(
                'Update Location',
                [$this, 'handleUpdateLocation'],
                'update_location',
            );
        }
            public function handleUpdateLocation(array $data):array
            {
                if (empty($data['location_id']??'')) {
                    return $this->response(false, 'No location selected');
                }
                $credentials = [
                    'location_id'   => sanitize_text_field($data['location_id'])
                ];
                return $this->saveCredentials($credentials, false);
    }
    protected function initialize(): void
    {
        if (empty($this->credentials)) {
            $this->loadCredentials();
        }
        $this->access_token = (array_key_exists('access_token', $this->credentials)) ? $this->credentials['access_token'] : null;
        $this->refresh_token = (array_key_exists('refresh_token', $this->credentials)) ? $this->credentials['refresh_token'] : null;
        $this->client_id = (array_key_exists('client_id', $this->credentials)) ? $this->credentials['client_id'] : null;
        $this->client_secret = (array_key_exists('client_secret', $this->credentials)) ? $this->credentials['client_secret'] : null;
        $this->location = (array_key_exists('location', $this->credentials)) ? $this->credentials['location'] : null;
        $this->account_id = (array_key_exists('account', $this->credentials)) ? $this->credentials['account'] : null;
        if ($this->account_id) {
            $this->apiEndpoints[] = "/v1/{$this->account_id}/locations";
        }
        if ($this->location) {
            $locationName = $this->getSelectedLocationResourceName();
            $this->apiEndpoints[] = "/v4/{$locationName}/localPosts";
        }
    }
    protected function performConnectionTest(): bool
    {
        try {
            $accounts = $this->getAccounts(true);
            return !empty($accounts);
        } catch (\Exception $e) {
            return false;
        }
    }
    /**
     * Check if response contains an error - Google-specific
     */
    protected function isErrorResponse(array $response): bool
    {
        // Google APIs return errors in this format:
        // {"error": {"code": 401, "message": "...", "status": "UNAUTHENTICATED"}}
        return isset($response['error']) && isset($response['error']['code']);
        // TODO: Implement initialize() method.
    }
    protected function getRequestHeaders(): array
    {
        return [
            'Authorization' => 'Bearer ' . $this->access_token,
            'Content-Type' => 'application/json',
            'Accept' => 'application/json'
        ];
        // TODO: Implement getRequestHeaders() method.
    }
    protected function addOAuthParams(array $params): array
    protected function handleResponse(\WP_Error|array $response, string $method, string $endpoint): array
    {
        $params['access_type'] = 'offline';
        $params['prompt'] = 'consent';
        return $params;
        if (is_wp_error($response) && $response->get_error_message() === 'rate_limit_exceeded') {
            return $this->response(false, 'Rate limit exceeded');
    }
    protected function addCredentialData(array $credentials, array $tokens): array
        return match($endpoint) {
            'https://mybusinessaccountmanagement.googleapis.com/v1/accounts' => $this->handleGetAccountsResponse($response, $method),
            sprintf('https://mybusinessbusinessinformation.googleapis.com/v1/%s/locations', $this->getAccountID()??'') => $this->handleGetLocationsResponse($response, $method),
            default => $this->response(false, 'Unhandled response for '.$endpoint.' endpoint, with method: '.$method)
        };
    }
    protected function refreshAccessToken(OAuthCredentials $credentials): array
    {
        // Preserve GMB-specific settings
        $existing = $this->credentials;
        $credentials['location'] = $existing['location'] ?? null;
        $credentials['account'] = $existing['account'] ?? null;
        return $credentials;
        // TODO: Implement refreshAccessToken() method.
    }
    public function handleTheSavePost(int $postID, \WP_Post $post, bool $update, array $settings):void
    /*********************************************************
     * UTILITY
    *********************************************************/
    protected function getAccountID():string|false
    {
        if (!$this->hasOAuthCredentials()) {
            error_log('OAuth Not set up for '.$this->service_name);
            return;
        return $this->loadCredentials()['account_id']??false;
        }
        $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
    protected function getAccountOptions(bool $force = false):array
    {
        $stored = $this->loadCredentials()['accounts']??[];
        if ($force || empty($stored)) {
            $stored = $this->getAccounts();
        }
        return $stored;
    }
    protected function getLocationID():string|false
    {
        return $this->loadCredentials()['location_id']??false;
    }
    protected function getLocationOptions(bool $force = false):array
    {
        $stored = $this->loadCredentials()['locations']??[];
        if ($force || empty($stored)) {
            $stored = $this->getLocations();
        }
        return $stored;
    }
    protected function getLocations():array|false
    {
        if (!$this->getAccessToken() || !$this->getAccountID()) {
            return false;
        }
        try {
            $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' => [
            return $this->getRequest(
                sprintf(
                    '/v1/%s/locations',
                    $this->getAccountID()
                ),
                    [
                        '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
        } catch (Exception $e)
    {
        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());
            $this->logError('getLocations', 'Could not get locations: '.$e->getMessage());
            return [];
        }
    }
    public function handleGetAllLocations():WP_Error|array
        protected function handleGetLocationsResponse(array $response, string $method):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());
            $locations = [];
            if (isset($response['locations'])) {
                foreach ($response['locations'] as $loc) {
                    if (isset($loc['name']) && isset($loc['title'])) {
                        $locations[$loc['name']] = $loc['title'];
        }
    }
            }
            return $locations;
        }
    /**
     * AJAX handler for updating selected location
     */
    public function handleUpdateLocation($data = null): array
    protected function getAccounts(bool $force = false):array|false
    {
        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());
        if (!$this->getAccessToken()) {
            return false;
        }
    }
    public function getPriceLists(): ?array
    {
        if (!$this->credentials['location']) {
            throw new \Exception('No location selected. Please select a location first.');
        }
        try {
            $location_data = $this->getLocation($this->credentials['location']);
            return $location_data['priceLists'] ?? [];
        } catch (\Exception $e) {
            $this->logError($e->getMessage(), [
                'method'    => 'getPriceLists'
            ]);
            return null;
        }
    }
    /**
     * Get insights/analytics
     */
    public function getInsights(string $location_name, string $start_date, string $end_date): ?array
    {
//      From the Docs:
//      Basic insights
//      Retrieves basic insights for a given list of locations. Use the accounts.locations.reportInsights API to return the insights that are associated with a location.
//
//          To return the basic insights associated with a location, use the following:
//
//      HTTP
//
//      POST
//      https://mybusiness.googleapis.com/v4/accounts/{accountId}/locations:reportInsights
//      {
//          "locationNames": [
//          "accounts/{accountId}/locations/{locationId}"
//      ],
//        "basicRequest": {
//          "metricRequests": [
//                   {
//                       "metric": "QUERIES_DIRECT"
//                   },
//                   {
//                       "metric": "QUERIES_INDIRECT"
//                   }
//                ],
//                "timeRange": {
//              "startTime": "2016-10-12T01:01:23.045123456Z",
//                     "endTime": "2017-01-10T23:59:59.045123456Z"
//                }
//          }
//      }
            $params = [
                'dailyMetrics' => 'BUSINESS_IMPRESSIONS_DESKTOP_MAPS,BUSINESS_IMPRESSIONS_DESKTOP_SEARCH,BUSINESS_IMPRESSIONS_MOBILE_MAPS,BUSINESS_IMPRESSIONS_MOBILE_SEARCH,BUSINESS_DIRECTION_REQUESTS,CALL_CLICKS,WEBSITE_CLICKS',
                'dailyRange.startDate.year' => date('Y', strtotime($start_date)),
                'dailyRange.startDate.month' => date('n', strtotime($start_date)),
                'dailyRange.startDate.day' => date('j', strtotime($start_date)),
                'dailyRange.endDate.year' => date('Y', strtotime($end_date)),
                'dailyRange.endDate.month' => date('n', strtotime($end_date)),
                'dailyRange.endDate.day' => date('j', strtotime($end_date))
            ];
        return $this->getRequest(
            "/v1/{$location_name}:fetchMultiDailyMetricsTimeSeries?" . http_build_query($params),
            $params,
            'performance'
            'https://mybusinessaccountmanagement.googleapis.com/v1/accounts',
            [],
            $force
        );
    }
    /**
     * Helper: Format hours for GMB API
     */
    private function formatHoursPeriods(array $hours): array
        protected function handleGetAccountsResponse(array $response, string $method):array
    {
        $periods = [];
        $days = ['SUNDAY', 'MONDAY', 'TUESDAY', 'WEDNESDAY', 'THURSDAY', 'FRIDAY', 'SATURDAY'];
        foreach ($hours as $day => $times) {
            if (empty($times['open']) || empty($times['close'])) {
                continue;
            $accounts = [];
            if (isset($response['accounts']) && is_array($response['accounts'])) {
                $accounts = $response['accounts'];
            } elseif (is_array($response) && isset($response['name'])) {
                $accounts = [$response];
            }
            $periods[] = [
                'openDay' => $days[$day] ?? $day,
                'openTime' => $times['open'], // Format: "09:00"
                'closeDay' => $days[$day] ?? $day,
                'closeTime' => $times['close'] // Format: "17:00"
            ];
        }
        return $periods;
    }
    /**
     * HELPER: Easy interface method for creating a simple post
     * JVB()->connect('gmb')->createSimplePost("Check out our new menu!", "https://example.com/menu")
     */
    public function createSimplePost(string $message, ?string $cta_url = null, string $cta_type = 'LEARN_MORE'): ?array
    {
        $location_name = $this->getSelectedLocationResourceName();
        if (empty($location_name)) {
            throw new \Exception('No location selected. Please select a location first.');
        }
        $post_data = [
            'summary' => $message,
            'topic_type' => 'STANDARD'
        ];
        if ($cta_url) {
            $post_data['call_to_action'] = [
                'type' => $cta_type,
                'url' => $cta_url
            ];
        }
        return $this->createPost($location_name, $post_data);
    }
    /**
     * HELPER: Easy interface method for updating basic business info
     * JVB()->connect('gmb')->updateInfo(['title' => 'New Business Name', 'websiteUri' => 'https://newsite.com'])
     */
    public function updateInfo(array $updates): bool
    {
        $location_name = $this->getSelectedLocationResourceName();
        if (empty($location_name)) {
            throw new \Exception('No location selected. Please select a location first.');
        }
        return $this->updateBusinessInfo($location_name, $updates);
    }
    /**
     * HELPER: Easy interface for clearing special hours
     * JVB()->connect('gmb')->clearAllSpecialHours()
     */
    public function clearAllSpecialHours(): bool
    {
        $location_name = $this->getSelectedLocationResourceName();
        if (empty($location_name)) {
            throw new \Exception('No location selected. Please select a location first.');
        }
        return $this->clearSpecialHours($location_name);
    }
    protected function mappedMenuFields(string $postType):array
    {
        /**
         * @param array $fields
         * @param string $fields.name Thing
         */
        return apply_filters(
            'jvbGMBFields',
        [
            'name'          => 'post_title',
            'price'         => 'price',
            'description'   => 'post_excerpt',
            'nutritionFacts'=> 'nutritionFacts',
            'servesNumPeople'=>'servesNumPeople',
            'portionSize'   => 'portionSize',
            'preparationMethods' => 'preparationMethods',
            'cuisines'      => 'cuisines',
            'spiciness'     => 'spiciness',
            'allergen'      => 'allergen',
            'dietaryRestriction'=>'dietaryRestriction'
        ],
        jvbNoBase($postType));
    }
    /**
     * Build menu structure from WordPress posts
     */
    protected function collectMenu(array $menu_items): array
    {
        $defaultMeta = Meta::forOptions($this->userID.'_integrations');
        $defaults = ['menu_name', 'menu_description', 'default_section', 'cuisines', 'source_url', 'language', 'default_currency'];
        $defaults = $defaultMeta->getAll($defaults);
        // Group items by section
        $sections_map = [];
        foreach ($menu_items as $item) {
            $gmb_item = $this->buildMenuItem($item, $defaults);
            // Get section for this item
            $categories = wp_get_post_terms($item->ID, BASE.'section');
            $section_slug = !empty($categories) ?
                $categories[0]->slug :
                'menu-items';
            $section_name = !empty($categories) ?
                $categories[0]->name :
                ($defaults['default_section'] ?? 'Menu Items');
            if (!isset($sections_map[$section_slug])) {
                $sections_map[$section_slug] = [
                    'labels'    => [
                        'displayName' => $section_name,
                        'languageCode' => $defaults['language'] ?? 'en',
                    ],
                    'items' => [],
                ];
                if (!empty($section['description'])) {
                    $sections_map[$section_slug]['labels']['description'] = $this->stripHtmlTags($section['description']);
            $options = [];
            foreach ($accounts as $account) {
                if (isset($account['name']) && isset($account['accountName'])) {
                    $options[$account['name']] = $account['accountName'];
                }
            }
            $sections_map[$section_slug]['items'][] = $gmb_item;
            return $options;
        }
        $gmb_sections = $this->getMenuSectionsOrder($sections_map);
        // Build complete menu
        $gmb_menu = [
            'cuisines' => $this->getMenuCuisines($menu_items),
            'labels'    => [
                'displayName' => $defaults['menu_name'] ?? 'Our Menu',
                'languageCode' => $defaults['language'] ?? 'en',
            ],
            'sections' => $gmb_sections
        ];
        if (array_key_exists('menu_description', $defaults)) {
            $gmb_menu['labels']['description'] = $this->stripHtmlTags($defaults['menu_description']);
        }
        // Add source URL if configured
        if (!empty($defaults['source_url'])) {
            $gmb_menu['source_url'] = $defaults['source_url'];
        }
        return [$gmb_menu]; // GMB expects an array of menus
    }
    protected function buildMenuItem(\WP_Post $item, array $defaults):array
    {
        $meta = Meta::forPost($item->ID);
        $fields = $this->mappedMenuFields($item->post_type);
        $values = $meta->getAll(array_values($fields));
        // Build GMB-formatted item
        $gmb_item = [
            'labels'    => [
                'displayName' => $item->post_title,
                'description' => !empty($item->post_excerpt) ?
                    $this->stripHtmlTags($item->post_excerpt) :
                    $this->stripHtmlTags(wp_trim_words($item->post_content, 20)),
                'languageCode' => $defaults['language'] ?? 'en'
            ],
            'attributes'    => []
        ];
        // Process mapped fields
        foreach ($fields as $googleField => $wpField) {
            if (in_array($wpField, ['post_title', 'post_excerpt'])) {
                continue;
            }
            $value = $values[$wpField] ?? null;
            if (empty($value)) {
                continue;
            }
            // Process field based on type
            $processed = $this->processMenuField($googleField, $value, $values);
            if ($processed !== null) {
                $gmb_item['attributes'] = array_merge($gmb_item['attributes'], $processed);
            }
        }
        $featuredImage = get_post_thumbnail_id($item->ID);
        $mediaKey = $this->uploadImageToGMB($featuredImage, 'FOOD_AND_DRINK');
        if ($mediaKey) {
            $gmb_item['attributes'] = array_merge($gmb_item['attributes'], ['mediaKeys' => $mediaKey]);
        }
        if (empty($gmb_item['attributes'])) {
            unset($gmb_item['attributes']);
        }
        return $gmb_item;
    }
    /**
     * Process a single menu field value
     */
    protected function processMenuField(string $fieldName, $value, array $allValues): ?array
    {
        switch ($fieldName) {
            case 'price':
                if (is_numeric($value)) {
                    return [
                        'price' => [
                            'currencyCode'  => $allValues['price_currency'] ?? 'CAD',
                            'units' => intval($value),
                            'nanos' => (($value - intval($value)) * 1000000000)
                        ]
                    ];
                }
                break;
            case 'nutritionFacts':
                if (is_array($value)) {
                    return ['nutritionFacts' => $this->processNutritionData($value)];
                }
                break;
            case 'allergen':
                $allergens = $this->processListField($value);
                return $allergens ? ['allergens' => $allergens] : null;
            case 'dietaryRestriction':
                $restrictions = $this->processListField($value);
                return $restrictions ? ['dietaryRestrictions' => $restrictions] : null;
            case 'servesNumPeople':
                return ['servesNumPeople' => intval($value)];
            case 'portionSize':
                return ['portionSize' => $value];
            case 'preparationMethods':
                $methods = $this->processListField($value);
                return $methods ? ['preparationMethods' => $methods] : null;
            case 'cuisines':
                $cuisines = $this->processListField($value, 'strtoupper');
                return $cuisines ? ['cuisines' => $cuisines] : null;
            case 'spiciness':
                return ['spiciness' => strtoupper($value)];
            default:
                return [$fieldName => $value];
        }
        return null;
    }
    /**
     * Process list fields (arrays or comma-separated strings)
     */
    protected function processListField($value, ?callable $transform = null): ?array
    {
        if (empty($value)) {
            return null;
        }
        // Convert to array if string
        if (is_string($value)) {
            $value = array_map('trim', explode(',', $value));
        }
        // Filter empty values
        $value = array_values(array_filter($value));
        // Apply transformation if provided
        if ($transform && !empty($value)) {
            $value = array_map($transform, $value);
        }
        return !empty($value) ? $value : null;
    }
    protected function getMenuSectionsOrder(array $sections_map):array
    {
        $optionsMeta = Meta::forOptions('options');
        $sectionOrder = $optionsMeta->get('menu_section_order');
        // Build final GMB menu structure
        $ordered = [];
        // First add sections in configured order
        foreach ($sectionOrder as $section) {
            $slug = sanitize_title($section['name']);
            if (isset($sections_map[$slug]) && !empty($sections_map[$slug]['items'])) {
                $ordered[$slug] = $sections_map[$slug];
                unset($sections_map[$slug]);
            }
        }
        // Add any remaining sections
        foreach ($sections_map as $slug => $section) {
            if (!empty($section['items'])) {
                $ordered[$slug] = $section;
            }
        }
        return array_values($ordered);
    }
    private function getMenuCuisines(array $menu_items, array $defaults = []): array
    {
        $cuisines = [];
        // Check if there's a default cuisine
        if (!empty($defaults['cuisines'])) {
            $cuisines = is_array($defaults['cuisines']) ?
                $defaults['cuisines'] :
                [$defaults['cuisines']];
        }
        // Collect cuisines from individual items if specified
        foreach ($menu_items as $item) {
            $meta = Meta::forPost($item->ID);
            $item_cuisines = $meta->get('cuisines');
            if (!empty($item_cuisines)) {
                $item_cuisines = is_array($item_cuisines) ?
                    $item_cuisines :
                    array_map('trim', explode(',', $item_cuisines));
                foreach ($item_cuisines as $cuisine) {
                    $cuisine = strtoupper(trim($cuisine));
                    if (!in_array($cuisine, $cuisines)) {
                        $cuisines[] = $cuisine;
                    }
                }
            }
        }
        // Ensure we have at least one cuisine (GMB requires it)
        if (empty($cuisines)) {
            $cuisines = ['AMERICAN'];
        }
        return array_map('strtoupper', $cuisines);
    }
    /**
     * Format nutrition facts for GMB API
     */
        protected function processNutritionData(array $nutritionData): array
    {
        $processed = [];
        // Handle both repeater format and simple array format
        if (isset($nutritionData[0]) && is_array($nutritionData[0])) {
            // Repeater format
            foreach ($nutritionData as $fact) {
                if (!empty($fact['name']) && isset($fact['amount'])) {
                    $processed[] = [
                        'name' => $fact['name'],
                        'amount' => floatval($fact['amount']),
                        'unit' => $fact['unit'] ?? 'GRAM'
                    ];
                }
            }
        } else {
            // Simple associative array format
            foreach ($nutritionData as $name => $value) {
                if (!empty($value)) {
                    $unit = 'GRAM';
                    if ($name === 'calories') {
                        $unit = 'CALORIE';
                    } elseif (in_array($name, ['cholesterol', 'sodium', 'totalCarbohydrate', 'protein'])) {
                        $unit = 'MILLIGRAM';
                    }
                    $processed[] = [
                        'name' => $name,
                        'amount' => floatval($value),
                        'unit' => $unit
                    ];
                }
            }
        }
        return $processed;
    }
    protected function setContentTypes():void
    {
        $this->contentTypes = [
            'menu_item' => [
                'nutritionFacts'    => [
                    'type'  => 'repeater',
                    'label' => 'Nutrition',
                    'fields'    => [
                        'name'  => [
                            'type'  => 'select',
                            'label' => 'Name',
                            'options'   => [
                                'calories' => 'Calories',
                                'totalFat'  => 'Total Fat',
                                'cholesterol' => 'Cholesterol',
                                'sodium'    => 'Sodium',
                                'totalCarbohydrate' => 'Total Carbohydrates',
                                'protein'   => 'Protein'
                            ]
                        ],
                        'amount'    => [
                            'type'  => 'number',
                            'label' => 'Amount',
                        ],
                        'unit'  => [
                            'type' => 'select',
                            'label' => 'Unit',
                            'options'   => [
                                'MILLIGRAM' => 'Milligram',
                                'GRAM'  => 'Gram',
                                'CALORIE'   => 'Calorie'
                            ]
                        ]
                    ],
                    'description'   => 'Recommended. Example: Total Fat: 3g',
                    'section'   => 'gmb',
                ],
                'servesNumPeople'   => [
                    'type'  => 'number',
                    'label' => 'Number of People this Serves',
                    'bulkEdit'  => true,
                    'section'   => 'gmb',
                ],
                'portionSize'   => [
                    'type'  => 'text',
                    'label' => 'Portion size',
                    'description' => 'Example: 8-piece of nuggets',
                    'bulkEdit'  => true,
                    'section'   => 'gmb',
                ],
                'preparationMethods' => [
                    'type'  => 'textarea',
                    'label' => 'Preparation Methods',
                    'description'   => 'Specific methods that the food can be prepared in',
                    'bulkEdit'  => true,
                    'section'   => 'gmb',
                ],
                'cuisines'  => [
                    'type'  => 'text',
                    'label' => 'Cuisines',
                    'description'   => 'Recommended. The specific cuisine of the food item',
                    'bulkEdit'  => true,
                    'section'   => 'gmb',
                ],
                'spiciness' => [
                    'type'  => 'select',
                    'label' => 'Spiciness',
                    'options'   => [
                        'none'  => 'None',
                        'mild'  => 'Mild',
                        'medium'    => 'Medium',
                        'hot'   => 'Hot',
                    ],
                    'bulkEdit'  => true,
                    'section'   => 'gmb',
                ],
                'allergen'  => [
                    'type'  => 'checkbox',
                    'label' => 'Allergens',
                    'options'   => [
                        'dairy' => 'Dairy',
                        'egg'   => 'Egg',
                        'fish'  => 'Fish',
                        'peanut'    => 'Peanut',
                        'shellfish' => 'Shellfish',
                        'soy'       => 'Soy',
                        'tree nut'  => 'Tree Nut',
                        'wheat'     => 'Wheat'
                    ],
                    'description'   => 'Recommended',
                    'bulkEdit'  => true,
                    'section'   => 'gmb',
                ],
                'dietaryRestriction'    => [
                    'type'  => 'checkbox',
                    'label' => 'Dietary Restrictions',
                    'description' => 'Recommended.',
                    'options'   => [
                        'halal' => 'Halal',
                        'kosher'    => 'Kosher',
                        'organic'   => 'Organic',
                        'vegan'     => 'Vegan',
                        'vegetarian'    => 'Vegetarian',
                        'gluten-free'   => 'Gluten Free'
                    ],
                    'bulkEdit'  => true,
                    'section'   => 'gmb',
                ]
            ],
            'post'  => [
                'summary' => [
                    'type' => 'textarea',
                    'label' => 'Post Summary',
                    'required' => true
                ],
                'language_code' => [
                    'type' => 'text',
                    'label' => 'Language',
                    'default' => 'en-CA'
                ],
                'media' => [
                    'type' => 'image',
                    'label' => 'Photo',
                ],
                'call_to_action' => [
                    'type' => 'group',
                    'fields' => [
                        'type' => [
                            'type' => 'select',
                            'options' =>
                                [
                                    'BOOK' => 'Book Now',
                                    'ORDER' => 'Order Now',
                                    'SHOP' => 'Shop Now',
                                    'LEARN_MORE' => 'Learn More',
                                    'SIGN_UP' => 'Sign up',
                                    'CALL' => 'Call'
                                ]
                        ],
                        'url' => [
                            'type' => 'url',
                            'label' => 'Action URL'
                        ]
                    ]
                ]
            ],
            'event' => [
                'summary' => [
                    'type' => 'textarea',
                    'label' => 'Event Description',
                    'required' => true
                ],
                'event' => [
                    'title' => [
                        'type' => 'text',
                        'label' => 'Event Title',
                        'required' => true
                    ],
                    'start_date' => [
                        'type' => 'date',
                        'label' => 'Start Date',
                        'required' => true
                    ],
                    'end_date' => [
                        'type' => 'date',
                        'label' => 'End Date'
                    ],
                    'start_time' => [
                        'type' => 'time',
                        'label' => 'Start Time'
                    ],
                    'end_time' => [
                        'type' => 'time',
                        'label' => 'End Time'
                    ]
                ],
                'media' => [
                    'type' => 'image',
                    'label' => 'Event Photo'
                ]
            ],
            'offer' => [
                'summary' => [
                    'type' => 'textarea',
                    'label' => 'Offer Description',
                    'required' => true
                ],
                'offer' => [
                    'coupon_code' => [
                        'type' => 'text',
                        'label' => 'Coupon Code'
                    ],
                    'redeem_online_url' => [
                        'type' => 'url',
                        'label' => 'Redemption URL'
                    ],
                    'terms_conditions' => [
                        'type' => 'textarea',
                        'label' => 'Terms & Conditions']
                ],
                'media' => ['type' => 'image', 'label' => 'Offer Photo']
            ]
        ];
    }
}
inc/integrations/services/Helcim.php
@@ -1,6 +1,8 @@
<?php
namespace JVBase\integrations\services;
use JVBase\integrations\Admin;
use JVBase\integrations\Instance;
use JVBase\integrations\Integrations;
use JVBase\managers\queue\executors\IntegrationExecutor;
use JVBase\managers\queue\TypeConfig;
@@ -24,1336 +26,10 @@
 */
class Helcim extends Integrations
{
    protected static string $syncCustomer = 'helcim_sync_customer';
    protected string $service_name = 'helcim';
    protected string|array $apiBase = 'https://api.helcim.com/v2';
    protected bool $isOAuthService = false;
    /**
     * Helcim API rate limits
     * @see https://devdocs.helcim.com/docs/api-rate-limits
     */
    protected array $rate_limits = [
        'per_second' => 5,
        'per_minute' => 60,
        'per_hour'   => 1000
    ];
    protected static array $instances = [];
    public static function getInstance(?int $userID = null):self
    {
        $key = is_null($userID) ? 'base' : $userID;
        if (!array_key_exists($key, self::$instances)) {
            self::$instances[$key] = new self($userID);
        }
        return self::$instances[$key];
    }
    protected function __construct(?int $userID = null)
    {
        $this->title = 'Helcim';
        $this->icon  = 'credit-card';
        $this->fields = [
            'api_token' => [
                'type'     => 'text',
                'subtype'  => 'password',
                'label'    => 'API Token',
                'hint'     => 'Found in Helcim Dashboard → Settings → API Access',
                'required' => true,
            ],
            'currency' => [
                'type'    => 'select',
                'label'   => 'Currency',
                'options' => [
                    'CAD' => 'CAD',
                    'USD' => 'USD',
                ],
                'default' => 'CAD',
            ],
        ];
        $this->advanced = [
            'fee_saver' => [
                'type'  => 'true_false',
                'label' => 'Fee Saver',
                'hint'  => 'Pass processing fees to customers (not compatible with Google Pay)',
            ],
            'allow_ach' => [
                'type'  => 'true_false',
                'label' => 'Allow ACH/Bank Payments',
                'hint'  => 'Enable bank account payments alongside credit card',
            ],
        ];
        $this->instructions = [
            'Go to <a href="https://my.helcim.com" target="_blank">Helcim Dashboard</a>',
            'Navigate to Settings → API Access',
            'Create a new API Access Configuration',
            'Enable permissions: General (Customers, Invoices, Products), Transaction Processing',
            'Copy the API Token and paste it below',
        ];
        $this->canSync = [
            'create' => true,
            'update' => true,
            'delete' => false,
        ];
        $this->hasWebhooks = false;
        parent::__construct();
        // Helcim-specific actions (processAction dispatches these)
        $this->actions = array_merge($this->actions, [
            'initialize_checkout' => 'initializeCheckout',
            'get_invoices'        => 'handleGetInvoices',
            'get_invoice'         => 'handleGetInvoice',
            'get_customer_cards'  => 'handleGetCustomerCards',
        ]);
        $this->buttons = array_merge($this->buttons, [
            'import_from_helcim' => 'Import Products from Helcim',
            'sync_to_helcim'    => 'Sync Site to Helcim',
        ]);
    }
    /*****************************************************************
     * ABSTRACT IMPLEMENTATIONS
     *****************************************************************/
    use Admin, Instance;
    protected function initialize(): void
    {
        if (empty($this->credentials)) {
            $this->loadCredentials();
        }
        $this->apiEndpoints = [
            'connection-test',
            'helcim-pay/initialize',
            'invoices',
            'customers',
            'payment/purchase',
            'payment/preauth',
            'payment/capture',
            'payment/refund',
            'payment/verify',
            'card-transactions',
            'card-batches',
        ];
    }
    protected function getRequestHeaders(): array
    {
        return [
            'api-token'    => $this->credentials['api_token'] ?? '',
            'Content-Type' => 'application/json',
            'Accept'       => 'application/json',
        ];
    }
    protected function performConnectionTest(): bool
    {
        try {
            $response = $this->getRequest('connection-test', [], null, 'none', true);
            return !is_wp_error($response) && !$this->isErrorResponse($response ?? []);
        } catch (Exception $e) {
            $this->logError('Connection test failed', ['error' => $e->getMessage()]);
            return false;
        }
    }
    /*****************************************************************
     * CONTENT TYPES — product field definitions
     *****************************************************************/
    protected function setContentTypes(): void
    {
        $types = ['REGULAR', 'SERVICE', 'DIGITAL', 'FOOD_AND_BEV', 'EVENT', 'SUBSCRIPTION', 'DONATION'];
        foreach ($types as $type) {
            $t = $type === 'REGULAR' ? null : $type;
            $this->contentTypes[$type] = $this->getHelcimMeta($t);
        }
    }
    public function getAdditionalFields(?string $content_type = null):array
    {
        if ($content_type === 'customer') {
            $array = $this->getCustomerFields();
            return array_combine(
                array_map(fn($k) => '_square_' . $k, array_keys($array)),
                $array
            );
        }
        return array_combine(
            array_map(fn($k) => 'hc_' . $k, array_keys($this->getHelcimMeta($content_type))),
            $this->getHelcimMeta($content_type)
        );
    }
    protected function getCustomerFields():array
    {
        return [];
    }
    /**
     * Get Helcim product meta fields by type.
     *
     * Used by Registrar.php  when helcim is configured
     */
    public function getHelcimMeta(?string $type = null): array
    {
        $fields = [
            // Basic Product Fields
            'price' => [
                'type'        => 'number',
                'bulkEdit'    => true,
                'label'       => 'Price',
                'step'        => 0.01,
                'max'         => 99999,
                'description' => 'Product price'
            ],
            'product_type' => [
                'type'  => 'select',
                'label' => 'Product Type',
                'options' => [
                    'REGULAR'       => 'Regular Product',
                    'SERVICE'       => 'Service',
                    'DIGITAL'       => 'Digital Product',
                    'FOOD_AND_BEV'  => 'Food & Beverage',
                    'EVENT'         => 'Event/Ticket',
                    'SUBSCRIPTION'  => 'Subscription',
                    'DONATION'      => 'Donation'
                ],
                'default' => $type ?? 'REGULAR'
            ],
            'cart_quantity' => [
                'type'   => 'number',
                'label'  => 'Quantity',
                'hidden' => true,
            ],
            // Tax & Shipping
            'tax_exempt' => [
                'type'    => 'true_false',
                'label'   => 'Tax Exempt',
                'section' => 'helcim-tax'
            ],
            'shipping_required' => [
                'type'    => 'true_false',
                'label'   => 'Shipping Required',
                'section' => 'helcim-shipping'
            ],
            'shipping_weight' => [
                'type'        => 'number',
                'label'       => 'Shipping Weight (kg)',
                'step'        => 0.01,
                'section'     => 'helcim-shipping',
                'condition'   => [
                    'field'    => 'shipping_required',
                    'operator' => '==',
                    'value'    => true
                ]
            ],
            // Availability
            'available_online' => [
                'type'    => 'true_false',
                'label'   => 'Available Online',
                'section' => 'helcim-availability',
                'default' => true
            ],
            'available_for_pickup' => [
                'type'    => 'true_false',
                'label'   => 'Available for Pickup',
                'section' => 'helcim-availability',
                'default' => true
            ],
            'available_for_delivery' => [
                'type'    => 'true_false',
                'label'   => 'Available for Delivery',
                'section' => 'helcim-availability',
                'default' => false
            ],
            '_helcim_sku' => [
                'type'        => 'text',
                'label'       => 'SKU',
                'description' => 'Stock keeping unit',
                'section'     => 'helcim-config'
            ],
            // Product Variations
            'product_variations' => [
                'type'        => 'repeater',
                'label'       => 'Product Variations',
                'description' => 'Different versions of this product',
                'add_label'   => 'Add Variation',
                'section'     => 'variations',
                'fields'      => $this->getHelcimVariationMeta($type)
            ],
            // Product Options
            'options' => [
                'type'   => 'group',
                'label'  => 'Product Options',
                'section'=> 'helcim-options',
                'fields' => [
                    'max_order' => [
                        'type'    => 'number',
                        'label'   => 'Maximum per order',
                        'default' => 50
                    ],
                    'min_order' => [
                        'type'    => 'number',
                        'label'   => 'Minimum per order',
                        'default' => 0,
                    ],
                    'step' => [
                        'type'    => 'number',
                        'label'   => 'Order increment',
                        'default' => 1,
                    ],
                    'preparation_time' => [
                        'type'        => 'number',
                        'label'       => 'Preparation time (minutes)',
                        'description' => 'Time needed to prepare this item',
                        'condition'   => [
                            'field'    => 'product_type',
                            'operator' => 'in',
                            'value'    => ['FOOD_AND_BEV', 'SERVICE']
                        ]
                    ]
                ]
            ],
            // Subscription Fields
            'subscription_settings' => [
                'type'      => 'group',
                'label'     => 'Subscription Settings',
                'section'   => 'helcim-subscription',
                'condition' => [
                    'field'    => 'product_type',
                    'operator' => '==',
                    'value'    => 'SUBSCRIPTION'
                ],
                'fields' => [
                    'billing_cycle' => [
                        'type'    => 'select',
                        'label'   => 'Billing Cycle',
                        'options' => [
                            'daily'     => 'Daily',
                            'weekly'    => 'Weekly',
                            'monthly'   => 'Monthly',
                            'quarterly' => 'Quarterly',
                            'yearly'    => 'Yearly'
                        ],
                        'default' => 'monthly'
                    ],
                    'trial_period' => [
                        'type'        => 'number',
                        'label'       => 'Trial Period (days)',
                        'description' => 'Free trial period before billing starts',
                        'default'     => 0
                    ],
                    'setup_fee' => [
                        'type'        => 'number',
                        'label'       => 'Setup Fee',
                        'step'        => 0.01,
                        'description' => 'One-time setup fee'
                    ]
                ]
            ],
            // Food & Beverage Specific
            'food_settings' => [
                'type'      => 'group',
                'label'     => 'Food & Beverage Settings',
                'section'   => 'helcim-food',
                'condition' => [
                    'field'    => 'product_type',
                    'operator' => '==',
                    'value'    => 'FOOD_AND_BEV'
                ],
                'fields' => [
                    'ingredients' => [
                        'type'        => 'textarea',
                        'label'       => 'Ingredients',
                        'description' => 'List ingredients (comma separated)'
                    ],
                    'allergens' => [
                        'type'        => 'checkbox_list',
                        'label'       => 'Allergens',
                        'options'     => [
                            'gluten'   => 'Contains Gluten',
                            'dairy'    => 'Contains Dairy',
                            'nuts'     => 'Contains Nuts',
                            'soy'      => 'Contains Soy',
                            'eggs'     => 'Contains Eggs',
                            'seafood'  => 'Contains Seafood'
                        ]
                    ],
                    'dietary_options' => [
                        'type'    => 'checkbox_list',
                        'label'   => 'Dietary Options',
                        'options' => [
                            'vegetarian'   => 'Vegetarian',
                            'vegan'        => 'Vegan',
                            'gluten_free'  => 'Gluten Free',
                            'dairy_free'   => 'Dairy Free',
                            'keto'         => 'Keto Friendly',
                            'halal'        => 'Halal',
                            'kosher'       => 'Kosher'
                        ]
                    ],
                    'spice_level' => [
                        'type'    => 'range',
                        'label'   => 'Spice Level',
                        'min'     => 0,
                        'max'     => 5,
                        'default' => 0
                    ],
                    'serving_size' => [
                        'type'        => 'text',
                        'label'       => 'Serving Size',
                        'description' => 'e.g., "Serves 2-3"'
                    ]
                ]
            ],
            // Service Specific
            'service_settings' => [
                'type'      => 'group',
                'label'     => 'Service Settings',
                'section'   => 'helcim-service',
                'condition' => [
                    'field'    => 'product_type',
                    'operator' => '==',
                    'value'    => 'SERVICE'
                ],
                'fields' => [
                    'service_duration' => [
                        'type'        => 'number',
                        'label'       => 'Duration (minutes)',
                        'description' => 'Service duration in minutes'
                    ],
                    'booking_required' => [
                        'type'  => 'true_false',
                        'label' => 'Booking Required'
                    ],
                    'capacity' => [
                        'type'        => 'number',
                        'label'       => 'Service Capacity',
                        'description' => 'Maximum number of customers per service'
                    ],
                    'staff_required' => [
                        'type'        => 'number',
                        'label'       => 'Staff Required',
                        'description' => 'Number of staff needed',
                        'default'     => 1
                    ]
                ]
            ],
            // Event Specific
            'event_settings' => [
                'type'      => 'group',
                'label'     => 'Event Settings',
                'section'   => 'helcim-event',
                'condition' => [
                    'field'    => 'product_type',
                    'operator' => '==',
                    'value'    => 'EVENT'
                ],
                'fields' => [
                    'event_date' => [
                        'type'  => 'datetime',
                        'label' => 'Event Date & Time'
                    ],
                    'event_location' => [
                        'type'  => 'text',
                        'label' => 'Event Location'
                    ],
                    'max_attendees' => [
                        'type'  => 'number',
                        'label' => 'Maximum Attendees'
                    ],
                    'early_bird_price' => [
                        'type'        => 'number',
                        'label'       => 'Early Bird Price',
                        'step'        => 0.01,
                        'description' => 'Discounted price for early registrations'
                    ],
                    'early_bird_deadline' => [
                        'type'  => 'date',
                        'label' => 'Early Bird Deadline'
                    ]
                ]
            ]
        ];
        // Add inventory fields if configured
        if ($config['hasInventory'] ?? false) {
            $fields['_helcim_inventory'] = [
                'type'     => 'number',
                'label'    => 'Inventory',
                'bulkEdit' => true,
                'section'  => 'inventory'
            ];
            $fields['track_inventory'] = [
                'type'    => 'true_false',
                'label'   => 'Track Inventory',
                'section' => 'inventory',
                'default' => true
            ];
            $fields['low_stock_threshold'] = [
                'type'        => 'number',
                'label'       => 'Low Stock Alert',
                'description' => 'Alert when stock falls below this level',
                'section'     => 'inventory',
                'default'     => 5
            ];
            $fields['product_variations']['fields']['inventory'] = [
                'type'        => 'number',
                'label'       => 'Stock Quantity',
                'description' => 'Current stock for this variation'
            ];
        }
        return $fields;
    }
    public function getHelcimVariationMeta(?string $type = null):array
    {
        $base = [
            'name' => [
                'type'        => 'text',
                'label'       => 'Variation Name',
                'description' => 'e.g., "Small", "Large", "Red"'
            ],
            'price' => [
                'type'        => 'number',
                'label'       => 'Price',
                'step'        => 0.01,
                'max'         => 99999,
                'description' => 'Price for this variation'
            ],
            'sku' => [
                'type'        => 'text',
                'label'       => 'SKU',
                'description' => 'Stock keeping unit for this variation'
            ],
            'track_inventory' => [
                'type'  => 'true_false',
                'label' => 'Track Inventory',
            ],
            '_helcim_variation_id' => [
                'type'        => 'text',
                'label'       => 'Helcim Variation ID',
                'description' => 'Helcim ID for this variation',
                'hidden'      => true
            ],
            '_helcim_last_sync' => [
                'type'   => 'datetime',
                'label'  => 'Last Sync',
                'hidden' => true
            ],
            'options' => [
                'type'        => 'group',
                'label'       => 'Variation Options',
                'collapsible' => true,
                'fields'      => [
                    'color' => [
                        'type'        => 'color',
                        'label'       => 'Color',
                        'description' => 'Visual color for this variation'
                    ],
                    'size' => [
                        'type'    => 'select',
                        'label'   => 'Size',
                        'options' => [
                            ''      => 'N/A',
                            'xs'    => 'Extra Small',
                            's'     => 'Small',
                            'm'     => 'Medium',
                            'l'     => 'Large',
                            'xl'    => 'Extra Large',
                            'xxl'   => '2X Large',
                            'custom'=> 'Custom'
                        ]
                    ],
                    'custom_size' => [
                        'type'        => 'text',
                        'label'       => 'Custom Size',
                        'condition'   => [
                            'field'    => 'size',
                            'operator' => '==',
                            'value'    => 'custom'
                        ]
                    ],
                    'weight' => [
                        'type'        => 'number',
                        'label'       => 'Weight (kg)',
                        'step'        => 0.01,
                        'description' => 'Weight of this variation'
                    ],
                    'dimensions' => [
                        'type'   => 'group',
                        'label'  => 'Dimensions',
                        'fields' => [
                            'length' => [
                                'type'  => 'number',
                                'label' => 'Length (cm)',
                                'step'  => 0.1
                            ],
                            'width' => [
                                'type'  => 'number',
                                'label' => 'Width (cm)',
                                'step'  => 0.1
                            ],
                            'height' => [
                                'type'  => 'number',
                                'label' => 'Height (cm)',
                                'step'  => 0.1
                            ]
                        ]
                    ]
                ]
            ]
        ];
        $extras = [
            'SERVICE' => [
                'service_duration'  => [
                    'type'        => 'number',
                    'label'       => 'Duration (minutes)',
                    'description' => 'Duration for this service variation'
                ],
                'available_for_booking' => [
                    'type'  => 'true_false',
                    'label' => 'Available for Booking'
                ]
            ],
            'FOOD_AND_BEV'  => [
                'portion_size'  => [
                    'type'    => 'select',
                    'label'   => 'Portion Size',
                    'options' => [
                        'small'    => 'Small',
                        'regular'  => 'Regular',
                        'large'    => 'Large',
                        'family'   => 'Family Size'
                    ]
                ],
                'calories'  => [
                    'type'        => 'number',
                    'label'       => 'Calories',
                    'description' => 'Calorie count for this variation'
                ]
            ],
            'DIGITAL'   => [
                'download_limit'    => [
                    'type'        => 'number',
                    'label'       => 'Download Limit',
                    'description' => 'Maximum number of downloads',
                    'default'     => -1
                ],
                'expiry_days'   => [
                    'type'        => 'number',
                    'label'       => 'Access Duration (days)',
                    'description' => 'Days until download expires',
                    'default'     => 0
                ]
            ]
        ];
        if ($type && array_key_exists($type, $extras)){
            $base = array_merge($base, $extras[$type]);
        }
        return $base;
    }
    /*****************************************************************
     * HELCIMPAY.JS — Frontend Scripts & Checkout Initialization
     *****************************************************************/
    protected function registerAdditionalHooks(): void
    {
        if (!$this->isSetUp()) {
            return;
        }
        add_action('wp_enqueue_scripts', [$this, 'enqueueScripts']);
        // Shared checkout UI (replaces provider-specific outputCheckout)
        add_filter('jvbAdditionalActions', [Checkout::class, 'render']);
        // Checkout description filter
        add_filter('jvb_checkout_description', function (string $desc, string $provider) {
            if ($provider === 'helcim') {
                return 'Securely checkout with your name, email, and payments processed by Helcim.';
            }
            return $desc;
        }, 10, 2);
        // Register webhook endpoint (handled by parent)
        $this->registerWebhookEndpoint();
    }
    public function enqueueScripts(): void
    {
        // HelcimPay.js SDK
        wp_enqueue_script(
            'helcim-pay-sdk',
            'https://secure.helcim.app/helcim-pay/services/start.js',
            [],
            null,
            true
        );
        // Base cart checkout (shared with Square)
        wp_register_script(
            'jvb-checkout',
            JVB_URL . 'assets/js/min/checkout.min.js',
            ['jvb-utility', 'jvb-queue', 'jvb-a11y', 'jvb-cache', 'jvb-tabs', 'jvb-popup'],
            '1.1.31',
            true
        );
        // Helcim checkout (extends CartCheckout)
        wp_register_script(
            'jvb-helcim-checkout',
            JVB_URL . 'assets/js/min/helcim.min.js',
            ['jvb-checkout', 'helcim-pay-sdk'],
            '1.1.31',
            true
        );
        wp_localize_script('jvb-helcim-checkout', 'helcimConfig', [
            'api_url'      => rest_url('jvb/v1/helcim/'),
            'nonce'        => wp_create_nonce('wp_rest'),
            'currency'     => $this->credentials['currency'] ?? 'CAD',
            'is_logged_in' => is_user_logged_in(),
            'user_email'   => is_user_logged_in() ? wp_get_current_user()->user_email : '',
            'isOpen'       => apply_filters('jvb_store_is_open', '1'),
        ]);
        wp_enqueue_script('jvb-helcim-checkout');
    }
    protected function registerAdditionalQueueTypes(IntegrationExecutor $executor): void
    {
        $queue    = JVB()->queue();
        $queue->registry()->register(self::$import, new TypeConfig(
            executor:   $executor,
            maxRetries: 3
        ));
        $queue->registry()->register(self::$syncCustomer, new TypeConfig(
            executor:   $executor,
            maxRetries: 2
        ));
    }
    /**
     * Initialize a HelcimPay.js checkout session.
     *
     * Server-side: POST /helcim-pay/initialize → returns checkoutToken + secretToken.
     * Client-side: appendHelcimPayIframe(checkoutToken) renders the payment modal.
     * Tokens are valid for 60 minutes.
     *
     * @param array $data [
     *     'amount'      => float,   // Required
     *     'invoiceId'   => string,  // Optional — pay a specific invoice
     *     'customerId'  => int,     // Optional — associate with Helcim customer
     *     'paymentType' => string,  // purchase|preauth|verify (default: purchase)
     * ]
     */
    public function initializeCheckout(array $data): array
    {
        if (empty($data['amount']) || (float) $data['amount'] <= 0) {
            return ['success' => false, 'message' => 'Invalid amount'];
        }
        $paymentMethod = !empty($this->credentials['allow_ach']) ? 'cc-ach' : 'cc';
        $body = [
            'paymentType'   => $data['paymentType'] ?? 'purchase',
            'amount'        => (float) $data['amount'],
            'currency'      => $this->credentials['currency'] ?? 'CAD',
            'paymentMethod' => $paymentMethod,
        ];
        if (!empty($data['invoiceId'])) {
            $body['invoiceNumber'] = $data['invoiceId'];
        }
        if (!empty($data['customerId'])) {
            $body['customerId'] = (int) $data['customerId'];
        }
        if (!empty($this->credentials['fee_saver'])) {
            $body['hasConvenienceFee'] = true;
        }
        $response = $this->postRequest('helcim-pay/initialize', $body);
        if (is_wp_error($response)) {
            return ['success' => false, 'message' => $response->get_error_message()];
        }
        if (empty($response['checkoutToken'])) {
            return ['success' => false, 'message' => 'Failed to initialize checkout'];
        }
        return [
            'success'       => true,
            'checkoutToken' => $response['checkoutToken'],
            'secretToken'   => $response['secretToken'] ?? '',
        ];
    }
    /*****************************************************************
     * INVOICES — Helcim is source of truth
     *****************************************************************/
    /**
     * Get invoices for a customer.
     *
     * @param array $data ['email' => string] or ['customerId' => int]
     */
    public function handleGetInvoices(array $data): array
    {
        $customerId = $data['customerId'] ?? null;
        if (empty($customerId) && !empty($data['email'])) {
            $customerId = $this->getCustomerIdByEmail($data['email']);
        }
        if (!$customerId) {
            return ['success' => true, 'invoices' => []];
        }
        $response = $this->getRequest('invoices', ['customerId' => $customerId], null, 'minimal');
        if (is_wp_error($response) || !is_array($response)) {
            return ['success' => false, 'message' => 'Failed to fetch invoices'];
        }
        return ['success' => true, 'invoices' => $response];
    }
    /**
     * Get a single invoice by ID.
     */
    public function handleGetInvoice(array $data): array
    {
        $invoiceId = $data['invoiceId'] ?? null;
        if (!$invoiceId) {
            return ['success' => false, 'message' => 'Invoice ID required'];
        }
        $response = $this->getRequest("invoices/{$invoiceId}", [], null, 'minimal');
        if (is_wp_error($response) || !is_array($response)) {
            return ['success' => false, 'message' => 'Failed to fetch invoice'];
        }
        return ['success' => true, 'invoice' => $response];
    }
    /*****************************************************************
     * CUSTOMERS & CARDS
     *****************************************************************/
    /**
     * Find Helcim customer ID by email.
     */
    public function getCustomerIdByEmail(string $email): ?int
    {
        $cacheKey = 'customer_email_' . md5($email);
        $cached   = $this->cache->get($cacheKey);
        if ($cached !== false) {
            return (int) $cached;
        }
        $response = $this->getRequest('customers', ['search' => $email], null, 'none', true);
        if (is_wp_error($response) || empty($response)) {
            return null;
        }
        $customers = is_array($response) ? $response : [];
        $emailLower = strtolower($email);
        foreach ($customers as $customer) {
            $contactEmail = strtolower($customer['contactEmail'] ?? $customer['email'] ?? '');
            if ($contactEmail === $emailLower) {
                $this->cache->set($cacheKey, $customer['id'], $this->cacheStrategy['aggressive']);
                return (int) $customer['id'];
            }
        }
        return null;
    }
    /**
     * Get or create a Helcim customer.
     */
    public function getOrCreateCustomer(array $info): ?int
    {
        if (empty($info['email'])) {
            return null;
        }
        $existing = $this->getCustomerIdByEmail($info['email']);
        if ($existing) {
            return $existing;
        }
        $response = $this->postRequest('customers', [
            'contactName'  => $info['name'] ?? '',
            'contactEmail' => $info['email'],
            'cellphone'    => $info['phone'] ?? '',
        ]);
        if (is_wp_error($response) || empty($response['id'])) {
            return null;
        }
        return (int) $response['id'];
    }
    /**
     * Get saved cards for a customer.
     */
    public function handleGetCustomerCards(array $data): array
    {
        $customerId = $data['customerId'] ?? null;
        if (empty($customerId) && !empty($data['email'])) {
            $customerId = $this->getCustomerIdByEmail($data['email']);
        }
        if (!$customerId) {
            return ['success' => true, 'cards' => []];
        }
        $response = $this->getRequest("customers/{$customerId}/cards", [], null, 'moderate');
        if (is_wp_error($response) || !is_array($response)) {
            return ['success' => false, 'message' => 'Failed to fetch cards'];
        }
        return ['success' => true, 'cards' => $response];
    }
    /**
     * Get bank accounts for a customer.
     */
    public function getCustomerBankAccounts(int $customerId): array
    {
        $response = $this->getRequest("customers/{$customerId}/bank-accounts", [], null, 'moderate');
        return (!is_wp_error($response) && is_array($response)) ? $response : [];
    }
    /*****************************************************************
     * TRANSACTIONS
     *****************************************************************/
    public function getTransactions(array $params = []): array
    {
        $response = $this->getRequest('card-transactions', $params, null, 'minimal');
        return (!is_wp_error($response) && is_array($response)) ? $response : [];
    }
    public function refundPayment(array $data): array
    {
        $response = $this->postRequest('payment/refund', $data);
        if (is_wp_error($response)) {
            return ['success' => false, 'message' => $response->get_error_message()];
        }
        return ['success' => true, 'transaction' => $response];
    }
    /*****************************************************************
     * PRODUCT SYNC
     *****************************************************************/
    protected function handleTheSavePost(int $postID, \WP_Post $post, bool $update, array $settings): void
    {
        error_log('==== [Helcim]::handleTheSavePost ====');
        $this->queueOperation(self::$syncTo, [
            'items'   => [$postID],
            'user'      => user_can($post->post_author, 'manage_options') ? null : $post->post_author
        ], [
            'priority' => 'high',
            'delay'    => 30,
        ]);
        Meta::forPost($postID)->set('_'.$this->service_name.'_sync_status', 'queued');
    }
    /**
     * Handle post deletion
     */
    public function handleDeletePost(int $postID): void
    {
        $item_id = Meta::forPost($postID)->get("_{$this->service_name}_item_id");
        if (empty($item_id)) {
            return;
        }
        $this->queueOperation(self::$deleteFrom, [
            'external_ids' => [$item_id],
            'post_id'      => $postID,
        ], [
            'priority' => 'high',
        ]);
    }
    protected function handleImportFromHelcim(): array
    {
        $this->queueOperation('import_products', [
            'user_id' => $this->userID,
        ], ['priority' => 'normal']);
        return ['success' => true, 'message' => 'Import from Helcim queued'];
    }
    /*****************************************************************
     * USER ↔ CUSTOMER LINKING
     *****************************************************************/
    public function linkUserToCustomer(int $userId, int $helcimCustomerId): void
    {
        update_user_meta($userId, BASE . '_helcim_customer_id', $helcimCustomerId);
    }
    public function getUserCustomerId(int $userId): ?int
    {
        $id = get_user_meta($userId, BASE . '_helcim_customer_id', true);
        return $id ? (int) $id : null;
    }
    /**
     * Resolve customer ID from user meta, falling back to email lookup + auto-link.
     */
    public function resolveCustomerId(int $userId): ?int
    {
        $id = $this->getUserCustomerId($userId);
        if ($id) {
            return $id;
        }
        $user = get_userdata($userId);
        if (!$user || empty($user->user_email)) {
            return null;
        }
        $id = $this->getCustomerIdByEmail($user->user_email);
        if ($id) {
            $this->linkUserToCustomer($userId, $id);
        }
        return $id;
    }
    /*****************************************************************
     * VALIDATION
     *****************************************************************/
    /**
     * Validate a HelcimPay.js transaction using the secret token.
     *
     * After the frontend receives a SUCCESS message event, call this
     * server-side to verify the transaction hash.
     */
    public function validateTransaction(string $secretToken, array $transactionData): bool
    {
        $hash = hash('sha256', $secretToken . json_encode($transactionData));
        return hash_equals($hash, $transactionData['hash'] ?? '');
    }
    /*******************************************************************
     * WEBHOOKS
    *******************************************************************/
    /**
     * Validate Helcim webhook signature.
     *
     * Helcim signs webhooks with HMAC-SHA256 using:
     *   signedContent = "{webhook-id}.{webhook-timestamp}.{body}"
     *   key = base64_decode(verifierToken)
     *
     * Headers: webhook-id, webhook-timestamp, webhook-signature (v1,{base64hash})
     *
     * @see https://devdocs.helcim.com/docs/enabling-webhooks-for-transactions
     */
    protected function validateWebhook(array $payload): bool
    {
        $headers = $payload['_headers'] ?? [];
        $webhookId        = $headers['webhook_id'][0]        ?? $headers['webhook-id']        ?? '';
        $webhookTimestamp  = $headers['webhook_timestamp'][0] ?? $headers['webhook-timestamp'] ?? '';
        $webhookSignature = $headers['webhook_signature'][0]  ?? $headers['webhook-signature'] ?? '';
        if (empty($webhookId) || empty($webhookTimestamp) || empty($webhookSignature)) {
            $this->logError('Webhook missing required headers', [], 'warning');
            return false;
        }
        // Verify timestamp is within 5 minutes (prevent replay attacks)
        $now = time();
        if (abs($now - (int)$webhookTimestamp) > 300) {
            $this->logError('Webhook timestamp too old', [
                'webhook_timestamp' => $webhookTimestamp,
                'server_time'       => $now,
            ], 'warning');
            return false;
        }
        $secret = $this->getAdvancedSetting('webhook_signature_key');
        if (empty($secret)) {
            // If no signature key configured, allow webhook but log warning
            $this->logDebug('No webhook signature key configured — skipping verification');
            return true;
        }
        // Reconstruct the raw body from the payload (minus our injected _headers)
        $body = $payload['_raw_body'] ?? json_encode(
            array_diff_key($payload, ['_headers' => 1, '_raw_body' => 1])
        );
        $signedContent = "{$webhookId}.{$webhookTimestamp}.{$body}";
        $secretBytes   = base64_decode($secret);
        $expectedHash  = base64_encode(
            hash_hmac('sha256', $signedContent, $secretBytes, true)
        );
        // webhook-signature may contain multiple signatures: "v1,hash1 v2,hash2"
        $signatures = explode(' ', $webhookSignature);
        foreach ($signatures as $sig) {
            $parts = explode(',', $sig, 2);
            if (count($parts) === 2 && hash_equals($expectedHash, $parts[1])) {
                return true;
            }
        }
        $this->logError('Webhook signature mismatch', [
            'webhook_id' => $webhookId,
        ], 'warning');
        return false;
    }
    /**
     * Process a validated Helcim webhook event.
     *
     * Helcim sends minimal payloads: {"id": "12345", "type": "cardTransaction"}
     * We fetch the full transaction details from the API.
     */
    protected function processWebhook(array $payload): bool
    {
        $type = $payload['type'] ?? '';
        $id   = $payload['id']  ?? '';
        if (empty($type) || empty($id)) {
            $this->logError('Webhook missing type or id', $payload, 'warning');
            return false;
        }
        return match ($type) {
            'cardTransaction' => $this->handleTransactionWebhook($id),
            'terminalCancel'  => $this->handleTerminalCancelWebhook($payload),
            default           => $this->handleUnknownWebhook($type, $id),
        };
    }
    /**
     * Handle a cardTransaction webhook — fetch full transaction, update records.
     */
    protected function handleTransactionWebhook(string $transactionId): bool
    {
        // Fetch full transaction from Helcim API
        $transaction = $this->getRequest("card-transactions/{$transactionId}");
        if (is_wp_error($transaction) || empty($transaction)) {
            $this->logError('Failed to fetch transaction for webhook', [
                'transaction_id' => $transactionId,
            ]);
            return false;
        }
        $status = $transaction['status'] ?? '';
        // Fire action for other parts of the system to react
        do_action('jvb_helcim_transaction', $transaction, $status);
        // If linked to an invoice, update invoice cache
        $invoiceNumber = $transaction['invoiceNumber'] ?? '';
        if (!empty($invoiceNumber)) {
            $this->cache->delete("invoice_{$invoiceNumber}");
            do_action('jvb_helcim_invoice_updated', $invoiceNumber, $transaction);
        }
        // Log for debugging
        $this->logDebug('Transaction webhook processed', [
            'transaction_id' => $transactionId,
            'status'         => $status,
            'amount'         => $transaction['amount'] ?? 0,
            'type'           => $transaction['type'] ?? '',
        ]);
        return true;
    }
    /**
     * Handle Smart Terminal cancel webhook
     */
    protected function handleTerminalCancelWebhook(array $payload): bool
    {
        do_action('jvb_helcim_terminal_cancel', $payload);
        $this->logDebug('Terminal cancel webhook processed', [
            'payload' => $payload,
        ]);
        return true;
    }
    /**
     * Handle unknown webhook types (future-proofing)
     */
    protected function handleUnknownWebhook(string $type, string $id): bool
    {
        $this->logDebug('Unknown webhook type received', [
            'type' => $type,
            'id'   => $id,
        ]);
        do_action("jvb_helcim_webhook_{$type}", $id);
        return true;
    }
    /**
     * Extract unique webhook ID for deduplication
     */
    protected function extractWebhookId(array $payload): ?string
    {
        $headers = $payload['_headers'] ?? [];
        return $headers['webhook_id'][0] ?? $headers['webhook-id'] ?? $payload['id'] ?? null;
    }
    /**
     * Override the webhook request handler to capture raw body for signature verification
     */
    public function handleWebhookRequest(\WP_REST_Request $request): \WP_REST_Response
    {
        $payload = $request->get_params();
        $payload['_headers']  = $request->get_headers();
        $payload['_raw_body'] = $request->get_body();
        $success = $this->handleWebhook($payload);
        return new \WP_REST_Response([
            'success' => $success,
        ], $success ? 200 : 400);
    }
    /***********************************************************************
     * POST HOOKS
    ***********************************************************************/
    /**
     * Sync a WordPress post to Helcim as a product.
     * Called by IntegrationExecutor::processSyncTo()
     */
    public function syncPostToService(int $postID): array|\WP_Error
    {
        $post = get_post($postID);
        if (!$post) {
            return new \WP_Error('not_found', "Post {$postID} not found");
        }
        $helcimProductId = get_post_meta($postID, BASE . '_helcim_item_id', true);
        $productData     = $this->buildProductPayload($postID);
        if (is_wp_error($productData)) {
            return $productData;
        }
        if ($helcimProductId) {
            // Update existing
            $response = $this->patchRequest("products/{$helcimProductId}", $productData);
        } else {
            // Create new
            $response = $this->postRequest('products', $productData);
        }
        if (is_wp_error($response)) {
            update_post_meta($postID, BASE . '_helcim_sync_status', 'error');
            return $response;
        }
        // Store Helcim product ID
        $newId = $response['id'] ?? $response['productId'] ?? $helcimProductId;
        update_post_meta($postID, BASE . '_helcim_item_id', $newId);
        update_post_meta($postID, BASE . '_helcim_sync_status', 'synced');
        update_post_meta($postID, BASE . '_helcim_last_sync', current_time('mysql'));
        return ['success' => true, 'helcim_id' => $newId];
    }
    /**
     * Delete a product from Helcim.
     * Called by IntegrationExecutor::processDeleteFrom()
     */
    public function deleteFromService(string $externalId): array|\WP_Error
    {
        $response = $this->deleteRequest("products/{$externalId}");
        if (is_wp_error($response)) {
            return $response;
        }
        return ['success' => true, 'deleted' => $externalId];
    }
    /**
     * Build Helcim product payload from a WordPress post.
     */
    protected function buildProductPayload(int $postID): array|\WP_Error
    {
        $meta = \JVBase\meta\Meta::forPost($postID);
        $post = get_post($postID);
        $price = $meta->get('price');
        if (empty($price) || !is_numeric($price)) {
            return new \WP_Error('invalid_price', "Post {$postID} has no valid price");
        }
        return [
            'name'        => $post->post_title,
            'description' => wp_strip_all_tags($post->post_content),
            'sku'         => $meta->get('sku') ?: "wp-{$postID}",
            'price'       => (float) $price,
            'taxExempt'   => (bool) $meta->get('tax_exempt'),
        ];
        // TODO: Implement initialize() method.
    }
}
inc/integrations/services/Instagram.php
@@ -2,11 +2,19 @@
/**
 * Instagram Integration
 * File: /inc/integrations/Instagram.php
 *
 * Limits:
 *  - 50 uploads (including carousels) per user per 24-hour period
 */
namespace JVBase\integrations\services;
use JVBase\integrations\Admin;
use JVBase\integrations\Instance;
use JVBase\integrations\Integrations;
use JVBase\meta\Meta;
use JVBase\integrations\OAuth;
use JVBase\integrations\OAuthCredentials;
use JVBase\integrations\OAuthURLs;
use JVBase\integrations\Requests;
if (!defined('ABSPATH')) {
    exit;
@@ -14,692 +22,56 @@
class Instagram extends Integrations
{
    protected array $allowedContent = [
        'post'
    ];
    private string $ig_user_id = '';
    private string $page_id = '';
    private string $page_access_token = '';
    private array $business_account = [];
    private bool $auto_post = false;
    private array $post_settings = [];
    // Instagram API endpoints
    private const API_VERSION = 'v18.0';
    private const GRAPH_API_BASE = 'https://graph.facebook.com/';
    private const INSTAGRAM_BASE = 'https://graph.instagram.com/';
    protected static array $instances = [];
    public static function getInstance(?int $userID = null):self
    {
        $key = is_null($userID) ? 'base' : $userID;
        if (!array_key_exists($key, self::$instances)) {
            self::$instances[$key] = new self($userID);
        }
        return self::$instances[$key];
    }
    protected function __construct(?int $userID = null)
    use Admin, Instance, OAuth, Requests;
    protected function __construct()
    {
        $this->service_name = 'instagram';
        $this->title = 'Instagram';
        $this->icon = 'instagram-logo';
        $this->apiBase = [
            'graph' => self::GRAPH_API_BASE . self::API_VERSION,
            'instagram' => self::INSTAGRAM_BASE . self::API_VERSION
        ];
        $this->apiEndpoints = [
            'me/accounts',
            'media',
            'media_publish',
            'content_publishing_limit',
            'insights'
        ];
        $this->isOAuthService = true;
        // OAuth configuration for Facebook/Instagram
        $this->oauth = [
            'authorize' => 'https://www.facebook.com/' . self::API_VERSION . '/dialog/oauth',
            'token' => self::GRAPH_API_BASE . self::API_VERSION . '/oauth/access_token',
            'redirect_uri' => rest_url('jvb/v1/oauth/instagram'),
            'scopes' => [
        $this->apiVersion = 'v25.0';
        $this->supportsWebp = false;
//      $this->defineFields();
        $this->defineOAuth();
//      $this->defineActions();
        $this->baseUrl = 'https://graph.instagram.com/'.$this->apiVersion;
        parent::__construct();
    }
    public function defineOAuth():void
    {
        $this->oauth = new OAuthURLs(
            'https://www.facebook.com/'.$this->apiVersion.'/dialog/oauth',
            'https://graph.facebook.com/'.$this->apiVersion.'/oauth/access_token',
            'https://graph.facebook.com/'.$this->apiVersion.'/me/permissions'
        );
        $this->oathScopes = [
                'instagram_basic',
                'instagram_content_publish',
                '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
        ];
        // TODO: Implement initialize() method.
    }
    /**
     * Get request headers for Instagram API
     */
    protected function refreshAccessToken(OAuthCredentials $credentials): array
    {
        // TODO: Implement refreshAccessToken() method.
        return [];
    }
    protected function getRequestHeaders(): array
    {
        return [
            'Content-Type' => 'application/json',
            'Accept' => 'application/json'
        ];
    }
    /**
     * 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()) {
        // TODO: Implement getRequestHeaders() method.
            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()) {
    protected function handleResponse(\WP_Error|array $response, string $method, string $endpoint): array
    {
        // TODO: Implement handleResponse() method.
                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/PostMark.php
@@ -1,7 +1,12 @@
<?php
namespace JVBase\integrations\services;
use JVBase\integrations\Admin;
use JVBase\integrations\Instance;
use JVBase\integrations\Integrations;
use JVBase\integrations\Requests;
use WP_Error;
if (!defined('ABSPATH')) {
    exit;
}
@@ -14,123 +19,69 @@
 */
class PostMark extends Integrations
{
    protected ?string $server_token = null;
    protected string $message_stream = 'outbound';
    protected string $from_email;
    protected string $from_name;
    protected bool $track_open;
    protected bool $track_links;
    protected ?string $lastMessageId = null;
    use Admin, Instance, Requests;
    protected static array $instances = [];
    public static function getInstance(?int $userID = null):self
    {
        $key = is_null($userID) ? 'base' : $userID;
        if (!array_key_exists($key, self::$instances)) {
            self::$instances[$key] = new static($userID);
        }
        return self::$instances[$key];
    }
    /**
     * Constructor
     */
    protected function __construct(?int $userID = null)
    protected function __construct()
    {
        $this->service_name = 'postmark';
        $this->title = 'PostMark';
        $this->icon = 'envelope-simple';
        $this->cacheName = ($userID)? 'gmb_'.$userID : 'postmark';
        // PostMark API configuration
        $this->apiBase = 'https://api.postmarkapp.com';
        $this->apiVersion = 'v1';
        $this->baseUrl = 'https://api.postmarkapp.com';
        // Define available endpoints
        $this->apiEndpoints = [
            'email',
            'email/batch',
            'email/withTemplate',
            'bounces',
            'deliverystats',
            'messages/outbound',
            'messages/outbound/[^/]+',
            'messages/outbound/opens',
            'stats/outbound',
            'stats/outbound/opens',
            'stats/outbound/bounces',
            'templates',
            'templates/[^/]+',
            'server'
        ];
        $this->defineFields();
        $this->defineInstructions();
        // Required credentials
        $this->fields = [
            'server_token' => [
                'label' => 'Server API Token',
                'type' => 'text',
                'subtype'=> 'password',
                'placeholder' => 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx',
                'hint' => 'Your PostMark Server API Token'
            ],
            'message_stream' => [
                'label' => 'Message Stream ID',
                'type' => 'text',
                'placeholder' => 'outbound',
                'default' => 'outbound',
                'description' => 'Message stream to use (default: outbound)'
            ],
            'from_email' => [
                'label' => 'From Email',
                'type' => 'email',
                'placeholder' => 'noreply@yourdomain.com',
                'description' => 'Default sender email address (must be verified in PostMark)'
            ],
            'from_name' => [
                'label' => 'From Name',
                'type' => 'text',
                'placeholder' => get_bloginfo('name'),
                'default' => get_bloginfo('name'),
                'description' => 'Default sender name'
            ],
            'track_opens' => [
                'label' => 'Track Opens',
                'type' => 'select',
                'options' => [
        parent::__construct();
        $this->defineActions();
    }
        protected function defineFields():void
        {
            $token = $this->addField('service_token', 'Server API Token', 'text', 'manage_options');
                $token->setSubType('password');
            $stream = $this->addField('message_stream', 'Message Stream ID', 'text', 'manage_options');
                $stream->setDefault('outbound');
                $stream->setHint('Message stream to use (default: outbound)');
            $from = $this->addField('from_email', 'From Email', 'email', 'manage_options');
                $from->setPlaceholder('noreply@yourdomain.com');
                $from->setDefault(get_option('admin_email'));
            $name = $this->addField('from_name', 'From Name', 'text', 'manage_options');
                $name->setDefault(get_bloginfo('name'));
                $name->setHint('Default sender name');
            $opens = $this->addField('track_opens', 'Track Opens', 'select', 'manage_options');
                $opens->setOptions([
                    'false' => 'Disabled',
                    'true' => 'Enabled'
                ],
                'default' => 'false',
                'description' => 'Track when emails are opened'
            ],
            'track_links' => [
                'label' => 'Track Links',
                'type' => 'select',
                'options' => [
                    'true'  => 'Enabled',
                ]);
                $opens->setDefault('false');
                $opens->setHint('Track when emails are opened');
            $links = $this->addField('track_links', 'Track Links', 'select', 'manage_options');
                $links->setOptions([
                    'None' => 'Disabled',
                    'HtmlOnly' => 'HTML Only',
                    'HtmlAndText' => 'HTML and Text',
                ],
                'default' => 'None',
                'description' => 'Track link clicks in emails'
            ]
        ];
        parent::__construct();
        // Hook into WordPress mail system if integration is healthy
        if ($this->is_healthy && $this->isSetUp()) {
            $this->initializeMailHooks();
                ]);
                $links->setDefault('None');
                $links->setHint('Track link clicks in emails');
        }
    }
        protected function defineInstructions():void
        {
    /**
     * Initialize WordPress mail hooks
     */
    protected function initializeMailHooks(): void
        }
        protected function defineActions():void
        {
        }
    protected function initialize(): void
    {
        // Replace wp_mail with PostMark
        add_filter('wp_mail', [$this, 'sendViaPostMark'], 10, 1);
        add_filter('wp_mail', [$this, 'wp_mail'], 10, 1);
        // Optional: Hook for logging sent emails
        add_action('postmark_email_sent', [$this, 'logSentEmail'], 10, 2);
@@ -138,13 +89,11 @@
    }
    /**
     * Send email via PostMark API
     * Hooks into wp_mail filter
     *
     * @param array $args Email arguments from wp_mail
     * @return array Modified args (or original if sending via PostMark)
     * Fired on the wp_mail filter
     * @param array $args Email arguments from wp_mail, including 'to', 'subject', 'message', 'headers', 'attachments'
     * @return array
     */
    public function sendViaPostMark(array $args): array
        public function wp_mail(array $args):array
    {
        // Extract email components
        $to = $args['to'];
@@ -153,200 +102,63 @@
        $headers = $args['headers'] ?? '';
        $attachments = $args['attachments'] ?? [];
        // Parse headers
        $parsed_headers = $this->parseMailHeaders($headers);
            $headers = $this->parseMailHeaders($headers);
        // Determine content type
        $is_html = $parsed_headers['content-type'] === 'text/html' ||
            strpos($message, '<html') !== false ||
            strpos($message, '<body') !== false;
            $is_html = $headers['content-type'] === 'text/html' || str_contains($message, '<html') || str_contains($message, '<body');
        // Build PostMark payload
        $payload = $this->buildEmailPayload(
            $response = $this->sendEmail(
            $to,
            $subject,
            $message,
            $is_html,
            $parsed_headers,
                $headers,
            $attachments
        );
        // Send via PostMark
        $result = $this->sendEmail($payload);
        if ($result === true) {
            if ($response['success']) {
            error_log('================================ Email sent! ================================');
            // Prevent default wp_mail from sending
            add_filter('pre_wp_mail', '__return_true');
            do_action('postmark_email_sent', $args, $payload);
                do_action('postmark_email_sent', $args, $response);
        } else {
            error_log('=-======================[POSTMARK]Something went wrong... ================================');
            // Log failure but allow fallback to default mail
            do_action('postmark_email_failed', $args, $result);
            // Optionally fall back to default wp_mail
            if ($this->shouldFallback()) {
                return $args;
                do_action('postmark_email_failed', $args, $response);
            }
        }
        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
            protected function parseMailHeaders(string|array $headers):array
    {
        $parsed = [
            'content-type' => 'text/plain',
            'from' => null,
            'from-name' => null,
            'reply-to' => null,
                    'reply-top'     => null,
            'cc' => [],
            'bcc' => [],
            'x-headers' => []
                    'x-headers'     => [],
        ];
        if (empty($headers)) {
            return $parsed;
        }
        // Handle array format
        if (is_array($headers)) {
            foreach ($headers as $header) {
                $this->parseHeaderLine($header, $parsed);
                if (is_string($headers)) {
                    $headers = explode("\n", $headers);
            }
        } else {
            // Handle string format (multiple lines)
            $lines = explode("\n", $headers);
            foreach ($lines as $line) {
                foreach ($headers 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;
@@ -354,11 +166,10 @@
        $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';
                            if (str_contains($value, 'text/html')) {
                                $parsed['content-type'] = $value;
                }
                break;
@@ -372,55 +183,130 @@
                }
                break;
            case 'reply-to':
                $parsed['reply-to'] = $value;
                break;
            case 'cc':
                $parsed['cc'][] = $value;
                break;
            case 'bcc':
                $parsed['bcc'][] = $value;
                            $parsed[$name][] = $value;
                break;
            default:
                // Store custom headers
                if (strpos($name, 'x-') === 0) {
                            //TODO: Verify we need this. This feels extra clunky
                            //Why not just $parsed['x-headers'][$name] = $value?
                            if (str_contains($name, 'x-')) {
                    $parsed['x-headers'][] = [
                        'Name' => $parts[0], // Original case
                                    'Name'  => $name,
                        'Value' => $value
                    ];
                            } else {
                                $parsed[$name] = $value;
                }
        }
    }
    /**
     * Process attachments for PostMark
     */
    protected function processAttachments(array $attachments): array
    protected function getRequestHeaders(): 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 [
            'Accept' => 'application/json',
            'Content-Type' => 'application/json',
            'X-Postmark-Server-Token' => $this->getServerToken()
            ];
        }
        return $processed;
    protected function handleResponse(\WP_Error|array $response, string $method, string $endpoint): array
    {
        if (is_wp_error($response) && $response->get_error_message() === 'rate_limit_exceeded') {
            return $this->response(false, 'Rate limit exceeded');
        }
        $endpoint = str_replace($this->apiBase, '', $endpoint);
        $error = $response['ErrorCode'] ?? false;
        if ($error) {
            switch ($error) {
                case '0':
                    //Do nothing, no error found
                    break;
                //Bad or missing API Token
                case '10':
                //Multiple errors
                case '11':
                //Resource Not found
                case '12':
                //Invalid Pagination Key
                case '13':
                //Maintenance; PostMark API down for a sec
                case '100':
                //Invalid Email Request
                case '300':
                //Sender Signature not found
                case '400':
                //Sender signature not confirmed
                case '401':
                //Invalid JSON
                case '402':
                //Invalid request field(s) - {FIELD_NAME}
                case '403':
                //Not allowed to send (no more credits in account)
                case '405':
                //Inactive recipient. Recipient hard bounces, or filed spam complaint
                case '406':
                //JSON required
                case '409':
                //Too many requests (500 max)
                case '410':
                //Forbidden Attachment type
                case '411':
                //Account is pending
                case '412':
                //Account may not exist
                case '413':
                //Invalid query string parameter
                case '500':
                default:
                    $this->logError(
                        'handleResponse',
                        'Something went wrong: '.$response['Message']??'',
                        $response
                    );
                    return $this->response(false, 'Something went wrong: '.$response['Message']??'');
            }
        }
        return match ($endpoint) {
            '/email' => $this->handleEmailResponse($response, $method),
            '/email/bulk'   => $this->handleBulkEmailResponse($response, $method),
            default => $this->response(false, 'Unhandled Response for '. $endpoint . ' endpoint, with method: '. $method),
        };
    }
    /**
     * Generate plain text from HTML content
     */
    protected function generateTextFromHtml(string $html): string
    /**********************************************************
     * UTILITY
    **********************************************************/
    protected function getServerToken():string|false
    {
        return $this->loadCredentials()['server_token']??false;
    }
    protected function getMessageStream():string
    {
        return $this->loadCredentials()['message_stream']??'outbound';
    }
    protected function getFromEmail():string
    {
        return $this->loadCredentials()['from_email']??get_option('admin_email');
    }
    protected function getFromName():string
    {
        return $this->loadCredentials()['from_name']??get_bloginfo('name');
    }
    protected function getTrackOpens():string
    {
        return $this->loadCredentials()['track_opens']??'false';
    }
    protected function getTrackLinks():string
    {
        return $this->loadCredentials()['track_links']??'None';
    }
    /****************************************************************************
     * HELPERS
    ****************************************************************************/
    protected function textFromHtml(string $html):string
    {
        // Remove HTML tags
        $text = strip_tags($html);
@@ -434,228 +320,124 @@
    }
    /**
     * Determine email tag based on content
     * @param array $attachments Either an array of file paths, or attachment IDs
     * @return array
     */
    protected function processAttachments(array $attachments):array
    {
        $processed = [];
        foreach ($attachments as $file) {
            if (is_int($file)) {
                $file = get_attached_file($file);
            }
            if (!$file || !file_exists($file)) {
                continue;
            }
            $processed[] = [
                'Name'  => basename($file),
                'Content'   => base64_encode(file_get_contents($file)),
                'ContentType'   => mime_content_type($file) ?? 'application/octet-stream'
            ];
        }
        return $processed;
    }
    protected function determineEmailTag(string $subject, array $headers): string
    {
        // Password reset emails
        if (stripos($subject, 'password') !== false && stripos($subject, 'reset') !== false) {
        if (array_key_exists('email-tag', $headers)) {
            return $headers['email-tag'];
        }
        $tmp = strtolower($subject);
        if (str_contains($tmp, 'password')) {
            return 'password-reset';
        }
        // New user registration
        if (stripos($subject, 'welcome') !== false || stripos($subject, 'registration') !== false) {
        if (str_contains($tmp, 'welcome') || str_contains($tmp, 'registr') || str_contains($tmp, 'register')) {
            return 'registration';
        }
        // Account notifications
        if (stripos($subject, 'account') !== false) {
        if (str_contains($tmp, 'account')) {
            return 'account';
        }
        // Order/transaction emails
        if (stripos($subject, 'order') !== false || stripos($subject, 'receipt') !== false) {
        if (str_contains($tmp, 'order') || str_contains($tmp, 'receipt')) {
            return 'transaction';
        }
        // Default
        return 'general';
    }
    /**
     * Check if should fallback to default mail
     */
    protected function shouldFallback(): bool
    /****************************************************************************
     * MAILERS
    ****************************************************************************/
    public function sendEmail(string $to, string $subject, string $message, bool $is_html = true, array $headers = [], array $attachments = []):array
    {
        // Don't fallback if we've had too many errors
        if ($this->error_stats['consecutive_errors'] >= 3) {
            return false;
        if (!$this->isSetUp()) {
            return $this->response(false, 'Not set up');
        }
        // Check if fallback is enabled in settings
        $credentials = $this->getCredentials();
        return ($credentials['enable_fallback'] ?? 'true') === 'true';
        $payload = $this->buildPayload($to, $subject, $message, $is_html, $headers, $attachments);
        $response = $this->postRequest(
            sprintf('%s/email', $this->apiBase),
            $payload
        );
    }
    /**
     * 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
        protected function handleEmailResponse(WP_Error|array $response, string $method):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
        protected function buildPayload(string|array $to, string $subject, string $message, bool $is_html = true, array $headers = [], array $attachments = []):array
    {
        try {
            return $this->getRequest('bounces', [
                'count' => $count,
                'offset' => $offset
            ]);
        } catch (\Exception $e) {
            $this->logError('[POSTMARK]', ['method' => 'getBounces', 'error' => $e->getMessage()]);
            return [];
        }
    }
            $to = is_array($to) ? implode(',',$to) : $to;
    /**
     * 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'
                'From'  => sprintf(
                    '%s <%s>',
                    $headers['from-name'] ?? $this->getFromName(),
                    $headers['from'] ?? $this->getFromEmail()
                ),
                'To'            => $to,
                'Subject'       => sanitize_text_field($subject),
                'TrackOpens'    => $this->getTrackOpens() === 'true',
                'TrackLinks'    => $this->getTrackLinks(),
                'MessageStream' => $this->getMessageStream()
        ];
        return $this->sendEmail($payload);
            if ($is_html) {
                $payload['HtmlBody'] = $message;
                $payload['TextBody'] = $this->textFromHtml($message);
            } else {
                $payload['TextBody'] = $message;
    }
    /**
     * 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
            if (!empty($headers['cc'])) {
                $payload['Cc'] = is_array($headers['cc']) ?
                    implode(',',$headers['cc']) :
                    $headers['cc'];
    }
    protected function initialize(): void
    {
        if (empty($this->credentials)) {
            $this->loadCredentials();
            if (!empty($headers['bcc'])) {
                $payload['Bcc'] = is_array($headers['bcc']) ?
                    implode(',',$headers['bcc']) :
                    $headers['bcc'];
        }
        $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;
            if (!empty($headers['reply-to'])) {
                $payload['ReplyTo'] = $headers['reply-to'];
            }
            if (!empty($attachments)) {
                $payload['Attachments'] = $this->processAttachments($attachments);
            }
            if (!empty($headers['x-headers'])) {
                $payload['Headers'] = $headers['x-headers'];
            }
            $payload['Tag'] = $this->determineEmailTag($subject, $headers);
            return $payload;
    }
}
inc/integrations/services/Square.php
@@ -36,18 +36,16 @@
        Webhooks;
    protected string $orderPostType = '_square_order';
    protected string $apiVersion = '2026-07-15';
    public bool $hasBatchCreate = true;
    public bool $hasBatchUpdate = true;
    public bool $hasBatchDelete = true;
    public array $apiEndpoints = [
    ];
    protected function __construct()
    {
        $this->service_name = 'square';
        $this->title = 'Square';
        $this->icon = 'square-logo';
        $this->apiVersion = '2026-07-15';
        $this->supportsWebp = false;
        $this->canSync = [
@@ -55,6 +53,9 @@
            'update'    => true,
            'delete'    => true,
        ];
        $this->hasBatchCreate = true;
        $this->hasBatchUpdate = true;
        $this->hasBatchDelete = true;
        $this->defineFields();
        $this->defineInstructions();
@@ -89,8 +90,8 @@
            $locs->setHidden();
            $locs->setOptions($this->getLocationOptions());
        $loc = $this->addField('location_id', 'Default Location', 'select', 'manage_options');
            $loc->setOptions($this->loadCredentials()['locations']);
        $loc = $this->addField('location_id', 'Set Location', 'select', 'manage_options');
            $loc->setOptions($this->loadCredentials()['locations']??[]);
            $loc->setHint('Select default Square location for transactions');
    }
@@ -271,15 +272,12 @@
            return $this->response(false, 'Rate limit exceeded');
        }
        $endpoint = str_replace($this->getBaseURL(), '', $endpoint);
        switch ($endpoint) {
            case '/v2/customers/search':
                return $this->handleEmailSearchResponse($response, $method);
            case '/v2/customers':
                return $this->handleCreateUserResponse($response, $method);
            case '/v2/locations':
                return $this->handleGetLocationResponse($response, $method);
        }
        return $this->response(false, 'Unhandled Response for '.$endpoint.' endpoint, with method: '.$method);
        return match ($endpoint) {
            '/v2/customers/search' => $this->handleEmailSearchResponse($response, $method),
            '/v2/customers' => $this->handleCreateUserResponse($response, $method),
            '/v2/locations' => $this->handleGetLocationResponse($response, $method),
            default => $this->response(false, 'Unhandled Response for ' . $endpoint . ' endpoint, with method: ' . $method),
        };
    }
    protected function setContentTypes(): void
@@ -914,10 +912,7 @@
            ? 'https://connect.squareup.com'
            : 'https://connect.squareupsandbox.com';
    }
    protected function getAccessToken():string
    {
        return $this->loadCredentials()['access_token']??'';
    }
    /***************************************************************
     * ACTIONS
    ***************************************************************/
inc/integrations/services/Umami.php
@@ -1,5 +1,7 @@
<?php
namespace JVBase\integrations\services;
use JVBase\integrations\Admin;
use JVBase\integrations\Instance;
use JVBase\integrations\Integrations;
if (!defined('ABSPATH')) {
@@ -8,796 +10,9 @@
class Umami extends Integrations
{
    private string $website_id;
    private string $api_url;
    private string $api_token;
    // Tracking configuration
    private array $valid_events = [
        'view_feed',
        'view_taxonomy',
        'view_profile',
        'view_shop',
        'view_content',
        'toggle_favourite',
        'click_profile',
        'click_content',
        'click_taxonomy',
        'click_shop',
        'user_session'
    ];
    // Database tables
    private string $events_table;
    private string $metrics_table;
    protected static array $instances = [];
    public static function getInstance(?int $userID = null):self
    {
        $key = is_null($userID) ? 'base' : $userID;
        if (!array_key_exists($key, self::$instances)) {
            self::$instances[$key] = new static($userID);
        }
        return self::$instances[$key];
    }
    protected function __construct(?int $userID = null)
    {
        $this->service_name = 'umami';
        $this->title = 'Umami.js';
        $this->icon = 'chart-line';
        $this->apiBase = '';  // Set dynamically based on credentials
        $this->apiEndpoints = [
            'api/websites',
            'api/websites/{websiteId}/stats',
            'api/websites/{websiteId}/pageviews',
            'api/websites/{websiteId}/events'
        ];
        $this->fields = [
            'website_id' => [
                'label'     => 'Website ID',
                'type'      => 'text',
                'placeholder'=> 'Enter Umami Website ID',
                'hint'      => 'Found in your Umami dashboard under Settings → Websites.',
                'required'  => true
            ],
        ];
        $this->advanced = [
            'api_url' => [
                'label'     => 'API URL',
                'type'      => 'text',
                'placeholder'=> 'https://analytics.yourdomain.com',
                'hint'      => 'Your Umami server URL. Leave blank if using umami.is cloud service.'
            ],
            'api_token' => [
                'label'     => 'API Token (Optional)',
                'type'      => 'text',
                'subtype'   => 'password',
                'placeholder'=> 'Enter API Token for analytics data',
                'hint'      => 'Required only if you want to retrieve analytics data for dashboards.'
            ]
        ];
        $this->instructions = [
            'Login to your <a href="https://cloud.umami.is/settings/websites" target="_blank">umami.is</a> account, select your website, and copy the website ID'
        ];
        parent::__construct();
        $this->actions = array_merge(
            $this->actions,
            [
                'refresh_data'  =>  'handleRefreshData'
            ]
        );
        global $wpdb;
        $this->events_table = $wpdb->prefix . BASE . 'umami_events';
        $this->metrics_table = $wpdb->prefix . BASE . 'performance_metrics';
    }
    /**
     * Initialize service-specific properties
     */
    use Admin, Instance;
    protected function initialize(): void
    {
        $this->website_id = $this->credentials['website_id'] ?? '';
        $this->api_url = $this->credentials['api_url'] ?? '';
        $this->api_token = $this->credentials['api_token'] ?? '';
        $this->ttl = intval($this->credentials['cache_duration'] ?? 60) * 60;
        // Set dynamic API base if provided
        if (!empty($this->api_url)) {
            $this->apiBase = rtrim($this->api_url, '/');
        }
    }
    /**
     * Register additional WordPress hooks
     */
    protected function registerAdditionalHooks(): void
    {
        // Add tracking script to frontend
        add_action('wp_head', [$this, 'renderTrackingScript'], 5);
        // Add user session tracking
        add_action('wp_footer', [$this, 'trackUserSession'], 100);
        // Schedule data collection if API is configured
        if (!empty($this->api_token)) {
            add_action('jvb_daily_umami_collection', [$this, 'collectDailyData']);
            if (!wp_next_scheduled('jvb_daily_umami_collection')) {
                wp_schedule_event(time(), 'daily', 'jvb_daily_umami_collection');
            }
        }
    }
    /***************************************************************************
     * TRACKING SCRIPT METHODS
     ***************************************************************************/
    /**
     * Render tracking script in head
     */
    public function renderTrackingScript(): void
    {
        // Skip on local environments
        if (JVB_TESTING) {
            return;
        }
        if (!$this->isSetUp() || is_admin()) {
            return;
        }
        $script_url = $this->getTrackingScriptUrl();
        $website_id = $this->getWebsiteId();
        if (!$website_id || !$script_url) {
            return;
        }
        // Preconnect for performance
        $domain = parse_url($script_url, PHP_URL_HOST);
        echo '<link rel="preconnect" href="https://' . esc_attr($domain) . '"/>' . "\n";
        // Add tracking script
        $attributes = [
            'defer',
            'src="' . esc_url($script_url) . '"',
            'data-website-id="' . esc_attr($website_id) . '"'
        ];
        // Add optional configuration
        if (!empty($this->credentials['respect_dnt']) && $this->credentials['respect_dnt']) {
            $attributes[] = 'data-do-not-track="true"';
        }
        if (!empty($this->credentials['domains'])) {
            $attributes[] = 'data-domains="' . esc_attr($this->credentials['domains']) . '"';
        }
        echo '<script ' . implode(' ', $attributes) . '></script>' . "\n";
    }
    /**
     * Track user session information
     */
    public function trackUserSession(): void
    {
        if (!$this->isSetUp() || is_admin()) {
            return;
        }
        $attributes = $this->buildTrackingAttributes('user_session', 'pageview', [
            'logged_in' => is_user_logged_in() ? 'true' : 'false',
            'user_type' => $this->getCurrentUserType()
        ]);
        if (empty($attributes)) {
            return;
        }
        echo '<div ' . $this->attributesToString($attributes) . ' style="display:none"></div>' . "\n";
        // Add page-specific tracking
        $this->trackPageView();
    }
    /**
     * Track specific page views
     */
    private function trackPageView(): void
    {
        $tracker_args = [];
        $event = '';
        $type = '';
        if (is_singular([BASE . 'artist', BASE . 'partner'])) {
            // Profile view
            global $post;
            $event = 'view_profile';
            $type = get_post_type();
            $tracker_args = [
                'id' => get_the_ID(),
                'from' => $this->getTrafficSource(),
                'owner_id' => $post->post_author
            ];
            // Check for specific content in query
            foreach (['tattoo', 'piercing', 'artwork'] as $content_type) {
                if (isset($_GET[$content_type])) {
                    $tracker_args['item'] = sanitize_text_field($_GET[$content_type]);
                    break;
                }
            }
        } elseif (is_tax(BASE . 'shop')) {
            // Shop view
            $event = 'view_shop';
            $type = BASE . 'shop';
            $tracker_args = [
                'id' => get_queried_object_id(),
                'from' => $this->getTrafficSource()
            ];
        } elseif (is_tax()) {
            // Taxonomy view
            $obj = get_queried_object();
            $event = 'view_taxonomy';
            $type = $obj->taxonomy;
            $tracker_args = [
                'id' => $obj->term_id,
                'from' => $this->getTrafficSource()
            ];
        }
        if ($event && $type) {
            $attributes = $this->buildTrackingAttributes($event, $type, $tracker_args);
            echo '<div ' . $this->attributesToString($attributes) . ' style="display:none"></div>' . "\n";
        }
    }
    /***************************************************************************
     * TRACKING ATTRIBUTE BUILDER METHODS
     ***************************************************************************/
    /**
     * Build tracking attributes for events
     */
    public function buildTrackingAttributes(string $event, string $type, array $args = []): array
    {
        if (!in_array($event, $this->valid_events)) {
            return [];
        }
        // Normalize type
        $normalized_type = str_replace(BASE, '', $type);
        // Build base attributes
        $attributes = [
            'data-umami-event' => esc_attr($event),
            'data-umami-event-type' => esc_attr($normalized_type)
        ];
        // Add ID if provided
        if (!empty($args['id'])) {
            $attributes['data-umami-event-id'] = esc_attr($args['id']);
        }
        // Add source information
        if (!empty($args['source_id'])) {
            $attributes['data-umami-event-source'] = esc_attr($args['source_id']);
            if (!empty($args['source_type'])) {
                $attributes['data-umami-event-source-type'] = esc_attr(
                    str_replace(BASE, '', $args['source_type'])
                );
            }
        }
        // Add owner information
        if (!empty($args['owner_id'])) {
            $attributes['data-umami-event-owner'] = esc_attr($args['owner_id']);
            if (!empty($args['owner_type'])) {
                $attributes['data-umami-event-owner-type'] = esc_attr(
                    str_replace(BASE, '', $args['owner_type'])
                );
            }
        }
        // Add traffic source
        if (!empty($args['from'])) {
            $attributes['data-umami-event-from'] = esc_attr($args['from']);
        }
        // Add item reference
        if (!empty($args['item'])) {
            $attributes['data-umami-event-item'] = esc_attr($args['item']);
        }
        // Add metadata
        foreach ($args as $key => $value) {
            if (in_array($key, ['id', 'source_id', 'source_type', 'owner_id', 'owner_type', 'from', 'item'])) {
                continue;
            }
            $clean_key = 'data-umami-event-' . str_replace('_', '-', sanitize_key($key));
            $attributes[$clean_key] = esc_attr($value);
        }
        return $attributes;
    }
    /**
     * Convert attributes array to HTML string
     */
    public function attributesToString(array $attributes): string
    {
        $attr_strings = [];
        foreach ($attributes as $key => $value) {
            $attr_strings[] = $key . '="' . $value . '"';
        }
        return implode(' ', $attr_strings);
    }
    /**
     * Public tracking methods for use in templates
     */
    public function trackContentClick(int $id, string $type, array $args = []): string
    {
        $args['id'] = $id;
        // Auto-detect owner for content types
        if (empty($args['owner_id']) && in_array($type, [BASE . 'tattoo', BASE . 'artwork', BASE . 'piercing'])) {
            $post = get_post($id);
            if ($post && !empty($post->post_author)) {
                $args['owner_id'] = $post->post_author;
                $args['owner_type'] = 'user';
            }
        }
        return $this->attributesToString(
            $this->buildTrackingAttributes('click_content', $type, $args)
        );
    }
    public function trackProfileClick(int $id, string $type, array $args = []): string
    {
        $args['id'] = $id;
        return $this->attributesToString(
            $this->buildTrackingAttributes('click_profile', $type, $args)
        );
    }
    public function trackTaxonomyClick(int $id, string $taxonomy, array $args = []): string
    {
        $args['id'] = $id;
        return $this->attributesToString(
            $this->buildTrackingAttributes('click_taxonomy', $taxonomy, $args)
        );
    }
    public function trackContentTaxonomyClick(int $id, string $taxonomy, array $args = []): string
    {
        $args['id'] = $id;
        return $this->attributesToString(
            $this->buildTrackingAttributes('click_' . $taxonomy, $taxonomy, $args)
        );
    }
    public function trackFavouriteToggle(int $id, string $type, bool $is_favourite, array $args = []): string
    {
        $args['id'] = $id;
        $args['action'] = $is_favourite ? 'add' : 'remove';
        return $this->attributesToString(
            $this->buildTrackingAttributes('toggle_favourite', $type, $args)
        );
    }
    public function trackFeedView(int $id, string $type, array $args = []): array
    {
        $args['id'] = $id;
        return $this->buildTrackingAttributes('view_feed', $type, $args);
    }
    /***************************************************************************
     * API DATA COLLECTION METHODS
     ***************************************************************************/
    /**
     * Get analytics data from Umami API
     */
    public function getAnalytics(string $start_date, string $end_date, bool $use_cache = true): ?array
    {
        if (empty($this->api_url) || empty($this->api_token)) {
            return null;
        }
        // Check cache first
        if ($use_cache && $this->cache) {
            $cache_key = md5("analytics_{$start_date}_{$end_date}");
            $cached = $this->cache->get($cache_key);
            if ($cached !== false) {
                return $cached;
            }
        }
        try {
            $url = rtrim($this->api_url, '/') . "/api/websites/{$this->website_id}/stats";
            $params = [
                'startAt' => strtotime($start_date) * 1000,
                'endAt' => strtotime($end_date) * 1000
            ];
            $response = wp_remote_get($url . '?' . http_build_query($params), [
                'headers' => [
                    'Authorization' => 'Bearer ' . $this->api_token,
                    'Accept' => 'application/json'
                ],
                'timeout' => 30
            ]);
            if (is_wp_error($response)) {
                $this->logError('API request failed', ['error' => $response->get_error_message()]);
                return null;
            }
            $body = wp_remote_retrieve_body($response);
            $data = json_decode($body, true);
            if (json_last_error() !== JSON_ERROR_NONE) {
                $this->logError('Invalid JSON response from API');
                return null;
            }
            // Cache the result
            if ($use_cache && $this->cache) {
                $this->cache->set($cache_key, $data, $this->ttl);
            }
            return $data;
        } catch (Exception $e) {
            $this->logError('Exception during API call', ['error' => $e->getMessage()]);
            return null;
        }
    }
    /**
     * Get page views for a specific URL
     */
    public function getPageViews(string $url, string $start_date, string $end_date): ?array
    {
        if (empty($this->api_url) || empty($this->api_token)) {
            return null;
        }
        $api_endpoint = rtrim($this->api_url, '/') . "/api/websites/{$this->website_id}/pageviews";
        $params = [
            'startAt' => strtotime($start_date) * 1000,
            'endAt' => strtotime($end_date) * 1000,
            'url' => $url,
            'unit' => 'day'
        ];
        $response = wp_remote_get($api_endpoint . '?' . http_build_query($params), [
            'headers' => [
                'Authorization' => 'Bearer ' . $this->api_token,
                'Accept' => 'application/json'
            ],
            'timeout' => 30
        ]);
        if (is_wp_error($response)) {
            return null;
        }
        return json_decode(wp_remote_retrieve_body($response), true);
    }
    /**
     * Get custom events data
     */
    public function getEvents(string $event_name = '', string $start_date = '', string $end_date = ''): ?array
    {
        if (empty($this->api_url) || empty($this->api_token)) {
            return null;
        }
        $api_endpoint = rtrim($this->api_url, '/') . "/api/websites/{$this->website_id}/events";
        $params = [];
        if ($start_date && $end_date) {
            $params['startAt'] = strtotime($start_date) * 1000;
            $params['endAt'] = strtotime($end_date) * 1000;
        }
        if ($event_name) {
            $params['event'] = $event_name;
        }
        $url = $api_endpoint;
        if (!empty($params)) {
            $url .= '?' . http_build_query($params);
        }
        $response = wp_remote_get($url, [
            'headers' => [
                'Authorization' => 'Bearer ' . $this->api_token,
                'Accept' => 'application/json'
            ],
            'timeout' => 30
        ]);
        if (is_wp_error($response)) {
            return null;
        }
        return json_decode(wp_remote_retrieve_body($response), true);
    }
    /**
     * Collect daily analytics data and store in database
     */
    public function collectDailyData(): void
    {
        if (!$this->isSetUp() || empty($this->api_token)) {
            return;
        }
        $yesterday = date('Y-m-d', strtotime('-1 day'));
        $data = $this->getAnalytics($yesterday, $yesterday, false);
        if (!$data) {
            $this->logError('Failed to collect daily data', ['date' => $yesterday]);
            return;
        }
        global $wpdb;
        // Store basic metrics
        $wpdb->insert($this->metrics_table, [
            'date' => $yesterday,
            'pageviews' => $data['pageviews']['value'] ?? 0,
            'visitors' => $data['visitors']['value'] ?? 0,
            'visits' => $data['visits']['value'] ?? 0,
            'bounces' => $data['bounces']['value'] ?? 0,
            'total_time' => $data['totaltime']['value'] ?? 0,
            'created_at' => current_time('mysql')
        ]);
        // Collect and store events
        $events = $this->getEvents('', $yesterday, $yesterday);
        if ($events) {
            foreach ($events as $event) {
                $wpdb->insert($this->events_table, [
                    'date' => $yesterday,
                    'event_name' => $event['x'] ?? '',
                    'event_count' => $event['y'] ?? 0,
                    'created_at' => current_time('mysql')
                ]);
            }
        }
        $this->logDebug('Successfully collected daily data', ['date' => $yesterday]);
    }
    /***************************************************************************
     * HELPER METHODS
     ***************************************************************************/
    /**
     * Get current user type for tracking
     */
    private function getCurrentUserType(): string
    {
        if (!is_user_logged_in()) {
            return 'guest';
        }
        $user = wp_get_current_user();
        $roles = $user->roles;
        if (empty($roles)) {
            return 'user';
        }
        $role = array_values($roles)[0];
        return str_replace(BASE, '', $role);
    }
    /**
     * Determine traffic source
     */
    private function getTrafficSource(): string
    {
        if (isset($_GET['utm_source'])) {
            return sanitize_text_field($_GET['utm_source']);
        }
        if (isset($_SERVER['HTTP_REFERER'])) {
            $referer = $_SERVER['HTTP_REFERER'];
            $site_url = get_site_url();
            if (strpos($referer, $site_url) === false) {
                $domain = parse_url($referer, PHP_URL_HOST);
                return $domain ?: 'external';
            }
            return 'internal';
        }
        return 'direct';
    }
    /**
     * Get website ID
     */
    public function getWebsiteId(): string
    {
        $this->ensureInitialized();
        return $this->website_id;
    }
    /**
     * Get tracking script URL
     */
    public function getTrackingScriptUrl(): string
    {
        if (!empty($this->api_url)) {
            return rtrim($this->api_url, '/') . '/script.js';
        }
        // Default to Umami cloud
        return 'https://cloud.umami.is/script.js';
    }
    /***************************************************************************
     * REQUIRED ABSTRACT METHOD IMPLEMENTATIONS
     ***************************************************************************/
    /**
     * Get request headers for API calls
     */
    protected function getRequestHeaders(): array
    {
        $headers = [
            'Accept' => 'application/json',
            'Content-Type' => 'application/json'
        ];
        if (!empty($this->api_token)) {
            $headers['Authorization'] = 'Bearer ' . $this->api_token;
        }
        return $headers;
    }
    protected function handleRefreshData():array
    {
        // Collect today's data
        $today = date('Y-m-d');
        $data = $this->getAnalytics($today, $today, false);
        if ($data) {
            // Clear cache for today
            $cache_key = md5("analytics_{$today}_{$today}");
            $this->cache->forget($cache_key);
            return [
                'success' => true,
                'message' => 'Data refreshed successfully',
                'data' => $data
            ];
        }
        return [
            'success'   => false,
            'message'   => 'Something went wrong...'
        ];
    }
    /**
     * Process queued operations
     */
    public function processOperation(array|\WP_Error $result, object $operation, array $data): array|\WP_Error
    {
        $prefix = strtolower($this->service_name) . '_';
        switch ($operation->type) {
            case $prefix . 'collect_analytics':
                $this->collectDailyData();
                return ['success' => true, 'message' => 'Analytics collected'];
            default:
                return $result;
        }
    }
    /**
     * Test API connection
     */
    protected function performConnectionTest(): bool
    {
        // Basic tracking only needs website ID
        if (empty($this->website_id)) {
            return false;
        }
        // If API credentials provided, test them
        if (!empty($this->api_url) && !empty($this->api_token)) {
            try {
                $response = wp_remote_get(rtrim($this->api_url, '/') . '/api/websites', [
                    'headers' => [
                        'Authorization' => 'Bearer ' . $this->api_token,
                        'Accept' => 'application/json'
                    ],
                    'timeout' => 10
                ]);
                if (is_wp_error($response)) {
                    return false;
                }
                return wp_remote_retrieve_response_code($response) === 200;
            } catch (Exception $e) {
                $this->logError('Connection test failed', ['error' => $e->getMessage()]);
                return false;
            }
        }
        // Website ID exists, basic tracking will work
        return true;
    }
    /**
     * Get service description
     */
    public function getServiceDescription(): string
    {
        return "Privacy-focused analytics to understand your website traffic.";
    }
    /**
     * Create database tables for metrics storage
     */
    public static function createTables(): void
    {
        global $wpdb;
        $charset_collate = $wpdb->get_charset_collate();
        // Events table
        $events_table = $wpdb->prefix . BASE . 'umami_events';
        $events_sql = "CREATE TABLE IF NOT EXISTS $events_table (
            id bigint(20) UNSIGNED NOT NULL AUTO_INCREMENT,
            date date NOT NULL,
            event_name varchar(255) NOT NULL,
            event_count int(11) NOT NULL DEFAULT 0,
            event_data longtext,
            created_at datetime NOT NULL,
            PRIMARY KEY (id),
            KEY date_event (date, event_name)
        ) $charset_collate;";
        // Metrics table
        $metrics_table = $wpdb->prefix . BASE . 'performance_metrics';
        $metrics_sql = "CREATE TABLE IF NOT EXISTS $metrics_table (
            id bigint(20) UNSIGNED NOT NULL AUTO_INCREMENT,
            date date NOT NULL,
            pageviews int(11) NOT NULL DEFAULT 0,
            visitors int(11) NOT NULL DEFAULT 0,
            visits int(11) NOT NULL DEFAULT 0,
            bounces int(11) NOT NULL DEFAULT 0,
            total_time int(11) NOT NULL DEFAULT 0,
            created_at datetime NOT NULL,
            PRIMARY KEY (id),
            UNIQUE KEY date_unique (date)
        ) $charset_collate;";
        require_once(ABSPATH . 'wp-admin/includes/upgrade.php');
        dbDelta($events_sql);
        dbDelta($metrics_sql);
        // TODO: Implement initialize() method.
    }
}