PluginProbe
bBlocks – Essential Gutenberg Blocks & Patterns Collection / 2.1.10
bBlocks – Essential Gutenberg Blocks & Patterns Collection v2.1.10
2.1.10 2.1.9 2.1.8 2.1.7 2.1.6 2.1.5 2.1.4 2.1.3 2.1.2 2.1.1 2.1.0 2.0.43 2.0.42 2.0.41 2.0.40 2.0.39 2.0.38 trunk 1.0 1.1 1.2 1.3 1.4 1.5 1.5.1 All 110 releases
← All changes | includes/blocks/woo-product-grid/WooProductGrid.php +2016 -271 2.1.4 → 2.1.10 View file →
@@ -1,25 +1,34 @@
1 1 <?php
2 2 /**
3 3 * Woo Product Grid — shared server logic.
4 4 *
5 - * Provides attribute sanitization, WooCommerce query building, product-card
6 - * markup rendering, and a hardened add-to-cart AJAX endpoint
7 - * (`bb_wpg_add_to_cart`) used by the frontend `view.js` for simple products.
5 + * The block is dynamic: this class owns attribute sanitization, the WooCommerce
6 + * query builder, the card/filter/pager markup and the three AJAX endpoints the
7 + * frontend talks to. render.php and the AJAX handlers run the *same* builder and
8 + * the *same* renderer, so a filtered or paged swap is byte-identical to the
9 + * initial server render.
8 10 *
9 - * Security model for `bb_wpg_add_to_cart`:
10 - * - Nonce verified on every request via check_ajax_referer().
11 - * - product_id sanitized with absint() and validated against wc_get_product().
12 - * - Only purchasable, in-stock, simple/non-variable products are added.
13 - * - All responses use wp_send_json_success / wp_send_json_error.
11 + * Security model:
12 + * - `bb_wpg_query` is public (nopriv) and verifies a `wp_ajax` nonce;
13 + * `bb_wpg_search` additionally requires `edit_posts` because it is only
14 + * ever called from the editor. Adding to the cart is left to WooCommerce's
15 + * own endpoint rather than a second one here.
16 + * - Attributes arriving over AJAX are untrusted: every value goes back through
17 + * resolveAttributes(), which clamps numbers, allowlists enums, and casts IDs
18 + * with absint(). `productsPerPage` is capped at MAX_PER_PAGE either way.
19 + * - Card markup is escaped at the point of output and filtered once more on the
20 + * way out through allowedTags().
14 21 *
15 - * All card output is escaped (esc_html / esc_url / esc_attr / wp_kses_post).
16 - *
17 22 * @package bBlocks
18 23 */
19 24
20 25 namespace BBlocks\Inc\Blocks;
21 26
27 +use BBlocks\Inc\GetCSS;
28 +use BBlocks\Inc\Sanitize;
29 +use WP_Query;
30 +
22 31 if ( ! defined( 'ABSPATH' ) ) {
23 32 exit;
24 33 }
25 34
@@ -24,35 +33,351 @@
24 33 }
25 34
26 35 class WooProductGrid {
27 36
37 + /** Hard ceiling on page size, applied to editor input and AJAX alike. */
38 + const MAX_PER_PAGE = 48;
39 +
40 + const LAYOUTS = [ 'grid', 'list' ];
41 + const ORDERBY = [ 'date', 'title', 'price', 'popularity', 'rating', 'menu_order', 'rand', 'id' ];
42 + const ORDER = [ 'ASC', 'DESC' ];
43 + const QUERY_TYPES = [ 'custom', 'related' ];
44 + const TAX_MODES = [ 'include', 'exclude' ];
45 + const RATING_STYLE = [ 'star', 'star-count', 'number' ];
46 + const TITLE_TAGS = [ 'h1', 'h2', 'h3', 'h4', 'h5', 'h6' ];
47 +
48 + /** Where the cart / view buttons sit. */
49 + const ACTION_POSITIONS = [ 'overlay', 'below', 'split' ];
50 +
51 + /** Where they sit inside the image, when they are on it. */
52 + const ACTION_ALIGNS = [ 'center', 'right', 'bottom' ];
53 +
54 + /** Which of the two comes first. */
55 + const ACTION_ORDER = [ 'cart', 'view' ];
56 +
57 + /** Which side of the label a button's icon sits on. */
58 + const ICON_POSITIONS = [ 'left', 'right' ];
59 + const IMAGE_SIZES = [ 'default', 'thumbnail', 'medium', 'large', 'full' ];
60 + const BADGE_ALIGN = [ 'top', 'left', 'right' ];
61 + const BADGE_CONTENT = [ 'text', 'amount', 'percent' ];
62 + const MORE_TYPES = [ 'button', 'pagination', 'prev-next' ];
63 + const ALIGNS = [ 'left', 'center', 'right' ];
64 + const RATIOS = [ '3/4', '1/1', '4/3', '16/9' ];
65 + const FITS = [ 'cover', 'contain' ];
66 + const SHADOWS = [ 'none', 'sm', 'md', 'lg' ];
67 +
68 + /** Card elements the Sorting Content panel reorders. */
69 + const ELEMENTS = [ 'price', 'rating', 'title', 'category', 'soldCount' ];
70 +
28 71 /**
29 - * Allowed orderby values mapped to WC_Product_Query orderby keys.
72 + * Register the AJAX endpoints. Query and add-to-cart are public because the
73 + * grid has to work for logged-out shoppers; search is editor-only.
74 + */
75 + public function __construct() {
76 + add_action( 'wp_ajax_bb_wpg_query', [ $this, 'ajaxQuery' ] );
77 + add_action( 'wp_ajax_nopriv_bb_wpg_query', [ $this, 'ajaxQuery' ] );
78 +
79 + add_action( 'wp_ajax_bb_wpg_search', [ $this, 'ajaxSearch' ] );
80 + }
81 +
82 + const NESTED_MAP = [
83 + 'layouts' => [ 'layout', 'type' ],
84 + 'titleTag' => [ 'layout', 'titleTag' ],
85 + 'actionPosition' => [ 'layout', 'actionPosition' ],
86 + 'actionAlign' => [ 'layout', 'actionAlign' ],
87 + 'actionFull' => [ 'layout', 'actionFull' ],
88 + 'actionIconOnly' => [ 'layout', 'actionIconOnly' ],
89 + 'actionOrder' => [ 'layout', 'actionOrder' ],
90 + 'imageSize' => [ 'layout', 'imageSize' ],
91 + 'showImage' => [ 'elements', 'image' ],
92 + 'showCategory' => [ 'elements', 'category' ],
93 + 'showRating' => [ 'elements', 'rating' ],
94 + 'showPrice' => [ 'elements', 'price' ],
95 + 'showSaleBadge' => [ 'elements', 'saleBadge' ],
96 + 'showSoldCount' => [ 'elements', 'soldCount' ],
97 + 'showProgressBar' => [ 'elements', 'progressBar' ],
98 + 'showViewButton' => [ 'elements', 'viewButton' ],
99 + 'showTitle' => [ 'elements', 'title' ],
100 + 'showAddToCart' => [ 'elements', 'cart' ],
101 + 'queryType' => [ 'query', 'type' ],
102 + 'orderBy' => [ 'query', 'orderBy' ],
103 + 'order' => [ 'query', 'order' ],
104 + 'productsPerPage' => [ 'query', 'perPage' ],
105 + 'offset' => [ 'query', 'offset' ],
106 + 'includeProducts' => [ 'query', 'include' ],
107 + 'excludeProducts' => [ 'query', 'exclude' ],
108 + 'categoryMode' => [ 'query', 'categoryMode' ],
109 + 'productCategories' => [ 'query', 'categories' ],
110 + 'tagMode' => [ 'query', 'tagMode' ],
111 + 'productTags' => [ 'query', 'tags' ],
112 + 'onSaleOnly' => [ 'query', 'onSaleOnly' ],
113 + 'featuredOnly' => [ 'query', 'featuredOnly' ],
114 + 'currentProductId' => [ 'query', 'currentProductId' ],
115 + 'noProductsMessage' => [ 'query', 'noProductsMessage' ],
116 + 'useCustomCartText' => [ 'cart', 'useCustomText' ],
117 + 'cartTextSimple' => [ 'cart', 'simple' ],
118 + 'cartTextVariable' => [ 'cart', 'variable' ],
119 + 'cartTextGrouped' => [ 'cart', 'grouped' ],
120 + 'cartTextExternal' => [ 'cart', 'external' ],
121 + 'cartTextDefault' => [ 'cart', 'default' ],
122 + 'btnColor' => [ 'cart', 'normal', 'color' ],
123 + 'btnBG' => [ 'cart', 'normal', 'bg' ],
124 + 'btnHovColor' => [ 'cart', 'hover', 'color' ],
125 + 'btnHovBG' => [ 'cart', 'hover', 'bg' ],
126 + 'cartShowIcon' => [ 'cart', 'showIcon' ],
127 + 'cartIcon' => [ 'cart', 'icon' ],
128 + 'cartIconColor' => [ 'cart', 'iconColor' ],
129 + 'cartIconPosition' => [ 'cart', 'iconPosition' ],
130 + 'btnTypo' => [ 'cart', 'typo' ],
131 + 'btnTransition' => [ 'cart', 'transition' ],
132 + 'btnBorder' => [ 'cart', 'normal', 'border' ],
133 + 'btnHovBorder' => [ 'cart', 'hover', 'border' ],
134 + 'viewButtonText' => [ 'viewButton', 'text' ],
135 + 'viewBtnColor' => [ 'viewButton', 'normal', 'color' ],
136 + 'viewBtnBG' => [ 'viewButton', 'normal', 'bg' ],
137 + 'viewBtnHovColor' => [ 'viewButton', 'hover', 'color' ],
138 + 'viewBtnHovBG' => [ 'viewButton', 'hover', 'bg' ],
139 + 'viewShowIcon' => [ 'viewButton', 'showIcon' ],
140 + 'viewIcon' => [ 'viewButton', 'icon' ],
141 + 'viewIconColor' => [ 'viewButton', 'iconColor' ],
142 + 'viewIconPosition' => [ 'viewButton', 'iconPosition' ],
143 + 'viewBtnTypo' => [ 'viewButton', 'typo' ],
144 + 'viewBtnTransition' => [ 'viewButton', 'transition' ],
145 + 'viewBtnBorder' => [ 'viewButton', 'normal', 'border' ],
146 + 'viewBtnHovBorder' => [ 'viewButton', 'hover', 'border' ],
147 + 'badgeAlign' => [ 'badge', 'align' ],
148 + 'saleBadgeLabel' => [ 'badge', 'text' ],
149 + 'badgeContent' => [ 'badge', 'content' ],
150 + 'badgeBG' => [ 'badge', 'bg' ],
151 + 'badgeTextColor' => [ 'badge', 'color' ],
152 + 'badgeTypo' => [ 'badge', 'typo' ],
153 + 'enableLoadMore' => [ 'pager', 'enable' ],
154 + 'morePostsType' => [ 'pager', 'type' ],
155 + 'loadMoreText' => [ 'pager', 'text' ],
156 + 'loadMoreAlign' => [ 'pager', 'align' ],
157 + 'pagerColor' => [ 'pager', 'normal', 'color' ],
158 + 'pagerBG' => [ 'pager', 'normal', 'bg' ],
159 + 'pagerHovColor' => [ 'pager', 'hover', 'color' ],
160 + 'pagerHovBG' => [ 'pager', 'hover', 'bg' ],
161 + 'pagerActiveColor' => [ 'pager', 'active', 'color' ],
162 + 'pagerActiveBG' => [ 'pager', 'active', 'bg' ],
163 + 'pagerTypo' => [ 'pager', 'typo' ],
164 + 'pagerTransition' => [ 'pager', 'transition' ],
165 + 'pagerBorder' => [ 'pager', 'normal', 'border' ],
166 + 'pagerHovBorder' => [ 'pager', 'hover', 'border' ],
167 + 'pagerActiveBorder' => [ 'pager', 'active', 'border' ],
168 + 'showTaxonomyFilter' => [ 'filter', 'enable' ],
169 + 'filterTaxonomy' => [ 'filter', 'taxonomy' ],
170 + 'filterAllText' => [ 'filter', 'allText' ],
171 + 'filterAlign' => [ 'filter', 'align' ],
172 + 'filterColor' => [ 'filter', 'normal', 'color' ],
173 + 'filterBG' => [ 'filter', 'normal', 'bg' ],
174 + 'filterActiveColor' => [ 'filter', 'hover', 'color' ],
175 + 'filterActiveBG' => [ 'filter', 'hover', 'bg' ],
176 + 'filterTypo' => [ 'filter', 'typo' ],
177 + 'filterTransition' => [ 'filter', 'transition' ],
178 + 'filterBorder' => [ 'filter', 'normal', 'border' ],
179 + 'filterHovBorder' => [ 'filter', 'hover', 'border' ],
180 + 'cardBodyBG' => [ 'card', 'bodyBg' ],
181 + 'cardDivider' => [ 'card', 'divider' ],
182 + 'cardBG' => [ 'card', 'normal', 'bg' ],
183 + 'cardTransition' => [ 'card', 'transition' ],
184 + 'cardBorder' => [ 'card', 'normal', 'border' ],
185 + 'cardShadow' => [ 'card', 'normal', 'shadow' ],
186 + 'cardHovBG' => [ 'card', 'hover', 'bg' ],
187 + 'cardHovBorder' => [ 'card', 'hover', 'border' ],
188 + 'cardHovShadow' => [ 'card', 'hover', 'shadow' ],
189 + 'imageRatio' => [ 'media', 'ratio' ],
190 + 'imageFit' => [ 'media', 'fit' ],
191 + 'imageBG' => [ 'media', 'bg' ],
192 + 'overlayBG' => [ 'media', 'overlay' ],
193 + 'titleTypo' => [ 'title', 'typo' ],
194 + 'titleTransition' => [ 'title', 'transition' ],
195 + 'titleColor' => [ 'title', 'normal', 'color' ],
196 + 'titleHovColor' => [ 'title', 'hover', 'color' ],
197 + 'priceTypo' => [ 'price', 'typo' ],
198 + 'priceColor' => [ 'price', 'color' ],
199 + 'regularPriceColor' => [ 'price', 'regularColor' ],
200 + 'ratingStyle' => [ 'rating', 'style' ],
201 + 'ratingShowEmpty' => [ 'rating', 'showEmpty' ],
202 + 'ratingColor' => [ 'rating', 'color' ],
203 + 'ratingEmptyColor' => [ 'rating', 'emptyColor' ],
204 + 'ratingCountColor' => [ 'rating', 'countColor' ],
205 + 'ratingSize' => [ 'rating', 'size' ],
206 + 'categoryTypo' => [ 'category', 'typo' ],
207 + 'categoryTransition' => [ 'category', 'transition' ],
208 + 'categoryColor' => [ 'category', 'normal', 'color' ],
209 + 'categoryHovColor' => [ 'category', 'hover', 'color' ],
210 + 'soldPrefix' => [ 'sold', 'prefix' ],
211 + 'soldSuffix' => [ 'sold', 'suffix' ],
212 + 'withoutStockValue' => [ 'sold', 'withoutStock' ],
213 + 'soldTypo' => [ 'sold', 'typo' ],
214 + 'soldColor' => [ 'sold', 'color' ],
215 + 'soldBG' => [ 'sold', 'bg' ],
216 + 'progressTrackColor' => [ 'sold', 'progress', 'trackColor' ],
217 + 'progressFillColor' => [ 'sold', 'progress', 'fillColor' ],
218 + 'progressHeight' => [ 'sold', 'progress', 'height' ],
219 + ];
220 +
221 + /**
222 + * Values set per breakpoint, stored device-first:
30 223 *
31 - * @var string[]
224 + * layout: { desktop: { columnGap: '20px' }, tablet: { … }, mobile: { … } }
225 + *
226 + * flat => [ the container holding the device keys, the prop inside each,
227 + * 'box' for a BoxControl value or 'scalar' for a plain one ].
228 + *
229 + * flatten() turns each into { desktop, tablet, mobile } under its flat key,
230 + * which is the shape resolveAttributes has always read, so nothing downstream
231 + * of it needed to change. viewAttributes writes them back device-first.
32 232 */
33 - const ORDERBY = [ 'date', 'price', 'rating', 'popularity', 'rand', 'title' ];
233 + const DEVICE_MAP = [
234 + 'columns' => [ [ 'layout' ], 'columns', 'scalar' ],
235 + 'contentAlign' => [ [ 'layout' ], 'contentAlign', 'scalar' ],
236 + 'columnGap' => [ [ 'layout' ], 'columnGap', 'scalar' ],
237 + 'rowGap' => [ [ 'layout' ], 'rowGap', 'scalar' ],
238 + 'cartIconSize' => [ [ 'cart' ], 'iconSize', 'scalar' ],
239 + 'btnPadding' => [ [ 'cart', 'normal' ], 'padding', 'box' ],
240 + 'btnMargin' => [ [ 'cart', 'normal' ], 'margin', 'box' ],
241 + 'btnRadius' => [ [ 'cart', 'normal' ], 'radius', 'box' ],
242 + 'btnHovPadding' => [ [ 'cart', 'hover' ], 'padding', 'box' ],
243 + 'btnHovMargin' => [ [ 'cart', 'hover' ], 'margin', 'box' ],
244 + 'btnHovRadius' => [ [ 'cart', 'hover' ], 'radius', 'box' ],
245 + 'viewIconSize' => [ [ 'viewButton' ], 'iconSize', 'scalar' ],
246 + 'viewBtnPadding' => [ [ 'viewButton', 'normal' ], 'padding', 'box' ],
247 + 'viewBtnMargin' => [ [ 'viewButton', 'normal' ], 'margin', 'box' ],
248 + 'viewBtnRadius' => [ [ 'viewButton', 'normal' ], 'radius', 'box' ],
249 + 'viewBtnHovPadding' => [ [ 'viewButton', 'hover' ], 'padding', 'box' ],
250 + 'viewBtnHovMargin' => [ [ 'viewButton', 'hover' ], 'margin', 'box' ],
251 + 'viewBtnHovRadius' => [ [ 'viewButton', 'hover' ], 'radius', 'box' ],
252 + 'badgeRadius' => [ [ 'badge' ], 'radius', 'box' ],
253 + 'badgePadding' => [ [ 'badge' ], 'padding', 'box' ],
254 + 'badgeOffset' => [ [ 'badge' ], 'offset', 'scalar' ],
255 + 'pagerPadding' => [ [ 'pager', 'normal' ], 'padding', 'box' ],
256 + 'pagerRadius' => [ [ 'pager', 'normal' ], 'radius', 'box' ],
257 + 'pagerHovPadding' => [ [ 'pager', 'hover' ], 'padding', 'box' ],
258 + 'pagerHovRadius' => [ [ 'pager', 'hover' ], 'radius', 'box' ],
259 + 'pagerActivePadding' => [ [ 'pager', 'active' ], 'padding', 'box' ],
260 + 'pagerActiveRadius' => [ [ 'pager', 'active' ], 'radius', 'box' ],
261 + 'filterPadding' => [ [ 'filter', 'normal' ], 'padding', 'box' ],
262 + 'filterRadius' => [ [ 'filter', 'normal' ], 'radius', 'box' ],
263 + 'filterHovPadding' => [ [ 'filter', 'hover' ], 'padding', 'box' ],
264 + 'filterHovRadius' => [ [ 'filter', 'hover' ], 'radius', 'box' ],
265 + 'cardBodyMargin' => [ [ 'card' ], 'bodyMargin', 'box' ],
266 + 'cardPadding' => [ [ 'card', 'normal' ], 'padding', 'box' ],
267 + 'cardRadius' => [ [ 'card', 'normal' ], 'radius', 'box' ],
268 + 'cardHovPadding' => [ [ 'card', 'hover' ], 'padding', 'box' ],
269 + 'cardHovRadius' => [ [ 'card', 'hover' ], 'radius', 'box' ],
270 + 'imageRadius' => [ [ 'media' ], 'radius', 'box' ],
271 + 'imageMargin' => [ [ 'media' ], 'margin', 'box' ],
272 + 'progressRadius' => [ [ 'sold', 'progress' ], 'radius', 'box' ],
273 + ];
34 274
35 275 /**
36 - * Allowed order values (uppercased).
276 + * Read a nested attribute set back into the flat keys this class works in.
37 277 *
38 - * @var string[]
278 + * Attributes are grouped by concern in block.json (the bBlocks convention,
279 + * see image-hotspot), but the query builder, renderer and CSS all read flat
280 + * keys. Converting once on the way in keeps that single translation point.
281 + *
282 + * @param array $attributes Nested block attributes.
283 + * @return array Flat attributes.
39 284 */
40 - const ORDER = [ 'ASC', 'DESC' ];
285 + public static function flatten( array $attributes ) {
286 + foreach ( self::NESTED_MAP as $flat => $path ) {
287 + $node = $attributes;
41 288
289 + foreach ( $path as $segment ) {
290 + if ( ! is_array( $node ) || ! array_key_exists( $segment, $node ) ) {
291 + $node = null;
292 + break;
293 + }
294 + $node = $node[ $segment ];
295 + }
296 +
297 + if ( null !== $node ) {
298 + $attributes[ $flat ] = $node;
299 + }
300 + }
301 +
302 + foreach ( self::DEVICE_MAP as $flat => $spec ) {
303 + $value = self::deviceValue( $attributes, $spec[0], $spec[1] );
304 +
305 + if ( null !== $value ) {
306 + $attributes[ $flat ] = $value;
307 + }
308 + }
309 +
310 + return $attributes;
311 + }
312 +
42 313 /**
43 - * Allowed aspect ratios.
314 + * Gather one per-device prop into { desktop, tablet, mobile }.
44 315 *
45 - * @var string[]
316 + * Read device-first — `container.desktop.prop` — and, for any breakpoint that
317 + * has no value there, from the shapes a post may still hold from before:
318 + * prop-first `container.prop.desktop`, or a single value in `container.prop`
319 + * that applied to every screen and is taken as desktop. Each breakpoint falls
320 + * back on its own, so a post edited half in the new shape still reads whole.
321 + *
322 + * @param array $attributes Raw block attributes.
323 + * @param string[] $container Path to the object holding the device keys.
324 + * @param string $prop The prop inside each device.
325 + * @return array|null Per-device values, or null when nothing is set at all.
46 326 */
47 - const RATIOS = [ '3/4', '1/1', '4/3', '16/9' ];
327 + protected static function deviceValue( array $attributes, array $container, $prop ) {
328 + $node = $attributes;
48 329
330 + foreach ( $container as $segment ) {
331 + if ( ! is_array( $node ) || ! array_key_exists( $segment, $node ) ) {
332 + return null;
333 + }
334 + $node = $node[ $segment ];
335 + }
336 +
337 + if ( ! is_array( $node ) ) {
338 + return null;
339 + }
340 +
341 + $legacy = $node[ $prop ] ?? null;
342 + $byDevice = is_array( $legacy ) && ( isset( $legacy['desktop'] ) || isset( $legacy['tablet'] ) || isset( $legacy['mobile'] ) );
343 +
344 + $out = [];
345 + $any = false;
346 +
347 + foreach ( [ 'desktop', 'tablet', 'mobile' ] as $device ) {
348 + if ( is_array( $node[ $device ] ?? null ) && array_key_exists( $prop, $node[ $device ] ) ) {
349 + $out[ $device ] = $node[ $device ][ $prop ];
350 + } elseif ( $byDevice ) {
351 + $out[ $device ] = $legacy[ $device ] ?? null;
352 + } else {
353 + $out[ $device ] = 'desktop' === $device ? $legacy : null;
354 + }
355 +
356 + $any = $any || null !== $out[ $device ];
357 + }
358 +
359 + return $any ? $out : null;
360 + }
361 +
49 362 /**
50 - * Hook the AJAX endpoint (public + logged-in).
363 + * Write a value into a nested array, creating the intermediate levels.
364 + *
365 + * @param array $target Array to write into, by reference.
366 + * @param array $path Path segments.
367 + * @param mixed $value Value to set.
51 368 */
52 - public function __construct() {
53 - add_action( 'wp_ajax_bb_wpg_add_to_cart', [ $this, 'ajaxAddToCart' ] );
54 - add_action( 'wp_ajax_nopriv_bb_wpg_add_to_cart', [ $this, 'ajaxAddToCart' ] );
369 + protected static function setPath( array &$target, array $path, $value ) {
370 + $node = &$target;
371 +
372 + foreach ( $path as $segment ) {
373 + if ( ! isset( $node[ $segment ] ) || ! is_array( $node[ $segment ] ) ) {
374 + $node[ $segment ] = [];
375 + }
376 + $node = &$node[ $segment ];
377 + }
378 +
379 + $node = $value;
55 380 }
56 381
57 382 /* ----------------------------------------------------------------------
58 383 * Sanitizers
@@ -65,9 +390,13 @@
65 390 * @param string $fallback Fallback when invalid.
66 391 * @return string
67 392 */
68 393 public static function sanitizeColor( $color, $fallback = '' ) {
69 - $color = trim( (string) $color );
394 + // Scalars only: a malformed post, or a crafted request to the public
395 + // query endpoint, can put an array here, and casting one to a string
396 + // raises a PHP warning on every render.
397 + $color = is_scalar( $color ) ? trim( (string) $color ) : '';
398 +
70 399 if ( '' === $color ) {
71 400 return $fallback;
72 401 }
73 402 if ( preg_match( '/^#([0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/', $color ) ) {
@@ -81,8 +410,9 @@
81 410 }
82 411 if ( preg_match( '/^[a-zA-Z]{1,30}$/', $color ) ) {
83 412 return $color;
84 413 }
414 +
85 415 return $fallback;
86 416 }
87 417
88 418 /**
@@ -97,16 +427,30 @@
97 427 public static function clampInt( $value, $min, $max, $fallback ) {
98 428 if ( ! is_numeric( $value ) ) {
99 429 return (int) $fallback;
100 430 }
101 - $value = (int) $value;
102 - if ( $value < $min ) {
103 - return (int) $min;
431 +
432 + return (int) min( $max, max( $min, (int) $value ) );
433 + }
434 +
435 + /**
436 + * Clamp a fractional number, for values a RangeControl stores with a step
437 + * below 1 — transition durations in seconds, in this block's case.
438 + *
439 + * Rounded so a crafted payload cannot push a long decimal into the CSS.
440 + *
441 + * @param mixed $value Raw value.
442 + * @param float $min Lower bound.
443 + * @param float $max Upper bound.
444 + * @param float $fallback Value for anything non-numeric.
445 + * @return float
446 + */
447 + public static function clampFloat( $value, $min, $max, $fallback ) {
448 + if ( ! is_numeric( $value ) ) {
449 + return (float) $fallback;
104 450 }
105 - if ( $value > $max ) {
106 - return (int) $max;
107 - }
108 - return $value;
451 +
452 + return round( min( $max, max( $min, (float) $value ) ), 2 );
109 453 }
110 454
111 455 /**
112 456 * Pick a value from an allowlist.
@@ -117,8 +461,9 @@
117 461 * @return string
118 462 */
119 463 public static function pickFrom( $value, array $allowed, $fallback ) {
120 464 $value = is_string( $value ) ? trim( $value ) : '';
465 +
121 466 return in_array( $value, $allowed, true ) ? $value : $fallback;
122 467 }
123 468
124 469 /**
@@ -130,354 +475,1754 @@
130 475 public static function intArray( $value ) {
131 476 if ( ! is_array( $value ) ) {
132 477 return [];
133 478 }
479 +
134 480 $out = [];
135 481 foreach ( $value as $item ) {
136 - $id = absint( $item );
482 + if ( ! is_scalar( $item ) ) {
483 + continue;
484 + }
485 +
486 + // Cast rather than absint(): a negative id posted over AJAX should be
487 + // dropped, not flipped into a different, valid product.
488 + $id = (int) $item;
489 +
137 490 if ( $id > 0 ) {
138 491 $out[] = $id;
139 492 }
140 493 }
494 +
141 495 return array_values( array_unique( $out ) );
142 496 }
143 497
144 498 /**
499 + * Plain single-line text with a guaranteed fallback.
500 + *
501 + * @param mixed $value Raw value.
502 + * @param string $fallback Used when the value is missing or blank.
503 + * @return string
504 + */
505 + public static function text( $value, $fallback = '' ) {
506 + $value = is_scalar( $value ) ? wp_strip_all_tags( (string) $value ) : '';
507 +
508 + return '' !== trim( $value ) ? $value : $fallback;
509 + }
510 +
511 + /**
512 + * Text that is allowed to be empty (a deliberately blank prefix/suffix).
513 + *
514 + * @param mixed $value Raw value.
515 + * @return string
516 + */
517 + public static function optionalText( $value ) {
518 + return is_scalar( $value ) ? wp_strip_all_tags( (string) $value ) : '';
519 + }
520 +
521 + /**
522 + * A boolean attribute that defaults to true when the key is absent.
523 + *
524 + * @param array $attributes Raw attributes.
525 + * @param string $key Attribute name.
526 + * @return bool
527 + */
528 + protected static function boolOn( array $attributes, $key ) {
529 + return ! isset( $attributes[ $key ] ) || (bool) $attributes[ $key ];
530 + }
531 +
532 + /**
533 + * Sanitize an icon from the library.
534 + *
535 + * IconLibrary stores the icon as SVG markup, not a class, and React renders
536 + * it with dangerouslySetInnerHTML — so it goes through the plugin's SVG
537 + * sanitizer, which strips scripts, event handlers and remote references.
538 + * Anything that is not an SVG is dropped rather than passed along.
539 + *
540 + * @param mixed $value Raw markup from the picker.
541 + * @return string
542 + */
543 + public static function iconMarkup( $value ) {
544 + $value = is_string( $value ) ? trim( $value ) : '';
545 +
546 + if ( '' === $value || false === stripos( $value, '<svg' ) ) {
547 + return '';
548 + }
549 +
550 + return (string) Sanitize::markup( $value );
551 + }
552 +
553 + /** Background types the Background control can produce. */
554 + const BG_TYPES = [ 'solid', 'color', 'gradient', 'image' ];
555 +
556 + /** Units the gap controls offer. */
557 + const CSS_UNITS = [ 'px', 'em', 'rem', '%', 'vh', 'vw' ];
558 +
559 + /**
560 + * Sanitize a CSS length such as `20px`, `1.5rem` or `5%`.
561 + *
562 + * A bare number is accepted and read as pixels — attributes come back
563 + * through AJAX, so a unitless value has to land somewhere defined.
564 + *
565 + * @param mixed $value Raw value.
566 + * @param string $fallback Fallback length.
567 + * @return string
568 + */
569 + public static function cssLength( $value, $fallback = '0px' ) {
570 + if ( is_int( $value ) || is_float( $value ) ) {
571 + return $value . 'px';
572 + }
573 +
574 + $value = is_string( $value ) ? trim( $value ) : '';
575 +
576 + if ( '' === $value ) {
577 + return $fallback;
578 + }
579 +
580 + if ( preg_match( '/^-?\d+(\.\d+)?$/', $value ) ) {
581 + return $value . 'px';
582 + }
583 +
584 + $units = implode( '|', self::CSS_UNITS );
585 +
586 + return preg_match( '/^-?\d+(\.\d+)?(' . $units . ')$/', $value ) ? $value : $fallback;
587 + }
588 +
589 + /**
590 + * Sanitize a bpl-tools BoxControl value into a CSS shorthand.
591 + *
592 + * The control stores { top, right, bottom, left }. A bare number is accepted
593 + * and applied to all four corners, for the same reason cssLength() takes one.
594 + *
595 + * @param mixed $box Raw box value.
596 + * @param string $fallback Fallback shorthand.
597 + * @return string
598 + */
599 + public static function boxCSS( $box, $fallback = '0px' ) {
600 + if ( is_int( $box ) || is_float( $box ) || ( is_string( $box ) && preg_match( '/^-?\d+(\.\d+)?$/', (string) $box ) ) ) {
601 + return $box . 'px';
602 + }
603 +
604 + if ( is_string( $box ) ) {
605 + return self::cssLength( $box, $fallback );
606 + }
607 +
608 + if ( ! is_array( $box ) ) {
609 + return $fallback;
610 + }
611 +
612 + $sides = [];
613 + foreach ( [ 'top', 'right', 'bottom', 'left' ] as $side ) {
614 + $sides[] = self::cssLength( $box[ $side ] ?? '', '0px' );
615 + }
616 +
617 + // All four empty means the author cleared it; fall back rather than
618 + // emitting a meaningless `0px 0px 0px 0px`.
619 + return implode( ' ', $sides ) === '0px 0px 0px 0px' && empty( array_filter( (array) $box ) ) ? $fallback : implode( ' ', $sides );
620 + }
621 +
622 + /** Border styles BorderBoxControl can produce. */
623 + const BORDER_STYLES = [ 'none', 'solid', 'dashed', 'dotted', 'double', 'groove', 'ridge', 'inset', 'outset' ];
624 +
625 + /**
626 + * Sanitize one side of a border: { width, style, color }.
627 + *
628 + * @param mixed $side Raw side.
629 + * @return array|null Cleaned side, or null when nothing usable is set.
630 + */
631 + protected static function borderSide( $side ) {
632 + if ( ! is_array( $side ) ) {
633 + return null;
634 + }
635 +
636 + $width = self::cssLength( $side['width'] ?? '', '' );
637 + $color = self::sanitizeColor( $side['color'] ?? '', '' );
638 + $style = self::pickFrom( $side['style'] ?? 'solid', self::BORDER_STYLES, 'solid' );
639 +
640 + if ( '' === $width && '' === $color ) {
641 + return null;
642 + }
643 +
644 + return [
645 + 'width' => '' === $width ? '0px' : $width,
646 + 'style' => $style,
647 + 'color' => $color,
648 + ];
649 + }
650 +
651 + /**
652 + * Sanitize a BorderBoxControl value.
653 + *
654 + * The control emits either one border — { width, style, color } — or a split
655 + * one keyed by side.
656 + *
657 + * @param mixed $border Raw BorderBoxControl value.
658 + * @return array
659 + */
660 + public static function borderBox( $border ) {
661 + if ( ! is_array( $border ) || empty( $border ) ) {
662 + return [];
663 + }
664 +
665 + $sides = [ 'top', 'right', 'bottom', 'left' ];
666 + $split = (bool) array_intersect( $sides, array_keys( $border ) );
667 +
668 + if ( ! $split ) {
669 + $side = self::borderSide( $border );
670 +
671 + return $side ? $side : [];
672 + }
673 +
674 + $clean = [];
675 + foreach ( $sides as $side ) {
676 + $value = self::borderSide( $border[ $side ] ?? null );
677 + if ( $value ) {
678 + $clean[ $side ] = $value;
679 + }
680 + }
681 +
682 + return $clean;
683 + }
684 +
685 + /**
686 + * Turn a `top right bottom left` shorthand back into its four sides.
687 + *
688 + * resolveAttributes() flattens boxes to a shorthand for CSS; React needs the
689 + * object shape BoxControl reads.
690 + *
691 + * @param string $shorthand Result of boxCSS().
692 + * @return array
693 + */
694 + public static function boxSides( $shorthand ) {
695 + $sides = [ 'top', 'right', 'bottom', 'left' ];
696 + $parts = preg_split( '/\s+/', trim( (string) $shorthand ) );
697 +
698 + // Spell the sides out even when unset, so the JSON stays an object and
699 + // BoxControl gets the shape it expects — the same form article-card uses.
700 + if ( 4 !== count( $parts ) || '' === $parts[0] ) {
701 + return array_fill_keys( $sides, '' );
702 + }
703 +
704 + return array_combine( $sides, $parts );
705 + }
706 +
707 + /**
708 + * Render a sanitized border box as CSS declarations.
709 + *
710 + * @param array $border Result of borderBox().
711 + * @return string
712 + */
713 + public static function borderBoxCSS( $border ) {
714 + if ( ! is_array( $border ) || empty( $border ) ) {
715 + return '';
716 + }
717 +
718 + $declaration = static function ( $side ) {
719 + return $side['width'] . ' ' . $side['style'] . ' ' . $side['color'];
720 + };
721 +
722 + if ( isset( $border['width'] ) ) {
723 + return 'border:' . $declaration( $border ) . ';';
724 + }
725 +
726 + $css = '';
727 + foreach ( [ 'top', 'right', 'bottom', 'left' ] as $side ) {
728 + if ( isset( $border[ $side ] ) ) {
729 + $css .= 'border-' . $side . ':' . $declaration( $border[ $side ] ) . ';';
730 + }
731 + }
732 +
733 + return $css;
734 + }
735 +
736 + /** Upper bound on stacked shadows, so a crafted payload cannot balloon the CSS. */
737 + const MAX_SHADOWS = 10;
738 +
739 + /**
740 + * Sanitize a bpl-tools ShadowControl value.
741 + *
742 + * The control stores a list of { hOffset, vOffset, blur, spreed, color,
743 + * isInset }.
744 + *
745 + * @param mixed $value Raw shadow value.
746 + * @return array
747 + */
748 + public static function shadows( $value ) {
749 + if ( ! is_array( $value ) ) {
750 + return [];
751 + }
752 +
753 + $out = [];
754 +
755 + foreach ( array_slice( $value, 0, self::MAX_SHADOWS ) as $shadow ) {
756 + if ( ! is_array( $shadow ) ) {
757 + continue;
758 + }
759 +
760 + $out[] = [
761 + 'hOffset' => self::cssLength( $shadow['hOffset'] ?? '', '0px' ),
762 + 'vOffset' => self::cssLength( $shadow['vOffset'] ?? '', '0px' ),
763 + 'blur' => self::cssLength( $shadow['blur'] ?? '', '0px' ),
764 + 'spreed' => self::cssLength( $shadow['spreed'] ?? '', '0px' ),
765 + 'color' => self::sanitizeColor( $shadow['color'] ?? '', '#7090b0' ),
766 + 'isInset' => ! empty( $shadow['isInset'] ),
767 + ];
768 + }
769 +
770 + return $out;
771 + }
772 +
773 + /**
774 + * Render sanitized shadows as a box-shadow value.
775 + *
776 + * @param array $shadows Result of shadows().
777 + * @return string
778 + */
779 + public static function shadowCSS( $shadows ) {
780 + if ( ! is_array( $shadows ) || empty( $shadows ) ) {
781 + return 'none';
782 + }
783 +
784 + $parts = [];
785 +
786 + foreach ( $shadows as $shadow ) {
787 + $parts[] = trim(
788 + $shadow['hOffset'] . ' ' . $shadow['vOffset'] . ' ' . $shadow['blur'] . ' ' .
789 + $shadow['spreed'] . ' ' . $shadow['color'] . ( $shadow['isInset'] ? ' inset' : '' )
790 + );
791 + }
792 +
793 + return implode( ', ', $parts );
794 + }
795 +
796 + /**
797 + * Sanitize a bpl-tools Background object.
798 + *
799 + * The control stores { type, color, gradient, image, position, … }.
800 + *
801 + * @param mixed $bg Raw background value.
802 + * @param string $fallback Fallback colour.
803 + * @return array
804 + */
805 + public static function background( $bg, $fallback = '' ) {
806 + if ( ! is_array( $bg ) ) {
807 + return [ 'type' => 'solid', 'color' => $fallback ];
808 + }
809 +
810 + $type = self::pickFrom( $bg['type'] ?? 'solid', self::BG_TYPES, 'solid' );
811 +
812 + $clean = [
813 + 'type' => $type,
814 + 'color' => self::sanitizeColor( $bg['color'] ?? '', $fallback ),
815 + ];
816 +
817 + if ( 'gradient' === $type ) {
818 + $gradient = is_string( $bg['gradient'] ?? null ) ? trim( $bg['gradient'] ) : '';
819 +
820 + // Only the gradient functions, and only the characters they need —
821 + // this lands in a CSS declaration.
822 + $clean['gradient'] = preg_match( '/^(linear|radial|conic)-gradient\([a-zA-Z0-9\s,%.#()\-]+\)$/', $gradient )
823 + ? $gradient
824 + : '';
825 + }
826 +
827 + if ( 'image' === $type ) {
828 + $image = is_array( $bg['image'] ?? null ) ? $bg['image'] : [];
829 +
830 + $clean['image'] = [
831 + 'id' => absint( $image['id'] ?? 0 ),
832 + 'url' => esc_url_raw( $image['url'] ?? '' ),
833 + ];
834 + $clean['position'] = self::cssToken( $bg['position'] ?? '' );
835 + $clean['attachment'] = self::cssToken( $bg['attachment'] ?? '' );
836 + $clean['repeat'] = self::cssToken( $bg['repeat'] ?? '' );
837 + $clean['size'] = self::cssToken( $bg['size'] ?? '' );
838 + $clean['overlayColor'] = self::sanitizeColor( $bg['overlayColor'] ?? '', '' );
839 + }
840 +
841 + return $clean;
842 + }
843 +
844 + /**
845 + * A short CSS keyword such as `center center` or `no-repeat`.
846 + *
847 + * @param mixed $value Raw value.
848 + * @return string
849 + */
850 + public static function cssToken( $value ) {
851 + $value = is_string( $value ) ? trim( $value ) : '';
852 +
853 + return preg_match( '/^[a-zA-Z0-9%\s.-]{0,40}$/', $value ) ? $value : '';
854 + }
855 +
856 + /**
857 + * Sanitize a bpl-tools typography object so it is safe to hand to GetCSS.
858 + *
859 + * Only the keys getTypoCSS reads are kept, and each is reduced to a short
860 + * CSS-safe token — the object arrives from the client over AJAX.
861 + *
862 + * @param mixed $typo Raw typography object.
863 + * @return array
864 + */
865 + public static function typo( $typo ) {
866 + $typo = is_array( $typo ) ? $typo : [];
867 +
868 + $token = static function ( $value, $pattern, $max = 40 ) {
869 + $value = is_scalar( $value ) ? trim( (string) $value ) : '';
870 +
871 + return ( '' !== $value && strlen( $value ) <= $max && preg_match( $pattern, $value ) ) ? $value : '';
872 + };
873 +
874 + $size = static function ( $value ) use ( $token ) {
875 + return $token( $value, '/^[0-9.]+(px|em|rem|%|vh|vw)?$/' );
876 + };
877 +
878 + // Letter spacing is the one size that can go below zero — tightening
879 + // display type is common — and the Typography control allows it. The
880 + // shared pattern dropped it here, so the editor showed -0.5px and the
881 + // published page showed nothing.
882 + $spacing = static function ( $value ) use ( $token ) {
883 + return $token( $value, '/^-?[0-9.]+(px|em|rem|%|vh|vw)?$/' );
884 + };
885 +
886 + $fontSize = is_array( $typo['fontSize'] ?? null ) ? $typo['fontSize'] : [];
887 +
888 + $clean = [
889 + 'fontFamily' => $token( $typo['fontFamily'] ?? '', '/^[a-zA-Z0-9 \-]+$/', 60 ),
890 + 'fontCategory' => $token( $typo['fontCategory'] ?? '', '/^[a-zA-Z\-]+$/' ),
891 + 'fontVariant' => $token( $typo['fontVariant'] ?? '', '/^[0-9]{3}i?$/' ),
892 + 'fontWeight' => $token( $typo['fontWeight'] ?? '', '/^([0-9]{3}|normal|bold|lighter|bolder)$/' ),
893 + 'isUploadFont' => ! isset( $typo['isUploadFont'] ) || (bool) $typo['isUploadFont'],
894 + 'fontStyle' => $token( $typo['fontStyle'] ?? '', '/^(normal|italic|oblique)$/' ),
895 + 'textTransform' => $token( $typo['textTransform'] ?? '', '/^(none|capitalize|uppercase|lowercase)$/' ),
896 + 'textDecoration' => $token( $typo['textDecoration'] ?? '', '/^(none|underline|overline|line-through)$/' ),
897 + 'lineHeight' => $size( $typo['lineHeight'] ?? '' ),
898 + 'letterSpace' => $spacing( $typo['letterSpace'] ?? '' ),
899 + 'fontSize' => [
900 + 'desktop' => $size( $fontSize['desktop'] ?? '' ),
901 + 'tablet' => $size( $fontSize['tablet'] ?? '' ),
902 + 'mobile' => $size( $fontSize['mobile'] ?? '' ),
903 + ],
904 + ];
905 +
906 + // getTypoCSS cascades desktop -> tablet -> mobile itself, but only when
907 + // the narrower keys are absent rather than empty strings.
908 + foreach ( [ 'tablet', 'mobile' ] as $device ) {
909 + if ( '' === $clean['fontSize'][ $device ] ) {
910 + unset( $clean['fontSize'][ $device ] );
911 + }
912 + }
913 +
914 + return $clean;
915 + }
916 +
917 + /**
145 918 * Normalize and sanitize the full attribute set into a safe, typed array.
146 919 *
920 + * Everything downstream — query, markup and CSS — reads only this array, so
921 + * an attribute that never lands here can never reach output.
922 + *
147 923 * @param array $attributes Raw block attributes.
148 924 * @return array
149 925 */
150 926 public static function resolveAttributes( array $attributes ) {
151 - $columns = (array) ( $attributes['columns'] ?? [] );
152 - $titleFont = (array) ( $attributes['titleFontSize'] ?? [] );
927 + $attributes = self::flatten( $attributes );
153 928
154 - $saleBadgeLabel = isset( $attributes['saleBadgeLabel'] ) ? wp_strip_all_tags( (string) $attributes['saleBadgeLabel'] ) : '';
155 - $saleBadgeLabel = '' !== trim( $saleBadgeLabel ) ? $saleBadgeLabel : __( 'Sale', 'b-blocks' );
929 + $columns = is_array( $attributes['columns'] ?? null ) ? $attributes['columns'] : [];
156 930
157 - $addToCartLabel = isset( $attributes['addToCartLabel'] ) ? wp_strip_all_tags( (string) $attributes['addToCartLabel'] ) : '';
158 - $addToCartLabel = '' !== trim( $addToCartLabel ) ? $addToCartLabel : __( 'Add to Cart', 'b-blocks' );
931 + // Per-device boxes are { desktop, tablet, mobile }, each a BoxControl
932 + // object. Anything else came off the wire malformed, so only the desktop
933 + // slot takes it and the other two fall back to their own defaults.
934 + $box = static function ( $key, $device ) use ( $attributes ) {
935 + $value = $attributes[ $key ] ?? null;
159 936
160 - $noProductsMessage = isset( $attributes['noProductsMessage'] ) ? wp_strip_all_tags( (string) $attributes['noProductsMessage'] ) : '';
161 - $noProductsMessage = '' !== trim( $noProductsMessage ) ? $noProductsMessage : __( 'No products found.', 'b-blocks' );
937 + if ( is_array( $value ) && ( isset( $value['desktop'] ) || isset( $value['tablet'] ) || isset( $value['mobile'] ) ) ) {
938 + return $value[ $device ] ?? null;
939 + }
162 940
941 + return 'desktop' === $device ? $value : null;
942 + };
943 +
944 + $contentAlign = $attributes['contentAlign'] ?? [];
945 + $contentAlign = is_array( $contentAlign ) ? $contentAlign : [];
946 + $titleTypo = self::typo( $attributes['titleTypo'] ?? [] );
947 +
948 + $order = self::contentOrder( $attributes['contentOrder'] ?? [] );
949 +
163 950 return [
164 - 'columnsDesktop' => self::clampInt( $columns['desktop'] ?? 3, 1, 6, 3 ),
165 - 'columnsTablet' => self::clampInt( $columns['tablet'] ?? 2, 1, 6, 2 ),
166 - 'columnsMobile' => self::clampInt( $columns['mobile'] ?? 1, 1, 6, 1 ),
167 - 'columnGap' => self::clampInt( $attributes['columnGap'] ?? 20, 0, 60, 20 ),
168 - 'rowGap' => self::clampInt( $attributes['rowGap'] ?? 20, 0, 60, 20 ),
951 + /* Layout */
952 + 'layouts' => self::pickFrom( $attributes['layouts'] ?? 'grid', self::LAYOUTS, 'grid' ),
953 + 'columnsDesktop' => self::clampInt( $columns['desktop'] ?? 4, 1, 12, 4 ),
954 + 'columnsTablet' => self::clampInt( $columns['tablet'] ?? 2, 1, 12, 2 ),
955 + 'columnsMobile' => self::clampInt( $columns['mobile'] ?? 1, 1, 12, 1 ),
956 + 'showImage' => self::boolOn( $attributes, 'showImage' ),
957 + 'showCategory' => self::boolOn( $attributes, 'showCategory' ),
958 + 'showRating' => self::boolOn( $attributes, 'showRating' ),
959 + 'ratingStyle' => self::pickFrom( $attributes['ratingStyle'] ?? 'star', self::RATING_STYLE, 'star' ),
960 + 'ratingShowEmpty' => self::boolOn( $attributes, 'ratingShowEmpty' ),
961 + 'showPrice' => self::boolOn( $attributes, 'showPrice' ),
962 + 'showSaleBadge' => self::boolOn( $attributes, 'showSaleBadge' ),
963 + 'showSoldCount' => self::boolOn( $attributes, 'showSoldCount' ),
964 + 'showProgressBar' => self::boolOn( $attributes, 'showProgressBar' ),
965 + 'soldPrefix' => self::optionalText( $attributes['soldPrefix'] ?? __( 'Sold', 'b-blocks' ) ),
966 + 'soldSuffix' => self::optionalText( $attributes['soldSuffix'] ?? '+' ),
967 + 'withoutStockValue' => self::clampInt( $attributes['withoutStockValue'] ?? 50, 0, 100, 50 ),
968 + 'showViewButton' => self::boolOn( $attributes, 'showViewButton' ),
969 + 'viewButtonText' => self::text( $attributes['viewButtonText'] ?? '', __( 'Visit Product', 'b-blocks' ) ),
970 + 'showTitle' => self::boolOn( $attributes, 'showTitle' ),
971 + 'titleTag' => self::pickFrom( is_string( $attributes['titleTag'] ?? null ) ? strtolower( $attributes['titleTag'] ) : 'h4', self::TITLE_TAGS, 'h4' ),
972 + 'actionPosition' => self::pickFrom( $attributes['actionPosition'] ?? 'overlay', self::ACTION_POSITIONS, 'overlay' ),
973 + 'actionAlign' => self::pickFrom( $attributes['actionAlign'] ?? 'center', self::ACTION_ALIGNS, 'center' ),
974 + 'actionFull' => ! empty( $attributes['actionFull'] ),
975 + 'actionIconOnly' => ! empty( $attributes['actionIconOnly'] ),
976 + 'actionOrder' => self::pickFrom( $attributes['actionOrder'] ?? 'cart', self::ACTION_ORDER, 'cart' ),
977 + 'imageSize' => self::pickFrom( $attributes['imageSize'] ?? 'default', self::IMAGE_SIZES, 'default' ),
169 978
170 - 'productsPerPage' => self::clampInt( $attributes['productsPerPage'] ?? 9, 1, 48, 9 ),
171 - 'orderBy' => self::pickFrom( $attributes['orderBy'] ?? 'date', self::ORDERBY, 'date' ),
172 - 'order' => self::pickFrom( strtoupper( (string) ( $attributes['order'] ?? 'desc' ) ), self::ORDER, 'DESC' ),
173 - 'productCategories' => self::intArray( $attributes['productCategories'] ?? [] ),
174 - 'productTags' => self::intArray( $attributes['productTags'] ?? [] ),
175 - 'onSaleOnly' => ! empty( $attributes['onSaleOnly'] ),
176 - 'featuredOnly' => ! empty( $attributes['featuredOnly'] ),
979 + /* Query */
980 + 'queryType' => self::pickFrom( $attributes['queryType'] ?? 'custom', self::QUERY_TYPES, 'custom' ),
981 + 'orderBy' => self::pickFrom( $attributes['orderBy'] ?? 'date', self::ORDERBY, 'date' ),
982 + 'order' => self::pickFrom( is_string( $attributes['order'] ?? null ) ? strtoupper( $attributes['order'] ) : 'DESC', self::ORDER, 'DESC' ),
983 + 'productsPerPage' => self::clampInt( $attributes['productsPerPage'] ?? 4, 1, self::MAX_PER_PAGE, 4 ),
984 + 'offset' => self::clampInt( $attributes['offset'] ?? 0, 0, 10000, 0 ),
985 + 'includeProducts' => self::intArray( $attributes['includeProducts'] ?? [] ),
986 + 'excludeProducts' => self::intArray( $attributes['excludeProducts'] ?? [] ),
987 + 'categoryMode' => self::pickFrom( $attributes['categoryMode'] ?? 'include', self::TAX_MODES, 'include' ),
988 + 'productCategories' => self::intArray( $attributes['productCategories'] ?? [] ),
989 + 'tagMode' => self::pickFrom( $attributes['tagMode'] ?? 'include', self::TAX_MODES, 'include' ),
990 + 'productTags' => self::intArray( $attributes['productTags'] ?? [] ),
991 + 'onSaleOnly' => ! empty( $attributes['onSaleOnly'] ),
992 + 'featuredOnly' => ! empty( $attributes['featuredOnly'] ),
993 + 'currentProductId' => absint( $attributes['currentProductId'] ?? 0 ),
177 994
178 - 'showImage' => ! isset( $attributes['showImage'] ) || (bool) $attributes['showImage'],
179 - 'imageFit' => self::pickFrom( $attributes['imageFit'] ?? 'cover', [ 'cover', 'contain' ], 'cover' ),
180 - 'imageRatio' => self::pickFrom( $attributes['imageRatio'] ?? '3/4', self::RATIOS, '3/4' ),
995 + /* Cart text */
996 + 'showAddToCart' => self::boolOn( $attributes, 'showAddToCart' ),
997 + 'useCustomCartText' => self::boolOn( $attributes, 'useCustomCartText' ),
998 + 'cartTextSimple' => self::text( $attributes['cartTextSimple'] ?? '', __( 'Buy Now', 'b-blocks' ) ),
999 + 'cartTextVariable' => self::text( $attributes['cartTextVariable'] ?? '', __( 'Select options', 'b-blocks' ) ),
1000 + 'cartTextGrouped' => self::text( $attributes['cartTextGrouped'] ?? '', __( 'View products', 'b-blocks' ) ),
1001 + 'cartTextExternal' => self::text( $attributes['cartTextExternal'] ?? '', __( 'Buy now', 'b-blocks' ) ),
1002 + 'cartTextDefault' => self::text( $attributes['cartTextDefault'] ?? '', __( 'Read more', 'b-blocks' ) ),
181 1003
182 - 'showTitle' => ! isset( $attributes['showTitle'] ) || (bool) $attributes['showTitle'],
183 - 'showPrice' => ! isset( $attributes['showPrice'] ) || (bool) $attributes['showPrice'],
184 - 'showRating' => ! isset( $attributes['showRating'] ) || (bool) $attributes['showRating'],
185 - 'showSaleBadge' => ! isset( $attributes['showSaleBadge'] ) || (bool) $attributes['showSaleBadge'],
186 - 'saleBadgeLabel' => $saleBadgeLabel,
187 - 'showAddToCart' => ! isset( $attributes['showAddToCart'] ) || (bool) $attributes['showAddToCart'],
188 - 'addToCartLabel' => $addToCartLabel,
189 - 'contentAlign' => self::pickFrom( $attributes['contentAlign'] ?? 'left', [ 'left', 'center', 'right' ], 'left' ),
1004 + /* Sale badge */
1005 + 'badgeAlign' => self::pickFrom( $attributes['badgeAlign'] ?? 'top', self::BADGE_ALIGN, 'top' ),
1006 + 'saleBadgeLabel' => self::text( $attributes['saleBadgeLabel'] ?? '', __( 'Sale', 'b-blocks' ) ),
1007 + 'badgeContent' => self::pickFrom( $attributes['badgeContent'] ?? 'text', self::BADGE_CONTENT, 'text' ),
190 1008
191 - 'cardBG' => self::sanitizeColor( $attributes['cardBG'] ?? '', '#ffffff' ),
192 - 'cardPadding' => self::clampInt( $attributes['cardPadding'] ?? 16, 0, 48, 16 ),
193 - 'cardBorderWidth' => self::clampInt( $attributes['cardBorderWidth'] ?? 1, 0, 8, 1 ),
194 - 'cardBorderColor' => self::sanitizeColor( $attributes['cardBorderColor'] ?? '', '#e2e8f0' ),
195 - 'cardRadius' => self::clampInt( $attributes['cardRadius'] ?? 8, 0, 32, 8 ),
196 - 'cardShadow' => self::pickFrom( $attributes['cardShadow'] ?? 'none', [ 'none', 'sm', 'md', 'lg' ], 'none' ),
1009 + /* Load more */
1010 + 'enableLoadMore' => self::boolOn( $attributes, 'enableLoadMore' ),
1011 + 'morePostsType' => self::pickFrom( $attributes['morePostsType'] ?? 'button', self::MORE_TYPES, 'button' ),
1012 + 'loadMoreText' => self::text( $attributes['loadMoreText'] ?? '', __( 'Load More', 'b-blocks' ) ),
1013 + 'loadMoreAlign' => self::pickFrom( $attributes['loadMoreAlign'] ?? 'center', self::ALIGNS, 'center' ),
197 1014
198 - 'titleColor' => self::sanitizeColor( $attributes['titleColor'] ?? '', 'inherit' ),
199 - 'priceColor' => self::sanitizeColor( $attributes['priceColor'] ?? '', '#e44d3a' ),
200 - 'regularPriceColor' => self::sanitizeColor( $attributes['regularPriceColor'] ?? '', '#999999' ),
201 - 'ratingColor' => self::sanitizeColor( $attributes['ratingColor'] ?? '', '#f5a623' ),
202 - 'badgeBG' => self::sanitizeColor( $attributes['badgeBG'] ?? '', '#e44d3a' ),
203 - 'badgeTextColor' => self::sanitizeColor( $attributes['badgeTextColor'] ?? '', '#ffffff' ),
204 - 'btnColor' => self::sanitizeColor( $attributes['btnColor'] ?? '', '#ffffff' ),
205 - 'btnBG' => self::sanitizeColor( $attributes['btnBG'] ?? '', '#146EF5' ),
206 - 'btnHovColor' => self::sanitizeColor( $attributes['btnHovColor'] ?? '', '#ffffff' ),
207 - 'btnHovBG' => self::sanitizeColor( $attributes['btnHovBG'] ?? '', '#070127' ),
208 - 'btnRadius' => self::clampInt( $attributes['btnRadius'] ?? 4, 0, 32, 4 ),
1015 + /* Sorting */
1016 + 'contentOrder' => $order,
209 1017
210 - 'titleSizeDesktop' => self::clampInt( preg_replace( '/[^0-9]/', '', (string) ( $titleFont['desktop'] ?? '17' ) ), 12, 40, 17 ),
211 - 'titleSizeTablet' => self::clampInt( preg_replace( '/[^0-9]/', '', (string) ( $titleFont['tablet'] ?? '16' ) ), 12, 36, 16 ),
212 - 'titleSizeMobile' => self::clampInt( preg_replace( '/[^0-9]/', '', (string) ( $titleFont['mobile'] ?? '15' ) ), 12, 32, 15 ),
1018 + /* Taxonomy filter */
1019 + 'showTaxonomyFilter' => ! empty( $attributes['showTaxonomyFilter'] ),
1020 + 'filterTaxonomy' => self::taxonomy( $attributes['filterTaxonomy'] ?? 'product_cat' ),
1021 + 'filterAllText' => self::text( $attributes['filterAllText'] ?? '', __( 'All', 'b-blocks' ) ),
1022 + 'filterAlign' => self::pickFrom( $attributes['filterAlign'] ?? 'left', self::ALIGNS, 'left' ),
213 1023
214 - 'noProductsMessage' => $noProductsMessage,
1024 + /* Style — grid & card */
1025 + 'columnGapDesktop' => self::cssLength( $box( 'columnGap', 'desktop' ) ?? '20px', '20px' ),
1026 + 'columnGapTablet' => self::cssLength( $box( 'columnGap', 'tablet' ) ?? '', '' ),
1027 + 'columnGapMobile' => self::cssLength( $box( 'columnGap', 'mobile' ) ?? '', '' ),
1028 + 'rowGapDesktop' => self::cssLength( $box( 'rowGap', 'desktop' ) ?? '20px', '20px' ),
1029 + 'rowGapTablet' => self::cssLength( $box( 'rowGap', 'tablet' ) ?? '', '' ),
1030 + 'rowGapMobile' => self::cssLength( $box( 'rowGap', 'mobile' ) ?? '', '' ),
1031 + 'cardBG' => self::background( $attributes['cardBG'] ?? '', '#ffffff' ),
1032 + 'cardBodyBG' => self::background( $attributes['cardBodyBG'] ?? '', '' ),
1033 + 'cardDivider' => self::sanitizeColor( $attributes['cardDivider'] ?? '', '#f1f5f9' ),
1034 + 'cardBodyMarginDesktop' => self::boxCSS( $box( 'cardBodyMargin', 'desktop' ), '' ),
1035 + 'cardBodyMarginTablet' => self::boxCSS( $box( 'cardBodyMargin', 'tablet' ), '' ),
1036 + 'cardBodyMarginMobile' => self::boxCSS( $box( 'cardBodyMargin', 'mobile' ), '' ),
1037 + 'cardPaddingDesktop' => self::boxCSS( $box( 'cardPadding', 'desktop' ), '16px' ),
1038 + 'cardPaddingTablet' => self::boxCSS( $box( 'cardPadding', 'tablet' ), '' ),
1039 + 'cardPaddingMobile' => self::boxCSS( $box( 'cardPadding', 'mobile' ), '' ),
1040 + 'cardBorder' => self::borderBox( $attributes['cardBorder'] ?? null ),
1041 + 'cardRadiusDesktop' => self::boxCSS( $box( 'cardRadius', 'desktop' ), '8px' ),
1042 + 'cardRadiusTablet' => self::boxCSS( $box( 'cardRadius', 'tablet' ), '' ),
1043 + 'cardRadiusMobile' => self::boxCSS( $box( 'cardRadius', 'mobile' ), '' ),
1044 + 'cardHovPaddingDesktop' => self::boxCSS( $box( 'cardHovPadding', 'desktop' ), '' ),
1045 + 'cardHovPaddingTablet' => self::boxCSS( $box( 'cardHovPadding', 'tablet' ), '' ),
1046 + 'cardHovPaddingMobile' => self::boxCSS( $box( 'cardHovPadding', 'mobile' ), '' ),
1047 + 'cardHovRadiusDesktop' => self::boxCSS( $box( 'cardHovRadius', 'desktop' ), '' ),
1048 + 'cardHovRadiusTablet' => self::boxCSS( $box( 'cardHovRadius', 'tablet' ), '' ),
1049 + 'cardHovRadiusMobile' => self::boxCSS( $box( 'cardHovRadius', 'mobile' ), '' ),
1050 + 'cardShadow' => self::shadows( $attributes['cardShadow'] ?? [] ),
1051 + 'cardTransition' => self::clampFloat( $attributes['cardTransition'] ?? 0.25, 0, 5, 0.25 ),
1052 + 'cardHovBG' => self::background( $attributes['cardHovBG'] ?? '', '' ),
1053 + 'cardHovBorder' => self::borderBox( $attributes['cardHovBorder'] ?? null ),
1054 + 'cardHovShadow' => self::shadows( $attributes['cardHovShadow'] ?? [] ),
1055 + 'contentAlignDesktop' => self::pickFrom( $contentAlign['desktop'] ?? 'left', self::ALIGNS, 'left' ),
1056 + 'contentAlignTablet' => self::pickFrom( $contentAlign['tablet'] ?? '', self::ALIGNS, '' ),
1057 + 'contentAlignMobile' => self::pickFrom( $contentAlign['mobile'] ?? '', self::ALIGNS, '' ),
1058 +
1059 + /* Style — image & overlay */
1060 + 'imageRatio' => self::pickFrom( $attributes['imageRatio'] ?? '3/4', self::RATIOS, '3/4' ),
1061 + 'imageFit' => self::pickFrom( $attributes['imageFit'] ?? 'cover', self::FITS, 'cover' ),
1062 + 'imageRadiusDesktop' => self::boxCSS( $box( 'imageRadius', 'desktop' ), '8px' ),
1063 + 'imageRadiusTablet' => self::boxCSS( $box( 'imageRadius', 'tablet' ), '' ),
1064 + 'imageRadiusMobile' => self::boxCSS( $box( 'imageRadius', 'mobile' ), '' ),
1065 + 'imageMarginDesktop' => self::boxCSS( $box( 'imageMargin', 'desktop' ), '' ),
1066 + 'imageMarginTablet' => self::boxCSS( $box( 'imageMargin', 'tablet' ), '' ),
1067 + 'imageMarginMobile' => self::boxCSS( $box( 'imageMargin', 'mobile' ), '' ),
1068 + 'imageBG' => self::background( $attributes['imageBG'] ?? '', '#f1f5f9' ),
1069 + 'overlayBG' => self::background( $attributes['overlayBG'] ?? '', 'rgba(7, 1, 39, 0.45)' ),
1070 +
1071 + /* Style — content */
1072 + 'titleTypo' => $titleTypo,
1073 + 'titleTransition' => self::clampFloat( $attributes['titleTransition'] ?? 0.2, 0, 5, 0.2 ),
1074 + 'titleColor' => self::sanitizeColor( $attributes['titleColor'] ?? '', '#070127' ),
1075 + 'titleHovColor' => self::sanitizeColor( $attributes['titleHovColor'] ?? '', '#146EF5' ),
1076 + 'priceTypo' => self::typo( $attributes['priceTypo'] ?? [] ),
1077 + 'priceColor' => self::sanitizeColor( $attributes['priceColor'] ?? '', '#e44d3a' ),
1078 + 'regularPriceColor' => self::sanitizeColor( $attributes['regularPriceColor'] ?? '', '#999999' ),
1079 + 'ratingColor' => self::sanitizeColor( $attributes['ratingColor'] ?? '', '#f5a623' ),
1080 + 'ratingEmptyColor' => self::sanitizeColor( $attributes['ratingEmptyColor'] ?? '', '#d1d5db' ),
1081 + 'ratingCountColor' => self::sanitizeColor( $attributes['ratingCountColor'] ?? '', '#64748b' ),
1082 + 'ratingSize' => self::clampInt( $attributes['ratingSize'] ?? 14, 8, 40, 14 ),
1083 + 'categoryTypo' => self::typo( $attributes['categoryTypo'] ?? [] ),
1084 + 'categoryTransition' => self::clampFloat( $attributes['categoryTransition'] ?? 0.2, 0, 5, 0.2 ),
1085 + 'categoryColor' => self::sanitizeColor( $attributes['categoryColor'] ?? '', '#6b7280' ),
1086 + 'categoryHovColor' => self::sanitizeColor( $attributes['categoryHovColor'] ?? '', '#146EF5' ),
1087 + 'soldTypo' => self::typo( $attributes['soldTypo'] ?? [] ),
1088 + 'soldColor' => self::sanitizeColor( $attributes['soldColor'] ?? '', '#6b7280' ),
1089 + 'soldBG' => self::background( $attributes['soldBG'] ?? '', '' ),
1090 + 'progressTrackColor' => self::sanitizeColor( $attributes['progressTrackColor'] ?? '', '#e2e8f0' ),
1091 + 'progressFillColor' => self::sanitizeColor( $attributes['progressFillColor'] ?? '', '#146EF5' ),
1092 + 'progressHeight' => self::clampInt( $attributes['progressHeight'] ?? 6, 2, 24, 6 ),
1093 + 'progressRadiusDesktop' => self::boxCSS( $box( 'progressRadius', 'desktop' ), '3px' ),
1094 + 'progressRadiusTablet' => self::boxCSS( $box( 'progressRadius', 'tablet' ), '' ),
1095 + 'progressRadiusMobile' => self::boxCSS( $box( 'progressRadius', 'mobile' ), '' ),
1096 +
1097 + /* Style — buttons */
1098 + 'btnColor' => self::sanitizeColor( $attributes['btnColor'] ?? '', '#ffffff' ),
1099 + 'btnBG' => self::background( $attributes['btnBG'] ?? '', '#146EF5' ),
1100 + 'btnHovColor' => self::sanitizeColor( $attributes['btnHovColor'] ?? '', '#ffffff' ),
1101 + 'btnHovBG' => self::background( $attributes['btnHovBG'] ?? '', '#070127' ),
1102 + 'cartShowIcon' => self::boolOn( $attributes, 'cartShowIcon' ),
1103 + 'cartIcon' => self::iconMarkup( $attributes['cartIcon'] ?? '' ),
1104 + 'cartIconColor' => self::sanitizeColor( $attributes['cartIconColor'] ?? '', '' ),
1105 + 'cartIconSizeDesktop' => self::cssLength( $box( 'cartIconSize', 'desktop' ) ?? '', '' ),
1106 + 'cartIconSizeTablet' => self::cssLength( $box( 'cartIconSize', 'tablet' ) ?? '', '' ),
1107 + 'cartIconSizeMobile' => self::cssLength( $box( 'cartIconSize', 'mobile' ) ?? '', '' ),
1108 + 'cartIconPosition' => self::pickFrom( $attributes['cartIconPosition'] ?? 'left', self::ICON_POSITIONS, 'left' ),
1109 + 'btnTypo' => self::typo( $attributes['btnTypo'] ?? [] ),
1110 + 'btnTransition' => self::clampFloat( $attributes['btnTransition'] ?? 0.2, 0, 5, 0.2 ),
1111 + 'btnBorder' => self::borderBox( $attributes['btnBorder'] ?? null ),
1112 + 'btnHovBorder' => self::borderBox( $attributes['btnHovBorder'] ?? null ),
1113 + 'btnMarginDesktop' => self::boxCSS( $box( 'btnMargin', 'desktop' ), '' ),
1114 + 'btnMarginTablet' => self::boxCSS( $box( 'btnMargin', 'tablet' ), '' ),
1115 + 'btnMarginMobile' => self::boxCSS( $box( 'btnMargin', 'mobile' ), '' ),
1116 + 'btnPaddingDesktop' => self::boxCSS( $box( 'btnPadding', 'desktop' ), '13px 14px 13px 14px' ),
1117 + 'btnPaddingTablet' => self::boxCSS( $box( 'btnPadding', 'tablet' ), '' ),
1118 + 'btnPaddingMobile' => self::boxCSS( $box( 'btnPadding', 'mobile' ), '' ),
1119 + 'btnRadiusDesktop' => self::boxCSS( $box( 'btnRadius', 'desktop' ), '4px' ),
1120 + 'btnRadiusTablet' => self::boxCSS( $box( 'btnRadius', 'tablet' ), '' ),
1121 + 'btnRadiusMobile' => self::boxCSS( $box( 'btnRadius', 'mobile' ), '' ),
1122 + 'btnHovMarginDesktop' => self::boxCSS( $box( 'btnHovMargin', 'desktop' ), '' ),
1123 + 'btnHovMarginTablet' => self::boxCSS( $box( 'btnHovMargin', 'tablet' ), '' ),
1124 + 'btnHovMarginMobile' => self::boxCSS( $box( 'btnHovMargin', 'mobile' ), '' ),
1125 + 'btnHovPaddingDesktop' => self::boxCSS( $box( 'btnHovPadding', 'desktop' ), '' ),
1126 + 'btnHovPaddingTablet' => self::boxCSS( $box( 'btnHovPadding', 'tablet' ), '' ),
1127 + 'btnHovPaddingMobile' => self::boxCSS( $box( 'btnHovPadding', 'mobile' ), '' ),
1128 + 'btnHovRadiusDesktop' => self::boxCSS( $box( 'btnHovRadius', 'desktop' ), '' ),
1129 + 'btnHovRadiusTablet' => self::boxCSS( $box( 'btnHovRadius', 'tablet' ), '' ),
1130 + 'btnHovRadiusMobile' => self::boxCSS( $box( 'btnHovRadius', 'mobile' ), '' ),
1131 + 'viewBtnColor' => self::sanitizeColor( $attributes['viewBtnColor'] ?? '', '#070127' ),
1132 + 'viewBtnBG' => self::background( $attributes['viewBtnBG'] ?? '', '#ffffff' ),
1133 + 'viewBtnHovColor' => self::sanitizeColor( $attributes['viewBtnHovColor'] ?? '', '#ffffff' ),
1134 + 'viewBtnHovBG' => self::background( $attributes['viewBtnHovBG'] ?? '', '#146EF5' ),
1135 + 'viewShowIcon' => self::boolOn( $attributes, 'viewShowIcon' ),
1136 + 'viewIcon' => self::iconMarkup( $attributes['viewIcon'] ?? '' ),
1137 + 'viewIconColor' => self::sanitizeColor( $attributes['viewIconColor'] ?? '', '' ),
1138 + 'viewIconSizeDesktop' => self::cssLength( $box( 'viewIconSize', 'desktop' ) ?? '', '' ),
1139 + 'viewIconSizeTablet' => self::cssLength( $box( 'viewIconSize', 'tablet' ) ?? '', '' ),
1140 + 'viewIconSizeMobile' => self::cssLength( $box( 'viewIconSize', 'mobile' ) ?? '', '' ),
1141 + 'viewIconPosition' => self::pickFrom( $attributes['viewIconPosition'] ?? 'left', self::ICON_POSITIONS, 'left' ),
1142 + 'viewBtnTypo' => self::typo( $attributes['viewBtnTypo'] ?? [] ),
1143 + 'viewBtnTransition' => self::clampFloat( $attributes['viewBtnTransition'] ?? 0.2, 0, 5, 0.2 ),
1144 + 'viewBtnBorder' => self::borderBox( $attributes['viewBtnBorder'] ?? null ),
1145 + 'viewBtnHovBorder' => self::borderBox( $attributes['viewBtnHovBorder'] ?? null ),
1146 + 'viewBtnMarginDesktop' => self::boxCSS( $box( 'viewBtnMargin', 'desktop' ), '' ),
1147 + 'viewBtnMarginTablet' => self::boxCSS( $box( 'viewBtnMargin', 'tablet' ), '' ),
1148 + 'viewBtnMarginMobile' => self::boxCSS( $box( 'viewBtnMargin', 'mobile' ), '' ),
1149 + 'viewBtnPaddingDesktop' => self::boxCSS( $box( 'viewBtnPadding', 'desktop' ), '13px 14px 13px 14px' ),
1150 + 'viewBtnPaddingTablet' => self::boxCSS( $box( 'viewBtnPadding', 'tablet' ), '' ),
1151 + 'viewBtnPaddingMobile' => self::boxCSS( $box( 'viewBtnPadding', 'mobile' ), '' ),
1152 + 'viewBtnRadiusDesktop' => self::boxCSS( $box( 'viewBtnRadius', 'desktop' ), '4px' ),
1153 + 'viewBtnRadiusTablet' => self::boxCSS( $box( 'viewBtnRadius', 'tablet' ), '' ),
1154 + 'viewBtnRadiusMobile' => self::boxCSS( $box( 'viewBtnRadius', 'mobile' ), '' ),
1155 + 'viewBtnHovMarginDesktop' => self::boxCSS( $box( 'viewBtnHovMargin', 'desktop' ), '' ),
1156 + 'viewBtnHovMarginTablet' => self::boxCSS( $box( 'viewBtnHovMargin', 'tablet' ), '' ),
1157 + 'viewBtnHovMarginMobile' => self::boxCSS( $box( 'viewBtnHovMargin', 'mobile' ), '' ),
1158 + 'viewBtnHovPaddingDesktop' => self::boxCSS( $box( 'viewBtnHovPadding', 'desktop' ), '' ),
1159 + 'viewBtnHovPaddingTablet' => self::boxCSS( $box( 'viewBtnHovPadding', 'tablet' ), '' ),
1160 + 'viewBtnHovPaddingMobile' => self::boxCSS( $box( 'viewBtnHovPadding', 'mobile' ), '' ),
1161 + 'viewBtnHovRadiusDesktop' => self::boxCSS( $box( 'viewBtnHovRadius', 'desktop' ), '' ),
1162 + 'viewBtnHovRadiusTablet' => self::boxCSS( $box( 'viewBtnHovRadius', 'tablet' ), '' ),
1163 + 'viewBtnHovRadiusMobile' => self::boxCSS( $box( 'viewBtnHovRadius', 'mobile' ), '' ),
1164 + 'badgeBG' => self::background( $attributes['badgeBG'] ?? '', '#e44d3a' ),
1165 + 'badgeTextColor' => self::sanitizeColor( $attributes['badgeTextColor'] ?? '', '#ffffff' ),
1166 + 'badgeRadiusDesktop' => self::boxCSS( $box( 'badgeRadius', 'desktop' ), '4px' ),
1167 + 'badgeRadiusTablet' => self::boxCSS( $box( 'badgeRadius', 'tablet' ), '' ),
1168 + 'badgeRadiusMobile' => self::boxCSS( $box( 'badgeRadius', 'mobile' ), '' ),
1169 + 'badgeTypo' => self::typo( $attributes['badgeTypo'] ?? [] ),
1170 + 'badgePaddingDesktop' => self::boxCSS( $box( 'badgePadding', 'desktop' ), '3px 10px 3px 10px' ),
1171 + 'badgePaddingTablet' => self::boxCSS( $box( 'badgePadding', 'tablet' ), '' ),
1172 + 'badgePaddingMobile' => self::boxCSS( $box( 'badgePadding', 'mobile' ), '' ),
1173 + 'badgeOffsetDesktop' => self::cssLength( $box( 'badgeOffset', 'desktop' ) ?? '10px', '10px' ),
1174 + 'badgeOffsetTablet' => self::cssLength( $box( 'badgeOffset', 'tablet' ) ?? '', '' ),
1175 + 'badgeOffsetMobile' => self::cssLength( $box( 'badgeOffset', 'mobile' ) ?? '', '' ),
1176 +
1177 + /* Style — navigation */
1178 + 'filterColor' => self::sanitizeColor( $attributes['filterColor'] ?? '', '#070127' ),
1179 + 'filterBG' => self::background( $attributes['filterBG'] ?? '', '#f1f5f9' ),
1180 + 'filterActiveColor' => self::sanitizeColor( $attributes['filterActiveColor'] ?? '', '#ffffff' ),
1181 + 'filterActiveBG' => self::background( $attributes['filterActiveBG'] ?? '', '#146EF5' ),
1182 + 'filterTypo' => self::typo( $attributes['filterTypo'] ?? [] ),
1183 + 'filterTransition' => self::clampFloat( $attributes['filterTransition'] ?? 0.2, 0, 5, 0.2 ),
1184 + 'filterBorder' => self::borderBox( $attributes['filterBorder'] ?? null ),
1185 + 'filterHovBorder' => self::borderBox( $attributes['filterHovBorder'] ?? null ),
1186 + 'filterPaddingDesktop' => self::boxCSS( $box( 'filterPadding', 'desktop' ), '6px 14px 6px 14px' ),
1187 + 'filterPaddingTablet' => self::boxCSS( $box( 'filterPadding', 'tablet' ), '' ),
1188 + 'filterPaddingMobile' => self::boxCSS( $box( 'filterPadding', 'mobile' ), '' ),
1189 + 'filterRadiusDesktop' => self::boxCSS( $box( 'filterRadius', 'desktop' ), '4px' ),
1190 + 'filterRadiusTablet' => self::boxCSS( $box( 'filterRadius', 'tablet' ), '' ),
1191 + 'filterRadiusMobile' => self::boxCSS( $box( 'filterRadius', 'mobile' ), '' ),
1192 + 'filterHovPaddingDesktop' => self::boxCSS( $box( 'filterHovPadding', 'desktop' ), '' ),
1193 + 'filterHovPaddingTablet' => self::boxCSS( $box( 'filterHovPadding', 'tablet' ), '' ),
1194 + 'filterHovPaddingMobile' => self::boxCSS( $box( 'filterHovPadding', 'mobile' ), '' ),
1195 + 'filterHovRadiusDesktop' => self::boxCSS( $box( 'filterHovRadius', 'desktop' ), '' ),
1196 + 'filterHovRadiusTablet' => self::boxCSS( $box( 'filterHovRadius', 'tablet' ), '' ),
1197 + 'filterHovRadiusMobile' => self::boxCSS( $box( 'filterHovRadius', 'mobile' ), '' ),
1198 + 'pagerColor' => self::sanitizeColor( $attributes['pagerColor'] ?? '', '#ffffff' ),
1199 + 'pagerBG' => self::background( $attributes['pagerBG'] ?? '', '#146EF5' ),
1200 + 'pagerHovColor' => self::sanitizeColor( $attributes['pagerHovColor'] ?? '', '#ffffff' ),
1201 + 'pagerHovBG' => self::background( $attributes['pagerHovBG'] ?? '', '#070127' ),
1202 + 'pagerActiveColor' => self::sanitizeColor( $attributes['pagerActiveColor'] ?? '', '#ffffff' ),
1203 + 'pagerActiveBG' => self::background( $attributes['pagerActiveBG'] ?? '', '#070127' ),
1204 + 'pagerTypo' => self::typo( $attributes['pagerTypo'] ?? [] ),
1205 + 'pagerTransition' => self::clampFloat( $attributes['pagerTransition'] ?? 0.2, 0, 5, 0.2 ),
1206 + 'pagerBorder' => self::borderBox( $attributes['pagerBorder'] ?? null ),
1207 + 'pagerHovBorder' => self::borderBox( $attributes['pagerHovBorder'] ?? null ),
1208 + 'pagerActiveBorder' => self::borderBox( $attributes['pagerActiveBorder'] ?? null ),
1209 + 'pagerPaddingDesktop' => self::boxCSS( $box( 'pagerPadding', 'desktop' ), '8px 18px 8px 18px' ),
1210 + 'pagerPaddingTablet' => self::boxCSS( $box( 'pagerPadding', 'tablet' ), '' ),
1211 + 'pagerPaddingMobile' => self::boxCSS( $box( 'pagerPadding', 'mobile' ), '' ),
1212 + 'pagerRadiusDesktop' => self::boxCSS( $box( 'pagerRadius', 'desktop' ), '4px' ),
1213 + 'pagerRadiusTablet' => self::boxCSS( $box( 'pagerRadius', 'tablet' ), '' ),
1214 + 'pagerRadiusMobile' => self::boxCSS( $box( 'pagerRadius', 'mobile' ), '' ),
1215 + 'pagerHovPaddingDesktop' => self::boxCSS( $box( 'pagerHovPadding', 'desktop' ), '' ),
1216 + 'pagerHovPaddingTablet' => self::boxCSS( $box( 'pagerHovPadding', 'tablet' ), '' ),
1217 + 'pagerHovPaddingMobile' => self::boxCSS( $box( 'pagerHovPadding', 'mobile' ), '' ),
1218 + 'pagerHovRadiusDesktop' => self::boxCSS( $box( 'pagerHovRadius', 'desktop' ), '' ),
1219 + 'pagerHovRadiusTablet' => self::boxCSS( $box( 'pagerHovRadius', 'tablet' ), '' ),
1220 + 'pagerHovRadiusMobile' => self::boxCSS( $box( 'pagerHovRadius', 'mobile' ), '' ),
1221 + 'pagerActivePaddingDesktop' => self::boxCSS( $box( 'pagerActivePadding', 'desktop' ), '' ),
1222 + 'pagerActivePaddingTablet' => self::boxCSS( $box( 'pagerActivePadding', 'tablet' ), '' ),
1223 + 'pagerActivePaddingMobile' => self::boxCSS( $box( 'pagerActivePadding', 'mobile' ), '' ),
1224 + 'pagerActiveRadiusDesktop' => self::boxCSS( $box( 'pagerActiveRadius', 'desktop' ), '' ),
1225 + 'pagerActiveRadiusTablet' => self::boxCSS( $box( 'pagerActiveRadius', 'tablet' ), '' ),
1226 + 'pagerActiveRadiusMobile' => self::boxCSS( $box( 'pagerActiveRadius', 'mobile' ), '' ),
1227 +
1228 + 'noProductsMessage' => self::text( $attributes['noProductsMessage'] ?? '', __( 'No products found.', 'b-blocks' ) ),
215 1229 ];
216 1230 }
217 1231
1232 + /**
1233 + * The card element order, repaired against the current element list so an
1234 + * order saved by an older version still renders every element exactly once.
1235 + *
1236 + * @param mixed $raw Stored order.
1237 + * @return string[]
1238 + */
1239 + public static function contentOrder( $raw ) {
1240 + $raw = is_array( $raw ) ? $raw : [];
1241 +
1242 + $kept = [];
1243 + foreach ( $raw as $key ) {
1244 + if ( is_string( $key ) && in_array( $key, self::ELEMENTS, true ) && ! in_array( $key, $kept, true ) ) {
1245 + $kept[] = $key;
1246 + }
1247 + }
1248 +
1249 + return array_merge( $kept, array_values( array_diff( self::ELEMENTS, $kept ) ) );
1250 + }
1251 +
1252 + /**
1253 + * Accept only a taxonomy actually attached to the product post type.
1254 + *
1255 + * @param mixed $taxonomy Raw slug.
1256 + * @return string
1257 + */
1258 + public static function taxonomy( $taxonomy ) {
1259 + $taxonomy = is_string( $taxonomy ) ? sanitize_key( $taxonomy ) : '';
1260 +
1261 + if ( '' === $taxonomy || ! taxonomy_exists( $taxonomy ) ) {
1262 + return 'product_cat';
1263 + }
1264 +
1265 + return in_array( $taxonomy, get_object_taxonomies( 'product' ), true ) ? $taxonomy : 'product_cat';
1266 + }
1267 +
218 1268 /* ----------------------------------------------------------------------
219 1269 * Query
220 1270 * ------------------------------------------------------------------- */
221 1271
222 1272 /**
223 - * Build sanitized wc_get_products() args.
1273 + * Run the product query for one page.
224 1274 *
225 - * @param array $a Resolved attributes.
1275 + * Both the initial render and the AJAX swap come through here, so a filtered
1276 + * page can never drift from what the server rendered first.
1277 + *
1278 + * @param array $a Resolved attributes.
1279 + * @param int $termId Active taxonomy-filter term, 0 for "All".
1280 + * @param int $page 1-based page number.
1281 + * @return array { query: WP_Query, maxPages: int, total: int }
1282 + */
1283 + public static function query( array $a, $termId = 0, $page = 1 ) {
1284 + $page = max( 1, (int) $page );
1285 + $termId = absint( $termId );
1286 + $perPage = $a['productsPerPage'];
1287 +
1288 + if ( 'related' === $a['queryType'] ) {
1289 + return self::relatedQuery( $a, $termId, $page, $perPage );
1290 + }
1291 +
1292 + $args = self::buildQueryArgs( $a, $termId, $page );
1293 + $query = new WP_Query( $args );
1294 +
1295 + $total = max( 0, (int) $query->found_posts - $a['offset'] );
1296 + $maxPages = (int) ceil( $total / $perPage );
1297 +
1298 + return [
1299 + 'query' => $query,
1300 + 'maxPages' => $maxPages,
1301 + 'total' => $total,
1302 + ];
1303 + }
1304 +
1305 + /**
1306 + * Build sanitized WP_Query args for the custom-query mode.
1307 + *
1308 + * @param array $a Resolved attributes.
1309 + * @param int $termId Active taxonomy-filter term.
1310 + * @param int $page 1-based page number.
226 1311 * @return array
227 1312 */
228 - public static function buildQueryArgs( array $a ) {
229 - // Map our orderby to WooCommerce-recognized values.
230 - $orderByMap = [
231 - 'date' => 'date',
232 - 'price' => 'price',
233 - 'rating' => 'rating',
234 - 'popularity' => 'popularity',
235 - 'rand' => 'rand',
236 - 'title' => 'title',
1313 + public static function buildQueryArgs( array $a, $termId = 0, $page = 1 ) {
1314 + $page = max( 1, (int) $page );
1315 + $perPage = $a['productsPerPage'];
1316 +
1317 + $args = [
1318 + 'post_type' => 'product',
1319 + 'post_status' => 'publish',
1320 + 'posts_per_page' => $perPage,
1321 + 'ignore_sticky_posts' => true,
237 1322 ];
238 1323
239 - $args = [
240 - 'status' => 'publish',
241 - 'limit' => $a['productsPerPage'],
242 - 'orderby' => $orderByMap[ $a['orderBy'] ] ?? 'date',
243 - 'order' => $a['order'],
244 - 'paginate' => false,
245 - 'return' => 'objects',
1324 + // WP_Query honours `offset` *or* `paged`, never both, so an author-set
1325 + // offset means paging has to be worked out by hand.
1326 + if ( $a['offset'] > 0 ) {
1327 + $args['offset'] = $a['offset'] + ( ( $page - 1 ) * $perPage );
1328 + } else {
1329 + $args['paged'] = $page;
1330 + }
1331 +
1332 + /* Visibility — mirror WooCommerce's own catalog loops. */
1333 + $hidden = [ 'exclude-from-catalog' ];
1334 + if ( 'yes' === get_option( 'woocommerce_hide_out_of_stock_items' ) ) {
1335 + $hidden[] = 'outofstock';
1336 + }
1337 +
1338 + $taxQuery = [
1339 + 'relation' => 'AND',
1340 + [
1341 + 'taxonomy' => 'product_visibility',
1342 + 'field' => 'name',
1343 + 'terms' => $hidden,
1344 + 'operator' => 'NOT IN',
1345 + ],
246 1346 ];
247 1347
1348 + if ( $a['featuredOnly'] ) {
1349 + $taxQuery[] = [
1350 + 'taxonomy' => 'product_visibility',
1351 + 'field' => 'name',
1352 + 'terms' => [ 'featured' ],
1353 + 'operator' => 'IN',
1354 + ];
1355 + }
1356 +
248 1357 if ( ! empty( $a['productCategories'] ) ) {
249 - $args['category'] = self::termIdsToSlugs( $a['productCategories'], 'product_cat' );
1358 + $taxQuery[] = [
1359 + 'taxonomy' => 'product_cat',
1360 + 'field' => 'term_id',
1361 + 'terms' => $a['productCategories'],
1362 + 'operator' => 'exclude' === $a['categoryMode'] ? 'NOT IN' : 'IN',
1363 + ];
250 1364 }
251 1365
252 1366 if ( ! empty( $a['productTags'] ) ) {
253 - $args['tag'] = self::termIdsToSlugs( $a['productTags'], 'product_tag' );
1367 + $taxQuery[] = [
1368 + 'taxonomy' => 'product_tag',
1369 + 'field' => 'term_id',
1370 + 'terms' => $a['productTags'],
1371 + 'operator' => 'exclude' === $a['tagMode'] ? 'NOT IN' : 'IN',
1372 + ];
254 1373 }
255 1374
256 - if ( $a['featuredOnly'] ) {
257 - $args['featured'] = true;
1375 + if ( $termId > 0 ) {
1376 + $taxQuery[] = [
1377 + 'taxonomy' => $a['filterTaxonomy'],
1378 + 'field' => 'term_id',
1379 + 'terms' => [ $termId ],
1380 + 'operator' => 'IN',
1381 + ];
258 1382 }
259 1383
1384 + $args['tax_query'] = $taxQuery; // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_tax_query -- catalog visibility requires it, as in WooCommerce core.
1385 +
1386 + /* Include / exclude. */
1387 + $include = $a['includeProducts'];
1388 +
260 1389 if ( $a['onSaleOnly'] && function_exists( 'wc_get_product_ids_on_sale' ) ) {
261 - $onSale = wc_get_product_ids_on_sale();
262 - // Empty include with on-sale-only means no products; use a sentinel.
263 - $args['include'] = ! empty( $onSale ) ? $onSale : [ 0 ];
1390 + $onSale = array_map( 'absint', (array) wc_get_product_ids_on_sale() );
1391 + $include = ! empty( $include ) ? array_values( array_intersect( $include, $onSale ) ) : $onSale;
1392 +
1393 + // An empty post__in is ignored by WP_Query, which would silently
1394 + // widen the query to everything — 0 keeps it correctly empty.
1395 + if ( empty( $include ) ) {
1396 + $include = [ 0 ];
1397 + }
264 1398 }
265 1399
266 - return $args;
1400 + if ( ! empty( $include ) ) {
1401 + $args['post__in'] = $include;
1402 + }
1403 +
1404 + if ( ! empty( $a['excludeProducts'] ) ) {
1405 + $args['post__not_in'] = $a['excludeProducts'];
1406 + }
1407 +
1408 + /* Ordering. */
1409 + switch ( $a['orderBy'] ) {
1410 + case 'title':
1411 + $args['orderby'] = 'title';
1412 + break;
1413 + case 'menu_order':
1414 + $args['orderby'] = 'menu_order title';
1415 + break;
1416 + case 'rand':
1417 + $args['orderby'] = 'rand';
1418 + break;
1419 + case 'id':
1420 + $args['orderby'] = 'ID';
1421 + break;
1422 + case 'price':
1423 + $args['orderby'] = 'meta_value_num';
1424 + $args['meta_key'] = '_price'; // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- same ordering WooCommerce uses for price.
1425 + break;
1426 + case 'popularity':
1427 + $args['orderby'] = 'meta_value_num';
1428 + $args['meta_key'] = 'total_sales'; // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- same ordering WooCommerce uses for popularity.
1429 + break;
1430 + case 'rating':
1431 + $args['orderby'] = 'meta_value_num';
1432 + $args['meta_key'] = '_wc_average_rating'; // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key -- same ordering WooCommerce uses for rating.
1433 + break;
1434 + default:
1435 + $args['orderby'] = 'date';
1436 + }
1437 +
1438 + $args['order'] = $a['order'];
1439 +
1440 + /**
1441 + * Filter the product-grid query args.
1442 + *
1443 + * @param array $args WP_Query args.
1444 + * @param array $a Resolved block attributes.
1445 + */
1446 + return apply_filters( 'b_blocks_woo_product_grid_query', $args, $a );
267 1447 }
268 1448
269 1449 /**
270 - * Convert term IDs to slugs for a taxonomy (wc_get_products expects slugs).
1450 + * Related-products mode: resolve WooCommerce's related IDs once, then page
1451 + * through the list in PHP because the set is already fully materialized.
271 1452 *
272 - * @param int[] $ids Term IDs.
273 - * @param string $taxonomy Taxonomy.
274 - * @return string[]
1453 + * @param array $a Resolved attributes.
1454 + * @param int $termId Active taxonomy-filter term.
1455 + * @param int $page 1-based page number.
1456 + * @param int $perPage Page size.
1457 + * @return array { query: WP_Query, maxPages: int, total: int }
275 1458 */
276 - protected static function termIdsToSlugs( array $ids, $taxonomy ) {
277 - $slugs = [];
278 - foreach ( $ids as $id ) {
279 - $term = get_term( (int) $id, $taxonomy );
280 - if ( $term && ! is_wp_error( $term ) ) {
281 - $slugs[] = $term->slug;
1459 + protected static function relatedQuery( array $a, $termId, $page, $perPage ) {
1460 + // Off a single product page there is nothing to be related to. The
1461 + // inspector tells the author this falls back to the newest products, so
1462 + // it has to actually do that rather than render an empty grid.
1463 + if ( ! $a['currentProductId'] ) {
1464 + $args = self::buildQueryArgs( array_merge( $a, [ 'queryType' => 'custom' ] ), $termId, $page );
1465 + $query = new WP_Query( $args );
1466 + $total = max( 0, (int) $query->found_posts - $a['offset'] );
1467 +
1468 + return [
1469 + 'query' => $query,
1470 + 'maxPages' => (int) ceil( $total / $perPage ),
1471 + 'total' => $total,
1472 + ];
1473 + }
1474 +
1475 + $ids = self::relatedIds( $a );
1476 +
1477 + if ( $termId > 0 ) {
1478 + $filtered = [];
1479 + foreach ( $ids as $id ) {
1480 + if ( has_term( $termId, $a['filterTaxonomy'], $id ) ) {
1481 + $filtered[] = $id;
1482 + }
282 1483 }
1484 + $ids = $filtered;
283 1485 }
284 - return $slugs;
1486 +
1487 + $total = count( $ids );
1488 + $maxPages = (int) ceil( $total / $perPage );
1489 + $slice = array_slice( $ids, ( $page - 1 ) * $perPage, $perPage );
1490 +
1491 + $query = new WP_Query(
1492 + [
1493 + 'post_type' => 'product',
1494 + 'post_status' => 'publish',
1495 + 'posts_per_page' => $perPage,
1496 + 'post__in' => ! empty( $slice ) ? $slice : [ 0 ],
1497 + 'orderby' => 'post__in',
1498 + 'ignore_sticky_posts' => true,
1499 + 'no_found_rows' => true,
1500 + ]
1501 + );
1502 +
1503 + return [
1504 + 'query' => $query,
1505 + 'maxPages' => $maxPages,
1506 + 'total' => $total,
1507 + ];
285 1508 }
286 1509
1510 + /**
1511 + * WooCommerce's related-product IDs for the product being viewed.
1512 + *
1513 + * Returns an empty list anywhere there is no current product — the caller
1514 + * then renders the empty-state message rather than an unrelated grid.
1515 + *
1516 + * @param array $a Resolved attributes.
1517 + * @return int[]
1518 + */
1519 + public static function relatedIds( array $a ) {
1520 + $productId = $a['currentProductId'];
1521 +
1522 + if ( ! $productId || ! function_exists( 'wc_get_related_products' ) ) {
1523 + return [];
1524 + }
1525 +
1526 + // Fetch several pages' worth up front so Load More has somewhere to go.
1527 + $limit = (int) min( 100, max( $a['productsPerPage'] * 5, $a['productsPerPage'] ) );
1528 + $exclude = array_merge( [ $productId ], $a['excludeProducts'] );
1529 +
1530 + return array_map( 'absint', (array) wc_get_related_products( $productId, $limit, $exclude ) );
1531 + }
1532 +
287 1533 /* ----------------------------------------------------------------------
288 - * Card rendering
1534 + * Data
289 1535 * ------------------------------------------------------------------- */
290 1536
291 1537 /**
292 - * Render the product cards as an escaped HTML fragment.
1538 + * Allowlist for the two HTML fragments WooCommerce itself produces.
293 1539 *
294 - * @param \WC_Product[] $products Products.
295 - * @param array $a Resolved attributes.
296 - * @return string Escaped HTML.
1540 + * wp_kses_post() drops <bdi>, which WooCommerce wraps every formatted amount
1541 + * in, so the post set is extended rather than replaced.
1542 + *
1543 + * @return array
297 1544 */
298 - public static function renderCards( array $products, array $a ) {
299 - ob_start();
1545 + public static function allowedTags() {
1546 + static $tags = null;
300 1547
301 - foreach ( $products as $product ) :
302 - if ( ! is_a( $product, 'WC_Product' ) ) {
1548 + if ( null === $tags ) {
1549 + $tags = wp_kses_allowed_html( 'post' );
1550 + $tags['bdi'] = [ 'class' => true ];
1551 + }
1552 +
1553 + return $tags;
1554 + }
1555 +
1556 + /**
1557 + * The WordPress image size to request for a card.
1558 + *
1559 + * @param array $a Resolved attributes.
1560 + * @return string
1561 + */
1562 + protected static function imageSize( array $a ) {
1563 + if ( 'default' !== $a['imageSize'] ) {
1564 + return $a['imageSize'];
1565 + }
1566 +
1567 + return has_image_size( 'woocommerce_thumbnail' ) ? 'woocommerce_thumbnail' : 'medium';
1568 + }
1569 +
1570 + /**
1571 + * Cart button label for a product type.
1572 + *
1573 + * @param \WC_Product $product Product.
1574 + * @param array $a Resolved attributes.
1575 + * @return string
1576 + */
1577 + protected static function cartLabel( $product, array $a ) {
1578 + if ( ! $a['useCustomCartText'] ) {
1579 + return self::text( $product->add_to_cart_text(), __( 'Read more', 'b-blocks' ) );
1580 + }
1581 +
1582 + switch ( $product->get_type() ) {
1583 + case 'simple':
1584 + return $a['cartTextSimple'];
1585 + case 'variable':
1586 + return $a['cartTextVariable'];
1587 + case 'grouped':
1588 + return $a['cartTextGrouped'];
1589 + case 'external':
1590 + return $a['cartTextExternal'];
1591 + default:
1592 + return $a['cartTextDefault'];
1593 + }
1594 + }
1595 +
1596 + /**
1597 + * What a product on sale saves, for the badge's Amount Saved / Percent Off.
1598 + *
1599 + * Prices go through wc_get_price_to_display(), so the saving matches the
1600 + * prices the card shows whichever way the store displays tax. A variable
1601 + * product reports its best saving across variations, flagged as a range when
1602 + * they differ, so the badge can say "up to". Anything without both a regular
1603 + * and a lower sale price — grouped, external with no sale — saves nothing,
1604 + * and the badge falls back to its own text.
1605 + *
1606 + * @param \WC_Product $product Product.
1607 + * @return array { amount: float, percent: int, isRange: bool }
1608 + */
1609 + protected static function savings( $product ) {
1610 + $none = [
1611 + 'amount' => 0.0,
1612 + 'percent' => 0,
1613 + 'isRange' => false,
1614 + ];
1615 +
1616 + if ( ! $product->is_on_sale() ) {
1617 + return $none;
1618 + }
1619 +
1620 + $pairs = [];
1621 +
1622 + if ( $product->is_type( 'variable' ) ) {
1623 + $prices = $product->get_variation_prices( true );
1624 +
1625 + foreach ( (array) ( $prices['regular_price'] ?? [] ) as $variationId => $regular ) {
1626 + $pairs[] = [ (float) $regular, (float) ( $prices['sale_price'][ $variationId ] ?? $regular ) ];
1627 + }
1628 + } elseif ( '' !== $product->get_regular_price() && '' !== $product->get_sale_price() ) {
1629 + $pairs[] = [
1630 + (float) wc_get_price_to_display( $product, [ 'price' => $product->get_regular_price() ] ),
1631 + (float) wc_get_price_to_display( $product, [ 'price' => $product->get_sale_price() ] ),
1632 + ];
1633 + }
1634 +
1635 + $decimals = wc_get_price_decimals();
1636 + $amounts = [];
1637 + $percent = 0;
1638 +
1639 + foreach ( $pairs as list( $regular, $sale ) ) {
1640 + if ( $regular <= 0 || $sale >= $regular ) {
303 1641 continue;
304 1642 }
305 1643
306 - $productId = $product->get_id();
307 - $titleText = $product->get_name();
308 - $permalink = get_permalink( $productId );
309 - $isOnSale = $product->is_on_sale();
310 - $isSimple = $product->is_type( 'simple' );
311 - $canAjax = $isSimple && $product->is_purchasable() && $product->is_in_stock();
1644 + $amounts[] = round( $regular - $sale, $decimals );
1645 + $percent = max( $percent, (int) round( ( ( $regular - $sale ) / $regular ) * 100 ) );
1646 + }
312 1647
313 - // Image.
314 - $imageUrl = '';
315 - $imageAlt = $titleText;
316 - if ( $a['showImage'] ) {
317 - $thumbId = $product->get_image_id();
318 - if ( $thumbId ) {
319 - $src = wp_get_attachment_image_url( $thumbId, 'woocommerce_thumbnail' );
320 - if ( $src ) {
321 - $imageUrl = $src;
322 - $metaAlt = get_post_meta( $thumbId, '_wp_attachment_image_alt', true );
323 - if ( is_string( $metaAlt ) && '' !== trim( $metaAlt ) ) {
324 - $imageAlt = trim( wp_strip_all_tags( $metaAlt ) );
325 - }
1648 + if ( ! $amounts ) {
1649 + return $none;
1650 + }
1651 +
1652 + return [
1653 + 'amount' => max( $amounts ),
1654 + 'percent' => $percent,
1655 + // Variations that are not on sale save nothing, which is also a
1656 + // difference worth the "up to".
1657 + 'isRange' => count( array_unique( $amounts ) ) > 1 || count( $amounts ) < count( $pairs ),
1658 + ];
1659 + }
1660 +
1661 + /**
1662 + * The two computed badge labels, already translated and formatted.
1663 + *
1664 + * The amount is plain text: wc_price() wraps it in markup, and the badge
1665 + * renders a string. Whole amounts drop their decimals — "Save $60", not
1666 + * "Save $60.00" — which is how a saving is normally written.
1667 + *
1668 + * @param array $saving From savings().
1669 + * @return array { saveLabel: string, percentLabel: string }
1670 + */
1671 + protected static function savingLabels( array $saving ) {
1672 + if ( $saving['amount'] <= 0 ) {
1673 + return [
1674 + 'saveLabel' => '',
1675 + 'percentLabel' => '',
1676 + ];
1677 + }
1678 +
1679 + $isWhole = abs( $saving['amount'] - round( $saving['amount'] ) ) < 0.005;
1680 + $money = html_entity_decode(
1681 + wp_strip_all_tags( wc_price( $saving['amount'], [ 'decimals' => $isWhole ? 0 : wc_get_price_decimals() ] ) ),
1682 + ENT_QUOTES,
1683 + 'UTF-8'
1684 + );
1685 +
1686 + // wc_price() separates symbol and number with a no-break space in some
1687 + // locales; a normal one reads the same and survives JSON cleanly.
1688 + $money = trim( str_replace( "\xC2\xA0", ' ', $money ) );
1689 +
1690 + return [
1691 + 'saveLabel' => $saving['isRange']
1692 + /* translators: %s: largest amount saved, with currency. */
1693 + ? sprintf( __( 'Save up to %s', 'b-blocks' ), $money )
1694 + /* translators: %s: amount saved, with currency. */
1695 + : sprintf( __( 'Save %s', 'b-blocks' ), $money ),
1696 + 'percentLabel' => $saving['isRange']
1697 + /* translators: %d: largest percentage off. */
1698 + ? sprintf( __( 'Up to -%d%%', 'b-blocks' ), $saving['percent'] )
1699 + /* translators: %d: percentage off. */
1700 + : sprintf( __( '-%d%%', 'b-blocks' ), $saving['percent'] ),
1701 + ];
1702 + }
1703 +
1704 + /**
1705 + * Flatten one product into everything the React card needs.
1706 + *
1707 + * Anything that has to be locale-formatted or capability-checked is resolved
1708 + * here rather than in JS: the sold label goes through number_format_i18n(),
1709 + * and the two HTML fragments are filtered before they leave the server.
1710 + *
1711 + * @param \WC_Product $product Product.
1712 + * @param array $a Resolved attributes.
1713 + * @param string $size Image size.
1714 + * @return array
1715 + */
1716 + protected static function productData( $product, array $a, $size ) {
1717 + $id = (int) $product->get_id();
1718 + $title = $product->get_name();
1719 +
1720 + /* Image, with the responsive sources WordPress already knows about. */
1721 + $image = [
1722 + 'url' => '',
1723 + 'alt' => $title,
1724 + 'srcset' => '',
1725 + 'sizes' => '',
1726 + 'width' => 0,
1727 + 'height' => 0,
1728 + ];
1729 +
1730 + $imageId = $product->get_image_id();
1731 + if ( $imageId ) {
1732 + $src = wp_get_attachment_image_src( $imageId, $size );
1733 +
1734 + if ( $src ) {
1735 + $alt = get_post_meta( $imageId, '_wp_attachment_image_alt', true );
1736 +
1737 + $image = [
1738 + 'url' => $src[0],
1739 + 'width' => (int) $src[1],
1740 + 'height' => (int) $src[2],
1741 + 'srcset' => (string) wp_get_attachment_image_srcset( $imageId, $size ),
1742 + 'sizes' => (string) wp_get_attachment_image_sizes( $imageId, $size ),
1743 + 'alt' => ( is_string( $alt ) && '' !== trim( $alt ) ) ? trim( wp_strip_all_tags( $alt ) ) : $title,
1744 + ];
1745 + }
1746 + }
1747 +
1748 + if ( '' === $image['url'] && function_exists( 'wc_placeholder_img_src' ) ) {
1749 + $image['url'] = wc_placeholder_img_src( $size );
1750 + }
1751 +
1752 + /* Sold count: sold / (sold + remaining stock), or the author's stand-in
1753 + percentage when the product does not manage stock at all. */
1754 + $sold = max( 0, (int) $product->get_total_sales() );
1755 + $stock = $product->get_stock_quantity();
1756 +
1757 + if ( $product->managing_stock() && null !== $stock ) {
1758 + $denominator = $sold + max( 0, (int) $stock );
1759 + $percent = $denominator > 0 ? (int) round( ( $sold / $denominator ) * 100 ) : 0;
1760 + } else {
1761 + $percent = $a['withoutStockValue'];
1762 + }
1763 +
1764 + $categories = function_exists( 'wc_get_product_category_list' )
1765 + ? wc_get_product_category_list( $id, ', ' )
1766 + : get_the_term_list( $id, 'product_cat', '', ', ' );
1767 +
1768 + if ( is_wp_error( $categories ) || ! $categories ) {
1769 + $categories = '';
1770 + }
1771 +
1772 + // React renders this as an href and does not vet the scheme itself.
1773 + // WooCommerce rejects a javascript: product URL on save, but
1774 + // `woocommerce_product_add_to_cart_url` is filterable, so the protocol
1775 + // allowlist is applied here too — as it already is for image URLs.
1776 + $cartUrl = esc_url_raw( $product->add_to_cart_url() );
1777 +
1778 + $saving = self::savingLabels( self::savings( $product ) );
1779 +
1780 + return [
1781 + 'id' => $id,
1782 + 'title' => $title,
1783 + 'link' => esc_url_raw( get_permalink( $id ) ),
1784 + 'type' => $product->get_type(),
1785 + 'onSale' => (bool) $product->is_on_sale(),
1786 + 'saveLabel' => $saving['saveLabel'],
1787 + 'percentLabel' => $saving['percentLabel'],
1788 + 'image' => $image,
1789 + 'priceHtml' => wp_kses( (string) $product->get_price_html(), self::allowedTags() ),
1790 + 'rating' => round( (float) $product->get_average_rating(), 2 ),
1791 + 'ratingCount' => (int) $product->get_rating_count(),
1792 +
1793 + // Screen-reader text is built here rather than in JS: PHP already has
1794 + // the translations loaded, which keeps @wordpress/i18n out of the
1795 + // frontend bundle entirely.
1796 + // A product nobody has reviewed is not rated zero — it is not rated
1797 + // yet, and saying "0.0 out of 5" to a screen reader would be wrong.
1798 + 'ratingLabel' => $product->get_rating_count() > 0
1799 + ? sprintf(
1800 + /* translators: %s: rating out of 5. */
1801 + __( 'Rated %s out of 5', 'b-blocks' ),
1802 + number_format_i18n( round( (float) $product->get_average_rating(), 1 ), 1 )
1803 + )
1804 + : __( 'Not yet rated', 'b-blocks' ),
1805 + 'categoriesHtml' => wp_kses( $categories, self::allowedTags() ),
1806 + 'sold' => $sold,
1807 + 'soldLabel' => trim( $a['soldPrefix'] . ' ' . number_format_i18n( $sold ) . $a['soldSuffix'] ),
1808 + 'soldPercent' => (int) min( 100, max( 0, $percent ) ),
1809 + 'soldBarLabel' => sprintf(
1810 + /* translators: %d: percentage of stock sold. */
1811 + __( '%d%% sold', 'b-blocks' ),
1812 + (int) min( 100, max( 0, $percent ) )
1813 + ),
1814 + 'cartText' => self::cartLabel( $product, $a ),
1815 + /* translators: %1$s: button text, %2$s: product name. */
1816 + 'cartAria' => sprintf( __( '%1$s: %2$s', 'b-blocks' ), self::cartLabel( $product, $a ), $title ),
1817 + /* translators: %1$s: button text, %2$s: product name. */
1818 + 'viewAria' => sprintf( __( '%1$s: %2$s', 'b-blocks' ), $a['viewButtonText'], $title ),
1819 +
1820 + // WooCommerce's add-to-cart script only enhances a link that carries
1821 + // these, and only for products it can add in one step; everything
1822 + // else falls through to the product page as an ordinary link.
1823 + 'cartAjax' => $product->supports( 'ajax_add_to_cart' ) && $product->is_purchasable() && $product->is_in_stock(),
1824 + 'cartUrl' => $cartUrl ? $cartUrl : esc_url_raw( get_permalink( $id ) ),
1825 + 'cartClass' => 'product_type_' . $product->get_type(),
1826 + 'sku' => (string) $product->get_sku(),
1827 + 'external' => $product->is_type( 'external' ),
1828 + ];
1829 + }
1830 +
1831 + /**
1832 + * One page of products, as a JSON-serializable payload.
1833 + *
1834 + * render.php embeds the first page of this and the AJAX endpoint serves
1835 + * every page after it, so the editor, the initial paint and a filtered swap
1836 + * all render from an identical structure.
1837 + *
1838 + * @param array $a Resolved attributes.
1839 + * @param int $termId Active taxonomy-filter term, 0 for "All".
1840 + * @param int $page 1-based page number.
1841 + * @return array
1842 + */
1843 + public static function getProducts( array $a, $termId = 0, $page = 1 ) {
1844 + $page = max( 1, (int) $page );
1845 +
1846 + if ( ! function_exists( 'wc_get_product' ) ) {
1847 + return [
1848 + 'products' => [],
1849 + 'maxPages' => 0,
1850 + 'total' => 0,
1851 + 'page' => $page,
1852 + ];
1853 + }
1854 +
1855 + $result = self::query( $a, $termId, $page );
1856 + $size = self::imageSize( $a );
1857 +
1858 + $products = [];
1859 + foreach ( $result['query']->posts as $post ) {
1860 + $product = wc_get_product( $post );
1861 +
1862 + if ( ! $product || ! is_a( $product, 'WC_Product' ) ) {
1863 + continue;
1864 + }
1865 +
1866 + $products[] = self::productData( $product, $a, $size );
1867 + }
1868 +
1869 + return [
1870 + 'products' => $products,
1871 + 'maxPages' => (int) $result['maxPages'],
1872 + 'total' => (int) $result['total'],
1873 + 'page' => $page,
1874 + ];
1875 + }
1876 +
1877 + /**
1878 + * Interface strings for the rendered grid.
1879 + *
1880 + * Shipped with the payload so the frontend bundle carries no translation
1881 + * runtime: PHP already has the text domain loaded, and the two that take a
1882 + * count are substituted client-side with a placeholder swap.
1883 + *
1884 + * @return array
1885 + */
1886 + public static function labels() {
1887 + return [
1888 + 'products' => __( 'Products', 'b-blocks' ),
1889 + 'filter' => __( 'Filter products', 'b-blocks' ),
1890 + 'pagination' => __( 'Product pagination', 'b-blocks' ),
1891 + /* translators: %d: page number. */
1892 + 'goToPage' => __( 'Go to page %d', 'b-blocks' ),
1893 + 'prevPage' => __( 'Previous page', 'b-blocks' ),
1894 + 'nextPage' => __( 'Next page', 'b-blocks' ),
1895 + 'prev' => __( 'Prev', 'b-blocks' ),
1896 + 'next' => __( 'Next', 'b-blocks' ),
1897 + /* translators: %d: number of products shown. */
1898 + 'showing' => __( 'Showing %d products.', 'b-blocks' ),
1899 + 'error' => __( 'Products could not be loaded. Please refresh the page and try again.', 'b-blocks' ),
1900 + ];
1901 + }
1902 +
1903 + /**
1904 + * Terms for the filter bar, flattened for JSON.
1905 + *
1906 + * @param array $a Resolved attributes.
1907 + * @return array
1908 + */
1909 + public static function filterTerms( array $a ) {
1910 + if ( ! $a['showTaxonomyFilter'] ) {
1911 + return [];
1912 + }
1913 +
1914 + $args = [
1915 + 'taxonomy' => $a['filterTaxonomy'],
1916 + 'hide_empty' => true,
1917 + 'number' => 50,
1918 + ];
1919 +
1920 + // Offer only terms the grid can actually show. A grid limited to Men
1921 + // used to list Women too, and that button always came back empty.
1922 + $limits = [
1923 + 'product_cat' => [ $a['productCategories'], $a['categoryMode'] ],
1924 + 'product_tag' => [ $a['productTags'], $a['tagMode'] ],
1925 + ];
1926 +
1927 + if ( isset( $limits[ $a['filterTaxonomy'] ] ) ) {
1928 + list( $ids, $mode ) = $limits[ $a['filterTaxonomy'] ];
1929 +
1930 + if ( ! empty( $ids ) && 'exclude' === $mode ) {
1931 + $args['exclude_tree'] = $ids;
1932 + } elseif ( ! empty( $ids ) ) {
1933 + // Children too: products in Men > Hoodies are in the query when
1934 + // Men is, so Hoodies is a filter that returns something.
1935 + foreach ( $ids as $id ) {
1936 + $children = get_term_children( $id, $a['filterTaxonomy'] );
1937 + if ( ! is_wp_error( $children ) ) {
1938 + $ids = array_merge( $ids, $children );
326 1939 }
327 1940 }
328 - if ( '' === $imageUrl && function_exists( 'wc_placeholder_img_src' ) ) {
329 - $imageUrl = wc_placeholder_img_src( 'woocommerce_thumbnail' );
1941 +
1942 + $args['include'] = array_values( array_unique( array_map( 'absint', $ids ) ) );
1943 + }
1944 + }
1945 +
1946 + $terms = get_terms( $args );
1947 +
1948 + if ( is_wp_error( $terms ) ) {
1949 + return [];
1950 + }
1951 +
1952 + // Two terms can share a name under different parents — Men > Hoodies and
1953 + // Women > Hoodies — and two identical buttons cannot be told apart. Those
1954 + // get their parent's name; unique names are left as they are.
1955 + $counts = array_count_values( wp_list_pluck( $terms, 'name' ) );
1956 +
1957 + $items = [];
1958 + foreach ( $terms as $term ) {
1959 + $name = $term->name;
1960 +
1961 + if ( $counts[ $name ] > 1 && $term->parent ) {
1962 + $parent = get_term( $term->parent, $a['filterTaxonomy'] );
1963 +
1964 + if ( $parent && ! is_wp_error( $parent ) ) {
1965 + /* translators: %1$s: term name, %2$s: parent term name. */
1966 + $name = sprintf( __( '%1$s (%2$s)', 'b-blocks' ), $name, $parent->name );
330 1967 }
331 1968 }
332 1969
333 - // Rating.
334 - $ratingValue = (float) $product->get_average_rating();
335 - $ratingCount = (int) $product->get_rating_count();
1970 + $items[] = [
1971 + 'id' => (int) $term->term_id,
1972 + 'name' => $name,
1973 + ];
1974 + }
336 1975
337 - // Add-to-cart label.
338 - $cartLabel = $a['addToCartLabel'];
339 - if ( ! $isSimple ) {
340 - $wcLabel = $product->add_to_cart_text();
341 - if ( is_string( $wcLabel ) && '' !== trim( $wcLabel ) ) {
342 - $cartLabel = wp_strip_all_tags( $wcLabel );
343 - }
1976 + return $items;
1977 + }
1978 +
1979 + /**
1980 + * The attribute set handed to the React tree, in the nested shape.
1981 + *
1982 + * Everything React reads comes straight from `data-attributes`, so anything
1983 + * it turns into an element name, a class name or a CSS declaration must be
1984 + * the value resolveAttributes() already allowlisted or clamped — a raw
1985 + * `titleTag` of "script" would otherwise become a real <script> element, and
1986 + * a raw colour could close the CSS declaration Style.js builds.
1987 + *
1988 + * Rebuilding from the resolved set rather than patching the raw one also
1989 + * means a legacy flat post is handed the current nested shape, so the
1990 + * components never need to know which era a post was saved in.
1991 + *
1992 + * @param array $attributes Raw block attributes.
1993 + * @param array $a Resolved attributes.
1994 + * @return array
1995 + */
1996 + public static function viewAttributes( array $attributes, array $a ) {
1997 + $view = [
1998 + 'align' => $attributes['align'] ?? '',
1999 + 'contentOrder' => $a['contentOrder'],
2000 + 'advanced' => $attributes['advanced'] ?? [],
2001 + ];
2002 +
2003 + foreach ( self::NESTED_MAP as $flat => $path ) {
2004 + // Legacy-only keys have no resolved counterpart and nothing reads them.
2005 + if ( ! array_key_exists( $flat, $a ) ) {
2006 + continue;
344 2007 }
345 - ?>
346 - <article class='bb-wpg-card' role='listitem' aria-label='<?php echo esc_attr( $titleText ); ?>'>
347 - <?php if ( $a['showImage'] && '' !== $imageUrl ) : ?>
348 - <a class='bb-wpg-image-link' href='<?php echo esc_url( $permalink ); ?>' tabindex='-1' aria-hidden='true'>
349 - <?php if ( $a['showSaleBadge'] && $isOnSale ) : ?>
350 - <span class='bb-wpg-sale-badge' role='img' aria-label='<?php echo esc_attr__( 'On sale', 'b-blocks' ); ?>'>
351 - <?php echo esc_html( $a['saleBadgeLabel'] ); ?>
352 - </span>
353 - <?php endif; ?>
354 - <img class='bb-wpg-image' src='<?php echo esc_url( $imageUrl ); ?>' alt='<?php echo esc_attr( $imageAlt ); ?>' loading='lazy' decoding='async' />
355 - </a>
356 - <?php elseif ( $a['showSaleBadge'] && $isOnSale ) : ?>
357 - <span class='bb-wpg-sale-badge bb-wpg-sale-badge--noimg' role='img' aria-label='<?php echo esc_attr__( 'On sale', 'b-blocks' ); ?>'>
358 - <?php echo esc_html( $a['saleBadgeLabel'] ); ?>
359 - </span>
360 - <?php endif; ?>
361 2008
362 - <div class='bb-wpg-card-body'>
363 - <?php if ( $a['showTitle'] && '' !== $titleText ) : ?>
364 - <h3 class='bb-wpg-title'>
365 - <a class='bb-wpg-title-link' href='<?php echo esc_url( $permalink ); ?>'>
366 - <?php echo esc_html( $titleText ); ?>
367 - </a>
368 - </h3>
369 - <?php endif; ?>
2009 + self::setPath( $view, $path, $a[ $flat ] );
2010 + }
370 2011
371 - <?php if ( $a['showRating'] && $ratingCount > 0 ) : ?>
372 - <?php
373 - $roundedRating = round( $ratingValue * 2 ) / 2;
374 - $ratingLabel = sprintf(
375 - /* translators: %s: rating value out of 5. */
376 - __( '%s out of 5 stars', 'b-blocks' ),
377 - number_format_i18n( $ratingValue, 1 )
378 - );
379 - ?>
380 - <span class='bb-wpg-rating' role='img' aria-label='<?php echo esc_attr( $ratingLabel ); ?>'>
381 - <?php
382 - for ( $i = 1; $i <= 5; $i++ ) {
383 - $starClass = 'bb-wpg-star';
384 - if ( $roundedRating >= $i ) {
385 - $starClass .= ' is-full';
386 - } elseif ( $roundedRating >= ( $i - 0.5 ) ) {
387 - $starClass .= ' is-half';
388 - }
389 - echo '<span class="' . esc_attr( $starClass ) . '" aria-hidden="true">★</span>';
390 - }
391 - ?>
392 - </span>
393 - <?php endif; ?>
2012 + // Per-device values come back device-first — container.desktop.prop — the
2013 + // shape the panels read. Boxes are a shorthand per breakpoint in the
2014 + // resolved set, so they are expanded back into the four sides BoxControl
2015 + // edits; plain values go back as they are.
2016 + foreach ( self::DEVICE_MAP as $flat => list( $container, $prop, $kind ) ) {
2017 + foreach ( [ 'Desktop', 'Tablet', 'Mobile' ] as $device ) {
2018 + $value = $a[ $flat . $device ] ?? '';
394 2019
395 - <?php if ( $a['showPrice'] ) : ?>
396 - <div class='bb-wpg-price'>
397 - <?php echo wp_kses_post( $product->get_price_html() ); ?>
398 - </div>
399 - <?php endif; ?>
2020 + self::setPath(
2021 + $view,
2022 + array_merge( $container, [ strtolower( $device ), $prop ] ),
2023 + 'box' === $kind ? self::boxSides( $value ) : $value
2024 + );
2025 + }
2026 + }
400 2027
401 - <?php
402 - if ( $a['showAddToCart'] ) :
403 - $cartAria = sprintf(
404 - /* translators: %s: product name. */
405 - __( 'Add %s to cart', 'b-blocks' ),
406 - $titleText
407 - );
408 - if ( $canAjax ) :
409 - ?>
410 - <button
411 - type='button'
412 - class='bb-wpg-atc-btn'
413 - data-product-id='<?php echo esc_attr( (string) $productId ); ?>'
414 - aria-label='<?php echo esc_attr( $cartAria ); ?>'
415 - >
416 - <span class='bb-wpg-atc-label'><?php echo esc_html( $cartLabel ); ?></span>
417 - <span class='bb-wpg-atc-added' aria-hidden='true'><?php echo esc_html__( 'Added', 'b-blocks' ); ?></span>
418 - </button>
419 - <?php else : ?>
420 - <a
421 - class='bb-wpg-atc-btn bb-wpg-atc-btn--link'
422 - href='<?php echo esc_url( $permalink ); ?>'
423 - aria-label='<?php echo esc_attr( $cartAria ); ?>'
424 - >
425 - <span class='bb-wpg-atc-label'><?php echo esc_html( $cartLabel ); ?></span>
426 - </a>
427 - <?php endif; ?>
428 - <?php endif; ?>
429 - </div>
430 - </article>
431 - <?php
432 - endforeach;
2028 + return $view;
2029 + }
433 2030
434 - return ob_get_clean();
2031 + /* ----------------------------------------------------------------------
2032 + * Server-rendered shell
2033 + * ------------------------------------------------------------------- */
2034 +
2035 + /**
2036 + * Where to send an author whose site has no WooCommerce.
2037 + *
2038 + * Downloaded-but-inactive and not-installed-at-all need different pages, and
2039 + * neither link is worth showing to someone without the capability to act on
2040 + * it — an empty string tells the editor to render the notice as plain text.
2041 + *
2042 + * @return string
2043 + */
2044 + public static function wooInstallUrl() {
2045 + $isDownloaded = defined( 'WP_PLUGIN_DIR' ) && file_exists( WP_PLUGIN_DIR . '/woocommerce/woocommerce.php' );
2046 +
2047 + if ( $isDownloaded && current_user_can( 'activate_plugins' ) ) {
2048 + return admin_url( 'plugins.php?s=woocommerce&plugin_status=all' );
2049 + }
2050 +
2051 + if ( ! $isDownloaded && current_user_can( 'install_plugins' ) ) {
2052 + return admin_url( 'plugin-install.php?tab=search&type=term&s=woocommerce' );
2053 + }
2054 +
2055 + return '';
435 2056 }
436 2057
437 2058 /* ----------------------------------------------------------------------
438 - * AJAX endpoint
2059 + * AJAX endpoints
439 2060 * ------------------------------------------------------------------- */
440 2061
441 2062 /**
442 - * Handle the `bb_wpg_add_to_cart` AJAX request for simple products.
2063 + * Attributes posted by the editor or the frontend, put back through the
2064 + * sanitizer.
443 2065 *
444 - * Returns JSON: { added: true, productName } or an error.
2066 + * `queryAttr` arrives as a JSON string rather than a nested form field on
2067 + * purpose: jQuery flattens nested data, which would turn every boolean into
2068 + * the string "true" or "false" — and "false" is truthy in PHP.
2069 + *
2070 + * @return array
445 2071 */
446 - public function ajaxAddToCart() {
447 - check_ajax_referer( 'bb_wpg_add_to_cart', 'nonce' );
2072 + protected static function postedAttributes() {
2073 + // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- callers verify the nonce; the JSON is decoded then fully sanitized by resolveAttributes().
2074 + $raw = isset( $_POST['queryAttr'] ) ? json_decode( wp_unslash( $_POST['queryAttr'] ), true ) : [];
448 2075
449 - if ( ! function_exists( 'WC' ) || ! WC()->cart ) {
450 - wp_send_json_error( [ 'message' => __( 'WooCommerce is not available.', 'b-blocks' ) ] );
2076 + return self::resolveAttributes( is_array( $raw ) ? $raw : [] );
2077 + }
2078 +
2079 + /**
2080 + * Serve one page of product data for the filter bar, Load More, pagination
2081 + * and the editor preview.
2082 + */
2083 + public function ajaxQuery() {
2084 + check_ajax_referer( 'wp_ajax', '_wpnonce' );
2085 +
2086 + $a = self::postedAttributes();
2087 +
2088 + if ( ! function_exists( 'wc_get_product' ) ) {
2089 + wp_send_json_success(
2090 + [
2091 + 'woo' => false,
2092 + 'installUrl' => self::wooInstallUrl(),
2093 + 'products' => [],
2094 + 'terms' => [],
2095 + 'maxPages' => 0,
2096 + 'total' => 0,
2097 + 'page' => 1,
2098 + ]
2099 + );
451 2100 }
452 2101
453 - $productId = isset( $_POST['product_id'] ) ? absint( wp_unslash( $_POST['product_id'] ) ) : 0;
454 - if ( $productId < 1 ) {
455 - wp_send_json_error( [ 'message' => __( 'Invalid product.', 'b-blocks' ) ] );
2102 + // phpcs:ignore WordPress.Security.NonceVerification.Missing -- verified above.
2103 + $termId = absint( $_POST['termId'] ?? 0 );
2104 + // phpcs:ignore WordPress.Security.NonceVerification.Missing -- verified above.
2105 + $page = max( 1, absint( $_POST['pageNumber'] ?? 1 ) );
2106 +
2107 + $payload = self::getProducts( $a, $termId, $page );
2108 + $payload['woo'] = true;
2109 + $payload['terms'] = self::filterTerms( $a );
2110 + // Echoed so an out-of-order response can be recognised and discarded.
2111 + $payload['termId'] = $termId;
2112 + $payload['labels'] = self::labels();
2113 +
2114 + wp_reset_postdata();
2115 +
2116 + wp_send_json_success( $payload );
2117 + }
2118 +
2119 + /**
2120 + * Editor-only lookups for the async selects and the taxonomy dropdown.
2121 + *
2122 + * Gated on `edit_posts` as well as the nonce: this is the only endpoint that
2123 + * enumerates content, and nothing on the frontend calls it.
2124 + */
2125 + public function ajaxSearch() {
2126 + check_ajax_referer( 'wp_ajax', '_wpnonce' );
2127 +
2128 + if ( ! current_user_can( 'edit_posts' ) ) {
2129 + wp_send_json_error( [ 'message' => __( 'Unauthorized', 'b-blocks' ) ] );
456 2130 }
457 2131
458 - $product = wc_get_product( $productId );
459 - if ( ! $product || ! is_a( $product, 'WC_Product' ) ) {
460 - wp_send_json_error( [ 'message' => __( 'Product not found.', 'b-blocks' ) ] );
2132 + // phpcs:disable WordPress.Security.NonceVerification.Missing -- verified above.
2133 + $type = sanitize_key( $_POST['type'] ?? '' );
2134 + $search = sanitize_text_field( wp_unslash( $_POST['search'] ?? '' ) );
2135 + $include = self::intArray( (array) ( $_POST['include'] ?? [] ) );
2136 + $taxonomy = self::taxonomy( $_POST['taxonomy'] ?? 'product_cat' );
2137 + // phpcs:enable WordPress.Security.NonceVerification.Missing
2138 +
2139 + if ( 'taxonomies' === $type ) {
2140 + $items = [];
2141 +
2142 + foreach ( get_object_taxonomies( 'product', 'objects' ) as $tax ) {
2143 + if ( empty( $tax->public ) || empty( $tax->show_ui ) ) {
2144 + continue;
2145 + }
2146 +
2147 + $items[] = [
2148 + 'id' => $tax->name,
2149 + 'label' => $tax->labels->singular_name ? $tax->labels->singular_name : $tax->name,
2150 + ];
2151 + }
2152 +
2153 + wp_send_json_success( [ 'items' => $items ] );
461 2154 }
462 2155
463 - if ( ! $product->is_type( 'simple' ) || ! $product->is_purchasable() || ! $product->is_in_stock() ) {
464 - wp_send_json_error( [ 'message' => __( 'This product cannot be added to the cart.', 'b-blocks' ) ] );
2156 + if ( 'term' === $type ) {
2157 + $args = [
2158 + 'taxonomy' => $taxonomy,
2159 + 'hide_empty' => false,
2160 + 'number' => 30,
2161 + ];
2162 +
2163 + if ( ! empty( $include ) ) {
2164 + $args['include'] = $include;
2165 + $args['number'] = count( $include );
2166 + } else {
2167 + $args['search'] = $search;
2168 + }
2169 +
2170 + $terms = get_terms( $args );
2171 + $items = [];
2172 +
2173 + if ( ! is_wp_error( $terms ) ) {
2174 + foreach ( $terms as $term ) {
2175 + $items[] = [
2176 + 'id' => (int) $term->term_id,
2177 + 'label' => $term->name,
2178 + ];
2179 + }
2180 + }
2181 +
2182 + wp_send_json_success( [ 'items' => $items ] );
465 2183 }
466 2184
467 - $added = WC()->cart->add_to_cart( $productId, 1 );
2185 + if ( 'product' !== $type ) {
2186 + wp_send_json_error( [ 'message' => __( 'Unsupported search type.', 'b-blocks' ) ] );
2187 + }
468 2188
469 - if ( ! $added ) {
470 - wp_send_json_error( [ 'message' => __( 'Could not add the product to the cart.', 'b-blocks' ) ] );
2189 + // Private and draft titles only for those who may see other people's
2190 + // products anyway. edit_posts alone — a Contributor — would otherwise
2191 + // be able to list them. The grid itself only ever shows published ones.
2192 + $statuses = current_user_can( 'edit_others_products' ) ? [ 'publish', 'private', 'draft' ] : [ 'publish' ];
2193 +
2194 + $args = [
2195 + 'post_type' => 'product',
2196 + 'post_status' => $statuses,
2197 + 'posts_per_page' => 30,
2198 + 'ignore_sticky_posts' => true,
2199 + 'no_found_rows' => true,
2200 + 'orderby' => 'title',
2201 + 'order' => 'ASC',
2202 + ];
2203 +
2204 + if ( ! empty( $include ) ) {
2205 + $args['post__in'] = $include;
2206 + $args['posts_per_page'] = count( $include );
2207 + $args['orderby'] = 'post__in';
2208 + } else {
2209 + $args['s'] = $search;
471 2210 }
472 2211
473 - wp_send_json_success(
474 - [
475 - 'added' => true,
476 - 'productName' => wp_strip_all_tags( $product->get_name() ),
477 - 'cartCount' => WC()->cart->get_cart_contents_count(),
478 - ]
479 - );
2212 + $query = new WP_Query( $args );
2213 + $items = [];
2214 +
2215 + foreach ( $query->posts as $post ) {
2216 + $items[] = [
2217 + 'id' => (int) $post->ID,
2218 + 'label' => html_entity_decode( get_the_title( $post ), ENT_QUOTES, get_bloginfo( 'charset' ) ),
2219 + ];
2220 + }
2221 +
2222 + wp_reset_postdata();
2223 +
2224 + wp_send_json_success( [ 'items' => $items ] );
480 2225 }
481 2226 }
482 2227
483 2228 new WooProductGrid();