'use client'; import { useEffect, useLayoutEffect, useMemo, useState, useSyncExternalStore } from 'react'; import settings from '@theme/settings'; import { getCheckoutFlowVariantOverride, isThemeEditorDesigner, noFlowVariantOnServer, subscribeCheckoutPieces, type CheckoutFlowVariantPreference } from '@akinon/pz-theme/src/chrome/checkout-pieces'; import { isKnownVariant } from './registry'; import type { CheckoutVariantId, CheckoutVariantOptions } from './types'; export interface ResolvedCheckoutConfig { variantId: CheckoutVariantId; options: CheckoutVariantOptions; } interface RawCheckoutConfig { variant?: string; options?: Record; } const FALLBACK_VARIANT: CheckoutVariantId = 'multi-step'; /** * The only two layouts a composition may steer to, mapped to variant ids. A * closed record on purpose: an unknown preference resolves to `undefined` here * and the storefront's configured variant survives — and the unified * basket-and-checkout family stays unreachable from a composition, so the * basket-to-checkout redirect pair can never be broken by a page edit. */ const COMPOSITION_VARIANT_MAP: Record< CheckoutFlowVariantPreference, CheckoutVariantId > = { 'one-page': 'one-page', 'step-by-step': 'multi-step' }; const coerceOptions = ( raw: Record | undefined ): CheckoutVariantOptions => { if (!raw) return {}; const cast = raw as Partial; const out: CheckoutVariantOptions = {}; if (typeof cast.enableEditMode === 'boolean') out.enableEditMode = cast.enableEditMode; if (typeof cast.autoAdvance === 'boolean') out.autoAdvance = cast.autoAdvance; if (typeof cast.stickyMobileCta === 'boolean') out.stickyMobileCta = cast.stickyMobileCta; if (typeof cast.showOrderReview === 'boolean') out.showOrderReview = cast.showOrderReview; if (typeof cast.showTrustBadges === 'boolean') out.showTrustBadges = cast.showTrustBadges; if (typeof cast.gtmEnabled === 'boolean') out.gtmEnabled = cast.gtmEnabled; if (typeof cast.compactMode === 'boolean') out.compactMode = cast.compactMode; return out; }; const readBaseFromSettings = (): ResolvedCheckoutConfig => { const fromSettings = (settings as { checkout?: RawCheckoutConfig })?.checkout; const variantId = isKnownVariant(fromSettings?.variant) ? fromSettings.variant : FALLBACK_VARIANT; return { variantId, options: coerceOptions(fromSettings?.options) }; }; const mergeOverride = ( base: ResolvedCheckoutConfig, override: RawCheckoutConfig | undefined ): ResolvedCheckoutConfig => { if (!override) return base; return { variantId: isKnownVariant(override.variant) ? override.variant : base.variantId, options: { ...base.options, ...coerceOptions(override.options) } }; }; /** * Resolves checkout config from three layers (each optional): * * 1. settings.js (sync, always available — baseline / fallback) * 2. /api/theme-settings (backend theme-config widget — persistent override) * 3. the composition's checkout flow outlet (`flowVariant`) — highest * * Layer 2 falls back silently if backend is unreachable or no config is saved. * The editor is OPTIONAL — when no theme-config is saved, the storefront keeps * using its built-in settings.js. * * Layer 3 only ever names a LAYOUT, never options, and only through the * closed `COMPOSITION_VARIANT_MAP`. It is read ONCE, in a layout effect, so a * composed checkout resolves its variant before first paint (the outlet's own * registration layout effect runs in an earlier sibling subtree of the same * hydration commit) and never flips mid-session — a late registration, or the * theme-settings response landing afterwards, can no longer swap the flow out * from under a shopper. On the designer canvas the live value is followed * instead, so a `Checkout Type` edit previews immediately. * * With no outlet registered, nothing overrides: the resolution is exactly the * two-layer result the storefront had before the bridge existed. */ export const useResolvedCheckoutConfig = (): ResolvedCheckoutConfig => { const [merged, setMerged] = useState(readBaseFromSettings); useEffect(() => { let cancelled = false; fetch('/api/theme-settings', { cache: 'no-store' }) .then((res) => (res.ok ? res.json() : null)) .then((data: { checkout?: RawCheckoutConfig } | null) => { if (cancelled) return; if (!data?.checkout) return; setMerged(() => mergeOverride(readBaseFromSettings(), data.checkout)); }) .catch(() => { // Silent: backend not reachable or widget missing — keep settings.js baseline }); return () => { cancelled = true; }; }, []); const liveOverride = useSyncExternalStore( subscribeCheckoutPieces, getCheckoutFlowVariantOverride, noFlowVariantOnServer ); const [latchedOverride, setLatchedOverride] = useState(null); useLayoutEffect(() => { setLatchedOverride(getCheckoutFlowVariantOverride()); }, []); // The designer flag rides a postMessage and can arrive after mount; every // claim change notifies the pieces store, which re-runs this render, so the // canvas picks it up before an outlet edit could matter. const composition = isThemeEditorDesigner() ? liveOverride : latchedOverride; // Own-key lookup: an inherited member ('constructor', 'toString', …) would // otherwise resolve to a truthy non-variant and lose the flow entirely. const compositionVariant = composition && Object.prototype.hasOwnProperty.call(COMPOSITION_VARIANT_MAP, composition) ? COMPOSITION_VARIANT_MAP[composition] : undefined; return useMemo( () => compositionVariant ? { ...merged, variantId: compositionVariant } : merged, [merged, compositionVariant] ); };