@reblu/site-next 1.10.0 → 1.12.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 CHANGED
@@ -1,5 +1,68 @@
1
1
  # @reblu/site-next
2
2
 
3
+ ## 1.12.0
4
+
5
+ ### Minor Changes
6
+
7
+ - baa36cf: Add a plug-and-play `ShopFilters` component so a SITE never hand-builds shop
8
+ filter UI again (REB-809) — the gap that made the dermamedicin SITE hardcode
9
+ `BRAND_AXIS = 'brand'` while that tenant's real axis key is `marca`.
10
+
11
+ `@reblu/site-blocks` exports `ShopFilters` (root entry, RSC-safe): one
12
+ fieldset per non-empty `facets.taxonomies` axis (single-select, with a
13
+ click-to-clear toggle on the active term — see the component's doc comment
14
+ for why single-select, given the current `taxonomy[<key>]` query contract), a
15
+ multi-select fieldset per filterable attribute, a price-band fieldset
16
+ (`priceBands` prop, sensible EUR defaults, also click-to-clear), and an
17
+ on-sale toggle. A filter selection that collapses every facet to empty still
18
+ renders "Clear filters" rather than nothing — also exported as the standalone
19
+ predicate `shouldRenderShopFilters(facets, active)`, so a consuming page can
20
+ gate its own wrapping landmark on the identical decision. Every option always
21
+ renders a real `<a href>`; `onChange` is strictly additive for a Client
22
+ Component caller. Price-band labels ("Under €25", "€25 – €50", "€100+") come
23
+ from three overridable `ShopFiltersLabels` templates (`priceUnder`,
24
+ `priceOver`, `priceRange`), not hardcoded English, plus `currencyCode`/
25
+ `locale` props matching `ProductCard`'s. Tokens only, fieldset/legend
26
+ semantics, `aria-current` on the active option(s), zero accessibility
27
+ violations (`jest-axe`).
28
+
29
+ `@reblu/site-next` exports the URL glue from two new subpaths:
30
+ `./shop-server` (`parseShopSearchParams`, `serializeShopParams`,
31
+ `toURLSearchParams`, `buildShopFilterHrefs`, `getShopCollection` — all
32
+ server-safe, no `'use client'`) and `./shop` (adds the client-reactive
33
+ `useShopFilterLinks` hook). `parseShopSearchParams`/`serializeShopParams`
34
+ mirror base-api's own `parseListParams` bracket shape and MVP
35
+ single-value-per-taxonomy-axis collapse, so a URL this SDK builds is exactly
36
+ the URL the server parses back — including hardening against
37
+ prototype-pollution-shaped bracket keys (`taxonomy[__proto__]` and friends)
38
+ and strict digit-only numeric parsing, while `taxonomy`/`attr` on the
39
+ returned `ShopProductsParams` are always guaranteed PLAIN objects (never the
40
+ null-prototype accumulator used internally for that hardening) — React
41
+ Flight refuses a null-prototype prop crossing the Server→Client Component
42
+ boundary, and a Client Component caller passing `active={params}` into
43
+ `ShopFilters`' `onChange` mode is exactly the documented usage this
44
+ guarantees stays safe. `toURLSearchParams` converts Next's `searchParams`
45
+ route prop into a `URLSearchParams`, preserving a repeated key as a repeated
46
+ entry (the native `URLSearchParams` constructor does not). `getShopCollection`
47
+ fetches products and facets with the identical params, in parallel — a
48
+ products failure propagates, a facets failure degrades to an empty
49
+ `ShopFacets` so the listing survives rather than one upstream outage taking
50
+ both down, logs once (`console.warn('[getShopCollection] …')`) so the
51
+ degradation is never silent, and the resolved `ShopCollection` carries
52
+ `facetsStatus: 'ok' | 'unavailable'` so a caller can tell "no filterable axes
53
+ configured" apart from "facets endpoint is down" without parsing a log line.
54
+
55
+ ### Patch Changes
56
+
57
+ - Updated dependencies [baa36cf]
58
+ - @reblu/site-contracts@1.3.0
59
+
60
+ ## 1.11.0
61
+
62
+ ### Minor Changes
63
+
64
+ - 604d464: Add `@reblu/site-next/recaptcha-provider` (REB-793): `<DeferredRecaptchaProvider>` and `useDeferredRecaptcha()` fix a defect in the REB-588 deferred-load pattern where wiring `deferredRecaptchaKey`'s return value directly into `<GoogleReCaptchaProvider reCaptchaKey={...}>` loaded Google's script with the correct site key but kept calling `executeRecaptcha` with the stale empty key from before arming — `react-google-recaptcha-v3@1.11.0` captures `reCaptchaKey` in a `useRef` on first mount and never re-reads it for invisible v3. Every token mint threw, so every contact-form submission silently failed to reach base-api. `DeferredRecaptchaProvider` applies the same validated key as both the `key` and the `reCaptchaKey` prop, forcing a full remount of the vendor provider on arm instead of a prop swap, while republishing `executeRecaptcha` through a stable context so the SITE's own form (and any in-flight submit) never remounts. `deferredRecaptchaKey` (`./recaptcha`) stays exported and unchanged — now documented `@deprecated` for direct use with `<GoogleReCaptchaProvider>` — and `react-google-recaptcha-v3` remains the consuming SITE's own optional peer dependency, never bundled into the SDK.
65
+
3
66
  ## 1.10.0
