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

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