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 +208 -160 1.5.3 → 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 {
@@ -63,11 +62,24 @@
63 62 $cardWidth = 'style="width: ' . esc_attr($widthValue) . ';"';
64 63 }
65 64
66 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 + ]));
67 79 ?>
68 80 <article data-fluent-cart-shop-app-single-product data-fct-product-card=""
69 - class="fct-product-card"
81 + class="<?php echo esc_attr($cardClasses); ?>"
70 82 <?php echo $cursor; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Already escaped ?>
71 83 <?php echo $cardWidth; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Already escaped ?>
72 84 aria-label="<?php echo esc_attr(sprintf(
73 85 /* translators: %s: product title */
@@ -74,17 +86,23 @@
74 86 __('%s product card', 'fluent-cart'), $this->product->post_title));
75 87 ?>">
76 88 <?php $this->renderProductImage(); ?>
77 89 <?php $this->renderTitle(); ?>
78 - <?php $this->renderExcerpt(); ?>
90 + <?php $this->renderStarRating(); ?>
91 + <?php if (!Arr::get($this->config, 'hide_excerpt', false)) { $this->renderExcerpt(); } ?>
79 92 <?php $this->renderPrices(); ?>
80 93 <?php $this->showBuyButton(); ?>
81 94 </article>
82 95 <?php
96 + do_action('fluent_cart/product/group/after_card', RenderContext::decorate([
97 + 'product' => $this->product,
98 + 'scope' => RenderGate::SCOPE_CARD,
99 + ]));
83 100 }
84 101
85 102 /**
86 - * 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).
87 105 *
88 106 * @param array $otherInfo Order item's other_info array
89 107 * @return string e.g. "Gift (Box) · 30 × 20 × 15 cm · Wt: 2 kg · Shipping: 2.5 kg"
90 108 */
@@ -89,53 +107,16 @@
89 107 * @return string e.g. "Gift (Box) · 30 × 20 × 15 cm · Wt: 2 kg · Shipping: 2.5 kg"
90 108 */
91 109 public static function buildPackageInfoFromOtherInfo($otherInfo)
92 110 {
93 - $storeWeightUnit = Helper::shopConfig('weight_unit') ?: 'kg';
94 - $parts = [];
95 -
96 - // Package name
97 - $name = Arr::get($otherInfo, 'package_name', '');
98 - if ($name) {
99 - $parts[] = $name;
100 - }
101 -
102 - // Dimensions
103 - $length = Arr::get($otherInfo, 'package_length', '');
104 - $width = Arr::get($otherInfo, 'package_width', '');
105 - $height = Arr::get($otherInfo, 'package_height', '');
106 - $dimensionUnit = Arr::get($otherInfo, 'package_dimension_unit', 'cm');
107 - $dimParts = array_filter([$length, $width, $height], function ($val) {
108 - return $val !== '' && $val !== null && $val != 0;
109 - });
110 - if ($dimParts) {
111 - $parts[] = implode(' × ', $dimParts) . ' ' . $dimensionUnit;
112 - }
113 -
114 - // Product weight
115 - $productWeight = floatval(Arr::get($otherInfo, 'weight', 0));
116 - $productWeightUnit = Arr::get($otherInfo, 'weight_unit', $storeWeightUnit);
117 - $convertedProductWeight = Helper::convertWeight($productWeight, $productWeightUnit, $storeWeightUnit);
118 - if ($convertedProductWeight) {
119 - $formatted = rtrim(rtrim(number_format($convertedProductWeight, 2), '0'), '.');
120 - $parts[] = __('Wt:', 'fluent-cart') . ' ' . $formatted . ' ' . $storeWeightUnit;
121 - }
122 -
123 - // Shipping weight (product + package)
124 - $packageWeight = floatval(Arr::get($otherInfo, 'package_weight', 0));
125 - $packageWeightUnit = Arr::get($otherInfo, 'package_weight_unit', $storeWeightUnit);
126 - $convertedPackageWeight = Helper::convertWeight($packageWeight, $packageWeightUnit, $storeWeightUnit);
127 - $totalWeight = $convertedProductWeight + $convertedPackageWeight;
128 - if ($totalWeight && $convertedPackageWeight) {
129 - $formatted = rtrim(rtrim(number_format($totalWeight, 2), '0'), '.');
130 - $parts[] = __('Shipping wt:', 'fluent-cart') . ' ' . $formatted . ' ' . $storeWeightUnit;
131 - }
132 -
133 - $info = implode(' · ', $parts);
134 -
135 - return $info ? __('Package:', 'fluent-cart') . ' ' . $info : '';
111 + return PackageDescriptionRenderer::buildPackageInfoFromOtherInfo($otherInfo);
136 112 }
137 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 + */
138 119 public function renderPackageDescription(
139 120 $wrapper_attributes = '',
140 121 $showName = true,
141 122 $showDimensions = true,
@@ -140,113 +121,116 @@
140 121 $showName = true,
141 122 $showDimensions = true,
142 123 $showProductWeight = true,
143 124 $showTotalWeight = true,
144 - $variant = null
125 + $variant = null,
126 + $defaultVariant = null
145 127 ) {
146 - $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 + }
147 138
148 - if (!$variant) {
139 + public function renderStarRating()
140 + {
141 + if (!ModuleSettings::isActive('reviews')) {
149 142 return;
150 143 }
151 144
152 - if (!$wrapper_attributes) {
153 - $wrapper_attributes = 'class="fct-package-description" data-fluent-cart-package-description';
154 - }
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');
155 152
156 - $this->renderPackageDescriptionForVariant($variant, $wrapper_attributes, $showName, $showDimensions, $showProductWeight, $showTotalWeight);
157 - }
153 + $ratingVisibilitySettingKey = $ratingContext === 'relevant'
154 + ? 'show_rating_in_relevant'
155 + : 'show_rating_in_shop';
158 156
159 - private function renderPackageDescriptionForVariant(
160 - ProductVariation $variant,
161 - $wrapper_attributes,
162 - $showName,
163 - $showDimensions,
164 - $showProductWeight,
165 - $showTotalWeight
166 - ) {
167 - if ($variant->fulfillment_type !== 'physical') {
157 + if ((new \FluentCart\Api\StoreSettings())->get($ratingVisibilitySettingKey, 'yes') !== 'yes') {
168 158 return;
169 159 }
170 160
171 - $packageSlug = Arr::get($variant->other_info, 'package_slug', '');
172 - $package = Helper::getPackageBySlug($packageSlug);
173 -
174 - if (!$package) {
161 + if (!ProductReviewService::ratingMeetsThresholds($this->product, $this->config)) {
175 162 return;
176 163 }
177 164
178 - $name = Arr::get($package, 'name', '');
179 - $length = Arr::get($package, 'length', '');
180 - $width = Arr::get($package, 'width', '');
181 - $height = Arr::get($package, 'height', '');
182 - $dimensionUnit = Arr::get($package, 'dimension_unit', 'cm');
183 - $packageWeight = floatval(Arr::get($package, 'weight', 0));
184 - $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);
185 168
186 - $storeWeightUnit = Helper::shopConfig('weight_unit') ?: 'kg';
187 - $otherInfo = $variant->other_info ?: [];
188 - $productWeight = floatval(Arr::get($otherInfo, 'weight', 0));
189 - $productWeightUnit = Arr::get($otherInfo, 'weight_unit', $storeWeightUnit);
190 -
191 - $convertedProductWeight = Helper::convertWeight($productWeight, $productWeightUnit, $storeWeightUnit);
192 - $convertedPackageWeight = Helper::convertWeight($packageWeight, $packageWeightUnit, $storeWeightUnit);
193 - $totalWeight = $convertedProductWeight + $convertedPackageWeight;
194 -
195 - $hasVisibleContent = ($showName && $name)
196 - || ($showDimensions && ($length || $width || $height))
197 - || ($showProductWeight && $convertedProductWeight)
198 - || ($showTotalWeight && $totalWeight);
199 -
200 - if (!$hasVisibleContent) {
169 + if ($reviewCount < 1) {
201 170 return;
202 171 }
203 172
204 - echo sprintf('<div %s>', $wrapper_attributes); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
205 - 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);
206 176
207 - if ($showName && $name) {
208 - echo sprintf(
209 - '<tr><th>%s</th><td>%s</td></tr>',
210 - esc_html__('Package', 'fluent-cart'),
211 - esc_html($name)
212 - );
213 - }
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 + }
214 198
215 - if ($showDimensions && ($length || $width || $height)) {
216 - $dimensionParts = array_filter([$length, $width, $height], function ($val) {
217 - return $val !== '' && $val !== null;
218 - });
219 - if ($dimensionParts) {
220 - $formattedDimensions = implode(' × ', $dimensionParts) . ' ' . $dimensionUnit;
221 - echo sprintf(
222 - '<tr><th>%s</th><td>%s</td></tr>',
223 - esc_html__('Dimensions', 'fluent-cart'),
224 - esc_html($formattedDimensions)
225 - );
226 - }
199 + public function renderStarRatingBlock($wrapperAttributes = '', array $attributes = [], $block = null)
200 + {
201 + if (!ProductReviewService::ratingMeetsThresholds($this->product, $attributes, $block)) {
202 + return;
227 203 }
228 204
229 - if ($showProductWeight && $convertedProductWeight) {
230 - $formattedProductWeight = rtrim(rtrim(number_format($convertedProductWeight, 2), '0'), '.');
231 - echo sprintf(
232 - '<tr><th>%s</th><td>%s</td></tr>',
233 - esc_html__('Weight', 'fluent-cart'),
234 - esc_html($formattedProductWeight . ' ' . $storeWeightUnit)
235 - );
236 - }
205 + $otherInfo = $this->product->detail->other_info ?: [];
206 + $avgRating = Arr::get($otherInfo, 'average_rating', 0);
207 + $reviewCount = (int) Arr::get($otherInfo, 'review_count', 0);
237 208
238 - if ($showTotalWeight && $totalWeight && $convertedPackageWeight) {
239 - $formattedTotalWeight = rtrim(rtrim(number_format($totalWeight, 2), '0'), '.');
240 - echo sprintf(
241 - '<tr><th>%s</th><td>%s</td></tr>',
242 - esc_html__('Shipping Weight', 'fluent-cart'),
243 - esc_html($formattedTotalWeight . ' ' . $storeWeightUnit)
244 - );
245 - }
209 + $fullStars = (int) floor($avgRating);
210 + $halfStar = ($avgRating - $fullStars) >= 0.5;
211 + $emptyStars = 5 - $fullStars - ($halfStar ? 1 : 0);
246 212
247 - echo '</tbody></table>';
248 - 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
249 233 }
250 234
251 235 public function renderExcerpt($atts = '')
252 236 {
@@ -253,8 +237,16 @@
253 237 if (empty($this->product->post_excerpt)) {
254 238 return;
255 239 }
256 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 +
257 249 echo sprintf(
258 250 '<p %1$s class="fct-product-card-excerpt">
259 251 %2$s
260 252 </p>',
@@ -260,12 +252,22 @@
260 252 </p>',
261 253 $atts, // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
262 254 wp_kses_post($this->product->post_excerpt),
263 255 );
256 +
257 + do_action('fluent_cart/product/group/after_excerpt_block', $gateContext);
264 258 }
265 259
266 260 public function renderTitle($atts = '', $config = [])
267 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 +
268 270 $link = Arr::get($config, 'isLink', true);
269 271 $target = Arr::get($config, 'target', '_self');
270 272
271 273 $titleText = esc_html($this->product->post_title);
@@ -295,17 +297,26 @@
295 297 $atts, // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
296 298 esc_html($titleText)
297 299 );
298 300 }
301 +
302 + do_action('fluent_cart/product/group/after_title_block', $gateContext);
299 303 }
300 304
301 305 public function renderProductImage()
302 306 {
303 - $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') : '';
304 315 $isPlaceholder = false;
305 316
306 317 if (!$image) {
307 - $image = Vite::getAssetUrl('images/placeholder.svg');
318 + $image = Helper::getProductPlaceholderUrl();
308 319 $isPlaceholder = true;
309 320 }
310 321
311 322 $altText = $isPlaceholder
@@ -313,14 +324,14 @@
313 324 /* translators: %s: product title */
314 325 __('Placeholder image for %s', 'fluent-cart'), $this->product->post_title)
315 326 : $this->product->post_title;
316 327
317 - do_action('fluent_cart/product/group/before_image_block', [
328 + do_action('fluent_cart/product/group/before_image_block', RenderContext::decorate([
318 329 'product' => $this->product,
319 330 'scope' => 'product_card'
320 - ]);
331 + ]));
321 332 ?>
322 - <a class="fct-product-card-image-wrap"
333 + <a class="fct-product-card-image-wrap<?php echo $isPlaceholder ? ' is-placeholder' : ''; ?>"
323 334 href="<?php echo esc_url($this->viewUrl); ?>"
324 335 style="display: block;"
325 336 aria-label="<?php echo esc_attr(sprintf(
326 337 /* translators: %s: product title */
@@ -334,16 +345,20 @@
334 345 height="300"/>
335 346 </a>
336 347 <?php
337 348
338 - do_action('fluent_cart/product/group/after_image_block', [
349 + do_action('fluent_cart/product/group/after_image_block', RenderContext::decorate([
339 350 'product' => $this->product,
340 351 'scope' => 'product_card'
341 - ]);
352 + ]));
342 353 }
343 354
344 355 public function renderPrices($wrapper_attributes = '')
345 356 {
357 + if (!RenderGate::shouldRender('price', RenderGate::context($this->product, RenderGate::SCOPE_CARD))) {
358 + return;
359 + }
360 +
346 361 $priceFormat = Arr::get($this->config, 'price_format', 'starts_from');
347 362 $isSimple = $this->product->detail->variation_type === 'simple';
348 363 $minPrice = $this->product->detail->min_price;
349 364 $maxPrice = $this->product->detail->max_price;
@@ -368,13 +383,13 @@
368 383 $formattedMinPrice = Helper::toDecimal($minPrice);
369 384 $formattedMaxPrice = Helper::toDecimal($maxPrice);
370 385 $formattedComparePrice = Helper::toDecimal($comparePrice);
371 386
372 - do_action('fluent_cart/product/group/before_price_block', [
387 + do_action('fluent_cart/product/group/before_price_block', RenderContext::decorate([
373 388 'product' => $this->product,
374 389 'current_price' => $minPrice,
375 390 'scope' => 'product_card'
376 - ]);
391 + ]));
377 392 ?>
378 393 <div <?php echo $wrapper_attributes; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
379 394 class="fct-product-card-prices"
380 395 role="region"
@@ -379,12 +394,11 @@
379 394 class="fct-product-card-prices"
380 395 role="region"
381 396 aria-label="<?php echo esc_attr__('Product pricing', 'fluent-cart'); ?>">
382 397 <?php if ($comparePrice): ?>
383 - <span class="fct-compare-price" aria-label="<?php echo esc_attr(sprintf(
384 - /* translators: %s: product price */
385 - __('Original price: %s', 'fluent-cart'), $formattedComparePrice)); ?>">
386 - <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>
387 401 </span>
388 402 <?php endif; ?>
389 403
390 404 <?php if (!$comparePrice && $maxPrice && $maxPrice > $minPrice): ?>
@@ -389,50 +403,68 @@
389 403
390 404 <?php if (!$comparePrice && $maxPrice && $maxPrice > $minPrice): ?>
391 405 <!-- Case 2: price range -->
392 406 <?php if ($priceFormat === 'range'): ?>
393 - <span class="fct-item-price" aria-label="<?php echo esc_attr(sprintf(
394 - /* translators: %1$s: min price, %2$s: max price */
395 - __('Price range from %1$s to %2$s', 'fluent-cart'), $formattedMinPrice, $formattedMaxPrice)); ?>">
396 - <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); ?>
397 413 </span>
398 414 <?php else: ?>
399 - <span class="fct-item-price" aria-label="<?php echo esc_attr(sprintf(
400 - /* translators: %s: min price */
401 - __('Starting from %s', 'fluent-cart'), $formattedMinPrice)); ?>">
402 - <span aria-hidden="true"><?php
415 + <span class="fct-item-price"><?php
403 416 /* translators: %s is the minimum price */
404 417 printf(esc_html__('From %s', 'fluent-cart'), esc_html($formattedMinPrice));
405 418 ?></span>
406 - </span>
407 419 <?php endif; ?>
408 420
409 421 <?php else: ?>
410 422 <!-- Case 3: Simple or single price -->
411 - <span class="fct-item-price" aria-label="<?php echo esc_attr(sprintf(
412 - /* translators: %s: product price */
413 - __('Price: %s', 'fluent-cart'), $formattedMinPrice)); ?>">
414 - <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); ?>
415 426 </span>
416 427 <?php endif; ?>
417 428
418 - <?php do_action('fluent_cart/product/after_price', [
429 + <?php
430 + do_action('fluent_cart/product/after_price', RenderContext::decorate([
419 431 'product' => $this->product,
420 432 'variant' => $firstVariant,
421 433 'current_price' => $minPrice,
422 434 'scope' => 'product_card'
423 - ]); ?>
435 + ]));
436 + RenderHelper::renderPriceSuffix($this->product, $firstVariant, 'product_card');
437 + ?>
424 438 </div>
425 439 <?php
426 - do_action('fluent_cart/product/group/after_price_block', [
440 + do_action('fluent_cart/product/group/after_price_block', RenderContext::decorate([
427 441 'product' => $this->product,
428 442 'current_price' => $minPrice,
429 443 'scope' => 'product_card'
430 - ]);
444 + ]));
431 445 }
432 446
433 447 public function showBuyButton($atts = '')
434 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 + {
435 467 $isOutOfStock = ModuleSettings::isActive('stock_management') && !$this->product->isStock();
436 468
437 469 if ($isOutOfStock) {
438 470 $soldOutText = __('Not Available', 'fluent-cart');
@@ -478,8 +510,24 @@
478 510 $buttonText = __('Add To Cart', 'fluent-cart');
479 511 $ariaLabel = sprintf(
480 512 /* translators: %s: product title */
481 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;
482 530 }
483 531 }
484 532
485 533 $buttonAttributes = [