@evinvest/kitstart 0.13.0 → 0.14.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 (50) hide show
  1. package/README.md +114 -8
  2. package/dist/core/accept.js +6 -2
  3. package/dist/core/analytics.d.ts +7 -2
  4. package/dist/core/lead-capture-text.d.ts +21 -1
  5. package/dist/core/lead-capture-text.js +16 -0
  6. package/dist/core/pricing/flow.d.ts +9 -0
  7. package/dist/core/pricing/flow.js +14 -0
  8. package/dist/core/pricing/index.d.ts +1 -1
  9. package/dist/core/pricing/index.js +1 -1
  10. package/dist/index.d.ts +1 -1
  11. package/dist/index.js +1 -1
  12. package/dist/react/LeadCapture.d.ts +42 -8
  13. package/dist/react/LeadCapture.js +171 -26
  14. package/dist/react/LeadCaptureChannels.d.ts +8 -2
  15. package/dist/react/LeadCaptureChannels.js +8 -2
  16. package/dist/react/LeadCaptureEstimate.d.ts +59 -26
  17. package/dist/react/LeadCaptureEstimate.js +81 -41
  18. package/dist/react/LeadCaptureFields.d.ts +2 -0
  19. package/dist/react/LeadCaptureFields.js +1 -1
  20. package/dist/react/LeadCaptureNeed.d.ts +27 -6
  21. package/dist/react/LeadCaptureNeed.js +10 -41
  22. package/dist/react/LeadCapturePrice.d.ts +36 -0
  23. package/dist/react/LeadCapturePrice.js +47 -0
  24. package/dist/react/LeadCaptureSteps.d.ts +47 -0
  25. package/dist/react/LeadCaptureSteps.js +39 -0
  26. package/dist/react/LeadCaptureTiles.d.ts +53 -0
  27. package/dist/react/LeadCaptureTiles.js +50 -0
  28. package/dist/react/QuoteFormShell.d.ts +5 -1
  29. package/dist/react/QuoteFormShell.js +2 -2
  30. package/dist/react/focus-next.d.ts +11 -0
  31. package/dist/react/focus-next.js +58 -0
  32. package/dist/react/index.d.ts +4 -1
  33. package/dist/react/lead-steps.d.ts +53 -0
  34. package/dist/react/lead-steps.js +58 -0
  35. package/dist/react/use-lead-context.d.ts +6 -0
  36. package/dist/react/use-lead-context.js +16 -0
  37. package/dist/react/use-lead-focus.d.ts +19 -0
  38. package/dist/react/use-lead-focus.js +70 -0
  39. package/package.json +1 -1
  40. package/template/app/[locale]/[location]/prices/page.tsx +6 -1
  41. package/template/flake.nix +1 -1
  42. package/template/package.json +2 -2
  43. package/template/src/entities/content/model/en.ts +7 -0
  44. package/template/src/entities/content/model/fr.ts +7 -0
  45. package/template/src/entities/content/model/types.ts +8 -0
  46. package/template/src/shared/config/lead.ts +5 -1
  47. package/template/src/views/prices/index.ts +1 -0
  48. package/template/src/views/prices/ui/EstimateCard.tsx +66 -0
  49. package/template/tests/bundle_budget.txt +2 -1
  50. package/template/tests/e2e/steps.spec.ts +138 -0
@@ -1,25 +1,31 @@
1
1
  "use client";
2
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
- import { cn } from "@evinvest/uikit";
2
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
+ import { Button, cn } from "@evinvest/uikit";
4
4
  import { Fragment, useEffect, useRef, useState } from "react";
5
5
  import { PRICE_CHANGED, SHOWN_CENTS_FIELD } from "../core/accept.js";
6
6
  import { resolveChannels } from "../core/channels.js";
7
- import { leadErrorFor } from "../core/lead.js";
7
+ import { CHANNEL_FIELD, leadErrorFor } from "../core/lead.js";
8
8
  import { fillText, openingText } from "../core/lead-capture-format.js";
9
9
  import { flowTextOf } from "../core/lead-capture-text.js";
10
10
  import { bookingOf } from "../core/booking/model.js";
11
11
  import { servedLocalities, storefrontOf } from "../core/place/types.js";
