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 +198 -32 1.6.0 → 1.7.0 View file →
@@ -5,9 +5,8 @@
5 5 use FluentCart\Api\ModuleSettings;
6 6 use FluentCart\App\Helpers\Helper;
7 7 use FluentCart\App\Models\Product;
8 8 use FluentCart\App\Modules\Templating\AssetLoader;
9 -use FluentCart\App\Vite;
10 9 use FluentCart\Framework\Support\Arr;
11 10
12 11 class ProductCardRender
13 12 {
@@ -62,11 +61,24 @@
62 61 $cardWidth = 'style="width: ' . esc_attr($widthValue) . ';"';
63 62 }
64 63
65 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 + ]));
66 78 ?>
67 79 <article data-fluent-cart-shop-app-single-product data-fct-product-card=""
68 - class="fct-product-card"
80 + class="<?php echo esc_attr($cardClasses); ?>"
69 81 <?php echo $cursor; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Already escaped ?>
70 82 <?php echo $cardWidth; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Already escaped ?>
71 83 aria-label="<?php echo esc_attr(sprintf(
72 84 /* translators: %s: product title */
@@ -73,13 +85,18 @@
73 85 __('%s product card', 'fluent-cart'), $this->product->post_title));
74 86 ?>">
75 87 <?php $this->renderProductImage(); ?>
76 88 <?php $this->renderTitle(); ?>
89 + <?php $this->renderStarRating(); ?>
77 90 <?php if (!Arr::get($this->config, 'hide_excerpt', false)) { $this->renderExcerpt(); } ?>
78 91 <?php $this->renderPrices(); ?>
79 92 <?php $this->showBuyButton(); ?>
80 93 </article>
81 94 <?php
95 + do_action('fluent_cart/product/group/after_card', RenderContext::decorate([
96 + 'product' => $this->product,
97 + 'scope' => RenderGate::SCOPE_CARD,
98 + ]));
82 99 }
83 100
84 101 /**
85 102 * @deprecated Use PackageDescriptionRenderer::buildPackageInfoFromOtherInfo() directly.
@@ -117,8 +134,96 @@
117 134 $defaultVariant
118 135 );
119 136 }
120 137
138 + public function renderStarRating()
139 + {
140 + if (!ModuleSettings::isActive('reviews')) {
141 + return;
142 + }
143 +
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');
151 +
152 + $ratingVisibilitySettingKey = $ratingContext === 'relevant'
153 + ? 'show_rating_in_relevant'
154 + : 'show_rating_in_shop';
155 +
156 + if ((new \FluentCart\Api\StoreSettings())->get($ratingVisibilitySettingKey, 'yes') !== 'yes') {
157 + return;
158 + }
159 +
160 + $otherInfo = $this->product->detail->other_info ?: [];
161 + $avgRating = Arr::get($otherInfo, 'average_rating', 0);
162 + $reviewCount = (int) Arr::get($otherInfo, 'review_count', 0);
163 +
164 + if ($reviewCount < 1) {
165 + return;
166 + }
167 +
168 + $fullStars = (int) floor($avgRating);
169 + $halfStar = ($avgRating - $fullStars) >= 0.5;
170 + $emptyStars = 5 - $fullStars - ($halfStar ? 1 : 0);
171 +
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 + }
193 +
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);
199 +
200 + $fullStars = (int) floor($avgRating);
201 + $halfStar = ($avgRating - $fullStars) >= 0.5;
202 + $emptyStars = 5 - $fullStars - ($halfStar ? 1 : 0);
203 +
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
224 + }
225 +
121 226 public function renderExcerpt($atts = '')
122 227 {
123 228 if (empty($this->product->post_excerpt)) {
124 229 return;
@@ -123,8 +228,16 @@
123 228 if (empty($this->product->post_excerpt)) {
124 229 return;
125 230 }
126 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 +
127 240 echo sprintf(
128 241 '<p %1$s class="fct-product-card-excerpt">
129 242 %2$s
130 243 </p>',
@@ -130,12 +243,22 @@
130 243 </p>',
131 244 $atts, // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
132 245 wp_kses_post($this->product->post_excerpt),
133 246 );
247 +
248 + do_action('fluent_cart/product/group/after_excerpt_block', $gateContext);
134 249 }
135 250
136 251 public function renderTitle($atts = '', $config = [])
137 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 +
138 261 $link = Arr::get($config, 'isLink', true);
139 262 $target = Arr::get($config, 'target', '_self');
140 263
141 264 $titleText = esc_html($this->product->post_title);
@@ -165,17 +288,26 @@
165 288 $atts, // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
166 289 esc_html($titleText)
167 290 );
168 291 }
292 +
293 + do_action('fluent_cart/product/group/after_title_block', $gateContext);
169 294 }
170 295
171 296 public function renderProductImage()
172 297 {
173 - $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') : '';
174 306 $isPlaceholder = false;
175 307
176 308 if (!$image) {
177 - $image = Vite::getAssetUrl('images/placeholder.svg');
309 + $image = Helper::getProductPlaceholderUrl();
178 310 $isPlaceholder = true;
179 311 }
180 312
181 313 $altText = $isPlaceholder
@@ -183,14 +315,14 @@
183 315 /* translators: %s: product title */
184 316 __('Placeholder image for %s', 'fluent-cart'), $this->product->post_title)
185 317 : $this->product->post_title;
186 318
187 - do_action('fluent_cart/product/group/before_image_block', [
319 + do_action('fluent_cart/product/group/before_image_block', RenderContext::decorate([
188 320 'product' => $this->product,
189 321 'scope' => 'product_card'
190 - ]);
322 + ]));
191 323 ?>
192 - <a class="fct-product-card-image-wrap"
324 + <a class="fct-product-card-image-wrap<?php echo $isPlaceholder ? ' is-placeholder' : ''; ?>"
193 325 href="<?php echo esc_url($this->viewUrl); ?>"
194 326 style="display: block;"
195 327 aria-label="<?php echo esc_attr(sprintf(
196 328 /* translators: %s: product title */
@@ -204,16 +336,20 @@
204 336 height="300"/>
205 337 </a>
206 338 <?php
207 339
208 - do_action('fluent_cart/product/group/after_image_block', [
340 + do_action('fluent_cart/product/group/after_image_block', RenderContext::decorate([
209 341 'product' => $this->product,
210 342 'scope' => 'product_card'
211 - ]);
343 + ]));
212 344 }
213 345
214 346 public function renderPrices($wrapper_attributes = '')
215 347 {
348 + if (!RenderGate::shouldRender('price', RenderGate::context($this->product, RenderGate::SCOPE_CARD))) {
349 + return;
350 + }
351 +
216 352 $priceFormat = Arr::get($this->config, 'price_format', 'starts_from');
217 353 $isSimple = $this->product->detail->variation_type === 'simple';
218 354 $minPrice = $this->product->detail->min_price;
219 355 $maxPrice = $this->product->detail->max_price;
@@ -238,13 +374,13 @@
238 374 $formattedMinPrice = Helper::toDecimal($minPrice);
239 375 $formattedMaxPrice = Helper::toDecimal($maxPrice);
240 376 $formattedComparePrice = Helper::toDecimal($comparePrice);
241 377
242 - do_action('fluent_cart/product/group/before_price_block', [
378 + do_action('fluent_cart/product/group/before_price_block', RenderContext::decorate([
243 379 'product' => $this->product,
244 380 'current_price' => $minPrice,
245 381 'scope' => 'product_card'
246 - ]);
382 + ]));
247 383 ?>
248 384 <div <?php echo $wrapper_attributes; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
249 385 class="fct-product-card-prices"
250 386 role="region"
@@ -249,12 +385,11 @@
249 385 class="fct-product-card-prices"
250 386 role="region"
251 387 aria-label="<?php echo esc_attr__('Product pricing', 'fluent-cart'); ?>">
252 388 <?php if ($comparePrice): ?>
253 - <span class="fct-compare-price" aria-label="<?php echo esc_attr(sprintf(
254 - /* translators: %s: product price */
255 - __('Original price: %s', 'fluent-cart'), $formattedComparePrice)); ?>">
256 - <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>
257 392 </span>
258 393 <?php endif; ?>
259 394
260 395 <?php if (!$comparePrice && $maxPrice && $maxPrice > $minPrice): ?>
@@ -259,53 +394,68 @@
259 394
260 395 <?php if (!$comparePrice && $maxPrice && $maxPrice > $minPrice): ?>
261 396 <!-- Case 2: price range -->
262 397 <?php if ($priceFormat === 'range'): ?>
263 - <span class="fct-item-price" aria-label="<?php echo esc_attr(sprintf(
264 - /* translators: %1$s: min price, %2$s: max price */
265 - __('Price range from %1$s to %2$s', 'fluent-cart'), $formattedMinPrice, $formattedMaxPrice)); ?>">
266 - <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); ?>
267 404 </span>
268 405 <?php else: ?>
269 - <span class="fct-item-price" aria-label="<?php echo esc_attr(sprintf(
270 - /* translators: %s: min price */
271 - __('Starting from %s', 'fluent-cart'), $formattedMinPrice)); ?>">
272 - <span aria-hidden="true"><?php
406 + <span class="fct-item-price"><?php
273 407 /* translators: %s is the minimum price */
274 408 printf(esc_html__('From %s', 'fluent-cart'), esc_html($formattedMinPrice));
275 409 ?></span>
276 - </span>
277 410 <?php endif; ?>
278 411
279 412 <?php else: ?>
280 413 <!-- Case 3: Simple or single price -->
281 - <span class="fct-item-price" aria-label="<?php echo esc_attr(sprintf(
282 - /* translators: %s: product price */
283 - __('Price: %s', 'fluent-cart'), $formattedMinPrice)); ?>">
284 - <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); ?>
285 417 </span>
286 418 <?php endif; ?>
287 419
288 420 <?php
289 - do_action('fluent_cart/product/after_price', [
421 + do_action('fluent_cart/product/after_price', RenderContext::decorate([
290 422 'product' => $this->product,
291 423 'variant' => $firstVariant,
292 424 'current_price' => $minPrice,
293 425 'scope' => 'product_card'
294 - ]);
426 + ]));
295 427 RenderHelper::renderPriceSuffix($this->product, $firstVariant, 'product_card');
296 428 ?>
297 429 </div>
298 430 <?php
299 - do_action('fluent_cart/product/group/after_price_block', [
431 + do_action('fluent_cart/product/group/after_price_block', RenderContext::decorate([
300 432 'product' => $this->product,
301 433 'current_price' => $minPrice,
302 434 'scope' => 'product_card'
303 - ]);
435 + ]));
304 436 }
305 437
306 438 public function showBuyButton($atts = '')
307 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 + {
308 458 $isOutOfStock = ModuleSettings::isActive('stock_management') && !$this->product->isStock();
309 459
310 460 if ($isOutOfStock) {
311 461 $soldOutText = __('Not Available', 'fluent-cart');
@@ -351,8 +501,24 @@
351 501 $buttonText = __('Add To Cart', 'fluent-cart');
352 502 $ariaLabel = sprintf(
353 503 /* translators: %s: product title */
354 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;
355 521 }
356 522 }
357 523
358 524 $buttonAttributes = [