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