@evinvest/kitstart 0.10.0 → 0.12.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 (84) hide show
  1. package/README.md +210 -17
  2. package/dist/core/accept.d.ts +19 -1
  3. package/dist/core/accept.js +25 -2
  4. package/dist/core/analytics.d.ts +13 -3
  5. package/dist/core/analytics.js +17 -2
  6. package/dist/core/booking/index.d.ts +3 -0
  7. package/dist/core/booking/index.js +3 -0
  8. package/dist/core/booking/model.d.ts +106 -0
  9. package/dist/core/booking/model.js +66 -0
  10. package/dist/core/booking/url.d.ts +17 -0
  11. package/dist/core/booking/url.js +53 -0
  12. package/dist/core/booking/validate.d.ts +27 -0
  13. package/dist/core/booking/validate.js +219 -0
  14. package/dist/core/lead-capture-text.d.ts +26 -2
  15. package/dist/core/lead-capture-text.js +32 -2
  16. package/dist/core/lead.d.ts +33 -0
  17. package/dist/core/lead.js +43 -0
  18. package/dist/core/phone.d.ts +3 -1
  19. package/dist/core/phone.js +28 -2
  20. package/dist/core/place/live.d.ts +6 -2
  21. package/dist/core/place/live.js +21 -2
  22. package/dist/core/place/types.d.ts +6 -0
  23. package/dist/core/place/types.js +0 -13
  24. package/dist/core/routing.d.ts +3 -2
  25. package/dist/core/routing.js +4 -3
  26. package/dist/core/site.d.ts +8 -0
  27. package/dist/core/site.js +15 -0
  28. package/dist/index.d.ts +4 -3
  29. package/dist/index.js +4 -3
  30. package/dist/next/booking-route.d.ts +31 -0
  31. package/dist/next/booking-route.js +97 -0
  32. package/dist/next/confirm-route.d.ts +39 -0
  33. package/dist/next/confirm-route.js +118 -0
  34. package/dist/next/index.d.ts +2 -0
  35. package/dist/next/index.js +2 -0
  36. package/dist/next/quote-route.js +14 -5
  37. package/dist/react/AnalyticsBoundary.js +8 -4
  38. package/dist/react/LeadBooking.d.ts +35 -0
  39. package/dist/react/LeadBooking.js +47 -0
  40. package/dist/react/LeadBookingManual.d.ts +17 -0
  41. package/dist/react/LeadBookingManual.js +50 -0
  42. package/dist/react/LeadCapture.d.ts +28 -5
  43. package/dist/react/LeadCapture.js +22 -8
  44. package/dist/react/LeadCaptureFields.js +60 -21
  45. package/dist/react/LeadCapturePriced.d.ts +3 -4
  46. package/dist/react/LeadCapturePriced.js +4 -4
  47. package/dist/react/analytics-context.d.ts +7 -0
  48. package/dist/react/analytics-context.js +7 -0
  49. package/dist/react/booking-adapters.d.ts +58 -0
  50. package/dist/react/booking-adapters.js +104 -0
  51. package/dist/react/index.d.ts +5 -1
  52. package/dist/react/index.js +4 -1
  53. package/dist/react/use-booking.d.ts +20 -0
  54. package/dist/react/use-booking.js +32 -0
  55. package/dist/react/use-lead-error.d.ts +3 -7
  56. package/dist/react/use-lead-error.js +9 -6
  57. package/dist/react/use-lead-submit.d.ts +7 -1
  58. package/dist/react/use-lead-submit.js +13 -3
  59. package/dist/react/use-repriced.d.ts +18 -0
  60. package/dist/react/use-repriced.js +27 -0
  61. package/dist/server/experiments-declared.d.ts +106 -0
  62. package/dist/server/experiments-declared.js +124 -0
  63. package/dist/server/experiments-source.d.ts +49 -0
  64. package/dist/server/experiments-source.js +88 -0
  65. package/dist/server/index.d.ts +4 -2
  66. package/dist/server/index.js +2 -0
  67. package/dist/server/lead-webhook.d.ts +63 -0
  68. package/dist/server/lead-webhook.js +24 -1
  69. package/dist/server/notify.d.ts +20 -2
  70. package/dist/server/notify.js +6 -4
  71. package/dist/server/place-source.js +1 -1
  72. package/dist/server/webhook-outbox.d.ts +11 -1
  73. package/dist/server/webhook-outbox.js +32 -5
  74. package/dist/testing/lead-rules.js +15 -1
  75. package/package.json +1 -1
  76. package/template/app/quote/booking/route.ts +11 -0
  77. package/template/app/quote/confirm/route.ts +12 -0
  78. package/template/flake.nix +1 -1
  79. package/template/package.json +2 -2
  80. package/template/src/shared/config/places.ts +4 -0
  81. package/template/src/shared/config/site.ts +3 -0
  82. package/template/tests/e2e/flows.spec.ts +86 -9
  83. package/template/tests/e2e/mock-panel.mjs +18 -4
  84. package/template/tests/e2e/quote-form.spec.ts +2 -2
