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

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