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

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

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