@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.
Files changed (87) hide show
  1. package/README.md +33 -0
  2. package/dist/contact/attachments-client.d.ts +3 -0
  3. package/dist/contact/attachments-client.d.ts.map +1 -1
  4. package/dist/contact/attachments-client.js +31 -8
  5. package/dist/contact/attachments-client.js.map +1 -1
  6. package/dist/contact/client-entry.d.ts +1 -1
  7. package/dist/contact/client-entry.d.ts.map +1 -1
  8. package/dist/contact/client-entry.js.map +1 -1
  9. package/dist/contact/contract.d.ts.map +1 -1
  10. package/dist/contact/contract.js +2 -2
  11. package/dist/contact/contract.js.map +1 -1
  12. package/dist/contact/email.d.ts.map +1 -1
  13. package/dist/contact/email.js +9 -0
  14. package/dist/contact/email.js.map +1 -1
  15. package/dist/contact/form-client.d.ts +4 -2
  16. package/dist/contact/form-client.d.ts.map +1 -1
  17. package/dist/contact/form-client.js +49 -13
  18. package/dist/contact/form-client.js.map +1 -1
  19. package/dist/contact/rate-limit.d.ts +59 -0
  20. package/dist/contact/rate-limit.d.ts.map +1 -0
  21. package/dist/contact/rate-limit.js +109 -0
  22. package/dist/contact/rate-limit.js.map +1 -0
  23. package/dist/contact/route-handler.d.ts +6 -0
  24. package/dist/contact/route-handler.d.ts.map +1 -1
  25. package/dist/contact/route-handler.js +4 -0
  26. package/dist/contact/route-handler.js.map +1 -1
  27. package/dist/contact/server-entry.d.ts +1 -1
  28. package/dist/contact/server-entry.d.ts.map +1 -1
  29. package/dist/contact/server-entry.js +1 -1
  30. package/dist/contact/server-entry.js.map +1 -1
  31. package/dist/contact/server.d.ts +9 -0
  32. package/dist/contact/server.d.ts.map +1 -1
  33. package/dist/contact/server.js +71 -7
  34. package/dist/contact/server.js.map +1 -1
  35. package/dist/contact/shared.d.ts +11 -0
  36. package/dist/contact/shared.d.ts.map +1 -1
  37. package/dist/contact/shared.js +11 -0
  38. package/dist/contact/shared.js.map +1 -1
  39. package/dist/contact/testing.d.ts +5 -0
  40. package/dist/contact/testing.d.ts.map +1 -1
  41. package/dist/contact/testing.js +1 -0
  42. package/dist/contact/testing.js.map +1 -1
  43. package/dist/lead-reporting.d.ts +6 -0
  44. package/dist/lead-reporting.d.ts.map +1 -1
  45. package/dist/lead-reporting.js +13 -1
  46. package/dist/lead-reporting.js.map +1 -1
  47. package/dist/ui-kit/brandlab-skin.d.ts +12 -0
  48. package/dist/ui-kit/brandlab-skin.d.ts.map +1 -1
  49. package/dist/ui-kit/brandlab-skin.js.map +1 -1
  50. package/dist/ui-kit/form/copy.d.ts +3 -0
  51. package/dist/ui-kit/form/copy.d.ts.map +1 -0
  52. package/dist/ui-kit/form/copy.js +5 -0
  53. package/dist/ui-kit/form/copy.js.map +1 -0
  54. package/dist/ui-kit/form/dialog.d.ts +24 -0
  55. package/dist/ui-kit/form/dialog.d.ts.map +1 -0
  56. package/dist/ui-kit/form/dialog.js +17 -0
  57. package/dist/ui-kit/form/dialog.js.map +1 -0
  58. package/dist/ui-kit/form/field.d.ts +30 -0
  59. package/dist/ui-kit/form/field.d.ts.map +1 -0
  60. package/dist/ui-kit/form/field.js +11 -0
  61. package/dist/ui-kit/form/field.js.map +1 -0
  62. package/dist/ui-kit/form/index.d.ts +7 -0
  63. package/dist/ui-kit/form/index.d.ts.map +1 -0
  64. package/dist/ui-kit/form/index.js +7 -0
  65. package/dist/ui-kit/form/index.js.map +1 -0
  66. package/dist/ui-kit/form/photo-upload.d.ts +37 -0
  67. package/dist/ui-kit/form/photo-upload.d.ts.map +1 -0
  68. package/dist/ui-kit/form/photo-upload.js +19 -0
  69. package/dist/ui-kit/form/photo-upload.js.map +1 -0
  70. package/dist/ui-kit/form/quote-dialog.d.ts +15 -0
  71. package/dist/ui-kit/form/quote-dialog.d.ts.map +1 -0
  72. package/dist/ui-kit/form/quote-dialog.js +68 -0
  73. package/dist/ui-kit/form/quote-dialog.js.map +1 -0
  74. package/dist/ui-kit/form/quote-form.d.ts +165 -0
  75. package/dist/ui-kit/form/quote-form.d.ts.map +1 -0
  76. package/dist/ui-kit/form/quote-form.js +235 -0
  77. package/dist/ui-kit/form/quote-form.js.map +1 -0
  78. package/dist/ui-kit/form/thank-you.d.ts +37 -0
  79. package/dist/ui-kit/form/thank-you.d.ts.map +1 -0
  80. package/dist/ui-kit/form/thank-you.js +36 -0
  81. package/dist/ui-kit/form/thank-you.js.map +1 -0
  82. package/dist/ui-kit/structure.css +197 -0
  83. package/dist/ui.d.ts +10 -1
  84. package/dist/ui.d.ts.map +1 -1
  85. package/dist/ui.js +3 -3
  86. package/dist/ui.js.map +1 -1
  87. 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. Browser validation is left exactly as
98
- * the form's own markup declares it.
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
- current.analytics?.trackLeadFormError({
168
- pagePath: rawPagePath,
169
- formId: diagnosticsFormId,
170
- stage: errorStage(result),
171
- errorCount: result.code === 'validation'
172
- ? Math.min(50, Math.max(1, Object.keys(result.fieldErrors ?? {}).length))
173
- : 1,
174
- reason,
175
- ...(field ? { field } : {}),
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;CACzD;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,CAwBzC"}
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;AAepB;;;;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,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;KAC7C,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 { 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\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 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 })\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
+ {"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,GAMpC,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_RUNTIME,\n createBrandlabContactRuntime,\n createResendSdkContactSend,\n detectBrandlabContactDeploymentMode,\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"]}
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"]}
@@ -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;