@reblu/site-next 1.15.3 → 1.16.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,16 @@
1
1
  # @reblu/site-next
2
2
 
3
+ ## 1.16.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 59ccb29: Fix `<CookieBanner>` rendering an empty title/description and three blank buttons when a tenant left a nexus cookie-banner copy field unset (persisted as `""`, not `null`). A blank or whitespace-only `texts` override now falls back to the SDK's default copy for that key instead of winning the merge. Add `cookieBannerToConsentTexts` (`@reblu/site-next/consent`) to map the `/api/site/analytics` `cookieBanner` contract shape onto `texts` directly, dropping `null` and blank fields. `@reblu/site-contracts` now also exports the standalone `CookieBanner` type.
8
+
9
+ ### Patch Changes
10
+
11
+ - Updated dependencies [59ccb29]
12
+ - @reblu/site-contracts@1.6.1
13
+
3
14
  ## 1.15.3
4
15
 
5
16
  ### Patch Changes
package/README.md CHANGED
@@ -586,6 +586,42 @@ function CartPage() {
586
586
  the default «Pago cancelado: tu carrito sigue aquí» copy when `true`, or any
587
587
  custom `ReactNode` — see that package's README.
588
588
 
589
+ ## Cookie consent
590
+
591
+ `./consent` (REB-642) — `<ConsentProvider>`/`useConsent()`, `<CookieBanner>` +
592
+ `<CookiePreferencesTrigger>`, `<ConsentGate>`, and `<ConsentedAnalytics>`. Mount
593
+ `<ConsentProvider>` once at the layout root and `<CookieBanner>` once inside it:
594
+
595
+ ```tsx
596
+ import { ConsentProvider, CookieBanner } from '@reblu/site-next/consent'
597
+ import { cookiePolicyVersion } from '@reblu/site-next/consent-server'
598
+
599
+ <ConsentProvider policyVersion={cookiePolicyVersion(legal?.cookiePolicy ?? null)}>
600
+ {children}
601
+ <CookieBanner />
602
+ </ConsentProvider>
603
+ ```
604
+
605
+ ### Tenant-editable copy — `cookieBannerToConsentTexts` (REB-845)
606
+
607
+ `<CookieBanner>` renders the SDK's own Spanish defaults unless you pass
608
+ `texts` (a `Partial<ConsentBannerTexts>`). Map the `/api/site/analytics`
609
+ response's `cookieBanner` field onto `texts` with `cookieBannerToConsentTexts`
610
+ instead of hand-rolling the mapping — it already drops `null` AND
611
+ blank/whitespace-only fields, so an unfilled nexus field falls back to the
612
+ default instead of rendering an empty title or an unlabelled button:
613
+
614
+ ```tsx
615
+ import { CookieBanner, cookieBannerToConsentTexts } from '@reblu/site-next/consent'
616
+
617
+ <CookieBanner texts={cookieBannerToConsentTexts(analytics?.cookieBanner)} />
618
+ ```
619
+
620
+ Even if you build `texts` yourself, `<CookieBanner>` treats a blank or
621
+ whitespace-only value for any key the same as `undefined` — it always falls
622
+ back to that key's default rather than rendering empty. There is no need to
623
+ `?.trim()` a value before passing it.
624
+
589
625
  ## Structured data (JSON-LD)
590
626
 
591
627
  `@reblu/site-next/json-ld` (REB-622, server-only, dependency-free) renders one
package/dist/consent.cjs CHANGED
@@ -37,6 +37,8 @@ __export(consent_exports, {
37
37
  CookieBanner: () => CookieBanner,
38
38
  CookiePreferencesTrigger: () => CookiePreferencesTrigger,
39
39
  DEFAULT_CONSENT_BANNER_TEXTS: () => DEFAULT_CONSENT_BANNER_TEXTS,
40
+ cookieBannerToConsentTexts: () => cookieBannerToConsentTexts,
41
+ mergeConsentBannerTexts: () => mergeConsentBannerTexts,
40
42
  useConsent: () => useConsent
41
43
  });
42
44
  module.exports = __toCommonJS(consent_exports);
@@ -266,11 +268,45 @@ var DEFAULT_CONSENT_BANNER_TEXTS = {
266
268
  preferences: "Recuerdan tus preferencias de navegaci\xF3n entre visitas."
267
269
  }
268
270
  };
271
+ var CONSENT_BANNER_TEXT_KEYS = [
272
+ "title",
273
+ "description",
274
+ "acceptLabel",
275
+ "rejectLabel",
276
+ "manageLabel",
277
+ "policyLinkLabel",
278
+ "preferencesTitle",
279
+ "preferencesDescription",
280
+ "saveLabel",
281
+ "necessaryLabel",
282
+ "necessaryDescription"
283
+ ];
284
+ function mergeConsentBannerTexts(overrides) {
285
+ const merged = { ...DEFAULT_CONSENT_BANNER_TEXTS, ...overrides };
286
+ for (const key of CONSENT_BANNER_TEXT_KEYS) {
287
+ const value = overrides?.[key];
288
+ const isUnset = value == null || typeof value === "string" && value.trim() === "";
289
+ if (isUnset) {
290
+ merged[key] = DEFAULT_CONSENT_BANNER_TEXTS[key];
291
+ }
292
+ }
293
+ return merged;
294
+ }
295
+ function cookieBannerToConsentTexts(cookieBanner) {
296
+ if (!cookieBanner) return {};
297
+ const overrides = {};
298
+ if (cookieBanner.title?.trim()) overrides.title = cookieBanner.title;
299
+ if (cookieBanner.body?.trim()) overrides.description = cookieBanner.body;
300
+ if (cookieBanner.acceptLabel?.trim()) overrides.acceptLabel = cookieBanner.acceptLabel;
301
+ if (cookieBanner.rejectLabel?.trim()) overrides.rejectLabel = cookieBanner.rejectLabel;
302
+ if (cookieBanner.manageLabel?.trim()) overrides.manageLabel = cookieBanner.manageLabel;
303
+ return overrides;
304
+ }
269
305
 
270
306
  // src/consent/banner.tsx
271
307
  var import_jsx_runtime6 = require("react/jsx-runtime");
272
308
  function resolveTexts(overrides) {
273
- return { ...DEFAULT_CONSENT_BANNER_TEXTS, ...overrides };
309
+ return mergeConsentBannerTexts(overrides);
274
310
  }
275
311
  function CookieBanner({ texts: textsOverride, policyHref = "/cookies" }) {
276
312
  const { status, acceptAll, rejectAll, openPreferences } = useConsent();
@@ -399,6 +435,8 @@ function CategoryRow({ id, label, description, checked, disabled, onChange }) {
399
435
  CookieBanner,
400
436
  CookiePreferencesTrigger,
401
437
  DEFAULT_CONSENT_BANNER_TEXTS,
438
+ cookieBannerToConsentTexts,
439
+ mergeConsentBannerTexts,
402
440
  useConsent
403
441
  });
