getMessage());
}
return FluentCartAdapter::notice(
__('This FluentCart element could not be rendered. Check plugin compatibility.', 'sparkcore-commerce'),
'broken'
);
}
}
private static function renderLive(array $settings, string $mode): string
{
// Mini Cart is deliberately NOT one of CacheGuard::ROUTE_NO_CACHE_CONTEXTS
// (see CacheGuard's own docblock, G0H): calling this is a documented
// no-op for caching purposes. Kept here anyway so a future reviewer of
// this render path sees, in one glance, that cache-safety was
// considered and is handled below via a session-neutral shell instead
// of route-level no-cache.
CacheGuard::protectCustomerState('mini_cart');
$cartIcon = in_array(($settings['cart_icon'] ?? ''), ['cart', 'bag', 'bag-alt'], true)
? $settings['cart_icon']
: 'bag';
$showItemCount = in_array(($settings['show_item_count'] ?? ''), ['always', 'has_items', 'never'], true)
? $settings['show_item_count']
: 'has_items';
$showTotalPrice = !empty($settings['show_total_price']);
$autoOpen = !empty($settings['auto_open_on_add']);
$countMode = ($settings['count_mode'] ?? '') === 'total_quantity' ? 'total_quantity' : 'distinct_products';
$drawerPosition = ($settings['drawer_position'] ?? '') === 'left' ? 'left' : 'right';
$body = self::renderTrigger($cartIcon, $showItemCount, $showTotalPrice, $countMode);
if ($mode === 'drawer') {
$body .= self::renderDrawer();
}
$wrapAttributes = CustomerStateHydrator::wrapAttributes(['mode' => $mode, 'auto_open' => $autoOpen])
. ' data-fcbd-mini-cart-count-mode="' . esc_attr($countMode) . '"'
. ' data-fcbd-mini-cart-drawer-position="' . esc_attr($drawerPosition) . '"';
return '
'
. $body
. '
';
}
/**
* FluentCart's own trigger button, `MiniCartRenderer::renderMiniCart()`
* (app/Services/Renderer/MiniCartRenderer.php:22-106), reused in BOTH
* modes: its markup already carries the real
* `data-fluent-cart-cart-expand-button` attribute FluentCart's own global
* click-delegation listens for (FluentCartApp.js), so clicking it opens
* whichever `[data-fluent-cart-cart-drawer]` exists on the page without
* this fork needing to reimplement any open/close wiring of its own beyond
* the a11y layer (assets/cart-hydration.js).
*
* Note this renderer emits a trigger BUTTON ONLY (verified live,
* MiniCartRenderer.php:68-103: a single `` wrapping an icon, an
* optional badge and an optional total). It renders no panel, no dropdown
* and no drawer of its own. Which drawer that button opens is therefore
* decided entirely by what else exists on the page, which is exactly why
* renderDrawer() below owns the suppression decision (see its docblock).
*
* Constructed with an empty cart array and item_count 0: this is
* genuinely neutral for THIS renderer (MiniCartRenderer.php:52 guards its
* own subtotal computation on `empty($this->cartItems)`, unlike
* CartRenderer::renderTotal() used by renderDrawer() below, see
* CustomerStateHydrator par. 1).
*
* count_mode IS passed through the constructor config (fixed): FluentCart's
* MiniCartRenderer.php:19 reads count_mode ONLY from that second
* constructor argument (`Arr::get($config, 'count_mode', 'distinct_products')`)
* and writes it straight onto the badge node as data-cart-count-mode
* (MiniCartRenderer.php:77). FluentCart's own frontend mutation handler
* reads that same attribute back off the badge after every cart change to
* decide how to keep counting. Leaving this key out (the original bug
* here) meant the badge always carried FluentCart's own default
* (distinct_products), so this element's own "Count" setting silently
* stopped applying after the very first add/remove/update. This mirrors
* FluentCart's own MiniCartShortcode.php (render(), the reference
* implementation), which always passes 'count_mode' => $countMode the
* same way.
*/
private static function renderTrigger(string $cartIcon, string $showItemCount, bool $showTotalPrice, string $countMode): string
{
if (!class_exists('FluentCart\\App\\Services\\Renderer\\MiniCartRenderer')) {
return FluentCartAdapter::notice(
__('The FluentCart mini cart renderer is not available.', 'sparkcore-commerce'),
'broken'
);
}
$renderer = new \FluentCart\App\Services\Renderer\MiniCartRenderer([], ['item_count' => 0, 'count_mode' => $countMode]);
ob_start();
try {
$renderer->renderMiniCart([
'is_shortcode' => true,
'cart_icon' => $cartIcon,
'show_item_count' => $showItemCount,
'show_total_price' => $showTotalPrice,
'button_class' => 'fcbd-fc-mini-cart-trigger',
]);
return (string) ob_get_clean();
} catch (\Throwable $error) {
ob_end_clean();
throw $error;
}
}
/**
* FluentCart's own offcanvas renderer, `CartDrawerRenderer`
* (app/Services/Renderer/CartDrawerRenderer.php:10, verified live,
* Contracts.php entry from the G2K kernel-commit). Constructed with an
* empty cart array, item_count 0, open_cart false: item count/badge/list
* all correctly reflect that (CartDrawerRenderer.php:29-30 reads
* `item_count`/`open_cart` straight off the passed `$config`, never a live
* session lookup; `CartRenderer::renderList([])` on an empty array renders
* FluentCart's own real "cart is empty" state, not a placeholder this fork
* invented). The ONE exception, `CartRenderer::renderTotal()`'s footer
* total node, is neutralized explicitly below; see CustomerStateHydrator
* par. 1 for the full grep+file:line evidence.
*
* -----------------------------------------------------------------------
* Why the drawer-suppression decision lives HERE and not in AssetManager
* -----------------------------------------------------------------------
* Suppressing FluentCart's own global floating drawer
* (`fluent_cart/buttons/enable_floating_cart_button`) is only ever safe
* when this element actually supplies a replacement drawer: in drawer
* mode, and only once that replacement has really been produced. It used
* to be triggered from AssetManager::cartHydration(), which fires off a
* string scan for the element TYPE name ('FCMiniCart') on the serialized
* document. That scan cannot see the element's `mode` property (the mode
* lives in the node's properties, not in its type name), so it suppressed
* the native drawer for EVERY FC_Mini_Cart, including the default
* 'dropdown' mode (element.php::defaultProperties()), which renders only
* FluentCart's trigger button and no drawer at all. The out-of-the-box
* configuration therefore shipped a button with nothing left on the page
* to open: a dead control. This render is the first point where the mode
* is actually known, so the decision belongs here.
*
* Placing it at the END of this method, rather than in renderLive()'s
* `$mode === 'drawer'` branch, keeps suppression coupled to a replacement
* that genuinely exists: the class_exists() guard above returns a notice
* without suppressing, and a throwing render propagates out (render()
* turns it into a notice) without ever reaching the suppression call.
*
* Timing (verified live against this install, and why render time is early
* enough): FluentCart injects the native drawer from
* `CartLoader::register()`'s `add_action('wp_footer', [$this, 'init'])`
* (app/Hooks/Cart/CartLoader.php:22) and only applies the
* `enable_floating_cart_button` filter inside that `init()`
* (CartLoader.php:49), so the filter is read during `wp_footer`. Every
* Breakdance surface that renders this element renders it strictly before
* that: page/post content and templates render during the template body,
* and even a Breakdance header/footer document renders earlier still,
* because Breakdance's themeless footer path is hooked on `get_footer`
* (plugin/themeless/headers-and-footers.php:189) and echoes the footer
* document BEFORE calling `outputFootHtml()`, which is what actually
* invokes `wp_footer()` (plugin/themeless/util.php:41-49). An add_filter()
* from this render therefore lands before CartLoader ever reads it.
*
* The one surface where that ordering is not structurally guaranteed is a
* Breakdance POPUP: those render from
* `add_action("wp_footer", "\Breakdance\Themeless\add_popups_to_footer")`
* (plugin/themeless/popups.php:70), the same hook and the same default
* priority 10 as CartLoader::init(), so their relative order follows
* registration order rather than a documented contract. Rather than rely
* on that silently, suppressNativeFloatingDrawer() asserts the invariant
* and logs a one-time WP_DEBUG notice if the decision ever arrives after
* `wp_footer` has already fired (see that method's own docblock).
*/
private static function renderDrawer(): string
{
if (!class_exists('FluentCart\\App\\Services\\Renderer\\CartDrawerRenderer')) {
return FluentCartAdapter::notice(
__('The FluentCart cart drawer renderer is not available.', 'sparkcore-commerce'),
'broken'
);
}
$renderer = new \FluentCart\App\Services\Renderer\CartDrawerRenderer([], [
'item_count' => 0,
'open_cart' => false,
]);
ob_start();
try {
$renderer->render();
$html = (string) ob_get_clean();
} catch (\Throwable $error) {
ob_end_clean();
throw $error;
}
$neutralTotal = class_exists('FluentCart\\App\\Helpers\\Helper')
? (string) \FluentCart\App\Helpers\Helper::toDecimal(0, true)
: '';
// A real replacement drawer now provably exists in the returned HTML,
// so and only so is it safe to switch FluentCart's own global one off.
CustomerStateHydrator::suppressNativeFloatingDrawer();
return CustomerStateHydrator::neutralizeDrawerTotalShell($html, $neutralTotal);
}
/** @return array */
private static function section(array $properties, string $name): array
{
$content = isset($properties['content']) && is_array($properties['content']) ? $properties['content'] : [];
return isset($content[$name]) && is_array($content[$name]) ? $content[$name] : [];
}
}