@evinvest/kitstart 0.5.0 → 0.6.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 (63) hide show
  1. package/README.md +110 -3
  2. package/dist/cli/size.d.ts +26 -4
  3. package/dist/cli/size.js +54 -16
  4. package/dist/core/accept.d.ts +9 -1
  5. package/dist/core/accept.js +13 -2
  6. package/dist/core/analytics.d.ts +22 -3
  7. package/dist/core/analytics.js +27 -2
  8. package/dist/core/channels.d.ts +47 -0
  9. package/dist/core/channels.js +51 -0
  10. package/dist/core/lead-capture-format.d.ts +6 -0
  11. package/dist/core/lead-capture-format.js +32 -0
  12. package/dist/core/lead-capture-text.d.ts +50 -0
  13. package/dist/core/lead-capture-text.js +57 -0
  14. package/dist/core/lead.d.ts +59 -4
  15. package/dist/core/lead.js +38 -4
  16. package/dist/core/phone.d.ts +19 -0
  17. package/dist/core/phone.js +39 -0
  18. package/dist/core/place/hours.d.ts +15 -0
  19. package/dist/core/place/hours.js +68 -0
  20. package/dist/core/place/index.d.ts +1 -0
  21. package/dist/core/place/index.js +1 -0
  22. package/dist/index.d.ts +8 -4
  23. package/dist/index.js +8 -4
  24. package/dist/next/quote-route.d.ts +1 -1
  25. package/dist/next/quote-route.js +4 -1
  26. package/dist/react/AnalyticsBoundary.js +11 -5
  27. package/dist/react/CallBar.d.ts +17 -2
  28. package/dist/react/CallBar.js +26 -3
  29. package/dist/react/LeadCapture.d.ts +61 -0
  30. package/dist/react/LeadCapture.js +60 -0
  31. package/dist/react/LeadCaptureChannels.d.ts +54 -0
  32. package/dist/react/LeadCaptureChannels.js +37 -0
  33. package/dist/react/LeadCaptureFields.d.ts +32 -0
  34. package/dist/react/LeadCaptureFields.js +42 -0
  35. package/dist/react/LeadCaptureNeed.d.ts +27 -0
  36. package/dist/react/LeadCaptureNeed.js +22 -0
  37. package/dist/react/analytics-context.d.ts +11 -0
  38. package/dist/react/analytics-context.js +15 -0
  39. package/dist/react/index.d.ts +3 -1
  40. package/dist/react/index.js +2 -1
  41. package/dist/react/use-lead-context.d.ts +24 -0
  42. package/dist/react/use-lead-context.js +71 -0
  43. package/dist/react/use-lead-events.d.ts +23 -0
  44. package/dist/react/use-lead-events.js +62 -0
  45. package/dist/server/index.d.ts +1 -1
  46. package/dist/server/index.js +1 -1
  47. package/dist/server/lead-store-sqlite.js +12 -4
  48. package/dist/server/lead-webhook.d.ts +12 -1
  49. package/dist/server/lead-webhook.js +12 -0
  50. package/dist/server/notify.d.ts +1 -1
  51. package/dist/server/notify.js +5 -2
  52. package/dist/testing/lead-store-contract.js +4 -2
  53. package/package.json +1 -1
  54. package/template/README.md +7 -4
  55. package/template/flake.nix +1 -1
  56. package/template/package.json +1 -1
  57. package/template/src/entities/content/model/en.ts +2 -2
  58. package/template/src/entities/content/model/fr.ts +2 -2
  59. package/template/src/entities/content/model/types.ts +3 -2
  60. package/template/src/shared/config/lead.ts +2 -0
  61. package/template/src/views/home/ui/PlaceHome.tsx +25 -40
  62. package/template/tests/bundle_budget.txt +8 -3
  63. package/template/tests/e2e/quote-form.spec.ts +86 -4