404
442
  //# sourceMappingURL=consent.cjs.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/consent/index.ts","../src/consent/context.tsx","../src/consent-shared.ts","../src/consent/gate.tsx","../src/analytics.tsx","../src/json-ld.tsx","../src/consent/consented-analytics.tsx","../src/consent/banner.tsx","../src/consent/texts.ts"],"sourcesContent":["'use client'\n\n/**\n * `@reblu/site-next/consent` — REB-642's consent mechanism.\n *\n * This barrel MUST begin with its OWN leading `'use client'` directive: tsup\n * only preserves a directive prologue declared by the entry file itself, not\n * one merely re-exported from a child module (see the `cta-buttons` /\n * `auth` R4 precedent in `../../tsup.config.ts`).\n *\n * See `docs/architecture/cookie-consent.md` for the full design.\n *\n * Deliberately does NOT re-export `CONSENT_CATEGORIES`, `CONSENT_COOKIE_NAME`,\n * `ConsentCategory` or anything else from `../consent-server` (or\n * `../consent-shared`) here — even though every one of those individual names\n * is itself dependency-free. This package's `treeshake: false` build (see the\n * R4 note in `../../tsup.config.ts`) bundles a re-exporting barrel's IMPORTED\n * FILES wholesale, and keeping this barrel's only source-level edge to\n * anything under `../consent-server*` at zero is what makes that provable by\n * `tests/consent-subpath-isolation.test.ts` rather than merely asserted by\n * this comment. A caller that needs the category taxonomy or the cookie name\n * imports them directly from `@reblu/site-next/consent-server`, which stays\n * the one full public surface (client + server share the same values).\n */\nexport {\n ConsentProvider,\n useConsent,\n type ConsentStatus,\n type ConsentContextValue,\n type ConsentProviderProps,\n} from './context'\nexport { ConsentGate, type ConsentGateProps } from './gate'\nexport { ConsentedAnalytics } from './consented-analytics'\nexport {\n CookieBanner,\n CookiePreferencesTrigger,\n type CookieBannerProps,\n type CookiePreferencesTriggerProps,\n} from './banner'\nexport { DEFAULT_CONSENT_BANNER_TEXTS, type ConsentBannerTexts } from './texts'\n","'use client'\n\nimport { createContext, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'\n\nimport {\n CONSENT_CATEGORIES,\n CONSENT_COOKIE_NAME,\n DEFAULT_CONSENT_CATEGORIES,\n parseConsentCookie,\n serializeConsentCookie,\n type ConsentCategory,\n type ConsentCategoryState,\n // Deliberately `../consent-shared`, NEVER `../consent-server`: the latter\n // pulls in `cookiePolicyVersion` and its `node:crypto` import, which must\n // never reach this client entry — see `../consent-shared.ts`'s doc comment.\n} from '../consent-shared'\n\n/**\n * `<ConsentProvider>` / `useConsent()` — the client half of cookie consent\n * (REB-642). See `../consent-shared` for the fail-closed parsing this relies\n * on (imported from there, not `../consent-server`, to keep `node:crypto` out\n * of this client entry), and `docs/architecture/cookie-consent.md` for the\n * full design.\n *\n * `status: 'unknown'` is the DEFAULT-DENIED state and the value every\n * `<ConsentGate>` treats as \"not granted\" — see `./gate.tsx`. It covers three\n * cases identically, on purpose: no cookie yet, a cookie that failed\n * `parseConsentCookie`, and a cookie recorded under a `policyVersion` that no\n * longer matches (the tenant edited the cookie policy — REB-642's \"re-ask when\n * the version changes\"). None of the three may ever resolve to granted.\n */\n\n/** Whether the visitor's choice for the CURRENT policy version is known. */\nexport type ConsentStatus = 'unknown' | 'resolved'\n\nexport interface ConsentContextValue {\n status: ConsentStatus\n categories: ConsentCategoryState\n /** True only when `status === 'resolved'` AND the category was explicitly granted. */\n isGranted: (category: ConsentCategory) => boolean\n /** Grants every category and persists immediately. */\n acceptAll: () => void\n /** Denies every category and persists immediately — the denial persists across reloads. */\n rejectAll: () => void\n /** Persists exactly the categories passed — no category is granted implicitly. */\n savePreferences: (categories: ConsentCategoryState) => void\n preferencesOpen: boolean\n openPreferences: () => void\n closePreferences: () => void\n /**\n * Refocuses whatever element had focus immediately before `openPreferences`\n * was called. The preferences dialog is openable from more than one place\n * (the banner's \"Configure\" button, `<CookiePreferencesTrigger>` elsewhere\n * in the tree) with no single `<Dialog.Trigger>` for Radix's own\n * focus-restore to key off, so this is tracked explicitly here — the one\n * funnel every opener already goes through.\n */\n restoreLastFocus: () => void\n}\n\nconst ConsentContext = createContext<ConsentContextValue | null>(null)\n\nexport interface ConsentProviderProps {\n children: ReactNode\n /**\n * `cookiePolicyVersion(tenant.cookiePolicy)` from the SITE's analytics\n * config, or `null` when no cookie policy is stored. A stored consent\n * recorded under a DIFFERENT version is treated as unresolved.\n */\n policyVersion: string | null\n}\n\n/** 1 year — matches the retention window discussed in the design proposal §9. */\nconst COOKIE_MAX_AGE_SECONDS = 60 * 60 * 24 * 365\n\nfunction readCookie(name: string): string | null {\n if (typeof document === 'undefined') return null\n const match = document.cookie.match(new RegExp(`(?:^|; )${name}=([^;]*)`))\n return match ? decodeURIComponent(match[1]) : null\n}\n\nfunction writeCookie(name: string, value: string) {\n if (typeof document === 'undefined') return\n document.cookie = `${name}=${encodeURIComponent(value)}; path=/; max-age=${COOKIE_MAX_AGE_SECONDS}; SameSite=Lax`\n}\n\nfunction allCategories(granted: boolean): ConsentCategoryState {\n return CONSENT_CATEGORIES.reduce(\n (state, category) => ({ ...state, [category]: granted }),\n {} as ConsentCategoryState,\n )\n}\n\nexport function ConsentProvider({ children, policyVersion }: ConsentProviderProps) {\n const [status, setStatus] = useState<ConsentStatus>('unknown')\n const [categories, setCategories] = useState<ConsentCategoryState>(DEFAULT_CONSENT_CATEGORIES)\n const [preferencesOpen, setPreferencesOpen] = useState(false)\n const lastFocusedRef = useRef<HTMLElement | null>(null)\n\n // Resolve from the cookie on mount, and whenever the tenant's policy version\n // changes (e.g. a client-navigation that re-fetches SITE config). `document`\n // is unavailable during SSR, so this is deliberately an effect, not a\n // useState initializer — the first render is always 'unknown', avoiding a\n // server/client markup mismatch, and resolves one tick later if a valid\n // cookie exists.\n useEffect(() => {\n const parsed = parseConsentCookie(readCookie(CONSENT_COOKIE_NAME))\n if (parsed && parsed.policyVersion === policyVersion) {\n setCategories(parsed.categories)\n setStatus('resolved')\n } else {\n setCategories(DEFAULT_CONSENT_CATEGORIES)\n setStatus('unknown')\n }\n }, [policyVersion])\n\n function persist(next: ConsentCategoryState) {\n writeCookie(CONSENT_COOKIE_NAME, serializeConsentCookie({ categories: next, policyVersion }))\n setCategories(next)\n setStatus('resolved')\n setPreferencesOpen(false)\n }\n\n const value = useMemo<ConsentContextValue>(\n () => ({\n status,\n categories,\n isGranted: (category) => status === 'resolved' && categories[category] === true,\n acceptAll: () => persist(allCategories(true)),\n rejectAll: () => persist(allCategories(false)),\n savePreferences: (next) => persist(next),\n preferencesOpen,\n openPreferences: () => {\n lastFocusedRef.current =\n typeof document !== 'undefined' ? (document.activeElement as HTMLElement | null) : null\n setPreferencesOpen(true)\n },\n closePreferences: () => setPreferencesOpen(false),\n restoreLastFocus: () => lastFocusedRef.current?.focus(),\n }),\n // eslint-disable-next-line react-hooks/exhaustive-deps\n [status, categories, preferencesOpen, policyVersion],\n )\n\n return <ConsentContext.Provider value={value}>{children}</ConsentContext.Provider>\n}\n\n/** Throws outside a `<ConsentProvider>` — a silent default would be a silent consent bypass. */\nexport function useConsent(): ConsentContextValue {\n const ctx = useContext(ConsentContext)\n if (!ctx) throw new Error('useConsent() must be used within a <ConsentProvider>')\n return ctx\n}\n","/**\n * `../consent-shared` — the truly isomorphic core of cookie consent (REB-642):\n * the cookie name, the category taxonomy, and fail-closed parse/serialise.\n * ZERO dependencies, including Node builtins — this is the one thing that\n * matters about this file's existence.\n *\n * ─────────────────────────────────────────────────────────────────────────────\n * WHY THIS IS A SEPARATE FILE FROM `./consent-server.ts`\n * ─────────────────────────────────────────────────────────────────────────────\n *\n * `./consent` (the client entry) needs `CONSENT_CATEGORIES` and friends, and\n * imports them straight from here. `./consent-server.ts` ALSO needs them, plus\n * `cookiePolicyVersion()`, which needs `node:crypto`. Those two facts collide\n * under this package's build rules: `tsup.config.ts` runs with\n * `treeshake: false` — REQUIRED to keep the `'use client'` prologue on the\n * `./consent` entry from being stripped (see the R4 note there) — and with\n * tree-shaking off, esbuild bundles an imported FILE WHOLESALE, not just the\n * specific binding a module asked for. So if `cookiePolicyVersion` (and its\n * `node:crypto` import) lived in the SAME file as `CONSENT_CATEGORIES`, every\n * client component that only wanted the category list would drag `node:crypto`\n * into the browser bundle too — dead weight at best, a broken build on a\n * bundler stricter than tsup about Node builtins in client code at worst. This\n * was caught live: `tests/consent-subpath-isolation.test.ts` asserts `./consent`\n * never references `crypto`, and failed until this split existed.\n *\n * `./consent-server.ts` re-exports everything here, so\n * `@reblu/site-next/consent-server` keeps serving the full public surface —\n * this split is purely internal.\n *\n * A cookie is attacker- and drift-controlled input: a visitor can edit it in\n * devtools, an older SDK version can leave a differently-shaped payload\n * behind, and a proxy or extension can mangle it in transit.\n * `parseConsentCookie` never coerces a doubtful value toward \"granted\" —\n * anything that is not EXACTLY the expected shape (an object, every known\n * category present and a strict boolean, `policyVersion` a string or `null`)\n * resolves to `null`, which `./consent`'s provider treats identically to \"no\n * cookie yet\": ask again, default every category denied. Silently granting a\n * truthy-but-malformed value is the one mistake this module is built to make\n * impossible.\n */\n\n/** The first-party cookie a visitor's consent choice is stored in. */\nexport const CONSENT_COOKIE_NAME = 'reblu_consent'\n\n/**\n * The fixed, non-tenant-editable category taxonomy (REB-642 §7.1): \"If each\n * SITE invents its own categories, no two clinics can be audited the same\n * way.\" `necessary` is deliberately NOT a member — it is not a choice, it has\n * no toggle, and it is never read from or written to the cookie.\n */\nexport const CONSENT_CATEGORIES = ['analytics', 'marketing', 'preferences'] as const\n\n/** One of the fixed consent categories. */\nexport type ConsentCategory = (typeof CONSENT_CATEGORIES)[number]\n\n/** A visitor's granted/denied state for every optional category. */\nexport type ConsentCategoryState = Record<ConsentCategory, boolean>\n\n/** The full payload stored in the consent cookie. */\nexport interface ConsentCookiePayload {\n categories: ConsentCategoryState\n /** The `cookiePolicyVersion()` in force when this choice was recorded, or `null`. */\n policyVersion: string | null\n}\n\n/** Every optional category denied — the only safe default. */\nexport const DEFAULT_CONSENT_CATEGORIES: Readonly<ConsentCategoryState> = Object.freeze(\n CONSENT_CATEGORIES.reduce(\n (state, category) => ({ ...state, [category]: false }),\n {} as ConsentCategoryState,\n ),\n)\n\nfunction isPlainObject(value: unknown): value is Record<string, unknown> {\n return typeof value === 'object' && value !== null && !Array.isArray(value)\n}\n\n/**\n * Reads and validates the categories object, requiring every known category to\n * be present as a STRICT boolean. Returns `null` on any deviation — a missing\n * category, an extra one being the only thing wrong is fine (ignored, never\n * leaked into the result), but a category that is present and not `true`/`false`\n * literally, or one that is simply absent, fails the whole parse closed. A\n * partial payload does NOT default the missing categories to `false`: that\n * would make \"a payload half-stripped by a proxy\" indistinguishable from \"a\n * visitor who explicitly denied the rest\", which is not a distinction this\n * function is in a position to make safely.\n */\nfunction parseCategories(value: unknown): ConsentCategoryState | null {\n if (!isPlainObject(value)) return null\n\n const result = {} as ConsentCategoryState\n for (const category of CONSENT_CATEGORIES) {\n const raw = value[category]\n if (typeof raw !== 'boolean') return null\n result[category] = raw\n }\n return result\n}\n\n/**\n * Parses a raw consent cookie value into a validated payload, or `null` if the\n * value is absent or does not match the expected shape exactly. See the module\n * doc for why this fails closed rather than coercing.\n */\nexport function parseConsentCookie(raw: string | null | undefined): ConsentCookiePayload | null {\n if (!raw) return null\n\n let json: unknown\n try {\n json = JSON.parse(raw)\n } catch {\n return null\n }\n\n if (!isPlainObject(json)) return null\n\n const categories = parseCategories(json.categories)\n if (!categories) return null\n\n const policyVersion = json.policyVersion\n if (policyVersion !== null && typeof policyVersion !== 'string') return null\n\n return { categories, policyVersion }\n}\n\n/** Serialises a consent payload for storage in the cookie. */\nexport function serializeConsentCookie(payload: ConsentCookiePayload): string {\n return JSON.stringify(payload)\n}\n","'use client'\n\nimport type { ReactNode } from 'react'\n\n// Deliberately `../consent-shared`, not `../consent-server` — see that\n// module's doc comment for why `node:crypto` must never reach this client entry.\nimport type { ConsentCategory } from '../consent-shared'\nimport { useConsent } from './context'\n\nexport interface ConsentGateProps {\n category: ConsentCategory\n children: ReactNode\n}\n\n/**\n * `<ConsentGate>` — the allowlist enforced by construction that REB-642's\n * design settles on: a third party is never rendered except through a\n * component that names a consent category, and a denied or UNRESOLVED\n * category renders `null`.\n *\n * The `status !== 'resolved'` branch matters as much as the denied branch: a\n * gate that rendered children while consent is still `'unknown'` would load\n * the third party on every fresh visitor for the split second before their\n * cookie is read, which is exactly the race `docs/architecture/cookie-consent.md`\n * §6.1 designs around by building tags inside a client component instead of\n * passing a server-rendered `<AnalyticsScripts>` through as `children`.\n */\nexport function ConsentGate({ category, children }: ConsentGateProps) {\n const { status, isGranted } = useConsent()\n\n if (status !== 'resolved') return null\n if (!isGranted(category)) return null\n\n return <>{children}</>\n}\n","import Script from 'next/script'\n\nimport { jsonLdScriptContent } from './json-ld'\n\n/**\n * `<AnalyticsScripts>` — safe rendering of tenant-configured analytics tags (REB-617).\n *\n * THE HOLE THIS CLOSES. Every SITE built on this SDK interpolated the tenant's\n * analytics ids straight into the body of an executing `<script>`:\n *\n * gtag('config','${analytics.gaId}');\n * …\"clarity\",\"script\",\"${analytics.clarityId}\");\n *\n * No validation, no escaping. A `gaId` of `x');alert(document.cookie);//` closes\n * the string literal, closes the call, comments out the rest, and runs arbitrary\n * JS on the TENANT'S OWN ORIGIN — on every page including checkout, same origin\n * as the portal session cookie. For a medical tenant that is patient-session\n * territory. It is the same trust boundary as the nexus-authored HTML that\n * REB-613 closed on the sink next door, except this one reached the JS\n * interpreter with no sanitiser anywhere in the path.\n *\n * TWO INDEPENDENT LAYERS, because either alone is one refactor away from failing:\n *\n * 1. VALIDATION — an id that does not match its vendor-documented shape is\n * dropped and NO script renders. Analytics silently does not load; the site\n * keeps working. Failing closed matters more than failing loud here: the\n * value is cosmetic, the sink is not.\n * 2. ESCAPING — `analyticsScriptLiteral` emits the id as a JSON string literal\n * with the `<script>` boundary neutralised, so even a value that skipped\n * layer 1 (a future field with a looser shape, a caller that reaches for the\n * builders directly) is inert DATA rather than code.\n *\n * WHY VALIDATION ALONE IS NOT ENOUGH, and escaping alone is not either: layer 1\n * is a policy that a product decision can widen (someone adds `GT-` ids, or a\n * Meta Pixel field whose shape is numeric-but-unbounded), and layer 2 is a\n * mechanism that cannot express \"this is not a usable analytics id\". Keeping both\n * means widening the policy never reopens the sink.\n *\n * This lives in the SDK, not in `apps/demo-site`, because every external SITE repo\n * consuming `@reblu/site-contracts` carries the identical snippet — publishing the\n * safe component is the only way the fix reaches them.\n */\n\n/**\n * GA4 measurement id: `G-` followed by alphanumeric characters.\n *\n * Vendor source: Google Analytics Help 12270356 (\"formatted as 'G-' followed by\n * alphanumeric characters\", example `G-PSW1MY7HB4`).\n *\n * NO MINIMUM LENGTH, deliberately. The familiar id is ten characters, but Google\n * documents no length at all and its own docs print `gtag('config', 'G-12345')`\n * (Help 11259997) — inventing a floor here would reject an id Google itself\n * publishes. The upper bound is sanity, not security: the `[A-Z0-9]` class already\n * excludes every character that could leave a JS string literal, so length adds\n * nothing to the guarantee.\n *\n * Uppercase only — every documented example is uppercase, and a lowercase variant\n * is not a Google-issued id. Google tag ids also exist as `GT-` and `AW-`\n * (Help 12326985), but this field is specifically the GA4 *measurement id*\n * (the nexus form labels it so, and the SITE reads it as `gaId`); accepting the\n * other prefixes is a product decision, not a security one, and is deliberately\n * left out of this fix.\n */\nexport const GA_MEASUREMENT_ID_PATTERN = /^G-[A-Z0-9]{1,32}$/\n\n/**\n * Microsoft Clarity project id: lowercase alphanumeric.\n *\n * Vendor source: Microsoft Learn `clarity-setup` publishes a live demo project\n * under id `3t0wlogvdz`; every id Microsoft prints is lowercase alphanumeric of\n * about ten characters. Microsoft documents no formal grammar, so — as with GA —\n * the bound is generous and the character class carries the guarantee.\n *\n * RESIDUAL RISK, stated plainly: no vendor page enumerates the legal Clarity\n * alphabet, so this is inferred from published examples. If some tenant holds an\n * id containing a character outside `[a-z0-9]`, this rejects it. That is why the\n * API coerces a non-matching id to `null` instead of erroring — analytics stops\n * loading for that tenant, the site keeps serving, and the misconfiguration is\n * visible and fixable rather than an outage.\n */\nexport const CLARITY_PROJECT_ID_PATTERN = /^[a-z0-9]{1,32}$/\n\n/** True only for a string matching the vendor-documented GA4 measurement id shape. */\nexport function isValidGaMeasurementId(value: unknown): value is string {\n return typeof value === 'string' && GA_MEASUREMENT_ID_PATTERN.test(value)\n}\n\n/** True only for a string matching the vendor-documented Clarity project id shape. */\nexport function isValidClarityProjectId(value: unknown): value is string {\n return typeof value === 'string' && CLARITY_PROJECT_ID_PATTERN.test(value)\n}\n\n/**\n * Serialises `id` as a JS string literal safe to embed in a `<script>` body.\n *\n * Reuses `jsonLdScriptContent` rather than growing a second escaper: a JSON string\n * literal IS a valid JS string literal, and that helper already does the two things\n * required here — `JSON.stringify` for the quote/backslash/newline boundary, plus\n * `<` `>` `&` U+2028 U+2029 escaping for the HTML raw-text boundary that\n * `JSON.stringify` leaves wide open. One audited primitive, one place to fix.\n */\nexport function analyticsScriptLiteral(id: string): string {\n return jsonLdScriptContent(id)\n}\n\n/** Body of the GA4 bootstrap script. Always escapes; never trusts its caller. */\nexport function gaInitScript(gaId: string): string {\n return `window.dataLayer=window.dataLayer||[];function gtag(){dataLayer.push(arguments);}gtag('js',new Date());gtag('config',${analyticsScriptLiteral(gaId)});`\n}\n\n/** Body of the Microsoft Clarity bootstrap script. Always escapes; never trusts its caller. */\nexport function clarityInitScript(clarityId: string): string {\n return `(function(c,l,a,r,i,t,y){c[a]=c[a]||function(){(c[a].q=c[a].q||[]).push(arguments)};t=l.createElement(r);t.async=1;t.src=\"https://www.clarity.ms/tag/\"+i;y=l.getElementsByTagName(r)[0];y.parentNode.insertBefore(t,y)})(window,document,\"clarity\",\"script\",${analyticsScriptLiteral(clarityId)});`\n}\n\nexport interface AnalyticsScriptsProps {\n /** GA4 measurement id from the SITE analytics contract. Ignored unless it matches `G-…`. */\n gaId?: string | null\n /** Microsoft Clarity project id from the SITE analytics contract. Ignored unless lowercase alphanumeric. */\n clarityId?: string | null\n}\n\n/**\n * Renders the Google Analytics and Microsoft Clarity tags for a tenant, dropping\n * any id that does not match its vendor-documented shape.\n */\nexport function AnalyticsScripts({ gaId, clarityId }: AnalyticsScriptsProps) {\n const safeGaId = isValidGaMeasurementId(gaId) ? gaId : null\n const safeClarityId = isValidClarityProjectId(clarityId) ? clarityId : null\n\n if (!safeGaId && !safeClarityId) return null\n\n return (\n <>\n {safeGaId && (\n <>\n <Script\n src={`https://www.googletagmanager.com/gtag/js?id=${encodeURIComponent(safeGaId)}`}\n strategy='afterInteractive'\n />\n <Script\n id='ga-init'\n strategy='afterInteractive'\n dangerouslySetInnerHTML={{ __html: gaInitScript(safeGaId) }}\n />\n </>\n )}\n\n {safeClarityId && (\n <Script\n id='clarity-init'\n strategy='afterInteractive'\n dangerouslySetInnerHTML={{ __html: clarityInitScript(safeClarityId) }}\n />\n )}\n </>\n )\n}\n","/**\n * `<JsonLd>` — safe `application/ld+json` script renderer (REB-622).\n *\n * A `<script>` element is HTML's \"raw text\" context: the parser closes it on the\n * first case-insensitive `</script` byte sequence it sees, no matter whether\n * that sequence sits inside a JSON string. `JSON.stringify` escapes for JSON\n * syntax, not for this HTML boundary, so it leaves `<` verbatim — turning any\n * JSON-LD payload built from tenant-authored data (page title, description,\n * FAQ copy, …) into a stored-XSS sink: a value containing\n * `</script><img src=x onerror=alert(1)>` breaks out of the element and runs.\n *\n * `jsonLdScriptContent` neutralises exactly that boundary while staying\n * byte-for-byte valid JSON: `<` / `>` / `&` are standard JSON\n * character escapes, so `JSON.parse` recovers the original value unchanged —\n * this is an ESCAPER, not a strip/allowlist sanitiser (see `sanitizeHtml` in\n * `@reblu/site-blocks` for that job, at the HTML boundary rather than the\n * script boundary). U+2028/U+2029 are escaped too: both are valid inside a\n * JSON string but are raw JS line terminators, and some `<script>` consumers\n * (older JS parsers, some static analysers) choke on them unescaped.\n *\n * This package, not `@reblu/site-blocks`, is the home for it: `site-blocks`'s\n * main entry bundles `parse5` + the `hast-util-*` chain + `marked` for\n * `sanitizeHtml` with `treeshake: false` — dist/index.js is ~463 KB, and PR #848\n * spent a full round removing exactly that weight (89 KB gzipped) from a client\n * bundle that only needed one HTML-escaping helper. `<JsonLd>` is Next-specific\n * (App Router's `suppressHydrationWarning` pattern) and this 40-line escaper has\n * zero dependencies of its own, so it ships as its OWN tsup entry\n * (`@reblu/site-next/json-ld`) rather than folding into the root `index` barrel\n * — importing it must never drag in `CtaButtons`/`EditableCta`'s `next/link` +\n * `@reblu/site-ui` graph, the same isolation `./cache` and `./route-handlers`\n * already use for the same reason.\n *\n * @see https://nextjs.org/docs/app/guides/json-ld\n */\n\nimport type { ConfigOpeningHours, ConfigOpeningHoursDay } from '@reblu/site-contracts'\n\nconst SCRIPT_BOUNDARY_UNSAFE = /[<>&\\u2028\\u2029]/g\n\nconst ESCAPES: Record<string, string> = {\n '<': '\\\\u003c',\n '>': '\\\\u003e',\n '&': '\\\\u0026',\n '\\u2028': '\\\\u2028',\n '\\u2029': '\\\\u2029',\n}\n\n/**\n * Serialises `value` for embedding inside `<script type=\"application/ld+json\">`.\n * Escapes `<`, `>`, `&`, U+2028 and U+2029 — every escape is a standard JSON\n * character escape, so `JSON.parse(jsonLdScriptContent(value))` always recovers\n * `value` unchanged. This only ever ADDS escaping; it never strips or rewrites\n * data, unlike an HTML sanitiser.\n *\n * `JSON.stringify(undefined)` returns the JS value `undefined`, not a string\n * (`undefined` has no JSON representation), which would otherwise throw here.\n * `data` is typed `unknown` with zero friction toward `undefined`, so this\n * falls back to the JSON literal `'null'` rather than crashing an entire page\n * render over one missing field.\n */\nexport function jsonLdScriptContent(value: unknown): string {\n const json = JSON.stringify(value) ?? 'null'\n return json.replace(SCRIPT_BOUNDARY_UNSAFE, (char) => ESCAPES[char])\n}\n\nexport interface JsonLdProps {\n /** Suffix for the rendered `<script>` id: `json-ld-${id}`. */\n id: string\n /** Structured data to serialise. Any JSON-serialisable value. */\n data: unknown\n}\n\n/**\n * Renders one `application/ld+json` `<script>` tag using the official Next.js\n * App Router pattern. `suppressHydrationWarning` is required here: React warns\n * on a server/client `textContent` mismatch for this element even though there\n * is no real divergence — `dangerouslySetInnerHTML` content is opaque to React's\n * hydration diff.\n */\nexport function JsonLd({ id, data }: JsonLdProps) {\n return (\n <script\n id={`json-ld-${id}`}\n type='application/ld+json'\n suppressHydrationWarning\n dangerouslySetInnerHTML={{ __html: jsonLdScriptContent(data) }}\n />\n )\n}\n\n// ─── Opening hours → schema.org OpeningHoursSpecification (REB-826) ──────────\n\n/** schema.org `OpeningHoursSpecification`, the shape a `LocalBusiness`/\n * `MedicalOrganization`/etc. entity's `openingHoursSpecification` array holds.\n * @see https://schema.org/OpeningHoursSpecification\n */\nexport interface OpeningHoursSpecification {\n '@type': 'OpeningHoursSpecification'\n dayOfWeek: string[]\n opens: string\n closes: string\n}\n\n/** schema.org `DayOfWeek` URIs, keyed by the Reblu `ConfigOpeningHoursDay` enum. */\nconst SCHEMA_DAY_OF_WEEK: Record<ConfigOpeningHoursDay, string> = {\n MON: 'https://schema.org/Monday',\n TUE: 'https://schema.org/Tuesday',\n WED: 'https://schema.org/Wednesday',\n THU: 'https://schema.org/Thursday',\n FRI: 'https://schema.org/Friday',\n SAT: 'https://schema.org/Saturday',\n SUN: 'https://schema.org/Sunday',\n}\n\n/**\n * Maps the Reblu `Config.openingHours` typed value to one\n * `OpeningHoursSpecification` object per range, each with its `days` turned\n * into schema.org `DayOfWeek` URIs. One range = one spec object; this does\n * NOT group or reformat for display (see `formatOpeningHours` in\n * `@reblu/site-next/opening-hours` for that) — structured data wants one\n * entry per machine-readable (days, opens, closes) tuple, not prose.\n *\n * Returns `undefined` — not `[]` — for \"nothing to merge\" (no typed hours, or\n * a typed value with zero ranges), so a caller building a JSON-LD entity with\n * `entity.openingHoursSpecification = toOpeningHoursSpecification(...)` can\n * `if (spec)` guard it without emitting an empty array into the graph.\n *\n * Used both by SITE builds assembling their own JSON-LD (the published use\n * case this SDK exists for) and by base-api's own structured-data builder —\n * see `generate-structured-data.ts`. Unlike `cookiePolicyVersion`\n * (`@reblu/site-next/consent-server`), this is NOT duplicated into base-api:\n * that duplication exists only where two independently-deployed sides (a\n * SITE's stored cookie vs. base-api's request-time check) must each derive\n * the identical value without talking to each other. Here base-api is the\n * ONLY side computing this value — there is no second computation to agree\n * with — so importing the single implementation is strictly better than a\n * second copy that could drift.\n *\n * Defensive against a malformed stored value — `Config.openingHours` is a\n * compile-time guarantee only. A range whose `days` is empty, or whose codes\n * are all unrecognised (outside `SCHEMA_DAY_OF_WEEK`'s keys), is dropped\n * entirely rather than emitting a spec object with an empty `dayOfWeek` (or\n * one padded with `undefined` for every unrecognised code). If every range\n * is dropped this way, the result is `undefined`, never `[]`.\n */\nexport function toOpeningHoursSpecification(\n openingHours: ConfigOpeningHours | null | undefined,\n): OpeningHoursSpecification[] | undefined {\n if (!openingHours || openingHours.ranges.length === 0) return undefined\n const specs: OpeningHoursSpecification[] = []\n for (const range of openingHours.ranges) {\n const dayOfWeek = range.days\n // `Object.hasOwn`, not `day in SCHEMA_DAY_OF_WEEK`: the `in` operator\n // also matches INHERITED properties (`'constructor'`, `'toString'`,\n // `'hasOwnProperty'`, …), which are not own keys of this lookup object\n // but would still pass an `in` check and resolve to a function, not a\n // schema.org URI string — corrupting `dayOfWeek` with exactly the kind\n // of malformed entry this filtering exists to prevent.\n .filter((day): day is ConfigOpeningHoursDay => Object.hasOwn(SCHEMA_DAY_OF_WEEK, day))\n .map((day) => SCHEMA_DAY_OF_WEEK[day])\n if (dayOfWeek.length === 0) continue\n specs.push({\n '@type': 'OpeningHoursSpecification',\n dayOfWeek,\n opens: range.opens,\n closes: range.closes,\n })\n }\n return specs.length > 0 ? specs : undefined\n}\n","'use client'\n\nimport { AnalyticsScripts, type AnalyticsScriptsProps } from '../analytics'\nimport { ConsentGate } from './gate'\n\n/**\n * `<ConsentedAnalytics>` — the `'analytics'`-gated replacement for rendering\n * `<AnalyticsScripts>` directly in a SITE layout (REB-642 Slice 1).\n *\n * Reuses the REB-617 validation + escaping builders verbatim via\n * `<AnalyticsScripts>` — this component adds NOTHING to the sink-safety\n * argument in `../analytics.tsx`, it only decides WHETHER that already-safe\n * renderer mounts at all. A SITE layout that still imports\n * `@reblu/site-next/analytics` directly bypasses consent entirely, which is\n * exactly what the source-scanning guard (REB-642 Slice 1, demo-site) exists\n * to catch — this component is the mechanism that guard enforces.\n */\nexport function ConsentedAnalytics(props: AnalyticsScriptsProps) {\n return (\n <ConsentGate category='analytics'>\n <AnalyticsScripts {...props} />\n </ConsentGate>\n )\n}\n","'use client'\n\nimport { useEffect, useState, type ReactNode } from 'react'\nimport * as Dialog from '@radix-ui/react-dialog'\nimport { Button, cn } from '@reblu/site-ui'\n\n// Deliberately `../consent-shared`, not `../consent-server` — see that\n// module's doc comment for why `node:crypto` must never reach this client entry.\nimport { CONSENT_CATEGORIES, type ConsentCategoryState } from '../consent-shared'\nimport { useConsent } from './context'\nimport { DEFAULT_CONSENT_BANNER_TEXTS, type ConsentBannerTexts } from './texts'\n\n/**\n * `<CookieBanner>` + `<CookiePreferencesTrigger>` — the visible layer of\n * REB-642's consent mechanism.\n *\n * ─────────────────────────────────────────────────────────────────────────────\n * ARCHITECTURE: ONE DIALOG, TWO OPENERS\n * ─────────────────────────────────────────────────────────────────────────────\n *\n * `<CookieBanner>` owns rendering the preferences dialog (mount it ONCE, at\n * the root of the SITE layout — it portals to `<body>` regardless of where it\n * is mounted). `<CookiePreferencesTrigger>` is a small, unstyled-by-default\n * button usable ANYWHERE ELSE in the tree (a footer link, a settings page) that\n * opens the SAME dialog via `useConsent().openPreferences()`. Both read the\n * single `preferencesOpen` boolean the `<ConsentProvider>` owns, so there is\n * never more than one dialog instance and never two focus traps competing.\n *\n * This is what REB-642 calls \"permanent withdrawal\" — a visitor's route back\n * to their preferences does not depend on the first-layer banner still being\n * on screen; the banner disappears once resolved, the trigger does not.\n *\n * ─────────────────────────────────────────────────────────────────────────────\n * THE THREE-BUTTON RULE (owner decision, not configurable)\n * ─────────────────────────────────────────────────────────────────────────────\n *\n * Reject / Configure / Accept render as three same-variant, same-size buttons\n * in that visual order — no button is styled as more prominent than another.\n * A tenant can override `title`/`description` text (REB-642 §7.1 recommends\n * v1 ship with NO tenant-editable copy at all, but the prop exists so a later\n * slice can add it without touching this component's contract); the buttons,\n * their order, and the category taxonomy are fixed.\n */\n\nexport interface CookieBannerProps {\n /** Overrides merged onto `DEFAULT_CONSENT_BANNER_TEXTS`. */\n texts?: Partial<ConsentBannerTexts>\n /** Where \"Política de cookies\" links to. */\n policyHref?: string\n}\n\nfunction resolveTexts(overrides?: Partial<ConsentBannerTexts>): ConsentBannerTexts {\n return { ...DEFAULT_CONSENT_BANNER_TEXTS, ...overrides }\n}\n\nexport function CookieBanner({ texts: textsOverride, policyHref = '/cookies' }: CookieBannerProps) {\n const { status, acceptAll, rejectAll, openPreferences } = useConsent()\n const texts = resolveTexts(textsOverride)\n\n return (\n <>\n {status === 'unknown' && (\n <div\n role='region'\n aria-label={texts.title}\n className='fixed inset-x-0 bottom-0 z-50 border-t border-border bg-background/95 px-4 py-4 backdrop-blur supports-[backdrop-filter]:bg-background/80'\n data-slot='cookie-banner'\n >\n <div className='mx-auto flex max-w-4xl flex-col gap-3 sm:flex-row sm:items-center sm:justify-between'>\n <div className='text-sm'>\n <p className='font-semibold'>{texts.title}</p>\n <p className='text-muted-foreground'>\n {texts.description}{' '}\n <a href={policyHref} className='underline underline-offset-2'>\n {texts.policyLinkLabel}\n </a>\n </p>\n </div>\n {/* Three equal-weight buttons, fixed order: reject, configure, accept.\n Same variant/size for all three — rejecting must cost exactly what\n accepting costs (AEPD first-layer requirement, REB-642). */}\n <div className='flex shrink-0 flex-wrap gap-2'>\n <Button type='button' variant='outline' size='sm' onClick={rejectAll}>\n {texts.rejectLabel}\n </Button>\n <Button type='button' variant='outline' size='sm' onClick={openPreferences}>\n {texts.manageLabel}\n </Button>\n <Button type='button' variant='outline' size='sm' onClick={acceptAll}>\n {texts.acceptLabel}\n </Button>\n </div>\n </div>\n </div>\n )}\n <ConsentPreferencesDialog texts={texts} />\n </>\n )\n}\n\nexport interface CookiePreferencesTriggerProps {\n children?: ReactNode\n className?: string\n texts?: Partial<ConsentBannerTexts>\n}\n\n/**\n * A persistent \"configure cookies\" entry point, safe to place anywhere in the\n * tree (typically a footer link) once `<CookieBanner>` is mounted at the root.\n * Opens the same preferences dialog `<CookieBanner>` owns.\n */\nexport function CookiePreferencesTrigger({\n children,\n className,\n texts: textsOverride,\n}: CookiePreferencesTriggerProps) {\n const { openPreferences } = useConsent()\n const texts = resolveTexts(textsOverride)\n\n return (\n <button type='button' onClick={openPreferences} className={className} data-slot='cookie-preferences-trigger'>\n {children ?? `${texts.manageLabel} cookies`}\n </button>\n )\n}\n\ninterface ConsentPreferencesDialogProps {\n texts: ConsentBannerTexts\n}\n\n/**\n * The second layer — a per-category preferences panel. `necessary` is\n * rendered as an always-checked, disabled checkbox for transparency (a\n * visitor can SEE that necessary cookies exist and why) without offering a\n * toggle that has no real effect, which would be dishonest UI.\n */\nfunction ConsentPreferencesDialog({ texts }: ConsentPreferencesDialogProps) {\n const { categories, preferencesOpen, closePreferences, savePreferences, restoreLastFocus } = useConsent()\n const [draft, setDraft] = useState<ConsentCategoryState>(categories)\n\n // Re-seed the draft from the last-saved categories every time the dialog\n // opens, so stale unsaved toggles from a previous open are never carried\n // into the next one.\n useEffect(() => {\n if (preferencesOpen) setDraft(categories)\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [preferencesOpen])\n\n return (\n <Dialog.Root\n open={preferencesOpen}\n onOpenChange={(open) => {\n // Radix only calls this for state changes IT initiates (Escape,\n // overlay click, `<Dialog.Close>`) — opening is entirely driven by\n // `useConsent().openPreferences()` from outside, which is also where\n // focus is captured (see `../consent/context.tsx`). So this handler\n // only ever needs to handle the closing transition.\n if (!open) closePreferences()\n }}\n >\n <Dialog.Portal>\n <Dialog.Overlay className='fixed inset-0 z-50 bg-black/50' />\n <Dialog.Content\n className='fixed left-1/2 top-1/2 z-50 w-full max-w-md -translate-x-1/2 -translate-y-1/2 rounded-lg border border-border bg-background p-6 shadow-lg'\n data-slot='cookie-preferences-dialog'\n onCloseAutoFocus={(event) => {\n event.preventDefault()\n restoreLastFocus()\n }}\n >\n <Dialog.Title className='text-base font-semibold'>{texts.preferencesTitle}</Dialog.Title>\n <Dialog.Description className='mt-1 text-sm text-muted-foreground'>\n {texts.preferencesDescription}\n </Dialog.Description>\n\n <div className='mt-4 flex flex-col gap-3'>\n <CategoryRow\n id='consent-category-necessary'\n label={texts.necessaryLabel}\n description={texts.necessaryDescription}\n checked\n disabled\n onChange={() => undefined}\n />\n {CONSENT_CATEGORIES.map((category) => (\n <CategoryRow\n key={category}\n id={`consent-category-${category}`}\n label={texts.categoryLabels[category]}\n description={texts.categoryDescriptions[category]}\n checked={draft[category]}\n disabled={false}\n onChange={(checked) => setDraft((prev) => ({ ...prev, [category]: checked }))}\n />\n ))}\n </div>\n\n <div className='mt-6 flex justify-end'>\n <Button type='button' size='sm' onClick={() => savePreferences(draft)}>\n {texts.saveLabel}\n </Button>\n </div>\n </Dialog.Content>\n </Dialog.Portal>\n </Dialog.Root>\n )\n}\n\ninterface CategoryRowProps {\n id: string\n label: string\n description: string\n checked: boolean\n disabled: boolean\n onChange: (checked: boolean) => void\n}\n\nfunction CategoryRow({ id, label, description, checked, disabled, onChange }: CategoryRowProps) {\n return (\n <div className={cn('flex items-start gap-3', disabled && 'opacity-70')}>\n <input\n id={id}\n type='checkbox'\n checked={checked}\n disabled={disabled}\n onChange={(event) => onChange(event.target.checked)}\n className='mt-1 size-4'\n />\n <label htmlFor={id} className='flex flex-col text-sm'>\n <span className='font-medium'>{label}</span>\n <span className='text-muted-foreground'>{description}</span>\n </label>\n </div>\n )\n}\n","import type { ConsentCategory } from '../consent-shared'\n\n/**\n * Copy for `<CookieBanner>`. SDK-owned defaults in Spanish (every Reblu SITE\n * renders `<html lang='es'>` today).\n *\n * REB-642 §7.1 recommends tenant-editable copy be limited to a TITLE/BODY\n * override for v1 — the per-category descriptions, the \"necessary\" copy and\n * every button label stay fixed, because the copy is a compliance surface: a\n * tenant writing \"we use cookies — OK\" with no reject option is the exact\n * misconfiguration this design exists to prevent. This type is intentionally\n * a `Partial<ConsentBannerTexts>` at the call site (see `CookieBanner`), so a\n * later tenant-configuration slice can override `title`/`description` without\n * touching this component's contract.\n */\nexport interface ConsentBannerTexts {\n title: string\n description: string\n acceptLabel: string\n rejectLabel: string\n manageLabel: string\n policyLinkLabel: string\n preferencesTitle: string\n preferencesDescription: string\n saveLabel: string\n necessaryLabel: string\n necessaryDescription: string\n categoryLabels: Record<ConsentCategory, string>\n categoryDescriptions: Record<ConsentCategory, string>\n}\n\nexport const DEFAULT_CONSENT_BANNER_TEXTS: ConsentBannerTexts = {\n title: 'Utilizamos cookies',\n description:\n 'Usamos cookies estrictamente necesarias para el funcionamiento del sitio. Con tu permiso, también usaríamos cookies de analítica y marketing para mejorar tu experiencia.',\n acceptLabel: 'Aceptar todas',\n rejectLabel: 'Rechazar todas',\n manageLabel: 'Configurar',\n policyLinkLabel: 'Política de cookies',\n preferencesTitle: 'Preferencias de cookies',\n preferencesDescription:\n 'Elige qué categorías de cookies opcionales permites. Las cookies necesarias no se pueden desactivar.',\n saveLabel: 'Guardar preferencias',\n necessaryLabel: 'Necesarias',\n necessaryDescription:\n 'Imprescindibles para el funcionamiento del sitio, como la seguridad del formulario de contacto. Siempre activas.',\n categoryLabels: {\n analytics: 'Analítica',\n marketing: 'Marketing',\n preferences: 'Preferencias',\n },\n categoryDescriptions: {\n analytics: 'Nos ayudan a entender cómo se usa el sitio (por ejemplo, Google Analytics o Microsoft Clarity).',\n marketing: 'Permiten mostrar contenido relevante de terceros (por ejemplo, Meta Pixel).',\n preferences: 'Recuerdan tus preferencias de navegación entre visitas.',\n },\n}\n"],"mappings":"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","names":["import_jsx_runtime","import_jsx_runtime","import_jsx_runtime","Script","import_jsx_runtime","import_react","import_jsx_runtime"]}
