PluginProbe ʕ •ᴥ•ʔ
SureCart – Ecommerce Made Easy For Selling Physical Products, Digital Downloads, Subscriptions, Donations, & Payments / 4.6.4
SureCart – Ecommerce Made Easy For Selling Physical Products, Digital Downloads, Subscriptions, Donations, & Payments v4.6.4
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 2 weeks ago ProductReviewBlock.php 6 months ago ProductReviewListBlock.php 6 months ago RelatedProductsBlock.php 4 months ago
ProductPageBlock.php
696 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 'product' => ! empty( $product ) && ! empty( $product->id ) ? $product->only( [ 'id', 'name', 'has_unlimited_stock', 'available_stock', 'archived', 'permalink', 'preview_image' ] ) : null,
154 'selectedPrice' => ! empty( $product->initial_price ) && ! empty( $product->initial_price->id ) ? $product->initial_price->only(
155 [
156 'id',
157 'archived',
158 'amount',
159 'display_amount',
160 'scratch_amount',
161 'scratch_display_amount',
162 'ad_hoc',
163 'is_on_sale',
164 'is_zero_decimal',
165 'currency',
166 'currency_symbol',
167 'converted_ad_hoc_min_amount',
168 'converted_ad_hoc_max_amount',
169 'setup_fee_text',
170 'setup_fee_text_with_punctuation',
171 'interval_text',
172 'short_interval_text',
173 'interval_count_text',
174 'payments_text',
175 'trial_text',
176 'trial_text_with_punctuation',
177 ]
178 ) : null,
179 'prices' => array_map(
180 fn( $price ) => $price->only(
181 [
182 'id',
183 'archived',
184 'amount',
185 'display_amount',
186 'scratch_amount',
187 'scratch_display_amount',
188 'ad_hoc',
189 'is_on_sale',
190 'is_zero_decimal',
191 'currency',
192 'currency_symbol',
193 'converted_ad_hoc_min_amount',
194 'converted_ad_hoc_max_amount',
195 'setup_fee_text',
196 'interval_text',
197 'short_interval_text',
198 'interval_count_text',
199 'payments_text',
200 'trial_text',
201 ]
202 ),
203 $product->active_prices ?? []
204 ),
205 'variants' => array_map(
206 fn( $variant ) => $variant->only(
207 [
208 'id',
209 'option_1',
210 'option_2',
211 'option_3',
212 'price',
213 'amount',
214 'display_amount',
215 'available_stock',
216 'line_item_image',
217 'has_unlimited_stock',
218 ]
219 ),
220 $product->variants->data ?? array()
221 ),
222 'quantity' => 1,
223 'busy' => false,
224 'adHocAmount' => ( ! empty( $product->initial_price->ad_hoc ) ? $product->initial_price->amount : 0 ) / ( ! empty( $product->initial_price->is_zero_decimal ) ? 1 : 100 ),
225 'variantValues' => array_filter(
226 array(
227 'option_1' => $selected_variant->option_1 ?? null,
228 'option_2' => $selected_variant->option_2 ?? null,
229 'option_3' => $selected_variant->option_3 ?? null,
230 )
231 ),
232 'text' => __( 'Add to Cart', 'surecart' ),
233 'outOfStockText' => __( 'Sold Out', 'surecart' ),
234 'unavailableText' => __( 'Unavailable For Purchase', 'surecart' ),
235 'selectComponentOptionsText' => __( 'Select options', 'surecart' ),
236 'note' => '',
237 'noteLabel' => '',
238 'bundleComponentVariants' => $this->getInitialBundleComponentVariants( $product ),
239 'bundleVariableComponentIds' => $product->bundle_variable_component_ids,
240 'bundleComponents' => $this->getBundleComponents( $product ),
241 ),
242 );
243 }
244
245 /**
246 * Seed each variable component with a default variant so the bundle PDP
247 * opens valid — parity with the main product's auto-pick.
248 *
249 * @param object $product The bundle product (or non-bundle — returns {}).
250 *
251 * @return object Map of component_product_id -> variant_id.
252 */
253 public function getInitialBundleComponentVariants( $product ) {
254 if ( empty( $product->bundle ) ) {
255 return (object) array();
256 }
257
258 $map = array();
259 foreach ( $product->bundle_items->data ?? array() as $item ) {
260 $component = $product->resolveBundleComponent( $item );
261 if ( empty( $component->id ) ) {
262 continue;
263 }
264
265 $initial = $this->findInitialBundleComponentVariant( $component );
266 if ( ! empty( $initial->id ) ) {
267 $map[ $component->id ] = $initial->id;
268 }
269 }
270
271 return (object) $map;
272 }
273
274 /**
275 * Pick a sensible default variant for a bundle component product.
276 *
277 * @param object $component Component product (resolved from its own cache).
278 *
279 * @return object|null
280 */
281 public function findInitialBundleComponentVariant( $component ) {
282 $variants = $component->variants->data ?? array();
283 if ( empty( $variants ) ) {
284 return null;
285 }
286
287 $from_url = $this->findBundleComponentVariantFromUrl( $component );
288 if ( $from_url ) {
289 return $from_url;
290 }
291
292 if ( ! empty( $component->has_unlimited_stock ) ) {
293 return $variants[0];
294 }
295
296 foreach ( $variants as $variant ) {
297 if ( ( $variant->available_stock ?? 0 ) > 0 ) {
298 return $variant;
299 }
300 }
301
302 // Nothing in stock — fall back to the first variant.
303 return $variants[0];
304 }
305
306 /**
307 * Resolve a component's variant from URL slugs (written by the scope-aware setOption callback).
308 *
309 * @param object $component Component product (resolved from its own cache).
310 *
311 * @return object|null
312 */
313 public function findBundleComponentVariantFromUrl( $component ) {
314 $variant_options = $component->variant_options->data ?? array();
315 $variants = $component->variants->data ?? array();
316 if ( empty( $variant_options ) || empty( $variants ) ) {
317 return null;
318 }
319
320 // The URL key embeds the component product's slug — matching the writer in
321 // packages/blocks-next/src/scripts/product-page/index.js — so a bundle
322 // selection reads as ?bundle-{slug}-{option}={value} instead of the UUID.
323 // The writer and this reader both look at the same $component, so applying an
324 // identical slug→id fallback on each side keeps the two keys in lockstep.
325 $identifier = ! empty( $component->slug ) ? $component->slug : $component->id;
326
327 $selected_values = array();
328 foreach ( $variant_options as $key => $option ) {
329 $arg_key = 'bundle-' . $identifier . '-' . sanitize_title( $option->name );
330 $arg_slug = $this->url->getArg( $arg_key );
331 if ( empty( $arg_slug ) ) {
332 continue;
333 }
334
335 foreach ( $option->values as $value ) {
336 if ( sanitize_title( $value ) === sanitize_title( $arg_slug ) ) {
337 $selected_values[ 'option_' . ( $key + 1 ) ] = $value;
338 break;
339 }
340 }
341 }
342
343 if ( empty( $selected_values ) ) {
344 return null;
345 }
346
347 foreach ( $variants as $variant ) {
348 $match = true;
349 foreach ( $selected_values as $option_key => $value ) {
350 if ( ( $variant->{$option_key} ?? null ) !== $value ) {
351 $match = false;
352 break;
353 }
354 }
355 if ( $match ) {
356 return $variant;
357 }
358 }
359
360 return null;
361 }
362
363 /**
364 * Stock-only snapshot of each bundle component (drives sold-out state).
365 *
366 * @param object $product The bundle product (or non-bundle — returns {}).
367 *
368 * @return object Map of component_product_id -> stock metadata.
369 */
370 protected function getBundleComponents( $product ) {
371 if ( empty( $product->bundle ) ) {
372 return (object) array();
373 }
374
375 $components = array();
376 foreach ( $product->bundle_items->data ?? array() as $item ) {
377 $component = $product->resolveBundleComponent( $item );
378 if ( empty( $component->id ) ) {
379 continue;
380 }
381
382 $components[ $component->id ] = array(
383 'has_unlimited_stock' => ! empty( $component->has_unlimited_stock ),
384 'available_stock' => (int) ( $component->available_stock ?? 0 ),
385 'variants' => array_map(
386 fn( $variant ) => array(
387 'id' => $variant->id,
388 'available_stock' => (int) ( $variant->available_stock ?? 0 ),
389 'has_unlimited_stock' => $variant->has_unlimited_stock,
390 ),
391 $component->variants->data ?? array()
392 ),
393 );
394 }
395
396 return (object) $components;
397 }
398
399 /**
400 * Get the state.
401 *
402 * @param array $state The state to add to the existing state.
403 *
404 * @return array
405 */
406 public function state( $state = [] ) {
407 $product = sc_get_product();
408 if ( empty( $product ) ) {
409 return [];
410 }
411 $selected_price = $product->initial_price;
412 $selected_variant = $this->getSelectedVariant();
413
414 return wp_parse_args(
415 $state,
416 [
417 'quantity' => 1,
418 'selectedDisplayAmount' => $product->display_amount,
419 'isOnSale' => function () {
420 $context = wp_interactivity_get_context();
421 $selected_price = $context['selectedPrice'] ?? [];
422 return $selected_price['is_on_sale'] ?? false;
423 },
424 'selectedAmount' => function () {
425 $context = wp_interactivity_get_context();
426 $state = wp_interactivity_state();
427 $selected_price = $context['selectedPrice'] ?? [];
428 $prices = $context['prices'] ?? [];
429
430 if ( ! empty( $prices ) && count( $prices ) > 1 ) {
431 return $selected_price['amount'];
432 }
433
434 return $state['selectedVariant']['amount'] ?? $selected_price['amount'];
435 },
436 'busy' => false,
437 'shouldDisplayImage' => function () {
438 $context = wp_interactivity_get_context();
439 $state = wp_interactivity_state();
440
441 if ( empty( $context['variants'] ) ) {
442 return true;
443 }
444
445 return $state['isOptionValueSelected']();
446 },
447 'adHocAmount' => ( ! empty( $selected_price->ad_hoc ) ? $selected_price->amount : 0 ) / ( ! empty( $selected_price->is_zero_decimal ) ? 1 : 100 ),
448 'selectedVariant' => ! empty( $selected_variant ) && ! empty( $selected_variant->id ) ? $selected_variant->only(
449 [
450 'id',
451 'option_1',
452 'option_2',
453 'option_3',
454 'price',
455 'amount',
456 'display_amount',
457 'available_stock',
458 'has_unlimited_stock',
459 ]
460 ) : [],
461 // Scope-aware: the same picker renders for the page product and for
462 // each bundle component. resolveVariantScope() resolves which slice
463 // of state to read, so this stays free of scope-specific branching.
464 'isOptionUnavailable' => function () {
465 $context = wp_interactivity_get_context();
466 $option_number = (int) ( $context['optionNumber'] ?? 0 );
467 $option_value = $context['option_value'] ?? null;
468 if ( ! $option_number || null === $option_value ) {
469 return false;
470 }
471
472 $scope = self::resolveVariantScope( $context );
473 return self::isVariantOptionSoldOut(
474 $option_number,
475 $option_value,
476 $scope['values'],
477 $scope['variants'],
478 $scope['product'],
479 $scope['missing_means_unavailable']
480 );
481 },
482 'isOptionValueSelected' => function () {
483 $context = wp_interactivity_get_context();
484
485 if ( empty( $context['optionValue'] ) ) {
486 return true;
487 }
488
489 $values = array_map(
490 function ( $value ) {
491 return strtolower( $value );
492 },
493 array_values( $context['variantValues'] )
494 );
495
496 return in_array( strtolower( $context['optionValue'] ), $values );
497 },
498 'imageDisplay' => function () {
499 $state = wp_interactivity_state();
500 return $state['shouldDisplayImage']() ? 'inherit' : 'none';
501 },
502 'isSoldOut' => function () {
503 $context = wp_interactivity_get_context();
504 $state = wp_interactivity_state();
505 $product = $context['product'] ?? [];
506 if ( empty( $product ) ) {
507 return false;
508 }
509 $variant = $state['selectedVariant'] ?? [];
510 if ( ! empty( $variant['id'] ) ) {
511 return self::effectiveVariantStock( $variant, $product ) <= 0;
512 }
513 if ( $product['has_unlimited_stock'] ) {
514 return false;
515 }
516 if ( ! empty( $context['variants'] ) && empty( $variant ) ) {
517 return false;
518 }
519 return $product['available_stock'] <= 0;
520 },
521 'isUnavailable' => function () {
522 $context = wp_interactivity_get_context();
523 $state = wp_interactivity_state();
524 if ( ! empty( $context['product']->archived ) || ! empty( $state['isSoldOut']() ) ) {
525 return true;
526 }
527 if ( ! empty( $context['variants'] ) && empty( $state['selectedVariant'] ) ) {
528 return true;
529 }
530 if ( ! empty( $state['isBundleIncomplete']() ) ) {
531 return true;
532 }
533 return false;
534 },
535
536 // Drives the "Select options" button text and disables Add to cart.
537 'isBundleIncomplete' => function () {
538 $context = wp_interactivity_get_context();
539 $variable_ids = $context['bundleVariableComponentIds'] ?? array();
540 if ( empty( $variable_ids ) ) {
541 return false;
542 }
543 // Cast for traversal — server hydration may give stdClass before JS writes.
544 $selections = (array) ( $context['bundleComponentVariants'] ?? array() );
545 foreach ( $variable_ids as $id ) {
546 if ( empty( $selections[ $id ] ) ) {
547 return true;
548 }
549 }
550 return false;
551 },
552
553 // Scope-aware via resolveVariantScope(): bundle component selections
554 // live in componentOptionValues, page product selections in variantValues.
555 'isOptionSelected' => function () {
556 $context = wp_interactivity_get_context();
557 $option_number = $context['optionNumber'] ?? '';
558 $option_value = $context['option_value'] ?? null;
559 if ( '' === $option_number || null === $option_value ) {
560 return false;
561 }
562 $scope = self::resolveVariantScope( $context );
563 return ( $scope['values'][ "option_$option_number" ] ?? null ) === $option_value;
564 },
565 'isPriceSelected' => function () {
566 $context = wp_interactivity_get_context();
567 if ( ! isset( $context['price'] ) || ! isset( $context['selectedPrice'] ) ) {
568 return false;
569 }
570 return $context['price']['id'] === $context['selectedPrice']['id'];
571 },
572 'buttonText' => function () {
573 $state = wp_interactivity_state();
574 $context = wp_interactivity_get_context();
575 if ( $state['isSoldOut']() ) {
576 return $context['outOfStockText'] ?? $context['text'];
577 }
578 if ( $state['isBundleIncomplete']() ) {
579 return $context['selectComponentOptionsText'] ?? $context['text'];
580 }
581 if ( $state['isUnavailable']() ) {
582 return $context['unavailableText'] ?? $context['text'];
583 }
584 return $context['text'] ?? '';
585 },
586 ]
587 );
588 }
589
590 /**
591 * Resolve the variant scope for the current pill's SSR state.
592 *
593 * Mirrors the JS `getVariantScope` (read half) so the option getters never
594 * branch on "is this a bundle?". A bundle component reads/writes its own
595 * slice; the page product reads the page-level slice. Add a scope here and
596 * the getters keep working unchanged.
597 *
598 * @param array $context Interactivity context for the current pill.
599 * @return array{values:array,variants:array,product:array,missing_means_unavailable:bool}
600 */
601 private static function resolveVariantScope( array $context ): array {
602 // Bundle component scope.
603 if ( ! empty( $context['componentProductId'] ) ) {
604 $unlimited = ! empty( $context['componentHasUnlimitedStock'] );
605 return array(
606 'values' => (array) ( $context['componentOptionValues'] ?? array() ),
607 'variants' => $context['componentVariants'] ?? array(),
608 'product' => array( 'has_unlimited_stock' => $unlimited ),
609 // A component combination with no matching variant can't be built —
610 // unless the component is unlimited stock, where every option stays open.
611 'missing_means_unavailable' => ! $unlimited,
612 );
613 }
614
615 // Page product scope — an unmatched combination stays selectable.
616 return array(
617 'values' => (array) ( $context['variantValues'] ?? array() ),
618 'variants' => $context['variants'] ?? array(),
619 'product' => (array) ( $context['product'] ?? array() ),
620 'missing_means_unavailable' => false,
621 );
622 }
623
624 /**
625 * Is a variant option value sold out, constrained by earlier selected options.
626 *
627 * Shared by the page product and bundle component pickers. Mirrors the JS
628 * `isProductVariantOptionSoldOut` in product-page/index.js.
629 *
630 * @param int $option_number Which option (1, 2 or 3) the pill represents.
631 * @param mixed $option_value The pill's option value.
632 * @param array $values Currently selected option values for this scope.
633 * @param array $variants Variant data (arrays or objects) for this scope.
634 * @param array $product Parent product data for unlimited-stock fallback.
635 * @param bool $missing_means_unavailable When true, a combination with no matching
636 * variant counts as unavailable (bundle scope).
637 * @return bool
638 */
639 private static function isVariantOptionSoldOut( int $option_number, $option_value, array $values, array $variants, array $product, bool $missing_means_unavailable = false ): bool {
640 $variants = array_map( fn( $v ) => (array) $v, array_values( (array) $variants ) );
641
642 $items = array_filter(
643 $variants,
644 function ( $variant ) use ( $option_number, $option_value, $values ) {
645 // Earlier options must match the current selection.
646 for ( $i = 1; $i < $option_number; $i++ ) {
647 $prev_key = "option_$i";
648 if ( ( $variant[ $prev_key ] ?? null ) !== ( $values[ $prev_key ] ?? null ) ) {
649 return false;
650 }
651 }
652 return ( $variant[ "option_$option_number" ] ?? null ) === $option_value;
653 }
654 );
655
656 // No variant matches this combination.
657 if ( empty( $items ) ) {
658 return $missing_means_unavailable;
659 }
660
661 return self::isVariantGroupSoldOut( $items, $product );
662 }
663
664 /**
665 * Whether every variant in a group is out of stock.
666 *
667 * @param array $items Variant data (arrays or objects) for the group.
668 * @param array $product Parent product data for unlimited-stock fallback.
669 * @return bool True only when the group has variants and all are sold out.
670 */
671 private static function isVariantGroupSoldOut( array $items, array $product ): bool {
672 if ( empty( $items ) ) {
673 return false;
674 }
675
676 $stocks = array_map( fn( $v ) => self::effectiveVariantStock( (array) $v, $product ), array_values( $items ) );
677 return max( $stocks ) <= 0;
678 }
679
680 /**
681 * Get the effective available stock for a variant, respecting variant-level stock overrides.
682 * Returns PHP_INT_MAX when stock is not tracked, it would be then unlimited stock.
683 *
684 * @param array $item Variant data.
685 * @param array $product Parent product data for fallback.
686 * @return int
687 */
688 private static function effectiveVariantStock( array $item, array $product ): int {
689 $has_unlimited_stock = $item['has_unlimited_stock'] ?? $product['has_unlimited_stock'] ?? false;
690 if ( $has_unlimited_stock ) {
691 return PHP_INT_MAX;
692 }
693 return (int) $item['available_stock'];
694 }
695 }
696