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 +207 -32 1.6.1 → 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 {
@@ -62,11 +62,24 @@
62 62 $cardWidth = 'style="width: ' . esc_attr($widthValue) . ';"';
63 63 }
64 64
65 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 + ]));
66 79 ?>
67 80 <article data-fluent-cart-shop-app-single-product data-fct-product-card=""
68 - class="fct-product-card"
81 + class="<?php echo esc_attr($cardClasses); ?>"
69 82 <?php echo $cursor; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Already escaped ?>
70 83 <?php echo $cardWidth; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Already escaped ?>
71 84 aria-label="<?php echo esc_attr(sprintf(
72 85 /* translators: %s: product title */
@@ -73,13 +86,18 @@
73 86 __('%s product card', 'fluent-cart'), $this->product->post_title));
74 87 ?>">
75 88 <?php $this->renderProductImage(); ?>
76 89 <?php $this->renderTitle(); ?>
90 + <?php $this->renderStarRating(); ?>
77 91 <?php if (!Arr::get($this->config, 'hide_excerpt', false)) { $this->renderExcerpt(); } ?>
78 92 <?php $this->renderPrices(); ?>
79 93 <?php $this->showBuyButton(); ?>
80 94 </article>
81 95 <?php
96 + do_action('fluent_cart/product/group/after_card', RenderContext::decorate([
97 + 'product' => $this->product,
98 + 'scope' => RenderGate::SCOPE_CARD,
99 + ]));
82 100 }
83 101
84 102 /**
85 103 * @deprecated Use PackageDescriptionRenderer::buildPackageInfoFromOtherInfo() directly.
@@ -117,8 +135,104 @@
117 135 $defaultVariant
118 136 );
119 137 }
120 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 +
121 235 public function renderExcerpt($atts = '')
122 236 {
123 237 if (empty($this->product->post_excerpt)) {
124 238 return;
@@ -123,8 +237,16 @@
123 237 if (empty($this->product->post_excerpt)) {
124 238 return;
125 239 }
126 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 +
127 249 echo sprintf(
128 250 '<p %1$s class="fct-product-card-excerpt">
129 251 %2$s
130 252 </p>',
@@ -130,12 +252,22 @@
130 252 </p>',
131 253 $atts, // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
132 254 wp_kses_post($this->product->post_excerpt),
133 255 );
256 +
257 + do_action('fluent_cart/product/group/after_excerpt_block', $gateContext);
134 258 }
135 259
136 260 public function renderTitle($atts = '', $config = [])
137 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 +
138 270 $link = Arr::get($config, 'isLink', true);
139 271 $target = Arr::get($config, 'target', '_self');
140 272
141 273 $titleText = esc_html($this->product->post_title);
@@ -165,17 +297,26 @@
165 297 $atts, // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
166 298 esc_html($titleText)
167 299 );
168 300 }
301 +
302 + do_action('fluent_cart/product/group/after_title_block', $gateContext);
169 303 }
170 304
171 305 public function renderProductImage()
172 306 {
173 - $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') : '';
174 315 $isPlaceholder = false;
175 316
176 317 if (!$image) {
177 - $image = Vite::getAssetUrl('images/placeholder.svg');
318 + $image = Helper::getProductPlaceholderUrl();
178 319 $isPlaceholder = true;
179 320 }
180 321
181 322 $altText = $isPlaceholder
@@ -183,14 +324,14 @@
183 324 /* translators: %s: product title */
184 325 __('Placeholder image for %s', 'fluent-cart'), $this->product->post_title)
185 326 : $this->product->post_title;
186 327
187 - do_action('fluent_cart/product/group/before_image_block', [
328 + do_action('fluent_cart/product/group/before_image_block', RenderContext::decorate([
188 329 'product' => $this->product,
189 330 'scope' => 'product_card'
190 - ]);
331 + ]));
191 332 ?>
192 - <a class="fct-product-card-image-wrap"
333 + <a class="fct-product-card-image-wrap<?php echo $isPlaceholder ? ' is-placeholder' : ''; ?>"
193 334 href="<?php echo esc_url($this->viewUrl); ?>"
194 335 style="display: block;"
195 336 aria-label="<?php echo esc_attr(sprintf(
196 337 /* translators: %s: product title */
@@ -204,16 +345,20 @@
204 345 height="300"/>
205 346 </a>
206 347 <?php
207 348
208 - do_action('fluent_cart/product/group/after_image_block', [
349 + do_action('fluent_cart/product/group/after_image_block', RenderContext::decorate([
209 350 'product' => $this->product,
210 351 'scope' => 'product_card'
211 - ]);
352 + ]));
212 353 }
213 354
214 355 public function renderPrices($wrapper_attributes = '')
215 356 {
357 + if (!RenderGate::shouldRender('price', RenderGate::context($this->product, RenderGate::SCOPE_CARD))) {
358 + return;
359 + }
360 +
216 361 $priceFormat = Arr::get($this->config, 'price_format', 'starts_from');
217 362 $isSimple = $this->product->detail->variation_type === 'simple';
218 363 $minPrice = $this->product->detail->min_price;
219 364 $maxPrice = $this->product->detail->max_price;
@@ -238,13 +383,13 @@
238 383 $formattedMinPrice = Helper::toDecimal($minPrice);
239 384 $formattedMaxPrice = Helper::toDecimal($maxPrice);
240 385 $formattedComparePrice = Helper::toDecimal($comparePrice);
241 386
242 - do_action('fluent_cart/product/group/before_price_block', [
387 + do_action('fluent_cart/product/group/before_price_block', RenderContext::decorate([
243 388 'product' => $this->product,
244 389 'current_price' => $minPrice,
245 390 'scope' => 'product_card'
246 - ]);
391 + ]));
247 392 ?>
248 393 <div <?php echo $wrapper_attributes; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
249 394 class="fct-product-card-prices"
250 395 role="region"
@@ -249,12 +394,11 @@
249 394 class="fct-product-card-prices"
250 395 role="region"
251 396 aria-label="<?php echo esc_attr__('Product pricing', 'fluent-cart'); ?>">
252 397 <?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>
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>
257 401 </span>
258 402 <?php endif; ?>
259 403
260 404 <?php if (!$comparePrice && $maxPrice && $maxPrice > $minPrice): ?>
@@ -259,53 +403,68 @@
259 403
260 404 <?php if (!$comparePrice && $maxPrice && $maxPrice > $minPrice): ?>
261 405 <!-- Case 2: price range -->
262 406 <?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>
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); ?>
267 413 </span>
268 414 <?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
415 + <span class="fct-item-price"><?php
273 416 /* translators: %s is the minimum price */
274 417 printf(esc_html__('From %s', 'fluent-cart'), esc_html($formattedMinPrice));
275 418 ?></span>
276 - </span>
277 419 <?php endif; ?>
278 420
279 421 <?php else: ?>
280 422 <!-- 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>
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); ?>
285 426 </span>
286 427 <?php endif; ?>
287 428
288 429 <?php
289 - do_action('fluent_cart/product/after_price', [
430 + do_action('fluent_cart/product/after_price', RenderContext::decorate([
290 431 'product' => $this->product,
291 432 'variant' => $firstVariant,
292 433 'current_price' => $minPrice,
293 434 'scope' => 'product_card'
294 - ]);
435 + ]));
295 436 RenderHelper::renderPriceSuffix($this->product, $firstVariant, 'product_card');
296 437 ?>
297 438 </div>
298 439 <?php
299 - do_action('fluent_cart/product/group/after_price_block', [
440 + do_action('fluent_cart/product/group/after_price_block', RenderContext::decorate([
300 441 'product' => $this->product,
301 442 'current_price' => $minPrice,
302 443 'scope' => 'product_card'
303 - ]);
444 + ]));
304 445 }
305 446
306 447 public function showBuyButton($atts = '')
307 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 + {
308 467 $isOutOfStock = ModuleSettings::isActive('stock_management') && !$this->product->isStock();
309 468
310 469 if ($isOutOfStock) {
311 470 $soldOutText = __('Not Available', 'fluent-cart');
@@ -351,8 +510,24 @@
351 510 $buttonText = __('Add To Cart', 'fluent-cart');
352 511 $ariaLabel = sprintf(
353 512 /* translators: %s: product title */
354 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;
355 530 }
356 531 }
357 532
358 533 $buttonAttributes = [