PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.6.4
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.6.4
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 1.2.0 All 47 releases
fluent-cart / app / Services / Renderer / ProductRenderer.php

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

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