PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.3.25
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.3.25
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 / ProductRenderer.php

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

1,751 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 $otherInfo = $variant->other_info ?: [];
630 $packageSlug = Arr::get($otherInfo, 'package_slug', '');
631 $package = Helper::getPackageBySlug($packageSlug);
632
633 if (!$package) {
634 return '';
635 }
636
637 static $storeWeightUnit = null;
638
639 if ($storeWeightUnit === null) {
640 $storeWeightUnit = Helper::shopConfig('weight_unit') ?: 'kg';
641 }
642
643 // Format dimensions
644 $length = Arr::get($package, 'length', '');
645 $width = Arr::get($package, 'width', '');
646 $height = Arr::get($package, 'height', '');
647 $dimensionUnit = Arr::get($package, 'dimension_unit', 'cm');
648 $dimensionParts = array_filter([$length, $width, $height], function ($val) {
649 return $val !== '' && $val !== null && $val != 0;
650 });
651 $formattedDimensions = $dimensionParts
652 ? implode(' × ', $dimensionParts) . ' ' . $dimensionUnit
653 : '';
654
655 // Calculate weights
656 $productWeight = floatval(Arr::get($otherInfo, 'weight', 0));
657 $productWeightUnit = Arr::get($otherInfo, 'weight_unit', $storeWeightUnit);
658 $convertedProductWeight = Helper::convertWeight($productWeight, $productWeightUnit, $storeWeightUnit);
659
660 $packageWeight = floatval(Arr::get($package, 'weight', 0));
661 $packageWeightUnit = Arr::get($package, 'weight_unit', $storeWeightUnit);
662 $convertedPackageWeight = Helper::convertWeight($packageWeight, $packageWeightUnit, $storeWeightUnit);
663 $totalWeight = $convertedProductWeight + $convertedPackageWeight;
664
665 // Format weights
666 $formattedProductWeight = $convertedProductWeight
667 ? rtrim(rtrim(number_format($convertedProductWeight, 2), '0'), '.') . ' ' . $storeWeightUnit
668 : '';
669
670 $formattedShippingWeight = ($totalWeight && $convertedPackageWeight)
671 ? rtrim(rtrim(number_format($totalWeight, 2), '0'), '.') . ' ' . $storeWeightUnit
672 : '';
673
674 return wp_json_encode([
675 'name' => Arr::get($package, 'name', ''),
676 'dimensions' => $formattedDimensions,
677 'product_weight' => $formattedProductWeight,
678 'shipping_weight' => $formattedShippingWeight,
679 ]);
680 }
681
682 public function renderExcerpt()
683 {
684 $excerpt = $this->product->post_excerpt;
685 if (!$excerpt) {
686 return;
687 }
688 ?>
689 <div class="fct-product-excerpt" aria-labelledby="fct-product-summary-title">
690 <p><?php echo wp_kses_post($excerpt); ?></p>
691 </div>
692 <?php
693
694 }
695
696 public function renderDescription()
697 {
698 $post = get_post($this->product->ID);
699 if (!$post || empty($post->post_content)) {
700 return;
701 }
702 ?>
703 <div class="fct-product-description">
704 <?php echo apply_filters('the_content', $post->post_content); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
705 </div>
706 <?php
707 }
708
709 public function renderPrices()
710 {
711 if ($this->product->detail->variation_type === 'simple') {
712 // we have to render for the simple product
713
714 $first_price = $this->product->variants()->first();
715
716 $itemPrice = $first_price ? $first_price->item_price : 0;
717 $itemPrice = apply_filters('fluent_cart/product/display_price', $itemPrice, [
718 'product' => $this->product,
719 'variation' => $first_price,
720 ]);
721 $itemPrice = (int)$itemPrice;
722 $comparePrice = $first_price ? (int)$first_price->compare_price : 0;
723 if ($comparePrice <= $itemPrice) {
724 $comparePrice = 0;
725 }
726 do_action('fluent_cart/product/single/before_price_block', [
727 'product' => $this->product,
728 'current_price' => $itemPrice,
729 'scope' => 'price_range'
730 ]);
731 ?>
732 <?php
733
734 if ($comparePrice) {
735 $aria_label = sprintf(
736 /* translators: 1: Original price, 2: Current item price */
737 __('Original Price: %1$s, Price: %2$s', 'fluent-cart'),
738 Helper::toDecimal($comparePrice),
739 Helper::toDecimal($itemPrice)
740 );
741 } else {
742 $aria_label = sprintf(
743 /* translators: 1: Current item price */
744 __('Price: %1$s', 'fluent-cart'),
745 Helper::toDecimal($itemPrice)
746 );
747 }
748
749 ?>
750 <div class="fct-price-range fct-product-prices" role="term"
751 aria-label="<?php echo esc_attr($aria_label); ?>">
752
753 <?php if ($comparePrice): ?>
754 <span class="fct-compare-price">
755 <del aria-label="<?php echo esc_attr(__('Original price', 'fluent-cart')); ?>"><?php echo esc_html(Helper::toDecimal($comparePrice)); ?></del>
756 </span>
757 <?php endif; ?>
758 <span class="fct-item-price" aria-label="<?php echo esc_attr(__('Current price', 'fluent-cart')); ?>">
759 <?php echo esc_html(Helper::toDecimal($itemPrice)); ?>
760 <?php do_action('fluent_cart/product/after_price', [
761 'product' => $this->product,
762 'current_price' => $itemPrice,
763 'scope' => 'price_range'
764 ]); ?>
765 </span>
766 </div>
767 <?php
768 do_action('fluent_cart/product/single/after_price_block', [
769 'product' => $this->product,
770 'current_price' => $itemPrice,
771 'scope' => 'price_range'
772 ]);
773 return;
774 }
775 $min_price = $this->product->detail->min_price;
776 $max_price = $this->product->detail->max_price;
777
778 do_action('fluent_cart/product/single/before_price_range_block', [
779 'product' => $this->product,
780 'current_price' => $min_price,
781 'scope' => 'price_range'
782 ]);
783 ?>
784 <?php
785 $aria_label = sprintf(
786 /* translators: 1: Minimum price, 2: Maximum price */
787 __('Price range: %1$s - %2$s', 'fluent-cart'),
788 Helper::toDecimal($min_price),
789 Helper::toDecimal($max_price)
790 );
791 ?>
792 <div class="fct-product-prices fct-price-range" role="term" aria-label="<?php echo esc_attr($aria_label); ?>">
793
794 <?php if ($max_price && $max_price != $min_price && $max_price > $min_price): ?>
795 <span class="fct-min-price"><?php echo esc_html(Helper::toDecimal($min_price)); ?></span>
796 <span class="fct-price-separator" aria-hidden="true">-</span>
797 <?php endif; ?>
798 <span class="fct-max-price">
799 <?php echo esc_html(Helper::toDecimal($max_price)); ?>
800 </span>
801
802 <?php do_action('fluent_cart/product/after_price', [
803 'product' => $this->product,
804 'current_price' => $min_price,
805 'scope' => 'price_range'
806 ]); ?>
807
808 </div>
809 <?php
810 do_action('fluent_cart/product/single/after_price_range_block', [
811 'product' => $this->product,
812 'current_price' => $min_price,
813 'scope' => 'price_range'
814 ]);
815 }
816
817 public function renderVariants($atts = [])
818 {
819 if ($this->product->detail->variation_type === 'simple') {
820 return;
821 }
822
823 $variants = $this->product->variants;
824 if (!$variants || $variants->isEmpty()) {
825 return;
826 }
827
828 // Sort by serial_index ascending
829 $variants = $variants->sortBy('serial_index')->values();
830
831 $classes = array_filter([
832 'fct-product-variants',
833 'column-type-' . $this->columnType,
834 Arr::get($atts, 'wrapper_class', ''),
835 ]);
836
837 ?>
838 <div class="<?php echo esc_attr(implode(' ', $classes)); ?>" role="radiogroup"
839 aria-label="<?php esc_attr_e('Product Variants', 'fluent-cart'); ?>">
840 <?php foreach ($variants as $variant) {
841 do_action('fluent_cart/product/single/before_variant_item', [
842 'product' => $this->product,
843 'variant' => $variant,
844 'scope' => 'product_variant_item'
845 ]);
846 $this->renderVariationItem($variant, $this->defaultVariationId);
847 do_action('fluent_cart/product/single/after_variant_item', [
848 'product' => $this->product,
849 'variant' => $variant,
850 'scope' => 'product_variant_item'
851 ]);
852 } ?>
853 </div>
854 <?php
855 }
856
857 public function renderItemPrice()
858 {
859 if ($this->product->detail->variation_type === 'simple' && !$this->hasSubscription) {
860 return; // for simple product we already rendered the price
861 }
862
863 do_action('fluent_cart/product/single/before_price_block', [
864 'product' => $this->product,
865 'current_price' => $this->defaultVariant ? $this->defaultVariant->item_price : 0,
866 'scope' => 'product_variant_price'
867 ]);
868
869 foreach ($this->product->variants as $variant) {
870 if ($this->shouldRenderPriceInPriceSection()) {
871 $this->renderVariantPricingWrapperStart($variant);
872 $paymentType = Arr::get($variant->other_info, 'payment_type', 'onetime');
873 if (!$this->hasSubscription) {
874 $this->renderVariationComparePrice($variant);
875 $this->applyVariationPriceFilter($variant, $paymentType);
876 } else {
877
878 $atts = [
879 'class' => 'fct-product-payment-type fluent-cart-product-variation-content' . ($this->defaultVariant->id != $variant->id ? ' is-hidden' : ''),
880 'data-fluent-cart-product-payment-type' => '',
881 'data-variation-id' => $variant->id
882 ];
883
884 $this->renderComparePriceWrapperStart($atts);
885 $this->renderVariationComparePrice($variant);
886 if ($paymentType === 'onetime') {
887 echo esc_html(Helper::toDecimal($variant->item_price));
888 } else {
889 $this->applyVariationPriceFilter($variant, $paymentType);
890 }
891 $this->renderComparePriceWrapperEnd();
892 }
893
894 $this->renderVariantPricingWrapperEnd();
895 }
896
897 }
898
899
900 foreach ($this->product->variants as $variant) {
901 $this->renderVariationsBundleProduct($variant);
902 }
903
904
905
906
907 do_action('fluent_cart/product/single/after_price_block', [
908 'product' => $this->product,
909 'current_price' => $this->defaultVariant ? $this->defaultVariant->item_price : 0,
910 'scope' => 'product_variant_price'
911 ]);
912 }
913
914 public function shouldRenderPriceInPriceSection(): bool
915 {
916 return !($this->viewType === 'text' && $this->columnType === 'one');
917 }
918
919 public function applyVariationPriceFilter($variant, $paymentType = 'onetime')
920 {
921 $priceText = $paymentType === 'onetime' ? Helper::toDecimal($variant->item_price) : $variant->getSubscriptionTermsText(true);
922 echo wp_kses_post(apply_filters('fluent_cart/single_product/variation_price', esc_html($priceText), [
923 'product' => $this->product,
924 'variant' => $variant,
925 'scope' => 'product_variant_price'
926 ]));
927 do_action('fluent_cart/product/after_price', [
928 'product' => $this->product,
929 'current_price' => $variant->item_price,
930 'scope' => 'product_variant_price'
931 ]);
932 }
933
934 public function renderComparePriceWrapperStart($atts = [])
935 {
936 ?>
937 <div <?php $this->renderAttributes($atts); ?> >
938 <?php
939 }
940
941 public function renderComparePriceWrapperEnd()
942 {
943 ?>
944 </div>
945 <?php
946 }
947
948 public function renderVariationComparePrice($variant)
949 {
950 if (!$variant->compare_price) {
951 return;
952 } ?>
953
954 <span class="fct-compare-price">
955 <del><?php echo esc_html(Helper::toDecimal($variant->compare_price)); ?></del>
956 </span>
957 <?php
958 }
959
960 public function renderVariantPricingWrapperStart($variant)
961 { ?>
962 <div
963 class="fct-product-item-price fluent-cart-product-variation-content <?php echo esc_attr($this->defaultVariant->id != $variant->id ? ' is-hidden' : ''); ?>"
964 data-fluent-cart-product-item-price
965 data-variation-id="<?php echo esc_attr($variant->id); ?>"
966 aria-live="polite"
967 role="status"
968 >
969 <?php }
970
971
972 public function renderVariantPricingWrapperEnd()
973 {
974 ?> </div> <?php
975 }
976
977 public function renderVariationsBundleProduct($variant)
978 {
979 if (count($variant->bundleChildren) == 0) {
980 return;
981 }
982
983 if(is_object($variant->bundleChildren)) {
984 $bundleProducts = $variant->bundleChildren->toArray();
985 }else{
986 $bundleProducts = $variant->bundleChildren;
987 }
988
989 $total = count($bundleProducts);
990 ?>
991 <div class="fluent-cart-product-variation-content fct-bundle-products <?php echo esc_attr($this->defaultVariant->id != $variant->id ? ' is-hidden' : ''); ?>"
992 data-variation-id="<?php echo esc_attr($variant->id); ?>"
993 data-fluent-cart-collapsibles
994 >
995 <h4 class="fct-bundle-products-title">
996 <?php echo esc_html__('Bundle of', 'fluent-cart') . ':'; ?>
997 </h4>
998
999 <div class="fct-bundle-products-list">
1000 <?php foreach (array_slice($bundleProducts, 0, 2) as $product): ?>
1001 <p>
1002 <?php echo esc_html(Arr::get($product, 'product.post_title')); ?> -
1003 <?php echo esc_html($product['variation_title']); ?>
1004 </p>
1005 <?php endforeach; ?>
1006
1007 <?php if($total > 2): ?>
1008 <div class="fct-bundle-products-more">
1009 <div class="fct-bundle-products-more-list">
1010 <?php foreach (array_slice($bundleProducts, 2) as $product): ?>
1011 <p>
1012 <?php echo esc_html(Arr::get($product, 'product.post_title')); ?> -
1013 <?php echo esc_html($product['variation_title']); ?>
1014 </p>
1015 <?php endforeach; ?>
1016 </div>
1017 </div>
1018 <?php endif;?>
1019 </div>
1020
1021 <?php if ($total > 2) : ?>
1022 <button type="button" class="fct-see-more-btn" data-fluent-cart-collapsible-toggle>
1023 <span class="see-more-text">
1024 <?php echo esc_html__('See More', 'fluent-cart'); ?>
1025 <svg xmlns="http://www.w3.org/2000/svg" width="12" height="7" viewBox="0 0 12 7" fill="none">
1026 <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"/>
1027 </svg>
1028 </span>
1029 <span class="see-less-text">
1030 <?php echo esc_html__('See Less', 'fluent-cart'); ?>
1031 <svg xmlns="http://www.w3.org/2000/svg" width="12" height="7" viewBox="0 0 14 8" fill="none">
1032 <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"/>
1033 </svg>
1034 </span>
1035 </button>
1036 <?php endif; ?>
1037 </div>
1038 <?php }
1039
1040 public function renderQuantity()
1041 {
1042 $soldIndividually = $this->product->soldIndividually();
1043
1044 if (!$this->hasOnetime || $soldIndividually) {
1045 return;
1046 }
1047
1048 $attributes = [
1049 'data-fluent-cart-product-quantity-container' => '',
1050 'data-cart-id' => $this->defaultVariant ? $this->defaultVariant->id : '',
1051 'data-variation-type' => $this->product->detail->variation_type,
1052 'data-payment-type' => 'onetime',
1053 'class' => 'fct-product-quantity-container'
1054 ];
1055
1056 $defaultVariantData = $this->getDefaultVariantData();
1057
1058 if ($this->hasSubscription && Arr::get($defaultVariantData, 'payment_type') !== 'onetime') {
1059 $attributes['class'] .= ' is-hidden';
1060 }
1061
1062 do_action('fluent_cart/product/single/before_quantity_block', [
1063 'product' => $this->product,
1064 'scope' => 'product_quantity_block'
1065 ]);
1066 ?>
1067 <div <?php $this->renderAttributes($attributes); ?>>
1068 <label for="fct-product-qty-input" class="quantity-title">
1069 <?php esc_html_e('Quantity', 'fluent-cart'); ?>
1070 </label>
1071
1072 <div class="fct-product-quantity">
1073 <button class="fct-quantity-decrease-button"
1074 data-fluent-cart-product-qty-decrease-button
1075 title="<?php esc_html_e('Decrease Quantity', 'fluent-cart'); ?>"
1076 aria-label="<?php esc_attr_e('Decrease Quantity', 'fluent-cart'); ?>"
1077 >
1078 <svg xmlns="http://www.w3.org/2000/svg" width="14" height="2" viewBox="0 0 14 2" fill="none">
1079 <path d="M12.3333 1L1.66659 1" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"
1080 stroke-linejoin="round"></path>
1081 </svg>
1082 </button>
1083
1084 <input
1085 id="fct-product-qty-input"
1086 min="1"
1087 <?php echo $soldIndividually ? 'max="1"' : ''; ?>
1088 class="fct-quantity-input"
1089 data-fluent-cart-single-product-page-product-quantity-input
1090 type="number"
1091 inputmode="numeric"
1092 pattern="[0-9]*"
1093 placeholder="<?php esc_attr_e('Quantity', 'fluent-cart'); ?>"
1094 value="1"
1095 aria-label="<?php esc_attr_e('Product quantity', 'fluent-cart'); ?>"
1096 />
1097
1098 <button class="fct-quantity-increase-button"
1099 data-fluent-cart-product-qty-increase-button
1100 title="<?php esc_attr_e('Increase Quantity', 'fluent-cart'); ?>"
1101 aria-label="<?php esc_attr_e('Increase Quantity', 'fluent-cart'); ?>"
1102 >
1103 <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 14 14" fill="none">
1104 <path d="M6.99996 1.66666L6.99996 12.3333M12.3333 6.99999L1.66663 6.99999" stroke="currentColor"
1105 stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path>
1106 </svg>
1107 </button>
1108 </div>
1109 </div>
1110 <?php
1111 do_action('fluent_cart/product/single/after_quantity_block', [
1112 'product' => $this->product,
1113 'scope' => 'product_quantity_block'
1114 ]);
1115 }
1116
1117 public function renderPurchaseButtons($atts = [])
1118 {
1119 $buyNowButtonAtts = $atts;
1120 $this->renderBuyNowButton($buyNowButtonAtts);
1121 $this->renderAddToCartButton($atts);
1122 }
1123
1124 public function renderBuyNowButton($atts = [])
1125 {
1126 // Stock management check using isStock() method
1127 // if (ModuleSettings::isActive('stock_management')) {
1128 // if ($this->product->detail->variation_type === 'simple' && $this->defaultVariant) {
1129 // if (!$this->defaultVariant->isStock()) {
1130 // echo '<span aria-disabled="true">' . esc_html__('Out of stock', 'fluent-cart') . '</span>';
1131 // return;
1132 // }
1133 // }
1134 // }
1135
1136 $defaults = [
1137 'buy_now_text' => __('Buy Now', 'fluent-cart'),
1138 'add_to_cart_text' => __('Add To Cart', 'fluent-cart'),
1139 ];
1140
1141 $atts = wp_parse_args($atts, $defaults);
1142
1143 $enableModalCheckout = Helper::isModalCheckoutEnabled();
1144
1145 $isInStock = true;
1146 if (ModuleSettings::isActive('stock_management')) {
1147 $isInStock = $this->product->isStock() && ($this->defaultVariant && $this->defaultVariant->isStock());
1148 }
1149
1150 $stockStatus = $isInStock ? 'in-stock' : 'out-of-stock';
1151
1152 $variationClass = 'fluent-cart-direct-checkout-button';
1153 if (!$isInStock) {
1154 $variationClass .= ' is-hidden';
1155 }
1156
1157 $buyNowAttributes = [
1158 'data-fluent-cart-direct-checkout-button' => '',
1159 'data-variation-type' => $this->product->detail->variation_type,
1160 'class' => $variationClass,
1161 'data-stock-availability' => $stockStatus,
1162 'data-quantity' => '1',
1163 'data-cart-id' => $this->defaultVariant ? $this->defaultVariant->id : '',
1164 'data-url' => site_url('?fluent-cart=instant_checkout&item_id='),
1165 ];
1166
1167 if ($isInStock) {
1168 $buyNowAttributes['href'] = site_url('?fluent-cart=instant_checkout&item_id=') . ($this->defaultVariant ? $this->defaultVariant->id : '') . '&quantity=1';
1169 }
1170
1171 if ($enableModalCheckout) {
1172 $buyNowAttributes['data-fct-instant-checkout-button'] = '';
1173 $buyNowAttributes['data-enable-modal-checkout'] = 'yes';
1174 }
1175
1176 $buyButtonText = apply_filters('fluent_cart/product/buy_now_button_text', $atts['buy_now_text'], [
1177 'product' => $this->product
1178 ]);
1179
1180 ?>
1181 <?php
1182 $variantTitle = $this->defaultVariant ? $this->defaultVariant->variation_title : '';
1183 $buyNowAriaLabel = $variantTitle
1184 ? sprintf(
1185 /* translators: 1: Button text (e.g. "Buy Now"), 2: Variant name */
1186 __('%1$s - %2$s', 'fluent-cart'),
1187 $buyButtonText,
1188 $variantTitle
1189 )
1190 : $buyButtonText;
1191 ?>
1192 <a <?php $this->renderAttributes($buyNowAttributes); ?> aria-label="<?php echo esc_attr($buyNowAriaLabel); ?>">
1193 <?php echo wp_kses_post($buyButtonText); ?>
1194 </a>
1195 <?php
1196 }
1197
1198 public function renderBuyNowButtonBlock($atts = [])
1199 {
1200 $text = Arr::get($atts, 'text', __('Buy Now', 'fluent-cart'));
1201 $variantIds = Arr::get($atts, 'variant_ids', []);
1202 $variantId = Arr::get($variantIds, 0);
1203
1204 $defaults = [
1205 'buy_now_text' => $text,
1206 'class' => '',
1207 'target' => '',
1208 'rel' => '',
1209 'is_shortcode' => false,
1210 ];
1211
1212 $atts = wp_parse_args($atts, $defaults);
1213
1214 $enableModalCheckout = Arr::get($atts, 'enable_modal_checkout', false);
1215
1216 $isInStock = true;
1217 if (ModuleSettings::isActive('stock_management')) {
1218 $isInStock = $this->product->isStock() && ($this->defaultVariant && $this->defaultVariant->isStock());
1219 }
1220 $stockStatus = $isInStock ? 'in-stock' : 'out-of-stock';
1221
1222 $checkoutUrl = add_query_arg([
1223 'fluent-cart' => $enableModalCheckout ? 'modal_checkout' : 'instant_checkout',
1224 'item_id' => $variantId ?? '',
1225 'quantity' => 1
1226 ], site_url());
1227
1228 $buyNowClass = trim('wp-block-button__link wp-element-button ' . Arr::get($atts, 'class', ''));
1229 if ($stockStatus === 'out-of-stock') {
1230 $buyNowClass .= ' out-of-stock';
1231 }
1232
1233 $buyNowAttributes = [
1234 'data-fluent-cart-direct-checkout-button' => '',
1235 'data-variation-type' => $this->product->detail->variation_type,
1236 'class' => $buyNowClass,
1237 'data-stock-availability' => $stockStatus,
1238 'data-quantity' => '1',
1239 'data-cart-id' => $variantId ?? '',
1240 'data-url' => $checkoutUrl,
1241 ];
1242
1243 if ($stockStatus === 'out-of-stock') {
1244 $buyNowAttributes['aria-disabled'] = 'true';
1245 } else {
1246 $buyNowAttributes['href'] = $checkoutUrl;
1247 }
1248
1249 $target = Arr::get($atts, 'target');
1250 if ($target) {
1251 $buyNowAttributes['target'] = $target;
1252 if (strtolower($target) === '_blank') {
1253 $buyNowAttributes['rel'] = Arr::get($atts, 'rel', 'noopener noreferrer');
1254 }
1255 }
1256 if ($enableModalCheckout) {
1257 $buyNowAttributes['data-fct-instant-checkout-button'] = '';
1258 $buyNowAttributes['data-enable-modal-checkout'] = 'yes';
1259 }
1260 $wrapperAttributes = '';
1261 $isShortcode = !empty($atts['is_shortcode']);
1262
1263 if ($isShortcode) {
1264 foreach ($buyNowAttributes as $attr => $value) {
1265 if ($value === '') {
1266 $wrapperAttributes .= esc_attr($attr) . ' ';
1267 } else {
1268 $wrapperAttributes .= sprintf('%s="%s" ', esc_attr($attr), esc_attr((string)$value));
1269 }
1270 }
1271 $wrapperAttributes = trim($wrapperAttributes);
1272 } else {
1273 $wrapperAttributes = get_block_wrapper_attributes($buyNowAttributes);
1274 }
1275
1276 $buyButtonText = apply_filters('fluent_cart/product/buy_now_button_text', $atts['buy_now_text'], [
1277 'product' => $this->product
1278 ]);
1279 ?>
1280 <a <?php echo($wrapperAttributes); ?> aria-label="<?php echo esc_attr($buyButtonText); ?>">
1281 <?php echo wp_kses_post($buyButtonText); ?>
1282 </a>
1283 <?php
1284 }
1285
1286 public function renderAddToCartButton($atts = [])
1287 {
1288 $defaults = [
1289 'buy_now_text' => __('Buy Now', 'fluent-cart'),
1290 'add_to_cart_text' => __('Add To Cart', 'fluent-cart'),
1291 ];
1292
1293 $atts = wp_parse_args($atts, $defaults);
1294
1295 $cartAttributes = [
1296 'data-fluent-cart-add-to-cart-button' => '',
1297 'data-cart-id' => $this->defaultVariant ? $this->defaultVariant->id : '',
1298 'data-product-id' => $this->product->ID,
1299 'class' => 'fluent-cart-add-to-cart-button',
1300 'data-variation-type' => $this->product->detail->variation_type,
1301 ];
1302
1303 $defaultVariantData = $this->getDefaultVariantData();
1304
1305 // If product is subscription-only, hide add-to-cart
1306 if ($this->hasSubscription && Arr::get($defaultVariantData, 'payment_type') !== 'onetime') {
1307 $cartAttributes['class'] .= ' is-hidden';
1308 }
1309
1310 // Check stock availability using both product-level and variant-level
1311 $isOutOfStock = false;
1312 if (ModuleSettings::isActive('stock_management')) {
1313 if (!$this->product->isStock() || ($this->defaultVariant && !$this->defaultVariant->isStock())) {
1314 $isOutOfStock = true;
1315 $cartAttributes['disabled'] = 'disabled';
1316 $cartAttributes['class'] .= ' out-of-stock';
1317 $cartAttributes['aria-disabled'] = 'true';
1318 $atts['add_to_cart_text'] = __('Not Available', 'fluent-cart');
1319 }
1320 }
1321
1322 $addToCartText = apply_filters('fluent_cart/product/add_to_cart_text', $atts['add_to_cart_text'], [
1323 'product' => $this->product
1324 ]);
1325 // Render add to cart if product supports onetime, or if out of stock (to show "Not Available")
1326 if ($this->hasOnetime || $isOutOfStock) :
1327 ?>
1328 <?php
1329 $variantTitle = $this->defaultVariant ? $this->defaultVariant->variation_title : '';
1330 $addToCartAriaLabel = $variantTitle
1331 ? sprintf(
1332 /* translators: 1: Button text (e.g. "Add To Cart"), 2: Variant name */
1333 __('%1$s - %2$s', 'fluent-cart'),
1334 $addToCartText,
1335 $variantTitle
1336 )
1337 : $addToCartText;
1338 ?>
1339 <button <?php $this->renderAttributes($cartAttributes); ?>
1340 aria-label="<?php echo esc_attr($addToCartAriaLabel); ?>">
1341 <span class="text">
1342 <?php echo wp_kses_post($addToCartText); ?>
1343 </span>
1344 <span class="fluent-cart-loader" role="status">
1345 <svg aria-hidden="true"
1346 width="20"
1347 height="20"
1348 class="w-5 h-5 text-gray-200 animate-spin fill-blue-600"
1349 viewBox="0 0 100 101" fill="none" xmlns="http://www.w3.org/2000/svg">
1350 <path
1351 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"
1352 fill="currentColor"/>
1353 <path
1354 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"
1355 fill="currentFill"/>
1356 </svg>
1357 </span>
1358 </button>
1359 <?php
1360 endif;
1361 }
1362
1363 public function renderAddToCartButtonBlock($atts = [])
1364 {
1365
1366 $text = Arr::get($atts, 'text', __('Add To Cart', 'fluent-cart'));
1367 $extraClass = trim(Arr::get($atts, 'class', ''));
1368
1369 $defaults = [
1370 'add_to_cart_text' => $text,
1371 ];
1372
1373 $atts = wp_parse_args($atts, $defaults);
1374
1375 $cartAttributes = [
1376 'data-fluent-cart-add-to-cart-button' => '',
1377 'class' => 'wp-block-button__link wp-element-button fct-loader',
1378 'data-cart-id' => $this->defaultVariant ? $this->defaultVariant->id : '',
1379 'data-product-id' => $this->product->ID,
1380 'data-variation-type' => $this->product->detail->variation_type,
1381 ];
1382
1383 if ($extraClass) {
1384 $cartAttributes['class'] .= ' ' . $extraClass;
1385 }
1386
1387 // If the product does NOT support one-time purchase
1388 if (!$this->hasOnetime) {
1389 if (Helper::isAdminUser()) {
1390 $view = '<p class="fct-admin-notice">' . esc_html__('Add to Cart is not supported for subscription product', 'fluent-cart') . '</p>';
1391
1392 FrontendView::make('', $view);
1393 return;
1394 }
1395
1396 return;
1397 }
1398
1399 // Check stock availability using both product-level and variant-level
1400 if (ModuleSettings::isActive('stock_management')) {
1401 if (!$this->product->isStock() || ($this->defaultVariant && !$this->defaultVariant->isStock())) {
1402 $cartAttributes['disabled'] = 'disabled';
1403 $cartAttributes['class'] .= ' out-of-stock';
1404 $cartAttributes['aria-disabled'] = 'true';
1405 $atts['add_to_cart_text'] = __('Not Available', 'fluent-cart');
1406 }
1407 }
1408
1409 $addToCartText = apply_filters('fluent_cart/product/add_to_cart_text', $atts['add_to_cart_text'], [
1410 'product' => $this->product
1411 ]);
1412
1413 $wrapperAttributes = '';
1414 $isShortcode = !empty($atts['is_shortcode']);
1415
1416 if ($isShortcode) {
1417 foreach ($cartAttributes as $attr => $value) {
1418 if ($value === '') {
1419 $wrapperAttributes .= esc_attr($attr) . ' ';
1420 } else {
1421 $wrapperAttributes .= sprintf('%s="%s" ', esc_attr($attr), esc_attr((string)$value));
1422 }
1423 }
1424 $wrapperAttributes = trim($wrapperAttributes);
1425 } else {
1426 $wrapperAttributes = get_block_wrapper_attributes($cartAttributes);
1427 }
1428
1429 ?>
1430
1431 <button <?php echo $wrapperAttributes; ?>
1432 aria-label="<?php echo esc_attr($addToCartText); ?>">
1433 <span class="text">
1434 <?php echo wp_kses_post($addToCartText); ?>
1435 </span>
1436 <span class="fluent-cart-loader" role="status">
1437 <svg aria-hidden="true"
1438 width="20"
1439 height="20"
1440 class="w-5 h-5 text-gray-200 animate-spin fill-blue-600"
1441 viewBox="0 0 100 101" fill="none" xmlns="http://www.w3.org/2000/svg">
1442 <path
1443 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"
1444 fill="currentColor"/>
1445 <path
1446 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"
1447 fill="currentFill"/>
1448 </svg>
1449 </span>
1450 </button>
1451
1452 <?php
1453 }
1454
1455 public static function renderNoProductFound()
1456 {
1457 ?>
1458 <div class="fluent-cart-shop-no-result-found" data-fluent-cart-shop-no-result-found role="status"
1459 aria-live="polite">
1460 <p class="has-text-align-center has-large-font-size m-0">
1461 <?php echo esc_html__('No Product Found!', 'fluent-cart'); ?>
1462 </p>
1463
1464 <p class="has-text-align-center">
1465 <?php echo esc_html__('You can try clearing any filters.', 'fluent-cart'); ?>
1466 </p>
1467 </div>
1468 <?php
1469 }
1470
1471 protected function renderVariationItem(ProductVariation $variant, $defaultId = '', $extraClasses = [])
1472 {
1473 $availableStocks = $variant->available;
1474 if (!$variant->manage_stock) {
1475 $availableStocks = 'unlimited';
1476 }
1477
1478 $comparePrice = $variant->compare_price;
1479 if ($comparePrice <= $variant->item_price) {
1480 $comparePrice = '';
1481 }
1482
1483 if ($comparePrice) {
1484 $comparePrice = Helper::toDecimal($comparePrice);
1485 }
1486
1487 $paymentType = Arr::get($variant->other_info, 'payment_type');
1488
1489 $itemClasses = [
1490 'fct-product-variant-item',
1491 'fct_price_type_' . $paymentType,
1492 'fct_variation_view_type_' . $this->viewType,
1493 ];
1494
1495 if ($variant->media_id) {
1496 $itemClasses[] = 'fct-item-has-image';
1497 }
1498
1499 if ($variant->id == $defaultId) {
1500 $itemClasses[] = 'selected';
1501 }
1502
1503 $priceSuffix = apply_filters('fluent_cart/product/price_suffix_atts', '', [
1504 'product' => $this->product,
1505 'variant' => $variant,
1506 'scope' => 'variant_item'
1507 ]);
1508
1509 $renderingAttributes = [
1510 'data-fluent-cart-product-variant' => '',
1511 'data-cart-id' => $variant->id,
1512 'data-item-stock' => $variant->isStock() ? 'in-stock' : 'out-of-stock',
1513 'data-default-variation-id' => $defaultId,
1514 'data-payment-type' => $paymentType,
1515 'data-available-stock' => $availableStocks,
1516 'data-item-price' => Helper::toDecimal($variant->item_price),
1517 'data-compare-price' => $comparePrice,
1518 'data-price-suffix' => $priceSuffix,
1519 'data-stock-management' => ModuleSettings::isActive('stock_management') ? 'yes' : 'no',
1520 'data-sku' => $variant->sku ?? '',
1521 'data-package-info' => $this->getVariantPackageInfoJson($variant),
1522 ];
1523
1524 if ($paymentType === 'subscription') {
1525 $renderingAttributes['data-subscription-terms'] = $variant->getSubscriptionTermsText(true);
1526 $repeatInterval = Arr::get($variant->other_info, 'repeat_interval', '');
1527 $hasInstallment = Arr::get($variant->other_info, 'has_installment') === 'yes';
1528
1529 $itemClasses[] = 'fct_sub_interval_' . $repeatInterval;
1530 if ($hasInstallment) {
1531 $itemClasses[] = 'fct_sub_has_installment';
1532 }
1533 }
1534
1535 if ($extraClasses) {
1536 $itemClasses = array_merge($itemClasses, $extraClasses);
1537 }
1538
1539 $itemClasses = array_filter($itemClasses);
1540 $renderingAttributes['class'] = implode(' ', $itemClasses);
1541
1542 $itemPrice = $variant->item_price;
1543 $comparePrice = $variant->compare_price;
1544 if (!$comparePrice || $comparePrice <= $itemPrice) {
1545 $comparePrice = 0;
1546 }
1547
1548 ?>
1549 <div
1550 <?php $this->renderAttributes($renderingAttributes); ?>
1551 role="radio"
1552 tabindex="<?php echo $variant->id == $defaultId ? '0' : '-1'; ?>"
1553 aria-checked="<?php echo $variant->id == $defaultId ? 'true' : 'false'; ?>"
1554 aria-label="<?php echo esc_attr($variant->variation_title); ?>"
1555 >
1556 <?php if ($this->viewType === 'image'): ?>
1557 <?php $this->renderTooltip($variant); ?>
1558 <?php endif; ?>
1559
1560 <div class="variant-content">
1561 <?php
1562 if ($this->viewType === 'both' || $this->viewType === 'image') {
1563 $this->renderVariantImage($variant);
1564 }
1565 ?>
1566 <?php
1567 if ($this->viewType === 'both' || $this->viewType === 'text') {
1568 echo '<div class="fct-product-variant-title" aria-label="' . esc_attr(__('Variant title', 'fluent-cart')) . '">' . esc_html($variant->variation_title) . '</div>';
1569 }
1570 ?>
1571 </div>
1572
1573 <?php if ($this->viewType === 'text' && $paymentType === 'subscription' && $this->columnType === 'one'): ?>
1574
1575 <?php $this->renderSubscriptionInfo($variant); ?>
1576 <?php endif; ?>
1577
1578 <?php if ($this->viewType === 'text' && $this->columnType === 'one'): ?>
1579 <div class="fct-product-variant-price">
1580 <?php if ($comparePrice): ?>
1581 <div class="fct-product-variant-compare-price">
1582 <del aria-label="<?php echo esc_attr(__('Original price', 'fluent-cart')); ?>">
1583 <span><?php echo esc_html(Helper::toDecimal($comparePrice)); ?></span></del>
1584 </div>
1585 <?php endif; ?>
1586 <div class="fct-product-variant-item-price"
1587 aria-label="<?php echo esc_attr(__('Current price', 'fluent-cart')); ?>">
1588 <span><?php echo esc_html(Helper::toDecimal($itemPrice)); ?></span>
1589 </div>
1590 </div>
1591 <?php endif; ?>
1592 </div>
1593 <?php
1594 }
1595
1596 protected function renderTooltip($variant)
1597 {
1598 ?>
1599 <div class="fct-product-variant-tooltip" role="tooltip" id="tooltip-<?php echo esc_attr($variant->id); ?>">
1600 <?php echo esc_html($variant->variation_title); ?>
1601 </div>
1602 <?php
1603 }
1604
1605 public function renderVariantImage($variant)
1606 {
1607 $image = $variant->thumbnail;
1608 if (!$image) {
1609 $image = Vite::getAssetUrl('images/placeholder.svg');
1610 }
1611 ?>
1612 <div class="fct-product-variant-image">
1613 <img role="img" alt="<?php echo esc_attr($variant->variation_title); ?>"
1614 src="<?php echo esc_url($image); ?>"/>
1615 </div>
1616 <?php
1617 }
1618
1619 protected function renderSubscriptionInfo($variant = null)
1620 {
1621
1622 if(!$variant){
1623 return '';
1624 }
1625 $info = $variant->getSubscriptionTermsText(true);
1626
1627 if (!$info) {
1628 return '';
1629 }
1630
1631 ?>
1632 <div class="fct-product-variant-payment-type" aria-live="polite">
1633 <div class="additional-info">
1634 <span><?php echo esc_html($info); ?></span>
1635 </div>
1636 </div>
1637 <?php
1638 }
1639
1640 protected function renderAttributes($atts = [])
1641 {
1642 foreach ($atts as $attr => $value) {
1643 if ($value !== '') {
1644 echo esc_attr($attr) . '="' . esc_attr((string)$value) . '" ';
1645 } else {
1646 echo esc_attr($attr) . ' ';
1647 }
1648 }
1649 }
1650
1651 protected function renderTab($atts = [])
1652 {
1653 ?>
1654 <div class="fct-product-tab" data-fluent-cart-product-tab>
1655 <?php $this->renderTabNav(); ?>
1656
1657 <div class="fct-product-tab-content" data-tab-contents>
1658 <?php $this->renderTabPane($atts); ?>
1659 </div>
1660 </div>
1661 <?php
1662
1663 }
1664
1665 protected function renderTabNav()
1666 {
1667 ?>
1668
1669 <div class="fct-product-tab-nav" role="tablist">
1670 <div class="tab-active-bar" data-tab-active-bar></div>
1671 <?php
1672 foreach ($this->paymentTypes as $typeKey => $typeLabel) : ?>
1673 <div
1674 class="fct-product-tab-nav-item <?php echo esc_attr($this->activeTab === $typeKey ? 'active' : ''); ?>"
1675 data-tab="<?php echo esc_attr($typeKey); ?>"
1676 role="tab"
1677 tabindex="<?php echo $this->activeTab === $typeKey ? '0' : '-1'; ?>"
1678 aria-selected="<?php echo $this->activeTab === $typeKey ? 'true' : 'false'; ?>"
1679 aria-controls="<?php echo esc_attr($typeKey); ?>"
1680 >
1681 <?php echo esc_html($typeLabel); ?>
1682 </div>
1683 <?php endforeach;
1684 ?>
1685 </div>
1686
1687 <?php
1688 }
1689
1690 protected function renderTabPane($atts = [])
1691 {
1692 $variantsClasses = [
1693 'fct-product-variants',
1694 'column-type-' . $this->columnType,
1695 Arr::get($atts, 'wrapper_class', ''),
1696 ];
1697
1698 foreach ($this->variantsByPaymentTypes as $variantKey => $variants): ?>
1699 <div
1700 data-tab-content
1701 id="<?php echo esc_attr($variantKey); ?>"
1702 class="fct-product-tab-pane <?php echo esc_attr($this->activeTab === $variantKey ? 'active' : ''); ?>"
1703 role="tabpanel"
1704 aria-labelledby="<?php echo esc_attr($variantKey); ?>"
1705 >
1706 <div class="<?php echo esc_attr(implode(' ', $variantsClasses)); ?>" role="radiogroup"
1707 aria-label="<?php esc_attr_e('Product Variants', 'fluent-cart'); ?>">
1708 <?php
1709 //Convert to collection safely before sorting
1710 $variants = (new Collection($variants))->sortBy('serial_index')->values();
1711
1712 foreach ($variants as $variant) {
1713 do_action('fluent_cart/product/single/before_variant_item', [
1714 'product' => $this->product,
1715 'variant' => $variant,
1716 'scope' => 'product_variant_item'
1717 ]);
1718
1719 $this->renderVariationItem($variant, $this->defaultVariationId);
1720
1721 do_action('fluent_cart/product/single/after_variant_item', [
1722 'product' => $this->product,
1723 'variant' => $variant,
1724 'scope' => 'product_variant_item'
1725 ]);
1726 }
1727 ?>
1728 </div>
1729
1730 </div>
1731 <?php endforeach; ?>
1732
1733 <?php
1734 }
1735
1736 protected function getDefaultVariantData()
1737 {
1738 if (empty($this->variants) || !$this->defaultVariationId) {
1739 return null;
1740 }
1741
1742 foreach ($this->variants as $variant) {
1743 if ($variant['id'] == $this->defaultVariationId) {
1744 return $variant;
1745 }
1746 }
1747
1748 return null;
1749 }
1750 }
1751