@evinvest/kitstart 0.17.0 → 0.19.1
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 +163 -20
- package/dist/core/accept.d.ts +14 -0
- package/dist/core/accept.js +21 -3
- package/dist/core/analytics.d.ts +11 -2
- package/dist/core/analytics.js +22 -0
- package/dist/core/channels.d.ts +12 -4
- package/dist/core/channels.js +11 -1
- package/dist/core/lead-capture-text.d.ts +6 -2
- package/dist/core/lead.d.ts +33 -10
- package/dist/core/lead.js +21 -8
- package/dist/core/messenger-message.d.ts +31 -0
- package/dist/core/messenger-message.js +33 -0
- package/dist/core/messenger-text.d.ts +104 -0
- package/dist/core/messenger-text.js +150 -0
- package/dist/core/messenger.d.ts +92 -0
- package/dist/core/messenger.js +55 -0
- package/dist/core/place/index.d.ts +1 -1
- package/dist/core/place/live.d.ts +5 -1
- package/dist/core/place/live.js +22 -1
- package/dist/core/place/types.d.ts +17 -1
- package/dist/core/pricing/index.d.ts +2 -1
- package/dist/core/pricing/index.js +2 -1
- package/dist/core/pricing/label.d.ts +7 -0
- package/dist/core/pricing/label.js +8 -0
- package/dist/core/pricing/validate.d.ts +1 -3
- package/dist/core/pricing/validate.js +0 -4
- package/dist/core/site.d.ts +8 -1
- package/dist/core/site.js +22 -1
- package/dist/index.d.ts +8 -5
- package/dist/index.js +7 -4
- package/dist/next/quote-route.js +4 -7
- package/dist/react/AnalyticsBoundary.js +3 -3
- package/dist/react/FormSelect.d.ts +5 -3
- package/dist/react/FormSelect.js +70 -24
- package/dist/react/FormSelectKit.d.ts +24 -0
- package/dist/react/FormSelectKit.js +28 -0
- package/dist/react/LeadCapture.d.ts +24 -1
- package/dist/react/LeadCapture.js +97 -12
- package/dist/react/LeadCaptureChannels.d.ts +7 -2
- package/dist/react/LeadCaptureChannels.js +2 -2
- package/dist/react/LeadCaptureEstimate.d.ts +6 -0
- package/dist/react/LeadCaptureEstimate.js +1 -1
- package/dist/react/LeadCaptureFields.d.ts +17 -0
- package/dist/react/LeadCaptureFields.js +42 -33
- package/dist/react/LeadCaptureMessenger.d.ts +41 -0
- package/dist/react/LeadCaptureMessenger.js +133 -0
- package/dist/react/LeadCapturePrice.d.ts +9 -3
- package/dist/react/LeadCapturePrice.js +25 -5
- package/dist/react/LeadCaptureSubmit.d.ts +3 -0
- package/dist/react/LeadCaptureSubmit.js +2 -2
- package/dist/react/LeadCaptureTiles.js +9 -4
- package/dist/react/analytics-context.d.ts +9 -3
- package/dist/react/analytics-context.js +7 -3
- package/dist/react/form-select-value.d.ts +3 -0
- package/dist/react/form-select-value.js +6 -6
- package/dist/react/index.d.ts +2 -0
- package/dist/react/index.js +1 -0
- package/dist/react/lazy-part.d.ts +33 -0
- package/dist/react/lazy-part.js +66 -0
- package/dist/react/messenger/action.d.ts +144 -0
- package/dist/react/messenger/action.js +301 -0
- package/dist/react/messenger/chip.d.ts +8 -0
- package/dist/react/messenger/chip.js +22 -0
- package/dist/react/messenger/fallback.d.ts +8 -0
- package/dist/react/messenger/fallback.js +11 -0
- package/dist/react/messenger/saga.d.ts +10 -0
- package/dist/react/messenger/saga.js +33 -0
- package/dist/react/messenger/segment.d.ts +8 -0
- package/dist/react/messenger/segment.js +15 -0
- package/dist/react/messenger/select.d.ts +10 -0
- package/dist/react/messenger/select.js +26 -0
- package/dist/react/messenger/sheet.d.ts +10 -0
- package/dist/react/messenger/sheet.js +57 -0
- package/dist/react/messenger/split.d.ts +9 -0
- package/dist/react/messenger/split.js +17 -0
- package/dist/react/messenger/swap.d.ts +10 -0
- package/dist/react/messenger/swap.js +18 -0
- package/dist/react/messenger/thanks.d.ts +10 -0
- package/dist/react/messenger/thanks.js +18 -0
- package/dist/react/messenger/tiles.d.ts +8 -0
- package/dist/react/messenger/tiles.js +20 -0
- package/dist/react/messenger/types.d.ts +98 -0
- package/dist/react/messenger/types.js +1 -0
- package/dist/react/messenger/urgency.d.ts +10 -0
- package/dist/react/messenger/urgency.js +29 -0
- package/dist/react/use-lead-error.d.ts +3 -3
- package/dist/react/use-lead-events.d.ts +21 -1
- package/dist/react/use-lead-events.js +9 -3
- package/dist/react/use-lead-submit.d.ts +20 -6
- package/dist/react/use-lead-submit.js +23 -1
- package/dist/server/index.d.ts +1 -1
- package/dist/server/lead-store-sqlite.js +11 -5
- package/dist/server/lead-webhook.d.ts +30 -5
- package/dist/server/lead-webhook.js +13 -7
- package/dist/server/notify.js +11 -5
- package/dist/testing/index.d.ts +1 -1
- package/dist/testing/index.js +1 -1
- package/dist/testing/landing-contract.js +6 -1
- package/dist/testing/lead-rules.d.ts +8 -0
- package/dist/testing/lead-rules.js +30 -1
- package/package.json +6 -3
- package/template/flake.nix +1 -1
- package/template/package.json +5 -5
|
@@ -2,15 +2,16 @@
|
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
3
3
|
import { Button, cn } from "@evinvest/uikit";
|
|
4
4
|
import { Fragment, useEffect, useRef, useState } from "react";
|
|
5
|
-
import { PRICE_CHANGED, SHOWN_CENTS_FIELD } from "../core/accept.js";
|
|
5
|
+
import { CHANNELS_FIELD, PRICE_CHANGED, SHOWN_CENTS_FIELD } from "../core/accept.js";
|
|
6
6
|
import { resolveChannels } from "../core/channels.js";
|
|
7
7
|
import { CHANNEL_FIELD, leadErrorFor } from "../core/lead.js";
|
|
8
|
-
import { fillText, openingText } from "../core/lead-capture-format.js";
|
|
8
|
+
import { fillText, formatCents, openingText } from "../core/lead-capture-format.js";
|
|
9
|
+
import { channelsAvailable, MESSAGE_REF_FIELD } from "../core/messenger.js";
|
|
9
10
|
import { flowTextOf } from "../core/lead-capture-text.js";
|
|
10
11
|
import { bookingOf } from "../core/booking/model.js";
|
|
11
12
|
import { servedLocalities, storefrontOf } from "../core/place/types.js";
|
|
12
13
|
import { ESTIMATE_UNKNOWN, estimateField, flowOf } from "../core/pricing/flow.js";
|
|
13
|
-
import { labelOf } from "../core/pricing/
|
|
14
|
+
import { labelOf } from "../core/pricing/label.js";
|
|
14
15
|
import { focusNext } from "./focus-next.js";
|
|
15
16
|
import { LeadBooking } from "./LeadBooking.js";
|
|
16
17
|
import { LeadCapturePriced } from "./LeadCapturePriced.js";
|
|
@@ -20,6 +21,7 @@ import { ConsentField, FormMessage, LocalityField, NameField, PhoneField } from
|
|
|
20
21
|
import { NeedField } from "./LeadCaptureNeed.js";
|
|
21
22
|
import { PriceBox, PriceCompact } from "./LeadCapturePrice.js";
|
|
22
23
|
import { IntroField, LeadSteps } from "./LeadCaptureSteps.js";
|
|
24
|
+
import { drawsAt, initialMode, MessengerSlot, messengerShownOf, NO_MESSENGERS, useMessageRef } from "./LeadCaptureMessenger.js";
|
|
23
25
|
import { stepOfField, stepsOf, useLeadSteps } from "./lead-steps.js";
|
|
24
26
|
import { QuoteFormShell } from "./QuoteFormShell.js";
|
|
25
27
|
import { useHeld, useHydrated, useNeed, useNow, useOpenOnHash, usePostcode } from "./use-lead-context.js";
|
|
@@ -64,7 +66,12 @@ export function LeadCapture(props) {
|
|
|
64
66
|
const postcode = usePostcode();
|
|
65
67
|
const [locality, setLocality] = useState(undefined);
|
|
66
68
|
const localityShown = locality ?? postcode ?? (served.length === 1 ? served[0] : undefined);
|
|
67
|
-
const
|
|
69
|
+
const facts = props.messengers ?? NO_MESSENGERS;
|
|
70
|
+
const shown = messengerShownOf(props.messenger, facts);
|
|
71
|
+
const channels = channelsAvailable(props.messengers);
|
|
72
|
+
const [mode, setMode] = useState(() => initialMode(props.messenger));
|
|
73
|
+
const { ref: messageRef, renew: renewRef, minted } = useMessageRef(props.refPrefix, shown !== null);
|
|
74
|
+
const events = useLeadEvents(root, { formId, layout, experiment, channels, messenger: shown ?? undefined });
|
|
68
75
|
useOpenOnHash(`${id}-callback`);
|
|
69
76
|
// Only this card's: a page may draw several, and the query names one.
|
|
70
77
|
const initial = leadErrorFor(props.initialError ?? null, id);
|
|
@@ -78,7 +85,8 @@ export function LeadCapture(props) {
|
|
|
78
85
|
const flow = estimate.flow;
|
|
79
86
|
const repriced = useRepriced(estimate.price, shownNeed, flowText, locale);
|
|
80
87
|
// A priced lead stays in the card: its price is confirmed there, and the slot promised.
|
|
81
|
-
|
|
88
|
+
// `thanks` offers its messengers in the success: the card stays.
|
|
89
|
+
const staysInCard = (ch) => props.done !== undefined || (ch === "form" && (flow !== "quote" || shown === "thanks"));
|
|
82
90
|
const { sent, onSubmit, busy, failure, retry } = useLeadSubmit(staysInCard, { mobile: wire.mobile, name: props.name?.field }, {
|
|
83
91
|
onRefused: (channel, field, cents) => {
|
|
84
92
|
repriced.refused(field, cents);
|
|
@@ -102,7 +110,9 @@ export function LeadCapture(props) {
|
|
|
102
110
|
const needLabel = needs.find(n => n.value === need)?.label;
|
|
103
111
|
const message = needLabel ? fillText(text.message, { need: needLabel }) : text.messageGeneric;
|
|
104
112
|
const [lead = "form", ...rest] = resolved.order;
|
|
105
|
-
|
|
113
|
+
// A variant owns WhatsApp — its link posts the lead first — and the owner's model has no text message.
|
|
114
|
+
const owned = (ch) => shown !== null && shown !== "fallback" && (ch === "whatsapp" || ch === "sms");
|
|
115
|
+
const links = rest.filter((ch) => ch !== "form" && ch !== "callback" && ch !== "telegram" && !owned(ch));
|
|
106
116
|
// The intro asked for a call back: the folded callback would be a second one.
|
|
107
117
|
const callbackElsewhere = !callbackPreset;
|
|
108
118
|
const asked = model && shownNeed !== undefined && wanted === "estimate" ? askedInputs(model, shownNeed, estimate.answers) : [];
|
|
@@ -179,11 +189,66 @@ export function LeadCapture(props) {
|
|
|
179
189
|
continueLocality();
|
|
180
190
|
}
|
|
181
191
|
};
|
|
182
|
-
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"), row: !primary && props.channelsDisplay === "row", icon: props.channelIcons?.callback, classNames: c }, ch)) : ch === "form" ? null : (_jsx(ChannelLink, { channel: ch, contact: contact, message: message, text: text, primary: primary, experiment: experiment, icon: props.channelIcons?.[ch], classNames: c }, ch));
|
|
183
|
-
|
|
192
|
+
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, channels: channels, 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"), row: !primary && props.channelsDisplay === "row", icon: props.channelIcons?.callback, classNames: c }, ch)) : ch === "form" || ch === "telegram" ? null : (_jsx(ChannelLink, { channel: ch, contact: contact, message: message, text: text, primary: primary, experiment: experiment, icon: props.channelIcons?.[ch], classNames: c }, ch));
|
|
193
|
+
// The estimate's answers as the message says them: the whole label, and the
|
|
194
|
+
// brand's words for the preview and the need's line (`previewLabels`, else
|
|
195
|
+
// `shortLabels`). «I don't know» is no answer; the timing question has a line of its own.
|
|
196
|
+
const answerOf = (input) => {
|
|
197
|
+
const option = input.options.find(o => o.id === estimate.answers[input.id]);
|
|
198
|
+
if (!option)
|
|
199
|
+
return null;
|
|
200
|
+
const label = labelOf(option.labels, locale);
|
|
201
|
+
const question = props.questions?.[input.id];
|
|
202
|
+
return { label, short: question?.previewLabels?.[option.id] ?? question?.shortLabels?.[option.id] ?? label };
|
|
203
|
+
};
|
|
204
|
+
const timingInput = asked.find(i => i.id === props.messengerTiming?.input);
|
|
205
|
+
const answerShorts = asked
|
|
206
|
+
.filter(i => i !== timingInput)
|
|
207
|
+
.map(i => answerOf(i)?.short)
|
|
208
|
+
.filter((short) => short !== undefined);
|
|
209
|
+
// A variant arranges the card's own phone field and submit; it never draws its own.
|
|
210
|
+
const kitOf = (at, variant, drawn) => ({
|
|
211
|
+
at,
|
|
212
|
+
variant,
|
|
213
|
+
shown: drawn,
|
|
214
|
+
facts,
|
|
215
|
+
id,
|
|
216
|
+
locale,
|
|
217
|
+
brand: props.brand ?? "",
|
|
218
|
+
text,
|
|
219
|
+
wire,
|
|
220
|
+
needs,
|
|
221
|
+
needLabel: needs.find(n => n.value === shownNeed)?.label ?? null,
|
|
222
|
+
priceText: flow !== "quote" && repriced.price ? formatCents(repriced.price.cents, locale) : null,
|
|
223
|
+
answers: answerShorts,
|
|
224
|
+
timing: timingInput ? answerOf(timingInput) : null,
|
|
225
|
+
mode,
|
|
226
|
+
setMode,
|
|
227
|
+
messageRef,
|
|
228
|
+
renewRef,
|
|
229
|
+
ready: minted,
|
|
230
|
+
phone: options => phoneOf(options),
|
|
231
|
+
// Inside a variant the submit is its main button, dressed as its messenger's.
|
|
232
|
+
// …with the phone's icon before its words: it is the call.
|
|
233
|
+
submit: (label, options) => submitOf(label, { ...options, className: cn(c?.messengerCta, options?.className), icon: props.channelIcons?.phone ?? props.channelIcons?.callback }),
|
|
234
|
+
title: text.title,
|
|
235
|
+
lede: text.lede,
|
|
236
|
+
events,
|
|
237
|
+
icons: props.channelIcons,
|
|
238
|
+
experiment,
|
|
239
|
+
classNames: c,
|
|
240
|
+
});
|
|
241
|
+
const slot = (at) => shown !== null && props.messenger && drawsAt(shown, at) && _jsx(MessengerSlot, { kit: kitOf(at, props.messenger, shown) }, `messenger-${at}`);
|
|
242
|
+
const rootProps = {
|
|
243
|
+
id,
|
|
244
|
+
className: cn("flex w-full flex-col gap-6", props.className, c?.root),
|
|
245
|
+
"data-experiment": experiment?.name,
|
|
246
|
+
"data-variant": experiment?.variant,
|
|
247
|
+
"data-channels-available": channels,
|
|
248
|
+
};
|
|
184
249
|
if (sent && staysInCard(sent.channel)) {
|
|
185
250
|
const done = typeof props.done === "function" ? props.done(sent) : props.done;
|
|
186
|
-
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 }))] }) }));
|
|
251
|
+
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 })), sent.channel === "form" && slot("done")] }) }));
|
|
187
252
|
}
|
|
188
253
|
// An arrow key in `steps`: chosen, and the screen stays on until the answer is given.
|
|
189
254
|
const stay = (choose) => (...args) => {
|
|
@@ -197,11 +262,28 @@ export function LeadCapture(props) {
|
|
|
197
262
|
const localityField = (_jsx(LocalityField
|
|
198
263
|
// Remounted with what the query knew: an untouched field takes it as typed.
|
|
199
264
|
, { name: wire.locality, label: text.localityLabel, servedLabel: text.servedLabel, served: served, placeholder: text.localityPlaceholder, required: props.locality !== "optional", optional: text.optional, requiredText: text.required, error: at("locality"), hydrated: hydrated, preset: postcode, classNames: c }, postcode ?? ""));
|
|
200
|
-
|
|
265
|
+
function phoneOf(o = {}) {
|
|
266
|
+
return (_jsxs(_Fragment, { children: [_jsx(PhoneField, { name: wire.mobile, text: text, error: at("phone"), onSoftError: () => events.fieldError("phone"), optional: o.optional, disabled: o.disabled, placeholder: o.placeholder, frame: o.frame, form: o.form, classNames: c }), _jsx(Fragment, { children: "after" in o ? o.after : props.afterPhone }, "afterPhone")] }));
|
|
267
|
+
}
|
|
268
|
+
// A variant's `contact` takes the phone's place, and brings its own button.
|
|
269
|
+
const contactSlot = slot("contact");
|
|
270
|
+
const phone = contactSlot || phoneOf();
|
|
201
271
|
const nameField = 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 }));
|
|
202
272
|
const mainBusy = busy === "form" || (callbackPreset && busy === "callback");
|
|
203
273
|
const mainFailure = failure && (failure.channel === "form" || callbackPreset) ? failure.failure : null;
|
|
204
|
-
|
|
274
|
+
function submitOf(label, o = {}) {
|
|
275
|
+
return (_jsx(SubmitButton, { busy: mainBusy, label: label ?? (callbackPreset ? text.callbackSubmit : flow === "quote" ? text.submit : flowText.bookSubmit), sending: text.sending, form: o.form, className: cn(callbackPreset ? cn(c?.submit, c?.callbackSubmit) : c?.submit, o.className), children: o.icon !== undefined && o.icon !== null && (_jsx("span", { "aria-hidden": "true", className: cn("flex shrink-0 items-center [&_svg]:size-5", c?.channelIcon), children: o.icon })) }));
|
|
276
|
+
}
|
|
277
|
+
/**
|
|
278
|
+
* Without a script a variant leads nowhere — its messenger links are inert
|
|
279
|
+
* until the reference is minted — so a browser that runs none gets the
|
|
280
|
+
* control's phone and submit (`select` draws the phone already). Never
|
|
281
|
+
* parsed where scripts run.
|
|
282
|
+
*/
|
|
283
|
+
function noScript() {
|
|
284
|
+
return (_jsxs("noscript", { children: [shown !== "select" && phoneOf({ after: null }), submitOf()] }));
|
|
285
|
+
}
|
|
286
|
+
const tail = (_jsxs(_Fragment, { children: [_jsx(FormMessage, { id: formMessageId(id, "form"), error: above, className: c?.error }), _jsx(FailureMessage, { failure: mainFailure, text: text, onRetry: retry, className: c?.error }), _jsxs("div", { className: cn("flex flex-col gap-3", c?.trust), children: [!contactSlot && submitOf(), contactSlot && noScript(), slot("afterSubmit"), _jsx(Fragment, { children: props.trust }, "trust")] }), opening && lead !== "callback" && _jsx("p", { className: cn("text-sm text-ink-soft", c?.opening), children: opening }), text.privacy !== "" && _jsx("p", { className: cn("text-sm text-ink-soft", c?.privacy), children: text.privacy })] }));
|
|
205
287
|
// `qualify-first` shows the contact step as soon as a need's radio is
|
|
206
288
|
// checked (`:has`) — without a script, and inside the tap with one.
|
|
207
289
|
const contactShown = layout === "single" || (need !== undefined && !editing);
|
|
@@ -258,7 +340,10 @@ export function LeadCapture(props) {
|
|
|
258
340
|
}
|
|
259
341
|
}
|
|
260
342
|
};
|
|
261
|
-
return (_jsxs("div", { ref: root, ...rootProps, children: [_jsx(Fragment, { children:
|
|
343
|
+
return (_jsxs("div", { ref: root, ...rootProps, children: [_jsx(Fragment, { children: slot("head") ||
|
|
344
|
+
(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 }), !drawsAt(shown, "afterHead") && _jsx("p", { className: cn("text-ink-soft", c?.lede), children: text.lede })] }))) }, "head"), slot("afterHead"), lead !== "form" && !owned(lead) && (lead !== "callback" || callbackElsewhere) && channel(lead, true), _jsxs(QuoteFormShell, { id: `${id}-form`, placeSlug: place.slug, locale: locale, renderedAt: renderedAt, honeypotLabel: text.honeypotLabel, formId: formId, card: id, onSubmit: onSubmit, onKeyDown: onKeyDown, data: steps ? { "data-lead-steps": "" } : undefined, className: cn("group/lead", c?.form), children: [_jsx(ExperimentFields, { experiment: experiment }), _jsx("input", { type: "hidden", name: CHANNELS_FIELD, value: channels }), messageRef && _jsx("input", { type: "hidden", name: MESSAGE_REF_FIELD, value: messageRef }), flow !== "quote" && repriced.shownCents !== undefined && _jsx("input", { type: "hidden", name: SHOWN_CENTS_FIELD, value: repriced.shownCents }), steps ? (_jsx(LeadSteps, { steps: stepIds.map(stepView), current: stepper.current, total: total, onBack: () => moveOn(stepper.back, null, false), onEdit: step => moveOn(() => stepper.edit(step), null, false), text: { stepProgress: flowText.stepProgress, stepProgressOpen: flowText.stepProgressOpen, stepBack: flowText.stepBack, change: text.needChange }, classNames: c })) : drawsAt(shown, "body") && mode === null ? (
|
|
345
|
+
// `saga`'s first screen: the channel, before anything else.
|
|
346
|
+
_jsxs(_Fragment, { children: [slot("body"), noScript()] })) : (_jsxs(_Fragment, { children: [slot("beforeNeed"), _jsx(NeedField, { display: needDisplay, name: wire.subject, needs: needs, need: need, editing: editing, hydrated: hydrated, label: text.needLabel, changeLabel: text.needChange, requiredText: text.needRequired, enterPicks: enterPicks, onPick: pick, onSelect: select, onEdit: () => {
|
|
262
347
|
setEditing(true);
|
|
263
348
|
events.step("need");
|
|
264
349
|
}, classNames: c }), _jsxs("div", { className: cn("flex-col gap-5", contactClass, c?.contact), children: [model && shownNeed !== undefined && wanted === "estimate" && _jsx(EstimateInputs, { model: model, need: shownNeed, questions: props.questions, ...estimateProps }), priceShown, photos, localityField, phone, nameField, _jsx(Fragment, { children: props.extras }, "extras"), tail] })] }))] }), (links.length > 0 || (rest.includes("callback") && callbackElsewhere)) &&
|
|
@@ -10,7 +10,7 @@ import { type PartClassNames } from "./parts.js";
|
|
|
10
10
|
*/
|
|
11
11
|
export type ChannelPart = "channel" | "primary" | "callback" | "callbackSummary" | "callbackForm" | "callbackLede" | "callbackSubmit" | "consent" | "photos" | "channelIcon";
|
|
12
12
|
/** A way out the brand may draw an icon for (`channelIcons`). */
|
|
13
|
-
export type ChannelIconKey = "phone" | "whatsapp" | "sms" | "callback";
|
|
13
|
+
export type ChannelIconKey = "phone" | "whatsapp" | "sms" | "callback" | "telegram";
|
|
14
14
|
export interface Experiment {
|
|
15
15
|
name: string;
|
|
16
16
|
variant: string;
|
|
@@ -19,6 +19,8 @@ export interface Experiment {
|
|
|
19
19
|
export declare function ExperimentFields({ experiment }: {
|
|
20
20
|
experiment: Experiment | undefined;
|
|
21
21
|
}): import("react").JSX.Element | null;
|
|
22
|
+
/** The links of the channel list; a bot is a `messenger` variant's. */
|
|
23
|
+
type LinkChannel = Exclude<CaptureChannel, "form" | "callback" | "telegram">;
|
|
22
24
|
/**
|
|
23
25
|
* A channel that leaves the page — `tel:`, `wa.me`, `sms:` — as a link the
|
|
24
26
|
* boundary counts: the tracker reads `tel:` and WhatsApp itself, `data-intent`
|
|
@@ -26,7 +28,7 @@ export declare function ExperimentFields({ experiment }: {
|
|
|
26
28
|
* because the tracker reads the link's own `data-*`.
|
|
27
29
|
*/
|
|
28
30
|
export declare function ChannelLink(props: {
|
|
29
|
-
channel:
|
|
31
|
+
channel: LinkChannel;
|
|
30
32
|
contact: {
|
|
31
33
|
phone: string | null;
|
|
32
34
|
whatsapp: string | null;
|
|
@@ -65,6 +67,8 @@ export declare function CallbackForm(props: {
|
|
|
65
67
|
opening: string | null;
|
|
66
68
|
text: LeadCaptureText;
|
|
67
69
|
experiment: Experiment | undefined;
|
|
70
|
+
/** The messengers the card offered (`channels_available`), for the submit's event. */
|
|
71
|
+
channels?: string | undefined;
|
|
68
72
|
onSubmit: FormEventHandler<HTMLFormElement> | undefined;
|
|
69
73
|
/** The server's refusal: the field and its words. */
|
|
70
74
|
error: {
|
|
@@ -93,3 +97,4 @@ export declare function PhotosAsk(props: {
|
|
|
93
97
|
experiment: Experiment | undefined;
|
|
94
98
|
className?: string | undefined;
|
|
95
99
|
}): import("react").JSX.Element | null;
|
|
100
|
+
export {};
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { Button, buttonVariants, cn } from "@evinvest/uikit";
|
|
3
|
-
import { EXPERIMENT_FIELD, VARIANT_FIELD } from "../core/accept.js";
|
|
3
|
+
import { CHANNELS_FIELD, 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
6
|
import { fillText } from "../core/lead-capture-format.js";
|
|
@@ -51,7 +51,7 @@ export function ChannelLink(props) {
|
|
|
51
51
|
export function CallbackForm(props) {
|
|
52
52
|
const { id, primary, open, formId, placeSlug, locale, renderedAt, mobileName, subject, opening, text, experiment, error, classNames: c } = props;
|
|
53
53
|
const at = (field) => (error?.field === field ? error.text : null);
|
|
54
|
-
return (_jsxs("details", { id: id, open: open, className: cn(props.row ? "min-w-0 flex-1 open:basis-full" : "w-full", c?.callback), children: [_jsxs("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: [_jsx(ChannelIcon, { icon: props.icon, className: c?.channelIcon }), 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 })] })] }));
|
|
54
|
+
return (_jsxs("details", { id: id, open: open, className: cn(props.row ? "min-w-0 flex-1 open:basis-full" : "w-full", c?.callback), children: [_jsxs("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: [_jsx(ChannelIcon, { icon: props.icon, className: c?.channelIcon }), 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 }), props.channels && _jsx("input", { type: "hidden", name: CHANNELS_FIELD, value: props.channels }), _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 })] })] }));
|
|
55
55
|
}
|
|
56
56
|
/**
|
|
57
57
|
* A `quote` need priced from photos (a deep clean, after-works): the photos
|
|
@@ -17,6 +17,12 @@ export interface EstimateQuestion {
|
|
|
17
17
|
badges?: Readonly<Record<string, string>> | undefined;
|
|
18
18
|
/** A shorter label per option id, drawn on a phone (under `sm`); the radio keeps the whole one. */
|
|
19
19
|
shortLabels?: Readonly<Record<string, string>> | undefined;
|
|
20
|
+
/**
|
|
21
|
+
* The answer as a `messenger` variant's message says it, per option id — in
|
|
22
|
+
* the need's line and the preview («2 ch.», «40–70 m²»), where a tile's own
|
|
23
|
+
* «2» says nothing. Over `shortLabels`, then the label.
|
|
24
|
+
*/
|
|
25
|
+
previewLabels?: Readonly<Record<string, string>> | undefined;
|
|
20
26
|
/**
|
|
21
27
|
* `steps` only: questions with the same number share a screen, in the
|
|
22
28
|
* need's order. It moves on by itself once every one is answered; until
|
|
@@ -5,7 +5,7 @@ import { useEffect, useMemo, useState } from "react";
|
|
|
5
5
|
import { formatCents } from "../core/lead-capture-format.js";
|
|
6
6
|
import { ESTIMATE_UNKNOWN, estimateField } from "../core/pricing/flow.js";
|
|
7
7
|
import { priceOf } from "../core/pricing/price.js";
|
|
8
|
-
import { labelOf } from "../core/pricing/
|
|
8
|
+
import { labelOf } from "../core/pricing/label.js";
|
|
9
9
|
import { TileGroup } from "./LeadCaptureTiles.js";
|
|
10
10
|
/**
|
|
11
11
|
* The screens an estimate's questions take in `steps`, in order: one each,
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type ReactNode } from "react";
|
|
1
2
|
import type { LeadCaptureText } from "../core/lead-capture-text.js";
|
|
2
3
|
import type { PartClassNames } from "./parts.js";
|
|
3
4
|
export type FieldPart = "field" | "label" | "control" | "hint" | "error" | "chips" | "chip";
|
|
@@ -24,6 +25,11 @@ export type PhoneText = Pick<LeadCaptureText, "phoneLabel" | "phoneHint" | "phon
|
|
|
24
25
|
* (`phoneProblem`): one it would refuse blocks the submit, in the page's
|
|
25
26
|
* words. Leaving the field with such a number shows the hint already — a
|
|
26
27
|
* hint, read out, not yet an error.
|
|
28
|
+
*
|
|
29
|
+
* A `messenger` variant asks it differently: `optional` (the chat answers;
|
|
30
|
+
* a number typed is still held to the rule), `disabled` (a bot needs none),
|
|
31
|
+
* in a box of the variant's with a channel picker beside it (`frame`), or in
|
|
32
|
+
* a drawer outside the form's element (`form`, the form's id).
|
|
27
33
|
*/
|
|
28
34
|
export declare function PhoneField(props: {
|
|
29
35
|
name: string;
|
|
@@ -33,6 +39,17 @@ export declare function PhoneField(props: {
|
|
|
33
39
|
/** The server's refusal of this field, in the page's words. */
|
|
34
40
|
error: string | null;
|
|
35
41
|
onSoftError: () => void;
|
|
42
|
+
optional?: boolean | undefined;
|
|
43
|
+
disabled?: boolean | undefined;
|
|
44
|
+
/** Over `text.phonePlaceholder`. */
|
|
45
|
+
placeholder?: string | undefined;
|
|
46
|
+
/**
|
|
47
|
+
* The variant's box around the input (the kit's `InputGroup` with a picker):
|
|
48
|
+
* the input then draws no box of its own. The variant's, so the control's
|
|
49
|
+
* page never loads the group.
|
|
50
|
+
*/
|
|
51
|
+
frame?: ((input: ReactNode) => ReactNode) | undefined;
|
|
52
|
+
form?: string | undefined;
|
|
36
53
|
classNames?: PartClassNames<FieldPart> | undefined;
|
|
37
54
|
}): import("react").JSX.Element;
|
|
38
55
|
export declare function NameField(props: {
|
|
@@ -48,14 +48,20 @@ export function LocalityField(props) {
|
|
|
48
48
|
* (`phoneProblem`): one it would refuse blocks the submit, in the page's
|
|
49
49
|
* words. Leaving the field with such a number shows the hint already — a
|
|
50
50
|
* hint, read out, not yet an error.
|
|
51
|
+
*
|
|
52
|
+
* A `messenger` variant asks it differently: `optional` (the chat answers;
|
|
53
|
+
* a number typed is still held to the rule), `disabled` (a bot needs none),
|
|
54
|
+
* in a box of the variant's with a channel picker beside it (`frame`), or in
|
|
55
|
+
* a drawer outside the form's element (`form`, the form's id).
|
|
51
56
|
*/
|
|
52
57
|
export function PhoneField(props) {
|
|
53
|
-
const { name, text, onSoftError, classNames: c } = props;
|
|
58
|
+
const { name, text, onSoftError, frame, classNames: c } = props;
|
|
59
|
+
const optional = props.optional === true || props.disabled === true;
|
|
54
60
|
const messageId = useId();
|
|
55
61
|
const [doubtful, setDoubtful] = useState(false);
|
|
56
62
|
const validity = useValidity(el => {
|
|
57
63
|
const problem = phoneProblem(el.value);
|
|
58
|
-
return problem === "required" ? text.required : problem === "invalid" ? text.phoneInvalid : "";
|
|
64
|
+
return problem === "required" ? (optional ? "" : text.required) : problem === "invalid" ? text.phoneInvalid : "";
|
|
59
65
|
});
|
|
60
66
|
const error = props.error ?? validity.shown;
|
|
61
67
|
const marked = Boolean(error) || doubtful;
|
|
@@ -95,37 +101,40 @@ export function PhoneField(props) {
|
|
|
95
101
|
onSoftError();
|
|
96
102
|
setDoubtful(bad);
|
|
97
103
|
};
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
104
|
+
const input = (_jsx(Input, { ref: validity.ref, name: name, size: "lg", ...PHONE_INPUT_PROPS, enterKeyHint: "send", placeholder: props.placeholder ?? text.phonePlaceholder, required: !optional, disabled: props.disabled, form: props.form, "data-lead-field": "phone", "aria-invalid": marked || undefined, "aria-describedby": marked ? messageId : undefined, onInvalid: validity.onInvalid, onBlur: e => {
|
|
105
|
+
const value = e.currentTarget.value;
|
|
106
|
+
const press = pressed.current;
|
|
107
|
+
pressed.current = null;
|
|
108
|
+
if (press === "submit")
|
|
109
|
+
return;
|
|
110
|
+
if (press === null)
|
|
111
|
+
return judge(value);
|
|
112
|
+
// After the press's click, which lands where the press began; a
|
|
113
|
+
// cancelled press (a scroll) ends the wait too, and so does a
|
|
114
|
+
// release no click follows (dragged off), a moment later.
|
|
115
|
+
let timer;
|
|
116
|
+
const ends = ["click", "pointercancel"];
|
|
117
|
+
const over = () => {
|
|
118
|
+
for (const type of ends)
|
|
119
|
+
window.removeEventListener(type, over, true);
|
|
120
|
+
window.removeEventListener("pointerup", late, true);
|
|
121
|
+
clearTimeout(timer);
|
|
122
|
+
setTimeout(() => judge(value), 0);
|
|
123
|
+
};
|
|
124
|
+
const late = () => {
|
|
125
|
+
timer = setTimeout(over, 500);
|
|
126
|
+
};
|
|
127
|
+
for (const type of ends)
|
|
128
|
+
window.addEventListener(type, over, true);
|
|
129
|
+
window.addEventListener("pointerup", late, true);
|
|
130
|
+
}, onChange: e => {
|
|
131
|
+
validity.onChange(e);
|
|
132
|
+
if (doubtful && phoneProblem(e.currentTarget.value) === null)
|
|
133
|
+
setDoubtful(false);
|
|
134
|
+
},
|
|
135
|
+
// In a frame the box is the frame's: the input loses its own border and ring.
|
|
136
|
+
className: frame ? "h-full flex-1 rounded-none border-0 bg-transparent shadow-none focus-visible:ring-0" : c?.control }));
|
|
137
|
+
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 }), frame ? frame(input) : input, _jsx(FieldMessage, { id: messageId, error: error, hint: doubtful ? text.phoneHint : null, classNames: c })] }));
|
|
129
138
|
}
|
|
130
139
|
export function NameField(props) {
|
|
131
140
|
const { name, label, placeholder, optional, classNames: c } = props;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { type MessengerFacts, type MessengerMode, type MessengerVariant } from "../core/messenger.js";
|
|
2
|
+
import type { MessengerAt, MessengerKit, MessengerShown } from "./messenger/types.js";
|
|
3
|
+
export declare const NO_MESSENGERS: MessengerFacts;
|
|
4
|
+
/**
|
|
5
|
+
* What a variant becomes on this place: itself while the place has WhatsApp;
|
|
6
|
+
* without it the control (Figma's «Выключено в панели») — with a way to the
|
|
7
|
+
* bot when there is one — except `urgency`, whose question is the lead's
|
|
8
|
+
* (both answers then ask the phone), and `thanks`, whose success keeps the
|
|
9
|
+
* bot. `null`: the control as it is.
|
|
10
|
+
*/
|
|
11
|
+
export declare function messengerShownOf(variant: MessengerVariant | undefined, facts: MessengerFacts): MessengerShown | null;
|
|
12
|
+
export declare function drawsAt(shown: MessengerShown | null, at: MessengerAt): boolean;
|
|
13
|
+
/** The channel a variant starts on: none until asked (`saga`, `urgency`), else WhatsApp. */
|
|
14
|
+
export declare function initialMode(variant: MessengerVariant | undefined): MessengerMode | null;
|
|
15
|
+
/**
|
|
16
|
+
* The card's `message_ref`, minted once the script runs — a server render
|
|
17
|
+
* cannot know it without the hydration seeing another — and again by
|
|
18
|
+
* `renew`, after a messenger lead is posted.
|
|
19
|
+
*/
|
|
20
|
+
export declare function useMessageRef(prefix: string | undefined, on: boolean): {
|
|
21
|
+
ref: string | null;
|
|
22
|
+
renew: () => void;
|
|
23
|
+
minted: boolean;
|
|
24
|
+
};
|
|
25
|
+
/**
|
|
26
|
+
* One variant's piece at one place in the card; nothing where it draws
|
|
27
|
+
* nothing.
|
|
28
|
+
*
|
|
29
|
+
* Drawn by the server, or hydrated, it has no boundary of its own. The server
|
|
30
|
+
* finds the variant loaded (every one is, as this module loads) and writes it
|
|
31
|
+
* into the page whole — a boundary would not do: React writes a large
|
|
32
|
+
* boundary's content apart from the shell and reveals it late, the card drawn
|
|
33
|
+
* without its contact block first. Hydrating, a variant whose chunk is still
|
|
34
|
+
* on its way holds the card's hydration — the server's markup stays as it is,
|
|
35
|
+
* and nothing in the card runs until the chunk is here. A piece first drawn
|
|
36
|
+
* after that (a step, the success) is the browser's own: it waits behind a
|
|
37
|
+
* boundary, its chunk loaded then.
|
|
38
|
+
*/
|
|
39
|
+
export declare function MessengerSlot({ kit }: {
|
|
40
|
+
kit: MessengerKit;
|
|
41
|
+
}): import("react").JSX.Element | null;
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
+
import { lazy, Suspense, useEffect, useState, useSyncExternalStore } from "react";
|
|
4
|
+
import { MESSAGE_REF_PREFIX, newMessageRef } from "../core/messenger.js";
|
|
5
|
+
/**
|
|
6
|
+
* The `messenger` variants, each its own chunk: a page pays for the one its
|
|
7
|
+
* arm draws, and the control for none. `LeadCapture` only says where.
|
|
8
|
+
*/
|
|
9
|
+
const LOAD = {
|
|
10
|
+
select: () => import("./messenger/select.js"),
|
|
11
|
+
segment: () => import("./messenger/segment.js"),
|
|
12
|
+
tiles: () => import("./messenger/tiles.js"),
|
|
13
|
+
thanks: () => import("./messenger/thanks.js"),
|
|
14
|
+
swap: () => import("./messenger/swap.js"),
|
|
15
|
+
saga: () => import("./messenger/saga.js"),
|
|
16
|
+
urgency: () => import("./messenger/urgency.js"),
|
|
17
|
+
sheet: () => import("./messenger/sheet.js"),
|
|
18
|
+
chip: () => import("./messenger/chip.js"),
|
|
19
|
+
split: () => import("./messenger/split.js"),
|
|
20
|
+
fallback: () => import("./messenger/fallback.js"),
|
|
21
|
+
};
|
|
22
|
+
const LOADING = new Map();
|
|
23
|
+
/** The variants whose chunk has arrived, by kind. */
|
|
24
|
+
const MODULES = new Map();
|
|
25
|
+
/** The variant's chunk, asked for once a page. */
|
|
26
|
+
function load(kind) {
|
|
27
|
+
const known = LOADING.get(kind);
|
|
28
|
+
if (known)
|
|
29
|
+
return known;
|
|
30
|
+
const loading = LOAD[kind]().then(mod => {
|
|
31
|
+
MODULES.set(kind, mod);
|
|
32
|
+
return mod;
|
|
33
|
+
});
|
|
34
|
+
LOADING.set(kind, loading);
|
|
35
|
+
return loading;
|
|
36
|
+
}
|
|
37
|
+
const KINDS = ["select", "segment", "tiles", "thanks", "swap", "saga", "urgency", "sheet", "chip", "split", "fallback"];
|
|
38
|
+
// On the server every variant is loaded as the module is: a render then finds
|
|
39
|
+
// its variant here and writes it into the page whole. One still loading
|
|
40
|
+
// renders through `lazy` and streams in behind its boundary, after the shell
|
|
41
|
+
// — React reveals such a boundary late, and the card would draw without its
|
|
42
|
+
// contact block first.
|
|
43
|
+
if (typeof window === "undefined")
|
|
44
|
+
for (const kind of KINDS)
|
|
45
|
+
void load(kind).catch(() => undefined);
|
|
46
|
+
/** Until its chunk is here, a variant is drawn through `lazy`, behind its boundary. */
|
|
47
|
+
const LAZY = new Map(KINDS.map(kind => [kind, lazy(() => load(kind))]));
|
|
48
|
+
/** The variant's component: its module's own once it is here, else its lazy stand-in. */
|
|
49
|
+
function partOf(kind) {
|
|
50
|
+
return MODULES.get(kind)?.default ?? LAZY.get(kind) ?? null;
|
|
51
|
+
}
|
|
52
|
+
/** Where each variant draws; everywhere else the card is the control's. */
|
|
53
|
+
const USES = {
|
|
54
|
+
select: ["contact"],
|
|
55
|
+
segment: ["contact"],
|
|
56
|
+
tiles: ["contact"],
|
|
57
|
+
thanks: ["done"],
|
|
58
|
+
swap: ["contact"],
|
|
59
|
+
saga: ["head", "body", "contact"],
|
|
60
|
+
urgency: ["beforeNeed", "contact"],
|
|
61
|
+
sheet: ["contact"],
|
|
62
|
+
chip: ["afterHead", "contact"],
|
|
63
|
+
split: ["contact"],
|
|
64
|
+
fallback: ["afterSubmit"],
|
|
65
|
+
};
|
|
66
|
+
export const NO_MESSENGERS = { whatsapp: null, telegram: null };
|
|
67
|
+
/**
|
|
68
|
+
* What a variant becomes on this place: itself while the place has WhatsApp;
|
|
69
|
+
* without it the control (Figma's «Выключено в панели») — with a way to the
|
|
70
|
+
* bot when there is one — except `urgency`, whose question is the lead's
|
|
71
|
+
* (both answers then ask the phone), and `thanks`, whose success keeps the
|
|
72
|
+
* bot. `null`: the control as it is.
|
|
73
|
+
*/
|
|
74
|
+
export function messengerShownOf(variant, facts) {
|
|
75
|
+
if (!variant)
|
|
76
|
+
return null;
|
|
77
|
+
if (facts.whatsapp)
|
|
78
|
+
return variant.kind;
|
|
79
|
+
if (variant.kind === "urgency")
|
|
80
|
+
return "urgency";
|
|
81
|
+
if (!facts.telegram)
|
|
82
|
+
return null;
|
|
83
|
+
return variant.kind === "thanks" ? "thanks" : "fallback";
|
|
84
|
+
}
|
|
85
|
+
export function drawsAt(shown, at) {
|
|
86
|
+
return shown !== null && USES[shown].includes(at);
|
|
87
|
+
}
|
|
88
|
+
/** The channel a variant starts on: none until asked (`saga`, `urgency`), else WhatsApp. */
|
|
89
|
+
export function initialMode(variant) {
|
|
90
|
+
return variant?.kind === "saga" || variant?.kind === "urgency" ? null : "whatsapp";
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* The card's `message_ref`, minted once the script runs — a server render
|
|
94
|
+
* cannot know it without the hydration seeing another — and again by
|
|
95
|
+
* `renew`, after a messenger lead is posted.
|
|
96
|
+
*/
|
|
97
|
+
export function useMessageRef(prefix, on) {
|
|
98
|
+
const [ref, setRef] = useState(null);
|
|
99
|
+
// Without a prefix there is no reference to wait for; with one, nothing is
|
|
100
|
+
// minted on the server — an ISR page is one render for every visitor.
|
|
101
|
+
const [settled, setSettled] = useState(false);
|
|
102
|
+
const valid = on && prefix !== undefined && MESSAGE_REF_PREFIX.test(prefix);
|
|
103
|
+
useEffect(() => {
|
|
104
|
+
if (valid && prefix !== undefined)
|
|
105
|
+
setRef(newMessageRef(prefix));
|
|
106
|
+
setSettled(true);
|
|
107
|
+
}, [valid, prefix]);
|
|
108
|
+
return { ref, renew: () => (valid && prefix !== undefined ? setRef(newMessageRef(prefix)) : undefined), minted: settled && (!valid || ref !== null) };
|
|
109
|
+
}
|
|
110
|
+
const noop = () => () => undefined;
|
|
111
|
+
/**
|
|
112
|
+
* One variant's piece at one place in the card; nothing where it draws
|
|
113
|
+
* nothing.
|
|
114
|
+
*
|
|
115
|
+
* Drawn by the server, or hydrated, it has no boundary of its own. The server
|
|
116
|
+
* finds the variant loaded (every one is, as this module loads) and writes it
|
|
117
|
+
* into the page whole — a boundary would not do: React writes a large
|
|
118
|
+
* boundary's content apart from the shell and reveals it late, the card drawn
|
|
119
|
+
* without its contact block first. Hydrating, a variant whose chunk is still
|
|
120
|
+
* on its way holds the card's hydration — the server's markup stays as it is,
|
|
121
|
+
* and nothing in the card runs until the chunk is here. A piece first drawn
|
|
122
|
+
* after that (a step, the success) is the browser's own: it waits behind a
|
|
123
|
+
* boundary, its chunk loaded then.
|
|
124
|
+
*/
|
|
125
|
+
export function MessengerSlot({ kit }) {
|
|
126
|
+
const hydrating = useSyncExternalStore(noop, () => false, () => true);
|
|
127
|
+
// Chosen on the first render: a structure swapped under the slot would draw the variant anew.
|
|
128
|
+
const [wrapped] = useState(() => !hydrating);
|
|
129
|
+
const [Part] = useState(() => partOf(kit.shown));
|
|
130
|
+
if (!drawsAt(kit.shown, kit.at) || !Part)
|
|
131
|
+
return null;
|
|
132
|
+
return wrapped ? (_jsx(Suspense, { fallback: null, children: _jsx(Part, { kit: kit }) })) : (_jsx(Part, { kit: kit }));
|
|
133
|
+
}
|
|
@@ -2,7 +2,12 @@ import type { LeadCaptureFlowText } from "../core/lead-capture-text.js";
|
|
|
2
2
|
import type { LeadFlow } from "../core/pricing/flow.js";
|
|
3
3
|
import type { Price, PricingModel } from "../core/pricing/model.js";
|
|
4
4
|
import type { PartClassNames } from "./parts.js";
|
|
5
|
-
|
|
5
|
+
/**
|
|
6
|
+
* `priceDetail`: the compact price's «Détail», the trigger — the popover's
|
|
7
|
+
* button, or the `<details>`' summary without a script; `priceDetailContent`:
|
|
8
|
+
* the breakdown it pops over the card.
|
|
9
|
+
*/
|
|
10
|
+
export type PricePart = "price" | "priceTotal" | "breakdown" | "priceNote" | "priceLine" | "priceTaxCredit" | "priceDetail" | "priceDetailContent";
|
|
6
11
|
/**
|
|
7
12
|
* The price, read out as it changes (`aria-live`): an estimate's total and
|
|
8
13
|
* how it was reached, or — until every question is answered — what is
|
|
@@ -22,8 +27,9 @@ export declare const afterCredit: (cents: number, ratio: number) => number;
|
|
|
22
27
|
/**
|
|
23
28
|
* `price="compact"`: the price on one line (`priceLine`), what is left after
|
|
24
29
|
* the brand's tax credit when it has one (`taxCredit`, a ratio), and how it
|
|
25
|
-
* was reached behind
|
|
26
|
-
*
|
|
30
|
+
* was reached behind «Détail» — the kit's `Popover`, over the card, so
|
|
31
|
+
* opening it moves nothing; without a script a `<details>`, which opens in
|
|
32
|
+
* place. Read out as it changes, the line only.
|
|
27
33
|
*/
|
|
28
34
|
export declare function PriceCompact(props: {
|
|
29
35
|
model: PricingModel;
|