@evinvest/kitstart 0.17.0 → 0.18.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 (87) hide show
  1. package/README.md +147 -10
  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/site.d.ts +8 -1
  22. package/dist/core/site.js +22 -1
  23. package/dist/index.d.ts +8 -5
  24. package/dist/index.js +7 -4
  25. package/dist/next/quote-route.js +4 -7
  26. package/dist/react/AnalyticsBoundary.js +3 -3
  27. package/dist/react/LeadCapture.d.ts +24 -1
  28. package/dist/react/LeadCapture.js +96 -11
  29. package/dist/react/LeadCaptureChannels.d.ts +7 -2
  30. package/dist/react/LeadCaptureChannels.js +2 -2
  31. package/dist/react/LeadCaptureEstimate.d.ts +6 -0
  32. package/dist/react/LeadCaptureFields.d.ts +17 -0
  33. package/dist/react/LeadCaptureFields.js +42 -33
  34. package/dist/react/LeadCaptureMessenger.d.ts +41 -0
  35. package/dist/react/LeadCaptureMessenger.js +133 -0
  36. package/dist/react/LeadCapturePrice.d.ts +9 -3
  37. package/dist/react/LeadCapturePrice.js +24 -4
  38. package/dist/react/LeadCaptureSubmit.d.ts +3 -0
  39. package/dist/react/LeadCaptureSubmit.js +2 -2
  40. package/dist/react/LeadCaptureTiles.js +9 -4
  41. package/dist/react/analytics-context.d.ts +9 -3
  42. package/dist/react/analytics-context.js +7 -3
  43. package/dist/react/index.d.ts +1 -0
  44. package/dist/react/messenger/action.d.ts +144 -0
  45. package/dist/react/messenger/action.js +301 -0
  46. package/dist/react/messenger/chip.d.ts +8 -0
  47. package/dist/react/messenger/chip.js +22 -0
  48. package/dist/react/messenger/fallback.d.ts +8 -0
  49. package/dist/react/messenger/fallback.js +11 -0
  50. package/dist/react/messenger/saga.d.ts +10 -0
  51. package/dist/react/messenger/saga.js +33 -0
  52. package/dist/react/messenger/segment.d.ts +8 -0
  53. package/dist/react/messenger/segment.js +15 -0
  54. package/dist/react/messenger/select.d.ts +10 -0
  55. package/dist/react/messenger/select.js +26 -0
  56. package/dist/react/messenger/sheet.d.ts +10 -0
  57. package/dist/react/messenger/sheet.js +57 -0
  58. package/dist/react/messenger/split.d.ts +9 -0
  59. package/dist/react/messenger/split.js +17 -0
  60. package/dist/react/messenger/swap.d.ts +10 -0
  61. package/dist/react/messenger/swap.js +18 -0
  62. package/dist/react/messenger/thanks.d.ts +10 -0
  63. package/dist/react/messenger/thanks.js +18 -0
  64. package/dist/react/messenger/tiles.d.ts +8 -0
  65. package/dist/react/messenger/tiles.js +20 -0
  66. package/dist/react/messenger/types.d.ts +98 -0
  67. package/dist/react/messenger/types.js +1 -0
  68. package/dist/react/messenger/urgency.d.ts +10 -0
  69. package/dist/react/messenger/urgency.js +29 -0
  70. package/dist/react/use-lead-error.d.ts +3 -3
  71. package/dist/react/use-lead-events.d.ts +21 -1
  72. package/dist/react/use-lead-events.js +9 -3
  73. package/dist/react/use-lead-submit.d.ts +20 -6
  74. package/dist/react/use-lead-submit.js +23 -1
  75. package/dist/server/index.d.ts +1 -1
  76. package/dist/server/lead-store-sqlite.js +11 -5
  77. package/dist/server/lead-webhook.d.ts +30 -5
  78. package/dist/server/lead-webhook.js +13 -7
  79. package/dist/server/notify.js +11 -5
  80. package/dist/testing/index.d.ts +1 -1
  81. package/dist/testing/index.js +1 -1
  82. package/dist/testing/landing-contract.js +6 -1
  83. package/dist/testing/lead-rules.d.ts +8 -0
  84. package/dist/testing/lead-rules.js +30 -1
  85. package/package.json +5 -2
  86. package/template/flake.nix +1 -1
  87. package/template/package.json +2 -2
@@ -48,14 +48,20 @@ export function LocalityField(props) {
48
48
  * (`phoneProblem`): one it would refuse blocks the submit, in the page's
49
49
  * words. Leaving the field with such a number shows the hint already — a
50
50
  * hint, read out, not yet an error.
51
+ *
52
+ * A `messenger` variant asks it differently: `optional` (the chat answers;
53
+ * a number typed is still held to the rule), `disabled` (a bot needs none),
54
+ * in a box of the variant's with a channel picker beside it (`frame`), or in
55
+ * a drawer outside the form's element (`form`, the form's id).
51
56
  */
