PluginProbe
bBlocks – Essential Gutenberg Blocks & Patterns Collection / 2.1.7
bBlocks – Essential Gutenberg Blocks & Patterns Collection v2.1.7
2.1.7 2.1.6 2.1.5 2.1.4 2.1.3 2.1.2 2.1.1 2.1.0 2.0.43 2.0.42 2.0.41 2.0.40 2.0.39 2.0.38 trunk 1.0 1.1 1.2 1.3 1.4 1.5 1.5.1 1.5.2 1.5.3 1.5.4 All 107 releases
b-blocks / includes / blocks / woo-product-carousel / WooProductCarousel.php

WooProductCarousel.php in bBlocks – Essential Gutenberg Blocks & Patterns Collection 2.1.7, at includes/blocks/woo-product-carousel/WooProductCarousel.php

572 lines 20.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Woo Product Carousel — shared server logic.
4 *
5 * Provides attribute sanitization, WooCommerce query building (including
6 * featured / on-sale / best-selling / top-rated product types), product-card
7 * markup rendering, the carousel JSON config, and a hardened add-to-cart AJAX
8 * endpoint (`bb_wpc_add_to_cart`) used by the frontend `view.js` for simple
9 * products.
10 *
11 * Security model for `bb_wpc_add_to_cart`:
12 * - Nonce verified on every request via check_ajax_referer().
13 * - product_id sanitized with absint() and validated against wc_get_product().
14 * - Only purchasable, in-stock, simple/non-variable products are added.
15 * - All responses use wp_send_json_success / wp_send_json_error.
16 *
17 * All card output is escaped (esc_html / esc_url / esc_attr / wp_kses_post).
18 *
19 * @package bBlocks
20 */
21
22 namespace BBlocks\Inc\Blocks;
23
24 if ( ! defined( 'ABSPATH' ) ) {
25 exit;
26 }
27
28 class WooProductCarousel {
29
30 /**
31 * Allowed orderby values (carousel-specific, includes price-desc).
32 *
33 * @var string[]
34 */
35 const ORDERBY = [ 'date', 'popularity', 'rating', 'price', 'price-desc', 'title', 'rand' ];
36
37 /**
38 * Allowed order values (uppercased).
39 *
40 * @var string[]
41 */
42 const ORDER = [ 'ASC', 'DESC' ];
43
44 /**
45 * Allowed product types.
46 *
47 * @var string[]
48 */
49 const TYPES = [ 'all', 'featured', 'on_sale', 'best_selling', 'top_rated' ];
50
51 /**
52 * Allowed aspect ratios.
53 *
54 * @var string[]
55 */
56 const RATIOS = [ '3/4', '1/1', '4/3', '16/9' ];
57
58 /**
59 * Hook the AJAX endpoint (public + logged-in).
60 */
61 public function __construct() {
62 add_action( 'wp_ajax_bb_wpc_add_to_cart', [ $this, 'ajaxAddToCart' ] );
63 add_action( 'wp_ajax_nopriv_bb_wpc_add_to_cart', [ $this, 'ajaxAddToCart' ] );
64 }
65
66 /* ----------------------------------------------------------------------
67 * Sanitizers
68 * ------------------------------------------------------------------- */
69
70 /**
71 * Sanitize a CSS color value (hex, rgb/hsl, var(), or a CSS keyword).
72 *
73 * @param mixed $color Raw color.
74 * @param string $fallback Fallback when invalid.
75 * @return string
76 */
77 public static function sanitizeColor( $color, $fallback = '' ) {
78 $color = trim( (string) $color );
79 if ( '' === $color ) {
80 return $fallback;
81 }
82 if ( preg_match( '/^#([0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/', $color ) ) {
83 return $color;
84 }
85 if ( preg_match( '/^(rgb|rgba|hsl|hsla)\s*\([0-9\s,%.\/]+\)$/i', $color ) ) {
86 return $color;
87 }
88 if ( preg_match( '/^var\(\s*--[a-zA-Z0-9\-_]+\s*(,\s*[a-zA-Z0-9 #%.,\-_\/]+)?\s*\)$/', $color ) ) {
89 return $color;
90 }
91 if ( preg_match( '/^[a-zA-Z]{1,30}$/', $color ) ) {
92 return $color;
93 }
94 return $fallback;
95 }
96
97 /**
98 * Clamp a value to an integer range.
99 *
100 * @param mixed $value Raw value.
101 * @param int $min Minimum.
102 * @param int $max Maximum.
103 * @param int $fallback Fallback when non-numeric.
104 * @return int
105 */
106 public static function clampInt( $value, $min, $max, $fallback ) {
107 if ( ! is_numeric( $value ) ) {
108 return (int) $fallback;
109 }
110 $value = (int) $value;
111 if ( $value < $min ) {
112 return (int) $min;
113 }
114 if ( $value > $max ) {
115 return (int) $max;
116 }
117 return $value;
118 }
119
120 /**
121 * Pick a value from an allowlist.
122 *
123 * @param mixed $value Raw value.
124 * @param string[] $allowed Allowed values.
125 * @param string $fallback Fallback.
126 * @return string
127 */
128 public static function pickFrom( $value, array $allowed, $fallback ) {
129 $value = is_string( $value ) ? trim( $value ) : '';
130 return in_array( $value, $allowed, true ) ? $value : $fallback;
131 }
132
133 /**
134 * Sanitize an array of positive integer IDs.
135 *
136 * @param mixed $value Raw array.
137 * @return int[]
138 */
139 public static function intArray( $value ) {
140 if ( ! is_array( $value ) ) {
141 return [];
142 }
143 $out = [];
144 foreach ( $value as $item ) {
145 $id = absint( $item );
146 if ( $id > 0 ) {
147 $out[] = $id;
148 }
149 }
150 return array_values( array_unique( $out ) );
151 }
152
153 /**
154 * Normalize and sanitize the full attribute set into a safe, typed array.
155 *
156 * @param array $attributes Raw block attributes.
157 * @return array
158 */
159 public static function resolveAttributes( array $attributes ) {
160 $spv = (array) ( $attributes['slidesPerView'] ?? [] );
161 $titleFont = (array) ( $attributes['titleFontSize'] ?? [] );
162
163 $saleBadgeLabel = isset( $attributes['saleBadgeLabel'] ) ? wp_strip_all_tags( (string) $attributes['saleBadgeLabel'] ) : '';
164 $saleBadgeLabel = '' !== trim( $saleBadgeLabel ) ? $saleBadgeLabel : __( 'Sale', 'b-blocks' );
165
166 $addToCartLabel = isset( $attributes['addToCartLabel'] ) ? wp_strip_all_tags( (string) $attributes['addToCartLabel'] ) : '';
167 $addToCartLabel = '' !== trim( $addToCartLabel ) ? $addToCartLabel : __( 'Add to Cart', 'b-blocks' );
168
169 $noProductsMessage = isset( $attributes['noProductsMessage'] ) ? wp_strip_all_tags( (string) $attributes['noProductsMessage'] ) : '';
170 $noProductsMessage = '' !== trim( $noProductsMessage ) ? $noProductsMessage : __( 'No products found.', 'b-blocks' );
171
172 $allowedWeights = [ 400, 500, 600, 700, 800 ];
173 $titleWeight = (int) ( $attributes['titleFontWeight'] ?? 600 );
174 if ( ! in_array( $titleWeight, $allowedWeights, true ) ) {
175 $titleWeight = 600;
176 }
177
178 return [
179 'productsToShow' => self::clampInt( $attributes['productsToShow'] ?? 8, 1, 24, 8 ),
180 'orderBy' => self::pickFrom( $attributes['orderBy'] ?? 'date', self::ORDERBY, 'date' ),
181 'order' => self::pickFrom( strtoupper( (string) ( $attributes['order'] ?? 'desc' ) ), self::ORDER, 'DESC' ),
182 'productType' => self::pickFrom( $attributes['productType'] ?? 'all', self::TYPES, 'all' ),
183 'productCategories' => self::intArray( $attributes['productCategories'] ?? [] ),
184 'productTags' => self::intArray( $attributes['productTags'] ?? [] ),
185 'excludeOutOfStock' => ! isset( $attributes['excludeOutOfStock'] ) || (bool) $attributes['excludeOutOfStock'],
186
187 'slidesDesktop' => self::clampInt( $spv['desktop'] ?? 3, 1, 6, 3 ),
188 'slidesTablet' => self::clampInt( $spv['tablet'] ?? 2, 1, 4, 2 ),
189 'slidesMobile' => self::clampInt( $spv['mobile'] ?? 1, 1, 2, 1 ),
190 'slideGap' => self::clampInt( $attributes['slideGap'] ?? 24, 0, 60, 24 ),
191 'isLoop' => ! isset( $attributes['isLoop'] ) || (bool) $attributes['isLoop'],
192 'isAutoplay' => ! isset( $attributes['isAutoplay'] ) || (bool) $attributes['isAutoplay'],
193 'autoplayDelay' => self::clampInt( $attributes['autoplayDelay'] ?? 3000, 1000, 10000, 3000 ),
194 'autoplayPauseOnHover' => ! isset( $attributes['autoplayPauseOnHover'] ) || (bool) $attributes['autoplayPauseOnHover'],
195 'transitionSpeed' => self::clampInt( $attributes['transitionSpeed'] ?? 500, 100, 2000, 500 ),
196 'isArrows' => ! isset( $attributes['isArrows'] ) || (bool) $attributes['isArrows'],
197 'isDots' => ! isset( $attributes['isDots'] ) || (bool) $attributes['isDots'],
198
199 'showImage' => ! isset( $attributes['showImage'] ) || (bool) $attributes['showImage'],
200 'imageRatio' => self::pickFrom( $attributes['imageRatio'] ?? '3/4', self::RATIOS, '3/4' ),
201 'showTitle' => ! isset( $attributes['showTitle'] ) || (bool) $attributes['showTitle'],
202 'showPrice' => ! isset( $attributes['showPrice'] ) || (bool) $attributes['showPrice'],
203 'showRating' => ! isset( $attributes['showRating'] ) || (bool) $attributes['showRating'],
204 'showSaleBadge' => ! isset( $attributes['showSaleBadge'] ) || (bool) $attributes['showSaleBadge'],
205 'saleBadgeLabel' => $saleBadgeLabel,
206 'showAddToCart' => ! isset( $attributes['showAddToCart'] ) || (bool) $attributes['showAddToCart'],
207 'addToCartLabel' => $addToCartLabel,
208 'contentAlign' => self::pickFrom( $attributes['contentAlign'] ?? 'left', [ 'left', 'center' ], 'left' ),
209
210 'cardBG' => self::sanitizeColor( $attributes['cardBG'] ?? '', '#ffffff' ),
211 'cardBorderWidth' => self::clampInt( $attributes['cardBorderWidth'] ?? 1, 0, 8, 1 ),
212 'cardBorderColor' => self::sanitizeColor( $attributes['cardBorderColor'] ?? '', '#e2e8f0' ),
213 'cardRadius' => self::clampInt( $attributes['cardRadius'] ?? 8, 0, 48, 8 ),
214 'cardShadow' => self::pickFrom( $attributes['cardShadow'] ?? 'none', [ 'none', 'sm', 'md', 'lg' ], 'none' ),
215 'cardPadding' => self::clampInt( $attributes['cardPadding'] ?? 16, 0, 48, 16 ),
216
217 'titleColor' => self::sanitizeColor( $attributes['titleColor'] ?? '', 'inherit' ),
218 'titleWeight' => $titleWeight,
219 'priceColor' => self::sanitizeColor( $attributes['priceColor'] ?? '', '#e44d3a' ),
220 'regularPriceColor' => self::sanitizeColor( $attributes['regularPriceColor'] ?? '', '#999999' ),
221 'ratingColor' => self::sanitizeColor( $attributes['ratingColor'] ?? '', '#f5a623' ),
222 'badgeBG' => self::sanitizeColor( $attributes['badgeBG'] ?? '', '#e44d3a' ),
223 'badgeTextColor' => self::sanitizeColor( $attributes['badgeTextColor'] ?? '', '#ffffff' ),
224 'btnColor' => self::sanitizeColor( $attributes['btnColor'] ?? '', '#ffffff' ),
225 'btnBG' => self::sanitizeColor( $attributes['btnBG'] ?? '', '#146EF5' ),
226 'btnHoverColor' => self::sanitizeColor( $attributes['btnHoverColor'] ?? '', '#ffffff' ),
227 'btnHoverBG' => self::sanitizeColor( $attributes['btnHoverBG'] ?? '', '#070127' ),
228 'btnRadius' => self::clampInt( $attributes['btnRadius'] ?? 4, 0, 48, 4 ),
229
230 'arrowColor' => self::sanitizeColor( $attributes['arrowColor'] ?? '', '#146EF5' ),
231 'arrowBgColor' => self::sanitizeColor( $attributes['arrowBgColor'] ?? '', '#ffffff' ),
232 'arrowBorderRadius' => self::clampInt( $attributes['arrowBorderRadius'] ?? 50, 0, 50, 50 ),
233 'dotColor' => self::sanitizeColor( $attributes['dotColor'] ?? '', '#146EF5' ),
234 'dotInactiveColor' => self::sanitizeColor( $attributes['dotInactiveColor'] ?? '', '#cccccc' ),
235
236 'titleSizeDesktop' => self::clampInt( preg_replace( '/[^0-9]/', '', (string) ( $titleFont['desktop'] ?? '17' ) ), 12, 40, 17 ),
237 'titleSizeTablet' => self::clampInt( preg_replace( '/[^0-9]/', '', (string) ( $titleFont['tablet'] ?? '16' ) ), 12, 36, 16 ),
238 'titleSizeMobile' => self::clampInt( preg_replace( '/[^0-9]/', '', (string) ( $titleFont['mobile'] ?? '15' ) ), 12, 32, 15 ),
239
240 'noProductsMessage' => $noProductsMessage,
241 ];
242 }
243
244 /* ----------------------------------------------------------------------
245 * Query
246 * ------------------------------------------------------------------- */
247
248 /**
249 * Build sanitized wc_get_products() args.
250 *
251 * @param array $a Resolved attributes.
252 * @return array
253 */
254 public static function buildQueryArgs( array $a ) {
255 $orderByMap = [
256 'date' => [ 'orderby' => 'date', 'order' => $a['order'] ],
257 'title' => [ 'orderby' => 'title', 'order' => $a['order'] ],
258 'rating' => [ 'orderby' => 'rating', 'order' => $a['order'] ],
259 'popularity' => [ 'orderby' => 'popularity', 'order' => $a['order'] ],
260 'price' => [ 'orderby' => 'price', 'order' => 'ASC' ],
261 'price-desc' => [ 'orderby' => 'price', 'order' => 'DESC' ],
262 'rand' => [ 'orderby' => 'rand', 'order' => $a['order'] ],
263 ];
264 $mapped = $orderByMap[ $a['orderBy'] ] ?? $orderByMap['date'];
265
266 $args = [
267 'status' => 'publish',
268 'limit' => $a['productsToShow'],
269 'orderby' => $mapped['orderby'],
270 'order' => $mapped['order'],
271 'paginate' => false,
272 'return' => 'objects',
273 ];
274
275 if ( $a['excludeOutOfStock'] ) {
276 $args['stock_status'] = 'instock';
277 }
278
279 if ( ! empty( $a['productCategories'] ) ) {
280 $args['category'] = self::termIdsToSlugs( $a['productCategories'], 'product_cat' );
281 }
282
283 if ( ! empty( $a['productTags'] ) ) {
284 $args['tag'] = self::termIdsToSlugs( $a['productTags'], 'product_tag' );
285 }
286
287 switch ( $a['productType'] ) {
288 case 'featured':
289 $args['featured'] = true;
290 break;
291
292 case 'on_sale':
293 if ( function_exists( 'wc_get_product_ids_on_sale' ) ) {
294 $onSale = wc_get_product_ids_on_sale();
295 $args['include'] = ! empty( $onSale ) ? $onSale : [ 0 ];
296 }
297 break;
298
299 case 'best_selling':
300 $args['orderby'] = 'meta_value_num';
301 $args['meta_key'] = 'total_sales'; // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
302 $args['order'] = 'DESC';
303 break;
304
305 case 'top_rated':
306 $args['orderby'] = 'meta_value_num';
307 $args['meta_key'] = '_wc_average_rating'; // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_key
308 $args['order'] = 'DESC';
309 break;
310 }
311
312 return $args;
313 }
314
315 /**
316 * Convert term IDs to slugs for a taxonomy (wc_get_products expects slugs).
317 *
318 * @param int[] $ids Term IDs.
319 * @param string $taxonomy Taxonomy.
320 * @return string[]
321 */
322 protected static function termIdsToSlugs( array $ids, $taxonomy ) {
323 $slugs = [];
324 foreach ( $ids as $id ) {
325 $term = get_term( (int) $id, $taxonomy );
326 if ( $term && ! is_wp_error( $term ) ) {
327 $slugs[] = $term->slug;
328 }
329 }
330 return $slugs;
331 }
332
333 /* ----------------------------------------------------------------------
334 * Carousel config
335 * ------------------------------------------------------------------- */
336
337 /**
338 * Build the JSON config consumed by view.js.
339 *
340 * @param array $a Resolved attributes.
341 * @return string JSON string.
342 */
343 public static function buildConfig( array $a ) {
344 $config = [
345 'slidesPerView' => [
346 'desktop' => $a['slidesDesktop'],
347 'tablet' => $a['slidesTablet'],
348 'mobile' => $a['slidesMobile'],
349 ],
350 'loop' => $a['isLoop'],
351 'autoplay' => $a['isAutoplay'],
352 'autoplayDelay' => $a['autoplayDelay'],
353 'pauseOnHover' => $a['autoplayPauseOnHover'],
354 'transitionSpeed' => $a['transitionSpeed'],
355 'arrows' => $a['isArrows'],
356 'dots' => $a['isDots'],
357 ];
358 return wp_json_encode( $config );
359 }
360
361 /* ----------------------------------------------------------------------
362 * Card rendering
363 * ------------------------------------------------------------------- */
364
365 /**
366 * Render the product cards as escaped slide <li> markup.
367 *
368 * @param \WC_Product[] $products Products.
369 * @param array $a Resolved attributes.
370 * @return string Escaped HTML.
371 */
372 public static function renderCards( array $products, array $a ) {
373 ob_start();
374
375 $total = count( $products );
376 $index = 0;
377
378 foreach ( $products as $product ) :
379 if ( ! is_a( $product, 'WC_Product' ) ) {
380 continue;
381 }
382 $index++;
383
384 $productId = $product->get_id();
385 $titleText = $product->get_name();
386 $permalink = get_permalink( $productId );
387 $isOnSale = $product->is_on_sale();
388 $isSimple = $product->is_type( 'simple' );
389 $canAjax = $isSimple && $product->is_purchasable() && $product->is_in_stock();
390
391 // Image.
392 $imageUrl = '';
393 $imageAlt = $titleText;
394 if ( $a['showImage'] ) {
395 $thumbId = $product->get_image_id();
396 if ( $thumbId ) {
397 $src = wp_get_attachment_image_url( $thumbId, 'woocommerce_thumbnail' );
398 if ( $src ) {
399 $imageUrl = $src;
400 $metaAlt = get_post_meta( $thumbId, '_wp_attachment_image_alt', true );
401 if ( is_string( $metaAlt ) && '' !== trim( $metaAlt ) ) {
402 $imageAlt = trim( wp_strip_all_tags( $metaAlt ) );
403 }
404 }
405 }
406 if ( '' === $imageUrl && function_exists( 'wc_placeholder_img_src' ) ) {
407 $imageUrl = wc_placeholder_img_src( 'woocommerce_thumbnail' );
408 }
409 }
410
411 // Rating.
412 $ratingValue = (float) $product->get_average_rating();
413 $ratingCount = (int) $product->get_rating_count();
414
415 // Add-to-cart label.
416 $cartLabel = $a['addToCartLabel'];
417 if ( ! $isSimple ) {
418 $wcLabel = $product->add_to_cart_text();
419 if ( is_string( $wcLabel ) && '' !== trim( $wcLabel ) ) {
420 $cartLabel = wp_strip_all_tags( $wcLabel );
421 }
422 }
423
424 $slideLabel = sprintf(
425 /* translators: 1: current slide number, 2: total slides. */
426 __( '%1$d of %2$d', 'b-blocks' ),
427 $index,
428 $total
429 );
430 ?>
431 <li class='bb-wpc-slide' role='group' aria-roledescription='<?php echo esc_attr__( 'slide', 'b-blocks' ); ?>' aria-label='<?php echo esc_attr( $slideLabel ); ?>'>
432 <article class='bb-wpc-card' aria-label='<?php echo esc_attr( $titleText ); ?>'>
433 <?php if ( $a['showImage'] && '' !== $imageUrl ) : ?>
434 <a class='bb-wpc-image-link' href='<?php echo esc_url( $permalink ); ?>' tabindex='-1' aria-hidden='true'>
435 <?php if ( $a['showSaleBadge'] && $isOnSale ) : ?>
436 <span class='bb-wpc-sale-badge' role='img' aria-label='<?php echo esc_attr__( 'On sale', 'b-blocks' ); ?>'>
437 <?php echo esc_html( $a['saleBadgeLabel'] ); ?>
438 </span>
439 <?php endif; ?>
440 <img class='bb-wpc-image' src='<?php echo esc_url( $imageUrl ); ?>' alt='<?php echo esc_attr( $imageAlt ); ?>' loading='lazy' decoding='async' />
441 </a>
442 <?php elseif ( $a['showSaleBadge'] && $isOnSale ) : ?>
443 <span class='bb-wpc-sale-badge bb-wpc-sale-badge--noimg' role='img' aria-label='<?php echo esc_attr__( 'On sale', 'b-blocks' ); ?>'>
444 <?php echo esc_html( $a['saleBadgeLabel'] ); ?>
445 </span>
446 <?php endif; ?>
447
448 <div class='bb-wpc-card-body'>
449 <?php if ( $a['showTitle'] && '' !== $titleText ) : ?>
450 <h3 class='bb-wpc-title'>
451 <a class='bb-wpc-title-link' href='<?php echo esc_url( $permalink ); ?>'>
452 <?php echo esc_html( $titleText ); ?>
453 </a>
454 </h3>
455 <?php endif; ?>
456
457 <?php if ( $a['showRating'] && $ratingCount > 0 ) : ?>
458 <?php
459 $roundedRating = round( $ratingValue * 2 ) / 2;
460 $ratingLabel = sprintf(
461 /* translators: %s: rating value out of 5. */
462 __( 'Rated %s out of 5 stars', 'b-blocks' ),
463 number_format_i18n( $ratingValue, 1 )
464 );
465 ?>
466 <span class='bb-wpc-rating' role='img' aria-label='<?php echo esc_attr( $ratingLabel ); ?>'>
467 <?php
468 for ( $i = 1; $i <= 5; $i++ ) {
469 $starClass = 'bb-wpc-star';
470 if ( $roundedRating >= $i ) {
471 $starClass .= ' is-full';
472 } elseif ( $roundedRating >= ( $i - 0.5 ) ) {
473 $starClass .= ' is-half';
474 }
475 echo '<span class="' . esc_attr( $starClass ) . '" aria-hidden="true">�
476 </span>';
477 }
478 ?>
479 </span>
480 <?php endif; ?>
481
482 <?php if ( $a['showPrice'] ) : ?>
483 <div class='bb-wpc-price'>
484 <?php echo wp_kses_post( $product->get_price_html() ); ?>
485 </div>
486 <?php endif; ?>
487
488 <?php
489 if ( $a['showAddToCart'] ) :
490 $cartAria = sprintf(
491 /* translators: %s: product name. */
492 __( 'Add %s to cart', 'b-blocks' ),
493 $titleText
494 );
495 if ( $canAjax ) :
496 ?>
497 <button
498 type='button'
499 class='bb-wpc-atc-btn'
500 data-product-id='<?php echo esc_attr( (string) $productId ); ?>'
501 aria-label='<?php echo esc_attr( $cartAria ); ?>'
502 >
503 <span class='bb-wpc-atc-label'><?php echo esc_html( $cartLabel ); ?></span>
504 <span class='bb-wpc-atc-added' aria-hidden='true'><?php echo esc_html__( 'Added', 'b-blocks' ); ?></span>
505 </button>
506 <?php else : ?>
507 <a
508 class='bb-wpc-atc-btn bb-wpc-atc-btn--link'
509 href='<?php echo esc_url( $permalink ); ?>'
510 aria-label='<?php echo esc_attr( $cartAria ); ?>'
511 >
512 <span class='bb-wpc-atc-label'><?php echo esc_html( $cartLabel ); ?></span>
513 </a>
514 <?php endif; ?>
515 <?php endif; ?>
516 </div>
517 </article>
518 </li>
519 <?php
520 endforeach;
521
522 return ob_get_clean();
523 }
524
525 /* ----------------------------------------------------------------------
526 * AJAX endpoint
527 * ------------------------------------------------------------------- */
528
529 /**
530 * Handle the `bb_wpc_add_to_cart` AJAX request for simple products.
531 *
532 * Returns JSON: { added: true, productName } or an error.
533 */
534 public function ajaxAddToCart() {
535 check_ajax_referer( 'bb_wpc_add_to_cart', 'nonce' );
536
537 if ( ! function_exists( 'WC' ) || ! WC()->cart ) {
538 wp_send_json_error( [ 'message' => __( 'WooCommerce is not available.', 'b-blocks' ) ] );
539 }
540
541 $productId = isset( $_POST['product_id'] ) ? absint( wp_unslash( $_POST['product_id'] ) ) : 0;
542 if ( $productId < 1 ) {
543 wp_send_json_error( [ 'message' => __( 'Invalid product.', 'b-blocks' ) ] );
544 }
545
546 $product = wc_get_product( $productId );
547 if ( ! $product || ! is_a( $product, 'WC_Product' ) ) {
548 wp_send_json_error( [ 'message' => __( 'Product not found.', 'b-blocks' ) ] );
549 }
550
551 if ( ! $product->is_type( 'simple' ) || ! $product->is_purchasable() || ! $product->is_in_stock() ) {
552 wp_send_json_error( [ 'message' => __( 'This product cannot be added to the cart.', 'b-blocks' ) ] );
553 }
554
555 $added = WC()->cart->add_to_cart( $productId, 1 );
556
557 if ( ! $added ) {
558 wp_send_json_error( [ 'message' => __( 'Could not add the product to the cart.', 'b-blocks' ) ] );
559 }
560
561 wp_send_json_success(
562 [
563 'added' => true,
564 'productName' => wp_strip_all_tags( $product->get_name() ),
565 'cartCount' => WC()->cart->get_cart_contents_count(),
566 ]
567 );
568 }
569 }
570
571 new WooProductCarousel();
572