PluginProbe ʕ •ᴥ•ʔ
SureCart – Ecommerce Made Easy For Selling Physical Products, Digital Downloads, Subscriptions, Donations, & Payments / 4.7.1
SureCart – Ecommerce Made Easy For Selling Physical Products, Digital Downloads, Subscriptions, Donations, & Payments v4.7.1
4.7.2 4.7.1 4.7.0 4.6.6 4.6.5 4.6.4 4.6.3 4.6.2 4.6.1 4.6.0 4.5.1 4.5.0 4.4.2 4.4.1 4.4.0 4.3.3 4.3.2 4.3.1 4.3.0 4.2.3 4.2.2 4.2.1 1.0.3 1.0.4 1.0.5 1.0.6 1.1.0 1.1.1 1.1.10 1.1.11 1.1.12 1.1.13 1.1.14 1.1.15 1.1.16 1.1.17 1.1.18 1.1.19 1.1.2 1.1.3 1.1.4 1.1.5 1.1.6 1.1.7 1.1.8 1.1.9 1.10.0 1.10.1 1.10.2 1.10.3 1.10.4 1.11.0 1.11.1 1.11.2 1.2.0 1.2.1 1.2.2 1.2.3 1.2.4 1.2.5 1.3.0 1.3.1 1.3.2 1.3.3 1.3.4 1.4.0 1.4.1 1.4.2 1.5.0 1.5.1 1.5.2 1.5.3 1.5.4 1.5.5 1.5.6 1.5.7 1.5.8 1.6.0 1.6.1 1.6.2 1.6.3 1.6.4 1.7.0 1.7.1 1.7.2 1.8.0 1.8.1 1.8.2 1.8.3 1.8.4 1.8.5 1.9.0 1.9.1 1.9.2 1.9.3 1.9.4 1.9.5 2.0.0 2.0.1 2.1.0 2.1.1 2.1.2 2.1.3 2.1.4 2.10.0 2.10.1 2.11.0 2.11.1 2.11.2 2.11.3 2.11.4 2.12.0 2.13.0 2.14.0 2.14.1 2.15.0 2.15.1 2.16.0 2.16.1 2.16.2 2.16.3 2.17.0 2.17.1 2.17.2 2.18.0 2.19.0 2.19.2 2.19.3 2.19.4 2.2.0 2.2.1 2.20.0 2.20.1 2.20.2 2.20.3 2.20.4 2.20.5 2.20.6 2.21.0 2.22.0 2.22.1 2.23.0 2.24.0 2.25.0 2.25.1 2.25.2 2.26.0 2.27.0 2.27.1 2.28.0 2.29.0 2.29.1 2.29.2 2.29.3 2.29.4 2.3.0 2.3.1 2.30.0 2.31.0 2.31.1 2.31.2 2.31.3 2.4.0 2.4.1 2.4.2 2.4.3 2.4.4 2.40.0 2.40.1 2.5.0 2.5.1 2.5.2 2.6.0 2.6.1 2.6.2 2.7.0 2.7.1 2.7.2 2.7.3 2.7.4 2.7.5 2.8.0 2.8.1 2.8.2 2.8.3 2.8.4 2.9.0 3.0.0 3.0.0-RC1 3.0.0-RC2 3.0.0-beta1 3.0.0-beta2 3.0.1 3.0.2 3.0.3 3.0.4 3.0.5 3.1.0 3.1.1 3.1.2 3.1.3 3.1.4 3.1.5 3.1.6 3.10.0 3.10.1 3.11.0 3.12.0 3.13.0 3.13.1 3.13.2 3.13.3 3.13.4 3.14.0 3.15.0 3.15.1 3.15.2 3.15.3 3.15.4 3.15.5 3.16.0 3.16.1 3.16.2 3.16.3 3.16.4 3.16.5 3.16.6 3.16.7 3.16.8 3.17.0 3.17.1 3.17.2 3.17.3 3.17.4 3.17.5 3.17.6 3.18.0 3.19.0 3.19.1 3.19.2 3.2.0 3.2.1 3.2.2 3.20.0 3.20.1 3.3.0 3.3.1 3.4.0 3.4.1 3.4.2 3.4.3 3.5.0 3.5.1 3.5.2 3.5.3 3.6.0 3.6.1 3.6.2 3.7.0 3.7.1 3.7.2 3.7.3 3.8.0 3.8.1 3.8.2 3.8.3 3.8.4 3.8.5 3.9.0 4.0.0 4.0.1 4.0.2 4.0.3 trunk 4.1.0 0.2.19.1 4.1.1 1.0.0 4.2.0 1.0.1 1.0.2
surecart / app / src / Models / Blocks / ProductPageBlock.php
surecart / app / src / Models / Blocks Last commit date
AbstractProductListBlock.php 4 months ago ProductListBlock.php 4 months ago ProductPageBlock.php 1 day ago ProductReviewBlock.php 6 months ago ProductReviewListBlock.php 6 months ago RelatedProductsBlock.php 4 months ago
ProductPageBlock.php
697 lines
1 <?php
2
3 namespace SureCart\Models\Blocks;
4
5 /**
6 * The product list service.
7 */
8 class ProductPageBlock {
9 /**
10 * The URL.
11 *
12 * @var object
13 */
14 protected $url;
15
16 /**
17 * Constructor.
18 */
19 public function __construct() {
20 $this->url = \SureCart::block()->urlParams();
21 }
22
23 /**
24 * Get the variants query.
25 *
26 * @return array|null
27 */
28 public function getVariantsQuery() {
29 $product = sc_get_product();
30
31 if ( empty( $product ) || empty( $product->variants->data ?? [] ) ) {
32 return null;
33 }
34
35 // get the initial defaults if there were no args.
36 // we need to turn into slugs for comparison to make sure capitalization,
37 // spacing, etc. doesn't matter.
38 $initial_defaults = array_reduce(
39 $product->variant_options->data ?? [],
40 function ( $carry, $option ) {
41 $name = sanitize_title( $option->name );
42 $carry[ $name ] = sanitize_title( $option->values[0] );
43 return $carry;
44 },
45 []
46 );
47
48 // use initial defaults to only get args needed.
49 $args = [];
50 foreach ( $initial_defaults as $key => $value ) {
51 $args[ $key ] = $this->url->getArg( $key );
52 }
53
54 // merge the args with the initial defaults.
55 $attributes = wp_parse_args(
56 array_filter( $args ),
57 $initial_defaults
58 );
59
60 // loop through the attributes.
61 $keys = [];
62 foreach ( $attributes as $option_name => $value ) {
63 // find the option index based on the name.
64 $option_index = array_search(
65 // get the sanitized option name for comparison.
66 sanitize_title( $option_name ),
67 // get the sanitized option names for comparison.
68 array_map(
69 fn( $name ) => sanitize_title( $name ),
70 array_column( $product->variant_options->data, 'name' )
71 ),
72 true
73 );
74
75 // if the option index is not found, skip.
76 $keys[ 'option_' . ( $option_index + 1 ) ] = $value;
77 }
78
79 return $keys;
80 }
81
82 /**
83 * Get the URL.
84 *
85 * @return object|null
86 */
87 public function getSelectedVariant() {
88 $product = sc_get_product();
89
90 if ( empty( $product ) || empty( $product->variants->data ?? [] ) ) {
91 return null;
92 }
93
94 // loop through the attributes.
95 $keys = $this->getVariantsQuery();
96
97 $variants = array_values(
98 array_filter(
99 ( $product->variants->data ?? [] ),
100 function ( $variant ) use ( $keys ) {
101 foreach ( $keys as $key => $value ) {
102 if ( sanitize_title( $variant->$key ) !== sanitize_title( $value ) ) {
103 return false;
104 }
105 }
106 return true;
107 }
108 )
109 );
110
111 if ( ! empty( $variants[0] ) ) {
112 return $variants[0];
113 }
114
115 if ( ! empty( $product->first_variant_with_stock ) ) {
116 return $product->first_variant_with_stock;
117 }
118
119 return $product->variants->data[0] ?? null;
120 }
121
122 /**
123 * Get the URL.
124 *
125 * @return object|null
126 */
127 public function urlParams() {
128 return $this->url;
129 }
130
131 /**
132 * Get the context.
133 *
134 * @param array $context The context to add to the existing context.
135 *
136 * @return array
137 */
138 public function context( $context = [] ) {
139 $product = sc_get_product();
140 if ( empty( $product ) ) {
141 return [];
142 }
143
144 $selected_variant = $this->getSelectedVariant();
145
146 return wp_parse_args(
147 $context,
148 array(
149 'formId' => \SureCart::forms()->getDefaultId(),
150 'mode' => \SureCart\Models\Form::getMode( \SureCart::forms()->getDefaultId() ),
151 'checkoutUrl' => \SureCart::pages()->url( 'checkout' ),
152 'urlPrefix' => $this->urlParams()->getKey(),
153 'updateUrl' => true, // Only a single-product context owns the page URL. A product list renders many pills, so it doesn't update the URL.
154 'product' => ! empty( $product ) && ! empty( $product->id ) ? $product->only( [ 'id', 'name', 'has_unlimited_stock', 'available_stock', 'archived', 'permalink', 'preview_image' ] ) : null,
155 'selectedPrice' => ! empty( $product->initial_price ) && ! empty( $product->initial_price->id ) ? $product->initial_price->only(
156 [
157 'id',
158 'archived',
159 'amount',
160 'display_amount',
161 'scratch_amount',
162 'scratch_display_amount',
163 'ad_hoc',
164 'is_on_sale',
165 'is_zero_decimal',
166 'currency',
167 'currency_symbol',
168 'converted_ad_hoc_min_amount',
169 'converted_ad_hoc_max_amount',
170 'setup_fee_text',
171 'setup_fee_text_with_punctuation',
172 'interval_text',
173 'short_interval_text',
174 'interval_count_text',
175 'payments_text',
176 'trial_text',
177 'trial_text_with_punctuation',
178 ]
179 ) : null,
180 'prices' => array_map(
181 fn( $price ) => $price->only(
182 [
183 'id',
184 'archived',
185 'amount',
186 'display_amount',
187 'scratch_amount',
188 'scratch_display_amount',
189 'ad_hoc',
190 'is_on_sale',
191 'is_zero_decimal',
192 'currency',
193 'currency_symbol',
194 'converted_ad_hoc_min_amount',
195 'converted_ad_hoc_max_amount',
196 'setup_fee_text',
197 'interval_text',
198 'short_interval_text',
199 'interval_count_text',
200 'payments_text',
201 'trial_text',
202 ]
203 ),
204 $product->active_prices ?? []
205 ),
206 'variants' => array_map(
207 fn( $variant ) => $variant->only(
208 [
209 'id',
210 'option_1',
211 'option_2',
212 'option_3',
213 'price',
214 'amount',
215 'display_amount',
216 'available_stock',
217 'line_item_image',
218 'has_unlimited_stock',
219 ]
220 ),
221 $product->variants->data ?? array()
222 ),
223 'quantity' => 1,
224 'busy' => false,
225 'adHocAmount' => ( ! empty( $product->initial_price->ad_hoc ) ? $product->initial_price->amount : 0 ) / ( ! empty( $product->initial_price->is_zero_decimal ) ? 1 : 100 ),
226 'variantValues' => array_filter(
227 array(
228 'option_1' => $selected_variant->option_1 ?? null,
229 'option_2' => $selected_variant->option_2 ?? null,
230 'option_3' => $selected_variant->option_3 ?? null,
231 )
232 ),
233 'text' => __( 'Add to Cart', 'surecart' ),
234 'outOfStockText' => __( 'Sold Out', 'surecart' ),
235 'unavailableText' => __( 'Unavailable For Purchase', 'surecart' ),
236 'selectComponentOptionsText' => __( 'Select options', 'surecart' ),
237 'note' => '',
238 'noteLabel' => '',
239 'bundleComponentVariants' => $this->getInitialBundleComponentVariants( $product ),
240 'bundleVariableComponentIds' => $product->bundle_variable_component_ids,
241 'bundleComponents' => $this->getBundleComponents( $product ),
242 ),
243 );
244 }
245
246 /**
247 * Seed each variable component with a default variant so the bundle PDP
248 * opens valid — parity with the main product's auto-pick.
249 *
250 * @param object $product The bundle product (or non-bundle — returns {}).
251 *
252 * @return object Map of component_product_id -> variant_id.
253 */
254 public function getInitialBundleComponentVariants( $product ) {
255 if ( empty( $product->bundle ) ) {
256 return (object) array();
257 }
258
259 $map = array();
260 foreach ( $product->bundle_items->data ?? array() as $item ) {
261 $component = $product->resolveBundleComponent( $item );
262 if ( empty( $component->id ) ) {
263 continue;
264 }
265
266 $initial = $this->findInitialBundleComponentVariant( $component );
267 if ( ! empty( $initial->id ) ) {
268 $map[ $component->id ] = $initial->id;
269 }
270 }
271
272 return (object) $map;
273 }
274
275 /**
276 * Pick a sensible default variant for a bundle component product.
277 *
278 * @param object $component Component product (resolved from its own cache).
279 *
280 * @return object|null
281 */
282 public function findInitialBundleComponentVariant( $component ) {
283 $variants = $component->variants->data ?? array();
284 if ( empty( $variants ) ) {
285 return null;
286 }
287
288 $from_url = $this->findBundleComponentVariantFromUrl( $component );
289 if ( $from_url ) {
290 return $from_url;
291 }
292
293 if ( ! empty( $component->has_unlimited_stock ) ) {
294 return $variants[0];
295 }
296
297 foreach ( $variants as $variant ) {
298 if ( ( $variant->available_stock ?? 0 ) > 0 ) {
299 return $variant;
300 }
301 }
302
303 // Nothing in stock — fall back to the first variant.
304 return $variants[0];
305 }
306
307 /**
308 * Resolve a component's variant from URL slugs (written by the scope-aware setOption callback).
309 *
310 * @param object $component Component product (resolved from its own cache).
311 *
312 * @return object|null
313 */
314 public function findBundleComponentVariantFromUrl( $component ) {
315 $variant_options = $component->variant_options->data ?? array();
316 $variants = $component->variants->data ?? array();
317 if ( empty( $variant_options ) || empty( $variants ) ) {
318 return null;
319 }
320
321 // The URL key embeds the component product's slug — matching the writer in
322 // packages/blocks-next/src/scripts/product-page/index.js — so a bundle
323 // selection reads as ?bundle-{slug}-{option}={value} instead of the UUID.
324 // The writer and this reader both look at the same $component, so applying an
325 // identical slug→id fallback on each side keeps the two keys in lockstep.
326 $identifier = ! empty( $component->slug ) ? $component->slug : $component->id;
327
328 $selected_values = array();
329 foreach ( $variant_options as $key => $option ) {
330 $arg_key = 'bundle-' . $identifier . '-' . sanitize_title( $option->name );
331 $arg_slug = $this->url->getArg( $arg_key );
332 if ( empty( $arg_slug ) ) {
333 continue;
334 }
335
336 foreach ( $option->values as $value ) {
337 if ( sanitize_title( $value ) === sanitize_title( $arg_slug ) ) {
338 $selected_values[ 'option_' . ( $key + 1 ) ] = $value;
339 break;
340 }
341 }
342 }
343
344 if ( empty( $selected_values ) ) {
345 return null;
346 }
347
348 foreach ( $variants as $variant ) {
349 $match = true;
350 foreach ( $selected_values as $option_key => $value ) {
351 if ( ( $variant->{$option_key} ?? null ) !== $value ) {
352 $match = false;
353 break;
354 }
355 }
356 if ( $match ) {
357 return $variant;
358 }
359 }
360
361 return null;
362 }
363
364 /**
365 * Stock-only snapshot of each bundle component (drives sold-out state).
366 *
367 * @param object $product The bundle product (or non-bundle — returns {}).
368 *
369 * @return object Map of component_product_id -> stock metadata.
370 */
371 protected function getBundleComponents( $product ) {
372 if ( empty( $product->bundle ) ) {
373 return (object) array();
374 }
375
376 $components = array();
377 foreach ( $product->bundle_items->data ?? array() as $item ) {
378 $component = $product->resolveBundleComponent( $item );
379 if ( empty( $component->id ) ) {
380 continue;
381 }
382
383 $components[ $component->id ] = array(
384 'has_unlimited_stock' => ! empty( $component->has_unlimited_stock ),
385 'available_stock' => (int) ( $component->available_stock ?? 0 ),
386 'variants' => array_map(
387 fn( $variant ) => array(
388 'id' => $variant->id,
389 'available_stock' => (int) ( $variant->available_stock ?? 0 ),
390 'has_unlimited_stock' => $variant->has_unlimited_stock,
391 ),
392 $component->variants->data ?? array()
393 ),
394 );
395 }
396
397 return (object) $components;
398 }
399
400 /**
401 * Get the state.
402 *
403 * @param array $state The state to add to the existing state.
404 *
405 * @return array
406 */
407 public function state( $state = [] ) {
408 $product = sc_get_product();
409 if ( empty( $product ) ) {
410 return [];
411 }
412 $selected_price = $product->initial_price;
413 $selected_variant = $this->getSelectedVariant();
414
415 return wp_parse_args(
416 $state,
417 [
418 'quantity' => 1,
419 'selectedDisplayAmount' => $product->display_amount,
420 'isOnSale' => function () {
421 $context = wp_interactivity_get_context();
422 $selected_price = $context['selectedPrice'] ?? [];
423 return $selected_price['is_on_sale'] ?? false;
424 },
425 'selectedAmount' => function () {
426 $context = wp_interactivity_get_context();
427 $state = wp_interactivity_state();
428 $selected_price = $context['selectedPrice'] ?? [];
429 $prices = $context['prices'] ?? [];
430
431 if ( ! empty( $prices ) && count( $prices ) > 1 ) {
432 return $selected_price['amount'];
433 }
434
435 return $state['selectedVariant']['amount'] ?? $selected_price['amount'];
436 },
437 'busy' => false,
438 'shouldDisplayImage' => function () {
439 $context = wp_interactivity_get_context();
440 $state = wp_interactivity_state();
441
442 if ( empty( $context['variants'] ) ) {
443 return true;
444 }
445
446 return $state['isOptionValueSelected']();
447 },
448 'adHocAmount' => ( ! empty( $selected_price->ad_hoc ) ? $selected_price->amount : 0 ) / ( ! empty( $selected_price->is_zero_decimal ) ? 1 : 100 ),
449 'selectedVariant' => ! empty( $selected_variant ) && ! empty( $selected_variant->id ) ? $selected_variant->only(
450 [
451 'id',
452 'option_1',
453 'option_2',
454 'option_3',
455 'price',
456 'amount',
457 'display_amount',
458 'available_stock',
459 'has_unlimited_stock',
460 ]
461 ) : [],
462 // Scope-aware: the same picker renders for the page product and for
463 // each bundle component. resolveVariantScope() resolves which slice
464 // of state to read, so this stays free of scope-specific branching.
465 'isOptionUnavailable' => function () {
466 $context = wp_interactivity_get_context();
467 $option_number = (int) ( $context['optionNumber'] ?? 0 );
468 $option_value = $context['option_value'] ?? null;
469 if ( ! $option_number || null === $option_value ) {
470 return false;
471 }
472
473 $scope = self::resolveVariantScope( $context );
474 return self::isVariantOptionSoldOut(
475 $option_number,
476 $option_value,
477 $scope['values'],
478 $scope['variants'],
479 $scope['product'],
480 $scope['missing_means_unavailable']
481 );
482 },
483 'isOptionValueSelected' => function () {
484 $context = wp_interactivity_get_context();
485
486 if ( empty( $context['optionValue'] ) ) {
487 return true;
488 }
489
490 $values = array_map(
491 function ( $value ) {
492 return strtolower( $value );
493 },
494 array_values( $context['variantValues'] )
495 );
496
497 return in_array( strtolower( $context['optionValue'] ), $values );
498 },
499 'imageDisplay' => function () {
500 $state = wp_interactivity_state();
501 return $state['shouldDisplayImage']() ? 'inherit' : 'none';
502 },
503 'isSoldOut' => function () {
504 $context = wp_interactivity_get_context();
505 $state = wp_interactivity_state();
506 $product = $context['product'] ?? [];
507 if ( empty( $product ) ) {
508 return false;
509 }
510 $variant = $state['selectedVariant'] ?? [];
511 if ( ! empty( $variant['id'] ) ) {
512 return self::effectiveVariantStock( $variant, $product ) <= 0;
513 }
514 if ( $product['has_unlimited_stock'] ) {
515 return false;
516 }
517 if ( ! empty( $context['variants'] ) && empty( $variant ) ) {
518 return false;
519 }
520 return $product['available_stock'] <= 0;
521 },
522 'isUnavailable' => function () {
523 $context = wp_interactivity_get_context();
524 $state = wp_interactivity_state();
525 if ( ! empty( $context['product']->archived ) || ! empty( $state['isSoldOut']() ) ) {
526 return true;
527 }
528 if ( ! empty( $context['variants'] ) && empty( $state['selectedVariant'] ) ) {
529 return true;
530 }
531 if ( ! empty( $state['isBundleIncomplete']() ) ) {
532 return true;
533 }
534 return false;
535 },
536
537 // Drives the "Select options" button text and disables Add to cart.
538 'isBundleIncomplete' => function () {
539 $context = wp_interactivity_get_context();
540 $variable_ids = $context['bundleVariableComponentIds'] ?? array();
541 if ( empty( $variable_ids ) ) {
542 return false;
543 }
544 // Cast for traversal — server hydration may give stdClass before JS writes.
545 $selections = (array) ( $context['bundleComponentVariants'] ?? array() );
546 foreach ( $variable_ids as $id ) {
547 if ( empty( $selections[ $id ] ) ) {
548 return true;
549 }
550 }
551 return false;
552 },
553
554 // Scope-aware via resolveVariantScope(): bundle component selections
555 // live in componentOptionValues, page product selections in variantValues.
556 'isOptionSelected' => function () {
557 $context = wp_interactivity_get_context();
558 $option_number = $context['optionNumber'] ?? '';
559 $option_value = $context['option_value'] ?? null;
560 if ( '' === $option_number || null === $option_value ) {
561 return false;
562 }
563 $scope = self::resolveVariantScope( $context );
564 return ( $scope['values'][ "option_$option_number" ] ?? null ) === $option_value;
565 },
566 'isPriceSelected' => function () {
567 $context = wp_interactivity_get_context();
568 if ( ! isset( $context['price'] ) || ! isset( $context['selectedPrice'] ) ) {
569 return false;
570 }
571 return $context['price']['id'] === $context['selectedPrice']['id'];
572 },
573 'buttonText' => function () {
574 $state = wp_interactivity_state();
575 $context = wp_interactivity_get_context();
576 if ( $state['isSoldOut']() ) {
577 return $context['outOfStockText'] ?? $context['text'];
578 }
579 if ( $state['isBundleIncomplete']() ) {
580 return $context['selectComponentOptionsText'] ?? $context['text'];
581 }
582 if ( $state['isUnavailable']() ) {
583 return $context['unavailableText'] ?? $context['text'];
584 }
585 return $context['text'] ?? '';
586 },
587 ]
588 );
589 }
590
591 /**
592 * Resolve the variant scope for the current pill's SSR state.
593 *
594 * Mirrors the JS `getVariantScope` (read half) so the option getters never
595 * branch on "is this a bundle?". A bundle component reads/writes its own
596 * slice; the page product reads the page-level slice. Add a scope here and
597 * the getters keep working unchanged.
598 *
599 * @param array $context Interactivity context for the current pill.
600 * @return array{values:array,variants:array,product:array,missing_means_unavailable:bool}
601 */
602 private static function resolveVariantScope( array $context ): array {
603 // Bundle component scope.
604 if ( ! empty( $context['componentProductId'] ) ) {
605 $unlimited = ! empty( $context['componentHasUnlimitedStock'] );
606 return array(
607 'values' => (array) ( $context['componentOptionValues'] ?? array() ),
608 'variants' => $context['componentVariants'] ?? array(),
609 'product' => array( 'has_unlimited_stock' => $unlimited ),
610 // A component combination with no matching variant can't be built —
611 // unless the component is unlimited stock, where every option stays open.
612 'missing_means_unavailable' => ! $unlimited,
613 );
614 }
615
616 // Page product scope — an unmatched combination stays selectable.
617 return array(
618 'values' => (array) ( $context['variantValues'] ?? array() ),
619 'variants' => $context['variants'] ?? array(),
620 'product' => (array) ( $context['product'] ?? array() ),
621 'missing_means_unavailable' => false,
622 );
623 }
624
625 /**
626 * Is a variant option value sold out, constrained by earlier selected options.
627 *
628 * Shared by the page product and bundle component pickers. Mirrors the JS
629 * `isProductVariantOptionSoldOut` in product-page/index.js.
630 *
631 * @param int $option_number Which option (1, 2 or 3) the pill represents.
632 * @param mixed $option_value The pill's option value.
633 * @param array $values Currently selected option values for this scope.
634 * @param array $variants Variant data (arrays or objects) for this scope.
635 * @param array $product Parent product data for unlimited-stock fallback.
636 * @param bool $missing_means_unavailable When true, a combination with no matching
637 * variant counts as unavailable (bundle scope).
638 * @return bool
639 */
640 private static function isVariantOptionSoldOut( int $option_number, $option_value, array $values, array $variants, array $product, bool $missing_means_unavailable = false ): bool {
641 $variants = array_map( fn( $v ) => (array) $v, array_values( (array) $variants ) );
642
643 $items = array_filter(
644 $variants,
645 function ( $variant ) use ( $option_number, $option_value, $values ) {
646 // Earlier options must match the current selection.
647 for ( $i = 1; $i < $option_number; $i++ ) {
648 $prev_key = "option_$i";
649 if ( ( $variant[ $prev_key ] ?? null ) !== ( $values[ $prev_key ] ?? null ) ) {
650 return false;
651 }
652 }
653 return ( $variant[ "option_$option_number" ] ?? null ) === $option_value;
654 }
655 );
656
657 // No variant matches this combination.
658 if ( empty( $items ) ) {
659 return $missing_means_unavailable;
660 }
661
662 return self::isVariantGroupSoldOut( $items, $product );
663 }
664
665 /**
666 * Whether every variant in a group is out of stock.
667 *
668 * @param array $items Variant data (arrays or objects) for the group.
669 * @param array $product Parent product data for unlimited-stock fallback.
670 * @return bool True only when the group has variants and all are sold out.
671 */
672 private static function isVariantGroupSoldOut( array $items, array $product ): bool {
673 if ( empty( $items ) ) {
674 return false;
675 }
676
677 $stocks = array_map( fn( $v ) => self::effectiveVariantStock( (array) $v, $product ), array_values( $items ) );
678 return max( $stocks ) <= 0;
679 }
680
681 /**
682 * Get the effective available stock for a variant, respecting variant-level stock overrides.
683 * Returns PHP_INT_MAX when stock is not tracked, it would be then unlimited stock.
684 *
685 * @param array $item Variant data.
686 * @param array $product Parent product data for fallback.
687 * @return int
688 */
689 private static function effectiveVariantStock( array $item, array $product ): int {
690 $has_unlimited_stock = $item['has_unlimited_stock'] ?? $product['has_unlimited_stock'] ?? false;
691 if ( $has_unlimited_stock ) {
692 return PHP_INT_MAX;
693 }
694 return (int) $item['available_stock'];
695 }
696 }
697