52
57
  export function PhoneField(props) {
53
- const { name, text, onSoftError, classNames: c } = props;
58
+ const { name, text, onSoftError, frame, classNames: c } = props;
59
+ const optional = props.optional === true || props.disabled === true;
54
60
  const messageId = useId();
55
61
  const [doubtful, setDoubtful] = useState(false);
56
62
  const validity = useValidity(el => {
57
63
  const problem = phoneProblem(el.value);
58
- return problem === "required" ? text.required : problem === "invalid" ? text.phoneInvalid : "";
64
+ return problem === "required" ? (optional ? "" : text.required) : problem === "invalid" ? text.phoneInvalid : "";
59
65
  });
60
66
  const error = props.error ?? validity.shown;
61
67
  const marked = Boolean(error) || doubtful;
@@ -95,37 +101,40 @@ export function PhoneField(props) {
95
101
  onSoftError();
96
102
  setDoubtful(bad);
97
103
  };
98
- return (_jsxs(Field, { ...(props.id ? { controlId: props.id } : {}), className: cn("flex flex-col gap-2", c?.field), children: [_jsx(FieldLabel, { className: c?.label, children: text.phoneLabel }), _jsx(Input, { ref: validity.ref, name: name, size: "lg", ...PHONE_INPUT_PROPS, enterKeyHint: "send", placeholder: text.phonePlaceholder, required: true, "data-lead-field": "phone", "aria-invalid": marked || undefined, "aria-describedby": marked ? messageId : undefined, onInvalid: validity.onInvalid, onBlur: e => {
99
- const value = e.currentTarget.value;
100
- const press = pressed.current;
101
- pressed.current = null;
102
- if (press === "submit")
103
- return;
104
- if (press === null)
105
- return judge(value);
106
- // After the press's click, which lands where the press began; a
107
- // cancelled press (a scroll) ends the wait too, and so does a
108
- // release no click follows (dragged off), a moment later.
109
- let timer;
110
- const ends = ["click", "pointercancel"];
111
- const over = () => {
112
- for (const type of ends)
113
- window.removeEventListener(type, over, true);
114
- window.removeEventListener("pointerup", late, true);
115
- clearTimeout(timer);
116
- setTimeout(() => judge(value), 0);
117
- };
118
- const late = () => {
119
- timer = setTimeout(over, 500);
120
- };
121
- for (const type of ends)
122
- window.addEventListener(type, over, true);
123
- window.addEventListener("pointerup", late, true);
124
- }, onChange: e => {
125
- validity.onChange(e);
126
- if (doubtful && phoneProblem(e.currentTarget.value) === null)
127
- setDoubtful(false);
128
- }, className: c?.control }), _jsx(FieldMessage, { id: messageId, error: error, hint: doubtful ? text.phoneHint : null, classNames: c })] }));
104
+ const input = (_jsx(Input, { ref: validity.ref, name: name, size: "lg", ...PHONE_INPUT_PROPS, enterKeyHint: "send", placeholder: props.placeholder ?? text.phonePlaceholder, required: !optional, disabled: props.disabled, form: props.form, "data-lead-field": "phone", "aria-invalid": marked || undefined, "aria-describedby": marked ? messageId : undefined, onInvalid: validity.onInvalid, onBlur: e => {
105
+ const value = e.currentTarget.value;
106
+ const press = pressed.current;
107
+ pressed.current = null;
108
+ if (press === "submit")
109
+ return;
110
+ if (press === null)
111
+ return judge(value);
112
+ // After the press's click, which lands where the press began; a
113
+ // cancelled press (a scroll) ends the wait too, and so does a
114
+ // release no click follows (dragged off), a moment later.
115
+ let timer;
116
+ const ends = ["click", "pointercancel"];
117
+ const over = () => {
118
+ for (const type of ends)
119
+ window.removeEventListener(type, over, true);
120
+ window.removeEventListener("pointerup", late, true);
121
+ clearTimeout(timer);
122
+ setTimeout(() => judge(value), 0);
123
+ };
124
+ const late = () => {
125
+ timer = setTimeout(over, 500);
126
+ };
127
+ for (const type of ends)
128
+ window.addEventListener(type, over, true);
129
+ window.addEventListener("pointerup", late, true);
130
+ }, onChange: e => {
131
+ validity.onChange(e);
132
+ if (doubtful && phoneProblem(e.currentTarget.value) === null)
133
+ setDoubtful(false);
134
+ },
135
+ // In a frame the box is the frame's: the input loses its own border and ring.
136
+ className: frame ? "h-full flex-1 rounded-none border-0 bg-transparent shadow-none focus-visible:ring-0" : c?.control }));
137
+ return (_jsxs(Field, { ...(props.id ? { controlId: props.id } : {}), className: cn("flex flex-col gap-2", c?.field), children: [_jsx(FieldLabel, { className: c?.label, children: text.phoneLabel }), frame ? frame(input) : input, _jsx(FieldMessage, { id: messageId, error: error, hint: doubtful ? text.phoneHint : null, classNames: c })] }));
129
138
  }
