@evinvest/kitstart 0.8.0 → 0.9.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 (62) hide show
  1. package/README.md +89 -23
  2. package/dist/cli/kitstart-outbox.d.ts +2 -0
  3. package/dist/cli/kitstart-outbox.js +10 -0
  4. package/dist/cli/outbox.d.ts +5 -0
  5. package/dist/cli/outbox.js +77 -0
  6. package/dist/core/accept.d.ts +22 -3
  7. package/dist/core/accept.js +40 -13
  8. package/dist/core/analytics.d.ts +3 -1
  9. package/dist/core/analytics.js +7 -0
  10. package/dist/core/lead-capture-text.d.ts +24 -1
  11. package/dist/core/lead-capture-text.js +20 -0
  12. package/dist/core/lead.d.ts +71 -10
  13. package/dist/core/lead.js +65 -11
  14. package/dist/core/phone.d.ts +20 -9
  15. package/dist/core/phone.js +43 -19
  16. package/dist/core/status.d.ts +13 -0
  17. package/dist/core/status.js +19 -2
  18. package/dist/index.d.ts +3 -3
  19. package/dist/index.js +3 -3
  20. package/dist/next/quote-route.d.ts +15 -2
  21. package/dist/next/quote-route.js +70 -10
  22. package/dist/react/LeadCapture.d.ts +7 -5
  23. package/dist/react/LeadCapture.js +18 -6
  24. package/dist/react/LeadCaptureChannels.d.ts +14 -1
  25. package/dist/react/LeadCaptureChannels.js +8 -5
  26. package/dist/react/LeadCaptureFields.d.ts +39 -7
  27. package/dist/react/LeadCaptureFields.js +91 -21
  28. package/dist/react/LeadCaptureNeed.d.ts +5 -0
  29. package/dist/react/LeadCaptureNeed.js +38 -2
  30. package/dist/react/LeadCaptureSubmit.d.ts +16 -0
  31. package/dist/react/LeadCaptureSubmit.js +13 -0
  32. package/dist/react/QuoteFormShell.d.ts +2 -0
  33. package/dist/react/QuoteFormShell.js +3 -2
  34. package/dist/react/use-lead-error.d.ts +24 -0
  35. package/dist/react/use-lead-error.js +73 -0
  36. package/dist/react/use-lead-events.d.ts +3 -0
  37. package/dist/react/use-lead-events.js +3 -2
  38. package/dist/react/use-lead-submit.d.ts +44 -9
  39. package/dist/react/use-lead-submit.js +122 -13
  40. package/dist/react/use-validity.d.ts +17 -0
  41. package/dist/react/use-validity.js +38 -0
  42. package/dist/server/env.d.ts +9 -0
  43. package/dist/server/env.js +12 -0
  44. package/dist/server/index.d.ts +1 -1
  45. package/dist/server/index.js +1 -1
  46. package/dist/server/lead-store-sqlite.d.ts +5 -1
  47. package/dist/server/lead-store-sqlite.js +37 -2
  48. package/dist/server/lead-webhook.d.ts +20 -1
  49. package/dist/server/lead-webhook.js +6 -2
  50. package/dist/server/webhook-outbox.d.ts +27 -1
  51. package/dist/server/webhook-outbox.js +33 -6
  52. package/dist/testing/index.d.ts +1 -0
  53. package/dist/testing/index.js +1 -0
  54. package/dist/testing/landing-contract.js +6 -0
  55. package/dist/testing/lead-rules.d.ts +18 -0
  56. package/dist/testing/lead-rules.js +50 -0
  57. package/dist/testing/lead-store-contract.js +20 -0
  58. package/package.json +3 -2
  59. package/template/flake.nix +1 -1
  60. package/template/package.json +1 -1
  61. package/template/src/shared/config/lead.ts +6 -3
  62. package/template/tests/e2e/quote-form.spec.ts +86 -1