12
- import { flowOf } from "../core/pricing/flow.js";
12
+ import { ESTIMATE_UNKNOWN, estimateField, flowOf } from "../core/pricing/flow.js";
13
+ import { labelOf } from "../core/pricing/validate.js";
14
+ import { focusNext } from "./focus-next.js";
13
15
  import { LeadBooking } from "./LeadBooking.js";
14
16
  import { LeadCapturePriced } from "./LeadCapturePriced.js";
15
17
  import { CallbackForm, ChannelLink, ExperimentFields, PhotosAsk } from "./LeadCaptureChannels.js";
16
- import { EstimateInputs, PriceBox, useEstimate } from "./LeadCaptureEstimate.js";
17
- import { FormMessage, LocalityField, NameField, PhoneField } from "./LeadCaptureFields.js";
18
+ import { askedInputs, EstimateInputs, EstimateQuestionField, estimatePlan, screensOf, useEstimate } from "./LeadCaptureEstimate.js";
19
+ import { ConsentField, FormMessage, LocalityField, NameField, PhoneField } from "./LeadCaptureFields.js";
18
20
  import { NeedField } from "./LeadCaptureNeed.js";
21
+ import { PriceBox, PriceCompact } from "./LeadCapturePrice.js";
22
+ import { IntroField, LeadSteps } from "./LeadCaptureSteps.js";
23
+ import { stepOfField, stepsOf, useLeadSteps } from "./lead-steps.js";
19
24
  import { QuoteFormShell } from "./QuoteFormShell.js";
20
- import { useHeld, useHydrated, useNeed, useNow, useOpenOnHash } from "./use-lead-context.js";
25
+ import { useHeld, useHydrated, useNeed, useNow, useOpenOnHash, usePostcode } from "./use-lead-context.js";
21
26
  import { errorText, formMessageId, OWN_FIELDS, useLeadError } from "./use-lead-error.js";
22
27
  import { useLeadEvents } from "./use-lead-events.js";
28
+ import { useLeadFocus } from "./use-lead-focus.js";
23
29
  import { FailureMessage, SubmitButton } from "./LeadCaptureSubmit.js";
24
30
  import { useLeadSubmit } from "./use-lead-submit.js";
25
31
  import { useRepriced } from "./use-repriced.js";