1
+ {"version":3,"sources":["../src/consent/index.ts","../src/consent/context.tsx","../src/consent-shared.ts","../src/consent/gate.tsx","../src/analytics.tsx","../src/json-ld.tsx","../src/consent/consented-analytics.tsx","../src/consent/banner.tsx","../src/consent/texts.ts"],"sourcesContent":["'use client'\n\n/**\n * `@reblu/site-next/consent` — REB-642's consent mechanism.\n *\n * This barrel MUST begin with its OWN leading `'use client'` directive: tsup\n * only preserves a directive prologue declared by the entry file itself, not\n * one merely re-exported from a child module (see the `cta-buttons` /\n * `auth` R4 precedent in `../../tsup.config.ts`).\n *\n * See `docs/architecture/cookie-consent.md` for the full design.\n *\n * Deliberately does NOT re-export `CONSENT_CATEGORIES`, `CONSENT_COOKIE_NAME`,\n * `ConsentCategory` or anything else from `../consent-server` (or\n * `../consent-shared`) here — even though every one of those individual names\n * is itself dependency-free. This package's `treeshake: false` build (see the\n * R4 note in `../../tsup.config.ts`) bundles a re-exporting barrel's IMPORTED\n * FILES wholesale, and keeping this barrel's only source-level edge to\n * anything under `../consent-server*` at zero is what makes that provable by\n * `tests/consent-subpath-isolation.test.ts` rather than merely asserted by\n * this comment. A caller that needs the category taxonomy or the cookie name\n * imports them directly from `@reblu/site-next/consent-server`, which stays\n * the one full public surface (client + server share the same values).\n */\nexport {\n ConsentProvider,\n useConsent,\n type ConsentStatus,\n type ConsentContextValue,\n type ConsentProviderProps,\n} from './context'\nexport { ConsentGate, type ConsentGateProps } from './gate'\nexport { ConsentedAnalytics } from './consented-analytics'\nexport {\n CookieBanner,\n CookiePreferencesTrigger,\n type CookieBannerProps,\n type CookiePreferencesTriggerProps,\n} from './banner'\nexport {\n DEFAULT_CONSENT_BANNER_TEXTS,\n mergeConsentBannerTexts,\n cookieBannerToConsentTexts,\n type ConsentBannerTexts,\n} from './texts'\n","'use client'\n\nimport { createContext, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'\n\nimport {\n CONSENT_CATEGORIES,\n CONSENT_COOKIE_NAME,\n DEFAULT_CONSENT_CATEGORIES,\n parseConsentCookie,\n serializeConsentCookie,\n type ConsentCategory,\n type ConsentCategoryState,\n // Deliberately `../consent-shared`, NEVER `../consent-server`: the latter\n // pulls in `cookiePolicyVersion` and its `node:crypto` import, which must\n // never reach this client entry — see `../consent-shared.ts`'s doc comment.\n} from '../consent-shared'\n\n/**\n * `<ConsentProvider>` / `useConsent()` — the client half of cookie consent\n * (REB-642). See `../consent-shared` for the fail-closed parsing this relies\n * on (imported from there, not `../consent-server`, to keep `node:crypto` out\n * of this client entry), and `docs/architecture/cookie-consent.md` for the\n * full design.\n *\n * `status: 'unknown'` is the DEFAULT-DENIED state and the value every\n * `<ConsentGate>` treats as \"not granted\" — see `./gate.tsx`. It covers three\n * cases identically, on purpose: no cookie yet, a cookie that failed\n * `parseConsentCookie`, and a cookie recorded under a `policyVersion` that no\n * longer matches (the tenant edited the cookie policy — REB-642's \"re-ask when\n * the version changes\"). None of the three may ever resolve to granted.\n */\n\n/** Whether the visitor's choice for the CURRENT policy version is known. */\nexport type ConsentStatus = 'unknown' | 'resolved'\n\nexport interface ConsentContextValue {\n status: ConsentStatus\n categories: ConsentCategoryState\n /** True only when `status === 'resolved'` AND the category was explicitly granted. */\n isGranted: (category: ConsentCategory) => boolean\n /** Grants every category and persists immediately. */\n acceptAll: () => void\n /** Denies every category and persists immediately — the denial persists across reloads. */\n rejectAll: () => void\n /** Persists exactly the categories passed — no category is granted implicitly. */\n savePreferences: (categories: ConsentCategoryState) => void\n preferencesOpen: boolean\n openPreferences: () => void\n closePreferences: () => void\n /**\n * Refocuses whatever element had focus immediately before `openPreferences`\n * was called. The preferences dialog is openable from more than one place\n * (the banner's \"Configure\" button, `<CookiePreferencesTrigger>` elsewhere\n * in the tree) with no single `<Dialog.Trigger>` for Radix's own\n * focus-restore to key off, so this is tracked explicitly here — the one\n * funnel every opener already goes through.\n */\n restoreLastFocus: () => void\n}\n\nconst ConsentContext = createContext<ConsentContextValue | null>(null)\n\nexport interface ConsentProviderProps {\n children: ReactNode\n /**\n * `cookiePolicyVersion(tenant.cookiePolicy)` from the SITE's analytics\n * config, or `null` when no cookie policy is stored. A stored consent\n * recorded under a DIFFERENT version is treated as unresolved.\n */\n policyVersion: string | null\n}\n\n/** 1 year — matches the retention window discussed in the design proposal §9. */\nconst COOKIE_MAX_AGE_SECONDS = 60 * 60 * 24 * 365\n\nfunction readCookie(name: string): string | null {\n if (typeof document === 'undefined') return null\n const match = document.cookie.match(new RegExp(`(?:^|; )${name}=([^;]*)`))\n return match ? decodeURIComponent(match[1]) : null\n}\n\nfunction writeCookie(name: string, value: string) {\n if (typeof document === 'undefined') return\n document.cookie = `${name}=${encodeURIComponent(value)}; path=/; max-age=${COOKIE_MAX_AGE_SECONDS}; SameSite=Lax`\n}\n\nfunction allCategories(granted: boolean): ConsentCategoryState {\n return CONSENT_CATEGORIES.reduce(\n (state, category) => ({ ...state, [category]: granted }),\n {} as ConsentCategoryState,\n )\n}\n\nexport function ConsentProvider({ children, policyVersion }: ConsentProviderProps) {\n const [status, setStatus] = useState<ConsentStatus>('unknown')\n const [categories, setCategories] = useState<ConsentCategoryState>(DEFAULT_CONSENT_CATEGORIES)\n const [preferencesOpen, setPreferencesOpen] = useState(false)\n const lastFocusedRef = useRef<HTMLElement | null>(null)\n\n // Resolve from the cookie on mount, and whenever the tenant's policy version\n // changes (e.g. a client-navigation that re-fetches SITE config). `document`\n // is unavailable during SSR, so this is deliberately an effect, not a\n // useState initializer — the first render is always 'unknown', avoiding a\n // server/client markup mismatch, and resolves one tick later if a valid\n // cookie exists.\n useEffect(() => {\n const parsed = parseConsentCookie(readCookie(CONSENT_COOKIE_NAME))\n if (parsed && parsed.policyVersion === policyVersion) {\n setCategories(parsed.categories)\n setStatus('resolved')\n } else {\n setCategories(DEFAULT_CONSENT_CATEGORIES)\n setStatus('unknown')\n }\n }, [policyVersion])\n\n function persist(next: ConsentCategoryState) {\n writeCookie(CONSENT_COOKIE_NAME, serializeConsentCookie({ categories: next, policyVersion }))\n setCategories(next)\n setStatus('resolved')\n setPreferencesOpen(false)\n }\n\n const value = useMemo<ConsentContextValue>(\n () => ({\n status,\n categories,\n isGranted: (category) => status === 'resolved' && categories[category] === true,\n acceptAll: () => persist(allCategories(true)),\n rejectAll: () => persist(allCategories(false)),\n savePreferences: (next) => persist(next),\n preferencesOpen,\n openPreferences: () => {\n lastFocusedRef.current =\n typeof document !== 'undefined' ? (document.activeElement as HTMLElement | null) : null\n setPreferencesOpen(true)\n },\n closePreferences: () => setPreferencesOpen(false),\n restoreLastFocus: () => lastFocusedRef.current?.focus(),\n }),\n // eslint-disable-next-line react-hooks/exhaustive-deps\n [status, categories, preferencesOpen, policyVersion],\n )\n\n return <ConsentContext.Provider value={value}>{children}</ConsentContext.Provider>\n}\n\n/** Throws outside a `<ConsentProvider>` — a silent default would be a silent consent bypass. */\nexport function useConsent(): ConsentContextValue {\n const ctx = useContext(ConsentContext)\n if (!ctx) throw new Error('useConsent() must be used within a <ConsentProvider>')\n return ctx\n}\n","/**\n * `../consent-shared` — the truly isomorphic core of cookie consent (REB-642):\n * the cookie name, the category taxonomy, and fail-closed parse/serialise.\n * ZERO dependencies, including Node builtins — this is the one thing that\n * matters about this file's existence.\n *\n * ─────────────────────────────────────────────────────────────────────────────\n * WHY THIS IS A SEPARATE FILE FROM `./consent-server.ts`\n * ─────────────────────────────────────────────────────────────────────────────\n *\n * `./consent` (the client entry) needs `CONSENT_CATEGORIES` and friends, and\n * imports them straight from here. `./consent-server.ts` ALSO needs them, plus\n * `cookiePolicyVersion()`, which needs `node:crypto`. Those two facts collide\n * under this package's build rules: `tsup.config.ts` runs with\n * `treeshake: false` — REQUIRED to keep the `'use client'` prologue on the\n * `./consent` entry from being stripped (see the R4 note there) — and with\n * tree-shaking off, esbuild bundles an imported FILE WHOLESALE, not just the\n * specific binding a module asked for. So if `cookiePolicyVersion` (and its\n * `node:crypto` import) lived in the SAME file as `CONSENT_CATEGORIES`, every\n * client component that only wanted the category list would drag `node:crypto`\n * into the browser bundle too — dead weight at best, a broken build on a\n * bundler stricter than tsup about Node builtins in client code at worst. This\n * was caught live: `tests/consent-subpath-isolation.test.ts` asserts `./consent`\n * never references `crypto`, and failed until this split existed.\n *\n * `./consent-server.ts` re-exports everything here, so\n * `@reblu/site-next/consent-server` keeps serving the full public surface —\n * this split is purely internal.\n *\n * A cookie is attacker- and drift-controlled input: a visitor can edit it in\n * devtools, an older SDK version can leave a differently-shaped payload\n * behind, and a proxy or extension can mangle it in transit.\n * `parseConsentCookie` never coerces a doubtful value toward \"granted\" —\n * anything that is not EXACTLY the expected shape (an object, every known\n * category present and a strict boolean, `policyVersion` a string or `null`)\n * resolves to `null`, which `./consent`'s provider treats identically to \"no\n * cookie yet\": ask again, default every category denied. Silently granting a\n * truthy-but-malformed value is the one mistake this module is built to make\n * impossible.\n */\n\n/** The first-party cookie a visitor's consent choice is stored in. */\nexport const CONSENT_COOKIE_NAME = 'reblu_consent'\n\n/**\n * The fixed, non-tenant-editable category taxonomy (REB-642 §7.1): \"If each\n * SITE invents its own categories, no two clinics can be audited the same\n * way.\" `necessary` is deliberately NOT a member — it is not a choice, it has\n * no toggle, and it is never read from or written to the cookie.\n */\nexport const CONSENT_CATEGORIES = ['analytics', 'marketing', 'preferences'] as const\n\n/** One of the fixed consent categories. */\nexport type ConsentCategory = (typeof CONSENT_CATEGORIES)[number]\n\n/** A visitor's granted/denied state for every optional category. */\nexport type ConsentCategoryState = Record<ConsentCategory, boolean>\n\n/** The full payload stored in the consent cookie. */\nexport interface ConsentCookiePayload {\n categories: ConsentCategoryState\n /** The `cookiePolicyVersion()` in force when this choice was recorded, or `null`. */\n policyVersion: string | null\n}\n\n/** Every optional category denied — the only safe default. */\nexport const DEFAULT_CONSENT_CATEGORIES: Readonly<ConsentCategoryState> = Object.freeze(\n CONSENT_CATEGORIES.reduce(\n (state, category) => ({ ...state, [category]: false }),\n {} as ConsentCategoryState,\n ),\n)\n\nfunction isPlainObject(value: unknown): value is Record<string, unknown> {\n return typeof value === 'object' && value !== null && !Array.isArray(value)\n}\n\n/**\n * Reads and validates the categories object, requiring every known category to\n * be present as a STRICT boolean. Returns `null` on any deviation — a missing\n * category, an extra one being the only thing wrong is fine (ignored, never\n * leaked into the result), but a category that is present and not `true`/`false`\n * literally, or one that is simply absent, fails the whole parse closed. A\n * partial payload does NOT default the missing categories to `false`: that\n * would make \"a payload half-stripped by a proxy\" indistinguishable from \"a\n * visitor who explicitly denied the rest\", which is not a distinction this\n * function is in a position to make safely.\n */\nfunction parseCategories(value: unknown): ConsentCategoryState | null {\n if (!isPlainObject(value)) return null\n\n const result = {} as ConsentCategoryState\n for (const category of CONSENT_CATEGORIES) {\n const raw = value[category]\n if (typeof raw !== 'boolean') return null\n result[category] = raw\n }\n return result\n}\n\n/**\n * Parses a raw consent cookie value into a validated payload, or `null` if the\n * value is absent or does not match the expected shape exactly. See the module\n * doc for why this fails closed rather than coercing.\n */\nexport function parseConsentCookie(raw: string | null | undefined): ConsentCookiePayload | null {\n if (!raw) return null\n\n let json: unknown\n try {\n json = JSON.parse(raw)\n } catch {\n return null\n }\n\n if (!isPlainObject(json)) return null\n\n const categories = parseCategories(json.categories)\n if (!categories) return null\n\n const policyVersion = json.policyVersion\n if (policyVersion !== null && typeof policyVersion !== 'string') return null\n\n return { categories, policyVersion }\n}\n\n/** Serialises a consent payload for storage in the cookie. */\nexport function serializeConsentCookie(payload: ConsentCookiePayload): string {\n return JSON.stringify(payload)\n}\n","'use client'\n\nimport type { ReactNode } from 'react'\n\n// Deliberately `../consent-shared`, not `../consent-server` — see that\n// module's doc comment for why `node:crypto` must never reach this client entry.\nimport type { ConsentCategory } from '../consent-shared'\nimport { useConsent } from './context'\n\nexport interface ConsentGateProps {\n category: ConsentCategory\n children: ReactNode\n}\n\n/**\n * `<ConsentGate>` — the allowlist enforced by construction that REB-642's\n * design settles on: a third party is never rendered except through a\n * component that names a consent category, and a denied or UNRESOLVED\n * category renders `null`.\n *\n * The `status !== 'resolved'` branch matters as much as the denied branch: a\n * gate that rendered children while consent is still `'unknown'` would load\n * the third party on every fresh visitor for the split second before their\n * cookie is read, which is exactly the race `docs/architecture/cookie-consent.md`\n * §6.1 designs around by building tags inside a client component instead of\n * passing a server-rendered `<AnalyticsScripts>` through as `children`.\n */\nexport function ConsentGate({ category, children }: ConsentGateProps) {\n const { status, isGranted } = useConsent()\n\n if (status !== 'resolved') return null\n if (!isGranted(category)) return null\n\n return <>{children}</>\n}\n","import Script from 'next/script'\n\nimport { jsonLdScriptContent } from './json-ld'\n\n/**\n * `<AnalyticsScripts>` — safe rendering of tenant-configured analytics tags (REB-617).\n *\n * THE HOLE THIS CLOSES. Every SITE built on this SDK interpolated the tenant's\n * analytics ids straight into the body of an executing `<script>`:\n *\n * gtag('config','${analytics.gaId}');\n * …\"clarity\",\"script\",\"${analytics.clarityId}\");\n *\n * No validation, no escaping. A `gaId` of `x');alert(document.cookie);//` closes\n * the string literal, closes the call, comments out the rest, and runs arbitrary\n * JS on the TENANT'S OWN ORIGIN — on every page including checkout, same origin\n * as the portal session cookie. For a medical tenant that is patient-session\n * territory. It is the same trust boundary as the nexus-authored HTML that\n * REB-613 closed on the sink next door, except this one reached the JS\n * interpreter with no sanitiser anywhere in the path.\n *\n * TWO INDEPENDENT LAYERS, because either alone is one refactor away from failing:\n *\n * 1. VALIDATION — an id that does not match its vendor-documented shape is\n * dropped and NO script renders. Analytics silently does not load; the site\n * keeps working. Failing closed matters more than failing loud here: the\n * value is cosmetic, the sink is not.\n * 2. ESCAPING — `analyticsScriptLiteral` emits the id as a JSON string literal\n * with the `<script>` boundary neutralised, so even a value that skipped\n * layer 1 (a future field with a looser shape, a caller that reaches for the\n * builders directly) is inert DATA rather than code.\n *\n * WHY VALIDATION ALONE IS NOT ENOUGH, and escaping alone is not either: layer 1\n * is a policy that a product decision can widen (someone adds `GT-` ids, or a\n * Meta Pixel field whose shape is numeric-but-unbounded), and layer 2 is a\n * mechanism that cannot express \"this is not a usable analytics id\". Keeping both\n * means widening the policy never reopens the sink.\n *\n * This lives in the SDK, not in `apps/demo-site`, because every external SITE repo\n * consuming `@reblu/site-contracts` carries the identical snippet — publishing the\n * safe component is the only way the fix reaches them.\n */\n\n/**\n * GA4 measurement id: `G-` followed by alphanumeric characters.\n *\n * Vendor source: Google Analytics Help 12270356 (\"formatted as 'G-' followed by\n * alphanumeric characters\", example `G-PSW1MY7HB4`).\n *\n * NO MINIMUM LENGTH, deliberately. The familiar id is ten characters, but Google\n * documents no length at all and its own docs print `gtag('config', 'G-12345')`\n * (Help 11259997) — inventing a floor here would reject an id Google itself\n * publishes. The upper bound is sanity, not security: the `[A-Z0-9]` class already\n * excludes every character that could leave a JS string literal, so length adds\n * nothing to the guarantee.\n *\n * Uppercase only — every documented example is uppercase, and a lowercase variant\n * is not a Google-issued id. Google tag ids also exist as `GT-` and `AW-`\n * (Help 12326985), but this field is specifically the GA4 *measurement id*\n * (the nexus form labels it so, and the SITE reads it as `gaId`); accepting the\n * other prefixes is a product decision, not a security one, and is deliberately\n * left out of this fix.\n */\nexport const GA_MEASUREMENT_ID_PATTERN = /^G-[A-Z0-9]{1,32}$/\n\n/**\n * Microsoft Clarity project id: lowercase alphanumeric.\n *\n * Vendor source: Microsoft Learn `clarity-setup` publishes a live demo project\n * under id `3t0wlogvdz`; every id Microsoft prints is lowercase alphanumeric of\n * about ten characters. Microsoft documents no formal grammar, so — as with GA —\n * the bound is generous and the character class carries the guarantee.\n *\n * RESIDUAL RISK, stated plainly: no vendor page enumerates the legal Clarity\n * alphabet, so this is inferred from published examples. If some tenant holds an\n * id containing a character outside `[a-z0-9]`, this rejects it. That is why the\n * API coerces a non-matching id to `null` instead of erroring — analytics stops\n * loading for that tenant, the site keeps serving, and the misconfiguration is\n * visible and fixable rather than an outage.\n */\nexport const CLARITY_PROJECT_ID_PATTERN = /^[a-z0-9]{1,32}$/\n\n/** True only for a string matching the vendor-documented GA4 measurement id shape. */\nexport function isValidGaMeasurementId(value: unknown): value is string {\n return typeof value === 'string' && GA_MEASUREMENT_ID_PATTERN.test(value)\n}\n\n/** True only for a string matching the vendor-documented Clarity project id shape. */\nexport function isValidClarityProjectId(value: unknown): value is string {\n return typeof value === 'string' && CLARITY_PROJECT_ID_PATTERN.test(value)\n}\n\n/**\n * Serialises `id` as a JS string literal safe to embed in a `<script>` body.\n *\n * Reuses `jsonLdScriptContent` rather than growing a second escaper: a JSON string\n * literal IS a valid JS string literal, and that helper already does the two things\n * required here — `JSON.stringify` for the quote/backslash/newline boundary, plus\n * `<` `>` `&` U+2028 U+2029 escaping for the HTML raw-text boundary that\n * `JSON.stringify` leaves wide open. One audited primitive, one place to fix.\n */\nexport function analyticsScriptLiteral(id: string): string {\n return jsonLdScriptContent(id)\n}\n\n/** Body of the GA4 bootstrap script. Always escapes; never trusts its caller. */\nexport function gaInitScript(gaId: string): string {\n return `window.dataLayer=window.dataLayer||[];function gtag(){dataLayer.push(arguments);}gtag('js',new Date());gtag('config',${analyticsScriptLiteral(gaId)});`\n}\n\n/** Body of the Microsoft Clarity bootstrap script. Always escapes; never trusts its caller. */\nexport function clarityInitScript(clarityId: string): string {\n return `(function(c,l,a,r,i,t,y){c[a]=c[a]||function(){(c[a].q=c[a].q||[]).push(arguments)};t=l.createElement(r);t.async=1;t.src=\"https://www.clarity.ms/tag/\"+i;y=l.getElementsByTagName(r)[0];y.parentNode.insertBefore(t,y)})(window,document,\"clarity\",\"script\",${analyticsScriptLiteral(clarityId)});`\n}\n\nexport interface AnalyticsScriptsProps {\n /** GA4 measurement id from the SITE analytics contract. Ignored unless it matches `G-…`. */\n gaId?: string | null\n /** Microsoft Clarity project id from the SITE analytics contract. Ignored unless lowercase alphanumeric. */\n clarityId?: string | null\n}\n\n/**\n * Renders the Google Analytics and Microsoft Clarity tags for a tenant, dropping\n * any id that does not match its vendor-documented shape.\n */\nexport function AnalyticsScripts({ gaId, clarityId }: AnalyticsScriptsProps) {\n const safeGaId = isValidGaMeasurementId(gaId) ? gaId : null\n const safeClarityId = isValidClarityProjectId(clarityId) ? clarityId : null\n\n if (!safeGaId && !safeClarityId) return null\n\n return (\n <>\n {safeGaId && (\n <>\n <Script\n src={`https://www.googletagmanager.com/gtag/js?id=${encodeURIComponent(safeGaId)}`}\n strategy='afterInteractive'\n />\n <Script\n id='ga-init'\n strategy='afterInteractive'\n dangerouslySetInnerHTML={{ __html: gaInitScript(safeGaId) }}\n />\n </>\n )}\n\n {safeClarityId && (\n <Script\n id='clarity-init'\n strategy='afterInteractive'\n dangerouslySetInnerHTML={{ __html: clarityInitScript(safeClarityId) }}\n />\n )}\n </>\n )\n}\n","/**\n * `<JsonLd>` — safe `application/ld+json` script renderer (REB-622).\n *\n * A `<script>` element is HTML's \"raw text\" context: the parser closes it on the\n * first case-insensitive `</script` byte sequence it sees, no matter whether\n * that sequence sits inside a JSON string. `JSON.stringify` escapes for JSON\n * syntax, not for this HTML boundary, so it leaves `<` verbatim — turning any\n * JSON-LD payload built from tenant-authored data (page title, description,\n * FAQ copy, …) into a stored-XSS sink: a value containing\n * `</script><img src=x onerror=alert(1)>` breaks out of the element and runs.\n *\n * `jsonLdScriptContent` neutralises exactly that boundary while staying\n * byte-for-byte valid JSON: `<` / `>` / `&` are standard JSON\n * character escapes, so `JSON.parse` recovers the original value unchanged —\n * this is an ESCAPER, not a strip/allowlist sanitiser (see `sanitizeHtml` in\n * `@reblu/site-blocks` for that job, at the HTML boundary rather than the\n * script boundary). U+2028/U+2029 are escaped too: both are valid inside a\n * JSON string but are raw JS line terminators, and some `<script>` consumers\n * (older JS parsers, some static analysers) choke on them unescaped.\n *\n * This package, not `@reblu/site-blocks`, is the home for it: `site-blocks`'s\n * main entry bundles `parse5` + the `hast-util-*` chain + `marked` for\n * `sanitizeHtml` with `treeshake: false` — dist/index.js is ~463 KB, and PR #848\n * spent a full round removing exactly that weight (89 KB gzipped) from a client\n * bundle that only needed one HTML-escaping helper. `<JsonLd>` is Next-specific\n * (App Router's `suppressHydrationWarning` pattern) and this 40-line escaper has\n * zero dependencies of its own, so it ships as its OWN tsup entry\n * (`@reblu/site-next/json-ld`) rather than folding into the root `index` barrel\n * — importing it must never drag in `CtaButtons`/`EditableCta`'s `next/link` +\n * `@reblu/site-ui` graph, the same isolation `./cache` and `./route-handlers`\n * already use for the same reason.\n *\n * @see https://nextjs.org/docs/app/guides/json-ld\n */\n\nimport type { ConfigOpeningHours, ConfigOpeningHoursDay } from '@reblu/site-contracts'\n\nconst SCRIPT_BOUNDARY_UNSAFE = /[<>&\\u2028\\u2029]/g\n\nconst ESCAPES: Record<string, string> = {\n '<': '\\\\u003c',\n '>': '\\\\u003e',\n '&': '\\\\u0026',\n '\\u2028': '\\\\u2028',\n '\\u2029': '\\\\u2029',\n}\n\n/**\n * Serialises `value` for embedding inside `<script type=\"application/ld+json\">`.\n * Escapes `<`, `>`, `&`, U+2028 and U+2029 — every escape is a standard JSON\n * character escape, so `JSON.parse(jsonLdScriptContent(value))` always recovers\n * `value` unchanged. This only ever ADDS escaping; it never strips or rewrites\n * data, unlike an HTML sanitiser.\n *\n * `JSON.stringify(undefined)` returns the JS value `undefined`, not a string\n * (`undefined` has no JSON representation), which would otherwise throw here.\n * `data` is typed `unknown` with zero friction toward `undefined`, so this\n * falls back to the JSON literal `'null'` rather than crashing an entire page\n * render over one missing field.\n */\nexport function jsonLdScriptContent(value: unknown): string {\n const json = JSON.stringify(value) ?? 'null'\n return json.replace(SCRIPT_BOUNDARY_UNSAFE, (char) => ESCAPES[char])\n}\n\nexport interface JsonLdProps {\n /** Suffix for the rendered `<script>` id: `json-ld-${id}`. */\n id: string\n /** Structured data to serialise. Any JSON-serialisable value. */\n data: unknown\n}\n\n/**\n * Renders one `application/ld+json` `<script>` tag using the official Next.js\n * App Router pattern. `suppressHydrationWarning` is required here: React warns\n * on a server/client `textContent` mismatch for this element even though there\n * is no real divergence — `dangerouslySetInnerHTML` content is opaque to React's\n * hydration diff.\n */\nexport function JsonLd({ id, data }: JsonLdProps) {\n return (\n <script\n id={`json-ld-${id}`}\n type='application/ld+json'\n suppressHydrationWarning\n dangerouslySetInnerHTML={{ __html: jsonLdScriptContent(data) }}\n />\n )\n}\n\n// ─── Opening hours → schema.org OpeningHoursSpecification (REB-826) ──────────\n\n/** schema.org `OpeningHoursSpecification`, the shape a `LocalBusiness`/\n * `MedicalOrganization`/etc. entity's `openingHoursSpecification` array holds.\n * @see https://schema.org/OpeningHoursSpecification\n */\nexport interface OpeningHoursSpecification {\n '@type': 'OpeningHoursSpecification'\n dayOfWeek: string[]\n opens: string\n closes: string\n}\n\n/** schema.org `DayOfWeek` URIs, keyed by the Reblu `ConfigOpeningHoursDay` enum. */\nconst SCHEMA_DAY_OF_WEEK: Record<ConfigOpeningHoursDay, string> = {\n MON: 'https://schema.org/Monday',\n TUE: 'https://schema.org/Tuesday',\n WED: 'https://schema.org/Wednesday',\n THU: 'https://schema.org/Thursday',\n FRI: 'https://schema.org/Friday',\n SAT: 'https://schema.org/Saturday',\n SUN: 'https://schema.org/Sunday',\n}\n\n/**\n * Maps the Reblu `Config.openingHours` typed value to one\n * `OpeningHoursSpecification` object per range, each with its `days` turned\n * into schema.org `DayOfWeek` URIs. One range = one spec object; this does\n * NOT group or reformat for display (see `formatOpeningHours` in\n * `@reblu/site-next/opening-hours` for that) — structured data wants one\n * entry per machine-readable (days, opens, closes) tuple, not prose.\n *\n * Returns `undefined` — not `[]` — for \"nothing to merge\" (no typed hours, or\n * a typed value with zero ranges), so a caller building a JSON-LD entity with\n * `entity.openingHoursSpecification = toOpeningHoursSpecification(...)` can\n * `if (spec)` guard it without emitting an empty array into the graph.\n *\n * Used both by SITE builds assembling their own JSON-LD (the published use\n * case this SDK exists for) and by base-api's own structured-data builder —\n * see `generate-structured-data.ts`. Unlike `cookiePolicyVersion`\n * (`@reblu/site-next/consent-server`), this is NOT duplicated into base-api:\n * that duplication exists only where two independently-deployed sides (a\n * SITE's stored cookie vs. base-api's request-time check) must each derive\n * the identical value without talking to each other. Here base-api is the\n * ONLY side computing this value — there is no second computation to agree\n * with — so importing the single implementation is strictly better than a\n * second copy that could drift.\n *\n * Defensive against a malformed stored value — `Config.openingHours` is a\n * compile-time guarantee only. A range whose `days` is empty, or whose codes\n * are all unrecognised (outside `SCHEMA_DAY_OF_WEEK`'s keys), is dropped\n * entirely rather than emitting a spec object with an empty `dayOfWeek` (or\n * one padded with `undefined` for every unrecognised code). If every range\n * is dropped this way, the result is `undefined`, never `[]`.\n */\nexport function toOpeningHoursSpecification(\n openingHours: ConfigOpeningHours | null | undefined,\n): OpeningHoursSpecification[] | undefined {\n if (!openingHours || openingHours.ranges.length === 0) return undefined\n const specs: OpeningHoursSpecification[] = []\n for (const range of openingHours.ranges) {\n const dayOfWeek = range.days\n // `Object.hasOwn`, not `day in SCHEMA_DAY_OF_WEEK`: the `in` operator\n // also matches INHERITED properties (`'constructor'`, `'toString'`,\n // `'hasOwnProperty'`, …), which are not own keys of this lookup object\n // but would still pass an `in` check and resolve to a function, not a\n // schema.org URI string — corrupting `dayOfWeek` with exactly the kind\n // of malformed entry this filtering exists to prevent.\n .filter((day): day is ConfigOpeningHoursDay => Object.hasOwn(SCHEMA_DAY_OF_WEEK, day))\n .map((day) => SCHEMA_DAY_OF_WEEK[day])\n if (dayOfWeek.length === 0) continue\n specs.push({\n '@type': 'OpeningHoursSpecification',\n dayOfWeek,\n opens: range.opens,\n closes: range.closes,\n })\n }\n return specs.length > 0 ? specs : undefined\n}\n","'use client'\n\nimport { AnalyticsScripts, type AnalyticsScriptsProps } from '../analytics'\nimport { ConsentGate } from './gate'\n\n/**\n * `<ConsentedAnalytics>` — the `'analytics'`-gated replacement for rendering\n * `<AnalyticsScripts>` directly in a SITE layout (REB-642 Slice 1).\n *\n * Reuses the REB-617 validation + escaping builders verbatim via\n * `<AnalyticsScripts>` — this component adds NOTHING to the sink-safety\n * argument in `../analytics.tsx`, it only decides WHETHER that already-safe\n * renderer mounts at all. A SITE layout that still imports\n * `@reblu/site-next/analytics` directly bypasses consent entirely, which is\n * exactly what the source-scanning guard (REB-642 Slice 1, demo-site) exists\n * to catch — this component is the mechanism that guard enforces.\n */\nexport function ConsentedAnalytics(props: AnalyticsScriptsProps) {\n return (\n <ConsentGate category='analytics'>\n <AnalyticsScripts {...props} />\n </ConsentGate>\n )\n}\n","'use client'\n\nimport { useEffect, useState, type ReactNode } from 'react'\nimport * as Dialog from '@radix-ui/react-dialog'\nimport { Button, cn } from '@reblu/site-ui'\n\n// Deliberately `../consent-shared`, not `../consent-server` — see that\n// module's doc comment for why `node:crypto` must never reach this client entry.\nimport { CONSENT_CATEGORIES, type ConsentCategoryState } from '../consent-shared'\nimport { useConsent } from './context'\nimport { mergeConsentBannerTexts, type ConsentBannerTexts } from './texts'\n\n/**\n * `<CookieBanner>` + `<CookiePreferencesTrigger>` — the visible layer of\n * REB-642's consent mechanism.\n *\n * ─────────────────────────────────────────────────────────────────────────────\n * ARCHITECTURE: ONE DIALOG, TWO OPENERS\n * ─────────────────────────────────────────────────────────────────────────────\n *\n * `<CookieBanner>` owns rendering the preferences dialog (mount it ONCE, at\n * the root of the SITE layout — it portals to `<body>` regardless of where it\n * is mounted). `<CookiePreferencesTrigger>` is a small, unstyled-by-default\n * button usable ANYWHERE ELSE in the tree (a footer link, a settings page) that\n * opens the SAME dialog via `useConsent().openPreferences()`. Both read the\n * single `preferencesOpen` boolean the `<ConsentProvider>` owns, so there is\n * never more than one dialog instance and never two focus traps competing.\n *\n * This is what REB-642 calls \"permanent withdrawal\" — a visitor's route back\n * to their preferences does not depend on the first-layer banner still being\n * on screen; the banner disappears once resolved, the trigger does not.\n *\n * ─────────────────────────────────────────────────────────────────────────────\n * THE THREE-BUTTON RULE (owner decision, not configurable)\n * ─────────────────────────────────────────────────────────────────────────────\n *\n * Reject / Configure / Accept render as three same-variant, same-size buttons\n * in that visual order — no button is styled as more prominent than another.\n * A tenant can override `title`/`description` text (REB-642 §7.1 recommends\n * v1 ship with NO tenant-editable copy at all, but the prop exists so a later\n * slice can add it without touching this component's contract); the buttons,\n * their order, and the category taxonomy are fixed.\n */\n\nexport interface CookieBannerProps {\n /** Overrides merged onto `DEFAULT_CONSENT_BANNER_TEXTS`. */\n texts?: Partial<ConsentBannerTexts>\n /** Where \"Política de cookies\" links to. */\n policyHref?: string\n}\n\nfunction resolveTexts(overrides?: Partial<ConsentBannerTexts>): ConsentBannerTexts {\n // REB-845 — a blank/whitespace-only override falls back to the SDK default\n // for that key instead of rendering empty. See `mergeConsentBannerTexts`'s\n // doc comment for why: nexus persists `\"\"` for an unfilled cookie-banner\n // copy field, and a naive `{ ...defaults, ...overrides }` spread let that\n // `\"\"` win over the Spanish default.\n return mergeConsentBannerTexts(overrides)\n}\n\nexport function CookieBanner({ texts: textsOverride, policyHref = '/cookies' }: CookieBannerProps) {\n const { status, acceptAll, rejectAll, openPreferences } = useConsent()\n const texts = resolveTexts(textsOverride)\n\n return (\n <>\n {status === 'unknown' && (\n <div\n role='region'\n aria-label={texts.title}\n className='fixed inset-x-0 bottom-0 z-50 border-t border-border bg-background/95 px-4 py-4 backdrop-blur supports-[backdrop-filter]:bg-background/80'\n data-slot='cookie-banner'\n >\n <div className='mx-auto flex max-w-4xl flex-col gap-3 sm:flex-row sm:items-center sm:justify-between'>\n <div className='text-sm'>\n <p className='font-semibold'>{texts.title}</p>\n <p className='text-muted-foreground'>\n {texts.description}{' '}\n <a href={policyHref} className='underline underline-offset-2'>\n {texts.policyLinkLabel}\n </a>\n </p>\n </div>\n {/* Three equal-weight buttons, fixed order: reject, configure, accept.\n Same variant/size for all three — rejecting must cost exactly what\n accepting costs (AEPD first-layer requirement, REB-642). */}\n <div className='flex shrink-0 flex-wrap gap-2'>\n <Button type='button' variant='outline' size='sm' onClick={rejectAll}>\n {texts.rejectLabel}\n </Button>\n <Button type='button' variant='outline' size='sm' onClick={openPreferences}>\n {texts.manageLabel}\n </Button>\n <Button type='button' variant='outline' size='sm' onClick={acceptAll}>\n {texts.acceptLabel}\n </Button>\n </div>\n </div>\n </div>\n )}\n <ConsentPreferencesDialog texts={texts} />\n </>\n )\n}\n\nexport interface CookiePreferencesTriggerProps {\n children?: ReactNode\n className?: string\n texts?: Partial<ConsentBannerTexts>\n}\n\n/**\n * A persistent \"configure cookies\" entry point, safe to place anywhere in the\n * tree (typically a footer link) once `<CookieBanner>` is mounted at the root.\n * Opens the same preferences dialog `<CookieBanner>` owns.\n */\nexport function CookiePreferencesTrigger({\n children,\n className,\n texts: textsOverride,\n}: CookiePreferencesTriggerProps) {\n const { openPreferences } = useConsent()\n const texts = resolveTexts(textsOverride)\n\n return (\n <button type='button' onClick={openPreferences} className={className} data-slot='cookie-preferences-trigger'>\n {children ?? `${texts.manageLabel} cookies`}\n </button>\n )\n}\n\ninterface ConsentPreferencesDialogProps {\n texts: ConsentBannerTexts\n}\n\n/**\n * The second layer — a per-category preferences panel. `necessary` is\n * rendered as an always-checked, disabled checkbox for transparency (a\n * visitor can SEE that necessary cookies exist and why) without offering a\n * toggle that has no real effect, which would be dishonest UI.\n */\nfunction ConsentPreferencesDialog({ texts }: ConsentPreferencesDialogProps) {\n const { categories, preferencesOpen, closePreferences, savePreferences, restoreLastFocus } = useConsent()\n const [draft, setDraft] = useState<ConsentCategoryState>(categories)\n\n // Re-seed the draft from the last-saved categories every time the dialog\n // opens, so stale unsaved toggles from a previous open are never carried\n // into the next one.\n useEffect(() => {\n if (preferencesOpen) setDraft(categories)\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [preferencesOpen])\n\n return (\n <Dialog.Root\n open={preferencesOpen}\n onOpenChange={(open) => {\n // Radix only calls this for state changes IT initiates (Escape,\n // overlay click, `<Dialog.Close>`) — opening is entirely driven by\n // `useConsent().openPreferences()` from outside, which is also where\n // focus is captured (see `../consent/context.tsx`). So this handler\n // only ever needs to handle the closing transition.\n if (!open) closePreferences()\n }}\n >\n <Dialog.Portal>\n <Dialog.Overlay className='fixed inset-0 z-50 bg-black/50' />\n <Dialog.Content\n className='fixed left-1/2 top-1/2 z-50 w-full max-w-md -translate-x-1/2 -translate-y-1/2 rounded-lg border border-border bg-background p-6 shadow-lg'\n data-slot='cookie-preferences-dialog'\n onCloseAutoFocus={(event) => {\n event.preventDefault()\n restoreLastFocus()\n }}\n >\n <Dialog.Title className='text-base font-semibold'>{texts.preferencesTitle}</Dialog.Title>\n <Dialog.Description className='mt-1 text-sm text-muted-foreground'>\n {texts.preferencesDescription}\n </Dialog.Description>\n\n <div className='mt-4 flex flex-col gap-3'>\n <CategoryRow\n id='consent-category-necessary'\n label={texts.necessaryLabel}\n description={texts.necessaryDescription}\n checked\n disabled\n onChange={() => undefined}\n />\n {CONSENT_CATEGORIES.map((category) => (\n <CategoryRow\n key={category}\n id={`consent-category-${category}`}\n label={texts.categoryLabels[category]}\n description={texts.categoryDescriptions[category]}\n checked={draft[category]}\n disabled={false}\n onChange={(checked) => setDraft((prev) => ({ ...prev, [category]: checked }))}\n />\n ))}\n </div>\n\n <div className='mt-6 flex justify-end'>\n <Button type='button' size='sm' onClick={() => savePreferences(draft)}>\n {texts.saveLabel}\n </Button>\n </div>\n </Dialog.Content>\n </Dialog.Portal>\n </Dialog.Root>\n )\n}\n\ninterface CategoryRowProps {\n id: string\n label: string\n description: string\n checked: boolean\n disabled: boolean\n onChange: (checked: boolean) => void\n}\n\nfunction CategoryRow({ id, label, description, checked, disabled, onChange }: CategoryRowProps) {\n return (\n <div className={cn('flex items-start gap-3', disabled && 'opacity-70')}>\n <input\n id={id}\n type='checkbox'\n checked={checked}\n disabled={disabled}\n onChange={(event) => onChange(event.target.checked)}\n className='mt-1 size-4'\n />\n <label htmlFor={id} className='flex flex-col text-sm'>\n <span className='font-medium'>{label}</span>\n <span className='text-muted-foreground'>{description}</span>\n </label>\n </div>\n )\n}\n","import type { CookieBanner } from '@reblu/site-contracts'\n\nimport type { ConsentCategory } from '../consent-shared'\n\n/**\n * Copy for `<CookieBanner>`. SDK-owned defaults in Spanish (every Reblu SITE\n * renders `<html lang='es'>` today).\n *\n * REB-642 §7.1 recommends tenant-editable copy be limited to a TITLE/BODY\n * override for v1 — the per-category descriptions, the \"necessary\" copy and\n * every button label stay fixed, because the copy is a compliance surface: a\n * tenant writing \"we use cookies — OK\" with no reject option is the exact\n * misconfiguration this design exists to prevent. This type is intentionally\n * a `Partial<ConsentBannerTexts>` at the call site (see `CookieBanner`), so a\n * later tenant-configuration slice can override `title`/`description` without\n * touching this component's contract.\n */\nexport interface ConsentBannerTexts {\n title: string\n description: string\n acceptLabel: string\n rejectLabel: string\n manageLabel: string\n policyLinkLabel: string\n preferencesTitle: string\n preferencesDescription: string\n saveLabel: string\n necessaryLabel: string\n necessaryDescription: string\n categoryLabels: Record<ConsentCategory, string>\n categoryDescriptions: Record<ConsentCategory, string>\n}\n\nexport const DEFAULT_CONSENT_BANNER_TEXTS: ConsentBannerTexts = {\n title: 'Utilizamos cookies',\n description:\n 'Usamos cookies estrictamente necesarias para el funcionamiento del sitio. Con tu permiso, también usaríamos cookies de analítica y marketing para mejorar tu experiencia.',\n acceptLabel: 'Aceptar todas',\n rejectLabel: 'Rechazar todas',\n manageLabel: 'Configurar',\n policyLinkLabel: 'Política de cookies',\n preferencesTitle: 'Preferencias de cookies',\n preferencesDescription:\n 'Elige qué categorías de cookies opcionales permites. Las cookies necesarias no se pueden desactivar.',\n saveLabel: 'Guardar preferencias',\n necessaryLabel: 'Necesarias',\n necessaryDescription:\n 'Imprescindibles para el funcionamiento del sitio, como la seguridad del formulario de contacto. Siempre activas.',\n categoryLabels: {\n analytics: 'Analítica',\n marketing: 'Marketing',\n preferences: 'Preferencias',\n },\n categoryDescriptions: {\n analytics: 'Nos ayudan a entender cómo se usa el sitio (por ejemplo, Google Analytics o Microsoft Clarity).',\n marketing: 'Permiten mostrar contenido relevante de terceros (por ejemplo, Meta Pixel).',\n preferences: 'Recuerdan tus preferencias de navegación entre visitas.',\n },\n}\n\n/**\n * The string-valued keys of `ConsentBannerTexts` — every key EXCEPT\n * `categoryLabels`/`categoryDescriptions`, which are nested `Record`s, not\n * plain copy. `mergeConsentBannerTexts` only applies its blank-is-unset rule\n * to these.\n */\nconst CONSENT_BANNER_TEXT_KEYS = [\n 'title',\n 'description',\n 'acceptLabel',\n 'rejectLabel',\n 'manageLabel',\n 'policyLinkLabel',\n 'preferencesTitle',\n 'preferencesDescription',\n 'saveLabel',\n 'necessaryLabel',\n 'necessaryDescription',\n] as const satisfies readonly (keyof ConsentBannerTexts)[]\n\n/**\n * Merges `overrides` onto `DEFAULT_CONSENT_BANNER_TEXTS` (REB-845).\n *\n * A plain `{ ...defaults, ...overrides }` spread lets a BLANK override win —\n * nexus persists `\"\"` for a cookie-banner copy field nobody filled in (see\n * `cookieBannerSchema`'s doc comment in base-api), so a naive merge rendered\n * an empty title, empty description and three blank buttons instead of the\n * SDK's Spanish defaults. An empty or whitespace-only string here means the\n * SAME THING as `undefined`: \"no override — use the default.\"\n *\n * Same for a PRESENT key holding `undefined` or `null` (R3-001 review\n * finding): the spread copies that value right over the default because the\n * key still exists on `overrides`, so `value == null` is checked first,\n * before the string/blank check, rather than relying on `typeof value ===\n * 'string'` to fall through.\n */\nexport function mergeConsentBannerTexts(\n overrides?: Partial<ConsentBannerTexts>,\n): ConsentBannerTexts {\n const merged: ConsentBannerTexts = { ...DEFAULT_CONSENT_BANNER_TEXTS, ...overrides }\n for (const key of CONSENT_BANNER_TEXT_KEYS) {\n const value = overrides?.[key]\n const isUnset = value == null || (typeof value === 'string' && value.trim() === '')\n if (isUnset) {\n merged[key] = DEFAULT_CONSENT_BANNER_TEXTS[key]\n }\n }\n return merged\n}\n\n/**\n * Maps the `/api/site/analytics` `cookieBanner` contract shape onto\n * `Partial<ConsentBannerTexts>` for `<CookieBanner texts={...}>`, dropping\n * `null` and blank fields so a SITE never has to hand-roll this mapping (and\n * never has to re-implement the blank-is-unset rule `mergeConsentBannerTexts`\n * already applies on the way in).\n *\n * Only the 5 fields REB-642 §7.1 makes tenant-editable map to copy;\n * `cookieBanner.enabled` and `cookieBanner.policyUrl` are not `texts` —\n * `enabled` gates whether `<CookieBanner>` renders at all, and `policyUrl` is\n * `<CookieBanner policyHref>`, not a text key.\n */\nexport function cookieBannerToConsentTexts(\n cookieBanner:\n | Pick<CookieBanner, 'title' | 'body' | 'acceptLabel' | 'rejectLabel' | 'manageLabel'>\n | null\n | undefined,\n): Partial<ConsentBannerTexts> {\n if (!cookieBanner) return {}\n\n const overrides: Partial<ConsentBannerTexts> = {}\n if (cookieBanner.title?.trim()) overrides.title = cookieBanner.title\n if (cookieBanner.body?.trim()) overrides.description = cookieBanner.body\n if (cookieBanner.acceptLabel?.trim()) overrides.acceptLabel = cookieBanner.acceptLabel\n if (cookieBanner.rejectLabel?.trim()) overrides.rejectLabel = cookieBanner.rejectLabel\n if (cookieBanner.manageLabel?.trim()) overrides.manageLabel = cookieBanner.manageLabel\n return overrides\n}\n"],"mappings":"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","names":["import_jsx_runtime","import_jsx_runtime","import_jsx_runtime","Script","import_jsx_runtime","import_react","import_jsx_runtime"]}
@@ -2,6 +2,7 @@ import * as react_jsx_runtime from 'react/jsx-runtime';
2
2
  import { ReactNode } from 'react';
