@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.
- package/README.md +147 -10
- package/dist/core/accept.d.ts +14 -0
- package/dist/core/accept.js +21 -3
- package/dist/core/analytics.d.ts +11 -2
- package/dist/core/analytics.js +22 -0
- package/dist/core/channels.d.ts +12 -4
- package/dist/core/channels.js +11 -1
- package/dist/core/lead-capture-text.d.ts +6 -2
- package/dist/core/lead.d.ts +33 -10
- package/dist/core/lead.js +21 -8
- package/dist/core/messenger-message.d.ts +31 -0
- package/dist/core/messenger-message.js +33 -0
- package/dist/core/messenger-text.d.ts +104 -0
- package/dist/core/messenger-text.js +150 -0
- package/dist/core/messenger.d.ts +92 -0
- package/dist/core/messenger.js +55 -0
- package/dist/core/place/index.d.ts +1 -1
- package/dist/core/place/live.d.ts +5 -1
- package/dist/core/place/live.js +22 -1
- package/dist/core/place/types.d.ts +17 -1
- package/dist/core/site.d.ts +8 -1
- package/dist/core/site.js +22 -1
- package/dist/index.d.ts +8 -5
- package/dist/index.js +7 -4
- package/dist/next/quote-route.js +4 -7
- package/dist/react/AnalyticsBoundary.js +3 -3
- package/dist/react/LeadCapture.d.ts +24 -1
- package/dist/react/LeadCapture.js +96 -11
- package/dist/react/LeadCaptureChannels.d.ts +7 -2
- package/dist/react/LeadCaptureChannels.js +2 -2
- package/dist/react/LeadCaptureEstimate.d.ts +6 -0
- package/dist/react/LeadCaptureFields.d.ts +17 -0
- package/dist/react/LeadCaptureFields.js +42 -33
- package/dist/react/LeadCaptureMessenger.d.ts +41 -0
- package/dist/react/LeadCaptureMessenger.js +133 -0
- package/dist/react/LeadCapturePrice.d.ts +9 -3
- package/dist/react/LeadCapturePrice.js +24 -4
- package/dist/react/LeadCaptureSubmit.d.ts +3 -0
- package/dist/react/LeadCaptureSubmit.js +2 -2
- package/dist/react/LeadCaptureTiles.js +9 -4
- package/dist/react/analytics-context.d.ts +9 -3
- package/dist/react/analytics-context.js +7 -3
- package/dist/react/index.d.ts +1 -0
- package/dist/react/messenger/action.d.ts +144 -0
- package/dist/react/messenger/action.js +301 -0
- package/dist/react/messenger/chip.d.ts +8 -0
- package/dist/react/messenger/chip.js +22 -0
- package/dist/react/messenger/fallback.d.ts +8 -0
- package/dist/react/messenger/fallback.js +11 -0
- package/dist/react/messenger/saga.d.ts +10 -0
- package/dist/react/messenger/saga.js +33 -0
- package/dist/react/messenger/segment.d.ts +8 -0
- package/dist/react/messenger/segment.js +15 -0
- package/dist/react/messenger/select.d.ts +10 -0
- package/dist/react/messenger/select.js +26 -0
- package/dist/react/messenger/sheet.d.ts +10 -0
- package/dist/react/messenger/sheet.js +57 -0
- package/dist/react/messenger/split.d.ts +9 -0
- package/dist/react/messenger/split.js +17 -0
- package/dist/react/messenger/swap.d.ts +10 -0
- package/dist/react/messenger/swap.js +18 -0
- package/dist/react/messenger/thanks.d.ts +10 -0
- package/dist/react/messenger/thanks.js +18 -0
- package/dist/react/messenger/tiles.d.ts +8 -0
- package/dist/react/messenger/tiles.js +20 -0
- package/dist/react/messenger/types.d.ts +98 -0
- package/dist/react/messenger/types.js +1 -0
- package/dist/react/messenger/urgency.d.ts +10 -0
- package/dist/react/messenger/urgency.js +29 -0
- package/dist/react/use-lead-error.d.ts +3 -3
- package/dist/react/use-lead-events.d.ts +21 -1
- package/dist/react/use-lead-events.js +9 -3
- package/dist/react/use-lead-submit.d.ts +20 -6
- package/dist/react/use-lead-submit.js +23 -1
- package/dist/server/index.d.ts +1 -1
- package/dist/server/lead-store-sqlite.js +11 -5
- package/dist/server/lead-webhook.d.ts +30 -5
- package/dist/server/lead-webhook.js +13 -7
- package/dist/server/notify.js +11 -5
- package/dist/testing/index.d.ts +1 -1
- package/dist/testing/index.js +1 -1
- package/dist/testing/landing-contract.js +6 -1
- package/dist/testing/lead-rules.d.ts +8 -0
- package/dist/testing/lead-rules.js +30 -1
- package/package.json +5 -2
- package/template/flake.nix +1 -1
- 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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
-
|
|
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
|
|
26
|
-
*
|
|
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
|
|
40
|
-
*
|
|
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(
|
|
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 {
|
|
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 (
|
|
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"),
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
},
|
|
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
|
-
/**
|
|
18
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
}
|
package/dist/react/index.d.ts
CHANGED
|
@@ -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;
|