@evinvest/kitstart 0.16.0 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/README.md +179 -19
  2. package/dist/core/ab-gate.js +3 -1
  3. package/dist/core/ab-switcher.d.ts +8 -0
  4. package/dist/core/ab-switcher.js +11 -0
  5. package/dist/core/accept.d.ts +14 -0
  6. package/dist/core/accept.js +21 -3
  7. package/dist/core/analytics.d.ts +28 -3
  8. package/dist/core/analytics.js +46 -2
  9. package/dist/core/channels.d.ts +12 -4
  10. package/dist/core/channels.js +11 -1
  11. package/dist/core/lead-capture-text.d.ts +6 -2
  12. package/dist/core/lead.d.ts +33 -10
  13. package/dist/core/lead.js +21 -8
  14. package/dist/core/messenger-message.d.ts +31 -0
  15. package/dist/core/messenger-message.js +33 -0
  16. package/dist/core/messenger-text.d.ts +104 -0
  17. package/dist/core/messenger-text.js +150 -0
  18. package/dist/core/messenger.d.ts +92 -0
  19. package/dist/core/messenger.js +55 -0
  20. package/dist/core/place/index.d.ts +1 -1
  21. package/dist/core/place/live.d.ts +5 -1
  22. package/dist/core/place/live.js +22 -1
  23. package/dist/core/place/types.d.ts +17 -1
  24. package/dist/core/site.d.ts +8 -1
  25. package/dist/core/site.js +22 -1
  26. package/dist/index.d.ts +9 -6
  27. package/dist/index.js +8 -5
  28. package/dist/next/quote-route.d.ts +7 -0
  29. package/dist/next/quote-route.js +9 -9
  30. package/dist/react/AbSwitcherPanel.d.ts +3 -1
  31. package/dist/react/AbSwitcherPanel.js +10 -6
  32. package/dist/react/AnalyticsBoundary.d.ts +3 -2
  33. package/dist/react/AnalyticsBoundary.js +7 -20
  34. package/dist/react/LeadCapture.d.ts +24 -1
  35. package/dist/react/LeadCapture.js +96 -11
  36. package/dist/react/LeadCaptureChannels.d.ts +7 -2
  37. package/dist/react/LeadCaptureChannels.js +2 -2
  38. package/dist/react/LeadCaptureEstimate.d.ts +6 -0
  39. package/dist/react/LeadCaptureFields.d.ts +17 -0
  40. package/dist/react/LeadCaptureFields.js +42 -33
  41. package/dist/react/LeadCaptureMessenger.d.ts +41 -0
  42. package/dist/react/LeadCaptureMessenger.js +133 -0
  43. package/dist/react/LeadCapturePrice.d.ts +9 -3
  44. package/dist/react/LeadCapturePrice.js +24 -4
  45. package/dist/react/LeadCaptureSubmit.d.ts +3 -0
  46. package/dist/react/LeadCaptureSubmit.js +2 -2
  47. package/dist/react/LeadCaptureTiles.js +9 -4
  48. package/dist/react/ab-switcher-types.d.ts +1 -1
  49. package/dist/react/analytics-context.d.ts +9 -3
  50. package/dist/react/analytics-context.js +7 -3
  51. package/dist/react/index.d.ts +1 -0
  52. package/dist/react/messenger/action.d.ts +144 -0
  53. package/dist/react/messenger/action.js +301 -0
  54. package/dist/react/messenger/chip.d.ts +8 -0
  55. package/dist/react/messenger/chip.js +22 -0
  56. package/dist/react/messenger/fallback.d.ts +8 -0
  57. package/dist/react/messenger/fallback.js +11 -0
  58. package/dist/react/messenger/saga.d.ts +10 -0
  59. package/dist/react/messenger/saga.js +33 -0
  60. package/dist/react/messenger/segment.d.ts +8 -0
  61. package/dist/react/messenger/segment.js +15 -0
  62. package/dist/react/messenger/select.d.ts +10 -0
  63. package/dist/react/messenger/select.js +26 -0
  64. package/dist/react/messenger/sheet.d.ts +10 -0
  65. package/dist/react/messenger/sheet.js +57 -0
  66. package/dist/react/messenger/split.d.ts +9 -0
  67. package/dist/react/messenger/split.js +17 -0
  68. package/dist/react/messenger/swap.d.ts +10 -0
  69. package/dist/react/messenger/swap.js +18 -0
  70. package/dist/react/messenger/thanks.d.ts +10 -0
  71. package/dist/react/messenger/thanks.js +18 -0
  72. package/dist/react/messenger/tiles.d.ts +8 -0
  73. package/dist/react/messenger/tiles.js +20 -0
  74. package/dist/react/messenger/types.d.ts +98 -0
  75. package/dist/react/messenger/types.js +1 -0
  76. package/dist/react/messenger/urgency.d.ts +10 -0
  77. package/dist/react/messenger/urgency.js +29 -0
  78. package/dist/react/use-lead-error.d.ts +3 -3
  79. package/dist/react/use-lead-events.d.ts +21 -1
  80. package/dist/react/use-lead-events.js +9 -3
  81. package/dist/react/use-lead-submit.d.ts +20 -6
  82. package/dist/react/use-lead-submit.js +23 -1
  83. package/dist/server/index.d.ts +1 -1
  84. package/dist/server/lead-store-sqlite.js +11 -5
  85. package/dist/server/lead-webhook.d.ts +30 -5
  86. package/dist/server/lead-webhook.js +13 -7
  87. package/dist/server/notify.js +11 -5
  88. package/dist/testing/index.d.ts +1 -1
  89. package/dist/testing/index.js +1 -1
  90. package/dist/testing/landing-contract.js +6 -1
  91. package/dist/testing/lead-rules.d.ts +8 -0
  92. package/dist/testing/lead-rules.js +30 -1
  93. package/package.json +5 -2
  94. package/template/app/quote/route.ts +3 -0
  95. package/template/flake.nix +1 -1
  96. package/template/package.json +2 -2
  97. package/template/tests/e2e/ab-switcher.spec.ts +2 -0
@@ -1,5 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
- import { cn } from "@evinvest/uikit";
2
+ import { cn, Popover, PopoverContent, PopoverTrigger } from "@evinvest/uikit";
3
+ import { useSyncExternalStore } from "react";
3
4
  import { fillText, formatCents } from "../core/lead-capture-format.js";
4
5
  import { labelOf } from "../core/pricing/validate.js";
