=Base Integrations mainly done - doing a test with setting up the Square integration to follow
12 files added
15 files modified
| | |
| | | return $result; |
| | | } |
| | | |
| | | public function response(bool $success, string $message):array |
| | | { |
| | | return [ |
| | | 'success' => $success, |
| | | 'message' => $message |
| | | ]; |
| | | } |
| | | |
| | | public function renderConnection():string |
| | | public function renderConnection(bool $return = false):string |
| | | { |
| | | if ($this->userID && !JVB()->userCanConnect($this->service_name, $this->userID)) { |
| | | return ''; |
| | |
| | | $form .= $this->outputActionButtons(); |
| | | $form .= '</form>'; |
| | | |
| | | if (!$return) { |
| | | echo $form; |
| | | } |
| | | |
| | | return $form; |
| | | } |
| | | protected function outputActionButtons():string |
| | |
| | | public string $name; |
| | | public string $label; |
| | | public string $type; |
| | | public string $hint; |
| | | public bool $required; |
| | | public string|Closure|bool $permission; |
| | | protected array $options; |
| | | protected mixed $default; |
| | | protected string $subtype; |
| | | protected bool $hidden = false; |
| | | |
| | | public function __construct(string $name, string $label, string $type, callable|string|bool $permission) |
| | | { |
| | |
| | | { |
| | | $this->options = $options; |
| | | } |
| | | public function setDefault(mixed $default):void |
| | | { |
| | | $this->default = $default; |
| | | } |
| | | |
| | | public function setHint(string $hint):void |
| | | { |
| | | $this->hint = $hint; |
| | | } |
| | | public function setHidden(bool $hidden = true):void |
| | | { |
| | | $this->hidden = $hidden; |
| | | } |
| | | |
| | | public function setSubType(string $type):void |
| | | { |
| | | $this->subType = $type; |
| | | } |
| | | |
| | | public function getConfig():array |
| | | { |
| | |
| | | 'label' => $this->label, |
| | | 'type' => $this->type, |
| | | ]; |
| | | if ($this->required) { |
| | | if (isset($this->required)) { |
| | | $conf['required'] = true; |
| | | } |
| | | if ($this->options) { |
| | | if (isset($this->options)) { |
| | | $conf['options'] = $this->options; |
| | | } |
| | | if (isset($this->default)) { |
| | | $conf['default'] = $this->default; |
| | | } |
| | | if (isset($this->hint)) { |
| | | $conf['hint'] = $this->hint; |
| | | } |
| | | if (isset($this->subType)) { |
| | | $conf['subtype'] = $this->subType; |
| | | } |
| | | if ($this->hidden) { |
| | | $conf['hidden'] = true; |
| | | } |
| | | return $conf; |
| | | } |
| | | } |
| New file |
| | |
| | | <?php |
| | | namespace JVBase\integrations; |
| | | |
| | | if (!defined('ABSPATH')) { |
| | | exit; |
| | | } |
| | | |
| | | trait Instance { |
| | | protected static self $instance; |
| | | public static function getInstance():self |
| | | { |
| | | if (!isset(self::$instance)) { |
| | | self::$instance = new self(); |
| | | } |
| | | return self::$instance; |
| | | } |
| | | } |
| | |
| | | |
| | | use Exception; |
| | | use JVBase\managers\Cache; |
| | | use JVBase\managers\ErrorHandler; |
| | | use JVBase\managers\queue\executors\IntegrationExecutor; |
| | | use JVBase\managers\queue\mergers\DefaultMerger; |
| | | use JVBase\managers\queue\TypeConfig; |
| | | use JVBase\managers\UploadManager; |
| | | use JVBase\meta\Form; |
| | | use JVBase\meta\Meta; |
| | | use JVBase\registrar\helpers\AddIntegrationFields; |
| | | use JVBase\registrar\Registrar; |
| | | use WP_Error; |
| | | use WP_Post; |
| | | |
| | | if (!defined('ABSPATH')) { |
| | | exit; |
| | |
| | | { |
| | | use _Base; |
| | | |
| | | //Flag to allow for custom settings (defaults, etc) for an integration in the dashboard |
| | | public bool $hasExtraOptions = false; |
| | | public bool $hasBatchCreate = false; |
| | | public bool $hasBatchUpdate = false; |
| | | public bool $hasBatchDelete = false; |
| | | public bool $canCreateOnUpdate = false; |
| | | |
| | | /** |
| | | * API Configuration |
| | | * These properties define how the integration connects to external services |
| | |
| | | /** |
| | | * Credentials & State |
| | | */ |
| | | |
| | | protected string $defaultContent = 'post'; //Default integration content type, is none is set. MUST EXIST as array key in $this->>getContentTypes |
| | | |
| | | protected array $allowedContent = []; |
| | | |
| | | /** |
| | | * Caching Configuration |
| | | */ |
| | | protected ?string $cacheName = null; |
| | | protected Cache $cache; |
| | | protected array $cacheStrategy = [ |
| | | 'aggressive' => 3600, // 1 hour for stable data (e.g., profile info) |
| | | 'moderate' => 300, // 5 minutes for semi-dynamic data (e.g., posts) |
| | |
| | | 'none' => 0 // No caching for real-time data |
| | | ]; |
| | | |
| | | /** |
| | | * Post Syncing Capabilities |
| | | * Define what sync operations this integration supports |
| | | */ |
| | | protected array $canSync = [ |
| | | 'initial' => false, // Can share new posts to the service |
| | | 'update' => false, // Can update already-shared posts |
| | | 'delete' => false, // Can remove posts from the service |
| | | ]; |
| | | protected array $syncPostTypes = []; // Post types that can be synced (e.g., ['artwork', 'tattoo']): usually built by Registrar.php if the integration name exists as a key in [ 'integrations' => []] |
| | | protected array $syncTaxonomies = []; // Post types that can be synced (e.g., ['artwork', 'tattoo']): usually built by Registrar.php if the integration name exists as a key in [ 'integrations' => []] |
| | | protected array $contentTypes = []; // Integration's available content types. Set by child classes' getContentTypes |
| | | protected bool $has_content = false; // Whether integration has content that can sync |
| | | /** |
| | | * Error Handling Configuration |
| | | */ |
| | | protected array $lastError = []; |
| | | protected array $retryDelays = [1, 2, 5]; // Exponential backoff in seconds |
| | | |
| | | |
| | | |
| | | protected function __construct(?int $userID = null) |
| | | { |
| | |
| | | |
| | | $this->getPostTypes(); |
| | | $this->getTaxonomies(); |
| | | $this->setContentTypes(); |
| | | $this->getUsers(); |
| | | |
| | | if (method_exists($this, 'setContentTypes')) { |
| | | $this->setContentTypes(); |
| | | } |
| | | $this->registerHooks(); |
| | | |
| | | if (method_exists($this, 'setQueueTypes')) { |
| | |
| | | } |
| | | |
| | | $this->initializeRateLimiters(); |
| | | |
| | | if (method_exists($this, 'initializeActions')) { |
| | | $this->initializeActions(); |
| | | } |
| | | if (method_exists($this, 'addOAuthActions')) { |
| | | $this->addOAuthActions(); |
| | | } |
| | | |
| | | add_filter('jvbShouldRenderMeta', [$this, 'checkRenderField'], 10, 4); |
| | | } |
| | | protected function initializeRateLimiters():void |
| | | { |
| | | $key = $this->service_name; |
| | | if (!is_null($this->userID)) { |
| | | $key .= '_'.$this->userID; |
| | | } |
| | | |
| | | if ($this->hasRequests && !isset($this->requestLimiter) && !is_null($this->oauthLimiter)) { |
| | | $this->requestLimiter = new RateLimits($key); |
| | | } |
| | | if ($this->hasOAuth && !isset($this->oauthLimiter) && !is_null($this->oauthLimiter)) { |
| | | $key .= '_oauth'; |
| | | $this->oauthLimiter = new RateLimits($key, ['s' => 1,'m' => 10, 'h' => 100]); |
| | | } |
| | | } |
| | | |
| | | |
| | | protected function setContentTypes():void |
| | | { |
| | | |
| | | } |
| | | public function getContentTypes(string $type):array |
| | | { |
| | | return (array_key_exists($type, $this->contentTypes)) ? $this->contentTypes[$type] : $this->contentTypes; |
| | | } |
| | | |
| | | public function checkRenderField($shouldRender, $name, $type, $objectType):bool |
| | | { |
| | | if ($type !== 'form') { |
| | | return $shouldRender; |
| | | } |
| | | |
| | | if (!$this->isSetUp() && str_contains($name, $this->service_name)) { |
| | | return false; |
| | | } |
| | | return $shouldRender; |
| | | } |
| | | |
| | | public function handleOAuthConnect(): array |
| | | { |
| | | if (!$this->hasOAuth) { |
| | | return ['success' => false, 'message' => 'Not an OAuth service']; |
| | | } |
| | | |
| | | // This would typically redirect to OAuth provider |
| | | // Child classes can override for specific behavior |
| | | $auth_url = $this->getOAuthUrl(); |
| | | |
| | | if ($auth_url) { |
| | | return [ |
| | | 'success' => true, |
| | | 'redirect' => $auth_url, |
| | | ]; |
| | | } |
| | | |
| | | return ['success' => false, 'message' => 'Failed to generate OAuth URL']; |
| | | } |
| | | |
| | | /** |
| | | * Check if OAuth token is valid and not expired |
| | | * @return bool |
| | | */ |
| | | public function isOAuthValid(): bool |
| | | { |
| | | if (!$this->isOAuthService) { |
| | | return false; |
| | | } |
| | | |
| | | // Check if we have tokens |
| | | if (empty($this->credentials['access_token'])) { |
| | | return false; |
| | | } |
| | | |
| | | // Check token expiry if stored |
| | | if (!empty($this->credentials['expires_at'])) { |
| | | $expires_at = intval($this->credentials['expires_at']); |
| | | if ($expires_at <= time()) { |
| | | return false; |
| | | } |
| | | } |
| | | |
| | | // For services without expiry info, do a test API call |
| | | return true; |
| | | // return $this->testConnection(); |
| | | } |
| | | |
| | | |
| | | |
| | | public function getOAuthUrlAction(array $data = []): array |
| | | { |
| | | if (!$this->isOAuthService) { |
| | | return ['success' => false, 'message' => 'Not an OAuth service']; |
| | | } |
| | | |
| | | $return_url = $data['return_url'] ?? null; |
| | | $auth_url = $this->getOAuthUrl($return_url); |
| | | |
| | | if ($auth_url) { |
| | | return [ |
| | | 'success' => true, |
| | | 'auth_url' => $auth_url, |
| | | 'popup' => true |
| | | ]; |
| | | } |
| | | |
| | | return ['success' => false, 'message' => 'Failed to generate OAuth URL']; |
| | | } |
| | | |
| | | protected function getPostTypes(): void |
| | | { |
| | | $this->syncPostTypes = Registrar::withIntegration($this->service_name); |
| | | $this->syncPostTypes = Registrar::withIntegration($this->service_name, 'post'); |
| | | } |
| | | |
| | | protected function getUsers():void |
| | | { |
| | | $this->syncUsers = Registrar::withIntegration($this->service_name, 'user'); |
| | | } |
| | | |
| | | protected function getTaxonomies():void |
| | | { |
| | | $key = BASE . $this->service_name . '_sync_taxonomies'; |
| | | $taxonomies = get_option($key, false); |
| | | |
| | | if (!$taxonomies) { |
| | | // Combine both content and taxonomy filtering |
| | | $taxonomies = []; |
| | | foreach (Registrar::withFeature('is_content', 'term') as $type) { |
| | | $registrar = Registrar::getInstance($type); |
| | | if ($registrar->hasIntegration($this->service_name)) { |
| | | $taxonomies[] = $registrar->getSlug(); |
| | | } |
| | | } |
| | | |
| | | update_option($key, $taxonomies); |
| | | } |
| | | |
| | | $this->syncTaxonomies = $taxonomies; |
| | | $this->syncTaxonomies = Registrar::withIntegration($this->service_name, 'term'); |
| | | } |
| | | |
| | | protected function getRedirectUri(): string |
| | | { |
| | | // if (!empty($this->oauth['redirect_uri'])) { |
| | | // return $this->oauth['redirect_uri']; |
| | | // } |
| | | if ($this->hasOAuth) { |
| | | return admin_url('admin-ajax.php?action=' . BASE . $this->service_name . '_oauth_callback'); |
| | | } |
| | | // Changed from admin-ajax.php to REST endpoint |
| | | return rest_url('jvb/v1/oauth/callback?service=' . $this->service_name); |
| | | } |
| | | |
| | | /** |
| | | * Used by IntegrationsRoutes.php |
| | | * @param string $code |
| | | * @param string $state |
| | | * @return array |
| | | */ |
| | | public function handleOAuthCode(string $code, string $state): array |
| | | { |
| | | try { |
| | | $this->loadCredentials(); |
| | | $tokens = $this->exchangeOAuthCode($code); |
| | | |
| | | if (!$tokens) { |
| | | return ['success' => false, 'message' => 'Failed to exchange authorization code']; |
| | | } |
| | | |
| | | $credentials = array_merge($this->credentials, $tokens); |
| | | $credentials = $this->addCredentialData($credentials, $tokens); |
| | | $saved = $this->saveCredentials($credentials, false); |
| | | |
| | | if ($saved) { |
| | | return ['success' => true, 'message' => 'Successfully connected']; |
| | | } |
| | | |
| | | return ['success' => false, 'message' => 'Failed to save credentials']; |
| | | } catch (Exception $e) { |
| | | $this->logError('OAuth code exchange failed', ['error' => $e->getMessage()]); |
| | | return ['success' => false, 'message' => 'OAuth error: ' . $e->getMessage()]; |
| | | } |
| | | } |
| | | |
| | | |
| | | /********************************************************************* |
| | | * ABSTRACT METHODS - MUST BE IMPLEMENTED BY CHILD CLASSES |
| | | *********************************************************************/ |
| | |
| | | try { |
| | | // Process and validate credentials |
| | | if (!$this->validateCredentials($credentials)) { |
| | | $this->logError('Invalid credentials'); |
| | | return [ |
| | | 'success' => false, |
| | | 'message' => 'Credentials not formatted correctly' |
| | | ]; |
| | | $this->logError('saveCredentials', 'Invalid credentials'); |
| | | return $this->response(false, 'Credentials not formatted correctly'); |
| | | } |
| | | |
| | | //Merge the new credentials with the old ones (new stuff overriding any old stuff) |
| | | $old = $this->getCredentials(); |
| | | $credentials = array_merge($old, $credentials); |
| | | |
| | | // Temporarily set credentials for testing |
| | | $this->credentials = $credentials; |
| | | $this->initialize(); |
| | | |
| | | // Test the connection before saving |
| | | if ($test) { |
| | | if (!$this->testConnection(true)) { |
| | | $this->logError('Connection test failed'); |
| | | // Revert to old credentials |
| | | $this->credentials = $old; |
| | | $this->initialize(); |
| | | $this->logError('saveCredentials', 'Connection test failed'); |
| | | |
| | | return [ |
| | | 'success' => false, |
| | | 'message' => 'Connection failed. Please check your credentials.', |
| | | 'test_failed' => true |
| | | ]; |
| | | return $this->response(false, 'Connection failed. Please check your credentials'); |
| | | } |
| | | } |
| | | |
| | |
| | | $this->updateLastTestedTime(); |
| | | $this->clearCache(); |
| | | |
| | | return [ |
| | | 'success' => true, |
| | | 'message' => 'Credentials validated and saved successfully', |
| | | 'reload' => true |
| | | ]; |
| | | return $this->response(true, 'Credentials validated and saved successfully'); |
| | | } |
| | | |
| | | return [ |
| | | 'success' => false, |
| | | 'message' => 'Failed to save credentials to database' |
| | | ]; |
| | | return $this->response(false, 'Failed to save credentials to database'); |
| | | |
| | | } catch (\Exception $e) { |
| | | // Revert to old credentials on any error |
| | |
| | | $this->service_name, |
| | | $this->userID |
| | | ); |
| | | $this->credentials = $old; |
| | | $this->initialize(); |
| | | |
| | | return [ |
| | | 'success' => false, |
| | | 'message' => 'Validation error: ' . $e->getMessage() |
| | | ]; |
| | | return $this->response(false, 'Validation error: '.print_r($e->getMessage(), true)); |
| | | } |
| | | } |
| | | |
| | |
| | | public function deleteCredentials():array |
| | | { |
| | | $success = Auth::getInstance()->deleteCredentials($this->service_name, $this->userID); |
| | | return [ |
| | | 'success' => $success |
| | | ]; |
| | | return $this->response(true, 'Deleted successfully'); |
| | | } |
| | | /** |
| | | * Validate credentials before storing |
| | |
| | | /****************************************************************** |
| | | POST SYNC |
| | | ******************************************************************/ |
| | | /** |
| | | * Handle post save for syncing |
| | | * |
| | | * Override to implement custom sync logic when posts are saved. |
| | | * Check the $settings array for post type specific configuration. |
| | | * |
| | | * @param int $postID The post ID |
| | | * @param WP_Post $post The post object |
| | | * @param bool $update Whether this is an update |
| | | * @param array $settings Post type integration settings |
| | | * @return void |
| | | */ |
| | | protected function handleTheSavePost(int $postID, WP_Post $post, bool $update, array $settings): void |
| | | { |
| | | // Override in child classes that support post syncing |
| | | // Example implementation: |
| | | /* |
| | | $fields = $this->getSyncFields($postID, 'post', ['schedule_' . $this->service_name]); |
| | | $options = $fields['schedule_' . $this->service_name] !== '' |
| | | ? ['scheduled' => strtotime($fields['schedule_' . $this->service_name])] |
| | | : []; |
| | | |
| | | $this->queueOperation( |
| | | 'sync_post', |
| | | ['post_id' => $postID, 'is_update' => $update], |
| | | $options |
| | | ); |
| | | */ |
| | | } |
| | | |
| | | /********************************************************************* |
| | | * API REQUEST METHODS |
| | | *********************************************************************/ |
| | | |
| | | |
| | | |
| | | |
| | | |
| | | |
| | | |
| | | |
| | | |
| | | |
| | | |
| | | |
| | | |
| | | |
| | | /********************************************************************* |
| | | * SYNC METHODS |
| | |
| | | // Let child classes register additional hooks if needed |
| | | $this->registerAdditionalHooks(); |
| | | |
| | | if (!empty($this->syncPostTypes) && is_null($this->userID)) { |
| | | |
| | | if (!empty($this->syncPostTypes)) { |
| | | $this->addSavePost(); |
| | | add_action('transition_post_status', [$this, 'handlePostStatusTransition'], 10, 3); |
| | | |
| | |
| | | add_action('before_delete_post', [$this, 'handleDeletePost'], 10, 1); |
| | | } |
| | | } |
| | | if (!empty($this->syncTaxonomies) && is_null($this->userID)) { |
| | | if (!empty($this->syncTaxonomies)) { |
| | | add_action('saved_term', [$this, 'handleSaveTerm'], 20, 5); |
| | | if ($this->canSync['delete']) { |
| | | add_action('pre_delete_term', [$this, 'handleDeleteTerm'], 10, 2); |
| | | } |
| | | } |
| | | |
| | | add_action('init', [$this, 'registerQueueTypes'], 10); |
| | | if (method_exists($this, 'registerQueueTypes')) { |
| | | add_action('init', [$this, 'registerQueueTypes'], 10); |
| | | } |
| | | } |
| | | public function addSavePost():void |
| | | { |
| | | if (!has_action('save_post', [$this, 'handleSavePost'])) { |
| | | add_action('save_post', [$this, 'handleSavePost'], 20, 3); |
| | | } |
| | | } |
| | | public function removeSavePost():void |
| | | { |
| | | remove_action('save_post', [$this, 'handleSavePost'], 20, 3); |
| | | } |
| | | |
| | | |
| | | public function registerQueueTypes():void |
| | | { |
| | | if (empty($this->syncTaxonomies) && empty($this->syncPostTypes)) { |
| | | return; |
| | | } |
| | | $queue = JVB()->queue(); |
| | | $executor = new IntegrationExecutor(); |
| | | |
| | | $queue->registry()->register(self::$syncTo, new TypeConfig( |
| | | mergeable: new DefaultMerger('items'), |
| | | executor: $executor, |
| | | chunkKey: 'items', |
| | | chunkSize: 50, |
| | | maxRetries: 3, |
| | | )); |
| | | |
| | | if ($this->canSync['delete']) { |
| | | $queue->registry()->register(self::$deleteFrom, new TypeConfig( |
| | | mergeable: new DefaultMerger('external_ids'), |
| | | executor: $executor, |
| | | chunkKey: 'external_ids', |
| | | chunkSize: 200, |
| | | maxRetries: 2 |
| | | )); |
| | | } |
| | | |
| | | $queue->registry()->register(self::$syncFrom, new TypeConfig( |
| | | executor: $executor, |
| | | maxRetries: 3 |
| | | )); |
| | | |
| | | $this->registerAdditionalQueueTypes($executor); |
| | | } |
| | | protected function registerAdditionalQueueTypes(IntegrationExecutor $executor):void |
| | | { |
| | | //Empty. Integration extensions can register additional operation types from here. |
| | | } |
| | | |
| | | /** |
| | | * Handle connection setup and credential storage |
| | |
| | | } |
| | | |
| | | // Store credentials |
| | | $this->credentials = array_merge($this->credentials ?? [], $sanitized); |
| | | $this->credentials['last_updated'] = time(); |
| | | $credentials = array_merge($this->loadCredentials() ?? [], $sanitized); |
| | | $credentials['last_updated'] = time(); |
| | | |
| | | // Save to database |
| | | $saved = $this->saveCredentials($this->credentials); |
| | | $saved = $this->saveCredentials($credentials); |
| | | |
| | | if (!$saved) { |
| | | return [ |
| | |
| | | |
| | | /** |
| | | * Ensure service is initialized |
| | | * TODO: I don't think this is necessary anymore. |
| | | */ |
| | | protected function ensureInitialized(): void |
| | | { |
| | |
| | | $this->initialize(); |
| | | } |
| | | |
| | | |
| | | /*************************************************************** |
| | | ERROR HANDLING |
| | | ***************************************************************/ |
| | | /** |
| | | * Handle error responses |
| | | */ |
| | | protected function handleApiError(int $code, string $body, string $endpoint): void |
| | | { |
| | | $message = "API Error ({$code}): "; |
| | | $decoded = json_decode($body, true); |
| | | |
| | | // Extract error details |
| | | $error_details = $this->extractErrorDetails($decoded, $body); |
| | | $message .= $error_details['message']; |
| | | |
| | | // Determine error severity based on HTTP code |
| | | $severity = $this->getErrorSeverity($code); |
| | | |
| | | // Build comprehensive error context |
| | | $error_context = [ |
| | | 'service' => $this->service_name, |
| | | 'endpoint' => $endpoint, |
| | | 'http_code' => $code, |
| | | 'error_type' => $this->categorizeApiError($code), |
| | | 'error_details' => $error_details, |
| | | 'user_id' => $this->userID, |
| | | 'request_time' => time(), |
| | | 'consecutive_errors' => $this->error_stats['consecutive_errors'], |
| | | 'is_oauth' => $this->isOAuthService, |
| | | 'api_version' => $this->apiVersion, |
| | | 'integration_healthy' => $this->is_healthy |
| | | ]; |
| | | |
| | | // Add rate limit information if present |
| | | if ($code === 429) { |
| | | $error_context['rate_limit'] = $this->extractRateLimitInfo($decoded, $body); |
| | | } |
| | | |
| | | // Log to ErrorHandler with proper severity |
| | | $this->logError($message, $error_context, $severity); |
| | | |
| | | // Update error statistics |
| | | $this->updateErrorStats($code, $endpoint); |
| | | |
| | | // Store last error for debugging |
| | | $this->lastError = [ |
| | | 'code' => $code, |
| | | 'message' => $message, |
| | | 'endpoint' => $endpoint, |
| | | 'timestamp' => time(), |
| | | 'context' => $error_context |
| | | ]; |
| | | } |
| | | |
| | | |
| | | /***************************************************************** |
| | | OAUTH |
| | | *****************************************************************/ |
| | | |
| | | /** |
| | | * Get OAuth authorization URL |
| | | */ |
| | | public function getOAuthUrl(?string $return_url = null): string |
| | | { |
| | | |
| | | if (!$this->hasOAuth) { |
| | | return ''; |
| | | } |
| | | |
| | | if (empty($this->credentials)) { |
| | | $this->ensureInitialized(); |
| | | } |
| | | |
| | | if (empty($this->oauth['authorize'])) { |
| | | $this->logError('OAuth authorize URL not configured'); |
| | | return ''; |
| | | } |
| | | |
| | | // Build base parameters |
| | | $params = [ |
| | | 'client_id' => $this->credentials['client_id'] ?? $this->credentials['app_id'] ?? '', |
| | | 'redirect_uri' => $this->getRedirectUri(), |
| | | 'response_type' => 'code', |
| | | 'scope' => implode(' ', $this->oauth['scopes'] ?? []) |
| | | ]; |
| | | $state_key = wp_generate_password(32, false); |
| | | $user_id = $this->userID??0; |
| | | |
| | | // Store state data in transient (expires in 10 minutes) |
| | | set_transient( |
| | | 'oauth_state_' . $state_key, |
| | | [ |
| | | 'service' => $this->service_name, |
| | | 'user_id' => $user_id, |
| | | 'created' => time() |
| | | ], |
| | | 600 |
| | | ); |
| | | |
| | | $state_parts = [ |
| | | $state_key, |
| | | $user_id |
| | | ]; |
| | | |
| | | // Add return URL if provided |
| | | if ($return_url) { |
| | | $state_parts[] = base64_encode($return_url); |
| | | } else { |
| | | $state_parts[] = base64_encode(admin_url('admin.php?page=jvb-integrations')); |
| | | } |
| | | |
| | | $params['state'] = implode('|', $state_parts); |
| | | |
| | | // Allow child classes to modify params (they can override/remove as needed) |
| | | if (method_exists($this, 'addOAuthParams')) { |
| | | $params = $this->addOAuthParams($params); |
| | | } |
| | | |
| | | return $this->oauth['authorize'] . '?' . http_build_query($params); |
| | | } |
| | | |
| | | /** |
| | | * Add service-specific OAuth parameters |
| | | */ |
| | | protected function addOAuthParams(array $params): array |
| | | { |
| | | // Override in child classes to add service-specific params |
| | | // e.g., access_type, prompt, etc. |
| | | return $params; |
| | | } |
| | | |
| | | |
| | | |
| | | /** |
| | | * Extract retry-after header from response |
| | | * |
| | | * @param array|WP_Error $response WordPress HTTP response |
| | | * @return int Seconds to wait before retry |
| | | */ |
| | | protected function extractRetryAfter($response): int |
| | | { |
| | | if (is_wp_error($response)) { |
| | | return 5; |
| | | } |
| | | |
| | | $headers = wp_remote_retrieve_headers($response); |
| | | |
| | | if (isset($headers['retry-after'])) { |
| | | // Could be seconds or HTTP date |
| | | $retry_after = $headers['retry-after']; |
| | | |
| | | if (is_numeric($retry_after)) { |
| | | return (int) $retry_after; |
| | | } |
| | | |
| | | // Try to parse as date |
| | | $timestamp = strtotime($retry_after); |
| | | if ($timestamp !== false) { |
| | | return max(0, $timestamp - time()); |
| | | } |
| | | } |
| | | |
| | | return 5; // Default wait time |
| | | } |
| | | |
| | | /** |
| | | * Exchange OAuth code for tokens |
| | | */ |
| | | protected function exchangeOAuthCode(string $code): ?array |
| | | { |
| | | $this->ensureInitialized(); |
| | | |
| | | // Build request data |
| | | $request_data = [ |
| | | 'client_id' => $this->credentials['client_id'] ?? '', |
| | | 'client_secret' => $this->credentials['client_secret'] ?? '', |
| | | 'code' => $code, |
| | | 'grant_type' => 'authorization_code', |
| | | 'redirect_uri' => $this->getRedirectUri() |
| | | ]; |
| | | |
| | | $oauth_endpoint = $this->oauth['token']; |
| | | $response = $this->makeOAuthRequest('POST', $oauth_endpoint, $request_data); |
| | | |
| | | if (is_wp_error($response)) { |
| | | $this->logError('OAuth token exchange failed', [ |
| | | 'error' => $response->get_error_message(), |
| | | 'code' => $response->get_error_code() |
| | | ]); |
| | | return null; |
| | | } |
| | | |
| | | // Parse response |
| | | if (isset($response['access_token'])) { |
| | | $expires_in = $response['expires_in'] ?? 2592000; // 30 days default |
| | | |
| | | return [ |
| | | 'access_token' => $response['access_token'], |
| | | 'refresh_token' => $response['refresh_token'] ?? '', |
| | | 'expires_in' => $expires_in, |
| | | 'expires_at' => time() + $expires_in, // Calculate expiry timestamp |
| | | 'token_type' => $response['token_type'] ?? 'Bearer', |
| | | 'merchant_id' => $response['merchant_id'] ?? '', |
| | | 'scope' => $response['scope'] ?? '' |
| | | ]; |
| | | } |
| | | |
| | | $this->logError('Failed to obtain access token', ['response' => $response]); |
| | | return null; |
| | | } |
| | | |
| | | /** |
| | | * Add service-specific credential data |
| | | */ |
| | | protected function addCredentialData(array $credentials, array $tokens): array |
| | | { |
| | | // Override in child classes to add service-specific data |
| | | return $credentials; |
| | | } |
| | | |
| | | /** |
| | | * Check if token should be proactively refreshed |
| | | * Different from isOAuthValid() which checks if token is actually expired |
| | | */ |
| | | protected function shouldRefreshToken(): bool |
| | | { |
| | | if (!$this->hasOAuth || $this->refresh_interval === 0) { |
| | | return false; |
| | | } |
| | | |
| | | // If no expiry info, we can't proactively refresh |
| | | if (empty($this->credentials['expires_at'])) { |
| | | return false; |
| | | } |
| | | |
| | | $expires_at = intval($this->credentials['expires_at']); |
| | | $time_until_expiry = $expires_at - time(); |
| | | |
| | | // Refresh if we're within the refresh interval window |
| | | return $time_until_expiry > 0 && $time_until_expiry <= $this->refresh_interval; |
| | | } |
| | | /** |
| | | * Get time until token refresh is recommended |
| | | * Useful for displaying in admin UI |
| | | */ |
| | | public function getTimeUntilRefresh(): ?int |
| | | { |
| | | if ($this->refresh_interval === 0 || empty($this->credentials['expires_at'])) { |
| | | return null; |
| | | } |
| | | |
| | | $expires_at = intval($this->credentials['expires_at']); |
| | | $refresh_at = $expires_at - $this->refresh_interval; |
| | | $time_until_refresh = $refresh_at - time(); |
| | | |
| | | return max(0, $time_until_refresh); |
| | | } |
| | | |
| | | /** |
| | | * Get token freshness status |
| | | * Returns: 'fresh', 'should_refresh', 'expired', or 'no_expiry_info' |
| | | */ |
| | | public function getTokenStatus(): string |
| | | { |
| | | if (!$this->hasOAuth) { |
| | | return 'not_oauth'; |
| | | } |
| | | |
| | | if (empty($this->credentials['access_token'])) { |
| | | return 'no_token'; |
| | | } |
| | | |
| | | if (empty($this->credentials['expires_at'])) { |
| | | return 'no_expiry_info'; |
| | | } |
| | | |
| | | $expires_at = intval($this->credentials['expires_at']); |
| | | $now = time(); |
| | | |
| | | if ($expires_at <= $now) { |
| | | return 'expired'; |
| | | } |
| | | |
| | | if ($this->shouldRefreshToken()) { |
| | | return 'should_refresh'; |
| | | } |
| | | |
| | | return 'fresh'; |
| | | } |
| | | /** |
| | | * Refresh OAuth token |
| | | */ |
| | | protected function refreshOAuthToken(): bool |
| | | { |
| | | if (!$this->hasOAuth || empty($this->credentials['refresh_token'])) { |
| | | return false; |
| | | } |
| | | |
| | | $request_data = [ |
| | | 'client_id' => $this->credentials['client_id'], |
| | | 'client_secret' => $this->credentials['client_secret'], |
| | | 'refresh_token' => $this->credentials['refresh_token'], |
| | | 'grant_type' => 'refresh_token' |
| | | ]; |
| | | |
| | | $response = $this->makeOAuthRequest('POST', $this->oauth['token'], $request_data); |
| | | |
| | | if (is_wp_error($response)) { |
| | | $error_message = $response->get_error_message(); |
| | | |
| | | if (str_contains($error_message, 'invalid_grant')) { |
| | | $this->logError('OAuth refresh token is invalid - user must re-authorize', [ |
| | | 'error' => $error_message |
| | | ], 'critical'); |
| | | |
| | | // Mark unhealthy immediately |
| | | $this->error_stats['consecutive_errors'] = $this->error_threshold; |
| | | $this->is_healthy = false; |
| | | $this->saveErrorStats(); |
| | | } |
| | | |
| | | $this->logError('Failed to refresh OAuth token for '.$this->service_name, [ |
| | | 'error' => $error_message |
| | | ]); |
| | | return false; |
| | | } |
| | | |
| | | if (isset($response['access_token'])) { |
| | | $this->credentials['access_token'] = $response['access_token']; |
| | | $this->credentials['expires_at'] = time() + ($response['expires_in'] ?? 2592000); // 30 days |
| | | |
| | | // Note: Some services return the SAME refresh token |
| | | if (isset($response['refresh_token'])) { |
| | | $this->credentials['refresh_token'] = $response['refresh_token']; |
| | | } |
| | | |
| | | $this->saveCredentials($this->credentials); |
| | | return true; |
| | | } |
| | | |
| | | return false; |
| | | } |
| | | |
| | | /** |
| | | * Revoke OAuth access |
| | | */ |
| | | public function revokeOAuthAccess(): bool |
| | | { |
| | | if (!$this->hasOAuth || empty($this->oauth['revoke'])) { |
| | | return false; |
| | | } |
| | | |
| | | if (!empty($this->credentials['access_token'])) { |
| | | wp_remote_post($this->oauth['revoke'], [ |
| | | 'body' => ['token' => $this->credentials['access_token']] |
| | | ]); |
| | | } |
| | | |
| | | return Auth::getInstance()->deleteCredentials($this->service_name, $this->userID); |
| | | |
| | | } |
| | | |
| | | public function handleOAuthDisconnect(): array |
| | | { |
| | | try { |
| | | // Revoke the token with Square using centralized request |
| | | if (!empty($this->credentials['access_token'])) { |
| | | $revoke_data = [ |
| | | 'client_id' => $this->credentials['client_id'] ?? '', |
| | | 'access_token' => $this->credentials['access_token'] |
| | | ]; |
| | | |
| | | // Make revoke request (ignore response as revoke often returns empty) |
| | | $this->makeOAuthRequest('POST', $this->oauth['revoke'], $revoke_data); |
| | | } |
| | | |
| | | // Clear stored credentials (preserve app credentials) |
| | | $this->credentials = [ |
| | | 'client_id' => $this->credentials['client_id'] ?? '', |
| | | 'client_secret' => $this->credentials['client_secret'] ?? '', |
| | | 'environment' => $this->credentials['environment'] ?? 'sandbox' |
| | | ]; |
| | | |
| | | $this->saveCredentials($this->credentials); |
| | | $this->clearCache(); |
| | | |
| | | return [ |
| | | 'success' => true, |
| | | 'message' => 'Successfully disconnected from Square' |
| | | ]; |
| | | } catch (Exception $e) { |
| | | return [ |
| | | 'success' => false, |
| | | 'message' => 'Failed to disconnect: ' . $e->getMessage() |
| | | ]; |
| | | } |
| | | } |
| | | /** |
| | | * Generate webhook signature key for services that require it |
| | | * @return string |
| | |
| | | { |
| | | return wp_generate_password(32, false); |
| | | } |
| | | /** |
| | | * Generate OAuth state parameter |
| | | */ |
| | | protected function generateOAuthState(?int $user_id, ?string $return_url = null): string |
| | | { |
| | | $user_id = $user_id ?? 0; |
| | | $state = wp_create_nonce($this->service_name . '_oauth_' . $user_id) . '|' . $user_id; |
| | | |
| | | if ($return_url) { |
| | | $state .= '|' . base64_encode($return_url); |
| | | } |
| | | |
| | | return $state; |
| | | } |
| | | |
| | | protected function getNonce(): string |
| | | { |
| | | return wp_create_nonce($this->service_name . '_oauth_' . $this->userID); |
| | | } |
| | | |
| | | /** |
| | | * Determine return URL after OAuth |
| | | */ |
| | | protected function determineReturnUrl(?int $user_id): string |
| | | { |
| | | if ($user_id > 0) { |
| | | return home_url('/dash/integrations/#' . $this->service_name); |
| | | } |
| | | |
| | | return admin_url('admin.php?page=jvb-integrations'); |
| | | } |
| | | |
| | | |
| | | /**************************************************************** |
| | | POST SYNC |
| | | ****************************************************************/ |
| | | /** |
| | | * Get field mapping for a post type |
| | | */ |
| | | protected function getFieldMapping(string $post_type): array |
| | | { |
| | | // Apply filter for custom mapping |
| | | return apply_filters( |
| | | "jvb_{$this->service_name}_field_mapping", |
| | | [], |
| | | $post_type, |
| | | $this |
| | | ); |
| | | } |
| | | |
| | | |
| | | /** |
| | | * Map WordPress fields to service fields |
| | | */ |
| | | protected function mapFieldsToService(int $postID, array $mapping): array |
| | | { |
| | | $meta_manager = Meta::forPost($postID); |
| | | $service_data = []; |
| | | |
| | | foreach ($mapping as $wp_field => $service_field) { |
| | | $value = $meta_manager->get($wp_field); |
| | | |
| | | if ($value !== null && $value !== '') { |
| | | $this->setNestedValue($service_data, $service_field, $value); |
| | | } |
| | | } |
| | | |
| | | return apply_filters( |
| | | "jvb_{$this->service_name}_mapped_data", |
| | | $service_data, |
| | | $postID, |
| | | $mapping |
| | | ); |
| | | } |
| | | |
| | | /** |
| | | * Set nested array value using dot notation |
| | | */ |
| | | protected function setNestedValue(array &$array, string $path, $value): void |
| | | { |
| | | $keys = explode('.', $path); |
| | | $current = &$array; |
| | | |
| | | foreach ($keys as $i => $key) { |
| | | if ($i === count($keys) - 1) { |
| | | $current[$key] = $value; |
| | | } else { |
| | | if (!isset($current[$key])) { |
| | | $current[$key] = []; |
| | | } |
| | | $current = &$current[$key]; |
| | | } |
| | | } |
| | | } |
| | | /** |
| | | * Handle post save |
| | | */ |
| | | public function handleSavePost(int $postID, WP_Post $post, bool $update): void |
| | | { |
| | | if (!is_null($this->userID)) { |
| | | return; |
| | | } |
| | | error_log('=== ['.$this->service_name.']::handleSavePost called'); |
| | | |
| | | if (!$postID || $postID === 0) { |
| | | return; |
| | | } |
| | | $postType = jvbNoBase($post->post_type); |
| | | |
| | | if (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE) return; |
| | | if (wp_is_post_revision($postID)) return; |
| | | |
| | | |
| | | if (empty($this->syncPostTypes) || !in_array(jvbNoBase($postType), $this->syncPostTypes)) { |
| | | error_log('Not handling save for '.$this->service_name.' because there are no syncPostTypes: '.print_r($this->syncPostTypes, true)); |
| | | return; |
| | | } |
| | | |
| | | $registrar = Registrar::getInstance($postType); |
| | | if (!$registrar){ |
| | | return; |
| | | } |
| | | |
| | | $settings = $registrar->hasIntegration($this->service_name)??null; |
| | | if (!$settings) { |
| | | error_log('Not handling save for '.$this->service_name.' because of no registrar settings'); |
| | | return; |
| | | } |
| | | |
| | | $settings = $registrar->getIntegrationConfig($this->service_name); |
| | | if (!$settings){ |
| | | error_log('Not handling save for '.$this->service_name.' because of no integration config '.print_r($settings, true)); |
| | | return; |
| | | } |
| | | |
| | | |
| | | $fields = $this->getSyncFields($postID, 'post', ['schedule_'.$this->service_name]); |
| | | if (!$fields['share_to_'.$this->service_name]) { |
| | | error_log('Not handling save for '.$this->service_name.' because of no share_to_'.$this->service_name.' '.print_r($fields, true)); |
| | | return; |
| | | } |
| | | |
| | | $isShared = isset($fields["_{$this->service_name}_item_id"]); |
| | | if ($update && $isShared && !$fields['_keep_synced_'.$this->service_name]) { |
| | | error_log('Not handling save for '.$this->service_name.' because it is already shared, and not set to keep synced. '); |
| | | return; |
| | | } |
| | | |
| | | if ($post->post_status !== 'publish' && !$isShared) { |
| | | error_log('Not handling save for '.$this->service_name.' because post status is not publish, and it is not already shared.'); |
| | | return; |
| | | } |
| | | error_log('==== Sending to integration\'s handleTheSavePost '.$this->service_name.' ===='); |
| | | $this->removeSavePost(); |
| | | $this->handleTheSavePost($postID, $post, $update, $settings); |
| | | $this->addSavePost(); |
| | | } |
| | | |
| | | |
| | | protected function getSyncFields(int $postID, string $type, array $additional = []):array |
| | | { |
| | | $meta = new Meta($postID, $type); |
| | | $fieldsToCheck = [ |
| | | 'share_to_' . $this->service_name, |
| | | '_keep_synced_' . $this->service_name, |
| | | "_{$this->service_name}_item_id", |
| | | "_{$this->service_name}_last_sync", |
| | | "_{$this->service_name}_shared_at", |
| | | "_{$this->service_name}_sync_status", |
| | | ... $additional |
| | | ]; |
| | | return $meta->getAll($fieldsToCheck); |
| | | } |
| | | |
| | | /** |
| | | * Handle post status transitions |
| | | */ |
| | | public function handlePostStatusTransition(string $new_status, string $old_status, WP_Post $post): void |
| | | { |
| | | if (empty($this->syncPostTypes)) { |
| | | return; |
| | | } |
| | | |
| | | if (!in_array(jvbNoBase($post->post_type), $this->syncPostTypes)) { |
| | | return; |
| | | } |
| | | |
| | | //Map fields from our custom post types to the fields expected by the integration |
| | | $mappedFields = $this->getFieldMapping($post->post_type); |
| | | |
| | | $fields = $this->getSyncFields($post->ID, 'post', $mappedFields); |
| | | |
| | | // Handle unpublish action |
| | | if ($old_status === 'publish' && $new_status !== 'publish') { |
| | | |
| | | if ($fields["_{$this->service_name}_item_id"] && $this->canSync['delete']) { |
| | | try { |
| | | $this->queueOperation( |
| | | 'delete_post', |
| | | [ |
| | | $post, |
| | | $fields["_{$this->service_name}_item_id"] |
| | | ] |
| | | ); |
| | | } catch (Exception $e) { |
| | | $this->logError("Failed to handle unpublish for post {$post->ID}: " . $e->getMessage()); |
| | | } |
| | | } |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Handle post deletion |
| | | */ |
| | | public function handleDeletePost(int $postID): void |
| | | { |
| | | if (!$this->canSync['delete']) { |
| | | return; |
| | | } |
| | | |
| | | $post = get_post($postID); |
| | | if (!$post || !in_array($post->post_type, $this->syncPostTypes)) { |
| | | return; |
| | | } |
| | | |
| | | $fields = $this->getSyncFields($postID, 'post'); |
| | | |
| | | if ($fields["_{$this->service_name}_item_id"] !== '') { |
| | | |
| | | $this->queueOperation( |
| | | 'delete_post', |
| | | [ |
| | | 'post_id' => $post->ID, |
| | | ] |
| | | ); |
| | | } |
| | | } |
| | | |
| | | protected function handleSaveTerm($term_id, $tt_id, $taxonomy, $update, $args): void |
| | | { |
| | | $noBase = jvbNoBase($taxonomy); |
| | | if (!in_array($noBase, $this->syncTaxonomies)) { |
| | | return; |
| | | } |
| | | $registrar = Registrar::getInstance($noBase); |
| | | if (!$registrar->hasFeature('is_content')) { |
| | | return; |
| | | } |
| | | |
| | | |
| | | $settings = $registrar->getIntegrationConfig($this->service_name); |
| | | if (!$settings) { |
| | | return; |
| | | } |
| | | |
| | | // Similar sync logic as handleSavePost but for terms |
| | | $this->handleTheTermSave($term_id, $taxonomy, $update, $settings); |
| | | } |
| | | |
| | | protected function handleTheTermSave($term_id, $taxonomy, $update, $settings) { |
| | | |
| | | } |
| | | |
| | | |
| | | /******************************************************************* |
| | |
| | | *******************************************************************/ |
| | | |
| | | /** |
| | | * Get API URL for endpoint |
| | | */ |
| | | protected function getApiUrl(string $endpoint, ?string $baseKey = null): string|false |
| | | { |
| | | if ($this->hasOAuth && in_array($endpoint, $this->oauth)) { |
| | | return $endpoint; |
| | | } |
| | | if (is_array($this->apiBase)) { |
| | | if ($baseKey && array_key_exists($baseKey, $this->apiBase)) { |
| | | $base = $this->apiBase[$baseKey]; |
| | | } else { |
| | | $base = ($this->apiBase['base'] ?? reset($this->apiBase)); |
| | | } |
| | | } else { |
| | | $base = $this->apiBase; |
| | | } |
| | | |
| | | if (!$base || $base === '') { |
| | | $this->logError('API base URL not configured for {$this->>service_name}'); |
| | | return false; |
| | | } |
| | | |
| | | // Handle named endpoints |
| | | if (!$this->isValidEndpoint($endpoint)) { |
| | | $this->logError("{$endpoint} is not a valid endpoint for {$this->service_name}"); |
| | | return false; |
| | | } |
| | | |
| | | // Build full URL |
| | | $base = rtrim($base, '/'); |
| | | $endpoint = ltrim($endpoint, '/'); |
| | | |
| | | return "{$base}/{$endpoint}"; |
| | | } |
| | | |
| | | /** |
| | | * Check if an endpoint is valid, supporting both exact matches and patterns |
| | | * |
| | | * @param string $endpoint |
| | | * @return bool |
| | | */ |
| | | protected function isValidEndpoint(string $endpoint): bool |
| | | { |
| | | // Remove query parameters for validation |
| | | $endpointPath = parse_url($endpoint, PHP_URL_PATH); |
| | | if ($endpointPath === false) { |
| | | $endpointPath = $endpoint; |
| | | } |
| | | |
| | | foreach ($this->apiEndpoints as $pattern) { |
| | | // Check for exact match first |
| | | if ($endpointPath === $pattern) { |
| | | return true; |
| | | } |
| | | |
| | | if (str_starts_with($endpointPath, $pattern)) { |
| | | return true; |
| | | } |
| | | |
| | | // Check if pattern contains wildcards (indicated by square brackets) |
| | | if (strpos($pattern, '[') !== false) { |
| | | // Convert the pattern to a regex |
| | | $regexPattern = '#^' . str_replace(['[^/]+'], ['[^/]+'], $pattern) . '(?:\?.*)?$#'; |
| | | if (preg_match($regexPattern, $endpoint)) { |
| | | return true; |
| | | } |
| | | } |
| | | } |
| | | |
| | | return false; |
| | | } |
| | | |
| | | |
| | | |
| | | /** |
| | | * Categorize API errors for better tracking |
| | | */ |
| | | protected function categorizeApiError(int $code): string |
| | | { |
| | | return match(true) { |
| | | $code >= 400 && $code < 404 => 'client_error', |
| | | $code === 404 => 'not_found', |
| | | $code === 401 => 'authentication', |
| | | $code === 403 => 'authorization', |
| | | $code === 429 => 'rate_limit', |
| | | $code >= 500 && $code < 600 => 'server_error', |
| | | default => 'unknown' |
| | | }; |
| | | } |
| | | |
| | | /** |
| | | * Determine error severity based on HTTP code |
| | | */ |
| | | protected function getErrorSeverity(int $code): string |
| | | { |
| | | return match(true) { |
| | | $code === 429 => 'warning', // Rate limiting |
| | | $code >= 400 && $code < 500 => 'error', // Client errors |
| | | $code >= 500 => 'critical', // Server errors |
| | | default => 'error' |
| | | }; |
| | | } |
| | | |
| | | /** |
| | | * Extract error details from response |
| | | */ |
| | | protected function extractErrorDetails($decoded, string $body): array |
| | | { |
| | | $details = [ |
| | | 'message' => 'Unknown error', |
| | | 'code' => null, |
| | | 'details' => null |
| | | ]; |
| | | |
| | | if ($decoded && isset($decoded['error'])) { |
| | | if (is_array($decoded['error'])) { |
| | | $details['message'] = $decoded['error']['message'] ?? json_encode($decoded['error']); |
| | | $details['code'] = $decoded['error']['code'] ?? null; |
| | | $details['details'] = $decoded['error']['details'] ?? null; |
| | | } else { |
| | | $details['message'] = $decoded['error']; |
| | | } |
| | | } elseif ($decoded && isset($decoded['message'])) { |
| | | $details['message'] = $decoded['message']; |
| | | } elseif (!empty($body)) { |
| | | $details['message'] = $body; |
| | | } |
| | | |
| | | return $details; |
| | | } |
| | | |
| | | /** |
| | | * Extract rate limit information from response |
| | | */ |
| | | protected function extractRateLimitInfo($decoded, string $body): array |
| | | { |
| | | $info = [ |
| | | 'retry_after' => null, |
| | | 'limit' => null, |
| | | 'remaining' => null, |
| | | 'reset' => null |
| | | ]; |
| | | |
| | | // Try to extract from decoded response |
| | | if ($decoded) { |
| | | $info['retry_after'] = $decoded['retry_after'] ?? $decoded['retry-after'] ?? null; |
| | | $info['limit'] = $decoded['x-rate-limit-limit'] ?? null; |
| | | $info['remaining'] = $decoded['x-rate-limit-remaining'] ?? null; |
| | | $info['reset'] = $decoded['x-rate-limit-reset'] ?? null; |
| | | } |
| | | |
| | | return $info; |
| | | } |
| | | |
| | | /** |
| | | * Update error statistics |
| | | */ |
| | | protected function updateErrorStats(int $code, string $endpoint): void |
| | | { |
| | | $this->error_stats['total_errors']++; |
| | | $this->error_stats['consecutive_errors']++; |
| | | |
| | | // Track error types |
| | | $error_type = $this->categorizeApiError($code); |
| | | if (!isset($this->error_stats['error_types'][$error_type])) { |
| | | $this->error_stats['error_types'][$error_type] = 0; |
| | | } |
| | | $this->error_stats['error_types'][$error_type]++; |
| | | |
| | | // Save stats to cache |
| | | $this->saveErrorStats(); |
| | | } |
| | | |
| | | |
| | | |
| | | |
| | | /** |
| | | * Get service name |
| | | */ |
| | | public function getServiceName(): string |
| | |
| | | return static::getInstance()::$hasExtraOptions; |
| | | } |
| | | |
| | | /********************************************************************* |
| | | RENDERING |
| | | *********************************************************************/ |
| | | // public function renderAdditionalOptions() |
| | | // { |
| | | // //Default: nothing. |
| | | // } |
| | | |
| | | /** |
| | | * Render additional action buttons (optional) |
| | | * Override in integration classes that need extra actions |
| | | */ |
| | | // public function renderAdditionalActions(): void |
| | | // { |
| | | // // Default: no additional actions |
| | | // // Override in extensions for service-specific actions |
| | | // } |
| | | |
| | | /** |
| | | * Get service description (optional) |
| | | * Override in integration classes for custom descriptions |
| | |
| | | } |
| | | |
| | | |
| | | |
| | | |
| | | |
| | | |
| | | public function renderConnection(bool $return = false):string |
| | | { |
| | | |
| | | if ($this->userID && !JVB()->userCanConnect($this->service_name, $this->userID)) { |
| | | return ''; |
| | | } |
| | | |
| | | $meta = Meta::forOptions($this->userID.'_integrations'); |
| | | $is_connected = $this->isSetUp(); |
| | | $credentials = $this->getCredentials(); |
| | | |
| | | $admin_only = $this->isOAuthService ? [ |
| | | 'client_id', |
| | | 'client_secret', |
| | | ] : []; |
| | | |
| | | ob_start(); |
| | | ?> |
| | | <form id="<?=$this->service_name?>" class="integration <?php echo $is_connected ? 'connected' : 'disconnected'; ?>" |
| | | data-service="<?php echo esc_attr($this->service_name); ?>"> |
| | | <div class="header row x-btw"> |
| | | <h3><?php echo esc_html($this->title); ?></h3> |
| | | <div class="setup"> |
| | | <?php if ($is_connected): ?> |
| | | <span class="indicator connected">●</span> |
| | | <span class="text">Set Up</span> |
| | | <?php else: ?> |
| | | <span class="indicator disconnected">●</span> |
| | | <span class="text">Not Set Up</span> |
| | | <?php endif; ?> |
| | | </div> |
| | | </div> |
| | | |
| | | <?php if ($is_connected && array_key_exists('updated_at', $credentials) && $credentials['updated_at'] > 0): ?> |
| | | <div class="meta"> |
| | | <small>Last updated: <?php echo human_time_diff($credentials['updated_at']) . ' ago'; ?></small> |
| | | </div> |
| | | <?php endif; ?> |
| | | |
| | | <?php |
| | | if (!empty($this->instructions)) { |
| | | ?> |
| | | <details> |
| | | <summary> |
| | | Instructions |
| | | </summary> |
| | | <ol> |
| | | <?php |
| | | foreach ($this->instructions as $instruction) { |
| | | echo '<li>'.$instruction.'</li>'; |
| | | } |
| | | ?> |
| | | </ol> |
| | | </details> |
| | | <?php |
| | | } |
| | | ?> |
| | | <details class="initial-setup"<?= $is_connected?'' : ' open'?>> |
| | | <summary>Initial Setup</summary> |
| | | <?php |
| | | foreach ($this->fields as $name => $config) { |
| | | if ($is_connected && !empty($credentials[$name])) { |
| | | if (in_array($name, $admin_only) && !current_user_can('manage_options')) { |
| | | continue; |
| | | } |
| | | ?> |
| | | <span class="label"><?=$config['label']?>:</span> |
| | | <code> |
| | | <?php |
| | | if (str_contains($name, 'secret')) { |
| | | for ($i = 1; $i<=strlen($credentials[$name]) - 8; $i++) { |
| | | echo '*'; |
| | | } |
| | | echo substr($credentials[$name], -8); |
| | | } else { |
| | | echo $credentials[$name]; |
| | | } |
| | | ?> |
| | | </code> |
| | | <?php |
| | | } else { |
| | | $config['value'] = $credentials[$name]??''; |
| | | $config['autocomplete'] = 'off'; |
| | | $config['base'] = $this->service_name.'_'; |
| | | echo Form::render($name, '', $config); |
| | | } |
| | | } |
| | | if ($this->hasWebhooks) { |
| | | echo $this->renderWebhookUrl(); |
| | | } |
| | | ?> |
| | | </details> |
| | | <?php |
| | | |
| | | if ($this->isOAuthService) { |
| | | $this->renderConnectedOAuthStatus(); |
| | | } |
| | | |
| | | ?> |
| | | |
| | | <div class="integration-content"> |
| | | |
| | | <?php |
| | | |
| | | |
| | | |
| | | if (!empty($this->advanced)) { |
| | | ?> |
| | | <details> |
| | | <summary>Advanced Settings</summary> |
| | | <?php |
| | | foreach ($this->advanced as $name => $config) { |
| | | $config['value'] = $credentials[$name]??''; |
| | | $config['base'] = $this->service_name.'_'; |
| | | $config['autocomplete'] = 'off'; |
| | | Form::render($name,null, $config); |
| | | } |
| | | ?> |
| | | </details> |
| | | <?php |
| | | } |
| | | if (!empty($this->defaults)) { |
| | | ?> |
| | | <a href="<?php echo admin_url('admin.php?page=jvb-integration-' . $this->service_name); ?>" |
| | | class="button"> |
| | | More Settings |
| | | </a> |
| | | <?php |
| | | } |
| | | ?> |
| | | </div> |
| | | <div class="actions row x-btw wrap"> |
| | | <?php |
| | | foreach ($this->buttons as $action => $label) { |
| | | if (!$is_connected && $action !== 'save_credentials') { |
| | | continue; |
| | | } |
| | | $title = $confirm = ''; |
| | | switch ($action) { |
| | | case 'save_credentials': |
| | | $title = $label; |
| | | $label = jvbIcon('floppy-disk'); |
| | | break; |
| | | case 'clear_credentials': |
| | | $title = $label; |
| | | $label = jvbIcon('plugs'); |
| | | $confirm = ' data-confirm="Are you sure you want to delete these credentials?"'; |
| | | break; |
| | | case 'clear_cache': |
| | | $title = $label; |
| | | $label = jvbIcon('arrows-clockwise'); |
| | | break; |
| | | } |
| | | $title = $title === '' ? '' : ' title ="'.$title.'"'; |
| | | ?> |
| | | <button type="button" data-action="<?=$action?>"<?=$title?><?=$confirm?>><?=$label?></button> |
| | | <?php |
| | | } |
| | | ?> |
| | | </div> |
| | | </form> |
| | | <?php |
| | | $result = ob_get_clean(); |
| | | if(!$return) { |
| | | echo $result; |
| | | } |
| | | return $result; |
| | | } |
| | | |
| | | protected function renderConnectedOAuthStatus(): void |
| | | { |
| | | if (!$this->isSetup()) { |
| | | return; |
| | | } |
| | | $credentials = $this->getCredentials(); |
| | | $hasCredentials = $this->hasOAuthCredentials(); |
| | | $returnURL = is_admin() ? admin_url('admin.php?page=jvb-integrations') : (get_the_permalink() ?: home_url()); |
| | | ?> |
| | | |
| | | <details <?= $hasCredentials?' open':''?>> |
| | | <summary> |
| | | <?php if ($hasCredentials) { ?> |
| | | Connected Account |
| | | <?php } else { ?> |
| | | <div class="oauth-connect"> |
| | | <a href="<?php echo esc_url($this->getOAuthUrl($returnURL)); ?>" |
| | | class="button button-primary jvb-oauth-connect" |
| | | data-service="<?php echo esc_attr($this->service_name); ?>"> |
| | | <?php echo jvbIcon($this->icon); ?> |
| | | Authorize Connection |
| | | </a> |
| | | </div> |
| | | <?php } ?> |
| | | <div class="connection-status <?= $hasCredentials ? 'connected' : 'disconnected' ?>"> |
| | | <span class="status-indicator">●</span> |
| | | <span><?= $hasCredentials ? 'Connected' : 'Not Connected' ?></span> |
| | | </div> |
| | | </summary> |
| | | <label>OAuth Redirect URL:</label> |
| | | <code> |
| | | <?= $this->getRedirectUri(); ?> |
| | | </code> |
| | | |
| | | |
| | | <?php if (!empty($credentials['updated_at'])): ?> |
| | | <div class="oauth-meta"> |
| | | <small>Token expires: <?php |
| | | echo isset($credentials['expires_at']) |
| | | ? human_time_diff($credentials['expires_at']) |
| | | : 'Never'; |
| | | ?></small> |
| | | </div> |
| | | <?php endif; |
| | | // Allow child classes to add service-specific connected UI |
| | | $this->renderOAuthConnectedOptions(); |
| | | ?> |
| | | </details> |
| | | <?php |
| | | } |
| | | |
| | | protected function renderOAuthConnectedOptions():void |
| | | { |
| | | |
| | | } |
| | | |
| | | |
| | | /** |
| | | * Update last tested time |
| | | */ |
| | |
| | | $cred->updateTested($this->service_name, $this->userID); |
| | | } |
| | | |
| | | public function handleAdminPost(): void |
| | | { |
| | | if (!current_user_can('manage_options')) { |
| | | wp_die('Insufficient permissions'); |
| | | } |
| | | |
| | | $service = $this->getServiceName(); |
| | | |
| | | // Verify nonce |
| | | $nonce_field = 'jvb_integration_nonce_' . $service; |
| | | $nonce_action = 'jvb_integration_save_' . $service; |
| | | if (!isset($_POST[$nonce_field]) || !wp_verify_nonce($_POST[$nonce_field], $nonce_action)) { |
| | | wp_die('Security check failed'); |
| | | } |
| | | |
| | | // Get the action type |
| | | $action_type = sanitize_text_field($_POST['action_type'] ?? 'save'); |
| | | |
| | | // Prepare the request in the format handleAjaxRequest expects |
| | | $_REQUEST['action'] = $action_type; |
| | | $_REQUEST['service'] = $service; |
| | | |
| | | // Copy all POST data to REQUEST |
| | | foreach ($_POST as $key => $value) { |
| | | $_REQUEST[$key] = $value; |
| | | } |
| | | |
| | | // Set up for JSON response capture |
| | | ob_start(); |
| | | |
| | | try { |
| | | // Call the existing AJAX handler |
| | | $this->handleAjaxRequest(); |
| | | $response = ob_get_clean(); |
| | | |
| | | // Parse the JSON response |
| | | $result = json_decode($response, true); |
| | | |
| | | if ($result && isset($result['success'])) { |
| | | if ($result['success']) { |
| | | $message = $result['message'] ?? ucfirst($service) . ' settings saved successfully!'; |
| | | $this->setAdminNotice($message, 'success'); |
| | | } else { |
| | | $message = $result['message'] ?? 'Failed to save ' . ucfirst($service) . ' settings.'; |
| | | $this->setAdminNotice($message, 'error'); |
| | | } |
| | | } else { |
| | | // If no proper JSON response, check if connection worked |
| | | if ($action_type === 'test') { |
| | | $connected = $this->testConnection(); |
| | | $message = $connected ? 'Connection successful!' : 'Connection failed. Please check your credentials.'; |
| | | $this->setAdminNotice($message, $connected ? 'success' : 'error'); |
| | | } |
| | | } |
| | | } catch (Exception $e) { |
| | | ob_end_clean(); |
| | | $this->setAdminNotice('Error: ' . $e->getMessage(), 'error'); |
| | | } |
| | | |
| | | // Redirect back to integrations page |
| | | wp_redirect(admin_url('admin.php?page=jvb-integrations')); |
| | | exit; |
| | | } |
| | | |
| | | /** |
| | | * Set admin notice using transients for redirect |
| | | */ |
| | | protected function setAdminNotice(string $message, string $type = 'info'): void |
| | | { |
| | | $notices = get_transient('jvb_admin_notices') ?: []; |
| | | $notices[] = [ |
| | | 'message' => $message, |
| | | 'type' => $type === 'success' ? 'updated' : 'error' |
| | | ]; |
| | | set_transient('jvb_admin_notices', $notices, 30); |
| | | } |
| | | |
| | | /** |
| | | * Display admin notices from transient |
| | | */ |
| | | public static function displayAdminNotices(): void |
| | | { |
| | | $notices = get_transient('jvb_admin_notices'); |
| | | |
| | | if ($notices) { |
| | | foreach ($notices as $notice) { |
| | | ?> |
| | | <div class="notice notice-<?php echo esc_attr($notice['type']); ?> is-dismissible"> |
| | | <p><?php echo esc_html($notice['message']); ?></p> |
| | | </div> |
| | | <?php |
| | | } |
| | | delete_transient('jvb_admin_notices'); |
| | | } |
| | | } |
| | | |
| | | public function hasDefaults():bool |
| | | { |
| | | return !empty($this->defaults); |
| | | } |
| | | |
| | | public function renderDefaults():void |
| | | { |
| | | $types = $this->enabledContentTypes(); |
| | | if (empty($types)) { |
| | | return; |
| | | } |
| | | $meta = Meta::forOptions($this->userID.'_integrations'); |
| | | ?> |
| | | <form> |
| | | <h1><?= $this->title?> Defaults:</h1> |
| | | <p>Find yourself constantly repeating yourself?</p> |
| | | <p>Set defaults for different content types and <?=$this->title?>!</p> |
| | | <?php |
| | | foreach ($this->defaults as $name => $config) { |
| | | $config['required'] = false; |
| | | |
| | | $config['base'] = $this->service_name.'_'; |
| | | $config['autocomplete'] = 'off'; |
| | | echo Form::render($name, null, $config); |
| | | } |
| | | foreach ($this->syncPostTypes as $type) { |
| | | $registrar = Registrar::getInstance($type); |
| | | |
| | | $icon = $registrar->getIcon(); |
| | | $icon = $icon === '' ? jvbDefaultIcon() : $icon; |
| | | ?> |
| | | <details> |
| | | <summary><?= jvbIcon($icon) ?><?= $registrar->getSingular()?> Defaults</summary> |
| | | <?php |
| | | $fields = new AddIntegrationFields($this->service_name); |
| | | $fields = $fields->getIntegrationFields(); |
| | | foreach($fields as $name=>$c) { |
| | | $c['required'] = false; |
| | | if ($c['type'] === 'number') { |
| | | $c['type'] = 'text'; |
| | | $c['subtype'] = 'number'; |
| | | } |
| | | if (array_key_exists('description', $c)) { |
| | | $c['hint'] = $c['description']; |
| | | unset($c['description']); |
| | | } |
| | | echo Form::render($name, null, $c); |
| | | } |
| | | ?> |
| | | </details> |
| | | <?php |
| | | } |
| | | ?> |
| | | </form> |
| | | <?php |
| | | } |
| | | |
| | | public function hasContent():bool |
| | | { |
| | | return $this->has_content; |
| | | } |
| | | public function getDefaultContentType():string |
| | | { |
| | | return $this->defaultContent; |
| | | } |
| | | |
| | | public function enabledContentTypes():array |
| | | { |
| | | if (!$this->has_content) { |
| | | return []; |
| | | } |
| | | |
| | | return array_filter(array_map(function($registrar) { |
| | | $registrar = Registrar::getInstance($registrar); |
| | | return $registrar->getIntegration($this->service_name)->getContentType(); |
| | | }, Registrar::withIntegration($this->service_name))); |
| | | } |
| | | |
| | | protected function getSupportedImage(int $imgID):int |
| | | { |
| | | //If this integration supports webp, we can just send the original image id |
| | |
| | | } |
| | | |
| | | |
| | | public function getAllowedContent():array |
| | | { |
| | | return $this->allowedContent; |
| | | } |
| | | |
| | | /** |
| | | * Used by JVBase\registrar\helpers\AddIntegrationFields.php |
| | | * @return array |
| | |
| | | return $this->icon; |
| | | } |
| | | |
| | | |
| | | |
| | | public function canBatchUpdate():bool |
| | | { |
| | | return $this->hasBatchUpdate; |
| | | } |
| | | public function canBatchCreate():bool |
| | | { |
| | | return $this->hasBatchCreate; |
| | | } |
| | | public function canBatchDelete():bool |
| | | { |
| | | return $this->hasBatchDelete; |
| | | } |
| | | } |
| | |
| | | { |
| | | use _Base, Admin; |
| | | protected bool $hasOAuth = true; |
| | | protected OAuthURLs $oauth; |
| | | /** |
| | | * Child classes must set this (e.g. 'https://api.example.com/v1/') |
| | | */ |
| | |
| | | protected array $oathScopes = []; |
| | | |
| | | |
| | | abstract protected function getOAuthRequestHeaders(): array; |
| | | abstract protected function handleOAuthResponse(array|WP_Error $response, string $method, string $endpoint): array; |
| | | /** |
| | | * @return array Defaults to the base getRequestHeaders, but can optionally be overridden |
| | | */ |
| | | protected function getOAuthRequestHeaders(): array |
| | | { |
| | | return $this->getRequestHeaders(); |
| | | } |
| | | |
| | | /** |
| | | * Default to the base handleResponse method, but extensions can modify accordingly |
| | | * @param array|WP_Error $response |
| | | * @param string $method |
| | | * @param string $endpoint |
| | | * @return array |
| | | */ |
| | | protected function handleOAuthResponse(array|WP_Error $response, string $method, string $endpoint): array |
| | | { |
| | | return $this->handleResponse($response, $method, $endpoint); |
| | | } |
| | | abstract protected function refreshAccessToken(OAuthCredentials $credentials): array; |
| | | |
| | | function addOAuthActions():void |
| | |
| | | 'icon' => 'plugs-disconnected', |
| | | 'action' => [$this, 'handleOAuthDisconnect'] |
| | | ], |
| | | [ |
| | | 'name' => 'Refresh Token', |
| | | 'icon' => 'arrows-clockwise', |
| | | 'action' => [$this, 'handleOAuthRefreshToken'] |
| | | ] |
| | | // [ |
| | | // 'name' => 'Refresh Token', |
| | | // 'icon' => 'arrows-clockwise', |
| | | // 'action' => [$this, 'handleOAuthRefreshToken'] |
| | | // ] |
| | | ]; |
| | | foreach ($actions as $a) { |
| | | $this->addAction($a['name'], $a['action'], $a['slug']??null, $a['icon']??null, $a['label']??null); |
| | | } |
| | | } |
| | | public function handleOAuthConnect():array |
| | | { |
| | | if (!$this->hasOAuth) { |
| | | return $this->response(false, 'Not an OAuth service'); |
| | | } |
| | | $url = $this->getOAuthUrl(); |
| | | if ($url) { |
| | | return [ |
| | | 'success' => true, |
| | | 'redirect' => $url, |
| | | ]; |
| | | } |
| | | return $this->response(false, 'Failed to generate OAuth URL'); |
| | | } |
| | | |
| | | public function handleOAuthDisconnect():array |
| | | { |
| | | $revoked = $this->revokeOAuth(); |
| | | return $this->response($revoked, $revoked ? |
| | | 'Successfully disconnected from '.$this->title : |
| | | 'Failed to disconnect from '.$this->title); |
| | | } |
| | | |
| | | public function revokeOAuth():bool |
| | | { |
| | | if (!$this->hasOAuth) { |
| | | return false; |
| | | } |
| | | if (empty($this->oauth['revoke'])) { |
| | | error_log('No revoke url set for '.$this->service_name); |
| | | return false; |
| | | } |
| | | $credentials = $this->loadCredentials(); |
| | | if (empty($credentials['access_token'])) { |
| | | error_log('Could not send revoke request, no access_token for '.$this->service_name); |
| | | } else { |
| | | $response = $this->postOAuthRequest($this->oauth['revoke'], [ |
| | | 'body' => [ |
| | | 'token' => $credentials['access_token'] |
| | | ] |
| | | ]); |
| | | $empty = new OAuthCredentials([]); |
| | | foreach ($empty->toArray() as $key => $value) { |
| | | if (array_key_exists($key, $credentials)) { |
| | | unset($credentials[$key]); |
| | | } |
| | | } |
| | | |
| | | return Auth::getInstance()->storeCredentials($this->service_name, $credentials, $this->userID); |
| | | } |
| | | return false; |
| | | } |
| | | |
| | | protected function registerOAuthCallbacks():void |
| | | { |
| | |
| | | return $this->handleOAuthResponse($error, $method, $endpoint); |
| | | } |
| | | |
| | | if (!empty($this->requiredScopes) && !$credentials->hasScopes($this->requiredScopes)) { |
| | | $error = new WP_Error('insufficient_scope', "Missing required scopes for {$this->service_name}"); |
| | | return $this->handleOAuthResponse($error, $method, $endpoint); |
| | | } |
| | | |
| | | $url = rtrim($this->baseOAuthUrl, '/') . '/' . ltrim($endpoint, '/'); |
| | | |
| | |
| | | return ''; |
| | | } |
| | | $credentials = $this->loadCredentials(); |
| | | if (empty($credentials['authorize'])) { |
| | | if (empty($this->oauth['authorize'])) { |
| | | $this->logError('getOAuthUrl', 'OAuth authorize URL not configured'); |
| | | return ''; |
| | | } |
| | |
| | | 'client_id' => $credentials['client_id']??$credentials['application_id']??$credentials['app_id']??'', |
| | | 'redirect_url' => $this->getRedirectUri(), |
| | | 'response_type' => 'code', |
| | | 'scope' => implode(' ', $credentials['scopes']??[]) |
| | | 'scope' => implode(' ', $this->oauthScopes??[]) |
| | | ]; |
| | | $state_key = wp_generate_password(32, false); |
| | | $user_id = $this->userID??0; |
| | |
| | | $params = $this->addOAuthParams($params); |
| | | } |
| | | |
| | | return $credentials['authorize'] . '?' . http_build_query($params); |
| | | return $this->oauth['authorize'] . '?' . http_build_query($params); |
| | | } |
| | | |
| | | protected function getRedirectUri():string |
| | |
| | | public string $access_token; |
| | | public int $expires_at; |
| | | public ?string $refresh_token; |
| | | public ?string $revoke; |
| | | public array $scopes; |
| | | |
| | | public function __construct(array $data) |
| | |
| | | 'access_token' => $this->access_token, |
| | | 'expires_at' => $this->expires_at, |
| | | 'refresh_token' => $this->refresh_token, |
| | | 'scopes' => $this->scopes, |
| | | // 'scopes' => $this->scopes, |
| | | ]; |
| | | } |
| | | } |
| New file |
| | |
| | | <?php |
| | | namespace JVBase\integrations; |
| | | |
| | | if (!defined('ABSPATH')) { |
| | | exit; |
| | | } |
| | | |
| | | class OAuthURLs { |
| | | protected string $authorize; |
| | | protected string $token; |
| | | protected string $revoke; |
| | | public function __construct(string $authorize, string $token, string $revoke) { |
| | | $this->authorize = filter_var($authorize, FILTER_SANITIZE_URL); |
| | | $this->token = filter_var($token, FILTER_SANITIZE_URL); |
| | | $this->revoke = filter_var($revoke, FILTER_SANITIZE_URL); |
| | | } |
| | | public function getAuthorize():string { |
| | | return $this->authorize; |
| | | } |
| | | public function getToken():string { |
| | | return $this->token; |
| | | } |
| | | public function getRevoke():string{ |
| | | return $this->revoke; |
| | | } |
| | | } |
| | |
| | | <?php |
| | | namespace JVBase\integrations; |
| | | |
| | | use JVBase\base\Options; |
| | | use JVBase\meta\Form; |
| | | use JVBase\meta\Meta; |
| | | use JVBase\registrar\Registrar; |
| | | use JVBase\ui\Tabs; |
| | | |
| | | if (!defined('ABSPATH')) { |
| | | exit; |
| | | } |
| | |
| | | * Handles the getting and setting of default values for service fields. |
| | | */ |
| | | trait SyncDefaults { |
| | | use _Base; |
| | | protected array $allContent; |
| | | protected array $enabledTaxonomies; |
| | | protected array $enabledContent; |
| | | protected array $enabledUsers; |
| | | public function enabledContentTypes():array |
| | | { |
| | | //Build an array of post type/taxonomy slugs => integration content type |
| | | if (!isset($this->allContent)) { |
| | | $content = Registrar::withIntegration($this->service_name); |
| | | $this->allContent = []; |
| | | foreach ($content as $c) { |
| | | $registrar = Registrar::getInstance($c); |
| | | if ($registrar){ |
| | | $this->allContent[$c] = $registrar->getIntegration($this->service_name)->getContentType(); |
| | | } |
| | | } |
| | | } |
| | | return $this->allContent; |
| | | } |
| | | public function enabledContent():array |
| | | { |
| | | if (!isset($this->enabledContent)) { |
| | | $content = Registrar::withIntegration($this->service_name, 'post'); |
| | | $this->enabledContent = []; |
| | | foreach ($content as $c) { |
| | | $registrar = Registrar::getInstance($c); |
| | | if ($registrar){ |
| | | $this->enabledContent[$c] = $registrar->getIntegration($this->service_name)->getContentType(); |
| | | } |
| | | } |
| | | } |
| | | return $this->enabledContent; |
| | | } |
| | | public function enabledTaxonomies():array |
| | | { |
| | | if (!isset($this->enabledTaxonomies)) { |
| | | $content = Registrar::withIntegration($this->service_name, 'term'); |
| | | $this->enabledTaxonomies = []; |
| | | foreach ($content as $c) { |
| | | $registrar = Registrar::getInstance($c); |
| | | if ($registrar){ |
| | | $this->enabledTaxonomies[$c] = $registrar->getIntegration($this->service_name)->getContentType(); |
| | | } |
| | | } |
| | | } |
| | | return $this->enabledTaxonomies; |
| | | } |
| | | public function enabledUsers():array |
| | | { |
| | | if (!isset($this->enabledUsers)) { |
| | | $content = Registrar::withIntegration($this->service_name, 'user'); |
| | | $this->enabledUsers = []; |
| | | foreach ($content as $c) { |
| | | $registrar = Registrar::getInstance($c); |
| | | if ($registrar){ |
| | | $this->enabledUsers[$c] = $registrar->getIntegration($this->service_name)->getContentType(); |
| | | } |
| | | } |
| | | } |
| | | return $this->enabledUsers; |
| | | } |
| | | |
| | | public function renderDefaults():void |
| | | { |
| | | $tabs = new Tabs(); |
| | | |
| | | $types = [ |
| | | 'enabledContent' => [ |
| | | 'title' => 'Content', |
| | | 'slug' => 'content', |
| | | 'icon' => 'note-blank', |
| | | 'description' => 'Set default values for the meta for different content types here.' |
| | | ], |
| | | 'enabledTaxonomies' => [ |
| | | 'title' => 'Taxonomies', |
| | | 'slug' => 'taxonomies', |
| | | 'icon' => 'folder', |
| | | 'description' => 'Set default values for the meta for different taxonomies here.' |
| | | ], |
| | | 'enabledUsers' => [ |
| | | 'title' => 'Users', |
| | | 'slug' => 'users', |
| | | 'icon' => 'user', |
| | | 'description' => 'Set default values for the meta for different user types here.' |
| | | ] |
| | | ]; |
| | | |
| | | foreach ($types as $method => $options) { |
| | | $registered = $this->$method(); |
| | | if (empty($registered)) { |
| | | continue; |
| | | } |
| | | $role = !is_null($this->userID) ? jvbUserRole($this->userID) : ''; |
| | | $userRegistrar = null; |
| | | if (!empty($role)) { |
| | | $userRegistrar = Registrar::getInstance($role); |
| | | } |
| | | $allowed = is_null($this->userID) ? |
| | | array_keys($registered) : |
| | | array_filter(array_keys($registered), function ($type) use ($userRegistrar) { |
| | | return in_array($type, $userRegistrar->getCreatable()); |
| | | }); |
| | | if (empty($allowed)) { |
| | | continue; |
| | | } |
| | | |
| | | $tab = $tabs->addTab($options['slug']) |
| | | ->icon($options['icon']) |
| | | ->title($options['title']) |
| | | ->description($options['description']) |
| | | ->content($this->getContentTypeContent($allowed)); |
| | | } |
| | | $tabs->render(false); |
| | | } |
| | | public function getContentTypeContent(array $content):string |
| | | { |
| | | $tabs = new Tabs(); |
| | | foreach ($content as $c) { |
| | | $registrar = Registrar::getInstance($c); |
| | | $additionalFields = $registrar->getIntegrationFields($this->service_name); |
| | | $fields = $additionalFields->getIntegrationFields(); |
| | | if (empty($fields)) { |
| | | continue; |
| | | } |
| | | $form = ''; |
| | | foreach ($fields as $name => $config) { |
| | | $form .= Form::render( |
| | | $this->getFieldName($name, $c), |
| | | $this->getDefaultValue($name, $c, $this->userID), |
| | | $config |
| | | ); |
| | | } |
| | | |
| | | $tab = $tabs->addTab($c) |
| | | ->icon($registrar->getIcon()) |
| | | ->title($registrar->getPlural()) |
| | | ->description('Set default values for the meta for '.$registrar->getPlural().' here.') |
| | | ->content($form); |
| | | } |
| | | return $tabs->render(); |
| | | } |
| | | |
| | | protected function getFieldName(string $fieldName, string $type):string |
| | | { |
| | | return $type.'_defaults_'.$fieldName; |
| | | } |
| | | |
| | | public function getDefaultValue(string $fieldName, string $type, ?int $userID = null):mixed |
| | | { |
| | | $name = $this->getFieldName($fieldName, $type); |
| | | if (is_null($userID)) { |
| | | return Options::get($name); |
| | | } else { |
| | | return Meta::forUser($userID)->get($name); |
| | | } |
| | | } |
| | | public function setDefaultMetaValue(string $fieldName, mixed $value, string $type, ?int $userID = null):void |
| | | { |
| | | $name = $this->getFieldName($fieldName, $type); |
| | | if (is_null($userID)) { |
| | | Options::set($name, $value); |
| | | } else { |
| | | Meta::forUser($userID)->set($name, $value); |
| | | } |
| | | } |
| | | } |
| | |
| | | <?php |
| | | namespace JVBase\integrations; |
| | | |
| | | use JVBase\managers\queue\executors\IntegrationExecutor; |
| | | use JVBase\managers\queue\mergers\DefaultMerger; |
| | | use JVBase\managers\queue\TypeConfig; |
| | | use JVBase\meta\Meta; |
| | | use WP_Query; |
| | | |
| | |
| | | protected static string $syncCustomer; |
| | | protected static string $import; |
| | | |
| | | |
| | | protected function setQueueTypes():void |
| | | { |
| | | self::$syncTo = $this->service_name.'_sync_to'; |
| | |
| | | ]); |
| | | return is_wp_error($get) || empty($get) ? false : [$get[0], 'user']; |
| | | } |
| | | |
| | | public function registerQueueTypes():void |
| | | { |
| | | if (empty($this->syncTaxonomies) && empty($this->syncPostTypes)) { |
| | | return; |
| | | } |
| | | $queue = JVB()->queue(); |
| | | $executor = new IntegrationExecutor(); |
| | | |
| | | $queue->registry()->register(self::$syncTo, new TypeConfig( |
| | | mergeable: new DefaultMerger('items'), |
| | | executor: $executor, |
| | | chunkKey: 'items', |
| | | chunkSize: 50, |
| | | maxRetries: 3, |
| | | )); |
| | | |
| | | if ($this->canSync['delete']) { |
| | | $queue->registry()->register(self::$deleteFrom, new TypeConfig( |
| | | mergeable: new DefaultMerger('external_ids'), |
| | | executor: $executor, |
| | | chunkKey: 'external_ids', |
| | | chunkSize: 200, |
| | | maxRetries: 2 |
| | | )); |
| | | } |
| | | |
| | | $queue->registry()->register(self::$syncFrom, new TypeConfig( |
| | | executor: $executor, |
| | | maxRetries: 3 |
| | | )); |
| | | |
| | | $this->registerAdditionalQueueTypes($executor); |
| | | } |
| | | protected function registerAdditionalQueueTypes(IntegrationExecutor $executor):void |
| | | { |
| | | //Empty. Integration extensions can register additional operation types from here. |
| | | } |
| | | |
| | | /** |
| | | * @return void Sets the $this->contentTypes |
| | | */ |
| | | abstract protected function setContentTypes():void; |
| | | } |
| | |
| | | namespace JVBase\integrations; |
| | | |
| | | use Exception; |
| | | use JVBase\meta\Meta; |
| | | use JVBase\meta\Sanitizer; |
| | | use JVBase\registrar\Registrar; |
| | | use WP_Error; |
| | | use WP_Post; |
| | | use WP_Term; |
| | | use WP_User; |
| | | |
| | | if (!defined('ABSPATH')) { |
| | | exit; |
| | | } |
| | | |
| | | trait SyncTo { |
| | | use SyncHelpers; |
| | | use SyncHelpers, UserConnection; |
| | | /** |
| | | * Must be defined according to how each service needs it to be |
| | | * @param int $itemID |
| | |
| | | * @return array |
| | | * @throws Exception |
| | | */ |
| | | protected function formatForService(int $itemID, string $type = 'post'):array |
| | | { |
| | | throw new Exception('formatForService must be implemented by child class'); |
| | | } |
| | | abstract protected function formatForService(int $itemID, string $type = 'post'):array; |
| | | |
| | | /** |
| | | * Defaults to an array of formatted items. Can be overridden in child classes |
| | |
| | | try { |
| | | $items[] = $this->formatForService($ID, $type); |
| | | } catch (Exception $e){ |
| | | $this->logError('Could not format Item for service',[ |
| | | $this->logError('formatItems', 'Could not format Item for service',[ |
| | | 'itemID' => $ID, |
| | | 'type' => $type, |
| | | 'message' => $e->getMessage(), |
| | |
| | | return $this->noCreatedItems($updated); |
| | | } |
| | | |
| | | $response = [ |
| | | 'outcome' => 'failed', |
| | | 'result' => [ |
| | | 'message' => 'No result' |
| | | ] |
| | | ]; |
| | | if ($this->hasBatchCreate) { |
| | | $response = $this->sendBatchCreate($items); |
| | | if (!is_wp_error($response)) { |
| | | $result = $this->processBatchCreateResponse($data, $response); |
| | | } else { |
| | | $this->logError('Batch create failed',[ |
| | | $this->logError('createBatchToService','Batch create failed',[ |
| | | 'method' => 'createBatchToService', |
| | | 'item_ids' => $data['items'], |
| | | 'error' => $response |
| | |
| | | if (!is_wp_error($response)) { |
| | | $result = $this->processBatchUpdateResponse($data, $response); |
| | | } else { |
| | | $this->logError('Batch update failed',[ |
| | | $this->logError('updateBatchToService','Batch update failed',[ |
| | | 'method' => 'updateBatchToService', |
| | | 'item_ids' => $data['items'], |
| | | 'error' => $response |
| | |
| | | } |
| | | return $result; |
| | | } |
| | | |
| | | /**************************************************************** |
| | | * UTILITY |
| | | ****************************************************************/ |
| | | protected function getSyncFields(int $itemID, string $type, array $additionalFields = []):array |
| | | { |
| | | $meta = match ($type) { |
| | | 'post' => Meta::forPost($itemID), |
| | | 'term' => Meta::forTerm($itemID), |
| | | 'user' => Meta::forUser($itemID), |
| | | default => false |
| | | }; |
| | | if (!$meta) { |
| | | return []; |
| | | } |
| | | |
| | | $fields = [ |
| | | 'share_to_' . $this->service_name, |
| | | '_keep_synced_' . $this->service_name, |
| | | "_{$this->service_name}_item_id", |
| | | "_{$this->service_name}_last_sync", |
| | | "_{$this->service_name}_shared_at", |
| | | "_{$this->service_name}_sync_status", |
| | | "_{$this->service_name}_scheduled_at", |
| | | ... $additionalFields |
| | | ]; |
| | | return $meta->getAll($fields); |
| | | } |
| | | /**************************************************************** |
| | | * POST SYNC |
| | | ****************************************************************/ |
| | | public function addSavePost():void |
| | | { |
| | | if (!has_action('save_post', [$this, 'handleSavePost'])) { |
| | | add_action('save_post', [$this, 'handleSavePost'], 20, 3); |
| | | } |
| | | } |
| | | public function removeSavePost():void |
| | | { |
| | | remove_action('save_post', [$this, 'handleSavePost'], 20, 3); |
| | | } |
| | | public function handleSavePost(int $postID, WP_Post $post, bool $update):void |
| | | { |
| | | if (defined('DOING_AUTOSAVE') && DOING_AUTOSAVE) return; |
| | | if (wp_is_post_revision($postID)) return; |
| | | |
| | | error_log('=== ['.$this->service_name.']::handleSavePost called'); |
| | | |
| | | $postType = jvbNoBase($post->post_type); |
| | | if (!in_array($postType, $this->syncPostTypes)) { |
| | | error_log('Not handling save for '.$this->service_name.' because there are no syncPostTypes: '.print_r($this->syncPostTypes, true)); |
| | | return; |
| | | } |
| | | |
| | | $registrar = Registrar::getInstance($postType); |
| | | //Should not happen, as syncPostTypes is defined by Registrar instances |
| | | if (!$registrar) { |
| | | return; |
| | | } |
| | | |
| | | $settings = $registrar->getIntegrationConfig($this->service_name); |
| | | if (!$settings) { |
| | | error_log('Not handling save for '.$this->service_name.' because of no integration config '.print_r($settings, true)); |
| | | return; |
| | | } |
| | | |
| | | $fields = $this->getSyncFields($postID, 'post'); |
| | | if (!$fields['share_to_'.$this->service_name]) { |
| | | error_log('Not handling save for '.$this->service_name.' because of no share_to_'.$this->service_name.' '.print_r($fields, true)); |
| | | return; |
| | | } |
| | | |
| | | $isShared = array_key_exists("_{$this->service_name}_item_id", $fields) && !empty($fields["_{$this->service_name}_item_id"]); |
| | | |
| | | if ($post->post_status !== 'publish' && !$isShared) { |
| | | error_log('Not handling save for '.$this->service_name.' because post status is not publish, and it is not already shared.'); |
| | | return; |
| | | } |
| | | |
| | | if ($isShared && $update && !$fields['_keep_synced_'.$this->service_name]) { |
| | | error_log('Not handling save for '.$this->service_name.' because it is already shared, and not set to keep synced. '); |
| | | return; |
| | | } |
| | | error_log('==== Sending to integration\'s handleTheSavePost '.$this->service_name.' ===='); |
| | | $this->removeSavePost(); |
| | | $this->handleTheSavePost($postID, $post, $update, $settings); |
| | | $this->addSavePost(); |
| | | } |
| | | /** |
| | | * Handle post save for syncing |
| | | * |
| | | * Override to implement custom sync logic when posts are saved. |
| | | * Check the $settings array for post type specific configuration. |
| | | * |
| | | * @param int $postID The post ID |
| | | * @param WP_Post $post The post object |
| | | * @param bool $update Whether this is an update |
| | | * @param array $settings Post type integration settings |
| | | * @return void |
| | | */ |
| | | protected function handleTheSavePost(int $postID, WP_Post $post, bool $update, array $settings):void |
| | | { |
| | | error_log('==== ['.$this->title.']::handleTheSavePost ===='); |
| | | $this->queueOperation(self::$syncTo, [ |
| | | 'posts' => [$postID], |
| | | 'user' => user_can($post->post_author, 'manage_options') ? null : $post->post_author |
| | | ], [ |
| | | 'priority' => 'high', |
| | | 'delay' => 30, |
| | | ]); |
| | | Meta::forPost($postID)->set('_'.$this->service_name.'_sync_status', 'queued'); |
| | | } |
| | | |
| | | public function addDeletePost():void |
| | | { |
| | | if (!has_action('before_delete_post', [$this, 'handleDeletePost'])) { |
| | | add_action('before_delete_post', [$this, 'handleDeletePost'], 20, 3); |
| | | } |
| | | } |
| | | public function removeDeletePost():void |
| | | { |
| | | remove_action('before_delete_post', [$this, 'handleSavePost'], 20, 3); |
| | | } |
| | | public function handleDeletePost(int $postID):void |
| | | { |
| | | if (!$this->canSync['delete']) { |
| | | return; |
| | | } |
| | | $postType = get_post_type($postID); |
| | | if (!in_array(jvbNoBase($postType), $this->syncPostTypes)) { |
| | | return; |
| | | } |
| | | $fields = $this->getSyncFields($postID, 'post'); |
| | | if (empty($fields["_{$this->service_name}_item_id"])) { |
| | | return; |
| | | } |
| | | $post = get_post($postID); |
| | | if (!$post) { |
| | | return; |
| | | } |
| | | $userID = $this->determineUserID($post->post_author); |
| | | if (!$userID) { |
| | | return; |
| | | } |
| | | |
| | | JVB()->queue()->add( |
| | | self::$deleteFrom, |
| | | $userID, |
| | | [ |
| | | 'fields' => [$postID => $fields], |
| | | 'service' => $this->service_name, |
| | | 'type' => 'post' |
| | | ] |
| | | ); |
| | | } |
| | | /**************************************************************** |
| | | * TERM SYNC |
| | | ****************************************************************/ |
| | | public function addSaveTerm():void |
| | | { |
| | | if (empty($this->syncTaxonomies)) { |
| | | return; |
| | | } |
| | | if (!has_action('saved_term', [$this, 'handleSaveTerm'])) { |
| | | add_action('saved_term', [$this, 'handleSaveTerm'], 20, 3); |
| | | } |
| | | } |
| | | public function removeSaveTerm():void |
| | | { |
| | | if (empty($this->syncTaxonomies)) { |
| | | return; |
| | | } |
| | | remove_action('saved_term', [$this, 'handleSaveTerm'], 20, 3); |
| | | } |
| | | protected function handleSaveTerm(int $termID, int $tt_id, string $taxonomy, bool $update, array $args):void |
| | | { |
| | | $tax = jvbNoBase($taxonomy); |
| | | if (!in_array($tax, $this->syncTaxonomies)) { |
| | | return; |
| | | } |
| | | $registrar = Registrar::getInstance($tax); |
| | | if (!$registrar) { |
| | | return; |
| | | } |
| | | |
| | | $settings = $registrar->getIntegrationConfig($this->service_name); |
| | | if (!$settings) { |
| | | return; |
| | | } |
| | | |
| | | $fields = $this->getSyncFields($termID, 'term'); |
| | | if (!$fields['share_to_'.$this->service_name]) { |
| | | return; |
| | | } |
| | | |
| | | $isShared = array_key_exists("_{$this->service_name}_item_id", $fields) && !empty($fields["_{$this->service_name}_item_id"]); |
| | | |
| | | if ($isShared && $update && !$fields['_keep_synced_'.$this->service_name]) { |
| | | return; |
| | | } |
| | | $this->removeSaveTerm(); |
| | | $this->handleTheSaveTerm($termID, $update, $taxonomy, $settings); |
| | | $this->addSaveTerm(); |
| | | } |
| | | |
| | | /** |
| | | * @param int $termID |
| | | * @param bool $update |
| | | * @param array $settings |
| | | * @return void |
| | | */ |
| | | protected function handleTheSaveTerm(int $termID, bool $update, string $taxonomy, array $settings):void |
| | | { |
| | | error_log('==== ['.$this->title.']::handleTheSaveTerm ===='); |
| | | //TODO: Figure out some sort of permissions for if the user can share this term, particularly for content types |
| | | //TODO: If this is a content type, it likely has its own integration. This is an edmonton.ink problem, so I'm offloading it for now |
| | | $this->queueOperation(self::$syncTo, [ |
| | | 'terms' => [$termID], |
| | | 'user' => get_current_user_id(), |
| | | ], [ |
| | | 'priority' => 'high', |
| | | 'delay' => 30, |
| | | ]); |
| | | Meta::forTerm($termID)->set('_'.$this->service_name.'_sync_status', 'queued'); |
| | | } |
| | | |
| | | public function addDeleteTerm():void |
| | | { |
| | | if (!has_action('pre_delete_term', [$this, 'handleDeleteTerm'])) { |
| | | add_action('pre_delete_term', [$this, 'handleDeleteTerm']); |
| | | } |
| | | } |
| | | public function removeDeleteTerm():void |
| | | { |
| | | remove_action('pre_delete_term', [$this, 'handleDeleteTerm']); |
| | | } |
| | | public function handleDeleteTerm(int $termID, string $taxonomy):void |
| | | { |
| | | if (!$this->canSync['delete']) { |
| | | return; |
| | | } |
| | | $tax = jvbNoBase($taxonomy); |
| | | if (!in_array($tax, $this->syncTaxonomies)) { |
| | | return; |
| | | } |
| | | $fields = $this->getSyncFields($termID, 'term'); |
| | | if (empty($fields["_{$this->service_name}_item_id"])) { |
| | | return; |
| | | } |
| | | JVB()->queue()->add( |
| | | self::$deleteFrom, |
| | | 0, |
| | | [ |
| | | 'fields' => [$termID => $fields], |
| | | 'service' => $this->service_name, |
| | | 'type' => 'term' |
| | | ] |
| | | ); |
| | | } |
| | | |
| | | /**************************************************************** |
| | | * USER SYNC |
| | | ****************************************************************/ |
| | | public function addSaveUser():void |
| | | { |
| | | if (empty($this->syncUsers)) { |
| | | return; |
| | | } |
| | | if (!has_action('profile_update', [$this, 'handleUpdateUser'])) { |
| | | add_action('profile_update', [$this, 'handleUpdateUser'], 20, 1); |
| | | } |
| | | if (!has_action('user_register', [$this, 'handleUpdateUser'])) { |
| | | add_action('user_register', [$this, 'handleUpdateUser'], 20, 1); |
| | | } |
| | | } |
| | | public function removeSaveUser():void |
| | | { |
| | | if (empty($this->syncUsers)) { |
| | | return; |
| | | } |
| | | remove_action('profile_update', [$this, 'handleUpdateUser'], 20); |
| | | remove_action('user_register', [$this, 'handleUpdateUser'], 20); |
| | | } |
| | | protected function handleUpdateUser(int $userID):void |
| | | { |
| | | $user = $this->getOrCreateUser($userID); |
| | | if (!$user) { |
| | | return; |
| | | } |
| | | |
| | | $fields = $this->getSyncFields($userID, 'user'); |
| | | if (!empty($fields["_{$this->service_name}_item_id"])) { |
| | | return; |
| | | } |
| | | |
| | | $this->removeSaveUser(); |
| | | $this->handleTheSaveUser($userID); |
| | | $this->addSaveTerm(); |
| | | } |
| | | |
| | | protected function getOrCreateUser(int $userID):string|false |
| | | { |
| | | $user = get_userdata($userID); |
| | | if (!$user || is_wp_error($user)) { |
| | | return false; |
| | | } |
| | | $role = jvbUserRole($userID); |
| | | if (!in_array(jvbNoBase($role), $this->syncUsers)) { |
| | | return false; |
| | | } |
| | | $fields = $this->getSyncFields($userID, 'user'); |
| | | if (!empty($fields["_{$this->service_name}_item_id"])) { |
| | | return $fields["_{$this->service_name}_item_id"]; |
| | | } |
| | | |
| | | $meta = Meta::forUser($userID); |
| | | $serviceUserID = $this->searchServiceForUser($user->user_email??''); |
| | | if ($serviceUserID) { |
| | | $meta->set("{$this->service_name}_item_id", $serviceUserID); |
| | | return $serviceUserID; |
| | | } |
| | | |
| | | $created = $this->createServiceUser($userID, $fields); |
| | | if ($created) { |
| | | return $created; |
| | | } |
| | | return false; |
| | | } |
| | | public function searchServiceForUser(string $email):string|false |
| | | { |
| | | if (!filter_var($email, FILTER_VALIDATE_EMAIL)) { |
| | | return false; |
| | | } |
| | | return $this->handleEmailSearch($email); |
| | | } |
| | | |
| | | /** |
| | | * Searches for existing user from sanitized email |
| | | * @param string $email |
| | | * @return string|false The found service's User ID or false on failure |
| | | */ |
| | | abstract public function handleEmailSearch(string $email):string|false; |
| | | |
| | | public function createServiceUser(int $userID, array $fields):string|false |
| | | { |
| | | $checked = $this->validateUserFields($userID, $fields); |
| | | $response = $this->handleCreateUser($checked); |
| | | if ($response['success']) { |
| | | $meta = Meta::forUser($userID); |
| | | $meta->set("{$this->service_name}_item_id", $response['result']['id']); |
| | | } |
| | | return $response['success'] ? $response['customer']??false : false; |
| | | } |
| | | |
| | | protected function validateUserFields(int $userID, array $fields):array|false |
| | | { |
| | | foreach ($fields as $f => $v) { |
| | | $v = match ($f) { |
| | | 'email' => filter_var($v, FILTER_SANITIZE_EMAIL), |
| | | 'phone' => Sanitizer::sanitizePhone($v), |
| | | default => sanitize_text_field($v), |
| | | }; |
| | | if (empty($v) && in_array($f, $this->requiredUserFields())) { |
| | | return false; |
| | | } |
| | | $fields[$f] = $v; |
| | | } |
| | | return $fields; |
| | | } |
| | | protected function requiredUserFields():array |
| | | { |
| | | return []; |
| | | } |
| | | |
| | | /** |
| | | * @param array $data User Data |
| | | * @return array The created user ID or false on failure |
| | | */ |
| | | abstract protected function handleCreateUser(array $data):array; |
| | | |
| | | /** |
| | | * @param int $userID |
| | | * @return void |
| | | */ |
| | | protected function handleTheSaveUser(int $userID):void |
| | | { |
| | | error_log('==== ['.$this->title.']::handleTheSaveUser ===='); |
| | | //TODO: This is likely only for stuff like customers. |
| | | //If we have multiple stores connected, we may have to queue operations to update every connection's customer account if they made an order with that store |
| | | $role = jvbUserRole($userID); |
| | | $registrar = Registrar::getInstance($role); |
| | | if (!$registrar || !$registrar->hasIntegration($this->service_name)) { |
| | | return; |
| | | } |
| | | |
| | | $this->queueOperation(self::$syncTo, [ |
| | | 'users' => [$userID], |
| | | ]); |
| | | } |
| | | |
| | | public function addDeleteUser():void |
| | | { |
| | | if (!has_action('delete_user', [$this, 'handleDeleteTerm'])) { |
| | | add_action('delete_user', [$this, 'handleDeleteTerm']); |
| | | } |
| | | } |
| | | public function removeDeleteUser():void |
| | | { |
| | | remove_action('delete_user', [$this, 'handleDeleteTerm']); |
| | | } |
| | | public function handleDeleteUser(int $userID):void |
| | | { |
| | | if (!$this->canSync['delete']) { |
| | | return; |
| | | } |
| | | |
| | | $fields = $this->getSyncFields($userID, 'user'); |
| | | if (empty($fields["_{$this->service_name}_item_id"])) { |
| | | return; |
| | | } |
| | | JVB()->queue()->add( |
| | | self::$deleteFrom, |
| | | 0, |
| | | [ |
| | | 'fields' => [$userID => $fields], |
| | | 'service' => $this->service_name, |
| | | 'type' => 'user' |
| | | ] |
| | | ); |
| | | } |
| | | } |
| | |
| | | } |
| | | |
| | | trait UserConnection { |
| | | use _Base; |
| | | protected function determineUser():void |
| | | { |
| | | if (current_user_can('manage_options')) { |
| | |
| | | $this->initializeRateLimiters(); |
| | | return true; |
| | | } |
| | | protected function determineUserID(int $userID):bool|int |
| | | { |
| | | if (!$this->userCanConnect($userID)) { |
| | | return false; |
| | | } |
| | | return $userID; |
| | | } |
| | | protected function clearUser():void |
| | | { |
| | | $this->userID = false; |
| | |
| | | */ |
| | | protected string $apiVersion; |
| | | protected Cache $cache; |
| | | protected ?string $cacheName = null; |
| | | //Use Trait "Requests" to set this |
| | | protected bool $hasRequests = false; |
| | | protected ?RateLimits $requestLimiter = null; |
| | |
| | | * Usually used for default field values for syncable services |
| | | */ |
| | | public bool $hasExtraOptions = false; |
| | | public bool $hasBatchCreate = false; |
| | | public bool $hasBatchUpdate = false; |
| | | public bool $hasBatchDelete = false; |
| | | public bool $canCreateOnUpdate = false; |
| | | /** |
| | | * Post Syncing Capabilities |
| | | * Define what sync operations this integration supports |
| | | */ |
| | | protected array $canSync = [ |
| | | 'initial' => false, // Can share new posts to the service |
| | | 'update' => false, // Can update already-shared posts |
| | | 'delete' => false, // Can remove posts from the service |
| | | ]; |
| | | |
| | | |
| | | /** |
| | | * @var array // Post types that can be synced (e.g., ['artwork', 'tattoo']): usually built by Registrar.php if the integration name exists as a key in [ 'integrations' => []] |
| | | */ |
| | | protected array $syncPostTypes = []; |
| | | /** |
| | | * @var array Taxonomies that can be synced (e.g., ['category', 'section']): usually built by Registrar.php if the integration name exists as a key in [ 'integrations' => []] |
| | | */ |
| | | protected array $syncTaxonomies = []; |
| | | /** |
| | | * @var array User roles that can be synced (e.g., ['customer']): usually built by Registrar.php if the integration name exists as a key in [ 'integrations' => []] |
| | | */ |
| | | protected array $syncUsers = []; |
| | | /** |
| | | * @var array Integration's specific content types. Set by child classes' setContentTypes |
| | | */ |
| | | protected array $contentTypes = []; |
| | | |
| | | |
| | | /** |
| | |
| | | } |
| | | return jvbDashIcon($icon); |
| | | } |
| | | |
| | | protected function initializeRateLimiters():void |
| | | { |
| | | $key = $this->service_name; |
| | | if (!is_null($this->userID)) { |
| | | $key .= '_'.$this->userID; |
| | | } |
| | | |
| | | if ($this->hasRequests && !isset($this->requestLimiter) && !is_null($this->oauthLimiter)) { |
| | | $this->requestLimiter = new RateLimits($key); |
| | | } |
| | | if ($this->hasOAuth && !isset($this->oauthLimiter) && !is_null($this->oauthLimiter)) { |
| | | $key .= '_oauth'; |
| | | $this->oauthLimiter = new RateLimits($key, ['s' => 1,'m' => 10, 'h' => 100]); |
| | | } |
| | | } |
| | | |
| | | public function response(bool $success, string $message, array $data = []):array |
| | | { |
| | | $response = [ |
| | | 'success' => $success, |
| | | 'message' => $message |
| | | ]; |
| | | if (!empty($data)) { |
| | | $response['data'] = $data; |
| | | } |
| | | return $response; |
| | | } |
| | | } |
| | |
| | | <?php |
| | | use JVBase\base\Site; |
| | | require(JVB_DIR . '/inc/integrations/_Base.php'); |
| | | require(JVB_DIR . '/inc/integrations/Admin.php'); |
| | | require(JVB_DIR . '/inc/integrations/AdminActionResult.php'); |
| | | require(JVB_DIR . '/inc/integrations/AdminActions.php'); |
| | | require(JVB_DIR . '/inc/integrations/Ajax.php'); |
| | | require(JVB_DIR . '/inc/integrations/Auth.php'); |
| | | require(JVB_DIR . '/inc/integrations/Field.php'); |
| | | require(JVB_DIR . '/inc/integrations/OAuth.php'); |
| | | require(JVB_DIR . '/inc/integrations/OAuthCredentials.php'); |
| | | require(JVB_DIR . '/inc/integrations/OAuthURLs.php'); |
| | | require(JVB_DIR . '/inc/integrations/RateLimits.php'); |
| | | require(JVB_DIR . '/inc/integrations/Requests.php'); |
| | | require(JVB_DIR . '/inc/integrations/SyncDefaults.php'); |
| | | require(JVB_DIR . '/inc/integrations/SyncFrom.php'); |
| | | require(JVB_DIR . '/inc/integrations/SyncHelpers.php'); |
| | | require(JVB_DIR . '/inc/integrations/SyncTo.php'); |
| | | require(JVB_DIR . '/inc/integrations/UserConnection.php'); |
| | | require(JVB_DIR . '/inc/integrations/Webhooks.php'); |
| | | require(JVB_DIR . '/inc/integrations/Integrations.php'); |
| | | |
| | | if (Site::hasIntegration('maps')) { |
| New file |
| | |
| | | <?php |
| | | namespace JVBase\integrations\services; |
| | | use Exception; |
| | | use JVBase\integrations\Integrations; |
| | | if (!defined('ABSPATH')) { |
| | | exit; |
| | | } |
| | | |
| | | class BlueSkyOld extends Integrations |
| | | { |
| | | protected string $api_base = 'https://bsky.social/xrpc/'; |
| | | protected int $max_description_length = 300; |
| | | protected ?string $access_token = null; |
| | | protected ?string $did = null; |
| | | |
| | | protected static array $instances = []; |
| | | public static function getInstance(?int $userID = null):self |
| | | { |
| | | $key = is_null($userID) ? 'base' : $userID; |
| | | if (!array_key_exists($key, self::$instances)) { |
| | | self::$instances[$key] = new self($userID); |
| | | } |
| | | return self::$instances[$key]; |
| | | } |
| | | |
| | | protected function __construct(?int $user_id = null) { |
| | | $this->service_name = 'bluesky'; |
| | | $this->title = 'BlueSky'; |
| | | $this->icon = 'fediverse-logo'; |
| | | $this->canSync = [ |
| | | 'initial' => true, |
| | | 'schedule' => true, |
| | | ]; |
| | | |
| | | $this->fields = [ |
| | | 'handle' => [ |
| | | 'type' => 'text', |
| | | 'placeholder'=> 'your-handle.bsky.social', |
| | | 'required' => true, |
| | | 'label' => 'Handle', |
| | | ], |
| | | 'password' => [ |
| | | 'type' => 'text', |
| | | 'subtype'=>'password', |
| | | 'label' => 'Application Password', |
| | | 'required' => true, |
| | | 'hint' => '<strong>Security:</strong> Generate an App Password in your BlueSky settings for better security.' |
| | | ] |
| | | ]; |
| | | parent::__construct($user_id); |
| | | } |
| | | protected function initialize(): void |
| | | { |
| | | } |
| | | |
| | | |
| | | /** |
| | | * Setup BlueSky client with credentials |
| | | */ |
| | | protected function setupClient(?int $user_id = null): bool |
| | | { |
| | | $creds = $this->credentials; |
| | | |
| | | if (empty($creds['identifier']) || empty($creds['password'])) { |
| | | return false; |
| | | } |
| | | |
| | | try { |
| | | $session = $this->createSession($creds['identifier'], $creds['password']); |
| | | |
| | | if ($session) { |
| | | $this->access_token = $session['accessJwt']; |
| | | $this->did = $session['did']; |
| | | return true; |
| | | } |
| | | } catch (Exception $e) { |
| | | $this->handleError($e); |
| | | } |
| | | |
| | | return false; |
| | | } |
| | | |
| | | /** |
| | | * Create BlueSky session |
| | | */ |
| | | private function createSession(string $identifier, string $password): ?array |
| | | { |
| | | $response = wp_remote_post($this->api_base . 'com.atproto.server.createSession', [ |
| | | 'headers' => [ |
| | | 'Content-Type' => 'application/json', |
| | | ], |
| | | 'body' => json_encode([ |
| | | 'identifier' => $identifier, |
| | | 'password' => $password |
| | | ]), |
| | | 'timeout' => 30 |
| | | ]); |
| | | |
| | | if (is_wp_error($response)) { |
| | | throw new Exception('Failed to connect to BlueSky: ' . $response->get_error_message()); |
| | | } |
| | | |
| | | $body = json_decode(wp_remote_retrieve_body($response), true); |
| | | $status_code = wp_remote_retrieve_response_code($response); |
| | | |
| | | if ($status_code !== 200) { |
| | | throw new Exception('BlueSky authentication failed: ' . ($body['message'] ?? 'Unknown error')); |
| | | } |
| | | |
| | | return $body; |
| | | } |
| | | |
| | | /** |
| | | * Publish a post to BlueSky |
| | | */ |
| | | public function publishPost(array $content, ?int $user_id = null): array |
| | | { |
| | | try { |
| | | // Validate content |
| | | $errors = $this->validateContent($content); |
| | | if (!empty($errors)) { |
| | | return [ |
| | | 'success' => false, |
| | | 'error' => implode(', ', $errors) |
| | | ]; |
| | | } |
| | | |
| | | // Setup client for this request |
| | | if (!$this->setupClient($user_id)) { |
| | | return [ |
| | | 'success' => false, |
| | | 'error' => 'Failed to authenticate with BlueSky' |
| | | ]; |
| | | } |
| | | |
| | | // Check rate limiting |
| | | if (!$this->checkRateLimit()) { |
| | | return [ |
| | | 'success' => false, |
| | | 'error' => 'Rate limit exceeded' |
| | | ]; |
| | | } |
| | | |
| | | // Prepare post data |
| | | $post_data = [ |
| | | 'repo' => $this->did, |
| | | 'collection' => 'app.bsky.feed.post', |
| | | 'record' => [ |
| | | 'text' => $this->prepareDescription($content['description'] ?? ''), |
| | | 'createdAt' => date('c'), |
| | | '$type' => 'app.bsky.feed.post' |
| | | ] |
| | | ]; |
| | | |
| | | // Add link if post URL provided |
| | | if (!empty($content['post_url'])) { |
| | | $post_data['record']['text'] .= "\n\n" . $content['post_url']; |
| | | } |
| | | |
| | | // Upload and attach image if provided |
| | | if (!empty($content['image_path']) && file_exists($content['image_path'])) { |
| | | $image_result = $this->uploadImage($content['image_path']); |
| | | |
| | | if ($image_result['success']) { |
| | | $post_data['record']['embed'] = [ |
| | | '$type' => 'app.bsky.embed.images', |
| | | 'images' => [ |
| | | [ |
| | | 'alt' => $content['title'] ?? 'Image', |
| | | 'image' => $image_result['blob'] |
| | | ] |
| | | ] |
| | | ]; |
| | | } |
| | | } |
| | | |
| | | // Post to BlueSky |
| | | $response = $this->makeApiRequest('com.atproto.repo.createRecord', $post_data); |
| | | |
| | | if ($response['success']) { |
| | | $this->log("Successfully posted to BlueSky", 'info'); |
| | | |
| | | return [ |
| | | 'success' => true, |
| | | 'platform_id' => $response['data']['uri'] ?? null, |
| | | 'post_url' => $this->getPostUrl($response['data']['uri'] ?? ''), |
| | | 'data' => $response['data'] |
| | | ]; |
| | | } |
| | | |
| | | return [ |
| | | 'success' => false, |
| | | 'error' => $response['error'] ?? 'Unknown error' |
| | | ]; |
| | | |
| | | } catch (Exception $e) { |
| | | $this->handleError($e); |
| | | return [ |
| | | 'success' => false, |
| | | 'error' => $e->getMessage() |
| | | ]; |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Validate content before posting |
| | | */ |
| | | protected function validateContent(array $content): array |
| | | { |
| | | $errors = []; |
| | | |
| | | // Check description length |
| | | if (isset($content['description']) && strlen($content['description']) > $this->max_description_length) { |
| | | $errors[] = "Description exceeds {$this->max_description_length} characters"; |
| | | } |
| | | |
| | | // Check image if provided |
| | | if (isset($content['image_path']) && $content['image_path']) { |
| | | if (!file_exists($content['image_path'])) { |
| | | $errors[] = "Image file does not exist"; |
| | | } else { |
| | | $mime_type = mime_content_type($content['image_path']); |
| | | $supported_types = ['image/jpeg', 'image/png', 'image/gif']; |
| | | |
| | | if (!in_array($mime_type, $supported_types)) { |
| | | $errors[] = "Unsupported image type: {$mime_type}"; |
| | | } |
| | | |
| | | $file_size = filesize($content['image_path']); |
| | | $max_size = 5242880; // 5MB |
| | | if ($file_size > $max_size) { |
| | | $errors[] = "Image too large: " . round($file_size / 1048576, 2) . "MB (max: 5MB)"; |
| | | } |
| | | } |
| | | } |
| | | |
| | | return $errors; |
| | | } |
| | | |
| | | /** |
| | | * Prepare description with length limits |
| | | */ |
| | | protected function prepareDescription(string $description): string |
| | | { |
| | | if (strlen($description) <= $this->max_description_length) { |
| | | return $description; |
| | | } |
| | | |
| | | // Truncate intelligently at word boundaries |
| | | $truncated = substr($description, 0, $this->max_description_length - 3); |
| | | $last_space = strrpos($truncated, ' '); |
| | | |
| | | if ($last_space !== false) { |
| | | $truncated = substr($truncated, 0, $last_space); |
| | | } |
| | | |
| | | return $truncated . '...'; |
| | | } |
| | | |
| | | /** |
| | | * Upload image to BlueSky |
| | | */ |
| | | protected function uploadImage(string $image_path): array |
| | | { |
| | | try { |
| | | $mime_type = mime_content_type($image_path); |
| | | $image_data = file_get_contents($image_path); |
| | | |
| | | $response = wp_remote_post($this->api_base . 'com.atproto.repo.uploadBlob', [ |
| | | 'headers' => [ |
| | | 'Authorization' => 'Bearer ' . $this->access_token, |
| | | 'Content-Type' => $mime_type, |
| | | ], |
| | | 'body' => $image_data, |
| | | 'timeout' => 60 |
| | | ]); |
| | | |
| | | if (is_wp_error($response)) { |
| | | throw new Exception('Failed to upload image: ' . $response->get_error_message()); |
| | | } |
| | | |
| | | $body = json_decode(wp_remote_retrieve_body($response), true); |
| | | $status_code = wp_remote_retrieve_response_code($response); |
| | | |
| | | if ($status_code === 200 && isset($body['blob'])) { |
| | | return [ |
| | | 'success' => true, |
| | | 'blob' => $body['blob'] |
| | | ]; |
| | | } |
| | | |
| | | return [ |
| | | 'success' => false, |
| | | 'error' => $body['message'] ?? 'Image upload failed' |
| | | ]; |
| | | |
| | | } catch (Exception $e) { |
| | | return [ |
| | | 'success' => false, |
| | | 'error' => $e->getMessage() |
| | | ]; |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Make API request to BlueSky |
| | | */ |
| | | private function makeApiRequest(string $endpoint, array $data): array |
| | | { |
| | | $response = wp_remote_post($this->api_base . $endpoint, [ |
| | | 'headers' => [ |
| | | 'Authorization' => 'Bearer ' . $this->access_token, |
| | | 'Content-Type' => 'application/json', |
| | | ], |
| | | 'body' => json_encode($data), |
| | | 'timeout' => 30 |
| | | ]); |
| | | |
| | | if (is_wp_error($response)) { |
| | | return [ |
| | | 'success' => false, |
| | | 'error' => $response->get_error_message() |
| | | ]; |
| | | } |
| | | |
| | | $body = json_decode(wp_remote_retrieve_body($response), true); |
| | | $status_code = wp_remote_retrieve_response_code($response); |
| | | |
| | | if ($status_code === 200) { |
| | | return [ |
| | | 'success' => true, |
| | | 'data' => $body |
| | | ]; |
| | | } |
| | | |
| | | return [ |
| | | 'success' => false, |
| | | 'error' => $body['message'] ?? "HTTP {$status_code} error" |
| | | ]; |
| | | } |
| | | |
| | | /** |
| | | * Get post URL from BlueSky URI |
| | | */ |
| | | private function getPostUrl(string $uri): string |
| | | { |
| | | if (preg_match('/at:\/\/([^\/]+)\/app\.bsky\.feed\.post\/(.+)/', $uri, $matches)) { |
| | | $did = $matches[1]; |
| | | $rkey = $matches[2]; |
| | | return "https://bsky.app/profile/{$did}/post/{$rkey}"; |
| | | } |
| | | |
| | | return ''; |
| | | } |
| | | |
| | | /** |
| | | * Disconnect user from this platform |
| | | */ |
| | | public function disconnectUser(int $user_id): bool |
| | | { |
| | | $key = "user_{$user_id}_{$this->service_name}"; |
| | | $credentials_manager = Auth::getInstance(); |
| | | return $credentials_manager->deleteCredentials($key); |
| | | } |
| | | |
| | | /** |
| | | * Get display name for this platform |
| | | */ |
| | | public function getDisplayName(): string |
| | | { |
| | | return 'BlueSky'; |
| | | } |
| | | |
| | | /** |
| | | * Get icon name for this platform |
| | | */ |
| | | public function getIconName(): string |
| | | { |
| | | return 'fediverse'; // Using existing fediverse icon |
| | | } |
| | | |
| | | /** |
| | | * Get supported post types |
| | | */ |
| | | public function getSupportedPostTypes(): array |
| | | { |
| | | return ['text', 'image', 'link']; |
| | | } |
| | | |
| | | |
| | | public function getServiceDescription(): string |
| | | { |
| | | return "Cross-post content to the BlueSky social network."; |
| | | } |
| | | |
| | | protected function getRequestHeaders(): array |
| | | { |
| | | // TODO: Implement getRequestHeaders() method. |
| | | return []; |
| | | } |
| | | |
| | | protected function getApiUrl(string $endpoint, ?string $baseUrl = null): string |
| | | { |
| | | return 'https://bsky.social/xrpc/'; |
| | | } |
| | | |
| | | protected function processIntegrationAction(string $action, array $data):\WP_Error|array |
| | | { |
| | | return []; |
| | | } |
| | | } |
| New file |
| | |
| | | <?php |
| | | |
| | | namespace JVBase\integrations\services; |
| | | use JVBase\integrations\Integrations; |
| | | if (!defined('ABSPATH')) { |
| | | exit; |
| | | } |
| | | |
| | | class CloudflareOld extends Integrations |
| | | { |
| | | private string $site_key; |
| | | private string $secret_key; |
| | | private string $theme = 'light'; |
| | | private string $size = 'normal'; |
| | | |
| | | protected static array $instances = []; |
| | | public static function getInstance(?int $userID = null):self |
| | | { |
| | | $key = is_null($userID) ? 'base' : $userID; |
| | | if (!array_key_exists($key, self::$instances)) { |
| | | self::$instances[$key] = new self($userID); |
| | | } |
| | | return self::$instances[$key]; |
| | | } |
| | | |
| | | protected function __construct() |
| | | { |
| | | $this->service_name = 'cloudflare'; |
| | | $this->title = 'Cloudflare Turnstile'; |
| | | $this->icon = 'cloud'; |
| | | $this->apiBase = 'https://challenges.cloudflare.com'; |
| | | $this->apiEndpoints = ['turnstile/v0/siteverify']; |
| | | |
| | | $this->fields = [ |
| | | 'site_key' => [ |
| | | 'label' => 'Site Key', |
| | | 'type' => 'text', |
| | | 'placeholder' => 'Enter Public key for Turnstile widget display.', |
| | | 'hint' => 'Public key for Turnstile widget', |
| | | 'required' => true |
| | | ], |
| | | 'secret_key' => [ |
| | | 'label' => 'Secret Key', |
| | | 'type' => 'text', |
| | | 'subtype' => 'password', |
| | | 'placeholder' => 'Enter Cloudflare Turnstile Secret Key', |
| | | 'required' => true, |
| | | 'hint' => 'Secret key for server-side verification' |
| | | ] |
| | | ]; |
| | | |
| | | $this->instructions = [ |
| | | 'Go to <a href="https://dash.cloudflare.com/?to=/:account/turnstile" target="_blank">Cloudflare Dashboard</a>', |
| | | 'Create a new Turnstile widget', |
| | | 'Add your domain to the widget settings', |
| | | 'Copy the Site Key and Secret Key' |
| | | ]; |
| | | |
| | | $this->advanced = []; |
| | | parent::__construct(); |
| | | } |
| | | |
| | | protected function initialize(): void |
| | | { |
| | | $this->site_key = $this->credentials['site_key'] ?? ''; |
| | | $this->secret_key = $this->credentials['secret_key'] ?? ''; |
| | | $this->theme = $this->credentials['theme'] ?? 'light'; |
| | | $this->size = $this->credentials['size'] ?? 'normal'; |
| | | |
| | | } |
| | | |
| | | /** |
| | | * Check if scripts should be enqueued |
| | | */ |
| | | public function shouldEnqueueScripts(): bool |
| | | { |
| | | return $this->isSetUp() && !is_admin(); |
| | | } |
| | | |
| | | /** |
| | | * Test connection by attempting a verification with an invalid token |
| | | */ |
| | | public function performConnectionTest(): bool |
| | | { |
| | | if (empty($this->site_key) || empty($this->secret_key)) { |
| | | return false; |
| | | } |
| | | |
| | | // Test with a dummy token to verify API connectivity |
| | | $response = wp_remote_post('https://challenges.cloudflare.com/turnstile/v0/siteverify', [ |
| | | 'body' => [ |
| | | 'secret' => $this->secret_key, |
| | | 'response' => 'test-token-connection-check' |
| | | ], |
| | | 'timeout' => 10 |
| | | ]); |
| | | |
| | | // We expect this to fail with success:false, but not with a connection error |
| | | if (is_wp_error($response)) { |
| | | $this->logError('Connection test failed: ', [ |
| | | 'message' => $response->get_error_message() |
| | | ]); |
| | | return false; |
| | | } |
| | | |
| | | $result = json_decode(wp_remote_retrieve_body($response), true); |
| | | |
| | | // If we get a response with success field (even if false), connection works |
| | | return array_key_exists('success', $result); |
| | | } |
| | | |
| | | protected function validateCredentials(array $credentials): bool |
| | | { |
| | | if (empty($credentials['site_key'])) { |
| | | $this->logError('Missing Site Key', [ |
| | | 'method' => 'validateCredentials' |
| | | ]); |
| | | return false; |
| | | } |
| | | |
| | | if (empty($credentials['secret_key'])) { |
| | | $this->logError('Missing Secret Key', [ |
| | | 'method' => 'validateCredentials' |
| | | ]); |
| | | return false; |
| | | } |
| | | |
| | | // Validate format |
| | | if (!preg_match('/^[0-9a-zA-Z._-]+$/', $credentials['site_key'])) { |
| | | |
| | | $this->logError('Invalid Site Key', [ |
| | | 'method' => 'validateCredentials' |
| | | ]); |
| | | return false; |
| | | } |
| | | |
| | | return true; |
| | | } |
| | | |
| | | /** |
| | | * Enqueue Turnstile script |
| | | */ |
| | | public function enqueueTurnstileScripts(): void |
| | | { |
| | | wp_enqueue_script( |
| | | 'cloudflare-turnstile', |
| | | 'https://challenges.cloudflare.com/turnstile/v0/api.js', |
| | | [], |
| | | null, |
| | | true |
| | | ); |
| | | wp_script_add_data('cloudflare-turnstile', 'async', true); |
| | | wp_script_add_data('cloudflare-turnstile', 'defer', true); |
| | | } |
| | | |
| | | /** |
| | | * Render Turnstile widget |
| | | * Can be called directly: jvbConnect('cloudflare')->renderTurnstile() |
| | | */ |
| | | public function renderTurnstile(array|string $options = []): void |
| | | { |
| | | $this->ensureInitialized(); |
| | | if (!$this->isSetUp()) { |
| | | return; |
| | | } |
| | | |
| | | if (!is_array($options)) { |
| | | $options = []; |
| | | } |
| | | |
| | | $defaults = [ |
| | | 'theme' => $this->theme, |
| | | 'size' => $this->size, |
| | | 'wrapper_class' => 'cf-turnstile-wrapper', |
| | | 'wrapper_style' => 'margin: 1em 0;' |
| | | ]; |
| | | |
| | | $options = wp_parse_args($options, $defaults); |
| | | |
| | | echo '<div class="' . esc_attr($options['wrapper_class']) . '" style="' . esc_attr($options['wrapper_style']) . '">'; |
| | | echo '<div class="cf-turnstile" '; |
| | | echo 'data-sitekey="' . esc_attr($this->site_key) . '" '; |
| | | echo 'data-theme="' . esc_attr($options['theme']) . '" '; |
| | | echo 'data-size="' . esc_attr($options['size']) . '">'; |
| | | echo '</div>'; |
| | | echo '</div>'; |
| | | } |
| | | |
| | | /** |
| | | * Verify Turnstile response |
| | | * Can be called directly: JVB()->connect('cloudflare')->verifyTurnstile($token) |
| | | */ |
| | | public function verifyTurnstile(?string $token = null, string $remote_ip = ''): bool |
| | | { |
| | | $this->ensureInitialized(); |
| | | if (!$this->isSetUp()) { |
| | | return false; |
| | | } |
| | | |
| | | // If no token provided, try to get from POST |
| | | if (!$token && isset($_POST['cf-turnstile-response'])) { |
| | | $token = sanitize_text_field($_POST['cf-turnstile-response']); |
| | | } |
| | | |
| | | if (empty($token)) { |
| | | return false; |
| | | } |
| | | |
| | | $data = [ |
| | | 'secret' => $this->secret_key, |
| | | 'response' => $token |
| | | ]; |
| | | |
| | | if (empty($remote_ip) && isset($_SERVER['REMOTE_ADDR'])) { |
| | | $remote_ip = $_SERVER['REMOTE_ADDR']; |
| | | } |
| | | |
| | | if (!empty($remote_ip)) { |
| | | $data['remoteip'] = $remote_ip; |
| | | } |
| | | |
| | | $response = wp_remote_post('https://challenges.cloudflare.com/turnstile/v0/siteverify', [ |
| | | 'body' => $data, |
| | | 'timeout' => 10 |
| | | ]); |
| | | |
| | | if (is_wp_error($response)) { |
| | | $this->logError('Turnstile verification failed: ' . $response->get_error_message()); |
| | | return false; |
| | | } |
| | | |
| | | $result = json_decode(wp_remote_retrieve_body($response), true); |
| | | |
| | | if (!($result['success'] ?? false)) { |
| | | $error_codes = implode(', ', $result['error-codes'] ?? ['Unknown error']); |
| | | $this->logError('Turnstile verification failed: ' . $error_codes); |
| | | } |
| | | |
| | | return $result['success'] ?? false; |
| | | } |
| | | |
| | | /** |
| | | * Get site key for frontend use |
| | | */ |
| | | public function getSiteKey(): string |
| | | { |
| | | return $this->site_key; |
| | | } |
| | | |
| | | /** |
| | | * Get service description |
| | | */ |
| | | public function getServiceDescription(): string |
| | | { |
| | | return "Add Turnstile captcha protection to your forms and login pages to prevent spam and bot submissions."; |
| | | } |
| | | |
| | | /** |
| | | * These methods are not used by Cloudflare but must be implemented |
| | | */ |
| | | protected function getRequestHeaders(): array |
| | | { |
| | | // Cloudflare Turnstile uses POST parameters, not headers |
| | | return []; |
| | | } |
| | | } |
| New file |
| | |
| | | <?php |
| | | /** |
| | | * Facebook Integration |
| | | * Handles Facebook posts, page information, events, and OAuth authentication |
| | | */ |
| | | |
| | | namespace JVBase\integrations\services; |
| | | use JVBase\integrations\Integrations; |
| | | use JVBase\meta\Meta; |
| | | use WP_Post; |
| | | use WP_Error; |
| | | |
| | | if (!defined('ABSPATH')) { |
| | | exit; |
| | | } |
| | | |
| | | class FacebookOld extends Integrations |
| | | { |
| | | // Facebook-specific properties |
| | | protected array $allowedContent = [ |
| | | 'post', |
| | | 'photo', |
| | | 'video', |
| | | 'event', |
| | | 'offer', |
| | | 'note', |
| | | 'milestone' |
| | | ]; |
| | | private string $page_id = ''; |
| | | private string $page_access_token = ''; |
| | | private array $permissions = []; |
| | | private array $pages = []; |
| | | |
| | | // Post type mapping for Facebook |
| | | private const FB_POST_TYPES = [ |
| | | 'post' => 'feed', |
| | | 'photo' => 'photos', |
| | | 'video' => 'videos', |
| | | 'event' => 'events', |
| | | 'offer' => 'offers', |
| | | 'note' => 'notes', |
| | | 'milestone' => 'milestones' |
| | | ]; |
| | | |
| | | protected static array $instances = []; |
| | | public static function getInstance(?int $userID = null):self |
| | | { |
| | | $key = is_null($userID) ? 'base' : $userID; |
| | | if (!array_key_exists($key, self::$instances)) { |
| | | self::$instances[$key] = new self($userID); |
| | | } |
| | | return self::$instances[$key]; |
| | | } |
| | | |
| | | protected function __construct(?int $userID = null) |
| | | { |
| | | $this->service_name = 'facebook'; |
| | | $this->title = 'Facebook'; |
| | | $this->icon = 'facebook-logo'; |
| | | |
| | | // API Configuration |
| | | $this->apiBase = [ |
| | | 'graph' => 'https://graph.facebook.com/v18.0', |
| | | 'auth' => 'https://www.facebook.com/v18.0' |
| | | ]; |
| | | |
| | | $this->apiVersion = 'v18.0'; |
| | | |
| | | $this->isOAuthService = true; |
| | | // OAuth Configuration |
| | | $this->oauth = [ |
| | | 'authorize' => 'https://www.facebook.com/v18.0/dialog/oauth', |
| | | 'token' => 'https://graph.facebook.com/v18.0/oauth/access_token', |
| | | 'revoke' => 'https://graph.facebook.com/v18.0/me/permissions', |
| | | 'scopes' => [ |
| | | 'pages_show_list', |
| | | 'pages_read_engagement', |
| | | 'pages_manage_posts', |
| | | 'pages_manage_metadata', |
| | | 'pages_manage_engagement', |
| | | 'pages_read_user_content', |
| | | 'business_management', |
| | | 'instagram_basic', |
| | | 'instagram_content_publish' |
| | | ] |
| | | ]; |
| | | |
| | | // Sync capabilities |
| | | $this->canSync = [ |
| | | 'initial' => true, |
| | | 'update' => true, |
| | | 'delete' => true |
| | | ]; |
| | | |
| | | // Rate limits for Facebook Graph API |
| | | $this->rate_limits = [ |
| | | 'per_second' => 10, |
| | | 'per_minute' => 600, |
| | | 'per_hour' => 200000 |
| | | ]; |
| | | |
| | | $this->fields = [ |
| | | 'client_id' => [ |
| | | 'type' => 'text', |
| | | 'placeholder' => 'Enter Facebook App ID', |
| | | 'hint' => 'Your Facebook App ID from developers.facebook.com', |
| | | 'label' => 'App ID', |
| | | 'required' => true, |
| | | ], |
| | | 'client_secret' => [ |
| | | 'type' => 'text', |
| | | 'subtype'=> 'password', |
| | | 'label' => 'App Secret', |
| | | 'required' => true, |
| | | 'hint' => 'Your Facebook App Secret (keep this secure!)' |
| | | ], |
| | | ]; |
| | | |
| | | $this->advanced = [ |
| | | |
| | | ]; |
| | | |
| | | $this->instructions = [ |
| | | |
| | | ]; |
| | | |
| | | $this->defaults = [ |
| | | |
| | | ]; |
| | | $this->hasWebhooks = true; |
| | | |
| | | parent::__construct(); |
| | | |
| | | $this->actions = array_merge($this->actions, [ |
| | | 'sync_pages' => 'getUserPages' |
| | | ]); |
| | | } |
| | | |
| | | /** |
| | | * Initialize Facebook-specific properties |
| | | */ |
| | | protected function initialize(): void |
| | | { |
| | | $this->page_id = $this->credentials['page_id'] ?? ''; |
| | | $this->page_access_token = $this->credentials['page_access_token'] ?? ''; |
| | | $this->permissions = $this->credentials['permissions'] ?? []; |
| | | |
| | | // Build dynamic endpoints based on page |
| | | if ($this->page_id) { |
| | | $this->apiEndpoints = [ |
| | | "/{$this->page_id}/feed", |
| | | "/{$this->page_id}/photos", |
| | | "/{$this->page_id}/videos", |
| | | "/{$this->page_id}/events", |
| | | "/{$this->page_id}/insights", |
| | | "/{$this->page_id}/conversations", |
| | | "/{$this->page_id}", |
| | | "/me/accounts" |
| | | ]; |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Get service description |
| | | */ |
| | | public function getServiceDescription(): string |
| | | { |
| | | return "Connect your Facebook Page to share posts, events, and engage with your audience."; |
| | | } |
| | | |
| | | /** |
| | | * Get request headers for Facebook API |
| | | */ |
| | | protected function getRequestHeaders(): array |
| | | { |
| | | $headers = [ |
| | | 'Content-Type' => 'application/json', |
| | | 'Accept' => 'application/json' |
| | | ]; |
| | | |
| | | // Use page access token if available, otherwise user token |
| | | if (!empty($this->page_access_token)) { |
| | | // Token is sent in URL params for Facebook, not headers |
| | | } |
| | | |
| | | return $headers; |
| | | } |
| | | |
| | | /** |
| | | * Add Facebook-specific OAuth parameters |
| | | */ |
| | | protected function addOAuthParams(array $params): array |
| | | { |
| | | $params['display'] = 'popup'; |
| | | $params['auth_type'] = 'rerequest'; |
| | | $params['return_scopes'] = 'true'; |
| | | return $params; |
| | | } |
| | | |
| | | /** |
| | | * Add Facebook-specific credential data after OAuth |
| | | */ |
| | | protected function addCredentialData(array $credentials, array $tokens): array |
| | | { |
| | | // Get user's pages after OAuth |
| | | try { |
| | | $pages = $this->getUserPages($tokens['access_token']); |
| | | if (!empty($pages)) { |
| | | $credentials['pages'] = $pages; |
| | | // Auto-select first page if not already selected |
| | | if (empty($credentials['page_id']) && !empty($pages[0])) { |
| | | $credentials['page_id'] = $pages[0]['id']; |
| | | $credentials['page_access_token'] = $pages[0]['access_token']; |
| | | $credentials['page_name'] = $pages[0]['name']; |
| | | } |
| | | } |
| | | |
| | | // Store permissions granted |
| | | $permissions = $this->getGrantedPermissions($tokens['access_token']); |
| | | $credentials['permissions'] = $permissions; |
| | | |
| | | } catch (Exception $e) { |
| | | $this->logError('Failed to fetch Facebook pages', ['error' => $e->getMessage()]); |
| | | } |
| | | |
| | | return $credentials; |
| | | } |
| | | |
| | | /** |
| | | * Test Facebook connection |
| | | */ |
| | | protected function performConnectionTest(): bool |
| | | { |
| | | if (empty($this->credentials['access_token'])) { |
| | | return false; |
| | | } |
| | | |
| | | try { |
| | | // Test by fetching user info |
| | | $response = wp_remote_get( |
| | | $this->apiBase['graph'] . '/me?access_token=' . $this->credentials['access_token'] |
| | | ); |
| | | |
| | | if (is_wp_error($response)) { |
| | | return false; |
| | | } |
| | | |
| | | $code = wp_remote_retrieve_response_code($response); |
| | | return $code === 200; |
| | | |
| | | } catch (Exception $e) { |
| | | $this->logError('Connection test failed', ['error' => $e->getMessage()]); |
| | | return false; |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Render Additional OAuth settings |
| | | */ |
| | | protected function renderOAuthConnectedOptions(): void |
| | | { |
| | | // Page selection dropdown |
| | | if (!empty($this->credentials['pages'])) { |
| | | ?> |
| | | <div class="form-field"> |
| | | <label for="fb_page">Active Facebook Page</label> |
| | | <select name="credentials[page_id]" id="fb_page"> |
| | | <option value="">Select a page...</option> |
| | | <?php foreach ($this->credentials['pages'] as $page): ?> |
| | | <option value="<?php echo esc_attr($page['id']); ?>" |
| | | <?php selected($this->credentials['page_id'] ?? '', $page['id']); ?>> |
| | | <?php echo esc_html($page['name']); ?> |
| | | </option> |
| | | <?php endforeach; ?> |
| | | </select> |
| | | </div> |
| | | <?php |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Validate webhook from Facebook |
| | | */ |
| | | protected function validateWebhook(array $payload): bool |
| | | { |
| | | // Handle webhook verification challenge |
| | | if (isset($payload['hub_mode']) && $payload['hub_mode'] === 'subscribe') { |
| | | $verify_token = $this->credentials['webhook_verify_token'] ?? ''; |
| | | if ($payload['hub_verify_token'] === $verify_token) { |
| | | // Echo the challenge for verification |
| | | echo $payload['hub_challenge']; |
| | | return true; |
| | | } |
| | | return false; |
| | | } |
| | | |
| | | // Validate webhook signature for actual events |
| | | $headers = $payload['_headers'] ?? []; |
| | | $signature = $headers['x-hub-signature-256'] ?? ''; |
| | | |
| | | if (empty($signature)) { |
| | | $this->logError('Missing webhook signature'); |
| | | return false; |
| | | } |
| | | |
| | | // Calculate expected signature |
| | | $app_secret = $this->credentials['client_secret'] ?? ''; |
| | | $expected = 'sha256=' . hash_hmac('sha256', json_encode($payload), $app_secret); |
| | | |
| | | return hash_equals($expected, $signature); |
| | | } |
| | | |
| | | /** |
| | | * Process webhook from Facebook |
| | | */ |
| | | protected function processWebhook(array $payload): bool |
| | | { |
| | | try { |
| | | $object = $payload['object'] ?? ''; |
| | | $entry = $payload['entry'] ?? []; |
| | | |
| | | foreach ($entry as $item) { |
| | | $changes = $item['changes'] ?? []; |
| | | |
| | | foreach ($changes as $change) { |
| | | $field = $change['field'] ?? ''; |
| | | $value = $change['value'] ?? []; |
| | | |
| | | switch ($field) { |
| | | case 'feed': |
| | | $this->handleFeedUpdate($value); |
| | | break; |
| | | |
| | | case 'conversations': |
| | | $this->handleMessageUpdate($value); |
| | | break; |
| | | |
| | | case 'photos': |
| | | $this->handlePhotoUpdate($value); |
| | | break; |
| | | |
| | | default: |
| | | $this->logDebug('Unhandled webhook field', ['field' => $field]); |
| | | } |
| | | } |
| | | } |
| | | |
| | | return true; |
| | | |
| | | } catch (Exception $e) { |
| | | $this->logError('Webhook processing failed', [ |
| | | 'error' => $e->getMessage(), |
| | | 'payload' => $payload |
| | | ]); |
| | | return false; |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Handle post save for Facebook sync |
| | | */ |
| | | protected function handleTheSavePost(int $postID, WP_Post $post, bool $update, array $settings): void |
| | | { |
| | | |
| | | if (!$this->hasOAuthCredentials()) { |
| | | error_log('OAuth Not set up for '.$this->service_name); |
| | | return; |
| | | } |
| | | $this->queueOperation(self::$syncTo, [ |
| | | 'items' => [$postID], |
| | | 'user' => user_can($post->post_author, 'manage_options') ? null : $post->post_author |
| | | ], [ |
| | | 'delay' => 60 |
| | | ]); |
| | | Meta::forPost($postID)->set('_'.$this->service_name.'_sync_status', 'queued'); |
| | | } |
| | | |
| | | /** |
| | | * Process queued operations |
| | | */ |
| | | public function processOperation(WP_Error|array $result, object $operation, array $data): WP_Error|array |
| | | { |
| | | try { |
| | | $fb = (array_key_exists('user', $data) && $data['user'] !== 0) ? $this->getInstance((int)$data['user']) : $this; |
| | | switch ($operation->type) { |
| | | case 'facebook_create_post': |
| | | return $fb->createFacebookPost($data); |
| | | |
| | | case 'facebook_update_post': |
| | | return $fb->updateFacebookPost($data); |
| | | |
| | | case 'facebook_delete_post': |
| | | return $fb->deleteFacebookPost($data); |
| | | |
| | | case 'facebook_create_event': |
| | | return $fb->createFacebookEvent($data); |
| | | |
| | | default: |
| | | return $result; |
| | | } |
| | | } catch (Exception $e) { |
| | | $this->logError('Operation failed', [ |
| | | 'type' => $operation->type, |
| | | 'error' => $e->getMessage() |
| | | ]); |
| | | return new WP_Error('operation_failed', $e->getMessage()); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Create a Facebook post |
| | | */ |
| | | private function createFacebookPost(array $data): array |
| | | { |
| | | $post = get_post($data['post_id']); |
| | | if (!$post) { |
| | | return ['success' => false, 'error' => 'Post not found']; |
| | | } |
| | | |
| | | // Build Facebook post data |
| | | $fb_data = [ |
| | | 'message' => $this->formatPostContent($post), |
| | | 'access_token' => $this->page_access_token |
| | | ]; |
| | | |
| | | // Add link if present |
| | | $link = get_post_meta($post->ID, 'link_url', true); |
| | | if ($link) { |
| | | $fb_data['link'] = $link; |
| | | } |
| | | |
| | | // Handle featured image |
| | | if (has_post_thumbnail($post->ID)) { |
| | | $image_url = get_the_post_thumbnail_url($post->ID, 'large'); |
| | | $fb_data['picture'] = $image_url; |
| | | } |
| | | |
| | | // Determine endpoint |
| | | $endpoint = $data['endpoint'] ?? 'feed'; |
| | | $url = $this->apiBase['graph'] . '/' . $this->page_id . '/' . $endpoint; |
| | | |
| | | // Make the API request |
| | | $response = wp_remote_post($url, [ |
| | | 'body' => $fb_data, |
| | | 'timeout' => 30 |
| | | ]); |
| | | |
| | | if (is_wp_error($response)) { |
| | | return ['success' => false, 'error' => $response->get_error_message()]; |
| | | } |
| | | |
| | | $body = json_decode(wp_remote_retrieve_body($response), true); |
| | | |
| | | if (isset($body['id'])) { |
| | | // Store Facebook post ID |
| | | update_post_meta($post->ID, BASE . '_facebook_post_id', $body['id']); |
| | | update_post_meta($post->ID, BASE . '_facebook_sync_time', time()); |
| | | |
| | | return [ |
| | | 'success' => true, |
| | | 'facebook_id' => $body['id'], |
| | | 'message' => 'Posted to Facebook successfully' |
| | | ]; |
| | | } |
| | | |
| | | return ['success' => false, 'error' => $body['error']['message'] ?? 'Unknown error']; |
| | | } |
| | | |
| | | /** |
| | | * Update a Facebook post |
| | | */ |
| | | private function updateFacebookPost(array $data): array |
| | | { |
| | | $post = get_post($data['post_id']); |
| | | $fb_post_id = get_post_meta($post->ID, BASE . '_facebook_post_id', true); |
| | | |
| | | if (!$fb_post_id) { |
| | | // No existing post, create new one |
| | | return $this->createFacebookPost($data); |
| | | } |
| | | |
| | | // Facebook doesn't allow editing most post types after creation |
| | | // We can only update certain fields like message |
| | | $fb_data = [ |
| | | 'message' => $this->formatPostContent($post), |
| | | 'access_token' => $this->page_access_token |
| | | ]; |
| | | |
| | | $url = $this->apiBase['graph'] . '/' . $fb_post_id; |
| | | |
| | | $response = wp_remote_request($url, [ |
| | | 'method' => 'POST', |
| | | 'body' => $fb_data |
| | | ]); |
| | | |
| | | if (is_wp_error($response)) { |
| | | return ['success' => false, 'error' => $response->get_error_message()]; |
| | | } |
| | | |
| | | update_post_meta($post->ID, BASE . '_facebook_sync_time', time()); |
| | | |
| | | return ['success' => true, 'message' => 'Updated on Facebook']; |
| | | } |
| | | |
| | | /** |
| | | * Delete a Facebook post |
| | | */ |
| | | private function deleteFacebookPost(array $data): array |
| | | { |
| | | $fb_post_id = get_post_meta($data['post_id'], BASE . '_facebook_post_id', true); |
| | | |
| | | if (!$fb_post_id) { |
| | | return ['success' => true, 'message' => 'No Facebook post to delete']; |
| | | } |
| | | |
| | | $url = $this->apiBase['graph'] . '/' . $fb_post_id . '?access_token=' . $this->page_access_token; |
| | | |
| | | $response = wp_remote_request($url, ['method' => 'DELETE']); |
| | | |
| | | if (!is_wp_error($response)) { |
| | | delete_post_meta($data['post_id'], BASE . '_facebook_post_id'); |
| | | delete_post_meta($data['post_id'], BASE . '_facebook_sync_time'); |
| | | } |
| | | |
| | | return ['success' => true, 'message' => 'Deleted from Facebook']; |
| | | } |
| | | |
| | | /** |
| | | * Create a Facebook event |
| | | */ |
| | | private function createFacebookEvent(array $data): array |
| | | { |
| | | $post = get_post($data['post_id']); |
| | | $meta = Meta::forPost($post->ID); |
| | | |
| | | $event_data = [ |
| | | 'name' => $post->post_title, |
| | | 'description' => $this->formatPostContent($post), |
| | | 'start_time' => $meta->get('event_start_date'), |
| | | 'end_time' => $meta->get('event_end_date'), |
| | | 'access_token' => $this->page_access_token |
| | | ]; |
| | | |
| | | // Add location if available |
| | | $location = $meta->get('event_location'); |
| | | if ($location) { |
| | | $event_data['location'] = $location; |
| | | } |
| | | |
| | | $url = $this->apiBase['graph'] . '/' . $this->page_id . '/events'; |
| | | |
| | | $response = wp_remote_post($url, [ |
| | | 'body' => $event_data |
| | | ]); |
| | | |
| | | if (is_wp_error($response)) { |
| | | return ['success' => false, 'error' => $response->get_error_message()]; |
| | | } |
| | | |
| | | $body = json_decode(wp_remote_retrieve_body($response), true); |
| | | |
| | | if (isset($body['id'])) { |
| | | update_post_meta($post->ID, BASE . '_facebook_event_id', $body['id']); |
| | | return ['success' => true, 'event_id' => $body['id']]; |
| | | } |
| | | |
| | | return ['success' => false, 'error' => $body['error']['message'] ?? 'Unknown error']; |
| | | } |
| | | |
| | | /** |
| | | * Helper: Get user's Facebook pages |
| | | */ |
| | | private function getUserPages(?string $access_token = null): array |
| | | { |
| | | $token = $access_token ?: $this->credentials['access_token']; |
| | | |
| | | $url = $this->apiBase['graph'] . '/me/accounts?access_token=' . $token; |
| | | |
| | | $response = wp_remote_get($url); |
| | | |
| | | if (is_wp_error($response)) { |
| | | throw new Exception($response->get_error_message()); |
| | | } |
| | | |
| | | $body = json_decode(wp_remote_retrieve_body($response), true); |
| | | |
| | | if (isset($body['error'])) { |
| | | throw new Exception($body['error']['message']); |
| | | } |
| | | |
| | | return $body['data'] ?? []; |
| | | } |
| | | |
| | | /** |
| | | * Helper: Get granted permissions |
| | | */ |
| | | private function getGrantedPermissions(string $access_token): array |
| | | { |
| | | $url = $this->apiBase['graph'] . '/me/permissions?access_token=' . $access_token; |
| | | |
| | | $response = wp_remote_get($url); |
| | | |
| | | if (is_wp_error($response)) { |
| | | return []; |
| | | } |
| | | |
| | | $body = json_decode(wp_remote_retrieve_body($response), true); |
| | | $permissions = []; |
| | | |
| | | foreach (($body['data'] ?? []) as $permission) { |
| | | if ($permission['status'] === 'granted') { |
| | | $permissions[] = $permission['permission']; |
| | | } |
| | | } |
| | | |
| | | return $permissions; |
| | | } |
| | | |
| | | /** |
| | | * Helper: Format post content for Facebook |
| | | */ |
| | | private function formatPostContent(WP_Post $post): string |
| | | { |
| | | $content = $post->post_content; |
| | | |
| | | // Strip shortcodes |
| | | $content = strip_shortcodes($content); |
| | | |
| | | // Convert to plain text |
| | | $content = wp_strip_all_tags($content); |
| | | |
| | | // Add link to full post |
| | | $permalink = get_permalink($post->ID); |
| | | $content .= "\n\nRead more: " . $permalink; |
| | | |
| | | // Trim to Facebook's character limit (63,206 characters) |
| | | if (strlen($content) > 63000) { |
| | | $content = substr($content, 0, 63000) . '...'; |
| | | } |
| | | |
| | | return $content; |
| | | } |
| | | |
| | | /** |
| | | * Helper: Handle feed updates from webhook |
| | | */ |
| | | private function handleFeedUpdate(array $data): void |
| | | { |
| | | // Process incoming feed updates |
| | | // Could sync comments, reactions, etc. back to WordPress |
| | | $this->logDebug('Feed update received', $data); |
| | | } |
| | | |
| | | /** |
| | | * Helper: Handle message updates from webhook |
| | | */ |
| | | private function handleMessageUpdate(array $data): void |
| | | { |
| | | // Process incoming messages |
| | | // Could create notifications or queue responses |
| | | $this->logDebug('Message received', $data); |
| | | } |
| | | |
| | | /** |
| | | * Helper: Handle photo updates from webhook |
| | | */ |
| | | private function handlePhotoUpdate(array $data): void |
| | | { |
| | | // Process photo updates |
| | | $this->logDebug('Photo update received', $data); |
| | | } |
| | | |
| | | /** |
| | | * Send test post to Facebook |
| | | */ |
| | | private function sendTestPost(): array |
| | | { |
| | | $test_data = [ |
| | | 'message' => 'Test post from ' . get_bloginfo('name') . ' at ' . current_time('mysql'), |
| | | 'access_token' => $this->page_access_token |
| | | ]; |
| | | |
| | | $url = $this->apiBase['graph'] . '/' . $this->page_id . '/feed'; |
| | | |
| | | $response = wp_remote_post($url, [ |
| | | 'body' => $test_data |
| | | ]); |
| | | |
| | | if (is_wp_error($response)) { |
| | | return ['success' => false, 'error' => $response->get_error_message()]; |
| | | } |
| | | |
| | | $body = json_decode(wp_remote_retrieve_body($response), true); |
| | | |
| | | if (isset($body['id'])) { |
| | | return ['success' => true, 'post_id' => $body['id']]; |
| | | } |
| | | |
| | | return ['success' => false, 'error' => $body['error']['message'] ?? 'Unknown error']; |
| | | } |
| | | } |
| New file |
| | |
| | | <?php |
| | | /** |
| | | * Google Maps Integration |
| | | * File: /inc/integrations/GoogleMapsIntegration.php |
| | | */ |
| | | |
| | | namespace JVBase\integrations\services; |
| | | use JVBase\integrations\Integrations; |
| | | use JVBase\registrar\Registrar; |
| | | |
| | | if (!defined('ABSPATH')) { |
| | | exit; |
| | | } |
| | | |
| | | class GoogleMapsOld extends Integrations |
| | | { |
| | | private string $public_key; |
| | | private string $private_key; |
| | | private string $map_id; |
| | | private array $enabled_apis = []; |
| | | |
| | | // Edmonton default coordinates |
| | | private const DEFAULT_LAT = 53.5461; |
| | | private const DEFAULT_LNG = -113.4938; |
| | | private const DEFAULT_ZOOM = 11; // City-wide view |
| | | private const DEFAULT_MAP_ID = '873aed4ca260b4203e45b6e3'; |
| | | |
| | | protected static self $instance; |
| | | public static function getInstance():self |
| | | { |
| | | |
| | | if (!isset(self::$instance)) { |
| | | self::$instance = new self(); |
| | | } |
| | | return self::$instance; |
| | | } |
| | | |
| | | protected function __construct() |
| | | { |
| | | $this->service_name = 'maps'; |
| | | $this->title = 'Google Maps'; |
| | | $this->icon = 'map-pin'; |
| | | $this->apiBase = 'https://maps.googleapis.com/maps/api/'; |
| | | $this->apiEndpoints = [ |
| | | 'geocode/json', |
| | | 'place/details/json', |
| | | 'place/nearbysearch/json', |
| | | 'distancematrix/json', |
| | | 'js' |
| | | ]; |
| | | |
| | | $this->fields = [ |
| | | 'public_key' => [ |
| | | 'type' => 'text', |
| | | 'subtype' => 'password', |
| | | 'label' => 'Maps Platform API Key (Referrer-Restricted)', |
| | | 'hint' => 'Restrict this key by <b>HTTP referrers</b> for security. Used for displaying maps in browsers.', |
| | | 'required' => true, |
| | | ], |
| | | 'private_key' => [ |
| | | 'type' => 'text', |
| | | 'subtype' => 'password', |
| | | 'label' => 'Geocoding & Places API Key (IP-Restricted)', |
| | | 'hint' => 'Restrict this key by <br>IP Addresses</b>. Used for geocoding and server-side operations.', |
| | | 'required' => true, |
| | | ] |
| | | ]; |
| | | $this->advanced = [ |
| | | 'map_id' => [ |
| | | 'type' => 'text', |
| | | 'label' => 'Map ID', |
| | | 'hint' => 'Required for custom styling. Create a Map ID in <a href="https://console.cloud.google.com/google/maps-apis/studio/maps" target="_blank">Google Cloud Console → Maps → Map Management</a>' |
| | | ], |
| | | 'default_zoom' => [ |
| | | 'type' => 'text', |
| | | 'subtype' => 'number', |
| | | 'min' => 1, |
| | | 'max' => 20, |
| | | 'label' => 'Default Zoom', |
| | | 'default' => 11 |
| | | ], |
| | | ]; |
| | | $this->instructions = [ |
| | | 'Create two API keys in <a href="https://console.cloud.google.com/apis/credentials" target="_blank">Google Cloud Console</a>', |
| | | 'Enable <strong>Geocoding API + Places API</strong> for frontend key, restricted by your server IP address', |
| | | 'Enable <strong>Maps Platform API</strong> for server key, restricted by your website domain', |
| | | ]; |
| | | $this->defaults = [ |
| | | |
| | | ]; |
| | | parent::__construct(); |
| | | |
| | | $this->actions = array_merge( |
| | | $this->actions, |
| | | [ |
| | | 'reverse_geocode' => 'processReverseGeocode', |
| | | 'geocode' => 'processGeocode' |
| | | ] |
| | | ); |
| | | // Auto-enqueue maps script when needed |
| | | add_action('wp_enqueue_scripts', [$this, 'maybeEnqueueMapsScript']); |
| | | // add_action('admin_enqueue_scripts', [$this, 'maybeEnqueueMapsScript']); |
| | | } |
| | | |
| | | protected function initialize(): void |
| | | { |
| | | $this->private_key = $this->credentials['private_key'] ?? ''; |
| | | $this->public_key = $this->credentials['public_key'] ?? ''; |
| | | $this->map_id = $this->credentials['map_id'] ?? self::DEFAULT_MAP_ID; |
| | | $this->rate_limits = ['min_interval' => 0]; // Google Maps has generous limits |
| | | } |
| | | |
| | | /** |
| | | * Get service description |
| | | */ |
| | | public function getServiceDescription(): string |
| | | { |
| | | return "Enable location features and embedded maps throughout your site."; |
| | | } |
| | | |
| | | /** |
| | | * Get request headers (required by base class) |
| | | */ |
| | | protected function getRequestHeaders(): array |
| | | { |
| | | // Google Maps API uses API key in URL params, not headers |
| | | return [ |
| | | 'Accept' => 'application/json', |
| | | 'Content-Type' => 'application/json' |
| | | ]; |
| | | } |
| | | |
| | | |
| | | /** |
| | | * Process test connection request |
| | | */ |
| | | protected function performConnectionTest(): bool |
| | | { |
| | | if (!$this->isSetUp()) { |
| | | return false; |
| | | } |
| | | |
| | | // Test with a simple geocoding request |
| | | $test_address = 'Edmonton, Alberta, Canada'; |
| | | $result = $this->geocodeAddress($test_address); |
| | | |
| | | return $result !== null; |
| | | } |
| | | |
| | | /** |
| | | * Process reverse geocode request |
| | | */ |
| | | private function processReverseGeocode(array $data): WP_Error|array |
| | | { |
| | | $lat = floatval($data['lat'] ?? 0); |
| | | $lng = floatval($data['lng'] ?? 0); |
| | | |
| | | if (!$lat || !$lng) { |
| | | return new WP_Error('invalid_coordinates', 'Invalid coordinates provided'); |
| | | } |
| | | |
| | | $result = $this->reverseGeocode($lat, $lng); |
| | | |
| | | if ($result) { |
| | | return ['success' => true, 'data' => $result]; |
| | | } |
| | | |
| | | return new WP_Error('geocoding_failed', 'Reverse geocoding failed'); |
| | | } |
| | | |
| | | /** |
| | | * Process geocode request |
| | | */ |
| | | private function processGeocode(array $data): WP_Error|array |
| | | { |
| | | $address = sanitize_text_field($data['address'] ?? ''); |
| | | |
| | | if (empty($address)) { |
| | | return new WP_Error('invalid_address', 'No address provided'); |
| | | } |
| | | |
| | | $result = $this->geocodeAddress($address); |
| | | |
| | | if ($result) { |
| | | return ['success' => true, 'data' => $result]; |
| | | } |
| | | |
| | | return new WP_Error('geocoding_failed', 'Geocoding failed'); |
| | | } |
| | | |
| | | /** |
| | | * Geocode an address to coordinates |
| | | */ |
| | | public function geocodeAddress(string $address): ?array |
| | | { |
| | | if (!$this->isSetUp()) { |
| | | return null; |
| | | } |
| | | |
| | | $params = [ |
| | | 'address' => $address, |
| | | 'key' => $this->private_key |
| | | ]; |
| | | |
| | | $key = $this->cache->generateKey(['geocode', $address]); |
| | | $cached = $this->cache->get($key); |
| | | if ($cached) { |
| | | return $cached; |
| | | } |
| | | |
| | | $url = $this->apiBase . 'geocode/json?' . http_build_query($params); |
| | | $response = wp_remote_get($url); |
| | | |
| | | if (is_wp_error($response)) { |
| | | $this->logError('Geocoding request failed', [ |
| | | 'error' => $response->get_error_message(), |
| | | 'address' => $address |
| | | ]); |
| | | return null; |
| | | } |
| | | |
| | | $data = json_decode(wp_remote_retrieve_body($response), true); |
| | | |
| | | if ($data['status'] === 'OK' && !empty($data['results'])) { |
| | | $result = $data['results'][0]; |
| | | $result = [ |
| | | 'lat' => $result['geometry']['location']['lat'], |
| | | 'lng' => $result['geometry']['location']['lng'], |
| | | 'formatted_address' => $result['formatted_address'], |
| | | 'place_id' => $result['place_id'] ?? '', |
| | | 'components' => $this->parseAddressComponents($result['address_components'] ?? []) |
| | | ]; |
| | | $this->cache->set($key, $result); |
| | | } else { |
| | | $this->logError('Google Maps API error', [ |
| | | 'status' => $data['status'] ?? 'unknown', |
| | | 'address' => $address |
| | | ], 'warning'); |
| | | $result = null; |
| | | } |
| | | |
| | | return $result; |
| | | } |
| | | |
| | | /** |
| | | * Reverse geocode coordinates to address |
| | | */ |
| | | public function reverseGeocode(float $lat, float $lng): ?array |
| | | { |
| | | if (!$this->isSetUp()) { |
| | | return null; |
| | | } |
| | | |
| | | $params = [ |
| | | 'latlng' => "{$lat},{$lng}", |
| | | 'key' => $this->private_key |
| | | ]; |
| | | |
| | | $key = $this->cache->generateKey(['reverse', $lat, $lng]); |
| | | $cached = $this->cache->get($key); |
| | | if ($cached) { |
| | | return $cached; |
| | | } |
| | | |
| | | $url = $this->apiBase . 'geocode/json?' . http_build_query($params); |
| | | $response = wp_remote_get($url); |
| | | |
| | | if (is_wp_error($response)) { |
| | | $this->logError('Reverse geocoding request failed', [ |
| | | 'error' => $response->get_error_message(), |
| | | 'lat' => $lat, |
| | | 'lng' => $lng |
| | | ]); |
| | | return null; |
| | | } |
| | | |
| | | $data = json_decode(wp_remote_retrieve_body($response), true); |
| | | |
| | | if ($data['status'] === 'OK' && !empty($data['results'])) { |
| | | $result = $data['results'][0]; |
| | | $return = [ |
| | | 'formatted_address' => $result['formatted_address'], |
| | | 'place_id' => $result['place_id'] ?? '', |
| | | 'components' => $this->parseAddressComponents($result['address_components'] ?? []) |
| | | ]; |
| | | $this->cache->set($key, $return); |
| | | return $return; |
| | | } |
| | | |
| | | return null; |
| | | } |
| | | |
| | | /** |
| | | * Get place details by place ID |
| | | */ |
| | | public function getPlaceDetails(string $place_id, array $fields = []): ?array |
| | | { |
| | | if (!$this->isSetUp()) { |
| | | return null; |
| | | } |
| | | |
| | | $default_fields = ['name', 'formatted_address', 'geometry', 'rating', 'formatted_phone_number']; |
| | | $fields = empty($fields) ? $default_fields : $fields; |
| | | |
| | | $key = $this->cache->generateKey(['place', $place_id, $fields]); |
| | | $cached = $this->cache->get($key); |
| | | if ($cached) { |
| | | return $cached; |
| | | } |
| | | |
| | | $params = [ |
| | | 'place_id' => $place_id, |
| | | 'fields' => implode(',', $fields), |
| | | 'key' => $this->private_key |
| | | ]; |
| | | |
| | | $url = $this->apiBase . 'place/details/json?' . http_build_query($params); |
| | | $response = wp_remote_get($url); |
| | | |
| | | if (is_wp_error($response)) { |
| | | $this->logError('Place details request failed', [ |
| | | 'error' => $response->get_error_message(), |
| | | 'place_id' => $place_id |
| | | ]); |
| | | return null; |
| | | } |
| | | |
| | | $data = json_decode(wp_remote_retrieve_body($response), true); |
| | | |
| | | if ($data['status'] === 'OK' && !empty($data['result'])) { |
| | | $result = $data['result']; |
| | | $this->cache->set($key, $result); |
| | | return $result; |
| | | } |
| | | |
| | | return null; |
| | | } |
| | | |
| | | /** |
| | | * Search for places nearby |
| | | */ |
| | | public function searchNearby(float $lat, float $lng, int $radius = 1000, string $type = ''): array |
| | | { |
| | | if (!$this->isSetUp()) { |
| | | return []; |
| | | } |
| | | |
| | | $params = [ |
| | | 'location' => "{$lat},{$lng}", |
| | | 'radius' => $radius, |
| | | 'key' => $this->private_key |
| | | ]; |
| | | |
| | | if (!empty($type)) { |
| | | $params['type'] = $type; |
| | | } |
| | | |
| | | $key = $this->cache->generateKey(['nearby', $lat, $lng, $radius, $type]); |
| | | $cached = $this->cache->get($key); |
| | | if ($cached) { |
| | | return $cached; |
| | | } |
| | | |
| | | $url = $this->apiBase . 'place/nearbysearch/json?' . http_build_query($params); |
| | | $response = wp_remote_get($url); |
| | | |
| | | if (is_wp_error($response)) { |
| | | $this->logError('Nearby search request failed', [ |
| | | 'error' => $response->get_error_message(), |
| | | 'lat' => $lat, |
| | | 'lng' => $lng, |
| | | 'radius' => $radius |
| | | ]); |
| | | return []; |
| | | } |
| | | |
| | | $data = json_decode(wp_remote_retrieve_body($response), true); |
| | | |
| | | if ($data['status'] === 'OK' && !empty($data['results'])) { |
| | | $return = $data['results']; |
| | | $this->cache->set($key, $return); |
| | | return $return; |
| | | } |
| | | |
| | | return []; |
| | | } |
| | | |
| | | /** |
| | | * Calculate distance between two points |
| | | */ |
| | | public function calculateDistance(array $origin, array $destination, string $mode = 'driving'): ?array |
| | | { |
| | | if (!$this->isSetUp()) { |
| | | return null; |
| | | } |
| | | |
| | | // Handle different input formats |
| | | $origin_str = is_array($origin) ? "{$origin['lat']},{$origin['lng']}" : $origin; |
| | | $dest_str = is_array($destination) ? "{$destination['lat']},{$destination['lng']}" : $destination; |
| | | |
| | | $params = [ |
| | | 'origins' => $origin_str, |
| | | 'destinations' => $dest_str, |
| | | 'mode' => $mode, |
| | | 'units' => 'metric', |
| | | 'key' => $this->private_key |
| | | ]; |
| | | |
| | | $key = $this->cache->generateKey(['distance', $origin_str, $dest_str, $mode]); |
| | | $cached = $this->cache->get($key); |
| | | if ($cached) { |
| | | return $cached; |
| | | } |
| | | |
| | | $url = $this->apiBase . 'distancematrix/json?' . http_build_query($params); |
| | | $response = wp_remote_get($url); |
| | | |
| | | if (is_wp_error($response)) { |
| | | $this->logError('Distance calculation request failed', [ |
| | | 'error' => $response->get_error_message(), |
| | | 'origin' => $origin_str, |
| | | 'destination' => $dest_str |
| | | ]); |
| | | return null; |
| | | } |
| | | |
| | | $data = json_decode(wp_remote_retrieve_body($response), true); |
| | | |
| | | if ($data['status'] === 'OK' && !empty($data['rows'][0]['elements'][0])) { |
| | | $element = $data['rows'][0]['elements'][0]; |
| | | if ($element['status'] === 'OK') { |
| | | $return = [ |
| | | 'distance' => $element['distance'], |
| | | 'duration' => $element['duration'], |
| | | 'mode' => $mode |
| | | ]; |
| | | $this->cache->set($key, $return); |
| | | return $return; |
| | | } |
| | | } |
| | | |
| | | return null; |
| | | } |
| | | |
| | | /** |
| | | * Get the JavaScript API URL with key |
| | | */ |
| | | public function getJavaScriptApiUrl(array $libraries = ['places', 'marker', 'geometry']): string |
| | | { |
| | | if (empty($this->public_key)) { |
| | | return ''; |
| | | } |
| | | |
| | | $params = [ |
| | | 'key' => $this->public_key, |
| | | 'libraries' => implode(',', $libraries), |
| | | 'callback' => 'initGoogleMaps', |
| | | ]; |
| | | |
| | | return $this->apiBase . 'js?' . http_build_query($params); |
| | | } |
| | | |
| | | /** |
| | | * Enqueue Google Maps script when needed |
| | | */ |
| | | public function maybeEnqueueMapsScript(): void |
| | | { |
| | | // Only enqueue if we're on a page that needs maps |
| | | if (!$this->shouldEnqueueMaps()) { |
| | | return; |
| | | } |
| | | |
| | | if (!$this->isSetUp()) { |
| | | return; |
| | | } |
| | | |
| | | $this->ensureInitialized(); |
| | | |
| | | // Register Google Maps API |
| | | wp_register_script( |
| | | 'google-maps-api', |
| | | $this->getJavaScriptApiUrl(['places', 'geometry', 'marker']), |
| | | [], |
| | | null, |
| | | [ |
| | | 'in_footer' => true, |
| | | 'strategy' => 'async' |
| | | ] |
| | | ); |
| | | |
| | | // Register our maps handler |
| | | wp_register_script( |
| | | 'jvb-maps', |
| | | JVB_URL . 'assets/js/min/maps.min.js', |
| | | ['google-maps-api'], |
| | | '1.0.0', |
| | | [ |
| | | 'in_footer' => true, |
| | | 'strategy' => 'defer' |
| | | ] |
| | | ); |
| | | |
| | | // Add initialization script |
| | | wp_add_inline_script('google-maps-api', ' |
| | | // Set map defaults |
| | | window.jvbMapDefaults = { |
| | | lat: ' . self::DEFAULT_LAT . ', |
| | | lng: ' . self::DEFAULT_LNG . ', |
| | | zoom: ' . self::DEFAULT_ZOOM . ', |
| | | mapId: "' . esc_js($this->map_id) . '" |
| | | }; |
| | | |
| | | // Google Maps initialization callback |
| | | window.initGoogleMaps = function() { |
| | | // Dispatch ready event |
| | | document.dispatchEvent(new Event("googleMapsReady")); |
| | | |
| | | // Call any registered callbacks |
| | | if (window.googleMapsCallbacks && Array.isArray(window.googleMapsCallbacks)) { |
| | | window.googleMapsCallbacks.forEach(function(callback) { |
| | | if (typeof callback === "function") { |
| | | callback(); |
| | | } |
| | | }); |
| | | window.googleMapsCallbacks = []; |
| | | } |
| | | }; |
| | | |
| | | // Initialize callback queue |
| | | window.googleMapsCallbacks = window.googleMapsCallbacks || []; |
| | | ', 'before'); |
| | | |
| | | // Enqueue scripts |
| | | wp_enqueue_script('google-maps-api'); |
| | | wp_enqueue_script('jvb-maps'); |
| | | |
| | | // Add AJAX URL and nonce for JavaScript AJAX calls |
| | | wp_localize_script('jvb-maps', 'jvbMapsAjax', [ |
| | | 'ajaxurl' => admin_url('admin-ajax.php'), |
| | | 'nonce' => wp_create_nonce('jvb_integration_' . $this->service_name), |
| | | 'service' => $this->service_name |
| | | ]); |
| | | } |
| | | |
| | | /** |
| | | * Check if maps should be enqueued on current page |
| | | */ |
| | | private function shouldEnqueueMaps(): bool |
| | | { |
| | | // Check for shortcodes |
| | | if (is_singular() && get_post_meta(get_the_ID(), BASE . 'has_map', true) === true) { |
| | | return true; |
| | | } |
| | | |
| | | if (!empty(Registrar::withFeature('is_content', 'term')) && get_term_meta(get_queried_object_id(), BASE . 'has_map', true) === true) { |
| | | return true; |
| | | } |
| | | |
| | | // Check for specific page templates or post types |
| | | if (is_singular(['shop', 'artist', 'location'])) { |
| | | return true; |
| | | } |
| | | |
| | | // Check for admin pages that need maps |
| | | if (is_admin()) { |
| | | $screen = get_current_screen(); |
| | | if ($screen && in_array($screen->post_type, ['shop', 'artist', 'location'])) { |
| | | return true; |
| | | } |
| | | } |
| | | |
| | | // Allow filtering |
| | | return apply_filters('jvb_should_enqueue_maps', false); |
| | | } |
| | | |
| | | /** |
| | | * Parse address components from Google response |
| | | */ |
| | | private function parseAddressComponents(array $components): array |
| | | { |
| | | $parsed = [ |
| | | 'street_number' => '', |
| | | 'street' => '', |
| | | 'city' => '', |
| | | 'province' => '', |
| | | 'country' => '', |
| | | 'postal_code' => '' |
| | | ]; |
| | | |
| | | foreach ($components as $component) { |
| | | $types = $component['types']; |
| | | |
| | | if (in_array('street_number', $types)) { |
| | | $parsed['street_number'] = $component['long_name']; |
| | | } |
| | | if (in_array('route', $types)) { |
| | | $parsed['street'] = $component['long_name']; |
| | | } |
| | | if (in_array('locality', $types)) { |
| | | $parsed['city'] = $component['long_name']; |
| | | } |
| | | if (in_array('administrative_area_level_1', $types)) { |
| | | $parsed['province'] = $component['short_name']; |
| | | } |
| | | if (in_array('country', $types)) { |
| | | $parsed['country'] = $component['short_name']; |
| | | } |
| | | if (in_array('postal_code', $types)) { |
| | | $parsed['postal_code'] = $component['long_name']; |
| | | } |
| | | } |
| | | |
| | | // Combine street number and street name |
| | | if ($parsed['street_number'] && $parsed['street']) { |
| | | $parsed['street'] = $parsed['street_number'] . ' ' . $parsed['street']; |
| | | } |
| | | |
| | | return $parsed; |
| | | } |
| | | |
| | | /** |
| | | * Get Map ID |
| | | */ |
| | | public function getMapId(): string |
| | | { |
| | | return $this->map_id; |
| | | } |
| | | |
| | | /** |
| | | * Get default map settings |
| | | */ |
| | | public function getDefaultMapSettings(): array |
| | | { |
| | | return [ |
| | | 'center' => [ |
| | | 'lat' => self::DEFAULT_LAT, |
| | | 'lng' => self::DEFAULT_LNG |
| | | ], |
| | | 'zoom' => $this->credentials['default_zoom'] ?? self::DEFAULT_ZOOM, |
| | | 'mapId' => $this->map_id, |
| | | 'styles' => $this->getMapStyles() |
| | | ]; |
| | | } |
| | | |
| | | /** |
| | | * Get map styles based on theme |
| | | */ |
| | | private function getMapStyles(): array |
| | | { |
| | | $theme = $this->credentials['theme'] ?? 'default'; |
| | | |
| | | switch ($theme) { |
| | | case 'dark': |
| | | return [ |
| | | ['elementType' => 'geometry', 'stylers' => [['color' => '#242f3e']]], |
| | | ['elementType' => 'labels.text.fill', 'stylers' => [['color' => '#746855']]], |
| | | ['elementType' => 'labels.text.stroke', 'stylers' => [['color' => '#242f3e']]], |
| | | ['featureType' => 'road', 'elementType' => 'geometry', 'stylers' => [['color' => '#38414e']]], |
| | | ['featureType' => 'water', 'elementType' => 'geometry', 'stylers' => [['color' => '#17263c']]] |
| | | ]; |
| | | |
| | | case 'minimal': |
| | | return [ |
| | | ['featureType' => 'poi', 'stylers' => [['visibility' => 'off']]], |
| | | ['featureType' => 'transit', 'stylers' => [['visibility' => 'off']]] |
| | | ]; |
| | | |
| | | default: |
| | | return []; |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Generate map application links |
| | | */ |
| | | public function generateMapLinks(float $lat, float $lng, ?string $address = null): array |
| | | { |
| | | $query = $address ? urlencode($address) : "{$lat},{$lng}"; |
| | | |
| | | return [ |
| | | 'google' => "https://www.google.com/maps/search/?api=1&query={$query}", |
| | | 'apple' => "https://maps.apple.com/?ll={$lat},{$lng}", |
| | | 'waze' => "https://waze.com/ul?ll={$lat},{$lng}&navigate=yes" |
| | | ]; |
| | | } |
| | | |
| | | /** |
| | | * Render a frontend display map |
| | | */ |
| | | public function renderDisplayMap(array $location, array $options = []): string |
| | | { |
| | | if (!$this->isSetUp() || empty($location['lat']) || empty($location['lng'])) { |
| | | return ''; |
| | | } |
| | | |
| | | $map_id = 'map-' . uniqid(); |
| | | $lat = (float)$location['lat']; |
| | | $lng = (float)$location['lng']; |
| | | $address = $location['address'] ?? ''; |
| | | |
| | | $map_options = array_merge([ |
| | | 'height' => '250px', |
| | | 'zoom' => 15, |
| | | 'show_marker' => true, |
| | | 'show_info_window' => true, |
| | | 'info_content' => $address, |
| | | 'interactive' => true |
| | | ], $options); |
| | | |
| | | ob_start(); |
| | | ?> |
| | | <div id="<?= esc_attr($map_id); ?>" |
| | | class="jvb-location-map-display" |
| | | style="height: <?= esc_attr($map_options['height']); ?>; border-radius: 8px; overflow: hidden;"> |
| | | </div> |
| | | |
| | | <script> |
| | | document.addEventListener('DOMContentLoaded', function() { |
| | | function initDisplayMap() { |
| | | if (!window.jvbMaps || !window.jvbMaps.ready) { |
| | | setTimeout(initDisplayMap, 100); |
| | | return; |
| | | } |
| | | |
| | | window.jvbMaps.createDisplayMap('<?= esc_js($map_id); ?>', { |
| | | lat: <?= $lat; ?>, |
| | | lng: <?= $lng; ?>, |
| | | zoom: <?= intval($map_options['zoom']); ?>, |
| | | show_marker: <?= $map_options['show_marker'] ? 'true' : 'false'; ?>, |
| | | show_info_window: <?= $map_options['show_info_window'] ? 'true' : 'false'; ?>, |
| | | info_content: <?= json_encode($map_options['info_content']); ?>, |
| | | interactive: <?= $map_options['interactive'] ? 'true' : 'false'; ?> |
| | | }); |
| | | } |
| | | |
| | | if (typeof google !== 'undefined' && google.maps) { |
| | | initDisplayMap(); |
| | | } else { |
| | | document.addEventListener('googleMapsReady', initDisplayMap); |
| | | } |
| | | }); |
| | | </script> |
| | | <?php |
| | | |
| | | return ob_get_clean(); |
| | | } |
| | | |
| | | /** |
| | | * Render map application links |
| | | */ |
| | | public function renderMapLinks(float $lat, float $lng, ?string $address = null, array $options = []): string |
| | | { |
| | | $links = $this->generateMapLinks($lat, $lng, $address); |
| | | |
| | | $link_options = array_merge([ |
| | | 'show_icons' => true, |
| | | 'style' => 'buttons', // 'buttons' or 'text' |
| | | 'include' => ['google', 'apple'], // which links to show |
| | | 'target' => '_blank' |
| | | ], $options); |
| | | |
| | | $output = '<div class="jvb-map-links' . ($link_options['style'] === 'buttons' ? ' button-style' : ' text-style') . '">'; |
| | | |
| | | foreach ($link_options['include'] as $provider) { |
| | | if (!isset($links[$provider])) continue; |
| | | |
| | | $icon = ''; |
| | | $label = ''; |
| | | |
| | | switch ($provider) { |
| | | case 'google': |
| | | $icon = $link_options['show_icons'] ? jvbIcon('google') : ''; |
| | | $label = 'Google Maps'; |
| | | break; |
| | | case 'apple': |
| | | $icon = $link_options['show_icons'] ? jvbIcon('apple') : ''; |
| | | $label = 'Apple Maps'; |
| | | break; |
| | | case 'waze': |
| | | $icon = $link_options['show_icons'] ? jvbIcon('waze') : ''; |
| | | $label = 'Waze'; |
| | | break; |
| | | } |
| | | |
| | | $output .= sprintf( |
| | | '<a href="%s" target="%s" class="map-link %s-maps-link">%s<span>%s</span></a>', |
| | | esc_url($links[$provider]), |
| | | esc_attr($link_options['target']), |
| | | esc_attr($provider), |
| | | $icon, |
| | | esc_html($label) |
| | | ); |
| | | } |
| | | |
| | | $output .= '</div>'; |
| | | |
| | | return $output; |
| | | } |
| | | |
| | | /** |
| | | * Format address for display |
| | | */ |
| | | public function formatAddress(array $location, string $format = 'full'): string |
| | | { |
| | | if (!empty($location['address'])) { |
| | | return $location['address']; |
| | | } |
| | | |
| | | // Build address from components if no formatted address |
| | | $parts = []; |
| | | |
| | | switch ($format) { |
| | | case 'full': |
| | | if (!empty($location['street'])) $parts[] = $location['street']; |
| | | if (!empty($location['city'])) $parts[] = $location['city']; |
| | | if (!empty($location['province'])) $parts[] = $location['province']; |
| | | if (!empty($location['postal_code'])) $parts[] = $location['postal_code']; |
| | | if (!empty($location['country'])) $parts[] = $location['country']; |
| | | break; |
| | | |
| | | case 'compact': |
| | | if (!empty($location['city']) && !empty($location['province'])) { |
| | | $parts[] = $location['city'] . ', ' . $location['province']; |
| | | } |
| | | break; |
| | | |
| | | case 'city_only': |
| | | if (!empty($location['city'])) $parts[] = $location['city']; |
| | | break; |
| | | } |
| | | |
| | | return implode(', ', $parts); |
| | | } |
| | | } |
| | |
| | | |
| | | protected function setContentTypes():void |
| | | { |
| | | $this->has_content = true; |
| | | $this->contentTypes = [ |
| | | 'menu_item' => [ |
| | | 'nutritionFacts' => [ |
| New file |
| | |
| | | <?php |
| | | namespace JVBase\integrations\services; |
| | | use JVBase\integrations\Integrations; |
| | | use JVBase\meta\Meta; |
| | | |
| | | if (!defined('ABSPATH')) { |
| | | exit; |
| | | } |
| | | |
| | | class GoogleMyBusinessOld extends Integrations |
| | | { |
| | | protected array $allowedContent = [ |
| | | 'menu_item', |
| | | 'post', |
| | | 'event', |
| | | ]; |
| | | private ?string $access_token = null; |
| | | protected string $readMask = 'name,title,storefrontAddress,metadata,openInfo,storeCode,categories,phoneNumbers,labels,specialHours'; |
| | | private ?string $location = null; |
| | | private ?string $refresh_token = null; |
| | | private ?string $client_id = null; |
| | | private ?string $client_secret = null; |
| | | private ?string $account_id = null; |
| | | |
| | | protected static array $instances = []; |
| | | public static function getInstance(?int $userID = null):self |
| | | { |
| | | $key = is_null($userID) ? 'base' : $userID; |
| | | if (!array_key_exists($key, self::$instances)) { |
| | | self::$instances[$key] = new self($userID); |
| | | } |
| | | return self::$instances[$key]; |
| | | } |
| | | |
| | | protected function __construct(?int $userID = null) |
| | | { |
| | | $this->service_name = 'gmb'; |
| | | $this->title = 'Google My Business'; |
| | | $this->icon = 'storefront'; |
| | | $this->cacheName = ($userID)? 'gmb_'.$userID : 'gmb'; |
| | | $this->canSync = [ |
| | | 'initial' => true, |
| | | 'update' => true, |
| | | 'delete' => true, |
| | | ]; |
| | | $this->apiBase = [ |
| | | 'base' => 'https://mybusinessbusinessinformation.googleapis.com', |
| | | 'accounts' => 'https://mybusinessaccountmanagement.googleapis.com', |
| | | 'posts' => 'https://mybusiness.googleapis.com', |
| | | 'performance'=> 'https://businessprofileperformance.googleapis.com', |
| | | 'v4' => 'https://mybusiness.googleapis.com/v4', |
| | | ]; |
| | | |
| | | $this->apiEndpoints = [ |
| | | '/accounts/[^/]+/locations/[^/]+/reviews', |
| | | '/accounts/[^/]+/locations/[^/]+/foodMenus', |
| | | '/v4/accounts/[^/]+/locations/[^/]+/media', |
| | | '/v4/accounts/[^/]+/locations/[^/]+/localPosts', |
| | | '/v4/accounts/[^/]+/locations/[^/]+/localPosts/[^/]+', |
| | | '/accounts/[^/]+/locations/[^/]+', |
| | | '/v1/accounts/[^/]+/locations', // Matches /v1/accounts/{accountId}/locations |
| | | '/v1/accounts', |
| | | // Add pattern matching for dynamic location endpoints |
| | | '/v1/locations/[^/]+', // Matches /v1/locations/{locationId} |
| | | ]; |
| | | |
| | | $this->isOAuthService = true; |
| | | $this->oauth = [ |
| | | 'authorize' => 'https://accounts.google.com/o/oauth2/v2/auth', |
| | | 'token' => 'https://oauth2.googleapis.com/token', |
| | | 'revoke' => 'https://oauth2.googleapis.com/revoke', |
| | | 'scopes' => [ |
| | | 'https://www.googleapis.com/auth/business.manage', |
| | | 'https://www.googleapis.com/auth/userinfo.email', |
| | | 'https://www.googleapis.com/auth/userinfo.profile', |
| | | 'https://www.googleapis.com/auth/plus.business.manage' |
| | | ] |
| | | ]; |
| | | |
| | | $this->supportsWebp = false; |
| | | |
| | | $this->rate_limits = [ |
| | | 'per_second' => 15, // Allow 15 requests in burst window |
| | | 'per_minute' => 60, // 1-minute burst window |
| | | 'per_hour' => 500 // 500 requests per hour (well below Google's limits) |
| | | ]; |
| | | |
| | | $this->fields = [ |
| | | 'client_id' => [ |
| | | 'type' => 'text', |
| | | 'label' => 'OAuth Client ID', |
| | | 'placeholder'=> 'Enter Google OAuth Client ID', |
| | | 'hint' => 'From your Google Cloud Console OAuth credentials.', |
| | | 'required' => true, |
| | | ], |
| | | 'client_secret' => [ |
| | | 'type' => 'text', |
| | | 'subtype' => 'password', |
| | | 'label' => 'OAuth Client Secret', |
| | | 'required' => true, |
| | | ], |
| | | // 'access_token' => [ |
| | | // 'type' => 'text', |
| | | // 'subtype' => 'password', |
| | | // 'label' => 'Access Token', |
| | | // 'hint' => 'Generated automagically after OAuth authorization.' |
| | | // ], |
| | | // 'refresh_token' => [ |
| | | // 'type' => 'text', |
| | | // 'subtype' => 'password', |
| | | // 'label' => 'Refresh Token', |
| | | // 'hint' => 'Generated automagically after OAuth authorization.' |
| | | // ] |
| | | ]; |
| | | |
| | | $this->advanced = [ |
| | | |
| | | ]; |
| | | |
| | | $this->instructions = [ |
| | | 'Go to <a href="https://console.cloud.google.com/apis/credentials" target="_blank">Google Cloud Console</a>', |
| | | 'Create OAuth 2.0 Client ID credentials', |
| | | 'Enable Google My Business API', |
| | | 'Add redirect URI: <code>'.esc_html($this->getRedirectUri()).'</code>', |
| | | 'Use the "Authorize" button below to complete OAuth flow' |
| | | ]; |
| | | |
| | | parent::__construct(); |
| | | |
| | | $this->actions = array_merge( |
| | | $this->actions, |
| | | [ |
| | | 'update_location' => 'handleUpdateLocation', |
| | | 'select_account' => 'handleSelectAccount', |
| | | 'select_location' => 'handleSelectLocation', |
| | | 'check_oauth_status'=> 'checkOAuthStatus' |
| | | ] |
| | | ); |
| | | $this->buttons = array_merge( |
| | | $this->buttons, |
| | | [ |
| | | 'check_oauth_status' => 'Check OAuth Status' |
| | | ] |
| | | ); |
| | | |
| | | if (JVB_TESTING) { |
| | | $this->cache->flush(); |
| | | } |
| | | } |
| | | |
| | | protected function initialize(): void |
| | | { |
| | | if (empty($this->credentials)) { |
| | | $this->loadCredentials(); |
| | | } |
| | | $this->access_token = (array_key_exists('access_token', $this->credentials)) ? $this->credentials['access_token'] : null; |
| | | $this->refresh_token = (array_key_exists('refresh_token', $this->credentials)) ? $this->credentials['refresh_token'] : null; |
| | | $this->client_id = (array_key_exists('client_id', $this->credentials)) ? $this->credentials['client_id'] : null; |
| | | $this->client_secret = (array_key_exists('client_secret', $this->credentials)) ? $this->credentials['client_secret'] : null; |
| | | $this->location = (array_key_exists('location', $this->credentials)) ? $this->credentials['location'] : null; |
| | | $this->account_id = (array_key_exists('account', $this->credentials)) ? $this->credentials['account'] : null; |
| | | |
| | | if ($this->account_id) { |
| | | $this->apiEndpoints[] = "/v1/{$this->account_id}/locations"; |
| | | } |
| | | if ($this->location) { |
| | | $locationName = $this->getSelectedLocationResourceName(); |
| | | $this->apiEndpoints[] = "/v4/{$locationName}/localPosts"; |
| | | } |
| | | } |
| | | |
| | | protected function performConnectionTest(): bool |
| | | { |
| | | try { |
| | | $accounts = $this->getAccounts(true); |
| | | return !empty($accounts); |
| | | } catch (\Exception $e) { |
| | | return false; |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Check if response contains an error - Google-specific |
| | | */ |
| | | protected function isErrorResponse(array $response): bool |
| | | { |
| | | // Google APIs return errors in this format: |
| | | // {"error": {"code": 401, "message": "...", "status": "UNAUTHENTICATED"}} |
| | | return isset($response['error']) && isset($response['error']['code']); |
| | | } |
| | | |
| | | protected function getRequestHeaders(): array |
| | | { |
| | | return [ |
| | | 'Authorization' => 'Bearer ' . $this->access_token, |
| | | 'Content-Type' => 'application/json', |
| | | 'Accept' => 'application/json' |
| | | ]; |
| | | } |
| | | |
| | | protected function addOAuthParams(array $params): array |
| | | { |
| | | $params['access_type'] = 'offline'; |
| | | $params['prompt'] = 'consent'; |
| | | return $params; |
| | | } |
| | | |
| | | protected function addCredentialData(array $credentials, array $tokens): array |
| | | { |
| | | // Preserve GMB-specific settings |
| | | $existing = $this->credentials; |
| | | $credentials['location'] = $existing['location'] ?? null; |
| | | $credentials['account'] = $existing['account'] ?? null; |
| | | return $credentials; |
| | | } |
| | | |
| | | public function handleTheSavePost(int $postID, \WP_Post $post, bool $update, array $settings):void |
| | | { |
| | | |
| | | if (!$this->hasOAuthCredentials()) { |
| | | error_log('OAuth Not set up for '.$this->service_name); |
| | | return; |
| | | } |
| | | |
| | | $this->queueOperation(self::$syncTo, [ |
| | | 'items' => [$postID], |
| | | 'user' => user_can($post->post_author, 'manage_options') ? null : $post->post_author |
| | | ], [ |
| | | 'delay' => 60 |
| | | ]); |
| | | Meta::forPost($postID)->set('_'.$this->service_name.'_sync_status', 'queued'); |
| | | } |
| | | |
| | | public function processOperation(\WP_Error|array $result, object $operation, array $data):\WP_Error|array |
| | | { |
| | | try { |
| | | $gmb = (array_key_exists('user', $data) && $data['user'] !== 0) ? $this->getInstance((int)$data['user']) : $this; |
| | | $gmb->ensureInitialized(); |
| | | switch ($operation->type) { |
| | | case 'gmb_create_menu_item': |
| | | case 'gmb_update_menu_item': |
| | | case 'gmb_menu_update': |
| | | case 'gmb_menu_update_' . $this->userID: |
| | | return $gmb->processMenuUpdate($operation, $data); |
| | | case 'gmb_create_event': |
| | | case 'gmb_update_event': |
| | | return $gmb->syncEventPost($operation, $data); |
| | | case 'gmb_create_offer': |
| | | case 'gmb_update_offer': |
| | | return $gmb->syncOfferPost($operation, $data); |
| | | case 'gmb_create_post': |
| | | case 'gmb_update_post': |
| | | return $gmb->syncStandardPost($operation, $data); |
| | | case 'gmb_specialHours': |
| | | return $gmb->setSpecialHours($data['date'], $data['data']); |
| | | case 'gmb_business_hours': |
| | | return $gmb->updateBusinessHours($data); |
| | | case 'gmb_business_info': |
| | | return $gmb->updateBusinessInfo($data); |
| | | case 'gmb_update_attributes': |
| | | return $gmb->updateAttributes($data); |
| | | default: |
| | | return $result; |
| | | } |
| | | } catch (\Exception $e) { |
| | | error_log('[GMB] Operation processing error: ' . $e->getMessage()); |
| | | return [ |
| | | 'success' => false, |
| | | 'result' => $e->getMessage() |
| | | ]; |
| | | } |
| | | } |
| | | |
| | | protected function syncEventPost(object $operation, array $data):\WP_Error|array |
| | | { |
| | | $location = $this->getSelectedLocationResourceName(); |
| | | |
| | | if (!$location) { |
| | | return [ |
| | | 'success' => false, |
| | | 'result' => 'No location Name set.' |
| | | ]; |
| | | } |
| | | |
| | | $postID = $data['post_id']; |
| | | $meta = Meta::forPost($postID); |
| | | $fields = [ |
| | | 'start_date', |
| | | 'end_date', |
| | | 'start_time', |
| | | 'end_time', |
| | | 'post_excerpt', |
| | | 'post_title', |
| | | 'post_thumbnail', |
| | | "_{$this->service_name}_item_id" |
| | | ]; |
| | | |
| | | $data = [ |
| | | 'summary' => $fields['post_excerpt'], |
| | | 'topic_type' => 'EVENT', |
| | | 'event' => [ |
| | | 'title' => $fields['post_title'], |
| | | 'start_date' => $this->formatDate($fields['start_date']), |
| | | 'end_date' => $this->formatDate(($fields['end_date'] === '') ? $fields['start_date'] : $fields['end_date']), |
| | | 'start_time' => $this->formatTime($fields['start_time']), |
| | | 'end_time' => $this->formatTime($fields['end_time']), |
| | | ] |
| | | ]; |
| | | if ($fields['post_thumbnail'] !== '') { |
| | | $url = wp_get_attachment_url($fields['post_thumbnail']); |
| | | if ($url) { |
| | | $data['media'] = [ |
| | | ['mediaFormat' => 'PHOTO', 'sourceUrl' => $url] |
| | | ]; |
| | | } |
| | | } |
| | | |
| | | if ($fields["_{$this->service_name}_item_id"] !== '') { |
| | | $result = $this->updatePost($fields["_{$this->service_name}_item_id"], $data); |
| | | } else { |
| | | $result = $this->createPost($data); |
| | | $meta->set("_{$this->service_name}_item_id", $result['name']); |
| | | } |
| | | |
| | | return [ |
| | | 'success' => !empty($result), |
| | | 'result' => $result |
| | | ]; |
| | | } |
| | | |
| | | protected function syncOfferPost(object $operation, array $data):\WP_Error|array |
| | | { |
| | | $location = $this->getSelectedLocationResourceName(); |
| | | |
| | | if (!$location) { |
| | | return [ |
| | | 'success' => false, |
| | | 'result' => 'No location Name set.' |
| | | ]; |
| | | } |
| | | |
| | | $postID = $data['post_id']; |
| | | $meta = Meta::forPost($postID); |
| | | $fields = [ |
| | | 'post_excerpt', |
| | | 'post_title', |
| | | 'coupon_code', |
| | | 'termsConditions', |
| | | 'post_thumbnail', |
| | | "_{$this->service_name}_item_id" |
| | | ]; |
| | | |
| | | $data = [ |
| | | 'summary' => $fields['post_excerpt'], |
| | | 'topic_type' => 'OFFER', |
| | | 'offer' => [ |
| | | 'couponCode' => ($fields['coupon_code'] === '') ? $fields['post_title'] : $fields['coupon_code'], |
| | | 'redeemOnlineUrl'=> get_the_permalink($postID), |
| | | // 'termsConditions'=> get_bloginfo('terms_condition') TODO: setup terms and Conditions |
| | | ] |
| | | ]; |
| | | if ($fields['post_thumbnail'] !== '') { |
| | | $url = wp_get_attachment_url($fields['post_thumbnail']); |
| | | if ($url) { |
| | | $data['media'] = [ |
| | | ['mediaFormat' => 'PHOTO', 'sourceUrl' => $url] |
| | | ]; |
| | | } |
| | | } |
| | | |
| | | if ($fields["_{$this->service_name}_item_id"] !== '') { |
| | | $result = $this->updatePost($fields["_{$this->service_name}_item_id"], $data); |
| | | } else { |
| | | $result = $this->createPost($data); |
| | | $meta->set("_{$this->service_name}_item_id", $result['name']); |
| | | } |
| | | |
| | | return [ |
| | | 'success' => !empty($result), |
| | | 'result' => $result |
| | | ]; |
| | | } |
| | | |
| | | |
| | | protected function syncStandardPost(object $operation, array $data):\WP_Error|array |
| | | { |
| | | $location = $this->getSelectedLocationResourceName(); |
| | | |
| | | if (!$location) { |
| | | return [ |
| | | 'success' => false, |
| | | 'result' => 'No location Name set.' |
| | | ]; |
| | | } |
| | | |
| | | $postID = $data['post_id']; |
| | | $meta = Meta::forPost($postID); |
| | | $fields = [ |
| | | 'post_excerpt', |
| | | 'post_title', |
| | | 'post_thumbnail', |
| | | "_{$this->service_name}_item_id" |
| | | ]; |
| | | |
| | | $data = [ |
| | | 'summary' => $fields['post_excerpt'], |
| | | 'topic_type' => 'STANDARD', |
| | | 'call_to_action' => [ |
| | | 'type' => 'learn_more', |
| | | 'url'=> get_the_permalink($postID), |
| | | ] |
| | | ]; |
| | | if ($fields['post_thumbnail'] !== '') { |
| | | $url = wp_get_attachment_url($fields['post_thumbnail']); |
| | | if ($url) { |
| | | $data['media'] = [ |
| | | ['mediaFormat' => 'PHOTO', 'sourceUrl' => $url] |
| | | ]; |
| | | } |
| | | } |
| | | |
| | | if ($fields["_{$this->service_name}_item_id"] !== '') { |
| | | $result = $this->updatePost($fields["_{$this->service_name}_item_id"], $data); |
| | | } else { |
| | | $result = $this->createPost($data); |
| | | $meta->set("_{$this->service_name}_item_id", $result['name']); |
| | | } |
| | | |
| | | return [ |
| | | 'success' => !empty($result), |
| | | 'result' => $result |
| | | ]; |
| | | } |
| | | |
| | | protected function processMenuUpdate($operation, $data) { |
| | | try { |
| | | |
| | | $postID = $data['post_id']; |
| | | if (!$postID) { |
| | | return [ |
| | | 'success' => false, |
| | | 'result' => 'No post ID set' |
| | | ]; |
| | | } |
| | | $post = get_post($postID); |
| | | $postType = jvbNoBase($post->post_type); |
| | | |
| | | $args = [ |
| | | 'post_type' => jvbCheckBase($postType), |
| | | 'post_status' => 'publish', |
| | | 'posts_per_page' => -1, |
| | | 'meta_query' => [ |
| | | [ |
| | | 'key' => BASE.'_keep_synced_' . $this->service_name, |
| | | 'value' => '1', |
| | | 'compare' => '=' |
| | | ] |
| | | ] |
| | | ]; |
| | | |
| | | $menu_items = get_posts($args); |
| | | |
| | | if (empty($menu_items)) { |
| | | return [ |
| | | 'success' => true, |
| | | 'result' => 'No menu items to sync' |
| | | ]; |
| | | } |
| | | |
| | | $gmb = self::getInstance($data['userID']??null); |
| | | |
| | | // Build the complete menu structure |
| | | $menu_data = $gmb->collectMenu($menu_items); |
| | | |
| | | // Send to Google My Business API |
| | | $result = $this->updateFoodMenus($menu_data); |
| | | return [ |
| | | 'success' => !is_null($result), |
| | | 'result' => (is_array($result)) ? array_merge($result, ['menu'=>$menu_data]) : ['menu'=>$menu_data], |
| | | ]; |
| | | |
| | | } catch (\Exception $e) { |
| | | error_log('GMB Menu Update Error: ' . $e->getMessage()); |
| | | return [ |
| | | 'success' => false, |
| | | 'result' => $e->getMessage() |
| | | ]; |
| | | } |
| | | } |
| | | |
| | | /*************************************************************** |
| | | API |
| | | ***************************************************************/ |
| | | private function getSelectedLocationResourceName(): ?string |
| | | { |
| | | if (empty($this->credentials['location'])) { |
| | | return null; |
| | | } |
| | | |
| | | // If already a full resource name, return as-is |
| | | if (str_starts_with($this->credentials['location'], 'locations/')) { |
| | | return $this->credentials['location']; |
| | | } |
| | | |
| | | // Otherwise, it's a storeCode - need to find the full resource name |
| | | try { |
| | | $accounts = $this->getAccounts(); |
| | | foreach ($accounts as $account) { |
| | | $locations = $this->getLocations($account['name']); |
| | | |
| | | foreach ($locations as $location) { |
| | | if ($location['storeCode'] === $this->credentials['location']) { |
| | | // Auto-migrate: update stored location to use full resource name |
| | | $this->setSelectedLocation($location['name']); |
| | | return $location['name']; // This is the full resource name |
| | | } |
| | | } |
| | | } |
| | | } catch (\Exception $e) { |
| | | error_log('[GMB] Error finding location resource name: ' . $e->getMessage()); |
| | | } |
| | | |
| | | return null; |
| | | } |
| | | /** |
| | | * Update business information |
| | | */ |
| | | /** |
| | | * @param array $updates |
| | | * @return array As expected by OperationQueue.php processOperation |
| | | */ |
| | | public function updateBusinessInfo(array $updates): array |
| | | { |
| | | /** |
| | | SEE ALSO: https://developers.google.com/my-business/content/attributes |
| | | |
| | | MENU EXAMPLE |
| | | You can add structured menu data, like a food menu, to a location with the use of the PriceList object. |
| | | |
| | | The following JSON request shows how to publish a breakfast menu to a location. The response contains an instance of the updated Location object. |
| | | |
| | | HTTP |
| | | |
| | | PATCH |
| | | https://mybusiness.googleapis.com/v4/accounts/{accountId}/locations/{locationId}?updateMask=priceLists |
| | | { |
| | | "priceLists": [ |
| | | { |
| | | "priceListId": "Breakfast", |
| | | "labels": [ |
| | | { |
| | | "displayName": "Breakfast", |
| | | "description": "Tasty Google Breakfast", |
| | | "languageCode": "en" |
| | | } |
| | | ], |
| | | "sourceUrl": "http://www.google.com/todays_menu", |
| | | "sections": [ |
| | | { |
| | | "sectionId": "entree_menu", |
| | | "sectionType":"FOOD", |
| | | "labels": [ |
| | | { |
| | | "displayName": "Entrées", |
| | | "description": "Breakfast Entrées", |
| | | "languageCode": "en" |
| | | } |
| | | ], |
| | | "items": [ |
| | | { |
| | | "itemId": "scramble", |
| | | "labels": [ |
| | | { |
| | | "displayName": "Big Scramble", |
| | | "description": "A delicious scramble filled with Potatoes, Eggs, |
| | | Bell Peppers, and Sausage", |
| | | "languageCode": "en" |
| | | } |
| | | ], |
| | | "price": { |
| | | "currencyCode": "USD", |
| | | "units": "12", |
| | | "nanos": "200000000" |
| | | } |
| | | }, |
| | | { |
| | | "itemId": "steak_omelette", |
| | | "labels": [ |
| | | { |
| | | "displayName": "Steak Omelette", |
| | | "description": "Three egg omelette with grilled prime rib, |
| | | fire-roasted bell peppers and onions, saut\u00e9ed mushrooms |
| | | and melted Swiss cheese", |
| | | "languageCode": "en" |
| | | } |
| | | ], |
| | | "price": { |
| | | "currencyCode": "USD", |
| | | "units": "15", |
| | | "nanos": "750000000" |
| | | } |
| | | } |
| | | ] |
| | | } |
| | | ] |
| | | } |
| | | ] |
| | | } |
| | | |
| | | |
| | | SERVICE DATA |
| | | If your business offers different service options, you can add structured services data to a location with the use of the PriceList object. |
| | | |
| | | The following JSON request shows how to publish a service offering to a location. The response contains an instance of the updated Location object. |
| | | |
| | | HTTP |
| | | |
| | | PATCH |
| | | https://mybusiness.googleapis.com/v4/accounts/{accountId}/locations/{locationId}?updateMask=priceLists |
| | | { |
| | | "priceLists": [ |
| | | { |
| | | "priceListId": "Oil Change", |
| | | "labels": [ |
| | | { |
| | | "displayName": "Oil Change", |
| | | "description": "Caseys Qwik Oil Change", |
| | | "languageCode": "en" |
| | | } |
| | | ], |
| | | "sourceUrl": "http://www.google.com/todays_services", |
| | | "sections": [ |
| | | { |
| | | "sectionId": "oil_services", |
| | | "sectionType":”SERVICES”, |
| | | "labels": [ |
| | | { |
| | | "displayName": "Services", |
| | | "description": "Oil Changes", |
| | | "languageCode": "en" |
| | | } |
| | | ], |
| | | "items": [ |
| | | { |
| | | "itemId": "20-minute-oil-change", |
| | | "labels": [ |
| | | { |
| | | "displayName": "20 Minute Oil Change", |
| | | "description": "Quick oil change and filter service.", |
| | | "languageCode": "en" |
| | | } |
| | | ], |
| | | "price": { |
| | | "currencyCode": "USD", |
| | | "units": "30", |
| | | "nanos": "200000000" |
| | | } |
| | | }, |
| | | { |
| | | "itemId": "full_service_oil_change", |
| | | "labels": [ |
| | | { |
| | | "displayName": "Full Service Oil Change", |
| | | "description": "Quick oil change, filter service, and brake inspection.", |
| | | "languageCode": "en" |
| | | } |
| | | ], |
| | | "price": { |
| | | "currencyCode": "USD", |
| | | "units": "45", |
| | | "nanos": "750000000" |
| | | } |
| | | } |
| | | ] |
| | | } |
| | | ] |
| | | } |
| | | ] |
| | | } |
| | | **/ |
| | | |
| | | try { |
| | | $allowed_fields = [ |
| | | 'title', // Business name |
| | | 'phoneNumbers', // Primary phone |
| | | 'websiteUri', // Website URL |
| | | 'categories', // Business categories |
| | | 'storefrontAddress', // Physical address |
| | | 'serviceArea', // Service area for service businesses |
| | | 'profile', // Business description |
| | | 'openInfo', // Opening date info |
| | | 'regularHours', // Business hours |
| | | 'specialHours', // Special hours |
| | | ]; |
| | | |
| | | // Filter to only allowed fields |
| | | $patch_data = array_intersect_key($updates,$allowed_fields); |
| | | |
| | | $location_name = $this->getSelectedLocationResourceName(); |
| | | if (empty($patch_data)) { |
| | | throw new \Exception('No valid fields to update'); |
| | | } |
| | | |
| | | // Create updateMask from the fields being updated |
| | | $update_mask = implode(',', array_keys($patch_data)); |
| | | |
| | | $response = $this->makeRequest( |
| | | 'PATCH', |
| | | "/v1/{$location_name}?updateMask={$update_mask}", |
| | | $patch_data, |
| | | 'base' |
| | | ); |
| | | |
| | | return [ |
| | | 'success' => !empty($response), |
| | | 'result' => $response |
| | | ]; |
| | | |
| | | } catch (\Exception $e) { |
| | | $this->logError($e->getMessage(), [ |
| | | 'method' => 'updateBusinessInfo' |
| | | ]); |
| | | return [ |
| | | 'success' => false, |
| | | 'result' => $e->getMessage(), |
| | | ]; |
| | | } |
| | | } |
| | | |
| | | |
| | | /** |
| | | * Update business attributes (amenities, services, etc.) |
| | | */ |
| | | /** |
| | | * @param string $location_name |
| | | * @param array $attributes |
| | | * @return array As expected by OperationQueue.php processOperation |
| | | */ |
| | | public function updateAttributes(array $attributes): array |
| | | { |
| | | try { |
| | | // Attributes like: wheelchair_accessible, wifi, parking, etc. |
| | | //TODO: sanitize data |
| | | $attributes_data = [ |
| | | 'attributes' => $attributes |
| | | ]; |
| | | $location_name = $this->getSelectedLocationResourceName(); |
| | | |
| | | $response = $this->makeRequest( |
| | | 'PATCH', |
| | | "/v1/accounts/{$location_name}/attributes", |
| | | $attributes_data, |
| | | 'base' |
| | | ); |
| | | |
| | | return [ |
| | | 'success' => !empty($response), |
| | | 'result' => $response |
| | | ]; |
| | | |
| | | } catch (\Exception $e) { |
| | | $this->logError($e->getMessage(), [ |
| | | 'method' => 'updateAttributes' |
| | | ]); |
| | | return [ |
| | | 'success' => false, |
| | | 'result' => $e->getMessage() |
| | | ]; |
| | | } |
| | | } |
| | | /***************** |
| | | * POSTS |
| | | ****************/ |
| | | /** |
| | | * Create a new GMB post |
| | | * |
| | | * @param string $location_name The location resource name |
| | | * @param array $post_data Post content including summary, callToAction, media, etc. |
| | | * @return array|null Created post data or null on failure |
| | | */ |
| | | |
| | | /** |
| | | FROM THE DOCS: |
| | | EVENT POST |
| | | Notify your customers about the next event at your business with a Post. Your Post for an event includes start and end dates and times, which display prominently on the Post. |
| | | |
| | | To make a Post to an account associated with a user, use the accounts.locations.localPosts API. |
| | | |
| | | To create a Post for an authenticated user, use the following: |
| | | |
| | | $ POST |
| | | https://mybusiness.googleapis.com/v4/accounts/{accountId}/locations/{locationId}/localPosts |
| | | { |
| | | "languageCode": "en-US", |
| | | "summary": "Come in for our spooky Halloween event!", |
| | | "event": { |
| | | "title": "Halloween Spook-tacular!", |
| | | "schedule": { |
| | | "startDate": { |
| | | "year": 2017, |
| | | "month": 10, |
| | | "day": 31, |
| | | }, |
| | | "startTime": { |
| | | "hours": 9, |
| | | "minutes": 0, |
| | | "seconds": 0, |
| | | "nanos": 0, |
| | | }, |
| | | "endDate": { |
| | | "year": 2017, |
| | | "month": 10, |
| | | "day": 31, |
| | | }, |
| | | "endTime": { |
| | | "hours": 17, |
| | | "minutes": 0, |
| | | "seconds": 0, |
| | | "nanos": 0, |
| | | }, |
| | | } |
| | | }, |
| | | "media": [ |
| | | { |
| | | "mediaFormat": "PHOTO", |
| | | "sourceUrl": "https://www.google.com/real-image.jpg", |
| | | } |
| | | ], |
| | | "topicType": "EVENT" |
| | | } |
| | | |
| | | CALL TO ACTION POST |
| | | Posts with a call to action include a button. The text on the call to action button is determined by the actionType field of the Post. A link to a user-provided URL is added to the button. |
| | | |
| | | To create a Post with a call to action button, use the following: |
| | | |
| | | $ POST |
| | | https://mybusiness.googleapis.com/v4/accounts/{accountId}/locations/{locationId}/localPosts |
| | | { |
| | | "languageCode": "en-US", |
| | | "summary": "Order your Thanksgiving turkeys now!!", |
| | | "callToAction": { |
| | | "actionType": "ORDER", |
| | | "url": "http://google.com/order_turkeys_here", |
| | | }, |
| | | "media": [ |
| | | { |
| | | "mediaFormat": "PHOTO", |
| | | "sourceUrl": "https://www.google.com/real-turkey-photo.jpg", |
| | | } |
| | | ], |
| | | "topicType": "OFFER" |
| | | } |
| | | Action types |
| | | The call to action Posts can have different action types that determine the type of call to action Post. |
| | | |
| | | The following are the supported call to action types: |
| | | |
| | | Action types |
| | | BOOK Creates a Post that prompts a user to book an appointment, table, or something similar. |
| | | ORDER Creates a Post that prompts a user to order something. |
| | | SHOP Creates a Post that prompts a user to browse a product catalog. |
| | | LEARN_MORE Creates a Post that prompts a user to see additional details on a website. |
| | | SIGN_UP Creates a Post that prompts a user to register, sign up, or join something. |
| | | CALL Creates a Post that prompts a user to call a business. |
| | | |
| | | |
| | | OFFER POST |
| | | To create an Offer Post, use the following: |
| | | |
| | | HTTP |
| | | |
| | | $ POST |
| | | https://mybusiness.googleapis.com/v4/accounts/{accountId}/locations/{locationId}/localPosts |
| | | { |
| | | "languageCode": "en-US", |
| | | "summary": "Buy one Google jetpack, get a second one free!!", |
| | | "offer": { |
| | | "couponCode": “BOGO-JET-CODE”, |
| | | "redeemOnlineUrl": “https://www.google.com/redeem”, |
| | | "termsConditions": “Offer only valid if you can prove you are a time traveler” |
| | | }, |
| | | "media": [ |
| | | { |
| | | "mediaFormat": "PHOTO", |
| | | "sourceUrl": "https://www.google.com/real-jetpack-photo.jpg", |
| | | } |
| | | ], |
| | | "topicType": "OFFER" |
| | | } |
| | | |
| | | EDIT POST |
| | | Once a post is created, you can edit it with a PATCH request. |
| | | |
| | | To edit a Post, use the following: |
| | | |
| | | HTTP |
| | | |
| | | $ PATCH |
| | | https://mybusiness.googleapis.com/v4/accounts/{accountId}/locations/{locationId}/localPosts/{localPostId}?updateMask=summary |
| | | { |
| | | "summary": "Order your Christmas turkeys now!!" |
| | | } |
| | | |
| | | |
| | | DELETE POSTS |
| | | After a Post is created, you can delete it with a DELETE request. |
| | | |
| | | To delete a Post, use the following: |
| | | |
| | | HTTP |
| | | |
| | | $ DELETE |
| | | https://mybusiness.googleapis.com/v4/accounts/{accountId}/locations/{locationId}/localPosts/{localPostId} |
| | | **/ |
| | | public function createPost(array $post_data, ?string $location_name = null): ?array |
| | | { |
| | | $location_name = (!$location_name) ? $this->getSelectedLocationResourceName() : $location_name; |
| | | try { |
| | | // Validate required fields |
| | | if (empty($post_data['summary'])) { |
| | | throw new \Exception('Post summary is required'); |
| | | } |
| | | |
| | | // Build the post object according to API docs |
| | | $post = [ |
| | | 'languageCode' => $post_data['language_code'] ?? 'en-US', |
| | | 'summary' => $post_data['summary'], |
| | | 'topicType' => $post_data['topic_type'] ?? 'STANDARD' |
| | | ]; |
| | | |
| | | // Add optional fields |
| | | if (!empty($post_data['call_to_action'])) { |
| | | $post['callToAction'] = [ |
| | | 'actionType' => $post_data['call_to_action']['type'] ?? 'LEARN_MORE', |
| | | 'url' => $post_data['call_to_action']['url'] |
| | | ]; |
| | | } |
| | | |
| | | if (!empty($post_data['media'])) { |
| | | $post['media'] = $post_data['media']; |
| | | } |
| | | |
| | | if ($post_data['topic_type'] === 'EVENT' && !empty($post_data['event'])) { |
| | | $post['event'] = $post_data['event']; |
| | | } |
| | | |
| | | if ($post_data['topic_type'] === 'OFFER' && !empty($post_data['offer'])) { |
| | | $post['offer'] = $post_data['offer']; |
| | | } |
| | | |
| | | $response = $this->makeRequest( |
| | | 'POST', |
| | | "/v4/{$location_name}/localPosts", |
| | | $post, |
| | | 'posts' |
| | | ); |
| | | |
| | | $this->cache->invalidate("posts_{$location_name}"); |
| | | |
| | | return $response; |
| | | |
| | | } catch (\Exception $e) { |
| | | $this->logError($e->getMessage(), [ |
| | | 'method' => 'createPost' |
| | | ]); |
| | | return null; |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Update an existing GMB post |
| | | */ |
| | | public function updatePost(string $post_name, array $post_data): bool |
| | | { |
| | | try { |
| | | $update_fields = []; |
| | | $update_mask = []; |
| | | |
| | | if (isset($post_data['summary'])) { |
| | | $update_fields['summary'] = $post_data['summary']; |
| | | $update_mask[] = 'summary'; |
| | | } |
| | | |
| | | if (isset($post_data['call_to_action'])) { |
| | | $update_fields['callToAction'] = $post_data['call_to_action']; |
| | | $update_mask[] = 'callToAction'; |
| | | } |
| | | |
| | | if (empty($update_fields)) { |
| | | return true; // Nothing to update |
| | | } |
| | | |
| | | $response = $this->makeRequest( |
| | | 'PATCH', |
| | | "/v4/{$post_name}?updateMask=" . implode(',', $update_mask), |
| | | $update_fields, |
| | | 'posts' // Use the posts API base |
| | | ); |
| | | |
| | | return !empty($response); |
| | | |
| | | } catch (\Exception $e) { |
| | | |
| | | $this->logError($e->getMessage(), [ |
| | | 'method' => 'updatePost' |
| | | ]); |
| | | return false; |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Delete a post |
| | | */ |
| | | public function deletePost(string $post_name): bool |
| | | { |
| | | try { |
| | | $this->makeRequest('DELETE', "/v1/accounts/{$post_name}", [], 'posts'); |
| | | return true; |
| | | } catch (\Exception $e) { |
| | | |
| | | $this->logError($e->getMessage(), [ |
| | | 'method' => 'deletePost' |
| | | ]); |
| | | return false; |
| | | } |
| | | } |
| | | /**************** |
| | | * HOURS |
| | | ***************/ |
| | | /** |
| | | * Update regular business hours |
| | | * |
| | | * @param array $hours Array of periods with open/close times per day |
| | | * @return array Success status, according to OperationQueue.php |
| | | */ |
| | | public function updateBusinessHours(array $hours): array |
| | | { |
| | | if(!$this->isSetUp()) { |
| | | return []; |
| | | } |
| | | $location_name = $this->credentials['location']; |
| | | |
| | | if (empty($location_name)) { |
| | | error_log('[GMB] ERROR: No location selected or found'); |
| | | throw new \Exception('No location selected. Please select a location first.'); |
| | | } |
| | | |
| | | try { |
| | | // Validate and format hours |
| | | $periods = $this->validateAndFormatHours($hours); |
| | | |
| | | if (empty($periods)) { |
| | | error_log('[GMB] ERROR: No valid periods found after validation'); |
| | | throw new \Exception('No valid business hours provided'); |
| | | } |
| | | |
| | | $update_data = [ |
| | | 'regularHours' => [ |
| | | 'periods' => $periods |
| | | ] |
| | | ]; |
| | | |
| | | $endpoint = "/v1/{$location_name}?updateMask=regularHours"; |
| | | |
| | | // Make the API request |
| | | $response = $this->makeRequest( |
| | | 'PATCH', |
| | | $endpoint, |
| | | $update_data, |
| | | 'base' |
| | | ); |
| | | |
| | | $success = $response !== null; |
| | | |
| | | // Additional validation - check if the response contains the updated hours |
| | | if ($success && $response) { |
| | | if (isset($response['regularHours'])) { |
| | | // error_log('[GMB] SUCCESS: Updated hours confirmed in response: ' . print_r($response['regularHours'], true)); |
| | | } else { |
| | | // error_log('[GMB] WARNING: No regularHours in response, but API call succeeded'); |
| | | // error_log('[GMB] Full response keys: ' . implode(', ', array_keys($response))); |
| | | } |
| | | } |
| | | |
| | | return [ |
| | | 'success' => $success, |
| | | 'result' => $response |
| | | ]; |
| | | |
| | | } catch (\Exception $e) { |
| | | $this->logError($e->getMessage(), [ |
| | | 'method' => 'updateBusinessHours' |
| | | ]); |
| | | return [ |
| | | 'success' => false, |
| | | 'result' => $e->getMessage() |
| | | ]; |
| | | } |
| | | } |
| | | |
| | | private function validateAndFormatHours(array $hours): array |
| | | { |
| | | $valid_days = ['MONDAY', 'TUESDAY', 'WEDNESDAY', 'THURSDAY', 'FRIDAY', 'SATURDAY', 'SUNDAY']; |
| | | $periods = []; |
| | | |
| | | foreach ($hours as $day => $times) { |
| | | $formatted_day = strtoupper($day); |
| | | |
| | | // Convert day names if needed |
| | | $day_mapping = [ |
| | | 'MON' => 'MONDAY', |
| | | 'TUE' => 'TUESDAY', |
| | | 'TUES' => 'TUESDAY', |
| | | 'WED' => 'WEDNESDAY', |
| | | 'THU' => 'THURSDAY', |
| | | 'THUR' => 'THURSDAY', |
| | | 'THURS' => 'THURSDAY', |
| | | 'FRI' => 'FRIDAY', |
| | | 'SAT' => 'SATURDAY', |
| | | 'SUN' => 'SUNDAY' |
| | | ]; |
| | | |
| | | if (isset($day_mapping[$formatted_day])) { |
| | | $formatted_day = $day_mapping[$formatted_day]; |
| | | } |
| | | |
| | | if (!in_array($formatted_day, $valid_days)) { |
| | | error_log('[GMB] Invalid day: ' . $day . ' (formatted: ' . $formatted_day . ')'); |
| | | continue; |
| | | } |
| | | |
| | | // Check if day is open with flexible comparison |
| | | $is_open = false; |
| | | if (isset($times['open'])) { |
| | | // Convert to boolean - handles string "1", integer 1, boolean true, etc. |
| | | $is_open = (bool)(int)$times['open']; |
| | | } |
| | | |
| | | if (!$is_open) { |
| | | error_log('[GMB] Day ' . $formatted_day . ' is closed (open value: ' . ($times['open'] ?? 'not set') . ')'); |
| | | continue; |
| | | } |
| | | |
| | | error_log('[GMB] Day ' . $formatted_day . ' is OPEN'); |
| | | |
| | | $open_time = $this->formatTime($times['time_opens'] ?? ''); |
| | | $close_time = $this->formatTime($times['time_closes'] ?? ''); |
| | | |
| | | if (empty($open_time) || empty($close_time)) { |
| | | error_log('[GMB] Missing or invalid times for ' . $formatted_day); |
| | | error_log('[GMB] Raw time data - time_opens: "' . ($times['time_opens'] ?? 'not set') . '", time_closes: "' . ($times['time_closes'] ?? 'not set') . '"'); |
| | | continue; |
| | | } |
| | | |
| | | $period = [ |
| | | 'openDay' => $formatted_day, |
| | | 'openTime' => $open_time, // Now a proper Google TimeOfDay object |
| | | 'closeDay' => $formatted_day, |
| | | 'closeTime' => $close_time // Now a proper Google TimeOfDay object |
| | | ]; |
| | | |
| | | $periods[] = $period; |
| | | } |
| | | return $periods; |
| | | } |
| | | |
| | | /** |
| | | * Set special/temporary hours (for holidays, special events, etc.) |
| | | * |
| | | * @param string $date |
| | | * @param array{open?: float, closed?:float, isClosed?: bool, location?: string} $options |
| | | * @return array Operation Queue [ 'success' => true/false, 'result' => $results] |
| | | */ |
| | | public function setSpecialHours(string $date, array $options = []):array |
| | | { |
| | | // Get the full location resource name |
| | | $location_name = $this->getSelectedLocationResourceName(); |
| | | |
| | | if (empty($location_name)) { |
| | | throw new \Exception('No location selected. Please select a location first.'); |
| | | } |
| | | |
| | | try { |
| | | // Format the date correctly for Google API |
| | | $date_obj = [ |
| | | 'year' => (int)date('Y', strtotime($date)), |
| | | 'month' => (int)date('n', strtotime($date)), |
| | | 'day' => (int)date('j', strtotime($date)) |
| | | ]; |
| | | |
| | | $special_hour_period = [ |
| | | 'startDate' => $date_obj, |
| | | 'endDate' => $date_obj, // Same day for single-day special hours |
| | | ]; |
| | | |
| | | if (array_key_exists('isClosed', $options) && $options['isClosed']) { |
| | | $special_hour_period['closed'] = true; |
| | | } else { |
| | | $special_hour_period['closed'] = false; |
| | | |
| | | // Format times as TimeOfDay objects |
| | | if (!empty($options['open'])) { |
| | | $open_time = $this->formatTime($options['open']); |
| | | if ($open_time) { |
| | | $special_hour_period['openTime'] = $open_time; |
| | | } |
| | | } |
| | | |
| | | if (!empty($options['close'])) { |
| | | $close_time = $this->formatTime($options['close']); |
| | | if ($close_time) { |
| | | $special_hour_period['closeTime'] = $close_time; |
| | | } |
| | | } |
| | | } |
| | | |
| | | // Get existing special hours and their periods |
| | | $existing_special_hours = $location['specialHours'] ?? []; |
| | | $existing_periods = $existing_special_hours['specialHourPeriods'] ?? []; |
| | | |
| | | // Add or update the special hour period |
| | | $found = false; |
| | | foreach ($existing_periods as &$existing_period) { |
| | | if (isset($existing_period['startDate']) && $existing_period['startDate'] == $date_obj) { |
| | | $existing_period = $special_hour_period; |
| | | $found = true; |
| | | break; |
| | | } |
| | | } |
| | | |
| | | if (!$found) { |
| | | $existing_periods[] = $special_hour_period; |
| | | } |
| | | |
| | | // Prepare the update data with correct structure |
| | | $update_data = [ |
| | | 'specialHours' => [ |
| | | 'specialHourPeriods' => $existing_periods |
| | | ] |
| | | ]; |
| | | |
| | | // Try the PATCH request |
| | | $response = $this->makeRequest( |
| | | 'PATCH', |
| | | "/v1/{$location_name}?updateMask=specialHours", |
| | | $update_data, |
| | | 'base' |
| | | ); |
| | | |
| | | return [ |
| | | 'success' => $response !== null, |
| | | 'results' => $response |
| | | ]; |
| | | |
| | | } catch (\Exception $e) { |
| | | $this->logError($e->getMessage(), [ |
| | | 'method' => 'setSpecialHours' |
| | | ]); |
| | | return [ |
| | | 'success' => false, |
| | | 'results' => $e->getMessage(), |
| | | ]; |
| | | } |
| | | } |
| | | /** |
| | | * Clear all special hours |
| | | */ |
| | | public function clearSpecialHours(string $location_name): bool |
| | | { |
| | | try { |
| | | $update_data = [ |
| | | 'specialHours' => [] |
| | | ]; |
| | | |
| | | $response = $this->makeRequest( |
| | | 'PATCH', |
| | | "/v1/{$location_name}?updateMask=specialHours", |
| | | $update_data, |
| | | 'base' |
| | | ); |
| | | |
| | | return $response !== null; |
| | | |
| | | } catch (\Exception $e) { |
| | | |
| | | $this->logError($e->getMessage(), [ |
| | | 'method' => 'clearSpecialHours' |
| | | ]); |
| | | return false; |
| | | } |
| | | } |
| | | /*************************************************************** |
| | | HOURS |
| | | ***************************************************************/ |
| | | /** |
| | | FROM THE DOCS: |
| | | UPLOAD FROM URL |
| | | To upload photos from a URL , make the following call to Media.Create. Use the relevant category as needed. |
| | | |
| | | |
| | | POST https://mybusiness.googleapis.com/v4/accounts/{accountId}/locations/{locationId}/media |
| | | { |
| | | "mediaFormat": "PHOTO", |
| | | "locationAssociation": { |
| | | "category": "COVER" |
| | | }, |
| | | "sourceUrl": “http://example.com/biz/image.jpg", |
| | | } |
| | | To upload videos from a URL with the Google My Business API, make the following call to Media.Create: |
| | | |
| | | |
| | | POST https://mybusiness.googleapis.com/v4/accounts/{accountId}/locations/{locationId}/media |
| | | { |
| | | "mediaFormat": "VIDEO", |
| | | "locationAssociation": { |
| | | "category": "ADDITIONAL" |
| | | }, |
| | | "sourceUrl": “http://example.com/biz/video.mp4", |
| | | } |
| | | **/ |
| | | public function uploadImageToGMB(int $imgID, string $category = 'ADDITIONAL'): ?string |
| | | { |
| | | if ($imgID === 0) { |
| | | return null; |
| | | } |
| | | if (empty($this->credentials)) { |
| | | $this->loadCredentials(); |
| | | } |
| | | |
| | | try { |
| | | $imgID = $this->getSupportedImage($imgID); |
| | | $valid_categories = [ |
| | | 'COVER', 'PROFILE', 'LOGO', 'EXTERIOR', 'INTERIOR', |
| | | 'PRODUCT', 'AT_WORK', 'FOOD_AND_DRINK', 'MENU', |
| | | 'COMMON_AREA', 'ROOMS', 'TEAMS', 'ADDITIONAL', |
| | | 'FOOD_AND_DRINK' |
| | | ]; |
| | | |
| | | if (!in_array($category, $valid_categories)) { |
| | | throw new \Exception('Invalid photo category: ' . $category); |
| | | } |
| | | $mediaKey = $this->getImageMediaKey($imgID); |
| | | if ($mediaKey) { |
| | | return $mediaKey; |
| | | } |
| | | $image_url = wp_get_attachment_image_url($imgID, 'full'); |
| | | |
| | | $media_data = [ |
| | | 'mediaFormat' => 'PHOTO', |
| | | 'locationAssociation' => [ |
| | | 'category' => $category |
| | | ], |
| | | 'sourceUrl' => $image_url |
| | | ]; |
| | | $response = $this->makeRequest( |
| | | 'POST', |
| | | "/v4/{$this->credentials['account']}/{$this->credentials['location']}/media", |
| | | $media_data, |
| | | 'posts' |
| | | ); |
| | | |
| | | if (!is_wp_error($response) && array_key_exists('name', $response)) { |
| | | $mediaKey = $this->extractMediaKey($response['name']); |
| | | $this->setImageMediaKey($imgID, $mediaKey); |
| | | return $mediaKey; |
| | | } |
| | | return null; |
| | | } catch (\Exception $e) { |
| | | $this->logError($e->getMessage(), [ |
| | | 'method' => 'uploadPhotoFromUrl' |
| | | ]); |
| | | return null; |
| | | } |
| | | } |
| | | |
| | | public function getImageMediaKey(int $imgID):string|false |
| | | { |
| | | $mediaKey = get_post_meta($imgID, BASE.'gmb_mediakey', true); |
| | | if ($mediaKey !== '') { |
| | | return $this->testGMBImage($mediaKey); |
| | | } |
| | | return false; |
| | | } |
| | | public function testGMBImage(string $mediaKey):string|false |
| | | { |
| | | $existing = $this->getLocationMedia(); |
| | | $exists = array_filter($existing, function ($item) use ($mediaKey) { |
| | | return $this->extractMediaKey($item['name']) === $mediaKey; |
| | | }); |
| | | return (empty($exists)) ? false : $mediaKey; |
| | | } |
| | | public function setImageMediaKey(int $imgID, string $key):bool |
| | | { |
| | | return update_post_meta($imgID, BASE.'gmb_mediakey', $key); |
| | | } |
| | | /*************************************************************** |
| | | CREDENTIALS |
| | | ***************************************************************/ |
| | | /** |
| | | * Get user's GMB accounts |
| | | */ |
| | | public function getAccounts(bool $force = false): array |
| | | { |
| | | $ttl = 7 * 24 * 60 * 60; // week in seconds |
| | | $response = $this->getRequest('/v1/accounts', [], 'accounts', $ttl, $force)??[]; |
| | | |
| | | if (isset($response['accounts']) && is_array($response['accounts'])) { |
| | | return $response['accounts']; |
| | | } |
| | | |
| | | if (is_array($response) && isset($response['name'])) { |
| | | return [$response]; |
| | | } |
| | | |
| | | return []; |
| | | } |
| | | |
| | | /** |
| | | * Get reviews for the current location |
| | | * @param int $page_size Number of reviews to fetch (max 50) |
| | | * @return array|null |
| | | */ |
| | | public function getReviews(int $page_size = 5): ?array |
| | | { |
| | | $this->ensureInitialized(); |
| | | if (!$this->location) { |
| | | throw new \Exception('No location selected'); |
| | | } |
| | | if (!$this->account_id) { |
| | | throw new \Exception('No account configured'); |
| | | } |
| | | |
| | | $location = $this->getSelectedLocationResourceName(); |
| | | $account = $this->account_id; |
| | | |
| | | |
| | | // Check cache first (weekly refresh = 604800 seconds) |
| | | $cache_key = ['reviews', $location, $page_size]; |
| | | $cached = $this->cache->get($cache_key); |
| | | if ($cached !== false) { |
| | | return $cached; |
| | | } |
| | | |
| | | try { |
| | | // Reviews endpoint from My Business Account Management API |
| | | $response = $this->getRequest( |
| | | "/{$account}/{$location}/reviews", |
| | | [ |
| | | 'orderBy' => 'updateTime desc' |
| | | ], |
| | | 'v4' |
| | | ); |
| | | |
| | | $reviews = $response ?? []; |
| | | |
| | | // Cache for 1 week (604800 seconds) |
| | | $this->cache->set($cache_key, $reviews, WEEK_IN_SECONDS); |
| | | |
| | | return $reviews; |
| | | |
| | | } catch (\Exception $e) { |
| | | $this->logError($e->getMessage(), [ |
| | | 'method' => 'getReviews' |
| | | ]); |
| | | return null; |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Get the URL to view all Google reviews for the current location |
| | | * @return string|null The reviews viewing URL or null if not available |
| | | */ |
| | | public function getReviewsViewUrl(): ?string |
| | | { |
| | | $this->ensureInitialized(); |
| | | try { |
| | | $location = $this->getLocation(); |
| | | |
| | | if (empty($location)) { |
| | | return null; |
| | | } |
| | | |
| | | // Prefer maps URL as it shows all reviews directly |
| | | if (!empty($location['metadata']['mapsUrl'])) { |
| | | return $location['metadata']['mapsUrl']; |
| | | } |
| | | |
| | | // Fallback: construct from Place ID |
| | | if (!empty($location['metadata']['placeId'])) { |
| | | return 'https://search.google.com/local/reviews?placeid=' . |
| | | urlencode($location['metadata']['placeId']); |
| | | } |
| | | |
| | | return null; |
| | | |
| | | } catch (\Exception $e) { |
| | | $this->logError('Failed to get reviews view URL: ' . $e->getMessage(), [ |
| | | 'method' => 'getReviewsViewUrl' |
| | | ]); |
| | | return null; |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Get the URL to leave a review for the current location |
| | | * @return string|null The review URL or null if not available |
| | | */ |
| | | public function getReviewUrl(): ?string |
| | | { |
| | | $this->ensureInitialized(); |
| | | try { |
| | | $location = $this->getLocation(); |
| | | |
| | | if (empty($location)) { |
| | | return null; |
| | | } |
| | | |
| | | // Try to use Place ID for write review |
| | | if (!empty($location['metadata']['placeId'])) { |
| | | return 'https://search.google.com/local/writereview?placeid=' . |
| | | urlencode($location['metadata']['placeId']); |
| | | } |
| | | |
| | | // Fallback to maps URL |
| | | if (!empty($location['metadata']['mapsUrl'])) { |
| | | return $location['metadata']['mapsUrl'] . '/reviews'; |
| | | } |
| | | |
| | | return null; |
| | | |
| | | } catch (\Exception $e) { |
| | | $this->logError('Failed to get review URL: ' . $e->getMessage(), [ |
| | | 'method' => 'getReviewUrl' |
| | | ]); |
| | | return null; |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Get locations for an account (with persistent storage) |
| | | * Allowed Fields: https://developers.google.com/my-business/content/location-data#list_of_all_supported_filter_fields |
| | | */ |
| | | public function getLocations(?string $account_name = null, bool $force = false): array |
| | | { |
| | | if (!$this->isSetUp()) { |
| | | return []; |
| | | } |
| | | |
| | | $account_name = $account_name ?: $this->account_id; |
| | | |
| | | if (empty($account_name) || !str_starts_with($account_name, 'accounts/')) { |
| | | return []; |
| | | } |
| | | $params = ['readMask' => $this->readMask]; |
| | | $ttl = 7 * 24 * 60 * 60; // Week in seconds |
| | | |
| | | try { |
| | | $response = $this->getRequest("/v1/{$account_name}/locations", $params,'accounts', $ttl, $force); |
| | | return isset($response['locations']) ? $response['locations'] : []; |
| | | |
| | | } catch (\Exception $e) { |
| | | error_log('[GMB] Error getting locations for ' . $account_name . ': ' . $e->getMessage()); |
| | | return []; |
| | | } |
| | | } |
| | | |
| | | public function handleRefreshAccessToken():WP_Error|array |
| | | { |
| | | if ($this->refreshOAuthToken()) { |
| | | return [ |
| | | 'success' => true, |
| | | 'message' => 'Successfully refreshed OAuth Token' |
| | | ]; |
| | | } |
| | | return new WP_Error('failure', 'Failed to Refresh token'); |
| | | } |
| | | |
| | | /** |
| | | * Manually refresh accounts and locations data from API |
| | | */ |
| | | public function refreshStoredData(): array |
| | | { |
| | | try { |
| | | // Fetch fresh accounts data from API |
| | | $accounts = $this->getAccounts(true); |
| | | if (!empty($accounts)) { |
| | | // Fetch and store locations for each account |
| | | $locationsProcessed = 0; |
| | | $accountsProcessed = count($accounts); |
| | | foreach ($accounts as $account) { |
| | | $locations = $this->getLocations($account['name'], true); |
| | | if (!empty($locations)) { |
| | | $locationsProcessed += count($locations); |
| | | } |
| | | } |
| | | |
| | | return [ |
| | | 'success' => true, |
| | | 'accounts' => $accountsProcessed, |
| | | 'locations' => $locationsProcessed, |
| | | 'message' => sprintf( |
| | | 'Successfully refreshed %d accounts with %d total locations', |
| | | $accountsProcessed, |
| | | $locationsProcessed |
| | | ) |
| | | ]; |
| | | } |
| | | |
| | | return [ |
| | | 'success' => false, |
| | | 'message' => 'No accounts found' |
| | | ]; |
| | | |
| | | } catch (\Exception $e) { |
| | | error_log('[GMB] Error refreshing stored data: ' . $e->getMessage()); |
| | | return [ |
| | | 'success' => false, |
| | | 'message' => 'Error: ' . $e->getMessage() |
| | | ]; |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Get a specific location details |
| | | */ |
| | | public function getLocation(?string $location_name = null, bool $force = false):?array |
| | | { |
| | | if (!$this->isSetUp()) { |
| | | return null; |
| | | } |
| | | $location_name = $location_name?:$this->credentials['location']; |
| | | if (empty($location_name)) { |
| | | error_log('[GMB] No location name provided for getLocation'); |
| | | return null; |
| | | } |
| | | |
| | | $params = [ |
| | | 'readMask' => $this->readMask |
| | | ]; |
| | | $endpoint = "/v1/{$location_name}?".http_build_query($params); |
| | | |
| | | $location = $this->getRequest($endpoint, [], 'base', 'moderate', $force); |
| | | |
| | | return $location??null; |
| | | } |
| | | |
| | | /** |
| | | * Get the currently selected location |
| | | */ |
| | | public function getSelectedLocation(): ?string |
| | | { |
| | | return $this->credentials['location']; |
| | | } |
| | | |
| | | /** |
| | | * Set the selected location |
| | | */ |
| | | public function setSelectedLocation(string $location): bool |
| | | { |
| | | $this->credentials['location'] = $location; |
| | | |
| | | if (preg_match('/accounts\/([^\/]+)\//', $location, $matches)) { |
| | | $this->account_id = 'accounts/' . $matches[1]; |
| | | } |
| | | |
| | | $credentials = $this->credentials; |
| | | $credentials['location'] = $location; |
| | | $credentials['account'] = $this->account_id; |
| | | |
| | | |
| | | return $this->saveCredentials($credentials)['success']; |
| | | } |
| | | |
| | | /************************************************************* |
| | | RENDER |
| | | *************************************************************/ |
| | | /** |
| | | * Render business list for connected users |
| | | */ |
| | | private function renderBusinessList(): void |
| | | { |
| | | try { |
| | | $accounts = $this->getAccounts(); |
| | | if (empty($accounts)) { |
| | | echo '<p>No business accounts found.</p>'; |
| | | return; |
| | | } |
| | | ?> |
| | | <div class="gmb-business-list"> |
| | | <h4>Your Business Locations</h4> |
| | | <?php |
| | | foreach ($accounts as $account) { |
| | | $locations = $this->getLocations($account['name']); |
| | | foreach ($locations as $location) { |
| | | ?> |
| | | <div class="business-location"> |
| | | <strong><?php echo esc_html($location['title']); ?></strong> |
| | | <?php if ($this->isSelectedLocation($location)): ?> |
| | | <span class="selected-badge">✓ Selected</span> |
| | | <?php endif; ?> |
| | | </div> |
| | | <?php |
| | | } |
| | | } |
| | | ?> |
| | | </div> |
| | | <?php |
| | | } catch (\Exception $e) { |
| | | echo '<p>Error loading business locations.</p>'; |
| | | } |
| | | } |
| | | |
| | | private function isSelectedLocation(array $location): bool |
| | | { |
| | | if (empty($this->credentials['location'])) { |
| | | return false; |
| | | } |
| | | |
| | | return ($location['name'] === $this->credentials['location']); |
| | | } |
| | | |
| | | /*********************************************************** |
| | | UTILITY |
| | | ***********************************************************/ |
| | | /** |
| | | * Helper method to strip HTML tags from text |
| | | * Preserves plain text content while removing all HTML formatting |
| | | */ |
| | | protected function stripHtmlTags(string $text): string |
| | | { |
| | | // Remove HTML tags |
| | | $text = wp_strip_all_tags($text); |
| | | |
| | | // Decode HTML entities |
| | | $text = html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8'); |
| | | |
| | | // Clean up whitespace |
| | | $text = preg_replace('/\s+/', ' ', $text); |
| | | |
| | | // Trim |
| | | return trim($text); |
| | | } |
| | | /** |
| | | * Format date for GMB API |
| | | */ |
| | | protected function formatDate(string $date): array |
| | | { |
| | | $timestamp = strtotime($date); |
| | | return [ |
| | | 'year' => (int)date('Y', $timestamp), |
| | | 'month' => (int)date('n', $timestamp), |
| | | 'day' => (int)date('j', $timestamp) |
| | | ]; |
| | | } |
| | | /** |
| | | * Format time for Google My Business API (HH:MM format) |
| | | */ |
| | | private function formatTime(string $time): ?array |
| | | { |
| | | if (empty($time)) { |
| | | return null; |
| | | } |
| | | |
| | | // Remove any extra whitespace |
| | | $time = trim($time); |
| | | |
| | | // Handle different time formats and convert to 24-hour format |
| | | if (preg_match('/^(\d{1,2}):(\d{2})(?:\s*(AM|PM))?$/i', $time, $matches)) { |
| | | $hour = (int)$matches[1]; |
| | | $minute = (int)$matches[2]; |
| | | $ampm = isset($matches[3]) ? strtoupper($matches[3]) : ''; |
| | | |
| | | // Convert 12-hour to 24-hour format if needed |
| | | if ($ampm === 'PM' && $hour !== 12) { |
| | | $hour += 12; |
| | | } elseif ($ampm === 'AM' && $hour === 12) { |
| | | $hour = 0; |
| | | } |
| | | |
| | | // Validate hour and minute ranges |
| | | if ($hour >= 0 && $hour <= 23 && $minute >= 0 && $minute <= 59) { |
| | | return [ |
| | | 'hours' => $hour, |
| | | 'minutes' => $minute |
| | | ]; |
| | | } |
| | | } |
| | | |
| | | error_log('[GMB] Invalid time format: ' . $time); |
| | | return null; |
| | | } |
| | | |
| | | |
| | | |
| | | /***************************************************************************** |
| | | * |
| | | * OAUTH IMPLEMENTATION |
| | | * |
| | | *****************************************************************************/ |
| | | private function getScopes(): string |
| | | { |
| | | return implode(' ', [ |
| | | 'https://www.googleapis.com/auth/business.manage', |
| | | 'https://www.googleapis.com/auth/userinfo.email', |
| | | 'https://www.googleapis.com/auth/userinfo.profile' |
| | | ]); |
| | | } |
| | | /** |
| | | * Handle account selection |
| | | */ |
| | | public function handleSelectAccount($data = null): array |
| | | { |
| | | try { |
| | | $account_name = null; |
| | | |
| | | // Extract account name from various sources |
| | | if (is_array($data)) { |
| | | $account_name = $data['account'] ?? $data['account'] ?? null; |
| | | } elseif (isset($_POST['account'])) { |
| | | $account_name = sanitize_text_field($_POST['account']); |
| | | } |
| | | |
| | | if (empty($account_name)) { |
| | | return [ |
| | | 'success' => false, |
| | | 'message' => 'No Account Selected' |
| | | ]; |
| | | } |
| | | |
| | | // Get account details |
| | | $accounts = $this->getAccounts(); |
| | | $selected_account = null; |
| | | |
| | | foreach ($accounts as $account) { |
| | | if ($account['name'] === $account_name) { |
| | | $selected_account = $account; |
| | | break; |
| | | } |
| | | } |
| | | |
| | | if (!$selected_account) { |
| | | throw new \Exception('Invalid account selected'); |
| | | } |
| | | |
| | | // Update credentials with selected account |
| | | $this->credentials['account'] = $account_name; |
| | | $this->credentials['selected_account_id'] = $selected_account['accountId'] ?? ''; |
| | | |
| | | $this->saveCredentials($this->credentials); |
| | | |
| | | return [ |
| | | 'success' => true, |
| | | 'message' => 'Account selected successfully', |
| | | 'reload' => true |
| | | ]; |
| | | |
| | | } catch (\Exception $e) { |
| | | $this->logError('Failed to select account', [ |
| | | 'error' => $e->getMessage(), |
| | | 'data' => $data |
| | | ]); |
| | | |
| | | return [ |
| | | 'success' => false, |
| | | 'message' => $e->getMessage() |
| | | ]; |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Handle location selection |
| | | */ |
| | | public function handleSelectLocation($data = null): array |
| | | { |
| | | try { |
| | | $location_name = null; |
| | | |
| | | // Extract location name from various sources |
| | | if (is_array($data)) { |
| | | $location_name = $data['location'] ?? $data['location'] ?? null; |
| | | } elseif (isset($_POST['location'])) { |
| | | $location_name = sanitize_text_field($_POST['location']); |
| | | } |
| | | |
| | | if (empty($location_name)) { |
| | | throw new \Exception('No location selected'); |
| | | } |
| | | |
| | | // Verify this is a valid location for the selected account |
| | | if (empty($this->credentials['account'])) { |
| | | throw new \Exception('No account selected. Please select an account first.'); |
| | | } |
| | | |
| | | $locations = $this->getLocations($this->credentials['account']); |
| | | $valid_location = false; |
| | | |
| | | foreach ($locations as $location) { |
| | | if ($location['name'] === $location_name) { |
| | | $valid_location = true; |
| | | $this->credentials['location_id'] = $location['locationId'] ?? ''; |
| | | break; |
| | | } |
| | | } |
| | | |
| | | if (!$valid_location) { |
| | | throw new \Exception('Invalid location selected'); |
| | | } |
| | | |
| | | // Update credentials with selected location |
| | | $this->credentials['location'] = $location_name; |
| | | |
| | | // Save updated credentials |
| | | $this->saveCredentials($this->credentials); |
| | | |
| | | // Initialize with new location |
| | | $this->credentials['location'] = $location_name; |
| | | |
| | | return [ |
| | | 'success' => true, |
| | | 'message' => 'Location selected successfully', |
| | | 'reload' => false // No need to reload for location selection |
| | | ]; |
| | | |
| | | } catch (\Exception $e) { |
| | | $this->logError('Failed to select location', [ |
| | | 'error' => $e->getMessage(), |
| | | 'data' => $data |
| | | ]); |
| | | |
| | | return [ |
| | | 'success' => false, |
| | | 'message' => $e->getMessage() |
| | | ]; |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Check OAuth status |
| | | */ |
| | | public function checkOAuthStatus(): array |
| | | { |
| | | try { |
| | | $valid = $this->isOAuthValid(); |
| | | |
| | | return [ |
| | | 'success' => true, |
| | | 'valid' => $valid, |
| | | 'expires_at' => $this->credentials['expires_at'] ?? null, |
| | | 'has_refresh_token' => !empty($this->credentials['refresh_token']) |
| | | ]; |
| | | |
| | | } catch (\Exception $e) { |
| | | return [ |
| | | 'success' => false, |
| | | 'valid' => false, |
| | | 'message' => $e->getMessage() |
| | | ]; |
| | | } |
| | | } |
| | | |
| | | |
| | | /******************************************************************** |
| | | * |
| | | * ADMIN UI |
| | | * |
| | | ********************************************************************/ |
| | | /** |
| | | * Updated renderOAuthConnectedOptions for better UI |
| | | */ |
| | | protected function renderOAuthConnectedOptions(): void |
| | | { |
| | | if (!$this->isSetUp()) { |
| | | return; |
| | | } |
| | | |
| | | // Check OAuth status |
| | | $oauth_valid = $this->isOAuthValid(); |
| | | |
| | | if (!$oauth_valid) { |
| | | ?> |
| | | <div class="oauth-status"> |
| | | <span class="status-invalid">⚠️ OAuth token expired - please reconnect</span> |
| | | </div> |
| | | <?php |
| | | } |
| | | |
| | | // Show account selection |
| | | $accounts = $this->getAccounts(); |
| | | if (!empty($accounts)) { |
| | | ?> |
| | | <div class="form-field" data-service="gmb"> |
| | | <label for="gmb_account">Google Business Account</label> |
| | | <select name="account" |
| | | id="gmb_account" |
| | | class="jvb-ajax-update" |
| | | data-action="select_account"> |
| | | <option value="">Select an account...</option> |
| | | <?php foreach ($accounts as $account): ?> |
| | | <option value="<?php echo esc_attr($account['name']); ?>" |
| | | <?php selected($credentials['account'] ?? '', $account['name']); ?>> |
| | | <?php echo esc_html($account['accountName'] ?? $account['name']); ?> |
| | | </option> |
| | | <?php endforeach; ?> |
| | | </select> |
| | | </div> |
| | | <?php |
| | | |
| | | // Show location selection if account is selected |
| | | if (!empty($this->credentials['account'])) { |
| | | $locations = $this->getLocations($this->credentials['account']); |
| | | |
| | | if (!empty($locations)) { |
| | | ?> |
| | | <div class="form-field location-field visible" data-service="gmb"> |
| | | <label for="gmb_location">Business Location</label> |
| | | <select name="location" |
| | | id="gmb_location" |
| | | class="jvb-ajax-update" |
| | | data-action="select_location"> |
| | | <option value="">Select a location...</option> |
| | | <?php foreach ($locations as $location): ?> |
| | | <option value="<?php echo esc_attr($location['name']); ?>" |
| | | <?php selected($this->credentials['location'] ?? '', $location['name']); ?>> |
| | | <?php echo esc_html($location['title'] ?? $location['name']); ?> |
| | | </option> |
| | | <?php endforeach; ?> |
| | | </select> |
| | | </div> |
| | | <?php |
| | | } |
| | | } |
| | | |
| | | // Show current selection status |
| | | if (!empty($this->credentials['location'])) { |
| | | ?> |
| | | <div class="connection-status connected"> |
| | | ✅ Connected to: <?php echo esc_html($this->credentials['location']); ?> |
| | | </div> |
| | | <?php |
| | | } |
| | | } else { |
| | | ?> |
| | | <div class="notice notice-warning"> |
| | | <p>No Google Business accounts found. Please ensure your Google account has access to Google My Business.</p> |
| | | </div> |
| | | <?php |
| | | } |
| | | } |
| | | |
| | | public function getServiceDescription(): string |
| | | { |
| | | return "Manage your Google My Business listings, posts, hours, and reviews."; |
| | | } |
| | | |
| | | /***************************************************************************** |
| | | * |
| | | * FOOD MENU IMPLEMENTATION |
| | | * |
| | | *****************************************************************************/ |
| | | |
| | | /** |
| | | * Get food menus for the current location |
| | | */ |
| | | public function getFoodMenus(): ?array |
| | | { |
| | | if (!$this->credentials['location']) { |
| | | throw new \Exception('No location selected. Please select a location first.'); |
| | | } |
| | | |
| | | $response = $this->getRequest( |
| | | "/v4/{$this->credentials['location']}/foodMenu", |
| | | [], |
| | | 'posts' |
| | | ); |
| | | return $response['foodMenus'] ?? []; |
| | | } |
| | | |
| | | /** |
| | | * Update food menus for the current location |
| | | */ |
| | | public function updateFoodMenus(array $menus): bool |
| | | { |
| | | if (!$this->credentials['location']) { |
| | | throw new \Exception('No location selected. Please select a location first.'); |
| | | } |
| | | |
| | | try { |
| | | $food_menus = ['menus' => $menus]; |
| | | |
| | | $endpoint = "/{$this->credentials['account']}/{$this->credentials['location']}/foodMenus"; |
| | | |
| | | // Use dedicated Food Menu API |
| | | $response = $this->makeRequest( |
| | | 'PATCH', |
| | | $endpoint, |
| | | $food_menus, |
| | | 'v4' |
| | | ); |
| | | |
| | | $this->logDebug('[GMB]Finished updating food menu', [ |
| | | 'method' => 'updateFoodMenus', |
| | | 'response' => $response, |
| | | ]); |
| | | return !empty($response); |
| | | |
| | | } catch (\Exception $e) { |
| | | $this->logError($e->getMessage(), [ |
| | | 'method' => 'updateFoodMenus' |
| | | ]); |
| | | return false; |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Upload menu images and return a map of identifiers to mediaKeys |
| | | */ |
| | | protected function uploadAndMapMenuImages(array $menus): array |
| | | { |
| | | $mediaKeyMap = []; |
| | | |
| | | // Process all menu items for images |
| | | foreach ($menus as $menu) { |
| | | foreach ($menu['sections'] ?? [] as $section) { |
| | | // Upload section images if needed (though API doesn't support section images yet) |
| | | if (!empty($section['image']) && is_numeric($section['image'])) { |
| | | $mediaKey = $this->uploadImageToGMB($section['image'], 'FOOD_AND_DRINK'); |
| | | if ($mediaKey) { |
| | | $mediaKeyMap['section_' . $section['id']] = $mediaKey; |
| | | } |
| | | } |
| | | |
| | | // Process menu items |
| | | foreach ($section['items'] ?? [] as $item) { |
| | | // Upload featured image |
| | | if (!empty($item['post_id'])) { |
| | | $featured_image_id = get_post_thumbnail_id($item['post_id']); |
| | | if ($featured_image_id) { |
| | | $mediaKey = $this->uploadImageToGMB($featured_image_id, 'FOOD_AND_DRINK'); |
| | | if ($mediaKey) { |
| | | $mediaKeyMap['item_' . $item['post_id']] = $mediaKey; |
| | | } |
| | | } |
| | | |
| | | // Upload gallery images if available |
| | | if (!empty($item['gallery_ids'])) { |
| | | foreach ($item['gallery_ids'] as $gallery_id) { |
| | | $mediaKey = $this->uploadImageToGMB($gallery_id, 'FOOD_AND_DRINK'); |
| | | if ($mediaKey) { |
| | | $mediaKeyMap['gallery_'.$gallery_id] = $mediaKey; |
| | | } |
| | | } |
| | | } |
| | | } |
| | | } |
| | | } |
| | | } |
| | | |
| | | return $mediaKeyMap; |
| | | } |
| | | |
| | | /** |
| | | * Extract mediaKey from the full media resource name |
| | | */ |
| | | protected function extractMediaKey(string $mediaResourceName): string |
| | | { |
| | | // Extract mediaKey from format: accounts/{accountId}/locations/{locationId}/media/{mediaKey} |
| | | $parts = explode('/media/', $mediaResourceName); |
| | | return isset($parts[1]) ? $parts[1] : ''; |
| | | } |
| | | |
| | | /** |
| | | * Get all media items for the current location |
| | | */ |
| | | public function getLocationMedia(): array |
| | | { |
| | | if (!$this->credentials['location']) { |
| | | throw new \Exception('No location selected. Please select a location first.'); |
| | | } |
| | | |
| | | try { |
| | | $response = $this->getRequest( |
| | | "/{$this->credentials['account']}/{$this->credentials['location']}/media", |
| | | [], |
| | | 'v4' |
| | | ); |
| | | |
| | | return $response['mediaItems'] ?? []; |
| | | } catch (\Exception $e) { |
| | | $this->logError('Failed to get location media: ' . $e->getMessage()); |
| | | return []; |
| | | } |
| | | } |
| | | |
| | | public function handleGetAllLocations():WP_Error|array |
| | | { |
| | | try { |
| | | // Check if we should force refresh |
| | | $force = isset($_POST['force']) && $_POST['force'] === 'true'; |
| | | |
| | | // Get accounts (use cache unless forced) |
| | | $accounts = $this->getAccounts($force); |
| | | $all_locations = []; |
| | | |
| | | foreach ($accounts as $account) { |
| | | // Get locations for each account (use cache unless forced) |
| | | $locations = $this->getLocations($account['name'], $force); |
| | | $all_locations = array_merge($all_locations, $locations); |
| | | } |
| | | |
| | | return [ |
| | | 'success' => true, |
| | | 'locations' => $all_locations, |
| | | 'cached' => !$force |
| | | ]; |
| | | |
| | | } catch (\Exception $e) { |
| | | return new WP_Error('failure', 'Something went wrong: '.$e->getMessage()); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * AJAX handler for updating selected location |
| | | */ |
| | | public function handleUpdateLocation($data = null): array |
| | | { |
| | | try { |
| | | // Handle both AJAX and REST API calls |
| | | $selected_account = null; |
| | | |
| | | if (is_array($data)) { |
| | | $selected_account = $data['account'] ?? $data['location'] ?? null; |
| | | } elseif (isset($_POST['account'])) { |
| | | $selected_account = sanitize_text_field($_POST['account']); |
| | | } |
| | | |
| | | if (empty($selected_account)) { |
| | | throw new \Exception('No account selected'); |
| | | } |
| | | |
| | | // Save the selected account |
| | | $credentials = $this->credentials; |
| | | $credentials['account'] = $selected_account; |
| | | |
| | | // Get locations for this account |
| | | $locations = $this->getLocations($selected_account); |
| | | |
| | | // Auto-select first location if only one exists |
| | | if (count($locations) === 1) { |
| | | $credentials['location'] = $locations[0]['name']; |
| | | } |
| | | |
| | | $this->saveCredentials($credentials); |
| | | |
| | | return [ |
| | | 'success' => true, |
| | | 'message' => 'Account selected successfully', |
| | | 'locations' => $locations, |
| | | 'reload' => true // Trigger page refresh to show location dropdown |
| | | ]; |
| | | |
| | | } catch (\Exception $e) { |
| | | $this->logError('Failed to update location', [ |
| | | 'error' => $e->getMessage(), |
| | | 'data' => $data |
| | | ]); |
| | | |
| | | return [ |
| | | 'success' => false, |
| | | 'message' => $e->getMessage() |
| | | ]; |
| | | } |
| | | } |
| | | |
| | | public function clearStoredData(): bool |
| | | { |
| | | try { |
| | | // Use the static method to clear the entire cache group |
| | | $this->cache->flush(); |
| | | return true; |
| | | |
| | | } catch (\Exception $e) { |
| | | error_log('[GMB] Error clearing stored data: ' . $e->getMessage()); |
| | | return false; |
| | | } |
| | | } |
| | | |
| | | public function getPriceLists(): ?array |
| | | { |
| | | if (!$this->credentials['location']) { |
| | | throw new \Exception('No location selected. Please select a location first.'); |
| | | } |
| | | |
| | | try { |
| | | $location_data = $this->getLocation($this->credentials['location']); |
| | | return $location_data['priceLists'] ?? []; |
| | | } catch (\Exception $e) { |
| | | |
| | | $this->logError($e->getMessage(), [ |
| | | 'method' => 'getPriceLists' |
| | | ]); |
| | | return null; |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Get insights/analytics |
| | | */ |
| | | public function getInsights(string $location_name, string $start_date, string $end_date): ?array |
| | | { |
| | | // From the Docs: |
| | | // Basic insights |
| | | // Retrieves basic insights for a given list of locations. Use the accounts.locations.reportInsights API to return the insights that are associated with a location. |
| | | // |
| | | // To return the basic insights associated with a location, use the following: |
| | | // |
| | | // HTTP |
| | | // |
| | | // POST |
| | | // https://mybusiness.googleapis.com/v4/accounts/{accountId}/locations:reportInsights |
| | | // { |
| | | // "locationNames": [ |
| | | // "accounts/{accountId}/locations/{locationId}" |
| | | // ], |
| | | // "basicRequest": { |
| | | // "metricRequests": [ |
| | | // { |
| | | // "metric": "QUERIES_DIRECT" |
| | | // }, |
| | | // { |
| | | // "metric": "QUERIES_INDIRECT" |
| | | // } |
| | | // ], |
| | | // "timeRange": { |
| | | // "startTime": "2016-10-12T01:01:23.045123456Z", |
| | | // "endTime": "2017-01-10T23:59:59.045123456Z" |
| | | // } |
| | | // } |
| | | // } |
| | | |
| | | $params = [ |
| | | 'dailyMetrics' => 'BUSINESS_IMPRESSIONS_DESKTOP_MAPS,BUSINESS_IMPRESSIONS_DESKTOP_SEARCH,BUSINESS_IMPRESSIONS_MOBILE_MAPS,BUSINESS_IMPRESSIONS_MOBILE_SEARCH,BUSINESS_DIRECTION_REQUESTS,CALL_CLICKS,WEBSITE_CLICKS', |
| | | 'dailyRange.startDate.year' => date('Y', strtotime($start_date)), |
| | | 'dailyRange.startDate.month' => date('n', strtotime($start_date)), |
| | | 'dailyRange.startDate.day' => date('j', strtotime($start_date)), |
| | | 'dailyRange.endDate.year' => date('Y', strtotime($end_date)), |
| | | 'dailyRange.endDate.month' => date('n', strtotime($end_date)), |
| | | 'dailyRange.endDate.day' => date('j', strtotime($end_date)) |
| | | ]; |
| | | |
| | | return $this->getRequest( |
| | | "/v1/{$location_name}:fetchMultiDailyMetricsTimeSeries?" . http_build_query($params), |
| | | $params, |
| | | 'performance' |
| | | ); |
| | | } |
| | | |
| | | /** |
| | | * Helper: Format hours for GMB API |
| | | */ |
| | | private function formatHoursPeriods(array $hours): array |
| | | { |
| | | $periods = []; |
| | | $days = ['SUNDAY', 'MONDAY', 'TUESDAY', 'WEDNESDAY', 'THURSDAY', 'FRIDAY', 'SATURDAY']; |
| | | |
| | | foreach ($hours as $day => $times) { |
| | | if (empty($times['open']) || empty($times['close'])) { |
| | | continue; |
| | | } |
| | | |
| | | $periods[] = [ |
| | | 'openDay' => $days[$day] ?? $day, |
| | | 'openTime' => $times['open'], // Format: "09:00" |
| | | 'closeDay' => $days[$day] ?? $day, |
| | | 'closeTime' => $times['close'] // Format: "17:00" |
| | | ]; |
| | | } |
| | | |
| | | return $periods; |
| | | } |
| | | |
| | | |
| | | /** |
| | | * HELPER: Easy interface method for creating a simple post |
| | | * JVB()->connect('gmb')->createSimplePost("Check out our new menu!", "https://example.com/menu") |
| | | */ |
| | | public function createSimplePost(string $message, ?string $cta_url = null, string $cta_type = 'LEARN_MORE'): ?array |
| | | { |
| | | $location_name = $this->getSelectedLocationResourceName(); |
| | | |
| | | if (empty($location_name)) { |
| | | throw new \Exception('No location selected. Please select a location first.'); |
| | | } |
| | | |
| | | $post_data = [ |
| | | 'summary' => $message, |
| | | 'topic_type' => 'STANDARD' |
| | | ]; |
| | | |
| | | if ($cta_url) { |
| | | $post_data['call_to_action'] = [ |
| | | 'type' => $cta_type, |
| | | 'url' => $cta_url |
| | | ]; |
| | | } |
| | | |
| | | return $this->createPost($location_name, $post_data); |
| | | } |
| | | |
| | | /** |
| | | * HELPER: Easy interface method for updating basic business info |
| | | * JVB()->connect('gmb')->updateInfo(['title' => 'New Business Name', 'websiteUri' => 'https://newsite.com']) |
| | | */ |
| | | public function updateInfo(array $updates): bool |
| | | { |
| | | $location_name = $this->getSelectedLocationResourceName(); |
| | | |
| | | if (empty($location_name)) { |
| | | throw new \Exception('No location selected. Please select a location first.'); |
| | | } |
| | | |
| | | return $this->updateBusinessInfo($location_name, $updates); |
| | | } |
| | | |
| | | |
| | | /** |
| | | * HELPER: Easy interface for clearing special hours |
| | | * JVB()->connect('gmb')->clearAllSpecialHours() |
| | | */ |
| | | public function clearAllSpecialHours(): bool |
| | | { |
| | | $location_name = $this->getSelectedLocationResourceName(); |
| | | |
| | | if (empty($location_name)) { |
| | | throw new \Exception('No location selected. Please select a location first.'); |
| | | } |
| | | |
| | | return $this->clearSpecialHours($location_name); |
| | | } |
| | | |
| | | |
| | | protected function mappedMenuFields(string $postType):array |
| | | { |
| | | /** |
| | | * @param array $fields |
| | | * @param string $fields.name Thing |
| | | */ |
| | | return apply_filters( |
| | | 'jvbGMBFields', |
| | | [ |
| | | 'name' => 'post_title', |
| | | 'price' => 'price', |
| | | 'description' => 'post_excerpt', |
| | | 'nutritionFacts'=> 'nutritionFacts', |
| | | 'servesNumPeople'=>'servesNumPeople', |
| | | 'portionSize' => 'portionSize', |
| | | 'preparationMethods' => 'preparationMethods', |
| | | 'cuisines' => 'cuisines', |
| | | 'spiciness' => 'spiciness', |
| | | 'allergen' => 'allergen', |
| | | 'dietaryRestriction'=>'dietaryRestriction' |
| | | ], |
| | | jvbNoBase($postType)); |
| | | } |
| | | |
| | | |
| | | |
| | | /** |
| | | * Build menu structure from WordPress posts |
| | | */ |
| | | protected function collectMenu(array $menu_items): array |
| | | { |
| | | |
| | | $defaultMeta = Meta::forOptions($this->userID.'_integrations'); |
| | | $defaults = ['menu_name', 'menu_description', 'default_section', 'cuisines', 'source_url', 'language', 'default_currency']; |
| | | $defaults = $defaultMeta->getAll($defaults); |
| | | |
| | | // Group items by section |
| | | $sections_map = []; |
| | | foreach ($menu_items as $item) { |
| | | |
| | | $gmb_item = $this->buildMenuItem($item, $defaults); |
| | | |
| | | // Get section for this item |
| | | $categories = wp_get_post_terms($item->ID, BASE.'section'); |
| | | $section_slug = !empty($categories) ? |
| | | $categories[0]->slug : |
| | | 'menu-items'; |
| | | $section_name = !empty($categories) ? |
| | | $categories[0]->name : |
| | | ($defaults['default_section'] ?? 'Menu Items'); |
| | | |
| | | if (!isset($sections_map[$section_slug])) { |
| | | $sections_map[$section_slug] = [ |
| | | 'labels' => [ |
| | | 'displayName' => $section_name, |
| | | 'languageCode' => $defaults['language'] ?? 'en', |
| | | ], |
| | | 'items' => [], |
| | | ]; |
| | | if (!empty($section['description'])) { |
| | | $sections_map[$section_slug]['labels']['description'] = $this->stripHtmlTags($section['description']); |
| | | } |
| | | } |
| | | |
| | | $sections_map[$section_slug]['items'][] = $gmb_item; |
| | | } |
| | | |
| | | $gmb_sections = $this->getMenuSectionsOrder($sections_map); |
| | | |
| | | // Build complete menu |
| | | $gmb_menu = [ |
| | | 'cuisines' => $this->getMenuCuisines($menu_items), |
| | | 'labels' => [ |
| | | 'displayName' => $defaults['menu_name'] ?? 'Our Menu', |
| | | 'languageCode' => $defaults['language'] ?? 'en', |
| | | ], |
| | | 'sections' => $gmb_sections |
| | | ]; |
| | | |
| | | if (array_key_exists('menu_description', $defaults)) { |
| | | $gmb_menu['labels']['description'] = $this->stripHtmlTags($defaults['menu_description']); |
| | | } |
| | | |
| | | // Add source URL if configured |
| | | if (!empty($defaults['source_url'])) { |
| | | $gmb_menu['source_url'] = $defaults['source_url']; |
| | | } |
| | | |
| | | return [$gmb_menu]; // GMB expects an array of menus |
| | | } |
| | | |
| | | protected function buildMenuItem(\WP_Post $item, array $defaults):array |
| | | { |
| | | $meta = Meta::forPost($item->ID); |
| | | $fields = $this->mappedMenuFields($item->post_type); |
| | | $values = $meta->getAll(array_values($fields)); |
| | | |
| | | |
| | | // Build GMB-formatted item |
| | | $gmb_item = [ |
| | | 'labels' => [ |
| | | 'displayName' => $item->post_title, |
| | | 'description' => !empty($item->post_excerpt) ? |
| | | $this->stripHtmlTags($item->post_excerpt) : |
| | | $this->stripHtmlTags(wp_trim_words($item->post_content, 20)), |
| | | 'languageCode' => $defaults['language'] ?? 'en' |
| | | ], |
| | | 'attributes' => [] |
| | | ]; |
| | | |
| | | // Process mapped fields |
| | | foreach ($fields as $googleField => $wpField) { |
| | | if (in_array($wpField, ['post_title', 'post_excerpt'])) { |
| | | continue; |
| | | } |
| | | |
| | | $value = $values[$wpField] ?? null; |
| | | if (empty($value)) { |
| | | continue; |
| | | } |
| | | |
| | | // Process field based on type |
| | | $processed = $this->processMenuField($googleField, $value, $values); |
| | | if ($processed !== null) { |
| | | $gmb_item['attributes'] = array_merge($gmb_item['attributes'], $processed); |
| | | } |
| | | } |
| | | $featuredImage = get_post_thumbnail_id($item->ID); |
| | | $mediaKey = $this->uploadImageToGMB($featuredImage, 'FOOD_AND_DRINK'); |
| | | if ($mediaKey) { |
| | | $gmb_item['attributes'] = array_merge($gmb_item['attributes'], ['mediaKeys' => $mediaKey]); |
| | | } |
| | | if (empty($gmb_item['attributes'])) { |
| | | unset($gmb_item['attributes']); |
| | | } |
| | | |
| | | return $gmb_item; |
| | | } |
| | | |
| | | /** |
| | | * Process a single menu field value |
| | | */ |
| | | protected function processMenuField(string $fieldName, $value, array $allValues): ?array |
| | | { |
| | | switch ($fieldName) { |
| | | case 'price': |
| | | if (is_numeric($value)) { |
| | | return [ |
| | | 'price' => [ |
| | | 'currencyCode' => $allValues['price_currency'] ?? 'CAD', |
| | | 'units' => intval($value), |
| | | 'nanos' => (($value - intval($value)) * 1000000000) |
| | | ] |
| | | ]; |
| | | } |
| | | break; |
| | | |
| | | case 'nutritionFacts': |
| | | if (is_array($value)) { |
| | | return ['nutritionFacts' => $this->processNutritionData($value)]; |
| | | } |
| | | break; |
| | | |
| | | case 'allergen': |
| | | $allergens = $this->processListField($value); |
| | | return $allergens ? ['allergens' => $allergens] : null; |
| | | |
| | | case 'dietaryRestriction': |
| | | $restrictions = $this->processListField($value); |
| | | return $restrictions ? ['dietaryRestrictions' => $restrictions] : null; |
| | | |
| | | case 'servesNumPeople': |
| | | return ['servesNumPeople' => intval($value)]; |
| | | |
| | | case 'portionSize': |
| | | return ['portionSize' => $value]; |
| | | |
| | | case 'preparationMethods': |
| | | $methods = $this->processListField($value); |
| | | return $methods ? ['preparationMethods' => $methods] : null; |
| | | |
| | | case 'cuisines': |
| | | $cuisines = $this->processListField($value, 'strtoupper'); |
| | | return $cuisines ? ['cuisines' => $cuisines] : null; |
| | | |
| | | case 'spiciness': |
| | | return ['spiciness' => strtoupper($value)]; |
| | | |
| | | default: |
| | | return [$fieldName => $value]; |
| | | } |
| | | |
| | | return null; |
| | | } |
| | | |
| | | /** |
| | | * Process list fields (arrays or comma-separated strings) |
| | | */ |
| | | protected function processListField($value, ?callable $transform = null): ?array |
| | | { |
| | | if (empty($value)) { |
| | | return null; |
| | | } |
| | | |
| | | // Convert to array if string |
| | | if (is_string($value)) { |
| | | $value = array_map('trim', explode(',', $value)); |
| | | } |
| | | |
| | | // Filter empty values |
| | | $value = array_values(array_filter($value)); |
| | | |
| | | // Apply transformation if provided |
| | | if ($transform && !empty($value)) { |
| | | $value = array_map($transform, $value); |
| | | } |
| | | |
| | | return !empty($value) ? $value : null; |
| | | } |
| | | |
| | | protected function getMenuSectionsOrder(array $sections_map):array |
| | | { |
| | | $optionsMeta = Meta::forOptions('options'); |
| | | $sectionOrder = $optionsMeta->get('menu_section_order'); |
| | | |
| | | // Build final GMB menu structure |
| | | $ordered = []; |
| | | |
| | | // First add sections in configured order |
| | | foreach ($sectionOrder as $section) { |
| | | $slug = sanitize_title($section['name']); |
| | | if (isset($sections_map[$slug]) && !empty($sections_map[$slug]['items'])) { |
| | | $ordered[$slug] = $sections_map[$slug]; |
| | | unset($sections_map[$slug]); |
| | | } |
| | | } |
| | | |
| | | // Add any remaining sections |
| | | foreach ($sections_map as $slug => $section) { |
| | | if (!empty($section['items'])) { |
| | | $ordered[$slug] = $section; |
| | | } |
| | | } |
| | | |
| | | return array_values($ordered); |
| | | } |
| | | private function getMenuCuisines(array $menu_items, array $defaults = []): array |
| | | { |
| | | $cuisines = []; |
| | | |
| | | // Check if there's a default cuisine |
| | | if (!empty($defaults['cuisines'])) { |
| | | $cuisines = is_array($defaults['cuisines']) ? |
| | | $defaults['cuisines'] : |
| | | [$defaults['cuisines']]; |
| | | } |
| | | |
| | | // Collect cuisines from individual items if specified |
| | | foreach ($menu_items as $item) { |
| | | $meta = Meta::forPost($item->ID); |
| | | $item_cuisines = $meta->get('cuisines'); |
| | | |
| | | if (!empty($item_cuisines)) { |
| | | $item_cuisines = is_array($item_cuisines) ? |
| | | $item_cuisines : |
| | | array_map('trim', explode(',', $item_cuisines)); |
| | | |
| | | foreach ($item_cuisines as $cuisine) { |
| | | $cuisine = strtoupper(trim($cuisine)); |
| | | if (!in_array($cuisine, $cuisines)) { |
| | | $cuisines[] = $cuisine; |
| | | } |
| | | } |
| | | } |
| | | } |
| | | |
| | | // Ensure we have at least one cuisine (GMB requires it) |
| | | if (empty($cuisines)) { |
| | | $cuisines = ['AMERICAN']; |
| | | } |
| | | |
| | | return array_map('strtoupper', $cuisines); |
| | | } |
| | | |
| | | /** |
| | | * Format nutrition facts for GMB API |
| | | */ |
| | | protected function processNutritionData(array $nutritionData): array |
| | | { |
| | | $processed = []; |
| | | |
| | | // Handle both repeater format and simple array format |
| | | if (isset($nutritionData[0]) && is_array($nutritionData[0])) { |
| | | // Repeater format |
| | | foreach ($nutritionData as $fact) { |
| | | if (!empty($fact['name']) && isset($fact['amount'])) { |
| | | $processed[] = [ |
| | | 'name' => $fact['name'], |
| | | 'amount' => floatval($fact['amount']), |
| | | 'unit' => $fact['unit'] ?? 'GRAM' |
| | | ]; |
| | | } |
| | | } |
| | | } else { |
| | | // Simple associative array format |
| | | foreach ($nutritionData as $name => $value) { |
| | | if (!empty($value)) { |
| | | $unit = 'GRAM'; |
| | | if ($name === 'calories') { |
| | | $unit = 'CALORIE'; |
| | | } elseif (in_array($name, ['cholesterol', 'sodium', 'totalCarbohydrate', 'protein'])) { |
| | | $unit = 'MILLIGRAM'; |
| | | } |
| | | |
| | | $processed[] = [ |
| | | 'name' => $name, |
| | | 'amount' => floatval($value), |
| | | 'unit' => $unit |
| | | ]; |
| | | } |
| | | } |
| | | } |
| | | |
| | | return $processed; |
| | | } |
| | | |
| | | protected function setContentTypes():void |
| | | { |
| | | $this->contentTypes = [ |
| | | 'menu_item' => [ |
| | | 'nutritionFacts' => [ |
| | | 'type' => 'repeater', |
| | | 'label' => 'Nutrition', |
| | | 'fields' => [ |
| | | 'name' => [ |
| | | 'type' => 'select', |
| | | 'label' => 'Name', |
| | | 'options' => [ |
| | | 'calories' => 'Calories', |
| | | 'totalFat' => 'Total Fat', |
| | | 'cholesterol' => 'Cholesterol', |
| | | 'sodium' => 'Sodium', |
| | | 'totalCarbohydrate' => 'Total Carbohydrates', |
| | | 'protein' => 'Protein' |
| | | ] |
| | | ], |
| | | 'amount' => [ |
| | | 'type' => 'number', |
| | | 'label' => 'Amount', |
| | | ], |
| | | 'unit' => [ |
| | | 'type' => 'select', |
| | | 'label' => 'Unit', |
| | | 'options' => [ |
| | | 'MILLIGRAM' => 'Milligram', |
| | | 'GRAM' => 'Gram', |
| | | 'CALORIE' => 'Calorie' |
| | | ] |
| | | ] |
| | | ], |
| | | 'description' => 'Recommended. Example: Total Fat: 3g', |
| | | 'section' => 'gmb', |
| | | ], |
| | | 'servesNumPeople' => [ |
| | | 'type' => 'number', |
| | | 'label' => 'Number of People this Serves', |
| | | 'bulkEdit' => true, |
| | | 'section' => 'gmb', |
| | | ], |
| | | 'portionSize' => [ |
| | | 'type' => 'text', |
| | | 'label' => 'Portion size', |
| | | 'description' => 'Example: 8-piece of nuggets', |
| | | 'bulkEdit' => true, |
| | | 'section' => 'gmb', |
| | | ], |
| | | 'preparationMethods' => [ |
| | | 'type' => 'textarea', |
| | | 'label' => 'Preparation Methods', |
| | | 'description' => 'Specific methods that the food can be prepared in', |
| | | 'bulkEdit' => true, |
| | | 'section' => 'gmb', |
| | | ], |
| | | 'cuisines' => [ |
| | | 'type' => 'text', |
| | | 'label' => 'Cuisines', |
| | | 'description' => 'Recommended. The specific cuisine of the food item', |
| | | 'bulkEdit' => true, |
| | | 'section' => 'gmb', |
| | | ], |
| | | 'spiciness' => [ |
| | | 'type' => 'select', |
| | | 'label' => 'Spiciness', |
| | | 'options' => [ |
| | | 'none' => 'None', |
| | | 'mild' => 'Mild', |
| | | 'medium' => 'Medium', |
| | | 'hot' => 'Hot', |
| | | ], |
| | | 'bulkEdit' => true, |
| | | 'section' => 'gmb', |
| | | ], |
| | | 'allergen' => [ |
| | | 'type' => 'checkbox', |
| | | 'label' => 'Allergens', |
| | | 'options' => [ |
| | | 'dairy' => 'Dairy', |
| | | 'egg' => 'Egg', |
| | | 'fish' => 'Fish', |
| | | 'peanut' => 'Peanut', |
| | | 'shellfish' => 'Shellfish', |
| | | 'soy' => 'Soy', |
| | | 'tree nut' => 'Tree Nut', |
| | | 'wheat' => 'Wheat' |
| | | ], |
| | | 'description' => 'Recommended', |
| | | 'bulkEdit' => true, |
| | | 'section' => 'gmb', |
| | | ], |
| | | 'dietaryRestriction' => [ |
| | | 'type' => 'checkbox', |
| | | 'label' => 'Dietary Restrictions', |
| | | 'description' => 'Recommended.', |
| | | 'options' => [ |
| | | 'halal' => 'Halal', |
| | | 'kosher' => 'Kosher', |
| | | 'organic' => 'Organic', |
| | | 'vegan' => 'Vegan', |
| | | 'vegetarian' => 'Vegetarian', |
| | | 'gluten-free' => 'Gluten Free' |
| | | ], |
| | | 'bulkEdit' => true, |
| | | 'section' => 'gmb', |
| | | ] |
| | | ], |
| | | 'post' => [ |
| | | 'summary' => [ |
| | | 'type' => 'textarea', |
| | | 'label' => 'Post Summary', |
| | | 'required' => true |
| | | ], |
| | | 'language_code' => [ |
| | | 'type' => 'text', |
| | | 'label' => 'Language', |
| | | 'default' => 'en-CA' |
| | | ], |
| | | 'media' => [ |
| | | 'type' => 'image', |
| | | 'label' => 'Photo', |
| | | ], |
| | | 'call_to_action' => [ |
| | | 'type' => 'group', |
| | | 'fields' => [ |
| | | 'type' => [ |
| | | 'type' => 'select', |
| | | 'options' => |
| | | [ |
| | | 'BOOK' => 'Book Now', |
| | | 'ORDER' => 'Order Now', |
| | | 'SHOP' => 'Shop Now', |
| | | 'LEARN_MORE' => 'Learn More', |
| | | 'SIGN_UP' => 'Sign up', |
| | | 'CALL' => 'Call' |
| | | ] |
| | | ], |
| | | 'url' => [ |
| | | 'type' => 'url', |
| | | 'label' => 'Action URL' |
| | | ] |
| | | ] |
| | | ] |
| | | ], |
| | | 'event' => [ |
| | | 'summary' => [ |
| | | 'type' => 'textarea', |
| | | 'label' => 'Event Description', |
| | | 'required' => true |
| | | ], |
| | | 'event' => [ |
| | | 'title' => [ |
| | | 'type' => 'text', |
| | | 'label' => 'Event Title', |
| | | 'required' => true |
| | | ], |
| | | 'start_date' => [ |
| | | 'type' => 'date', |
| | | 'label' => 'Start Date', |
| | | 'required' => true |
| | | ], |
| | | 'end_date' => [ |
| | | 'type' => 'date', |
| | | 'label' => 'End Date' |
| | | ], |
| | | 'start_time' => [ |
| | | 'type' => 'time', |
| | | 'label' => 'Start Time' |
| | | ], |
| | | 'end_time' => [ |
| | | 'type' => 'time', |
| | | 'label' => 'End Time' |
| | | ] |
| | | ], |
| | | 'media' => [ |
| | | 'type' => 'image', |
| | | 'label' => 'Event Photo' |
| | | ] |
| | | ], |
| | | 'offer' => [ |
| | | 'summary' => [ |
| | | 'type' => 'textarea', |
| | | 'label' => 'Offer Description', |
| | | 'required' => true |
| | | ], |
| | | 'offer' => [ |
| | | 'coupon_code' => [ |
| | | 'type' => 'text', |
| | | 'label' => 'Coupon Code' |
| | | ], |
| | | 'redeem_online_url' => [ |
| | | 'type' => 'url', |
| | | 'label' => 'Redemption URL' |
| | | ], |
| | | 'terms_conditions' => [ |
| | | 'type' => 'textarea', |
| | | 'label' => 'Terms & Conditions'] |
| | | ], |
| | | 'media' => ['type' => 'image', 'label' => 'Offer Photo'] |
| | | ] |
| | | ]; |
| | | } |
| | | |
| | | |
| | | } |
| | |
| | | |
| | | protected function setContentTypes(): void |
| | | { |
| | | $this->has_content = true; |
| | | $this->defaultContent = 'REGULAR'; |
| | | $types = ['REGULAR', 'SERVICE', 'DIGITAL', 'FOOD_AND_BEV', 'EVENT', 'SUBSCRIPTION', 'DONATION']; |
| | | |
| | | foreach ($types as $type) { |
| New file |
| | |
| | | <?php |
| | | |
| | | namespace JVBase\integrations\services; |
| | | use JVBase\integrations\Integrations; |
| | | use JVBase\managers\queue\executors\IntegrationExecutor; |
| | | use JVBase\managers\queue\TypeConfig; |
| | | use JVBase\meta\Meta; |
| | | use JVBase\ui\Checkout; |
| | | |
| | | if (!defined('ABSPATH')) { |
| | | exit; |
| | | } |
| | | |
| | | /** |
| | | * Helcim Integration Class |
| | | * |
| | | * Handles HelcimPay.js checkout, invoice retrieval, customer/card management, |
| | | * and bidirectional product sync via API token authentication. |
| | | * |
| | | * Helcim is the source of truth for invoices and orders. |
| | | * Products sync bidirectionally through the standard integration field flow. |
| | | * |
| | | * @since 1.0.0 |
| | | */ |
| | | class HelcimOld extends Integrations |
| | | { |
| | | protected static string $syncCustomer = 'helcim_sync_customer'; |
| | | protected string $service_name = 'helcim'; |
| | | protected string|array $apiBase = 'https://api.helcim.com/v2'; |
| | | |
| | | protected bool $isOAuthService = false; |
| | | |
| | | /** |
| | | * Helcim API rate limits |
| | | * @see https://devdocs.helcim.com/docs/api-rate-limits |
| | | */ |
| | | protected array $rate_limits = [ |
| | | 'per_second' => 5, |
| | | 'per_minute' => 60, |
| | | 'per_hour' => 1000 |
| | | ]; |
| | | |
| | | protected static array $instances = []; |
| | | public static function getInstance(?int $userID = null):self |
| | | { |
| | | $key = is_null($userID) ? 'base' : $userID; |
| | | if (!array_key_exists($key, self::$instances)) { |
| | | self::$instances[$key] = new self($userID); |
| | | } |
| | | return self::$instances[$key]; |
| | | } |
| | | |
| | | protected function __construct(?int $userID = null) |
| | | { |
| | | $this->title = 'Helcim'; |
| | | $this->icon = 'credit-card'; |
| | | |
| | | $this->fields = [ |
| | | 'api_token' => [ |
| | | 'type' => 'text', |
| | | 'subtype' => 'password', |
| | | 'label' => 'API Token', |
| | | 'hint' => 'Found in Helcim Dashboard → Settings → API Access', |
| | | 'required' => true, |
| | | ], |
| | | 'currency' => [ |
| | | 'type' => 'select', |
| | | 'label' => 'Currency', |
| | | 'options' => [ |
| | | 'CAD' => 'CAD', |
| | | 'USD' => 'USD', |
| | | ], |
| | | 'default' => 'CAD', |
| | | ], |
| | | ]; |
| | | |
| | | $this->advanced = [ |
| | | 'fee_saver' => [ |
| | | 'type' => 'true_false', |
| | | 'label' => 'Fee Saver', |
| | | 'hint' => 'Pass processing fees to customers (not compatible with Google Pay)', |
| | | ], |
| | | 'allow_ach' => [ |
| | | 'type' => 'true_false', |
| | | 'label' => 'Allow ACH/Bank Payments', |
| | | 'hint' => 'Enable bank account payments alongside credit card', |
| | | ], |
| | | ]; |
| | | |
| | | $this->instructions = [ |
| | | 'Go to <a href="https://my.helcim.com" target="_blank">Helcim Dashboard</a>', |
| | | 'Navigate to Settings → API Access', |
| | | 'Create a new API Access Configuration', |
| | | 'Enable permissions: General (Customers, Invoices, Products), Transaction Processing', |
| | | 'Copy the API Token and paste it below', |
| | | ]; |
| | | |
| | | $this->canSync = [ |
| | | 'create' => true, |
| | | 'update' => true, |
| | | 'delete' => false, |
| | | ]; |
| | | |
| | | $this->hasWebhooks = false; |
| | | |
| | | parent::__construct(); |
| | | |
| | | // Helcim-specific actions (processAction dispatches these) |
| | | $this->actions = array_merge($this->actions, [ |
| | | 'initialize_checkout' => 'initializeCheckout', |
| | | 'get_invoices' => 'handleGetInvoices', |
| | | 'get_invoice' => 'handleGetInvoice', |
| | | 'get_customer_cards' => 'handleGetCustomerCards', |
| | | ]); |
| | | |
| | | $this->buttons = array_merge($this->buttons, [ |
| | | 'import_from_helcim' => 'Import Products from Helcim', |
| | | 'sync_to_helcim' => 'Sync Site to Helcim', |
| | | ]); |
| | | } |
| | | |
| | | /***************************************************************** |
| | | * ABSTRACT IMPLEMENTATIONS |
| | | *****************************************************************/ |
| | | |
| | | protected function initialize(): void |
| | | { |
| | | if (empty($this->credentials)) { |
| | | $this->loadCredentials(); |
| | | } |
| | | |
| | | $this->apiEndpoints = [ |
| | | 'connection-test', |
| | | 'helcim-pay/initialize', |
| | | 'invoices', |
| | | 'customers', |
| | | 'payment/purchase', |
| | | 'payment/preauth', |
| | | 'payment/capture', |
| | | 'payment/refund', |
| | | 'payment/verify', |
| | | 'card-transactions', |
| | | 'card-batches', |
| | | ]; |
| | | } |
| | | |
| | | protected function getRequestHeaders(): array |
| | | { |
| | | return [ |
| | | 'api-token' => $this->credentials['api_token'] ?? '', |
| | | 'Content-Type' => 'application/json', |
| | | 'Accept' => 'application/json', |
| | | ]; |
| | | } |
| | | |
| | | protected function performConnectionTest(): bool |
| | | { |
| | | try { |
| | | $response = $this->getRequest('connection-test', [], null, 'none', true); |
| | | return !is_wp_error($response) && !$this->isErrorResponse($response ?? []); |
| | | } catch (Exception $e) { |
| | | $this->logError('Connection test failed', ['error' => $e->getMessage()]); |
| | | return false; |
| | | } |
| | | } |
| | | |
| | | /***************************************************************** |
| | | * CONTENT TYPES — product field definitions |
| | | *****************************************************************/ |
| | | |
| | | protected function setContentTypes(): void |
| | | { |
| | | $types = ['REGULAR', 'SERVICE', 'DIGITAL', 'FOOD_AND_BEV', 'EVENT', 'SUBSCRIPTION', 'DONATION']; |
| | | |
| | | foreach ($types as $type) { |
| | | $t = $type === 'REGULAR' ? null : $type; |
| | | $this->contentTypes[$type] = $this->getHelcimMeta($t); |
| | | } |
| | | } |
| | | |
| | | public function getAdditionalFields(?string $content_type = null):array |
| | | { |
| | | if ($content_type === 'customer') { |
| | | $array = $this->getCustomerFields(); |
| | | return array_combine( |
| | | array_map(fn($k) => '_square_' . $k, array_keys($array)), |
| | | $array |
| | | ); |
| | | } |
| | | return array_combine( |
| | | array_map(fn($k) => 'hc_' . $k, array_keys($this->getHelcimMeta($content_type))), |
| | | $this->getHelcimMeta($content_type) |
| | | ); |
| | | } |
| | | |
| | | protected function getCustomerFields():array |
| | | { |
| | | return []; |
| | | } |
| | | |
| | | /** |
| | | * Get Helcim product meta fields by type. |
| | | * |
| | | * Used by Registrar.php when helcim is configured |
| | | */ |
| | | public function getHelcimMeta(?string $type = null): array |
| | | { |
| | | $fields = [ |
| | | // Basic Product Fields |
| | | 'price' => [ |
| | | 'type' => 'number', |
| | | 'bulkEdit' => true, |
| | | 'label' => 'Price', |
| | | 'step' => 0.01, |
| | | 'max' => 99999, |
| | | 'description' => 'Product price' |
| | | ], |
| | | |
| | | 'product_type' => [ |
| | | 'type' => 'select', |
| | | 'label' => 'Product Type', |
| | | 'options' => [ |
| | | 'REGULAR' => 'Regular Product', |
| | | 'SERVICE' => 'Service', |
| | | 'DIGITAL' => 'Digital Product', |
| | | 'FOOD_AND_BEV' => 'Food & Beverage', |
| | | 'EVENT' => 'Event/Ticket', |
| | | 'SUBSCRIPTION' => 'Subscription', |
| | | 'DONATION' => 'Donation' |
| | | ], |
| | | 'default' => $type ?? 'REGULAR' |
| | | ], |
| | | |
| | | 'cart_quantity' => [ |
| | | 'type' => 'number', |
| | | 'label' => 'Quantity', |
| | | 'hidden' => true, |
| | | ], |
| | | |
| | | // Tax & Shipping |
| | | 'tax_exempt' => [ |
| | | 'type' => 'true_false', |
| | | 'label' => 'Tax Exempt', |
| | | 'section' => 'helcim-tax' |
| | | ], |
| | | |
| | | 'shipping_required' => [ |
| | | 'type' => 'true_false', |
| | | 'label' => 'Shipping Required', |
| | | 'section' => 'helcim-shipping' |
| | | ], |
| | | |
| | | 'shipping_weight' => [ |
| | | 'type' => 'number', |
| | | 'label' => 'Shipping Weight (kg)', |
| | | 'step' => 0.01, |
| | | 'section' => 'helcim-shipping', |
| | | 'condition' => [ |
| | | 'field' => 'shipping_required', |
| | | 'operator' => '==', |
| | | 'value' => true |
| | | ] |
| | | ], |
| | | |
| | | // Availability |
| | | 'available_online' => [ |
| | | 'type' => 'true_false', |
| | | 'label' => 'Available Online', |
| | | 'section' => 'helcim-availability', |
| | | 'default' => true |
| | | ], |
| | | |
| | | 'available_for_pickup' => [ |
| | | 'type' => 'true_false', |
| | | 'label' => 'Available for Pickup', |
| | | 'section' => 'helcim-availability', |
| | | 'default' => true |
| | | ], |
| | | |
| | | 'available_for_delivery' => [ |
| | | 'type' => 'true_false', |
| | | 'label' => 'Available for Delivery', |
| | | 'section' => 'helcim-availability', |
| | | 'default' => false |
| | | ], |
| | | |
| | | '_helcim_sku' => [ |
| | | 'type' => 'text', |
| | | 'label' => 'SKU', |
| | | 'description' => 'Stock keeping unit', |
| | | 'section' => 'helcim-config' |
| | | ], |
| | | |
| | | // Product Variations |
| | | 'product_variations' => [ |
| | | 'type' => 'repeater', |
| | | 'label' => 'Product Variations', |
| | | 'description' => 'Different versions of this product', |
| | | 'add_label' => 'Add Variation', |
| | | 'section' => 'variations', |
| | | 'fields' => $this->getHelcimVariationMeta($type) |
| | | ], |
| | | |
| | | // Product Options |
| | | 'options' => [ |
| | | 'type' => 'group', |
| | | 'label' => 'Product Options', |
| | | 'section'=> 'helcim-options', |
| | | 'fields' => [ |
| | | 'max_order' => [ |
| | | 'type' => 'number', |
| | | 'label' => 'Maximum per order', |
| | | 'default' => 50 |
| | | ], |
| | | 'min_order' => [ |
| | | 'type' => 'number', |
| | | 'label' => 'Minimum per order', |
| | | 'default' => 0, |
| | | ], |
| | | 'step' => [ |
| | | 'type' => 'number', |
| | | 'label' => 'Order increment', |
| | | 'default' => 1, |
| | | ], |
| | | 'preparation_time' => [ |
| | | 'type' => 'number', |
| | | 'label' => 'Preparation time (minutes)', |
| | | 'description' => 'Time needed to prepare this item', |
| | | 'condition' => [ |
| | | 'field' => 'product_type', |
| | | 'operator' => 'in', |
| | | 'value' => ['FOOD_AND_BEV', 'SERVICE'] |
| | | ] |
| | | ] |
| | | ] |
| | | ], |
| | | |
| | | // Subscription Fields |
| | | 'subscription_settings' => [ |
| | | 'type' => 'group', |
| | | 'label' => 'Subscription Settings', |
| | | 'section' => 'helcim-subscription', |
| | | 'condition' => [ |
| | | 'field' => 'product_type', |
| | | 'operator' => '==', |
| | | 'value' => 'SUBSCRIPTION' |
| | | ], |
| | | 'fields' => [ |
| | | 'billing_cycle' => [ |
| | | 'type' => 'select', |
| | | 'label' => 'Billing Cycle', |
| | | 'options' => [ |
| | | 'daily' => 'Daily', |
| | | 'weekly' => 'Weekly', |
| | | 'monthly' => 'Monthly', |
| | | 'quarterly' => 'Quarterly', |
| | | 'yearly' => 'Yearly' |
| | | ], |
| | | 'default' => 'monthly' |
| | | ], |
| | | 'trial_period' => [ |
| | | 'type' => 'number', |
| | | 'label' => 'Trial Period (days)', |
| | | 'description' => 'Free trial period before billing starts', |
| | | 'default' => 0 |
| | | ], |
| | | 'setup_fee' => [ |
| | | 'type' => 'number', |
| | | 'label' => 'Setup Fee', |
| | | 'step' => 0.01, |
| | | 'description' => 'One-time setup fee' |
| | | ] |
| | | ] |
| | | ], |
| | | |
| | | // Food & Beverage Specific |
| | | 'food_settings' => [ |
| | | 'type' => 'group', |
| | | 'label' => 'Food & Beverage Settings', |
| | | 'section' => 'helcim-food', |
| | | 'condition' => [ |
| | | 'field' => 'product_type', |
| | | 'operator' => '==', |
| | | 'value' => 'FOOD_AND_BEV' |
| | | ], |
| | | 'fields' => [ |
| | | 'ingredients' => [ |
| | | 'type' => 'textarea', |
| | | 'label' => 'Ingredients', |
| | | 'description' => 'List ingredients (comma separated)' |
| | | ], |
| | | 'allergens' => [ |
| | | 'type' => 'checkbox_list', |
| | | 'label' => 'Allergens', |
| | | 'options' => [ |
| | | 'gluten' => 'Contains Gluten', |
| | | 'dairy' => 'Contains Dairy', |
| | | 'nuts' => 'Contains Nuts', |
| | | 'soy' => 'Contains Soy', |
| | | 'eggs' => 'Contains Eggs', |
| | | 'seafood' => 'Contains Seafood' |
| | | ] |
| | | ], |
| | | 'dietary_options' => [ |
| | | 'type' => 'checkbox_list', |
| | | 'label' => 'Dietary Options', |
| | | 'options' => [ |
| | | 'vegetarian' => 'Vegetarian', |
| | | 'vegan' => 'Vegan', |
| | | 'gluten_free' => 'Gluten Free', |
| | | 'dairy_free' => 'Dairy Free', |
| | | 'keto' => 'Keto Friendly', |
| | | 'halal' => 'Halal', |
| | | 'kosher' => 'Kosher' |
| | | ] |
| | | ], |
| | | 'spice_level' => [ |
| | | 'type' => 'range', |
| | | 'label' => 'Spice Level', |
| | | 'min' => 0, |
| | | 'max' => 5, |
| | | 'default' => 0 |
| | | ], |
| | | 'serving_size' => [ |
| | | 'type' => 'text', |
| | | 'label' => 'Serving Size', |
| | | 'description' => 'e.g., "Serves 2-3"' |
| | | ] |
| | | ] |
| | | ], |
| | | |
| | | // Service Specific |
| | | 'service_settings' => [ |
| | | 'type' => 'group', |
| | | 'label' => 'Service Settings', |
| | | 'section' => 'helcim-service', |
| | | 'condition' => [ |
| | | 'field' => 'product_type', |
| | | 'operator' => '==', |
| | | 'value' => 'SERVICE' |
| | | ], |
| | | 'fields' => [ |
| | | 'service_duration' => [ |
| | | 'type' => 'number', |
| | | 'label' => 'Duration (minutes)', |
| | | 'description' => 'Service duration in minutes' |
| | | ], |
| | | 'booking_required' => [ |
| | | 'type' => 'true_false', |
| | | 'label' => 'Booking Required' |
| | | ], |
| | | 'capacity' => [ |
| | | 'type' => 'number', |
| | | 'label' => 'Service Capacity', |
| | | 'description' => 'Maximum number of customers per service' |
| | | ], |
| | | 'staff_required' => [ |
| | | 'type' => 'number', |
| | | 'label' => 'Staff Required', |
| | | 'description' => 'Number of staff needed', |
| | | 'default' => 1 |
| | | ] |
| | | ] |
| | | ], |
| | | |
| | | // Event Specific |
| | | 'event_settings' => [ |
| | | 'type' => 'group', |
| | | 'label' => 'Event Settings', |
| | | 'section' => 'helcim-event', |
| | | 'condition' => [ |
| | | 'field' => 'product_type', |
| | | 'operator' => '==', |
| | | 'value' => 'EVENT' |
| | | ], |
| | | 'fields' => [ |
| | | 'event_date' => [ |
| | | 'type' => 'datetime', |
| | | 'label' => 'Event Date & Time' |
| | | ], |
| | | 'event_location' => [ |
| | | 'type' => 'text', |
| | | 'label' => 'Event Location' |
| | | ], |
| | | 'max_attendees' => [ |
| | | 'type' => 'number', |
| | | 'label' => 'Maximum Attendees' |
| | | ], |
| | | 'early_bird_price' => [ |
| | | 'type' => 'number', |
| | | 'label' => 'Early Bird Price', |
| | | 'step' => 0.01, |
| | | 'description' => 'Discounted price for early registrations' |
| | | ], |
| | | 'early_bird_deadline' => [ |
| | | 'type' => 'date', |
| | | 'label' => 'Early Bird Deadline' |
| | | ] |
| | | ] |
| | | ] |
| | | ]; |
| | | |
| | | // Add inventory fields if configured |
| | | if ($config['hasInventory'] ?? false) { |
| | | $fields['_helcim_inventory'] = [ |
| | | 'type' => 'number', |
| | | 'label' => 'Inventory', |
| | | 'bulkEdit' => true, |
| | | 'section' => 'inventory' |
| | | ]; |
| | | |
| | | $fields['track_inventory'] = [ |
| | | 'type' => 'true_false', |
| | | 'label' => 'Track Inventory', |
| | | 'section' => 'inventory', |
| | | 'default' => true |
| | | ]; |
| | | |
| | | $fields['low_stock_threshold'] = [ |
| | | 'type' => 'number', |
| | | 'label' => 'Low Stock Alert', |
| | | 'description' => 'Alert when stock falls below this level', |
| | | 'section' => 'inventory', |
| | | 'default' => 5 |
| | | ]; |
| | | |
| | | $fields['product_variations']['fields']['inventory'] = [ |
| | | 'type' => 'number', |
| | | 'label' => 'Stock Quantity', |
| | | 'description' => 'Current stock for this variation' |
| | | ]; |
| | | } |
| | | |
| | | return $fields; |
| | | } |
| | | |
| | | public function getHelcimVariationMeta(?string $type = null):array |
| | | { |
| | | |
| | | $base = [ |
| | | 'name' => [ |
| | | 'type' => 'text', |
| | | 'label' => 'Variation Name', |
| | | 'description' => 'e.g., "Small", "Large", "Red"' |
| | | ], |
| | | 'price' => [ |
| | | 'type' => 'number', |
| | | 'label' => 'Price', |
| | | 'step' => 0.01, |
| | | 'max' => 99999, |
| | | 'description' => 'Price for this variation' |
| | | ], |
| | | 'sku' => [ |
| | | 'type' => 'text', |
| | | 'label' => 'SKU', |
| | | 'description' => 'Stock keeping unit for this variation' |
| | | ], |
| | | 'track_inventory' => [ |
| | | 'type' => 'true_false', |
| | | 'label' => 'Track Inventory', |
| | | ], |
| | | '_helcim_variation_id' => [ |
| | | 'type' => 'text', |
| | | 'label' => 'Helcim Variation ID', |
| | | 'description' => 'Helcim ID for this variation', |
| | | 'hidden' => true |
| | | ], |
| | | '_helcim_last_sync' => [ |
| | | 'type' => 'datetime', |
| | | 'label' => 'Last Sync', |
| | | 'hidden' => true |
| | | ], |
| | | 'options' => [ |
| | | 'type' => 'group', |
| | | 'label' => 'Variation Options', |
| | | 'collapsible' => true, |
| | | 'fields' => [ |
| | | 'color' => [ |
| | | 'type' => 'color', |
| | | 'label' => 'Color', |
| | | 'description' => 'Visual color for this variation' |
| | | ], |
| | | 'size' => [ |
| | | 'type' => 'select', |
| | | 'label' => 'Size', |
| | | 'options' => [ |
| | | '' => 'N/A', |
| | | 'xs' => 'Extra Small', |
| | | 's' => 'Small', |
| | | 'm' => 'Medium', |
| | | 'l' => 'Large', |
| | | 'xl' => 'Extra Large', |
| | | 'xxl' => '2X Large', |
| | | 'custom'=> 'Custom' |
| | | ] |
| | | ], |
| | | 'custom_size' => [ |
| | | 'type' => 'text', |
| | | 'label' => 'Custom Size', |
| | | 'condition' => [ |
| | | 'field' => 'size', |
| | | 'operator' => '==', |
| | | 'value' => 'custom' |
| | | ] |
| | | ], |
| | | 'weight' => [ |
| | | 'type' => 'number', |
| | | 'label' => 'Weight (kg)', |
| | | 'step' => 0.01, |
| | | 'description' => 'Weight of this variation' |
| | | ], |
| | | 'dimensions' => [ |
| | | 'type' => 'group', |
| | | 'label' => 'Dimensions', |
| | | 'fields' => [ |
| | | 'length' => [ |
| | | 'type' => 'number', |
| | | 'label' => 'Length (cm)', |
| | | 'step' => 0.1 |
| | | ], |
| | | 'width' => [ |
| | | 'type' => 'number', |
| | | 'label' => 'Width (cm)', |
| | | 'step' => 0.1 |
| | | ], |
| | | 'height' => [ |
| | | 'type' => 'number', |
| | | 'label' => 'Height (cm)', |
| | | 'step' => 0.1 |
| | | ] |
| | | ] |
| | | ] |
| | | ] |
| | | ] |
| | | ]; |
| | | |
| | | $extras = [ |
| | | 'SERVICE' => [ |
| | | 'service_duration' => [ |
| | | 'type' => 'number', |
| | | 'label' => 'Duration (minutes)', |
| | | 'description' => 'Duration for this service variation' |
| | | ], |
| | | 'available_for_booking' => [ |
| | | 'type' => 'true_false', |
| | | 'label' => 'Available for Booking' |
| | | ] |
| | | ], |
| | | |
| | | 'FOOD_AND_BEV' => [ |
| | | 'portion_size' => [ |
| | | 'type' => 'select', |
| | | 'label' => 'Portion Size', |
| | | 'options' => [ |
| | | 'small' => 'Small', |
| | | 'regular' => 'Regular', |
| | | 'large' => 'Large', |
| | | 'family' => 'Family Size' |
| | | ] |
| | | ], |
| | | 'calories' => [ |
| | | 'type' => 'number', |
| | | 'label' => 'Calories', |
| | | 'description' => 'Calorie count for this variation' |
| | | ] |
| | | ], |
| | | |
| | | 'DIGITAL' => [ |
| | | 'download_limit' => [ |
| | | 'type' => 'number', |
| | | 'label' => 'Download Limit', |
| | | 'description' => 'Maximum number of downloads', |
| | | 'default' => -1 |
| | | ], |
| | | 'expiry_days' => [ |
| | | 'type' => 'number', |
| | | 'label' => 'Access Duration (days)', |
| | | 'description' => 'Days until download expires', |
| | | 'default' => 0 |
| | | ] |
| | | ] |
| | | ]; |
| | | if ($type && array_key_exists($type, $extras)){ |
| | | $base = array_merge($base, $extras[$type]); |
| | | } |
| | | return $base; |
| | | } |
| | | |
| | | /***************************************************************** |
| | | * HELCIMPAY.JS — Frontend Scripts & Checkout Initialization |
| | | *****************************************************************/ |
| | | |
| | | protected function registerAdditionalHooks(): void |
| | | { |
| | | if (!$this->isSetUp()) { |
| | | return; |
| | | } |
| | | |
| | | add_action('wp_enqueue_scripts', [$this, 'enqueueScripts']); |
| | | |
| | | // Shared checkout UI (replaces provider-specific outputCheckout) |
| | | add_filter('jvbAdditionalActions', [Checkout::class, 'render']); |
| | | |
| | | // Checkout description filter |
| | | add_filter('jvb_checkout_description', function (string $desc, string $provider) { |
| | | if ($provider === 'helcim') { |
| | | return 'Securely checkout with your name, email, and payments processed by Helcim.'; |
| | | } |
| | | return $desc; |
| | | }, 10, 2); |
| | | |
| | | // Register webhook endpoint (handled by parent) |
| | | $this->registerWebhookEndpoint(); |
| | | } |
| | | |
| | | public function enqueueScripts(): void |
| | | { |
| | | // HelcimPay.js SDK |
| | | wp_enqueue_script( |
| | | 'helcim-pay-sdk', |
| | | 'https://secure.helcim.app/helcim-pay/services/start.js', |
| | | [], |
| | | null, |
| | | true |
| | | ); |
| | | |
| | | // Base cart checkout (shared with Square) |
| | | wp_register_script( |
| | | 'jvb-checkout', |
| | | JVB_URL . 'assets/js/min/checkout.min.js', |
| | | ['jvb-utility', 'jvb-queue', 'jvb-a11y', 'jvb-cache', 'jvb-tabs', 'jvb-popup'], |
| | | '1.1.31', |
| | | true |
| | | ); |
| | | |
| | | // Helcim checkout (extends CartCheckout) |
| | | wp_register_script( |
| | | 'jvb-helcim-checkout', |
| | | JVB_URL . 'assets/js/min/helcim.min.js', |
| | | ['jvb-checkout', 'helcim-pay-sdk'], |
| | | '1.1.31', |
| | | true |
| | | ); |
| | | |
| | | wp_localize_script('jvb-helcim-checkout', 'helcimConfig', [ |
| | | 'api_url' => rest_url('jvb/v1/helcim/'), |
| | | 'nonce' => wp_create_nonce('wp_rest'), |
| | | 'currency' => $this->credentials['currency'] ?? 'CAD', |
| | | 'is_logged_in' => is_user_logged_in(), |
| | | 'user_email' => is_user_logged_in() ? wp_get_current_user()->user_email : '', |
| | | 'isOpen' => apply_filters('jvb_store_is_open', '1'), |
| | | ]); |
| | | |
| | | wp_enqueue_script('jvb-helcim-checkout'); |
| | | } |
| | | |
| | | |
| | | protected function registerAdditionalQueueTypes(IntegrationExecutor $executor): void |
| | | { |
| | | $queue = JVB()->queue(); |
| | | |
| | | $queue->registry()->register(self::$import, new TypeConfig( |
| | | executor: $executor, |
| | | maxRetries: 3 |
| | | )); |
| | | |
| | | $queue->registry()->register(self::$syncCustomer, new TypeConfig( |
| | | executor: $executor, |
| | | maxRetries: 2 |
| | | )); |
| | | } |
| | | |
| | | /** |
| | | * Initialize a HelcimPay.js checkout session. |
| | | * |
| | | * Server-side: POST /helcim-pay/initialize → returns checkoutToken + secretToken. |
| | | * Client-side: appendHelcimPayIframe(checkoutToken) renders the payment modal. |
| | | * Tokens are valid for 60 minutes. |
| | | * |
| | | * @param array $data [ |
| | | * 'amount' => float, // Required |
| | | * 'invoiceId' => string, // Optional — pay a specific invoice |
| | | * 'customerId' => int, // Optional — associate with Helcim customer |
| | | * 'paymentType' => string, // purchase|preauth|verify (default: purchase) |
| | | * ] |
| | | */ |
| | | public function initializeCheckout(array $data): array |
| | | { |
| | | if (empty($data['amount']) || (float) $data['amount'] <= 0) { |
| | | return ['success' => false, 'message' => 'Invalid amount']; |
| | | } |
| | | |
| | | $paymentMethod = !empty($this->credentials['allow_ach']) ? 'cc-ach' : 'cc'; |
| | | |
| | | $body = [ |
| | | 'paymentType' => $data['paymentType'] ?? 'purchase', |
| | | 'amount' => (float) $data['amount'], |
| | | 'currency' => $this->credentials['currency'] ?? 'CAD', |
| | | 'paymentMethod' => $paymentMethod, |
| | | ]; |
| | | |
| | | if (!empty($data['invoiceId'])) { |
| | | $body['invoiceNumber'] = $data['invoiceId']; |
| | | } |
| | | |
| | | if (!empty($data['customerId'])) { |
| | | $body['customerId'] = (int) $data['customerId']; |
| | | } |
| | | |
| | | if (!empty($this->credentials['fee_saver'])) { |
| | | $body['hasConvenienceFee'] = true; |
| | | } |
| | | |
| | | $response = $this->postRequest('helcim-pay/initialize', $body); |
| | | |
| | | if (is_wp_error($response)) { |
| | | return ['success' => false, 'message' => $response->get_error_message()]; |
| | | } |
| | | |
| | | if (empty($response['checkoutToken'])) { |
| | | return ['success' => false, 'message' => 'Failed to initialize checkout']; |
| | | } |
| | | |
| | | return [ |
| | | 'success' => true, |
| | | 'checkoutToken' => $response['checkoutToken'], |
| | | 'secretToken' => $response['secretToken'] ?? '', |
| | | ]; |
| | | } |
| | | |
| | | /***************************************************************** |
| | | * INVOICES — Helcim is source of truth |
| | | *****************************************************************/ |
| | | |
| | | /** |
| | | * Get invoices for a customer. |
| | | * |
| | | * @param array $data ['email' => string] or ['customerId' => int] |
| | | */ |
| | | public function handleGetInvoices(array $data): array |
| | | { |
| | | $customerId = $data['customerId'] ?? null; |
| | | |
| | | if (empty($customerId) && !empty($data['email'])) { |
| | | $customerId = $this->getCustomerIdByEmail($data['email']); |
| | | } |
| | | |
| | | if (!$customerId) { |
| | | return ['success' => true, 'invoices' => []]; |
| | | } |
| | | |
| | | $response = $this->getRequest('invoices', ['customerId' => $customerId], null, 'minimal'); |
| | | |
| | | if (is_wp_error($response) || !is_array($response)) { |
| | | return ['success' => false, 'message' => 'Failed to fetch invoices']; |
| | | } |
| | | |
| | | return ['success' => true, 'invoices' => $response]; |
| | | } |
| | | |
| | | /** |
| | | * Get a single invoice by ID. |
| | | */ |
| | | public function handleGetInvoice(array $data): array |
| | | { |
| | | $invoiceId = $data['invoiceId'] ?? null; |
| | | |
| | | if (!$invoiceId) { |
| | | return ['success' => false, 'message' => 'Invoice ID required']; |
| | | } |
| | | |
| | | $response = $this->getRequest("invoices/{$invoiceId}", [], null, 'minimal'); |
| | | |
| | | if (is_wp_error($response) || !is_array($response)) { |
| | | return ['success' => false, 'message' => 'Failed to fetch invoice']; |
| | | } |
| | | |
| | | return ['success' => true, 'invoice' => $response]; |
| | | } |
| | | |
| | | /***************************************************************** |
| | | * CUSTOMERS & CARDS |
| | | *****************************************************************/ |
| | | |
| | | /** |
| | | * Find Helcim customer ID by email. |
| | | */ |
| | | public function getCustomerIdByEmail(string $email): ?int |
| | | { |
| | | $cacheKey = 'customer_email_' . md5($email); |
| | | $cached = $this->cache->get($cacheKey); |
| | | |
| | | if ($cached !== false) { |
| | | return (int) $cached; |
| | | } |
| | | |
| | | $response = $this->getRequest('customers', ['search' => $email], null, 'none', true); |
| | | |
| | | if (is_wp_error($response) || empty($response)) { |
| | | return null; |
| | | } |
| | | |
| | | $customers = is_array($response) ? $response : []; |
| | | $emailLower = strtolower($email); |
| | | |
| | | foreach ($customers as $customer) { |
| | | $contactEmail = strtolower($customer['contactEmail'] ?? $customer['email'] ?? ''); |
| | | if ($contactEmail === $emailLower) { |
| | | $this->cache->set($cacheKey, $customer['id'], $this->cacheStrategy['aggressive']); |
| | | return (int) $customer['id']; |
| | | } |
| | | } |
| | | |
| | | return null; |
| | | } |
| | | |
| | | /** |
| | | * Get or create a Helcim customer. |
| | | */ |
| | | public function getOrCreateCustomer(array $info): ?int |
| | | { |
| | | if (empty($info['email'])) { |
| | | return null; |
| | | } |
| | | |
| | | $existing = $this->getCustomerIdByEmail($info['email']); |
| | | if ($existing) { |
| | | return $existing; |
| | | } |
| | | |
| | | $response = $this->postRequest('customers', [ |
| | | 'contactName' => $info['name'] ?? '', |
| | | 'contactEmail' => $info['email'], |
| | | 'cellphone' => $info['phone'] ?? '', |
| | | ]); |
| | | |
| | | if (is_wp_error($response) || empty($response['id'])) { |
| | | return null; |
| | | } |
| | | |
| | | return (int) $response['id']; |
| | | } |
| | | |
| | | /** |
| | | * Get saved cards for a customer. |
| | | */ |
| | | public function handleGetCustomerCards(array $data): array |
| | | { |
| | | $customerId = $data['customerId'] ?? null; |
| | | |
| | | if (empty($customerId) && !empty($data['email'])) { |
| | | $customerId = $this->getCustomerIdByEmail($data['email']); |
| | | } |
| | | |
| | | if (!$customerId) { |
| | | return ['success' => true, 'cards' => []]; |
| | | } |
| | | |
| | | $response = $this->getRequest("customers/{$customerId}/cards", [], null, 'moderate'); |
| | | |
| | | if (is_wp_error($response) || !is_array($response)) { |
| | | return ['success' => false, 'message' => 'Failed to fetch cards']; |
| | | } |
| | | |
| | | return ['success' => true, 'cards' => $response]; |
| | | } |
| | | |
| | | /** |
| | | * Get bank accounts for a customer. |
| | | */ |
| | | public function getCustomerBankAccounts(int $customerId): array |
| | | { |
| | | $response = $this->getRequest("customers/{$customerId}/bank-accounts", [], null, 'moderate'); |
| | | return (!is_wp_error($response) && is_array($response)) ? $response : []; |
| | | } |
| | | |
| | | /***************************************************************** |
| | | * TRANSACTIONS |
| | | *****************************************************************/ |
| | | |
| | | public function getTransactions(array $params = []): array |
| | | { |
| | | $response = $this->getRequest('card-transactions', $params, null, 'minimal'); |
| | | return (!is_wp_error($response) && is_array($response)) ? $response : []; |
| | | } |
| | | |
| | | public function refundPayment(array $data): array |
| | | { |
| | | $response = $this->postRequest('payment/refund', $data); |
| | | |
| | | if (is_wp_error($response)) { |
| | | return ['success' => false, 'message' => $response->get_error_message()]; |
| | | } |
| | | |
| | | return ['success' => true, 'transaction' => $response]; |
| | | } |
| | | |
| | | /***************************************************************** |
| | | * PRODUCT SYNC |
| | | *****************************************************************/ |
| | | |
| | | protected function handleTheSavePost(int $postID, \WP_Post $post, bool $update, array $settings): void |
| | | { |
| | | |
| | | error_log('==== [Helcim]::handleTheSavePost ===='); |
| | | $this->queueOperation(self::$syncTo, [ |
| | | 'items' => [$postID], |
| | | 'user' => user_can($post->post_author, 'manage_options') ? null : $post->post_author |
| | | ], [ |
| | | 'priority' => 'high', |
| | | 'delay' => 30, |
| | | ]); |
| | | Meta::forPost($postID)->set('_'.$this->service_name.'_sync_status', 'queued'); |
| | | } |
| | | /** |
| | | * Handle post deletion |
| | | */ |
| | | public function handleDeletePost(int $postID): void |
| | | { |
| | | $item_id = Meta::forPost($postID)->get("_{$this->service_name}_item_id"); |
| | | |
| | | if (empty($item_id)) { |
| | | return; |
| | | } |
| | | $this->queueOperation(self::$deleteFrom, [ |
| | | 'external_ids' => [$item_id], |
| | | 'post_id' => $postID, |
| | | ], [ |
| | | 'priority' => 'high', |
| | | ]); |
| | | |
| | | } |
| | | |
| | | protected function handleImportFromHelcim(): array |
| | | { |
| | | $this->queueOperation('import_products', [ |
| | | 'user_id' => $this->userID, |
| | | ], ['priority' => 'normal']); |
| | | |
| | | return ['success' => true, 'message' => 'Import from Helcim queued']; |
| | | } |
| | | |
| | | /***************************************************************** |
| | | * USER ↔ CUSTOMER LINKING |
| | | *****************************************************************/ |
| | | |
| | | public function linkUserToCustomer(int $userId, int $helcimCustomerId): void |
| | | { |
| | | update_user_meta($userId, BASE . '_helcim_customer_id', $helcimCustomerId); |
| | | } |
| | | |
| | | public function getUserCustomerId(int $userId): ?int |
| | | { |
| | | $id = get_user_meta($userId, BASE . '_helcim_customer_id', true); |
| | | return $id ? (int) $id : null; |
| | | } |
| | | |
| | | /** |
| | | * Resolve customer ID from user meta, falling back to email lookup + auto-link. |
| | | */ |
| | | public function resolveCustomerId(int $userId): ?int |
| | | { |
| | | $id = $this->getUserCustomerId($userId); |
| | | if ($id) { |
| | | return $id; |
| | | } |
| | | |
| | | $user = get_userdata($userId); |
| | | if (!$user || empty($user->user_email)) { |
| | | return null; |
| | | } |
| | | |
| | | $id = $this->getCustomerIdByEmail($user->user_email); |
| | | if ($id) { |
| | | $this->linkUserToCustomer($userId, $id); |
| | | } |
| | | |
| | | return $id; |
| | | } |
| | | |
| | | /***************************************************************** |
| | | * VALIDATION |
| | | *****************************************************************/ |
| | | |
| | | /** |
| | | * Validate a HelcimPay.js transaction using the secret token. |
| | | * |
| | | * After the frontend receives a SUCCESS message event, call this |
| | | * server-side to verify the transaction hash. |
| | | */ |
| | | public function validateTransaction(string $secretToken, array $transactionData): bool |
| | | { |
| | | $hash = hash('sha256', $secretToken . json_encode($transactionData)); |
| | | return hash_equals($hash, $transactionData['hash'] ?? ''); |
| | | } |
| | | /******************************************************************* |
| | | * WEBHOOKS |
| | | *******************************************************************/ |
| | | /** |
| | | * Validate Helcim webhook signature. |
| | | * |
| | | * Helcim signs webhooks with HMAC-SHA256 using: |
| | | * signedContent = "{webhook-id}.{webhook-timestamp}.{body}" |
| | | * key = base64_decode(verifierToken) |
| | | * |
| | | * Headers: webhook-id, webhook-timestamp, webhook-signature (v1,{base64hash}) |
| | | * |
| | | * @see https://devdocs.helcim.com/docs/enabling-webhooks-for-transactions |
| | | */ |
| | | protected function validateWebhook(array $payload): bool |
| | | { |
| | | $headers = $payload['_headers'] ?? []; |
| | | |
| | | $webhookId = $headers['webhook_id'][0] ?? $headers['webhook-id'] ?? ''; |
| | | $webhookTimestamp = $headers['webhook_timestamp'][0] ?? $headers['webhook-timestamp'] ?? ''; |
| | | $webhookSignature = $headers['webhook_signature'][0] ?? $headers['webhook-signature'] ?? ''; |
| | | |
| | | if (empty($webhookId) || empty($webhookTimestamp) || empty($webhookSignature)) { |
| | | $this->logError('Webhook missing required headers', [], 'warning'); |
| | | return false; |
| | | } |
| | | |
| | | // Verify timestamp is within 5 minutes (prevent replay attacks) |
| | | $now = time(); |
| | | if (abs($now - (int)$webhookTimestamp) > 300) { |
| | | $this->logError('Webhook timestamp too old', [ |
| | | 'webhook_timestamp' => $webhookTimestamp, |
| | | 'server_time' => $now, |
| | | ], 'warning'); |
| | | return false; |
| | | } |
| | | |
| | | $secret = $this->getAdvancedSetting('webhook_signature_key'); |
| | | if (empty($secret)) { |
| | | // If no signature key configured, allow webhook but log warning |
| | | $this->logDebug('No webhook signature key configured — skipping verification'); |
| | | return true; |
| | | } |
| | | |
| | | // Reconstruct the raw body from the payload (minus our injected _headers) |
| | | $body = $payload['_raw_body'] ?? json_encode( |
| | | array_diff_key($payload, ['_headers' => 1, '_raw_body' => 1]) |
| | | ); |
| | | |
| | | $signedContent = "{$webhookId}.{$webhookTimestamp}.{$body}"; |
| | | $secretBytes = base64_decode($secret); |
| | | $expectedHash = base64_encode( |
| | | hash_hmac('sha256', $signedContent, $secretBytes, true) |
| | | ); |
| | | |
| | | // webhook-signature may contain multiple signatures: "v1,hash1 v2,hash2" |
| | | $signatures = explode(' ', $webhookSignature); |
| | | foreach ($signatures as $sig) { |
| | | $parts = explode(',', $sig, 2); |
| | | if (count($parts) === 2 && hash_equals($expectedHash, $parts[1])) { |
| | | return true; |
| | | } |
| | | } |
| | | |
| | | $this->logError('Webhook signature mismatch', [ |
| | | 'webhook_id' => $webhookId, |
| | | ], 'warning'); |
| | | |
| | | return false; |
| | | } |
| | | |
| | | /** |
| | | * Process a validated Helcim webhook event. |
| | | * |
| | | * Helcim sends minimal payloads: {"id": "12345", "type": "cardTransaction"} |
| | | * We fetch the full transaction details from the API. |
| | | */ |
| | | protected function processWebhook(array $payload): bool |
| | | { |
| | | $type = $payload['type'] ?? ''; |
| | | $id = $payload['id'] ?? ''; |
| | | |
| | | if (empty($type) || empty($id)) { |
| | | $this->logError('Webhook missing type or id', $payload, 'warning'); |
| | | return false; |
| | | } |
| | | |
| | | return match ($type) { |
| | | 'cardTransaction' => $this->handleTransactionWebhook($id), |
| | | 'terminalCancel' => $this->handleTerminalCancelWebhook($payload), |
| | | default => $this->handleUnknownWebhook($type, $id), |
| | | }; |
| | | } |
| | | |
| | | /** |
| | | * Handle a cardTransaction webhook — fetch full transaction, update records. |
| | | */ |
| | | protected function handleTransactionWebhook(string $transactionId): bool |
| | | { |
| | | // Fetch full transaction from Helcim API |
| | | $transaction = $this->getRequest("card-transactions/{$transactionId}"); |
| | | |
| | | if (is_wp_error($transaction) || empty($transaction)) { |
| | | $this->logError('Failed to fetch transaction for webhook', [ |
| | | 'transaction_id' => $transactionId, |
| | | ]); |
| | | return false; |
| | | } |
| | | |
| | | $status = $transaction['status'] ?? ''; |
| | | |
| | | // Fire action for other parts of the system to react |
| | | do_action('jvb_helcim_transaction', $transaction, $status); |
| | | |
| | | // If linked to an invoice, update invoice cache |
| | | $invoiceNumber = $transaction['invoiceNumber'] ?? ''; |
| | | if (!empty($invoiceNumber)) { |
| | | $this->cache->delete("invoice_{$invoiceNumber}"); |
| | | do_action('jvb_helcim_invoice_updated', $invoiceNumber, $transaction); |
| | | } |
| | | |
| | | // Log for debugging |
| | | $this->logDebug('Transaction webhook processed', [ |
| | | 'transaction_id' => $transactionId, |
| | | 'status' => $status, |
| | | 'amount' => $transaction['amount'] ?? 0, |
| | | 'type' => $transaction['type'] ?? '', |
| | | ]); |
| | | |
| | | return true; |
| | | } |
| | | |
| | | /** |
| | | * Handle Smart Terminal cancel webhook |
| | | */ |
| | | protected function handleTerminalCancelWebhook(array $payload): bool |
| | | { |
| | | do_action('jvb_helcim_terminal_cancel', $payload); |
| | | |
| | | $this->logDebug('Terminal cancel webhook processed', [ |
| | | 'payload' => $payload, |
| | | ]); |
| | | |
| | | return true; |
| | | } |
| | | |
| | | /** |
| | | * Handle unknown webhook types (future-proofing) |
| | | */ |
| | | protected function handleUnknownWebhook(string $type, string $id): bool |
| | | { |
| | | $this->logDebug('Unknown webhook type received', [ |
| | | 'type' => $type, |
| | | 'id' => $id, |
| | | ]); |
| | | |
| | | do_action("jvb_helcim_webhook_{$type}", $id); |
| | | return true; |
| | | } |
| | | |
| | | /** |
| | | * Extract unique webhook ID for deduplication |
| | | */ |
| | | protected function extractWebhookId(array $payload): ?string |
| | | { |
| | | $headers = $payload['_headers'] ?? []; |
| | | return $headers['webhook_id'][0] ?? $headers['webhook-id'] ?? $payload['id'] ?? null; |
| | | } |
| | | |
| | | /** |
| | | * Override the webhook request handler to capture raw body for signature verification |
| | | */ |
| | | public function handleWebhookRequest(\WP_REST_Request $request): \WP_REST_Response |
| | | { |
| | | $payload = $request->get_params(); |
| | | $payload['_headers'] = $request->get_headers(); |
| | | $payload['_raw_body'] = $request->get_body(); |
| | | |
| | | $success = $this->handleWebhook($payload); |
| | | |
| | | return new \WP_REST_Response([ |
| | | 'success' => $success, |
| | | ], $success ? 200 : 400); |
| | | } |
| | | /*********************************************************************** |
| | | * POST HOOKS |
| | | ***********************************************************************/ |
| | | /** |
| | | * Sync a WordPress post to Helcim as a product. |
| | | * Called by IntegrationExecutor::processSyncTo() |
| | | */ |
| | | public function syncPostToService(int $postID): array|\WP_Error |
| | | { |
| | | $post = get_post($postID); |
| | | if (!$post) { |
| | | return new \WP_Error('not_found', "Post {$postID} not found"); |
| | | } |
| | | |
| | | $helcimProductId = get_post_meta($postID, BASE . '_helcim_item_id', true); |
| | | $productData = $this->buildProductPayload($postID); |
| | | |
| | | if (is_wp_error($productData)) { |
| | | return $productData; |
| | | } |
| | | |
| | | if ($helcimProductId) { |
| | | // Update existing |
| | | $response = $this->patchRequest("products/{$helcimProductId}", $productData); |
| | | } else { |
| | | // Create new |
| | | $response = $this->postRequest('products', $productData); |
| | | } |
| | | |
| | | if (is_wp_error($response)) { |
| | | update_post_meta($postID, BASE . '_helcim_sync_status', 'error'); |
| | | return $response; |
| | | } |
| | | |
| | | // Store Helcim product ID |
| | | $newId = $response['id'] ?? $response['productId'] ?? $helcimProductId; |
| | | update_post_meta($postID, BASE . '_helcim_item_id', $newId); |
| | | update_post_meta($postID, BASE . '_helcim_sync_status', 'synced'); |
| | | update_post_meta($postID, BASE . '_helcim_last_sync', current_time('mysql')); |
| | | |
| | | return ['success' => true, 'helcim_id' => $newId]; |
| | | } |
| | | |
| | | /** |
| | | * Delete a product from Helcim. |
| | | * Called by IntegrationExecutor::processDeleteFrom() |
| | | */ |
| | | public function deleteFromService(string $externalId): array|\WP_Error |
| | | { |
| | | $response = $this->deleteRequest("products/{$externalId}"); |
| | | |
| | | if (is_wp_error($response)) { |
| | | return $response; |
| | | } |
| | | |
| | | return ['success' => true, 'deleted' => $externalId]; |
| | | } |
| | | |
| | | /** |
| | | * Build Helcim product payload from a WordPress post. |
| | | */ |
| | | protected function buildProductPayload(int $postID): array|\WP_Error |
| | | { |
| | | $meta = \JVBase\meta\Meta::forPost($postID); |
| | | $post = get_post($postID); |
| | | |
| | | $price = $meta->get('price'); |
| | | if (empty($price) || !is_numeric($price)) { |
| | | return new \WP_Error('invalid_price', "Post {$postID} has no valid price"); |
| | | } |
| | | |
| | | return [ |
| | | 'name' => $post->post_title, |
| | | 'description' => wp_strip_all_tags($post->post_content), |
| | | 'sku' => $meta->get('sku') ?: "wp-{$postID}", |
| | | 'price' => (float) $price, |
| | | 'taxExempt' => (bool) $meta->get('tax_exempt'), |
| | | ]; |
| | | } |
| | | } |
| New file |
| | |
| | | <?php |
| | | /** |
| | | * Instagram Integration |
| | | * File: /inc/integrations/Instagram.php |
| | | */ |
| | | |
| | | namespace JVBase\integrations\services; |
| | | use JVBase\integrations\Integrations; |
| | | use JVBase\meta\Meta; |
| | | |
| | | if (!defined('ABSPATH')) { |
| | | exit; |
| | | } |
| | | |
| | | class InstagramOld extends Integrations |
| | | { |
| | | protected array $allowedContent = [ |
| | | 'post' |
| | | ]; |
| | | private string $ig_user_id = ''; |
| | | private string $page_id = ''; |
| | | private string $page_access_token = ''; |
| | | private array $business_account = []; |
| | | private bool $auto_post = false; |
| | | private array $post_settings = []; |
| | | |
| | | // Instagram API endpoints |
| | | private const API_VERSION = 'v18.0'; |
| | | private const GRAPH_API_BASE = 'https://graph.facebook.com/'; |
| | | private const INSTAGRAM_BASE = 'https://graph.instagram.com/'; |
| | | |
| | | protected static array $instances = []; |
| | | public static function getInstance(?int $userID = null):self |
| | | { |
| | | $key = is_null($userID) ? 'base' : $userID; |
| | | if (!array_key_exists($key, self::$instances)) { |
| | | self::$instances[$key] = new self($userID); |
| | | } |
| | | |
| | | return self::$instances[$key]; |
| | | } |
| | | |
| | | protected function __construct(?int $userID = null) |
| | | { |
| | | $this->service_name = 'instagram'; |
| | | $this->title = 'Instagram'; |
| | | $this->icon = 'instagram-logo'; |
| | | $this->apiBase = [ |
| | | 'graph' => self::GRAPH_API_BASE . self::API_VERSION, |
| | | 'instagram' => self::INSTAGRAM_BASE . self::API_VERSION |
| | | ]; |
| | | $this->apiEndpoints = [ |
| | | 'me/accounts', |
| | | 'media', |
| | | 'media_publish', |
| | | 'content_publishing_limit', |
| | | 'insights' |
| | | ]; |
| | | |
| | | $this->isOAuthService = true; |
| | | // OAuth configuration for Facebook/Instagram |
| | | $this->oauth = [ |
| | | 'authorize' => 'https://www.facebook.com/' . self::API_VERSION . '/dialog/oauth', |
| | | 'token' => self::GRAPH_API_BASE . self::API_VERSION . '/oauth/access_token', |
| | | 'redirect_uri' => rest_url('jvb/v1/oauth/instagram'), |
| | | 'scopes' => [ |
| | | 'instagram_basic', |
| | | 'instagram_content_publish', |
| | | 'instagram_manage_insights', |
| | | 'pages_show_list', |
| | | 'pages_read_engagement', |
| | | 'business_management' |
| | | ] |
| | | ]; |
| | | |
| | | // Define sync capabilities |
| | | $this->canSync = [ |
| | | 'create' => true, |
| | | 'update' => false, // Instagram doesn't allow editing posts |
| | | 'delete' => true |
| | | ]; |
| | | |
| | | $this->fields = [ |
| | | |
| | | ]; |
| | | |
| | | $this->advanced = [ |
| | | |
| | | ]; |
| | | |
| | | $this->instructions = [ |
| | | 'Setup Facebook in order to use Instagram.' |
| | | ]; |
| | | |
| | | $this->defaults = [ |
| | | |
| | | ]; |
| | | $this->hasWebhooks = true; |
| | | parent::__construct($userID); |
| | | |
| | | $this->actions = array_merge( |
| | | $this->actions, |
| | | [ |
| | | 'switch_account' => 'switchInstagramAccount' |
| | | ] |
| | | ); |
| | | } |
| | | |
| | | protected function initialize(): void |
| | | { |
| | | $this->page_access_token = $this->credentials['page_access_token'] ?? ''; |
| | | $this->ig_user_id = $this->credentials['ig_user_id'] ?? ''; |
| | | $this->page_id = $this->credentials['page_id'] ?? ''; |
| | | $this->business_account = $this->credentials['business_account'] ?? []; |
| | | $this->auto_post = $this->credentials['auto_post'] ?? false; |
| | | $this->post_settings = $this->credentials['post_settings'] ?? [ |
| | | 'include_caption' => true, |
| | | 'include_hashtags' => true, |
| | | 'include_location' => false, |
| | | 'default_hashtags' => '' |
| | | ]; |
| | | |
| | | // Set rate limits |
| | | $this->rate_limits = [ |
| | | 'min_interval' => 30, |
| | | 'daily_limit' => 25, // Instagram content publishing limit |
| | | 'hourly_limit' => 5 |
| | | ]; |
| | | } |
| | | |
| | | /** |
| | | * Get request headers for Instagram API |
| | | */ |
| | | protected function getRequestHeaders(): array |
| | | { |
| | | return [ |
| | | 'Content-Type' => 'application/json', |
| | | 'Accept' => 'application/json' |
| | | ]; |
| | | } |
| | | |
| | | /** |
| | | * Build Instagram API URL |
| | | */ |
| | | protected function getApiUrl(string $endpoint, ?string $baseKey = null): string|false |
| | | { |
| | | $base = ($baseKey === 'instagram') ? $this->apiBase['instagram'] : $this->apiBase['graph']; |
| | | |
| | | // Handle Instagram-specific endpoints |
| | | if (in_array($endpoint, ['media', 'media_publish'])) { |
| | | return "{$base}/{$this->ig_user_id}/{$endpoint}"; |
| | | } |
| | | |
| | | return "{$base}/{$endpoint}"; |
| | | } |
| | | |
| | | /** |
| | | * Add OAuth parameters specific to Facebook/Instagram |
| | | */ |
| | | protected function addOAuthParams(array $params): array |
| | | { |
| | | $params['display'] = 'popup'; |
| | | $params['auth_type'] = 'rerequest'; |
| | | return $params; |
| | | } |
| | | |
| | | public function isSetUp():bool { |
| | | $fb = JVB()->connect('facebook'); |
| | | return $fb->isSetUp(); |
| | | } |
| | | |
| | | protected function getOAuthCredentials(): array |
| | | { |
| | | // Get Facebook integration |
| | | $facebook = JVB()->connect('facebook'); |
| | | |
| | | if (!$facebook || !$facebook->isSetUp()) { |
| | | return []; |
| | | } |
| | | |
| | | $fbCredentials = $facebook->getCredentials(); |
| | | |
| | | return [ |
| | | 'client_id' => $fbCredentials['app_id'] ?? $fbCredentials['client_id'] ?? '', |
| | | 'client_secret' => $fbCredentials['app_secret'] ?? $fbCredentials['client_secret'] ?? '', |
| | | 'access_token' => $fbCredentials['access_token'] ?? '', |
| | | ]; |
| | | } |
| | | |
| | | public function getCredentials():array { |
| | | $credentials = parent::getCredentials(); |
| | | if (empty($credentials)) { |
| | | $facebook = JVB()->connect('facebook'); |
| | | |
| | | if (!$facebook || !$facebook->isSetUp()) { |
| | | return []; |
| | | } |
| | | |
| | | $fbCredentials = $facebook->getCredentials(); |
| | | |
| | | return [ |
| | | 'client_id' => $fbCredentials['client_id'], |
| | | 'client_secret' => $fbCredentials['client_secret'], |
| | | ]; |
| | | } |
| | | return $credentials; |
| | | } |
| | | |
| | | private function switchInstagramAccount(array $data): WP_Error|array |
| | | { |
| | | $page_id = $data['page_id'] ?? ''; |
| | | |
| | | if (empty($page_id)) { |
| | | return [ |
| | | 'success' => false, |
| | | 'message' => 'Page ID is required' |
| | | ]; |
| | | } |
| | | |
| | | // Find the selected page in available pages |
| | | $selected_page = null; |
| | | foreach ($this->credentials['available_pages'] ?? [] as $page) { |
| | | if ($page['id'] === $page_id) { |
| | | $selected_page = $page; |
| | | break; |
| | | } |
| | | } |
| | | |
| | | if (!$selected_page || empty($selected_page['instagram_business_account'])) { |
| | | return [ |
| | | 'success' => false, |
| | | 'message' => 'Invalid page or no Instagram account' |
| | | ]; |
| | | } |
| | | |
| | | // Update credentials with new page |
| | | $this->credentials['page_id'] = $selected_page['id']; |
| | | $this->credentials['page_access_token'] = $selected_page['access_token']; |
| | | $this->credentials['ig_user_id'] = $selected_page['instagram_business_account']['id']; |
| | | $this->credentials['business_account'] = $this->getBusinessProfile( |
| | | $selected_page['instagram_business_account']['id'], |
| | | $selected_page['access_token'] |
| | | ); |
| | | |
| | | $this->saveCredentials($this->credentials); |
| | | |
| | | // Reinitialize with new credentials |
| | | $this->initialize(); |
| | | |
| | | return [ |
| | | 'success' => true, |
| | | 'message' => 'Switched to @' . ($this->credentials['business_account']['username'] ?? 'Instagram account'), |
| | | 'account' => $this->credentials['business_account'] |
| | | ]; |
| | | } |
| | | /** |
| | | * Handle post save - queue Instagram post creation |
| | | */ |
| | | public function handleTheSavePost(int $postID, \WP_Post $post, bool $update, array $settings): void |
| | | { |
| | | |
| | | if (!$this->hasOAuthCredentials()) { |
| | | error_log('OAuth Not set up for '.$this->service_name); |
| | | return; |
| | | } |
| | | if (!has_post_thumbnail($postID)) { |
| | | return; |
| | | } |
| | | |
| | | $this->queueOperation(self::$syncTo, [ |
| | | 'items' => [$postID], |
| | | 'user' => user_can($post->post_author, 'manage_options') ? null : $post->post_author |
| | | ], [ |
| | | 'delay' => 60 |
| | | ]); |
| | | Meta::forPost($postID)->set('_'.$this->service_name.'_sync_status', 'queued'); |
| | | } |
| | | |
| | | /** |
| | | * Process queued operations |
| | | */ |
| | | public function processOperation(\WP_Error|array $result, object $operation, array $data): \WP_Error|array |
| | | { |
| | | $base = strtolower($this->service_name) . '_'; |
| | | $insta = (array_key_exists('user', $data)) ? new self((int)$data['user']) : $this; |
| | | switch ($operation->type) { |
| | | case $base . 'create_post': |
| | | return $insta->processCreatePost($data); |
| | | |
| | | case $base . 'create_story': |
| | | return $insta->processCreateStory($data); |
| | | |
| | | case $base . 'delete_post': |
| | | return $insta->processDeletePost($data); |
| | | |
| | | default: |
| | | return $result; |
| | | } |
| | | } |
| | | |
| | | private function processDeletePost(array $data): array |
| | | { |
| | | return [ |
| | | 'success' => true, |
| | | 'message' => 'Instagram sync data removed (post remains on Instagram)', |
| | | 'note' => 'Instagram API does not support deleting posts programmatically' |
| | | ]; |
| | | } |
| | | |
| | | /** |
| | | * Process post creation for Instagram |
| | | */ |
| | | private function processCreatePost(array $data): array |
| | | { |
| | | $post_id = $data['post_id'] ?? 0; |
| | | $post = get_post($post_id); |
| | | |
| | | if (!$post) { |
| | | return ['success' => false, 'message' => 'Post not found']; |
| | | } |
| | | |
| | | try { |
| | | // Get image URL |
| | | $image_url = get_the_post_thumbnail_url($post_id, 'full'); |
| | | if (!$image_url) { |
| | | throw new Exception('No featured image found'); |
| | | } |
| | | |
| | | // Build caption |
| | | $caption = $this->buildCaption($post); |
| | | |
| | | // Create media container |
| | | $container = $this->createMediaContainer($image_url, $caption); |
| | | |
| | | if (!$container || empty($container['id'])) { |
| | | throw new Exception('Failed to create media container'); |
| | | } |
| | | |
| | | // Wait for processing (Instagram needs time to process the image) |
| | | sleep(10); |
| | | |
| | | // Publish the media |
| | | $published = $this->publishMedia($container['id']); |
| | | |
| | | if ($published && !empty($published['id'])) { |
| | | // Store Instagram media ID |
| | | update_post_meta($post_id, BASE . '_instagram_media_id', $published['id']); |
| | | update_post_meta($post_id, BASE . '_instagram_sync_status', 'synced'); |
| | | update_post_meta($post_id, BASE . '_instagram_sync_date', current_time('mysql')); |
| | | |
| | | // Get permalink if available |
| | | if (!empty($published['permalink'])) { |
| | | update_post_meta($post_id, BASE . '_instagram_permalink', $published['permalink']); |
| | | } |
| | | |
| | | return [ |
| | | 'success' => true, |
| | | 'message' => 'Posted to Instagram successfully', |
| | | 'instagram_id' => $published['id'] |
| | | ]; |
| | | } |
| | | |
| | | throw new Exception('Failed to publish media'); |
| | | |
| | | } catch (Exception $e) { |
| | | $this->logError('Instagram post creation failed', [ |
| | | 'post_id' => $post_id, |
| | | 'error' => $e->getMessage() |
| | | ]); |
| | | |
| | | update_post_meta($post_id, BASE . '_instagram_sync_status', 'failed'); |
| | | update_post_meta($post_id, BASE . '_instagram_sync_error', $e->getMessage()); |
| | | |
| | | return [ |
| | | 'success' => false, |
| | | 'message' => $e->getMessage() |
| | | ]; |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Build caption for Instagram post |
| | | */ |
| | | private function buildCaption(\WP_Post $post): string |
| | | { |
| | | $caption_parts = []; |
| | | |
| | | // Add title and description if enabled |
| | | if ($this->post_settings['include_caption'] ?? true) { |
| | | $caption_parts[] = $post->post_title; |
| | | |
| | | if (!empty($post->post_excerpt)) { |
| | | $caption_parts[] = "\n\n" . $post->post_excerpt; |
| | | } |
| | | } |
| | | |
| | | // Add hashtags |
| | | if ($this->post_settings['include_hashtags'] ?? true) { |
| | | $hashtags = []; |
| | | |
| | | // Get tags from post |
| | | $tags = wp_get_post_tags($post->ID, ['fields' => 'names']); |
| | | foreach ($tags as $tag) { |
| | | $hashtags[] = '#' . str_replace(' ', '', $tag); |
| | | } |
| | | |
| | | // Add default hashtags |
| | | if (!empty($this->post_settings['default_hashtags'])) { |
| | | $default = preg_split('/[\s,]+/', $this->post_settings['default_hashtags']); |
| | | foreach ($default as $tag) { |
| | | $tag = trim($tag); |
| | | if (strpos($tag, '#') !== 0) { |
| | | $tag = '#' . $tag; |
| | | } |
| | | $hashtags[] = $tag; |
| | | } |
| | | } |
| | | |
| | | if (!empty($hashtags)) { |
| | | $caption_parts[] = "\n\n" . implode(' ', array_unique($hashtags)); |
| | | } |
| | | } |
| | | |
| | | // Add website link |
| | | $caption_parts[] = "\n\n🔗 " . get_permalink($post); |
| | | |
| | | return implode('', $caption_parts); |
| | | } |
| | | |
| | | /** |
| | | * Webhook validation |
| | | */ |
| | | protected function validateWebhook(array $payload): bool |
| | | { |
| | | // Facebook webhook verification |
| | | if (isset($payload['hub_mode']) && $payload['hub_mode'] === 'subscribe') { |
| | | $verify_token = $this->credentials['webhook_verify_token'] ?? 'jvb_instagram_webhook'; |
| | | |
| | | if ($payload['hub_verify_token'] === $verify_token) { |
| | | // Echo the challenge for verification |
| | | echo $payload['hub_challenge']; |
| | | exit; |
| | | } |
| | | |
| | | return false; |
| | | } |
| | | |
| | | // Validate webhook signature for actual events |
| | | $signature = $_SERVER['HTTP_X_HUB_SIGNATURE_256'] ?? ''; |
| | | if (!$signature) { |
| | | return false; |
| | | } |
| | | |
| | | $app_secret = $this->credentials['app_secret'] ?? ''; |
| | | if (!$app_secret) { |
| | | return true; // Allow if no secret configured |
| | | } |
| | | |
| | | $expected = 'sha256=' . hash_hmac('sha256', file_get_contents('php://input'), $app_secret); |
| | | return hash_equals($expected, $signature); |
| | | } |
| | | |
| | | /** |
| | | * Process webhook |
| | | */ |
| | | protected function processWebhook(array $payload): bool |
| | | { |
| | | $entry = $payload['entry'][0] ?? []; |
| | | $changes = $entry['changes'] ?? []; |
| | | |
| | | foreach ($changes as $change) { |
| | | $field = $change['field'] ?? ''; |
| | | $value = $change['value'] ?? []; |
| | | |
| | | switch ($field) { |
| | | case 'comments': |
| | | $this->handleCommentWebhook($value); |
| | | break; |
| | | |
| | | case 'mentions': |
| | | $this->handleMentionWebhook($value); |
| | | break; |
| | | |
| | | default: |
| | | $this->logDebug('Unhandled webhook field', ['field' => $field]); |
| | | } |
| | | } |
| | | |
| | | return true; |
| | | } |
| | | |
| | | /** |
| | | * API Helper Methods |
| | | */ |
| | | |
| | | private function getFacebookPages(string $access_token): array |
| | | { |
| | | $response = wp_remote_get( |
| | | $this->apiBase['graph'] . '/me/accounts?' . http_build_query([ |
| | | 'access_token' => $access_token, |
| | | 'fields' => 'id,name,access_token,instagram_business_account' |
| | | ]) |
| | | ); |
| | | |
| | | if (is_wp_error($response)) { |
| | | $this->logError('Failed to get Facebook pages', ['error' => $response->get_error_message()]); |
| | | return []; |
| | | } |
| | | |
| | | $data = json_decode(wp_remote_retrieve_body($response), true); |
| | | return $data['data'] ?? []; |
| | | } |
| | | |
| | | private function getBusinessProfile(string $ig_user_id, string $access_token): array |
| | | { |
| | | $response = wp_remote_get( |
| | | $this->apiBase['graph'] . '/' . $ig_user_id . '?' . http_build_query([ |
| | | 'access_token' => $access_token, |
| | | 'fields' => 'id,username,name,profile_picture_url,followers_count,media_count,biography,website' |
| | | ]) |
| | | ); |
| | | |
| | | if (is_wp_error($response)) { |
| | | return []; |
| | | } |
| | | |
| | | return json_decode(wp_remote_retrieve_body($response), true) ?: []; |
| | | } |
| | | |
| | | private function createMediaContainer(string $image_url, string $caption): ?array |
| | | { |
| | | $response = wp_remote_post( |
| | | $this->getApiUrl('media', 'instagram'), |
| | | [ |
| | | 'body' => [ |
| | | 'image_url' => $image_url, |
| | | 'caption' => $caption, |
| | | 'access_token' => $this->page_access_token |
| | | ] |
| | | ] |
| | | ); |
| | | |
| | | if (is_wp_error($response)) { |
| | | $this->logError('Failed to create media container', ['error' => $response->get_error_message()]); |
| | | return null; |
| | | } |
| | | |
| | | return json_decode(wp_remote_retrieve_body($response), true); |
| | | } |
| | | |
| | | private function publishMedia(string $creation_id): ?array |
| | | { |
| | | $response = wp_remote_post( |
| | | $this->getApiUrl('media_publish', 'instagram'), |
| | | [ |
| | | 'body' => [ |
| | | 'creation_id' => $creation_id, |
| | | 'access_token' => $this->page_access_token |
| | | ] |
| | | ] |
| | | ); |
| | | |
| | | if (is_wp_error($response)) { |
| | | $this->logError('Failed to publish media', ['error' => $response->get_error_message()]); |
| | | return null; |
| | | } |
| | | |
| | | return json_decode(wp_remote_retrieve_body($response), true); |
| | | } |
| | | |
| | | private function getPublishingLimit(): array |
| | | { |
| | | $response = wp_remote_get( |
| | | $this->getApiUrl('content_publishing_limit', 'instagram') . '?' . http_build_query([ |
| | | 'access_token' => $this->page_access_token |
| | | ]) |
| | | ); |
| | | |
| | | if (is_wp_error($response)) { |
| | | return new WP_Error('api_error', $response->get_error_message()); |
| | | } |
| | | |
| | | $data = json_decode(wp_remote_retrieve_body($response), true); |
| | | |
| | | return [ |
| | | 'success' => true, |
| | | 'data' => [ |
| | | 'quota_usage' => $data['data'][0]['quota_usage'] ?? 0, |
| | | 'quota_total' => $data['data'][0]['config']['quota_total'] ?? 25, |
| | | 'quota_duration' => $data['data'][0]['config']['quota_duration'] ?? 86400 |
| | | ] |
| | | ]; |
| | | } |
| | | |
| | | protected function performConnectionTest(): bool |
| | | { |
| | | if (empty($this->ig_user_id) || empty($this->page_access_token)) { |
| | | return false; |
| | | } |
| | | |
| | | // Test by getting account info |
| | | $profile = $this->getBusinessProfile($this->ig_user_id, $this->page_access_token); |
| | | |
| | | if (!empty($profile['id'])) { |
| | | return true; |
| | | } |
| | | |
| | | return false; |
| | | } |
| | | |
| | | private function createStory(array $data): WP_Error|array |
| | | { |
| | | $image_url = $data['image_url'] ?? ''; |
| | | |
| | | if (empty($image_url)) { |
| | | return new WP_Error('missing_image', 'Image URL required for story'); |
| | | } |
| | | |
| | | // Queue story creation |
| | | $this->queueOperation('create_story', [ |
| | | 'image_url' => $image_url, |
| | | 'sticker_url' => $data['sticker_url'] ?? '' |
| | | ], [ |
| | | 'priority' => 'high' |
| | | ]); |
| | | |
| | | return [ |
| | | 'success' => true, |
| | | 'message' => 'Story creation queued' |
| | | ]; |
| | | } |
| | | |
| | | private function processCreateStory(array $data): array |
| | | { |
| | | $params = [ |
| | | 'image_url' => $data['image_url'], |
| | | 'media_type' => 'STORIES', |
| | | 'access_token' => $this->page_access_token |
| | | ]; |
| | | |
| | | if (!empty($data['sticker_url'])) { |
| | | $params['sticker_url'] = $data['sticker_url']; |
| | | } |
| | | |
| | | // Create story container |
| | | $response = wp_remote_post( |
| | | $this->getApiUrl('media', 'instagram'), |
| | | ['body' => $params] |
| | | ); |
| | | |
| | | if (is_wp_error($response)) { |
| | | return [ |
| | | 'success' => false, |
| | | 'message' => $response->get_error_message() |
| | | ]; |
| | | } |
| | | |
| | | $container = json_decode(wp_remote_retrieve_body($response), true); |
| | | |
| | | if (empty($container['id'])) { |
| | | return [ |
| | | 'success' => false, |
| | | 'message' => 'Failed to create story container' |
| | | ]; |
| | | } |
| | | |
| | | // Wait for processing |
| | | sleep(5); |
| | | |
| | | // Publish story |
| | | $published = $this->publishMedia($container['id']); |
| | | |
| | | return [ |
| | | 'success' => !empty($published['id']), |
| | | 'message' => !empty($published['id']) ? 'Story published' : 'Failed to publish story', |
| | | 'story_id' => $published['id'] ?? null |
| | | ]; |
| | | } |
| | | |
| | | private function handleCommentWebhook(array $value): void |
| | | { |
| | | // Log new comments for moderation |
| | | $this->logDebug('New Instagram comment', $value); |
| | | |
| | | // Could trigger notifications or store for moderation |
| | | do_action(BASE . 'instagram_comment_received', $value); |
| | | } |
| | | |
| | | private function handleMentionWebhook(array $value): void |
| | | { |
| | | // Log mentions |
| | | $this->logDebug('New Instagram mention', $value); |
| | | |
| | | // Could trigger notifications |
| | | do_action(BASE . 'instagram_mention_received', $value); |
| | | } |
| | | |
| | | /** |
| | | * Get service description |
| | | */ |
| | | public function getServiceDescription(): string |
| | | { |
| | | return "Connect your Instagram Business account to automatically share your content and manage your Instagram presence."; |
| | | } |
| | | } |
| New file |
| | |
| | | <?php |
| | | |
| | | namespace JVBase\integrations\services; |
| | | use JVBase\integrations\Integrations; |
| | | if (!defined('ABSPATH')) { |
| | | exit; |
| | | } |
| | | |
| | | /** |
| | | * PostMark Email Service Integration |
| | | * |
| | | * Provides integration with PostMark for transactional email delivery |
| | | * Hooks into WordPress's wp_mail() function for seamless integration |
| | | */ |
| | | class PostMarkOld extends Integrations |
| | | { |
| | | protected ?string $server_token = null; |
| | | protected string $message_stream = 'outbound'; |
| | | protected string $from_email; |
| | | protected string $from_name; |
| | | protected bool $track_open; |
| | | protected bool $track_links; |
| | | protected ?string $lastMessageId = null; |
| | | |
| | | protected static array $instances = []; |
| | | public static function getInstance(?int $userID = null):self |
| | | { |
| | | $key = is_null($userID) ? 'base' : $userID; |
| | | if (!array_key_exists($key, self::$instances)) { |
| | | self::$instances[$key] = new static($userID); |
| | | } |
| | | return self::$instances[$key]; |
| | | } |
| | | /** |
| | | * Constructor |
| | | */ |
| | | protected function __construct(?int $userID = null) |
| | | { |
| | | $this->service_name = 'postmark'; |
| | | $this->title = 'PostMark'; |
| | | $this->icon = 'envelope-simple'; |
| | | |
| | | $this->cacheName = ($userID)? 'gmb_'.$userID : 'postmark'; |
| | | |
| | | // PostMark API configuration |
| | | $this->apiBase = 'https://api.postmarkapp.com'; |
| | | $this->apiVersion = 'v1'; |
| | | |
| | | // Define available endpoints |
| | | $this->apiEndpoints = [ |
| | | 'email', |
| | | 'email/batch', |
| | | 'email/withTemplate', |
| | | 'bounces', |
| | | 'deliverystats', |
| | | 'messages/outbound', |
| | | 'messages/outbound/[^/]+', |
| | | 'messages/outbound/opens', |
| | | 'stats/outbound', |
| | | 'stats/outbound/opens', |
| | | 'stats/outbound/bounces', |
| | | 'templates', |
| | | 'templates/[^/]+', |
| | | 'server' |
| | | ]; |
| | | |
| | | // Required credentials |
| | | $this->fields = [ |
| | | 'server_token' => [ |
| | | 'label' => 'Server API Token', |
| | | 'type' => 'text', |
| | | 'subtype'=> 'password', |
| | | 'placeholder' => 'xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx', |
| | | 'hint' => 'Your PostMark Server API Token' |
| | | ], |
| | | 'message_stream' => [ |
| | | 'label' => 'Message Stream ID', |
| | | 'type' => 'text', |
| | | 'placeholder' => 'outbound', |
| | | 'default' => 'outbound', |
| | | 'description' => 'Message stream to use (default: outbound)' |
| | | ], |
| | | 'from_email' => [ |
| | | 'label' => 'From Email', |
| | | 'type' => 'email', |
| | | 'placeholder' => 'noreply@yourdomain.com', |
| | | 'description' => 'Default sender email address (must be verified in PostMark)' |
| | | ], |
| | | 'from_name' => [ |
| | | 'label' => 'From Name', |
| | | 'type' => 'text', |
| | | 'placeholder' => get_bloginfo('name'), |
| | | 'default' => get_bloginfo('name'), |
| | | 'description' => 'Default sender name' |
| | | ], |
| | | 'track_opens' => [ |
| | | 'label' => 'Track Opens', |
| | | 'type' => 'select', |
| | | 'options' => [ |
| | | 'false' => 'Disabled', |
| | | 'true' => 'Enabled' |
| | | ], |
| | | 'default' => 'false', |
| | | 'description' => 'Track when emails are opened' |
| | | ], |
| | | 'track_links' => [ |
| | | 'label' => 'Track Links', |
| | | 'type' => 'select', |
| | | 'options' => [ |
| | | 'None' => 'Disabled', |
| | | 'HtmlOnly' => 'HTML Only', |
| | | 'HtmlAndText' => 'HTML and Text', |
| | | ], |
| | | 'default' => 'None', |
| | | 'description' => 'Track link clicks in emails' |
| | | ] |
| | | ]; |
| | | |
| | | parent::__construct(); |
| | | |
| | | // Hook into WordPress mail system if integration is healthy |
| | | if ($this->is_healthy && $this->isSetUp()) { |
| | | $this->initializeMailHooks(); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Initialize WordPress mail hooks |
| | | */ |
| | | protected function initializeMailHooks(): void |
| | | { |
| | | // Replace wp_mail with PostMark |
| | | add_filter('wp_mail', [$this, 'sendViaPostMark'], 10, 1); |
| | | |
| | | // Optional: Hook for logging sent emails |
| | | add_action('postmark_email_sent', [$this, 'logSentEmail'], 10, 2); |
| | | add_action('postmark_email_failed', [$this, 'logFailedEmail'], 10, 2); |
| | | } |
| | | |
| | | /** |
| | | * Send email via PostMark API |
| | | * Hooks into wp_mail filter |
| | | * |
| | | * @param array $args Email arguments from wp_mail |
| | | * @return array Modified args (or original if sending via PostMark) |
| | | */ |
| | | public function sendViaPostMark(array $args): array |
| | | { |
| | | // Extract email components |
| | | $to = $args['to']; |
| | | $subject = $args['subject']; |
| | | $message = $args['message']; |
| | | $headers = $args['headers'] ?? ''; |
| | | $attachments = $args['attachments'] ?? []; |
| | | |
| | | // Parse headers |
| | | $parsed_headers = $this->parseMailHeaders($headers); |
| | | |
| | | // Determine content type |
| | | $is_html = $parsed_headers['content-type'] === 'text/html' || |
| | | strpos($message, '<html') !== false || |
| | | strpos($message, '<body') !== false; |
| | | |
| | | // Build PostMark payload |
| | | $payload = $this->buildEmailPayload( |
| | | $to, |
| | | $subject, |
| | | $message, |
| | | $is_html, |
| | | $parsed_headers, |
| | | $attachments |
| | | ); |
| | | |
| | | // Send via PostMark |
| | | $result = $this->sendEmail($payload); |
| | | |
| | | if ($result === true) { |
| | | error_log('================================ Email sent! ================================'); |
| | | // Prevent default wp_mail from sending |
| | | add_filter('pre_wp_mail', '__return_true'); |
| | | do_action('postmark_email_sent', $args, $payload); |
| | | } else { |
| | | error_log('=-======================[POSTMARK]Something went wrong... ================================'); |
| | | // Log failure but allow fallback to default mail |
| | | do_action('postmark_email_failed', $args, $result); |
| | | |
| | | // Optionally fall back to default wp_mail |
| | | if ($this->shouldFallback()) { |
| | | return $args; |
| | | } |
| | | } |
| | | |
| | | return $args; |
| | | } |
| | | |
| | | /** |
| | | * Build email payload for PostMark API |
| | | */ |
| | | protected function buildEmailPayload( |
| | | $to, |
| | | string $subject, |
| | | string $message, |
| | | bool $is_html, |
| | | array $headers, |
| | | array $attachments |
| | | ): array { |
| | | $credentials = $this->getCredentials(); |
| | | |
| | | // Handle multiple recipients |
| | | $to_addresses = is_array($to) ? implode(',', $to) : $to; |
| | | |
| | | // Build base payload |
| | | $payload = [ |
| | | 'From' => sprintf( |
| | | '%s <%s>', |
| | | $headers['from-name'] ?? $credentials['from_name'], |
| | | $headers['from'] ?? $credentials['from_email'] |
| | | ), |
| | | 'To' => $to_addresses, |
| | | 'Subject' => $subject, |
| | | 'TrackOpens' => $credentials['track_opens'] === 'true', |
| | | 'TrackLinks' => $credentials['track_links'], |
| | | 'MessageStream' => $credentials['message_stream'] ?? 'outbound' |
| | | ]; |
| | | |
| | | // Set content based on type |
| | | if ($is_html) { |
| | | $payload['HtmlBody'] = $message; |
| | | // Generate text version from HTML |
| | | $payload['TextBody'] = $this->generateTextFromHtml($message); |
| | | } else { |
| | | $payload['TextBody'] = $message; |
| | | } |
| | | |
| | | // Add CC/BCC if present |
| | | if (!empty($headers['cc'])) { |
| | | $payload['Cc'] = is_array($headers['cc']) ? |
| | | implode(',', $headers['cc']) : $headers['cc']; |
| | | } |
| | | |
| | | if (!empty($headers['bcc'])) { |
| | | $payload['Bcc'] = is_array($headers['bcc']) ? |
| | | implode(',', $headers['bcc']) : $headers['bcc']; |
| | | } |
| | | |
| | | // Add Reply-To if present |
| | | if (!empty($headers['reply-to'])) { |
| | | $payload['ReplyTo'] = $headers['reply-to']; |
| | | } |
| | | |
| | | // Handle attachments |
| | | if (!empty($attachments)) { |
| | | $payload['Attachments'] = $this->processAttachments($attachments); |
| | | } |
| | | |
| | | // Add custom headers if needed |
| | | if (!empty($headers['x-headers'])) { |
| | | $payload['Headers'] = $headers['x-headers']; |
| | | } |
| | | |
| | | // Add tags for tracking (optional) |
| | | $payload['Tag'] = $this->determineEmailTag($subject, $headers); |
| | | |
| | | return $payload; |
| | | } |
| | | |
| | | /** |
| | | * Send email via PostMark API |
| | | */ |
| | | protected function sendEmail(array $payload): bool|WP_Error |
| | | { |
| | | if (!$this->isSetUp()) { |
| | | return false; |
| | | } |
| | | try { |
| | | $response = $this->postRequest('email', $payload); |
| | | error_log('================================ POSTMARK RESPONSE: ================================'.print_r($response, true)); |
| | | if (is_wp_error($response)) { |
| | | return $response; |
| | | } |
| | | |
| | | // Store message ID for tracking |
| | | if (!empty($response['MessageID'])) { |
| | | $this->lastMessageId = $response['MessageID']; |
| | | |
| | | // Cache for potential status checking |
| | | $this->cache->set( |
| | | 'postmark_message_' . $response['MessageID'], |
| | | $payload, |
| | | 3600 // 1 hour |
| | | ); |
| | | } |
| | | |
| | | return true; |
| | | |
| | | } catch (\Exception $e) { |
| | | $this->logError('[POSTMARK]', ['method' => 'sendEmail', 'error' => $e->getMessage()]); |
| | | return new WP_Error( |
| | | 'postmark_send_failed', |
| | | $e->getMessage() |
| | | ); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Parse mail headers from various formats |
| | | */ |
| | | protected function parseMailHeaders($headers): array |
| | | { |
| | | $parsed = [ |
| | | 'content-type' => 'text/plain', |
| | | 'from' => null, |
| | | 'from-name' => null, |
| | | 'reply-to' => null, |
| | | 'cc' => [], |
| | | 'bcc' => [], |
| | | 'x-headers' => [] |
| | | ]; |
| | | |
| | | if (empty($headers)) { |
| | | return $parsed; |
| | | } |
| | | |
| | | // Handle array format |
| | | if (is_array($headers)) { |
| | | foreach ($headers as $header) { |
| | | $this->parseHeaderLine($header, $parsed); |
| | | } |
| | | } else { |
| | | // Handle string format (multiple lines) |
| | | $lines = explode("\n", $headers); |
| | | foreach ($lines as $line) { |
| | | $this->parseHeaderLine($line, $parsed); |
| | | } |
| | | } |
| | | |
| | | return $parsed; |
| | | } |
| | | |
| | | /** |
| | | * Parse individual header line |
| | | */ |
| | | protected function parseHeaderLine(string $line, array &$parsed): void |
| | | { |
| | | if (empty($line)) { |
| | | return; |
| | | } |
| | | |
| | | // Split header name and value |
| | | $parts = explode(':', $line, 2); |
| | | if (count($parts) !== 2) { |
| | | return; |
| | | } |
| | | |
| | | $name = strtolower(trim($parts[0])); |
| | | $value = trim($parts[1]); |
| | | |
| | | switch ($name) { |
| | | case 'content-type': |
| | | if (strpos($value, 'text/html') !== false) { |
| | | $parsed['content-type'] = 'text/html'; |
| | | } |
| | | break; |
| | | |
| | | case 'from': |
| | | // Parse "Name <email>" format |
| | | if (preg_match('/^(.+?)\s*<(.+)>$/', $value, $matches)) { |
| | | $parsed['from-name'] = trim($matches[1], '"\''); |
| | | $parsed['from'] = $matches[2]; |
| | | } else { |
| | | $parsed['from'] = $value; |
| | | } |
| | | break; |
| | | |
| | | case 'reply-to': |
| | | $parsed['reply-to'] = $value; |
| | | break; |
| | | |
| | | case 'cc': |
| | | $parsed['cc'][] = $value; |
| | | break; |
| | | |
| | | case 'bcc': |
| | | $parsed['bcc'][] = $value; |
| | | break; |
| | | |
| | | default: |
| | | // Store custom headers |
| | | if (strpos($name, 'x-') === 0) { |
| | | $parsed['x-headers'][] = [ |
| | | 'Name' => $parts[0], // Original case |
| | | 'Value' => $value |
| | | ]; |
| | | } |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Process attachments for PostMark |
| | | */ |
| | | protected function processAttachments(array $attachments): array |
| | | { |
| | | $processed = []; |
| | | |
| | | foreach ($attachments as $file) { |
| | | if (!file_exists($file)) { |
| | | continue; |
| | | } |
| | | |
| | | $processed[] = [ |
| | | 'Name' => basename($file), |
| | | 'Content' => base64_encode(file_get_contents($file)), |
| | | 'ContentType' => mime_content_type($file) ?: 'application/octet-stream' |
| | | ]; |
| | | } |
| | | |
| | | return $processed; |
| | | } |
| | | |
| | | /** |
| | | * Generate plain text from HTML content |
| | | */ |
| | | protected function generateTextFromHtml(string $html): string |
| | | { |
| | | // Remove HTML tags |
| | | $text = strip_tags($html); |
| | | |
| | | // Convert entities |
| | | $text = html_entity_decode($text, ENT_QUOTES | ENT_HTML5, 'UTF-8'); |
| | | |
| | | // Clean up whitespace |
| | | $text = preg_replace('/\s+/', ' ', $text); |
| | | return trim($text); |
| | | } |
| | | |
| | | /** |
| | | * Determine email tag based on content |
| | | */ |
| | | protected function determineEmailTag(string $subject, array $headers): string |
| | | { |
| | | // Password reset emails |
| | | if (stripos($subject, 'password') !== false && stripos($subject, 'reset') !== false) { |
| | | return 'password-reset'; |
| | | } |
| | | |
| | | // New user registration |
| | | if (stripos($subject, 'welcome') !== false || stripos($subject, 'registration') !== false) { |
| | | return 'registration'; |
| | | } |
| | | |
| | | // Account notifications |
| | | if (stripos($subject, 'account') !== false) { |
| | | return 'account'; |
| | | } |
| | | |
| | | // Order/transaction emails |
| | | if (stripos($subject, 'order') !== false || stripos($subject, 'receipt') !== false) { |
| | | return 'transaction'; |
| | | } |
| | | |
| | | // Default |
| | | return 'general'; |
| | | } |
| | | |
| | | /** |
| | | * Check if should fallback to default mail |
| | | */ |
| | | protected function shouldFallback(): bool |
| | | { |
| | | // Don't fallback if we've had too many errors |
| | | if ($this->error_stats['consecutive_errors'] >= 3) { |
| | | return false; |
| | | } |
| | | |
| | | // Check if fallback is enabled in settings |
| | | $credentials = $this->getCredentials(); |
| | | return ($credentials['enable_fallback'] ?? 'true') === 'true'; |
| | | } |
| | | |
| | | /** |
| | | * Override makeRequest to add PostMark headers |
| | | */ |
| | | protected function getRequestHeaders(): array { |
| | | $credentials = $this->getCredentials(); |
| | | |
| | | // Add PostMark specific headers |
| | | return [ |
| | | 'Accept' => 'application/json', |
| | | 'Content-Type' => 'application/json', |
| | | 'X-Postmark-Server-Token' => $credentials['server_token'] ?? '' |
| | | ]; |
| | | } |
| | | |
| | | /** |
| | | * Get email statistics |
| | | */ |
| | | public function getEmailStats(int $days = 30): array |
| | | { |
| | | $cache_key = 'postmark_stats_' . $days; |
| | | $cached = $this->cache->get($cache_key); |
| | | |
| | | if ($cached !== false) { |
| | | return $cached; |
| | | } |
| | | |
| | | try { |
| | | $stats = $this->getRequest('stats/outbound', [ |
| | | 'fromdate' => date('Y-m-d', strtotime("-{$days} days")), |
| | | 'todate' => date('Y-m-d') |
| | | ]); |
| | | |
| | | if (!is_wp_error($stats)) { |
| | | $this->cache->set($cache_key, $stats, 3600); // Cache for 1 hour |
| | | return $stats; |
| | | } |
| | | } catch (\Exception $e) { |
| | | $this->logError('stats/outbound', ['error' => $e->getMessage()]); |
| | | } |
| | | |
| | | return []; |
| | | } |
| | | |
| | | /** |
| | | * Get bounce information |
| | | */ |
| | | public function getBounces(int $count = 25, int $offset = 0): array |
| | | { |
| | | try { |
| | | return $this->getRequest('bounces', [ |
| | | 'count' => $count, |
| | | 'offset' => $offset |
| | | ]); |
| | | } catch (\Exception $e) { |
| | | $this->logError('[POSTMARK]', ['method' => 'getBounces', 'error' => $e->getMessage()]); |
| | | return []; |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Log sent emails for debugging/tracking |
| | | */ |
| | | public function logSentEmail(array $args, array $payload): void |
| | | { |
| | | if (!defined('WP_DEBUG') || !WP_DEBUG) { |
| | | return; |
| | | } |
| | | |
| | | $this->logDebug('Email sent via PostMark', [ |
| | | 'to' => $args['to'], |
| | | 'subject' => $args['subject'], |
| | | 'message_id' => $this->lastMessageId ?? 'unknown', |
| | | 'tag' => $payload['Tag'] ?? 'none' |
| | | ]); |
| | | } |
| | | |
| | | /** |
| | | * Log failed email attempts |
| | | */ |
| | | public function logFailedEmail(array $args, $error): void |
| | | { |
| | | $error_message = is_wp_error($error) ? $error->get_error_message() : 'Unknown error'; |
| | | |
| | | $this->logError('Failed to send email via PostMark', [ |
| | | 'to' => $args['to'], |
| | | 'subject' => $args['subject'], |
| | | 'error' => $error_message |
| | | ]); |
| | | } |
| | | |
| | | /** |
| | | * Test email configuration |
| | | */ |
| | | public function sendTestEmail(string $to_email): bool|WP_Error |
| | | { |
| | | $payload = [ |
| | | 'From' => $this->getCredentials()['from_email'] . ' <' . $this->getCredentials()['from_email'] . '>', |
| | | 'To' => $to_email, |
| | | 'Subject' => 'PostMark Test Email - ' . get_bloginfo('name'), |
| | | 'TextBody' => 'This is a test email from your PostMark integration.', |
| | | 'HtmlBody' => '<p>This is a <strong>test email</strong> from your PostMark integration.</p>', |
| | | 'Tag' => 'test', |
| | | 'TrackOpens' => false, |
| | | 'TrackLinks' => 'None' |
| | | ]; |
| | | |
| | | return $this->sendEmail($payload); |
| | | } |
| | | |
| | | /** |
| | | * Render additional options in dashboard |
| | | */ |
| | | public function renderAdditionalOptions(): void |
| | | { |
| | | $stats = $this->getEmailStats(7); |
| | | |
| | | ?> |
| | | <div class="postmark-dashboard"> |
| | | <h3>Email Statistics (Last 7 Days)</h3> |
| | | |
| | | <?php if (!empty($stats)): ?> |
| | | <div class="stats-grid"> |
| | | <div class="stat-item"> |
| | | <span class="stat-value"><?= number_format($stats['Sent'] ?? 0) ?></span> |
| | | <span class="stat-label">Emails Sent</span> |
| | | </div> |
| | | <div class="stat-item"> |
| | | <span class="stat-value"><?= number_format($stats['Bounced'] ?? 0) ?></span> |
| | | <span class="stat-label">Bounced</span> |
| | | </div> |
| | | <div class="stat-item"> |
| | | <span class="stat-value"><?= $stats['Opens'] ?? 0 ?>%</span> |
| | | <span class="stat-label">Open Rate</span> |
| | | </div> |
| | | </div> |
| | | <?php else: ?> |
| | | <p>No statistics available. Check your API configuration.</p> |
| | | <?php endif; ?> |
| | | |
| | | <div class="test-email-section"> |
| | | <h4>Send Test Email</h4> |
| | | <form id="postmark-test-email" method="post"> |
| | | <input type="email" |
| | | name="test_email" |
| | | placeholder="test@example.com" |
| | | value="<?= esc_attr(wp_get_current_user()->user_email) ?>" |
| | | required> |
| | | <button type="submit" class="button">Send Test</button> |
| | | </form> |
| | | </div> |
| | | |
| | | <?php if ($bounces = $this->getBounces(5)): ?> |
| | | <div class="recent-bounces"> |
| | | <h4>Recent Bounces</h4> |
| | | <ul> |
| | | <?php foreach ($bounces as $bounce): ?> |
| | | <li> |
| | | <?= esc_html($bounce['Email']) ?> - |
| | | <?= esc_html($bounce['Type']) ?> |
| | | <small>(<?= esc_html($bounce['BouncedAt']) ?>)</small> |
| | | </li> |
| | | <?php endforeach; ?> |
| | | </ul> |
| | | </div> |
| | | <?php endif; ?> |
| | | </div> |
| | | <?php |
| | | } |
| | | |
| | | protected function initialize(): void |
| | | { |
| | | if (empty($this->credentials)) { |
| | | $this->loadCredentials(); |
| | | } |
| | | $this->server_token = (array_key_exists('server_token', $this->credentials)) ? $this->credentials['server_token'] : null; |
| | | $this->from_email = (array_key_exists('from_email', $this->credentials)) ? $this->credentials['from_email'] : get_bloginfo('admin_email'); |
| | | $this->from_name = (array_key_exists('from_name', $this->credentials)) ? $this->credentials['from_name'] : get_bloginfo('name'); |
| | | $this->message_stream = (array_key_exists('message_stream', $this->credentials)) ? $this->credentials['message_stream'] : 'outbound'; |
| | | $this->track_open = (array_key_exists('track_open', $this->credentials)) ? $this->credentials['track_open'] : false; |
| | | $this->track_links = (array_key_exists('track_links', $this->credentials)) ? $this->credentials['track_links'] : false; |
| | | } |
| | | } |
| | |
| | | |
| | | namespace JVBase\integrations\services; |
| | | use Exception; |
| | | use JVBase\integrations\Admin; |
| | | use JVBase\integrations\Instance; |
| | | use JVBase\integrations\Integrations; |
| | | use JVBase\integrations\OAuth; |
| | | use JVBase\integrations\OAuthCredentials; |
| | | use JVBase\integrations\OAuthURLs; |
| | | use JVBase\integrations\Requests; |
| | | use JVBase\integrations\SyncDefaults; |
| | | use JVBase\integrations\SyncFrom; |
| | | use JVBase\integrations\SyncTo; |
| | | use JVBase\integrations\Webhooks; |
| | | use JVBase\managers\queue\executors\IntegrationExecutor; |
| | | use JVBase\managers\queue\TypeConfig; |
| | | use JVBase\meta\Form; |
| | | use JVBase\meta\Meta; |
| | | use JVBase\meta\Sanitizer; |
| | | use JVBase\registrar\Registrar; |
| | | use JVBase\ui\Checkout; |
| | | use JVBase\ui\CRUDSkeleton; |
| | | use WP_Error; |
| | | use WP_Post; |
| | | use WP_Query; |
| | | use WP_User; |
| | | |
| | | if (!defined('ABSPATH')) { |
| | | exit; |
| | | } |
| | | |
| | | /** |
| | | * Square Integration Class |
| | | * |
| | | * Handles OAuth 2.0 authentication and API interactions with Square |
| | | * Uses the latest Square API version with OAuth code flow |
| | | * |
| | | * @since 1.0.0 |
| | | */ |
| | | class Square extends Integrations |
| | | { |
| | | protected static string $syncCustomer = 'square_sync_customer'; |
| | | protected array $allowedContent = [ |
| | | 'REGULAR', |
| | | 'FOOD_AND_BEV', |
| | | 'APPOINTMENTS_SERVICE', |
| | | 'DIGITAL', |
| | | 'EVENT', |
| | | 'DONATION' |
| | | ]; |
| | | /** |
| | | * Square API Configuration |
| | | */ |
| | | protected string $service_name = 'square'; |
| | | protected array|string $apiBase = [ |
| | | 'production' => 'https://connect.squareup.com', |
| | | 'sandbox' => 'https://connect.squareupsandbox.com' |
| | | ]; |
| | | |
| | | protected string $apiVersion = '2025-09-24'; |
| | | |
| | | protected string $environment = 'sandbox'; |
| | | |
| | | private const PASSWORD_RESET_INTERVAL = 3; // Reset password every 3 logins, for customers |
| | | |
| | | /** |
| | | * OAuth Configuration |
| | | */ |
| | | protected bool $isOAuthService = true; |
| | | use Admin, |
| | | Instance, |
| | | Requests, |
| | | OAuth, |
| | | SyncFrom, |
| | | SyncTo, |
| | | SyncDefaults, |
| | | Webhooks; |
| | | |
| | | protected string $orderPostType = '_square_order'; |
| | | protected array $newOrder = []; |
| | | protected array $oauth = [ |
| | | 'authorize' => '', |
| | | 'token' => '', |
| | | 'revoke' => '', |
| | | 'scopes' => [ |
| | | 'MERCHANT_PROFILE_READ', |
| | | 'MERCHANT_PROFILE_WRITE', |
| | | 'PAYMENTS_WRITE', |
| | | 'PAYMENTS_READ', |
| | | 'CUSTOMERS_WRITE', |
| | | 'CUSTOMERS_READ', |
| | | 'INVENTORY_READ', |
| | | 'INVENTORY_WRITE', |
| | | 'ITEMS_READ', |
| | | 'ITEMS_WRITE', |
| | | 'ORDERS_READ', |
| | | 'ORDERS_WRITE' |
| | | ] |
| | | ]; |
| | | protected string $apiVersion = '2025-09-24'; |
| | | |
| | | /** |
| | | * Square-specific properties |
| | | */ |
| | | protected string $merchantId = ''; |
| | | protected string $locationId = ''; |
| | | protected array $locations = []; |
| | | |
| | | protected static Square $instance; |
| | | |
| | | public static function getInstance():self |
| | | protected function __construct() |
| | | { |
| | | if (!isset(self::$instance)) { |
| | | self::$instance = new self(); |
| | | } |
| | | return self::$instance; |
| | | } |
| | | |
| | | protected function __construct(?int $userID = null) |
| | | { |
| | | |
| | | // Display properties |
| | | $this->title = 'Square'; |
| | | $this->icon = 'square-logo'; |
| | | $this->supportsWebp = false; |
| | | |
| | | $this->refresh_interval = 7 * DAY_IN_SECONDS; |
| | | |
| | | $this->newOrder = [ |
| | | 'post_type' => $this->orderPostType, |
| | | 'post_status' => 'PROPOSED', |
| | | $this->canSync = [ |
| | | 'create' => true, |
| | | 'update' => true, |
| | | 'delete' => true, |
| | | ]; |
| | | |
| | | // Define credential fields |
| | | $this->fields = [ |
| | | 'environment' => [ |
| | | 'type' => 'select', |
| | | 'label' => 'Environment', |
| | | 'options' => [ |
| | | 'sandbox' => 'Sandbox', |
| | | 'production'=> 'Production', |
| | | ], |
| | | 'default' => 'sandbox', |
| | | ], |
| | | 'client_id' => [ |
| | | 'type' => 'text', |
| | | 'label' => 'Application ID', |
| | | 'hint' => 'Found in Square Developer Dashboard', |
| | | 'required' => true, |
| | | ], |
| | | 'client_secret' => [ |
| | | 'type' => 'text', |
| | | 'subtype' => 'password', |
| | | 'label' => 'Application Secret', |
| | | 'hint' => 'Found in OAuth section of Square Developer Dashboard', |
| | | 'required' => true, |
| | | ], |
| | | 'location_id' => [ |
| | | 'type' => 'select', |
| | | 'label' => 'Default Location', |
| | | 'options' => [], |
| | | 'hint' => 'Select default Square location for transactions', |
| | | ] |
| | | ]; |
| | | $this->defineFields(); |
| | | $this->defineInstructions(); |
| | | $this->defineOAuth(); |
| | | |
| | | // Advanced settings |
| | | $this->advanced = [ |
| | | 'webhook_signature_key' => [ |
| | | 'type' => 'text', |
| | | 'subtype' => 'password', |
| | | 'label' => 'Webhook Signature Key', |
| | | 'hint' => 'Used to verify webhook authenticity', |
| | | ], |
| | | 'access_token' => [ |
| | | 'type' => 'text', |
| | | 'subtype' => 'password', |
| | | 'label' => 'Access Token', |
| | | 'hint' => 'Generated automatically after OAuth authorization', |
| | | 'readonly' => true, |
| | | ], |
| | | 'refresh_token' => [ |
| | | 'type' => 'text', |
| | | 'subtype' => 'password', |
| | | 'label' => 'Refresh Token', |
| | | 'hint' => 'Used to refresh expired access tokens', |
| | | 'readonly' => true, |
| | | ], |
| | | 'merchant_id' => [ |
| | | 'type' => 'text', |
| | | 'label' => 'Merchant ID', |
| | | 'hint' => 'Square Merchant ID (obtained after authorization)', |
| | | 'readonly' => true, |
| | | ], |
| | | ]; |
| | | $this->baseUrl = $this->getBaseURL(); |
| | | |
| | | parent::__construct(); |
| | | |
| | | $this->defineActions(); |
| | | } |
| | | |
| | | protected function defineFields():void |
| | | { |
| | | $env = $this->addField('environment', 'Environment','select','manage_options'); |
| | | $env->setOptions([ |
| | | 'sandbox' => 'Sandbox', |
| | | 'production'=> 'Production' |
| | | ]); |
| | | $env->setDefault('sandbox'); |
| | | $id = $this->addField('client_id', 'Application ID', 'text', 'manage_options'); |
| | | $id->setRequired(); |
| | | $id->setHint('Found in Square Developer Dashboard'); |
| | | |
| | | $sec = $this->addField('client_secret', 'Application Secret', 'text', 'manage_options'); |
| | | $sec->setSubType('password'); |
| | | $sec->setRequired(); |
| | | $sec->setHint('Found in OAuth section of Square Developer Dashboard'); |
| | | |
| | | //Locations are set from the first response from the integration |
| | | $locs = $this->addField('locations', 'Locations', 'select','manage_options'); |
| | | $locs->setHidden(); |
| | | $locs->setOptions($this->getLocationOptions()); |
| | | |
| | | $loc = $this->addField('location_id', 'Default Location', 'select', 'manage_options'); |
| | | $loc->setOptions($this->loadCredentials()['locations']); |
| | | $loc->setHint('Select default Square location for transactions'); |
| | | } |
| | | |
| | | protected function defineInstructions():void |
| | | { |
| | | $this->instructions = [ |
| | | 'Go to <a href="https://developer.squareup.com/apps" target="_blank">Square Developer Dashboard</a>', |
| | | 'Create a new application or select an existing one', |
| | |
| | | 'Copy the Application ID and Application Secret', |
| | | 'Click "Authorize Connection" below to complete OAuth flow' |
| | | ]; |
| | | } |
| | | |
| | | |
| | | $this->canSync = [ |
| | | 'create' => true, |
| | | 'update' => true, |
| | | 'delete' => true |
| | | ]; |
| | | $this->supportsWebp = false; |
| | | $this->hasWebhooks = true; |
| | | |
| | | parent::__construct(); |
| | | |
| | | // Add Square-specific actions |
| | | $this->actions = array_merge( |
| | | $this->actions, |
| | | [ |
| | | 'select_location' => 'handleSelectLocation', |
| | | 'fetch_locations' => 'handleFetchLocations', |
| | | 'verify_webhook' => 'handleVerifyWebhook', |
| | | 'test_payment' => 'handleTestPayment' |
| | | ] |
| | | protected function defineActions():void |
| | | { |
| | | $this->addAction( |
| | | 'Select Location', |
| | | [$this, 'handleSelectLocation'], |
| | | 'select_location', |
| | | 'building-office', |
| | | ); |
| | | |
| | | $this->buttons = array_merge( |
| | | $this->buttons, |
| | | [ |
| | | 'import_from_square' => 'Import Catalog from Square', |
| | | 'sync_to_square' => 'Sync Site to Square', |
| | | ] |
| | | $this->addAction( |
| | | 'Fetch Locations', |
| | | [$this, 'getLocations'], |
| | | 'fetch_locations', |
| | | 'building-office', |
| | | ); |
| | | |
| | | $this->addAction( |
| | | 'Import From Square', |
| | | [$this, 'handleImportFromSquare'], |
| | | 'import_from_square', |
| | | 'download-simple' |
| | | ); |
| | | |
| | | $this->addAction( |
| | | 'Sync to Square', |
| | | [$this, 'handleSyncToSquare'], |
| | | 'sync_to_square', |
| | | 'export', |
| | | ); |
| | | |
| | | // $this->addAction( |
| | | // 'Verify Webhook', |
| | | // [$this, 'handleVerifyWebhook'], |
| | | // 'verify_webhook', |
| | | // ); |
| | | } |
| | | |
| | | protected function defineOAuth():void |
| | | { |
| | | $this->baseOAuthUrl = $this->getBaseURL(); |
| | | $base = $this->getBaseURL(); |
| | | |
| | | $this->oauth = new OAuthURLs( |
| | | sprintf('%s/oauth2/authorize', $base), |
| | | sprintf('%s/oauth2/token', $base), |
| | | sprintf('%s/oauth2/revoke', $base) |
| | | ); |
| | | } |
| | | |
| | | protected function registerAdditionalHooks():void |
| | | { |
| | | add_action('init', [$this, 'registerSquarePostTypes'], 5); |
| | | add_action('init', [$this, 'addDashboardPages'], 10); |
| | | add_action(BASE.'dashboard_page_orders', [$this, 'renderDashPage']); |
| | | } |
| | | |
| | | /** |
| | | * Initialize the integration with loaded credentials |
| | | */ |
| | | protected function initialize(): void |
| | | { |
| | | if (empty($this->credentials)) { |
| | | $this->loadCredentials(); |
| | | } |
| | | |
| | | $this->merchantId = $this->credentials['merchant_id'] ?? ''; |
| | | $this->locationId = $this->credentials['location_id'] ?? ''; |
| | | $this->environment = $this->credentials['environment'] ?? 'sandbox'; |
| | | |
| | | // Set OAuth URLs based on environment |
| | | $baseUrl = ($this->environment === 'production') |
| | | ? 'https://connect.squareup.com' |
| | | : 'https://connect.squareupsandbox.com'; |
| | | |
| | | $this->oauth['authorize'] = $baseUrl . '/oauth2/authorize'; |
| | | $this->oauth['token'] = $baseUrl . '/oauth2/token'; |
| | | $this->oauth['revoke'] = $baseUrl . '/oauth2/revoke'; |
| | | |
| | | // Set up API endpoints with version |
| | | $this->apiEndpoints = [ |
| | | 'locations' => '/v2/locations', |
| | | 'customers' => '/v2/customers', |
| | | 'catalog' => '/v2/catalog', |
| | | 'payments' => '/v2/payments', |
| | | 'orders' => '/v2/orders', |
| | | 'inventory' => '/v2/inventory' |
| | | ]; |
| | | |
| | | // TODO: Implement initialize() method. |
| | | } |
| | | |
| | | public function getOrderFields():array |
| | | protected function refreshAccessToken(OAuthCredentials $credentials): array |
| | | { |
| | | if (!$this->getClientID() || !$this->getClientSecret()) |
| | | $response = wp_remote_post( |
| | | $this->oauth->getToken(), |
| | | [ |
| | | 'body' => [ |
| | | 'client_id' => $this->getClientID(), |
| | | 'client_secret' => $this->getClientSecret(), |
| | | 'refresh_token' => $credentials->refresh_token, |
| | | 'grant_type' => 'refresh_token' |
| | | ], |
| | | 'headers' => $this->getOAuthRequestHeaders() |
| | | ] |
| | | ); |
| | | if (is_wp_error($response)) { |
| | | $this->logError( |
| | | 'refreshAccessToken', |
| | | 'Failed to refresh Square token', |
| | | [ |
| | | 'error' => $response->get_error_message() |
| | | ] |
| | | ); |
| | | return $this->response(false, 'Could not refresh token'); |
| | | } |
| | | |
| | | $data = json_decode(wp_remote_retrieve_body($response)); |
| | | if (isset($data['access_token'])) { |
| | | $credentials = [ |
| | | 'access_token' => $data['access_token'], |
| | | 'expires_at' => time() + ($data['expires_in'] ?? 2592000) // 30 days default |
| | | ]; |
| | | |
| | | $this->saveCredentials($credentials); |
| | | return $this->response(true, 'Successfully refreshed access token'); |
| | | } |
| | | return $this->response(false, 'Something went wrong that wasn\'t caught - could not refresh access token'); |
| | | } |
| | | |
| | | protected function getRequestHeaders(): array |
| | | { |
| | | return [ |
| | | 'Authorization' => 'Bearer ' . ($this->getAccessToken()), |
| | | 'Square-Version' => $this->apiVersion, |
| | | 'Content-Type' => 'application/json' |
| | | ]; |
| | | } |
| | | |
| | | protected function handleResponse(\WP_Error|array $response, string $method, string $endpoint): array |
| | | { |
| | | if (is_wp_error($response) && $response->get_error_message() === 'rate_limit_exceeded') { |
| | | return $this->response(false, 'Rate limit exceeded'); |
| | | } |
| | | $endpoint = str_replace($this->getBaseURL(), '', $endpoint); |
| | | switch ($endpoint) { |
| | | case '/v2/customers/search': |
| | | return $this->handleEmailSearchResponse($response, $method); |
| | | case '/v2/customers': |
| | | return $this->handleCreateUserResponse($response, $method); |
| | | case '/v2/locations': |
| | | return $this->handleGetLocationResponse($response, $method); |
| | | } |
| | | return $this->response(false, 'Unhandled Response for '.$endpoint.' endpoint, with method: '.$method); |
| | | } |
| | | |
| | | protected function setContentTypes(): void |
| | | { |
| | | // TODO: Implement setContentTypes() method. |
| | | } |
| | | |
| | | protected function formatForService(int $itemID, string $type = 'post'): array |
| | | { |
| | | return match ($type) { |
| | | 'post' => $this->formatPostItem($itemID), |
| | | 'user' => $this->formatUserItem($itemID), |
| | | 'term' => $this->formatTermItem($itemID), |
| | | default => [] |
| | | }; |
| | | } |
| | | |
| | | public function handleEmailSearch(string $email): string|false |
| | | { |
| | | try { |
| | | $response = $this->postRequest( |
| | | sprintf('%s/v2/customers/search', $this->getBaseURL()), |
| | | [ |
| | | 'query' => [ |
| | | 'filter' => [ |
| | | 'email_address' => [ |
| | | 'exact' => $email |
| | | ] |
| | | ] |
| | | ] |
| | | ] |
| | | ); |
| | | return $response['id']; |
| | | } catch (Exception $e) { |
| | | return false; |
| | | } |
| | | } |
| | | protected function handleEmailSearchResponse(WP_Error|array $response, string $method):array |
| | | { |
| | | |
| | | } |
| | | |
| | | protected function handleCreateUser(array $data): array |
| | | { |
| | | try { |
| | | return $this->postRequest( |
| | | sprintf('%s/v2/customers',$this->getBaseURL()), |
| | | $data |
| | | ); |
| | | } catch (Exception $e) { |
| | | return $this->response(false, 'Failed to create customer: '.$e->getMessage()); |
| | | } |
| | | } |
| | | protected function handleCreateUserResponse(WP_Error|array $response, string $method):array |
| | | { |
| | | |
| | | } |
| | | protected function validateUserFields(int $userID, array $fields):array|false |
| | | { |
| | | $allowed = [ |
| | | 'given_name', |
| | | 'family_name', |
| | | 'company_name', |
| | | 'email_address', |
| | | 'phone_number', |
| | | 'idempotency_key', |
| | | 'nickname', |
| | | 'address', |
| | | 'reference_id', // The WP user id, in this case |
| | | 'note', |
| | | 'birthday' |
| | | ]; |
| | | $fields = array_filter($fields, function ($f) use ($allowed, $fields) { |
| | | if (!in_array($f, $allowed)) { |
| | | $this->logError('validateUserFields', 'Stripped attempted field for user: '.$f.' with value: '.print_r($fields[$f], true)); |
| | | } |
| | | return in_array($f, $allowed) && !empty($fields[$f]); |
| | | }, ARRAY_FILTER_USE_KEY); |
| | | if (empty($fields)) { |
| | | $this->logError('validateUserFields', 'No valid fields found'); |
| | | return false; |
| | | } |
| | | |
| | | |
| | | //Square requires at least one of these fields |
| | | $any = [ |
| | | 'given_name', |
| | | 'family_name', |
| | | 'company_name', |
| | | 'email_address', |
| | | 'phone_number' |
| | | ]; |
| | | |
| | | $doIt = false; |
| | | foreach ($any as $a) { |
| | | if (array_key_exists($a, $fields) && !empty($fields[$a])) { |
| | | $doIt = true; |
| | | } |
| | | } |
| | | if (!$doIt) { |
| | | $this->logError('validateUserFields', 'We need at least one of the following fields: '.print_r($any, true)); |
| | | return false; |
| | | } |
| | | |
| | | $addressAllowed = [ |
| | | 'address_line_1', |
| | | 'address_line_2', |
| | | 'address_line_3', |
| | | 'locality', // city |
| | | 'sublocality', // |
| | | 'sublocality_2', // |
| | | 'sublocality_3', // |
| | | 'administrative_district_1', //Province, in our case |
| | | 'administrative_district_2', // |
| | | 'administrative_district_3', // |
| | | 'postal_code', |
| | | 'country', |
| | | 'first_name', |
| | | 'last_name', |
| | | ]; |
| | | if (array_key_exists('address', $fields)) { |
| | | $fields['address'] = array_filter($fields['address'], function ($f) use ($addressAllowed, $fields){ |
| | | return array_key_exists($f, $addressAllowed) && !empty($fields['address'][$f]); |
| | | }, ARRAY_FILTER_USE_KEY); |
| | | } |
| | | |
| | | foreach ($fields as $f => $v) { |
| | | $v = match ($f) { |
| | | 'email' => filter_var($v, FILTER_SANITIZE_EMAIL), |
| | | 'phone' => Sanitizer::sanitizePhone($v), |
| | | default => sanitize_text_field($v), |
| | | }; |
| | | if (empty($v) && in_array($f, $this->requiredUserFields())) { |
| | | return false; |
| | | } |
| | | $fields[$f] = $v; |
| | | } |
| | | //Add the WP User ID as the reference |
| | | $fields['reference_id'] = $userID; |
| | | return $fields; |
| | | } |
| | | |
| | | |
| | | protected function validateWebhook(array $payload): bool |
| | | { |
| | | // TODO: Implement validateWebhook() method. |
| | | } |
| | | |
| | | protected function processWebhook(array $payload): bool |
| | | { |
| | | // TODO: Implement processWebhook() method. |
| | | } |
| | | |
| | | protected function extractWebhookId(array $payload): string |
| | | { |
| | | // TODO: Implement extractWebhookId() method. |
| | | } |
| | | |
| | | /******************************************************************** |
| | | * CUSTOM POST TYPES |
| | | ********************************************************************/ |
| | | protected function registerAdditionalQueueTypes(IntegrationExecutor $executor): void |
| | | { |
| | | $queue = JVB()->queue(); |
| | | $queue->registry()->register( |
| | | self::$syncCustomer, |
| | | new TypeConfig( |
| | | executor: $executor, |
| | | maxRetries: 1, |
| | | ) |
| | | ); |
| | | |
| | | $queue->registry()->register( |
| | | self::$import, |
| | | new TypeConfig( |
| | | executor: $executor, |
| | | maxRetries: 2 |
| | | ) |
| | | ); |
| | | } |
| | | |
| | | public function registerSquarePostTypes():void |
| | | { |
| | | $orders = Registrar::forPost($this->orderPostType, 'Square Order', 'Square Orders'); |
| | | $orders->make([ |
| | | 'public' => true |
| | | ]); |
| | | $orders->setAll(['system']); |
| | | |
| | | $fields = $orders->fields(); |
| | | foreach ($this->getOrderFields() as $fieldName => $config) { |
| | | $fields->addField($fieldName, $config); |
| | | } |
| | | } |
| | | public function getOrderFields():array |
| | | { |
| | | return [ |
| | | 'post_title' => [ |
| | | 'type' => 'text', |
| | | 'label' => 'Order Number' |
| | |
| | | 'label' => 'Last Updated', |
| | | ] |
| | | ]; |
| | | } |
| | | |
| | | public function registerSquarePostTypes():void |
| | | { |
| | | $orders = Registrar::forPost($this->orderPostType, 'Square Order', 'Square Orders'); |
| | | $orders->make([ |
| | | 'public' => true |
| | | ]); |
| | | $orders->setAll(['system']); |
| | | |
| | | $fields = $orders->fields(); |
| | | foreach ($this->getOrderFields() as $fieldName => $config) { |
| | | $fields->addField($fieldName, $config); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Get request headers for API calls |
| | | */ |
| | | protected function getRequestHeaders(): array |
| | | { |
| | | return [ |
| | | 'Authorization' => 'Bearer ' . ($this->credentials['access_token'] ?? ''), |
| | | 'Square-Version' => $this->apiVersion, |
| | | 'Content-Type' => 'application/json' |
| | | ]; |
| | | } |
| | | |
| | | /** |
| | | * Add Square-specific OAuth parameters |
| | | */ |
| | | protected function addOAuthParams(array $params): array |
| | | { |
| | | // Load current environment setting |
| | | $this->ensureInitialized(); |
| | | |
| | | // For production, add session=false |
| | | // For sandbox, session parameter is not supported and should be omitted |
| | | if ($this->environment === 'production') { |
| | | $params['session'] = 'false'; |
| | | } else { |
| | | // Ensure session is not included for sandbox |
| | | unset($params['session']); |
| | | } |
| | | |
| | | return $params; |
| | | } |
| | | |
| | | /** |
| | | * Exchange OAuth code for tokens |
| | | * Override to handle Square's specific response format |
| | | */ |
| | | protected function exchangeOAuthCode(string $code): ?array |
| | | { |
| | | $this->ensureInitialized(); |
| | | |
| | | // Prepare the request body as an array |
| | | $body = [ |
| | | 'client_id' => $this->credentials['client_id'] ?? '', |
| | | 'client_secret' => $this->credentials['client_secret'] ?? '', |
| | | 'code' => $code, |
| | | 'grant_type' => 'authorization_code', |
| | | 'redirect_uri' => $this->getRedirectUri() |
| | | ]; |
| | | |
| | | $response = wp_remote_post($this->oauth['token'], [ |
| | | 'body' => json_encode($body), |
| | | 'headers' => [ |
| | | 'Content-Type' => 'application/json', |
| | | 'Square-Version' => $this->apiVersion |
| | | ] |
| | | ]); |
| | | |
| | | if (is_wp_error($response)) { |
| | | $this->logError('OAuth token exchange failed', ['error' => $response->get_error_message()]); |
| | | return null; |
| | | } |
| | | |
| | | $data = json_decode(wp_remote_retrieve_body($response), true); |
| | | if (isset($data['access_token'])) { |
| | | return [ |
| | | 'access_token' => $data['access_token'], |
| | | 'refresh_token' => $data['refresh_token'] ?? '', |
| | | 'expires_in' => $data['expires_in'] ?? 2592000, // 30 days default |
| | | 'token_type' => $data['token_type'] ?? 'Bearer', |
| | | 'merchant_id' => $data['merchant_id'] ?? '', |
| | | 'scope' => $data['scope'] ?? '' |
| | | ]; |
| | | } |
| | | |
| | | $this->logError('Failed to obtain access token', ['response' => $data]); |
| | | return null; |
| | | } |
| | | |
| | | /** |
| | | * Add Square-specific credential data after OAuth |
| | | */ |
| | | protected function addCredentialData(array $credentials, array $tokens): array |
| | | { |
| | | // Store Square-specific data |
| | | $credentials['merchant_id'] = $tokens['merchant_id'] ?? ''; |
| | | |
| | | // Preserve selected location if it exists |
| | | $credentials['location_id'] = $this->credentials['location_id'] ?? ''; |
| | | |
| | | // Preserve sandbox setting |
| | | $credentials['environment'] = $this->credentials['environment'] ?? 'sandbox'; |
| | | |
| | | return $credentials; |
| | | } |
| | | |
| | | /** |
| | | * Refresh OAuth token |
| | | * Override to handle Square's refresh token flow |
| | | */ |
| | | protected function refreshOAuthToken(): bool |
| | | { |
| | | if (!$this->isOAuthService || empty($this->credentials['refresh_token'])) { |
| | | return false; |
| | | } |
| | | |
| | | $response = wp_remote_post($this->oauth['token'], [ |
| | | 'body' => [ |
| | | 'client_id' => $this->credentials['client_id'], |
| | | 'client_secret' => $this->credentials['client_secret'], |
| | | 'refresh_token' => $this->credentials['refresh_token'], |
| | | 'grant_type' => 'refresh_token' |
| | | ], |
| | | 'headers' => $this->getRequestHeaders() |
| | | ]); |
| | | |
| | | if (is_wp_error($response)) { |
| | | $this->logError('Failed to refresh Square token', [ |
| | | 'error' => $response->get_error_message() |
| | | ]); |
| | | return false; |
| | | } |
| | | |
| | | $data = json_decode(wp_remote_retrieve_body($response), true); |
| | | |
| | | if (isset($data['access_token'])) { |
| | | $this->credentials['access_token'] = $data['access_token']; |
| | | $this->credentials['expires_at'] = time() + ($data['expires_in'] ?? 2592000); // 30 days |
| | | // Note: Square returns the SAME refresh token |
| | | if (isset($data['refresh_token'])) { |
| | | $this->credentials['refresh_token'] = $data['refresh_token']; |
| | | } |
| | | |
| | | $this->saveCredentials($this->credentials); |
| | | return true; |
| | | } |
| | | |
| | | return false; |
| | | } |
| | | |
| | | /** |
| | | * Load Square locations |
| | | */ |
| | | protected function loadLocations(): void |
| | | /******************************************************************** |
| | | * UTILITY METHODS |
| | | ********************************************************************/ |
| | | protected function getEnvironment():string|false |
| | | { |
| | | // Skip if we don't have credentials yet (during OAuth flow) |
| | | if (empty($this->credentials['access_token'])) { |
| | | return; |
| | | return $this->loadCredentials()['environment']??false; |
| | | } |
| | | protected function getClientID():string|false |
| | | { |
| | | return $this->loadCredentials()['client_id']??false; |
| | | } |
| | | protected function getClientSecret():string|false |
| | | { |
| | | return $this->loadCredentials()['client_secret']??false; |
| | | } |
| | | protected function getMerchantID():string|false |
| | | { |
| | | return $this->loadCredentials()['merchant_id']??false; |
| | | } |
| | | protected function getLocationID():string|false |
| | | { |
| | | return $this->loadCredentials()['location_id']??false; |
| | | } |
| | | protected function getLocations():array|false |
| | | { |
| | | if (!$this->getAccessToken()) { |
| | | return false; |
| | | } |
| | | try { |
| | | $response = $this->getRequest( |
| | | '/v2/locations', |
| | | [], |
| | | $this->environment |
| | | return $this->getRequest( |
| | | sprintf('%s/v2/locations', $this->getBaseURL()), |
| | | ); |
| | | |
| | | } catch (Exception $e) { |
| | | $this->logError( |
| | | 'getLocations', |
| | | 'Failed to load locations', |
| | | [ |
| | | 'error' => $e->getMessage() |
| | | ] |
| | | ); |
| | | } |
| | | return []; |
| | | } |
| | | protected function handleGetLocationResponse(WP_Error|array $response, string $method):array |
| | | { |
| | | if (isset($response['locations'])) { |
| | | $this->locations = $response['locations']; |
| | | $locations = $response['locations']; |
| | | |
| | | // Update location options in fields |
| | | $locationOptions = []; |
| | | foreach ($this->locations as $location) { |
| | | if ($location['status'] === 'ACTIVE') { |
| | | $locationOptions[$location['id']] = $location['name'] ?? $location['id']; |
| | | $options=[]; |
| | | foreach ($locations as $l) { |
| | | if ($l['status'] === 'ACTIVE') { |
| | | $options[$l['id']] = $l['name']??$l['id']; |
| | | } |
| | | } |
| | | |
| | | if (!empty($locationOptions)) { |
| | | $this->fields['location_id']['options'] = $locationOptions; |
| | | if (!empty($options)) { |
| | | $this->saveCredentials(['locations' => $options], false); |
| | | } |
| | | return $this->response(true, 'Found locations. Stored in table for reference.', $locations); |
| | | } |
| | | } catch (Exception $e) { |
| | | $this->logError('Failed to load locations', ['error' => $e->getMessage()]); |
| | | // Set empty locations array so we don't retry constantly |
| | | $this->locations = []; |
| | | return $this->response(false, 'No locations found.'); |
| | | } |
| | | } |
| | | |
| | | protected function renderOAuthConnectedOptions(): void |
| | | protected function getLocationOptions():array |
| | | { |
| | | return $this->loadCredentials()['locations']??[]; |
| | | } |
| | | protected function getBaseURL():string |
| | | { |
| | | if (!$this->isSetUp()) { |
| | | return; |
| | | } |
| | | |
| | | // Check OAuth status |
| | | $oauth_valid = $this->isOAuthValid(); |
| | | |
| | | if (!$oauth_valid) { |
| | | ?> |
| | | <div class="oauth-status"> |
| | | <span class="status-invalid">⚠️ OAuth token expired - please reconnect</span> |
| | | </div> |
| | | <?php |
| | | } |
| | | |
| | | $this->loadLocations(); |
| | | if (!empty($this->locations)) { |
| | | ?> |
| | | <div class="form-field" data-service="square"> |
| | | <label for="square_location_id">Square Location</label> |
| | | <select name="location_id" |
| | | id="square_location_id" |
| | | class="jvb-ajax-update" |
| | | data-action="select_location"> |
| | | <option value="">Select a location...</option> |
| | | <?php foreach ($this->locations as $location): ?> |
| | | <option value="<?php echo esc_attr($location['id']); ?>" |
| | | <?php selected($this->credentials['location_id'] ?? '', $location['id']); ?>> |
| | | <?php echo esc_html($location['name']); ?> |
| | | </option> |
| | | <?php endforeach; ?> |
| | | </select> |
| | | </div> |
| | | <?php |
| | | |
| | | // Show current selection status |
| | | if (!empty($this->credentials['location'])) { |
| | | ?> |
| | | <div class="connection-status connected"> |
| | | ✅ Connected to: <?php echo esc_html($this->credentials['location_id']); ?> |
| | | </div> |
| | | <?php |
| | | } |
| | | } else { |
| | | ?> |
| | | <div class="notice notice-warning"> |
| | | <p>No Square locations found. Please ensure your Square credentials are correct.</p> |
| | | </div> |
| | | <?php |
| | | } |
| | | return $this->getEnvironment() === 'production' |
| | | ? 'https://connect.squareup.com' |
| | | : 'https://connect.squareupsandbox.com'; |
| | | } |
| | | |
| | | /** |
| | | * Handle location selection |
| | | */ |
| | | public function handleSelectLocation($data): array |
| | | protected function getAccessToken():string |
| | | { |
| | | if (empty($data['location_id'])) { |
| | | return [ |
| | | 'success' => false, |
| | | 'message' => 'No location selected' |
| | | ]; |
| | | } |
| | | |
| | | $this->credentials['location_id'] = sanitize_text_field($data['location_id']); |
| | | $this->locationId = $this->credentials['location_id']; |
| | | |
| | | $result = $this->saveCredentials($this->credentials); |
| | | |
| | | if ($result['success']) { |
| | | return [ |
| | | 'success' => true, |
| | | 'message' => 'Location updated successfully' |
| | | ]; |
| | | } |
| | | |
| | | return $result; |
| | | return $this->loadCredentials()['access_token']??''; |
| | | } |
| | | |
| | | /** |
| | | * Handle fetching locations |
| | | */ |
| | | public function handleFetchLocations(): array |
| | | /*************************************************************** |
| | | * ACTIONS |
| | | ***************************************************************/ |
| | | public function handleSelectLocation(array $data):array |
| | | { |
| | | try { |
| | | $this->loadLocations(); |
| | | |
| | | if (empty($this->locations)) { |
| | | return [ |
| | | 'success' => false, |
| | | 'message' => 'No locations found' |
| | | ]; |
| | | } |
| | | |
| | | return [ |
| | | 'success' => true, |
| | | 'data' => $this->locations, |
| | | 'message' => sprintf('Found %d location(s)', count($this->locations)) |
| | | ]; |
| | | } catch (Exception $e) { |
| | | return [ |
| | | 'success' => false, |
| | | 'message' => 'Failed to fetch locations: ' . $e->getMessage() |
| | | ]; |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Handle webhook verification |
| | | */ |
| | | public function handleVerifyWebhook($data): array |
| | | { |
| | | if (empty($data['signature']) || empty($data['body'])) { |
| | | return [ |
| | | 'success' => false, |
| | | 'message' => 'Missing signature or body' |
| | | ]; |
| | | if (empty($data['location_id']??'')) { |
| | | return $this->response(false, 'No location selected'); |
| | | } |
| | | |
| | | $signatureKey = $this->credentials['webhook_signature_key'] ?? ''; |
| | | |
| | | if (empty($signatureKey)) { |
| | | return [ |
| | | 'success' => false, |
| | | 'message' => 'Webhook signature key not configured' |
| | | ]; |
| | | } |
| | | |
| | | // Calculate expected signature |
| | | $stringToSign = $data['url'] . $data['body']; |
| | | $expectedSignature = base64_encode( |
| | | hash_hmac('sha256', $stringToSign, $signatureKey, true) |
| | | ); |
| | | |
| | | $isValid = hash_equals($expectedSignature, $data['signature']); |
| | | |
| | | return [ |
| | | 'success' => $isValid, |
| | | 'message' => $isValid ? 'Valid webhook signature' : 'Invalid webhook signature' |
| | | $credentials = [ |
| | | 'location_id' => sanitize_text_field($data['location_id']) |
| | | ]; |
| | | return $this->saveCredentials($credentials, false); |
| | | } |
| | | |
| | | /** |
| | | * Test payment/connection |
| | | */ |
| | | public function handleTestPayment(): array |
| | | public function handleFetchLocations():array |
| | | { |
| | | try { |
| | | // Test with a simple API call to verify connection |
| | | $response = $this->getRequest( |
| | | '/v2/locations/'.$this->locationId, |
| | | [], |
| | | $this->environment |
| | | $locations = $this->getLocations(); |
| | | if (!$locations) { |
| | | return $this->response(false, 'No locations found'); |
| | | } |
| | | return $this->response( |
| | | true, |
| | | sprintf('Found %d location(s)', count($locations)), |
| | | $locations |
| | | ); |
| | | if (!empty($response['location'])) { |
| | | return [ |
| | | 'success' => true, |
| | | 'message' => 'Successfully connected to Square', |
| | | 'data' => [ |
| | | 'location_name' => $response['location']['name'] ?? 'Unknown', |
| | | 'merchant_id' => $this->merchantId, |
| | | 'environment' => $this->environment |
| | | ] |
| | | ]; |
| | | } |
| | | |
| | | return [ |
| | | 'success' => false, |
| | | 'message' => 'Connection test failed' |
| | | ]; |
| | | } catch (Exception $e) { |
| | | return [ |
| | | 'success' => false, |
| | | 'message' => 'Connection test failed: ' . $e->getMessage() |
| | | ]; |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Create a Square customer |
| | | */ |
| | | public function createCustomer(array $customerData): array |
| | | { |
| | | try { |
| | | $response = $this->postRequest('/v2/customers', [ |
| | | 'given_name' => $customerData['first_name'] ?? '', |
| | | 'family_name' => $customerData['last_name'] ?? '', |
| | | 'email_address' => $customerData['email'] ?? '', |
| | | 'phone_number' => $customerData['phone'] ?? '', |
| | | 'reference_id' => $customerData['reference_id'] ?? '', |
| | | 'note' => $customerData['note'] ?? '' |
| | | ], $this->environment); |
| | | |
| | | return [ |
| | | 'success' => true, |
| | | 'data' => $response['customer'] ?? [] |
| | | ]; |
| | | } catch (Exception $e) { |
| | | return [ |
| | | 'success' => false, |
| | | 'message' => 'Failed to create customer: ' . $e->getMessage() |
| | | ]; |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Create a catalog item |
| | | */ |
| | | public function createCatalogItem(array $itemData): array |
| | | { |
| | | try { |
| | | $response = $this->postRequest('/v2/catalog/object', [ |
| | | 'idempotency_key' => wp_generate_uuid4(), |
| | | 'object' => [ |
| | | 'type' => 'ITEM', |
| | | 'id' => '#' . sanitize_title($itemData['name']), |
| | | 'item_data' => [ |
| | | 'name' => $itemData['name'], |
| | | 'description' => $itemData['description'] ?? '', |
| | | 'variations' => $this->buildItemVariations($itemData) |
| | | ] |
| | | ] |
| | | ], $this->environment); |
| | | |
| | | return [ |
| | | 'success' => true, |
| | | 'data' => $response['catalog_object'] ?? [] |
| | | ]; |
| | | } catch (Exception $e) { |
| | | return [ |
| | | 'success' => false, |
| | | 'message' => 'Failed to create catalog item: ' . $e->getMessage() |
| | | ]; |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Build item variations for catalog |
| | | */ |
| | | protected function buildItemVariations(array $itemData): array |
| | | { |
| | | $variations = []; |
| | | |
| | | if (isset($itemData['variations']) && is_array($itemData['variations'])) { |
| | | foreach ($itemData['variations'] as $variation) { |
| | | $variations[] = [ |
| | | 'type' => 'ITEM_VARIATION', |
| | | 'id' => '#' . sanitize_title($variation['name']), |
| | | 'item_variation_data' => [ |
| | | 'name' => $variation['name'], |
| | | 'pricing_type' => 'FIXED_PRICING', |
| | | 'price_money' => [ |
| | | 'amount' => $this->formatPrice($variation['_square_price']??0), |
| | | 'currency' => $this->getCurrency() |
| | | ] |
| | | ] |
| | | ]; |
| | | } |
| | | } else { |
| | | // Default single variation |
| | | $variations[] = [ |
| | | 'type' => 'ITEM_VARIATION', |
| | | 'id' => '#regular', |
| | | 'item_variation_data' => [ |
| | | 'name' => 'Regular', |
| | | 'pricing_type' => 'FIXED_PRICING', |
| | | 'price_money' => [ |
| | | 'amount' => $this->formatPrice($itemData['_square_price'] ?? 0), |
| | | 'currency' => $this->getCurrency() |
| | | ] |
| | | ] |
| | | ]; |
| | | } |
| | | |
| | | return $variations; |
| | | } |
| | | |
| | | /** |
| | | * Handle OAuth disconnect |
| | | */ |
| | | public function handleOAuthDisconnect(): array |
| | | { |
| | | try { |
| | | // Revoke the token with Square |
| | | if (!empty($this->credentials['access_token'])) { |
| | | wp_remote_post($this->oauth['revoke'], [ |
| | | 'body' => [ |
| | | 'client_id' => $this->credentials['client_id'] ?? '', |
| | | 'access_token' => $this->credentials['access_token'] |
| | | ], |
| | | 'headers' => $this->getRequestHeaders() |
| | | ]); |
| | | } |
| | | |
| | | // Clear stored credentials |
| | | $this->credentials = [ |
| | | 'client_id' => $this->credentials['client_id'] ?? '', |
| | | 'client_secret' => $this->credentials['client_secret'] ?? '' |
| | | ]; |
| | | |
| | | $this->saveCredentials($this->credentials); |
| | | $this->clearCache(); |
| | | |
| | | return [ |
| | | 'success' => true, |
| | | 'message' => 'Successfully disconnected from Square' |
| | | ]; |
| | | } catch (Exception $e) { |
| | | return [ |
| | | 'success' => false, |
| | | 'message' => 'Failed to disconnect: ' . $e->getMessage() |
| | | ]; |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Register additional WordPress hooks |
| | | */ |
| | | protected function registerAdditionalHooks(): void |
| | | { |
| | | if (!$this->isSetUp()) { |
| | | error_log('Square is not setup'); |
| | | return; |
| | | } |
| | | |
| | | add_action('wp_enqueue_scripts', [$this, 'enqueueScripts']); |
| | | |
| | | add_filter('jvbAdditionalActions', [Checkout::class, 'render']); |
| | | |
| | | add_filter('jvb_checkout_description', function (string $desc, string $provider) { |
| | | if ($provider === 'square') { |
| | | return 'Securely checkout with your name, email, and payments processed by Square.'; |
| | | } |
| | | return $desc; |
| | | }, 10, 2); |
| | | |
| | | // Square-specific pickup fields (extracted from old outputCheckout) |
| | | add_filter('jvb_checkout_fields', [$this, 'addPickupFields'], 10, 2); |
| | | |
| | | // Browse URL for this client (restaurant menu) |
| | | add_filter('jvb_checkout_browse_url', function () { |
| | | return get_post_type_archive_link(BASE . 'menu_item'); |
| | | }); |
| | | add_filter('jvb_checkout_browse_text', function () { |
| | | return 'browse our menu'; |
| | | }); |
| | | } |
| | | |
| | | /** |
| | | * Pickup/ordering fields for the shared checkout form. |
| | | * Specific to this Square client's food ordering use case. |
| | | */ |
| | | public function addPickupFields(string $html, string $provider): string |
| | | { |
| | | if ($provider !== 'square') { |
| | | return $html; |
| | | } |
| | | |
| | | return $html |
| | | . '<h3>Pickup Details</h3>' |
| | | . Form::render('pickup_time', null, [ |
| | | 'type' => 'datetime', |
| | | 'label' => 'Pickup Time', |
| | | 'min' => '11:00', |
| | | 'max' => '20:00', |
| | | 'required' => true, |
| | | ]) |
| | | . Form::render('special_instructions', null, [ |
| | | 'type' => 'textarea', |
| | | 'label' => 'Special Instructions', |
| | | 'quill' => true, |
| | | ]); |
| | | } |
| | | |
| | | protected function registerAdditionalQueueTypes(IntegrationExecutor $executor): void |
| | | { |
| | | $queue = JVB()->queue(); |
| | | |
| | | $queue->registry()->register(self::$syncCustomer, new TypeConfig( |
| | | executor: $executor, |
| | | maxRetries: 2 |
| | | )); |
| | | |
| | | $queue->registry()->register(self::$import, new TypeConfig( |
| | | executor: $executor, |
| | | maxRetries: 3 |
| | | )); |
| | | } |
| | | |
| | | /****************************************************************** |
| | | * POST SYNC METHODS |
| | | ******************************************************************/ |
| | | |
| | | /** |
| | | * Handle post save for Square sync |
| | | */ |
| | | protected function handleTheSavePost(int $postID, WP_Post $post, bool $update, array $settings): void |
| | | { |
| | | error_log('==== [Square]::handleTheSavePost ===='); |
| | | $this->queueOperation(self::$syncTo, [ |
| | | 'items' => [$postID], |
| | | 'user' => user_can($post->post_author, 'manage_options') ? null : $post->post_author |
| | | ], [ |
| | | 'priority' => 'high', |
| | | 'delay' => 30, |
| | | ]); |
| | | Meta::forPost($postID)->set('_'.$this->service_name.'_sync_status', 'queued'); |
| | | } |
| | | |
| | | /** |
| | | * Handle post deletion |
| | | */ |
| | | public function handleDeletePost(int $postID): void |
| | | { |
| | | $item_id = $this->getServiceItemID($postID); |
| | | |
| | | if (empty($item_id)) { |
| | | return; |
| | | } |
| | | $this->queueOperation(self::$deleteFrom, [ |
| | | 'external_ids' => [$item_id], |
| | | 'post_id' => $postID, |
| | | ], [ |
| | | 'priority' => 'high', |
| | | ]); |
| | | |
| | | } |
| | | |
| | | /** |
| | | * Process sync to Square |
| | | */ |
| | | private function processSyncToSquare(array $data): array |
| | | { |
| | | $items = $data['items'] ?? []; |
| | | $success = []; |
| | | $errors = []; |
| | | |
| | | if (empty($items)) { |
| | | return [ |
| | | 'success' => true, |
| | | 'result' => [ |
| | | 'synced' => [], |
| | | 'errors' => ['No items to sync'] |
| | | ] |
| | | ]; |
| | | } |
| | | |
| | | //Check images are already uploaded first |
| | | $image_mappings = $this->batchUploadImages($items); |
| | | |
| | | $catalog = []; |
| | | $map = []; |
| | | foreach ($items as $postID) { |
| | | $post = get_post($postID); |
| | | if (!$post) { |
| | | $errors[] = "Post $postID not found"; |
| | | continue; |
| | | } |
| | | |
| | | $square_image_id = $image_mappings[$postID] ?? null; |
| | | $catalog_object = $this->buildCatalogObject($postID, $square_image_id); |
| | | if (is_wp_error($catalog_object)) { |
| | | $errors[] = $catalog_object->get_error_message(); |
| | | continue; |
| | | } |
| | | $map[$catalog_object['id']] = $postID; |
| | | $catalog[] = $catalog_object; |
| | | } |
| | | |
| | | if (!empty($catalog)) { |
| | | $response = $this->postRequest('catalog/batch-upsert', [ |
| | | 'idempotency_key' => wp_generate_uuid4(), |
| | | 'batches' => [[ |
| | | 'objects' => $catalog |
| | | ]] |
| | | ]); |
| | | |
| | | if (!is_wp_error($response)) { |
| | | $this->processBatchSyncResponse($response, $map, $success, $errors); |
| | | } else { |
| | | // Handle batch request failure |
| | | $error_message = 'Batch sync failed'; |
| | | $this->logError($error_message, [ |
| | | 'method' => 'processSyncToSquare', |
| | | 'post_ids' => $items, |
| | | 'error' => $response |
| | | ]); |
| | | |
| | | // Mark all items as failed |
| | | foreach ($items as $postID) { |
| | | if (get_post($postID)) { // Only if post exists |
| | | $errors[] = "Failed to sync post $postID"; |
| | | update_post_meta($postID, BASE . '_square_sync_status', 'error'); |
| | | } |
| | | } |
| | | } |
| | | } |
| | | |
| | | return [ |
| | | 'success' => count($success) > 0, |
| | | 'result' => [ |
| | | 'synced' => $success, |
| | | 'errors' => $errors |
| | | ] |
| | | ]; |
| | | } |
| | | |
| | | private function processBatchSyncResponse(array $response, array $map, array &$success, array &$errors):void |
| | | { |
| | | error_log('==== SQUARE::processBatchSyncResponse ====='); |
| | | error_log('Full response: '.print_r($response, true)); |
| | | |
| | | // Handle successful objects |
| | | if (!empty($response['objects'])) { |
| | | foreach ($response['objects'] as $object) { |
| | | $object_id = $object['id']; |
| | | $post_id = $map[$object_id] ?? null; |
| | | |
| | | if (!$post_id) { |
| | | // Try to find post ID from the object ID pattern |
| | | if (preg_match('/#\w+_(\d+)/', $object_id, $matches)) { |
| | | $post_id = (int)$matches[1]; |
| | | } |
| | | } |
| | | |
| | | if ($post_id && get_post($post_id)) { |
| | | // Update post meta with Square data |
| | | update_post_meta($post_id, BASE . '_square_catalog_id', $object['id']); |
| | | update_post_meta($post_id, BASE . '_square_sync_status', 'synced'); |
| | | update_post_meta($post_id, BASE . '_square_last_sync', current_time('mysql')); |
| | | |
| | | // Save variation IDs if present |
| | | if (!empty($object['item_data']['variations'])) { |
| | | foreach ($object['item_data']['variations'] as $index => $variation) { |
| | | update_post_meta($post_id, BASE . '_square_variation_' . $index . '_id', $variation['id']); |
| | | } |
| | | } |
| | | |
| | | $success[] = $post_id; |
| | | } |
| | | } |
| | | } |
| | | |
| | | // Handle errors |
| | | if (!empty($response['errors'])) { |
| | | foreach ($response['errors'] as $error) { |
| | | $error_detail = $error['detail'] ?? 'Unknown error'; |
| | | $error_field = $error['field'] ?? ''; |
| | | |
| | | // Try to extract post ID from error field if possible |
| | | $post_id = null; |
| | | if (preg_match('/batches\[0\]\.objects\[(\d+)\]/', $error_field, $matches)) { |
| | | $object_index = (int)$matches[1]; |
| | | // Get post ID from the object at this index |
| | | if (isset($catalog_objects[$object_index])) { |
| | | $object_id = $catalog_objects[$object_index]['id']; |
| | | $post_id = $map[$object_id] ?? null; |
| | | } |
| | | } |
| | | |
| | | $error_message = $post_id |
| | | ? "Post $post_id: $error_detail" |
| | | : "Batch error: $error_detail"; |
| | | |
| | | $errors[] = $error_message; |
| | | |
| | | if ($post_id) { |
| | | update_post_meta($post_id, BASE . '_square_sync_status', 'error'); |
| | | } |
| | | } |
| | | } |
| | | |
| | | // Handle any posts that weren't in the response (shouldn't happen but good to check) |
| | | foreach ($map as $object_id => $post_id) { |
| | | if (!in_array($post_id, $success)) { |
| | | $found_in_errors = false; |
| | | foreach ($errors as $error) { |
| | | if (str_contains($error, "Post $post_id")) { |
| | | $found_in_errors = true; |
| | | break; |
| | | } |
| | | } |
| | | |
| | | if (!$found_in_errors) { |
| | | $errors[] = "Post $post_id: No response from Square"; |
| | | update_post_meta($post_id, BASE . '_square_sync_status', 'error'); |
| | | } |
| | | } |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Build catalog object from WordPress post |
| | | * |
| | | * @param int $postID WordPress post ID |
| | | * @param string|null $square_image_id Previously uploaded Square image ID |
| | | * @return array|WP_Error Catalog object or error |
| | | */ |
| | | //TODO: Get to work with Registrar settings |
| | | protected function buildCatalogObject(int $postID, ?string $square_image_id = null): array|WP_Error |
| | | { |
| | | $post = get_post($postID); |
| | | if (!$post) { |
| | | return new WP_Error('post_not_found', "Post $postID not found"); |
| | | } |
| | | |
| | | $meta = Meta::forPost($postID); |
| | | $post_type = get_post_type($postID); |
| | | |
| | | // Get existing Square catalog ID if it exists |
| | | $existing_square_id = get_post_meta($postID, BASE . '_square_catalog_id', true); |
| | | $registrar = Registrar::getInstance($post_type); |
| | | $product_type = 'FOOD_AND_BEV'; |
| | | if ($registrar) { |
| | | $conf = $registrar->getIntegration($this->service_name); |
| | | if ($conf) { |
| | | $product_type = $conf->getContentType(); |
| | | } |
| | | } |
| | | |
| | | // Build the base catalog object |
| | | $catalog_object = [ |
| | | 'type' => 'ITEM', |
| | | 'id' => $existing_square_id ?: '#menu_item_item_' . $postID, |
| | | 'item_data' => [ |
| | | 'name' => $post->post_title, |
| | | 'description' => wp_strip_all_tags($post->post_content), |
| | | 'product_type' => $product_type, |
| | | 'variations' => [], |
| | | 'is_taxable' => true, |
| | | ] |
| | | ]; |
| | | |
| | | // Add image ID if provided (must be already uploaded to Square) |
| | | if (!empty($square_image_id)) { |
| | | $catalog_object['item_data']['image_ids'] = [$square_image_id]; |
| | | } |
| | | |
| | | // Add variations |
| | | $variations = $meta->get('_square_product_variations'); |
| | | if (empty($variations)) { |
| | | // Create default variation if none exist |
| | | $catalog_object['item_data']['variations'][] = [ |
| | | 'type' => 'ITEM_VARIATION', |
| | | 'id' => $existing_square_id ? null : '#'.BASE.'menu_item_' . $postID . '_var_default', |
| | | 'item_variation_data' => [ |
| | | 'name' => 'Regular', |
| | | 'ordinal' => 0, |
| | | 'pricing_type' => 'FIXED_PRICING', |
| | | 'price_money' => [ |
| | | 'amount' => $this->formatPrice($meta->get('_square_price')), |
| | | 'currency' => $this->getCurrency() |
| | | ], |
| | | 'sellable' => true, |
| | | 'stockable' => true |
| | | ] |
| | | ]; |
| | | } else { |
| | | $resetVariations = false; |
| | | foreach ($variations as $index => $variation) { |
| | | $id = '#'.BASE.'menu_item_' . $postID . '_var_' . $index; |
| | | if (empty($variation['item_id'])) { |
| | | $resetVariations = true; |
| | | $variations[$index]['item_id'] = $id; |
| | | $variation['item_id'] = $id; |
| | | } |
| | | $catalog_object['item_data']['variations'][] = [ |
| | | 'type' => 'ITEM_VARIATION', |
| | | 'id' => $variation['item_id'], |
| | | 'item_variation_data' => [ |
| | | 'name' => $variation['name'] ?? 'Variation ' . ($index + 1), |
| | | 'ordinal' => $index, |
| | | 'pricing_type' => 'FIXED_PRICING', |
| | | 'price_money' => [ |
| | | 'amount' =>$this->formatPrice($variation['price'] ?? 0), |
| | | 'currency' => $this->getCurrency() |
| | | ], |
| | | 'sellable' => true, |
| | | 'stockable' => true |
| | | ] |
| | | ]; |
| | | } |
| | | if ($resetVariations) { |
| | | $meta->set('_square_product_variations', $variations); |
| | | } |
| | | } |
| | | |
| | | // Add categories if they exist |
| | | $categories = wp_get_post_terms($postID, $post_type . '_category', ['fields' => 'ids']); |
| | | if (!empty($categories)) { |
| | | $category_ids = []; |
| | | foreach ($categories as $term_id) { |
| | | $square_cat_id = get_term_meta($term_id, BASE . '_square_category_id', true); |
| | | if ($square_cat_id) { |
| | | $category_ids[] = $square_cat_id; |
| | | } |
| | | } |
| | | if (!empty($category_ids)) { |
| | | $catalog_object['item_data']['category_ids'] = $category_ids; |
| | | } |
| | | } |
| | | |
| | | // Add modifiers if they exist |
| | | $modifiers = $meta->get('modifiers'); |
| | | if (!empty($modifiers)) { |
| | | $modifier_ids = []; |
| | | foreach ($modifiers as $modifier) { |
| | | if (!empty($modifier['square_id'])) { |
| | | $modifier_ids[] = $modifier['square_id']; |
| | | } |
| | | } |
| | | if (!empty($modifier_ids)) { |
| | | $catalog_object['item_data']['modifier_list_info'] = array_map(function($id) { |
| | | return ['modifier_list_id' => $id]; |
| | | }, $modifier_ids); |
| | | } |
| | | } |
| | | |
| | | // Add tax settings |
| | | $tax_ids = $meta->get('tax_ids'); |
| | | if (!empty($tax_ids)) { |
| | | $catalog_object['item_data']['tax_ids'] = $tax_ids; |
| | | } |
| | | |
| | | return $catalog_object; |
| | | } |
| | | |
| | | |
| | | |
| | | /** |
| | | * Get variation mapping for post type |
| | | */ |
| | | protected function getVariationMapping(string $post_type): array |
| | | { |
| | | $registrar = Registrar::getInstance($post_type); |
| | | if (!$registrar) { |
| | | return []; |
| | | } |
| | | $config = $registrar->getIntegrationConfig($this->service_name); |
| | | $product_type = $config['content_type']??'REGULAR'; |
| | | $valid_fields = $this->getValidFieldsForProductType($product_type); |
| | | |
| | | $defaults = [ |
| | | 'name' => 'name', |
| | | 'id' => '_square_item_id', |
| | | 'sku' => '_square_sku', |
| | | 'price' => '_square_price', |
| | | 'track_inventory' => '_square_track_inventory', |
| | | 'service_duration' => '_square_service_duration', |
| | | 'available_for_booking' => '_square_available_for_booking', |
| | | 'gift_card_type' => '_square_gift_card_type', |
| | | 'ingredients' => '_square_ingredients', |
| | | 'preparation_time_duration' => '_square_preparation_time_duration' |
| | | ]; |
| | | |
| | | $extended = apply_filters( |
| | | BASE . $this->service_name . '_variation_mapping', |
| | | $defaults, |
| | | $post_type, |
| | | $this |
| | | ); |
| | | |
| | | return array_filter($extended, function($key) use ($valid_fields) { |
| | | return in_array($key, $valid_fields); |
| | | }, ARRAY_FILTER_USE_KEY); |
| | | } |
| | | |
| | | |
| | | |
| | | /** |
| | | * Get or create Square category |
| | | */ |
| | | private function getOrCreateSquareCategory(string $name): ?string |
| | | { |
| | | // Check cached mapping |
| | | $cached_id = get_option(BASE . 'square_category_' . sanitize_title($name)); |
| | | if ($cached_id) { |
| | | return $cached_id; |
| | | } |
| | | |
| | | // Create new category |
| | | $response = $this->postRequest('catalog/batch-upsert', [ |
| | | 'idempotency_key' => wp_generate_uuid4(), |
| | | 'batches' => [[ |
| | | 'objects' => [[ |
| | | 'type' => 'CATEGORY', |
| | | 'id' => '#category_' . sanitize_title($name), |
| | | 'category_data' => [ |
| | | 'name' => $name |
| | | ] |
| | | ]] |
| | | ]] |
| | | ]); |
| | | |
| | | if (!is_wp_error($response) && !empty($response['objects'][0]['id'])) { |
| | | $category_id = $response['objects'][0]['id']; |
| | | update_option(BASE . 'square_category_' . sanitize_title($name), $category_id); |
| | | return $category_id; |
| | | } |
| | | |
| | | return null; |
| | | } |
| | | |
| | | /** |
| | | * Get field mapping for post type |
| | | */ |
| | | protected function getFieldMapping(string $post_type): array |
| | | { |
| | | $registrar = Registrar::getInstance($post_type); |
| | | if (!$registrar) { |
| | | return []; |
| | | } |
| | | $config = $registrar->getIntegrationConfig($this->service_name); |
| | | $product_type = $config['content_type']??'REGULAR'; |
| | | $valid_fields = $this->getValidFieldsForProductType($product_type); |
| | | |
| | | $defaults = [ |
| | | 'name' => 'post_title', |
| | | 'description_html' => 'post_content', |
| | | 'abbreviation' => 'abbreviation', |
| | | 'id' => '_square_catalog_id', |
| | | 'sku' => '_square_sku', |
| | | 'category_id' => 'category', |
| | | 'image_ids' => 'post_thumbnail', |
| | | 'price' => '_square_price', |
| | | 'tax_ids' => 'tax_ids', |
| | | // Availability |
| | | 'available_online' => '_square_available_online', |
| | | 'available_for_pickup' => '_square_available_for_pickup', |
| | | 'available_electronically' => '_square_available_electronically', |
| | | // Modifiers |
| | | 'modifier_list_info' => 'modifiers', |
| | | // Item options |
| | | 'item_options' => 'options', |
| | | // Reporting |
| | | 'reporting_category' => 'reporting_category', |
| | | ]; |
| | | |
| | | $extended = apply_filters( |
| | | BASE . $this->service_name . '_field_mapping', |
| | | $defaults, |
| | | $post_type, |
| | | $this |
| | | ); |
| | | |
| | | return array_filter($extended, function($key) use ($valid_fields) { |
| | | return in_array($key, $valid_fields); |
| | | }, ARRAY_FILTER_USE_KEY); |
| | | } |
| | | |
| | | /** |
| | | * Get valid fields for Square product type |
| | | */ |
| | | //TODO: This feels redundant now, with how we've defined fields in getAdditionalFields |
| | | private function getValidFieldsForProductType(string $product_type): array |
| | | { |
| | | $fields = ['name', 'description_html', 'sku', 'price', 'image_ids', 'category_id']; |
| | | |
| | | $type_specific = [ |
| | | 'FOOD_AND_BEV' => [ |
| | | 'ingredients', |
| | | 'preparation_time_duration', |
| | | 'dietary_preferences', |
| | | 'calories_text' |
| | | ], |
| | | 'APPOINTMENTS_SERVICE' => [ |
| | | 'service_duration', |
| | | 'available_for_booking', |
| | | 'team_member_ids', |
| | | 'booking_availability' |
| | | ], |
| | | 'EVENT' => [ |
| | | 'start_date', |
| | | 'end_date', |
| | | 'venue_details', |
| | | 'capacity' |
| | | ], |
| | | 'GIFT_CARD' => [ |
| | | 'gift_card_type', |
| | | 'allowed_locations' |
| | | ] |
| | | ]; |
| | | |
| | | if (isset($type_specific[$product_type])) { |
| | | $fields = array_merge($fields, $type_specific[$product_type]); |
| | | } |
| | | |
| | | return $fields; |
| | | } |
| | | |
| | | /****************************************************************** |
| | | * CUSTOMER MANAGEMENT |
| | | ******************************************************************/ |
| | | |
| | | /** |
| | | * Handle customer authentication during checkout |
| | | */ |
| | | //TODO: Is this necessary? |
| | | public function handleCustomerAuth($data):WP_Error|array |
| | | { |
| | | $email = sanitize_email($data['email'] ?? ''); |
| | | $action = sanitize_text_field($data['action_type'] ?? 'check'); |
| | | |
| | | if (!$email) { |
| | | return new WP_Error('error', 'Email required'); |
| | | } |
| | | |
| | | switch ($action) { |
| | | case 'check': |
| | | return $this->checkCustomerExists($email); |
| | | case 'create': |
| | | return $this->createCustomerAccount($email); |
| | | default: |
| | | $this->logError('No action set for customer auth: '.$action, [ |
| | | 'method' => 'handleCustomerAuth' |
| | | ]); |
| | | return new WP_Error('error', 'No action set for customer auth'); |
| | | } |
| | | |
| | | } |
| | | |
| | | |
| | | |
| | | /** |
| | | * Check if customer exists |
| | | */ |
| | | private function checkCustomerExists(string $email):WP_Error|array |
| | | { |
| | | // Check WordPress user |
| | | $user = get_user_by('email', $email); |
| | | |
| | | if ($user) { |
| | | // Check if user has Square customer integration |
| | | $square_customer_id = get_user_meta($user->ID, BASE . '_square_customer_id', true); |
| | | |
| | | if ($square_customer_id) { |
| | | return [ |
| | | 'exists' => true, |
| | | 'has_account' => true, |
| | | 'message' => 'Account found. Please enter your password to continue' |
| | | ]; |
| | | } else { |
| | | return [ |
| | | 'exists' => true, |
| | | 'has_account' => false, |
| | | 'message' => 'Email found. Would you like to create an account to save your order history?' |
| | | ]; |
| | | } |
| | | } |
| | | |
| | | // Check Square for customer |
| | | $response = $this->postRequest('customers/search', [ |
| | | 'filter' => [ |
| | | 'email_address' => [ |
| | | 'exact' => $email |
| | | ] |
| | | ] |
| | | ]); |
| | | |
| | | if (!is_wp_error($response) && !empty($response['customers'])) { |
| | | return [ |
| | | 'exists' => true, |
| | | 'has_account' => false, |
| | | 'square_only' => true, |
| | | 'message' => 'Found your previous orders. Create an account to access them?' |
| | | ]; |
| | | } else { |
| | | return [ |
| | | 'exists' => false, |
| | | 'message' => 'New customer' |
| | | ]; |
| | | } |
| | | } |
| | | |
| | | private function createCustomerAccount(string $email):WP_Error|array |
| | | { |
| | | // Validate email format |
| | | if (!is_email($email)) { |
| | | return new WP_Error('error', 'Invalid email address'); |
| | | } |
| | | |
| | | // Check if user already exists |
| | | if (email_exists($email)) { |
| | | return [ |
| | | 'success' => false, |
| | | 'exists' => true, |
| | | 'message' => 'An account with this email already exists. Please log in instead.' |
| | | ]; |
| | | } |
| | | |
| | | // Create user account without password (they'll set it via email) |
| | | $user_id = wp_create_user( |
| | | $email, |
| | | wp_generate_password(20, true, true), // Temporary random password |
| | | $email |
| | | ); |
| | | |
| | | if (is_wp_error($user_id)) { |
| | | $this->logError('Failed to create customer account', [ |
| | | 'email' => $email, |
| | | 'error' => $user_id->get_error_message() |
| | | ]); |
| | | return new WP_Error('error', 'Failed to create account. Please try again.'); |
| | | } |
| | | |
| | | // Set user role (assuming you have a customer role defined) |
| | | $user = new WP_User($user_id); |
| | | $user->set_role(BASE.'foodie'); |
| | | |
| | | // Generate password reset key |
| | | $reset_key = get_password_reset_key($user); |
| | | |
| | | if (is_wp_error($reset_key)) { |
| | | return new WP_Error('error', 'Account created, but couldn\'t send email. Please use password reset.'); |
| | | } |
| | | |
| | | |
| | | // Send welcome email with password setup link |
| | | $this->sendWelcomeEmail($user, $reset_key); |
| | | |
| | | // Link to Square customer if exists |
| | | $square_customer_id = $this->getOrCreateSquareCustomer([ |
| | | 'email' => $email, |
| | | 'name' => $email |
| | | ]); |
| | | |
| | | if ($square_customer_id) { |
| | | update_user_meta($user_id, BASE . '_square_customer_id', $square_customer_id); |
| | | } |
| | | |
| | | return [ |
| | | 'success' => true, |
| | | 'message' => 'Account created! Check your email to set your password.', |
| | | 'user_id' => $user_id |
| | | ]; |
| | | } |
| | | |
| | | /** |
| | | * Send welcome email with password setup |
| | | */ |
| | | private function sendWelcomeEmail(WP_User $user, string $reset_key): void |
| | | { |
| | | $site_name = get_bloginfo('name'); |
| | | $reset_url = get_home_url(null, "wp-login.php?action=rp&key=$reset_key&login=" . rawurlencode($user->user_login), 'login'); |
| | | |
| | | $message = sprintf( |
| | | "Welcome to %s!\n\n" . |
| | | "Your account has been created. Please click the button below to set your password:\n\n" . |
| | | "%s\n\n" . |
| | | "Or, copy and paste the link below:\n\n". |
| | | "%s\n\n" . |
| | | "Once you've set your password, you can:\n" . |
| | | "- View your order history\n" . |
| | | // "- Save your favorite items\n" . |
| | | "- Speed up checkout with saved payment methods\n\n" . |
| | | "If you didn't create this account, please ignore this email.\n\n" . |
| | | "Thanks,\n", |
| | | $site_name, |
| | | JVB()->email()->button('Reset Password', $reset_url), |
| | | JVB()->email()->link($reset_url), |
| | | ); |
| | | |
| | | JVB()->email()->sendEmail( |
| | | $user->user_email, |
| | | sprintf('[%s] Welcome! Set Your Password', $site_name), |
| | | $message |
| | | ); |
| | | } |
| | | |
| | | /****************************************************************** |
| | | * WEBHOOK HANDLING |
| | | ******************************************************************/ |
| | | |
| | | /** |
| | | * Validate webhook signature |
| | | */ |
| | | protected function validateWebhook(array $payload): bool |
| | | { |
| | | // Get signature from headers |
| | | $signature = $_SERVER['HTTP_X_SQUARE_SIGNATURE'] ?? ''; |
| | | |
| | | // If no signature provided by Square, validation fails |
| | | if (empty($signature)) { |
| | | $this->logDebug('No webhook signature provided by Square'); |
| | | return false; |
| | | } |
| | | |
| | | // If webhook signature key is not configured, we can't validate |
| | | // but we might want to process the webhook anyway (less secure) |
| | | if (empty($this->webhook_signature_key)) { |
| | | $this->logDebug('Webhook signature key not configured - processing without validation'); |
| | | // You might want to return false here for stricter security |
| | | // return false; |
| | | return true; // Process webhook but log that it's unverified |
| | | } |
| | | |
| | | // Get the raw request body |
| | | $body = file_get_contents('php://input'); |
| | | |
| | | // Calculate expected signature |
| | | $expected = base64_encode(hash_hmac('sha256', $body, $this->webhook_signature_key, true)); |
| | | |
| | | // Use timing-safe comparison |
| | | $is_valid = hash_equals($expected, $signature); |
| | | |
| | | if (!$is_valid) { |
| | | $this->logError('Invalid webhook signature', [ |
| | | 'provided' => substr($signature, 0, 10) . '...', |
| | | 'expected' => substr($expected, 0, 10) . '...' |
| | | ]); |
| | | } |
| | | |
| | | return $is_valid; |
| | | } |
| | | |
| | | /** |
| | | * Process webhook event |
| | | */ |
| | | protected function processWebhook(array $payload): bool |
| | | { |
| | | $event_type = $payload['type'] ?? ''; |
| | | $data = $payload['data'] ?? []; |
| | | |
| | | switch ($event_type) { |
| | | case 'payment.created': |
| | | case 'payment.updated': |
| | | return $this->handlePaymentWebhook($data); |
| | | |
| | | case 'order.created': |
| | | case 'order.updated': |
| | | case 'order.fulfillment.updated': |
| | | return $this->handleOrderWebhook($data); |
| | | |
| | | case 'catalog.version.updated': |
| | | return $this->handleCatalogWebhook($data); |
| | | |
| | | case 'customer.created': |
| | | case 'customer.updated': |
| | | return $this->handleCustomerWebhook($data); |
| | | |
| | | default: |
| | | $this->logDebug('Unhandled webhook type', ['type' => $event_type]); |
| | | return true; |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Handle order status webhook |
| | | */ |
| | | private function handleOrderWebhook(array $data): bool |
| | | { |
| | | $order_id = $data['object']['order']['id'] ?? ''; |
| | | $state = $data['object']['order']['state'] ?? ''; |
| | | $fulfillments = $data['object']['order']['fulfillments'] ?? []; |
| | | |
| | | if (!$order_id) { |
| | | return false; |
| | | } |
| | | |
| | | // Find the WP post for this order |
| | | $wp_order_id = $this->getOrderPost($order_id); |
| | | |
| | | if ($wp_order_id) { |
| | | // Update the post meta |
| | | $meta = Meta::forPost($wp_order_id); |
| | | $updates = [ |
| | | 'status' => $state, |
| | | 'updated_at' => current_time('mysql') |
| | | ]; |
| | | |
| | | // Extract fulfillment status and pickup time |
| | | if (!empty($fulfillments[0])) { |
| | | $fulfillment = $fulfillments[0]; |
| | | $updates['fulfillment_status'] = $fulfillment['state'] ?? $state; |
| | | |
| | | if (!empty($fulfillment['pickup_details']['pickup_at'])) { |
| | | $updates['pickup_time'] = $fulfillment['pickup_details']['pickup_at']; |
| | | } |
| | | } |
| | | |
| | | $meta->setAll($updates); |
| | | |
| | | // Trigger notification to customer if order is ready |
| | | if ($state === 'PREPARED') { |
| | | do_action(BASE . 'square_order_ready', $wp_order_id, $order_id); |
| | | } |
| | | } |
| | | // Trigger action for other integrations |
| | | do_action(BASE . 'square_order_updated', $order_id, $state, $data); |
| | | |
| | | return true; |
| | | } |
| | | |
| | | /****************************************************************** |
| | | * CONNECTION TESTING |
| | | ******************************************************************/ |
| | | protected function performConnectionTest(): bool |
| | | { |
| | | try { |
| | | $response = $this->getRequest('/v2/merchants/me'); |
| | | if (is_wp_error($response)) { |
| | | $this->logError('Connection test failed', ['error' => $response->get_error_message()]); |
| | | return false; |
| | | } |
| | | |
| | | // Check if we got valid merchant data |
| | | return !empty($response['merchant']); |
| | | } catch (Exception $e) { |
| | | $this->logError('Connection test failed', ['error' => $e->getMessage()]); |
| | | return false; |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Check OAuth connection status |
| | | */ |
| | | protected function checkOAuthStatus(): array |
| | | { |
| | | if (!$this->hasOAuthCredentials()) { |
| | | return [ |
| | | 'success' => false, |
| | | 'message' => 'OAuth not configured. Please authorize with Square.', |
| | | 'authorized' => false |
| | | ]; |
| | | } |
| | | |
| | | // Test the connection |
| | | if ($this->performConnectionTest()) { |
| | | $merchant = $this->getRequest('merchants/me'); |
| | | return [ |
| | | 'success' => true, |
| | | 'message' => 'Successfully connected to Square', |
| | | 'authorized' => true, |
| | | 'merchant' => $merchant['merchant'] ?? [] |
| | | ]; |
| | | } |
| | | |
| | | return [ |
| | | 'success' => false, |
| | | 'message' => 'OAuth token may be expired. Please re-authorize.', |
| | | 'authorized' => false |
| | | ]; |
| | | } |
| | | |
| | | /****************************************************************** |
| | | * ADMIN UI |
| | | ******************************************************************/ |
| | | /** |
| | | * Enqueue checkout scripts with Square configuration |
| | | */ |
| | | public function enqueueScripts(): void |
| | | { |
| | | jvbInlineStyles('forms'); |
| | | $this->loadCredentials(); |
| | | $sdk_url = $this->environment === 'production' |
| | | ? 'https://web.squarecdn.com/v1/square.js' |
| | | : 'https://sandbox.web.squarecdn.com/v1/square.js'; |
| | | |
| | | wp_enqueue_script( |
| | | 'square-payments-sdk', |
| | | $sdk_url, |
| | | [], |
| | | null, |
| | | ['strategy' => 'defer', 'in_footer' => true] |
| | | ); |
| | | |
| | | // Shared cart checkout base class |
| | | wp_register_script( |
| | | 'jvb-checkout', |
| | | JVB_URL . 'assets/js/min/checkout.min.js', |
| | | ['jvb-utility', 'jvb-queue', 'jvb-a11y', 'jvb-cache', 'jvb-tabs', 'jvb-popup', 'jvb-login'], |
| | | '1.1.32', |
| | | ['strategy' => 'defer', 'in_footer' => true] |
| | | ); |
| | | |
| | | // Square checkout extends CartCheckout |
| | | wp_register_script( |
| | | 'jvb-square-checkout', |
| | | JVB_URL . 'assets/js/min/square.min.js', |
| | | ['jvb-checkout', 'square-payments-sdk'], |
| | | '1.1.32', |
| | | ['strategy' => 'defer', 'in_footer' => true] |
| | | ); |
| | | |
| | | wp_enqueue_script('jvb-square-checkout'); |
| | | |
| | | wp_localize_script('jvb-square-checkout', 'squareConfig', [ |
| | | 'isOpen' => jvbIsOpen()?'1':'0', |
| | | 'application_id' => $this->credentials['client_id'] ?? '', |
| | | 'location_id' => $this->locationId, |
| | | 'environment' => $this->environment, |
| | | 'currency' => $this->getCurrency(), |
| | | 'is_logged_in' => is_user_logged_in(), |
| | | 'user_email' => is_user_logged_in() ? wp_get_current_user()->user_email : '', |
| | | ]); |
| | | } |
| | | |
| | | /****************************************************************** |
| | | * HELPER METHODS |
| | | ******************************************************************/ |
| | | |
| | | /** |
| | | * Get or create Square customer |
| | | */ |
| | | public function getOrCreateSquareCustomer(array $customer_info): ?string |
| | | { |
| | | if (empty($customer_info['email'])) { |
| | | return null; |
| | | } |
| | | |
| | | // Search for existing customer |
| | | $search_response = $this->postRequest('customers/search', [ |
| | | 'filter' => [ |
| | | 'email_address' => [ |
| | | 'exact' => $customer_info['email'] |
| | | ] |
| | | ] |
| | | ]); |
| | | |
| | | if (!is_wp_error($search_response) && !empty($search_response['customers'])) { |
| | | return $search_response['customers'][0]['id']; |
| | | } |
| | | |
| | | // Create new customer |
| | | $create_response = $this->postRequest('customers', [ |
| | | 'given_name' => $customer_info['name'] ?? '', |
| | | 'email_address' => $customer_info['email'], |
| | | 'phone_number' => $customer_info['phone'] ?? '' |
| | | ]); |
| | | |
| | | if (!is_wp_error($create_response) && !empty($create_response['customer']['id'])) { |
| | | return $create_response['customer']['id']; |
| | | } |
| | | |
| | | return null; |
| | | } |
| | | |
| | | |
| | | /** |
| | | * Process delete from Square |
| | | */ |
| | | private function processDeleteFromSquare(array $data): array |
| | | { |
| | | $square_ids = $data['square_ids'] ?? []; |
| | | |
| | | if (empty($square_ids)) { |
| | | return [ |
| | | 'success' => false, |
| | | 'result' => ['error' => 'No Square IDs provided'] |
| | | ]; |
| | | } |
| | | |
| | | $response = $this->postRequest('catalog/batch-delete', [ |
| | | 'object_ids' => $square_ids |
| | | ]); |
| | | |
| | | if (is_wp_error($response)) { |
| | | return [ |
| | | 'success' => false, |
| | | 'result' => ['error' => $response->get_error_message()] |
| | | ]; |
| | | } |
| | | |
| | | // Clean up post meta |
| | | if (!empty($data['post_id'])) { |
| | | delete_post_meta($data['post_id'], BASE . '_square_catalog_id'); |
| | | delete_post_meta($data['post_id'], BASE . '_square_sync_status'); |
| | | } |
| | | |
| | | return [ |
| | | 'success' => true, |
| | | 'result' => ['deleted' => $square_ids] |
| | | ]; |
| | | } |
| | | |
| | | /** |
| | | * Process sync from Square |
| | | */ |
| | | private function processSyncFromSquare(array $data): array |
| | | { |
| | | $cursor = $data['cursor'] ?? null; |
| | | $types = $data['types'] ?? ['ITEM']; |
| | | |
| | | $request_data = [ |
| | | 'types' => implode(',', $types), |
| | | 'limit' => 100 |
| | | ]; |
| | | |
| | | if ($cursor) { |
| | | $request_data['cursor'] = $cursor; |
| | | } |
| | | |
| | | $response = $this->getRequest('catalog/list?' . http_build_query($request_data)); |
| | | |
| | | if (is_wp_error($response)) { |
| | | return [ |
| | | 'success' => false, |
| | | 'result' => ['error' => $response->get_error_message()] |
| | | ]; |
| | | } |
| | | |
| | | $imported = 0; |
| | | $errors = []; |
| | | |
| | | foreach ($response['objects'] ?? [] as $object) { |
| | | if ($object['type'] === 'ITEM') { |
| | | $result = $this->importSquareItem($object); |
| | | if ($result) { |
| | | $imported++; |
| | | } else { |
| | | $errors[] = $object['id']; |
| | | } |
| | | } |
| | | } |
| | | |
| | | // Queue next batch if cursor exists |
| | | if (!empty($response['cursor'])) { |
| | | $this->queueOperation('sync_from_square', [ |
| | | 'cursor' => $response['cursor'], |
| | | 'types' => $types |
| | | ]); |
| | | } |
| | | |
| | | return [ |
| | | 'success' => true, |
| | | 'result' => [ |
| | | 'imported' => $imported, |
| | | 'errors' => $errors, |
| | | 'has_more' => !empty($response['cursor']) |
| | | ] |
| | | ]; |
| | | } |
| | | |
| | | /** |
| | | * Import Square item to WordPress |
| | | */ |
| | | private function importSquareItem(array $item): bool|int |
| | | { |
| | | //TODO: We need to add the post type to custom meta for Square, this is not good if we have multiple post types with the same product type |
| | | // Find matching content type |
| | | $product_type = $item['item_data']['product_type'] ?? 'REGULAR'; |
| | | $post_type = null; |
| | | |
| | | foreach (Registrar::getRegistered() as $registrar) { |
| | | if (!$registrar->hasIntegration($this->service_name)) { |
| | | continue; |
| | | } |
| | | $config = $registrar->getIntegration($this->service_name); |
| | | if ($config->getContent_type() && $config->getContent_type() === $product_type) { |
| | | $post_type = jvbCheckBase($registrar->getSlug()); |
| | | break; |
| | | } |
| | | |
| | | } |
| | | |
| | | if (!$post_type) { |
| | | return false; |
| | | } |
| | | |
| | | // Check if item already exists |
| | | $existing = get_posts([ |
| | | 'post_type' => $post_type, |
| | | 'meta_key' => BASE . '_square_catalog_id', |
| | | 'meta_value' => $item['id'], |
| | | 'posts_per_page' => 1 |
| | | ]); |
| | | |
| | | $post_data = [ |
| | | 'post_title' => $item['item_data']['name'] ?? '', |
| | | 'post_content' => $item['item_data']['description'] ?? '', |
| | | 'post_type' => $post_type, |
| | | 'post_status' => 'publish' |
| | | ]; |
| | | |
| | | if (!empty($existing)) { |
| | | $post_data['ID'] = $existing[0]->ID; |
| | | $post_id = wp_update_post($post_data); |
| | | } else { |
| | | $post_id = wp_insert_post($post_data); |
| | | } |
| | | |
| | | if (!$post_id || is_wp_error($post_id)) { |
| | | return false; |
| | | } |
| | | |
| | | // Map and save meta fields |
| | | $this->mapSquareFieldsToWordPress($post_id, $item); |
| | | |
| | | return $post_id; |
| | | } |
| | | |
| | | /** |
| | | * Map Square fields to WordPress meta |
| | | */ |
| | | private function mapSquareFieldsToWordPress(int $post_id, array $item): void |
| | | { |
| | | $meta = Meta::forPost($post_id); |
| | | $field_map = $this->getFieldMapping(get_post_type($post_id)); |
| | | |
| | | $values_to_save = []; |
| | | |
| | | // Save Square ID |
| | | $values_to_save['_square_catalog_id'] = $item['id']; |
| | | $values_to_save['_square_last_sync'] = current_time('mysql'); |
| | | |
| | | // Handle variations if present |
| | | if (!empty($item['item_data']['variations'])) { |
| | | $variations_data = []; |
| | | |
| | | foreach ($item['item_data']['variations'] as $index => $variation) { |
| | | $var_data = [ |
| | | 'name' => $variation['item_variation_data']['name'] ?? '', |
| | | 'sku' => $variation['item_variation_data']['_square_sku'] ?? '', |
| | | ]; |
| | | |
| | | // Extract price |
| | | if (!empty($variation['item_variation_data']['price_money'])) { |
| | | $var_data['price'] = $variation['item_variation_data']['price_money']['amount'] / 100; |
| | | } |
| | | |
| | | // Extract other variation fields |
| | | $variation_map = $this->getVariationMapping(get_post_type($post_id)); |
| | | foreach ($variation_map as $square_field => $wp_field) { |
| | | if (isset($variation['item_variation_data'][$square_field])) { |
| | | $var_data[$wp_field] = $variation['item_variation_data'][$square_field]; |
| | | } |
| | | } |
| | | |
| | | $variations_data[] = $var_data; |
| | | |
| | | // Save variation Square ID |
| | | update_post_meta($post_id, BASE . '_square_variation_' . $index . '_id', $variation['id']); |
| | | } |
| | | |
| | | $values_to_save['product_variations'] = $variations_data; |
| | | } |
| | | |
| | | // Map other fields |
| | | foreach ($field_map as $square_field => $wp_field) { |
| | | if ($square_field !== 'price' && isset($item['item_data'][$square_field])) { |
| | | $values_to_save[$wp_field] = $item['item_data'][$square_field]; |
| | | } |
| | | } |
| | | |
| | | // Save all values at once |
| | | $meta->setAll($values_to_save); |
| | | } |
| | | |
| | | /** |
| | | * Process customer sync |
| | | */ |
| | | private function processSyncCustomer(array $data): array |
| | | { |
| | | $user_id = $data['user_id'] ?? 0; |
| | | |
| | | if (!$user_id) { |
| | | return [ |
| | | 'success' => false, |
| | | 'result' => ['error' => 'No user ID provided'] |
| | | ]; |
| | | } |
| | | |
| | | $user = get_user_by('ID', $user_id); |
| | | if (!$user) { |
| | | return [ |
| | | 'success' => false, |
| | | 'result' => ['error' => 'User not found'] |
| | | ]; |
| | | } |
| | | |
| | | // Get or create Square customer |
| | | $square_customer_id = $this->getOrCreateSquareCustomer([ |
| | | 'email' => $user->user_email, |
| | | 'name' => $user->display_name |
| | | ]); |
| | | |
| | | if ($square_customer_id) { |
| | | update_user_meta($user_id, BASE . '_square_customer_id', $square_customer_id); |
| | | |
| | | return [ |
| | | 'success' => true, |
| | | 'result' => ['customer_id' => $square_customer_id] |
| | | ]; |
| | | } |
| | | |
| | | return [ |
| | | 'success' => false, |
| | | 'result' => ['error' => 'Could not sync customer'] |
| | | ]; |
| | | } |
| | | |
| | | /** |
| | | * Handle catalog webhook |
| | | */ |
| | | private function handleCatalogWebhook(array $data): bool |
| | | { |
| | | // Queue sync from Square for updated items |
| | | $this->queueOperation('import_catalog', [ |
| | | 'types' => ['ITEM'], |
| | | 'updated_at' => $data['object']['catalog_version']['updated_at'] ?? null |
| | | ], [ |
| | | 'delay' => 60 // Wait a minute to batch multiple updates |
| | | ]); |
| | | |
| | | return true; |
| | | } |
| | | |
| | | /** |
| | | * Handle customer webhook |
| | | */ |
| | | private function handleCustomerWebhook(array $data): bool |
| | | { |
| | | $square_customer_id = $data['object']['customer']['id'] ?? ''; |
| | | $email = $data['object']['customer']['email_address'] ?? ''; |
| | | |
| | | if (!$square_customer_id || !$email) { |
| | | return false; |
| | | } |
| | | |
| | | // Find WordPress user with this Square customer ID |
| | | $users = get_users([ |
| | | 'meta_key' => BASE . '_square_customer_id', |
| | | 'meta_value' => $square_customer_id, |
| | | 'number' => 1 |
| | | ]); |
| | | |
| | | if (!empty($users)) { |
| | | // Update user meta with latest Square data |
| | | $user = $users[0]; |
| | | update_user_meta($user->ID, BASE . '_square_customer_updated', current_time('mysql')); |
| | | |
| | | // Clear cached customer data |
| | | $this->cache->forget('square_customer_' . $user->ID); |
| | | } |
| | | |
| | | return true; |
| | | } |
| | | |
| | | /** |
| | | * Handle payment webhook |
| | | */ |
| | | private function handlePaymentWebhook(array $data): bool |
| | | { |
| | | $payment_id = $data['object']['payment']['id'] ?? ''; |
| | | $order_id = $data['object']['payment']['order_id'] ?? ''; |
| | | $status = $data['object']['payment']['status'] ?? ''; |
| | | |
| | | if (!$payment_id) { |
| | | return false; |
| | | } |
| | | |
| | | if ($order_id) { |
| | | $order = $this->getOrderPost($order_id); |
| | | if ($order) { |
| | | Meta::forPost($order)->set('square_payment_status', $status); |
| | | } |
| | | } |
| | | |
| | | // Trigger action for other integrations |
| | | do_action(BASE . 'square_payment_updated', $payment_id, $status, $order_id, $data); |
| | | |
| | | return true; |
| | | } |
| | | |
| | | /** |
| | | * Get the appropriate API base URL |
| | | */ |
| | | protected function getApiUrl(string $endpoint, ?string $baseKey = null): string |
| | | { |
| | | return rtrim($this->apiBase[$this->environment], '/') . '/' . ltrim($endpoint, '/'); |
| | | } |
| | | |
| | | /** |
| | | * Handle importing catalog from Square |
| | | */ |
| | | protected function handleImportFromSquare(): array |
| | | { |
| | | if (!$this->locationId) { |
| | | return [ |
| | | 'success' => false, |
| | | 'message' => 'Please select a location first' |
| | | ]; |
| | | } |
| | | |
| | | $this->queueOperation('import_catalog', [ |
| | | 'types' => ['ITEM'], |
| | | 'user_id' => $this->userID |
| | | ], [ |
| | | 'priority' => 'normal' |
| | | ]); |
| | | |
| | | return [ |
| | | 'success' => true, |
| | | 'message' => 'Import from Square queued' |
| | | ]; |
| | | } |
| | | /** |
| | | * Handle syncing to Square |
| | | */ |
| | | protected function handleSyncToSquare(): array |
| | | { |
| | | if (!$this->locationId) { |
| | | return [ |
| | | 'success' => false, |
| | | 'message' => 'Please select a location first' |
| | | ]; |
| | | } |
| | | |
| | | $post_types = array_map(function($type) { |
| | | return jvbCheckBase($type); |
| | | }, $this->syncPostTypes); |
| | | |
| | | // Get all posts to sync |
| | | $posts = get_posts([ |
| | | 'post_type' => $post_types, |
| | | 'posts_per_page' => -1, |
| | | 'post_status' => 'publish' |
| | | ]); |
| | | |
| | | $post_ids = wp_list_pluck($posts, 'ID'); |
| | | |
| | | // Queue sync operation |
| | | $this->queueOperation('sync_to_square', [ |
| | | 'items' => $post_ids, |
| | | 'user_id' => $this->userID |
| | | ], [ |
| | | 'priority' => 'normal' |
| | | ]); |
| | | |
| | | return [ |
| | | 'success' => true, |
| | | 'message' => sprintf('Queued %d items for sync to Square', count($post_ids)) |
| | | ]; |
| | | } |
| | | /** |
| | | * Refresh category mappings from Square |
| | | */ |
| | | protected function handleRefreshCategories(): array |
| | | { |
| | | // Refresh category mappings from Square |
| | | $response = $this->getRequest('catalog/list?types=CATEGORY'); |
| | | |
| | | if (!is_wp_error($response) && isset($response['objects'])) { |
| | | $count = 0; |
| | | foreach ($response['objects'] as $category) { |
| | | if ($category['type'] === 'CATEGORY') { |
| | | $name = $category['category_data']['name'] ?? ''; |
| | | if ($name) { |
| | | update_option( |
| | | BASE . 'square_category_' . sanitize_title($name), |
| | | $category['id'] |
| | | ); |
| | | $count++; |
| | | } |
| | | } |
| | | } |
| | | return [ |
| | | 'success' => true, |
| | | 'message' => sprintf('Refreshed %d categories', $count), |
| | | 'count' => $count |
| | | ]; |
| | | } else { |
| | | return [ |
| | | 'success' => false, |
| | | 'message' => 'Failed to refresh categories' |
| | | ]; |
| | | } |
| | | } |
| | | /** |
| | | * Get available Square locations |
| | | */ |
| | | protected function handleGetLocations(): array |
| | | { |
| | | // Make sure we have an access token |
| | | if (empty($this->access_token)) { |
| | | return [ |
| | | 'success' => false, |
| | | 'message' => 'Access token required to fetch locations' |
| | | ]; |
| | | } |
| | | |
| | | // Fetch available Square locations |
| | | $response = $this->getRequest('locations'); |
| | | |
| | | if (!is_wp_error($response) && isset($response['locations'])) { |
| | | $locations = array_map(function($loc) { |
| | | return [ |
| | | 'id' => $loc['id'], |
| | | 'name' => $loc['name'] ?? 'Unnamed Location', |
| | | 'address' => $loc['address']['address_line_1'] ?? '', |
| | | 'status' => $loc['status'] ?? 'ACTIVE' |
| | | ]; |
| | | }, $response['locations']); |
| | | |
| | | // Filter to only active locations |
| | | $active_locations = array_filter($locations, function($loc) { |
| | | return $loc['status'] === 'ACTIVE'; |
| | | }); |
| | | |
| | | // Format for select field |
| | | if (isset($_REQUEST['for_select'])) { |
| | | $options = []; |
| | | foreach ($active_locations as $location) { |
| | | $label = $location['name']; |
| | | if ($location['address']) { |
| | | $label .= ' - ' . $location['address']; |
| | | } |
| | | $options[$location['id']] = $label; |
| | | } |
| | | |
| | | return [ |
| | | 'success' => true, |
| | | 'options' => $options |
| | | ]; |
| | | } |
| | | |
| | | return [ |
| | | 'success' => true, |
| | | 'locations' => array_values($active_locations) |
| | | ]; |
| | | } |
| | | |
| | | return [ |
| | | 'success' => false, |
| | | 'message' => 'Failed to fetch locations. Please check your access token and environment settings.' |
| | | ]; |
| | | } |
| | | |
| | | protected function validateCredentials(array $credentials): bool |
| | | { |
| | | // For OAuth services, we need different validation based on setup stage |
| | | if ($this->isOAuthService) { |
| | | // Stage 1: Initial setup (need app credentials) |
| | | $hasAppCredentials = !empty($credentials['client_id']) |
| | | && !empty($credentials['client_secret']); |
| | | |
| | | if (!$hasAppCredentials) { |
| | | $this->logError('Missing required Square application credentials', [ |
| | | 'has_app_id' => !empty($credentials['client_id']), |
| | | 'has_app_secret' => !empty($credentials['client_secret']) |
| | | ]); |
| | | return false; |
| | | } |
| | | |
| | | // Stage 2: After OAuth (should have access token) |
| | | // Access token might not exist yet if we're just saving app credentials |
| | | // before OAuth flow, so we only validate format if it exists |
| | | if (!empty($credentials['access_token'])) { |
| | | // Validate token format (Square tokens are long alphanumeric strings) |
| | | if (!is_string($credentials['access_token']) || |
| | | strlen($credentials['access_token']) < 20) { |
| | | $this->logError('Invalid access token format'); |
| | | return false; |
| | | } |
| | | |
| | | // Validate merchant_id if present (should be set after OAuth) |
| | | if (!empty($credentials['merchant_id'])) { |
| | | if (!is_string($credentials['merchant_id']) || |
| | | strlen($credentials['merchant_id']) < 10) { |
| | | $this->logError('Invalid merchant ID format'); |
| | | return false; |
| | | } |
| | | } |
| | | } |
| | | |
| | | // Validate environment setting |
| | | if (isset($credentials['environment'])) { |
| | | $validEnvironments = ['sandbox', 'production']; |
| | | if (!in_array($credentials['environment'], $validEnvironments)) { |
| | | $this->logError('Invalid environment setting', [ |
| | | 'provided' => $credentials['environment'], |
| | | 'valid' => $validEnvironments |
| | | ]); |
| | | return false; |
| | | } |
| | | } |
| | | |
| | | return true; |
| | | } |
| | | |
| | | // Fallback for non-OAuth (shouldn't happen for Square, but good practice) |
| | | return !empty($credentials); |
| | | } |
| | | |
| | | |
| | | protected function sanitizeCredentials(array $credentials): array |
| | | { |
| | | $sanitized = []; |
| | | |
| | | // Define which fields should be sanitized and how |
| | | $text_fields = [ |
| | | 'client_id', |
| | | 'client_secret', |
| | | 'access_token', |
| | | 'refresh_token', |
| | | 'merchant_id', |
| | | 'location_id', |
| | | 'webhook_signature_key' |
| | | ]; |
| | | |
| | | foreach ($credentials as $key => $value) { |
| | | // Handle text fields - trim whitespace but preserve case |
| | | if (in_array($key, $text_fields)) { |
| | | if (is_string($value)) { |
| | | $sanitized[$key] = trim($value); |
| | | } else { |
| | | $sanitized[$key] = $value; |
| | | } |
| | | } |
| | | // Handle environment field |
| | | elseif ($key === 'environment') { |
| | | $sanitized[$key] = sanitize_key($value); |
| | | } |
| | | // Handle boolean fields |
| | | elseif ($key === 'use_sandbox' || str_starts_with($key, 'enable_')) { |
| | | $sanitized[$key] = (bool) $value; |
| | | } |
| | | // Handle numeric fields |
| | | elseif ($key === 'expires_at' || $key === 'updated_at' || $key === 'expires_in') { |
| | | $sanitized[$key] = is_numeric($value) ? (int) $value : $value; |
| | | } |
| | | // Handle arrays (like locations) |
| | | elseif (is_array($value)) { |
| | | $sanitized[$key] = array_map('sanitize_text_field', $value); |
| | | } |
| | | // Default sanitization for other string fields |
| | | else { |
| | | if (is_string($value)) { |
| | | $sanitized[$key] = sanitize_text_field($value); |
| | | } else { |
| | | $sanitized[$key] = $value; |
| | | } |
| | | } |
| | | } |
| | | |
| | | return $sanitized; |
| | | } |
| | | |
| | | public function getServiceDescription(): string |
| | | { |
| | | return "Connect with Square for payment processing, inventory management, and customer synchronization."; |
| | | } |
| | | |
| | | public function setContentTypes(): void |
| | | { |
| | | $this->has_content = true; |
| | | $this->defaultContent = 'REGULAR'; |
| | | $base = $this->setBaseFields(); |
| | | $this->contentTypes = [ |
| | | 'REGULAR' => $base, |
| | | 'FOOD_AND_BEV' => array_merge($base, $this->setFoodAndBevFields()), |
| | | 'APPOINTMENTS_SERVICE' => array_merge($this->setAppointmentServiceFields()), |
| | | 'EVENT' => array_merge($this->setEventFields()), |
| | | 'GIFT_CARD' => array_merge($this->setGiftCardFields()) |
| | | ]; |
| | | } |
| | | public function getAdditionalFields(?string $content_type = null):array { |
| | | if ($content_type === 'customer') { |
| | | return $this->getCustomerFields(); |
| | | } |
| | | if ($content_type && array_key_exists($content_type, $this->contentTypes)){ |
| | | $array = $this->contentTypes[$content_type]; |
| | | return array_combine( |
| | | array_map(fn($k) => '_square_' . $k, array_keys($array)), |
| | | $array |
| | | ); |
| | | } else if ($content_type && !array_key_exists($content_type, $this->contentTypes)) { |
| | | error_log('Could not get default fields for '.$this->service_name.' content type: '.$content_type); |
| | | return []; |
| | | } |
| | | $array = $this->setBaseFields(); |
| | | return array_combine( |
| | | array_map(fn($k) => '_square_' . $k, array_keys($array)), |
| | | $array |
| | | ); |
| | | } |
| | | |
| | | protected function getCustomerFields():array |
| | | { |
| | | return [ |
| | | 'customer_id' => [ |
| | | 'type' => 'text', |
| | | 'label' => 'Square Customer ID', |
| | | 'hidden'=> true, |
| | | 'section'=> 'your-account' |
| | | ], |
| | | 'address_line_1' => [ |
| | | 'type' => 'text', |
| | | 'label' => 'Address Line 1', |
| | | 'hint' => 'ex: 6551 111 St NW', |
| | | 'required' => true, |
| | | 'section' => 'address' |
| | | ], |
| | | 'address_line_2' => [ |
| | | 'type' => 'text', |
| | | 'label' => 'Address Line 2', |
| | | 'hint' => 'ex: Unit 2', |
| | | 'section' => 'address' |
| | | ], |
| | | 'city' => [ |
| | | 'type' => 'text', |
| | | 'label'=> 'City', |
| | | 'section' => 'address', |
| | | 'required' => true, |
| | | ], |
| | | 'state' => [ |
| | | 'type' => 'text', |
| | | 'label' => 'Province', |
| | | 'hint' => 'The two-character code, example: AB', |
| | | 'default'=> 'AB', |
| | | 'section' => 'address', |
| | | 'required' => true, |
| | | ], |
| | | 'zip_code' => [ |
| | | 'type' => 'text', |
| | | 'label' => 'Postal Code', |
| | | 'section'=> 'address' |
| | | ], |
| | | 'countryCode' => [ |
| | | 'type' => 'text', |
| | | 'label' => 'Country Code', |
| | | 'hint' => 'The tw-character country code, example: CA', |
| | | 'default' => 'CA', |
| | | 'section' => 'address', |
| | | 'required' => true, |
| | | ] |
| | | ]; |
| | | } |
| | | protected function setBaseFields():array |
| | | { |
| | | return [ |
| | | 'price' => [ |
| | | 'type' => 'number', |
| | | 'label' => 'Price', |
| | | 'step' => 0.01, |
| | | 'max' => 99999, |
| | | 'description' => 'Price for this variation' |
| | | ], |
| | | 'sku' => [ |
| | | 'type' => 'text', |
| | | 'label' => 'SKU', |
| | | 'description' => 'Stock keeping unit' |
| | | ], |
| | | 'cart_quantity' => [ |
| | | 'type' => 'number', |
| | | 'label' => 'Quantity', |
| | | 'hidden' => true, |
| | | ], |
| | | 'available_for_pickup' => [ |
| | | 'type' => 'true_false', |
| | | 'label' => 'Available for Pick Up', |
| | | 'section'=> 'square-advanced' |
| | | ], |
| | | 'available_online' => [ |
| | | 'type' => 'true_false', |
| | | 'label' => 'Available online', |
| | | 'section'=> 'square-advanced' |
| | | ], |
| | | 'product_variations' => [ |
| | | 'type' => 'repeater', |
| | | 'label' => 'Product Variations', |
| | | 'description' => 'Different versions of this product (sizes, colors, etc.)', |
| | | 'add_label' => 'Add Variation', |
| | | 'section' => 'variations', |
| | | 'fields' => $this->setVariationFields() |
| | | ], |
| | | ]; |
| | | } |
| | | |
| | | protected function setVariationFields(?string $type = null):array |
| | | { |
| | | $fields = [ |
| | | 'name' => [ |
| | | 'type' => 'text', |
| | | 'label' => 'Variation Name', |
| | | 'description' => 'e.g., "Small", "Large", "Red", etc.' |
| | | ], |
| | | 'sku' => [ |
| | | 'type' => 'text', |
| | | 'label' => 'SKU', |
| | | 'description' => 'Stock keeping unit' |
| | | ], |
| | | 'price' => [ |
| | | 'type' => 'number', |
| | | 'label' => 'Price', |
| | | 'step' => 0.01, |
| | | 'max' => 99999, |
| | | 'description' => 'Price for this variation' |
| | | ], |
| | | 'track_inventory' => [ |
| | | 'type' => 'true_false', |
| | | 'label' => 'Track Inventory', |
| | | ], |
| | | 'item_id' => [ |
| | | 'type' => 'text', |
| | | 'label' => 'Square Variation ID', |
| | | 'description' => 'Square catalog ID for this variation', |
| | | 'hidden' => true |
| | | ], |
| | | 'last_sync' => [ |
| | | 'type' => 'datetime', |
| | | 'label' => 'Last Sync', |
| | | 'hidden' => true |
| | | ] |
| | | ]; |
| | | |
| | | switch ($type) { |
| | | case 'FOOD_AND_BEV': |
| | | $fields['ingredients'] = [ |
| | | 'type' => 'textarea', |
| | | 'label' => 'Ingredients List', |
| | | 'description' => 'Separate ingredients with commas', |
| | | ]; |
| | | $fields['preparation_time_duration'] = [ |
| | | 'type' => 'number', |
| | | 'label' => 'Preparation time (in minutes)', |
| | | ]; |
| | | break; |
| | | case 'GIFT_CARD': |
| | | $fields['gift_card_type'] = [ |
| | | 'type' => 'select', |
| | | 'label' => 'Gift Card Type', |
| | | 'options' => [ |
| | | 'PHYSICAL' => 'Physical', |
| | | 'DIGITAL' => 'Digital', |
| | | ], |
| | | 'default' => 'DIGITAL', |
| | | ]; |
| | | break; |
| | | case 'APPOINTMENTS_SERVICE': |
| | | $fields['service_duration'] = [ |
| | | 'type' => 'number', |
| | | 'label' => 'Duration of Service in Minutes' |
| | | ]; |
| | | $fields['available_for_booking'] = [ |
| | | 'type' => 'true_false', |
| | | 'label' => 'Available for Booking' |
| | | ]; |
| | | break; |
| | | } |
| | | return array_combine( |
| | | array_map(fn($k) => '_square_' . $k, array_keys($fields)), |
| | | $fields |
| | | ); |
| | | } |
| | | protected function setFoodAndBevFields():array |
| | | { |
| | | return [ |
| | | 'ingredients' => [ |
| | | 'type' => 'textarea', |
| | | 'label' => 'Ingredients', |
| | | 'hint' => 'A comma separated list of ingredients' |
| | | ], |
| | | 'preparation_time_duration' => [ |
| | | 'label' => 'Preparation Time', |
| | | 'type' => 'number', |
| | | 'hint' => 'Preparation time in minutes' |
| | | ], |
| | | 'product_variations' => [ |
| | | 'type' => 'repeater', |
| | | 'label' => 'Product Variations', |
| | | 'description' => 'Different versions of this product (sizes, colors, etc.)', |
| | | 'add_label' => 'Add Variation', |
| | | 'section' => 'variations', |
| | | 'fields' => $this->setVariationFields('FOOD_AND_BEV') |
| | | ], |
| | | ]; |
| | | /* TODO: Set definitions for: |
| | | 'dietary_preferences' => [ |
| | | |
| | | ], |
| | | 'calories_text' => [ |
| | | |
| | | ], |
| | | */ |
| | | } |
| | | protected function setAppointmentServiceFields():array |
| | | { |
| | | return [ |
| | | |
| | | 'product_variations' => [ |
| | | 'type' => 'repeater', |
| | | 'label' => 'Product Variations', |
| | | 'description' => 'Different versions of this product (sizes, colors, etc.)', |
| | | 'add_label' => 'Add Variation', |
| | | 'section' => 'variations', |
| | | 'fields' => $this->setVariationFields('APPOINTMENTS_SERVICE') |
| | | ], |
| | | ]; |
| | | } |
| | | protected function setEventFields():array |
| | | { |
| | | return [ |
| | | 'venue_details' => [ |
| | | 'type' => 'textarea', |
| | | 'label' => 'Venue Details', |
| | | ], |
| | | 'capacity' => [ |
| | | 'type' => 'number', |
| | | 'label' => 'Capacity' |
| | | ], |
| | | 'product_variations' => [ |
| | | 'type' => 'repeater', |
| | | 'label' => 'Product Variations', |
| | | 'description' => 'Different versions of this product (sizes, colors, etc.)', |
| | | 'add_label' => 'Add Variation', |
| | | 'section' => 'variations', |
| | | 'fields' => $this->setVariationFields('EVENT') |
| | | ], |
| | | ]; |
| | | } |
| | | protected function setGiftCardFields():array |
| | | { |
| | | return [ |
| | | // 'allowed_locations' => [ |
| | | // |
| | | // ], |
| | | 'product_variations' => [ |
| | | 'type' => 'repeater', |
| | | 'label' => 'Product Variations', |
| | | 'description' => 'Different versions of this product (sizes, colors, etc.)', |
| | | 'add_label' => 'Add Variation', |
| | | 'section' => 'variations', |
| | | 'fields' => $this->setVariationFields('GIFT_CARD') |
| | | ], |
| | | ]; |
| | | } |
| | | |
| | | /********************************************************* |
| | | IMAGE PROCESSING |
| | | *********************************************************/ |
| | | /** |
| | | * Upload image file to Square |
| | | * |
| | | * @param int $imgID WordPress attachment ID |
| | | * @return array|WP_Error Upload result or error |
| | | */ |
| | | protected function uploadImageToSquare(int $imgID): array|WP_Error |
| | | { |
| | | $supported_image_id = $this->getSupportedImage($imgID); |
| | | |
| | | // Check if already uploaded |
| | | $existing_square_image_id = $this->getSquareImageId($supported_image_id); |
| | | if ($existing_square_image_id) { |
| | | return [ |
| | | 'success' => true, |
| | | 'image_id' => $existing_square_image_id |
| | | ]; |
| | | } |
| | | |
| | | $file_path = get_attached_file($supported_image_id); |
| | | if (!file_exists($file_path)) { |
| | | return new WP_Error('file_not_found', 'Image file not found'); |
| | | } |
| | | |
| | | // Verify file type |
| | | $mime_type = get_post_mime_type($supported_image_id); |
| | | if (!in_array($mime_type, ['image/jpeg', 'image/png', 'image/gif'])) { |
| | | return new WP_Error('invalid_type', 'Square only supports JPEG, PNG, and GIF images'); |
| | | } |
| | | |
| | | $image_title = get_the_title($supported_image_id); |
| | | $alt_text = get_post_meta($supported_image_id, '_wp_attachment_image_alt', true); |
| | | |
| | | // Build multipart request - SINGLE STEP |
| | | $boundary = wp_generate_password(24); |
| | | $headers = $this->getRequestHeaders(); |
| | | $headers['Content-Type'] = 'multipart/form-data; boundary=' . $boundary; |
| | | |
| | | // Request JSON part |
| | | $request_json = [ |
| | | 'idempotency_key' => wp_generate_uuid4(), |
| | | 'image' => [ |
| | | 'type' => 'IMAGE', |
| | | 'id' => '#IMAGE_' . $supported_image_id . '_' . time(), |
| | | 'image_data' => [ |
| | | 'name' => $image_title ?: 'Image', |
| | | 'caption' => $alt_text ?: '' |
| | | ] |
| | | ], |
| | | 'object_id' => $supported_image_id |
| | | ]; |
| | | |
| | | $body = $this->buildMultipartBody($file_path, $request_json, $boundary); |
| | | |
| | | $response = wp_remote_post( |
| | | $this->getApiUrl('v2/catalog/images'), |
| | | [ |
| | | 'headers' => $headers, |
| | | 'body' => $body, |
| | | 'timeout' => 60 |
| | | ] |
| | | ); |
| | | |
| | | if (is_wp_error($response)) { |
| | | return $response; |
| | | } |
| | | |
| | | $data = json_decode(wp_remote_retrieve_body($response), true); |
| | | |
| | | if (!empty($data['errors'])) { |
| | | return new WP_Error('upload_error', $data['errors'][0]['detail'] ?? 'Unknown error'); |
| | | } |
| | | |
| | | if (!empty($data['image']['id'])) { |
| | | $this->setSquareImageId($supported_image_id, $data['image']['id']); |
| | | return [ |
| | | 'success' => true, |
| | | 'image_id' => $data['image']['id'] |
| | | ]; |
| | | } |
| | | |
| | | return new WP_Error('upload_failed', 'Failed to upload image'); |
| | | } |
| | | |
| | | protected function buildMultipartBody(string $file_path, array $request_json, string $boundary): string |
| | | { |
| | | $eol = "\r\n"; |
| | | $body = ''; |
| | | |
| | | // Add request JSON part |
| | | $body .= '--' . $boundary . $eol; |
| | | $body .= 'Content-Disposition: form-data; name="request"' . $eol; |
| | | $body .= 'Content-Type: application/json' . $eol . $eol; |
| | | $body .= json_encode($request_json) . $eol; |
| | | |
| | | // Add image file part |
| | | $filename = basename($file_path); |
| | | $file_contents = file_get_contents($file_path); |
| | | $mime_type = mime_content_type($file_path); |
| | | |
| | | $body .= '--' . $boundary . $eol; |
| | | $body .= 'Content-Disposition: form-data; name="file"; filename="' . $filename . '"' . $eol; |
| | | $body .= 'Content-Type: ' . $mime_type . $eol . $eol; |
| | | $body .= $file_contents . $eol; |
| | | $body .= '--' . $boundary . '--' . $eol; |
| | | |
| | | return $body; |
| | | } |
| | | |
| | | /** |
| | | * Attach image to catalog item |
| | | * |
| | | * @param int $postID WordPress post ID |
| | | * @param string $square_image_id Square image ID |
| | | * @return array|WP_Error Result or error |
| | | */ |
| | | public function attachImageToCatalogItem(int $postID, string $square_image_id): array|WP_Error |
| | | { |
| | | $square_catalog_id = get_post_meta($postID, BASE . '_square_catalog_id', true); |
| | | |
| | | if (!$square_catalog_id) { |
| | | return new WP_Error('no_catalog_id', 'Post has not been synced to Square'); |
| | | } |
| | | |
| | | // Get the current catalog item |
| | | $response = $this->getRequest('catalog/object/' . $square_catalog_id); |
| | | |
| | | if (is_wp_error($response)) { |
| | | return $response; |
| | | } |
| | | |
| | | if (empty($response['object'])) { |
| | | return new WP_Error('item_not_found', 'Catalog item not found in Square'); |
| | | } |
| | | |
| | | $catalog_object = $response['object']; |
| | | |
| | | // Add image to item data |
| | | $catalog_object['item_data']['image_ids'] = [$square_image_id]; |
| | | |
| | | // Update the catalog item |
| | | $update_response = $this->postRequest('catalog/batch-upsert', [ |
| | | 'idempotency_key' => wp_generate_uuid4(), |
| | | 'batches' => [[ |
| | | 'objects' => [$catalog_object] |
| | | ]] |
| | | ]); |
| | | |
| | | if (is_wp_error($update_response)) { |
| | | return $update_response; |
| | | } |
| | | |
| | | return [ |
| | | 'success' => true, |
| | | 'message' => 'Image attached to catalog item' |
| | | ]; |
| | | } |
| | | |
| | | /** |
| | | * Get stored Square image ID for an attachment |
| | | * |
| | | * @param int $attachment_id WordPress attachment ID |
| | | * @return string|false Square image ID or false if not found |
| | | */ |
| | | public function getSquareImageId(int $attachment_id): string|false |
| | | { |
| | | $image_id = get_post_meta($attachment_id, BASE . 'square_image_id', true); |
| | | |
| | | if ($image_id) { |
| | | // Verify it still exists in Square |
| | | return $this->verifySquareImage($image_id) ? $image_id : false; |
| | | } |
| | | |
| | | return false; |
| | | } |
| | | |
| | | /** |
| | | * Store Square image ID for an attachment |
| | | * |
| | | * @param int $attachment_id WordPress attachment ID |
| | | * @param string $square_image_id Square image ID |
| | | * @return bool Success |
| | | */ |
| | | public function setSquareImageId(int $attachment_id, string $square_image_id): bool |
| | | { |
| | | return update_post_meta($attachment_id, BASE . 'square_image_id', $square_image_id); |
| | | } |
| | | |
| | | /** |
| | | * Verify image exists in Square catalog |
| | | * |
| | | * @param string $square_image_id Square image ID |
| | | * @return bool Whether image exists |
| | | */ |
| | | protected function verifySquareImage(string $square_image_id): bool |
| | | { |
| | | $response = $this->getRequest('catalog/object/' . $square_image_id); |
| | | |
| | | if (is_wp_error($response)) { |
| | | return false; |
| | | } |
| | | |
| | | return !empty($response['object']) && $response['object']['type'] === 'IMAGE'; |
| | | } |
| | | |
| | | /** |
| | | * Batch upload images for multiple posts |
| | | * |
| | | * @param array $post_ids Array of WordPress post IDs |
| | | * @return array Results for each post |
| | | */ |
| | | private function batchUploadImages(array $post_ids): array |
| | | { |
| | | $image_mappings = []; |
| | | $image_objects = []; |
| | | |
| | | // First pass: collect images that need uploading |
| | | foreach ($post_ids as $post_id) { |
| | | $thumbnail_id = get_post_thumbnail_id($post_id); |
| | | if (!$thumbnail_id) { |
| | | $image_mappings[$post_id] = null; |
| | | continue; |
| | | } |
| | | |
| | | // Check if already uploaded and still exists in Square |
| | | $existing_square_id = get_post_meta($thumbnail_id, BASE . '_square_image_id', true); |
| | | if ($existing_square_id && $this->verifySquareImage($existing_square_id)) { |
| | | $image_mappings[$post_id] = $existing_square_id; |
| | | continue; |
| | | } |
| | | |
| | | // Get supported image format (handles WebP conversion) |
| | | $supported_image_id = $this->getSupportedImage($thumbnail_id); |
| | | if (!$supported_image_id) { |
| | | $image_mappings[$post_id] = null; |
| | | continue; |
| | | } |
| | | |
| | | // Upload the image to Square using the proper endpoint |
| | | $square_image_id = $this->uploadSingleImageToSquare($supported_image_id, $post_id); |
| | | |
| | | if ($square_image_id && !is_wp_error($square_image_id)) { |
| | | $image_mappings[$post_id] = $square_image_id; |
| | | // Store the Square image ID for future reference |
| | | update_post_meta($thumbnail_id, BASE . '_square_image_id', $square_image_id); |
| | | } else { |
| | | $this->logError('Failed to upload image for post', [ |
| | | 'post_id' => $post_id, |
| | | 'thumbnail_id' => $thumbnail_id, |
| | | 'error' => is_wp_error($square_image_id) ? $square_image_id->get_error_message() : 'Unknown error' |
| | | ]); |
| | | $image_mappings[$post_id] = null; |
| | | } |
| | | } |
| | | |
| | | return $image_mappings; |
| | | } |
| | | |
| | | /** |
| | | * Upload a single image to Square using the CreateCatalogImage endpoint |
| | | * |
| | | * @param int $attachment_id WordPress attachment ID |
| | | * @param int $post_id Associated post ID |
| | | * @return string|WP_Error Square image ID or error |
| | | */ |
| | | private function uploadSingleImageToSquare(int $attachment_id, int $post_id): string|WP_Error |
| | | { |
| | | $file_path = get_attached_file($attachment_id); |
| | | |
| | | if (!file_exists($file_path)) { |
| | | return new WP_Error('file_not_found', 'Image file not found'); |
| | | } |
| | | |
| | | // Verify file type |
| | | $mime_type = get_post_mime_type($attachment_id); |
| | | $allowed_types = ['image/jpeg', 'image/png', 'image/gif']; |
| | | |
| | | if (!in_array($mime_type, $allowed_types)) { |
| | | return new WP_Error('invalid_type', 'Square only supports JPEG, PNG, and GIF images'); |
| | | } |
| | | |
| | | // Prepare multipart form data |
| | | $boundary = wp_generate_password(24); |
| | | $headers = $this->getRequestHeaders(); |
| | | $headers['Content-Type'] = 'multipart/form-data; boundary=' . $boundary; |
| | | |
| | | // Build the request body |
| | | $eol = "\r\n"; |
| | | $body = ''; |
| | | |
| | | // Add request JSON |
| | | $request_data = [ |
| | | 'idempotency_key' => wp_generate_uuid4(), |
| | | 'image' => [ |
| | | 'type' => 'IMAGE', |
| | | 'id' => '#TEMP_IMAGE_' . $attachment_id . '_' . time(), |
| | | 'image_data' => [ |
| | | 'name' => get_the_title($attachment_id) ?: basename($file_path), |
| | | 'caption' => get_post_meta($attachment_id, '_wp_attachment_image_alt', true) ?: |
| | | sprintf('Image for %s', get_the_title($post_id)) |
| | | ] |
| | | ] |
| | | ]; |
| | | |
| | | $body .= '--' . $boundary . $eol; |
| | | $body .= 'Content-Disposition: form-data; name="request"' . $eol; |
| | | $body .= 'Content-Type: application/json' . $eol . $eol; |
| | | $body .= json_encode($request_data) . $eol; |
| | | |
| | | // Add image file |
| | | $filename = basename($file_path); |
| | | $file_contents = file_get_contents($file_path); |
| | | |
| | | $body .= '--' . $boundary . $eol; |
| | | $body .= 'Content-Disposition: form-data; name="image"; filename="' . $filename . '"' . $eol; |
| | | $body .= 'Content-Type: ' . $mime_type . $eol . $eol; |
| | | $body .= $file_contents . $eol; |
| | | |
| | | // End boundary |
| | | $body .= '--' . $boundary . '--' . $eol; |
| | | |
| | | // Make the request to the CreateCatalogImage endpoint |
| | | $response = wp_remote_post( |
| | | $this->getApiUrl('catalog/images'), |
| | | [ |
| | | 'headers' => $headers, |
| | | 'body' => $body, |
| | | 'timeout' => 60 |
| | | ] |
| | | ); |
| | | |
| | | if (is_wp_error($response)) { |
| | | return $response; |
| | | } |
| | | |
| | | $response_body = wp_remote_retrieve_body($response); |
| | | $data = json_decode($response_body, true); |
| | | |
| | | if (!empty($data['errors'])) { |
| | | $error_message = $data['errors'][0]['detail'] ?? 'Unknown error'; |
| | | return new WP_Error('upload_error', $error_message); |
| | | } |
| | | |
| | | if (!empty($data['image']['id'])) { |
| | | $this->logDebug('Successfully uploaded image to Square', [ |
| | | 'post_id' => $post_id, |
| | | 'attachment_id' => $attachment_id, |
| | | 'square_image_id' => $data['image']['id'] |
| | | ]); |
| | | return $data['image']['id']; |
| | | } |
| | | |
| | | return new WP_Error('upload_failed', 'Failed to get image ID from Square response'); |
| | | } |
| | | |
| | | protected function handleApiError(int $code, string $body, string $endpoint): void |
| | | { |
| | | parent::handleApiError($code, $body, $endpoint); |
| | | |
| | | $decoded = json_decode($body, true); |
| | | $errorCode = $decoded['errors'][0]['code'] ?? ''; |
| | | |
| | | // Handle Square-specific OAuth errors |
| | | if ($errorCode === 'ACCESS_TOKEN_EXPIRED') { |
| | | $this->logDebug('Access token expired, attempting refresh'); |
| | | if ($this->refreshOAuthToken()) { |
| | | // Token refreshed, could retry the request |
| | | $this->logDebug('Token refreshed successfully'); |
| | | } |
| | | } elseif ($errorCode === 'UNAUTHORIZED' || $errorCode === 'ACCESS_TOKEN_REVOKED') { |
| | | $this->logError('Square authorization revoked or invalid', [ |
| | | 'error_code' => $errorCode, |
| | | 'user_id' => $this->userID |
| | | ]); |
| | | // Clear invalid credentials |
| | | $this->deleteCredentials(); |
| | | } |
| | | } |
| | | |
| | | public function createSquareOrder(array $items, ?string $customer_id, array $data): array|WP_Error |
| | | { |
| | | // Build line items for Square |
| | | $line_items = []; |
| | | foreach ($items as $item) { |
| | | $line_item = [ |
| | | 'quantity' => (string)$item['quantity'], // MUST be string! |
| | | ]; |
| | | |
| | | // Use catalog_object_id if available (recommended) |
| | | if (!empty($item['catalog_object_id'])) { |
| | | $line_item['catalog_object_id'] = $item['catalog_object_id']; |
| | | $line_item['catalog_version'] = $item['catalog_version'] ?? null; |
| | | } else { |
| | | // Ad-hoc line item (not recommended - no tax/inventory automation) |
| | | $line_item['name'] = $item['name']; |
| | | $line_item['base_price_money'] = [ |
| | | 'amount' => (int)$item['_square_price'], |
| | | 'currency' => $this->getCurrency() |
| | | ]; |
| | | } |
| | | |
| | | if (!empty($item['note'])) { |
| | | $line_item['note'] = $item['note']; |
| | | } |
| | | |
| | | $line_items[] = $line_item; |
| | | } |
| | | |
| | | $order_data = [ |
| | | 'idempotency_key' => wp_generate_uuid4(), // Different from payment idempotency key |
| | | 'order' => [ |
| | | 'location_id' => $this->locationId, |
| | | 'line_items' => $line_items, |
| | | 'state' => 'OPEN' |
| | | ] |
| | | ]; |
| | | |
| | | // Add customer if available |
| | | if ($customer_id) { |
| | | $order_data['order']['customer_id'] = $customer_id; |
| | | } |
| | | |
| | | // Add metadata |
| | | if (!empty($data['note'])) { |
| | | $order_data['order']['metadata'] = [ |
| | | 'special_instructions' => $data['note'] |
| | | ]; |
| | | } |
| | | |
| | | if (!empty($data['pickup_time'])) { |
| | | $order_data['order']['metadata']['pickup_time'] = $data['pickup_time']; |
| | | } |
| | | |
| | | return $this->postRequest('orders', $order_data); |
| | | } |
| | | |
| | | public function createSquarePayment( |
| | | string $source_id, |
| | | string $idempotency_key, |
| | | int $amount_cents, |
| | | string $order_id, |
| | | ?string $customer_id |
| | | ): array|WP_Error |
| | | { |
| | | $payment_data = [ |
| | | 'idempotency_key' => $idempotency_key, |
| | | 'source_id' => $source_id, |
| | | 'amount_money' => [ |
| | | 'amount' => $amount_cents, // Already in cents! |
| | | 'currency' => $this->getCurrency() |
| | | ], |
| | | 'order_id' => $order_id, |
| | | 'location_id' => $this->locationId, |
| | | 'autocomplete' => true, // Capture immediately |
| | | ]; |
| | | |
| | | // Add customer if available |
| | | if ($customer_id) { |
| | | $payment_data['customer_id'] = $customer_id; |
| | | } |
| | | |
| | | // Add reference ID for tracking |
| | | $payment_data['reference_id'] = 'WP_' . time(); |
| | | |
| | | return $this->postRequest('payments', $payment_data); |
| | | } |
| | | |
| | | public function saveOrderToWordPress(array $order_data): int |
| | | { |
| | | // Extract customer info |
| | | $customer_email = $order_data['customer']['email'] ?? ''; |
| | | $customer_name = $order_data['customer']['name'] ?? ''; |
| | | |
| | | // Find or create WP user for logged-in association |
| | | $user_id = 0; |
| | | if ($customer_email) { |
| | | $user = get_user_by('email', $customer_email); |
| | | if ($user) { |
| | | $user_id = $user->ID; |
| | | // Store Square customer ID on user |
| | | if (!empty($order_data['square_customer_id'])) { |
| | | update_user_meta($user_id, BASE . '_square_customer_id', $order_data['square_customer_id']); |
| | | } |
| | | } |
| | | } |
| | | |
| | | // Create order post |
| | | $order_post_id = wp_insert_post([ |
| | | 'post_type' => BASE.$this->orderPostType, |
| | | 'post_title' => 'Order #' . $order_data['square_order_id'], |
| | | 'post_status' => 'publish', |
| | | 'post_author' => $user_id // Associate with user if logged in |
| | | ]); |
| | | |
| | | if (!$order_post_id || is_wp_error($order_post_id)) { |
| | | $this->logError('Failed to create order post', ['order_data' => $order_data]); |
| | | return 0; |
| | | } |
| | | |
| | | // Save all order meta |
| | | $meta = Meta::forPost($order_post_id); |
| | | $fields = $this->getOrderFields(); |
| | | unset($fields['post_title']); |
| | | |
| | | $meta->setAll([ |
| | | 'square_order_id' => $order_data['square_order_id'], |
| | | 'square_payment_id' => $order_data['square_payment_id'] ?? '', |
| | | 'square_customer_id' => $order_data['square_customer_id'] ?? '', |
| | | 'amount' => $order_data['amount'], |
| | | 'status' => $order_data['status'], |
| | | 'fulfillment_status' => $order_data['fulfillment_status'] ?? 'PROPOSED', |
| | | 'pickup_time' => $order_data['pickup_time'] ?? '', |
| | | 'customer_email' => $customer_email, |
| | | 'customer_name' => $customer_name, |
| | | 'customer_phone' => $order_data['customer']['phone'] ?? '', |
| | | 'special_instructions' => $order_data['note'] ?? '', |
| | | 'items' => $order_data['items'], |
| | | 'receipt_url' => $order_data['receipt_url'] ?? '', |
| | | 'created_at' => current_time('mysql'), |
| | | 'updated_at' => current_time('mysql') |
| | | ]); |
| | | |
| | | return $order_post_id; |
| | | } |
| | | |
| | | /** |
| | | * Get currency code |
| | | */ |
| | | private function getCurrency(): string |
| | | { |
| | | return get_option(BASE . 'currency', 'CAD'); |
| | | } |
| | | |
| | | /** |
| | | * Get customer with saved cards (2025-compliant) |
| | | */ |
| | | public function getUserCards(string $customer_id): array |
| | | { |
| | | $response = $this->getRequest('cards?customer_id=' . $customer_id); |
| | | return $response['cards'] ?? []; |
| | | } |
| | | |
| | | |
| | | public function getUserOrders(string $customer_email): array |
| | | { |
| | | // First get Square customer ID |
| | | $customer_response = $this->postRequest('customers/search', [ |
| | | 'filter' => [ |
| | | 'email_address' => ['exact' => $customer_email] |
| | | ] |
| | | ]); |
| | | |
| | | $customer_id = $customer_response['customers'][0]['id'] ?? null; |
| | | if (!$customer_id) { |
| | | return []; |
| | | } |
| | | |
| | | // Get their orders |
| | | $orders_response = $this->postRequest('orders/search', [ |
| | | 'filter' => [ |
| | | 'customer_filter' => [ |
| | | 'customer_ids' => [$customer_id] |
| | | ] |
| | | ], |
| | | 'sort' => [ |
| | | 'sort_field' => 'CREATED_AT', |
| | | 'sort_order' => 'DESC' |
| | | ], |
| | | 'limit' => 50 |
| | | ]); |
| | | |
| | | return $orders_response['orders'] ?? []; |
| | | } |
| | | |
| | | public function checkOrderStatus(string $order_id): ?string |
| | | { |
| | | // Fetch from Square |
| | | $response = $this->getRequest('orders/' . $order_id); |
| | | if (!is_wp_error($response)) { |
| | | return $response['order']['state'] ?? null; |
| | | } |
| | | |
| | | return null; |
| | | } |
| | | |
| | | /** |
| | | * Single-item sync. Called by IntegrationExecutor::processSyncTo(). |
| | | * Delegates to syncBatchToService since Square uses batch-upsert. |
| | | */ |
| | | public function syncPostToService(int $postID): array|WP_Error |
| | | { |
| | | return $this->syncBatchToService(['items' => [$postID]]); |
| | | } |
| | | |
| | | /** |
| | | * Batch sync — preferred by IntegrationExecutor when available. |
| | | * Wraps existing processSyncToSquare which already handles batches. |
| | | */ |
| | | public function syncBatchToService(array $data): array|WP_Error |
| | | { |
| | | $result = $this->processSyncToSquare($data); |
| | | |
| | | if (empty($result['success'])) { |
| | | $errors = implode(', ', $result['result']['errors'] ?? ['Sync failed']); |
| | | return new WP_Error('square_sync_failed', $errors); |
| | | } |
| | | |
| | | return $result; |
| | | } |
| | | |
| | | /** |
| | | * Delete catalog object from Square. |
| | | * Called by IntegrationExecutor::processDeleteFrom(). |
| | | */ |
| | | public function deleteFromService(string $externalId): array|WP_Error |
| | | { |
| | | $result = $this->processDeleteFromSquare(['square_ids' => [$externalId]]); |
| | | |
| | | if (empty($result['success'])) { |
| | | return new WP_Error('square_delete_failed', $result['result']['error'] ?? 'Delete failed'); |
| | | } |
| | | |
| | | return $result; |
| | | } |
| | | |
| | | /** |
| | | * Import from Square catalog → WordPress. |
| | | * Called by IntegrationExecutor::processImport(). |
| | | */ |
| | | public function importFromService(array $data): array|WP_Error |
| | | { |
| | | $result = $this->processSyncFromSquare($data); |
| | | |
| | | if (empty($result['success'])) { |
| | | return new WP_Error('square_import_failed', $result['result']['error'] ?? 'Import failed'); |
| | | } |
| | | |
| | | return $result; |
| | | } |
| | | |
| | | /** |
| | | * Sync customer to Square. |
| | | * Called by IntegrationExecutor::processSyncCustomer(). |
| | | */ |
| | | public function syncCustomer(array $data): array|WP_Error |
| | | { |
| | | $result = $this->processSyncCustomer($data); |
| | | |
| | | if (empty($result['success'])) { |
| | | return new WP_Error('square_customer_sync_failed', $result['result']['error'] ?? 'Customer sync failed'); |
| | | return $this->response(false, 'Failed to fetch locations: '.$e->getMessage()); |
| | | } |
| | | |
| | | return $result; |
| | | } |
| | | |
| | | public function addDashboardPages():void |
| | | { |
| | | $page = JVB()->dashboard()->addPage('Your Orders', 'orders', 'receipt'); |
| | | $page->setScripts(['jvb-crud']); |
| | | } |
| | | public function renderDashPage():void |
| | | { |
| | | $this->determineUser(); |
| | | |
| | | $crud = new CRUDSkeleton(); |
| | | $crud->icon('receipt'); |
| | | $crud->title('Your Orders','Here you can see your past orders, and reorder from there if you\'d like'); |
| | | $crud->content($this->orderPostType, 'Order', 'Orders'); |
| | | $crud->addSearch(); |
| | | $crud->addCapabilities(['view']); |
| | | $crud->setEmptyState(sprintf( |
| | | '<div class="empty-state"> |
| | | <h3>%sNothing here%s</h3> |
| | | <p>It doesn\'t look like you have any orders yet.</p> |
| | | <p>Head on over to <a href="%s">our menu</a> and make your first order!</p> |
| | | </div>', |
| | | jvbDashIcon($crud->getIcon()), |
| | | jvbDashIcon($crud->getIcon()), |
| | | get_post_type_archive_link(BASE.'menu_item') |
| | | )); |
| | | |
| | | |
| | | $crud->render(); |
| | | } |
| | | |
| | | |
| | | public function getOrderPost(string $order_id):int|false |
| | | { |
| | | $posts = new WP_Query([ |
| | | 'post_type' => BASE.$this->orderPostType, |
| | | 'posts_per_page' => 1, |
| | | 'meta_key' => BASE.'square_order_id', |
| | | 'meta_value' => $order_id, |
| | | 'fields' => 'ids', |
| | | ]); |
| | | wp_reset_postdata(); |
| | | return $posts->have_posts() ? $posts[0] : false; |
| | | } |
| | | public function getOrderHistory(int $user_id):array |
| | | { |
| | | $posts = new WP_Query([ |
| | | 'post_type' => BASE.$this->orderPostType, |
| | | 'posts_per_page' => 25, |
| | | 'author' => $user_id, |
| | | 'orderby' => 'date', |
| | | 'order' => 'desc', |
| | | 'fields' => 'ids', |
| | | ]); |
| | | wp_reset_postdata(); |
| | | return array_map(function ($post) { |
| | | $fields = Meta::forPost($post); |
| | | $fields['wp_order_id'] = $post; |
| | | return $fields; |
| | | }, $posts->posts); |
| | | |
| | | } |
| | | |
| | | public function formatPrice(string|float|int $priceValue):int |
| | | { |
| | | //Convert dollars to cents |
| | | return intval(floatval($priceValue) * 100); |
| | | } |
| | | } |
| New file |
| | |
| | | <?php |
| | | |
| | | namespace JVBase\integrations\services; |
| | | use Exception; |
| | | use JVBase\integrations\Integrations; |
| | | use JVBase\managers\queue\executors\IntegrationExecutor; |
| | | use JVBase\managers\queue\TypeConfig; |
| | | use JVBase\meta\Form; |
| | | use JVBase\meta\Meta; |
| | | use JVBase\registrar\Registrar; |
| | | use JVBase\ui\Checkout; |
| | | use JVBase\ui\CRUDSkeleton; |
| | | use WP_Error; |
| | | use WP_Post; |
| | | use WP_Query; |
| | | use WP_User; |
| | | |
| | | if (!defined('ABSPATH')) { |
| | | exit; |
| | | } |
| | | |
| | | /** |
| | | * Square Integration Class |
| | | * |
| | | * Handles OAuth 2.0 authentication and API interactions with Square |
| | | * Uses the latest Square API version with OAuth code flow |
| | | * |
| | | * @since 1.0.0 |
| | | */ |
| | | class SquareOld extends Integrations |
| | | { |
| | | protected static string $syncCustomer = 'square_sync_customer'; |
| | | protected array $allowedContent = [ |
| | | 'REGULAR', |
| | | 'FOOD_AND_BEV', |
| | | 'APPOINTMENTS_SERVICE', |
| | | 'DIGITAL', |
| | | 'EVENT', |
| | | 'DONATION' |
| | | ]; |
| | | /** |
| | | * Square API Configuration |
| | | */ |
| | | protected string $service_name = 'square'; |
| | | protected array|string $apiBase = [ |
| | | 'production' => 'https://connect.squareup.com', |
| | | 'sandbox' => 'https://connect.squareupsandbox.com' |
| | | ]; |
| | | |
| | | protected string $apiVersion = '2025-09-24'; |
| | | |
| | | protected string $environment = 'sandbox'; |
| | | |
| | | private const PASSWORD_RESET_INTERVAL = 3; // Reset password every 3 logins, for customers |
| | | |
| | | /** |
| | | * OAuth Configuration |
| | | */ |
| | | protected bool $isOAuthService = true; |
| | | |
| | | protected string $orderPostType = '_square_order'; |
| | | protected array $newOrder = []; |
| | | protected array $oauth = [ |
| | | 'authorize' => '', |
| | | 'token' => '', |
| | | 'revoke' => '', |
| | | 'scopes' => [ |
| | | 'MERCHANT_PROFILE_READ', |
| | | 'MERCHANT_PROFILE_WRITE', |
| | | 'PAYMENTS_WRITE', |
| | | 'PAYMENTS_READ', |
| | | 'CUSTOMERS_WRITE', |
| | | 'CUSTOMERS_READ', |
| | | 'INVENTORY_READ', |
| | | 'INVENTORY_WRITE', |
| | | 'ITEMS_READ', |
| | | 'ITEMS_WRITE', |
| | | 'ORDERS_READ', |
| | | 'ORDERS_WRITE' |
| | | ] |
| | | ]; |
| | | |
| | | /** |
| | | * Square-specific properties |
| | | */ |
| | | protected string $merchantId = ''; |
| | | protected string $locationId = ''; |
| | | protected array $locations = []; |
| | | |
| | | protected static SquareOld $instance; |
| | | |
| | | public static function getInstance():self |
| | | { |
| | | if (!isset(self::$instance)) { |
| | | self::$instance = new self(); |
| | | } |
| | | return self::$instance; |
| | | } |
| | | |
| | | protected function __construct(?int $userID = null) |
| | | { |
| | | |
| | | // Display properties |
| | | $this->title = 'Square'; |
| | | $this->icon = 'square-logo'; |
| | | |
| | | $this->refresh_interval = 7 * DAY_IN_SECONDS; |
| | | |
| | | $this->newOrder = [ |
| | | 'post_type' => $this->orderPostType, |
| | | 'post_status' => 'PROPOSED', |
| | | ]; |
| | | |
| | | // Define credential fields |
| | | $this->fields = [ |
| | | 'environment' => [ |
| | | 'type' => 'select', |
| | | 'label' => 'Environment', |
| | | 'options' => [ |
| | | 'sandbox' => 'Sandbox', |
| | | 'production'=> 'Production', |
| | | ], |
| | | 'default' => 'sandbox', |
| | | ], |
| | | 'client_id' => [ |
| | | 'type' => 'text', |
| | | 'label' => 'Application ID', |
| | | 'hint' => 'Found in Square Developer Dashboard', |
| | | 'required' => true, |
| | | ], |
| | | 'client_secret' => [ |
| | | 'type' => 'text', |
| | | 'subtype' => 'password', |
| | | 'label' => 'Application Secret', |
| | | 'hint' => 'Found in OAuth section of Square Developer Dashboard', |
| | | 'required' => true, |
| | | ], |
| | | 'location_id' => [ |
| | | 'type' => 'select', |
| | | 'label' => 'Default Location', |
| | | 'options' => [], |
| | | 'hint' => 'Select default Square location for transactions', |
| | | ] |
| | | ]; |
| | | |
| | | // Advanced settings |
| | | $this->advanced = [ |
| | | 'webhook_signature_key' => [ |
| | | 'type' => 'text', |
| | | 'subtype' => 'password', |
| | | 'label' => 'Webhook Signature Key', |
| | | 'hint' => 'Used to verify webhook authenticity', |
| | | ], |
| | | 'access_token' => [ |
| | | 'type' => 'text', |
| | | 'subtype' => 'password', |
| | | 'label' => 'Access Token', |
| | | 'hint' => 'Generated automatically after OAuth authorization', |
| | | 'readonly' => true, |
| | | ], |
| | | 'refresh_token' => [ |
| | | 'type' => 'text', |
| | | 'subtype' => 'password', |
| | | 'label' => 'Refresh Token', |
| | | 'hint' => 'Used to refresh expired access tokens', |
| | | 'readonly' => true, |
| | | ], |
| | | 'merchant_id' => [ |
| | | 'type' => 'text', |
| | | 'label' => 'Merchant ID', |
| | | 'hint' => 'Square Merchant ID (obtained after authorization)', |
| | | 'readonly' => true, |
| | | ], |
| | | ]; |
| | | |
| | | $this->instructions = [ |
| | | 'Go to <a href="https://developer.squareup.com/apps" target="_blank">Square Developer Dashboard</a>', |
| | | 'Create a new application or select an existing one', |
| | | 'Navigate to the OAuth section', |
| | | 'Add this redirect URL: <code>' . esc_html($this->getRedirectUri()) . '</code>', |
| | | 'Copy the Application ID and Application Secret', |
| | | 'Click "Authorize Connection" below to complete OAuth flow' |
| | | ]; |
| | | |
| | | |
| | | $this->canSync = [ |
| | | 'create' => true, |
| | | 'update' => true, |
| | | 'delete' => true |
| | | ]; |
| | | $this->supportsWebp = false; |
| | | $this->hasWebhooks = true; |
| | | |
| | | parent::__construct(); |
| | | |
| | | // Add Square-specific actions |
| | | $this->actions = array_merge( |
| | | $this->actions, |
| | | [ |
| | | 'select_location' => 'handleSelectLocation', |
| | | 'fetch_locations' => 'handleFetchLocations', |
| | | 'verify_webhook' => 'handleVerifyWebhook', |
| | | 'test_payment' => 'handleTestPayment' |
| | | ] |
| | | ); |
| | | |
| | | $this->buttons = array_merge( |
| | | $this->buttons, |
| | | [ |
| | | 'import_from_square' => 'Import Catalog from Square', |
| | | 'sync_to_square' => 'Sync Site to Square', |
| | | ] |
| | | ); |
| | | |
| | | add_action('init', [$this, 'registerSquarePostTypes'], 5); |
| | | add_action('init', [$this, 'addDashboardPages'], 10); |
| | | add_action(BASE.'dashboard_page_orders', [$this, 'renderDashPage']); |
| | | } |
| | | |
| | | /** |
| | | * Initialize the integration with loaded credentials |
| | | */ |
| | | protected function initialize(): void |
| | | { |
| | | if (empty($this->credentials)) { |
| | | $this->loadCredentials(); |
| | | } |
| | | |
| | | $this->merchantId = $this->credentials['merchant_id'] ?? ''; |
| | | $this->locationId = $this->credentials['location_id'] ?? ''; |
| | | $this->environment = $this->credentials['environment'] ?? 'sandbox'; |
| | | |
| | | // Set OAuth URLs based on environment |
| | | $baseUrl = ($this->environment === 'production') |
| | | ? 'https://connect.squareup.com' |
| | | : 'https://connect.squareupsandbox.com'; |
| | | |
| | | $this->oauth['authorize'] = $baseUrl . '/oauth2/authorize'; |
| | | $this->oauth['token'] = $baseUrl . '/oauth2/token'; |
| | | $this->oauth['revoke'] = $baseUrl . '/oauth2/revoke'; |
| | | |
| | | // Set up API endpoints with version |
| | | $this->apiEndpoints = [ |
| | | 'locations' => '/v2/locations', |
| | | 'customers' => '/v2/customers', |
| | | 'catalog' => '/v2/catalog', |
| | | 'payments' => '/v2/payments', |
| | | 'orders' => '/v2/orders', |
| | | 'inventory' => '/v2/inventory' |
| | | ]; |
| | | |
| | | } |
| | | |
| | | public function getOrderFields():array |
| | | { |
| | | return [ |
| | | 'post_title' => [ |
| | | 'type' => 'text', |
| | | 'label' => 'Order Number' |
| | | ], |
| | | 'square_order_id' => [ |
| | | 'type' => 'text', |
| | | 'label' => 'Square Order ID', |
| | | ], |
| | | 'square_payment_id' => [ |
| | | 'type' => 'text', |
| | | 'label' => 'Square Payment ID', |
| | | ], |
| | | 'square_customer_id' => [ |
| | | 'type' => 'text', |
| | | 'label' => 'Square Customer ID', |
| | | ], |
| | | 'amount' => [ |
| | | 'type' => 'number', |
| | | 'label' => 'Total Amount (cents)', |
| | | ], |
| | | 'square_payment_status' => [ |
| | | 'type' => 'select', |
| | | 'label' => 'Order Status', |
| | | 'options' => [ |
| | | 'PROPOSED' => 'Proposed', |
| | | 'RESERVED' => 'Reserved', |
| | | 'PREPARED' => 'Prepared (Ready for Pickup)', |
| | | 'COMPLETED' => 'Completed', |
| | | 'CANCELED' => 'Canceled' |
| | | ], |
| | | ], |
| | | 'fulfillment_status' => [ |
| | | 'type' => 'select', |
| | | 'label' => 'Fulfillment Status', |
| | | 'options' => [ |
| | | 'PROPOSED' => 'Proposed', |
| | | 'RESERVED' => 'Reserved', |
| | | 'PREPARED' => 'Prepared', |
| | | 'COMPLETED' => 'Completed', |
| | | 'CANCELED' => 'Canceled', |
| | | 'FAILED' => 'Failed' |
| | | ], |
| | | ], |
| | | 'pickup_time' => [ |
| | | 'type' => 'datetime', |
| | | 'label' => 'Pickup Time' |
| | | ], |
| | | 'customer_email' => [ |
| | | 'type' => 'email', |
| | | 'label' => 'Customer Email', |
| | | ], |
| | | 'customer_name' => [ |
| | | 'type' => 'text', |
| | | 'label' => 'Customer Name', |
| | | ], |
| | | 'customer_phone' => [ |
| | | 'type' => 'phone', |
| | | 'label' => 'Customer Phone', |
| | | 'section'=> 'your-account' |
| | | ], |
| | | 'special_instructions' => [ |
| | | 'type' => 'textarea', |
| | | 'label' => 'Special Instructions', |
| | | ], |
| | | 'items' => [ |
| | | 'type' => 'repeater', |
| | | 'label' => 'Order Items', |
| | | 'fields' => [ |
| | | 'name' => ['type' => 'text', 'label' => 'Item Name'], |
| | | 'quantity' => ['type' => 'number', 'label' => 'Quantity'], |
| | | 'price' => ['type' => 'number', 'label' => 'Price'], |
| | | 'note' => ['type' => 'text', 'label' => 'Note'] |
| | | ] |
| | | ], |
| | | 'receipt_url' => [ |
| | | 'type' => 'url', |
| | | 'label' => 'Receipt URL', |
| | | ], |
| | | 'created_at' => [ |
| | | 'type' => 'datetime', |
| | | 'label' => 'Created At', |
| | | ], |
| | | 'updated_at' => [ |
| | | 'type' => 'datetime', |
| | | 'label' => 'Last Updated', |
| | | ] |
| | | ]; |
| | | } |
| | | |
| | | public function registerSquarePostTypes():void |
| | | { |
| | | $orders = Registrar::forPost($this->orderPostType, 'Square Order', 'Square Orders'); |
| | | $orders->make([ |
| | | 'public' => true |
| | | ]); |
| | | $orders->setAll(['system']); |
| | | |
| | | $fields = $orders->fields(); |
| | | foreach ($this->getOrderFields() as $fieldName => $config) { |
| | | $fields->addField($fieldName, $config); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Get request headers for API calls |
| | | */ |
| | | protected function getRequestHeaders(): array |
| | | { |
| | | return [ |
| | | 'Authorization' => 'Bearer ' . ($this->credentials['access_token'] ?? ''), |
| | | 'Square-Version' => $this->apiVersion, |
| | | 'Content-Type' => 'application/json' |
| | | ]; |
| | | } |
| | | |
| | | /** |
| | | * Add Square-specific OAuth parameters |
| | | */ |
| | | protected function addOAuthParams(array $params): array |
| | | { |
| | | // Load current environment setting |
| | | $this->ensureInitialized(); |
| | | |
| | | // For production, add session=false |
| | | // For sandbox, session parameter is not supported and should be omitted |
| | | if ($this->environment === 'production') { |
| | | $params['session'] = 'false'; |
| | | } else { |
| | | // Ensure session is not included for sandbox |
| | | unset($params['session']); |
| | | } |
| | | |
| | | return $params; |
| | | } |
| | | |
| | | /** |
| | | * Exchange OAuth code for tokens |
| | | * Override to handle Square's specific response format |
| | | */ |
| | | protected function exchangeOAuthCode(string $code): ?array |
| | | { |
| | | $this->ensureInitialized(); |
| | | |
| | | // Prepare the request body as an array |
| | | $body = [ |
| | | 'client_id' => $this->credentials['client_id'] ?? '', |
| | | 'client_secret' => $this->credentials['client_secret'] ?? '', |
| | | 'code' => $code, |
| | | 'grant_type' => 'authorization_code', |
| | | 'redirect_uri' => $this->getRedirectUri() |
| | | ]; |
| | | |
| | | $response = wp_remote_post($this->oauth['token'], [ |
| | | 'body' => json_encode($body), |
| | | 'headers' => [ |
| | | 'Content-Type' => 'application/json', |
| | | 'Square-Version' => $this->apiVersion |
| | | ] |
| | | ]); |
| | | |
| | | if (is_wp_error($response)) { |
| | | $this->logError('OAuth token exchange failed', ['error' => $response->get_error_message()]); |
| | | return null; |
| | | } |
| | | |
| | | $data = json_decode(wp_remote_retrieve_body($response), true); |
| | | if (isset($data['access_token'])) { |
| | | return [ |
| | | 'access_token' => $data['access_token'], |
| | | 'refresh_token' => $data['refresh_token'] ?? '', |
| | | 'expires_in' => $data['expires_in'] ?? 2592000, // 30 days default |
| | | 'token_type' => $data['token_type'] ?? 'Bearer', |
| | | 'merchant_id' => $data['merchant_id'] ?? '', |
| | | 'scope' => $data['scope'] ?? '' |
| | | ]; |
| | | } |
| | | |
| | | $this->logError('Failed to obtain access token', ['response' => $data]); |
| | | return null; |
| | | } |
| | | |
| | | /** |
| | | * Add Square-specific credential data after OAuth |
| | | */ |
| | | protected function addCredentialData(array $credentials, array $tokens): array |
| | | { |
| | | // Store Square-specific data |
| | | $credentials['merchant_id'] = $tokens['merchant_id'] ?? ''; |
| | | |
| | | // Preserve selected location if it exists |
| | | $credentials['location_id'] = $this->credentials['location_id'] ?? ''; |
| | | |
| | | // Preserve sandbox setting |
| | | $credentials['environment'] = $this->credentials['environment'] ?? 'sandbox'; |
| | | |
| | | return $credentials; |
| | | } |
| | | |
| | | /** |
| | | * Refresh OAuth token |
| | | * Override to handle Square's refresh token flow |
| | | */ |
| | | protected function refreshOAuthToken(): bool |
| | | { |
| | | if (!$this->isOAuthService || empty($this->credentials['refresh_token'])) { |
| | | return false; |
| | | } |
| | | |
| | | $response = wp_remote_post($this->oauth['token'], [ |
| | | 'body' => [ |
| | | 'client_id' => $this->credentials['client_id'], |
| | | 'client_secret' => $this->credentials['client_secret'], |
| | | 'refresh_token' => $this->credentials['refresh_token'], |
| | | 'grant_type' => 'refresh_token' |
| | | ], |
| | | 'headers' => $this->getRequestHeaders() |
| | | ]); |
| | | |
| | | if (is_wp_error($response)) { |
| | | $this->logError('Failed to refresh Square token', [ |
| | | 'error' => $response->get_error_message() |
| | | ]); |
| | | return false; |
| | | } |
| | | |
| | | $data = json_decode(wp_remote_retrieve_body($response), true); |
| | | |
| | | if (isset($data['access_token'])) { |
| | | $this->credentials['access_token'] = $data['access_token']; |
| | | $this->credentials['expires_at'] = time() + ($data['expires_in'] ?? 2592000); // 30 days |
| | | // Note: Square returns the SAME refresh token |
| | | if (isset($data['refresh_token'])) { |
| | | $this->credentials['refresh_token'] = $data['refresh_token']; |
| | | } |
| | | |
| | | $this->saveCredentials($this->credentials); |
| | | return true; |
| | | } |
| | | |
| | | return false; |
| | | } |
| | | |
| | | /** |
| | | * Load Square locations |
| | | */ |
| | | protected function loadLocations(): void |
| | | { |
| | | // Skip if we don't have credentials yet (during OAuth flow) |
| | | if (empty($this->credentials['access_token'])) { |
| | | return; |
| | | } |
| | | try { |
| | | $response = $this->getRequest( |
| | | '/v2/locations', |
| | | [], |
| | | $this->environment |
| | | ); |
| | | |
| | | if (isset($response['locations'])) { |
| | | $this->locations = $response['locations']; |
| | | |
| | | // Update location options in fields |
| | | $locationOptions = []; |
| | | foreach ($this->locations as $location) { |
| | | if ($location['status'] === 'ACTIVE') { |
| | | $locationOptions[$location['id']] = $location['name'] ?? $location['id']; |
| | | } |
| | | } |
| | | |
| | | if (!empty($locationOptions)) { |
| | | $this->fields['location_id']['options'] = $locationOptions; |
| | | } |
| | | } |
| | | } catch (Exception $e) { |
| | | $this->logError('Failed to load locations', ['error' => $e->getMessage()]); |
| | | // Set empty locations array so we don't retry constantly |
| | | $this->locations = []; |
| | | } |
| | | } |
| | | |
| | | protected function renderOAuthConnectedOptions(): void |
| | | { |
| | | if (!$this->isSetUp()) { |
| | | return; |
| | | } |
| | | |
| | | // Check OAuth status |
| | | $oauth_valid = $this->isOAuthValid(); |
| | | |
| | | if (!$oauth_valid) { |
| | | ?> |
| | | <div class="oauth-status"> |
| | | <span class="status-invalid">⚠️ OAuth token expired - please reconnect</span> |
| | | </div> |
| | | <?php |
| | | } |
| | | |
| | | $this->loadLocations(); |
| | | if (!empty($this->locations)) { |
| | | ?> |
| | | <div class="form-field" data-service="square"> |
| | | <label for="square_location_id">Square Location</label> |
| | | <select name="location_id" |
| | | id="square_location_id" |
| | | class="jvb-ajax-update" |
| | | data-action="select_location"> |
| | | <option value="">Select a location...</option> |
| | | <?php foreach ($this->locations as $location): ?> |
| | | <option value="<?php echo esc_attr($location['id']); ?>" |
| | | <?php selected($this->credentials['location_id'] ?? '', $location['id']); ?>> |
| | | <?php echo esc_html($location['name']); ?> |
| | | </option> |
| | | <?php endforeach; ?> |
| | | </select> |
| | | </div> |
| | | <?php |
| | | |
| | | // Show current selection status |
| | | if (!empty($this->credentials['location'])) { |
| | | ?> |
| | | <div class="connection-status connected"> |
| | | ✅ Connected to: <?php echo esc_html($this->credentials['location_id']); ?> |
| | | </div> |
| | | <?php |
| | | } |
| | | } else { |
| | | ?> |
| | | <div class="notice notice-warning"> |
| | | <p>No Square locations found. Please ensure your Square credentials are correct.</p> |
| | | </div> |
| | | <?php |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Handle location selection |
| | | */ |
| | | public function handleSelectLocation($data): array |
| | | { |
| | | if (empty($data['location_id'])) { |
| | | return [ |
| | | 'success' => false, |
| | | 'message' => 'No location selected' |
| | | ]; |
| | | } |
| | | |
| | | $this->credentials['location_id'] = sanitize_text_field($data['location_id']); |
| | | $this->locationId = $this->credentials['location_id']; |
| | | |
| | | $result = $this->saveCredentials($this->credentials); |
| | | |
| | | if ($result['success']) { |
| | | return [ |
| | | 'success' => true, |
| | | 'message' => 'Location updated successfully' |
| | | ]; |
| | | } |
| | | |
| | | return $result; |
| | | } |
| | | |
| | | /** |
| | | * Handle fetching locations |
| | | */ |
| | | public function handleFetchLocations(): array |
| | | { |
| | | try { |
| | | $this->loadLocations(); |
| | | |
| | | if (empty($this->locations)) { |
| | | return [ |
| | | 'success' => false, |
| | | 'message' => 'No locations found' |
| | | ]; |
| | | } |
| | | |
| | | return [ |
| | | 'success' => true, |
| | | 'data' => $this->locations, |
| | | 'message' => sprintf('Found %d location(s)', count($this->locations)) |
| | | ]; |
| | | } catch (Exception $e) { |
| | | return [ |
| | | 'success' => false, |
| | | 'message' => 'Failed to fetch locations: ' . $e->getMessage() |
| | | ]; |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Handle webhook verification |
| | | */ |
| | | public function handleVerifyWebhook($data): array |
| | | { |
| | | if (empty($data['signature']) || empty($data['body'])) { |
| | | return [ |
| | | 'success' => false, |
| | | 'message' => 'Missing signature or body' |
| | | ]; |
| | | } |
| | | |
| | | $signatureKey = $this->credentials['webhook_signature_key'] ?? ''; |
| | | |
| | | if (empty($signatureKey)) { |
| | | return [ |
| | | 'success' => false, |
| | | 'message' => 'Webhook signature key not configured' |
| | | ]; |
| | | } |
| | | |
| | | // Calculate expected signature |
| | | $stringToSign = $data['url'] . $data['body']; |
| | | $expectedSignature = base64_encode( |
| | | hash_hmac('sha256', $stringToSign, $signatureKey, true) |
| | | ); |
| | | |
| | | $isValid = hash_equals($expectedSignature, $data['signature']); |
| | | |
| | | return [ |
| | | 'success' => $isValid, |
| | | 'message' => $isValid ? 'Valid webhook signature' : 'Invalid webhook signature' |
| | | ]; |
| | | } |
| | | |
| | | /** |
| | | * Test payment/connection |
| | | */ |
| | | public function handleTestPayment(): array |
| | | { |
| | | try { |
| | | // Test with a simple API call to verify connection |
| | | $response = $this->getRequest( |
| | | '/v2/locations/'.$this->locationId, |
| | | [], |
| | | $this->environment |
| | | ); |
| | | if (!empty($response['location'])) { |
| | | return [ |
| | | 'success' => true, |
| | | 'message' => 'Successfully connected to Square', |
| | | 'data' => [ |
| | | 'location_name' => $response['location']['name'] ?? 'Unknown', |
| | | 'merchant_id' => $this->merchantId, |
| | | 'environment' => $this->environment |
| | | ] |
| | | ]; |
| | | } |
| | | |
| | | return [ |
| | | 'success' => false, |
| | | 'message' => 'Connection test failed' |
| | | ]; |
| | | } catch (Exception $e) { |
| | | return [ |
| | | 'success' => false, |
| | | 'message' => 'Connection test failed: ' . $e->getMessage() |
| | | ]; |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Create a Square customer |
| | | */ |
| | | public function createCustomer(array $customerData): array |
| | | { |
| | | try { |
| | | $response = $this->postRequest('/v2/customers', [ |
| | | 'given_name' => $customerData['first_name'] ?? '', |
| | | 'family_name' => $customerData['last_name'] ?? '', |
| | | 'email_address' => $customerData['email'] ?? '', |
| | | 'phone_number' => $customerData['phone'] ?? '', |
| | | 'reference_id' => $customerData['reference_id'] ?? '', |
| | | 'note' => $customerData['note'] ?? '' |
| | | ], $this->environment); |
| | | |
| | | return [ |
| | | 'success' => true, |
| | | 'data' => $response['customer'] ?? [] |
| | | ]; |
| | | } catch (Exception $e) { |
| | | return [ |
| | | 'success' => false, |
| | | 'message' => 'Failed to create customer: ' . $e->getMessage() |
| | | ]; |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Create a catalog item |
| | | */ |
| | | public function createCatalogItem(array $itemData): array |
| | | { |
| | | try { |
| | | $response = $this->postRequest('/v2/catalog/object', [ |
| | | 'idempotency_key' => wp_generate_uuid4(), |
| | | 'object' => [ |
| | | 'type' => 'ITEM', |
| | | 'id' => '#' . sanitize_title($itemData['name']), |
| | | 'item_data' => [ |
| | | 'name' => $itemData['name'], |
| | | 'description' => $itemData['description'] ?? '', |
| | | 'variations' => $this->buildItemVariations($itemData) |
| | | ] |
| | | ] |
| | | ], $this->environment); |
| | | |
| | | return [ |
| | | 'success' => true, |
| | | 'data' => $response['catalog_object'] ?? [] |
| | | ]; |
| | | } catch (Exception $e) { |
| | | return [ |
| | | 'success' => false, |
| | | 'message' => 'Failed to create catalog item: ' . $e->getMessage() |
| | | ]; |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Build item variations for catalog |
| | | */ |
| | | protected function buildItemVariations(array $itemData): array |
| | | { |
| | | $variations = []; |
| | | |
| | | if (isset($itemData['variations']) && is_array($itemData['variations'])) { |
| | | foreach ($itemData['variations'] as $variation) { |
| | | $variations[] = [ |
| | | 'type' => 'ITEM_VARIATION', |
| | | 'id' => '#' . sanitize_title($variation['name']), |
| | | 'item_variation_data' => [ |
| | | 'name' => $variation['name'], |
| | | 'pricing_type' => 'FIXED_PRICING', |
| | | 'price_money' => [ |
| | | 'amount' => $this->formatPrice($variation['_square_price']??0), |
| | | 'currency' => $this->getCurrency() |
| | | ] |
| | | ] |
| | | ]; |
| | | } |
| | | } else { |
| | | // Default single variation |
| | | $variations[] = [ |
| | | 'type' => 'ITEM_VARIATION', |
| | | 'id' => '#regular', |
| | | 'item_variation_data' => [ |
| | | 'name' => 'Regular', |
| | | 'pricing_type' => 'FIXED_PRICING', |
| | | 'price_money' => [ |
| | | 'amount' => $this->formatPrice($itemData['_square_price'] ?? 0), |
| | | 'currency' => $this->getCurrency() |
| | | ] |
| | | ] |
| | | ]; |
| | | } |
| | | |
| | | return $variations; |
| | | } |
| | | |
| | | /** |
| | | * Handle OAuth disconnect |
| | | */ |
| | | public function handleOAuthDisconnect(): array |
| | | { |
| | | try { |
| | | // Revoke the token with Square |
| | | if (!empty($this->credentials['access_token'])) { |
| | | wp_remote_post($this->oauth['revoke'], [ |
| | | 'body' => [ |
| | | 'client_id' => $this->credentials['client_id'] ?? '', |
| | | 'access_token' => $this->credentials['access_token'] |
| | | ], |
| | | 'headers' => $this->getRequestHeaders() |
| | | ]); |
| | | } |
| | | |
| | | // Clear stored credentials |
| | | $this->credentials = [ |
| | | 'client_id' => $this->credentials['client_id'] ?? '', |
| | | 'client_secret' => $this->credentials['client_secret'] ?? '' |
| | | ]; |
| | | |
| | | $this->saveCredentials($this->credentials); |
| | | $this->clearCache(); |
| | | |
| | | return [ |
| | | 'success' => true, |
| | | 'message' => 'Successfully disconnected from Square' |
| | | ]; |
| | | } catch (Exception $e) { |
| | | return [ |
| | | 'success' => false, |
| | | 'message' => 'Failed to disconnect: ' . $e->getMessage() |
| | | ]; |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Register additional WordPress hooks |
| | | */ |
| | | protected function registerAdditionalHooks(): void |
| | | { |
| | | if (!$this->isSetUp()) { |
| | | error_log('Square is not setup'); |
| | | return; |
| | | } |
| | | |
| | | add_action('wp_enqueue_scripts', [$this, 'enqueueScripts']); |
| | | |
| | | add_filter('jvbAdditionalActions', [Checkout::class, 'render']); |
| | | |
| | | add_filter('jvb_checkout_description', function (string $desc, string $provider) { |
| | | if ($provider === 'square') { |
| | | return 'Securely checkout with your name, email, and payments processed by Square.'; |
| | | } |
| | | return $desc; |
| | | }, 10, 2); |
| | | |
| | | // Square-specific pickup fields (extracted from old outputCheckout) |
| | | add_filter('jvb_checkout_fields', [$this, 'addPickupFields'], 10, 2); |
| | | |
| | | // Browse URL for this client (restaurant menu) |
| | | add_filter('jvb_checkout_browse_url', function () { |
| | | return get_post_type_archive_link(BASE . 'menu_item'); |
| | | }); |
| | | add_filter('jvb_checkout_browse_text', function () { |
| | | return 'browse our menu'; |
| | | }); |
| | | } |
| | | |
| | | /** |
| | | * Pickup/ordering fields for the shared checkout form. |
| | | * Specific to this Square client's food ordering use case. |
| | | */ |
| | | public function addPickupFields(string $html, string $provider): string |
| | | { |
| | | if ($provider !== 'square') { |
| | | return $html; |
| | | } |
| | | |
| | | return $html |
| | | . '<h3>Pickup Details</h3>' |
| | | . Form::render('pickup_time', null, [ |
| | | 'type' => 'datetime', |
| | | 'label' => 'Pickup Time', |
| | | 'min' => '11:00', |
| | | 'max' => '20:00', |
| | | 'required' => true, |
| | | ]) |
| | | . Form::render('special_instructions', null, [ |
| | | 'type' => 'textarea', |
| | | 'label' => 'Special Instructions', |
| | | 'quill' => true, |
| | | ]); |
| | | } |
| | | |
| | | protected function registerAdditionalQueueTypes(IntegrationExecutor $executor): void |
| | | { |
| | | $queue = JVB()->queue(); |
| | | |
| | | $queue->registry()->register(self::$syncCustomer, new TypeConfig( |
| | | executor: $executor, |
| | | maxRetries: 2 |
| | | )); |
| | | |
| | | $queue->registry()->register(self::$import, new TypeConfig( |
| | | executor: $executor, |
| | | maxRetries: 3 |
| | | )); |
| | | } |
| | | |
| | | /****************************************************************** |
| | | * POST SYNC METHODS |
| | | ******************************************************************/ |
| | | |
| | | /** |
| | | * Handle post save for Square sync |
| | | */ |
| | | protected function handleTheSavePost(int $postID, WP_Post $post, bool $update, array $settings): void |
| | | { |
| | | error_log('==== [Square]::handleTheSavePost ===='); |
| | | $this->queueOperation(self::$syncTo, [ |
| | | 'items' => [$postID], |
| | | 'user' => user_can($post->post_author, 'manage_options') ? null : $post->post_author |
| | | ], [ |
| | | 'priority' => 'high', |
| | | 'delay' => 30, |
| | | ]); |
| | | Meta::forPost($postID)->set('_'.$this->service_name.'_sync_status', 'queued'); |
| | | } |
| | | |
| | | /** |
| | | * Handle post deletion |
| | | */ |
| | | public function handleDeletePost(int $postID): void |
| | | { |
| | | $item_id = $this->getServiceItemID($postID); |
| | | |
| | | if (empty($item_id)) { |
| | | return; |
| | | } |
| | | $this->queueOperation(self::$deleteFrom, [ |
| | | 'external_ids' => [$item_id], |
| | | 'post_id' => $postID, |
| | | ], [ |
| | | 'priority' => 'high', |
| | | ]); |
| | | |
| | | } |
| | | |
| | | /** |
| | | * Process sync to Square |
| | | */ |
| | | private function processSyncToSquare(array $data): array |
| | | { |
| | | $items = $data['items'] ?? []; |
| | | $success = []; |
| | | $errors = []; |
| | | |
| | | if (empty($items)) { |
| | | return [ |
| | | 'success' => true, |
| | | 'result' => [ |
| | | 'synced' => [], |
| | | 'errors' => ['No items to sync'] |
| | | ] |
| | | ]; |
| | | } |
| | | |
| | | //Check images are already uploaded first |
| | | $image_mappings = $this->batchUploadImages($items); |
| | | |
| | | $catalog = []; |
| | | $map = []; |
| | | foreach ($items as $postID) { |
| | | $post = get_post($postID); |
| | | if (!$post) { |
| | | $errors[] = "Post $postID not found"; |
| | | continue; |
| | | } |
| | | |
| | | $square_image_id = $image_mappings[$postID] ?? null; |
| | | $catalog_object = $this->buildCatalogObject($postID, $square_image_id); |
| | | if (is_wp_error($catalog_object)) { |
| | | $errors[] = $catalog_object->get_error_message(); |
| | | continue; |
| | | } |
| | | $map[$catalog_object['id']] = $postID; |
| | | $catalog[] = $catalog_object; |
| | | } |
| | | |
| | | if (!empty($catalog)) { |
| | | $response = $this->postRequest('catalog/batch-upsert', [ |
| | | 'idempotency_key' => wp_generate_uuid4(), |
| | | 'batches' => [[ |
| | | 'objects' => $catalog |
| | | ]] |
| | | ]); |
| | | |
| | | if (!is_wp_error($response)) { |
| | | $this->processBatchSyncResponse($response, $map, $success, $errors); |
| | | } else { |
| | | // Handle batch request failure |
| | | $error_message = 'Batch sync failed'; |
| | | $this->logError($error_message, [ |
| | | 'method' => 'processSyncToSquare', |
| | | 'post_ids' => $items, |
| | | 'error' => $response |
| | | ]); |
| | | |
| | | // Mark all items as failed |
| | | foreach ($items as $postID) { |
| | | if (get_post($postID)) { // Only if post exists |
| | | $errors[] = "Failed to sync post $postID"; |
| | | update_post_meta($postID, BASE . '_square_sync_status', 'error'); |
| | | } |
| | | } |
| | | } |
| | | } |
| | | |
| | | return [ |
| | | 'success' => count($success) > 0, |
| | | 'result' => [ |
| | | 'synced' => $success, |
| | | 'errors' => $errors |
| | | ] |
| | | ]; |
| | | } |
| | | |
| | | private function processBatchSyncResponse(array $response, array $map, array &$success, array &$errors):void |
| | | { |
| | | error_log('==== SQUARE::processBatchSyncResponse ====='); |
| | | error_log('Full response: '.print_r($response, true)); |
| | | |
| | | // Handle successful objects |
| | | if (!empty($response['objects'])) { |
| | | foreach ($response['objects'] as $object) { |
| | | $object_id = $object['id']; |
| | | $post_id = $map[$object_id] ?? null; |
| | | |
| | | if (!$post_id) { |
| | | // Try to find post ID from the object ID pattern |
| | | if (preg_match('/#\w+_(\d+)/', $object_id, $matches)) { |
| | | $post_id = (int)$matches[1]; |
| | | } |
| | | } |
| | | |
| | | if ($post_id && get_post($post_id)) { |
| | | // Update post meta with Square data |
| | | update_post_meta($post_id, BASE . '_square_catalog_id', $object['id']); |
| | | update_post_meta($post_id, BASE . '_square_sync_status', 'synced'); |
| | | update_post_meta($post_id, BASE . '_square_last_sync', current_time('mysql')); |
| | | |
| | | // Save variation IDs if present |
| | | if (!empty($object['item_data']['variations'])) { |
| | | foreach ($object['item_data']['variations'] as $index => $variation) { |
| | | update_post_meta($post_id, BASE . '_square_variation_' . $index . '_id', $variation['id']); |
| | | } |
| | | } |
| | | |
| | | $success[] = $post_id; |
| | | } |
| | | } |
| | | } |
| | | |
| | | // Handle errors |
| | | if (!empty($response['errors'])) { |
| | | foreach ($response['errors'] as $error) { |
| | | $error_detail = $error['detail'] ?? 'Unknown error'; |
| | | $error_field = $error['field'] ?? ''; |
| | | |
| | | // Try to extract post ID from error field if possible |
| | | $post_id = null; |
| | | if (preg_match('/batches\[0\]\.objects\[(\d+)\]/', $error_field, $matches)) { |
| | | $object_index = (int)$matches[1]; |
| | | // Get post ID from the object at this index |
| | | if (isset($catalog_objects[$object_index])) { |
| | | $object_id = $catalog_objects[$object_index]['id']; |
| | | $post_id = $map[$object_id] ?? null; |
| | | } |
| | | } |
| | | |
| | | $error_message = $post_id |
| | | ? "Post $post_id: $error_detail" |
| | | : "Batch error: $error_detail"; |
| | | |
| | | $errors[] = $error_message; |
| | | |
| | | if ($post_id) { |
| | | update_post_meta($post_id, BASE . '_square_sync_status', 'error'); |
| | | } |
| | | } |
| | | } |
| | | |
| | | // Handle any posts that weren't in the response (shouldn't happen but good to check) |
| | | foreach ($map as $object_id => $post_id) { |
| | | if (!in_array($post_id, $success)) { |
| | | $found_in_errors = false; |
| | | foreach ($errors as $error) { |
| | | if (str_contains($error, "Post $post_id")) { |
| | | $found_in_errors = true; |
| | | break; |
| | | } |
| | | } |
| | | |
| | | if (!$found_in_errors) { |
| | | $errors[] = "Post $post_id: No response from Square"; |
| | | update_post_meta($post_id, BASE . '_square_sync_status', 'error'); |
| | | } |
| | | } |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Build catalog object from WordPress post |
| | | * |
| | | * @param int $postID WordPress post ID |
| | | * @param string|null $square_image_id Previously uploaded Square image ID |
| | | * @return array|WP_Error Catalog object or error |
| | | */ |
| | | //TODO: Get to work with Registrar settings |
| | | protected function buildCatalogObject(int $postID, ?string $square_image_id = null): array|WP_Error |
| | | { |
| | | $post = get_post($postID); |
| | | if (!$post) { |
| | | return new WP_Error('post_not_found', "Post $postID not found"); |
| | | } |
| | | |
| | | $meta = Meta::forPost($postID); |
| | | $post_type = get_post_type($postID); |
| | | |
| | | // Get existing Square catalog ID if it exists |
| | | $existing_square_id = get_post_meta($postID, BASE . '_square_catalog_id', true); |
| | | $registrar = Registrar::getInstance($post_type); |
| | | $product_type = 'FOOD_AND_BEV'; |
| | | if ($registrar) { |
| | | $conf = $registrar->getIntegration($this->service_name); |
| | | if ($conf) { |
| | | $product_type = $conf->getContentType(); |
| | | } |
| | | } |
| | | |
| | | // Build the base catalog object |
| | | $catalog_object = [ |
| | | 'type' => 'ITEM', |
| | | 'id' => $existing_square_id ?: '#menu_item_item_' . $postID, |
| | | 'item_data' => [ |
| | | 'name' => $post->post_title, |
| | | 'description' => wp_strip_all_tags($post->post_content), |
| | | 'product_type' => $product_type, |
| | | 'variations' => [], |
| | | 'is_taxable' => true, |
| | | ] |
| | | ]; |
| | | |
| | | // Add image ID if provided (must be already uploaded to Square) |
| | | if (!empty($square_image_id)) { |
| | | $catalog_object['item_data']['image_ids'] = [$square_image_id]; |
| | | } |
| | | |
| | | // Add variations |
| | | $variations = $meta->get('_square_product_variations'); |
| | | if (empty($variations)) { |
| | | // Create default variation if none exist |
| | | $catalog_object['item_data']['variations'][] = [ |
| | | 'type' => 'ITEM_VARIATION', |
| | | 'id' => $existing_square_id ? null : '#'.BASE.'menu_item_' . $postID . '_var_default', |
| | | 'item_variation_data' => [ |
| | | 'name' => 'Regular', |
| | | 'ordinal' => 0, |
| | | 'pricing_type' => 'FIXED_PRICING', |
| | | 'price_money' => [ |
| | | 'amount' => $this->formatPrice($meta->get('_square_price')), |
| | | 'currency' => $this->getCurrency() |
| | | ], |
| | | 'sellable' => true, |
| | | 'stockable' => true |
| | | ] |
| | | ]; |
| | | } else { |
| | | $resetVariations = false; |
| | | foreach ($variations as $index => $variation) { |
| | | $id = '#'.BASE.'menu_item_' . $postID . '_var_' . $index; |
| | | if (empty($variation['item_id'])) { |
| | | $resetVariations = true; |
| | | $variations[$index]['item_id'] = $id; |
| | | $variation['item_id'] = $id; |
| | | } |
| | | $catalog_object['item_data']['variations'][] = [ |
| | | 'type' => 'ITEM_VARIATION', |
| | | 'id' => $variation['item_id'], |
| | | 'item_variation_data' => [ |
| | | 'name' => $variation['name'] ?? 'Variation ' . ($index + 1), |
| | | 'ordinal' => $index, |
| | | 'pricing_type' => 'FIXED_PRICING', |
| | | 'price_money' => [ |
| | | 'amount' =>$this->formatPrice($variation['price'] ?? 0), |
| | | 'currency' => $this->getCurrency() |
| | | ], |
| | | 'sellable' => true, |
| | | 'stockable' => true |
| | | ] |
| | | ]; |
| | | } |
| | | if ($resetVariations) { |
| | | $meta->set('_square_product_variations', $variations); |
| | | } |
| | | } |
| | | |
| | | // Add categories if they exist |
| | | $categories = wp_get_post_terms($postID, $post_type . '_category', ['fields' => 'ids']); |
| | | if (!empty($categories)) { |
| | | $category_ids = []; |
| | | foreach ($categories as $term_id) { |
| | | $square_cat_id = get_term_meta($term_id, BASE . '_square_category_id', true); |
| | | if ($square_cat_id) { |
| | | $category_ids[] = $square_cat_id; |
| | | } |
| | | } |
| | | if (!empty($category_ids)) { |
| | | $catalog_object['item_data']['category_ids'] = $category_ids; |
| | | } |
| | | } |
| | | |
| | | // Add modifiers if they exist |
| | | $modifiers = $meta->get('modifiers'); |
| | | if (!empty($modifiers)) { |
| | | $modifier_ids = []; |
| | | foreach ($modifiers as $modifier) { |
| | | if (!empty($modifier['square_id'])) { |
| | | $modifier_ids[] = $modifier['square_id']; |
| | | } |
| | | } |
| | | if (!empty($modifier_ids)) { |
| | | $catalog_object['item_data']['modifier_list_info'] = array_map(function($id) { |
| | | return ['modifier_list_id' => $id]; |
| | | }, $modifier_ids); |
| | | } |
| | | } |
| | | |
| | | // Add tax settings |
| | | $tax_ids = $meta->get('tax_ids'); |
| | | if (!empty($tax_ids)) { |
| | | $catalog_object['item_data']['tax_ids'] = $tax_ids; |
| | | } |
| | | |
| | | return $catalog_object; |
| | | } |
| | | |
| | | |
| | | |
| | | /** |
| | | * Get variation mapping for post type |
| | | */ |
| | | protected function getVariationMapping(string $post_type): array |
| | | { |
| | | $registrar = Registrar::getInstance($post_type); |
| | | if (!$registrar) { |
| | | return []; |
| | | } |
| | | $config = $registrar->getIntegrationConfig($this->service_name); |
| | | $product_type = $config['content_type']??'REGULAR'; |
| | | $valid_fields = $this->getValidFieldsForProductType($product_type); |
| | | |
| | | $defaults = [ |
| | | 'name' => 'name', |
| | | 'id' => '_square_item_id', |
| | | 'sku' => '_square_sku', |
| | | 'price' => '_square_price', |
| | | 'track_inventory' => '_square_track_inventory', |
| | | 'service_duration' => '_square_service_duration', |
| | | 'available_for_booking' => '_square_available_for_booking', |
| | | 'gift_card_type' => '_square_gift_card_type', |
| | | 'ingredients' => '_square_ingredients', |
| | | 'preparation_time_duration' => '_square_preparation_time_duration' |
| | | ]; |
| | | |
| | | $extended = apply_filters( |
| | | BASE . $this->service_name . '_variation_mapping', |
| | | $defaults, |
| | | $post_type, |
| | | $this |
| | | ); |
| | | |
| | | return array_filter($extended, function($key) use ($valid_fields) { |
| | | return in_array($key, $valid_fields); |
| | | }, ARRAY_FILTER_USE_KEY); |
| | | } |
| | | |
| | | |
| | | |
| | | /** |
| | | * Get or create Square category |
| | | */ |
| | | private function getOrCreateSquareCategory(string $name): ?string |
| | | { |
| | | // Check cached mapping |
| | | $cached_id = get_option(BASE . 'square_category_' . sanitize_title($name)); |
| | | if ($cached_id) { |
| | | return $cached_id; |
| | | } |
| | | |
| | | // Create new category |
| | | $response = $this->postRequest('catalog/batch-upsert', [ |
| | | 'idempotency_key' => wp_generate_uuid4(), |
| | | 'batches' => [[ |
| | | 'objects' => [[ |
| | | 'type' => 'CATEGORY', |
| | | 'id' => '#category_' . sanitize_title($name), |
| | | 'category_data' => [ |
| | | 'name' => $name |
| | | ] |
| | | ]] |
| | | ]] |
| | | ]); |
| | | |
| | | if (!is_wp_error($response) && !empty($response['objects'][0]['id'])) { |
| | | $category_id = $response['objects'][0]['id']; |
| | | update_option(BASE . 'square_category_' . sanitize_title($name), $category_id); |
| | | return $category_id; |
| | | } |
| | | |
| | | return null; |
| | | } |
| | | |
| | | /** |
| | | * Get field mapping for post type |
| | | */ |
| | | protected function getFieldMapping(string $post_type): array |
| | | { |
| | | $registrar = Registrar::getInstance($post_type); |
| | | if (!$registrar) { |
| | | return []; |
| | | } |
| | | $config = $registrar->getIntegrationConfig($this->service_name); |
| | | $product_type = $config['content_type']??'REGULAR'; |
| | | $valid_fields = $this->getValidFieldsForProductType($product_type); |
| | | |
| | | $defaults = [ |
| | | 'name' => 'post_title', |
| | | 'description_html' => 'post_content', |
| | | 'abbreviation' => 'abbreviation', |
| | | 'id' => '_square_catalog_id', |
| | | 'sku' => '_square_sku', |
| | | 'category_id' => 'category', |
| | | 'image_ids' => 'post_thumbnail', |
| | | 'price' => '_square_price', |
| | | 'tax_ids' => 'tax_ids', |
| | | // Availability |
| | | 'available_online' => '_square_available_online', |
| | | 'available_for_pickup' => '_square_available_for_pickup', |
| | | 'available_electronically' => '_square_available_electronically', |
| | | // Modifiers |
| | | 'modifier_list_info' => 'modifiers', |
| | | // Item options |
| | | 'item_options' => 'options', |
| | | // Reporting |
| | | 'reporting_category' => 'reporting_category', |
| | | ]; |
| | | |
| | | $extended = apply_filters( |
| | | BASE . $this->service_name . '_field_mapping', |
| | | $defaults, |
| | | $post_type, |
| | | $this |
| | | ); |
| | | |
| | | return array_filter($extended, function($key) use ($valid_fields) { |
| | | return in_array($key, $valid_fields); |
| | | }, ARRAY_FILTER_USE_KEY); |
| | | } |
| | | |
| | | /** |
| | | * Get valid fields for Square product type |
| | | */ |
| | | //TODO: This feels redundant now, with how we've defined fields in getAdditionalFields |
| | | private function getValidFieldsForProductType(string $product_type): array |
| | | { |
| | | $fields = ['name', 'description_html', 'sku', 'price', 'image_ids', 'category_id']; |
| | | |
| | | $type_specific = [ |
| | | 'FOOD_AND_BEV' => [ |
| | | 'ingredients', |
| | | 'preparation_time_duration', |
| | | 'dietary_preferences', |
| | | 'calories_text' |
| | | ], |
| | | 'APPOINTMENTS_SERVICE' => [ |
| | | 'service_duration', |
| | | 'available_for_booking', |
| | | 'team_member_ids', |
| | | 'booking_availability' |
| | | ], |
| | | 'EVENT' => [ |
| | | 'start_date', |
| | | 'end_date', |
| | | 'venue_details', |
| | | 'capacity' |
| | | ], |
| | | 'GIFT_CARD' => [ |
| | | 'gift_card_type', |
| | | 'allowed_locations' |
| | | ] |
| | | ]; |
| | | |
| | | if (isset($type_specific[$product_type])) { |
| | | $fields = array_merge($fields, $type_specific[$product_type]); |
| | | } |
| | | |
| | | return $fields; |
| | | } |
| | | |
| | | /****************************************************************** |
| | | * CUSTOMER MANAGEMENT |
| | | ******************************************************************/ |
| | | |
| | | /** |
| | | * Handle customer authentication during checkout |
| | | */ |
| | | //TODO: Is this necessary? |
| | | public function handleCustomerAuth($data):WP_Error|array |
| | | { |
| | | $email = sanitize_email($data['email'] ?? ''); |
| | | $action = sanitize_text_field($data['action_type'] ?? 'check'); |
| | | |
| | | if (!$email) { |
| | | return new WP_Error('error', 'Email required'); |
| | | } |
| | | |
| | | switch ($action) { |
| | | case 'check': |
| | | return $this->checkCustomerExists($email); |
| | | case 'create': |
| | | return $this->createCustomerAccount($email); |
| | | default: |
| | | $this->logError('No action set for customer auth: '.$action, [ |
| | | 'method' => 'handleCustomerAuth' |
| | | ]); |
| | | return new WP_Error('error', 'No action set for customer auth'); |
| | | } |
| | | |
| | | } |
| | | |
| | | |
| | | |
| | | /** |
| | | * Check if customer exists |
| | | */ |
| | | private function checkCustomerExists(string $email):WP_Error|array |
| | | { |
| | | // Check WordPress user |
| | | $user = get_user_by('email', $email); |
| | | |
| | | if ($user) { |
| | | // Check if user has Square customer integration |
| | | $square_customer_id = get_user_meta($user->ID, BASE . '_square_customer_id', true); |
| | | |
| | | if ($square_customer_id) { |
| | | return [ |
| | | 'exists' => true, |
| | | 'has_account' => true, |
| | | 'message' => 'Account found. Please enter your password to continue' |
| | | ]; |
| | | } else { |
| | | return [ |
| | | 'exists' => true, |
| | | 'has_account' => false, |
| | | 'message' => 'Email found. Would you like to create an account to save your order history?' |
| | | ]; |
| | | } |
| | | } |
| | | |
| | | // Check Square for customer |
| | | $response = $this->postRequest('customers/search', [ |
| | | 'filter' => [ |
| | | 'email_address' => [ |
| | | 'exact' => $email |
| | | ] |
| | | ] |
| | | ]); |
| | | |
| | | if (!is_wp_error($response) && !empty($response['customers'])) { |
| | | return [ |
| | | 'exists' => true, |
| | | 'has_account' => false, |
| | | 'square_only' => true, |
| | | 'message' => 'Found your previous orders. Create an account to access them?' |
| | | ]; |
| | | } else { |
| | | return [ |
| | | 'exists' => false, |
| | | 'message' => 'New customer' |
| | | ]; |
| | | } |
| | | } |
| | | |
| | | private function createCustomerAccount(string $email):WP_Error|array |
| | | { |
| | | // Validate email format |
| | | if (!is_email($email)) { |
| | | return new WP_Error('error', 'Invalid email address'); |
| | | } |
| | | |
| | | // Check if user already exists |
| | | if (email_exists($email)) { |
| | | return [ |
| | | 'success' => false, |
| | | 'exists' => true, |
| | | 'message' => 'An account with this email already exists. Please log in instead.' |
| | | ]; |
| | | } |
| | | |
| | | // Create user account without password (they'll set it via email) |
| | | $user_id = wp_create_user( |
| | | $email, |
| | | wp_generate_password(20, true, true), // Temporary random password |
| | | $email |
| | | ); |
| | | |
| | | if (is_wp_error($user_id)) { |
| | | $this->logError('Failed to create customer account', [ |
| | | 'email' => $email, |
| | | 'error' => $user_id->get_error_message() |
| | | ]); |
| | | return new WP_Error('error', 'Failed to create account. Please try again.'); |
| | | } |
| | | |
| | | // Set user role (assuming you have a customer role defined) |
| | | $user = new WP_User($user_id); |
| | | $user->set_role(BASE.'foodie'); |
| | | |
| | | // Generate password reset key |
| | | $reset_key = get_password_reset_key($user); |
| | | |
| | | if (is_wp_error($reset_key)) { |
| | | return new WP_Error('error', 'Account created, but couldn\'t send email. Please use password reset.'); |
| | | } |
| | | |
| | | |
| | | // Send welcome email with password setup link |
| | | $this->sendWelcomeEmail($user, $reset_key); |
| | | |
| | | // Link to Square customer if exists |
| | | $square_customer_id = $this->getOrCreateSquareCustomer([ |
| | | 'email' => $email, |
| | | 'name' => $email |
| | | ]); |
| | | |
| | | if ($square_customer_id) { |
| | | update_user_meta($user_id, BASE . '_square_customer_id', $square_customer_id); |
| | | } |
| | | |
| | | return [ |
| | | 'success' => true, |
| | | 'message' => 'Account created! Check your email to set your password.', |
| | | 'user_id' => $user_id |
| | | ]; |
| | | } |
| | | |
| | | /** |
| | | * Send welcome email with password setup |
| | | */ |
| | | private function sendWelcomeEmail(WP_User $user, string $reset_key): void |
| | | { |
| | | $site_name = get_bloginfo('name'); |
| | | $reset_url = get_home_url(null, "wp-login.php?action=rp&key=$reset_key&login=" . rawurlencode($user->user_login), 'login'); |
| | | |
| | | $message = sprintf( |
| | | "Welcome to %s!\n\n" . |
| | | "Your account has been created. Please click the button below to set your password:\n\n" . |
| | | "%s\n\n" . |
| | | "Or, copy and paste the link below:\n\n". |
| | | "%s\n\n" . |
| | | "Once you've set your password, you can:\n" . |
| | | "- View your order history\n" . |
| | | // "- Save your favorite items\n" . |
| | | "- Speed up checkout with saved payment methods\n\n" . |
| | | "If you didn't create this account, please ignore this email.\n\n" . |
| | | "Thanks,\n", |
| | | $site_name, |
| | | JVB()->email()->button('Reset Password', $reset_url), |
| | | JVB()->email()->link($reset_url), |
| | | ); |
| | | |
| | | JVB()->email()->sendEmail( |
| | | $user->user_email, |
| | | sprintf('[%s] Welcome! Set Your Password', $site_name), |
| | | $message |
| | | ); |
| | | } |
| | | |
| | | /****************************************************************** |
| | | * WEBHOOK HANDLING |
| | | ******************************************************************/ |
| | | |
| | | /** |
| | | * Validate webhook signature |
| | | */ |
| | | protected function validateWebhook(array $payload): bool |
| | | { |
| | | // Get signature from headers |
| | | $signature = $_SERVER['HTTP_X_SQUARE_SIGNATURE'] ?? ''; |
| | | |
| | | // If no signature provided by Square, validation fails |
| | | if (empty($signature)) { |
| | | $this->logDebug('No webhook signature provided by Square'); |
| | | return false; |
| | | } |
| | | |
| | | // If webhook signature key is not configured, we can't validate |
| | | // but we might want to process the webhook anyway (less secure) |
| | | if (empty($this->webhook_signature_key)) { |
| | | $this->logDebug('Webhook signature key not configured - processing without validation'); |
| | | // You might want to return false here for stricter security |
| | | // return false; |
| | | return true; // Process webhook but log that it's unverified |
| | | } |
| | | |
| | | // Get the raw request body |
| | | $body = file_get_contents('php://input'); |
| | | |
| | | // Calculate expected signature |
| | | $expected = base64_encode(hash_hmac('sha256', $body, $this->webhook_signature_key, true)); |
| | | |
| | | // Use timing-safe comparison |
| | | $is_valid = hash_equals($expected, $signature); |
| | | |
| | | if (!$is_valid) { |
| | | $this->logError('Invalid webhook signature', [ |
| | | 'provided' => substr($signature, 0, 10) . '...', |
| | | 'expected' => substr($expected, 0, 10) . '...' |
| | | ]); |
| | | } |
| | | |
| | | return $is_valid; |
| | | } |
| | | |
| | | /** |
| | | * Process webhook event |
| | | */ |
| | | protected function processWebhook(array $payload): bool |
| | | { |
| | | $event_type = $payload['type'] ?? ''; |
| | | $data = $payload['data'] ?? []; |
| | | |
| | | switch ($event_type) { |
| | | case 'payment.created': |
| | | case 'payment.updated': |
| | | return $this->handlePaymentWebhook($data); |
| | | |
| | | case 'order.created': |
| | | case 'order.updated': |
| | | case 'order.fulfillment.updated': |
| | | return $this->handleOrderWebhook($data); |
| | | |
| | | case 'catalog.version.updated': |
| | | return $this->handleCatalogWebhook($data); |
| | | |
| | | case 'customer.created': |
| | | case 'customer.updated': |
| | | return $this->handleCustomerWebhook($data); |
| | | |
| | | default: |
| | | $this->logDebug('Unhandled webhook type', ['type' => $event_type]); |
| | | return true; |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Handle order status webhook |
| | | */ |
| | | private function handleOrderWebhook(array $data): bool |
| | | { |
| | | $order_id = $data['object']['order']['id'] ?? ''; |
| | | $state = $data['object']['order']['state'] ?? ''; |
| | | $fulfillments = $data['object']['order']['fulfillments'] ?? []; |
| | | |
| | | if (!$order_id) { |
| | | return false; |
| | | } |
| | | |
| | | // Find the WP post for this order |
| | | $wp_order_id = $this->getOrderPost($order_id); |
| | | |
| | | if ($wp_order_id) { |
| | | // Update the post meta |
| | | $meta = Meta::forPost($wp_order_id); |
| | | $updates = [ |
| | | 'status' => $state, |
| | | 'updated_at' => current_time('mysql') |
| | | ]; |
| | | |
| | | // Extract fulfillment status and pickup time |
| | | if (!empty($fulfillments[0])) { |
| | | $fulfillment = $fulfillments[0]; |
| | | $updates['fulfillment_status'] = $fulfillment['state'] ?? $state; |
| | | |
| | | if (!empty($fulfillment['pickup_details']['pickup_at'])) { |
| | | $updates['pickup_time'] = $fulfillment['pickup_details']['pickup_at']; |
| | | } |
| | | } |
| | | |
| | | $meta->setAll($updates); |
| | | |
| | | // Trigger notification to customer if order is ready |
| | | if ($state === 'PREPARED') { |
| | | do_action(BASE . 'square_order_ready', $wp_order_id, $order_id); |
| | | } |
| | | } |
| | | // Trigger action for other integrations |
| | | do_action(BASE . 'square_order_updated', $order_id, $state, $data); |
| | | |
| | | return true; |
| | | } |
| | | |
| | | /****************************************************************** |
| | | * CONNECTION TESTING |
| | | ******************************************************************/ |
| | | protected function performConnectionTest(): bool |
| | | { |
| | | try { |
| | | $response = $this->getRequest('/v2/merchants/me'); |
| | | if (is_wp_error($response)) { |
| | | $this->logError('Connection test failed', ['error' => $response->get_error_message()]); |
| | | return false; |
| | | } |
| | | |
| | | // Check if we got valid merchant data |
| | | return !empty($response['merchant']); |
| | | } catch (Exception $e) { |
| | | $this->logError('Connection test failed', ['error' => $e->getMessage()]); |
| | | return false; |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Check OAuth connection status |
| | | */ |
| | | protected function checkOAuthStatus(): array |
| | | { |
| | | if (!$this->hasOAuthCredentials()) { |
| | | return [ |
| | | 'success' => false, |
| | | 'message' => 'OAuth not configured. Please authorize with Square.', |
| | | 'authorized' => false |
| | | ]; |
| | | } |
| | | |
| | | // Test the connection |
| | | if ($this->performConnectionTest()) { |
| | | $merchant = $this->getRequest('merchants/me'); |
| | | return [ |
| | | 'success' => true, |
| | | 'message' => 'Successfully connected to Square', |
| | | 'authorized' => true, |
| | | 'merchant' => $merchant['merchant'] ?? [] |
| | | ]; |
| | | } |
| | | |
| | | return [ |
| | | 'success' => false, |
| | | 'message' => 'OAuth token may be expired. Please re-authorize.', |
| | | 'authorized' => false |
| | | ]; |
| | | } |
| | | |
| | | /****************************************************************** |
| | | * ADMIN UI |
| | | ******************************************************************/ |
| | | /** |
| | | * Enqueue checkout scripts with Square configuration |
| | | */ |
| | | public function enqueueScripts(): void |
| | | { |
| | | jvbInlineStyles('forms'); |
| | | $this->loadCredentials(); |
| | | $sdk_url = $this->environment === 'production' |
| | | ? 'https://web.squarecdn.com/v1/square.js' |
| | | : 'https://sandbox.web.squarecdn.com/v1/square.js'; |
| | | |
| | | wp_enqueue_script( |
| | | 'square-payments-sdk', |
| | | $sdk_url, |
| | | [], |
| | | null, |
| | | ['strategy' => 'defer', 'in_footer' => true] |
| | | ); |
| | | |
| | | // Shared cart checkout base class |
| | | wp_register_script( |
| | | 'jvb-checkout', |
| | | JVB_URL . 'assets/js/min/checkout.min.js', |
| | | ['jvb-utility', 'jvb-queue', 'jvb-a11y', 'jvb-cache', 'jvb-tabs', 'jvb-popup', 'jvb-login'], |
| | | '1.1.32', |
| | | ['strategy' => 'defer', 'in_footer' => true] |
| | | ); |
| | | |
| | | // Square checkout extends CartCheckout |
| | | wp_register_script( |
| | | 'jvb-square-checkout', |
| | | JVB_URL . 'assets/js/min/square.min.js', |
| | | ['jvb-checkout', 'square-payments-sdk'], |
| | | '1.1.32', |
| | | ['strategy' => 'defer', 'in_footer' => true] |
| | | ); |
| | | |
| | | wp_enqueue_script('jvb-square-checkout'); |
| | | |
| | | wp_localize_script('jvb-square-checkout', 'squareConfig', [ |
| | | 'isOpen' => jvbIsOpen()?'1':'0', |
| | | 'application_id' => $this->credentials['client_id'] ?? '', |
| | | 'location_id' => $this->locationId, |
| | | 'environment' => $this->environment, |
| | | 'currency' => $this->getCurrency(), |
| | | 'is_logged_in' => is_user_logged_in(), |
| | | 'user_email' => is_user_logged_in() ? wp_get_current_user()->user_email : '', |
| | | ]); |
| | | } |
| | | |
| | | /****************************************************************** |
| | | * HELPER METHODS |
| | | ******************************************************************/ |
| | | |
| | | /** |
| | | * Get or create Square customer |
| | | */ |
| | | public function getOrCreateSquareCustomer(array $customer_info): ?string |
| | | { |
| | | if (empty($customer_info['email'])) { |
| | | return null; |
| | | } |
| | | |
| | | // Search for existing customer |
| | | $search_response = $this->postRequest('customers/search', [ |
| | | 'filter' => [ |
| | | 'email_address' => [ |
| | | 'exact' => $customer_info['email'] |
| | | ] |
| | | ] |
| | | ]); |
| | | |
| | | if (!is_wp_error($search_response) && !empty($search_response['customers'])) { |
| | | return $search_response['customers'][0]['id']; |
| | | } |
| | | |
| | | // Create new customer |
| | | $create_response = $this->postRequest('customers', [ |
| | | 'given_name' => $customer_info['name'] ?? '', |
| | | 'email_address' => $customer_info['email'], |
| | | 'phone_number' => $customer_info['phone'] ?? '' |
| | | ]); |
| | | |
| | | if (!is_wp_error($create_response) && !empty($create_response['customer']['id'])) { |
| | | return $create_response['customer']['id']; |
| | | } |
| | | |
| | | return null; |
| | | } |
| | | |
| | | |
| | | /** |
| | | * Process delete from Square |
| | | */ |
| | | private function processDeleteFromSquare(array $data): array |
| | | { |
| | | $square_ids = $data['square_ids'] ?? []; |
| | | |
| | | if (empty($square_ids)) { |
| | | return [ |
| | | 'success' => false, |
| | | 'result' => ['error' => 'No Square IDs provided'] |
| | | ]; |
| | | } |
| | | |
| | | $response = $this->postRequest('catalog/batch-delete', [ |
| | | 'object_ids' => $square_ids |
| | | ]); |
| | | |
| | | if (is_wp_error($response)) { |
| | | return [ |
| | | 'success' => false, |
| | | 'result' => ['error' => $response->get_error_message()] |
| | | ]; |
| | | } |
| | | |
| | | // Clean up post meta |
| | | if (!empty($data['post_id'])) { |
| | | delete_post_meta($data['post_id'], BASE . '_square_catalog_id'); |
| | | delete_post_meta($data['post_id'], BASE . '_square_sync_status'); |
| | | } |
| | | |
| | | return [ |
| | | 'success' => true, |
| | | 'result' => ['deleted' => $square_ids] |
| | | ]; |
| | | } |
| | | |
| | | /** |
| | | * Process sync from Square |
| | | */ |
| | | private function processSyncFromSquare(array $data): array |
| | | { |
| | | $cursor = $data['cursor'] ?? null; |
| | | $types = $data['types'] ?? ['ITEM']; |
| | | |
| | | $request_data = [ |
| | | 'types' => implode(',', $types), |
| | | 'limit' => 100 |
| | | ]; |
| | | |
| | | if ($cursor) { |
| | | $request_data['cursor'] = $cursor; |
| | | } |
| | | |
| | | $response = $this->getRequest('catalog/list?' . http_build_query($request_data)); |
| | | |
| | | if (is_wp_error($response)) { |
| | | return [ |
| | | 'success' => false, |
| | | 'result' => ['error' => $response->get_error_message()] |
| | | ]; |
| | | } |
| | | |
| | | $imported = 0; |
| | | $errors = []; |
| | | |
| | | foreach ($response['objects'] ?? [] as $object) { |
| | | if ($object['type'] === 'ITEM') { |
| | | $result = $this->importSquareItem($object); |
| | | if ($result) { |
| | | $imported++; |
| | | } else { |
| | | $errors[] = $object['id']; |
| | | } |
| | | } |
| | | } |
| | | |
| | | // Queue next batch if cursor exists |
| | | if (!empty($response['cursor'])) { |
| | | $this->queueOperation('sync_from_square', [ |
| | | 'cursor' => $response['cursor'], |
| | | 'types' => $types |
| | | ]); |
| | | } |
| | | |
| | | return [ |
| | | 'success' => true, |
| | | 'result' => [ |
| | | 'imported' => $imported, |
| | | 'errors' => $errors, |
| | | 'has_more' => !empty($response['cursor']) |
| | | ] |
| | | ]; |
| | | } |
| | | |
| | | /** |
| | | * Import Square item to WordPress |
| | | */ |
| | | private function importSquareItem(array $item): bool|int |
| | | { |
| | | //TODO: We need to add the post type to custom meta for Square, this is not good if we have multiple post types with the same product type |
| | | // Find matching content type |
| | | $product_type = $item['item_data']['product_type'] ?? 'REGULAR'; |
| | | $post_type = null; |
| | | |
| | | foreach (Registrar::getRegistered() as $registrar) { |
| | | if (!$registrar->hasIntegration($this->service_name)) { |
| | | continue; |
| | | } |
| | | $config = $registrar->getIntegration($this->service_name); |
| | | if ($config->getContent_type() && $config->getContent_type() === $product_type) { |
| | | $post_type = jvbCheckBase($registrar->getSlug()); |
| | | break; |
| | | } |
| | | |
| | | } |
| | | |
| | | if (!$post_type) { |
| | | return false; |
| | | } |
| | | |
| | | // Check if item already exists |
| | | $existing = get_posts([ |
| | | 'post_type' => $post_type, |
| | | 'meta_key' => BASE . '_square_catalog_id', |
| | | 'meta_value' => $item['id'], |
| | | 'posts_per_page' => 1 |
| | | ]); |
| | | |
| | | $post_data = [ |
| | | 'post_title' => $item['item_data']['name'] ?? '', |
| | | 'post_content' => $item['item_data']['description'] ?? '', |
| | | 'post_type' => $post_type, |
| | | 'post_status' => 'publish' |
| | | ]; |
| | | |
| | | if (!empty($existing)) { |
| | | $post_data['ID'] = $existing[0]->ID; |
| | | $post_id = wp_update_post($post_data); |
| | | } else { |
| | | $post_id = wp_insert_post($post_data); |
| | | } |
| | | |
| | | if (!$post_id || is_wp_error($post_id)) { |
| | | return false; |
| | | } |
| | | |
| | | // Map and save meta fields |
| | | $this->mapSquareFieldsToWordPress($post_id, $item); |
| | | |
| | | return $post_id; |
| | | } |
| | | |
| | | /** |
| | | * Map Square fields to WordPress meta |
| | | */ |
| | | private function mapSquareFieldsToWordPress(int $post_id, array $item): void |
| | | { |
| | | $meta = Meta::forPost($post_id); |
| | | $field_map = $this->getFieldMapping(get_post_type($post_id)); |
| | | |
| | | $values_to_save = []; |
| | | |
| | | // Save Square ID |
| | | $values_to_save['_square_catalog_id'] = $item['id']; |
| | | $values_to_save['_square_last_sync'] = current_time('mysql'); |
| | | |
| | | // Handle variations if present |
| | | if (!empty($item['item_data']['variations'])) { |
| | | $variations_data = []; |
| | | |
| | | foreach ($item['item_data']['variations'] as $index => $variation) { |
| | | $var_data = [ |
| | | 'name' => $variation['item_variation_data']['name'] ?? '', |
| | | 'sku' => $variation['item_variation_data']['_square_sku'] ?? '', |
| | | ]; |
| | | |
| | | // Extract price |
| | | if (!empty($variation['item_variation_data']['price_money'])) { |
| | | $var_data['price'] = $variation['item_variation_data']['price_money']['amount'] / 100; |
| | | } |
| | | |
| | | // Extract other variation fields |
| | | $variation_map = $this->getVariationMapping(get_post_type($post_id)); |
| | | foreach ($variation_map as $square_field => $wp_field) { |
| | | if (isset($variation['item_variation_data'][$square_field])) { |
| | | $var_data[$wp_field] = $variation['item_variation_data'][$square_field]; |
| | | } |
| | | } |
| | | |
| | | $variations_data[] = $var_data; |
| | | |
| | | // Save variation Square ID |
| | | update_post_meta($post_id, BASE . '_square_variation_' . $index . '_id', $variation['id']); |
| | | } |
| | | |
| | | $values_to_save['product_variations'] = $variations_data; |
| | | } |
| | | |
| | | // Map other fields |
| | | foreach ($field_map as $square_field => $wp_field) { |
| | | if ($square_field !== 'price' && isset($item['item_data'][$square_field])) { |
| | | $values_to_save[$wp_field] = $item['item_data'][$square_field]; |
| | | } |
| | | } |
| | | |
| | | // Save all values at once |
| | | $meta->setAll($values_to_save); |
| | | } |
| | | |
| | | /** |
| | | * Process customer sync |
| | | */ |
| | | private function processSyncCustomer(array $data): array |
| | | { |
| | | $user_id = $data['user_id'] ?? 0; |
| | | |
| | | if (!$user_id) { |
| | | return [ |
| | | 'success' => false, |
| | | 'result' => ['error' => 'No user ID provided'] |
| | | ]; |
| | | } |
| | | |
| | | $user = get_user_by('ID', $user_id); |
| | | if (!$user) { |
| | | return [ |
| | | 'success' => false, |
| | | 'result' => ['error' => 'User not found'] |
| | | ]; |
| | | } |
| | | |
| | | // Get or create Square customer |
| | | $square_customer_id = $this->getOrCreateSquareCustomer([ |
| | | 'email' => $user->user_email, |
| | | 'name' => $user->display_name |
| | | ]); |
| | | |
| | | if ($square_customer_id) { |
| | | update_user_meta($user_id, BASE . '_square_customer_id', $square_customer_id); |
| | | |
| | | return [ |
| | | 'success' => true, |
| | | 'result' => ['customer_id' => $square_customer_id] |
| | | ]; |
| | | } |
| | | |
| | | return [ |
| | | 'success' => false, |
| | | 'result' => ['error' => 'Could not sync customer'] |
| | | ]; |
| | | } |
| | | |
| | | /** |
| | | * Handle catalog webhook |
| | | */ |
| | | private function handleCatalogWebhook(array $data): bool |
| | | { |
| | | // Queue sync from Square for updated items |
| | | $this->queueOperation('import_catalog', [ |
| | | 'types' => ['ITEM'], |
| | | 'updated_at' => $data['object']['catalog_version']['updated_at'] ?? null |
| | | ], [ |
| | | 'delay' => 60 // Wait a minute to batch multiple updates |
| | | ]); |
| | | |
| | | return true; |
| | | } |
| | | |
| | | /** |
| | | * Handle customer webhook |
| | | */ |
| | | private function handleCustomerWebhook(array $data): bool |
| | | { |
| | | $square_customer_id = $data['object']['customer']['id'] ?? ''; |
| | | $email = $data['object']['customer']['email_address'] ?? ''; |
| | | |
| | | if (!$square_customer_id || !$email) { |
| | | return false; |
| | | } |
| | | |
| | | // Find WordPress user with this Square customer ID |
| | | $users = get_users([ |
| | | 'meta_key' => BASE . '_square_customer_id', |
| | | 'meta_value' => $square_customer_id, |
| | | 'number' => 1 |
| | | ]); |
| | | |
| | | if (!empty($users)) { |
| | | // Update user meta with latest Square data |
| | | $user = $users[0]; |
| | | update_user_meta($user->ID, BASE . '_square_customer_updated', current_time('mysql')); |
| | | |
| | | // Clear cached customer data |
| | | $this->cache->forget('square_customer_' . $user->ID); |
| | | } |
| | | |
| | | return true; |
| | | } |
| | | |
| | | /** |
| | | * Handle payment webhook |
| | | */ |
| | | private function handlePaymentWebhook(array $data): bool |
| | | { |
| | | $payment_id = $data['object']['payment']['id'] ?? ''; |
| | | $order_id = $data['object']['payment']['order_id'] ?? ''; |
| | | $status = $data['object']['payment']['status'] ?? ''; |
| | | |
| | | if (!$payment_id) { |
| | | return false; |
| | | } |
| | | |
| | | if ($order_id) { |
| | | $order = $this->getOrderPost($order_id); |
| | | if ($order) { |
| | | Meta::forPost($order)->set('square_payment_status', $status); |
| | | } |
| | | } |
| | | |
| | | // Trigger action for other integrations |
| | | do_action(BASE . 'square_payment_updated', $payment_id, $status, $order_id, $data); |
| | | |
| | | return true; |
| | | } |
| | | |
| | | /** |
| | | * Get the appropriate API base URL |
| | | */ |
| | | protected function getApiUrl(string $endpoint, ?string $baseKey = null): string |
| | | { |
| | | return rtrim($this->apiBase[$this->environment], '/') . '/' . ltrim($endpoint, '/'); |
| | | } |
| | | |
| | | /** |
| | | * Handle importing catalog from Square |
| | | */ |
| | | protected function handleImportFromSquare(): array |
| | | { |
| | | if (!$this->locationId) { |
| | | return [ |
| | | 'success' => false, |
| | | 'message' => 'Please select a location first' |
| | | ]; |
| | | } |
| | | |
| | | $this->queueOperation('import_catalog', [ |
| | | 'types' => ['ITEM'], |
| | | 'user_id' => $this->userID |
| | | ], [ |
| | | 'priority' => 'normal' |
| | | ]); |
| | | |
| | | return [ |
| | | 'success' => true, |
| | | 'message' => 'Import from Square queued' |
| | | ]; |
| | | } |
| | | /** |
| | | * Handle syncing to Square |
| | | */ |
| | | protected function handleSyncToSquare(): array |
| | | { |
| | | if (!$this->locationId) { |
| | | return [ |
| | | 'success' => false, |
| | | 'message' => 'Please select a location first' |
| | | ]; |
| | | } |
| | | |
| | | $post_types = array_map(function($type) { |
| | | return jvbCheckBase($type); |
| | | }, $this->syncPostTypes); |
| | | |
| | | // Get all posts to sync |
| | | $posts = get_posts([ |
| | | 'post_type' => $post_types, |
| | | 'posts_per_page' => -1, |
| | | 'post_status' => 'publish' |
| | | ]); |
| | | |
| | | $post_ids = wp_list_pluck($posts, 'ID'); |
| | | |
| | | // Queue sync operation |
| | | $this->queueOperation('sync_to_square', [ |
| | | 'items' => $post_ids, |
| | | 'user_id' => $this->userID |
| | | ], [ |
| | | 'priority' => 'normal' |
| | | ]); |
| | | |
| | | return [ |
| | | 'success' => true, |
| | | 'message' => sprintf('Queued %d items for sync to Square', count($post_ids)) |
| | | ]; |
| | | } |
| | | /** |
| | | * Refresh category mappings from Square |
| | | */ |
| | | protected function handleRefreshCategories(): array |
| | | { |
| | | // Refresh category mappings from Square |
| | | $response = $this->getRequest('catalog/list?types=CATEGORY'); |
| | | |
| | | if (!is_wp_error($response) && isset($response['objects'])) { |
| | | $count = 0; |
| | | foreach ($response['objects'] as $category) { |
| | | if ($category['type'] === 'CATEGORY') { |
| | | $name = $category['category_data']['name'] ?? ''; |
| | | if ($name) { |
| | | update_option( |
| | | BASE . 'square_category_' . sanitize_title($name), |
| | | $category['id'] |
| | | ); |
| | | $count++; |
| | | } |
| | | } |
| | | } |
| | | return [ |
| | | 'success' => true, |
| | | 'message' => sprintf('Refreshed %d categories', $count), |
| | | 'count' => $count |
| | | ]; |
| | | } else { |
| | | return [ |
| | | 'success' => false, |
| | | 'message' => 'Failed to refresh categories' |
| | | ]; |
| | | } |
| | | } |
| | | /** |
| | | * Get available Square locations |
| | | */ |
| | | protected function handleGetLocations(): array |
| | | { |
| | | // Make sure we have an access token |
| | | if (empty($this->access_token)) { |
| | | return [ |
| | | 'success' => false, |
| | | 'message' => 'Access token required to fetch locations' |
| | | ]; |
| | | } |
| | | |
| | | // Fetch available Square locations |
| | | $response = $this->getRequest('locations'); |
| | | |
| | | if (!is_wp_error($response) && isset($response['locations'])) { |
| | | $locations = array_map(function($loc) { |
| | | return [ |
| | | 'id' => $loc['id'], |
| | | 'name' => $loc['name'] ?? 'Unnamed Location', |
| | | 'address' => $loc['address']['address_line_1'] ?? '', |
| | | 'status' => $loc['status'] ?? 'ACTIVE' |
| | | ]; |
| | | }, $response['locations']); |
| | | |
| | | // Filter to only active locations |
| | | $active_locations = array_filter($locations, function($loc) { |
| | | return $loc['status'] === 'ACTIVE'; |
| | | }); |
| | | |
| | | // Format for select field |
| | | if (isset($_REQUEST['for_select'])) { |
| | | $options = []; |
| | | foreach ($active_locations as $location) { |
| | | $label = $location['name']; |
| | | if ($location['address']) { |
| | | $label .= ' - ' . $location['address']; |
| | | } |
| | | $options[$location['id']] = $label; |
| | | } |
| | | |
| | | return [ |
| | | 'success' => true, |
| | | 'options' => $options |
| | | ]; |
| | | } |
| | | |
| | | return [ |
| | | 'success' => true, |
| | | 'locations' => array_values($active_locations) |
| | | ]; |
| | | } |
| | | |
| | | return [ |
| | | 'success' => false, |
| | | 'message' => 'Failed to fetch locations. Please check your access token and environment settings.' |
| | | ]; |
| | | } |
| | | |
| | | protected function validateCredentials(array $credentials): bool |
| | | { |
| | | // For OAuth services, we need different validation based on setup stage |
| | | if ($this->isOAuthService) { |
| | | // Stage 1: Initial setup (need app credentials) |
| | | $hasAppCredentials = !empty($credentials['client_id']) |
| | | && !empty($credentials['client_secret']); |
| | | |
| | | if (!$hasAppCredentials) { |
| | | $this->logError('Missing required Square application credentials', [ |
| | | 'has_app_id' => !empty($credentials['client_id']), |
| | | 'has_app_secret' => !empty($credentials['client_secret']) |
| | | ]); |
| | | return false; |
| | | } |
| | | |
| | | // Stage 2: After OAuth (should have access token) |
| | | // Access token might not exist yet if we're just saving app credentials |
| | | // before OAuth flow, so we only validate format if it exists |
| | | if (!empty($credentials['access_token'])) { |
| | | // Validate token format (Square tokens are long alphanumeric strings) |
| | | if (!is_string($credentials['access_token']) || |
| | | strlen($credentials['access_token']) < 20) { |
| | | $this->logError('Invalid access token format'); |
| | | return false; |
| | | } |
| | | |
| | | // Validate merchant_id if present (should be set after OAuth) |
| | | if (!empty($credentials['merchant_id'])) { |
| | | if (!is_string($credentials['merchant_id']) || |
| | | strlen($credentials['merchant_id']) < 10) { |
| | | $this->logError('Invalid merchant ID format'); |
| | | return false; |
| | | } |
| | | } |
| | | } |
| | | |
| | | // Validate environment setting |
| | | if (isset($credentials['environment'])) { |
| | | $validEnvironments = ['sandbox', 'production']; |
| | | if (!in_array($credentials['environment'], $validEnvironments)) { |
| | | $this->logError('Invalid environment setting', [ |
| | | 'provided' => $credentials['environment'], |
| | | 'valid' => $validEnvironments |
| | | ]); |
| | | return false; |
| | | } |
| | | } |
| | | |
| | | return true; |
| | | } |
| | | |
| | | // Fallback for non-OAuth (shouldn't happen for Square, but good practice) |
| | | return !empty($credentials); |
| | | } |
| | | |
| | | |
| | | protected function sanitizeCredentials(array $credentials): array |
| | | { |
| | | $sanitized = []; |
| | | |
| | | // Define which fields should be sanitized and how |
| | | $text_fields = [ |
| | | 'client_id', |
| | | 'client_secret', |
| | | 'access_token', |
| | | 'refresh_token', |
| | | 'merchant_id', |
| | | 'location_id', |
| | | 'webhook_signature_key' |
| | | ]; |
| | | |
| | | foreach ($credentials as $key => $value) { |
| | | // Handle text fields - trim whitespace but preserve case |
| | | if (in_array($key, $text_fields)) { |
| | | if (is_string($value)) { |
| | | $sanitized[$key] = trim($value); |
| | | } else { |
| | | $sanitized[$key] = $value; |
| | | } |
| | | } |
| | | // Handle environment field |
| | | elseif ($key === 'environment') { |
| | | $sanitized[$key] = sanitize_key($value); |
| | | } |
| | | // Handle boolean fields |
| | | elseif ($key === 'use_sandbox' || str_starts_with($key, 'enable_')) { |
| | | $sanitized[$key] = (bool) $value; |
| | | } |
| | | // Handle numeric fields |
| | | elseif ($key === 'expires_at' || $key === 'updated_at' || $key === 'expires_in') { |
| | | $sanitized[$key] = is_numeric($value) ? (int) $value : $value; |
| | | } |
| | | // Handle arrays (like locations) |
| | | elseif (is_array($value)) { |
| | | $sanitized[$key] = array_map('sanitize_text_field', $value); |
| | | } |
| | | // Default sanitization for other string fields |
| | | else { |
| | | if (is_string($value)) { |
| | | $sanitized[$key] = sanitize_text_field($value); |
| | | } else { |
| | | $sanitized[$key] = $value; |
| | | } |
| | | } |
| | | } |
| | | |
| | | return $sanitized; |
| | | } |
| | | |
| | | public function getServiceDescription(): string |
| | | { |
| | | return "Connect with Square for payment processing, inventory management, and customer synchronization."; |
| | | } |
| | | |
| | | public function setContentTypes(): void |
| | | { |
| | | $base = $this->setBaseFields(); |
| | | $this->contentTypes = [ |
| | | 'REGULAR' => $base, |
| | | 'FOOD_AND_BEV' => array_merge($base, $this->setFoodAndBevFields()), |
| | | 'APPOINTMENTS_SERVICE' => array_merge($this->setAppointmentServiceFields()), |
| | | 'EVENT' => array_merge($this->setEventFields()), |
| | | 'GIFT_CARD' => array_merge($this->setGiftCardFields()) |
| | | ]; |
| | | } |
| | | public function getAdditionalFields(?string $content_type = null):array { |
| | | if ($content_type === 'customer') { |
| | | return $this->getCustomerFields(); |
| | | } |
| | | if ($content_type && array_key_exists($content_type, $this->contentTypes)){ |
| | | $array = $this->contentTypes[$content_type]; |
| | | return array_combine( |
| | | array_map(fn($k) => '_square_' . $k, array_keys($array)), |
| | | $array |
| | | ); |
| | | } else if ($content_type && !array_key_exists($content_type, $this->contentTypes)) { |
| | | error_log('Could not get default fields for '.$this->service_name.' content type: '.$content_type); |
| | | return []; |
| | | } |
| | | $array = $this->setBaseFields(); |
| | | return array_combine( |
| | | array_map(fn($k) => '_square_' . $k, array_keys($array)), |
| | | $array |
| | | ); |
| | | } |
| | | |
| | | protected function getCustomerFields():array |
| | | { |
| | | return [ |
| | | 'customer_id' => [ |
| | | 'type' => 'text', |
| | | 'label' => 'Square Customer ID', |
| | | 'hidden'=> true, |
| | | 'section'=> 'your-account' |
| | | ], |
| | | 'address' => [ |
| | | 'type' => 'group', |
| | | 'fields' => [ |
| | | 'address_line_1' => [ |
| | | 'type' => 'text', |
| | | 'label' => 'Address Line 1', |
| | | 'hint' => 'ex: 6551 111 St NW', |
| | | 'required' => true, |
| | | 'section' => 'address' |
| | | ], |
| | | 'address_line_2' => [ |
| | | 'type' => 'text', |
| | | 'label' => 'Address Line 2', |
| | | 'hint' => 'ex: Unit 2', |
| | | 'section' => 'address' |
| | | ], |
| | | 'locality' => [ |
| | | 'type' => 'text', |
| | | 'label'=> 'City', |
| | | 'section' => 'address', |
| | | 'required' => true, |
| | | ], |
| | | 'administrative_district_level_1' => [ |
| | | 'type' => 'text', |
| | | 'label' => 'Province', |
| | | 'hint' => 'The two-character code, example: AB', |
| | | 'default'=> 'AB', |
| | | 'section' => 'address', |
| | | 'required' => true, |
| | | ], |
| | | 'postal_code' => [ |
| | | 'type' => 'text', |
| | | 'label' => 'Postal Code', |
| | | 'section'=> 'address' |
| | | ], |
| | | 'country' => [ |
| | | 'type' => 'text', |
| | | 'label' => 'Country Code', |
| | | 'hint' => 'The tw-character country code, example: CA', |
| | | 'default' => 'CA', |
| | | 'section' => 'address', |
| | | 'required' => true, |
| | | ], |
| | | 'first_name' => [ |
| | | 'type' => 'text', |
| | | 'label' => 'First Name (if different)', |
| | | 'hint' => 'Optional field to save the address to someone that isn\'t you' |
| | | ], |
| | | 'last_name' => [ |
| | | 'type' => 'text', |
| | | 'label' => 'Last Name (if different)', |
| | | 'hint' => 'Optional field to save the address to someone that isn\'t you' |
| | | ] |
| | | ] |
| | | ] |
| | | |
| | | ]; |
| | | } |
| | | protected function setBaseFields():array |
| | | { |
| | | return [ |
| | | 'price' => [ |
| | | 'type' => 'number', |
| | | 'label' => 'Price', |
| | | 'step' => 0.01, |
| | | 'max' => 99999, |
| | | 'description' => 'Price for this variation' |
| | | ], |
| | | 'sku' => [ |
| | | 'type' => 'text', |
| | | 'label' => 'SKU', |
| | | 'description' => 'Stock keeping unit' |
| | | ], |
| | | 'cart_quantity' => [ |
| | | 'type' => 'number', |
| | | 'label' => 'Quantity', |
| | | 'hidden' => true, |
| | | ], |
| | | 'available_for_pickup' => [ |
| | | 'type' => 'true_false', |
| | | 'label' => 'Available for Pick Up', |
| | | 'section'=> 'square-advanced' |
| | | ], |
| | | 'available_online' => [ |
| | | 'type' => 'true_false', |
| | | 'label' => 'Available online', |
| | | 'section'=> 'square-advanced' |
| | | ], |
| | | 'product_variations' => [ |
| | | 'type' => 'repeater', |
| | | 'label' => 'Product Variations', |
| | | 'description' => 'Different versions of this product (sizes, colors, etc.)', |
| | | 'add_label' => 'Add Variation', |
| | | 'section' => 'variations', |
| | | 'fields' => $this->setVariationFields() |
| | | ], |
| | | ]; |
| | | } |
| | | |
| | | protected function setVariationFields(?string $type = null):array |
| | | { |
| | | $fields = [ |
| | | 'name' => [ |
| | | 'type' => 'text', |
| | | 'label' => 'Variation Name', |
| | | 'description' => 'e.g., "Small", "Large", "Red", etc.' |
| | | ], |
| | | 'sku' => [ |
| | | 'type' => 'text', |
| | | 'label' => 'SKU', |
| | | 'description' => 'Stock keeping unit' |
| | | ], |
| | | 'price' => [ |
| | | 'type' => 'number', |
| | | 'label' => 'Price', |
| | | 'step' => 0.01, |
| | | 'max' => 99999, |
| | | 'description' => 'Price for this variation' |
| | | ], |
| | | 'track_inventory' => [ |
| | | 'type' => 'true_false', |
| | | 'label' => 'Track Inventory', |
| | | ], |
| | | 'item_id' => [ |
| | | 'type' => 'text', |
| | | 'label' => 'Square Variation ID', |
| | | 'description' => 'Square catalog ID for this variation', |
| | | 'hidden' => true |
| | | ], |
| | | 'last_sync' => [ |
| | | 'type' => 'datetime', |
| | | 'label' => 'Last Sync', |
| | | 'hidden' => true |
| | | ] |
| | | ]; |
| | | |
| | | switch ($type) { |
| | | case 'FOOD_AND_BEV': |
| | | $fields['ingredients'] = [ |
| | | 'type' => 'textarea', |
| | | 'label' => 'Ingredients List', |
| | | 'description' => 'Separate ingredients with commas', |
| | | ]; |
| | | $fields['preparation_time_duration'] = [ |
| | | 'type' => 'number', |
| | | 'label' => 'Preparation time (in minutes)', |
| | | ]; |
| | | break; |
| | | case 'GIFT_CARD': |
| | | $fields['gift_card_type'] = [ |
| | | 'type' => 'select', |
| | | 'label' => 'Gift Card Type', |
| | | 'options' => [ |
| | | 'PHYSICAL' => 'Physical', |
| | | 'DIGITAL' => 'Digital', |
| | | ], |
| | | 'default' => 'DIGITAL', |
| | | ]; |
| | | break; |
| | | case 'APPOINTMENTS_SERVICE': |
| | | $fields['service_duration'] = [ |
| | | 'type' => 'number', |
| | | 'label' => 'Duration of Service in Minutes' |
| | | ]; |
| | | $fields['available_for_booking'] = [ |
| | | 'type' => 'true_false', |
| | | 'label' => 'Available for Booking' |
| | | ]; |
| | | break; |
| | | } |
| | | return array_combine( |
| | | array_map(fn($k) => '_square_' . $k, array_keys($fields)), |
| | | $fields |
| | | ); |
| | | } |
| | | protected function setFoodAndBevFields():array |
| | | { |
| | | return [ |
| | | 'ingredients' => [ |
| | | 'type' => 'textarea', |
| | | 'label' => 'Ingredients', |
| | | 'hint' => 'A comma separated list of ingredients' |
| | | ], |
| | | 'preparation_time_duration' => [ |
| | | 'label' => 'Preparation Time', |
| | | 'type' => 'number', |
| | | 'hint' => 'Preparation time in minutes' |
| | | ], |
| | | 'product_variations' => [ |
| | | 'type' => 'repeater', |
| | | 'label' => 'Product Variations', |
| | | 'description' => 'Different versions of this product (sizes, colors, etc.)', |
| | | 'add_label' => 'Add Variation', |
| | | 'section' => 'variations', |
| | | 'fields' => $this->setVariationFields('FOOD_AND_BEV') |
| | | ], |
| | | ]; |
| | | /* TODO: Set definitions for: |
| | | 'dietary_preferences' => [ |
| | | |
| | | ], |
| | | 'calories_text' => [ |
| | | |
| | | ], |
| | | */ |
| | | } |
| | | protected function setAppointmentServiceFields():array |
| | | { |
| | | return [ |
| | | |
| | | 'product_variations' => [ |
| | | 'type' => 'repeater', |
| | | 'label' => 'Product Variations', |
| | | 'description' => 'Different versions of this product (sizes, colors, etc.)', |
| | | 'add_label' => 'Add Variation', |
| | | 'section' => 'variations', |
| | | 'fields' => $this->setVariationFields('APPOINTMENTS_SERVICE') |
| | | ], |
| | | ]; |
| | | } |
| | | protected function setEventFields():array |
| | | { |
| | | return [ |
| | | 'venue_details' => [ |
| | | 'type' => 'textarea', |
| | | 'label' => 'Venue Details', |
| | | ], |
| | | 'capacity' => [ |
| | | 'type' => 'number', |
| | | 'label' => 'Capacity' |
| | | ], |
| | | 'product_variations' => [ |
| | | 'type' => 'repeater', |
| | | 'label' => 'Product Variations', |
| | | 'description' => 'Different versions of this product (sizes, colors, etc.)', |
| | | 'add_label' => 'Add Variation', |
| | | 'section' => 'variations', |
| | | 'fields' => $this->setVariationFields('EVENT') |
| | | ], |
| | | ]; |
| | | } |
| | | protected function setGiftCardFields():array |
| | | { |
| | | return [ |
| | | // 'allowed_locations' => [ |
| | | // |
| | | // ], |
| | | 'product_variations' => [ |
| | | 'type' => 'repeater', |
| | | 'label' => 'Product Variations', |
| | | 'description' => 'Different versions of this product (sizes, colors, etc.)', |
| | | 'add_label' => 'Add Variation', |
| | | 'section' => 'variations', |
| | | 'fields' => $this->setVariationFields('GIFT_CARD') |
| | | ], |
| | | ]; |
| | | } |
| | | |
| | | /********************************************************* |
| | | IMAGE PROCESSING |
| | | *********************************************************/ |
| | | /** |
| | | * Upload image file to Square |
| | | * |
| | | * @param int $imgID WordPress attachment ID |
| | | * @return array|WP_Error Upload result or error |
| | | */ |
| | | protected function uploadImageToSquare(int $imgID): array|WP_Error |
| | | { |
| | | $supported_image_id = $this->getSupportedImage($imgID); |
| | | |
| | | // Check if already uploaded |
| | | $existing_square_image_id = $this->getSquareImageId($supported_image_id); |
| | | if ($existing_square_image_id) { |
| | | return [ |
| | | 'success' => true, |
| | | 'image_id' => $existing_square_image_id |
| | | ]; |
| | | } |
| | | |
| | | $file_path = get_attached_file($supported_image_id); |
| | | if (!file_exists($file_path)) { |
| | | return new WP_Error('file_not_found', 'Image file not found'); |
| | | } |
| | | |
| | | // Verify file type |
| | | $mime_type = get_post_mime_type($supported_image_id); |
| | | if (!in_array($mime_type, ['image/jpeg', 'image/png', 'image/gif'])) { |
| | | return new WP_Error('invalid_type', 'Square only supports JPEG, PNG, and GIF images'); |
| | | } |
| | | |
| | | $image_title = get_the_title($supported_image_id); |
| | | $alt_text = get_post_meta($supported_image_id, '_wp_attachment_image_alt', true); |
| | | |
| | | // Build multipart request - SINGLE STEP |
| | | $boundary = wp_generate_password(24); |
| | | $headers = $this->getRequestHeaders(); |
| | | $headers['Content-Type'] = 'multipart/form-data; boundary=' . $boundary; |
| | | |
| | | // Request JSON part |
| | | $request_json = [ |
| | | 'idempotency_key' => wp_generate_uuid4(), |
| | | 'image' => [ |
| | | 'type' => 'IMAGE', |
| | | 'id' => '#IMAGE_' . $supported_image_id . '_' . time(), |
| | | 'image_data' => [ |
| | | 'name' => $image_title ?: 'Image', |
| | | 'caption' => $alt_text ?: '' |
| | | ] |
| | | ], |
| | | 'object_id' => $supported_image_id |
| | | ]; |
| | | |
| | | $body = $this->buildMultipartBody($file_path, $request_json, $boundary); |
| | | |
| | | $response = wp_remote_post( |
| | | $this->getApiUrl('v2/catalog/images'), |
| | | [ |
| | | 'headers' => $headers, |
| | | 'body' => $body, |
| | | 'timeout' => 60 |
| | | ] |
| | | ); |
| | | |
| | | if (is_wp_error($response)) { |
| | | return $response; |
| | | } |
| | | |
| | | $data = json_decode(wp_remote_retrieve_body($response), true); |
| | | |
| | | if (!empty($data['errors'])) { |
| | | return new WP_Error('upload_error', $data['errors'][0]['detail'] ?? 'Unknown error'); |
| | | } |
| | | |
| | | if (!empty($data['image']['id'])) { |
| | | $this->setSquareImageId($supported_image_id, $data['image']['id']); |
| | | return [ |
| | | 'success' => true, |
| | | 'image_id' => $data['image']['id'] |
| | | ]; |
| | | } |
| | | |
| | | return new WP_Error('upload_failed', 'Failed to upload image'); |
| | | } |
| | | |
| | | protected function buildMultipartBody(string $file_path, array $request_json, string $boundary): string |
| | | { |
| | | $eol = "\r\n"; |
| | | $body = ''; |
| | | |
| | | // Add request JSON part |
| | | $body .= '--' . $boundary . $eol; |
| | | $body .= 'Content-Disposition: form-data; name="request"' . $eol; |
| | | $body .= 'Content-Type: application/json' . $eol . $eol; |
| | | $body .= json_encode($request_json) . $eol; |
| | | |
| | | // Add image file part |
| | | $filename = basename($file_path); |
| | | $file_contents = file_get_contents($file_path); |
| | | $mime_type = mime_content_type($file_path); |
| | | |
| | | $body .= '--' . $boundary . $eol; |
| | | $body .= 'Content-Disposition: form-data; name="file"; filename="' . $filename . '"' . $eol; |
| | | $body .= 'Content-Type: ' . $mime_type . $eol . $eol; |
| | | $body .= $file_contents . $eol; |
| | | $body .= '--' . $boundary . '--' . $eol; |
| | | |
| | | return $body; |
| | | } |
| | | |
| | | /** |
| | | * Attach image to catalog item |
| | | * |
| | | * @param int $postID WordPress post ID |
| | | * @param string $square_image_id Square image ID |
| | | * @return array|WP_Error Result or error |
| | | */ |
| | | public function attachImageToCatalogItem(int $postID, string $square_image_id): array|WP_Error |
| | | { |
| | | $square_catalog_id = get_post_meta($postID, BASE . '_square_catalog_id', true); |
| | | |
| | | if (!$square_catalog_id) { |
| | | return new WP_Error('no_catalog_id', 'Post has not been synced to Square'); |
| | | } |
| | | |
| | | // Get the current catalog item |
| | | $response = $this->getRequest('catalog/object/' . $square_catalog_id); |
| | | |
| | | if (is_wp_error($response)) { |
| | | return $response; |
| | | } |
| | | |
| | | if (empty($response['object'])) { |
| | | return new WP_Error('item_not_found', 'Catalog item not found in Square'); |
| | | } |
| | | |
| | | $catalog_object = $response['object']; |
| | | |
| | | // Add image to item data |
| | | $catalog_object['item_data']['image_ids'] = [$square_image_id]; |
| | | |
| | | // Update the catalog item |
| | | $update_response = $this->postRequest('catalog/batch-upsert', [ |
| | | 'idempotency_key' => wp_generate_uuid4(), |
| | | 'batches' => [[ |
| | | 'objects' => [$catalog_object] |
| | | ]] |
| | | ]); |
| | | |
| | | if (is_wp_error($update_response)) { |
| | | return $update_response; |
| | | } |
| | | |
| | | return [ |
| | | 'success' => true, |
| | | 'message' => 'Image attached to catalog item' |
| | | ]; |
| | | } |
| | | |
| | | /** |
| | | * Get stored Square image ID for an attachment |
| | | * |
| | | * @param int $attachment_id WordPress attachment ID |
| | | * @return string|false Square image ID or false if not found |
| | | */ |
| | | public function getSquareImageId(int $attachment_id): string|false |
| | | { |
| | | $image_id = get_post_meta($attachment_id, BASE . 'square_image_id', true); |
| | | |
| | | if ($image_id) { |
| | | // Verify it still exists in Square |
| | | return $this->verifySquareImage($image_id) ? $image_id : false; |
| | | } |
| | | |
| | | return false; |
| | | } |
| | | |
| | | /** |
| | | * Store Square image ID for an attachment |
| | | * |
| | | * @param int $attachment_id WordPress attachment ID |
| | | * @param string $square_image_id Square image ID |
| | | * @return bool Success |
| | | */ |
| | | public function setSquareImageId(int $attachment_id, string $square_image_id): bool |
| | | { |
| | | return update_post_meta($attachment_id, BASE . 'square_image_id', $square_image_id); |
| | | } |
| | | |
| | | /** |
| | | * Verify image exists in Square catalog |
| | | * |
| | | * @param string $square_image_id Square image ID |
| | | * @return bool Whether image exists |
| | | */ |
| | | protected function verifySquareImage(string $square_image_id): bool |
| | | { |
| | | $response = $this->getRequest('catalog/object/' . $square_image_id); |
| | | |
| | | if (is_wp_error($response)) { |
| | | return false; |
| | | } |
| | | |
| | | return !empty($response['object']) && $response['object']['type'] === 'IMAGE'; |
| | | } |
| | | |
| | | /** |
| | | * Batch upload images for multiple posts |
| | | * |
| | | * @param array $post_ids Array of WordPress post IDs |
| | | * @return array Results for each post |
| | | */ |
| | | private function batchUploadImages(array $post_ids): array |
| | | { |
| | | $image_mappings = []; |
| | | $image_objects = []; |
| | | |
| | | // First pass: collect images that need uploading |
| | | foreach ($post_ids as $post_id) { |
| | | $thumbnail_id = get_post_thumbnail_id($post_id); |
| | | if (!$thumbnail_id) { |
| | | $image_mappings[$post_id] = null; |
| | | continue; |
| | | } |
| | | |
| | | // Check if already uploaded and still exists in Square |
| | | $existing_square_id = get_post_meta($thumbnail_id, BASE . '_square_image_id', true); |
| | | if ($existing_square_id && $this->verifySquareImage($existing_square_id)) { |
| | | $image_mappings[$post_id] = $existing_square_id; |
| | | continue; |
| | | } |
| | | |
| | | // Get supported image format (handles WebP conversion) |
| | | $supported_image_id = $this->getSupportedImage($thumbnail_id); |
| | | if (!$supported_image_id) { |
| | | $image_mappings[$post_id] = null; |
| | | continue; |
| | | } |
| | | |
| | | // Upload the image to Square using the proper endpoint |
| | | $square_image_id = $this->uploadSingleImageToSquare($supported_image_id, $post_id); |
| | | |
| | | if ($square_image_id && !is_wp_error($square_image_id)) { |
| | | $image_mappings[$post_id] = $square_image_id; |
| | | // Store the Square image ID for future reference |
| | | update_post_meta($thumbnail_id, BASE . '_square_image_id', $square_image_id); |
| | | } else { |
| | | $this->logError('Failed to upload image for post', [ |
| | | 'post_id' => $post_id, |
| | | 'thumbnail_id' => $thumbnail_id, |
| | | 'error' => is_wp_error($square_image_id) ? $square_image_id->get_error_message() : 'Unknown error' |
| | | ]); |
| | | $image_mappings[$post_id] = null; |
| | | } |
| | | } |
| | | |
| | | return $image_mappings; |
| | | } |
| | | |
| | | /** |
| | | * Upload a single image to Square using the CreateCatalogImage endpoint |
| | | * |
| | | * @param int $attachment_id WordPress attachment ID |
| | | * @param int $post_id Associated post ID |
| | | * @return string|WP_Error Square image ID or error |
| | | */ |
| | | private function uploadSingleImageToSquare(int $attachment_id, int $post_id): string|WP_Error |
| | | { |
| | | $file_path = get_attached_file($attachment_id); |
| | | |
| | | if (!file_exists($file_path)) { |
| | | return new WP_Error('file_not_found', 'Image file not found'); |
| | | } |
| | | |
| | | // Verify file type |
| | | $mime_type = get_post_mime_type($attachment_id); |
| | | $allowed_types = ['image/jpeg', 'image/png', 'image/gif']; |
| | | |
| | | if (!in_array($mime_type, $allowed_types)) { |
| | | return new WP_Error('invalid_type', 'Square only supports JPEG, PNG, and GIF images'); |
| | | } |
| | | |
| | | // Prepare multipart form data |
| | | $boundary = wp_generate_password(24); |
| | | $headers = $this->getRequestHeaders(); |
| | | $headers['Content-Type'] = 'multipart/form-data; boundary=' . $boundary; |
| | | |
| | | // Build the request body |
| | | $eol = "\r\n"; |
| | | $body = ''; |
| | | |
| | | // Add request JSON |
| | | $request_data = [ |
| | | 'idempotency_key' => wp_generate_uuid4(), |
| | | 'image' => [ |
| | | 'type' => 'IMAGE', |
| | | 'id' => '#TEMP_IMAGE_' . $attachment_id . '_' . time(), |
| | | 'image_data' => [ |
| | | 'name' => get_the_title($attachment_id) ?: basename($file_path), |
| | | 'caption' => get_post_meta($attachment_id, '_wp_attachment_image_alt', true) ?: |
| | | sprintf('Image for %s', get_the_title($post_id)) |
| | | ] |
| | | ] |
| | | ]; |
| | | |
| | | $body .= '--' . $boundary . $eol; |
| | | $body .= 'Content-Disposition: form-data; name="request"' . $eol; |
| | | $body .= 'Content-Type: application/json' . $eol . $eol; |
| | | $body .= json_encode($request_data) . $eol; |
| | | |
| | | // Add image file |
| | | $filename = basename($file_path); |
| | | $file_contents = file_get_contents($file_path); |
| | | |
| | | $body .= '--' . $boundary . $eol; |
| | | $body .= 'Content-Disposition: form-data; name="image"; filename="' . $filename . '"' . $eol; |
| | | $body .= 'Content-Type: ' . $mime_type . $eol . $eol; |
| | | $body .= $file_contents . $eol; |
| | | |
| | | // End boundary |
| | | $body .= '--' . $boundary . '--' . $eol; |
| | | |
| | | // Make the request to the CreateCatalogImage endpoint |
| | | $response = wp_remote_post( |
| | | $this->getApiUrl('catalog/images'), |
| | | [ |
| | | 'headers' => $headers, |
| | | 'body' => $body, |
| | | 'timeout' => 60 |
| | | ] |
| | | ); |
| | | |
| | | if (is_wp_error($response)) { |
| | | return $response; |
| | | } |
| | | |
| | | $response_body = wp_remote_retrieve_body($response); |
| | | $data = json_decode($response_body, true); |
| | | |
| | | if (!empty($data['errors'])) { |
| | | $error_message = $data['errors'][0]['detail'] ?? 'Unknown error'; |
| | | return new WP_Error('upload_error', $error_message); |
| | | } |
| | | |
| | | if (!empty($data['image']['id'])) { |
| | | $this->logDebug('Successfully uploaded image to Square', [ |
| | | 'post_id' => $post_id, |
| | | 'attachment_id' => $attachment_id, |
| | | 'square_image_id' => $data['image']['id'] |
| | | ]); |
| | | return $data['image']['id']; |
| | | } |
| | | |
| | | return new WP_Error('upload_failed', 'Failed to get image ID from Square response'); |
| | | } |
| | | |
| | | protected function handleApiError(int $code, string $body, string $endpoint): void |
| | | { |
| | | parent::handleApiError($code, $body, $endpoint); |
| | | |
| | | $decoded = json_decode($body, true); |
| | | $errorCode = $decoded['errors'][0]['code'] ?? ''; |
| | | |
| | | // Handle Square-specific OAuth errors |
| | | if ($errorCode === 'ACCESS_TOKEN_EXPIRED') { |
| | | $this->logDebug('Access token expired, attempting refresh'); |
| | | if ($this->refreshOAuthToken()) { |
| | | // Token refreshed, could retry the request |
| | | $this->logDebug('Token refreshed successfully'); |
| | | } |
| | | } elseif ($errorCode === 'UNAUTHORIZED' || $errorCode === 'ACCESS_TOKEN_REVOKED') { |
| | | $this->logError('Square authorization revoked or invalid', [ |
| | | 'error_code' => $errorCode, |
| | | 'user_id' => $this->userID |
| | | ]); |
| | | // Clear invalid credentials |
| | | $this->deleteCredentials(); |
| | | } |
| | | } |
| | | |
| | | public function createSquareOrder(array $items, ?string $customer_id, array $data): array|WP_Error |
| | | { |
| | | // Build line items for Square |
| | | $line_items = []; |
| | | foreach ($items as $item) { |
| | | $line_item = [ |
| | | 'quantity' => (string)$item['quantity'], // MUST be string! |
| | | ]; |
| | | |
| | | // Use catalog_object_id if available (recommended) |
| | | if (!empty($item['catalog_object_id'])) { |
| | | $line_item['catalog_object_id'] = $item['catalog_object_id']; |
| | | $line_item['catalog_version'] = $item['catalog_version'] ?? null; |
| | | } else { |
| | | // Ad-hoc line item (not recommended - no tax/inventory automation) |
| | | $line_item['name'] = $item['name']; |
| | | $line_item['base_price_money'] = [ |
| | | 'amount' => (int)$item['_square_price'], |
| | | 'currency' => $this->getCurrency() |
| | | ]; |
| | | } |
| | | |
| | | if (!empty($item['note'])) { |
| | | $line_item['note'] = $item['note']; |
| | | } |
| | | |
| | | $line_items[] = $line_item; |
| | | } |
| | | |
| | | $order_data = [ |
| | | 'idempotency_key' => wp_generate_uuid4(), // Different from payment idempotency key |
| | | 'order' => [ |
| | | 'location_id' => $this->locationId, |
| | | 'line_items' => $line_items, |
| | | 'state' => 'OPEN' |
| | | ] |
| | | ]; |
| | | |
| | | // Add customer if available |
| | | if ($customer_id) { |
| | | $order_data['order']['customer_id'] = $customer_id; |
| | | } |
| | | |
| | | // Add metadata |
| | | if (!empty($data['note'])) { |
| | | $order_data['order']['metadata'] = [ |
| | | 'special_instructions' => $data['note'] |
| | | ]; |
| | | } |
| | | |
| | | if (!empty($data['pickup_time'])) { |
| | | $order_data['order']['metadata']['pickup_time'] = $data['pickup_time']; |
| | | } |
| | | |
| | | return $this->postRequest('orders', $order_data); |
| | | } |
| | | |
| | | public function createSquarePayment( |
| | | string $source_id, |
| | | string $idempotency_key, |
| | | int $amount_cents, |
| | | string $order_id, |
| | | ?string $customer_id |
| | | ): array|WP_Error |
| | | { |
| | | $payment_data = [ |
| | | 'idempotency_key' => $idempotency_key, |
| | | 'source_id' => $source_id, |
| | | 'amount_money' => [ |
| | | 'amount' => $amount_cents, // Already in cents! |
| | | 'currency' => $this->getCurrency() |
| | | ], |
| | | 'order_id' => $order_id, |
| | | 'location_id' => $this->locationId, |
| | | 'autocomplete' => true, // Capture immediately |
| | | ]; |
| | | |
| | | // Add customer if available |
| | | if ($customer_id) { |
| | | $payment_data['customer_id'] = $customer_id; |
| | | } |
| | | |
| | | // Add reference ID for tracking |
| | | $payment_data['reference_id'] = 'WP_' . time(); |
| | | |
| | | return $this->postRequest('payments', $payment_data); |
| | | } |
| | | |
| | | public function saveOrderToWordPress(array $order_data): int |
| | | { |
| | | // Extract customer info |
| | | $customer_email = $order_data['customer']['email'] ?? ''; |
| | | $customer_name = $order_data['customer']['name'] ?? ''; |
| | | |
| | | // Find or create WP user for logged-in association |
| | | $user_id = 0; |
| | | if ($customer_email) { |
| | | $user = get_user_by('email', $customer_email); |
| | | if ($user) { |
| | | $user_id = $user->ID; |
| | | // Store Square customer ID on user |
| | | if (!empty($order_data['square_customer_id'])) { |
| | | update_user_meta($user_id, BASE . '_square_customer_id', $order_data['square_customer_id']); |
| | | } |
| | | } |
| | | } |
| | | |
| | | // Create order post |
| | | $order_post_id = wp_insert_post([ |
| | | 'post_type' => BASE.$this->orderPostType, |
| | | 'post_title' => 'Order #' . $order_data['square_order_id'], |
| | | 'post_status' => 'publish', |
| | | 'post_author' => $user_id // Associate with user if logged in |
| | | ]); |
| | | |
| | | if (!$order_post_id || is_wp_error($order_post_id)) { |
| | | $this->logError('Failed to create order post', ['order_data' => $order_data]); |
| | | return 0; |
| | | } |
| | | |
| | | // Save all order meta |
| | | $meta = Meta::forPost($order_post_id); |
| | | $fields = $this->getOrderFields(); |
| | | unset($fields['post_title']); |
| | | |
| | | $meta->setAll([ |
| | | 'square_order_id' => $order_data['square_order_id'], |
| | | 'square_payment_id' => $order_data['square_payment_id'] ?? '', |
| | | 'square_customer_id' => $order_data['square_customer_id'] ?? '', |
| | | 'amount' => $order_data['amount'], |
| | | 'status' => $order_data['status'], |
| | | 'fulfillment_status' => $order_data['fulfillment_status'] ?? 'PROPOSED', |
| | | 'pickup_time' => $order_data['pickup_time'] ?? '', |
| | | 'customer_email' => $customer_email, |
| | | 'customer_name' => $customer_name, |
| | | 'customer_phone' => $order_data['customer']['phone'] ?? '', |
| | | 'special_instructions' => $order_data['note'] ?? '', |
| | | 'items' => $order_data['items'], |
| | | 'receipt_url' => $order_data['receipt_url'] ?? '', |
| | | 'created_at' => current_time('mysql'), |
| | | 'updated_at' => current_time('mysql') |
| | | ]); |
| | | |
| | | return $order_post_id; |
| | | } |
| | | |
| | | /** |
| | | * Get currency code |
| | | */ |
| | | private function getCurrency(): string |
| | | { |
| | | return get_option(BASE . 'currency', 'CAD'); |
| | | } |
| | | |
| | | /** |
| | | * Get customer with saved cards (2025-compliant) |
| | | */ |
| | | public function getUserCards(string $customer_id): array |
| | | { |
| | | $response = $this->getRequest('cards?customer_id=' . $customer_id); |
| | | return $response['cards'] ?? []; |
| | | } |
| | | |
| | | |
| | | public function getUserOrders(string $customer_email): array |
| | | { |
| | | // First get Square customer ID |
| | | $customer_response = $this->postRequest('customers/search', [ |
| | | 'filter' => [ |
| | | 'email_address' => ['exact' => $customer_email] |
| | | ] |
| | | ]); |
| | | |
| | | $customer_id = $customer_response['customers'][0]['id'] ?? null; |
| | | if (!$customer_id) { |
| | | return []; |
| | | } |
| | | |
| | | // Get their orders |
| | | $orders_response = $this->postRequest('orders/search', [ |
| | | 'filter' => [ |
| | | 'customer_filter' => [ |
| | | 'customer_ids' => [$customer_id] |
| | | ] |
| | | ], |
| | | 'sort' => [ |
| | | 'sort_field' => 'CREATED_AT', |
| | | 'sort_order' => 'DESC' |
| | | ], |
| | | 'limit' => 50 |
| | | ]); |
| | | |
| | | return $orders_response['orders'] ?? []; |
| | | } |
| | | |
| | | public function checkOrderStatus(string $order_id): ?string |
| | | { |
| | | // Fetch from Square |
| | | $response = $this->getRequest('orders/' . $order_id); |
| | | if (!is_wp_error($response)) { |
| | | return $response['order']['state'] ?? null; |
| | | } |
| | | |
| | | return null; |
| | | } |
| | | |
| | | /** |
| | | * Single-item sync. Called by IntegrationExecutor::processSyncTo(). |
| | | * Delegates to syncBatchToService since Square uses batch-upsert. |
| | | */ |
| | | public function syncPostToService(int $postID): array|WP_Error |
| | | { |
| | | return $this->syncBatchToService(['items' => [$postID]]); |
| | | } |
| | | |
| | | /** |
| | | * Batch sync — preferred by IntegrationExecutor when available. |
| | | * Wraps existing processSyncToSquare which already handles batches. |
| | | */ |
| | | public function syncBatchToService(array $data): array|WP_Error |
| | | { |
| | | $result = $this->processSyncToSquare($data); |
| | | |
| | | if (empty($result['success'])) { |
| | | $errors = implode(', ', $result['result']['errors'] ?? ['Sync failed']); |
| | | return new WP_Error('square_sync_failed', $errors); |
| | | } |
| | | |
| | | return $result; |
| | | } |
| | | |
| | | /** |
| | | * Delete catalog object from Square. |
| | | * Called by IntegrationExecutor::processDeleteFrom(). |
| | | */ |
| | | public function deleteFromService(string $externalId): array|WP_Error |
| | | { |
| | | $result = $this->processDeleteFromSquare(['square_ids' => [$externalId]]); |
| | | |
| | | if (empty($result['success'])) { |
| | | return new WP_Error('square_delete_failed', $result['result']['error'] ?? 'Delete failed'); |
| | | } |
| | | |
| | | return $result; |
| | | } |
| | | |
| | | /** |
| | | * Import from Square catalog → WordPress. |
| | | * Called by IntegrationExecutor::processImport(). |
| | | */ |
| | | public function importFromService(array $data): array|WP_Error |
| | | { |
| | | $result = $this->processSyncFromSquare($data); |
| | | |
| | | if (empty($result['success'])) { |
| | | return new WP_Error('square_import_failed', $result['result']['error'] ?? 'Import failed'); |
| | | } |
| | | |
| | | return $result; |
| | | } |
| | | |
| | | /** |
| | | * Sync customer to Square. |
| | | * Called by IntegrationExecutor::processSyncCustomer(). |
| | | */ |
| | | public function syncCustomer(array $data): array|WP_Error |
| | | { |
| | | $result = $this->processSyncCustomer($data); |
| | | |
| | | if (empty($result['success'])) { |
| | | return new WP_Error('square_customer_sync_failed', $result['result']['error'] ?? 'Customer sync failed'); |
| | | } |
| | | |
| | | return $result; |
| | | } |
| | | |
| | | public function addDashboardPages():void |
| | | { |
| | | $page = JVB()->dashboard()->addPage('Your Orders', 'orders', 'receipt'); |
| | | $page->setScripts(['jvb-crud']); |
| | | } |
| | | public function renderDashPage():void |
| | | { |
| | | $this->determineUser(); |
| | | |
| | | $crud = new CRUDSkeleton(); |
| | | $crud->icon('receipt'); |
| | | $crud->title('Your Orders','Here you can see your past orders, and reorder from there if you\'d like'); |
| | | $crud->content($this->orderPostType, 'Order', 'Orders'); |
| | | $crud->addSearch(); |
| | | $crud->addCapabilities(['view']); |
| | | $crud->setEmptyState(sprintf( |
| | | '<div class="empty-state"> |
| | | <h3>%sNothing here%s</h3> |
| | | <p>It doesn\'t look like you have any orders yet.</p> |
| | | <p>Head on over to <a href="%s">our menu</a> and make your first order!</p> |
| | | </div>', |
| | | jvbDashIcon($crud->getIcon()), |
| | | jvbDashIcon($crud->getIcon()), |
| | | get_post_type_archive_link(BASE.'menu_item') |
| | | )); |
| | | |
| | | |
| | | $crud->render(); |
| | | } |
| | | |
| | | |
| | | public function getOrderPost(string $order_id):int|false |
| | | { |
| | | $posts = new WP_Query([ |
| | | 'post_type' => BASE.$this->orderPostType, |
| | | 'posts_per_page' => 1, |
| | | 'meta_key' => BASE.'square_order_id', |
| | | 'meta_value' => $order_id, |
| | | 'fields' => 'ids', |
| | | ]); |
| | | wp_reset_postdata(); |
| | | return $posts->have_posts() ? $posts[0] : false; |
| | | } |
| | | public function getOrderHistory(int $user_id):array |
| | | { |
| | | $posts = new WP_Query([ |
| | | 'post_type' => BASE.$this->orderPostType, |
| | | 'posts_per_page' => 25, |
| | | 'author' => $user_id, |
| | | 'orderby' => 'date', |
| | | 'order' => 'desc', |
| | | 'fields' => 'ids', |
| | | ]); |
| | | wp_reset_postdata(); |
| | | return array_map(function ($post) { |
| | | $fields = Meta::forPost($post); |
| | | $fields['wp_order_id'] = $post; |
| | | return $fields; |
| | | }, $posts->posts); |
| | | |
| | | } |
| | | |
| | | public function formatPrice(string|float|int $priceValue):int |
| | | { |
| | | //Convert dollars to cents |
| | | return intval(floatval($priceValue) * 100); |
| | | } |
| | | } |
| New file |
| | |
| | | <?php |
| | | namespace JVBase\integrations\services; |
| | | use JVBase\integrations\Integrations; |
| | | |
| | | if (!defined('ABSPATH')) { |
| | | exit; |
| | | } |
| | | |
| | | class UmamiOld extends Integrations |
| | | { |
| | | private string $website_id; |
| | | private string $api_url; |
| | | private string $api_token; |
| | | |
| | | // Tracking configuration |
| | | private array $valid_events = [ |
| | | 'view_feed', |
| | | 'view_taxonomy', |
| | | 'view_profile', |
| | | 'view_shop', |
| | | 'view_content', |
| | | 'toggle_favourite', |
| | | 'click_profile', |
| | | 'click_content', |
| | | 'click_taxonomy', |
| | | 'click_shop', |
| | | 'user_session' |
| | | ]; |
| | | |
| | | // Database tables |
| | | private string $events_table; |
| | | private string $metrics_table; |
| | | |
| | | protected static array $instances = []; |
| | | public static function getInstance(?int $userID = null):self |
| | | { |
| | | $key = is_null($userID) ? 'base' : $userID; |
| | | if (!array_key_exists($key, self::$instances)) { |
| | | self::$instances[$key] = new static($userID); |
| | | } |
| | | return self::$instances[$key]; |
| | | } |
| | | |
| | | protected function __construct(?int $userID = null) |
| | | { |
| | | $this->service_name = 'umami'; |
| | | $this->title = 'Umami.js'; |
| | | $this->icon = 'chart-line'; |
| | | $this->apiBase = ''; // Set dynamically based on credentials |
| | | $this->apiEndpoints = [ |
| | | 'api/websites', |
| | | 'api/websites/{websiteId}/stats', |
| | | 'api/websites/{websiteId}/pageviews', |
| | | 'api/websites/{websiteId}/events' |
| | | ]; |
| | | |
| | | $this->fields = [ |
| | | 'website_id' => [ |
| | | 'label' => 'Website ID', |
| | | 'type' => 'text', |
| | | 'placeholder'=> 'Enter Umami Website ID', |
| | | 'hint' => 'Found in your Umami dashboard under Settings → Websites.', |
| | | 'required' => true |
| | | ], |
| | | ]; |
| | | |
| | | $this->advanced = [ |
| | | 'api_url' => [ |
| | | 'label' => 'API URL', |
| | | 'type' => 'text', |
| | | 'placeholder'=> 'https://analytics.yourdomain.com', |
| | | 'hint' => 'Your Umami server URL. Leave blank if using umami.is cloud service.' |
| | | ], |
| | | 'api_token' => [ |
| | | 'label' => 'API Token (Optional)', |
| | | 'type' => 'text', |
| | | 'subtype' => 'password', |
| | | 'placeholder'=> 'Enter API Token for analytics data', |
| | | 'hint' => 'Required only if you want to retrieve analytics data for dashboards.' |
| | | ] |
| | | ]; |
| | | |
| | | $this->instructions = [ |
| | | 'Login to your <a href="https://cloud.umami.is/settings/websites" target="_blank">umami.is</a> account, select your website, and copy the website ID' |
| | | ]; |
| | | |
| | | parent::__construct(); |
| | | |
| | | $this->actions = array_merge( |
| | | $this->actions, |
| | | [ |
| | | 'refresh_data' => 'handleRefreshData' |
| | | ] |
| | | ); |
| | | |
| | | global $wpdb; |
| | | $this->events_table = $wpdb->prefix . BASE . 'umami_events'; |
| | | $this->metrics_table = $wpdb->prefix . BASE . 'performance_metrics'; |
| | | } |
| | | |
| | | /** |
| | | * Initialize service-specific properties |
| | | */ |
| | | protected function initialize(): void |
| | | { |
| | | $this->website_id = $this->credentials['website_id'] ?? ''; |
| | | $this->api_url = $this->credentials['api_url'] ?? ''; |
| | | $this->api_token = $this->credentials['api_token'] ?? ''; |
| | | $this->ttl = intval($this->credentials['cache_duration'] ?? 60) * 60; |
| | | |
| | | // Set dynamic API base if provided |
| | | if (!empty($this->api_url)) { |
| | | $this->apiBase = rtrim($this->api_url, '/'); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Register additional WordPress hooks |
| | | */ |
| | | protected function registerAdditionalHooks(): void |
| | | { |
| | | // Add tracking script to frontend |
| | | add_action('wp_head', [$this, 'renderTrackingScript'], 5); |
| | | |
| | | // Add user session tracking |
| | | add_action('wp_footer', [$this, 'trackUserSession'], 100); |
| | | |
| | | // Schedule data collection if API is configured |
| | | if (!empty($this->api_token)) { |
| | | add_action('jvb_daily_umami_collection', [$this, 'collectDailyData']); |
| | | |
| | | if (!wp_next_scheduled('jvb_daily_umami_collection')) { |
| | | wp_schedule_event(time(), 'daily', 'jvb_daily_umami_collection'); |
| | | } |
| | | } |
| | | } |
| | | |
| | | /*************************************************************************** |
| | | * TRACKING SCRIPT METHODS |
| | | ***************************************************************************/ |
| | | |
| | | /** |
| | | * Render tracking script in head |
| | | */ |
| | | public function renderTrackingScript(): void |
| | | { |
| | | // Skip on local environments |
| | | if (JVB_TESTING) { |
| | | return; |
| | | } |
| | | if (!$this->isSetUp() || is_admin()) { |
| | | return; |
| | | } |
| | | |
| | | |
| | | $script_url = $this->getTrackingScriptUrl(); |
| | | $website_id = $this->getWebsiteId(); |
| | | |
| | | if (!$website_id || !$script_url) { |
| | | return; |
| | | } |
| | | |
| | | // Preconnect for performance |
| | | $domain = parse_url($script_url, PHP_URL_HOST); |
| | | echo '<link rel="preconnect" href="https://' . esc_attr($domain) . '"/>' . "\n"; |
| | | |
| | | // Add tracking script |
| | | $attributes = [ |
| | | 'defer', |
| | | 'src="' . esc_url($script_url) . '"', |
| | | 'data-website-id="' . esc_attr($website_id) . '"' |
| | | ]; |
| | | |
| | | // Add optional configuration |
| | | if (!empty($this->credentials['respect_dnt']) && $this->credentials['respect_dnt']) { |
| | | $attributes[] = 'data-do-not-track="true"'; |
| | | } |
| | | |
| | | if (!empty($this->credentials['domains'])) { |
| | | $attributes[] = 'data-domains="' . esc_attr($this->credentials['domains']) . '"'; |
| | | } |
| | | |
| | | echo '<script ' . implode(' ', $attributes) . '></script>' . "\n"; |
| | | } |
| | | |
| | | /** |
| | | * Track user session information |
| | | */ |
| | | public function trackUserSession(): void |
| | | { |
| | | if (!$this->isSetUp() || is_admin()) { |
| | | return; |
| | | } |
| | | |
| | | $attributes = $this->buildTrackingAttributes('user_session', 'pageview', [ |
| | | 'logged_in' => is_user_logged_in() ? 'true' : 'false', |
| | | 'user_type' => $this->getCurrentUserType() |
| | | ]); |
| | | |
| | | if (empty($attributes)) { |
| | | return; |
| | | } |
| | | |
| | | echo '<div ' . $this->attributesToString($attributes) . ' style="display:none"></div>' . "\n"; |
| | | |
| | | // Add page-specific tracking |
| | | $this->trackPageView(); |
| | | } |
| | | |
| | | /** |
| | | * Track specific page views |
| | | */ |
| | | private function trackPageView(): void |
| | | { |
| | | $tracker_args = []; |
| | | $event = ''; |
| | | $type = ''; |
| | | |
| | | if (is_singular([BASE . 'artist', BASE . 'partner'])) { |
| | | // Profile view |
| | | global $post; |
| | | $event = 'view_profile'; |
| | | $type = get_post_type(); |
| | | $tracker_args = [ |
| | | 'id' => get_the_ID(), |
| | | 'from' => $this->getTrafficSource(), |
| | | 'owner_id' => $post->post_author |
| | | ]; |
| | | |
| | | // Check for specific content in query |
| | | foreach (['tattoo', 'piercing', 'artwork'] as $content_type) { |
| | | if (isset($_GET[$content_type])) { |
| | | $tracker_args['item'] = sanitize_text_field($_GET[$content_type]); |
| | | break; |
| | | } |
| | | } |
| | | |
| | | } elseif (is_tax(BASE . 'shop')) { |
| | | // Shop view |
| | | $event = 'view_shop'; |
| | | $type = BASE . 'shop'; |
| | | $tracker_args = [ |
| | | 'id' => get_queried_object_id(), |
| | | 'from' => $this->getTrafficSource() |
| | | ]; |
| | | |
| | | } elseif (is_tax()) { |
| | | // Taxonomy view |
| | | $obj = get_queried_object(); |
| | | $event = 'view_taxonomy'; |
| | | $type = $obj->taxonomy; |
| | | $tracker_args = [ |
| | | 'id' => $obj->term_id, |
| | | 'from' => $this->getTrafficSource() |
| | | ]; |
| | | } |
| | | |
| | | if ($event && $type) { |
| | | $attributes = $this->buildTrackingAttributes($event, $type, $tracker_args); |
| | | echo '<div ' . $this->attributesToString($attributes) . ' style="display:none"></div>' . "\n"; |
| | | } |
| | | } |
| | | |
| | | /*************************************************************************** |
| | | * TRACKING ATTRIBUTE BUILDER METHODS |
| | | ***************************************************************************/ |
| | | |
| | | /** |
| | | * Build tracking attributes for events |
| | | */ |
| | | public function buildTrackingAttributes(string $event, string $type, array $args = []): array |
| | | { |
| | | if (!in_array($event, $this->valid_events)) { |
| | | return []; |
| | | } |
| | | |
| | | // Normalize type |
| | | $normalized_type = str_replace(BASE, '', $type); |
| | | |
| | | // Build base attributes |
| | | $attributes = [ |
| | | 'data-umami-event' => esc_attr($event), |
| | | 'data-umami-event-type' => esc_attr($normalized_type) |
| | | ]; |
| | | |
| | | // Add ID if provided |
| | | if (!empty($args['id'])) { |
| | | $attributes['data-umami-event-id'] = esc_attr($args['id']); |
| | | } |
| | | |
| | | // Add source information |
| | | if (!empty($args['source_id'])) { |
| | | $attributes['data-umami-event-source'] = esc_attr($args['source_id']); |
| | | if (!empty($args['source_type'])) { |
| | | $attributes['data-umami-event-source-type'] = esc_attr( |
| | | str_replace(BASE, '', $args['source_type']) |
| | | ); |
| | | } |
| | | } |
| | | |
| | | // Add owner information |
| | | if (!empty($args['owner_id'])) { |
| | | $attributes['data-umami-event-owner'] = esc_attr($args['owner_id']); |
| | | if (!empty($args['owner_type'])) { |
| | | $attributes['data-umami-event-owner-type'] = esc_attr( |
| | | str_replace(BASE, '', $args['owner_type']) |
| | | ); |
| | | } |
| | | } |
| | | |
| | | // Add traffic source |
| | | if (!empty($args['from'])) { |
| | | $attributes['data-umami-event-from'] = esc_attr($args['from']); |
| | | } |
| | | |
| | | // Add item reference |
| | | if (!empty($args['item'])) { |
| | | $attributes['data-umami-event-item'] = esc_attr($args['item']); |
| | | } |
| | | |
| | | // Add metadata |
| | | foreach ($args as $key => $value) { |
| | | if (in_array($key, ['id', 'source_id', 'source_type', 'owner_id', 'owner_type', 'from', 'item'])) { |
| | | continue; |
| | | } |
| | | $clean_key = 'data-umami-event-' . str_replace('_', '-', sanitize_key($key)); |
| | | $attributes[$clean_key] = esc_attr($value); |
| | | } |
| | | |
| | | return $attributes; |
| | | } |
| | | |
| | | /** |
| | | * Convert attributes array to HTML string |
| | | */ |
| | | public function attributesToString(array $attributes): string |
| | | { |
| | | $attr_strings = []; |
| | | foreach ($attributes as $key => $value) { |
| | | $attr_strings[] = $key . '="' . $value . '"'; |
| | | } |
| | | return implode(' ', $attr_strings); |
| | | } |
| | | |
| | | /** |
| | | * Public tracking methods for use in templates |
| | | */ |
| | | public function trackContentClick(int $id, string $type, array $args = []): string |
| | | { |
| | | $args['id'] = $id; |
| | | |
| | | // Auto-detect owner for content types |
| | | if (empty($args['owner_id']) && in_array($type, [BASE . 'tattoo', BASE . 'artwork', BASE . 'piercing'])) { |
| | | $post = get_post($id); |
| | | if ($post && !empty($post->post_author)) { |
| | | $args['owner_id'] = $post->post_author; |
| | | $args['owner_type'] = 'user'; |
| | | } |
| | | } |
| | | |
| | | return $this->attributesToString( |
| | | $this->buildTrackingAttributes('click_content', $type, $args) |
| | | ); |
| | | } |
| | | |
| | | public function trackProfileClick(int $id, string $type, array $args = []): string |
| | | { |
| | | $args['id'] = $id; |
| | | return $this->attributesToString( |
| | | $this->buildTrackingAttributes('click_profile', $type, $args) |
| | | ); |
| | | } |
| | | |
| | | public function trackTaxonomyClick(int $id, string $taxonomy, array $args = []): string |
| | | { |
| | | $args['id'] = $id; |
| | | return $this->attributesToString( |
| | | $this->buildTrackingAttributes('click_taxonomy', $taxonomy, $args) |
| | | ); |
| | | } |
| | | |
| | | public function trackContentTaxonomyClick(int $id, string $taxonomy, array $args = []): string |
| | | { |
| | | $args['id'] = $id; |
| | | return $this->attributesToString( |
| | | $this->buildTrackingAttributes('click_' . $taxonomy, $taxonomy, $args) |
| | | ); |
| | | } |
| | | |
| | | public function trackFavouriteToggle(int $id, string $type, bool $is_favourite, array $args = []): string |
| | | { |
| | | $args['id'] = $id; |
| | | $args['action'] = $is_favourite ? 'add' : 'remove'; |
| | | return $this->attributesToString( |
| | | $this->buildTrackingAttributes('toggle_favourite', $type, $args) |
| | | ); |
| | | } |
| | | |
| | | public function trackFeedView(int $id, string $type, array $args = []): array |
| | | { |
| | | $args['id'] = $id; |
| | | return $this->buildTrackingAttributes('view_feed', $type, $args); |
| | | } |
| | | |
| | | /*************************************************************************** |
| | | * API DATA COLLECTION METHODS |
| | | ***************************************************************************/ |
| | | |
| | | /** |
| | | * Get analytics data from Umami API |
| | | */ |
| | | public function getAnalytics(string $start_date, string $end_date, bool $use_cache = true): ?array |
| | | { |
| | | if (empty($this->api_url) || empty($this->api_token)) { |
| | | return null; |
| | | } |
| | | |
| | | // Check cache first |
| | | if ($use_cache && $this->cache) { |
| | | $cache_key = md5("analytics_{$start_date}_{$end_date}"); |
| | | $cached = $this->cache->get($cache_key); |
| | | if ($cached !== false) { |
| | | return $cached; |
| | | } |
| | | } |
| | | |
| | | try { |
| | | $url = rtrim($this->api_url, '/') . "/api/websites/{$this->website_id}/stats"; |
| | | $params = [ |
| | | 'startAt' => strtotime($start_date) * 1000, |
| | | 'endAt' => strtotime($end_date) * 1000 |
| | | ]; |
| | | |
| | | $response = wp_remote_get($url . '?' . http_build_query($params), [ |
| | | 'headers' => [ |
| | | 'Authorization' => 'Bearer ' . $this->api_token, |
| | | 'Accept' => 'application/json' |
| | | ], |
| | | 'timeout' => 30 |
| | | ]); |
| | | |
| | | if (is_wp_error($response)) { |
| | | $this->logError('API request failed', ['error' => $response->get_error_message()]); |
| | | return null; |
| | | } |
| | | |
| | | $body = wp_remote_retrieve_body($response); |
| | | $data = json_decode($body, true); |
| | | |
| | | if (json_last_error() !== JSON_ERROR_NONE) { |
| | | $this->logError('Invalid JSON response from API'); |
| | | return null; |
| | | } |
| | | |
| | | // Cache the result |
| | | if ($use_cache && $this->cache) { |
| | | $this->cache->set($cache_key, $data, $this->ttl); |
| | | } |
| | | |
| | | return $data; |
| | | |
| | | } catch (Exception $e) { |
| | | $this->logError('Exception during API call', ['error' => $e->getMessage()]); |
| | | return null; |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Get page views for a specific URL |
| | | */ |
| | | public function getPageViews(string $url, string $start_date, string $end_date): ?array |
| | | { |
| | | if (empty($this->api_url) || empty($this->api_token)) { |
| | | return null; |
| | | } |
| | | |
| | | $api_endpoint = rtrim($this->api_url, '/') . "/api/websites/{$this->website_id}/pageviews"; |
| | | $params = [ |
| | | 'startAt' => strtotime($start_date) * 1000, |
| | | 'endAt' => strtotime($end_date) * 1000, |
| | | 'url' => $url, |
| | | 'unit' => 'day' |
| | | ]; |
| | | |
| | | $response = wp_remote_get($api_endpoint . '?' . http_build_query($params), [ |
| | | 'headers' => [ |
| | | 'Authorization' => 'Bearer ' . $this->api_token, |
| | | 'Accept' => 'application/json' |
| | | ], |
| | | 'timeout' => 30 |
| | | ]); |
| | | |
| | | if (is_wp_error($response)) { |
| | | return null; |
| | | } |
| | | |
| | | return json_decode(wp_remote_retrieve_body($response), true); |
| | | } |
| | | |
| | | /** |
| | | * Get custom events data |
| | | */ |
| | | public function getEvents(string $event_name = '', string $start_date = '', string $end_date = ''): ?array |
| | | { |
| | | if (empty($this->api_url) || empty($this->api_token)) { |
| | | return null; |
| | | } |
| | | |
| | | $api_endpoint = rtrim($this->api_url, '/') . "/api/websites/{$this->website_id}/events"; |
| | | |
| | | $params = []; |
| | | if ($start_date && $end_date) { |
| | | $params['startAt'] = strtotime($start_date) * 1000; |
| | | $params['endAt'] = strtotime($end_date) * 1000; |
| | | } |
| | | if ($event_name) { |
| | | $params['event'] = $event_name; |
| | | } |
| | | |
| | | $url = $api_endpoint; |
| | | if (!empty($params)) { |
| | | $url .= '?' . http_build_query($params); |
| | | } |
| | | |
| | | $response = wp_remote_get($url, [ |
| | | 'headers' => [ |
| | | 'Authorization' => 'Bearer ' . $this->api_token, |
| | | 'Accept' => 'application/json' |
| | | ], |
| | | 'timeout' => 30 |
| | | ]); |
| | | |
| | | if (is_wp_error($response)) { |
| | | return null; |
| | | } |
| | | |
| | | return json_decode(wp_remote_retrieve_body($response), true); |
| | | } |
| | | |
| | | /** |
| | | * Collect daily analytics data and store in database |
| | | */ |
| | | public function collectDailyData(): void |
| | | { |
| | | if (!$this->isSetUp() || empty($this->api_token)) { |
| | | return; |
| | | } |
| | | |
| | | $yesterday = date('Y-m-d', strtotime('-1 day')); |
| | | $data = $this->getAnalytics($yesterday, $yesterday, false); |
| | | |
| | | if (!$data) { |
| | | $this->logError('Failed to collect daily data', ['date' => $yesterday]); |
| | | return; |
| | | } |
| | | |
| | | global $wpdb; |
| | | |
| | | // Store basic metrics |
| | | $wpdb->insert($this->metrics_table, [ |
| | | 'date' => $yesterday, |
| | | 'pageviews' => $data['pageviews']['value'] ?? 0, |
| | | 'visitors' => $data['visitors']['value'] ?? 0, |
| | | 'visits' => $data['visits']['value'] ?? 0, |
| | | 'bounces' => $data['bounces']['value'] ?? 0, |
| | | 'total_time' => $data['totaltime']['value'] ?? 0, |
| | | 'created_at' => current_time('mysql') |
| | | ]); |
| | | |
| | | // Collect and store events |
| | | $events = $this->getEvents('', $yesterday, $yesterday); |
| | | if ($events) { |
| | | foreach ($events as $event) { |
| | | $wpdb->insert($this->events_table, [ |
| | | 'date' => $yesterday, |
| | | 'event_name' => $event['x'] ?? '', |
| | | 'event_count' => $event['y'] ?? 0, |
| | | 'created_at' => current_time('mysql') |
| | | ]); |
| | | } |
| | | } |
| | | |
| | | $this->logDebug('Successfully collected daily data', ['date' => $yesterday]); |
| | | } |
| | | |
| | | /*************************************************************************** |
| | | * HELPER METHODS |
| | | ***************************************************************************/ |
| | | |
| | | /** |
| | | * Get current user type for tracking |
| | | */ |
| | | private function getCurrentUserType(): string |
| | | { |
| | | if (!is_user_logged_in()) { |
| | | return 'guest'; |
| | | } |
| | | |
| | | $user = wp_get_current_user(); |
| | | $roles = $user->roles; |
| | | |
| | | if (empty($roles)) { |
| | | return 'user'; |
| | | } |
| | | |
| | | $role = array_values($roles)[0]; |
| | | return str_replace(BASE, '', $role); |
| | | } |
| | | |
| | | /** |
| | | * Determine traffic source |
| | | */ |
| | | private function getTrafficSource(): string |
| | | { |
| | | if (isset($_GET['utm_source'])) { |
| | | return sanitize_text_field($_GET['utm_source']); |
| | | } |
| | | |
| | | if (isset($_SERVER['HTTP_REFERER'])) { |
| | | $referer = $_SERVER['HTTP_REFERER']; |
| | | $site_url = get_site_url(); |
| | | |
| | | if (strpos($referer, $site_url) === false) { |
| | | $domain = parse_url($referer, PHP_URL_HOST); |
| | | return $domain ?: 'external'; |
| | | } |
| | | |
| | | return 'internal'; |
| | | } |
| | | |
| | | return 'direct'; |
| | | } |
| | | |
| | | /** |
| | | * Get website ID |
| | | */ |
| | | public function getWebsiteId(): string |
| | | { |
| | | $this->ensureInitialized(); |
| | | return $this->website_id; |
| | | } |
| | | |
| | | /** |
| | | * Get tracking script URL |
| | | */ |
| | | public function getTrackingScriptUrl(): string |
| | | { |
| | | if (!empty($this->api_url)) { |
| | | return rtrim($this->api_url, '/') . '/script.js'; |
| | | } |
| | | |
| | | // Default to Umami cloud |
| | | return 'https://cloud.umami.is/script.js'; |
| | | } |
| | | |
| | | /*************************************************************************** |
| | | * REQUIRED ABSTRACT METHOD IMPLEMENTATIONS |
| | | ***************************************************************************/ |
| | | |
| | | /** |
| | | * Get request headers for API calls |
| | | */ |
| | | protected function getRequestHeaders(): array |
| | | { |
| | | $headers = [ |
| | | 'Accept' => 'application/json', |
| | | 'Content-Type' => 'application/json' |
| | | ]; |
| | | |
| | | if (!empty($this->api_token)) { |
| | | $headers['Authorization'] = 'Bearer ' . $this->api_token; |
| | | } |
| | | |
| | | return $headers; |
| | | } |
| | | |
| | | protected function handleRefreshData():array |
| | | { |
| | | // Collect today's data |
| | | $today = date('Y-m-d'); |
| | | $data = $this->getAnalytics($today, $today, false); |
| | | |
| | | if ($data) { |
| | | // Clear cache for today |
| | | $cache_key = md5("analytics_{$today}_{$today}"); |
| | | $this->cache->forget($cache_key); |
| | | |
| | | return [ |
| | | 'success' => true, |
| | | 'message' => 'Data refreshed successfully', |
| | | 'data' => $data |
| | | ]; |
| | | } |
| | | |
| | | return [ |
| | | 'success' => false, |
| | | 'message' => 'Something went wrong...' |
| | | ]; |
| | | } |
| | | |
| | | /** |
| | | * Process queued operations |
| | | */ |
| | | public function processOperation(array|\WP_Error $result, object $operation, array $data): array|\WP_Error |
| | | { |
| | | $prefix = strtolower($this->service_name) . '_'; |
| | | |
| | | switch ($operation->type) { |
| | | case $prefix . 'collect_analytics': |
| | | $this->collectDailyData(); |
| | | return ['success' => true, 'message' => 'Analytics collected']; |
| | | |
| | | default: |
| | | return $result; |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * Test API connection |
| | | */ |
| | | protected function performConnectionTest(): bool |
| | | { |
| | | // Basic tracking only needs website ID |
| | | if (empty($this->website_id)) { |
| | | return false; |
| | | } |
| | | |
| | | // If API credentials provided, test them |
| | | if (!empty($this->api_url) && !empty($this->api_token)) { |
| | | try { |
| | | $response = wp_remote_get(rtrim($this->api_url, '/') . '/api/websites', [ |
| | | 'headers' => [ |
| | | 'Authorization' => 'Bearer ' . $this->api_token, |
| | | 'Accept' => 'application/json' |
| | | ], |
| | | 'timeout' => 10 |
| | | ]); |
| | | |
| | | if (is_wp_error($response)) { |
| | | return false; |
| | | } |
| | | |
| | | return wp_remote_retrieve_response_code($response) === 200; |
| | | |
| | | } catch (Exception $e) { |
| | | $this->logError('Connection test failed', ['error' => $e->getMessage()]); |
| | | return false; |
| | | } |
| | | } |
| | | |
| | | // Website ID exists, basic tracking will work |
| | | return true; |
| | | } |
| | | |
| | | /** |
| | | * Get service description |
| | | */ |
| | | public function getServiceDescription(): string |
| | | { |
| | | return "Privacy-focused analytics to understand your website traffic."; |
| | | } |
| | | |
| | | /** |
| | | * Create database tables for metrics storage |
| | | */ |
| | | public static function createTables(): void |
| | | { |
| | | global $wpdb; |
| | | $charset_collate = $wpdb->get_charset_collate(); |
| | | |
| | | // Events table |
| | | $events_table = $wpdb->prefix . BASE . 'umami_events'; |
| | | $events_sql = "CREATE TABLE IF NOT EXISTS $events_table ( |
| | | id bigint(20) UNSIGNED NOT NULL AUTO_INCREMENT, |
| | | date date NOT NULL, |
| | | event_name varchar(255) NOT NULL, |
| | | event_count int(11) NOT NULL DEFAULT 0, |
| | | event_data longtext, |
| | | created_at datetime NOT NULL, |
| | | PRIMARY KEY (id), |
| | | KEY date_event (date, event_name) |
| | | ) $charset_collate;"; |
| | | |
| | | // Metrics table |
| | | $metrics_table = $wpdb->prefix . BASE . 'performance_metrics'; |
| | | $metrics_sql = "CREATE TABLE IF NOT EXISTS $metrics_table ( |
| | | id bigint(20) UNSIGNED NOT NULL AUTO_INCREMENT, |
| | | date date NOT NULL, |
| | | pageviews int(11) NOT NULL DEFAULT 0, |
| | | visitors int(11) NOT NULL DEFAULT 0, |
| | | visits int(11) NOT NULL DEFAULT 0, |
| | | bounces int(11) NOT NULL DEFAULT 0, |
| | | total_time int(11) NOT NULL DEFAULT 0, |
| | | created_at datetime NOT NULL, |
| | | PRIMARY KEY (id), |
| | | UNIQUE KEY date_unique (date) |
| | | ) $charset_collate;"; |
| | | |
| | | require_once(ABSPATH . 'wp-admin/includes/upgrade.php'); |
| | | dbDelta($events_sql); |
| | | dbDelta($metrics_sql); |
| | | } |
| | | } |
| | |
| | | } |
| | | |
| | | if ($this->config->getCanSchedule()) { |
| | | $fields['schedule_'.$this->service_name] = [ |
| | | $fields['_'.$this->service_name.'_scheduled_at'] = [ |
| | | 'type' => 'datetime', |
| | | 'label' => 'Schedule for later?', |
| | | 'condition' => [ |