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 +232 -96 1.5.3 → 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 {
@@ -729,21 +805,40 @@
729 805
730 806 $stockLabel = Arr::get($stockAvailability, 'availability');
731 807 $statusClass = $stockAvailability['class'] ?? '';
732 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 +
733 817 // The variant-level check above can override the aggregate stock_availability
734 818 // used for $stockLabel/$statusClass (e.g. this specific default variant is out
735 819 // of stock even though the product overall has other in-stock variants) — keep
736 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.
737 823 if (!$isStock) {
738 824 $statusClass = 'out-of-stock';
739 - $stockLabel = __('Out of Stock', 'fluent-cart');
825 + $stockLabel = !empty($outOfStockText) ? $outOfStockText : __('Out of Stock', 'fluent-cart');
740 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 +
741 836 echo sprintf(
742 837 '<div class="fct-product-stock %1$s" role="status" aria-live="polite">
743 838 <div %2$s>
744 839 <span class="fct-stock-label">%3$s</span>
745 - <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>
746 841 %4$s
747 842 </span>
748 843 </div>
749 844 </div>',
@@ -749,9 +844,10 @@
749 844 </div>',
750 845 esc_attr($statusClass),
751 846 $wrapper_attributes, // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
752 847 esc_html__('Availability:', 'fluent-cart'),
753 - esc_html($stockLabel)
848 + esc_html($stockLabel),
849 + $badgeAttributes // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- values passed through esc_attr() above
754 850 );
755 851 }
756 852
757 853 public function renderSku($wrapper_attributes = '', $showLabel = true, $label = '', $variant = null)
@@ -801,23 +897,24 @@
801 897 );
802 898 }
803 899 }
804 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 + */
805 906 public function renderPackageDescription($wrapper_attributes = '', $showName = true, $showDimensions = true, $showProductWeight = true, $showTotalWeight = true, $variant = null)
806 907 {
807 - if ($variant) {
808 - if (!$wrapper_attributes) {
809 - $wrapper_attributes = 'class="fct-package-description" data-fluent-cart-package-description';
810 - }
811 - (new ProductCardRender($this->product))->renderPackageDescription($wrapper_attributes, $showName, $showDimensions, $showProductWeight, $showTotalWeight, $variant);
812 - return;
813 - }
814 -
815 - foreach ($this->product->variants as $v) {
816 - $isHidden = ($this->defaultVariant && $this->defaultVariant->id != $v->id) ? ' is-hidden' : '';
817 - $variantWrapper = 'class="fct-package-description fluent-cart-product-variation-content' . $isHidden . '" data-fluent-cart-package-description data-variation-id="' . esc_attr($v->id) . '"';
818 - (new ProductCardRender($this->product))->renderPackageDescription($variantWrapper, $showName, $showDimensions, $showProductWeight, $showTotalWeight, $v);
819 - }
908 + (new PackageDescriptionRenderer($this->product))->renderPackageDescription(
909 + $wrapper_attributes,
910 + $showName,
911 + $showDimensions,
912 + $showProductWeight,
913 + $showTotalWeight,
914 + $variant,
915 + $this->defaultVariant
916 + );
820 917 }
821 918
822 919 /**
823 920 * Build a JSON string of package info for a variant (used as data attribute for JS switching).
@@ -886,14 +983,22 @@
886 983 $excerpt = $this->product->post_excerpt;
887 984 if (!$excerpt) {
888 985 return;
889 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);
890 995 ?>
891 996 <div class="fct-product-excerpt" aria-labelledby="fct-product-summary-title">
892 997 <p><?php echo wp_kses_post($excerpt); ?></p>
893 998 </div>
894 999 <?php
895 -
1000 + do_action('fluent_cart/product/single/after_excerpt_block', $gateContext);
896 1001 }
897 1002
898 1003 public function renderDescription()
899 1004 {
@@ -923,8 +1028,14 @@
923 1028 }
924 1029
925 1030 public function renderPrices()
926 1031 {
1032 + $gateContext = RenderGate::context($this->product, RenderGate::SCOPE_SINGLE, $this->defaultVariant);
1033 +
1034 + if (!RenderGate::shouldRender('price', $gateContext)) {
1035 + return;
1036 + }
1037 +
927 1038 if ($this->product->detail->variation_type === 'simple') {
928 1039 // we have to render for the simple product
929 1040
930 1041 $first_price = $this->product->variants()->first();
@@ -938,97 +1049,76 @@
938 1049 $comparePrice = $first_price ? (int)$first_price->compare_price : 0;
939 1050 if ($comparePrice <= $itemPrice) {
940 1051 $comparePrice = 0;
941 1052 }
942 - do_action('fluent_cart/product/single/before_price_block', [
1053 + do_action('fluent_cart/product/single/before_price_block', RenderContext::decorate([
943 1054 'product' => $this->product,
944 1055 'current_price' => $itemPrice,
945 1056 'scope' => 'price_range'
946 - ]);
1057 + ]));
947 1058 ?>
948 - <?php
1059 + <div class="fct-price-range fct-product-prices">
949 1060
950 - if ($comparePrice) {
951 - $aria_label = sprintf(
952 - /* translators: 1: Original price, 2: Current item price */
953 - __('Original Price: %1$s, Price: %2$s', 'fluent-cart'),
954 - Helper::toDecimal($comparePrice),
955 - Helper::toDecimal($itemPrice)
956 - );
957 - } else {
958 - $aria_label = sprintf(
959 - /* translators: 1: Current item price */
960 - __('Price: %1$s', 'fluent-cart'),
961 - Helper::toDecimal($itemPrice)
962 - );
963 - }
964 -
965 - ?>
966 - <div class="fct-price-range fct-product-prices" role="term"
967 - aria-label="<?php echo esc_attr($aria_label); ?>">
968 -
969 1061 <?php if ($comparePrice): ?>
970 1062 <span class="fct-compare-price">
971 - <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>
972 1065 </span>
973 1066 <?php endif; ?>
974 - <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>
975 1069 <?php echo esc_html(Helper::toDecimal($itemPrice)); ?>
976 - <?php do_action('fluent_cart/product/after_price', [
1070 + <?php do_action('fluent_cart/product/after_price', RenderContext::decorate([
977 1071 'product' => $this->product,
1072 + 'variant' => $first_price,
978 1073 'current_price' => $itemPrice,
979 1074 'scope' => 'price_range'
980 - ]); ?>
1075 + ])); ?>
1076 + <?php RenderHelper::renderPriceSuffix($this->product, $first_price, 'price_range'); ?>
981 1077 </span>
982 1078 </div>
983 1079 <?php
984 - do_action('fluent_cart/product/single/after_price_block', [
1080 + do_action('fluent_cart/product/single/after_price_block', RenderContext::decorate([
985 1081 'product' => $this->product,
986 1082 'current_price' => $itemPrice,
987 1083 'scope' => 'price_range'
988 - ]);
1084 + ]));
989 1085 return;
990 1086 }
991 1087 $min_price = $this->product->detail->min_price;
992 1088 $max_price = $this->product->detail->max_price;
993 1089
994 - do_action('fluent_cart/product/single/before_price_range_block', [
1090 + do_action('fluent_cart/product/single/before_price_range_block', RenderContext::decorate([
995 1091 'product' => $this->product,
996 1092 'current_price' => $min_price,
997 1093 'scope' => 'price_range'
998 - ]);
1094 + ]));
999 1095 ?>
1000 - <?php
1001 - $aria_label = sprintf(
1002 - /* translators: 1: Minimum price, 2: Maximum price */
1003 - __('Price range: %1$s - %2$s', 'fluent-cart'),
1004 - Helper::toDecimal($min_price),
1005 - Helper::toDecimal($max_price)
1006 - );
1007 - ?>
1008 - <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">
1009 1097
1010 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>
1011 1100 <span class="fct-min-price"><?php echo esc_html(Helper::toDecimal($min_price)); ?></span>
1012 1101 <span class="fct-price-separator" aria-hidden="true">-</span>
1102 + <span class="fct-sr-only"><?php echo esc_html__('to', 'fluent-cart'); ?></span>
1013 1103 <?php endif; ?>
1014 1104 <span class="fct-max-price">
1015 1105 <?php echo esc_html(Helper::toDecimal($max_price)); ?>
1016 1106 </span>
1017 1107
1018 - <?php do_action('fluent_cart/product/after_price', [
1108 + <?php do_action('fluent_cart/product/after_price', RenderContext::decorate([
1019 1109 'product' => $this->product,
1020 1110 'current_price' => $min_price,
1021 1111 'scope' => 'price_range'
1022 - ]); ?>
1112 + ])); ?>
1023 1113
1024 1114 </div>
1025 1115 <?php
1026 - do_action('fluent_cart/product/single/after_price_range_block', [
1116 + do_action('fluent_cart/product/single/after_price_range_block', RenderContext::decorate([
1027 1117 'product' => $this->product,
1028 1118 'current_price' => $min_price,
1029 1119 'scope' => 'price_range'
1030 - ]);
1120 + ]));
1031 1121 }
1032 1122
1033 1123 public function renderVariants($atts = [])
1034 1124 {
@@ -1053,19 +1143,19 @@
1053 1143 ?>
1054 1144 <div class="<?php echo esc_attr(implode(' ', $classes)); ?>" role="radiogroup"
1055 1145 aria-label="<?php esc_attr_e('Product Variants', 'fluent-cart'); ?>">
1056 1146 <?php foreach ($variants as $variant) {
1057 - do_action('fluent_cart/product/single/before_variant_item', [
1147 + do_action('fluent_cart/product/single/before_variant_item', RenderContext::decorate([
1058 1148 'product' => $this->product,
1059 1149 'variant' => $variant,
1060 1150 'scope' => 'product_variant_item'
1061 - ]);
1151 + ]));
1062 1152 $this->renderVariationItem($variant, $this->defaultVariationId);
1063 - do_action('fluent_cart/product/single/after_variant_item', [
1153 + do_action('fluent_cart/product/single/after_variant_item', RenderContext::decorate([
1064 1154 'product' => $this->product,
1065 1155 'variant' => $variant,
1066 1156 'scope' => 'product_variant_item'
1067 - ]);
1157 + ]));
1068 1158 } ?>
1069 1159 </div>
1070 1160 <?php
1071 1161 }
@@ -1071,17 +1161,24 @@
1071 1161 }
1072 1162
1073 1163 public function renderItemPrice()
1074 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 +
1075 1172 if ($this->product->detail->variation_type === 'simple' && !$this->hasSubscription) {
1076 1173 return; // for simple product we already rendered the price
1077 1174 }
1078 1175
1079 - do_action('fluent_cart/product/single/before_price_block', [
1176 + do_action('fluent_cart/product/single/before_price_block', RenderContext::decorate([
1080 1177 'product' => $this->product,
1081 1178 'current_price' => $this->defaultVariant ? $this->defaultVariant->item_price : 0,
1082 1179 'scope' => 'product_variant_price'
1083 - ]);
1180 + ]));
1084 1181
1085 1182 foreach ($this->product->variants as $variant) {
1086 1183 if ($this->shouldRenderPriceInPriceSection()) {
1087 1184 $this->renderVariantPricingWrapperStart($variant);
@@ -1115,13 +1212,13 @@
1115 1212
1116 1213
1117 1214
1118 1215
1119 - do_action('fluent_cart/product/single/after_price_block', [
1216 + do_action('fluent_cart/product/single/after_price_block', RenderContext::decorate([
1120 1217 'product' => $this->product,
1121 1218 'current_price' => $this->defaultVariant ? $this->defaultVariant->item_price : 0,
1122 1219 'scope' => 'product_variant_price'
1123 - ]);
1220 + ]));
1124 1221 }
1125 1222
1126 1223 public function shouldRenderPriceInPriceSection(): bool
1127 1224 {
@@ -1148,14 +1245,15 @@
1148 1245 'product' => $this->product,
1149 1246 'variant' => $variant,
1150 1247 'scope' => 'product_variant_price'
1151 1248 ]));
1152 - do_action('fluent_cart/product/after_price', [
1249 + do_action('fluent_cart/product/after_price', RenderContext::decorate([
1153 1250 'product' => $this->product,
1154 1251 'variant' => $variant,
1155 1252 'current_price' => $variant->item_price,
1156 1253 'scope' => 'product_variant_price'
1157 - ]);
1254 + ]));
1255 + RenderHelper::renderPriceSuffix($this->product, $variant, 'product_variant_price');
1158 1256 }
1159 1257
1160 1258 public function renderComparePriceWrapperStart($atts = [])
1161 1259 {
@@ -1177,8 +1275,9 @@
1177 1275 return;
1178 1276 } ?>
1179 1277
1180 1278 <span class="fct-compare-price">
1279 + <span class="fct-sr-only"><?php echo esc_html__('Original price:', 'fluent-cart'); ?></span>
1181 1280 <del><?php echo esc_html(Helper::toDecimal($variant->compare_price)); ?></del>
1182 1281 </span>
1183 1282 <?php
1184 1283 }
@@ -1264,8 +1363,14 @@
1264 1363 <?php }
1265 1364
1266 1365 public function renderQuantity()
1267 1366 {
1367 + $gateContext = RenderGate::context($this->product, RenderGate::SCOPE_SINGLE, $this->defaultVariant);
1368 +
1369 + if (!RenderGate::shouldRender('quantity', $gateContext)) {
1370 + return;
1371 + }
1372 +
1268 1373 $soldIndividually = $this->product->soldIndividually();
1269 1374
1270 1375 if (!$this->hasOnetime || $soldIndividually) {
1271 1376 return;
@@ -1284,12 +1389,12 @@
1284 1389 if ($this->hasSubscription && Arr::get($defaultVariantData, 'payment_type') !== 'onetime') {
1285 1390 $attributes['class'] .= ' is-hidden';
1286 1391 }
1287 1392
1288 - do_action('fluent_cart/product/single/before_quantity_block', [
1393 + do_action('fluent_cart/product/single/before_quantity_block', RenderContext::decorate([
1289 1394 'product' => $this->product,
1290 1395 'scope' => 'product_quantity_block'
1291 - ]);
1396 + ]));
1292 1397 ?>
1293 1398 <div <?php $this->renderAttributes($attributes); ?>>
1294 1399 <label for="fct-product-qty-input" class="quantity-title">
1295 1400 <?php esc_html_e('Quantity', 'fluent-cart'); ?>
@@ -1333,23 +1438,39 @@
1333 1438 </button>
1334 1439 </div>
1335 1440 </div>
1336 1441 <?php
1337 - do_action('fluent_cart/product/single/after_quantity_block', [
1442 + do_action('fluent_cart/product/single/after_quantity_block', RenderContext::decorate([
1338 1443 'product' => $this->product,
1339 1444 'scope' => 'product_quantity_block'
1340 - ]);
1445 + ]));
1341 1446 }
1342 1447
1343 1448 public function renderPurchaseButtons($atts = [])
1344 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 +
1345 1458 $buyNowButtonAtts = $atts;
1346 1459 $this->renderBuyNowButton($buyNowButtonAtts);
1347 1460 $this->renderAddToCartButton($atts);
1461 +
1462 + do_action('fluent_cart/product/single/after_actions_block', $gateContext);
1348 1463 }
1349 1464
1350 1465 public function renderBuyNowButton($atts = [])
1351 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 +
1352 1473 // Stock management check using isStock() method
1353 1474 // if (ModuleSettings::isActive('stock_management')) {
1354 1475 // if ($this->product->detail->variation_type === 'simple' && $this->defaultVariant) {
1355 1476 // if (!$this->defaultVariant->isStock()) {
@@ -1431,8 +1552,16 @@
1431 1552 }
1432 1553
1433 1554 public function renderBuyNowButtonBlock($atts = [])
1434 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 +
1435 1564 $text = Arr::get($atts, 'text', __('Buy Now', 'fluent-cart'));
1436 1565 $variantIds = Arr::get($atts, 'variant_ids', []);
1437 1566 $variantId = Arr::get($variantIds, 0);
1438 1567 $customClass = trim(Arr::get($atts, 'class', ''));
@@ -1517,8 +1646,14 @@
1517 1646 }
1518 1647
1519 1648 public function renderAddToCartButton($atts = [])
1520 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 +
1521 1656 $defaults = [
1522 1657 'buy_now_text' => __('Buy Now', 'fluent-cart'),
1523 1658 'add_to_cart_text' => __('Add To Cart', 'fluent-cart'),
1524 1659 ];
@@ -1613,8 +1748,15 @@
1613 1748 }
1614 1749
1615 1750 public function renderAddToCartButtonBlock($atts = [])
1616 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 +
1617 1759 $text = Arr::get($atts, 'text', __('Add To Cart', 'fluent-cart'));
1618 1760 $customClass = trim(Arr::get($atts, 'class', ''));
1619 1761 $extraClass = trim(Arr::get($atts, 'extra_class', ''));
1620 1762
@@ -1748,14 +1890,8 @@
1748 1890 if ($variant->id == $defaultId) {
1749 1891 $itemClasses[] = 'selected';
1750 1892 }
1751 1893
1752 - $priceSuffix = apply_filters('fluent_cart/product/price_suffix_atts', '', [
1753 - 'product' => $this->product,
1754 - 'variant' => $variant,
1755 - 'scope' => 'variant_item'
1756 - ]);
1757 -
1758 1894 $renderingAttributes = [
1759 1895 'data-fluent-cart-product-variant' => '',
1760 1896 'data-cart-id' => $variant->id,
1761 1897 'data-item-stock' => $variant->isStock() ? 'in-stock' : 'out-of-stock',
@@ -1763,9 +1899,8 @@
1763 1899 'data-payment-type' => $paymentType,
1764 1900 'data-available-stock' => $availableStocks,
1765 1901 'data-item-price' => Helper::toDecimal($variant->item_price),
1766 1902 'data-compare-price' => $comparePrice,
1767 - 'data-price-suffix' => $priceSuffix,
1768 1903 'data-stock-management' => ModuleSettings::isActive('stock_management') ? 'yes' : 'no',
1769 1904 'data-sku' => $variant->sku ?? '',
1770 1905 'data-package-info' => $this->getVariantPackageInfoJson($variant),
1771 1906 ];
@@ -1813,9 +1948,9 @@
1813 1948 }
1814 1949 ?>
1815 1950 <?php if ($this->viewType === 'both' || $this->viewType === 'text'): ?>
1816 1951 <div class="fct-product-variant-text">
1817 - <div class="fct-product-variant-title" aria-label="<?php echo esc_attr(__('Variant title', 'fluent-cart')); ?>"><?php echo esc_html($variant->variation_title); ?></div>
1952 + <div class="fct-product-variant-title"><?php echo esc_html($variant->variation_title); ?></div>
1818 1953 <?php if (!$this->shouldRenderPriceInPriceSection() && $paymentType === 'subscription'): ?>
1819 1954 <?php $this->renderSubscriptionInfo($variant); ?>
1820 1955 <?php endif; ?>
1821 1956 </div>
@@ -1825,14 +1960,15 @@
1825 1960 <?php if (!$this->shouldRenderPriceInPriceSection()): ?>
1826 1961 <div class="fct-product-variant-price">
1827 1962 <?php if ($comparePrice): ?>
1828 1963 <div class="fct-product-variant-compare-price">
1829 - <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>
1830 1966 <span><?php echo esc_html(Helper::toDecimal($comparePrice)); ?></span></del>
1831 1967 </div>
1832 1968 <?php endif; ?>
1833 - <div class="fct-product-variant-item-price"
1834 - 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>
1835 1971 <span><?php echo esc_html(Helper::toDecimal($itemPrice)); ?></span>
1836 1972 </div>
1837 1973 </div>
1838 1974 <?php endif; ?>
@@ -1852,9 +1988,9 @@
1852 1988 public function renderVariantImage($variant)
1853 1989 {
1854 1990 $image = $variant->thumbnail;
1855 1991 if (!$image) {
1856 - $image = Vite::getAssetUrl('images/placeholder.svg');
1992 + $image = Helper::getProductPlaceholderUrl();
1857 1993 }
1858 1994 ?>
1859 1995 <div class="fct-product-variant-image">
1860 1996 <img role="img" alt="<?php echo esc_attr($variant->variation_title); ?>"
@@ -1956,21 +2092,21 @@
1956 2092 //Convert to collection safely before sorting
1957 2093 $variants = (new Collection($variants))->sortBy('serial_index')->values();
1958 2094
1959 2095 foreach ($variants as $variant) {
1960 - do_action('fluent_cart/product/single/before_variant_item', [
2096 + do_action('fluent_cart/product/single/before_variant_item', RenderContext::decorate([
1961 2097 'product' => $this->product,
1962 2098 'variant' => $variant,
1963 2099 'scope' => 'product_variant_item'
1964 - ]);
2100 + ]));
1965 2101
1966 2102 $this->renderVariationItem($variant, $this->defaultVariationId);
1967 2103
1968 - do_action('fluent_cart/product/single/after_variant_item', [
2104 + do_action('fluent_cart/product/single/after_variant_item', RenderContext::decorate([
1969 2105 'product' => $this->product,
1970 2106 'variant' => $variant,
1971 2107 'scope' => 'product_variant_item'
1972 - ]);
2108 + ]));
1973 2109 }
1974 2110 ?>
1975 2111 </div>
1976 2112