@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.
- package/README.md +163 -20
- 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/pricing/index.d.ts +2 -1
- package/dist/core/pricing/index.js +2 -1
- package/dist/core/pricing/label.d.ts +7 -0
- package/dist/core/pricing/label.js +8 -0
- package/dist/core/pricing/validate.d.ts +1 -3
- package/dist/core/pricing/validate.js +0 -4
- 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/FormSelect.d.ts +5 -3
- package/dist/react/FormSelect.js +70 -24
- package/dist/react/FormSelectKit.d.ts +24 -0
- package/dist/react/FormSelectKit.js +28 -0
- package/dist/react/LeadCapture.d.ts +24 -1
- package/dist/react/LeadCapture.js +97 -12
- 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/LeadCaptureEstimate.js +1 -1
- 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 +25 -5
- 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/form-select-value.d.ts +3 -0
- package/dist/react/form-select-value.js +6 -6
- package/dist/react/index.d.ts +2 -0
- package/dist/react/index.js +1 -0
- package/dist/react/lazy-part.d.ts +33 -0
- package/dist/react/lazy-part.js +66 -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 +6 -3
- package/template/flake.nix +1 -1
- package/template/package.json +5 -5
|
@@ -1,7 +1,8 @@
|
|
|
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
|
-
import { labelOf } from "../core/pricing/
|
|
5
|
+
import { labelOf } from "../core/pricing/label.js";
|
|
5
6
|
function lineLabel(line, model, locale, text) {
|
|
6
7
|
switch (line.kind) {
|
|
7
8
|
case "base":
|
|
@@ -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
|
}
|
|
@@ -14,6 +14,9 @@ export declare function useFormSelectValue(opts: {
|
|
|
14
14
|
controlled: string | undefined;
|
|
15
15
|
native: RefObject<HTMLSelectElement | null>;
|
|
16
16
|
box: RefObject<HTMLElement | null>;
|
|
17
|
+
/** The page's script runs: the native select, if still drawn, is the field. */
|
|
18
|
+
live: boolean;
|
|
19
|
+
/** The kit's `Select` is drawn. */
|
|
17
20
|
scripted: boolean;
|
|
18
21
|
onValueChange: ((value: string) => void) | undefined;
|
|
19
22
|
}): {
|
|
@@ -12,7 +12,7 @@ const DEV = typeof process !== "undefined" && process.env.NODE_ENV !== "producti
|
|
|
12
12
|
* so a switch back to uncontrolled keeps the last value, as an `<input>` does.
|
|
13
13
|
*/
|
|
14
14
|
export function useFormSelectValue(opts) {
|
|
15
|
-
const { initial, controlled, native, box, scripted, onValueChange } = opts;
|
|
15
|
+
const { initial, controlled, native, box, live, scripted, onValueChange } = opts;
|
|
16
16
|
const [own, setOwn] = useState(controlled ?? initial);
|
|
17
17
|
const [invalid, setInvalid] = useState(false);
|
|
18
18
|
const [open, setOpen] = useState(false);
|
|
@@ -25,8 +25,8 @@ export function useFormSelectValue(opts) {
|
|
|
25
25
|
const latest = useRef({ initial, isControlled, value, onValueChange });
|
|
26
26
|
latest.current = { initial, isControlled, value, onValueChange };
|
|
27
27
|
// A choice made in the native select before the script arrived survives the
|
|
28
|
-
// swap: read in the hydration commit
|
|
29
|
-
//
|
|
28
|
+
// swap: read in the hydration commit. Once — from then on the native
|
|
29
|
+
// select, while it stays, reports its choices itself. Under a `value` it is the
|
|
30
30
|
// parent's to take, so it is reported instead of kept — and only a real
|
|
31
31
|
// pick: a `value` no option carries leaves the browser on its own default,
|
|
32
32
|
// which is no choice of the visitor's.
|
|
@@ -46,8 +46,8 @@ export function useFormSelectValue(opts) {
|
|
|
46
46
|
// from a script's `checkValidity()` by the submit that starts it.
|
|
47
47
|
const submitting = useRef(false);
|
|
48
48
|
useEffect(() => {
|
|
49
|
-
const form = box.current?.closest("form");
|
|
50
|
-
if (!
|
|
49
|
+
const form = (scripted ? box.current : native.current)?.closest("form");
|
|
50
|
+
if (!live || !form)
|
|
51
51
|
return;
|
|
52
52
|
const reset = () => {
|
|
53
53
|
if (!latest.current.isControlled)
|
|
@@ -71,7 +71,7 @@ export function useFormSelectValue(opts) {
|
|
|
71
71
|
form.removeEventListener("reset", reset);
|
|
72
72
|
document.removeEventListener("click", submit, true);
|
|
73
73
|
};
|
|
74
|
-
}, [box, scripted]);
|
|
74
|
+
}, [box, native, live, scripted]);
|
|
75
75
|
const choose = (next) => {
|
|
76
76
|
if (!isControlled)
|
|
77
77
|
setOwn(next);
|
package/dist/react/index.d.ts
CHANGED
|
@@ -23,6 +23,7 @@ export { Faq, type FaqPart, type FaqProps } from "./Faq.js";
|
|
|
23
23
|
export { FormSelect, type FormSelectOption, type FormSelectPart, type FormSelectProps } from "./FormSelect.js";
|
|
24
24
|
export { LangSwitch, withLang, type LangSwitchProps } from "./LangSwitch.js";
|
|
25
25
|
export { LeadCapture, type LeadCapturePart, type LeadCaptureProps } from "./LeadCapture.js";
|
|
26
|
+
export { loadLazyParts } from "./lazy-part.js";
|
|
26
27
|
export { LeadBooking, type LeadBookingProps } from "./LeadBooking.js";
|
|
27
28
|
export type { BookingPart } from "./LeadBookingManual.js";
|
|
28
29
|
export { BOOKING_ADAPTERS, BOOKING_EMBEDS, bookingAdapters, calComAdapter, calComEmbedAdapter, calComEmbedOrigin, googleCalendarAdapter, linkAdapter, manualAdapter, type BookedSlot, type BookingAdapter, type BookingAdapters, type BookingContext, } from "./booking-adapters.js";
|
|
@@ -32,6 +33,7 @@ export type { LeadCaptureLayout, LeadNeedDisplay, LeadNeedOption } from "./LeadC
|
|
|
32
33
|
export type { EstimateQuestion, EstimateQuestions } from "./LeadCaptureEstimate.js";
|
|
33
34
|
export type { LeadIntro, LeadIntroOption } from "./lead-steps.js";
|
|
34
35
|
export type { ChannelIconKey } from "./LeadCaptureChannels.js";
|
|
36
|
+
export type { MessengerPart } from "./messenger/types.js";
|
|
35
37
|
export { MapFacade, type MapFacadePart, type MapFacadeProps } from "./MapFacade.js";
|
|
36
38
|
export { PlaceDirectory, type PlaceDirectoryPart, type PlaceDirectoryProps } from "./PlaceDirectory.js";
|
|
37
39
|
export { PHONE_INPUT_PROPS, QuoteFormShell, type QuoteFormShellProps } from "./QuoteFormShell.js";
|
package/dist/react/index.js
CHANGED
|
@@ -22,6 +22,7 @@ export { Faq } from "./Faq.js";
|
|
|
22
22
|
export { FormSelect } from "./FormSelect.js";
|
|
23
23
|
export { LangSwitch, withLang } from "./LangSwitch.js";
|
|
24
24
|
export { LeadCapture } from "./LeadCapture.js";
|
|
25
|
+
export { loadLazyParts } from "./lazy-part.js";
|
|
25
26
|
export { LeadBooking } from "./LeadBooking.js";
|
|
26
27
|
export { BOOKING_ADAPTERS, BOOKING_EMBEDS, bookingAdapters, calComAdapter, calComEmbedAdapter, calComEmbedOrigin, googleCalendarAdapter, linkAdapter, manualAdapter, } from "./booking-adapters.js";
|
|
27
28
|
export { BOOKING_ACTION } from "./use-booking.js";
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { type ComponentType } from "react";
|
|
2
|
+
/** A piece of a client island in a chunk of its own, and the way to ask for that chunk early. */
|
|
3
|
+
export interface LazyPart<P extends object> {
|
|
4
|
+
/** Drawn where the piece goes; see {@link lazyPart}. */
|
|
5
|
+
Part: ComponentType<P>;
|
|
6
|
+
/** Asks for the chunk — once a page; later calls share the first. */
|
|
7
|
+
preload: () => void;
|
|
8
|
+
/** Whether the chunk is here, for `useSyncExternalStore` with {@link LazyPart.subscribe}. */
|
|
9
|
+
isLoaded: () => boolean;
|
|
10
|
+
subscribe: (onLoad: () => void) => () => void;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Resolves once every lazily drawn part of the kit is loaded — today
|
|
14
|
+
* `FormSelect`'s scripted list — for a test that renders a form and reads it
|
|
15
|
+
* at once (`beforeAll(loadLazyParts)`). A page never needs it: the server
|
|
16
|
+
* loads them as their modules load, and a browser fetches them when idle.
|
|
17
|
+
*/
|
|
18
|
+
export declare function loadLazyParts(): Promise<void>;
|
|
19
|
+
/**
|
|
20
|
+
* A component a page pays for only when it draws it — the shape of the
|
|
21
|
+
* `messenger` variants (`LeadCaptureMessenger`), here for `FormSelect`'s
|
|
22
|
+
* scripted state.
|
|
23
|
+
*
|
|
24
|
+
* On the server every part is loaded as its module is, so a render finds it
|
|
25
|
+
* and writes it into the page whole: no boundary of its own, which React
|
|
26
|
+
* would reveal late. Hydrating, a part whose chunk is still on its way holds
|
|
27
|
+
* the island's hydration — the server's markup stays as it is, no fallback
|
|
28
|
+
* drawn — and `preload` from the island's render starts every chunk it will
|
|
29
|
+
* need at once, so none waits behind another. A part first drawn after
|
|
30
|
+
* hydration (a later step, the success) waits behind a boundary of its own,
|
|
31
|
+
* drawing nothing until its chunk is here.
|
|
32
|
+
*/
|
|
33
|
+
export declare function lazyPart<P extends object>(load: () => Promise<ComponentType<P>>): LazyPart<P>;
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
+
import { lazy, Suspense, useState, useSyncExternalStore } from "react";
|
|
4
|
+
const noop = () => () => undefined;
|
|
5
|
+
/** Every part's loader, for {@link loadLazyParts}. */
|
|
6
|
+
const LOADERS = [];
|
|
7
|
+
/**
|
|
8
|
+
* Resolves once every lazily drawn part of the kit is loaded — today
|
|
9
|
+
* `FormSelect`'s scripted list — for a test that renders a form and reads it
|
|
10
|
+
* at once (`beforeAll(loadLazyParts)`). A page never needs it: the server
|
|
11
|
+
* loads them as their modules load, and a browser fetches them when idle.
|
|
12
|
+
*/
|
|
13
|
+
export function loadLazyParts() {
|
|
14
|
+
return Promise.all(LOADERS.map(load => load())).then(() => undefined);
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* A component a page pays for only when it draws it — the shape of the
|
|
18
|
+
* `messenger` variants (`LeadCaptureMessenger`), here for `FormSelect`'s
|
|
19
|
+
* scripted state.
|
|
20
|
+
*
|
|
21
|
+
* On the server every part is loaded as its module is, so a render finds it
|
|
22
|
+
* and writes it into the page whole: no boundary of its own, which React
|
|
23
|
+
* would reveal late. Hydrating, a part whose chunk is still on its way holds
|
|
24
|
+
* the island's hydration — the server's markup stays as it is, no fallback
|
|
25
|
+
* drawn — and `preload` from the island's render starts every chunk it will
|
|
26
|
+
* need at once, so none waits behind another. A part first drawn after
|
|
27
|
+
* hydration (a later step, the success) waits behind a boundary of its own,
|
|
28
|
+
* drawing nothing until its chunk is here.
|
|
29
|
+
*/
|
|
30
|
+
export function lazyPart(load) {
|
|
31
|
+
let loaded = null;
|
|
32
|
+
let loading = null;
|
|
33
|
+
const listeners = new Set();
|
|
34
|
+
const fetchPart = () => {
|
|
35
|
+
loading ??= load().then(part => {
|
|
36
|
+
loaded = part;
|
|
37
|
+
for (const listener of listeners)
|
|
38
|
+
listener();
|
|
39
|
+
return part;
|
|
40
|
+
}, (error) => {
|
|
41
|
+
// A chunk that failed may be asked for again: the next draw retries.
|
|
42
|
+
loading = null;
|
|
43
|
+
throw error;
|
|
44
|
+
});
|
|
45
|
+
return loading;
|
|
46
|
+
};
|
|
47
|
+
const preload = () => void fetchPart().catch(() => undefined);
|
|
48
|
+
LOADERS.push(fetchPart);
|
|
49
|
+
if (typeof window === "undefined")
|
|
50
|
+
preload();
|
|
51
|
+
const Lazy = lazy(() => fetchPart().then(part => ({ default: part })));
|
|
52
|
+
function Part(props) {
|
|
53
|
+
const hydrating = useSyncExternalStore(noop, () => false, () => true);
|
|
54
|
+
// Chosen on the first render and kept: a structure swapped under the part would draw it anew.
|
|
55
|
+
const [Loaded] = useState(() => loaded);
|
|
56
|
+
const [wrapped] = useState(() => !hydrating);
|
|
57
|
+
if (Loaded)
|
|
58
|
+
return _jsx(Loaded, { ...props });
|
|
59
|
+
return wrapped ? (_jsx(Suspense, { fallback: null, children: _jsx(Lazy, { ...props }) })) : (_jsx(Lazy, { ...props }));
|
|
60
|
+
}
|
|
61
|
+
const subscribe = (onLoad) => {
|
|
62
|
+
listeners.add(onLoad);
|
|
63
|
+
return () => void listeners.delete(onLoad);
|
|
64
|
+
};
|
|
65
|
+
return { Part, preload, isLoaded: () => loaded !== null, subscribe };
|
|
66
|
+
}
|
|
@@ -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;
|