only when the singular post actually contains a card (shortcode / block / Elementor * widget); a card that shows up somewhere undetectable (widget area, theme builder template) enqueues it late * (footer). The cart script (quick-add · swatches) is enqueued by class-cart.php on `aooshop_cards_html`. * * @package AooShop */ defined( 'ABSPATH' ) || exit; class AooShop_Cards { const MAX = 24; const DEFAULT_LIMIT = 4; const SORTS = array( 'best_selling', 'newest', 'name', 'price_asc', 'price_desc', 'manual' ); const SEARCH_LIMIT = 12; const PRICE_MAX = 10000000; /** `hide` att value → wrapper class `aoo-pc-no-{class}` (theme-bridge.css hides the element inside each card). */ const HIDE = array( 'category' => 'cat', 'price' => 'price', 'compare' => 'compare', 'cart' => 'cart', 'swatches' => 'swatch', ); /** @var AooShop_Settings */ private $settings; /** @var AooShop_Api_Client */ private $api; /** @var AooShop_Cache */ private $cache; /** @var AooShop_Renderer */ private $renderer; /** @var AooShop_Assets */ private $assets; /** @var bool */ private $css_extra_added = false; public function __construct( AooShop_Settings $settings, AooShop_Api_Client $api, AooShop_Cache $cache, AooShop_Renderer $renderer, AooShop_Assets $assets ) { $this->settings = $settings; $this->api = $api; $this->cache = $cache; $this->renderer = $renderer; $this->assets = $assets; } public function hooks() { add_action( 'wp_enqueue_scripts', array( $this, 'maybe_enqueue' ), 21 ); add_action( 'aooshop_rest_routes', array( $this, 'rest_routes' ) ); } // ── args ───────────────────────────────────────────────────────── /** Selector / display options with their defaults (shortcode attribute names). */ public static function defaults() { return array( 'ids' => '', 'slugs' => '', 'category' => '', 'brand' => '', 'collection' => '', 'price' => '', // Price bucket slug from the ping (e.g. 1500-2000) — anything else is dropped. 'limit' => '', 'sort' => '', // '' = best_selling · in a collection: the shop's own order (manual). 'layout' => 'grid', // grid | list | carousel (carousel = aoo grid HTML in a scroll-snap row). 'columns' => '', // 0.5 — every one of these is '' by default and changes nothing (HTML, cache key) until set. 'on_sale' => '', // 1 = discounted products only (aoo scans ≤ 96 in `sort` order, then filters). 'hide_out_of_stock' => '', // 1 = drop sold-out products (shops with stock tracking). 'price_min' => '', // Free price range [min, max) in THB — ignored when `price` (bucket) is set. 'price_max' => '', 'rows' => '', // Grid: limit = rows × columns (when `limit` is empty). 'columns_tablet' => '', // 768–1024 px. 'columns_mobile' => '', // < 768 px. 'gap' => '', // px between cards. 'per_view' => '', // Carousel: cards per view (desktop · tablet · mobile — default 4 · 3 · 2). 'per_view_tablet' => '', 'per_view_mobile' => '', 'arrows' => '', // Carousel: 0 = no prev/next buttons (default 1). 'hide' => '', // Comma list: category,price,compare,cart,swatches. 'view_all' => '', // 1 = "ดูทั้งหมด" link to the matching shop page. 'view_all_text' => '', ); } /** * Raw attributes → {api: args sent to aoo + cache key, columns: int (WP-side only)}. * * @param array $raw Shortcode / block / widget attributes (lists as array or comma string). * @return array{api:array, columns:int, empty:bool} */ public static function normalize( array $raw ) { $a = self::defaults(); foreach ( $a as $k => $default ) { if ( ! isset( $raw[ $k ] ) ) { continue; } // Lists may come as arrays (block attributes); everything else must be scalar. if ( in_array( $k, array( 'ids', 'slugs' ), true ) ? ( is_array( $raw[ $k ] ) || is_scalar( $raw[ $k ] ) ) : is_scalar( $raw[ $k ] ) ) { $a[ $k ] = $raw[ $k ]; } } $ids = array(); foreach ( self::split( $a['ids'] ) as $id ) { if ( preg_match( '/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i', $id ) ) { $ids[] = strtolower( $id ); } } $slugs = array(); foreach ( self::split( $a['slugs'] ) as $slug ) { $slug = self::clean_slug( $slug ); if ( '' !== $slug ) { $slugs[] = $slug; } } $ids = array_slice( array_values( array_unique( $ids ) ), 0, self::MAX ); $slugs = array_slice( array_values( array_unique( $slugs ) ), 0, self::MAX ); $picks = count( $ids ) + count( $slugs ); $limit = (int) $a['limit']; if ( $limit < 1 ) { $limit = $picks ? $picks : self::DEFAULT_LIMIT; // Picked products: show all of them unless limited. } $limit = min( self::MAX, $limit ); $collection = $picks ? '' : self::clean_slug( $a['collection'] ); $price = ''; if ( ! $picks && '' !== (string) $a['price'] && function_exists( 'aooshop' ) ) { $price = aooshop()->settings->valid_price( $a['price'] ); } $sort = in_array( (string) $a['sort'], self::SORTS, true ) ? (string) $a['sort'] : ''; if ( 'manual' === $sort && '' === $collection ) { $sort = ''; } if ( '' === $sort ) { $sort = '' !== $collection ? '' : 'best_selling'; // Collection: aoo uses the shop's order when sort is empty. } $display = self::display( $a, $picks ); $layout = 'list' === $a['layout'] ? 'list' : 'grid'; // Carousel = grid HTML (shares aoo's cache entry). if ( ! $picks && $display['rows'] && (int) $a['limit'] < 1 && 'grid' === $display['layout'] ) { // Rows × desktop columns (no columns set = 4, the usual desktop row). $limit = min( self::MAX, $display['rows'] * ( $display['cols']['d'] ? $display['cols']['d'] : 4 ) ); } $api = array( 'ids' => $ids, 'slugs' => $slugs, // Picks win over filters on aoo — drop the filters so they don't split the cache. 'category' => $picks ? '' : self::clean_slug( $a['category'] ), 'brand' => $picks ? '' : self::clean_slug( $a['brand'] ), 'limit' => $limit, 'sort' => $picks ? '' : $sort, 'layout' => $layout, ); // Only when set — card lists from 0.1/0.2 keep their cache keys. if ( '' !== $collection ) { $api['collection'] = $collection; } if ( '' !== $price ) { $api['price'] = $price; } // 0.5 filters — only when set (older card lists keep their cache keys byte for byte). if ( ! $picks ) { if ( '' === $price ) { foreach ( array( 'price_min', 'price_max' ) as $k ) { $n = self::price_number( $a[ $k ] ); if ( null !== $n && ( 'price_min' !== $k || $n > 0 ) ) { $api[ $k ] = $n; } } } if ( self::truthy( $a['hide_out_of_stock'] ) ) { $api['hide_out_of_stock'] = 1; } if ( self::truthy( $a['on_sale'] ) ) { $api['on_sale'] = 1; } } return array( 'api' => $api, 'columns' => 'carousel' === $display['layout'] ? 0 : $display['cols']['d'], 'display' => $display, ); } /** * WP-side display options (never sent to aoo — same cached HTML, different wrapper classes / CSS variables). * * @param array $a Attributes merged with defaults(). * @param int $picks Number of picked products. * @return array{layout:string, cols:array, rows:int, gap:int, per_view:array, arrows:bool, hide:string[], view_all:bool, view_all_text:string} */ private static function display( array $a, $picks ) { $layout = in_array( (string) $a['layout'], array( 'list', 'carousel' ), true ) ? (string) $a['layout'] : 'grid'; $int = function ( $v, $min, $max ) { $n = (int) $v; return ( '' === trim( (string) $v ) || $n < $min ) ? 0 : min( $max, $n ); }; $view = function ( $v ) { $n = round( (float) $v, 1 ); return ( '' === trim( (string) $v ) || $n < 1 ) ? '' : (string) min( 8, $n ); }; $hide = array(); foreach ( self::split( $a['hide'] ) as $h ) { $h = strtolower( $h ); if ( isset( self::HIDE[ $h ] ) ) { $hide[] = $h; } } return array( 'layout' => $layout, 'cols' => array( 'd' => $int( $a['columns'], 1, 6 ), 't' => $int( $a['columns_tablet'], 1, 6 ), 'm' => $int( $a['columns_mobile'], 1, 6 ), ), 'rows' => $picks ? 0 : $int( $a['rows'], 1, 6 ), 'gap' => '' === trim( (string) $a['gap'] ) ? -1 : max( 0, min( 80, (int) $a['gap'] ) ), 'per_view' => array( 'd' => $view( $a['per_view'] ), 't' => $view( $a['per_view_tablet'] ), 'm' => $view( $a['per_view_mobile'] ), ), 'arrows' => '' === (string) $a['arrows'] || self::truthy( $a['arrows'] ), 'hide' => array_values( array_unique( $hide ) ), 'view_all' => self::truthy( $a['view_all'] ), 'view_all_text' => trim( wp_strip_all_tags( (string) $a['view_all_text'] ) ), ); } private static function truthy( $v ) { return in_array( strtolower( trim( (string) $v ) ), array( '1', 'yes', 'true', 'on' ), true ); } /** Whole THB 0–10,000,000 · anything else = null (not sent). */ private static function price_number( $v ) { $v = trim( (string) $v ); if ( '' === $v || ! is_numeric( $v ) ) { return null; } $n = (int) floor( (float) $v ); return ( $n < 0 || $n > self::PRICE_MAX ) ? null : $n; } private static function split( $value ) { $items = is_array( $value ) ? $value : explode( ',', (string) $value ); return array_filter( array_map( 'trim', array_map( 'strval', array_filter( $items, 'is_scalar' ) ) ), 'strlen' ); } /** Slug as typed / pasted (Thai or %-encoded both fine — aoo decodes). A pasted product URL → its last segment. */ public static function clean_slug( $value ) { $value = trim( wp_strip_all_tags( (string) $value ) ); if ( preg_match( '#^https?://#i', $value ) ) { $path = (string) wp_parse_url( $value, PHP_URL_PATH ); $parts = array_values( array_filter( explode( '/', $path ), 'strlen' ) ); $value = $parts ? end( $parts ) : ''; } $value = trim( $value, "/ \t\n\r" ); return preg_replace( '/[\s<>"\'`,]+/u', '-', $value ); } // ── who sees cards ───────────────────────────────────────────────── /** * Live: everyone. Preview: admins (can_preview) + editors inside an editor (block editor preview / Elementor). */ public function can_show() { if ( ! $this->settings->is_preview() ) { return true; } if ( $this->renderer->can_preview() ) { return true; } return current_user_can( 'edit_posts' ) && self::in_editor(); } /** Block editor ServerSideRender (REST) · Elementor editor (preview iframe / ajax render) · post preview. */ public static function in_editor() { if ( defined( 'REST_REQUEST' ) && REST_REQUEST ) { return true; } if ( wp_doing_ajax() || is_admin() ) { return true; } // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only context check. if ( isset( $_GET['elementor-preview'] ) || is_preview() ) { return true; } return false; } // ── render ───────────────────────────────────────────────────────── /** * Card HTML for a shortcode / block / widget. * * @param array $raw Attributes (see defaults()). * @param string $wrapper Extra wrapper attributes (block: get_block_wrapper_attributes()); '' = plugin default. * @return string '' when nothing should show (plus an admin-only HTML comment explaining why). */ public function render( array $raw, $wrapper = '' ) { if ( ! $this->can_show() ) { return ''; } // No picks and no filters = the whole shop in `sort` order (e.g. best sellers) — not an empty block. $args = self::normalize( $raw ); $result = $this->cache->get_cards( $args['api'] ); if ( is_wp_error( $result ) ) { return $this->note( $result->get_error_message() ); } $payload = $result['payload']; if ( '' === trim( (string) $payload['html'] ) ) { return $this->note( __( 'No products match these conditions (or they are not for sale).', 'aoo-shop' ) ); } if ( ! empty( $payload['assets']['css'] ) ) { $this->remember_css( (string) $payload['assets']['css'] ); } $this->enqueue(); $cols = $args['columns']; $d = $args['display']; $attrs = $wrapper ? ' ' . $wrapper : ' class="aoo-shop-cards"'; $extra = self::display_classes( $d ); if ( $extra ) { $attrs = self::add_classes( $attrs, $extra ); } $vars = array(); if ( $cols ) { $attrs .= ' data-aoo-cols="' . (int) $cols . '"'; $vars[] = '--aoo-cols:' . (int) $cols; } // Shortcode-only per-device values (the Elementor widget sets the same variables from its Style tab instead). foreach ( array( 't' => 'tablet', 'm' => 'mobile' ) as $code => $device ) { if ( 'carousel' !== $d['layout'] && $d['cols'][ $code ] ) { $attrs .= ' data-aoo-cols-' . $code . '="' . (int) $d['cols'][ $code ] . '"'; $vars[] = '--aoo-cols-' . $code . ':' . (int) $d['cols'][ $code ]; } } if ( $d['gap'] >= 0 ) { $attrs .= ' data-aoo-gap'; $vars[] = '--aoo-gap:' . (int) $d['gap'] . 'px'; } if ( 'carousel' === $d['layout'] ) { $pv = $d['per_view']; if ( '' !== $pv['d'] ) { // Desktop only → smaller screens keep at most the default 3 / 2 (instead of inheriting e.g. 5 on a phone). $pv['t'] = '' !== $pv['t'] ? $pv['t'] : (string) min( (float) $pv['d'], 3 ); $pv['m'] = '' !== $pv['m'] ? $pv['m'] : (string) min( (float) $pv['d'], 2 ); } foreach ( array( 'd' => '', 't' => '-t', 'm' => '-m' ) as $code => $suffix ) { if ( '' !== $pv[ $code ] ) { $vars[] = '--aoo-per-view' . $suffix . ':' . $pv[ $code ]; } } } if ( $vars ) { $attrs .= ' style="' . esc_attr( implode( ';', $vars ) ) . '"'; // CSS custom properties only. } $html = $this->renderer->localize( (string) $payload['html'] ); $html = AooShop_Kses::clean( (string) apply_filters( 'aooshop_cards_html', $html, $args, $payload ) ); if ( 'carousel' === $d['layout'] ) { $html = '