@@ -0,0 +1,35 @@
1
+ import type { OpenBookingConfig } from "../core/booking/model.js";
2
+ import type { LeadCaptureFlowText } from "../core/lead-capture-text.js";
3
+ import { type BookingAdapters } from "./booking-adapters.js";
4
+ import { type BookingPart } from "./LeadBookingManual.js";
5
+ import type { PartClassNames } from "./parts.js";
6
+ import type { LeadSent } from "./use-lead-submit.js";
7
+ export interface LeadBookingProps {
8
+ /** The booking chosen for this visitor (`bookingOf(place, variant)`). */
9
+ booking: OpenBookingConfig;
10
+ sent: LeadSent;
11
+ locale: string;
12
+ text: LeadCaptureFlowText;
13
+ /** The built-in adapters with the brand's own over them, by provider. */
14
+ adapters?: BookingAdapters | undefined;
15
+ /**
16
+ * The providers' embeds (`BOOKING_EMBEDS`) rather than a new tab — only
17
+ * once the visitor accepted the provider's cookies. A flag, because a
18
+ * server component cannot hand a client island an adapter.
19
+ */
20
+ embed?: boolean | undefined;
21
+ formId?: string | undefined;
22
+ /** Where the request posts; `BOOKING_ACTION` by default. */
23
+ action?: string | undefined;
24
+ /** For the `manual` day tiles; the click's clock by default. */
25
+ now?: number | undefined;
26
+ classNames?: PartClassNames<BookingPart> | undefined;
27
+ }
28
+ /**
29
+ * After a priced lead: how the slot is set, through the adapter of the
30
+ * config's provider. Nothing a provider serves is requested before the visitor's click
31
+ * — a link the browser opens, or an embed whose script loads on the click.
32
+ * Without the lead's reference (an old route's answer) there is nothing to
33
+ * join a booking to, and the card only promises the call.
34
+ */
35
+ export declare function LeadBooking(props: LeadBookingProps): import("react").JSX.Element | null;
@@ -0,0 +1,47 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { buttonVariants, cn } from "@evinvest/uikit";
4
+ import { useState } from "react";
5
+ import { BOOKING_EMBEDS, bookingAdapters } from "./booking-adapters.js";
6
+ import { LeadBookingManual } from "./LeadBookingManual.js";
7
+ import { useBooking } from "./use-booking.js";
8
+ function OpenBooking(props) {
9
+ const { adapter, ctx, report, text, classNames: c } = props;
10
+ const [booked, setBooked] = useState(false);
11
+ const onBooked = () => {
12
+ setBooked(true);
13
+ report.booked();
14
+ };
15
+ const click = () => {
16
+ void report.requested({ lead_ref: ctx.leadRef, provider: adapter.provider });
17
+ void adapter.open?.({ ...ctx, onBooked });
18
+ };
19
+ const href = adapter.href?.(ctx) ?? null;
20
+ const look = buttonVariants({ variant: "primary", size: "touch", className: cn("w-full", c?.bookingCta) });
21
+ if (booked)
22
+ return _jsx("p", { className: cn("font-medium text-ink", c?.bookingNote), children: text.booked });
23
+ return (_jsxs("div", { className: cn("flex flex-col gap-3", c?.booking), children: [href !== null ? (_jsx("a", { href: href, target: "_blank", rel: "noopener noreferrer", onClick: click, className: look, "data-booking-provider": adapter.provider, children: text.bookCta })) : (_jsx("button", { type: "button", onClick: click, className: look, "data-booking-provider": adapter.provider, children: text.bookCta })), !adapter.prefillsPhone?.(ctx) && _jsx("p", { className: cn("text-sm text-ink-soft", c?.bookingNote), children: text.bookPhoneHint }), _jsx("p", { className: cn("text-sm text-ink-soft", c?.bookingNote), children: text.bookNote })] }));
24
+ }
25
+ /**
26
+ * After a priced lead: how the slot is set, through the adapter of the
27
+ * config's provider. Nothing a provider serves is requested before the visitor's click
28
+ * — a link the browser opens, or an embed whose script loads on the click.
29
+ * Without the lead's reference (an old route's answer) there is nothing to
30
+ * join a booking to, and the card only promises the call.
31
+ */
32
+ export function LeadBooking(props) {
33
+ const { booking, sent, text, classNames: c } = props;
34
+ const provider = booking.provider;
35
+ const report = useBooking(provider, sent.submission ?? "", props.formId ?? "quote", props.action);
36
+ const proven = sent.lead !== undefined && sent.submission !== undefined;
37
+ const adapter = bookingAdapters({ ...(props.embed ? BOOKING_EMBEDS : {}), ...props.adapters })[provider];
38
+ // No reference to join a booking to, or a provider no adapter knows: the call is promised.
39
+ if (sent.lead === undefined || (provider !== "manual" && (!proven || !adapter)))
40
+ return _jsx("p", { className: cn("text-ink", c?.bookingNote), children: text.slotCallback });
41
+ if (provider === "manual")
42
+ return _jsx(LeadBookingManual, { leadRef: sent.lead, locale: props.locale, now: props.now ?? Date.now(), text: text, report: proven ? report : null, classNames: c });
43
+ const ctx = { config: booking, leadRef: sent.lead, name: sent.name, phone: sent.phone, locale: props.locale };
44
+ if (!adapter)
45
+ return null;
46
+ return _jsx(OpenBooking, { adapter: adapter, ctx: ctx, report: report, text: text, classNames: c });
47
+ }
@@ -0,0 +1,17 @@
1
+ import type { LeadCaptureFlowText } from "../core/lead-capture-text.js";
2
+ import type { PartClassNames } from "./parts.js";
3
+ import type { BookingReport } from "./use-booking.js";
4
+ export type BookingPart = "booking" | "bookingCta" | "bookingNote" | "prefer" | "preferOption" | "preferSubmit";
5
+ /**
6
+ * `manual`: the call is promised, and the visitor may say when suits them —
7
+ * a day of the coming week and a part of the day, both optional, no free
8
+ * text. Sent as `booking.requested@1`; the operator sets the slot on the call.
9
+ */
10
+ export declare function LeadBookingManual(props: {
11
+ leadRef: string;
12
+ locale: string;
13
+ now: number;
14
+ text: LeadCaptureFlowText;
15
+ report: BookingReport | null;
16
+ classNames?: PartClassNames<BookingPart> | undefined;
17
+ }): import("react").JSX.Element;
@@ -0,0 +1,50 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { Button, cn } from "@evinvest/uikit";
4
+ import { useMemo, useState } from "react";
5
+ import { PREFERRED_PARTS } from "../core/booking/model.js";
6
+ /** How many days ahead a visitor may prefer: tiles, not a calendar — a tap each. */
7
+ const DAYS_AHEAD = 7;
8
+ /** `YYYY-MM-DD` in the visitor's own calendar, `n` days from `now`. */
9
+ function dayFrom(now, n) {
10
+ const d = new Date(now);
11
+ d.setDate(d.getDate() + n);
12
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
13
+ }
14
+ function Tiles(props) {
15
+ return (_jsxs("fieldset", { className: "flex flex-col gap-2", children: [_jsx("legend", { className: "mb-2 text-sm font-medium text-ink", children: props.legend }), _jsx("div", { className: "grid grid-cols-3 gap-2 sm:grid-cols-4", children: props.options.map(o => (_jsxs("label", { className: "relative block cursor-pointer", children: [_jsx("input", { type: "radio", name: props.name, value: o.value, checked: props.value === o.value, onChange: () => props.onChange(o.value), className: "peer absolute inset-0 opacity-0" }), _jsx("span", { className: cn("flex h-full min-h-11 items-center justify-center rounded-[var(--control-radius)] border border-input px-2 py-2 text-center text-sm text-ink transition-colors", "peer-checked:border-primary peer-checked:bg-hover peer-focus-visible:ring-[3px] peer-focus-visible:ring-ring/50", props.className), children: o.label })] }, o.value))) })] }));
16
+ }
17
+ /**
18
+ * `manual`: the call is promised, and the visitor may say when suits them —
19
+ * a day of the coming week and a part of the day, both optional, no free
20
+ * text. Sent as `booking.requested@1`; the operator sets the slot on the call.
21
+ */
22
+ export function LeadBookingManual(props) {
23
+ const { text, locale, classNames: c } = props;
24
+ const [day, setDay] = useState(null);
25
+ const [part, setPart] = useState(null);
26
+ const [state, setState] = useState("idle");
27
+ const days = useMemo(() => {
28
+ const fmt = new Intl.DateTimeFormat(locale, { weekday: "short", day: "numeric" });
29
+ return Array.from({ length: DAYS_AHEAD }, (_, i) => {
30
+ const value = dayFrom(props.now, i + 1);
31
+ return { value, label: fmt.format(new Date(`${value}T12:00:00`)) };
32
+ });
33
+ }, [locale, props.now]);
34
+ const parts = PREFERRED_PARTS.map(value => ({ value, label: { morning: text.partMorning, afternoon: text.partAfternoon, evening: text.partEvening }[value] }));
35
+ const report = props.report;
36
+ const send = async (event) => {
37
+ event.preventDefault();
38
+ if (!report || (day === null && part === null))
39
+ return;
40
+ setState("busy");
41
+ const ok = await report.requested({ lead_ref: props.leadRef, provider: "manual", ...(day ? { preferred_date: day } : {}), ...(part ? { preferred_part: part } : {}) });
42
+ setState(ok ? "sent" : "failed");
43
+ };
44
+ let body = null;
45
+ if (state === "sent")
46
+ body = _jsx("p", { className: "text-ink", children: text.preferSent });
47
+ else if (report)
48
+ body = (_jsxs("form", { onSubmit: send, className: cn("flex flex-col gap-4", c?.prefer), "data-booking-prefer": "", children: [_jsx("p", { className: "text-sm text-ink-soft", children: text.preferTitle }), _jsx(Tiles, { legend: text.preferDay, name: "preferred_date", options: days, value: day, onChange: setDay, className: c?.preferOption }), _jsx(Tiles, { legend: text.preferPart, name: "preferred_part", options: parts, value: part, onChange: setPart, className: c?.preferOption }), state === "failed" && (_jsx("p", { role: "alert", className: "text-sm text-ink", children: text.preferFailed })), _jsx(Button, { type: "submit", variant: "outline", size: "touch", disabled: state === "busy" || (day === null && part === null), className: cn("w-full", c?.preferSubmit), children: text.preferSubmit })] }));
49
+ return (_jsxs("div", { className: cn("flex flex-col gap-4", c?.booking), children: [_jsx("p", { className: cn("text-ink", c?.bookingNote), children: text.slotCallback }), body] }));
50
+ }
@@ -1,11 +1,13 @@
1
1
  import { type ReactNode } from "react";
