5, 'order' => 5, 'client' => 30, ); public const WINDOW = 3600; private const SETTINGS = 'storesuit_order_tracking_settings'; private const COUNTER = 'storesuit_track_miss_'; private const STEPS = array( 'shipping' => array( 'received' => 'Order received', 'preparing' => 'Being prepared', 'shipped' => 'On its way', 'delivered' => 'Delivered', ), 'pickup' => array( 'received' => 'Order received', 'preparing' => 'Being prepared', 'ready_pickup' => 'Ready for pickup', 'picked_up' => 'Picked up', ), ); private const LABELS = array( 'awaiting_payment' => 'Awaiting payment', 'failed' => 'Payment failed', 'received' => 'Order received', 'preparing' => 'Being prepared', 'shipped' => 'On its way', 'delivered' => 'Delivered', 'ready_pickup' => 'Ready for pickup', 'picked_up' => 'Picked up', 'completed' => 'Completed', 'cancelled' => 'Order cancelled', 'refunded' => 'Order refunded', ); private static int $instances = 0; /** The page's own answer to a post made without the script, shown once in the next form. */ private static ?array $posted = null; public static function hooks() : void { add_shortcode( 'storesuit_order_tracking', array(__CLASS__, 'shortcode') ); add_action( 'init', array(__CLASS__, 'register_block') ); add_action( 'wc_ajax_' . self::ACTION, array(__CLASS__, 'ajax') ); add_action( 'template_redirect', array(__CLASS__, 'posted'), 5 ); add_action( 'admin_menu', array(__CLASS__, 'menu'), 998 ); add_action( 'admin_post_storesuit_tracking_settings', array(__CLASS__, 'save') ); } // ---- Storefront ----------------------------------------------------------------------------------------------- public static function register_block() : void { if ( \WP_Block_Type_Registry::get_instance()->is_registered( 'storesuit/order-tracking' ) ) { return; } wp_register_script( 'storesuit-order-tracking-block', plugins_url( 'assets/order-tracking-block.js', STORESUIT_FILE ), array( 'wp-blocks', 'wp-element', 'wp-block-editor', 'wp-components' ), (string) filemtime( STORESUIT_DIR . 'assets/order-tracking-block.js' ), true ); $title = I18n::t( 'Order tracking' ); $description = I18n::t( 'Customers enter their order number and email address to see its status.' ); wp_localize_script( 'storesuit-order-tracking-block', 'StoreSuitTrackingBlock', array( 'title' => $title, 'description' => $description, 'keywords' => self::keywords(), 'heading' => I18n::t( 'Where is my order?' ), 'show' => I18n::t( 'Show the heading' ), 'settings' => I18n::t( 'Settings' ), ) ); register_block_type( 'storesuit/order-tracking', array( 'api_version' => 3, 'title' => $title, 'description' => $description, 'category' => 'widgets', 'icon' => 'location-alt', 'keywords' => self::keywords(), 'attributes' => array( 'showTitle' => array( 'type' => 'boolean', 'default' => true, ), ), 'supports' => array( 'html' => false, ), 'editor_script' => 'storesuit-order-tracking-block', 'render_callback' => array(__CLASS__, 'block'), ) ); } private static function keywords() : array { return array_values( array_unique( array( 'order', 'tracking', 'track', 'shipment', 'delivery', mb_strtolower( I18n::t( 'Order tracking' ) ), mb_strtolower( I18n::t( 'Tracking number' ) ) ) ) ); } public static function block( $attributes = array() ) : string { return self::form( !is_array( $attributes ) || !array_key_exists( 'showTitle', $attributes ) || false !== $attributes['showTitle'] ); } /** [storesuit_order_tracking], or [storesuit_order_tracking title="no"] without the heading. */ public static function shortcode( $atts = array() ) : string { $atts = shortcode_atts( array( 'title' => 'yes', ), ( is_array( $atts ) ? $atts : array() ), 'storesuit_order_tracking' ); return self::form( !in_array( strtolower( (string) $atts['title'] ), array( 'no', '0', 'false', 'off', 'hide' ), true ) ); } /** * The form. It posts to the page itself (no action attribute), so without the script the page answers in place; the * script sends the same fields to the wc-ajax endpoint instead. The fields carry their own names, so no other form * handler on the page picks them up. */ public static function form( bool $heading = true ) : string { wp_enqueue_style( 'storesuit-order-tracking', plugins_url( 'assets/order-tracking.css', STORESUIT_FILE ), array(), (string) filemtime( STORESUIT_DIR . 'assets/order-tracking.css' ) ); wp_enqueue_script( 'storesuit-order-tracking', plugins_url( 'assets/order-tracking.js', STORESUIT_FILE ), array(), (string) filemtime( STORESUIT_DIR . 'assets/order-tracking.js' ), array( 'in_footer' => true, 'strategy' => 'defer', ) ); // A page already titled "Where is my order?" does not show the same heading twice (0.61.1). if ( $heading && is_singular() && mb_strtolower( trim( wp_strip_all_tags( (string) get_the_title( get_queried_object_id() ) ) ) ) === mb_strtolower( I18n::t( 'Where is my order?' ) ) ) { $heading = false; } $id = 'ss-tracking-' . ++self::$instances; $answer = ''; if ( self::$posted !== null ) { $answer = self::$posted['html']; self::$posted = null; } return '
' . (( $heading ? '

