@evinvest/kitstart 0.17.0 → 0.19.1

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 (103) hide show
  1. package/README.md +163 -20
  2. package/dist/core/accept.d.ts +14 -0
  3. package/dist/core/accept.js +21 -3
  4. package/dist/core/analytics.d.ts +11 -2
  5. package/dist/core/analytics.js +22 -0
  6. package/dist/core/channels.d.ts +12 -4
  7. package/dist/core/channels.js +11 -1
  8. package/dist/core/lead-capture-text.d.ts +6 -2
  9. package/dist/core/lead.d.ts +33 -10
  10. package/dist/core/lead.js +21 -8
  11. package/dist/core/messenger-message.d.ts +31 -0
  12. package/dist/core/messenger-message.js +33 -0
  13. package/dist/core/messenger-text.d.ts +104 -0
  14. package/dist/core/messenger-text.js +150 -0
  15. package/dist/core/messenger.d.ts +92 -0
  16. package/dist/core/messenger.js +55 -0
  17. package/dist/core/place/index.d.ts +1 -1
  18. package/dist/core/place/live.d.ts +5 -1
  19. package/dist/core/place/live.js +22 -1
  20. package/dist/core/place/types.d.ts +17 -1
  21. package/dist/core/pricing/index.d.ts +2 -1
  22. package/dist/core/pricing/index.js +2 -1
  23. package/dist/core/pricing/label.d.ts +7 -0
  24. package/dist/core/pricing/label.js +8 -0
  25. package/dist/core/pricing/validate.d.ts +1 -3
  26. package/dist/core/pricing/validate.js +0 -4
  27. package/dist/core/site.d.ts +8 -1
  28. package/dist/core/site.js +22 -1
  29. package/dist/index.d.ts +8 -5
  30. package/dist/index.js +7 -4
  31. package/dist/next/quote-route.js +4 -7
  32. package/dist/react/AnalyticsBoundary.js +3 -3
  33. package/dist/react/FormSelect.d.ts +5 -3
  34. package/dist/react/FormSelect.js +70 -24
  35. package/dist/react/FormSelectKit.d.ts +24 -0
  36. package/dist/react/FormSelectKit.js +28 -0
  37. package/dist/react/LeadCapture.d.ts +24 -1
  38. package/dist/react/LeadCapture.js +97 -12
  39. package/dist/react/LeadCaptureChannels.d.ts +7 -2
  40. package/dist/react/LeadCaptureChannels.js +2 -2
  41. package/dist/react/LeadCaptureEstimate.d.ts +6 -0
  42. package/dist/react/LeadCaptureEstimate.js +1 -1
  43. package/dist/react/LeadCaptureFields.d.ts +17 -0
  44. package/dist/react/LeadCaptureFields.js +42 -33
  45. package/dist/react/LeadCaptureMessenger.d.ts +41 -0
  46. package/dist/react/LeadCaptureMessenger.js +133 -0
  47. package/dist/react/LeadCapturePrice.d.ts +9 -3
  48. package/dist/react/LeadCapturePrice.js +25 -5
  49. package/dist/react/LeadCaptureSubmit.d.ts +3 -0
  50. package/dist/react/LeadCaptureSubmit.js +2 -2
  51. package/dist/react/LeadCaptureTiles.js +9 -4
  52. package/dist/react/analytics-context.d.ts +9 -3
  53. package/dist/react/analytics-context.js +7 -3
  54. package/dist/react/form-select-value.d.ts +3 -0
  55. package/dist/react/form-select-value.js +6 -6
  56. package/dist/react/index.d.ts +2 -0
  57. package/dist/react/index.js +1 -0
  58. package/dist/react/lazy-part.d.ts +33 -0
  59. package/dist/react/lazy-part.js +66 -0
  60. package/dist/react/messenger/action.d.ts +144 -0
  61. package/dist/react/messenger/action.js +301 -0
  62. package/dist/react/messenger/chip.d.ts +8 -0
  63. package/dist/react/messenger/chip.js +22 -0
  64. package/dist/react/messenger/fallback.d.ts +8 -0
  65. package/dist/react/messenger/fallback.js +11 -0
  66. package/dist/react/messenger/saga.d.ts +10 -0
  67. package/dist/react/messenger/saga.js +33 -0
  68. package/dist/react/messenger/segment.d.ts +8 -0
  69. package/dist/react/messenger/segment.js +15 -0
  70. package/dist/react/messenger/select.d.ts +10 -0
  71. package/dist/react/messenger/select.js +26 -0
  72. package/dist/react/messenger/sheet.d.ts +10 -0
  73. package/dist/react/messenger/sheet.js +57 -0
  74. package/dist/react/messenger/split.d.ts +9 -0
  75. package/dist/react/messenger/split.js +17 -0
  76. package/dist/react/messenger/swap.d.ts +10 -0
  77. package/dist/react/messenger/swap.js +18 -0
  78. package/dist/react/messenger/thanks.d.ts +10 -0
  79. package/dist/react/messenger/thanks.js +18 -0
  80. package/dist/react/messenger/tiles.d.ts +8 -0
  81. package/dist/react/messenger/tiles.js +20 -0
  82. package/dist/react/messenger/types.d.ts +98 -0
  83. package/dist/react/messenger/types.js +1 -0
  84. package/dist/react/messenger/urgency.d.ts +10 -0
  85. package/dist/react/messenger/urgency.js +29 -0
  86. package/dist/react/use-lead-error.d.ts +3 -3
  87. package/dist/react/use-lead-events.d.ts +21 -1
  88. package/dist/react/use-lead-events.js +9 -3
  89. package/dist/react/use-lead-submit.d.ts +20 -6
  90. package/dist/react/use-lead-submit.js +23 -1
  91. package/dist/server/index.d.ts +1 -1
  92. package/dist/server/lead-store-sqlite.js +11 -5
  93. package/dist/server/lead-webhook.d.ts +30 -5
  94. package/dist/server/lead-webhook.js +13 -7
  95. package/dist/server/notify.js +11 -5
  96. package/dist/testing/index.d.ts +1 -1
  97. package/dist/testing/index.js +1 -1
  98. package/dist/testing/landing-contract.js +6 -1
  99. package/dist/testing/lead-rules.d.ts +8 -0
  100. package/dist/testing/lead-rules.js +30 -1
  101. package/package.json +6 -3
  102. package/template/flake.nix +1 -1
  103. package/template/package.json +5 -5
