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 +137 -176 1.4.1 → 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,16 +245,20 @@
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;
@@ -355,13 +283,13 @@
355 283 $formattedMinPrice = Helper::toDecimal($minPrice);
356 284 $formattedMaxPrice = Helper::toDecimal($maxPrice);
357 285 $formattedComparePrice = Helper::toDecimal($comparePrice);
358 286
359 - do_action('fluent_cart/product/group/before_price_block', [
287 + do_action('fluent_cart/product/group/before_price_block', RenderContext::decorate([
360 288 'product' => $this->product,
361 289 'current_price' => $minPrice,
362 290 'scope' => 'product_card'
363 - ]);
291 + ]));
364 292 ?>
365 293 <div <?php echo $wrapper_attributes; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
366 294 class="fct-product-card-prices"
367 295 role="region"
@@ -366,12 +294,11 @@
366 294 class="fct-product-card-prices"
367 295 role="region"
368 296 aria-label="<?php echo esc_attr__('Product pricing', 'fluent-cart'); ?>">
369 297 <?php if ($comparePrice): ?>
370 - <span class="fct-compare-price" aria-label="<?php echo esc_attr(sprintf(
371 - /* translators: %s: product price */
372 - __('Original price: %s', 'fluent-cart'), $formattedComparePrice)); ?>">
373 - <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>
374 301 </span>
375 302 <?php endif; ?>
376 303
377 304 <?php if (!$comparePrice && $maxPrice && $maxPrice > $minPrice): ?>
@@ -376,50 +303,68 @@
376 303
377 304 <?php if (!$comparePrice && $maxPrice && $maxPrice > $minPrice): ?>
378 305 <!-- Case 2: price range -->
379 306 <?php if ($priceFormat === 'range'): ?>
380 - <span class="fct-item-price" aria-label="<?php echo esc_attr(sprintf(
381 - /* translators: %1$s: min price, %2$s: max price */
382 - __('Price range from %1$s to %2$s', 'fluent-cart'), $formattedMinPrice, $formattedMaxPrice)); ?>">
383 - <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); ?>
384 313 </span>
385 314 <?php else: ?>
386 - <span class="fct-item-price" aria-label="<?php echo esc_attr(sprintf(
387 - /* translators: %s: min price */
388 - __('Starting from %s', 'fluent-cart'), $formattedMinPrice)); ?>">
389 - <span aria-hidden="true"><?php
315 + <span class="fct-item-price"><?php
390 316 /* translators: %s is the minimum price */
391 317 printf(esc_html__('From %s', 'fluent-cart'), esc_html($formattedMinPrice));
392 318 ?></span>
393 - </span>
394 319 <?php endif; ?>
395 320
396 321 <?php else: ?>
397 322 <!-- Case 3: Simple or single price -->
398 - <span class="fct-item-price" aria-label="<?php echo esc_attr(sprintf(
399 - /* translators: %s: product price */
400 - __('Price: %s', 'fluent-cart'), $formattedMinPrice)); ?>">
401 - <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); ?>
402 326 </span>
403 327 <?php endif; ?>
404 328
405 - <?php do_action('fluent_cart/product/after_price', [
329 + <?php
330 + do_action('fluent_cart/product/after_price', RenderContext::decorate([
406 331 'product' => $this->product,
407 332 'variant' => $firstVariant,
408 333 'current_price' => $minPrice,
409 334 'scope' => 'product_card'
410 - ]); ?>
335 + ]));
336 + RenderHelper::renderPriceSuffix($this->product, $firstVariant, 'product_card');
337 + ?>
411 338 </div>
412 339 <?php
413 - do_action('fluent_cart/product/group/after_price_block', [
340 + do_action('fluent_cart/product/group/after_price_block', RenderContext::decorate([
414 341 'product' => $this->product,
415 342 'current_price' => $minPrice,
416 343 'scope' => 'product_card'
417 - ]);
344 + ]));
418 345 }
419 346
420 347 public function showBuyButton($atts = '')
421 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 + {
422 367 $isOutOfStock = ModuleSettings::isActive('stock_management') && !$this->product->isStock();
423 368
424 369 if ($isOutOfStock) {
425 370 $soldOutText = __('Not Available', 'fluent-cart');
@@ -465,8 +410,24 @@
465 410 $buttonText = __('Add To Cart', 'fluent-cart');
466 411 $ariaLabel = sprintf(
467 412 /* translators: %s: product title */
468 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;
469 430 }
470 431 }
471 432
472 433 $buttonAttributes = [