3
3
  import { c as ConsentCategoryState, b as ConsentCategory } from './consent-shared-DbdhEXW2.cjs';
4
4
  import { AnalyticsScriptsProps } from './analytics.cjs';
5
+ import { CookieBanner as CookieBanner$1 } from '@reblu/site-contracts';
5
6
 
6
7
  /**
7
8
  * `<ConsentProvider>` / `useConsent()` — the client half of cookie consent
@@ -118,6 +119,36 @@ interface ConsentBannerTexts {
118
119
  categoryDescriptions: Record<ConsentCategory, string>;
119
120
  }
120
121
  declare const DEFAULT_CONSENT_BANNER_TEXTS: ConsentBannerTexts;
122
+ /**
123
+ * Merges `overrides` onto `DEFAULT_CONSENT_BANNER_TEXTS` (REB-845).
124
+ *
125
+ * A plain `{ ...defaults, ...overrides }` spread lets a BLANK override win —
126
+ * nexus persists `""` for a cookie-banner copy field nobody filled in (see
127
+ * `cookieBannerSchema`'s doc comment in base-api), so a naive merge rendered
128
+ * an empty title, empty description and three blank buttons instead of the
129
+ * SDK's Spanish defaults. An empty or whitespace-only string here means the
130
+ * SAME THING as `undefined`: "no override — use the default."
131
+ *
132
+ * Same for a PRESENT key holding `undefined` or `null` (R3-001 review
133
+ * finding): the spread copies that value right over the default because the
134
+ * key still exists on `overrides`, so `value == null` is checked first,
135
+ * before the string/blank check, rather than relying on `typeof value ===
136
+ * 'string'` to fall through.
137
+ */
138
+ declare function mergeConsentBannerTexts(overrides?: Partial<ConsentBannerTexts>): ConsentBannerTexts;
139
+ /**
140
+ * Maps the `/api/site/analytics` `cookieBanner` contract shape onto
141
+ * `Partial<ConsentBannerTexts>` for `<CookieBanner texts={...}>`, dropping
142
+ * `null` and blank fields so a SITE never has to hand-roll this mapping (and
143
+ * never has to re-implement the blank-is-unset rule `mergeConsentBannerTexts`
144
+ * already applies on the way in).
145
+ *
146
+ * Only the 5 fields REB-642 §7.1 makes tenant-editable map to copy;
147
+ * `cookieBanner.enabled` and `cookieBanner.policyUrl` are not `texts` —
148
+ * `enabled` gates whether `<CookieBanner>` renders at all, and `policyUrl` is
149
+ * `<CookieBanner policyHref>`, not a text key.
150
+ */
151
+ declare function cookieBannerToConsentTexts(cookieBanner: Pick<CookieBanner$1, 'title' | 'body' | 'acceptLabel' | 'rejectLabel' | 'manageLabel'> | null | undefined): Partial<ConsentBannerTexts>;
121
152
 
