=Continuing on with Integrations setup. Working on PostMark now
| | |
| | | [ |
| | | 'name' => 'Clear Credentials', |
| | | 'icon' => 'broom', |
| | | 'action' => [$this, 'clearCredentials'] |
| | | 'action' => [$this, 'deleteCredentials'] |
| | | ], |
| | | ]; |
| | | foreach ($actions as $a) { |
| | |
| | | $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( |
| | |
| | | $this->outputFields($this->optionalFields) |
| | | ); |
| | | } |
| | | if ($this->hasExtraOptions) { |
| | | if ($this->hasExtraOptions()) { |
| | | $form .= sprintf( |
| | | '<a href="%s" class="btn">More Settings</a>', |
| | | $this->determineOptionPage() |
| | |
| | | return true; |
| | | } |
| | | |
| | | |
| | | |
| | | } |
| | |
| | | { |
| | | $this->name = $name; |
| | | $this->setSlug(); |
| | | $this->setCallback($callback); |
| | | } |
| | | |
| | | public function setSlug(?string $slug = null):void |
| | |
| | | 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 |
| | |
| | | |
| | | 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 |
| | |
| | | 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; |
| | | } |
| | | |
| | | } |
| | |
| | | $this->userID = $userID; |
| | | $this->cache = Cache::for('integrations_' . $this->cacheName); |
| | | |
| | | |
| | | $this->getPostTypes(); |
| | | $this->getTaxonomies(); |
| | | $this->getUsers(); |
| | |
| | | } |
| | | |
| | | 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 { |
| | |
| | | |
| | | |
| | | if (!empty($this->syncPostTypes)) { |
| | | if (method_exists($this, 'addSavePost')) { |
| | | $this->addSavePost(); |
| | | } |
| | | |
| | | add_action('transition_post_status', [$this, 'handlePostStatusTransition'], 10, 3); |
| | | |
| | | if ($this->canSync['delete']) { |
| | |
| | | |
| | | public function hasOAuthCredentials(?int $userID = null): bool |
| | | { |
| | | if ($userID !== $this->userID) { |
| | | $this->switchUser($userID); |
| | | } |
| | | return $this->hasOAuth && $this->hasValidOAuth(); |
| | | return false; |
| | | } |
| | | |
| | | |
| | |
| | | |
| | | public static function hasExtraOptions():bool |
| | | { |
| | | return static::getInstance()::$hasExtraOptions; |
| | | return self::$hasExtraOptions; |
| | | } |
| | | |
| | | /** |
| | |
| | | trait OAuth |
| | | { |
| | | use _Base, Admin; |
| | | protected bool $hasOAuth = true; |
| | | protected OAuthURLs $oauth; |
| | | /** |
| | | * Child classes must set this (e.g. 'https://api.example.com/v1/') |
| | |
| | | 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 |
| | | */ |
| | |
| | | $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 [ |
| | |
| | | |
| | | 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; |
| | |
| | | |
| | | 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'); |
| | |
| | | |
| | | 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']??''; |
| | | } |
| | | } |
| | |
| | | |
| | | 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.) |
| | |
| | | */ |
| | | 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]; |
| | |
| | | protected function clearUser():void |
| | | { |
| | | $this->userID = false; |
| | | $this->credentials = []; |
| | | if (property_exists($this, 'rateLimiter')) { |
| | | $this->requestLimiter?->saveHistory(); |
| | | $this->requestLimiter = null; |
| | |
| | | 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 |
| | |
| | | * @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; |
| | |
| | | */ |
| | | protected array $contentTypes = []; |
| | | |
| | | protected array $credentials; |
| | | |
| | | |
| | | /** |
| | | * Check if integration is properly configured |
| | |
| | | |
| | | 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 |
| | |
| | | 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(); |
| | | } |
| | | } |
| | | |
| | |
| | | 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'); |
| | | |
| | |
| | | <?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 |
| | |
| | | |
| | | // 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 |
| | | */ |
| | |
| | | /** |
| | | * 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; |
| | | } |
| | |
| | | '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']); |
| | | } |
| | |
| | | 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 []; |
| | | } |
| | | } |
| | |
| | | */ |
| | | |
| | | 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')) { |
| | |
| | | |
| | | 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 []; |
| | | } |
| | | } |
| | |
| | | */ |
| | | |
| | | 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; |
| | |
| | | |
| | | 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', |
| | |
| | | '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 |
| | |
| | | |
| | | // 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'); |
| | |
| | | '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 |
| | |
| | | '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']; |
| | | } |
| | | } |
| | |
| | | <?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; |
| | |
| | | |
| | | 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', |
| | |
| | | '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'] |
| | | ] |
| | | ]; |
| | | } |
| | | |
| | | |
| | | } |
| | |
| | | <?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; |
| | |
| | | */ |
| | | 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. |
| | | } |
| | | } |
| | |
| | | /** |
| | | * 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; |
| | |
| | | |
| | | 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."; |
| | | } |
| | | } |
| | |
| | | <?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; |
| | | } |
| | |
| | | */ |
| | | 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); |
| | |
| | | } |
| | | |
| | | /** |
| | | * 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']; |
| | |
| | | $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; |
| | |
| | | |
| | | $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; |
| | | |
| | |
| | | } |
| | | 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); |
| | |
| | | } |
| | | |
| | | /** |
| | | * 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; |
| | | } |
| | | } |
| | |
| | | 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 = [ |
| | |
| | | 'update' => true, |
| | | 'delete' => true, |
| | | ]; |
| | | $this->hasBatchCreate = true; |
| | | $this->hasBatchUpdate = true; |
| | | $this->hasBatchDelete = true; |
| | | |
| | | $this->defineFields(); |
| | | $this->defineInstructions(); |
| | |
| | | $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'); |
| | | } |
| | | |
| | |
| | | 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 |
| | |
| | | ? 'https://connect.squareup.com' |
| | | : 'https://connect.squareupsandbox.com'; |
| | | } |
| | | protected function getAccessToken():string |
| | | { |
| | | return $this->loadCredentials()['access_token']??''; |
| | | } |
| | | |
| | | /*************************************************************** |
| | | * ACTIONS |
| | | ***************************************************************/ |
| | |
| | | <?php |
| | | namespace JVBase\integrations\services; |
| | | use JVBase\integrations\Admin; |
| | | use JVBase\integrations\Instance; |
| | | use JVBase\integrations\Integrations; |
| | | |
| | | if (!defined('ABSPATH')) { |
| | |
| | | |
| | | 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. |
| | | } |
| | | } |