@@ -0,0 +1,60 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { Button, cn } from "@evinvest/uikit";
4
+ import { useRef, useState } from "react";
5
+ import { resolveChannels } from "../core/channels.js";
6
+ import { fillText, openingText } from "../core/lead-capture-format.js";
7
+ import { servedLocalities } from "../core/place/types.js";
8
+ import { CallbackForm, ChannelLink, ExperimentFields } from "./LeadCaptureChannels.js";
9
+ import { LocalityField, NameField, PhoneField } from "./LeadCaptureFields.js";
10
+ import { NeedField } from "./LeadCaptureNeed.js";
11
+ import { QuoteFormShell } from "./QuoteFormShell.js";
12
+ import { useHydrated, useNeed, useNow, useOpenOnHash } from "./use-lead-context.js";
13
+ import { useLeadEvents } from "./use-lead-events.js";
14
+ /**
15
+ * The lead form every brand shares: the need (asked once, or not at all when
16
+ * the page knows it), the postcode, the phone, and the other ways to reach the
17
+ * business ordered by the place's hours. Over `QuoteFormShell`, so without a
18
+ * script it is the plain POST to `/quote` it always was.
19
+ */
20
+ export function LeadCapture(props) {
21
+ const { place, contact, locale, renderedAt, wire, needs, layout = "single", text, classNames: c } = props;
22
+ const { formId = "quote", id = "quote", experiment } = props;
23
+ const root = useRef(null);
24
+ const hydrated = useHydrated();
25
+ const [editing, setEditing] = useState(false);
26
+ const [need, setNeed] = useNeed(props.need, needs.map(n => n.value), () => setEditing(false));
27
+ const events = useLeadEvents(root, { formId, layout, experiment });
28
+ useOpenOnHash(`${id}-callback`);
29
+ const resolved = resolveChannels({ ...contact, hours: place.hours }, { now: new Date(useNow(renderedAt)), timeZone: props.timeZone, prefer: props.prefer });
30
+ const opening = openingText(resolved.nextOpening, text, locale);
31
+ const needLabel = needs.find(n => n.value === need)?.label;
32
+ const message = needLabel ? fillText(text.message, { need: needLabel }) : text.messageGeneric;
33
+ const [lead = "form", ...rest] = resolved.order;
34
+ const links = rest.filter((ch) => ch !== "form" && ch !== "callback");
35
+ const pick = (value) => {
36
+ if (layout === "single") {
37
+ setNeed(value);
38
+ setEditing(true);
39
+ return;
40
+ }
41
+ // The checked radio has already shown the contact step (`:has`, below), so
42
+ // the first empty field — the postcode unless filled, else the phone —
43
+ // takes focus inside the tap: iOS opens the keyboard only for a focus a
44
+ // gesture made, and a re-render is too late for it.
45
+ const fields = root.current?.querySelectorAll(`#${CSS.escape(id)} [data-lead-field="locality"], #${CSS.escape(id)} [data-lead-field="phone"]`);
46
+ [...(fields ?? [])].find(f => f.value === "")?.focus();
47
+ setNeed(value);
48
+ setEditing(false);
49
+ events.step("contact");
50
+ };
51
+ const channel = (ch, primary) => ch === "callback" ? (_jsx(CallbackForm, { id: `${id}-callback`, open: primary, formId: formId, placeSlug: place.slug, locale: locale, renderedAt: renderedAt, mobileName: wire.mobile, subject: need ? { name: wire.subject, value: need } : null, opening: opening, text: text, experiment: experiment, classNames: c }, ch)) : ch === "form" ? null : (_jsx(ChannelLink, { channel: ch, contact: contact, message: message, text: text, primary: primary, experiment: experiment, classNames: c }, ch));
52
+ // `qualify-first` shows the contact step as soon as a need's radio is
53
+ // checked (`:has`) — without a script, and inside the tap with one.
54
+ const contactShown = layout === "single" || (need !== undefined && !editing);
55
+ const contactClass = contactShown ? "flex" : "hidden group-has-[[data-need-option]:checked]/lead:flex";
56
+ return (_jsxs("div", { ref: root, className: cn("flex w-full flex-col gap-6", props.className, c?.root), "data-experiment": experiment?.name, "data-variant": experiment?.variant, children: [props.head ?? (_jsxs("div", { className: cn("flex flex-col gap-1", c?.head), children: [_jsx("p", { className: cn("font-display text-2xl font-bold text-ink", c?.title), children: text.title }), _jsx("p", { className: cn("text-ink-soft", c?.lede), children: text.lede })] })), lead !== "form" && channel(lead, true), _jsxs(QuoteFormShell, { id: id, placeSlug: place.slug, locale: locale, renderedAt: renderedAt, honeypotLabel: text.honeypotLabel, formId: formId, className: cn("group/lead", c?.form), children: [_jsx(ExperimentFields, { experiment: experiment }), _jsx(NeedField, { layout: layout, name: wire.subject, needs: needs, need: need, editing: editing, hydrated: hydrated, label: text.needLabel, changeLabel: text.needChange, onPick: pick, onEdit: () => {
57
+ setEditing(true);
58
+ events.step("need");
59
+ }, classNames: c }), _jsxs("div", { className: cn("flex-col gap-5", contactClass, c?.contact), children: [_jsx(LocalityField, { name: wire.locality, label: text.localityLabel, servedLabel: text.servedLabel, served: servedLocalities(place), required: props.locality !== "optional", optional: text.optional, hydrated: hydrated, classNames: c }), _jsx(PhoneField, { name: wire.mobile, label: text.phoneLabel, hint: text.phoneHint, onSoftError: () => events.fieldError("phone"), classNames: c }), props.name && _jsx(NameField, { name: props.name.field, label: text.nameLabel, required: props.name.required ?? false, optional: text.optional, classNames: c }), props.extras, _jsxs("div", { className: cn("flex flex-col gap-3", c?.trust), children: [_jsx(Button, { type: "submit", size: "touch", className: cn("w-full", c?.submit), children: text.submit }), props.trust] }), opening && lead !== "callback" && _jsx("p", { className: cn("text-sm text-ink-soft", c?.opening), children: opening }), _jsx("p", { className: cn("text-sm text-ink-soft", c?.privacy), children: text.privacy })] })] }), (links.length > 0 || rest.includes("callback")) && (_jsxs("div", { className: cn("flex flex-col gap-3", c?.others), children: [_jsx("p", { className: "text-sm font-medium text-ink", children: text.otherChannels }), links.length > 0 && _jsx("div", { className: "flex flex-wrap gap-2", children: links.map(ch => channel(ch, false)) }), rest.includes("callback") && channel("callback", false)] }))] }));
60
+ }
@@ -0,0 +1,54 @@
1
+ import { type CaptureChannel } from "../core/channels.js";
2
+ import type { LeadCaptureText } from "../core/lead-capture-text.js";
3
+ import type { PartClassNames } from "./parts.js";
4
+ export type ChannelPart = "channel" | "primary" | "callback" | "consent";
5
+ export interface Experiment {
6
+ name: string;
7
+ variant: string;
8
+ }
9
+ /** The site's assignment, posted with the form so the submit counts in its arm. */
10
+ export declare function ExperimentFields({ experiment }: {
11
+ experiment: Experiment | undefined;
12
+ }): import("react").JSX.Element | null;
13
+ /**
14
+ * A channel that leaves the page — `tel:`, `wa.me`, `sms:` — as a link the
15
+ * boundary counts: the tracker reads `tel:` and WhatsApp itself, `data-intent`
16
+ * names the text message it cannot classify. The assignment rides on the link
17
+ * because the tracker reads the link's own `data-*`.
18
+ */
19
+ export declare function ChannelLink(props: {
20
+ channel: Exclude<CaptureChannel, "form" | "callback">;
21
+ contact: {
22
+ phone: string | null;
23
+ whatsapp: string | null;
24
+ };
25
+ message: string;
26
+ text: LeadCaptureText;
27
+ primary: boolean;
28
+ experiment: Experiment | undefined;
29
+ classNames?: PartClassNames<ChannelPart> | undefined;
30
+ }): import("react").JSX.Element | null;
31
+ /**
32
+ * "Call me back": the form cut to a phone number and a consent, posted to the
33
+ * same `/quote` with `channel=callback` — a lead like any other, without a
34
+ * script. A `<details>` so it opens without one too; open from the start when
35
+ * it is the channel that leads (the place is closed), or when the page is
36
+ * opened at its id (`#quote-callback`, the call bar's link).
37
+ */
38
+ export declare function CallbackForm(props: {
39
+ id: string;
40
+ open: boolean;
41
+ formId: string;
42
+ placeSlug: string | null;
43
+ locale: string;
44
+ renderedAt: number;
45
+ mobileName: string;
46
+ subject: {
47
+ name: string;
48
+ value: string;
49
+ } | null;
50
+ opening: string | null;
51
+ text: LeadCaptureText;
52
+ experiment: Experiment | undefined;
53
+ classNames?: PartClassNames<ChannelPart> | undefined;
54
+ }): import("react").JSX.Element;
@@ -0,0 +1,37 @@
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Button, buttonVariants, cn, FieldLabel, Input } from "@evinvest/uikit";
3
+ import { EXPERIMENT_FIELD, VARIANT_FIELD } from "../core/accept.js";
4
+ import { channelHref } from "../core/channels.js";
5
+ import { CHANNEL_FIELD, CONSENT_FIELD } from "../core/lead.js";
6
+ import { PHONE_INPUT_PROPS, QuoteFormShell } from "./QuoteFormShell.js";
7
+ /** The site's assignment, posted with the form so the submit counts in its arm. */
8
+ export function ExperimentFields({ experiment }) {
9
+ if (!experiment)
10
+ return null;
11
+ return (_jsxs(_Fragment, { children: [_jsx("input", { type: "hidden", name: EXPERIMENT_FIELD, value: experiment.name }), _jsx("input", { type: "hidden", name: VARIANT_FIELD, value: experiment.variant })] }));
12
+ }
13
+ const LABEL = { phone: "call", whatsapp: "whatsapp", sms: "sms" };
14
+ /**
15
+ * A channel that leaves the page — `tel:`, `wa.me`, `sms:` — as a link the
16
+ * boundary counts: the tracker reads `tel:` and WhatsApp itself, `data-intent`
17
+ * names the text message it cannot classify. The assignment rides on the link
18
+ * because the tracker reads the link's own `data-*`.
19
+ */
20
+ export function ChannelLink(props) {
21
+ const { channel, contact, message, text, primary, experiment, classNames: c } = props;
22
+ const href = channelHref(channel, contact, message);
23
+ if (!href)
24
+ return null;
25
+ return (_jsx(Button, { href: href, variant: primary ? "primary" : "outline", size: "touch", className: cn(primary ? "w-full" : "flex-1", c?.channel, primary && c?.primary), "data-intent": channel === "sms" ? "sms" : undefined, "data-experiment": experiment?.name, "data-variant": experiment?.variant, children: text[LABEL[channel]] }));
26
+ }
27
+ /**
28
+ * "Call me back": the form cut to a phone number and a consent, posted to the
29
+ * same `/quote` with `channel=callback` — a lead like any other, without a
30
+ * script. A `<details>` so it opens without one too; open from the start when
31
+ * it is the channel that leads (the place is closed), or when the page is
32
+ * opened at its id (`#quote-callback`, the call bar's link).
33
+ */
34
+ export function CallbackForm(props) {
35
+ const { id, open, formId, placeSlug, locale, renderedAt, mobileName, subject, opening, text, experiment, classNames: c } = props;
36
+ return (_jsxs("details", { id: id, open: open, className: cn("w-full", c?.callback), children: [_jsx("summary", { "data-intent": "callback", className: buttonVariants({ variant: open ? "primary" : "outline", size: "touch", className: cn("w-full list-none [&::-webkit-details-marker]:hidden", c?.channel, open && c?.primary) }), children: text.callback }), _jsxs(QuoteFormShell, { id: `${id}-form`, placeSlug: placeSlug, locale: locale, renderedAt: renderedAt, honeypotLabel: text.honeypotLabel, formId: formId, className: "mt-4 gap-4", children: [_jsx("input", { type: "hidden", name: CHANNEL_FIELD, value: "callback" }), subject && _jsx("input", { type: "hidden", name: subject.name, value: subject.value }), _jsx(ExperimentFields, { experiment: experiment }), _jsx("p", { className: "text-ink-soft", children: opening ?? text.callbackLede }), _jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(FieldLabel, { htmlFor: `${id}-phone`, children: text.phoneLabel }), _jsx(Input, { id: `${id}-phone`, name: mobileName, size: "lg", ...PHONE_INPUT_PROPS, enterKeyHint: "send", required: true, "data-lead-field": "phone" })] }), _jsxs("label", { className: cn("flex min-h-11 items-start gap-3 text-sm text-ink", c?.consent), children: [_jsx("input", { type: "checkbox", name: CONSENT_FIELD, value: text.callbackConsent, required: true, "data-lead-field": "consent", className: "mt-0.5 size-5 shrink-0 accent-primary" }), _jsx("span", { children: text.callbackConsent })] }), _jsx(Button, { type: "submit", size: "touch", className: "w-full", children: text.callbackSubmit })] })] }));
37
+ }
@@ -0,0 +1,32 @@
1
+ import type { PartClassNames } from "./parts.js";
2
+ export type FieldPart = "field" | "label" | "control" | "hint" | "chips" | "chip";
3
+ export declare function LocalityField(props: {
4
+ name: string;
5
+ label: string;
6
+ servedLabel: string;
7
+ /** The communes the place names: one is filled in, a few are a tap each. */
8
+ served: readonly string[];
9
+ required: boolean;
10
+ optional: string;
11
+ hydrated: boolean;
12
+ classNames?: PartClassNames<FieldPart> | undefined;
13
+ }): import("react").JSX.Element;
14
+ /**
15
+ * The number, required and never masked. A value that does not read as a
16
+ * phone gets a hint under it when the visitor leaves the field — a hint, not
17
+ * a refusal: the server keeps what it cannot parse, as typed.
18
+ */
19
+ export declare function PhoneField(props: {
20
+ name: string;
21
+ label: string;
22
+ hint: string;
23
+ onSoftError: () => void;
24
+ classNames?: PartClassNames<FieldPart> | undefined;
25
+ }): import("react").JSX.Element;
26
+ export declare function NameField(props: {
27
+ name: string;
28
+ label: string;
29
+ required: boolean;
30
+ optional: string;
31
+ classNames?: PartClassNames<FieldPart> | undefined;
32
+ }): import("react").JSX.Element;
@@ -0,0 +1,42 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { Button, cn, Field, FieldDescription, FieldLabel, Input } from "@evinvest/uikit";
4
+ import { useId, useRef, useState } from "react";
5
+ import { isPlausiblePhone } from "../core/phone.js";
6
+ import { PHONE_INPUT_PROPS } from "./QuoteFormShell.js";
7
+ /** More than this and the row is a list to read, not a tap to save typing. */
8
+ const MAX_CHIPS = 6;
9
+ const optionalLabel = (label, optional) => (optional ? `${label} (${optional})` : label);
10
+ export function LocalityField(props) {
11
+ const { name, label, servedLabel, served, required, optional, hydrated, classNames: c } = props;
12
+ const input = useRef(null);
13
+ const chips = hydrated && served.length > 1 && served.length <= MAX_CHIPS;
14
+ return (_jsxs(Field, { className: cn("flex flex-col gap-2", c?.field), children: [_jsx(FieldLabel, { className: c?.label, children: optionalLabel(label, required ? null : optional) }), _jsx(Input, { ref: input, name: name, size: "lg", inputMode: "numeric", autoComplete: "postal-code", enterKeyHint: "next", required: required, defaultValue: served.length === 1 ? served[0] : undefined, "data-lead-field": "locality", className: c?.control }), chips && (_jsx("div", { role: "group", "aria-label": servedLabel, className: cn("flex flex-wrap gap-2", c?.chips), children: served.map(commune => (_jsx(Button, { type: "button", variant: "outline", size: "touch", className: cn("px-3", c?.chip), onClick: () => {
15
+ if (input.current)
16
+ input.current.value = commune;
17
+ }, children: commune }, commune))) }))] }));
18
+ }
19
+ /**
20
+ * The number, required and never masked. A value that does not read as a
21
+ * phone gets a hint under it when the visitor leaves the field — a hint, not
22
+ * a refusal: the server keeps what it cannot parse, as typed.
23
+ */
24
+ export function PhoneField(props) {
25
+ const { name, label, hint, onSoftError, classNames: c } = props;
26
+ const hintId = useId();
27
+ const [doubtful, setDoubtful] = useState(false);
28
+ return (_jsxs(Field, { className: cn("flex flex-col gap-2", c?.field), children: [_jsx(FieldLabel, { className: c?.label, children: label }), _jsx(Input, { name: name, size: "lg", ...PHONE_INPUT_PROPS, enterKeyHint: "send", required: true, "data-lead-field": "phone", "aria-invalid": doubtful || undefined, "aria-describedby": doubtful ? hintId : undefined, onBlur: e => {
29
+ const value = e.currentTarget.value;
30
+ const bad = value.trim() !== "" && !isPlausiblePhone(value);
31
+ if (bad && !doubtful)
32
+ onSoftError();
33
+ setDoubtful(bad);
34
+ }, onInput: e => {
35
+ if (doubtful && isPlausiblePhone(e.currentTarget.value))
36
+ setDoubtful(false);
37
+ }, className: c?.control }), doubtful && (_jsx(FieldDescription, { id: hintId, className: cn("text-accent-error", c?.hint), children: hint }))] }));
38
+ }
39
+ export function NameField(props) {
40
+ const { name, label, required, optional, classNames: c } = props;
41
+ return (_jsxs(Field, { className: cn("flex flex-col gap-2", c?.field), children: [_jsx(FieldLabel, { className: c?.label, children: optionalLabel(label, required ? null : optional) }), _jsx(Input, { name: name, size: "lg", autoComplete: "name", enterKeyHint: "next", required: required, "data-lead-field": "name", className: c?.control })] }));
42
+ }
@@ -0,0 +1,27 @@
1
+ import { type FormSelectOption } from "./FormSelect.js";
2
+ import type { PartClassNames } from "./parts.js";
3
+ export type LeadCaptureLayout = "single" | "qualify-first";
4
+ type NeedPart = "field" | "label" | "need" | "needs" | "summary";
5
+ export interface NeedFieldProps {
6
+ layout: LeadCaptureLayout;
7
+ name: string;
8
+ needs: readonly FormSelectOption[];
9
+ need: string | undefined;
10
+ /** Showing the choice again after it was made for the visitor. */
11
+ editing: boolean;
12
+ hydrated: boolean;
13
+ label: string;
14
+ changeLabel: string;
15
+ onPick: (need: string) => void;
16
+ onEdit: () => void;
17
+ classNames?: PartClassNames<NeedPart> | undefined;
18
+ }
19
+ /**
20
+ * What the job is. A need already known — from the page, `?need=` or the card
21
+ * tapped — is not asked again: it is a line with its label and, once the
22
+ * script runs, a way to change it. Otherwise `single` asks with the kit's
23
+ * select (first need by default, as before) and `qualify-first` with a tile
24
+ * per need: one tap answers it.
25
+ */
26
+ export declare function NeedField(props: NeedFieldProps): import("react").JSX.Element;
27
+ export {};
@@ -0,0 +1,22 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { Button, cn, Field, FieldLabel } from "@evinvest/uikit";
4
+ import { FormSelect } from "./FormSelect.js";
5
+ /**
6
+ * What the job is. A need already known — from the page, `?need=` or the card
7
+ * tapped — is not asked again: it is a line with its label and, once the
8
+ * script runs, a way to change it. Otherwise `single` asks with the kit's
9
+ * select (first need by default, as before) and `qualify-first` with a tile
10
+ * per need: one tap answers it.
11
+ */
12
+ export function NeedField(props) {
13
+ const { layout, name, needs, need, editing, hydrated, label, changeLabel, onPick, onEdit, classNames: c } = props;
14
+ const chosen = needs.find(n => n.value === need);
15
+ if (chosen && !editing) {
16
+ 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 }))] }));
17
+ }
18
+ if (layout === "single") {
19
+ 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 })] }));
20
+ }
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))) })] }));
22
+ }
@@ -0,0 +1,11 @@
1
+ import type { AnalyticsSink } from "@evinvest/analytics";
2
+ import { experimentProps } from "../core/analytics.js";
3
+ /**
4
+ * The sink `AnalyticsBoundary` built, for an island inside it that reports
5
+ * events of its own (`LeadCapture`). Outside a boundary there is none, and the
6
+ * island reports nothing — the same as a boundary with no key.
7
+ */
8
+ export declare const AnalyticsSinkContext: import("react").Context<AnalyticsSink | null>;
9
+ export declare const useAnalyticsSink: () => AnalyticsSink | null;
10
+ /** `data-experiment` / `data-variant` on an element or its ancestors, for an intent's event. */
11
+ export declare function experimentOf(el: Element | null): ReturnType<typeof experimentProps>;
@@ -0,0 +1,15 @@
1
+ "use client";
2
+ import { createContext, useContext } from "react";
3
+ import { experimentProps } from "../core/analytics.js";
4
+ /**
5
+ * The sink `AnalyticsBoundary` built, for an island inside it that reports
6
+ * events of its own (`LeadCapture`). Outside a boundary there is none, and the
7
+ * island reports nothing — the same as a boundary with no key.
8
+ */
9
+ export const AnalyticsSinkContext = createContext(null);
10
+ export const useAnalyticsSink = () => useContext(AnalyticsSinkContext);
11
+ /** `data-experiment` / `data-variant` on an element or its ancestors, for an intent's event. */
12
+ export function experimentOf(el) {
13
+ const host = el?.closest("[data-experiment]");
14
+ return experimentProps(host?.getAttribute("data-experiment"), host?.getAttribute("data-variant"));
15
+ }
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * `@evinvest/kitstart/react` — the structural widgets every landing shares,
3
3
  * where behaviour matters more than look. Server components by default; only
