PluginProbe
bBlocks – Essential Gutenberg Blocks & Patterns Collection / 2.1.8
bBlocks – Essential Gutenberg Blocks & Patterns Collection v2.1.8
2.1.10 2.1.9 2.1.8 2.1.7 2.1.6 2.1.5 2.1.4 2.1.3 2.1.2 2.1.1 2.1.0 2.0.43 2.0.42 2.0.41 2.0.40 2.0.39 2.0.38 trunk 1.0 1.1 1.2 1.3 1.4 1.5 1.5.1 All 110 releases
← All changes | includes/blocks/woo-add-to-cart/WooAddToCart.php +50 -1054 trunk → 2.1.8 View file →
@@ -1,21 +1,17 @@
1 1 <?php
2 2 /**
3 3 * Woo Add to Cart — shared server logic.
4 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.
5 + * Provides attribute sanitization helpers and a hardened single-product
6 + * add-to-cart AJAX endpoint (`bb_atc_add_to_cart`) used by the frontend
7 + * `view.js`.
9 8 *
10 - * Security model for `bBlocksWooAddToCart`:
9 + * Security model for `bb_atc_add_to_cart`:
11 10 * - Nonce verified on every request via check_ajax_referer().
12 11 * - 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`.
12 + * - quantity sanitized with absint() and clamped to 1..99.
13 + * - Only purchasable, in-stock, simple (non-variable) products are added.
18 14 * - All responses use wp_send_json_success / wp_send_json_error.
19 15 *
20 16 * @package bBlocks
21 17 */
@@ -28,51 +24,16 @@
28 24
29 25 class WooAddToCart {
30 26
31 27 /**
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 28 * Hook the AJAX endpoint (public + logged-in).
39 29 */
40 30 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' ] );
31 + add_action( 'wp_ajax_bb_atc_add_to_cart', [ $this, 'ajaxAddToCart' ] );
32 + add_action( 'wp_ajax_nopriv_bb_atc_add_to_cart', [ $this, 'ajaxAddToCart' ] );
49 33 }
50 34
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 35 /**
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 36 * Sanitize a CSS color value (hex, rgb/hsl, var(), or a CSS keyword).
76 37 *
77 38 * @param mixed $color Raw color.
78 39 * @param string $fallback Fallback when invalid.
@@ -78,9 +39,9 @@
78 39 * @param string $fallback Fallback when invalid.
79 40 * @return string
80 41 */
81 42 public static function sanitizeColor( $color, $fallback = '' ) {
82 - $color = is_scalar( $color ) ? trim( (string) $color ) : '';
43 + $color = trim( (string) $color );
83 44 if ( '' === $color ) {
84 45 return $fallback;
85 46 }
86 47 if ( preg_match( '/^#([0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/', $color ) ) {
@@ -98,8 +59,32 @@
98 59 return $fallback;
99 60 }
100 61
101 62 /**
63 + * Sanitize a CSS length in px (digits + "px"). Falls back when invalid.
64 + *
65 + * @param mixed $value Raw value (e.g. "12px" or 12).
66 + * @param int $min Minimum px.
67 + * @param int $max Maximum px.
68 + * @param string $fallback Fallback value (e.g. "12px").
69 + * @return string
70 + */
71 + public static function sanitizePx( $value, $min, $max, $fallback ) {
72 + $digits = preg_replace( '/[^0-9]/', '', (string) $value );
73 + if ( '' === $digits ) {
74 + return $fallback;
75 + }
76 + $n = (int) $digits;
77 + if ( $n < $min ) {
78 + $n = $min;
79 + }
80 + if ( $n > $max ) {
81 + $n = $max;
82 + }
83 + return $n . 'px';
84 + }
85 +
86 + /**
102 87 * Clamp a value to an integer range.
103 88 *
104 89 * @param mixed $value Raw value.
105 90 * @param int $min Minimum.
@@ -121,793 +106,14 @@
121 106 return $value;
122 107 }
123 108
124 109 /**
125 - * Sanitize a CSS length: a number with an optional px, em, rem, %, vw or vh.
110 + * Handle the `bb_atc_add_to_cart` AJAX request for simple products.
126 111 *
127 - * @param mixed $value Raw value.
128 - * @param string $fallback Returned when the value is empty or invalid.
129 - * @return string
112 + * Returns JSON: { added, productName, cartCount, cartUrl } or an error.
130 113 */
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 114 public function ajaxAddToCart() {
909 - check_ajax_referer( 'bBlocksWooAddToCart', 'nonce' );
115 + check_ajax_referer( 'bb_atc_add_to_cart', 'nonce' );
910 116
911 117 if ( ! function_exists( 'WC' ) || ! WC()->cart ) {
912 118 wp_send_json_error( [ 'message' => __( 'WooCommerce is not available.', 'b-blocks' ) ] );
913 119 }
@@ -912,33 +118,30 @@
912 118 wp_send_json_error( [ 'message' => __( 'WooCommerce is not available.', 'b-blocks' ) ] );
913 119 }
914 120
915 121 $productId = isset( $_POST['product_id'] ) ? absint( wp_unslash( $_POST['product_id'] ) ) : 0;
916 - $product = $productId ? wc_get_product( $productId ) : null;
122 + if ( $productId < 1 ) {
123 + wp_send_json_error( [ 'message' => __( 'Invalid product.', 'b-blocks' ) ] );
124 + }
125 +
126 + $quantity = isset( $_POST['quantity'] ) ? absint( wp_unslash( $_POST['quantity'] ) ) : 1;
127 + $quantity = self::clampInt( $quantity, 1, 99, 1 );
128 +
129 + $product = wc_get_product( $productId );
917 130 if ( ! $product || ! is_a( $product, 'WC_Product' ) ) {
918 131 wp_send_json_error( [ 'message' => __( 'Product not found.', 'b-blocks' ) ] );
919 132 }
920 133
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' );
134 + if ( ! $product->is_type( 'simple' ) || ! $product->is_purchasable() || ! $product->is_in_stock() ) {
135 + wp_send_json_error( [ 'message' => __( 'This product cannot be added to the cart.', 'b-blocks' ) ] );
931 136 }
932 137
933 - if ( true !== $result ) {
934 - wp_send_json_error( [ 'message' => self::takeErrors( $result ) ] );
138 + $added = WC()->cart->add_to_cart( $productId, $quantity );
139 +
140 + if ( ! $added ) {
141 + wp_send_json_error( [ 'message' => __( 'Could not add the product to the cart.', 'b-blocks' ) ] );
935 142 }
936 143
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 144 wp_send_json_success(
942 145 [
943 146 'added' => true,
944 147 'productName' => wp_strip_all_tags( $product->get_name() ),
@@ -943,217 +146,10 @@
943 146 'added' => true,
944 147 'productName' => wp_strip_all_tags( $product->get_name() ),
945 148 'cartCount' => WC()->cart->get_cart_contents_count(),
946 149 '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 150 ]
955 151 );
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 152 }
1157 153 }
1158 154
1159 155 new WooAddToCart();