@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.
Files changed (110) hide show
  1. package/README.md +237 -12
  2. package/dist/core/accept.d.ts +35 -2
  3. package/dist/core/accept.js +74 -5
  4. package/dist/core/analytics.d.ts +4 -1
  5. package/dist/core/analytics.js +13 -0
  6. package/dist/core/booking/index.d.ts +3 -0
  7. package/dist/core/booking/index.js +3 -0
  8. package/dist/core/booking/model.d.ts +106 -0
  9. package/dist/core/booking/model.js +66 -0
  10. package/dist/core/booking/url.d.ts +17 -0
  11. package/dist/core/booking/url.js +53 -0
  12. package/dist/core/booking/validate.d.ts +27 -0
  13. package/dist/core/booking/validate.js +219 -0
  14. package/dist/core/lead-capture-format.d.ts +6 -0
  15. package/dist/core/lead-capture-format.js +13 -0
  16. package/dist/core/lead-capture-text.d.ts +57 -2
  17. package/dist/core/lead-capture-text.js +75 -2
  18. package/dist/core/lead.d.ts +65 -3
  19. package/dist/core/lead.js +46 -2
  20. package/dist/core/phone.d.ts +3 -1
  21. package/dist/core/phone.js +28 -2
  22. package/dist/core/place/live.d.ts +6 -2
  23. package/dist/core/place/live.js +21 -2
  24. package/dist/core/place/types.d.ts +6 -0
  25. package/dist/core/place/types.js +0 -13
  26. package/dist/core/pricing/flow.d.ts +27 -0
  27. package/dist/core/pricing/flow.js +37 -0
  28. package/dist/core/pricing/index.d.ts +4 -0
  29. package/dist/core/pricing/index.js +4 -0
  30. package/dist/core/pricing/model.d.ts +129 -0
  31. package/dist/core/pricing/model.js +34 -0
  32. package/dist/core/pricing/price.d.ts +34 -0
  33. package/dist/core/pricing/price.js +105 -0
  34. package/dist/core/pricing/validate.d.ts +27 -0
  35. package/dist/core/pricing/validate.js +244 -0
  36. package/dist/core/routing.d.ts +3 -2
  37. package/dist/core/routing.js +4 -3
  38. package/dist/core/site.d.ts +15 -0
  39. package/dist/core/site.js +26 -0
  40. package/dist/index.d.ts +6 -4
  41. package/dist/index.js +6 -4
  42. package/dist/next/booking-route.d.ts +31 -0
  43. package/dist/next/booking-route.js +97 -0
  44. package/dist/next/confirm-route.d.ts +39 -0
  45. package/dist/next/confirm-route.js +118 -0
  46. package/dist/next/index.d.ts +2 -0
  47. package/dist/next/index.js +2 -0
  48. package/dist/next/quote-route.d.ts +8 -1
  49. package/dist/next/quote-route.js +17 -6
  50. package/dist/react/LeadBooking.d.ts +35 -0
  51. package/dist/react/LeadBooking.js +47 -0
  52. package/dist/react/LeadBookingManual.d.ts +17 -0
  53. package/dist/react/LeadBookingManual.js +50 -0
  54. package/dist/react/LeadCapture.d.ts +54 -3
  55. package/dist/react/LeadCapture.js +37 -10
  56. package/dist/react/LeadCaptureChannels.d.ts +14 -2
  57. package/dist/react/LeadCaptureChannels.js +13 -0
  58. package/dist/react/LeadCaptureEstimate.d.ts +46 -0
  59. package/dist/react/LeadCaptureEstimate.js +69 -0
  60. package/dist/react/LeadCaptureFields.js +60 -21
  61. package/dist/react/LeadCapturePriced.d.ts +18 -0
  62. package/dist/react/LeadCapturePriced.js +14 -0
  63. package/dist/react/booking-adapters.d.ts +58 -0
  64. package/dist/react/booking-adapters.js +104 -0
  65. package/dist/react/index.d.ts +5 -1
  66. package/dist/react/index.js +4 -1
  67. package/dist/react/use-booking.d.ts +20 -0
  68. package/dist/react/use-booking.js +32 -0
  69. package/dist/react/use-lead-error.d.ts +3 -7
  70. package/dist/react/use-lead-error.js +9 -6
  71. package/dist/react/use-lead-events.d.ts +2 -0
  72. package/dist/react/use-lead-events.js +10 -0
  73. package/dist/react/use-lead-submit.d.ts +14 -4
  74. package/dist/react/use-lead-submit.js +19 -9
  75. package/dist/react/use-repriced.d.ts +18 -0
  76. package/dist/react/use-repriced.js +27 -0
  77. package/dist/server/index.d.ts +3 -2
  78. package/dist/server/index.js +2 -1
  79. package/dist/server/lead-store-sqlite.js +31 -5
  80. package/dist/server/lead-webhook.d.ts +94 -0
  81. package/dist/server/lead-webhook.js +46 -2
  82. package/dist/server/notify.d.ts +20 -2
  83. package/dist/server/notify.js +6 -4
  84. package/dist/server/place-source.js +1 -1
  85. package/dist/server/pricing-source.d.ts +25 -0
  86. package/dist/server/pricing-source.js +48 -0
  87. package/dist/server/webhook-outbox.d.ts +11 -1
  88. package/dist/server/webhook-outbox.js +32 -5
  89. package/dist/testing/lead-rules.js +15 -1
  90. package/dist/testing/lead-store-contract.js +9 -2
  91. package/package.json +1 -1
  92. package/template/app/[locale]/[location]/page.tsx +4 -2
  93. package/template/app/quote/booking/route.ts +11 -0
  94. package/template/app/quote/confirm/route.ts +12 -0
  95. package/template/app/quote/route.ts +2 -1
  96. package/template/flake.nix +1 -1
  97. package/template/package.json +2 -2
  98. package/template/src/entities/content/model/en.ts +1 -1
  99. package/template/src/entities/content/model/fr.ts +1 -1
  100. package/template/src/shared/config/env.ts +4 -1
  101. package/template/src/shared/config/lead.ts +12 -2
  102. package/template/src/shared/config/places.ts +4 -0
  103. package/template/src/shared/config/pricing.ts +49 -0
  104. package/template/src/shared/config/site.ts +6 -0
  105. package/template/src/views/home/ui/PlaceHome.tsx +6 -3
  106. package/template/tests/bundle_budget.txt +3 -1
  107. package/template/tests/e2e/flows.spec.ts +183 -0
  108. package/template/tests/e2e/mock-panel.mjs +34 -0
  109. package/template/tests/e2e/playwright.config.ts +25 -9
  110. 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 { CallbackForm, ChannelLink, ExperimentFields } from "./LeadCaptureChannels.js";
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
- const [error, setError] = useLeadError(root, id);
43
- const { sent, onSubmit, busy, failure, retry } = useLeadSubmit(props.done !== undefined, { mobile: wire.mobile, name: props.name?.field }, { onRefused: (channel, field) => setError({ channel, field }), onFailed: (channel, why) => events.submitError(why, channel) });
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 && props.done !== undefined) {
86
- return (_jsx("div", { ref: root, ...rootProps, children: _jsx("div", { ref: doneRef, role: "status", tabIndex: -1, className: cn("outline-none", c?.done), children: typeof props.done === "function" ? props.done(sent) : props.done }) }));
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 gives back the field's gap rather than vanish: a live region
23
- // hidden while empty is not one some screen readers listen to.
24
- _jsx("div", { "aria-live": "polite", className: "empty:-mt-2", children: error ? (_jsx(FieldError, { id: id, className: cn("text-accent-error", c?.error), children: error })) : hint ? (_jsx(FieldDescription, { id: id, className: cn("text-accent-error", c?.hint), children: hint })) : null }));
22
+ // Empty, it leaves the flex flow (an absolute child takes no gap, whatever
23
+ // gap the brand gives the field) rather than vanish: a live region hidden
24
+ // while empty is not one some screen readers listen to.
25
+ _jsx("div", { "aria-live": "polite", className: "empty:absolute", children: error ? (_jsx(FieldError, { id: id, className: cn("text-accent-error", c?.error), children: error })) : hint ? (_jsx(FieldDescription, { id: id, className: cn("text-accent-error", c?.hint), children: hint })) : null }));
25
26
  }