' . esc_html( I18n::t( 'Where is my order?' ) ) . '

' : '' )) . '

' . esc_html( I18n::t( 'Enter your order number and the email address you used at checkout.' ) ) . ' ' . esc_html( I18n::t( 'You will find the order number in your order confirmation email.' ) ) . '

' . '
' . '' . '

' . '

' . '

' . '
' . $answer . '
'; } /** A post made without the script (or before it loaded): answered by the page itself, never cached, nothing in the URL. */ public static function posted() : void { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- A read-only lookup without a nonce, so cached pages keep working (see the class comment). if ( !self::is_post() || empty( $_POST['storesuit_order_tracking'] ) || wp_doing_ajax() ) { return; } nocache_headers(); wc_maybe_define_constant( 'DONOTCACHEPAGE', true ); // Page caches' own signal, set the way WooCommerce sets it for the cart and checkout. self::$posted = self::answer( self::posted_fields(), self::client( Store::request_addresses() ) ); } /** wc-ajax=storesuit_track_order: POST only, never cached, HTTP 200 with the answer (429 at the limit). */ public static function ajax() : void { nocache_headers(); if ( !self::is_post() ) { header( 'Allow: POST' ); wp_send_json_error( array( 'code' => 'method', ), 405 ); } $answer = self::answer( self::posted_fields(), self::client( Store::request_addresses() ) ); $payload = array( 'code' => $answer['code'], 'html' => $answer['html'], ); if ( $answer['code'] === 'found' ) { wp_send_json_success( $payload, 200 ); } if ( $answer['code'] === 'limit' ) { header( 'Retry-After: ' . (self::WINDOW - time() % self::WINDOW) ); } wp_send_json_error( $payload, ( $answer['code'] === 'limit' ? 429 : 200 ) ); } /** Whether this request is a POST (the request method, unslashed and sanitized). */ private static function is_post() : bool { return isset( $_SERVER['REQUEST_METHOD'] ) && is_string( $_SERVER['REQUEST_METHOD'] ) && sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) === 'POST'; } /** The form's two fields, each read alone, unslashed and sanitized; nothing else of the post is read. */ private static function posted_fields() : array { $fields = array(); foreach ( array('storesuit_track_number', 'storesuit_track_email') as $key ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- A read-only lookup without a nonce, so cached pages keep working (see the class comment). $fields[$key] = ( isset( $_POST[$key] ) && is_string( $_POST[$key] ) ? sanitize_text_field( wp_unslash( $_POST[$key] ) ) : '' ); } return $fields; } /** The lookup and the HTML both ways answer with (the page without the script, and the wc-ajax endpoint). */ public static function answer( array $post, string $client ) : array { $field = static fn( string $key ): string => ( isset( $post[$key] ) && is_scalar( $post[$key] ) ? (string) $post[$key] : '' ); try { $result = self::lookup( array( 'number' => $field( 'storesuit_track_number' ), 'email' => $field( 'storesuit_track_email' ), ), $client ); return $result + array( 'html' => self::html( $result ), ); } catch ( \Throwable $e ) { if ( function_exists( 'wc_get_logger' ) ) { wc_get_logger()->error( 'Order tracking lookup failed: ' . get_class( $e ) . ': ' . $e->getMessage(), array( 'source' => 'storesuit-order-tracking', ) ); } $result = array( 'code' => 'error', ); return $result + array( 'html' => self::html( $result ), ); } } /** * The lookup itself, without output: array( 'code'=>'found', 'view'=>… ), or 'invalid' for every miss and for input * that cannot be an order number or an email, or 'limit'. The attempt is counted before the order is read and given * back when it finds the order, so parallel requests cannot pass a limit together and only misses stay counted. */ public static function lookup( array $input, string $client ) : array { $number = self::number( (string) ($input['number'] ?? '') ); $email = self::email( (string) ($input['email'] ?? '') ); if ( $number === '' || $email === '' ) { return array( 'code' => 'invalid', ); } $buckets = self::buckets( $number, $email, $client ); if ( !self::reserve( $buckets ) ) { self::forget_old(); return array( 'code' => 'limit', ); } $order = self::find( $number, $email ); if ( !$order ) { self::forget_old(); return array( 'code' => 'invalid', ); } self::release( $buckets ); return array( 'code' => 'found', 'view' => self::view( $order ), ); } /** The order number as typed: trimmed, without a leading "#"; empty when it cannot be one. */ public static function number( string $raw ) : string { $number = trim( ltrim( trim( sanitize_text_field( $raw ) ), '#' ) ); return ( strlen( $number ) <= 64 ? $number : '' ); } public static function email( string $raw ) : string { $email = strtolower( trim( sanitize_email( trim( $raw ) ) ) ); return ( $email !== '' && strlen( $email ) <= 254 && is_email( $email ) ? $email : '' ); } /** * Exactly one order: the number goes through the same filter chain as WooCommerce's own tracking form * (woocommerce_shortcode_order_tracking_order_id: order number plugins map their numbers to an ID there, and * WooCommerce's wc_sanitize_order_id() keeps the digits), then that order's own number (ignoring case, "#" and leading * zeros) and its billing email must match what was typed, so a number that sanitizing turns into another order's ID * finds nothing. Nothing is searched by email: the answer takes as long for an address with many orders as for none. */ public static function find( string $number, string $email ) : ?\WC_Order { if ( $number === '' || $email === '' || self::refused( $email ) ) { return null; } // phpcs:disable WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- WooCommerce's own filter, applied as its tracking form does. $resolved = apply_filters( 'woocommerce_shortcode_order_tracking_order_id', $number ); // phpcs:enable WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound if ( $resolved instanceof \WC_Abstract_Order ) { $resolved = $resolved->get_id(); } $id = ( is_int( $resolved ) ? $resolved : (( is_string( $resolved ) && ctype_digit( $resolved ) ? (int) $resolved : 0 )) ); if ( $id <= 0 ) { return null; } $order = wc_get_order( $id ); if ( !self::eligible( $order ) || !self::same_number( (string) $order->get_order_number(), $number ) ) { return null; } return ( hash_equals( strtolower( trim( (string) $order->get_billing_email() ) ), $email ) ? $order : null ); } /** Addresses nobody can prove they own: anonymised orders' "deleted@site.invalid" (any ".invalid"), and the store's own public addresses. */ private static function refused( string $email ) : bool { $domain = strtolower( substr( (string) strrchr( $email, '@' ), 1 ) ); if ( $domain === 'invalid' || str_ends_with( $domain, '.invalid' ) ) { return true; } $own = array_filter( array_map( static fn( $address ) => strtolower( trim( (string) $address ) ), array(get_option( 'admin_email' ), get_option( 'woocommerce_email_from_address' )) ) ); return in_array( $email, $own, true ); } private static function eligible( $order ) : bool { return $order instanceof \WC_Order && $order->get_type() === 'shop_order' && !$order->has_status( array( 'trash', 'checkout-draft', 'auto-draft', 'draft' ) ) && $order->get_meta( '_anonymized' ) !== 'yes' && !$order->get_meta( '_storesuit_demo' ); } private static function same_number( string $shown, string $typed ) : bool { $shown = mb_strtolower( trim( ltrim( trim( $shown ), '#' ) ) ); $typed = mb_strtolower( $typed ); if ( ctype_digit( $shown ) && ctype_digit( $typed ) ) { $shown = ltrim( $shown, '0' ); $typed = ltrim( $typed, '0' ); } return $shown !== '' && hash_equals( $shown, $typed ); } // ---- Attempt limits -------------------------------------------------------------------------------------------- /** * The visitor for the per-visitor backstop: REMOTE_ADDR, an IPv6 address by its /64 network (one connection gets a * whole /64). Behind a local proxy or load balancer (a private or loopback REMOTE_ADDR) the address that proxy wrote into * X-Forwarded-For (the right-most public one) or X-Real-IP is used; from anywhere else these headers could be forged, * so they are ignored. A store behind a CDN that does not restore the visitor's address can name it with the * storesuit_order_tracking_client_ip filter (which gets only these three headers; a CDN's own header is the filter's to * read); otherwise that CDN's addresses share the generous backstop, and the limits per email and per order number still hold. */ public static function client( array $server ) : string { $ip = self::plain( (string) ($server['REMOTE_ADDR'] ?? '') ); if ( $ip !== '' && self::internal( $ip ) ) { $hops = array_reverse( explode( ',', (string) ($server['HTTP_X_FORWARDED_FOR'] ?? '') ) ); $hops[] = (string) ($server['HTTP_X_REAL_IP'] ?? ''); foreach ( $hops as $hop ) { $hop = self::plain( $hop ); if ( $hop !== '' && !self::internal( $hop ) ) { $ip = $hop; break; } } } return self::network( (string) apply_filters( 'storesuit_order_tracking_client_ip', $ip, $server ) ); } /** One key per visitor: an IPv4 address, or the /64 network of an IPv6 address. */ public static function network( string $ip ) : string { $ip = self::plain( $ip ); if ( $ip === '' ) { return 'unknown'; } $packed = (string) inet_pton( $ip ); return ( strlen( $packed ) === 16 ? inet_ntop( substr( $packed, 0, 8 ) . str_repeat( "\x00", 8 ) ) . '/64' : (string) inet_ntop( $packed ) ); } /** A valid address in its short form, an IPv4-mapped IPv6 address (a dual-stack server's IPv4 visitor) as IPv4; '' otherwise. */ private static function plain( string $ip ) : string { $ip = trim( $ip ); if ( !filter_var( $ip, FILTER_VALIDATE_IP ) ) { return ''; } $packed = (string) inet_pton( $ip ); if ( strlen( $packed ) === 16 && substr( $packed, 0, 12 ) === str_repeat( "\x00", 10 ) . "\xff\xff" ) { $packed = substr( $packed, 12 ); } return (string) inet_ntop( $packed ); } private static function internal( string $ip ) : bool { return !filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE ); } private static function window( int $at = 0 ) : string { return sprintf( '%08d', intdiv( ( $at ?: time() ), self::WINDOW ) ); } /** The counter names of the window around $at (now by default): keyed hashes, so no email, order number or address is stored. */ public static function buckets( string $number, string $email, string $client, int $at = 0 ) : array { $number = mb_strtolower( $number ); if ( ctype_digit( $number ) ) { $number = ltrim( $number, '0' ); } $prefix = self::COUNTER . self::window( $at ) . '_'; $key = static fn( string $kind, string $value ): string => $prefix . $kind . substr( hash_hmac( 'sha256', $kind . '|' . $value, wp_salt( 'nonce' ) ), 0, 32 ); return array( 'email' => $key( 'e', $email ), 'order' => $key( 'o', $number ), 'client' => $key( 'c', $client ), ); } /** * Counts the attempt, then reads the counts: false when one is past its limit. The visitor's own counter comes first and * alone: a visitor already past it counts nothing on the shared email and order counters, so one visitor cannot lock * other customers' orders and addresses out, and adds no counter rows (security review S10). Then the email and order * counters together, in one atomic statement. */ private static function reserve( array $buckets ) : bool { if ( isset( $buckets['client'] ) ) { if ( !self::bump( array( 'client' => $buckets['client'], ) ) ) { return false; } unset($buckets['client']); } return !$buckets || self::bump( $buckets ); } /** One counter (the visitor's) or two (the email's and the order's, together): each statement written out, as WordPress.org asks. */ private static function bump( array $buckets ) : bool { global $wpdb; $names = array_values( $buckets ); $two = count( $names ) === 2; if ( !$two && count( $names ) !== 1 ) { throw new \LogicException('One or two attempt counters at a time.'); } $done = ( $two ? $wpdb->query( $wpdb->prepare( "INSERT INTO %i (option_name,option_value,autoload) VALUES (%s,'1','no'),(%s,'1','no') ON DUPLICATE KEY UPDATE option_value=CAST(option_value AS UNSIGNED)+1", $wpdb->options, $names[0], $names[1] ) ) : $wpdb->query( $wpdb->prepare( "INSERT INTO %i (option_name,option_value,autoload) VALUES (%s,'1','no') ON DUPLICATE KEY UPDATE option_value=CAST(option_value AS UNSIGNED)+1", $wpdb->options, $names[0] ) ) ); if ( false === $done ) { throw new \RuntimeException('The attempt could not be counted.'); } $counts = ( $two ? $wpdb->get_results( $wpdb->prepare( 'SELECT option_name,option_value FROM %i WHERE option_name IN (%s,%s)', $wpdb->options, $names[0], $names[1] ), OBJECT_K ) : $wpdb->get_results( $wpdb->prepare( 'SELECT option_name,option_value FROM %i WHERE option_name=%s', $wpdb->options, $names[0] ), OBJECT_K ) ); foreach ( $buckets as $kind => $name ) { if ( !isset( $counts[$name] ) ) { throw new \RuntimeException('The attempt could not be counted.'); } if ( (int) $counts[$name]->option_value > self::LIMITS[$kind] ) { return false; } } return true; } /** A success is not a miss: its attempt is given back. */ private static function release( array $buckets ) : void { global $wpdb; $wpdb->query( $wpdb->prepare( 'UPDATE %i SET option_value=GREATEST(CAST(option_value AS SIGNED)-1,0) WHERE option_name IN (%s,%s,%s)', $wpdb->options, (string) $buckets['email'], (string) $buckets['order'], (string) $buckets['client'] ) ); } /** * Counters of earlier windows no longer count; each miss and each refused attempt removes up to 500 of them, so a visitor * request never runs an unbounded delete and old windows still go even when every attempt is refused. */ private static function forget_old() : void { global $wpdb; $wpdb->query( $wpdb->prepare( 'DELETE FROM %i WHERE option_name LIKE %s AND option_name < %s LIMIT 500', $wpdb->options, $wpdb->esc_like( self::COUNTER ) . '%', self::COUNTER . self::window() ) ); } // ---- What the customer sees ------------------------------------------------------------------------------------ /** * Only what helps a customer who already knows the order: its number and date, the status, the progress, parcels with * their tracking numbers, and a partly hidden amount and destination. Never a name, street, phone, email, product, * note, payment method, order key or admin link; in-store handover rows hold the recipient's name and are left out. */ public static function view( \WC_Order $order ) : array { $flow = OrderFlow::read( $order ); $pickup = (( $order->get_meta( '_storesuit_delivery' ) ?: 'shipping' )) === 'pickup'; $status = self::status( $order, $flow, $pickup ); $path = self::STEPS[( $pickup ? 'pickup' : 'shipping' )]; $steps = array(); if ( isset( $path[$status] ) ) { $at = array_search( $status, array_keys( $path ), true ); foreach ( array_keys( $path ) as $i => $key ) { $steps[] = array( 'key' => $key, 'label' => I18n::t( $path[$key] ), 'state' => ( $i < $at ? 'done' : (( $i === $at ? 'current' : 'next' )) ), ); } } $notes = array(); if ( !$pickup && !empty( $flow['partial_shipment'] ) && in_array( $status, array('received', 'preparing'), true ) ) { $notes[] = I18n::t( 'Part of your order is on its way.' ); } $open = false; $finished = false; foreach ( (array) (( $order->get_meta( '_storesuit_returns' ) ?: array() )) as $return ) { $state = ( is_array( $return ) ? (string) ($return['status'] ?? '') : '' ); if ( in_array( $state, array('requested', 'approved'), true ) ) { $open = true; } elseif ( in_array( $state, array('refunded', 'exchanged'), true ) ) { $finished = true; } } if ( $open ) { $notes[] = I18n::t( 'Return requested' ); } elseif ( $finished ) { $notes[] = I18n::t( 'Return completed' ); } $parcels = self::parcels( $order ); if ( !in_array( $status, array('shipped', 'preparing', 'received'), true ) ) { foreach ( $parcels as &$parcel ) { $parcel['expected'] = ''; } unset($parcel); } $created = $order->get_date_created(); return array( 'number' => (string) $order->get_order_number(), 'date' => ( $created ? wc_format_datetime( $created ) : '' ), 'status' => $status, 'label' => ( $status === 'custom' ? wc_get_order_status_name( $order->get_status() ) : I18n::t( self::LABELS[$status] ) ), 'steps' => $steps, 'notes' => $notes, 'total' => self::mask_total( $order ), 'destination' => ( $pickup ? '' : self::mask_place( $order ) ), 'parcels' => $parcels, 'whatsapp' => self::whatsapp(), ); } /** * WooCommerce's status first, then StoreSuit's stage. "Delivered" or "Picked up" needs a record of it: a confirmed * delivery, an in-person handover or a store pickup, with every product shipped. WooCommerce's "Completed" alone stays * "Completed". An order waiting for a bank transfer, a cheque or a payment link says so instead of "Being prepared". */ private static function status( \WC_Order $order, array $flow, bool $pickup ) : string { $status = $order->get_status(); if ( in_array( $status, array('cancelled', 'refunded', 'failed'), true ) ) { return $status; } if ( $status === 'pending' ) { return 'awaiting_payment'; } if ( !in_array( $status, array('processing', 'on-hold', 'completed'), true ) ) { return 'custom'; } $totals = $flow['totals']; if ( $status === 'on-hold' && (float) $totals['shipped'] <= 0 && self::awaits_payment( $order ) ) { return 'awaiting_payment'; } $shipped = (float) $totals['quantity'] > 0 && (float) $totals['shipped'] >= (float) $totals['quantity']; if ( $shipped && ($pickup || !empty( $flow['delivery_confirmed'] ) || self::handed_over( $order )) ) { return ( $pickup ? 'picked_up' : 'delivered' ); } if ( $shipped ) { return 'shipped'; } if ( $status === 'completed' ) { return 'completed'; } if ( (float) $totals['quantity'] > 0 && (float) $totals['packed'] >= (float) $totals['quantity'] ) { return ( $pickup ? 'ready_pickup' : 'preparing' ); } return ( $flow['stage'] === 'preparing' ? 'preparing' : 'received' ); } /** Still to be paid by the customer before it ships: StoreSuit's payment link or manual (bank transfer) orders, WooCommerce's bank transfer and cheque. Cash on delivery and payment terms are not. */ private static function awaits_payment( \WC_Order $order ) : bool { if ( Orders::outstanding( $order ) <= 0 ) { return false; } $mode = (string) $order->get_meta( '_storesuit_payment_mode' ); if ( $mode !== '' ) { return in_array( $mode, array('link', 'manual'), true ); } return in_array( $order->get_payment_method(), array('bacs', 'cheque'), true ); } private static function handed_over( \WC_Order $order ) : bool { $rows = array_filter( (array) (( $order->get_meta( '_storesuit_shipments' ) ?: array() )), 'is_array' ); foreach ( $rows as $row ) { if ( ($row['mode'] ?? '') !== 'handover' ) { return false; } } return (bool) $rows; } /** Parcels with a tracking number, newest first, one per carrier and number (a parcel shipped line by line is recorded per line), at most five. */ private static function parcels( \WC_Order $order ) : array { $out = array(); $rows = array_reverse( array_filter( (array) (( $order->get_meta( '_storesuit_shipments' ) ?: array() )), 'is_array' ) ); foreach ( $rows as $row ) { if ( ($row['mode'] ?? '') === 'handover' ) { continue; } $tracking = mb_substr( sanitize_text_field( (string) ($row['tracking'] ?? '') ), 0, 100 ); if ( $tracking === '' ) { continue; } $carrier = mb_substr( sanitize_text_field( (string) ($row['carrier_label'] ?? '') ), 0, 80 ); $key = mb_strtolower( $carrier . '|' . preg_replace( '/\\s+/', '', $tracking ) ); if ( isset( $out[$key] ) ) { continue; } // EasyPost's own page for this parcel when it is linked; the carrier's tracking page otherwise. Only http(s). $tracker = ( is_array( $row['provider_tracker'] ?? null ) ? (string) ($row['provider_tracker']['public_url'] ?? '') : '' ); $url = ( $tracker !== '' && wp_parse_url( $tracker, PHP_URL_SCHEME ) === 'https' && wp_parse_url( $tracker, PHP_URL_HOST ) === 'track.easypost.com' ? $tracker : (string) ($row['tracking_url'] ?? '') ); $expected = (string) ($row['expected_delivery'] ?? ''); $date = ''; // Only a real calendar day is shown; PHP would silently turn 2026-02-31 into another date. if ( Store::valid_date( $expected ) ) { $day = \DateTimeImmutable::createFromFormat( '!Y-m-d H:i', $expected . ' 12:00', wp_timezone() ); $date = ( $day ? wp_date( (string) get_option( 'date_format' ), $day->getTimestamp() ) : '' ); } $out[$key] = array( 'carrier' => $carrier, 'tracking' => $tracking, 'url' => esc_url_raw( $url, array('http', 'https') ), 'expected' => (string) $date, ); if ( count( $out ) >= 5 ) { break; } } return array_values( $out ); } /** The amount in the store's format with its whole part hidden: "$•••.45", "•••,45 €" (zero-decimal currencies: the last digits). */ public static function mask_total( \WC_Order $order ) : string { $decimals = max( 0, (int) wc_get_price_decimals() ); $parts = explode( '.', number_format( abs( (float) $order->get_total() ), $decimals, '.', '' ) ); $whole = $parts[0]; $shown = ( $decimals > 0 ? wc_get_price_decimal_separator() . ($parts[1] ?? '') : substr( $whole, ( strlen( $whole ) > 2 ? -2 : -1 ) ) ); $symbol = html_entity_decode( (string) get_woocommerce_currency_symbol( $order->get_currency() ), ENT_QUOTES, 'UTF-8' ); $format = str_replace( ' ', " ", html_entity_decode( (string) get_woocommerce_price_format(), ENT_QUOTES, 'UTF-8' ) ); return sprintf( $format, $symbol, '•••' . $shown ); } /** The first letter of the delivery city and the country's name: "B•••, United Kingdom". */ public static function mask_place( \WC_Order $order ) : string { $shipping = $order->has_shipping_address(); $city = trim( (string) (( $shipping ? $order->get_shipping_city() : $order->get_billing_city() )) ); $code = strtoupper( trim( (string) (( $shipping ? $order->get_shipping_country() : $order->get_billing_country() )) ) ); $countries = ( WC()->countries ? (array) WC()->countries->get_countries() : array() ); $country = ( $code !== '' ? html_entity_decode( (string) ($countries[$code] ?? $code), ENT_QUOTES, 'UTF-8' ) : '' ); return implode( ', ', array_filter( array(( $city !== '' ? mb_substr( $city, 0, 1 ) . '•••' : '' ), $country) ) ); } /** The answer's HTML, in the site's language. Every miss, whatever its cause, gets the same text. */ public static function html( array $result ) : string { $code = (string) ($result['code'] ?? 'invalid'); if ( $code === 'found' && is_array( $result['view'] ?? null ) ) { return self::card( $result['view'] ); } $lines = ( $code === 'limit' ? array('Too many attempts. Please try again in an hour.') : (( $code === 'error' ? array('The order could not be checked right now. Please try again later.') : array('We could not find an order with those details.', 'Check the order number in your order confirmation email and use the email address you ordered with.') )) ); return '
' . implode( '', array_map( static fn( $line ) => '

' . esc_html( I18n::t( $line ) ) . '

', $lines ) ) . self::contact() . '
'; } private static function card( array $view ) : string { $out = '

' . esc_html( I18n::format( 'Order #%s', $view['number'] ) ) . (( $view['date'] !== '' ? ' · ' . esc_html( $view['date'] ) : '' )) . '

' . '

' . esc_html( $view['label'] ) . '

'; if ( $view['steps'] ) { $out .= '
    '; foreach ( $view['steps'] as $step ) { $out .= '
  1. ' . (( $step['state'] === 'done' ? '' . esc_html( $step['label'] ) . ' (' . esc_html( I18n::t( 'Step completed' ) ) . ')' : esc_html( $step['label'] ) )) . '
  2. '; } $out .= '
'; } foreach ( $view['notes'] as $note ) { $out .= '

' . esc_html( $note ) . '

'; } $facts = array_filter( array( I18n::t( 'Amount' ) => $view['total'], I18n::t( 'Delivery to' ) => $view['destination'], ), static fn( $value ) => $value !== '' ); if ( $facts ) { $out .= '
'; // The hidden part is not read out; the note below says it is hidden. foreach ( $facts as $name => $value ) { $out .= '
' . esc_html( (string) $name ) . '
' . preg_replace( '/•+/u', '', esc_html( $value ) ) . '
'; } $out .= '

' . esc_html( I18n::t( 'Partly hidden for your privacy.' ) ) . '

'; } if ( $view['parcels'] ) { $out .= ''; } return $out . self::contact() . '
'; } private static function contact() : string { $url = self::whatsapp(); return ( $url === '' ? '' : '

' . esc_html( I18n::t( 'Contact the store on WhatsApp' ) ) . ' ' . esc_html( I18n::t( '(opens in a new tab)' ) ) . '

' ); } // ---- Owner settings -------------------------------------------------------------------------------------------- public static function settings() : array { $value = get_option( self::SETTINGS, array() ); $value = ( is_array( $value ) ? $value : array() ); return array( 'whatsapp' => !empty( $value['whatsapp'] ), 'phone' => (string) preg_replace( '/\\D+/', '', (string) ($value['phone'] ?? '') ), ); } /** The store's WhatsApp chat (a link only; nothing about the order goes with it), when the owner switched it on. */ public static function whatsapp() : string { $settings = self::settings(); return ( $settings['whatsapp'] && preg_match( '/^[1-9][0-9]{6,14}$/D', $settings['phone'] ) ? 'https://wa.me/' . $settings['phone'] : '' ); } public static function menu() : void { $title = I18n::t( 'Order tracking' ); $hook = add_submenu_page( '', $title, $title, 'manage_woocommerce', self::PAGE, array(__CLASS__, 'admin') ); if ( $hook ) { add_action( 'load-' . $hook, static function () use($title) { $GLOBALS['title'] = $title; } ); } } /** * Where customers find the lookup: published pages holding the block or the shortcode (at most 5), and whether the * StoreRuler theme shows its Order tracking section (Almira audit SAT-20, 28 Oct 2026: the settings gave the shortcode, * no page had it, and nothing said so). * @return array{pages:array,section:bool} */ public static function places() : array { global $wpdb; $ids = (array) $wpdb->get_col( $wpdb->prepare( "SELECT ID FROM %i WHERE post_status='publish' AND post_type IN ('page','post') AND ( post_content LIKE %s OR post_content LIKE %s ) ORDER BY ID LIMIT 5", $wpdb->posts, '%' . $wpdb->esc_like( '[storesuit_order_tracking' ) . '%', '%' . $wpdb->esc_like( '