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 +554 -170 1.3.23 → 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();
@@ -257,8 +297,33 @@
257 297 }
258 298
259 299 public function renderBuySection($atts = [])
260 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 +
261 326 $otherInfo = (array)Arr::get($this->product->detail, 'other_info');
262 327 $groupBy = Arr::get($otherInfo, 'group_pricing_by', 'repeat_interval'); //repeat_interval,payment_type,none
263 328
264 329 $this->renderBuySectionWrapperStart();
@@ -277,8 +342,25 @@
277 342 }
278 343
279 344 public function renderVariationDisplay($atts = [])
280 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 +
281 363 $otherInfo = (array)Arr::get($this->product->detail, 'other_info');
282 364 $groupBy = Arr::get($otherInfo, 'group_pricing_by', 'repeat_interval'); //repeat_interval,payment_type,none
283 365
284 366 if (count($this->paymentTypes) === 1 || $groupBy === 'none') {
@@ -293,10 +375,13 @@
293 375 $thumbnails = [];
294 376
295 377 $featuredMedia = $this->product->thumbnail ?? Vite::getAssetUrl('images/placeholder.svg');
296 378
297 - if (!$featuredMedia) {
298 - $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');
299 384 }
300 385
301 386 $galleryImage = get_post_meta($this->product->ID, 'fluent-products-gallery-image', true);
302 387
@@ -329,14 +414,37 @@
329 414 if (isset($images[$imageId])) {
330 415 $imageMetaValue = $images[$imageId];
331 416 $this->defaultImageUrl = Arr::get($imageMetaValue, 'media.0.url', '');
332 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', '');
333 429 }
334 430 }
335 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 +
336 443 ?>
337 - <div class="fct-product-gallery-thumb" role="region"
338 - 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) . '"' : ''; ?>>
339 447 <img
340 448 src="<?php echo esc_url($this->defaultImageUrl ?? '') ?>"
341 449 alt="<?php echo esc_attr($this->defaultImageAlt) ?>"
342 450 data-fluent-cart-single-product-page-product-thumbnail
@@ -341,36 +449,117 @@
341 449 alt="<?php echo esc_attr($this->defaultImageAlt) ?>"
342 450 data-fluent-cart-single-product-page-product-thumbnail
343 451 data-default-image-url="<?php echo esc_url($featuredMedia) ?>"
344 452 />
453 + <?php $this->getProductVideoRenderer()->renderInlinePlayers(); ?>
345 454 </div>
346 455 <?php
347 456 }
348 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 +
349 484 public function renderGalleryThumbControls($maxThumbnails = null)
350 485 {
351 486 $totalThumbImages = Arr::pluck($this->images, 'media.*.url');
352 487
353 - 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()){
354 490
355 491 return '';
356 492 }
357 493
358 - // 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).
359 536 $allGalleryImages = [];
537 + $addedMediaKeys = [];
538 + $isAdvVariation = !empty($this->variantTermMap);
360 539 foreach ($this->images as $imageId => $image) {
361 540 if (empty($image['media']) || !is_array($image['media'])) {
362 541 continue;
363 542 }
364 543 foreach ($image['media'] as $item) {
365 - $url = Arr::get($item, 'url', '');
544 + $url = Arr::get($item, 'url', '');
545 + $mediaId = (int) Arr::get($item, 'id', 0);
366 546 if (empty($url)) {
367 547 continue;
368 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 + }
369 556 $allGalleryImages[] = [
370 557 'url' => $url,
371 558 'title' => Arr::get($item, 'title', ''),
372 559 'variation_id' => (string) $imageId,
560 + 'term_id' => (int) ($this->variantTermMap[(int) $imageId] ?? 0),
561 + 'media_id' => $mediaId,
373 562 ];
374 563 }
375 564 }
376 565
@@ -379,9 +568,10 @@
379 568 <div class="fct-gallery-thumb-controls"
380 569 role="toolbar"
381 570 aria-label="<?php echo esc_attr__('Product image thumbnails', 'fluent-cart'); ?>"
382 571 data-fluent-cart-single-product-page-product-thumbnail-controls
383 - 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) ?: '{}'); ?>">
384 574
385 575 <?php $this->renderGalleryThumbControl($maxThumbnails); ?>
386 576
387 577 </div>
@@ -395,45 +585,76 @@
395 585 if ($maxThumbnails !== null && $maxThumbnails <= 0) {
396 586 $maxThumbnails = null; // treat invalid value as "no limit"
397 587 }
398 588
399 - $count = 0;
400 - $totalImages = 0;
589 + $isAdvVariation = !empty($this->variantTermMap);
590 + $countedMediaKeys = [];
591 + $count = 0;
592 + $totalImages = 0;
593 + $videoRenderer = $this->getProductVideoRenderer();
401 594
402 - // 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).
403 603 foreach ($this->images as $imageId => $image) {
404 604 if (empty($image['media']) || !is_array($image['media'])) {
405 605 continue;
406 606 }
407 607 foreach ($image['media'] as $item) {
408 - if (!empty(Arr::get($item, 'url', ''))) {
409 - $totalImages++;
608 + $url = Arr::get($item, 'url', '');
609 + $mediaId = (int) Arr::get($item, 'id', 0);
610 + if (empty($url)) {
611 + continue;
410 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++;
411 621 }
412 622 }
413 623
414 - // Then render up to max
624 + $renderedMediaKeys = [];
625 + // Render up to max; skip already-rendered media for advanced variation products.
415 626 foreach ($this->images as $imageId => $image) {
416 627 if (empty($image['media']) || !is_array($image['media'])) {
417 628 continue;
418 629 }
419 -
420 630 foreach ($image['media'] as $item) {
421 - if (empty(Arr::get($item, 'url', ''))) {
631 + $url = Arr::get($item, 'url', '');
632 + $mediaId = (int) Arr::get($item, 'id', 0);
633 + if (empty($url)) {
422 634 continue;
423 635 }
424 -
636 + if ($isAdvVariation) {
637 + $mediaDedupeKey = $mediaId > 0 ? 'i:' . $mediaId : 'u:' . $url;
638 + if (isset($renderedMediaKeys[$mediaDedupeKey])) {
639 + continue;
640 + }
641 + $renderedMediaKeys[$mediaDedupeKey] = true;
642 + }
425 643 if ($maxThumbnails !== null && $count >= (int) $maxThumbnails) {
644 + $videoRenderer->renderThumbControls(!$this->galleryActiveSet);
426 645 $this->renderGallerySeeMoreButton($totalImages - (int) $maxThumbnails);
427 646 return;
428 647 }
429 -
430 - $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);
431 652 $count++;
432 653 }
433 -
434 654 }
435 655
656 + $videoRenderer->renderThumbControls(!$this->galleryActiveSet);
436 657 }
437 658
438 659 public function renderGallerySeeMoreButton($remainingCount)
439 660 {
@@ -464,9 +685,9 @@
464 685 </button>
465 686 <?php
466 687 }
467 688
468 - public function renderGalleryThumbControlButton($item, $imageId)
689 + public function renderGalleryThumbControlButton($item, $imageId, $termId = 0, $mediaId = 0)
469 690 {
470 691
471 692 $isHidden = ''; //$imageId != $this->defaultVariationId ? 'is-hidden' : '';
472 693 $itemUrl = Arr::get($item, 'url', '');
@@ -482,11 +703,13 @@
482 703 class="fct-gallery-thumb-control-button <?php echo $isSelected ? 'active' : ''; ?> <?php echo esc_attr($isHidden); ?>"
483 704 data-fluent-cart-thumb-control-button
484 705 data-url="<?php echo esc_url($itemUrl); ?>"
485 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); ?>"
486 709 aria-label="<?php echo
487 - /* translators: %s image title */
488 - 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));
489 712 ?>"
490 713 aria-pressed="<?php echo $isSelected ? 'true' : 'false'; ?>"
491 714 tabindex="<?php echo $isSelected ? '0' : '-1'; ?>"
492 715 >
@@ -504,9 +727,14 @@
504 727 }
505 728
506 729 public function renderGallery($args = [])
507 730 {
731 + $gateContext = RenderGate::context($this->product, RenderGate::SCOPE_SINGLE, $this->defaultVariant);
508 732
733 + if (!RenderGate::shouldRender('image', $gateContext)) {
734 + return;
735 + }
736 +
509 737 $defaults = [
510 738 'thumbnail_mode' => 'all', // horizontal, vertical
511 739 'thumb_position' => 'bottom', // bottom, left, right, top
512 740 'scrollable_thumbs' => 'no', // yes / no
@@ -540,13 +768,21 @@
540 768 }
541 769
542 770 public function renderTitle()
543 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);
544 779 ?>
545 780 <div class="fct-product-title">
546 781 <h1 id="fct-product-summary-title"><?php echo esc_html($this->product->post_title); ?></h1>
547 782 </div>
548 783 <?php
784 + do_action('fluent_cart/product/single/after_title_block', $gateContext);
549 785 }
550 786
551 787 public function renderStockAvailability($wrapper_attributes = '')
552 788 {
@@ -554,8 +790,9 @@
554 790 return '';
555 791 }
556 792
557 793 $stockAvailability = $this->product->detail->getStockAvailability();
794 +
558 795
559 796 if (!Arr::get($stockAvailability, 'manage_stock')) {
560 797 return '';
561 798 }
@@ -565,15 +802,44 @@
565 802 // Check default variant stock for both simple and variable products
566 803 if ($this->defaultVariant) {
567 804 $isStock = $isStock && $this->defaultVariant->isStock();
568 805 }
569 - $stockLabel = $isStock ? $stockAvailability['availability'] : __('Out of Stock', 'fluent-cart');
570 - $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 +
571 837 echo sprintf(
572 838 '<div class="fct-product-stock %1$s" role="status" aria-live="polite">
573 839 <div %2$s>
574 840 <span class="fct-stock-label">%3$s</span>
575 - <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>
576 842 %4$s
577 843 </span>
578 844 </div>
579 845 </div>',
@@ -579,22 +845,15 @@
579 845 </div>',
580 846 esc_attr($statusClass),
581 847 $wrapper_attributes, // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
582 848 esc_html__('Availability:', 'fluent-cart'),
583 - esc_html($stockLabel)
849 + esc_html($stockLabel),
850 + $badgeAttributes // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- values passed through esc_attr() above
584 851 );
585 852 }
586 853
587 854 public function renderSku($wrapper_attributes = '', $showLabel = true, $label = '', $variant = null)
588 855 {
589 - if (!$variant) {
590 - $variant = $this->defaultVariant ?: $this->product->variants->first();
591 - }
592 -
593 - if (!$variant || empty($variant->sku)) {
594 - return;
595 - }
596 -
597 856 if (!$label) {
598 857 $label = __('SKU:', 'fluent-cart');
599 858 }
600 859
@@ -602,9 +861,13 @@
602 861 if ($showLabel && $label) {
603 862 $labelHtml = sprintf('<span class="fct-product-sku__label">%s</span> ', esc_html($label));
604 863 }
605 864
606 - echo sprintf(
865 + if ($variant) {
866 + if (empty($variant->sku)) {
867 + return;
868 + }
869 + echo sprintf(
607 870 '<div class="fct-product-sku">
608 871 <div %s>
609 872 %s<span class="fct-product-sku__value" data-fluent-cart-product-sku>%s</span>
610 873 </div>
@@ -611,15 +874,48 @@
611 874 </div>',
612 875 $wrapper_attributes, // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
613 876 $labelHtml,
614 877 esc_html($variant->sku)
615 - );
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 + }
616 900 }
617 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 + */
618 907 public function renderPackageDescription($wrapper_attributes = '', $showName = true, $showDimensions = true, $showProductWeight = true, $showTotalWeight = true, $variant = null)
619 908 {
620 - $variant = $variant ?: ($this->defaultVariant ?: $this->product->variants->first());
621 - (new ProductCardRender($this->product))->renderPackageDescription($wrapper_attributes, $showName, $showDimensions, $showProductWeight, $showTotalWeight, $variant);
909 + (new PackageDescriptionRenderer($this->product))->renderPackageDescription(
910 + $wrapper_attributes,
911 + $showName,
912 + $showDimensions,
913 + $showProductWeight,
914 + $showTotalWeight,
915 + $variant,
916 + $this->defaultVariant
917 + );
622 918 }
623 919
624 920 /**
625 921 * Build a JSON string of package info for a variant (used as data attribute for JS switching).
@@ -625,8 +921,12 @@
625 921 * Build a JSON string of package info for a variant (used as data attribute for JS switching).
626 922 */
627 923 private function getVariantPackageInfoJson(ProductVariation $variant)
628 924 {
925 + if ($variant->fulfillment_type !== 'physical') {
926 + return '';
927 + }
928 +
629 929 $otherInfo = $variant->other_info ?: [];
630 930 $packageSlug = Arr::get($otherInfo, 'package_slug', '');
631 931 $package = Helper::getPackageBySlug($packageSlug);
632 932
@@ -684,25 +984,47 @@
684 984 $excerpt = $this->product->post_excerpt;
685 985 if (!$excerpt) {
686 986 return;
687 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);
688 996 ?>
689 997 <div class="fct-product-excerpt" aria-labelledby="fct-product-summary-title">
690 998 <p><?php echo wp_kses_post($excerpt); ?></p>
691 999 </div>
692 1000 <?php
693 -
1001 + do_action('fluent_cart/product/single/after_excerpt_block', $gateContext);
694 1002 }
695 1003
696 1004 public function renderDescription()
697 1005 {
698 - $post = get_post($this->product->ID);
699 - if (!$post || empty($post->post_content)) {
1006 + $productPost = get_post($this->product->ID);
1007 + if (!$productPost || empty($productPost->post_content)) {
700 1008 return;
701 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 + }
702 1024 ?>
703 1025 <div class="fct-product-description">
704 - <?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 ?>
705 1027 </div>
706 1028 <?php
707 1029 }
708 1030
@@ -707,8 +1029,14 @@
707 1029 }
708 1030
709 1031 public function renderPrices()
710 1032 {
1033 + $gateContext = RenderGate::context($this->product, RenderGate::SCOPE_SINGLE, $this->defaultVariant);
1034 +
1035 + if (!RenderGate::shouldRender('price', $gateContext)) {
1036 + return;
1037 + }
1038 +
711 1039 if ($this->product->detail->variation_type === 'simple') {
712 1040 // we have to render for the simple product
713 1041
714 1042 $first_price = $this->product->variants()->first();
@@ -722,97 +1050,76 @@
722 1050 $comparePrice = $first_price ? (int)$first_price->compare_price : 0;
723 1051 if ($comparePrice <= $itemPrice) {
724 1052 $comparePrice = 0;
725 1053 }
726 - do_action('fluent_cart/product/single/before_price_block', [
1054 + do_action('fluent_cart/product/single/before_price_block', RenderContext::decorate([
727 1055 'product' => $this->product,
728 1056 'current_price' => $itemPrice,
729 1057 'scope' => 'price_range'
730 - ]);
1058 + ]));
731 1059 ?>
732 - <?php
1060 + <div class="fct-price-range fct-product-prices">
733 1061
734 - if ($comparePrice) {
735 - $aria_label = sprintf(
736 - /* translators: 1: Original price, 2: Current item price */
737 - __('Original Price: %1$s, Price: %2$s', 'fluent-cart'),
738 - Helper::toDecimal($comparePrice),
739 - Helper::toDecimal($itemPrice)
740 - );
741 - } else {
742 - $aria_label = sprintf(
743 - /* translators: 1: Current item price */
744 - __('Price: %1$s', 'fluent-cart'),
745 - Helper::toDecimal($itemPrice)
746 - );
747 - }
748 -
749 - ?>
750 - <div class="fct-price-range fct-product-prices" role="term"
751 - aria-label="<?php echo esc_attr($aria_label); ?>">
752 -
753 1062 <?php if ($comparePrice): ?>
754 1063 <span class="fct-compare-price">
755 - <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>
756 1066 </span>
757 1067 <?php endif; ?>
758 - <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>
759 1070 <?php echo esc_html(Helper::toDecimal($itemPrice)); ?>
760 - <?php do_action('fluent_cart/product/after_price', [
1071 + <?php do_action('fluent_cart/product/after_price', RenderContext::decorate([
761 1072 'product' => $this->product,
1073 + 'variant' => $first_price,
762 1074 'current_price' => $itemPrice,
763 1075 'scope' => 'price_range'
764 - ]); ?>
1076 + ])); ?>
1077 + <?php RenderHelper::renderPriceSuffix($this->product, $first_price, 'price_range'); ?>
765 1078 </span>
766 1079 </div>
767 1080 <?php
768 - do_action('fluent_cart/product/single/after_price_block', [
1081 + do_action('fluent_cart/product/single/after_price_block', RenderContext::decorate([
769 1082 'product' => $this->product,
770 1083 'current_price' => $itemPrice,
771 1084 'scope' => 'price_range'
772 - ]);
1085 + ]));
773 1086 return;
774 1087 }
775 1088 $min_price = $this->product->detail->min_price;
776 1089 $max_price = $this->product->detail->max_price;
777 1090
778 - do_action('fluent_cart/product/single/before_price_range_block', [
1091 + do_action('fluent_cart/product/single/before_price_range_block', RenderContext::decorate([
779 1092 'product' => $this->product,
780 1093 'current_price' => $min_price,
781 1094 'scope' => 'price_range'
782 - ]);
1095 + ]));
783 1096 ?>
784 - <?php
785 - $aria_label = sprintf(
786 - /* translators: 1: Minimum price, 2: Maximum price */
787 - __('Price range: %1$s - %2$s', 'fluent-cart'),
788 - Helper::toDecimal($min_price),
789 - Helper::toDecimal($max_price)
790 - );
791 - ?>
792 - <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">
793 1098
794 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>
795 1101 <span class="fct-min-price"><?php echo esc_html(Helper::toDecimal($min_price)); ?></span>
796 1102 <span class="fct-price-separator" aria-hidden="true">-</span>
1103 + <span class="fct-sr-only"><?php echo esc_html__('to', 'fluent-cart'); ?></span>
797 1104 <?php endif; ?>
798 1105 <span class="fct-max-price">
799 1106 <?php echo esc_html(Helper::toDecimal($max_price)); ?>
800 1107 </span>
801 1108
802 - <?php do_action('fluent_cart/product/after_price', [
1109 + <?php do_action('fluent_cart/product/after_price', RenderContext::decorate([
803 1110 'product' => $this->product,
804 1111 'current_price' => $min_price,
805 1112 'scope' => 'price_range'
806 - ]); ?>
1113 + ])); ?>
807 1114
808 1115 </div>
809 1116 <?php
810 - do_action('fluent_cart/product/single/after_price_range_block', [
1117 + do_action('fluent_cart/product/single/after_price_range_block', RenderContext::decorate([
811 1118 'product' => $this->product,
812 1119 'current_price' => $min_price,
813 1120 'scope' => 'price_range'
814 - ]);
1121 + ]));
815 1122 }
816 1123
817 1124 public function renderVariants($atts = [])
818 1125 {
@@ -837,19 +1144,19 @@
837 1144 ?>
838 1145 <div class="<?php echo esc_attr(implode(' ', $classes)); ?>" role="radiogroup"
839 1146 aria-label="<?php esc_attr_e('Product Variants', 'fluent-cart'); ?>">
840 1147 <?php foreach ($variants as $variant) {
841 - do_action('fluent_cart/product/single/before_variant_item', [
1148 + do_action('fluent_cart/product/single/before_variant_item', RenderContext::decorate([
842 1149 'product' => $this->product,
843 1150 'variant' => $variant,
844 1151 'scope' => 'product_variant_item'
845 - ]);
1152 + ]));
846 1153 $this->renderVariationItem($variant, $this->defaultVariationId);
847 - do_action('fluent_cart/product/single/after_variant_item', [
1154 + do_action('fluent_cart/product/single/after_variant_item', RenderContext::decorate([
848 1155 'product' => $this->product,
849 1156 'variant' => $variant,
850 1157 'scope' => 'product_variant_item'
851 - ]);
1158 + ]));
852 1159 } ?>
853 1160 </div>
854 1161 <?php
855 1162 }
@@ -855,17 +1162,24 @@
855 1162 }
856 1163
857 1164 public function renderItemPrice()
858 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 +
859 1173 if ($this->product->detail->variation_type === 'simple' && !$this->hasSubscription) {
860 1174 return; // for simple product we already rendered the price
861 1175 }
862 1176
863 - do_action('fluent_cart/product/single/before_price_block', [
1177 + do_action('fluent_cart/product/single/before_price_block', RenderContext::decorate([
864 1178 'product' => $this->product,
865 1179 'current_price' => $this->defaultVariant ? $this->defaultVariant->item_price : 0,
866 1180 'scope' => 'product_variant_price'
867 - ]);
1181 + ]));
868 1182
869 1183 foreach ($this->product->variants as $variant) {
870 1184 if ($this->shouldRenderPriceInPriceSection()) {
871 1185 $this->renderVariantPricingWrapperStart($variant);
@@ -882,13 +1196,9 @@
882 1196 ];
883 1197
884 1198 $this->renderComparePriceWrapperStart($atts);
885 1199 $this->renderVariationComparePrice($variant);
886 - if ($paymentType === 'onetime') {
887 - echo esc_html(Helper::toDecimal($variant->item_price));
888 - } else {
889 - $this->applyVariationPriceFilter($variant, $paymentType);
890 - }
1200 + $this->applyVariationPriceFilter($variant, $paymentType);
891 1201 $this->renderComparePriceWrapperEnd();
892 1202 }
893 1203
894 1204 $this->renderVariantPricingWrapperEnd();
@@ -903,18 +1213,31 @@
903 1213
904 1214
905 1215
906 1216
907 - do_action('fluent_cart/product/single/after_price_block', [
1217 + do_action('fluent_cart/product/single/after_price_block', RenderContext::decorate([
908 1218 'product' => $this->product,
909 1219 'current_price' => $this->defaultVariant ? $this->defaultVariant->item_price : 0,
910 1220 'scope' => 'product_variant_price'
911 - ]);
1221 + ]));
912 1222 }
913 1223
914 1224 public function shouldRenderPriceInPriceSection(): bool
915 1225 {
916 - 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;
917 1240 }
918 1241
919 1242 public function applyVariationPriceFilter($variant, $paymentType = 'onetime')
920 1243 {
@@ -923,13 +1246,15 @@
923 1246 'product' => $this->product,
924 1247 'variant' => $variant,
925 1248 'scope' => 'product_variant_price'
926 1249 ]));
927 - do_action('fluent_cart/product/after_price', [
1250 + do_action('fluent_cart/product/after_price', RenderContext::decorate([
928 1251 'product' => $this->product,
1252 + 'variant' => $variant,
929 1253 'current_price' => $variant->item_price,
930 1254 'scope' => 'product_variant_price'
931 - ]);
1255 + ]));
1256 + RenderHelper::renderPriceSuffix($this->product, $variant, 'product_variant_price');
932 1257 }
933 1258
934 1259 public function renderComparePriceWrapperStart($atts = [])
935 1260 {
@@ -951,8 +1276,9 @@
951 1276 return;
952 1277 } ?>
953 1278
954 1279 <span class="fct-compare-price">
1280 + <span class="fct-sr-only"><?php echo esc_html__('Original price:', 'fluent-cart'); ?></span>
955 1281 <del><?php echo esc_html(Helper::toDecimal($variant->compare_price)); ?></del>
956 1282 </span>
957 1283 <?php
958 1284 }
@@ -1038,8 +1364,14 @@
1038 1364 <?php }
1039 1365
1040 1366 public function renderQuantity()
1041 1367 {
1368 + $gateContext = RenderGate::context($this->product, RenderGate::SCOPE_SINGLE, $this->defaultVariant);
1369 +
1370 + if (!RenderGate::shouldRender('quantity', $gateContext)) {
1371 + return;
1372 + }
1373 +
1042 1374 $soldIndividually = $this->product->soldIndividually();
1043 1375
1044 1376 if (!$this->hasOnetime || $soldIndividually) {
1045 1377 return;
@@ -1058,12 +1390,12 @@
1058 1390 if ($this->hasSubscription && Arr::get($defaultVariantData, 'payment_type') !== 'onetime') {
1059 1391 $attributes['class'] .= ' is-hidden';
1060 1392 }
1061 1393
1062 - do_action('fluent_cart/product/single/before_quantity_block', [
1394 + do_action('fluent_cart/product/single/before_quantity_block', RenderContext::decorate([
1063 1395 'product' => $this->product,
1064 1396 'scope' => 'product_quantity_block'
1065 - ]);
1397 + ]));
1066 1398 ?>
1067 1399 <div <?php $this->renderAttributes($attributes); ?>>
1068 1400 <label for="fct-product-qty-input" class="quantity-title">
1069 1401 <?php esc_html_e('Quantity', 'fluent-cart'); ?>
@@ -1107,23 +1439,39 @@
1107 1439 </button>
1108 1440 </div>
1109 1441 </div>
1110 1442 <?php
1111 - do_action('fluent_cart/product/single/after_quantity_block', [
1443 + do_action('fluent_cart/product/single/after_quantity_block', RenderContext::decorate([
1112 1444 'product' => $this->product,
1113 1445 'scope' => 'product_quantity_block'
1114 - ]);
1446 + ]));
1115 1447 }
1116 1448
1117 1449 public function renderPurchaseButtons($atts = [])
1118 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 +
1119 1459 $buyNowButtonAtts = $atts;
1120 1460 $this->renderBuyNowButton($buyNowButtonAtts);
1121 1461 $this->renderAddToCartButton($atts);
1462 +
1463 + do_action('fluent_cart/product/single/after_actions_block', $gateContext);
1122 1464 }
1123 1465
1124 1466 public function renderBuyNowButton($atts = [])
1125 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 +
1126 1474 // Stock management check using isStock() method
1127 1475 // if (ModuleSettings::isActive('stock_management')) {
1128 1476 // if ($this->product->detail->variation_type === 'simple' && $this->defaultVariant) {
1129 1477 // if (!$this->defaultVariant->isStock()) {
@@ -1172,8 +1520,17 @@
1172 1520 $buyNowAttributes['data-fct-instant-checkout-button'] = '';
1173 1521 $buyNowAttributes['data-enable-modal-checkout'] = 'yes';
1174 1522 }
1175 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 +
1176 1533 $buyButtonText = apply_filters('fluent_cart/product/buy_now_button_text', $atts['buy_now_text'], [
1177 1534 'product' => $this->product
1178 1535 ]);
1179 1536
@@ -1188,9 +1545,9 @@
1188 1545 $variantTitle
1189 1546 )
1190 1547 : $buyButtonText;
1191 1548 ?>
1192 - <a <?php $this->renderAttributes($buyNowAttributes); ?> aria-label="<?php echo esc_attr($buyNowAriaLabel); ?>">
1549 + <a <?php echo $wrapperAttributes; ?> aria-label="<?php echo esc_attr($buyNowAriaLabel); ?>">
1193 1550 <?php echo wp_kses_post($buyButtonText); ?>
1194 1551 </a>
1195 1552 <?php
1196 1553 }
@@ -1196,15 +1553,24 @@
1196 1553 }
1197 1554
1198 1555 public function renderBuyNowButtonBlock($atts = [])
1199 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 +
1200 1565 $text = Arr::get($atts, 'text', __('Buy Now', 'fluent-cart'));
1201 1566 $variantIds = Arr::get($atts, 'variant_ids', []);
1202 1567 $variantId = Arr::get($variantIds, 0);
1568 + $customClass = trim(Arr::get($atts, 'class', ''));
1569 + $extraClass = trim(Arr::get($atts, 'extra_class', ''));
1203 1570
1204 1571 $defaults = [
1205 1572 'buy_now_text' => $text,
1206 - 'class' => '',
1207 1573 'target' => '',
1208 1574 'rel' => '',
1209 1575 'is_shortcode' => false,
1210 1576 ];
@@ -1224,9 +1590,13 @@
1224 1590 'item_id' => $variantId ?? '',
1225 1591 'quantity' => 1
1226 1592 ], site_url());
1227 1593
1228 - $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);
1229 1599 if ($stockStatus === 'out-of-stock') {
1230 1600 $buyNowClass .= ' out-of-stock';
1231 1601 }
1232 1602
@@ -1256,22 +1626,15 @@
1256 1626 if ($enableModalCheckout) {
1257 1627 $buyNowAttributes['data-fct-instant-checkout-button'] = '';
1258 1628 $buyNowAttributes['data-enable-modal-checkout'] = 'yes';
1259 1629 }
1260 - $wrapperAttributes = '';
1261 - $isShortcode = !empty($atts['is_shortcode']);
1262 -
1630 + $isShortcode = !empty($atts['is_shortcode']);
1263 1631 if ($isShortcode) {
1264 - foreach ($buyNowAttributes as $attr => $value) {
1265 - if ($value === '') {
1266 - $wrapperAttributes .= esc_attr($attr) . ' ';
1267 - } else {
1268 - $wrapperAttributes .= sprintf('%s="%s" ', esc_attr($attr), esc_attr((string)$value));
1269 - }
1270 - }
1271 - $wrapperAttributes = trim($wrapperAttributes);
1632 + ob_start();
1633 + $this->renderAttributes($buyNowAttributes);
1634 + $wrapperAttributes = ob_get_clean();
1272 1635 } else {
1273 - $wrapperAttributes = get_block_wrapper_attributes($buyNowAttributes);
1636 + $wrapperAttributes = RenderHelper::getBlockWrapperAttributes($buyNowAttributes);
1274 1637 }
1275 1638
1276 1639 $buyButtonText = apply_filters('fluent_cart/product/buy_now_button_text', $atts['buy_now_text'], [
1277 1640 'product' => $this->product
@@ -1284,8 +1647,14 @@
1284 1647 }
1285 1648
1286 1649 public function renderAddToCartButton($atts = [])
1287 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 +
1288 1657 $defaults = [
1289 1658 'buy_now_text' => __('Buy Now', 'fluent-cart'),
1290 1659 'add_to_cart_text' => __('Add To Cart', 'fluent-cart'),
1291 1660 ];
@@ -1297,8 +1666,9 @@
1297 1666 'data-cart-id' => $this->defaultVariant ? $this->defaultVariant->id : '',
1298 1667 'data-product-id' => $this->product->ID,
1299 1668 'class' => 'fluent-cart-add-to-cart-button',
1300 1669 'data-variation-type' => $this->product->detail->variation_type,
1670 + 'data-icon-only' => !empty($atts['is_icon_only']) ? 'true' : 'false',
1301 1671 ];
1302 1672
1303 1673 $defaultVariantData = $this->getDefaultVariantData();
1304 1674
@@ -1321,10 +1691,28 @@
1321 1691
1322 1692 $addToCartText = apply_filters('fluent_cart/product/add_to_cart_text', $atts['add_to_cart_text'], [
1323 1693 'product' => $this->product
1324 1694 ]);
1325 - // Render add to cart if product supports onetime, or if out of stock (to show "Not Available")
1326 - 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) :
1327 1715 ?>
1328 1716 <?php
1329 1717 $variantTitle = $this->defaultVariant ? $this->defaultVariant->variation_title : '';
1330 1718 $addToCartAriaLabel = $variantTitle
@@ -1335,9 +1723,9 @@
1335 1723 $variantTitle
1336 1724 )
1337 1725 : $addToCartText;
1338 1726 ?>
1339 - <button <?php $this->renderAttributes($cartAttributes); ?>
1727 + <button <?php echo $wrapperAttributes; ?>
1340 1728 aria-label="<?php echo esc_attr($addToCartAriaLabel); ?>">
1341 1729 <span class="text">
1342 1730 <?php echo wp_kses_post($addToCartText); ?>
1343 1731 </span>
@@ -1361,11 +1749,18 @@
1361 1749 }
1362 1750
1363 1751 public function renderAddToCartButtonBlock($atts = [])
1364 1752 {
1753 + $gateContext = RenderGate::context($this->product, RenderGate::SCOPE_SINGLE, $this->defaultVariant);
1365 1754
1755 + // Same gate as the in-section button — see renderBuyNowButtonBlock().
1756 + if (!RenderGate::shouldRenderPurchaseButton('add_to_cart_button', $gateContext)) {
1757 + return;
1758 + }
1759 +
1366 1760 $text = Arr::get($atts, 'text', __('Add To Cart', 'fluent-cart'));
1367 - $extraClass = trim(Arr::get($atts, 'class', ''));
1761 + $customClass = trim(Arr::get($atts, 'class', ''));
1762 + $extraClass = trim(Arr::get($atts, 'extra_class', ''));
1368 1763
1369 1764 $defaults = [
1370 1765 'add_to_cart_text' => $text,
1371 1766 ];
@@ -1371,14 +1766,18 @@
1371 1766 ];
1372 1767
1373 1768 $atts = wp_parse_args($atts, $defaults);
1374 1769
1770 + $buttonClasses = ['fct-loader', 'wp-block-button__link wp-element-button'];
1771 + $buttonClass = $customClass ?: implode(' ', $buttonClasses);
1772 +
1375 1773 $cartAttributes = [
1376 1774 'data-fluent-cart-add-to-cart-button' => '',
1377 - 'class' => 'wp-block-button__link wp-element-button fct-loader',
1775 + 'class' => $buttonClass,
1378 1776 'data-cart-id' => $this->defaultVariant ? $this->defaultVariant->id : '',
1379 1777 'data-product-id' => $this->product->ID,
1380 1778 'data-variation-type' => $this->product->detail->variation_type,
1779 + 'data-icon-only' => !empty($atts['is_icon_only']) ? 'true' : 'false',
1381 1780 ];
1382 1781
1383 1782 if ($extraClass) {
1384 1783 $cartAttributes['class'] .= ' ' . $extraClass;
@@ -1409,22 +1808,15 @@
1409 1808 $addToCartText = apply_filters('fluent_cart/product/add_to_cart_text', $atts['add_to_cart_text'], [
1410 1809 'product' => $this->product
1411 1810 ]);
1412 1811
1413 - $wrapperAttributes = '';
1414 - $isShortcode = !empty($atts['is_shortcode']);
1415 -
1812 + $isShortcode = !empty($atts['is_shortcode']);
1416 1813 if ($isShortcode) {
1417 - foreach ($cartAttributes as $attr => $value) {
1418 - if ($value === '') {
1419 - $wrapperAttributes .= esc_attr($attr) . ' ';
1420 - } else {
1421 - $wrapperAttributes .= sprintf('%s="%s" ', esc_attr($attr), esc_attr((string)$value));
1422 - }
1423 - }
1424 - $wrapperAttributes = trim($wrapperAttributes);
1814 + ob_start();
1815 + $this->renderAttributes($cartAttributes);
1816 + $wrapperAttributes = ob_get_clean();
1425 1817 } else {
1426 - $wrapperAttributes = get_block_wrapper_attributes($cartAttributes);
1818 + $wrapperAttributes = RenderHelper::getBlockWrapperAttributes($cartAttributes);
1427 1819 }
1428 1820
1429 1821 ?>
1430 1822
@@ -1460,9 +1852,9 @@
1460 1852 <p class="has-text-align-center has-large-font-size m-0">
1461 1853 <?php echo esc_html__('No Product Found!', 'fluent-cart'); ?>
1462 1854 </p>
1463 1855
1464 - <p class="has-text-align-center">
1856 + <p class="has-text-align-center m-0">
1465 1857 <?php echo esc_html__('You can try clearing any filters.', 'fluent-cart'); ?>
1466 1858 </p>
1467 1859 </div>
1468 1860 <?php
@@ -1499,14 +1891,8 @@
1499 1891 if ($variant->id == $defaultId) {
1500 1892 $itemClasses[] = 'selected';
1501 1893 }
1502 1894
1503 - $priceSuffix = apply_filters('fluent_cart/product/price_suffix_atts', '', [
1504 - 'product' => $this->product,
1505 - 'variant' => $variant,
1506 - 'scope' => 'variant_item'
1507 - ]);
1508 -
1509 1895 $renderingAttributes = [
1510 1896 'data-fluent-cart-product-variant' => '',
1511 1897 'data-cart-id' => $variant->id,
1512 1898 'data-item-stock' => $variant->isStock() ? 'in-stock' : 'out-of-stock',
@@ -1514,9 +1900,8 @@
1514 1900 'data-payment-type' => $paymentType,
1515 1901 'data-available-stock' => $availableStocks,
1516 1902 'data-item-price' => Helper::toDecimal($variant->item_price),
1517 1903 'data-compare-price' => $comparePrice,
1518 - 'data-price-suffix' => $priceSuffix,
1519 1904 'data-stock-management' => ModuleSettings::isActive('stock_management') ? 'yes' : 'no',
1520 1905 'data-sku' => $variant->sku ?? '',
1521 1906 'data-package-info' => $this->getVariantPackageInfoJson($variant),
1522 1907 ];
@@ -1562,30 +1947,29 @@
1562 1947 if ($this->viewType === 'both' || $this->viewType === 'image') {
1563 1948 $this->renderVariantImage($variant);
1564 1949 }
1565 1950 ?>
1566 - <?php
1567 - if ($this->viewType === 'both' || $this->viewType === 'text') {
1568 - echo '<div class="fct-product-variant-title" aria-label="' . esc_attr(__('Variant title', 'fluent-cart')) . '">' . esc_html($variant->variation_title) . '</div>';
1569 - }
1570 - ?>
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; ?>
1571 1959 </div>
1572 1960
1573 - <?php if ($this->viewType === 'text' && $paymentType === 'subscription' && $this->columnType === 'one'): ?>
1574 -
1575 - <?php $this->renderSubscriptionInfo($variant); ?>
1576 - <?php endif; ?>
1577 -
1578 - <?php if ($this->viewType === 'text' && $this->columnType === 'one'): ?>
1961 + <?php if (!$this->shouldRenderPriceInPriceSection()): ?>
1579 1962 <div class="fct-product-variant-price">
1580 1963 <?php if ($comparePrice): ?>
1581 1964 <div class="fct-product-variant-compare-price">
1582 - <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>
1583 1967 <span><?php echo esc_html(Helper::toDecimal($comparePrice)); ?></span></del>
1584 1968 </div>
1585 1969 <?php endif; ?>
1586 - <div class="fct-product-variant-item-price"
1587 - 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>
1588 1972 <span><?php echo esc_html(Helper::toDecimal($itemPrice)); ?></span>
1589 1973 </div>
1590 1974 </div>
1591 1975 <?php endif; ?>
@@ -1709,21 +2093,21 @@
1709 2093 //Convert to collection safely before sorting
1710 2094 $variants = (new Collection($variants))->sortBy('serial_index')->values();
1711 2095
1712 2096 foreach ($variants as $variant) {
1713 - do_action('fluent_cart/product/single/before_variant_item', [
2097 + do_action('fluent_cart/product/single/before_variant_item', RenderContext::decorate([
1714 2098 'product' => $this->product,
1715 2099 'variant' => $variant,
1716 2100 'scope' => 'product_variant_item'
1717 - ]);
2101 + ]));
1718 2102
1719 2103 $this->renderVariationItem($variant, $this->defaultVariationId);
1720 2104
1721 - do_action('fluent_cart/product/single/after_variant_item', [
2105 + do_action('fluent_cart/product/single/after_variant_item', RenderContext::decorate([
1722 2106 'product' => $this->product,
1723 2107 'variant' => $variant,
1724 2108 'scope' => 'product_variant_item'
1725 - ]);
2109 + ]));
1726 2110 }
1727 2111 ?>
1728 2112 </div>
1729 2113