4
- * `MapFacade`, `AnalyticsBoundary` and `FormSelect` are client modules, each its own file
4
+ * `MapFacade`, `AnalyticsBoundary`, `FormSelect` and `LeadCapture` are client modules, each its own file
5
5
  * with its own `"use client"`, so a page pays for what it renders.
6
6
  *
7
7
  * Styled with the kit's token roles only (`text-ink`, `border-border`,
@@ -17,6 +17,8 @@ export { Coverage, type CoveragePart, type CoverageProps } from "./Coverage.js";
17
17
  export { Faq, type FaqPart, type FaqProps } from "./Faq.js";
18
18
  export { FormSelect, type FormSelectOption, type FormSelectPart, type FormSelectProps } from "./FormSelect.js";
19
19
  export { LangSwitch, withLang, type LangSwitchProps } from "./LangSwitch.js";
20
+ export { LeadCapture, type LeadCapturePart, type LeadCaptureProps } from "./LeadCapture.js";
21
+ export type { LeadCaptureLayout } from "./LeadCaptureNeed.js";
20
22
  export { MapFacade, type MapFacadePart, type MapFacadeProps } from "./MapFacade.js";
21
23
  export { PlaceDirectory, type PlaceDirectoryPart, type PlaceDirectoryProps } from "./PlaceDirectory.js";
