PluginProbe
bBlocks – Essential Gutenberg Blocks & Patterns Collection / 2.1.8
bBlocks – Essential Gutenberg Blocks & Patterns Collection v2.1.8
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
← All changes | includes/blocks/woo-product-grid/WooProductGrid.php +271 -2016 trunk → 2.1.8 View file →
@@ -1,34 +1,25 @@
1 1 <?php
2 2 /**
3 3 * Woo Product Grid — shared server logic.
4 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.
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.
10 8 *
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().
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.
21 14 *
15 + * All card output is escaped (esc_html / esc_url / esc_attr / wp_kses_post).
16 + *
22 17 * @package bBlocks
23 18 */
24 19
25 20 namespace BBlocks\Inc\Blocks;
26 21
27 -use BBlocks\Inc\GetCSS;
28 -use BBlocks\Inc\Sanitize;
29 -use WP_Query;
30 -
31 22 if ( ! defined( 'ABSPATH' ) ) {
32 23 exit;
33 24 }
34 25
@@ -33,351 +24,35 @@
33 24 }
34 25
35 26 class WooProductGrid {
36 27
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 28 /**
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:
29 + * Allowed orderby values mapped to WC_Product_Query orderby keys.
223 30 *
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.
31 + * @var string[]
232 32 */
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 - ];
33 + const ORDERBY = [ 'date', 'price', 'rating', 'popularity', 'rand', 'title' ];
274 34
275 35 /**
276 - * Read a nested attribute set back into the flat keys this class works in.
36 + * Allowed order values (uppercased).
277 37 *
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.
38 + * @var string[]
284 39 */
285 - public static function flatten( array $attributes ) {
286 - foreach ( self::NESTED_MAP as $flat => $path ) {
287 - $node = $attributes;
40 + const ORDER = [ 'ASC', 'DESC' ];
288 41
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 42 /**
314 - * Gather one per-device prop into { desktop, tablet, mobile }.
43 + * Allowed aspect ratios.
315 44 *
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.
45 + * @var string[]
326 46 */
327 - protected static function deviceValue( array $attributes, array $container, $prop ) {
328 - $node = $attributes;
47 + const RATIOS = [ '3/4', '1/1', '4/3', '16/9' ];
329 48
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 49 /**
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.
50 + * Hook the AJAX endpoint (public + logged-in).
368 51 */
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;
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' ] );
380 55 }
381 56
382 57 /* ----------------------------------------------------------------------
383 58 * Sanitizers
@@ -390,13 +65,9 @@
390 65 * @param string $fallback Fallback when invalid.
391 66 * @return string
392 67 */
393 68 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 -
69 + $color = trim( (string) $color );
399 70 if ( '' === $color ) {
400 71 return $fallback;
401 72 }
402 73 if ( preg_match( '/^#([0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/', $color ) ) {
@@ -410,9 +81,8 @@
410 81 }
411 82 if ( preg_match( '/^[a-zA-Z]{1,30}$/', $color ) ) {
412 83 return $color;
413 84 }
414 -
415 85 return $fallback;
416 86 }
417 87
418 88 /**
@@ -427,30 +97,16 @@
427 97 public static function clampInt( $value, $min, $max, $fallback ) {
428 98 if ( ! is_numeric( $value ) ) {
429 99 return (int) $fallback;
430 100 }
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;
101 + $value = (int) $value;
102 + if ( $value < $min ) {
103 + return (int) $min;
450 104 }
451 -
452 - return round( min( $max, max( $min, (float) $value ) ), 2 );
105 + if ( $value > $max ) {
106 + return (int) $max;
107 + }
108 + return $value;
453 109 }
454 110
455 111 /**
456 112 * Pick a value from an allowlist.
@@ -461,9 +117,8 @@
461 117 * @return string
462 118 */
463 119 public static function pickFrom( $value, array $allowed, $fallback ) {
464 120 $value = is_string( $value ) ? trim( $value ) : '';
465 -
466 121 return in_array( $value, $allowed, true ) ? $value : $fallback;
467 122 }
468 123
469 124 /**
@@ -475,1754 +130,354 @@
475 130 public static function intArray( $value ) {
476 131 if ( ! is_array( $value ) ) {
477 132 return [];
478 133 }
479 -
480 134 $out = [];
481 135 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 -
136 + $id = absint( $item );
490 137 if ( $id > 0 ) {
491 138 $out[] = $id;
492 139 }
493 140 }
494 -
495 141 return array_values( array_unique( $out ) );
496 142 }
497 143
498 144 /**
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 145 * Normalize and sanitize the full attribute set into a safe, typed array.
919 146 *
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 147 * @param array $attributes Raw block attributes.
924 148 * @return array
925 149 */
926 150 public static function resolveAttributes( array $attributes ) {
927 - $attributes = self::flatten( $attributes );
151 + $columns = (array) ( $attributes['columns'] ?? [] );
152 + $titleFont = (array) ( $attributes['titleFontSize'] ?? [] );
928 153
929 - $columns = is_array( $attributes['columns'] ?? null ) ? $attributes['columns'] : [];
154 + $saleBadgeLabel = isset( $attributes['saleBadgeLabel'] ) ? wp_strip_all_tags( (string) $attributes['saleBadgeLabel'] ) : '';
155 + $saleBadgeLabel = '' !== trim( $saleBadgeLabel ) ? $saleBadgeLabel : __( 'Sale', 'b-blocks' );
930 156
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;
157 + $addToCartLabel = isset( $attributes['addToCartLabel'] ) ? wp_strip_all_tags( (string) $attributes['addToCartLabel'] ) : '';
158 + $addToCartLabel = '' !== trim( $addToCartLabel ) ? $addToCartLabel : __( 'Add to Cart', 'b-blocks' );
936 159
937 - if ( is_array( $value ) && ( isset( $value['desktop'] ) || isset( $value['tablet'] ) || isset( $value['mobile'] ) ) ) {
938 - return $value[ $device ] ?? null;
939 - }
160 + $noProductsMessage = isset( $attributes['noProductsMessage'] ) ? wp_strip_all_tags( (string) $attributes['noProductsMessage'] ) : '';
161 + $noProductsMessage = '' !== trim( $noProductsMessage ) ? $noProductsMessage : __( 'No products found.', 'b-blocks' );
940 162
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 163 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' ),
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 ),
978 169
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 ),
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'] ),
994 177
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' ) ),
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' ),
1003 181
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' ),
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' ),
1008 190
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' ),
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' ),
1014 197
1015 - /* Sorting */
1016 - 'contentOrder' => $order,
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 ),
1017 209
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' ),
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 ),
1023 213
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' ) ),
214 + 'noProductsMessage' => $noProductsMessage,
1229 215 ];
1230 216 }
1231 217
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 218 /* ----------------------------------------------------------------------
1269 219 * Query
1270 220 * ------------------------------------------------------------------- */
1271 221
1272 222 /**
1273 - * Run the product query for one page.
223 + * Build sanitized wc_get_products() args.
1274 224 *
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 }
225 + * @param array $a Resolved attributes.
226 + * @return array
1282 227 */
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,
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',
1302 237 ];
1303 - }
1304 238
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 239 $args = [
1318 - 'post_type' => 'product',
1319 - 'post_status' => 'publish',
1320 - 'posts_per_page' => $perPage,
1321 - 'ignore_sticky_posts' => true,
240 + 'status' => 'publish',
241 + 'limit' => $a['productsPerPage'],
242 + 'orderby' => $orderByMap[ $a['orderBy'] ] ?? 'date',
243 + 'order' => $a['order'],
244 + 'paginate' => false,
245 + 'return' => 'objects',
1322 246 ];
1323 247
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 248 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 - ];
249 + $args['category'] = self::termIdsToSlugs( $a['productCategories'], 'product_cat' );
1364 250 }
1365 251
1366 252 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 - ];
253 + $args['tag'] = self::termIdsToSlugs( $a['productTags'], 'product_tag' );
1373 254 }
1374 255
1375 - if ( $termId > 0 ) {
1376 - $taxQuery[] = [
1377 - 'taxonomy' => $a['filterTaxonomy'],
1378 - 'field' => 'term_id',
1379 - 'terms' => [ $termId ],
1380 - 'operator' => 'IN',
1381 - ];
256 + if ( $a['featuredOnly'] ) {
257 + $args['featured'] = true;
1382 258 }
1383 259
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 260 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 - }
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 ];
1398 264 }
1399 265
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 );
266 + return $args;
1447 267 }
1448 268
1449 269 /**
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.
270 + * Convert term IDs to slugs for a taxonomy (wc_get_products expects slugs).
1452 271 *
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 }
272 + * @param int[] $ids Term IDs.
273 + * @param string $taxonomy Taxonomy.
274 + * @return string[]
1458 275 */
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 - }
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;
1483 282 }
1484 - $ids = $filtered;
1485 283 }
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 - ];
284 + return $slugs;
1508 285 }
1509 286
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 287 /* ----------------------------------------------------------------------
1534 - * Data
288 + * Card rendering
1535 289 * ------------------------------------------------------------------- */
1536 290
1537 291 /**
1538 - * Allowlist for the two HTML fragments WooCommerce itself produces.
292 + * Render the product cards as an escaped HTML fragment.
1539 293 *
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
294 + * @param \WC_Product[] $products Products.
295 + * @param array $a Resolved attributes.
296 + * @return string Escaped HTML.
1544 297 */
1545 - public static function allowedTags() {
1546 - static $tags = null;
298 + public static function renderCards( array $products, array $a ) {
299 + ob_start();
1547 300
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 ) {
301 + foreach ( $products as $product ) :
302 + if ( ! is_a( $product, 'WC_Product' ) ) {
1641 303 continue;
1642 304 }
1643 305
1644 - $amounts[] = round( $regular - $sale, $decimals );
1645 - $percent = max( $percent, (int) round( ( ( $regular - $sale ) / $regular ) * 100 ) );
1646 - }
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();
1647 312
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 );
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 + }
1939 326 }
1940 327 }
1941 -
1942 - $args['include'] = array_values( array_unique( array_map( 'absint', $ids ) ) );
328 + if ( '' === $imageUrl && function_exists( 'wc_placeholder_img_src' ) ) {
329 + $imageUrl = wc_placeholder_img_src( 'woocommerce_thumbnail' );
330 + }
1943 331 }
1944 - }
1945 332
1946 - $terms = get_terms( $args );
333 + // Rating.
334 + $ratingValue = (float) $product->get_average_rating();
335 + $ratingCount = (int) $product->get_rating_count();
1947 336
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 );
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 );
1967 343 }
1968 344 }
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; ?>
1969 361
1970 - $items[] = [
1971 - 'id' => (int) $term->term_id,
1972 - 'name' => $name,
1973 - ];
1974 - }
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; ?>
1975 370
1976 - return $items;
1977 - }
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; ?>
1978 394
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 - ];
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; ?>
2002 400
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 - }
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;
2008 433
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;
434 + return ob_get_clean();
2029 435 }
2030 436
2031 437 /* ----------------------------------------------------------------------
2032 - * Server-rendered shell
438 + * AJAX endpoint
2033 439 * ------------------------------------------------------------------- */
2034 440
2035 441 /**
2036 - * Where to send an author whose site has no WooCommerce.
442 + * Handle the `bb_wpg_add_to_cart` AJAX request for simple products.
2037 443 *
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
444 + * Returns JSON: { added: true, productName } or an error.
2043 445 */
2044 - public static function wooInstallUrl() {
2045 - $isDownloaded = defined( 'WP_PLUGIN_DIR' ) && file_exists( WP_PLUGIN_DIR . '/woocommerce/woocommerce.php' );
446 + public function ajaxAddToCart() {
447 + check_ajax_referer( 'bb_wpg_add_to_cart', 'nonce' );
2046 448
2047 - if ( $isDownloaded && current_user_can( 'activate_plugins' ) ) {
2048 - return admin_url( 'plugins.php?s=woocommerce&plugin_status=all' );
449 + if ( ! function_exists( 'WC' ) || ! WC()->cart ) {
450 + wp_send_json_error( [ 'message' => __( 'WooCommerce is not available.', 'b-blocks' ) ] );
2049 451 }
2050 452
2051 - if ( ! $isDownloaded && current_user_can( 'install_plugins' ) ) {
2052 - return admin_url( 'plugin-install.php?tab=search&type=term&s=woocommerce' );
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' ) ] );
2053 456 }
2054 457
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 - );
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' ) ] );
2100 461 }
2101 462
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' ) ] );
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' ) ] );
2130 465 }
2131 466
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
467 + $added = WC()->cart->add_to_cart( $productId, 1 );
2138 468
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 ] );
469 + if ( ! $added ) {
470 + wp_send_json_error( [ 'message' => __( 'Could not add the product to the cart.', 'b-blocks' ) ] );
2154 471 }
2155 472
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 ] );
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 + );
2225 480 }
2226 481 }
2227 482
2228 483 new WooProductGrid();