@brandlab-digital/site-next 3.9.0 → 3.10.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/README.md +33 -0
- package/dist/contact/attachments-client.d.ts +3 -0
- package/dist/contact/attachments-client.d.ts.map +1 -1
- package/dist/contact/attachments-client.js +31 -8
- package/dist/contact/attachments-client.js.map +1 -1
- package/dist/contact/client-entry.d.ts +1 -1
- package/dist/contact/client-entry.d.ts.map +1 -1
- package/dist/contact/client-entry.js.map +1 -1
- package/dist/contact/contract.d.ts.map +1 -1
- package/dist/contact/contract.js +2 -2
- package/dist/contact/contract.js.map +1 -1
- package/dist/contact/email.d.ts.map +1 -1
- package/dist/contact/email.js +9 -0
- package/dist/contact/email.js.map +1 -1
- package/dist/contact/form-client.d.ts +4 -2
- package/dist/contact/form-client.d.ts.map +1 -1
- package/dist/contact/form-client.js +49 -13
- package/dist/contact/form-client.js.map +1 -1
- package/dist/contact/rate-limit.d.ts +59 -0
- package/dist/contact/rate-limit.d.ts.map +1 -0
- package/dist/contact/rate-limit.js +109 -0
- package/dist/contact/rate-limit.js.map +1 -0
- package/dist/contact/route-handler.d.ts +6 -0
- package/dist/contact/route-handler.d.ts.map +1 -1
- package/dist/contact/route-handler.js +4 -0
- package/dist/contact/route-handler.js.map +1 -1
- package/dist/contact/server-entry.d.ts +1 -1
- package/dist/contact/server-entry.d.ts.map +1 -1
- package/dist/contact/server-entry.js +1 -1
- package/dist/contact/server-entry.js.map +1 -1
- package/dist/contact/server.d.ts +9 -0
- package/dist/contact/server.d.ts.map +1 -1
- package/dist/contact/server.js +71 -7
- package/dist/contact/server.js.map +1 -1
- package/dist/contact/shared.d.ts +11 -0
- package/dist/contact/shared.d.ts.map +1 -1
- package/dist/contact/shared.js +11 -0
- package/dist/contact/shared.js.map +1 -1
- package/dist/contact/testing.d.ts +5 -0
- package/dist/contact/testing.d.ts.map +1 -1
- package/dist/contact/testing.js +1 -0
- package/dist/contact/testing.js.map +1 -1
- package/dist/lead-reporting.d.ts +6 -0
- package/dist/lead-reporting.d.ts.map +1 -1
- package/dist/lead-reporting.js +13 -1
- package/dist/lead-reporting.js.map +1 -1
- package/dist/ui-kit/brandlab-skin.d.ts +12 -0
- package/dist/ui-kit/brandlab-skin.d.ts.map +1 -1
- package/dist/ui-kit/brandlab-skin.js.map +1 -1
- package/dist/ui-kit/form/copy.d.ts +3 -0
- package/dist/ui-kit/form/copy.d.ts.map +1 -0
- package/dist/ui-kit/form/copy.js +5 -0
- package/dist/ui-kit/form/copy.js.map +1 -0
- package/dist/ui-kit/form/dialog.d.ts +24 -0
- package/dist/ui-kit/form/dialog.d.ts.map +1 -0
- package/dist/ui-kit/form/dialog.js +17 -0
- package/dist/ui-kit/form/dialog.js.map +1 -0
- package/dist/ui-kit/form/field.d.ts +30 -0
- package/dist/ui-kit/form/field.d.ts.map +1 -0
- package/dist/ui-kit/form/field.js +11 -0
- package/dist/ui-kit/form/field.js.map +1 -0
- package/dist/ui-kit/form/index.d.ts +7 -0
- package/dist/ui-kit/form/index.d.ts.map +1 -0
- package/dist/ui-kit/form/index.js +7 -0
- package/dist/ui-kit/form/index.js.map +1 -0
- package/dist/ui-kit/form/photo-upload.d.ts +37 -0
- package/dist/ui-kit/form/photo-upload.d.ts.map +1 -0
- package/dist/ui-kit/form/photo-upload.js +19 -0
- package/dist/ui-kit/form/photo-upload.js.map +1 -0
- package/dist/ui-kit/form/quote-dialog.d.ts +15 -0
- package/dist/ui-kit/form/quote-dialog.d.ts.map +1 -0
- package/dist/ui-kit/form/quote-dialog.js +68 -0
- package/dist/ui-kit/form/quote-dialog.js.map +1 -0
- package/dist/ui-kit/form/quote-form.d.ts +165 -0
- package/dist/ui-kit/form/quote-form.d.ts.map +1 -0
- package/dist/ui-kit/form/quote-form.js +235 -0
- package/dist/ui-kit/form/quote-form.js.map +1 -0
- package/dist/ui-kit/form/thank-you.d.ts +37 -0
- package/dist/ui-kit/form/thank-you.d.ts.map +1 -0
- package/dist/ui-kit/form/thank-you.js +36 -0
- package/dist/ui-kit/form/thank-you.js.map +1 -0
- package/dist/ui-kit/structure.css +197 -0
- package/dist/ui.d.ts +10 -1
- package/dist/ui.d.ts.map +1 -1
- package/dist/ui.js +3 -3
- package/dist/ui.js.map +1 -1
- package/package.json +6 -2
|
@@ -3,9 +3,10 @@ import { DEFAULT_CONTACT_ATTACHMENT_POLICY, DOCUMENT_CONTACT_ATTACHMENT_POLICY,
|
|
|
3
3
|
import { normalizeBrandlabRoutePath } from '@brandlab-digital/site-core/routes';
|
|
4
4
|
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore, } from 'react';
|
|
5
5
|
import { leadBindingFor, safeSlug } from '../lead-registry.js';
|
|
6
|
-
import { reportAcceptedLead } from '../lead-reporting.js';
|
|
6
|
+
import { contactFormDataHasPhotos, reportAcceptedLead } from '../lead-reporting.js';
|
|
7
7
|
import { useBrandlabContactAttachments, } from './attachments-client.js';
|
|
8
8
|
import { createContactHttpSubmissionController, isContactClientFailure, } from './client.js';
|
|
9
|
+
import { BRANDLAB_CONTACT_TEST_MARKER, CONTACT_FORM_FIELDS } from './shared.js';
|
|
9
10
|
const INITIAL_STATE = {
|
|
10
11
|
phase: 'idle',
|
|
11
12
|
pending: false,
|
|
@@ -45,6 +46,29 @@ export function contactFormSlug(formId) {
|
|
|
45
46
|
.replace(/-+$/, '');
|
|
46
47
|
return derived || 'contact-form';
|
|
47
48
|
}
|
|
49
|
+
/**
|
|
50
|
+
* A Brandlab test visit: a page opened with `?bl_test=1` (a launch proof or canary), remembered for the tab so the
|
|
51
|
+
* marker survives moving to the quote form; `?bl_test=0` ends it. Its enquiries carry the `bl_test` marker.
|
|
52
|
+
*/
|
|
53
|
+
function brandlabTestVisit() {
|
|
54
|
+
if (typeof window === 'undefined')
|
|
55
|
+
return false;
|
|
56
|
+
try {
|
|
57
|
+
const flag = new URLSearchParams(window.location.search).get(CONTACT_FORM_FIELDS.testMarker);
|
|
58
|
+
if (flag === BRANDLAB_CONTACT_TEST_MARKER)
|
|
59
|
+
window.sessionStorage.setItem(CONTACT_FORM_FIELDS.testMarker, flag);
|
|
60
|
+
if (flag === '0')
|
|
61
|
+
window.sessionStorage.removeItem(CONTACT_FORM_FIELDS.testMarker);
|
|
62
|
+
return window.sessionStorage.getItem(CONTACT_FORM_FIELDS.testMarker) === BRANDLAB_CONTACT_TEST_MARKER ||
|
|
63
|
+
flag === BRANDLAB_CONTACT_TEST_MARKER;
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
function isTestSubmission(formData) {
|
|
70
|
+
return formData.get(CONTACT_FORM_FIELDS.testMarker) === BRANDLAB_CONTACT_TEST_MARKER;
|
|
71
|
+
}
|
|
48
72
|
function safePagePath(pagePath) {
|
|
49
73
|
try {
|
|
50
74
|
return normalizeBrandlabRoutePath(pagePath.split(/[?#]/, 1)[0] || '/');
|
|
@@ -94,8 +118,10 @@ function assertLifecycleOptions(options) {
|
|
|
94
118
|
* submission ID (so a resend cannot duplicate the email), the first invalid
|
|
95
119
|
* field is focused, and an accepted enquiry resets the form and redirects.
|
|
96
120
|
* The lead is reported exactly as the site's defineBrandlabAnalytics binding
|
|
97
|
-
* declares, once per accepted enquiry
|
|
98
|
-
*
|
|
121
|
+
* declares, once per accepted enquiry, with contact_form's `has_photos`. On a
|
|
122
|
+
* Brandlab test visit (`?bl_test=1`) each enquiry carries the `bl_test` marker
|
|
123
|
+
* and is never reported (no lead diagnostics either). Browser validation is left exactly as the form's own
|
|
124
|
+
* markup declares it.
|
|
99
125
|
*/
|
|
100
126
|
export function useBrandlabContactForm(options) {
|
|
101
127
|
const contract = useMemo(() => defineContactContract(options.contract), [options.contract]);
|
|
@@ -133,10 +159,14 @@ export function useBrandlabContactForm(options) {
|
|
|
133
159
|
...(lead
|
|
134
160
|
? {
|
|
135
161
|
reportAccepted: (result, formData) => {
|
|
162
|
+
// The server answers a test enquiry without a confirmation; this only guards that rule.
|
|
163
|
+
if (isTestSubmission(formData))
|
|
164
|
+
return;
|
|
136
165
|
reportAcceptedLead(lead, result, {
|
|
137
166
|
pagePath: rawPagePath,
|
|
138
167
|
formData,
|
|
139
168
|
fieldRoles: roles,
|
|
169
|
+
hasPhotos: contactFormDataHasPhotos(formData),
|
|
140
170
|
...(adUserDataConsent !== undefined ? { adUserDataConsent } : {}),
|
|
141
171
|
});
|
|
142
172
|
},
|
|
@@ -156,6 +186,9 @@ export function useBrandlabContactForm(options) {
|
|
|
156
186
|
if (startedRef.current)
|
|
157
187
|
return;
|
|
158
188
|
startedRef.current = true;
|
|
189
|
+
// A Brandlab test visit is never a starter: no diagnostics.
|
|
190
|
+
if (brandlabTestVisit())
|
|
191
|
+
return;
|
|
159
192
|
latest.current.analytics?.trackLeadFormStart({ pagePath: rawPagePath, formId: diagnosticsFormId });
|
|
160
193
|
}, [diagnosticsFormId, rawPagePath]);
|
|
161
194
|
const handleResult = useCallback((result) => {
|
|
@@ -164,16 +197,17 @@ export function useBrandlabContactForm(options) {
|
|
|
164
197
|
if (!result.ok) {
|
|
165
198
|
const reason = errorReason(result);
|
|
166
199
|
const field = reason === 'field' ? firstErrorField(result, fieldOrder) : undefined;
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
200
|
+
if (!brandlabTestVisit())
|
|
201
|
+
current.analytics?.trackLeadFormError({
|
|
202
|
+
pagePath: rawPagePath,
|
|
203
|
+
formId: diagnosticsFormId,
|
|
204
|
+
stage: errorStage(result),
|
|
205
|
+
errorCount: result.code === 'validation'
|
|
206
|
+
? Math.min(50, Math.max(1, Object.keys(result.fieldErrors ?? {}).length))
|
|
207
|
+
: 1,
|
|
208
|
+
reason,
|
|
209
|
+
...(field ? { field } : {}),
|
|
210
|
+
});
|
|
177
211
|
current.onFailed?.(result);
|
|
178
212
|
return;
|
|
179
213
|
}
|
|
@@ -213,6 +247,8 @@ export function useBrandlabContactForm(options) {
|
|
|
213
247
|
const formData = new FormData(form);
|
|
214
248
|
if (latest.current.onSubmitStart?.({ form, formData }) === false)
|
|
215
249
|
return;
|
|
250
|
+
if (brandlabTestVisit())
|
|
251
|
+
formData.set(CONTACT_FORM_FIELDS.testMarker, BRANDLAB_CONTACT_TEST_MARKER);
|
|
216
252
|
trackStart();
|
|
217
253
|
attachments.clearErrorMessage();
|
|
218
254
|
attachments.images.forEach((image, index) => {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"form-client.js","sourceRoot":"","sources":["../../src/contact/form-client.ts"],"names":[],"mappings":"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","sourcesContent":["'use client'\n\nimport {\n DEFAULT_CONTACT_ATTACHMENT_POLICY,\n DOCUMENT_CONTACT_ATTACHMENT_POLICY,\n defineContactContract,\n type BrandlabContactContract,\n type ContactActionResult,\n} from '@brandlab-digital/site-core/contact'\nimport { normalizeBrandlabRoutePath } from '@brandlab-digital/site-core/routes'\nimport {\n useCallback,\n useEffect,\n useMemo,\n useRef,\n useState,\n useSyncExternalStore,\n type FormEvent,\n type RefObject,\n} from 'react'\nimport type { BrandlabAnalytics, LeadFormErrorReason, LeadFormErrorStage } from '../analytics.js'\nimport { leadBindingFor, safeSlug } from '../lead-registry.js'\nimport { reportAcceptedLead } from '../lead-reporting.js'\nimport {\n useBrandlabContactAttachments,\n type UseBrandlabContactAttachmentsOptions,\n type UseBrandlabContactAttachmentsResult,\n} from './attachments-client.js'\nimport {\n createContactHttpSubmissionController,\n isContactClientFailure,\n type ContactSubmissionPhase,\n type ContactSubmissionState,\n} from './client.js'\n\n/** An accepted enquiry, or a silently accepted spam-protected submission (`accepted: false`). */\nexport type BrandlabContactAcceptedResult = Extract<ContactActionResult, { ok: true }>\nexport type BrandlabContactFailedResult = Extract<ContactActionResult, { ok: false }>\n\nexport interface BrandlabContactSubmitStart {\n form: HTMLFormElement\n /**\n * The fields about to be sent, read from the form. Add or replace entries\n * here (for example `service_interest`); prepared photos are appended after.\n */\n formData: FormData\n}\n\nexport interface UseBrandlabContactFormOptions {\n /** The contact contract from src/config/contact.ts. */\n contract: BrandlabContactContract\n /** The site's defineBrandlabAnalytics binding: the declared lead after each accepted enquiry, plus lead diagnostics. */\n analytics?: BrandlabAnalytics\n /** The current pathname, for example `usePathname()`. */\n pagePath: string\n /** Reviewed label for the enquiry email's page row. */\n pageLabel?: string\n /**\n * The form element's id and the lead diagnostics' form_id. Without it the form\n * gets no id (the hook never takes a React id, so the form's own ids are\n * unchanged), diagnostics use `contact-form` and error ids start `contact-form-`.\n * The element keeps the id exactly as given; diagnostics report it as a slug\n * (React `useId()` parts removed, see `contactFormSlug`).\n */\n formId?: string\n /** Page to open after an accepted enquiry; `null` or omitted keeps the visitor on the page. */\n redirectTo?: string | null\n /** Client navigation such as `router.push`; defaults to `window.location.assign`. */\n navigate?: (href: string) => void\n /** Delay before the redirect, in milliseconds (default 1500); 0 navigates at once. */\n redirectDelayMs?: number\n /**\n * Runs when the visitor submits, before anything is sent: clear the form's\n * own notices, adjust `formData`, or return `false` to cancel (for example\n * after `form.reportValidity()`). An unchanged resubmission runs it too;\n * `retry()` does not.\n */\n onSubmitStart?: (submission: BrandlabContactSubmitStart) => void | false\n /**\n * Runs after every attempt, before onAccepted or onFailed: an accepted\n * enquiry, a silently accepted submission (even when ignored) or a failure.\n */\n onSettled?: (result: ContactActionResult) => void\n /** Runs after an accepted enquiry, once the form has been reset and before any redirect. */\n onAccepted?: (result: BrandlabContactAcceptedResult) => void\n /** Runs after a failed attempt; typed values and prepared photos are kept. */\n onFailed?: (result: BrandlabContactFailedResult) => void\n /**\n * A silently accepted submission (spam protection): `complete` (default)\n * resets, runs onAccepted and redirects like an accepted enquiry; `ignore`\n * leaves the form as it is and runs only onSettled.\n */\n silentAcceptance?: 'complete' | 'ignore'\n /** Photo preparation options; the policy follows the contract's attachment mode by default. */\n attachments?: UseBrandlabContactAttachmentsOptions\n /** Multipart field prefix for prepared files (default `attachments`, sent as `attachments[0]` …). */\n attachmentFieldPrefix?: string\n /** Live Consent Mode decision for ad_user_data; without it Enhanced Conversions stay off, unless the analytics contract's mode is `disclosed`. */\n adUserDataConsent?: boolean\n contactEndpoint?: string\n fetcher?: typeof fetch\n}\n\n/**\n * Spread onto the `<form>`: `method=\"post\"` with no `action` or `encType`, so a\n * submission before hydration posts back to the page and never puts fields in\n * a URL; the hook submits through fetch. `id` only when `formId` is given.\n */\nexport interface BrandlabContactFormProps {\n id?: string\n ref: RefObject<HTMLFormElement | null>\n method: 'post'\n 'aria-busy': boolean\n onSubmit: (event: FormEvent<HTMLFormElement>) => void\n onFocusCapture: () => void\n}\n\n/** Spread onto the submit button: disabled until hydration, while sending or preparing photos. */\nexport interface BrandlabContactSubmitProps {\n type: 'submit'\n disabled: boolean\n}\n\nexport interface UseBrandlabContactFormResult {\n /** The `formId` option, or `contact-form` when none was given. */\n formId: string\n formProps: BrandlabContactFormProps\n submitProps: BrandlabContactSubmitProps\n /** False during server rendering and hydration. */\n hydrated: boolean\n /** The submit button's `disabled` (also in submitProps): until hydration, while sending or preparing photos. */\n submitDisabled: boolean\n pending: boolean\n phase: ContactSubmissionPhase\n result: ContactActionResult | undefined\n /** An accepted enquiry (a silently accepted spam-protected submission looks the same). */\n succeeded: boolean\n /** A retryable failure: retry(), or submitting the same fields again, reuses its submission ID. */\n canRetry: boolean\n fieldErrors: Readonly<Record<string, readonly string[]>>\n fieldError(name: string): string | undefined\n /** Id for a field's error message element: `${formId}-${name}-error`. */\n errorId(name: string): string\n /** Attach to the status element; it receives focus when an error has no invalid field. */\n statusRef: RefObject<HTMLElement | null>\n /** Resends the failed enquiry with the same submission ID. */\n retry(): Promise<ContactActionResult | undefined>\n attachments: UseBrandlabContactAttachmentsResult\n /** Contract field keys accepted by contactFieldProps. */\n fieldNames: ReadonlySet<string>\n}\n\nconst INITIAL_STATE: ContactSubmissionState = {\n phase: 'idle',\n pending: false,\n canRetry: false,\n}\nconst DEFAULT_REDIRECT_DELAY_MS = 1_500\nconst FOCUSABLE_INVALID_FIELD = [\n 'input[aria-invalid=\"true\"]:not([disabled]):not([type=\"hidden\"])',\n 'select[aria-invalid=\"true\"]:not([disabled])',\n 'textarea[aria-invalid=\"true\"]:not([disabled])',\n 'button[aria-invalid=\"true\"]:not([disabled])',\n '[aria-invalid=\"true\"][tabindex]:not([aria-disabled=\"true\"])',\n].join(',')\n\nconst subscribeToNothing = () => () => undefined\nconst getClientSnapshot = () => true\nconst getServerSnapshot = () => false\n\n/** React `useId()` values: `_R_1a_`/`_r_1a_` (19.2), `«r1a»` (19.0 and 19.1), `:r1a:` (18). */\nconst REACT_ID = /_[rR]_[0-9A-Za-z]*_|«[rR][0-9A-Za-z]*»|:[rR][0-9A-Za-z]*:/g\n\n/**\n * The lead diagnostics' form_id for any formId. A valid slug is kept (trimmed and\n * lowercased). Otherwise React `useId()` parts are removed, so an id made unique for\n * the page still reports as one stable form, every run of other characters becomes\n * one hyphen, and the result is at most 80 characters; `contact-form` when nothing\n * is left. Without this, a formId such as `contact-form-_R_5e35f9db_` silently\n * dropped lead_form_start and lead_form_error.\n */\nexport function contactFormSlug(formId: string): string {\n const slug = safeSlug(String(formId))\n if (slug) return slug\n const derived = String(formId)\n .replace(REACT_ID, '-')\n .toLowerCase()\n .replace(/[^a-z0-9]+/g, '-')\n .replace(/^-+/, '')\n .slice(0, 80)\n .replace(/-+$/, '')\n return derived || 'contact-form'\n}\n\nfunction safePagePath(pagePath: string) {\n try {\n return normalizeBrandlabRoutePath(pagePath.split(/[?#]/, 1)[0] || '/')\n } catch {\n return '/'\n }\n}\n\nfunction errorStage(result: BrandlabContactFailedResult): LeadFormErrorStage {\n return result.code === 'validation' ? 'validation' : 'delivery'\n}\n\n/**\n * Separates a field the visitor can correct from a request the server refused,\n * rejected photos, a server delivery failure and a browser-side failure, which\n * error_stage alone reports as only `validation` or `delivery`.\n */\nfunction errorReason(result: BrandlabContactFailedResult): LeadFormErrorReason {\n if (result.code === 'validation') {\n return Object.keys(result.fieldErrors ?? {}).length > 0 ? 'field' : 'request'\n }\n if (result.code === 'attachment_rejected') return 'attachment'\n return isContactClientFailure(result) ? 'network' : 'delivery'\n}\n\n/** The first rejected field in contract order: a contract key, never a value. */\nfunction firstErrorField(result: BrandlabContactFailedResult, fieldOrder: readonly string[]) {\n const fieldErrors = result.fieldErrors ?? {}\n return fieldOrder.find((key) => fieldErrors[key]?.length) ?? Object.keys(fieldErrors)[0]\n}\n\nfunction assertLifecycleOptions(options: UseBrandlabContactFormOptions) {\n const delay = options.redirectDelayMs\n if (delay !== undefined && (!Number.isInteger(delay) || delay < 0 || delay > 60_000)) {\n throw new TypeError('redirectDelayMs must be a whole number of milliseconds from 0 to 60000.')\n }\n if (options.silentAcceptance !== undefined && !['complete', 'ignore'].includes(options.silentAcceptance)) {\n throw new TypeError('silentAcceptance must be \"complete\" or \"ignore\".')\n }\n if (options.analytics !== undefined && !leadBindingFor(options.analytics)) {\n throw new TypeError('analytics must be the binding returned by defineBrandlabAnalytics.')\n }\n}\n\n/**\n * Contact form behaviour for project-owned markup and copy. The form posts\n * through fetch with FormData; before hydration the submit button stays\n * disabled. Typed values (uncontrolled fields) and prepared photos are kept\n * after any failure, retry() and an unchanged resubmission reuse the same\n * submission ID (so a resend cannot duplicate the email), the first invalid\n * field is focused, and an accepted enquiry resets the form and redirects.\n * The lead is reported exactly as the site's defineBrandlabAnalytics binding\n * declares, once per accepted enquiry. Browser validation is left exactly as\n * the form's own markup declares it.\n */\nexport function useBrandlabContactForm(options: UseBrandlabContactFormOptions): UseBrandlabContactFormResult {\n const contract = useMemo(() => defineContactContract(options.contract), [options.contract])\n assertLifecycleOptions(options)\n const hydrated = useSyncExternalStore(subscribeToNothing, getClientSnapshot, getServerSnapshot)\n const formId = options.formId ?? 'contact-form'\n const diagnosticsFormId = contactFormSlug(formId)\n const rawPagePath = options.pagePath\n const pagePath = safePagePath(rawPagePath)\n const formRef = useRef<HTMLFormElement>(null)\n const statusRef = useRef<HTMLElement>(null)\n const [state, setState] = useState<ContactSubmissionState>(INITIAL_STATE)\n const lockRef = useRef(false)\n const startedRef = useRef(false)\n const redirectTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)\n const latest = useRef(options)\n latest.current = options\n\n const policy = options.attachments?.policy ??\n (contract.attachments.mode === 'images-and-pdfs-optional'\n ? DOCUMENT_CONTACT_ATTACHMENT_POLICY\n : DEFAULT_CONTACT_ATTACHMENT_POLICY)\n const attachments = useBrandlabContactAttachments({ ...options.attachments, policy })\n const attachmentPrefix = options.attachmentFieldPrefix ?? 'attachments'\n\n const fieldOrder = useMemo(() => contract.fields.map((field) => field.key), [contract])\n const fieldNames = useMemo(() => new Set(fieldOrder), [fieldOrder])\n const roles = useMemo(\n () => ({ fields: contract.fields.map((field) => ({ name: field.key, role: field.role })) }),\n [contract],\n )\n const { analytics, pageLabel, contactEndpoint, fetcher, adUserDataConsent } = options\n const controller = useMemo(() => {\n const lead = leadBindingFor(analytics)\n return createContactHttpSubmissionController({\n pagePath,\n ...(pageLabel ? { pageLabel } : {}),\n ...(contactEndpoint ? { contactEndpoint } : {}),\n ...(fetcher ? { fetcher } : {}),\n ...(lead\n ? {\n reportAccepted: (result, formData) => {\n reportAcceptedLead(lead, result, {\n pagePath: rawPagePath,\n formData,\n fieldRoles: roles,\n ...(adUserDataConsent !== undefined ? { adUserDataConsent } : {}),\n })\n },\n }\n : {}),\n onStateChange: setState,\n })\n }, [adUserDataConsent, analytics, contactEndpoint, fetcher, pageLabel, pagePath, rawPagePath, roles])\n\n useEffect(() => () => clearTimeout(redirectTimerRef.current), [])\n\n useEffect(() => {\n if (state.phase !== 'error') return\n const invalid = formRef.current?.querySelector<HTMLElement>(FOCUSABLE_INVALID_FIELD)\n ;(invalid ?? statusRef.current)?.focus()\n }, [state])\n\n const trackStart = useCallback(() => {\n if (startedRef.current) return\n startedRef.current = true\n latest.current.analytics?.trackLeadFormStart({ pagePath: rawPagePath, formId: diagnosticsFormId })\n }, [diagnosticsFormId, rawPagePath])\n\n const handleResult = useCallback(\n (result: ContactActionResult) => {\n const current = latest.current\n current.onSettled?.(result)\n if (!result.ok) {\n const reason = errorReason(result)\n const field = reason === 'field' ? firstErrorField(result, fieldOrder) : undefined\n current.analytics?.trackLeadFormError({\n pagePath: rawPagePath,\n formId: diagnosticsFormId,\n stage: errorStage(result),\n errorCount: result.code === 'validation'\n ? Math.min(50, Math.max(1, Object.keys(result.fieldErrors ?? {}).length))\n : 1,\n reason,\n ...(field ? { field } : {}),\n })\n current.onFailed?.(result)\n return\n }\n if (!result.accepted && current.silentAcceptance === 'ignore') return\n formRef.current?.reset()\n attachments.clearImages()\n current.onAccepted?.(result)\n const redirectTo = current.redirectTo\n if (!redirectTo) return\n const navigate = current.navigate ?? ((href: string) => window.location.assign(href))\n const delay = current.redirectDelayMs ?? DEFAULT_REDIRECT_DELAY_MS\n clearTimeout(redirectTimerRef.current)\n if (delay === 0) {\n navigate(redirectTo)\n return\n }\n redirectTimerRef.current = setTimeout(() => navigate(redirectTo), delay)\n },\n [attachments, diagnosticsFormId, fieldOrder, rawPagePath],\n )\n\n const send = useCallback(\n async (attempt: () => Promise<ContactActionResult>) => {\n lockRef.current = true\n try {\n const result = await attempt()\n handleResult(result)\n return result\n } finally {\n lockRef.current = false\n }\n },\n [handleResult],\n )\n\n const onSubmit = useCallback(\n (event: FormEvent<HTMLFormElement>) => {\n event.preventDefault()\n if (lockRef.current || attachments.isProcessing) return\n const form = event.currentTarget\n const formData = new FormData(form)\n if (latest.current.onSubmitStart?.({ form, formData }) === false) return\n trackStart()\n attachments.clearErrorMessage()\n attachments.images.forEach((image, index) => {\n formData.append(`${attachmentPrefix}[${index}]`, image.file)\n })\n // The controller reports every failure as a result; nothing is left to handle here.\n send(() => controller.submit(formData)).catch(() => undefined)\n },\n [attachmentPrefix, attachments, controller, send, trackStart],\n )\n\n const retry = useCallback(async () => {\n if (lockRef.current || !controller.getState().canRetry) return undefined\n return send(() => controller.retry())\n }, [controller, send])\n\n const fieldErrors = state.result && !state.result.ok && state.result.code === 'validation'\n ? state.result.fieldErrors ?? {}\n : {}\n\n const submitDisabled = !hydrated || state.pending || attachments.isProcessing\n return {\n formId,\n formProps: {\n ...(options.formId ? { id: options.formId } : {}),\n ref: formRef,\n method: 'post',\n 'aria-busy': state.pending || attachments.isProcessing,\n onSubmit,\n onFocusCapture: trackStart,\n },\n submitProps: { type: 'submit', disabled: submitDisabled },\n hydrated,\n submitDisabled,\n pending: state.pending,\n phase: state.phase,\n result: state.result,\n succeeded: state.result?.ok === true,\n canRetry: state.canRetry,\n fieldErrors,\n fieldError: (name: string) => fieldErrors[name]?.[0],\n errorId: (name: string) => `${formId}-${name}-error`,\n statusRef,\n retry,\n attachments,\n fieldNames,\n }\n}\n\nexport interface ContactFieldPropsOptions {\n /** Ids of help text already describing the field; the error id is appended. */\n describedBy?: string\n /** Defaults to disabled while an enquiry is sending. */\n disabled?: boolean\n}\n\n/**\n * Props for one uncontrolled contract field: name, disabled while sending and\n * the error relationship. It never adds value, required or length attributes,\n * so the form's existing validation is unchanged.\n */\nexport function contactFieldProps(\n form: UseBrandlabContactFormResult,\n name: string,\n options: ContactFieldPropsOptions = {},\n) {\n if (!form.fieldNames.has(name)) {\n throw new Error(`\"${name}\" is not a field in the contact contract.`)\n }\n const error = form.fieldError(name)\n const describedBy = [options.describedBy, error ? form.errorId(name) : undefined]\n .filter(Boolean)\n .join(' ')\n return {\n name,\n disabled: options.disabled ?? form.pending,\n 'aria-invalid': error ? (true as const) : undefined,\n 'aria-describedby': describedBy || undefined,\n }\n}\n"]}
|
|
1
|
+
{"version":3,"file":"form-client.js","sourceRoot":"","sources":["../../src/contact/form-client.ts"],"names":[],"mappings":"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","sourcesContent":["'use client'\n\nimport {\n DEFAULT_CONTACT_ATTACHMENT_POLICY,\n DOCUMENT_CONTACT_ATTACHMENT_POLICY,\n defineContactContract,\n type BrandlabContactContract,\n type ContactActionResult,\n} from '@brandlab-digital/site-core/contact'\nimport { normalizeBrandlabRoutePath } from '@brandlab-digital/site-core/routes'\nimport {\n useCallback,\n useEffect,\n useMemo,\n useRef,\n useState,\n useSyncExternalStore,\n type FormEvent,\n type RefObject,\n} from 'react'\nimport type { BrandlabAnalytics, LeadFormErrorReason, LeadFormErrorStage } from '../analytics.js'\nimport { leadBindingFor, safeSlug } from '../lead-registry.js'\nimport { contactFormDataHasPhotos, reportAcceptedLead } from '../lead-reporting.js'\nimport {\n useBrandlabContactAttachments,\n type UseBrandlabContactAttachmentsOptions,\n type UseBrandlabContactAttachmentsResult,\n} from './attachments-client.js'\nimport {\n createContactHttpSubmissionController,\n isContactClientFailure,\n type ContactSubmissionPhase,\n type ContactSubmissionState,\n} from './client.js'\nimport { BRANDLAB_CONTACT_TEST_MARKER, CONTACT_FORM_FIELDS } from './shared.js'\n\n/** An accepted enquiry, or a silently accepted spam-protected submission (`accepted: false`). */\nexport type BrandlabContactAcceptedResult = Extract<ContactActionResult, { ok: true }>\nexport type BrandlabContactFailedResult = Extract<ContactActionResult, { ok: false }>\n\nexport interface BrandlabContactSubmitStart {\n form: HTMLFormElement\n /**\n * The fields about to be sent, read from the form. Add or replace entries\n * here (for example `service_interest`); prepared photos are appended after.\n */\n formData: FormData\n}\n\nexport interface UseBrandlabContactFormOptions {\n /** The contact contract from src/config/contact.ts. */\n contract: BrandlabContactContract\n /** The site's defineBrandlabAnalytics binding: the declared lead after each accepted enquiry, plus lead diagnostics. */\n analytics?: BrandlabAnalytics\n /** The current pathname, for example `usePathname()`. */\n pagePath: string\n /** Reviewed label for the enquiry email's page row. */\n pageLabel?: string\n /**\n * The form element's id and the lead diagnostics' form_id. Without it the form\n * gets no id (the hook never takes a React id, so the form's own ids are\n * unchanged), diagnostics use `contact-form` and error ids start `contact-form-`.\n * The element keeps the id exactly as given; diagnostics report it as a slug\n * (React `useId()` parts removed, see `contactFormSlug`).\n */\n formId?: string\n /** Page to open after an accepted enquiry; `null` or omitted keeps the visitor on the page. */\n redirectTo?: string | null\n /** Client navigation such as `router.push`; defaults to `window.location.assign`. */\n navigate?: (href: string) => void\n /** Delay before the redirect, in milliseconds (default 1500); 0 navigates at once. */\n redirectDelayMs?: number\n /**\n * Runs when the visitor submits, before anything is sent: clear the form's\n * own notices, adjust `formData`, or return `false` to cancel (for example\n * after `form.reportValidity()`). An unchanged resubmission runs it too;\n * `retry()` does not.\n */\n onSubmitStart?: (submission: BrandlabContactSubmitStart) => void | false\n /**\n * Runs after every attempt, before onAccepted or onFailed: an accepted\n * enquiry, a silently accepted submission (even when ignored) or a failure.\n */\n onSettled?: (result: ContactActionResult) => void\n /** Runs after an accepted enquiry, once the form has been reset and before any redirect. */\n onAccepted?: (result: BrandlabContactAcceptedResult) => void\n /** Runs after a failed attempt; typed values and prepared photos are kept. */\n onFailed?: (result: BrandlabContactFailedResult) => void\n /**\n * A silently accepted submission (spam protection): `complete` (default)\n * resets, runs onAccepted and redirects like an accepted enquiry; `ignore`\n * leaves the form as it is and runs only onSettled.\n */\n silentAcceptance?: 'complete' | 'ignore'\n /** Photo preparation options; the policy follows the contract's attachment mode by default. */\n attachments?: UseBrandlabContactAttachmentsOptions\n /** Multipart field prefix for prepared files (default `attachments`, sent as `attachments[0]` …). */\n attachmentFieldPrefix?: string\n /** Live Consent Mode decision for ad_user_data; without it Enhanced Conversions stay off, unless the analytics contract's mode is `disclosed`. */\n adUserDataConsent?: boolean\n contactEndpoint?: string\n fetcher?: typeof fetch\n}\n\n/**\n * Spread onto the `<form>`: `method=\"post\"` with no `action` or `encType`, so a\n * submission before hydration posts back to the page and never puts fields in\n * a URL; the hook submits through fetch. `id` only when `formId` is given.\n */\nexport interface BrandlabContactFormProps {\n id?: string\n ref: RefObject<HTMLFormElement | null>\n method: 'post'\n 'aria-busy': boolean\n onSubmit: (event: FormEvent<HTMLFormElement>) => void\n onFocusCapture: () => void\n}\n\n/** Spread onto the submit button: disabled until hydration, while sending or preparing photos. */\nexport interface BrandlabContactSubmitProps {\n type: 'submit'\n disabled: boolean\n}\n\nexport interface UseBrandlabContactFormResult {\n /** The `formId` option, or `contact-form` when none was given. */\n formId: string\n formProps: BrandlabContactFormProps\n submitProps: BrandlabContactSubmitProps\n /** False during server rendering and hydration. */\n hydrated: boolean\n /** The submit button's `disabled` (also in submitProps): until hydration, while sending or preparing photos. */\n submitDisabled: boolean\n pending: boolean\n phase: ContactSubmissionPhase\n result: ContactActionResult | undefined\n /** An accepted enquiry (a silently accepted spam-protected submission looks the same). */\n succeeded: boolean\n /** A retryable failure: retry(), or submitting the same fields again, reuses its submission ID. */\n canRetry: boolean\n fieldErrors: Readonly<Record<string, readonly string[]>>\n fieldError(name: string): string | undefined\n /** Id for a field's error message element: `${formId}-${name}-error`. */\n errorId(name: string): string\n /** Attach to the status element; it receives focus when an error has no invalid field. */\n statusRef: RefObject<HTMLElement | null>\n /** Resends the failed enquiry with the same submission ID. */\n retry(): Promise<ContactActionResult | undefined>\n attachments: UseBrandlabContactAttachmentsResult\n /** Contract field keys accepted by contactFieldProps. */\n fieldNames: ReadonlySet<string>\n}\n\nconst INITIAL_STATE: ContactSubmissionState = {\n phase: 'idle',\n pending: false,\n canRetry: false,\n}\nconst DEFAULT_REDIRECT_DELAY_MS = 1_500\nconst FOCUSABLE_INVALID_FIELD = [\n 'input[aria-invalid=\"true\"]:not([disabled]):not([type=\"hidden\"])',\n 'select[aria-invalid=\"true\"]:not([disabled])',\n 'textarea[aria-invalid=\"true\"]:not([disabled])',\n 'button[aria-invalid=\"true\"]:not([disabled])',\n '[aria-invalid=\"true\"][tabindex]:not([aria-disabled=\"true\"])',\n].join(',')\n\nconst subscribeToNothing = () => () => undefined\nconst getClientSnapshot = () => true\nconst getServerSnapshot = () => false\n\n/** React `useId()` values: `_R_1a_`/`_r_1a_` (19.2), `«r1a»` (19.0 and 19.1), `:r1a:` (18). */\nconst REACT_ID = /_[rR]_[0-9A-Za-z]*_|«[rR][0-9A-Za-z]*»|:[rR][0-9A-Za-z]*:/g\n\n/**\n * The lead diagnostics' form_id for any formId. A valid slug is kept (trimmed and\n * lowercased). Otherwise React `useId()` parts are removed, so an id made unique for\n * the page still reports as one stable form, every run of other characters becomes\n * one hyphen, and the result is at most 80 characters; `contact-form` when nothing\n * is left. Without this, a formId such as `contact-form-_R_5e35f9db_` silently\n * dropped lead_form_start and lead_form_error.\n */\nexport function contactFormSlug(formId: string): string {\n const slug = safeSlug(String(formId))\n if (slug) return slug\n const derived = String(formId)\n .replace(REACT_ID, '-')\n .toLowerCase()\n .replace(/[^a-z0-9]+/g, '-')\n .replace(/^-+/, '')\n .slice(0, 80)\n .replace(/-+$/, '')\n return derived || 'contact-form'\n}\n\n/**\n * A Brandlab test visit: a page opened with `?bl_test=1` (a launch proof or canary), remembered for the tab so the\n * marker survives moving to the quote form; `?bl_test=0` ends it. Its enquiries carry the `bl_test` marker.\n */\nfunction brandlabTestVisit() {\n if (typeof window === 'undefined') return false\n try {\n const flag = new URLSearchParams(window.location.search).get(CONTACT_FORM_FIELDS.testMarker)\n if (flag === BRANDLAB_CONTACT_TEST_MARKER) window.sessionStorage.setItem(CONTACT_FORM_FIELDS.testMarker, flag)\n if (flag === '0') window.sessionStorage.removeItem(CONTACT_FORM_FIELDS.testMarker)\n return window.sessionStorage.getItem(CONTACT_FORM_FIELDS.testMarker) === BRANDLAB_CONTACT_TEST_MARKER ||\n flag === BRANDLAB_CONTACT_TEST_MARKER\n } catch {\n return false\n }\n}\n\nfunction isTestSubmission(formData: FormData) {\n return formData.get(CONTACT_FORM_FIELDS.testMarker) === BRANDLAB_CONTACT_TEST_MARKER\n}\n\nfunction safePagePath(pagePath: string) {\n try {\n return normalizeBrandlabRoutePath(pagePath.split(/[?#]/, 1)[0] || '/')\n } catch {\n return '/'\n }\n}\n\nfunction errorStage(result: BrandlabContactFailedResult): LeadFormErrorStage {\n return result.code === 'validation' ? 'validation' : 'delivery'\n}\n\n/**\n * Separates a field the visitor can correct from a request the server refused,\n * rejected photos, a server delivery failure and a browser-side failure, which\n * error_stage alone reports as only `validation` or `delivery`.\n */\nfunction errorReason(result: BrandlabContactFailedResult): LeadFormErrorReason {\n if (result.code === 'validation') {\n return Object.keys(result.fieldErrors ?? {}).length > 0 ? 'field' : 'request'\n }\n if (result.code === 'attachment_rejected') return 'attachment'\n return isContactClientFailure(result) ? 'network' : 'delivery'\n}\n\n/** The first rejected field in contract order: a contract key, never a value. */\nfunction firstErrorField(result: BrandlabContactFailedResult, fieldOrder: readonly string[]) {\n const fieldErrors = result.fieldErrors ?? {}\n return fieldOrder.find((key) => fieldErrors[key]?.length) ?? Object.keys(fieldErrors)[0]\n}\n\nfunction assertLifecycleOptions(options: UseBrandlabContactFormOptions) {\n const delay = options.redirectDelayMs\n if (delay !== undefined && (!Number.isInteger(delay) || delay < 0 || delay > 60_000)) {\n throw new TypeError('redirectDelayMs must be a whole number of milliseconds from 0 to 60000.')\n }\n if (options.silentAcceptance !== undefined && !['complete', 'ignore'].includes(options.silentAcceptance)) {\n throw new TypeError('silentAcceptance must be \"complete\" or \"ignore\".')\n }\n if (options.analytics !== undefined && !leadBindingFor(options.analytics)) {\n throw new TypeError('analytics must be the binding returned by defineBrandlabAnalytics.')\n }\n}\n\n/**\n * Contact form behaviour for project-owned markup and copy. The form posts\n * through fetch with FormData; before hydration the submit button stays\n * disabled. Typed values (uncontrolled fields) and prepared photos are kept\n * after any failure, retry() and an unchanged resubmission reuse the same\n * submission ID (so a resend cannot duplicate the email), the first invalid\n * field is focused, and an accepted enquiry resets the form and redirects.\n * The lead is reported exactly as the site's defineBrandlabAnalytics binding\n * declares, once per accepted enquiry, with contact_form's `has_photos`. On a\n * Brandlab test visit (`?bl_test=1`) each enquiry carries the `bl_test` marker\n * and is never reported (no lead diagnostics either). Browser validation is left exactly as the form's own\n * markup declares it.\n */\nexport function useBrandlabContactForm(options: UseBrandlabContactFormOptions): UseBrandlabContactFormResult {\n const contract = useMemo(() => defineContactContract(options.contract), [options.contract])\n assertLifecycleOptions(options)\n const hydrated = useSyncExternalStore(subscribeToNothing, getClientSnapshot, getServerSnapshot)\n const formId = options.formId ?? 'contact-form'\n const diagnosticsFormId = contactFormSlug(formId)\n const rawPagePath = options.pagePath\n const pagePath = safePagePath(rawPagePath)\n const formRef = useRef<HTMLFormElement>(null)\n const statusRef = useRef<HTMLElement>(null)\n const [state, setState] = useState<ContactSubmissionState>(INITIAL_STATE)\n const lockRef = useRef(false)\n const startedRef = useRef(false)\n const redirectTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)\n const latest = useRef(options)\n latest.current = options\n\n const policy = options.attachments?.policy ??\n (contract.attachments.mode === 'images-and-pdfs-optional'\n ? DOCUMENT_CONTACT_ATTACHMENT_POLICY\n : DEFAULT_CONTACT_ATTACHMENT_POLICY)\n const attachments = useBrandlabContactAttachments({ ...options.attachments, policy })\n const attachmentPrefix = options.attachmentFieldPrefix ?? 'attachments'\n\n const fieldOrder = useMemo(() => contract.fields.map((field) => field.key), [contract])\n const fieldNames = useMemo(() => new Set(fieldOrder), [fieldOrder])\n const roles = useMemo(\n () => ({ fields: contract.fields.map((field) => ({ name: field.key, role: field.role })) }),\n [contract],\n )\n const { analytics, pageLabel, contactEndpoint, fetcher, adUserDataConsent } = options\n const controller = useMemo(() => {\n const lead = leadBindingFor(analytics)\n return createContactHttpSubmissionController({\n pagePath,\n ...(pageLabel ? { pageLabel } : {}),\n ...(contactEndpoint ? { contactEndpoint } : {}),\n ...(fetcher ? { fetcher } : {}),\n ...(lead\n ? {\n reportAccepted: (result, formData) => {\n // The server answers a test enquiry without a confirmation; this only guards that rule.\n if (isTestSubmission(formData)) return\n reportAcceptedLead(lead, result, {\n pagePath: rawPagePath,\n formData,\n fieldRoles: roles,\n hasPhotos: contactFormDataHasPhotos(formData),\n ...(adUserDataConsent !== undefined ? { adUserDataConsent } : {}),\n })\n },\n }\n : {}),\n onStateChange: setState,\n })\n }, [adUserDataConsent, analytics, contactEndpoint, fetcher, pageLabel, pagePath, rawPagePath, roles])\n\n useEffect(() => () => clearTimeout(redirectTimerRef.current), [])\n\n useEffect(() => {\n if (state.phase !== 'error') return\n const invalid = formRef.current?.querySelector<HTMLElement>(FOCUSABLE_INVALID_FIELD)\n ;(invalid ?? statusRef.current)?.focus()\n }, [state])\n\n const trackStart = useCallback(() => {\n if (startedRef.current) return\n startedRef.current = true\n // A Brandlab test visit is never a starter: no diagnostics.\n if (brandlabTestVisit()) return\n latest.current.analytics?.trackLeadFormStart({ pagePath: rawPagePath, formId: diagnosticsFormId })\n }, [diagnosticsFormId, rawPagePath])\n\n const handleResult = useCallback(\n (result: ContactActionResult) => {\n const current = latest.current\n current.onSettled?.(result)\n if (!result.ok) {\n const reason = errorReason(result)\n const field = reason === 'field' ? firstErrorField(result, fieldOrder) : undefined\n if (!brandlabTestVisit()) current.analytics?.trackLeadFormError({\n pagePath: rawPagePath,\n formId: diagnosticsFormId,\n stage: errorStage(result),\n errorCount: result.code === 'validation'\n ? Math.min(50, Math.max(1, Object.keys(result.fieldErrors ?? {}).length))\n : 1,\n reason,\n ...(field ? { field } : {}),\n })\n current.onFailed?.(result)\n return\n }\n if (!result.accepted && current.silentAcceptance === 'ignore') return\n formRef.current?.reset()\n attachments.clearImages()\n current.onAccepted?.(result)\n const redirectTo = current.redirectTo\n if (!redirectTo) return\n const navigate = current.navigate ?? ((href: string) => window.location.assign(href))\n const delay = current.redirectDelayMs ?? DEFAULT_REDIRECT_DELAY_MS\n clearTimeout(redirectTimerRef.current)\n if (delay === 0) {\n navigate(redirectTo)\n return\n }\n redirectTimerRef.current = setTimeout(() => navigate(redirectTo), delay)\n },\n [attachments, diagnosticsFormId, fieldOrder, rawPagePath],\n )\n\n const send = useCallback(\n async (attempt: () => Promise<ContactActionResult>) => {\n lockRef.current = true\n try {\n const result = await attempt()\n handleResult(result)\n return result\n } finally {\n lockRef.current = false\n }\n },\n [handleResult],\n )\n\n const onSubmit = useCallback(\n (event: FormEvent<HTMLFormElement>) => {\n event.preventDefault()\n if (lockRef.current || attachments.isProcessing) return\n const form = event.currentTarget\n const formData = new FormData(form)\n if (latest.current.onSubmitStart?.({ form, formData }) === false) return\n if (brandlabTestVisit()) formData.set(CONTACT_FORM_FIELDS.testMarker, BRANDLAB_CONTACT_TEST_MARKER)\n trackStart()\n attachments.clearErrorMessage()\n attachments.images.forEach((image, index) => {\n formData.append(`${attachmentPrefix}[${index}]`, image.file)\n })\n // The controller reports every failure as a result; nothing is left to handle here.\n send(() => controller.submit(formData)).catch(() => undefined)\n },\n [attachmentPrefix, attachments, controller, send, trackStart],\n )\n\n const retry = useCallback(async () => {\n if (lockRef.current || !controller.getState().canRetry) return undefined\n return send(() => controller.retry())\n }, [controller, send])\n\n const fieldErrors = state.result && !state.result.ok && state.result.code === 'validation'\n ? state.result.fieldErrors ?? {}\n : {}\n\n const submitDisabled = !hydrated || state.pending || attachments.isProcessing\n return {\n formId,\n formProps: {\n ...(options.formId ? { id: options.formId } : {}),\n ref: formRef,\n method: 'post',\n 'aria-busy': state.pending || attachments.isProcessing,\n onSubmit,\n onFocusCapture: trackStart,\n },\n submitProps: { type: 'submit', disabled: submitDisabled },\n hydrated,\n submitDisabled,\n pending: state.pending,\n phase: state.phase,\n result: state.result,\n succeeded: state.result?.ok === true,\n canRetry: state.canRetry,\n fieldErrors,\n fieldError: (name: string) => fieldErrors[name]?.[0],\n errorId: (name: string) => `${formId}-${name}-error`,\n statusRef,\n retry,\n attachments,\n fieldNames,\n }\n}\n\nexport interface ContactFieldPropsOptions {\n /** Ids of help text already describing the field; the error id is appended. */\n describedBy?: string\n /** Defaults to disabled while an enquiry is sending. */\n disabled?: boolean\n}\n\n/**\n * Props for one uncontrolled contract field: name, disabled while sending and\n * the error relationship. It never adds value, required or length attributes,\n * so the form's existing validation is unchanged.\n */\nexport function contactFieldProps(\n form: UseBrandlabContactFormResult,\n name: string,\n options: ContactFieldPropsOptions = {},\n) {\n if (!form.fieldNames.has(name)) {\n throw new Error(`\"${name}\" is not a field in the contact contract.`)\n }\n const error = form.fieldError(name)\n const describedBy = [options.describedBy, error ? form.errorId(name) : undefined]\n .filter(Boolean)\n .join(' ')\n return {\n name,\n disabled: options.disabled ?? form.pending,\n 'aria-invalid': error ? (true as const) : undefined,\n 'aria-describedby': describedBy || undefined,\n }\n}\n"]}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The contact route's abuse limits: distinct enquiries per client IP and per email address in a sliding window, held
|
|
3
|
+
* in memory per server instance (the first layer; the Vercel firewall rule in the package README is the second). A
|
|
4
|
+
* resend of the same enquiry (the same submission ID: a retry or a lost response) never counts again, so a failed
|
|
5
|
+
* delivery can't lock a customer out. Keys are SHA-256 digests; no address or IP is held as text.
|
|
6
|
+
*/
|
|
7
|
+
export interface BrandlabContactRateLimitRule {
|
|
8
|
+
/** Distinct enquiries allowed in the window. */
|
|
9
|
+
readonly limit: number;
|
|
10
|
+
/** The window in milliseconds. */
|
|
11
|
+
readonly windowMs: number;
|
|
12
|
+
}
|
|
13
|
+
export interface BrandlabContactRateLimitOptions {
|
|
14
|
+
/** Per client IP (`x-real-ip`, else the first `x-forwarded-for` entry, as Vercel sets them). `false` turns it off. */
|
|
15
|
+
readonly perIp?: BrandlabContactRateLimitRule | false;
|
|
16
|
+
/** Per email address, as normalised by the contract. `false` turns it off. */
|
|
17
|
+
readonly perAddress?: BrandlabContactRateLimitRule | false;
|
|
18
|
+
/** The most keys each rule holds before the oldest are dropped (default 5,000). */
|
|
19
|
+
readonly maxKeys?: number;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* The defaults: 10 enquiries per IP in 10 minutes (shared mobile-network addresses stay well clear of it) and 5 per
|
|
23
|
+
* email address in an hour.
|
|
24
|
+
*/
|
|
25
|
+
export declare const BRANDLAB_CONTACT_RATE_LIMITS: Readonly<{
|
|
26
|
+
perIp: Readonly<{
|
|
27
|
+
limit: 10;
|
|
28
|
+
windowMs: number;
|
|
29
|
+
}>;
|
|
30
|
+
perAddress: Readonly<{
|
|
31
|
+
limit: 5;
|
|
32
|
+
windowMs: number;
|
|
33
|
+
}>;
|
|
34
|
+
maxKeys: 5000;
|
|
35
|
+
}>;
|
|
36
|
+
export interface BrandlabContactRateLimitInput {
|
|
37
|
+
/** A digest of the client IP, when the request names one. */
|
|
38
|
+
ipKey?: string;
|
|
39
|
+
/** A digest of the normalised email address. */
|
|
40
|
+
addressKey?: string;
|
|
41
|
+
submissionId: string;
|
|
42
|
+
now: number;
|
|
43
|
+
}
|
|
44
|
+
export type BrandlabContactRateLimitDecision = {
|
|
45
|
+
ok: true;
|
|
46
|
+
} | {
|
|
47
|
+
ok: false;
|
|
48
|
+
limit: 'ip' | 'address';
|
|
49
|
+
retryAfterSeconds: number;
|
|
50
|
+
};
|
|
51
|
+
export interface BrandlabContactRateLimiter {
|
|
52
|
+
readonly capability: 'brandlab-contact-rate-limit-v1';
|
|
53
|
+
/** Allows and records the enquiry, or refuses it without recording anything. */
|
|
54
|
+
check(input: BrandlabContactRateLimitInput): BrandlabContactRateLimitDecision;
|
|
55
|
+
}
|
|
56
|
+
/** One limiter per route module: create it once, so its windows outlive each request. */
|
|
57
|
+
export declare function createBrandlabContactRateLimiter(options?: BrandlabContactRateLimitOptions): BrandlabContactRateLimiter;
|
|
58
|
+
export declare function isBrandlabContactRateLimiter(value: unknown): value is BrandlabContactRateLimiter;
|
|
59
|
+
//# sourceMappingURL=rate-limit.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"rate-limit.d.ts","sourceRoot":"","sources":["../../src/contact/rate-limit.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,MAAM,WAAW,4BAA4B;IAC3C,gDAAgD;IAChD,QAAQ,CAAC,KAAK,EAAE,MAAM,CAAA;IACtB,kCAAkC;IAClC,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAA;CAC1B;AAED,MAAM,WAAW,+BAA+B;IAC9C,sHAAsH;IACtH,QAAQ,CAAC,KAAK,CAAC,EAAE,4BAA4B,GAAG,KAAK,CAAA;IACrD,8EAA8E;IAC9E,QAAQ,CAAC,UAAU,CAAC,EAAE,4BAA4B,GAAG,KAAK,CAAA;IAC1D,mFAAmF;IACnF,QAAQ,CAAC,OAAO,CAAC,EAAE,MAAM,CAAA;CAC1B;AAED;;;GAGG;AACH,eAAO,MAAM,4BAA4B;;;;;;;;;;EAIvC,CAAA;AAEF,MAAM,WAAW,6BAA6B;IAC5C,6DAA6D;IAC7D,KAAK,CAAC,EAAE,MAAM,CAAA;IACd,gDAAgD;IAChD,UAAU,CAAC,EAAE,MAAM,CAAA;IACnB,YAAY,EAAE,MAAM,CAAA;IACpB,GAAG,EAAE,MAAM,CAAA;CACZ;AAED,MAAM,MAAM,gCAAgC,GACxC;IAAE,EAAE,EAAE,IAAI,CAAA;CAAE,GACZ;IAAE,EAAE,EAAE,KAAK,CAAC;IAAC,KAAK,EAAE,IAAI,GAAG,SAAS,CAAC;IAAC,iBAAiB,EAAE,MAAM,CAAA;CAAE,CAAA;AAErE,MAAM,WAAW,0BAA0B;IACzC,QAAQ,CAAC,UAAU,EAAE,gCAAgC,CAAA;IACrD,gFAAgF;IAChF,KAAK,CAAC,KAAK,EAAE,6BAA6B,GAAG,gCAAgC,CAAA;CAC9E;AAmDD,yFAAyF;AACzF,wBAAgB,gCAAgC,CAC9C,OAAO,GAAE,+BAAoC,GAC5C,0BAA0B,CA4B5B;AAED,wBAAgB,4BAA4B,CAAC,KAAK,EAAE,OAAO,GAAG,KAAK,IAAI,0BAA0B,CAOhG"}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The contact route's abuse limits: distinct enquiries per client IP and per email address in a sliding window, held
|
|
3
|
+
* in memory per server instance (the first layer; the Vercel firewall rule in the package README is the second). A
|
|
4
|
+
* resend of the same enquiry (the same submission ID: a retry or a lost response) never counts again, so a failed
|
|
5
|
+
* delivery can't lock a customer out. Keys are SHA-256 digests; no address or IP is held as text.
|
|
6
|
+
*/
|
|
7
|
+
/**
|
|
8
|
+
* The defaults: 10 enquiries per IP in 10 minutes (shared mobile-network addresses stay well clear of it) and 5 per
|
|
9
|
+
* email address in an hour.
|
|
10
|
+
*/
|
|
11
|
+
export const BRANDLAB_CONTACT_RATE_LIMITS = Object.freeze({
|
|
12
|
+
perIp: Object.freeze({ limit: 10, windowMs: 10 * 60 * 1_000 }),
|
|
13
|
+
perAddress: Object.freeze({ limit: 5, windowMs: 60 * 60 * 1_000 }),
|
|
14
|
+
maxKeys: 5_000,
|
|
15
|
+
});
|
|
16
|
+
function assertRule(rule, field) {
|
|
17
|
+
if (rule === false)
|
|
18
|
+
return undefined;
|
|
19
|
+
const candidate = rule;
|
|
20
|
+
if (!candidate ||
|
|
21
|
+
typeof candidate !== 'object' ||
|
|
22
|
+
!Number.isSafeInteger(candidate.limit) ||
|
|
23
|
+
candidate.limit < 1 ||
|
|
24
|
+
candidate.limit > 1_000 ||
|
|
25
|
+
!Number.isSafeInteger(candidate.windowMs) ||
|
|
26
|
+
candidate.windowMs < 1_000 ||
|
|
27
|
+
candidate.windowMs > 24 * 60 * 60 * 1_000) {
|
|
28
|
+
throw new TypeError(`${field} must be { limit: 1 to 1000, windowMs: 1000 to 86400000 } or false.`);
|
|
29
|
+
}
|
|
30
|
+
return { limit: candidate.limit, windowMs: candidate.windowMs };
|
|
31
|
+
}
|
|
32
|
+
function createWindow(rule, maxKeys) {
|
|
33
|
+
// key -> (submission ID -> first accepted time), in insertion order so the oldest key is dropped first.
|
|
34
|
+
const keys = new Map();
|
|
35
|
+
const live = (key, now) => {
|
|
36
|
+
const ids = keys.get(key);
|
|
37
|
+
if (!ids)
|
|
38
|
+
return undefined;
|
|
39
|
+
for (const [id, at] of ids)
|
|
40
|
+
if (now - at >= rule.windowMs)
|
|
41
|
+
ids.delete(id);
|
|
42
|
+
if (ids.size === 0)
|
|
43
|
+
keys.delete(key);
|
|
44
|
+
return ids.size === 0 ? undefined : ids;
|
|
45
|
+
};
|
|
46
|
+
return {
|
|
47
|
+
decide(key, submissionId, now) {
|
|
48
|
+
const ids = live(key, now);
|
|
49
|
+
if (!ids || ids.has(submissionId) || ids.size < rule.limit)
|
|
50
|
+
return { ok: true };
|
|
51
|
+
const oldest = Math.min(...ids.values());
|
|
52
|
+
return { ok: false, retryAfterSeconds: Math.max(1, Math.ceil((oldest + rule.windowMs - now) / 1_000)) };
|
|
53
|
+
},
|
|
54
|
+
record(key, submissionId, now) {
|
|
55
|
+
const ids = live(key, now) ?? new Map();
|
|
56
|
+
if (!ids.has(submissionId))
|
|
57
|
+
ids.set(submissionId, now);
|
|
58
|
+
keys.delete(key);
|
|
59
|
+
keys.set(key, ids);
|
|
60
|
+
while (keys.size > maxKeys) {
|
|
61
|
+
const oldest = keys.keys().next().value;
|
|
62
|
+
if (oldest === undefined)
|
|
63
|
+
break;
|
|
64
|
+
keys.delete(oldest);
|
|
65
|
+
}
|
|
66
|
+
},
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
/** One limiter per route module: create it once, so its windows outlive each request. */
|
|
70
|
+
export function createBrandlabContactRateLimiter(options = {}) {
|
|
71
|
+
if (!options || typeof options !== 'object')
|
|
72
|
+
throw new TypeError('Contact rate limit options must be an object.');
|
|
73
|
+
const maxKeys = options.maxKeys ?? BRANDLAB_CONTACT_RATE_LIMITS.maxKeys;
|
|
74
|
+
if (!Number.isSafeInteger(maxKeys) || maxKeys < 1 || maxKeys > 100_000) {
|
|
75
|
+
throw new TypeError('maxKeys must be a whole number from 1 to 100000.');
|
|
76
|
+
}
|
|
77
|
+
const perIp = assertRule(options.perIp ?? BRANDLAB_CONTACT_RATE_LIMITS.perIp, 'perIp');
|
|
78
|
+
const perAddress = assertRule(options.perAddress ?? BRANDLAB_CONTACT_RATE_LIMITS.perAddress, 'perAddress');
|
|
79
|
+
const ip = perIp ? createWindow(perIp, maxKeys) : undefined;
|
|
80
|
+
const address = perAddress ? createWindow(perAddress, maxKeys) : undefined;
|
|
81
|
+
return Object.freeze({
|
|
82
|
+
capability: 'brandlab-contact-rate-limit-v1',
|
|
83
|
+
check(input) {
|
|
84
|
+
const { ipKey, addressKey, submissionId, now } = input;
|
|
85
|
+
if (ip && ipKey) {
|
|
86
|
+
const decision = ip.decide(ipKey, submissionId, now);
|
|
87
|
+
if (!decision.ok)
|
|
88
|
+
return { ok: false, limit: 'ip', retryAfterSeconds: decision.retryAfterSeconds };
|
|
89
|
+
}
|
|
90
|
+
if (address && addressKey) {
|
|
91
|
+
const decision = address.decide(addressKey, submissionId, now);
|
|
92
|
+
if (!decision.ok)
|
|
93
|
+
return { ok: false, limit: 'address', retryAfterSeconds: decision.retryAfterSeconds };
|
|
94
|
+
}
|
|
95
|
+
if (ip && ipKey)
|
|
96
|
+
ip.record(ipKey, submissionId, now);
|
|
97
|
+
if (address && addressKey)
|
|
98
|
+
address.record(addressKey, submissionId, now);
|
|
99
|
+
return { ok: true };
|
|
100
|
+
},
|
|
101
|
+
});
|
|
102
|
+
}
|
|
103
|
+
export function isBrandlabContactRateLimiter(value) {
|
|
104
|
+
return (Boolean(value) &&
|
|
105
|
+
typeof value === 'object' &&
|
|
106
|
+
value.capability === 'brandlab-contact-rate-limit-v1' &&
|
|
107
|
+
typeof value.check === 'function');
|
|
108
|
+
}
|
|
109
|
+
//# sourceMappingURL=rate-limit.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"rate-limit.js","sourceRoot":"","sources":["../../src/contact/rate-limit.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * The contact route's abuse limits: distinct enquiries per client IP and per email address in a sliding window, held\n * in memory per server instance (the first layer; the Vercel firewall rule in the package README is the second). A\n * resend of the same enquiry (the same submission ID: a retry or a lost response) never counts again, so a failed\n * delivery can't lock a customer out. Keys are SHA-256 digests; no address or IP is held as text.\n */\n\nexport interface BrandlabContactRateLimitRule {\n /** Distinct enquiries allowed in the window. */\n readonly limit: number\n /** The window in milliseconds. */\n readonly windowMs: number\n}\n\nexport interface BrandlabContactRateLimitOptions {\n /** Per client IP (`x-real-ip`, else the first `x-forwarded-for` entry, as Vercel sets them). `false` turns it off. */\n readonly perIp?: BrandlabContactRateLimitRule | false\n /** Per email address, as normalised by the contract. `false` turns it off. */\n readonly perAddress?: BrandlabContactRateLimitRule | false\n /** The most keys each rule holds before the oldest are dropped (default 5,000). */\n readonly maxKeys?: number\n}\n\n/**\n * The defaults: 10 enquiries per IP in 10 minutes (shared mobile-network addresses stay well clear of it) and 5 per\n * email address in an hour.\n */\nexport const BRANDLAB_CONTACT_RATE_LIMITS = Object.freeze({\n perIp: Object.freeze({ limit: 10, windowMs: 10 * 60 * 1_000 }),\n perAddress: Object.freeze({ limit: 5, windowMs: 60 * 60 * 1_000 }),\n maxKeys: 5_000,\n})\n\nexport interface BrandlabContactRateLimitInput {\n /** A digest of the client IP, when the request names one. */\n ipKey?: string\n /** A digest of the normalised email address. */\n addressKey?: string\n submissionId: string\n now: number\n}\n\nexport type BrandlabContactRateLimitDecision =\n | { ok: true }\n | { ok: false; limit: 'ip' | 'address'; retryAfterSeconds: number }\n\nexport interface BrandlabContactRateLimiter {\n readonly capability: 'brandlab-contact-rate-limit-v1'\n /** Allows and records the enquiry, or refuses it without recording anything. */\n check(input: BrandlabContactRateLimitInput): BrandlabContactRateLimitDecision\n}\n\nfunction assertRule(rule: unknown, field: string): BrandlabContactRateLimitRule | undefined {\n if (rule === false) return undefined\n const candidate = rule as BrandlabContactRateLimitRule\n if (\n !candidate ||\n typeof candidate !== 'object' ||\n !Number.isSafeInteger(candidate.limit) ||\n candidate.limit < 1 ||\n candidate.limit > 1_000 ||\n !Number.isSafeInteger(candidate.windowMs) ||\n candidate.windowMs < 1_000 ||\n candidate.windowMs > 24 * 60 * 60 * 1_000\n ) {\n throw new TypeError(`${field} must be { limit: 1 to 1000, windowMs: 1000 to 86400000 } or false.`)\n }\n return { limit: candidate.limit, windowMs: candidate.windowMs }\n}\n\nfunction createWindow(rule: BrandlabContactRateLimitRule, maxKeys: number) {\n // key -> (submission ID -> first accepted time), in insertion order so the oldest key is dropped first.\n const keys = new Map<string, Map<string, number>>()\n const live = (key: string, now: number) => {\n const ids = keys.get(key)\n if (!ids) return undefined\n for (const [id, at] of ids) if (now - at >= rule.windowMs) ids.delete(id)\n if (ids.size === 0) keys.delete(key)\n return ids.size === 0 ? undefined : ids\n }\n return {\n decide(key: string, submissionId: string, now: number) {\n const ids = live(key, now)\n if (!ids || ids.has(submissionId) || ids.size < rule.limit) return { ok: true as const }\n const oldest = Math.min(...ids.values())\n return { ok: false as const, retryAfterSeconds: Math.max(1, Math.ceil((oldest + rule.windowMs - now) / 1_000)) }\n },\n record(key: string, submissionId: string, now: number) {\n const ids = live(key, now) ?? new Map<string, number>()\n if (!ids.has(submissionId)) ids.set(submissionId, now)\n keys.delete(key)\n keys.set(key, ids)\n while (keys.size > maxKeys) {\n const oldest = keys.keys().next().value\n if (oldest === undefined) break\n keys.delete(oldest)\n }\n },\n }\n}\n\n/** One limiter per route module: create it once, so its windows outlive each request. */\nexport function createBrandlabContactRateLimiter(\n options: BrandlabContactRateLimitOptions = {},\n): BrandlabContactRateLimiter {\n if (!options || typeof options !== 'object') throw new TypeError('Contact rate limit options must be an object.')\n const maxKeys = options.maxKeys ?? BRANDLAB_CONTACT_RATE_LIMITS.maxKeys\n if (!Number.isSafeInteger(maxKeys) || maxKeys < 1 || maxKeys > 100_000) {\n throw new TypeError('maxKeys must be a whole number from 1 to 100000.')\n }\n const perIp = assertRule(options.perIp ?? BRANDLAB_CONTACT_RATE_LIMITS.perIp, 'perIp')\n const perAddress = assertRule(options.perAddress ?? BRANDLAB_CONTACT_RATE_LIMITS.perAddress, 'perAddress')\n const ip = perIp ? createWindow(perIp, maxKeys) : undefined\n const address = perAddress ? createWindow(perAddress, maxKeys) : undefined\n\n return Object.freeze({\n capability: 'brandlab-contact-rate-limit-v1' as const,\n check(input: BrandlabContactRateLimitInput): BrandlabContactRateLimitDecision {\n const { ipKey, addressKey, submissionId, now } = input\n if (ip && ipKey) {\n const decision = ip.decide(ipKey, submissionId, now)\n if (!decision.ok) return { ok: false, limit: 'ip', retryAfterSeconds: decision.retryAfterSeconds }\n }\n if (address && addressKey) {\n const decision = address.decide(addressKey, submissionId, now)\n if (!decision.ok) return { ok: false, limit: 'address', retryAfterSeconds: decision.retryAfterSeconds }\n }\n if (ip && ipKey) ip.record(ipKey, submissionId, now)\n if (address && addressKey) address.record(addressKey, submissionId, now)\n return { ok: true }\n },\n })\n}\n\nexport function isBrandlabContactRateLimiter(value: unknown): value is BrandlabContactRateLimiter {\n return (\n Boolean(value) &&\n typeof value === 'object' &&\n (value as BrandlabContactRateLimiter).capability === 'brandlab-contact-rate-limit-v1' &&\n typeof (value as BrandlabContactRateLimiter).check === 'function'\n )\n}\n"]}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import 'server-only';
|
|
2
2
|
import type { BrandlabContactContract } from '@brandlab-digital/site-core/contact';
|
|
3
3
|
import type { BrandlabEnvironment } from '@brandlab-digital/site-core/site';
|
|
4
|
+
import { type BrandlabContactRateLimitOptions } from './rate-limit.js';
|
|
4
5
|
import { type ResendContactSend } from './resend.js';
|
|
5
6
|
import { type BrandlabContactRuntimeDefinitionOptions } from './server.js';
|
|
6
7
|
import type { ContactBotGuard } from './shared.js';
|
|
@@ -14,6 +15,11 @@ export interface BrandlabContactRouteHandlerOptions {
|
|
|
14
15
|
now?: () => Date;
|
|
15
16
|
/** Replaces the Resend SDK in synthetic tests; production code omits it. */
|
|
16
17
|
createResendSend?: (apiKey: string) => ResendContactSend;
|
|
18
|
+
/**
|
|
19
|
+
* Abuse limits per client IP and per email address, held for the life of the route module (BRANDLAB_CONTACT_RATE_LIMITS
|
|
20
|
+
* by default; `false` turns them off). They apply only where enquiries are delivered (production and Preview).
|
|
21
|
+
*/
|
|
22
|
+
rateLimit?: BrandlabContactRateLimitOptions | false;
|
|
17
23
|
}
|
|
18
24
|
/**
|
|
19
25
|
* A Resend send function that loads the optional `resend` peer only when an
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"route-handler.d.ts","sourceRoot":"","sources":["../../src/contact/route-handler.ts"],"names":[],"mappings":"AAAA,OAAO,aAAa,CAAA;AAEpB,OAAO,KAAK,EAAE,uBAAuB,EAAE,MAAM,qCAAqC,CAAA;AAClF,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,kCAAkC,CAAA;AAC3E,OAAO,EAA8B,KAAK,iBAAiB,EAAE,MAAM,aAAa,CAAA;AAChF,OAAO,EAGL,KAAK,uCAAuC,EAC7C,MAAM,aAAa,CAAA;AACpB,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,aAAa,CAAA;AAElD,MAAM,WAAW,kCAAkC;IACjD,QAAQ,EAAE,uBAAuB,CAAA;IACjC,mFAAmF;IACnF,OAAO,EAAE,uCAAuC,CAAA;IAChD,QAAQ,CAAC,EAAE,eAAe,CAAA;IAC1B,sDAAsD;IACtD,WAAW,CAAC,EAAE,mBAAmB,CAAA;IACjC,GAAG,CAAC,EAAE,MAAM,IAAI,CAAA;IAChB,4EAA4E;IAC5E,gBAAgB,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,iBAAiB,CAAA;
|
|
1
|
+
{"version":3,"file":"route-handler.d.ts","sourceRoot":"","sources":["../../src/contact/route-handler.ts"],"names":[],"mappings":"AAAA,OAAO,aAAa,CAAA;AAEpB,OAAO,KAAK,EAAE,uBAAuB,EAAE,MAAM,qCAAqC,CAAA;AAClF,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,kCAAkC,CAAA;AAC3E,OAAO,EAEL,KAAK,+BAA+B,EACrC,MAAM,iBAAiB,CAAA;AACxB,OAAO,EAA8B,KAAK,iBAAiB,EAAE,MAAM,aAAa,CAAA;AAChF,OAAO,EAGL,KAAK,uCAAuC,EAC7C,MAAM,aAAa,CAAA;AACpB,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,aAAa,CAAA;AAElD,MAAM,WAAW,kCAAkC;IACjD,QAAQ,EAAE,uBAAuB,CAAA;IACjC,mFAAmF;IACnF,OAAO,EAAE,uCAAuC,CAAA;IAChD,QAAQ,CAAC,EAAE,eAAe,CAAA;IAC1B,sDAAsD;IACtD,WAAW,CAAC,EAAE,mBAAmB,CAAA;IACjC,GAAG,CAAC,EAAE,MAAM,IAAI,CAAA;IAChB,4EAA4E;IAC5E,gBAAgB,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,iBAAiB,CAAA;IACxD;;;OAGG;IACH,SAAS,CAAC,EAAE,+BAA+B,GAAG,KAAK,CAAA;CACpD;AAED;;;;GAIG;AACH,wBAAgB,2BAA2B,CAAC,MAAM,EAAE,MAAM,GAAG,iBAAiB,CAQ7E;AAED;;;;;;GAMG;AACH,wBAAgB,iCAAiC,CAC/C,OAAO,EAAE,kCAAkC,GAC1C,CAAC,OAAO,EAAE,OAAO,KAAK,OAAO,CAAC,QAAQ,CAAC,CA2BzC"}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import 'server-only';
|
|
2
|
+
import { createBrandlabContactRateLimiter, } from './rate-limit.js';
|
|
2
3
|
import { createResendSdkContactSend } from './resend.js';
|
|
3
4
|
import { createBrandlabContactRuntime, detectBrandlabContactDeploymentMode, } from './server.js';
|
|
4
5
|
/**
|
|
@@ -28,6 +29,8 @@ export function createBrandlabContactRouteHandler(options) {
|
|
|
28
29
|
throw new TypeError('createResendSend must be a function.');
|
|
29
30
|
}
|
|
30
31
|
const createResendSend = options.createResendSend ?? createLazyResendContactSend;
|
|
32
|
+
// One limiter for the module, shared by every per-request runtime, so its windows outlive each request.
|
|
33
|
+
const rateLimit = options.rateLimit === false ? false : createBrandlabContactRateLimiter(options.rateLimit);
|
|
31
34
|
const runtimeFor = (environment) => createBrandlabContactRuntime({
|
|
32
35
|
contract: options.contract,
|
|
33
36
|
runtime: options.runtime,
|
|
@@ -36,6 +39,7 @@ export function createBrandlabContactRouteHandler(options) {
|
|
|
36
39
|
createResendSend,
|
|
37
40
|
...(options.botGuard ? { botGuard: options.botGuard } : {}),
|
|
38
41
|
...(options.now ? { now: options.now } : {}),
|
|
42
|
+
rateLimit,
|
|
39
43
|
});
|
|
40
44
|
// Fail at module load, not on a customer's first enquiry.
|
|
41
45
|
runtimeFor({});
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"route-handler.js","sourceRoot":"","sources":["../../src/contact/route-handler.ts"],"names":[],"mappings":"AAAA,OAAO,aAAa,CAAA;AAIpB,OAAO,EAAE,0BAA0B,EAA0B,MAAM,aAAa,CAAA;AAChF,OAAO,EACL,4BAA4B,EAC5B,mCAAmC,GAEpC,MAAM,aAAa,CAAA;
|
|
1
|
+
{"version":3,"file":"route-handler.js","sourceRoot":"","sources":["../../src/contact/route-handler.ts"],"names":[],"mappings":"AAAA,OAAO,aAAa,CAAA;AAIpB,OAAO,EACL,gCAAgC,GAEjC,MAAM,iBAAiB,CAAA;AACxB,OAAO,EAAE,0BAA0B,EAA0B,MAAM,aAAa,CAAA;AAChF,OAAO,EACL,4BAA4B,EAC5B,mCAAmC,GAEpC,MAAM,aAAa,CAAA;AAoBpB;;;;GAIG;AACH,MAAM,UAAU,2BAA2B,CAAC,MAAc;IACxD,IAAI,IAA4C,CAAA;IAChD,OAAO,KAAK,EAAE,OAAO,EAAE,OAAO,EAAE,EAAE;QAChC,IAAI,KAAK,MAAM,CAAC,QAAQ,CAAC,CAAC,IAAI,CAAC,CAAC,EAAE,MAAM,EAAE,EAAE,EAAE,CAC5C,0BAA0B,CAAC,IAAI,MAAM,CAAC,MAAM,CAAC,CAAC,CAC/C,CAAA;QACD,OAAO,CAAC,MAAM,IAAI,CAAC,CAAC,OAAO,EAAE,OAAO,CAAC,CAAA;IACvC,CAAC,CAAA;AACH,CAAC;AAED;;;;;;GAMG;AACH,MAAM,UAAU,iCAAiC,CAC/C,OAA2C;IAE3C,IAAI,CAAC,OAAO,IAAI,OAAO,OAAO,KAAK,QAAQ,EAAE,CAAC;QAC5C,MAAM,IAAI,SAAS,CAAC,sEAAsE,CAAC,CAAA;IAC7F,CAAC;IACD,IAAI,OAAO,CAAC,gBAAgB,KAAK,SAAS,IAAI,OAAO,OAAO,CAAC,gBAAgB,KAAK,UAAU,EAAE,CAAC;QAC7F,MAAM,IAAI,SAAS,CAAC,sCAAsC,CAAC,CAAA;IAC7D,CAAC;IACD,MAAM,gBAAgB,GAAG,OAAO,CAAC,gBAAgB,IAAI,2BAA2B,CAAA;IAChF,wGAAwG;IACxG,MAAM,SAAS,GAAG,OAAO,CAAC,SAAS,KAAK,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,gCAAgC,CAAC,OAAO,CAAC,SAAS,CAAC,CAAA;IAC3G,MAAM,UAAU,GAAG,CAAC,WAAgC,EAAE,EAAE,CACtD,4BAA4B,CAAC;QAC3B,QAAQ,EAAE,OAAO,CAAC,QAAQ;QAC1B,OAAO,EAAE,OAAO,CAAC,OAAO;QACxB,WAAW;QACX,cAAc,EAAE,mCAAmC,CAAC,WAAW,CAAC;QAChE,gBAAgB;QAChB,GAAG,CAAC,OAAO,CAAC,QAAQ,CAAC,CAAC,CAAC,EAAE,QAAQ,EAAE,OAAO,CAAC,QAAQ,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;QAC3D,GAAG,CAAC,OAAO,CAAC,GAAG,CAAC,CAAC,CAAC,EAAE,GAAG,EAAE,OAAO,CAAC,GAAG,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;QAC5C,SAAS;KACV,CAAC,CAAA;IAEJ,0DAA0D;IAC1D,UAAU,CAAC,EAAE,CAAC,CAAA;IAEd,OAAO,CAAC,OAAgB,EAAE,EAAE,CAC1B,UAAU,CAAC,OAAO,CAAC,WAAW,IAAI,OAAO,CAAC,GAAG,CAAC,CAAC,mBAAmB,CAAC,OAAO,CAAC,CAAA;AAC/E,CAAC","sourcesContent":["import 'server-only'\n\nimport type { BrandlabContactContract } from '@brandlab-digital/site-core/contact'\nimport type { BrandlabEnvironment } from '@brandlab-digital/site-core/site'\nimport {\n createBrandlabContactRateLimiter,\n type BrandlabContactRateLimitOptions,\n} from './rate-limit.js'\nimport { createResendSdkContactSend, type ResendContactSend } from './resend.js'\nimport {\n createBrandlabContactRuntime,\n detectBrandlabContactDeploymentMode,\n type BrandlabContactRuntimeDefinitionOptions,\n} from './server.js'\nimport type { ContactBotGuard } from './shared.js'\n\nexport interface BrandlabContactRouteHandlerOptions {\n contract: BrandlabContactContract\n /** Labels, brand name, canonical site URL, email style and attachment settings. */\n runtime: BrandlabContactRuntimeDefinitionOptions\n botGuard?: ContactBotGuard\n /** Defaults to process.env, read on every request. */\n environment?: BrandlabEnvironment\n now?: () => Date\n /** Replaces the Resend SDK in synthetic tests; production code omits it. */\n createResendSend?: (apiKey: string) => ResendContactSend\n /**\n * Abuse limits per client IP and per email address, held for the life of the route module (BRANDLAB_CONTACT_RATE_LIMITS\n * by default; `false` turns them off). They apply only where enquiries are delivered (production and Preview).\n */\n rateLimit?: BrandlabContactRateLimitOptions | false\n}\n\n/**\n * A Resend send function that loads the optional `resend` peer only when an\n * enquiry is actually delivered, so validation, bot and local requests never\n * load the provider SDK.\n */\nexport function createLazyResendContactSend(apiKey: string): ResendContactSend {\n let send: Promise<ResendContactSend> | undefined\n return async (payload, options) => {\n send ??= import('resend').then(({ Resend }) =>\n createResendSdkContactSend(new Resend(apiKey)),\n )\n return (await send)(payload, options)\n }\n}\n\n/**\n * The whole of `src/app/api/contact/route.ts`:\n * `export const POST = createBrandlabContactRouteHandler({ contract, runtime })`.\n * The contract and runtime are validated when the route module loads; each\n * request then resolves the deployment from its environment and delegates to\n * createBrandlabContactRuntime exactly as a per-request runtime would.\n */\nexport function createBrandlabContactRouteHandler(\n options: BrandlabContactRouteHandlerOptions,\n): (request: Request) => Promise<Response> {\n if (!options || typeof options !== 'object') {\n throw new TypeError('createBrandlabContactRouteHandler requires the contract and runtime.')\n }\n if (options.createResendSend !== undefined && typeof options.createResendSend !== 'function') {\n throw new TypeError('createResendSend must be a function.')\n }\n const createResendSend = options.createResendSend ?? createLazyResendContactSend\n // One limiter for the module, shared by every per-request runtime, so its windows outlive each request.\n const rateLimit = options.rateLimit === false ? false : createBrandlabContactRateLimiter(options.rateLimit)\n const runtimeFor = (environment: BrandlabEnvironment) =>\n createBrandlabContactRuntime({\n contract: options.contract,\n runtime: options.runtime,\n environment,\n deploymentMode: detectBrandlabContactDeploymentMode(environment),\n createResendSend,\n ...(options.botGuard ? { botGuard: options.botGuard } : {}),\n ...(options.now ? { now: options.now } : {}),\n rateLimit,\n })\n\n // Fail at module load, not on a customer's first enquiry.\n runtimeFor({})\n\n return (request: Request) =>\n runtimeFor(options.environment ?? process.env).contactRouteHandler(request)\n}\n"]}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
import 'server-only';
|
|
2
|
-
export { BRANDLAB_CONTACT_API_KEY_ENV, BRANDLAB_CONTACT_MAX_REQUEST_BYTES, BRANDLAB_CONTACT_RUNTIME, createBrandlabContactRuntime, createResendSdkContactSend, detectBrandlabContactDeploymentMode, type BrandlabContactRuntime, type BrandlabContactRuntimeDefinitionOptions, type BrandlabContactRuntimeOptions, type ResendContactSend, type ResendSdkContactClient, } from './server.js';
|
|
2
|
+
export { BRANDLAB_CONTACT_API_KEY_ENV, BRANDLAB_CONTACT_MAX_REQUEST_BYTES, BRANDLAB_CONTACT_RATE_LIMITS, BRANDLAB_CONTACT_RUNTIME, createBrandlabContactRateLimiter, createBrandlabContactRuntime, createResendSdkContactSend, detectBrandlabContactDeploymentMode, type BrandlabContactRateLimitOptions, type BrandlabContactRateLimitRule, type BrandlabContactRateLimiter, type BrandlabContactRuntime, type BrandlabContactRuntimeDefinitionOptions, type BrandlabContactRuntimeOptions, type ResendContactSend, type ResendSdkContactClient, } from './server.js';
|
|
3
3
|
export { createBrandlabContactRouteHandler, createLazyResendContactSend, type BrandlabContactRouteHandlerOptions, } from './route-handler.js';
|
|
4
4
|
//# sourceMappingURL=server-entry.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"server-entry.d.ts","sourceRoot":"","sources":["../../src/contact/server-entry.ts"],"names":[],"mappings":"AAAA,OAAO,aAAa,CAAA;AAEpB,OAAO,EACL,4BAA4B,EAC5B,kCAAkC,EAClC,wBAAwB,EACxB,4BAA4B,EAC5B,0BAA0B,EAC1B,mCAAmC,EACnC,KAAK,sBAAsB,EAC3B,KAAK,uCAAuC,EAC5C,KAAK,6BAA6B,EAClC,KAAK,iBAAiB,EACtB,KAAK,sBAAsB,GAC5B,MAAM,aAAa,CAAA;AACpB,OAAO,EACL,iCAAiC,EACjC,2BAA2B,EAC3B,KAAK,kCAAkC,GACxC,MAAM,oBAAoB,CAAA"}
|
|
1
|
+
{"version":3,"file":"server-entry.d.ts","sourceRoot":"","sources":["../../src/contact/server-entry.ts"],"names":[],"mappings":"AAAA,OAAO,aAAa,CAAA;AAEpB,OAAO,EACL,4BAA4B,EAC5B,kCAAkC,EAClC,4BAA4B,EAC5B,wBAAwB,EACxB,gCAAgC,EAChC,4BAA4B,EAC5B,0BAA0B,EAC1B,mCAAmC,EACnC,KAAK,+BAA+B,EACpC,KAAK,4BAA4B,EACjC,KAAK,0BAA0B,EAC/B,KAAK,sBAAsB,EAC3B,KAAK,uCAAuC,EAC5C,KAAK,6BAA6B,EAClC,KAAK,iBAAiB,EACtB,KAAK,sBAAsB,GAC5B,MAAM,aAAa,CAAA;AACpB,OAAO,EACL,iCAAiC,EACjC,2BAA2B,EAC3B,KAAK,kCAAkC,GACxC,MAAM,oBAAoB,CAAA"}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
import 'server-only';
|
|
2
|
-
export { BRANDLAB_CONTACT_API_KEY_ENV, BRANDLAB_CONTACT_MAX_REQUEST_BYTES, BRANDLAB_CONTACT_RUNTIME, createBrandlabContactRuntime, createResendSdkContactSend, detectBrandlabContactDeploymentMode, } from './server.js';
|
|
2
|
+
export { BRANDLAB_CONTACT_API_KEY_ENV, BRANDLAB_CONTACT_MAX_REQUEST_BYTES, BRANDLAB_CONTACT_RATE_LIMITS, BRANDLAB_CONTACT_RUNTIME, createBrandlabContactRateLimiter, createBrandlabContactRuntime, createResendSdkContactSend, detectBrandlabContactDeploymentMode, } from './server.js';
|
|
3
3
|
export { createBrandlabContactRouteHandler, createLazyResendContactSend, } from './route-handler.js';
|
|
4
4
|
//# sourceMappingURL=server-entry.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"server-entry.js","sourceRoot":"","sources":["../../src/contact/server-entry.ts"],"names":[],"mappings":"AAAA,OAAO,aAAa,CAAA;AAEpB,OAAO,EACL,4BAA4B,EAC5B,kCAAkC,EAClC,wBAAwB,EACxB,4BAA4B,EAC5B,0BAA0B,EAC1B,mCAAmC,
|
|
1
|
+
{"version":3,"file":"server-entry.js","sourceRoot":"","sources":["../../src/contact/server-entry.ts"],"names":[],"mappings":"AAAA,OAAO,aAAa,CAAA;AAEpB,OAAO,EACL,4BAA4B,EAC5B,kCAAkC,EAClC,4BAA4B,EAC5B,wBAAwB,EACxB,gCAAgC,EAChC,4BAA4B,EAC5B,0BAA0B,EAC1B,mCAAmC,GASpC,MAAM,aAAa,CAAA;AACpB,OAAO,EACL,iCAAiC,EACjC,2BAA2B,GAE5B,MAAM,oBAAoB,CAAA","sourcesContent":["import 'server-only'\n\nexport {\n BRANDLAB_CONTACT_API_KEY_ENV,\n BRANDLAB_CONTACT_MAX_REQUEST_BYTES,\n BRANDLAB_CONTACT_RATE_LIMITS,\n BRANDLAB_CONTACT_RUNTIME,\n createBrandlabContactRateLimiter,\n createBrandlabContactRuntime,\n createResendSdkContactSend,\n detectBrandlabContactDeploymentMode,\n type BrandlabContactRateLimitOptions,\n type BrandlabContactRateLimitRule,\n type BrandlabContactRateLimiter,\n type BrandlabContactRuntime,\n type BrandlabContactRuntimeDefinitionOptions,\n type BrandlabContactRuntimeOptions,\n type ResendContactSend,\n type ResendSdkContactClient,\n} from './server.js'\nexport {\n createBrandlabContactRouteHandler,\n createLazyResendContactSend,\n type BrandlabContactRouteHandlerOptions,\n} from './route-handler.js'\n"]}
|
package/dist/contact/server.d.ts
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import 'server-only';
|
|
2
2
|
import { type BrandlabContactContract } from '@brandlab-digital/site-core/contact';
|
|
3
3
|
import { createContactDefinitionFromContract, type ContactRuntimeDefinitionOptions } from './contract.js';
|
|
4
|
+
import { type BrandlabContactRateLimitOptions, type BrandlabContactRateLimiter } from './rate-limit.js';
|
|
4
5
|
import { type ResendContactSend } from './resend.js';
|
|
5
6
|
import { type BrandlabContactDefinition, type BrandlabContactDeploymentMode, type ContactAttachmentProcessor, type ContactBotGuard, type ContactTransport } from './shared.js';
|
|
6
7
|
export { createBrandlabContactImageSanitizer } from './image-sanitizer.js';
|
|
8
|
+
export { BRANDLAB_CONTACT_RATE_LIMITS, createBrandlabContactRateLimiter, type BrandlabContactRateLimitDecision, type BrandlabContactRateLimitInput, type BrandlabContactRateLimitOptions, type BrandlabContactRateLimitRule, type BrandlabContactRateLimiter, } from './rate-limit.js';
|
|
7
9
|
export { createResendSdkContactSend } from './resend.js';
|
|
8
10
|
export type { ResendContactSend, ResendSdkContactClient } from './resend.js';
|
|
9
11
|
export declare const BRANDLAB_CONTACT_RUNTIME: "brandlab-contact-v4";
|
|
@@ -24,6 +26,12 @@ export interface BrandlabContactRuntimeOptions {
|
|
|
24
26
|
createResendSend: (apiKey: string) => ResendContactSend;
|
|
25
27
|
botGuard?: ContactBotGuard;
|
|
26
28
|
now?: () => Date;
|
|
29
|
+
/**
|
|
30
|
+
* Abuse limits per client IP and per email address on deployments that deliver (production and Preview): the
|
|
31
|
+
* defaults (BRANDLAB_CONTACT_RATE_LIMITS) when omitted, adjusted limits, a limiter shared across runtimes, or
|
|
32
|
+
* `false` to turn them off. Create a runtime once per route module so its limits persist between requests.
|
|
33
|
+
*/
|
|
34
|
+
rateLimit?: BrandlabContactRateLimitOptions | BrandlabContactRateLimiter | false;
|
|
27
35
|
}
|
|
28
36
|
export interface BrandlabContactRuntime {
|
|
29
37
|
runtimeKind: typeof BRANDLAB_CONTACT_RUNTIME;
|
|
@@ -46,6 +54,7 @@ interface ContactRouteHandlerOptions {
|
|
|
46
54
|
attachmentProcessor?: ContactAttachmentProcessor;
|
|
47
55
|
botGuard?: ContactBotGuard;
|
|
48
56
|
now?: () => Date;
|
|
57
|
+
rateLimiter?: BrandlabContactRateLimiter;
|
|
49
58
|
}
|
|
50
59
|
export declare function getContactRequestLimits(definition: BrandlabContactDefinition): {
|
|
51
60
|
maxRequestBytes: number;
|