4
67
 
5
68
  ### Minor Changes
package/README.md CHANGED
@@ -15,13 +15,18 @@ intentionally Next-coupled so the lower SDK layers —
15
15
  factory that proxies them to base-api's `/api/site/auth/*`.
16
16
  4. **`<JsonLd>`** (`./json-ld`) — safe `application/ld+json` `<script>`
17
17
  rendering; see "Structured data (JSON-LD)" below.
18
- 5. **reCAPTCHA v3** (`./recaptcha`) — the SITE's half of the contact-form
19
- captcha contract: the canonical action, the render gate, and the
20
- interaction-mount key. See "reCAPTCHA v3" below.
18
+ 5. **reCAPTCHA v3** (`./recaptcha`, `./recaptcha-provider`) — the SITE's half
19
+ of the contact-form captcha contract: the canonical action, the render
20
+ gate, and a defect-fixed (REB-793) interaction-mount provider,
21
+ `<DeferredRecaptchaProvider>`. See "reCAPTCHA v3" below.
21
22
  6. **Academy** (`./academy`, `./academy-server`) — `AcademyEnrollmentForm`,
22
23
  the anonymous enrollment-request form, plus `deriveValidationFields` to
23
24
  build its per-tenant validation fields from course/config data. See
24
25
  "Academy" below.
26
+ 7. **Shop filters** (`./shop`, `./shop-server`) — URL ↔ `ShopProductsParams`
27
+ glue for `@reblu/site-blocks`'s `ShopFilters`: parse/serialize, a server
28
+ helper that fetches products + facets with identical params, and a
29
+ client-reactive navigation hook. See "Shop filters" below.
25
30
 
26
31
  ## Install
27
32
 
@@ -29,7 +34,9 @@ intentionally Next-coupled so the lower SDK layers —
29
34
  pnpm add @reblu/site-next @reblu/site-client @reblu/site-contracts @reblu/site-ui next-themes