130
139
  export function NameField(props) {
131
140
  const { name, label, placeholder, optional, classNames: c } = props;
@@ -0,0 +1,41 @@
1
+ import { type MessengerFacts, type MessengerMode, type MessengerVariant } from "../core/messenger.js";
2
+ import type { MessengerAt, MessengerKit, MessengerShown } from "./messenger/types.js";
3
+ export declare const NO_MESSENGERS: MessengerFacts;
4
+ /**
5
+ * What a variant becomes on this place: itself while the place has WhatsApp;
6
+ * without it the control (Figma's «Выключено в панели») — with a way to the
7
+ * bot when there is one — except `urgency`, whose question is the lead's
8
+ * (both answers then ask the phone), and `thanks`, whose success keeps the
9
+ * bot. `null`: the control as it is.
10
+ */
11
+ export declare function messengerShownOf(variant: MessengerVariant | undefined, facts: MessengerFacts): MessengerShown | null;
12
+ export declare function drawsAt(shown: MessengerShown | null, at: MessengerAt): boolean;
13
+ /** The channel a variant starts on: none until asked (`saga`, `urgency`), else WhatsApp. */
14
+ export declare function initialMode(variant: MessengerVariant | undefined): MessengerMode | null;
15
+ /**
16
+ * The card's `message_ref`, minted once the script runs — a server render
17
+ * cannot know it without the hydration seeing another — and again by
18
+ * `renew`, after a messenger lead is posted.
19
+ */
20
+ export declare function useMessageRef(prefix: string | undefined, on: boolean): {
21
+ ref: string | null;
22
+ renew: () => void;
23
+ minted: boolean;
24
+ };
25
+ /**
26
+ * One variant's piece at one place in the card; nothing where it draws
27
+ * nothing.
28
+ *
29
+ * Drawn by the server, or hydrated, it has no boundary of its own. The server
30
+ * finds the variant loaded (every one is, as this module loads) and writes it
31
+ * into the page whole — a boundary would not do: React writes a large
32
+ * boundary's content apart from the shell and reveals it late, the card drawn
33
+ * without its contact block first. Hydrating, a variant whose chunk is still
34
+ * on its way holds the card's hydration — the server's markup stays as it is,
35
+ * and nothing in the card runs until the chunk is here. A piece first drawn
36
+ * after that (a step, the success) is the browser's own: it waits behind a
37
+ * boundary, its chunk loaded then.
38
+ */
39
+ export declare function MessengerSlot({ kit }: {
40
+ kit: MessengerKit;
41
+ }): import("react").JSX.Element | null;
@@ -0,0 +1,133 @@
1
+ "use client";
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ import { lazy, Suspense, useEffect, useState, useSyncExternalStore } from "react";
4
+ import { MESSAGE_REF_PREFIX, newMessageRef } from "../core/messenger.js";
5
+ /**
6
+ * The `messenger` variants, each its own chunk: a page pays for the one its
7
+ * arm draws, and the control for none. `LeadCapture` only says where.
8
+ */
9
+ const LOAD = {
10
+ select: () => import("./messenger/select.js"),
11
+ segment: () => import("./messenger/segment.js"),
12
+ tiles: () => import("./messenger/tiles.js"),
13
+ thanks: () => import("./messenger/thanks.js"),
14
+ swap: () => import("./messenger/swap.js"),
15
+ saga: () => import("./messenger/saga.js"),
16
+ urgency: () => import("./messenger/urgency.js"),
17
+ sheet: () => import("./messenger/sheet.js"),
18
+ chip: () => import("./messenger/chip.js"),
19
+ split: () => import("./messenger/split.js"),
20
+ fallback: () => import("./messenger/fallback.js"),
21
+ };
22
+ const LOADING = new Map();
23
+ /** The variants whose chunk has arrived, by kind. */
24
+ const MODULES = new Map();
25
+ /** The variant's chunk, asked for once a page. */
26
+ function load(kind) {
27
+ const known = LOADING.get(kind);
28
+ if (known)
29
+ return known;
30
+ const loading = LOAD[kind]().then(mod => {
31
+ MODULES.set(kind, mod);
32
+ return mod;
33
+ });
34
+ LOADING.set(kind, loading);
35
+ return loading;
36
+ }
37
+ const KINDS = ["select", "segment", "tiles", "thanks", "swap", "saga", "urgency", "sheet", "chip", "split", "fallback"];
38
+ // On the server every variant is loaded as the module is: a render then finds
39
+ // its variant here and writes it into the page whole. One still loading
40
+ // renders through `lazy` and streams in behind its boundary, after the shell
41
+ // — React reveals such a boundary late, and the card would draw without its
42
+ // contact block first.
43
+ if (typeof window === "undefined")
44
+ for (const kind of KINDS)
45
+ void load(kind).catch(() => undefined);
46
+ /** Until its chunk is here, a variant is drawn through `lazy`, behind its boundary. */
47
+ const LAZY = new Map(KINDS.map(kind => [kind, lazy(() => load(kind))]));
48
+ /** The variant's component: its module's own once it is here, else its lazy stand-in. */
49
+ function partOf(kind) {
50
+ return MODULES.get(kind)?.default ?? LAZY.get(kind) ?? null;
51
+ }
52
+ /** Where each variant draws; everywhere else the card is the control's. */
53
+ const USES = {
54
+ select: ["contact"],
55
+ segment: ["contact"],
56
+ tiles: ["contact"],
57
+ thanks: ["done"],
58
+ swap: ["contact"],
59
+ saga: ["head", "body", "contact"],
60
+ urgency: ["beforeNeed", "contact"],
61
+ sheet: ["contact"],
62
+ chip: ["afterHead", "contact"],
63
+ split: ["contact"],
64
+ fallback: ["afterSubmit"],
65
+ };
66
+ export const NO_MESSENGERS = { whatsapp: null, telegram: null };
67
+ /**
68
+ * What a variant becomes on this place: itself while the place has WhatsApp;
69
+ * without it the control (Figma's «Выключено в панели») — with a way to the
70
+ * bot when there is one — except `urgency`, whose question is the lead's
71
+ * (both answers then ask the phone), and `thanks`, whose success keeps the
72
+ * bot. `null`: the control as it is.
73
+ */
74
+ export function messengerShownOf(variant, facts) {
75
+ if (!variant)
76
+ return null;
77
+ if (facts.whatsapp)
78
+ return variant.kind;
79
+ if (variant.kind === "urgency")
80
+ return "urgency";
81
+ if (!facts.telegram)
82
+ return null;
83
+ return variant.kind === "thanks" ? "thanks" : "fallback";
84
+ }
85
+ export function drawsAt(shown, at) {
86
+ return shown !== null && USES[shown].includes(at);
87
+ }
88
+ /** The channel a variant starts on: none until asked (`saga`, `urgency`), else WhatsApp. */
89
+ export function initialMode(variant) {
90
+ return variant?.kind === "saga" || variant?.kind === "urgency" ? null : "whatsapp";
91
+ }
92
+ /**
93
+ * The card's `message_ref`, minted once the script runs — a server render
94
+ * cannot know it without the hydration seeing another — and again by
95
+ * `renew`, after a messenger lead is posted.
96
+ */
97
+ export function useMessageRef(prefix, on) {
98
+ const [ref, setRef] = useState(null);
99
+ // Without a prefix there is no reference to wait for; with one, nothing is
100
+ // minted on the server — an ISR page is one render for every visitor.
101
+ const [settled, setSettled] = useState(false);
102
+ const valid = on && prefix !== undefined && MESSAGE_REF_PREFIX.test(prefix);
103
+ useEffect(() => {
104
+ if (valid && prefix !== undefined)
105
+ setRef(newMessageRef(prefix));
106
+ setSettled(true);
107
+ }, [valid, prefix]);
108
+ return { ref, renew: () => (valid && prefix !== undefined ? setRef(newMessageRef(prefix)) : undefined), minted: settled && (!valid || ref !== null) };
109
+ }
110
+ const noop = () => () => undefined;
111
+ /**
112
+ * One variant's piece at one place in the card; nothing where it draws
113
+ * nothing.
114
+ *
115
+ * Drawn by the server, or hydrated, it has no boundary of its own. The server
116
+ * finds the variant loaded (every one is, as this module loads) and writes it
117
+ * into the page whole — a boundary would not do: React writes a large
118
+ * boundary's content apart from the shell and reveals it late, the card drawn
119
+ * without its contact block first. Hydrating, a variant whose chunk is still
120
+ * on its way holds the card's hydration — the server's markup stays as it is,
121
+ * and nothing in the card runs until the chunk is here. A piece first drawn
122
+ * after that (a step, the success) is the browser's own: it waits behind a
123
+ * boundary, its chunk loaded then.
124
+ */
125
+ export function MessengerSlot({ kit }) {
126
+ const hydrating = useSyncExternalStore(noop, () => false, () => true);
127
+ // Chosen on the first render: a structure swapped under the slot would draw the variant anew.
128
+ const [wrapped] = useState(() => !hydrating);
129
+ const [Part] = useState(() => partOf(kit.shown));
130
+ if (!drawsAt(kit.shown, kit.at) || !Part)
131
+ return null;
132
+ return wrapped ? (_jsx(Suspense, { fallback: null, children: _jsx(Part, { kit: kit }) })) : (_jsx(Part, { kit: kit }));
133
+ }
@@ -2,7 +2,12 @@ import type { LeadCaptureFlowText } from "../core/lead-capture-text.js";
2
2
  import type { LeadFlow } from "../core/pricing/flow.js";
3
3
  import type { Price, PricingModel } from "../core/pricing/model.js";
4
4
  import type { PartClassNames } from "./parts.js";
5
- export type PricePart = "price" | "priceTotal" | "breakdown" | "priceNote" | "priceLine" | "priceTaxCredit" | "priceDetail";
5
+ /**
6
+ * `priceDetail`: the compact price's «Détail», the trigger — the popover's
7
+ * button, or the `<details>`' summary without a script; `priceDetailContent`:
8
+ * the breakdown it pops over the card.
9
+ */
10
+ export type PricePart = "price" | "priceTotal" | "breakdown" | "priceNote" | "priceLine" | "priceTaxCredit" | "priceDetail" | "priceDetailContent";
6
11
  /**
7
12
  * The price, read out as it changes (`aria-live`): an estimate's total and
8
13
  * how it was reached, or — until every question is answered — what is
@@ -22,8 +27,9 @@ export declare const afterCredit: (cents: number, ratio: number) => number;
22
27
  /**
23
28
  * `price="compact"`: the price on one line (`priceLine`), what is left after
24
29
  * the brand's tax credit when it has one (`taxCredit`, a ratio), and how it
25
- * was reached behind a disclosure — a `<details>`, so it opens without a
26
- * script. Read out as it changes, the line only.
30
+ * was reached behind «Détail» — the kit's `Popover`, over the card, so
31
+ * opening it moves nothing; without a script a `<details>`, which opens in
32
+ * place. Read out as it changes, the line only.
27
33
  */
28
34
  export declare function PriceCompact(props: {
29
35
  model: PricingModel;
@@ -1,5 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
- import { cn } from "@evinvest/uikit";
2
+ import { cn, Popover, PopoverContent, PopoverTrigger } from "@evinvest/uikit";
3
+ import { useSyncExternalStore } from "react";
3
4
  import { fillText, formatCents } from "../core/lead-capture-format.js";
4
5
  import { labelOf } from "../core/pricing/validate.js";
5
6
  function lineLabel(line, model, locale, text) {
@@ -36,12 +37,31 @@ export const afterCredit = (cents, ratio) => cents - Math.round(cents * ratio);
36
37
  /**
37
38
  * `price="compact"`: the price on one line (`priceLine`), what is left after
38
39
  * the brand's tax credit when it has one (`taxCredit`, a ratio), and how it
39
- * was reached behind a disclosure — a `<details>`, so it opens without a
40
- * script. Read out as it changes, the line only.
40
+ * was reached behind «Détail» — the kit's `Popover`, over the card, so
41
+ * opening it moves nothing; without a script a `<details>`, which opens in
42
+ * place. Read out as it changes, the line only.
41
43
  */
42
44
  export function PriceCompact(props) {
43
45
  const { model, flow, price, locale, taxCredit, text, classNames: c } = props;
44
46
  const lines = price?.breakdown.filter((line, i) => i === 0 || line.cents !== 0) ?? [];
45
47
  const credit = price && taxCredit !== undefined && taxCredit > 0 && taxCredit < 1 ? afterCredit(price.cents, taxCredit) : null;
46
- return (_jsxs("div", { className: cn("flex flex-col gap-1", c?.price), children: [_jsx("p", { "aria-live": "polite", "aria-atomic": "true", className: cn("flex flex-wrap items-baseline gap-x-2 text-ink", c?.priceLine), children: price ? (_jsxs(_Fragment, { children: [_jsx("span", { className: cn("font-semibold", c?.priceTotal), "data-price-cents": price.cents, children: fillText(text.priceLine, { price: formatCents(price.cents, locale) }) }), credit !== null && (_jsxs("span", { className: cn("text-sm text-ink-soft", c?.priceTaxCredit), "data-credit-cents": credit, children: [_jsx("span", { "aria-hidden": "true", children: "\u00B7 " }), fillText(text.priceTaxCredit, { price: formatCents(credit, locale) })] }))] })) : (_jsx("span", { className: "text-sm text-ink-soft", children: text.pricePending })) }), flow === "estimate" && lines.length > 1 && (_jsxs("details", { className: cn("text-sm text-ink-soft", c?.priceDetail), children: [_jsx("summary", { className: "inline-flex min-h-11 cursor-pointer items-center underline underline-offset-4", children: text.priceDetail }), _jsx("ul", { className: cn("flex flex-col gap-1 pb-2", c?.breakdown), children: lines.map((line, i) => (_jsxs("li", { className: "flex justify-between gap-3", children: [_jsx("span", { children: lineLabel(line, model, locale, text) }), _jsx("span", { className: "tabular-nums", children: formatCents(line.cents, locale, i > 0) })] }, i))) }), _jsx("p", { className: c?.priceNote, children: text.priceNote })] }))] }));
48
+ return (_jsxs("div", { className: cn("flex flex-col gap-1", c?.price), children: [_jsx("p", { "aria-live": "polite", "aria-atomic": "true", className: cn("flex flex-wrap items-baseline gap-x-2 text-ink", c?.priceLine), children: price ? (_jsxs(_Fragment, { children: [_jsx("span", { className: cn("font-semibold", c?.priceTotal), "data-price-cents": price.cents, children: fillText(text.priceLine, { price: formatCents(price.cents, locale) }) }), credit !== null && (_jsxs("span", { className: cn("text-sm text-ink-soft", c?.priceTaxCredit), "data-credit-cents": credit, children: [_jsx("span", { "aria-hidden": "true", children: "\u00B7 " }), fillText(text.priceTaxCredit, { price: formatCents(credit, locale) })] }))] })) : (_jsx("span", { className: "text-sm text-ink-soft", children: text.pricePending })) }), flow === "estimate" && lines.length > 1 && (_jsxs(PriceDetail, { label: text.priceDetail, classNames: c, children: [_jsx("ul", { className: cn("flex flex-col gap-1 pb-2", c?.breakdown), children: lines.map((line, i) => (_jsxs("li", { className: "flex justify-between gap-3", children: [_jsx("span", { children: lineLabel(line, model, locale, text) }), _jsx("span", { className: "tabular-nums", children: formatCents(line.cents, locale, i > 0) })] }, i))) }), _jsx("p", { className: c?.priceNote, children: text.priceNote })] }))] }));
49
+ }
50
+ const noop = () => () => undefined;
51
+ /** The trigger both forms share: one height, so the swap after hydration moves nothing. */
52
+ const TRIGGER = "inline-flex min-h-11 cursor-pointer items-center text-sm text-ink-soft underline underline-offset-4";
53
+ /**
54
+ * The breakdown behind «Détail». Scripted: the kit's `Popover` — over the
55
+ * card, closed by a tap outside or Escape, kept inside the screen. The
56
+ * server's (and a browser without scripts): a `<details>` opening in place;
57
+ * hydration draws that first, then the popover — `useSyncExternalStore`'s
58
+ * server snapshot is what hydration reads.
59
+ */
60
+ function PriceDetail(props) {
61
+ const { label, classNames: c } = props;
62
+ const scripted = useSyncExternalStore(noop, () => true, () => false);
63
+ if (!scripted) {
64
+ return (_jsxs("details", { className: "text-sm text-ink-soft", children: [_jsx("summary", { className: cn(TRIGGER, c?.priceDetail), children: label }), _jsx("div", { className: c?.priceDetailContent, children: props.children })] }));
65
+ }
66
+ return (_jsxs(Popover, { children: [_jsx(PopoverTrigger, { type: "button", className: cn("self-start", TRIGGER, c?.priceDetail), children: label }), _jsx(PopoverContent, { role: "dialog", "aria-label": label, side: "bottom", align: "end", sideOffset: 8, className: cn("max-w-[calc(100vw-16px)] text-sm text-ink-soft", c?.priceDetailContent), children: props.children })] }));
47
67
  }
@@ -1,3 +1,4 @@
1
+ import type { ReactNode } from "react";
1
2
  import type { LeadCaptureText } from "../core/lead-capture-text.js";
2
3
  import type { SendFailure } from "./use-lead-submit.js";
3
4
  /** The submit, busy while the script posts: disabled, `aria-busy`, and saying so. */
@@ -6,6 +7,8 @@ export declare function SubmitButton(props: {
6
7
  label: string;
7
8
  sending: string;
8
9
  className: string | undefined;
10
+ form?: string | undefined;
11
+ children?: ReactNode;
9
12
  }): import("react").JSX.Element;
10
13
  /** Why the post got no answer, and the way to send the same lead again. */
11
14
  export declare function FailureMessage(props: {
@@ -1,8 +1,8 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
2
  import { Button, cn, FieldError } from "@evinvest/uikit";
3
3
  /** The submit, busy while the script posts: disabled, `aria-busy`, and saying so. */
4
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 }));
5
+ return (_jsxs(Button, { type: "submit", size: "touch", form: props.form, disabled: props.busy, "aria-busy": props.busy || undefined, className: cn("w-full", props.className), children: [props.children, props.busy ? props.sending : props.label] }));
6
6
  }
7
7
  /** Why the post got no answer, and the way to send the same lead again. */
8
8
  export function FailureMessage(props) {
@@ -21,9 +21,12 @@ export function TileGroup(props) {
21
21
  radio.setCustomValidity(value === undefined ? requiredText : "");
22
22
  });
23
23
  const markerProps = marker ? { [marker]: "" } : {};
24
- 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.legend), children: legend }), _jsx("div", { className: c.grid, children: options.map(o => (_jsxs("label", { className: cn("relative block cursor-pointer", o.span === 2 && "col-span-2"), children: [_jsx("input", { type: "radio", name: name, value: o.value, required: required, checked: value === o.value, onPointerDown: () => {
25
- pointer.current = true;
26
- }, onKeyDown: (e) => {
24
+ 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.legend), children: legend }), _jsx("div", { className: c.grid, children: options.map(o => (_jsxs("label", { className: cn("relative block cursor-pointer", o.span === 2 && "col-span-2"),
25
+ // On the label, not the radio: a tap on the tile's words lands on them,
26
+ // and the label's click on the radio (`detail` 0) must still read as a tap.
27
+ onPointerDown: () => {
28
+ pointer.current = true;
29
+ }, children: [_jsx("input", { type: "radio", name: name, value: o.value, required: required, checked: value === o.value, onKeyDown: (e) => {
27
30
  pointer.current = false;
28
31
  answered.current = false;
29
32
  if (e.key !== "Enter" || !enterPicks)
@@ -46,5 +49,7 @@ export function TileGroup(props) {
46
49
  if (!answered.current)
47
50
  onSelect(o.value);
48
51
  answered.current = false;
49
- }, className: "peer absolute inset-0 opacity-0", "data-lead-field": field, "aria-label": o.shortLabel !== undefined ? [o.label, o.badge, o.aside].filter(Boolean).join(", ") : undefined, ...markerProps }), _jsxs("span", { className: cn("flex min-h-11 items-center rounded-[var(--control-radius)] border border-input text-ink transition-colors", "peer-checked:border-primary peer-checked:bg-hover peer-focus-visible:ring-[3px] peer-focus-visible:ring-ring/50", c.tile, o.className), children: [o.icon !== undefined && (_jsx("span", { "aria-hidden": "true", className: cn("flex shrink-0 items-center justify-center [&_svg]:size-6", c.icon), children: o.icon })), o.shortLabel !== undefined ? (_jsxs(_Fragment, { children: [_jsx("span", { "aria-hidden": "true", className: "min-w-0 sm:hidden", children: o.shortLabel }), _jsx("span", { className: "min-w-0 max-sm:hidden", children: o.label })] })) : (_jsx("span", { className: "min-w-0", children: o.label })), o.badge && _jsx("span", { className: cn("rounded-full bg-hover px-2 py-0.5 text-xs font-medium text-ink", c.badge), children: o.badge }), o.aside && _jsx("span", { className: cn("ms-auto shrink-0 font-medium tabular-nums", c.aside), children: o.aside })] })] }, o.value))) })] }));
52
+ },
53
+ // The whole tile: without a size WebKit keeps a radio 12×12 in its corner.
54
+ className: "peer absolute inset-0 m-0 size-full cursor-pointer opacity-0", "data-lead-field": field, "aria-label": o.shortLabel !== undefined ? [o.label, o.badge, o.aside].filter(Boolean).join(", ") : undefined, ...markerProps }), _jsxs("span", { className: cn("flex min-h-11 items-center rounded-[var(--control-radius)] border border-input text-ink transition-colors", "peer-checked:border-primary peer-checked:bg-hover peer-focus-visible:ring-[3px] peer-focus-visible:ring-ring/50", c.tile, o.className), children: [o.icon !== undefined && (_jsx("span", { "aria-hidden": "true", className: cn("flex shrink-0 items-center justify-center [&_svg]:size-6", c.icon), children: o.icon })), o.shortLabel !== undefined ? (_jsxs(_Fragment, { children: [_jsx("span", { "aria-hidden": "true", className: "min-w-0 sm:hidden", children: o.shortLabel }), _jsx("span", { className: "min-w-0 max-sm:hidden", children: o.label })] })) : (_jsx("span", { className: "min-w-0", children: o.label })), o.badge && _jsx("span", { className: cn("rounded-full bg-hover px-2 py-0.5 text-xs font-medium text-ink", c.badge), children: o.badge }), o.aside && _jsx("span", { className: cn("ms-auto shrink-0 font-medium tabular-nums", c.aside), children: o.aside })] })] }, o.value))) })] }));
50
55
  }
