@evinvest/kitstart 0.9.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 +237 -12
- package/dist/core/accept.d.ts +35 -2
- package/dist/core/accept.js +74 -5
- package/dist/core/analytics.d.ts +4 -1
- package/dist/core/analytics.js +13 -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-format.d.ts +6 -0
- package/dist/core/lead-capture-format.js +13 -0
- package/dist/core/lead-capture-text.d.ts +57 -2
- package/dist/core/lead-capture-text.js +75 -2
- package/dist/core/lead.d.ts +65 -3
- package/dist/core/lead.js +46 -2
- 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/pricing/flow.d.ts +27 -0
- package/dist/core/pricing/flow.js +37 -0
- package/dist/core/pricing/index.d.ts +4 -0
- package/dist/core/pricing/index.js +4 -0
- package/dist/core/pricing/model.d.ts +129 -0
- package/dist/core/pricing/model.js +34 -0
- package/dist/core/pricing/price.d.ts +34 -0
- package/dist/core/pricing/price.js +105 -0
- package/dist/core/pricing/validate.d.ts +27 -0
- package/dist/core/pricing/validate.js +244 -0
- package/dist/core/routing.d.ts +3 -2
- package/dist/core/routing.js +4 -3
- package/dist/core/site.d.ts +15 -0
- package/dist/core/site.js +26 -0
- package/dist/index.d.ts +6 -4
- package/dist/index.js +6 -4
- 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.d.ts +8 -1
- package/dist/next/quote-route.js +17 -6
- 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 +54 -3
- package/dist/react/LeadCapture.js +37 -10
- package/dist/react/LeadCaptureChannels.d.ts +14 -2
- package/dist/react/LeadCaptureChannels.js +13 -0
- package/dist/react/LeadCaptureEstimate.d.ts +46 -0
- package/dist/react/LeadCaptureEstimate.js +69 -0
- package/dist/react/LeadCaptureFields.js +60 -21
- package/dist/react/LeadCapturePriced.d.ts +18 -0
- package/dist/react/LeadCapturePriced.js +14 -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-events.d.ts +2 -0
- package/dist/react/use-lead-events.js +10 -0
- package/dist/react/use-lead-submit.d.ts +14 -4
- package/dist/react/use-lead-submit.js +19 -9
- package/dist/react/use-repriced.d.ts +18 -0
- package/dist/react/use-repriced.js +27 -0
- package/dist/server/index.d.ts +3 -2
- package/dist/server/index.js +2 -1
- package/dist/server/lead-store-sqlite.js +31 -5
- package/dist/server/lead-webhook.d.ts +94 -0
- package/dist/server/lead-webhook.js +46 -2
- 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/pricing-source.d.ts +25 -0
- package/dist/server/pricing-source.js +48 -0
- 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/dist/testing/lead-store-contract.js +9 -2
- package/package.json +1 -1
- package/template/app/[locale]/[location]/page.tsx +4 -2
- package/template/app/quote/booking/route.ts +11 -0
- package/template/app/quote/confirm/route.ts +12 -0
- package/template/app/quote/route.ts +2 -1
- package/template/flake.nix +1 -1
- package/template/package.json +2 -2
- package/template/src/entities/content/model/en.ts +1 -1
- package/template/src/entities/content/model/fr.ts +1 -1
- package/template/src/shared/config/env.ts +4 -1
- package/template/src/shared/config/lead.ts +12 -2
- package/template/src/shared/config/places.ts +4 -0
- package/template/src/shared/config/pricing.ts +49 -0
- package/template/src/shared/config/site.ts +6 -0
- package/template/src/views/home/ui/PlaceHome.tsx +6 -3
- package/template/tests/bundle_budget.txt +3 -1
- package/template/tests/e2e/flows.spec.ts +183 -0
- package/template/tests/e2e/mock-panel.mjs +34 -0
- package/template/tests/e2e/playwright.config.ts +25 -9
- package/template/tests/e2e/quote-form.spec.ts +2 -2
|
@@ -1,11 +1,19 @@
|
|
|
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";
|
|
9
|
+
import { flowTextOf } from "../core/lead-capture-text.js";
|
|
10
|
+
import { bookingOf } from "../core/booking/model.js";
|
|
7
11
|
import { servedLocalities, storefrontOf } from "../core/place/types.js";
|
|
8
|
-
import {
|
|
12
|
+
import { flowOf } from "../core/pricing/flow.js";
|
|
13
|
+
import { LeadBooking } from "./LeadBooking.js";
|
|
14
|
+
import { LeadCapturePriced } from "./LeadCapturePriced.js";
|
|
15
|
+
import { CallbackForm, ChannelLink, ExperimentFields, PhotosAsk } from "./LeadCaptureChannels.js";
|
|
16
|
+
import { EstimateInputs, PriceBox, useEstimate } from "./LeadCaptureEstimate.js";
|
|
9
17
|
import { FormMessage, LocalityField, NameField, PhoneField } from "./LeadCaptureFields.js";
|
|
10
18
|
import { NeedField } from "./LeadCaptureNeed.js";
|
|
11
19
|
import { QuoteFormShell } from "./QuoteFormShell.js";
|
|
@@ -14,6 +22,7 @@ import { errorText, formMessageId, OWN_FIELDS, useLeadError } from "./use-lead-e
|
|
|
14
22
|
import { useLeadEvents } from "./use-lead-events.js";
|
|
15
23
|
import { FailureMessage, SubmitButton } from "./LeadCaptureSubmit.js";
|
|
16
24
|
import { useLeadSubmit } from "./use-lead-submit.js";
|
|
25
|
+
import { useRepriced } from "./use-repriced.js";
|
|
17
26
|
/**
|
|
18
27
|
* What the locality field is offered. A storefront serving its own town knows
|
|
19
28
|
* that town's postcode — what the field asks — so it offers that, not the
|
|
@@ -39,12 +48,29 @@ export function LeadCapture(props) {
|
|
|
39
48
|
const [need, setNeed] = useNeed(props.need, needs.map(n => n.value), () => setEditing(false));
|
|
40
49
|
const events = useLeadEvents(root, { formId, layout, experiment });
|
|
41
50
|
useOpenOnHash(`${id}-callback`);
|
|
42
|
-
|
|
43
|
-
const
|
|
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);
|
|
54
|
+
const flowText = flowTextOf(text, locale);
|
|
55
|
+
// `single` shows its select at the first need until one is picked: that is the need on screen.
|
|
56
|
+
const shownNeed = need ?? (layout === "single" ? needs[0]?.value : undefined);
|
|
57
|
+
const model = props.pricing ?? null;
|
|
58
|
+
const flow = flowOf(props.flows, model, shownNeed);
|
|
59
|
+
const estimate = useEstimate(model, shownNeed, flow, events.estimateShown);
|
|
60
|
+
const repriced = useRepriced(estimate.price, shownNeed, flowText, locale);
|
|
61
|
+
// A priced lead stays in the card: its price is confirmed there, and the slot promised.
|
|
62
|
+
const staysInCard = (ch) => props.done !== undefined || (ch === "form" && flow !== "quote");
|
|
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
|
+
});
|
|
44
70
|
// The form's refusal by where it shows: under the field the card draws, else above the submit.
|
|
45
71
|
const formError = error?.channel === "form" ? error.field : null;
|
|
46
72
|
const at = (field) => (formError === field ? errorText(field, text) : null);
|
|
47
|
-
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;
|
|
48
74
|
const doneRef = useRef(null);
|
|
49
75
|
// The form the focus was in is gone: the news takes it, and is read out.
|
|
50
76
|
useEffect(() => doneRef.current?.focus(), [sent]);
|
|
@@ -76,17 +102,18 @@ export function LeadCapture(props) {
|
|
|
76
102
|
setNeed(value);
|
|
77
103
|
setEditing(true);
|
|
78
104
|
};
|
|
79
|
-
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));
|
|
80
106
|
// `qualify-first` shows the contact step as soon as a need's radio is
|
|
81
107
|
// checked (`:has`) — without a script, and inside the tap with one.
|
|
82
108
|
const contactShown = layout === "single" || (need !== undefined && !editing);
|
|
83
109
|
const contactClass = contactShown ? "flex" : "hidden group-has-[[data-need-option]:checked]/lead:flex";
|
|
84
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 };
|
|
85
|
-
if (sent &&
|
|
86
|
-
|
|
111
|
+
if (sent && staysInCard(sent.channel)) {
|
|
112
|
+
const done = typeof props.done === "function" ? props.done(sent) : props.done;
|
|
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 }))] }) }));
|
|
87
114
|
}
|
|
88
|
-
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: () => {
|
|
89
116
|
setEditing(true);
|
|
90
117
|
events.step("need");
|
|
91
|
-
}, classNames: c }), _jsxs("div", { className: cn("flex-col gap-5", contactClass, c?.contact), children: [_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: text.submit, 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)] }))] }));
|
|
92
119
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { type CaptureChannel } from "../core/channels.js";
|
|
2
|
-
import type { LeadCaptureText } from "../core/lead-capture-text.js";
|
|
2
|
+
import type { LeadCaptureFlowText, LeadCaptureText } from "../core/lead-capture-text.js";
|
|
3
3
|
import type { FormEventHandler } from "react";
|
|
4
4
|
import { type FieldPart } from "./LeadCaptureFields.js";
|
|
5
5
|
import type { SendFailure } from "./use-lead-submit.js";
|
|
@@ -8,7 +8,7 @@ import { type PartClassNames } from "./parts.js";
|
|
|
8
8
|
* `channel` and `primary` dress every way out, the callback's summary
|
|
9
9
|
* included; the `callback*` parts are the callback's own, after them.
|
|
10
10
|
*/
|
|
11
|
-
export type ChannelPart = "channel" | "primary" | "callback" | "callbackSummary" | "callbackForm" | "callbackLede" | "callbackSubmit" | "consent";
|
|
11
|
+
export type ChannelPart = "channel" | "primary" | "callback" | "callbackSummary" | "callbackForm" | "callbackLede" | "callbackSubmit" | "consent" | "photos";
|
|
12
12
|
export interface Experiment {
|
|
13
13
|
name: string;
|
|
14
14
|
variant: string;
|
|
@@ -75,3 +75,15 @@ export declare function CallbackForm(props: {
|
|
|
75
75
|
onSoftError: () => void;
|
|
76
76
|
classNames?: PartClassNames<ChannelPart | FieldPart> | undefined;
|
|
77
77
|
}): import("react").JSX.Element;
|
|
78
|
+
/**
|
|
79
|
+
* A `quote` need priced from photos (a deep clean, after-works): the photos
|
|
80
|
+
* go by WhatsApp, with the need already said — only when the place has
|
|
81
|
+
* WhatsApp; the callback stays where it is.
|
|
82
|
+
*/
|
|
83
|
+
export declare function PhotosAsk(props: {
|
|
84
|
+
whatsapp: string | null;
|
|
85
|
+
needLabel: string;
|
|
86
|
+
text: Pick<LeadCaptureFlowText, "photosTitle" | "photosLede" | "photosCta" | "photosMessage">;
|
|
87
|
+
experiment: Experiment | undefined;
|
|
88
|
+
className?: string | undefined;
|
|
89
|
+
}): import("react").JSX.Element | null;
|
|
@@ -3,6 +3,7 @@ import { Button, buttonVariants, cn } from "@evinvest/uikit";
|
|
|
3
3
|
import { EXPERIMENT_FIELD, VARIANT_FIELD } from "../core/accept.js";
|
|
4
4
|
import { channelHref } from "../core/channels.js";
|
|
5
5
|
import { CHANNEL_FIELD } from "../core/lead.js";
|
|
6
|
+
import { fillText } from "../core/lead-capture-format.js";
|
|
6
7
|
import { ConsentField, FormMessage, PhoneField } from "./LeadCaptureFields.js";
|
|
7
8
|
import { FailureMessage, SubmitButton } from "./LeadCaptureSubmit.js";
|
|
8
9
|
import { partWithLeading } from "./parts.js";
|
|
@@ -46,3 +47,15 @@ export function CallbackForm(props) {
|
|
|
46
47
|
const at = (field) => (error?.field === field ? error.text : null);
|
|
47
48
|
return (_jsxs("details", { id: id, open: open, className: cn("w-full", c?.callback), children: [_jsx("summary", { "data-intent": "callback", className: partWithLeading(buttonVariants({ variant: primary ? "primary" : "outline", size: "touch", className: "flex w-full list-none [&::-webkit-details-marker]:hidden" }), CHANNEL_LEADING, cn(c?.channel, primary && c?.primary, c?.callbackSummary) || undefined), children: text.callback }), _jsxs(QuoteFormShell, { id: `${id}-form`, placeSlug: placeSlug, locale: locale, renderedAt: renderedAt, honeypotLabel: text.honeypotLabel, formId: formId, card: props.card, onSubmit: props.onSubmit, className: cn("mt-4 gap-4", c?.callbackForm), children: [_jsx("input", { type: "hidden", name: CHANNEL_FIELD, value: "callback" }), subject && _jsx("input", { type: "hidden", name: subject.name, value: subject.value }), _jsx(ExperimentFields, { experiment: experiment }), _jsx("p", { className: cn("text-ink-soft", c?.callbackLede), children: opening ?? text.callbackLede }), _jsx(PhoneField, { id: `${id}-phone`, name: mobileName, text: text, error: at("phone"), onSoftError: props.onSoftError, classNames: c }), _jsx(ConsentField, { sentence: text.callbackConsent, requiredText: text.consentRequired, error: at("consent"), className: c?.consent, classNames: c }), _jsx(FormMessage, { id: `${id}-error`, error: error && error.field !== "phone" && error.field !== "consent" ? error.text : null, className: c?.error }), _jsx(FailureMessage, { failure: props.failure, text: text, onRetry: props.onRetry, className: c?.error }), _jsx(SubmitButton, { busy: props.busy, label: text.callbackSubmit, sending: text.sending, className: c?.callbackSubmit })] })] }));
|
|
48
49
|
}
|
|
50
|
+
/**
|
|
51
|
+
* A `quote` need priced from photos (a deep clean, after-works): the photos
|
|
52
|
+
* go by WhatsApp, with the need already said — only when the place has
|
|
53
|
+
* WhatsApp; the callback stays where it is.
|
|
54
|
+
*/
|
|
55
|
+
export function PhotosAsk(props) {
|
|
56
|
+
const { whatsapp, text, experiment } = props;
|
|
57
|
+
const href = whatsapp ? channelHref("whatsapp", { phone: null, whatsapp }, fillText(text.photosMessage, { need: props.needLabel })) : null;
|
|
58
|
+
if (!href)
|
|
59
|
+
return null;
|
|
60
|
+
return (_jsxs("div", { className: cn("flex flex-col gap-2 rounded-[var(--control-radius)] border border-border p-4", props.className), children: [_jsx("p", { className: "font-medium text-ink", children: text.photosTitle }), _jsx("p", { className: "text-sm text-ink-soft", children: text.photosLede }), _jsx(Button, { href: href, variant: "outline", size: "touch", className: "w-full", "data-experiment": experiment?.name, "data-variant": experiment?.variant, children: text.photosCta })] }));
|
|
61
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { LeadCaptureFlowText } from "../core/lead-capture-text.js";
|
|
2
|
+
import { type LeadFlow } from "../core/pricing/flow.js";
|
|
3
|
+
import type { Price, PricingModel } from "../core/pricing/model.js";
|
|
4
|
+
import type { PartClassNames } from "./parts.js";
|
|
5
|
+
export type EstimatePart = "estimate" | "estimateInput" | "estimateLegend" | "estimateOption" | "price" | "priceTotal" | "breakdown" | "priceNote";
|
|
6
|
+
/**
|
|
7
|
+
* The live price of the need on screen: its answers, as the visitor taps
|
|
8
|
+
* them, and the price `priceOf` makes of them — the same function the server
|
|
9
|
+
* stores the lead's price with. A full set of answers is reported once per
|
|
10
|
+
* band (`onShown`), never the price itself.
|
|
11
|
+
*/
|
|
12
|
+
export declare function useEstimate(model: PricingModel | null, need: string | undefined, flow: LeadFlow, onShown: (need: string, cents: number) => void): {
|
|
13
|
+
answers: Readonly<Record<string, string>>;
|
|
14
|
+
answer: (input: string, option: string) => void;
|
|
15
|
+
price: Price | null;
|
|
16
|
+
};
|
|
17
|
+
/**
|
|
18
|
+
* An estimate's questions, a tile per answer — touch-sized radios, so the
|
|
19
|
+
* answers post without a script too (the server prices them; the live price
|
|
20
|
+
* needs the script). Required once the script runs: a price needs every
|
|
21
|
+
* answer, and without the script an unanswered estimate is still a lead, a
|
|
22
|
+
* quote.
|
|
23
|
+
*/
|
|
24
|
+
export declare function EstimateInputs(props: {
|
|
25
|
+
model: PricingModel;
|
|
26
|
+
need: string;
|
|
27
|
+
locale: string;
|
|
28
|
+
answers: Readonly<Record<string, string>>;
|
|
29
|
+
onAnswer: (input: string, option: string) => void;
|
|
30
|
+
required: boolean;
|
|
31
|
+
classNames?: PartClassNames<EstimatePart> | undefined;
|
|
32
|
+
}): import("react").JSX.Element | null;
|
|
33
|
+
/**
|
|
34
|
+
* The price, read out as it changes (`aria-live`): an estimate's total and
|
|
35
|
+
* how it was reached, or — until every question is answered — what is
|
|
36
|
+
* missing; a fixed need's price, as is. Never "from": it is the price for
|
|
37
|
+
* these answers.
|
|
38
|
+
*/
|
|
39
|
+
export declare function PriceBox(props: {
|
|
40
|
+
model: PricingModel;
|
|
41
|
+
flow: Exclude<LeadFlow, "quote">;
|
|
42
|
+
price: Price | null;
|
|
43
|
+
locale: string;
|
|
44
|
+
text: LeadCaptureFlowText;
|
|
45
|
+
classNames?: PartClassNames<EstimatePart> | undefined;
|
|
46
|
+
}): import("react").JSX.Element;
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { cn } from "@evinvest/uikit";
|
|
4
|
+
import { useEffect, useMemo, useState } from "react";
|
|
5
|
+
import { formatCents } from "../core/lead-capture-format.js";
|
|
6
|
+
import { estimateField } from "../core/pricing/flow.js";
|
|
7
|
+
import { priceOf } from "../core/pricing/price.js";
|
|
8
|
+
import { labelOf } from "../core/pricing/validate.js";
|
|
9
|
+
/**
|
|
10
|
+
* The live price of the need on screen: its answers, as the visitor taps
|
|
11
|
+
* them, and the price `priceOf` makes of them — the same function the server
|
|
12
|
+
* stores the lead's price with. A full set of answers is reported once per
|
|
13
|
+
* band (`onShown`), never the price itself.
|
|
14
|
+
*/
|
|
15
|
+
export function useEstimate(model, need, flow, onShown) {
|
|
16
|
+
const [answers, setAnswers] = useState({});
|
|
17
|
+
const price = useMemo(() => (flow === "quote" || !model || need === undefined ? null : priceOf(model, need, flow === "estimate" ? answers : {})), [model, need, flow, answers]);
|
|
18
|
+
const cents = flow === "estimate" ? price?.cents : undefined;
|
|
19
|
+
useEffect(() => {
|
|
20
|
+
if (need !== undefined && cents !== undefined)
|
|
21
|
+
onShown(need, cents);
|
|
22
|
+
}, [need, cents, onShown]);
|
|
23
|
+
const answer = (input, option) => setAnswers(prev => ({ ...prev, [input]: option }));
|
|
24
|
+
return { answers, answer, price };
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* An estimate's questions, a tile per answer — touch-sized radios, so the
|
|
28
|
+
* answers post without a script too (the server prices them; the live price
|
|
29
|
+
* needs the script). Required once the script runs: a price needs every
|
|
30
|
+
* answer, and without the script an unanswered estimate is still a lead, a
|
|
31
|
+
* quote.
|
|
32
|
+
*/
|
|
33
|
+
export function EstimateInputs(props) {
|
|
34
|
+
const { model, need, locale, answers, classNames: c } = props;
|
|
35
|
+
const pricing = model.needs[need];
|
|
36
|
+
if (pricing?.kind !== "estimate")
|
|
37
|
+
return null;
|
|
38
|
+
const inputs = pricing.inputs.map(id => model.inputs.find(i => i.id === id)).filter((i) => i !== undefined);
|
|
39
|
+
return (_jsx("div", { className: cn("flex flex-col gap-5", c?.estimate), children: inputs.map(input => (_jsxs("fieldset", { className: cn("flex flex-col gap-2", c?.estimateInput), children: [_jsx("legend", { className: cn("mb-2 text-sm font-medium text-ink", c?.estimateLegend), children: labelOf(input.labels, locale) }), _jsx("div", { className: "grid grid-cols-2 gap-2 sm:grid-cols-3", children: input.options.map(option => (_jsxs("label", { className: "relative block cursor-pointer", children: [_jsx("input", { type: "radio", name: estimateField(input.id), value: option.id, required: props.required, checked: answers[input.id] === option.id, onChange: () => props.onAnswer(input.id, option.id), className: "peer absolute inset-0 opacity-0", "data-lead-field": estimateField(input.id) }), _jsx("span", { className: cn("flex h-full min-h-11 items-center rounded-[var(--control-radius)] border border-input px-3 py-2 text-ink transition-colors", "peer-checked:border-primary peer-checked:bg-hover peer-focus-visible:ring-[3px] peer-focus-visible:ring-ring/50", c?.estimateOption), children: labelOf(option.labels, locale) })] }, option.id))) })] }, input.id))) }));
|
|
40
|
+
}
|
|
41
|
+
function lineLabel(line, model, locale, text) {
|
|
42
|
+
switch (line.kind) {
|
|
43
|
+
case "base":
|
|
44
|
+
return text.priceBase;
|
|
45
|
+
case "rounding":
|
|
46
|
+
return text.priceRounding;
|
|
47
|
+
case "minimum":
|
|
48
|
+
return text.priceMinimum;
|
|
49
|
+
case "add":
|
|
50
|
+
case "multiply":
|
|
51
|
+
case "discount": {
|
|
52
|
+
// The answer names itself ("2 chambres", "Toutes les 2 semaines"); the question would only repeat it.
|
|
53
|
+
const option = model.inputs.find(i => i.id === line.input)?.options.find(o => o.id === line.option);
|
|
54
|
+
return option ? labelOf(option.labels, locale) : line.option;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* The price, read out as it changes (`aria-live`): an estimate's total and
|
|
60
|
+
* how it was reached, or — until every question is answered — what is
|
|
61
|
+
* missing; a fixed need's price, as is. Never "from": it is the price for
|
|
62
|
+
* these answers.
|
|
63
|
+
*/
|
|
64
|
+
export function PriceBox(props) {
|
|
65
|
+
const { model, flow, price, locale, text, classNames: c } = props;
|
|
66
|
+
// A line that changed nothing (an answer worth 0) is not worth reading out.
|
|
67
|
+
const lines = price?.breakdown.filter((line, i) => i === 0 || line.cents !== 0) ?? [];
|
|
68
|
+
return (_jsxs("div", { "aria-live": "polite", "aria-atomic": "true", className: cn("flex flex-col gap-2 rounded-[var(--control-radius)] border border-border bg-card p-4", c?.price), children: [_jsx("p", { className: "text-sm text-ink-soft", children: text.priceTitle }), price ? (_jsx("p", { className: cn("font-display text-3xl font-bold text-ink", c?.priceTotal), "data-price-cents": price.cents, children: formatCents(price.cents, locale) })) : (_jsx("p", { className: "text-ink", children: text.pricePending })), flow === "estimate" && lines.length > 1 && (_jsx("ul", { className: cn("flex flex-col gap-1 text-sm text-ink-soft", c?.breakdown), children: lines.map((line, i) => (_jsxs("li", { className: "flex justify-between gap-3", children: [_jsx("span", { children: lineLabel(line, model, locale, text) }), _jsx("span", { className: "tabular-nums", children: formatCents(line.cents, locale, i > 0) })] }, i))) })), _jsx("p", { className: cn("text-sm text-ink-soft", c?.priceNote), children: text.priceNote })] }));
|
|
69
|
+
}
|
|
@@ -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)
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { type ReactNode } from "react";
|
|
2
|
+
import type { LeadCaptureFlowText } from "../core/lead-capture-text.js";
|
|
3
|
+
import type { LeadSent } from "./use-lead-submit.js";
|
|
4
|
+
import type { PartClassNames } from "./parts.js";
|
|
5
|
+
export type PricedPart = "priced" | "pricedPrice" | "pricedNote";
|
|
6
|
+
/**
|
|
7
|
+
* After a priced lead (`estimate`, `fixed`): the price the server took it at,
|
|
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
|
|
10
|
+
* still a lead to call.
|
|
11
|
+
*/
|
|
12
|
+
export declare function LeadCapturePriced(props: {
|
|
13
|
+
sent: LeadSent;
|
|
14
|
+
booking: ReactNode;
|
|
15
|
+
locale: string;
|
|
16
|
+
text: LeadCaptureFlowText;
|
|
17
|
+
classNames?: PartClassNames<PricedPart> | undefined;
|
|
18
|
+
}): import("react").JSX.Element;
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { cn } from "@evinvest/uikit";
|
|
3
|
+
import { Fragment } from "react";
|
|
4
|
+
import { fillText, formatCents } from "../core/lead-capture-format.js";
|
|
5
|
+
/**
|
|
6
|
+
* After a priced lead (`estimate`, `fixed`): the price the server took it at,
|
|
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
|
+
* still a lead to call.
|
|
10
|
+
*/
|
|
11
|
+
export function LeadCapturePriced(props) {
|
|
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) }) }), _jsx(Fragment, { children: props.booking ?? _jsx("p", { className: cn("text-ink", c?.pricedNote), children: text.slotCallback }) }, "booking")] }));
|
|
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";
|