PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.3.26
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.3.26
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 trunk All 48 releases
fluent-cart / app / Services / Renderer / ProductCardRender.php

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

588 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
339 if ($isSimple) {
340 $firstVariant = $this->product->variants->first();
341 if ($firstVariant) {
342 $minPrice = $firstVariant->item_price;
343 $minPrice = apply_filters('fluent_cart/product/display_price', $minPrice, [
344 'product' => $this->product,
345 'variation' => $firstVariant,
346 ]);
347 $minPrice = (int)$minPrice;
348 if ($firstVariant->compare_price > $minPrice) {
349 $comparePrice = $firstVariant->compare_price;
350 }
351 }
352 }
353
354 $formattedMinPrice = Helper::toDecimal($minPrice);
355 $formattedMaxPrice = Helper::toDecimal($maxPrice);
356 $formattedComparePrice = Helper::toDecimal($comparePrice);
357
358 do_action('fluent_cart/product/group/before_price_block', [
359 'product' => $this->product,
360 'current_price' => $minPrice,
361 'scope' => 'product_card'
362 ]);
363 ?>
364 <div <?php echo $wrapper_attributes; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
365 class="fct-product-card-prices"
366 role="region"
367 aria-label="<?php echo esc_attr__('Product pricing', 'fluent-cart'); ?>">
368 <?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>
373 </span>
374 <?php endif; ?>
375
376 <?php if (!$comparePrice && $maxPrice && $maxPrice > $minPrice): ?>
377 <!-- Case 2: price range -->
378 <?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>
383 </span>
384 <?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
389 /* translators: %s is the minimum price */
390 printf(esc_html__('From %s', 'fluent-cart'), esc_html($formattedMinPrice));
391 ?></span>
392 </span>
393 <?php endif; ?>
394
395 <?php else: ?>
396 <!-- 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>
401 </span>
402 <?php endif; ?>
403
404 <?php do_action('fluent_cart/product/after_price', [
405 'product' => $this->product,
406 'current_price' => $minPrice,
407 'scope' => 'product_card'
408 ]); ?>
409 </div>
410 <?php
411 do_action('fluent_cart/product/group/after_price_block', [
412 'product' => $this->product,
413 'current_price' => $minPrice,
414 'scope' => 'product_card'
415 ]);
416 }
417
418 public function showBuyButton($atts = '')
419 {
420 $isOutOfStock = ModuleSettings::isActive('stock_management') && !$this->product->isStock();
421
422 if ($isOutOfStock) {
423 $soldOutText = __('Not Available', 'fluent-cart');
424 ?>
425 <button type="button" class="fct-product-view-button out-of-stock" disabled aria-disabled="true"
426 aria-label="<?php echo esc_attr($soldOutText); ?>">
427 <span class="fct-button-text">
428 <?php echo esc_html($soldOutText); ?>
429 </span>
430 </button>
431 <?php
432 return;
433 }
434
435 $enableModalCheckout = Helper::isModalCheckoutEnabled();
436 $isSimple = $this->product->detail->variation_type === 'simple';
437 $firstVariant = null;
438 $buttonHref = $this->viewUrl;
439
440 if ($isSimple) {
441 $firstVariant = $this->product->variants->first();
442 if ($firstVariant) {
443 // return '';
444 }
445 }
446
447 $isInstantCheckout = false;
448 $hasSubscription = $this->product->has_subscription;
449 $buttonText = __('View Options', 'fluent-cart');
450 $ariaLabel = sprintf(
451 /* translators: %s: product title */
452 __('View options for %s', 'fluent-cart'), $this->product->post_title);
453
454 if ($isSimple) {
455 if ($hasSubscription) {
456 $buttonText = __('Buy Now', 'fluent-cart');
457 $ariaLabel = sprintf(
458 /* translators: %s: product title */
459 __('Buy %s now', 'fluent-cart'), $this->product->post_title);
460 $buttonHref = $firstVariant->getPurchaseUrl();
461 $isInstantCheckout = true;
462 } else {
463 $buttonText = __('Add To Cart', 'fluent-cart');
464 $ariaLabel = sprintf(
465 /* translators: %s: product title */
466 __('Add %s to cart', 'fluent-cart'), $this->product->post_title);
467 }
468 }
469
470 $buttonAttributes = [
471 'class' => 'fct-product-view-button fct-single-product-card-view-button',
472 'data-product-id' => $this->product->ID,
473 'data-fluent-cart-single-product-card-view-button' => '',
474 'aria-label' => $ariaLabel
475 ];
476
477 if ($firstVariant) {
478 $buttonAttributes = [
479 'data-cart-id' => $firstVariant->id,
480 'class' => 'fluent-cart-add-to-cart-button',
481 'data-variation-type' => $this->product->detail->variation_type,
482 'data-fluent-cart-add-to-cart-button' => '',
483 'data-is-custom' => false,
484 'aria-label' => $ariaLabel
485 ];
486 }
487 $customAttributes = $this->parseAttributes($atts);
488 if (!empty($customAttributes)) {
489 if (isset($customAttributes['class']) && isset($buttonAttributes['class'])) {
490 $buttonAttributes['class'] .= ' ' . $customAttributes['class'];
491 unset($customAttributes['class']);
492 }
493 $buttonAttributes = array_merge($buttonAttributes, $customAttributes);
494 }
495
496 $anchorAttributes = [
497 'href' => $buttonHref,
498 'class' => 'fct-product-view-button',
499 'aria-label' => $ariaLabel,
500 ];
501
502 if ($enableModalCheckout) {
503 $anchorAttributes['data-fct-instant-checkout-button'] = '';
504 $anchorAttributes['data-enable-modal-checkout'] = 'yes';
505 }
506
507 if ($isInstantCheckout) {
508 $parsedCustomAttributes = $this->parseAttributes($atts);
509 if (isset($parsedCustomAttributes['class']) && isset($anchorAttributes['class'])) {
510 $anchorAttributes['class'] .= ' ' . $parsedCustomAttributes['class'];
511 unset($parsedCustomAttributes['class']);
512 }
513 $anchorAttributes = array_merge($anchorAttributes, $parsedCustomAttributes);
514 }
515 ?>
516 <?php if ($isInstantCheckout): ?>
517 <a <?php $this->renderAttributes($anchorAttributes); ?>>
518 <span aria-hidden="true">
519 <?php echo esc_html($buttonText); ?>
520 </span>
521 </a>
522 <?php else: ?>
523 <button
524 type="button"
525 data-button-url="<?php echo esc_url($buttonHref); ?>"
526 <?php $this->renderAttributes($buttonAttributes); ?>>
527 <span class="fct-button-text">
528 <?php echo esc_html($buttonText); ?>
529 </span>
530 <span
531 class="fluent-cart-loader"
532 role="status"
533 aria-live="polite"
534 aria-label="<?php echo esc_attr__('Loading', 'fluent-cart'); ?>">
535 <svg aria-hidden="true"
536 viewBox="0 0 100 101"
537 fill="none"
538 xmlns="http://www.w3.org/2000/svg"
539 focusable="false">
540 <path
541 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"
542 fill="currentColor"></path>
543 <path
544 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"
545 fill="currentFill"></path>
546 </svg>
547 </span>
548 </button>
549 <?php endif; ?>
550 <?php
551 }
552
553 protected function renderAttributes($atts = [])
554 {
555 foreach ($atts as $attr => $value) {
556 if ($value !== '') {
557 echo esc_attr($attr) . '="' . esc_attr((string)$value) . '" ';
558 } else {
559 echo esc_attr($attr) . ' ';
560 }
561 }
562 }
563
564 private function parseAttributes($atts)
565 {
566 if (empty($atts)) {
567 return [];
568 }
569
570 $attributes = [];
571
572 // Match attribute="value" or attribute='value' or attribute=value
573 preg_match_all('/(\w+(?:-\w+)*)=(["\'])(.*?)\2|\b(\w+(?:-\w+)*)=(\S+)/', $atts, $matches, PREG_SET_ORDER);
574
575 foreach ($matches as $match) {
576 if (!empty($match[1])) {
577 // Quoted value
578 $attributes[$match[1]] = $match[3];
579 } elseif (!empty($match[4])) {
580 // Unquoted value
581 $attributes[$match[4]] = $match[5];
582 }
583 }
584
585 return $attributes;
586 }
587 }
588