PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.6.6
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.6.6
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 1.3.19 All 49 releases
← All changes | app/Services/Renderer/ProductCardRender.php +139 -176 1.3.27 → 1.6.6 View file →
@@ -4,9 +4,8 @@
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 9 use FluentCart\App\Vite;
11 10 use FluentCart\Framework\Support\Arr;
12 11
@@ -37,8 +36,21 @@
37 36
38 37 public function render()
39 38 {
40 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 + }
41 53 $cursor = '';
42 54 if (!empty($this->config['cursor'])) {
43 55 $cursor = 'data-fluent-cart-cursor="' . esc_attr($this->config['cursor']) . '"';
44 56 }
@@ -50,11 +62,24 @@
50 62 $cardWidth = 'style="width: ' . esc_attr($widthValue) . ';"';
51 63 }
52 64
53 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 + ]));
54 79 ?>
55 80 <article data-fluent-cart-shop-app-single-product data-fct-product-card=""
56 - class="fct-product-card"
81 + class="<?php echo esc_attr($cardClasses); ?>"
57 82 <?php echo $cursor; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Already escaped ?>
58 83 <?php echo $cardWidth; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Already escaped ?>
59 84 aria-label="<?php echo esc_attr(sprintf(
60 85 /* translators: %s: product title */
@@ -61,17 +86,22 @@
61 86 __('%s product card', 'fluent-cart'), $this->product->post_title));
62 87 ?>">
63 88 <?php $this->renderProductImage(); ?>
64 89 <?php $this->renderTitle(); ?>
65 - <?php $this->renderExcerpt(); ?>
90 + <?php if (!Arr::get($this->config, 'hide_excerpt', false)) { $this->renderExcerpt(); } ?>
66 91 <?php $this->renderPrices(); ?>
67 92 <?php $this->showBuyButton(); ?>
68 93 </article>
69 94 <?php
95 + do_action('fluent_cart/product/group/after_card', RenderContext::decorate([
96 + 'product' => $this->product,
97 + 'scope' => RenderGate::SCOPE_CARD,
98 + ]));
70 99 }
71 100
72 101 /**
73 - * 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).
74 104 *
75 105 * @param array $otherInfo Order item's other_info array
76 106 * @return string e.g. "Gift (Box) · 30 × 20 × 15 cm · Wt: 2 kg · Shipping: 2.5 kg"
77 107 */
@@ -76,53 +106,16 @@
76 106 * @return string e.g. "Gift (Box) · 30 × 20 × 15 cm · Wt: 2 kg · Shipping: 2.5 kg"
77 107 */
78 108 public static function buildPackageInfoFromOtherInfo($otherInfo)
79 109 {
80 - $storeWeightUnit = Helper::shopConfig('weight_unit') ?: 'kg';
81 - $parts = [];
82 -
83 - // Package name
84 - $name = Arr::get($otherInfo, 'package_name', '');
85 - if ($name) {
86 - $parts[] = $name;
87 - }
88 -
89 - // Dimensions
90 - $length = Arr::get($otherInfo, 'package_length', '');
91 - $width = Arr::get($otherInfo, 'package_width', '');
92 - $height = Arr::get($otherInfo, 'package_height', '');
93 - $dimensionUnit = Arr::get($otherInfo, 'package_dimension_unit', 'cm');
94 - $dimParts = array_filter([$length, $width, $height], function ($val) {
95 - return $val !== '' && $val !== null && $val != 0;
96 - });
97 - if ($dimParts) {
98 - $parts[] = implode(' × ', $dimParts) . ' ' . $dimensionUnit;
99 - }
100 -
101 - // Product weight
102 - $productWeight = floatval(Arr::get($otherInfo, 'weight', 0));
103 - $productWeightUnit = Arr::get($otherInfo, 'weight_unit', $storeWeightUnit);
104 - $convertedProductWeight = Helper::convertWeight($productWeight, $productWeightUnit, $storeWeightUnit);
105 - if ($convertedProductWeight) {
106 - $formatted = rtrim(rtrim(number_format($convertedProductWeight, 2), '0'), '.');
107 - $parts[] = __('Wt:', 'fluent-cart') . ' ' . $formatted . ' ' . $storeWeightUnit;
108 - }
109 -
110 - // Shipping weight (product + package)
111 - $packageWeight = floatval(Arr::get($otherInfo, 'package_weight', 0));
112 - $packageWeightUnit = Arr::get($otherInfo, 'package_weight_unit', $storeWeightUnit);
113 - $convertedPackageWeight = Helper::convertWeight($packageWeight, $packageWeightUnit, $storeWeightUnit);
114 - $totalWeight = $convertedProductWeight + $convertedPackageWeight;
115 - if ($totalWeight && $convertedPackageWeight) {
116 - $formatted = rtrim(rtrim(number_format($totalWeight, 2), '0'), '.');
117 - $parts[] = __('Shipping wt:', 'fluent-cart') . ' ' . $formatted . ' ' . $storeWeightUnit;
118 - }
119 -
120 - $info = implode(' · ', $parts);
121 -
122 - return $info ? __('Package:', 'fluent-cart') . ' ' . $info : '';
110 + return PackageDescriptionRenderer::buildPackageInfoFromOtherInfo($otherInfo);
123 111 }
124 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 + */
125 118 public function renderPackageDescription(
126 119 $wrapper_attributes = '',
127 120 $showName = true,
128 121 $showDimensions = true,
@@ -127,121 +120,36 @@
127 120 $showName = true,
128 121 $showDimensions = true,
129 122 $showProductWeight = true,
130 123 $showTotalWeight = true,
131 - $variant = null
124 + $variant = null,
125 + $defaultVariant = null
132 126 ) {
133 - $variant = $variant ?: $this->product->variants->first();
134 -
135 - if (!$variant) {
136 - return;
137 - }
138 -
139 - if (!$wrapper_attributes) {
140 - $wrapper_attributes = 'class="fct-package-description" data-fluent-cart-package-description';
141 - }
142 -
143 - $this->renderPackageDescriptionForVariant($variant, $wrapper_attributes, $showName, $showDimensions, $showProductWeight, $showTotalWeight);
127 + (new PackageDescriptionRenderer($this->product))->renderPackageDescription(
128 + $wrapper_attributes,
129 + $showName,
130 + $showDimensions,
131 + $showProductWeight,
132 + $showTotalWeight,
133 + $variant,
134 + $defaultVariant
135 + );
144 136 }
145 137
146 - private function renderPackageDescriptionForVariant(
147 - ProductVariation $variant,
148 - $wrapper_attributes,
149 - $showName,
150 - $showDimensions,
151 - $showProductWeight,
152 - $showTotalWeight
153 - ) {
154 - if ($variant->fulfillment_type !== 'physical') {
138 + public function renderExcerpt($atts = '')
139 + {
140 + if (empty($this->product->post_excerpt)) {
155 141 return;
156 142 }
157 143
158 - $packageSlug = Arr::get($variant->other_info, 'package_slug', '');
159 - $package = Helper::getPackageBySlug($packageSlug);
144 + $gateContext = RenderGate::context($this->product, RenderGate::SCOPE_CARD);
160 145
161 - if (!$package) {
146 + if (!RenderGate::shouldRender('excerpt', $gateContext)) {
162 147 return;
163 148 }
164 149
165 - $name = Arr::get($package, 'name', '');
166 - $length = Arr::get($package, 'length', '');
167 - $width = Arr::get($package, 'width', '');
168 - $height = Arr::get($package, 'height', '');
169 - $dimensionUnit = Arr::get($package, 'dimension_unit', 'cm');
170 - $packageWeight = floatval(Arr::get($package, 'weight', 0));
171 - $packageWeightUnit = Arr::get($package, 'weight_unit', 'kg');
150 + do_action('fluent_cart/product/group/before_excerpt_block', $gateContext);
172 151
173 - $storeWeightUnit = Helper::shopConfig('weight_unit') ?: 'kg';
174 - $otherInfo = $variant->other_info ?: [];
175 - $productWeight = floatval(Arr::get($otherInfo, 'weight', 0));
176 - $productWeightUnit = Arr::get($otherInfo, 'weight_unit', $storeWeightUnit);
177 -
178 - $convertedProductWeight = Helper::convertWeight($productWeight, $productWeightUnit, $storeWeightUnit);
179 - $convertedPackageWeight = Helper::convertWeight($packageWeight, $packageWeightUnit, $storeWeightUnit);
180 - $totalWeight = $convertedProductWeight + $convertedPackageWeight;
181 -
182 - $hasVisibleContent = ($showName && $name)
183 - || ($showDimensions && ($length || $width || $height))
184 - || ($showProductWeight && $convertedProductWeight)
185 - || ($showTotalWeight && $totalWeight);
186 -
187 - if (!$hasVisibleContent) {
188 - return;
189 - }
190 -
191 - echo sprintf('<div %s>', $wrapper_attributes); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
192 - echo '<table class="fct-package-description__table" role="presentation"><tbody>';
193 -
194 - if ($showName && $name) {
195 - echo sprintf(
196 - '<tr><th>%s</th><td>%s</td></tr>',
197 - esc_html__('Package', 'fluent-cart'),
198 - esc_html($name)
199 - );
200 - }
201 -
202 - if ($showDimensions && ($length || $width || $height)) {
203 - $dimensionParts = array_filter([$length, $width, $height], function ($val) {
204 - return $val !== '' && $val !== null;
205 - });
206 - if ($dimensionParts) {
207 - $formattedDimensions = implode(' × ', $dimensionParts) . ' ' . $dimensionUnit;
208 - echo sprintf(
209 - '<tr><th>%s</th><td>%s</td></tr>',
210 - esc_html__('Dimensions', 'fluent-cart'),
211 - esc_html($formattedDimensions)
212 - );
213 - }
214 - }
215 -
216 - if ($showProductWeight && $convertedProductWeight) {
217 - $formattedProductWeight = rtrim(rtrim(number_format($convertedProductWeight, 2), '0'), '.');
218 - echo sprintf(
219 - '<tr><th>%s</th><td>%s</td></tr>',
220 - esc_html__('Weight', 'fluent-cart'),
221 - esc_html($formattedProductWeight . ' ' . $storeWeightUnit)
222 - );
223 - }
224 -
225 - if ($showTotalWeight && $totalWeight && $convertedPackageWeight) {
226 - $formattedTotalWeight = rtrim(rtrim(number_format($totalWeight, 2), '0'), '.');
227 - echo sprintf(
228 - '<tr><th>%s</th><td>%s</td></tr>',
229 - esc_html__('Shipping Weight', 'fluent-cart'),
230 - esc_html($formattedTotalWeight . ' ' . $storeWeightUnit)
231 - );
232 - }
233 -
234 - echo '</tbody></table>';
235 - echo '</div>';
236 - }
237 -
238 - public function renderExcerpt($atts = '')
239 - {
240 - if (empty($this->product->post_excerpt)) {
241 - return;
242 - }
243 -
244 152 echo sprintf(
245 153 '<p %1$s class="fct-product-card-excerpt">
246 154 %2$s
247 155 </p>',
@@ -247,12 +155,22 @@
247 155 </p>',
248 156 $atts, // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
249 157 wp_kses_post($this->product->post_excerpt),
250 158 );
159 +
160 + do_action('fluent_cart/product/group/after_excerpt_block', $gateContext);
251 161 }
252 162
253 163 public function renderTitle($atts = '', $config = [])
254 164 {
165 + $gateContext = RenderGate::context($this->product, RenderGate::SCOPE_CARD);
166 +
167 + if (!RenderGate::shouldRender('title', $gateContext)) {
168 + return;
169 + }
170 +
171 + do_action('fluent_cart/product/group/before_title_block', $gateContext);
172 +
255 173 $link = Arr::get($config, 'isLink', true);
256 174 $target = Arr::get($config, 'target', '_self');
257 175
258 176 $titleText = esc_html($this->product->post_title);
@@ -282,12 +200,18 @@
282 200 $atts, // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
283 201 esc_html($titleText)
284 202 );
285 203 }
204 +
205 + do_action('fluent_cart/product/group/after_title_block', $gateContext);
286 206 }
287 207
288 208 public function renderProductImage()
289 209 {
210 + if (!RenderGate::shouldRender('image', RenderGate::context($this->product, RenderGate::SCOPE_CARD))) {
211 + return;
212 + }
213 +
290 214 $image = $this->product->thumbnail;
291 215 $isPlaceholder = false;
292 216
293 217 if (!$image) {
@@ -300,12 +224,12 @@
300 224 /* translators: %s: product title */
301 225 __('Placeholder image for %s', 'fluent-cart'), $this->product->post_title)
302 226 : $this->product->post_title;
303 227
304 - do_action('fluent_cart/product/group/before_image_block', [
228 + do_action('fluent_cart/product/group/before_image_block', RenderContext::decorate([
305 229 'product' => $this->product,
306 230 'scope' => 'product_card'
307 - ]);
231 + ]));
308 232 ?>
309 233 <a class="fct-product-card-image-wrap"
310 234 href="<?php echo esc_url($this->viewUrl); ?>"
311 235 style="display: block;"
@@ -321,21 +245,26 @@
321 245 height="300"/>
322 246 </a>
323 247 <?php
324 248
325 - do_action('fluent_cart/product/group/after_image_block', [
249 + do_action('fluent_cart/product/group/after_image_block', RenderContext::decorate([
326 250 'product' => $this->product,
327 251 'scope' => 'product_card'
328 - ]);
252 + ]));
329 253 }
330 254
331 255 public function renderPrices($wrapper_attributes = '')
332 256 {
257 + if (!RenderGate::shouldRender('price', RenderGate::context($this->product, RenderGate::SCOPE_CARD))) {
258 + return;
259 + }
260 +
333 261 $priceFormat = Arr::get($this->config, 'price_format', 'starts_from');
334 262 $isSimple = $this->product->detail->variation_type === 'simple';
335 263 $minPrice = $this->product->detail->min_price;
336 264 $maxPrice = $this->product->detail->max_price;
337 265 $comparePrice = 0;
266 + $firstVariant = null;
338 267
339 268 if ($isSimple) {
340 269 $firstVariant = $this->product->variants->first();
341 270 if ($firstVariant) {
@@ -354,13 +283,13 @@
354 283 $formattedMinPrice = Helper::toDecimal($minPrice);
355 284 $formattedMaxPrice = Helper::toDecimal($maxPrice);
356 285 $formattedComparePrice = Helper::toDecimal($comparePrice);
357 286
358 - do_action('fluent_cart/product/group/before_price_block', [
287 + do_action('fluent_cart/product/group/before_price_block', RenderContext::decorate([
359 288 'product' => $this->product,
360 289 'current_price' => $minPrice,
361 290 'scope' => 'product_card'
362 - ]);
291 + ]));
363 292 ?>
364 293 <div <?php echo $wrapper_attributes; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
365 294 class="fct-product-card-prices"
366 295 role="region"
@@ -365,12 +294,11 @@
365 294 class="fct-product-card-prices"
366 295 role="region"
367 296 aria-label="<?php echo esc_attr__('Product pricing', 'fluent-cart'); ?>">
368 297 <?php if ($comparePrice): ?>
369 - <span class="fct-compare-price" aria-label="<?php echo esc_attr(sprintf(
370 - /* translators: %s: product price */
371 - __('Original price: %s', 'fluent-cart'), $formattedComparePrice)); ?>">
372 - <del aria-hidden="true"><?php echo esc_html($formattedComparePrice); ?></del>
298 + <span class="fct-compare-price">
299 + <span class="fct-sr-only"><?php echo esc_html__('Original price:', 'fluent-cart'); ?></span>
300 + <del><?php echo esc_html($formattedComparePrice); ?></del>
373 301 </span>
374 302 <?php endif; ?>
375 303
376 304 <?php if (!$comparePrice && $maxPrice && $maxPrice > $minPrice): ?>
@@ -375,49 +303,68 @@
375 303
376 304 <?php if (!$comparePrice && $maxPrice && $maxPrice > $minPrice): ?>
377 305 <!-- Case 2: price range -->
378 306 <?php if ($priceFormat === 'range'): ?>
379 - <span class="fct-item-price" aria-label="<?php echo esc_attr(sprintf(
380 - /* translators: %1$s: min price, %2$s: max price */
381 - __('Price range from %1$s to %2$s', 'fluent-cart'), $formattedMinPrice, $formattedMaxPrice)); ?>">
382 - <span aria-hidden="true"><?php echo esc_html($formattedMinPrice); ?> - <?php echo esc_html($formattedMaxPrice); ?></span>
307 + <span class="fct-item-price">
308 + <span class="fct-sr-only"><?php echo esc_html__('Price range:', 'fluent-cart'); ?></span>
309 + <?php echo esc_html($formattedMinPrice); ?>
310 + <span aria-hidden="true">-</span>
311 + <span class="fct-sr-only"><?php echo esc_html__('to', 'fluent-cart'); ?></span>
312 + <?php echo esc_html($formattedMaxPrice); ?>
383 313 </span>
384 314 <?php else: ?>
385 - <span class="fct-item-price" aria-label="<?php echo esc_attr(sprintf(
386 - /* translators: %s: min price */
387 - __('Starting from %s', 'fluent-cart'), $formattedMinPrice)); ?>">
388 - <span aria-hidden="true"><?php
315 + <span class="fct-item-price"><?php
389 316 /* translators: %s is the minimum price */
390 317 printf(esc_html__('From %s', 'fluent-cart'), esc_html($formattedMinPrice));
391 318 ?></span>
392 - </span>
393 319 <?php endif; ?>
394 320
395 321 <?php else: ?>
396 322 <!-- Case 3: Simple or single price -->
397 - <span class="fct-item-price" aria-label="<?php echo esc_attr(sprintf(
398 - /* translators: %s: product price */
399 - __('Price: %s', 'fluent-cart'), $formattedMinPrice)); ?>">
400 - <span aria-hidden="true"><?php echo esc_html($formattedMinPrice); ?></span>
323 + <span class="fct-item-price">
324 + <span class="fct-sr-only"><?php echo esc_html__('Price:', 'fluent-cart'); ?></span>
325 + <?php echo esc_html($formattedMinPrice); ?>
401 326 </span>
402 327 <?php endif; ?>
403 328
404 - <?php do_action('fluent_cart/product/after_price', [
329 + <?php
330 + do_action('fluent_cart/product/after_price', RenderContext::decorate([
405 331 'product' => $this->product,
332 + 'variant' => $firstVariant,
406 333 'current_price' => $minPrice,
407 334 'scope' => 'product_card'
408 - ]); ?>
335 + ]));
336 + RenderHelper::renderPriceSuffix($this->product, $firstVariant, 'product_card');
337 + ?>
409 338 </div>
410 339 <?php
411 - do_action('fluent_cart/product/group/after_price_block', [
340 + do_action('fluent_cart/product/group/after_price_block', RenderContext::decorate([
412 341 'product' => $this->product,
413 342 'current_price' => $minPrice,
414 343 'scope' => 'product_card'
415 - ]);
344 + ]));
416 345 }
417 346
418 347 public function showBuyButton($atts = '')
419 348 {
349 + $gateContext = RenderGate::context($this->product, RenderGate::SCOPE_CARD);
350 +
351 + // 'actions' gates the whole affordance slot, including the disabled
352 + // out-of-stock placeholder — hiding the row should not leave a stray
353 + // "Not Available" button behind.
354 + if (!RenderGate::shouldRender('actions', $gateContext)) {
355 + return;
356 + }
357 +
358 + do_action('fluent_cart/product/group/before_actions_block', $gateContext);
359 +
360 + $this->renderBuyButtonMarkup($atts, $gateContext);
361 +
362 + do_action('fluent_cart/product/group/after_actions_block', $gateContext);
363 + }
364 +
365 + protected function renderBuyButtonMarkup($atts, array $gateContext)
366 + {
420 367 $isOutOfStock = ModuleSettings::isActive('stock_management') && !$this->product->isStock();
421 368
422 369 if ($isOutOfStock) {
423 370 $soldOutText = __('Not Available', 'fluent-cart');
@@ -463,8 +410,24 @@
463 410 $buttonText = __('Add To Cart', 'fluent-cart');
464 411 $ariaLabel = sprintf(
465 412 /* translators: %s: product title */
466 413 __('Add %s to cart', 'fluent-cart'), $this->product->post_title);
414 + }
415 + }
416 +
417 + // The card fills this slot with one of three things: an instant-checkout
418 + // Buy Now anchor (simple + subscription), an Add to Cart button (simple),
419 + // or a "View Options" link through to the product page (variable). The
420 + // first two are purchase affordances and answer to their own gates.
421 + // "View Options" is navigation, so it stays under 'actions' alone — a
422 + // catalog-mode listener hides the buying, not the browsing.
423 + if ($isInstantCheckout) {
424 + if (!RenderGate::shouldRender('buy_now_button', $gateContext)) {
425 + return;
426 + }
427 + } elseif ($firstVariant) {
428 + if (!RenderGate::shouldRender('add_to_cart_button', $gateContext)) {
429 + return;
467 430 }
468 431 }
469 432
470 433 $buttonAttributes = [