PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.7.1
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.7.1
1.7.1 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 All 51 releases
← All changes | app/Services/Renderer/ProductCardRender.php +223 -160 1.3.28 → 1.7.1 View file →
@@ -4,11 +4,10 @@
4 4
5 5 use FluentCart\Api\ModuleSettings;
6 6 use FluentCart\App\Helpers\Helper;
7 7 use FluentCart\App\Models\Product;
8 -use FluentCart\App\Models\ProductVariation;
8 +use FluentCart\App\Services\ProductReviewService;
9 9 use FluentCart\App\Modules\Templating\AssetLoader;
10 -use FluentCart\App\Vite;
11 10 use FluentCart\Framework\Support\Arr;
12 11
13 12 class ProductCardRender
14 13 {
@@ -37,8 +36,21 @@
37 36
38 37 public function render()
39 38 {
40 39 AssetLoader::loadSingleProductAssets();
40 + // Lets extensions (e.g. Pro's advanced-variation selector) enqueue the
41 + // CSS/JS a product purchase UI needs. Backs the card surfaces that go
42 + // through this renderer's render() — Shop, Product List, the product
43 + // shortcode, and the quick-view modal. Surfaces that render card parts
44 + // via the individual render* methods instead of render() (Product
45 + // Carousel, Related Products) fire this same hook from their own block
46 + // render. Guarded with a static flag so a grid of N cards fires it once
47 + // per request, not once per card. No payload — assets are uniform.
48 + static $assetsHookFired = false;
49 + if (!$assetsHookFired) {
50 + $assetsHookFired = true;
51 + do_action('fluent_cart/advanced_variation/enqueue_assets');
52 + }
41 53 $cursor = '';
42 54 if (!empty($this->config['cursor'])) {
43 55 $cursor = 'data-fluent-cart-cursor="' . esc_attr($this->config['cursor']) . '"';
44 56 }
@@ -50,11 +62,24 @@
50 62 $cardWidth = 'style="width: ' . esc_attr($widthValue) . ';"';
51 63 }
52 64
53 65
66 + // Card wrapper classes are filterable so an integration can tag cards
67 + // (sale, low-stock, layout variants) without wrapping or re-rendering
68 + // the whole card. Sanitised in RenderGate, so listeners may return a
69 + // plain array of class names.
70 + $cardClasses = RenderGate::cardClasses(['fct-product-card'], [
71 + 'product' => $this->product,
72 + 'scope' => RenderGate::SCOPE_CARD,
73 + ]);
74 +
75 + do_action('fluent_cart/product/group/before_card', RenderContext::decorate([
76 + 'product' => $this->product,
77 + 'scope' => RenderGate::SCOPE_CARD,
78 + ]));
54 79 ?>
55 80 <article data-fluent-cart-shop-app-single-product data-fct-product-card=""
56 - class="fct-product-card"
81 + class="<?php echo esc_attr($cardClasses); ?>"
57 82 <?php echo $cursor; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Already escaped ?>
58 83 <?php echo $cardWidth; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Already escaped ?>
59 84 aria-label="<?php echo esc_attr(sprintf(
60 85 /* translators: %s: product title */
@@ -61,17 +86,23 @@
61 86 __('%s product card', 'fluent-cart'), $this->product->post_title));
62 87 ?>">
63 88 <?php $this->renderProductImage(); ?>
64 89 <?php $this->renderTitle(); ?>
65 - <?php $this->renderExcerpt(); ?>
90 + <?php $this->renderStarRating(); ?>
91 + <?php if (!Arr::get($this->config, 'hide_excerpt', false)) { $this->renderExcerpt(); } ?>
66 92 <?php $this->renderPrices(); ?>
67 93 <?php $this->showBuyButton(); ?>
68 94 </article>
69 95 <?php
96 + do_action('fluent_cart/product/group/after_card', RenderContext::decorate([
97 + 'product' => $this->product,
98 + 'scope' => RenderGate::SCOPE_CARD,
99 + ]));
70 100 }
71 101
72 102 /**
73 - * Build package info string from snapshotted other_info fields.
103 + * @deprecated Use PackageDescriptionRenderer::buildPackageInfoFromOtherInfo() directly.
104 + * Kept as a compatibility shim for external callers (themes, extensions).
74 105 *
75 106 * @param array $otherInfo Order item's other_info array
76 107 * @return string e.g. "Gift (Box) · 30 × 20 × 15 cm · Wt: 2 kg · Shipping: 2.5 kg"
77 108 */
@@ -76,53 +107,16 @@
76 107 * @return string e.g. "Gift (Box) · 30 × 20 × 15 cm · Wt: 2 kg · Shipping: 2.5 kg"
77 108 */
78 109 public static function buildPackageInfoFromOtherInfo($otherInfo)
79 110 {
80 - $storeWeightUnit = Helper::shopConfig('weight_unit') ?: 'kg';
81 - $parts = [];
82 -
83 - // Package name
84 - $name = Arr::get($otherInfo, 'package_name', '');
85 - if ($name) {
86 - $parts[] = $name;
87 - }
88 -
89 - // Dimensions
90 - $length = Arr::get($otherInfo, 'package_length', '');
91 - $width = Arr::get($otherInfo, 'package_width', '');
92 - $height = Arr::get($otherInfo, 'package_height', '');
93 - $dimensionUnit = Arr::get($otherInfo, 'package_dimension_unit', 'cm');
94 - $dimParts = array_filter([$length, $width, $height], function ($val) {
95 - return $val !== '' && $val !== null && $val != 0;
96 - });
97 - if ($dimParts) {
98 - $parts[] = implode(' × ', $dimParts) . ' ' . $dimensionUnit;
99 - }
100 -
101 - // Product weight
102 - $productWeight = floatval(Arr::get($otherInfo, 'weight', 0));
103 - $productWeightUnit = Arr::get($otherInfo, 'weight_unit', $storeWeightUnit);
104 - $convertedProductWeight = Helper::convertWeight($productWeight, $productWeightUnit, $storeWeightUnit);
105 - if ($convertedProductWeight) {
106 - $formatted = rtrim(rtrim(number_format($convertedProductWeight, 2), '0'), '.');
107 - $parts[] = __('Wt:', 'fluent-cart') . ' ' . $formatted . ' ' . $storeWeightUnit;
108 - }
109 -
110 - // Shipping weight (product + package)
111 - $packageWeight = floatval(Arr::get($otherInfo, 'package_weight', 0));
112 - $packageWeightUnit = Arr::get($otherInfo, 'package_weight_unit', $storeWeightUnit);
113 - $convertedPackageWeight = Helper::convertWeight($packageWeight, $packageWeightUnit, $storeWeightUnit);
114 - $totalWeight = $convertedProductWeight + $convertedPackageWeight;
115 - if ($totalWeight && $convertedPackageWeight) {
116 - $formatted = rtrim(rtrim(number_format($totalWeight, 2), '0'), '.');
117 - $parts[] = __('Shipping wt:', 'fluent-cart') . ' ' . $formatted . ' ' . $storeWeightUnit;
118 - }
119 -
120 - $info = implode(' · ', $parts);
121 -
122 - return $info ? __('Package:', 'fluent-cart') . ' ' . $info : '';
111 + return PackageDescriptionRenderer::buildPackageInfoFromOtherInfo($otherInfo);
123 112 }
124 113
114 + /**
115 + * @deprecated Use PackageDescriptionRenderer::renderPackageDescription() directly.
116 + * Kept as a compatibility shim for external callers (themes, extensions) —
117 + * package-description rendering now lives in PackageDescriptionRenderer.
118 + */
125 119 public function renderPackageDescription(
126 120 $wrapper_attributes = '',
127 121 $showName = true,
128 122 $showDimensions = true,
@@ -127,113 +121,116 @@
127 121 $showName = true,
128 122 $showDimensions = true,
129 123 $showProductWeight = true,
130 124 $showTotalWeight = true,
131 - $variant = null
125 + $variant = null,
126 + $defaultVariant = null
132 127 ) {
133 - $variant = $variant ?: $this->product->variants->first();
128 + (new PackageDescriptionRenderer($this->product))->renderPackageDescription(
129 + $wrapper_attributes,
130 + $showName,
131 + $showDimensions,
132 + $showProductWeight,
133 + $showTotalWeight,
134 + $variant,
135 + $defaultVariant
136 + );
137 + }
134 138
135 - if (!$variant) {
139 + public function renderStarRating()
140 + {
141 + if (!ModuleSettings::isActive('reviews')) {
136 142 return;
137 143 }
138 144
139 - if (!$wrapper_attributes) {
140 - $wrapper_attributes = 'class="fct-package-description" data-fluent-cart-package-description';
141 - }
145 + // Store-level toggles: Settings → Store Settings → Product Page →
146 + // Product Rating. Shop grid/carousels gate on show_rating_in_shop;
147 + // relevant/related product sections gate on show_rating_in_relevant
148 + // (callers tag those cards with config rating_context = 'relevant').
149 + // The explicit product-rating block (renderStarRatingBlock) stays
150 + // unaffected — it is user-placed.
151 + $ratingContext = Arr::get($this->config, 'rating_context', 'shop');
142 152
143 - $this->renderPackageDescriptionForVariant($variant, $wrapper_attributes, $showName, $showDimensions, $showProductWeight, $showTotalWeight);
144 - }
153 + $ratingVisibilitySettingKey = $ratingContext === 'relevant'
154 + ? 'show_rating_in_relevant'
155 + : 'show_rating_in_shop';
145 156
146 - private function renderPackageDescriptionForVariant(
147 - ProductVariation $variant,
148 - $wrapper_attributes,
149 - $showName,
150 - $showDimensions,
151 - $showProductWeight,
152 - $showTotalWeight
153 - ) {
154 - if ($variant->fulfillment_type !== 'physical') {
157 + if ((new \FluentCart\Api\StoreSettings())->get($ratingVisibilitySettingKey, 'yes') !== 'yes') {
155 158 return;
156 159 }
157 160
158 - $packageSlug = Arr::get($variant->other_info, 'package_slug', '');
159 - $package = Helper::getPackageBySlug($packageSlug);
160 -
161 - if (!$package) {
161 + if (!ProductReviewService::ratingMeetsThresholds($this->product, $this->config)) {
162 162 return;
163 163 }
164 164
165 - $name = Arr::get($package, 'name', '');
166 - $length = Arr::get($package, 'length', '');
167 - $width = Arr::get($package, 'width', '');
168 - $height = Arr::get($package, 'height', '');
169 - $dimensionUnit = Arr::get($package, 'dimension_unit', 'cm');
170 - $packageWeight = floatval(Arr::get($package, 'weight', 0));
171 - $packageWeightUnit = Arr::get($package, 'weight_unit', 'kg');
165 + $otherInfo = $this->product->detail->other_info ?: [];
166 + $avgRating = Arr::get($otherInfo, 'average_rating', 0);
167 + $reviewCount = (int) Arr::get($otherInfo, 'review_count', 0);
172 168
173 - $storeWeightUnit = Helper::shopConfig('weight_unit') ?: 'kg';
174 - $otherInfo = $variant->other_info ?: [];
175 - $productWeight = floatval(Arr::get($otherInfo, 'weight', 0));
176 - $productWeightUnit = Arr::get($otherInfo, 'weight_unit', $storeWeightUnit);
177 -
178 - $convertedProductWeight = Helper::convertWeight($productWeight, $productWeightUnit, $storeWeightUnit);
179 - $convertedPackageWeight = Helper::convertWeight($packageWeight, $packageWeightUnit, $storeWeightUnit);
180 - $totalWeight = $convertedProductWeight + $convertedPackageWeight;
181 -
182 - $hasVisibleContent = ($showName && $name)
183 - || ($showDimensions && ($length || $width || $height))
184 - || ($showProductWeight && $convertedProductWeight)
185 - || ($showTotalWeight && $totalWeight);
186 -
187 - if (!$hasVisibleContent) {
169 + if ($reviewCount < 1) {
188 170 return;
189 171 }
190 172
191 - echo sprintf('<div %s>', $wrapper_attributes); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
192 - echo '<table class="fct-package-description__table" role="presentation"><tbody>';
173 + $fullStars = (int) floor($avgRating);
174 + $halfStar = ($avgRating - $fullStars) >= 0.5;
175 + $emptyStars = 5 - $fullStars - ($halfStar ? 1 : 0);
193 176
194 - if ($showName && $name) {
195 - echo sprintf(
196 - '<tr><th>%s</th><td>%s</td></tr>',
197 - esc_html__('Package', 'fluent-cart'),
198 - esc_html($name)
199 - );
200 - }
177 + ?>
178 + <div class="fct-product-card-rating" role="img" aria-label="<?php echo esc_attr(sprintf(
179 + /* translators: %1$s: average rating, %2$d: review count */
180 + __('Rated %1$s out of 5 based on %2$d reviews', 'fluent-cart'), $avgRating, $reviewCount)); ?>">
181 + <span class="fct-product-card-stars" aria-hidden="true">
182 + <?php
183 + for ($i = 0; $i < $fullStars; $i++) {
184 + echo '<span class="fct-star fct-star-filled">' . ReviewThreadMarkup::starSvg() . '</span>'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
185 + }
186 + if ($halfStar) {
187 + echo '<span class="fct-star fct-star-half"><span class="fct-star-half-empty">' . ReviewThreadMarkup::starSvg() . '</span><span class="fct-star-half-fill">' . ReviewThreadMarkup::starSvg() . '</span></span>'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
188 + }
189 + for ($i = 0; $i < $emptyStars; $i++) {
190 + echo '<span class="fct-star fct-star-empty">' . ReviewThreadMarkup::starSvg() . '</span>'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
191 + }
192 + ?>
193 + </span>
194 + <span class="fct-product-card-review-count">(<?php echo esc_html($reviewCount); ?>)</span>
195 + </div>
196 + <?php
197 + }
201 198
202 - if ($showDimensions && ($length || $width || $height)) {
203 - $dimensionParts = array_filter([$length, $width, $height], function ($val) {
204 - return $val !== '' && $val !== null;
205 - });
206 - if ($dimensionParts) {
207 - $formattedDimensions = implode(' × ', $dimensionParts) . ' ' . $dimensionUnit;
208 - echo sprintf(
209 - '<tr><th>%s</th><td>%s</td></tr>',
210 - esc_html__('Dimensions', 'fluent-cart'),
211 - esc_html($formattedDimensions)
212 - );
213 - }
199 + public function renderStarRatingBlock($wrapperAttributes = '', array $attributes = [], $block = null)
200 + {
201 + if (!ProductReviewService::ratingMeetsThresholds($this->product, $attributes, $block)) {
202 + return;
214 203 }
215 204
216 - if ($showProductWeight && $convertedProductWeight) {
217 - $formattedProductWeight = rtrim(rtrim(number_format($convertedProductWeight, 2), '0'), '.');
218 - echo sprintf(
219 - '<tr><th>%s</th><td>%s</td></tr>',
220 - esc_html__('Weight', 'fluent-cart'),
221 - esc_html($formattedProductWeight . ' ' . $storeWeightUnit)
222 - );
223 - }
205 + $otherInfo = $this->product->detail->other_info ?: [];
206 + $avgRating = Arr::get($otherInfo, 'average_rating', 0);
207 + $reviewCount = (int) Arr::get($otherInfo, 'review_count', 0);
224 208
225 - if ($showTotalWeight && $totalWeight && $convertedPackageWeight) {
226 - $formattedTotalWeight = rtrim(rtrim(number_format($totalWeight, 2), '0'), '.');
227 - echo sprintf(
228 - '<tr><th>%s</th><td>%s</td></tr>',
229 - esc_html__('Shipping Weight', 'fluent-cart'),
230 - esc_html($formattedTotalWeight . ' ' . $storeWeightUnit)
231 - );
232 - }
209 + $fullStars = (int) floor($avgRating);
210 + $halfStar = ($avgRating - $fullStars) >= 0.5;
211 + $emptyStars = 5 - $fullStars - ($halfStar ? 1 : 0);
233 212
234 - echo '</tbody></table>';
235 - echo '</div>';
213 + ?>
214 + <div <?php echo $wrapperAttributes; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?> role="img" aria-label="<?php echo esc_attr(sprintf(
215 + /* translators: %1$s: average rating, %2$d: review count */
216 + __('Rated %1$s out of 5 based on %2$d reviews', 'fluent-cart'), $avgRating, $reviewCount)); ?>">
217 + <span class="fct-product-card-stars" aria-hidden="true">
218 + <?php
219 + for ($i = 0; $i < $fullStars; $i++) {
220 + echo '<span class="fct-star fct-star-filled">' . ReviewThreadMarkup::starSvg() . '</span>'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
221 + }
222 + if ($halfStar) {
223 + echo '<span class="fct-star fct-star-half"><span class="fct-star-half-empty">' . ReviewThreadMarkup::starSvg() . '</span><span class="fct-star-half-fill">' . ReviewThreadMarkup::starSvg() . '</span></span>'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
224 + }
225 + for ($i = 0; $i < $emptyStars; $i++) {
226 + echo '<span class="fct-star fct-star-empty">' . ReviewThreadMarkup::starSvg() . '</span>'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
227 + }
228 + ?>
229 + </span>
230 + <span class="fct-product-card-review-count">(<?php echo esc_html($reviewCount); ?>)</span>
231 + </div>
232 + <?php
236 233 }
237 234
238 235 public function renderExcerpt($atts = '')
239 236 {
@@ -240,8 +237,16 @@
240 237 if (empty($this->product->post_excerpt)) {
241 238 return;
242 239 }
243 240
241 + $gateContext = RenderGate::context($this->product, RenderGate::SCOPE_CARD);
242 +
243 + if (!RenderGate::shouldRender('excerpt', $gateContext)) {
244 + return;
245 + }
246 +
247 + do_action('fluent_cart/product/group/before_excerpt_block', $gateContext);
248 +
244 249 echo sprintf(
245 250 '<p %1$s class="fct-product-card-excerpt">
246 251 %2$s
247 252 </p>',
@@ -247,12 +252,22 @@
247 252 </p>',
248 253 $atts, // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
249 254 wp_kses_post($this->product->post_excerpt),
250 255 );
256 +
257 + do_action('fluent_cart/product/group/after_excerpt_block', $gateContext);
251 258 }
252 259
253 260 public function renderTitle($atts = '', $config = [])
254 261 {
262 + $gateContext = RenderGate::context($this->product, RenderGate::SCOPE_CARD);
263 +
264 + if (!RenderGate::shouldRender('title', $gateContext)) {
265 + return;
266 + }
267 +
268 + do_action('fluent_cart/product/group/before_title_block', $gateContext);
269 +
255 270 $link = Arr::get($config, 'isLink', true);
256 271 $target = Arr::get($config, 'target', '_self');
257 272
258 273 $titleText = esc_html($this->product->post_title);
@@ -282,17 +297,26 @@
282 297 $atts, // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
283 298 esc_html($titleText)
284 299 );
285 300 }
301 +
302 + do_action('fluent_cart/product/group/after_title_block', $gateContext);
286 303 }
287 304
288 305 public function renderProductImage()
289 306 {
290 - $image = $this->product->thumbnail;
307 + if (!RenderGate::shouldRender('image', RenderGate::context($this->product, RenderGate::SCOPE_CARD))) {
308 + return;
309 + }
310 +
311 + // Product::$thumbnail already falls back to the placeholder, which
312 + // hides whether this is one, so ask for the featured image directly.
313 + $detail = $this->product->detail;
314 + $image = $detail ? Arr::get((array) $detail->featured_media, 'url') : '';
291 315 $isPlaceholder = false;
292 316
293 317 if (!$image) {
294 - $image = Vite::getAssetUrl('images/placeholder.svg');
318 + $image = Helper::getProductPlaceholderUrl();
295 319 $isPlaceholder = true;
296 320 }
297 321
298 322 $altText = $isPlaceholder
@@ -300,14 +324,14 @@
300 324 /* translators: %s: product title */
301 325 __('Placeholder image for %s', 'fluent-cart'), $this->product->post_title)
302 326 : $this->product->post_title;
303 327
304 - do_action('fluent_cart/product/group/before_image_block', [
328 + do_action('fluent_cart/product/group/before_image_block', RenderContext::decorate([
305 329 'product' => $this->product,
306 330 'scope' => 'product_card'
307 - ]);
331 + ]));
308 332 ?>
309 - <a class="fct-product-card-image-wrap"
333 + <a class="fct-product-card-image-wrap<?php echo $isPlaceholder ? ' is-placeholder' : ''; ?>"
310 334 href="<?php echo esc_url($this->viewUrl); ?>"
311 335 style="display: block;"
312 336 aria-label="<?php echo esc_attr(sprintf(
313 337 /* translators: %s: product title */
@@ -321,21 +345,26 @@
321 345 height="300"/>
322 346 </a>
323 347 <?php
324 348
325 - do_action('fluent_cart/product/group/after_image_block', [
349 + do_action('fluent_cart/product/group/after_image_block', RenderContext::decorate([
326 350 'product' => $this->product,
327 351 'scope' => 'product_card'
328 - ]);
352 + ]));
329 353 }
330 354
331 355 public function renderPrices($wrapper_attributes = '')
332 356 {
357 + if (!RenderGate::shouldRender('price', RenderGate::context($this->product, RenderGate::SCOPE_CARD))) {
358 + return;
359 + }
360 +
333 361 $priceFormat = Arr::get($this->config, 'price_format', 'starts_from');
334 362 $isSimple = $this->product->detail->variation_type === 'simple';
335 363 $minPrice = $this->product->detail->min_price;
336 364 $maxPrice = $this->product->detail->max_price;
337 365 $comparePrice = 0;
366 + $firstVariant = null;
338 367
339 368 if ($isSimple) {
340 369 $firstVariant = $this->product->variants->first();
341 370 if ($firstVariant) {
@@ -354,13 +383,13 @@
354 383 $formattedMinPrice = Helper::toDecimal($minPrice);
355 384 $formattedMaxPrice = Helper::toDecimal($maxPrice);
356 385 $formattedComparePrice = Helper::toDecimal($comparePrice);
357 386
358 - do_action('fluent_cart/product/group/before_price_block', [
387 + do_action('fluent_cart/product/group/before_price_block', RenderContext::decorate([
359 388 'product' => $this->product,
360 389 'current_price' => $minPrice,
361 390 'scope' => 'product_card'
362 - ]);
391 + ]));
363 392 ?>
364 393 <div <?php echo $wrapper_attributes; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
365 394 class="fct-product-card-prices"
366 395 role="region"
@@ -365,12 +394,11 @@
365 394 class="fct-product-card-prices"
366 395 role="region"
367 396 aria-label="<?php echo esc_attr__('Product pricing', 'fluent-cart'); ?>">
368 397 <?php if ($comparePrice): ?>
369 - <span class="fct-compare-price" aria-label="<?php echo esc_attr(sprintf(
370 - /* translators: %s: product price */
371 - __('Original price: %s', 'fluent-cart'), $formattedComparePrice)); ?>">
372 - <del aria-hidden="true"><?php echo esc_html($formattedComparePrice); ?></del>
398 + <span class="fct-compare-price">
399 + <span class="fct-sr-only"><?php echo esc_html__('Original price:', 'fluent-cart'); ?></span>
400 + <del><?php echo esc_html($formattedComparePrice); ?></del>
373 401 </span>
374 402 <?php endif; ?>
375 403
376 404 <?php if (!$comparePrice && $maxPrice && $maxPrice > $minPrice): ?>
@@ -375,49 +403,68 @@
375 403
376 404 <?php if (!$comparePrice && $maxPrice && $maxPrice > $minPrice): ?>
377 405 <!-- Case 2: price range -->
378 406 <?php if ($priceFormat === 'range'): ?>
379 - <span class="fct-item-price" aria-label="<?php echo esc_attr(sprintf(
380 - /* translators: %1$s: min price, %2$s: max price */
381 - __('Price range from %1$s to %2$s', 'fluent-cart'), $formattedMinPrice, $formattedMaxPrice)); ?>">
382 - <span aria-hidden="true"><?php echo esc_html($formattedMinPrice); ?> - <?php echo esc_html($formattedMaxPrice); ?></span>
407 + <span class="fct-item-price">
408 + <span class="fct-sr-only"><?php echo esc_html__('Price range:', 'fluent-cart'); ?></span>
409 + <?php echo esc_html($formattedMinPrice); ?>
410 + <span aria-hidden="true">-</span>
411 + <span class="fct-sr-only"><?php echo esc_html__('to', 'fluent-cart'); ?></span>
412 + <?php echo esc_html($formattedMaxPrice); ?>
383 413 </span>
384 414 <?php else: ?>
385 - <span class="fct-item-price" aria-label="<?php echo esc_attr(sprintf(
386 - /* translators: %s: min price */
387 - __('Starting from %s', 'fluent-cart'), $formattedMinPrice)); ?>">
388 - <span aria-hidden="true"><?php
415 + <span class="fct-item-price"><?php
389 416 /* translators: %s is the minimum price */
390 417 printf(esc_html__('From %s', 'fluent-cart'), esc_html($formattedMinPrice));
391 418 ?></span>
392 - </span>
393 419 <?php endif; ?>
394 420
395 421 <?php else: ?>
396 422 <!-- Case 3: Simple or single price -->
397 - <span class="fct-item-price" aria-label="<?php echo esc_attr(sprintf(
398 - /* translators: %s: product price */
399 - __('Price: %s', 'fluent-cart'), $formattedMinPrice)); ?>">
400 - <span aria-hidden="true"><?php echo esc_html($formattedMinPrice); ?></span>
423 + <span class="fct-item-price">
424 + <span class="fct-sr-only"><?php echo esc_html__('Price:', 'fluent-cart'); ?></span>
425 + <?php echo esc_html($formattedMinPrice); ?>
401 426 </span>
402 427 <?php endif; ?>
403 428
404 - <?php do_action('fluent_cart/product/after_price', [
429 + <?php
430 + do_action('fluent_cart/product/after_price', RenderContext::decorate([
405 431 'product' => $this->product,
432 + 'variant' => $firstVariant,
406 433 'current_price' => $minPrice,
407 434 'scope' => 'product_card'
408 - ]); ?>
435 + ]));
436 + RenderHelper::renderPriceSuffix($this->product, $firstVariant, 'product_card');
437 + ?>
409 438 </div>
410 439 <?php
411 - do_action('fluent_cart/product/group/after_price_block', [
440 + do_action('fluent_cart/product/group/after_price_block', RenderContext::decorate([
412 441 'product' => $this->product,
413 442 'current_price' => $minPrice,
414 443 'scope' => 'product_card'
415 - ]);
444 + ]));
416 445 }
417 446
418 447 public function showBuyButton($atts = '')
419 448 {
449 + $gateContext = RenderGate::context($this->product, RenderGate::SCOPE_CARD);
450 +
451 + // 'actions' gates the whole affordance slot, including the disabled
452 + // out-of-stock placeholder — hiding the row should not leave a stray
453 + // "Not Available" button behind.
454 + if (!RenderGate::shouldRender('actions', $gateContext)) {
455 + return;
456 + }
457 +
458 + do_action('fluent_cart/product/group/before_actions_block', $gateContext);
459 +
460 + $this->renderBuyButtonMarkup($atts, $gateContext);
461 +
462 + do_action('fluent_cart/product/group/after_actions_block', $gateContext);
463 + }
464 +
465 + protected function renderBuyButtonMarkup($atts, array $gateContext)
466 + {
420 467 $isOutOfStock = ModuleSettings::isActive('stock_management') && !$this->product->isStock();
421 468
422 469 if ($isOutOfStock) {
423 470 $soldOutText = __('Not Available', 'fluent-cart');
@@ -463,8 +510,24 @@
463 510 $buttonText = __('Add To Cart', 'fluent-cart');
464 511 $ariaLabel = sprintf(
465 512 /* translators: %s: product title */
466 513 __('Add %s to cart', 'fluent-cart'), $this->product->post_title);
514 + }
515 + }
516 +
517 + // The card fills this slot with one of three things: an instant-checkout
518 + // Buy Now anchor (simple + subscription), an Add to Cart button (simple),
519 + // or a "View Options" link through to the product page (variable). The
520 + // first two are purchase affordances and answer to their own gates.
521 + // "View Options" is navigation, so it stays under 'actions' alone — a
522 + // catalog-mode listener hides the buying, not the browsing.
523 + if ($isInstantCheckout) {
524 + if (!RenderGate::shouldRender('buy_now_button', $gateContext)) {
525 + return;
526 + }
527 + } elseif ($firstVariant) {
528 + if (!RenderGate::shouldRender('add_to_cart_button', $gateContext)) {
529 + return;
467 530 }
468 531 }
469 532
470 533 $buttonAttributes = [