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 +242 -96 1.5.2 → 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,9 +844,10 @@
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)
@@ -791,23 +897,24 @@
791 897 );
792 898 }
793 899 }
794 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 + */
795 906 public function renderPackageDescription($wrapper_attributes = '', $showName = true, $showDimensions = true, $showProductWeight = true, $showTotalWeight = true, $variant = null)
796 907 {
797 - if ($variant) {
798 - if (!$wrapper_attributes) {
799 - $wrapper_attributes = 'class="fct-package-description" data-fluent-cart-package-description';
800 - }
801 - (new ProductCardRender($this->product))->renderPackageDescription($wrapper_attributes, $showName, $showDimensions, $showProductWeight, $showTotalWeight, $variant);
802 - return;
803 - }
804 -
805 - foreach ($this->product->variants as $v) {
806 - $isHidden = ($this->defaultVariant && $this->defaultVariant->id != $v->id) ? ' is-hidden' : '';
807 - $variantWrapper = 'class="fct-package-description fluent-cart-product-variation-content' . $isHidden . '" data-fluent-cart-package-description data-variation-id="' . esc_attr($v->id) . '"';
808 - (new ProductCardRender($this->product))->renderPackageDescription($variantWrapper, $showName, $showDimensions, $showProductWeight, $showTotalWeight, $v);
809 - }
908 + (new PackageDescriptionRenderer($this->product))->renderPackageDescription(
909 + $wrapper_attributes,
910 + $showName,
911 + $showDimensions,
912 + $showProductWeight,
913 + $showTotalWeight,
914 + $variant,
915 + $this->defaultVariant
916 + );
810 917 }
811 918
812 919 /**
813 920 * Build a JSON string of package info for a variant (used as data attribute for JS switching).
@@ -876,14 +983,22 @@
876 983 $excerpt = $this->product->post_excerpt;
877 984 if (!$excerpt) {
878 985 return;
879 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);
880 995 ?>
881 996 <div class="fct-product-excerpt" aria-labelledby="fct-product-summary-title">
882 997 <p><?php echo wp_kses_post($excerpt); ?></p>
883 998 </div>
884 999 <?php
885 -
1000 + do_action('fluent_cart/product/single/after_excerpt_block', $gateContext);
886 1001 }
887 1002
888 1003 public function renderDescription()
889 1004 {
@@ -913,8 +1028,14 @@
913 1028 }
914 1029
915 1030 public function renderPrices()
916 1031 {
1032 + $gateContext = RenderGate::context($this->product, RenderGate::SCOPE_SINGLE, $this->defaultVariant);
1033 +
1034 + if (!RenderGate::shouldRender('price', $gateContext)) {
1035 + return;
1036 + }
1037 +
917 1038 if ($this->product->detail->variation_type === 'simple') {
918 1039 // we have to render for the simple product
919 1040
920 1041 $first_price = $this->product->variants()->first();
@@ -928,97 +1049,76 @@
928 1049 $comparePrice = $first_price ? (int)$first_price->compare_price : 0;
929 1050 if ($comparePrice <= $itemPrice) {
930 1051 $comparePrice = 0;
931 1052 }
932 - do_action('fluent_cart/product/single/before_price_block', [
1053 + do_action('fluent_cart/product/single/before_price_block', RenderContext::decorate([
933 1054 'product' => $this->product,
934 1055 'current_price' => $itemPrice,
935 1056 'scope' => 'price_range'
936 - ]);
1057 + ]));
937 1058 ?>
938 - <?php
1059 + <div class="fct-price-range fct-product-prices">
939 1060
940 - if ($comparePrice) {
941 - $aria_label = sprintf(
942 - /* translators: 1: Original price, 2: Current item price */
943 - __('Original Price: %1$s, Price: %2$s', 'fluent-cart'),
944 - Helper::toDecimal($comparePrice),
945 - Helper::toDecimal($itemPrice)
946 - );
947 - } else {
948 - $aria_label = sprintf(
949 - /* translators: 1: Current item price */
950 - __('Price: %1$s', 'fluent-cart'),
951 - Helper::toDecimal($itemPrice)
952 - );
953 - }
954 -
955 - ?>
956 - <div class="fct-price-range fct-product-prices" role="term"
957 - aria-label="<?php echo esc_attr($aria_label); ?>">
958 -
959 1061 <?php if ($comparePrice): ?>
960 1062 <span class="fct-compare-price">
961 - <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>
962 1065 </span>
963 1066 <?php endif; ?>
964 - <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>
965 1069 <?php echo esc_html(Helper::toDecimal($itemPrice)); ?>
966 - <?php do_action('fluent_cart/product/after_price', [
1070 + <?php do_action('fluent_cart/product/after_price', RenderContext::decorate([
967 1071 'product' => $this->product,
1072 + 'variant' => $first_price,
968 1073 'current_price' => $itemPrice,
969 1074 'scope' => 'price_range'
970 - ]); ?>
1075 + ])); ?>
1076 + <?php RenderHelper::renderPriceSuffix($this->product, $first_price, 'price_range'); ?>
971 1077 </span>
972 1078 </div>
973 1079 <?php
974 - do_action('fluent_cart/product/single/after_price_block', [
1080 + do_action('fluent_cart/product/single/after_price_block', RenderContext::decorate([
975 1081 'product' => $this->product,
976 1082 'current_price' => $itemPrice,
977 1083 'scope' => 'price_range'
978 - ]);
1084 + ]));
979 1085 return;
980 1086 }
981 1087 $min_price = $this->product->detail->min_price;
982 1088 $max_price = $this->product->detail->max_price;
983 1089
984 - do_action('fluent_cart/product/single/before_price_range_block', [
1090 + do_action('fluent_cart/product/single/before_price_range_block', RenderContext::decorate([
985 1091 'product' => $this->product,
986 1092 'current_price' => $min_price,
987 1093 'scope' => 'price_range'
988 - ]);
1094 + ]));
989 1095 ?>
990 - <?php
991 - $aria_label = sprintf(
992 - /* translators: 1: Minimum price, 2: Maximum price */
993 - __('Price range: %1$s - %2$s', 'fluent-cart'),
994 - Helper::toDecimal($min_price),
995 - Helper::toDecimal($max_price)
996 - );
997 - ?>
998 - <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">
999 1097
1000 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>
1001 1100 <span class="fct-min-price"><?php echo esc_html(Helper::toDecimal($min_price)); ?></span>
1002 1101 <span class="fct-price-separator" aria-hidden="true">-</span>
1102 + <span class="fct-sr-only"><?php echo esc_html__('to', 'fluent-cart'); ?></span>
1003 1103 <?php endif; ?>
1004 1104 <span class="fct-max-price">
1005 1105 <?php echo esc_html(Helper::toDecimal($max_price)); ?>
1006 1106 </span>
1007 1107
1008 - <?php do_action('fluent_cart/product/after_price', [
1108 + <?php do_action('fluent_cart/product/after_price', RenderContext::decorate([
1009 1109 'product' => $this->product,
1010 1110 'current_price' => $min_price,
1011 1111 'scope' => 'price_range'
1012 - ]); ?>
1112 + ])); ?>
1013 1113
1014 1114 </div>
1015 1115 <?php
1016 - do_action('fluent_cart/product/single/after_price_range_block', [
1116 + do_action('fluent_cart/product/single/after_price_range_block', RenderContext::decorate([
1017 1117 'product' => $this->product,
1018 1118 'current_price' => $min_price,
1019 1119 'scope' => 'price_range'
1020 - ]);
1120 + ]));
1021 1121 }
1022 1122
1023 1123 public function renderVariants($atts = [])
1024 1124 {
@@ -1043,19 +1143,19 @@
1043 1143 ?>
1044 1144 <div class="<?php echo esc_attr(implode(' ', $classes)); ?>" role="radiogroup"
1045 1145 aria-label="<?php esc_attr_e('Product Variants', 'fluent-cart'); ?>">
1046 1146 <?php foreach ($variants as $variant) {
1047 - do_action('fluent_cart/product/single/before_variant_item', [
1147 + do_action('fluent_cart/product/single/before_variant_item', RenderContext::decorate([
1048 1148 'product' => $this->product,
1049 1149 'variant' => $variant,
1050 1150 'scope' => 'product_variant_item'
1051 - ]);
1151 + ]));
1052 1152 $this->renderVariationItem($variant, $this->defaultVariationId);
1053 - do_action('fluent_cart/product/single/after_variant_item', [
1153 + do_action('fluent_cart/product/single/after_variant_item', RenderContext::decorate([
1054 1154 'product' => $this->product,
1055 1155 'variant' => $variant,
1056 1156 'scope' => 'product_variant_item'
1057 - ]);
1157 + ]));
1058 1158 } ?>
1059 1159 </div>
1060 1160 <?php
1061 1161 }
@@ -1061,17 +1161,24 @@
1061 1161 }
1062 1162
1063 1163 public function renderItemPrice()
1064 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 +
1065 1172 if ($this->product->detail->variation_type === 'simple' && !$this->hasSubscription) {
1066 1173 return; // for simple product we already rendered the price
1067 1174 }
1068 1175
1069 - do_action('fluent_cart/product/single/before_price_block', [
1176 + do_action('fluent_cart/product/single/before_price_block', RenderContext::decorate([
1070 1177 'product' => $this->product,
1071 1178 'current_price' => $this->defaultVariant ? $this->defaultVariant->item_price : 0,
1072 1179 'scope' => 'product_variant_price'
1073 - ]);
1180 + ]));
1074 1181
1075 1182 foreach ($this->product->variants as $variant) {
1076 1183 if ($this->shouldRenderPriceInPriceSection()) {
1077 1184 $this->renderVariantPricingWrapperStart($variant);
@@ -1105,13 +1212,13 @@
1105 1212
1106 1213
1107 1214
1108 1215
1109 - do_action('fluent_cart/product/single/after_price_block', [
1216 + do_action('fluent_cart/product/single/after_price_block', RenderContext::decorate([
1110 1217 'product' => $this->product,
1111 1218 'current_price' => $this->defaultVariant ? $this->defaultVariant->item_price : 0,
1112 1219 'scope' => 'product_variant_price'
1113 - ]);
1220 + ]));
1114 1221 }
1115 1222
1116 1223 public function shouldRenderPriceInPriceSection(): bool
1117 1224 {
@@ -1138,14 +1245,15 @@
1138 1245 'product' => $this->product,
1139 1246 'variant' => $variant,
1140 1247 'scope' => 'product_variant_price'
1141 1248 ]));
1142 - do_action('fluent_cart/product/after_price', [
1249 + do_action('fluent_cart/product/after_price', RenderContext::decorate([
1143 1250 'product' => $this->product,
1144 1251 'variant' => $variant,
1145 1252 'current_price' => $variant->item_price,
1146 1253 'scope' => 'product_variant_price'
1147 - ]);
1254 + ]));
1255 + RenderHelper::renderPriceSuffix($this->product, $variant, 'product_variant_price');
1148 1256 }
1149 1257
1150 1258 public function renderComparePriceWrapperStart($atts = [])
1151 1259 {
@@ -1167,8 +1275,9 @@
1167 1275 return;
1168 1276 } ?>
1169 1277
1170 1278 <span class="fct-compare-price">
1279 + <span class="fct-sr-only"><?php echo esc_html__('Original price:', 'fluent-cart'); ?></span>
1171 1280 <del><?php echo esc_html(Helper::toDecimal($variant->compare_price)); ?></del>
1172 1281 </span>
1173 1282 <?php
1174 1283 }
@@ -1254,8 +1363,14 @@
1254 1363 <?php }
1255 1364
1256 1365 public function renderQuantity()
1257 1366 {
1367 + $gateContext = RenderGate::context($this->product, RenderGate::SCOPE_SINGLE, $this->defaultVariant);
1368 +
1369 + if (!RenderGate::shouldRender('quantity', $gateContext)) {
1370 + return;
1371 + }
1372 +
1258 1373 $soldIndividually = $this->product->soldIndividually();
1259 1374
1260 1375 if (!$this->hasOnetime || $soldIndividually) {
1261 1376 return;
@@ -1274,12 +1389,12 @@
1274 1389 if ($this->hasSubscription && Arr::get($defaultVariantData, 'payment_type') !== 'onetime') {
1275 1390 $attributes['class'] .= ' is-hidden';
1276 1391 }
1277 1392
1278 - do_action('fluent_cart/product/single/before_quantity_block', [
1393 + do_action('fluent_cart/product/single/before_quantity_block', RenderContext::decorate([
1279 1394 'product' => $this->product,
1280 1395 'scope' => 'product_quantity_block'
1281 - ]);
1396 + ]));
1282 1397 ?>
1283 1398 <div <?php $this->renderAttributes($attributes); ?>>
1284 1399 <label for="fct-product-qty-input" class="quantity-title">
1285 1400 <?php esc_html_e('Quantity', 'fluent-cart'); ?>
@@ -1323,23 +1438,39 @@
1323 1438 </button>
1324 1439 </div>
1325 1440 </div>
1326 1441 <?php
1327 - do_action('fluent_cart/product/single/after_quantity_block', [
1442 + do_action('fluent_cart/product/single/after_quantity_block', RenderContext::decorate([
1328 1443 'product' => $this->product,
1329 1444 'scope' => 'product_quantity_block'
1330 - ]);
1445 + ]));
1331 1446 }
1332 1447
1333 1448 public function renderPurchaseButtons($atts = [])
1334 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 +
1335 1458 $buyNowButtonAtts = $atts;
1336 1459 $this->renderBuyNowButton($buyNowButtonAtts);
1337 1460 $this->renderAddToCartButton($atts);
1461 +
1462 + do_action('fluent_cart/product/single/after_actions_block', $gateContext);
1338 1463 }
1339 1464
1340 1465 public function renderBuyNowButton($atts = [])
1341 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 +
1342 1473 // Stock management check using isStock() method
1343 1474 // if (ModuleSettings::isActive('stock_management')) {
1344 1475 // if ($this->product->detail->variation_type === 'simple' && $this->defaultVariant) {
1345 1476 // if (!$this->defaultVariant->isStock()) {
@@ -1421,8 +1552,16 @@
1421 1552 }
1422 1553
1423 1554 public function renderBuyNowButtonBlock($atts = [])
1424 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 +
1425 1564 $text = Arr::get($atts, 'text', __('Buy Now', 'fluent-cart'));
1426 1565 $variantIds = Arr::get($atts, 'variant_ids', []);
1427 1566 $variantId = Arr::get($variantIds, 0);
1428 1567 $customClass = trim(Arr::get($atts, 'class', ''));
@@ -1507,8 +1646,14 @@
1507 1646 }
1508 1647
1509 1648 public function renderAddToCartButton($atts = [])
1510 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 +
1511 1656 $defaults = [
1512 1657 'buy_now_text' => __('Buy Now', 'fluent-cart'),
1513 1658 'add_to_cart_text' => __('Add To Cart', 'fluent-cart'),
1514 1659 ];
@@ -1603,8 +1748,15 @@
1603 1748 }
1604 1749
1605 1750 public function renderAddToCartButtonBlock($atts = [])
1606 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 +
1607 1759 $text = Arr::get($atts, 'text', __('Add To Cart', 'fluent-cart'));
1608 1760 $customClass = trim(Arr::get($atts, 'class', ''));
1609 1761 $extraClass = trim(Arr::get($atts, 'extra_class', ''));
1610 1762
@@ -1738,14 +1890,8 @@
1738 1890 if ($variant->id == $defaultId) {
1739 1891 $itemClasses[] = 'selected';
1740 1892 }
1741 1893
1742 - $priceSuffix = apply_filters('fluent_cart/product/price_suffix_atts', '', [
1743 - 'product' => $this->product,
1744 - 'variant' => $variant,
1745 - 'scope' => 'variant_item'
1746 - ]);
1747 -
1748 1894 $renderingAttributes = [
1749 1895 'data-fluent-cart-product-variant' => '',
1750 1896 'data-cart-id' => $variant->id,
1751 1897 'data-item-stock' => $variant->isStock() ? 'in-stock' : 'out-of-stock',
@@ -1753,9 +1899,8 @@
1753 1899 'data-payment-type' => $paymentType,
1754 1900 'data-available-stock' => $availableStocks,
1755 1901 'data-item-price' => Helper::toDecimal($variant->item_price),
1756 1902 'data-compare-price' => $comparePrice,
1757 - 'data-price-suffix' => $priceSuffix,
1758 1903 'data-stock-management' => ModuleSettings::isActive('stock_management') ? 'yes' : 'no',
1759 1904 'data-sku' => $variant->sku ?? '',
1760 1905 'data-package-info' => $this->getVariantPackageInfoJson($variant),
1761 1906 ];
@@ -1803,9 +1948,9 @@
1803 1948 }
1804 1949 ?>
1805 1950 <?php if ($this->viewType === 'both' || $this->viewType === 'text'): ?>
1806 1951 <div class="fct-product-variant-text">
1807 - <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>
1808 1953 <?php if (!$this->shouldRenderPriceInPriceSection() && $paymentType === 'subscription'): ?>
1809 1954 <?php $this->renderSubscriptionInfo($variant); ?>
1810 1955 <?php endif; ?>
1811 1956 </div>
@@ -1815,14 +1960,15 @@
1815 1960 <?php if (!$this->shouldRenderPriceInPriceSection()): ?>
1816 1961 <div class="fct-product-variant-price">
1817 1962 <?php if ($comparePrice): ?>
1818 1963 <div class="fct-product-variant-compare-price">
1819 - <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>
1820 1966 <span><?php echo esc_html(Helper::toDecimal($comparePrice)); ?></span></del>
1821 1967 </div>
1822 1968 <?php endif; ?>
1823 - <div class="fct-product-variant-item-price"
1824 - 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>
1825 1971 <span><?php echo esc_html(Helper::toDecimal($itemPrice)); ?></span>
1826 1972 </div>
1827 1973 </div>
1828 1974 <?php endif; ?>
@@ -1842,9 +1988,9 @@
1842 1988 public function renderVariantImage($variant)
1843 1989 {
1844 1990 $image = $variant->thumbnail;
1845 1991 if (!$image) {
1846 - $image = Vite::getAssetUrl('images/placeholder.svg');
1992 + $image = Helper::getProductPlaceholderUrl();
1847 1993 }
1848 1994 ?>
1849 1995 <div class="fct-product-variant-image">
1850 1996 <img role="img" alt="<?php echo esc_attr($variant->variation_title); ?>"
@@ -1946,21 +2092,21 @@
1946 2092 //Convert to collection safely before sorting
1947 2093 $variants = (new Collection($variants))->sortBy('serial_index')->values();
1948 2094
1949 2095 foreach ($variants as $variant) {
1950 - do_action('fluent_cart/product/single/before_variant_item', [
2096 + do_action('fluent_cart/product/single/before_variant_item', RenderContext::decorate([
1951 2097 'product' => $this->product,
1952 2098 'variant' => $variant,
1953 2099 'scope' => 'product_variant_item'
1954 - ]);
2100 + ]));
1955 2101
1956 2102 $this->renderVariationItem($variant, $this->defaultVariationId);
1957 2103
1958 - do_action('fluent_cart/product/single/after_variant_item', [
2104 + do_action('fluent_cart/product/single/after_variant_item', RenderContext::decorate([
1959 2105 'product' => $this->product,
1960 2106 'variant' => $variant,
1961 2107 'scope' => 'product_variant_item'
1962 - ]);
2108 + ]));
1963 2109 }
1964 2110 ?>
1965 2111 </div>
1966 2112