@evinvest/kitstart 0.8.0 → 0.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 (94) hide show
  1. package/README.md +178 -25
  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 +44 -3
  7. package/dist/core/accept.js +96 -15
  8. package/dist/core/analytics.d.ts +4 -1
  9. package/dist/core/analytics.js +14 -0
  10. package/dist/core/lead-capture-format.d.ts +6 -0
  11. package/dist/core/lead-capture-format.js +13 -0
  12. package/dist/core/lead-capture-text.d.ts +56 -2
  13. package/dist/core/lead-capture-text.js +63 -0
  14. package/dist/core/lead.d.ts +101 -11
  15. package/dist/core/lead.js +66 -11
  16. package/dist/core/phone.d.ts +20 -9
  17. package/dist/core/phone.js +43 -19
  18. package/dist/core/pricing/flow.d.ts +27 -0
  19. package/dist/core/pricing/flow.js +37 -0
  20. package/dist/core/pricing/index.d.ts +4 -0
  21. package/dist/core/pricing/index.js +4 -0
  22. package/dist/core/pricing/model.d.ts +129 -0
  23. package/dist/core/pricing/model.js +34 -0
  24. package/dist/core/pricing/price.d.ts +34 -0
  25. package/dist/core/pricing/price.js +105 -0
  26. package/dist/core/pricing/validate.d.ts +27 -0
  27. package/dist/core/pricing/validate.js +244 -0
  28. package/dist/core/site.d.ts +7 -0
  29. package/dist/core/site.js +11 -0
  30. package/dist/core/status.d.ts +13 -0
  31. package/dist/core/status.js +19 -2
  32. package/dist/index.d.ts +7 -6
  33. package/dist/index.js +7 -6
  34. package/dist/next/quote-route.d.ts +22 -2
  35. package/dist/next/quote-route.js +72 -10
  36. package/dist/react/LeadCapture.d.ts +37 -7
  37. package/dist/react/LeadCapture.js +34 -9
  38. package/dist/react/LeadCaptureChannels.d.ts +28 -3
  39. package/dist/react/LeadCaptureChannels.js +21 -5
  40. package/dist/react/LeadCaptureEstimate.d.ts +46 -0
  41. package/dist/react/LeadCaptureEstimate.js +69 -0
  42. package/dist/react/LeadCaptureFields.d.ts +39 -7
  43. package/dist/react/LeadCaptureFields.js +91 -21
  44. package/dist/react/LeadCaptureNeed.d.ts +5 -0
  45. package/dist/react/LeadCaptureNeed.js +38 -2
  46. package/dist/react/LeadCapturePriced.d.ts +19 -0
  47. package/dist/react/LeadCapturePriced.js +14 -0
  48. package/dist/react/LeadCaptureSubmit.d.ts +16 -0
  49. package/dist/react/LeadCaptureSubmit.js +13 -0
  50. package/dist/react/QuoteFormShell.d.ts +2 -0
  51. package/dist/react/QuoteFormShell.js +3 -2
  52. package/dist/react/use-lead-error.d.ts +24 -0
  53. package/dist/react/use-lead-error.js +73 -0
  54. package/dist/react/use-lead-events.d.ts +5 -0
  55. package/dist/react/use-lead-events.js +13 -2
  56. package/dist/react/use-lead-submit.d.ts +48 -9
  57. package/dist/react/use-lead-submit.js +129 -13
  58. package/dist/react/use-validity.d.ts +17 -0
  59. package/dist/react/use-validity.js +38 -0
  60. package/dist/server/env.d.ts +9 -0
  61. package/dist/server/env.js +12 -0
  62. package/dist/server/index.d.ts +3 -2
  63. package/dist/server/index.js +3 -2
  64. package/dist/server/lead-store-sqlite.d.ts +5 -1
  65. package/dist/server/lead-store-sqlite.js +63 -2
  66. package/dist/server/lead-webhook.d.ts +68 -1
  67. package/dist/server/lead-webhook.js +34 -2
  68. package/dist/server/pricing-source.d.ts +25 -0
  69. package/dist/server/pricing-source.js +48 -0
  70. package/dist/server/webhook-outbox.d.ts +27 -1
  71. package/dist/server/webhook-outbox.js +33 -6
  72. package/dist/testing/index.d.ts +1 -0
  73. package/dist/testing/index.js +1 -0
  74. package/dist/testing/landing-contract.js +6 -0
  75. package/dist/testing/lead-rules.d.ts +18 -0
  76. package/dist/testing/lead-rules.js +50 -0
  77. package/dist/testing/lead-store-contract.js +28 -1
  78. package/package.json +3 -2
  79. package/template/app/[locale]/[location]/page.tsx +4 -2
  80. package/template/app/quote/route.ts +2 -1
  81. package/template/flake.nix +1 -1
  82. package/template/package.json +1 -1
  83. package/template/src/entities/content/model/en.ts +1 -1
  84. package/template/src/entities/content/model/fr.ts +1 -1
  85. package/template/src/shared/config/env.ts +4 -1
  86. package/template/src/shared/config/lead.ts +17 -4
  87. package/template/src/shared/config/pricing.ts +49 -0
  88. package/template/src/shared/config/site.ts +3 -0
  89. package/template/src/views/home/ui/PlaceHome.tsx +6 -3
  90. package/template/tests/bundle_budget.txt +3 -1
  91. package/template/tests/e2e/flows.spec.ts +106 -0
  92. package/template/tests/e2e/mock-panel.mjs +20 -0
  93. package/template/tests/e2e/playwright.config.ts +25 -9
  94. package/template/tests/e2e/quote-form.spec.ts +86 -1