22
24
  export { PHONE_INPUT_PROPS, QuoteFormShell, type QuoteFormShellProps } from "./QuoteFormShell.js";
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * `@evinvest/kitstart/react` — the structural widgets every landing shares,
3
3
  * where behaviour matters more than look. Server components by default; only
4
- * `MapFacade`, `AnalyticsBoundary` and `FormSelect` are client modules, each its own file
4
+ * `MapFacade`, `AnalyticsBoundary`, `FormSelect` and `LeadCapture` are client modules, each its own file
5
5
  * with its own `"use client"`, so a page pays for what it renders.
6
6
  *
7
7
  * Styled with the kit's token roles only (`text-ink`, `border-border`,
@@ -17,6 +17,7 @@ export { Coverage } from "./Coverage.js";
17
17
  export { Faq } from "./Faq.js";
18
18
  export { FormSelect } from "./FormSelect.js";
19
19
  export { LangSwitch, withLang } from "./LangSwitch.js";
20
+ export { LeadCapture } from "./LeadCapture.js";
20
21
  export { MapFacade } from "./MapFacade.js";
21
22
  export { PlaceDirectory } from "./PlaceDirectory.js";
22
23
  export { PHONE_INPUT_PROPS, QuoteFormShell } from "./QuoteFormShell.js";