30
35
  ```
31
36
 
32
- Peer deps: `react >=19`, `next >=16`, `@reblu/site-ui`, `next-themes`.
37
+ Peer deps: `react >=19`, `next >=16`, `@reblu/site-ui`, `next-themes`, and
38
+ optionally `react-google-recaptcha-v3` (only if you use
39
+ `@reblu/site-next/recaptcha-provider` — see "reCAPTCHA v3" below).
33
40
 
34
41
  `next-themes` is required by the `./auth` entry as a whole, not only by
35
42
  `AuthLogo`: `tsup.config.ts` builds `auth.js`/`auth.cjs` as ONE
@@ -240,6 +247,71 @@ maps each `fieldErrors` key onto the matching input (`email`, or a
240
247
  `details`, or a `429` — renders as a single form-level error instead. No
241
248
  reCAPTCHA: the route is IP rate-limited server-side.
242
249
 
250
+ ## Shop filters
251
+
252
+ `./shop-server` and `./shop` (REB-809) are the URL glue behind
253
+ `@reblu/site-blocks`'s `ShopFilters` — see that package's README for the full
254
+ example. Two subpaths, same split reasoning as `./academy`/`./academy-server`:
255
+
256
+ | Export | Subpath | Needs `'use client'`? |
257
+ | ------ | ------- | ---------------------- |
258
+ | `parseShopSearchParams(searchParams): ShopProductsParams` | both | No |
259
+ | `serializeShopParams(params): URLSearchParams` | both | No |
260
+ | `toURLSearchParams(nextSearchParams): URLSearchParams` | both | No |
261
+ | `buildShopFilterHrefs(current, pathname): ShopFilterHrefs` | both | No |
262
+ | `getShopCollection(client, params?)` | both | No |
263
+ | `useShopFilterLinks(): ShopFilterHrefs` | `./shop` only | **Yes** |
264
+
265
+ `toURLSearchParams(searchParams)` converts Next's route `searchParams` prop —
266
+ `Record<string, string | string[] | undefined>`, a repeated query key already
267
+ grouped into an array — into the `URLSearchParams` `parseShopSearchParams`
268
+ expects, preserving every repeated key as a repeated entry. Use it, not
269
+ `new URLSearchParams(await searchParams)`: the native constructor only
270
+ accepts `Record<string, string>`, so handed an array value it calls
271
+ `String()` on it and produces ONE entry (`attr[color]=red,blue`) instead of
272
+ two repeated ones — silently breaking `parseShopSearchParams`'s
273
+ repeat-count-based array/scalar distinction for every multi-value
274
+ `attr[<key>]` filter. See `@reblu/site-blocks`'s README for the full
275
+ Server Component recipe built on this.
276
+
277
+ `parseShopSearchParams`/`serializeShopParams` are a deliberate MIRROR of
278
+ base-api's own `parseListParams`
279
+ (`apps/base-api/src/lib/shop/catalogue-query.ts`) — same bracket shape
280
+ (`taxonomy[<key>]=<slug>`, `attr[<key>]=<value>`, repeatable for `attr`), same
281
+ MVP collapse of a repeated `taxonomy[<key>]` to its first value. A URL this
282
+ module builds is exactly the URL the server parses back to the same params —
283
+ there is no second, drifting parser. `serializeShopParams` round-trips
284
+ through `parseShopSearchParams` for any canonical input; a single-element
285
+ `attr` array is the one documented exception — see `params.test.ts` — because
286
+ one `attr[key]=value` occurrence is indistinguishable from a plain scalar
287
+ once serialized (the array/scalar distinction is carried entirely by repeat
288
+ count). Reading it back with `Array.isArray(...) ? value : [value]` (exactly
289
+ what `ShopFilters` does) makes the distinction invisible to a consumer.
290
+
291
+ `getShopCollection(client, params)` fetches `client.shop.products(params)`
292
+ and `client.shop.facets(params)` **with the identical params object**, in
293
+ parallel — the seam that keeps a collection page correct. A hand-rolled page
294
+ that calls the two endpoints with separately-derived params is exactly how a
295
+ facet count and its product list drift apart.
296
+
297
+ `products` and `facets` are not equally critical: a products failure
298
+ propagates (`getShopCollection` rejects — there is no listing without
299
+ products), while a facets failure degrades to an empty `ShopFacets` so the
300
+ listing still renders, just without filter UI. That degradation is never
301
+ silent: it logs `console.warn('[getShopCollection] facets fetch failed, …')`
302
+ once, and the resolved `ShopCollection` carries `facetsStatus: 'ok' |
303
+ 'unavailable'` so a caller that wants to act on it (retry, surface a banner)
304
+ can tell "this tenant configured no filterable axes" apart from "the facets
305
+ endpoint is down right now" without parsing a log line.
306
+
307
+ `buildShopFilterHrefs(current, pathname)` is pure (no `next/navigation`) — a
308
+ Server Component calls it directly with its own `searchParams` prop and
309
+ route pathname. Every returned href preserves every OTHER current param and
310
+ resets pagination (`page`) — a filter change is never silently stuck on page
311
+ 4 of a now-smaller result set. `useShopFilterLinks()` is the same logic bound
312
+ reactively to `usePathname()`/`useSearchParams()`, for a filter bar that is
313
+ its own Client Component.
314
+
243
315
  ## Structured data (JSON-LD)
244
316
 
245
317
  `@reblu/site-next/json-ld` (REB-622, server-only, dependency-free) renders one
@@ -328,33 +400,76 @@ the way out of `/api/site/analytics`, or in the response contract.
328
400
  ### Load Google only when someone actually uses the form
329
401
 
330
402
  ```tsx
