PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.4.1
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.4.1
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
fluent-cart / app / Services / Renderer / ProductCardRender.php

ProductCardRender.php in FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler 1.4.1, at app/Services/Renderer/ProductCardRender.php

590 lines 23.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 namespace FluentCart\App\Services\Renderer;
4
5 use FluentCart\Api\ModuleSettings;
6 use FluentCart\App\Helpers\Helper;
7 use FluentCart\App\Models\Product;
8 use FluentCart\App\Models\ProductVariation;
9 use FluentCart\App\Modules\Templating\AssetLoader;
10 use FluentCart\App\Vite;
11 use FluentCart\Framework\Support\Arr;
12
13 class ProductCardRender
14 {
15 protected $product;
16
17 protected $viewUrl = '';
18
19 protected $config = [];
20
21 public function __construct(Product $product, $config = [])
22 {
23 $this->product = $product;
24 $this->viewUrl = $product->view_url;
25 $this->config = $config;
26 }
27
28 public function renderWrapperStart()
29 {
30
31 }
32
33 public function renderWrapperEnd()
34 {
35
36 }
37
38 public function render()
39 {
40 AssetLoader::loadSingleProductAssets();
41 $cursor = '';
42 if (!empty($this->config['cursor'])) {
43 $cursor = 'data-fluent-cart-cursor="' . esc_attr($this->config['cursor']) . '"';
44 }
45
46 $cardWidth = '';
47 $cardWidthValue = Arr::get($this->config, 'card_width', '');
48 if ($cardWidthValue) {
49 $widthValue = $cardWidthValue === '100%' ? '100%' : intval($cardWidthValue) . 'px';
50 $cardWidth = 'style="width: ' . esc_attr($widthValue) . ';"';
51 }
52
53
54 ?>
55 <article data-fluent-cart-shop-app-single-product data-fct-product-card=""
56 class="fct-product-card"
57 <?php echo $cursor; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Already escaped ?>
58 <?php echo $cardWidth; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Already escaped ?>
59 aria-label="<?php echo esc_attr(sprintf(
60 /* translators: %s: product title */
61 __('%s product card', 'fluent-cart'), $this->product->post_title));
62 ?>">
63 <?php $this->renderProductImage(); ?>
64 <?php $this->renderTitle(); ?>
65 <?php $this->renderExcerpt(); ?>
66 <?php $this->renderPrices(); ?>
67 <?php $this->showBuyButton(); ?>
68 </article>
69 <?php
70 }
71
72 /**
73 * Build package info string from snapshotted other_info fields.
74 *
75 * @param array $otherInfo Order item's other_info array
76 * @return string e.g. "Gift (Box) · 30 × 20 × 15 cm · Wt: 2 kg · Shipping: 2.5 kg"
77 */
78 public static function buildPackageInfoFromOtherInfo($otherInfo)
79 {
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 : '';
123 }
124
125 public function renderPackageDescription(
126 $wrapper_attributes = '',
127 $showName = true,
128 $showDimensions = true,
129 $showProductWeight = true,
130 $showTotalWeight = true,
131 $variant = null
132 ) {
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);
144 }
145
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') {
155 return;
156 }
157
158 $packageSlug = Arr::get($variant->other_info, 'package_slug', '');
159 $package = Helper::getPackageBySlug($packageSlug);
160
161 if (!$package) {
162 return;
163 }
164
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');
172
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 echo sprintf(
245 '<p %1$s class="fct-product-card-excerpt">
246 %2$s
247 </p>',
248 $atts, // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
249 wp_kses_post($this->product->post_excerpt),
250 );
251 }
252
253 public function renderTitle($atts = '', $config = [])
254 {
255 $link = Arr::get($config, 'isLink', true);
256 $target = Arr::get($config, 'target', '_self');
257
258 $titleText = esc_html($this->product->post_title);
259
260 if ($link) {
261 // Render as link
262 $targetAttr = $target === '_blank' ? 'target="_blank" rel="noopener noreferrer"' : '';
263 echo sprintf(
264 '<h3 class="fct-product-card-title">
265 <a %1$s data-fluent-cart-product-link
266 data-product-id="%2$s"
267 href="%3$s"
268 aria-label="%4$s"
269 %5$s>%6$s</a>
270 </h3>',
271 $atts, // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
272 esc_attr($this->product->ID),
273 esc_url($this->product->view_url),
274 esc_attr($this->product->post_title),
275 $targetAttr, // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
276 esc_html($titleText)
277 );
278 } else {
279 // Render as plain text (no link)
280 echo sprintf(
281 '<h3 class="fct-product-card-title" %1$s>%2$s</h3>',
282 $atts, // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
283 esc_html($titleText)
284 );
285 }
286 }
287
288 public function renderProductImage()
289 {
290 $image = $this->product->thumbnail;
291 $isPlaceholder = false;
292
293 if (!$image) {
294 $image = Vite::getAssetUrl('images/placeholder.svg');
295 $isPlaceholder = true;
296 }
297
298 $altText = $isPlaceholder
299 ? sprintf(
300 /* translators: %s: product title */
301 __('Placeholder image for %s', 'fluent-cart'), $this->product->post_title)
302 : $this->product->post_title;
303
304 do_action('fluent_cart/product/group/before_image_block', [
305 'product' => $this->product,
306 'scope' => 'product_card'
307 ]);
308 ?>
309 <a class="fct-product-card-image-wrap"
310 href="<?php echo esc_url($this->viewUrl); ?>"
311 style="display: block;"
312 aria-label="<?php echo esc_attr(sprintf(
313 /* translators: %s: product title */
314 __('View %s product image', 'fluent-cart'), $this->product->post_title)); ?>">
315 <img class="fct-product-card-image"
316 data-fluent-cart-shop-app-single-product-image
317 src="<?php echo esc_url($image); ?>"
318 alt="<?php echo esc_attr($altText); ?>"
319 loading="lazy"
320 width="300"
321 height="300"/>
322 </a>
323 <?php
324
325 do_action('fluent_cart/product/group/after_image_block', [
326 'product' => $this->product,
327 'scope' => 'product_card'
328 ]);
329 }
330
331 public function renderPrices($wrapper_attributes = '')
332 {
333 $priceFormat = Arr::get($this->config, 'price_format', 'starts_from');
334 $isSimple = $this->product->detail->variation_type === 'simple';
335 $minPrice = $this->product->detail->min_price;
336 $maxPrice = $this->product->detail->max_price;
337 $comparePrice = 0;
338 $firstVariant = null;
339
340 if ($isSimple) {
341 $firstVariant = $this->product->variants->first();
342 if ($firstVariant) {
343 $minPrice = $firstVariant->item_price;
344 $minPrice = apply_filters('fluent_cart/product/display_price', $minPrice, [
345 'product' => $this->product,
346 'variation' => $firstVariant,
347 ]);
348 $minPrice = (int)$minPrice;
349 if ($firstVariant->compare_price > $minPrice) {
350 $comparePrice = $firstVariant->compare_price;
351 }
352 }
353 }
354
355 $formattedMinPrice = Helper::toDecimal($minPrice);
356 $formattedMaxPrice = Helper::toDecimal($maxPrice);
357 $formattedComparePrice = Helper::toDecimal($comparePrice);
358
359 do_action('fluent_cart/product/group/before_price_block', [
360 'product' => $this->product,
361 'current_price' => $minPrice,
362 'scope' => 'product_card'
363 ]);
364 ?>
365 <div <?php echo $wrapper_attributes; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
366 class="fct-product-card-prices"
367 role="region"
368 aria-label="<?php echo esc_attr__('Product pricing', 'fluent-cart'); ?>">
369 <?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>
374 </span>
375 <?php endif; ?>
376
377 <?php if (!$comparePrice && $maxPrice && $maxPrice > $minPrice): ?>
378 <!-- Case 2: price range -->
379 <?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>
384 </span>
385 <?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
390 /* translators: %s is the minimum price */
391 printf(esc_html__('From %s', 'fluent-cart'), esc_html($formattedMinPrice));
392 ?></span>
393 </span>
394 <?php endif; ?>
395
396 <?php else: ?>
397 <!-- 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>
402 </span>
403 <?php endif; ?>
404
405 <?php do_action('fluent_cart/product/after_price', [
406 'product' => $this->product,
407 'variant' => $firstVariant,
408 'current_price' => $minPrice,
409 'scope' => 'product_card'
410 ]); ?>
411 </div>
412 <?php
413 do_action('fluent_cart/product/group/after_price_block', [
414 'product' => $this->product,
415 'current_price' => $minPrice,
416 'scope' => 'product_card'
417 ]);
418 }
419
420 public function showBuyButton($atts = '')
421 {
422 $isOutOfStock = ModuleSettings::isActive('stock_management') && !$this->product->isStock();
423
424 if ($isOutOfStock) {
425 $soldOutText = __('Not Available', 'fluent-cart');
426 ?>
427 <button type="button" class="fct-product-view-button out-of-stock" disabled aria-disabled="true"
428 aria-label="<?php echo esc_attr($soldOutText); ?>">
429 <span class="fct-button-text">
430 <?php echo esc_html($soldOutText); ?>
431 </span>
432 </button>
433 <?php
434 return;
435 }
436
437 $enableModalCheckout = Helper::isModalCheckoutEnabled();
438 $isSimple = $this->product->detail->variation_type === 'simple';
439 $firstVariant = null;
440 $buttonHref = $this->viewUrl;
441
442 if ($isSimple) {
443 $firstVariant = $this->product->variants->first();
444 if ($firstVariant) {
445 // return '';
446 }
447 }
448
449 $isInstantCheckout = false;
450 $hasSubscription = $this->product->has_subscription;
451 $buttonText = __('View Options', 'fluent-cart');
452 $ariaLabel = sprintf(
453 /* translators: %s: product title */
454 __('View options for %s', 'fluent-cart'), $this->product->post_title);
455
456 if ($isSimple) {
457 if ($hasSubscription) {
458 $buttonText = __('Buy Now', 'fluent-cart');
459 $ariaLabel = sprintf(
460 /* translators: %s: product title */
461 __('Buy %s now', 'fluent-cart'), $this->product->post_title);
462 $buttonHref = $firstVariant->getPurchaseUrl();
463 $isInstantCheckout = true;
464 } else {
465 $buttonText = __('Add To Cart', 'fluent-cart');
466 $ariaLabel = sprintf(
467 /* translators: %s: product title */
468 __('Add %s to cart', 'fluent-cart'), $this->product->post_title);
469 }
470 }
471
472 $buttonAttributes = [
473 'class' => 'fct-product-view-button fct-single-product-card-view-button',
474 'data-product-id' => $this->product->ID,
475 'data-fluent-cart-single-product-card-view-button' => '',
476 'aria-label' => $ariaLabel
477 ];
478
479 if ($firstVariant) {
480 $buttonAttributes = [
481 'data-cart-id' => $firstVariant->id,
482 'class' => 'fluent-cart-add-to-cart-button',
483 'data-variation-type' => $this->product->detail->variation_type,
484 'data-fluent-cart-add-to-cart-button' => '',
485 'data-is-custom' => false,
486 'aria-label' => $ariaLabel
487 ];
488 }
489 $customAttributes = $this->parseAttributes($atts);
490 if (!empty($customAttributes)) {
491 if (isset($customAttributes['class']) && isset($buttonAttributes['class'])) {
492 $buttonAttributes['class'] .= ' ' . $customAttributes['class'];
493 unset($customAttributes['class']);
494 }
495 $buttonAttributes = array_merge($buttonAttributes, $customAttributes);
496 }
497
498 $anchorAttributes = [
499 'href' => $buttonHref,
500 'class' => 'fct-product-view-button',
501 'aria-label' => $ariaLabel,
502 ];
503
504 if ($enableModalCheckout) {
505 $anchorAttributes['data-fct-instant-checkout-button'] = '';
506 $anchorAttributes['data-enable-modal-checkout'] = 'yes';
507 }
508
509 if ($isInstantCheckout) {
510 $parsedCustomAttributes = $this->parseAttributes($atts);
511 if (isset($parsedCustomAttributes['class']) && isset($anchorAttributes['class'])) {
512 $anchorAttributes['class'] .= ' ' . $parsedCustomAttributes['class'];
513 unset($parsedCustomAttributes['class']);
514 }
515 $anchorAttributes = array_merge($anchorAttributes, $parsedCustomAttributes);
516 }
517 ?>
518 <?php if ($isInstantCheckout): ?>
519 <a <?php $this->renderAttributes($anchorAttributes); ?>>
520 <span aria-hidden="true">
521 <?php echo esc_html($buttonText); ?>
522 </span>
523 </a>
524 <?php else: ?>
525 <button
526 type="button"
527 data-button-url="<?php echo esc_url($buttonHref); ?>"
528 <?php $this->renderAttributes($buttonAttributes); ?>>
529 <span class="fct-button-text">
530 <?php echo esc_html($buttonText); ?>
531 </span>
532 <span
533 class="fluent-cart-loader"
534 role="status"
535 aria-live="polite"
536 aria-label="<?php echo esc_attr__('Loading', 'fluent-cart'); ?>">
537 <svg aria-hidden="true"
538 viewBox="0 0 100 101"
539 fill="none"
540 xmlns="http://www.w3.org/2000/svg"
541 focusable="false">
542 <path
543 d="M100 50.5908C100 78.2051 77.6142 100.591 50 100.591C22.3858 100.591 0 78.2051 0 50.5908C0 22.9766 22.3858 0.59082 50 0.59082C77.6142 0.59082 100 22.9766 100 50.5908ZM9.08144 50.5908C9.08144 73.1895 27.4013 91.5094 50 91.5094C72.5987 91.5094 90.9186 73.1895 90.9186 50.5908C90.9186 27.9921 72.5987 9.67226 50 9.67226C27.4013 9.67226 9.08144 27.9921 9.08144 50.5908Z"
544 fill="currentColor"></path>
545 <path
546 d="M93.9676 39.0409C96.393 38.4038 97.8624 35.9116 97.0079 33.5539C95.2932 28.8227 92.871 24.3692 89.8167 20.348C85.8452 15.1192 80.8826 10.7238 75.2124 7.41289C69.5422 4.10194 63.2754 1.94025 56.7698 1.05124C51.7666 0.367541 46.6976 0.446843 41.7345 1.27873C39.2613 1.69328 37.813 4.19778 38.4501 6.62326C39.0873 9.04874 41.5694 10.4717 44.0505 10.1071C47.8511 9.54855 51.7191 9.52689 55.5402 10.0491C60.8642 10.7766 65.9928 12.5457 70.6331 15.2552C75.2735 17.9648 79.3347 21.5619 82.5849 25.841C84.9175 28.9121 86.7997 32.2913 88.1811 35.8758C89.083 38.2158 91.5421 39.6781 93.9676 39.0409Z"
547 fill="currentFill"></path>
548 </svg>
549 </span>
550 </button>
551 <?php endif; ?>
552 <?php
553 }
554
555 protected function renderAttributes($atts = [])
556 {
557 foreach ($atts as $attr => $value) {
558 if ($value !== '') {
559 echo esc_attr($attr) . '="' . esc_attr((string)$value) . '" ';
560 } else {
561 echo esc_attr($attr) . ' ';
562 }
563 }
564 }
565
566 private function parseAttributes($atts)
567 {
568 if (empty($atts)) {
569 return [];
570 }
571
572 $attributes = [];
573
574 // Match attribute="value" or attribute='value' or attribute=value
575 preg_match_all('/(\w+(?:-\w+)*)=(["\'])(.*?)\2|\b(\w+(?:-\w+)*)=(\S+)/', $atts, $matches, PREG_SET_ORDER);
576
577 foreach ($matches as $match) {
578 if (!empty($match[1])) {
579 // Quoted value
580 $attributes[$match[1]] = $match[3];
581 } elseif (!empty($match[4])) {
582 // Unquoted value
583 $attributes[$match[4]] = $match[5];
584 }
585 }
586
587 return $attributes;
588 }
589 }
590