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

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

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