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

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

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