122
153
  /**
123
154
  * `<CookieBanner>` + `<CookiePreferencesTrigger>` — the visible layer of
@@ -169,4 +200,4 @@ interface CookiePreferencesTriggerProps {
169
200
  */
170
201
  declare function CookiePreferencesTrigger({ children, className, texts: textsOverride, }: CookiePreferencesTriggerProps): react_jsx_runtime.JSX.Element;
171
202
 
172
- export { type ConsentBannerTexts, type ConsentContextValue, ConsentGate, type ConsentGateProps, ConsentProvider, type ConsentProviderProps, type ConsentStatus, ConsentedAnalytics, CookieBanner, type CookieBannerProps, CookiePreferencesTrigger, type CookiePreferencesTriggerProps, DEFAULT_CONSENT_BANNER_TEXTS, useConsent };
203
+ export { type ConsentBannerTexts, type ConsentContextValue, ConsentGate, type ConsentGateProps, ConsentProvider, type ConsentProviderProps, type ConsentStatus, ConsentedAnalytics, CookieBanner, type CookieBannerProps, CookiePreferencesTrigger, type CookiePreferencesTriggerProps, DEFAULT_CONSENT_BANNER_TEXTS, cookieBannerToConsentTexts, mergeConsentBannerTexts, useConsent };
package/dist/consent.d.ts CHANGED
@@ -2,6 +2,7 @@ import * as react_jsx_runtime from 'react/jsx-runtime';
2
2
  import { ReactNode } from 'react';
3
3
  import { c as ConsentCategoryState, b as ConsentCategory } from './consent-shared-DbdhEXW2.js';
4
4
  import { AnalyticsScriptsProps } from './analytics.js';
5
+ import { CookieBanner as CookieBanner$1 } from '@reblu/site-contracts';
5
6
 
6
7
  /**
7
8
  * `<ConsentProvider>` / `useConsent()` — the client half of cookie consent
@@ -118,6 +119,36 @@ interface ConsentBannerTexts {
118
119
  categoryDescriptions: Record<ConsentCategory, string>;
119
120
  }
120
121
  declare const DEFAULT_CONSENT_BANNER_TEXTS: ConsentBannerTexts;
122
+ /**
123
+ * Merges `overrides` onto `DEFAULT_CONSENT_BANNER_TEXTS` (REB-845).
124
+ *
125
+ * A plain `{ ...defaults, ...overrides }` spread lets a BLANK override win —
126
+ * nexus persists `""` for a cookie-banner copy field nobody filled in (see
127
+ * `cookieBannerSchema`'s doc comment in base-api), so a naive merge rendered
128
+ * an empty title, empty description and three blank buttons instead of the
129
+ * SDK's Spanish defaults. An empty or whitespace-only string here means the
130
+ * SAME THING as `undefined`: "no override — use the default."
131
+ *
132
+ * Same for a PRESENT key holding `undefined` or `null` (R3-001 review
133
+ * finding): the spread copies that value right over the default because the
134
+ * key still exists on `overrides`, so `value == null` is checked first,
135
+ * before the string/blank check, rather than relying on `typeof value ===
136
+ * 'string'` to fall through.
137
+ */
138
+ declare function mergeConsentBannerTexts(overrides?: Partial<ConsentBannerTexts>): ConsentBannerTexts;
139
+ /**
140
+ * Maps the `/api/site/analytics` `cookieBanner` contract shape onto
141
+ * `Partial<ConsentBannerTexts>` for `<CookieBanner texts={...}>`, dropping
142
+ * `null` and blank fields so a SITE never has to hand-roll this mapping (and
143
+ * never has to re-implement the blank-is-unset rule `mergeConsentBannerTexts`
144
+ * already applies on the way in).
145
+ *
146
+ * Only the 5 fields REB-642 §7.1 makes tenant-editable map to copy;
147
+ * `cookieBanner.enabled` and `cookieBanner.policyUrl` are not `texts` —
148
+ * `enabled` gates whether `<CookieBanner>` renders at all, and `policyUrl` is
149
+ * `<CookieBanner policyHref>`, not a text key.
150
+ */
151
+ declare function cookieBannerToConsentTexts(cookieBanner: Pick<CookieBanner$1, 'title' | 'body' | 'acceptLabel' | 'rejectLabel' | 'manageLabel'> | null | undefined): Partial<ConsentBannerTexts>;
121
152
 
122
153
  /**
123
154
  * `<CookieBanner>` + `<CookiePreferencesTrigger>` — the visible layer of
@@ -169,4 +200,4 @@ interface CookiePreferencesTriggerProps {
169
200
  */
170
201
  declare function CookiePreferencesTrigger({ children, className, texts: textsOverride, }: CookiePreferencesTriggerProps): react_jsx_runtime.JSX.Element;
171
202
 
172
- export { type ConsentBannerTexts, type ConsentContextValue, ConsentGate, type ConsentGateProps, ConsentProvider, type ConsentProviderProps, type ConsentStatus, ConsentedAnalytics, CookieBanner, type CookieBannerProps, CookiePreferencesTrigger, type CookiePreferencesTriggerProps, DEFAULT_CONSENT_BANNER_TEXTS, useConsent };
203
+ export { type ConsentBannerTexts, type ConsentContextValue, ConsentGate, type ConsentGateProps, ConsentProvider, type ConsentProviderProps, type ConsentStatus, ConsentedAnalytics, CookieBanner, type CookieBannerProps, CookiePreferencesTrigger, type CookiePreferencesTriggerProps, DEFAULT_CONSENT_BANNER_TEXTS, cookieBannerToConsentTexts, mergeConsentBannerTexts, useConsent };
package/dist/consent.js CHANGED
@@ -225,11 +225,45 @@ var DEFAULT_CONSENT_BANNER_TEXTS = {
225
225
  preferences: "Recuerdan tus preferencias de navegaci\xF3n entre visitas."
226
226
  }
227
227
  };
228
+ var CONSENT_BANNER_TEXT_KEYS = [
229
+ "title",
230
+ "description",
231
+ "acceptLabel",
232
+ "rejectLabel",
233
+ "manageLabel",
234
+ "policyLinkLabel",
235
+ "preferencesTitle",
236
+ "preferencesDescription",
237
+ "saveLabel",
238
+ "necessaryLabel",
239
+ "necessaryDescription"
240
+ ];
241
+ function mergeConsentBannerTexts(overrides) {
242
+ const merged = { ...DEFAULT_CONSENT_BANNER_TEXTS, ...overrides };
243
+ for (const key of CONSENT_BANNER_TEXT_KEYS) {
244
+ const value = overrides?.[key];
245
+ const isUnset = value == null || typeof value === "string" && value.trim() === "";
246
+ if (isUnset) {
247
+ merged[key] = DEFAULT_CONSENT_BANNER_TEXTS[key];
248
+ }
249
+ }
250
+ return merged;
251
+ }
252
+ function cookieBannerToConsentTexts(cookieBanner) {
253
+ if (!cookieBanner) return {};
254
+ const overrides = {};
255
+ if (cookieBanner.title?.trim()) overrides.title = cookieBanner.title;
256
+ if (cookieBanner.body?.trim()) overrides.description = cookieBanner.body;
257
+ if (cookieBanner.acceptLabel?.trim()) overrides.acceptLabel = cookieBanner.acceptLabel;
258
+ if (cookieBanner.rejectLabel?.trim()) overrides.rejectLabel = cookieBanner.rejectLabel;
259
+ if (cookieBanner.manageLabel?.trim()) overrides.manageLabel = cookieBanner.manageLabel;
260
+ return overrides;
261
+ }
228
262
 
229
263
  // src/consent/banner.tsx
230
264
  import { Fragment as Fragment3, jsx as jsx6, jsxs as jsxs2 } from "react/jsx-runtime";
231
265
  function resolveTexts(overrides) {
232
- return { ...DEFAULT_CONSENT_BANNER_TEXTS, ...overrides };
266
+ return mergeConsentBannerTexts(overrides);
233
267
  }
