@reblu/site-next 1.20.1 → 1.22.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +21 -0
- package/README.md +94 -0
- package/dist/cart.cjs +235 -2
- package/dist/cart.cjs.map +1 -1
- package/dist/cart.d.cts +135 -1
- package/dist/cart.d.ts +135 -1
- package/dist/cart.js +229 -1
- package/dist/cart.js.map +1 -1
- package/dist/recaptcha-provider.cjs +26 -2
- package/dist/recaptcha-provider.cjs.map +1 -1
- package/dist/recaptcha-provider.d.cts +24 -1
- package/dist/recaptcha-provider.d.ts +24 -1
- package/dist/recaptcha-provider.js +25 -1
- package/dist/recaptcha-provider.js.map +1 -1
- package/dist/recaptcha.cjs +3 -0
- package/dist/recaptcha.cjs.map +1 -1
- package/dist/recaptcha.d.cts +8 -1
- package/dist/recaptcha.d.ts +8 -1
- package/dist/recaptcha.js +2 -0
- package/dist/recaptcha.js.map +1 -1
- package/package.json +4 -4
package/dist/cart.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/cart/provider.tsx","../src/cart/store.ts","../src/cart/use-cart.ts"],"sourcesContent":["'use client'\n\nimport { useEffect } from 'react'\nimport { useCartStore } from './store'\n\n/**\n * `CartProvider` — mount this once near the root layout (inside a Client\n * Component boundary). It owns the ONE explicit-hydration call\n * `useCartStore.persist.rehydrate()` requires (`store.ts`'s `skipHydration`\n * doc) — a SITE that forgets to mount it gets an always-empty cart that never\n * reads back whatever a previous visit (or page reload) persisted.\n */\nexport function CartProvider({ children }: { children: React.ReactNode }) {\n useEffect(() => {\n useCartStore.persist.rehydrate()\n }, [])\n\n return <>{children}</>\n}\n","import { create } from 'zustand'\nimport { persist } from 'zustand/middleware'\n\n/**\n * `useCartStore` — the SDK's cart state (REB-829). Ported verbatim (same\n * shape, same quantity-clamping rules) from demo-site's local\n * `lib/store/cart-store.ts`, which every SITE was otherwise expected to\n * hand-copy. `CART_STORAGE_KEY` stays exactly `'shop-cart'` on purpose: a\n * SITE migrating from the local copy keeps its visitors' existing carts.\n *\n * `skipHydration: true` (zustand persist) is load-bearing for SSR: without\n * it, zustand's persist middleware reads `localStorage` synchronously during\n * module evaluation, which throws (or silently desyncs) on the server. The\n * `CartProvider` (`./provider`) calls `useCartStore.persist.rehydrate()`\n * inside a `useEffect`, so hydration only ever happens client-side, after\n * mount — the same explicit-hydration contract the SITE README documents.\n */\nexport const CART_STORAGE_KEY = 'shop-cart'\n\n/**\n * The zustand `persist` schema version for the cart's PERSISTED shape (the\n * `CartItem[]` written to `localStorage` under `CART_STORAGE_KEY`) — bump\n * this (with a `migrate` option) if that shape ever changes incompatibly.\n * Also folded into {@link CART_STORE_GLOBAL_KEY} (REB-837 review fix): a\n * future incompatible shape change should mint a NEW global singleton\n * rather than let a stale, already-evaluated old-shape instance (e.g. kept\n * alive across an HMR reload in dev) silently satisfy the new one.\n */\nexport const CART_STORE_PERSIST_VERSION = 0\n\nexport interface CartItem {\n productId: string\n variantId?: string | null\n name: string\n variantName?: string\n /** Integer cents — every Reblu shop price travels as cents, never a float. */\n unitPrice: number\n quantity: number\n imageUrl?: string\n /** `null` means untracked/unlimited stock — never clamp against it. */\n maxStock: number | null\n /** Basis points, e.g. `2100` = 21%. `null`/`undefined` = unknown or exempt. */\n taxRateBps?: number | null\n taxRateName?: string | null\n}\n\n/**\n * Outcome of `addItem` (REB-837): a SITE's add-to-cart feedback (toast,\n * `AddToCartButton`'s built-in confirmed/error state) needs to know what\n * actually happened, not just fire-and-forget — `'clamped'` only differs\n * from `'added'` in the RESULTING quantity reaching less than what was\n * requested (capped by `maxStock`) while the line still grew; `'refused'`\n * means nothing was added (invalid quantity, out of stock, or the line was\n * already at `maxStock` — REB-839).\n */\nexport type AddItemStatus = 'added' | 'clamped' | 'refused'\n\nexport interface AddItemOutcome {\n status: AddItemStatus\n /** The resulting line quantity after the call (`0` when refused and no line exists). */\n quantity: number\n}\n\nexport interface CartStore {\n items: CartItem[]\n addItem: (item: CartItem) => AddItemOutcome\n removeItem: (productId: string, variantId?: string | null) => void\n updateQuantity: (\n productId: string,\n variantId: string | null | undefined,\n quantity: number,\n ) => void\n clearCart: () => void\n getTotal: () => number\n getItemCount: () => number\n}\n\nfunction isSameItem(a: CartItem, productId: string, variantId?: string | null): boolean {\n return a.productId === productId && (a.variantId ?? null) === (variantId ?? null)\n}\n\n/**\n * REB-829 review finding: neither `addItem` nor `updateQuantity` validated\n * `quantity` — `0`, a negative number, `NaN` (e.g. a stray `parseInt('')`\n * upstream), a non-integer, or `maxStock` itself being `0` (out of stock)\n * all used to leave an invalid line sitting in the cart, silently reaching\n * checkout.\n *\n * Normalizes to a safe POSITIVE INTEGER clamped to `maxStock`. Returns `0`\n * to signal \"not a valid positive quantity\" — callers decide what that\n * means for them: `addItem` ignores the call (never creates/corrupts a\n * line), `updateQuantity` removes the line (same as its pre-existing\n * `quantity <= 0` → remove contract, just now reached by more inputs).\n *\n * `Math.trunc` rounds toward zero (not `Math.floor`, which would round a\n * negative input further negative before the `< 1` check below ever saw it\n * — not a behavioural difference here since any negative result is\n * rejected either way, but `trunc` is the operation that actually means\n * \"drop the fractional part\").\n */\nfunction normalizeQuantity(quantity: number, maxStock: number | null): number {\n if (!Number.isFinite(quantity)) return 0\n const truncated = Math.trunc(quantity)\n if (truncated < 1) return 0\n if (maxStock === null) return truncated\n const maxTruncated = Math.trunc(maxStock)\n if (maxTruncated < 1) return 0\n return Math.min(truncated, maxTruncated)\n}\n\n/**\n * REB-837 cross-entry singleton guard — the mutation that caught it: a\n * single test rendering `useShopOrderStatus` (the `./shop` tsup entry) and\n * asserting on `useCartStore` (the `./cart` entry) saw the order resolve to\n * `paid` but the cart stay non-empty. Root cause: `splitting: false` (R4,\n * `tsup.config.ts`) bundles each entry standalone, so a plain relative\n * import of this file from `./shop` INLINES a SEPARATE copy of this\n * `create(...)` call — a second, disconnected store instance with its own\n * state, not the one `@reblu/site-next/cart` consumers read. `clearCart()`\n * from inside the hook silently cleared the WRONG store.\n *\n * `globalThis` keyed by a `Symbol.for(...)` (same registry entry regardless\n * of which bundle's copy of this module evaluates it first) makes every\n * entry that imports this file — however many times esbuild inlines it —\n * resolve to the SAME store. Verified by mutation: removing this guard\n * (reverting to a bare `export const useCartStore = create(...)`) turns\n * `use-shop-order-status.test.ts`'s cart-clearing assertions red again once\n * the hook is exercised through its OWN entry rather than imported directly\n * within `cart`'s own test file (which never saw the bug, since it only\n * ever touches ONE copy of this module).\n */\nfunction createCartStore() {\n return create<CartStore>()(\n persist(\n (set, get) => ({\n items: [],\n\n addItem: (item) => {\n // REB-837: `set`'s updater can't return a value to the caller, so the\n // outcome is captured in this closure variable and returned after.\n let outcome: AddItemOutcome = { status: 'refused', quantity: 0 }\n\n set((state) => {\n const existing = state.items.find((i) => isSameItem(i, item.productId, item.variantId))\n\n if (existing) {\n // REB-829 review follow-up (a): incoming maxStock is the\n // FRESHEST stock signal available — if it says 0, the product is\n // confirmed out of stock right now, so the existing line is\n // removed outright (same contract as updateQuantity's own\n // \"quantity resolves to <1 → remove\"), not merely left alone.\n if (item.maxStock !== null && Math.trunc(item.maxStock) < 1) {\n outcome = { status: 'refused', quantity: 0 }\n return {\n items: state.items.filter((i) => !isSameItem(i, item.productId, item.variantId)),\n }\n }\n\n const requestedQty = existing.quantity + item.quantity\n const newQty = normalizeQuantity(requestedQty, item.maxStock)\n if (newQty < 1) {\n // Invalid delta (NaN/non-positive quantity) — leave the\n // existing, previously-valid line untouched rather than\n // destroying good data because of one bad call.\n outcome = { status: 'refused', quantity: existing.quantity }\n return {}\n }\n\n // REB-839: nothing was gained when stock caps the line at (or,\n // with fresher stock, below) what it already held — report that\n // as 'refused', never as a partial-success 'clamped'. The line is\n // still reconciled below to the fresher maxStock.\n outcome = {\n status:\n newQty <= existing.quantity\n ? 'refused'\n : newQty < requestedQty\n ? 'clamped'\n : 'added',\n quantity: newQty,\n }\n\n // Refresh maxStock with the incoming value on every successful\n // merge — the stale-maxStock finding: a stored line used to keep\n // its ORIGINAL maxStock forever, so a later updateQuantity call\n // clamped against stock data that could be long out of date.\n return {\n items: state.items.map((i) =>\n isSameItem(i, item.productId, item.variantId)\n ? { ...i, quantity: newQty, maxStock: item.maxStock }\n : i,\n ),\n }\n }\n\n const qty = normalizeQuantity(item.quantity, item.maxStock)\n if (qty < 1) {\n // Invalid quantity, or maxStock 0 (out of stock) — never create\n // a line nobody could actually buy.\n outcome = { status: 'refused', quantity: 0 }\n return {}\n }\n\n outcome = { status: qty < item.quantity ? 'clamped' : 'added', quantity: qty }\n return { items: [...state.items, { ...item, quantity: qty }] }\n })\n\n return outcome\n },\n\n removeItem: (productId, variantId) => {\n set((state) => ({\n items: state.items.filter((i) => !isSameItem(i, productId, variantId)),\n }))\n },\n\n updateQuantity: (productId, variantId, quantity) => {\n const existing = get().items.find((i) => isSameItem(i, productId, variantId))\n if (!existing) return\n\n const newQty = normalizeQuantity(quantity, existing.maxStock)\n if (newQty < 1) {\n get().removeItem(productId, variantId)\n return\n }\n\n set((state) => ({\n items: state.items.map((i) =>\n isSameItem(i, productId, variantId) ? { ...i, quantity: newQty } : i,\n ),\n }))\n },\n\n clearCart: () => set({ items: [] }),\n\n getTotal: () => get().items.reduce((sum, item) => sum + item.unitPrice * item.quantity, 0),\n\n getItemCount: () => get().items.reduce((sum, item) => sum + item.quantity, 0),\n }),\n {\n name: CART_STORAGE_KEY,\n version: CART_STORE_PERSIST_VERSION,\n skipHydration: true,\n },\n ),\n )\n}\n\ntype CartStoreApi = ReturnType<typeof createCartStore>\n\n/**\n * Versioned (REB-837 review fix) with {@link CART_STORE_PERSIST_VERSION} —\n * an incompatible shape change bumps that version, which mints a NEW\n * `Symbol.for(...)` registry key, so a stale old-shape singleton (e.g. kept\n * alive across an HMR reload in dev before a code change takes effect)\n * never satisfies the new one.\n */\nconst CART_STORE_GLOBAL_KEY = Symbol.for(\n `@reblu/site-next/cart-store@v${CART_STORE_PERSIST_VERSION}`,\n)\n\ninterface GlobalWithCartStore {\n [CART_STORE_GLOBAL_KEY]?: CartStoreApi\n}\n\nconst globalForCartStore = globalThis as typeof globalThis & GlobalWithCartStore\n\nexport const useCartStore: CartStoreApi =\n globalForCartStore[CART_STORE_GLOBAL_KEY] ??\n (globalForCartStore[CART_STORE_GLOBAL_KEY] = createCartStore())\n\n/**\n * TEST-ONLY (REB-837 review fix). Clears the `globalThis`-keyed slot so the\n * NEXT access to `useCartStore` — in THIS module instance — creates a fresh\n * store. The singleton is intentionally shared process-wide; most tests\n * should keep resetting STATE via `useCartStore.setState(...)` in\n * `beforeEach`, not the instance itself. Reach for this only to prove\n * something about the singleton mechanism (e.g. the cross-entry test in\n * `tests/cart-store-cross-entry-singleton.test.ts`) — it does not and\n * cannot affect an ALREADY-evaluated copy of this module inlined into a\n * different tsup entry's bundle (each such copy owns its own `const\n * globalForCartStore`/`CART_STORE_GLOBAL_KEY` closed over at that copy's\n * own module-evaluation time, same as any other ES module binding).\n */\nexport function __resetCartStoreForTests(): void {\n delete globalForCartStore[CART_STORE_GLOBAL_KEY]\n}\n","'use client'\n\nimport { useSyncExternalStore } from 'react'\n\nimport { useCartStore, type AddItemOutcome, type CartItem } from './store'\n\nexport interface UseCartResult {\n items: CartItem[]\n /** Sum of every line's `unitPrice * quantity`, in integer cents. */\n total: number\n /** Sum of every line's `quantity`. */\n itemCount: number\n /**\n * Whether `CartProvider`'s `persist.rehydrate()` has finished (REB-837).\n * `false` on the server and on the very first client render — `items`\n * (and therefore `itemCount`) jumps from `0` to whatever was persisted the\n * instant hydration finishes, which is NOT a real \"item added\" event. A\n * consumer driving `CartIcon`'s `bumpOnAdd` MUST gate the bump on this\n * flag (see `CartIcon`'s own guard) or every page load with a non-empty\n * cart bumps the badge once for free.\n */\n hydrated: boolean\n addItem: (item: CartItem) => AddItemOutcome\n removeItem: (productId: string, variantId?: string | null) => void\n updateQuantity: (\n productId: string,\n variantId: string | null | undefined,\n quantity: number,\n ) => void\n clearCart: () => void\n}\n\nfunction subscribeHydration(onStoreChange: () => void): () => void {\n return useCartStore.persist.onFinishHydration(onStoreChange)\n}\n\nfunction getHydratedSnapshot(): boolean {\n return useCartStore.persist.hasHydrated()\n}\n\n/** Server snapshot is always `false` — the server never reads `localStorage`. */\nfunction getServerHydratedSnapshot(): boolean {\n return false\n}\n\n/**\n * `useCartHydrated` — standalone so a SITE that only needs the flag (e.g. to\n * gate its own effects) doesn't have to subscribe to the whole cart via\n * `useCart()`.\n */\nexport function useCartHydrated(): boolean {\n return useSyncExternalStore(subscribeHydration, getHydratedSnapshot, getServerHydratedSnapshot)\n}\n\n/**\n * `useCart` — the ergonomic entry point for a SITE's cart UI: a single\n * selector call that re-renders only on `items` changes, with `total`/\n * `itemCount` derived inline (cheap — a plain `reduce` over a typically-small\n * array) rather than via `useCartStore`'s own `getTotal`/`getItemCount`\n * selectors, which are stable function references and would NOT trigger a\n * re-render on their own. A SITE that wants raw store access (existing\n * per-field selectors, e.g. `useCartStore((s) => s.addItem)`) can still\n * import `useCartStore` directly — this hook doesn't replace it.\n */\nexport function useCart(): UseCartResult {\n const items = useCartStore((s) => s.items)\n const addItem = useCartStore((s) => s.addItem)\n const removeItem = useCartStore((s) => s.removeItem)\n const updateQuantity = useCartStore((s) => s.updateQuantity)\n const clearCart = useCartStore((s) => s.clearCart)\n const hydrated = useCartHydrated()\n\n const total = items.reduce((sum, item) => sum + item.unitPrice * item.quantity, 0)\n const itemCount = items.reduce((sum, item) => sum + item.quantity, 0)\n\n return { items, total, itemCount, hydrated, addItem, removeItem, updateQuantity, clearCart }\n}\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../src/cart/provider.tsx","../src/cart/store.ts","../src/cart/use-cart.ts","../src/cart/validate.ts","../src/cart/use-cart-validation.ts"],"sourcesContent":["'use client'\n\nimport { useEffect } from 'react'\nimport { useCartStore } from './store'\n\n/**\n * `CartProvider` — mount this once near the root layout (inside a Client\n * Component boundary). It owns the ONE explicit-hydration call\n * `useCartStore.persist.rehydrate()` requires (`store.ts`'s `skipHydration`\n * doc) — a SITE that forgets to mount it gets an always-empty cart that never\n * reads back whatever a previous visit (or page reload) persisted.\n */\nexport function CartProvider({ children }: { children: React.ReactNode }) {\n useEffect(() => {\n useCartStore.persist.rehydrate()\n }, [])\n\n return <>{children}</>\n}\n","import { create } from 'zustand'\nimport { persist } from 'zustand/middleware'\n\n/**\n * `useCartStore` — the SDK's cart state (REB-829). Ported verbatim (same\n * shape, same quantity-clamping rules) from demo-site's local\n * `lib/store/cart-store.ts`, which every SITE was otherwise expected to\n * hand-copy. `CART_STORAGE_KEY` stays exactly `'shop-cart'` on purpose: a\n * SITE migrating from the local copy keeps its visitors' existing carts.\n *\n * `skipHydration: true` (zustand persist) is load-bearing for SSR: without\n * it, zustand's persist middleware reads `localStorage` synchronously during\n * module evaluation, which throws (or silently desyncs) on the server. The\n * `CartProvider` (`./provider`) calls `useCartStore.persist.rehydrate()`\n * inside a `useEffect`, so hydration only ever happens client-side, after\n * mount — the same explicit-hydration contract the SITE README documents.\n */\nexport const CART_STORAGE_KEY = 'shop-cart'\n\n/**\n * The zustand `persist` schema version for the cart's PERSISTED shape (the\n * `CartItem[]` written to `localStorage` under `CART_STORAGE_KEY`) — bump\n * this (with a `migrate` option) if that shape ever changes incompatibly.\n * Also folded into {@link CART_STORE_GLOBAL_KEY} (REB-837 review fix): a\n * future incompatible shape change should mint a NEW global singleton\n * rather than let a stale, already-evaluated old-shape instance (e.g. kept\n * alive across an HMR reload in dev) silently satisfy the new one.\n */\nexport const CART_STORE_PERSIST_VERSION = 0\n\nexport interface CartItem {\n productId: string\n variantId?: string | null\n name: string\n variantName?: string\n /** Integer cents — every Reblu shop price travels as cents, never a float. */\n unitPrice: number\n quantity: number\n imageUrl?: string\n /** `null` means untracked/unlimited stock — never clamp against it. */\n maxStock: number | null\n /** Basis points, e.g. `2100` = 21%. `null`/`undefined` = unknown or exempt. */\n taxRateBps?: number | null\n taxRateName?: string | null\n}\n\n/**\n * Outcome of `addItem` (REB-837): a SITE's add-to-cart feedback (toast,\n * `AddToCartButton`'s built-in confirmed/error state) needs to know what\n * actually happened, not just fire-and-forget — `'clamped'` only differs\n * from `'added'` in the RESULTING quantity reaching less than what was\n * requested (capped by `maxStock`) while the line still grew; `'refused'`\n * means nothing was added (invalid quantity, out of stock, or the line was\n * already at `maxStock` — REB-839).\n */\nexport type AddItemStatus = 'added' | 'clamped' | 'refused'\n\nexport interface AddItemOutcome {\n status: AddItemStatus\n /** The resulting line quantity after the call (`0` when refused and no line exists). */\n quantity: number\n}\n\nexport interface CartStore {\n items: CartItem[]\n addItem: (item: CartItem) => AddItemOutcome\n removeItem: (productId: string, variantId?: string | null) => void\n updateQuantity: (\n productId: string,\n variantId: string | null | undefined,\n quantity: number,\n ) => void\n clearCart: () => void\n getTotal: () => number\n getItemCount: () => number\n}\n\nfunction isSameItem(a: CartItem, productId: string, variantId?: string | null): boolean {\n return a.productId === productId && (a.variantId ?? null) === (variantId ?? null)\n}\n\n/**\n * REB-829 review finding: neither `addItem` nor `updateQuantity` validated\n * `quantity` — `0`, a negative number, `NaN` (e.g. a stray `parseInt('')`\n * upstream), a non-integer, or `maxStock` itself being `0` (out of stock)\n * all used to leave an invalid line sitting in the cart, silently reaching\n * checkout.\n *\n * Normalizes to a safe POSITIVE INTEGER clamped to `maxStock`. Returns `0`\n * to signal \"not a valid positive quantity\" — callers decide what that\n * means for them: `addItem` ignores the call (never creates/corrupts a\n * line), `updateQuantity` removes the line (same as its pre-existing\n * `quantity <= 0` → remove contract, just now reached by more inputs).\n *\n * `Math.trunc` rounds toward zero (not `Math.floor`, which would round a\n * negative input further negative before the `< 1` check below ever saw it\n * — not a behavioural difference here since any negative result is\n * rejected either way, but `trunc` is the operation that actually means\n * \"drop the fractional part\").\n */\nfunction normalizeQuantity(quantity: number, maxStock: number | null): number {\n if (!Number.isFinite(quantity)) return 0\n const truncated = Math.trunc(quantity)\n if (truncated < 1) return 0\n if (maxStock === null) return truncated\n const maxTruncated = Math.trunc(maxStock)\n if (maxTruncated < 1) return 0\n return Math.min(truncated, maxTruncated)\n}\n\n/**\n * REB-837 cross-entry singleton guard — the mutation that caught it: a\n * single test rendering `useShopOrderStatus` (the `./shop` tsup entry) and\n * asserting on `useCartStore` (the `./cart` entry) saw the order resolve to\n * `paid` but the cart stay non-empty. Root cause: `splitting: false` (R4,\n * `tsup.config.ts`) bundles each entry standalone, so a plain relative\n * import of this file from `./shop` INLINES a SEPARATE copy of this\n * `create(...)` call — a second, disconnected store instance with its own\n * state, not the one `@reblu/site-next/cart` consumers read. `clearCart()`\n * from inside the hook silently cleared the WRONG store.\n *\n * `globalThis` keyed by a `Symbol.for(...)` (same registry entry regardless\n * of which bundle's copy of this module evaluates it first) makes every\n * entry that imports this file — however many times esbuild inlines it —\n * resolve to the SAME store. Verified by mutation: removing this guard\n * (reverting to a bare `export const useCartStore = create(...)`) turns\n * `use-shop-order-status.test.ts`'s cart-clearing assertions red again once\n * the hook is exercised through its OWN entry rather than imported directly\n * within `cart`'s own test file (which never saw the bug, since it only\n * ever touches ONE copy of this module).\n */\nfunction createCartStore() {\n return create<CartStore>()(\n persist(\n (set, get) => ({\n items: [],\n\n addItem: (item) => {\n // REB-837: `set`'s updater can't return a value to the caller, so the\n // outcome is captured in this closure variable and returned after.\n let outcome: AddItemOutcome = { status: 'refused', quantity: 0 }\n\n set((state) => {\n const existing = state.items.find((i) => isSameItem(i, item.productId, item.variantId))\n\n if (existing) {\n // REB-829 review follow-up (a): incoming maxStock is the\n // FRESHEST stock signal available — if it says 0, the product is\n // confirmed out of stock right now, so the existing line is\n // removed outright (same contract as updateQuantity's own\n // \"quantity resolves to <1 → remove\"), not merely left alone.\n if (item.maxStock !== null && Math.trunc(item.maxStock) < 1) {\n outcome = { status: 'refused', quantity: 0 }\n return {\n items: state.items.filter((i) => !isSameItem(i, item.productId, item.variantId)),\n }\n }\n\n const requestedQty = existing.quantity + item.quantity\n const newQty = normalizeQuantity(requestedQty, item.maxStock)\n if (newQty < 1) {\n // Invalid delta (NaN/non-positive quantity) — leave the\n // existing, previously-valid line untouched rather than\n // destroying good data because of one bad call.\n outcome = { status: 'refused', quantity: existing.quantity }\n return {}\n }\n\n // REB-839: nothing was gained when stock caps the line at (or,\n // with fresher stock, below) what it already held — report that\n // as 'refused', never as a partial-success 'clamped'. The line is\n // still reconciled below to the fresher maxStock.\n outcome = {\n status:\n newQty <= existing.quantity\n ? 'refused'\n : newQty < requestedQty\n ? 'clamped'\n : 'added',\n quantity: newQty,\n }\n\n // Refresh maxStock with the incoming value on every successful\n // merge — the stale-maxStock finding: a stored line used to keep\n // its ORIGINAL maxStock forever, so a later updateQuantity call\n // clamped against stock data that could be long out of date.\n return {\n items: state.items.map((i) =>\n isSameItem(i, item.productId, item.variantId)\n ? { ...i, quantity: newQty, maxStock: item.maxStock }\n : i,\n ),\n }\n }\n\n const qty = normalizeQuantity(item.quantity, item.maxStock)\n if (qty < 1) {\n // Invalid quantity, or maxStock 0 (out of stock) — never create\n // a line nobody could actually buy.\n outcome = { status: 'refused', quantity: 0 }\n return {}\n }\n\n outcome = { status: qty < item.quantity ? 'clamped' : 'added', quantity: qty }\n return { items: [...state.items, { ...item, quantity: qty }] }\n })\n\n return outcome\n },\n\n removeItem: (productId, variantId) => {\n set((state) => ({\n items: state.items.filter((i) => !isSameItem(i, productId, variantId)),\n }))\n },\n\n updateQuantity: (productId, variantId, quantity) => {\n const existing = get().items.find((i) => isSameItem(i, productId, variantId))\n if (!existing) return\n\n const newQty = normalizeQuantity(quantity, existing.maxStock)\n if (newQty < 1) {\n get().removeItem(productId, variantId)\n return\n }\n\n set((state) => ({\n items: state.items.map((i) =>\n isSameItem(i, productId, variantId) ? { ...i, quantity: newQty } : i,\n ),\n }))\n },\n\n clearCart: () => set({ items: [] }),\n\n getTotal: () => get().items.reduce((sum, item) => sum + item.unitPrice * item.quantity, 0),\n\n getItemCount: () => get().items.reduce((sum, item) => sum + item.quantity, 0),\n }),\n {\n name: CART_STORAGE_KEY,\n version: CART_STORE_PERSIST_VERSION,\n skipHydration: true,\n },\n ),\n )\n}\n\ntype CartStoreApi = ReturnType<typeof createCartStore>\n\n/**\n * Versioned (REB-837 review fix) with {@link CART_STORE_PERSIST_VERSION} —\n * an incompatible shape change bumps that version, which mints a NEW\n * `Symbol.for(...)` registry key, so a stale old-shape singleton (e.g. kept\n * alive across an HMR reload in dev before a code change takes effect)\n * never satisfies the new one.\n */\nconst CART_STORE_GLOBAL_KEY = Symbol.for(\n `@reblu/site-next/cart-store@v${CART_STORE_PERSIST_VERSION}`,\n)\n\ninterface GlobalWithCartStore {\n [CART_STORE_GLOBAL_KEY]?: CartStoreApi\n}\n\nconst globalForCartStore = globalThis as typeof globalThis & GlobalWithCartStore\n\nexport const useCartStore: CartStoreApi =\n globalForCartStore[CART_STORE_GLOBAL_KEY] ??\n (globalForCartStore[CART_STORE_GLOBAL_KEY] = createCartStore())\n\n/**\n * TEST-ONLY (REB-837 review fix). Clears the `globalThis`-keyed slot so the\n * NEXT access to `useCartStore` — in THIS module instance — creates a fresh\n * store. The singleton is intentionally shared process-wide; most tests\n * should keep resetting STATE via `useCartStore.setState(...)` in\n * `beforeEach`, not the instance itself. Reach for this only to prove\n * something about the singleton mechanism (e.g. the cross-entry test in\n * `tests/cart-store-cross-entry-singleton.test.ts`) — it does not and\n * cannot affect an ALREADY-evaluated copy of this module inlined into a\n * different tsup entry's bundle (each such copy owns its own `const\n * globalForCartStore`/`CART_STORE_GLOBAL_KEY` closed over at that copy's\n * own module-evaluation time, same as any other ES module binding).\n */\nexport function __resetCartStoreForTests(): void {\n delete globalForCartStore[CART_STORE_GLOBAL_KEY]\n}\n","'use client'\n\nimport { useSyncExternalStore } from 'react'\n\nimport { useCartStore, type AddItemOutcome, type CartItem } from './store'\n\nexport interface UseCartResult {\n items: CartItem[]\n /** Sum of every line's `unitPrice * quantity`, in integer cents. */\n total: number\n /** Sum of every line's `quantity`. */\n itemCount: number\n /**\n * Whether `CartProvider`'s `persist.rehydrate()` has finished (REB-837).\n * `false` on the server and on the very first client render — `items`\n * (and therefore `itemCount`) jumps from `0` to whatever was persisted the\n * instant hydration finishes, which is NOT a real \"item added\" event. A\n * consumer driving `CartIcon`'s `bumpOnAdd` MUST gate the bump on this\n * flag (see `CartIcon`'s own guard) or every page load with a non-empty\n * cart bumps the badge once for free.\n */\n hydrated: boolean\n addItem: (item: CartItem) => AddItemOutcome\n removeItem: (productId: string, variantId?: string | null) => void\n updateQuantity: (\n productId: string,\n variantId: string | null | undefined,\n quantity: number,\n ) => void\n clearCart: () => void\n}\n\nfunction subscribeHydration(onStoreChange: () => void): () => void {\n return useCartStore.persist.onFinishHydration(onStoreChange)\n}\n\nfunction getHydratedSnapshot(): boolean {\n return useCartStore.persist.hasHydrated()\n}\n\n/** Server snapshot is always `false` — the server never reads `localStorage`. */\nfunction getServerHydratedSnapshot(): boolean {\n return false\n}\n\n/**\n * `useCartHydrated` — standalone so a SITE that only needs the flag (e.g. to\n * gate its own effects) doesn't have to subscribe to the whole cart via\n * `useCart()`.\n */\nexport function useCartHydrated(): boolean {\n return useSyncExternalStore(subscribeHydration, getHydratedSnapshot, getServerHydratedSnapshot)\n}\n\n/**\n * `useCart` — the ergonomic entry point for a SITE's cart UI: a single\n * selector call that re-renders only on `items` changes, with `total`/\n * `itemCount` derived inline (cheap — a plain `reduce` over a typically-small\n * array) rather than via `useCartStore`'s own `getTotal`/`getItemCount`\n * selectors, which are stable function references and would NOT trigger a\n * re-render on their own. A SITE that wants raw store access (existing\n * per-field selectors, e.g. `useCartStore((s) => s.addItem)`) can still\n * import `useCartStore` directly — this hook doesn't replace it.\n */\nexport function useCart(): UseCartResult {\n const items = useCartStore((s) => s.items)\n const addItem = useCartStore((s) => s.addItem)\n const removeItem = useCartStore((s) => s.removeItem)\n const updateQuantity = useCartStore((s) => s.updateQuantity)\n const clearCart = useCartStore((s) => s.clearCart)\n const hydrated = useCartHydrated()\n\n const total = items.reduce((sum, item) => sum + item.unitPrice * item.quantity, 0)\n const itemCount = items.reduce((sum, item) => sum + item.quantity, 0)\n\n return { items, total, itemCount, hydrated, addItem, removeItem, updateQuantity, clearCart }\n}\n","import type {\n ShopCartValidation,\n ShopCartValidationError,\n ShopValidateCartInput,\n} from '@reblu/site-contracts'\n\nimport { useCartStore, type CartItem } from './store'\n\n/**\n * Cart validation (REB-981). A persisted cart (`localStorage` `shop-cart`)\n * outlives the catalog it was filled from: a product can be deleted,\n * deactivated, sold out or repriced between visits. `validateCart` asks the\n * SITE's validate-cart Route Handler (`createShopValidateCartRoute`) about\n * every line and APPLIES the answer to the store, so the cart page and the\n * checkout never show or submit a line nobody can buy.\n */\n\n/** Calls validate-cart for the given lines. Must reject (or throw) on failure. */\nexport type CartValidator = (input: ShopValidateCartInput) => Promise<ShopCartValidation>\n\nexport interface ValidateCartOptions {\n /**\n * The SITE's validate-cart Route Handler path, used by the default\n * validator. Defaults to `'/api/shop/validate-cart'`.\n */\n endpoint?: string\n /** Replaces the default `fetch`-based validator entirely. */\n validate?: CartValidator\n /**\n * `true` (default) removes lines whose error says `suggestedAction:\n * 'remove'` (`not_found`, `inactive`, `out_of_stock`). `false` leaves them\n * in the cart and only reports them through `lineErrors`.\n */\n removeUnavailable?: boolean\n}\n\n/** A line the validation removed — `name`/`variantName` come from the persisted line. */\nexport interface CartRemovedLine {\n productId: string\n variantId: string | null\n name: string\n variantName?: string\n type: ShopCartValidationError['type']\n message: string\n}\n\n/** A line whose quantity was clamped to the stock still available. */\nexport interface CartQuantityChange {\n productId: string\n variantId: string | null\n name: string\n variantName?: string\n from: number\n to: number\n}\n\n/** A line whose `unitPrice` (integer cents) was refreshed from the live price. */\nexport interface CartPriceChange {\n productId: string\n variantId: string | null\n name: string\n variantName?: string\n from: number\n to: number\n}\n\n/**\n * - `valid` — every line is buyable as it stands; nothing changed.\n * - `adjusted` — the cart was changed (lines removed, quantities clamped or\n * prices refreshed) and every error was resolved by those changes. A\n * `price_changed` error counts as resolved once its line's new price is\n * applied (it is then reported in `priceChanges`, not `lineErrors`). Show\n * the changes before letting the visitor pay.\n * - `invalid` — problems remain that were not applied (lines flagged with\n * `removeUnavailable: false`, a cart-wide error, a `price_changed` with no\n * new price to apply, an `update_quantity` that would not lower the line),\n * or the server answered `valid: false` and nothing was applied. Never\n * `valid` when the server said `valid: false`.\n * - `empty` — no lines; nothing was sent.\n * - `failed` — the request failed; the cart was left exactly as it was.\n */\nexport type CartValidationStatus = 'valid' | 'adjusted' | 'invalid' | 'empty' | 'failed'\n\nexport interface CartValidationOutcome {\n status: CartValidationStatus\n removed: CartRemovedLine[]\n quantityChanges: CartQuantityChange[]\n priceChanges: CartPriceChange[]\n /**\n * Every line error of the response, keyed by {@link cartLineKey}, except a\n * `price_changed` resolved by the price refresh (REB-998).\n */\n lineErrors: Record<string, ShopCartValidationError>\n /** Errors that match no cart line (e.g. `empty_cart`) or were not applied. */\n unresolvedErrors: ShopCartValidationError[]\n /** Set when `status` is `'failed'`. */\n error?: unknown\n}\n\nexport const DEFAULT_VALIDATE_CART_ENDPOINT = '/api/shop/validate-cart'\n\n/** Stable key for a cart line: `productId` + `variantId` (absent and `null` are the same line). */\nexport function cartLineKey(productId: string, variantId?: string | null): string {\n return JSON.stringify([productId, variantId ?? null])\n}\n\nfunction emptyOutcome(status: CartValidationStatus): CartValidationOutcome {\n return {\n status,\n removed: [],\n quantityChanges: [],\n priceChanges: [],\n lineErrors: {},\n unresolvedErrors: [],\n }\n}\n\nfunction isValidation(value: unknown): value is ShopCartValidation {\n if (!value || typeof value !== 'object') return false\n const v = value as Record<string, unknown>\n return typeof v.valid === 'boolean' && Array.isArray(v.errors) && Array.isArray(v.items)\n}\n\nfunction defaultValidator(endpoint: string): CartValidator {\n return async (input) => {\n const res = await fetch(endpoint, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify(input),\n })\n if (!res.ok) {\n throw new Error(`validate-cart failed with HTTP ${res.status}`)\n }\n const json: unknown = await res.json()\n const data = json && typeof json === 'object' ? (json as { data?: unknown }).data : undefined\n if (!isValidation(data)) {\n throw new Error('validate-cart returned an unexpected body')\n }\n return data\n }\n}\n\nfunction waitForHydration(): Promise<void> {\n if (useCartStore.persist.hasHydrated()) return Promise.resolve()\n return new Promise((resolve) => {\n const unsubscribe = useCartStore.persist.onFinishHydration(() => {\n unsubscribe()\n resolve()\n })\n })\n}\n\n/**\n * Pure: applies a validate-cart response to a list of lines. Lines the\n * response does not mention (e.g. added while the request was in flight)\n * are left untouched.\n */\nexport function applyCartValidation(\n items: CartItem[],\n validation: ShopCartValidation,\n { removeUnavailable = true }: Pick<ValidateCartOptions, 'removeUnavailable'> = {},\n): Omit<CartValidationOutcome, 'status' | 'error'> & { items: CartItem[] } {\n const removed: CartRemovedLine[] = []\n const quantityChanges: CartQuantityChange[] = []\n const priceChanges: CartPriceChange[] = []\n const lineErrors: Record<string, ShopCartValidationError> = {}\n const unresolvedErrors: ShopCartValidationError[] = []\n\n const byKey = new Map(items.map((item) => [cartLineKey(item.productId, item.variantId), item]))\n const removeKeys = new Set<string>()\n const newQuantity = new Map<string, { to: number; error: ShopCartValidationError }>()\n // REB-998: `price_changed` lines are resolved by the price refresh below.\n const priceErrors = new Map<string, ShopCartValidationError>()\n\n for (const error of validation.errors) {\n const key = cartLineKey(error.productId, error.variantId)\n const line = byKey.get(key)\n if (!line) {\n unresolvedErrors.push(error)\n continue\n }\n lineErrors[key] = error\n if (error.suggestedAction === 'remove' && removeUnavailable) {\n removeKeys.add(key)\n removed.push({\n productId: line.productId,\n variantId: line.variantId ?? null,\n name: line.name,\n ...(line.variantName !== undefined && { variantName: line.variantName }),\n type: error.type,\n message: error.message,\n })\n } else if (error.type === 'price_changed') {\n priceErrors.set(key, error)\n } else if (error.suggestedAction === 'update_quantity' && typeof error.newQuantity === 'number') {\n newQuantity.set(key, { to: error.newQuantity, error })\n } else {\n // Flagged but not applied (`removeUnavailable: false`, or an action\n // this version does not know) — the line stays and still blocks.\n unresolvedErrors.push(error)\n }\n }\n\n const live = new Map(\n validation.items.map((item) => [cartLineKey(item.productId, item.variantId), item]),\n )\n\n const next: CartItem[] = []\n for (const line of items) {\n const key = cartLineKey(line.productId, line.variantId)\n if (removeKeys.has(key)) continue\n\n let updated = line\n let priceRefreshed = false\n const liveItem = live.get(key)\n if (liveItem) {\n if (liveItem.unitPrice !== line.unitPrice) {\n priceRefreshed = true\n priceChanges.push({\n productId: line.productId,\n variantId: line.variantId ?? null,\n name: line.name,\n ...(line.variantName !== undefined && { variantName: line.variantName }),\n from: line.unitPrice,\n to: liveItem.unitPrice,\n })\n updated = { ...updated, unitPrice: liveItem.unitPrice }\n }\n if (liveItem.maxStock !== line.maxStock) {\n updated = { ...updated, maxStock: liveItem.maxStock }\n }\n }\n\n const priceError = priceErrors.get(key)\n if (priceError) {\n if (priceRefreshed) {\n // REB-998: the new price is applied and reported in `priceChanges`,\n // so the error is resolved — like a removal or a clamp.\n if (lineErrors[key] === priceError) delete lineErrors[key]\n } else {\n // Nothing to refresh from (no live line, or the same price): the\n // server's complaint was not addressed, so it still blocks.\n unresolvedErrors.push(priceError)\n }\n }\n\n const clamp = newQuantity.get(key)\n if (clamp !== undefined) {\n const to = Math.trunc(clamp.to)\n if (to < 1) {\n // No stock left at all — treat as unavailable.\n removed.push({\n productId: line.productId,\n variantId: line.variantId ?? null,\n name: line.name,\n ...(line.variantName !== undefined && { variantName: line.variantName }),\n type: 'out_of_stock',\n message: lineErrors[key]?.message ?? '',\n })\n continue\n }\n if (to < line.quantity) {\n quantityChanges.push({\n productId: line.productId,\n variantId: line.variantId ?? null,\n name: line.name,\n ...(line.variantName !== undefined && { variantName: line.variantName }),\n from: line.quantity,\n to,\n })\n updated = { ...updated, quantity: to }\n } else {\n // REB-998: a \"new\" quantity that does not lower the line changes\n // nothing, so the error was not applied and still blocks.\n unresolvedErrors.push(clamp.error)\n }\n }\n\n next.push(updated)\n }\n\n return { items: next, removed, quantityChanges, priceChanges, lineErrors, unresolvedErrors }\n}\n\n/**\n * Validates the persisted cart against live product, stock and price data\n * and applies the result to `useCartStore`:\n *\n * 1. removes lines the API says to remove, reporting their persisted names;\n * 2. clamps `insufficient_stock` lines to the quantity still available;\n * 3. refreshes `unitPrice` (and `maxStock`) from the live data.\n *\n * Waits for `CartProvider`'s hydration first, so it never validates the\n * empty pre-hydration cart. Never throws: a failed request resolves to\n * `status: 'failed'` and leaves the cart untouched. Running it again on an\n * already-clean cart is a no-op (`status: 'valid'`).\n */\nexport async function validateCart(options: ValidateCartOptions = {}): Promise<CartValidationOutcome> {\n await waitForHydration()\n\n const items = useCartStore.getState().items\n if (items.length === 0) return emptyOutcome('empty')\n\n const validate = options.validate ?? defaultValidator(options.endpoint ?? DEFAULT_VALIDATE_CART_ENDPOINT)\n\n let validation: ShopCartValidation\n try {\n validation = await validate({\n items: items.map((i) => ({\n productId: i.productId,\n variantId: i.variantId,\n quantity: i.quantity,\n })),\n })\n } catch (error) {\n return { ...emptyOutcome('failed'), error }\n }\n\n // Apply against the CURRENT lines, not the snapshot sent: the visitor may\n // have changed the cart while the request was in flight.\n const applied = applyCartValidation(useCartStore.getState().items, validation, options)\n const { items: nextItems, ...report } = applied\n const changed =\n report.removed.length > 0 || report.quantityChanges.length > 0 || report.priceChanges.length > 0\n const current = useCartStore.getState().items\n // Only write when a line object actually changed (a refreshed `maxStock`\n // alone counts) — an unchanged cart never re-renders its subscribers.\n if (nextItems.length !== current.length || nextItems.some((item, i) => item !== current[i])) {\n useCartStore.setState({ items: nextItems })\n }\n\n // REB-998: `valid` only when the server said so — a `valid: false` with\n // nothing applied (e.g. no errors at all) is never upgraded to `valid`.\n const status: CartValidationStatus =\n report.unresolvedErrors.length > 0\n ? 'invalid'\n : changed\n ? 'adjusted'\n : validation.valid\n ? 'valid'\n : 'invalid'\n\n return { status, ...report }\n}\n","'use client'\n\nimport { useCallback, useEffect, useRef, useState } from 'react'\nimport type { ShopCartValidationError } from '@reblu/site-contracts'\n\nimport { useCartHydrated } from './use-cart'\nimport {\n cartLineKey,\n validateCart,\n type CartValidationOutcome,\n type CartValidationStatus,\n type ValidateCartOptions,\n} from './validate'\n\nexport interface UseCartValidationOptions extends ValidateCartOptions {\n /** Validate once automatically when the cart has hydrated. Defaults to `true`. */\n auto?: boolean\n}\n\nexport interface UseCartValidationResult extends Omit<CartValidationOutcome, 'status'> {\n /** `'idle'` before the first run, `'validating'` while a request is in flight. */\n status: CartValidationStatus | 'idle' | 'validating'\n /** The error the last run reported for this line, if any. */\n getLineError: (productId: string, variantId?: string | null) => ShopCartValidationError | undefined\n /**\n * Runs the validation now (e.g. right before submitting the checkout) and\n * resolves to its outcome. Concurrent calls share the in-flight request.\n */\n revalidate: () => Promise<CartValidationOutcome>\n}\n\nconst IDLE: Omit<CartValidationOutcome, 'status'> = {\n removed: [],\n quantityChanges: [],\n priceChanges: [],\n lineErrors: {},\n unresolvedErrors: [],\n}\n\n/**\n * `useCartValidation` (REB-981) — runs {@link validateCart} once when the\n * cart has hydrated (cart page mount) and exposes the last outcome, so the\n * page can show what was removed or adjusted and mark the offending lines.\n * Call `revalidate()` before checkout. Later cart edits never re-trigger the\n * automatic run, so it cannot loop.\n */\nexport function useCartValidation(options: UseCartValidationOptions = {}): UseCartValidationResult {\n const { auto = true } = options\n const hydrated = useCartHydrated()\n const [status, setStatus] = useState<UseCartValidationResult['status']>('idle')\n const [report, setReport] = useState<Omit<CartValidationOutcome, 'status'>>(IDLE)\n\n // Latest options without re-creating `revalidate` (synced after render,\n // never written during it).\n const optionsRef = useRef(options)\n useEffect(() => {\n optionsRef.current = options\n })\n const inFlight = useRef<Promise<CartValidationOutcome> | null>(null)\n const autoRan = useRef(false)\n\n const revalidate = useCallback((): Promise<CartValidationOutcome> => {\n if (inFlight.current) return inFlight.current\n setStatus('validating')\n const run = validateCart(optionsRef.current).then((outcome) => {\n inFlight.current = null\n const { status: nextStatus, ...rest } = outcome\n setReport(rest)\n setStatus(nextStatus)\n return outcome\n })\n inFlight.current = run\n return run\n }, [])\n\n useEffect(() => {\n if (!auto || !hydrated || autoRan.current) return\n autoRan.current = true\n void revalidate()\n }, [auto, hydrated, revalidate])\n\n const getLineError = useCallback(\n (productId: string, variantId?: string | null) =>\n report.lineErrors[cartLineKey(productId, variantId)],\n [report],\n )\n\n return { status, ...report, getLineError, revalidate }\n}\n"],"mappings":"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","names":["useEffect","useEffect"]}
|
|
@@ -22,7 +22,8 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
|
|
|
22
22
|
var recaptcha_provider_exports = {};
|
|
23
23
|
__export(recaptcha_provider_exports, {
|
|
24
24
|
DeferredRecaptchaProvider: () => DeferredRecaptchaProvider,
|
|
25
|
-
useDeferredRecaptcha: () => useDeferredRecaptcha
|
|
25
|
+
useDeferredRecaptcha: () => useDeferredRecaptcha,
|
|
26
|
+
useDeferredRecaptchaToken: () => useDeferredRecaptchaToken
|
|
26
27
|
});
|
|
27
28
|
module.exports = __toCommonJS(recaptcha_provider_exports);
|
|
28
29
|
var import_react = require("react");
|
|
@@ -78,9 +79,32 @@ function DeferredRecaptchaProvider({
|
|
|
78
79
|
function useDeferredRecaptcha() {
|
|
79
80
|
return (0, import_react.useContext)(DeferredRecaptchaContext);
|
|
80
81
|
}
|
|
82
|
+
function useDeferredRecaptchaToken(action, { onArm, timeoutMs = 5e3, intervalMs = 50 }) {
|
|
83
|
+
const { executeRecaptcha } = useDeferredRecaptcha();
|
|
84
|
+
const executeRef = (0, import_react.useRef)(executeRecaptcha);
|
|
85
|
+
(0, import_react.useEffect)(() => {
|
|
86
|
+
executeRef.current = executeRecaptcha;
|
|
87
|
+
}, [executeRecaptcha]);
|
|
88
|
+
return (0, import_react.useCallback)(async () => {
|
|
89
|
+
if (!executeRef.current) {
|
|
90
|
+
onArm();
|
|
91
|
+
const deadline = Date.now() + timeoutMs;
|
|
92
|
+
while (!executeRef.current) {
|
|
93
|
+
if (Date.now() >= deadline) return void 0;
|
|
94
|
+
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
try {
|
|
98
|
+
return await executeRef.current(action);
|
|
99
|
+
} catch {
|
|
100
|
+
return void 0;
|
|
101
|
+
}
|
|
102
|
+
}, [action, onArm, timeoutMs, intervalMs]);
|
|
103
|
+
}
|
|
81
104
|
// Annotate the CommonJS export names for ESM import in node:
|
|
82
105
|
0 && (module.exports = {
|
|
83
106
|
DeferredRecaptchaProvider,
|
|
84
|
-
useDeferredRecaptcha
|
|
107
|
+
useDeferredRecaptcha,
|
|
108
|
+
useDeferredRecaptchaToken
|
|
85
109
|
});
|
|
86
110
|
//# sourceMappingURL=recaptcha-provider.cjs.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/recaptcha-provider.tsx","../src/recaptcha.ts"],"sourcesContent":["'use client'\n\n/**\n * `@reblu/site-next/recaptcha-provider` — the FIX for REB-793.\n *\n * ─────────────────────────────────────────────────────────────────────────────\n * THE BUG THIS EXISTS TO CLOSE\n * ─────────────────────────────────────────────────────────────────────────────\n *\n * `deferredRecaptchaKey` (`./recaptcha`, REB-588) returns `''` until `armed`,\n * on the documented claim that `<GoogleReCaptchaProvider reCaptchaKey={...}>`\n * swaps the key in later without remounting. That claim is only half true.\n * Verified directly in `react-google-recaptcha-v3@1.11.0`'s own dist\n * (`dist/react-google-recaptcha-v3.esm.js`):\n *\n * var x = useRef(reCaptchaKey) // captured ONCE, on first mount\n * …\n * window[onLoadCallbackName] = () => { x.current = grecaptcha.render(…) }\n * …\n * executeRecaptcha = () => grecaptcha.execute(x.current, { action })\n *\n * `x.current` is reassigned ONLY inside the `onLoadCallbackName` global the\n * library wires into the script URL's `&onload=` query param — and that param\n * is only added when `render === 'explicit'`, i.e. when a visible widget\n * `container` is configured. Invisible v3 (no `container`, the ONLY mode this\n * SDK or any Reblu SITE uses) never sets that param, so `x.current` never\n * updates: every `executeRecaptcha()` call keeps sending the EMPTY first-mount\n * key to `grecaptcha.execute('', { action })`, which throws, which the SITE's\n * submit handler swallows into \"reCAPTCHA no está disponible\" — no token,\n * every submission dead on arrival. Found 29-Sep in the first real\n * contact-form test on dermamedicin preprod: zero requests ever reached\n * `/api/site/contact-form`.\n *\n * ─────────────────────────────────────────────────────────────────────────────\n * THE FIX — REMOUNT THE VENDOR, NOT THE SITE'S FORM\n * ─────────────────────────────────────────────────────────────────────────────\n *\n * `useRef`'s initial value is captured on FIRST MOUNT and nothing short of\n * unmounting-and-remounting the element changes that — the vendor gives us no\n * other hook. So the element that eventually loads Google's script must never\n * have been mounted with any key other than the real one: `key={reCaptchaKey}`\n * on `<GoogleReCaptchaProvider>` does exactly that. React treats a `key`\n * change as a different element identity and discards the old subtree instead\n * of re-rendering it, so arming does not swap a prop on a live instance — it\n * unmounts the empty-key instance (which never loaded anything, so there is\n * nothing to tear down) and mounts a BRAND NEW one whose `useRef` captures the\n * real key from its very first render.\n *\n * A first version of this fix put the SITE's own `children` directly inside\n * that keyed element. It closes the ref bug, but it also means the SITE's\n * form — every typed field, and any in-flight submit closure reading\n * `executeRecaptcha` — gets unmounted and remounted at the exact moment arming\n * happens. For the common case (arm on first focus, before any text exists)\n * that only costs a moment of focus. For demo-site's actual \"visitor tabs\n * straight to submit\" fallback (`waitForRecaptchaReady` in\n * `contact-form.section.tsx`) it is fatal: arming happens FROM INSIDE the\n * form's own submit handler, so remounting the form destroys the very\n * component instance that is awaiting the result — the typed values vanish\n * and the poll never resolves, reproduced by\n * `contact-form.section.recaptcha-submit.test.tsx` going red under that\n * design.\n *\n * So the vendor `<GoogleReCaptchaProvider>` here wraps ONLY an invisible\n * bridge component, never the SITE's `children`. The bridge reads the\n * vendor's `useGoogleReCaptcha()` — correct by construction, because it lives\n * inside the freshly, correctly keyed instance — and republishes\n * `executeRecaptcha` into `DeferredRecaptchaContext`, a context on a STABLE\n * element that is never keyed and never remounts. `children` renders as a\n * SIBLING of the vendor provider, under that stable context, and consumes the\n * function via `useDeferredRecaptcha()` (this module's own replacement for\n * the vendor's `useGoogleReCaptcha()`). Arming remounts the invisible bridge;\n * it never touches the SITE's form.\n *\n * ─────────────────────────────────────────────────────────────────────────────\n * WHY THIS IS A SEPARATE SUBPATH, NOT PART OF `./recaptcha`\n * ─────────────────────────────────────────────────────────────────────────────\n *\n * `./recaptcha` is guarded by `tests/recaptcha-subpath-isolation.test.ts` to\n * import NOTHING — not React, not `react-google-recaptcha-v3` — so a Server\n * Component can call `resolveContactFormAvailability` without paying for\n * Google's loader library. This component unavoidably imports both. Same R4\n * rule as `auth`/`academy`/`consent`: a `'use client'` directive is only\n * preserved by esbuild when the ENTRY FILE itself declares it, never when\n * merely re-exported — so this file is tsup's OWN entry (`recaptcha-provider`\n * in `../tsup.config.ts`), not folded into `./recaptcha` or the root barrel.\n *\n * `react-google-recaptcha-v3` stays the consuming SITE's OWN dependency\n * (an optional `peerDependencies` entry here, exactly as `./recaptcha`'s\n * README section already promises) — this package never bundles it.\n */\nimport {\n createContext,\n useCallback,\n useContext,\n useEffect,\n useMemo,\n useState,\n type ComponentProps,\n type ReactNode,\n} from 'react'\nimport { GoogleReCaptchaProvider, useGoogleReCaptcha } from 'react-google-recaptcha-v3'\n\nimport { deferredRecaptchaKey } from './recaptcha'\n\n/** The one thing a SITE needs, in the same shape the vendor's own hook returns it. */\nexport interface DeferredRecaptchaContextValue {\n /** `undefined` until Google's script has loaded under the ARMED key. */\n executeRecaptcha: ((action?: string) => Promise<string>) | undefined\n}\n\nconst UNARMED: DeferredRecaptchaContextValue = { executeRecaptcha: undefined }\n\nconst DeferredRecaptchaContext = createContext<DeferredRecaptchaContextValue>(UNARMED)\n\n/**\n * Lives INSIDE the keyed vendor provider — the only place the vendor's own\n * `executeRecaptcha` is ever correct — and republishes it to the stable\n * context above. Renders nothing; it is pure plumbing.\n */\nfunction ExecuteBridge({\n onChange,\n}: {\n onChange: (fn: DeferredRecaptchaContextValue['executeRecaptcha']) => void\n}) {\n const { executeRecaptcha } = useGoogleReCaptcha()\n useEffect(() => {\n onChange(executeRecaptcha)\n }, [executeRecaptcha, onChange])\n return null\n}\n\ntype ProviderPassthroughProps = Omit<\n ComponentProps<typeof GoogleReCaptchaProvider>,\n 'reCaptchaKey' | 'children'\n>\n\nexport interface DeferredRecaptchaProviderProps extends ProviderPassthroughProps {\n /** The tenant's public reCAPTCHA site key — `resolveContactFormAvailability`'s `recaptchaSiteKey`. */\n siteKey: string\n /** Whether the visitor has interacted with the form yet (REB-588 arming signal). */\n armed: boolean\n children: ReactNode\n}\n\n/**\n * Drop-in replacement for wiring `deferredRecaptchaKey` directly into\n * `<GoogleReCaptchaProvider reCaptchaKey={...}>` — see the module docs above\n * for why that direct wiring never mints a real token. Pair with\n * `useDeferredRecaptcha()` instead of the vendor's `useGoogleReCaptcha()`.\n *\n * ```tsx\n * <DeferredRecaptchaProvider siteKey={siteKey} armed={armed}>\n * <form onFocusCapture={arm} onPointerDown={arm}>…</form>\n * </DeferredRecaptchaProvider>\n * ```\n */\nexport function DeferredRecaptchaProvider({\n siteKey,\n armed,\n children,\n ...providerProps\n}: DeferredRecaptchaProviderProps) {\n const reCaptchaKey = deferredRecaptchaKey(siteKey, armed)\n const [executeRecaptcha, setExecuteRecaptcha] =\n useState<DeferredRecaptchaContextValue['executeRecaptcha']>(undefined)\n\n // No separate \"reset to undefined on key change\" effect is needed here,\n // and deliberately so: `reCaptchaKey` changing remounts `ExecuteBridge`\n // (it lives inside the keyed `GoogleReCaptchaProvider`), and a FRESH bridge\n // instance's very first effect run already reports the fresh instance's\n // own `executeRecaptcha` — `undefined` until that instance's own script has\n // loaded, never a value carried over from the instance that just unmounted.\n // An extra reset effect on THIS component was tried and reverted: as the\n // parent, its effects fire AFTER its children's on the same commit, so it\n // clobbered the bridge's own report back to `undefined` on every mount —\n // caught by `contact-form.section.test.tsx`, whose mocked\n // `useGoogleReCaptcha()` reports a working function synchronously.\n const handleChange = useCallback(\n (fn: DeferredRecaptchaContextValue['executeRecaptcha']) => setExecuteRecaptcha(() => fn),\n [],\n )\n\n // Memoized so every consumer of `useDeferredRecaptcha()` re-renders only\n // when `executeRecaptcha` itself actually changes, not on every render of\n // this provider (e.g. from an unrelated `providerProps` change).\n const contextValue = useMemo(() => ({ executeRecaptcha }), [executeRecaptcha])\n\n return (\n <DeferredRecaptchaContext.Provider value={contextValue}>\n <GoogleReCaptchaProvider key={reCaptchaKey} reCaptchaKey={reCaptchaKey} {...providerProps}>\n <ExecuteBridge onChange={handleChange} />\n </GoogleReCaptchaProvider>\n {children}\n </DeferredRecaptchaContext.Provider>\n )\n}\n\n/**\n * The SITE's replacement for `react-google-recaptcha-v3`'s own\n * `useGoogleReCaptcha()` when the form is wrapped in\n * `<DeferredRecaptchaProvider>`. Same shape (`{ executeRecaptcha }`), but\n * reads the STABLE context republished by `ExecuteBridge`, which never\n * remounts on arm — unlike the vendor's own context, which lives on the\n * element `DeferredRecaptchaProvider` deliberately keys and remounts.\n */\nexport function useDeferredRecaptcha(): DeferredRecaptchaContextValue {\n return useContext(DeferredRecaptchaContext)\n}\n","/**\n * reCAPTCHA v3 — the SITE's half of the contract (REB-588).\n *\n * ─────────────────────────────────────────────────────────────────────────────\n * WHICH HALF LIVES WHERE, AND WHY\n * ─────────────────────────────────────────────────────────────────────────────\n *\n * reCAPTCHA is two secrets and two steps, and they are deliberately split across\n * a trust boundary:\n *\n * SITE KEY — public by design. It ships to the browser inside the script URL,\n * so the tenant serves it from `/api/site/analytics`. This module\n * handles it.\n * SECRET — never leaves base-api. It is stored per tenant in\n * `site_integration_config.recaptchaSecretKey` and read only by\n * `serverCaptchaVerify` when `POST /api/site/contact-form` runs.\n *\n * **Nothing in this module, in this package, or anywhere in the `@reblu/site-*`\n * SDK ever sees the secret, and nothing here calls Google's `siteverify`.** A\n * SITE holding the secret would be a SITE that could be tricked into approving\n * its own tokens, and a distributable SDK is the worst possible place to put a\n * credential. Verification is base-api's job and already works: the SITE mints a\n * token, posts it as `ContactFormInput.captcha`, and base-api decides.\n *\n * That is also why this module is NOT \"reCAPTCHA support\" in the round. It is\n * the three decisions every SITE was otherwise making alone and differently:\n * which action to name, whether a form may be shown at all, and when Google's\n * script is allowed to load.\n *\n * ─────────────────────────────────────────────────────────────────────────────\n * WHY IT IS IN THE SDK AT ALL\n * ─────────────────────────────────────────────────────────────────────────────\n *\n * Because it was already being written four times. `apps/base-api`,\n * `apps/demo-site` and the external `reblu-site-dermamedicin` each grew their\n * own copy of this flow, and they had already drifted — different action\n * strings, different mount timing, a retry loop in one of them. A score that\n * reCAPTCHA reports per action is not comparable across SITEs that name the\n * action differently, and a defect fixed in one copy stays live in the other\n * two. The SDK is where a decision gets made once.\n *\n * This module has ZERO runtime dependencies and imports nothing — not React, not\n * `next`, not `react-google-recaptcha-v3`, not `@reblu/site-ui`. It is pure\n * functions over plain data, callable from a Server Component deciding whether\n * to render a form and from the Client Component that renders it. The subpath is\n * kept dependency-free by `tests/recaptcha-subpath-isolation.test.ts`.\n */\n\nimport type { Analytics, ContactFormMode } from '@reblu/site-contracts'\n\n/**\n * The action name every Reblu SITE mints its contact-form token under.\n *\n * reCAPTCHA v3 scores PER ACTION, and a score is only interpretable against a\n * stable one: Google's own admin console charts each action separately, and a\n * threshold tuned on one action says nothing about another. Two SITEs calling\n * `executeRecaptcha('submit_form')` and `executeRecaptcha('contact_form')` are\n * building two unrelated score histories for the same feature — which is exactly\n * the state the repo was in before this constant existed.\n *\n * `contact_form` over `submit_form` because it names the form, not the verb;\n * a second protected form would need its own action, and `submit_form` would\n * have nothing left to call it.\n *\n * ── WHAT THIS CONSTANT DOES **NOT** BUY, STATED PLAINLY ──\n *\n * It buys comparable scores. It does NOT bind a token to this form, because\n * `serverCaptchaVerify` in base-api checks only `success` and `score`, and never\n * reads the `action` field Google returns. A token minted for any other action\n * on the same site verifies here exactly as well. Making the action load-bearing\n * requires base-api to assert `action === RECAPTCHA_ACTION` on verify; until it\n * does, do not read this constant as a security control.\n */\nexport const RECAPTCHA_ACTION = 'contact_form'\n\n/**\n * The shape a site key must have before this SDK will hand it to Google.\n *\n * ── WHAT THIS IS, AND EMPHATICALLY WHAT IT IS NOT ──\n *\n * It is a SINK-SAFETY guard, not a guess at Google's key format. The key is\n * interpolated into the loader's script URL as a query parameter, so what\n * matters here is that it cannot carry a space, a quote, a newline, an `&`, or a\n * `#` — characters that make a URL mean something other than what was intended.\n * The base64url alphabet Google actually issues from (`A-Z a-z 0-9 - _`) is a\n * strict subset of \"safe\", so a real key passes by construction.\n *\n * The length bounds are deliberately far wider than any key Google issues\n * (theirs are 40 characters). A bound tight enough to be a real format check\n * would be a bound tight enough to take a WORKING tenant's contact form offline\n * the day Google changes its key length — which is strictly worse than the\n * malformed key it was meant to catch. If you are tempted to tighten this,\n * the thing to tighten is the nexus write path, where an owner can still see\n * and fix the error, not this read path where they cannot.\n *\n * ── WHY THE SDK RE-VALIDATES AT ALL ──\n *\n * Same reason `@reblu/site-next/analytics` re-checks `gaId` and `clarityId`\n * (REB-617): base-api is not the only thing a SITE ever talks to, and the layer\n * that owns the value is not the layer that suffers from it being wrong. As of\n * REB-588 this column has NO validation anywhere — not in the nexus form, not on\n * the way out of `/api/site/analytics`, not in the response contract, which is\n * the one asymmetry `gaId`/`clarityId` do not share. Until that is fixed\n * upstream, this is the only guard between a mistyped key and a contact form\n * that renders, looks live, and silently eats every enquiry.\n */\nexport const RECAPTCHA_SITE_KEY_PATTERN = /^[A-Za-z0-9_-]{16,128}$/\n\n/**\n * Whether `value` is a site key safe to put in the loader's script URL.\n *\n * Takes `unknown` on purpose: the value arrives over HTTP, so the contract's\n * `string | null` is a promise about the shape, not a guarantee about the bytes.\n */\nexport function isSafeRecaptchaSiteKey(value: unknown): value is string {\n return typeof value === 'string' && RECAPTCHA_SITE_KEY_PATTERN.test(value)\n}\n\n/**\n * The modes in which base-api will actually accept a submission.\n *\n * An ALLOW-LIST, never `mode !== 'DISABLED'`. The three differ only in what\n * base-api DOES with the payload — a row, a webhook, an email — and the SITE\n * must not care and must never learn the destination: a webhook URL or a Resend\n * key is a tenant secret with no business in a public site.\n *\n * The deny-list version of this is a real incident, not a hypothetical: a page\n * that hid the form on `DISABLED` and rendered it otherwise rendered it for\n * `NOT_CONFIGURED` too — which is what EVERY freshly provisioned tenant carries\n * until someone finishes the wizard (REB-586). A visitor typed out a medical\n * enquiry, pressed send, and got `503 Contact form is disabled`. They learned\n * the clinic could not be reached only after writing to it.\n */\nexport const CONTACT_FORM_OPERATIONAL_MODES = ['DB_ONLY', 'WEBHOOK', 'RESEND'] as const\n\n/** A mode in which the form may be shown. */\nexport type ContactFormOperationalMode = (typeof CONTACT_FORM_OPERATIONAL_MODES)[number]\n\n/**\n * The modes deliberately declined. A TYPE, never a value: the runtime must never\n * read it, because an allow-list must not be expressible as \"everything except\n * this list\", and this subpath's defining property is that it ships nothing it\n * does not need. It exists only to feed {@link ContactFormModeExhaustiveCheck}.\n */\ntype DeclinedMode = 'NOT_CONFIGURED' | 'DISABLED'\n\n/**\n * Compile-time exhaustiveness guard over `ContactFormMode`.\n *\n * `Missing` is constrained to `never` and DEFAULTS to the contract modes that\n * are neither accepted nor explicitly declined, so a default violating its own\n * constraint breaks the build the moment `@reblu/site-contracts` gains a mode.\n *\n * At runtime a new mode already fails closed, which is correct but SILENT — and\n * silence is how a tenant switches their form on in nexus and watches nothing\n * appear. This forces a human to make the call in a diff instead.\n *\n * Exported so it is never reported as an unused symbol.\n */\nexport type ContactFormModeExhaustiveCheck<\n Missing extends never = Exclude<ContactFormMode, ContactFormOperationalMode | DeclinedMode>,\n> = Missing\n\n/** Why the form is not being shown. Drives diagnostics, never visitor-facing copy. */\nexport type ContactFormUnavailableReason =\n /** The analytics read failed or was never made — no answer at all. */\n | 'unreadable'\n /** The tenant's mode is not one base-api accepts submissions in. */\n | 'mode-not-operational'\n /** Accepting mode, but no site key: the browser cannot mint a token. */\n | 'missing-recaptcha-key'\n /** A site key is present but cannot be a key — see {@link RECAPTCHA_SITE_KEY_PATTERN}. */\n | 'malformed-recaptcha-key'\n\nexport type ContactFormAvailability =\n /**\n * Confirmed: base-api accepts submissions AND a usable site key exists. The\n * key travels inside the `available` case on purpose — a component that has\n * proven the form may render should not have to re-read (and re-null-check)\n * the payload to find out what to render it WITH.\n */\n | { state: 'available'; mode: ContactFormOperationalMode; recaptchaSiteKey: string }\n | { state: 'unavailable'; reason: ContactFormUnavailableReason }\n\n/** Whitespace-only is absent — a key someone saved blank is not a key. */\nfunction trimmed(value: unknown): string | null {\n if (typeof value !== 'string') return null\n const text = value.trim()\n return text.length > 0 ? text : null\n}\n\nfunction isOperationalMode(mode: unknown): mode is ContactFormOperationalMode {\n // Widened to `unknown` deliberately: see isSafeRecaptchaSiteKey. A base-api\n // newer than this SDK sends a string TypeScript has never heard of, and it\n // must fail closed rather than be assumed accepting.\n return (CONTACT_FORM_OPERATIONAL_MODES as readonly string[]).includes(mode as string)\n}\n\n/**\n * Resolve whether a contact form may be rendered for this tenant.\n *\n * We offer what we can CONFIRM, never what we have merely failed to disprove: an\n * unknown mode, a mode from a runtime newer than this build, and a config read\n * that threw all resolve to the same answer — no form. The page still shows the\n * phone, the email and the address, so a hidden form costs a visitor one extra\n * glance; a form that eats their message costs them the message.\n *\n * Order matters. The mode is checked BEFORE the key, because \"the tenant has not\n * switched the form on\" is the actionable half of a double failure — a site key\n * for a form nobody enabled is not the problem to fix first.\n */\nexport function resolveContactFormAvailability(\n analytics: Analytics | null | undefined,\n): ContactFormAvailability {\n if (!analytics) return { state: 'unavailable', reason: 'unreadable' }\n\n const mode = analytics.contactFormMode\n if (!isOperationalMode(mode)) return { state: 'unavailable', reason: 'mode-not-operational' }\n\n // `ContactFormInput.captcha` is REQUIRED and base-api verifies it, so an\n // accepting mode with no usable key is still a form whose every submission is\n // dead on arrival. \"Missing\" and \"malformed\" are separated because they call\n // for different fixes by different people: one owner never filled the field,\n // the other filled it with the wrong thing.\n const recaptchaSiteKey = trimmed(analytics.recaptchaSiteKey)\n if (!recaptchaSiteKey) return { state: 'unavailable', reason: 'missing-recaptcha-key' }\n if (!isSafeRecaptchaSiteKey(recaptchaSiteKey)) {\n return { state: 'unavailable', reason: 'malformed-recaptcha-key' }\n }\n\n return { state: 'available', mode, recaptchaSiteKey }\n}\n\n/** Narrowing helper — the single question a renderer should ask. */\nexport function isContactFormAvailable(\n availability: ContactFormAvailability,\n): availability is Extract<ContactFormAvailability, { state: 'available' }> {\n return availability.state === 'available'\n}\n\n/**\n * The key to hand `GoogleReCaptchaProvider` on this render — `''` until `armed`.\n *\n * @deprecated **Wiring this return value directly into\n * `<GoogleReCaptchaProvider reCaptchaKey={deferredRecaptchaKey(siteKey, armed)}>`\n * is the exact REB-793 bug**: `react-google-recaptcha-v3@1.11.0` captures\n * `reCaptchaKey` in a `useRef` on FIRST MOUNT and never re-reads the prop for\n * invisible v3 (no visible `container`), so `executeRecaptcha` keeps sending\n * the STALE `''` from before arming to `grecaptcha.execute` forever — the\n * script loads with the right key, but every token mint throws, and every\n * contact-form submission is silently dead on arrival. Verified on\n * dermamedicin preprod 29-Sep: zero requests ever reached\n * `/api/site/contact-form`. Use `DeferredRecaptchaProvider` from\n * `@reblu/site-next/recaptcha-provider` instead — it applies this exact same\n * value as BOTH the `key` and the `reCaptchaKey` prop, which forces a full\n * remount on arm instead of a prop swap, so the mounted instance's `useRef`\n * never sees any key but the real one. This function stays exported,\n * unchanged, because `DeferredRecaptchaProvider` calls it internally and\n * external SITEs may already depend on its signature for other purposes\n * (e.g. rendering their own diagnostics) — this is a documentation-only\n * deprecation, not a behavior or breaking change.\n *\n * ─────────────────────────────────────────────────────────────────────────────\n * WHAT THIS BUYS\n * ─────────────────────────────────────────────────────────────────────────────\n *\n * `GoogleReCaptchaProvider` loads Google's `api.js` the moment it MOUNTS. Mount\n * it with the form and every visitor who merely lands on the page to read the\n * phone number has contacted Google — on a clinic's contact page, a third-party\n * request nobody asked for, on a route whose whole subject is personal data.\n *\n * Arming on first interaction with the form (focus, first keystroke) means\n * Google is contacted only for a visitor who is actually about to submit, which\n * is the only visitor whose token is worth anything. It is a smaller page, a\n * faster one, and a materially smaller consent surface.\n *\n * **This is NOT a cookie-consent implementation.** Deferring the script narrows\n * the exposure; it does not gate it on a lawful basis. Full consent gating for\n * third parties is REB-642 and has its own owner.\n *\n * ─────────────────────────────────────────────────────────────────────────────\n * HOW, WITHOUT A HACK — MEASURED, NOT ASSUMED\n * ─────────────────────────────────────────────────────────────────────────────\n *\n * `react-google-recaptcha-v3` injects the script from an effect shaped\n *\n * useEffect(() => { if (reCaptchaKey) { …inject…; return cleanup } … },\n * [ …, reCaptchaKey, … ])\n *\n * Two facts fall out, and together they are the whole mechanism:\n *\n * 1. An EMPTY key mounts the provider and injects NOTHING.\n * 2. `reCaptchaKey` is a dependency, so swapping the real key in later re-runs\n * the effect and injects the script then.\n *\n * Crucially the provider element never leaves the tree, so its children are\n * never remounted merely because `reCaptchaKey` changed on a LIVE instance:\n * no imperative script injection, no reaching into the DOM, no fork.\n *\n * **This describes script LOADING only, not token MINTING.** REB-793 found\n * the gap: the same swapped prop that safely re-runs this effect is also\n * where `react-google-recaptcha-v3` caches `reCaptchaKey` in a `useRef` — set\n * once, on first mount, and (for invisible v3) never reassigned. The script\n * loads with the swapped-in real key; `executeRecaptcha` keeps sending the\n * key from BEFORE the swap. Wiring this function's return value straight\n * into `<GoogleReCaptchaProvider reCaptchaKey={...}>`, the pattern this\n * whole comment used to recommend, is exactly that bug — see the\n * `@deprecated` note above and `@reblu/site-next/recaptcha-provider`'s\n * `DeferredRecaptchaProvider`, which fixes it by forcing a real remount\n * instead of relying on this same prop swap.\n *\n * Both facts are pinned by `tests/recaptcha-deferred-oracle.test.tsx`, which\n * drives the REAL library and counts script tags in the DOM. They are claims\n * about someone else's code, so prose asserting them is not enough — if a future\n * version of the library injects eagerly, that test goes red and this whole\n * optimisation is known to be void.\n *\n * ── THE ONE COST, MEASURED ──\n *\n * On the empty-key render the library calls its own warn helper, which is\n * written inverted, so the warning fires in PRODUCTION and stays silent in\n * development. A visitor who opens devtools before touching a field sees one\n * `\"<GoogleReCaptchaProvider /> recaptcha key not provided\"`. Cosmetic console\n * noise, traded against a third-party request on every view of the page. Worth\n * it, and recorded here so the next reader knows it is expected.\n *\n * ── AND NO FALLBACK KEY, EVER ──\n *\n * `''` for a blank OR malformed key however armed. There is no hardcoded\n * fallback anywhere in this module, because a captcha key that is not the\n * tenant's fails Google's verification while LOOKING configured — converting a\n * visible gap an owner can fix in nexus into a silent one nobody can.\n * {@link resolveContactFormAvailability} hides the form in that case; this is\n * the second lock on the same door.\n */\nexport function deferredRecaptchaKey(siteKey: string, armed: boolean): string {\n if (!armed) return ''\n const key = trimmed(siteKey)\n return key && isSafeRecaptchaSiteKey(key) ? key : ''\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AA0FA,mBASO;AACP,uCAA4D;;;ACMrD,IAAM,6BAA6B;AAQnC,SAAS,uBAAuB,OAAiC;AACtE,SAAO,OAAO,UAAU,YAAY,2BAA2B,KAAK,KAAK;AAC3E;AAqEA,SAAS,QAAQ,OAA+B;AAC9C,MAAI,OAAO,UAAU,SAAU,QAAO;AACtC,QAAM,OAAO,MAAM,KAAK;AACxB,SAAO,KAAK,SAAS,IAAI,OAAO;AAClC;AAkJO,SAAS,qBAAqB,SAAiB,OAAwB;AAC5E,MAAI,CAAC,MAAO,QAAO;AACnB,QAAM,MAAM,QAAQ,OAAO;AAC3B,SAAO,OAAO,uBAAuB,GAAG,IAAI,MAAM;AACpD;;;ADvJI;AA9EJ,IAAM,UAAyC,EAAE,kBAAkB,OAAU;AAE7E,IAAM,+BAA2B,4BAA6C,OAAO;AAOrF,SAAS,cAAc;AAAA,EACrB;AACF,GAEG;AACD,QAAM,EAAE,iBAAiB,QAAI,qDAAmB;AAChD,8BAAU,MAAM;AACd,aAAS,gBAAgB;AAAA,EAC3B,GAAG,CAAC,kBAAkB,QAAQ,CAAC;AAC/B,SAAO;AACT;AA2BO,SAAS,0BAA0B;AAAA,EACxC;AAAA,EACA;AAAA,EACA;AAAA,EACA,GAAG;AACL,GAAmC;AACjC,QAAM,eAAe,qBAAqB,SAAS,KAAK;AACxD,QAAM,CAAC,kBAAkB,mBAAmB,QAC1C,uBAA4D,MAAS;AAavE,QAAM,mBAAe;AAAA,IACnB,CAAC,OAA0D,oBAAoB,MAAM,EAAE;AAAA,IACvF,CAAC;AAAA,EACH;AAKA,QAAM,mBAAe,sBAAQ,OAAO,EAAE,iBAAiB,IAAI,CAAC,gBAAgB,CAAC;AAE7E,SACE,6CAAC,yBAAyB,UAAzB,EAAkC,OAAO,cACxC;AAAA,gDAAC,4DAA2C,cAA6B,GAAG,eAC1E,sDAAC,iBAAc,UAAU,cAAc,KADX,YAE9B;AAAA,IACC;AAAA,KACH;AAEJ;AAUO,SAAS,uBAAsD;AACpE,aAAO,yBAAW,wBAAwB;AAC5C;","names":[]}
|
|
1
|
+
{"version":3,"sources":["../src/recaptcha-provider.tsx","../src/recaptcha.ts"],"sourcesContent":["'use client'\n\n/**\n * `@reblu/site-next/recaptcha-provider` — the FIX for REB-793.\n *\n * ─────────────────────────────────────────────────────────────────────────────\n * THE BUG THIS EXISTS TO CLOSE\n * ─────────────────────────────────────────────────────────────────────────────\n *\n * `deferredRecaptchaKey` (`./recaptcha`, REB-588) returns `''` until `armed`,\n * on the documented claim that `<GoogleReCaptchaProvider reCaptchaKey={...}>`\n * swaps the key in later without remounting. That claim is only half true.\n * Verified directly in `react-google-recaptcha-v3@1.11.0`'s own dist\n * (`dist/react-google-recaptcha-v3.esm.js`):\n *\n * var x = useRef(reCaptchaKey) // captured ONCE, on first mount\n * …\n * window[onLoadCallbackName] = () => { x.current = grecaptcha.render(…) }\n * …\n * executeRecaptcha = () => grecaptcha.execute(x.current, { action })\n *\n * `x.current` is reassigned ONLY inside the `onLoadCallbackName` global the\n * library wires into the script URL's `&onload=` query param — and that param\n * is only added when `render === 'explicit'`, i.e. when a visible widget\n * `container` is configured. Invisible v3 (no `container`, the ONLY mode this\n * SDK or any Reblu SITE uses) never sets that param, so `x.current` never\n * updates: every `executeRecaptcha()` call keeps sending the EMPTY first-mount\n * key to `grecaptcha.execute('', { action })`, which throws, which the SITE's\n * submit handler swallows into \"reCAPTCHA no está disponible\" — no token,\n * every submission dead on arrival. Found 29-Sep in the first real\n * contact-form test on dermamedicin preprod: zero requests ever reached\n * `/api/site/contact-form`.\n *\n * ─────────────────────────────────────────────────────────────────────────────\n * THE FIX — REMOUNT THE VENDOR, NOT THE SITE'S FORM\n * ─────────────────────────────────────────────────────────────────────────────\n *\n * `useRef`'s initial value is captured on FIRST MOUNT and nothing short of\n * unmounting-and-remounting the element changes that — the vendor gives us no\n * other hook. So the element that eventually loads Google's script must never\n * have been mounted with any key other than the real one: `key={reCaptchaKey}`\n * on `<GoogleReCaptchaProvider>` does exactly that. React treats a `key`\n * change as a different element identity and discards the old subtree instead\n * of re-rendering it, so arming does not swap a prop on a live instance — it\n * unmounts the empty-key instance (which never loaded anything, so there is\n * nothing to tear down) and mounts a BRAND NEW one whose `useRef` captures the\n * real key from its very first render.\n *\n * A first version of this fix put the SITE's own `children` directly inside\n * that keyed element. It closes the ref bug, but it also means the SITE's\n * form — every typed field, and any in-flight submit closure reading\n * `executeRecaptcha` — gets unmounted and remounted at the exact moment arming\n * happens. For the common case (arm on first focus, before any text exists)\n * that only costs a moment of focus. For demo-site's actual \"visitor tabs\n * straight to submit\" fallback (`waitForRecaptchaReady` in\n * `contact-form.section.tsx`) it is fatal: arming happens FROM INSIDE the\n * form's own submit handler, so remounting the form destroys the very\n * component instance that is awaiting the result — the typed values vanish\n * and the poll never resolves, reproduced by\n * `contact-form.section.recaptcha-submit.test.tsx` going red under that\n * design.\n *\n * So the vendor `<GoogleReCaptchaProvider>` here wraps ONLY an invisible\n * bridge component, never the SITE's `children`. The bridge reads the\n * vendor's `useGoogleReCaptcha()` — correct by construction, because it lives\n * inside the freshly, correctly keyed instance — and republishes\n * `executeRecaptcha` into `DeferredRecaptchaContext`, a context on a STABLE\n * element that is never keyed and never remounts. `children` renders as a\n * SIBLING of the vendor provider, under that stable context, and consumes the\n * function via `useDeferredRecaptcha()` (this module's own replacement for\n * the vendor's `useGoogleReCaptcha()`). Arming remounts the invisible bridge;\n * it never touches the SITE's form.\n *\n * ─────────────────────────────────────────────────────────────────────────────\n * WHY THIS IS A SEPARATE SUBPATH, NOT PART OF `./recaptcha`\n * ─────────────────────────────────────────────────────────────────────────────\n *\n * `./recaptcha` is guarded by `tests/recaptcha-subpath-isolation.test.ts` to\n * import NOTHING — not React, not `react-google-recaptcha-v3` — so a Server\n * Component can call `resolveContactFormAvailability` without paying for\n * Google's loader library. This component unavoidably imports both. Same R4\n * rule as `auth`/`academy`/`consent`: a `'use client'` directive is only\n * preserved by esbuild when the ENTRY FILE itself declares it, never when\n * merely re-exported — so this file is tsup's OWN entry (`recaptcha-provider`\n * in `../tsup.config.ts`), not folded into `./recaptcha` or the root barrel.\n *\n * `react-google-recaptcha-v3` stays the consuming SITE's OWN dependency\n * (an optional `peerDependencies` entry here, exactly as `./recaptcha`'s\n * README section already promises) — this package never bundles it.\n */\nimport {\n createContext,\n useCallback,\n useContext,\n useEffect,\n useMemo,\n useRef,\n useState,\n type ComponentProps,\n type ReactNode,\n} from 'react'\nimport { GoogleReCaptchaProvider, useGoogleReCaptcha } from 'react-google-recaptcha-v3'\n\nimport { deferredRecaptchaKey } from './recaptcha'\n\n/** The one thing a SITE needs, in the same shape the vendor's own hook returns it. */\nexport interface DeferredRecaptchaContextValue {\n /** `undefined` until Google's script has loaded under the ARMED key. */\n executeRecaptcha: ((action?: string) => Promise<string>) | undefined\n}\n\nconst UNARMED: DeferredRecaptchaContextValue = { executeRecaptcha: undefined }\n\nconst DeferredRecaptchaContext = createContext<DeferredRecaptchaContextValue>(UNARMED)\n\n/**\n * Lives INSIDE the keyed vendor provider — the only place the vendor's own\n * `executeRecaptcha` is ever correct — and republishes it to the stable\n * context above. Renders nothing; it is pure plumbing.\n */\nfunction ExecuteBridge({\n onChange,\n}: {\n onChange: (fn: DeferredRecaptchaContextValue['executeRecaptcha']) => void\n}) {\n const { executeRecaptcha } = useGoogleReCaptcha()\n useEffect(() => {\n onChange(executeRecaptcha)\n }, [executeRecaptcha, onChange])\n return null\n}\n\ntype ProviderPassthroughProps = Omit<\n ComponentProps<typeof GoogleReCaptchaProvider>,\n 'reCaptchaKey' | 'children'\n>\n\nexport interface DeferredRecaptchaProviderProps extends ProviderPassthroughProps {\n /** The tenant's public reCAPTCHA site key — `resolveContactFormAvailability`'s `recaptchaSiteKey`. */\n siteKey: string\n /** Whether the visitor has interacted with the form yet (REB-588 arming signal). */\n armed: boolean\n children: ReactNode\n}\n\n/**\n * Drop-in replacement for wiring `deferredRecaptchaKey` directly into\n * `<GoogleReCaptchaProvider reCaptchaKey={...}>` — see the module docs above\n * for why that direct wiring never mints a real token. Pair with\n * `useDeferredRecaptcha()` instead of the vendor's `useGoogleReCaptcha()`.\n *\n * ```tsx\n * <DeferredRecaptchaProvider siteKey={siteKey} armed={armed}>\n * <form onFocusCapture={arm} onPointerDown={arm}>…</form>\n * </DeferredRecaptchaProvider>\n * ```\n */\nexport function DeferredRecaptchaProvider({\n siteKey,\n armed,\n children,\n ...providerProps\n}: DeferredRecaptchaProviderProps) {\n const reCaptchaKey = deferredRecaptchaKey(siteKey, armed)\n const [executeRecaptcha, setExecuteRecaptcha] =\n useState<DeferredRecaptchaContextValue['executeRecaptcha']>(undefined)\n\n // No separate \"reset to undefined on key change\" effect is needed here,\n // and deliberately so: `reCaptchaKey` changing remounts `ExecuteBridge`\n // (it lives inside the keyed `GoogleReCaptchaProvider`), and a FRESH bridge\n // instance's very first effect run already reports the fresh instance's\n // own `executeRecaptcha` — `undefined` until that instance's own script has\n // loaded, never a value carried over from the instance that just unmounted.\n // An extra reset effect on THIS component was tried and reverted: as the\n // parent, its effects fire AFTER its children's on the same commit, so it\n // clobbered the bridge's own report back to `undefined` on every mount —\n // caught by `contact-form.section.test.tsx`, whose mocked\n // `useGoogleReCaptcha()` reports a working function synchronously.\n const handleChange = useCallback(\n (fn: DeferredRecaptchaContextValue['executeRecaptcha']) => setExecuteRecaptcha(() => fn),\n [],\n )\n\n // Memoized so every consumer of `useDeferredRecaptcha()` re-renders only\n // when `executeRecaptcha` itself actually changes, not on every render of\n // this provider (e.g. from an unrelated `providerProps` change).\n const contextValue = useMemo(() => ({ executeRecaptcha }), [executeRecaptcha])\n\n return (\n <DeferredRecaptchaContext.Provider value={contextValue}>\n <GoogleReCaptchaProvider key={reCaptchaKey} reCaptchaKey={reCaptchaKey} {...providerProps}>\n <ExecuteBridge onChange={handleChange} />\n </GoogleReCaptchaProvider>\n {children}\n </DeferredRecaptchaContext.Provider>\n )\n}\n\n/**\n * The SITE's replacement for `react-google-recaptcha-v3`'s own\n * `useGoogleReCaptcha()` when the form is wrapped in\n * `<DeferredRecaptchaProvider>`. Same shape (`{ executeRecaptcha }`), but\n * reads the STABLE context republished by `ExecuteBridge`, which never\n * remounts on arm — unlike the vendor's own context, which lives on the\n * element `DeferredRecaptchaProvider` deliberately keys and remounts.\n */\nexport function useDeferredRecaptcha(): DeferredRecaptchaContextValue {\n return useContext(DeferredRecaptchaContext)\n}\n\nexport interface UseDeferredRecaptchaTokenOptions {\n /** Arms the provider (sets its `armed` prop) when the visitor submits before interacting. */\n onArm: () => void\n /** How long to wait for Google's script after arming. @default 5000 */\n timeoutMs?: number\n /** Polling interval while waiting. @default 50 */\n intervalMs?: number\n}\n\n/**\n * A token getter for a form wrapped in `<DeferredRecaptchaProvider>` (REB-980)\n * — the shape `DataRequestForm`'s `getCaptchaToken` takes.\n *\n * The provider withholds Google's script until the form is armed, so a visitor\n * who tabs straight to submit finds `executeRecaptcha` still `undefined`. The\n * returned function arms the provider (`onArm`) and waits up to `timeoutMs` for\n * the script instead of failing them. Resolves `undefined` — never throws —\n * when the script does not become ready or Google rejects, so the form can\n * show its own \"reCAPTCHA unavailable\" copy.\n *\n * Safe to arm from inside the form's own submit handler: the provider never\n * remounts its children on arm, so the awaiting component survives.\n */\nexport function useDeferredRecaptchaToken(\n action: string,\n { onArm, timeoutMs = 5000, intervalMs = 50 }: UseDeferredRecaptchaTokenOptions,\n): () => Promise<string | undefined> {\n const { executeRecaptcha } = useDeferredRecaptcha()\n const executeRef = useRef(executeRecaptcha)\n useEffect(() => {\n executeRef.current = executeRecaptcha\n }, [executeRecaptcha])\n\n return useCallback(async () => {\n if (!executeRef.current) {\n onArm()\n const deadline = Date.now() + timeoutMs\n while (!executeRef.current) {\n if (Date.now() >= deadline) return undefined\n await new Promise((resolve) => setTimeout(resolve, intervalMs))\n }\n }\n try {\n return await executeRef.current(action)\n } catch {\n return undefined\n }\n }, [action, onArm, timeoutMs, intervalMs])\n}\n","/**\n * reCAPTCHA v3 — the SITE's half of the contract (REB-588).\n *\n * ─────────────────────────────────────────────────────────────────────────────\n * WHICH HALF LIVES WHERE, AND WHY\n * ─────────────────────────────────────────────────────────────────────────────\n *\n * reCAPTCHA is two secrets and two steps, and they are deliberately split across\n * a trust boundary:\n *\n * SITE KEY — public by design. It ships to the browser inside the script URL,\n * so the tenant serves it from `/api/site/analytics`. This module\n * handles it.\n * SECRET — never leaves base-api. It is stored per tenant in\n * `site_integration_config.recaptchaSecretKey` and read only by\n * `serverCaptchaVerify` when `POST /api/site/contact-form` runs.\n *\n * **Nothing in this module, in this package, or anywhere in the `@reblu/site-*`\n * SDK ever sees the secret, and nothing here calls Google's `siteverify`.** A\n * SITE holding the secret would be a SITE that could be tricked into approving\n * its own tokens, and a distributable SDK is the worst possible place to put a\n * credential. Verification is base-api's job and already works: the SITE mints a\n * token, posts it as `ContactFormInput.captcha`, and base-api decides.\n *\n * That is also why this module is NOT \"reCAPTCHA support\" in the round. It is\n * the three decisions every SITE was otherwise making alone and differently:\n * which action to name, whether a form may be shown at all, and when Google's\n * script is allowed to load.\n *\n * ─────────────────────────────────────────────────────────────────────────────\n * WHY IT IS IN THE SDK AT ALL\n * ─────────────────────────────────────────────────────────────────────────────\n *\n * Because it was already being written four times. `apps/base-api`,\n * `apps/demo-site` and the external `reblu-site-dermamedicin` each grew their\n * own copy of this flow, and they had already drifted — different action\n * strings, different mount timing, a retry loop in one of them. A score that\n * reCAPTCHA reports per action is not comparable across SITEs that name the\n * action differently, and a defect fixed in one copy stays live in the other\n * two. The SDK is where a decision gets made once.\n *\n * This module has ZERO runtime dependencies and imports nothing — not React, not\n * `next`, not `react-google-recaptcha-v3`, not `@reblu/site-ui`. It is pure\n * functions over plain data, callable from a Server Component deciding whether\n * to render a form and from the Client Component that renders it. The subpath is\n * kept dependency-free by `tests/recaptcha-subpath-isolation.test.ts`.\n */\n\nimport type { Analytics, ContactFormMode } from '@reblu/site-contracts'\n\n/**\n * The action name every Reblu SITE mints its contact-form token under.\n *\n * reCAPTCHA v3 scores PER ACTION, and a score is only interpretable against a\n * stable one: Google's own admin console charts each action separately, and a\n * threshold tuned on one action says nothing about another. Two SITEs calling\n * `executeRecaptcha('submit_form')` and `executeRecaptcha('contact_form')` are\n * building two unrelated score histories for the same feature — which is exactly\n * the state the repo was in before this constant existed.\n *\n * `contact_form` over `submit_form` because it names the form, not the verb;\n * a second protected form would need its own action, and `submit_form` would\n * have nothing left to call it.\n *\n * ── WHAT THIS CONSTANT DOES **NOT** BUY, STATED PLAINLY ──\n *\n * It buys comparable scores. It does NOT bind a token to this form, because\n * `serverCaptchaVerify` in base-api checks only `success` and `score`, and never\n * reads the `action` field Google returns. A token minted for any other action\n * on the same site verifies here exactly as well. Making the action load-bearing\n * requires base-api to assert `action === RECAPTCHA_ACTION` on verify; until it\n * does, do not read this constant as a security control.\n */\nexport const RECAPTCHA_ACTION = 'contact_form'\n\n/**\n * The action name for the public GDPR rights form (REB-980,\n * `POST /api/site/data-requests`) — the \"second protected form\" the note above\n * anticipates, with its own score history. Same caveat: base-api does not\n * assert the action on verify, so this is not a security control.\n */\nexport const RECAPTCHA_DATA_REQUEST_ACTION = 'data_request'\n\n/**\n * The shape a site key must have before this SDK will hand it to Google.\n *\n * ── WHAT THIS IS, AND EMPHATICALLY WHAT IT IS NOT ──\n *\n * It is a SINK-SAFETY guard, not a guess at Google's key format. The key is\n * interpolated into the loader's script URL as a query parameter, so what\n * matters here is that it cannot carry a space, a quote, a newline, an `&`, or a\n * `#` — characters that make a URL mean something other than what was intended.\n * The base64url alphabet Google actually issues from (`A-Z a-z 0-9 - _`) is a\n * strict subset of \"safe\", so a real key passes by construction.\n *\n * The length bounds are deliberately far wider than any key Google issues\n * (theirs are 40 characters). A bound tight enough to be a real format check\n * would be a bound tight enough to take a WORKING tenant's contact form offline\n * the day Google changes its key length — which is strictly worse than the\n * malformed key it was meant to catch. If you are tempted to tighten this,\n * the thing to tighten is the nexus write path, where an owner can still see\n * and fix the error, not this read path where they cannot.\n *\n * ── WHY THE SDK RE-VALIDATES AT ALL ──\n *\n * Same reason `@reblu/site-next/analytics` re-checks `gaId` and `clarityId`\n * (REB-617): base-api is not the only thing a SITE ever talks to, and the layer\n * that owns the value is not the layer that suffers from it being wrong. As of\n * REB-588 this column has NO validation anywhere — not in the nexus form, not on\n * the way out of `/api/site/analytics`, not in the response contract, which is\n * the one asymmetry `gaId`/`clarityId` do not share. Until that is fixed\n * upstream, this is the only guard between a mistyped key and a contact form\n * that renders, looks live, and silently eats every enquiry.\n */\nexport const RECAPTCHA_SITE_KEY_PATTERN = /^[A-Za-z0-9_-]{16,128}$/\n\n/**\n * Whether `value` is a site key safe to put in the loader's script URL.\n *\n * Takes `unknown` on purpose: the value arrives over HTTP, so the contract's\n * `string | null` is a promise about the shape, not a guarantee about the bytes.\n */\nexport function isSafeRecaptchaSiteKey(value: unknown): value is string {\n return typeof value === 'string' && RECAPTCHA_SITE_KEY_PATTERN.test(value)\n}\n\n/**\n * The modes in which base-api will actually accept a submission.\n *\n * An ALLOW-LIST, never `mode !== 'DISABLED'`. The three differ only in what\n * base-api DOES with the payload — a row, a webhook, an email — and the SITE\n * must not care and must never learn the destination: a webhook URL or a Resend\n * key is a tenant secret with no business in a public site.\n *\n * The deny-list version of this is a real incident, not a hypothetical: a page\n * that hid the form on `DISABLED` and rendered it otherwise rendered it for\n * `NOT_CONFIGURED` too — which is what EVERY freshly provisioned tenant carries\n * until someone finishes the wizard (REB-586). A visitor typed out a medical\n * enquiry, pressed send, and got `503 Contact form is disabled`. They learned\n * the clinic could not be reached only after writing to it.\n */\nexport const CONTACT_FORM_OPERATIONAL_MODES = ['DB_ONLY', 'WEBHOOK', 'RESEND'] as const\n\n/** A mode in which the form may be shown. */\nexport type ContactFormOperationalMode = (typeof CONTACT_FORM_OPERATIONAL_MODES)[number]\n\n/**\n * The modes deliberately declined. A TYPE, never a value: the runtime must never\n * read it, because an allow-list must not be expressible as \"everything except\n * this list\", and this subpath's defining property is that it ships nothing it\n * does not need. It exists only to feed {@link ContactFormModeExhaustiveCheck}.\n */\ntype DeclinedMode = 'NOT_CONFIGURED' | 'DISABLED'\n\n/**\n * Compile-time exhaustiveness guard over `ContactFormMode`.\n *\n * `Missing` is constrained to `never` and DEFAULTS to the contract modes that\n * are neither accepted nor explicitly declined, so a default violating its own\n * constraint breaks the build the moment `@reblu/site-contracts` gains a mode.\n *\n * At runtime a new mode already fails closed, which is correct but SILENT — and\n * silence is how a tenant switches their form on in nexus and watches nothing\n * appear. This forces a human to make the call in a diff instead.\n *\n * Exported so it is never reported as an unused symbol.\n */\nexport type ContactFormModeExhaustiveCheck<\n Missing extends never = Exclude<ContactFormMode, ContactFormOperationalMode | DeclinedMode>,\n> = Missing\n\n/** Why the form is not being shown. Drives diagnostics, never visitor-facing copy. */\nexport type ContactFormUnavailableReason =\n /** The analytics read failed or was never made — no answer at all. */\n | 'unreadable'\n /** The tenant's mode is not one base-api accepts submissions in. */\n | 'mode-not-operational'\n /** Accepting mode, but no site key: the browser cannot mint a token. */\n | 'missing-recaptcha-key'\n /** A site key is present but cannot be a key — see {@link RECAPTCHA_SITE_KEY_PATTERN}. */\n | 'malformed-recaptcha-key'\n\nexport type ContactFormAvailability =\n /**\n * Confirmed: base-api accepts submissions AND a usable site key exists. The\n * key travels inside the `available` case on purpose — a component that has\n * proven the form may render should not have to re-read (and re-null-check)\n * the payload to find out what to render it WITH.\n */\n | { state: 'available'; mode: ContactFormOperationalMode; recaptchaSiteKey: string }\n | { state: 'unavailable'; reason: ContactFormUnavailableReason }\n\n/** Whitespace-only is absent — a key someone saved blank is not a key. */\nfunction trimmed(value: unknown): string | null {\n if (typeof value !== 'string') return null\n const text = value.trim()\n return text.length > 0 ? text : null\n}\n\nfunction isOperationalMode(mode: unknown): mode is ContactFormOperationalMode {\n // Widened to `unknown` deliberately: see isSafeRecaptchaSiteKey. A base-api\n // newer than this SDK sends a string TypeScript has never heard of, and it\n // must fail closed rather than be assumed accepting.\n return (CONTACT_FORM_OPERATIONAL_MODES as readonly string[]).includes(mode as string)\n}\n\n/**\n * Resolve whether a contact form may be rendered for this tenant.\n *\n * We offer what we can CONFIRM, never what we have merely failed to disprove: an\n * unknown mode, a mode from a runtime newer than this build, and a config read\n * that threw all resolve to the same answer — no form. The page still shows the\n * phone, the email and the address, so a hidden form costs a visitor one extra\n * glance; a form that eats their message costs them the message.\n *\n * Order matters. The mode is checked BEFORE the key, because \"the tenant has not\n * switched the form on\" is the actionable half of a double failure — a site key\n * for a form nobody enabled is not the problem to fix first.\n */\nexport function resolveContactFormAvailability(\n analytics: Analytics | null | undefined,\n): ContactFormAvailability {\n if (!analytics) return { state: 'unavailable', reason: 'unreadable' }\n\n const mode = analytics.contactFormMode\n if (!isOperationalMode(mode)) return { state: 'unavailable', reason: 'mode-not-operational' }\n\n // `ContactFormInput.captcha` is REQUIRED and base-api verifies it, so an\n // accepting mode with no usable key is still a form whose every submission is\n // dead on arrival. \"Missing\" and \"malformed\" are separated because they call\n // for different fixes by different people: one owner never filled the field,\n // the other filled it with the wrong thing.\n const recaptchaSiteKey = trimmed(analytics.recaptchaSiteKey)\n if (!recaptchaSiteKey) return { state: 'unavailable', reason: 'missing-recaptcha-key' }\n if (!isSafeRecaptchaSiteKey(recaptchaSiteKey)) {\n return { state: 'unavailable', reason: 'malformed-recaptcha-key' }\n }\n\n return { state: 'available', mode, recaptchaSiteKey }\n}\n\n/** Narrowing helper — the single question a renderer should ask. */\nexport function isContactFormAvailable(\n availability: ContactFormAvailability,\n): availability is Extract<ContactFormAvailability, { state: 'available' }> {\n return availability.state === 'available'\n}\n\n/**\n * The key to hand `GoogleReCaptchaProvider` on this render — `''` until `armed`.\n *\n * @deprecated **Wiring this return value directly into\n * `<GoogleReCaptchaProvider reCaptchaKey={deferredRecaptchaKey(siteKey, armed)}>`\n * is the exact REB-793 bug**: `react-google-recaptcha-v3@1.11.0` captures\n * `reCaptchaKey` in a `useRef` on FIRST MOUNT and never re-reads the prop for\n * invisible v3 (no visible `container`), so `executeRecaptcha` keeps sending\n * the STALE `''` from before arming to `grecaptcha.execute` forever — the\n * script loads with the right key, but every token mint throws, and every\n * contact-form submission is silently dead on arrival. Verified on\n * dermamedicin preprod 29-Sep: zero requests ever reached\n * `/api/site/contact-form`. Use `DeferredRecaptchaProvider` from\n * `@reblu/site-next/recaptcha-provider` instead — it applies this exact same\n * value as BOTH the `key` and the `reCaptchaKey` prop, which forces a full\n * remount on arm instead of a prop swap, so the mounted instance's `useRef`\n * never sees any key but the real one. This function stays exported,\n * unchanged, because `DeferredRecaptchaProvider` calls it internally and\n * external SITEs may already depend on its signature for other purposes\n * (e.g. rendering their own diagnostics) — this is a documentation-only\n * deprecation, not a behavior or breaking change.\n *\n * ─────────────────────────────────────────────────────────────────────────────\n * WHAT THIS BUYS\n * ─────────────────────────────────────────────────────────────────────────────\n *\n * `GoogleReCaptchaProvider` loads Google's `api.js` the moment it MOUNTS. Mount\n * it with the form and every visitor who merely lands on the page to read the\n * phone number has contacted Google — on a clinic's contact page, a third-party\n * request nobody asked for, on a route whose whole subject is personal data.\n *\n * Arming on first interaction with the form (focus, first keystroke) means\n * Google is contacted only for a visitor who is actually about to submit, which\n * is the only visitor whose token is worth anything. It is a smaller page, a\n * faster one, and a materially smaller consent surface.\n *\n * **This is NOT a cookie-consent implementation.** Deferring the script narrows\n * the exposure; it does not gate it on a lawful basis. Full consent gating for\n * third parties is REB-642 and has its own owner.\n *\n * ─────────────────────────────────────────────────────────────────────────────\n * HOW, WITHOUT A HACK — MEASURED, NOT ASSUMED\n * ─────────────────────────────────────────────────────────────────────────────\n *\n * `react-google-recaptcha-v3` injects the script from an effect shaped\n *\n * useEffect(() => { if (reCaptchaKey) { …inject…; return cleanup } … },\n * [ …, reCaptchaKey, … ])\n *\n * Two facts fall out, and together they are the whole mechanism:\n *\n * 1. An EMPTY key mounts the provider and injects NOTHING.\n * 2. `reCaptchaKey` is a dependency, so swapping the real key in later re-runs\n * the effect and injects the script then.\n *\n * Crucially the provider element never leaves the tree, so its children are\n * never remounted merely because `reCaptchaKey` changed on a LIVE instance:\n * no imperative script injection, no reaching into the DOM, no fork.\n *\n * **This describes script LOADING only, not token MINTING.** REB-793 found\n * the gap: the same swapped prop that safely re-runs this effect is also\n * where `react-google-recaptcha-v3` caches `reCaptchaKey` in a `useRef` — set\n * once, on first mount, and (for invisible v3) never reassigned. The script\n * loads with the swapped-in real key; `executeRecaptcha` keeps sending the\n * key from BEFORE the swap. Wiring this function's return value straight\n * into `<GoogleReCaptchaProvider reCaptchaKey={...}>`, the pattern this\n * whole comment used to recommend, is exactly that bug — see the\n * `@deprecated` note above and `@reblu/site-next/recaptcha-provider`'s\n * `DeferredRecaptchaProvider`, which fixes it by forcing a real remount\n * instead of relying on this same prop swap.\n *\n * Both facts are pinned by `tests/recaptcha-deferred-oracle.test.tsx`, which\n * drives the REAL library and counts script tags in the DOM. They are claims\n * about someone else's code, so prose asserting them is not enough — if a future\n * version of the library injects eagerly, that test goes red and this whole\n * optimisation is known to be void.\n *\n * ── THE ONE COST, MEASURED ──\n *\n * On the empty-key render the library calls its own warn helper, which is\n * written inverted, so the warning fires in PRODUCTION and stays silent in\n * development. A visitor who opens devtools before touching a field sees one\n * `\"<GoogleReCaptchaProvider /> recaptcha key not provided\"`. Cosmetic console\n * noise, traded against a third-party request on every view of the page. Worth\n * it, and recorded here so the next reader knows it is expected.\n *\n * ── AND NO FALLBACK KEY, EVER ──\n *\n * `''` for a blank OR malformed key however armed. There is no hardcoded\n * fallback anywhere in this module, because a captcha key that is not the\n * tenant's fails Google's verification while LOOKING configured — converting a\n * visible gap an owner can fix in nexus into a silent one nobody can.\n * {@link resolveContactFormAvailability} hides the form in that case; this is\n * the second lock on the same door.\n */\nexport function deferredRecaptchaKey(siteKey: string, armed: boolean): string {\n if (!armed) return ''\n const key = trimmed(siteKey)\n return key && isSafeRecaptchaSiteKey(key) ? key : ''\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AA0FA,mBAUO;AACP,uCAA4D;;;ACarD,IAAM,6BAA6B;AAQnC,SAAS,uBAAuB,OAAiC;AACtE,SAAO,OAAO,UAAU,YAAY,2BAA2B,KAAK,KAAK;AAC3E;AAqEA,SAAS,QAAQ,OAA+B;AAC9C,MAAI,OAAO,UAAU,SAAU,QAAO;AACtC,QAAM,OAAO,MAAM,KAAK;AACxB,SAAO,KAAK,SAAS,IAAI,OAAO;AAClC;AAkJO,SAAS,qBAAqB,SAAiB,OAAwB;AAC5E,MAAI,CAAC,MAAO,QAAO;AACnB,QAAM,MAAM,QAAQ,OAAO;AAC3B,SAAO,OAAO,uBAAuB,GAAG,IAAI,MAAM;AACpD;;;AD9JI;AA9EJ,IAAM,UAAyC,EAAE,kBAAkB,OAAU;AAE7E,IAAM,+BAA2B,4BAA6C,OAAO;AAOrF,SAAS,cAAc;AAAA,EACrB;AACF,GAEG;AACD,QAAM,EAAE,iBAAiB,QAAI,qDAAmB;AAChD,8BAAU,MAAM;AACd,aAAS,gBAAgB;AAAA,EAC3B,GAAG,CAAC,kBAAkB,QAAQ,CAAC;AAC/B,SAAO;AACT;AA2BO,SAAS,0BAA0B;AAAA,EACxC;AAAA,EACA;AAAA,EACA;AAAA,EACA,GAAG;AACL,GAAmC;AACjC,QAAM,eAAe,qBAAqB,SAAS,KAAK;AACxD,QAAM,CAAC,kBAAkB,mBAAmB,QAC1C,uBAA4D,MAAS;AAavE,QAAM,mBAAe;AAAA,IACnB,CAAC,OAA0D,oBAAoB,MAAM,EAAE;AAAA,IACvF,CAAC;AAAA,EACH;AAKA,QAAM,mBAAe,sBAAQ,OAAO,EAAE,iBAAiB,IAAI,CAAC,gBAAgB,CAAC;AAE7E,SACE,6CAAC,yBAAyB,UAAzB,EAAkC,OAAO,cACxC;AAAA,gDAAC,4DAA2C,cAA6B,GAAG,eAC1E,sDAAC,iBAAc,UAAU,cAAc,KADX,YAE9B;AAAA,IACC;AAAA,KACH;AAEJ;AAUO,SAAS,uBAAsD;AACpE,aAAO,yBAAW,wBAAwB;AAC5C;AAyBO,SAAS,0BACd,QACA,EAAE,OAAO,YAAY,KAAM,aAAa,GAAG,GACR;AACnC,QAAM,EAAE,iBAAiB,IAAI,qBAAqB;AAClD,QAAM,iBAAa,qBAAO,gBAAgB;AAC1C,8BAAU,MAAM;AACd,eAAW,UAAU;AAAA,EACvB,GAAG,CAAC,gBAAgB,CAAC;AAErB,aAAO,0BAAY,YAAY;AAC7B,QAAI,CAAC,WAAW,SAAS;AACvB,YAAM;AACN,YAAM,WAAW,KAAK,IAAI,IAAI;AAC9B,aAAO,CAAC,WAAW,SAAS;AAC1B,YAAI,KAAK,IAAI,KAAK,SAAU,QAAO;AACnC,cAAM,IAAI,QAAQ,CAAC,YAAY,WAAW,SAAS,UAAU,CAAC;AAAA,MAChE;AAAA,IACF;AACA,QAAI;AACF,aAAO,MAAM,WAAW,QAAQ,MAAM;AAAA,IACxC,QAAQ;AACN,aAAO;AAAA,IACT;AAAA,EACF,GAAG,CAAC,QAAQ,OAAO,WAAW,UAAU,CAAC;AAC3C;","names":[]}
|
|
@@ -37,5 +37,28 @@ declare function DeferredRecaptchaProvider({ siteKey, armed, children, ...provid
|
|
|
37
37
|
* element `DeferredRecaptchaProvider` deliberately keys and remounts.
|
|
38
38
|
*/
|
|
39
39
|
declare function useDeferredRecaptcha(): DeferredRecaptchaContextValue;
|
|
40
|
+
interface UseDeferredRecaptchaTokenOptions {
|
|
41
|
+
/** Arms the provider (sets its `armed` prop) when the visitor submits before interacting. */
|
|
42
|
+
onArm: () => void;
|
|
43
|
+
/** How long to wait for Google's script after arming. @default 5000 */
|
|
44
|
+
timeoutMs?: number;
|
|
45
|
+
/** Polling interval while waiting. @default 50 */
|
|
46
|
+
intervalMs?: number;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* A token getter for a form wrapped in `<DeferredRecaptchaProvider>` (REB-980)
|
|
50
|
+
* — the shape `DataRequestForm`'s `getCaptchaToken` takes.
|
|
51
|
+
*
|
|
52
|
+
* The provider withholds Google's script until the form is armed, so a visitor
|
|
53
|
+
* who tabs straight to submit finds `executeRecaptcha` still `undefined`. The
|
|
54
|
+
* returned function arms the provider (`onArm`) and waits up to `timeoutMs` for
|
|
55
|
+
* the script instead of failing them. Resolves `undefined` — never throws —
|
|
56
|
+
* when the script does not become ready or Google rejects, so the form can
|
|
57
|
+
* show its own "reCAPTCHA unavailable" copy.
|
|
58
|
+
*
|
|
59
|
+
* Safe to arm from inside the form's own submit handler: the provider never
|
|
60
|
+
* remounts its children on arm, so the awaiting component survives.
|
|
61
|
+
*/
|
|
62
|
+
declare function useDeferredRecaptchaToken(action: string, { onArm, timeoutMs, intervalMs }: UseDeferredRecaptchaTokenOptions): () => Promise<string | undefined>;
|
|
40
63
|
|
|
41
|
-
export { type DeferredRecaptchaContextValue, DeferredRecaptchaProvider, type DeferredRecaptchaProviderProps, useDeferredRecaptcha };
|
|
64
|
+
export { type DeferredRecaptchaContextValue, DeferredRecaptchaProvider, type DeferredRecaptchaProviderProps, type UseDeferredRecaptchaTokenOptions, useDeferredRecaptcha, useDeferredRecaptchaToken };
|
|
@@ -37,5 +37,28 @@ declare function DeferredRecaptchaProvider({ siteKey, armed, children, ...provid
|
|
|
37
37
|
* element `DeferredRecaptchaProvider` deliberately keys and remounts.
|
|
38
38
|
*/
|
|
39
39
|
declare function useDeferredRecaptcha(): DeferredRecaptchaContextValue;
|
|
40
|
+
interface UseDeferredRecaptchaTokenOptions {
|
|
41
|
+
/** Arms the provider (sets its `armed` prop) when the visitor submits before interacting. */
|
|
42
|
+
onArm: () => void;
|
|
43
|
+
/** How long to wait for Google's script after arming. @default 5000 */
|
|
44
|
+
timeoutMs?: number;
|
|
45
|
+
/** Polling interval while waiting. @default 50 */
|
|
46
|
+
intervalMs?: number;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* A token getter for a form wrapped in `<DeferredRecaptchaProvider>` (REB-980)
|
|
50
|
+
* — the shape `DataRequestForm`'s `getCaptchaToken` takes.
|
|
51
|
+
*
|
|
52
|
+
* The provider withholds Google's script until the form is armed, so a visitor
|
|
53
|
+
* who tabs straight to submit finds `executeRecaptcha` still `undefined`. The
|
|
54
|
+
* returned function arms the provider (`onArm`) and waits up to `timeoutMs` for
|
|
55
|
+
* the script instead of failing them. Resolves `undefined` — never throws —
|
|
56
|
+
* when the script does not become ready or Google rejects, so the form can
|
|
57
|
+
* show its own "reCAPTCHA unavailable" copy.
|
|
58
|
+
*
|
|
59
|
+
* Safe to arm from inside the form's own submit handler: the provider never
|
|
60
|
+
* remounts its children on arm, so the awaiting component survives.
|
|
61
|
+
*/
|
|
62
|
+
declare function useDeferredRecaptchaToken(action: string, { onArm, timeoutMs, intervalMs }: UseDeferredRecaptchaTokenOptions): () => Promise<string | undefined>;
|
|
40
63
|
|
|
41
|
-
export { type DeferredRecaptchaContextValue, DeferredRecaptchaProvider, type DeferredRecaptchaProviderProps, useDeferredRecaptcha };
|
|
64
|
+
export { type DeferredRecaptchaContextValue, DeferredRecaptchaProvider, type DeferredRecaptchaProviderProps, type UseDeferredRecaptchaTokenOptions, useDeferredRecaptcha, useDeferredRecaptchaToken };
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
useContext,
|
|
8
8
|
useEffect,
|
|
9
9
|
useMemo,
|
|
10
|
+
useRef,
|
|
10
11
|
useState
|
|
11
12
|
} from "react";
|
|
12
13
|
import { GoogleReCaptchaProvider, useGoogleReCaptcha } from "react-google-recaptcha-v3";
|
|
@@ -61,8 +62,31 @@ function DeferredRecaptchaProvider({
|
|
|
61
62
|
function useDeferredRecaptcha() {
|
|
62
63
|
return useContext(DeferredRecaptchaContext);
|
|
63
64
|
}
|
|
65
|
+
function useDeferredRecaptchaToken(action, { onArm, timeoutMs = 5e3, intervalMs = 50 }) {
|
|
66
|
+
const { executeRecaptcha } = useDeferredRecaptcha();
|
|
67
|
+
const executeRef = useRef(executeRecaptcha);
|
|
68
|
+
useEffect(() => {
|
|
69
|
+
executeRef.current = executeRecaptcha;
|
|
70
|
+
}, [executeRecaptcha]);
|
|
71
|
+
return useCallback(async () => {
|
|
72
|
+
if (!executeRef.current) {
|
|
73
|
+
onArm();
|
|
74
|
+
const deadline = Date.now() + timeoutMs;
|
|
75
|
+
while (!executeRef.current) {
|
|
76
|
+
if (Date.now() >= deadline) return void 0;
|
|
77
|
+
await new Promise((resolve) => setTimeout(resolve, intervalMs));
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
try {
|
|
81
|
+
return await executeRef.current(action);
|
|
82
|
+
} catch {
|
|
83
|
+
return void 0;
|
|
84
|
+
}
|
|
85
|
+
}, [action, onArm, timeoutMs, intervalMs]);
|
|
86
|
+
}
|
|
64
87
|
export {
|
|
65
88
|
DeferredRecaptchaProvider,
|
|
66
|
-
useDeferredRecaptcha
|
|
89
|
+
useDeferredRecaptcha,
|
|
90
|
+
useDeferredRecaptchaToken
|
|
67
91
|
};
|
|
68
92
|
//# sourceMappingURL=recaptcha-provider.js.map
|