` on THIS domain (first-party — * Safari ITP never touches it), shape = aoo's CartLine (lib/storefront-cart.ts). assets/aoo-shop.js owns it. * - `{shop}/cart/` route (preview: /aoo-preview/cart/) — the template page with an empty shell that the JS fills. * Never calls aoo; noindex, no canonical. Added through `aooshop_route_rules` (before the product catch-all, * so a product whose slug is literally "cart" is shadowed — acceptable) + `aooshop_local_payload`. * - REST `POST /wp-json/aooshop/v1/checkout` — validates the cart, IP rate limit 20/min, asks aoo for a cart token * (Bearer key stays on the server) → {checkout_url, dropped}. The browser then goes to aoo's checkout. * - REST `GET /wp-json/aooshop/v1/cart-status?token=` — proxies aoo's public status (15 s cache per token) so the JS * can clear the cart once the order went through. The browser never talks to aoo directly. * - `[aooshop_cart_icon]` (via `aooshop_cart_icon_html`) + Elementor widget "AOO Cart Icon": a link to the cart page * (works without JS) that opens the mini-cart drawer. Any `` gets the count. * - Script (deferred, footer) + bridge CSS load on every page since 0.5 — a `[data-aoo-cart-count]` in the theme * header always works (was the "แสดงไอคอนตะกร้า" option). Preview mode: only for users who may preview (links * would 404 for visitors). * * @package AooShop */ defined( 'ABSPATH' ) || exit; class AooShop_Cart { const SCRIPT = 'aooshop'; const STYLE = 'aooshop-bridge'; const RATE_LIMIT = 20; // checkout calls per IP per minute const STATUS_LIMIT = 120; // status calls per IP per minute const STATUS_TTL = 15; const MAX_LINES = 50; const MAX_QTY = 99; const UUID_RE = '/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i'; const UTM_KEYS = array( 'source', 'medium', 'campaign', 'term', 'content' ); /** @var AooShop_Settings */ private $settings; /** @var AooShop_Api_Client */ private $api; /** @var AooShop_Router */ private $router; /** @var AooShop_Renderer */ private $renderer; public function __construct( AooShop_Settings $settings, AooShop_Api_Client $api, AooShop_Router $router, AooShop_Renderer $renderer ) { $this->settings = $settings; $this->api = $api; $this->router = $router; $this->renderer = $renderer; } public function hooks() { add_filter( 'aooshop_route_rules', array( $this, 'route_rules' ), 10, 2 ); add_filter( 'aooshop_local_payload', array( $this, 'local_payload' ), 10, 2 ); add_action( 'aooshop_rest_routes', array( $this, 'rest_routes' ) ); add_action( 'wp_enqueue_scripts', array( $this, 'maybe_enqueue' ), 22 ); add_filter( 'aooshop_cart_icon_html', array( $this, 'cart_icon_html' ), 5 ); // Cards render through AooShop_Cards::render() → filter on its HTML = "a card is on this page". add_filter( 'aooshop_cards_html', array( $this, 'enqueue_passthrough' ) ); // Browse widgets (collections · chips · search — class-browse.php) need the script (autocomplete · countdown). add_action( 'aooshop_enqueue_front', array( $this, 'enqueue' ) ); // SiteGround Optimizer writes `.min.css` NEXT TO the source file (inside this plugin) — the files are // tiny already; keep the plugin directory untouched. add_filter( 'sgo_css_minify_exclude', array( $this, 'sgo_exclude_style' ) ); add_filter( 'sgo_js_minify_exclude', array( $this, 'sgo_exclude_script' ) ); } public function sgo_exclude_style( $handles ) { $handles = is_array( $handles ) ? $handles : array(); $handles[] = self::STYLE; return $handles; } public function sgo_exclude_script( $handles ) { $handles = is_array( $handles ) ? $handles : array(); $handles[] = self::SCRIPT; return $handles; } // ── route ───────────────────────────────────────────────────────── public function route_rules( $rules, $paths ) { $shop = preg_quote( ltrim( $paths['prefix_shop'], '/' ), '#' ); $rules[ "^{$shop}/cart/?$" ] = 'index.php?aooshop_route=cart'; return $rules; } public function cart_url() { $p = $this->settings->paths(); return untrailingslashit( home_url() ) . $p['prefix_shop'] . '/cart/'; } public function local_payload( $payload, $route ) { if ( ! is_array( $route ) || 'cart' !== $route['type'] ) { return $payload; } $shop_url = $this->router->url( 'index' ); $html = '' . '' . '' . esc_html__( 'Cart', 'aoo-shop' ) . '' . '' . esc_html__( 'Loading cart…', 'aoo-shop' ) . '' . '' . esc_html__( 'JavaScript is required to use the cart.', 'aoo-shop' ) . ' · ' . esc_html__( 'Back to shop', 'aoo-shop' ) . '' . ''; return array( 'status' => 'ok', 'html' => $html, 'seo' => array( 'title' => __( 'Cart', 'aoo-shop' ) . ' | ' . get_bloginfo( 'name' ), 'description' => '', 'robots' => 'noindex, follow', 'canonical' => '', ), 'schema' => array(), 'breadcrumbs' => array(), ); } // ── assets ──────────────────────────────────────────────────────── /** May this request see cart UI at all (preview mode = admins only, like the shop routes). */ public function can_show() { return ! $this->settings->is_preview() || $this->renderer->can_preview(); } public function maybe_enqueue() { if ( ! $this->can_show() || ! $this->settings->is_configured() ) { return; } // Always on (0.5): a cart count in the theme header must work on every page. The script is deferred in the // footer and does nothing heavy until the visitor touches the cart. $this->enqueue(); } public function enqueue_passthrough( $html ) { if ( ! is_admin() && ! ( defined( 'REST_REQUEST' ) && REST_REQUEST ) ) { $this->enqueue(); } return $html; } /** Script (footer, defer) + bridge CSS. Safe to call late (while the body renders) — the script prints in the footer. */ public function enqueue() { if ( wp_script_is( self::SCRIPT, 'enqueued' ) ) { return; } $ver = AOOSHOP_VERSION . '-' . (string) @filemtime( AOOSHOP_DIR . 'assets/aoo-shop.js' ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged $this->enqueue_style(); $args = array( 'in_footer' => true ); if ( version_compare( get_bloginfo( 'version' ), '6.3', '>=' ) ) { $args['strategy'] = 'defer'; } wp_enqueue_script( self::SCRIPT, AOOSHOP_URL . 'assets/aoo-shop.js', array(), $ver, $args ); wp_add_inline_script( self::SCRIPT, 'window.aooShopConfig=' . wp_json_encode( $this->config(), JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT ) . ';', 'before' ); } /** Bridge CSS (cart UI + browse widgets) — also used by the block editor / Elementor preview. */ public function enqueue_style() { if ( wp_style_is( self::STYLE, 'enqueued' ) ) { return; } wp_enqueue_style( self::STYLE, AOOSHOP_URL . 'assets/theme-bridge.css', array(), AOOSHOP_VERSION . '-' . (string) @filemtime( AOOSHOP_DIR . 'assets/theme-bridge.css' ) ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged $css = AooShop_Settings::esc_css_vars_rule( '.aoo-mc-root,.aoo-sc,.aoo-ac', $this->settings->theme_vars() ); if ( '' !== $css ) { wp_add_inline_style( self::STYLE, $css ); } } /** Public, secret-free config for the browser. */ public function config() { $remote = $this->settings->remote(); $slug = ( isset( $remote['company']['storefront_slug'] ) && is_string( $remote['company']['storefront_slug'] ) ) ? $remote['company']['storefront_slug'] : ''; $p = $this->settings->paths(); $cfg = array( 'shop' => sanitize_key( $slug ), 'accepting' => ! isset( $remote['accepting_orders'] ) || ! empty( $remote['accepting_orders'] ), 'cartUrl' => $this->cart_url(), 'shopUrl' => $this->router->url( 'index' ), 'productBase' => untrailingslashit( home_url() ) . $p['prefix_shop'] . '/', 'checkout' => rest_url( AooShop_Rest::NS . '/checkout' ), 'status' => rest_url( AooShop_Rest::NS . '/cart-status' ), 'suggest' => rest_url( AooShop_Rest::NS . '/suggest' ), 'host' => (string) wp_parse_url( home_url(), PHP_URL_HOST ), // Owner picked a color → aoo-shop.js keeps it even when near-black (ensureCtaColor()). 'ownColor' => $this->settings->has_color_override(), 'i18n' => self::i18n(), ); // Preview mode: the data API answers admins only — REST sees the login only with a nonce. Preview pages are // never cached (nocache_headers), so a per-user nonce here is safe. Live pages stay nonce-free (cacheable). if ( $this->settings->is_preview() && is_user_logged_in() ) { $cfg['nonce'] = wp_create_nonce( 'wp_rest' ); } return $cfg; } /** UI strings for assets/aoo-shop.js (cart + browse helpers) — `{code}` / `{n}` / `{q}` are replaced in the browser. */ private static function i18n() { return array( // Cart. 'cart' => __( 'Cart', 'aoo-shop' ), 'close' => __( 'Close cart', 'aoo-shop' ), 'empty' => __( 'Your cart is empty.', 'aoo-shop' ), 'keepShopping' => __( 'Continue shopping', 'aoo-shop' ), 'subtotal' => __( 'Subtotal', 'aoo-shop' ), 'note' => __( 'Shipping and discounts are calculated at checkout.', 'aoo-shop' ), 'checkout' => __( 'Checkout', 'aoo-shop' ), 'going' => __( 'Going to checkout…', 'aoo-shop' ), 'goingShort' => __( 'Going…', 'aoo-shop' ), 'viewCart' => __( 'View cart', 'aoo-shop' ), 'added' => __( 'Added to cart', 'aoo-shop' ), 'addedShort' => __( 'Added', 'aoo-shop' ), 'addToCart' => __( 'Add to cart', 'aoo-shop' ), 'remove' => __( 'Remove', 'aoo-shop' ), 'qty' => __( 'Quantity', 'aoo-shop' ), 'dec' => __( 'Decrease quantity', 'aoo-shop' ), 'inc' => __( 'Increase quantity', 'aoo-shop' ), 'paused' => __( 'The shop is temporarily not accepting orders.', 'aoo-shop' ), 'failed' => __( 'Could not open checkout. Please try again.', 'aoo-shop' ), 'offline' => __( 'Could not connect. Please check your internet connection and try again.', 'aoo-shop' ), 'dropped' => __( 'Some items are no longer available and were removed from your cart.', 'aoo-shop' ), 'pending' => __( 'You have an unpaid order.', 'aoo-shop' ), 'resume' => __( 'Continue to payment', 'aoo-shop' ), 'hide' => __( 'Hide', 'aoo-shop' ), 'closeImage' => __( 'Close image', 'aoo-shop' ), // Option chooser sheet. 'chooseTitle' => __( 'Choose an option', 'aoo-shop' ), 'next' => __( 'Next', 'aoo-shop' ), 'back' => __( 'Back', 'aoo-shop' ), 'buyNow' => __( 'Buy now', 'aoo-shop' ), 'soldOut' => __( 'Sold out', 'aoo-shop' ), 'closeChooser' => __( 'Close', 'aoo-shop' ), 'thanks' => __( 'Order received. Thank you!', 'aoo-shop' ), /* translators: {code}: coupon code (keep the {code} token as is) */ 'couponSaved' => __( 'Code {code} saved. Use it at checkout.', 'aoo-shop' ), 'item' => __( 'item', 'aoo-shop' ), 'items' => __( 'items', 'aoo-shop' ), 'unit' => __( 'piece', 'aoo-shop' ), // Browse helpers (countdown · search autocomplete). 'today' => __( 'Today', 'aoo-shop' ), /* translators: {n}: number of days (keep the {n} token as is) */ 'day' => __( 'In {n} day', 'aoo-shop' ), /* translators: {n}: number of days (keep the {n} token as is) */ 'days' => __( 'In {n} days', 'aoo-shop' ), 'categories' => __( 'Categories', 'aoo-shop' ), 'collections' => __( 'Collections', 'aoo-shop' ), 'products' => __( 'Products', 'aoo-shop' ), /* translators: {q}: the search text (keep the {q} token as is) */ 'searchFor' => __( 'Search “{q}”', 'aoo-shop' ), /* translators: {n}: number of products (keep the {n} token as is) */ 'countOne' => __( '{n} item', 'aoo-shop' ), /* translators: {n}: number of products (keep the {n} token as is) */ 'countMany' => __( '{n} items', 'aoo-shop' ), /* translators: {n}: number of suggestions (keep the {n} token as is) */ 'results' => __( 'Suggestions found: {n}. Use the up and down arrow keys to choose.', 'aoo-shop' ), 'noResults' => __( 'No suggestions', 'aoo-shop' ), ); } // ── cart icon ───────────────────────────────────────────────────── public function cart_icon_html( $html ) { if ( ! $this->can_show() ) { return ''; } $this->enqueue(); $label = __( 'Cart', 'aoo-shop' ); return '' . '' . '' . '' . '' . ''; } // ── REST ────────────────────────────────────────────────────────── public function rest_routes( $ns ) { register_rest_route( $ns, '/checkout', array( 'methods' => 'POST', 'callback' => array( $this, 'checkout' ), 'permission_callback' => '__return_true', // Public by design: anyone may buy. Guarded by rate limit + shape checks. // JSON body — every line is re-checked in checkout() (UUID · quantity · MAX_LINES); prices are never sent. 'args' => array( 'lines' => array( 'type' => 'array', 'required' => true, 'maxItems' => 200, 'items' => array( 'type' => 'object' ), ), 'attribution' => array( 'type' => 'object', ), 'coupon' => array( 'type' => 'string', 'maxLength' => 40, 'sanitize_callback' => 'sanitize_text_field', ), ), ) ); register_rest_route( $ns, '/cart-status', array( 'methods' => 'GET', 'callback' => array( $this, 'status' ), 'permission_callback' => '__return_true', 'args' => array( 'token' => array( 'type' => 'string', 'required' => true, 'maxLength' => 100, 'sanitize_callback' => 'sanitize_text_field', ), ), ) ); } private static function reply( $data, $code = 200 ) { $r = new WP_REST_Response( $data, $code ); $r->header( 'Cache-Control', 'no-store' ); $r->header( 'X-Robots-Tag', 'noindex' ); return $r; } private static function fail( $code, $error, $message, array $extra = array() ) { return self::reply( array_merge( array( 'error' => $error, 'message' => $message ), $extra ), $code ); } private static function clean_str( $value, $max ) { if ( ! is_string( $value ) ) { return ''; } $value = trim( wp_check_invalid_utf8( $value ) ); return function_exists( 'mb_substr' ) ? mb_substr( $value, 0, $max ) : substr( $value, 0, $max ); } private static function clean_url( $value ) { $value = self::clean_str( $value, 2000 ); return ( '' !== $value && preg_match( '#^https?://#i', $value ) ) ? esc_url_raw( $value ) : ''; } public function checkout( WP_REST_Request $request ) { // Not gated on preview mode: REST calls carry no login (no nonce on purpose — pages stay cacheable), and a // cart token only ever points at aoo's own checkout for products that are on sale anyway. if ( AooShop_Rest::rate_limited( 'co', (int) apply_filters( 'aooshop_checkout_rate_limit', self::RATE_LIMIT ) ) ) { return self::fail( 429, 'rate_limited', __( 'Too many checkout attempts. Please wait a moment and try again.', 'aoo-shop' ) ); } $body = $request->get_json_params(); if ( ! is_array( $body ) || ! isset( $body['lines'] ) || ! is_array( $body['lines'] ) ) { return self::fail( 400, 'invalid_body', __( 'Invalid cart data.', 'aoo-shop' ) ); } $qty = array(); foreach ( $body['lines'] as $line ) { if ( ! is_array( $line ) || ! isset( $line['variation_id'], $line['quantity'] ) || ! is_string( $line['variation_id'] ) ) { continue; } $id = strtolower( trim( $line['variation_id'] ) ); $q = is_numeric( $line['quantity'] ) ? (int) $line['quantity'] : 0; if ( ! preg_match( self::UUID_RE, $id ) || $q < 1 ) { continue; } if ( ! isset( $qty[ $id ] ) && count( $qty ) >= self::MAX_LINES ) { break; } $qty[ $id ] = min( self::MAX_QTY, ( isset( $qty[ $id ] ) ? $qty[ $id ] : 0 ) + $q ); } if ( ! $qty ) { return self::fail( 400, 'empty_cart', __( 'Your cart is empty.', 'aoo-shop' ) ); } $lines = array(); foreach ( $qty as $id => $q ) { $lines[] = array( 'variation_id' => $id, 'quantity' => $q ); } $attr = array( 'host' => (string) wp_parse_url( home_url(), PHP_URL_HOST ) ); $raw = ( isset( $body['attribution'] ) && is_array( $body['attribution'] ) ) ? $body['attribution'] : array(); $landing = isset( $raw['landing_url'] ) ? self::clean_url( $raw['landing_url'] ) : ''; if ( $landing ) { $attr['landing_url'] = $landing; } $referrer = isset( $raw['referrer'] ) ? self::clean_url( $raw['referrer'] ) : ''; if ( $referrer ) { $attr['referrer'] = $referrer; } $cart_url = isset( $raw['cart_url'] ) ? self::clean_url( $raw['cart_url'] ) : ''; if ( $cart_url && wp_parse_url( $cart_url, PHP_URL_HOST ) === $attr['host'] ) { $attr['cart_url'] = $cart_url; } if ( isset( $raw['utm'] ) && is_array( $raw['utm'] ) ) { $utm = array(); foreach ( self::UTM_KEYS as $k ) { $v = isset( $raw['utm'][ $k ] ) ? self::clean_str( $raw['utm'][ $k ], 200 ) : ''; if ( '' !== $v ) { $utm[ $k ] = sanitize_text_field( $v ); } } if ( $utm ) { $attr['utm'] = $utm; } } $payload = array( 'lines' => $lines, 'attribution' => $attr ); $coupon = isset( $body['coupon'] ) ? sanitize_text_field( self::clean_str( $body['coupon'], 40 ) ) : ''; if ( '' !== $coupon ) { $payload['coupon'] = $coupon; } $res = $this->api->post( 'cart-token', $payload ); if ( is_wp_error( $res ) ) { return self::fail( 502, 'aoo_unreachable', __( 'Could not connect to checkout. Please try again.', 'aoo-shop' ) ); } $data = is_array( $res['data'] ) ? $res['data'] : array(); $dropped = array(); if ( isset( $data['dropped'] ) && is_array( $data['dropped'] ) ) { foreach ( $data['dropped'] as $d ) { if ( is_string( $d ) && preg_match( self::UUID_RE, $d ) ) { $dropped[] = strtolower( $d ); } } } $error = isset( $data['error'] ) ? (string) $data['error'] : ''; if ( 200 === $res['code'] && ! empty( $data['checkout_url'] ) && ! empty( $data['token'] ) && preg_match( self::UUID_RE, (string) $data['token'] ) ) { return self::reply( array( 'checkout_url' => esc_url_raw( (string) $data['checkout_url'] ), 'token' => strtolower( (string) $data['token'] ), 'expires_at' => isset( $data['expires_at'] ) ? (string) $data['expires_at'] : '', 'dropped' => $dropped, ) ); } if ( 409 === $res['code'] && 'not_accepting_orders' === $error ) { return self::fail( 409, 'not_accepting_orders', __( 'The shop is temporarily not accepting orders.', 'aoo-shop' ) ); } if ( 422 === $res['code'] ) { return self::fail( 422, 'empty_cart', __( 'The items in your cart are no longer available.', 'aoo-shop' ), array( 'dropped' => $dropped ) ); } if ( 429 === $res['code'] ) { return self::fail( 429, 'rate_limited', __( 'Many orders are coming in right now. Please try again in a moment.', 'aoo-shop' ) ); } $detail = ''; if ( current_user_can( 'manage_options' ) ) { $detail = ' (' . sprintf( 'HTTP %d %s', (int) $res['code'], $error ) . ')'; } return self::fail( 502, 'checkout_failed', __( 'Could not open checkout. Please try again.', 'aoo-shop' ) . $detail ); } /** {consumed, expired, order_number?} — unknown token = expired (the JS forgets it). */ public function status( WP_REST_Request $request ) { $token = strtolower( trim( (string) $request->get_param( 'token' ) ) ); if ( ! preg_match( self::UUID_RE, $token ) ) { return self::fail( 400, 'invalid_token', __( 'Invalid cart token.', 'aoo-shop' ) ); } $key = 'aooshop_cs_' . md5( $token ); $cached = get_transient( $key ); if ( is_array( $cached ) ) { return self::reply( $cached ); } if ( AooShop_Rest::rate_limited( 'cs', self::STATUS_LIMIT ) ) { return self::fail( 429, 'rate_limited', __( 'Too many requests.', 'aoo-shop' ) ); } $res = $this->api->get( 'cart-token/' . $token . '/status' ); if ( is_wp_error( $res ) ) { $data = $res->get_error_data(); if ( is_array( $data ) && isset( $data['status'] ) && 404 === (int) $data['status'] ) { $out = array( 'consumed' => false, 'expired' => true, 'missing' => true ); set_transient( $key, $out, self::STATUS_TTL ); return self::reply( $out ); } return self::fail( 502, 'aoo_unreachable', __( 'Order status is temporarily unavailable.', 'aoo-shop' ) ); } $d = is_array( $res['data'] ) ? $res['data'] : array(); $out = array( 'consumed' => ! empty( $d['consumed'] ), 'expired' => ! empty( $d['expired'] ), ); if ( ! empty( $d['order_number'] ) && is_scalar( $d['order_number'] ) ) { $out['order_number'] = sanitize_text_field( (string) $d['order_number'] ); } set_transient( $key, $out, self::STATUS_TTL ); return self::reply( $out ); } }
' . esc_html__( 'Loading cart…', 'aoo-shop' ) . '
' . esc_html__( 'JavaScript is required to use the cart.', 'aoo-shop' ) . ' · ' . esc_html__( 'Back to shop', 'aoo-shop' ) . '