@@ -0,0 +1,24 @@
1
+ /** The attribute a trigger elsewhere on the page names the need it is about in. */
2
+ export declare const NEED_ATTR = "data-need";
3
+ /**
4
+ * "Now" for the opening hours: the render's stamp on the server and while
5
+ * hydrating — so the two agree — then the visitor's clock, a minute at a time.
6
+ * A page cached for ten minutes is put right once it runs.
7
+ */
8
+ export declare function useNow(renderedAt: number): number;
9
+ /** `false` on the server and while hydrating, `true` from the render after. */
10
+ export declare const useHydrated: () => boolean;
11
+ /**
12
+ * The need, chosen for the visitor when the page knows it: the prop, then
13
+ * `?need=` once the script runs (a cached page cannot read the query), then a
14
+ * tap on any `[data-need]` trigger — the card the visitor came from. Only one
15
+ * of `needs` is taken; anything else is ignored.
16
+ */
17
+ export declare function useNeed(initial: string | undefined, needs: readonly string[],
18
+ /** Told when the page, not the visitor's own pick, set the need. */
19
+ onPreset: () => void): [string | undefined, (need: string) => void];
20
+ /**
21
+ * Opens the `<details>` the page's fragment names — the call bar links to the
22
+ * callback by id, and not every browser opens a closed one it scrolls to.
23
+ */
24
+ export declare function useOpenOnHash(id: string): void;
@@ -0,0 +1,71 @@
1
+ "use client";
2
+ import { useEffect, useRef, useState, useSyncExternalStore } from "react";
3
+ /** The attribute a trigger elsewhere on the page names the need it is about in. */
4
+ export const NEED_ATTR = "data-need";
5
+ const MINUTE = 60_000;
6
+ const subscribeMinute = (tick) => {
7
+ const timer = setInterval(tick, MINUTE);
8
+ return () => clearInterval(timer);
9
+ };
10
+ // The minute, not the millisecond: the snapshot must be stable between reads.
11
+ const minuteNow = () => Math.floor(Date.now() / MINUTE) * MINUTE;
12
+ /**
13
+ * "Now" for the opening hours: the render's stamp on the server and while
14
+ * hydrating — so the two agree — then the visitor's clock, a minute at a time.
15
+ * A page cached for ten minutes is put right once it runs.
16
+ */
17
+ export function useNow(renderedAt) {
18
+ return useSyncExternalStore(subscribeMinute, minuteNow, () => renderedAt);
19
+ }
20
+ const subscribeNothing = () => () => { };
21
+ /** `false` on the server and while hydrating, `true` from the render after. */
22
+ export const useHydrated = () => useSyncExternalStore(subscribeNothing, () => true, () => false);
23
+ /**
24
+ * The need, chosen for the visitor when the page knows it: the prop, then
25
+ * `?need=` once the script runs (a cached page cannot read the query), then a
26
+ * tap on any `[data-need]` trigger — the card the visitor came from. Only one
27
+ * of `needs` is taken; anything else is ignored.
28
+ */
29
+ export function useNeed(initial, needs,
30
+ /** Told when the page, not the visitor's own pick, set the need. */
31
+ onPreset) {
32
+ const [need, setNeed] = useState(initial !== undefined && needs.includes(initial) ? initial : undefined);
33
+ const key = needs.join("\n");
34
+ const preset = useRef(onPreset);
35
+ preset.current = onPreset;
36
+ useEffect(() => {
37
+ const list = key.split("\n");
38
+ const take = (value) => {
39
+ if (!value || !list.includes(value))
40
+ return;
41
+ setNeed(value);
42
+ preset.current();
43
+ };
44
+ take(new URLSearchParams(window.location.search).get("need"));
45
+ const onClick = (event) => {
46
+ const trigger = event.target instanceof Element ? event.target.closest(`[${NEED_ATTR}]`) : null;
47
+ take(trigger?.getAttribute(NEED_ATTR));
48
+ };
49
+ document.addEventListener("click", onClick, { capture: true });
50
+ return () => document.removeEventListener("click", onClick, { capture: true });
51
+ }, [key]);
52
+ return [need, setNeed];
53
+ }
54
+ /**
55
+ * Opens the `<details>` the page's fragment names — the call bar links to the
56
+ * callback by id, and not every browser opens a closed one it scrolls to.
57
+ */
58
+ export function useOpenOnHash(id) {
59
+ useEffect(() => {
60
+ const open = () => {
61
+ if (window.location.hash !== `#${id}`)
62
+ return;
63
+ const el = document.getElementById(id);
64
+ if (el instanceof HTMLDetailsElement)
65
+ el.open = true;
66
+ };
67
+ open();
68
+ window.addEventListener("hashchange", open);
69
+ return () => window.removeEventListener("hashchange", open);
70
+ }, [id]);
71
+ }
@@ -0,0 +1,23 @@
1
+ import { type RefObject } from "react";
2
+ import { type LeadField, type LeadStep } from "../core/analytics.js";
3
+ /** The attribute a lead form's control names its role in, for `lead_form_field_error`. */
4
+ export declare const FIELD_ATTR = "data-lead-field";
5
+ export interface LeadEvents {
6
+ step(step: LeadStep): void;
7
+ fieldError(field: LeadField): void;
8
+ }
9
+ /**
10
+ * The lead form's own funnel, reported through the boundary's sink: seen
11
+ * (half of it in view, once), started (the first focus inside, once), a
12
+ * field the browser refused (`invalid`, by role — never the value), and the
13
+ * step `qualify-first` moved to. Every event carries the form, the layout and
14
+ * the site's experiment, so the arms of a test compare on one schema.
15
+ */
16
+ export declare function useLeadEvents(root: RefObject<HTMLElement | null>, tags: {
17
+ formId: string;
18
+ layout: string;
19
+ experiment?: {
20
+ name: string;
21
+ variant: string;
22
+ } | undefined;
23
+ }): LeadEvents;
@@ -0,0 +1,62 @@
1
+ "use client";
2
+ import { useEffect, useMemo, useRef } from "react";
3
+ import { EVENTS, experimentProps } from "../core/analytics.js";
4
+ import { useAnalyticsSink } from "./analytics-context.js";
5
+ /** The attribute a lead form's control names its role in, for `lead_form_field_error`. */
6
+ export const FIELD_ATTR = "data-lead-field";
7
+ /**
8
+ * The lead form's own funnel, reported through the boundary's sink: seen
9
+ * (half of it in view, once), started (the first focus inside, once), a
10
+ * field the browser refused (`invalid`, by role — never the value), and the
11
+ * step `qualify-first` moved to. Every event carries the form, the layout and
12
+ * the site's experiment, so the arms of a test compare on one schema.
13
+ */
14
+ export function useLeadEvents(root, tags) {
15
+ const sink = useAnalyticsSink();
16
+ const { formId, layout } = tags;
17
+ const name = tags.experiment?.name;
18
+ const variant = tags.experiment?.variant;
19
+ const props = useMemo(() => ({ form_id: formId, layout, ...experimentProps(name, variant) }), [formId, layout, name, variant]);
20
+ const started = useRef(false);
21
+ useEffect(() => {
22
+ const el = root.current;
23
+ if (!sink || !el)
24
+ return;
25
+ let seen = false;
26
+ const view = () => {
27
+ if (seen)
28
+ return;
29
+ seen = true;
30
+ sink.capture(EVENTS.formView, props);
31
+ };
32
+ // Without an observer (an old browser, a test DOM) rendering is seeing.
33
+ const observer = typeof IntersectionObserver === "function" ? new IntersectionObserver(es => es.some(e => e.isIntersecting) && view(), { threshold: 0.5 }) : null;
34
+ if (observer)
35
+ observer.observe(el);
36
+ else
37
+ view();
38
+ const focus = () => {
39
+ if (started.current)
40
+ return;
41
+ started.current = true;
42
+ sink.capture(EVENTS.formStart, props);
43
+ };
44
+ // `invalid` does not bubble: listened to on the way down.
45
+ const invalid = (event) => {
46
+ const field = event.target instanceof Element ? event.target.getAttribute(FIELD_ATTR) : null;
47
+ if (field)
48
+ sink.capture(EVENTS.fieldError, { ...props, field });
49
+ };
50
+ el.addEventListener("focusin", focus);
51
+ el.addEventListener("invalid", invalid, true);
52
+ return () => {
53
+ observer?.disconnect();
54
+ el.removeEventListener("focusin", focus);
55
+ el.removeEventListener("invalid", invalid, true);
56
+ };
57
+ }, [sink, root, props]);
58
+ return useMemo(() => ({
59
+ step: step => sink?.capture(EVENTS.formStep, { ...props, step }),
60
+ fieldError: field => sink?.capture(EVENTS.fieldError, { ...props, field }),
61
+ }), [sink, props]);
62
+ }
@@ -13,6 +13,6 @@ export { parseSmtpUrl, sendMail, type Mail, type SendOptions } from "./smtp.js";
13
13
  export { defaultLeadMail, leadNotifier, MAIL_PER_MINUTE, type LeadMail, type LeadNotifier, type NotifyEnv } from "./notify.js";
