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 +201 -165 1.5.5 → 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 {
@@ -63,11 +61,24 @@
63 61 $cardWidth = 'style="width: ' . esc_attr($widthValue) . ';"';
64 62 }
65 63
66 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 + ]));
67 78 ?>
68 79 <article data-fluent-cart-shop-app-single-product data-fct-product-card=""
69 - class="fct-product-card"
80 + class="<?php echo esc_attr($cardClasses); ?>"
70 81 <?php echo $cursor; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Already escaped ?>
71 82 <?php echo $cardWidth; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Already escaped ?>
72 83 aria-label="<?php echo esc_attr(sprintf(
73 84 /* translators: %s: product title */
@@ -74,17 +85,23 @@
74 85 __('%s product card', 'fluent-cart'), $this->product->post_title));
75 86 ?>">
76 87 <?php $this->renderProductImage(); ?>
77 88 <?php $this->renderTitle(); ?>
78 - <?php $this->renderExcerpt(); ?>
89 + <?php $this->renderStarRating(); ?>
90 + <?php if (!Arr::get($this->config, 'hide_excerpt', false)) { $this->renderExcerpt(); } ?>
79 91 <?php $this->renderPrices(); ?>
80 92 <?php $this->showBuyButton(); ?>
81 93 </article>
82 94 <?php
95 + do_action('fluent_cart/product/group/after_card', RenderContext::decorate([
96 + 'product' => $this->product,
97 + 'scope' => RenderGate::SCOPE_CARD,
98 + ]));
83 99 }
84 100
85 101 /**
86 - * 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).
87 104 *
88 105 * @param array $otherInfo Order item's other_info array
89 106 * @return string e.g. "Gift (Box) · 30 × 20 × 15 cm · Wt: 2 kg · Shipping: 2.5 kg"
90 107 */
@@ -89,53 +106,16 @@
89 106 * @return string e.g. "Gift (Box) · 30 × 20 × 15 cm · Wt: 2 kg · Shipping: 2.5 kg"
90 107 */
91 108 public static function buildPackageInfoFromOtherInfo($otherInfo)
92 109 {
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 : '';
110 + return PackageDescriptionRenderer::buildPackageInfoFromOtherInfo($otherInfo);
136 111 }
137 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 + */
138 118 public function renderPackageDescription(
139 119 $wrapper_attributes = '',
140 120 $showName = true,
141 121 $showDimensions = true,
@@ -140,113 +120,108 @@
140 120 $showName = true,
141 121 $showDimensions = true,
142 122 $showProductWeight = true,
143 123 $showTotalWeight = true,
144 - $variant = null
124 + $variant = null,
125 + $defaultVariant = null
145 126 ) {
146 - $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 + }
147 137
148 - if (!$variant) {
138 + public function renderStarRating()
139 + {
140 + if (!ModuleSettings::isActive('reviews')) {
149 141 return;
150 142 }
151 143
152 - if (!$wrapper_attributes) {
153 - $wrapper_attributes = 'class="fct-package-description" data-fluent-cart-package-description';
154 - }
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');
155 151
156 - $this->renderPackageDescriptionForVariant($variant, $wrapper_attributes, $showName, $showDimensions, $showProductWeight, $showTotalWeight);
157 - }
152 + $ratingVisibilitySettingKey = $ratingContext === 'relevant'
153 + ? 'show_rating_in_relevant'
154 + : 'show_rating_in_shop';
158 155
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') {
156 + if ((new \FluentCart\Api\StoreSettings())->get($ratingVisibilitySettingKey, 'yes') !== 'yes') {
168 157 return;
169 158 }
170 159
171 - $packageSlug = Arr::get($variant->other_info, 'package_slug', '');
172 - $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);
173 163
174 - if (!$package) {
164 + if ($reviewCount < 1) {
175 165 return;
176 166 }
177 167
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');
168 + $fullStars = (int) floor($avgRating);
169 + $halfStar = ($avgRating - $fullStars) >= 0.5;
170 + $emptyStars = 5 - $fullStars - ($halfStar ? 1 : 0);
185 171
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);
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 + }
190 193
191 - $convertedProductWeight = Helper::convertWeight($productWeight, $productWeightUnit, $storeWeightUnit);
192 - $convertedPackageWeight = Helper::convertWeight($packageWeight, $packageWeightUnit, $storeWeightUnit);
193 - $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);
194 199
195 - $hasVisibleContent = ($showName && $name)
196 - || ($showDimensions && ($length || $width || $height))
197 - || ($showProductWeight && $convertedProductWeight)
198 - || ($showTotalWeight && $totalWeight);
200 + $fullStars = (int) floor($avgRating);
201 + $halfStar = ($avgRating - $fullStars) >= 0.5;
202 + $emptyStars = 5 - $fullStars - ($halfStar ? 1 : 0);
199 203
200 - if (!$hasVisibleContent) {
201 - return;
202 - }
203 -
204 - echo sprintf('<div %s>', $wrapper_attributes); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
205 - echo '<table class="fct-package-description__table" role="presentation"><tbody>';
206 -
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 - }
214 -
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 - }
227 - }
228 -
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 - }
237 -
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 - }
246 -
247 - echo '</tbody></table>';
248 - 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
249 224 }
250 225
251 226 public function renderExcerpt($atts = '')
252 227 {
@@ -253,8 +228,16 @@
253 228 if (empty($this->product->post_excerpt)) {
254 229 return;
255 230 }
256 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 +
257 240 echo sprintf(
258 241 '<p %1$s class="fct-product-card-excerpt">
259 242 %2$s
260 243 </p>',
@@ -260,12 +243,22 @@
260 243 </p>',
261 244 $atts, // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
262 245 wp_kses_post($this->product->post_excerpt),
263 246 );
247 +
248 + do_action('fluent_cart/product/group/after_excerpt_block', $gateContext);
264 249 }
265 250
266 251 public function renderTitle($atts = '', $config = [])
267 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 +
268 261 $link = Arr::get($config, 'isLink', true);
269 262 $target = Arr::get($config, 'target', '_self');
270 263
271 264 $titleText = esc_html($this->product->post_title);
@@ -295,17 +288,26 @@
295 288 $atts, // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
296 289 esc_html($titleText)
297 290 );
298 291 }
292 +
293 + do_action('fluent_cart/product/group/after_title_block', $gateContext);
299 294 }
300 295
301 296 public function renderProductImage()
302 297 {
303 - $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') : '';
304 306 $isPlaceholder = false;
305 307
306 308 if (!$image) {
307 - $image = Vite::getAssetUrl('images/placeholder.svg');
309 + $image = Helper::getProductPlaceholderUrl();
308 310 $isPlaceholder = true;
309 311 }
310 312
311 313 $altText = $isPlaceholder
@@ -313,14 +315,14 @@
313 315 /* translators: %s: product title */
314 316 __('Placeholder image for %s', 'fluent-cart'), $this->product->post_title)
315 317 : $this->product->post_title;
316 318
317 - do_action('fluent_cart/product/group/before_image_block', [
319 + do_action('fluent_cart/product/group/before_image_block', RenderContext::decorate([
318 320 'product' => $this->product,
319 321 'scope' => 'product_card'
320 - ]);
322 + ]));
321 323 ?>
322 - <a class="fct-product-card-image-wrap"
324 + <a class="fct-product-card-image-wrap<?php echo $isPlaceholder ? ' is-placeholder' : ''; ?>"
323 325 href="<?php echo esc_url($this->viewUrl); ?>"
324 326 style="display: block;"
325 327 aria-label="<?php echo esc_attr(sprintf(
326 328 /* translators: %s: product title */
@@ -334,16 +336,20 @@
334 336 height="300"/>
335 337 </a>
336 338 <?php
337 339
338 - do_action('fluent_cart/product/group/after_image_block', [
340 + do_action('fluent_cart/product/group/after_image_block', RenderContext::decorate([
339 341 'product' => $this->product,
340 342 'scope' => 'product_card'
341 - ]);
343 + ]));
342 344 }
343 345
344 346 public function renderPrices($wrapper_attributes = '')
345 347 {
348 + if (!RenderGate::shouldRender('price', RenderGate::context($this->product, RenderGate::SCOPE_CARD))) {
349 + return;
350 + }
351 +
346 352 $priceFormat = Arr::get($this->config, 'price_format', 'starts_from');
347 353 $isSimple = $this->product->detail->variation_type === 'simple';
348 354 $minPrice = $this->product->detail->min_price;
349 355 $maxPrice = $this->product->detail->max_price;
@@ -368,13 +374,13 @@
368 374 $formattedMinPrice = Helper::toDecimal($minPrice);
369 375 $formattedMaxPrice = Helper::toDecimal($maxPrice);
370 376 $formattedComparePrice = Helper::toDecimal($comparePrice);
371 377
372 - do_action('fluent_cart/product/group/before_price_block', [
378 + do_action('fluent_cart/product/group/before_price_block', RenderContext::decorate([
373 379 'product' => $this->product,
374 380 'current_price' => $minPrice,
375 381 'scope' => 'product_card'
376 - ]);
382 + ]));
377 383 ?>
378 384 <div <?php echo $wrapper_attributes; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
379 385 class="fct-product-card-prices"
380 386 role="region"
@@ -379,12 +385,11 @@
379 385 class="fct-product-card-prices"
380 386 role="region"
381 387 aria-label="<?php echo esc_attr__('Product pricing', 'fluent-cart'); ?>">
382 388 <?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>
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>
387 392 </span>
388 393 <?php endif; ?>
389 394
390 395 <?php if (!$comparePrice && $maxPrice && $maxPrice > $minPrice): ?>
@@ -389,53 +394,68 @@
389 394
390 395 <?php if (!$comparePrice && $maxPrice && $maxPrice > $minPrice): ?>
391 396 <!-- Case 2: price range -->
392 397 <?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>
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); ?>
397 404 </span>
398 405 <?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
406 + <span class="fct-item-price"><?php
403 407 /* translators: %s is the minimum price */
404 408 printf(esc_html__('From %s', 'fluent-cart'), esc_html($formattedMinPrice));
405 409 ?></span>
406 - </span>
407 410 <?php endif; ?>
408 411
409 412 <?php else: ?>
410 413 <!-- 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>
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); ?>
415 417 </span>
416 418 <?php endif; ?>
417 419
418 420 <?php
419 - do_action('fluent_cart/product/after_price', [
421 + do_action('fluent_cart/product/after_price', RenderContext::decorate([
420 422 'product' => $this->product,
421 423 'variant' => $firstVariant,
422 424 'current_price' => $minPrice,
423 425 'scope' => 'product_card'
424 - ]);
426 + ]));
425 427 RenderHelper::renderPriceSuffix($this->product, $firstVariant, 'product_card');
426 428 ?>
427 429 </div>
428 430 <?php
429 - do_action('fluent_cart/product/group/after_price_block', [
431 + do_action('fluent_cart/product/group/after_price_block', RenderContext::decorate([
430 432 'product' => $this->product,
431 433 'current_price' => $minPrice,
432 434 'scope' => 'product_card'
433 - ]);
435 + ]));
434 436 }
435 437
436 438 public function showBuyButton($atts = '')
437 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 + {
438 458 $isOutOfStock = ModuleSettings::isActive('stock_management') && !$this->product->isStock();
439 459
440 460 if ($isOutOfStock) {
441 461 $soldOutText = __('Not Available', 'fluent-cart');
@@ -481,8 +501,24 @@
481 501 $buttonText = __('Add To Cart', 'fluent-cart');
482 502 $ariaLabel = sprintf(
483 503 /* translators: %s: product title */
484 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;
485 521 }
486 522 }
487 523
488 524 $buttonAttributes = [