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 +225 -158 1.3.22 → 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,109 +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 - $packageSlug = Arr::get($variant->other_info, 'package_slug', '');
155 - $package = Helper::getPackageBySlug($packageSlug);
157 + if ((new \FluentCart\Api\StoreSettings())->get($ratingVisibilitySettingKey, 'yes') !== 'yes') {
158 + return;
159 + }
156 160
157 - if (!$package) {
161 + if (!ProductReviewService::ratingMeetsThresholds($this->product, $this->config)) {
158 162 return;
159 163 }
160 164
161 - $name = Arr::get($package, 'name', '');
162 - $length = Arr::get($package, 'length', '');
163 - $width = Arr::get($package, 'width', '');
164 - $height = Arr::get($package, 'height', '');
165 - $dimensionUnit = Arr::get($package, 'dimension_unit', 'cm');
166 - $packageWeight = floatval(Arr::get($package, 'weight', 0));
167 - $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);
168 168
169 - $storeWeightUnit = Helper::shopConfig('weight_unit') ?: 'kg';
170 - $otherInfo = $variant->other_info ?: [];
171 - $productWeight = floatval(Arr::get($otherInfo, 'weight', 0));
172 - $productWeightUnit = Arr::get($otherInfo, 'weight_unit', $storeWeightUnit);
173 -
174 - $convertedProductWeight = Helper::convertWeight($productWeight, $productWeightUnit, $storeWeightUnit);
175 - $convertedPackageWeight = Helper::convertWeight($packageWeight, $packageWeightUnit, $storeWeightUnit);
176 - $totalWeight = $convertedProductWeight + $convertedPackageWeight;
177 -
178 - $hasVisibleContent = ($showName && $name)
179 - || ($showDimensions && ($length || $width || $height))
180 - || ($showProductWeight && $convertedProductWeight)
181 - || ($showTotalWeight && $totalWeight);
182 -
183 - if (!$hasVisibleContent) {
169 + if ($reviewCount < 1) {
184 170 return;
185 171 }
186 172
187 - echo sprintf('<div %s>', $wrapper_attributes); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
188 - 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);
189 176
190 - if ($showName && $name) {
191 - echo sprintf(
192 - '<tr><th>%s</th><td>%s</td></tr>',
193 - esc_html__('Package', 'fluent-cart'),
194 - esc_html($name)
195 - );
196 - }
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 + }
197 198
198 - if ($showDimensions && ($length || $width || $height)) {
199 - $dimensionParts = array_filter([$length, $width, $height], function ($val) {
200 - return $val !== '' && $val !== null;
201 - });
202 - if ($dimensionParts) {
203 - $formattedDimensions = implode(' × ', $dimensionParts) . ' ' . $dimensionUnit;
204 - echo sprintf(
205 - '<tr><th>%s</th><td>%s</td></tr>',
206 - esc_html__('Dimensions', 'fluent-cart'),
207 - esc_html($formattedDimensions)
208 - );
209 - }
199 + public function renderStarRatingBlock($wrapperAttributes = '', array $attributes = [], $block = null)
200 + {
201 + if (!ProductReviewService::ratingMeetsThresholds($this->product, $attributes, $block)) {
202 + return;
210 203 }
211 204
212 - if ($showProductWeight && $convertedProductWeight) {
213 - $formattedProductWeight = rtrim(rtrim(number_format($convertedProductWeight, 2), '0'), '.');
214 - echo sprintf(
215 - '<tr><th>%s</th><td>%s</td></tr>',
216 - esc_html__('Weight', 'fluent-cart'),
217 - esc_html($formattedProductWeight . ' ' . $storeWeightUnit)
218 - );
219 - }
205 + $otherInfo = $this->product->detail->other_info ?: [];
206 + $avgRating = Arr::get($otherInfo, 'average_rating', 0);
207 + $reviewCount = (int) Arr::get($otherInfo, 'review_count', 0);
220 208
221 - if ($showTotalWeight && $totalWeight && $convertedPackageWeight) {
222 - $formattedTotalWeight = rtrim(rtrim(number_format($totalWeight, 2), '0'), '.');
223 - echo sprintf(
224 - '<tr><th>%s</th><td>%s</td></tr>',
225 - esc_html__('Shipping Weight', 'fluent-cart'),
226 - esc_html($formattedTotalWeight . ' ' . $storeWeightUnit)
227 - );
228 - }
209 + $fullStars = (int) floor($avgRating);
210 + $halfStar = ($avgRating - $fullStars) >= 0.5;
211 + $emptyStars = 5 - $fullStars - ($halfStar ? 1 : 0);
229 212
230 - echo '</tbody></table>';
231 - 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
232 233 }
233 234
234 235 public function renderExcerpt($atts = '')
235 236 {
@@ -236,8 +237,16 @@
236 237 if (empty($this->product->post_excerpt)) {
237 238 return;
238 239 }
239 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 +
240 249 echo sprintf(
241 250 '<p %1$s class="fct-product-card-excerpt">
242 251 %2$s
243 252 </p>',
@@ -243,12 +252,22 @@
243 252 </p>',
244 253 $atts, // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
245 254 wp_kses_post($this->product->post_excerpt),
246 255 );
256 +
257 + do_action('fluent_cart/product/group/after_excerpt_block', $gateContext);
247 258 }
248 259
249 260 public function renderTitle($atts = '', $config = [])
250 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 +
251 270 $link = Arr::get($config, 'isLink', true);
252 271 $target = Arr::get($config, 'target', '_self');
253 272
254 273 $titleText = esc_html($this->product->post_title);
@@ -278,17 +297,26 @@
278 297 $atts, // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
279 298 esc_html($titleText)
280 299 );
281 300 }
301 +
302 + do_action('fluent_cart/product/group/after_title_block', $gateContext);
282 303 }
283 304
284 305 public function renderProductImage()
285 306 {
286 - $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') : '';
287 315 $isPlaceholder = false;
288 316
289 317 if (!$image) {
290 - $image = Vite::getAssetUrl('images/placeholder.svg');
318 + $image = Helper::getProductPlaceholderUrl();
291 319 $isPlaceholder = true;
292 320 }
293 321
294 322 $altText = $isPlaceholder
@@ -296,14 +324,14 @@
296 324 /* translators: %s: product title */
297 325 __('Placeholder image for %s', 'fluent-cart'), $this->product->post_title)
298 326 : $this->product->post_title;
299 327
300 - do_action('fluent_cart/product/group/before_image_block', [
328 + do_action('fluent_cart/product/group/before_image_block', RenderContext::decorate([
301 329 'product' => $this->product,
302 330 'scope' => 'product_card'
303 - ]);
331 + ]));
304 332 ?>
305 - <a class="fct-product-card-image-wrap"
333 + <a class="fct-product-card-image-wrap<?php echo $isPlaceholder ? ' is-placeholder' : ''; ?>"
306 334 href="<?php echo esc_url($this->viewUrl); ?>"
307 335 style="display: block;"
308 336 aria-label="<?php echo esc_attr(sprintf(
309 337 /* translators: %s: product title */
@@ -317,21 +345,26 @@
317 345 height="300"/>
318 346 </a>
319 347 <?php
320 348
321 - do_action('fluent_cart/product/group/after_image_block', [
349 + do_action('fluent_cart/product/group/after_image_block', RenderContext::decorate([
322 350 'product' => $this->product,
323 351 'scope' => 'product_card'
324 - ]);
352 + ]));
325 353 }
326 354
327 355 public function renderPrices($wrapper_attributes = '')
328 356 {
357 + if (!RenderGate::shouldRender('price', RenderGate::context($this->product, RenderGate::SCOPE_CARD))) {
358 + return;
359 + }
360 +
329 361 $priceFormat = Arr::get($this->config, 'price_format', 'starts_from');
330 362 $isSimple = $this->product->detail->variation_type === 'simple';
331 363 $minPrice = $this->product->detail->min_price;
332 364 $maxPrice = $this->product->detail->max_price;
333 365 $comparePrice = 0;
366 + $firstVariant = null;
334 367
335 368 if ($isSimple) {
336 369 $firstVariant = $this->product->variants->first();
337 370 if ($firstVariant) {
@@ -350,13 +383,13 @@
350 383 $formattedMinPrice = Helper::toDecimal($minPrice);
351 384 $formattedMaxPrice = Helper::toDecimal($maxPrice);
352 385 $formattedComparePrice = Helper::toDecimal($comparePrice);
353 386
354 - do_action('fluent_cart/product/group/before_price_block', [
387 + do_action('fluent_cart/product/group/before_price_block', RenderContext::decorate([
355 388 'product' => $this->product,
356 389 'current_price' => $minPrice,
357 390 'scope' => 'product_card'
358 - ]);
391 + ]));
359 392 ?>
360 393 <div <?php echo $wrapper_attributes; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
361 394 class="fct-product-card-prices"
362 395 role="region"
@@ -361,12 +394,11 @@
361 394 class="fct-product-card-prices"
362 395 role="region"
363 396 aria-label="<?php echo esc_attr__('Product pricing', 'fluent-cart'); ?>">
364 397 <?php if ($comparePrice): ?>
365 - <span class="fct-compare-price" aria-label="<?php echo esc_attr(sprintf(
366 - /* translators: %s: product price */
367 - __('Original price: %s', 'fluent-cart'), $formattedComparePrice)); ?>">
368 - <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>
369 401 </span>
370 402 <?php endif; ?>
371 403
372 404 <?php if (!$comparePrice && $maxPrice && $maxPrice > $minPrice): ?>
@@ -371,49 +403,68 @@
371 403
372 404 <?php if (!$comparePrice && $maxPrice && $maxPrice > $minPrice): ?>
373 405 <!-- Case 2: price range -->
374 406 <?php if ($priceFormat === 'range'): ?>
375 - <span class="fct-item-price" aria-label="<?php echo esc_attr(sprintf(
376 - /* translators: %1$s: min price, %2$s: max price */
377 - __('Price range from %1$s to %2$s', 'fluent-cart'), $formattedMinPrice, $formattedMaxPrice)); ?>">
378 - <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); ?>
379 413 </span>
380 414 <?php else: ?>
381 - <span class="fct-item-price" aria-label="<?php echo esc_attr(sprintf(
382 - /* translators: %s: min price */
383 - __('Starting from %s', 'fluent-cart'), $formattedMinPrice)); ?>">
384 - <span aria-hidden="true"><?php
415 + <span class="fct-item-price"><?php
385 416 /* translators: %s is the minimum price */
386 417 printf(esc_html__('From %s', 'fluent-cart'), esc_html($formattedMinPrice));
387 418 ?></span>
388 - </span>
389 419 <?php endif; ?>
390 420
391 421 <?php else: ?>
392 422 <!-- Case 3: Simple or single price -->
393 - <span class="fct-item-price" aria-label="<?php echo esc_attr(sprintf(
394 - /* translators: %s: product price */
395 - __('Price: %s', 'fluent-cart'), $formattedMinPrice)); ?>">
396 - <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); ?>
397 426 </span>
398 427 <?php endif; ?>
399 428
400 - <?php do_action('fluent_cart/product/after_price', [
429 + <?php
430 + do_action('fluent_cart/product/after_price', RenderContext::decorate([
401 431 'product' => $this->product,
432 + 'variant' => $firstVariant,
402 433 'current_price' => $minPrice,
403 434 'scope' => 'product_card'
404 - ]); ?>
435 + ]));
436 + RenderHelper::renderPriceSuffix($this->product, $firstVariant, 'product_card');
437 + ?>
405 438 </div>
406 439 <?php
407 - do_action('fluent_cart/product/group/after_price_block', [
440 + do_action('fluent_cart/product/group/after_price_block', RenderContext::decorate([
408 441 'product' => $this->product,
409 442 'current_price' => $minPrice,
410 443 'scope' => 'product_card'
411 - ]);
444 + ]));
412 445 }
413 446
414 447 public function showBuyButton($atts = '')
415 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 + {
416 467 $isOutOfStock = ModuleSettings::isActive('stock_management') && !$this->product->isStock();
417 468
418 469 if ($isOutOfStock) {
419 470 $soldOutText = __('Not Available', 'fluent-cart');
@@ -459,8 +510,24 @@
459 510 $buttonText = __('Add To Cart', 'fluent-cart');
460 511 $ariaLabel = sprintf(
461 512 /* translators: %s: product title */
462 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;
463 530 }
464 531 }
465 532
466 533 $buttonAttributes = [