PluginProbe ʕ •ᴥ•ʔ
SureCart – Ecommerce Made Easy For Selling Physical Products, Digital Downloads, Subscriptions, Donations, & Payments / 4.5.0
SureCart – Ecommerce Made Easy For Selling Physical Products, Digital Downloads, Subscriptions, Donations, & Payments v4.5.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 2 months ago ProductListBlock.php 2 months ago ProductPageBlock.php 2 months ago ProductReviewBlock.php 4 months ago ProductReviewListBlock.php 4 months ago RelatedProductsBlock.php 2 months ago
ProductPageBlock.php
442 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 'note' => '',
236 'noteLabel' => '',
237 ),
238 );
239 }
240
241 /**
242 * Get the state.
243 *
244 * @param array $state The state to add to the existing state.
245 *
246 * @return array
247 */
248 public function state( $state = [] ) {
249 $product = sc_get_product();
250 if ( empty( $product ) ) {
251 return [];
252 }
253 $selected_price = $product->initial_price;
254 $selected_variant = $this->getSelectedVariant();
255
256 return wp_parse_args(
257 $state,
258 [
259 'quantity' => 1,
260 'selectedDisplayAmount' => $product->display_amount,
261 'isOnSale' => function () {
262 $context = wp_interactivity_get_context();
263 $selected_price = $context['selectedPrice'] ?? [];
264 return $selected_price['is_on_sale'] ?? false;
265 },
266 'selectedAmount' => function () {
267 $context = wp_interactivity_get_context();
268 $state = wp_interactivity_state();
269 $selected_price = $context['selectedPrice'] ?? [];
270 $prices = $context['prices'] ?? [];
271
272 if ( ! empty( $prices ) && count( $prices ) > 1 ) {
273 return $selected_price['amount'];
274 }
275
276 return $state['selectedVariant']['amount'] ?? $selected_price['amount'];
277 },
278 'busy' => false,
279 'shouldDisplayImage' => function () {
280 $context = wp_interactivity_get_context();
281 $state = wp_interactivity_state();
282
283 if ( empty( $context['variants'] ) ) {
284 return true;
285 }
286
287 return $state['isOptionValueSelected']();
288 },
289 'adHocAmount' => ( ! empty( $selected_price->ad_hoc ) ? $selected_price->amount : 0 ) / ( ! empty( $selected_price->is_zero_decimal ) ? 1 : 100 ),
290 'selectedVariant' => ! empty( $selected_variant ) && ! empty( $selected_variant->id ) ? $selected_variant->only(
291 [
292 'id',
293 'option_1',
294 'option_2',
295 'option_3',
296 'price',
297 'amount',
298 'display_amount',
299 'available_stock',
300 'has_unlimited_stock',
301 ]
302 ) : [],
303 'isOptionUnavailable' => function () {
304 $context = wp_interactivity_get_context();
305 $variants = $context['variants'];
306 $option = $context['option_value'];
307 $product = $context['product'];
308 $variant_values = $context['variantValues'];
309 $option_number = $context['optionNumber'];
310
311 if ( 1 === $option_number ) {
312 $items = array_filter( $variants ?? [], fn( $v ) => $v['option_1'] === $option );
313 return self::isVariantGroupSoldOut( $items, $product );
314 }
315
316 if ( 2 === $option_number ) {
317 $items = array_filter(
318 $variants ?? [],
319 fn( $v ) => $v['option_1'] === $variant_values['option_1'] && $v['option_2'] === $option
320 );
321 return self::isVariantGroupSoldOut( $items, $product );
322 }
323
324 $items = array_filter(
325 $variants ?? [],
326 fn( $v ) => $v['option_1'] === $variant_values['option_1'] && $v['option_2'] === $variant_values['option_2'] && $v['option_3'] === $option
327 );
328 return self::isVariantGroupSoldOut( $items, $product );
329 },
330 'isOptionValueSelected' => function () {
331 $context = wp_interactivity_get_context();
332
333 if ( empty( $context['optionValue'] ) ) {
334 return true;
335 }
336
337 $values = array_map(
338 function ( $value ) {
339 return strtolower( $value );
340 },
341 array_values( $context['variantValues'] )
342 );
343
344 return in_array( strtolower( $context['optionValue'] ), $values );
345 },
346 'imageDisplay' => function () {
347 $state = wp_interactivity_state();
348 return $state['shouldDisplayImage']() ? 'inherit' : 'none';
349 },
350 'isSoldOut' => function () {
351 $context = wp_interactivity_get_context();
352 $state = wp_interactivity_state();
353 $product = $context['product'] ?? [];
354 if ( empty( $product ) ) {
355 return false;
356 }
357 $variant = $state['selectedVariant'] ?? [];
358 if ( ! empty( $variant['id'] ) ) {
359 return self::effectiveVariantStock( $variant, $product ) <= 0;
360 }
361 if ( $product['has_unlimited_stock'] ) {
362 return false;
363 }
364 if ( ! empty( $context['variants'] ) && empty( $variant ) ) {
365 return false;
366 }
367 return $product['available_stock'] <= 0;
368 },
369 'isUnavailable' => function () {
370 $context = wp_interactivity_get_context();
371 $state = wp_interactivity_state();
372 if ( ! empty( $context['product']->archived ) || ! empty( $state['isSoldOut']() ) ) {
373 return true;
374 }
375 if ( ! empty( $context['variants'] ) && empty( $state['selectedVariant'] ) ) {
376 return true;
377 }
378 return false;
379 },
380 'isOptionSelected' => function () {
381 $context = wp_interactivity_get_context();
382 $option_number = $context['optionNumber'] ?? '';
383 if ( ! isset( $context['variantValues'][ "option_$option_number" ] ) || ! isset( $context['option_value'] ) ) {
384 return false;
385 }
386 return $context['variantValues'][ "option_$option_number" ] === $context['option_value'];
387 },
388 'isPriceSelected' => function () {
389 $context = wp_interactivity_get_context();
390 if ( ! isset( $context['price'] ) || ! isset( $context['selectedPrice'] ) ) {
391 return false;
392 }
393 return $context['price']['id'] === $context['selectedPrice']['id'];
394 },
395 'buttonText' => function () {
396 $state = wp_interactivity_state();
397 $context = wp_interactivity_get_context();
398 if ( $state['isSoldOut']() ) {
399 return $context['outOfStockText'] ?? $context['text'];
400 }
401 if ( $state['isUnavailable']() ) {
402 return $context['unavailableText'] ?? $context['text'];
403 }
404 return $context['text'] ?? '';
405 },
406 ]
407 );
408 }
409
410 /**
411 * Check if a filtered group of variants is sold out.
412 * Returns false (not sold out) when the group is empty — no matching variants means nothing to sell out.
413 *
414 * @param array $items Filtered variant data arrays.
415 * @param array $product Parent product data for fallback.
416 * @return bool
417 */
418 private static function isVariantGroupSoldOut( array $items, array $product ): bool {
419 $stocks = array_map( fn( $v ) => self::effectiveVariantStock( $v, $product ), array_values( $items ) );
420 if ( empty( $stocks ) ) {
421 return false;
422 }
423 return max( $stocks ) <= 0;
424 }
425
426 /**
427 * Get the effective available stock for a variant, respecting variant-level stock overrides.
428 * Returns PHP_INT_MAX when stock is not tracked, it would be then unlimited stock.
429 *
430 * @param array $item Variant data.
431 * @param array $product Parent product data for fallback.
432 * @return int
433 */
434 private static function effectiveVariantStock( array $item, array $product ): int {
435 $has_unlimited_stock = $item['has_unlimited_stock'] ?? $product['has_unlimited_stock'] ?? false;
436 if ( $has_unlimited_stock ) {
437 return PHP_INT_MAX;
438 }
439 return (int) $item['available_stock'];
440 }
441 }
442