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

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

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