Product.php
| 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 |