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

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

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