PluginProbe
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler / 1.7.0
FluentCart A New Era of eCommerce – Faster, Lighter, and Simpler v1.7.0
1.7.0 1.6.6 1.6.5 1.6.4 1.6.3 1.6.2 1.6.1 1.6.0 1.5.4 1.5.5 1.5.3 1.5.2 1.5.1 1.5.0 1.4.2 1.4.1 1.4.0 1.3.28 1.3.27 1.3.26 1.3.25 1.3.23 1.3.22 1.3.21 1.3.20 All 50 releases
← All changes | app/Services/Renderer/ProductRenderer.php +619 -170 1.3.21 → 1.7.0 View file →
@@ -3,9 +3,8 @@
3 3 namespace FluentCart\App\Services\Renderer;
4 4
5 5 use FluentCart\App\App;
6 6 use FluentCart\App\Services\FrontendView;
7 -use FluentCart\App\Vite;
8 7 use FluentCart\Api\StoreSettings;
9 8 use FluentCart\Api\ModuleSettings;
10 9 use FluentCart\App\Helpers\Helper;
11 10 use FluentCart\App\Models\Product;
@@ -13,8 +12,9 @@
13 12 use FluentCart\App\Http\Routes\WebRoutes;
14 13 use FluentCart\App\Models\ProductVariation;
15 14 use FluentCart\Framework\Support\Collection;
16 15 use FluentCart\App\Modules\Templating\AssetLoader;
16 +use FluentCart\App\Modules\FluentPlayer\ProductVideoRenderer;
17 17
18 18 class ProductRenderer
19 19 {
20 20 protected $product;
@@ -38,8 +38,10 @@
38 38 protected $defaultGalleryImageId = 0;
39 39
40 40 protected $galleryActiveSet = false;
41 41
42 + protected $productVideoRenderer = null;
43 +
42 44 protected $paymentTypes = [];
43 45
44 46 protected $variantsByPaymentTypes = [];
45 47
@@ -46,8 +48,10 @@
46 48 protected $activeTab = 'onetime';
47 49
48 50 protected $images = [];
49 51
52 + protected $variantTermMap = [];
53 +
50 54 protected $defaultImageUrl = null;
51 55
52 56 protected $defaultImageAlt = null;
53 57
@@ -83,10 +87,41 @@
83 87 if (!$defaultVariationId) {
84 88 $variationIds = $product->variants->pluck('id')->toArray();
85 89 $defaultVariationId = $product->detail->default_variation_id;
86 90
87 - if (!$defaultVariationId || !in_array($defaultVariationId, $variationIds)) {
88 - $defaultVariationId = Arr::get($variationIds, '0');
91 + // For advanced variations the storefront selector only exposes ACTIVE
92 + // variants (AdvancedVariationRenderer skips item_status != active), so
93 + // resolve the default against the same set. default_variation_id is
94 + // maintained stock/status-agnostically, so it can legitimately point
95 + // at an inactive variant — accepting it here would render the inactive
96 + // default's price/stock/button while the selector omits that id and
97 + // falls back to a different active variant. Scoped to advanced so
98 + // simple / simple_variations keep their existing default handling.
99 + $isAdvanced = $product->detail
100 + && $product->detail->variation_type === Helper::PRODUCT_TYPE_ADVANCE_VARIATION;
101 + $eligibleVariants = $isAdvanced
102 + ? $product->variants->where('item_status', 'active')
103 + : $product->variants;
104 + $eligibleIds = $eligibleVariants->pluck('id')->toArray();
105 +
106 + if (!$defaultVariationId || !in_array($defaultVariationId, $eligibleIds)) {
107 + // No valid stored default — fall back to the FIRST eligible
108 + // combination by serial_index (active-only for advanced), not the
109 + // first by DB/id order, so the server-rendered price/stock/button
110 + // match what the storefront selector highlights. Stock is ignored
111 + // — first by order wins. A NULL serial_index (legacy/malformed row
112 + // the merchant never ordered) is pushed AFTER numbered variants so
113 + // it can't become the default ahead of an explicitly ordered one —
114 + // PHP's default null-first sort would otherwise promote it, and the
115 + // frontend mirrors this by treating null serial as last too.
116 + $firstBySerial = $eligibleVariants
117 + ->sortBy(function ($variant) {
118 + return is_null($variant->serial_index)
119 + ? PHP_INT_MAX
120 + : (int) $variant->serial_index;
121 + })
122 + ->first();
123 + $defaultVariationId = $firstBySerial ? (int) $firstBySerial->id : Arr::get($variationIds, '0');
89 124 $hasExplicitDefault = false;
90 125 }
91 126 }
92 127
@@ -104,13 +139,17 @@
104 139 foreach ($this->product->variants as $variant) {
105 140 if ($variant->id == $this->defaultVariationId) {
106 141 $this->defaultVariant = $variant;
107 142 }
108 - $paymentType = Arr::get($variant->other_info, 'payment_type');
109 - if ($paymentType === 'onetime') {
143 + // Read the authoritative payment_type column, not other_info. The
144 + // ProductVariation accessor only injects payment_type into other_info
145 + // for subscriptions, so one-time variants (notably advanced-variation
146 + // combinations generated by Pro) leave other_info['payment_type']
147 + // unset. Anything that is not a subscription is a one-time path.
148 + if ($variant->payment_type === 'subscription') {
149 + $this->hasSubscription = true;
150 + } else {
110 151 $this->hasOnetime = true;
111 - } else if ($paymentType === 'subscription') {
112 - $this->hasSubscription = true;
113 152 }
114 153 }
115 154
116 155 $this->buildProductGroups();
@@ -222,8 +261,9 @@
222 261 $this->renderVariationsBundleProduct($variant);
223 262 }
224 263 }
225 264
265 + $this->renderPackageDescription();
226 266 $this->renderBuySection();
227 267 ?>
228 268 </div>
229 269 </div>
@@ -256,8 +296,33 @@
256 296 }
257 297
258 298 public function renderBuySection($atts = [])
259 299 {
300 + $gateContext = RenderGate::context($this->product, RenderGate::SCOPE_SINGLE, $this->defaultVariant);
301 +
302 + // The buy section is the root the variation-selector JS binds to
303 + // (data-fluent-cart-product-pricing-section). Gating it removes the
304 + // variation picker along with the buttons, which is what a full catalog
305 + // mode wants; to keep shoppers able to browse options while hiding only
306 + // the purchase affordances, gate 'actions' instead.
307 + if (!RenderGate::shouldRender('buy_section', $gateContext)) {
308 + return;
309 + }
310 +
311 + // Render no buy section when there is nothing purchasable — avoids a
312 + // broken quantity + "Not Available" block. Two cases:
313 + // - no variants at all (any product type), or
314 + // - an advanced-variation product with no attribute_config yet (e.g.
315 + // switched on before options were configured). Its variants are kept
316 + // until generation, so we key on the config, not the variant count,
317 + // to hide it until real combinations exist.
318 + $isUnconfiguredAdvanced = $this->product->detail->variation_type === Helper::PRODUCT_TYPE_ADVANCE_VARIATION
319 + && empty(Arr::get((array) $this->product->detail->other_info, 'attribute_config'));
320 +
321 + if ($this->product->variants->isEmpty() || $isUnconfiguredAdvanced) {
322 + return;
323 + }
324 +
260 325 $otherInfo = (array)Arr::get($this->product->detail, 'other_info');
261 326 $groupBy = Arr::get($otherInfo, 'group_pricing_by', 'repeat_interval'); //repeat_interval,payment_type,none
262 327
263 328 $this->renderBuySectionWrapperStart();
@@ -276,8 +341,25 @@
276 341 }
277 342
278 343 public function renderVariationDisplay($atts = [])
279 344 {
345 + // Hand off rendering to the advanced-variation selector when the
346 + // product is configured for advanced variations. The handler returns
347 + // the filtered array with rendered=true after emitting its markup; if
348 + // no listener handles it (e.g. an unconfigured advanced product), the
349 + // filter is a no-op and we fall through to the simple-variation
350 + // rendering below.
351 + if ($this->product->detail->variation_type === Helper::PRODUCT_TYPE_ADVANCE_VARIATION) {
352 + $result = apply_filters('fluent_cart/product/render_advanced_variation', [
353 + 'product' => $this->product,
354 + 'selector_style' => Arr::get($atts, 'selector_style', 'auto'),
355 + 'rendered' => false,
356 + ]);
357 + if (!empty($result['rendered'])) {
358 + return;
359 + }
360 + }
361 +
280 362 $otherInfo = (array)Arr::get($this->product->detail, 'other_info');
281 363 $groupBy = Arr::get($otherInfo, 'group_pricing_by', 'repeat_interval'); //repeat_interval,payment_type,none
282 364
283 365 if (count($this->paymentTypes) === 1 || $groupBy === 'none') {
@@ -290,12 +372,15 @@
290 372 public function renderGalleryThumb()
291 373 {
292 374 $thumbnails = [];
293 375
294 - $featuredMedia = $this->product->thumbnail ?? Vite::getAssetUrl('images/placeholder.svg');
376 + $featuredMedia = $this->product->thumbnail ?? Helper::getProductPlaceholderUrl();
295 377
296 - if (!$featuredMedia) {
297 - $featuredMedia = [];
378 + // thumbnail can be an empty string (not null), so ?? above doesn't catch
379 + // it — fall back to the placeholder so $featuredMedia is always a usable
380 + // image URL for both the main <img> and data-default-image-url.
381 + if (!$featuredMedia || !\is_string($featuredMedia)) {
382 + $featuredMedia = Helper::getProductPlaceholderUrl();
298 383 }
299 384
300 385 $galleryImage = get_post_meta($this->product->ID, 'fluent-products-gallery-image', true);
301 386
@@ -328,14 +413,37 @@
328 413 if (isset($images[$imageId])) {
329 414 $imageMetaValue = $images[$imageId];
330 415 $this->defaultImageUrl = Arr::get($imageMetaValue, 'media.0.url', '');
331 416 $this->defaultImageAlt = Arr::get($imageMetaValue, 'media.0.title', '');
417 + } else {
418 + // Fallback to the first available thumbnail. Advanced-variation
419 + // products often have per-variant images but no explicit
420 + // default_variation_id — the thumbnails are keyed by real
421 + // variant IDs while defaultGalleryImageId is 0, so the lookup
422 + // above misses and the main image area renders blank with a
423 + // broken-image icon.
424 + $firstImage = reset($images);
425 + $fallbackUrl = Arr::get($firstImage, 'media.0.url', '');
426 + $this->defaultImageUrl = $fallbackUrl ?: ($featuredMedia ?: '');
427 + $this->defaultImageAlt = $this->defaultImageAlt ?: Arr::get($firstImage, 'media.0.title', '');
332 428 }
333 429 }
334 430
431 + // Nothing set a main image — e.g. a product with no variants, or no
432 + // variant/gallery media. Fall back to the featured/placeholder image so
433 + // the main area shows the placeholder instead of a broken <img src="">.
434 + if (empty($this->defaultImageUrl)) {
435 + $this->defaultImageUrl = $featuredMedia;
436 + }
437 +
438 + $videoRenderer = $this->getProductVideoRenderer();
439 + $videoRenderer->showFirstByDefault(!$this->hasGalleryImages());
440 + $defaultVideoId = $videoRenderer->getDefaultMediaId();
441 +
335 442 ?>
336 - <div class="fct-product-gallery-thumb" role="region"
337 - aria-label="<?php echo esc_attr($this->product->post_title . ' gallery'); ?>">
443 + <div class="fct-product-gallery-thumb<?php echo $defaultVideoId ? ' is-video-active' : ''; ?>" role="region"
444 + aria-label="<?php echo esc_attr($this->product->post_title . ' gallery'); ?>"
445 + <?php echo $defaultVideoId ? 'data-fct-video-default="' . esc_attr((string) $defaultVideoId) . '"' : ''; ?>>
338 446 <img
339 447 src="<?php echo esc_url($this->defaultImageUrl ?? '') ?>"
340 448 alt="<?php echo esc_attr($this->defaultImageAlt) ?>"
341 449 data-fluent-cart-single-product-page-product-thumbnail
@@ -340,36 +448,117 @@
340 448 alt="<?php echo esc_attr($this->defaultImageAlt) ?>"
341 449 data-fluent-cart-single-product-page-product-thumbnail
342 450 data-default-image-url="<?php echo esc_url($featuredMedia) ?>"
343 451 />
452 + <?php $this->getProductVideoRenderer()->renderInlinePlayers(); ?>
344 453 </div>
345 454 <?php
346 455 }
347 456
457 + /**
458 + * Whether any gallery / variant image exists to show in the main area.
459 + * Only meaningful after renderGalleryThumb() has built $this->images.
460 + */
461 + protected function hasGalleryImages(): bool
462 + {
463 + foreach ($this->images as $image) {
464 + foreach ((array) Arr::get($image, 'media', []) as $item) {
465 + if (!empty(Arr::get($item, 'url'))) {
466 + return true;
467 + }
468 + }
469 + }
470 +
471 + return false;
472 + }
473 +
474 + public function getProductVideoRenderer(): ProductVideoRenderer
475 + {
476 + if ($this->productVideoRenderer === null) {
477 + $this->productVideoRenderer = new ProductVideoRenderer($this->product);
478 + }
479 +
480 + return $this->productVideoRenderer;
481 + }
482 +
348 483 public function renderGalleryThumbControls($maxThumbnails = null)
349 484 {
350 485 $totalThumbImages = Arr::pluck($this->images, 'media.*.url');
351 486
352 - if(count($totalThumbImages) == 1 && is_countable($totalThumbImages[0]) && count($totalThumbImages[0]) == 1){
487 + // A lone image needs no strip, but a video thumb still has to be reachable.
488 + if(count($totalThumbImages) == 1 && is_countable($totalThumbImages[0]) && count($totalThumbImages[0]) == 1 && !$this->getProductVideoRenderer()->isAvailable()){
353 489
354 490 return '';
355 491 }
356 492
357 - // Collect ALL gallery images as JSON for lightbox (even when max thumbnails limits visible thumbs)
493 + // A lone video without images is already showing in the main area.
494 + if (!$this->hasGalleryImages() && count($this->getProductVideoRenderer()->getVideos()) === 1) {
495 + return '';
496 + }
497 +
498 + // Build variantTermMap and variantFirstMediaMap.
499 + // For advanced variations, Pro builds both maps via the gallery_variation_data
500 + // filter — it has access to AttributeGroup types (color/image) that free cannot
501 + // query directly. For all other product types, free builds variantFirstMediaMap
502 + // from the already-loaded $this->images; variantTermMap stays empty.
503 + $this->variantTermMap = [];
504 + $variantFirstMediaMap = [];
505 +
506 + if ($this->product->detail->variation_type === Helper::PRODUCT_TYPE_ADVANCE_VARIATION) {
507 + $galleryVariationData = apply_filters('fluent_cart/product/gallery_variation_data', [
508 + 'variant_term_map' => [],
509 + 'variant_first_media_map' => [],
510 + ], $this->product);
511 + $this->variantTermMap = (array) Arr::get($galleryVariationData, 'variant_term_map', []);
512 + $variantFirstMediaMap = (array) Arr::get($galleryVariationData, 'variant_first_media_map', []);
513 + } else {
514 + foreach ($this->images as $imageId => $image) {
515 + if (empty($image['media']) || !is_array($image['media'])) {
516 + continue;
517 + }
518 + $firstItem = $image['media'][0] ?? null;
519 + if (!$firstItem) {
520 + continue;
521 + }
522 + $firstUrl = Arr::get($firstItem, 'url', '');
523 + $firstMediaId = (int) Arr::get($firstItem, 'id', 0);
524 + if ($firstUrl) {
525 + $variantFirstMediaMap[(int) $imageId] = [
526 + 'id' => $firstMediaId,
527 + 'url' => $firstUrl,
528 + ];
529 + }
530 + }
531 + }
532 +
533 + // Collect ALL gallery images as JSON for lightbox (even when max thumbnails limits visible thumbs).
534 + // For advanced variations, deduplicate by media ID (when > 0) or by URL (for imported images).
358 535 $allGalleryImages = [];
536 + $addedMediaKeys = [];
537 + $isAdvVariation = !empty($this->variantTermMap);
359 538 foreach ($this->images as $imageId => $image) {
360 539 if (empty($image['media']) || !is_array($image['media'])) {
361 540 continue;
362 541 }
363 542 foreach ($image['media'] as $item) {
364 - $url = Arr::get($item, 'url', '');
543 + $url = Arr::get($item, 'url', '');
544 + $mediaId = (int) Arr::get($item, 'id', 0);
365 545 if (empty($url)) {
366 546 continue;
367 547 }
548 + if ($isAdvVariation) {
549 + $dedupeKey = $mediaId > 0 ? 'i:' . $mediaId : 'u:' . $url;
550 + if (isset($addedMediaKeys[$dedupeKey])) {
551 + continue; // skip — already added this image
552 + }
553 + $addedMediaKeys[$dedupeKey] = true;
554 + }
368 555 $allGalleryImages[] = [
369 556 'url' => $url,
370 557 'title' => Arr::get($item, 'title', ''),
371 558 'variation_id' => (string) $imageId,
559 + 'term_id' => (int) ($this->variantTermMap[(int) $imageId] ?? 0),
560 + 'media_id' => $mediaId,
372 561 ];
373 562 }
374 563 }
375 564
@@ -378,9 +567,10 @@
378 567 <div class="fct-gallery-thumb-controls"
379 568 role="toolbar"
380 569 aria-label="<?php echo esc_attr__('Product image thumbnails', 'fluent-cart'); ?>"
381 570 data-fluent-cart-single-product-page-product-thumbnail-controls
382 - data-all-gallery-images="<?php echo esc_attr(wp_json_encode($allGalleryImages) ?: '[]'); ?>">
571 + data-all-gallery-images="<?php echo esc_attr(wp_json_encode($allGalleryImages) ?: '[]'); ?>"
572 + data-variant-first-media-map="<?php echo esc_attr(wp_json_encode($variantFirstMediaMap) ?: '{}'); ?>">
383 573
384 574 <?php $this->renderGalleryThumbControl($maxThumbnails); ?>
385 575
386 576 </div>
@@ -394,45 +584,76 @@
394 584 if ($maxThumbnails !== null && $maxThumbnails <= 0) {
395 585 $maxThumbnails = null; // treat invalid value as "no limit"
396 586 }
397 587
398 - $count = 0;
399 - $totalImages = 0;
588 + $isAdvVariation = !empty($this->variantTermMap);
589 + $countedMediaKeys = [];
590 + $count = 0;
591 + $totalImages = 0;
592 + $videoRenderer = $this->getProductVideoRenderer();
400 593
401 - // First, count total renderable images
594 + // The gallery opens on a video the admin put in front of every image,
595 + // so no image thumb takes the selected slot in that case.
596 + if ($videoRenderer->getDefaultMediaId()) {
597 + $this->galleryActiveSet = true;
598 + }
599 +
600 + // Count unique images to render. For advanced variations, deduplicate by WP media ID
601 + // when available, or by URL for externally imported images (media ID = 0).
402 602 foreach ($this->images as $imageId => $image) {
403 603 if (empty($image['media']) || !is_array($image['media'])) {
404 604 continue;
405 605 }
406 606 foreach ($image['media'] as $item) {
407 - if (!empty(Arr::get($item, 'url', ''))) {
408 - $totalImages++;
607 + $url = Arr::get($item, 'url', '');
608 + $mediaId = (int) Arr::get($item, 'id', 0);
609 + if (empty($url)) {
610 + continue;
409 611 }
612 + if ($isAdvVariation) {
613 + $mediaDedupeKey = $mediaId > 0 ? 'i:' . $mediaId : 'u:' . $url;
614 + if (isset($countedMediaKeys[$mediaDedupeKey])) {
615 + continue;
616 + }
617 + $countedMediaKeys[$mediaDedupeKey] = true;
618 + }
619 + $totalImages++;
410 620 }
411 621 }
412 622
413 - // Then render up to max
623 + $renderedMediaKeys = [];
624 + // Render up to max; skip already-rendered media for advanced variation products.
414 625 foreach ($this->images as $imageId => $image) {
415 626 if (empty($image['media']) || !is_array($image['media'])) {
416 627 continue;
417 628 }
418 -
419 629 foreach ($image['media'] as $item) {
420 - if (empty(Arr::get($item, 'url', ''))) {
630 + $url = Arr::get($item, 'url', '');
631 + $mediaId = (int) Arr::get($item, 'id', 0);
632 + if (empty($url)) {
421 633 continue;
422 634 }
423 -
635 + if ($isAdvVariation) {
636 + $mediaDedupeKey = $mediaId > 0 ? 'i:' . $mediaId : 'u:' . $url;
637 + if (isset($renderedMediaKeys[$mediaDedupeKey])) {
638 + continue;
639 + }
640 + $renderedMediaKeys[$mediaDedupeKey] = true;
641 + }
424 642 if ($maxThumbnails !== null && $count >= (int) $maxThumbnails) {
643 + $videoRenderer->renderThumbControls(!$this->galleryActiveSet);
425 644 $this->renderGallerySeeMoreButton($totalImages - (int) $maxThumbnails);
426 645 return;
427 646 }
428 -
429 - $this->renderGalleryThumbControlButton($item, $imageId);
647 + // Videos the admin dragged in front of this image come first.
648 + $videoRenderer->renderThumbControlsBefore($count, !$this->galleryActiveSet);
649 + $termId = (int) ($this->variantTermMap[(int) $imageId] ?? 0);
650 + $this->renderGalleryThumbControlButton($item, $imageId, $termId, $mediaId);
430 651 $count++;
431 652 }
432 -
433 653 }
434 654
655 + $videoRenderer->renderThumbControls(!$this->galleryActiveSet);
435 656 }
436 657
437 658 public function renderGallerySeeMoreButton($remainingCount)
438 659 {
@@ -463,9 +684,9 @@
463 684 </button>
464 685 <?php
465 686 }
466 687
467 - public function renderGalleryThumbControlButton($item, $imageId)
688 + public function renderGalleryThumbControlButton($item, $imageId, $termId = 0, $mediaId = 0)
468 689 {
469 690
470 691 $isHidden = ''; //$imageId != $this->defaultVariationId ? 'is-hidden' : '';
471 692 $itemUrl = Arr::get($item, 'url', '');
@@ -481,11 +702,13 @@
481 702 class="fct-gallery-thumb-control-button <?php echo $isSelected ? 'active' : ''; ?> <?php echo esc_attr($isHidden); ?>"
482 703 data-fluent-cart-thumb-control-button
483 704 data-url="<?php echo esc_url($itemUrl); ?>"
484 705 data-variation-id="<?php echo esc_attr($imageId); ?>"
706 + data-term-id="<?php echo esc_attr((string) $termId); ?>"
707 + data-media-id="<?php echo esc_attr((string) $mediaId); ?>"
485 708 aria-label="<?php echo
486 - /* translators: %s image title */
487 - esc_attr(sprintf(__('View %s image', 'fluent-cart'), $itemTitle));
709 + /* translators: %1$s: image title */
710 + esc_attr(sprintf(__('View %1$s image', 'fluent-cart'), $itemTitle));
488 711 ?>"
489 712 aria-pressed="<?php echo $isSelected ? 'true' : 'false'; ?>"
490 713 tabindex="<?php echo $isSelected ? '0' : '-1'; ?>"
491 714 >
@@ -503,9 +726,14 @@
503 726 }
504 727
505 728 public function renderGallery($args = [])
506 729 {
730 + $gateContext = RenderGate::context($this->product, RenderGate::SCOPE_SINGLE, $this->defaultVariant);
507 731
732 + if (!RenderGate::shouldRender('image', $gateContext)) {
733 + return;
734 + }
735 +
508 736 $defaults = [
509 737 'thumbnail_mode' => 'all', // horizontal, vertical
510 738 'thumb_position' => 'bottom', // bottom, left, right, top
511 739 'scrollable_thumbs' => 'no', // yes / no
@@ -539,13 +767,21 @@
539 767 }
540 768
541 769 public function renderTitle()
542 770 {
771 + $gateContext = RenderGate::context($this->product, RenderGate::SCOPE_SINGLE, $this->defaultVariant);
772 +
773 + if (!RenderGate::shouldRender('title', $gateContext)) {
774 + return;
775 + }
776 +
777 + do_action('fluent_cart/product/single/before_title_block', $gateContext);
543 778 ?>
544 779 <div class="fct-product-title">
545 780 <h1 id="fct-product-summary-title"><?php echo esc_html($this->product->post_title); ?></h1>
546 781 </div>
547 782 <?php
783 + do_action('fluent_cart/product/single/after_title_block', $gateContext);
548 784 }
549 785
550 786 public function renderStockAvailability($wrapper_attributes = '')
551 787 {
@@ -553,8 +789,9 @@
553 789 return '';
554 790 }
555 791
556 792 $stockAvailability = $this->product->detail->getStockAvailability();
793 +
557 794
558 795 if (!Arr::get($stockAvailability, 'manage_stock')) {
559 796 return '';
560 797 }
@@ -564,15 +801,44 @@
564 801 // Check default variant stock for both simple and variable products
565 802 if ($this->defaultVariant) {
566 803 $isStock = $isStock && $this->defaultVariant->isStock();
567 804 }
568 - $stockLabel = $isStock ? $stockAvailability['availability'] : __('Out of Stock', 'fluent-cart');
569 - $statusClass = $isStock ? ($stockAvailability['class'] ?? '') : 'out-of-stock';
805 +
806 + $stockLabel = Arr::get($stockAvailability, 'availability');
807 + $statusClass = $stockAvailability['class'] ?? '';
808 +
809 + // Optional per-status custom labels (e.g. set on the Bricks Product Stock
810 + // element via the fluent_cart/product_stock_availability filter). Emitted as
811 + // data-attributes so the frontend JS, which re-derives the badge text on load
812 + // and on variant switches, prefers them over the generic label map instead of
813 + // overwriting them. Absent for the default template, so behavior is unchanged.
814 + $inStockText = Arr::get($stockAvailability, 'in_stock_text');
815 + $outOfStockText = Arr::get($stockAvailability, 'out_of_stock_text');
816 +
817 + // The variant-level check above can override the aggregate stock_availability
818 + // used for $stockLabel/$statusClass (e.g. this specific default variant is out
819 + // of stock even though the product overall has other in-stock variants) — keep
820 + // the label and class in sync so the badge never shows mismatched text/color.
821 + // Honor the custom out-of-stock label here too so it survives this override on
822 + // first load, matching what the frontend JS shows after a variant switch.
823 + if (!$isStock) {
824 + $statusClass = 'out-of-stock';
825 + $stockLabel = !empty($outOfStockText) ? $outOfStockText : __('Out of Stock', 'fluent-cart');
826 + }
827 +
828 + $badgeAttributes = '';
829 + if (!empty($inStockText)) {
830 + $badgeAttributes .= sprintf(' data-in-stock-text="%s"', esc_attr($inStockText));
831 + }
832 + if (!empty($outOfStockText)) {
833 + $badgeAttributes .= sprintf(' data-out-of-stock-text="%s"', esc_attr($outOfStockText));
834 + }
835 +
570 836 echo sprintf(
571 837 '<div class="fct-product-stock %1$s" role="status" aria-live="polite">
572 838 <div %2$s>
573 839 <span class="fct-stock-label">%3$s</span>
574 - <span class="fct-stock-badge fct_status_badge_%1$s" data-fluent-cart-product-stock>
840 + <span class="fct-stock-badge fct_status_badge_%1$s" data-fluent-cart-product-stock%5$s>
575 841 %4$s
576 842 </span>
577 843 </div>
578 844 </div>',
@@ -578,22 +844,15 @@
578 844 </div>',
579 845 esc_attr($statusClass),
580 846 $wrapper_attributes, // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
581 847 esc_html__('Availability:', 'fluent-cart'),
582 - esc_html($stockLabel)
848 + esc_html($stockLabel),
849 + $badgeAttributes // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- values passed through esc_attr() above
583 850 );
584 851 }
585 852
586 853 public function renderSku($wrapper_attributes = '', $showLabel = true, $label = '', $variant = null)
587 854 {
588 - if (!$variant) {
589 - $variant = $this->defaultVariant ?: $this->product->variants->first();
590 - }
591 -
592 - if (!$variant || empty($variant->sku)) {
593 - return;
594 - }
595 -
596 855 if (!$label) {
597 856 $label = __('SKU:', 'fluent-cart');
598 857 }
599 858
@@ -601,9 +860,13 @@
601 860 if ($showLabel && $label) {
602 861 $labelHtml = sprintf('<span class="fct-product-sku__label">%s</span> ', esc_html($label));
603 862 }
604 863
605 - echo sprintf(
864 + if ($variant) {
865 + if (empty($variant->sku)) {
866 + return;
867 + }
868 + echo sprintf(
606 869 '<div class="fct-product-sku">
607 870 <div %s>
608 871 %s<span class="fct-product-sku__value" data-fluent-cart-product-sku>%s</span>
609 872 </div>
@@ -610,11 +873,112 @@
610 873 </div>',
611 874 $wrapper_attributes, // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
612 875 $labelHtml,
613 876 esc_html($variant->sku)
877 + );
878 + return;
879 + }
880 +
881 + foreach ($this->product->variants as $v) {
882 + if (empty($v->sku)) {
883 + continue;
884 + }
885 + $isHidden = ($this->defaultVariant && $this->defaultVariant->id != $v->id) ? ' is-hidden' : '';
886 + echo sprintf(
887 + '<div class="fct-product-sku fluent-cart-product-variation-content%s" data-variation-id="%s">
888 + <div %s>
889 + %s<span class="fct-product-sku__value" data-fluent-cart-product-sku>%s</span>
890 + </div>
891 + </div>',
892 + $isHidden,
893 + esc_attr($v->id),
894 + $wrapper_attributes, // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
895 + $labelHtml,
896 + esc_html($v->sku)
897 + );
898 + }
899 + }
900 +
901 + /**
902 + * @deprecated Use PackageDescriptionRenderer::renderPackageDescription() directly.
903 + * Kept as a compatibility shim for external callers (themes, extensions) —
904 + * package-description rendering now lives in PackageDescriptionRenderer.
905 + */
906 + public function renderPackageDescription($wrapper_attributes = '', $showName = true, $showDimensions = true, $showProductWeight = true, $showTotalWeight = true, $variant = null)
907 + {
908 + (new PackageDescriptionRenderer($this->product))->renderPackageDescription(
909 + $wrapper_attributes,
910 + $showName,
911 + $showDimensions,
912 + $showProductWeight,
913 + $showTotalWeight,
914 + $variant,
915 + $this->defaultVariant
614 916 );
615 917 }
616 918
919 + /**
920 + * Build a JSON string of package info for a variant (used as data attribute for JS switching).
921 + */
922 + private function getVariantPackageInfoJson(ProductVariation $variant)
923 + {
924 + if ($variant->fulfillment_type !== 'physical') {
925 + return '';
926 + }
927 +
928 + $otherInfo = $variant->other_info ?: [];
929 + $packageSlug = Arr::get($otherInfo, 'package_slug', '');
930 + $package = Helper::getPackageBySlug($packageSlug);
931 +
932 + if (!$package) {
933 + return '';
934 + }
935 +
936 + static $storeWeightUnit = null;
937 +
938 + if ($storeWeightUnit === null) {
939 + $storeWeightUnit = Helper::shopConfig('weight_unit') ?: 'kg';
940 + }
941 +
942 + // Format dimensions
943 + $length = Arr::get($package, 'length', '');
944 + $width = Arr::get($package, 'width', '');
945 + $height = Arr::get($package, 'height', '');
946 + $dimensionUnit = Arr::get($package, 'dimension_unit', 'cm');
947 + $dimensionParts = array_filter([$length, $width, $height], function ($val) {
948 + return $val !== '' && $val !== null && $val != 0;
949 + });
950 + $formattedDimensions = $dimensionParts
951 + ? implode(' × ', $dimensionParts) . ' ' . $dimensionUnit
952 + : '';
953 +
954 + // Calculate weights
955 + $productWeight = floatval(Arr::get($otherInfo, 'weight', 0));
956 + $productWeightUnit = Arr::get($otherInfo, 'weight_unit', $storeWeightUnit);
957 + $convertedProductWeight = Helper::convertWeight($productWeight, $productWeightUnit, $storeWeightUnit);
958 +
959 + $packageWeight = floatval(Arr::get($package, 'weight', 0));
960 + $packageWeightUnit = Arr::get($package, 'weight_unit', $storeWeightUnit);
961 + $convertedPackageWeight = Helper::convertWeight($packageWeight, $packageWeightUnit, $storeWeightUnit);
962 + $totalWeight = $convertedProductWeight + $convertedPackageWeight;
963 +
964 + // Format weights
965 + $formattedProductWeight = $convertedProductWeight
966 + ? rtrim(rtrim(number_format($convertedProductWeight, 2), '0'), '.') . ' ' . $storeWeightUnit
967 + : '';
968 +
969 + $formattedShippingWeight = ($totalWeight && $convertedPackageWeight)
970 + ? rtrim(rtrim(number_format($totalWeight, 2), '0'), '.') . ' ' . $storeWeightUnit
971 + : '';
972 +
973 + return wp_json_encode([
974 + 'name' => Arr::get($package, 'name', ''),
975 + 'dimensions' => $formattedDimensions,
976 + 'product_weight' => $formattedProductWeight,
977 + 'shipping_weight' => $formattedShippingWeight,
978 + ]);
979 + }
980 +
617 981 public function renderExcerpt()
618 982 {
619 983 $excerpt = $this->product->post_excerpt;
620 984 if (!$excerpt) {
@@ -619,25 +983,47 @@
619 983 $excerpt = $this->product->post_excerpt;
620 984 if (!$excerpt) {
621 985 return;
622 986 }
987 +
988 + $gateContext = RenderGate::context($this->product, RenderGate::SCOPE_SINGLE, $this->defaultVariant);
989 +
990 + if (!RenderGate::shouldRender('excerpt', $gateContext)) {
991 + return;
992 + }
993 +
994 + do_action('fluent_cart/product/single/before_excerpt_block', $gateContext);
623 995 ?>
624 996 <div class="fct-product-excerpt" aria-labelledby="fct-product-summary-title">
625 997 <p><?php echo wp_kses_post($excerpt); ?></p>
626 998 </div>
627 999 <?php
628 -
1000 + do_action('fluent_cart/product/single/after_excerpt_block', $gateContext);
629 1001 }
630 1002
631 1003 public function renderDescription()
632 1004 {
633 - $post = get_post($this->product->ID);
634 - if (!$post || empty($post->post_content)) {
1005 + $productPost = get_post($this->product->ID);
1006 + if (!$productPost || empty($productPost->post_content)) {
635 1007 return;
636 1008 }
1009 +
1010 + global $post;
1011 + $originalPost = $post;
1012 + $post = $productPost;
1013 + setup_postdata($post);
1014 +
1015 + $content = apply_filters('the_content', $productPost->post_content);
1016 +
1017 + $post = $originalPost;
1018 + if ($originalPost) {
1019 + setup_postdata($originalPost);
1020 + } else {
1021 + wp_reset_postdata();
1022 + }
637 1023 ?>
638 1024 <div class="fct-product-description">
639 - <?php echo apply_filters('the_content', $post->post_content); // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
1025 + <?php echo $content; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
640 1026 </div>
641 1027 <?php
642 1028 }
643 1029
@@ -642,8 +1028,14 @@
642 1028 }
643 1029
644 1030 public function renderPrices()
645 1031 {
1032 + $gateContext = RenderGate::context($this->product, RenderGate::SCOPE_SINGLE, $this->defaultVariant);
1033 +
1034 + if (!RenderGate::shouldRender('price', $gateContext)) {
1035 + return;
1036 + }
1037 +
646 1038 if ($this->product->detail->variation_type === 'simple') {
647 1039 // we have to render for the simple product
648 1040
649 1041 $first_price = $this->product->variants()->first();
@@ -657,97 +1049,76 @@
657 1049 $comparePrice = $first_price ? (int)$first_price->compare_price : 0;
658 1050 if ($comparePrice <= $itemPrice) {
659 1051 $comparePrice = 0;
660 1052 }
661 - do_action('fluent_cart/product/single/before_price_block', [
1053 + do_action('fluent_cart/product/single/before_price_block', RenderContext::decorate([
662 1054 'product' => $this->product,
663 1055 'current_price' => $itemPrice,
664 1056 'scope' => 'price_range'
665 - ]);
1057 + ]));
666 1058 ?>
667 - <?php
1059 + <div class="fct-price-range fct-product-prices">
668 1060
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 1061 <?php if ($comparePrice): ?>
689 1062 <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>
1063 + <span class="fct-sr-only"><?php echo esc_html__('Original price:', 'fluent-cart'); ?></span>
1064 + <del><?php echo esc_html(Helper::toDecimal($comparePrice)); ?></del>
691 1065 </span>
692 1066 <?php endif; ?>
693 - <span class="fct-item-price" aria-label="<?php echo esc_attr(__('Current price', 'fluent-cart')); ?>">
1067 + <span class="fct-item-price">
1068 + <span class="fct-sr-only"><?php echo $comparePrice ? esc_html__('Sale price:', 'fluent-cart') : esc_html__('Price:', 'fluent-cart'); ?></span>
694 1069 <?php echo esc_html(Helper::toDecimal($itemPrice)); ?>
695 - <?php do_action('fluent_cart/product/after_price', [
1070 + <?php do_action('fluent_cart/product/after_price', RenderContext::decorate([
696 1071 'product' => $this->product,
1072 + 'variant' => $first_price,
697 1073 'current_price' => $itemPrice,
698 1074 'scope' => 'price_range'
699 - ]); ?>
1075 + ])); ?>
1076 + <?php RenderHelper::renderPriceSuffix($this->product, $first_price, 'price_range'); ?>
700 1077 </span>
701 1078 </div>
702 1079 <?php
703 - do_action('fluent_cart/product/single/after_price_block', [
1080 + do_action('fluent_cart/product/single/after_price_block', RenderContext::decorate([
704 1081 'product' => $this->product,
705 1082 'current_price' => $itemPrice,
706 1083 'scope' => 'price_range'
707 - ]);
1084 + ]));
708 1085 return;
709 1086 }
710 1087 $min_price = $this->product->detail->min_price;
711 1088 $max_price = $this->product->detail->max_price;
712 1089
713 - do_action('fluent_cart/product/single/before_price_range_block', [
1090 + do_action('fluent_cart/product/single/before_price_range_block', RenderContext::decorate([
714 1091 'product' => $this->product,
715 1092 'current_price' => $min_price,
716 1093 'scope' => 'price_range'
717 - ]);
1094 + ]));
718 1095 ?>
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); ?>">
1096 + <div class="fct-product-prices fct-price-range">
728 1097
729 1098 <?php if ($max_price && $max_price != $min_price && $max_price > $min_price): ?>
1099 + <span class="fct-sr-only"><?php echo esc_html__('Price range:', 'fluent-cart'); ?></span>
730 1100 <span class="fct-min-price"><?php echo esc_html(Helper::toDecimal($min_price)); ?></span>
731 1101 <span class="fct-price-separator" aria-hidden="true">-</span>
1102 + <span class="fct-sr-only"><?php echo esc_html__('to', 'fluent-cart'); ?></span>
732 1103 <?php endif; ?>
733 1104 <span class="fct-max-price">
734 1105 <?php echo esc_html(Helper::toDecimal($max_price)); ?>
735 1106 </span>
736 1107
737 - <?php do_action('fluent_cart/product/after_price', [
1108 + <?php do_action('fluent_cart/product/after_price', RenderContext::decorate([
738 1109 'product' => $this->product,
739 1110 'current_price' => $min_price,
740 1111 'scope' => 'price_range'
741 - ]); ?>
1112 + ])); ?>
742 1113
743 1114 </div>
744 1115 <?php
745 - do_action('fluent_cart/product/single/after_price_range_block', [
1116 + do_action('fluent_cart/product/single/after_price_range_block', RenderContext::decorate([
746 1117 'product' => $this->product,
747 1118 'current_price' => $min_price,
748 1119 'scope' => 'price_range'
749 - ]);
1120 + ]));
750 1121 }
751 1122
752 1123 public function renderVariants($atts = [])
753 1124 {
@@ -772,19 +1143,19 @@
772 1143 ?>
773 1144 <div class="<?php echo esc_attr(implode(' ', $classes)); ?>" role="radiogroup"
774 1145 aria-label="<?php esc_attr_e('Product Variants', 'fluent-cart'); ?>">
775 1146 <?php foreach ($variants as $variant) {
776 - do_action('fluent_cart/product/single/before_variant_item', [
1147 + do_action('fluent_cart/product/single/before_variant_item', RenderContext::decorate([
777 1148 'product' => $this->product,
778 1149 'variant' => $variant,
779 1150 'scope' => 'product_variant_item'
780 - ]);
1151 + ]));
781 1152 $this->renderVariationItem($variant, $this->defaultVariationId);
782 - do_action('fluent_cart/product/single/after_variant_item', [
1153 + do_action('fluent_cart/product/single/after_variant_item', RenderContext::decorate([
783 1154 'product' => $this->product,
784 1155 'variant' => $variant,
785 1156 'scope' => 'product_variant_item'
786 - ]);
1157 + ]));
787 1158 } ?>
788 1159 </div>
789 1160 <?php
790 1161 }
@@ -790,17 +1161,24 @@
790 1161 }
791 1162
792 1163 public function renderItemPrice()
793 1164 {
1165 + // Same 'price' gate as renderPrices(): one filter hides the price
1166 + // wherever it appears, rather than making callers hunt for the second
1167 + // place the single product page prints one.
1168 + if (!RenderGate::shouldRender('price', RenderGate::context($this->product, RenderGate::SCOPE_SINGLE, $this->defaultVariant))) {
1169 + return;
1170 + }
1171 +
794 1172 if ($this->product->detail->variation_type === 'simple' && !$this->hasSubscription) {
795 1173 return; // for simple product we already rendered the price
796 1174 }
797 1175
798 - do_action('fluent_cart/product/single/before_price_block', [
1176 + do_action('fluent_cart/product/single/before_price_block', RenderContext::decorate([
799 1177 'product' => $this->product,
800 1178 'current_price' => $this->defaultVariant ? $this->defaultVariant->item_price : 0,
801 1179 'scope' => 'product_variant_price'
802 - ]);
1180 + ]));
803 1181
804 1182 foreach ($this->product->variants as $variant) {
805 1183 if ($this->shouldRenderPriceInPriceSection()) {
806 1184 $this->renderVariantPricingWrapperStart($variant);
@@ -817,13 +1195,9 @@
817 1195 ];
818 1196
819 1197 $this->renderComparePriceWrapperStart($atts);
820 1198 $this->renderVariationComparePrice($variant);
821 - if ($paymentType === 'onetime') {
822 - echo esc_html(Helper::toDecimal($variant->item_price));
823 - } else {
824 - $this->applyVariationPriceFilter($variant, $paymentType);
825 - }
1199 + $this->applyVariationPriceFilter($variant, $paymentType);
826 1200 $this->renderComparePriceWrapperEnd();
827 1201 }
828 1202
829 1203 $this->renderVariantPricingWrapperEnd();
@@ -838,18 +1212,31 @@
838 1212
839 1213
840 1214
841 1215
842 - do_action('fluent_cart/product/single/after_price_block', [
1216 + do_action('fluent_cart/product/single/after_price_block', RenderContext::decorate([
843 1217 'product' => $this->product,
844 1218 'current_price' => $this->defaultVariant ? $this->defaultVariant->item_price : 0,
845 1219 'scope' => 'product_variant_price'
846 - ]);
1220 + ]));
847 1221 }
848 1222
849 1223 public function shouldRenderPriceInPriceSection(): bool
850 1224 {
851 - return !($this->viewType === 'text' && $this->columnType === 'one');
1225 + // simple_variations keeps the legacy inline price flow for the
1226 + // one-column layouts that render the variant title (text-only and
1227 + // image+text), where the price sits on each variant row.
1228 + // Every other variation type always renders the price in the dedicated
1229 + // below-block so the data-fluent-cart-product-item-price element keeps
1230 + // a consistent position across every view/column combination.
1231 + if ($this->product->detail->variation_type === Helper::PRODUCT_TYPE_SIMPLE_VARIATION) {
1232 + $isInlineRowLayout = in_array($this->viewType, ['text', 'both'], true)
1233 + && $this->columnType === 'one';
1234 +
1235 + return !$isInlineRowLayout;
1236 + }
1237 +
1238 + return true;
852 1239 }
853 1240
854 1241 public function applyVariationPriceFilter($variant, $paymentType = 'onetime')
855 1242 {
@@ -858,13 +1245,15 @@
858 1245 'product' => $this->product,
859 1246 'variant' => $variant,
860 1247 'scope' => 'product_variant_price'
861 1248 ]));
862 - do_action('fluent_cart/product/after_price', [
1249 + do_action('fluent_cart/product/after_price', RenderContext::decorate([
863 1250 'product' => $this->product,
1251 + 'variant' => $variant,
864 1252 'current_price' => $variant->item_price,
865 1253 'scope' => 'product_variant_price'
866 - ]);
1254 + ]));
1255 + RenderHelper::renderPriceSuffix($this->product, $variant, 'product_variant_price');
867 1256 }
868 1257
869 1258 public function renderComparePriceWrapperStart($atts = [])
870 1259 {
@@ -886,8 +1275,9 @@
886 1275 return;
887 1276 } ?>
888 1277
889 1278 <span class="fct-compare-price">
1279 + <span class="fct-sr-only"><?php echo esc_html__('Original price:', 'fluent-cart'); ?></span>
890 1280 <del><?php echo esc_html(Helper::toDecimal($variant->compare_price)); ?></del>
891 1281 </span>
892 1282 <?php
893 1283 }
@@ -973,8 +1363,14 @@
973 1363 <?php }
974 1364
975 1365 public function renderQuantity()
976 1366 {
1367 + $gateContext = RenderGate::context($this->product, RenderGate::SCOPE_SINGLE, $this->defaultVariant);
1368 +
1369 + if (!RenderGate::shouldRender('quantity', $gateContext)) {
1370 + return;
1371 + }
1372 +
977 1373 $soldIndividually = $this->product->soldIndividually();
978 1374
979 1375 if (!$this->hasOnetime || $soldIndividually) {
980 1376 return;
@@ -993,12 +1389,12 @@
993 1389 if ($this->hasSubscription && Arr::get($defaultVariantData, 'payment_type') !== 'onetime') {
994 1390 $attributes['class'] .= ' is-hidden';
995 1391 }
996 1392
997 - do_action('fluent_cart/product/single/before_quantity_block', [
1393 + do_action('fluent_cart/product/single/before_quantity_block', RenderContext::decorate([
998 1394 'product' => $this->product,
999 1395 'scope' => 'product_quantity_block'
1000 - ]);
1396 + ]));
1001 1397 ?>
1002 1398 <div <?php $this->renderAttributes($attributes); ?>>
1003 1399 <label for="fct-product-qty-input" class="quantity-title">
1004 1400 <?php esc_html_e('Quantity', 'fluent-cart'); ?>
@@ -1042,23 +1438,39 @@
1042 1438 </button>
1043 1439 </div>
1044 1440 </div>
1045 1441 <?php
1046 - do_action('fluent_cart/product/single/after_quantity_block', [
1442 + do_action('fluent_cart/product/single/after_quantity_block', RenderContext::decorate([
1047 1443 'product' => $this->product,
1048 1444 'scope' => 'product_quantity_block'
1049 - ]);
1445 + ]));
1050 1446 }
1051 1447
1052 1448 public function renderPurchaseButtons($atts = [])
1053 1449 {
1450 + $gateContext = RenderGate::context($this->product, RenderGate::SCOPE_SINGLE, $this->defaultVariant);
1451 +
1452 + if (!RenderGate::shouldRender('actions', $gateContext)) {
1453 + return;
1454 + }
1455 +
1456 + do_action('fluent_cart/product/single/before_actions_block', $gateContext);
1457 +
1054 1458 $buyNowButtonAtts = $atts;
1055 1459 $this->renderBuyNowButton($buyNowButtonAtts);
1056 1460 $this->renderAddToCartButton($atts);
1461 +
1462 + do_action('fluent_cart/product/single/after_actions_block', $gateContext);
1057 1463 }
1058 1464
1059 1465 public function renderBuyNowButton($atts = [])
1060 1466 {
1467 + $gateContext = RenderGate::context($this->product, RenderGate::SCOPE_SINGLE, $this->defaultVariant);
1468 +
1469 + if (!RenderGate::shouldRenderPurchaseButton('buy_now_button', $gateContext)) {
1470 + return;
1471 + }
1472 +
1061 1473 // Stock management check using isStock() method
1062 1474 // if (ModuleSettings::isActive('stock_management')) {
1063 1475 // if ($this->product->detail->variation_type === 'simple' && $this->defaultVariant) {
1064 1476 // if (!$this->defaultVariant->isStock()) {
@@ -1107,8 +1519,17 @@
1107 1519 $buyNowAttributes['data-fct-instant-checkout-button'] = '';
1108 1520 $buyNowAttributes['data-enable-modal-checkout'] = 'yes';
1109 1521 }
1110 1522
1523 + $isShortcode = !empty($atts['is_shortcode']);
1524 + if ($isShortcode) {
1525 + ob_start();
1526 + $this->renderAttributes($buyNowAttributes);
1527 + $wrapperAttributes = ob_get_clean();
1528 + } else {
1529 + $wrapperAttributes = RenderHelper::getBlockWrapperAttributes($buyNowAttributes);
1530 + }
1531 +
1111 1532 $buyButtonText = apply_filters('fluent_cart/product/buy_now_button_text', $atts['buy_now_text'], [
1112 1533 'product' => $this->product
1113 1534 ]);
1114 1535
@@ -1123,9 +1544,9 @@
1123 1544 $variantTitle
1124 1545 )
1125 1546 : $buyButtonText;
1126 1547 ?>
1127 - <a <?php $this->renderAttributes($buyNowAttributes); ?> aria-label="<?php echo esc_attr($buyNowAriaLabel); ?>">
1548 + <a <?php echo $wrapperAttributes; ?> aria-label="<?php echo esc_attr($buyNowAriaLabel); ?>">
1128 1549 <?php echo wp_kses_post($buyButtonText); ?>
1129 1550 </a>
1130 1551 <?php
1131 1552 }
@@ -1131,15 +1552,24 @@
1131 1552 }
1132 1553
1133 1554 public function renderBuyNowButtonBlock($atts = [])
1134 1555 {
1556 + $gateContext = RenderGate::context($this->product, RenderGate::SCOPE_SINGLE, $this->defaultVariant);
1557 +
1558 + // Same gate as the in-section button: a page assembled from standalone
1559 + // button blocks must honour catalog mode too, or the gate leaks.
1560 + if (!RenderGate::shouldRenderPurchaseButton('buy_now_button', $gateContext)) {
1561 + return;
1562 + }
1563 +
1135 1564 $text = Arr::get($atts, 'text', __('Buy Now', 'fluent-cart'));
1136 1565 $variantIds = Arr::get($atts, 'variant_ids', []);
1137 1566 $variantId = Arr::get($variantIds, 0);
1567 + $customClass = trim(Arr::get($atts, 'class', ''));
1568 + $extraClass = trim(Arr::get($atts, 'extra_class', ''));
1138 1569
1139 1570 $defaults = [
1140 1571 'buy_now_text' => $text,
1141 - 'class' => '',
1142 1572 'target' => '',
1143 1573 'rel' => '',
1144 1574 'is_shortcode' => false,
1145 1575 ];
@@ -1159,9 +1589,13 @@
1159 1589 'item_id' => $variantId ?? '',
1160 1590 'quantity' => 1
1161 1591 ], site_url());
1162 1592
1163 - $buyNowClass = trim('wp-block-button__link wp-element-button ' . Arr::get($atts, 'class', ''));
1593 + $buyNowClass = $customClass ?: 'wp-block-button__link wp-element-button';
1594 + if ($extraClass) {
1595 + $buyNowClass .= ' ' . $extraClass;
1596 + }
1597 + $buyNowClass = trim($buyNowClass);
1164 1598 if ($stockStatus === 'out-of-stock') {
1165 1599 $buyNowClass .= ' out-of-stock';
1166 1600 }
1167 1601
@@ -1191,22 +1625,15 @@
1191 1625 if ($enableModalCheckout) {
1192 1626 $buyNowAttributes['data-fct-instant-checkout-button'] = '';
1193 1627 $buyNowAttributes['data-enable-modal-checkout'] = 'yes';
1194 1628 }
1195 - $wrapperAttributes = '';
1196 - $isShortcode = !empty($atts['is_shortcode']);
1197 -
1629 + $isShortcode = !empty($atts['is_shortcode']);
1198 1630 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);
1631 + ob_start();
1632 + $this->renderAttributes($buyNowAttributes);
1633 + $wrapperAttributes = ob_get_clean();
1207 1634 } else {
1208 - $wrapperAttributes = get_block_wrapper_attributes($buyNowAttributes);
1635 + $wrapperAttributes = RenderHelper::getBlockWrapperAttributes($buyNowAttributes);
1209 1636 }
1210 1637
1211 1638 $buyButtonText = apply_filters('fluent_cart/product/buy_now_button_text', $atts['buy_now_text'], [
1212 1639 'product' => $this->product
@@ -1219,8 +1646,14 @@
1219 1646 }
1220 1647
1221 1648 public function renderAddToCartButton($atts = [])
1222 1649 {
1650 + $gateContext = RenderGate::context($this->product, RenderGate::SCOPE_SINGLE, $this->defaultVariant);
1651 +
1652 + if (!RenderGate::shouldRenderPurchaseButton('add_to_cart_button', $gateContext)) {
1653 + return;
1654 + }
1655 +
1223 1656 $defaults = [
1224 1657 'buy_now_text' => __('Buy Now', 'fluent-cart'),
1225 1658 'add_to_cart_text' => __('Add To Cart', 'fluent-cart'),
1226 1659 ];
@@ -1232,8 +1665,9 @@
1232 1665 'data-cart-id' => $this->defaultVariant ? $this->defaultVariant->id : '',
1233 1666 'data-product-id' => $this->product->ID,
1234 1667 'class' => 'fluent-cart-add-to-cart-button',
1235 1668 'data-variation-type' => $this->product->detail->variation_type,
1669 + 'data-icon-only' => !empty($atts['is_icon_only']) ? 'true' : 'false',
1236 1670 ];
1237 1671
1238 1672 $defaultVariantData = $this->getDefaultVariantData();
1239 1673
@@ -1256,10 +1690,28 @@
1256 1690
1257 1691 $addToCartText = apply_filters('fluent_cart/product/add_to_cart_text', $atts['add_to_cart_text'], [
1258 1692 'product' => $this->product
1259 1693 ]);
1260 - // Render add to cart if product supports onetime, or if out of stock (to show "Not Available")
1261 - if ($this->hasOnetime || $isOutOfStock) :
1694 +
1695 + $isShortcode = !empty($atts['is_shortcode']);
1696 + if ($isShortcode) {
1697 + ob_start();
1698 + $this->renderAttributes($cartAttributes);
1699 + $wrapperAttributes = ob_get_clean();
1700 + } else {
1701 + $wrapperAttributes = RenderHelper::getBlockWrapperAttributes($cartAttributes);
1702 + }
1703 +
1704 +
1705 + // Render add to cart when the product supports a one-time path, when out
1706 + // of stock (to show "Not Available"), or for advanced-variation products.
1707 + // The advanced selector toggles this button's visibility / disabled /
1708 + // payment-type state per selection, so it must exist in the DOM even for
1709 + // a subscription-only product whose in-stock default starts hidden via
1710 + // the is-hidden class applied above — otherwise an out-of-stock
1711 + // subscription combination has no button to surface "Not Available".
1712 + $isAdvancedVariation = $this->product->detail->variation_type === Helper::PRODUCT_TYPE_ADVANCE_VARIATION;
1713 + if ($this->hasOnetime || $isOutOfStock || $isAdvancedVariation) :
1262 1714 ?>
1263 1715 <?php
1264 1716 $variantTitle = $this->defaultVariant ? $this->defaultVariant->variation_title : '';
1265 1717 $addToCartAriaLabel = $variantTitle
@@ -1270,9 +1722,9 @@
1270 1722 $variantTitle
1271 1723 )
1272 1724 : $addToCartText;
1273 1725 ?>
1274 - <button <?php $this->renderAttributes($cartAttributes); ?>
1726 + <button <?php echo $wrapperAttributes; ?>
1275 1727 aria-label="<?php echo esc_attr($addToCartAriaLabel); ?>">
1276 1728 <span class="text">
1277 1729 <?php echo wp_kses_post($addToCartText); ?>
1278 1730 </span>
@@ -1296,11 +1748,18 @@
1296 1748 }
1297 1749
1298 1750 public function renderAddToCartButtonBlock($atts = [])
1299 1751 {
1752 + $gateContext = RenderGate::context($this->product, RenderGate::SCOPE_SINGLE, $this->defaultVariant);
1300 1753
1754 + // Same gate as the in-section button — see renderBuyNowButtonBlock().
1755 + if (!RenderGate::shouldRenderPurchaseButton('add_to_cart_button', $gateContext)) {
1756 + return;
1757 + }
1758 +
1301 1759 $text = Arr::get($atts, 'text', __('Add To Cart', 'fluent-cart'));
1302 - $extraClass = trim(Arr::get($atts, 'class', ''));
1760 + $customClass = trim(Arr::get($atts, 'class', ''));
1761 + $extraClass = trim(Arr::get($atts, 'extra_class', ''));
1303 1762
1304 1763 $defaults = [
1305 1764 'add_to_cart_text' => $text,
1306 1765 ];
@@ -1306,14 +1765,18 @@
1306 1765 ];
1307 1766
1308 1767 $atts = wp_parse_args($atts, $defaults);
1309 1768
1769 + $buttonClasses = ['fct-loader', 'wp-block-button__link wp-element-button'];
1770 + $buttonClass = $customClass ?: implode(' ', $buttonClasses);
1771 +
1310 1772 $cartAttributes = [
1311 1773 'data-fluent-cart-add-to-cart-button' => '',
1312 - 'class' => 'wp-block-button__link wp-element-button fct-loader',
1774 + 'class' => $buttonClass,
1313 1775 'data-cart-id' => $this->defaultVariant ? $this->defaultVariant->id : '',
1314 1776 'data-product-id' => $this->product->ID,
1315 1777 'data-variation-type' => $this->product->detail->variation_type,
1778 + 'data-icon-only' => !empty($atts['is_icon_only']) ? 'true' : 'false',
1316 1779 ];
1317 1780
1318 1781 if ($extraClass) {
1319 1782 $cartAttributes['class'] .= ' ' . $extraClass;
@@ -1344,22 +1807,15 @@
1344 1807 $addToCartText = apply_filters('fluent_cart/product/add_to_cart_text', $atts['add_to_cart_text'], [
1345 1808 'product' => $this->product
1346 1809 ]);
1347 1810
1348 - $wrapperAttributes = '';
1349 - $isShortcode = !empty($atts['is_shortcode']);
1350 -
1811 + $isShortcode = !empty($atts['is_shortcode']);
1351 1812 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);
1813 + ob_start();
1814 + $this->renderAttributes($cartAttributes);
1815 + $wrapperAttributes = ob_get_clean();
1360 1816 } else {
1361 - $wrapperAttributes = get_block_wrapper_attributes($cartAttributes);
1817 + $wrapperAttributes = RenderHelper::getBlockWrapperAttributes($cartAttributes);
1362 1818 }
1363 1819
1364 1820 ?>
1365 1821
@@ -1395,9 +1851,9 @@
1395 1851 <p class="has-text-align-center has-large-font-size m-0">
1396 1852 <?php echo esc_html__('No Product Found!', 'fluent-cart'); ?>
1397 1853 </p>
1398 1854
1399 - <p class="has-text-align-center">
1855 + <p class="has-text-align-center m-0">
1400 1856 <?php echo esc_html__('You can try clearing any filters.', 'fluent-cart'); ?>
1401 1857 </p>
1402 1858 </div>
1403 1859 <?php
@@ -1434,14 +1890,8 @@
1434 1890 if ($variant->id == $defaultId) {
1435 1891 $itemClasses[] = 'selected';
1436 1892 }
1437 1893
1438 - $priceSuffix = apply_filters('fluent_cart/product/price_suffix_atts', '', [
1439 - 'product' => $this->product,
1440 - 'variant' => $variant,
1441 - 'scope' => 'variant_item'
1442 - ]);
1443 -
1444 1894 $renderingAttributes = [
1445 1895 'data-fluent-cart-product-variant' => '',
1446 1896 'data-cart-id' => $variant->id,
1447 1897 'data-item-stock' => $variant->isStock() ? 'in-stock' : 'out-of-stock',
@@ -1449,11 +1899,11 @@
1449 1899 'data-payment-type' => $paymentType,
1450 1900 'data-available-stock' => $availableStocks,
1451 1901 'data-item-price' => Helper::toDecimal($variant->item_price),
1452 1902 'data-compare-price' => $comparePrice,
1453 - 'data-price-suffix' => $priceSuffix,
1454 1903 'data-stock-management' => ModuleSettings::isActive('stock_management') ? 'yes' : 'no',
1455 1904 'data-sku' => $variant->sku ?? '',
1905 + 'data-package-info' => $this->getVariantPackageInfoJson($variant),
1456 1906 ];
1457 1907
1458 1908 if ($paymentType === 'subscription') {
1459 1909 $renderingAttributes['data-subscription-terms'] = $variant->getSubscriptionTermsText(true);
@@ -1496,30 +1946,29 @@
1496 1946 if ($this->viewType === 'both' || $this->viewType === 'image') {
1497 1947 $this->renderVariantImage($variant);
1498 1948 }
1499 1949 ?>
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 - ?>
1950 + <?php if ($this->viewType === 'both' || $this->viewType === 'text'): ?>
1951 + <div class="fct-product-variant-text">
1952 + <div class="fct-product-variant-title"><?php echo esc_html($variant->variation_title); ?></div>
1953 + <?php if (!$this->shouldRenderPriceInPriceSection() && $paymentType === 'subscription'): ?>
1954 + <?php $this->renderSubscriptionInfo($variant); ?>
1955 + <?php endif; ?>
1956 + </div>
1957 + <?php endif; ?>
1505 1958 </div>
1506 1959
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'): ?>
1960 + <?php if (!$this->shouldRenderPriceInPriceSection()): ?>
1513 1961 <div class="fct-product-variant-price">
1514 1962 <?php if ($comparePrice): ?>
1515 1963 <div class="fct-product-variant-compare-price">
1516 - <del aria-label="<?php echo esc_attr(__('Original price', 'fluent-cart')); ?>">
1964 + <span class="fct-sr-only"><?php echo esc_html__('Original price:', 'fluent-cart'); ?></span>
1965 + <del>
1517 1966 <span><?php echo esc_html(Helper::toDecimal($comparePrice)); ?></span></del>
1518 1967 </div>
1519 1968 <?php endif; ?>
1520 - <div class="fct-product-variant-item-price"
1521 - aria-label="<?php echo esc_attr(__('Current price', 'fluent-cart')); ?>">
1969 + <div class="fct-product-variant-item-price">
1970 + <span class="fct-sr-only"><?php echo $comparePrice ? esc_html__('Sale price:', 'fluent-cart') : esc_html__('Price:', 'fluent-cart'); ?></span>
1522 1971 <span><?php echo esc_html(Helper::toDecimal($itemPrice)); ?></span>
1523 1972 </div>
1524 1973 </div>
1525 1974 <?php endif; ?>
@@ -1539,9 +1988,9 @@
1539 1988 public function renderVariantImage($variant)
1540 1989 {
1541 1990 $image = $variant->thumbnail;
1542 1991 if (!$image) {
1543 - $image = Vite::getAssetUrl('images/placeholder.svg');
1992 + $image = Helper::getProductPlaceholderUrl();
1544 1993 }
1545 1994 ?>
1546 1995 <div class="fct-product-variant-image">
1547 1996 <img role="img" alt="<?php echo esc_attr($variant->variation_title); ?>"
@@ -1643,21 +2092,21 @@
1643 2092 //Convert to collection safely before sorting
1644 2093 $variants = (new Collection($variants))->sortBy('serial_index')->values();
1645 2094
1646 2095 foreach ($variants as $variant) {
1647 - do_action('fluent_cart/product/single/before_variant_item', [
2096 + do_action('fluent_cart/product/single/before_variant_item', RenderContext::decorate([
1648 2097 'product' => $this->product,
1649 2098 'variant' => $variant,
1650 2099 'scope' => 'product_variant_item'
1651 - ]);
2100 + ]));
1652 2101
1653 2102 $this->renderVariationItem($variant, $this->defaultVariationId);
1654 2103
1655 - do_action('fluent_cart/product/single/after_variant_item', [
2104 + do_action('fluent_cart/product/single/after_variant_item', RenderContext::decorate([
1656 2105 'product' => $this->product,
1657 2106 'variant' => $variant,
1658 2107 'scope' => 'product_variant_item'
1659 - ]);
2108 + ]));
1660 2109 }
1661 2110 ?>
1662 2111 </div>
1663 2112