PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.7.0
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.7.0
1.7.0 1.6.6 1.6.5 1.6.4 1.6.3 1.6.2 1.6.1 1.6.0 1.5.4 1.5.5 1.5.3 1.5.2 1.5.1 1.5.0 1.4.2 1.4.1 1.4.0 1.3.28 1.3.27 1.3.26 1.3.25 1.3.23 1.3.22 1.3.21 1.3.20 All 50 releases
← All changes | app/Services/Renderer/ProductRenderer.php +556 -173 1.3.22 → 1.7.0 View file →
@@ -3,9 +3,8 @@
3 3 namespace FluentCart\App\Services\Renderer;
4 4
5 5 use FluentCart\App\App;
6 6 use FluentCart\App\Services\FrontendView;
7 -use FluentCart\App\Vite;
8 7 use FluentCart\Api\StoreSettings;
9 8 use FluentCart\Api\ModuleSettings;
10 9 use FluentCart\App\Helpers\Helper;
11 10 use FluentCart\App\Models\Product;
@@ -13,8 +12,9 @@
13 12 use FluentCart\App\Http\Routes\WebRoutes;
14 13 use FluentCart\App\Models\ProductVariation;
15 14 use FluentCart\Framework\Support\Collection;
16 15 use FluentCart\App\Modules\Templating\AssetLoader;
16 +use FluentCart\App\Modules\FluentPlayer\ProductVideoRenderer;
17 17
18 18 class ProductRenderer
19 19 {
20 20 protected $product;
@@ -38,8 +38,10 @@
38 38 protected $defaultGalleryImageId = 0;
39 39
40 40 protected $galleryActiveSet = false;
41 41
42 + protected $productVideoRenderer = null;
43 +
42 44 protected $paymentTypes = [];
43 45
44 46 protected $variantsByPaymentTypes = [];
45 47
@@ -46,8 +48,10 @@
46 48 protected $activeTab = 'onetime';
47 49
48 50 protected $images = [];
49 51
52 + protected $variantTermMap = [];
53 +
50 54 protected $defaultImageUrl = null;
51 55
52 56 protected $defaultImageAlt = null;
53 57
@@ -83,10 +87,41 @@
83 87 if (!$defaultVariationId) {
84 88 $variationIds = $product->variants->pluck('id')->toArray();
85 89 $defaultVariationId = $product->detail->default_variation_id;
86 90
87 - if (!$defaultVariationId || !in_array($defaultVariationId, $variationIds)) {
88 - $defaultVariationId = Arr::get($variationIds, '0');
91 + // For advanced variations the storefront selector only exposes ACTIVE
92 + // variants (AdvancedVariationRenderer skips item_status != active), so
93 + // resolve the default against the same set. default_variation_id is
94 + // maintained stock/status-agnostically, so it can legitimately point
95 + // at an inactive variant — accepting it here would render the inactive
96 + // default's price/stock/button while the selector omits that id and
97 + // falls back to a different active variant. Scoped to advanced so
98 + // simple / simple_variations keep their existing default handling.
99 + $isAdvanced = $product->detail
100 + && $product->detail->variation_type === Helper::PRODUCT_TYPE_ADVANCE_VARIATION;
101 + $eligibleVariants = $isAdvanced
102 + ? $product->variants->where('item_status', 'active')
103 + : $product->variants;
104 + $eligibleIds = $eligibleVariants->pluck('id')->toArray();
105 +
106 + if (!$defaultVariationId || !in_array($defaultVariationId, $eligibleIds)) {
107 + // No valid stored default — fall back to the FIRST eligible
108 + // combination by serial_index (active-only for advanced), not the
109 + // first by DB/id order, so the server-rendered price/stock/button
110 + // match what the storefront selector highlights. Stock is ignored
111 + // — first by order wins. A NULL serial_index (legacy/malformed row
112 + // the merchant never ordered) is pushed AFTER numbered variants so
113 + // it can't become the default ahead of an explicitly ordered one —
114 + // PHP's default null-first sort would otherwise promote it, and the
115 + // frontend mirrors this by treating null serial as last too.
116 + $firstBySerial = $eligibleVariants
117 + ->sortBy(function ($variant) {
118 + return is_null($variant->serial_index)
119 + ? PHP_INT_MAX
120 + : (int) $variant->serial_index;
121 + })
122 + ->first();
123 + $defaultVariationId = $firstBySerial ? (int) $firstBySerial->id : Arr::get($variationIds, '0');
89 124 $hasExplicitDefault = false;
90 125 }
91 126 }
92 127
@@ -104,13 +139,17 @@
104 139 foreach ($this->product->variants as $variant) {
105 140 if ($variant->id == $this->defaultVariationId) {
106 141 $this->defaultVariant = $variant;
107 142 }
108 - $paymentType = Arr::get($variant->other_info, 'payment_type');
109 - if ($paymentType === 'onetime') {
143 + // Read the authoritative payment_type column, not other_info. The
144 + // ProductVariation accessor only injects payment_type into other_info
145 + // for subscriptions, so one-time variants (notably advanced-variation
146 + // combinations generated by Pro) leave other_info['payment_type']
147 + // unset. Anything that is not a subscription is a one-time path.
148 + if ($variant->payment_type === 'subscription') {
149 + $this->hasSubscription = true;
150 + } else {
110 151 $this->hasOnetime = true;
111 - } else if ($paymentType === 'subscription') {
112 - $this->hasSubscription = true;
113 152 }
114 153 }
115 154
116 155 $this->buildProductGroups();
@@ -257,8 +296,33 @@
257 296 }
258 297
259 298 public function renderBuySection($atts = [])
260 299 {
300 + $gateContext = RenderGate::context($this->product, RenderGate::SCOPE_SINGLE, $this->defaultVariant);
301 +
302 + // The buy section is the root the variation-selector JS binds to
303 + // (data-fluent-cart-product-pricing-section). Gating it removes the
304 + // variation picker along with the buttons, which is what a full catalog
305 + // mode wants; to keep shoppers able to browse options while hiding only
306 + // the purchase affordances, gate 'actions' instead.
307 + if (!RenderGate::shouldRender('buy_section', $gateContext)) {
308 + return;
309 + }
310 +
311 + // Render no buy section when there is nothing purchasable — avoids a
312 + // broken quantity + "Not Available" block. Two cases:
313 + // - no variants at all (any product type), or
314 + // - an advanced-variation product with no attribute_config yet (e.g.
315 + // switched on before options were configured). Its variants are kept
316 + // until generation, so we key on the config, not the variant count,
317 + // to hide it until real combinations exist.
318 + $isUnconfiguredAdvanced = $this->product->detail->variation_type === Helper::PRODUCT_TYPE_ADVANCE_VARIATION
319 + && empty(Arr::get((array) $this->product->detail->other_info, 'attribute_config'));
320 +
321 + if ($this->product->variants->isEmpty() || $isUnconfiguredAdvanced) {
322 + return;
323 + }
324 +
261 325 $otherInfo = (array)Arr::get($this->product->detail, 'other_info');
262 326 $groupBy = Arr::get($otherInfo, 'group_pricing_by', 'repeat_interval'); //repeat_interval,payment_type,none
263 327
264 328 $this->renderBuySectionWrapperStart();
@@ -277,8 +341,25 @@
277 341 }
278 342
279 343 public function renderVariationDisplay($atts = [])
280 344 {
345 + // Hand off rendering to the advanced-variation selector when the
346 + // product is configured for advanced variations. The handler returns
347 + // the filtered array with rendered=true after emitting its markup; if
348 + // no listener handles it (e.g. an unconfigured advanced product), the
349 + // filter is a no-op and we fall through to the simple-variation
350 + // rendering below.
351 + if ($this->product->detail->variation_type === Helper::PRODUCT_TYPE_ADVANCE_VARIATION) {
352 + $result = apply_filters('fluent_cart/product/render_advanced_variation', [
353 + 'product' => $this->product,
354 + 'selector_style' => Arr::get($atts, 'selector_style', 'auto'),
355 + 'rendered' => false,
356 + ]);
357 + if (!empty($result['rendered'])) {
358 + return;
359 + }
360 + }
361 +
281 362 $otherInfo = (array)Arr::get($this->product->detail, 'other_info');
282 363 $groupBy = Arr::get($otherInfo, 'group_pricing_by', 'repeat_interval'); //repeat_interval,payment_type,none
283 364
284 365 if (count($this->paymentTypes) === 1 || $groupBy === 'none') {
@@ -291,12 +372,15 @@
291 372 public function renderGalleryThumb()
292 373 {
293 374 $thumbnails = [];
294 375
295 - $featuredMedia = $this->product->thumbnail ?? Vite::getAssetUrl('images/placeholder.svg');
376 + $featuredMedia = $this->product->thumbnail ?? Helper::getProductPlaceholderUrl();
296 377
297 - if (!$featuredMedia) {
298 - $featuredMedia = [];
378 + // thumbnail can be an empty string (not null), so ?? above doesn't catch
379 + // it — fall back to the placeholder so $featuredMedia is always a usable
380 + // image URL for both the main <img> and data-default-image-url.
381 + if (!$featuredMedia || !\is_string($featuredMedia)) {
382 + $featuredMedia = Helper::getProductPlaceholderUrl();
299 383 }
300 384
301 385 $galleryImage = get_post_meta($this->product->ID, 'fluent-products-gallery-image', true);
302 386
@@ -329,14 +413,37 @@
329 413 if (isset($images[$imageId])) {
330 414 $imageMetaValue = $images[$imageId];
331 415 $this->defaultImageUrl = Arr::get($imageMetaValue, 'media.0.url', '');
332 416 $this->defaultImageAlt = Arr::get($imageMetaValue, 'media.0.title', '');
417 + } else {
418 + // Fallback to the first available thumbnail. Advanced-variation
419 + // products often have per-variant images but no explicit
420 + // default_variation_id — the thumbnails are keyed by real
421 + // variant IDs while defaultGalleryImageId is 0, so the lookup
422 + // above misses and the main image area renders blank with a
423 + // broken-image icon.
424 + $firstImage = reset($images);
425 + $fallbackUrl = Arr::get($firstImage, 'media.0.url', '');
426 + $this->defaultImageUrl = $fallbackUrl ?: ($featuredMedia ?: '');
427 + $this->defaultImageAlt = $this->defaultImageAlt ?: Arr::get($firstImage, 'media.0.title', '');
333 428 }
334 429 }
335 430
431 + // Nothing set a main image — e.g. a product with no variants, or no
432 + // variant/gallery media. Fall back to the featured/placeholder image so
433 + // the main area shows the placeholder instead of a broken <img src="">.
434 + if (empty($this->defaultImageUrl)) {
435 + $this->defaultImageUrl = $featuredMedia;
436 + }
437 +
438 + $videoRenderer = $this->getProductVideoRenderer();
439 + $videoRenderer->showFirstByDefault(!$this->hasGalleryImages());
440 + $defaultVideoId = $videoRenderer->getDefaultMediaId();
441 +
336 442 ?>
337 - <div class="fct-product-gallery-thumb" role="region"
338 - aria-label="<?php echo esc_attr($this->product->post_title . ' gallery'); ?>">
443 + <div class="fct-product-gallery-thumb<?php echo $defaultVideoId ? ' is-video-active' : ''; ?>" role="region"
444 + aria-label="<?php echo esc_attr($this->product->post_title . ' gallery'); ?>"
445 + <?php echo $defaultVideoId ? 'data-fct-video-default="' . esc_attr((string) $defaultVideoId) . '"' : ''; ?>>
339 446 <img
340 447 src="<?php echo esc_url($this->defaultImageUrl ?? '') ?>"
341 448 alt="<?php echo esc_attr($this->defaultImageAlt) ?>"
342 449 data-fluent-cart-single-product-page-product-thumbnail
@@ -341,36 +448,117 @@
341 448 alt="<?php echo esc_attr($this->defaultImageAlt) ?>"
342 449 data-fluent-cart-single-product-page-product-thumbnail
343 450 data-default-image-url="<?php echo esc_url($featuredMedia) ?>"
344 451 />
452 + <?php $this->getProductVideoRenderer()->renderInlinePlayers(); ?>
345 453 </div>
346 454 <?php
347 455 }
348 456
457 + /**
458 + * Whether any gallery / variant image exists to show in the main area.
459 + * Only meaningful after renderGalleryThumb() has built $this->images.
460 + */
461 + protected function hasGalleryImages(): bool
462 + {
463 + foreach ($this->images as $image) {
464 + foreach ((array) Arr::get($image, 'media', []) as $item) {
465 + if (!empty(Arr::get($item, 'url'))) {
466 + return true;
467 + }
468 + }
469 + }
470 +
471 + return false;
472 + }
473 +
474 + public function getProductVideoRenderer(): ProductVideoRenderer
475 + {
476 + if ($this->productVideoRenderer === null) {
477 + $this->productVideoRenderer = new ProductVideoRenderer($this->product);
478 + }
479 +
480 + return $this->productVideoRenderer;
481 + }
482 +
349 483 public function renderGalleryThumbControls($maxThumbnails = null)
350 484 {
351 485 $totalThumbImages = Arr::pluck($this->images, 'media.*.url');
352 486
353 - if(count($totalThumbImages) == 1 && is_countable($totalThumbImages[0]) && count($totalThumbImages[0]) == 1){
487 + // A lone image needs no strip, but a video thumb still has to be reachable.
488 + if(count($totalThumbImages) == 1 && is_countable($totalThumbImages[0]) && count($totalThumbImages[0]) == 1 && !$this->getProductVideoRenderer()->isAvailable()){
354 489
355 490 return '';
356 491 }
357 492
358 - // Collect ALL gallery images as JSON for lightbox (even when max thumbnails limits visible thumbs)
493 + // A lone video without images is already showing in the main area.
494 + if (!$this->hasGalleryImages() && count($this->getProductVideoRenderer()->getVideos()) === 1) {
495 + return '';
496 + }
497 +
498 + // Build variantTermMap and variantFirstMediaMap.
499 + // For advanced variations, Pro builds both maps via the gallery_variation_data
500 + // filter — it has access to AttributeGroup types (color/image) that free cannot
501 + // query directly. For all other product types, free builds variantFirstMediaMap
502 + // from the already-loaded $this->images; variantTermMap stays empty.
503 + $this->variantTermMap = [];
504 + $variantFirstMediaMap = [];
505 +
506 + if ($this->product->detail->variation_type === Helper::PRODUCT_TYPE_ADVANCE_VARIATION) {
507 + $galleryVariationData = apply_filters('fluent_cart/product/gallery_variation_data', [
508 + 'variant_term_map' => [],
509 + 'variant_first_media_map' => [],
510 + ], $this->product);
511 + $this->variantTermMap = (array) Arr::get($galleryVariationData, 'variant_term_map', []);
512 + $variantFirstMediaMap = (array) Arr::get($galleryVariationData, 'variant_first_media_map', []);
513 + } else {
514 + foreach ($this->images as $imageId => $image) {
515 + if (empty($image['media']) || !is_array($image['media'])) {
516 + continue;
517 + }
518 + $firstItem = $image['media'][0] ?? null;
519 + if (!$firstItem) {
520 + continue;
521 + }
522 + $firstUrl = Arr::get($firstItem, 'url', '');
523 + $firstMediaId = (int) Arr::get($firstItem, 'id', 0);
524 + if ($firstUrl) {
525 + $variantFirstMediaMap[(int) $imageId] = [
526 + 'id' => $firstMediaId,
527 + 'url' => $firstUrl,
528 + ];
529 + }
530 + }
531 + }
532 +
533 + // Collect ALL gallery images as JSON for lightbox (even when max thumbnails limits visible thumbs).
534 + // For advanced variations, deduplicate by media ID (when > 0) or by URL (for imported images).
359 535 $allGalleryImages = [];
536 + $addedMediaKeys = [];
537 + $isAdvVariation = !empty($this->variantTermMap);
360 538 foreach ($this->images as $imageId => $image) {
361 539 if (empty($image['media']) || !is_array($image['media'])) {
362 540 continue;
363 541 }
364 542 foreach ($image['media'] as $item) {
365 - $url = Arr::get($item, 'url', '');
543 + $url = Arr::get($item, 'url', '');
544 + $mediaId = (int) Arr::get($item, 'id', 0);
366 545 if (empty($url)) {
367 546 continue;
368 547 }
548 + if ($isAdvVariation) {
549 + $dedupeKey = $mediaId > 0 ? 'i:' . $mediaId : 'u:' . $url;
550 + if (isset($addedMediaKeys[$dedupeKey])) {
551 + continue; // skip — already added this image
552 + }
553 + $addedMediaKeys[$dedupeKey] = true;
554 + }
369 555 $allGalleryImages[] = [
370 556 'url' => $url,
371 557 'title' => Arr::get($item, 'title', ''),
372 558 'variation_id' => (string) $imageId,
559 + 'term_id' => (int) ($this->variantTermMap[(int) $imageId] ?? 0),
560 + 'media_id' => $mediaId,
373 561 ];
374 562 }
375 563 }
376 564
@@ -379,9 +567,10 @@
379 567 <div class="fct-gallery-thumb-controls"
380 568 role="toolbar"
381 569 aria-label="<?php echo esc_attr__('Product image thumbnails', 'fluent-cart'); ?>"
382 570 data-fluent-cart-single-product-page-product-thumbnail-controls
383 - data-all-gallery-images="<?php echo esc_attr(wp_json_encode($allGalleryImages) ?: '[]'); ?>">
571 + data-all-gallery-images="<?php echo esc_attr(wp_json_encode($allGalleryImages) ?: '[]'); ?>"
572 + data-variant-first-media-map="<?php echo esc_attr(wp_json_encode($variantFirstMediaMap) ?: '{}'); ?>">
384 573
385 574 <?php $this->renderGalleryThumbControl($maxThumbnails); ?>
386 575
387 576 </div>
@@ -395,45 +584,76 @@
395 584 if ($maxThumbnails !== null && $maxThumbnails <= 0) {
396 585 $maxThumbnails = null; // treat invalid value as "no limit"
397 586 }
398 587
399 - $count = 0;
400 - $totalImages = 0;
588 + $isAdvVariation = !empty($this->variantTermMap);
589 + $countedMediaKeys = [];
590 + $count = 0;
591 + $totalImages = 0;
592 + $videoRenderer = $this->getProductVideoRenderer();
401 593
402 - // First, count total renderable images
594 + // The gallery opens on a video the admin put in front of every image,
595 + // so no image thumb takes the selected slot in that case.
596 + if ($videoRenderer->getDefaultMediaId()) {
597 + $this->galleryActiveSet = true;
598 + }
599 +
600 + // Count unique images to render. For advanced variations, deduplicate by WP media ID
601 + // when available, or by URL for externally imported images (media ID = 0).
403 602 foreach ($this->images as $imageId => $image) {
404 603 if (empty($image['media']) || !is_array($image['media'])) {
405 604 continue;
406 605 }
407 606 foreach ($image['media'] as $item) {
408 - if (!empty(Arr::get($item, 'url', ''))) {
409 - $totalImages++;
607 + $url = Arr::get($item, 'url', '');
608 + $mediaId = (int) Arr::get($item, 'id', 0);
609 + if (empty($url)) {
610 + continue;
410 611 }
612 + if ($isAdvVariation) {
613 + $mediaDedupeKey = $mediaId > 0 ? 'i:' . $mediaId : 'u:' . $url;
614 + if (isset($countedMediaKeys[$mediaDedupeKey])) {
615 + continue;
616 + }
617 + $countedMediaKeys[$mediaDedupeKey] = true;
618 + }
619 + $totalImages++;
411 620 }
412 621 }
413 622
414 - // Then render up to max
623 + $renderedMediaKeys = [];
624 + // Render up to max; skip already-rendered media for advanced variation products.
415 625 foreach ($this->images as $imageId => $image) {
416 626 if (empty($image['media']) || !is_array($image['media'])) {
417 627 continue;
418 628 }
419 -
420 629 foreach ($image['media'] as $item) {
421 - if (empty(Arr::get($item, 'url', ''))) {
630 + $url = Arr::get($item, 'url', '');
631 + $mediaId = (int) Arr::get($item, 'id', 0);
632 + if (empty($url)) {
422 633 continue;
423 634 }
424 -
635 + if ($isAdvVariation) {
636 + $mediaDedupeKey = $mediaId > 0 ? 'i:' . $mediaId : 'u:' . $url;
637 + if (isset($renderedMediaKeys[$mediaDedupeKey])) {
638 + continue;
639 + }
640 + $renderedMediaKeys[$mediaDedupeKey] = true;
641 + }
425 642 if ($maxThumbnails !== null && $count >= (int) $maxThumbnails) {
643 + $videoRenderer->renderThumbControls(!$this->galleryActiveSet);
426 644 $this->renderGallerySeeMoreButton($totalImages - (int) $maxThumbnails);
427 645 return;
428 646 }
429 -
430 - $this->renderGalleryThumbControlButton($item, $imageId);
647 + // Videos the admin dragged in front of this image come first.
648 + $videoRenderer->renderThumbControlsBefore($count, !$this->galleryActiveSet);
649 + $termId = (int) ($this->variantTermMap[(int) $imageId] ?? 0);
650 + $this->renderGalleryThumbControlButton($item, $imageId, $termId, $mediaId);
431 651 $count++;
432 652 }
433 -
434 653 }
435 654
655 + $videoRenderer->renderThumbControls(!$this->galleryActiveSet);
436 656 }
437 657
438 658 public function renderGallerySeeMoreButton($remainingCount)
439 659 {
@@ -464,9 +684,9 @@
464 684 </button>
465 685 <?php
466 686 }
467 687
468 - public function renderGalleryThumbControlButton($item, $imageId)
688 + public function renderGalleryThumbControlButton($item, $imageId, $termId = 0, $mediaId = 0)
469 689 {
470 690
471 691 $isHidden = ''; //$imageId != $this->defaultVariationId ? 'is-hidden' : '';
472 692 $itemUrl = Arr::get($item, 'url', '');
@@ -482,11 +702,13 @@
482 702 class="fct-gallery-thumb-control-button <?php echo $isSelected ? 'active' : ''; ?> <?php echo esc_attr($isHidden); ?>"
483 703 data-fluent-cart-thumb-control-button
484 704 data-url="<?php echo esc_url($itemUrl); ?>"
485 705 data-variation-id="<?php echo esc_attr($imageId); ?>"
706 + data-term-id="<?php echo esc_attr((string) $termId); ?>"
707 + data-media-id="<?php echo esc_attr((string) $mediaId); ?>"
486 708 aria-label="<?php echo
487 - /* translators: %s image title */
488 - esc_attr(sprintf(__('View %s image', 'fluent-cart'), $itemTitle));
709 + /* translators: %1$s: image title */
710 + esc_attr(sprintf(__('View %1$s image', 'fluent-cart'), $itemTitle));
489 711 ?>"
490 712 aria-pressed="<?php echo $isSelected ? 'true' : 'false'; ?>"
491 713 tabindex="<?php echo $isSelected ? '0' : '-1'; ?>"
492 714 >
@@ -504,9 +726,14 @@
504 726 }
505 727
506 728 public function renderGallery($args = [])
507 729 {
730 + $gateContext = RenderGate::context($this->product, RenderGate::SCOPE_SINGLE, $this->defaultVariant);
508 731
732 + if (!RenderGate::shouldRender('image', $gateContext)) {
733 + return;
734 + }
735 +
509 736 $defaults = [
510 737 'thumbnail_mode' => 'all', // horizontal, vertical
511 738 'thumb_position' => 'bottom', // bottom, left, right, top
512 739 'scrollable_thumbs' => 'no', // yes / no
@@ -540,13 +767,21 @@
540 767 }
541 768
542 769 public function renderTitle()
543 770 {
771 + $gateContext = RenderGate::context($this->product, RenderGate::SCOPE_SINGLE, $this->defaultVariant);
772 +
773 + if (!RenderGate::shouldRender('title', $gateContext)) {
774 + return;
775 + }
776 +
777 + do_action('fluent_cart/product/single/before_title_block', $gateContext);
544 778 ?>
545 779 <div class="fct-product-title">
546 780 <h1 id="fct-product-summary-title"><?php echo esc_html($this->product->post_title); ?></h1>
547 781 </div>
548 782 <?php
783 + do_action('fluent_cart/product/single/after_title_block', $gateContext);
549 784 }
550 785
551 786 public function renderStockAvailability($wrapper_attributes = '')
552 787 {
@@ -554,8 +789,9 @@
554 789 return '';
555 790 }
556 791
557 792 $stockAvailability = $this->product->detail->getStockAvailability();
793 +
558 794
559 795 if (!Arr::get($stockAvailability, 'manage_stock')) {
560 796 return '';
561 797 }
@@ -565,15 +801,44 @@
565 801 // Check default variant stock for both simple and variable products
566 802 if ($this->defaultVariant) {
567 803 $isStock = $isStock && $this->defaultVariant->isStock();
568 804 }
569 - $stockLabel = $isStock ? $stockAvailability['availability'] : __('Out of Stock', 'fluent-cart');
570 - $statusClass = $isStock ? ($stockAvailability['class'] ?? '') : 'out-of-stock';
805 +
806 + $stockLabel = Arr::get($stockAvailability, 'availability');
807 + $statusClass = $stockAvailability['class'] ?? '';
808 +
809 + // Optional per-status custom labels (e.g. set on the Bricks Product Stock
810 + // element via the fluent_cart/product_stock_availability filter). Emitted as
811 + // data-attributes so the frontend JS, which re-derives the badge text on load
812 + // and on variant switches, prefers them over the generic label map instead of
813 + // overwriting them. Absent for the default template, so behavior is unchanged.
814 + $inStockText = Arr::get($stockAvailability, 'in_stock_text');
815 + $outOfStockText = Arr::get($stockAvailability, 'out_of_stock_text');
816 +
817 + // The variant-level check above can override the aggregate stock_availability
818 + // used for $stockLabel/$statusClass (e.g. this specific default variant is out
819 + // of stock even though the product overall has other in-stock variants) — keep
820 + // the label and class in sync so the badge never shows mismatched text/color.
821 + // Honor the custom out-of-stock label here too so it survives this override on
822 + // first load, matching what the frontend JS shows after a variant switch.
823 + if (!$isStock) {
824 + $statusClass = 'out-of-stock';
825 + $stockLabel = !empty($outOfStockText) ? $outOfStockText : __('Out of Stock', 'fluent-cart');
826 + }
827 +
828 + $badgeAttributes = '';
829 + if (!empty($inStockText)) {
830 + $badgeAttributes .= sprintf(' data-in-stock-text="%s"', esc_attr($inStockText));
831 + }
832 + if (!empty($outOfStockText)) {
833 + $badgeAttributes .= sprintf(' data-out-of-stock-text="%s"', esc_attr($outOfStockText));
834 + }
835 +
571 836 echo sprintf(
572 837 '<div class="fct-product-stock %1$s" role="status" aria-live="polite">
573 838 <div %2$s>
574 839 <span class="fct-stock-label">%3$s</span>
575 - <span class="fct-stock-badge fct_status_badge_%1$s" data-fluent-cart-product-stock>
840 + <span class="fct-stock-badge fct_status_badge_%1$s" data-fluent-cart-product-stock%5$s>
576 841 %4$s
577 842 </span>
578 843 </div>
579 844 </div>',
@@ -579,22 +844,15 @@
579 844 </div>',
580 845 esc_attr($statusClass),
581 846 $wrapper_attributes, // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
582 847 esc_html__('Availability:', 'fluent-cart'),
583 - esc_html($stockLabel)
848 + esc_html($stockLabel),
849 + $badgeAttributes // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- values passed through esc_attr() above
584 850 );
585 851 }
586 852
587 853 public function renderSku($wrapper_attributes = '', $showLabel = true, $label = '', $variant = null)
588 854 {
589 - if (!$variant) {
590 - $variant = $this->defaultVariant ?: $this->product->variants->first();
591 - }
592 -
593 - if (!$variant || empty($variant->sku)) {
594 - return;
595 - }
596 -
597 855 if (!$label) {
598 856 $label = __('SKU:', 'fluent-cart');
599 857 }
600 858
@@ -602,9 +860,13 @@
602 860 if ($showLabel && $label) {
603 861 $labelHtml = sprintf('<span class="fct-product-sku__label">%s</span> ', esc_html($label));
604 862 }
605 863
606 - echo sprintf(
864 + if ($variant) {
865 + if (empty($variant->sku)) {
866 + return;
867 + }
868 + echo sprintf(
607 869 '<div class="fct-product-sku">
608 870 <div %s>
609 871 %s<span class="fct-product-sku__value" data-fluent-cart-product-sku>%s</span>
610 872 </div>
@@ -611,15 +873,48 @@
611 873 </div>',
612 874 $wrapper_attributes, // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
613 875 $labelHtml,
614 876 esc_html($variant->sku)
615 - );
877 + );
878 + return;
879 + }
880 +
881 + foreach ($this->product->variants as $v) {
882 + if (empty($v->sku)) {
883 + continue;
884 + }
885 + $isHidden = ($this->defaultVariant && $this->defaultVariant->id != $v->id) ? ' is-hidden' : '';
886 + echo sprintf(
887 + '<div class="fct-product-sku fluent-cart-product-variation-content%s" data-variation-id="%s">
888 + <div %s>
889 + %s<span class="fct-product-sku__value" data-fluent-cart-product-sku>%s</span>
890 + </div>
891 + </div>',
892 + $isHidden,
893 + esc_attr($v->id),
894 + $wrapper_attributes, // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
895 + $labelHtml,
896 + esc_html($v->sku)
897 + );
898 + }
616 899 }
617 900
901 + /**
902 + * @deprecated Use PackageDescriptionRenderer::renderPackageDescription() directly.
903 + * Kept as a compatibility shim for external callers (themes, extensions) —
904 + * package-description rendering now lives in PackageDescriptionRenderer.
905 + */
618 906 public function renderPackageDescription($wrapper_attributes = '', $showName = true, $showDimensions = true, $showProductWeight = true, $showTotalWeight = true, $variant = null)
619 907 {
620 - $variant = $variant ?: ($this->defaultVariant ?: $this->product->variants->first());
621 - (new ProductCardRender($this->product))->renderPackageDescription($wrapper_attributes, $showName, $showDimensions, $showProductWeight, $showTotalWeight, $variant);
908 + (new PackageDescriptionRenderer($this->product))->renderPackageDescription(
909 + $wrapper_attributes,
910 + $showName,
911 + $showDimensions,
912 + $showProductWeight,
913 + $showTotalWeight,
914 + $variant,
915 + $this->defaultVariant
916 + );
622 917 }
623 918
624 919 /**
625 920 * Build a JSON string of package info for a variant (used as data attribute for JS switching).
@@ -625,8 +920,12 @@
625 920 * Build a JSON string of package info for a variant (used as data attribute for JS switching).
626 921 */
627 922 private function getVariantPackageInfoJson(ProductVariation $variant)
628 923 {
924 + if ($variant->fulfillment_type !== 'physical') {
925 + return '';
926 + }
927 +
629 928 $otherInfo = $variant->other_info ?: [];
630 929 $packageSlug = Arr::get($otherInfo, 'package_slug', '');
631 930 $package = Helper::getPackageBySlug($packageSlug);
632 931
@@ -684,25 +983,47 @@
684 983 $excerpt = $this->product->post_excerpt;
685 984 if (!$excerpt) {
686 985 return;
687 986 }
987 +
988 + $gateContext = RenderGate::context($this->product, RenderGate::SCOPE_SINGLE, $this->defaultVariant);
989 +
990 + if (!RenderGate::shouldRender('excerpt', $gateContext)) {
991 + return;
992 + }
993 +
994 + do_action('fluent_cart/product/single/before_excerpt_block', $gateContext);
688 995 ?>
689 996 <div class="fct-product-excerpt" aria-labelledby="fct-product-summary-title">
690 997 <p><?php echo wp_kses_post($excerpt); ?></p>
691 998 </div>
692 999 <?php
693 -
1000 + do_action('fluent_cart/product/single/after_excerpt_block', $gateContext);
694 1001 }
695 1002
696 1003 public function renderDescription()
697 1004 {
698 - $post = get_post($this->product->ID);
699 - if (!$post || empty($post->post_content)) {
1005 + $productPost = get_post($this->product->ID);
1006 + if (!$productPost || empty($productPost->post_content)) {
700 1007 return;
701 1008 }
1009 +
1010 + global $post;
1011 + $originalPost = $post;
1012 + $post = $productPost;
1013 + setup_postdata($post);
1014 +
1015 + $content = apply_filters('the_content', $productPost->post_content);
1016 +
1017 + $post = $originalPost;
1018 + if ($originalPost) {
1019 + setup_postdata($originalPost);
1020 + } else {
1021 + wp_reset_postdata();
1022 + }
702 1023 ?>
703 1024 <div class="fct-product-description">
704 - <?php echo apply_filters('the_content', $post->post_content); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
1025 + <?php echo $content; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
705 1026 </div>
706 1027 <?php
707 1028 }
708 1029
@@ -707,8 +1028,14 @@
707 1028 }
708 1029
709 1030 public function renderPrices()
710 1031 {
1032 + $gateContext = RenderGate::context($this->product, RenderGate::SCOPE_SINGLE, $this->defaultVariant);
1033 +
1034 + if (!RenderGate::shouldRender('price', $gateContext)) {
1035 + return;
1036 + }
1037 +
711 1038 if ($this->product->detail->variation_type === 'simple') {
712 1039 // we have to render for the simple product
713 1040
714 1041 $first_price = $this->product->variants()->first();
@@ -722,97 +1049,76 @@
722 1049 $comparePrice = $first_price ? (int)$first_price->compare_price : 0;
723 1050 if ($comparePrice <= $itemPrice) {
724 1051 $comparePrice = 0;
725 1052 }
726 - do_action('fluent_cart/product/single/before_price_block', [
1053 + do_action('fluent_cart/product/single/before_price_block', RenderContext::decorate([
727 1054 'product' => $this->product,
728 1055 'current_price' => $itemPrice,
729 1056 'scope' => 'price_range'
730 - ]);
1057 + ]));
731 1058 ?>
732 - <?php
1059 + <div class="fct-price-range fct-product-prices">
733 1060
734 - if ($comparePrice) {
735 - $aria_label = sprintf(
736 - /* translators: 1: Original price, 2: Current item price */
737 - __('Original Price: %1$s, Price: %2$s', 'fluent-cart'),
738 - Helper::toDecimal($comparePrice),
739 - Helper::toDecimal($itemPrice)
740 - );
741 - } else {
742 - $aria_label = sprintf(
743 - /* translators: 1: Current item price */
744 - __('Price: %1$s', 'fluent-cart'),
745 - Helper::toDecimal($itemPrice)
746 - );
747 - }
748 -
749 - ?>
750 - <div class="fct-price-range fct-product-prices" role="term"
751 - aria-label="<?php echo esc_attr($aria_label); ?>">
752 -
753 1061 <?php if ($comparePrice): ?>
754 1062 <span class="fct-compare-price">
755 - <del aria-label="<?php echo esc_attr(__('Original price', 'fluent-cart')); ?>"><?php echo esc_html(Helper::toDecimal($comparePrice)); ?></del>
1063 + <span class="fct-sr-only"><?php echo esc_html__('Original price:', 'fluent-cart'); ?></span>
1064 + <del><?php echo esc_html(Helper::toDecimal($comparePrice)); ?></del>
756 1065 </span>
757 1066 <?php endif; ?>
758 - <span class="fct-item-price" aria-label="<?php echo esc_attr(__('Current price', 'fluent-cart')); ?>">
1067 + <span class="fct-item-price">
1068 + <span class="fct-sr-only"><?php echo $comparePrice ? esc_html__('Sale price:', 'fluent-cart') : esc_html__('Price:', 'fluent-cart'); ?></span>
759 1069 <?php echo esc_html(Helper::toDecimal($itemPrice)); ?>
760 - <?php do_action('fluent_cart/product/after_price', [
1070 + <?php do_action('fluent_cart/product/after_price', RenderContext::decorate([
761 1071 'product' => $this->product,
1072 + 'variant' => $first_price,
762 1073 'current_price' => $itemPrice,
763 1074 'scope' => 'price_range'
764 - ]); ?>
1075 + ])); ?>
1076 + <?php RenderHelper::renderPriceSuffix($this->product, $first_price, 'price_range'); ?>
765 1077 </span>
766 1078 </div>
767 1079 <?php
768 - do_action('fluent_cart/product/single/after_price_block', [
1080 + do_action('fluent_cart/product/single/after_price_block', RenderContext::decorate([
769 1081 'product' => $this->product,
770 1082 'current_price' => $itemPrice,
771 1083 'scope' => 'price_range'
772 - ]);
1084 + ]));
773 1085 return;
774 1086 }
775 1087 $min_price = $this->product->detail->min_price;
776 1088 $max_price = $this->product->detail->max_price;
777 1089
778 - do_action('fluent_cart/product/single/before_price_range_block', [
1090 + do_action('fluent_cart/product/single/before_price_range_block', RenderContext::decorate([
779 1091 'product' => $this->product,
780 1092 'current_price' => $min_price,
781 1093 'scope' => 'price_range'
782 - ]);
1094 + ]));
783 1095 ?>
784 - <?php
785 - $aria_label = sprintf(
786 - /* translators: 1: Minimum price, 2: Maximum price */
787 - __('Price range: %1$s - %2$s', 'fluent-cart'),
788 - Helper::toDecimal($min_price),
789 - Helper::toDecimal($max_price)
790 - );
791 - ?>
792 - <div class="fct-product-prices fct-price-range" role="term" aria-label="<?php echo esc_attr($aria_label); ?>">
1096 + <div class="fct-product-prices fct-price-range">
793 1097
794 1098 <?php if ($max_price && $max_price != $min_price && $max_price > $min_price): ?>
1099 + <span class="fct-sr-only"><?php echo esc_html__('Price range:', 'fluent-cart'); ?></span>
795 1100 <span class="fct-min-price"><?php echo esc_html(Helper::toDecimal($min_price)); ?></span>
796 1101 <span class="fct-price-separator" aria-hidden="true">-</span>
1102 + <span class="fct-sr-only"><?php echo esc_html__('to', 'fluent-cart'); ?></span>
797 1103 <?php endif; ?>
798 1104 <span class="fct-max-price">
799 1105 <?php echo esc_html(Helper::toDecimal($max_price)); ?>
800 1106 </span>
801 1107
802 - <?php do_action('fluent_cart/product/after_price', [
1108 + <?php do_action('fluent_cart/product/after_price', RenderContext::decorate([
803 1109 'product' => $this->product,
804 1110 'current_price' => $min_price,
805 1111 'scope' => 'price_range'
806 - ]); ?>
1112 + ])); ?>
807 1113
808 1114 </div>
809 1115 <?php
810 - do_action('fluent_cart/product/single/after_price_range_block', [
1116 + do_action('fluent_cart/product/single/after_price_range_block', RenderContext::decorate([
811 1117 'product' => $this->product,
812 1118 'current_price' => $min_price,
813 1119 'scope' => 'price_range'
814 - ]);
1120 + ]));
815 1121 }
816 1122
817 1123 public function renderVariants($atts = [])
818 1124 {
@@ -837,19 +1143,19 @@
837 1143 ?>
838 1144 <div class="<?php echo esc_attr(implode(' ', $classes)); ?>" role="radiogroup"
839 1145 aria-label="<?php esc_attr_e('Product Variants', 'fluent-cart'); ?>">
840 1146 <?php foreach ($variants as $variant) {
841 - do_action('fluent_cart/product/single/before_variant_item', [
1147 + do_action('fluent_cart/product/single/before_variant_item', RenderContext::decorate([
842 1148 'product' => $this->product,
843 1149 'variant' => $variant,
844 1150 'scope' => 'product_variant_item'
845 - ]);
1151 + ]));
846 1152 $this->renderVariationItem($variant, $this->defaultVariationId);
847 - do_action('fluent_cart/product/single/after_variant_item', [
1153 + do_action('fluent_cart/product/single/after_variant_item', RenderContext::decorate([
848 1154 'product' => $this->product,
849 1155 'variant' => $variant,
850 1156 'scope' => 'product_variant_item'
851 - ]);
1157 + ]));
852 1158 } ?>
853 1159 </div>
854 1160 <?php
855 1161 }
@@ -855,17 +1161,24 @@
855 1161 }
856 1162
857 1163 public function renderItemPrice()
858 1164 {
1165 + // Same 'price' gate as renderPrices(): one filter hides the price
1166 + // wherever it appears, rather than making callers hunt for the second
1167 + // place the single product page prints one.
1168 + if (!RenderGate::shouldRender('price', RenderGate::context($this->product, RenderGate::SCOPE_SINGLE, $this->defaultVariant))) {
1169 + return;
1170 + }
1171 +
859 1172 if ($this->product->detail->variation_type === 'simple' && !$this->hasSubscription) {
860 1173 return; // for simple product we already rendered the price
861 1174 }
862 1175
863 - do_action('fluent_cart/product/single/before_price_block', [
1176 + do_action('fluent_cart/product/single/before_price_block', RenderContext::decorate([
864 1177 'product' => $this->product,
865 1178 'current_price' => $this->defaultVariant ? $this->defaultVariant->item_price : 0,
866 1179 'scope' => 'product_variant_price'
867 - ]);
1180 + ]));
868 1181
869 1182 foreach ($this->product->variants as $variant) {
870 1183 if ($this->shouldRenderPriceInPriceSection()) {
871 1184 $this->renderVariantPricingWrapperStart($variant);
@@ -882,13 +1195,9 @@
882 1195 ];
883 1196
884 1197 $this->renderComparePriceWrapperStart($atts);
885 1198 $this->renderVariationComparePrice($variant);
886 - if ($paymentType === 'onetime') {
887 - echo esc_html(Helper::toDecimal($variant->item_price));
888 - } else {
889 - $this->applyVariationPriceFilter($variant, $paymentType);
890 - }
1199 + $this->applyVariationPriceFilter($variant, $paymentType);
891 1200 $this->renderComparePriceWrapperEnd();
892 1201 }
893 1202
894 1203 $this->renderVariantPricingWrapperEnd();
@@ -903,18 +1212,31 @@
903 1212
904 1213
905 1214
906 1215
907 - do_action('fluent_cart/product/single/after_price_block', [
1216 + do_action('fluent_cart/product/single/after_price_block', RenderContext::decorate([
908 1217 'product' => $this->product,
909 1218 'current_price' => $this->defaultVariant ? $this->defaultVariant->item_price : 0,
910 1219 'scope' => 'product_variant_price'
911 - ]);
1220 + ]));
912 1221 }
913 1222
914 1223 public function shouldRenderPriceInPriceSection(): bool
915 1224 {
916 - return !($this->viewType === 'text' && $this->columnType === 'one');
1225 + // simple_variations keeps the legacy inline price flow for the
1226 + // one-column layouts that render the variant title (text-only and
1227 + // image+text), where the price sits on each variant row.
1228 + // Every other variation type always renders the price in the dedicated
1229 + // below-block so the data-fluent-cart-product-item-price element keeps
1230 + // a consistent position across every view/column combination.
1231 + if ($this->product->detail->variation_type === Helper::PRODUCT_TYPE_SIMPLE_VARIATION) {
1232 + $isInlineRowLayout = in_array($this->viewType, ['text', 'both'], true)
1233 + && $this->columnType === 'one';
1234 +
1235 + return !$isInlineRowLayout;
1236 + }
1237 +
1238 + return true;
917 1239 }
918 1240
919 1241 public function applyVariationPriceFilter($variant, $paymentType = 'onetime')
920 1242 {
@@ -923,13 +1245,15 @@
923 1245 'product' => $this->product,
924 1246 'variant' => $variant,
925 1247 'scope' => 'product_variant_price'
926 1248 ]));
927 - do_action('fluent_cart/product/after_price', [
1249 + do_action('fluent_cart/product/after_price', RenderContext::decorate([
928 1250 'product' => $this->product,
1251 + 'variant' => $variant,
929 1252 'current_price' => $variant->item_price,
930 1253 'scope' => 'product_variant_price'
931 - ]);
1254 + ]));
1255 + RenderHelper::renderPriceSuffix($this->product, $variant, 'product_variant_price');
932 1256 }
933 1257
934 1258 public function renderComparePriceWrapperStart($atts = [])
935 1259 {
@@ -951,8 +1275,9 @@
951 1275 return;
952 1276 } ?>
953 1277
954 1278 <span class="fct-compare-price">
1279 + <span class="fct-sr-only"><?php echo esc_html__('Original price:', 'fluent-cart'); ?></span>
955 1280 <del><?php echo esc_html(Helper::toDecimal($variant->compare_price)); ?></del>
956 1281 </span>
957 1282 <?php
958 1283 }
@@ -1038,8 +1363,14 @@
1038 1363 <?php }
1039 1364
1040 1365 public function renderQuantity()
1041 1366 {
1367 + $gateContext = RenderGate::context($this->product, RenderGate::SCOPE_SINGLE, $this->defaultVariant);
1368 +
1369 + if (!RenderGate::shouldRender('quantity', $gateContext)) {
1370 + return;
1371 + }
1372 +
1042 1373 $soldIndividually = $this->product->soldIndividually();
1043 1374
1044 1375 if (!$this->hasOnetime || $soldIndividually) {
1045 1376 return;
@@ -1058,12 +1389,12 @@
1058 1389 if ($this->hasSubscription && Arr::get($defaultVariantData, 'payment_type') !== 'onetime') {
1059 1390 $attributes['class'] .= ' is-hidden';
1060 1391 }
1061 1392
1062 - do_action('fluent_cart/product/single/before_quantity_block', [
1393 + do_action('fluent_cart/product/single/before_quantity_block', RenderContext::decorate([
1063 1394 'product' => $this->product,
1064 1395 'scope' => 'product_quantity_block'
1065 - ]);
1396 + ]));
1066 1397 ?>
1067 1398 <div <?php $this->renderAttributes($attributes); ?>>
1068 1399 <label for="fct-product-qty-input" class="quantity-title">
1069 1400 <?php esc_html_e('Quantity', 'fluent-cart'); ?>
@@ -1107,23 +1438,39 @@
1107 1438 </button>
1108 1439 </div>
1109 1440 </div>
1110 1441 <?php
1111 - do_action('fluent_cart/product/single/after_quantity_block', [
1442 + do_action('fluent_cart/product/single/after_quantity_block', RenderContext::decorate([
1112 1443 'product' => $this->product,
1113 1444 'scope' => 'product_quantity_block'
1114 - ]);
1445 + ]));
1115 1446 }
1116 1447
1117 1448 public function renderPurchaseButtons($atts = [])
1118 1449 {
1450 + $gateContext = RenderGate::context($this->product, RenderGate::SCOPE_SINGLE, $this->defaultVariant);
1451 +
1452 + if (!RenderGate::shouldRender('actions', $gateContext)) {
1453 + return;
1454 + }
1455 +
1456 + do_action('fluent_cart/product/single/before_actions_block', $gateContext);
1457 +
1119 1458 $buyNowButtonAtts = $atts;
1120 1459 $this->renderBuyNowButton($buyNowButtonAtts);
1121 1460 $this->renderAddToCartButton($atts);
1461 +
1462 + do_action('fluent_cart/product/single/after_actions_block', $gateContext);
1122 1463 }
1123 1464
1124 1465 public function renderBuyNowButton($atts = [])
1125 1466 {
1467 + $gateContext = RenderGate::context($this->product, RenderGate::SCOPE_SINGLE, $this->defaultVariant);
1468 +
1469 + if (!RenderGate::shouldRenderPurchaseButton('buy_now_button', $gateContext)) {
1470 + return;
1471 + }
1472 +
1126 1473 // Stock management check using isStock() method
1127 1474 // if (ModuleSettings::isActive('stock_management')) {
1128 1475 // if ($this->product->detail->variation_type === 'simple' && $this->defaultVariant) {
1129 1476 // if (!$this->defaultVariant->isStock()) {
@@ -1172,8 +1519,17 @@
1172 1519 $buyNowAttributes['data-fct-instant-checkout-button'] = '';
1173 1520 $buyNowAttributes['data-enable-modal-checkout'] = 'yes';
1174 1521 }
1175 1522
1523 + $isShortcode = !empty($atts['is_shortcode']);
1524 + if ($isShortcode) {
1525 + ob_start();
1526 + $this->renderAttributes($buyNowAttributes);
1527 + $wrapperAttributes = ob_get_clean();
1528 + } else {
1529 + $wrapperAttributes = RenderHelper::getBlockWrapperAttributes($buyNowAttributes);
1530 + }
1531 +
1176 1532 $buyButtonText = apply_filters('fluent_cart/product/buy_now_button_text', $atts['buy_now_text'], [
1177 1533 'product' => $this->product
1178 1534 ]);
1179 1535
@@ -1188,9 +1544,9 @@
1188 1544 $variantTitle
1189 1545 )
1190 1546 : $buyButtonText;
1191 1547 ?>
1192 - <a <?php $this->renderAttributes($buyNowAttributes); ?> aria-label="<?php echo esc_attr($buyNowAriaLabel); ?>">
1548 + <a <?php echo $wrapperAttributes; ?> aria-label="<?php echo esc_attr($buyNowAriaLabel); ?>">
1193 1549 <?php echo wp_kses_post($buyButtonText); ?>
1194 1550 </a>
1195 1551 <?php
1196 1552 }
@@ -1196,15 +1552,24 @@
1196 1552 }
1197 1553
1198 1554 public function renderBuyNowButtonBlock($atts = [])
1199 1555 {
1556 + $gateContext = RenderGate::context($this->product, RenderGate::SCOPE_SINGLE, $this->defaultVariant);
1557 +
1558 + // Same gate as the in-section button: a page assembled from standalone
1559 + // button blocks must honour catalog mode too, or the gate leaks.
1560 + if (!RenderGate::shouldRenderPurchaseButton('buy_now_button', $gateContext)) {
1561 + return;
1562 + }
1563 +
1200 1564 $text = Arr::get($atts, 'text', __('Buy Now', 'fluent-cart'));
1201 1565 $variantIds = Arr::get($atts, 'variant_ids', []);
1202 1566 $variantId = Arr::get($variantIds, 0);
1567 + $customClass = trim(Arr::get($atts, 'class', ''));
1568 + $extraClass = trim(Arr::get($atts, 'extra_class', ''));
1203 1569
1204 1570 $defaults = [
1205 1571 'buy_now_text' => $text,
1206 - 'class' => '',
1207 1572 'target' => '',
1208 1573 'rel' => '',
1209 1574 'is_shortcode' => false,
1210 1575 ];
@@ -1224,9 +1589,13 @@
1224 1589 'item_id' => $variantId ?? '',
1225 1590 'quantity' => 1
1226 1591 ], site_url());
1227 1592
1228 - $buyNowClass = trim('wp-block-button__link wp-element-button ' . Arr::get($atts, 'class', ''));
1593 + $buyNowClass = $customClass ?: 'wp-block-button__link wp-element-button';
1594 + if ($extraClass) {
1595 + $buyNowClass .= ' ' . $extraClass;
1596 + }
1597 + $buyNowClass = trim($buyNowClass);
1229 1598 if ($stockStatus === 'out-of-stock') {
1230 1599 $buyNowClass .= ' out-of-stock';
1231 1600 }
1232 1601
@@ -1256,22 +1625,15 @@
1256 1625 if ($enableModalCheckout) {
1257 1626 $buyNowAttributes['data-fct-instant-checkout-button'] = '';
1258 1627 $buyNowAttributes['data-enable-modal-checkout'] = 'yes';
1259 1628 }
1260 - $wrapperAttributes = '';
1261 - $isShortcode = !empty($atts['is_shortcode']);
1262 -
1629 + $isShortcode = !empty($atts['is_shortcode']);
1263 1630 if ($isShortcode) {
1264 - foreach ($buyNowAttributes as $attr => $value) {
1265 - if ($value === '') {
1266 - $wrapperAttributes .= esc_attr($attr) . ' ';
1267 - } else {
1268 - $wrapperAttributes .= sprintf('%s="%s" ', esc_attr($attr), esc_attr((string)$value));
1269 - }
1270 - }
1271 - $wrapperAttributes = trim($wrapperAttributes);
1631 + ob_start();
1632 + $this->renderAttributes($buyNowAttributes);
1633 + $wrapperAttributes = ob_get_clean();
1272 1634 } else {
1273 - $wrapperAttributes = get_block_wrapper_attributes($buyNowAttributes);
1635 + $wrapperAttributes = RenderHelper::getBlockWrapperAttributes($buyNowAttributes);
1274 1636 }
1275 1637
1276 1638 $buyButtonText = apply_filters('fluent_cart/product/buy_now_button_text', $atts['buy_now_text'], [
1277 1639 'product' => $this->product
@@ -1284,8 +1646,14 @@
1284 1646 }
1285 1647
1286 1648 public function renderAddToCartButton($atts = [])
1287 1649 {
1650 + $gateContext = RenderGate::context($this->product, RenderGate::SCOPE_SINGLE, $this->defaultVariant);
1651 +
1652 + if (!RenderGate::shouldRenderPurchaseButton('add_to_cart_button', $gateContext)) {
1653 + return;
1654 + }
1655 +
1288 1656 $defaults = [
1289 1657 'buy_now_text' => __('Buy Now', 'fluent-cart'),
1290 1658 'add_to_cart_text' => __('Add To Cart', 'fluent-cart'),
1291 1659 ];
@@ -1297,8 +1665,9 @@
1297 1665 'data-cart-id' => $this->defaultVariant ? $this->defaultVariant->id : '',
1298 1666 'data-product-id' => $this->product->ID,
1299 1667 'class' => 'fluent-cart-add-to-cart-button',
1300 1668 'data-variation-type' => $this->product->detail->variation_type,
1669 + 'data-icon-only' => !empty($atts['is_icon_only']) ? 'true' : 'false',
1301 1670 ];
1302 1671
1303 1672 $defaultVariantData = $this->getDefaultVariantData();
1304 1673
@@ -1321,10 +1690,28 @@
1321 1690
1322 1691 $addToCartText = apply_filters('fluent_cart/product/add_to_cart_text', $atts['add_to_cart_text'], [
1323 1692 'product' => $this->product
1324 1693 ]);
1325 - // Render add to cart if product supports onetime, or if out of stock (to show "Not Available")
1326 - if ($this->hasOnetime || $isOutOfStock) :
1694 +
1695 + $isShortcode = !empty($atts['is_shortcode']);
1696 + if ($isShortcode) {
1697 + ob_start();
1698 + $this->renderAttributes($cartAttributes);
1699 + $wrapperAttributes = ob_get_clean();
1700 + } else {
1701 + $wrapperAttributes = RenderHelper::getBlockWrapperAttributes($cartAttributes);
1702 + }
1703 +
1704 +
1705 + // Render add to cart when the product supports a one-time path, when out
1706 + // of stock (to show "Not Available"), or for advanced-variation products.
1707 + // The advanced selector toggles this button's visibility / disabled /
1708 + // payment-type state per selection, so it must exist in the DOM even for
1709 + // a subscription-only product whose in-stock default starts hidden via
1710 + // the is-hidden class applied above — otherwise an out-of-stock
1711 + // subscription combination has no button to surface "Not Available".
1712 + $isAdvancedVariation = $this->product->detail->variation_type === Helper::PRODUCT_TYPE_ADVANCE_VARIATION;
1713 + if ($this->hasOnetime || $isOutOfStock || $isAdvancedVariation) :
1327 1714 ?>
1328 1715 <?php
1329 1716 $variantTitle = $this->defaultVariant ? $this->defaultVariant->variation_title : '';
1330 1717 $addToCartAriaLabel = $variantTitle
@@ -1335,9 +1722,9 @@
1335 1722 $variantTitle
1336 1723 )
1337 1724 : $addToCartText;
1338 1725 ?>
1339 - <button <?php $this->renderAttributes($cartAttributes); ?>
1726 + <button <?php echo $wrapperAttributes; ?>
1340 1727 aria-label="<?php echo esc_attr($addToCartAriaLabel); ?>">
1341 1728 <span class="text">
1342 1729 <?php echo wp_kses_post($addToCartText); ?>
1343 1730 </span>
@@ -1361,11 +1748,18 @@
1361 1748 }
1362 1749
1363 1750 public function renderAddToCartButtonBlock($atts = [])
1364 1751 {
1752 + $gateContext = RenderGate::context($this->product, RenderGate::SCOPE_SINGLE, $this->defaultVariant);
1365 1753
1754 + // Same gate as the in-section button — see renderBuyNowButtonBlock().
1755 + if (!RenderGate::shouldRenderPurchaseButton('add_to_cart_button', $gateContext)) {
1756 + return;
1757 + }
1758 +
1366 1759 $text = Arr::get($atts, 'text', __('Add To Cart', 'fluent-cart'));
1367 - $extraClass = trim(Arr::get($atts, 'class', ''));
1760 + $customClass = trim(Arr::get($atts, 'class', ''));
1761 + $extraClass = trim(Arr::get($atts, 'extra_class', ''));
1368 1762
1369 1763 $defaults = [
1370 1764 'add_to_cart_text' => $text,
1371 1765 ];
@@ -1371,14 +1765,18 @@
1371 1765 ];
1372 1766
1373 1767 $atts = wp_parse_args($atts, $defaults);
1374 1768
1769 + $buttonClasses = ['fct-loader', 'wp-block-button__link wp-element-button'];
1770 + $buttonClass = $customClass ?: implode(' ', $buttonClasses);
1771 +
1375 1772 $cartAttributes = [
1376 1773 'data-fluent-cart-add-to-cart-button' => '',
1377 - 'class' => 'wp-block-button__link wp-element-button fct-loader',
1774 + 'class' => $buttonClass,
1378 1775 'data-cart-id' => $this->defaultVariant ? $this->defaultVariant->id : '',
1379 1776 'data-product-id' => $this->product->ID,
1380 1777 'data-variation-type' => $this->product->detail->variation_type,
1778 + 'data-icon-only' => !empty($atts['is_icon_only']) ? 'true' : 'false',
1381 1779 ];
1382 1780
1383 1781 if ($extraClass) {
1384 1782 $cartAttributes['class'] .= ' ' . $extraClass;
@@ -1409,22 +1807,15 @@
1409 1807 $addToCartText = apply_filters('fluent_cart/product/add_to_cart_text', $atts['add_to_cart_text'], [
1410 1808 'product' => $this->product
1411 1809 ]);
1412 1810
1413 - $wrapperAttributes = '';
1414 - $isShortcode = !empty($atts['is_shortcode']);
1415 -
1811 + $isShortcode = !empty($atts['is_shortcode']);
1416 1812 if ($isShortcode) {
1417 - foreach ($cartAttributes as $attr => $value) {
1418 - if ($value === '') {
1419 - $wrapperAttributes .= esc_attr($attr) . ' ';
1420 - } else {
1421 - $wrapperAttributes .= sprintf('%s="%s" ', esc_attr($attr), esc_attr((string)$value));
1422 - }
1423 - }
1424 - $wrapperAttributes = trim($wrapperAttributes);
1813 + ob_start();
1814 + $this->renderAttributes($cartAttributes);
1815 + $wrapperAttributes = ob_get_clean();
1425 1816 } else {
1426 - $wrapperAttributes = get_block_wrapper_attributes($cartAttributes);
1817 + $wrapperAttributes = RenderHelper::getBlockWrapperAttributes($cartAttributes);
1427 1818 }
1428 1819
1429 1820 ?>
1430 1821
@@ -1460,9 +1851,9 @@
1460 1851 <p class="has-text-align-center has-large-font-size m-0">
1461 1852 <?php echo esc_html__('No Product Found!', 'fluent-cart'); ?>
1462 1853 </p>
1463 1854
1464 - <p class="has-text-align-center">
1855 + <p class="has-text-align-center m-0">
1465 1856 <?php echo esc_html__('You can try clearing any filters.', 'fluent-cart'); ?>
1466 1857 </p>
1467 1858 </div>
1468 1859 <?php
@@ -1499,14 +1890,8 @@
1499 1890 if ($variant->id == $defaultId) {
1500 1891 $itemClasses[] = 'selected';
1501 1892 }
1502 1893
1503 - $priceSuffix = apply_filters('fluent_cart/product/price_suffix_atts', '', [
1504 - 'product' => $this->product,
1505 - 'variant' => $variant,
1506 - 'scope' => 'variant_item'
1507 - ]);
1508 -
1509 1894 $renderingAttributes = [
1510 1895 'data-fluent-cart-product-variant' => '',
1511 1896 'data-cart-id' => $variant->id,
1512 1897 'data-item-stock' => $variant->isStock() ? 'in-stock' : 'out-of-stock',
@@ -1514,9 +1899,8 @@
1514 1899 'data-payment-type' => $paymentType,
1515 1900 'data-available-stock' => $availableStocks,
1516 1901 'data-item-price' => Helper::toDecimal($variant->item_price),
1517 1902 'data-compare-price' => $comparePrice,
1518 - 'data-price-suffix' => $priceSuffix,
1519 1903 'data-stock-management' => ModuleSettings::isActive('stock_management') ? 'yes' : 'no',
1520 1904 'data-sku' => $variant->sku ?? '',
1521 1905 'data-package-info' => $this->getVariantPackageInfoJson($variant),
1522 1906 ];
@@ -1562,30 +1946,29 @@
1562 1946 if ($this->viewType === 'both' || $this->viewType === 'image') {
1563 1947 $this->renderVariantImage($variant);
1564 1948 }
1565 1949 ?>
1566 - <?php
1567 - if ($this->viewType === 'both' || $this->viewType === 'text') {
1568 - echo '<div class="fct-product-variant-title" aria-label="' . esc_attr(__('Variant title', 'fluent-cart')) . '">' . esc_html($variant->variation_title) . '</div>';
1569 - }
1570 - ?>
1950 + <?php if ($this->viewType === 'both' || $this->viewType === 'text'): ?>
1951 + <div class="fct-product-variant-text">
1952 + <div class="fct-product-variant-title"><?php echo esc_html($variant->variation_title); ?></div>
1953 + <?php if (!$this->shouldRenderPriceInPriceSection() && $paymentType === 'subscription'): ?>
1954 + <?php $this->renderSubscriptionInfo($variant); ?>
1955 + <?php endif; ?>
1956 + </div>
1957 + <?php endif; ?>
1571 1958 </div>
1572 1959
1573 - <?php if ($this->viewType === 'text' && $paymentType === 'subscription' && $this->columnType === 'one'): ?>
1574 -
1575 - <?php $this->renderSubscriptionInfo($variant); ?>
1576 - <?php endif; ?>
1577 -
1578 - <?php if ($this->viewType === 'text' && $this->columnType === 'one'): ?>
1960 + <?php if (!$this->shouldRenderPriceInPriceSection()): ?>
1579 1961 <div class="fct-product-variant-price">
1580 1962 <?php if ($comparePrice): ?>
1581 1963 <div class="fct-product-variant-compare-price">
1582 - <del aria-label="<?php echo esc_attr(__('Original price', 'fluent-cart')); ?>">
1964 + <span class="fct-sr-only"><?php echo esc_html__('Original price:', 'fluent-cart'); ?></span>
1965 + <del>
1583 1966 <span><?php echo esc_html(Helper::toDecimal($comparePrice)); ?></span></del>
1584 1967 </div>
1585 1968 <?php endif; ?>
1586 - <div class="fct-product-variant-item-price"
1587 - aria-label="<?php echo esc_attr(__('Current price', 'fluent-cart')); ?>">
1969 + <div class="fct-product-variant-item-price">
1970 + <span class="fct-sr-only"><?php echo $comparePrice ? esc_html__('Sale price:', 'fluent-cart') : esc_html__('Price:', 'fluent-cart'); ?></span>
1588 1971 <span><?php echo esc_html(Helper::toDecimal($itemPrice)); ?></span>
1589 1972 </div>
1590 1973 </div>
1591 1974 <?php endif; ?>
@@ -1605,9 +1988,9 @@
1605 1988 public function renderVariantImage($variant)
1606 1989 {
1607 1990 $image = $variant->thumbnail;
1608 1991 if (!$image) {
1609 - $image = Vite::getAssetUrl('images/placeholder.svg');
1992 + $image = Helper::getProductPlaceholderUrl();
1610 1993 }
1611 1994 ?>
1612 1995 <div class="fct-product-variant-image">
1613 1996 <img role="img" alt="<?php echo esc_attr($variant->variation_title); ?>"
@@ -1709,21 +2092,21 @@
1709 2092 //Convert to collection safely before sorting
1710 2093 $variants = (new Collection($variants))->sortBy('serial_index')->values();
1711 2094
1712 2095 foreach ($variants as $variant) {
1713 - do_action('fluent_cart/product/single/before_variant_item', [
2096 + do_action('fluent_cart/product/single/before_variant_item', RenderContext::decorate([
1714 2097 'product' => $this->product,
1715 2098 'variant' => $variant,
1716 2099 'scope' => 'product_variant_item'
1717 - ]);
2100 + ]));
1718 2101
1719 2102 $this->renderVariationItem($variant, $this->defaultVariationId);
1720 2103
1721 - do_action('fluent_cart/product/single/after_variant_item', [
2104 + do_action('fluent_cart/product/single/after_variant_item', RenderContext::decorate([
1722 2105 'product' => $this->product,
1723 2106 'variant' => $variant,
1724 2107 'scope' => 'product_variant_item'
1725 - ]);
2108 + ]));
1726 2109 }
1727 2110 ?>
1728 2111 </div>
1729 2112