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 +124 -176 1.5.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
@@ -63,11 +62,24 @@
63 62 $cardWidth = 'style="width: ' . esc_attr($widthValue) . ';"';
64 63 }
65 64
66 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 + ]));
67 79 ?>
68 80 <article data-fluent-cart-shop-app-single-product data-fct-product-card=""
69 - class="fct-product-card"
81 + class="<?php echo esc_attr($cardClasses); ?>"
70 82 <?php echo $cursor; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Already escaped ?>
71 83 <?php echo $cardWidth; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Already escaped ?>
72 84 aria-label="<?php echo esc_attr(sprintf(
73 85 /* translators: %s: product title */
@@ -74,17 +86,22 @@
74 86 __('%s product card', 'fluent-cart'), $this->product->post_title));
75 87 ?>">
76 88 <?php $this->renderProductImage(); ?>
77 89 <?php $this->renderTitle(); ?>
78 - <?php $this->renderExcerpt(); ?>
90 + <?php if (!Arr::get($this->config, 'hide_excerpt', false)) { $this->renderExcerpt(); } ?>
79 91 <?php $this->renderPrices(); ?>
80 92 <?php $this->showBuyButton(); ?>
81 93 </article>
82 94 <?php
95 + do_action('fluent_cart/product/group/after_card', RenderContext::decorate([
96 + 'product' => $this->product,
97 + 'scope' => RenderGate::SCOPE_CARD,
98 + ]));
83 99 }
84 100
85 101 /**
86 - * 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).
87 104 *
88 105 * @param array $otherInfo Order item's other_info array
89 106 * @return string e.g. "Gift (Box) · 30 × 20 × 15 cm · Wt: 2 kg · Shipping: 2.5 kg"
90 107 */
@@ -89,53 +106,16 @@
89 106 * @return string e.g. "Gift (Box) · 30 × 20 × 15 cm · Wt: 2 kg · Shipping: 2.5 kg"
90 107 */
91 108 public static function buildPackageInfoFromOtherInfo($otherInfo)
92 109 {
93 - $storeWeightUnit = Helper::shopConfig('weight_unit') ?: 'kg';
94 - $parts = [];
95 -
96 - // Package name
97 - $name = Arr::get($otherInfo, 'package_name', '');
98 - if ($name) {
99 - $parts[] = $name;
100 - }
101 -
102 - // Dimensions
103 - $length = Arr::get($otherInfo, 'package_length', '');
104 - $width = Arr::get($otherInfo, 'package_width', '');
105 - $height = Arr::get($otherInfo, 'package_height', '');
106 - $dimensionUnit = Arr::get($otherInfo, 'package_dimension_unit', 'cm');
107 - $dimParts = array_filter([$length, $width, $height], function ($val) {
108 - return $val !== '' && $val !== null && $val != 0;
109 - });
110 - if ($dimParts) {
111 - $parts[] = implode(' × ', $dimParts) . ' ' . $dimensionUnit;
112 - }
113 -
114 - // Product weight
115 - $productWeight = floatval(Arr::get($otherInfo, 'weight', 0));
116 - $productWeightUnit = Arr::get($otherInfo, 'weight_unit', $storeWeightUnit);
117 - $convertedProductWeight = Helper::convertWeight($productWeight, $productWeightUnit, $storeWeightUnit);
118 - if ($convertedProductWeight) {
119 - $formatted = rtrim(rtrim(number_format($convertedProductWeight, 2), '0'), '.');
120 - $parts[] = __('Wt:', 'fluent-cart') . ' ' . $formatted . ' ' . $storeWeightUnit;
121 - }
122 -
123 - // Shipping weight (product + package)
124 - $packageWeight = floatval(Arr::get($otherInfo, 'package_weight', 0));
125 - $packageWeightUnit = Arr::get($otherInfo, 'package_weight_unit', $storeWeightUnit);
126 - $convertedPackageWeight = Helper::convertWeight($packageWeight, $packageWeightUnit, $storeWeightUnit);
127 - $totalWeight = $convertedProductWeight + $convertedPackageWeight;
128 - if ($totalWeight && $convertedPackageWeight) {
129 - $formatted = rtrim(rtrim(number_format($totalWeight, 2), '0'), '.');
130 - $parts[] = __('Shipping wt:', 'fluent-cart') . ' ' . $formatted . ' ' . $storeWeightUnit;
131 - }
132 -
133 - $info = implode(' · ', $parts);
134 -
135 - return $info ? __('Package:', 'fluent-cart') . ' ' . $info : '';
110 + return PackageDescriptionRenderer::buildPackageInfoFromOtherInfo($otherInfo);
136 111 }
137 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 + */
138 118 public function renderPackageDescription(
139 119 $wrapper_attributes = '',
140 120 $showName = true,
141 121 $showDimensions = true,
@@ -140,121 +120,36 @@
140 120 $showName = true,
141 121 $showDimensions = true,
142 122 $showProductWeight = true,
143 123 $showTotalWeight = true,
144 - $variant = null
124 + $variant = null,
125 + $defaultVariant = null
145 126 ) {
146 - $variant = $variant ?: $this->product->variants->first();
147 -
148 - if (!$variant) {
149 - return;
150 - }
151 -
152 - if (!$wrapper_attributes) {
153 - $wrapper_attributes = 'class="fct-package-description" data-fluent-cart-package-description';
154 - }
155 -
156 - $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 + );
157 136 }
158 137
159 - private function renderPackageDescriptionForVariant(
160 - ProductVariation $variant,
161 - $wrapper_attributes,
162 - $showName,
163 - $showDimensions,
164 - $showProductWeight,
165 - $showTotalWeight
166 - ) {
167 - if ($variant->fulfillment_type !== 'physical') {
138 + public function renderExcerpt($atts = '')
139 + {
140 + if (empty($this->product->post_excerpt)) {
168 141 return;
169 142 }
170 143
171 - $packageSlug = Arr::get($variant->other_info, 'package_slug', '');
172 - $package = Helper::getPackageBySlug($packageSlug);
144 + $gateContext = RenderGate::context($this->product, RenderGate::SCOPE_CARD);
173 145
174 - if (!$package) {
146 + if (!RenderGate::shouldRender('excerpt', $gateContext)) {
175 147 return;
176 148 }
177 149
178 - $name = Arr::get($package, 'name', '');
179 - $length = Arr::get($package, 'length', '');
180 - $width = Arr::get($package, 'width', '');
181 - $height = Arr::get($package, 'height', '');
182 - $dimensionUnit = Arr::get($package, 'dimension_unit', 'cm');
183 - $packageWeight = floatval(Arr::get($package, 'weight', 0));
184 - $packageWeightUnit = Arr::get($package, 'weight_unit', 'kg');
150 + do_action('fluent_cart/product/group/before_excerpt_block', $gateContext);
185 151
186 - $storeWeightUnit = Helper::shopConfig('weight_unit') ?: 'kg';
187 - $otherInfo = $variant->other_info ?: [];
188 - $productWeight = floatval(Arr::get($otherInfo, 'weight', 0));
189 - $productWeightUnit = Arr::get($otherInfo, 'weight_unit', $storeWeightUnit);
190 -
191 - $convertedProductWeight = Helper::convertWeight($productWeight, $productWeightUnit, $storeWeightUnit);
192 - $convertedPackageWeight = Helper::convertWeight($packageWeight, $packageWeightUnit, $storeWeightUnit);
193 - $totalWeight = $convertedProductWeight + $convertedPackageWeight;
194 -
195 - $hasVisibleContent = ($showName && $name)
196 - || ($showDimensions && ($length || $width || $height))
197 - || ($showProductWeight && $convertedProductWeight)
198 - || ($showTotalWeight && $totalWeight);
199 -
200 - if (!$hasVisibleContent) {
201 - return;
202 - }
203 -
204 - echo sprintf('<div %s>', $wrapper_attributes); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
205 - echo '<table class="fct-package-description__table" role="presentation"><tbody>';
206 -
207 - if ($showName && $name) {
208 - echo sprintf(
209 - '<tr><th>%s</th><td>%s</td></tr>',
210 - esc_html__('Package', 'fluent-cart'),
211 - esc_html($name)
212 - );
213 - }
214 -
215 - if ($showDimensions && ($length || $width || $height)) {
216 - $dimensionParts = array_filter([$length, $width, $height], function ($val) {
217 - return $val !== '' && $val !== null;
218 - });
219 - if ($dimensionParts) {
220 - $formattedDimensions = implode(' × ', $dimensionParts) . ' ' . $dimensionUnit;
221 - echo sprintf(
222 - '<tr><th>%s</th><td>%s</td></tr>',
223 - esc_html__('Dimensions', 'fluent-cart'),
224 - esc_html($formattedDimensions)
225 - );
226 - }
227 - }
228 -
229 - if ($showProductWeight && $convertedProductWeight) {
230 - $formattedProductWeight = rtrim(rtrim(number_format($convertedProductWeight, 2), '0'), '.');
231 - echo sprintf(
232 - '<tr><th>%s</th><td>%s</td></tr>',
233 - esc_html__('Weight', 'fluent-cart'),
234 - esc_html($formattedProductWeight . ' ' . $storeWeightUnit)
235 - );
236 - }
237 -
238 - if ($showTotalWeight && $totalWeight && $convertedPackageWeight) {
239 - $formattedTotalWeight = rtrim(rtrim(number_format($totalWeight, 2), '0'), '.');
240 - echo sprintf(
241 - '<tr><th>%s</th><td>%s</td></tr>',
242 - esc_html__('Shipping Weight', 'fluent-cart'),
243 - esc_html($formattedTotalWeight . ' ' . $storeWeightUnit)
244 - );
245 - }
246 -
247 - echo '</tbody></table>';
248 - echo '</div>';
249 - }
250 -
251 - public function renderExcerpt($atts = '')
252 - {
253 - if (empty($this->product->post_excerpt)) {
254 - return;
255 - }
256 -
257 152 echo sprintf(
258 153 '<p %1$s class="fct-product-card-excerpt">
259 154 %2$s
260 155 </p>',
@@ -260,12 +155,22 @@
260 155 </p>',
261 156 $atts, // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
262 157 wp_kses_post($this->product->post_excerpt),
263 158 );
159 +
160 + do_action('fluent_cart/product/group/after_excerpt_block', $gateContext);
264 161 }
265 162
266 163 public function renderTitle($atts = '', $config = [])
267 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 +
268 173 $link = Arr::get($config, 'isLink', true);
269 174 $target = Arr::get($config, 'target', '_self');
270 175
271 176 $titleText = esc_html($this->product->post_title);
@@ -295,12 +200,18 @@
295 200 $atts, // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
296 201 esc_html($titleText)
297 202 );
298 203 }
204 +
205 + do_action('fluent_cart/product/group/after_title_block', $gateContext);
299 206 }
300 207
301 208 public function renderProductImage()
302 209 {
210 + if (!RenderGate::shouldRender('image', RenderGate::context($this->product, RenderGate::SCOPE_CARD))) {
211 + return;
212 + }
213 +
303 214 $image = $this->product->thumbnail;
304 215 $isPlaceholder = false;
305 216
306 217 if (!$image) {
@@ -313,12 +224,12 @@
313 224 /* translators: %s: product title */
314 225 __('Placeholder image for %s', 'fluent-cart'), $this->product->post_title)
315 226 : $this->product->post_title;
316 227
317 - do_action('fluent_cart/product/group/before_image_block', [
228 + do_action('fluent_cart/product/group/before_image_block', RenderContext::decorate([
318 229 'product' => $this->product,
319 230 'scope' => 'product_card'
320 - ]);
231 + ]));
321 232 ?>
322 233 <a class="fct-product-card-image-wrap"
323 234 href="<?php echo esc_url($this->viewUrl); ?>"
324 235 style="display: block;"
@@ -334,16 +245,20 @@
334 245 height="300"/>
335 246 </a>
336 247 <?php
337 248
338 - do_action('fluent_cart/product/group/after_image_block', [
249 + do_action('fluent_cart/product/group/after_image_block', RenderContext::decorate([
339 250 'product' => $this->product,
340 251 'scope' => 'product_card'
341 - ]);
252 + ]));
342 253 }
343 254
344 255 public function renderPrices($wrapper_attributes = '')
345 256 {
257 + if (!RenderGate::shouldRender('price', RenderGate::context($this->product, RenderGate::SCOPE_CARD))) {
258 + return;
259 + }
260 +
346 261 $priceFormat = Arr::get($this->config, 'price_format', 'starts_from');
347 262 $isSimple = $this->product->detail->variation_type === 'simple';
348 263 $minPrice = $this->product->detail->min_price;
349 264 $maxPrice = $this->product->detail->max_price;
@@ -368,13 +283,13 @@
368 283 $formattedMinPrice = Helper::toDecimal($minPrice);
369 284 $formattedMaxPrice = Helper::toDecimal($maxPrice);
370 285 $formattedComparePrice = Helper::toDecimal($comparePrice);
371 286
372 - do_action('fluent_cart/product/group/before_price_block', [
287 + do_action('fluent_cart/product/group/before_price_block', RenderContext::decorate([
373 288 'product' => $this->product,
374 289 'current_price' => $minPrice,
375 290 'scope' => 'product_card'
376 - ]);
291 + ]));
377 292 ?>
378 293 <div <?php echo $wrapper_attributes; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
379 294 class="fct-product-card-prices"
380 295 role="region"
@@ -379,12 +294,11 @@
379 294 class="fct-product-card-prices"
380 295 role="region"
381 296 aria-label="<?php echo esc_attr__('Product pricing', 'fluent-cart'); ?>">
382 297 <?php if ($comparePrice): ?>
383 - <span class="fct-compare-price" aria-label="<?php echo esc_attr(sprintf(
384 - /* translators: %s: product price */
385 - __('Original price: %s', 'fluent-cart'), $formattedComparePrice)); ?>">
386 - <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>
387 301 </span>
388 302 <?php endif; ?>
389 303
390 304 <?php if (!$comparePrice && $maxPrice && $maxPrice > $minPrice): ?>
@@ -389,50 +303,68 @@
389 303
390 304 <?php if (!$comparePrice && $maxPrice && $maxPrice > $minPrice): ?>
391 305 <!-- Case 2: price range -->
392 306 <?php if ($priceFormat === 'range'): ?>
393 - <span class="fct-item-price" aria-label="<?php echo esc_attr(sprintf(
394 - /* translators: %1$s: min price, %2$s: max price */
395 - __('Price range from %1$s to %2$s', 'fluent-cart'), $formattedMinPrice, $formattedMaxPrice)); ?>">
396 - <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); ?>
397 313 </span>
398 314 <?php else: ?>
399 - <span class="fct-item-price" aria-label="<?php echo esc_attr(sprintf(
400 - /* translators: %s: min price */
401 - __('Starting from %s', 'fluent-cart'), $formattedMinPrice)); ?>">
402 - <span aria-hidden="true"><?php
315 + <span class="fct-item-price"><?php
403 316 /* translators: %s is the minimum price */
404 317 printf(esc_html__('From %s', 'fluent-cart'), esc_html($formattedMinPrice));
405 318 ?></span>
406 - </span>
407 319 <?php endif; ?>
408 320
409 321 <?php else: ?>
410 322 <!-- Case 3: Simple or single price -->
411 - <span class="fct-item-price" aria-label="<?php echo esc_attr(sprintf(
412 - /* translators: %s: product price */
413 - __('Price: %s', 'fluent-cart'), $formattedMinPrice)); ?>">
414 - <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); ?>
415 326 </span>
416 327 <?php endif; ?>
417 328
418 - <?php do_action('fluent_cart/product/after_price', [
329 + <?php
330 + do_action('fluent_cart/product/after_price', RenderContext::decorate([
419 331 'product' => $this->product,
420 332 'variant' => $firstVariant,
421 333 'current_price' => $minPrice,
422 334 'scope' => 'product_card'
423 - ]); ?>
335 + ]));
336 + RenderHelper::renderPriceSuffix($this->product, $firstVariant, 'product_card');
337 + ?>
424 338 </div>
425 339 <?php
426 - do_action('fluent_cart/product/group/after_price_block', [
340 + do_action('fluent_cart/product/group/after_price_block', RenderContext::decorate([
427 341 'product' => $this->product,
428 342 'current_price' => $minPrice,
429 343 'scope' => 'product_card'
430 - ]);
344 + ]));
431 345 }
432 346
433 347 public function showBuyButton($atts = '')
434 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 + {
435 367 $isOutOfStock = ModuleSettings::isActive('stock_management') && !$this->product->isStock();
436 368
437 369 if ($isOutOfStock) {
438 370 $soldOutText = __('Not Available', 'fluent-cart');
@@ -478,8 +410,24 @@
478 410 $buttonText = __('Add To Cart', 'fluent-cart');
479 411 $ariaLabel = sprintf(
480 412 /* translators: %s: product title */
481 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;
482 430 }
483 431 }
484 432
485 433 $buttonAttributes = [