14
14
  export { clientKey, NO_CLIENT_ADDRESS, parseProxyTrust, type ProxyTrust } from "./client-key.js";
15
15
  export { checkLeadStore } from "./boot.js";
16
- export { leadWebhook, type BuildWebhookBody, type LeadWebhook, type LeadWebhookContext, type LeadWebhookOptions } from "./lead-webhook.js";
16
+ export { leadWebhook, panelChannel, type BuildWebhookBody, type LeadWebhook, type LeadWebhookContext, type LeadWebhookOptions } from "./lead-webhook.js";
17
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";
18
18
  export { signatureHeaders, signWebhook, type WebhookSignatureHeaders, type WebhookSigning } from "./webhook-signature.js";
@@ -13,6 +13,6 @@ export { parseSmtpUrl, sendMail } from "./smtp.js";
13
13
  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
- export { leadWebhook } from "./lead-webhook.js";
16
+ export { leadWebhook, panelChannel } from "./lead-webhook.js";
17
17
  export { checkWebhookUrl, openWebhookOutbox, WEBHOOK_MAX_ATTEMPTS, WEBHOOK_TICK_MS, } from "./webhook-outbox.js";
18
18
  export { signatureHeaders, signWebhook } from "./webhook-signature.js";
@@ -1,7 +1,7 @@
1
1
  import "server-only";
