PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.6.5
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.6.5
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 -172 1.3.23 → 1.6.5 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,117 +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 - $packageSlug = Arr::get($variant->other_info, 'package_slug', '');
155 - $package = Helper::getPackageBySlug($packageSlug);
156 -
157 - if (!$package) {
138 + public function renderExcerpt($atts = '')
139 + {
140 + if (empty($this->product->post_excerpt)) {
158 141 return;
159 142 }
160 143
161 - $name = Arr::get($package, 'name', '');
162 - $length = Arr::get($package, 'length', '');
163 - $width = Arr::get($package, 'width', '');
164 - $height = Arr::get($package, 'height', '');
165 - $dimensionUnit = Arr::get($package, 'dimension_unit', 'cm');
166 - $packageWeight = floatval(Arr::get($package, 'weight', 0));
167 - $packageWeightUnit = Arr::get($package, 'weight_unit', 'kg');
144 + $gateContext = RenderGate::context($this->product, RenderGate::SCOPE_CARD);
168 145
169 - $storeWeightUnit = Helper::shopConfig('weight_unit') ?: 'kg';
170 - $otherInfo = $variant->other_info ?: [];
171 - $productWeight = floatval(Arr::get($otherInfo, 'weight', 0));
172 - $productWeightUnit = Arr::get($otherInfo, 'weight_unit', $storeWeightUnit);
173 -
174 - $convertedProductWeight = Helper::convertWeight($productWeight, $productWeightUnit, $storeWeightUnit);
175 - $convertedPackageWeight = Helper::convertWeight($packageWeight, $packageWeightUnit, $storeWeightUnit);
176 - $totalWeight = $convertedProductWeight + $convertedPackageWeight;
177 -
178 - $hasVisibleContent = ($showName && $name)
179 - || ($showDimensions && ($length || $width || $height))
180 - || ($showProductWeight && $convertedProductWeight)
181 - || ($showTotalWeight && $totalWeight);
182 -
183 - if (!$hasVisibleContent) {
146 + if (!RenderGate::shouldRender('excerpt', $gateContext)) {
184 147 return;
185 148 }
186 149
187 - echo sprintf('<div %s>', $wrapper_attributes); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
188 - echo '<table class="fct-package-description__table" role="presentation"><tbody>';
150 + do_action('fluent_cart/product/group/before_excerpt_block', $gateContext);
189 151
190 - if ($showName && $name) {
191 - echo sprintf(
192 - '<tr><th>%s</th><td>%s</td></tr>',
193 - esc_html__('Package', 'fluent-cart'),
194 - esc_html($name)
195 - );
196 - }
197 -
198 - if ($showDimensions && ($length || $width || $height)) {
199 - $dimensionParts = array_filter([$length, $width, $height], function ($val) {
200 - return $val !== '' && $val !== null;
201 - });
202 - if ($dimensionParts) {
203 - $formattedDimensions = implode(' × ', $dimensionParts) . ' ' . $dimensionUnit;
204 - echo sprintf(
205 - '<tr><th>%s</th><td>%s</td></tr>',
206 - esc_html__('Dimensions', 'fluent-cart'),
207 - esc_html($formattedDimensions)
208 - );
209 - }
210 - }
211 -
212 - if ($showProductWeight && $convertedProductWeight) {
213 - $formattedProductWeight = rtrim(rtrim(number_format($convertedProductWeight, 2), '0'), '.');
214 - echo sprintf(
215 - '<tr><th>%s</th><td>%s</td></tr>',
216 - esc_html__('Weight', 'fluent-cart'),
217 - esc_html($formattedProductWeight . ' ' . $storeWeightUnit)
218 - );
219 - }
220 -
221 - if ($showTotalWeight && $totalWeight && $convertedPackageWeight) {
222 - $formattedTotalWeight = rtrim(rtrim(number_format($totalWeight, 2), '0'), '.');
223 - echo sprintf(
224 - '<tr><th>%s</th><td>%s</td></tr>',
225 - esc_html__('Shipping Weight', 'fluent-cart'),
226 - esc_html($formattedTotalWeight . ' ' . $storeWeightUnit)
227 - );
228 - }
229 -
230 - echo '</tbody></table>';
231 - echo '</div>';
232 - }
233 -
234 - public function renderExcerpt($atts = '')
235 - {
236 - if (empty($this->product->post_excerpt)) {
237 - return;
238 - }
239 -
240 152 echo sprintf(
241 153 '<p %1$s class="fct-product-card-excerpt">
242 154 %2$s
243 155 </p>',
@@ -243,12 +155,22 @@
243 155 </p>',
244 156 $atts, // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
245 157 wp_kses_post($this->product->post_excerpt),
246 158 );
159 +
160 + do_action('fluent_cart/product/group/after_excerpt_block', $gateContext);
247 161 }
248 162
249 163 public function renderTitle($atts = '', $config = [])
250 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 +
251 173 $link = Arr::get($config, 'isLink', true);
252 174 $target = Arr::get($config, 'target', '_self');
253 175
254 176 $titleText = esc_html($this->product->post_title);
@@ -278,12 +200,18 @@
278 200 $atts, // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
279 201 esc_html($titleText)
280 202 );
281 203 }
204 +
205 + do_action('fluent_cart/product/group/after_title_block', $gateContext);
282 206 }
283 207
284 208 public function renderProductImage()
285 209 {
210 + if (!RenderGate::shouldRender('image', RenderGate::context($this->product, RenderGate::SCOPE_CARD))) {
211 + return;
212 + }
213 +
286 214 $image = $this->product->thumbnail;
287 215 $isPlaceholder = false;
288 216
289 217 if (!$image) {
@@ -296,12 +224,12 @@
296 224 /* translators: %s: product title */
297 225 __('Placeholder image for %s', 'fluent-cart'), $this->product->post_title)
298 226 : $this->product->post_title;
299 227
300 - do_action('fluent_cart/product/group/before_image_block', [
228 + do_action('fluent_cart/product/group/before_image_block', RenderContext::decorate([
301 229 'product' => $this->product,
302 230 'scope' => 'product_card'
303 - ]);
231 + ]));
304 232 ?>
305 233 <a class="fct-product-card-image-wrap"
306 234 href="<?php echo esc_url($this->viewUrl); ?>"
307 235 style="display: block;"
@@ -317,21 +245,26 @@
317 245 height="300"/>
318 246 </a>
319 247 <?php
320 248
321 - do_action('fluent_cart/product/group/after_image_block', [
249 + do_action('fluent_cart/product/group/after_image_block', RenderContext::decorate([
322 250 'product' => $this->product,
323 251 'scope' => 'product_card'
324 - ]);
252 + ]));
325 253 }
326 254
327 255 public function renderPrices($wrapper_attributes = '')
328 256 {
257 + if (!RenderGate::shouldRender('price', RenderGate::context($this->product, RenderGate::SCOPE_CARD))) {
258 + return;
259 + }
260 +
329 261 $priceFormat = Arr::get($this->config, 'price_format', 'starts_from');
330 262 $isSimple = $this->product->detail->variation_type === 'simple';
331 263 $minPrice = $this->product->detail->min_price;
332 264 $maxPrice = $this->product->detail->max_price;
333 265 $comparePrice = 0;
266 + $firstVariant = null;
334 267
335 268 if ($isSimple) {
336 269 $firstVariant = $this->product->variants->first();
337 270 if ($firstVariant) {
@@ -350,13 +283,13 @@
350 283 $formattedMinPrice = Helper::toDecimal($minPrice);
351 284 $formattedMaxPrice = Helper::toDecimal($maxPrice);
352 285 $formattedComparePrice = Helper::toDecimal($comparePrice);
353 286
354 - do_action('fluent_cart/product/group/before_price_block', [
287 + do_action('fluent_cart/product/group/before_price_block', RenderContext::decorate([
355 288 'product' => $this->product,
356 289 'current_price' => $minPrice,
357 290 'scope' => 'product_card'
358 - ]);
291 + ]));
359 292 ?>
360 293 <div <?php echo $wrapper_attributes; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
361 294 class="fct-product-card-prices"
362 295 role="region"
@@ -361,12 +294,11 @@
361 294 class="fct-product-card-prices"
362 295 role="region"
363 296 aria-label="<?php echo esc_attr__('Product pricing', 'fluent-cart'); ?>">
364 297 <?php if ($comparePrice): ?>
365 - <span class="fct-compare-price" aria-label="<?php echo esc_attr(sprintf(
366 - /* translators: %s: product price */
367 - __('Original price: %s', 'fluent-cart'), $formattedComparePrice)); ?>">
368 - <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>
369 301 </span>
370 302 <?php endif; ?>
371 303
372 304 <?php if (!$comparePrice && $maxPrice && $maxPrice > $minPrice): ?>
@@ -371,49 +303,68 @@
371 303
372 304 <?php if (!$comparePrice && $maxPrice && $maxPrice > $minPrice): ?>
373 305 <!-- Case 2: price range -->
374 306 <?php if ($priceFormat === 'range'): ?>
375 - <span class="fct-item-price" aria-label="<?php echo esc_attr(sprintf(
376 - /* translators: %1$s: min price, %2$s: max price */
377 - __('Price range from %1$s to %2$s', 'fluent-cart'), $formattedMinPrice, $formattedMaxPrice)); ?>">
378 - <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); ?>
379 313 </span>
380 314 <?php else: ?>
381 - <span class="fct-item-price" aria-label="<?php echo esc_attr(sprintf(
382 - /* translators: %s: min price */
383 - __('Starting from %s', 'fluent-cart'), $formattedMinPrice)); ?>">
384 - <span aria-hidden="true"><?php
315 + <span class="fct-item-price"><?php
385 316 /* translators: %s is the minimum price */
386 317 printf(esc_html__('From %s', 'fluent-cart'), esc_html($formattedMinPrice));
387 318 ?></span>
388 - </span>
389 319 <?php endif; ?>
390 320
391 321 <?php else: ?>
392 322 <!-- Case 3: Simple or single price -->
393 - <span class="fct-item-price" aria-label="<?php echo esc_attr(sprintf(
394 - /* translators: %s: product price */
395 - __('Price: %s', 'fluent-cart'), $formattedMinPrice)); ?>">
396 - <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); ?>
397 326 </span>
398 327 <?php endif; ?>
399 328
400 - <?php do_action('fluent_cart/product/after_price', [
329 + <?php
330 + do_action('fluent_cart/product/after_price', RenderContext::decorate([
401 331 'product' => $this->product,
332 + 'variant' => $firstVariant,
402 333 'current_price' => $minPrice,
403 334 'scope' => 'product_card'
404 - ]); ?>
335 + ]));
336 + RenderHelper::renderPriceSuffix($this->product, $firstVariant, 'product_card');
337 + ?>
405 338 </div>
406 339 <?php
407 - do_action('fluent_cart/product/group/after_price_block', [
340 + do_action('fluent_cart/product/group/after_price_block', RenderContext::decorate([
408 341 'product' => $this->product,
409 342 'current_price' => $minPrice,
410 343 'scope' => 'product_card'
411 - ]);
344 + ]));
412 345 }
413 346
414 347 public function showBuyButton($atts = '')
415 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 + {
416 367 $isOutOfStock = ModuleSettings::isActive('stock_management') && !$this->product->isStock();
417 368
418 369 if ($isOutOfStock) {
419 370 $soldOutText = __('Not Available', 'fluent-cart');
@@ -459,8 +410,24 @@
459 410 $buttonText = __('Add To Cart', 'fluent-cart');
460 411 $ariaLabel = sprintf(
461 412 /* translators: %s: product title */
462 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;
463 430 }
464 431 }
465 432
466 433 $buttonAttributes = [