@@ -1,6 +1,7 @@
1
1
  "use client";
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { Button, cn, Field, FieldLabel } from "@evinvest/uikit";
4
+ import { useEffect, useRef } from "react";
4
5
  import { FormSelect } from "./FormSelect.js";
5
6
  /**
6
7
  * What the job is. A need already known — from the page, `?need=` or the card
@@ -10,7 +11,16 @@ import { FormSelect } from "./FormSelect.js";
10
11
  * per need: one tap answers it.
11
12
  */
12
13
  export function NeedField(props) {
13
- const { layout, name, needs, need, editing, hydrated, label, changeLabel, onPick, onEdit, classNames: c } = props;
14
+ const { layout, name, needs, need, editing, hydrated, label, changeLabel, onPick, onSelect, onEdit, classNames: c } = props;
15
+ const group = useRef(null);
16
+ // A radio group answers arrows with a click (`detail` 0) and a change, the
17
+ // same events a tap makes: what moves on is told apart by its key or pointer.
18
+ const pointer = useRef(false);
19
+ const answered = useRef(false);
20
+ useEffect(() => {
21
+ for (const radio of group.current?.querySelectorAll("input[type=radio]") ?? [])
22
+ radio.setCustomValidity(need === undefined ? props.requiredText : "");
23
+ });
14
24
  const chosen = needs.find(n => n.value === need);
15
25
  if (chosen && !editing) {
16
26
  return (_jsxs("div", { className: cn("flex flex-wrap items-center gap-x-3 gap-y-1 text-ink", c?.summary), children: [_jsx("input", { type: "hidden", name: name, value: chosen.value }), _jsx("span", { className: "text-sm text-ink-soft", children: label }), _jsx("span", { className: "font-medium", children: chosen.label }), hydrated && (_jsx(Button, { type: "button", variant: "link", size: "touch", className: "px-1", onClick: onEdit, children: changeLabel }))] }));
@@ -18,5 +28,31 @@ export function NeedField(props) {
18
28
  if (layout === "single") {
19
29
  return (_jsxs(Field, { className: cn("flex flex-col gap-2", c?.field), children: [_jsx(FieldLabel, { className: c?.label, children: label }), _jsx(FormSelect, { name: name, size: "lg", defaultValue: need ?? needs[0]?.value, options: needs, onValueChange: onPick, classNames: c?.control ? { trigger: c.control } : undefined })] }));
20
30
  }
21
- return (_jsxs("fieldset", { className: cn("flex flex-col gap-2", c?.field), children: [_jsx("legend", { className: cn("mb-2 text-sm font-medium text-ink", c?.label), children: label }), _jsx("div", { className: cn("grid grid-cols-1 gap-2 sm:grid-cols-2", c?.needs), children: needs.map(n => (_jsxs("label", { className: "relative block cursor-pointer", children: [_jsx("input", { type: "radio", name: name, value: n.value, required: true, checked: need === n.value, onChange: () => onPick(n.value), className: "peer absolute inset-0 opacity-0", "data-need-option": "", "data-lead-field": "need" }), _jsx("span", { className: cn("flex min-h-11 items-center rounded-[var(--control-radius)] border border-input px-4 py-3 text-ink transition-colors", "peer-checked:border-primary peer-checked:bg-hover peer-focus-visible:ring-[3px] peer-focus-visible:ring-ring/50", c?.need), children: n.label })] }, n.value))) })] }));
31
+ return (_jsxs("fieldset", { ref: group, className: cn("flex flex-col gap-2", c?.field), children: [_jsx("legend", { className: cn("mb-2 text-sm font-medium text-ink", c?.label), children: label }), _jsx("div", { className: cn("grid grid-cols-1 gap-2 sm:grid-cols-2", c?.needs), children: needs.map(n => (_jsxs("label", { className: "relative block cursor-pointer", children: [_jsx("input", { type: "radio", name: name, value: n.value, required: true, checked: need === n.value, onPointerDown: () => {
32
+ pointer.current = true;
33
+ }, onKeyDown: (e) => {
34
+ pointer.current = false;
35
+ answered.current = false;
36
+ if (e.key !== "Enter")
37
+ return;
38
+ // From a radio, Enter would submit the form half filled.
39
+ e.preventDefault();
40
+ answered.current = true;
41
+ onPick(n.value);
42
+ }, onKeyUp: (e) => {
43
+ if (e.key !== " ")
44
+ return;
45
+ answered.current = true;
46
+ onPick(n.value);
47
+ }, onClick: (e) => {
48
+ if (e.detail === 0 && !pointer.current)
49
+ return;
50
+ pointer.current = false;
51
+ answered.current = true;
52
+ onPick(n.value);
53
+ }, onChange: () => {
54
+ if (!answered.current)
55
+ onSelect(n.value);
56
+ answered.current = false;
57
+ }, className: "peer absolute inset-0 opacity-0", "data-need-option": "", "data-lead-field": "need" }), _jsx("span", { className: cn("flex min-h-11 items-center rounded-[var(--control-radius)] border border-input px-4 py-3 text-ink transition-colors", "peer-checked:border-primary peer-checked:bg-hover peer-focus-visible:ring-[3px] peer-focus-visible:ring-ring/50", c?.need), children: n.label })] }, n.value))) })] }));
22
58
  }
@@ -0,0 +1,19 @@
1
+ import type { ReactNode } from "react";
2
+ import type { LeadCaptureFlowText } from "../core/lead-capture-text.js";
3
+ import type { LeadSent } from "./use-lead-submit.js";
4
+ import type { PartClassNames } from "./parts.js";
5
+ export type PricedPart = "priced" | "pricedPrice" | "pricedNote";
6
+ /**
7
+ * After a priced lead (`estimate`, `fixed`): the price the server took it at,
8
+ * then how the slot is set — by a call, until a booking provider is chosen;
9
+ * a brand's `booking` (its own scheduling widget, say) stands in for that
10
+ * promise. The lead is stored before any of this, so an abandoned booking is
11
+ * still a lead to call.
12
+ */
13
+ export declare function LeadCapturePriced(props: {
14
+ sent: LeadSent;
15
+ booking: ReactNode;
16
+ locale: string;
17
+ text: LeadCaptureFlowText;
18
+ classNames?: PartClassNames<PricedPart> | undefined;
19
+ }): import("react").JSX.Element;
@@ -0,0 +1,14 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { cn } from "@evinvest/uikit";
3
+ import { fillText, formatCents } from "../core/lead-capture-format.js";
4
+ /**
5
+ * After a priced lead (`estimate`, `fixed`): the price the server took it at,
6
+ * then how the slot is set — by a call, until a booking provider is chosen;
7
+ * a brand's `booking` (its own scheduling widget, say) stands in for that
8
+ * promise. The lead is stored before any of this, so an abandoned booking is
9
+ * still a lead to call.
10
+ */
11
+ export function LeadCapturePriced(props) {
12
+ const { sent, locale, text, classNames: c } = props;
13
+ return (_jsxs("div", { className: cn("flex flex-col gap-3", c?.priced), children: [sent.cents !== undefined && _jsx("p", { className: cn("font-medium text-ink", c?.pricedPrice), children: fillText(text.sentPrice, { price: formatCents(sent.cents, locale) }) }), props.booking ?? _jsx("p", { className: cn("text-ink", c?.pricedNote), children: text.slotCallback })] }));
14
+ }
@@ -0,0 +1,16 @@
1
+ import type { LeadCaptureText } from "../core/lead-capture-text.js";
2
+ import type { SendFailure } from "./use-lead-submit.js";
3
+ /** The submit, busy while the script posts: disabled, `aria-busy`, and saying so. */
4
+ export declare function SubmitButton(props: {
5
+ busy: boolean;
6
+ label: string;
7
+ sending: string;
8
+ className: string | undefined;
9
+ }): import("react").JSX.Element;
10
+ /** Why the post got no answer, and the way to send the same lead again. */
11
+ export declare function FailureMessage(props: {
12
+ failure: SendFailure | null;
13
+ text: Pick<LeadCaptureText, "networkError" | "timeoutError" | "retry">;
14
+ onRetry: () => void;
15
+ className: string | undefined;
16
+ }): import("react").JSX.Element | null;
@@ -0,0 +1,13 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Button, cn, FieldError } from "@evinvest/uikit";
3
+ /** The submit, busy while the script posts: disabled, `aria-busy`, and saying so. */
4
+ export function SubmitButton(props) {
5
+ return (_jsx(Button, { type: "submit", size: "touch", disabled: props.busy, "aria-busy": props.busy || undefined, className: cn("w-full", props.className), children: props.busy ? props.sending : props.label }));
6
+ }
7
+ /** Why the post got no answer, and the way to send the same lead again. */
8
+ export function FailureMessage(props) {
9
+ const { failure, text } = props;
10
+ if (!failure)
11
+ return null;
12
+ return (_jsxs(FieldError, { className: cn("flex flex-wrap items-center gap-x-3 gap-y-1 text-accent-error", props.className), children: [_jsx("span", { children: failure === "timeout" ? text.timeoutError : text.networkError }), _jsx(Button, { type: "button", variant: "link", size: "touch", className: "px-1", onClick: props.onRetry, children: text.retry })] }));
13
+ }
@@ -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,12 @@ 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;
11
+ /** An estimate's price for a full set of answers: its band only, once per need and band. */
12
+ estimateShown(need: string, cents: number): void;
8
13
  }
