PluginProbe
ShopBuilder – WooCommerce Builder For Elementor / 3.4.2
ShopBuilder – WooCommerce Builder For Elementor v3.4.2
3.4.2 3.4.1 3.4.0 2.0.1 2.0.2 2.0.3 2.1.0 2.1.1 2.1.10 2.1.11 2.1.12 2.1.13 2.1.14 2.1.15 2.1.2 2.1.3 2.1.4 2.1.5 2.1.6 2.1.7 2.1.8 2.1.9 2.2.0 2.2.1 2.2.2 All 63 releases
shopbuilder / app / Controllers / Frontend / OrderReviewQuantity.php

OrderReviewQuantity.php in ShopBuilder – WooCommerce Builder For Elementor 3.4.2, at app/Controllers/Frontend/OrderReviewQuantity.php

493 lines 13.1 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Order Review Quantity Controller.
4 *
5 * Renders an editable quantity selector inside the checkout "Order Review"
6 * table and applies the posted quantities to the cart while WooCommerce
7 * refreshes the review fragment.
8 *
9 * @package RadiusTheme\SB
10 */
11
12 namespace RadiusTheme\SB\Controllers\Frontend;
13
14 use WC_Product;
15 use RadiusTheme\SB\Helpers\Fns;
16 use RadiusTheme\SB\Traits\SingletonTrait;
17
18 // Do not allow directly accessing this file.
19 if ( ! defined( 'ABSPATH' ) ) {
20 exit( 'This script cannot be accessed directly.' );
21 }
22
23 /**
24 * Order Review Quantity Controller.
25 */
26 class OrderReviewQuantity {
27 /**
28 * Singleton.
29 */
30 use SingletonTrait;
31
32 /**
33 * Hidden marker field printed by the Order Review widget.
34 *
35 * Tells the update_order_review request that the quantity selector is
36 * enabled, because the Elementor widget does not render on that request.
37 *
38 * @var string
39 */
40 const MARKER_FIELD = 'rtsb_order_review_qty';
41
42 /**
43 * Quantity input name, keyed by cart item key.
44 *
45 * @var string
46 */
47 const QUANTITY_FIELD = 'rtsb_review_qty';
48
49 /**
50 * Wrapper class discount modules use for their cart item summary.
51 *
52 * @var string
53 */
54 const SUMMARY_CLASS = 'rtsb-dicount-summery';
55
56 /**
57 * Whether the quantity selector is enabled for this request.
58 *
59 * @var bool
60 */
61 private $enabled = false;
62
63 /**
64 * Cart item key whose item data has already been printed by this class.
65 *
66 * Used once, to keep the review template from repeating the meta list.
67 *
68 * @var string
69 */
70 private $printed_item_data = '';
71
72 /**
73 * Whether the review order table is currently being rendered.
74 *
75 * @var bool
76 */
77 private $in_review_table = false;
78
79 /**
80 * Discount summaries pulled out of the product name, by cart item key.
81 *
82 * @var array
83 */
84 private $discount_summary = [];
85
86 /**
87 * Class Constructor.
88 *
89 * @return void
90 */
91 private function __construct() {
92 // Runs before WooCommerce recalculates shipping and totals for the fragment.
93 add_action( 'woocommerce_checkout_update_order_review', [ $this, 'handle_order_review_update' ], 5 );
94 }
95
96 /**
97 * Enables the quantity selector for the current request.
98 *
99 * @return void
100 */
101 public function enable() {
102 if ( $this->enabled ) {
103 return;
104 }
105
106 $this->enabled = true;
107
108 add_filter( 'woocommerce_checkout_cart_item_quantity', [ $this, 'quantity_field' ], 10, 3 );
109 add_filter( 'woocommerce_get_item_data', [ $this, 'maybe_skip_item_data' ], PHP_INT_MAX, 2 );
110
111 // Scope the product name filter to the review table: the same filter also
112 // feeds the cart page and the mini cart, which must stay untouched.
113 add_action( 'woocommerce_review_order_before_cart_contents', [ $this, 'open_review_table' ] );
114 add_action( 'woocommerce_review_order_after_cart_contents', [ $this, 'close_review_table' ] );
115 add_filter( 'woocommerce_cart_item_name', [ $this, 'extract_discount_summary' ], PHP_INT_MAX, 3 );
116 }
117
118 /**
119 * Marks the start of the review order table.
120 *
121 * @return void
122 */
123 public function open_review_table() {
124 $this->in_review_table = true;
125 }
126
127 /**
128 * Marks the end of the review order table.
129 *
130 * @return void
131 */
132 public function close_review_table() {
133 $this->in_review_table = false;
134 $this->discount_summary = [];
135 }
136
137 /**
138 * Takes the discount summary out of the product name.
139 *
140 * Discount modules append their summary to the name, which pushes the "x 2"
141 * counter onto the next line. It is stored here and printed again after the
142 * counter by the quantity filter.
143 *
144 * @param string $name Product name markup.
145 * @param array $cart_item Cart item data.
146 * @param string $cart_item_key Cart item key.
147 *
148 * @return string
149 */
150 public function extract_discount_summary( $name, $cart_item, $cart_item_key = '' ) {
151 if ( ! $this->in_review_table || '' === $cart_item_key || false === strpos( $name, self::SUMMARY_CLASS ) ) {
152 return $name;
153 }
154
155 list( $name, $summary ) = $this->split_discount_summary( $name );
156
157 if ( '' !== $summary ) {
158 $this->discount_summary[ $cart_item_key ] = $summary;
159 }
160
161 return $name;
162 }
163
164 /**
165 * Checks whether the quantity selector is enabled.
166 *
167 * @return bool
168 */
169 public function is_enabled() {
170 return $this->enabled;
171 }
172
173 /**
174 * Applies the posted quantities before the order review fragment is rebuilt.
175 *
176 * @param string $post_data Serialized checkout form data.
177 *
178 * @return void
179 */
180 public function handle_order_review_update( $post_data ) {
181 if ( ! is_string( $post_data ) || '' === $post_data ) {
182 return;
183 }
184
185 $data = [];
186
187 parse_str( $post_data, $data );
188
189 if ( empty( $data[ self::MARKER_FIELD ] ) ) {
190 return;
191 }
192
193 // Keep the selector in the refreshed fragment.
194 $this->enable();
195
196 $this->update_cart_quantities( $data[ self::QUANTITY_FIELD ] ?? [] );
197 }
198
199 /**
200 * Replaces the static "x quantity" markup with a quantity selector.
201 *
202 * The review template prints the item meta right after this filter, so the
203 * meta is pulled forward here and skipped afterwards. That keeps the
204 * selector below every other line of the item.
205 *
206 * @param string $html Default quantity markup.
207 * @param array $cart_item Cart item data.
208 * @param string $cart_item_key Cart item key.
209 *
210 * @return string
211 */
212 public function quantity_field( $html, $cart_item, $cart_item_key ) {
213 $summary = '';
214
215 if ( isset( $this->discount_summary[ $cart_item_key ] ) ) {
216 $summary = wp_kses_post( $this->discount_summary[ $cart_item_key ] );
217
218 unset( $this->discount_summary[ $cart_item_key ] );
219 }
220
221 $product = $cart_item['data'] ?? null;
222
223 if ( ! $product instanceof WC_Product || ! Fns::is_visible_qty_input( $product ) ) {
224 return $html . $summary;
225 }
226
227 $item_data = wc_get_formatted_cart_item_data( $cart_item );
228 $this->printed_item_data = $cart_item_key;
229
230 $quantity = $cart_item['quantity'] ?? 0;
231 $min = $this->get_min_quantity( $product );
232 $max = $product->get_max_purchase_quantity();
233 $step = apply_filters( 'woocommerce_quantity_input_step', 1, $product );
234
235 $input = sprintf(
236 '<input type="number" class="rtsb-order-qty-input" name="%1$s[%2$s]" value="%3$s" min="%4$s" max="%5$s" step="%6$s" inputmode="numeric" autocomplete="off" aria-label="%7$s" />',
237 esc_attr( self::QUANTITY_FIELD ),
238 esc_attr( $cart_item_key ),
239 esc_attr( $quantity ),
240 esc_attr( $min ),
241 esc_attr( $max > 0 ? $max : '' ),
242 esc_attr( $step ),
243 esc_attr__( 'Product quantity', 'shopbuilder' )
244 );
245
246 $field = sprintf(
247 '<div class="rtsb-order-review-quantity">
248 <div class="rtsb-order-qty-group">%1$s%2$s%3$s</div>
249 </div>',
250 $this->get_button( 'minus', $quantity <= $min ),
251 $input,
252 $this->get_button( 'plus', ! $this->can_increase( $product, $quantity, $step ) )
253 );
254
255 // Counter beside the product name, then the discount summary, then the meta.
256 $field = $html . $summary . $item_data . $field;
257
258 return apply_filters( 'rtsb/order_review/quantity/html', $field, $html, $cart_item, $cart_item_key );
259 }
260
261 /**
262 * Skips the item meta the review template prints after the quantity.
263 *
264 * Runs once per cart item, right after the meta was pulled forward into the
265 * quantity markup, so nothing is rendered twice.
266 *
267 * @param array $item_data Formatted item data.
268 * @param array $cart_item Cart item data.
269 *
270 * @return array
271 */
272 public function maybe_skip_item_data( $item_data, $cart_item ) {
273 $cart_item_key = $cart_item['key'] ?? '';
274
275 if ( '' === $cart_item_key || $cart_item_key !== $this->printed_item_data ) {
276 return $item_data;
277 }
278
279 $this->printed_item_data = '';
280
281 return [];
282 }
283
284 /**
285 * Updates the cart with the posted quantities.
286 *
287 * @param array $quantities Posted quantities, keyed by cart item key.
288 *
289 * @return void
290 */
291 private function update_cart_quantities( $quantities ) {
292 if ( empty( $quantities ) || ! is_array( $quantities ) ) {
293 return;
294 }
295
296 $cart = function_exists( 'WC' ) ? WC()->cart : null;
297
298 if ( ! $cart ) {
299 return;
300 }
301
302 foreach ( $quantities as $cart_item_key => $quantity ) {
303 if ( ! is_scalar( $quantity ) ) {
304 continue;
305 }
306
307 $cart_item_key = sanitize_text_field( wp_unslash( (string) $cart_item_key ) );
308 $cart_item = $cart->get_cart_item( $cart_item_key );
309 $product = $cart_item['data'] ?? null;
310
311 if ( ! $product instanceof WC_Product || ! Fns::is_visible_qty_input( $product ) ) {
312 continue;
313 }
314
315 $quantity = $this->clamp_quantity( wc_stock_amount( wp_unslash( $quantity ) ), $product );
316 $current = $cart_item['quantity'] ?? 0;
317
318 if ( (string) $current === (string) $quantity ) {
319 continue;
320 }
321
322 // A typed value must obey the same stock rules as the buttons.
323 if ( $quantity > $current && ! $this->can_increase( $product, $current, $quantity - $current ) ) {
324 continue;
325 }
326
327 // Totals are recalculated by WooCommerce right after this hook.
328 $cart->set_quantity( $cart_item_key, $quantity, false );
329 }
330 }
331
332 /**
333 * Keeps a quantity within the purchasable range of the product.
334 *
335 * @param int|float $quantity Requested quantity.
336 * @param WC_Product $product Product object.
337 *
338 * @return int|float
339 */
340 private function clamp_quantity( $quantity, $product ) {
341 $min = $this->get_min_quantity( $product );
342 $max = $product->get_max_purchase_quantity();
343
344 $quantity = max( $min, $quantity );
345
346 if ( $max > 0 ) {
347 $quantity = min( $max, $quantity );
348 }
349
350 return $quantity;
351 }
352
353 /**
354 * Returns the minimum quantity allowed in the order review.
355 *
356 * Never lower than 1, so a customer cannot empty the cart mid-checkout.
357 *
358 * @param WC_Product $product Product object.
359 *
360 * @return int|float
361 */
362 private function get_min_quantity( $product ) {
363 $min = apply_filters( 'woocommerce_quantity_input_min', 1, $product );
364
365 return max( 1, $min );
366 }
367
368 /**
369 * Splits the discount summary wrapper out of a product name.
370 *
371 * Walks the markup counting nested divs, because the summary of some
372 * discount types wraps further divs and a plain match would cut it short.
373 *
374 * @param string $name Product name markup.
375 *
376 * @return array Name without the summary, and the summary markup.
377 */
378 private function split_discount_summary( $name ) {
379 $class_position = strpos( $name, self::SUMMARY_CLASS );
380
381 if ( false === $class_position ) {
382 return [ $name, '' ];
383 }
384
385 $start = strrpos( substr( $name, 0, $class_position ), '<div' );
386
387 if ( false === $start ) {
388 return [ $name, '' ];
389 }
390
391 $length = strlen( $name );
392 $offset = $start;
393 $depth = 0;
394
395 while ( $offset < $length ) {
396 $open = stripos( $name, '<div', $offset );
397 $close = stripos( $name, '</div', $offset );
398
399 if ( false === $close ) {
400 break;
401 }
402
403 if ( false !== $open && $open < $close ) {
404 ++$depth;
405 $offset = $open + 4;
406 continue;
407 }
408
409 $tag_end = strpos( $name, '>', $close );
410
411 if ( false === $tag_end ) {
412 break;
413 }
414
415 --$depth;
416 $offset = $tag_end + 1;
417
418 if ( 0 === $depth ) {
419 return [
420 substr( $name, 0, $start ) . substr( $name, $offset ),
421 substr( $name, $start, $offset - $start ),
422 ];
423 }
424 }
425
426 return [ $name, '' ];
427 }
428
429 /**
430 * Checks whether one more step can still be added to the cart.
431 *
432 * Covers every stock situation WooCommerce knows about: out of stock,
433 * managed stock without backorders, and the max purchase quantity.
434 *
435 * @param WC_Product $product Product object.
436 * @param int|float $quantity Current cart quantity.
437 * @param int|float $step Increment step.
438 *
439 * @return bool
440 */
441 private function can_increase( $product, $quantity, $step ) {
442 $next = $quantity + max( 1, (float) $step );
443 $max = $product->get_max_purchase_quantity();
444
445 $can_increase = $product->is_in_stock()
446 && $product->is_purchasable()
447 && ( $max <= 0 || $next <= $max )
448 && $product->has_enough_stock( $next );
449
450 return (bool) apply_filters( 'rtsb/order_review/quantity/can_increase', $can_increase, $product, $quantity, $step );
451 }
452
453 /**
454 * Returns the increment/decrement button markup.
455 *
456 * @param string $type Button type: plus or minus.
457 * @param bool $disabled Whether the button cannot change the quantity.
458 *
459 * @return string
460 */
461 private function get_button( $type, $disabled ) {
462 $label = 'plus' === $type
463 ? esc_attr__( 'Increase quantity', 'shopbuilder' )
464 : esc_attr__( 'Decrease quantity', 'shopbuilder' );
465
466 return sprintf(
467 '<button type="button" class="rtsb-order-qty-btn rtsb-order-qty-%1$s%2$s" aria-label="%3$s"%4$s>%5$s</button>',
468 esc_attr( $type ),
469 $disabled ? ' disabled' : '',
470 $label,
471 // The flag lets the script keep the button disabled until the
472 // quantity drops back below the one the cart accepted.
473 $disabled ? ' disabled="disabled" data-rtsb-limit="1"' : '',
474 $this->get_icon( $type )
475 );
476 }
477
478 /**
479 * Returns the increment/decrement icon markup.
480 *
481 * @param string $type Icon type: plus or minus.
482 *
483 * @return string
484 */
485 private function get_icon( $type ) {
486 if ( 'plus' === $type ) {
487 return '<svg width="10" height="10" viewBox="0 0 10 10" xmlns="http://www.w3.org/2000/svg" aria-hidden="true" focusable="false"><path d="M4.25 0h1.5v4.25H10v1.5H5.75V10h-1.5V5.75H0v-1.5h4.25V0z" fill="currentColor"/></svg>';
488 }
489
490 return '<svg width="10" height="2" viewBox="0 0 10 2" xmlns="http://www.w3.org/2000/svg" aria-hidden="true" focusable="false"><path d="M0 0h10v2H0z" fill="currentColor"/></svg>';
491 }
492 }
493