| 1 |
<?php |
| 2 |
|
| 3 |
namespace FluentCart\App\Services\Renderer; |
| 4 |
|
| 5 |
use FluentCart\Framework\Support\Arr; |
| 6 |
|
| 7 |
/** |
| 8 |
* Visibility gate for product display regions. |
| 9 |
* |
| 10 |
* Every product surface in FluentCart — the single product page, the shop |
| 11 |
* grid, carousels, related products, shortcodes, Bricks elements and the Divi |
| 12 |
* modules — funnels through ProductRenderer or ProductCardRender. Gating a |
| 13 |
* region here therefore gates it everywhere, including the AJAX fragments the |
| 14 |
* filter/infinite-scroll paths render, without a template override or CSS. |
| 15 |
* |
| 16 |
* Returning false OMITS the region's markup entirely. That is deliberate: |
| 17 |
* blanking the existing *_text filters leaves an empty styled button in the |
| 18 |
* DOM (a wasted click target and a hole in the layout), which is exactly what |
| 19 |
* a catalog-mode integration cannot use. |
| 20 |
* |
| 21 |
* Two filters run per region, in this order: |
| 22 |
* |
| 23 |
* fluent_cart/product/show_{$section} — one region, every surface |
| 24 |
* fluent_cart/product/show_section — catch-all, sees the result above |
| 25 |
* |
| 26 |
* The catch-all runs last and therefore has the final say, so "hide every |
| 27 |
* purchase affordance" stays a single add_filter instead of one per region. |
| 28 |
* |
| 29 |
* Callers narrow by the payload rather than by the hook name: `scope` says |
| 30 |
* which surface is rendering (product_card, single_product) and `section` says |
| 31 |
* which region. One hook name per region across all surfaces — a listener that |
| 32 |
* wants the shop grid but not the product page checks $context['scope']. |
| 33 |
*/ |
| 34 |
class RenderGate |
| 35 |
{ |
| 36 |
/** |
| 37 |
* A region rendered on the shop/listing product card. |
| 38 |
*/ |
| 39 |
const SCOPE_CARD = 'product_card'; |
| 40 |
|
| 41 |
/** |
| 42 |
* A region rendered on the single product page. |
| 43 |
*/ |
| 44 |
const SCOPE_SINGLE = 'single_product'; |
| 45 |
|
| 46 |
/** |
| 47 |
* Gateable regions. Each maps to a fluent_cart/product/show_{section} |
| 48 |
* filter. Kept as a list so documentation and tests can enumerate the |
| 49 |
* contract instead of grepping for apply_filters calls. |
| 50 |
*/ |
| 51 |
const SECTIONS = [ |
| 52 |
'image', |
| 53 |
'title', |
| 54 |
'excerpt', |
| 55 |
'price', |
| 56 |
'quantity', |
| 57 |
'actions', |
| 58 |
'add_to_cart_button', |
| 59 |
'buy_now_button', |
| 60 |
'buy_section', |
| 61 |
]; |
| 62 |
|
| 63 |
/** |
| 64 |
* Build the context payload a gate and its surrounding actions share. |
| 65 |
* |
| 66 |
* Single construction point so `source` / `source_name` cannot be present |
| 67 |
* on one region's payload and missing from the next one's. |
| 68 |
* |
| 69 |
* @param mixed $product |
| 70 |
* @param string $scope One of the SCOPE_* constants |
| 71 |
* @param mixed $variant Variation-specific regions only |
| 72 |
* @return array |
| 73 |
*/ |
| 74 |
public static function context($product, $scope, $variant = null) |
| 75 |
{ |
| 76 |
return RenderContext::decorate([ |
| 77 |
'product' => $product, |
| 78 |
'variant' => $variant, |
| 79 |
'scope' => $scope, |
| 80 |
]); |
| 81 |
} |
| 82 |
|
| 83 |
/** |
| 84 |
* Should this region render? |
| 85 |
* |
| 86 |
* @param string $section One of self::SECTIONS |
| 87 |
* @param array $context At minimum 'product' and 'scope'; 'variant' when |
| 88 |
* the region is variation-specific |
| 89 |
* @return bool |
| 90 |
*/ |
| 91 |
public static function shouldRender($section, array $context = []) |
| 92 |
{ |
| 93 |
$context = wp_parse_args($context, [ |
| 94 |
'product' => null, |
| 95 |
'variant' => null, |
| 96 |
'scope' => '', |
| 97 |
]); |
| 98 |
|
| 99 |
// Set after the merge so a caller cannot pass a 'section' that |
| 100 |
// disagrees with the hook name listeners are actually bound to. |
| 101 |
$context['section'] = $section; |
| 102 |
|
| 103 |
// Idempotent: decorate() leaves existing keys alone, so a context that |
| 104 |
// already came from self::context() is unchanged here. |
| 105 |
$context = RenderContext::decorate($context); |
| 106 |
|
| 107 |
$show = apply_filters("fluent_cart/product/show_{$section}", true, $context); |
| 108 |
|
| 109 |
return (bool) apply_filters('fluent_cart/product/show_section', $show, $context); |
| 110 |
} |
| 111 |
|
| 112 |
/** |
| 113 |
* Convenience for the two purchase buttons: the whole action row has to be |
| 114 |
* visible before an individual button inside it can be. |
| 115 |
* |
| 116 |
* Without this, hiding 'actions' would still leave a listener on |
| 117 |
* 'add_to_cart_button' able to resurrect a button into a row that is not |
| 118 |
* being rendered. |
| 119 |
* |
| 120 |
* @param string $section 'add_to_cart_button' or 'buy_now_button' |
| 121 |
* @param array $context |
| 122 |
* @return bool |
| 123 |
*/ |
| 124 |
public static function shouldRenderPurchaseButton($section, array $context = []) |
| 125 |
{ |
| 126 |
if (!self::shouldRender('actions', $context)) { |
| 127 |
return false; |
| 128 |
} |
| 129 |
|
| 130 |
return self::shouldRender($section, $context); |
| 131 |
} |
| 132 |
|
| 133 |
/** |
| 134 |
* CSS classes for a product card wrapper. |
| 135 |
* |
| 136 |
* @param array $classes |
| 137 |
* @param array $context |
| 138 |
* @return string Space-separated, escaped-safe class list |
| 139 |
*/ |
| 140 |
public static function cardClasses(array $classes, array $context = []) |
| 141 |
{ |
| 142 |
$classes = apply_filters('fluent_cart/product/card_classes', $classes, RenderContext::decorate(wp_parse_args($context, [ |
| 143 |
'product' => null, |
| 144 |
'scope' => self::SCOPE_CARD, |
| 145 |
]))); |
| 146 |
|
| 147 |
if (!is_array($classes)) { |
| 148 |
return ''; |
| 149 |
} |
| 150 |
|
| 151 |
// Split on whitespace before sanitising so a listener may return either |
| 152 |
// one class per entry or a space-separated string — sanitize_html_class() |
| 153 |
// strips the space and would otherwise weld 'sale badge' into 'salebadge'. |
| 154 |
$flat = []; |
| 155 |
foreach (Arr::flatten($classes) as $class) { |
| 156 |
foreach (preg_split('/\s+/', (string) $class) as $piece) { |
| 157 |
$flat[] = $piece; |
| 158 |
} |
| 159 |
} |
| 160 |
|
| 161 |
$flat = array_filter(array_map('sanitize_html_class', $flat)); |
| 162 |
|
| 163 |
return implode(' ', array_unique($flat)); |
| 164 |
} |
| 165 |
} |
| 166 |
|