@evinvest/kitstart 0.17.0 → 0.19.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (103) hide show
  1. package/README.md +163 -20
  2. package/dist/core/accept.d.ts +14 -0
  3. package/dist/core/accept.js +21 -3
  4. package/dist/core/analytics.d.ts +11 -2
  5. package/dist/core/analytics.js +22 -0
  6. package/dist/core/channels.d.ts +12 -4
  7. package/dist/core/channels.js +11 -1
  8. package/dist/core/lead-capture-text.d.ts +6 -2
  9. package/dist/core/lead.d.ts +33 -10
  10. package/dist/core/lead.js +21 -8
  11. package/dist/core/messenger-message.d.ts +31 -0
  12. package/dist/core/messenger-message.js +33 -0
  13. package/dist/core/messenger-text.d.ts +104 -0
  14. package/dist/core/messenger-text.js +150 -0
  15. package/dist/core/messenger.d.ts +92 -0
  16. package/dist/core/messenger.js +55 -0
  17. package/dist/core/place/index.d.ts +1 -1
  18. package/dist/core/place/live.d.ts +5 -1
  19. package/dist/core/place/live.js +22 -1
  20. package/dist/core/place/types.d.ts +17 -1
  21. package/dist/core/pricing/index.d.ts +2 -1
  22. package/dist/core/pricing/index.js +2 -1
  23. package/dist/core/pricing/label.d.ts +7 -0
  24. package/dist/core/pricing/label.js +8 -0
  25. package/dist/core/pricing/validate.d.ts +1 -3
  26. package/dist/core/pricing/validate.js +0 -4
  27. package/dist/core/site.d.ts +8 -1
  28. package/dist/core/site.js +22 -1
  29. package/dist/index.d.ts +8 -5
  30. package/dist/index.js +7 -4
  31. package/dist/next/quote-route.js +4 -7
  32. package/dist/react/AnalyticsBoundary.js +3 -3
  33. package/dist/react/FormSelect.d.ts +5 -3
  34. package/dist/react/FormSelect.js +70 -24
  35. package/dist/react/FormSelectKit.d.ts +24 -0
  36. package/dist/react/FormSelectKit.js +28 -0
  37. package/dist/react/LeadCapture.d.ts +24 -1
  38. package/dist/react/LeadCapture.js +97 -12
  39. package/dist/react/LeadCaptureChannels.d.ts +7 -2
  40. package/dist/react/LeadCaptureChannels.js +2 -2
  41. package/dist/react/LeadCaptureEstimate.d.ts +6 -0
  42. package/dist/react/LeadCaptureEstimate.js +1 -1
  43. package/dist/react/LeadCaptureFields.d.ts +17 -0
  44. package/dist/react/LeadCaptureFields.js +42 -33
  45. package/dist/react/LeadCaptureMessenger.d.ts +41 -0
  46. package/dist/react/LeadCaptureMessenger.js +133 -0
  47. package/dist/react/LeadCapturePrice.d.ts +9 -3
  48. package/dist/react/LeadCapturePrice.js +25 -5
  49. package/dist/react/LeadCaptureSubmit.d.ts +3 -0
  50. package/dist/react/LeadCaptureSubmit.js +2 -2
  51. package/dist/react/LeadCaptureTiles.js +9 -4
  52. package/dist/react/analytics-context.d.ts +9 -3
  53. package/dist/react/analytics-context.js +7 -3
  54. package/dist/react/form-select-value.d.ts +3 -0
  55. package/dist/react/form-select-value.js +6 -6
  56. package/dist/react/index.d.ts +2 -0
  57. package/dist/react/index.js +1 -0
  58. package/dist/react/lazy-part.d.ts +33 -0
  59. package/dist/react/lazy-part.js +66 -0
  60. package/dist/react/messenger/action.d.ts +144 -0
  61. package/dist/react/messenger/action.js +301 -0
  62. package/dist/react/messenger/chip.d.ts +8 -0
  63. package/dist/react/messenger/chip.js +22 -0
  64. package/dist/react/messenger/fallback.d.ts +8 -0
  65. package/dist/react/messenger/fallback.js +11 -0
  66. package/dist/react/messenger/saga.d.ts +10 -0
  67. package/dist/react/messenger/saga.js +33 -0
  68. package/dist/react/messenger/segment.d.ts +8 -0
  69. package/dist/react/messenger/segment.js +15 -0
  70. package/dist/react/messenger/select.d.ts +10 -0
  71. package/dist/react/messenger/select.js +26 -0
  72. package/dist/react/messenger/sheet.d.ts +10 -0
  73. package/dist/react/messenger/sheet.js +57 -0
  74. package/dist/react/messenger/split.d.ts +9 -0
  75. package/dist/react/messenger/split.js +17 -0
  76. package/dist/react/messenger/swap.d.ts +10 -0
  77. package/dist/react/messenger/swap.js +18 -0
  78. package/dist/react/messenger/thanks.d.ts +10 -0
  79. package/dist/react/messenger/thanks.js +18 -0
  80. package/dist/react/messenger/tiles.d.ts +8 -0
  81. package/dist/react/messenger/tiles.js +20 -0
  82. package/dist/react/messenger/types.d.ts +98 -0
  83. package/dist/react/messenger/types.js +1 -0
  84. package/dist/react/messenger/urgency.d.ts +10 -0
  85. package/dist/react/messenger/urgency.js +29 -0
  86. package/dist/react/use-lead-error.d.ts +3 -3
  87. package/dist/react/use-lead-events.d.ts +21 -1
  88. package/dist/react/use-lead-events.js +9 -3
  89. package/dist/react/use-lead-submit.d.ts +20 -6
  90. package/dist/react/use-lead-submit.js +23 -1
  91. package/dist/server/index.d.ts +1 -1
  92. package/dist/server/lead-store-sqlite.js +11 -5
  93. package/dist/server/lead-webhook.d.ts +30 -5
  94. package/dist/server/lead-webhook.js +13 -7
  95. package/dist/server/notify.js +11 -5
  96. package/dist/testing/index.d.ts +1 -1
  97. package/dist/testing/index.js +1 -1
  98. package/dist/testing/landing-contract.js +6 -1
  99. package/dist/testing/lead-rules.d.ts +8 -0
  100. package/dist/testing/lead-rules.js +30 -1
  101. package/package.json +6 -3
  102. package/template/flake.nix +1 -1
  103. package/template/package.json +5 -5
@@ -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/validate.js";
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 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
  }
@@ -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, before the scripted render. Once —
29
- // later renders never hold the native select. Under a `value` it is the
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 (!scripted || !form)
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);
@@ -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";
@@ -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;