2
2
  import { mkdirSync } from "node:fs";
3
3
  import { dirname } from "node:path";
4
- import { LEAD_SCHEMA_VERSION } from "../core/lead.js";
4
+ import { channelOf, LEAD_SCHEMA_VERSION } from "../core/lead.js";
5
5
  // `node:sqlite` is reached through `getBuiltinModule` rather than an import so
6
6
  // the bundler never has to resolve a builtin it may not know yet; the type is
7
7
  // the module's own.
@@ -42,6 +42,14 @@ const STEPS = [
42
42
  db => db.exec("ALTER TABLE leads ADD COLUMN spam_verdict TEXT"),
43
43
  // 4 — the brand's extra fields, as a JSON object; `NULL` when there are none.
44
44
  db => db.exec("ALTER TABLE leads ADD COLUMN extras TEXT"),
45
+ // 5 — how the lead was asked for (`form`, `callback`), and a callback's
46
+ // consent: when the server accepted it and the sentence shown. Nullable: a
47
+ // row from before is a form lead, and a form lead has no consent to keep.
48
+ db => {
49
+ db.exec("ALTER TABLE leads ADD COLUMN channel TEXT");
50
+ db.exec("ALTER TABLE leads ADD COLUMN consent_at TEXT");
51
+ db.exec("ALTER TABLE leads ADD COLUMN consent_text TEXT");
52
+ },
45
53
  ];
