prefix}joryio_carts, {$wpdb->prefix}joryio_restore_links), // which have no WordPress API or object cache to go through. Table and column // names come from $wpdb->prefix and constants in this file, never from a // request; every value goes through $wpdb->prepare(). // phpcs:disable WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter // Default API endpoint (can be overridden in settings) if (!defined('JORYIO_API_ENDPOINT')) { // Every public host is region-marked; api.joryio.com does not exist. define('JORYIO_API_ENDPOINT', 'https://api-eu1.joryio.com'); } /** * Main Joryio Plugin Class */ final class Joryio { /** * The WP-Cron interval of the abandoned-cart check (every 15 minutes). * 1.6.25: prefixed (WordPress.org review); the unprefixed key earlier * versions scheduled with is LEGACY_CRON_SCHEDULE, moved over by * migrate_cron_schedule(). */ const CRON_SCHEDULE = 'joryio_fifteen_minutes'; const LEGACY_CRON_SCHEDULE = 'fifteen_minutes'; /** * 1.6.35 (WordPress.org review, guideline 11): user meta holding the * admin notices this user dismissed (notice key => 1). */ const DISMISSED_NOTICES_META = 'joryio_dismissed_notices'; /** Notices a user may dismiss. The missing-API-key error is not one: nothing reaches Joryio until it is fixed. */ const DISMISSIBLE_NOTICES = array('reconnect_store', 'signup_switched_off'); /** * The admin screens the plugin's notices appear on: the Plugins screen * and the plugin's own settings page (WooCommerce > Joryio, or * Settings > Joryio without WooCommerce). Never every admin page. */ const NOTICE_SCREENS = array('plugins', 'plugins-network', 'woocommerce_page_joryio', 'settings_page_joryio'); /** * Plugin instance * * @var Joryio */ private static $instance = null; /** * Settings instance * * @var Joryio_Settings */ public $settings; /** * Cart tracking instance * * @var Joryio_Cart */ public $cart; /** * Identity tracking instance * * @var Joryio_Identity */ public $identity; /** * Events dispatcher instance * * @var Joryio_Events */ public $events; /** * Marketing consent checkbox instance * * @var Joryio_Consent */ public $consent; /** * Joryio coupon apply link + coupon list column (1.4.0) * * @var Joryio_Coupons */ public $coupons; /** * Joryio web SDK: in-app messages + web push service worker (1.5.0) * * @var Joryio_Web_Sdk */ public $web_sdk; /** * Website forms: Contact Form 7 and Elementor Pro (1.6.0) * * @var Joryio_Forms */ public $forms; /** * Get singleton instance * * @return Joryio */ public static function instance() { if (is_null(self::$instance)) { self::$instance = new self(); } return self::$instance; } /** * Constructor */ private function __construct() { $this->includes(); $this->init_hooks(); } /** * Include required files */ private function includes() { require_once JORYIO_PLUGIN_DIR . 'includes/class-joryio-checkout-optin-report.php'; require_once JORYIO_PLUGIN_DIR . 'includes/class-joryio-visitor-identity-report.php'; require_once JORYIO_PLUGIN_DIR . 'includes/class-joryio-cart-tracking-report.php'; require_once JORYIO_PLUGIN_DIR . 'includes/class-joryio-untick-optout-report.php'; require_once JORYIO_PLUGIN_DIR . 'includes/class-joryio-settings.php'; require_once JORYIO_PLUGIN_DIR . 'includes/class-joryio-attribution-params.php'; require_once JORYIO_PLUGIN_DIR . 'includes/class-joryio-cart-restore.php'; require_once JORYIO_PLUGIN_DIR . 'includes/class-joryio-reabandon-rule.php'; require_once JORYIO_PLUGIN_DIR . 'includes/class-joryio-restore-link-rule.php'; require_once JORYIO_PLUGIN_DIR . 'includes/class-joryio-cart.php'; require_once JORYIO_PLUGIN_DIR . 'includes/class-joryio-storefront-queue.php'; require_once JORYIO_PLUGIN_DIR . 'includes/class-joryio-customer-proof.php'; require_once JORYIO_PLUGIN_DIR . 'includes/class-joryio-identity.php'; require_once JORYIO_PLUGIN_DIR . 'includes/class-joryio-product-data.php'; require_once JORYIO_PLUGIN_DIR . 'includes/class-joryio-events.php'; require_once JORYIO_PLUGIN_DIR . 'includes/class-joryio-consent.php'; require_once JORYIO_PLUGIN_DIR . 'includes/class-joryio-coupons.php'; require_once JORYIO_PLUGIN_DIR . 'includes/class-joryio-web-sdk.php'; require_once JORYIO_PLUGIN_DIR . 'includes/class-joryio-forms-payload.php'; require_once JORYIO_PLUGIN_DIR . 'includes/class-joryio-forms.php'; } /** * Initialize hooks */ private function init_hooks() { // Initialize components after plugins are loaded add_action('plugins_loaded', array($this, 'init'), 0); // Activation/deactivation hooks register_activation_hook(JORYIO_PLUGIN_FILE, array($this, 'activate')); register_deactivation_hook(JORYIO_PLUGIN_FILE, array($this, 'deactivate')); // Add settings link to plugins page add_filter('plugin_action_links_' . JORYIO_PLUGIN_BASENAME, array($this, 'add_settings_link')); // 1.6.35: "Dismiss" on a dismissible admin notice (nonce + capability). add_action('admin_post_joryio_dismiss_notice', array($this, 'dismiss_notice')); // Declare HPOS compatibility add_action('before_woocommerce_init', array($this, 'declare_hpos_compatibility')); // 1.6.16: a saved change to the checkout opt-in settings reaches // Joryio's store page at once (one validate probe at the end of the // request), not only with the next event. // 1.6.19: the tracking script and web SDK settings change the // snippet every cached page carries. foreach (array_merge(array('joryio_inject_tracking'), array_keys(Joryio_Web_Sdk::DEFAULTS)) as $option) { add_action('update_option_' . $option, array($this, 'purge_page_caches'), 10, 0); } // 1.6.22: "Recognise visitors by the email they type" too (same probe). // 1.6.24: "Track guest carts" and the abandonment time too. // 1.6.27: "An unticked opt-in box ... unsubscribes the person" too. foreach (array_merge(Joryio_Checkout_Optin_Report::options(), array(Joryio_Visitor_Identity_Report::OPTION), Joryio_Cart_Tracking_Report::OPTIONS, Joryio_Untick_Optout_Report::OPTIONS) as $option) { add_action('update_option_' . $option, array($this, 'checkout_optin_changed')); add_action('add_option_' . $option, array($this, 'checkout_optin_changed')); } } /** * @var bool A checkout opt-in setting changed in this request. */ private $checkout_optin_dirty = false; /** * A checkout opt-in setting was saved: report it once, at shutdown. */ public function checkout_optin_changed() { if ($this->checkout_optin_dirty) { return; } $this->checkout_optin_dirty = true; add_action('shutdown', array($this, 'report_checkout_optin')); } /** * GET /api/woocommerce/plugin/validate with the X-Joryio-Checkout-Optin * header (non-blocking): Joryio records what the plugin has now. */ public function report_checkout_optin() { if (!$this->is_connected() || $this->get_api_key() === '') { return; } wp_remote_get($this->get_api_endpoint() . '/api/woocommerce/plugin/validate', array( 'timeout' => 5, 'blocking' => false, 'headers' => $this->api_headers(), )); } /** * The checkout opt-in settings as stored here (Joryio_Checkout_Optin_Report). * * @return array */ public function checkout_optin_report() { return Joryio_Checkout_Optin_Report::report( JORYIO_VERSION, get_option('joryio_consent_checkout', false), get_option('joryio_consent_block_checkout', false), get_option('joryio_consent_default_checked', false), get_option('joryio_consent_record_mode', 'order') ); } /** * "Recognise visitors by the email they type" as stored here * (Joryio_Visitor_Identity_Report). Never saved = on. * * @return array */ public function visitor_identity_report() { return Joryio_Visitor_Identity_Report::report( JORYIO_VERSION, get_option(Joryio_Visitor_Identity_Report::OPTION, Joryio_Visitor_Identity_Report::DEFAULT_VALUE) ); } /** * 1.6.24: "Track guest carts" and the abandonment time as stored here * (Joryio_Cart_Tracking_Report) - Joryio's own abandoned-cart fallback * follows them. * * @return array */ public function cart_tracking_report() { return Joryio_Cart_Tracking_Report::report( JORYIO_VERSION, get_option('joryio_track_guests', true), get_option('joryio_abandonment_time', Joryio_Cart_Tracking_Report::DEFAULT_MINUTES) ); } /** * 1.6.27: "An unticked opt-in box at checkout or registration * unsubscribes the person" and "Also for guest checkouts" as stored here * (Joryio_Untick_Optout_Report) - Joryio applies them. Never saved = off. * * @return array */ public function untick_optout_report() { return Joryio_Untick_Optout_Report::report( JORYIO_VERSION, get_option(Joryio_Untick_Optout_Report::OPTION_ENABLED, false), get_option(Joryio_Untick_Optout_Report::OPTION_GUESTS, false) ); } /** * The headers of every request to Joryio: the API key, (1.6.16) the * checkout opt-in report Joryio shows read-only on the store's page and * (1.6.22) "Recognise visitors by the email they type", which Joryio * applies to this store. * * @param string|null $api_key Defaults to the saved key. * @param array $extra More headers (Content-Type). * @return array */ public function api_headers($api_key = null, array $extra = array()) { return array_merge($extra, array( 'X-Joryio-Api-Key' => $api_key === null ? $this->get_api_key() : $api_key, Joryio_Checkout_Optin_Report::HEADER => Joryio_Checkout_Optin_Report::header_value($this->checkout_optin_report()), Joryio_Visitor_Identity_Report::HEADER => Joryio_Visitor_Identity_Report::header_value($this->visitor_identity_report()), Joryio_Cart_Tracking_Report::HEADER => Joryio_Cart_Tracking_Report::header_value($this->cart_tracking_report()), Joryio_Untick_Optout_Report::HEADER => Joryio_Untick_Optout_Report::header_value($this->untick_optout_report()), )); } /** * Initialize plugin */ public function init() { // 1.6.12: plugin updates do not run the activation hook - bring the // carts table up to date once per version. $this->maybe_upgrade(); // 1.6.25: move the abandoned-cart check off the old unprefixed // interval. Runs at plugins_loaded, before WP-Cron (init), so the // event is never run on an interval WordPress no longer knows. $this->migrate_cron_schedule(); // Translations: WordPress loads them for the "joryio" text domain // itself (WordPress.org language packs, WordPress 4.6+), so the // plugin no longer calls load_plugin_textdomain() (1.6.25). // Since 1.6.0 the plugin runs on any WordPress site. Settings, the // connection, the tracking script with the web SDK, and website forms // work everywhere; the store features (cart tracking and abandonment, // order / product / customer sync, coupon links, the checkout and // registration consent checkbox) register only when WooCommerce is // active. Joryio_Events and Joryio_Cart are always created - the // background sender and the visitor session id are shared - but they // hook into WooCommerce only when it is there. $this->settings = new Joryio_Settings(); $this->events = new Joryio_Events(); $this->cart = new Joryio_Cart(); $this->identity = new Joryio_Identity(); if ($this->has_woocommerce()) { // Not gated on the connection: the evidence is recorded on the order // even while disconnected; it reaches Joryio with the next order event. $this->consent = new Joryio_Consent(); // Not gated on the connection either: a link from an earlier message // still applies its code, and the list column describes stored meta. $this->coupons = new Joryio_Coupons(); } // Serves the web push service worker; see the class comment. $this->web_sdk = new Joryio_Web_Sdk(); // Contact Form 7 and Elementor Pro forms (any site). $this->forms = new Joryio_Forms(); // Connected without WooCommerce, and WooCommerce is active now: the // store needs a reconnect to sync (REST keys are created on connect). if ($this->has_woocommerce() && $this->is_connected() && get_option('joryio_connected_with_woocommerce', 'yes') === 'no') { add_action('admin_notices', array($this, 'reconnect_for_store_notice')); } // 1.6.21: connected, but no API key saved (a Save Changes in 1.6.18 // or older erased it): nothing server side reaches Joryio and no // signed-in customer can be recognised. 1.6.35 (WordPress.org review, // guideline 11): both notices show only on the Plugins screen and the // Joryio page, to users who can fix them (show_notice_here()). if ($this->is_connected() && $this->get_api_key() === '') { add_action('admin_notices', array($this, 'missing_api_key_notice')); } // Plugin initialized action do_action('joryio_init'); } /** * Plugin activation */ public function activate() { // Create custom tables for cart tracking $this->create_tables(); // Schedule cron events (an old-interval event moves first, 1.6.25) $this->migrate_cron_schedule(); $this->schedule_events(); // Set default options $this->set_defaults(); // 1.6.35: the joryio-sw.js an older version wrote into the site root // goes (only when it is exactly that file; see Joryio_Web_Sdk). Joryio_Web_Sdk::remove_legacy_static_worker(); // Flush rewrite rules (activation only - never on a normal request). flush_rewrite_rules(); } /** * Plugin deactivation */ public function deactivate() { // Clear scheduled events wp_clear_scheduled_hook('joryio_check_abandoned_carts'); wp_clear_scheduled_hook('joryio_cleanup_old_carts'); // Flush rewrite rules (deactivation only). flush_rewrite_rules(); } /** * 1.6.12: the columns a restored cart's lineage needs * (Joryio_Reabandon_Rule) on a table created by an older version. The * CREATE TABLE above is IF NOT EXISTS, so it never alters an existing * table; each column is added once, when missing. Never throws. */ public function maybe_upgrade() { if (get_option('joryio_db_version') === JORYIO_VERSION) { return; } // 1.6.19: an update gets the defaults a fresh install gets, for the // options it has never saved - and ONLY for those: add_option never // overwrites, so every setting the store chose (the consent checkbox, // its text, the record mode...) is kept as it is // (tests/settings-upgrade-test.php upgrades a 1.4.0 install). $this->set_defaults(); // 1.6.35 (WordPress.org review): the plugin no longer writes // joryio-sw.js into the site root; the copy an older version wrote is // removed once (Joryio_Web_Sdk::remove_legacy_static_worker). Joryio_Web_Sdk::remove_legacy_static_worker(); // 1.6.41: signup forms move to the one consent-text rule, once // (Joryio_Forms::migrate_signup_rule keeps its own flag). if (class_exists('Joryio_Forms')) { Joryio_Forms::migrate_signup_rule(); } // A version change can change the storefront snippet (see purge_page_caches). $this->purge_page_caches(); global $wpdb; $table = $wpdb->prefix . 'joryio_carts'; try { if (!$wpdb->get_var($wpdb->prepare('SHOW TABLES LIKE %s', $table))) { $this->create_tables(); return; } $columns = array( 'restored_from' => 'bigint(20) unsigned DEFAULT NULL', 'restore_merged' => 'tinyint(1) NOT NULL DEFAULT 0', 'reabandoned_at' => 'datetime DEFAULT NULL', ); foreach ($columns as $name => $definition) { if (!$wpdb->get_var($wpdb->prepare("SHOW COLUMNS FROM {$table} LIKE %s", $name))) { $wpdb->query("ALTER TABLE {$table} ADD COLUMN {$name} {$definition}"); } } if (!$wpdb->get_var($wpdb->prepare("SHOW INDEX FROM {$table} WHERE Key_name = %s", 'restored_from'))) { $wpdb->query("ALTER TABLE {$table} ADD KEY restored_from (restored_from)"); } // 1.6.17: the restore links table (CREATE TABLE IF NOT EXISTS). $this->create_restore_links_table(); update_option('joryio_db_version', JORYIO_VERSION); } catch (Exception $e) { $this->log('Carts table upgrade failed: ' . $e->getMessage(), 'error'); } } /** * Create custom database tables */ private function create_tables() { global $wpdb; $charset_collate = $wpdb->get_charset_collate(); $table_name = $wpdb->prefix . 'joryio_carts'; $sql = "CREATE TABLE IF NOT EXISTS $table_name ( id bigint(20) unsigned NOT NULL AUTO_INCREMENT, session_id varchar(255) NOT NULL, user_id bigint(20) unsigned DEFAULT NULL, email varchar(255) DEFAULT NULL, cart_contents longtext NOT NULL, cart_total decimal(10,2) NOT NULL DEFAULT '0.00', currency varchar(3) NOT NULL DEFAULT 'USD', recovery_token varchar(64) NOT NULL, status varchar(20) NOT NULL DEFAULT 'active', ip_address varchar(45) DEFAULT NULL, user_agent text DEFAULT NULL, utm_source varchar(255) DEFAULT NULL, utm_medium varchar(255) DEFAULT NULL, utm_campaign varchar(255) DEFAULT NULL, created_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, abandoned_at datetime DEFAULT NULL, recovered_at datetime DEFAULT NULL, restored_from bigint(20) unsigned DEFAULT NULL, restore_merged tinyint(1) NOT NULL DEFAULT 0, reabandoned_at datetime DEFAULT NULL, PRIMARY KEY (id), UNIQUE KEY session_id (session_id), UNIQUE KEY recovery_token (recovery_token), KEY user_id (user_id), KEY email (email), KEY status (status), KEY created_at (created_at), KEY abandoned_at (abandoned_at), KEY restored_from (restored_from) ) $charset_collate;"; require_once ABSPATH . 'wp-admin/includes/upgrade.php'; dbDelta($sql); $this->create_restore_links_table(); // Store database version update_option('joryio_db_version', JORYIO_VERSION); } /** * 1.6.17: joryio_restore_links - a recovery link (by the SHA-256 of its * token, never the token) -> the session whose cart it restored, and the * link's restores this hour (Joryio_Restore_Link_Rule). Rows older than * 30 days are deleted by the daily cleanup. */ private function create_restore_links_table() { global $wpdb; $charset_collate = $wpdb->get_charset_collate(); $table = $wpdb->prefix . 'joryio_restore_links'; $wpdb->query("CREATE TABLE IF NOT EXISTS {$table} ( token_hash char(64) NOT NULL, cart_id bigint(20) unsigned NOT NULL, copy_session varchar(255) DEFAULT NULL, copied_at datetime DEFAULT NULL, window_started_at datetime DEFAULT NULL, attempts int(10) unsigned NOT NULL DEFAULT 0, updated_at datetime NOT NULL, PRIMARY KEY (token_hash), KEY updated_at (updated_at) ) {$charset_collate}"); } /** * Schedule cron events */ private function schedule_events() { // Check for abandoned carts every 15 minutes if (!wp_next_scheduled('joryio_check_abandoned_carts')) { wp_schedule_event(time(), self::CRON_SCHEDULE, 'joryio_check_abandoned_carts'); } // Cleanup old carts daily if (!wp_next_scheduled('joryio_cleanup_old_carts')) { wp_schedule_event(time(), 'daily', 'joryio_cleanup_old_carts'); } } /** * 1.6.25: an abandoned-cart check scheduled by an older version on the * unprefixed "fifteen_minutes" interval is unscheduled and scheduled * again on CRON_SCHEDULE, keeping its next run time. Does nothing when * the event is on the new interval or not scheduled at all (a site that * deactivated the plugin has none, and activation schedules it). */ public function migrate_cron_schedule() { $hook = 'joryio_check_abandoned_carts'; if (wp_get_schedule($hook) !== self::LEGACY_CRON_SCHEDULE) { return; } $next = wp_next_scheduled($hook); wp_clear_scheduled_hook($hook); wp_schedule_event($next ? (int) $next : time(), self::CRON_SCHEDULE, $hook); } /** * Set default options */ private function set_defaults() { $defaults = array( 'joryio_api_key' => '', 'joryio_workspace_id' => '', 'joryio_connected' => false, 'joryio_store_url' => get_site_url(), 'joryio_store_name' => get_bloginfo('name'), 'joryio_abandonment_time' => 30, // minutes 'joryio_restore_reuse_days' => 7, // 1.6.17: Joryio_Restore_Link_Rule 'joryio_track_guests' => true, 'joryio_inject_tracking' => true, // 1.6.22: "Recognise visitors by the email they type" - on. Joryio_Visitor_Identity_Report::OPTION => Joryio_Visitor_Identity_Report::DEFAULT_VALUE, // Marketing consent checkbox: off everywhere until the owner turns // it on, and never pre-ticked by default. 'joryio_consent_checkout' => false, 'joryio_consent_block_checkout' => false, 'joryio_consent_registration' => false, 'joryio_consent_default_checked' => false, // 1.6.13: when a shopper's checkout tick counts. 1.6.14 (security // review 2026-10-27): only when the order is placed by default. 'joryio_consent_record_mode' => 'order', // 1.6.15: checkout opt-in ticks per shopper per hour (immediately). 'joryio_consent_tick_limit' => 3, 'joryio_consent_sms' => false, // Web SDK (1.5.0). Installs upgraded from older versions never run // this; they get the same values because an unsaved option reads // as its default (Joryio_Web_Sdk::settings). Joryio_Web_Sdk::OPTION_IN_APP => true, Joryio_Web_Sdk::OPTION_ALLOW_HTML => true, Joryio_Web_Sdk::OPTION_WEB_PUSH => false, ); foreach ($defaults as $key => $value) { // add_option() does nothing for an option that exists (even one // saved as '' or 0); the get_option check only spares the query. if (get_option($key) === false) { add_option($key, $value); } } } /** * Add settings link to plugins page * * @param array $links Plugin action links * @return array */ public function add_settings_link($links) { $settings_link = '' . __('Settings', 'joryio') . ''; array_unshift($links, $settings_link); return $links; } /** * Is WooCommerce active on this site? The store features depend on it. * * @return bool */ public function has_woocommerce() { return (bool) apply_filters('joryio_has_woocommerce', class_exists('WooCommerce')); } /** * Who may manage the plugin: shop managers on a store, administrators * elsewhere (manage_woocommerce does not exist without WooCommerce). * * @return string */ public function admin_capability() { return $this->has_woocommerce() ? 'manage_woocommerce' : 'manage_options'; } /** * The settings page: WooCommerce > Joryio on a store, Settings > Joryio elsewhere. * * @param string $tab A tab of the page (1.6.32, Joryio_Settings::tabs()); '' for the first. * @return string */ public function settings_url($tab = '') { $url = $this->has_woocommerce() ? admin_url('admin.php?page=joryio') : admin_url('options-general.php?page=joryio'); return $tab === '' ? $url : $url . '&tab=' . rawurlencode($tab); } /** * Is $screen_id one of the screens the plugin's admin notices may use? * * @param string $screen_id WP_Screen::$id * @return bool */ public static function is_notice_screen($screen_id) { return in_array((string) $screen_id, self::NOTICE_SCREENS, true); } /** * Should a notice print on this request: a user who can manage the * plugin, on the Plugins screen or the Joryio page, and (for a * dismissible one) not dismissed by this user. * * @param string $key '' for a notice that cannot be dismissed. * @return bool */ public function show_notice_here($key = '') { if (!current_user_can($this->admin_capability())) { return false; } $screen = function_exists('get_current_screen') ? get_current_screen() : null; if (!$screen || !self::is_notice_screen($screen->id)) { return false; } return $key === '' || !$this->notice_dismissed($key); } /** * @param string $key * @return bool This user dismissed the notice. */ public function notice_dismissed($key) { $dismissed = get_user_meta(get_current_user_id(), self::DISMISSED_NOTICES_META, true); return is_array($dismissed) && !empty($dismissed[$key]); } /** * The "Dismiss" link of a dismissible notice (admin-post, with a nonce). * * @param string $key * @return string */ public function dismiss_url($key) { return wp_nonce_url( add_query_arg(array('action' => 'joryio_dismiss_notice', 'notice' => $key), admin_url('admin-post.php')), 'joryio_dismiss_notice_' . $key ); } /** * admin-post: remember for this user that a notice was dismissed, then go * back. Nonce + capability checked; only a known notice key is stored. */ public function dismiss_notice() { if (!current_user_can($this->admin_capability())) { wp_die(esc_html__('You do not have permission to do that.', 'joryio'), '', array('response' => 403)); } $key = isset($_GET['notice']) ? sanitize_key(wp_unslash($_GET['notice'])) : ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- the nonce is checked on the next line; its action names the key check_admin_referer('joryio_dismiss_notice_' . $key); if (in_array($key, self::DISMISSIBLE_NOTICES, true)) { $user_id = get_current_user_id(); $dismissed = get_user_meta($user_id, self::DISMISSED_NOTICES_META, true); $dismissed = is_array($dismissed) ? $dismissed : array(); $dismissed[$key] = 1; update_user_meta($user_id, self::DISMISSED_NOTICES_META, $dismissed); } $back = wp_get_referer(); wp_safe_redirect($back ? $back : admin_url('plugins.php')); exit; } /** * WooCommerce was activated after the site connected without it. * Dismissible, per user. */ public function reconnect_for_store_notice() { if (!$this->show_notice_here('reconnect_store')) { return; } ?>

