@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.
- package/README.md +210 -17
- package/dist/core/accept.d.ts +19 -1
- package/dist/core/accept.js +25 -2
- package/dist/core/analytics.d.ts +13 -3
- package/dist/core/analytics.js +17 -2
- package/dist/core/booking/index.d.ts +3 -0
- package/dist/core/booking/index.js +3 -0
- package/dist/core/booking/model.d.ts +106 -0
- package/dist/core/booking/model.js +66 -0
- package/dist/core/booking/url.d.ts +17 -0
- package/dist/core/booking/url.js +53 -0
- package/dist/core/booking/validate.d.ts +27 -0
- package/dist/core/booking/validate.js +219 -0
- package/dist/core/lead-capture-text.d.ts +26 -2
- package/dist/core/lead-capture-text.js +32 -2
- package/dist/core/lead.d.ts +33 -0
- package/dist/core/lead.js +43 -0
- package/dist/core/phone.d.ts +3 -1
- package/dist/core/phone.js +28 -2
- package/dist/core/place/live.d.ts +6 -2
- package/dist/core/place/live.js +21 -2
- package/dist/core/place/types.d.ts +6 -0
- package/dist/core/place/types.js +0 -13
- package/dist/core/routing.d.ts +3 -2
- package/dist/core/routing.js +4 -3
- package/dist/core/site.d.ts +8 -0
- package/dist/core/site.js +15 -0
- package/dist/index.d.ts +4 -3
- package/dist/index.js +4 -3
- package/dist/next/booking-route.d.ts +31 -0
- package/dist/next/booking-route.js +97 -0
- package/dist/next/confirm-route.d.ts +39 -0
- package/dist/next/confirm-route.js +118 -0
- package/dist/next/index.d.ts +2 -0
- package/dist/next/index.js +2 -0
- package/dist/next/quote-route.js +14 -5
- package/dist/react/AnalyticsBoundary.js +8 -4
- package/dist/react/LeadBooking.d.ts +35 -0
- package/dist/react/LeadBooking.js +47 -0
- package/dist/react/LeadBookingManual.d.ts +17 -0
- package/dist/react/LeadBookingManual.js +50 -0
- package/dist/react/LeadCapture.d.ts +28 -5
- package/dist/react/LeadCapture.js +22 -8
- package/dist/react/LeadCaptureFields.js +60 -21
- package/dist/react/LeadCapturePriced.d.ts +3 -4
- package/dist/react/LeadCapturePriced.js +4 -4
- package/dist/react/analytics-context.d.ts +7 -0
- package/dist/react/analytics-context.js +7 -0
- package/dist/react/booking-adapters.d.ts +58 -0
- package/dist/react/booking-adapters.js +104 -0
- package/dist/react/index.d.ts +5 -1
- package/dist/react/index.js +4 -1
- package/dist/react/use-booking.d.ts +20 -0
- package/dist/react/use-booking.js +32 -0
- package/dist/react/use-lead-error.d.ts +3 -7
- package/dist/react/use-lead-error.js +9 -6
- package/dist/react/use-lead-submit.d.ts +7 -1
- package/dist/react/use-lead-submit.js +13 -3
- package/dist/react/use-repriced.d.ts +18 -0
- package/dist/react/use-repriced.js +27 -0
- package/dist/server/experiments-declared.d.ts +106 -0
- package/dist/server/experiments-declared.js +124 -0
- package/dist/server/experiments-source.d.ts +49 -0
- package/dist/server/experiments-source.js +88 -0
- package/dist/server/index.d.ts +4 -2
- package/dist/server/index.js +2 -0
- package/dist/server/lead-webhook.d.ts +63 -0
- package/dist/server/lead-webhook.js +24 -1
- package/dist/server/notify.d.ts +20 -2
- package/dist/server/notify.js +6 -4
- package/dist/server/place-source.js +1 -1
- package/dist/server/webhook-outbox.d.ts +11 -1
- package/dist/server/webhook-outbox.js +32 -5
- package/dist/testing/lead-rules.js +15 -1
- package/package.json +1 -1
- package/template/app/quote/booking/route.ts +11 -0
- package/template/app/quote/confirm/route.ts +12 -0
- package/template/flake.nix +1 -1
- package/template/package.json +2 -2
- package/template/src/shared/config/places.ts +4 -0
- package/template/src/shared/config/site.ts +3 -0
- package/template/tests/e2e/flows.spec.ts +86 -9
- package/template/tests/e2e/mock-panel.mjs +18 -4
- 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
|
|
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
|
|
52
|
-
* booking
|
|
53
|
-
*
|
|
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
|
-
|
|
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 }, {
|
|
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:
|
|
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
|
|
23
|
-
//
|
|
24
|
-
|
|
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
|
|
62
|
-
// down between press and release, and the
|
|
63
|
-
// submit's
|
|
64
|
-
|
|
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
|
-
|
|
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
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
|
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 —
|
|
9
|
-
*
|
|
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 —
|
|
7
|
-
*
|
|
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;
|