@reblu/site-next 1.9.0 → 1.11.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 +17 -0
- package/README.md +65 -19
- package/dist/consent-server.cjs +88 -0
- package/dist/consent-server.cjs.map +1 -0
- package/dist/consent-server.d.cts +22 -0
- package/dist/consent-server.d.ts +22 -0
- package/dist/consent-server.js +58 -0
- package/dist/consent-server.js.map +1 -0
- package/dist/consent-shared-DbdhEXW2.d.cts +71 -0
- package/dist/consent-shared-DbdhEXW2.d.ts +71 -0
- package/dist/consent.cjs +404 -0
- package/dist/consent.cjs.map +1 -0
- package/dist/consent.d.cts +172 -0
- package/dist/consent.d.ts +172 -0
- package/dist/consent.js +362 -0
- package/dist/consent.js.map +1 -0
- package/dist/recaptcha-provider.cjs +86 -0
- package/dist/recaptcha-provider.cjs.map +1 -0
- package/dist/recaptcha-provider.d.cts +41 -0
- package/dist/recaptcha-provider.d.ts +41 -0
- package/dist/recaptcha-provider.js +68 -0
- package/dist/recaptcha-provider.js.map +1 -0
- package/dist/recaptcha.cjs.map +1 -1
- package/dist/recaptcha.d.cts +33 -4
- package/dist/recaptcha.d.ts +33 -4
- package/dist/recaptcha.js.map +1 -1
- package/package.json +42 -3
package/dist/consent.js
ADDED
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
// src/consent/context.tsx
|
|
4
|
+
import { createContext, useContext, useEffect, useMemo, useRef, useState } from "react";
|
|
5
|
+
|
|
6
|
+
// src/consent-shared.ts
|
|
7
|
+
var CONSENT_COOKIE_NAME = "reblu_consent";
|
|
8
|
+
var CONSENT_CATEGORIES = ["analytics", "marketing", "preferences"];
|
|
9
|
+
var DEFAULT_CONSENT_CATEGORIES = Object.freeze(
|
|
10
|
+
CONSENT_CATEGORIES.reduce(
|
|
11
|
+
(state, category) => ({ ...state, [category]: false }),
|
|
12
|
+
{}
|
|
13
|
+
)
|
|
14
|
+
);
|
|
15
|
+
function isPlainObject(value) {
|
|
16
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
17
|
+
}
|
|
18
|
+
function parseCategories(value) {
|
|
19
|
+
if (!isPlainObject(value)) return null;
|
|
20
|
+
const result = {};
|
|
21
|
+
for (const category of CONSENT_CATEGORIES) {
|
|
22
|
+
const raw = value[category];
|
|
23
|
+
if (typeof raw !== "boolean") return null;
|
|
24
|
+
result[category] = raw;
|
|
25
|
+
}
|
|
26
|
+
return result;
|
|
27
|
+
}
|
|
28
|
+
function parseConsentCookie(raw) {
|
|
29
|
+
if (!raw) return null;
|
|
30
|
+
let json;
|
|
31
|
+
try {
|
|
32
|
+
json = JSON.parse(raw);
|
|
33
|
+
} catch {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
if (!isPlainObject(json)) return null;
|
|
37
|
+
const categories = parseCategories(json.categories);
|
|
38
|
+
if (!categories) return null;
|
|
39
|
+
const policyVersion = json.policyVersion;
|
|
40
|
+
if (policyVersion !== null && typeof policyVersion !== "string") return null;
|
|
41
|
+
return { categories, policyVersion };
|
|
42
|
+
}
|
|
43
|
+
function serializeConsentCookie(payload) {
|
|
44
|
+
return JSON.stringify(payload);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// src/consent/context.tsx
|
|
48
|
+
import { jsx } from "react/jsx-runtime";
|
|
49
|
+
var ConsentContext = createContext(null);
|
|
50
|
+
var COOKIE_MAX_AGE_SECONDS = 60 * 60 * 24 * 365;
|
|
51
|
+
function readCookie(name) {
|
|
52
|
+
if (typeof document === "undefined") return null;
|
|
53
|
+
const match = document.cookie.match(new RegExp(`(?:^|; )${name}=([^;]*)`));
|
|
54
|
+
return match ? decodeURIComponent(match[1]) : null;
|
|
55
|
+
}
|
|
56
|
+
function writeCookie(name, value) {
|
|
57
|
+
if (typeof document === "undefined") return;
|
|
58
|
+
document.cookie = `${name}=${encodeURIComponent(value)}; path=/; max-age=${COOKIE_MAX_AGE_SECONDS}; SameSite=Lax`;
|
|
59
|
+
}
|
|
60
|
+
function allCategories(granted) {
|
|
61
|
+
return CONSENT_CATEGORIES.reduce(
|
|
62
|
+
(state, category) => ({ ...state, [category]: granted }),
|
|
63
|
+
{}
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
function ConsentProvider({ children, policyVersion }) {
|
|
67
|
+
const [status, setStatus] = useState("unknown");
|
|
68
|
+
const [categories, setCategories] = useState(DEFAULT_CONSENT_CATEGORIES);
|
|
69
|
+
const [preferencesOpen, setPreferencesOpen] = useState(false);
|
|
70
|
+
const lastFocusedRef = useRef(null);
|
|
71
|
+
useEffect(() => {
|
|
72
|
+
const parsed = parseConsentCookie(readCookie(CONSENT_COOKIE_NAME));
|
|
73
|
+
if (parsed && parsed.policyVersion === policyVersion) {
|
|
74
|
+
setCategories(parsed.categories);
|
|
75
|
+
setStatus("resolved");
|
|
76
|
+
} else {
|
|
77
|
+
setCategories(DEFAULT_CONSENT_CATEGORIES);
|
|
78
|
+
setStatus("unknown");
|
|
79
|
+
}
|
|
80
|
+
}, [policyVersion]);
|
|
81
|
+
function persist(next) {
|
|
82
|
+
writeCookie(CONSENT_COOKIE_NAME, serializeConsentCookie({ categories: next, policyVersion }));
|
|
83
|
+
setCategories(next);
|
|
84
|
+
setStatus("resolved");
|
|
85
|
+
setPreferencesOpen(false);
|
|
86
|
+
}
|
|
87
|
+
const value = useMemo(
|
|
88
|
+
() => ({
|
|
89
|
+
status,
|
|
90
|
+
categories,
|
|
91
|
+
isGranted: (category) => status === "resolved" && categories[category] === true,
|
|
92
|
+
acceptAll: () => persist(allCategories(true)),
|
|
93
|
+
rejectAll: () => persist(allCategories(false)),
|
|
94
|
+
savePreferences: (next) => persist(next),
|
|
95
|
+
preferencesOpen,
|
|
96
|
+
openPreferences: () => {
|
|
97
|
+
lastFocusedRef.current = typeof document !== "undefined" ? document.activeElement : null;
|
|
98
|
+
setPreferencesOpen(true);
|
|
99
|
+
},
|
|
100
|
+
closePreferences: () => setPreferencesOpen(false),
|
|
101
|
+
restoreLastFocus: () => lastFocusedRef.current?.focus()
|
|
102
|
+
}),
|
|
103
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
104
|
+
[status, categories, preferencesOpen, policyVersion]
|
|
105
|
+
);
|
|
106
|
+
return /* @__PURE__ */ jsx(ConsentContext.Provider, { value, children });
|
|
107
|
+
}
|
|
108
|
+
function useConsent() {
|
|
109
|
+
const ctx = useContext(ConsentContext);
|
|
110
|
+
if (!ctx) throw new Error("useConsent() must be used within a <ConsentProvider>");
|
|
111
|
+
return ctx;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// src/consent/gate.tsx
|
|
115
|
+
import { Fragment, jsx as jsx2 } from "react/jsx-runtime";
|
|
116
|
+
function ConsentGate({ category, children }) {
|
|
117
|
+
const { status, isGranted } = useConsent();
|
|
118
|
+
if (status !== "resolved") return null;
|
|
119
|
+
if (!isGranted(category)) return null;
|
|
120
|
+
return /* @__PURE__ */ jsx2(Fragment, { children });
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// src/analytics.tsx
|
|
124
|
+
import Script from "next/script";
|
|
125
|
+
|
|
126
|
+
// src/json-ld.tsx
|
|
127
|
+
import { jsx as jsx3 } from "react/jsx-runtime";
|
|
128
|
+
var SCRIPT_BOUNDARY_UNSAFE = /[<>&\u2028\u2029]/g;
|
|
129
|
+
var ESCAPES = {
|
|
130
|
+
"<": "\\u003c",
|
|
131
|
+
">": "\\u003e",
|
|
132
|
+
"&": "\\u0026",
|
|
133
|
+
"\u2028": "\\u2028",
|
|
134
|
+
"\u2029": "\\u2029"
|
|
135
|
+
};
|
|
136
|
+
function jsonLdScriptContent(value) {
|
|
137
|
+
const json = JSON.stringify(value) ?? "null";
|
|
138
|
+
return json.replace(SCRIPT_BOUNDARY_UNSAFE, (char) => ESCAPES[char]);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// src/analytics.tsx
|
|
142
|
+
import { Fragment as Fragment2, jsx as jsx4, jsxs } from "react/jsx-runtime";
|
|
143
|
+
var GA_MEASUREMENT_ID_PATTERN = /^G-[A-Z0-9]{1,32}$/;
|
|
144
|
+
var CLARITY_PROJECT_ID_PATTERN = /^[a-z0-9]{1,32}$/;
|
|
145
|
+
function isValidGaMeasurementId(value) {
|
|
146
|
+
return typeof value === "string" && GA_MEASUREMENT_ID_PATTERN.test(value);
|
|
147
|
+
}
|
|
148
|
+
function isValidClarityProjectId(value) {
|
|
149
|
+
return typeof value === "string" && CLARITY_PROJECT_ID_PATTERN.test(value);
|
|
150
|
+
}
|
|
151
|
+
function analyticsScriptLiteral(id) {
|
|
152
|
+
return jsonLdScriptContent(id);
|
|
153
|
+
}
|
|
154
|
+
function gaInitScript(gaId) {
|
|
155
|
+
return `window.dataLayer=window.dataLayer||[];function gtag(){dataLayer.push(arguments);}gtag('js',new Date());gtag('config',${analyticsScriptLiteral(gaId)});`;
|
|
156
|
+
}
|
|
157
|
+
function clarityInitScript(clarityId) {
|
|
158
|
+
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)});`;
|
|
159
|
+
}
|
|
160
|
+
function AnalyticsScripts({ gaId, clarityId }) {
|
|
161
|
+
const safeGaId = isValidGaMeasurementId(gaId) ? gaId : null;
|
|
162
|
+
const safeClarityId = isValidClarityProjectId(clarityId) ? clarityId : null;
|
|
163
|
+
if (!safeGaId && !safeClarityId) return null;
|
|
164
|
+
return /* @__PURE__ */ jsxs(Fragment2, { children: [
|
|
165
|
+
safeGaId && /* @__PURE__ */ jsxs(Fragment2, { children: [
|
|
166
|
+
/* @__PURE__ */ jsx4(
|
|
167
|
+
Script,
|
|
168
|
+
{
|
|
169
|
+
src: `https://www.googletagmanager.com/gtag/js?id=${encodeURIComponent(safeGaId)}`,
|
|
170
|
+
strategy: "afterInteractive"
|
|
171
|
+
}
|
|
172
|
+
),
|
|
173
|
+
/* @__PURE__ */ jsx4(
|
|
174
|
+
Script,
|
|
175
|
+
{
|
|
176
|
+
id: "ga-init",
|
|
177
|
+
strategy: "afterInteractive",
|
|
178
|
+
dangerouslySetInnerHTML: { __html: gaInitScript(safeGaId) }
|
|
179
|
+
}
|
|
180
|
+
)
|
|
181
|
+
] }),
|
|
182
|
+
safeClarityId && /* @__PURE__ */ jsx4(
|
|
183
|
+
Script,
|
|
184
|
+
{
|
|
185
|
+
id: "clarity-init",
|
|
186
|
+
strategy: "afterInteractive",
|
|
187
|
+
dangerouslySetInnerHTML: { __html: clarityInitScript(safeClarityId) }
|
|
188
|
+
}
|
|
189
|
+
)
|
|
190
|
+
] });
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// src/consent/consented-analytics.tsx
|
|
194
|
+
import { jsx as jsx5 } from "react/jsx-runtime";
|
|
195
|
+
function ConsentedAnalytics(props) {
|
|
196
|
+
return /* @__PURE__ */ jsx5(ConsentGate, { category: "analytics", children: /* @__PURE__ */ jsx5(AnalyticsScripts, { ...props }) });
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// src/consent/banner.tsx
|
|
200
|
+
import { useEffect as useEffect2, useState as useState2 } from "react";
|
|
201
|
+
import * as Dialog from "@radix-ui/react-dialog";
|
|
202
|
+
import { Button, cn } from "@reblu/site-ui";
|
|
203
|
+
|
|
204
|
+
// src/consent/texts.ts
|
|
205
|
+
var DEFAULT_CONSENT_BANNER_TEXTS = {
|
|
206
|
+
title: "Utilizamos cookies",
|
|
207
|
+
description: "Usamos cookies estrictamente necesarias para el funcionamiento del sitio. Con tu permiso, tambi\xE9n usar\xEDamos cookies de anal\xEDtica y marketing para mejorar tu experiencia.",
|
|
208
|
+
acceptLabel: "Aceptar todas",
|
|
209
|
+
rejectLabel: "Rechazar todas",
|
|
210
|
+
manageLabel: "Configurar",
|
|
211
|
+
policyLinkLabel: "Pol\xEDtica de cookies",
|
|
212
|
+
preferencesTitle: "Preferencias de cookies",
|
|
213
|
+
preferencesDescription: "Elige qu\xE9 categor\xEDas de cookies opcionales permites. Las cookies necesarias no se pueden desactivar.",
|
|
214
|
+
saveLabel: "Guardar preferencias",
|
|
215
|
+
necessaryLabel: "Necesarias",
|
|
216
|
+
necessaryDescription: "Imprescindibles para el funcionamiento del sitio, como la seguridad del formulario de contacto. Siempre activas.",
|
|
217
|
+
categoryLabels: {
|
|
218
|
+
analytics: "Anal\xEDtica",
|
|
219
|
+
marketing: "Marketing",
|
|
220
|
+
preferences: "Preferencias"
|
|
221
|
+
},
|
|
222
|
+
categoryDescriptions: {
|
|
223
|
+
analytics: "Nos ayudan a entender c\xF3mo se usa el sitio (por ejemplo, Google Analytics o Microsoft Clarity).",
|
|
224
|
+
marketing: "Permiten mostrar contenido relevante de terceros (por ejemplo, Meta Pixel).",
|
|
225
|
+
preferences: "Recuerdan tus preferencias de navegaci\xF3n entre visitas."
|
|
226
|
+
}
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
// src/consent/banner.tsx
|
|
230
|
+
import { Fragment as Fragment3, jsx as jsx6, jsxs as jsxs2 } from "react/jsx-runtime";
|
|
231
|
+
function resolveTexts(overrides) {
|
|
232
|
+
return { ...DEFAULT_CONSENT_BANNER_TEXTS, ...overrides };
|
|
233
|
+
}
|
|
234
|
+
function CookieBanner({ texts: textsOverride, policyHref = "/cookies" }) {
|
|
235
|
+
const { status, acceptAll, rejectAll, openPreferences } = useConsent();
|
|
236
|
+
const texts = resolveTexts(textsOverride);
|
|
237
|
+
return /* @__PURE__ */ jsxs2(Fragment3, { children: [
|
|
238
|
+
status === "unknown" && /* @__PURE__ */ jsx6(
|
|
239
|
+
"div",
|
|
240
|
+
{
|
|
241
|
+
role: "region",
|
|
242
|
+
"aria-label": texts.title,
|
|
243
|
+
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",
|
|
244
|
+
"data-slot": "cookie-banner",
|
|
245
|
+
children: /* @__PURE__ */ jsxs2("div", { className: "mx-auto flex max-w-4xl flex-col gap-3 sm:flex-row sm:items-center sm:justify-between", children: [
|
|
246
|
+
/* @__PURE__ */ jsxs2("div", { className: "text-sm", children: [
|
|
247
|
+
/* @__PURE__ */ jsx6("p", { className: "font-semibold", children: texts.title }),
|
|
248
|
+
/* @__PURE__ */ jsxs2("p", { className: "text-muted-foreground", children: [
|
|
249
|
+
texts.description,
|
|
250
|
+
" ",
|
|
251
|
+
/* @__PURE__ */ jsx6("a", { href: policyHref, className: "underline underline-offset-2", children: texts.policyLinkLabel })
|
|
252
|
+
] })
|
|
253
|
+
] }),
|
|
254
|
+
/* @__PURE__ */ jsxs2("div", { className: "flex shrink-0 flex-wrap gap-2", children: [
|
|
255
|
+
/* @__PURE__ */ jsx6(Button, { type: "button", variant: "outline", size: "sm", onClick: rejectAll, children: texts.rejectLabel }),
|
|
256
|
+
/* @__PURE__ */ jsx6(Button, { type: "button", variant: "outline", size: "sm", onClick: openPreferences, children: texts.manageLabel }),
|
|
257
|
+
/* @__PURE__ */ jsx6(Button, { type: "button", variant: "outline", size: "sm", onClick: acceptAll, children: texts.acceptLabel })
|
|
258
|
+
] })
|
|
259
|
+
] })
|
|
260
|
+
}
|
|
261
|
+
),
|
|
262
|
+
/* @__PURE__ */ jsx6(ConsentPreferencesDialog, { texts })
|
|
263
|
+
] });
|
|
264
|
+
}
|
|
265
|
+
function CookiePreferencesTrigger({
|
|
266
|
+
children,
|
|
267
|
+
className,
|
|
268
|
+
texts: textsOverride
|
|
269
|
+
}) {
|
|
270
|
+
const { openPreferences } = useConsent();
|
|
271
|
+
const texts = resolveTexts(textsOverride);
|
|
272
|
+
return /* @__PURE__ */ jsx6("button", { type: "button", onClick: openPreferences, className, "data-slot": "cookie-preferences-trigger", children: children ?? `${texts.manageLabel} cookies` });
|
|
273
|
+
}
|
|
274
|
+
function ConsentPreferencesDialog({ texts }) {
|
|
275
|
+
const { categories, preferencesOpen, closePreferences, savePreferences, restoreLastFocus } = useConsent();
|
|
276
|
+
const [draft, setDraft] = useState2(categories);
|
|
277
|
+
useEffect2(() => {
|
|
278
|
+
if (preferencesOpen) setDraft(categories);
|
|
279
|
+
}, [preferencesOpen]);
|
|
280
|
+
return /* @__PURE__ */ jsx6(
|
|
281
|
+
Dialog.Root,
|
|
282
|
+
{
|
|
283
|
+
open: preferencesOpen,
|
|
284
|
+
onOpenChange: (open) => {
|
|
285
|
+
if (!open) closePreferences();
|
|
286
|
+
},
|
|
287
|
+
children: /* @__PURE__ */ jsxs2(Dialog.Portal, { children: [
|
|
288
|
+
/* @__PURE__ */ jsx6(Dialog.Overlay, { className: "fixed inset-0 z-50 bg-black/50" }),
|
|
289
|
+
/* @__PURE__ */ jsxs2(
|
|
290
|
+
Dialog.Content,
|
|
291
|
+
{
|
|
292
|
+
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",
|
|
293
|
+
"data-slot": "cookie-preferences-dialog",
|
|
294
|
+
onCloseAutoFocus: (event) => {
|
|
295
|
+
event.preventDefault();
|
|
296
|
+
restoreLastFocus();
|
|
297
|
+
},
|
|
298
|
+
children: [
|
|
299
|
+
/* @__PURE__ */ jsx6(Dialog.Title, { className: "text-base font-semibold", children: texts.preferencesTitle }),
|
|
300
|
+
/* @__PURE__ */ jsx6(Dialog.Description, { className: "mt-1 text-sm text-muted-foreground", children: texts.preferencesDescription }),
|
|
301
|
+
/* @__PURE__ */ jsxs2("div", { className: "mt-4 flex flex-col gap-3", children: [
|
|
302
|
+
/* @__PURE__ */ jsx6(
|
|
303
|
+
CategoryRow,
|
|
304
|
+
{
|
|
305
|
+
id: "consent-category-necessary",
|
|
306
|
+
label: texts.necessaryLabel,
|
|
307
|
+
description: texts.necessaryDescription,
|
|
308
|
+
checked: true,
|
|
309
|
+
disabled: true,
|
|
310
|
+
onChange: () => void 0
|
|
311
|
+
}
|
|
312
|
+
),
|
|
313
|
+
CONSENT_CATEGORIES.map((category) => /* @__PURE__ */ jsx6(
|
|
314
|
+
CategoryRow,
|
|
315
|
+
{
|
|
316
|
+
id: `consent-category-${category}`,
|
|
317
|
+
label: texts.categoryLabels[category],
|
|
318
|
+
description: texts.categoryDescriptions[category],
|
|
319
|
+
checked: draft[category],
|
|
320
|
+
disabled: false,
|
|
321
|
+
onChange: (checked) => setDraft((prev) => ({ ...prev, [category]: checked }))
|
|
322
|
+
},
|
|
323
|
+
category
|
|
324
|
+
))
|
|
325
|
+
] }),
|
|
326
|
+
/* @__PURE__ */ jsx6("div", { className: "mt-6 flex justify-end", children: /* @__PURE__ */ jsx6(Button, { type: "button", size: "sm", onClick: () => savePreferences(draft), children: texts.saveLabel }) })
|
|
327
|
+
]
|
|
328
|
+
}
|
|
329
|
+
)
|
|
330
|
+
] })
|
|
331
|
+
}
|
|
332
|
+
);
|
|
333
|
+
}
|
|
334
|
+
function CategoryRow({ id, label, description, checked, disabled, onChange }) {
|
|
335
|
+
return /* @__PURE__ */ jsxs2("div", { className: cn("flex items-start gap-3", disabled && "opacity-70"), children: [
|
|
336
|
+
/* @__PURE__ */ jsx6(
|
|
337
|
+
"input",
|
|
338
|
+
{
|
|
339
|
+
id,
|
|
340
|
+
type: "checkbox",
|
|
341
|
+
checked,
|
|
342
|
+
disabled,
|
|
343
|
+
onChange: (event) => onChange(event.target.checked),
|
|
344
|
+
className: "mt-1 size-4"
|
|
345
|
+
}
|
|
346
|
+
),
|
|
347
|
+
/* @__PURE__ */ jsxs2("label", { htmlFor: id, className: "flex flex-col text-sm", children: [
|
|
348
|
+
/* @__PURE__ */ jsx6("span", { className: "font-medium", children: label }),
|
|
349
|
+
/* @__PURE__ */ jsx6("span", { className: "text-muted-foreground", children: description })
|
|
350
|
+
] })
|
|
351
|
+
] });
|
|
352
|
+
}
|
|
353
|
+
export {
|
|
354
|
+
ConsentGate,
|
|
355
|
+
ConsentProvider,
|
|
356
|
+
ConsentedAnalytics,
|
|
357
|
+
CookieBanner,
|
|
358
|
+
CookiePreferencesTrigger,
|
|
359
|
+
DEFAULT_CONSENT_BANNER_TEXTS,
|
|
360
|
+
useConsent
|
|
361
|
+
};
|
|
362
|
+
//# sourceMappingURL=consent.js.map
|
|
@@ -0,0 +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\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","'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"]}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
"use client";
|
|
3
|
+
var __defProp = Object.defineProperty;
|
|
4
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
5
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
6
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
7
|
+
var __export = (target, all) => {
|
|
8
|
+
for (var name in all)
|
|
9
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
10
|
+
};
|
|
11
|
+
var __copyProps = (to, from, except, desc) => {
|
|
12
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
13
|
+
for (let key of __getOwnPropNames(from))
|
|
14
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
15
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
16
|
+
}
|
|
17
|
+
return to;
|
|
18
|
+
};
|
|
19
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
20
|
+
|
|
21
|
+
// src/recaptcha-provider.tsx
|
|
22
|
+
var recaptcha_provider_exports = {};
|
|
23
|
+
__export(recaptcha_provider_exports, {
|
|
24
|
+
DeferredRecaptchaProvider: () => DeferredRecaptchaProvider,
|
|
25
|
+
useDeferredRecaptcha: () => useDeferredRecaptcha
|
|
26
|
+
});
|
|
27
|
+
module.exports = __toCommonJS(recaptcha_provider_exports);
|
|
28
|
+
var import_react = require("react");
|
|
29
|
+
var import_react_google_recaptcha_v3 = require("react-google-recaptcha-v3");
|
|
30
|
+
|
|
31
|
+
// src/recaptcha.ts
|
|
32
|
+
var RECAPTCHA_SITE_KEY_PATTERN = /^[A-Za-z0-9_-]{16,128}$/;
|
|
33
|
+
function isSafeRecaptchaSiteKey(value) {
|
|
34
|
+
return typeof value === "string" && RECAPTCHA_SITE_KEY_PATTERN.test(value);
|
|
35
|
+
}
|
|
36
|
+
function trimmed(value) {
|
|
37
|
+
if (typeof value !== "string") return null;
|
|
38
|
+
const text = value.trim();
|
|
39
|
+
return text.length > 0 ? text : null;
|
|
40
|
+
}
|
|
41
|
+
function deferredRecaptchaKey(siteKey, armed) {
|
|
42
|
+
if (!armed) return "";
|
|
43
|
+
const key = trimmed(siteKey);
|
|
44
|
+
return key && isSafeRecaptchaSiteKey(key) ? key : "";
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// src/recaptcha-provider.tsx
|
|
48
|
+
var import_jsx_runtime = require("react/jsx-runtime");
|
|
49
|
+
var UNARMED = { executeRecaptcha: void 0 };
|
|
50
|
+
var DeferredRecaptchaContext = (0, import_react.createContext)(UNARMED);
|
|
51
|
+
function ExecuteBridge({
|
|
52
|
+
onChange
|
|
53
|
+
}) {
|
|
54
|
+
const { executeRecaptcha } = (0, import_react_google_recaptcha_v3.useGoogleReCaptcha)();
|
|
55
|
+
(0, import_react.useEffect)(() => {
|
|
56
|
+
onChange(executeRecaptcha);
|
|
57
|
+
}, [executeRecaptcha, onChange]);
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
function DeferredRecaptchaProvider({
|
|
61
|
+
siteKey,
|
|
62
|
+
armed,
|
|
63
|
+
children,
|
|
64
|
+
...providerProps
|
|
65
|
+
}) {
|
|
66
|
+
const reCaptchaKey = deferredRecaptchaKey(siteKey, armed);
|
|
67
|
+
const [executeRecaptcha, setExecuteRecaptcha] = (0, import_react.useState)(void 0);
|
|
68
|
+
const handleChange = (0, import_react.useCallback)(
|
|
69
|
+
(fn) => setExecuteRecaptcha(() => fn),
|
|
70
|
+
[]
|
|
71
|
+
);
|
|
72
|
+
const contextValue = (0, import_react.useMemo)(() => ({ executeRecaptcha }), [executeRecaptcha]);
|
|
73
|
+
return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(DeferredRecaptchaContext.Provider, { value: contextValue, children: [
|
|
74
|
+
/* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_react_google_recaptcha_v3.GoogleReCaptchaProvider, { reCaptchaKey, ...providerProps, children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(ExecuteBridge, { onChange: handleChange }) }, reCaptchaKey),
|
|
75
|
+
children
|
|
76
|
+
] });
|
|
77
|
+
}
|
|
78
|
+
function useDeferredRecaptcha() {
|
|
79
|
+
return (0, import_react.useContext)(DeferredRecaptchaContext);
|
|
80
|
+
}
|
|
81
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
82
|
+
0 && (module.exports = {
|
|
83
|
+
DeferredRecaptchaProvider,
|
|
84
|
+
useDeferredRecaptcha
|
|
85
|
+
});
|
|
86
|
+
//# sourceMappingURL=recaptcha-provider.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/recaptcha-provider.tsx","../src/recaptcha.ts"],"sourcesContent":["'use client'\n\n/**\n * `@reblu/site-next/recaptcha-provider` — the FIX for REB-793.\n *\n * ─────────────────────────────────────────────────────────────────────────────\n * THE BUG THIS EXISTS TO CLOSE\n * ─────────────────────────────────────────────────────────────────────────────\n *\n * `deferredRecaptchaKey` (`./recaptcha`, REB-588) returns `''` until `armed`,\n * on the documented claim that `<GoogleReCaptchaProvider reCaptchaKey={...}>`\n * swaps the key in later without remounting. That claim is only half true.\n * Verified directly in `react-google-recaptcha-v3@1.11.0`'s own dist\n * (`dist/react-google-recaptcha-v3.esm.js`):\n *\n * var x = useRef(reCaptchaKey) // captured ONCE, on first mount\n * …\n * window[onLoadCallbackName] = () => { x.current = grecaptcha.render(…) }\n * …\n * executeRecaptcha = () => grecaptcha.execute(x.current, { action })\n *\n * `x.current` is reassigned ONLY inside the `onLoadCallbackName` global the\n * library wires into the script URL's `&onload=` query param — and that param\n * is only added when `render === 'explicit'`, i.e. when a visible widget\n * `container` is configured. Invisible v3 (no `container`, the ONLY mode this\n * SDK or any Reblu SITE uses) never sets that param, so `x.current` never\n * updates: every `executeRecaptcha()` call keeps sending the EMPTY first-mount\n * key to `grecaptcha.execute('', { action })`, which throws, which the SITE's\n * submit handler swallows into \"reCAPTCHA no está disponible\" — no token,\n * every submission dead on arrival. Found 29-Sep in the first real\n * contact-form test on dermamedicin preprod: zero requests ever reached\n * `/api/site/contact-form`.\n *\n * ─────────────────────────────────────────────────────────────────────────────\n * THE FIX — REMOUNT THE VENDOR, NOT THE SITE'S FORM\n * ─────────────────────────────────────────────────────────────────────────────\n *\n * `useRef`'s initial value is captured on FIRST MOUNT and nothing short of\n * unmounting-and-remounting the element changes that — the vendor gives us no\n * other hook. So the element that eventually loads Google's script must never\n * have been mounted with any key other than the real one: `key={reCaptchaKey}`\n * on `<GoogleReCaptchaProvider>` does exactly that. React treats a `key`\n * change as a different element identity and discards the old subtree instead\n * of re-rendering it, so arming does not swap a prop on a live instance — it\n * unmounts the empty-key instance (which never loaded anything, so there is\n * nothing to tear down) and mounts a BRAND NEW one whose `useRef` captures the\n * real key from its very first render.\n *\n * A first version of this fix put the SITE's own `children` directly inside\n * that keyed element. It closes the ref bug, but it also means the SITE's\n * form — every typed field, and any in-flight submit closure reading\n * `executeRecaptcha` — gets unmounted and remounted at the exact moment arming\n * happens. For the common case (arm on first focus, before any text exists)\n * that only costs a moment of focus. For demo-site's actual \"visitor tabs\n * straight to submit\" fallback (`waitForRecaptchaReady` in\n * `contact-form.section.tsx`) it is fatal: arming happens FROM INSIDE the\n * form's own submit handler, so remounting the form destroys the very\n * component instance that is awaiting the result — the typed values vanish\n * and the poll never resolves, reproduced by\n * `contact-form.section.recaptcha-submit.test.tsx` going red under that\n * design.\n *\n * So the vendor `<GoogleReCaptchaProvider>` here wraps ONLY an invisible\n * bridge component, never the SITE's `children`. The bridge reads the\n * vendor's `useGoogleReCaptcha()` — correct by construction, because it lives\n * inside the freshly, correctly keyed instance — and republishes\n * `executeRecaptcha` into `DeferredRecaptchaContext`, a context on a STABLE\n * element that is never keyed and never remounts. `children` renders as a\n * SIBLING of the vendor provider, under that stable context, and consumes the\n * function via `useDeferredRecaptcha()` (this module's own replacement for\n * the vendor's `useGoogleReCaptcha()`). Arming remounts the invisible bridge;\n * it never touches the SITE's form.\n *\n * ─────────────────────────────────────────────────────────────────────────────\n * WHY THIS IS A SEPARATE SUBPATH, NOT PART OF `./recaptcha`\n * ─────────────────────────────────────────────────────────────────────────────\n *\n * `./recaptcha` is guarded by `tests/recaptcha-subpath-isolation.test.ts` to\n * import NOTHING — not React, not `react-google-recaptcha-v3` — so a Server\n * Component can call `resolveContactFormAvailability` without paying for\n * Google's loader library. This component unavoidably imports both. Same R4\n * rule as `auth`/`academy`/`consent`: a `'use client'` directive is only\n * preserved by esbuild when the ENTRY FILE itself declares it, never when\n * merely re-exported — so this file is tsup's OWN entry (`recaptcha-provider`\n * in `../tsup.config.ts`), not folded into `./recaptcha` or the root barrel.\n *\n * `react-google-recaptcha-v3` stays the consuming SITE's OWN dependency\n * (an optional `peerDependencies` entry here, exactly as `./recaptcha`'s\n * README section already promises) — this package never bundles it.\n */\nimport {\n createContext,\n useCallback,\n useContext,\n useEffect,\n useMemo,\n useState,\n type ComponentProps,\n type ReactNode,\n} from 'react'\nimport { GoogleReCaptchaProvider, useGoogleReCaptcha } from 'react-google-recaptcha-v3'\n\nimport { deferredRecaptchaKey } from './recaptcha'\n\n/** The one thing a SITE needs, in the same shape the vendor's own hook returns it. */\nexport interface DeferredRecaptchaContextValue {\n /** `undefined` until Google's script has loaded under the ARMED key. */\n executeRecaptcha: ((action?: string) => Promise<string>) | undefined\n}\n\nconst UNARMED: DeferredRecaptchaContextValue = { executeRecaptcha: undefined }\n\nconst DeferredRecaptchaContext = createContext<DeferredRecaptchaContextValue>(UNARMED)\n\n/**\n * Lives INSIDE the keyed vendor provider — the only place the vendor's own\n * `executeRecaptcha` is ever correct — and republishes it to the stable\n * context above. Renders nothing; it is pure plumbing.\n */\nfunction ExecuteBridge({\n onChange,\n}: {\n onChange: (fn: DeferredRecaptchaContextValue['executeRecaptcha']) => void\n}) {\n const { executeRecaptcha } = useGoogleReCaptcha()\n useEffect(() => {\n onChange(executeRecaptcha)\n }, [executeRecaptcha, onChange])\n return null\n}\n\ntype ProviderPassthroughProps = Omit<\n ComponentProps<typeof GoogleReCaptchaProvider>,\n 'reCaptchaKey' | 'children'\n>\n\nexport interface DeferredRecaptchaProviderProps extends ProviderPassthroughProps {\n /** The tenant's public reCAPTCHA site key — `resolveContactFormAvailability`'s `recaptchaSiteKey`. */\n siteKey: string\n /** Whether the visitor has interacted with the form yet (REB-588 arming signal). */\n armed: boolean\n children: ReactNode\n}\n\n/**\n * Drop-in replacement for wiring `deferredRecaptchaKey` directly into\n * `<GoogleReCaptchaProvider reCaptchaKey={...}>` — see the module docs above\n * for why that direct wiring never mints a real token. Pair with\n * `useDeferredRecaptcha()` instead of the vendor's `useGoogleReCaptcha()`.\n *\n * ```tsx\n * <DeferredRecaptchaProvider siteKey={siteKey} armed={armed}>\n * <form onFocusCapture={arm} onPointerDown={arm}>…</form>\n * </DeferredRecaptchaProvider>\n * ```\n */\nexport function DeferredRecaptchaProvider({\n siteKey,\n armed,\n children,\n ...providerProps\n}: DeferredRecaptchaProviderProps) {\n const reCaptchaKey = deferredRecaptchaKey(siteKey, armed)\n const [executeRecaptcha, setExecuteRecaptcha] =\n useState<DeferredRecaptchaContextValue['executeRecaptcha']>(undefined)\n\n // No separate \"reset to undefined on key change\" effect is needed here,\n // and deliberately so: `reCaptchaKey` changing remounts `ExecuteBridge`\n // (it lives inside the keyed `GoogleReCaptchaProvider`), and a FRESH bridge\n // instance's very first effect run already reports the fresh instance's\n // own `executeRecaptcha` — `undefined` until that instance's own script has\n // loaded, never a value carried over from the instance that just unmounted.\n // An extra reset effect on THIS component was tried and reverted: as the\n // parent, its effects fire AFTER its children's on the same commit, so it\n // clobbered the bridge's own report back to `undefined` on every mount —\n // caught by `contact-form.section.test.tsx`, whose mocked\n // `useGoogleReCaptcha()` reports a working function synchronously.\n const handleChange = useCallback(\n (fn: DeferredRecaptchaContextValue['executeRecaptcha']) => setExecuteRecaptcha(() => fn),\n [],\n )\n\n // Memoized so every consumer of `useDeferredRecaptcha()` re-renders only\n // when `executeRecaptcha` itself actually changes, not on every render of\n // this provider (e.g. from an unrelated `providerProps` change).\n const contextValue = useMemo(() => ({ executeRecaptcha }), [executeRecaptcha])\n\n return (\n <DeferredRecaptchaContext.Provider value={contextValue}>\n <GoogleReCaptchaProvider key={reCaptchaKey} reCaptchaKey={reCaptchaKey} {...providerProps}>\n <ExecuteBridge onChange={handleChange} />\n </GoogleReCaptchaProvider>\n {children}\n </DeferredRecaptchaContext.Provider>\n )\n}\n\n/**\n * The SITE's replacement for `react-google-recaptcha-v3`'s own\n * `useGoogleReCaptcha()` when the form is wrapped in\n * `<DeferredRecaptchaProvider>`. Same shape (`{ executeRecaptcha }`), but\n * reads the STABLE context republished by `ExecuteBridge`, which never\n * remounts on arm — unlike the vendor's own context, which lives on the\n * element `DeferredRecaptchaProvider` deliberately keys and remounts.\n */\nexport function useDeferredRecaptcha(): DeferredRecaptchaContextValue {\n return useContext(DeferredRecaptchaContext)\n}\n","/**\n * reCAPTCHA v3 — the SITE's half of the contract (REB-588).\n *\n * ─────────────────────────────────────────────────────────────────────────────\n * WHICH HALF LIVES WHERE, AND WHY\n * ─────────────────────────────────────────────────────────────────────────────\n *\n * reCAPTCHA is two secrets and two steps, and they are deliberately split across\n * a trust boundary:\n *\n * SITE KEY — public by design. It ships to the browser inside the script URL,\n * so the tenant serves it from `/api/site/analytics`. This module\n * handles it.\n * SECRET — never leaves base-api. It is stored per tenant in\n * `site_integration_config.recaptchaSecretKey` and read only by\n * `serverCaptchaVerify` when `POST /api/site/contact-form` runs.\n *\n * **Nothing in this module, in this package, or anywhere in the `@reblu/site-*`\n * SDK ever sees the secret, and nothing here calls Google's `siteverify`.** A\n * SITE holding the secret would be a SITE that could be tricked into approving\n * its own tokens, and a distributable SDK is the worst possible place to put a\n * credential. Verification is base-api's job and already works: the SITE mints a\n * token, posts it as `ContactFormInput.captcha`, and base-api decides.\n *\n * That is also why this module is NOT \"reCAPTCHA support\" in the round. It is\n * the three decisions every SITE was otherwise making alone and differently:\n * which action to name, whether a form may be shown at all, and when Google's\n * script is allowed to load.\n *\n * ─────────────────────────────────────────────────────────────────────────────\n * WHY IT IS IN THE SDK AT ALL\n * ─────────────────────────────────────────────────────────────────────────────\n *\n * Because it was already being written four times. `apps/base-api`,\n * `apps/demo-site` and the external `reblu-site-dermamedicin` each grew their\n * own copy of this flow, and they had already drifted — different action\n * strings, different mount timing, a retry loop in one of them. A score that\n * reCAPTCHA reports per action is not comparable across SITEs that name the\n * action differently, and a defect fixed in one copy stays live in the other\n * two. The SDK is where a decision gets made once.\n *\n * This module has ZERO runtime dependencies and imports nothing — not React, not\n * `next`, not `react-google-recaptcha-v3`, not `@reblu/site-ui`. It is pure\n * functions over plain data, callable from a Server Component deciding whether\n * to render a form and from the Client Component that renders it. The subpath is\n * kept dependency-free by `tests/recaptcha-subpath-isolation.test.ts`.\n */\n\nimport type { Analytics, ContactFormMode } from '@reblu/site-contracts'\n\n/**\n * The action name every Reblu SITE mints its contact-form token under.\n *\n * reCAPTCHA v3 scores PER ACTION, and a score is only interpretable against a\n * stable one: Google's own admin console charts each action separately, and a\n * threshold tuned on one action says nothing about another. Two SITEs calling\n * `executeRecaptcha('submit_form')` and `executeRecaptcha('contact_form')` are\n * building two unrelated score histories for the same feature — which is exactly\n * the state the repo was in before this constant existed.\n *\n * `contact_form` over `submit_form` because it names the form, not the verb;\n * a second protected form would need its own action, and `submit_form` would\n * have nothing left to call it.\n *\n * ── WHAT THIS CONSTANT DOES **NOT** BUY, STATED PLAINLY ──\n *\n * It buys comparable scores. It does NOT bind a token to this form, because\n * `serverCaptchaVerify` in base-api checks only `success` and `score`, and never\n * reads the `action` field Google returns. A token minted for any other action\n * on the same site verifies here exactly as well. Making the action load-bearing\n * requires base-api to assert `action === RECAPTCHA_ACTION` on verify; until it\n * does, do not read this constant as a security control.\n */\nexport const RECAPTCHA_ACTION = 'contact_form'\n\n/**\n * The shape a site key must have before this SDK will hand it to Google.\n *\n * ── WHAT THIS IS, AND EMPHATICALLY WHAT IT IS NOT ──\n *\n * It is a SINK-SAFETY guard, not a guess at Google's key format. The key is\n * interpolated into the loader's script URL as a query parameter, so what\n * matters here is that it cannot carry a space, a quote, a newline, an `&`, or a\n * `#` — characters that make a URL mean something other than what was intended.\n * The base64url alphabet Google actually issues from (`A-Z a-z 0-9 - _`) is a\n * strict subset of \"safe\", so a real key passes by construction.\n *\n * The length bounds are deliberately far wider than any key Google issues\n * (theirs are 40 characters). A bound tight enough to be a real format check\n * would be a bound tight enough to take a WORKING tenant's contact form offline\n * the day Google changes its key length — which is strictly worse than the\n * malformed key it was meant to catch. If you are tempted to tighten this,\n * the thing to tighten is the nexus write path, where an owner can still see\n * and fix the error, not this read path where they cannot.\n *\n * ── WHY THE SDK RE-VALIDATES AT ALL ──\n *\n * Same reason `@reblu/site-next/analytics` re-checks `gaId` and `clarityId`\n * (REB-617): base-api is not the only thing a SITE ever talks to, and the layer\n * that owns the value is not the layer that suffers from it being wrong. As of\n * REB-588 this column has NO validation anywhere — not in the nexus form, not on\n * the way out of `/api/site/analytics`, not in the response contract, which is\n * the one asymmetry `gaId`/`clarityId` do not share. Until that is fixed\n * upstream, this is the only guard between a mistyped key and a contact form\n * that renders, looks live, and silently eats every enquiry.\n */\nexport const RECAPTCHA_SITE_KEY_PATTERN = /^[A-Za-z0-9_-]{16,128}$/\n\n/**\n * Whether `value` is a site key safe to put in the loader's script URL.\n *\n * Takes `unknown` on purpose: the value arrives over HTTP, so the contract's\n * `string | null` is a promise about the shape, not a guarantee about the bytes.\n */\nexport function isSafeRecaptchaSiteKey(value: unknown): value is string {\n return typeof value === 'string' && RECAPTCHA_SITE_KEY_PATTERN.test(value)\n}\n\n/**\n * The modes in which base-api will actually accept a submission.\n *\n * An ALLOW-LIST, never `mode !== 'DISABLED'`. The three differ only in what\n * base-api DOES with the payload — a row, a webhook, an email — and the SITE\n * must not care and must never learn the destination: a webhook URL or a Resend\n * key is a tenant secret with no business in a public site.\n *\n * The deny-list version of this is a real incident, not a hypothetical: a page\n * that hid the form on `DISABLED` and rendered it otherwise rendered it for\n * `NOT_CONFIGURED` too — which is what EVERY freshly provisioned tenant carries\n * until someone finishes the wizard (REB-586). A visitor typed out a medical\n * enquiry, pressed send, and got `503 Contact form is disabled`. They learned\n * the clinic could not be reached only after writing to it.\n */\nexport const CONTACT_FORM_OPERATIONAL_MODES = ['DB_ONLY', 'WEBHOOK', 'RESEND'] as const\n\n/** A mode in which the form may be shown. */\nexport type ContactFormOperationalMode = (typeof CONTACT_FORM_OPERATIONAL_MODES)[number]\n\n/**\n * The modes deliberately declined. A TYPE, never a value: the runtime must never\n * read it, because an allow-list must not be expressible as \"everything except\n * this list\", and this subpath's defining property is that it ships nothing it\n * does not need. It exists only to feed {@link ContactFormModeExhaustiveCheck}.\n */\ntype DeclinedMode = 'NOT_CONFIGURED' | 'DISABLED'\n\n/**\n * Compile-time exhaustiveness guard over `ContactFormMode`.\n *\n * `Missing` is constrained to `never` and DEFAULTS to the contract modes that\n * are neither accepted nor explicitly declined, so a default violating its own\n * constraint breaks the build the moment `@reblu/site-contracts` gains a mode.\n *\n * At runtime a new mode already fails closed, which is correct but SILENT — and\n * silence is how a tenant switches their form on in nexus and watches nothing\n * appear. This forces a human to make the call in a diff instead.\n *\n * Exported so it is never reported as an unused symbol.\n */\nexport type ContactFormModeExhaustiveCheck<\n Missing extends never = Exclude<ContactFormMode, ContactFormOperationalMode | DeclinedMode>,\n> = Missing\n\n/** Why the form is not being shown. Drives diagnostics, never visitor-facing copy. */\nexport type ContactFormUnavailableReason =\n /** The analytics read failed or was never made — no answer at all. */\n | 'unreadable'\n /** The tenant's mode is not one base-api accepts submissions in. */\n | 'mode-not-operational'\n /** Accepting mode, but no site key: the browser cannot mint a token. */\n | 'missing-recaptcha-key'\n /** A site key is present but cannot be a key — see {@link RECAPTCHA_SITE_KEY_PATTERN}. */\n | 'malformed-recaptcha-key'\n\nexport type ContactFormAvailability =\n /**\n * Confirmed: base-api accepts submissions AND a usable site key exists. The\n * key travels inside the `available` case on purpose — a component that has\n * proven the form may render should not have to re-read (and re-null-check)\n * the payload to find out what to render it WITH.\n */\n | { state: 'available'; mode: ContactFormOperationalMode; recaptchaSiteKey: string }\n | { state: 'unavailable'; reason: ContactFormUnavailableReason }\n\n/** Whitespace-only is absent — a key someone saved blank is not a key. */\nfunction trimmed(value: unknown): string | null {\n if (typeof value !== 'string') return null\n const text = value.trim()\n return text.length > 0 ? text : null\n}\n\nfunction isOperationalMode(mode: unknown): mode is ContactFormOperationalMode {\n // Widened to `unknown` deliberately: see isSafeRecaptchaSiteKey. A base-api\n // newer than this SDK sends a string TypeScript has never heard of, and it\n // must fail closed rather than be assumed accepting.\n return (CONTACT_FORM_OPERATIONAL_MODES as readonly string[]).includes(mode as string)\n}\n\n/**\n * Resolve whether a contact form may be rendered for this tenant.\n *\n * We offer what we can CONFIRM, never what we have merely failed to disprove: an\n * unknown mode, a mode from a runtime newer than this build, and a config read\n * that threw all resolve to the same answer — no form. The page still shows the\n * phone, the email and the address, so a hidden form costs a visitor one extra\n * glance; a form that eats their message costs them the message.\n *\n * Order matters. The mode is checked BEFORE the key, because \"the tenant has not\n * switched the form on\" is the actionable half of a double failure — a site key\n * for a form nobody enabled is not the problem to fix first.\n */\nexport function resolveContactFormAvailability(\n analytics: Analytics | null | undefined,\n): ContactFormAvailability {\n if (!analytics) return { state: 'unavailable', reason: 'unreadable' }\n\n const mode = analytics.contactFormMode\n if (!isOperationalMode(mode)) return { state: 'unavailable', reason: 'mode-not-operational' }\n\n // `ContactFormInput.captcha` is REQUIRED and base-api verifies it, so an\n // accepting mode with no usable key is still a form whose every submission is\n // dead on arrival. \"Missing\" and \"malformed\" are separated because they call\n // for different fixes by different people: one owner never filled the field,\n // the other filled it with the wrong thing.\n const recaptchaSiteKey = trimmed(analytics.recaptchaSiteKey)\n if (!recaptchaSiteKey) return { state: 'unavailable', reason: 'missing-recaptcha-key' }\n if (!isSafeRecaptchaSiteKey(recaptchaSiteKey)) {\n return { state: 'unavailable', reason: 'malformed-recaptcha-key' }\n }\n\n return { state: 'available', mode, recaptchaSiteKey }\n}\n\n/** Narrowing helper — the single question a renderer should ask. */\nexport function isContactFormAvailable(\n availability: ContactFormAvailability,\n): availability is Extract<ContactFormAvailability, { state: 'available' }> {\n return availability.state === 'available'\n}\n\n/**\n * The key to hand `GoogleReCaptchaProvider` on this render — `''` until `armed`.\n *\n * @deprecated **Wiring this return value directly into\n * `<GoogleReCaptchaProvider reCaptchaKey={deferredRecaptchaKey(siteKey, armed)}>`\n * is the exact REB-793 bug**: `react-google-recaptcha-v3@1.11.0` captures\n * `reCaptchaKey` in a `useRef` on FIRST MOUNT and never re-reads the prop for\n * invisible v3 (no visible `container`), so `executeRecaptcha` keeps sending\n * the STALE `''` from before arming to `grecaptcha.execute` forever — the\n * script loads with the right key, but every token mint throws, and every\n * contact-form submission is silently dead on arrival. Verified on\n * dermamedicin preprod 29-Sep: zero requests ever reached\n * `/api/site/contact-form`. Use `DeferredRecaptchaProvider` from\n * `@reblu/site-next/recaptcha-provider` instead — it applies this exact same\n * value as BOTH the `key` and the `reCaptchaKey` prop, which forces a full\n * remount on arm instead of a prop swap, so the mounted instance's `useRef`\n * never sees any key but the real one. This function stays exported,\n * unchanged, because `DeferredRecaptchaProvider` calls it internally and\n * external SITEs may already depend on its signature for other purposes\n * (e.g. rendering their own diagnostics) — this is a documentation-only\n * deprecation, not a behavior or breaking change.\n *\n * ─────────────────────────────────────────────────────────────────────────────\n * WHAT THIS BUYS\n * ─────────────────────────────────────────────────────────────────────────────\n *\n * `GoogleReCaptchaProvider` loads Google's `api.js` the moment it MOUNTS. Mount\n * it with the form and every visitor who merely lands on the page to read the\n * phone number has contacted Google — on a clinic's contact page, a third-party\n * request nobody asked for, on a route whose whole subject is personal data.\n *\n * Arming on first interaction with the form (focus, first keystroke) means\n * Google is contacted only for a visitor who is actually about to submit, which\n * is the only visitor whose token is worth anything. It is a smaller page, a\n * faster one, and a materially smaller consent surface.\n *\n * **This is NOT a cookie-consent implementation.** Deferring the script narrows\n * the exposure; it does not gate it on a lawful basis. Full consent gating for\n * third parties is REB-642 and has its own owner.\n *\n * ─────────────────────────────────────────────────────────────────────────────\n * HOW, WITHOUT A HACK — MEASURED, NOT ASSUMED\n * ─────────────────────────────────────────────────────────────────────────────\n *\n * `react-google-recaptcha-v3` injects the script from an effect shaped\n *\n * useEffect(() => { if (reCaptchaKey) { …inject…; return cleanup } … },\n * [ …, reCaptchaKey, … ])\n *\n * Two facts fall out, and together they are the whole mechanism:\n *\n * 1. An EMPTY key mounts the provider and injects NOTHING.\n * 2. `reCaptchaKey` is a dependency, so swapping the real key in later re-runs\n * the effect and injects the script then.\n *\n * Crucially the provider element never leaves the tree, so its children are\n * never remounted merely because `reCaptchaKey` changed on a LIVE instance:\n * no imperative script injection, no reaching into the DOM, no fork.\n *\n * **This describes script LOADING only, not token MINTING.** REB-793 found\n * the gap: the same swapped prop that safely re-runs this effect is also\n * where `react-google-recaptcha-v3` caches `reCaptchaKey` in a `useRef` — set\n * once, on first mount, and (for invisible v3) never reassigned. The script\n * loads with the swapped-in real key; `executeRecaptcha` keeps sending the\n * key from BEFORE the swap. Wiring this function's return value straight\n * into `<GoogleReCaptchaProvider reCaptchaKey={...}>`, the pattern this\n * whole comment used to recommend, is exactly that bug — see the\n * `@deprecated` note above and `@reblu/site-next/recaptcha-provider`'s\n * `DeferredRecaptchaProvider`, which fixes it by forcing a real remount\n * instead of relying on this same prop swap.\n *\n * Both facts are pinned by `tests/recaptcha-deferred-oracle.test.tsx`, which\n * drives the REAL library and counts script tags in the DOM. They are claims\n * about someone else's code, so prose asserting them is not enough — if a future\n * version of the library injects eagerly, that test goes red and this whole\n * optimisation is known to be void.\n *\n * ── THE ONE COST, MEASURED ──\n *\n * On the empty-key render the library calls its own warn helper, which is\n * written inverted, so the warning fires in PRODUCTION and stays silent in\n * development. A visitor who opens devtools before touching a field sees one\n * `\"<GoogleReCaptchaProvider /> recaptcha key not provided\"`. Cosmetic console\n * noise, traded against a third-party request on every view of the page. Worth\n * it, and recorded here so the next reader knows it is expected.\n *\n * ── AND NO FALLBACK KEY, EVER ──\n *\n * `''` for a blank OR malformed key however armed. There is no hardcoded\n * fallback anywhere in this module, because a captcha key that is not the\n * tenant's fails Google's verification while LOOKING configured — converting a\n * visible gap an owner can fix in nexus into a silent one nobody can.\n * {@link resolveContactFormAvailability} hides the form in that case; this is\n * the second lock on the same door.\n */\nexport function deferredRecaptchaKey(siteKey: string, armed: boolean): string {\n if (!armed) return ''\n const key = trimmed(siteKey)\n return key && isSafeRecaptchaSiteKey(key) ? key : ''\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AA0FA,mBASO;AACP,uCAA4D;;;ACMrD,IAAM,6BAA6B;AAQnC,SAAS,uBAAuB,OAAiC;AACtE,SAAO,OAAO,UAAU,YAAY,2BAA2B,KAAK,KAAK;AAC3E;AAqEA,SAAS,QAAQ,OAA+B;AAC9C,MAAI,OAAO,UAAU,SAAU,QAAO;AACtC,QAAM,OAAO,MAAM,KAAK;AACxB,SAAO,KAAK,SAAS,IAAI,OAAO;AAClC;AAkJO,SAAS,qBAAqB,SAAiB,OAAwB;AAC5E,MAAI,CAAC,MAAO,QAAO;AACnB,QAAM,MAAM,QAAQ,OAAO;AAC3B,SAAO,OAAO,uBAAuB,GAAG,IAAI,MAAM;AACpD;;;ADvJI;AA9EJ,IAAM,UAAyC,EAAE,kBAAkB,OAAU;AAE7E,IAAM,+BAA2B,4BAA6C,OAAO;AAOrF,SAAS,cAAc;AAAA,EACrB;AACF,GAEG;AACD,QAAM,EAAE,iBAAiB,QAAI,qDAAmB;AAChD,8BAAU,MAAM;AACd,aAAS,gBAAgB;AAAA,EAC3B,GAAG,CAAC,kBAAkB,QAAQ,CAAC;AAC/B,SAAO;AACT;AA2BO,SAAS,0BAA0B;AAAA,EACxC;AAAA,EACA;AAAA,EACA;AAAA,EACA,GAAG;AACL,GAAmC;AACjC,QAAM,eAAe,qBAAqB,SAAS,KAAK;AACxD,QAAM,CAAC,kBAAkB,mBAAmB,QAC1C,uBAA4D,MAAS;AAavE,QAAM,mBAAe;AAAA,IACnB,CAAC,OAA0D,oBAAoB,MAAM,EAAE;AAAA,IACvF,CAAC;AAAA,EACH;AAKA,QAAM,mBAAe,sBAAQ,OAAO,EAAE,iBAAiB,IAAI,CAAC,gBAAgB,CAAC;AAE7E,SACE,6CAAC,yBAAyB,UAAzB,EAAkC,OAAO,cACxC;AAAA,gDAAC,4DAA2C,cAA6B,GAAG,eAC1E,sDAAC,iBAAc,UAAU,cAAc,KADX,YAE9B;AAAA,IACC;AAAA,KACH;AAEJ;AAUO,SAAS,uBAAsD;AACpE,aAAO,yBAAW,wBAAwB;AAC5C;","names":[]}
|