PluginProbe
SureCart – Ecommerce Made Easy For Selling Physical Products, Digital Downloads, Subscriptions, Donations, & Payments / 4.2.2
SureCart – Ecommerce Made Easy For Selling Physical Products, Digital Downloads, Subscriptions, Donations, & Payments v4.2.2
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 All 281 releases
surecart / app / src / Models / Product.php
Product.php
1,269 lines 28.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace SureCart\Models;
4
5 use SureCart\Models\Traits\HasDates;
6 use SureCart\Models\Traits\HasImageSizes;
7 use SureCart\Models\Traits\HasPurchases;
8 use SureCart\Models\Traits\HasCommissionStructure;
9 use SureCart\Models\Traits\CanDuplicate;
10 use SureCart\Support\Contracts\GalleryItem;
11 use SureCart\Support\Contracts\PageModel;
12 use SureCart\Support\Currency;
13 use SureCart\Support\TimeDate;
14
15 /**
16 * Product model
17 */
18 class Product extends Model implements PageModel {
19 use HasImageSizes;
20 use HasPurchases;
21 use HasCommissionStructure;
22 use HasDates;
23 use canDuplicate {
24 duplicate as protected originalDuplicate;
25 }
26
27 /**
28 * These always need to be fetched during create/update in order to sync with post model.
29 *
30 * @var array
31 */
32 protected $sync_expands = array( 'prices', 'product_medias', 'product_media.media', 'variants', 'variant_options', 'product_collections', 'featured_product_media', 'reviews_breakdown' );
33
34 /**
35 * Rest API endpoint
36 *
37 * @var string
38 */
39 protected $endpoint = 'products';
40
41 /**
42 * Object name
43 *
44 * @var string
45 */
46 protected $object_name = 'product';
47
48 /**
49 * Is this cachable?
50 *
51 * @var boolean
52 */
53 protected $cachable = true;
54
55 /**
56 * Clear cache when products are updated.
57 *
58 * @var string
59 */
60 protected $cache_key = 'products';
61
62 /**
63 * Create a new model
64 *
65 * @param array $attributes Attributes to create.
66 *
67 * @return $this|false
68 */
69 protected function create( $attributes = array() ) {
70 // create the model.
71 $created = parent::create( $attributes );
72 if ( is_wp_error( $created ) ) {
73 return $created;
74 }
75
76 // sync with the post.
77 $this->sync();
78
79 // return.
80 return $this;
81 }
82
83 /**
84 * Update a model
85 *
86 * @param array $attributes Attributes to update.
87 *
88 * @return $this|false
89 */
90 protected function update( $attributes = array() ) {
91 // update the model.
92 $updated = parent::update( $attributes );
93 if ( is_wp_error( $updated ) ) {
94 return $updated;
95 }
96
97 // sync with the post.
98 $this->sync();
99
100 // return.
101 return $this;
102 }
103
104 /**
105 * Immediately sync with a post.
106 *
107 * @param string $id The id of the product to sync.
108 *
109 * @return \WP_Post|\WP_Error|self
110 */
111 protected function sync( $id = '' ) {
112 // set the id.
113 if ( ! empty( $id ) ) {
114 $this->id = $id;
115 }
116
117 // we need an id.
118 if ( empty( $this->id ) ) {
119 return new \WP_Error( 'missing_id', __( 'Missing ID', 'surecart' ) );
120 }
121
122 // if there are no syncable expands, let's fetch them.
123 $this->with( $this->sync_expands )->where( array( 'cached' => false ) )->find( $this->id );
124
125 // sync the product.
126 $synced = \SureCart::sync()->product()->sync( $this );
127
128 // on success, cancel any queued syncs.
129 if ( is_wp_error( $synced ) ) {
130 return $synced;
131 }
132
133 return $this;
134 }
135
136 /**
137 * Delete the synced post.
138 *
139 * @param string $id The id of the model to delete.
140 * @return \SureCart\Models\Product
141 */
142 protected function deleteSynced( $id = '' ) {
143 $id = ! empty( $id ) ? $id : $this->id;
144 \SureCart::sync()
145 ->product()
146 ->delete( $id );
147
148 return $this;
149 }
150
151 /**
152 * Queue a sync process with a post.
153 *
154 * @param boolean $show_notice Whether to show a notice.
155 *
156 * @return \SureCart\Background\QueueService
157 */
158 protected function queueSync( $show_notice = false ) {
159 \SureCart::sync()
160 ->product()
161 ->withNotice( $show_notice )
162 ->queue( $this );
163
164 return $this;
165 }
166
167 /**
168 * Maybe queue a sync job if updated_at is different
169 * than the product post updated_at.
170 *
171 * @return \SureCart\Background\QueueService|false Whether the sync was queued.
172 */
173 protected function maybeQueueSync() {
174 // already in sync.
175 if ( $this->synced ) {
176 return false;
177 }
178
179 return $this->queueSync();
180 }
181
182 /**
183 * Update a model
184 *
185 * @param string $id The id of the model to delete.
186 * @return $this|false
187 */
188 protected function delete( $id = '' ) {
189 // delete the model.
190 $deleted = parent::delete( $id );
191
192 // check for errors.
193 if ( is_wp_error( $deleted ) ) {
194 return $deleted;
195 }
196
197 // delete the post.
198 $this->deleteSynced( $id );
199
200 // return.
201 return $this;
202 }
203
204 /**
205 * Duplicate the model.
206 *
207 * @param string $id The id of the model to duplicate.
208 * @return $this|false
209 */
210 protected function duplicate( $id = '' ) {
211 if ( $id ) {
212 $this->attributes['id'] = $id;
213 }
214
215 // get the post duplication data.
216 $duplication_data = $this->getPostDuplicationData();
217
218 // duplicate the model.
219 $duplicated = $this->originalDuplicate( $id );
220
221 // check for errors.
222 if ( is_wp_error( $duplicated ) ) {
223 return $duplicated;
224 }
225
226 // sync with the post.
227 $post = $this->sync();
228
229 // check for errors.
230 if ( is_wp_error( $post ) ) {
231 return $post;
232 }
233
234 // update the post duplication data.
235 $this->updatePostDuplicationData( $duplication_data );
236
237 return $this;
238 }
239
240 /**
241 * Get the post duplication data.
242 *
243 * @return array|false
244 */
245 public function getPostDuplicationData() {
246 // we don't have a post.
247 if ( empty( $this->post ) || empty( $this->post->ID ) ) {
248 return [];
249 }
250
251 // store post content before duplication.
252 $current_post_content = $this->post->post_content ?? '';
253
254 // store post meta before duplication.
255 $post_meta = $this->getPostMeta();
256
257 // store post taxonomies before duplication.
258 $taxonomy_names = get_post_taxonomies( $this->post->ID );
259 $taxonomy_terms = [];
260
261 // store post terms before duplication.
262 foreach ( $taxonomy_names as $taxonomy ) {
263 $terms = wp_get_object_terms( $this->post->ID, $taxonomy, [ 'fields' => 'ids' ] );
264 if ( ! empty( $terms ) ) {
265 $taxonomy_terms[ $taxonomy ] = $terms;
266 }
267 }
268
269 return [
270 'post_content' => $current_post_content,
271 'post_meta' => $post_meta,
272 'taxonomy_terms' => $taxonomy_terms,
273 ];
274 }
275
276 /**
277 * Update the post duplication data.
278 *
279 * @param array $post_data The post data.
280 *
281 * @return void
282 */
283 public function updatePostDuplicationData( $post_data ) {
284 // we don't have a post.
285 if ( empty( $this->post ) || empty( $this->post->ID ) || empty( $post_data ) ) {
286 return;
287 }
288
289 // update the post content.
290 wp_update_post(
291 array(
292 'ID' => $this->post->ID,
293 'post_content' => $post_data['post_content'],
294 )
295 );
296
297 $post_meta = $post_data['post_meta'] ?? array();
298
299 // update the post meta.
300 if ( ! empty( $post_meta ) ) {
301 foreach ( $post_meta as $meta ) {
302 $meta_value = maybe_unserialize( $meta->meta_value );
303 update_post_meta( $this->post->ID, $meta->meta_key, $meta_value );
304 }
305 }
306
307 $taxonomy_terms = $post_data['taxonomy_terms'] ?? array();
308
309 // update the post taxonomies.
310 if ( ! empty( $taxonomy_terms ) ) {
311 foreach ( $taxonomy_terms as $taxonomy => $terms ) {
312 if ( ! empty( $terms ) ) {
313 wp_set_object_terms( $this->post->ID, $terms, $taxonomy );
314 }
315 }
316 }
317 }
318
319 /**
320 * Get post meta, excluding specific keys.
321 *
322 * @return array|false Array of meta_key/meta_value objects or false if no post.
323 */
324 public function getPostMeta() {
325 $skip_keys = [
326 '_edit_lock',
327 '_edit_last',
328 'product',
329 'sc_id',
330 '_wp_trash_meta_status',
331 '_wp_trash_meta_time',
332 ];
333
334 global $wpdb;
335
336 $placeholders = implode( ',', array_fill( 0, count( $skip_keys ), '%s' ) );
337
338 $not_in_clause = "AND meta_key NOT IN ($placeholders)";
339
340 return $wpdb->get_results(
341 $wpdb->prepare(
342 "SELECT meta_key, meta_value FROM {$wpdb->postmeta} WHERE post_id = %d $not_in_clause",
343 array_merge( [ $this->post->ID ], $skip_keys )
344 )
345 );
346 }
347
348 /**
349 * Get the attached post.
350 *
351 * @return int|false
352 */
353 public function getPostAttribute() {
354 return \SureCart::sync()->product()->post()->findByModelId( $this->id );
355 }
356
357 /**
358 * Get the is synced attribute.
359 *
360 * @return bool
361 */
362 protected function getSyncedAttribute() {
363 // we don't have a post.
364 if ( empty( $this->post ) ) {
365 return false;
366 }
367
368 // the post is trashed.
369 if ( 'trash' === $this->post->post_status ) {
370 return false;
371 }
372
373 // this doesn't have updated at.
374 if ( empty( $this->updated_at ) ) {
375 return false;
376 }
377
378 // get the product and decode it.
379 $product = get_post_meta( $this->post->ID, 'product', true );
380 $product = is_string( $product ) ? json_decode( get_post_meta( $this->post->ID, 'product', true ) ) : $product;
381 $product = (object) $product;
382 if ( empty( $product ) || ! isset( $product->updated_at ) ) {
383 return false;
384 }
385
386 // sync if updated at is different.
387 return $this->updated_at === $product->updated_at;
388 }
389
390 /**
391 * Check if model has syncable expands as properties
392 *
393 * @return bool
394 */
395 protected function getHasSyncableExpandsAttribute() {
396 foreach ( $this->sync_expands as $expand ) {
397 // if expand contains a ., let's ignore it for now.
398 if ( false !== strpos( $expand, '.' ) ) {
399 return true;
400 }
401 if ( ! isset( $this->$expand ) ) {
402 return false;
403 }
404 }
405 return true;
406 }
407
408 /**
409 * Get the sync expands.
410 *
411 * @return array
412 */
413 protected function getSyncExpands() {
414 return $this->sync_expands;
415 }
416
417 /**
418 * Maybe queue a sync job if updated_at is different
419 * than the product post updated_at.
420 *
421 * @param string $value The updated_at value.
422 *
423 * @return void
424 */
425 public function setUpdatedAtAttribute( $value ) {
426 $this->attributes['updated_at'] = apply_filters( "surecart/$this->object_name/attributes/updated_at", $value, $this );
427 $this->maybeQueueSync();
428 }
429
430 /**
431 * Image srcset.
432 *
433 * @return string
434 */
435 public function getImageSrcsetAttribute() {
436 if ( empty( $this->attributes['image_url'] ) ) {
437 return '';
438 }
439 return $this->imageSrcSet( $this->attributes['image_url'] );
440 }
441
442 /**
443 * Get the image url for a specific size.
444 *
445 * @param integer $size The size.
446 *
447 * @return string
448 */
449 public function getImageUrl( $size = 0 ) {
450 if ( empty( $this->attributes['image_url'] ) ) {
451 return '';
452 }
453 return $size ? $this->imageUrl( $this->attributes['image_url'], $size ) : $this->attributes['image_url'];
454 }
455
456 /**
457 * Set the prices attribute.
458 *
459 * @param object $value Array of price objects.
460 * @return void
461 */
462 public function setPricesAttribute( $value ) {
463 $this->setCollection( 'prices', $value, Price::class );
464 }
465
466 /**
467 * Set the product collections attribute
468 *
469 * @param object $value Product collections.
470 * @return void
471 */
472 public function setProductCollectionsAttribute( $value ) {
473 $this->setCollection( 'product_collections', $value, ProductCollection::class );
474 }
475
476 /**
477 * Set the variants attribute.
478 *
479 * @param object $value Array of price objects.
480 * @return void
481 */
482 public function setVariantsAttribute( $value ) {
483 $this->setCollection( 'variants', $value, Variant::class );
484 }
485
486 /**
487 * Set the variants attribute.
488 *
489 * @param object $value Array of price objects.
490 * @return void
491 */
492 public function setVariantOptionsAttribute( $value ) {
493 $this->setCollection( 'variant_options', $value, VariantOption::class );
494 }
495
496 /**
497 * Set the featured product media attribute.
498 *
499 * @param string $value Product properties.
500 * @return void
501 */
502 public function setFeaturedProductMediaAttribute( $value ) {
503 $this->setRelation( 'featured_product_media', $value, ProductMedia::class );
504 }
505
506 /**
507 * Set the product media attribute
508 *
509 * @param string $value ProductMedia properties.
510 * @return void
511 */
512 public function setProductMediasAttribute( $value ) {
513 $this->setCollection( 'product_medias', $value, ProductMedia::class );
514 }
515
516 /**
517 * Buy link model
518 *
519 * @return \SureCart\Models\BuyLink
520 */
521 public function buyLink() {
522 return new BuyLink( $this );
523 }
524
525 /**
526 * Checkout Permalink.
527 *
528 * @return string
529 */
530 public function getCheckoutPermalinkAttribute() {
531 return $this->buyLink()->url();
532 }
533
534 /**
535 * Get the product permalink.
536 *
537 * @return string
538 */
539 public function getPermalinkAttribute(): string {
540 return ! empty( $this->post ) ? get_the_permalink( $this->post->ID ) : '';
541 }
542
543 /**
544 * Is the post published?
545 *
546 * @return string
547 */
548 public function getIsPublishedAttribute(): bool {
549 return ! empty( $this->post ) && 'publish' === $this->post->post_status;
550 }
551
552 /**
553 * Get the page title.
554 *
555 * @return string
556 */
557 public function getPageTitleAttribute(): string {
558 return $this->metadata->page_title ?? $this->name ?? '';
559 }
560
561 /**
562 * Get the meta description.
563 *
564 * @return string
565 */
566 public function getMetaDescriptionAttribute(): string {
567 return $this->metadata->meta_description ?? $this->description ?? '';
568 }
569
570 /**
571 * Get the product in stock attribute.
572 *
573 * @param Product $product The product.
574 *
575 * @return bool
576 */
577 public function getInStockAttribute(): bool {
578 if ( ! $this->stock_enabled ) {
579 return true;
580 }
581
582 if ( $this->allow_out_of_stock_purchases ) {
583 return true;
584 }
585
586 return $this->available_stock > 0;
587 }
588
589 /**
590 * Return attached active prices.
591 *
592 * @return array
593 */
594 public function getActivePricesAttribute() {
595 $active_prices = array_values(
596 array_filter(
597 $this->prices->data ?? array(),
598 function ( $price ) {
599 return ! $price->archived;
600 }
601 )
602 );
603
604 usort(
605 $active_prices,
606 function ( $a, $b ) {
607 if ( $a->position == $b->position ) {
608 return 0;
609 }
610 return ( $a->position < $b->position ) ? -1 : 1;
611 }
612 );
613
614 return $active_prices;
615 }
616
617 /**
618 * Get the has variants attribute.
619 *
620 * @return boolean
621 */
622 public function getHasVariantsAttribute() {
623 return ! empty( $this->variants->data ?? [] );
624 }
625
626 /**
627 * Get the has multiple prices attribute.
628 *
629 * @return boolean
630 */
631 public function getHasMultiplePricesAttribute() {
632 return count( $this->active_prices ) > 1;
633 }
634
635 /**
636 * Return attached active prices.
637 */
638 public function getActiveAdHocPricesAttribute() {
639 return array_filter(
640 $this->active_prices ?? array(),
641 function ( $price ) {
642 return $price->ad_hoc;
643 }
644 );
645 }
646
647 /**
648 * Get the has options attribute.
649 * Determines if product has options (variants, multiple prices, or ad hoc pricing).
650 *
651 * @return boolean
652 */
653 public function getHasOptionsAttribute() {
654 // Check if product has variant options.
655 return $this->has_variants || $this->has_multiple_prices || ! empty( $this->active_ad_hoc_prices );
656 }
657
658 /**
659 * Get the featured image attribute.
660 *
661 * @return \SureCart\Support\Contracts\GalleryItem|null;
662 */
663 public function getFeaturedImageAttribute() {
664 $gallery = array_values( $this->gallery ?? array() );
665 $first_media = $gallery[0] ?? [];
666
667 if ( $first_media instanceof GalleryItemVideoAttachment ) {
668 return $this->getVideoThumbnailOrFallback( $first_media, $gallery );
669 }
670
671 if ( ! empty( $first_media ) ) {
672 return $first_media;
673 }
674
675 if ( empty( $this->featured_product_media ) ) {
676 return null;
677 }
678 if ( ! is_a( $this->featured_product_media, \SureCart\Models\ProductMedia::class ) ) {
679 return null;
680 }
681 return new GalleryItemProductMedia( $this->featured_product_media );
682 }
683
684 /**
685 * Returns the product media image attributes.
686 *
687 * @return \SureCart\Support\Contracts\GalleryItem|null;
688 */
689 public function getFeaturedMediaAttribute() {
690 return $this->featured_product_image;
691 }
692
693 /**
694 * Get the product template id.
695 *
696 * @return string
697 */
698 public function getTemplateIdAttribute(): string {
699 if ( ! empty( $this->metadata->wp_template_id ) ) {
700 // we have a php file, switch to default.
701 if ( wp_is_block_theme() && false !== strpos( $this->metadata->wp_template_id, '.php' ) ) {
702 return 'single-sc_product';
703 }
704
705 // this is acceptable.
706 return $this->metadata->wp_template_id;
707 }
708
709 return '';
710 }
711
712 /**
713 * Get with sorted prices.
714 *
715 * @return self
716 */
717 public function withSortedPrices() {
718 if ( empty( $this->prices->data ) ) {
719 return $this;
720 }
721
722 $filtered = clone $this;
723
724 // Sort prices by position.
725 usort(
726 $filtered->prices->data,
727 function ( $a, $b ) {
728 return $a->position - $b->position;
729 }
730 );
731
732 return $filtered;
733 }
734
735 /**
736 * Get product with active and sorted prices.
737 *
738 * @return self
739 */
740 public function withActivePrices() {
741 if ( empty( $this->prices->data ) ) {
742 return $this;
743 }
744
745 $filtered = clone $this;
746
747 // Filter out archived prices.
748 $filtered->prices->data = array_values(
749 array_filter(
750 $filtered->prices->data ?? array(),
751 function ( $price ) {
752 return ! $price->archived;
753 }
754 )
755 );
756
757 return $filtered;
758 }
759
760 /**
761 * Get the trial text attribute.
762 *
763 * @return string
764 */
765 public function getTrialTextAttribute() {
766 return $this->initial_price ? $this->initial_price->trial_text ?? '' : '';
767 }
768
769 /**
770 * Get the billing interval attribute.
771 *
772 * @return string
773 */
774 public function getBillingIntervalTextAttribute() {
775 return $this->initial_price ? $this->initial_price->interval_text ?? '' : '';
776 }
777
778 /**
779 * Get the setup fee attribute
780 *
781 * @return string
782 */
783 public function getSetupFeeTextAttribute() {
784 return $this->initial_price ? $this->initial_price->setup_fee_text ?? '' : '';
785 }
786
787 /**
788 * Is the product or any variants in stock.
789 *
790 * @return int
791 */
792 public function getHasUnlimitedStockAttribute() {
793 if ( empty( $this->stock_enabled ) ) {
794 return true;
795 }
796 return $this->allow_out_of_stock_purchases;
797 }
798
799 /**
800 * Get the first variant with stock.
801 *
802 * @return \SureCart\Models\Variant;
803 */
804 public function getFirstVariantWithStockAttribute() {
805 return $this->in_stock_variants[0] ?? null;
806 }
807
808 /**
809 * Get the in stock variants.
810 *
811 * @return array
812 */
813 public function getInStockVariantsAttribute() {
814 if ( ! $this->has_unlimited_stock && ! empty( $this->variants->data ) ) {
815 return array_map(
816 function ( $variant ) {
817 return $variant->available_stock > 0;
818 },
819 $this->variants->data,
820 );
821 }
822 return $this->variants->data ?? null;
823 }
824
825 /**
826 * Get the initial price.
827 *
828 * @return string
829 */
830 public function getInitialPriceAttribute() {
831 $prices = $this->active_prices ?? array();
832 $initial_price = $prices[0] ?? null;
833 return $initial_price;
834 }
835
836 /**
837 * Get the initial variant.
838 *
839 * @return string
840 */
841 public function getInitialVariantAttribute() {
842 $initial_variant = $this->first_variant_with_stock;
843 if ( ! empty( $initial_variant ) ) {
844 return $initial_variant;
845 }
846 return $this->variants->data[0] ?? null;
847 }
848
849 /**
850 * Get the initial amount.
851 *
852 * @return string
853 */
854 public function getInitialAmountAttribute() {
855 $initial_price = $this->initial_price;
856 if ( count( $this->active_prices ) > 1 ) {
857 return $this->initial_price->amount;
858 }
859
860 $initial_variant = $this->initial_variant;
861 if ( ! empty( $initial_variant->amount ) ) {
862 return $initial_variant->amount;
863 }
864
865 return $initial_price->amount ?? null;
866 }
867
868 /**
869 * Get the scratch amount.
870 *
871 * @return string
872 */
873 public function getScratchAmountAttribute() {
874 $prices = $this->active_prices ?? array();
875 $initial_price = $prices[0] ?? null;
876 return $initial_price->scratch_amount ?? null;
877 }
878
879 /**
880 * Get the scratch display amount.
881 *
882 * @return string
883 */
884 public function getScratchDisplayAmountAttribute() {
885 if ( empty( $this->initial_price->scratch_amount ) ) {
886 return '';
887 }
888 return Currency::format( $this->initial_price->scratch_amount, $this->initial_price->currency );
889 }
890
891 /**
892 * Get the product template
893 *
894 * @return \WP_Template
895 */
896 public function getTemplateAttribute() {
897 return null;
898 }
899
900 /**
901 * Get the product template id.
902 *
903 * @return string
904 */
905 public function getTemplatePartIdAttribute(): string {
906 if ( ! empty( $this->metadata->wp_template_part_id ) ) {
907 return $this->metadata->wp_template_part_id;
908 }
909 return 'surecart/surecart//product-info';
910 }
911
912 /**
913 * Get the gallery ids attribute.
914 *
915 * @return array
916 */
917 public function getGalleryIdsAttribute() {
918 // fallback.
919 if ( empty( $this->metadata->gallery_ids ) ) {
920 return array_values(
921 array_filter(
922 array_map(
923 function ( $media ) {
924 return $media->id ?? null;
925 },
926 $this->product_medias->data ?? array()
927 ),
928 function ( $id ) {
929 return ! empty( $id );
930 }
931 )
932 );
933 }
934
935 // Get the raw gallery ids from metadata.
936 $gallery_ids = $this->metadata->gallery_ids ?? '';
937
938 // Check if it's already an array, if not, we need to decode it.
939 if ( is_array( $gallery_ids ) ) {
940 return $gallery_ids;
941 }
942
943 // If the JSON has been corrupted to PHP syntax, fix it.
944 if ( is_string( $gallery_ids ) && strpos( $gallery_ids, '=>' ) !== false ) {
945 $gallery_ids = str_replace( ' => ', ': ', $gallery_ids );
946 }
947
948 $decoded = json_decode( $gallery_ids, true );
949 return is_array( $decoded ) ? $decoded : array();
950 }
951
952 /**
953 * Set the gallery ids attribute.
954 * This needs to be converted to JSON for the platform.
955 *
956 * @param array $value The gallery array.
957 * @return void
958 */
959 public function setGalleryIdsAttribute( $value ) {
960 $this->attributes['metadata'] = (object) ( $this->attributes['metadata'] ?? [] );
961 $this->attributes['metadata']->gallery_ids = is_string( $value ) ? $value : wp_json_encode( $value );
962 }
963
964 /**
965 * Get the gallery attribute.
966 *
967 * Map the post gallery array to GalleryItem objects.
968 *
969 * @return GalleryItem[]
970 */
971 public function getGalleryAttribute() {
972 $cached = $this->getCachedAttribute( 'gallery' );
973 if ( null !== $cached ) {
974 return $cached;
975 }
976
977 // Get gallery_ids using the accessor method which handles metadata parsing.
978 $gallery_ids = $this->getGalleryIdsAttribute();
979 if ( ! is_array( $gallery_ids ) ) {
980 $gallery_ids = array();
981 }
982
983 $product_featured_image = $this->getFeaturedImageAttribute();
984
985 $gallery = array_values(
986 array_filter(
987 array_map(
988 function ( $gallery_item ) use ( $product_featured_image ) {
989 // Extract the ID from the gallery item (can be int, string(ProductMedia) or object).
990 $id = is_string( $gallery_item ) ? $gallery_item : ( is_int( $gallery_item ) ? intval( $gallery_item ) : intval( ( (object) $gallery_item )->id ?? 0 ) );
991
992 // this is an attachment id.
993 if ( is_int( $id ) ) {
994 $attachment = GalleryItemAttachment::create( $gallery_item, $product_featured_image );
995
996 // If no attachment, return null.
997 if ( empty( $attachment ) || ! $attachment->exists() ) {
998 return null;
999 }
1000
1001 if ( is_object( $gallery_item ) || is_array( $gallery_item ) ) {
1002 $item = (object) $gallery_item;
1003 $attachment->setMetadata( 'variant_option', $item->variant_option ?? null );
1004 $attachment->setMetadata( 'thumbnail_image', $item->thumbnail_image ?? null );
1005 $attachment->setMetadata( 'aspect_ratio', $item->aspect_ratio ?? null );
1006 $attachment->setMetadata( 'controls', $item->controls ?? true );
1007 $attachment->setMetadata( 'autoplay', $item->autoplay ?? false );
1008 $attachment->setMetadata( 'loop', $item->loop ?? false );
1009 $attachment->setMetadata( 'muted', $item->muted ?? false );
1010 }
1011
1012 return $attachment;
1013 }
1014
1015 // get the product media item that matches the id.
1016 $item = array_filter(
1017 $this->getAttribute( 'product_medias' )->data ?? array(),
1018 function ( $item ) use ( $id ) {
1019 return $item->id === $id;
1020 }
1021 );
1022
1023 // get the first item.
1024 $item = array_shift( $item );
1025 if ( ! empty( $item ) ) {
1026 return new GalleryItemProductMedia( $item );
1027 }
1028
1029 return null;
1030 },
1031 $this->gallery_ids ?? []
1032 ),
1033 function ( $item ) {
1034 // it must have a src at least.
1035 return ! empty( $item ) && $item->exists();
1036 }
1037 )
1038 );
1039
1040 $this->setAttributeCache( 'gallery', $gallery );
1041
1042 return $gallery;
1043 }
1044
1045 /**
1046 * Get the price display amount.
1047 *
1048 * @return array
1049 */
1050 public function getDisplayAmountAttribute() {
1051 $prices = $this->active_prices ?? array();
1052
1053 // only if we have one price.
1054 if ( count( $prices ) === 1 ) {
1055 $initial_variant = $this->first_variant_with_stock;
1056 if ( ! empty( $initial_variant->amount ) ) {
1057 return Currency::format( $initial_variant->amount, $initial_variant->currency );
1058 }
1059 }
1060
1061 // we don't have an initial price.
1062 if ( empty( $this->initial_price ) ) {
1063 return '';
1064 }
1065
1066 // return the formatted amount.
1067 return Currency::format( $this->initial_price->amount, $this->initial_price->currency );
1068 }
1069
1070 /**
1071 * Get Price Range Display Amount.
1072 *
1073 * @return string
1074 */
1075 public function getRangeDisplayAmountAttribute() {
1076 // there are no metrics.
1077 if ( ! $this->metrics || empty( $this->metrics->min_price_amount ) || empty( $this->metrics->max_price_amount ) ) {
1078 return '';
1079 }
1080
1081 // the min and max are the same.
1082 if ( $this->metrics->min_price_amount === $this->metrics->max_price_amount ) {
1083 return Currency::format( $this->metrics->min_price_amount, $this->metrics->currency );
1084 }
1085
1086 // return the range.
1087 return sprintf(
1088 // translators: %1$1s is the min price, %2$2s is the max price.
1089 __(
1090 '%1$1s - %2$2s',
1091 'surecart',
1092 ),
1093 Currency::format( $this->metrics->min_price_amount, $this->metrics->currency ),
1094 Currency::format( $this->metrics->max_price_amount, $this->metrics->currency )
1095 );
1096 }
1097
1098 /**
1099 * Is the product on sale?
1100 *
1101 * @return array
1102 */
1103 public function getIsOnSaleAttribute() {
1104 return $this->initial_price->is_on_sale ?? false;
1105 }
1106
1107 /**
1108 * Get the image used in line items.
1109 *
1110 * @return object
1111 */
1112 public function getLineItemImageAttribute() {
1113 if ( ! is_a( $this->featured_image, GalleryItem::class ) ) {
1114 return (object) array(
1115 'src' => apply_filters( 'surecart/product-line-item-image/fallback_src', \SureCart::core()->assets()->getUrl() . '/images/image-placeholder.svg', $this ),
1116 'type' => 'fallback',
1117 );
1118 }
1119
1120 return sc_sanitize_image_attributes( $this->featured_image->attributes( 'thumbnail' ) );
1121 }
1122
1123 /**
1124 * Get the image used in line items.
1125 *
1126 * @return object
1127 */
1128 public function getPreviewImageAttribute() {
1129 return is_a( $this->featured_image, GalleryItem::class ) ? sc_sanitize_image_attributes( $this->featured_image->attributes( 'medium_large' ) ) : (object) [];
1130 }
1131
1132 /**
1133 * Get the product page initial state
1134 *
1135 * @param array $args Array of arguments.
1136 *
1137 * @return array
1138 */
1139 public function getInitialPageState( $args = [] ) {
1140 $form = \SureCart::forms()->getDefault();
1141
1142 return wp_parse_args(
1143 $args,
1144 [
1145 'formId' => $form->ID,
1146 'mode' => \SureCart\Models\Form::getMode( $form->ID ),
1147 'product' => $this,
1148 'prices' => $this->active_prices,
1149 'selectedPrice' => ( $this->active_prices ?? [] )[0] ?? null,
1150 'checkoutUrl' => \SureCart::pages()->url( 'checkout' ),
1151 'variant_options' => $this->variant_options->data ?? [],
1152 'variants' => $this->variants->data ?? [],
1153 'selectedVariant' => $this->initial_variant ?? null,
1154 'isProductPage' => ! empty( get_query_var( 'surecart_current_product' )->id ),
1155 ]
1156 );
1157 }
1158
1159 /**
1160 * Get the cataloged at date time attribute.
1161 *
1162 * @return string
1163 */
1164 public function getCatalogedAtDateTimeAttribute() {
1165 return ! empty( $this->cataloged_at ) ? TimeDate::formatDateAndTime( $this->cataloged_at ) : '';
1166 }
1167
1168 /**
1169 * Get the video thumbnail or fallback to the next image in the gallery.
1170 *
1171 * @param GalleryItemAttachment $first_media The first media item.
1172 * @param array $gallery The gallery items.
1173 *
1174 * @return GalleryItemAttachment|null
1175 */
1176 private function getVideoThumbnailOrFallback( $first_media, $gallery ) {
1177 $thumbnail_image = $first_media->getMetadata( 'thumbnail_image' ) ?? null;
1178 if ( ! empty( $thumbnail_image ) ) {
1179 $attachment = GalleryItemAttachment::create( $thumbnail_image );
1180 if ( ! empty( $attachment ) && $attachment->exists() ) {
1181 return $attachment;
1182 }
1183 }
1184
1185 // If no thumbnail, look for next image in gallery.
1186 foreach ( $gallery as $media ) {
1187 if ( false !== strpos( $media->post_mime_type ?? '', 'image' ) ) {
1188 return $media;
1189 }
1190 }
1191
1192 return null;
1193 }
1194
1195 /**
1196 * Get if the product has videos.
1197 *
1198 * @return bool
1199 */
1200 public function getHasVideosAttribute(): bool {
1201 return ! empty( array_filter( $this->gallery, fn( $media ) => $media->isVideo() ) );
1202 }
1203
1204 /**
1205 * Get if the product reviews are enabled.
1206 *
1207 * @return bool
1208 */
1209 public function getReviewsEnabledAttribute(): bool {
1210 if ( empty( \SureCart::account()->review_protocol->reviews_enabled ) ) {
1211 return false;
1212 }
1213 return $this->attributes['reviews_enabled'] ?? true;
1214 }
1215
1216 /**
1217 * Get the total reviews count from reviews_breakdown.
1218 *
1219 * @return int
1220 */
1221 public function getTotalReviewsAttribute(): int {
1222 if ( empty( $this->reviews_breakdown ) ) {
1223 return 0;
1224 }
1225 return array_sum( (array) $this->reviews_breakdown );
1226 }
1227
1228 /**
1229 * Get the reviews breakdown as an array with proper structure.
1230 * Ensures all star ratings (1-5) are present with default value of 0.
1231 *
1232 * @return array
1233 */
1234 public function getReviewsBreakdownArrayAttribute(): array {
1235 $breakdown = array_merge(
1236 array(
1237 1 => 0,
1238 2 => 0,
1239 3 => 0,
1240 4 => 0,
1241 5 => 0,
1242 ),
1243 (array) ( $this->reviews_breakdown ?? array() )
1244 );
1245
1246 // Ensure all values are integers and sort by key.
1247 ksort( $breakdown );
1248 return array_map( 'intval', $breakdown );
1249 }
1250
1251 /**
1252 * Get the review url attribute.
1253 *
1254 * @return string
1255 */
1256 public function getReviewUrlAttribute(): string {
1257 if ( ! $this->reviews_enabled ) {
1258 return '';
1259 }
1260
1261 $product_post_id = $this->post->ID ?? null;
1262 if ( empty( $product_post_id ) ) {
1263 return '';
1264 }
1265
1266 return $this->permalink ? add_query_arg( 'product-review-form', $product_post_id, $this->permalink ) : '';
1267 }
1268 }
1269