PluginProbe
bBlocks – Essential Gutenberg Blocks & Patterns Collection / 2.1.10
bBlocks – Essential Gutenberg Blocks & Patterns Collection v2.1.10
2.1.10 2.1.9 2.1.8 2.1.7 2.1.6 2.1.5 2.1.4 2.1.3 2.1.2 2.1.1 2.1.0 2.0.43 2.0.42 2.0.41 2.0.40 2.0.39 2.0.38 trunk 1.0 1.1 1.2 1.3 1.4 1.5 1.5.1 All 110 releases
b-blocks / includes / blocks / woo-add-to-cart / WooAddToCart.php

WooAddToCart.php in bBlocks – Essential Gutenberg Blocks & Patterns Collection 2.1.10, at includes/blocks/woo-add-to-cart/WooAddToCart.php

1,160 lines 43.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Woo Add to Cart — shared server logic.
4 *
5 * Provides attribute sanitization helpers, product resolution, WooCommerce's
6 * native add-to-cart form for variable / grouped / external products, and a
7 * hardened add-to-cart AJAX endpoint (`bBlocksWooAddToCart`) used by the
8 * frontend, so adding to the cart never reloads the page.
9 *
10 * Security model for `bBlocksWooAddToCart`:
11 * - Nonce verified on every request via check_ajax_referer().
12 * - product_id sanitized with absint() and validated against wc_get_product().
13 * - quantity sanitized with absint() and clamped to the product's own
14 * minimum / maximum (capped at MAX_QUANTITY); sold-individually products add 1.
15 * - Simple, variable (a variation of the posted product only) and grouped
16 * (the group's own children only) products; purchasable and in stock.
17 * - Every add passes `woocommerce_add_to_cart_validation`.
18 * - All responses use wp_send_json_success / wp_send_json_error.
19 *
20 * @package bBlocks
21 */
22
23 namespace BBlocks\Inc\Blocks;
24
25 if ( ! defined( 'ABSPATH' ) ) {
26 exit;
27 }
28
29 class WooAddToCart {
30
31 /**
32 * The most the block's stepper and Default Quantity allow, for a product
33 * WooCommerce sets no maximum of its own for.
34 */
35 const MAX_QUANTITY = 1000;
36
37 /**
38 * Hook the AJAX endpoint (public + logged-in).
39 */
40 public function __construct() {
41 add_action( 'wp_ajax_bBlocksWooAddToCart', [ $this, 'ajaxAddToCart' ] );
42 add_action( 'wp_ajax_nopriv_bBlocksWooAddToCart', [ $this, 'ajaxAddToCart' ] );
43 add_action( 'wp_loaded', [ $this, 'keepFormHandlerOut' ], 0 );
44
45 // A fresh nonce, for a page served from a cache that has outlived the one
46 // printed in it (utils/cart.js asks once, then retries).
47 add_action( 'wp_ajax_bBlocksWooAddToCartNonce', [ $this, 'ajaxNonce' ] );
48 add_action( 'wp_ajax_nopriv_bBlocksWooAddToCartNonce', [ $this, 'ajaxNonce' ] );
49 }
50
51 /** A fresh `bBlocksWooAddToCart` nonce. */
52 public function ajaxNonce() {
53 nocache_headers();
54 wp_send_json_success( [ 'nonce' => wp_create_nonce( 'bBlocksWooAddToCart' ) ] );
55 }
56
57 /**
58 * WooCommerce's form handler adds whatever `add-to-cart` names on every
59 * request, admin-ajax included, before this endpoint runs (wp_loaded, 20).
60 * A request to this endpoint is added here and only here, so the field is
61 * dropped first — or the product would go in twice.
62 */
63 public function keepFormHandlerOut() {
64 // Only compared, never output (sanitize_key() would lowercase it); the
65 // endpoint itself checks the nonce.
66 // phpcs:ignore WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
67 $action = isset( $_REQUEST['action'] ) && is_string( $_REQUEST['action'] ) ? wp_unslash( $_REQUEST['action'] ) : '';
68 if ( ! wp_doing_ajax() || 'bBlocksWooAddToCart' !== $action ) {
69 return;
70 }
71 unset( $_REQUEST['add-to-cart'], $_POST['add-to-cart'], $_GET['add-to-cart'] );
72 }
73
74 /**
75 * Sanitize a CSS color value (hex, rgb/hsl, var(), or a CSS keyword).
76 *
77 * @param mixed $color Raw color.
78 * @param string $fallback Fallback when invalid.
79 * @return string
80 */
81 public static function sanitizeColor( $color, $fallback = '' ) {
82 $color = is_scalar( $color ) ? trim( (string) $color ) : '';
83 if ( '' === $color ) {
84 return $fallback;
85 }
86 if ( preg_match( '/^#([0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/', $color ) ) {
87 return $color;
88 }
89 if ( preg_match( '/^(rgb|rgba|hsl|hsla)\s*\([0-9\s,%.\/]+\)$/i', $color ) ) {
90 return $color;
91 }
92 if ( preg_match( '/^var\(\s*--[a-zA-Z0-9\-_]+\s*(,\s*[a-zA-Z0-9 #%.,\-_\/]+)?\s*\)$/', $color ) ) {
93 return $color;
94 }
95 if ( preg_match( '/^[a-zA-Z]{1,30}$/', $color ) ) {
96 return $color;
97 }
98 return $fallback;
99 }
100
101 /**
102 * Clamp a value to an integer range.
103 *
104 * @param mixed $value Raw value.
105 * @param int $min Minimum.
106 * @param int $max Maximum.
107 * @param int $fallback Fallback when non-numeric.
108 * @return int
109 */
110 public static function clampInt( $value, $min, $max, $fallback ) {
111 if ( ! is_numeric( $value ) ) {
112 return (int) $fallback;
113 }
114 $value = (int) $value;
115 if ( $value < $min ) {
116 return (int) $min;
117 }
118 if ( $value > $max ) {
119 return (int) $max;
120 }
121 return $value;
122 }
123
124 /**
125 * Sanitize a CSS length: a number with an optional px, em, rem, %, vw or vh.
126 *
127 * @param mixed $value Raw value.
128 * @param string $fallback Returned when the value is empty or invalid.
129 * @return string
130 */
131 public static function sanitizeLength( $value, $fallback = '' ) {
132 $value = is_scalar( $value ) ? trim( (string) $value ) : '';
133
134 return preg_match( '/^\d+(\.\d+)?(px|em|rem|%|vw|vh)?$/', $value ) ? $value : $fallback;
135 }
136
137 /**
138 * The product this block instance is for.
139 *
140 * A product picked in the block wins — that is what the block always did,
141 * and it is what makes it usable on any page. With none picked it takes the
142 * product in context: the Single Product template or a product loop (block
143 * context), the global product a classic template sets up, or the product
144 * page being viewed.
145 *
146 * @param array $attributes Block attributes.
147 * @param \WP_Block|null $block Block instance.
148 * @return \WC_Product|null
149 */
150 public static function resolveProduct( array $attributes, $block = null ) {
151 if ( ! function_exists( 'wc_get_product' ) ) {
152 return null;
153 }
154
155 $product = is_array( $attributes['product'] ?? null ) ? $attributes['product'] : [];
156 $id = absint( $product['id'] ?? 0 );
157
158 if ( ! $id && $block instanceof \WP_Block ) {
159 $contextId = absint( $block->context['postId'] ?? 0 );
160 $contextType = (string) ( $block->context['postType'] ?? '' );
161
162 if ( $contextId && 'product' === $contextType ) {
163 $id = $contextId;
164 }
165 }
166
167 if ( ! $id && isset( $GLOBALS['product'] ) && is_a( $GLOBALS['product'], 'WC_Product' ) ) {
168 $id = $GLOBALS['product']->get_id();
169 }
170
171 if ( ! $id && is_singular( 'product' ) ) {
172 $id = get_queried_object_id();
173 }
174
175 $found = $id ? wc_get_product( $id ) : null;
176 if ( ! $found || ! is_a( $found, 'WC_Product' ) ) {
177 return null;
178 }
179
180 // Only a product the visitor may see: a draft, private or trashed one, or
181 // one behind a password, would otherwise leak its name, prices and
182 // variations into the page.
183 $productId = $found->get_id();
184 if ( ( 'publish' !== get_post_status( $productId ) && ! current_user_can( 'read_post', $productId ) ) || post_password_required( $productId ) ) {
185 return null;
186 }
187
188 return $found;
189 }
190
191 /**
192 * Quantity limits for the block's own stepper, from WooCommerce's rules.
193 *
194 * A product without a maximum of its own is capped at MAX_QUANTITY.
195 *
196 * @param \WC_Product $product Product.
197 * @return array { min: int, max: int, step: int }
198 */
199 public static function quantityLimits( $product ) {
200 $min = max( 1, (int) apply_filters( 'woocommerce_quantity_input_min', $product->get_min_purchase_quantity(), $product ) );
201 $max = (int) apply_filters( 'woocommerce_quantity_input_max', $product->get_max_purchase_quantity(), $product );
202 $max = $max > 0 ? min( $max, self::MAX_QUANTITY ) : self::MAX_QUANTITY;
203 $step = max( 1, (int) apply_filters( 'woocommerce_quantity_input_step', 1, $product ) );
204
205 return [
206 'min' => $min,
207 'max' => max( $min, $max ),
208 'step' => $step,
209 ];
210 }
211
212 /**
213 * The label for the add-to-cart button.
214 *
215 * The block's text when there is one; otherwise WooCommerce's own. An
216 * external product's own "Button text" always wins over the block's.
217 *
218 * @param \WC_Product $product Product.
219 * @param string $label Block's Button Text.
220 * @return string
221 */
222 public static function buttonText( $product, $label ) {
223 if ( self::hasOwnText( $product ) ) {
224 return trim( (string) $product->get_button_text() );
225 }
226
227 return '' !== trim( $label ) ? $label : $product->single_add_to_cart_text();
228 }
229
230 /**
231 * Whether an external product sets its own button text in WooCommerce.
232 *
233 * @param \WC_Product $product Product.
234 * @return bool
235 */
236 public static function hasOwnText( $product ) {
237 return $product->is_type( 'external' ) && '' !== trim( (string) $product->get_button_text() );
238 }
239
240 /**
241 * WooCommerce's own add-to-cart form for a variable, grouped or external
242 * product, so its scripts and every add-to-cart hook keep working.
243 *
244 * The global product and post are swapped for the render and restored
245 * after, and the button-text filter is added only for this call — other
246 * add-to-cart buttons on the page never see it.
247 *
248 * @param \WC_Product $product Product.
249 * @param string $label Block's Button Text.
250 * @param bool $showQuantity Whether quantities are shown.
251 * @return string
252 */
253 public static function nativeForm( $product, $label, $showQuantity ) {
254 if ( ! function_exists( 'woocommerce_template_single_add_to_cart' ) ) {
255 return '';
256 }
257
258 global $post;
259 $previousProduct = $GLOBALS['product'] ?? null;
260 $previousPost = $post;
261
262 $productId = $product->get_id();
263
264 // Never calls back into single_add_to_cart_text(): that runs this very
265 // filter, and the recursion never ends. WooCommerce's own text is
266 // already $current, so keeping it is just returning it.
267 $text = static function ( $current, $item = null ) use ( $product, $label, $productId ) {
268 if ( ! $item || ! is_a( $item, 'WC_Product' ) || $item->get_id() !== $productId || self::hasOwnText( $product ) ) {
269 return $current;
270 }
271 return '' !== trim( $label ) ? $label : $current;
272 };
273
274 // Grouped children default to 0. With quantities hidden that would add
275 // nothing, so each child is submitted as 1 instead.
276 // The filter is only attached for this one grouped render, and only the
277 // children's inputs are named quantity[<id>].
278 $groupedOne = static function ( $args ) {
279 if ( 0 === strpos( (string) ( $args['input_name'] ?? '' ), 'quantity[' ) ) {
280 $args['input_value'] = max( 1, (int) ( $args['min_value'] ?? 0 ) );
281 }
282 return $args;
283 };
284
285 add_filter( 'woocommerce_product_single_add_to_cart_text', $text, 99, 2 );
286 if ( ! $showQuantity && $product->is_type( 'grouped' ) ) {
287 add_filter( 'woocommerce_quantity_input_args', $groupedOne, 99 );
288 }
289
290 $GLOBALS['product'] = $product; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- restored below.
291 $post = get_post( $productId ); // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- restored below.
292 setup_postdata( $post );
293
294 ob_start();
295 woocommerce_template_single_add_to_cart();
296 $html = (string) ob_get_clean();
297
298 // The post first: setup_postdata() fires `the_post`, on which WooCommerce
299 // resets the global product — so that is put back last.
300 $post = $previousPost; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- restoring.
301 if ( $previousPost ) {
302 setup_postdata( $previousPost );
303 } else {
304 wp_reset_postdata();
305 }
306 $GLOBALS['product'] = $previousProduct; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- restoring.
307
308 remove_filter( 'woocommerce_product_single_add_to_cart_text', $text, 99 );
309 remove_filter( 'woocommerce_quantity_input_args', $groupedOne, 99 );
310
311 return $html;
312 }
313
314 /* ----------------------------------------------------------------------
315 * Attributes handed to the React Style component
316 * ------------------------------------------------------------------- */
317
318 /** An array, or an empty one. */
319 protected static function arr( $value ) {
320 return is_array( $value ) ? $value : [];
321 }
322
323 /**
324 * A box value (padding, radius): each side a length or empty.
325 *
326 * @param mixed $box { top, right, bottom, left }.
327 * @return array
328 */
329 public static function box( $box ) {
330 $box = self::arr( $box );
331 $out = [];
332 foreach ( [ 'top', 'right', 'bottom', 'left' ] as $side ) {
333 $out[ $side ] = self::sanitizeLength( $box[ $side ] ?? '' );
334 }
335 return $out;
336 }
337
338 /**
339 * The Advanced tab's settings (bpl-tools), checked before generateCSS()
340 * writes them into CSS: it prints every value as it is.
341 *
342 * Every value must be a plain CSS value — lengths, colours, gradients,
343 * keywords, numbers — with no `;`, braces, quotes or `url(`; an image is a
344 * URL of its own. The free-form Custom CSS is kept only when the post's
345 * author may publish unfiltered HTML, as WordPress itself decides for
346 * markup: for anyone else it could restyle or hide the whole page.
347 *
348 * @param mixed $advanced The `advanced` attribute.
349 * @return array
350 */
351 public static function advanced( $advanced ) {
352 $advanced = self::arr( $advanced );
353
354 $walk = static function ( $value, $key ) use ( &$walk ) {
355 if ( is_array( $value ) ) {
356 $out = [];
357 foreach ( $value as $k => $v ) {
358 $clean = $walk( $v, (string) $k );
359 if ( null !== $clean ) {
360 $out[ $k ] = $clean;
361 }
362 }
363 return $out;
364 }
365 if ( is_bool( $value ) || is_int( $value ) || is_float( $value ) ) {
366 return $value;
367 }
368 if ( ! is_string( $value ) ) {
369 return null;
370 }
371 if ( 'url' === $key ) {
372 return esc_url_raw( $value );
373 }
374 $safe = preg_match( '/^[a-zA-Z0-9#%.,()\s\-+_\/:]*$/', $value ) && ! preg_match( '/url\s*\(|expression|javascript:|@import/i', $value );
375 return $safe ? $value : '';
376 };
377
378 $out = $walk( $advanced, '' );
379
380 $canCss = class_exists( '\BBlocks\Inc\Sanitize' ) && \BBlocks\Inc\Sanitize::authorCanUnfilteredHtml();
381 $out['css'] = $canCss && is_string( $advanced['css'] ?? null ) ? $advanced['css'] : '';
382
383 return $out;
384 }
385
386 /**
387 * The button's icon: an SVG from bpl-tools' IconLibrary, put into the page
388 * as markup — so through bBlocks' SVG sanitizer, which drops anything that
389 * is not plain drawing (scripts, event handlers, remote references) — and
390 * its position, size, gap and colours.
391 *
392 * @param mixed $icon The `button.icon` attribute.
393 * @return array
394 */
395 public static function buttonIcon( $icon ) {
396 $icon = self::arr( $icon );
397 $svg = is_string( $icon['svg'] ?? null ) ? trim( $icon['svg'] ) : '';
398
399 if ( '' !== $svg ) {
400 $svg = class_exists( '\BBlocks\Inc\Sanitize' ) ? \BBlocks\Inc\Sanitize::svg( $svg ) : '';
401 }
402
403 return [
404 'svg' => $svg,
405 'position' => 'after' === ( $icon['position'] ?? '' ) ? 'after' : 'before',
406 'size' => self::sanitizeLength( $icon['size'] ?? '' ),
407 'gap' => self::sanitizeLength( $icon['gap'] ?? '' ),
408 'color' => [ 'text' => self::sanitizeColor( self::arr( $icon['color'] ?? null )['text'] ?? '', '' ) ],
409 'hover' => [ 'color' => [ 'text' => self::sanitizeColor( self::arr( self::arr( $icon['hover'] ?? null )['color'] ?? null )['text'] ?? '', '' ) ] ],
410 ];
411 }
412
413 /**
414 * A bpl-tools Background object: solid, gradient or image, every part
415 * checked, since Style.js writes it straight into CSS.
416 *
417 * @param mixed $bg Background attribute.
418 * @return array
419 */
420 public static function background( $bg ) {
421 $bg = self::arr( $bg );
422 $type = in_array( $bg['type'] ?? '', [ 'solid', 'gradient', 'image' ], true ) ? $bg['type'] : 'solid';
423 // What is unset or invalid is left out rather than emptied: bpl-tools'
424 // getBackgroundCSS() fills its own defaults for a missing key only (the
425 // default gradient, no-repeat), which is what the editor shows.
426 $out = [ 'type' => $type ];
427 $set = static function ( $key, $value ) use ( &$out ) {
428 if ( '' !== $value && null !== $value ) {
429 $out[ $key ] = $value;
430 }
431 };
432
433 $set( 'color', self::sanitizeColor( $bg['color'] ?? '', '' ) );
434
435 if ( 'gradient' === $type ) {
436 $gradient = is_string( $bg['gradient'] ?? null ) ? trim( $bg['gradient'] ) : '';
437 // A CSS gradient and nothing else: no quote, semicolon, brace or tag.
438 $set( 'gradient', preg_match( '/^(repeating-)?(linear|radial|conic)-gradient\([a-z0-9#%.,()\s-]*\)$/i', $gradient ) ? $gradient : '' );
439 }
440
441 if ( 'image' === $type ) {
442 $image = self::arr( $bg['image'] ?? null );
443 $position = is_string( $bg['position'] ?? null ) ? $bg['position'] : '';
444 $size = is_string( $bg['size'] ?? null ) ? $bg['size'] : '';
445
446 // Position, attachment, repeat and size: a "Default" picked in the
447 // control is an empty value, which getBackgroundCSS() prints nothing
448 // for (the browser's own: repeat, top left). Kept as empty, not left
449 // out — left out, its fallbacks (no-repeat, center) would show here
450 // but not in the editor.
451 $keep = static function ( $key, $value ) use ( &$out, $bg, $set ) {
452 if ( array_key_exists( $key, $bg ) && '' === $value ) {
453 $out[ $key ] = '';
454 return;
455 }
456 $set( $key, $value );
457 };
458
459 $out['image'] = [ 'url' => esc_url_raw( is_string( $image['url'] ?? null ) ? $image['url'] : '' ) ];
460 $keep( 'position', preg_match( '/^[a-z0-9%.\s-]{1,40}$/i', $position ) ? $position : '' );
461 $keep( 'attachment', in_array( $bg['attachment'] ?? '', [ 'initial', 'scroll', 'fixed', 'local' ], true ) ? $bg['attachment'] : '' );
462 $keep( 'repeat', in_array( $bg['repeat'] ?? '', [ 'no-repeat', 'repeat', 'repeat-x', 'repeat-y' ], true ) ? $bg['repeat'] : '' );
463 $keep( 'size', in_array( $size, [ 'cover', 'auto', 'contain' ], true ) || '' !== self::sanitizeLength( $size ) ? $size : '' );
464 $set( 'overlayColor', self::sanitizeColor( $bg['overlayColor'] ?? '', '' ) );
465 }
466
467 return $out;
468 }
469
470 /**
471 * A BorderBoxControl value: one border, or one per side.
472 *
473 * @param mixed $border Border value.
474 * @return array
475 */
476 public static function border( $border ) {
477 $border = self::arr( $border );
478 $line = static function ( $side ) {
479 $side = self::arr( $side );
480 $style = $side['style'] ?? '';
481 // Unset parts are left out, not emptied: getBorderBoxCSS() defaults
482 // a missing width to 0px, as in the editor; an empty one would print
483 // `border: solid …`, a 3px border the editor never showed.
484 return array_filter(
485 [
486 'width' => self::sanitizeLength( $side['width'] ?? '' ),
487 'style' => in_array( $style, [ 'solid', 'dashed', 'dotted', 'double', 'groove', 'ridge', 'inset', 'outset', 'none' ], true ) ? $style : '',
488 'color' => self::sanitizeColor( $side['color'] ?? '', '' ),
489 ],
490 static function ( $v ) {
491 return '' !== $v;
492 }
493 );
494 };
495
496 $sides = array_intersect_key( $border, array_flip( [ 'top', 'right', 'bottom', 'left' ] ) );
497 if ( $sides ) {
498 return array_map( $line, $sides );
499 }
500
501 return $border ? $line( $border ) : [];
502 }
503
504 /**
505 * A ShadowControl value, each shadow's lengths and colour checked.
506 *
507 * @param mixed $value Shadows.
508 * @return array
509 */
510 public static function shadowList( $value ) {
511 $length = static function ( $v ) {
512 $v = is_scalar( $v ) ? trim( (string) $v ) : '';
513 return preg_match( '/^-?\d+(\.\d+)?(px|em|rem)?$/', $v ) ? $v : '0px';
514 };
515
516 $out = [];
517 foreach ( array_slice( self::arr( $value ), 0, 5 ) as $shadow ) {
518 $shadow = self::arr( $shadow );
519 $color = self::sanitizeColor( $shadow['color'] ?? '', '' );
520 if ( '' === $color ) {
521 continue;
522 }
523 $out[] = [
524 'hOffset' => $length( $shadow['hOffset'] ?? '0px' ),
525 'vOffset' => $length( $shadow['vOffset'] ?? '0px' ),
526 'blur' => $length( $shadow['blur'] ?? '0px' ),
527 'spreed' => $length( $shadow['spreed'] ?? '0px' ),
528 'color' => $color,
529 'isInset' => ! empty( $shadow['isInset'] ),
530 ];
531 }
532 return $out;
533 }
534
535 /**
536 * A bpl-tools Typography value, every key checked against the shape the
537 * control produces. getTypoCSS() turns this into CSS on the client.
538 *
539 * @param mixed $typo Typography value.
540 * @return array
541 */
542 public static function typo( $typo ) {
543 $typo = self::arr( $typo );
544 $token = static function ( $value, $pattern ) {
545 $value = is_scalar( $value ) ? trim( (string) $value ) : '';
546 return ( '' !== $value && strlen( $value ) <= 60 && preg_match( $pattern, $value ) ) ? $value : '';
547 };
548 $size = static function ( $value ) use ( $token ) {
549 return $token( $value, '/^[0-9.]+(px|em|rem|%|vh|vw)?$/' );
550 };
551
552 $fontSize = $typo['fontSize'] ?? [];
553 $fontSize = is_array( $fontSize ) ? $fontSize : [ 'desktop' => $fontSize ];
554
555 $out = [
556 'fontFamily' => $token( $typo['fontFamily'] ?? '', '/^[a-zA-Z0-9 \-]+$/' ),
557 'fontCategory' => $token( $typo['fontCategory'] ?? '', '/^[a-zA-Z\-]+$/' ),
558 'fontVariant' => $token( $typo['fontVariant'] ?? '', '/^[0-9]{3}i?$/' ),
559 'fontWeight' => $token( $typo['fontWeight'] ?? '', '/^([1-9]00|normal|bold|lighter|bolder)$/' ),
560 'isUploadFont' => ! isset( $typo['isUploadFont'] ) || (bool) $typo['isUploadFont'],
561 'fontStyle' => $token( $typo['fontStyle'] ?? '', '/^(normal|italic|oblique)$/' ),
562 'textTransform' => $token( $typo['textTransform'] ?? '', '/^(none|capitalize|uppercase|lowercase)$/' ),
563 'textDecoration' => $token( $typo['textDecoration'] ?? '', '/^(none|underline|overline|line-through)$/' ),
564 'lineHeight' => $token( $typo['lineHeight'] ?? '', '/^[0-9.]+(px|em|rem|%)?$/' ),
565 'letterSpace' => $token( $typo['letterSpace'] ?? '', '/^-?[0-9.]+(px|em|rem)?$/' ),
566 'fontSize' => [
567 'desktop' => $size( $fontSize['desktop'] ?? '' ),
568 'tablet' => $size( $fontSize['tablet'] ?? '' ),
569 'mobile' => $size( $fontSize['mobile'] ?? '' ),
570 ],
571 ];
572
573 // An unset family must read as the control's own "Default".
574 if ( '' === $out['fontFamily'] ) {
575 $out['fontFamily'] = 'Default';
576 }
577
578 // Other unset values are left out, not emptied: getTypoCSS() fills its
579 // own defaults (a family's category, sans-serif) for a missing key only.
580 return array_filter(
581 $out,
582 static function ( $v ) {
583 return '' !== $v;
584 }
585 );
586 }
587
588 /**
589 * The attributes the frontend Style component reads, every value checked.
590 *
591 * Style.js turns these into CSS, so anything that reaches it must already
592 * be a valid colour, length or keyword — a raw value could close the CSS
593 * declaration or rule it is written into. Same grouped shape as block.json;
594 * only the presentation groups, since that is all Style.js needs.
595 *
596 * @param array $attributes Block attributes.
597 * @return array
598 */
599 public static function viewAttributes( array $attributes ) {
600 $g = static function ( $name ) use ( $attributes ) {
601 return self::arr( $attributes[ $name ] ?? null );
602 };
603 $color = static function ( $value ) {
604 return self::sanitizeColor( $value, '' );
605 };
606 // An element's `color` object: { text } or { text, bg }.
607 $colors = static function ( $node, array $keys = [ 'text' ] ) use ( $color ) {
608 $node = self::arr( self::arr( $node )['color'] ?? null );
609 $out = [];
610 foreach ( $keys as $key ) {
611 $out[ $key ] = $color( $node[ $key ] ?? '' );
612 }
613 return $out;
614 };
615
616 // A hover state of a field: text colour, background, border.
617 $hoverState = static function ( $node ) use ( $colors ) {
618 $node = self::arr( $node );
619 return [
620 'color' => $colors( $node ),
621 'bg' => self::background( $node['bg'] ?? [] ),
622 'border' => self::border( $node['border'] ?? [] ),
623 ];
624 };
625
626 $layout = $g( 'layout' );
627 $button = $g( 'button' );
628 $quantity = $g( 'quantity' );
629 $stepper = $g( 'stepper' );
630 $variations = $g( 'variations' );
631 $grouped = $g( 'grouped' );
632 $messages = $g( 'messages' );
633 $viewCart = $g( 'viewCart' );
634
635 $vTable = self::arr( $variations['table'] ?? null );
636 $label = self::arr( $variations['label'] ?? null );
637 $select = self::arr( $variations['select'] ?? null );
638 $clear = self::arr( $variations['clear'] ?? null );
639 $vPrice = self::arr( $variations['price'] ?? null );
640 $table = self::arr( $grouped['table'] ?? null );
641 $name = self::arr( $grouped['name'] ?? null );
642 $price = self::arr( $grouped['price'] ?? null );
643
644 $alignment = $layout['alignment'] ?? 'left';
645
646 $view = [
647 'layout' => [
648 'alignment' => in_array( $alignment, [ 'left', 'center', 'right' ], true ) ? $alignment : 'left',
649 'fullWidth' => ! empty( $layout['fullWidth'] ),
650 ],
651 'button' => [
652 'typo' => self::typo( $button['typo'] ?? [] ),
653 'shadow' => self::shadowList( $button['shadow'] ?? [] ),
654 'icon' => self::buttonIcon( $button['icon'] ?? null ),
655 'added' => [
656 'color' => $colors( $button['added'] ?? null ),
657 'bg' => self::background( self::arr( $button['added'] ?? null )['bg'] ?? [] ),
658 ],
659 ],
660 'quantity' => [
661 'typo' => self::typo( $quantity['typo'] ?? [] ),
662 'color' => $colors( $quantity ),
663 'bg' => self::background( $quantity['bg'] ?? [] ),
664 'border' => self::border( $quantity['border'] ?? [] ),
665 'hover' => $hoverState( $quantity['hover'] ?? null ),
666 ],
667 'stepper' => [
668 'color' => $colors( $stepper ),
669 'bg' => self::background( $stepper['bg'] ?? [] ),
670 'hover' => [
671 'color' => $colors( $stepper['hover'] ?? null ),
672 'bg' => self::background( self::arr( $stepper['hover'] ?? null )['bg'] ?? [] ),
673 ],
674 ],
675 'variations' => [
676 'label' => [
677 'color' => $colors( $label ),
678 'typo' => self::typo( $label['typo'] ?? [] ),
679 'hover' => [ 'color' => $colors( $label['hover'] ?? null ) ],
680 ],
681 'select' => [
682 'typo' => self::typo( $select['typo'] ?? [] ),
683 'color' => $colors( $select ),
684 'bg' => self::background( $select['bg'] ?? [] ),
685 'border' => self::border( $select['border'] ?? [] ),
686 'hover' => $hoverState( $select['hover'] ?? null ),
687 ],
688 'clear' => [
689 'typo' => self::typo( $clear['typo'] ?? [] ),
690 'color' => $colors( $clear ),
691 'hover' => [ 'color' => $colors( $clear['hover'] ?? null ) ],
692 ],
693 'price' => [
694 'color' => $colors( $vPrice ),
695 'typo' => self::typo( $vPrice['typo'] ?? [] ),
696 ],
697 ],
698 'grouped' => [
699 'table' => [
700 'border' => self::border( $table['border'] ?? [] ),
701 'bg' => self::background( $table['bg'] ?? [] ),
702 ],
703 'name' => [
704 'color' => $colors( $name ),
705 'typo' => self::typo( $name['typo'] ?? [] ),
706 'hover' => [ 'color' => $colors( $name['hover'] ?? null ) ],
707 ],
708 'price' => [
709 'color' => $colors( $price ),
710 'typo' => self::typo( $price['typo'] ?? [] ),
711 ],
712 ],
713 'messages' => [
714 'typo' => self::typo( $messages['typo'] ?? [] ),
715 'color' => array_map( $color, array_intersect_key( self::arr( $messages['color'] ?? null ), array_flip( [ 'text', 'inStock', 'outOfStock' ] ) ) ),
716 ],
717 'viewCart' => [
718 'typo' => self::typo( $viewCart['typo'] ?? [] ),
719 'color' => $colors( $viewCart ),
720 'hover' => [ 'color' => $colors( $viewCart['hover'] ?? null ) ],
721 ],
722 ];
723
724 foreach ( [ 'normal', 'hover' ] as $state ) {
725 $node = self::arr( $button[ $state ] ?? null );
726 $view['button'][ $state ] = [
727 'color' => $colors( $node ),
728 'bg' => self::background( $node['bg'] ?? [] ),
729 'border' => self::border( $node['border'] ?? [] ),
730 ];
731 }
732
733 foreach ( [ 'desktop', 'tablet', 'mobile' ] as $device ) {
734 $slot = self::arr( $layout[ $device ] ?? null );
735 $type = $slot['displayType'] ?? '';
736
737 $view['layout'][ $device ] = [
738 'displayType' => in_array( $type, [ 'inline', 'inline-reverse', 'stacked', 'stacked-reverse' ], true ) ? $type : '',
739 'gap' => self::sanitizeLength( $slot['gap'] ?? '' ),
740 ];
741
742 $view['button'][ $device ] = [
743 'padding' => self::box( self::arr( $button[ $device ] ?? null )['padding'] ?? [] ),
744 'radius' => self::box( self::arr( $button[ $device ] ?? null )['radius'] ?? [] ),
745 'width' => self::sanitizeLength( self::arr( $button[ $device ] ?? null )['width'] ?? '' ),
746 ];
747
748 $view['stepper'][ $device ] = [
749 'width' => self::sanitizeLength( self::arr( $stepper[ $device ] ?? null )['width'] ?? '' ),
750 ];
751
752 $q = self::arr( $quantity[ $device ] ?? null );
753 $view['quantity'][ $device ] = [
754 'width' => self::sanitizeLength( $q['width'] ?? '' ),
755 'height' => self::sanitizeLength( $q['height'] ?? '' ),
756 'radius' => self::box( $q['radius'] ?? [] ),
757 ];
758
759 $s = self::arr( $select[ $device ] ?? null );
760 $view['variations']['select'][ $device ] = [
761 'radius' => self::box( $s['radius'] ?? [] ),
762 'padding' => self::box( $s['padding'] ?? [] ),
763 'width' => self::sanitizeLength( $s['width'] ?? '' ),
764 ];
765
766 $t = self::arr( $vTable[ $device ] ?? null );
767 $view['variations']['table'][ $device ] = [
768 'rowGap' => self::sanitizeLength( $t['rowGap'] ?? '' ),
769 'labelWidth' => self::sanitizeLength( $t['labelWidth'] ?? '' ),
770 'bottomGap' => self::sanitizeLength( $t['bottomGap'] ?? '' ),
771 ];
772
773 $view['grouped']['table'][ $device ] = [
774 'rowGap' => self::sanitizeLength( self::arr( $table[ $device ] ?? null )['rowGap'] ?? '' ),
775 'padding' => self::box( self::arr( $table[ $device ] ?? null )['padding'] ?? [] ),
776 ];
777 }
778
779 return $view;
780 }
781
782 /**
783 * Everything the frontend renders the block from, for render.php to hand to
784 * view.js as `data-attributes`.
785 *
786 * Only what the browser cannot know is resolved here — which product is in
787 * context, its type, stock and quantity rules, WooCommerce's own label, the
788 * AJAX nonce — and, for any product the block's own button does not sell,
789 * WooCommerce's form (or availability message) as HTML, since its
790 * templates and hooks only run server-side. What to show from all that is
791 * decided by Components/Frontend/AddToCart.js.
792 *
793 * Presentation settings go through viewAttributes(): Style.js writes them
794 * straight into CSS.
795 *
796 * @param array $attributes Block attributes.
797 * @param WP_Block|null $block Block instance, for its context.
798 * @return array|null Null when there is no product to sell.
799 */
800 public static function frontendAttributes( array $attributes, $block = null ) {
801 $product = self::resolveProduct( $attributes, $block );
802 if ( ! $product ) {
803 return null;
804 }
805
806 $content = self::arr( $attributes['content'] ?? null );
807 $options = self::arr( $attributes['options'] ?? null );
808 $text = static function ( $value ) {
809 return is_scalar( $value ) ? wp_strip_all_tags( (string) $value ) : '';
810 };
811
812 $limits = self::quantityLimits( $product );
813 $buttonTxt = $text( $content['buttonText'] ?? '' );
814 $isSimple = $product->is_type( 'simple' );
815 $canAjax = $isSimple && $product->is_purchasable() && $product->is_in_stock();
816
817 $html = '';
818 if ( ! $isSimple ) {
819 $html = self::nativeForm( $product, $buttonTxt, ! empty( $options['showQuantity'] ) );
820 } elseif ( ! $canAjax ) {
821 // As WooCommerce's own template: a product that cannot be bought
822 // (no price, say) shows no stock count, only that it is unavailable.
823 $html = $product->is_purchasable() ? wc_get_stock_html( $product ) : '';
824 if ( '' === trim( $html ) ) {
825 $html = '<p class="stock out-of-stock">' . esc_html__( 'This product is currently unavailable.', 'b-blocks' ) . '</p>';
826 }
827 $html = wp_kses_post( $html );
828 }
829
830 $view = self::viewAttributes( $attributes );
831
832 $view['advanced'] = self::advanced( $attributes['advanced'] ?? null );
833
834 $view['content'] = [
835 'buttonText' => $buttonTxt,
836 'addedText' => $text( $content['addedText'] ?? '' ),
837 'viewCartText' => $text( $content['viewCartText'] ?? '' ),
838 ];
839
840 $view['options'] = [
841 'showQuantity' => ! empty( $options['showQuantity'] ),
842 // 0 is allowed: the stepper then starts empty (AddToCart.js).
843 'quantity' => self::clampInt( $options['quantity'] ?? 1, 0, self::MAX_QUANTITY, 1 ),
844 // The quantity's look (simple products): − and + buttons, or a plain box.
845 'quantityStyle' => 'input' === ( $options['quantityStyle'] ?? '' ) ? 'input' : 'stepper',
846 'redirectToCart' => ! empty( $options['redirectToCart'] ),
847 'viewCart' => ! empty( $options['viewCart'] ),
848 ];
849
850 $view['product'] = [
851 'id' => $product->get_id(),
852 'name' => wp_strip_all_tags( $product->get_name() ),
853 'url' => esc_url_raw( (string) $product->get_permalink() ),
854 'type' => sanitize_key( $product->get_type() ),
855 'purchasable' => $product->is_purchasable(),
856 'inStock' => $product->is_in_stock(),
857 'soldIndividually' => $product->is_sold_individually(),
858 'min' => $limits['min'],
859 'max' => $limits['max'],
860 'step' => $limits['step'],
861 'label' => wp_strip_all_tags( self::buttonText( $product, '' ) ),
862 'ownLabel' => self::hasOwnText( $product ),
863 'html' => $html,
864 ];
865
866 $view['cart'] = [
867 'ajaxUrl' => esc_url_raw( admin_url( 'admin-ajax.php' ) ),
868 'cartUrl' => function_exists( 'wc_get_cart_url' ) ? esc_url_raw( wc_get_cart_url() ) : '',
869 'nonce' => wp_create_nonce( 'bBlocksWooAddToCart' ),
870 ];
871
872 // The frontend's own text, translated here: view.js loads no
873 // WordPress script (no wp-i18n), so it does not translate anything.
874 $view['i18n'] = [
875 'addToCart' => __( 'Add to cart', 'b-blocks' ),
876 'added' => __( 'Added!', 'b-blocks' ),
877 'viewCart' => __( 'View cart', 'b-blocks' ),
878 'quantity' => __( 'Quantity', 'b-blocks' ),
879 'decrease' => __( 'Decrease quantity', 'b-blocks' ),
880 'increase' => __( 'Increase quantity', 'b-blocks' ),
881 'chooseQuantity' => __( 'Choose a quantity first.', 'b-blocks' ),
882 /* translators: %s: product name. */
883 'addedToCart' => __( '%s added to cart.', 'b-blocks' ),
884 'failed' => __( 'Could not add the product to the cart.', 'b-blocks' ),
885 ];
886
887 // The plan class 1.x printed on the box, kept for custom CSS that uses it.
888 $view['plan'] = class_exists( '\BBlocks\Inc\Utils' ) && \BBlocks\Inc\Utils::isPro() ? 'pro' : 'free';
889
890 return $view;
891 }
892
893 /**
894 * Handle the `bBlocksWooAddToCart` AJAX request, so adding to the cart
895 * never reloads the page.
896 *
897 * - Simple: `product_id`, `quantity`.
898 * - Variable: `product_id` (the parent), `variation_id`, `quantity` and the
899 * form's `attribute_*` fields; WC_Cart::add_to_cart() checks the chosen
900 * attributes against the product.
901 * - Grouped: `product_id` (the group) and `quantity[<child id>]`, as
902 * WooCommerce's own form posts them; only the group's own children.
903 *
904 * Each add passes `woocommerce_add_to_cart_validation`, as WooCommerce's own
905 * form handler does. Returns JSON { added, productName, cartCount, cartUrl },
906 * or an error with WooCommerce's own reason.
907 */
908 public function ajaxAddToCart() {
909 check_ajax_referer( 'bBlocksWooAddToCart', 'nonce' );
910
911 if ( ! function_exists( 'WC' ) || ! WC()->cart ) {
912 wp_send_json_error( [ 'message' => __( 'WooCommerce is not available.', 'b-blocks' ) ] );
913 }
914
915 $productId = isset( $_POST['product_id'] ) ? absint( wp_unslash( $_POST['product_id'] ) ) : 0;
916 $product = $productId ? wc_get_product( $productId ) : null;
917 if ( ! $product || ! is_a( $product, 'WC_Product' ) ) {
918 wp_send_json_error( [ 'message' => __( 'Product not found.', 'b-blocks' ) ] );
919 }
920
921 // By class, so types built on these (a variable subscription, say) are
922 // handled as what they are.
923 if ( $product instanceof \WC_Product_Variable ) {
924 $result = self::addVariation( $product );
925 } elseif ( $product instanceof \WC_Product_Grouped ) {
926 $result = self::addGrouped( $product );
927 } elseif ( $product->is_type( 'simple' ) ) {
928 $result = self::addSimple( $product );
929 } else {
930 $result = __( 'This product cannot be added to the cart.', 'b-blocks' );
931 }
932
933 if ( true !== $result ) {
934 wp_send_json_error( [ 'message' => self::takeErrors( $result ) ] );
935 }
936
937 // As WooCommerce's own AJAX add-to-cart: analytics and pixel plugins
938 // record the add on this.
939 do_action( 'woocommerce_ajax_added_to_cart', $product->get_id() );
940
941 wp_send_json_success(
942 [
943 'added' => true,
944 'productName' => wp_strip_all_tags( $product->get_name() ),
945 'cartCount' => WC()->cart->get_cart_contents_count(),
946 'cartUrl' => function_exists( 'wc_get_cart_url' ) ? wc_get_cart_url() : '',
947 // Part of a grouped add can fail while the rest goes in: say so,
948 // rather than leave WooCommerce's notice for the next page.
949 'message' => self::takeErrors( '' ),
950 // What WooCommerce's own add-to-cart returns, for its mini cart
951 // and cart fragments (`added_to_cart`).
952 'fragments' => self::cartFragments(),
953 'cartHash' => WC()->cart->get_cart_hash(),
954 ]
955 );
956 }
957
958 /**
959 * WooCommerce's cart fragments — the mini cart and whatever else hooks in —
960 * as its own AJAX add-to-cart returns them.
961 *
962 * @return array
963 */
964 protected static function cartFragments() {
965 if ( ! function_exists( 'woocommerce_mini_cart' ) ) {
966 return [];
967 }
968
969 ob_start();
970 woocommerce_mini_cart();
971 $miniCart = (string) ob_get_clean();
972
973 return (array) apply_filters( 'woocommerce_add_to_cart_fragments', [ 'div.widget_shopping_cart_content' => '<div class="widget_shopping_cart_content">' . $miniCart . '</div>' ] );
974 }
975
976 /**
977 * A posted quantity, checked against the product's own limits: the number,
978 * or why it cannot be added. Not quietly changed — stock can drop between
979 * the page and the click, and WooCommerce's own form refuses then too.
980 *
981 * @param \WC_Product $product Product.
982 * @param mixed $value Posted quantity.
983 * @return int|string
984 */
985 protected static function postedQuantity( $product, $value ) {
986 if ( $product->is_sold_individually() ) {
987 return 1;
988 }
989
990 $limits = self::quantityLimits( $product );
991 $quantity = function_exists( 'wc_stock_amount' ) ? wc_stock_amount( is_scalar( $value ) ? $value : 0 ) : (int) $value;
992
993 if ( $quantity < $limits['min'] ) {
994 /* translators: %d: smallest quantity. */
995 return sprintf( __( 'Please choose a quantity of at least %d.', 'b-blocks' ), $limits['min'] );
996 }
997 if ( $quantity > $limits['max'] ) {
998 /* translators: %d: largest quantity. */
999 return sprintf( __( 'You can add at most %d of this product.', 'b-blocks' ), $limits['max'] );
1000 }
1001
1002 return $quantity;
1003 }
1004
1005 /**
1006 * @param \WC_Product $product Simple product.
1007 * @return true|string True, or why not.
1008 */
1009 protected static function addSimple( $product ) {
1010 if ( ! $product->is_purchasable() || ! $product->is_in_stock() ) {
1011 return __( 'This product cannot be added to the cart.', 'b-blocks' );
1012 }
1013
1014 // phpcs:ignore WordPress.Security.NonceVerification.Missing -- verified in ajaxAddToCart().
1015 $quantity = self::postedQuantity( $product, isset( $_POST['quantity'] ) ? wp_unslash( $_POST['quantity'] ) : 1 );
1016 if ( is_string( $quantity ) ) {
1017 return $quantity;
1018 }
1019
1020 if ( ! apply_filters( 'woocommerce_add_to_cart_validation', true, $product->get_id(), $quantity ) ) {
1021 return __( 'Could not add the product to the cart.', 'b-blocks' );
1022 }
1023
1024 return false !== WC()->cart->add_to_cart( $product->get_id(), $quantity ) ? true : __( 'Could not add the product to the cart.', 'b-blocks' );
1025 }
1026
1027 /**
1028 * @param \WC_Product $product Variable product (the parent).
1029 * @return true|string True, or why not.
1030 */
1031 protected static function addVariation( $product ) {
1032 // phpcs:disable WordPress.Security.NonceVerification.Missing -- verified in ajaxAddToCart().
1033 $variationId = isset( $_POST['variation_id'] ) ? absint( wp_unslash( $_POST['variation_id'] ) ) : 0;
1034 $variation = $variationId ? wc_get_product( $variationId ) : null;
1035
1036 // Only a variation of this very product.
1037 if ( ! $variation || ! $variation->is_type( 'variation' ) || $variation->get_parent_id() !== $product->get_id() ) {
1038 return __( 'Please choose product options before adding this product to your cart.', 'b-blocks' );
1039 }
1040
1041 // The form's chosen attributes, as WooCommerce's own form handler reads them.
1042 $attributes = [];
1043 foreach ( wp_unslash( $_POST ) as $key => $value ) { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- each key and value is sanitized below.
1044 if ( is_string( $key ) && 0 === strpos( $key, 'attribute_' ) && is_scalar( $value ) ) {
1045 $attributes[ sanitize_title( $key ) ] = wc_clean( (string) $value );
1046 }
1047 }
1048
1049 $quantity = self::postedQuantity( $variation, isset( $_POST['quantity'] ) ? wp_unslash( $_POST['quantity'] ) : 1 );
1050 // phpcs:enable WordPress.Security.NonceVerification.Missing
1051 if ( is_string( $quantity ) ) {
1052 return $quantity;
1053 }
1054
1055 if ( ! $variation->is_purchasable() || ! $variation->is_in_stock() ) {
1056 return __( 'This product cannot be added to the cart.', 'b-blocks' );
1057 }
1058
1059 if ( ! apply_filters( 'woocommerce_add_to_cart_validation', true, $product->get_id(), $quantity, $variationId, $attributes ) ) {
1060 return __( 'Could not add the product to the cart.', 'b-blocks' );
1061 }
1062
1063 return false !== WC()->cart->add_to_cart( $product->get_id(), $quantity, $variationId, $attributes ) ? true : __( 'Could not add the product to the cart.', 'b-blocks' );
1064 }
1065
1066 /**
1067 * @param \WC_Product $product Grouped product.
1068 * @return true|string True when anything was added, or why not.
1069 */
1070 protected static function addGrouped( $product ) {
1071 // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- verified in ajaxAddToCart(); each id and quantity is sanitized below.
1072 $posted = isset( $_POST['quantity'] ) && is_array( $_POST['quantity'] ) ? wp_unslash( $_POST['quantity'] ) : [];
1073 $children = array_map( 'absint', $product->get_children() );
1074 $added = false;
1075 $chosen = false;
1076 $problems = [];
1077
1078 // As WooCommerce's own grouped handler: the totals once at the end, not
1079 // once per product.
1080 $cart = WC()->cart;
1081 $deferred = remove_action( 'woocommerce_add_to_cart', [ $cart, 'calculate_totals' ], 20 );
1082
1083 foreach ( $posted as $childId => $value ) {
1084 $childId = absint( $childId );
1085 if ( ! is_scalar( $value ) || ( function_exists( 'wc_stock_amount' ) ? wc_stock_amount( $value ) : (int) $value ) <= 0 || ! in_array( $childId, $children, true ) ) {
1086 continue;
1087 }
1088 $chosen = true;
1089
1090 $child = wc_get_product( $childId );
1091 if ( ! $child || ! $child->is_purchasable() || ! $child->is_in_stock() ) {
1092 continue;
1093 }
1094
1095 $quantity = self::postedQuantity( $child, $value );
1096 if ( is_string( $quantity ) ) {
1097 $problems[] = wp_strip_all_tags( $child->get_name() ) . ': ' . $quantity;
1098 continue;
1099 }
1100 if ( apply_filters( 'woocommerce_add_to_cart_validation', true, $childId, $quantity ) && false !== $cart->add_to_cart( $childId, $quantity ) ) {
1101 $added = true;
1102 }
1103 }
1104
1105 if ( $deferred ) {
1106 add_action( 'woocommerce_add_to_cart', [ $cart, 'calculate_totals' ], 20, 0 );
1107 $cart->calculate_totals();
1108 }
1109
1110 // Reported with WooCommerce's own notices (takeErrors()).
1111 foreach ( $problems as $problem ) {
1112 wc_add_notice( $problem, 'error' );
1113 }
1114
1115 if ( $added ) {
1116 return true;
1117 }
1118
1119 return $chosen
1120 ? __( 'Could not add the products to the cart.', 'b-blocks' )
1121 : __( 'Please choose the quantity of items you wish to add to your cart.', 'b-blocks' );
1122 }
1123
1124 /**
1125 * WooCommerce's own reason for a failed add — it leaves it as an error
1126 * notice — as plain text, removed from the notices so it does not show up
1127 * again on the next page; or the fallback.
1128 *
1129 * @param string $fallback Message when WooCommerce left none.
1130 * @return string
1131 */
1132 protected static function takeErrors( $fallback ) {
1133 if ( ! function_exists( 'wc_get_notices' ) || ! function_exists( 'wc_set_notices' ) ) {
1134 return $fallback;
1135 }
1136
1137 $notices = wc_get_notices();
1138 $errors = $notices['error'] ?? [];
1139 unset( $notices['error'] );
1140 wc_set_notices( $notices );
1141
1142 $messages = array_filter(
1143 array_map(
1144 static function ( $notice ) {
1145 $notice = is_array( $notice ) ? ( $notice['notice'] ?? '' ) : (string) $notice;
1146 // WooCommerce adds a "View cart" button link to some notices;
1147 // as text it would only read "… View cart".
1148 $notice = preg_replace( '#<a\b[^>]*>.*?</a>#is', '', $notice );
1149 return trim( preg_replace( '/\s+/', ' ', html_entity_decode( wp_strip_all_tags( $notice ), ENT_QUOTES, 'UTF-8' ) ) );
1150 },
1151 $errors
1152 )
1153 );
1154
1155 return $messages ? implode( ' ', $messages ) : $fallback;
1156 }
1157 }
1158
1159 new WooAddToCart();
1160