*/ private static array $products = []; /** @var array */ private static array $variations = []; /** * Request-scoped explicit product scope stack. This is the documented fallback * described in the WP4 task brief for "no usable server mechanism exists": Golf 1's * G1K kernel-commit PROVED that a usable server mechanism (sharePropsWithSSRChildren, * see resolveInheritedScope()) does in fact exist and is the primary channel, so this * stack is a secondary safety net only, for call sites that invoke resolve() outside * the SSR $parentPropertiesData argument chain (e.g. a DynamicDataRegistry field * callback resolving "the product in scope" synchronously during the SAME PHP call * stack as FC Product Builder's own ssr.php, rather than via a descendant element's * own independent ssr() invocation). * * Documented limitation, honestly: Breakdance's renderer walks `_render($node['children'], ...)` * (plugin/render/renderer.php:1291-1297) BEFORE substituting a parent's own `%%CHILDREN%%` * placeholder text into its already-returned ssr() string (plugin/render/renderer.php:488), * so a descendant ELEMENT's ssr() call is not guaranteed to run strictly "between" a * pushScope() and popScope() pair issued from inside the parent's own ssr.php. The * $parentPropertiesData channel has no such ordering dependency (it is plain data * attached to the node tree, read lazily whenever a descendant's ssr() actually runs) * and is therefore always tried first in resolveInheritedScope(). * * @var array */ private static array $scopeStack = []; /** @var int */ private static int $instanceCounter = 0; /** * @param array $settings * @param array|null $parentPropertiesData The `$parentPropertiesData` * argument Breakdance threads into `Element::ssr($propertiesData, $parentPropertiesData, ...)` * whenever the nearest flagged ancestor's `settings()` sets * `sharePropsWithSSRChildren => true` (plugin/elements/Element.class.php:218; * docs/audit/g1k-probes.md par. 1). Pass it straight through from an element's own * ssr.php (it is already in scope there as a same-named local variable); omit it * (or pass null) for call sites with no such argument available, e.g. dynamic-data * field callbacks, in which case only the scope-stack fallback is consulted. * Golf 0 callers that only pass $settings keep working unmodified: this parameter * is optional and appended last. * @return object|null FluentCart product model when available. */ public static function resolve(array $settings = [], ?array $parentPropertiesData = null) { $source = isset($settings['source']) ? (string) $settings['source'] : 'current'; $manualId = isset($settings['product_id']) ? self::normalizeChosenProductId($settings['product_id']) : 0; // 1. Explicit choice on this element. if ($source === 'manual' && $manualId > 0) { return self::find($manualId); } // 2. Inherited ancestor scope (FC Product Builder), only when this element did // not itself pin a manual product above. $inherited = self::resolveInheritedScope($parentPropertiesData); if ($inherited) { return $inherited; } // 3. Breakdance LoopController "current loop item". $loopProductId = self::loopControllerProductId(); if ($loopProductId > 0) { $loopProduct = self::find($loopProductId); if ($loopProduct) { return $loopProduct; } } // 4. FluentCart's own single-product routing. if (function_exists('fluent_cart_get_current_product')) { $current = fluent_cart_get_current_product(); if ($current) { return $current; } } // 5. Queried post. $post = function_exists('get_post') ? get_post() : null; if ($post instanceof \WP_Post && $post->post_type === 'fluent-products') { return self::find((int) $post->ID); } // 6. Legacy golf-0 fallback: a manual id filled in while `source` was left at its // default, honored only on a confirmed builder/SSR/dynamic-data request. if ($manualId > 0 && self::isBuilderRequest()) { return self::find($manualId); } // 7. Editor-preview-product fallback. if (self::isBuilderRequest()) { $previewId = self::editorPreviewProductId(); if ($previewId > 0) { return self::find($previewId); } } return null; } /** * Resolves an inherited FC Product Builder scope, trying the real SSR * parent-property channel first and the explicit scope-stack second. * * @param array|null $parentPropertiesData * @return object|null */ private static function resolveInheritedScope(?array $parentPropertiesData) { if ($parentPropertiesData) { $parentProductSettings = self::extractParentProductSettings($parentPropertiesData); if ($parentProductSettings !== null) { // Bounded, single-level resolution using the ancestor's OWN settings: no // further $parentPropertiesData is passed, so this cannot recurse past one // level. Breakdance threads the SAME flagged ancestor's properties to every // descendant at every nesting depth (renderer.php:1280, "$parentProperties" // is only reassigned by the NEXT flagged ancestor), so one level is already // the complete inherited context; there is nothing further up to walk. $inherited = self::resolve($parentProductSettings, null); if ($inherited) { return $inherited; } } } $scopedId = self::currentScopeProductId(); if ($scopedId > 0) { return self::find($scopedId); } return null; } /** * Reads the ancestor FC Product Builder's own chosen-product settings back out of * the properties array Breakdance hands down via sharePropsWithSSRChildren. * * Path convention: `content.product` (matching the EXACT value shape already used by * Single_Product/Product_Part/Product_Action's own `content.product.{source,product_id}` * controls, per the WP4 task brief's "zelfde value-shape-normalisatie als golf 0"). * This intentionally differs from the real WooCommerce Product Builder's own * `content.content.product` (a bare scalar id, subplugins/breakdance-elements/elements/ * WooProductPrice/ssr.php:7) because FC Product Builder's OWN contentControls() nests * `product` directly under `content` (one level shallower), not under a `content.content` * wrapper section; the descendant-side reading convention always mirrors whatever the * providing element's own contentControls() shape actually is. * * @param array $parentPropertiesData * @return array|null */ private static function extractParentProductSettings(array $parentPropertiesData): ?array { $product = $parentPropertiesData['content']['product'] ?? null; return is_array($product) ? $product : null; } /** * Pushes an explicit product scope onto the request-scoped fallback stack. See the * docblock on `$scopeStack` for what this is (and is not) a reliable substitute for. */ public static function pushScope(int $productId): void { self::$scopeStack[] = $productId; } /** * Pops the most recently pushed scope. Safe to call even when the stack is already * empty (a no-op), so a defensive `finally { ProductContext::popScope(); }` around a * pushScope() call never itself throws. */ public static function popScope(): void { if (!empty(self::$scopeStack)) { array_pop(self::$scopeStack); } } /** * @return int The product id at the top of the scope stack, or 0 when empty. */ public static function currentScopeProductId(): int { if (empty(self::$scopeStack)) { return 0; } return (int) end(self::$scopeStack); } /** * FIX (audit 2026-07, finding 1): runs $callback while guaranteeing that whatever it * does to $GLOBALS['fct_product'] and global $post is undone immediately afterward, * even when $callback throws. * * The mechanism this closes: FluentCart's own * FluentCart\App\Services\Renderer\ProductRenderer::renderDescription() temporarily * swaps global $post to the product being described and calls setup_postdata($post) * so `the_content` filters see the right post. setup_postdata() fires WordPress's * `the_post` action, which FluentCart's own * ProductDataSetup::maybeSetupProductData() listens to (priority 1) and uses to set * $GLOBALS['fct_product']. renderDescription() then tries to restore the previous * state, either via setup_postdata($originalPost) when a $post was already set, or * wp_reset_postdata() when it was not. wp_reset_postdata() is documented WordPress * core behavior: it is a no-op whenever $wp_query->post is empty. On this plugin's own * render paths (Breakdance SSR, admin-ajax, REST dynamic-data) the main $wp_query * frequently has no ->post at all, even on the rendered product's own permalink, so * that restore silently fails and both $GLOBALS['fct_product'] and global $post stay * pointed at the just-rendered product for the rest of the request. Every other * element on the same page whose product source is "current" (resolve() step 4, via * fluent_cart_get_current_product(), which reads $GLOBALS['fct_product'] first and * falls back to a get_post()-based lookup keyed on global $post) then silently * resolves the wrong product. * * This cannot be fixed by calling wp_reset_postdata() more aggressively (this fork * does not own $wp_query and must not mutate it), so instead every render path in this * fork that can reach a FluentCart renderer wraps that one call with this method: * snapshot the two globals immediately before, run the callback, restore the exact * snapshot immediately after, in a finally block so a thrown exception still restores * state before it propagates to the caller's own guard()/try-catch. * * Snapshotting uses array_key_exists(), not isset()/empty(), on purpose: a global that * was not set at all before the callback ran is a different state from one that was * set to an explicit null, and restoring the wrong one of those two would silently * reintroduce the same class of bug in the opposite direction (a legitimate null gets * overwritten back to "not set", or a legitimate "not set" gets overwritten to an * explicit null). * * @template T * @param callable(): T $callback * @return T Whatever $callback returns, unchanged. */ public static function withIsolatedGlobalState(callable $callback) { $hadPost = array_key_exists('post', $GLOBALS); $previousPost = $hadPost ? $GLOBALS['post'] : null; $hadFctProduct = array_key_exists('fct_product', $GLOBALS); $previousFctProduct = $hadFctProduct ? $GLOBALS['fct_product'] : null; try { return $callback(); } finally { if ($hadPost) { $GLOBALS['post'] = $previousPost; } else { unset($GLOBALS['post']); } if ($hadFctProduct) { // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedVariableFound -- Restores FluentCart's own global; not a global owned by this plugin. $GLOBALS['fct_product'] = $previousFctProduct; } else { unset($GLOBALS['fct_product']); } } } /** * Hands out a per-request-unique instance id for the `data-fcbd-instance` wrapper * attribute (see the class docblock, "Instance-scoping fundament"). Format is * intentionally opaque and stable only within a single request; nothing persists it. */ public static function nextInstanceId(): string { self::$instanceCounter++; return 'fcbd-instance-' . self::$instanceCounter; } /** * Runtime capability check for the SSR parent-property mechanism, mirroring * `Contracts.php`'s `cap.bd.share_props_with_ssr_children` check (that entry is * read-only to this fork per the G1K kernel-commit rule; this is an independent, * equivalent check owned by ProductContext so FC Product Builder's own `settings()` * can gate `sharePropsWithSSRChildren` without reaching into a file this fork does * not own). Evidence: plugin/render/renderer.php:1277-1297,505; * plugin/elements/Element.class.php:218; docs/audit/g1k-probes.md par. 1. * * When this is false (a hypothetical future Breakdance profile lacking the base * `Element::ssr()` signature entirely), FC Product Builder simply omits the flag from * its `settings()` and descendants fall back to the scope-stack channel only. */ public static function supportsParentPropertySharing(): bool { return class_exists('Breakdance\\Elements\\Element') && method_exists('Breakdance\\Elements\\Element', 'ssr'); } /** * Reads the current Breakdance LoopController "loop item" (plugin/dynamic-data/ * loop-controller.php:1-59; docs/audit/g1k-probes.md par. 3) and, if it carries a * `fluent-products` post reference, returns that post's id. * * Consumer pattern deliberately mirrors the real, confirmed producer/consumer contract * documented in g1k-probes.md par. 3 (`toolset-oembed-field.php:56-61`'s default-id * fallback-read pattern and `MetaboxField.php:143`'s by-id singleton read): this method * only READS `LoopController::getInstance('default')->get()`; it never calls `set()`/ * `reset()` itself, since per the proven contract those calls are the responsibility of * whichever repeater/loop ELEMENT owns the iteration (a future FC Products collection * loop, ELITE-PLAN.md WP5/WP7, is the natural future producer for a fluent-products * loop item on this id). Accepts either the array shape a dynamic-data field payload * commonly uses (`['product_id' => ...]` or `['ID' => ..., 'post_type' => 'fluent-products']`) * or an object with equivalent properties, since the real LoopController stores * `$field` as "whatever the owning repeater set" with no schema enforced. */ private static function loopControllerProductId(): int { if (!class_exists('Breakdance\\DynamicData\\LoopController') || !method_exists('Breakdance\\DynamicData\\LoopController', 'getInstance')) { return 0; } $instance = \Breakdance\DynamicData\LoopController::getInstance(); if (!is_object($instance) || !method_exists($instance, 'get')) { return 0; } $field = $instance->get(); if (empty($field)) { return 0; } if (is_array($field)) { if (!empty($field['product_id'])) { return absint($field['product_id']); } if (!empty($field['ID']) && ($field['post_type'] ?? '') === 'fluent-products') { return absint($field['ID']); } return 0; } if (is_object($field)) { if (isset($field->product_id)) { return absint($field->product_id); } if (isset($field->post_type, $field->ID) && $field->post_type === 'fluent-products') { return absint($field->ID); } } return 0; } /** * Normalizes the value stored by the `product_id` control across both the * legacy plain text field (a bare numeric string, e.g. "42") and the * `post_chooser` control now used by Product_Action/Product_Card/Product_Part/ * Single_Product/FC_Product_Builder. * * Verified against the real Breakdance source (subplugins/breakdance-elements), * where every first-party element using `post_chooser` with * `postChooserOptions.multiple => false` reads its stored content value as a * bare scalar post ID: * - Product/ssr.php: `$productId = $content['product'] ?? null;` passed * straight into `WooActions::filterProduct()`. * - Related_Products/ssr.php and Upsell_Products/ssr.php: same pattern, * `$productId = $content['product'] ?? null;` passed straight into * `get_post($productId)`. * So the single-select post_chooser value shape is identical to the old * text field (a scalar id), no shape migration is actually required for the * common case. This helper stays defensive only: if a future control * revision or a `multiple => true` reconfiguration ever stores an array * (e.g. `['value' => 42]` or `[42]`), the first usable scalar is unwrapped * before casting, instead of silently resolving to product 0. */ private static function normalizeChosenProductId($value): int { if (is_array($value)) { if (isset($value['value'])) { $value = $value['value']; } elseif (isset($value['id'])) { $value = $value['id']; } else { $value = reset($value); } } return absint($value ?: 0); } /** * Editor-preview-product fallback (shared WP2 contract between the two * Golf-0 forks). The option key `fcbd_preview_product_id` and the filter * `fcbd/editor_preview_product_id` are owned and populated by fork 2b in * src/Breakdance/EditorSupport.php; this is only the read side, consumed as * the LAST resort and only for requests already confirmed to be a Breakdance * builder/SSR/dynamic-data request (see isBuilderRequest()), never on the * live frontend. * * No admin UI ships for this yet by design: a proper picker for the * site-wide (and later per-document) preview product is planned for WP12 * (the backend admin app), see ELITE-PLAN.md par. 3. Until then the value is * only settable via the `fcbd_preview_product_id` option or the filter. */ private static function editorPreviewProductId(): int { $default = function_exists('get_option') ? (int) get_option('fcbd_preview_product_id', 0) : 0; if (function_exists('apply_filters')) { return absint(apply_filters('fcbd/editor_preview_product_id', $default)); } return absint($default); } /** @return object|null */ public static function find(int $productId) { if ($productId < 1 || !class_exists('FluentCart\\App\\Models\\Product')) { return null; } if (!array_key_exists($productId, self::$products)) { if (class_exists('FluentCart\\App\\Modules\\Data\\ProductDataSetup') && method_exists('FluentCart\\App\\Modules\\Data\\ProductDataSetup', 'getProductModel')) { self::$products[$productId] = \FluentCart\App\Modules\Data\ProductDataSetup::getProductModel($productId); } else { self::$products[$productId] = \FluentCart\App\Models\Product::query() ->with(['detail', 'variants']) ->find($productId); } } return self::$products[$productId]; } /** * FIX (audit 2026-07, finding 2): whether a resolved product carries a usable * wp_fct_product_details row. A product post can exist (any status, including draft), * with zero variations and no matching row in that table at all, in which case the * detail() relation resolves to null (same "?? null" idiom already used throughout * this codebase, e.g. AtomRenderer::minPriceText(), FluentCartAdapter::formattedPrice()). * * FluentCart's own renderers (ProductRenderer, ProductCardRender) read * $product->detail->... in several places with no null check of their own, which * raises a PHP warning (and, in at least one place, a deprecation) per access when * handed a product in that state, instead of failing visibly. This is deliberately * kept as its own predicate rather than folded into find()'s return value: find() is * a shared, cached lookup consumed by other forks' query/collection code too (outside * this fork's file area), so its "a product row exists" contract is left unchanged * here. Callers in this fork's own render paths (AtomRenderer, ElementRenderer) that * resolve a SINGLE product for rendering call this explicitly and treat a false result * the same as no product at all. * * Substance-wise this is "missing / incomplete", not "broken": the product exists, it * is just not renderable yet, so a caller reporting this should use the 'missing' * placeholder state (FluentCartAdapter::notice()), not 'broken'. * * @param object|null $product */ public static function hasUsableDetail($product): bool { return $product !== null && ($product->detail ?? null) !== null; } /** @param object|null $product */ public static function defaultVariation($product) { if (!$product) { return null; } $detail = $product->detail ?? null; $defaultId = $detail ? (int) ($detail->default_variation_id ?? 0) : 0; $variants = $product->variants ?? []; $isAdvanced = (string) ($detail->variation_type ?? '') === 'advanced_variations'; $eligible = []; foreach ($variants as $variation) { if (!$isAdvanced || ($variation->item_status ?? 'active') === 'active') { $eligible[] = $variation; } } foreach ($eligible as $variation) { if ($defaultId && (int) ($variation->id ?? 0) === $defaultId) { return $variation; } } usort($eligible, static function ($left, $right): int { $leftOrder = isset($left->serial_index) ? (int) $left->serial_index : PHP_INT_MAX; $rightOrder = isset($right->serial_index) ? (int) $right->serial_index : PHP_INT_MAX; return $leftOrder <=> $rightOrder; }); return $eligible[0] ?? null; } /** @return object|null */ public static function findVariation(int $variationId) { if ($variationId < 1 || !class_exists('FluentCart\\App\\Models\\ProductVariation')) { return null; } if (!array_key_exists($variationId, self::$variations)) { self::$variations[$variationId] = \FluentCart\App\Models\ProductVariation::query()->find($variationId); } return self::$variations[$variationId]; } /** * Detects a Breakdance builder-editor, SSR, or dynamic-data request. * * Uses the official Breakdance detection functions when available * (plugin/util/is-request-from-builder-iframe.php), falling back to the same * $_GET['breakdance_iframe'] flag they check when the function isn't loaded yet. * The legacy Oxygen-era markers this used to also check have zero references in * Breakdance 2.7.2 and have been removed (audit-fix A4, see * docs/audit/breakdance-api.md par. 12). */ public static function isBuilderRequest(): bool { if (function_exists('Breakdance\\isRequestFromBuilderIframe') && \Breakdance\isRequestFromBuilderIframe()) { return true; } // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Read-only builder-context detection; presence check only, nothing is stored or changed. if (!function_exists('Breakdance\\isRequestFromBuilderIframe') && isset($_GET['breakdance_iframe'])) { return true; } if (function_exists('Breakdance\\isRequestFromBuilderSsr') && \Breakdance\isRequestFromBuilderSsr()) { return true; } if (function_exists('Breakdance\\isRequestFromBuilderDynamicDataGet') && \Breakdance\isRequestFromBuilderDynamicDataGet()) { return true; } $action = ''; // phpcs:disable WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput -- Read-only builder-request detection (compares the action name only); unslashed and sanitized below, no state is changed. if (isset($_POST['action'])) { $action = (string) $_POST['action']; $action = function_exists('wp_unslash') ? (string) wp_unslash($action) : $action; $action = function_exists('sanitize_text_field') ? sanitize_text_field($action) : $action; } // phpcs:enable WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput return $action === 'breakdance_server_side_render' || $action === 'breakdance_dynamic_data_get'; } }