2
2
  import { type CaptureChannel } from "../core/channels.js";
3
- import type { LeadWire } from "../core/lead.js";
3
+ import { type LeadError, type LeadWire, type PageLeadError } from "../core/lead.js";
4
4
  import { type LeadCaptureText } from "../core/lead-capture-text.js";
5
5
  import { type Place } from "../core/place/types.js";
6
6
  import { type LeadFlows } from "../core/pricing/flow.js";
7
7
  import type { PricingModel } from "../core/pricing/model.js";
8
8
  import type { FormSelectOption } from "./FormSelect.js";
9
+ import type { BookingAdapters } from "./booking-adapters.js";
10
+ import type { BookingPart } from "./LeadBookingManual.js";
9
11
  import { type PricedPart } from "./LeadCapturePriced.js";
10
12
  import { type ChannelPart, type Experiment } from "./LeadCaptureChannels.js";
11
13
  import { type EstimatePart } from "./LeadCaptureEstimate.js";
@@ -13,7 +15,7 @@ import { type FieldPart } from "./LeadCaptureFields.js";
13
15
  import { type LeadCaptureLayout } from "./LeadCaptureNeed.js";
14
16
  import type { PartClassNames } from "./parts.js";
15
17
  import { type LeadSent } from "./use-lead-submit.js";
