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 +254 -33 1.3.21 → 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 {
@@ -36,8 +35,21 @@
36 35
37 36 public function render()
38 37 {
39 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 + }
40 52 $cursor = '';
41 53 if (!empty($this->config['cursor'])) {
42 54 $cursor = 'data-fluent-cart-cursor="' . esc_attr($this->config['cursor']) . '"';
43 55 }
@@ -49,11 +61,24 @@
49 61 $cardWidth = 'style="width: ' . esc_attr($widthValue) . ';"';
50 62 }
51 63
52 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 + ]));
53 78 ?>
54 79 <article data-fluent-cart-shop-app-single-product data-fct-product-card=""
55 - class="fct-product-card"
80 + class="<?php echo esc_attr($cardClasses); ?>"
56 81 <?php echo $cursor; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Already escaped ?>
57 82 <?php echo $cardWidth; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Already escaped ?>
58 83 aria-label="<?php echo esc_attr(sprintf(
59 84 /* translators: %s: product title */
@@ -60,15 +85,145 @@
60 85 __('%s product card', 'fluent-cart'), $this->product->post_title));
61 86 ?>">
62 87 <?php $this->renderProductImage(); ?>
63 88 <?php $this->renderTitle(); ?>
64 - <?php $this->renderExcerpt(); ?>
89 + <?php $this->renderStarRating(); ?>
90 + <?php if (!Arr::get($this->config, 'hide_excerpt', false)) { $this->renderExcerpt(); } ?>
65 91 <?php $this->renderPrices(); ?>
66 92 <?php $this->showBuyButton(); ?>
67 93 </article>
68 94 <?php
95 + do_action('fluent_cart/product/group/after_card', RenderContext::decorate([
96 + 'product' => $this->product,
97 + 'scope' => RenderGate::SCOPE_CARD,
98 + ]));
69 99 }
70 100
101 + /**
102 + * @deprecated Use PackageDescriptionRenderer::buildPackageInfoFromOtherInfo() directly.
103 + * Kept as a compatibility shim for external callers (themes, extensions).
104 + *
105 + * @param array $otherInfo Order item's other_info array
106 + * @return string e.g. "Gift (Box) · 30 × 20 × 15 cm · Wt: 2 kg · Shipping: 2.5 kg"
107 + */
108 + public static function buildPackageInfoFromOtherInfo($otherInfo)
109 + {
110 + return PackageDescriptionRenderer::buildPackageInfoFromOtherInfo($otherInfo);
111 + }
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 + */
118 + public function renderPackageDescription(
119 + $wrapper_attributes = '',
120 + $showName = true,
121 + $showDimensions = true,
122 + $showProductWeight = true,
123 + $showTotalWeight = true,
124 + $variant = null,
125 + $defaultVariant = null
126 + ) {
127 + (new PackageDescriptionRenderer($this->product))->renderPackageDescription(
128 + $wrapper_attributes,
129 + $showName,
130 + $showDimensions,
131 + $showProductWeight,
132 + $showTotalWeight,
133 + $variant,
134 + $defaultVariant
135 + );
136 + }
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 +
71 226 public function renderExcerpt($atts = '')
72 227 {
73 228 if (empty($this->product->post_excerpt)) {
74 229 return;
@@ -73,8 +228,16 @@
73 228 if (empty($this->product->post_excerpt)) {
74 229 return;
75 230 }
76 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 +
77 240 echo sprintf(
78 241 '<p %1$s class="fct-product-card-excerpt">
79 242 %2$s
80 243 </p>',
@@ -80,12 +243,22 @@
80 243 </p>',
81 244 $atts, // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
82 245 wp_kses_post($this->product->post_excerpt),
83 246 );
247 +
248 + do_action('fluent_cart/product/group/after_excerpt_block', $gateContext);
84 249 }
85 250
86 251 public function renderTitle($atts = '', $config = [])
87 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 +
88 261 $link = Arr::get($config, 'isLink', true);
89 262 $target = Arr::get($config, 'target', '_self');
90 263
91 264 $titleText = esc_html($this->product->post_title);
@@ -115,17 +288,26 @@
115 288 $atts, // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
116 289 esc_html($titleText)
117 290 );
118 291 }
292 +
293 + do_action('fluent_cart/product/group/after_title_block', $gateContext);
119 294 }
120 295
121 296 public function renderProductImage()
122 297 {
123 - $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') : '';
124 306 $isPlaceholder = false;
125 307
126 308 if (!$image) {
127 - $image = Vite::getAssetUrl('images/placeholder.svg');
309 + $image = Helper::getProductPlaceholderUrl();
128 310 $isPlaceholder = true;
129 311 }
130 312
131 313 $altText = $isPlaceholder
@@ -133,14 +315,14 @@
133 315 /* translators: %s: product title */
134 316 __('Placeholder image for %s', 'fluent-cart'), $this->product->post_title)
135 317 : $this->product->post_title;
136 318
137 - do_action('fluent_cart/product/group/before_image_block', [
319 + do_action('fluent_cart/product/group/before_image_block', RenderContext::decorate([
138 320 'product' => $this->product,
139 321 'scope' => 'product_card'
140 - ]);
322 + ]));
141 323 ?>
142 - <a class="fct-product-card-image-wrap"
324 + <a class="fct-product-card-image-wrap<?php echo $isPlaceholder ? ' is-placeholder' : ''; ?>"
143 325 href="<?php echo esc_url($this->viewUrl); ?>"
144 326 style="display: block;"
145 327 aria-label="<?php echo esc_attr(sprintf(
146 328 /* translators: %s: product title */
@@ -154,21 +336,26 @@
154 336 height="300"/>
155 337 </a>
156 338 <?php
157 339
158 - do_action('fluent_cart/product/group/after_image_block', [
340 + do_action('fluent_cart/product/group/after_image_block', RenderContext::decorate([
159 341 'product' => $this->product,
160 342 'scope' => 'product_card'
161 - ]);
343 + ]));
162 344 }
163 345
164 346 public function renderPrices($wrapper_attributes = '')
165 347 {
348 + if (!RenderGate::shouldRender('price', RenderGate::context($this->product, RenderGate::SCOPE_CARD))) {
349 + return;
350 + }
351 +
166 352 $priceFormat = Arr::get($this->config, 'price_format', 'starts_from');
167 353 $isSimple = $this->product->detail->variation_type === 'simple';
168 354 $minPrice = $this->product->detail->min_price;
169 355 $maxPrice = $this->product->detail->max_price;
170 356 $comparePrice = 0;
357 + $firstVariant = null;
171 358
172 359 if ($isSimple) {
173 360 $firstVariant = $this->product->variants->first();
174 361 if ($firstVariant) {
@@ -187,13 +374,13 @@
187 374 $formattedMinPrice = Helper::toDecimal($minPrice);
188 375 $formattedMaxPrice = Helper::toDecimal($maxPrice);
189 376 $formattedComparePrice = Helper::toDecimal($comparePrice);
190 377
191 - do_action('fluent_cart/product/group/before_price_block', [
378 + do_action('fluent_cart/product/group/before_price_block', RenderContext::decorate([
192 379 'product' => $this->product,
193 380 'current_price' => $minPrice,
194 381 'scope' => 'product_card'
195 - ]);
382 + ]));
196 383 ?>
197 384 <div <?php echo $wrapper_attributes; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
198 385 class="fct-product-card-prices"
199 386 role="region"
@@ -198,12 +385,11 @@
198 385 class="fct-product-card-prices"
199 386 role="region"
200 387 aria-label="<?php echo esc_attr__('Product pricing', 'fluent-cart'); ?>">
201 388 <?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>
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>
206 392 </span>
207 393 <?php endif; ?>
208 394
209 395 <?php if (!$comparePrice && $maxPrice && $maxPrice > $minPrice): ?>
@@ -208,49 +394,68 @@
208 394
209 395 <?php if (!$comparePrice && $maxPrice && $maxPrice > $minPrice): ?>
210 396 <!-- Case 2: price range -->
211 397 <?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>
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); ?>
216 404 </span>
217 405 <?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
406 + <span class="fct-item-price"><?php
222 407 /* translators: %s is the minimum price */
223 408 printf(esc_html__('From %s', 'fluent-cart'), esc_html($formattedMinPrice));
224 409 ?></span>
225 - </span>
226 410 <?php endif; ?>
227 411
228 412 <?php else: ?>
229 413 <!-- 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>
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); ?>
234 417 </span>
235 418 <?php endif; ?>
236 419
237 - <?php do_action('fluent_cart/product/after_price', [
420 + <?php
421 + do_action('fluent_cart/product/after_price', RenderContext::decorate([
238 422 'product' => $this->product,
423 + 'variant' => $firstVariant,
239 424 'current_price' => $minPrice,
240 425 'scope' => 'product_card'
241 - ]); ?>
426 + ]));
427 + RenderHelper::renderPriceSuffix($this->product, $firstVariant, 'product_card');
428 + ?>
242 429 </div>
243 430 <?php
244 - do_action('fluent_cart/product/group/after_price_block', [
431 + do_action('fluent_cart/product/group/after_price_block', RenderContext::decorate([
245 432 'product' => $this->product,
246 433 'current_price' => $minPrice,
247 434 'scope' => 'product_card'
248 - ]);
435 + ]));
249 436 }
250 437
251 438 public function showBuyButton($atts = '')
252 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 + {
253 458 $isOutOfStock = ModuleSettings::isActive('stock_management') && !$this->product->isStock();
254 459
255 460 if ($isOutOfStock) {
256 461 $soldOutText = __('Not Available', 'fluent-cart');
@@ -296,8 +501,24 @@
296 501 $buttonText = __('Add To Cart', 'fluent-cart');
297 502 $ariaLabel = sprintf(
298 503 /* translators: %s: product title */
299 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;
300 521 }
301 522 }
302 523
303 524 $buttonAttributes = [