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 -165 1.3.28 → 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,113 +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 - if ($variant->fulfillment_type !== 'physical') {
156 + if ((new \FluentCart\Api\StoreSettings())->get($ratingVisibilitySettingKey, 'yes') !== 'yes') {
155 157 return;
156 158 }
157 159
158 - $packageSlug = Arr::get($variant->other_info, 'package_slug', '');
159 - $package = Helper::getPackageBySlug($packageSlug);
160 + $otherInfo = $this->product->detail->other_info ?: [];
161 + $avgRating = Arr::get($otherInfo, 'average_rating', 0);
162 + $reviewCount = (int) Arr::get($otherInfo, 'review_count', 0);
160 163
161 - if (!$package) {
164 + if ($reviewCount < 1) {
162 165 return;
163 166 }
164 167
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');
168 + $fullStars = (int) floor($avgRating);
169 + $halfStar = ($avgRating - $fullStars) >= 0.5;
170 + $emptyStars = 5 - $fullStars - ($halfStar ? 1 : 0);
172 171
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);
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 + }
177 193
178 - $convertedProductWeight = Helper::convertWeight($productWeight, $productWeightUnit, $storeWeightUnit);
179 - $convertedPackageWeight = Helper::convertWeight($packageWeight, $packageWeightUnit, $storeWeightUnit);
180 - $totalWeight = $convertedProductWeight + $convertedPackageWeight;
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);
181 199
182 - $hasVisibleContent = ($showName && $name)
183 - || ($showDimensions && ($length || $width || $height))
184 - || ($showProductWeight && $convertedProductWeight)
185 - || ($showTotalWeight && $totalWeight);
200 + $fullStars = (int) floor($avgRating);
201 + $halfStar = ($avgRating - $fullStars) >= 0.5;
202 + $emptyStars = 5 - $fullStars - ($halfStar ? 1 : 0);
186 203
187 - if (!$hasVisibleContent) {
188 - return;
189 - }
190 -
191 - echo sprintf('<div %s>', $wrapper_attributes); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
192 - echo '<table class="fct-package-description__table" role="presentation"><tbody>';
193 -
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 - }
201 -
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 - }
214 - }
215 -
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 - }
224 -
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 - }
233 -
234 - echo '</tbody></table>';
235 - 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
236 224 }
237 225
238 226 public function renderExcerpt($atts = '')
239 227 {
@@ -240,8 +228,16 @@
240 228 if (empty($this->product->post_excerpt)) {
241 229 return;
242 230 }
243 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 +
244 240 echo sprintf(
245 241 '<p %1$s class="fct-product-card-excerpt">
246 242 %2$s
247 243 </p>',
@@ -247,12 +243,22 @@
247 243 </p>',
248 244 $atts, // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
249 245 wp_kses_post($this->product->post_excerpt),
250 246 );
247 +
248 + do_action('fluent_cart/product/group/after_excerpt_block', $gateContext);
251 249 }
252 250
253 251 public function renderTitle($atts = '', $config = [])
254 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 +
255 261 $link = Arr::get($config, 'isLink', true);
256 262 $target = Arr::get($config, 'target', '_self');
257 263
258 264 $titleText = esc_html($this->product->post_title);
@@ -282,17 +288,26 @@
282 288 $atts, // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
283 289 esc_html($titleText)
284 290 );
285 291 }
292 +
293 + do_action('fluent_cart/product/group/after_title_block', $gateContext);
286 294 }
287 295
288 296 public function renderProductImage()
289 297 {
290 - $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') : '';
291 306 $isPlaceholder = false;
292 307
293 308 if (!$image) {
294 - $image = Vite::getAssetUrl('images/placeholder.svg');
309 + $image = Helper::getProductPlaceholderUrl();
295 310 $isPlaceholder = true;
296 311 }
297 312
298 313 $altText = $isPlaceholder
@@ -300,14 +315,14 @@
300 315 /* translators: %s: product title */
301 316 __('Placeholder image for %s', 'fluent-cart'), $this->product->post_title)
302 317 : $this->product->post_title;
303 318
304 - do_action('fluent_cart/product/group/before_image_block', [
319 + do_action('fluent_cart/product/group/before_image_block', RenderContext::decorate([
305 320 'product' => $this->product,
306 321 'scope' => 'product_card'
307 - ]);
322 + ]));
308 323 ?>
309 - <a class="fct-product-card-image-wrap"
324 + <a class="fct-product-card-image-wrap<?php echo $isPlaceholder ? ' is-placeholder' : ''; ?>"
310 325 href="<?php echo esc_url($this->viewUrl); ?>"
311 326 style="display: block;"
312 327 aria-label="<?php echo esc_attr(sprintf(
313 328 /* translators: %s: product title */
@@ -321,21 +336,26 @@
321 336 height="300"/>
322 337 </a>
323 338 <?php
324 339
325 - do_action('fluent_cart/product/group/after_image_block', [
340 + do_action('fluent_cart/product/group/after_image_block', RenderContext::decorate([
326 341 'product' => $this->product,
327 342 'scope' => 'product_card'
328 - ]);
343 + ]));
329 344 }
330 345
331 346 public function renderPrices($wrapper_attributes = '')
332 347 {
348 + if (!RenderGate::shouldRender('price', RenderGate::context($this->product, RenderGate::SCOPE_CARD))) {
349 + return;
350 + }
351 +
333 352 $priceFormat = Arr::get($this->config, 'price_format', 'starts_from');
334 353 $isSimple = $this->product->detail->variation_type === 'simple';
335 354 $minPrice = $this->product->detail->min_price;
336 355 $maxPrice = $this->product->detail->max_price;
337 356 $comparePrice = 0;
357 + $firstVariant = null;
338 358
339 359 if ($isSimple) {
340 360 $firstVariant = $this->product->variants->first();
341 361 if ($firstVariant) {
@@ -354,13 +374,13 @@
354 374 $formattedMinPrice = Helper::toDecimal($minPrice);
355 375 $formattedMaxPrice = Helper::toDecimal($maxPrice);
356 376 $formattedComparePrice = Helper::toDecimal($comparePrice);
357 377
358 - do_action('fluent_cart/product/group/before_price_block', [
378 + do_action('fluent_cart/product/group/before_price_block', RenderContext::decorate([
359 379 'product' => $this->product,
360 380 'current_price' => $minPrice,
361 381 'scope' => 'product_card'
362 - ]);
382 + ]));
363 383 ?>
364 384 <div <?php echo $wrapper_attributes; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
365 385 class="fct-product-card-prices"
366 386 role="region"
@@ -365,12 +385,11 @@
365 385 class="fct-product-card-prices"
366 386 role="region"
367 387 aria-label="<?php echo esc_attr__('Product pricing', 'fluent-cart'); ?>">
368 388 <?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>
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>
373 392 </span>
374 393 <?php endif; ?>
375 394
376 395 <?php if (!$comparePrice && $maxPrice && $maxPrice > $minPrice): ?>
@@ -375,49 +394,68 @@
375 394
376 395 <?php if (!$comparePrice && $maxPrice && $maxPrice > $minPrice): ?>
377 396 <!-- Case 2: price range -->
378 397 <?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>
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); ?>
383 404 </span>
384 405 <?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
406 + <span class="fct-item-price"><?php
389 407 /* translators: %s is the minimum price */
390 408 printf(esc_html__('From %s', 'fluent-cart'), esc_html($formattedMinPrice));
391 409 ?></span>
392 - </span>
393 410 <?php endif; ?>
394 411
395 412 <?php else: ?>
396 413 <!-- 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>
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); ?>
401 417 </span>
402 418 <?php endif; ?>
403 419
404 - <?php do_action('fluent_cart/product/after_price', [
420 + <?php
421 + do_action('fluent_cart/product/after_price', RenderContext::decorate([
405 422 'product' => $this->product,
423 + 'variant' => $firstVariant,
406 424 'current_price' => $minPrice,
407 425 'scope' => 'product_card'
408 - ]); ?>
426 + ]));
427 + RenderHelper::renderPriceSuffix($this->product, $firstVariant, 'product_card');
428 + ?>
409 429 </div>
410 430 <?php
411 - do_action('fluent_cart/product/group/after_price_block', [
431 + do_action('fluent_cart/product/group/after_price_block', RenderContext::decorate([
412 432 'product' => $this->product,
413 433 'current_price' => $minPrice,
414 434 'scope' => 'product_card'
415 - ]);
435 + ]));
416 436 }
417 437
418 438 public function showBuyButton($atts = '')
419 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 + {
420 458 $isOutOfStock = ModuleSettings::isActive('stock_management') && !$this->product->isStock();
421 459
422 460 if ($isOutOfStock) {
423 461 $soldOutText = __('Not Available', 'fluent-cart');
@@ -463,8 +501,24 @@
463 501 $buttonText = __('Add To Cart', 'fluent-cart');
464 502 $ariaLabel = sprintf(
465 503 /* translators: %s: product title */
466 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;
467 521 }
468 522 }
469 523
470 524 $buttonAttributes = [