prefix . 'storesuit_cart_signals'; } /** * Schema 2 (31 Oct 2026) adds where the cart's shopper came from: a channel key (CHANNELS) and the referring site's host. * A store whose upgrade could not add them keeps schema 1 and records carts without a source. */ public static function install(): void { if ( get_option( self::SCHEMA ) === '2' ) { return; } global $wpdb; require_once ABSPATH . 'wp-admin/includes/upgrade.php'; $table = self::table(); $charset = $wpdb->get_charset_collate(); dbDelta( "CREATE TABLE $table ( signal_key char(64) NOT NULL, customer_id bigint(20) unsigned NOT NULL DEFAULT 0, item_count int(10) unsigned NOT NULL DEFAULT 0, item_ids longtext NOT NULL, estimated_total decimal(20,6) NOT NULL DEFAULT 0, currency char(3) NOT NULL, cart_hash char(64) NOT NULL, source varchar(20) NOT NULL DEFAULT '', source_host varchar(100) NOT NULL DEFAULT '', first_seen datetime NOT NULL, last_seen datetime NOT NULL, expires_at datetime NOT NULL, PRIMARY KEY (signal_key), KEY last_seen (last_seen), KEY expires_at (expires_at), KEY customer_seen (customer_id,last_seen) ) $charset;" ); if ( $wpdb->get_var( $wpdb->prepare( 'SHOW TABLES LIKE %s', $wpdb->esc_like( $table ) ) ) !== $table ) { return; } update_option( self::SCHEMA, $wpdb->get_var( $wpdb->prepare( 'SHOW COLUMNS FROM %i LIKE %s', $table, 'source_host' ) ) ? '2' : '1', false ); } /** The table is there (schema 1 or 2). */ private static function ready(): bool { return in_array( get_option( self::SCHEMA ), array( '1', '2' ), true ); } /** The table keeps each cart's source (schema 2). */ private static function sourced(): bool { return get_option( self::SCHEMA ) === '2'; } /** Where shoppers come from, the keys a cart's source may hold ('' when it is not known). */ public const CHANNELS = array( 'direct', 'google', 'google_ads', 'yandex', 'bing', 'instagram', 'facebook', 'tiktok', 'youtube', 'pinterest', 'x', 'whatsapp', 'email', 'other' ); /** * Why a cart's source is not known (31 Oct 2026), kept in place of a channel: 'none', the shopper's browser brought no * visit details; 'link', the cart was made by a bare add-to-cart link, without the store's page scripts or a referring * page (on Almira every such cart had one product and never changed: search engines and other automated visitors that * follow those links). A later visit with known details replaces either. */ public const UNKNOWN = array( 'none', 'link' ); /** * Search engines, link previews, scripts and other automated visitors (the user agent says so, or says nothing). They * follow the add-to-cart links a product list prints, which makes a WooCommerce cart for each of them: no summary is * kept for them, and they never count as a cart. */ public static function crawler(): bool { $agent = isset( $_SERVER['HTTP_USER_AGENT'] ) && is_string( $_SERVER['HTTP_USER_AGENT'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ) ) : ''; return trim( $agent ) === '' || (bool) preg_match( '/bot\b|bot[\/;-]|crawl|spider|slurp|scrap|headless|phantom|puppeteer|playwright|lighthouse|pagespeed|preview|facebookexternalhit|whatsapp\/|python|curl\/|wget|libwww|httpclient|okhttp|go-http|java\/|axios|node-fetch|ahrefs|semrush|mj12|petalsearch|bytespider|gptbot|claude|ccbot|dataforseo/i', $agent ); } /** * The visit's source as WooCommerce's order attribution recorded it in the shopper's browser (its Sourcebuster cookies, * on by default since WooCommerce 8.5): [channel, referring host]. Only the channel and the host are kept; never the full * address, a query string or a click id. Without those cookies: the campaign or click ID on this request or on the page * it came from, else the referring site; with nothing at all, a reason (UNKNOWN). */ private static function source(): array { $read = static function ( string $name ): array { $raw = isset( $_COOKIE[ $name ] ) && is_string( $_COOKIE[ $name ] ) ? substr( sanitize_text_field( wp_unslash( $_COOKIE[ $name ] ) ), 0, 2000 ) : ''; $out = array(); foreach ( explode( '|||', $raw ) as $pair ) { $part = explode( '=', $pair, 2 ); if ( count( $part ) === 2 ) { $out[ strtolower( trim( $part[0] ) ) ] = trim( $part[1] ); } } return $out; }; $visit = $read( 'sbjs_current' ) ?: $read( 'sbjs_first' ); if ( ! $visit ) { return self::fallback(); } $type = strtolower( (string) ( $visit['typ'] ?? '' ) ); $host = ''; if ( $type !== 'typein' && $type !== 'admin' ) { // The referring page's host only (rf is the full address); a referral's source is that host already. $extra = $read( isset( $_COOKIE['sbjs_current'] ) ? 'sbjs_current_add' : 'sbjs_first_add' ); $host = self::host( (string) wp_parse_url( (string) ( $extra['rf'] ?? '' ), PHP_URL_HOST ) ) ?: ( $type === 'referral' ? self::host( (string) ( $visit['src'] ?? '' ) ) : '' ); } return array( self::channel( $type, strtolower( (string) ( $visit['src'] ?? '' ) ), strtolower( (string) ( $visit['mdm'] ?? '' ) ) ), $host ); } /** * A cart made without WooCommerce's visit cookies (a visit that added before the page's scripts ran, or none ran): the * campaign (utm_source/utm_medium) or click ID (gclid, fbclid, yclid, ttclid, msclkid) in this request's address or in * the page it was sent from, else the other site that page names as its referrer. Only the channel and that site's host * are kept. Nothing: 'link' for a bare add-to-cart link with no page behind it, 'none' otherwise. */ private static function fallback(): array { $referer = isset( $_SERVER['HTTP_REFERER'] ) && is_string( $_SERVER['HTTP_REFERER'] ) ? esc_url_raw( wp_unslash( $_SERVER['HTTP_REFERER'] ) ) : ''; $own = self::host( (string) wp_parse_url( home_url(), PHP_URL_HOST ) ); $from = self::host( (string) wp_parse_url( $referer, PHP_URL_HOST ) ); $query = array(); $page = $from !== '' && $from === $own ? (array) wp_parse_args( (string) wp_parse_url( $referer, PHP_URL_QUERY ) ) : array(); foreach ( array( $_GET, $page ) as $set ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Only reads a campaign name from the address; nothing is changed by it. foreach ( array( 'utm_source', 'utm_medium', 'gclid', 'gbraid', 'wbraid', 'fbclid', 'yclid', 'ttclid', 'msclkid' ) as $name ) { if ( ! isset( $query[ $name ] ) && isset( $set[ $name ] ) && is_string( $set[ $name ] ) && $set[ $name ] !== '' ) { $query[ $name ] = strtolower( substr( sanitize_text_field( wp_unslash( $set[ $name ] ) ), 0, 100 ) ); } } } if ( isset( $query['utm_source'] ) ) { return array( self::channel( 'utm', $query['utm_source'], $query['utm_medium'] ?? '' ), '' ); } foreach ( array( 'gclid'=>'google_ads', 'gbraid'=>'google_ads', 'wbraid'=>'google_ads', 'fbclid'=>'facebook', 'yclid'=>'yandex', 'ttclid'=>'tiktok', 'msclkid'=>'bing' ) as $name => $channel ) { if ( isset( $query[ $name ] ) ) { return array( $channel, '' ); } } if ( $from !== '' && $from !== $own ) { return array( self::channel( 'referral', $from, 'referral' ), $from ); } $link = isset( $_SERVER['REQUEST_METHOD'] ) && $_SERVER['REQUEST_METHOD'] === 'GET' && isset( $_GET['add-to-cart'] ) && ! wp_doing_ajax() && $from === ''; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Only tells how the cart was made. return array( $link ? 'link' : 'none', '' ); } /** A bare host name ("l.instagram.com"), or '' for anything else. */ private static function host( string $value ): string { $host = (string) preg_replace( '/^www\./', '', strtolower( trim( $value ) ) ); return strlen( $host ) <= 100 && preg_match( '/^[a-z0-9-]+(\.[a-z0-9-]+)+$/', $host ) ? $host : ''; } /** * The channel of a visit from Sourcebuster's type (typein, referral, organic, utm), source (a host or utm_source) and * medium (organic, cpc, email …): a CHANNELS key, '' when nothing is known. Google's paid clicks (cpc/ppc/paid) are * google_ads; a source no rule names is "other". */ public static function channel( string $type, string $source, string $medium ): string { if ( $type === '' ) { return ''; } if ( $type === 'typein' || $type === 'admin' || $source === '(direct)' ) { return 'direct'; } $is = static fn( string $hosts, string $names ): bool => (bool) preg_match( '/(^|\.)(' . $hosts . ')$/', $source ) || in_array( $source, explode( '|', $names ), true ); if ( $is( 'instagram\.com|ig\.me', 'instagram|ig' ) ) { return 'instagram'; } if ( $is( 'facebook\.com|fb\.com|fb\.me|messenger\.com', 'facebook|fb|meta' ) ) { return 'facebook'; } if ( $is( 'tiktok\.com', 'tiktok' ) ) { return 'tiktok'; } if ( $is( 'youtube\.com|youtu\.be', 'youtube' ) ) { return 'youtube'; } if ( $is( 'pinterest\.[a-z.]+|pin\.it', 'pinterest' ) ) { return 'pinterest'; } if ( $is( 'twitter\.com|x\.com|t\.co', 'twitter|x' ) ) { return 'x'; } if ( $is( 'whatsapp\.com|wa\.me', 'whatsapp' ) ) { return 'whatsapp'; } if ( $is( 'google\.[a-z.]+|googleadservices\.com|googlesyndication\.com', 'google' ) ) { return preg_match( '/^(cpc|ppc|paid|paidsearch|paid_search|cpm|display|ads?)$/', $medium ) ? 'google_ads' : 'google'; } if ( $is( 'yandex\.[a-z.]+|ya\.ru', 'yandex' ) ) { return 'yandex'; } if ( $is( 'bing\.com', 'bing' ) ) { return 'bing'; } if ( $medium === 'email' || in_array( $source, array( 'email', 'newsletter' ), true ) ) { return 'email'; } return 'other'; } /** A channel's name for the screen ('' for an unknown source). */ public static function channel_label( string $channel ): string { $names = array( 'direct'=>I18n::t( 'Direct' ), 'google'=>I18n::t( 'Google search' ), 'google_ads'=>'Google Ads', 'yandex'=>'Yandex', 'bing'=>'Bing', 'instagram'=>'Instagram', 'facebook'=>'Facebook', 'tiktok'=>'TikTok', 'youtube'=>'YouTube', 'pinterest'=>'Pinterest', 'x'=>'X', 'whatsapp'=>'WhatsApp', 'email'=>I18n::t( 'Email' ), 'other'=>I18n::t( 'Other site' ), 'link'=>I18n::t( 'Bare link (maybe a bot)' ) ); return $names[ $channel ] ?? ''; } /** * The sources of these cart keys (the cart reminders list them beside their carts): key => [source, source_host]. Only * carts still kept (72 hours) answer. */ public static function sources( array $keys ): array { $keys = array_values( array_unique( array_filter( array_map( 'strval', $keys ), static fn( $k ) => (bool) preg_match( '/^[a-f0-9]{64}$/', $k ) ) ) ); if ( ! $keys || ! self::sourced() ) { return array(); } global $wpdb; $out = array(); foreach ( (array) $wpdb->get_results( $wpdb->prepare( 'SELECT signal_key,source,source_host FROM %i WHERE signal_key IN (' . implode( ',', array_fill( 0, count( $keys ), '%s' ) ) . ')', array_merge( array( self::table() ), $keys ) ), ARRAY_A ) as $row ) { $out[ $row['signal_key'] ] = array( (string) $row['source'], (string) $row['source_host'] ); } return $out; } public static function hooks(): void { add_action( 'woocommerce_cart_updated', array( __CLASS__, 'record' ), 30 ); add_action( 'woocommerce_cart_emptied', array( __CLASS__, 'clear' ), 5 ); add_action( 'woocommerce_checkout_order_processed', array( __CLASS__, 'clear' ), 20 ); add_action( 'woocommerce_store_api_checkout_order_processed', array( __CLASS__, 'clear' ), 20 ); add_action( self::CLEANUP, array( __CLASS__, 'cleanup' ) ); add_action( 'init', array( __CLASS__, 'ensure_schedule' ), 99 ); add_filter( 'wp_privacy_personal_data_exporters', static function ( $exporters ) { $exporters['storesuit-cart-signals'] = array( 'exporter_friendly_name'=>I18n::t( 'Web carts' ), 'callback'=>array( __CLASS__, 'export' ) ); return $exporters; } ); add_filter( 'wp_privacy_personal_data_erasers', static function ( $erasers ) { $erasers['storesuit-cart-signals'] = array( 'eraser_friendly_name'=>I18n::t( 'Web carts' ), 'callback'=>array( __CLASS__, 'erase' ) ); return $erasers; } ); } public static function deactivate(): void { wp_clear_scheduled_hook( self::CLEANUP ); } public static function ensure_schedule(): void { if ( ! wp_next_scheduled( self::CLEANUP ) ) { wp_schedule_event( time() + HOUR_IN_SECONDS, 'daily', self::CLEANUP ); } } /** * On unless the store turned it off (31 Oct 2026). As an opt-in, a store that never found the switch saw its cart screens * stay at 0 and took them for broken (Almira). Nothing is kept about a guest beyond the cart, and only for 72 hours. */ public static function enabled(): bool { return get_option( self::OPTION, 'yes' ) === 'yes'; } /** * Signed-in team members (store managers and order staff). Their carts are store tests: listed among the active carts * with a label, left out of the waiting carts and of every count (0.62.1). */ private static function team_ids(): array { return array_values( array_filter( array_map( 'intval', (array) get_users( array( 'capability__in'=>array( 'manage_woocommerce', 'storesuit_orders' ), 'fields'=>'ID', 'number'=>1000 ) ) ) ) ); } /** * The customer ids a count leaves out, for "customer_id NOT IN (…)" with one %d each: the team's ids, or -1 when there is * no team (no customer id is negative), so the list is never empty and leaves nothing out. */ private static function skip( array $team ): array { return $team ? array_map( 'intval', $team ) : array( -1 ); } /** HMAC of the WooCommerce session id; also identifies a visitor's cart reminder. */ public static function key(): string { $session = WC()->session; if ( ! $session || ! method_exists( $session, 'get_customer_id' ) ) { return ''; } $id = (string) $session->get_customer_id(); return $id === '' ? '' : hash_hmac( 'sha256', $id, wp_salt( 'auth' ) ); } /** Called after WooCommerce updates the cart, for classic and Store API carts. */ public static function record(): void { // A signed-in team member's own cart is recorded too (0.62.1), so the store can try this screen. It is listed as a // test cart and never counted as a customer's (team_ids()); cart reminders keep skipping staff on their own. if ( ! self::enabled() || ! self::ready() || ! WC()->cart ) { return; } $key = self::key(); if ( $key === '' ) { return; } // A search engine or other automated visitor following an add-to-cart link (31 Oct 2026): not a shopper's cart. if ( self::crawler() ) { return; } $cart = WC()->cart; // A visitor without a WooCommerce session never had a cart signal (its id is new on every page), so there is nothing to // delete: storefront pages no longer write to the database for every guest view (TTFB review, 19 Oct 2026). if ( $cart->is_empty() ) { if ( ! method_exists( WC()->session, 'has_session' ) || WC()->session->has_session() ) { self::clear(); } return; } $items = array(); $count = 0; foreach ( $cart->get_cart() as $row ) { $id = absint( $row['variation_id'] ?: $row['product_id'] ); $qty = max( 0, (float) ( $row['quantity'] ?? 0 ) ); if ( ! $id || $qty <= 0 ) { continue; } $count += $qty; if ( count( $items ) < 50 ) { $items[] = array( 'id'=>$id, 'qty'=>$qty ); } } if ( ! $items ) { return; } global $wpdb; $table = self::table(); $now = time(); $customer = get_current_user_id(); $hash = hash( 'sha256', wp_json_encode( $items ) ); $old = $wpdb->get_row( $wpdb->prepare( 'SELECT cart_hash,customer_id,last_seen FROM %i WHERE signal_key=%s', $table, $key ), ARRAY_A ); if ( $old && $old['cart_hash'] === $hash && (int) $old['customer_id'] === $customer && strtotime( $old['last_seen'] . ' UTC' ) > $now - 300 ) { return; } $total = max( 0, (float) $cart->get_total( 'edit' ) ); $currency = strtoupper( substr( get_woocommerce_currency(), 0, 3 ) ); if ( self::sourced() ) { // The source of the visit that started the cart stays; a later visit only fills one that was not known (blank or a // reason, UNKNOWN), and only with something better than a reason. list( $source, $source_host ) = self::source(); $wpdb->query( $wpdb->prepare( 'INSERT INTO %i (signal_key,customer_id,item_count,item_ids,estimated_total,currency,cart_hash,source,source_host,first_seen,last_seen,expires_at) VALUES (%s,%d,%d,%s,%f,%s,%s,%s,%s,%s,%s,%s) ON DUPLICATE KEY UPDATE customer_id=VALUES(customer_id),item_count=VALUES(item_count),item_ids=VALUES(item_ids),estimated_total=VALUES(estimated_total),currency=VALUES(currency),cart_hash=VALUES(cart_hash),source_host=IF(source IN (%s,%s,%s) AND VALUES(source) NOT IN (%s,%s,%s),VALUES(source_host),source_host),source=IF(CHAR_LENGTH(source)=0 OR (source IN (%s,%s) AND VALUES(source) NOT IN (%s,%s,%s)),VALUES(source),source),last_seen=VALUES(last_seen),expires_at=VALUES(expires_at)', $table, $key, $customer, (int) ceil( $count ), wp_json_encode( $items ), $total, $currency, $hash, $source, $source_host, gmdate( 'Y-m-d H:i:s', $now ), gmdate( 'Y-m-d H:i:s', $now ), gmdate( 'Y-m-d H:i:s', $now + self::RETENTION ), '', 'none', 'link', '', 'none', 'link', 'none', 'link', '', 'none', 'link' ) ); return; } $wpdb->query( $wpdb->prepare( 'INSERT INTO %i (signal_key,customer_id,item_count,item_ids,estimated_total,currency,cart_hash,first_seen,last_seen,expires_at) VALUES (%s,%d,%d,%s,%f,%s,%s,%s,%s,%s) ON DUPLICATE KEY UPDATE customer_id=VALUES(customer_id),item_count=VALUES(item_count),item_ids=VALUES(item_ids),estimated_total=VALUES(estimated_total),currency=VALUES(currency),cart_hash=VALUES(cart_hash),last_seen=VALUES(last_seen),expires_at=VALUES(expires_at)', $table, $key, $customer, (int) ceil( $count ), wp_json_encode( $items ), $total, $currency, $hash, gmdate( 'Y-m-d H:i:s', $now ), gmdate( 'Y-m-d H:i:s', $now ), gmdate( 'Y-m-d H:i:s', $now + self::RETENTION ) ) ); } /** Removes only StoreSuit's derived signal, never the live WooCommerce cart. */ public static function clear(): void { if ( ! self::ready() ) { return; } $key = self::key(); if ( $key === '' ) { return; } global $wpdb; $wpdb->delete( self::table(), array( 'signal_key'=>$key ) ); } public static function cleanup(): void { if ( ! self::ready() ) { return; } global $wpdb; $wpdb->query( $wpdb->prepare( 'DELETE FROM %i WHERE expires_at < %s OR last_seen < %s', self::table(), gmdate( 'Y-m-d H:i:s' ), gmdate( 'Y-m-d H:i:s', time() - self::RETENTION ) ) ); } /* ---------- Privacy ---------- */ /** * The account behind a WordPress privacy request. Only a signed-in customer's signal names a person (customer_id); * a guest's signal keeps no contact, so no email address can lead to it. */ private static function customer( string $email ): int { if ( ! self::ready() ) { return 0; } $user = get_user_by( 'email', $email ); return $user ? (int) $user->ID : 0; } /** Personal data export: what the customer's cart signal holds. */ public static function export( string $email, int $page = 1 ): array { global $wpdb; $data = array(); $customer = self::customer( $email ); if ( $customer ) { $rows = (array) ( self::sourced() ? $wpdb->get_results( $wpdb->prepare( 'SELECT item_count,item_ids,estimated_total,currency,source,source_host,first_seen,last_seen FROM %i WHERE customer_id=%d ORDER BY last_seen DESC', self::table(), $customer ), ARRAY_A ) : $wpdb->get_results( $wpdb->prepare( 'SELECT item_count,item_ids,estimated_total,currency,first_seen,last_seen FROM %i WHERE customer_id=%d ORDER BY last_seen DESC', self::table(), $customer ), ARRAY_A ) ); foreach ( $rows as $n => $row ) { $products = array(); foreach ( (array) json_decode( (string) $row['item_ids'], true ) as $item ) { $id = absint( $item['id'] ?? 0 ); $product = $id ? wc_get_product( $id ) : null; $products[] = ( $product ? $product->get_name() : '#' . $id ) . ' × ' . wc_format_decimal( (float) ( $item['qty'] ?? 0 ) ); } $data[] = array( 'group_id'=>'storesuit-cart-signals', 'group_label'=>I18n::t( 'Web carts' ), 'item_id'=>'cart-signal-' . ( $n + 1 ), 'data'=>array_merge( array( array( 'name'=>I18n::t( 'Products' ), 'value'=>implode( ', ', $products ) ), array( 'name'=>I18n::t( 'Quantity' ), 'value'=>(string) (int) $row['item_count'] ), array( 'name'=>I18n::t( 'Total' ), 'value'=>wc_format_decimal( (float) $row['estimated_total'], wc_get_price_decimals() ) . ' ' . $row['currency'] ), array( 'name'=>I18n::t( 'Created' ), 'value'=>$row['first_seen'] . ' UTC' ), array( 'name'=>I18n::t( 'Last change' ), 'value'=>$row['last_seen'] . ' UTC' ), ), ! empty( $row['source'] ) && $row['source'] !== 'none' ? array( array( 'name'=>I18n::t( 'Came from' ), 'value'=>self::channel_label( (string) $row['source'] ) . ( $row['source_host'] ? ' (' . $row['source_host'] . ')' : '' ) ) ) : array() ) ); } } return array( 'data'=>$data, 'done'=>true ); } /** * Personal data erasure: the customer's signals are deleted, not just unlinked. The key of a signed-in cart is * derived from the account itself, so a row kept with customer_id 0 would still point at the person. */ public static function erase( string $email, int $page = 1 ): array { global $wpdb; $removed = 0; $customer = self::customer( $email ); if ( $customer ) { $removed = (int) $wpdb->delete( self::table(), array( 'customer_id'=>$customer ), array( '%d' ) ); } return array( 'items_removed'=>$removed > 0, 'items_retained'=>false, 'messages'=>array(), 'done'=>true ); } public static function save( array $data ): array { Store::require_cap( 'manage_woocommerce' ); if ( empty( $data['confirmed'] ) || ! isset( $data['enabled'] ) || ! in_array( (string) $data['enabled'], array( '0', '1' ), true ) ) { throw new \InvalidArgumentException( I18n::t( 'Confirm whether to collect short-lived web-cart signals.' ) ); } if ( ! self::ready() ) { throw new \RuntimeException( I18n::t( 'Web-cart storage is not ready. Ask your host to check the database.' ) ); } $enabled = (string) $data['enabled'] === '1'; return Store::once_request( 'cart-signals-setting', $data, static function () use ( $enabled ) { $old = self::enabled(); update_option( self::OPTION, $enabled ? 'yes' : 'no', false ); if ( ! $enabled ) { global $wpdb; $wpdb->query( $wpdb->prepare( 'DELETE FROM %i', self::table() ) ); } if ( $old !== $enabled ) { Store::audit( 'settings', 0, 'cart_signals_setting_updated', array( 'enabled'=>$enabled ) ); } return self::read(); } ); } /** Customers' carts being filled now, for the Home page (0.62): one count, no rows or products, no team test carts. */ public static function active_now(): ?int { if ( ! self::ready() || ! self::enabled() ) { return null; } global $wpdb; $skip = self::skip( self::team_ids() ); return (int) $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i WHERE last_seen > %s AND expires_at >= %s AND customer_id NOT IN (' . implode( ',', array_fill( 0, count( $skip ), '%d' ) ) . ')', array_merge( array( self::table(), gmdate( 'Y-m-d H:i:s', time() - self::QUIET ), gmdate( 'Y-m-d H:i:s' ) ), $skip ) ) ); } public static function read( array $data = array() ): array { Store::require_cap( 'manage_woocommerce' ); if ( ! self::ready() ) { throw new \RuntimeException( I18n::t( 'Web-cart storage is not ready. Ask your host to check the database.' ) ); } $enabled = self::enabled(); // "Active now": carts changed in the last hour, as they are being filled (0.58); by default those that stopped changing. $view = ( $data['view'] ?? '' ) === 'active' ? 'active' : 'waiting'; $empty = array( 'enabled'=>$enabled, 'view'=>$view, 'quiet_minutes'=>60, 'retention_hours'=>72, 'total'=>0, 'waiting'=>0, 'registered'=>0, 'anonymous'=>0, 'recent_active'=>0, 'page'=>1, 'pages'=>0, 'rows'=>array(), 'sources'=>array(), 'source_tracking'=>self::sourced() ); if ( ! $enabled ) { return $empty; } global $wpdb; $table = self::table(); $now = gmdate( 'Y-m-d H:i:s' ); $cutoff = gmdate( 'Y-m-d H:i:s', time() - self::QUIET ); // The team's own carts are tests, not opportunities: only the active view lists them, with a label (0.62.1). // Waiting carts: quiet for an hour, kept for three days, never the team's. The active view lists every cart being filled. $team = self::team_ids(); $skip = self::skip( $team ); $kept = gmdate( 'Y-m-d H:i:s', time() - self::RETENTION ); $counts = $view === 'active' ? $wpdb->get_row( $wpdb->prepare( 'SELECT COUNT(*) total, SUM(CASE WHEN customer_id>0 THEN 1 ELSE 0 END) registered FROM %i WHERE last_seen > %s AND expires_at >= %s', $table, $cutoff, $now ), ARRAY_A ) : $wpdb->get_row( $wpdb->prepare( 'SELECT COUNT(*) total, SUM(CASE WHEN customer_id>0 THEN 1 ELSE 0 END) registered FROM %i WHERE last_seen <= %s AND last_seen >= %s AND expires_at >= %s AND customer_id NOT IN (' . implode( ',', array_fill( 0, count( $skip ), '%d' ) ) . ')', array_merge( array( $table, $cutoff, $kept, $now ), $skip ) ), ARRAY_A ); $total = (int) ( $counts['total'] ?? 0 ); $registered = (int) ( $counts['registered'] ?? 0 ); $active = (int) $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i WHERE last_seen > %s AND expires_at >= %s AND customer_id NOT IN (' . implode( ',', array_fill( 0, count( $skip ), '%d' ) ) . ')', array_merge( array( $table, $cutoff, $now ), $skip ) ) ); $pages = $total ? min( 100, (int) ceil( $total / 30 ) ) : 0; $page = min( max( 1, $pages ), max( 1, min( 100, absint( $data['page'] ?? 1 ) ) ) ); $sourced = self::sourced(); if ( $sourced ) { $rows = ( $view === 'active' ? $wpdb->get_results( $wpdb->prepare( 'SELECT customer_id,item_count,item_ids,estimated_total,currency,source,source_host,last_seen FROM %i WHERE last_seen > %s AND expires_at >= %s ORDER BY last_seen DESC, signal_key DESC LIMIT %d OFFSET %d', $table, $cutoff, $now, 30, ( $page - 1 ) * 30 ), ARRAY_A ) : $wpdb->get_results( $wpdb->prepare( 'SELECT customer_id,item_count,item_ids,estimated_total,currency,source,source_host,last_seen FROM %i WHERE last_seen <= %s AND last_seen >= %s AND expires_at >= %s AND customer_id NOT IN (' . implode( ',', array_fill( 0, count( $skip ), '%d' ) ) . ') ORDER BY last_seen DESC, signal_key DESC LIMIT %d OFFSET %d', array_merge( array( $table, $cutoff, $kept, $now ), $skip, array( 30, ( $page - 1 ) * 30 ) ) ), ARRAY_A ) ) ?: array(); // Where this view's customers came from (31 Oct 2026), never counting the team's own carts. $by_source = ( $view === 'active' ? $wpdb->get_results( $wpdb->prepare( 'SELECT source,COUNT(*) carts FROM %i WHERE last_seen > %s AND expires_at >= %s AND customer_id NOT IN (' . implode( ',', array_fill( 0, count( $skip ), '%d' ) ) . ') GROUP BY source', array_merge( array( $table, $cutoff, $now ), $skip ) ), ARRAY_A ) : $wpdb->get_results( $wpdb->prepare( 'SELECT source,COUNT(*) carts FROM %i WHERE last_seen <= %s AND last_seen >= %s AND expires_at >= %s AND customer_id NOT IN (' . implode( ',', array_fill( 0, count( $skip ), '%d' ) ) . ') GROUP BY source', array_merge( array( $table, $cutoff, $kept, $now ), $skip ) ), ARRAY_A ) ) ?: array(); } else { $rows = ( $view === 'active' ? $wpdb->get_results( $wpdb->prepare( 'SELECT customer_id,item_count,item_ids,estimated_total,currency,last_seen FROM %i WHERE last_seen > %s AND expires_at >= %s ORDER BY last_seen DESC, signal_key DESC LIMIT %d OFFSET %d', $table, $cutoff, $now, 30, ( $page - 1 ) * 30 ), ARRAY_A ) : $wpdb->get_results( $wpdb->prepare( 'SELECT customer_id,item_count,item_ids,estimated_total,currency,last_seen FROM %i WHERE last_seen <= %s AND last_seen >= %s AND expires_at >= %s AND customer_id NOT IN (' . implode( ',', array_fill( 0, count( $skip ), '%d' ) ) . ') ORDER BY last_seen DESC, signal_key DESC LIMIT %d OFFSET %d', array_merge( array( $table, $cutoff, $kept, $now ), $skip, array( 30, ( $page - 1 ) * 30 ) ) ), ARRAY_A ) ) ?: array(); $by_source = array(); } $sources = array(); // No visit details and no source at all read the same: Unknown. foreach ( $by_source as $row ) { $key = (string) $row['source'] === 'none' ? '' : (string) $row['source']; $sources[ $key ] = array( 'source'=>$key, 'label'=>self::channel_label( $key ), 'carts'=>( $sources[ $key ]['carts'] ?? 0 ) + (int) $row['carts'] ); } $sources = array_values( $sources ); usort( $sources, static fn( $a, $b ) => $b['carts'] <=> $a['carts'] ?: strcmp( $a['source'], $b['source'] ) ); $out = array(); // One cache fill for every product on this page, not one query per cart line (0.62.2 review). $ids = array(); foreach ( $rows as $row ) { foreach ( (array) json_decode( $row['item_ids'], true ) as $item ) { $ids[] = absint( $item['id'] ?? 0 ); } } $ids = array_values( array_unique( array_filter( $ids ) ) ); if ( $ids ) { _prime_post_caches( $ids, false, true ); } foreach ( $rows as $row ) { $id = (int) $row['customer_id']; $user = $id ? get_user_by( 'id', $id ) : false; $products = array(); $items = array(); foreach ( array_slice( (array) json_decode( $row['item_ids'], true ), 0, 50 ) as $item ) { $product = wc_get_product( absint( $item['id'] ?? 0 ) ); $qty = (float) ( $item['qty'] ?? 0 ); if ( ! $product ) { continue; } $parent = $product->get_parent_id() ?: $product->get_id(); $image = (string) ( wp_get_attachment_image_url( $product->get_image_id() ?: (int) get_post_thumbnail_id( $parent ), 'thumbnail' ) ?: '' ); // The first three with their picture (31 Oct 2026: the list shows them as thumbnails in both views). if ( count( $products ) < 3 ) { $products[] = array( 'name'=>$product->get_name(), 'qty'=>$qty, 'image'=>$image ); } // The full contents are opened only on the active carts; the waiting view shows the first three. if ( $view !== 'active' ) { if ( count( $products ) >= 3 ) { break; } continue; } // The cart opened (0.62.2): every product with today's web price; the card's total stays the cart's own. $price = (float) wc_get_price_to_display( $product ); $items[] = array( 'name'=>$product->get_name(), 'sku'=>(string) $product->get_sku(), 'qty'=>$qty, 'price'=>$price, 'total'=>round( $price * $qty, wc_get_price_decimals() ), 'image'=>$image, 'url'=>admin_url( 'admin.php?page=storesuit-products&id=' . $parent ) ); } $member = $user && ! in_array( $id, $team, true ); $source = (string) ( $row['source'] ?? '' ); $out[] = array( 'customer_id'=>$user ? $id : 0, 'customer'=>$user ? $user->display_name : '', 'customer_url'=>$member ? admin_url( 'admin.php?page=storesuit-customers&id=' . $id ) : '', 'team'=>$user && in_array( $id, $team, true ), 'item_count'=>(int) $row['item_count'], 'products'=>$products, 'items'=>$items, 'omitted_count'=>$view === 'active' ? max( 0, (int) $row['item_count'] - (int) ceil( array_sum( array_column( $items, 'qty' ) ) ) ) : 0, 'estimated_total'=>(float) $row['estimated_total'], 'currency'=>$row['currency'], 'source'=>$source, 'source_label'=>self::channel_label( $source ), 'source_host'=>(string) ( $row['source_host'] ?? '' ), 'last_seen'=>gmdate( 'c', (int) strtotime( $row['last_seen'] . ' UTC' ) ) ); } $waiting = $view === 'waiting' ? $total : (int) $wpdb->get_var( $wpdb->prepare( 'SELECT COUNT(*) FROM %i WHERE last_seen <= %s AND last_seen >= %s AND expires_at >= %s AND customer_id NOT IN (' . implode( ',', array_fill( 0, count( $skip ), '%d' ) ) . ')', array_merge( array( $table, $cutoff, $kept, $now ), $skip ) ) ); return array_merge( $empty, array( 'total'=>$total, 'waiting'=>$waiting, 'registered'=>$registered, 'anonymous'=>$total - $registered, 'recent_active'=>$active, 'page'=>$page, 'pages'=>$pages, 'rows'=>$out, 'sources'=>$sources ) ); } }