PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.3.28
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.3.28
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.28, at app/Services/Renderer/ProductRenderer.php

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