@@ -0,0 +1,18 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { cn } from "@evinvest/uikit";
4
+ import { hrefOf, InappHint, MessengerCta, QrPanel, useDesktop, useMessage, useMessengerAction, wordsOf } from "./action.js";
5
+ /**
6
+ * AQ-3: the form is the control's; its success adds the faster way — a photo
7
+ * on WhatsApp, the message carrying the lead's own reference — and the bot.
8
+ * On a computer, WhatsApp is a QR code to scan with the phone that takes the
9
+ * photo. The lead is sent already: a tap here posts nothing.
10
+ */
11
+ export default function Thanks({ kit }) {
12
+ const words = wordsOf(kit);
13
+ const message = useMessage(kit);
14
+ const action = useMessengerAction(kit, message, () => undefined, { followUp: true });
15
+ const desktop = useDesktop();
16
+ const whatsapp = hrefOf(kit, "whatsapp", message.message);
17
+ return (_jsxs("div", { className: cn("flex flex-col gap-3 border-t border-border pt-4", kit.classNames?.messenger), children: [whatsapp && !desktop && _jsx("p", { className: "font-medium text-ink", children: words.messengerThanksTitle }), whatsapp && (desktop ? _jsx(QrPanel, { href: whatsapp, words: words, onCallback: null, kit: kit }) : _jsx(MessengerCta, { kit: kit, action: action, channel: "whatsapp", label: words.messengerThanksCta })), whatsapp && !desktop && _jsx(InappHint, { kit: kit, message: message.message }), kit.facts.telegram && _jsx(MessengerCta, { kit: kit, action: action, channel: "telegram", label: desktop ? words.messengerTelegramCta : words.messengerOptionTelegram, variant: "outline" })] }));
18
+ }
@@ -0,0 +1,8 @@
1
+ import type { MessengerKit } from "./types.js";
2
+ /**
3
+ * VF-2: «Recevoir mon devis par» and a tile per channel — WhatsApp, Telegram,
4
+ * Appel — over a slot of one height: the message, what the bot does, or the phone.
5
+ */
6
+ export default function Tiles({ kit }: {
7
+ kit: MessengerKit;
8
+ }): import("react").JSX.Element;
@@ -0,0 +1,20 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { cn, ToggleGroup, ToggleGroupItem } from "@evinvest/uikit";
4
+ import { Glyph, labelOf, ModeBody, modesOf, toCall, useMessage, useMessengerAction, wordsOf } from "./action.js";
5
+ /**
6
+ * VF-2: «Recevoir mon devis par» and a tile per channel — WhatsApp, Telegram,
7
+ * Appel — over a slot of one height: the message, what the bot does, or the phone.
8
+ */
9
+ export default function Tiles({ kit }) {
10
+ const words = wordsOf(kit);
11
+ const message = useMessage(kit);
12
+ const action = useMessengerAction(kit, message, () => toCall(kit));
13
+ const modes = modesOf(kit, ["whatsapp", "telegram", "call"]);
14
+ const mode = kit.mode ?? "whatsapp";
15
+ return (_jsxs("div", { className: cn("flex flex-col gap-3", kit.classNames?.messenger), children: [_jsx("p", { className: "text-sm font-medium text-ink", children: words.messengerTilesLabel }), _jsx(ToggleGroup, { role: "group", type: "single", value: mode, onValueChange: v => {
16
+ const picked = modes.find((m) => m === v);
17
+ if (picked)
18
+ kit.setMode(picked);
19
+ }, "aria-label": words.messengerTilesLabel, className: cn("grid w-full gap-2", modes.length === 3 ? "grid-cols-3" : "grid-cols-2", kit.classNames?.messengerPicker), children: modes.map(m => (_jsxs(ToggleGroupItem, { value: m, className: cn("h-11 gap-2 rounded-[var(--control-radius)] border border-border first:rounded-[var(--control-radius)] last:rounded-[var(--control-radius)] data-[state=on]:border-primary data-[state=on]:text-ink", kit.classNames?.messengerOption, kit.classNames?.messengerTile), children: [_jsx(Glyph, { kit: kit, channel: m }), labelOf(words, m)] }, m))) }), _jsx(ModeBody, { kit: kit, message: message, action: action }), action.overlay] }));
20
+ }
@@ -0,0 +1,98 @@
1
+ import type { ReactNode } from "react";
2
+ import type { LeadWire } from "../../core/lead.js";
3
+ import type { LeadCaptureText } from "../../core/lead-capture-text.js";
4
+ import type { MessengerFacts, MessengerMode, MessengerVariant } from "../../core/messenger.js";
5
+ import type { ChannelIconKey, Experiment } from "../LeadCaptureChannels.js";
6
+ import type { LeadNeedOption } from "../LeadCaptureNeed.js";
7
+ import type { PartClassNames } from "../parts.js";
8
+ import type { LeadEvents } from "../use-lead-events.js";
9
+ /**
10
+ * Where in the card a variant draws: in place of the heading (`head`), under
11
+ * it (`afterHead`), before the need (`beforeNeed`), in place of the form's
12
+ * fields (`body`), in place of the phone and the submit (`contact`), under
13
+ * the submit (`afterSubmit`), or in the success (`done`).
14
+ */
15
+ export type MessengerAt = "head" | "afterHead" | "beforeNeed" | "body" | "contact" | "afterSubmit" | "done";
16
+ /** A variant as drawn: its own kind, or the control with a way to the bot (`fallback`). */
17
+ export type MessengerShown = MessengerVariant["kind"] | "fallback";
18
+ /**
19
+ * The parts a variant dresses; a brand sizes `messengerSlot` (92 px, 72 px)
20
+ * so no state of a variant moves the card. `messengerCta`: a variant's main
21
+ * button — a messenger's, or the call's submit; `messengerSecondary`: the
22
+ * others (Telegram and «Être rappelé» beside it, «ou via Telegram», the way
23
+ * back to the chat); `messengerSquare`: the icon squares; `messengerSegment`,
24
+ * `messengerTile`: a segment's and a tile's item, after `messengerOption`;
25
+ * `messengerTrigger`: the channel picker's button (`select`, `chip`).
26
+ */
27
+ export type MessengerPart = "messenger" | "messengerSlot" | "messengerCta" | "messengerSecondary" | "messengerSegment" | "messengerTile" | "messengerTrigger" | "messengerPreview" | "messengerPicker" | "messengerOption" | "messengerSquare" | "messengerQr" | "messengerReturn" | "messengerHint";
28
+ /** How a variant asks for the card's phone field. */
29
+ export interface PhoneOptions {
30
+ optional?: boolean;
31
+ disabled?: boolean;
32
+ placeholder?: string;
33
+ /** The variant's box around the input (`PhoneField`'s `frame`). */
34
+ frame?: (input: ReactNode) => ReactNode;
35
+ /** The form's id, for a field drawn outside the form's element (a drawer). */
36
+ form?: string;
37
+ /** Under the field instead of the brand's `afterPhone`. */
38
+ after?: ReactNode;
39
+ }
40
+ /**
41
+ * What `LeadCapture` hands a variant: the card's facts and state, and its own
42
+ * pieces — the phone field and the submit — so a variant arranges them and
43
+ * never re-implements them.
44
+ */
45
+ export interface MessengerKit {
46
+ at: MessengerAt;
47
+ variant: MessengerVariant;
48
+ shown: MessengerShown;
49
+ facts: MessengerFacts;
50
+ /** The card's id; its form is `<id>-form`. */
51
+ id: string;
52
+ locale: string;
53
+ /** The brand's name, the message's greeting. */
54
+ brand: string;
55
+ text: LeadCaptureText;
56
+ wire: LeadWire;
57
+ needs: readonly LeadNeedOption[];
58
+ /** The need on screen, by its label — for the message. */
59
+ needLabel: string | null;
60
+ /** The estimate the card shows, formatted — for the message. */
61
+ priceText: string | null;
62
+ /** The estimate's answers, by their short labels, in order — the timing question's left out. */
63
+ answers: readonly string[];
64
+ /**
65
+ * The answer to the estimate's question the brand names (`messengerTiming`):
66
+ * its label for the message's timing line, its short one for the preview.
67
+ */
68
+ timing: {
69
+ label: string;
70
+ short: string;
71
+ } | null;
72
+ /** The channel the visitor is on; `null` before a variant asks (`saga`, `urgency`). */
73
+ mode: MessengerMode | null;
74
+ setMode: (mode: MessengerMode | null) => void;
75
+ /** The card's reference (`message_ref`), minted once the script runs; `null` before. */
76
+ messageRef: string | null;
77
+ /**
78
+ * Whether a messenger link may lead anywhere: the script runs and the
79
+ * reference is minted. Until then a tap would open the chat with no lead
80
+ * posted and no `Réf.` to find it by — the links are inert.
81
+ */
82
+ ready: boolean;
83
+ /** A new reference, once a messenger lead is posted. */
84
+ renewRef: () => void;
85
+ phone: (options?: PhoneOptions) => ReactNode;
86
+ /** The card's submit — a call lead, posted as the control posts it. */
87
+ submit: (label?: string, options?: {
88
+ form?: string;
89
+ className?: string;
90
+ }) => ReactNode;
91
+ /** The default heading's words, for a variant that redraws the heading. */
92
+ title: string;
93
+ lede: string;
94
+ events: LeadEvents;
95
+ icons: Partial<Record<ChannelIconKey, ReactNode>> | undefined;
96
+ experiment: Experiment | undefined;
97
+ classNames: PartClassNames<MessengerPart> | undefined;
98
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,10 @@
1
+ import type { MessengerKit } from "./types.js";
2
+ /**
3
+ * AQ-6: «C’est urgent ?» picks the channel — today is a call, «je compare»
4
+ * the message ready in the slot — and the answer is the lead's, posted as
5
+ * the brand's extra (`field`, which `site.lead.extras` must list). Without
6
+ * WhatsApp the question stays, and both answers ask the phone.
7
+ */
8
+ export default function Urgency({ kit }: {
9
+ kit: MessengerKit;
10
+ }): import("react").JSX.Element;
@@ -0,0 +1,29 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { cn, ToggleGroup, ToggleGroupItem } from "@evinvest/uikit";
4
+ import { fillText } from "../../core/lead-capture-format.js";
5
+ import { MessengerCta, InappHint, Preview, Slot, TelegramLink, toCall, useMessage, useMessengerAction, wordsOf } from "./action.js";
6
+ /** What the answer is posted as, in the brand's extra: a slug, never the label. */
7
+ const ANSWER = { call: "today", whatsapp: "later" };
8
+ /**
9
+ * AQ-6: «C’est urgent ?» picks the channel — today is a call, «je compare»
10
+ * the message ready in the slot — and the answer is the lead's, posted as
11
+ * the brand's extra (`field`, which `site.lead.extras` must list). Without
12
+ * WhatsApp the question stays, and both answers ask the phone.
13
+ */
14
+ export default function Urgency({ kit }) {
15
+ return kit.at === "beforeNeed" ? _jsx(Question, { kit: kit }) : _jsx(UrgencyContact, { kit: kit });
16
+ }
17
+ function Question({ kit }) {
18
+ const words = wordsOf(kit);
19
+ const field = kit.variant.kind === "urgency" ? kit.variant.field : "urgency";
20
+ const answer = kit.mode === "call" || kit.mode === "whatsapp" ? kit.mode : null;
21
+ return (_jsxs("div", { className: cn("flex flex-col gap-2", kit.classNames?.messenger), children: [_jsx("p", { className: "text-sm font-medium text-ink", children: words.messengerUrgencyLabel }), _jsx(ToggleGroup, { role: "group", type: "single", value: answer ?? "", onValueChange: v => (v === "call" || v === "whatsapp" ? kit.setMode(v) : undefined), "aria-label": words.messengerUrgencyLabel, className: cn("grid w-full grid-cols-2 gap-2", kit.classNames?.messengerPicker), children: ["call", "whatsapp"].map(m => (_jsx(ToggleGroupItem, { value: m, className: cn("h-11 rounded-[var(--control-radius)] border border-border first:rounded-[var(--control-radius)] last:rounded-[var(--control-radius)] data-[state=on]:border-primary data-[state=on]:text-ink", kit.classNames?.messengerOption, kit.classNames?.messengerTile), children: m === "call" ? words.messengerUrgencyYes : words.messengerUrgencyNo }, m))) }), answer && _jsx("input", { type: "hidden", name: field, value: ANSWER[answer] })] }));
22
+ }
23
+ function UrgencyContact({ kit }) {
24
+ const words = wordsOf(kit);
25
+ const message = useMessage(kit, kit.mode === "call" ? words.messengerUrgencyToday : kit.mode === "whatsapp" ? words.messengerUrgencyLater : null);
26
+ const action = useMessengerAction(kit, message, () => toCall(kit));
27
+ const chat = kit.mode === "whatsapp" && kit.facts.whatsapp !== null;
28
+ return (_jsxs("div", { className: cn("flex flex-col gap-3", kit.classNames?.messenger), children: [_jsx(Slot, { kit: kit, children: chat ? (action.panel ?? _jsx(Preview, { kit: kit, preview: message.preview, title: words.messengerNotUrgentTitle, lede: fillText(words.messengerNotUrgentLede, { ref: kit.messageRef ?? "" }) })) : kit.phone() }), chat ? _jsx(MessengerCta, { kit: kit, action: action, channel: "whatsapp", label: words.messengerWhatsappCta }) : kit.submit(kit.mode === "call" ? words.messengerUrgentSubmit : undefined), chat && _jsx(InappHint, { kit: kit, message: message.message }), _jsx(TelegramLink, { kit: kit, action: action }), action.overlay] }));
29
+ }
@@ -1,11 +1,11 @@
1
1
  import { type RefObject } from "react";
2
- import { type LeadChannel, type LeadError } from "../core/lead.js";
2
+ import { type LeadError } from "../core/lead.js";
3
3
  import type { LeadCaptureText } from "../core/lead-capture-text.js";
4
4
  export type { LeadError };
5
5
  /** The fields `LeadCapture` draws itself, which show a refusal under themselves. */
6
- export declare const OWN_FIELDS: Readonly<Record<LeadChannel, readonly string[]>>;
6
+ export declare const OWN_FIELDS: Readonly<Record<LeadError["channel"], readonly string[]>>;
7
7
  /** The id of the message a refusal of a field the card does not draw is shown in, above the submit. */
8
- export declare const formMessageId: (id: string, channel: LeadChannel) => string;
8
+ export declare const formMessageId: (id: string, channel: LeadError["channel"]) => string;
9
9
  /** The words for a refused field: the field's own rule where the form has one. */
10
10
  export declare function errorText(field: string, text: LeadCaptureText): string;
11
11
  /**
@@ -1,5 +1,6 @@
1
1
  import { type RefObject } from "react";
2
2
  import { type LeadField, type LeadStep } from "../core/analytics.js";
3
+ import type { ChannelsAvailable } from "../core/messenger.js";
3
4
  /** The attribute a lead form's control names its role in, for `lead_form_field_error`. */
4
5
  export declare const FIELD_ATTR = "data-lead-field";
5
6
  export interface LeadEvents {
@@ -10,13 +11,30 @@ export interface LeadEvents {
10
11
  submitError(reason: "network" | "timeout", channel: "form" | "callback"): void;
11
12
  /** An estimate's price for a full set of answers: its band only, once per need and band. */
12
13
  estimateShown(need: string, cents: number): void;
14
+ /** A messenger CTA: the lead posted, the visitor sent on — never what was typed. */
15
+ messengerOpen(open: {
16
+ channel: "whatsapp" | "telegram";
17
+ device: "mobile" | "desktop";
18
+ inapp: boolean;
19
+ ref: string | null;
20
+ }): void;
21
+ /** On a computer: the QR code that opens WhatsApp on the phone was shown (`contact_intent_click`, `whatsapp_qr`). */
22
+ qrShown(): void;
23
+ /** Back on the page after a messenger: what the visitor said of it. */
24
+ messengerReturn(back: {
25
+ channel: "whatsapp" | "telegram";
26
+ answer: "sent" | "failed";
27
+ ref: string | null;
28
+ }): void;
13
29
  }
14
30
  /**
15
31
  * The lead form's own funnel, reported through the boundary's sink: seen
16
32
  * (half of it in view, once), started (the first focus inside, once), a
17
33
  * field the browser refused (`invalid`, by role — never the value), and the
18
34
  * step `qualify-first` moved to. Every event carries the form, the layout and
19
- * the site's experiment, so the arms of a test compare on one schema.
35
+ * the site's experiment, so the arms of a test compare on one schema — and
36
+ * the messengers the card offered (`channels_available`) and its `messenger`
37
+ * variant, so an arm that fell back to the control is told apart.
20
38
  */
21
39
  export declare function useLeadEvents(root: RefObject<HTMLElement | null>, tags: {
22
40
  formId: string;
@@ -25,4 +43,6 @@ export declare function useLeadEvents(root: RefObject<HTMLElement | null>, tags:
25
43
  name: string;
26
44
  variant: string;
27
45
  } | undefined;
46
+ channels?: ChannelsAvailable | undefined;
47
+ messenger?: string | undefined;
28
48
  }): LeadEvents;
@@ -10,14 +10,16 @@ export const FIELD_ATTR = "data-lead-field";
10
10
  * (half of it in view, once), started (the first focus inside, once), a
11
11
  * field the browser refused (`invalid`, by role — never the value), and the
12
12
  * step `qualify-first` moved to. Every event carries the form, the layout and
13
- * the site's experiment, so the arms of a test compare on one schema.
13
+ * the site's experiment, so the arms of a test compare on one schema — and
14
+ * the messengers the card offered (`channels_available`) and its `messenger`
15
+ * variant, so an arm that fell back to the control is told apart.
14
16
  */
15
17
  export function useLeadEvents(root, tags) {
16
18
  const sink = useAnalyticsSink();
17
- const { formId, layout } = tags;
19
+ const { formId, layout, channels, messenger } = tags;
18
20
  const name = tags.experiment?.name;
19
21
  const variant = tags.experiment?.variant;
20
- const props = useMemo(() => ({ form_id: formId, layout, ...experimentProps(name, variant) }), [formId, layout, name, variant]);
22
+ const props = useMemo(() => ({ form_id: formId, layout, ...experimentProps(name, variant), ...(channels ? { channels_available: channels } : {}), ...(messenger ? { messenger_variant: messenger } : {}) }), [formId, layout, name, variant, channels, messenger]);
21
23
  const started = useRef(false);
22
24
  const shown = useRef(new Set());
23
25
  useEffect(() => {
@@ -69,5 +71,9 @@ export function useLeadEvents(root, tags) {
69
71
  shown.current.add(key);
70
72
  sink?.capture(EVENTS.estimateShown, { ...props, need, cents_bucket });
71
73
  },
74
+ // Beacons: the page is being left for the app.
75
+ messengerOpen: ({ channel, device, inapp, ref }) => sink?.capture(EVENTS.messengerOpen, { ...props, channel, device, inapp, ...(ref ? { message_ref: ref } : {}) }, { transport: "beacon" }),
76
+ qrShown: () => sink?.capture(EVENTS.intent, { ...props, channel: "whatsapp_qr" }),
77
+ messengerReturn: ({ channel, answer, ref }) => sink?.capture(EVENTS.messengerReturn, { ...props, channel, answer, ...(ref ? { message_ref: ref } : {}) }),
72
78
  }), [sink, props]);
73
79
  }
@@ -1,8 +1,13 @@
1
1
  import { type FormEventHandler } from "react";
2
2
  import { type LeadChannel } from "../core/lead.js";
3
+ /**
4
+ * Which of the card's forms a submit posts: a messenger lead is posted in the
5
+ * background instead (`postInBackground`), and never answers in the card.
6
+ */
7
+ export type FormChannel = Extract<LeadChannel, "form" | "callback">;
3
8
  /** What the visitor just sent, for a brand's in-card success ("we call 06 … back"). */
4
9
  export interface LeadSent {
5
- channel: LeadChannel;
10
+ channel: FormChannel;
6
11
  /** As typed. */
7
12
  phone: string;
8
13
  /** The name field's value, when the form has one and it was filled. */
@@ -26,6 +31,15 @@ export declare const navigation: {
26
31
  * over plain http is not one, but `getRandomValues` works everywhere.
27
32
  */
28
33
  export declare function newSubmissionId(): string;
34
+ /**
35
+ * A messenger lead: the form as it stands, with `extra` over it (the
36
+ * channel, the reference), posted as the visitor leaves for the chat —
37
+ * `keepalive`, so the request outlives the page handing over to the app. No
38
+ * answer is waited for and none is shown: the chat is where it goes on. A
39
+ * second tap on the same lead carries the same submission id, so the route
40
+ * stores it once. Resolves whether the route took it.
41
+ */
42
+ export declare function postInBackground(form: HTMLFormElement, extra: Readonly<Record<string, string>>, analyticsId: string | null): Promise<boolean>;
29
43
  /** Why the script's post got no answer: none came back (`network`), or none in time (`timeout`). */
30
44
  export type SendFailure = "network" | "timeout";
31
45
  /** How long the script waits for `/quote` before it says so; the lead is a few short fields. */
@@ -33,18 +47,18 @@ export declare const SUBMIT_TIMEOUT_MS = 15000;
33
47
  export interface LeadSubmitHandlers {
34
48
  /** The route refused the lead: the field to fix. */
35
49
  /** `cents`: with `price_changed`, the server's fresh price. */
36
- onRefused: (channel: LeadChannel, field: string, cents?: number) => void;
50
+ onRefused: (channel: FormChannel, field: string, cents?: number) => void;
37
51
  /** No answer: the form stays as typed, with a retry. */
38
- onFailed: (channel: LeadChannel, failure: SendFailure) => void;
52
+ onFailed: (channel: FormChannel, failure: SendFailure) => void;
39
53
  }
40
54
  export interface LeadSubmit {
41
55
  sent: LeadSent | null;
42
56
  onSubmit: FormEventHandler<HTMLFormElement>;
43
57
  /** The form being posted, while it is. */
44
- busy: LeadChannel | null;
58
+ busy: FormChannel | null;
45
59
  /** The last post that got no answer, until the next one. */
46
60
  failure: {
47
- channel: LeadChannel;
61
+ channel: FormChannel;
48
62
  failure: SendFailure;
49
63
  } | null;
50
64
  /** Posts that form again — the same submission id, so never a second lead. */
@@ -60,7 +74,7 @@ export interface LeadSubmit {
60
74
  * form. Any other answer (the store's 500) submits the form for real, so the
61
75
  * server's own page says what went wrong; the submission id makes that safe.
62
76
  */
63
- export declare function useLeadSubmit(done: boolean | ((channel: LeadChannel) => boolean), fields: {
77
+ export declare function useLeadSubmit(done: boolean | ((channel: FormChannel) => boolean), fields: {
64
78
  mobile: string;
65
79
  name: string | undefined;
66
80
  }, on: LeadSubmitHandlers): LeadSubmit;
@@ -31,18 +31,40 @@ const minted = new WeakMap();
31
31
  * fixed after a timeout is another lead, which must not be answered with the
32
32
  * first one's row.
33
33
  */
34
- function stamp(form) {
34
+ function stamp(form, extra = {}) {
35
35
  const field = form.elements.namedItem(SUBMISSION_FIELD);
36
36
  if (!(field instanceof HTMLInputElement))
37
37
  return;
38
38
  const posted = new FormData(form);
39
39
  posted.delete(SUBMISSION_FIELD);
40
+ for (const [key, value] of Object.entries(extra))
41
+ posted.set(key, value);
40
42
  const lead = new URLSearchParams([...posted].filter((e) => typeof e[1] === "string")).toString();
41
43
  const known = minted.get(form);
42
44
  const id = known && known.lead === lead ? known.id : newSubmissionId();
43
45
  minted.set(form, { id, lead });
44
46
  field.value = id;
45
47
  }
48
+ /**
49
+ * A messenger lead: the form as it stands, with `extra` over it (the
50
+ * channel, the reference), posted as the visitor leaves for the chat —
51
+ * `keepalive`, so the request outlives the page handing over to the app. No
52
+ * answer is waited for and none is shown: the chat is where it goes on. A
53
+ * second tap on the same lead carries the same submission id, so the route
54
+ * stores it once. Resolves whether the route took it.
55
+ */
56
+ export function postInBackground(form, extra, analyticsId) {
57
+ stamp(form, extra);
58
+ const body = new URLSearchParams();
59
+ for (const [key, value] of new FormData(form))
60
+ if (typeof value === "string" && !(key in extra))
61
+ body.append(key, value);
62
+ for (const [key, value] of Object.entries(extra))
63
+ body.set(key, value);
64
+ if (analyticsId)
65
+ body.set(ANALYTICS_ID_FIELD, analyticsId);
66
+ return fetch(form.action, { method: "POST", body, headers: { Accept: "application/json" }, keepalive: true }).then(res => res.ok, () => false);
67
+ }
46
68
  const LEAD_REF = /^lead-\d+-[0-9a-f]{8}$/;
47
69
  /** A root-relative path on this origin — never `//elsewhere`. */
48
70
  const OWN_PATH = /^\/(?!\/)/;
@@ -16,6 +16,6 @@ export { parseSmtpUrl, sendMail, type Mail, type SendOptions } from "./smtp.js";
16
16
  export { defaultLeadMail, leadNotifier, MAIL_PER_MINUTE, type LeadMail, type LeadMailShown, type LeadNotifier, type NeedLabel, type NotifyEnv } from "./notify.js";
17
17
  export { clientKey, NO_CLIENT_ADDRESS, parseProxyTrust, type ProxyTrust } from "./client-key.js";
18
18
  export { checkLeadStore } from "./boot.js";
19
- export { leadWebhook, panelChannel, panelFlowOf, panelFlowProperties, type BookingQueued, type BookingWebhookContext, type BuildBookingBody, type BuildWebhookBody, type LeadWebhook, type LeadWebhookContext, type LeadWebhookOptions, type PanelFlow, type PanelFlowProperties, } from "./lead-webhook.js";
19
+ export { leadWebhook, panelChannel, panelFlowOf, panelFlowProperties, type BookingQueued, type BookingWebhookContext, type BuildBookingBody, type BuildWebhookBody, type LeadWebhook, type LeadWebhookContext, type LeadWebhookOptions, type PanelChannel, type PanelFlow, type PanelFlowProperties, } from "./lead-webhook.js";
20
20
  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";
21
21
  export { signatureHeaders, signWebhook, type WebhookSignatureHeaders, type WebhookSigning } 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 { channelOf, LEAD_SCHEMA_VERSION } from "../core/lead.js";
4
+ import { channelOf, LEAD_CHANNELS, LEAD_SCHEMA_VERSION } from "../core/lead.js";
5
5
  import { LEAD_FLOWS } from "../core/pricing/flow.js";
6
6
  // `node:sqlite` is reached through `getBuiltinModule` rather than an import so
7
7
  // the bundler never has to resolve a builtin it may not know yet; the type is
@@ -67,6 +67,10 @@ const STEPS = [
67
67
  db.exec("ALTER TABLE leads ADD COLUMN pricing_valid_from TEXT");
68
68
  db.exec("ALTER TABLE leads ADD COLUMN estimate_inputs TEXT");
69
69
  },
70
+ // 8 — the reference a messenger lead's chat carries (`AQ-7K3F`), so the
71
+ // operator finds the lead from the message. Nullable: a row from before, a
72
+ // lead from a card without a reference.
73
+ db => db.exec("ALTER TABLE leads ADD COLUMN message_ref TEXT"),
70
74
  ];
71
75
  if (STEPS.length !== LEAD_SCHEMA_VERSION) {
72
76
  // The version belongs to the port, shared by every adapter; this table of
@@ -99,6 +103,7 @@ function leadOf(row) {
99
103
  const submissionId = str(row, "submission_id");
100
104
  const flow = LEAD_FLOWS.find(f => f === str(row, "flow"));
101
105
  const price = priceOf(row);
106
+ const messageRef = str(row, "message_ref");
102
107
  return {
103
108
  subject: str(row, "job") ?? "",
104
109
  locality: str(row, "zip") ?? "",
@@ -106,11 +111,12 @@ function leadOf(row) {
106
111
  extras: strings(str(row, "extras")),
107
112
  placeSlug: str(row, "location_id"),
108
113
  spamVerdict: verdict !== null && VERDICTS.includes(verdict) ? verdict : null,
109
- channel: str(row, "channel") === "callback" ? "callback" : "form",
114
+ channel: LEAD_CHANNELS.find(c => c === str(row, "channel")) ?? "form",
110
115
  ...(consentAt !== null ? { consent: { at: consentAt, text: str(row, "consent_text") ?? "" } } : {}),
111
116
  ...(submissionId !== null ? { submissionId } : {}),
112
117
  ...(flow !== undefined ? { flow } : {}),
113
118
  ...(price !== null ? { price } : {}),
119
+ ...(messageRef !== null ? { messageRef } : {}),
114
120
  };
115
121
  }
116
122
  function userVersion(db) {
@@ -194,13 +200,13 @@ export function openSqliteLeadStore(path) {
194
200
  db.close();
195
201
  throw error;
196
202
  }
197
- const insert = db.prepare("INSERT INTO leads (job, zip, mobile, location_id, spam_verdict, extras, channel, consent_at, consent_text, submission_id, flow, quoted_cents, pricing_valid_from, estimate_inputs) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id");
198
- const bySubmission = db.prepare("SELECT id, job, zip, mobile, location_id, spam_verdict, extras, channel, consent_at, consent_text, submission_id, flow, quoted_cents, pricing_valid_from, estimate_inputs FROM leads WHERE submission_id = ?");
203
+ const insert = db.prepare("INSERT INTO leads (job, zip, mobile, location_id, spam_verdict, extras, channel, consent_at, consent_text, submission_id, flow, quoted_cents, pricing_valid_from, estimate_inputs, message_ref) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id");
204
+ const bySubmission = db.prepare("SELECT id, job, zip, mobile, location_id, spam_verdict, extras, channel, consent_at, consent_text, submission_id, flow, quoted_cents, pricing_valid_from, estimate_inputs, message_ref FROM leads WHERE submission_id = ?");
199
205
  const count = db.prepare("SELECT COUNT(*) AS n FROM leads");
200
206
  return {
201
207
  async insert(lead) {
202
208
  const extras = Object.keys(lead.extras).length > 0 ? JSON.stringify(lead.extras) : null;
203
- 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, lead.flow ?? null, lead.price?.cents ?? null, lead.price?.validFrom ?? null, lead.price?.inputs ? JSON.stringify(lead.price.inputs) : null), "id");
209
+ 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, lead.flow ?? null, lead.price?.cents ?? null, lead.price?.validFrom ?? null, lead.price?.inputs ? JSON.stringify(lead.price.inputs) : null, lead.messageRef ?? null), "id");
204
210
  },
205
211
  async findSubmission(submissionId) {
206
212
  const row = bySubmission.get(submissionId);
@@ -43,6 +43,18 @@ export interface LeadWebhookContext {
43
43
  * property: it refuses an unknown one, and the outbox would park the lead.
44
44
  */
45
45
  analyticsId?: string;
46
+ /**
47
+ * The lead's channel as the panel's `properties.channel` takes it
48
+ * (`panelChannel`): `whatsapp` and `telegram` only under `panelMessenger`,
49
+ * `form` otherwise. Absent only on a context built by hand.
50
+ */
51
+ channel?: PanelChannel;
52
+ /**
53
+ * The reference the visitor's chat carries (`Lead.messageRef`), for the
54
+ * panel's `properties.message_ref` — only under `panelMessenger`, and only
55
+ * on a lead that has one; otherwise absent, and the body must not carry it.
56
+ */
57
+ messageRef?: string;
46
58
  }
47
59
  /** A lead's sale as the panel's `lead.created` may carry it. */
48
60
  export interface PanelFlow {
@@ -69,14 +81,17 @@ export interface PanelFlowProperties {
69
81
  estimate_inputs?: Readonly<Record<string, string>>;
70
82
  }
71
83
  export declare function panelFlowProperties(flow: PanelFlow | undefined): PanelFlowProperties | Record<string, never>;
84
+ /** A channel a site sends the panel; `phone_inbound` is the panel's own, for the calls it records. */
85
+ export type PanelChannel = "form" | "callback" | "whatsapp" | "telegram";
72
86
  /**
73
87
  * A lead's channel as the Service-Arb panel's `lead.created` carries it in
74
- * `properties.channel` — a closed set (`form` | `phone_inbound` | `callback`)
75
- * that refuses the whole event outside it, so the outbox would park the lead.
76
- * A site's lead is a form or a callback, each its own: `phone_inbound` is the
77
- * panel's, for the calls it records itself. `panelChannel(channelOf(lead))`.
88
+ * `properties.channel` — a closed set that refuses the whole event outside
89
+ * it, so the outbox would park the lead. `messenger`: whether the panel takes
90
+ * `whatsapp` and `telegram` (`LeadWebhookOptions.panelMessenger`); until it
91
+ * does, a messenger lead is a `form` there — the leads table keeps the truth.
92
+ * `ctx.channel` is this, already decided.
78
93
  */
79
- export declare function panelChannel(channel: LeadChannel): "form" | "callback";
94
+ export declare function panelChannel(channel: LeadChannel, messenger?: boolean): PanelChannel;
80
95
  /**
81
96
  * The receiver's body, built by the brand: the kit serialises and signs
82
97
  * whatever this returns and knows nothing of its shape. Called once, when the
@@ -129,6 +144,8 @@ export interface LeadWebhook {
129
144
  readonly panelFlow: boolean;
130
145
  /** Whether `requestBooking` queues anything (`LeadWebhookOptions.panelBooking`). */
131
146
  readonly panelBooking?: boolean;
147
+ /** Whether `ctx.channel` says `whatsapp` / `telegram` and `ctx.messageRef` is filled in (`LeadWebhookOptions.panelMessenger`). */
148
+ readonly panelMessenger?: boolean;
132
149
  /**
133
150
  * Queues `booking.requested@1` behind the lead's `lead.created`, through
134
151
  * the same outbox: not sent until that row is delivered, and a `409` / `425`
@@ -173,6 +190,14 @@ export interface LeadWebhookOptions extends WebhookOutboxOptions {
173
190
  * and dropped; the lead is untouched either way.
174
191
  */
175
192
  panelBooking?: boolean;
193
+ /**
194
+ * The switch for the messenger channels: `ctx.channel` says `whatsapp` or
195
+ * `telegram`, and `ctx.messageRef` carries the lead's reference. Off (the
196
+ * default) until the panel's `lead.created` accepts them — it refuses an
197
+ * unknown channel or property, and the outbox would park the lead: a
198
+ * messenger lead goes as a `form`, without its reference.
199
+ */
200
+ panelMessenger?: boolean;
176
201
  /** The body of `booking.requested@1`; absent → booking requests are dropped whatever `panelBooking` says. */
177
202
  buildBookingBody?: BuildBookingBody | undefined;
178
203
  }
@@ -1,6 +1,6 @@
1
1
  import "server-only";
2
2
  import { randomUUID } from "node:crypto";
3
- import { suspectOf } from "../core/lead.js";
3
+ import { channelOf, suspectOf } from "../core/lead.js";
4
4
  import { experimentsDeclaredBody } from "./experiments-declared.js";
5
5
  import { openWebhookOutbox } from "./webhook-outbox.js";
6
6
  /** The sale `ctx.flow` describes, or nothing for a lead without a flow. */
@@ -23,12 +23,15 @@ export function panelFlowProperties(flow) {
23
23
  }
24
24
  /**
25
25
  * A lead's channel as the Service-Arb panel's `lead.created` carries it in
26
- * `properties.channel` — a closed set (`form` | `phone_inbound` | `callback`)
27
- * that refuses the whole event outside it, so the outbox would park the lead.
28
- * A site's lead is a form or a callback, each its own: `phone_inbound` is the
29
- * panel's, for the calls it records itself. `panelChannel(channelOf(lead))`.
26
+ * `properties.channel` — a closed set that refuses the whole event outside
27
+ * it, so the outbox would park the lead. `messenger`: whether the panel takes
28
+ * `whatsapp` and `telegram` (`LeadWebhookOptions.panelMessenger`); until it
29
+ * does, a messenger lead is a `form` there — the leads table keeps the truth.
30
+ * `ctx.channel` is this, already decided.
30
31
  */
31
- export function panelChannel(channel) {
32
+ export function panelChannel(channel, messenger = false) {
33
+ if (channel === "whatsapp" || channel === "telegram")
34
+ return messenger ? channel : "form";
32
35
  return channel;
33
36
  }
34
37
  /**
@@ -37,7 +40,7 @@ export function panelChannel(channel) {
37
40
  * so a broken setting fails startup rather than a lead.
38
41
  */
39
42
  export function leadWebhook(site, env, options) {
40
- const { buildBody, buildBookingBody, signing, panelSuspect = false, panelFlow = false, panelBooking = false, ...outboxOptions } = options;
43
+ const { buildBody, buildBookingBody, signing, panelSuspect = false, panelFlow = false, panelBooking = false, panelMessenger = false, ...outboxOptions } = options;
41
44
  if (!env.leadWebhook || !buildBody)
42
45
  return null;
43
46
  if (env.leadsDb.kind !== "sqlite")
@@ -50,6 +53,7 @@ export function leadWebhook(site, env, options) {
50
53
  panelSuspect,
51
54
  panelFlow,
52
55
  panelBooking,
56
+ panelMessenger,
53
57
  requestBooking(request) {
54
58
  if (!panelBooking || !buildBookingBody)
55
59
  return { kind: "off" };
@@ -81,6 +85,8 @@ export function leadWebhook(site, env, options) {
81
85
  idempotencyKey: randomUUID(),
82
86
  ...(suspect ? { suspect } : {}),
83
87
  ...(flow ? { flow } : {}),
88
+ channel: panelChannel(channelOf(lead), panelMessenger),
89
+ ...(panelMessenger && lead.messageRef ? { messageRef: lead.messageRef } : {}),
84
90
  };
85
91
  const body = JSON.stringify(buildBody(lead, ctx));
86
92
  if (typeof body !== "string")
@@ -5,16 +5,22 @@ const shownOf = (lead, needLabel) => ({ need: needLabel?.(lead.subject) ?? lead.
5
5
  /** A plain default; a brand passes `format` to write it in its own language. */
6
6
  export function defaultLeadMail(brand, lead, id, options = {}) {
7
7
  const place = lead.placeSlug ?? "unknown";
8
- // A callback request is a promise to ring the customer: it says so first.
9
- const callback = channelOf(lead) === "callback";
8
+ // A callback request is a promise to ring the customer: it says so first. A
9
+ // messenger lead says where the customer will write, and the reference the
10
+ // message carries — what the operator matches the chat by.
11
+ const channel = channelOf(lead);
12
+ const messenger = channel === "whatsapp" ? "WhatsApp" : channel === "telegram" ? "Telegram" : null;
13
+ const kind = channel === "callback" ? "call back" : messenger ? `${messenger} lead` : "new lead";
14
+ const flag = channel === "callback" ? " — CALL BACK" : messenger ? ` — ${messenger.toUpperCase()}` : "";
10
15
  return {
11
- subject: `${brand.name} — ${callback ? "call back" : "new lead"} (${place})`,
16
+ subject: `${brand.name} — ${kind} (${place})`,
12
17
  text: [
13
- `Lead #${id} — place ${place}${callback ? " — CALL BACK" : ""}${lead.spamVerdict ? ` — suspect (${lead.spamVerdict})` : ""}`,
18
+ `Lead #${id} — place ${place}${flag}${lead.spamVerdict ? ` — suspect (${lead.spamVerdict})` : ""}`,
14
19
  "",
20
+ ...(lead.messageRef ? [`Réf. : ${lead.messageRef}`] : []),
15
21
  `Subject : ${shownOf(lead, options.needLabel).need}`,
16
22
  `Locality : ${lead.locality}`,
17
- `Mobile : ${lead.mobile}`,
23
+ `Mobile : ${lead.mobile || (messenger ? `(none — answer on ${messenger})` : "")}`,
18
24
  ...Object.entries(lead.extras).map(([name, value]) => `${name} : ${value}`),
19
25
  ].join("\n"),
20
26
  };
@@ -9,5 +9,5 @@
9
9
  export { describeLandingContract, type LandingContractOptions } from "./landing-contract.js";
10
10
  export { servedPaths } from "./served-files.js";
11
11
  export { describeLeadStoreContract, type LeadStoreHarness } from "./lead-store-contract.js";
12
- export { leadRuleDisagreements, PHONE_MATRIX } from "./lead-rules.js";
12
+ export { leadRuleDisagreements, messengerRuleDisagreements, PHONE_MATRIX } from "./lead-rules.js";
13
13
  export { serviceAreaPlace, storefrontPlace, testLead } from "./fixtures.js";
@@ -9,5 +9,5 @@
9
9
  export { describeLandingContract } from "./landing-contract.js";
10
10
  export { servedPaths } from "./served-files.js";
11
11
  export { describeLeadStoreContract } from "./lead-store-contract.js";
12
- export { leadRuleDisagreements, PHONE_MATRIX } from "./lead-rules.js";
12
+ export { leadRuleDisagreements, messengerRuleDisagreements, PHONE_MATRIX } from "./lead-rules.js";
13
13
  export { serviceAreaPlace, storefrontPlace, testLead } from "./fixtures.js";