Joryio so Joryio can sync your store.', 'joryio'); ?>

show_notice_here()) { return; } ?>

log('Page cache purge failed: ' . $e->getMessage(), 'warning'); } } /** * Get API endpoint * * @return string */ public function get_api_endpoint() { // Precedence: the URL saved from the settings form (option) wins, then the // JORYIO_API_ENDPOINT constant (wp-config define), then the built-in default. $saved = get_option('joryio_api_endpoint', ''); $endpoint = !empty($saved) ? $saved : JORYIO_API_ENDPOINT; return apply_filters('joryio_api_endpoint', $endpoint); } /** * Check if plugin is connected to Joryio * * @return bool */ public function is_connected() { return (bool) get_option('joryio_connected', false); } /** * Get API key * * @return string */ public function get_api_key() { return get_option('joryio_api_key', ''); } /** * Get workspace ID * * @return string */ public function get_workspace_id() { return get_option('joryio_workspace_id', ''); } /** * Get the store's SDK key (used by the storefront tracking snippet). * * The key is persisted from the /connect response. Installs that connected * before the plugin persisted it self-heal here: fetch it on demand from * GET {server}/api/woocommerce/plugin/validate (which returns sdkKey) and * persist it — no reconnect required. The remote call is throttled with a * transient so a backend outage can't slow every storefront page load. * * @return string SDK key, or '' when unavailable. */ public function get_sdk_key() { $sdk_key = get_option('joryio_sdk_key', ''); if (!empty($sdk_key)) { return $sdk_key; } if (!$this->is_connected()) { return ''; } $api_key = $this->get_api_key(); if (empty($api_key)) { return ''; } // Throttle self-heal attempts (one remote call per 15 minutes at most). if (get_transient('joryio_sdk_key_retry')) { return ''; } set_transient('joryio_sdk_key_retry', 1, 15 * MINUTE_IN_SECONDS); $response = wp_remote_get($this->get_api_endpoint() . '/api/woocommerce/plugin/validate', array( 'timeout' => 5, 'headers' => $this->api_headers($api_key), )); if (is_wp_error($response) || wp_remote_retrieve_response_code($response) !== 200) { return ''; } $body = json_decode(wp_remote_retrieve_body($response), true); if (empty($body['sdkKey'])) { return ''; } $sdk_key = sanitize_text_field($body['sdkKey']); update_option('joryio_sdk_key', $sdk_key); if (!empty($body['appId'])) { update_option('joryio_app_id', sanitize_text_field($body['appId'])); } delete_transient('joryio_sdk_key_retry'); return $sdk_key; } /** * Log debug message * * @param string $message Log message * @param string $level Log level (debug, info, warning, error) */ public function log($message, $level = 'debug') { // 1.6.28 (D310): errors, warnings and notices always reach // WooCommerce > Status > Logs (source "joryio") - a failed send or // the abandoned-cart check's report left no trace on a live store // (owner QA 2026-11-02). Debug and info only with WP_DEBUG. $always = in_array($level, array('emergency', 'alert', 'critical', 'error', 'warning', 'notice'), true); if ($always && function_exists('wc_get_logger')) { wc_get_logger()->log($level, $message, array('source' => 'joryio')); return; } if (defined('WP_DEBUG') && WP_DEBUG) { if (function_exists('wc_get_logger')) { wc_get_logger()->log($level, $message, array('source' => 'joryio')); } else { error_log('[joryio] ' . $level . ': ' . $message); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- only with WP_DEBUG on, and only without the WooCommerce logger } } } } /** * Add custom cron schedules */ add_filter('cron_schedules', function($schedules) { $schedules[Joryio::CRON_SCHEDULE] = array( 'interval' => 15 * 60, 'display' => __('Every 15 Minutes', 'joryio'), ); return $schedules; }); /** * Get Joryio instance * * @return Joryio */ function joryio() { return Joryio::instance(); } // Initialize plugin joryio();