9
14
  /**
10
15
  * The lead form's own funnel, reported through the boundary's sink: seen
@@ -1,6 +1,7 @@
1
1
  "use client";
2
2
  import { useEffect, useMemo, useRef } from "react";
3
3
  import { EVENTS, experimentProps } from "../core/analytics.js";
4
+ import { centsBucket } from "../core/pricing/price.js";
4
5
  import { useAnalyticsSink } from "./analytics-context.js";
5
6
  /** The attribute a lead form's control names its role in, for `lead_form_field_error`. */
6
7
  export const FIELD_ATTR = "data-lead-field";
@@ -18,6 +19,7 @@ export function useLeadEvents(root, tags) {
18
19
  const variant = tags.experiment?.variant;
19
20
  const props = useMemo(() => ({ form_id: formId, layout, ...experimentProps(name, variant) }), [formId, layout, name, variant]);
20
21
  const started = useRef(false);
22
+ const shown = useRef(new Set());
21
23
  useEffect(() => {
22
24
  const el = root.current;
23
25
  if (!sink || !el)
@@ -45,7 +47,7 @@ export function useLeadEvents(root, tags) {
45
47
  const invalid = (event) => {
46
48
  const field = event.target instanceof Element ? event.target.getAttribute(FIELD_ATTR) : null;
47
49
  if (field)
48
- sink.capture(EVENTS.fieldError, { ...props, field });
50
+ sink.capture(EVENTS.fieldError, { ...props, field, blocking: true });
49
51
  };
50
52
  el.addEventListener("focusin", focus);
51
53
  el.addEventListener("invalid", invalid, true);
@@ -57,6 +59,15 @@ export function useLeadEvents(root, tags) {
57
59
  }, [sink, root, props]);
58
60
  return useMemo(() => ({
59
61
  step: step => sink?.capture(EVENTS.formStep, { ...props, step }),
60
- fieldError: field => sink?.capture(EVENTS.fieldError, { ...props, field }),
62
+ fieldError: field => sink?.capture(EVENTS.fieldError, { ...props, field, blocking: false }),
63
+ submitError: (reason, channel) => sink?.capture(EVENTS.submitError, { ...props, reason, channel }),
64
+ estimateShown: (need, cents) => {
65
+ const cents_bucket = centsBucket(cents);
66
+ const key = `${need}\n${cents_bucket}`;
67
+ if (shown.current.has(key))
68
+ return;
69
+ shown.current.add(key);
70
+ sink?.capture(EVENTS.estimateShown, { ...props, need, cents_bucket });
71
+ },
61
72
  }), [sink, props]);
62
73
  }
@@ -1,21 +1,60 @@
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;
10
+ /** The lead's public reference, as the route answered it (`leadRef`), for a `booking` to name the lead. */
11
+ lead?: string;
12
+ /** The price the server took the lead at, for an `estimate` or `fixed` need. */
13
+ cents?: number;
9
14
  }
15
+ /** Leaving the page, behind a seam a test can stand in for (jsdom cannot navigate). */
16
+ export declare const navigation: {
17
+ assign: (url: string) => void;
18
+ };
10
19
  /**
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.
20
+ * A v4 UUID. `randomUUID` exists only in a secure context; a LAN dev server
21
+ * over plain http is not one, but `getRandomValues` works everywhere.
17
22
  */
18
- export declare function useLeadSubmit(enabled: boolean, fields: {
23
+ export declare function newSubmissionId(): string;
24
+ /** Why the script's post got no answer: none came back (`network`), or none in time (`timeout`). */
25
+ export type SendFailure = "network" | "timeout";
26
+ /** How long the script waits for `/quote` before it says so; the lead is a few short fields. */
27
+ export declare const SUBMIT_TIMEOUT_MS = 15000;
28
+ export interface LeadSubmitHandlers {
29
+ /** The route refused the lead: the field to fix. */
30
+ onRefused: (channel: LeadChannel, field: string) => void;
31
+ /** No answer: the form stays as typed, with a retry. */
32
+ onFailed: (channel: LeadChannel, failure: SendFailure) => void;
33
+ }
34
+ export interface LeadSubmit {
35
+ sent: LeadSent | null;
36
+ onSubmit: FormEventHandler<HTMLFormElement>;
37
+ /** The form being posted, while it is. */
38
+ busy: LeadChannel | null;
39
+ /** The last post that got no answer, until the next one. */
40
+ failure: {
41
+ channel: LeadChannel;
42
+ failure: SendFailure;
43
+ } | null;
44
+ /** Posts that form again — the same submission id, so never a second lead. */
45
+ retry: () => void;
46
+ }
47
+ /**
48
+ * The lead posted by a script, so a refusal keeps what was typed: the body
49
+ * and the endpoint are the form's own, asked for JSON. Taken → the in-card
50
+ * success when the card has one for that channel (`done`), else the thanks page the route
51
+ * names. Refused → `onRefused` with the field, the form as it was. No answer
52
+ * — no network, or nothing in `SUBMIT_TIMEOUT_MS` — is said in place with a
53
+ * retry, never by leaving the page: the browser's error page would lose the
54
+ * form. Any other answer (the store's 500) submits the form for real, so the
55
+ * server's own page says what went wrong; the submission id makes that safe.
56
+ */
57
+ export declare function useLeadSubmit(done: boolean | ((channel: LeadChannel) => boolean), fields: {
19
58
  mobile: string;
20
59
  name: string | undefined;
21
- }): [LeadSent | null, FormEventHandler<HTMLFormElement> | undefined];
60
+ }, on: LeadSubmitHandlers): LeadSubmit;
@@ -1,26 +1,110 @@
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
+ const LEAD_REF = /^lead-\d+-[0-9a-f]{8}$/;
45
+ /** A root-relative path on this origin — never `//elsewhere`. */
46
+ const OWN_PATH = /^\/(?!\/)/;
47
+ /**
48
+ * The route's answer to a script (`quoteRoute`): JSON when it asked for it,
49
+ * or — through a proxy that rewrote nothing but followed the 303 — the
50
+ * thanks page itself. Anything else is not an answer: `null`.
51
+ */
52
+ async function readAnswer(res) {
53
+ if ((res.ok || res.status === 422) && (res.headers.get("content-type") ?? "").includes("application/json")) {
54
+ // Not JSON is no answer; a body cut off (the network, the timeout) is no answer either — thrown on.
55
+ const body = await res.json().catch((error) => {
56
+ if (error instanceof SyntaxError)
57
+ return null;
58
+ throw error;
59
+ });
60
+ if (typeof body !== "object" || body === null)
61
+ return null;
62
+ const { ok, location, field, lead, cents } = body;
63
+ if (ok === true && typeof location === "string" && OWN_PATH.test(location)) {
64
+ return {
65
+ ok,
66
+ location,
67
+ ...(typeof lead === "string" && LEAD_REF.test(lead) ? { lead } : {}),
68
+ ...(typeof cents === "number" && Number.isSafeInteger(cents) && cents >= 0 ? { cents } : {}),
69
+ };
70
+ }
71
+ if (ok === false && typeof field === "string")
72
+ return { ok, field };
73
+ return null;
74
+ }
75
+ if (res.ok && res.redirected) {
76
+ const url = new URL(res.url);
77
+ if (url.pathname.endsWith(THANKS))
78
+ return { ok: true, location: `${url.pathname}${url.search}` };
79
+ }
80
+ return null;
81
+ }
82
+ /** How long the script waits for `/quote` before it says so; the lead is a few short fields. */
83
+ export const SUBMIT_TIMEOUT_MS = 15_000;
84
+ /**
85
+ * The lead posted by a script, so a refusal keeps what was typed: the body
86
+ * and the endpoint are the form's own, asked for JSON. Taken → the in-card
87
+ * success when the card has one for that channel (`done`), else the thanks page the route
88
+ * names. Refused → `onRefused` with the field, the form as it was. No answer
89
+ * — no network, or nothing in `SUBMIT_TIMEOUT_MS` — is said in place with a
90
+ * retry, never by leaving the page: the browser's error page would lose the
91
+ * form. Any other answer (the store's 500) submits the form for real, so the
92
+ * server's own page says what went wrong; the submission id makes that safe.
93
+ */
94
+ export function useLeadSubmit(done, fields, on) {
14
95
  const [sent, setSent] = useState(null);
96
+ const [busy, setBusy] = useState(null);
97
+ const [failure, setFailure] = useState(null);
15
98
  const pending = useRef(false);
16
- if (!enabled)
17
- return [sent, undefined];
99
+ const last = useRef(null);
18
100
  const submit = (event) => {
19
101
  event.preventDefault();
20
102
  if (pending.current)
21
103
  return;
22
104
  pending.current = true;
23
105
  const form = event.currentTarget;
106
+ last.current = form;
107
+ stamp(form);
24
108
  const data = new FormData(form);
25
109
  const body = new URLSearchParams();
26
110
  for (const [key, value] of data)
@@ -31,12 +115,44 @@ export function useLeadSubmit(enabled, fields) {
31
115
  return typeof value === "string" ? value.trim() : "";
32
116
  };
33
117
  const lead = { channel: read(CHANNEL_FIELD) === "callback" ? "callback" : "form", phone: read(fields.mobile), name: read(fields.name) || null };
118
+ setBusy(lead.channel);
119
+ setFailure(null);
120
+ const settle = () => {
121
+ pending.current = false;
122
+ setBusy(null);
123
+ };
34
124
  // `form.submit()` neither validates again nor comes back here.
35
125
  const fallBack = () => {
36
- pending.current = false;
126
+ settle();
37
127
  form.submit();
38
128
  };
39
- fetch(form.action, { method: "POST", body }).then(res => (res.ok && res.redirected && new URL(res.url).pathname.endsWith(THANKS) ? setSent(lead) : fallBack()), fallBack);
129
+ // A controller and a timer rather than `AbortSignal.timeout`: one clock the page (and a test) controls.
130
+ const controller = new AbortController();
131
+ const timer = setTimeout(() => controller.abort(new DOMException("no answer in time", "TimeoutError")), SUBMIT_TIMEOUT_MS);
132
+ fetch(form.action, { method: "POST", body, headers: { Accept: "application/json" }, signal: controller.signal })
133
+ .then(readAnswer)
134
+ .then(answer => {
135
+ if (answer === null)
136
+ return fallBack();
137
+ if (!answer.ok) {
138
+ settle();
139
+ return on.onRefused(lead.channel, answer.field);
140
+ }
141
+ // Still pending: the page is leaving, or the form is gone. The next
142
+ // lead from this form (Back, another need) gets an id of its own.
143
+ minted.delete(form);
144
+ if (typeof done === "function" ? done(lead.channel) : done)
145
+ setSent({ ...lead, ...(answer.lead ? { lead: answer.lead } : {}), ...(answer.cents !== undefined ? { cents: answer.cents } : {}) });
146
+ else
147
+ navigation.assign(answer.location);
148
+ }, () => {
149
+ settle();
150
+ const kind = controller.signal.aborted ? "timeout" : "network";
151
+ setFailure({ channel: lead.channel, failure: kind });
152
+ on.onFailed(lead.channel, kind);
153
+ })
154
+ .finally(() => clearTimeout(timer));
40
155
  };
41
- return [sent, submit];
156
+ const retry = () => last.current?.requestSubmit();
157
+ return { sent, onSubmit: submit, busy, failure, retry };
42
158
  }
@@ -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: {