@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.
- package/README.md +89 -23
- package/dist/cli/kitstart-outbox.d.ts +2 -0
- package/dist/cli/kitstart-outbox.js +10 -0
- package/dist/cli/outbox.d.ts +5 -0
- package/dist/cli/outbox.js +77 -0
- package/dist/core/accept.d.ts +22 -3
- package/dist/core/accept.js +40 -13
- package/dist/core/analytics.d.ts +3 -1
- package/dist/core/analytics.js +7 -0
- package/dist/core/lead-capture-text.d.ts +24 -1
- package/dist/core/lead-capture-text.js +20 -0
- package/dist/core/lead.d.ts +71 -10
- package/dist/core/lead.js +65 -11
- package/dist/core/phone.d.ts +20 -9
- package/dist/core/phone.js +43 -19
- package/dist/core/status.d.ts +13 -0
- package/dist/core/status.js +19 -2
- package/dist/index.d.ts +3 -3
- package/dist/index.js +3 -3
- package/dist/next/quote-route.d.ts +15 -2
- package/dist/next/quote-route.js +70 -10
- package/dist/react/LeadCapture.d.ts +7 -5
- package/dist/react/LeadCapture.js +18 -6
- package/dist/react/LeadCaptureChannels.d.ts +14 -1
- package/dist/react/LeadCaptureChannels.js +8 -5
- package/dist/react/LeadCaptureFields.d.ts +39 -7
- package/dist/react/LeadCaptureFields.js +91 -21
- package/dist/react/LeadCaptureNeed.d.ts +5 -0
- package/dist/react/LeadCaptureNeed.js +38 -2
- package/dist/react/LeadCaptureSubmit.d.ts +16 -0
- package/dist/react/LeadCaptureSubmit.js +13 -0
- package/dist/react/QuoteFormShell.d.ts +2 -0
- package/dist/react/QuoteFormShell.js +3 -2
- package/dist/react/use-lead-error.d.ts +24 -0
- package/dist/react/use-lead-error.js +73 -0
- package/dist/react/use-lead-events.d.ts +3 -0
- package/dist/react/use-lead-events.js +3 -2
- package/dist/react/use-lead-submit.d.ts +44 -9
- package/dist/react/use-lead-submit.js +122 -13
- package/dist/react/use-validity.d.ts +17 -0
- package/dist/react/use-validity.js +38 -0
- package/dist/server/env.d.ts +9 -0
- package/dist/server/env.js +12 -0
- package/dist/server/index.d.ts +1 -1
- package/dist/server/index.js +1 -1
- package/dist/server/lead-store-sqlite.d.ts +5 -1
- package/dist/server/lead-store-sqlite.js +37 -2
- package/dist/server/lead-webhook.d.ts +20 -1
- package/dist/server/lead-webhook.js +6 -2
- package/dist/server/webhook-outbox.d.ts +27 -1
- package/dist/server/webhook-outbox.js +33 -6
- package/dist/testing/index.d.ts +1 -0
- package/dist/testing/index.js +1 -0
- package/dist/testing/landing-contract.js +6 -0
- package/dist/testing/lead-rules.d.ts +18 -0
- package/dist/testing/lead-rules.js +50 -0
- package/dist/testing/lead-store-contract.js +20 -0
- package/package.json +3 -2
- package/template/flake.nix +1 -1
- package/template/package.json +1 -1
- package/template/src/shared/config/lead.ts +6 -3
- 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:
|
|
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
|
-
*
|
|
12
|
-
*
|
|
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
|
|
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
|
-
}
|
|
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
|
-
*
|
|
7
|
-
*
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
119
|
+
settle();
|
|
37
120
|
form.submit();
|
|
38
121
|
};
|
|
39
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/dist/server/env.d.ts
CHANGED
|
@@ -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: {
|
package/dist/server/env.js
CHANGED
|
@@ -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. */
|
package/dist/server/index.d.ts
CHANGED
|
@@ -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";
|
package/dist/server/index.js
CHANGED
|
@@ -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
|
|
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
|