@evinvest/kitstart 0.13.0 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +178 -15
- package/dist/core/ab-gate.d.ts +11 -0
- package/dist/core/ab-gate.js +19 -0
- package/dist/core/ab-switcher.d.ts +33 -0
- package/dist/core/ab-switcher.js +61 -0
- package/dist/core/accept.js +6 -2
- package/dist/core/analytics.d.ts +7 -2
- package/dist/core/lead-capture-text.d.ts +21 -1
- package/dist/core/lead-capture-text.js +16 -0
- package/dist/core/pricing/flow.d.ts +9 -0
- package/dist/core/pricing/flow.js +14 -0
- package/dist/core/pricing/index.d.ts +1 -1
- package/dist/core/pricing/index.js +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.js +3 -1
- package/dist/react/AbSwitcher.d.ts +9 -0
- package/dist/react/AbSwitcher.js +22 -0
- package/dist/react/AbSwitcherPanel.d.ts +8 -0
- package/dist/react/AbSwitcherPanel.js +77 -0
- package/dist/react/FormSelect.d.ts +6 -0
- package/dist/react/FormSelect.js +5 -2
- package/dist/react/LeadCapture.d.ts +42 -8
- package/dist/react/LeadCapture.js +171 -26
- package/dist/react/LeadCaptureChannels.d.ts +8 -2
- package/dist/react/LeadCaptureChannels.js +8 -2
- package/dist/react/LeadCaptureEstimate.d.ts +59 -26
- package/dist/react/LeadCaptureEstimate.js +81 -41
- package/dist/react/LeadCaptureFields.d.ts +2 -0
- package/dist/react/LeadCaptureFields.js +1 -1
- package/dist/react/LeadCaptureNeed.d.ts +27 -6
- package/dist/react/LeadCaptureNeed.js +10 -41
- package/dist/react/LeadCapturePrice.d.ts +36 -0
- package/dist/react/LeadCapturePrice.js +47 -0
- package/dist/react/LeadCaptureSteps.d.ts +47 -0
- package/dist/react/LeadCaptureSteps.js +39 -0
- package/dist/react/LeadCaptureTiles.d.ts +53 -0
- package/dist/react/LeadCaptureTiles.js +50 -0
- package/dist/react/QuoteFormShell.d.ts +5 -1
- package/dist/react/QuoteFormShell.js +2 -2
- package/dist/react/ab-switcher-types.d.ts +39 -0
- package/dist/react/ab-switcher-types.js +1 -0
- package/dist/react/focus-next.d.ts +11 -0
- package/dist/react/focus-next.js +58 -0
- package/dist/react/form-select-value.d.ts +6 -0
- package/dist/react/form-select-value.js +54 -9
- package/dist/react/index.d.ts +11 -3
- package/dist/react/index.js +6 -2
- package/dist/react/lead-steps.d.ts +53 -0
- package/dist/react/lead-steps.js +58 -0
- package/dist/react/use-lead-context.d.ts +6 -0
- package/dist/react/use-lead-context.js +16 -0
- package/dist/react/use-lead-focus.d.ts +19 -0
- package/dist/react/use-lead-focus.js +70 -0
- package/dist/server/experiments-declared.d.ts +7 -3
- package/dist/server/experiments-declared.js +1 -7
- package/package.json +1 -1
- package/template/app/[locale]/[location]/layout.tsx +4 -1
- package/template/app/[locale]/[location]/prices/page.tsx +6 -1
- package/template/flake.nix +1 -1
- package/template/package.json +2 -2
- package/template/src/entities/content/model/en.ts +7 -0
- package/template/src/entities/content/model/fr.ts +7 -0
- package/template/src/entities/content/model/types.ts +8 -0
- package/template/src/shared/config/experiments.ts +21 -0
- package/template/src/shared/config/lead.ts +5 -1
- package/template/src/views/prices/index.ts +1 -0
- package/template/src/views/prices/ui/EstimateCard.tsx +66 -0
- package/template/tests/bundle_budget.txt +2 -1
- package/template/tests/e2e/ab-switcher.spec.ts +99 -0
- package/template/tests/e2e/screenshot-section.css +6 -0
- package/template/tests/e2e/steps.spec.ts +138 -0
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { AbSwitcherProps } from "./ab-switcher-types.js";
|
|
2
|
+
/**
|
|
3
|
+
* The QA menu's gate: everything a visitor downloads for it. On the server and
|
|
4
|
+
* on the first client render it is nothing — the page stays static, no cookie
|
|
5
|
+
* is read there — and after mount it loads the panel only outside production
|
|
6
|
+
* or for a visit carrying the QA cookie. Nothing heavy is imported here: the
|
|
7
|
+
* panel and the kit come with the dynamic import, in their own chunk.
|
|
8
|
+
*/
|
|
9
|
+
export declare function AbSwitcher(props: AbSwitcherProps): import("react").JSX.Element | null;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
+
import { useEffect, useState } from "react";
|
|
4
|
+
import { abSwitcherVisible } from "../core/ab-gate.js";
|
|
5
|
+
/**
|
|
6
|
+
* The QA menu's gate: everything a visitor downloads for it. On the server and
|
|
7
|
+
* on the first client render it is nothing — the page stays static, no cookie
|
|
8
|
+
* is read there — and after mount it loads the panel only outside production
|
|
9
|
+
* or for a visit carrying the QA cookie. Nothing heavy is imported here: the
|
|
10
|
+
* panel and the kit come with the dynamic import, in their own chunk.
|
|
11
|
+
*/
|
|
12
|
+
export function AbSwitcher(props) {
|
|
13
|
+
const [Panel, setPanel] = useState(null);
|
|
14
|
+
const { qaCookie } = props;
|
|
15
|
+
useEffect(() => {
|
|
16
|
+
if (!abSwitcherVisible(document.cookie, qaCookie, process.env.NODE_ENV))
|
|
17
|
+
return;
|
|
18
|
+
// An answer after unmount is dropped by React; no flag to carry.
|
|
19
|
+
void import("./AbSwitcherPanel.js").then(m => setPanel(() => m.AbSwitcherPanel));
|
|
20
|
+
}, [qaCookie]);
|
|
21
|
+
return Panel ? _jsx(Panel, { ...props }) : null;
|
|
22
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { AbSwitcherProps } from "./ab-switcher-types.js";
|
|
2
|
+
/**
|
|
3
|
+
* The QA menu itself, loaded only when `AbSwitcher` lets it: a chip in a
|
|
4
|
+
* corner and, on a tap, each experiment's variants. A tap on a variant is the
|
|
5
|
+
* brand's own force parameter — no other way in. Minimized and hidden live in
|
|
6
|
+
* memory only: a reload brings the chip back.
|
|
7
|
+
*/
|
|
8
|
+
export declare function AbSwitcherPanel({ experiments, qaCookie, current, forceParam, className, text }: AbSwitcherProps): import("react").JSX.Element | null;
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { Badge, Button, cn } from "@evinvest/uikit";
|
|
4
|
+
import { useEffect, useRef, useState } from "react";
|
|
5
|
+
import { abAssignments, abReset, abVariantUrl, AB_FORCE_PARAM } from "../core/ab-switcher.js";
|
|
6
|
+
const TEXT = {
|
|
7
|
+
title: "A/B test switcher",
|
|
8
|
+
reset: "Reset",
|
|
9
|
+
leave: "Leave test",
|
|
10
|
+
minimize: "Minimize",
|
|
11
|
+
hide: "Hide",
|
|
12
|
+
unassigned: "not assigned",
|
|
13
|
+
};
|
|
14
|
+
/**
|
|
15
|
+
* The QA menu itself, loaded only when `AbSwitcher` lets it: a chip in a
|
|
16
|
+
* corner and, on a tap, each experiment's variants. A tap on a variant is the
|
|
17
|
+
* brand's own force parameter — no other way in. Minimized and hidden live in
|
|
18
|
+
* memory only: a reload brings the chip back.
|
|
19
|
+
*/
|
|
20
|
+
export function AbSwitcherPanel({ experiments, qaCookie, current, forceParam = AB_FORCE_PARAM, className, text }) {
|
|
21
|
+
const t = { ...TEXT, ...text };
|
|
22
|
+
const keys = experiments.map(e => e.key);
|
|
23
|
+
const [assigned] = useState(() => current ?? abAssignments(document.cookie, keys));
|
|
24
|
+
const [open, setOpen] = useState(false);
|
|
25
|
+
const [minimized, setMinimized] = useState(false);
|
|
26
|
+
const [hidden, setHidden] = useState(false);
|
|
27
|
+
const root = useRef(null);
|
|
28
|
+
const chip = useRef(null);
|
|
29
|
+
const close = (refocus) => {
|
|
30
|
+
setOpen(false);
|
|
31
|
+
if (refocus)
|
|
32
|
+
chip.current?.focus();
|
|
33
|
+
};
|
|
34
|
+
useDismiss(open, root, close);
|
|
35
|
+
const reset = (mode) => {
|
|
36
|
+
const plan = abReset(location.href, keys, qaCookie, mode, forceParam);
|
|
37
|
+
for (const c of plan.cookies)
|
|
38
|
+
document.cookie = c;
|
|
39
|
+
location.replace(plan.url);
|
|
40
|
+
};
|
|
41
|
+
if (hidden)
|
|
42
|
+
return null;
|
|
43
|
+
return (_jsxs("div", { "data-ab-switcher": "", ref: root, className: cn("fixed right-4 bottom-4 z-40", className), children: [open && (_jsxs("div", { role: "dialog", "aria-label": t.title, className: "absolute right-0 bottom-full mb-2 flex w-80 max-w-[calc(100vw-2rem)] flex-col gap-4 rounded-md border border-border bg-popover p-3 text-ink shadow-md", children: [experiments.map((e, i) => (_jsx(ExperimentRow, { experiment: e, value: assigned[e.key], unassigned: t.unassigned, autoFocus: i === 0, onPick: v => location.assign(abVariantUrl(location.href, e.key, v, forceParam)) }, e.key))), _jsxs("div", { className: "flex flex-wrap gap-2", children: [_jsx(Button, { variant: "primary", size: "touch", className: "flex-1", onClick: () => reset("reassign"), children: t.reset }), _jsx(Button, { variant: "outline", size: "touch", className: "flex-1", onClick: () => reset("leave"), children: t.leave })] }), _jsxs("div", { className: "flex gap-2", children: [_jsx(Button, { variant: "ghost", size: "touch", className: "flex-1", onClick: () => { setMinimized(true); close(true); }, children: t.minimize }), _jsx(Button, { variant: "ghost", size: "touch", className: "flex-1", onClick: () => setHidden(true), children: t.hide })] })] })), _jsxs(Button, { ref: chip, variant: "secondary", size: "touch", icon: minimized, "aria-label": t.title, "aria-expanded": open, className: cn("shadow-md", !minimized && "gap-1.5 px-3"), onClick: () => { if (open)
|
|
44
|
+
close(false);
|
|
45
|
+
else {
|
|
46
|
+
setOpen(true);
|
|
47
|
+
setMinimized(false);
|
|
48
|
+
} }, children: [_jsx("span", { className: "font-semibold", children: "A/B" }), !minimized && keys.map(k => _jsx(Badge, { variant: "outline", className: "border-current/40 text-current", children: assigned[k] ?? "–" }, k))] })] }));
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Escape closes and gives the focus back to the chip; a tap outside closes.
|
|
52
|
+
* Not the kit's `Popover`: sharing its primitives with the page makes
|
|
53
|
+
* Turbopack re-split the page's chunks, ~400 B gz on every visitor's first
|
|
54
|
+
* load for a menu almost nobody opens. The panel is not portaled — the chip's
|
|
55
|
+
* fixed corner is its anchor — so the root holds both.
|
|
56
|
+
*/
|
|
57
|
+
function useDismiss(open, root, close) {
|
|
58
|
+
useEffect(() => {
|
|
59
|
+
if (!open)
|
|
60
|
+
return;
|
|
61
|
+
const key = (e) => e.key === "Escape" && close(true);
|
|
62
|
+
const down = (e) => !(e.target instanceof Node && root.current?.contains(e.target)) && close(false);
|
|
63
|
+
document.addEventListener("keydown", key);
|
|
64
|
+
document.addEventListener("pointerdown", down, true);
|
|
65
|
+
return () => {
|
|
66
|
+
document.removeEventListener("keydown", key);
|
|
67
|
+
document.removeEventListener("pointerdown", down, true);
|
|
68
|
+
};
|
|
69
|
+
}, [open, root, close]);
|
|
70
|
+
}
|
|
71
|
+
function ExperimentRow({ experiment: e, value, unassigned, autoFocus, onPick }) {
|
|
72
|
+
const labelId = `ab-switcher-${e.key}`;
|
|
73
|
+
return (_jsxs("div", { role: "group", "aria-labelledby": labelId, className: "flex flex-col gap-2", children: [_jsxs("div", { className: "flex items-baseline justify-between gap-2 text-sm", children: [_jsx("span", { id: labelId, className: "font-medium text-ink", children: e.label ?? e.key }), value === undefined && _jsx("span", { className: "text-xs text-ink-soft", children: unassigned })] }), _jsx("div", { className: "flex flex-wrap gap-2", children: e.variants.map((v, i) => {
|
|
74
|
+
const on = v.value === value;
|
|
75
|
+
return (_jsx(Button, { variant: on ? "primary" : "outline", size: "touch", "aria-pressed": on, autoFocus: autoFocus && i === 0, className: "flex-1", onClick: () => onPick(v.value), children: v.label }, v.value));
|
|
76
|
+
}) })] }));
|
|
77
|
+
}
|
|
@@ -22,6 +22,12 @@ export interface FormSelectProps {
|
|
|
22
22
|
options: readonly FormSelectOption[];
|
|
23
23
|
/** Without it: empty under a `placeholder`, else the first option — a native select's own rule. */
|
|
24
24
|
defaultValue?: string | undefined;
|
|
25
|
+
/**
|
|
26
|
+
* Controlled: the value shown and posted, kept until the parent changes it
|
|
27
|
+
* from `onValueChange` (a form reset leaves it too). `undefined` is
|
|
28
|
+
* uncontrolled, as on an `<input>`; `""` is the placeholder.
|
|
29
|
+
*/
|
|
30
|
+
value?: string | undefined;
|
|
25
31
|
/** An empty, unpickable choice shown until one is made. */
|
|
26
32
|
placeholder?: string | undefined;
|
|
27
33
|
required?: boolean | undefined;
|
package/dist/react/FormSelect.js
CHANGED
|
@@ -26,11 +26,14 @@ export function FormSelectView({ scripted, ...props }) {
|
|
|
26
26
|
const native = useRef(null);
|
|
27
27
|
// The box, not the trigger: `SelectTrigger` keeps its own ref for placing the list.
|
|
28
28
|
const box = useRef(null);
|
|
29
|
-
const state = useFormSelectValue({ initial, native, box, scripted, onValueChange: props.onValueChange });
|
|
29
|
+
const state = useFormSelectValue({ initial, controlled: props.value, native, box, scripted, onValueChange: props.onValueChange });
|
|
30
30
|
const hint = placeholder !== undefined ? { placeholder } : {};
|
|
31
31
|
const aria = { "aria-describedby": props["aria-describedby"], "aria-invalid": props["aria-invalid"] || state.invalid || undefined };
|
|
32
32
|
if (!scripted) {
|
|
33
|
-
return (_jsx(NativeSelect, { ref: native, name: name, id: id, size: size, ...hint, required: required, disabled: disabled,
|
|
33
|
+
return (_jsx(NativeSelect, { ref: native, name: name, id: id, size: size, ...hint, required: required, disabled: disabled,
|
|
34
|
+
// Uncontrolled even under `value`: before the script nothing could
|
|
35
|
+
// follow a change, and a pick made then must survive into the swap.
|
|
36
|
+
defaultValue: state.value, wrapperClassName: cn(BOX, className), className: classNames?.trigger, ...aria, children: options.map(o => (_jsx(NativeSelectOption, { value: o.value, children: o.label }, o.value))) }));
|
|
34
37
|
}
|
|
35
38
|
return (_jsxs("span", { ref: box, "data-slot": "form-select", className: cn(BOX, className), children: [_jsxs(Select, { value: state.value, onValueChange: state.choose, open: state.open, onOpenChange: state.setOpen, children: [_jsx(SelectTrigger, { id: id, size: size, disabled: disabled, "aria-required": required || undefined, className: cn(TRIGGER, TRIGGER_TEXT[size], classNames?.trigger), ...aria, children: _jsx(SelectValue, { ...hint }) }), _jsx(SelectContent, { className: classNames?.content, children: options.map(o => (_jsx(SelectItem, { value: o.value, className: cn(size === "lg" && "py-2.5 text-base", classNames?.item), children: o.label }, o.value))) })] }), required ? (
|
|
36
39
|
// Validated, so not `type="hidden"` (which the browser never checks);
|
|
@@ -5,17 +5,19 @@ import { type LeadCaptureText } from "../core/lead-capture-text.js";
|
|
|
5
5
|
import { type Place } from "../core/place/types.js";
|
|
6
6
|
import { type LeadFlows } from "../core/pricing/flow.js";
|
|
7
7
|
import type { PricingModel } from "../core/pricing/model.js";
|
|
8
|
-
import type { FormSelectOption } from "./FormSelect.js";
|
|
9
8
|
import type { BookingAdapters } from "./booking-adapters.js";
|
|
10
9
|
import type { BookingPart } from "./LeadBookingManual.js";
|
|
11
10
|
import { type PricedPart } from "./LeadCapturePriced.js";
|
|
12
|
-
import { type ChannelPart, type Experiment } from "./LeadCaptureChannels.js";
|
|
13
|
-
import { type EstimatePart } from "./LeadCaptureEstimate.js";
|
|
11
|
+
import { type ChannelIconKey, type ChannelPart, type Experiment } from "./LeadCaptureChannels.js";
|
|
12
|
+
import { type EstimatePart, type EstimateQuestions } from "./LeadCaptureEstimate.js";
|
|
14
13
|
import { type FieldPart } from "./LeadCaptureFields.js";
|
|
15
|
-
import { type LeadCaptureLayout } from "./LeadCaptureNeed.js";
|
|
14
|
+
import { type LeadCaptureLayout, type LeadNeedDisplay, type LeadNeedOption } from "./LeadCaptureNeed.js";
|
|
15
|
+
import { type PricePart } from "./LeadCapturePrice.js";
|
|
16
|
+
import { type StepsPart } from "./LeadCaptureSteps.js";
|
|
17
|
+
import { type LeadIntro } from "./lead-steps.js";
|
|
16
18
|
import type { PartClassNames } from "./parts.js";
|
|
17
19
|
import { type LeadSent } from "./use-lead-submit.js";
|
|
18
|
-
export type LeadCapturePart = "root" | "head" | "title" | "lede" | "form" | "contact" | "submit" | "trust" | "privacy" | "opening" | "others" | "done" | "needs" | "need" | "summary" | FieldPart | ChannelPart | EstimatePart | PricedPart | BookingPart;
|
|
20
|
+
export type LeadCapturePart = "root" | "head" | "title" | "lede" | "form" | "contact" | "submit" | "trust" | "privacy" | "opening" | "others" | "done" | "needs" | "need" | "summary" | "icon" | "intro" | "introOption" | "stepNext" | FieldPart | ChannelPart | EstimatePart | PricePart | StepsPart | PricedPart | BookingPart;
|
|
19
21
|
export interface LeadCaptureProps {
|
|
20
22
|
/** Its hours order the channels; its service area suggests the commune. */
|
|
21
23
|
place: Place<string>;
|
|
@@ -29,8 +31,8 @@ export interface LeadCaptureProps {
|
|
|
29
31
|
renderedAt: number;
|
|
30
32
|
/** `site.lead.wire`: the names the funnel reads. */
|
|
31
33
|
wire: LeadWire;
|
|
32
|
-
/** The brand's subjects with their labels, in order
|
|
33
|
-
needs: readonly
|
|
34
|
+
/** The brand's subjects with their labels, in order — and an icon each for `needDisplay="cards"`. */
|
|
35
|
+
needs: readonly LeadNeedOption[];
|
|
34
36
|
/**
|
|
35
37
|
* How each need is sold — `site.lead.flows`, the map the server prices
|
|
36
38
|
* with. A need runs `estimate` or `fixed` only when `pricing` prices it so
|
|
@@ -71,8 +73,34 @@ export interface LeadCaptureProps {
|
|
|
71
73
|
bookingEmbed?: boolean | undefined;
|
|
72
74
|
/** The need the page already knows; `?need=` and `[data-need]` triggers set it too. */
|
|
73
75
|
need?: string | undefined;
|
|
74
|
-
/**
|
|
76
|
+
/**
|
|
77
|
+
* `single` (one screen, the default) · `steps` (one question per screen, a
|
|
78
|
+
* thin bar, a way back, the phone last) · `qualify-first` (the two screens
|
|
79
|
+
* `steps` grew from: the need, then the contact) — an experiment's switch.
|
|
80
|
+
*/
|
|
75
81
|
layout?: LeadCaptureLayout | undefined;
|
|
82
|
+
/** How the need is asked: `select` (default on one screen), `tiles` (default otherwise), `cards` with the `icon` of each need. */
|
|
83
|
+
needDisplay?: LeadNeedDisplay | undefined;
|
|
84
|
+
/**
|
|
85
|
+
* `steps` only: a question before the need whose answer is posted as one of
|
|
86
|
+
* the brand's extras (`field`) and may change the channel — `callback`:
|
|
87
|
+
* the rest is the phone and its consent, posted as a callback.
|
|
88
|
+
*/
|
|
89
|
+
intro?: LeadIntro | undefined;
|
|
90
|
+
/** `steps` only: the postcode on a screen of its own (default), or on the phone's. */
|
|
91
|
+
localityStep?: "own" | "with-phone" | undefined;
|
|
92
|
+
/**
|
|
93
|
+
* After a choice (the need, an estimate's answer) the focus moves to the
|
|
94
|
+
* next empty field, and Enter in a field moves to the next empty one before
|
|
95
|
+
* it submits. Always so in `steps`; off by default on one screen.
|
|
96
|
+
*/
|
|
97
|
+
focusNext?: boolean | undefined;
|
|
98
|
+
/** By estimate input id: how a question is asked — `cards` with the total each answer makes, an "I don't know", badges. */
|
|
99
|
+
questions?: EstimateQuestions | undefined;
|
|
100
|
+
/** `box` (default): the price box. `compact`: one line, a tax-credit line with `taxCredit`, the breakdown behind "Détail". */
|
|
101
|
+
price?: "box" | "compact" | undefined;
|
|
102
|
+
/** `price="compact"`: the share of the price a tax credit gives back (0.5 for half), shown as what is left to pay. */
|
|
103
|
+
taxCredit?: number | undefined;
|
|
76
104
|
locality?: "required" | "optional" | undefined;
|
|
77
105
|
/** A name field, posted as the brand's extra `field`. Off by default: every field costs leads. */
|
|
78
106
|
name?: {
|
|
@@ -81,6 +109,12 @@ export interface LeadCaptureProps {
|
|
|
81
109
|
} | undefined;
|
|
82
110
|
/** The brand's extra fields, after the phone — never before it. */
|
|
83
111
|
extras?: ReactNode;
|
|
112
|
+
/** Right under the phone field: one short line of reassurance ("Votre numéro reste entre nous"). */
|
|
113
|
+
afterPhone?: ReactNode;
|
|
114
|
+
/** The other ways out: `stack` (default), under a heading, or `row`, one row of compact buttons. */
|
|
115
|
+
channelsDisplay?: "stack" | "row" | undefined;
|
|
116
|
+
/** The brand's icon before a channel's label, by channel. */
|
|
117
|
+
channelIcons?: Partial<Record<ChannelIconKey, ReactNode>> | undefined;
|
|
84
118
|
/** Moved first when available — a `default_channel` experiment's arm. */
|
|
85
119
|
prefer?: CaptureChannel | undefined;
|
|
86
120
|
/** The site's assignment: on every event, and posted with the form. Slugs only. */
|
|
@@ -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,
|
|
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
|
|
59
|
-
const estimate = useEstimate(model, shownNeed,
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
|
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
|
-
|
|
108
|
-
//
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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 (
|
|
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: [
|
|
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
|