46
54
  if (STEPS.length !== LEAD_SCHEMA_VERSION) {
47
55
  // The version belongs to the port, shared by every adapter; this table of
@@ -66,7 +74,7 @@ function recognise(db) {
66
74
  throw new Error(`leads: an unrecognised table (${columns.join(", ")}); refusing to migrate it`);
67
75
  }
68
76
  let at = 1;
69
- for (const [step, added] of [[2, "location_id"], [3, "spam_verdict"], [4, "extras"]]) {
77
+ for (const [step, added] of [[2, "location_id"], [3, "spam_verdict"], [4, "extras"], [5, "consent_text"]]) {
70
78
  if (!columns.includes(added))
71
79
  break;
72
80
  at = step;
@@ -130,12 +138,12 @@ export function openSqliteLeadStore(path) {
130
138
  db.close();
131
139
  throw error;
132
140
  }
133
- const insert = db.prepare("INSERT INTO leads (job, zip, mobile, location_id, spam_verdict, extras) VALUES (?, ?, ?, ?, ?, ?) RETURNING id");
141
+ const insert = db.prepare("INSERT INTO leads (job, zip, mobile, location_id, spam_verdict, extras, channel, consent_at, consent_text) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id");
134
142
  const count = db.prepare("SELECT COUNT(*) AS n FROM leads");
135
143
  return {
136
144
  async insert(lead) {
137
145
  const extras = Object.keys(lead.extras).length > 0 ? JSON.stringify(lead.extras) : null;
138
- return integer(insert.get(lead.subject, lead.locality, lead.mobile, lead.placeSlug, lead.spamVerdict, extras), "id");
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");
139
147
  },
140
148
  async count() {
141
149
  return integer(count.get(), "n");
@@ -1,5 +1,5 @@
1
1
  import "server-only";
2
- import type { Lead } from "../core/lead.js";
2
+ import type { Lead, LeadChannel } 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";
@@ -15,6 +15,16 @@ export interface LeadWebhookContext {
15
15
  /** Fresh per lead and stored with the body, so every retry carries the same one. */
16
16
  idempotencyKey: string;
17
17
  }
18
+ /**
19
+ * What the Service-Arb panel's `lead.created` calls a callback request. Its
20
+ * `properties.channel` is a closed set (`form` | `phone_inbound`) and an event
21
+ * outside it is refused whole — the outbox would park the lead — so until the
22
+ * panel's `sa.funnel.v1` accepts `callback`, a callback travels as `form`.
23
+ * TODO(panel): set to "callback" once the panel accepts it; nothing else changes.
24
+ */
25
+ declare const PANEL_CALLBACK = "form";
26
+ /** A lead's channel as the panel's `properties.channel` may carry it: `panelChannel(channelOf(lead))`. */
27
+ export declare function panelChannel(channel: LeadChannel): "form" | typeof PANEL_CALLBACK;
18
28
  /**
19
29
  * The receiver's body, built by the brand: the kit serialises and signs
20
30
  * whatever this returns and knows nothing of its shape. Called once, when the
@@ -48,3 +58,4 @@ export declare function leadWebhook(site: {
48
58
  id: string;
49
59
  };
50
60
  }, env: Pick<ServerEnv, "leadsDb" | "leadWebhook">, options: LeadWebhookOptions): LeadWebhook | null;
61
+ export {};