PluginProbe
bBlocks – Essential Gutenberg Blocks & Patterns Collection / 2.1.9
bBlocks – Essential Gutenberg Blocks & Patterns Collection v2.1.9
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 1.5.2 All 109 releases
b-blocks / includes / blocks / woo-product-grid / WooProductGrid.php

WooProductGrid.php in bBlocks – Essential Gutenberg Blocks & Patterns Collection 2.1.9, at includes/blocks/woo-product-grid/WooProductGrid.php

2,229 lines 87.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Woo Product Grid — shared server logic.
4 *
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.
10 *
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().
21 *
22 * @package bBlocks
23 */
24
25 namespace BBlocks\Inc\Blocks;
26
27 use BBlocks\Inc\GetCSS;
28 use BBlocks\Inc\Sanitize;
29 use WP_Query;
30
31 if ( ! defined( 'ABSPATH' ) ) {
32 exit;
33 }
34
35 class WooProductGrid {
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
71 /**
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:
223 *
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.
232 */
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 ];
274
275 /**
276 * Read a nested attribute set back into the flat keys this class works in.
277 *
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.
284 */
285 public static function flatten( array $attributes ) {
286 foreach ( self::NESTED_MAP as $flat => $path ) {
287 $node = $attributes;
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
313 /**
314 * Gather one per-device prop into { desktop, tablet, mobile }.
315 *
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.
326 */
327 protected static function deviceValue( array $attributes, array $container, $prop ) {
328 $node = $attributes;
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
362 /**
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.
368 */
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;
380 }
381
382 /* ----------------------------------------------------------------------
383 * Sanitizers
384 * ------------------------------------------------------------------- */
385
386 /**
387 * Sanitize a CSS color value (hex, rgb/hsl, var(), or a CSS keyword).
388 *
389 * @param mixed $color Raw color.
390 * @param string $fallback Fallback when invalid.
391 * @return string
392 */
393 public static function sanitizeColor( $color, $fallback = '' ) {
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
399 if ( '' === $color ) {
400 return $fallback;
401 }
402 if ( preg_match( '/^#([0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/', $color ) ) {
403 return $color;
404 }
405 if ( preg_match( '/^(rgb|rgba|hsl|hsla)\s*\([0-9\s,%.\/]+\)$/i', $color ) ) {
406 return $color;
407 }
408 if ( preg_match( '/^var\(\s*--[a-zA-Z0-9\-_]+\s*(,\s*[a-zA-Z0-9 #%.,\-_\/]+)?\s*\)$/', $color ) ) {
409 return $color;
410 }
411 if ( preg_match( '/^[a-zA-Z]{1,30}$/', $color ) ) {
412 return $color;
413 }
414
415 return $fallback;
416 }
417
418 /**
419 * Clamp a value to an integer range.
420 *
421 * @param mixed $value Raw value.
422 * @param int $min Minimum.
423 * @param int $max Maximum.
424 * @param int $fallback Fallback when non-numeric.
425 * @return int
426 */
427 public static function clampInt( $value, $min, $max, $fallback ) {
428 if ( ! is_numeric( $value ) ) {
429 return (int) $fallback;
430 }
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;
450 }
451
452 return round( min( $max, max( $min, (float) $value ) ), 2 );
453 }
454
455 /**
456 * Pick a value from an allowlist.
457 *
458 * @param mixed $value Raw value.
459 * @param string[] $allowed Allowed values.
460 * @param string $fallback Fallback.
461 * @return string
462 */
463 public static function pickFrom( $value, array $allowed, $fallback ) {
464 $value = is_string( $value ) ? trim( $value ) : '';
465
466 return in_array( $value, $allowed, true ) ? $value : $fallback;
467 }
468
469 /**
470 * Sanitize an array of positive integer IDs.
471 *
472 * @param mixed $value Raw array.
473 * @return int[]
474 */
475 public static function intArray( $value ) {
476 if ( ! is_array( $value ) ) {
477 return [];
478 }
479
480 $out = [];
481 foreach ( $value as $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
490 if ( $id > 0 ) {
491 $out[] = $id;
492 }
493 }
494
495 return array_values( array_unique( $out ) );
496 }
497
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 /**
918 * Normalize and sanitize the full attribute set into a safe, typed array.
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 *
923 * @param array $attributes Raw block attributes.
924 * @return array
925 */
926 public static function resolveAttributes( array $attributes ) {
927 $attributes = self::flatten( $attributes );
928
929 $columns = is_array( $attributes['columns'] ?? null ) ? $attributes['columns'] : [];
930
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;
936
937 if ( is_array( $value ) && ( isset( $value['desktop'] ) || isset( $value['tablet'] ) || isset( $value['mobile'] ) ) ) {
938 return $value[ $device ] ?? null;
939 }
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
950 return [
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' ),
978
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 ),
994
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' ) ),
1003
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' ),
1008
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' ),
1014
1015 /* Sorting */
1016 'contentOrder' => $order,
1017
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' ),
1023
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' ) ),
1229 ];
1230 }
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
1268 /* ----------------------------------------------------------------------
1269 * Query
1270 * ------------------------------------------------------------------- */
1271
1272 /**
1273 * Run the product query for one page.
1274 *
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.
1311 * @return array
1312 */
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,
1322 ];
1323
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 ],
1346 ];
1347
1348 if ( $a['featuredOnly'] ) {
1349 $taxQuery[] = [
1350 'taxonomy' => 'product_visibility',
1351 'field' => 'name',
1352 'terms' => [ 'featured' ],
1353 'operator' => 'IN',
1354 ];
1355 }
1356
1357 if ( ! empty( $a['productCategories'] ) ) {
1358 $taxQuery[] = [
1359 'taxonomy' => 'product_cat',
1360 'field' => 'term_id',
1361 'terms' => $a['productCategories'],
1362 'operator' => 'exclude' === $a['categoryMode'] ? 'NOT IN' : 'IN',
1363 ];
1364 }
1365
1366 if ( ! empty( $a['productTags'] ) ) {
1367 $taxQuery[] = [
1368 'taxonomy' => 'product_tag',
1369 'field' => 'term_id',
1370 'terms' => $a['productTags'],
1371 'operator' => 'exclude' === $a['tagMode'] ? 'NOT IN' : 'IN',
1372 ];
1373 }
1374
1375 if ( $termId > 0 ) {
1376 $taxQuery[] = [
1377 'taxonomy' => $a['filterTaxonomy'],
1378 'field' => 'term_id',
1379 'terms' => [ $termId ],
1380 'operator' => 'IN',
1381 ];
1382 }
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
1389 if ( $a['onSaleOnly'] && function_exists( 'wc_get_product_ids_on_sale' ) ) {
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 }
1398 }
1399
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 );
1447 }
1448
1449 /**
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.
1452 *
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 }
1458 */
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 }
1483 }
1484 $ids = $filtered;
1485 }
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 ];
1508 }
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
1533 /* ----------------------------------------------------------------------
1534 * Data
1535 * ------------------------------------------------------------------- */
1536
1537 /**
1538 * Allowlist for the two HTML fragments WooCommerce itself produces.
1539 *
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
1544 */
1545 public static function allowedTags() {
1546 static $tags = null;
1547
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 ) {
1641 continue;
1642 }
1643
1644 $amounts[] = round( $regular - $sale, $decimals );
1645 $percent = max( $percent, (int) round( ( ( $regular - $sale ) / $regular ) * 100 ) );
1646 }
1647
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 );
1939 }
1940 }
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 );
1967 }
1968 }
1969
1970 $items[] = [
1971 'id' => (int) $term->term_id,
1972 'name' => $name,
1973 ];
1974 }
1975
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;
2007 }
2008
2009 self::setPath( $view, $path, $a[ $flat ] );
2010 }
2011
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 ] ?? '';
2019
2020 self::setPath(
2021 $view,
2022 array_merge( $container, [ strtolower( $device ), $prop ] ),
2023 'box' === $kind ? self::boxSides( $value ) : $value
2024 );
2025 }
2026 }
2027
2028 return $view;
2029 }
2030
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 '';
2056 }
2057
2058 /* ----------------------------------------------------------------------
2059 * AJAX endpoints
2060 * ------------------------------------------------------------------- */
2061
2062 /**
2063 * Attributes posted by the editor or the frontend, put back through the
2064 * sanitizer.
2065 *
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
2071 */
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 ) : [];
2075
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 );
2100 }
2101
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' ) ] );
2130 }
2131
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 ] );
2154 }
2155
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 ] );
2183 }
2184
2185 if ( 'product' !== $type ) {
2186 wp_send_json_error( [ 'message' => __( 'Unsupported search type.', 'b-blocks' ) ] );
2187 }
2188
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;
2210 }
2211
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 ] );
2225 }
2226 }
2227
2228 new WooProductGrid();
2229