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.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 / ProductRenderer.php

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

1,755 lines 68.5 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\App\App;
6 use FluentCart\App\Services\FrontendView;
7 use FluentCart\App\Vite;
8 use FluentCart\Api\StoreSettings;
9 use FluentCart\Api\ModuleSettings;
10 use FluentCart\App\Helpers\Helper;
11 use FluentCart\App\Models\Product;
12 use FluentCart\Framework\Support\Arr;
13 use FluentCart\App\Http\Routes\WebRoutes;
14 use FluentCart\App\Models\ProductVariation;
15 use FluentCart\Framework\Support\Collection;
16 use FluentCart\App\Modules\Templating\AssetLoader;
17
18 class ProductRenderer
19 {
20 protected $product;
21
22 protected $variants;
23
24 protected $storeSettings;
25
26 protected $defaultVariant = null;
27
28 protected $hasOnetime = false;
29
30 protected $hasSubscription = false;
31
32 protected $viewType = '';
33
34 protected $columnType = '';
35
36 protected $defaultVariationId = '';
37
38 protected $defaultGalleryImageId = 0;
39
40 protected $galleryActiveSet = false;
41
42 protected $paymentTypes = [];
43
44 protected $variantsByPaymentTypes = [];
45
46 protected $activeTab = 'onetime';
47
48 protected $images = [];
49
50 protected $defaultImageUrl = null;
51
52 protected $defaultImageAlt = null;
53
54 public function __construct(Product $product, $config = [])
55 {
56
57 $this->product = $product;
58 $this->variants = $product->variants;
59
60 $this->storeSettings = new StoreSettings();
61 $this->viewType = $this->storeSettings->get('variation_view', 'both');
62 $this->columnType = $this->storeSettings->get('variation_columns', 'masonry');
63
64 $defaultVariationId = $config['default_variation_id'] ?? '';
65
66 // 'image', 'text','both'
67 $this->viewType = apply_filters('fluent_cart/single_product/variation_view_type', $this->viewType, [
68 'product' => $product,
69 'variants' => $this->variants,
70 'defaultVariationId' => $defaultVariationId,
71 ]);
72
73 // 'one', 'two','three', 'four', 'masonry'
74 $this->columnType = apply_filters('fluent_cart/single_product/variation_column_type', $this->columnType, [
75 'product' => $product,
76 'variants' => $this->variants,
77 'defaultVariationId' => $defaultVariationId,
78 ]);
79
80
81 $hasExplicitDefault = true;
82
83 if (!$defaultVariationId) {
84 $variationIds = $product->variants->pluck('id')->toArray();
85 $defaultVariationId = $product->detail->default_variation_id;
86
87 if (!$defaultVariationId || !in_array($defaultVariationId, $variationIds)) {
88 $defaultVariationId = Arr::get($variationIds, '0');
89 $hasExplicitDefault = false;
90 }
91 }
92
93 // Always set resolved default variation id
94 $this->defaultVariationId = $defaultVariationId;
95
96 // Gallery defaults to featured image (key 0) when no explicit default variation is set
97 $this->defaultGalleryImageId = $hasExplicitDefault ? $defaultVariationId : 0;
98
99
100 $this->product->variants->load('bundleChildren.product');
101
102
103
104 foreach ($this->product->variants as $variant) {
105 if ($variant->id == $this->defaultVariationId) {
106 $this->defaultVariant = $variant;
107 }
108 $paymentType = Arr::get($variant->other_info, 'payment_type');
109 if ($paymentType === 'onetime') {
110 $this->hasOnetime = true;
111 } else if ($paymentType === 'subscription') {
112 $this->hasSubscription = true;
113 }
114 }
115
116 $this->buildProductGroups();
117 }
118
119 public function buildProductGroups()
120 {
121 $groupKey = 'repeat_interval';
122 $otherInfo = (array)Arr::get($this->product->detail, 'other_info');
123 $groupBy = Arr::get($otherInfo, 'group_pricing_by', 'repeat_interval'); //repeat_interval,payment_type,none
124
125
126 if ($groupBy !== 'none') {
127 if ($groupBy === 'payment_type') {
128 $groupKey = 'payment_type';
129 }
130
131 $paymentTypes = [];
132
133 if ($groupBy === 'repeat_interval') {
134 foreach ($this->variants as $key => $variant) {
135 $paymentType = 'onetime';
136 $type = Arr::get($variant, 'payment_type');
137 if ($type === 'subscription') {
138 $isInstallment = Arr::get($variant, 'other_info.installment', 'no');
139 if ($isInstallment === 'yes' && App::isProActive()) {
140 $paymentType = 'installment';
141 } else {
142 $paymentType = Arr::get($variant, 'other_info.repeat_interval', 'onetime');;
143 }
144 }
145
146 $paymentTypes[] = $paymentType;
147
148 if (!isset($this->variantsByPaymentTypes[$paymentType])) {
149 $this->variantsByPaymentTypes[$paymentType] = [];
150 }
151
152 $this->variantsByPaymentTypes[$paymentType][] = $variant;
153
154 if ($this->defaultVariationId == $variant['id']) {
155 $this->activeTab = $paymentType;
156 }
157
158 }
159 } else {
160 foreach ($this->variants as $key => $variant) {
161 $paymentType = 'onetime';
162 $type = Arr::get($variant, 'payment_type');
163 if ($type === 'subscription') {
164 $isInstallment = Arr::get($variant, 'other_info.installment');
165 if ($isInstallment === 'yes' && App::isProActive()) {
166 $paymentType = 'installment';
167 } else {
168 $paymentType = 'subscription';
169 }
170 }
171 $paymentTypes[] = $paymentType;
172
173 if (!isset($this->variantsByPaymentTypes[$paymentType])) {
174 $this->variantsByPaymentTypes[$paymentType] = [];
175 }
176
177 $this->variantsByPaymentTypes[$paymentType][] = $variant;
178
179 if ($this->defaultVariationId == $variant['id']) {
180 $this->activeTab = $paymentType;
181 }
182
183 }
184 }
185
186 $paymentTypes = array_unique($paymentTypes);
187
188
189 $intervalOptions = Helper::getAvailableSubscriptionIntervalOptions();
190
191 $groupLanguageMap = [
192 'onetime' => __('One Time', 'fluent-cart'),
193 'subscription' => __('Subscription', 'fluent-cart'),
194 'installment' => __('Installment', 'fluent-cart'),
195 ];
196
197 foreach ($intervalOptions as $interval) {
198 $groupLanguageMap[$interval['value']] = $interval['label'];
199 }
200
201 foreach ($paymentTypes as $paymentType) {
202 $this->paymentTypes[$paymentType ?: 'onetime'] = Arr::get($groupLanguageMap, $paymentType ?: 'onetime');
203 }
204 }
205 }
206
207 public function render()
208 {
209 ?>
210 <div class="fct-single-product-page" data-fluent-cart-single-product-page data-product-id="<?php echo esc_attr($this->product->ID); ?>">
211 <div class="fct-single-product-page-row">
212 <?php $this->renderGallery(); ?>
213 <div class="fct-product-summary">
214 <?php
215 $this->renderTitle();
216 $this->renderProductMeta();
217 $this->renderExcerpt();
218 $this->renderPrices();
219
220 if ($this->product->detail->variation_type === 'simple' && !$this->hasSubscription) {
221 foreach ($this->product->variants as $variant) {
222 $this->renderVariationsBundleProduct($variant);
223 }
224 }
225
226 $this->renderPackageDescription();
227 $this->renderBuySection();
228 ?>
229 </div>
230 </div>
231 </div>
232 <?php
233 }
234
235 public function renderProductMeta() {
236 ?>
237 <div class="fct-product-meta">
238 <?php $this->renderStockAvailability(); ?>
239 <?php $this->renderSku(); ?>
240 </div>
241
242 <?php
243 }
244
245 public function renderBuySectionWrapperStart()
246 {
247 ?>
248 <div aria-labelledby="fct-product-summary-title" data-fluent-cart-product-pricing-section data-product-id="<?php echo esc_attr($this->product->ID); ?>" class="fct_buy_section">
249 <?php
250 }
251
252 public function renderBuySectionWrapperEnd()
253 {
254 ?>
255 </div>
256 <?php
257 }
258
259 public function renderBuySection($atts = [])
260 {
261 $otherInfo = (array)Arr::get($this->product->detail, 'other_info');
262 $groupBy = Arr::get($otherInfo, 'group_pricing_by', 'repeat_interval'); //repeat_interval,payment_type,none
263
264 $this->renderBuySectionWrapperStart();
265
266 $this->renderVariationDisplay($atts);
267
268 $this->renderItemPrice();
269
270 $this->renderQuantity();
271 ?>
272 <div class="fct-product-buttons-wrap">
273 <?php $this->renderPurchaseButtons(Arr::get($atts, 'button_atts', [])); ?>
274 </div>
275 <?php
276 $this->renderBuySectionWrapperEnd();
277 }
278
279 public function renderVariationDisplay($atts = [])
280 {
281 $otherInfo = (array)Arr::get($this->product->detail, 'other_info');
282 $groupBy = Arr::get($otherInfo, 'group_pricing_by', 'repeat_interval'); //repeat_interval,payment_type,none
283
284 if (count($this->paymentTypes) === 1 || $groupBy === 'none') {
285 $this->renderVariants(Arr::get($atts, 'variation_atts', []));
286 } else {
287 $this->renderTab(Arr::get($atts, 'variation_atts', []));
288 }
289 }
290
291 public function renderGalleryThumb()
292 {
293 $thumbnails = [];
294
295 $featuredMedia = $this->product->thumbnail ?? Vite::getAssetUrl('images/placeholder.svg');
296
297 if (!$featuredMedia) {
298 $featuredMedia = [];
299 }
300
301 $galleryImage = get_post_meta($this->product->ID, 'fluent-products-gallery-image', true);
302
303 if (!empty($galleryImage)) {
304 $thumbnails[0] = [
305 'media' => $galleryImage,
306 ];
307 }
308
309 foreach ($this->variants as $variant) {
310 if (!empty($variant['media']['meta_value'])) {
311 $thumbnails[$variant['id']] = [
312 'media' => $variant['media']['meta_value'],
313 ];
314 } else {
315 $this->defaultImageUrl = $featuredMedia;
316 $this->defaultImageAlt = Arr::get($variant, 'variation_title', '');
317 }
318 }
319
320 $images = empty($thumbnails) ? [] : $thumbnails;
321
322
323
324 $this->images = $images;
325
326 if (!empty($images)) {
327 $imageId = $this->defaultGalleryImageId;
328
329 if (isset($images[$imageId])) {
330 $imageMetaValue = $images[$imageId];
331 $this->defaultImageUrl = Arr::get($imageMetaValue, 'media.0.url', '');
332 $this->defaultImageAlt = Arr::get($imageMetaValue, 'media.0.title', '');
333 }
334 }
335
336 ?>
337 <div class="fct-product-gallery-thumb" role="region"
338 aria-label="<?php echo esc_attr($this->product->post_title . ' gallery'); ?>">
339 <img
340 src="<?php echo esc_url($this->defaultImageUrl ?? '') ?>"
341 alt="<?php echo esc_attr($this->defaultImageAlt) ?>"
342 data-fluent-cart-single-product-page-product-thumbnail
343 data-default-image-url="<?php echo esc_url($featuredMedia) ?>"
344 />
345 </div>
346 <?php
347 }
348
349 public function renderGalleryThumbControls($maxThumbnails = null)
350 {
351 $totalThumbImages = Arr::pluck($this->images, 'media.*.url');
352
353 if(count($totalThumbImages) == 1 && is_countable($totalThumbImages[0]) && count($totalThumbImages[0]) == 1){
354
355 return '';
356 }
357
358 // Collect ALL gallery images as JSON for lightbox (even when max thumbnails limits visible thumbs)
359 $allGalleryImages = [];
360 foreach ($this->images as $imageId => $image) {
361 if (empty($image['media']) || !is_array($image['media'])) {
362 continue;
363 }
364 foreach ($image['media'] as $item) {
365 $url = Arr::get($item, 'url', '');
366 if (empty($url)) {
367 continue;
368 }
369 $allGalleryImages[] = [
370 'url' => $url,
371 'title' => Arr::get($item, 'title', ''),
372 'variation_id' => (string) $imageId,
373 ];
374 }
375 }
376
377 ?>
378
379 <div class="fct-gallery-thumb-controls"
380 role="toolbar"
381 aria-label="<?php echo esc_attr__('Product image thumbnails', 'fluent-cart'); ?>"
382 data-fluent-cart-single-product-page-product-thumbnail-controls
383 data-all-gallery-images="<?php echo esc_attr(wp_json_encode($allGalleryImages) ?: '[]'); ?>">
384
385 <?php $this->renderGalleryThumbControl($maxThumbnails); ?>
386
387 </div>
388
389 <?php
390
391 }
392
393 public function renderGalleryThumbControl($maxThumbnails = null)
394 {
395 if ($maxThumbnails !== null && $maxThumbnails <= 0) {
396 $maxThumbnails = null; // treat invalid value as "no limit"
397 }
398
399 $count = 0;
400 $totalImages = 0;
401
402 // First, count total renderable images
403 foreach ($this->images as $imageId => $image) {
404 if (empty($image['media']) || !is_array($image['media'])) {
405 continue;
406 }
407 foreach ($image['media'] as $item) {
408 if (!empty(Arr::get($item, 'url', ''))) {
409 $totalImages++;
410 }
411 }
412 }
413
414 // Then render up to max
415 foreach ($this->images as $imageId => $image) {
416 if (empty($image['media']) || !is_array($image['media'])) {
417 continue;
418 }
419
420 foreach ($image['media'] as $item) {
421 if (empty(Arr::get($item, 'url', ''))) {
422 continue;
423 }
424
425 if ($maxThumbnails !== null && $count >= (int) $maxThumbnails) {
426 $this->renderGallerySeeMoreButton($totalImages - (int) $maxThumbnails);
427 return;
428 }
429
430 $this->renderGalleryThumbControlButton($item, $imageId);
431 $count++;
432 }
433
434 }
435
436 }
437
438 public function renderGallerySeeMoreButton($remainingCount)
439 {
440 ?>
441 <button
442 type="button"
443 class="fct-gallery-see-more-button"
444 data-fluent-cart-gallery-see-more
445 aria-label="<?php echo esc_attr(
446 sprintf(
447 /* translators: %d number of remaining images */
448 __('View all %d more images', 'fluent-cart'),
449 $remainingCount
450 )
451 ); ?>"
452 >
453 <svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
454 <path d="M2.58078 19.0103L2.56078 19.0303C2.29078 18.4403 2.12078 17.7703 2.05078 17.0303C2.12078 17.7603 2.31078 18.4203 2.58078 19.0103Z" fill="#9D9FAC"/>
455 <path d="M8.99914 10.3801C10.3136 10.3801 11.3791 9.31456 11.3791 8.00012C11.3791 6.68568 10.3136 5.62012 8.99914 5.62012C7.6847 5.62012 6.61914 6.68568 6.61914 8.00012C6.61914 9.31456 7.6847 10.3801 8.99914 10.3801Z" fill="#9D9FAC"/>
456 <path d="M16.19 2H7.81C4.17 2 2 4.17 2 7.81V16.19C2 17.28 2.19 18.23 2.56 19.03C3.42 20.93 5.26 22 7.81 22H16.19C19.83 22 22 19.83 22 16.19V13.9V7.81C22 4.17 19.83 2 16.19 2ZM20.37 12.5C19.59 11.83 18.33 11.83 17.55 12.5L13.39 16.07C12.61 16.74 11.35 16.74 10.57 16.07L10.23 15.79C9.52 15.17 8.39 15.11 7.59 15.65L3.85 18.16C3.63 17.6 3.5 16.95 3.5 16.19V7.81C3.5 4.99 4.99 3.5 7.81 3.5H16.19C19.01 3.5 20.5 4.99 20.5 7.81V12.61L20.37 12.5Z" fill="#9D9FAC"/>
457 <script xmlns=""/></svg>
458
459 <span class="fct-see-more-text">
460 <?php echo esc_html__('See', 'fluent-cart'); ?>
461 <span class="fct-see-more-count"><?php echo esc_html($remainingCount); ?></span>
462 <?php echo esc_html__('More', 'fluent-cart'); ?>
463 </span>
464 </button>
465 <?php
466 }
467
468 public function renderGalleryThumbControlButton($item, $imageId)
469 {
470
471 $isHidden = ''; //$imageId != $this->defaultVariationId ? 'is-hidden' : '';
472 $itemUrl = Arr::get($item, 'url', '');
473 $itemTitle = Arr::get($item, 'title', '');
474 $isSelected = !$this->galleryActiveSet && $imageId == $this->defaultGalleryImageId;
475 if ($isSelected) {
476 $this->galleryActiveSet = true;
477 }
478 ?>
479
480 <button
481 type="button"
482 class="fct-gallery-thumb-control-button <?php echo $isSelected ? 'active' : ''; ?> <?php echo esc_attr($isHidden); ?>"
483 data-fluent-cart-thumb-control-button
484 data-url="<?php echo esc_url($itemUrl); ?>"
485 data-variation-id="<?php echo esc_attr($imageId); ?>"
486 aria-label="<?php echo
487 /* translators: %s image title */
488 esc_attr(sprintf(__('View %s image', 'fluent-cart'), $itemTitle));
489 ?>"
490 aria-pressed="<?php echo $isSelected ? 'true' : 'false'; ?>"
491 tabindex="<?php echo $isSelected ? '0' : '-1'; ?>"
492 >
493 <img
494 class="fct-gallery-control-thumb"
495 data-fluent-cart-single-product-page-product-thumbnail-controls-thumb
496 src="<?php echo esc_url($itemUrl); ?>"
497 alt="<?php echo esc_attr($itemTitle); ?>"
498 />
499 </button>
500
501 <?php
502
503
504 }
505
506 public function renderGallery($args = [])
507 {
508
509 $defaults = [
510 'thumbnail_mode' => 'all', // horizontal, vertical
511 'thumb_position' => 'bottom', // bottom, left, right, top
512 'scrollable_thumbs' => 'no', // yes / no
513 'max_thumbnails' => null, // null = no limit, integer = max visible
514 ];
515
516 $atts = wp_parse_args($args, $defaults);
517
518 $thumbnailMode = $atts['thumbnail_mode'];
519
520 $wrapperAtts = [
521 'class' => 'fct-product-gallery-wrapper ' . 'thumb-pos-' . $atts['thumb_position'] . ' thumb-mode-' . $thumbnailMode,
522 'data-fct-product-gallery' => '',
523 'data-fluent-cart-product-gallery-wrapper' => '',
524 'data-thumbnail-mode' => $thumbnailMode,
525 'data-product-id' => $this->product->ID,
526 'data-scrollable-thumbs' => $atts['scrollable_thumbs'],
527 ];
528
529 ?>
530
531 <div <?php RenderHelper::renderAtts($wrapperAtts); ?>>
532
533 <?php
534 $this->renderGalleryThumb();
535 $this->renderGalleryThumbControls($atts['max_thumbnails']);
536 ?>
537 </div>
538
539 <?php
540 }
541
542 public function renderTitle()
543 {
544 ?>
545 <div class="fct-product-title">
546 <h1 id="fct-product-summary-title"><?php echo esc_html($this->product->post_title); ?></h1>
547 </div>
548 <?php
549 }
550
551 public function renderStockAvailability($wrapper_attributes = '')
552 {
553 if (!ModuleSettings::isActive('stock_management')) {
554 return '';
555 }
556
557 $stockAvailability = $this->product->detail->getStockAvailability();
558
559 if (!Arr::get($stockAvailability, 'manage_stock')) {
560 return '';
561 }
562
563 $isStock = $this->product->isStock();
564
565 // Check default variant stock for both simple and variable products
566 if ($this->defaultVariant) {
567 $isStock = $isStock && $this->defaultVariant->isStock();
568 }
569 $stockLabel = $isStock ? $stockAvailability['availability'] : __('Out of Stock', 'fluent-cart');
570 $statusClass = $isStock ? ($stockAvailability['class'] ?? '') : 'out-of-stock';
571 echo sprintf(
572 '<div class="fct-product-stock %1$s" role="status" aria-live="polite">
573 <div %2$s>
574 <span class="fct-stock-label">%3$s</span>
575 <span class="fct-stock-badge fct_status_badge_%1$s" data-fluent-cart-product-stock>
576 %4$s
577 </span>
578 </div>
579 </div>',
580 esc_attr($statusClass),
581 $wrapper_attributes, // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
582 esc_html__('Availability:', 'fluent-cart'),
583 esc_html($stockLabel)
584 );
585 }
586
587 public function renderSku($wrapper_attributes = '', $showLabel = true, $label = '', $variant = null)
588 {
589 if (!$variant) {
590 $variant = $this->defaultVariant ?: $this->product->variants->first();
591 }
592
593 if (!$variant || empty($variant->sku)) {
594 return;
595 }
596
597 if (!$label) {
598 $label = __('SKU:', 'fluent-cart');
599 }
600
601 $labelHtml = '';
602 if ($showLabel && $label) {
603 $labelHtml = sprintf('<span class="fct-product-sku__label">%s</span> ', esc_html($label));
604 }
605
606 echo sprintf(
607 '<div class="fct-product-sku">
608 <div %s>
609 %s<span class="fct-product-sku__value" data-fluent-cart-product-sku>%s</span>
610 </div>
611 </div>',
612 $wrapper_attributes, // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
613 $labelHtml,
614 esc_html($variant->sku)
615 );
616 }
617
618 public function renderPackageDescription($wrapper_attributes = '', $showName = true, $showDimensions = true, $showProductWeight = true, $showTotalWeight = true, $variant = null)
619 {
620 $variant = $variant ?: ($this->defaultVariant ?: $this->product->variants->first());
621 (new ProductCardRender($this->product))->renderPackageDescription($wrapper_attributes, $showName, $showDimensions, $showProductWeight, $showTotalWeight, $variant);
622 }
623
624 /**
625 * Build a JSON string of package info for a variant (used as data attribute for JS switching).
626 */
627 private function getVariantPackageInfoJson(ProductVariation $variant)
628 {
629 if ($variant->fulfillment_type !== 'physical') {
630 return '';
631 }
632
633 $otherInfo = $variant->other_info ?: [];
634 $packageSlug = Arr::get($otherInfo, 'package_slug', '');
635 $package = Helper::getPackageBySlug($packageSlug);
636
637 if (!$package) {
638 return '';
639 }
640
641 static $storeWeightUnit = null;
642
643 if ($storeWeightUnit === null) {
644 $storeWeightUnit = Helper::shopConfig('weight_unit') ?: 'kg';
645 }
646
647 // Format dimensions
648 $length = Arr::get($package, 'length', '');
649 $width = Arr::get($package, 'width', '');
650 $height = Arr::get($package, 'height', '');
651 $dimensionUnit = Arr::get($package, 'dimension_unit', 'cm');
652 $dimensionParts = array_filter([$length, $width, $height], function ($val) {
653 return $val !== '' && $val !== null && $val != 0;
654 });
655 $formattedDimensions = $dimensionParts
656 ? implode(' × ', $dimensionParts) . ' ' . $dimensionUnit
657 : '';
658
659 // Calculate weights
660 $productWeight = floatval(Arr::get($otherInfo, 'weight', 0));
661 $productWeightUnit = Arr::get($otherInfo, 'weight_unit', $storeWeightUnit);
662 $convertedProductWeight = Helper::convertWeight($productWeight, $productWeightUnit, $storeWeightUnit);
663
664 $packageWeight = floatval(Arr::get($package, 'weight', 0));
665 $packageWeightUnit = Arr::get($package, 'weight_unit', $storeWeightUnit);
666 $convertedPackageWeight = Helper::convertWeight($packageWeight, $packageWeightUnit, $storeWeightUnit);
667 $totalWeight = $convertedProductWeight + $convertedPackageWeight;
668
669 // Format weights
670 $formattedProductWeight = $convertedProductWeight
671 ? rtrim(rtrim(number_format($convertedProductWeight, 2), '0'), '.') . ' ' . $storeWeightUnit
672 : '';
673
674 $formattedShippingWeight = ($totalWeight && $convertedPackageWeight)
675 ? rtrim(rtrim(number_format($totalWeight, 2), '0'), '.') . ' ' . $storeWeightUnit
676 : '';
677
678 return wp_json_encode([
679 'name' => Arr::get($package, 'name', ''),
680 'dimensions' => $formattedDimensions,
681 'product_weight' => $formattedProductWeight,
682 'shipping_weight' => $formattedShippingWeight,
683 ]);
684 }
685
686 public function renderExcerpt()
687 {
688 $excerpt = $this->product->post_excerpt;
689 if (!$excerpt) {
690 return;
691 }
692 ?>
693 <div class="fct-product-excerpt" aria-labelledby="fct-product-summary-title">
694 <p><?php echo wp_kses_post($excerpt); ?></p>
695 </div>
696 <?php
697
698 }
699
700 public function renderDescription()
701 {
702 $post = get_post($this->product->ID);
703 if (!$post || empty($post->post_content)) {
704 return;
705 }
706 ?>
707 <div class="fct-product-description">
708 <?php echo apply_filters('the_content', $post->post_content); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
709 </div>
710 <?php
711 }
712
713 public function renderPrices()
714 {
715 if ($this->product->detail->variation_type === 'simple') {
716 // we have to render for the simple product
717
718 $first_price = $this->product->variants()->first();
719
720 $itemPrice = $first_price ? $first_price->item_price : 0;
721 $itemPrice = apply_filters('fluent_cart/product/display_price', $itemPrice, [
722 'product' => $this->product,
723 'variation' => $first_price,
724 ]);
725 $itemPrice = (int)$itemPrice;
726 $comparePrice = $first_price ? (int)$first_price->compare_price : 0;
727 if ($comparePrice <= $itemPrice) {
728 $comparePrice = 0;
729 }
730 do_action('fluent_cart/product/single/before_price_block', [
731 'product' => $this->product,
732 'current_price' => $itemPrice,
733 'scope' => 'price_range'
734 ]);
735 ?>
736 <?php
737
738 if ($comparePrice) {
739 $aria_label = sprintf(
740 /* translators: 1: Original price, 2: Current item price */
741 __('Original Price: %1$s, Price: %2$s', 'fluent-cart'),
742 Helper::toDecimal($comparePrice),
743 Helper::toDecimal($itemPrice)
744 );
745 } else {
746 $aria_label = sprintf(
747 /* translators: 1: Current item price */
748 __('Price: %1$s', 'fluent-cart'),
749 Helper::toDecimal($itemPrice)
750 );
751 }
752
753 ?>
754 <div class="fct-price-range fct-product-prices" role="term"
755 aria-label="<?php echo esc_attr($aria_label); ?>">
756
757 <?php if ($comparePrice): ?>
758 <span class="fct-compare-price">
759 <del aria-label="<?php echo esc_attr(__('Original price', 'fluent-cart')); ?>"><?php echo esc_html(Helper::toDecimal($comparePrice)); ?></del>
760 </span>
761 <?php endif; ?>
762 <span class="fct-item-price" aria-label="<?php echo esc_attr(__('Current price', 'fluent-cart')); ?>">
763 <?php echo esc_html(Helper::toDecimal($itemPrice)); ?>
764 <?php do_action('fluent_cart/product/after_price', [
765 'product' => $this->product,
766 'current_price' => $itemPrice,
767 'scope' => 'price_range'
768 ]); ?>
769 </span>
770 </div>
771 <?php
772 do_action('fluent_cart/product/single/after_price_block', [
773 'product' => $this->product,
774 'current_price' => $itemPrice,
775 'scope' => 'price_range'
776 ]);
777 return;
778 }
779 $min_price = $this->product->detail->min_price;
780 $max_price = $this->product->detail->max_price;
781
782 do_action('fluent_cart/product/single/before_price_range_block', [
783 'product' => $this->product,
784 'current_price' => $min_price,
785 'scope' => 'price_range'
786 ]);
787 ?>
788 <?php
789 $aria_label = sprintf(
790 /* translators: 1: Minimum price, 2: Maximum price */
791 __('Price range: %1$s - %2$s', 'fluent-cart'),
792 Helper::toDecimal($min_price),
793 Helper::toDecimal($max_price)
794 );
795 ?>
796 <div class="fct-product-prices fct-price-range" role="term" aria-label="<?php echo esc_attr($aria_label); ?>">
797
798 <?php if ($max_price && $max_price != $min_price && $max_price > $min_price): ?>
799 <span class="fct-min-price"><?php echo esc_html(Helper::toDecimal($min_price)); ?></span>
800 <span class="fct-price-separator" aria-hidden="true">-</span>
801 <?php endif; ?>
802 <span class="fct-max-price">
803 <?php echo esc_html(Helper::toDecimal($max_price)); ?>
804 </span>
805
806 <?php do_action('fluent_cart/product/after_price', [
807 'product' => $this->product,
808 'current_price' => $min_price,
809 'scope' => 'price_range'
810 ]); ?>
811
812 </div>
813 <?php
814 do_action('fluent_cart/product/single/after_price_range_block', [
815 'product' => $this->product,
816 'current_price' => $min_price,
817 'scope' => 'price_range'
818 ]);
819 }
820
821 public function renderVariants($atts = [])
822 {
823 if ($this->product->detail->variation_type === 'simple') {
824 return;
825 }
826
827 $variants = $this->product->variants;
828 if (!$variants || $variants->isEmpty()) {
829 return;
830 }
831
832 // Sort by serial_index ascending
833 $variants = $variants->sortBy('serial_index')->values();
834
835 $classes = array_filter([
836 'fct-product-variants',
837 'column-type-' . $this->columnType,
838 Arr::get($atts, 'wrapper_class', ''),
839 ]);
840
841 ?>
842 <div class="<?php echo esc_attr(implode(' ', $classes)); ?>" role="radiogroup"
843 aria-label="<?php esc_attr_e('Product Variants', 'fluent-cart'); ?>">
844 <?php foreach ($variants as $variant) {
845 do_action('fluent_cart/product/single/before_variant_item', [
846 'product' => $this->product,
847 'variant' => $variant,
848 'scope' => 'product_variant_item'
849 ]);
850 $this->renderVariationItem($variant, $this->defaultVariationId);
851 do_action('fluent_cart/product/single/after_variant_item', [
852 'product' => $this->product,
853 'variant' => $variant,
854 'scope' => 'product_variant_item'
855 ]);
856 } ?>
857 </div>
858 <?php
859 }
860
861 public function renderItemPrice()
862 {
863 if ($this->product->detail->variation_type === 'simple' && !$this->hasSubscription) {
864 return; // for simple product we already rendered the price
865 }
866
867 do_action('fluent_cart/product/single/before_price_block', [
868 'product' => $this->product,
869 'current_price' => $this->defaultVariant ? $this->defaultVariant->item_price : 0,
870 'scope' => 'product_variant_price'
871 ]);
872
873 foreach ($this->product->variants as $variant) {
874 if ($this->shouldRenderPriceInPriceSection()) {
875 $this->renderVariantPricingWrapperStart($variant);
876 $paymentType = Arr::get($variant->other_info, 'payment_type', 'onetime');
877 if (!$this->hasSubscription) {
878 $this->renderVariationComparePrice($variant);
879 $this->applyVariationPriceFilter($variant, $paymentType);
880 } else {
881
882 $atts = [
883 'class' => 'fct-product-payment-type fluent-cart-product-variation-content' . ($this->defaultVariant->id != $variant->id ? ' is-hidden' : ''),
884 'data-fluent-cart-product-payment-type' => '',
885 'data-variation-id' => $variant->id
886 ];
887
888 $this->renderComparePriceWrapperStart($atts);
889 $this->renderVariationComparePrice($variant);
890 if ($paymentType === 'onetime') {
891 echo esc_html(Helper::toDecimal($variant->item_price));
892 } else {
893 $this->applyVariationPriceFilter($variant, $paymentType);
894 }
895 $this->renderComparePriceWrapperEnd();
896 }
897
898 $this->renderVariantPricingWrapperEnd();
899 }
900
901 }
902
903
904 foreach ($this->product->variants as $variant) {
905 $this->renderVariationsBundleProduct($variant);
906 }
907
908
909
910
911 do_action('fluent_cart/product/single/after_price_block', [
912 'product' => $this->product,
913 'current_price' => $this->defaultVariant ? $this->defaultVariant->item_price : 0,
914 'scope' => 'product_variant_price'
915 ]);
916 }
917
918 public function shouldRenderPriceInPriceSection(): bool
919 {
920 return !($this->viewType === 'text' && $this->columnType === 'one');
921 }
922
923 public function applyVariationPriceFilter($variant, $paymentType = 'onetime')
924 {
925 $priceText = $paymentType === 'onetime' ? Helper::toDecimal($variant->item_price) : $variant->getSubscriptionTermsText(true);
926 echo wp_kses_post(apply_filters('fluent_cart/single_product/variation_price', esc_html($priceText), [
927 'product' => $this->product,
928 'variant' => $variant,
929 'scope' => 'product_variant_price'
930 ]));
931 do_action('fluent_cart/product/after_price', [
932 'product' => $this->product,
933 'current_price' => $variant->item_price,
934 'scope' => 'product_variant_price'
935 ]);
936 }
937
938 public function renderComparePriceWrapperStart($atts = [])
939 {
940 ?>
941 <div <?php $this->renderAttributes($atts); ?> >
942 <?php
943 }
944
945 public function renderComparePriceWrapperEnd()
946 {
947 ?>
948 </div>
949 <?php
950 }
951
952 public function renderVariationComparePrice($variant)
953 {
954 if (!$variant->compare_price) {
955 return;
956 } ?>
957
958 <span class="fct-compare-price">
959 <del><?php echo esc_html(Helper::toDecimal($variant->compare_price)); ?></del>
960 </span>
961 <?php
962 }
963
964 public function renderVariantPricingWrapperStart($variant)
965 { ?>
966 <div
967 class="fct-product-item-price fluent-cart-product-variation-content <?php echo esc_attr($this->defaultVariant->id != $variant->id ? ' is-hidden' : ''); ?>"
968 data-fluent-cart-product-item-price
969 data-variation-id="<?php echo esc_attr($variant->id); ?>"
970 aria-live="polite"
971 role="status"
972 >
973 <?php }
974
975
976 public function renderVariantPricingWrapperEnd()
977 {
978 ?> </div> <?php
979 }
980
981 public function renderVariationsBundleProduct($variant)
982 {
983 if (count($variant->bundleChildren) == 0) {
984 return;
985 }
986
987 if(is_object($variant->bundleChildren)) {
988 $bundleProducts = $variant->bundleChildren->toArray();
989 }else{
990 $bundleProducts = $variant->bundleChildren;
991 }
992
993 $total = count($bundleProducts);
994 ?>
995 <div class="fluent-cart-product-variation-content fct-bundle-products <?php echo esc_attr($this->defaultVariant->id != $variant->id ? ' is-hidden' : ''); ?>"
996 data-variation-id="<?php echo esc_attr($variant->id); ?>"
997 data-fluent-cart-collapsibles
998 >
999 <h4 class="fct-bundle-products-title">
1000 <?php echo esc_html__('Bundle of', 'fluent-cart') . ':'; ?>
1001 </h4>
1002
1003 <div class="fct-bundle-products-list">
1004 <?php foreach (array_slice($bundleProducts, 0, 2) as $product): ?>
1005 <p>
1006 <?php echo esc_html(Arr::get($product, 'product.post_title')); ?> -
1007 <?php echo esc_html($product['variation_title']); ?>
1008 </p>
1009 <?php endforeach; ?>
1010
1011 <?php if($total > 2): ?>
1012 <div class="fct-bundle-products-more">
1013 <div class="fct-bundle-products-more-list">
1014 <?php foreach (array_slice($bundleProducts, 2) as $product): ?>
1015 <p>
1016 <?php echo esc_html(Arr::get($product, 'product.post_title')); ?> -
1017 <?php echo esc_html($product['variation_title']); ?>
1018 </p>
1019 <?php endforeach; ?>
1020 </div>
1021 </div>
1022 <?php endif;?>
1023 </div>
1024
1025 <?php if ($total > 2) : ?>
1026 <button type="button" class="fct-see-more-btn" data-fluent-cart-collapsible-toggle>
1027 <span class="see-more-text">
1028 <?php echo esc_html__('See More', 'fluent-cart'); ?>
1029 <svg xmlns="http://www.w3.org/2000/svg" width="12" height="7" viewBox="0 0 12 7" fill="none">
1030 <path d="M0.75 0.75L5.04289 5.04289C5.37623 5.37623 5.54289 5.54289 5.75 5.54289C5.95711 5.54289 6.12377 5.37623 6.45711 5.04289L10.75 0.75" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
1031 </svg>
1032 </span>
1033 <span class="see-less-text">
1034 <?php echo esc_html__('See Less', 'fluent-cart'); ?>
1035 <svg xmlns="http://www.w3.org/2000/svg" width="12" height="7" viewBox="0 0 14 8" fill="none">
1036 <path d="M0.75 6.54297L6.04289 1.25008C6.37623 0.916742 6.54289 0.750076 6.75 0.750076C6.95711 0.750076 7.12377 0.916742 7.45711 1.25008L12.75 6.54297" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
1037 </svg>
1038 </span>
1039 </button>
1040 <?php endif; ?>
1041 </div>
1042 <?php }
1043
1044 public function renderQuantity()
1045 {
1046 $soldIndividually = $this->product->soldIndividually();
1047
1048 if (!$this->hasOnetime || $soldIndividually) {
1049 return;
1050 }
1051
1052 $attributes = [
1053 'data-fluent-cart-product-quantity-container' => '',
1054 'data-cart-id' => $this->defaultVariant ? $this->defaultVariant->id : '',
1055 'data-variation-type' => $this->product->detail->variation_type,
1056 'data-payment-type' => 'onetime',
1057 'class' => 'fct-product-quantity-container'
1058 ];
1059
1060 $defaultVariantData = $this->getDefaultVariantData();
1061
1062 if ($this->hasSubscription && Arr::get($defaultVariantData, 'payment_type') !== 'onetime') {
1063 $attributes['class'] .= ' is-hidden';
1064 }
1065
1066 do_action('fluent_cart/product/single/before_quantity_block', [
1067 'product' => $this->product,
1068 'scope' => 'product_quantity_block'
1069 ]);
1070 ?>
1071 <div <?php $this->renderAttributes($attributes); ?>>
1072 <label for="fct-product-qty-input" class="quantity-title">
1073 <?php esc_html_e('Quantity', 'fluent-cart'); ?>
1074 </label>
1075
1076 <div class="fct-product-quantity">
1077 <button class="fct-quantity-decrease-button"
1078 data-fluent-cart-product-qty-decrease-button
1079 title="<?php esc_html_e('Decrease Quantity', 'fluent-cart'); ?>"
1080 aria-label="<?php esc_attr_e('Decrease Quantity', 'fluent-cart'); ?>"
1081 >
1082 <svg xmlns="http://www.w3.org/2000/svg" width="14" height="2" viewBox="0 0 14 2" fill="none">
1083 <path d="M12.3333 1L1.66659 1" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"
1084 stroke-linejoin="round"></path>
1085 </svg>
1086 </button>
1087
1088 <input
1089 id="fct-product-qty-input"
1090 min="1"
1091 <?php echo $soldIndividually ? 'max="1"' : ''; ?>
1092 class="fct-quantity-input"
1093 data-fluent-cart-single-product-page-product-quantity-input
1094 type="number"
1095 inputmode="numeric"
1096 pattern="[0-9]*"
1097 placeholder="<?php esc_attr_e('Quantity', 'fluent-cart'); ?>"
1098 value="1"
1099 aria-label="<?php esc_attr_e('Product quantity', 'fluent-cart'); ?>"
1100 />
1101
1102 <button class="fct-quantity-increase-button"
1103 data-fluent-cart-product-qty-increase-button
1104 title="<?php esc_attr_e('Increase Quantity', 'fluent-cart'); ?>"
1105 aria-label="<?php esc_attr_e('Increase Quantity', 'fluent-cart'); ?>"
1106 >
1107 <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 14 14" fill="none">
1108 <path d="M6.99996 1.66666L6.99996 12.3333M12.3333 6.99999L1.66663 6.99999" stroke="currentColor"
1109 stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path>
1110 </svg>
1111 </button>
1112 </div>
1113 </div>
1114 <?php
1115 do_action('fluent_cart/product/single/after_quantity_block', [
1116 'product' => $this->product,
1117 'scope' => 'product_quantity_block'
1118 ]);
1119 }
1120
1121 public function renderPurchaseButtons($atts = [])
1122 {
1123 $buyNowButtonAtts = $atts;
1124 $this->renderBuyNowButton($buyNowButtonAtts);
1125 $this->renderAddToCartButton($atts);
1126 }
1127
1128 public function renderBuyNowButton($atts = [])
1129 {
1130 // Stock management check using isStock() method
1131 // if (ModuleSettings::isActive('stock_management')) {
1132 // if ($this->product->detail->variation_type === 'simple' && $this->defaultVariant) {
1133 // if (!$this->defaultVariant->isStock()) {
1134 // echo '<span aria-disabled="true">' . esc_html__('Out of stock', 'fluent-cart') . '</span>';
1135 // return;
1136 // }
1137 // }
1138 // }
1139
1140 $defaults = [
1141 'buy_now_text' => __('Buy Now', 'fluent-cart'),
1142 'add_to_cart_text' => __('Add To Cart', 'fluent-cart'),
1143 ];
1144
1145 $atts = wp_parse_args($atts, $defaults);
1146
1147 $enableModalCheckout = Helper::isModalCheckoutEnabled();
1148
1149 $isInStock = true;
1150 if (ModuleSettings::isActive('stock_management')) {
1151 $isInStock = $this->product->isStock() && ($this->defaultVariant && $this->defaultVariant->isStock());
1152 }
1153
1154 $stockStatus = $isInStock ? 'in-stock' : 'out-of-stock';
1155
1156 $variationClass = 'fluent-cart-direct-checkout-button';
1157 if (!$isInStock) {
1158 $variationClass .= ' is-hidden';
1159 }
1160
1161 $buyNowAttributes = [
1162 'data-fluent-cart-direct-checkout-button' => '',
1163 'data-variation-type' => $this->product->detail->variation_type,
1164 'class' => $variationClass,
1165 'data-stock-availability' => $stockStatus,
1166 'data-quantity' => '1',
1167 'data-cart-id' => $this->defaultVariant ? $this->defaultVariant->id : '',
1168 'data-url' => site_url('?fluent-cart=instant_checkout&item_id='),
1169 ];
1170
1171 if ($isInStock) {
1172 $buyNowAttributes['href'] = site_url('?fluent-cart=instant_checkout&item_id=') . ($this->defaultVariant ? $this->defaultVariant->id : '') . '&quantity=1';
1173 }
1174
1175 if ($enableModalCheckout) {
1176 $buyNowAttributes['data-fct-instant-checkout-button'] = '';
1177 $buyNowAttributes['data-enable-modal-checkout'] = 'yes';
1178 }
1179
1180 $buyButtonText = apply_filters('fluent_cart/product/buy_now_button_text', $atts['buy_now_text'], [
1181 'product' => $this->product
1182 ]);
1183
1184 ?>
1185 <?php
1186 $variantTitle = $this->defaultVariant ? $this->defaultVariant->variation_title : '';
1187 $buyNowAriaLabel = $variantTitle
1188 ? sprintf(
1189 /* translators: 1: Button text (e.g. "Buy Now"), 2: Variant name */
1190 __('%1$s - %2$s', 'fluent-cart'),
1191 $buyButtonText,
1192 $variantTitle
1193 )
1194 : $buyButtonText;
1195 ?>
1196 <a <?php $this->renderAttributes($buyNowAttributes); ?> aria-label="<?php echo esc_attr($buyNowAriaLabel); ?>">
1197 <?php echo wp_kses_post($buyButtonText); ?>
1198 </a>
1199 <?php
1200 }
1201
1202 public function renderBuyNowButtonBlock($atts = [])
1203 {
1204 $text = Arr::get($atts, 'text', __('Buy Now', 'fluent-cart'));
1205 $variantIds = Arr::get($atts, 'variant_ids', []);
1206 $variantId = Arr::get($variantIds, 0);
1207
1208 $defaults = [
1209 'buy_now_text' => $text,
1210 'class' => '',
1211 'target' => '',
1212 'rel' => '',
1213 'is_shortcode' => false,
1214 ];
1215
1216 $atts = wp_parse_args($atts, $defaults);
1217
1218 $enableModalCheckout = Arr::get($atts, 'enable_modal_checkout', false);
1219
1220 $isInStock = true;
1221 if (ModuleSettings::isActive('stock_management')) {
1222 $isInStock = $this->product->isStock() && ($this->defaultVariant && $this->defaultVariant->isStock());
1223 }
1224 $stockStatus = $isInStock ? 'in-stock' : 'out-of-stock';
1225
1226 $checkoutUrl = add_query_arg([
1227 'fluent-cart' => $enableModalCheckout ? 'modal_checkout' : 'instant_checkout',
1228 'item_id' => $variantId ?? '',
1229 'quantity' => 1
1230 ], site_url());
1231
1232 $buyNowClass = trim('wp-block-button__link wp-element-button ' . Arr::get($atts, 'class', ''));
1233 if ($stockStatus === 'out-of-stock') {
1234 $buyNowClass .= ' out-of-stock';
1235 }
1236
1237 $buyNowAttributes = [
1238 'data-fluent-cart-direct-checkout-button' => '',
1239 'data-variation-type' => $this->product->detail->variation_type,
1240 'class' => $buyNowClass,
1241 'data-stock-availability' => $stockStatus,
1242 'data-quantity' => '1',
1243 'data-cart-id' => $variantId ?? '',
1244 'data-url' => $checkoutUrl,
1245 ];
1246
1247 if ($stockStatus === 'out-of-stock') {
1248 $buyNowAttributes['aria-disabled'] = 'true';
1249 } else {
1250 $buyNowAttributes['href'] = $checkoutUrl;
1251 }
1252
1253 $target = Arr::get($atts, 'target');
1254 if ($target) {
1255 $buyNowAttributes['target'] = $target;
1256 if (strtolower($target) === '_blank') {
1257 $buyNowAttributes['rel'] = Arr::get($atts, 'rel', 'noopener noreferrer');
1258 }
1259 }
1260 if ($enableModalCheckout) {
1261 $buyNowAttributes['data-fct-instant-checkout-button'] = '';
1262 $buyNowAttributes['data-enable-modal-checkout'] = 'yes';
1263 }
1264 $wrapperAttributes = '';
1265 $isShortcode = !empty($atts['is_shortcode']);
1266
1267 if ($isShortcode) {
1268 foreach ($buyNowAttributes as $attr => $value) {
1269 if ($value === '') {
1270 $wrapperAttributes .= esc_attr($attr) . ' ';
1271 } else {
1272 $wrapperAttributes .= sprintf('%s="%s" ', esc_attr($attr), esc_attr((string)$value));
1273 }
1274 }
1275 $wrapperAttributes = trim($wrapperAttributes);
1276 } else {
1277 $wrapperAttributes = get_block_wrapper_attributes($buyNowAttributes);
1278 }
1279
1280 $buyButtonText = apply_filters('fluent_cart/product/buy_now_button_text', $atts['buy_now_text'], [
1281 'product' => $this->product
1282 ]);
1283 ?>
1284 <a <?php echo($wrapperAttributes); ?> aria-label="<?php echo esc_attr($buyButtonText); ?>">
1285 <?php echo wp_kses_post($buyButtonText); ?>
1286 </a>
1287 <?php
1288 }
1289
1290 public function renderAddToCartButton($atts = [])
1291 {
1292 $defaults = [
1293 'buy_now_text' => __('Buy Now', 'fluent-cart'),
1294 'add_to_cart_text' => __('Add To Cart', 'fluent-cart'),
1295 ];
1296
1297 $atts = wp_parse_args($atts, $defaults);
1298
1299 $cartAttributes = [
1300 'data-fluent-cart-add-to-cart-button' => '',
1301 'data-cart-id' => $this->defaultVariant ? $this->defaultVariant->id : '',
1302 'data-product-id' => $this->product->ID,
1303 'class' => 'fluent-cart-add-to-cart-button',
1304 'data-variation-type' => $this->product->detail->variation_type,
1305 ];
1306
1307 $defaultVariantData = $this->getDefaultVariantData();
1308
1309 // If product is subscription-only, hide add-to-cart
1310 if ($this->hasSubscription && Arr::get($defaultVariantData, 'payment_type') !== 'onetime') {
1311 $cartAttributes['class'] .= ' is-hidden';
1312 }
1313
1314 // Check stock availability using both product-level and variant-level
1315 $isOutOfStock = false;
1316 if (ModuleSettings::isActive('stock_management')) {
1317 if (!$this->product->isStock() || ($this->defaultVariant && !$this->defaultVariant->isStock())) {
1318 $isOutOfStock = true;
1319 $cartAttributes['disabled'] = 'disabled';
1320 $cartAttributes['class'] .= ' out-of-stock';
1321 $cartAttributes['aria-disabled'] = 'true';
1322 $atts['add_to_cart_text'] = __('Not Available', 'fluent-cart');
1323 }
1324 }
1325
1326 $addToCartText = apply_filters('fluent_cart/product/add_to_cart_text', $atts['add_to_cart_text'], [
1327 'product' => $this->product
1328 ]);
1329 // Render add to cart if product supports onetime, or if out of stock (to show "Not Available")
1330 if ($this->hasOnetime || $isOutOfStock) :
1331 ?>
1332 <?php
1333 $variantTitle = $this->defaultVariant ? $this->defaultVariant->variation_title : '';
1334 $addToCartAriaLabel = $variantTitle
1335 ? sprintf(
1336 /* translators: 1: Button text (e.g. "Add To Cart"), 2: Variant name */
1337 __('%1$s - %2$s', 'fluent-cart'),
1338 $addToCartText,
1339 $variantTitle
1340 )
1341 : $addToCartText;
1342 ?>
1343 <button <?php $this->renderAttributes($cartAttributes); ?>
1344 aria-label="<?php echo esc_attr($addToCartAriaLabel); ?>">
1345 <span class="text">
1346 <?php echo wp_kses_post($addToCartText); ?>
1347 </span>
1348 <span class="fluent-cart-loader" role="status">
1349 <svg aria-hidden="true"
1350 width="20"
1351 height="20"
1352 class="w-5 h-5 text-gray-200 animate-spin fill-blue-600"
1353 viewBox="0 0 100 101" fill="none" xmlns="http://www.w3.org/2000/svg">
1354 <path
1355 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"
1356 fill="currentColor"/>
1357 <path
1358 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"
1359 fill="currentFill"/>
1360 </svg>
1361 </span>
1362 </button>
1363 <?php
1364 endif;
1365 }
1366
1367 public function renderAddToCartButtonBlock($atts = [])
1368 {
1369
1370 $text = Arr::get($atts, 'text', __('Add To Cart', 'fluent-cart'));
1371 $extraClass = trim(Arr::get($atts, 'class', ''));
1372
1373 $defaults = [
1374 'add_to_cart_text' => $text,
1375 ];
1376
1377 $atts = wp_parse_args($atts, $defaults);
1378
1379 $cartAttributes = [
1380 'data-fluent-cart-add-to-cart-button' => '',
1381 'class' => 'wp-block-button__link wp-element-button fct-loader',
1382 'data-cart-id' => $this->defaultVariant ? $this->defaultVariant->id : '',
1383 'data-product-id' => $this->product->ID,
1384 'data-variation-type' => $this->product->detail->variation_type,
1385 ];
1386
1387 if ($extraClass) {
1388 $cartAttributes['class'] .= ' ' . $extraClass;
1389 }
1390
1391 // If the product does NOT support one-time purchase
1392 if (!$this->hasOnetime) {
1393 if (Helper::isAdminUser()) {
1394 $view = '<p class="fct-admin-notice">' . esc_html__('Add to Cart is not supported for subscription product', 'fluent-cart') . '</p>';
1395
1396 FrontendView::make('', $view);
1397 return;
1398 }
1399
1400 return;
1401 }
1402
1403 // Check stock availability using both product-level and variant-level
1404 if (ModuleSettings::isActive('stock_management')) {
1405 if (!$this->product->isStock() || ($this->defaultVariant && !$this->defaultVariant->isStock())) {
1406 $cartAttributes['disabled'] = 'disabled';
1407 $cartAttributes['class'] .= ' out-of-stock';
1408 $cartAttributes['aria-disabled'] = 'true';
1409 $atts['add_to_cart_text'] = __('Not Available', 'fluent-cart');
1410 }
1411 }
1412
1413 $addToCartText = apply_filters('fluent_cart/product/add_to_cart_text', $atts['add_to_cart_text'], [
1414 'product' => $this->product
1415 ]);
1416
1417 $wrapperAttributes = '';
1418 $isShortcode = !empty($atts['is_shortcode']);
1419
1420 if ($isShortcode) {
1421 foreach ($cartAttributes as $attr => $value) {
1422 if ($value === '') {
1423 $wrapperAttributes .= esc_attr($attr) . ' ';
1424 } else {
1425 $wrapperAttributes .= sprintf('%s="%s" ', esc_attr($attr), esc_attr((string)$value));
1426 }
1427 }
1428 $wrapperAttributes = trim($wrapperAttributes);
1429 } else {
1430 $wrapperAttributes = get_block_wrapper_attributes($cartAttributes);
1431 }
1432
1433 ?>
1434
1435 <button <?php echo $wrapperAttributes; ?>
1436 aria-label="<?php echo esc_attr($addToCartText); ?>">
1437 <span class="text">
1438 <?php echo wp_kses_post($addToCartText); ?>
1439 </span>
1440 <span class="fluent-cart-loader" role="status">
1441 <svg aria-hidden="true"
1442 width="20"
1443 height="20"
1444 class="w-5 h-5 text-gray-200 animate-spin fill-blue-600"
1445 viewBox="0 0 100 101" fill="none" xmlns="http://www.w3.org/2000/svg">
1446 <path
1447 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"
1448 fill="currentColor"/>
1449 <path
1450 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"
1451 fill="currentFill"/>
1452 </svg>
1453 </span>
1454 </button>
1455
1456 <?php
1457 }
1458
1459 public static function renderNoProductFound()
1460 {
1461 ?>
1462 <div class="fluent-cart-shop-no-result-found" data-fluent-cart-shop-no-result-found role="status"
1463 aria-live="polite">
1464 <p class="has-text-align-center has-large-font-size m-0">
1465 <?php echo esc_html__('No Product Found!', 'fluent-cart'); ?>
1466 </p>
1467
1468 <p class="has-text-align-center">
1469 <?php echo esc_html__('You can try clearing any filters.', 'fluent-cart'); ?>
1470 </p>
1471 </div>
1472 <?php
1473 }
1474
1475 protected function renderVariationItem(ProductVariation $variant, $defaultId = '', $extraClasses = [])
1476 {
1477 $availableStocks = $variant->available;
1478 if (!$variant->manage_stock) {
1479 $availableStocks = 'unlimited';
1480 }
1481
1482 $comparePrice = $variant->compare_price;
1483 if ($comparePrice <= $variant->item_price) {
1484 $comparePrice = '';
1485 }
1486
1487 if ($comparePrice) {
1488 $comparePrice = Helper::toDecimal($comparePrice);
1489 }
1490
1491 $paymentType = Arr::get($variant->other_info, 'payment_type');
1492
1493 $itemClasses = [
1494 'fct-product-variant-item',
1495 'fct_price_type_' . $paymentType,
1496 'fct_variation_view_type_' . $this->viewType,
1497 ];
1498
1499 if ($variant->media_id) {
1500 $itemClasses[] = 'fct-item-has-image';
1501 }
1502
1503 if ($variant->id == $defaultId) {
1504 $itemClasses[] = 'selected';
1505 }
1506
1507 $priceSuffix = apply_filters('fluent_cart/product/price_suffix_atts', '', [
1508 'product' => $this->product,
1509 'variant' => $variant,
1510 'scope' => 'variant_item'
1511 ]);
1512
1513 $renderingAttributes = [
1514 'data-fluent-cart-product-variant' => '',
1515 'data-cart-id' => $variant->id,
1516 'data-item-stock' => $variant->isStock() ? 'in-stock' : 'out-of-stock',
1517 'data-default-variation-id' => $defaultId,
1518 'data-payment-type' => $paymentType,
1519 'data-available-stock' => $availableStocks,
1520 'data-item-price' => Helper::toDecimal($variant->item_price),
1521 'data-compare-price' => $comparePrice,
1522 'data-price-suffix' => $priceSuffix,
1523 'data-stock-management' => ModuleSettings::isActive('stock_management') ? 'yes' : 'no',
1524 'data-sku' => $variant->sku ?? '',
1525 'data-package-info' => $this->getVariantPackageInfoJson($variant),
1526 ];
1527
1528 if ($paymentType === 'subscription') {
1529 $renderingAttributes['data-subscription-terms'] = $variant->getSubscriptionTermsText(true);
1530 $repeatInterval = Arr::get($variant->other_info, 'repeat_interval', '');
1531 $hasInstallment = Arr::get($variant->other_info, 'has_installment') === 'yes';
1532
1533 $itemClasses[] = 'fct_sub_interval_' . $repeatInterval;
1534 if ($hasInstallment) {
1535 $itemClasses[] = 'fct_sub_has_installment';
1536 }
1537 }
1538
1539 if ($extraClasses) {
1540 $itemClasses = array_merge($itemClasses, $extraClasses);
1541 }
1542
1543 $itemClasses = array_filter($itemClasses);
1544 $renderingAttributes['class'] = implode(' ', $itemClasses);
1545
1546 $itemPrice = $variant->item_price;
1547 $comparePrice = $variant->compare_price;
1548 if (!$comparePrice || $comparePrice <= $itemPrice) {
1549 $comparePrice = 0;
1550 }
1551
1552 ?>
1553 <div
1554 <?php $this->renderAttributes($renderingAttributes); ?>
1555 role="radio"
1556 tabindex="<?php echo $variant->id == $defaultId ? '0' : '-1'; ?>"
1557 aria-checked="<?php echo $variant->id == $defaultId ? 'true' : 'false'; ?>"
1558 aria-label="<?php echo esc_attr($variant->variation_title); ?>"
1559 >
1560 <?php if ($this->viewType === 'image'): ?>
1561 <?php $this->renderTooltip($variant); ?>
1562 <?php endif; ?>
1563
1564 <div class="variant-content">
1565 <?php
1566 if ($this->viewType === 'both' || $this->viewType === 'image') {
1567 $this->renderVariantImage($variant);
1568 }
1569 ?>
1570 <?php
1571 if ($this->viewType === 'both' || $this->viewType === 'text') {
1572 echo '<div class="fct-product-variant-title" aria-label="' . esc_attr(__('Variant title', 'fluent-cart')) . '">' . esc_html($variant->variation_title) . '</div>';
1573 }
1574 ?>
1575 </div>
1576
1577 <?php if ($this->viewType === 'text' && $paymentType === 'subscription' && $this->columnType === 'one'): ?>
1578
1579 <?php $this->renderSubscriptionInfo($variant); ?>
1580 <?php endif; ?>
1581
1582 <?php if ($this->viewType === 'text' && $this->columnType === 'one'): ?>
1583 <div class="fct-product-variant-price">
1584 <?php if ($comparePrice): ?>
1585 <div class="fct-product-variant-compare-price">
1586 <del aria-label="<?php echo esc_attr(__('Original price', 'fluent-cart')); ?>">
1587 <span><?php echo esc_html(Helper::toDecimal($comparePrice)); ?></span></del>
1588 </div>
1589 <?php endif; ?>
1590 <div class="fct-product-variant-item-price"
1591 aria-label="<?php echo esc_attr(__('Current price', 'fluent-cart')); ?>">
1592 <span><?php echo esc_html(Helper::toDecimal($itemPrice)); ?></span>
1593 </div>
1594 </div>
1595 <?php endif; ?>
1596 </div>
1597 <?php
1598 }
1599
1600 protected function renderTooltip($variant)
1601 {
1602 ?>
1603 <div class="fct-product-variant-tooltip" role="tooltip" id="tooltip-<?php echo esc_attr($variant->id); ?>">
1604 <?php echo esc_html($variant->variation_title); ?>
1605 </div>
1606 <?php
1607 }
1608
1609 public function renderVariantImage($variant)
1610 {
1611 $image = $variant->thumbnail;
1612 if (!$image) {
1613 $image = Vite::getAssetUrl('images/placeholder.svg');
1614 }
1615 ?>
1616 <div class="fct-product-variant-image">
1617 <img role="img" alt="<?php echo esc_attr($variant->variation_title); ?>"
1618 src="<?php echo esc_url($image); ?>"/>
1619 </div>
1620 <?php
1621 }
1622
1623 protected function renderSubscriptionInfo($variant = null)
1624 {
1625
1626 if(!$variant){
1627 return '';
1628 }
1629 $info = $variant->getSubscriptionTermsText(true);
1630
1631 if (!$info) {
1632 return '';
1633 }
1634
1635 ?>
1636 <div class="fct-product-variant-payment-type" aria-live="polite">
1637 <div class="additional-info">
1638 <span><?php echo esc_html($info); ?></span>
1639 </div>
1640 </div>
1641 <?php
1642 }
1643
1644 protected function renderAttributes($atts = [])
1645 {
1646 foreach ($atts as $attr => $value) {
1647 if ($value !== '') {
1648 echo esc_attr($attr) . '="' . esc_attr((string)$value) . '" ';
1649 } else {
1650 echo esc_attr($attr) . ' ';
1651 }
1652 }
1653 }
1654
1655 protected function renderTab($atts = [])
1656 {
1657 ?>
1658 <div class="fct-product-tab" data-fluent-cart-product-tab>
1659 <?php $this->renderTabNav(); ?>
1660
1661 <div class="fct-product-tab-content" data-tab-contents>
1662 <?php $this->renderTabPane($atts); ?>
1663 </div>
1664 </div>
1665 <?php
1666
1667 }
1668
1669 protected function renderTabNav()
1670 {
1671 ?>
1672
1673 <div class="fct-product-tab-nav" role="tablist">
1674 <div class="tab-active-bar" data-tab-active-bar></div>
1675 <?php
1676 foreach ($this->paymentTypes as $typeKey => $typeLabel) : ?>
1677 <div
1678 class="fct-product-tab-nav-item <?php echo esc_attr($this->activeTab === $typeKey ? 'active' : ''); ?>"
1679 data-tab="<?php echo esc_attr($typeKey); ?>"
1680 role="tab"
1681 tabindex="<?php echo $this->activeTab === $typeKey ? '0' : '-1'; ?>"
1682 aria-selected="<?php echo $this->activeTab === $typeKey ? 'true' : 'false'; ?>"
1683 aria-controls="<?php echo esc_attr($typeKey); ?>"
1684 >
1685 <?php echo esc_html($typeLabel); ?>
1686 </div>
1687 <?php endforeach;
1688 ?>
1689 </div>
1690
1691 <?php
1692 }
1693
1694 protected function renderTabPane($atts = [])
1695 {
1696 $variantsClasses = [
1697 'fct-product-variants',
1698 'column-type-' . $this->columnType,
1699 Arr::get($atts, 'wrapper_class', ''),
1700 ];
1701
1702 foreach ($this->variantsByPaymentTypes as $variantKey => $variants): ?>
1703 <div
1704 data-tab-content
1705 id="<?php echo esc_attr($variantKey); ?>"
1706 class="fct-product-tab-pane <?php echo esc_attr($this->activeTab === $variantKey ? 'active' : ''); ?>"
1707 role="tabpanel"
1708 aria-labelledby="<?php echo esc_attr($variantKey); ?>"
1709 >
1710 <div class="<?php echo esc_attr(implode(' ', $variantsClasses)); ?>" role="radiogroup"
1711 aria-label="<?php esc_attr_e('Product Variants', 'fluent-cart'); ?>">
1712 <?php
1713 //Convert to collection safely before sorting
1714 $variants = (new Collection($variants))->sortBy('serial_index')->values();
1715
1716 foreach ($variants as $variant) {
1717 do_action('fluent_cart/product/single/before_variant_item', [
1718 'product' => $this->product,
1719 'variant' => $variant,
1720 'scope' => 'product_variant_item'
1721 ]);
1722
1723 $this->renderVariationItem($variant, $this->defaultVariationId);
1724
1725 do_action('fluent_cart/product/single/after_variant_item', [
1726 'product' => $this->product,
1727 'variant' => $variant,
1728 'scope' => 'product_variant_item'
1729 ]);
1730 }
1731 ?>
1732 </div>
1733
1734 </div>
1735 <?php endforeach; ?>
1736
1737 <?php
1738 }
1739
1740 protected function getDefaultVariantData()
1741 {
1742 if (empty($this->variants) || !$this->defaultVariationId) {
1743 return null;
1744 }
1745
1746 foreach ($this->variants as $variant) {
1747 if ($variant['id'] == $this->defaultVariationId) {
1748 return $variant;
1749 }
1750 }
1751
1752 return null;
1753 }
1754 }
1755