16
- export type LeadCapturePart = "root" | "head" | "title" | "lede" | "form" | "contact" | "submit" | "trust" | "privacy" | "opening" | "others" | "done" | "needs" | "need" | "summary" | FieldPart | ChannelPart | EstimatePart | PricedPart;
18
+ export type LeadCapturePart = "root" | "head" | "title" | "lede" | "form" | "contact" | "submit" | "trust" | "privacy" | "opening" | "others" | "done" | "needs" | "need" | "summary" | FieldPart | ChannelPart | EstimatePart | PricedPart | BookingPart;
17
19
  export interface LeadCaptureProps {
18
20
  /** Its hours order the channels; its service area suggests the commune. */
19
21
  place: Place<string>;
@@ -48,11 +50,25 @@ export interface LeadCaptureProps {
48
50
  */
49
51
  photos?: readonly string[] | undefined;
50
52
  /**
51
- * After a priced lead, in place of "we call you to set the slot": a
52
- * booking provider's widget, when the brand has one. None ships with the
53
- * kit yet.
53
+ * After a priced lead: how the slot is set. By default the place's booking
54
+ * (`place.booking`, `manual` when it has none) for `bookingVariant`,
55
+ * through its provider's adapter — `LeadBooking`. A node here replaces it
56
+ * whole.
54
57
  */
55
58
  booking?: ReactNode | ((sent: LeadSent) => ReactNode);
59
+ /**
60
+ * The built-in booking adapters with the brand's own over them, by
61
+ * provider — from a client component only: a server one cannot pass functions.
62
+ */
63
+ bookingAdapters?: BookingAdapters | undefined;
64
+ /**
65
+ * The variant of the `booking_provider` experiment (`BOOKING_EXPERIMENT`):
66
+ * the provider this visitor is offered, when the place has it; else the
67
+ * place's default (`bookingOf`).
68
+ */
69
+ bookingVariant?: string | null | undefined;
70
+ /** The providers' embeds instead of a new tab — only once the visitor accepted their cookies. */
71
+ bookingEmbed?: boolean | undefined;
56
72
  /** The need the page already knows; `?need=` and `[data-need]` triggers set it too. */
57
73
  need?: string | undefined;
58
74
  /** `single` (one screen) or `qualify-first` (the need, then the contact) — an experiment's switch. */
@@ -94,6 +110,13 @@ export interface LeadCaptureProps {
94
110
  * posts as it always did.
95
111
  */
96
112
  done?: ReactNode | ((sent: LeadSent) => ReactNode);
113
+ /**
114
+ * The refusal the page read from its query — `leadErrorOf(searchParams)` —
115
+ * drawn on the server, so the card says why without a script — by this card
116
+ * only when the query names it (`lead_card`, `leadErrorFor`). A page built
117
+ * for ISR has no query to read; there the script says it once it runs.
118
+ */
119
+ initialError?: PageLeadError | LeadError | null | undefined;
97
120
  /** Replaces the title and lede — the brand's own heading. */
98
121
  head?: ReactNode;
99
122
  /** Beside the submit: a guarantee, a live rating. */
@@ -1,12 +1,16 @@
1
1
  "use client";
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { cn } from "@evinvest/uikit";
4
- import { useEffect, useRef, useState } from "react";
4
+ import { Fragment, useEffect, useRef, useState } from "react";
5
+ import { PRICE_CHANGED, SHOWN_CENTS_FIELD } from "../core/accept.js";
5
6
  import { resolveChannels } from "../core/channels.js";
7
+ import { leadErrorFor } from "../core/lead.js";
6
8
  import { fillText, openingText } from "../core/lead-capture-format.js";
7
9
  import { flowTextOf } from "../core/lead-capture-text.js";
10
+ import { bookingOf } from "../core/booking/model.js";
8
11
  import { servedLocalities, storefrontOf } from "../core/place/types.js";
9
12
  import { flowOf } from "../core/pricing/flow.js";
13
+ import { LeadBooking } from "./LeadBooking.js";
10
14
  import { LeadCapturePriced } from "./LeadCapturePriced.js";
11
15
  import { CallbackForm, ChannelLink, ExperimentFields, PhotosAsk } from "./LeadCaptureChannels.js";
12
16
  import { EstimateInputs, PriceBox, useEstimate } from "./LeadCaptureEstimate.js";
@@ -18,6 +22,7 @@ import { errorText, formMessageId, OWN_FIELDS, useLeadError } from "./use-lead-e
18
22
  import { useLeadEvents } from "./use-lead-events.js";
19
23
  import { FailureMessage, SubmitButton } from "./LeadCaptureSubmit.js";
20
24
  import { useLeadSubmit } from "./use-lead-submit.js";
25
+ import { useRepriced } from "./use-repriced.js";
21
26
  /**
22
27
  * What the locality field is offered. A storefront serving its own town knows
23
28
  * that town's postcode — what the field asks — so it offers that, not the
@@ -43,20 +48,29 @@ export function LeadCapture(props) {
43
48
  const [need, setNeed] = useNeed(props.need, needs.map(n => n.value), () => setEditing(false));
44
49
  const events = useLeadEvents(root, { formId, layout, experiment });
45
50
  useOpenOnHash(`${id}-callback`);
46
- const [error, setError] = useLeadError(root, id);
51
+ // Only this card's: a page may draw several, and the query names one.
52
+ const initial = leadErrorFor(props.initialError ?? null, id);
53
+ const [error, setError] = useLeadError(root, id, initial);
47
54
  const flowText = flowTextOf(text, locale);
48
55
  // `single` shows its select at the first need until one is picked: that is the need on screen.
49
56
  const shownNeed = need ?? (layout === "single" ? needs[0]?.value : undefined);
50
57
  const model = props.pricing ?? null;
51
58
  const flow = flowOf(props.flows, model, shownNeed);
52
59
  const estimate = useEstimate(model, shownNeed, flow, events.estimateShown);
60
+ const repriced = useRepriced(estimate.price, shownNeed, flowText, locale);
53
61
  // A priced lead stays in the card: its price is confirmed there, and the slot promised.
54
62
  const staysInCard = (ch) => props.done !== undefined || (ch === "form" && flow !== "quote");
55
- const { sent, onSubmit, busy, failure, retry } = useLeadSubmit(staysInCard, { mobile: wire.mobile, name: props.name?.field }, { onRefused: (channel, field) => setError({ channel, field }), onFailed: (channel, why) => events.submitError(why, channel) });
63
+ const { sent, onSubmit, busy, failure, retry } = useLeadSubmit(staysInCard, { mobile: wire.mobile, name: props.name?.field }, {
64
+ onRefused: (channel, field, cents) => {
65
+ repriced.refused(field, cents);
66
+ setError({ channel, field });
67
+ },
68
+ onFailed: (channel, why) => events.submitError(why, channel),
69
+ });
56
70
  // The form's refusal by where it shows: under the field the card draws, else above the submit.
57
71
  const formError = error?.channel === "form" ? error.field : null;
58
72
  const at = (field) => (formError === field ? errorText(field, text) : null);
59
- const above = formError !== null && !OWN_FIELDS.form.includes(formError) ? errorText(formError, text) : null;
73
+ const above = formError === PRICE_CHANGED ? repriced.message() : formError !== null && !OWN_FIELDS.form.includes(formError) ? errorText(formError, text) : null;
60
74
  const doneRef = useRef(null);
61
75
  // The form the focus was in is gone: the news takes it, and is read out.
62
76
  useEffect(() => doneRef.current?.focus(), [sent]);
@@ -88,7 +102,7 @@ export function LeadCapture(props) {
88
102
  setNeed(value);
89
103
  setEditing(true);
90
104
  };
91
- const channel = (ch, primary) => ch === "callback" ? (_jsx(CallbackForm, { id: `${id}-callback`, card: id, primary: primary, open: props.callbackOpen ?? primary, formId: formId, placeSlug: place.slug, locale: locale, renderedAt: renderedAt, mobileName: wire.mobile, subject: need ? { name: wire.subject, value: need } : null, opening: opening, text: text, experiment: experiment, onSubmit: onSubmit, error: error?.channel === "callback" ? { field: error.field, text: errorText(error.field, text) } : null, busy: busy === "callback", failure: failure?.channel === "callback" ? failure.failure : null, onRetry: retry, onSoftError: () => events.fieldError("phone"), classNames: c }, ch)) : ch === "form" ? null : (_jsx(ChannelLink, { channel: ch, contact: contact, message: message, text: text, primary: primary, experiment: experiment, classNames: c }, ch));
105
+ const channel = (ch, primary) => ch === "callback" ? (_jsx(CallbackForm, { id: `${id}-callback`, card: id, primary: primary, open: initial?.channel === "callback" || (props.callbackOpen ?? primary), formId: formId, placeSlug: place.slug, locale: locale, renderedAt: renderedAt, mobileName: wire.mobile, subject: need ? { name: wire.subject, value: need } : null, opening: opening, text: text, experiment: experiment, onSubmit: onSubmit, error: error?.channel === "callback" ? { field: error.field, text: errorText(error.field, text) } : null, busy: busy === "callback", failure: failure?.channel === "callback" ? failure.failure : null, onRetry: retry, onSoftError: () => events.fieldError("phone"), classNames: c }, ch)) : ch === "form" ? null : (_jsx(ChannelLink, { channel: ch, contact: contact, message: message, text: text, primary: primary, experiment: experiment, classNames: c }, ch));
92
106
  // `qualify-first` shows the contact step as soon as a need's radio is
93
107
  // checked (`:has`) — without a script, and inside the tap with one.
94
108
  const contactShown = layout === "single" || (need !== undefined && !editing);
@@ -96,10 +110,10 @@ export function LeadCapture(props) {
96
110
  const rootProps = { id, className: cn("flex w-full flex-col gap-6", props.className, c?.root), "data-experiment": experiment?.name, "data-variant": experiment?.variant };
97
111
  if (sent && staysInCard(sent.channel)) {
98
112
  const done = typeof props.done === "function" ? props.done(sent) : props.done;
99
- return (_jsx("div", { ref: root, ...rootProps, children: _jsxs("div", { ref: doneRef, role: "status", tabIndex: -1, className: cn("flex flex-col gap-4 outline-none", c?.done), children: [done, sent.channel === "form" && flow !== "quote" && (_jsx(LeadCapturePriced, { sent: sent, booking: typeof props.booking === "function" ? props.booking(sent) : props.booking, locale: locale, text: flowText, classNames: c }))] }) }));
113
+ return (_jsx("div", { ref: root, ...rootProps, children: _jsxs("div", { ref: doneRef, role: "status", tabIndex: -1, className: cn("flex flex-col gap-4 outline-none", c?.done), children: [_jsx(Fragment, { children: done }, "done"), sent.channel === "form" && flow !== "quote" && (_jsx(LeadCapturePriced, { sent: sent, booking: props.booking === undefined ? (_jsx(LeadBooking, { booking: bookingOf(place, props.bookingVariant), sent: sent, locale: locale, text: flowText, adapters: props.bookingAdapters, embed: props.bookingEmbed, formId: formId, classNames: c })) : typeof props.booking === "function" ? (props.booking(sent)) : (props.booking), locale: locale, text: flowText, classNames: c }))] }) }));
100
114
  }
101
- return (_jsxs("div", { ref: root, ...rootProps, children: [props.head ?? (_jsxs("div", { className: cn("flex flex-col gap-1", c?.head), children: [_jsx("p", { className: cn("font-display text-2xl font-bold text-ink", c?.title), children: text.title }), _jsx("p", { className: cn("text-ink-soft", c?.lede), children: text.lede })] })), lead !== "form" && channel(lead, true), _jsxs(QuoteFormShell, { id: `${id}-form`, placeSlug: place.slug, locale: locale, renderedAt: renderedAt, honeypotLabel: text.honeypotLabel, formId: formId, card: id, onSubmit: onSubmit, className: cn("group/lead", c?.form), children: [_jsx(ExperimentFields, { experiment: experiment }), _jsx(NeedField, { layout: layout, name: wire.subject, needs: needs, need: need, editing: editing, hydrated: hydrated, label: text.needLabel, changeLabel: text.needChange, requiredText: text.needRequired, onPick: pick, onSelect: select, onEdit: () => {
115
+ return (_jsxs("div", { ref: root, ...rootProps, children: [_jsx(Fragment, { children: props.head ?? (_jsxs("div", { className: cn("flex flex-col gap-1", c?.head), children: [_jsx("p", { className: cn("font-display text-2xl font-bold text-ink", c?.title), children: text.title }), _jsx("p", { className: cn("text-ink-soft", c?.lede), children: text.lede })] })) }, "head"), lead !== "form" && channel(lead, true), _jsxs(QuoteFormShell, { id: `${id}-form`, placeSlug: place.slug, locale: locale, renderedAt: renderedAt, honeypotLabel: text.honeypotLabel, formId: formId, card: id, onSubmit: onSubmit, className: cn("group/lead", c?.form), children: [_jsx(ExperimentFields, { experiment: experiment }), _jsx(NeedField, { layout: layout, name: wire.subject, needs: needs, need: need, editing: editing, hydrated: hydrated, label: text.needLabel, changeLabel: text.needChange, requiredText: text.needRequired, onPick: pick, onSelect: select, onEdit: () => {
102
116
  setEditing(true);
103
117
  events.step("need");
104
- }, classNames: c }), _jsxs("div", { className: cn("flex-col gap-5", contactClass, c?.contact), children: [model && shownNeed !== undefined && flow === "estimate" && (_jsx(EstimateInputs, { model: model, need: shownNeed, locale: locale, answers: estimate.answers, onAnswer: estimate.answer, required: hydrated, classNames: c })), model && flow !== "quote" && _jsx(PriceBox, { model: model, flow: flow, price: estimate.price, locale: locale, text: flowText, classNames: c }), flow === "quote" && shownNeed !== undefined && props.photos?.includes(shownNeed) && (_jsx(PhotosAsk, { whatsapp: contact.whatsapp, needLabel: needs.find(n => n.value === shownNeed)?.label ?? shownNeed, text: flowText, experiment: experiment, className: c?.photos })), _jsx(LocalityField, { name: wire.locality, label: text.localityLabel, servedLabel: text.servedLabel, served: localitySuggestions(place), placeholder: text.localityPlaceholder, required: props.locality !== "optional", optional: text.optional, requiredText: text.required, error: at("locality"), hydrated: hydrated, classNames: c }), _jsx(PhoneField, { name: wire.mobile, text: text, error: at("phone"), onSoftError: () => events.fieldError("phone"), classNames: c }), props.name && (_jsx(NameField, { name: props.name.field, label: text.nameLabel, placeholder: text.namePlaceholder, required: props.name.required ?? false, optional: text.optional, requiredText: text.required, error: at("name"), classNames: c })), props.extras, _jsx(FormMessage, { id: formMessageId(id, "form"), error: above, className: c?.error }), _jsx(FailureMessage, { failure: failure?.channel === "form" ? failure.failure : null, text: text, onRetry: retry, className: c?.error }), _jsxs("div", { className: cn("flex flex-col gap-3", c?.trust), children: [_jsx(SubmitButton, { busy: busy === "form", label: flow === "quote" ? text.submit : flowText.bookSubmit, sending: text.sending, className: c?.submit }), props.trust] }), opening && lead !== "callback" && _jsx("p", { className: cn("text-sm text-ink-soft", c?.opening), children: opening }), _jsx("p", { className: cn("text-sm text-ink-soft", c?.privacy), children: text.privacy })] })] }), (links.length > 0 || rest.includes("callback")) && (_jsxs("div", { className: cn("flex flex-col gap-3", c?.others), children: [_jsx("p", { className: "text-sm font-medium text-ink", children: text.otherChannels }), links.length > 0 && _jsx("div", { className: "flex flex-wrap gap-2", children: links.map(ch => channel(ch, false)) }), rest.includes("callback") && channel("callback", false)] }))] }));
118
+ }, classNames: c }), _jsxs("div", { className: cn("flex-col gap-5", contactClass, c?.contact), children: [model && shownNeed !== undefined && flow === "estimate" && (_jsx(EstimateInputs, { model: model, need: shownNeed, locale: locale, answers: estimate.answers, onAnswer: estimate.answer, required: hydrated, classNames: c })), model && flow !== "quote" && _jsx(PriceBox, { model: model, flow: flow, price: repriced.price, locale: locale, text: flowText, classNames: c }), flow !== "quote" && repriced.shownCents !== undefined && _jsx("input", { type: "hidden", name: SHOWN_CENTS_FIELD, value: repriced.shownCents }), flow === "quote" && shownNeed !== undefined && props.photos?.includes(shownNeed) && (_jsx(PhotosAsk, { whatsapp: contact.whatsapp, needLabel: needs.find(n => n.value === shownNeed)?.label ?? shownNeed, text: flowText, experiment: experiment, className: c?.photos })), _jsx(LocalityField, { name: wire.locality, label: text.localityLabel, servedLabel: text.servedLabel, served: localitySuggestions(place), placeholder: text.localityPlaceholder, required: props.locality !== "optional", optional: text.optional, requiredText: text.required, error: at("locality"), hydrated: hydrated, classNames: c }), _jsx(PhoneField, { name: wire.mobile, text: text, error: at("phone"), onSoftError: () => events.fieldError("phone"), classNames: c }), props.name && (_jsx(NameField, { name: props.name.field, label: text.nameLabel, placeholder: text.namePlaceholder, required: props.name.required ?? false, optional: text.optional, requiredText: text.required, error: at("name"), classNames: c })), _jsx(Fragment, { children: props.extras }, "extras"), _jsx(FormMessage, { id: formMessageId(id, "form"), error: above, className: c?.error }), _jsx(FailureMessage, { failure: failure?.channel === "form" ? failure.failure : null, text: text, onRetry: retry, className: c?.error }), _jsxs("div", { className: cn("flex flex-col gap-3", c?.trust), children: [_jsx(SubmitButton, { busy: busy === "form", label: flow === "quote" ? text.submit : flowText.bookSubmit, sending: text.sending, className: c?.submit }), _jsx(Fragment, { children: props.trust }, "trust")] }), opening && lead !== "callback" && _jsx("p", { className: cn("text-sm text-ink-soft", c?.opening), children: opening }), _jsx("p", { className: cn("text-sm text-ink-soft", c?.privacy), children: text.privacy })] })] }), (links.length > 0 || rest.includes("callback")) && (_jsxs("div", { className: cn("flex flex-col gap-3", c?.others), children: [_jsx("p", { className: "text-sm font-medium text-ink", children: text.otherChannels }), links.length > 0 && _jsx("div", { className: "flex flex-wrap gap-2", children: links.map(ch => channel(ch, false)) }), rest.includes("callback") && channel("callback", false)] }))] }));
105
119
  }
@@ -19,9 +19,10 @@ const POSTCODE = /^\d+$/;
19
19
  function FieldMessage(props) {
20
20
  const { id, error, hint, classNames: c } = props;
21
21
  return (
22
- // Empty, it gives back the field's gap rather than vanish: a live region
23
- // hidden while empty is not one some screen readers listen to.
24
- _jsx("div", { "aria-live": "polite", className: "empty:-mt-2", children: error ? (_jsx(FieldError, { id: id, className: cn("text-accent-error", c?.error), children: error })) : hint ? (_jsx(FieldDescription, { id: id, className: cn("text-accent-error", c?.hint), children: hint })) : null }));
22
+ // Empty, it leaves the flex flow (an absolute child takes no gap, whatever
23
+ // gap the brand gives the field) rather than vanish: a live region hidden
24
+ // while empty is not one some screen readers listen to.
25
+ _jsx("div", { "aria-live": "polite", className: "empty:absolute", children: error ? (_jsx(FieldError, { id: id, className: cn("text-accent-error", c?.error), children: error })) : hint ? (_jsx(FieldDescription, { id: id, className: cn("text-accent-error", c?.hint), children: hint })) : null }));
25
26
  }
26
27
  const required = (text, on) => (el) => (on && el.value.trim() === "" ? text : "");
27
28
  export function LocalityField(props) {
@@ -58,30 +59,68 @@ export function PhoneField(props) {
58
59
  });
59
60
  const error = props.error ?? validity.shown;
60
61
  const marked = Boolean(error) || doubtful;
61
- // A hint drawn on the blur a press of the submit causes pushes the button
62
- // down between press and release, and the click lands on nothing: the
63
- // submit's own check speaks instead.
64
- const submitting = useRef(false);
62
+ // A hint drawn on the blur a press causes pushes whatever is below the
63
+ // field down between press and release, and the tap lands on nothing (the
64
+ // consent box, the submit). So a press on this form's submit draws no hint
65
+ // — the submit's own check speaks instead — and a press anywhere else
66
+ // draws it only once the press is over.
67
+ const pressed = useRef(null);
65
68
  useEffect(() => {
66
- const form = validity.ref.current?.form;
67
- if (!form)
68
- return;
69
69
  const press = (event) => {
70
- submitting.current = event.target instanceof Element && event.target.closest("[type=submit]") !== null;
70
+ const own = validity.ref.current;
71
+ const target = event.target instanceof Element ? event.target : null;
72
+ const submit = target?.closest("[type=submit]");
73
+ pressed.current = target === own ? null : submit && own?.form?.contains(submit) ? "submit" : "other";
74
+ };
75
+ // The press lasts until its click, not its release: on touch the
76
+ // release comes before the blur it causes (down, up, mousedown, blur,
77
+ // click). A press that blurred nothing must not hold a later keyboard
78
+ // blur, so a key ends it too.
79
+ const release = () => {
80
+ pressed.current = null;
81
+ };
82
+ const ends = ["click", "pointercancel", "keydown"];
83
+ document.addEventListener("pointerdown", press, true);
84
+ for (const type of ends)
85
+ document.addEventListener(type, release, true);
86
+ return () => {
87
+ document.removeEventListener("pointerdown", press, true);
88
+ for (const type of ends)
89
+ document.removeEventListener(type, release, true);
71
90
  };
72
- form.addEventListener("pointerdown", press, true);
73
- return () => form.removeEventListener("pointerdown", press, true);
74
91
  }, [validity.ref]);
92
+ const judge = (value) => {
93
+ const bad = value.trim() !== "" && phoneProblem(value) !== null;
94
+ if (bad && !doubtful)
95
+ onSoftError();
96
+ setDoubtful(bad);
97
+ };
75
98
  return (_jsxs(Field, { ...(props.id ? { controlId: props.id } : {}), className: cn("flex flex-col gap-2", c?.field), children: [_jsx(FieldLabel, { className: c?.label, children: text.phoneLabel }), _jsx(Input, { ref: validity.ref, name: name, size: "lg", ...PHONE_INPUT_PROPS, enterKeyHint: "send", placeholder: text.phonePlaceholder, required: true, "data-lead-field": "phone", "aria-invalid": marked || undefined, "aria-describedby": marked ? messageId : undefined, onInvalid: validity.onInvalid, onBlur: e => {
76
- if (submitting.current) {
77
- submitting.current = false;
78
- return;
79
- }
80
99
  const value = e.currentTarget.value;
81
- const bad = value.trim() !== "" && phoneProblem(value) !== null;
82
- if (bad && !doubtful)
83
- onSoftError();
84
- setDoubtful(bad);
100
+ const press = pressed.current;
101
+ pressed.current = null;
102
+ if (press === "submit")
103
+ return;
104
+ if (press === null)
105
+ return judge(value);
106
+ // After the press's click, which lands where the press began; a
107
+ // cancelled press (a scroll) ends the wait too, and so does a
108
+ // release no click follows (dragged off), a moment later.
109
+ let timer;
110
+ const ends = ["click", "pointercancel"];
111
+ const over = () => {
112
+ for (const type of ends)
113
+ window.removeEventListener(type, over, true);
114
+ window.removeEventListener("pointerup", late, true);
115
+ clearTimeout(timer);
116
+ setTimeout(() => judge(value), 0);
117
+ };
118
+ const late = () => {
119
+ timer = setTimeout(over, 500);
120
+ };
121
+ for (const type of ends)
122
+ window.addEventListener(type, over, true);
123
+ window.addEventListener("pointerup", late, true);
85
124
  }, onChange: e => {
86
125
  validity.onChange(e);
87
126
  if (doubtful && phoneProblem(e.currentTarget.value) === null)
@@ -1,13 +1,12 @@
1
- import type { ReactNode } from "react";
1
+ import { type ReactNode } from "react";
2
2
  import type { LeadCaptureFlowText } from "../core/lead-capture-text.js";
3
3
  import type { LeadSent } from "./use-lead-submit.js";
4
4
  import type { PartClassNames } from "./parts.js";
5
5
  export type PricedPart = "priced" | "pricedPrice" | "pricedNote";
6
6
  /**
7
7
  * After a priced lead (`estimate`, `fixed`): the price the server took it at,
8
- * then how the slot is set — by a call, until a booking provider is chosen;
9
- * a brand's `booking` (its own scheduling widget, say) stands in for that
10
- * promise. The lead is stored before any of this, so an abandoned booking is
8
+ * then how the slot is set — the place's booking (`LeadBooking`), or the
9
+ * brand's own `booking`; with neither, the promise of a call. The lead is stored before any of this, so an abandoned booking is
11
10
  * still a lead to call.
12
11
  */
13
12
  export declare function LeadCapturePriced(props: {
@@ -1,14 +1,14 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { cn } from "@evinvest/uikit";
3
+ import { Fragment } from "react";
3
4
  import { fillText, formatCents } from "../core/lead-capture-format.js";
4
5
  /**
5
6
  * After a priced lead (`estimate`, `fixed`): the price the server took it at,
6
- * then how the slot is set — by a call, until a booking provider is chosen;
7
- * a brand's `booking` (its own scheduling widget, say) stands in for that
8
- * promise. The lead is stored before any of this, so an abandoned booking is
7
+ * then how the slot is set — the place's booking (`LeadBooking`), or the
8
+ * brand's own `booking`; with neither, the promise of a call. The lead is stored before any of this, so an abandoned booking is
9
9
  * still a lead to call.
10
10
  */
11
11
  export function LeadCapturePriced(props) {
12
12
  const { sent, locale, text, classNames: c } = props;
13
- return (_jsxs("div", { className: cn("flex flex-col gap-3", c?.priced), children: [sent.cents !== undefined && _jsx("p", { className: cn("font-medium text-ink", c?.pricedPrice), children: fillText(text.sentPrice, { price: formatCents(sent.cents, locale) }) }), props.booking ?? _jsx("p", { className: cn("text-ink", c?.pricedNote), children: text.slotCallback })] }));
13
+ return (_jsxs("div", { className: cn("flex flex-col gap-3", c?.priced), children: [sent.cents !== undefined && _jsx("p", { className: cn("font-medium text-ink", c?.pricedPrice), children: fillText(text.sentPrice, { price: formatCents(sent.cents, locale) }) }), _jsx(Fragment, { children: props.booking ?? _jsx("p", { className: cn("text-ink", c?.pricedNote), children: text.slotCallback }) }, "booking")] }));
14
14
  }
@@ -7,5 +7,12 @@ import { experimentProps } from "../core/analytics.js";
7
7
  */
8
8
  export declare const AnalyticsSinkContext: import("react").Context<AnalyticsSink | null>;
9
9
  export declare const useAnalyticsSink: () => AnalyticsSink | null;
10
+ /**
11
+ * The `distinct_id` the boundary's sink sends, for a lead to carry to the
12
+ * panel. `null` outside a boundary or with no key: no event names it, so a
13
+ * lead has nothing to join.
14
+ */
15
+ export declare const AnalyticsIdContext: import("react").Context<string | null>;
16
+ export declare const useAnalyticsId: () => string | null;
10
17
  /** `data-experiment` / `data-variant` on an element or its ancestors, for an intent's event. */
11
18
  export declare function experimentOf(el: Element | null): ReturnType<typeof experimentProps>;
@@ -8,6 +8,13 @@ import { experimentProps } from "../core/analytics.js";
8
8
  */
9
9
  export const AnalyticsSinkContext = createContext(null);
10
10
  export const useAnalyticsSink = () => useContext(AnalyticsSinkContext);
11
+ /**
12
+ * The `distinct_id` the boundary's sink sends, for a lead to carry to the
13
+ * panel. `null` outside a boundary or with no key: no event names it, so a
14
+ * lead has nothing to join.
15
+ */
16
+ export const AnalyticsIdContext = createContext(null);
17
+ export const useAnalyticsId = () => useContext(AnalyticsIdContext);
11
18
  /** `data-experiment` / `data-variant` on an element or its ancestors, for an intent's event. */
12
19
  export function experimentOf(el) {
13
20
  const host = el?.closest("[data-experiment]");
@@ -0,0 +1,58 @@
1
+ import { type OpenBookingConfig } from "../core/booking/model.js";
2
+ /** What a provider may say about the slot it booked; all optional, never shown as is. */
3
+ export interface BookedSlot {
4
+ startAt?: string;
5
+ endAt?: string;
6
+ }
7
+ /** What the page hands an adapter when the visitor asks for a slot. */
8
+ export interface BookingContext {
9
+ /** The booking the card offers (`bookingOf(place, variant)`). */
10
+ config: OpenBookingConfig;
11
+ /** The lead's public reference — the panel's lead id, and the join key a provider carries back. */
12
+ leadRef: string;
13
+ name?: string | null | undefined;
14
+ phone?: string | null | undefined;
15
+ locale: string;
16
+ /** Called when the provider says on the page that the slot is booked. */
17
+ onBooked?: (slot?: BookedSlot) => void;
18
+ }
19
+ /**
20
+ * How a provider opens its booking — one adapter per provider, picked by the
21
+ * config's `provider`; a brand registers its own (`bookingAdapters`) and
22
+ * `LeadCapture` is not touched. `href` renders a plain link — opened by the
23
+ * browser in a new tab, so no popup blocker and nothing fetched before the
24
+ * click; `open` runs on the click instead (an embed that loads its script
25
+ * then). Neither may touch the network before the click. `prefillsPhone`:
26
+ * the provider's form gets the visitor's number, so the card need not ask
27
+ * them to type it again.
28
+ */
29
+ export interface BookingAdapter<P extends string = string> {
30
+ provider: P;
31
+ href?: (ctx: BookingContext) => string | null;
32
+ open?: (ctx: BookingContext) => void | Promise<void>;
33
+ prefillsPhone?: (ctx: BookingContext) => boolean;
34
+ }
35
+ /** `manual`: no page to open — the card promises the call and offers a preference. */
36
+ export declare const manualAdapter: BookingAdapter<"manual">;
37
+ /** `link`: the page with `ref=<leadRef>`. */
38
+ export declare const linkAdapter: BookingAdapter<"link">;
39
+ /** `google_calendar`: the schedule as is — it takes no parameter, so the visitor types the same number there. */
40
+ export declare const googleCalendarAdapter: BookingAdapter<"google_calendar">;
41
+ /** `cal_com`: the event with the name, the phone and `metadata[ref]`. */
42
+ export declare const calComAdapter: BookingAdapter<"cal_com">;
43
+ /** The hosted cloud serves its embed from `app.cal.com`; a self-hosted Cal.com from its own host. */
44
+ export declare function calComEmbedOrigin(url: string): string;
45
+ /**
46
+ * `cal_com` as Cal.com's modal on the page: its script loads only on the
47
+ * click, and its `bookingSuccessful` is `onBooked` — once per lead, however
48
+ * often the modal is opened and closed. Opt-in (`bookingEmbed`), and only
49
+ * once the visitor accepted the provider's cookies.
50
+ */
51
+ export declare const calComEmbedAdapter: BookingAdapter<"cal_com">;
52
+ /** Adapters by provider: the built-ins, and whatever a brand registers. */
53
+ export type BookingAdapters = Readonly<Record<string, BookingAdapter>>;
54
+ export declare const BOOKING_ADAPTERS: BookingAdapters;
55
+ /** The embeds `bookingEmbed` turns on. */
56
+ export declare const BOOKING_EMBEDS: BookingAdapters;
57
+ /** The built-ins with a brand's own over them, by provider. */
58
+ export declare function bookingAdapters(over?: BookingAdapters): BookingAdapters;