PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.4.1
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.4.1
1.6.6 1.6.5 1.6.4 1.6.3 1.6.2 1.6.1 1.6.0 1.5.4 1.5.5 1.5.3 1.5.2 1.5.1 1.5.0 1.4.2 1.4.1 1.4.0 1.3.28 1.3.27 1.3.26 1.3.25 1.3.23 1.3.22 1.3.21 1.3.20 1.3.19 All 49 releases
fluent-cart / app / Services / Renderer / ProductRenderer.php

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

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