@@ -1,5 +1,4 @@
1
1
  import type { AnalyticsSink } from "@evinvest/analytics";
2
- import { experimentProps } from "../core/analytics.js";
3
2
  /**
4
3
  * The sink `AnalyticsBoundary` built, for an island inside it that reports
5
4
  * events of its own (`LeadCapture`). Outside a boundary there is none, and the
@@ -14,5 +13,12 @@ export declare const useAnalyticsSink: () => AnalyticsSink | null;
14
13
  */
15
14
  export declare const AnalyticsIdContext: import("react").Context<string | null>;
16
15
  export declare const useAnalyticsId: () => string | null;
17
- /** `data-experiment` / `data-variant` on an element or its ancestors, for an intent's event. */
18
- export declare function experimentOf(el: Element | null): ReturnType<typeof experimentProps>;
16
+ /**
17
+ * `data-experiment` / `data-variant` on an element or its ancestors, for an
18
+ * intent's event — and `data-channels-available`, the messengers its card offered.
19
+ */
20
+ export declare function experimentOf(el: Element | null): {
21
+ experiment?: string;
22
+ variant?: string;
23
+ channels_available?: string;
24
+ };
@@ -1,6 +1,6 @@
1
1
  "use client";
2
2
  import { createContext, useContext } from "react";