@@ -42,10 +48,22 @@ export function LeadCapture(props) {
42
48
  const { place, contact, locale, renderedAt, wire, needs, layout = "single", text } = props;
43
49
  const { formId = "quote", id = "quote", experiment } = props;
44
50
  const c = props.labels === "hidden" ? { ...props.classNames, label: cn("sr-only", props.classNames?.label) } : props.classNames;
51
+ const steps = layout === "steps";
52
+ const needDisplay = props.needDisplay ?? (layout === "single" ? "select" : "tiles");
53
+ // Enter on a tile answers it wherever an answer moves the visitor on.
54
+ const enterPicks = layout !== "single" || props.focusNext === true;
45
55
  const root = useRef(null);
56
+ const formRef = () => root.current?.querySelector(`#${CSS.escape(`${id}-form`)}`) ?? null;
46
57
  const hydrated = useHydrated();
47
58
  const [editing, setEditing] = useState(false);
48
59
  const [need, setNeed] = useNeed(props.need, needs.map(n => n.value), () => setEditing(false));
60
+ const [introValue, setIntro] = useState(undefined);
61
+ const intro = steps ? props.intro : undefined;
62
+ const callbackPreset = intro?.options.find(o => o.value === introValue)?.channel === "callback";
63
+ const served = localitySuggestions(place);
64
+ const postcode = usePostcode();
65
+ const [locality, setLocality] = useState(undefined);
66
+ const localityShown = locality ?? postcode ?? (served.length === 1 ? served[0] : undefined);
49
67
  const events = useLeadEvents(root, { formId, layout, experiment });
50
68
  useOpenOnHash(`${id}-callback`);
51
69
  // Only this card's: a page may draw several, and the query names one.
@@ -53,24 +71,27 @@ export function LeadCapture(props) {
53
71
  const [error, setError] = useLeadError(root, id, initial);
54
72
  const flowText = flowTextOf(text, locale);
55
73
  // `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);
74
+ const shownNeed = need ?? (layout === "single" && needDisplay === "select" ? needs[0]?.value : undefined);
57
75
  const model = props.pricing ?? null;
58
- const flow = flowOf(props.flows, model, shownNeed);
59
- const estimate = useEstimate(model, shownNeed, flow, events.estimateShown);
76
+ const wanted = flowOf(props.flows, model, shownNeed);
77
+ const estimate = useEstimate(model, shownNeed, wanted, events.estimateShown);
78
+ const flow = estimate.flow;
60
79
  const repriced = useRepriced(estimate.price, shownNeed, flowText, locale);
61
80
  // A priced lead stays in the card: its price is confirmed there, and the slot promised.
62
81
  const staysInCard = (ch) => props.done !== undefined || (ch === "form" && flow !== "quote");
63
82
  const { sent, onSubmit, busy, failure, retry } = useLeadSubmit(staysInCard, { mobile: wire.mobile, name: props.name?.field }, {
64
83
  onRefused: (channel, field, cents) => {
65
84
  repriced.refused(field, cents);
66
- setError({ channel, field });
85
+ // The intro's callback is this form's: its refusal shows here, not in the folded callback.
86
+ setError({ channel: callbackPreset ? "form" : channel, field });
67
87
  },
68
88
  onFailed: (channel, why) => events.submitError(why, channel),
69
89
  });
70
90
  // The form's refusal by where it shows: under the field the card draws, else above the submit.
71
91
  const formError = error?.channel === "form" ? error.field : null;
72
92
  const at = (field) => (formError === field ? errorText(field, text) : null);
73
- const above = formError === PRICE_CHANGED ? repriced.message() : formError !== null && !OWN_FIELDS.form.includes(formError) ? errorText(formError, text) : null;
93
+ const drawn = (field) => OWN_FIELDS.form.includes(field) || (callbackPreset && field === "consent");
94
+ const above = formError === PRICE_CHANGED ? repriced.message() : formError !== null && !drawn(formError) ? errorText(formError, text) : null;
74
95
  const doneRef = useRef(null);
75
96
  // The form the focus was in is gone: the news takes it, and is read out.
76
97
  useEffect(() => doneRef.current?.focus(), [sent]);
@@ -82,11 +103,32 @@ export function LeadCapture(props) {
82
103
  const message = needLabel ? fillText(text.message, { need: needLabel }) : text.messageGeneric;
83
104
  const [lead = "form", ...rest] = resolved.order;
84
105
  const links = rest.filter((ch) => ch !== "form" && ch !== "callback");
85
- const pick = (value) => {
106
+ // The intro asked for a call back: the folded callback would be a second one.
107
+ const callbackElsewhere = !callbackPreset;
108
+ const asked = model && shownNeed !== undefined && wanted === "estimate" ? askedInputs(model, shownNeed, estimate.answers) : [];
109
+ const screens = screensOf(asked.map(i => i.id), props.questions);
110
+ const stepIds = steps ? stepsOf({ intro: intro !== undefined, callback: callbackPreset, estimate: screens, localityOwn: props.localityStep !== "with-phone" }) : [];
111
+ const screenOf = (step) => screens.find(screen => screen[0] !== undefined && estimateField(screen[0]) === step) ?? [];
112
+ // The bar counts the most screens an answer to come could add — the need's
113
+ // longest estimate — so it never moves back; until the intro picks its branch, no total is said.
114
+ const longest = Math.max(0, ...needs.map(n => (model && flowOf(props.flows, model, n.value) === "estimate" ? screensOf(estimatePlan(model, n.value), props.questions).length : 0)));
115
+ const total = intro !== undefined && introValue === undefined ? null : stepIds.length + (need === undefined && !callbackPreset ? longest : 0);
116
+ const answered = (step) => step === "intro" ? introValue !== undefined : step === "need" ? need !== undefined : step === "locality" ? localityShown !== undefined : step === "phone" ? false : screenOf(step).every(id => estimate.answers[id] !== undefined);
117
+ const stepper = useLeadSteps(stepIds, answered, steps && formError !== null ? stepOfField(stepIds, formError, screens) : null, events.step);
118
+ const { moveOn, focusAfterSelect } = useLeadFocus(formRef, { steps, focusNext: props.focusNext === true, current: stepper.current });
119
+ const pick = (value, from) => {
120
+ // A need that brings an estimate's questions brings screens React has yet to draw.
121
+ const brings = flowOf(props.flows, model, value) === "estimate";
122
+ if (steps)
123
+ return moveOn(() => (setNeed(value), stepper.next()), from, !brings && asked.length === 0);
86
124
  if (layout === "single") {
87
- setNeed(value);
88
- setEditing(true);
89
- return;
125
+ if (from === null) {
126
+ setNeed(value);
127
+ setEditing(true);
128
+ focusAfterSelect();
129
+ return;
130
+ }
131
+ return moveOn(() => (setNeed(value), setEditing(true)), from, !brings && asked.length === 0);
90
132
  }
91
133
  // The checked radio has already shown the contact step (`:has`, below), so
92
134
  // the first empty field — the postcode unless filled, else the phone —
@@ -99,23 +141,126 @@ export function LeadCapture(props) {
99
141
  setEditing(false);
100
142
  events.step("contact");
101
143
  };
102
- // An arrow key in `qualify-first`: the tile is chosen, the group stays open.
144
+ // An arrow key among the tiles: the tile is chosen, the group stays open.
103
145
  const select = (value) => {
104
146
  setNeed(value);
105
147
  setEditing(true);
106
148
  };
107
- 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));
108
- // `qualify-first` shows the contact step as soon as a need's radio is
109
- // checked (`:has`) — without a script, and inside the tap with one.
110
- const contactShown = layout === "single" || (need !== undefined && !editing);
111
- const contactClass = contactShown ? "flex" : "hidden group-has-[[data-need-option]:checked]/lead:flex";
149
+ // "I don't know", or an answer instead of it, changes which questions are asked.
150
+ // On a screen shared by a few questions, the last one answered moves on; until then the screen stays.
151
+ const answerPicked = (input, option, radio) => {
152
+ const screen = steps ? screenOf(stepper.current) : [];
153
+ const complete = screen.every(id => id === input || option === ESTIMATE_UNKNOWN || estimate.answers[id] !== undefined);
154
+ moveOn(() => (estimate.answer(input, option), steps && (complete ? stepper.next() : stepper.edit(stepper.current))), radio, option !== ESTIMATE_UNKNOWN && estimate.answers[input] !== ESTIMATE_UNKNOWN);
155
+ };
156
+ /** A shared screen's button: on when every question is answered, else the first one unanswered says so. */
157
+ const continueScreen = (screen) => {
158
+ const missing = screen.find(id => estimate.answers[id] === undefined);
159
+ const radio = missing === undefined ? null : formRef()?.querySelector(`input[name="${estimateField(missing)}"]`);
160
+ if (radio)
161
+ return void radio.reportValidity();
162
+ moveOn(stepper.next, null, false);
163
+ };
164
+ const continueLocality = () => {
165
+ const el = formRef()?.querySelector('[data-lead-step="locality"] [data-lead-field="locality"]');
166
+ if (!el?.reportValidity())
167
+ return;
168
+ moveOn(() => (setLocality(el.value.trim()), stepper.next()), el, true);
169
+ };
170
+ // Enter in a typed field: the next empty field, or the next screen, before the submit.
171
+ const onKeyDown = (e) => {
172
+ const field = e.target;
173
+ if (e.key !== "Enter" || !(steps || props.focusNext) || !(field instanceof HTMLInputElement) || ["radio", "checkbox", "submit", "button"].includes(field.type))
174
+ return;
175
+ if (focusNext(e.currentTarget, field))
176
+ e.preventDefault();
177
+ else if (steps && stepper.current === "locality") {
178
+ e.preventDefault();
179
+ continueLocality();
180
+ }
181
+ };
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));
112
183
  const rootProps = { id, className: cn("flex w-full flex-col gap-6", props.className, c?.root), "data-experiment": experiment?.name, "data-variant": experiment?.variant };
113
184
  if (sent && staysInCard(sent.channel)) {
114
185
  const done = typeof props.done === "function" ? props.done(sent) : props.done;
115
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 }))] }) }));
116
187
  }
