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-mini-cart / WooMiniCart.php

WooMiniCart.php in bBlocks – Essential Gutenberg Blocks & Patterns Collection 2.1.7, at includes/blocks/woo-mini-cart/WooMiniCart.php

359 lines 12.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Woo Mini Cart — shared server logic.
4 *
5 * Provides attribute-sanitization helpers, a per-instance cart-fragment
6 * registration mechanism (hooked onto `woocommerce_add_to_cart_fragments`
7 * so WooCommerce's own cart-fragments script and the block's `view.js`
8 * fetch can swap the inner cart markup), and a hardened block-specific
9 * AJAX remove-item endpoint (`bb_wmc_remove_item`).
10 *
11 * Security model for `bb_wmc_remove_item`:
12 * - Nonce verified on every request via check_ajax_referer().
13 * - cart item key sanitized with sanitize_text_field() and validated
14 * against the live cart contents before removal.
15 * - All responses use wp_send_json_success / wp_send_json_error.
16 *
17 * @package bBlocks
18 */
19
20 namespace BBlocks\Inc\Blocks;
21
22 if ( ! defined( 'ABSPATH' ) ) {
23 exit;
24 }
25
26 class WooMiniCart {
27
28 /**
29 * Render configurations keyed by fragment selector, captured per request
30 * from render.php so the fragment callback can re-render each instance
31 * with the same options the visitor originally configured.
32 *
33 * @var array<string,array>
34 */
35 protected static $instances = [];
36
37 /**
38 * Whether the fragment filter has already been registered this request.
39 *
40 * @var bool
41 */
42 protected static $fragmentHooked = false;
43
44 /**
45 * Hook the AJAX remove endpoint (public + logged-in).
46 */
47 public function __construct() {
48 add_action( 'wp_ajax_bb_wmc_remove_item', [ $this, 'ajaxRemoveItem' ] );
49 add_action( 'wp_ajax_nopriv_bb_wmc_remove_item', [ $this, 'ajaxRemoveItem' ] );
50 }
51
52 /**
53 * Sanitize a CSS color value (hex, rgb/hsl, var(), or a CSS keyword).
54 *
55 * @param mixed $color Raw color.
56 * @param string $fallback Fallback when invalid.
57 * @return string
58 */
59 public static function sanitizeColor( $color, $fallback = '' ) {
60 $color = trim( (string) $color );
61 if ( '' === $color ) {
62 return $fallback;
63 }
64 if ( preg_match( '/^#([0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/', $color ) ) {
65 return $color;
66 }
67 if ( preg_match( '/^(rgb|rgba|hsl|hsla)\s*\([0-9\s,%.\/]+\)$/i', $color ) ) {
68 return $color;
69 }
70 if ( preg_match( '/^var\(\s*--[a-zA-Z0-9\-_]+\s*(,\s*[a-zA-Z0-9 #%.,\-_\/]+)?\s*\)$/', $color ) ) {
71 return $color;
72 }
73 if ( preg_match( '/^[a-zA-Z]{1,30}$/', $color ) ) {
74 return $color;
75 }
76 return $fallback;
77 }
78
79 /**
80 * Clamp a value to an integer range.
81 *
82 * @param mixed $value Raw value.
83 * @param int $min Minimum.
84 * @param int $max Maximum.
85 * @param int $fallback Fallback when non-numeric.
86 * @return int
87 */
88 public static function clampInt( $value, $min, $max, $fallback ) {
89 if ( ! is_numeric( $value ) ) {
90 return (int) $fallback;
91 }
92 $value = (int) $value;
93 if ( $value < $min ) {
94 return (int) $min;
95 }
96 if ( $value > $max ) {
97 return (int) $max;
98 }
99 return $value;
100 }
101
102 /**
103 * Sanitize a Font Awesome icon class string (allow letters, digits,
104 * spaces and hyphens only). Falls back when empty.
105 *
106 * @param mixed $value Raw icon class.
107 * @param string $fallback Fallback class.
108 * @return string
109 */
110 public static function sanitizeIconClass( $value, $fallback ) {
111 $value = trim( (string) $value );
112 $value = preg_replace( '/[^a-zA-Z0-9 _\-]/', '', $value );
113 return '' !== $value ? $value : $fallback;
114 }
115
116 /**
117 * Map a shadow preset keyword to a box-shadow value.
118 *
119 * @param string $preset Preset keyword.
120 * @return string
121 */
122 public static function shadowValue( $preset ) {
123 switch ( $preset ) {
124 case 'none':
125 return 'none';
126 case 'small':
127 return '0 1px 4px rgba(0,0,0,0.10)';
128 case 'large':
129 return '0 16px 48px rgba(0,0,0,0.22)';
130 case 'medium':
131 default:
132 return '0 8px 28px rgba(0,0,0,0.16)';
133 }
134 }
135
136 /**
137 * Register a block instance for fragment re-rendering.
138 *
139 * @param string $selector Fragment CSS selector (e.g. ".bb-wmc-<id>").
140 * @param array $config Render configuration for the instance.
141 */
142 public static function registerInstance( $selector, array $config ) {
143 self::$instances[ $selector ] = $config;
144
145 if ( ! self::$fragmentHooked ) {
146 self::$fragmentHooked = true;
147 add_filter( 'woocommerce_add_to_cart_fragments', [ __CLASS__, 'cartFragments' ] );
148 }
149 }
150
151 /**
152 * `woocommerce_add_to_cart_fragments` callback — re-render the inner
153 * markup for every registered instance and merge it into the fragments
154 * array so WooCommerce can swap it in place.
155 *
156 * @param array $fragments Existing fragments.
157 * @return array
158 */
159 public static function cartFragments( $fragments ) {
160 if ( ! is_array( $fragments ) ) {
161 $fragments = [];
162 }
163 foreach ( self::$instances as $selector => $config ) {
164 $fragments[ $selector ] = self::renderInner( $config );
165 }
166 return $fragments;
167 }
168
169 /**
170 * Render the inner cart state HTML for one instance.
171 *
172 * Outputs the trigger inner (icon, count badge, total) plus the dropdown
173 * panel (item list, subtotal, footer buttons). The element is wrapped in a
174 * `<div class="bb-wmc-inner bb-wmc-<id>">` so it matches the fragment
175 * selector and is what gets swapped on cart updates.
176 *
177 * @param array $c Instance configuration.
178 * @return string
179 */
180 public static function renderInner( array $c ) {
181 $selectorClass = ltrim( (string) ( $c['selector'] ?? '' ), '.' );
182
183 $cart = ( function_exists( 'WC' ) && WC()->cart ) ? WC()->cart : null;
184 $count = $cart ? (int) $cart->get_cart_contents_count() : 0;
185 $totalHtml = $cart ? $cart->get_cart_total() : '';
186 $cartItems = $cart ? $cart->get_cart() : [];
187 $isEmpty = $count < 1;
188 $isDropdown = 'dropdown' === ( $c['triggerMode'] ?? 'dropdown' );
189
190 $ariaLabel = sprintf(
191 /* translators: %d: number of items in the cart. */
192 _n( 'Shopping cart, %d item', 'Shopping cart, %d items', $count, 'b-blocks' ),
193 $count
194 );
195
196 // The fragment element. WooCommerce / view.js swap this whole node, so it
197 // carries the canonical cart state: the visible trigger badge/total plus
198 // (in dropdown mode) the dropdown body. The wrapper shell repositions the
199 // dropdown body with CSS — it is rendered as a sibling of the trigger
200 // content, never nested inside the trigger button.
201 ob_start();
202 ?>
203 <div
204 class="bb-wmc-inner <?php echo esc_attr( $selectorClass ); ?><?php echo $isEmpty ? ' bb-wmc--empty' : ''; ?>"
205 data-count="<?php echo esc_attr( (string) $count ); ?>"
206 >
207 <span class="bb-wmc-trigger-inner">
208 <i class="bb-wmc-icon <?php echo esc_attr( $c['iconClass'] ); ?>" aria-hidden="true"></i>
209 <?php if ( ! empty( $c['showCount'] ) ) : ?>
210 <span class="bb-wmc-badge" aria-hidden="true"><?php echo esc_html( (string) $count ); ?></span>
211 <?php endif; ?>
212 <?php if ( ! empty( $c['showTotal'] ) ) : ?>
213 <span class="bb-wmc-total-label"><?php echo wp_kses_post( $totalHtml ); ?></span>
214 <?php endif; ?>
215 <span class="bb-wmc-aria-text screen-reader-text"><?php echo esc_html( $ariaLabel ); ?></span>
216 </span>
217
218 <?php if ( $isDropdown ) : ?>
219 <?php echo self::renderDropdownBody( $c, $cartItems, $totalHtml, $isEmpty ); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Escaped within helper. ?>
220 <?php endif; ?>
221 </div>
222 <?php
223 return (string) ob_get_clean();
224 }
225
226 /**
227 * Render the dropdown body (item list, subtotal, footer buttons).
228 *
229 * @param array $c Instance configuration.
230 * @param array $cartItems Cart contents.
231 * @param string $totalHtml Cart total HTML.
232 * @param bool $isEmpty Whether the cart is empty.
233 * @return string
234 */
235 protected static function renderDropdownBody( array $c, array $cartItems, $totalHtml, $isEmpty ) {
236 $cartUrl = function_exists( 'wc_get_cart_url' ) ? wc_get_cart_url() : '';
237 $checkoutUrl = function_exists( 'wc_get_checkout_url' ) ? wc_get_checkout_url() : '';
238
239 ob_start();
240 ?>
241 <div class="bb-wmc-dropdown-body">
242 <?php if ( $isEmpty ) : ?>
243 <p class="bb-wmc-empty-message"><?php echo esc_html( $c['emptyMessage'] ); ?></p>
244 <?php else : ?>
245 <?php if ( ! empty( $c['showItemList'] ) ) : ?>
246 <ul class="bb-wmc-items">
247 <?php foreach ( $cartItems as $cartItemKey => $cartItem ) :
248 $product = isset( $cartItem['data'] ) ? $cartItem['data'] : null;
249 if ( ! $product || ! is_a( $product, 'WC_Product' ) || ! apply_filters( 'woocommerce_widget_cart_item_visible', true, $cartItem, $cartItemKey ) ) {
250 continue;
251 }
252 $productName = $product->get_name();
253 $quantity = (int) $cartItem['quantity'];
254 $linePrice = function_exists( 'WC' ) ? WC()->cart->get_product_subtotal( $product, $quantity ) : '';
255 $removeUrl = function_exists( 'wc_get_cart_remove_url' ) ? wc_get_cart_remove_url( $cartItemKey ) : '#';
256 $removeLabel = sprintf(
257 /* translators: %s: product name. */
258 __( 'Remove %s from cart', 'b-blocks' ),
259 wp_strip_all_tags( $productName )
260 );
261 ?>
262 <li class="bb-wmc-item" data-key="<?php echo esc_attr( $cartItemKey ); ?>">
263 <span class="bb-wmc-item-info">
264 <span class="bb-wmc-item-name"><?php echo esc_html( $productName ); ?></span>
265 <span class="bb-wmc-item-meta">
266 <span class="bb-wmc-item-qty"><?php echo esc_html( sprintf( '× %d', $quantity ) ); ?></span>
267 <?php if ( ! empty( $c['showItemPrice'] ) ) : ?>
268 <span class="bb-wmc-item-price"><?php echo wp_kses_post( $linePrice ); ?></span>
269 <?php endif; ?>
270 </span>
271 </span>
272 <a
273 class="bb-wmc-remove"
274 href="<?php echo esc_url( $removeUrl ); ?>"
275 data-key="<?php echo esc_attr( $cartItemKey ); ?>"
276 aria-label="<?php echo esc_attr( $removeLabel ); ?>"
277 >
278 <span aria-hidden="true">&times;</span>
279 </a>
280 </li>
281 <?php endforeach; ?>
282 </ul>
283 <?php endif; ?>
284
285 <?php if ( ! empty( $c['showSubtotal'] ) ) : ?>
286 <div class="bb-wmc-subtotal">
287 <span class="bb-wmc-subtotal-label"><?php echo esc_html__( 'Subtotal', 'b-blocks' ); ?></span>
288 <span class="bb-wmc-subtotal-value"><?php echo wp_kses_post( $totalHtml ); ?></span>
289 </div>
290 <?php endif; ?>
291 <?php endif; ?>
292
293 <?php if ( ! $isEmpty && ( ! empty( $c['showViewCartBtn'] ) || ! empty( $c['showCheckoutBtn'] ) ) ) : ?>
294 <div class="bb-wmc-footer">
295 <?php if ( ! empty( $c['showViewCartBtn'] ) && $cartUrl ) : ?>
296 <a class="bb-wmc-btn bb-wmc-btn--secondary" href="<?php echo esc_url( $cartUrl ); ?>">
297 <?php echo esc_html( $c['viewCartLabel'] ); ?>
298 </a>
299 <?php endif; ?>
300 <?php if ( ! empty( $c['showCheckoutBtn'] ) && $checkoutUrl ) : ?>
301 <a class="bb-wmc-btn bb-wmc-btn--primary" href="<?php echo esc_url( $checkoutUrl ); ?>">
302 <?php echo esc_html( $c['checkoutLabel'] ); ?>
303 </a>
304 <?php endif; ?>
305 </div>
306 <?php endif; ?>
307 </div>
308 <?php
309 return (string) ob_get_clean();
310 }
311
312 /**
313 * Handle the `bb_wmc_remove_item` AJAX request.
314 *
315 * Removes a single cart item by its key and responds with the refreshed
316 * cart fragments so the caller can swap the inner markup without a reload.
317 */
318 public function ajaxRemoveItem() {
319 check_ajax_referer( 'bb_wmc_remove_item', 'nonce' );
320
321 if ( ! function_exists( 'WC' ) || ! WC()->cart ) {
322 wp_send_json_error( [ 'message' => __( 'WooCommerce is not available.', 'b-blocks' ) ] );
323 }
324
325 $cartItemKey = isset( $_POST['cart_item_key'] ) ? sanitize_text_field( wp_unslash( $_POST['cart_item_key'] ) ) : '';
326 // WooCommerce cart item keys are 32-character lowercase MD5 hex strings.
327 // Reject anything that does not match that format before touching the cart.
328 if ( '' === $cartItemKey || ! preg_match( '/^[0-9a-f]{32}$/', $cartItemKey ) ) {
329 wp_send_json_error( [ 'message' => __( 'Invalid cart item.', 'b-blocks' ) ] );
330 }
331
332 $cart = WC()->cart;
333 if ( ! $cart->get_cart_item( $cartItemKey ) ) {
334 wp_send_json_error( [ 'message' => __( 'Cart item not found.', 'b-blocks' ) ] );
335 }
336
337 $removed = $cart->remove_cart_item( $cartItemKey );
338 if ( ! $removed ) {
339 wp_send_json_error( [ 'message' => __( 'Could not remove the item.', 'b-blocks' ) ] );
340 }
341
342 $cart->calculate_totals();
343
344 // Re-run WooCommerce's fragment filter so the client receives fresh markup.
345 $fragments = apply_filters( 'woocommerce_add_to_cart_fragments', [] );
346
347 wp_send_json_success(
348 [
349 'removed' => true,
350 'cartCount' => (int) $cart->get_cart_contents_count(),
351 'fragments' => $fragments,
352 'cart_hash' => $cart->get_cart_hash(),
353 ]
354 );
355 }
356 }
357
358 new WooMiniCart();
359