26
27
  const required = (text, on) => (el) => (on && el.value.trim() === "" ? text : "");
27
28
  export function LocalityField(props) {
@@ -58,30 +59,68 @@ export function PhoneField(props) {
58
59
  });
59
60
  const error = props.error ?? validity.shown;
60
61
  const marked = Boolean(error) || doubtful;
61
- // A hint drawn on the blur a press of the submit causes pushes the button
62
- // down between press and release, and the click lands on nothing: the
63
- // submit's own check speaks instead.
64
- const submitting = useRef(false);
62
+ // A hint drawn on the blur a press causes pushes whatever is below the
63
+ // field down between press and release, and the tap lands on nothing (the
64
+ // consent box, the submit). So a press on this form's submit draws no hint
65
+ // — the submit's own check speaks instead — and a press anywhere else
66
+ // draws it only once the press is over.
67
+ const pressed = useRef(null);
65
68
  useEffect(() => {
66
- const form = validity.ref.current?.form;
67
- if (!form)
68
- return;
69
69
  const press = (event) => {
70
- submitting.current = event.target instanceof Element && event.target.closest("[type=submit]") !== null;
70
+ const own = validity.ref.current;
71
+ const target = event.target instanceof Element ? event.target : null;
72
+ const submit = target?.closest("[type=submit]");
73
+ pressed.current = target === own ? null : submit && own?.form?.contains(submit) ? "submit" : "other";
74
+ };
75
+ // The press lasts until its click, not its release: on touch the
76
+ // release comes before the blur it causes (down, up, mousedown, blur,
77
+ // click). A press that blurred nothing must not hold a later keyboard
78
+ // blur, so a key ends it too.
79
+ const release = () => {
80
+ pressed.current = null;
81
+ };
82
+ const ends = ["click", "pointercancel", "keydown"];
83
+ document.addEventListener("pointerdown", press, true);
84
+ for (const type of ends)
85
+ document.addEventListener(type, release, true);
86
+ return () => {
87
+ document.removeEventListener("pointerdown", press, true);
88
+ for (const type of ends)
89
+ document.removeEventListener(type, release, true);
71
90
  };
72
- form.addEventListener("pointerdown", press, true);
73
- return () => form.removeEventListener("pointerdown", press, true);
74
91
  }, [validity.ref]);