@@ -20,6 +20,8 @@ export interface QuoteFormShellProps {
20
20
  /** The honeypot's label — read only by a bot filling every field. */
21
21
  honeypotLabel: string;
22
22
  formId?: string;
23
+ /** The card's anchor, posted so a refusal lands back on it (`quoteRoute`). */
24
+ card?: string | undefined;
23
25
  action?: string;
24
26
  id?: string;
25
27
  className?: string;
@@ -2,9 +2,10 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { cn } from "@evinvest/uikit";
3
3
  import { FORM_ID_FIELD, LOCALE_FIELD, LOCATION_FIELD } from "../core/accept.js";
4
4
  import { HONEYPOT_FIELD, RENDERED_AT_FIELD } from "../core/antispam.js";
5
+ import { CARD_FIELD, SUBMISSION_FIELD } from "../core/lead.js";
5
6
  /** The mobile field's attributes: the numeric keypad and the phone's autofill. */
6
7
  export const PHONE_INPUT_PROPS = { type: "tel", inputMode: "tel", autoComplete: "tel" };
7
8
  export function QuoteFormShell(props) {
8
- const { placeSlug, locale, renderedAt, honeypotLabel, formId = "quote", action = "/quote", id = "quote", className, onSubmit, children } = props;
9
- return (_jsxs("form", { id: id, method: "post", action: action, onSubmit: onSubmit, className: cn("relative flex w-full flex-col gap-5", className), children: [placeSlug && _jsx("input", { type: "hidden", name: LOCATION_FIELD, value: placeSlug }), _jsx("input", { type: "hidden", name: LOCALE_FIELD, value: locale }), _jsx("input", { type: "hidden", name: FORM_ID_FIELD, value: formId }), _jsx("input", { type: "hidden", name: RENDERED_AT_FIELD, value: String(renderedAt) }), children, _jsx("div", { "aria-hidden": "true", style: { position: "absolute", left: "-10000px", width: 1, height: 1, overflow: "hidden" }, children: _jsxs("label", { children: [honeypotLabel, _jsx("input", { type: "text", name: HONEYPOT_FIELD, tabIndex: -1, autoComplete: "off", defaultValue: "" })] }) })] }));
9
+ const { placeSlug, locale, renderedAt, honeypotLabel, formId = "quote", card, action = "/quote", id = "quote", className, onSubmit, children } = props;
10
+ return (_jsxs("form", { id: id, method: "post", action: action, onSubmit: onSubmit, className: cn("relative flex w-full flex-col gap-5", className), children: [placeSlug && _jsx("input", { type: "hidden", name: LOCATION_FIELD, value: placeSlug }), _jsx("input", { type: "hidden", name: LOCALE_FIELD, value: locale }), _jsx("input", { type: "hidden", name: FORM_ID_FIELD, value: formId }), _jsx("input", { type: "hidden", name: RENDERED_AT_FIELD, value: String(renderedAt) }), card && _jsx("input", { type: "hidden", name: CARD_FIELD, value: card }), _jsx("input", { type: "hidden", name: SUBMISSION_FIELD }), children, _jsx("div", { "aria-hidden": "true", style: { position: "absolute", left: "-10000px", width: 1, height: 1, overflow: "hidden" }, children: _jsxs("label", { children: [honeypotLabel, _jsx("input", { type: "text", name: HONEYPOT_FIELD, tabIndex: -1, autoComplete: "off", defaultValue: "" })] }) })] }));
10
11
  }
@@ -0,0 +1,24 @@
1
+ import { type RefObject } from "react";
2
+ import { type LeadChannel } from "../core/lead.js";
3
+ import type { LeadCaptureText } from "../core/lead-capture-text.js";
4
+ /** A refusal the server sent back: which of the card's forms, and the field it is about. */
5
+ export interface LeadError {
6
+ channel: LeadChannel;
7
+ field: string;
8
+ }
9
+ /** The fields `LeadCapture` draws itself, which show a refusal under themselves. */
10
+ export declare const OWN_FIELDS: Readonly<Record<LeadChannel, readonly string[]>>;
11
+ /** The id of the message a refusal of a field the card does not draw is shown in, above the submit. */
12
+ export declare const formMessageId: (id: string, channel: LeadChannel) => string;
13
+ /** The words for a refused field: the field's own rule where the form has one. */
14
+ export declare function errorText(field: string, text: LeadCaptureText): string;
15
+ /**
16
+ * The server's refusal, for the card `id`: read once from the page's query
17
+ * when the 303 brought the visitor back (`?lead_error=phone#<id>`, or
18
+ * `#<id>-callback`) — then taken out of the URL, so a reload does not show it
19
+ * again — or set by the script's own post. The refused control takes the
20
+ * focus; a control the card does not draw (a brand's extra) is marked
21
+ * `aria-invalid` and pointed at the message above the submit. Fixing that
22
+ * control clears it.
23
+ */
24
+ export declare function useLeadError(root: RefObject<HTMLElement | null>, id: string): [LeadError | null, (error: LeadError | null) => void];
@@ -0,0 +1,73 @@
1
+ "use client";
2
+ import { useEffect, useState } from "react";
3
+ import { LEAD_ERROR_PARAM } from "../core/lead.js";
4
+ const FIELD = /^[a-z][a-z0-9_]{0,31}$/;
5
+ /** The fields `LeadCapture` draws itself, which show a refusal under themselves. */
6
+ export const OWN_FIELDS = { form: ["phone", "locality", "name"], callback: ["phone", "consent"] };
7
+ /** The id of the message a refusal of a field the card does not draw is shown in, above the submit. */
8
+ export const formMessageId = (id, channel) => (channel === "callback" ? `${id}-callback-error` : `${id}-error`);
9
+ /** The words for a refused field: the field's own rule where the form has one. */
10
+ export function errorText(field, text) {
11
+ if (field === "phone")
12
+ return text.phoneInvalid;
13
+ if (field === "consent")
14
+ return text.consentRequired;
15
+ if (field === "need")
16
+ return text.needRequired;
17
+ if (field === "form")
18
+ return text.formInvalid;
19
+ return text.fieldInvalid;
20
+ }
21
+ /** The control a refusal is about: by its role, then by its posted name. */
22
+ function controlOf(form, field) {
23
+ return form.querySelector(`[data-lead-field="${field}"]`) ?? form.querySelector(`[name="${CSS.escape(field)}"]`);
24
+ }
25
+ /**
26
+ * The server's refusal, for the card `id`: read once from the page's query
27
+ * when the 303 brought the visitor back (`?lead_error=phone#<id>`, or
28
+ * `#<id>-callback`) — then taken out of the URL, so a reload does not show it
29
+ * again — or set by the script's own post. The refused control takes the
30
+ * focus; a control the card does not draw (a brand's extra) is marked
31
+ * `aria-invalid` and pointed at the message above the submit. Fixing that
32
+ * control clears it.
33
+ */
34
+ export function useLeadError(root, id) {
35
+ const [error, setError] = useState(null);
36
+ useEffect(() => {
37
+ const url = new URL(window.location.href);
38
+ const field = url.searchParams.get(LEAD_ERROR_PARAM);
39
+ const channel = url.hash === `#${id}` ? "form" : url.hash === `#${id}-callback` ? "callback" : null;
40
+ if (field === null || channel === null)
41
+ return;
42
+ url.searchParams.delete(LEAD_ERROR_PARAM);
43
+ window.history.replaceState(window.history.state, "", url);
44
+ if (FIELD.test(field))
45
+ setError({ channel, field });
46
+ }, [id]);
47
+ useEffect(() => {
48
+ const form = error && root.current?.querySelector(`#${CSS.escape(error.channel === "callback" ? `${id}-callback-form` : `${id}-form`)}`);
49
+ if (!error || !form)
50
+ return;
51
+ const details = form.closest("details");
52
+ if (details)
53
+ details.open = true;
54
+ const control = controlOf(form, error.field);
55
+ const foreign = control && !OWN_FIELDS[error.channel].includes(error.field) ? control : null;
56
+ foreign?.setAttribute("aria-invalid", "true");
57
+ foreign?.setAttribute("aria-describedby", formMessageId(id, error.channel));
58
+ (control ?? document.getElementById(formMessageId(id, error.channel)))?.focus();
59
+ const fixed = (event) => {
60
+ if (error.field === "form" || (event.target instanceof Element && control?.contains(event.target)))
61
+ setError(null);
62
+ };
63
+ form.addEventListener("input", fixed);
64
+ form.addEventListener("change", fixed);
65
+ return () => {
66
+ form.removeEventListener("input", fixed);
67
+ form.removeEventListener("change", fixed);
68
+ foreign?.removeAttribute("aria-invalid");
69
+ foreign?.removeAttribute("aria-describedby");
70
+ };
71
+ }, [error, id, root]);
72
+ return [error, setError];
73
+ }
@@ -4,7 +4,10 @@ import { type LeadField, type LeadStep } from "../core/analytics.js";
4
4
  export declare const FIELD_ATTR = "data-lead-field";
5
5
  export interface LeadEvents {
6
6
  step(step: LeadStep): void;
7
+ /** A hint under a field (`blocking: false`); the browser's refusal is counted by itself (`blocking: true`). */
7
8
  fieldError(field: LeadField): void;
9
+ /** The script's post got no answer: `network` or `timeout`, and which form. */
10
+ submitError(reason: "network" | "timeout", channel: "form" | "callback"): void;
8
11
  }
9
12
  /**
10
13
  * The lead form's own funnel, reported through the boundary's sink: seen
@@ -45,7 +45,7 @@ export function useLeadEvents(root, tags) {
45
45
  const invalid = (event) => {
46
46
  const field = event.target instanceof Element ? event.target.getAttribute(FIELD_ATTR) : null;
47
47
  if (field)
48
- sink.capture(EVENTS.fieldError, { ...props, field });
48
+ sink.capture(EVENTS.fieldError, { ...props, field, blocking: true });
49
49
  };
50
50
  el.addEventListener("focusin", focus);
51
51
  el.addEventListener("invalid", invalid, true);
@@ -57,6 +57,7 @@ export function useLeadEvents(root, tags) {
57
57
  }, [sink, root, props]);
58
58
  return useMemo(() => ({
59
59
  step: step => sink?.capture(EVENTS.formStep, { ...props, step }),
60
- fieldError: field => sink?.capture(EVENTS.fieldError, { ...props, field }),
60
+ fieldError: field => sink?.capture(EVENTS.fieldError, { ...props, field, blocking: false }),
61
+ submitError: (reason, channel) => sink?.capture(EVENTS.submitError, { ...props, reason, channel }),
61
62
  }), [sink, props]);
62
63
  }
@@ -1,21 +1,56 @@
1
1
  import { type FormEventHandler } from "react";
2
+ import { type LeadChannel } from "../core/lead.js";
2
3
  /** What the visitor just sent, for a brand's in-card success ("we call 06 … back"). */
3
4
  export interface LeadSent {
4
- channel: "form" | "callback";
5
+ channel: LeadChannel;
5
6
  /** As typed. */
6
7
  phone: string;
7
8
  /** The name field's value, when the form has one and it was filled. */
8
9
  name: string | null;
9
10
  }
11
+ /** Leaving the page, behind a seam a test can stand in for (jsdom cannot navigate). */
12
+ export declare const navigation: {
13
+ assign: (url: string) => void;
14
+ };
10
15
  /**
11
- * The lead posted by a script, so the card can say "done" in place. The body
12
- * and the endpoint are the form's own, and the server answers it as it answers
13
- * the plain post: a 303 to the thanks page means the lead was taken. Anything
14
- * else — a refusal, the store's 500, no network — submits the form for real,
15
- * so the server's own page says what went wrong. Off (`undefined` handler)
16
- * when the brand gave no in-card success: the 303 to `/thanks` stays the path.
16
+ * A v4 UUID. `randomUUID` exists only in a secure context; a LAN dev server
17
+ * over plain http is not one, but `getRandomValues` works everywhere.
17
18
  */
18
- export declare function useLeadSubmit(enabled: boolean, fields: {
19
+ export declare function newSubmissionId(): string;
20
+ /** Why the script's post got no answer: none came back (`network`), or none in time (`timeout`). */
21
+ export type SendFailure = "network" | "timeout";
22
+ /** How long the script waits for `/quote` before it says so; the lead is a few short fields. */
23
+ export declare const SUBMIT_TIMEOUT_MS = 15000;
24
+ export interface LeadSubmitHandlers {
25
+ /** The route refused the lead: the field to fix. */
26
+ onRefused: (channel: LeadChannel, field: string) => void;
27
+ /** No answer: the form stays as typed, with a retry. */
28
+ onFailed: (channel: LeadChannel, failure: SendFailure) => void;
29
+ }
30
+ export interface LeadSubmit {
31
+ sent: LeadSent | null;
32
+ onSubmit: FormEventHandler<HTMLFormElement>;
33
+ /** The form being posted, while it is. */
34
+ busy: LeadChannel | null;
35
+ /** The last post that got no answer, until the next one. */
36
+ failure: {
37
+ channel: LeadChannel;
38
+ failure: SendFailure;
39
+ } | null;
40
+ /** Posts that form again — the same submission id, so never a second lead. */
41
+ retry: () => void;
42
+ }
43
+ /**
44
+ * The lead posted by a script, so a refusal keeps what was typed: the body
45
+ * and the endpoint are the form's own, asked for JSON. Taken → the brand's
46
+ * in-card success when it has one (`done`), else the thanks page the route
47
+ * names. Refused → `onRefused` with the field, the form as it was. No answer
48
+ * — no network, or nothing in `SUBMIT_TIMEOUT_MS` — is said in place with a
49
+ * retry, never by leaving the page: the browser's error page would lose the
50
+ * form. Any other answer (the store's 500) submits the form for real, so the
51
+ * server's own page says what went wrong; the submission id makes that safe.
52
+ */
53
+ export declare function useLeadSubmit(done: boolean, fields: {
19
54
  mobile: string;
20
55
  name: string | undefined;
21
- }): [LeadSent | null, FormEventHandler<HTMLFormElement> | undefined];
56
+ }, on: LeadSubmitHandlers): LeadSubmit;
@@ -1,26 +1,103 @@
1
1
  "use client";
2
2
  import { useRef, useState } from "react";
3
- import { CHANNEL_FIELD } from "../core/lead.js";
3
+ import { CHANNEL_FIELD, SUBMISSION_FIELD } from "../core/lead.js";
4
4
  import { THANKS } from "../core/routing.js";
5
+ /** Leaving the page, behind a seam a test can stand in for (jsdom cannot navigate). */
6
+ export const navigation = { assign: (url) => window.location.assign(url) };
5
7
  /**
6
- * The lead posted by a script, so the card can say "done" in place. The body
7
- * and the endpoint are the form's own, and the server answers it as it answers
8
- * the plain post: a 303 to the thanks page means the lead was taken. Anything
9
- * else — a refusal, the store's 500, no network — submits the form for real,
10
- * so the server's own page says what went wrong. Off (`undefined` handler)
11
- * when the brand gave no in-card success: the 303 to `/thanks` stays the path.
8
+ * A v4 UUID. `randomUUID` exists only in a secure context; a LAN dev server
9
+ * over plain http is not one, but `getRandomValues` works everywhere.
12
10
  */
13
- export function useLeadSubmit(enabled, fields) {
11
+ export function newSubmissionId() {
12
+ if (typeof crypto.randomUUID === "function")
13
+ return crypto.randomUUID();
14
+ const b = crypto.getRandomValues(new Uint8Array(16));
15
+ b[6] = ((b[6] ?? 0) & 0x0f) | 0x40;
16
+ b[8] = ((b[8] ?? 0) & 0x3f) | 0x80;
17
+ const hex = [...b].map(x => x.toString(16).padStart(2, "0")).join("");
18
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
19
+ }
20
+ /**
21
+ * The ids this page minted, by form, with what each was minted for. Kept in
22
+ * memory, not read back from the field: a browser restores a hidden field's
23
+ * value on Back, and a new lead would go out under an old one's id.
24
+ */
25
+ const minted = new WeakMap();
26
+ /**
27
+ * The form's submission id, written into its field: the same for a resend of
28
+ * the same lead, a new one as soon as anything posted differs — a number
29
+ * fixed after a timeout is another lead, which must not be answered with the
30
+ * first one's row.
31
+ */
32
+ function stamp(form) {
33
+ const field = form.elements.namedItem(SUBMISSION_FIELD);
34
+ if (!(field instanceof HTMLInputElement))
35
+ return;
36
+ const posted = new FormData(form);
37
+ posted.delete(SUBMISSION_FIELD);
38
+ const lead = new URLSearchParams([...posted].filter((e) => typeof e[1] === "string")).toString();
39
+ const known = minted.get(form);
40
+ const id = known && known.lead === lead ? known.id : newSubmissionId();
41
+ minted.set(form, { id, lead });
42
+ field.value = id;
43
+ }
44
+ /** A root-relative path on this origin — never `//elsewhere`. */
45
+ const OWN_PATH = /^\/(?!\/)/;
46
+ /**
47
+ * The route's answer to a script (`quoteRoute`): JSON when it asked for it,
48
+ * or — through a proxy that rewrote nothing but followed the 303 — the
49
+ * thanks page itself. Anything else is not an answer: `null`.
50
+ */
51
+ async function readAnswer(res) {
52
+ if ((res.ok || res.status === 422) && (res.headers.get("content-type") ?? "").includes("application/json")) {
53
+ // Not JSON is no answer; a body cut off (the network, the timeout) is no answer either — thrown on.
54
+ const body = await res.json().catch((error) => {
55
+ if (error instanceof SyntaxError)
56
+ return null;
57
+ throw error;
58
+ });
59
+ if (typeof body !== "object" || body === null)
60
+ return null;
61
+ const { ok, location, field } = body;
62
+ if (ok === true && typeof location === "string" && OWN_PATH.test(location))
63
+ return { ok, location };
64
+ if (ok === false && typeof field === "string")
65
+ return { ok, field };
66
+ return null;
67
+ }
68
+ if (res.ok && res.redirected) {
69
+ const url = new URL(res.url);
70
+ if (url.pathname.endsWith(THANKS))
71
+ return { ok: true, location: `${url.pathname}${url.search}` };
72
+ }
73
+ return null;
74
+ }
75
+ /** How long the script waits for `/quote` before it says so; the lead is a few short fields. */
76
+ export const SUBMIT_TIMEOUT_MS = 15_000;
77
+ /**
78
+ * The lead posted by a script, so a refusal keeps what was typed: the body
79
+ * and the endpoint are the form's own, asked for JSON. Taken → the brand's
80
+ * in-card success when it has one (`done`), else the thanks page the route
81
+ * names. Refused → `onRefused` with the field, the form as it was. No answer
82
+ * — no network, or nothing in `SUBMIT_TIMEOUT_MS` — is said in place with a
83
+ * retry, never by leaving the page: the browser's error page would lose the
84
+ * form. Any other answer (the store's 500) submits the form for real, so the
85
+ * server's own page says what went wrong; the submission id makes that safe.
86
+ */
87
+ export function useLeadSubmit(done, fields, on) {
14
88
  const [sent, setSent] = useState(null);
89
+ const [busy, setBusy] = useState(null);
90
+ const [failure, setFailure] = useState(null);
15
91
  const pending = useRef(false);
16
- if (!enabled)
17
- return [sent, undefined];
92
+ const last = useRef(null);
18
93
  const submit = (event) => {
19
94
  event.preventDefault();
20
95
  if (pending.current)
21
96
  return;
22
97
  pending.current = true;
23
98
  const form = event.currentTarget;
99
+ last.current = form;
100
+ stamp(form);
24
101
  const data = new FormData(form);
25
102
  const body = new URLSearchParams();
26
103
  for (const [key, value] of data)
@@ -31,12 +108,44 @@ export function useLeadSubmit(enabled, fields) {
31
108
  return typeof value === "string" ? value.trim() : "";
32
109
  };
33
110
  const lead = { channel: read(CHANNEL_FIELD) === "callback" ? "callback" : "form", phone: read(fields.mobile), name: read(fields.name) || null };
111
+ setBusy(lead.channel);
112
+ setFailure(null);
113
+ const settle = () => {
114
+ pending.current = false;
115
+ setBusy(null);
116
+ };
34
117
  // `form.submit()` neither validates again nor comes back here.
35
118
  const fallBack = () => {
36
- pending.current = false;
119
+ settle();
37
120
  form.submit();
38
121
  };
39
- fetch(form.action, { method: "POST", body }).then(res => (res.ok && res.redirected && new URL(res.url).pathname.endsWith(THANKS) ? setSent(lead) : fallBack()), fallBack);
122
+ // A controller and a timer rather than `AbortSignal.timeout`: one clock the page (and a test) controls.
123
+ const controller = new AbortController();
124
+ const timer = setTimeout(() => controller.abort(new DOMException("no answer in time", "TimeoutError")), SUBMIT_TIMEOUT_MS);
125
+ fetch(form.action, { method: "POST", body, headers: { Accept: "application/json" }, signal: controller.signal })
126
+ .then(readAnswer)
127
+ .then(answer => {
128
+ if (answer === null)
129
+ return fallBack();
130
+ if (!answer.ok) {
131
+ settle();
132
+ return on.onRefused(lead.channel, answer.field);
133
+ }
134
+ // Still pending: the page is leaving, or the form is gone. The next
135
+ // lead from this form (Back, another need) gets an id of its own.
136
+ minted.delete(form);
137
+ if (done)
138
+ setSent(lead);
139
+ else
140
+ navigation.assign(answer.location);
141
+ }, () => {
142
+ settle();
143
+ const kind = controller.signal.aborted ? "timeout" : "network";
144
+ setFailure({ channel: lead.channel, failure: kind });
145
+ on.onFailed(lead.channel, kind);
146
+ })
147
+ .finally(() => clearTimeout(timer));
40
148
  };
41
- return [sent, submit];
149
+ const retry = () => last.current?.requestSubmit();
150
+ return { sent, onSubmit: submit, busy, failure, retry };
42
151
  }
@@ -0,0 +1,17 @@
1
+ import { type ChangeEvent, type FormEvent } from "react";
2
+ /**
3
+ * A control's validity in the page's words. `message(el)` is what is wrong
4
+ * now, `""` when nothing is; it is the control's custom validity from the
5
+ * first render on, so the browser blocks the submit and its bubble speaks the
6
+ * page's language, not the browser's. `shown` is the message once the browser
7
+ * refused the control (`invalid`), until the visitor fixes it — for the
8
+ * field to show under itself and mark `aria-invalid`.
9
+ */
10
+ export declare function useValidity<E extends HTMLInputElement>(message: (el: E) => string): {
11
+ ref: import("react").RefObject<E | null>;
12
+ shown: string | null;
13
+ onInvalid: (event: FormEvent<E>) => void;
14
+ onChange: (event: ChangeEvent<E>) => void;
15
+ /** After a value set by script, which fires no input event. */
16
+ recheck: (el: E) => void;
17
+ };
@@ -0,0 +1,38 @@
1
+ "use client";
2
+ import { useEffect, useRef, useState } from "react";
3
+ /**
4
+ * A control's validity in the page's words. `message(el)` is what is wrong
5
+ * now, `""` when nothing is; it is the control's custom validity from the
6
+ * first render on, so the browser blocks the submit and its bubble speaks the
7
+ * page's language, not the browser's. `shown` is the message once the browser
8
+ * refused the control (`invalid`), until the visitor fixes it — for the
9
+ * field to show under itself and mark `aria-invalid`.
10
+ */
11
+ export function useValidity(message) {
12
+ const ref = useRef(null);
13
+ const [shown, setShown] = useState(null);
14
+ const latest = useRef(message);
15
+ latest.current = message;
16
+ // Every render: the words arrive as props and may change with the locale.
17
+ useEffect(() => {
18
+ const el = ref.current;
19
+ if (el)
20
+ el.setCustomValidity(latest.current(el));
21
+ });
22
+ const recheck = (el) => {
23
+ const now = latest.current(el);
24
+ el.setCustomValidity(now);
25
+ if (now === "")
26
+ setShown(null);
27
+ else
28
+ setShown(was => (was === null ? null : now));
29
+ };
30
+ return {
31
+ ref,
32
+ shown,
33
+ onInvalid: (event) => setShown(latest.current(event.currentTarget) || event.currentTarget.validationMessage),
34
+ onChange: (event) => recheck(event.currentTarget),
35
+ /** After a value set by script, which fires no input event. */
36
+ recheck,
37
+ };
38
+ }
@@ -35,6 +35,15 @@ export interface ServerEnv {
35
35
  } | null;
36
36
  posthogKey: string | null;
37
37
  posthogHost: string;
38
+ /**
39
+ * Leads one client address may send per window before the rest are kept
40
+ * as `rate-limited` (`LEAD_RATE_LIMIT`, `<count>/<seconds>`): 5 per 10 min
41
+ * in production; 100 outside it, where every request shares one address.
42
+ */
43
+ leadRateLimit: {
44
+ limit: number;
45
+ windowMs: number;
46
+ };
38
47
  }
39
48
  export type EnvSource = Readonly<Record<string, string | undefined>>;
40
49
  export declare function parseServerEnv(site: {
@@ -56,6 +56,17 @@ function leadWebhook(source) {
56
56
  }
57
57
  return { url, keyId, secret };
58
58
  }
59
+ function rateLimit(source, production) {
60
+ const raw = opt(source, "LEAD_RATE_LIMIT");
61
+ if (raw === null)
62
+ return { limit: production ? 5 : 100, windowMs: 600_000 };
63
+ const parsed = /^(\d+)\/(\d+)$/.exec(raw);
64
+ const limit = Number(parsed?.[1]);
65
+ const seconds = Number(parsed?.[2]);
66
+ if (!parsed || limit < 1 || seconds < 1)
67
+ throw new Error("LEAD_RATE_LIMIT: <count>/<seconds>, both at least 1, e.g. 5/600");
68
+ return { limit, windowMs: seconds * 1000 };
69
+ }
59
70
  export function parseServerEnv(site, source) {
60
71
  const production = source["NODE_ENV"] === "production";
61
72
  const trust = opt(source, "TRUSTED_PROXY");
@@ -76,6 +87,7 @@ export function parseServerEnv(site, source) {
76
87
  // Explicit because PostHog rejects a project's events at the other
77
88
  // region's host; the audience is European, so EU unless told otherwise.
78
89
  posthogHost: url(source, "POSTHOG_HOST") ?? "https://eu.i.posthog.com",
90
+ leadRateLimit: rateLimit(source, production),
79
91
  };
80
92
  }
81
93
  /** Lazy and cached: `next build` imports the brand's env module and must not need prod secrets. */
@@ -14,5 +14,5 @@ export { defaultLeadMail, leadNotifier, MAIL_PER_MINUTE, type LeadMail, type Lea
14
14
  export { clientKey, NO_CLIENT_ADDRESS, parseProxyTrust, type ProxyTrust } from "./client-key.js";
15
15
  export { checkLeadStore } from "./boot.js";
16
16
  export { leadWebhook, panelChannel, type BuildWebhookBody, type LeadWebhook, type LeadWebhookContext, type LeadWebhookOptions } from "./lead-webhook.js";
17
- export { checkWebhookUrl, openWebhookOutbox, WEBHOOK_MAX_ATTEMPTS, WEBHOOK_TICK_MS, type OutboxRow, type OutboxState, type TickReport, type WebhookOutbox, type WebhookOutboxOptions, type WebhookTarget, } from "./webhook-outbox.js";
17
+ export { checkWebhookUrl, openWebhookOutbox, WEBHOOK_HORIZON_MS, WEBHOOK_MAX_ATTEMPTS, WEBHOOK_MAX_DELAY_MS, WEBHOOK_TICK_MS, type DeadRow, type OutboxRow, type OutboxState, type TickReport, type WebhookOutbox, type WebhookOutboxOptions, type WebhookTarget, } from "./webhook-outbox.js";
18
18
  export { signatureHeaders, signWebhook, type WebhookSignatureHeaders, type WebhookSigning } from "./webhook-signature.js";
@@ -14,5 +14,5 @@ export { defaultLeadMail, leadNotifier, MAIL_PER_MINUTE } from "./notify.js";
14
14
  export { clientKey, NO_CLIENT_ADDRESS, parseProxyTrust } from "./client-key.js";
15
15
  export { checkLeadStore } from "./boot.js";
16
16
  export { leadWebhook, panelChannel } from "./lead-webhook.js";
17
- export { checkWebhookUrl, openWebhookOutbox, WEBHOOK_MAX_ATTEMPTS, WEBHOOK_TICK_MS, } from "./webhook-outbox.js";
17
+ export { checkWebhookUrl, openWebhookOutbox, WEBHOOK_HORIZON_MS, WEBHOOK_MAX_ATTEMPTS, WEBHOOK_MAX_DELAY_MS, WEBHOOK_TICK_MS, } from "./webhook-outbox.js";
18
18
  export { signatureHeaders, signWebhook } from "./webhook-signature.js";
@@ -1,5 +1,5 @@
1
1
  import "server-only";
2
- import { type LeadStore } from "../core/lead.js";
2
+ import { type Lead, type LeadStore } from "../core/lead.js";
3
3
  /**
4
4
  * The SQLite adapter of the `LeadStore` port: one file on the pod's volume.
5
5
  *
@@ -9,6 +9,10 @@ import { type LeadStore } from "../core/lead.js";
9
9
  * nothing — and map to the lead's `subject`, `locality` and `mobile`.
10
10
  */
11
11
  export interface SqliteLeadStore extends LeadStore {
12
+ findSubmission(submissionId: string): Promise<{
13
+ id: number;
14
+ lead: Lead;
15
+ } | null>;
12
16
  /** The schema version the file is at after opening. */
13
17
  version(): number;
14
18
  }
@@ -50,6 +50,13 @@ const STEPS = [
50
50
  db.exec("ALTER TABLE leads ADD COLUMN consent_at TEXT");
51
51
  db.exec("ALTER TABLE leads ADD COLUMN consent_text TEXT");
52
52
  },
53
+ // 6 — the script's id for a submission, so a resend after a lost answer is
54
+ // the same row. Unique where present; a plain post has none, and NULLs
55
+ // never collide.
56
+ db => {
57
+ db.exec("ALTER TABLE leads ADD COLUMN submission_id TEXT");
58
+ db.exec("CREATE UNIQUE INDEX leads_submission_id ON leads (submission_id) WHERE submission_id IS NOT NULL");
59
+ },
53
60
  ];
54
61
  if (STEPS.length !== LEAD_SCHEMA_VERSION) {
55
62
  // The version belongs to the port, shared by every adapter; this table of
@@ -57,6 +64,29 @@ if (STEPS.length !== LEAD_SCHEMA_VERSION) {
57
64
  throw new Error(`leads: ${STEPS.length} sqlite steps for schema version ${LEAD_SCHEMA_VERSION}`);
58
65
  }
59
66
  const RUST_COLUMNS = ["id", "job", "zip", "mobile", "at"];
67
+ function str(row, key) {
68
+ const value = column(row, key);
69
+ return typeof value === "string" ? value : null;
70
+ }
71
+ const VERDICTS = ["honeypot", "too-fast", "rate-limited"];
72
+ /** A stored row read back as the lead it was written from. */
73
+ function leadOf(row) {
74
+ const verdict = str(row, "spam_verdict");
75
+ const extras = JSON.parse(str(row, "extras") ?? "{}");
76
+ const consentAt = str(row, "consent_at");
77
+ const submissionId = str(row, "submission_id");
78
+ return {
79
+ subject: str(row, "job") ?? "",
80
+ locality: str(row, "zip") ?? "",
81
+ mobile: str(row, "mobile") ?? "",
82
+ extras: Object.fromEntries(Object.entries(typeof extras === "object" && extras !== null ? extras : {}).filter((e) => typeof e[1] === "string")),
83
+ placeSlug: str(row, "location_id"),
84
+ spamVerdict: verdict !== null && VERDICTS.includes(verdict) ? verdict : null,
85
+ channel: str(row, "channel") === "callback" ? "callback" : "form",
86
+ ...(consentAt !== null ? { consent: { at: consentAt, text: str(row, "consent_text") ?? "" } } : {}),
87
+ ...(submissionId !== null ? { submissionId } : {}),
88
+ };
89
+ }
60
90
  function userVersion(db) {
61
91
  return integer(db.prepare("PRAGMA user_version").get(), "user_version");
62
92
  }
@@ -138,12 +168,17 @@ export function openSqliteLeadStore(path) {
138
168
  db.close();
139
169
  throw error;
140
170
  }
141
- const insert = db.prepare("INSERT INTO leads (job, zip, mobile, location_id, spam_verdict, extras, channel, consent_at, consent_text) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id");
171
+ const insert = db.prepare("INSERT INTO leads (job, zip, mobile, location_id, spam_verdict, extras, channel, consent_at, consent_text, submission_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id");
172
+ const bySubmission = db.prepare("SELECT id, job, zip, mobile, location_id, spam_verdict, extras, channel, consent_at, consent_text, submission_id FROM leads WHERE submission_id = ?");
142
173
  const count = db.prepare("SELECT COUNT(*) AS n FROM leads");
143
174
  return {
144
175
  async insert(lead) {
145
176
  const extras = Object.keys(lead.extras).length > 0 ? JSON.stringify(lead.extras) : null;
146
- return integer(insert.get(lead.subject, lead.locality, lead.mobile, lead.placeSlug, lead.spamVerdict, extras, channelOf(lead), lead.consent?.at ?? null, lead.consent?.text ?? null), "id");
177
+ return integer(insert.get(lead.subject, lead.locality, lead.mobile, lead.placeSlug, lead.spamVerdict, extras, channelOf(lead), lead.consent?.at ?? null, lead.consent?.text ?? null, lead.submissionId ?? null), "id");
178
+ },
179
+ async findSubmission(submissionId) {
180
+ const row = bySubmission.get(submissionId);
181
+ return row === undefined ? null : { id: integer(row, "id"), lead: leadOf(row) };
147
182
  },
148
183
  async count() {
149
184
  return integer(count.get(), "n");
@@ -1,5 +1,5 @@
1
1
  import "server-only";
2
- import type { Lead, LeadChannel } from "../core/lead.js";
2
+ import { type Lead, type LeadChannel, type LeadSuspect } from "../core/lead.js";
3
3
  import type { ServerEnv } from "./env.js";
4
4
  import { type TickReport, type WebhookOutbox, type WebhookOutboxOptions } from "./webhook-outbox.js";
5
5
  import type { WebhookSigning } from "./webhook-signature.js";
@@ -14,6 +14,12 @@ export interface LeadWebhookContext {
14
14
  at: Date;
15
15
  /** Fresh per lead and stored with the body, so every retry carries the same one. */
16
16
  idempotencyKey: string;
17
+ /**
18
+ * Why the lead is suspect (`suspectOf`), for the panel's `suspect`
19
+ * property — only under `panelSuspect`; otherwise always absent, and the
20
+ * body must not carry the property at all.
21
+ */
22
+ suspect?: LeadSuspect;
17
23
  }
18
24
  /**
19
25
  * What the Service-Arb panel's `lead.created` calls a callback request. Its
@@ -41,12 +47,25 @@ export interface LeadWebhook {
41
47
  start(intervalMs?: number): void;
42
48
  stop(): void;
43
49
  close(): void;
50
+ /** The outbox's `requeueDead`: this target's dead rows back in the queue. */
51
+ requeueDead(): number;
52
+ /** Whether a suspect lead is queued, marked (`LeadWebhookOptions.panelSuspect`). */
53
+ readonly panelSuspect: boolean;
44
54
  readonly outbox: WebhookOutbox;
45
55
  }
46
56
  export interface LeadWebhookOptions extends WebhookOutboxOptions {
47
57
  /** Absent → the webhook is off, whatever the environment says. */
48
58
  buildBody?: BuildWebhookBody | undefined;
49
59
  signing: WebhookSigning;
60
+ /**
61
+ * The one switch for the panel's `suspect` marker. Off (the default) until
62
+ * the panel's `lead.created` accepts the property — it refuses an unknown
63
+ * one, and the outbox would park the lead: a rate-limited lead stays in the
64
+ * table and `ctx.suspect` stays absent, as before. On: a rate-limited lead
65
+ * is queued too, and every queued lead's `ctx.suspect` says why it is
66
+ * suspect, for the brand's body to carry.
67
+ */
68
+ panelSuspect?: boolean;
50
69
  }
51
70
  /**
52
71
  * The lead webhook, or `null` when it is off: no `LEAD_WEBHOOK_URL`, or no