3
- import { experimentProps } from "../core/analytics.js";
3
+ import { channelsProps, experimentProps } from "../core/analytics.js";
4
4
  /**
5
5
  * The sink `AnalyticsBoundary` built, for an island inside it that reports
6
6
  * events of its own (`LeadCapture`). Outside a boundary there is none, and the
@@ -15,8 +15,12 @@ export const useAnalyticsSink = () => useContext(AnalyticsSinkContext);
15
15
  */
16
16
  export const AnalyticsIdContext = createContext(null);
17
17
  export const useAnalyticsId = () => useContext(AnalyticsIdContext);
18
- /** `data-experiment` / `data-variant` on an element or its ancestors, for an intent's event. */
18
+ /**
19
+ * `data-experiment` / `data-variant` on an element or its ancestors, for an
20
+ * intent's event — and `data-channels-available`, the messengers its card offered.
21
+ */
19
22
  export function experimentOf(el) {
20
23
  const host = el?.closest("[data-experiment]");
21
- return experimentProps(host?.getAttribute("data-experiment"), host?.getAttribute("data-variant"));
24
+ const card = el?.closest("[data-channels-available]");
25
+ return { ...experimentProps(host?.getAttribute("data-experiment"), host?.getAttribute("data-variant")), ...channelsProps(card?.getAttribute("data-channels-available")) };
22
26
  }