117
- 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: () => {
118
- setEditing(true);
119
- events.step("need");
120
- }, 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)] }))] }));
188
+ // An arrow key in `steps`: chosen, and the screen stays on until the answer is given.
189
+ const stay = (choose) => (...args) => {
190
+ if (steps)
191
+ stepper.edit(stepper.current);
192
+ choose(...args);
193
+ };
194
+ const estimateProps = { locale, answers: estimate.answers, unknownLabel: flowText.estimateUnknown, required: hydrated, enterPicks, onPick: answerPicked, onSelect: stay(estimate.answer), classNames: c };
195
+ const priceShown = model && flow !== "quote" && (props.price === "compact" ? (_jsx(PriceCompact, { model: model, flow: flow, price: repriced.price, locale: locale, taxCredit: props.taxCredit, text: flowText, classNames: c })) : (_jsx(PriceBox, { model: model, flow: flow, price: repriced.price, locale: locale, text: flowText, classNames: c })));
196
+ const photos = flow === "quote" && shownNeed !== undefined && props.photos?.includes(shownNeed) && !callbackPreset && (_jsx(PhotosAsk, { whatsapp: contact.whatsapp, needLabel: needs.find(n => n.value === shownNeed)?.label ?? shownNeed, text: flowText, experiment: experiment, className: c?.photos }));
197
+ const localityField = (_jsx(LocalityField
198
+ // Remounted with what the query knew: an untouched field takes it as typed.
199
+ , { 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
+ const phone = (_jsxs(_Fragment, { children: [_jsx(PhoneField, { name: wire.mobile, text: text, error: at("phone"), onSoftError: () => events.fieldError("phone"), classNames: c }), _jsx(Fragment, { children: props.afterPhone }, "afterPhone")] }));
201
+ 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
+ const mainBusy = busy === "form" || (callbackPreset && busy === "callback");
203
+ const mainFailure = failure && (failure.channel === "form" || callbackPreset) ? failure.failure : null;
204
+ 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: [_jsx(SubmitButton, { busy: mainBusy, label: callbackPreset ? text.callbackSubmit : flow === "quote" ? text.submit : flowText.bookSubmit, sending: text.sending, className: callbackPreset ? cn(c?.submit, c?.callbackSubmit) : c?.submit }), _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
+ // `qualify-first` shows the contact step as soon as a need's radio is
206
+ // checked (`:has`) — without a script, and inside the tap with one.
207
+ const contactShown = layout === "single" || (need !== undefined && !editing);
208
+ const contactClass = contactShown ? "flex" : "hidden group-has-[[data-need-option]:checked]/lead:flex";
209
+ const stepView = (step) => {
210
+ switch (step) {
211
+ case "intro": {
212
+ const chosen = intro?.options.find(o => o.value === introValue);
213
+ return {
214
+ id: step,
215
+ label: intro?.label ?? "",
216
+ value: chosen?.label ?? null,
217
+ node: intro && (_jsx(IntroField, { intro: intro, value: introValue, onPick: (v, radio) => moveOn(() => (setIntro(v), stepper.next()), radio, false), onSelect: stay(setIntro),
218
+ // On a screen of its own, a question is its heading: never only for assistive technology.
219
+ classNames: props.classNames })),
220
+ };
221
+ }
222
+ case "need":
223
+ return {
224
+ id: step,
225
+ label: text.needLabel,
226
+ value: needLabel ?? null,
227
+ node: (_jsx(NeedField, { display: needDisplay === "select" ? "tiles" : needDisplay, name: wire.subject, needs: needs, need: need, editing: true, hydrated: hydrated, label: text.needLabel, changeLabel: text.needChange, requiredText: text.needRequired, enterPicks: true, onPick: pick, onSelect: stay(setNeed), onEdit: () => undefined, classNames: props.classNames })),
228
+ };
229
+ case "locality":
230
+ return {
231
+ id: step,
232
+ label: text.localityLabel,
233
+ value: localityShown ?? null,
234
+ node: (_jsxs(_Fragment, { children: [localityField, _jsx(Button, { type: "button", size: "touch", "data-lead-chrome": "", className: cn("w-full", c?.stepNext), onClick: continueLocality, children: flowText.stepNext })] })),
235
+ };
236
+ case "phone":
237
+ return {
238
+ id: step,
239
+ label: text.phoneLabel,
240
+ value: null,
241
+ node: callbackPreset ? (_jsxs(_Fragment, { children: [_jsx("input", { type: "hidden", name: CHANNEL_FIELD, value: "callback" }), phone, _jsx(ConsentField, { sentence: text.callbackConsent, requiredText: text.consentRequired, error: at("consent"), className: c?.consent, classNames: c }), tail] })) : (_jsxs(_Fragment, { children: [priceShown, photos, props.localityStep === "with-phone" && localityField, phone, nameField, _jsx(Fragment, { children: props.extras }, "extras"), tail] })),
242
+ };
243
+ default: {
244
+ const inputs = screenOf(step).flatMap(id => asked.filter(i => i.id === id));
245
+ const answerOf = (input) => {
246
+ const answer = estimate.answers[input.id];
247
+ const option = input.options.find(o => o.id === answer);
248
+ return answer === ESTIMATE_UNKNOWN ? flowText.estimateUnknown : option ? labelOf(option.labels, locale) : null;
249
+ };
250
+ const values = inputs.map(answerOf);
251
+ const first = inputs[0];
252
+ return {
253
+ id: step,
254
+ label: inputs.map(i => labelOf(i.labels, locale)).join(" · "),
255
+ value: values.length > 0 && values.every(v => v !== null) ? values.join(" · ") : null,
256
+ node: model && shownNeed !== undefined && first && (_jsxs(_Fragment, { children: [inputs.map(input => (_jsx(EstimateQuestionField, { model: model, need: shownNeed, input: input, question: props.questions?.[input.id], ...estimateProps }, input.id))), inputs.length > 1 && (_jsx(Button, { type: "button", size: "touch", "data-lead-chrome": "", className: cn("w-full", c?.stepNext), onClick: () => continueScreen(inputs.map(i => i.id)), children: props.questions?.[first.id]?.next ?? flowText.stepNext }))] })),
257
+ };
258
+ }
259
+ }
260
+ };
261
+ 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" && (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 }), 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 })) : (_jsxs(_Fragment, { children: [_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
+ setEditing(true);
263
+ events.step("need");
264
+ }, 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)) &&
265
+ (props.channelsDisplay === "row" ? (_jsxs("div", { role: "group", "aria-label": text.otherChannels, className: cn("flex flex-row flex-wrap gap-2", c?.others), children: [links.map(ch => channel(ch, false)), rest.includes("callback") && callbackElsewhere && channel("callback", false)] })) : (_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") && callbackElsewhere && channel("callback", false)] })))] }));
121
266
  }
@@ -1,6 +1,6 @@
1
1
  import { type CaptureChannel } from "../core/channels.js";
2
2
  import type { LeadCaptureFlowText, LeadCaptureText } from "../core/lead-capture-text.js";
3
- import type { FormEventHandler } from "react";
3
+ import type { FormEventHandler, ReactNode } from "react";
4
4
  import { type FieldPart } from "./LeadCaptureFields.js";
5
5
  import type { SendFailure } from "./use-lead-submit.js";
6
6
  import { type PartClassNames } from "./parts.js";
@@ -8,7 +8,9 @@ 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" | "photos";
11
+ export type ChannelPart = "channel" | "primary" | "callback" | "callbackSummary" | "callbackForm" | "callbackLede" | "callbackSubmit" | "consent" | "photos" | "channelIcon";
12
+ /** A way out the brand may draw an icon for (`channelIcons`). */
13
+ export type ChannelIconKey = "phone" | "whatsapp" | "sms" | "callback";
12
14
  export interface Experiment {
13
15
  name: string;
14
16
  variant: string;
@@ -33,6 +35,7 @@ export declare function ChannelLink(props: {
33
35
  text: LeadCaptureText;
34
36
  primary: boolean;
35
37
  experiment: Experiment | undefined;
38
+ icon?: ReactNode;
36
39
  classNames?: PartClassNames<ChannelPart> | undefined;
37
40
  }): import("react").JSX.Element | null;
38
41
  /**
@@ -73,6 +76,9 @@ export declare function CallbackForm(props: {
73
76
  failure: SendFailure | null;
74
77
  onRetry: () => void;
75
78
  onSoftError: () => void;
79
+ /** In the row of channels (`channelsDisplay="row"`): a button among the others, its form on a line of its own once open. */
80
+ row?: boolean | undefined;
81
+ icon?: ReactNode;
76
82
  classNames?: PartClassNames<ChannelPart | FieldPart> | undefined;
77
83
  }): import("react").JSX.Element;
78
84
  /**
@@ -8,6 +8,12 @@ import { ConsentField, FormMessage, PhoneField } from "./LeadCaptureFields.js";
8
8
  import { FailureMessage, SubmitButton } from "./LeadCaptureSubmit.js";
9
9
  import { partWithLeading } from "./parts.js";
10
10
  import { QuoteFormShell } from "./QuoteFormShell.js";
11
+ /** The brand's icon before a channel's label, hidden from assistive technology: the label names it. */
12
+ function ChannelIcon({ icon, className }) {
13
+ if (icon === undefined || icon === null)
14
+ return null;
15
+ return (_jsx("span", { "aria-hidden": "true", className: cn("flex shrink-0 items-center [&_svg]:size-5", className), children: icon }));
16
+ }
11
17
  /**
12
18
  * A whole-pixel line under a brand's own type size: tailwind-merge drops a
13
19
  * size's line height for nothing, and a 15 px face at the page's 1.5 is a
@@ -32,7 +38,7 @@ export function ChannelLink(props) {
32
38
  const href = channelHref(channel, contact, message);
33
39
  if (!href)
34
40
  return null;
35
- return (_jsx(Button, { href: href, variant: primary ? "primary" : "outline", size: "touch", className: partWithLeading(primary ? "w-full" : "flex-1", CHANNEL_LEADING, cn(c?.channel, primary && c?.primary) || undefined), "data-intent": channel === "sms" ? "sms" : undefined, "data-experiment": experiment?.name, "data-variant": experiment?.variant, children: text[LABEL[channel]] }));
41
+ return (_jsxs(Button, { href: href, variant: primary ? "primary" : "outline", size: "touch", className: partWithLeading(primary ? "w-full" : "flex-1", CHANNEL_LEADING, cn(c?.channel, primary && c?.primary) || undefined), "data-intent": channel === "sms" ? "sms" : undefined, "data-experiment": experiment?.name, "data-variant": experiment?.variant, children: [_jsx(ChannelIcon, { icon: props.icon, className: c?.channelIcon }), text[LABEL[channel]]] }));
36
42
  }
37
43
  /**
38
44
  * "Call me back": the form cut to a phone number and a consent, posted to the
@@ -45,7 +51,7 @@ export function ChannelLink(props) {
45
51
  export function CallbackForm(props) {
46
52
  const { id, primary, open, formId, placeSlug, locale, renderedAt, mobileName, subject, opening, text, experiment, error, classNames: c } = props;
47
53
  const at = (field) => (error?.field === field ? error.text : null);
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 })] })] }));
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 })] })] }));
49
55
  }
50
56
  /**
51
57
  * A `quote` need priced from photos (a deep clean, after-works): the photos
@@ -1,46 +1,79 @@
1
- import type { LeadCaptureFlowText } from "../core/lead-capture-text.js";
2
1
  import { type LeadFlow } from "../core/pricing/flow.js";
3
- import type { Price, PricingModel } from "../core/pricing/model.js";
2
+ import type { PricingInput, PricingModel } from "../core/pricing/model.js";
4
3
  import type { PartClassNames } from "./parts.js";
5
- export type EstimatePart = "estimate" | "estimateInput" | "estimateLegend" | "estimateGrid" | "estimateOption" | "price" | "priceTotal" | "breakdown" | "priceNote";
4
+ export type EstimatePart = "estimate" | "estimateInput" | "estimateLegend" | "estimateGrid" | "estimateOption" | "estimateUnknown" | "badge" | "optionPrice";
5
+ /**
6
+ * How one of an estimate's questions is asked, by its input id — the brand's
7
+ * presentation, never the price list's (which the panel owns).
8
+ */
9
+ export interface EstimateQuestion {
10
+ /** `tiles` (default): a grid of answers. `cards`: one per line, each with the total it would make. */
11
+ display?: "tiles" | "cards" | undefined;
12
+ /** Offers "I don't know" (`text.estimateUnknown`): the lead is then a quote, on the form and on the server. */
13
+ unknown?: boolean | undefined;
14
+ /** `2`: the "I don't know" tile takes two columns of the grid. Its own part is `estimateUnknown`, after `estimateOption`. */
15
+ unknownSpan?: 1 | 2 | undefined;
16
+ /** A short tag on an answer, by option id — the brand's words ("Le plus avantageux"); the kit picks none. */
17
+ badges?: Readonly<Record<string, string>> | undefined;
18
+ /** A shorter label per option id, drawn on a phone (under `sm`); the radio keeps the whole one. */
19
+ shortLabels?: Readonly<Record<string, string>> | undefined;
20
+ /**
21
+ * `steps` only: questions with the same number share a screen, in the
22
+ * need's order. It moves on by itself once every one is answered; until
23
+ * then a button does (`next`, else `text.stepNext`), and says what is missing.
24
+ */
25
+ step?: number | undefined;
26
+ /** The words of that button, from the first question of the screen ("Voir les prix"). */
27
+ next?: string | undefined;
28
+ }
29
+ /**
30
+ * The screens an estimate's questions take in `steps`, in order: one each,
31
+ * or those sharing a `step` number together, where the first of them stands.
32
+ */
33
+ export declare function screensOf(inputs: readonly string[], questions: EstimateQuestions | undefined): string[][];
34
+ export type EstimateQuestions = Readonly<Record<string, EstimateQuestion>>;
6
35
  /**
7
36
  * The live price of the need on screen: its answers, as the visitor taps
8
37
  * them, and the price `priceOf` makes of them — the same function the server
9
38
  * stores the lead's price with. A full set of answers is reported once per
10
- * band (`onShown`), never the price itself.
39
+ * band (`onShown`), never the price itself. An answer "I don't know" makes
40
+ * the need a `quote` for this lead (`flow`), as the server will.
11
41
  */
12
- export declare function useEstimate(model: PricingModel | null, need: string | undefined, flow: LeadFlow, onShown: (need: string, cents: number) => void): {
42
+ export declare function useEstimate(model: PricingModel | null, need: string | undefined, wanted: LeadFlow, onShown: (need: string, cents: number) => void): {
13
43
  answers: Readonly<Record<string, string>>;
14
44
  answer: (input: string, option: string) => void;
15
- price: Price | null;
45
+ price: import("..").Price | null;
46
+ flow: LeadFlow;
16
47
  };
48
+ /** Every question a need's estimate asks, by input id, in order: none for a need not priced so. */
49
+ export declare function estimatePlan(model: PricingModel, need: string): readonly string[];
17
50
  /**
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.
51
+ * The questions an estimate asks now, in order: all of them, or — once one
52
+ * is answered "I don't know" — up to that one. The rest would price nothing.
23
53
  */
24
- export declare function EstimateInputs(props: {
54
+ export declare function askedInputs(model: PricingModel, need: string, answers: Readonly<Record<string, string>>): PricingInput[];
55
+ export interface EstimateQuestionProps {
25
56
  model: PricingModel;
26
57
  need: string;
58
+ input: PricingInput;
59
+ question: EstimateQuestion | undefined;
27
60
  locale: string;
28
61
  answers: Readonly<Record<string, string>>;
29
- onAnswer: (input: string, option: string) => void;
62
+ unknownLabel: string;
30
63
  required: boolean;
64
+ enterPicks: boolean;
65
+ onPick: (input: string, option: string, radio: HTMLInputElement) => void;
66
+ onSelect: (input: string, option: string) => void;
31
67
  classNames?: PartClassNames<EstimatePart> | undefined;
32
- }): import("react").JSX.Element | null;
68
+ }
33
69
  /**
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.
70
+ * One question, a tile per answer — touch-sized radios, so the answers post
71
+ * without a script too (the server prices them; the live price needs the
72
+ * script). As `cards`, each answer shows the total it would make with the
73
+ * others given, once they are.
38
74
  */
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;
75
+ export declare function EstimateQuestionField(props: EstimateQuestionProps): import("react").JSX.Element;
76
+ /** An estimate's questions on one screen: `EstimateQuestionField` each, in order. */
77
+ export declare function EstimateInputs(props: Omit<EstimateQuestionProps, "input" | "question"> & {
78
+ questions: EstimateQuestions | undefined;
79
+ }): import("react").JSX.Element | null;