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 +290 -116 1.5.0 → 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
@@ -294,8 +296,19 @@
294 296 }
295 297
296 298 public function renderBuySection($atts = [])
297 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 +
298 311 // Render no buy section when there is nothing purchasable — avoids a
299 312 // broken quantity + "Not Available" block. Two cases:
300 313 // - no variants at all (any product type), or
301 314 // - an advanced-variation product with no attribute_config yet (e.g.
@@ -359,15 +372,15 @@
359 372 public function renderGalleryThumb()
360 373 {
361 374 $thumbnails = [];
362 375
363 - $featuredMedia = $this->product->thumbnail ?? Vite::getAssetUrl('images/placeholder.svg');
376 + $featuredMedia = $this->product->thumbnail ?? Helper::getProductPlaceholderUrl();
364 377
365 378 // thumbnail can be an empty string (not null), so ?? above doesn't catch
366 379 // it — fall back to the placeholder so $featuredMedia is always a usable
367 380 // image URL for both the main <img> and data-default-image-url.
368 381 if (!$featuredMedia || !\is_string($featuredMedia)) {
369 - $featuredMedia = Vite::getAssetUrl('images/placeholder.svg');
382 + $featuredMedia = Helper::getProductPlaceholderUrl();
370 383 }
371 384
372 385 $galleryImage = get_post_meta($this->product->ID, 'fluent-products-gallery-image', true);
373 386
@@ -421,11 +434,16 @@
421 434 if (empty($this->defaultImageUrl)) {
422 435 $this->defaultImageUrl = $featuredMedia;
423 436 }
424 437
438 + $videoRenderer = $this->getProductVideoRenderer();
439 + $videoRenderer->showFirstByDefault(!$this->hasGalleryImages());
440 + $defaultVideoId = $videoRenderer->getDefaultMediaId();
441 +
425 442 ?>
426 - <div class="fct-product-gallery-thumb" role="region"
427 - 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) . '"' : ''; ?>>
428 446 <img
429 447 src="<?php echo esc_url($this->defaultImageUrl ?? '') ?>"
430 448 alt="<?php echo esc_attr($this->defaultImageAlt) ?>"
431 449 data-fluent-cart-single-product-page-product-thumbnail
@@ -430,21 +448,54 @@
430 448 alt="<?php echo esc_attr($this->defaultImageAlt) ?>"
431 449 data-fluent-cart-single-product-page-product-thumbnail
432 450 data-default-image-url="<?php echo esc_url($featuredMedia) ?>"
433 451 />
452 + <?php $this->getProductVideoRenderer()->renderInlinePlayers(); ?>
434 453 </div>
435 454 <?php
436 455 }
437 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 +
438 483 public function renderGalleryThumbControls($maxThumbnails = null)
439 484 {
440 485 $totalThumbImages = Arr::pluck($this->images, 'media.*.url');
441 486
442 - 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()){
443 489
444 490 return '';
445 491 }
446 492
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 +
447 498 // Build variantTermMap and variantFirstMediaMap.
448 499 // For advanced variations, Pro builds both maps via the gallery_variation_data
449 500 // filter — it has access to AttributeGroup types (color/image) that free cannot
450 501 // query directly. For all other product types, free builds variantFirstMediaMap
@@ -537,9 +588,16 @@
537 588 $isAdvVariation = !empty($this->variantTermMap);
538 589 $countedMediaKeys = [];
539 590 $count = 0;
540 591 $totalImages = 0;
592 + $videoRenderer = $this->getProductVideoRenderer();
541 593
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 +
542 600 // Count unique images to render. For advanced variations, deduplicate by WP media ID
543 601 // when available, or by URL for externally imported images (media ID = 0).
544 602 foreach ($this->images as $imageId => $image) {
545 603 if (empty($image['media']) || !is_array($image['media'])) {
@@ -581,16 +639,21 @@
581 639 }
582 640 $renderedMediaKeys[$mediaDedupeKey] = true;
583 641 }
584 642 if ($maxThumbnails !== null && $count >= (int) $maxThumbnails) {
643 + $videoRenderer->renderThumbControls(!$this->galleryActiveSet);
585 644 $this->renderGallerySeeMoreButton($totalImages - (int) $maxThumbnails);
586 645 return;
587 646 }
647 + // Videos the admin dragged in front of this image come first.
648 + $videoRenderer->renderThumbControlsBefore($count, !$this->galleryActiveSet);
588 649 $termId = (int) ($this->variantTermMap[(int) $imageId] ?? 0);
589 650 $this->renderGalleryThumbControlButton($item, $imageId, $termId, $mediaId);
590 651 $count++;
591 652 }
592 653 }
654 +
655 + $videoRenderer->renderThumbControls(!$this->galleryActiveSet);
593 656 }
594 657
595 658 public function renderGallerySeeMoreButton($remainingCount)
596 659 {
@@ -663,9 +726,14 @@
663 726 }
664 727
665 728 public function renderGallery($args = [])
666 729 {
730 + $gateContext = RenderGate::context($this->product, RenderGate::SCOPE_SINGLE, $this->defaultVariant);
667 731
732 + if (!RenderGate::shouldRender('image', $gateContext)) {
733 + return;
734 + }
735 +
668 736 $defaults = [
669 737 'thumbnail_mode' => 'all', // horizontal, vertical
670 738 'thumb_position' => 'bottom', // bottom, left, right, top
671 739 'scrollable_thumbs' => 'no', // yes / no
@@ -699,13 +767,21 @@
699 767 }
700 768
701 769 public function renderTitle()
702 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);
703 778 ?>
704 779 <div class="fct-product-title">
705 780 <h1 id="fct-product-summary-title"><?php echo esc_html($this->product->post_title); ?></h1>
706 781 </div>
707 782 <?php
783 + do_action('fluent_cart/product/single/after_title_block', $gateContext);
708 784 }
709 785
710 786 public function renderStockAvailability($wrapper_attributes = '')
711 787 {
@@ -725,15 +801,44 @@
725 801 // Check default variant stock for both simple and variable products
726 802 if ($this->defaultVariant) {
727 803 $isStock = $isStock && $this->defaultVariant->isStock();
728 804 }
805 +
729 806 $stockLabel = Arr::get($stockAvailability, 'availability');
730 - $statusClass = $isStock ? ($stockAvailability['class'] ?? '') : 'out-of-stock';
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 +
731 836 echo sprintf(
732 837 '<div class="fct-product-stock %1$s" role="status" aria-live="polite">
733 838 <div %2$s>
734 839 <span class="fct-stock-label">%3$s</span>
735 - <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>
736 841 %4$s
737 842 </span>
738 843 </div>
739 844 </div>',
@@ -739,22 +844,15 @@
739 844 </div>',
740 845 esc_attr($statusClass),
741 846 $wrapper_attributes, // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
742 847 esc_html__('Availability:', 'fluent-cart'),
743 - esc_html($stockLabel)
848 + esc_html($stockLabel),
849 + $badgeAttributes // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- values passed through esc_attr() above
744 850 );
745 851 }
746 852
747 853 public function renderSku($wrapper_attributes = '', $showLabel = true, $label = '', $variant = null)
748 854 {
749 - if (!$variant) {
750 - $variant = $this->defaultVariant ?: $this->product->variants->first();
751 - }
752 -
753 - if (!$variant || empty($variant->sku)) {
754 - return;
755 - }
756 -
757 855 if (!$label) {
758 856 $label = __('SKU:', 'fluent-cart');
759 857 }
760 858
@@ -762,9 +860,13 @@
762 860 if ($showLabel && $label) {
763 861 $labelHtml = sprintf('<span class="fct-product-sku__label">%s</span> ', esc_html($label));
764 862 }
765 863
766 - echo sprintf(
864 + if ($variant) {
865 + if (empty($variant->sku)) {
866 + return;
867 + }
868 + echo sprintf(
767 869 '<div class="fct-product-sku">
768 870 <div %s>
769 871 %s<span class="fct-product-sku__value" data-fluent-cart-product-sku>%s</span>
770 872 </div>
@@ -771,29 +873,51 @@
771 873 </div>',
772 874 $wrapper_attributes, // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
773 875 $labelHtml,
774 876 esc_html($variant->sku)
775 - );
776 - }
777 -
778 - public function renderPackageDescription($wrapper_attributes = '', $showName = true, $showDimensions = true, $showProductWeight = true, $showTotalWeight = true, $variant = null)
779 - {
780 - if ($variant) {
781 - if (!$wrapper_attributes) {
782 - $wrapper_attributes = 'class="fct-package-description" data-fluent-cart-package-description';
783 - }
784 - (new ProductCardRender($this->product))->renderPackageDescription($wrapper_attributes, $showName, $showDimensions, $showProductWeight, $showTotalWeight, $variant);
877 + );
785 878 return;
786 879 }
787 880
788 881 foreach ($this->product->variants as $v) {
882 + if (empty($v->sku)) {
883 + continue;
884 + }
789 885 $isHidden = ($this->defaultVariant && $this->defaultVariant->id != $v->id) ? ' is-hidden' : '';
790 - $variantWrapper = 'class="fct-package-description fluent-cart-product-variation-content' . $isHidden . '" data-fluent-cart-package-description data-variation-id="' . esc_attr($v->id) . '"';
791 - (new ProductCardRender($this->product))->renderPackageDescription($variantWrapper, $showName, $showDimensions, $showProductWeight, $showTotalWeight, $v);
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 + );
792 898 }
793 899 }
794 900
795 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 + */
906 + public function renderPackageDescription($wrapper_attributes = '', $showName = true, $showDimensions = true, $showProductWeight = true, $showTotalWeight = true, $variant = null)
907 + {
908 + (new PackageDescriptionRenderer($this->product))->renderPackageDescription(
909 + $wrapper_attributes,
910 + $showName,
911 + $showDimensions,
912 + $showProductWeight,
913 + $showTotalWeight,
914 + $variant,
915 + $this->defaultVariant
916 + );
917 + }
918 +
919 + /**
796 920 * Build a JSON string of package info for a variant (used as data attribute for JS switching).
797 921 */
798 922 private function getVariantPackageInfoJson(ProductVariation $variant)
799 923 {
@@ -859,14 +983,22 @@
859 983 $excerpt = $this->product->post_excerpt;
860 984 if (!$excerpt) {
861 985 return;
862 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);
863 995 ?>
864 996 <div class="fct-product-excerpt" aria-labelledby="fct-product-summary-title">
865 997 <p><?php echo wp_kses_post($excerpt); ?></p>
866 998 </div>
867 999 <?php
868 -
1000 + do_action('fluent_cart/product/single/after_excerpt_block', $gateContext);
869 1001 }
870 1002
871 1003 public function renderDescription()
872 1004 {
@@ -896,8 +1028,14 @@
896 1028 }
897 1029
898 1030 public function renderPrices()
899 1031 {
1032 + $gateContext = RenderGate::context($this->product, RenderGate::SCOPE_SINGLE, $this->defaultVariant);
1033 +
1034 + if (!RenderGate::shouldRender('price', $gateContext)) {
1035 + return;
1036 + }
1037 +
900 1038 if ($this->product->detail->variation_type === 'simple') {
901 1039 // we have to render for the simple product
902 1040
903 1041 $first_price = $this->product->variants()->first();
@@ -911,97 +1049,76 @@
911 1049 $comparePrice = $first_price ? (int)$first_price->compare_price : 0;
912 1050 if ($comparePrice <= $itemPrice) {
913 1051 $comparePrice = 0;
914 1052 }
915 - do_action('fluent_cart/product/single/before_price_block', [
1053 + do_action('fluent_cart/product/single/before_price_block', RenderContext::decorate([
916 1054 'product' => $this->product,
917 1055 'current_price' => $itemPrice,
918 1056 'scope' => 'price_range'
919 - ]);
1057 + ]));
920 1058 ?>
921 - <?php
1059 + <div class="fct-price-range fct-product-prices">
922 1060
923 - if ($comparePrice) {
924 - $aria_label = sprintf(
925 - /* translators: 1: Original price, 2: Current item price */
926 - __('Original Price: %1$s, Price: %2$s', 'fluent-cart'),
927 - Helper::toDecimal($comparePrice),
928 - Helper::toDecimal($itemPrice)
929 - );
930 - } else {
931 - $aria_label = sprintf(
932 - /* translators: 1: Current item price */
933 - __('Price: %1$s', 'fluent-cart'),
934 - Helper::toDecimal($itemPrice)
935 - );
936 - }
937 -
938 - ?>
939 - <div class="fct-price-range fct-product-prices" role="term"
940 - aria-label="<?php echo esc_attr($aria_label); ?>">
941 -
942 1061 <?php if ($comparePrice): ?>
943 1062 <span class="fct-compare-price">
944 - <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>
945 1065 </span>
946 1066 <?php endif; ?>
947 - <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>
948 1069 <?php echo esc_html(Helper::toDecimal($itemPrice)); ?>
949 - <?php do_action('fluent_cart/product/after_price', [
1070 + <?php do_action('fluent_cart/product/after_price', RenderContext::decorate([
950 1071 'product' => $this->product,
1072 + 'variant' => $first_price,
951 1073 'current_price' => $itemPrice,
952 1074 'scope' => 'price_range'
953 - ]); ?>
1075 + ])); ?>
1076 + <?php RenderHelper::renderPriceSuffix($this->product, $first_price, 'price_range'); ?>
954 1077 </span>
955 1078 </div>
956 1079 <?php
957 - do_action('fluent_cart/product/single/after_price_block', [
1080 + do_action('fluent_cart/product/single/after_price_block', RenderContext::decorate([
958 1081 'product' => $this->product,
959 1082 'current_price' => $itemPrice,
960 1083 'scope' => 'price_range'
961 - ]);
1084 + ]));
962 1085 return;
963 1086 }
964 1087 $min_price = $this->product->detail->min_price;
965 1088 $max_price = $this->product->detail->max_price;
966 1089
967 - do_action('fluent_cart/product/single/before_price_range_block', [
1090 + do_action('fluent_cart/product/single/before_price_range_block', RenderContext::decorate([
968 1091 'product' => $this->product,
969 1092 'current_price' => $min_price,
970 1093 'scope' => 'price_range'
971 - ]);
1094 + ]));
972 1095 ?>
973 - <?php
974 - $aria_label = sprintf(
975 - /* translators: 1: Minimum price, 2: Maximum price */
976 - __('Price range: %1$s - %2$s', 'fluent-cart'),
977 - Helper::toDecimal($min_price),
978 - Helper::toDecimal($max_price)
979 - );
980 - ?>
981 - <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">
982 1097
983 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>
984 1100 <span class="fct-min-price"><?php echo esc_html(Helper::toDecimal($min_price)); ?></span>
985 1101 <span class="fct-price-separator" aria-hidden="true">-</span>
1102 + <span class="fct-sr-only"><?php echo esc_html__('to', 'fluent-cart'); ?></span>
986 1103 <?php endif; ?>
987 1104 <span class="fct-max-price">
988 1105 <?php echo esc_html(Helper::toDecimal($max_price)); ?>
989 1106 </span>
990 1107
991 - <?php do_action('fluent_cart/product/after_price', [
1108 + <?php do_action('fluent_cart/product/after_price', RenderContext::decorate([
992 1109 'product' => $this->product,
993 1110 'current_price' => $min_price,
994 1111 'scope' => 'price_range'
995 - ]); ?>
1112 + ])); ?>
996 1113
997 1114 </div>
998 1115 <?php
999 - do_action('fluent_cart/product/single/after_price_range_block', [
1116 + do_action('fluent_cart/product/single/after_price_range_block', RenderContext::decorate([
1000 1117 'product' => $this->product,
1001 1118 'current_price' => $min_price,
1002 1119 'scope' => 'price_range'
1003 - ]);
1120 + ]));
1004 1121 }
1005 1122
1006 1123 public function renderVariants($atts = [])
1007 1124 {
@@ -1026,19 +1143,19 @@
1026 1143 ?>
1027 1144 <div class="<?php echo esc_attr(implode(' ', $classes)); ?>" role="radiogroup"
1028 1145 aria-label="<?php esc_attr_e('Product Variants', 'fluent-cart'); ?>">
1029 1146 <?php foreach ($variants as $variant) {
1030 - do_action('fluent_cart/product/single/before_variant_item', [
1147 + do_action('fluent_cart/product/single/before_variant_item', RenderContext::decorate([
1031 1148 'product' => $this->product,
1032 1149 'variant' => $variant,
1033 1150 'scope' => 'product_variant_item'
1034 - ]);
1151 + ]));
1035 1152 $this->renderVariationItem($variant, $this->defaultVariationId);
1036 - do_action('fluent_cart/product/single/after_variant_item', [
1153 + do_action('fluent_cart/product/single/after_variant_item', RenderContext::decorate([
1037 1154 'product' => $this->product,
1038 1155 'variant' => $variant,
1039 1156 'scope' => 'product_variant_item'
1040 - ]);
1157 + ]));
1041 1158 } ?>
1042 1159 </div>
1043 1160 <?php
1044 1161 }
@@ -1044,17 +1161,24 @@
1044 1161 }
1045 1162
1046 1163 public function renderItemPrice()
1047 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 +
1048 1172 if ($this->product->detail->variation_type === 'simple' && !$this->hasSubscription) {
1049 1173 return; // for simple product we already rendered the price
1050 1174 }
1051 1175
1052 - do_action('fluent_cart/product/single/before_price_block', [
1176 + do_action('fluent_cart/product/single/before_price_block', RenderContext::decorate([
1053 1177 'product' => $this->product,
1054 1178 'current_price' => $this->defaultVariant ? $this->defaultVariant->item_price : 0,
1055 1179 'scope' => 'product_variant_price'
1056 - ]);
1180 + ]));
1057 1181
1058 1182 foreach ($this->product->variants as $variant) {
1059 1183 if ($this->shouldRenderPriceInPriceSection()) {
1060 1184 $this->renderVariantPricingWrapperStart($variant);
@@ -1088,18 +1212,31 @@
1088 1212
1089 1213
1090 1214
1091 1215
1092 - do_action('fluent_cart/product/single/after_price_block', [
1216 + do_action('fluent_cart/product/single/after_price_block', RenderContext::decorate([
1093 1217 'product' => $this->product,
1094 1218 'current_price' => $this->defaultVariant ? $this->defaultVariant->item_price : 0,
1095 1219 'scope' => 'product_variant_price'
1096 - ]);
1220 + ]));
1097 1221 }
1098 1222
1099 1223 public function shouldRenderPriceInPriceSection(): bool
1100 1224 {
1101 - 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;
1102 1239 }
1103 1240
1104 1241 public function applyVariationPriceFilter($variant, $paymentType = 'onetime')
1105 1242 {
@@ -1108,14 +1245,15 @@
1108 1245 'product' => $this->product,
1109 1246 'variant' => $variant,
1110 1247 'scope' => 'product_variant_price'
1111 1248 ]));
1112 - do_action('fluent_cart/product/after_price', [
1249 + do_action('fluent_cart/product/after_price', RenderContext::decorate([
1113 1250 'product' => $this->product,
1114 1251 'variant' => $variant,
1115 1252 'current_price' => $variant->item_price,
1116 1253 'scope' => 'product_variant_price'
1117 - ]);
1254 + ]));
1255 + RenderHelper::renderPriceSuffix($this->product, $variant, 'product_variant_price');
1118 1256 }
1119 1257
1120 1258 public function renderComparePriceWrapperStart($atts = [])
1121 1259 {
@@ -1137,8 +1275,9 @@
1137 1275 return;
1138 1276 } ?>
1139 1277
1140 1278 <span class="fct-compare-price">
1279 + <span class="fct-sr-only"><?php echo esc_html__('Original price:', 'fluent-cart'); ?></span>
1141 1280 <del><?php echo esc_html(Helper::toDecimal($variant->compare_price)); ?></del>
1142 1281 </span>
1143 1282 <?php
1144 1283 }
@@ -1224,8 +1363,14 @@
1224 1363 <?php }
1225 1364
1226 1365 public function renderQuantity()
1227 1366 {
1367 + $gateContext = RenderGate::context($this->product, RenderGate::SCOPE_SINGLE, $this->defaultVariant);
1368 +
1369 + if (!RenderGate::shouldRender('quantity', $gateContext)) {
1370 + return;
1371 + }
1372 +
1228 1373 $soldIndividually = $this->product->soldIndividually();
1229 1374
1230 1375 if (!$this->hasOnetime || $soldIndividually) {
1231 1376 return;
@@ -1244,12 +1389,12 @@
1244 1389 if ($this->hasSubscription && Arr::get($defaultVariantData, 'payment_type') !== 'onetime') {
1245 1390 $attributes['class'] .= ' is-hidden';
1246 1391 }
1247 1392
1248 - do_action('fluent_cart/product/single/before_quantity_block', [
1393 + do_action('fluent_cart/product/single/before_quantity_block', RenderContext::decorate([
1249 1394 'product' => $this->product,
1250 1395 'scope' => 'product_quantity_block'
1251 - ]);
1396 + ]));
1252 1397 ?>
1253 1398 <div <?php $this->renderAttributes($attributes); ?>>
1254 1399 <label for="fct-product-qty-input" class="quantity-title">
1255 1400 <?php esc_html_e('Quantity', 'fluent-cart'); ?>
@@ -1293,23 +1438,39 @@
1293 1438 </button>
1294 1439 </div>
1295 1440 </div>
1296 1441 <?php
1297 - do_action('fluent_cart/product/single/after_quantity_block', [
1442 + do_action('fluent_cart/product/single/after_quantity_block', RenderContext::decorate([
1298 1443 'product' => $this->product,
1299 1444 'scope' => 'product_quantity_block'
1300 - ]);
1445 + ]));
1301 1446 }
1302 1447
1303 1448 public function renderPurchaseButtons($atts = [])
1304 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 +
1305 1458 $buyNowButtonAtts = $atts;
1306 1459 $this->renderBuyNowButton($buyNowButtonAtts);
1307 1460 $this->renderAddToCartButton($atts);
1461 +
1462 + do_action('fluent_cart/product/single/after_actions_block', $gateContext);
1308 1463 }
1309 1464
1310 1465 public function renderBuyNowButton($atts = [])
1311 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 +
1312 1473 // Stock management check using isStock() method
1313 1474 // if (ModuleSettings::isActive('stock_management')) {
1314 1475 // if ($this->product->detail->variation_type === 'simple' && $this->defaultVariant) {
1315 1476 // if (!$this->defaultVariant->isStock()) {
@@ -1391,8 +1552,16 @@
1391 1552 }
1392 1553
1393 1554 public function renderBuyNowButtonBlock($atts = [])
1394 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 +
1395 1564 $text = Arr::get($atts, 'text', __('Buy Now', 'fluent-cart'));
1396 1565 $variantIds = Arr::get($atts, 'variant_ids', []);
1397 1566 $variantId = Arr::get($variantIds, 0);
1398 1567 $customClass = trim(Arr::get($atts, 'class', ''));
@@ -1477,8 +1646,14 @@
1477 1646 }
1478 1647
1479 1648 public function renderAddToCartButton($atts = [])
1480 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 +
1481 1656 $defaults = [
1482 1657 'buy_now_text' => __('Buy Now', 'fluent-cart'),
1483 1658 'add_to_cart_text' => __('Add To Cart', 'fluent-cart'),
1484 1659 ];
@@ -1573,8 +1748,15 @@
1573 1748 }
1574 1749
1575 1750 public function renderAddToCartButtonBlock($atts = [])
1576 1751 {
1752 + $gateContext = RenderGate::context($this->product, RenderGate::SCOPE_SINGLE, $this->defaultVariant);
1753 +
1754 + // Same gate as the in-section button — see renderBuyNowButtonBlock().
1755 + if (!RenderGate::shouldRenderPurchaseButton('add_to_cart_button', $gateContext)) {
1756 + return;
1757 + }
1758 +
1577 1759 $text = Arr::get($atts, 'text', __('Add To Cart', 'fluent-cart'));
1578 1760 $customClass = trim(Arr::get($atts, 'class', ''));
1579 1761 $extraClass = trim(Arr::get($atts, 'extra_class', ''));
1580 1762
@@ -1669,9 +1851,9 @@
1669 1851 <p class="has-text-align-center has-large-font-size m-0">
1670 1852 <?php echo esc_html__('No Product Found!', 'fluent-cart'); ?>
1671 1853 </p>
1672 1854
1673 - <p class="has-text-align-center">
1855 + <p class="has-text-align-center m-0">
1674 1856 <?php echo esc_html__('You can try clearing any filters.', 'fluent-cart'); ?>
1675 1857 </p>
1676 1858 </div>
1677 1859 <?php
@@ -1708,14 +1890,8 @@
1708 1890 if ($variant->id == $defaultId) {
1709 1891 $itemClasses[] = 'selected';
1710 1892 }
1711 1893
1712 - $priceSuffix = apply_filters('fluent_cart/product/price_suffix_atts', '', [
1713 - 'product' => $this->product,
1714 - 'variant' => $variant,
1715 - 'scope' => 'variant_item'
1716 - ]);
1717 -
1718 1894 $renderingAttributes = [
1719 1895 'data-fluent-cart-product-variant' => '',
1720 1896 'data-cart-id' => $variant->id,
1721 1897 'data-item-stock' => $variant->isStock() ? 'in-stock' : 'out-of-stock',
@@ -1723,9 +1899,8 @@
1723 1899 'data-payment-type' => $paymentType,
1724 1900 'data-available-stock' => $availableStocks,
1725 1901 'data-item-price' => Helper::toDecimal($variant->item_price),
1726 1902 'data-compare-price' => $comparePrice,
1727 - 'data-price-suffix' => $priceSuffix,
1728 1903 'data-stock-management' => ModuleSettings::isActive('stock_management') ? 'yes' : 'no',
1729 1904 'data-sku' => $variant->sku ?? '',
1730 1905 'data-package-info' => $this->getVariantPackageInfoJson($variant),
1731 1906 ];
@@ -1771,30 +1946,29 @@
1771 1946 if ($this->viewType === 'both' || $this->viewType === 'image') {
1772 1947 $this->renderVariantImage($variant);
1773 1948 }
1774 1949 ?>
1775 - <?php
1776 - if ($this->viewType === 'both' || $this->viewType === 'text') {
1777 - echo '<div class="fct-product-variant-title" aria-label="' . esc_attr(__('Variant title', 'fluent-cart')) . '">' . esc_html($variant->variation_title) . '</div>';
1778 - }
1779 - ?>
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; ?>
1780 1958 </div>
1781 1959
1782 - <?php if ($this->viewType === 'text' && $paymentType === 'subscription' && $this->columnType === 'one'): ?>
1783 -
1784 - <?php $this->renderSubscriptionInfo($variant); ?>
1785 - <?php endif; ?>
1786 -
1787 - <?php if ($this->viewType === 'text' && $this->columnType === 'one'): ?>
1960 + <?php if (!$this->shouldRenderPriceInPriceSection()): ?>
1788 1961 <div class="fct-product-variant-price">
1789 1962 <?php if ($comparePrice): ?>
1790 1963 <div class="fct-product-variant-compare-price">
1791 - <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>
1792 1966 <span><?php echo esc_html(Helper::toDecimal($comparePrice)); ?></span></del>
1793 1967 </div>
1794 1968 <?php endif; ?>
1795 - <div class="fct-product-variant-item-price"
1796 - 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>
1797 1971 <span><?php echo esc_html(Helper::toDecimal($itemPrice)); ?></span>
1798 1972 </div>
1799 1973 </div>
1800 1974 <?php endif; ?>
@@ -1814,9 +1988,9 @@
1814 1988 public function renderVariantImage($variant)
1815 1989 {
1816 1990 $image = $variant->thumbnail;
1817 1991 if (!$image) {
1818 - $image = Vite::getAssetUrl('images/placeholder.svg');
1992 + $image = Helper::getProductPlaceholderUrl();
1819 1993 }
1820 1994 ?>
1821 1995 <div class="fct-product-variant-image">
1822 1996 <img role="img" alt="<?php echo esc_attr($variant->variation_title); ?>"
@@ -1918,21 +2092,21 @@
1918 2092 //Convert to collection safely before sorting
1919 2093 $variants = (new Collection($variants))->sortBy('serial_index')->values();
1920 2094
1921 2095 foreach ($variants as $variant) {
1922 - do_action('fluent_cart/product/single/before_variant_item', [
2096 + do_action('fluent_cart/product/single/before_variant_item', RenderContext::decorate([
1923 2097 'product' => $this->product,
1924 2098 'variant' => $variant,
1925 2099 'scope' => 'product_variant_item'
1926 - ]);
2100 + ]));
1927 2101
1928 2102 $this->renderVariationItem($variant, $this->defaultVariationId);
1929 2103
1930 - do_action('fluent_cart/product/single/after_variant_item', [
2104 + do_action('fluent_cart/product/single/after_variant_item', RenderContext::decorate([
1931 2105 'product' => $this->product,
1932 2106 'variant' => $variant,
1933 2107 'scope' => 'product_variant_item'
1934 - ]);
2108 + ]));
1935 2109 }
1936 2110 ?>
1937 2111 </div>
1938 2112