234
268
  function CookieBanner({ texts: textsOverride, policyHref = "/cookies" }) {
235
269
  const { status, acceptAll, rejectAll, openPreferences } = useConsent();
@@ -357,6 +391,8 @@ export {
357
391
  CookieBanner,
358
392
  CookiePreferencesTrigger,
359
393
  DEFAULT_CONSENT_BANNER_TEXTS,
394
+ cookieBannerToConsentTexts,
395
+ mergeConsentBannerTexts,
360
396
  useConsent
361
397
  };
362
398
  //# sourceMappingURL=consent.js.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/consent/context.tsx","../src/consent-shared.ts","../src/consent/gate.tsx","../src/analytics.tsx","../src/json-ld.tsx","../src/consent/consented-analytics.tsx","../src/consent/banner.tsx","../src/consent/texts.ts"],"sourcesContent":["'use client'\n\nimport { createContext, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'\n\nimport {\n CONSENT_CATEGORIES,\n CONSENT_COOKIE_NAME,\n DEFAULT_CONSENT_CATEGORIES,\n parseConsentCookie,\n serializeConsentCookie,\n type ConsentCategory,\n type ConsentCategoryState,\n // Deliberately `../consent-shared`, NEVER `../consent-server`: the latter\n // pulls in `cookiePolicyVersion` and its `node:crypto` import, which must\n // never reach this client entry — see `../consent-shared.ts`'s doc comment.\n} from '../consent-shared'\n\n/**\n * `<ConsentProvider>` / `useConsent()` — the client half of cookie consent\n * (REB-642). See `../consent-shared` for the fail-closed parsing this relies\n * on (imported from there, not `../consent-server`, to keep `node:crypto` out\n * of this client entry), and `docs/architecture/cookie-consent.md` for the\n * full design.\n *\n * `status: 'unknown'` is the DEFAULT-DENIED state and the value every\n * `<ConsentGate>` treats as \"not granted\" — see `./gate.tsx`. It covers three\n * cases identically, on purpose: no cookie yet, a cookie that failed\n * `parseConsentCookie`, and a cookie recorded under a `policyVersion` that no\n * longer matches (the tenant edited the cookie policy — REB-642's \"re-ask when\n * the version changes\"). None of the three may ever resolve to granted.\n */\n\n/** Whether the visitor's choice for the CURRENT policy version is known. */\nexport type ConsentStatus = 'unknown' | 'resolved'\n\nexport interface ConsentContextValue {\n status: ConsentStatus\n categories: ConsentCategoryState\n /** True only when `status === 'resolved'` AND the category was explicitly granted. */\n isGranted: (category: ConsentCategory) => boolean\n /** Grants every category and persists immediately. */\n acceptAll: () => void\n /** Denies every category and persists immediately — the denial persists across reloads. */\n rejectAll: () => void\n /** Persists exactly the categories passed — no category is granted implicitly. */\n savePreferences: (categories: ConsentCategoryState) => void\n preferencesOpen: boolean\n openPreferences: () => void\n closePreferences: () => void\n /**\n * Refocuses whatever element had focus immediately before `openPreferences`\n * was called. The preferences dialog is openable from more than one place\n * (the banner's \"Configure\" button, `<CookiePreferencesTrigger>` elsewhere\n * in the tree) with no single `<Dialog.Trigger>` for Radix's own\n * focus-restore to key off, so this is tracked explicitly here — the one\n * funnel every opener already goes through.\n */\n restoreLastFocus: () => void\n}\n\nconst ConsentContext = createContext<ConsentContextValue | null>(null)\n\nexport interface ConsentProviderProps {\n children: ReactNode\n /**\n * `cookiePolicyVersion(tenant.cookiePolicy)` from the SITE's analytics\n * config, or `null` when no cookie policy is stored. A stored consent\n * recorded under a DIFFERENT version is treated as unresolved.\n */\n policyVersion: string | null\n}\n\n/** 1 year — matches the retention window discussed in the design proposal §9. */\nconst COOKIE_MAX_AGE_SECONDS = 60 * 60 * 24 * 365\n\nfunction readCookie(name: string): string | null {\n if (typeof document === 'undefined') return null\n const match = document.cookie.match(new RegExp(`(?:^|; )${name}=([^;]*)`))\n return match ? decodeURIComponent(match[1]) : null\n}\n\nfunction writeCookie(name: string, value: string) {\n if (typeof document === 'undefined') return\n document.cookie = `${name}=${encodeURIComponent(value)}; path=/; max-age=${COOKIE_MAX_AGE_SECONDS}; SameSite=Lax`\n}\n\nfunction allCategories(granted: boolean): ConsentCategoryState {\n return CONSENT_CATEGORIES.reduce(\n (state, category) => ({ ...state, [category]: granted }),\n {} as ConsentCategoryState,\n )\n}\n\nexport function ConsentProvider({ children, policyVersion }: ConsentProviderProps) {\n const [status, setStatus] = useState<ConsentStatus>('unknown')\n const [categories, setCategories] = useState<ConsentCategoryState>(DEFAULT_CONSENT_CATEGORIES)\n const [preferencesOpen, setPreferencesOpen] = useState(false)\n const lastFocusedRef = useRef<HTMLElement | null>(null)\n\n // Resolve from the cookie on mount, and whenever the tenant's policy version\n // changes (e.g. a client-navigation that re-fetches SITE config). `document`\n // is unavailable during SSR, so this is deliberately an effect, not a\n // useState initializer — the first render is always 'unknown', avoiding a\n // server/client markup mismatch, and resolves one tick later if a valid\n // cookie exists.\n useEffect(() => {\n const parsed = parseConsentCookie(readCookie(CONSENT_COOKIE_NAME))\n if (parsed && parsed.policyVersion === policyVersion) {\n setCategories(parsed.categories)\n setStatus('resolved')\n } else {\n setCategories(DEFAULT_CONSENT_CATEGORIES)\n setStatus('unknown')\n }\n }, [policyVersion])\n\n function persist(next: ConsentCategoryState) {\n writeCookie(CONSENT_COOKIE_NAME, serializeConsentCookie({ categories: next, policyVersion }))\n setCategories(next)\n setStatus('resolved')\n setPreferencesOpen(false)\n }\n\n const value = useMemo<ConsentContextValue>(\n () => ({\n status,\n categories,\n isGranted: (category) => status === 'resolved' && categories[category] === true,\n acceptAll: () => persist(allCategories(true)),\n rejectAll: () => persist(allCategories(false)),\n savePreferences: (next) => persist(next),\n preferencesOpen,\n openPreferences: () => {\n lastFocusedRef.current =\n typeof document !== 'undefined' ? (document.activeElement as HTMLElement | null) : null\n setPreferencesOpen(true)\n },\n closePreferences: () => setPreferencesOpen(false),\n restoreLastFocus: () => lastFocusedRef.current?.focus(),\n }),\n // eslint-disable-next-line react-hooks/exhaustive-deps\n [status, categories, preferencesOpen, policyVersion],\n )\n\n return <ConsentContext.Provider value={value}>{children}</ConsentContext.Provider>\n}\n\n/** Throws outside a `<ConsentProvider>` — a silent default would be a silent consent bypass. */\nexport function useConsent(): ConsentContextValue {\n const ctx = useContext(ConsentContext)\n if (!ctx) throw new Error('useConsent() must be used within a <ConsentProvider>')\n return ctx\n}\n","/**\n * `../consent-shared` — the truly isomorphic core of cookie consent (REB-642):\n * the cookie name, the category taxonomy, and fail-closed parse/serialise.\n * ZERO dependencies, including Node builtins — this is the one thing that\n * matters about this file's existence.\n *\n * ─────────────────────────────────────────────────────────────────────────────\n * WHY THIS IS A SEPARATE FILE FROM `./consent-server.ts`\n * ─────────────────────────────────────────────────────────────────────────────\n *\n * `./consent` (the client entry) needs `CONSENT_CATEGORIES` and friends, and\n * imports them straight from here. `./consent-server.ts` ALSO needs them, plus\n * `cookiePolicyVersion()`, which needs `node:crypto`. Those two facts collide\n * under this package's build rules: `tsup.config.ts` runs with\n * `treeshake: false` — REQUIRED to keep the `'use client'` prologue on the\n * `./consent` entry from being stripped (see the R4 note there) — and with\n * tree-shaking off, esbuild bundles an imported FILE WHOLESALE, not just the\n * specific binding a module asked for. So if `cookiePolicyVersion` (and its\n * `node:crypto` import) lived in the SAME file as `CONSENT_CATEGORIES`, every\n * client component that only wanted the category list would drag `node:crypto`\n * into the browser bundle too — dead weight at best, a broken build on a\n * bundler stricter than tsup about Node builtins in client code at worst. This\n * was caught live: `tests/consent-subpath-isolation.test.ts` asserts `./consent`\n * never references `crypto`, and failed until this split existed.\n *\n * `./consent-server.ts` re-exports everything here, so\n * `@reblu/site-next/consent-server` keeps serving the full public surface —\n * this split is purely internal.\n *\n * A cookie is attacker- and drift-controlled input: a visitor can edit it in\n * devtools, an older SDK version can leave a differently-shaped payload\n * behind, and a proxy or extension can mangle it in transit.\n * `parseConsentCookie` never coerces a doubtful value toward \"granted\" —\n * anything that is not EXACTLY the expected shape (an object, every known\n * category present and a strict boolean, `policyVersion` a string or `null`)\n * resolves to `null`, which `./consent`'s provider treats identically to \"no\n * cookie yet\": ask again, default every category denied. Silently granting a\n * truthy-but-malformed value is the one mistake this module is built to make\n * impossible.\n */\n\n/** The first-party cookie a visitor's consent choice is stored in. */\nexport const CONSENT_COOKIE_NAME = 'reblu_consent'\n\n/**\n * The fixed, non-tenant-editable category taxonomy (REB-642 §7.1): \"If each\n * SITE invents its own categories, no two clinics can be audited the same\n * way.\" `necessary` is deliberately NOT a member — it is not a choice, it has\n * no toggle, and it is never read from or written to the cookie.\n */\nexport const CONSENT_CATEGORIES = ['analytics', 'marketing', 'preferences'] as const\n\n/** One of the fixed consent categories. */\nexport type ConsentCategory = (typeof CONSENT_CATEGORIES)[number]\n\n/** A visitor's granted/denied state for every optional category. */\nexport type ConsentCategoryState = Record<ConsentCategory, boolean>\n\n/** The full payload stored in the consent cookie. */\nexport interface ConsentCookiePayload {\n categories: ConsentCategoryState\n /** The `cookiePolicyVersion()` in force when this choice was recorded, or `null`. */\n policyVersion: string | null\n}\n\n/** Every optional category denied — the only safe default. */\nexport const DEFAULT_CONSENT_CATEGORIES: Readonly<ConsentCategoryState> = Object.freeze(\n CONSENT_CATEGORIES.reduce(\n (state, category) => ({ ...state, [category]: false }),\n {} as ConsentCategoryState,\n ),\n)\n\nfunction isPlainObject(value: unknown): value is Record<string, unknown> {\n return typeof value === 'object' && value !== null && !Array.isArray(value)\n}\n\n/**\n * Reads and validates the categories object, requiring every known category to\n * be present as a STRICT boolean. Returns `null` on any deviation — a missing\n * category, an extra one being the only thing wrong is fine (ignored, never\n * leaked into the result), but a category that is present and not `true`/`false`\n * literally, or one that is simply absent, fails the whole parse closed. A\n * partial payload does NOT default the missing categories to `false`: that\n * would make \"a payload half-stripped by a proxy\" indistinguishable from \"a\n * visitor who explicitly denied the rest\", which is not a distinction this\n * function is in a position to make safely.\n */\nfunction parseCategories(value: unknown): ConsentCategoryState | null {\n if (!isPlainObject(value)) return null\n\n const result = {} as ConsentCategoryState\n for (const category of CONSENT_CATEGORIES) {\n const raw = value[category]\n if (typeof raw !== 'boolean') return null\n result[category] = raw\n }\n return result\n}\n\n/**\n * Parses a raw consent cookie value into a validated payload, or `null` if the\n * value is absent or does not match the expected shape exactly. See the module\n * doc for why this fails closed rather than coercing.\n */\nexport function parseConsentCookie(raw: string | null | undefined): ConsentCookiePayload | null {\n if (!raw) return null\n\n let json: unknown\n try {\n json = JSON.parse(raw)\n } catch {\n return null\n }\n\n if (!isPlainObject(json)) return null\n\n const categories = parseCategories(json.categories)\n if (!categories) return null\n\n const policyVersion = json.policyVersion\n if (policyVersion !== null && typeof policyVersion !== 'string') return null\n\n return { categories, policyVersion }\n}\n\n/** Serialises a consent payload for storage in the cookie. */\nexport function serializeConsentCookie(payload: ConsentCookiePayload): string {\n return JSON.stringify(payload)\n}\n","'use client'\n\nimport type { ReactNode } from 'react'\n\n// Deliberately `../consent-shared`, not `../consent-server` — see that\n// module's doc comment for why `node:crypto` must never reach this client entry.\nimport type { ConsentCategory } from '../consent-shared'\nimport { useConsent } from './context'\n\nexport interface ConsentGateProps {\n category: ConsentCategory\n children: ReactNode\n}\n\n/**\n * `<ConsentGate>` — the allowlist enforced by construction that REB-642's\n * design settles on: a third party is never rendered except through a\n * component that names a consent category, and a denied or UNRESOLVED\n * category renders `null`.\n *\n * The `status !== 'resolved'` branch matters as much as the denied branch: a\n * gate that rendered children while consent is still `'unknown'` would load\n * the third party on every fresh visitor for the split second before their\n * cookie is read, which is exactly the race `docs/architecture/cookie-consent.md`\n * §6.1 designs around by building tags inside a client component instead of\n * passing a server-rendered `<AnalyticsScripts>` through as `children`.\n */\nexport function ConsentGate({ category, children }: ConsentGateProps) {\n const { status, isGranted } = useConsent()\n\n if (status !== 'resolved') return null\n if (!isGranted(category)) return null\n\n return <>{children}</>\n}\n","import Script from 'next/script'\n\nimport { jsonLdScriptContent } from './json-ld'\n\n/**\n * `<AnalyticsScripts>` — safe rendering of tenant-configured analytics tags (REB-617).\n *\n * THE HOLE THIS CLOSES. Every SITE built on this SDK interpolated the tenant's\n * analytics ids straight into the body of an executing `<script>`:\n *\n * gtag('config','${analytics.gaId}');\n * …\"clarity\",\"script\",\"${analytics.clarityId}\");\n *\n * No validation, no escaping. A `gaId` of `x');alert(document.cookie);//` closes\n * the string literal, closes the call, comments out the rest, and runs arbitrary\n * JS on the TENANT'S OWN ORIGIN — on every page including checkout, same origin\n * as the portal session cookie. For a medical tenant that is patient-session\n * territory. It is the same trust boundary as the nexus-authored HTML that\n * REB-613 closed on the sink next door, except this one reached the JS\n * interpreter with no sanitiser anywhere in the path.\n *\n * TWO INDEPENDENT LAYERS, because either alone is one refactor away from failing:\n *\n * 1. VALIDATION — an id that does not match its vendor-documented shape is\n * dropped and NO script renders. Analytics silently does not load; the site\n * keeps working. Failing closed matters more than failing loud here: the\n * value is cosmetic, the sink is not.\n * 2. ESCAPING — `analyticsScriptLiteral` emits the id as a JSON string literal\n * with the `<script>` boundary neutralised, so even a value that skipped\n * layer 1 (a future field with a looser shape, a caller that reaches for the\n * builders directly) is inert DATA rather than code.\n *\n * WHY VALIDATION ALONE IS NOT ENOUGH, and escaping alone is not either: layer 1\n * is a policy that a product decision can widen (someone adds `GT-` ids, or a\n * Meta Pixel field whose shape is numeric-but-unbounded), and layer 2 is a\n * mechanism that cannot express \"this is not a usable analytics id\". Keeping both\n * means widening the policy never reopens the sink.\n *\n * This lives in the SDK, not in `apps/demo-site`, because every external SITE repo\n * consuming `@reblu/site-contracts` carries the identical snippet — publishing the\n * safe component is the only way the fix reaches them.\n */\n\n/**\n * GA4 measurement id: `G-` followed by alphanumeric characters.\n *\n * Vendor source: Google Analytics Help 12270356 (\"formatted as 'G-' followed by\n * alphanumeric characters\", example `G-PSW1MY7HB4`).\n *\n * NO MINIMUM LENGTH, deliberately. The familiar id is ten characters, but Google\n * documents no length at all and its own docs print `gtag('config', 'G-12345')`\n * (Help 11259997) — inventing a floor here would reject an id Google itself\n * publishes. The upper bound is sanity, not security: the `[A-Z0-9]` class already\n * excludes every character that could leave a JS string literal, so length adds\n * nothing to the guarantee.\n *\n * Uppercase only — every documented example is uppercase, and a lowercase variant\n * is not a Google-issued id. Google tag ids also exist as `GT-` and `AW-`\n * (Help 12326985), but this field is specifically the GA4 *measurement id*\n * (the nexus form labels it so, and the SITE reads it as `gaId`); accepting the\n * other prefixes is a product decision, not a security one, and is deliberately\n * left out of this fix.\n */\nexport const GA_MEASUREMENT_ID_PATTERN = /^G-[A-Z0-9]{1,32}$/\n\n/**\n * Microsoft Clarity project id: lowercase alphanumeric.\n *\n * Vendor source: Microsoft Learn `clarity-setup` publishes a live demo project\n * under id `3t0wlogvdz`; every id Microsoft prints is lowercase alphanumeric of\n * about ten characters. Microsoft documents no formal grammar, so — as with GA —\n * the bound is generous and the character class carries the guarantee.\n *\n * RESIDUAL RISK, stated plainly: no vendor page enumerates the legal Clarity\n * alphabet, so this is inferred from published examples. If some tenant holds an\n * id containing a character outside `[a-z0-9]`, this rejects it. That is why the\n * API coerces a non-matching id to `null` instead of erroring — analytics stops\n * loading for that tenant, the site keeps serving, and the misconfiguration is\n * visible and fixable rather than an outage.\n */\nexport const CLARITY_PROJECT_ID_PATTERN = /^[a-z0-9]{1,32}$/\n\n/** True only for a string matching the vendor-documented GA4 measurement id shape. */\nexport function isValidGaMeasurementId(value: unknown): value is string {\n return typeof value === 'string' && GA_MEASUREMENT_ID_PATTERN.test(value)\n}\n\n/** True only for a string matching the vendor-documented Clarity project id shape. */\nexport function isValidClarityProjectId(value: unknown): value is string {\n return typeof value === 'string' && CLARITY_PROJECT_ID_PATTERN.test(value)\n}\n\n/**\n * Serialises `id` as a JS string literal safe to embed in a `<script>` body.\n *\n * Reuses `jsonLdScriptContent` rather than growing a second escaper: a JSON string\n * literal IS a valid JS string literal, and that helper already does the two things\n * required here — `JSON.stringify` for the quote/backslash/newline boundary, plus\n * `<` `>` `&` U+2028 U+2029 escaping for the HTML raw-text boundary that\n * `JSON.stringify` leaves wide open. One audited primitive, one place to fix.\n */\nexport function analyticsScriptLiteral(id: string): string {\n return jsonLdScriptContent(id)\n}\n\n/** Body of the GA4 bootstrap script. Always escapes; never trusts its caller. */\nexport function gaInitScript(gaId: string): string {\n return `window.dataLayer=window.dataLayer||[];function gtag(){dataLayer.push(arguments);}gtag('js',new Date());gtag('config',${analyticsScriptLiteral(gaId)});`\n}\n\n/** Body of the Microsoft Clarity bootstrap script. Always escapes; never trusts its caller. */\nexport function clarityInitScript(clarityId: string): string {\n return `(function(c,l,a,r,i,t,y){c[a]=c[a]||function(){(c[a].q=c[a].q||[]).push(arguments)};t=l.createElement(r);t.async=1;t.src=\"https://www.clarity.ms/tag/\"+i;y=l.getElementsByTagName(r)[0];y.parentNode.insertBefore(t,y)})(window,document,\"clarity\",\"script\",${analyticsScriptLiteral(clarityId)});`\n}\n\nexport interface AnalyticsScriptsProps {\n /** GA4 measurement id from the SITE analytics contract. Ignored unless it matches `G-…`. */\n gaId?: string | null\n /** Microsoft Clarity project id from the SITE analytics contract. Ignored unless lowercase alphanumeric. */\n clarityId?: string | null\n}\n\n/**\n * Renders the Google Analytics and Microsoft Clarity tags for a tenant, dropping\n * any id that does not match its vendor-documented shape.\n */\nexport function AnalyticsScripts({ gaId, clarityId }: AnalyticsScriptsProps) {\n const safeGaId = isValidGaMeasurementId(gaId) ? gaId : null\n const safeClarityId = isValidClarityProjectId(clarityId) ? clarityId : null\n\n if (!safeGaId && !safeClarityId) return null\n\n return (\n <>\n {safeGaId && (\n <>\n <Script\n src={`https://www.googletagmanager.com/gtag/js?id=${encodeURIComponent(safeGaId)}`}\n strategy='afterInteractive'\n />\n <Script\n id='ga-init'\n strategy='afterInteractive'\n dangerouslySetInnerHTML={{ __html: gaInitScript(safeGaId) }}\n />\n </>\n )}\n\n {safeClarityId && (\n <Script\n id='clarity-init'\n strategy='afterInteractive'\n dangerouslySetInnerHTML={{ __html: clarityInitScript(safeClarityId) }}\n />\n )}\n </>\n )\n}\n","/**\n * `<JsonLd>` — safe `application/ld+json` script renderer (REB-622).\n *\n * A `<script>` element is HTML's \"raw text\" context: the parser closes it on the\n * first case-insensitive `</script` byte sequence it sees, no matter whether\n * that sequence sits inside a JSON string. `JSON.stringify` escapes for JSON\n * syntax, not for this HTML boundary, so it leaves `<` verbatim — turning any\n * JSON-LD payload built from tenant-authored data (page title, description,\n * FAQ copy, …) into a stored-XSS sink: a value containing\n * `</script><img src=x onerror=alert(1)>` breaks out of the element and runs.\n *\n * `jsonLdScriptContent` neutralises exactly that boundary while staying\n * byte-for-byte valid JSON: `<` / `>` / `&` are standard JSON\n * character escapes, so `JSON.parse` recovers the original value unchanged —\n * this is an ESCAPER, not a strip/allowlist sanitiser (see `sanitizeHtml` in\n * `@reblu/site-blocks` for that job, at the HTML boundary rather than the\n * script boundary). U+2028/U+2029 are escaped too: both are valid inside a\n * JSON string but are raw JS line terminators, and some `<script>` consumers\n * (older JS parsers, some static analysers) choke on them unescaped.\n *\n * This package, not `@reblu/site-blocks`, is the home for it: `site-blocks`'s\n * main entry bundles `parse5` + the `hast-util-*` chain + `marked` for\n * `sanitizeHtml` with `treeshake: false` — dist/index.js is ~463 KB, and PR #848\n * spent a full round removing exactly that weight (89 KB gzipped) from a client\n * bundle that only needed one HTML-escaping helper. `<JsonLd>` is Next-specific\n * (App Router's `suppressHydrationWarning` pattern) and this 40-line escaper has\n * zero dependencies of its own, so it ships as its OWN tsup entry\n * (`@reblu/site-next/json-ld`) rather than folding into the root `index` barrel\n * — importing it must never drag in `CtaButtons`/`EditableCta`'s `next/link` +\n * `@reblu/site-ui` graph, the same isolation `./cache` and `./route-handlers`\n * already use for the same reason.\n *\n * @see https://nextjs.org/docs/app/guides/json-ld\n */\n\nimport type { ConfigOpeningHours, ConfigOpeningHoursDay } from '@reblu/site-contracts'\n\nconst SCRIPT_BOUNDARY_UNSAFE = /[<>&\\u2028\\u2029]/g\n\nconst ESCAPES: Record<string, string> = {\n '<': '\\\\u003c',\n '>': '\\\\u003e',\n '&': '\\\\u0026',\n '\\u2028': '\\\\u2028',\n '\\u2029': '\\\\u2029',\n}\n\n/**\n * Serialises `value` for embedding inside `<script type=\"application/ld+json\">`.\n * Escapes `<`, `>`, `&`, U+2028 and U+2029 — every escape is a standard JSON\n * character escape, so `JSON.parse(jsonLdScriptContent(value))` always recovers\n * `value` unchanged. This only ever ADDS escaping; it never strips or rewrites\n * data, unlike an HTML sanitiser.\n *\n * `JSON.stringify(undefined)` returns the JS value `undefined`, not a string\n * (`undefined` has no JSON representation), which would otherwise throw here.\n * `data` is typed `unknown` with zero friction toward `undefined`, so this\n * falls back to the JSON literal `'null'` rather than crashing an entire page\n * render over one missing field.\n */\nexport function jsonLdScriptContent(value: unknown): string {\n const json = JSON.stringify(value) ?? 'null'\n return json.replace(SCRIPT_BOUNDARY_UNSAFE, (char) => ESCAPES[char])\n}\n\nexport interface JsonLdProps {\n /** Suffix for the rendered `<script>` id: `json-ld-${id}`. */\n id: string\n /** Structured data to serialise. Any JSON-serialisable value. */\n data: unknown\n}\n\n/**\n * Renders one `application/ld+json` `<script>` tag using the official Next.js\n * App Router pattern. `suppressHydrationWarning` is required here: React warns\n * on a server/client `textContent` mismatch for this element even though there\n * is no real divergence — `dangerouslySetInnerHTML` content is opaque to React's\n * hydration diff.\n */\nexport function JsonLd({ id, data }: JsonLdProps) {\n return (\n <script\n id={`json-ld-${id}`}\n type='application/ld+json'\n suppressHydrationWarning\n dangerouslySetInnerHTML={{ __html: jsonLdScriptContent(data) }}\n />\n )\n}\n\n// ─── Opening hours → schema.org OpeningHoursSpecification (REB-826) ──────────\n\n/** schema.org `OpeningHoursSpecification`, the shape a `LocalBusiness`/\n * `MedicalOrganization`/etc. entity's `openingHoursSpecification` array holds.\n * @see https://schema.org/OpeningHoursSpecification\n */\nexport interface OpeningHoursSpecification {\n '@type': 'OpeningHoursSpecification'\n dayOfWeek: string[]\n opens: string\n closes: string\n}\n\n/** schema.org `DayOfWeek` URIs, keyed by the Reblu `ConfigOpeningHoursDay` enum. */\nconst SCHEMA_DAY_OF_WEEK: Record<ConfigOpeningHoursDay, string> = {\n MON: 'https://schema.org/Monday',\n TUE: 'https://schema.org/Tuesday',\n WED: 'https://schema.org/Wednesday',\n THU: 'https://schema.org/Thursday',\n FRI: 'https://schema.org/Friday',\n SAT: 'https://schema.org/Saturday',\n SUN: 'https://schema.org/Sunday',\n}\n\n/**\n * Maps the Reblu `Config.openingHours` typed value to one\n * `OpeningHoursSpecification` object per range, each with its `days` turned\n * into schema.org `DayOfWeek` URIs. One range = one spec object; this does\n * NOT group or reformat for display (see `formatOpeningHours` in\n * `@reblu/site-next/opening-hours` for that) — structured data wants one\n * entry per machine-readable (days, opens, closes) tuple, not prose.\n *\n * Returns `undefined` — not `[]` — for \"nothing to merge\" (no typed hours, or\n * a typed value with zero ranges), so a caller building a JSON-LD entity with\n * `entity.openingHoursSpecification = toOpeningHoursSpecification(...)` can\n * `if (spec)` guard it without emitting an empty array into the graph.\n *\n * Used both by SITE builds assembling their own JSON-LD (the published use\n * case this SDK exists for) and by base-api's own structured-data builder —\n * see `generate-structured-data.ts`. Unlike `cookiePolicyVersion`\n * (`@reblu/site-next/consent-server`), this is NOT duplicated into base-api:\n * that duplication exists only where two independently-deployed sides (a\n * SITE's stored cookie vs. base-api's request-time check) must each derive\n * the identical value without talking to each other. Here base-api is the\n * ONLY side computing this value — there is no second computation to agree\n * with — so importing the single implementation is strictly better than a\n * second copy that could drift.\n *\n * Defensive against a malformed stored value — `Config.openingHours` is a\n * compile-time guarantee only. A range whose `days` is empty, or whose codes\n * are all unrecognised (outside `SCHEMA_DAY_OF_WEEK`'s keys), is dropped\n * entirely rather than emitting a spec object with an empty `dayOfWeek` (or\n * one padded with `undefined` for every unrecognised code). If every range\n * is dropped this way, the result is `undefined`, never `[]`.\n */\nexport function toOpeningHoursSpecification(\n openingHours: ConfigOpeningHours | null | undefined,\n): OpeningHoursSpecification[] | undefined {\n if (!openingHours || openingHours.ranges.length === 0) return undefined\n const specs: OpeningHoursSpecification[] = []\n for (const range of openingHours.ranges) {\n const dayOfWeek = range.days\n // `Object.hasOwn`, not `day in SCHEMA_DAY_OF_WEEK`: the `in` operator\n // also matches INHERITED properties (`'constructor'`, `'toString'`,\n // `'hasOwnProperty'`, …), which are not own keys of this lookup object\n // but would still pass an `in` check and resolve to a function, not a\n // schema.org URI string — corrupting `dayOfWeek` with exactly the kind\n // of malformed entry this filtering exists to prevent.\n .filter((day): day is ConfigOpeningHoursDay => Object.hasOwn(SCHEMA_DAY_OF_WEEK, day))\n .map((day) => SCHEMA_DAY_OF_WEEK[day])\n if (dayOfWeek.length === 0) continue\n specs.push({\n '@type': 'OpeningHoursSpecification',\n dayOfWeek,\n opens: range.opens,\n closes: range.closes,\n })\n }\n return specs.length > 0 ? specs : undefined\n}\n","'use client'\n\nimport { AnalyticsScripts, type AnalyticsScriptsProps } from '../analytics'\nimport { ConsentGate } from './gate'\n\n/**\n * `<ConsentedAnalytics>` — the `'analytics'`-gated replacement for rendering\n * `<AnalyticsScripts>` directly in a SITE layout (REB-642 Slice 1).\n *\n * Reuses the REB-617 validation + escaping builders verbatim via\n * `<AnalyticsScripts>` — this component adds NOTHING to the sink-safety\n * argument in `../analytics.tsx`, it only decides WHETHER that already-safe\n * renderer mounts at all. A SITE layout that still imports\n * `@reblu/site-next/analytics` directly bypasses consent entirely, which is\n * exactly what the source-scanning guard (REB-642 Slice 1, demo-site) exists\n * to catch — this component is the mechanism that guard enforces.\n */\nexport function ConsentedAnalytics(props: AnalyticsScriptsProps) {\n return (\n <ConsentGate category='analytics'>\n <AnalyticsScripts {...props} />\n </ConsentGate>\n )\n}\n","'use client'\n\nimport { useEffect, useState, type ReactNode } from 'react'\nimport * as Dialog from '@radix-ui/react-dialog'\nimport { Button, cn } from '@reblu/site-ui'\n\n// Deliberately `../consent-shared`, not `../consent-server` — see that\n// module's doc comment for why `node:crypto` must never reach this client entry.\nimport { CONSENT_CATEGORIES, type ConsentCategoryState } from '../consent-shared'\nimport { useConsent } from './context'\nimport { DEFAULT_CONSENT_BANNER_TEXTS, type ConsentBannerTexts } from './texts'\n\n/**\n * `<CookieBanner>` + `<CookiePreferencesTrigger>` — the visible layer of\n * REB-642's consent mechanism.\n *\n * ─────────────────────────────────────────────────────────────────────────────\n * ARCHITECTURE: ONE DIALOG, TWO OPENERS\n * ─────────────────────────────────────────────────────────────────────────────\n *\n * `<CookieBanner>` owns rendering the preferences dialog (mount it ONCE, at\n * the root of the SITE layout — it portals to `<body>` regardless of where it\n * is mounted). `<CookiePreferencesTrigger>` is a small, unstyled-by-default\n * button usable ANYWHERE ELSE in the tree (a footer link, a settings page) that\n * opens the SAME dialog via `useConsent().openPreferences()`. Both read the\n * single `preferencesOpen` boolean the `<ConsentProvider>` owns, so there is\n * never more than one dialog instance and never two focus traps competing.\n *\n * This is what REB-642 calls \"permanent withdrawal\" — a visitor's route back\n * to their preferences does not depend on the first-layer banner still being\n * on screen; the banner disappears once resolved, the trigger does not.\n *\n * ─────────────────────────────────────────────────────────────────────────────\n * THE THREE-BUTTON RULE (owner decision, not configurable)\n * ─────────────────────────────────────────────────────────────────────────────\n *\n * Reject / Configure / Accept render as three same-variant, same-size buttons\n * in that visual order — no button is styled as more prominent than another.\n * A tenant can override `title`/`description` text (REB-642 §7.1 recommends\n * v1 ship with NO tenant-editable copy at all, but the prop exists so a later\n * slice can add it without touching this component's contract); the buttons,\n * their order, and the category taxonomy are fixed.\n */\n\nexport interface CookieBannerProps {\n /** Overrides merged onto `DEFAULT_CONSENT_BANNER_TEXTS`. */\n texts?: Partial<ConsentBannerTexts>\n /** Where \"Política de cookies\" links to. */\n policyHref?: string\n}\n\nfunction resolveTexts(overrides?: Partial<ConsentBannerTexts>): ConsentBannerTexts {\n return { ...DEFAULT_CONSENT_BANNER_TEXTS, ...overrides }\n}\n\nexport function CookieBanner({ texts: textsOverride, policyHref = '/cookies' }: CookieBannerProps) {\n const { status, acceptAll, rejectAll, openPreferences } = useConsent()\n const texts = resolveTexts(textsOverride)\n\n return (\n <>\n {status === 'unknown' && (\n <div\n role='region'\n aria-label={texts.title}\n className='fixed inset-x-0 bottom-0 z-50 border-t border-border bg-background/95 px-4 py-4 backdrop-blur supports-[backdrop-filter]:bg-background/80'\n data-slot='cookie-banner'\n >\n <div className='mx-auto flex max-w-4xl flex-col gap-3 sm:flex-row sm:items-center sm:justify-between'>\n <div className='text-sm'>\n <p className='font-semibold'>{texts.title}</p>\n <p className='text-muted-foreground'>\n {texts.description}{' '}\n <a href={policyHref} className='underline underline-offset-2'>\n {texts.policyLinkLabel}\n </a>\n </p>\n </div>\n {/* Three equal-weight buttons, fixed order: reject, configure, accept.\n Same variant/size for all three — rejecting must cost exactly what\n accepting costs (AEPD first-layer requirement, REB-642). */}\n <div className='flex shrink-0 flex-wrap gap-2'>\n <Button type='button' variant='outline' size='sm' onClick={rejectAll}>\n {texts.rejectLabel}\n </Button>\n <Button type='button' variant='outline' size='sm' onClick={openPreferences}>\n {texts.manageLabel}\n </Button>\n <Button type='button' variant='outline' size='sm' onClick={acceptAll}>\n {texts.acceptLabel}\n </Button>\n </div>\n </div>\n </div>\n )}\n <ConsentPreferencesDialog texts={texts} />\n </>\n )\n}\n\nexport interface CookiePreferencesTriggerProps {\n children?: ReactNode\n className?: string\n texts?: Partial<ConsentBannerTexts>\n}\n\n/**\n * A persistent \"configure cookies\" entry point, safe to place anywhere in the\n * tree (typically a footer link) once `<CookieBanner>` is mounted at the root.\n * Opens the same preferences dialog `<CookieBanner>` owns.\n */\nexport function CookiePreferencesTrigger({\n children,\n className,\n texts: textsOverride,\n}: CookiePreferencesTriggerProps) {\n const { openPreferences } = useConsent()\n const texts = resolveTexts(textsOverride)\n\n return (\n <button type='button' onClick={openPreferences} className={className} data-slot='cookie-preferences-trigger'>\n {children ?? `${texts.manageLabel} cookies`}\n </button>\n )\n}\n\ninterface ConsentPreferencesDialogProps {\n texts: ConsentBannerTexts\n}\n\n/**\n * The second layer — a per-category preferences panel. `necessary` is\n * rendered as an always-checked, disabled checkbox for transparency (a\n * visitor can SEE that necessary cookies exist and why) without offering a\n * toggle that has no real effect, which would be dishonest UI.\n */\nfunction ConsentPreferencesDialog({ texts }: ConsentPreferencesDialogProps) {\n const { categories, preferencesOpen, closePreferences, savePreferences, restoreLastFocus } = useConsent()\n const [draft, setDraft] = useState<ConsentCategoryState>(categories)\n\n // Re-seed the draft from the last-saved categories every time the dialog\n // opens, so stale unsaved toggles from a previous open are never carried\n // into the next one.\n useEffect(() => {\n if (preferencesOpen) setDraft(categories)\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [preferencesOpen])\n\n return (\n <Dialog.Root\n open={preferencesOpen}\n onOpenChange={(open) => {\n // Radix only calls this for state changes IT initiates (Escape,\n // overlay click, `<Dialog.Close>`) — opening is entirely driven by\n // `useConsent().openPreferences()` from outside, which is also where\n // focus is captured (see `../consent/context.tsx`). So this handler\n // only ever needs to handle the closing transition.\n if (!open) closePreferences()\n }}\n >\n <Dialog.Portal>\n <Dialog.Overlay className='fixed inset-0 z-50 bg-black/50' />\n <Dialog.Content\n className='fixed left-1/2 top-1/2 z-50 w-full max-w-md -translate-x-1/2 -translate-y-1/2 rounded-lg border border-border bg-background p-6 shadow-lg'\n data-slot='cookie-preferences-dialog'\n onCloseAutoFocus={(event) => {\n event.preventDefault()\n restoreLastFocus()\n }}\n >\n <Dialog.Title className='text-base font-semibold'>{texts.preferencesTitle}</Dialog.Title>\n <Dialog.Description className='mt-1 text-sm text-muted-foreground'>\n {texts.preferencesDescription}\n </Dialog.Description>\n\n <div className='mt-4 flex flex-col gap-3'>\n <CategoryRow\n id='consent-category-necessary'\n label={texts.necessaryLabel}\n description={texts.necessaryDescription}\n checked\n disabled\n onChange={() => undefined}\n />\n {CONSENT_CATEGORIES.map((category) => (\n <CategoryRow\n key={category}\n id={`consent-category-${category}`}\n label={texts.categoryLabels[category]}\n description={texts.categoryDescriptions[category]}\n checked={draft[category]}\n disabled={false}\n onChange={(checked) => setDraft((prev) => ({ ...prev, [category]: checked }))}\n />\n ))}\n </div>\n\n <div className='mt-6 flex justify-end'>\n <Button type='button' size='sm' onClick={() => savePreferences(draft)}>\n {texts.saveLabel}\n </Button>\n </div>\n </Dialog.Content>\n </Dialog.Portal>\n </Dialog.Root>\n )\n}\n\ninterface CategoryRowProps {\n id: string\n label: string\n description: string\n checked: boolean\n disabled: boolean\n onChange: (checked: boolean) => void\n}\n\nfunction CategoryRow({ id, label, description, checked, disabled, onChange }: CategoryRowProps) {\n return (\n <div className={cn('flex items-start gap-3', disabled && 'opacity-70')}>\n <input\n id={id}\n type='checkbox'\n checked={checked}\n disabled={disabled}\n onChange={(event) => onChange(event.target.checked)}\n className='mt-1 size-4'\n />\n <label htmlFor={id} className='flex flex-col text-sm'>\n <span className='font-medium'>{label}</span>\n <span className='text-muted-foreground'>{description}</span>\n </label>\n </div>\n )\n}\n","import type { ConsentCategory } from '../consent-shared'\n\n/**\n * Copy for `<CookieBanner>`. SDK-owned defaults in Spanish (every Reblu SITE\n * renders `<html lang='es'>` today).\n *\n * REB-642 §7.1 recommends tenant-editable copy be limited to a TITLE/BODY\n * override for v1 — the per-category descriptions, the \"necessary\" copy and\n * every button label stay fixed, because the copy is a compliance surface: a\n * tenant writing \"we use cookies — OK\" with no reject option is the exact\n * misconfiguration this design exists to prevent. This type is intentionally\n * a `Partial<ConsentBannerTexts>` at the call site (see `CookieBanner`), so a\n * later tenant-configuration slice can override `title`/`description` without\n * touching this component's contract.\n */\nexport interface ConsentBannerTexts {\n title: string\n description: string\n acceptLabel: string\n rejectLabel: string\n manageLabel: string\n policyLinkLabel: string\n preferencesTitle: string\n preferencesDescription: string\n saveLabel: string\n necessaryLabel: string\n necessaryDescription: string\n categoryLabels: Record<ConsentCategory, string>\n categoryDescriptions: Record<ConsentCategory, string>\n}\n\nexport const DEFAULT_CONSENT_BANNER_TEXTS: ConsentBannerTexts = {\n title: 'Utilizamos cookies',\n description:\n 'Usamos cookies estrictamente necesarias para el funcionamiento del sitio. Con tu permiso, también usaríamos cookies de analítica y marketing para mejorar tu experiencia.',\n acceptLabel: 'Aceptar todas',\n rejectLabel: 'Rechazar todas',\n manageLabel: 'Configurar',\n policyLinkLabel: 'Política de cookies',\n preferencesTitle: 'Preferencias de cookies',\n preferencesDescription:\n 'Elige qué categorías de cookies opcionales permites. Las cookies necesarias no se pueden desactivar.',\n saveLabel: 'Guardar preferencias',\n necessaryLabel: 'Necesarias',\n necessaryDescription:\n 'Imprescindibles para el funcionamiento del sitio, como la seguridad del formulario de contacto. Siempre activas.',\n categoryLabels: {\n analytics: 'Analítica',\n marketing: 'Marketing',\n preferences: 'Preferencias',\n },\n categoryDescriptions: {\n analytics: 'Nos ayudan a entender cómo se usa el sitio (por ejemplo, Google Analytics o Microsoft Clarity).',\n marketing: 'Permiten mostrar contenido relevante de terceros (por ejemplo, Meta Pixel).',\n preferences: 'Recuerdan tus preferencias de navegación entre visitas.',\n },\n}\n"],"mappings":"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","names":["jsx","jsx","Fragment","jsx","jsx","useEffect","useState","Fragment","jsx","jsxs","useState","useEffect"]}
1
+ {"version":3,"sources":["../src/consent/context.tsx","../src/consent-shared.ts","../src/consent/gate.tsx","../src/analytics.tsx","../src/json-ld.tsx","../src/consent/consented-analytics.tsx","../src/consent/banner.tsx","../src/consent/texts.ts"],"sourcesContent":["'use client'\n\nimport { createContext, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'\n\nimport {\n CONSENT_CATEGORIES,\n CONSENT_COOKIE_NAME,\n DEFAULT_CONSENT_CATEGORIES,\n parseConsentCookie,\n serializeConsentCookie,\n type ConsentCategory,\n type ConsentCategoryState,\n // Deliberately `../consent-shared`, NEVER `../consent-server`: the latter\n // pulls in `cookiePolicyVersion` and its `node:crypto` import, which must\n // never reach this client entry — see `../consent-shared.ts`'s doc comment.\n} from '../consent-shared'\n\n/**\n * `<ConsentProvider>` / `useConsent()` — the client half of cookie consent\n * (REB-642). See `../consent-shared` for the fail-closed parsing this relies\n * on (imported from there, not `../consent-server`, to keep `node:crypto` out\n * of this client entry), and `docs/architecture/cookie-consent.md` for the\n * full design.\n *\n * `status: 'unknown'` is the DEFAULT-DENIED state and the value every\n * `<ConsentGate>` treats as \"not granted\" — see `./gate.tsx`. It covers three\n * cases identically, on purpose: no cookie yet, a cookie that failed\n * `parseConsentCookie`, and a cookie recorded under a `policyVersion` that no\n * longer matches (the tenant edited the cookie policy — REB-642's \"re-ask when\n * the version changes\"). None of the three may ever resolve to granted.\n */\n\n/** Whether the visitor's choice for the CURRENT policy version is known. */\nexport type ConsentStatus = 'unknown' | 'resolved'\n\nexport interface ConsentContextValue {\n status: ConsentStatus\n categories: ConsentCategoryState\n /** True only when `status === 'resolved'` AND the category was explicitly granted. */\n isGranted: (category: ConsentCategory) => boolean\n /** Grants every category and persists immediately. */\n acceptAll: () => void\n /** Denies every category and persists immediately — the denial persists across reloads. */\n rejectAll: () => void\n /** Persists exactly the categories passed — no category is granted implicitly. */\n savePreferences: (categories: ConsentCategoryState) => void\n preferencesOpen: boolean\n openPreferences: () => void\n closePreferences: () => void\n /**\n * Refocuses whatever element had focus immediately before `openPreferences`\n * was called. The preferences dialog is openable from more than one place\n * (the banner's \"Configure\" button, `<CookiePreferencesTrigger>` elsewhere\n * in the tree) with no single `<Dialog.Trigger>` for Radix's own\n * focus-restore to key off, so this is tracked explicitly here — the one\n * funnel every opener already goes through.\n */\n restoreLastFocus: () => void\n}\n\nconst ConsentContext = createContext<ConsentContextValue | null>(null)\n\nexport interface ConsentProviderProps {\n children: ReactNode\n /**\n * `cookiePolicyVersion(tenant.cookiePolicy)` from the SITE's analytics\n * config, or `null` when no cookie policy is stored. A stored consent\n * recorded under a DIFFERENT version is treated as unresolved.\n */\n policyVersion: string | null\n}\n\n/** 1 year — matches the retention window discussed in the design proposal §9. */\nconst COOKIE_MAX_AGE_SECONDS = 60 * 60 * 24 * 365\n\nfunction readCookie(name: string): string | null {\n if (typeof document === 'undefined') return null\n const match = document.cookie.match(new RegExp(`(?:^|; )${name}=([^;]*)`))\n return match ? decodeURIComponent(match[1]) : null\n}\n\nfunction writeCookie(name: string, value: string) {\n if (typeof document === 'undefined') return\n document.cookie = `${name}=${encodeURIComponent(value)}; path=/; max-age=${COOKIE_MAX_AGE_SECONDS}; SameSite=Lax`\n}\n\nfunction allCategories(granted: boolean): ConsentCategoryState {\n return CONSENT_CATEGORIES.reduce(\n (state, category) => ({ ...state, [category]: granted }),\n {} as ConsentCategoryState,\n )\n}\n\nexport function ConsentProvider({ children, policyVersion }: ConsentProviderProps) {\n const [status, setStatus] = useState<ConsentStatus>('unknown')\n const [categories, setCategories] = useState<ConsentCategoryState>(DEFAULT_CONSENT_CATEGORIES)\n const [preferencesOpen, setPreferencesOpen] = useState(false)\n const lastFocusedRef = useRef<HTMLElement | null>(null)\n\n // Resolve from the cookie on mount, and whenever the tenant's policy version\n // changes (e.g. a client-navigation that re-fetches SITE config). `document`\n // is unavailable during SSR, so this is deliberately an effect, not a\n // useState initializer — the first render is always 'unknown', avoiding a\n // server/client markup mismatch, and resolves one tick later if a valid\n // cookie exists.\n useEffect(() => {\n const parsed = parseConsentCookie(readCookie(CONSENT_COOKIE_NAME))\n if (parsed && parsed.policyVersion === policyVersion) {\n setCategories(parsed.categories)\n setStatus('resolved')\n } else {\n setCategories(DEFAULT_CONSENT_CATEGORIES)\n setStatus('unknown')\n }\n }, [policyVersion])\n\n function persist(next: ConsentCategoryState) {\n writeCookie(CONSENT_COOKIE_NAME, serializeConsentCookie({ categories: next, policyVersion }))\n setCategories(next)\n setStatus('resolved')\n setPreferencesOpen(false)\n }\n\n const value = useMemo<ConsentContextValue>(\n () => ({\n status,\n categories,\n isGranted: (category) => status === 'resolved' && categories[category] === true,\n acceptAll: () => persist(allCategories(true)),\n rejectAll: () => persist(allCategories(false)),\n savePreferences: (next) => persist(next),\n preferencesOpen,\n openPreferences: () => {\n lastFocusedRef.current =\n typeof document !== 'undefined' ? (document.activeElement as HTMLElement | null) : null\n setPreferencesOpen(true)\n },\n closePreferences: () => setPreferencesOpen(false),\n restoreLastFocus: () => lastFocusedRef.current?.focus(),\n }),\n // eslint-disable-next-line react-hooks/exhaustive-deps\n [status, categories, preferencesOpen, policyVersion],\n )\n\n return <ConsentContext.Provider value={value}>{children}</ConsentContext.Provider>\n}\n\n/** Throws outside a `<ConsentProvider>` — a silent default would be a silent consent bypass. */\nexport function useConsent(): ConsentContextValue {\n const ctx = useContext(ConsentContext)\n if (!ctx) throw new Error('useConsent() must be used within a <ConsentProvider>')\n return ctx\n}\n","/**\n * `../consent-shared` — the truly isomorphic core of cookie consent (REB-642):\n * the cookie name, the category taxonomy, and fail-closed parse/serialise.\n * ZERO dependencies, including Node builtins — this is the one thing that\n * matters about this file's existence.\n *\n * ─────────────────────────────────────────────────────────────────────────────\n * WHY THIS IS A SEPARATE FILE FROM `./consent-server.ts`\n * ─────────────────────────────────────────────────────────────────────────────\n *\n * `./consent` (the client entry) needs `CONSENT_CATEGORIES` and friends, and\n * imports them straight from here. `./consent-server.ts` ALSO needs them, plus\n * `cookiePolicyVersion()`, which needs `node:crypto`. Those two facts collide\n * under this package's build rules: `tsup.config.ts` runs with\n * `treeshake: false` — REQUIRED to keep the `'use client'` prologue on the\n * `./consent` entry from being stripped (see the R4 note there) — and with\n * tree-shaking off, esbuild bundles an imported FILE WHOLESALE, not just the\n * specific binding a module asked for. So if `cookiePolicyVersion` (and its\n * `node:crypto` import) lived in the SAME file as `CONSENT_CATEGORIES`, every\n * client component that only wanted the category list would drag `node:crypto`\n * into the browser bundle too — dead weight at best, a broken build on a\n * bundler stricter than tsup about Node builtins in client code at worst. This\n * was caught live: `tests/consent-subpath-isolation.test.ts` asserts `./consent`\n * never references `crypto`, and failed until this split existed.\n *\n * `./consent-server.ts` re-exports everything here, so\n * `@reblu/site-next/consent-server` keeps serving the full public surface —\n * this split is purely internal.\n *\n * A cookie is attacker- and drift-controlled input: a visitor can edit it in\n * devtools, an older SDK version can leave a differently-shaped payload\n * behind, and a proxy or extension can mangle it in transit.\n * `parseConsentCookie` never coerces a doubtful value toward \"granted\" —\n * anything that is not EXACTLY the expected shape (an object, every known\n * category present and a strict boolean, `policyVersion` a string or `null`)\n * resolves to `null`, which `./consent`'s provider treats identically to \"no\n * cookie yet\": ask again, default every category denied. Silently granting a\n * truthy-but-malformed value is the one mistake this module is built to make\n * impossible.\n */\n\n/** The first-party cookie a visitor's consent choice is stored in. */\nexport const CONSENT_COOKIE_NAME = 'reblu_consent'\n\n/**\n * The fixed, non-tenant-editable category taxonomy (REB-642 §7.1): \"If each\n * SITE invents its own categories, no two clinics can be audited the same\n * way.\" `necessary` is deliberately NOT a member — it is not a choice, it has\n * no toggle, and it is never read from or written to the cookie.\n */\nexport const CONSENT_CATEGORIES = ['analytics', 'marketing', 'preferences'] as const\n\n/** One of the fixed consent categories. */\nexport type ConsentCategory = (typeof CONSENT_CATEGORIES)[number]\n\n/** A visitor's granted/denied state for every optional category. */\nexport type ConsentCategoryState = Record<ConsentCategory, boolean>\n\n/** The full payload stored in the consent cookie. */\nexport interface ConsentCookiePayload {\n categories: ConsentCategoryState\n /** The `cookiePolicyVersion()` in force when this choice was recorded, or `null`. */\n policyVersion: string | null\n}\n\n/** Every optional category denied — the only safe default. */\nexport const DEFAULT_CONSENT_CATEGORIES: Readonly<ConsentCategoryState> = Object.freeze(\n CONSENT_CATEGORIES.reduce(\n (state, category) => ({ ...state, [category]: false }),\n {} as ConsentCategoryState,\n ),\n)\n\nfunction isPlainObject(value: unknown): value is Record<string, unknown> {\n return typeof value === 'object' && value !== null && !Array.isArray(value)\n}\n\n/**\n * Reads and validates the categories object, requiring every known category to\n * be present as a STRICT boolean. Returns `null` on any deviation — a missing\n * category, an extra one being the only thing wrong is fine (ignored, never\n * leaked into the result), but a category that is present and not `true`/`false`\n * literally, or one that is simply absent, fails the whole parse closed. A\n * partial payload does NOT default the missing categories to `false`: that\n * would make \"a payload half-stripped by a proxy\" indistinguishable from \"a\n * visitor who explicitly denied the rest\", which is not a distinction this\n * function is in a position to make safely.\n */\nfunction parseCategories(value: unknown): ConsentCategoryState | null {\n if (!isPlainObject(value)) return null\n\n const result = {} as ConsentCategoryState\n for (const category of CONSENT_CATEGORIES) {\n const raw = value[category]\n if (typeof raw !== 'boolean') return null\n result[category] = raw\n }\n return result\n}\n\n/**\n * Parses a raw consent cookie value into a validated payload, or `null` if the\n * value is absent or does not match the expected shape exactly. See the module\n * doc for why this fails closed rather than coercing.\n */\nexport function parseConsentCookie(raw: string | null | undefined): ConsentCookiePayload | null {\n if (!raw) return null\n\n let json: unknown\n try {\n json = JSON.parse(raw)\n } catch {\n return null\n }\n\n if (!isPlainObject(json)) return null\n\n const categories = parseCategories(json.categories)\n if (!categories) return null\n\n const policyVersion = json.policyVersion\n if (policyVersion !== null && typeof policyVersion !== 'string') return null\n\n return { categories, policyVersion }\n}\n\n/** Serialises a consent payload for storage in the cookie. */\nexport function serializeConsentCookie(payload: ConsentCookiePayload): string {\n return JSON.stringify(payload)\n}\n","'use client'\n\nimport type { ReactNode } from 'react'\n\n// Deliberately `../consent-shared`, not `../consent-server` — see that\n// module's doc comment for why `node:crypto` must never reach this client entry.\nimport type { ConsentCategory } from '../consent-shared'\nimport { useConsent } from './context'\n\nexport interface ConsentGateProps {\n category: ConsentCategory\n children: ReactNode\n}\n\n/**\n * `<ConsentGate>` — the allowlist enforced by construction that REB-642's\n * design settles on: a third party is never rendered except through a\n * component that names a consent category, and a denied or UNRESOLVED\n * category renders `null`.\n *\n * The `status !== 'resolved'` branch matters as much as the denied branch: a\n * gate that rendered children while consent is still `'unknown'` would load\n * the third party on every fresh visitor for the split second before their\n * cookie is read, which is exactly the race `docs/architecture/cookie-consent.md`\n * §6.1 designs around by building tags inside a client component instead of\n * passing a server-rendered `<AnalyticsScripts>` through as `children`.\n */\nexport function ConsentGate({ category, children }: ConsentGateProps) {\n const { status, isGranted } = useConsent()\n\n if (status !== 'resolved') return null\n if (!isGranted(category)) return null\n\n return <>{children}</>\n}\n","import Script from 'next/script'\n\nimport { jsonLdScriptContent } from './json-ld'\n\n/**\n * `<AnalyticsScripts>` — safe rendering of tenant-configured analytics tags (REB-617).\n *\n * THE HOLE THIS CLOSES. Every SITE built on this SDK interpolated the tenant's\n * analytics ids straight into the body of an executing `<script>`:\n *\n * gtag('config','${analytics.gaId}');\n * …\"clarity\",\"script\",\"${analytics.clarityId}\");\n *\n * No validation, no escaping. A `gaId` of `x');alert(document.cookie);//` closes\n * the string literal, closes the call, comments out the rest, and runs arbitrary\n * JS on the TENANT'S OWN ORIGIN — on every page including checkout, same origin\n * as the portal session cookie. For a medical tenant that is patient-session\n * territory. It is the same trust boundary as the nexus-authored HTML that\n * REB-613 closed on the sink next door, except this one reached the JS\n * interpreter with no sanitiser anywhere in the path.\n *\n * TWO INDEPENDENT LAYERS, because either alone is one refactor away from failing:\n *\n * 1. VALIDATION — an id that does not match its vendor-documented shape is\n * dropped and NO script renders. Analytics silently does not load; the site\n * keeps working. Failing closed matters more than failing loud here: the\n * value is cosmetic, the sink is not.\n * 2. ESCAPING — `analyticsScriptLiteral` emits the id as a JSON string literal\n * with the `<script>` boundary neutralised, so even a value that skipped\n * layer 1 (a future field with a looser shape, a caller that reaches for the\n * builders directly) is inert DATA rather than code.\n *\n * WHY VALIDATION ALONE IS NOT ENOUGH, and escaping alone is not either: layer 1\n * is a policy that a product decision can widen (someone adds `GT-` ids, or a\n * Meta Pixel field whose shape is numeric-but-unbounded), and layer 2 is a\n * mechanism that cannot express \"this is not a usable analytics id\". Keeping both\n * means widening the policy never reopens the sink.\n *\n * This lives in the SDK, not in `apps/demo-site`, because every external SITE repo\n * consuming `@reblu/site-contracts` carries the identical snippet — publishing the\n * safe component is the only way the fix reaches them.\n */\n\n/**\n * GA4 measurement id: `G-` followed by alphanumeric characters.\n *\n * Vendor source: Google Analytics Help 12270356 (\"formatted as 'G-' followed by\n * alphanumeric characters\", example `G-PSW1MY7HB4`).\n *\n * NO MINIMUM LENGTH, deliberately. The familiar id is ten characters, but Google\n * documents no length at all and its own docs print `gtag('config', 'G-12345')`\n * (Help 11259997) — inventing a floor here would reject an id Google itself\n * publishes. The upper bound is sanity, not security: the `[A-Z0-9]` class already\n * excludes every character that could leave a JS string literal, so length adds\n * nothing to the guarantee.\n *\n * Uppercase only — every documented example is uppercase, and a lowercase variant\n * is not a Google-issued id. Google tag ids also exist as `GT-` and `AW-`\n * (Help 12326985), but this field is specifically the GA4 *measurement id*\n * (the nexus form labels it so, and the SITE reads it as `gaId`); accepting the\n * other prefixes is a product decision, not a security one, and is deliberately\n * left out of this fix.\n */\nexport const GA_MEASUREMENT_ID_PATTERN = /^G-[A-Z0-9]{1,32}$/\n\n/**\n * Microsoft Clarity project id: lowercase alphanumeric.\n *\n * Vendor source: Microsoft Learn `clarity-setup` publishes a live demo project\n * under id `3t0wlogvdz`; every id Microsoft prints is lowercase alphanumeric of\n * about ten characters. Microsoft documents no formal grammar, so — as with GA —\n * the bound is generous and the character class carries the guarantee.\n *\n * RESIDUAL RISK, stated plainly: no vendor page enumerates the legal Clarity\n * alphabet, so this is inferred from published examples. If some tenant holds an\n * id containing a character outside `[a-z0-9]`, this rejects it. That is why the\n * API coerces a non-matching id to `null` instead of erroring — analytics stops\n * loading for that tenant, the site keeps serving, and the misconfiguration is\n * visible and fixable rather than an outage.\n */\nexport const CLARITY_PROJECT_ID_PATTERN = /^[a-z0-9]{1,32}$/\n\n/** True only for a string matching the vendor-documented GA4 measurement id shape. */\nexport function isValidGaMeasurementId(value: unknown): value is string {\n return typeof value === 'string' && GA_MEASUREMENT_ID_PATTERN.test(value)\n}\n\n/** True only for a string matching the vendor-documented Clarity project id shape. */\nexport function isValidClarityProjectId(value: unknown): value is string {\n return typeof value === 'string' && CLARITY_PROJECT_ID_PATTERN.test(value)\n}\n\n/**\n * Serialises `id` as a JS string literal safe to embed in a `<script>` body.\n *\n * Reuses `jsonLdScriptContent` rather than growing a second escaper: a JSON string\n * literal IS a valid JS string literal, and that helper already does the two things\n * required here — `JSON.stringify` for the quote/backslash/newline boundary, plus\n * `<` `>` `&` U+2028 U+2029 escaping for the HTML raw-text boundary that\n * `JSON.stringify` leaves wide open. One audited primitive, one place to fix.\n */\nexport function analyticsScriptLiteral(id: string): string {\n return jsonLdScriptContent(id)\n}\n\n/** Body of the GA4 bootstrap script. Always escapes; never trusts its caller. */\nexport function gaInitScript(gaId: string): string {\n return `window.dataLayer=window.dataLayer||[];function gtag(){dataLayer.push(arguments);}gtag('js',new Date());gtag('config',${analyticsScriptLiteral(gaId)});`\n}\n\n/** Body of the Microsoft Clarity bootstrap script. Always escapes; never trusts its caller. */\nexport function clarityInitScript(clarityId: string): string {\n return `(function(c,l,a,r,i,t,y){c[a]=c[a]||function(){(c[a].q=c[a].q||[]).push(arguments)};t=l.createElement(r);t.async=1;t.src=\"https://www.clarity.ms/tag/\"+i;y=l.getElementsByTagName(r)[0];y.parentNode.insertBefore(t,y)})(window,document,\"clarity\",\"script\",${analyticsScriptLiteral(clarityId)});`\n}\n\nexport interface AnalyticsScriptsProps {\n /** GA4 measurement id from the SITE analytics contract. Ignored unless it matches `G-…`. */\n gaId?: string | null\n /** Microsoft Clarity project id from the SITE analytics contract. Ignored unless lowercase alphanumeric. */\n clarityId?: string | null\n}\n\n/**\n * Renders the Google Analytics and Microsoft Clarity tags for a tenant, dropping\n * any id that does not match its vendor-documented shape.\n */\nexport function AnalyticsScripts({ gaId, clarityId }: AnalyticsScriptsProps) {\n const safeGaId = isValidGaMeasurementId(gaId) ? gaId : null\n const safeClarityId = isValidClarityProjectId(clarityId) ? clarityId : null\n\n if (!safeGaId && !safeClarityId) return null\n\n return (\n <>\n {safeGaId && (\n <>\n <Script\n src={`https://www.googletagmanager.com/gtag/js?id=${encodeURIComponent(safeGaId)}`}\n strategy='afterInteractive'\n />\n <Script\n id='ga-init'\n strategy='afterInteractive'\n dangerouslySetInnerHTML={{ __html: gaInitScript(safeGaId) }}\n />\n </>\n )}\n\n {safeClarityId && (\n <Script\n id='clarity-init'\n strategy='afterInteractive'\n dangerouslySetInnerHTML={{ __html: clarityInitScript(safeClarityId) }}\n />\n )}\n </>\n )\n}\n","/**\n * `<JsonLd>` — safe `application/ld+json` script renderer (REB-622).\n *\n * A `<script>` element is HTML's \"raw text\" context: the parser closes it on the\n * first case-insensitive `</script` byte sequence it sees, no matter whether\n * that sequence sits inside a JSON string. `JSON.stringify` escapes for JSON\n * syntax, not for this HTML boundary, so it leaves `<` verbatim — turning any\n * JSON-LD payload built from tenant-authored data (page title, description,\n * FAQ copy, …) into a stored-XSS sink: a value containing\n * `</script><img src=x onerror=alert(1)>` breaks out of the element and runs.\n *\n * `jsonLdScriptContent` neutralises exactly that boundary while staying\n * byte-for-byte valid JSON: `<` / `>` / `&` are standard JSON\n * character escapes, so `JSON.parse` recovers the original value unchanged —\n * this is an ESCAPER, not a strip/allowlist sanitiser (see `sanitizeHtml` in\n * `@reblu/site-blocks` for that job, at the HTML boundary rather than the\n * script boundary). U+2028/U+2029 are escaped too: both are valid inside a\n * JSON string but are raw JS line terminators, and some `<script>` consumers\n * (older JS parsers, some static analysers) choke on them unescaped.\n *\n * This package, not `@reblu/site-blocks`, is the home for it: `site-blocks`'s\n * main entry bundles `parse5` + the `hast-util-*` chain + `marked` for\n * `sanitizeHtml` with `treeshake: false` — dist/index.js is ~463 KB, and PR #848\n * spent a full round removing exactly that weight (89 KB gzipped) from a client\n * bundle that only needed one HTML-escaping helper. `<JsonLd>` is Next-specific\n * (App Router's `suppressHydrationWarning` pattern) and this 40-line escaper has\n * zero dependencies of its own, so it ships as its OWN tsup entry\n * (`@reblu/site-next/json-ld`) rather than folding into the root `index` barrel\n * — importing it must never drag in `CtaButtons`/`EditableCta`'s `next/link` +\n * `@reblu/site-ui` graph, the same isolation `./cache` and `./route-handlers`\n * already use for the same reason.\n *\n * @see https://nextjs.org/docs/app/guides/json-ld\n */\n\nimport type { ConfigOpeningHours, ConfigOpeningHoursDay } from '@reblu/site-contracts'\n\nconst SCRIPT_BOUNDARY_UNSAFE = /[<>&\\u2028\\u2029]/g\n\nconst ESCAPES: Record<string, string> = {\n '<': '\\\\u003c',\n '>': '\\\\u003e',\n '&': '\\\\u0026',\n '\\u2028': '\\\\u2028',\n '\\u2029': '\\\\u2029',\n}\n\n/**\n * Serialises `value` for embedding inside `<script type=\"application/ld+json\">`.\n * Escapes `<`, `>`, `&`, U+2028 and U+2029 — every escape is a standard JSON\n * character escape, so `JSON.parse(jsonLdScriptContent(value))` always recovers\n * `value` unchanged. This only ever ADDS escaping; it never strips or rewrites\n * data, unlike an HTML sanitiser.\n *\n * `JSON.stringify(undefined)` returns the JS value `undefined`, not a string\n * (`undefined` has no JSON representation), which would otherwise throw here.\n * `data` is typed `unknown` with zero friction toward `undefined`, so this\n * falls back to the JSON literal `'null'` rather than crashing an entire page\n * render over one missing field.\n */\nexport function jsonLdScriptContent(value: unknown): string {\n const json = JSON.stringify(value) ?? 'null'\n return json.replace(SCRIPT_BOUNDARY_UNSAFE, (char) => ESCAPES[char])\n}\n\nexport interface JsonLdProps {\n /** Suffix for the rendered `<script>` id: `json-ld-${id}`. */\n id: string\n /** Structured data to serialise. Any JSON-serialisable value. */\n data: unknown\n}\n\n/**\n * Renders one `application/ld+json` `<script>` tag using the official Next.js\n * App Router pattern. `suppressHydrationWarning` is required here: React warns\n * on a server/client `textContent` mismatch for this element even though there\n * is no real divergence — `dangerouslySetInnerHTML` content is opaque to React's\n * hydration diff.\n */\nexport function JsonLd({ id, data }: JsonLdProps) {\n return (\n <script\n id={`json-ld-${id}`}\n type='application/ld+json'\n suppressHydrationWarning\n dangerouslySetInnerHTML={{ __html: jsonLdScriptContent(data) }}\n />\n )\n}\n\n// ─── Opening hours → schema.org OpeningHoursSpecification (REB-826) ──────────\n\n/** schema.org `OpeningHoursSpecification`, the shape a `LocalBusiness`/\n * `MedicalOrganization`/etc. entity's `openingHoursSpecification` array holds.\n * @see https://schema.org/OpeningHoursSpecification\n */\nexport interface OpeningHoursSpecification {\n '@type': 'OpeningHoursSpecification'\n dayOfWeek: string[]\n opens: string\n closes: string\n}\n\n/** schema.org `DayOfWeek` URIs, keyed by the Reblu `ConfigOpeningHoursDay` enum. */\nconst SCHEMA_DAY_OF_WEEK: Record<ConfigOpeningHoursDay, string> = {\n MON: 'https://schema.org/Monday',\n TUE: 'https://schema.org/Tuesday',\n WED: 'https://schema.org/Wednesday',\n THU: 'https://schema.org/Thursday',\n FRI: 'https://schema.org/Friday',\n SAT: 'https://schema.org/Saturday',\n SUN: 'https://schema.org/Sunday',\n}\n\n/**\n * Maps the Reblu `Config.openingHours` typed value to one\n * `OpeningHoursSpecification` object per range, each with its `days` turned\n * into schema.org `DayOfWeek` URIs. One range = one spec object; this does\n * NOT group or reformat for display (see `formatOpeningHours` in\n * `@reblu/site-next/opening-hours` for that) — structured data wants one\n * entry per machine-readable (days, opens, closes) tuple, not prose.\n *\n * Returns `undefined` — not `[]` — for \"nothing to merge\" (no typed hours, or\n * a typed value with zero ranges), so a caller building a JSON-LD entity with\n * `entity.openingHoursSpecification = toOpeningHoursSpecification(...)` can\n * `if (spec)` guard it without emitting an empty array into the graph.\n *\n * Used both by SITE builds assembling their own JSON-LD (the published use\n * case this SDK exists for) and by base-api's own structured-data builder —\n * see `generate-structured-data.ts`. Unlike `cookiePolicyVersion`\n * (`@reblu/site-next/consent-server`), this is NOT duplicated into base-api:\n * that duplication exists only where two independently-deployed sides (a\n * SITE's stored cookie vs. base-api's request-time check) must each derive\n * the identical value without talking to each other. Here base-api is the\n * ONLY side computing this value — there is no second computation to agree\n * with — so importing the single implementation is strictly better than a\n * second copy that could drift.\n *\n * Defensive against a malformed stored value — `Config.openingHours` is a\n * compile-time guarantee only. A range whose `days` is empty, or whose codes\n * are all unrecognised (outside `SCHEMA_DAY_OF_WEEK`'s keys), is dropped\n * entirely rather than emitting a spec object with an empty `dayOfWeek` (or\n * one padded with `undefined` for every unrecognised code). If every range\n * is dropped this way, the result is `undefined`, never `[]`.\n */\nexport function toOpeningHoursSpecification(\n openingHours: ConfigOpeningHours | null | undefined,\n): OpeningHoursSpecification[] | undefined {\n if (!openingHours || openingHours.ranges.length === 0) return undefined\n const specs: OpeningHoursSpecification[] = []\n for (const range of openingHours.ranges) {\n const dayOfWeek = range.days\n // `Object.hasOwn`, not `day in SCHEMA_DAY_OF_WEEK`: the `in` operator\n // also matches INHERITED properties (`'constructor'`, `'toString'`,\n // `'hasOwnProperty'`, …), which are not own keys of this lookup object\n // but would still pass an `in` check and resolve to a function, not a\n // schema.org URI string — corrupting `dayOfWeek` with exactly the kind\n // of malformed entry this filtering exists to prevent.\n .filter((day): day is ConfigOpeningHoursDay => Object.hasOwn(SCHEMA_DAY_OF_WEEK, day))\n .map((day) => SCHEMA_DAY_OF_WEEK[day])\n if (dayOfWeek.length === 0) continue\n specs.push({\n '@type': 'OpeningHoursSpecification',\n dayOfWeek,\n opens: range.opens,\n closes: range.closes,\n })\n }\n return specs.length > 0 ? specs : undefined\n}\n","'use client'\n\nimport { AnalyticsScripts, type AnalyticsScriptsProps } from '../analytics'\nimport { ConsentGate } from './gate'\n\n/**\n * `<ConsentedAnalytics>` — the `'analytics'`-gated replacement for rendering\n * `<AnalyticsScripts>` directly in a SITE layout (REB-642 Slice 1).\n *\n * Reuses the REB-617 validation + escaping builders verbatim via\n * `<AnalyticsScripts>` — this component adds NOTHING to the sink-safety\n * argument in `../analytics.tsx`, it only decides WHETHER that already-safe\n * renderer mounts at all. A SITE layout that still imports\n * `@reblu/site-next/analytics` directly bypasses consent entirely, which is\n * exactly what the source-scanning guard (REB-642 Slice 1, demo-site) exists\n * to catch — this component is the mechanism that guard enforces.\n */\nexport function ConsentedAnalytics(props: AnalyticsScriptsProps) {\n return (\n <ConsentGate category='analytics'>\n <AnalyticsScripts {...props} />\n </ConsentGate>\n )\n}\n","'use client'\n\nimport { useEffect, useState, type ReactNode } from 'react'\nimport * as Dialog from '@radix-ui/react-dialog'\nimport { Button, cn } from '@reblu/site-ui'\n\n// Deliberately `../consent-shared`, not `../consent-server` — see that\n// module's doc comment for why `node:crypto` must never reach this client entry.\nimport { CONSENT_CATEGORIES, type ConsentCategoryState } from '../consent-shared'\nimport { useConsent } from './context'\nimport { mergeConsentBannerTexts, type ConsentBannerTexts } from './texts'\n\n/**\n * `<CookieBanner>` + `<CookiePreferencesTrigger>` — the visible layer of\n * REB-642's consent mechanism.\n *\n * ─────────────────────────────────────────────────────────────────────────────\n * ARCHITECTURE: ONE DIALOG, TWO OPENERS\n * ─────────────────────────────────────────────────────────────────────────────\n *\n * `<CookieBanner>` owns rendering the preferences dialog (mount it ONCE, at\n * the root of the SITE layout — it portals to `<body>` regardless of where it\n * is mounted). `<CookiePreferencesTrigger>` is a small, unstyled-by-default\n * button usable ANYWHERE ELSE in the tree (a footer link, a settings page) that\n * opens the SAME dialog via `useConsent().openPreferences()`. Both read the\n * single `preferencesOpen` boolean the `<ConsentProvider>` owns, so there is\n * never more than one dialog instance and never two focus traps competing.\n *\n * This is what REB-642 calls \"permanent withdrawal\" — a visitor's route back\n * to their preferences does not depend on the first-layer banner still being\n * on screen; the banner disappears once resolved, the trigger does not.\n *\n * ─────────────────────────────────────────────────────────────────────────────\n * THE THREE-BUTTON RULE (owner decision, not configurable)\n * ─────────────────────────────────────────────────────────────────────────────\n *\n * Reject / Configure / Accept render as three same-variant, same-size buttons\n * in that visual order — no button is styled as more prominent than another.\n * A tenant can override `title`/`description` text (REB-642 §7.1 recommends\n * v1 ship with NO tenant-editable copy at all, but the prop exists so a later\n * slice can add it without touching this component's contract); the buttons,\n * their order, and the category taxonomy are fixed.\n */\n\nexport interface CookieBannerProps {\n /** Overrides merged onto `DEFAULT_CONSENT_BANNER_TEXTS`. */\n texts?: Partial<ConsentBannerTexts>\n /** Where \"Política de cookies\" links to. */\n policyHref?: string\n}\n\nfunction resolveTexts(overrides?: Partial<ConsentBannerTexts>): ConsentBannerTexts {\n // REB-845 — a blank/whitespace-only override falls back to the SDK default\n // for that key instead of rendering empty. See `mergeConsentBannerTexts`'s\n // doc comment for why: nexus persists `\"\"` for an unfilled cookie-banner\n // copy field, and a naive `{ ...defaults, ...overrides }` spread let that\n // `\"\"` win over the Spanish default.\n return mergeConsentBannerTexts(overrides)\n}\n\nexport function CookieBanner({ texts: textsOverride, policyHref = '/cookies' }: CookieBannerProps) {\n const { status, acceptAll, rejectAll, openPreferences } = useConsent()\n const texts = resolveTexts(textsOverride)\n\n return (\n <>\n {status === 'unknown' && (\n <div\n role='region'\n aria-label={texts.title}\n className='fixed inset-x-0 bottom-0 z-50 border-t border-border bg-background/95 px-4 py-4 backdrop-blur supports-[backdrop-filter]:bg-background/80'\n data-slot='cookie-banner'\n >\n <div className='mx-auto flex max-w-4xl flex-col gap-3 sm:flex-row sm:items-center sm:justify-between'>\n <div className='text-sm'>\n <p className='font-semibold'>{texts.title}</p>\n <p className='text-muted-foreground'>\n {texts.description}{' '}\n <a href={policyHref} className='underline underline-offset-2'>\n {texts.policyLinkLabel}\n </a>\n </p>\n </div>\n {/* Three equal-weight buttons, fixed order: reject, configure, accept.\n Same variant/size for all three — rejecting must cost exactly what\n accepting costs (AEPD first-layer requirement, REB-642). */}\n <div className='flex shrink-0 flex-wrap gap-2'>\n <Button type='button' variant='outline' size='sm' onClick={rejectAll}>\n {texts.rejectLabel}\n </Button>\n <Button type='button' variant='outline' size='sm' onClick={openPreferences}>\n {texts.manageLabel}\n </Button>\n <Button type='button' variant='outline' size='sm' onClick={acceptAll}>\n {texts.acceptLabel}\n </Button>\n </div>\n </div>\n </div>\n )}\n <ConsentPreferencesDialog texts={texts} />\n </>\n )\n}\n\nexport interface CookiePreferencesTriggerProps {\n children?: ReactNode\n className?: string\n texts?: Partial<ConsentBannerTexts>\n}\n\n/**\n * A persistent \"configure cookies\" entry point, safe to place anywhere in the\n * tree (typically a footer link) once `<CookieBanner>` is mounted at the root.\n * Opens the same preferences dialog `<CookieBanner>` owns.\n */\nexport function CookiePreferencesTrigger({\n children,\n className,\n texts: textsOverride,\n}: CookiePreferencesTriggerProps) {\n const { openPreferences } = useConsent()\n const texts = resolveTexts(textsOverride)\n\n return (\n <button type='button' onClick={openPreferences} className={className} data-slot='cookie-preferences-trigger'>\n {children ?? `${texts.manageLabel} cookies`}\n </button>\n )\n}\n\ninterface ConsentPreferencesDialogProps {\n texts: ConsentBannerTexts\n}\n\n/**\n * The second layer — a per-category preferences panel. `necessary` is\n * rendered as an always-checked, disabled checkbox for transparency (a\n * visitor can SEE that necessary cookies exist and why) without offering a\n * toggle that has no real effect, which would be dishonest UI.\n */\nfunction ConsentPreferencesDialog({ texts }: ConsentPreferencesDialogProps) {\n const { categories, preferencesOpen, closePreferences, savePreferences, restoreLastFocus } = useConsent()\n const [draft, setDraft] = useState<ConsentCategoryState>(categories)\n\n // Re-seed the draft from the last-saved categories every time the dialog\n // opens, so stale unsaved toggles from a previous open are never carried\n // into the next one.\n useEffect(() => {\n if (preferencesOpen) setDraft(categories)\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [preferencesOpen])\n\n return (\n <Dialog.Root\n open={preferencesOpen}\n onOpenChange={(open) => {\n // Radix only calls this for state changes IT initiates (Escape,\n // overlay click, `<Dialog.Close>`) — opening is entirely driven by\n // `useConsent().openPreferences()` from outside, which is also where\n // focus is captured (see `../consent/context.tsx`). So this handler\n // only ever needs to handle the closing transition.\n if (!open) closePreferences()\n }}\n >\n <Dialog.Portal>\n <Dialog.Overlay className='fixed inset-0 z-50 bg-black/50' />\n <Dialog.Content\n className='fixed left-1/2 top-1/2 z-50 w-full max-w-md -translate-x-1/2 -translate-y-1/2 rounded-lg border border-border bg-background p-6 shadow-lg'\n data-slot='cookie-preferences-dialog'\n onCloseAutoFocus={(event) => {\n event.preventDefault()\n restoreLastFocus()\n }}\n >\n <Dialog.Title className='text-base font-semibold'>{texts.preferencesTitle}</Dialog.Title>\n <Dialog.Description className='mt-1 text-sm text-muted-foreground'>\n {texts.preferencesDescription}\n </Dialog.Description>\n\n <div className='mt-4 flex flex-col gap-3'>\n <CategoryRow\n id='consent-category-necessary'\n label={texts.necessaryLabel}\n description={texts.necessaryDescription}\n checked\n disabled\n onChange={() => undefined}\n />\n {CONSENT_CATEGORIES.map((category) => (\n <CategoryRow\n key={category}\n id={`consent-category-${category}`}\n label={texts.categoryLabels[category]}\n description={texts.categoryDescriptions[category]}\n checked={draft[category]}\n disabled={false}\n onChange={(checked) => setDraft((prev) => ({ ...prev, [category]: checked }))}\n />\n ))}\n </div>\n\n <div className='mt-6 flex justify-end'>\n <Button type='button' size='sm' onClick={() => savePreferences(draft)}>\n {texts.saveLabel}\n </Button>\n </div>\n </Dialog.Content>\n </Dialog.Portal>\n </Dialog.Root>\n )\n}\n\ninterface CategoryRowProps {\n id: string\n label: string\n description: string\n checked: boolean\n disabled: boolean\n onChange: (checked: boolean) => void\n}\n\nfunction CategoryRow({ id, label, description, checked, disabled, onChange }: CategoryRowProps) {\n return (\n <div className={cn('flex items-start gap-3', disabled && 'opacity-70')}>\n <input\n id={id}\n type='checkbox'\n checked={checked}\n disabled={disabled}\n onChange={(event) => onChange(event.target.checked)}\n className='mt-1 size-4'\n />\n <label htmlFor={id} className='flex flex-col text-sm'>\n <span className='font-medium'>{label}</span>\n <span className='text-muted-foreground'>{description}</span>\n </label>\n </div>\n )\n}\n","import type { CookieBanner } from '@reblu/site-contracts'\n\nimport type { ConsentCategory } from '../consent-shared'\n\n/**\n * Copy for `<CookieBanner>`. SDK-owned defaults in Spanish (every Reblu SITE\n * renders `<html lang='es'>` today).\n *\n * REB-642 §7.1 recommends tenant-editable copy be limited to a TITLE/BODY\n * override for v1 — the per-category descriptions, the \"necessary\" copy and\n * every button label stay fixed, because the copy is a compliance surface: a\n * tenant writing \"we use cookies — OK\" with no reject option is the exact\n * misconfiguration this design exists to prevent. This type is intentionally\n * a `Partial<ConsentBannerTexts>` at the call site (see `CookieBanner`), so a\n * later tenant-configuration slice can override `title`/`description` without\n * touching this component's contract.\n */\nexport interface ConsentBannerTexts {\n title: string\n description: string\n acceptLabel: string\n rejectLabel: string\n manageLabel: string\n policyLinkLabel: string\n preferencesTitle: string\n preferencesDescription: string\n saveLabel: string\n necessaryLabel: string\n necessaryDescription: string\n categoryLabels: Record<ConsentCategory, string>\n categoryDescriptions: Record<ConsentCategory, string>\n}\n\nexport const DEFAULT_CONSENT_BANNER_TEXTS: ConsentBannerTexts = {\n title: 'Utilizamos cookies',\n description:\n 'Usamos cookies estrictamente necesarias para el funcionamiento del sitio. Con tu permiso, también usaríamos cookies de analítica y marketing para mejorar tu experiencia.',\n acceptLabel: 'Aceptar todas',\n rejectLabel: 'Rechazar todas',\n manageLabel: 'Configurar',\n policyLinkLabel: 'Política de cookies',\n preferencesTitle: 'Preferencias de cookies',\n preferencesDescription:\n 'Elige qué categorías de cookies opcionales permites. Las cookies necesarias no se pueden desactivar.',\n saveLabel: 'Guardar preferencias',\n necessaryLabel: 'Necesarias',\n necessaryDescription:\n 'Imprescindibles para el funcionamiento del sitio, como la seguridad del formulario de contacto. Siempre activas.',\n categoryLabels: {\n analytics: 'Analítica',\n marketing: 'Marketing',\n preferences: 'Preferencias',\n },\n categoryDescriptions: {\n analytics: 'Nos ayudan a entender cómo se usa el sitio (por ejemplo, Google Analytics o Microsoft Clarity).',\n marketing: 'Permiten mostrar contenido relevante de terceros (por ejemplo, Meta Pixel).',\n preferences: 'Recuerdan tus preferencias de navegación entre visitas.',\n },\n}\n\n/**\n * The string-valued keys of `ConsentBannerTexts` — every key EXCEPT\n * `categoryLabels`/`categoryDescriptions`, which are nested `Record`s, not\n * plain copy. `mergeConsentBannerTexts` only applies its blank-is-unset rule\n * to these.\n */\nconst CONSENT_BANNER_TEXT_KEYS = [\n 'title',\n 'description',\n 'acceptLabel',\n 'rejectLabel',\n 'manageLabel',\n 'policyLinkLabel',\n 'preferencesTitle',\n 'preferencesDescription',\n 'saveLabel',\n 'necessaryLabel',\n 'necessaryDescription',\n] as const satisfies readonly (keyof ConsentBannerTexts)[]\n\n/**\n * Merges `overrides` onto `DEFAULT_CONSENT_BANNER_TEXTS` (REB-845).\n *\n * A plain `{ ...defaults, ...overrides }` spread lets a BLANK override win —\n * nexus persists `\"\"` for a cookie-banner copy field nobody filled in (see\n * `cookieBannerSchema`'s doc comment in base-api), so a naive merge rendered\n * an empty title, empty description and three blank buttons instead of the\n * SDK's Spanish defaults. An empty or whitespace-only string here means the\n * SAME THING as `undefined`: \"no override — use the default.\"\n *\n * Same for a PRESENT key holding `undefined` or `null` (R3-001 review\n * finding): the spread copies that value right over the default because the\n * key still exists on `overrides`, so `value == null` is checked first,\n * before the string/blank check, rather than relying on `typeof value ===\n * 'string'` to fall through.\n */\nexport function mergeConsentBannerTexts(\n overrides?: Partial<ConsentBannerTexts>,\n): ConsentBannerTexts {\n const merged: ConsentBannerTexts = { ...DEFAULT_CONSENT_BANNER_TEXTS, ...overrides }\n for (const key of CONSENT_BANNER_TEXT_KEYS) {\n const value = overrides?.[key]\n const isUnset = value == null || (typeof value === 'string' && value.trim() === '')\n if (isUnset) {\n merged[key] = DEFAULT_CONSENT_BANNER_TEXTS[key]\n }\n }\n return merged\n}\n\n/**\n * Maps the `/api/site/analytics` `cookieBanner` contract shape onto\n * `Partial<ConsentBannerTexts>` for `<CookieBanner texts={...}>`, dropping\n * `null` and blank fields so a SITE never has to hand-roll this mapping (and\n * never has to re-implement the blank-is-unset rule `mergeConsentBannerTexts`\n * already applies on the way in).\n *\n * Only the 5 fields REB-642 §7.1 makes tenant-editable map to copy;\n * `cookieBanner.enabled` and `cookieBanner.policyUrl` are not `texts` —\n * `enabled` gates whether `<CookieBanner>` renders at all, and `policyUrl` is\n * `<CookieBanner policyHref>`, not a text key.\n */\nexport function cookieBannerToConsentTexts(\n cookieBanner:\n | Pick<CookieBanner, 'title' | 'body' | 'acceptLabel' | 'rejectLabel' | 'manageLabel'>\n | null\n | undefined,\n): Partial<ConsentBannerTexts> {\n if (!cookieBanner) return {}\n\n const overrides: Partial<ConsentBannerTexts> = {}\n if (cookieBanner.title?.trim()) overrides.title = cookieBanner.title\n if (cookieBanner.body?.trim()) overrides.description = cookieBanner.body\n if (cookieBanner.acceptLabel?.trim()) overrides.acceptLabel = cookieBanner.acceptLabel\n if (cookieBanner.rejectLabel?.trim()) overrides.rejectLabel = cookieBanner.rejectLabel\n if (cookieBanner.manageLabel?.trim()) overrides.manageLabel = cookieBanner.manageLabel\n return overrides\n}\n"],"mappings":"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","names":["jsx","jsx","Fragment","jsx","jsx","useEffect","useState","Fragment","jsx","jsxs","useState","useEffect"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reblu/site-next",
3
- "version": "1.15.3",
3
+ "version": "1.16.0",
4
4
  "description": "Next.js RSC glue for Reblu SITEs — cache + CTA + auth server/client components.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Reblu — Adolfo Unturbe",
@@ -241,8 +241,8 @@
241
241
  "@radix-ui/react-dialog": "^1.1.15",
242
242
  "zustand": "^5.0.9",
243
243
  "@reblu/site-client": "^5.3.1",
244
- "@reblu/site-ui": "^0.7.0",
245
- "@reblu/site-contracts": "^1.6.0"
244
+ "@reblu/site-contracts": "^1.6.1",
245
+ "@reblu/site-ui": "^0.7.0"
246
246
  },
247
247
  "devDependencies": {
248
248
  "@testing-library/jest-dom": "^6.9.1",