PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.6.6
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.6.6
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
← All changes | app/Services/Renderer/ProductRenderer.php +617 -167 1.3.21 → 1.6.6 View file →
@@ -13,8 +13,9 @@
13 13 use FluentCart\App\Http\Routes\WebRoutes;
14 14 use FluentCart\App\Models\ProductVariation;
15 15 use FluentCart\Framework\Support\Collection;
16 16 use FluentCart\App\Modules\Templating\AssetLoader;
17 +use FluentCart\App\Modules\FluentPlayer\ProductVideoRenderer;
17 18
18 19 class ProductRenderer
19 20 {
20 21 protected $product;
@@ -38,8 +39,10 @@
38 39 protected $defaultGalleryImageId = 0;
39 40
40 41 protected $galleryActiveSet = false;
41 42
43 + protected $productVideoRenderer = null;
44 +
42 45 protected $paymentTypes = [];
43 46
44 47 protected $variantsByPaymentTypes = [];
45 48
@@ -46,8 +49,10 @@
46 49 protected $activeTab = 'onetime';
47 50
48 51 protected $images = [];
49 52
53 + protected $variantTermMap = [];
54 +
50 55 protected $defaultImageUrl = null;
51 56
52 57 protected $defaultImageAlt = null;
53 58
@@ -83,10 +88,41 @@
83 88 if (!$defaultVariationId) {
84 89 $variationIds = $product->variants->pluck('id')->toArray();
85 90 $defaultVariationId = $product->detail->default_variation_id;
86 91
87 - if (!$defaultVariationId || !in_array($defaultVariationId, $variationIds)) {
88 - $defaultVariationId = Arr::get($variationIds, '0');
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');
89 125 $hasExplicitDefault = false;
90 126 }
91 127 }
92 128
@@ -104,13 +140,17 @@
104 140 foreach ($this->product->variants as $variant) {
105 141 if ($variant->id == $this->defaultVariationId) {
106 142 $this->defaultVariant = $variant;
107 143 }
108 - $paymentType = Arr::get($variant->other_info, 'payment_type');
109 - if ($paymentType === 'onetime') {
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 {
110 152 $this->hasOnetime = true;
111 - } else if ($paymentType === 'subscription') {
112 - $this->hasSubscription = true;
113 153 }
114 154 }
115 155
116 156 $this->buildProductGroups();
@@ -222,8 +262,9 @@
222 262 $this->renderVariationsBundleProduct($variant);
223 263 }
224 264 }
225 265
266 + $this->renderPackageDescription();
226 267 $this->renderBuySection();
227 268 ?>
228 269 </div>
229 270 </div>
@@ -256,8 +297,33 @@
256 297 }
257 298
258 299 public function renderBuySection($atts = [])
259 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 +
260 326 $otherInfo = (array)Arr::get($this->product->detail, 'other_info');
261 327 $groupBy = Arr::get($otherInfo, 'group_pricing_by', 'repeat_interval'); //repeat_interval,payment_type,none
262 328
263 329 $this->renderBuySectionWrapperStart();
@@ -276,8 +342,25 @@
276 342 }
277 343
278 344 public function renderVariationDisplay($atts = [])
279 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 +
280 363 $otherInfo = (array)Arr::get($this->product->detail, 'other_info');
281 364 $groupBy = Arr::get($otherInfo, 'group_pricing_by', 'repeat_interval'); //repeat_interval,payment_type,none
282 365
283 366 if (count($this->paymentTypes) === 1 || $groupBy === 'none') {
@@ -292,10 +375,13 @@
292 375 $thumbnails = [];
293 376
294 377 $featuredMedia = $this->product->thumbnail ?? Vite::getAssetUrl('images/placeholder.svg');
295 378
296 - if (!$featuredMedia) {
297 - $featuredMedia = [];
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');
298 384 }
299 385
300 386 $galleryImage = get_post_meta($this->product->ID, 'fluent-products-gallery-image', true);
301 387
@@ -328,14 +414,37 @@
328 414 if (isset($images[$imageId])) {
329 415 $imageMetaValue = $images[$imageId];
330 416 $this->defaultImageUrl = Arr::get($imageMetaValue, 'media.0.url', '');
331 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', '');
332 429 }
333 430 }
334 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 +
335 443 ?>
336 - <div class="fct-product-gallery-thumb" role="region"
337 - aria-label="<?php echo esc_attr($this->product->post_title . ' gallery'); ?>">
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) . '"' : ''; ?>>
338 447 <img
339 448 src="<?php echo esc_url($this->defaultImageUrl ?? '') ?>"
340 449 alt="<?php echo esc_attr($this->defaultImageAlt) ?>"
341 450 data-fluent-cart-single-product-page-product-thumbnail
@@ -340,36 +449,117 @@
340 449 alt="<?php echo esc_attr($this->defaultImageAlt) ?>"
341 450 data-fluent-cart-single-product-page-product-thumbnail
342 451 data-default-image-url="<?php echo esc_url($featuredMedia) ?>"
343 452 />
453 + <?php $this->getProductVideoRenderer()->renderInlinePlayers(); ?>
344 454 </div>
345 455 <?php
346 456 }
347 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 +
348 484 public function renderGalleryThumbControls($maxThumbnails = null)
349 485 {
350 486 $totalThumbImages = Arr::pluck($this->images, 'media.*.url');
351 487
352 - if(count($totalThumbImages) == 1 && is_countable($totalThumbImages[0]) && count($totalThumbImages[0]) == 1){
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()){
353 490
354 491 return '';
355 492 }
356 493
357 - // Collect ALL gallery images as JSON for lightbox (even when max thumbnails limits visible thumbs)
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).
358 536 $allGalleryImages = [];
537 + $addedMediaKeys = [];
538 + $isAdvVariation = !empty($this->variantTermMap);
359 539 foreach ($this->images as $imageId => $image) {
360 540 if (empty($image['media']) || !is_array($image['media'])) {
361 541 continue;
362 542 }
363 543 foreach ($image['media'] as $item) {
364 - $url = Arr::get($item, 'url', '');
544 + $url = Arr::get($item, 'url', '');
545 + $mediaId = (int) Arr::get($item, 'id', 0);
365 546 if (empty($url)) {
366 547 continue;
367 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 + }
368 556 $allGalleryImages[] = [
369 557 'url' => $url,
370 558 'title' => Arr::get($item, 'title', ''),
371 559 'variation_id' => (string) $imageId,
560 + 'term_id' => (int) ($this->variantTermMap[(int) $imageId] ?? 0),
561 + 'media_id' => $mediaId,
372 562 ];
373 563 }
374 564 }
375 565
@@ -378,9 +568,10 @@
378 568 <div class="fct-gallery-thumb-controls"
379 569 role="toolbar"
380 570 aria-label="<?php echo esc_attr__('Product image thumbnails', 'fluent-cart'); ?>"
381 571 data-fluent-cart-single-product-page-product-thumbnail-controls
382 - data-all-gallery-images="<?php echo esc_attr(wp_json_encode($allGalleryImages) ?: '[]'); ?>">
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) ?: '{}'); ?>">
383 574
384 575 <?php $this->renderGalleryThumbControl($maxThumbnails); ?>
385 576
386 577 </div>
@@ -394,45 +585,76 @@
394 585 if ($maxThumbnails !== null && $maxThumbnails <= 0) {
395 586 $maxThumbnails = null; // treat invalid value as "no limit"
396 587 }
397 588
398 - $count = 0;
399 - $totalImages = 0;
589 + $isAdvVariation = !empty($this->variantTermMap);
590 + $countedMediaKeys = [];
591 + $count = 0;
592 + $totalImages = 0;
593 + $videoRenderer = $this->getProductVideoRenderer();
400 594
401 - // First, count total renderable images
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).
402 603 foreach ($this->images as $imageId => $image) {
403 604 if (empty($image['media']) || !is_array($image['media'])) {
404 605 continue;
405 606 }
406 607 foreach ($image['media'] as $item) {
407 - if (!empty(Arr::get($item, 'url', ''))) {
408 - $totalImages++;
608 + $url = Arr::get($item, 'url', '');
609 + $mediaId = (int) Arr::get($item, 'id', 0);
610 + if (empty($url)) {
611 + continue;
409 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++;
410 621 }
411 622 }
412 623
413 - // Then render up to max
624 + $renderedMediaKeys = [];
625 + // Render up to max; skip already-rendered media for advanced variation products.
414 626 foreach ($this->images as $imageId => $image) {
415 627 if (empty($image['media']) || !is_array($image['media'])) {
416 628 continue;
417 629 }
418 -
419 630 foreach ($image['media'] as $item) {
420 - if (empty(Arr::get($item, 'url', ''))) {
631 + $url = Arr::get($item, 'url', '');
632 + $mediaId = (int) Arr::get($item, 'id', 0);
633 + if (empty($url)) {
421 634 continue;
422 635 }
423 -
636 + if ($isAdvVariation) {
637 + $mediaDedupeKey = $mediaId > 0 ? 'i:' . $mediaId : 'u:' . $url;
638 + if (isset($renderedMediaKeys[$mediaDedupeKey])) {
639 + continue;
640 + }
641 + $renderedMediaKeys[$mediaDedupeKey] = true;
642 + }
424 643 if ($maxThumbnails !== null && $count >= (int) $maxThumbnails) {
644 + $videoRenderer->renderThumbControls(!$this->galleryActiveSet);
425 645 $this->renderGallerySeeMoreButton($totalImages - (int) $maxThumbnails);
426 646 return;
427 647 }
428 -
429 - $this->renderGalleryThumbControlButton($item, $imageId);
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);
430 652 $count++;
431 653 }
432 -
433 654 }
434 655
656 + $videoRenderer->renderThumbControls(!$this->galleryActiveSet);
435 657 }
436 658
437 659 public function renderGallerySeeMoreButton($remainingCount)
438 660 {
@@ -463,9 +685,9 @@
463 685 </button>
464 686 <?php
465 687 }
466 688
467 - public function renderGalleryThumbControlButton($item, $imageId)
689 + public function renderGalleryThumbControlButton($item, $imageId, $termId = 0, $mediaId = 0)
468 690 {
469 691
470 692 $isHidden = ''; //$imageId != $this->defaultVariationId ? 'is-hidden' : '';
471 693 $itemUrl = Arr::get($item, 'url', '');
@@ -481,11 +703,13 @@
481 703 class="fct-gallery-thumb-control-button <?php echo $isSelected ? 'active' : ''; ?> <?php echo esc_attr($isHidden); ?>"
482 704 data-fluent-cart-thumb-control-button
483 705 data-url="<?php echo esc_url($itemUrl); ?>"
484 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); ?>"
485 709 aria-label="<?php echo
486 - /* translators: %s image title */
487 - esc_attr(sprintf(__('View %s image', 'fluent-cart'), $itemTitle));
710 + /* translators: %1$s: image title */
711 + esc_attr(sprintf(__('View %1$s image', 'fluent-cart'), $itemTitle));
488 712 ?>"
489 713 aria-pressed="<?php echo $isSelected ? 'true' : 'false'; ?>"
490 714 tabindex="<?php echo $isSelected ? '0' : '-1'; ?>"
491 715 >
@@ -503,9 +727,14 @@
503 727 }
504 728
505 729 public function renderGallery($args = [])
506 730 {
731 + $gateContext = RenderGate::context($this->product, RenderGate::SCOPE_SINGLE, $this->defaultVariant);
507 732
733 + if (!RenderGate::shouldRender('image', $gateContext)) {
734 + return;
735 + }
736 +
508 737 $defaults = [
509 738 'thumbnail_mode' => 'all', // horizontal, vertical
510 739 'thumb_position' => 'bottom', // bottom, left, right, top
511 740 'scrollable_thumbs' => 'no', // yes / no
@@ -539,13 +768,21 @@
539 768 }
540 769
541 770 public function renderTitle()
542 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);
543 779 ?>
544 780 <div class="fct-product-title">
545 781 <h1 id="fct-product-summary-title"><?php echo esc_html($this->product->post_title); ?></h1>
546 782 </div>
547 783 <?php
784 + do_action('fluent_cart/product/single/after_title_block', $gateContext);
548 785 }
549 786
550 787 public function renderStockAvailability($wrapper_attributes = '')
551 788 {
@@ -553,8 +790,9 @@
553 790 return '';
554 791 }
555 792
556 793 $stockAvailability = $this->product->detail->getStockAvailability();
794 +
557 795
558 796 if (!Arr::get($stockAvailability, 'manage_stock')) {
559 797 return '';
560 798 }
@@ -564,15 +802,44 @@
564 802 // Check default variant stock for both simple and variable products
565 803 if ($this->defaultVariant) {
566 804 $isStock = $isStock && $this->defaultVariant->isStock();
567 805 }
568 - $stockLabel = $isStock ? $stockAvailability['availability'] : __('Out of Stock', 'fluent-cart');
569 - $statusClass = $isStock ? ($stockAvailability['class'] ?? '') : 'out-of-stock';
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 +
570 837 echo sprintf(
571 838 '<div class="fct-product-stock %1$s" role="status" aria-live="polite">
572 839 <div %2$s>
573 840 <span class="fct-stock-label">%3$s</span>
574 - <span class="fct-stock-badge fct_status_badge_%1$s" data-fluent-cart-product-stock>
841 + <span class="fct-stock-badge fct_status_badge_%1$s" data-fluent-cart-product-stock%5$s>
575 842 %4$s
576 843 </span>
577 844 </div>
578 845 </div>',
@@ -578,22 +845,15 @@
578 845 </div>',
579 846 esc_attr($statusClass),
580 847 $wrapper_attributes, // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
581 848 esc_html__('Availability:', 'fluent-cart'),
582 - esc_html($stockLabel)
849 + esc_html($stockLabel),
850 + $badgeAttributes // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- values passed through esc_attr() above
583 851 );
584 852 }
585 853
586 854 public function renderSku($wrapper_attributes = '', $showLabel = true, $label = '', $variant = null)
587 855 {
588 - if (!$variant) {
589 - $variant = $this->defaultVariant ?: $this->product->variants->first();
590 - }
591 -
592 - if (!$variant || empty($variant->sku)) {
593 - return;
594 - }
595 -
596 856 if (!$label) {
597 857 $label = __('SKU:', 'fluent-cart');
598 858 }
599 859
@@ -601,9 +861,13 @@
601 861 if ($showLabel && $label) {
602 862 $labelHtml = sprintf('<span class="fct-product-sku__label">%s</span> ', esc_html($label));
603 863 }
604 864
605 - echo sprintf(
865 + if ($variant) {
866 + if (empty($variant->sku)) {
867 + return;
868 + }
869 + echo sprintf(
606 870 '<div class="fct-product-sku">
607 871 <div %s>
608 872 %s<span class="fct-product-sku__value" data-fluent-cart-product-sku>%s</span>
609 873 </div>
@@ -610,11 +874,112 @@
610 874 </div>',
611 875 $wrapper_attributes, // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
612 876 $labelHtml,
613 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
614 917 );
615 918 }
616 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 +
617 982 public function renderExcerpt()
618 983 {
619 984 $excerpt = $this->product->post_excerpt;
620 985 if (!$excerpt) {
@@ -619,25 +984,47 @@
619 984 $excerpt = $this->product->post_excerpt;
620 985 if (!$excerpt) {
621 986 return;
622 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);
623 996 ?>
624 997 <div class="fct-product-excerpt" aria-labelledby="fct-product-summary-title">
625 998 <p><?php echo wp_kses_post($excerpt); ?></p>
626 999 </div>
627 1000 <?php
628 -
1001 + do_action('fluent_cart/product/single/after_excerpt_block', $gateContext);
629 1002 }
630 1003
631 1004 public function renderDescription()
632 1005 {
633 - $post = get_post($this->product->ID);
634 - if (!$post || empty($post->post_content)) {
1006 + $productPost = get_post($this->product->ID);
1007 + if (!$productPost || empty($productPost->post_content)) {
635 1008 return;
636 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 + }
637 1024 ?>
638 1025 <div class="fct-product-description">
639 - <?php echo apply_filters('the_content', $post->post_content); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
1026 + <?php echo $content; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
640 1027 </div>
641 1028 <?php
642 1029 }
643 1030
@@ -642,8 +1029,14 @@
642 1029 }
643 1030
644 1031 public function renderPrices()
645 1032 {
1033 + $gateContext = RenderGate::context($this->product, RenderGate::SCOPE_SINGLE, $this->defaultVariant);
1034 +
1035 + if (!RenderGate::shouldRender('price', $gateContext)) {
1036 + return;
1037 + }
1038 +
646 1039 if ($this->product->detail->variation_type === 'simple') {
647 1040 // we have to render for the simple product
648 1041
649 1042 $first_price = $this->product->variants()->first();
@@ -657,97 +1050,76 @@
657 1050 $comparePrice = $first_price ? (int)$first_price->compare_price : 0;
658 1051 if ($comparePrice <= $itemPrice) {
659 1052 $comparePrice = 0;
660 1053 }
661 - do_action('fluent_cart/product/single/before_price_block', [
1054 + do_action('fluent_cart/product/single/before_price_block', RenderContext::decorate([
662 1055 'product' => $this->product,
663 1056 'current_price' => $itemPrice,
664 1057 'scope' => 'price_range'
665 - ]);
1058 + ]));
666 1059 ?>
667 - <?php
1060 + <div class="fct-price-range fct-product-prices">
668 1061
669 - if ($comparePrice) {
670 - $aria_label = sprintf(
671 - /* translators: 1: Original price, 2: Current item price */
672 - __('Original Price: %1$s, Price: %2$s', 'fluent-cart'),
673 - Helper::toDecimal($comparePrice),
674 - Helper::toDecimal($itemPrice)
675 - );
676 - } else {
677 - $aria_label = sprintf(
678 - /* translators: 1: Current item price */
679 - __('Price: %1$s', 'fluent-cart'),
680 - Helper::toDecimal($itemPrice)
681 - );
682 - }
683 -
684 - ?>
685 - <div class="fct-price-range fct-product-prices" role="term"
686 - aria-label="<?php echo esc_attr($aria_label); ?>">
687 -
688 1062 <?php if ($comparePrice): ?>
689 1063 <span class="fct-compare-price">
690 - <del aria-label="<?php echo esc_attr(__('Original price', 'fluent-cart')); ?>"><?php echo esc_html(Helper::toDecimal($comparePrice)); ?></del>
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>
691 1066 </span>
692 1067 <?php endif; ?>
693 - <span class="fct-item-price" aria-label="<?php echo esc_attr(__('Current price', 'fluent-cart')); ?>">
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>
694 1070 <?php echo esc_html(Helper::toDecimal($itemPrice)); ?>
695 - <?php do_action('fluent_cart/product/after_price', [
1071 + <?php do_action('fluent_cart/product/after_price', RenderContext::decorate([
696 1072 'product' => $this->product,
1073 + 'variant' => $first_price,
697 1074 'current_price' => $itemPrice,
698 1075 'scope' => 'price_range'
699 - ]); ?>
1076 + ])); ?>
1077 + <?php RenderHelper::renderPriceSuffix($this->product, $first_price, 'price_range'); ?>
700 1078 </span>
701 1079 </div>
702 1080 <?php
703 - do_action('fluent_cart/product/single/after_price_block', [
1081 + do_action('fluent_cart/product/single/after_price_block', RenderContext::decorate([
704 1082 'product' => $this->product,
705 1083 'current_price' => $itemPrice,
706 1084 'scope' => 'price_range'
707 - ]);
1085 + ]));
708 1086 return;
709 1087 }
710 1088 $min_price = $this->product->detail->min_price;
711 1089 $max_price = $this->product->detail->max_price;
712 1090
713 - do_action('fluent_cart/product/single/before_price_range_block', [
1091 + do_action('fluent_cart/product/single/before_price_range_block', RenderContext::decorate([
714 1092 'product' => $this->product,
715 1093 'current_price' => $min_price,
716 1094 'scope' => 'price_range'
717 - ]);
1095 + ]));
718 1096 ?>
719 - <?php
720 - $aria_label = sprintf(
721 - /* translators: 1: Minimum price, 2: Maximum price */
722 - __('Price range: %1$s - %2$s', 'fluent-cart'),
723 - Helper::toDecimal($min_price),
724 - Helper::toDecimal($max_price)
725 - );
726 - ?>
727 - <div class="fct-product-prices fct-price-range" role="term" aria-label="<?php echo esc_attr($aria_label); ?>">
1097 + <div class="fct-product-prices fct-price-range">
728 1098
729 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>
730 1101 <span class="fct-min-price"><?php echo esc_html(Helper::toDecimal($min_price)); ?></span>
731 1102 <span class="fct-price-separator" aria-hidden="true">-</span>
1103 + <span class="fct-sr-only"><?php echo esc_html__('to', 'fluent-cart'); ?></span>
732 1104 <?php endif; ?>
733 1105 <span class="fct-max-price">
734 1106 <?php echo esc_html(Helper::toDecimal($max_price)); ?>
735 1107 </span>
736 1108
737 - <?php do_action('fluent_cart/product/after_price', [
1109 + <?php do_action('fluent_cart/product/after_price', RenderContext::decorate([
738 1110 'product' => $this->product,
739 1111 'current_price' => $min_price,
740 1112 'scope' => 'price_range'
741 - ]); ?>
1113 + ])); ?>
742 1114
743 1115 </div>
744 1116 <?php
745 - do_action('fluent_cart/product/single/after_price_range_block', [
1117 + do_action('fluent_cart/product/single/after_price_range_block', RenderContext::decorate([
746 1118 'product' => $this->product,
747 1119 'current_price' => $min_price,
748 1120 'scope' => 'price_range'
749 - ]);
1121 + ]));
750 1122 }
751 1123
752 1124 public function renderVariants($atts = [])
753 1125 {
@@ -772,19 +1144,19 @@
772 1144 ?>
773 1145 <div class="<?php echo esc_attr(implode(' ', $classes)); ?>" role="radiogroup"
774 1146 aria-label="<?php esc_attr_e('Product Variants', 'fluent-cart'); ?>">
775 1147 <?php foreach ($variants as $variant) {
776 - do_action('fluent_cart/product/single/before_variant_item', [
1148 + do_action('fluent_cart/product/single/before_variant_item', RenderContext::decorate([
777 1149 'product' => $this->product,
778 1150 'variant' => $variant,
779 1151 'scope' => 'product_variant_item'
780 - ]);
1152 + ]));
781 1153 $this->renderVariationItem($variant, $this->defaultVariationId);
782 - do_action('fluent_cart/product/single/after_variant_item', [
1154 + do_action('fluent_cart/product/single/after_variant_item', RenderContext::decorate([
783 1155 'product' => $this->product,
784 1156 'variant' => $variant,
785 1157 'scope' => 'product_variant_item'
786 - ]);
1158 + ]));
787 1159 } ?>
788 1160 </div>
789 1161 <?php
790 1162 }
@@ -790,17 +1162,24 @@
790 1162 }
791 1163
792 1164 public function renderItemPrice()
793 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 +
794 1173 if ($this->product->detail->variation_type === 'simple' && !$this->hasSubscription) {
795 1174 return; // for simple product we already rendered the price
796 1175 }
797 1176
798 - do_action('fluent_cart/product/single/before_price_block', [
1177 + do_action('fluent_cart/product/single/before_price_block', RenderContext::decorate([
799 1178 'product' => $this->product,
800 1179 'current_price' => $this->defaultVariant ? $this->defaultVariant->item_price : 0,
801 1180 'scope' => 'product_variant_price'
802 - ]);
1181 + ]));
803 1182
804 1183 foreach ($this->product->variants as $variant) {
805 1184 if ($this->shouldRenderPriceInPriceSection()) {
806 1185 $this->renderVariantPricingWrapperStart($variant);
@@ -817,13 +1196,9 @@
817 1196 ];
818 1197
819 1198 $this->renderComparePriceWrapperStart($atts);
820 1199 $this->renderVariationComparePrice($variant);
821 - if ($paymentType === 'onetime') {
822 - echo esc_html(Helper::toDecimal($variant->item_price));
823 - } else {
824 - $this->applyVariationPriceFilter($variant, $paymentType);
825 - }
1200 + $this->applyVariationPriceFilter($variant, $paymentType);
826 1201 $this->renderComparePriceWrapperEnd();
827 1202 }
828 1203
829 1204 $this->renderVariantPricingWrapperEnd();
@@ -838,18 +1213,31 @@
838 1213
839 1214
840 1215
841 1216
842 - do_action('fluent_cart/product/single/after_price_block', [
1217 + do_action('fluent_cart/product/single/after_price_block', RenderContext::decorate([
843 1218 'product' => $this->product,
844 1219 'current_price' => $this->defaultVariant ? $this->defaultVariant->item_price : 0,
845 1220 'scope' => 'product_variant_price'
846 - ]);
1221 + ]));
847 1222 }
848 1223
849 1224 public function shouldRenderPriceInPriceSection(): bool
850 1225 {
851 - return !($this->viewType === 'text' && $this->columnType === 'one');
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;
852 1240 }
853 1241
854 1242 public function applyVariationPriceFilter($variant, $paymentType = 'onetime')
855 1243 {
@@ -858,13 +1246,15 @@
858 1246 'product' => $this->product,
859 1247 'variant' => $variant,
860 1248 'scope' => 'product_variant_price'
861 1249 ]));
862 - do_action('fluent_cart/product/after_price', [
1250 + do_action('fluent_cart/product/after_price', RenderContext::decorate([
863 1251 'product' => $this->product,
1252 + 'variant' => $variant,
864 1253 'current_price' => $variant->item_price,
865 1254 'scope' => 'product_variant_price'
866 - ]);
1255 + ]));
1256 + RenderHelper::renderPriceSuffix($this->product, $variant, 'product_variant_price');
867 1257 }
868 1258
869 1259 public function renderComparePriceWrapperStart($atts = [])
870 1260 {
@@ -886,8 +1276,9 @@
886 1276 return;
887 1277 } ?>
888 1278
889 1279 <span class="fct-compare-price">
1280 + <span class="fct-sr-only"><?php echo esc_html__('Original price:', 'fluent-cart'); ?></span>
890 1281 <del><?php echo esc_html(Helper::toDecimal($variant->compare_price)); ?></del>
891 1282 </span>
892 1283 <?php
893 1284 }
@@ -973,8 +1364,14 @@
973 1364 <?php }
974 1365
975 1366 public function renderQuantity()
976 1367 {
1368 + $gateContext = RenderGate::context($this->product, RenderGate::SCOPE_SINGLE, $this->defaultVariant);
1369 +
1370 + if (!RenderGate::shouldRender('quantity', $gateContext)) {
1371 + return;
1372 + }
1373 +
977 1374 $soldIndividually = $this->product->soldIndividually();
978 1375
979 1376 if (!$this->hasOnetime || $soldIndividually) {
980 1377 return;
@@ -993,12 +1390,12 @@
993 1390 if ($this->hasSubscription && Arr::get($defaultVariantData, 'payment_type') !== 'onetime') {
994 1391 $attributes['class'] .= ' is-hidden';
995 1392 }
996 1393
997 - do_action('fluent_cart/product/single/before_quantity_block', [
1394 + do_action('fluent_cart/product/single/before_quantity_block', RenderContext::decorate([
998 1395 'product' => $this->product,
999 1396 'scope' => 'product_quantity_block'
1000 - ]);
1397 + ]));
1001 1398 ?>
1002 1399 <div <?php $this->renderAttributes($attributes); ?>>
1003 1400 <label for="fct-product-qty-input" class="quantity-title">
1004 1401 <?php esc_html_e('Quantity', 'fluent-cart'); ?>
@@ -1042,23 +1439,39 @@
1042 1439 </button>
1043 1440 </div>
1044 1441 </div>
1045 1442 <?php
1046 - do_action('fluent_cart/product/single/after_quantity_block', [
1443 + do_action('fluent_cart/product/single/after_quantity_block', RenderContext::decorate([
1047 1444 'product' => $this->product,
1048 1445 'scope' => 'product_quantity_block'
1049 - ]);
1446 + ]));
1050 1447 }
1051 1448
1052 1449 public function renderPurchaseButtons($atts = [])
1053 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 +
1054 1459 $buyNowButtonAtts = $atts;
1055 1460 $this->renderBuyNowButton($buyNowButtonAtts);
1056 1461 $this->renderAddToCartButton($atts);
1462 +
1463 + do_action('fluent_cart/product/single/after_actions_block', $gateContext);
1057 1464 }
1058 1465
1059 1466 public function renderBuyNowButton($atts = [])
1060 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 +
1061 1474 // Stock management check using isStock() method
1062 1475 // if (ModuleSettings::isActive('stock_management')) {
1063 1476 // if ($this->product->detail->variation_type === 'simple' && $this->defaultVariant) {
1064 1477 // if (!$this->defaultVariant->isStock()) {
@@ -1107,8 +1520,17 @@
1107 1520 $buyNowAttributes['data-fct-instant-checkout-button'] = '';
1108 1521 $buyNowAttributes['data-enable-modal-checkout'] = 'yes';
1109 1522 }
1110 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 +
1111 1533 $buyButtonText = apply_filters('fluent_cart/product/buy_now_button_text', $atts['buy_now_text'], [
1112 1534 'product' => $this->product
1113 1535 ]);
1114 1536
@@ -1123,9 +1545,9 @@
1123 1545 $variantTitle
1124 1546 )
1125 1547 : $buyButtonText;
1126 1548 ?>
1127 - <a <?php $this->renderAttributes($buyNowAttributes); ?> aria-label="<?php echo esc_attr($buyNowAriaLabel); ?>">
1549 + <a <?php echo $wrapperAttributes; ?> aria-label="<?php echo esc_attr($buyNowAriaLabel); ?>">
1128 1550 <?php echo wp_kses_post($buyButtonText); ?>
1129 1551 </a>
1130 1552 <?php
1131 1553 }
@@ -1131,15 +1553,24 @@
1131 1553 }
1132 1554
1133 1555 public function renderBuyNowButtonBlock($atts = [])
1134 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 +
1135 1565 $text = Arr::get($atts, 'text', __('Buy Now', 'fluent-cart'));
1136 1566 $variantIds = Arr::get($atts, 'variant_ids', []);
1137 1567 $variantId = Arr::get($variantIds, 0);
1568 + $customClass = trim(Arr::get($atts, 'class', ''));
1569 + $extraClass = trim(Arr::get($atts, 'extra_class', ''));
1138 1570
1139 1571 $defaults = [
1140 1572 'buy_now_text' => $text,
1141 - 'class' => '',
1142 1573 'target' => '',
1143 1574 'rel' => '',
1144 1575 'is_shortcode' => false,
1145 1576 ];
@@ -1159,9 +1590,13 @@
1159 1590 'item_id' => $variantId ?? '',
1160 1591 'quantity' => 1
1161 1592 ], site_url());
1162 1593
1163 - $buyNowClass = trim('wp-block-button__link wp-element-button ' . Arr::get($atts, 'class', ''));
1594 + $buyNowClass = $customClass ?: 'wp-block-button__link wp-element-button';
1595 + if ($extraClass) {
1596 + $buyNowClass .= ' ' . $extraClass;
1597 + }
1598 + $buyNowClass = trim($buyNowClass);
1164 1599 if ($stockStatus === 'out-of-stock') {
1165 1600 $buyNowClass .= ' out-of-stock';
1166 1601 }
1167 1602
@@ -1191,22 +1626,15 @@
1191 1626 if ($enableModalCheckout) {
1192 1627 $buyNowAttributes['data-fct-instant-checkout-button'] = '';
1193 1628 $buyNowAttributes['data-enable-modal-checkout'] = 'yes';
1194 1629 }
1195 - $wrapperAttributes = '';
1196 - $isShortcode = !empty($atts['is_shortcode']);
1197 -
1630 + $isShortcode = !empty($atts['is_shortcode']);
1198 1631 if ($isShortcode) {
1199 - foreach ($buyNowAttributes as $attr => $value) {
1200 - if ($value === '') {
1201 - $wrapperAttributes .= esc_attr($attr) . ' ';
1202 - } else {
1203 - $wrapperAttributes .= sprintf('%s="%s" ', esc_attr($attr), esc_attr((string)$value));
1204 - }
1205 - }
1206 - $wrapperAttributes = trim($wrapperAttributes);
1632 + ob_start();
1633 + $this->renderAttributes($buyNowAttributes);
1634 + $wrapperAttributes = ob_get_clean();
1207 1635 } else {
1208 - $wrapperAttributes = get_block_wrapper_attributes($buyNowAttributes);
1636 + $wrapperAttributes = RenderHelper::getBlockWrapperAttributes($buyNowAttributes);
1209 1637 }
1210 1638
1211 1639 $buyButtonText = apply_filters('fluent_cart/product/buy_now_button_text', $atts['buy_now_text'], [
1212 1640 'product' => $this->product
@@ -1219,8 +1647,14 @@
1219 1647 }
1220 1648
1221 1649 public function renderAddToCartButton($atts = [])
1222 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 +
1223 1657 $defaults = [
1224 1658 'buy_now_text' => __('Buy Now', 'fluent-cart'),
1225 1659 'add_to_cart_text' => __('Add To Cart', 'fluent-cart'),
1226 1660 ];
@@ -1232,8 +1666,9 @@
1232 1666 'data-cart-id' => $this->defaultVariant ? $this->defaultVariant->id : '',
1233 1667 'data-product-id' => $this->product->ID,
1234 1668 'class' => 'fluent-cart-add-to-cart-button',
1235 1669 'data-variation-type' => $this->product->detail->variation_type,
1670 + 'data-icon-only' => !empty($atts['is_icon_only']) ? 'true' : 'false',
1236 1671 ];
1237 1672
1238 1673 $defaultVariantData = $this->getDefaultVariantData();
1239 1674
@@ -1256,10 +1691,28 @@
1256 1691
1257 1692 $addToCartText = apply_filters('fluent_cart/product/add_to_cart_text', $atts['add_to_cart_text'], [
1258 1693 'product' => $this->product
1259 1694 ]);
1260 - // Render add to cart if product supports onetime, or if out of stock (to show "Not Available")
1261 - if ($this->hasOnetime || $isOutOfStock) :
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) :
1262 1715 ?>
1263 1716 <?php
1264 1717 $variantTitle = $this->defaultVariant ? $this->defaultVariant->variation_title : '';
1265 1718 $addToCartAriaLabel = $variantTitle
@@ -1270,9 +1723,9 @@
1270 1723 $variantTitle
1271 1724 )
1272 1725 : $addToCartText;
1273 1726 ?>
1274 - <button <?php $this->renderAttributes($cartAttributes); ?>
1727 + <button <?php echo $wrapperAttributes; ?>
1275 1728 aria-label="<?php echo esc_attr($addToCartAriaLabel); ?>">
1276 1729 <span class="text">
1277 1730 <?php echo wp_kses_post($addToCartText); ?>
1278 1731 </span>
@@ -1296,11 +1749,18 @@
1296 1749 }
1297 1750
1298 1751 public function renderAddToCartButtonBlock($atts = [])
1299 1752 {
1753 + $gateContext = RenderGate::context($this->product, RenderGate::SCOPE_SINGLE, $this->defaultVariant);
1300 1754
1755 + // Same gate as the in-section button — see renderBuyNowButtonBlock().
1756 + if (!RenderGate::shouldRenderPurchaseButton('add_to_cart_button', $gateContext)) {
1757 + return;
1758 + }
1759 +
1301 1760 $text = Arr::get($atts, 'text', __('Add To Cart', 'fluent-cart'));
1302 - $extraClass = trim(Arr::get($atts, 'class', ''));
1761 + $customClass = trim(Arr::get($atts, 'class', ''));
1762 + $extraClass = trim(Arr::get($atts, 'extra_class', ''));
1303 1763
1304 1764 $defaults = [
1305 1765 'add_to_cart_text' => $text,
1306 1766 ];
@@ -1306,14 +1766,18 @@
1306 1766 ];
1307 1767
1308 1768 $atts = wp_parse_args($atts, $defaults);
1309 1769
1770 + $buttonClasses = ['fct-loader', 'wp-block-button__link wp-element-button'];
1771 + $buttonClass = $customClass ?: implode(' ', $buttonClasses);
1772 +
1310 1773 $cartAttributes = [
1311 1774 'data-fluent-cart-add-to-cart-button' => '',
1312 - 'class' => 'wp-block-button__link wp-element-button fct-loader',
1775 + 'class' => $buttonClass,
1313 1776 'data-cart-id' => $this->defaultVariant ? $this->defaultVariant->id : '',
1314 1777 'data-product-id' => $this->product->ID,
1315 1778 'data-variation-type' => $this->product->detail->variation_type,
1779 + 'data-icon-only' => !empty($atts['is_icon_only']) ? 'true' : 'false',
1316 1780 ];
1317 1781
1318 1782 if ($extraClass) {
1319 1783 $cartAttributes['class'] .= ' ' . $extraClass;
@@ -1344,22 +1808,15 @@
1344 1808 $addToCartText = apply_filters('fluent_cart/product/add_to_cart_text', $atts['add_to_cart_text'], [
1345 1809 'product' => $this->product
1346 1810 ]);
1347 1811
1348 - $wrapperAttributes = '';
1349 - $isShortcode = !empty($atts['is_shortcode']);
1350 -
1812 + $isShortcode = !empty($atts['is_shortcode']);
1351 1813 if ($isShortcode) {
1352 - foreach ($cartAttributes as $attr => $value) {
1353 - if ($value === '') {
1354 - $wrapperAttributes .= esc_attr($attr) . ' ';
1355 - } else {
1356 - $wrapperAttributes .= sprintf('%s="%s" ', esc_attr($attr), esc_attr((string)$value));
1357 - }
1358 - }
1359 - $wrapperAttributes = trim($wrapperAttributes);
1814 + ob_start();
1815 + $this->renderAttributes($cartAttributes);
1816 + $wrapperAttributes = ob_get_clean();
1360 1817 } else {
1361 - $wrapperAttributes = get_block_wrapper_attributes($cartAttributes);
1818 + $wrapperAttributes = RenderHelper::getBlockWrapperAttributes($cartAttributes);
1362 1819 }
1363 1820
1364 1821 ?>
1365 1822
@@ -1395,9 +1852,9 @@
1395 1852 <p class="has-text-align-center has-large-font-size m-0">
1396 1853 <?php echo esc_html__('No Product Found!', 'fluent-cart'); ?>
1397 1854 </p>
1398 1855
1399 - <p class="has-text-align-center">
1856 + <p class="has-text-align-center m-0">
1400 1857 <?php echo esc_html__('You can try clearing any filters.', 'fluent-cart'); ?>
1401 1858 </p>
1402 1859 </div>
1403 1860 <?php
@@ -1434,14 +1891,8 @@
1434 1891 if ($variant->id == $defaultId) {
1435 1892 $itemClasses[] = 'selected';
1436 1893 }
1437 1894
1438 - $priceSuffix = apply_filters('fluent_cart/product/price_suffix_atts', '', [
1439 - 'product' => $this->product,
1440 - 'variant' => $variant,
1441 - 'scope' => 'variant_item'
1442 - ]);
1443 -
1444 1895 $renderingAttributes = [
1445 1896 'data-fluent-cart-product-variant' => '',
1446 1897 'data-cart-id' => $variant->id,
1447 1898 'data-item-stock' => $variant->isStock() ? 'in-stock' : 'out-of-stock',
@@ -1449,11 +1900,11 @@
1449 1900 'data-payment-type' => $paymentType,
1450 1901 'data-available-stock' => $availableStocks,
1451 1902 'data-item-price' => Helper::toDecimal($variant->item_price),
1452 1903 'data-compare-price' => $comparePrice,
1453 - 'data-price-suffix' => $priceSuffix,
1454 1904 'data-stock-management' => ModuleSettings::isActive('stock_management') ? 'yes' : 'no',
1455 1905 'data-sku' => $variant->sku ?? '',
1906 + 'data-package-info' => $this->getVariantPackageInfoJson($variant),
1456 1907 ];
1457 1908
1458 1909 if ($paymentType === 'subscription') {
1459 1910 $renderingAttributes['data-subscription-terms'] = $variant->getSubscriptionTermsText(true);
@@ -1496,30 +1947,29 @@
1496 1947 if ($this->viewType === 'both' || $this->viewType === 'image') {
1497 1948 $this->renderVariantImage($variant);
1498 1949 }
1499 1950 ?>
1500 - <?php
1501 - if ($this->viewType === 'both' || $this->viewType === 'text') {
1502 - echo '<div class="fct-product-variant-title" aria-label="' . esc_attr(__('Variant title', 'fluent-cart')) . '">' . esc_html($variant->variation_title) . '</div>';
1503 - }
1504 - ?>
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; ?>
1505 1959 </div>
1506 1960
1507 - <?php if ($this->viewType === 'text' && $paymentType === 'subscription' && $this->columnType === 'one'): ?>
1508 -
1509 - <?php $this->renderSubscriptionInfo($variant); ?>
1510 - <?php endif; ?>
1511 -
1512 - <?php if ($this->viewType === 'text' && $this->columnType === 'one'): ?>
1961 + <?php if (!$this->shouldRenderPriceInPriceSection()): ?>
1513 1962 <div class="fct-product-variant-price">
1514 1963 <?php if ($comparePrice): ?>
1515 1964 <div class="fct-product-variant-compare-price">
1516 - <del aria-label="<?php echo esc_attr(__('Original price', 'fluent-cart')); ?>">
1965 + <span class="fct-sr-only"><?php echo esc_html__('Original price:', 'fluent-cart'); ?></span>
1966 + <del>
1517 1967 <span><?php echo esc_html(Helper::toDecimal($comparePrice)); ?></span></del>
1518 1968 </div>
1519 1969 <?php endif; ?>
1520 - <div class="fct-product-variant-item-price"
1521 - aria-label="<?php echo esc_attr(__('Current price', 'fluent-cart')); ?>">
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>
1522 1972 <span><?php echo esc_html(Helper::toDecimal($itemPrice)); ?></span>
1523 1973 </div>
1524 1974 </div>
1525 1975 <?php endif; ?>
@@ -1643,21 +2093,21 @@
1643 2093 //Convert to collection safely before sorting
1644 2094 $variants = (new Collection($variants))->sortBy('serial_index')->values();
1645 2095
1646 2096 foreach ($variants as $variant) {
1647 - do_action('fluent_cart/product/single/before_variant_item', [
2097 + do_action('fluent_cart/product/single/before_variant_item', RenderContext::decorate([
1648 2098 'product' => $this->product,
1649 2099 'variant' => $variant,
1650 2100 'scope' => 'product_variant_item'
1651 - ]);
2101 + ]));
1652 2102
1653 2103 $this->renderVariationItem($variant, $this->defaultVariationId);
1654 2104
1655 - do_action('fluent_cart/product/single/after_variant_item', [
2105 + do_action('fluent_cart/product/single/after_variant_item', RenderContext::decorate([
1656 2106 'product' => $this->product,
1657 2107 'variant' => $variant,
1658 2108 'scope' => 'product_variant_item'
1659 - ]);
2109 + ]));
1660 2110 }
1661 2111 ?>
1662 2112 </div>
1663 2113