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

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

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