@evinvest/kitstart 0.10.0 → 0.11.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 +155 -17
- package/dist/core/accept.d.ts +12 -1
- package/dist/core/accept.js +15 -0
- package/dist/core/analytics.d.ts +3 -1
- package/dist/core/analytics.js +6 -0
- 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 +3 -2
- package/dist/index.js +3 -2
- 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/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/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 +6 -3
- package/dist/react/use-repriced.d.ts +18 -0
- package/dist/react/use-repriced.js +27 -0
- package/dist/server/index.d.ts +2 -2
- package/dist/server/lead-webhook.d.ts +46 -0
- package/dist/server/lead-webhook.js +17 -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
|
@@ -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
|
}
|
|
@@ -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;
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { isPageProvider } from "../core/booking/model.js";
|
|
2
|
+
import { bookingHref } from "../core/booking/url.js";
|
|
3
|
+
/** The built-in choice a context carries, or `null` when it is not one. */
|
|
4
|
+
function choiceOf(config) {
|
|
5
|
+
if (config.provider === "manual")
|
|
6
|
+
return { provider: "manual" };
|
|
7
|
+
return isPageProvider(config.provider) && config.url ? { provider: config.provider, url: config.url } : null;
|
|
8
|
+
}
|
|
9
|
+
const hrefOf = (ctx) => {
|
|
10
|
+
const choice = choiceOf(ctx.config);
|
|
11
|
+
return choice && bookingHref(choice, { leadRef: ctx.leadRef, name: ctx.name, phone: ctx.phone });
|
|
12
|
+
};
|
|
13
|
+
/** `manual`: no page to open — the card promises the call and offers a preference. */
|
|
14
|
+
export const manualAdapter = { provider: "manual" };
|
|
15
|
+
/** `link`: the page with `ref=<leadRef>`. */
|
|
16
|
+
export const linkAdapter = { provider: "link", href: hrefOf };
|
|
17
|
+
/** `google_calendar`: the schedule as is — it takes no parameter, so the visitor types the same number there. */
|
|
18
|
+
export const googleCalendarAdapter = { provider: "google_calendar", href: hrefOf };
|
|
19
|
+
/** `cal_com`: the event with the name, the phone and `metadata[ref]`. */
|
|
20
|
+
export const calComAdapter = { provider: "cal_com", href: hrefOf, prefillsPhone: () => true };
|
|
21
|
+
const isObject = (v) => typeof v === "object" && v !== null;
|
|
22
|
+
function addScript(src) {
|
|
23
|
+
const el = document.createElement("script");
|
|
24
|
+
el.src = src;
|
|
25
|
+
el.async = true;
|
|
26
|
+
document.head.appendChild(el);
|
|
27
|
+
}
|
|
28
|
+
/** Cal.com's own queue stub, as its embed snippet installs it; the script is added on the first call. */
|
|
29
|
+
function calQueue(w, script) {
|
|
30
|
+
if (w.Cal)
|
|
31
|
+
return w.Cal;
|
|
32
|
+
const cal = (...args) => {
|
|
33
|
+
if (!cal.loaded) {
|
|
34
|
+
cal.ns = {};
|
|
35
|
+
addScript(script);
|
|
36
|
+
cal.loaded = true;
|
|
37
|
+
}
|
|
38
|
+
(cal.q ??= []).push(args);
|
|
39
|
+
};
|
|
40
|
+
w.Cal = cal;
|
|
41
|
+
return cal;
|
|
42
|
+
}
|
|
43
|
+
/** The hosted cloud serves its embed from `app.cal.com`; a self-hosted Cal.com from its own host. */
|
|
44
|
+
export function calComEmbedOrigin(url) {
|
|
45
|
+
const u = new URL(url);
|
|
46
|
+
return u.hostname === "cal.com" ? "https://app.cal.com" : u.origin;
|
|
47
|
+
}
|
|
48
|
+
/** Per `Cal` instance — one per page — so the listeners are registered once however often the modal opens. */
|
|
49
|
+
const calPages = new WeakMap();
|
|
50
|
+
function calPage(cal, origin) {
|
|
51
|
+
const known = calPages.get(cal);
|
|
52
|
+
if (known)
|
|
53
|
+
return known;
|
|
54
|
+
const page = { current: null, booked: new Set() };
|
|
55
|
+
calPages.set(cal, page);
|
|
56
|
+
const booked = (e) => {
|
|
57
|
+
const ctx = page.current;
|
|
58
|
+
// Cal.com may fire both versions of the event for one booking.
|
|
59
|
+
if (!ctx || page.booked.has(ctx.leadRef))
|
|
60
|
+
return;
|
|
61
|
+
page.booked.add(ctx.leadRef);
|
|
62
|
+
const detail = isObject(e) ? e.detail : undefined;
|
|
63
|
+
const data = isObject(detail) && isObject(detail.data) ? detail.data : undefined;
|
|
64
|
+
const startAt = data?.startTime ?? data?.date;
|
|
65
|
+
const endAt = data?.endTime;
|
|
66
|
+
ctx.onBooked?.({ ...(typeof startAt === "string" ? { startAt } : {}), ...(typeof endAt === "string" ? { endAt } : {}) });
|
|
67
|
+
};
|
|
68
|
+
cal("init", { origin });
|
|
69
|
+
cal("on", { action: "bookingSuccessful", callback: booked });
|
|
70
|
+
cal("on", { action: "bookingSuccessfulV2", callback: booked });
|
|
71
|
+
return page;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* `cal_com` as Cal.com's modal on the page: its script loads only on the
|
|
75
|
+
* click, and its `bookingSuccessful` is `onBooked` — once per lead, however
|
|
76
|
+
* often the modal is opened and closed. Opt-in (`bookingEmbed`), and only
|
|
77
|
+
* once the visitor accepted the provider's cookies.
|
|
78
|
+
*/
|
|
79
|
+
export const calComEmbedAdapter = {
|
|
80
|
+
provider: "cal_com",
|
|
81
|
+
prefillsPhone: () => true,
|
|
82
|
+
open: ctx => {
|
|
83
|
+
const href = hrefOf(ctx);
|
|
84
|
+
if (href === null)
|
|
85
|
+
return;
|
|
86
|
+
const u = new URL(href);
|
|
87
|
+
const origin = calComEmbedOrigin(href);
|
|
88
|
+
const cal = calQueue(window, `${origin}/embed/embed.js`);
|
|
89
|
+
calPage(cal, origin).current = ctx;
|
|
90
|
+
cal("modal", { calLink: u.pathname.slice(1), config: Object.fromEntries(u.searchParams) });
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
export const BOOKING_ADAPTERS = {
|
|
94
|
+
manual: manualAdapter,
|
|
95
|
+
link: linkAdapter,
|
|
96
|
+
google_calendar: googleCalendarAdapter,
|
|
97
|
+
cal_com: calComAdapter,
|
|
98
|
+
};
|
|
99
|
+
/** The embeds `bookingEmbed` turns on. */
|
|
100
|
+
export const BOOKING_EMBEDS = { cal_com: calComEmbedAdapter };
|
|
101
|
+
/** The built-ins with a brand's own over them, by provider. */
|
|
102
|
+
export function bookingAdapters(over = {}) {
|
|
103
|
+
return { ...BOOKING_ADAPTERS, ...over };
|
|
104
|
+
}
|
package/dist/react/index.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `@evinvest/kitstart/react` — the structural widgets every landing shares,
|
|
3
3
|
* where behaviour matters more than look. Server components by default; only
|
|
4
|
-
* `MapFacade`, `AnalyticsBoundary`, `FormSelect` and `
|
|
4
|
+
* `MapFacade`, `AnalyticsBoundary`, `FormSelect`, `LeadCapture` and `LeadBooking` are client modules, each its own file
|
|
5
5
|
* with its own `"use client"`, so a page pays for what it renders.
|
|
6
6
|
*
|
|
7
7
|
* Styled with the kit's token roles only (`text-ink`, `border-border`,
|
|
@@ -18,6 +18,10 @@ export { Faq, type FaqPart, type FaqProps } from "./Faq.js";
|
|
|
18
18
|
export { FormSelect, type FormSelectOption, type FormSelectPart, type FormSelectProps } from "./FormSelect.js";
|
|
19
19
|
export { LangSwitch, withLang, type LangSwitchProps } from "./LangSwitch.js";
|
|
20
20
|
export { LeadCapture, type LeadCapturePart, type LeadCaptureProps } from "./LeadCapture.js";
|
|
21
|
+
export { LeadBooking, type LeadBookingProps } from "./LeadBooking.js";
|
|
22
|
+
export type { BookingPart } from "./LeadBookingManual.js";
|
|
23
|
+
export { BOOKING_ADAPTERS, BOOKING_EMBEDS, bookingAdapters, calComAdapter, calComEmbedAdapter, calComEmbedOrigin, googleCalendarAdapter, linkAdapter, manualAdapter, type BookedSlot, type BookingAdapter, type BookingAdapters, type BookingContext, } from "./booking-adapters.js";
|
|
24
|
+
export { BOOKING_ACTION } from "./use-booking.js";
|
|
21
25
|
export type { LeadSent } from "./use-lead-submit.js";
|
|
22
26
|
export type { LeadCaptureLayout } from "./LeadCaptureNeed.js";
|
|
23
27
|
export { MapFacade, type MapFacadePart, type MapFacadeProps } from "./MapFacade.js";
|
package/dist/react/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `@evinvest/kitstart/react` — the structural widgets every landing shares,
|
|
3
3
|
* where behaviour matters more than look. Server components by default; only
|
|
4
|
-
* `MapFacade`, `AnalyticsBoundary`, `FormSelect` and `
|
|
4
|
+
* `MapFacade`, `AnalyticsBoundary`, `FormSelect`, `LeadCapture` and `LeadBooking` are client modules, each its own file
|
|
5
5
|
* with its own `"use client"`, so a page pays for what it renders.
|
|
6
6
|
*
|
|
7
7
|
* Styled with the kit's token roles only (`text-ink`, `border-border`,
|
|
@@ -18,6 +18,9 @@ export { Faq } from "./Faq.js";
|
|
|
18
18
|
export { FormSelect } from "./FormSelect.js";
|
|
19
19
|
export { LangSwitch, withLang } from "./LangSwitch.js";
|
|
20
20
|
export { LeadCapture } from "./LeadCapture.js";
|
|
21
|
+
export { LeadBooking } from "./LeadBooking.js";
|
|
22
|
+
export { BOOKING_ADAPTERS, BOOKING_EMBEDS, bookingAdapters, calComAdapter, calComEmbedAdapter, calComEmbedOrigin, googleCalendarAdapter, linkAdapter, manualAdapter, } from "./booking-adapters.js";
|
|
23
|
+
export { BOOKING_ACTION } from "./use-booking.js";
|
|
21
24
|
export { MapFacade } from "./MapFacade.js";
|
|
22
25
|
export { PlaceDirectory } from "./PlaceDirectory.js";
|
|
23
26
|
export { PHONE_INPUT_PROPS, QuoteFormShell } from "./QuoteFormShell.js";
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { BookingRequestedProperties } from "../core/booking/model.js";
|
|
2
|
+
/** A request as the page posts it; a provider outside the closed set is the server's to refuse. */
|
|
3
|
+
export type PostedBooking = Omit<BookingRequestedProperties, "provider"> & {
|
|
4
|
+
provider: string;
|
|
5
|
+
};
|
|
6
|
+
/** Where `LeadCapture` posts a booking request: `bookingRoute`, beside the quote route. */
|
|
7
|
+
export declare const BOOKING_ACTION = "/quote/booking";
|
|
8
|
+
export interface BookingReport {
|
|
9
|
+
/** The visitor opened the booking (or sent a `manual` preference): the event, and `booking.requested@1` to the site. */
|
|
10
|
+
requested(properties: PostedBooking): Promise<boolean>;
|
|
11
|
+
/** The provider said the slot is booked. */
|
|
12
|
+
booked(): void;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* The page's side of a booking: `lead_booking_open` / `lead_booking_done`
|
|
16
|
+
* (the provider only, never the URL), and the request to the site's
|
|
17
|
+
* `bookingRoute`, which the panel hears as `booking.requested@1`. The post is
|
|
18
|
+
* first-party, and the page stays: a provider's page opens in a new tab.
|
|
19
|
+
*/
|
|
20
|
+
export declare function useBooking(provider: string, submission: string, formId: string, action?: string): BookingReport;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { useCallback, useMemo } from "react";
|
|
3
|
+
import { EVENTS } from "../core/analytics.js";
|
|
4
|
+
import { useAnalyticsSink } from "./analytics-context.js";
|
|
5
|
+
/** Where `LeadCapture` posts a booking request: `bookingRoute`, beside the quote route. */
|
|
6
|
+
export const BOOKING_ACTION = "/quote/booking";
|
|
7
|
+
/**
|
|
8
|
+
* The page's side of a booking: `lead_booking_open` / `lead_booking_done`
|
|
9
|
+
* (the provider only, never the URL), and the request to the site's
|
|
10
|
+
* `bookingRoute`, which the panel hears as `booking.requested@1`. The post is
|
|
11
|
+
* first-party, and the page stays: a provider's page opens in a new tab.
|
|
12
|
+
*/
|
|
13
|
+
export function useBooking(provider, submission, formId, action = BOOKING_ACTION) {
|
|
14
|
+
const sink = useAnalyticsSink();
|
|
15
|
+
const props = useMemo(() => ({ provider, form_id: formId }), [provider, formId]);
|
|
16
|
+
const requested = useCallback(async (properties) => {
|
|
17
|
+
sink?.capture(EVENTS.bookingOpen, props);
|
|
18
|
+
try {
|
|
19
|
+
const res = await fetch(action, {
|
|
20
|
+
method: "POST",
|
|
21
|
+
headers: { "Content-Type": "application/json" },
|
|
22
|
+
body: JSON.stringify({ submission, ...properties }),
|
|
23
|
+
});
|
|
24
|
+
return res.ok;
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
}, [sink, props, action, submission]);
|
|
30
|
+
const booked = useCallback(() => sink?.capture(EVENTS.bookingDone, props), [sink, props]);
|
|
31
|
+
return { requested, booked };
|
|
32
|
+
}
|
|
@@ -1,11 +1,7 @@
|
|
|
1
1
|
import { type RefObject } from "react";
|
|
2
|
-
import { type LeadChannel } from "../core/lead.js";
|
|
2
|
+
import { type LeadChannel, type LeadError } from "../core/lead.js";
|
|
3
3
|
import type { LeadCaptureText } from "../core/lead-capture-text.js";
|
|
4
|
-
|
|
5
|
-
export interface LeadError {
|
|
6
|
-
channel: LeadChannel;
|
|
7
|
-
field: string;
|
|
8
|
-
}
|
|
4
|
+
export type { LeadError };
|
|
9
5
|
/** The fields `LeadCapture` draws itself, which show a refusal under themselves. */
|
|
10
6
|
export declare const OWN_FIELDS: Readonly<Record<LeadChannel, readonly string[]>>;
|
|
11
7
|
/** The id of the message a refusal of a field the card does not draw is shown in, above the submit. */
|
|
@@ -21,4 +17,4 @@ export declare function errorText(field: string, text: LeadCaptureText): string;
|
|
|
21
17
|
* `aria-invalid` and pointed at the message above the submit. Fixing that
|
|
22
18
|
* control clears it.
|
|
23
19
|
*/
|
|
24
|
-
export declare function useLeadError(root: RefObject<HTMLElement | null>, id: string): [LeadError | null, (error: LeadError | null) => void];
|
|
20
|
+
export declare function useLeadError(root: RefObject<HTMLElement | null>, id: string, initial?: LeadError | null): [LeadError | null, (error: LeadError | null) => void];
|