92
+ const judge = (value) => {
93
+ const bad = value.trim() !== "" && phoneProblem(value) !== null;
94
+ if (bad && !doubtful)
95
+ onSoftError();
96
+ setDoubtful(bad);
97
+ };
75
98
  return (_jsxs(Field, { ...(props.id ? { controlId: props.id } : {}), className: cn("flex flex-col gap-2", c?.field), children: [_jsx(FieldLabel, { className: c?.label, children: text.phoneLabel }), _jsx(Input, { ref: validity.ref, name: name, size: "lg", ...PHONE_INPUT_PROPS, enterKeyHint: "send", placeholder: text.phonePlaceholder, required: true, "data-lead-field": "phone", "aria-invalid": marked || undefined, "aria-describedby": marked ? messageId : undefined, onInvalid: validity.onInvalid, onBlur: e => {
76
- if (submitting.current) {
77
- submitting.current = false;
78
- return;
79
- }
80
99
  const value = e.currentTarget.value;
81
- const bad = value.trim() !== "" && phoneProblem(value) !== null;
82
- if (bad && !doubtful)
83
- onSoftError();
84
- setDoubtful(bad);
100
+ const press = pressed.current;
101
+ pressed.current = null;
102
+ if (press === "submit")
103
+ return;
104
+ if (press === null)
105
+ return judge(value);
106
+ // After the press's click, which lands where the press began; a
107
+ // cancelled press (a scroll) ends the wait too, and so does a
108
+ // release no click follows (dragged off), a moment later.
109
+ let timer;
110
+ const ends = ["click", "pointercancel"];
111
+ const over = () => {
112
+ for (const type of ends)
113
+ window.removeEventListener(type, over, true);
114
+ window.removeEventListener("pointerup", late, true);
115
+ clearTimeout(timer);
116
+ setTimeout(() => judge(value), 0);
117
+ };
118
+ const late = () => {
119
+ timer = setTimeout(over, 500);
120
+ };
121
+ for (const type of ends)
122
+ window.addEventListener(type, over, true);
123
+ window.addEventListener("pointerup", late, true);
85
124
  }, onChange: e => {
86
125
  validity.onChange(e);
87
126
  if (doubtful && phoneProblem(e.currentTarget.value) === null)
@@ -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
+ }
@@ -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 `LeadCapture` are client modules, each its own file
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";
@@ -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 `LeadCapture` are client modules, each its own file
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";