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

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