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

1,988 lines 81.4 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 (!$label) {
750 $label = __('SKU:', 'fluent-cart');
751 }
752
753 $labelHtml = '';
754 if ($showLabel && $label) {
755 $labelHtml = sprintf('<span class="fct-product-sku__label">%s</span> ', esc_html($label));
756 }
757
758 if ($variant) {
759 if (empty($variant->sku)) {
760 return;
761 }
762 echo sprintf(
763 '<div class="fct-product-sku">
764 <div %s>
765 %s<span class="fct-product-sku__value" data-fluent-cart-product-sku>%s</span>
766 </div>
767 </div>',
768 $wrapper_attributes, // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
769 $labelHtml,
770 esc_html($variant->sku)
771 );
772 return;
773 }
774
775 foreach ($this->product->variants as $v) {
776 if (empty($v->sku)) {
777 continue;
778 }
779 $isHidden = ($this->defaultVariant && $this->defaultVariant->id != $v->id) ? ' is-hidden' : '';
780 echo sprintf(
781 '<div class="fct-product-sku fluent-cart-product-variation-content%s" data-variation-id="%s">
782 <div %s>
783 %s<span class="fct-product-sku__value" data-fluent-cart-product-sku>%s</span>
784 </div>
785 </div>',
786 $isHidden,
787 esc_attr($v->id),
788 $wrapper_attributes, // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
789 $labelHtml,
790 esc_html($v->sku)
791 );
792 }
793 }
794
795 public function renderPackageDescription($wrapper_attributes = '', $showName = true, $showDimensions = true, $showProductWeight = true, $showTotalWeight = true, $variant = null)
796 {
797 if ($variant) {
798 if (!$wrapper_attributes) {
799 $wrapper_attributes = 'class="fct-package-description" data-fluent-cart-package-description';
800 }
801 (new ProductCardRender($this->product))->renderPackageDescription($wrapper_attributes, $showName, $showDimensions, $showProductWeight, $showTotalWeight, $variant);
802 return;
803 }
804
805 foreach ($this->product->variants as $v) {
806 $isHidden = ($this->defaultVariant && $this->defaultVariant->id != $v->id) ? ' is-hidden' : '';
807 $variantWrapper = 'class="fct-package-description fluent-cart-product-variation-content' . $isHidden . '" data-fluent-cart-package-description data-variation-id="' . esc_attr($v->id) . '"';
808 (new ProductCardRender($this->product))->renderPackageDescription($variantWrapper, $showName, $showDimensions, $showProductWeight, $showTotalWeight, $v);
809 }
810 }
811
812 /**
813 * Build a JSON string of package info for a variant (used as data attribute for JS switching).
814 */
815 private function getVariantPackageInfoJson(ProductVariation $variant)
816 {
817 if ($variant->fulfillment_type !== 'physical') {
818 return '';
819 }
820
821 $otherInfo = $variant->other_info ?: [];
822 $packageSlug = Arr::get($otherInfo, 'package_slug', '');
823 $package = Helper::getPackageBySlug($packageSlug);
824
825 if (!$package) {
826 return '';
827 }
828
829 static $storeWeightUnit = null;
830
831 if ($storeWeightUnit === null) {
832 $storeWeightUnit = Helper::shopConfig('weight_unit') ?: 'kg';
833 }
834
835 // Format dimensions
836 $length = Arr::get($package, 'length', '');
837 $width = Arr::get($package, 'width', '');
838 $height = Arr::get($package, 'height', '');
839 $dimensionUnit = Arr::get($package, 'dimension_unit', 'cm');
840 $dimensionParts = array_filter([$length, $width, $height], function ($val) {
841 return $val !== '' && $val !== null && $val != 0;
842 });
843 $formattedDimensions = $dimensionParts
844 ? implode(' × ', $dimensionParts) . ' ' . $dimensionUnit
845 : '';
846
847 // Calculate weights
848 $productWeight = floatval(Arr::get($otherInfo, 'weight', 0));
849 $productWeightUnit = Arr::get($otherInfo, 'weight_unit', $storeWeightUnit);
850 $convertedProductWeight = Helper::convertWeight($productWeight, $productWeightUnit, $storeWeightUnit);
851
852 $packageWeight = floatval(Arr::get($package, 'weight', 0));
853 $packageWeightUnit = Arr::get($package, 'weight_unit', $storeWeightUnit);
854 $convertedPackageWeight = Helper::convertWeight($packageWeight, $packageWeightUnit, $storeWeightUnit);
855 $totalWeight = $convertedProductWeight + $convertedPackageWeight;
856
857 // Format weights
858 $formattedProductWeight = $convertedProductWeight
859 ? rtrim(rtrim(number_format($convertedProductWeight, 2), '0'), '.') . ' ' . $storeWeightUnit
860 : '';
861
862 $formattedShippingWeight = ($totalWeight && $convertedPackageWeight)
863 ? rtrim(rtrim(number_format($totalWeight, 2), '0'), '.') . ' ' . $storeWeightUnit
864 : '';
865
866 return wp_json_encode([
867 'name' => Arr::get($package, 'name', ''),
868 'dimensions' => $formattedDimensions,
869 'product_weight' => $formattedProductWeight,
870 'shipping_weight' => $formattedShippingWeight,
871 ]);
872 }
873
874 public function renderExcerpt()
875 {
876 $excerpt = $this->product->post_excerpt;
877 if (!$excerpt) {
878 return;
879 }
880 ?>
881 <div class="fct-product-excerpt" aria-labelledby="fct-product-summary-title">
882 <p><?php echo wp_kses_post($excerpt); ?></p>
883 </div>
884 <?php
885
886 }
887
888 public function renderDescription()
889 {
890 $productPost = get_post($this->product->ID);
891 if (!$productPost || empty($productPost->post_content)) {
892 return;
893 }
894
895 global $post;
896 $originalPost = $post;
897 $post = $productPost;
898 setup_postdata($post);
899
900 $content = apply_filters('the_content', $productPost->post_content);
901
902 $post = $originalPost;
903 if ($originalPost) {
904 setup_postdata($originalPost);
905 } else {
906 wp_reset_postdata();
907 }
908 ?>
909 <div class="fct-product-description">
910 <?php echo $content; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
911 </div>
912 <?php
913 }
914
915 public function renderPrices()
916 {
917 if ($this->product->detail->variation_type === 'simple') {
918 // we have to render for the simple product
919
920 $first_price = $this->product->variants()->first();
921
922 $itemPrice = $first_price ? $first_price->item_price : 0;
923 $itemPrice = apply_filters('fluent_cart/product/display_price', $itemPrice, [
924 'product' => $this->product,
925 'variation' => $first_price,
926 ]);
927 $itemPrice = (int)$itemPrice;
928 $comparePrice = $first_price ? (int)$first_price->compare_price : 0;
929 if ($comparePrice <= $itemPrice) {
930 $comparePrice = 0;
931 }
932 do_action('fluent_cart/product/single/before_price_block', [
933 'product' => $this->product,
934 'current_price' => $itemPrice,
935 'scope' => 'price_range'
936 ]);
937 ?>
938 <?php
939
940 if ($comparePrice) {
941 $aria_label = sprintf(
942 /* translators: 1: Original price, 2: Current item price */
943 __('Original Price: %1$s, Price: %2$s', 'fluent-cart'),
944 Helper::toDecimal($comparePrice),
945 Helper::toDecimal($itemPrice)
946 );
947 } else {
948 $aria_label = sprintf(
949 /* translators: 1: Current item price */
950 __('Price: %1$s', 'fluent-cart'),
951 Helper::toDecimal($itemPrice)
952 );
953 }
954
955 ?>
956 <div class="fct-price-range fct-product-prices" role="term"
957 aria-label="<?php echo esc_attr($aria_label); ?>">
958
959 <?php if ($comparePrice): ?>
960 <span class="fct-compare-price">
961 <del aria-label="<?php echo esc_attr(__('Original price', 'fluent-cart')); ?>"><?php echo esc_html(Helper::toDecimal($comparePrice)); ?></del>
962 </span>
963 <?php endif; ?>
964 <span class="fct-item-price" aria-label="<?php echo esc_attr(__('Current price', 'fluent-cart')); ?>">
965 <?php echo esc_html(Helper::toDecimal($itemPrice)); ?>
966 <?php do_action('fluent_cart/product/after_price', [
967 'product' => $this->product,
968 'current_price' => $itemPrice,
969 'scope' => 'price_range'
970 ]); ?>
971 </span>
972 </div>
973 <?php
974 do_action('fluent_cart/product/single/after_price_block', [
975 'product' => $this->product,
976 'current_price' => $itemPrice,
977 'scope' => 'price_range'
978 ]);
979 return;
980 }
981 $min_price = $this->product->detail->min_price;
982 $max_price = $this->product->detail->max_price;
983
984 do_action('fluent_cart/product/single/before_price_range_block', [
985 'product' => $this->product,
986 'current_price' => $min_price,
987 'scope' => 'price_range'
988 ]);
989 ?>
990 <?php
991 $aria_label = sprintf(
992 /* translators: 1: Minimum price, 2: Maximum price */
993 __('Price range: %1$s - %2$s', 'fluent-cart'),
994 Helper::toDecimal($min_price),
995 Helper::toDecimal($max_price)
996 );
997 ?>
998 <div class="fct-product-prices fct-price-range" role="term" aria-label="<?php echo esc_attr($aria_label); ?>">
999
1000 <?php if ($max_price && $max_price != $min_price && $max_price > $min_price): ?>
1001 <span class="fct-min-price"><?php echo esc_html(Helper::toDecimal($min_price)); ?></span>
1002 <span class="fct-price-separator" aria-hidden="true">-</span>
1003 <?php endif; ?>
1004 <span class="fct-max-price">
1005 <?php echo esc_html(Helper::toDecimal($max_price)); ?>
1006 </span>
1007
1008 <?php do_action('fluent_cart/product/after_price', [
1009 'product' => $this->product,
1010 'current_price' => $min_price,
1011 'scope' => 'price_range'
1012 ]); ?>
1013
1014 </div>
1015 <?php
1016 do_action('fluent_cart/product/single/after_price_range_block', [
1017 'product' => $this->product,
1018 'current_price' => $min_price,
1019 'scope' => 'price_range'
1020 ]);
1021 }
1022
1023 public function renderVariants($atts = [])
1024 {
1025 if ($this->product->detail->variation_type === 'simple') {
1026 return;
1027 }
1028
1029 $variants = $this->product->variants;
1030 if (!$variants || $variants->isEmpty()) {
1031 return;
1032 }
1033
1034 // Sort by serial_index ascending
1035 $variants = $variants->sortBy('serial_index')->values();
1036
1037 $classes = array_filter([
1038 'fct-product-variants',
1039 'column-type-' . $this->columnType,
1040 Arr::get($atts, 'wrapper_class', ''),
1041 ]);
1042
1043 ?>
1044 <div class="<?php echo esc_attr(implode(' ', $classes)); ?>" role="radiogroup"
1045 aria-label="<?php esc_attr_e('Product Variants', 'fluent-cart'); ?>">
1046 <?php foreach ($variants as $variant) {
1047 do_action('fluent_cart/product/single/before_variant_item', [
1048 'product' => $this->product,
1049 'variant' => $variant,
1050 'scope' => 'product_variant_item'
1051 ]);
1052 $this->renderVariationItem($variant, $this->defaultVariationId);
1053 do_action('fluent_cart/product/single/after_variant_item', [
1054 'product' => $this->product,
1055 'variant' => $variant,
1056 'scope' => 'product_variant_item'
1057 ]);
1058 } ?>
1059 </div>
1060 <?php
1061 }
1062
1063 public function renderItemPrice()
1064 {
1065 if ($this->product->detail->variation_type === 'simple' && !$this->hasSubscription) {
1066 return; // for simple product we already rendered the price
1067 }
1068
1069 do_action('fluent_cart/product/single/before_price_block', [
1070 'product' => $this->product,
1071 'current_price' => $this->defaultVariant ? $this->defaultVariant->item_price : 0,
1072 'scope' => 'product_variant_price'
1073 ]);
1074
1075 foreach ($this->product->variants as $variant) {
1076 if ($this->shouldRenderPriceInPriceSection()) {
1077 $this->renderVariantPricingWrapperStart($variant);
1078 $paymentType = Arr::get($variant->other_info, 'payment_type', 'onetime');
1079 if (!$this->hasSubscription) {
1080 $this->renderVariationComparePrice($variant);
1081 $this->applyVariationPriceFilter($variant, $paymentType);
1082 } else {
1083
1084 $atts = [
1085 'class' => 'fct-product-payment-type fluent-cart-product-variation-content' . ($this->defaultVariant->id != $variant->id ? ' is-hidden' : ''),
1086 'data-fluent-cart-product-payment-type' => '',
1087 'data-variation-id' => $variant->id
1088 ];
1089
1090 $this->renderComparePriceWrapperStart($atts);
1091 $this->renderVariationComparePrice($variant);
1092 $this->applyVariationPriceFilter($variant, $paymentType);
1093 $this->renderComparePriceWrapperEnd();
1094 }
1095
1096 $this->renderVariantPricingWrapperEnd();
1097 }
1098
1099 }
1100
1101
1102 foreach ($this->product->variants as $variant) {
1103 $this->renderVariationsBundleProduct($variant);
1104 }
1105
1106
1107
1108
1109 do_action('fluent_cart/product/single/after_price_block', [
1110 'product' => $this->product,
1111 'current_price' => $this->defaultVariant ? $this->defaultVariant->item_price : 0,
1112 'scope' => 'product_variant_price'
1113 ]);
1114 }
1115
1116 public function shouldRenderPriceInPriceSection(): bool
1117 {
1118 // simple_variations keeps the legacy inline price flow for the
1119 // one-column layouts that render the variant title (text-only and
1120 // image+text), where the price sits on each variant row.
1121 // Every other variation type always renders the price in the dedicated
1122 // below-block so the data-fluent-cart-product-item-price element keeps
1123 // a consistent position across every view/column combination.
1124 if ($this->product->detail->variation_type === Helper::PRODUCT_TYPE_SIMPLE_VARIATION) {
1125 $isInlineRowLayout = in_array($this->viewType, ['text', 'both'], true)
1126 && $this->columnType === 'one';
1127
1128 return !$isInlineRowLayout;
1129 }
1130
1131 return true;
1132 }
1133
1134 public function applyVariationPriceFilter($variant, $paymentType = 'onetime')
1135 {
1136 $priceText = $paymentType === 'onetime' ? Helper::toDecimal($variant->item_price) : $variant->getSubscriptionTermsText(true);
1137 echo wp_kses_post(apply_filters('fluent_cart/single_product/variation_price', esc_html($priceText), [
1138 'product' => $this->product,
1139 'variant' => $variant,
1140 'scope' => 'product_variant_price'
1141 ]));
1142 do_action('fluent_cart/product/after_price', [
1143 'product' => $this->product,
1144 'variant' => $variant,
1145 'current_price' => $variant->item_price,
1146 'scope' => 'product_variant_price'
1147 ]);
1148 }
1149
1150 public function renderComparePriceWrapperStart($atts = [])
1151 {
1152 ?>
1153 <div <?php $this->renderAttributes($atts); ?> >
1154 <?php
1155 }
1156
1157 public function renderComparePriceWrapperEnd()
1158 {
1159 ?>
1160 </div>
1161 <?php
1162 }
1163
1164 public function renderVariationComparePrice($variant)
1165 {
1166 if (!$variant->compare_price) {
1167 return;
1168 } ?>
1169
1170 <span class="fct-compare-price">
1171 <del><?php echo esc_html(Helper::toDecimal($variant->compare_price)); ?></del>
1172 </span>
1173 <?php
1174 }
1175
1176 public function renderVariantPricingWrapperStart($variant)
1177 { ?>
1178 <div
1179 class="fct-product-item-price fluent-cart-product-variation-content <?php echo esc_attr($this->defaultVariant->id != $variant->id ? ' is-hidden' : ''); ?>"
1180 data-fluent-cart-product-item-price
1181 data-variation-id="<?php echo esc_attr($variant->id); ?>"
1182 aria-live="polite"
1183 role="status"
1184 >
1185 <?php }
1186
1187
1188 public function renderVariantPricingWrapperEnd()
1189 {
1190 ?> </div> <?php
1191 }
1192
1193 public function renderVariationsBundleProduct($variant)
1194 {
1195 if (count($variant->bundleChildren) == 0) {
1196 return;
1197 }
1198
1199 if(is_object($variant->bundleChildren)) {
1200 $bundleProducts = $variant->bundleChildren->toArray();
1201 }else{
1202 $bundleProducts = $variant->bundleChildren;
1203 }
1204
1205 $total = count($bundleProducts);
1206 ?>
1207 <div class="fluent-cart-product-variation-content fct-bundle-products <?php echo esc_attr($this->defaultVariant->id != $variant->id ? ' is-hidden' : ''); ?>"
1208 data-variation-id="<?php echo esc_attr($variant->id); ?>"
1209 data-fluent-cart-collapsibles
1210 >
1211 <h4 class="fct-bundle-products-title">
1212 <?php echo esc_html__('Bundle of', 'fluent-cart') . ':'; ?>
1213 </h4>
1214
1215 <div class="fct-bundle-products-list">
1216 <?php foreach (array_slice($bundleProducts, 0, 2) as $product): ?>
1217 <p>
1218 <?php echo esc_html(Arr::get($product, 'product.post_title')); ?> -
1219 <?php echo esc_html($product['variation_title']); ?>
1220 </p>
1221 <?php endforeach; ?>
1222
1223 <?php if($total > 2): ?>
1224 <div class="fct-bundle-products-more">
1225 <div class="fct-bundle-products-more-list">
1226 <?php foreach (array_slice($bundleProducts, 2) as $product): ?>
1227 <p>
1228 <?php echo esc_html(Arr::get($product, 'product.post_title')); ?> -
1229 <?php echo esc_html($product['variation_title']); ?>
1230 </p>
1231 <?php endforeach; ?>
1232 </div>
1233 </div>
1234 <?php endif;?>
1235 </div>
1236
1237 <?php if ($total > 2) : ?>
1238 <button type="button" class="fct-see-more-btn" data-fluent-cart-collapsible-toggle>
1239 <span class="see-more-text">
1240 <?php echo esc_html__('See More', 'fluent-cart'); ?>
1241 <svg xmlns="http://www.w3.org/2000/svg" width="12" height="7" viewBox="0 0 12 7" fill="none">
1242 <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"/>
1243 </svg>
1244 </span>
1245 <span class="see-less-text">
1246 <?php echo esc_html__('See Less', 'fluent-cart'); ?>
1247 <svg xmlns="http://www.w3.org/2000/svg" width="12" height="7" viewBox="0 0 14 8" fill="none">
1248 <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"/>
1249 </svg>
1250 </span>
1251 </button>
1252 <?php endif; ?>
1253 </div>
1254 <?php }
1255
1256 public function renderQuantity()
1257 {
1258 $soldIndividually = $this->product->soldIndividually();
1259
1260 if (!$this->hasOnetime || $soldIndividually) {
1261 return;
1262 }
1263
1264 $attributes = [
1265 'data-fluent-cart-product-quantity-container' => '',
1266 'data-cart-id' => $this->defaultVariant ? $this->defaultVariant->id : '',
1267 'data-variation-type' => $this->product->detail->variation_type,
1268 'data-payment-type' => 'onetime',
1269 'class' => 'fct-product-quantity-container'
1270 ];
1271
1272 $defaultVariantData = $this->getDefaultVariantData();
1273
1274 if ($this->hasSubscription && Arr::get($defaultVariantData, 'payment_type') !== 'onetime') {
1275 $attributes['class'] .= ' is-hidden';
1276 }
1277
1278 do_action('fluent_cart/product/single/before_quantity_block', [
1279 'product' => $this->product,
1280 'scope' => 'product_quantity_block'
1281 ]);
1282 ?>
1283 <div <?php $this->renderAttributes($attributes); ?>>
1284 <label for="fct-product-qty-input" class="quantity-title">
1285 <?php esc_html_e('Quantity', 'fluent-cart'); ?>
1286 </label>
1287
1288 <div class="fct-product-quantity">
1289 <button class="fct-quantity-decrease-button"
1290 data-fluent-cart-product-qty-decrease-button
1291 title="<?php esc_html_e('Decrease Quantity', 'fluent-cart'); ?>"
1292 aria-label="<?php esc_attr_e('Decrease Quantity', 'fluent-cart'); ?>"
1293 >
1294 <svg xmlns="http://www.w3.org/2000/svg" width="14" height="2" viewBox="0 0 14 2" fill="none">
1295 <path d="M12.3333 1L1.66659 1" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"
1296 stroke-linejoin="round"></path>
1297 </svg>
1298 </button>
1299
1300 <input
1301 id="fct-product-qty-input"
1302 min="1"
1303 <?php echo $soldIndividually ? 'max="1"' : ''; ?>
1304 class="fct-quantity-input"
1305 data-fluent-cart-single-product-page-product-quantity-input
1306 type="number"
1307 inputmode="numeric"
1308 pattern="[0-9]*"
1309 placeholder="<?php esc_attr_e('Quantity', 'fluent-cart'); ?>"
1310 value="1"
1311 aria-label="<?php esc_attr_e('Product quantity', 'fluent-cart'); ?>"
1312 />
1313
1314 <button class="fct-quantity-increase-button"
1315 data-fluent-cart-product-qty-increase-button
1316 title="<?php esc_attr_e('Increase Quantity', 'fluent-cart'); ?>"
1317 aria-label="<?php esc_attr_e('Increase Quantity', 'fluent-cart'); ?>"
1318 >
1319 <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 14 14" fill="none">
1320 <path d="M6.99996 1.66666L6.99996 12.3333M12.3333 6.99999L1.66663 6.99999" stroke="currentColor"
1321 stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path>
1322 </svg>
1323 </button>
1324 </div>
1325 </div>
1326 <?php
1327 do_action('fluent_cart/product/single/after_quantity_block', [
1328 'product' => $this->product,
1329 'scope' => 'product_quantity_block'
1330 ]);
1331 }
1332
1333 public function renderPurchaseButtons($atts = [])
1334 {
1335 $buyNowButtonAtts = $atts;
1336 $this->renderBuyNowButton($buyNowButtonAtts);
1337 $this->renderAddToCartButton($atts);
1338 }
1339
1340 public function renderBuyNowButton($atts = [])
1341 {
1342 // Stock management check using isStock() method
1343 // if (ModuleSettings::isActive('stock_management')) {
1344 // if ($this->product->detail->variation_type === 'simple' && $this->defaultVariant) {
1345 // if (!$this->defaultVariant->isStock()) {
1346 // echo '<span aria-disabled="true">' . esc_html__('Out of stock', 'fluent-cart') . '</span>';
1347 // return;
1348 // }
1349 // }
1350 // }
1351
1352 $defaults = [
1353 'buy_now_text' => __('Buy Now', 'fluent-cart'),
1354 'add_to_cart_text' => __('Add To Cart', 'fluent-cart'),
1355 ];
1356
1357 $atts = wp_parse_args($atts, $defaults);
1358
1359 $enableModalCheckout = Helper::isModalCheckoutEnabled();
1360
1361 $isInStock = true;
1362 if (ModuleSettings::isActive('stock_management')) {
1363 $isInStock = $this->product->isStock() && ($this->defaultVariant && $this->defaultVariant->isStock());
1364 }
1365
1366 $stockStatus = $isInStock ? 'in-stock' : 'out-of-stock';
1367
1368 $variationClass = 'fluent-cart-direct-checkout-button';
1369 if (!$isInStock) {
1370 $variationClass .= ' is-hidden';
1371 }
1372
1373 $buyNowAttributes = [
1374 'data-fluent-cart-direct-checkout-button' => '',
1375 'data-variation-type' => $this->product->detail->variation_type,
1376 'class' => $variationClass,
1377 'data-stock-availability' => $stockStatus,
1378 'data-quantity' => '1',
1379 'data-cart-id' => $this->defaultVariant ? $this->defaultVariant->id : '',
1380 'data-url' => site_url('?fluent-cart=instant_checkout&item_id='),
1381 ];
1382
1383 if ($isInStock) {
1384 $buyNowAttributes['href'] = site_url('?fluent-cart=instant_checkout&item_id=') . ($this->defaultVariant ? $this->defaultVariant->id : '') . '&quantity=1';
1385 }
1386
1387 if ($enableModalCheckout) {
1388 $buyNowAttributes['data-fct-instant-checkout-button'] = '';
1389 $buyNowAttributes['data-enable-modal-checkout'] = 'yes';
1390 }
1391
1392 $isShortcode = !empty($atts['is_shortcode']);
1393 if ($isShortcode) {
1394 ob_start();
1395 $this->renderAttributes($buyNowAttributes);
1396 $wrapperAttributes = ob_get_clean();
1397 } else {
1398 $wrapperAttributes = RenderHelper::getBlockWrapperAttributes($buyNowAttributes);
1399 }
1400
1401 $buyButtonText = apply_filters('fluent_cart/product/buy_now_button_text', $atts['buy_now_text'], [
1402 'product' => $this->product
1403 ]);
1404
1405 ?>
1406 <?php
1407 $variantTitle = $this->defaultVariant ? $this->defaultVariant->variation_title : '';
1408 $buyNowAriaLabel = $variantTitle
1409 ? sprintf(
1410 /* translators: 1: Button text (e.g. "Buy Now"), 2: Variant name */
1411 __('%1$s - %2$s', 'fluent-cart'),
1412 $buyButtonText,
1413 $variantTitle
1414 )
1415 : $buyButtonText;
1416 ?>
1417 <a <?php echo $wrapperAttributes; ?> aria-label="<?php echo esc_attr($buyNowAriaLabel); ?>">
1418 <?php echo wp_kses_post($buyButtonText); ?>
1419 </a>
1420 <?php
1421 }
1422
1423 public function renderBuyNowButtonBlock($atts = [])
1424 {
1425 $text = Arr::get($atts, 'text', __('Buy Now', 'fluent-cart'));
1426 $variantIds = Arr::get($atts, 'variant_ids', []);
1427 $variantId = Arr::get($variantIds, 0);
1428 $customClass = trim(Arr::get($atts, 'class', ''));
1429 $extraClass = trim(Arr::get($atts, 'extra_class', ''));
1430
1431 $defaults = [
1432 'buy_now_text' => $text,
1433 'target' => '',
1434 'rel' => '',
1435 'is_shortcode' => false,
1436 ];
1437
1438 $atts = wp_parse_args($atts, $defaults);
1439
1440 $enableModalCheckout = Arr::get($atts, 'enable_modal_checkout', false);
1441
1442 $isInStock = true;
1443 if (ModuleSettings::isActive('stock_management')) {
1444 $isInStock = $this->product->isStock() && ($this->defaultVariant && $this->defaultVariant->isStock());
1445 }
1446 $stockStatus = $isInStock ? 'in-stock' : 'out-of-stock';
1447
1448 $checkoutUrl = add_query_arg([
1449 'fluent-cart' => $enableModalCheckout ? 'modal_checkout' : 'instant_checkout',
1450 'item_id' => $variantId ?? '',
1451 'quantity' => 1
1452 ], site_url());
1453
1454 $buyNowClass = $customClass ?: 'wp-block-button__link wp-element-button';
1455 if ($extraClass) {
1456 $buyNowClass .= ' ' . $extraClass;
1457 }
1458 $buyNowClass = trim($buyNowClass);
1459 if ($stockStatus === 'out-of-stock') {
1460 $buyNowClass .= ' out-of-stock';
1461 }
1462
1463 $buyNowAttributes = [
1464 'data-fluent-cart-direct-checkout-button' => '',
1465 'data-variation-type' => $this->product->detail->variation_type,
1466 'class' => $buyNowClass,
1467 'data-stock-availability' => $stockStatus,
1468 'data-quantity' => '1',
1469 'data-cart-id' => $variantId ?? '',
1470 'data-url' => $checkoutUrl,
1471 ];
1472
1473 if ($stockStatus === 'out-of-stock') {
1474 $buyNowAttributes['aria-disabled'] = 'true';
1475 } else {
1476 $buyNowAttributes['href'] = $checkoutUrl;
1477 }
1478
1479 $target = Arr::get($atts, 'target');
1480 if ($target) {
1481 $buyNowAttributes['target'] = $target;
1482 if (strtolower($target) === '_blank') {
1483 $buyNowAttributes['rel'] = Arr::get($atts, 'rel', 'noopener noreferrer');
1484 }
1485 }
1486 if ($enableModalCheckout) {
1487 $buyNowAttributes['data-fct-instant-checkout-button'] = '';
1488 $buyNowAttributes['data-enable-modal-checkout'] = 'yes';
1489 }
1490 $isShortcode = !empty($atts['is_shortcode']);
1491 if ($isShortcode) {
1492 ob_start();
1493 $this->renderAttributes($buyNowAttributes);
1494 $wrapperAttributes = ob_get_clean();
1495 } else {
1496 $wrapperAttributes = RenderHelper::getBlockWrapperAttributes($buyNowAttributes);
1497 }
1498
1499 $buyButtonText = apply_filters('fluent_cart/product/buy_now_button_text', $atts['buy_now_text'], [
1500 'product' => $this->product
1501 ]);
1502 ?>
1503 <a <?php echo($wrapperAttributes); ?> aria-label="<?php echo esc_attr($buyButtonText); ?>">
1504 <?php echo wp_kses_post($buyButtonText); ?>
1505 </a>
1506 <?php
1507 }
1508
1509 public function renderAddToCartButton($atts = [])
1510 {
1511 $defaults = [
1512 'buy_now_text' => __('Buy Now', 'fluent-cart'),
1513 'add_to_cart_text' => __('Add To Cart', 'fluent-cart'),
1514 ];
1515
1516 $atts = wp_parse_args($atts, $defaults);
1517
1518 $cartAttributes = [
1519 'data-fluent-cart-add-to-cart-button' => '',
1520 'data-cart-id' => $this->defaultVariant ? $this->defaultVariant->id : '',
1521 'data-product-id' => $this->product->ID,
1522 'class' => 'fluent-cart-add-to-cart-button',
1523 'data-variation-type' => $this->product->detail->variation_type,
1524 'data-icon-only' => !empty($atts['is_icon_only']) ? 'true' : 'false',
1525 ];
1526
1527 $defaultVariantData = $this->getDefaultVariantData();
1528
1529 // If product is subscription-only, hide add-to-cart
1530 if ($this->hasSubscription && Arr::get($defaultVariantData, 'payment_type') !== 'onetime') {
1531 $cartAttributes['class'] .= ' is-hidden';
1532 }
1533
1534 // Check stock availability using both product-level and variant-level
1535 $isOutOfStock = false;
1536 if (ModuleSettings::isActive('stock_management')) {
1537 if (!$this->product->isStock() || ($this->defaultVariant && !$this->defaultVariant->isStock())) {
1538 $isOutOfStock = true;
1539 $cartAttributes['disabled'] = 'disabled';
1540 $cartAttributes['class'] .= ' out-of-stock';
1541 $cartAttributes['aria-disabled'] = 'true';
1542 $atts['add_to_cart_text'] = __('Not Available', 'fluent-cart');
1543 }
1544 }
1545
1546 $addToCartText = apply_filters('fluent_cart/product/add_to_cart_text', $atts['add_to_cart_text'], [
1547 'product' => $this->product
1548 ]);
1549
1550 $isShortcode = !empty($atts['is_shortcode']);
1551 if ($isShortcode) {
1552 ob_start();
1553 $this->renderAttributes($cartAttributes);
1554 $wrapperAttributes = ob_get_clean();
1555 } else {
1556 $wrapperAttributes = RenderHelper::getBlockWrapperAttributes($cartAttributes);
1557 }
1558
1559
1560 // Render add to cart when the product supports a one-time path, when out
1561 // of stock (to show "Not Available"), or for advanced-variation products.
1562 // The advanced selector toggles this button's visibility / disabled /
1563 // payment-type state per selection, so it must exist in the DOM even for
1564 // a subscription-only product whose in-stock default starts hidden via
1565 // the is-hidden class applied above — otherwise an out-of-stock
1566 // subscription combination has no button to surface "Not Available".
1567 $isAdvancedVariation = $this->product->detail->variation_type === Helper::PRODUCT_TYPE_ADVANCE_VARIATION;
1568 if ($this->hasOnetime || $isOutOfStock || $isAdvancedVariation) :
1569 ?>
1570 <?php
1571 $variantTitle = $this->defaultVariant ? $this->defaultVariant->variation_title : '';
1572 $addToCartAriaLabel = $variantTitle
1573 ? sprintf(
1574 /* translators: 1: Button text (e.g. "Add To Cart"), 2: Variant name */
1575 __('%1$s - %2$s', 'fluent-cart'),
1576 $addToCartText,
1577 $variantTitle
1578 )
1579 : $addToCartText;
1580 ?>
1581 <button <?php echo $wrapperAttributes; ?>
1582 aria-label="<?php echo esc_attr($addToCartAriaLabel); ?>">
1583 <span class="text">
1584 <?php echo wp_kses_post($addToCartText); ?>
1585 </span>
1586 <span class="fluent-cart-loader" role="status">
1587 <svg aria-hidden="true"
1588 width="20"
1589 height="20"
1590 class="w-5 h-5 text-gray-200 animate-spin fill-blue-600"
1591 viewBox="0 0 100 101" fill="none" xmlns="http://www.w3.org/2000/svg">
1592 <path
1593 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"
1594 fill="currentColor"/>
1595 <path
1596 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"
1597 fill="currentFill"/>
1598 </svg>
1599 </span>
1600 </button>
1601 <?php
1602 endif;
1603 }
1604
1605 public function renderAddToCartButtonBlock($atts = [])
1606 {
1607 $text = Arr::get($atts, 'text', __('Add To Cart', 'fluent-cart'));
1608 $customClass = trim(Arr::get($atts, 'class', ''));
1609 $extraClass = trim(Arr::get($atts, 'extra_class', ''));
1610
1611 $defaults = [
1612 'add_to_cart_text' => $text,
1613 ];
1614
1615 $atts = wp_parse_args($atts, $defaults);
1616
1617 $buttonClasses = ['fct-loader', 'wp-block-button__link wp-element-button'];
1618 $buttonClass = $customClass ?: implode(' ', $buttonClasses);
1619
1620 $cartAttributes = [
1621 'data-fluent-cart-add-to-cart-button' => '',
1622 'class' => $buttonClass,
1623 'data-cart-id' => $this->defaultVariant ? $this->defaultVariant->id : '',
1624 'data-product-id' => $this->product->ID,
1625 'data-variation-type' => $this->product->detail->variation_type,
1626 'data-icon-only' => !empty($atts['is_icon_only']) ? 'true' : 'false',
1627 ];
1628
1629 if ($extraClass) {
1630 $cartAttributes['class'] .= ' ' . $extraClass;
1631 }
1632
1633 // If the product does NOT support one-time purchase
1634 if (!$this->hasOnetime) {
1635 if (Helper::isAdminUser()) {
1636 $view = '<p class="fct-admin-notice">' . esc_html__('Add to Cart is not supported for subscription product', 'fluent-cart') . '</p>';
1637
1638 FrontendView::make('', $view);
1639 return;
1640 }
1641
1642 return;
1643 }
1644
1645 // Check stock availability using both product-level and variant-level
1646 if (ModuleSettings::isActive('stock_management')) {
1647 if (!$this->product->isStock() || ($this->defaultVariant && !$this->defaultVariant->isStock())) {
1648 $cartAttributes['disabled'] = 'disabled';
1649 $cartAttributes['class'] .= ' out-of-stock';
1650 $cartAttributes['aria-disabled'] = 'true';
1651 $atts['add_to_cart_text'] = __('Not Available', 'fluent-cart');
1652 }
1653 }
1654
1655 $addToCartText = apply_filters('fluent_cart/product/add_to_cart_text', $atts['add_to_cart_text'], [
1656 'product' => $this->product
1657 ]);
1658
1659 $isShortcode = !empty($atts['is_shortcode']);
1660 if ($isShortcode) {
1661 ob_start();
1662 $this->renderAttributes($cartAttributes);
1663 $wrapperAttributes = ob_get_clean();
1664 } else {
1665 $wrapperAttributes = RenderHelper::getBlockWrapperAttributes($cartAttributes);
1666 }
1667
1668 ?>
1669
1670 <button <?php echo $wrapperAttributes; ?>
1671 aria-label="<?php echo esc_attr($addToCartText); ?>">
1672 <span class="text">
1673 <?php echo wp_kses_post($addToCartText); ?>
1674 </span>
1675 <span class="fluent-cart-loader" role="status">
1676 <svg aria-hidden="true"
1677 width="20"
1678 height="20"
1679 class="w-5 h-5 text-gray-200 animate-spin fill-blue-600"
1680 viewBox="0 0 100 101" fill="none" xmlns="http://www.w3.org/2000/svg">
1681 <path
1682 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"
1683 fill="currentColor"/>
1684 <path
1685 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"
1686 fill="currentFill"/>
1687 </svg>
1688 </span>
1689 </button>
1690
1691 <?php
1692 }
1693
1694 public static function renderNoProductFound()
1695 {
1696 ?>
1697 <div class="fluent-cart-shop-no-result-found" data-fluent-cart-shop-no-result-found role="status"
1698 aria-live="polite">
1699 <p class="has-text-align-center has-large-font-size m-0">
1700 <?php echo esc_html__('No Product Found!', 'fluent-cart'); ?>
1701 </p>
1702
1703 <p class="has-text-align-center m-0">
1704 <?php echo esc_html__('You can try clearing any filters.', 'fluent-cart'); ?>
1705 </p>
1706 </div>
1707 <?php
1708 }
1709
1710 protected function renderVariationItem(ProductVariation $variant, $defaultId = '', $extraClasses = [])
1711 {
1712 $availableStocks = $variant->available;
1713 if (!$variant->manage_stock) {
1714 $availableStocks = 'unlimited';
1715 }
1716
1717 $comparePrice = $variant->compare_price;
1718 if ($comparePrice <= $variant->item_price) {
1719 $comparePrice = '';
1720 }
1721
1722 if ($comparePrice) {
1723 $comparePrice = Helper::toDecimal($comparePrice);
1724 }
1725
1726 $paymentType = Arr::get($variant->other_info, 'payment_type');
1727
1728 $itemClasses = [
1729 'fct-product-variant-item',
1730 'fct_price_type_' . $paymentType,
1731 'fct_variation_view_type_' . $this->viewType,
1732 ];
1733
1734 if ($variant->media_id) {
1735 $itemClasses[] = 'fct-item-has-image';
1736 }
1737
1738 if ($variant->id == $defaultId) {
1739 $itemClasses[] = 'selected';
1740 }
1741
1742 $priceSuffix = apply_filters('fluent_cart/product/price_suffix_atts', '', [
1743 'product' => $this->product,
1744 'variant' => $variant,
1745 'scope' => 'variant_item'
1746 ]);
1747
1748 $renderingAttributes = [
1749 'data-fluent-cart-product-variant' => '',
1750 'data-cart-id' => $variant->id,
1751 'data-item-stock' => $variant->isStock() ? 'in-stock' : 'out-of-stock',
1752 'data-default-variation-id' => $defaultId,
1753 'data-payment-type' => $paymentType,
1754 'data-available-stock' => $availableStocks,
1755 'data-item-price' => Helper::toDecimal($variant->item_price),
1756 'data-compare-price' => $comparePrice,
1757 'data-price-suffix' => $priceSuffix,
1758 'data-stock-management' => ModuleSettings::isActive('stock_management') ? 'yes' : 'no',
1759 'data-sku' => $variant->sku ?? '',
1760 'data-package-info' => $this->getVariantPackageInfoJson($variant),
1761 ];
1762
1763 if ($paymentType === 'subscription') {
1764 $renderingAttributes['data-subscription-terms'] = $variant->getSubscriptionTermsText(true);
1765 $repeatInterval = Arr::get($variant->other_info, 'repeat_interval', '');
1766 $hasInstallment = Arr::get($variant->other_info, 'has_installment') === 'yes';
1767
1768 $itemClasses[] = 'fct_sub_interval_' . $repeatInterval;
1769 if ($hasInstallment) {
1770 $itemClasses[] = 'fct_sub_has_installment';
1771 }
1772 }
1773
1774 if ($extraClasses) {
1775 $itemClasses = array_merge($itemClasses, $extraClasses);
1776 }
1777
1778 $itemClasses = array_filter($itemClasses);
1779 $renderingAttributes['class'] = implode(' ', $itemClasses);
1780
1781 $itemPrice = $variant->item_price;
1782 $comparePrice = $variant->compare_price;
1783 if (!$comparePrice || $comparePrice <= $itemPrice) {
1784 $comparePrice = 0;
1785 }
1786
1787 ?>
1788 <div
1789 <?php $this->renderAttributes($renderingAttributes); ?>
1790 role="radio"
1791 tabindex="<?php echo $variant->id == $defaultId ? '0' : '-1'; ?>"
1792 aria-checked="<?php echo $variant->id == $defaultId ? 'true' : 'false'; ?>"
1793 aria-label="<?php echo esc_attr($variant->variation_title); ?>"
1794 >
1795 <?php if ($this->viewType === 'image'): ?>
1796 <?php $this->renderTooltip($variant); ?>
1797 <?php endif; ?>
1798
1799 <div class="variant-content">
1800 <?php
1801 if ($this->viewType === 'both' || $this->viewType === 'image') {
1802 $this->renderVariantImage($variant);
1803 }
1804 ?>
1805 <?php if ($this->viewType === 'both' || $this->viewType === 'text'): ?>
1806 <div class="fct-product-variant-text">
1807 <div class="fct-product-variant-title" aria-label="<?php echo esc_attr(__('Variant title', 'fluent-cart')); ?>"><?php echo esc_html($variant->variation_title); ?></div>
1808 <?php if (!$this->shouldRenderPriceInPriceSection() && $paymentType === 'subscription'): ?>
1809 <?php $this->renderSubscriptionInfo($variant); ?>
1810 <?php endif; ?>
1811 </div>
1812 <?php endif; ?>
1813 </div>
1814
1815 <?php if (!$this->shouldRenderPriceInPriceSection()): ?>
1816 <div class="fct-product-variant-price">
1817 <?php if ($comparePrice): ?>
1818 <div class="fct-product-variant-compare-price">
1819 <del aria-label="<?php echo esc_attr(__('Original price', 'fluent-cart')); ?>">
1820 <span><?php echo esc_html(Helper::toDecimal($comparePrice)); ?></span></del>
1821 </div>
1822 <?php endif; ?>
1823 <div class="fct-product-variant-item-price"
1824 aria-label="<?php echo esc_attr(__('Current price', 'fluent-cart')); ?>">
1825 <span><?php echo esc_html(Helper::toDecimal($itemPrice)); ?></span>
1826 </div>
1827 </div>
1828 <?php endif; ?>
1829 </div>
1830 <?php
1831 }
1832
1833 protected function renderTooltip($variant)
1834 {
1835 ?>
1836 <div class="fct-product-variant-tooltip" role="tooltip" id="tooltip-<?php echo esc_attr($variant->id); ?>">
1837 <?php echo esc_html($variant->variation_title); ?>
1838 </div>
1839 <?php
1840 }
1841
1842 public function renderVariantImage($variant)
1843 {
1844 $image = $variant->thumbnail;
1845 if (!$image) {
1846 $image = Vite::getAssetUrl('images/placeholder.svg');
1847 }
1848 ?>
1849 <div class="fct-product-variant-image">
1850 <img role="img" alt="<?php echo esc_attr($variant->variation_title); ?>"
1851 src="<?php echo esc_url($image); ?>"/>
1852 </div>
1853 <?php
1854 }
1855
1856 protected function renderSubscriptionInfo($variant = null)
1857 {
1858
1859 if(!$variant){
1860 return '';
1861 }
1862 $info = $variant->getSubscriptionTermsText(true);
1863
1864 if (!$info) {
1865 return '';
1866 }
1867
1868 ?>
1869 <div class="fct-product-variant-payment-type" aria-live="polite">
1870 <div class="additional-info">
1871 <span><?php echo esc_html($info); ?></span>
1872 </div>
1873 </div>
1874 <?php
1875 }
1876
1877 protected function renderAttributes($atts = [])
1878 {
1879 foreach ($atts as $attr => $value) {
1880 if ($value !== '') {
1881 echo esc_attr($attr) . '="' . esc_attr((string)$value) . '" ';
1882 } else {
1883 echo esc_attr($attr) . ' ';
1884 }
1885 }
1886 }
1887
1888 protected function renderTab($atts = [])
1889 {
1890 ?>
1891 <div class="fct-product-tab" data-fluent-cart-product-tab>
1892 <?php $this->renderTabNav(); ?>
1893
1894 <div class="fct-product-tab-content" data-tab-contents>
1895 <?php $this->renderTabPane($atts); ?>
1896 </div>
1897 </div>
1898 <?php
1899
1900 }
1901
1902 protected function renderTabNav()
1903 {
1904 ?>
1905
1906 <div class="fct-product-tab-nav" role="tablist">
1907 <div class="tab-active-bar" data-tab-active-bar></div>
1908 <?php
1909 foreach ($this->paymentTypes as $typeKey => $typeLabel) : ?>
1910 <div
1911 class="fct-product-tab-nav-item <?php echo esc_attr($this->activeTab === $typeKey ? 'active' : ''); ?>"
1912 data-tab="<?php echo esc_attr($typeKey); ?>"
1913 role="tab"
1914 tabindex="<?php echo $this->activeTab === $typeKey ? '0' : '-1'; ?>"
1915 aria-selected="<?php echo $this->activeTab === $typeKey ? 'true' : 'false'; ?>"
1916 aria-controls="<?php echo esc_attr($typeKey); ?>"
1917 >
1918 <?php echo esc_html($typeLabel); ?>
1919 </div>
1920 <?php endforeach;
1921 ?>
1922 </div>
1923
1924 <?php
1925 }
1926
1927 protected function renderTabPane($atts = [])
1928 {
1929 $variantsClasses = [
1930 'fct-product-variants',
1931 'column-type-' . $this->columnType,
1932 Arr::get($atts, 'wrapper_class', ''),
1933 ];
1934
1935 foreach ($this->variantsByPaymentTypes as $variantKey => $variants): ?>
1936 <div
1937 data-tab-content
1938 id="<?php echo esc_attr($variantKey); ?>"
1939 class="fct-product-tab-pane <?php echo esc_attr($this->activeTab === $variantKey ? 'active' : ''); ?>"
1940 role="tabpanel"
1941 aria-labelledby="<?php echo esc_attr($variantKey); ?>"
1942 >
1943 <div class="<?php echo esc_attr(implode(' ', $variantsClasses)); ?>" role="radiogroup"
1944 aria-label="<?php esc_attr_e('Product Variants', 'fluent-cart'); ?>">
1945 <?php
1946 //Convert to collection safely before sorting
1947 $variants = (new Collection($variants))->sortBy('serial_index')->values();
1948
1949 foreach ($variants as $variant) {
1950 do_action('fluent_cart/product/single/before_variant_item', [
1951 'product' => $this->product,
1952 'variant' => $variant,
1953 'scope' => 'product_variant_item'
1954 ]);
1955
1956 $this->renderVariationItem($variant, $this->defaultVariationId);
1957
1958 do_action('fluent_cart/product/single/after_variant_item', [
1959 'product' => $this->product,
1960 'variant' => $variant,
1961 'scope' => 'product_variant_item'
1962 ]);
1963 }
1964 ?>
1965 </div>
1966
1967 </div>
1968 <?php endforeach; ?>
1969
1970 <?php
1971 }
1972
1973 protected function getDefaultVariantData()
1974 {
1975 if (empty($this->variants) || !$this->defaultVariationId) {
1976 return null;
1977 }
1978
1979 foreach ($this->variants as $variant) {
1980 if ($variant['id'] == $this->defaultVariationId) {
1981 return $variant;
1982 }
1983 }
1984
1985 return null;
1986 }
1987 }
1988