@@ -32,6 +32,7 @@ export type { LeadCaptureLayout, LeadNeedDisplay, LeadNeedOption } from "./LeadC
32
32
  export type { EstimateQuestion, EstimateQuestions } from "./LeadCaptureEstimate.js";
33
33
  export type { LeadIntro, LeadIntroOption } from "./lead-steps.js";
34
34
  export type { ChannelIconKey } from "./LeadCaptureChannels.js";
35
+ export type { MessengerPart } from "./messenger/types.js";
35
36
  export { MapFacade, type MapFacadePart, type MapFacadeProps } from "./MapFacade.js";
36
37
  export { PlaceDirectory, type PlaceDirectoryPart, type PlaceDirectoryProps } from "./PlaceDirectory.js";
37
38
  export { PHONE_INPUT_PROPS, QuoteFormShell, type QuoteFormShellProps } from "./QuoteFormShell.js";
@@ -0,0 +1,144 @@
1
+ import { type MouseEvent, type ReactNode } from "react";
2
+ import { type MessengerMode } from "../../core/messenger.js";
3
+ import { type LeadCaptureMessengerText } from "../../core/messenger-text.js";
4
+ import type { MessengerKit } from "./types.js";
5
+ export type Messenger = Exclude<MessengerMode, "call">;
6
+ /** The variant's words, the brand's over the kit's. */
7
+ export declare const wordsOf: (kit: MessengerKit) => LeadCaptureMessengerText;
8
+ /**
9
+ * What the message says: the need the card knows, the postcode as the
10
+ * visitor types it — and the estimate, the timing and the reference. The
11
+ * preview follows the typing; `compose` reads the form again at the tap, so
12
+ * the link carries what is on screen even after a value set by script.
13
+ */
14
+ export interface Message {
15
+ message: string;
16
+ preview: string;
17
+ compose: () => string;
18
+ }
19
+ export declare function useMessage(kit: MessengerKit, own?: string | null): Message;
20
+ /** Where a messenger CTA leads: wa.me with the message, or the bot with the reference. */
21
+ export declare function hrefOf(kit: MessengerKit, channel: Messenger, message: string): string | null;
22
+ /**
23
+ * A messenger link's attributes. Not `ready` (before the script, before the
24
+ * reference): no `href` — nothing to follow — and `aria-disabled`.
25
+ */
26
+ export interface LinkAttrs {
27
+ href?: string;
28
+ onClick: (event: MouseEvent<HTMLAnchorElement>) => void;
29
+ target?: string;
30
+ rel?: string;
31
+ "aria-disabled"?: true;
32
+ }
33
+ export interface MessengerAction {
34
+ /** A messenger CTA's link: the real `href` (the OS opens the app from the tap) and what the tap does first. */
35
+ link: (channel: Messenger) => LinkAttrs | null;
36
+ /** On a computer, WhatsApp's QR code, drawn in place of the slot; `null` otherwise. */
37
+ panel: ReactNode;
38
+ /** Back from the messenger: drawn over the card's form. */
39
+ overlay: ReactNode;
40
+ }
41
+ /**
42
+ * The tap on a messenger CTA, shared by every variant: a number typed must be
43
+ * one (the browser says so, nothing leaves); the lead is posted in the
44
+ * background; on a phone the link opens the app, on a computer WhatsApp is a
45
+ * QR code in place and Telegram a new tab. Back on the page, the card asks
46
+ * whether the message went, with the way to a call when it did not.
47
+ */
48
+ export declare function useMessengerAction(kit: MessengerKit, message: Message, onCallback: () => void,
49
+ /**
50
+ * `followUp`: the lead is already sent (`thanks`) — a tap posts nothing,
51
+ * keeps the reference and asks nothing on the way back.
52
+ */
53
+ options?: {
54
+ followUp?: boolean;
55
+ }): MessengerAction;
56
+ /** To the phone: the variant's call state, its field focused once it is drawn. */
57
+ export declare function toCall(kit: MessengerKit): void;
58
+ /** A computer, as `isDesktop` says it — after hydration only. */
59
+ export declare function useDesktop(): boolean;
60
+ /** The slot by the channel — the message, the bot, or the phone — and the channel's button under it. */
61
+ export declare function ModeBody(props: {
62
+ kit: MessengerKit;
63
+ message: Message;
64
+ action: MessengerAction;
65
+ labels?: Partial<Record<MessengerMode, string>>;
66
+ }): import("react").JSX.Element;
67
+ /** Whether the brand drew an icon for a channel: a square without one says its name instead. */
68
+ export declare const hasIcon: (kit: MessengerKit, channel: MessengerMode) => boolean;
69
+ /** A channel as a square beside the row's button: its icon, or — the brand drew none — its name; `label` says what it does. */
70
+ export declare function SquareButton(props: {
71
+ kit: MessengerKit;
72
+ channel: MessengerMode;
73
+ label: string;
74
+ onClick: () => void;
75
+ }): import("react").JSX.Element;
76
+ /** The brand's icon for a channel, hidden from assistive technology: the label names it. */
77
+ export declare function Glyph({ kit, channel }: {
78
+ kit: MessengerKit;
79
+ channel: MessengerMode;
80
+ }): import("react").JSX.Element | null;
81
+ export declare function MessengerCta(props: {
82
+ kit: MessengerKit;
83
+ action: MessengerAction;
84
+ channel: Messenger;
85
+ label: string;
86
+ variant?: "primary" | "outline";
87
+ className?: string | undefined;
88
+ square?: boolean;
89
+ }): import("react").JSX.Element | null;
90
+ /** In an app's own browser: WhatsApp may not open — how to leave it, and the message to paste. */
91
+ export declare function InappHint({ kit, message }: {
92
+ kit: MessengerKit;
93
+ message: string;
94
+ }): import("react").JSX.Element | null;
95
+ /** The fixed box a variant swaps its states in; the brand gives it its height (`messengerSlot`). */
96
+ export declare function Slot({ kit, children }: {
97
+ kit: MessengerKit;
98
+ children: ReactNode;
99
+ }): import("react").JSX.Element;
100
+ /** The message, ready: what goes into the chat, so the visitor knows there is nothing to write. */
101
+ export declare function Preview({ kit, preview, title, lede }: {
102
+ kit: MessengerKit;
103
+ preview: string;
104
+ title?: string;
105
+ lede?: string;
106
+ }): import("react").JSX.Element;
107
+ /** What the bot does, in the slot. */
108
+ export declare function BotCard({ kit }: {
109
+ kit: MessengerKit;
110
+ }): import("react").JSX.Element;
111
+ /** «ou via Telegram»: the bot, as a link under the submit. */
112
+ export declare function TelegramLink({ kit, action }: {
113
+ kit: MessengerKit;
114
+ action: MessengerAction;
115
+ }): import("react").JSX.Element | null;
116
+ /** The channels a variant offers, in the Figma order; Telegram only with a bot. */
117
+ export declare function modesOf(kit: MessengerKit, order?: readonly MessengerMode[]): MessengerMode[];
118
+ export declare const labelOf: (words: LeadCaptureMessengerText, mode: MessengerMode) => string;
119
+ export declare const noteOf: (words: LeadCaptureMessengerText, mode: MessengerMode) => string;
120
+ /**
121
+ * The kit's `Select` as a channel picker: an overlay, so the form under it
122
+ * does not move. The trigger draws its own label — `SelectValue` would print
123
+ * the raw value.
124
+ */
125
+ export declare function ChannelPicker(props: {
126
+ kit: MessengerKit;
127
+ trigger: ReactNode;
128
+ triggerClassName?: string | undefined;
129
+ }): import("react").JSX.Element;
130
+ /** A channel as a card to tap (`saga`, `sheet`): its name, its line, the recommended badge on WhatsApp. */
131
+ export declare function OptionCard(props: {
132
+ kit: MessengerKit;
133
+ mode: MessengerMode;
134
+ /** A step in the card; or, for a messenger, `link` — a real link, the app opened from the tap. */
135
+ onPick?: () => void;
136
+ link?: ReturnType<MessengerAction["link"]>;
137
+ recommended?: boolean;
138
+ }): import("react").JSX.Element;
139
+ export declare function QrPanel(props: {
140
+ href: string;
141
+ words: LeadCaptureMessengerText;
142
+ onCallback: (() => void) | null;
143
+ kit: MessengerKit;
144
+ }): import("react").JSX.Element;