331
- import { RECAPTCHA_ACTION, deferredRecaptchaKey } from '@reblu/site-next/recaptcha'
403
+ import { RECAPTCHA_ACTION } from '@reblu/site-next/recaptcha'
404
+ import { DeferredRecaptchaProvider, useDeferredRecaptcha } from '@reblu/site-next/recaptcha-provider'
332
405
 
333
406
  const [armed, setArmed] = useState(false)
334
407
 
335
- <GoogleReCaptchaProvider reCaptchaKey={deferredRecaptchaKey(siteKey, armed)}>
408
+ <DeferredRecaptchaProvider siteKey={siteKey} armed={armed}>
336
409
  <form onFocusCapture={() => setArmed(true)}>…</form>
337
- </GoogleReCaptchaProvider>
410
+ </DeferredRecaptchaProvider>
338
411
 
339
- // at submit: await executeRecaptcha(RECAPTCHA_ACTION)
412
+ // inside the form, instead of react-google-recaptcha-v3's own useGoogleReCaptcha():
413
+ const { executeRecaptcha } = useDeferredRecaptcha()
414
+ // at submit: await executeRecaptcha?.(RECAPTCHA_ACTION)
340
415
  ```
341
416
 
342
417
  `GoogleReCaptchaProvider` loads `api.js` the moment it mounts, so mounting it
343
- with the form means every visitor who merely lands on the page to read the phone
344
- number has contacted Google. `deferredRecaptchaKey` returns `''` until `armed`,
345
- and an empty key mounts the provider while injecting nothing; swapping the real
346
- key in re-runs the library's effect and injects it then, **without remounting
347
- the children** — so the visitor never loses focus or a half-typed word.
348
-
349
- Both of those are claims about the vendor's code, so they are pinned by
350
- `tests/recaptcha-deferred-oracle.test.tsx`, which drives the real
351
- `react-google-recaptcha-v3` and counts script tags in the DOM. If a future
352
- version injects eagerly, that test goes red rather than this README quietly
353
- becoming false.
418
+ with the form means every visitor who merely lands on the page to read the
419
+ phone number has contacted Google. `DeferredRecaptchaProvider` withholds a real
420
+ key until `armed`, exactly like `deferredRecaptchaKey` (below) did — but see
421
+ **REB-793** for why it does not stop there.
422
+
423
+ > **REB-793 — do not wire `deferredRecaptchaKey` straight into
424
+ > `<GoogleReCaptchaProvider>`.** That was this section's guidance before
425
+ > REB-793 and it is a confirmed defect: `react-google-recaptcha-v3@1.11.0`
426
+ > captures `reCaptchaKey` in a `useRef` on the FIRST render and only ever
427
+ > reassigns it through a callback the library wires when a visible widget
428
+ > `container` is configured — invisible v3 (no `container`, the only mode any
429
+ > Reblu SITE uses) never triggers that path. Swapping the prop from `''` to
430
+ > the real key therefore loads `api.js` with the correct key while
431
+ > `executeRecaptcha` keeps sending `''` to `grecaptcha.execute` forever —
432
+ > every token mint throws, every submission silently dies. Found 29-Sep in the
433
+ > first real contact-form test on dermamedicin preprod: zero requests reached
434
+ > `/api/site/contact-form`. `deferredRecaptchaKey` stays exported (see its own
435
+ > `@deprecated` doc) because `DeferredRecaptchaProvider` calls it internally
436
+ > and removing it would be a breaking change — but do not call it directly
437
+ > against `<GoogleReCaptchaProvider>` yourself.
438
+
439
+ `DeferredRecaptchaProvider` fixes this by applying that same validated key as
440
+ BOTH the `key` prop and the `reCaptchaKey` prop on the vendor's
441
+ `GoogleReCaptchaProvider`. A `key` change forces React to unmount the
442
+ empty-key instance (which never loaded anything) and mount a brand-new one
443
+ whose `useRef` captures the real key from its very first render — never a
444
+ stale prop swap on a live instance. That remount is contained to an internal,
445
+ invisible bridge component; **the SITE's own `children` — the form — never
446
+ remounts**, because they render as a sibling under a separate, stable context
447
+ that `useDeferredRecaptcha()` reads. A visitor's typed fields, and an
448
+ in-flight submit awaiting `executeRecaptcha` (demo-site's own arm-on-submit
449
+ fallback for a visitor who tabs straight to the button), both survive arming.
450
+
451
+ Both the vendor's mount-vs-inject behaviour and this fix's own claims about
452
+ key-vs-execute correctness are pinned by real end-to-end tests, not prose:
453
+ `tests/recaptcha-deferred-oracle.test.tsx` drives the real
454
+ `react-google-recaptcha-v3` and counts script tags in the DOM (the original
455
+ REB-588 oracle); `tests/recaptcha-provider.test.tsx` goes one step further and
456
+ actually calls `executeRecaptcha`, asserting `grecaptcha.execute` was invoked
457
+ with the ARMED site key — including a negative control that reproduces the
458
+ REB-793 bug directly against the vendor library, so this suite is proven to
459
+ catch it if it ever comes back. If a future library version injects eagerly,
460
+ or stops honoring `key`-based remounts, these tests go red rather than this
461
+ README quietly becoming false.
354
462
 
355
463
  **This is not cookie consent.** Deferring the script narrows the exposure; it
356
464
  does not gate it on a lawful basis. That is REB-642.
357
465
 
466
+ `DeferredRecaptchaProvider`/`useDeferredRecaptcha` live in their own subpath
467
+ (`./recaptcha-provider`), separate from `./recaptcha` above, because they
468
+ import React and `react-google-recaptcha-v3` — see "Which half lives where" —
469
+ so `react-google-recaptcha-v3` is an OPTIONAL peer dependency of this package:
470
+ install it yourself alongside `@reblu/site-next` (it already is, if you
471
+ followed "Install" above).
472
+
358
473
  `RECAPTCHA_ACTION` is exported because reCAPTCHA v3 scores **per action**: two
359
474
  SITEs calling `executeRecaptcha('submit_form')` and `'contact_form'` build two
360
475
  unrelated score histories for the same feature.
@@ -0,0 +1,86 @@
1
+ "use strict";
2
+ "use client";
3
+ var __defProp = Object.defineProperty;
4
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
+ var __getOwnPropNames = Object.getOwnPropertyNames;
6
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
7
+ var __export = (target, all) => {
8
+ for (var name in all)
9
+ __defProp(target, name, { get: all[name], enumerable: true });
10
+ };
11
+ var __copyProps = (to, from, except, desc) => {
12
+ if (from && typeof from === "object" || typeof from === "function") {
13
+ for (let key of __getOwnPropNames(from))
14
+ if (!__hasOwnProp.call(to, key) && key !== except)
15
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
16
+ }
17
+ return to;
18
+ };
19
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
20
+
21
+ // src/recaptcha-provider.tsx
22
+ var recaptcha_provider_exports = {};
23
+ __export(recaptcha_provider_exports, {
24
+ DeferredRecaptchaProvider: () => DeferredRecaptchaProvider,
25
+ useDeferredRecaptcha: () => useDeferredRecaptcha
26
+ });
27
+ module.exports = __toCommonJS(recaptcha_provider_exports);
28
+ var import_react = require("react");
29
+ var import_react_google_recaptcha_v3 = require("react-google-recaptcha-v3");
30
+
31
+ // src/recaptcha.ts
32
+ var RECAPTCHA_SITE_KEY_PATTERN = /^[A-Za-z0-9_-]{16,128}$/;
33
+ function isSafeRecaptchaSiteKey(value) {
34
+ return typeof value === "string" && RECAPTCHA_SITE_KEY_PATTERN.test(value);
35
+ }
36
+ function trimmed(value) {
37
+ if (typeof value !== "string") return null;
38
+ const text = value.trim();
39
+ return text.length > 0 ? text : null;
40
+ }
41
+ function deferredRecaptchaKey(siteKey, armed) {
42
+ if (!armed) return "";
43
+ const key = trimmed(siteKey);
44
+ return key && isSafeRecaptchaSiteKey(key) ? key : "";
45
+ }
46
+
47
+ // src/recaptcha-provider.tsx
48
+ var import_jsx_runtime = require("react/jsx-runtime");
49
+ var UNARMED = { executeRecaptcha: void 0 };
50
+ var DeferredRecaptchaContext = (0, import_react.createContext)(UNARMED);
51
+ function ExecuteBridge({
52
+ onChange
53
+ }) {
54
+ const { executeRecaptcha } = (0, import_react_google_recaptcha_v3.useGoogleReCaptcha)();
55
+ (0, import_react.useEffect)(() => {
56
+ onChange(executeRecaptcha);
57
+ }, [executeRecaptcha, onChange]);
58
+ return null;
59
+ }
60
+ function DeferredRecaptchaProvider({
61
+ siteKey,
62
+ armed,
63
+ children,
64
+ ...providerProps
65
+ }) {
66
+ const reCaptchaKey = deferredRecaptchaKey(siteKey, armed);
67
+ const [executeRecaptcha, setExecuteRecaptcha] = (0, import_react.useState)(void 0);
68
+ const handleChange = (0, import_react.useCallback)(
69
+ (fn) => setExecuteRecaptcha(() => fn),
70
+ []
71
+ );
72
+ const contextValue = (0, import_react.useMemo)(() => ({ executeRecaptcha }), [executeRecaptcha]);
73
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(DeferredRecaptchaContext.Provider, { value: contextValue, children: [
74
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_react_google_recaptcha_v3.GoogleReCaptchaProvider, { reCaptchaKey, ...providerProps, children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(ExecuteBridge, { onChange: handleChange }) }, reCaptchaKey),
75
+ children
76
+ ] });
77
+ }
78
+ function useDeferredRecaptcha() {
79
+ return (0, import_react.useContext)(DeferredRecaptchaContext);
80
+ }
81
+ // Annotate the CommonJS export names for ESM import in node:
82
+ 0 && (module.exports = {
83
+ DeferredRecaptchaProvider,
84
+ useDeferredRecaptcha
85
+ });
86
+ //# sourceMappingURL=recaptcha-provider.cjs.map
@@ -0,0 +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":[]}
@@ -0,0 +1,41 @@
1
+ import * as react_jsx_runtime from 'react/jsx-runtime';
2
+ import { ComponentProps, ReactNode } from 'react';
3
+ import { GoogleReCaptchaProvider } from 'react-google-recaptcha-v3';
4
+
5
+ /** The one thing a SITE needs, in the same shape the vendor's own hook returns it. */
6
+ interface DeferredRecaptchaContextValue {
7
+ /** `undefined` until Google's script has loaded under the ARMED key. */
8
+ executeRecaptcha: ((action?: string) => Promise<string>) | undefined;
9
+ }
10
+ type ProviderPassthroughProps = Omit<ComponentProps<typeof GoogleReCaptchaProvider>, 'reCaptchaKey' | 'children'>;
11
+ interface DeferredRecaptchaProviderProps extends ProviderPassthroughProps {
12
+ /** The tenant's public reCAPTCHA site key — `resolveContactFormAvailability`'s `recaptchaSiteKey`. */
13
+ siteKey: string;
14
+ /** Whether the visitor has interacted with the form yet (REB-588 arming signal). */
15
+ armed: boolean;
16
+ children: ReactNode;
17
+ }
18
+ /**
19
+ * Drop-in replacement for wiring `deferredRecaptchaKey` directly into
20
+ * `<GoogleReCaptchaProvider reCaptchaKey={...}>` — see the module docs above
21
+ * for why that direct wiring never mints a real token. Pair with
22
+ * `useDeferredRecaptcha()` instead of the vendor's `useGoogleReCaptcha()`.
23
+ *
24
+ * ```tsx
25
+ * <DeferredRecaptchaProvider siteKey={siteKey} armed={armed}>
26
+ * <form onFocusCapture={arm} onPointerDown={arm}>…</form>
27
+ * </DeferredRecaptchaProvider>
28
+ * ```
29
+ */
30
+ declare function DeferredRecaptchaProvider({ siteKey, armed, children, ...providerProps }: DeferredRecaptchaProviderProps): react_jsx_runtime.JSX.Element;
31
+ /**
32
+ * The SITE's replacement for `react-google-recaptcha-v3`'s own
33
+ * `useGoogleReCaptcha()` when the form is wrapped in
34
+ * `<DeferredRecaptchaProvider>`. Same shape (`{ executeRecaptcha }`), but
35
+ * reads the STABLE context republished by `ExecuteBridge`, which never
36
+ * remounts on arm — unlike the vendor's own context, which lives on the
37
+ * element `DeferredRecaptchaProvider` deliberately keys and remounts.
38
+ */
39
+ declare function useDeferredRecaptcha(): DeferredRecaptchaContextValue;
40
+
41
+ export { type DeferredRecaptchaContextValue, DeferredRecaptchaProvider, type DeferredRecaptchaProviderProps, useDeferredRecaptcha };
@@ -0,0 +1,41 @@
1
+ import * as react_jsx_runtime from 'react/jsx-runtime';
2
+ import { ComponentProps, ReactNode } from 'react';
3
+ import { GoogleReCaptchaProvider } from 'react-google-recaptcha-v3';
4
+
5
+ /** The one thing a SITE needs, in the same shape the vendor's own hook returns it. */
6
+ interface DeferredRecaptchaContextValue {
7
+ /** `undefined` until Google's script has loaded under the ARMED key. */
8
+ executeRecaptcha: ((action?: string) => Promise<string>) | undefined;
9
+ }
10
+ type ProviderPassthroughProps = Omit<ComponentProps<typeof GoogleReCaptchaProvider>, 'reCaptchaKey' | 'children'>;
11
+ interface DeferredRecaptchaProviderProps extends ProviderPassthroughProps {
12
+ /** The tenant's public reCAPTCHA site key — `resolveContactFormAvailability`'s `recaptchaSiteKey`. */
13
+ siteKey: string;
14
+ /** Whether the visitor has interacted with the form yet (REB-588 arming signal). */
15
+ armed: boolean;
16
+ children: ReactNode;
17
+ }
18
+ /**
19
+ * Drop-in replacement for wiring `deferredRecaptchaKey` directly into
20
+ * `<GoogleReCaptchaProvider reCaptchaKey={...}>` — see the module docs above
21
+ * for why that direct wiring never mints a real token. Pair with
22
+ * `useDeferredRecaptcha()` instead of the vendor's `useGoogleReCaptcha()`.
23
+ *
24
+ * ```tsx
25
+ * <DeferredRecaptchaProvider siteKey={siteKey} armed={armed}>
26
+ * <form onFocusCapture={arm} onPointerDown={arm}>…</form>
27
+ * </DeferredRecaptchaProvider>
28
+ * ```
29
+ */
30
+ declare function DeferredRecaptchaProvider({ siteKey, armed, children, ...providerProps }: DeferredRecaptchaProviderProps): react_jsx_runtime.JSX.Element;
31
+ /**
32
+ * The SITE's replacement for `react-google-recaptcha-v3`'s own
33
+ * `useGoogleReCaptcha()` when the form is wrapped in
34
+ * `<DeferredRecaptchaProvider>`. Same shape (`{ executeRecaptcha }`), but
35
+ * reads the STABLE context republished by `ExecuteBridge`, which never
36
+ * remounts on arm — unlike the vendor's own context, which lives on the
37
+ * element `DeferredRecaptchaProvider` deliberately keys and remounts.
38
+ */
39
+ declare function useDeferredRecaptcha(): DeferredRecaptchaContextValue;
40
+
41
+ export { type DeferredRecaptchaContextValue, DeferredRecaptchaProvider, type DeferredRecaptchaProviderProps, useDeferredRecaptcha };
@@ -0,0 +1,68 @@
1
+ "use client";
2
+
3
+ // src/recaptcha-provider.tsx
4
+ import {
5
+ createContext,
6
+ useCallback,
7
+ useContext,
8
+ useEffect,
9
+ useMemo,
10
+ useState
11
+ } from "react";
12
+ import { GoogleReCaptchaProvider, useGoogleReCaptcha } from "react-google-recaptcha-v3";
13
+
14
+ // src/recaptcha.ts
15
+ var RECAPTCHA_SITE_KEY_PATTERN = /^[A-Za-z0-9_-]{16,128}$/;
16
+ function isSafeRecaptchaSiteKey(value) {
17
+ return typeof value === "string" && RECAPTCHA_SITE_KEY_PATTERN.test(value);
18
+ }
19
+ function trimmed(value) {
20
+ if (typeof value !== "string") return null;
21
+ const text = value.trim();
22
+ return text.length > 0 ? text : null;
23
+ }
24
+ function deferredRecaptchaKey(siteKey, armed) {
25
+ if (!armed) return "";
26
+ const key = trimmed(siteKey);
27
+ return key && isSafeRecaptchaSiteKey(key) ? key : "";
28
+ }
29
+
30
+ // src/recaptcha-provider.tsx
31
+ import { jsx, jsxs } from "react/jsx-runtime";
32
+ var UNARMED = { executeRecaptcha: void 0 };
33
+ var DeferredRecaptchaContext = createContext(UNARMED);
34
+ function ExecuteBridge({
35
+ onChange
36
+ }) {
37
+ const { executeRecaptcha } = useGoogleReCaptcha();
38
+ useEffect(() => {
39
+ onChange(executeRecaptcha);
40
+ }, [executeRecaptcha, onChange]);
41
+ return null;
42
+ }
43
+ function DeferredRecaptchaProvider({
44
+ siteKey,
45
+ armed,
46
+ children,
47
+ ...providerProps
48
+ }) {
49
+ const reCaptchaKey = deferredRecaptchaKey(siteKey, armed);
50
+ const [executeRecaptcha, setExecuteRecaptcha] = useState(void 0);
51
+ const handleChange = useCallback(
52
+ (fn) => setExecuteRecaptcha(() => fn),
53
+ []
54
+ );
55
+ const contextValue = useMemo(() => ({ executeRecaptcha }), [executeRecaptcha]);
56
+ return /* @__PURE__ */ jsxs(DeferredRecaptchaContext.Provider, { value: contextValue, children: [
57
+ /* @__PURE__ */ jsx(GoogleReCaptchaProvider, { reCaptchaKey, ...providerProps, children: /* @__PURE__ */ jsx(ExecuteBridge, { onChange: handleChange }) }, reCaptchaKey),
58
+ children
59
+ ] });
60
+ }
61
+ function useDeferredRecaptcha() {
62
+ return useContext(DeferredRecaptchaContext);
63
+ }
64
+ export {
65
+ DeferredRecaptchaProvider,
66
+ useDeferredRecaptcha
67
+ };
68
+ //# sourceMappingURL=recaptcha-provider.js.map