5
6
  function lineLabel(line, model, locale, text) {
@@ -36,12 +37,31 @@ export const afterCredit = (cents, ratio) => cents - Math.round(cents * ratio);
36
37
  /**
37
38
  * `price="compact"`: the price on one line (`priceLine`), what is left after
38
39
  * the brand's tax credit when it has one (`taxCredit`, a ratio), and how it
39
- * was reached behind a disclosure — a `<details>`, so it opens without a
40
- * script. Read out as it changes, the line only.
40
+ * was reached behind «Détail» — the kit's `Popover`, over the card, so
41
+ * opening it moves nothing; without a script a `<details>`, which opens in
42
+ * place. Read out as it changes, the line only.
41
43
  */
42
44
  export function PriceCompact(props) {
43
45
  const { model, flow, price, locale, taxCredit, text, classNames: c } = props;
44
46
  const lines = price?.breakdown.filter((line, i) => i === 0 || line.cents !== 0) ?? [];
45
47
  const credit = price && taxCredit !== undefined && taxCredit > 0 && taxCredit < 1 ? afterCredit(price.cents, taxCredit) : null;
46
- return (_jsxs("div", { className: cn("flex flex-col gap-1", c?.price), children: [_jsx("p", { "aria-live": "polite", "aria-atomic": "true", className: cn("flex flex-wrap items-baseline gap-x-2 text-ink", c?.priceLine), children: price ? (_jsxs(_Fragment, { children: [_jsx("span", { className: cn("font-semibold", c?.priceTotal), "data-price-cents": price.cents, children: fillText(text.priceLine, { price: formatCents(price.cents, locale) }) }), credit !== null && (_jsxs("span", { className: cn("text-sm text-ink-soft", c?.priceTaxCredit), "data-credit-cents": credit, children: [_jsx("span", { "aria-hidden": "true", children: "\u00B7 " }), fillText(text.priceTaxCredit, { price: formatCents(credit, locale) })] }))] })) : (_jsx("span", { className: "text-sm text-ink-soft", children: text.pricePending })) }), flow === "estimate" && lines.length > 1 && (_jsxs("details", { className: cn("text-sm text-ink-soft", c?.priceDetail), children: [_jsx("summary", { className: "inline-flex min-h-11 cursor-pointer items-center underline underline-offset-4", children: text.priceDetail }), _jsx("ul", { className: cn("flex flex-col gap-1 pb-2", c?.breakdown), children: lines.map((line, i) => (_jsxs("li", { className: "flex justify-between gap-3", children: [_jsx("span", { children: lineLabel(line, model, locale, text) }), _jsx("span", { className: "tabular-nums", children: formatCents(line.cents, locale, i > 0) })] }, i))) }), _jsx("p", { className: c?.priceNote, children: text.priceNote })] }))] }));
48
+ return (_jsxs("div", { className: cn("flex flex-col gap-1", c?.price), children: [_jsx("p", { "aria-live": "polite", "aria-atomic": "true", className: cn("flex flex-wrap items-baseline gap-x-2 text-ink", c?.priceLine), children: price ? (_jsxs(_Fragment, { children: [_jsx("span", { className: cn("font-semibold", c?.priceTotal), "data-price-cents": price.cents, children: fillText(text.priceLine, { price: formatCents(price.cents, locale) }) }), credit !== null && (_jsxs("span", { className: cn("text-sm text-ink-soft", c?.priceTaxCredit), "data-credit-cents": credit, children: [_jsx("span", { "aria-hidden": "true", children: "\u00B7 " }), fillText(text.priceTaxCredit, { price: formatCents(credit, locale) })] }))] })) : (_jsx("span", { className: "text-sm text-ink-soft", children: text.pricePending })) }), flow === "estimate" && lines.length > 1 && (_jsxs(PriceDetail, { label: text.priceDetail, classNames: c, children: [_jsx("ul", { className: cn("flex flex-col gap-1 pb-2", c?.breakdown), children: lines.map((line, i) => (_jsxs("li", { className: "flex justify-between gap-3", children: [_jsx("span", { children: lineLabel(line, model, locale, text) }), _jsx("span", { className: "tabular-nums", children: formatCents(line.cents, locale, i > 0) })] }, i))) }), _jsx("p", { className: c?.priceNote, children: text.priceNote })] }))] }));
49
+ }
50
+ const noop = () => () => undefined;
51
+ /** The trigger both forms share: one height, so the swap after hydration moves nothing. */
52
+ const TRIGGER = "inline-flex min-h-11 cursor-pointer items-center text-sm text-ink-soft underline underline-offset-4";
53
+ /**
54
+ * The breakdown behind «Détail». Scripted: the kit's `Popover` — over the
55
+ * card, closed by a tap outside or Escape, kept inside the screen. The
56
+ * server's (and a browser without scripts): a `<details>` opening in place;
57
+ * hydration draws that first, then the popover — `useSyncExternalStore`'s
58
+ * server snapshot is what hydration reads.
59
+ */
60
+ function PriceDetail(props) {
61
+ const { label, classNames: c } = props;
62
+ const scripted = useSyncExternalStore(noop, () => true, () => false);
63
+ if (!scripted) {
64
+ return (_jsxs("details", { className: "text-sm text-ink-soft", children: [_jsx("summary", { className: cn(TRIGGER, c?.priceDetail), children: label }), _jsx("div", { className: c?.priceDetailContent, children: props.children })] }));
65
+ }
66
+ return (_jsxs(Popover, { children: [_jsx(PopoverTrigger, { type: "button", className: cn("self-start", TRIGGER, c?.priceDetail), children: label }), _jsx(PopoverContent, { role: "dialog", "aria-label": label, side: "bottom", align: "end", sideOffset: 8, className: cn("max-w-[calc(100vw-16px)] text-sm text-ink-soft", c?.priceDetailContent), children: props.children })] }));
47
67
  }
@@ -1,3 +1,4 @@
1
+ import type { ReactNode } from "react";
1
2
  import type { LeadCaptureText } from "../core/lead-capture-text.js";
2
3
  import type { SendFailure } from "./use-lead-submit.js";
3
4
  /** The submit, busy while the script posts: disabled, `aria-busy`, and saying so. */
@@ -6,6 +7,8 @@ export declare function SubmitButton(props: {
6
7
  label: string;
7
8
  sending: string;
8
9
  className: string | undefined;
10
+ form?: string | undefined;
11
+ children?: ReactNode;
9
12
  }): import("react").JSX.Element;
10
13
  /** Why the post got no answer, and the way to send the same lead again. */
11
14
  export declare function FailureMessage(props: {
@@ -1,8 +1,8 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { jsxs as _jsxs, jsx as _jsx } from "react/jsx-runtime";
2
2
  import { Button, cn, FieldError } from "@evinvest/uikit";
3
3
  /** The submit, busy while the script posts: disabled, `aria-busy`, and saying so. */
4
4
  export function SubmitButton(props) {
5
- return (_jsx(Button, { type: "submit", size: "touch", disabled: props.busy, "aria-busy": props.busy || undefined, className: cn("w-full", props.className), children: props.busy ? props.sending : props.label }));
5
+ return (_jsxs(Button, { type: "submit", size: "touch", form: props.form, disabled: props.busy, "aria-busy": props.busy || undefined, className: cn("w-full", props.className), children: [props.children, props.busy ? props.sending : props.label] }));
6
6
  }
7
7
  /** Why the post got no answer, and the way to send the same lead again. */
8
8
  export function FailureMessage(props) {
@@ -21,9 +21,12 @@ export function TileGroup(props) {
21
21
  radio.setCustomValidity(value === undefined ? requiredText : "");
22
22
  });
23
23
  const markerProps = marker ? { [marker]: "" } : {};
24
- return (_jsxs("fieldset", { ref: group, className: cn("flex flex-col gap-2", c.field), children: [_jsx("legend", { className: cn("mb-2 text-sm font-medium text-ink", c.legend), children: legend }), _jsx("div", { className: c.grid, children: options.map(o => (_jsxs("label", { className: cn("relative block cursor-pointer", o.span === 2 && "col-span-2"), children: [_jsx("input", { type: "radio", name: name, value: o.value, required: required, checked: value === o.value, onPointerDown: () => {
25
- pointer.current = true;
26
- }, onKeyDown: (e) => {
24
+ return (_jsxs("fieldset", { ref: group, className: cn("flex flex-col gap-2", c.field), children: [_jsx("legend", { className: cn("mb-2 text-sm font-medium text-ink", c.legend), children: legend }), _jsx("div", { className: c.grid, children: options.map(o => (_jsxs("label", { className: cn("relative block cursor-pointer", o.span === 2 && "col-span-2"),
25
+ // On the label, not the radio: a tap on the tile's words lands on them,
26
+ // and the label's click on the radio (`detail` 0) must still read as a tap.
27
+ onPointerDown: () => {
28
+ pointer.current = true;
29
+ }, children: [_jsx("input", { type: "radio", name: name, value: o.value, required: required, checked: value === o.value, onKeyDown: (e) => {
27
30
  pointer.current = false;
28
31
  answered.current = false;
29
32
  if (e.key !== "Enter" || !enterPicks)
@@ -46,5 +49,7 @@ export function TileGroup(props) {
46
49
  if (!answered.current)
47
50
  onSelect(o.value);
48
51
  answered.current = false;
49
- }, className: "peer absolute inset-0 opacity-0", "data-lead-field": field, "aria-label": o.shortLabel !== undefined ? [o.label, o.badge, o.aside].filter(Boolean).join(", ") : undefined, ...markerProps }), _jsxs("span", { className: cn("flex min-h-11 items-center rounded-[var(--control-radius)] border border-input text-ink transition-colors", "peer-checked:border-primary peer-checked:bg-hover peer-focus-visible:ring-[3px] peer-focus-visible:ring-ring/50", c.tile, o.className), children: [o.icon !== undefined && (_jsx("span", { "aria-hidden": "true", className: cn("flex shrink-0 items-center justify-center [&_svg]:size-6", c.icon), children: o.icon })), o.shortLabel !== undefined ? (_jsxs(_Fragment, { children: [_jsx("span", { "aria-hidden": "true", className: "min-w-0 sm:hidden", children: o.shortLabel }), _jsx("span", { className: "min-w-0 max-sm:hidden", children: o.label })] })) : (_jsx("span", { className: "min-w-0", children: o.label })), o.badge && _jsx("span", { className: cn("rounded-full bg-hover px-2 py-0.5 text-xs font-medium text-ink", c.badge), children: o.badge }), o.aside && _jsx("span", { className: cn("ms-auto shrink-0 font-medium tabular-nums", c.aside), children: o.aside })] })] }, o.value))) })] }));
52
+ },
53
+ // The whole tile: without a size WebKit keeps a radio 12×12 in its corner.
54
+ className: "peer absolute inset-0 m-0 size-full cursor-pointer opacity-0", "data-lead-field": field, "aria-label": o.shortLabel !== undefined ? [o.label, o.badge, o.aside].filter(Boolean).join(", ") : undefined, ...markerProps }), _jsxs("span", { className: cn("flex min-h-11 items-center rounded-[var(--control-radius)] border border-input text-ink transition-colors", "peer-checked:border-primary peer-checked:bg-hover peer-focus-visible:ring-[3px] peer-focus-visible:ring-ring/50", c.tile, o.className), children: [o.icon !== undefined && (_jsx("span", { "aria-hidden": "true", className: cn("flex shrink-0 items-center justify-center [&_svg]:size-6", c.icon), children: o.icon })), o.shortLabel !== undefined ? (_jsxs(_Fragment, { children: [_jsx("span", { "aria-hidden": "true", className: "min-w-0 sm:hidden", children: o.shortLabel }), _jsx("span", { className: "min-w-0 max-sm:hidden", children: o.label })] })) : (_jsx("span", { className: "min-w-0", children: o.label })), o.badge && _jsx("span", { className: cn("rounded-full bg-hover px-2 py-0.5 text-xs font-medium text-ink", c.badge), children: o.badge }), o.aside && _jsx("span", { className: cn("ms-auto shrink-0 font-medium tabular-nums", c.aside), children: o.aside })] })] }, o.value))) })] }));
50
55
  }
@@ -19,7 +19,7 @@ export interface AbSwitcherText {
19
19
  leave: string;
20
20
  minimize: string;
21
21
  hide: string;
22
- /** Shown for an experiment no cookie assigns yet. */
22
+ /** Shown for an experiment no cookie assigns — one that is off; `not running` by default. */
23
23
  unassigned: string;
24
24
  }
25
25
  /**
@@ -1,5 +1,4 @@
1
1
  import type { AnalyticsSink } from "@evinvest/analytics";
2
- import { experimentProps } from "../core/analytics.js";
3
2
  /**
4
3
  * The sink `AnalyticsBoundary` built, for an island inside it that reports
5
4
  * events of its own (`LeadCapture`). Outside a boundary there is none, and the
@@ -14,5 +13,12 @@ export declare const useAnalyticsSink: () => AnalyticsSink | null;
14
13
  */
15
14
  export declare const AnalyticsIdContext: import("react").Context<string | null>;
16
15
  export declare const useAnalyticsId: () => string | null;
17
- /** `data-experiment` / `data-variant` on an element or its ancestors, for an intent's event. */
18
- export declare function experimentOf(el: Element | null): ReturnType<typeof experimentProps>;
16
+ /**
17
+ * `data-experiment` / `data-variant` on an element or its ancestors, for an
18
+ * intent's event — and `data-channels-available`, the messengers its card offered.
19
+ */
20
+ export declare function experimentOf(el: Element | null): {
21
+ experiment?: string;
22
+ variant?: string;
23
+ channels_available?: string;
24
+ };
@@ -1,6 +1,6 @@
1
1
  "use client";
2
2
  import { createContext, useContext } from "react";
3
- import { experimentProps } from "../core/analytics.js";
3
+ import { channelsProps, experimentProps } from "../core/analytics.js";
4
4
  /**
5
5
  * The sink `AnalyticsBoundary` built, for an island inside it that reports
6
6
  * events of its own (`LeadCapture`). Outside a boundary there is none, and the
@@ -15,8 +15,12 @@ export const useAnalyticsSink = () => useContext(AnalyticsSinkContext);
15
15
  */
16
16
  export const AnalyticsIdContext = createContext(null);
17
17
  export const useAnalyticsId = () => useContext(AnalyticsIdContext);
18
- /** `data-experiment` / `data-variant` on an element or its ancestors, for an intent's event. */
18
+ /**
19
+ * `data-experiment` / `data-variant` on an element or its ancestors, for an
20
+ * intent's event — and `data-channels-available`, the messengers its card offered.
21
+ */
19
22
  export function experimentOf(el) {
20
23
  const host = el?.closest("[data-experiment]");
21
- return experimentProps(host?.getAttribute("data-experiment"), host?.getAttribute("data-variant"));
24
+ const card = el?.closest("[data-channels-available]");
25
+ return { ...experimentProps(host?.getAttribute("data-experiment"), host?.getAttribute("data-variant")), ...channelsProps(card?.getAttribute("data-channels-available")) };
22
26
  }
@@ -32,6 +32,7 @@ export type { LeadCaptureLayout, LeadNeedDisplay, LeadNeedOption } from "./LeadC
32
32
  export type { EstimateQuestion, EstimateQuestions } from "./LeadCaptureEstimate.js";
33
33
  export type { LeadIntro, LeadIntroOption } from "./lead-steps.js";
34
34
  export type { ChannelIconKey } from "./LeadCaptureChannels.js";
35
+ export type { MessengerPart } from "./messenger/types.js";
35
36
  export { MapFacade, type MapFacadePart, type MapFacadeProps } from "./MapFacade.js";
36
37
  export { PlaceDirectory, type PlaceDirectoryPart, type PlaceDirectoryProps } from "./PlaceDirectory.js";
37
38
  export { PHONE_INPUT_PROPS, QuoteFormShell, type QuoteFormShellProps } from "./QuoteFormShell.js";
@@ -0,0 +1,144 @@
1
+ import { type MouseEvent, type ReactNode } from "react";
2
+ import { type MessengerMode } from "../../core/messenger.js";
3
+ import { type LeadCaptureMessengerText } from "../../core/messenger-text.js";
4
+ import type { MessengerKit } from "./types.js";
5
+ export type Messenger = Exclude<MessengerMode, "call">;
6
+ /** The variant's words, the brand's over the kit's. */
7
+ export declare const wordsOf: (kit: MessengerKit) => LeadCaptureMessengerText;
8
+ /**
9
+ * What the message says: the need the card knows, the postcode as the
10
+ * visitor types it — and the estimate, the timing and the reference. The
11
+ * preview follows the typing; `compose` reads the form again at the tap, so
12
+ * the link carries what is on screen even after a value set by script.
13
+ */
14
+ export interface Message {
15
+ message: string;
16
+ preview: string;
17
+ compose: () => string;
18
+ }
19
+ export declare function useMessage(kit: MessengerKit, own?: string | null): Message;
20
+ /** Where a messenger CTA leads: wa.me with the message, or the bot with the reference. */
21
+ export declare function hrefOf(kit: MessengerKit, channel: Messenger, message: string): string | null;
22
+ /**
23
+ * A messenger link's attributes. Not `ready` (before the script, before the
24
+ * reference): no `href` — nothing to follow — and `aria-disabled`.
25
+ */
26
+ export interface LinkAttrs {
27
+ href?: string;
28
+ onClick: (event: MouseEvent<HTMLAnchorElement>) => void;
29
+ target?: string;
30
+ rel?: string;
31
+ "aria-disabled"?: true;
32
+ }
33
+ export interface MessengerAction {
34
+ /** A messenger CTA's link: the real `href` (the OS opens the app from the tap) and what the tap does first. */
35
+ link: (channel: Messenger) => LinkAttrs | null;
36
+ /** On a computer, WhatsApp's QR code, drawn in place of the slot; `null` otherwise. */
37
+ panel: ReactNode;
38
+ /** Back from the messenger: drawn over the card's form. */
39
+ overlay: ReactNode;
40
+ }
41
+ /**
42
+ * The tap on a messenger CTA, shared by every variant: a number typed must be
43
+ * one (the browser says so, nothing leaves); the lead is posted in the
44
+ * background; on a phone the link opens the app, on a computer WhatsApp is a
45
+ * QR code in place and Telegram a new tab. Back on the page, the card asks
46
+ * whether the message went, with the way to a call when it did not.
47
+ */
48
+ export declare function useMessengerAction(kit: MessengerKit, message: Message, onCallback: () => void,
49
+ /**
50
+ * `followUp`: the lead is already sent (`thanks`) — a tap posts nothing,
51
+ * keeps the reference and asks nothing on the way back.
52
+ */
53
+ options?: {
54
+ followUp?: boolean;
55
+ }): MessengerAction;
56
+ /** To the phone: the variant's call state, its field focused once it is drawn. */
57
+ export declare function toCall(kit: MessengerKit): void;
58
+ /** A computer, as `isDesktop` says it — after hydration only. */
59
+ export declare function useDesktop(): boolean;
60
+ /** The slot by the channel — the message, the bot, or the phone — and the channel's button under it. */
61
+ export declare function ModeBody(props: {
62
+ kit: MessengerKit;
63
+ message: Message;
64
+ action: MessengerAction;
65
+ labels?: Partial<Record<MessengerMode, string>>;
66
+ }): import("react").JSX.Element;
67
+ /** Whether the brand drew an icon for a channel: a square without one says its name instead. */
68
+ export declare const hasIcon: (kit: MessengerKit, channel: MessengerMode) => boolean;
69
+ /** A channel as a square beside the row's button: its icon, or — the brand drew none — its name; `label` says what it does. */
70
+ export declare function SquareButton(props: {
71
+ kit: MessengerKit;
72
+ channel: MessengerMode;
73
+ label: string;
74
+ onClick: () => void;
75
+ }): import("react").JSX.Element;
76
+ /** The brand's icon for a channel, hidden from assistive technology: the label names it. */
77
+ export declare function Glyph({ kit, channel }: {
78
+ kit: MessengerKit;
79
+ channel: MessengerMode;
80
+ }): import("react").JSX.Element | null;
81
+ export declare function MessengerCta(props: {
82
+ kit: MessengerKit;
83
+ action: MessengerAction;
84
+ channel: Messenger;
85
+ label: string;
86
+ variant?: "primary" | "outline";
87
+ className?: string | undefined;
88
+ square?: boolean;
89
+ }): import("react").JSX.Element | null;
90
+ /** In an app's own browser: WhatsApp may not open — how to leave it, and the message to paste. */
91
+ export declare function InappHint({ kit, message }: {
92
+ kit: MessengerKit;
93
+ message: string;
94
+ }): import("react").JSX.Element | null;
95
+ /** The fixed box a variant swaps its states in; the brand gives it its height (`messengerSlot`). */
96
+ export declare function Slot({ kit, children }: {
97
+ kit: MessengerKit;
98
+ children: ReactNode;
99
+ }): import("react").JSX.Element;
100
+ /** The message, ready: what goes into the chat, so the visitor knows there is nothing to write. */
101
+ export declare function Preview({ kit, preview, title, lede }: {
102
+ kit: MessengerKit;
103
+ preview: string;
104
+ title?: string;
105
+ lede?: string;
106
+ }): import("react").JSX.Element;
107
+ /** What the bot does, in the slot. */
108
+ export declare function BotCard({ kit }: {
109
+ kit: MessengerKit;
110
+ }): import("react").JSX.Element;
111
+ /** «ou via Telegram»: the bot, as a link under the submit. */
112
+ export declare function TelegramLink({ kit, action }: {
113
+ kit: MessengerKit;
114
+ action: MessengerAction;
115
+ }): import("react").JSX.Element | null;
116
+ /** The channels a variant offers, in the Figma order; Telegram only with a bot. */
117
+ export declare function modesOf(kit: MessengerKit, order?: readonly MessengerMode[]): MessengerMode[];
118
+ export declare const labelOf: (words: LeadCaptureMessengerText, mode: MessengerMode) => string;
119
+ export declare const noteOf: (words: LeadCaptureMessengerText, mode: MessengerMode) => string;
120
+ /**
121
+ * The kit's `Select` as a channel picker: an overlay, so the form under it
122
+ * does not move. The trigger draws its own label — `SelectValue` would print
123
+ * the raw value.
124
+ */
125
+ export declare function ChannelPicker(props: {
126
+ kit: MessengerKit;
127
+ trigger: ReactNode;
128
+ triggerClassName?: string | undefined;
129
+ }): import("react").JSX.Element;
130
+ /** A channel as a card to tap (`saga`, `sheet`): its name, its line, the recommended badge on WhatsApp. */
131
+ export declare function OptionCard(props: {
132
+ kit: MessengerKit;
133
+ mode: MessengerMode;
134
+ /** A step in the card; or, for a messenger, `link` — a real link, the app opened from the tap. */
135
+ onPick?: () => void;
136
+ link?: ReturnType<MessengerAction["link"]>;
137
+ recommended?: boolean;
138
+ }): import("react").JSX.Element;
139
+ export declare function QrPanel(props: {
140
+ href: string;
141
+ words: LeadCaptureMessengerText;
142
+ onCallback: (() => void) | null;
143
+ kit: MessengerKit;
144
+ }): import("react").JSX.Element;
@@ -0,0 +1,301 @@
1
+ "use client";
2
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { whatsappHref } from "@evinvest/marketing";
4
+ import { Badge, Button, cn, Select, SelectContent, SelectItem, SelectTrigger } from "@evinvest/uikit";
5
+ import { useEffect, useId, useRef, useState, useSyncExternalStore } from "react";
6
+ import { fillText } from "../../core/lead-capture-format.js";
7
+ import { telegramHref } from "../../core/messenger.js";
8
+ import { messengerMessage } from "../../core/messenger-message.js";
9
+ import { messengerTextOf } from "../../core/messenger-text.js";
10
+ import { phoneProblem } from "../../core/phone.js";
11
+ import { partWithLeading } from "../parts.js";
12
+ import { useAnalyticsId } from "../analytics-context.js";
13
+ import { postInBackground } from "../use-lead-submit.js";
14
+ /** The variant's words, the brand's over the kit's. */
15
+ export const wordsOf = (kit) => messengerTextOf(kit.text, kit.locale);
16
+ const formOf = (kit) => {
17
+ if (typeof document === "undefined")
18
+ return null;
19
+ const el = document.getElementById(`${kit.id}-form`);
20
+ return el instanceof HTMLFormElement ? el : null;
21
+ };
22
+ /** A posted field's value as it stands — an element of the form wherever it is drawn. */
23
+ function valueOf(form, name) {
24
+ const el = form?.elements.namedItem(name);
25
+ return el instanceof HTMLInputElement || el instanceof HTMLSelectElement ? el.value.trim() : "";
26
+ }
27
+ export function useMessage(kit, own) {
28
+ // A variant's own timing (`urgency`), else the estimate's answer the brand named.
29
+ const timing = own === undefined ? kit.timing : own === null ? null : { label: own, short: own };
30
+ const [postcode, setPostcode] = useState("");
31
+ useEffect(() => {
32
+ const form = formOf(kit);
33
+ if (!form)
34
+ return;
35
+ const read = () => setPostcode(valueOf(form, kit.wire.locality));
36
+ read();
37
+ form.addEventListener("input", read);
38
+ form.addEventListener("change", read);
39
+ return () => {
40
+ form.removeEventListener("input", read);
41
+ form.removeEventListener("change", read);
42
+ };
43
+ }, [kit.id, kit.wire.locality]);
44
+ const words = wordsOf(kit);
45
+ // The need with the estimate's answers: «Ménage standard · 2 ch. · 40–70 m²».
46
+ const need = kit.needLabel ? [kit.needLabel, ...kit.answers].join(" · ") : null;
47
+ const compose = (code) => messengerMessage({ brand: kit.brand, need, postcode: code, price: kit.priceText, timing: timing?.label ?? null, ref: kit.messageRef }, words);
48
+ const ref = kit.messageRef ? fillText(words.messageRef, { ref: kit.messageRef }) : null;
49
+ const price = kit.priceText ? fillText(words.messengerPreviewPrice, { price: kit.priceText }) : null;
50
+ const preview = [fillText(words.messageHello, { brand: kit.brand }), need, timing?.short, price, postcode, ref].filter(Boolean).join(" · ");
51
+ return { message: compose(postcode), preview, compose: () => compose(valueOf(formOf(kit), kit.wire.locality)) };
52
+ }
53
+ /** Where a messenger CTA leads: wa.me with the message, or the bot with the reference. */
54
+ export function hrefOf(kit, channel, message) {
55
+ if (channel === "whatsapp")
56
+ return kit.facts.whatsapp ? whatsappHref(kit.facts.whatsapp, message) : null;
57
+ return kit.facts.telegram ? telegramHref(kit.facts.telegram, kit.messageRef ?? undefined) : null;
58
+ }
59
+ /** A computer: no touch to hand a link to an app, and a screen wide enough to read a QR code off. */
60
+ const isDesktop = () => typeof window.matchMedia === "function" && window.matchMedia("(hover: hover) and (pointer: fine)").matches && window.innerWidth >= 768;
61
+ /** The browsers of apps that hold a link to themselves: WhatsApp may not open from them. */
62
+ const IN_APP = /Instagram|FBAN|FBAV|TikTok|musical_ly|BytedanceWebview|\bLine\/|Snapchat/i;
63
+ const noop = () => () => undefined;
64
+ /** Whether the page runs in such a browser — after hydration only: the server cannot know. */
65
+ function useInApp() {
66
+ return useSyncExternalStore(noop, () => IN_APP.test(navigator.userAgent), () => false);
67
+ }
68
+ /**
69
+ * The tap on a messenger CTA, shared by every variant: a number typed must be
70
+ * one (the browser says so, nothing leaves); the lead is posted in the
71
+ * background; on a phone the link opens the app, on a computer WhatsApp is a
72
+ * QR code in place and Telegram a new tab. Back on the page, the card asks
73
+ * whether the message went, with the way to a call when it did not.
74
+ */
75
+ export function useMessengerAction(kit, message, onCallback,
76
+ /**
77
+ * `followUp`: the lead is already sent (`thanks`) — a tap posts nothing,
78
+ * keeps the reference and asks nothing on the way back.
79
+ */
80
+ options = {}) {
81
+ const followUp = options.followUp === true;
82
+ const analyticsId = useAnalyticsId();
83
+ const words = wordsOf(kit);
84
+ const [qr, setQr] = useState(null);
85
+ const [back, setBack] = useState(null);
86
+ const left = useRef(null);
87
+ useEffect(() => {
88
+ let hidden = false;
89
+ const change = () => {
90
+ if (document.visibilityState === "hidden")
91
+ hidden = true;
92
+ else if (hidden && left.current) {
93
+ hidden = false;
94
+ setBack(left.current);
95
+ left.current = null;
96
+ }
97
+ };
98
+ document.addEventListener("visibilitychange", change);
99
+ return () => document.removeEventListener("visibilitychange", change);
100
+ }, []);
101
+ const link = channel => {
102
+ // The render's message — the server has no form to read; the tap reads it again.
103
+ const href = hrefOf(kit, channel, message.message);
104
+ if (!href)
105
+ return null;
106
+ if (!kit.ready)
107
+ return { "aria-disabled": true, onClick: event => event.preventDefault() };
108
+ const onClick = (event) => {
109
+ const form = formOf(kit);
110
+ const phone = form?.elements.namedItem(kit.wire.mobile);
111
+ if (phone instanceof HTMLInputElement && phone.value.trim() !== "" && phoneProblem(phone.value) !== null) {
112
+ event.preventDefault();
113
+ phone.reportValidity();
114
+ return;
115
+ }
116
+ const ref = kit.messageRef;
117
+ // What is on screen now; the link follows it before the browser does.
118
+ const fresh = hrefOf(kit, channel, message.compose()) ?? href;
119
+ event.currentTarget.href = fresh;
120
+ if (form && !followUp)
121
+ void postInBackground(form, { channel, ...(ref ? { message_ref: ref } : {}) }, analyticsId);
122
+ const desktop = isDesktop();
123
+ kit.events.messengerOpen({ channel, device: desktop ? "desktop" : "mobile", inapp: IN_APP.test(navigator.userAgent), ref });
124
+ if (desktop && channel === "whatsapp") {
125
+ event.preventDefault();
126
+ setQr(fresh);
127
+ kit.events.qrShown();
128
+ }
129
+ else if (!followUp) {
130
+ left.current = { channel, ref, href: fresh };
131
+ }
132
+ // Never renewed here: React redraws the link before the browser follows
133
+ // it, and the visitor would leave with a reference the lead does not
134
+ // carry. A second tap on the same lead is the same reference and the
135
+ // same submission id — one lead.
136
+ };
137
+ return channel === "telegram" ? { href, onClick, target: "_blank", rel: "noopener" } : { href, onClick };
138
+ };
139
+ const callback = () => {
140
+ setQr(null);
141
+ onCallback();
142
+ };
143
+ const panel = qr && _jsx(QrPanel, { href: qr, words: words, onCallback: followUp ? null : callback, kit: kit });
144
+ const answer = (said) => {
145
+ if (back)
146
+ kit.events.messengerReturn({ channel: back.channel, answer: said, ref: back.ref });
147
+ setBack(null);
148
+ // Back from the chat and answered: the next lead is another, with a reference of its own.
149
+ if (!followUp)
150
+ kit.renewRef();
151
+ if (said === "failed")
152
+ onCallback();
153
+ };
154
+ const overlay = back && _jsx(ReturnScreen, { left: back, words: words, onAnswer: answer, kit: kit });
155
+ return { link, panel, overlay };
156
+ }
157
+ /** To the phone: the variant's call state, its field focused once it is drawn. */
158
+ export function toCall(kit) {
159
+ kit.setMode("call");
160
+ requestAnimationFrame(() => formOf(kit)?.querySelector('[data-lead-field="phone"]')?.focus());
161
+ }
162
+ /** A computer, as `isDesktop` says it — after hydration only. */
163
+ export function useDesktop() {
164
+ return useSyncExternalStore(noop, isDesktop, () => false);
165
+ }
166
+ /** The slot by the channel — the message, the bot, or the phone — and the channel's button under it. */
167
+ export function ModeBody(props) {
168
+ const { kit, message, action, labels } = props;
169
+ const words = wordsOf(kit);
170
+ const mode = kit.mode ?? "whatsapp";
171
+ return (_jsxs(_Fragment, { children: [_jsx(Slot, { kit: kit, children: mode === "call" ? kit.phone() : mode === "telegram" ? _jsx(BotCard, { kit: kit }) : (action.panel ?? _jsx(Preview, { kit: kit, preview: message.preview })) }), mode === "call" ? (kit.submit(labels?.call)) : (_jsx(MessengerCta, { kit: kit, action: action, channel: mode, label: labels?.[mode] ?? (mode === "whatsapp" ? words.messengerWhatsappCta : words.messengerTelegramCta) })), mode === "whatsapp" && _jsx(InappHint, { kit: kit, message: message.message })] }));
172
+ }
173
+ const iconOf = (kit, channel) => (channel === "call" ? (kit.icons?.phone ?? kit.icons?.callback) : kit.icons?.[channel]);
174
+ /** Whether the brand drew an icon for a channel: a square without one says its name instead. */
175
+ export const hasIcon = (kit, channel) => iconOf(kit, channel) !== undefined && iconOf(kit, channel) !== null;
176
+ /** A channel as a square beside the row's button: its icon, or — the brand drew none — its name; `label` says what it does. */
177
+ export function SquareButton(props) {
178
+ const { kit, channel } = props;
179
+ const icon = hasIcon(kit, channel);
180
+ return (_jsx(Button, { type: "button", variant: "outline", size: "touch", icon: icon, "aria-label": props.label, className: cn("shrink-0", kit.classNames?.messengerSquare), onClick: props.onClick, children: icon ? _jsx(Glyph, { kit: kit, channel: channel }) : labelOf(wordsOf(kit), channel) }));
181
+ }
182
+ /** The brand's icon for a channel, hidden from assistive technology: the label names it. */
183
+ export function Glyph({ kit, channel }) {
184
+ const icon = iconOf(kit, channel);
185
+ if (icon === undefined || icon === null)
186
+ return null;
187
+ return (_jsx("span", { "aria-hidden": "true", className: "flex shrink-0 items-center [&_svg]:size-5", children: icon }));
188
+ }
189
+ /** A messenger CTA: a real link dressed as the card's button, its in-app hint under it. */
190
+ /** A whole-pixel line under a brand's own type size, as the channel buttons keep one. */
191
+ const CTA_LEADING = "leading-6";
192
+ /** The part a messenger button wears: the main one, a secondary one, or a square. */
193
+ const partOf = (kit, kind) => kind === "cta" ? kit.classNames?.messengerCta : kind === "secondary" ? kit.classNames?.messengerSecondary : kit.classNames?.messengerSquare;
194
+ export function MessengerCta(props) {
195
+ const { kit, action, channel } = props;
196
+ const link = action.link(channel);
197
+ if (!link)
198
+ return null;
199
+ const { onClick, ...attrs } = link;
200
+ const square = props.square === true && hasIcon(kit, channel);
201
+ return (_jsx(Button, { asChild: true, variant: props.variant ?? "primary", size: "touch", icon: square, className: partWithLeading(cn(props.square ? "shrink-0" : "w-full", props.className), CTA_LEADING, partOf(kit, props.square ? "square" : props.variant === "outline" ? "secondary" : "cta")), children: _jsxs("a", { ...attrs, onClick: onClick, "aria-label": square ? props.label : undefined, "data-experiment": kit.experiment?.name, "data-variant": kit.experiment?.variant, "data-intent": channel === "telegram" ? "telegram" : undefined, children: [_jsx(Glyph, { kit: kit, channel: channel }), !square && props.label] }) }));
202
+ }
203
+ /** In an app's own browser: WhatsApp may not open — how to leave it, and the message to paste. */
204
+ export function InappHint({ kit, message }) {
205
+ const inApp = useInApp();
206
+ const [copied, setCopied] = useState(false);
207
+ if (!inApp || !kit.facts.whatsapp)
208
+ return null;
209
+ const words = wordsOf(kit);
210
+ return (_jsxs("div", { className: cn("flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-ink-soft", kit.classNames?.messengerHint), children: [_jsx("span", { children: words.messengerInappHint }), _jsx(Button, { type: "button", variant: "link", size: "touch", className: "px-1", onClick: () => void navigator.clipboard?.writeText(message).then(() => setCopied(true), () => undefined), children: copied ? words.messengerCopied : words.messengerCopy })] }));
211
+ }
212
+ /** The fixed box a variant swaps its states in; the brand gives it its height (`messengerSlot`). */
213
+ export function Slot({ kit, children }) {
214
+ return _jsx("div", { className: cn("flex flex-col justify-center", kit.classNames?.messengerSlot), children: children });
215
+ }
216
+ /** The message, ready: what goes into the chat, so the visitor knows there is nothing to write. */
217
+ export function Preview({ kit, preview, title, lede }) {
218
+ const words = wordsOf(kit);
219
+ return (_jsxs("div", { className: cn("flex items-start gap-3 rounded-[var(--control-radius)] bg-muted p-3", kit.classNames?.messengerPreview), children: [_jsx(Glyph, { kit: kit, channel: "whatsapp" }), _jsxs("div", { className: "flex min-w-0 flex-col gap-0.5", children: [_jsx("p", { className: "text-sm font-medium text-ink", children: title ?? words.messengerPreviewTitle }), _jsx("p", { className: "line-clamp-2 text-sm text-ink-soft", children: lede ?? `« ${preview} »${words.messengerPreviewNote ? ` ${words.messengerPreviewNote}` : ""}` })] })] }));
220
+ }
221
+ /** What the bot does, in the slot. */
222
+ export function BotCard({ kit }) {
223
+ const words = wordsOf(kit);
224
+ return (_jsxs("div", { className: cn("flex items-start gap-3 rounded-[var(--control-radius)] bg-muted p-3", kit.classNames?.messengerPreview), children: [_jsx(Glyph, { kit: kit, channel: "telegram" }), _jsxs("div", { className: "flex min-w-0 flex-col gap-0.5", children: [_jsx("p", { className: "text-sm font-medium text-ink", children: words.messengerBotTitle }), _jsx("p", { className: "line-clamp-2 text-sm text-ink-soft", children: words.messengerBotLede })] })] }));
225
+ }
226
+ /** «ou via Telegram»: the bot, as a link under the submit. */
227
+ export function TelegramLink({ kit, action }) {
228
+ const link = action.link("telegram");
229
+ if (!link)
230
+ return null;
231
+ const { onClick, ...attrs } = link;
232
+ return (_jsx(Button, { asChild: true, variant: "link", size: "touch", className: cn("self-center", kit.classNames?.messengerSecondary), children: _jsxs("a", { ...attrs, onClick: onClick, "data-intent": "telegram", "data-experiment": kit.experiment?.name, "data-variant": kit.experiment?.variant, children: [_jsx(Glyph, { kit: kit, channel: "telegram" }), wordsOf(kit).messengerViaTelegram] }) }));
233
+ }
234
+ /** The channels a variant offers, in the Figma order; Telegram only with a bot. */
235
+ export function modesOf(kit, order = ["whatsapp", "call", "telegram"]) {
236
+ return order.filter(m => (m === "whatsapp" ? kit.facts.whatsapp !== null : m === "telegram" ? kit.facts.telegram !== null : true));
237
+ }
238
+ export const labelOf = (words, mode) => mode === "whatsapp" ? words.messengerOptionWhatsapp : mode === "telegram" ? words.messengerOptionTelegram : words.messengerOptionCall;
239
+ export const noteOf = (words, mode) => mode === "whatsapp" ? words.messengerOptionWhatsappNote : mode === "telegram" ? words.messengerOptionTelegramNote : words.messengerOptionCallNote;
240
+ /**
241
+ * The kit's `Select` as a channel picker: an overlay, so the form under it
242
+ * does not move. The trigger draws its own label — `SelectValue` would print
243
+ * the raw value.
244
+ */
245
+ export function ChannelPicker(props) {
246
+ const { kit } = props;
247
+ const words = wordsOf(kit);
248
+ // Its own id: inside the phone's `Field` the trigger would claim the field's,
249
+ // and the label «Téléphone» would name the picker instead of the number.
250
+ const id = useId();
251
+ return (_jsxs(Select, { value: kit.mode ?? "whatsapp", onValueChange: v => kit.setMode(modesOf(kit).find(m => m === v) ?? "call"), children: [_jsx(SelectTrigger, { id: id, "aria-label": words.messengerChannelLabel, className: cn("border-0 shadow-none", props.triggerClassName, kit.classNames?.messengerTrigger), children: props.trigger }), _jsx(SelectContent, { children: modesOf(kit).map(mode => (_jsx(SelectItem, { value: mode, textValue: labelOf(words, mode), className: kit.classNames?.messengerOption, children: _jsxs("span", { className: "flex items-center gap-3", children: [_jsx(Glyph, { kit: kit, channel: mode }), _jsxs("span", { className: "flex flex-col", children: [_jsx("span", { className: "font-medium text-ink", children: labelOf(words, mode) }), _jsx("span", { className: "text-xs text-ink-soft", children: noteOf(words, mode) })] })] }) }, mode))) })] }));
252
+ }
253
+ /** A channel as a card to tap (`saga`, `sheet`): its name, its line, the recommended badge on WhatsApp. */
254
+ export function OptionCard(props) {
255
+ const { kit, mode, link } = props;
256
+ const words = wordsOf(kit);
257
+ const className = cn("flex w-full items-center gap-3 rounded-[var(--control-radius)] border border-border p-3 text-left outline-none hover:bg-hover focus-visible:ring-[3px] focus-visible:ring-ring/50", props.recommended && "border-primary", kit.classNames?.messengerOption);
258
+ const body = (_jsxs(_Fragment, { children: [_jsx(Glyph, { kit: kit, channel: mode }), _jsxs("span", { className: "flex min-w-0 flex-1 flex-col", children: [_jsxs("span", { className: "flex items-center gap-2 font-medium text-ink", children: [labelOf(words, mode), props.recommended && _jsx(Badge, { children: words.messengerRecommended })] }), _jsx("span", { className: "text-sm text-ink-soft", children: noteOf(words, mode) })] }), _jsx("span", { "aria-hidden": "true", className: "text-ink-soft", children: "\u203A" })] }));
259
+ if (link) {
260
+ const { onClick, ...attrs } = link;
261
+ return (_jsx("a", { ...attrs, onClick: onClick, className: className, "data-messenger-option": "", "data-intent": mode === "telegram" ? "telegram" : undefined, "data-experiment": kit.experiment?.name, "data-variant": kit.experiment?.variant, children: body }));
262
+ }
263
+ return (_jsx("button", { type: "button", onClick: props.onPick, className: className, "data-messenger-option": "", children: body }));
264
+ }
265
+ /** A QR code drawn from `qrcode-generator`, loaded on the first desktop tap only. */
266
+ function QrCode({ value, label, className }) {
267
+ const [code, setCode] = useState(null);
268
+ useEffect(() => {
269
+ let live = true;
270
+ void import("qrcode-generator").then(({ default: qrcode }) => {
271
+ const qr = qrcode(0, "L");
272
+ qr.addData(value);
273
+ qr.make();
274
+ const size = qr.getModuleCount();
275
+ let path = "";
276
+ for (let r = 0; r < size; r++)
277
+ for (let c = 0; c < size; c++)
278
+ if (qr.isDark(r, c))
279
+ path += `M${c} ${r}h1v1h-1z`;
280
+ if (live)
281
+ setCode({ size, path });
282
+ });
283
+ return () => {
284
+ live = false;
285
+ };
286
+ }, [value]);
287
+ return (_jsx("svg", { role: "img", "aria-label": label, viewBox: code ? `-2 -2 ${code.size + 4} ${code.size + 4}` : "0 0 1 1", shapeRendering: "crispEdges", className: cn("size-28 shrink-0 bg-card text-ink", className), children: code && _jsx("path", { d: code.path, fill: "currentColor" }) }));
288
+ }
289
+ export function QrPanel(props) {
290
+ const { href, words, kit } = props;
291
+ return (_jsxs("div", { className: cn("flex items-center gap-4 rounded-[var(--control-radius)] border border-border p-3", kit.classNames?.messengerQr), children: [_jsx(QrCode, { value: href, label: words.messengerQrAlt }), _jsxs("div", { className: "flex min-w-0 flex-col gap-1", children: [_jsx("p", { className: "font-medium text-ink", children: words.messengerQrTitle }), _jsx("p", { className: "text-sm text-ink-soft", children: words.messengerQrLede }), _jsx("a", { href: href, target: "_blank", rel: "noopener", className: "text-sm font-medium text-primary-ink underline-offset-4 hover:underline", children: words.messengerQrWeb }), props.onCallback && (_jsx(Button, { type: "button", variant: "link", size: "touch", className: cn("self-start px-0", kit.classNames?.messengerSecondary), onClick: props.onCallback, children: words.messengerCallback }))] })] }));
292
+ }
293
+ function ReturnScreen(props) {
294
+ const c = props.kit.classNames;
295
+ const { left, words } = props;
296
+ const title = useRef(null);
297
+ // The card under it is still there: the question takes the focus, and is read out.
298
+ useEffect(() => title.current?.focus(), []);
299
+ const name = left.channel === "whatsapp" ? words.messengerOptionWhatsapp : words.messengerOptionTelegram;
300
+ return (_jsxs("div", { role: "status", className: cn("absolute inset-0 z-10 flex flex-col justify-center gap-4 bg-card", c?.messengerReturn), children: [_jsx("p", { ref: title, tabIndex: -1, className: "font-display text-xl font-bold text-ink outline-none", children: words.messengerReturnTitle }), _jsx("p", { className: "text-ink-soft", children: fillText(words.messengerReturnBody, { channel: name, ref: left.ref ?? "" }) }), _jsx(Button, { asChild: true, variant: "outline", size: "touch", className: partWithLeading("w-full", CTA_LEADING, c?.messengerSecondary), children: _jsx("a", { href: left.href, ...(left.channel === "telegram" ? { target: "_blank", rel: "noopener" } : {}), children: fillText(words.messengerReopen, { channel: name }) }) }), _jsx(Button, { type: "button", size: "touch", className: partWithLeading("w-full", CTA_LEADING, c?.messengerCta), onClick: () => props.onAnswer("failed"), children: words.messengerReturnFailed }), _jsx(Button, { type: "button", variant: "link", size: "touch", className: "self-center", onClick: () => props.onAnswer("sent"), children: words.messengerReturnDone })] }));
301
+ }
@@ -0,0 +1,8 @@
1
+ import type { MessengerKit } from "./types.js";
2
+ /**
3
+ * VF-5: the lede becomes a chip naming where the answer comes — its menu an
4
+ * overlay over the form — and the slot follows it: the message, the bot, or the phone.
5
+ */
6
+ export default function Chip({ kit }: {
7
+ kit: MessengerKit;
8
+ }): import("react").JSX.Element;