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 +263 -33 1.3.21 → 1.7.1 View file →
@@ -4,10 +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\Services\ProductReviewService;
8 9 use FluentCart\App\Modules\Templating\AssetLoader;
9 -use FluentCart\App\Vite;
10 10 use FluentCart\Framework\Support\Arr;
11 11
12 12 class ProductCardRender
13 13 {
@@ -36,8 +36,21 @@
36 36
37 37 public function render()
38 38 {
39 39 AssetLoader::loadSingleProductAssets();
40 + // Lets extensions (e.g. Pro's advanced-variation selector) enqueue the
41 + // CSS/JS a product purchase UI needs. Backs the card surfaces that go
42 + // through this renderer's render() — Shop, Product List, the product
43 + // shortcode, and the quick-view modal. Surfaces that render card parts
44 + // via the individual render* methods instead of render() (Product
45 + // Carousel, Related Products) fire this same hook from their own block
46 + // render. Guarded with a static flag so a grid of N cards fires it once
47 + // per request, not once per card. No payload — assets are uniform.
48 + static $assetsHookFired = false;
49 + if (!$assetsHookFired) {
50 + $assetsHookFired = true;
51 + do_action('fluent_cart/advanced_variation/enqueue_assets');
52 + }
40 53 $cursor = '';
41 54 if (!empty($this->config['cursor'])) {
42 55 $cursor = 'data-fluent-cart-cursor="' . esc_attr($this->config['cursor']) . '"';
43 56 }
@@ -49,11 +62,24 @@
49 62 $cardWidth = 'style="width: ' . esc_attr($widthValue) . ';"';
50 63 }
51 64
52 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 + ]));
53 79 ?>
54 80 <article data-fluent-cart-shop-app-single-product data-fct-product-card=""
55 - class="fct-product-card"
81 + class="<?php echo esc_attr($cardClasses); ?>"
56 82 <?php echo $cursor; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Already escaped ?>
57 83 <?php echo $cardWidth; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Already escaped ?>
58 84 aria-label="<?php echo esc_attr(sprintf(
59 85 /* translators: %s: product title */
@@ -60,15 +86,153 @@
60 86 __('%s product card', 'fluent-cart'), $this->product->post_title));
61 87 ?>">
62 88 <?php $this->renderProductImage(); ?>
63 89 <?php $this->renderTitle(); ?>
64 - <?php $this->renderExcerpt(); ?>
90 + <?php $this->renderStarRating(); ?>
91 + <?php if (!Arr::get($this->config, 'hide_excerpt', false)) { $this->renderExcerpt(); } ?>
65 92 <?php $this->renderPrices(); ?>
66 93 <?php $this->showBuyButton(); ?>
67 94 </article>
68 95 <?php
96 + do_action('fluent_cart/product/group/after_card', RenderContext::decorate([
97 + 'product' => $this->product,
98 + 'scope' => RenderGate::SCOPE_CARD,
99 + ]));
69 100 }
70 101
102 + /**
103 + * @deprecated Use PackageDescriptionRenderer::buildPackageInfoFromOtherInfo() directly.
104 + * Kept as a compatibility shim for external callers (themes, extensions).
105 + *
106 + * @param array $otherInfo Order item's other_info array
107 + * @return string e.g. "Gift (Box) · 30 × 20 × 15 cm · Wt: 2 kg · Shipping: 2.5 kg"
108 + */
109 + public static function buildPackageInfoFromOtherInfo($otherInfo)
110 + {
111 + return PackageDescriptionRenderer::buildPackageInfoFromOtherInfo($otherInfo);
112 + }
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 + */
119 + public function renderPackageDescription(
120 + $wrapper_attributes = '',
121 + $showName = true,
122 + $showDimensions = true,
123 + $showProductWeight = true,
124 + $showTotalWeight = true,
125 + $variant = null,
126 + $defaultVariant = null
127 + ) {
128 + (new PackageDescriptionRenderer($this->product))->renderPackageDescription(
129 + $wrapper_attributes,
130 + $showName,
131 + $showDimensions,
132 + $showProductWeight,
133 + $showTotalWeight,
134 + $variant,
135 + $defaultVariant
136 + );
137 + }
138 +
139 + public function renderStarRating()
140 + {
141 + if (!ModuleSettings::isActive('reviews')) {
142 + return;
143 + }
144 +
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');
152 +
153 + $ratingVisibilitySettingKey = $ratingContext === 'relevant'
154 + ? 'show_rating_in_relevant'
155 + : 'show_rating_in_shop';
156 +
157 + if ((new \FluentCart\Api\StoreSettings())->get($ratingVisibilitySettingKey, 'yes') !== 'yes') {
158 + return;
159 + }
160 +
161 + if (!ProductReviewService::ratingMeetsThresholds($this->product, $this->config)) {
162 + return;
163 + }
164 +
165 + $otherInfo = $this->product->detail->other_info ?: [];
166 + $avgRating = Arr::get($otherInfo, 'average_rating', 0);
167 + $reviewCount = (int) Arr::get($otherInfo, 'review_count', 0);
168 +
169 + if ($reviewCount < 1) {
170 + return;
171 + }
172 +
173 + $fullStars = (int) floor($avgRating);
174 + $halfStar = ($avgRating - $fullStars) >= 0.5;
175 + $emptyStars = 5 - $fullStars - ($halfStar ? 1 : 0);
176 +
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 + }
198 +
199 + public function renderStarRatingBlock($wrapperAttributes = '', array $attributes = [], $block = null)
200 + {
201 + if (!ProductReviewService::ratingMeetsThresholds($this->product, $attributes, $block)) {
202 + return;
203 + }
204 +
205 + $otherInfo = $this->product->detail->other_info ?: [];
206 + $avgRating = Arr::get($otherInfo, 'average_rating', 0);
207 + $reviewCount = (int) Arr::get($otherInfo, 'review_count', 0);
208 +
209 + $fullStars = (int) floor($avgRating);
210 + $halfStar = ($avgRating - $fullStars) >= 0.5;
211 + $emptyStars = 5 - $fullStars - ($halfStar ? 1 : 0);
212 +
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
233 + }
234 +
71 235 public function renderExcerpt($atts = '')
72 236 {
73 237 if (empty($this->product->post_excerpt)) {
74 238 return;
@@ -73,8 +237,16 @@
73 237 if (empty($this->product->post_excerpt)) {
74 238 return;
75 239 }
76 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 +
77 249 echo sprintf(
78 250 '<p %1$s class="fct-product-card-excerpt">
79 251 %2$s
80 252 </p>',
@@ -80,12 +252,22 @@
80 252 </p>',
81 253 $atts, // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
82 254 wp_kses_post($this->product->post_excerpt),
83 255 );
256 +
257 + do_action('fluent_cart/product/group/after_excerpt_block', $gateContext);
84 258 }
85 259
86 260 public function renderTitle($atts = '', $config = [])
87 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 +
88 270 $link = Arr::get($config, 'isLink', true);
89 271 $target = Arr::get($config, 'target', '_self');
90 272
91 273 $titleText = esc_html($this->product->post_title);
@@ -115,17 +297,26 @@
115 297 $atts, // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
116 298 esc_html($titleText)
117 299 );
118 300 }
301 +
302 + do_action('fluent_cart/product/group/after_title_block', $gateContext);
119 303 }
120 304
121 305 public function renderProductImage()
122 306 {
123 - $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') : '';
124 315 $isPlaceholder = false;
125 316
126 317 if (!$image) {
127 - $image = Vite::getAssetUrl('images/placeholder.svg');
318 + $image = Helper::getProductPlaceholderUrl();
128 319 $isPlaceholder = true;
129 320 }
130 321
131 322 $altText = $isPlaceholder
@@ -133,14 +324,14 @@
133 324 /* translators: %s: product title */
134 325 __('Placeholder image for %s', 'fluent-cart'), $this->product->post_title)
135 326 : $this->product->post_title;
136 327
137 - do_action('fluent_cart/product/group/before_image_block', [
328 + do_action('fluent_cart/product/group/before_image_block', RenderContext::decorate([
138 329 'product' => $this->product,
139 330 'scope' => 'product_card'
140 - ]);
331 + ]));
141 332 ?>
142 - <a class="fct-product-card-image-wrap"
333 + <a class="fct-product-card-image-wrap<?php echo $isPlaceholder ? ' is-placeholder' : ''; ?>"
143 334 href="<?php echo esc_url($this->viewUrl); ?>"
144 335 style="display: block;"
145 336 aria-label="<?php echo esc_attr(sprintf(
146 337 /* translators: %s: product title */
@@ -154,21 +345,26 @@
154 345 height="300"/>
155 346 </a>
156 347 <?php
157 348
158 - do_action('fluent_cart/product/group/after_image_block', [
349 + do_action('fluent_cart/product/group/after_image_block', RenderContext::decorate([
159 350 'product' => $this->product,
160 351 'scope' => 'product_card'
161 - ]);
352 + ]));
162 353 }
163 354
164 355 public function renderPrices($wrapper_attributes = '')
165 356 {
357 + if (!RenderGate::shouldRender('price', RenderGate::context($this->product, RenderGate::SCOPE_CARD))) {
358 + return;
359 + }
360 +
166 361 $priceFormat = Arr::get($this->config, 'price_format', 'starts_from');
167 362 $isSimple = $this->product->detail->variation_type === 'simple';
168 363 $minPrice = $this->product->detail->min_price;
169 364 $maxPrice = $this->product->detail->max_price;
170 365 $comparePrice = 0;
366 + $firstVariant = null;
171 367
172 368 if ($isSimple) {
173 369 $firstVariant = $this->product->variants->first();
174 370 if ($firstVariant) {
@@ -187,13 +383,13 @@
187 383 $formattedMinPrice = Helper::toDecimal($minPrice);
188 384 $formattedMaxPrice = Helper::toDecimal($maxPrice);
189 385 $formattedComparePrice = Helper::toDecimal($comparePrice);
190 386
191 - do_action('fluent_cart/product/group/before_price_block', [
387 + do_action('fluent_cart/product/group/before_price_block', RenderContext::decorate([
192 388 'product' => $this->product,
193 389 'current_price' => $minPrice,
194 390 'scope' => 'product_card'
195 - ]);
391 + ]));
196 392 ?>
197 393 <div <?php echo $wrapper_attributes; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
198 394 class="fct-product-card-prices"
199 395 role="region"
@@ -198,12 +394,11 @@
198 394 class="fct-product-card-prices"
199 395 role="region"
200 396 aria-label="<?php echo esc_attr__('Product pricing', 'fluent-cart'); ?>">
201 397 <?php if ($comparePrice): ?>
202 - <span class="fct-compare-price" aria-label="<?php echo esc_attr(sprintf(
203 - /* translators: %s: product price */
204 - __('Original price: %s', 'fluent-cart'), $formattedComparePrice)); ?>">
205 - <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>
206 401 </span>
207 402 <?php endif; ?>
208 403
209 404 <?php if (!$comparePrice && $maxPrice && $maxPrice > $minPrice): ?>
@@ -208,49 +403,68 @@
208 403
209 404 <?php if (!$comparePrice && $maxPrice && $maxPrice > $minPrice): ?>
210 405 <!-- Case 2: price range -->
211 406 <?php if ($priceFormat === 'range'): ?>
212 - <span class="fct-item-price" aria-label="<?php echo esc_attr(sprintf(
213 - /* translators: %1$s: min price, %2$s: max price */
214 - __('Price range from %1$s to %2$s', 'fluent-cart'), $formattedMinPrice, $formattedMaxPrice)); ?>">
215 - <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); ?>
216 413 </span>
217 414 <?php else: ?>
218 - <span class="fct-item-price" aria-label="<?php echo esc_attr(sprintf(
219 - /* translators: %s: min price */
220 - __('Starting from %s', 'fluent-cart'), $formattedMinPrice)); ?>">
221 - <span aria-hidden="true"><?php
415 + <span class="fct-item-price"><?php
222 416 /* translators: %s is the minimum price */
223 417 printf(esc_html__('From %s', 'fluent-cart'), esc_html($formattedMinPrice));
224 418 ?></span>
225 - </span>
226 419 <?php endif; ?>
227 420
228 421 <?php else: ?>
229 422 <!-- Case 3: Simple or single price -->
230 - <span class="fct-item-price" aria-label="<?php echo esc_attr(sprintf(
231 - /* translators: %s: product price */
232 - __('Price: %s', 'fluent-cart'), $formattedMinPrice)); ?>">
233 - <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); ?>
234 426 </span>
235 427 <?php endif; ?>
236 428
237 - <?php do_action('fluent_cart/product/after_price', [
429 + <?php
430 + do_action('fluent_cart/product/after_price', RenderContext::decorate([
238 431 'product' => $this->product,
432 + 'variant' => $firstVariant,
239 433 'current_price' => $minPrice,
240 434 'scope' => 'product_card'
241 - ]); ?>
435 + ]));
436 + RenderHelper::renderPriceSuffix($this->product, $firstVariant, 'product_card');
437 + ?>
242 438 </div>
243 439 <?php
244 - do_action('fluent_cart/product/group/after_price_block', [
440 + do_action('fluent_cart/product/group/after_price_block', RenderContext::decorate([
245 441 'product' => $this->product,
246 442 'current_price' => $minPrice,
247 443 'scope' => 'product_card'
248 - ]);
444 + ]));
249 445 }
250 446
251 447 public function showBuyButton($atts = '')
252 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 + {
253 467 $isOutOfStock = ModuleSettings::isActive('stock_management') && !$this->product->isStock();
254 468
255 469 if ($isOutOfStock) {
256 470 $soldOutText = __('Not Available', 'fluent-cart');
@@ -296,8 +510,24 @@
296 510 $buttonText = __('Add To Cart', 'fluent-cart');
297 511 $ariaLabel = sprintf(
298 512 /* translators: %s: product title */
299 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;
300 530 }
301 531 }
302 532
303 533 $buttonAttributes = [