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 = '
' . $html . ( $d['arrows'] ? self::arrows() : '' ) . '
'; } if ( $d['view_all'] ) { $html .= $this->view_all( $args['api'], $d['view_all_text'] ); } // aoo HTML went through AooShop_Kses::clean() above; the wrapper attributes are escaped. return '' . $this->late_css() . $html . ''; } /** * Wrapper classes for display options (theme-bridge.css · "product cards widget options"). * * @param array $d normalize()['display'] — or the same keys from a widget (layout · hide). * @return string[] */ public static function display_classes( array $d ) { $out = array(); if ( isset( $d['layout'] ) && 'carousel' === $d['layout'] ) { $out[] = 'aoo-pc-carousel'; } foreach ( isset( $d['hide'] ) ? (array) $d['hide'] : array() as $h ) { if ( isset( self::HIDE[ $h ] ) ) { $out[] = 'aoo-pc-no-' . self::HIDE[ $h ]; } } return $out; } /** ` class="a"` (+ other attributes) → ` class="a b c"`; no class attribute → one is added. */ public static function add_classes( $attrs, array $classes ) { $classes = esc_attr( implode( ' ', $classes ) ); if ( preg_match( '/\sclass="/', $attrs ) ) { return preg_replace( '/(\sclass=")([^"]*)"/', '$1$2 ' . $classes . '"', $attrs, 1 ); } return $attrs . ' class="' . $classes . '"'; } /** Prev/next buttons for the carousel (assets/aoo-shop.js scrolls the row · disabled at the ends). */ private static function arrows() { $svg = ''; return '' . ''; } /** * Shop page that lists the same products: category / brand / collection (+ budget) / budget page · sort on the * shop index (newest · price · name) · otherwise the shop index. Picked products → the shop index. * * @param array $api normalize()['api']. */ public function view_all_url( array $api ) { $router = aooshop()->router; $price = isset( $api['price'] ) ? (string) $api['price'] : ''; if ( empty( $api['ids'] ) && empty( $api['slugs'] ) ) { foreach ( array( 'category', 'brand', 'collection' ) as $type ) { if ( ! empty( $api[ $type ] ) ) { return $router->url( $type, (string) $api[ $type ], 1, 'collection' === $type && '' !== $price ? array( 'price' => $price ) : array() ); } } if ( '' !== $price ) { return $router->url( 'budget', $price ); } $sort = isset( $api['sort'] ) ? (string) $api['sort'] : ''; if ( empty( $api['on_sale'] ) && in_array( $sort, array( 'newest', 'name', 'price_asc', 'price_desc' ), true ) ) { return $router->url( 'index', '', 1, array( 'sort' => $sort ) ); } } return $router->url( 'index' ); } private function view_all( array $api, $text ) { $text = '' !== $text ? $text : __( 'View all', 'aoo-shop' ); return '

' . esc_html( $text ) . '

'; } /** Admin-only HTML comment (never visible, never for visitors). */ private function note( $message ) { // Block editor preview (ServerSideRender): a visible line — a comment would just look empty. if ( defined( 'REST_REQUEST' ) && REST_REQUEST && current_user_can( 'edit_posts' ) ) { return '
' . esc_html( sprintf( /* translators: %s: reason */ __( 'AOO products: %s', 'aoo-shop' ), (string) $message ) ) . '
'; } if ( ! current_user_can( 'manage_options' ) ) { return ''; } return ''; } private function remember_css( $url ) { if ( get_option( AooShop_Assets::CSS_OPTION ) !== $url ) { update_option( AooShop_Assets::CSS_OPTION, $url, true ); } } // ── assets ───────────────────────────────────────────────────────── /** Embed CSS + the WP-side column override (aoo's `.sf-grid` rule is scoped with #aoo-shop → needs !important). */ public function enqueue() { $this->assets->enqueue_embed_css(); // Checked on the style object, not a flag: the block editor collects iframe assets with a fresh // WP_Styles (enqueue_block_assets fires again), so a per-request flag would skip the second one. if ( ! wp_style_is( AooShop_Assets::HANDLE, 'done' ) && ! $this->has_extra_css() ) { wp_add_inline_style( AooShop_Assets::HANDLE, self::extra_css() ); } } private function has_extra_css() { $after = wp_styles()->get_data( AooShop_Assets::HANDLE, 'after' ); return is_array( $after ) && in_array( self::extra_css(), $after, true ); } /** * Style already printed in without our extra rule (cards in a spot we could not detect) — once, as a * late inline style WordPress prints in the footer (print_late_styles). Returns '' (kept for the callers). */ private function late_css() { if ( $this->css_extra_added || ! wp_style_is( AooShop_Assets::HANDLE, 'done' ) || $this->has_extra_css() ) { return ''; } $this->css_extra_added = true; wp_register_style( 'aooshop-cards', false, array(), AOOSHOP_VERSION ); wp_enqueue_style( 'aooshop-cards' ); wp_add_inline_style( 'aooshop-cards', self::extra_css() ); return ''; } public static function extra_css() { return '.aoo-shop-cards{margin-block:1.5em}' . '.aoo-shop-cards .aoo-embed{max-width:100%}' . '@media (min-width:768px){.aoo-shop-cards[data-aoo-cols] .aoo-cards-grid{grid-template-columns:repeat(var(--aoo-cols),minmax(0,1fr))!important}}'; } /** enqueue when the singular post has a card — keeps every other page untouched. */ public function maybe_enqueue() { if ( ! is_singular() || $this->renderer->active() || ! $this->can_show() ) { return; } $post = get_queried_object(); if ( $post instanceof WP_Post && self::post_has_cards( $post ) ) { $this->enqueue(); } } public static function post_has_cards( WP_Post $post ) { $content = (string) $post->post_content; // New names + the pre-rename ones (sites with the legacy-names flag still have them in content). foreach ( AooShop_Shortcodes::CARD_TAGS as $tag ) { if ( has_shortcode( $content, $tag ) ) { return true; } } if ( has_block( 'aooshop/products', $post ) ) { return true; } $elementor = get_post_meta( $post->ID, '_elementor_data', true ); if ( is_string( $elementor ) && '' !== $elementor ) { return (bool) preg_match( '/"widgetType":"aooshop-products"|\[aooshop_product/', $elementor ); } return (bool) apply_filters( 'aooshop_post_has_cards', false, $post ); } // ── editor product search ────────────────────────────────────────── public function rest_routes( $ns ) { register_rest_route( $ns, '/search', array( 'methods' => 'GET', 'callback' => array( $this, 'search' ), 'permission_callback' => function () { return current_user_can( 'edit_posts' ); }, 'args' => array( 'q' => array( 'type' => 'string', 'default' => '', 'sanitize_callback' => 'sanitize_text_field', ), 'slugs' => array( 'type' => 'string', 'default' => '', 'sanitize_callback' => 'sanitize_text_field', ), 'format' => array( 'type' => 'string', 'default' => '', 'enum' => array( '', 'select2' ), ), ), ) ); } /** * Editor product picker: `GET /wp-json/aooshop/v1/search?q=` → {products:[{id,slug,name,price_min,price_max,image,url}]}. * `slugs=a,b` = look up those products (names of saved picks, in that order) instead of searching. * `format=select2` → {results:[{id: slug, text: name}]} (Elementor "AOO Products" picker). * Not cached (editors only, results must be current). */ public function search( WP_REST_Request $request ) { $q = trim( (string) $request->get_param( 'q' ) ); $slugs = array(); foreach ( self::split( (string) $request->get_param( 'slugs' ) ) as $slug ) { $slug = self::clean_slug( $slug ); if ( '' !== $slug ) { $slugs[] = $slug; } } $slugs = array_slice( array_values( array_unique( $slugs ) ), 0, self::MAX ); $res = $this->api->get( 'cards', $slugs ? array( 'slugs' => implode( ',', $slugs ), 'limit' => count( $slugs ), 'layout' => 'grid', ) : array( 'q' => '' !== $q ? $q : null, 'limit' => self::SEARCH_LIMIT, 'sort' => 'best_selling', 'layout' => 'grid', ) ); if ( is_wp_error( $res ) ) { return new WP_Error( 'aoo_search_failed', $res->get_error_message(), array( 'status' => 502 ) ); } $out = array(); $products = ( is_array( $res['data'] ) && isset( $res['data']['products'] ) && is_array( $res['data']['products'] ) ) ? $res['data']['products'] : array(); foreach ( $products as $p ) { if ( ! is_array( $p ) || empty( $p['slug'] ) ) { continue; } $out[] = array( 'id' => isset( $p['id'] ) ? (string) $p['id'] : '', 'slug' => rawurldecode( (string) $p['slug'] ), 'name' => isset( $p['name'] ) ? (string) $p['name'] : '', 'price_min' => isset( $p['price_min'] ) ? (float) $p['price_min'] : null, 'price_max' => isset( $p['price_max'] ) ? (float) $p['price_max'] : null, 'image' => isset( $p['image'] ) ? (string) $p['image'] : '', 'url' => isset( $p['url'] ) ? $this->renderer->localize( (string) $p['url'] ) : '', ); } if ( 'select2' === $request->get_param( 'format' ) ) { $results = array(); foreach ( $out as $p ) { $results[] = array( 'id' => $p['slug'], 'text' => '' !== $p['name'] ? $p['name'] : $p['slug'], ); } $response = new WP_REST_Response( array( 'results' => $results ), 200 ); $response->header( 'Cache-Control', 'no-store' ); return $response; } $response = new WP_REST_Response( array( 'products' => $out ), 200 ); $response->header( 'Cache-Control', 'no-store' ); return $response; } }