@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,19 +1,45 @@
1
1
  "use client";
2
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
3
  import { cn } from "@evinvest/uikit";
4
4
  import { useEffect, useMemo, useState } from "react";
5
5
  import { formatCents } from "../core/lead-capture-format.js";
6
- import { estimateField } from "../core/pricing/flow.js";
6
+ import { ESTIMATE_UNKNOWN, estimateField } from "../core/pricing/flow.js";
7
7
  import { priceOf } from "../core/pricing/price.js";
8
8
  import { labelOf } from "../core/pricing/validate.js";
9
+ import { TileGroup } from "./LeadCaptureTiles.js";
10
+ /**
11
+ * The screens an estimate's questions take in `steps`, in order: one each,
12
+ * or those sharing a `step` number together, where the first of them stands.
13
+ */
14
+ export function screensOf(inputs, questions) {
15
+ const out = [];
16
+ const byStep = new Map();
17
+ for (const id of inputs) {
18
+ const step = questions?.[id]?.step;
19
+ const shared = step === undefined ? undefined : byStep.get(step);
20
+ if (shared) {
21
+ shared.push(id);
22
+ continue;
23
+ }
24
+ const screen = [id];
25
+ if (step !== undefined)
26
+ byStep.set(step, screen);
27
+ out.push(screen);
28
+ }
29
+ return out;
30
+ }
31
+ const isUnknown = (answers, ids) => ids.some(id => answers[id] === ESTIMATE_UNKNOWN);
9
32
  /**
10
33
  * The live price of the need on screen: its answers, as the visitor taps
11
34
  * them, and the price `priceOf` makes of them — the same function the server
12
35
  * stores the lead's price with. A full set of answers is reported once per
13
- * band (`onShown`), never the price itself.
36
+ * band (`onShown`), never the price itself. An answer "I don't know" makes
37
+ * the need a `quote` for this lead (`flow`), as the server will.
14
38
  */
15
- export function useEstimate(model, need, flow, onShown) {
39
+ export function useEstimate(model, need, wanted, onShown) {
16
40
  const [answers, setAnswers] = useState({});
41
+ const pricing = model && need !== undefined && Object.hasOwn(model.needs, need) ? model.needs[need] : undefined;
42
+ const flow = wanted === "estimate" && pricing?.kind === "estimate" && isUnknown(answers, pricing.inputs) ? "quote" : wanted;
17
43
  const price = useMemo(() => (flow === "quote" || !model || need === undefined ? null : priceOf(model, need, flow === "estimate" ? answers : {})), [model, need, flow, answers]);
18
44
  const cents = flow === "estimate" ? price?.cents : undefined;
19
45
  useEffect(() => {
@@ -21,49 +47,63 @@ export function useEstimate(model, need, flow, onShown) {
21
47
  onShown(need, cents);
22
48
  }, [need, cents, onShown]);
23
49
  const answer = (input, option) => setAnswers(prev => ({ ...prev, [input]: option }));
24
- return { answers, answer, price };
50
+ return { answers, answer, price, flow };
51
+ }
52
+ /** Every question a need's estimate asks, by input id, in order: none for a need not priced so. */
53
+ export function estimatePlan(model, need) {
54
+ const pricing = Object.hasOwn(model.needs, need) ? model.needs[need] : undefined;
55
+ return pricing?.kind === "estimate" ? pricing.inputs : [];
25
56
  }
26
57
  /**
27
- * An estimate's questions, a tile per answer — touch-sized radios, so the
28
- * answers post without a script too (the server prices them; the live price
29
- * needs the script). Required once the script runs: a price needs every
30
- * answer, and without the script an unanswered estimate is still a lead, a
31
- * quote.
58
+ * The questions an estimate asks now, in order: all of them, or — once one
59
+ * is answered "I don't know" — up to that one. The rest would price nothing.
32
60
  */
33
- export function EstimateInputs(props) {
34
- const { model, need, locale, answers, classNames: c } = props;
35
- const pricing = model.needs[need];
61
+ export function askedInputs(model, need, answers) {
62
+ const pricing = Object.hasOwn(model.needs, need) ? model.needs[need] : undefined;
36
63
  if (pricing?.kind !== "estimate")
37
- return null;
38
- const inputs = pricing.inputs.map(id => model.inputs.find(i => i.id === id)).filter((i) => i !== undefined);
39
- return (_jsx("div", { className: cn("flex flex-col gap-5", c?.estimate), children: inputs.map(input => (_jsxs("fieldset", { className: cn("flex flex-col gap-2", c?.estimateInput), children: [_jsx("legend", { className: cn("mb-2 text-sm font-medium text-ink", c?.estimateLegend), children: labelOf(input.labels, locale) }), _jsx("div", { className: cn("grid grid-cols-2 gap-2 sm:grid-cols-3", c?.estimateGrid), children: input.options.map(option => (_jsxs("label", { className: "relative block cursor-pointer", children: [_jsx("input", { type: "radio", name: estimateField(input.id), value: option.id, required: props.required, checked: answers[input.id] === option.id, onChange: () => props.onAnswer(input.id, option.id), className: "peer absolute inset-0 opacity-0", "data-lead-field": estimateField(input.id) }), _jsx("span", { className: cn("flex h-full min-h-11 items-center rounded-[var(--control-radius)] border border-input px-3 py-2 text-ink transition-colors", "peer-checked:border-primary peer-checked:bg-hover peer-focus-visible:ring-[3px] peer-focus-visible:ring-ring/50", c?.estimateOption), children: labelOf(option.labels, locale) })] }, option.id))) })] }, input.id))) }));
40
- }
41
- function lineLabel(line, model, locale, text) {
42
- switch (line.kind) {
43
- case "base":
44
- return text.priceBase;
45
- case "rounding":
46
- return text.priceRounding;
47
- case "minimum":
48
- return text.priceMinimum;
49
- case "add":
50
- case "multiply":
51
- case "discount": {
52
- // The answer names itself ("2 chambres", "Toutes les 2 semaines"); the question would only repeat it.
53
- const option = model.inputs.find(i => i.id === line.input)?.options.find(o => o.id === line.option);
54
- return option ? labelOf(option.labels, locale) : line.option;
55
- }
64
+ return [];
65
+ const out = [];
66
+ for (const id of pricing.inputs) {
67
+ const input = model.inputs.find(i => i.id === id);
68
+ if (!input)
69
+ continue;
70
+ out.push(input);
71
+ if (answers[id] === ESTIMATE_UNKNOWN)
72
+ break;
56
73
  }
74
+ return out;
57
75
  }
58
76
  /**
59
- * The price, read out as it changes (`aria-live`): an estimate's total and
60
- * how it was reached, or — until every question is answered — what is
61
- * missing; a fixed need's price, as is. Never "from": it is the price for
62
- * these answers.
77
+ * One question, a tile per answer — touch-sized radios, so the answers post
78
+ * without a script too (the server prices them; the live price needs the
79
+ * script). As `cards`, each answer shows the total it would make with the
80
+ * others given, once they are.
63
81
  */
64
- export function PriceBox(props) {
65
- const { model, flow, price, locale, text, classNames: c } = props;
66
- // A line that changed nothing (an answer worth 0) is not worth reading out.
67
- const lines = price?.breakdown.filter((line, i) => i === 0 || line.cents !== 0) ?? [];
68
- return (_jsxs("div", { "aria-live": "polite", "aria-atomic": "true", className: cn("flex flex-col gap-2 rounded-[var(--control-radius)] border border-border bg-card p-4", c?.price), children: [_jsx("p", { className: "text-sm text-ink-soft", children: text.priceTitle }), price ? (_jsx("p", { className: cn("font-display text-3xl font-bold text-ink", c?.priceTotal), "data-price-cents": price.cents, children: formatCents(price.cents, locale) })) : (_jsx("p", { className: "text-ink", children: text.pricePending })), flow === "estimate" && lines.length > 1 && (_jsx("ul", { className: cn("flex flex-col gap-1 text-sm text-ink-soft", c?.breakdown), children: lines.map((line, i) => (_jsxs("li", { className: "flex justify-between gap-3", children: [_jsx("span", { children: lineLabel(line, model, locale, text) }), _jsx("span", { className: "tabular-nums", children: formatCents(line.cents, locale, i > 0) })] }, i))) })), _jsx("p", { className: cn("text-sm text-ink-soft", c?.priceNote), children: text.priceNote })] }));
82
+ export function EstimateQuestionField(props) {
83
+ const { model, need, input, question, locale, answers, classNames: c } = props;
84
+ const cards = question?.display === "cards";
85
+ const totalWith = (option) => {
86
+ const price = cards ? priceOf(model, need, { ...answers, [input.id]: option }) : null;
87
+ return price ? formatCents(price.cents, locale) : null;
88
+ };
89
+ const options = input.options.map(o => ({ value: o.id, label: labelOf(o.labels, locale), shortLabel: question?.shortLabels?.[o.id], badge: question?.badges?.[o.id], aside: totalWith(o.id) }));
90
+ if (question?.unknown)
91
+ options.push({ value: ESTIMATE_UNKNOWN, label: props.unknownLabel, span: question.unknownSpan === 2 ? 2 : undefined, className: c?.estimateUnknown });
92
+ return (_jsx(TileGroup, { name: estimateField(input.id), legend: labelOf(input.labels, locale), options: options, value: answers[input.id], required: props.required, field: estimateField(input.id), enterPicks: props.enterPicks, onPick: (option, radio) => props.onPick(input.id, option, radio), onSelect: option => props.onSelect(input.id, option), classNames: {
93
+ field: c?.estimateInput,
94
+ legend: c?.estimateLegend,
95
+ // The column count is the brand's (`estimateGrid`): a denser grid makes a shorter card, and the tile keeps its touch height in any.
96
+ grid: cn(cards ? "grid grid-cols-1 gap-2" : "grid grid-cols-2 gap-2 sm:grid-cols-3", c?.estimateGrid),
97
+ tile: cn(cards ? "h-full gap-3 px-4 py-3" : "h-full px-3 py-2", c?.estimateOption),
98
+ badge: c?.badge,
99
+ aside: c?.optionPrice,
100
+ } }));
101
+ }
102
+ /** An estimate's questions on one screen: `EstimateQuestionField` each, in order. */
103
+ export function EstimateInputs(props) {
104
+ const { model, need, answers, questions, classNames: c, ...rest } = props;
105
+ const inputs = askedInputs(model, need, answers);
106
+ if (inputs.length === 0)
107
+ return null;
108
+ return (_jsx("div", { className: cn("flex flex-col gap-5", c?.estimate), children: inputs.map(input => (_jsx(EstimateQuestionField, { model: model, need: need, input: input, question: questions?.[input.id], answers: answers, classNames: c, ...rest }, input.id))) }));
69
109
  }
@@ -14,6 +14,8 @@ export declare function LocalityField(props: {
14
14
  /** The server's refusal of this field, in the page's words. */
15
15
  error: string | null;
16
16
  hydrated: boolean;
17
+ /** The postcode the page already knows (`?postcode=`), over the one the place fills in. */
18
+ preset?: string | undefined;
17
19
  classNames?: PartClassNames<FieldPart> | undefined;
18
20
  }): import("react").JSX.Element;
19
21
  export type PhoneText = Pick<LeadCaptureText, "phoneLabel" | "phoneHint" | "phoneInvalid" | "required" | "phonePlaceholder">;
@@ -35,7 +35,7 @@ export function LocalityField(props) {
35
35
  // keypad has no letters, so the keypad follows what the field is offered.
36
36
  // A list too long for chips offers nothing — the visitor types a postcode.
37
37
  const numeric = served.length > MAX_CHIPS || served.every(s => POSTCODE.test(s));
38
- return (_jsxs(Field, { className: cn("flex flex-col gap-2", c?.field), children: [_jsx(FieldLabel, { className: c?.label, children: optionalLabel(label, props.required ? null : optional) }), _jsx(Input, { ref: validity.ref, name: name, size: "lg", inputMode: numeric ? "numeric" : "text", autoComplete: "postal-code", placeholder: props.placeholder, enterKeyHint: "next", required: props.required, defaultValue: served.length === 1 ? served[0] : undefined, "data-lead-field": "locality", "aria-invalid": error ? true : undefined, "aria-describedby": error ? messageId : undefined, onInvalid: validity.onInvalid, onChange: validity.onChange, className: c?.control }), chips && (_jsx("div", { role: "group", "aria-label": servedLabel, className: cn("flex flex-wrap gap-2", c?.chips), children: served.map(commune => (_jsx(Button, { type: "button", variant: "outline", size: "touch", className: cn("px-3", c?.chip), onClick: () => {
38
+ return (_jsxs(Field, { className: cn("flex flex-col gap-2", c?.field), children: [_jsx(FieldLabel, { className: c?.label, children: optionalLabel(label, props.required ? null : optional) }), _jsx(Input, { ref: validity.ref, name: name, size: "lg", inputMode: numeric ? "numeric" : "text", autoComplete: "postal-code", placeholder: props.placeholder, enterKeyHint: "next", required: props.required, defaultValue: props.preset ?? (served.length === 1 ? served[0] : undefined), "data-lead-field": "locality", "aria-invalid": error ? true : undefined, "aria-describedby": error ? messageId : undefined, onInvalid: validity.onInvalid, onChange: validity.onChange, className: c?.control }), chips && (_jsx("div", { role: "group", "aria-label": servedLabel, className: cn("flex flex-wrap gap-2", c?.chips), children: served.map(commune => (_jsx(Button, { type: "button", variant: "outline", size: "touch", className: cn("px-3", c?.chip), onClick: () => {
39
39
  const el = validity.ref.current;
40
40
  if (!el)
41
41
  return;
@@ -1,11 +1,29 @@
1
+ import type { ReactNode } from "react";
1
2
  import { type FormSelectOption } from "./FormSelect.js";
2
3
  import type { PartClassNames } from "./parts.js";
3
- export type LeadCaptureLayout = "single" | "qualify-first";
4
- type NeedPart = "field" | "label" | "control" | "need" | "needs" | "summary";
4
+ /**
5
+ * `single`: one screen. `steps`: one question per screen. `qualify-first`:
6
+ * the two-screen form `steps` grew from — the need, then everything else —
7
+ * kept as it was for the sites that run it.
8
+ */
9
+ export type LeadCaptureLayout = "single" | "steps" | "qualify-first";
10
+ /** How the need is asked: the kit's select, a tile per need, or a card per need with the brand's icon. */
11
+ export type LeadNeedDisplay = "select" | "tiles" | "cards";
12
+ /** A need as the form offers it; `icon` is drawn on its card (`needDisplay="cards"`), the brand's own. */
13
+ export interface LeadNeedOption extends FormSelectOption {
14
+ icon?: ReactNode;
15
+ /** On a tile or card, on a phone (under `sm`): a shorter label; the select and assistive technology keep `label`. */
16
+ shortLabel?: string | undefined;
17
+ }
18
+ type NeedPart = "field" | "label" | "control" | "need" | "needs" | "summary" | "icon";
19
+ /** The need's tiles or cards: a list, or a grid of cards with an icon each. */
20
+ export declare const needGrid: (display: LeadNeedDisplay) => "grid grid-cols-2 gap-2 sm:grid-cols-3" | "grid grid-cols-1 gap-2 sm:grid-cols-2";
21
+ export declare const needTile: (display: LeadNeedDisplay) => "h-full flex-col justify-center gap-2 px-3 py-4 text-center" | "px-4 py-3";
5
22
  export interface NeedFieldProps {
6
- layout: LeadCaptureLayout;
23
+ /** The select on one screen by default, tiles on two. */
24
+ display: LeadNeedDisplay;
7
25
  name: string;
8
- needs: readonly FormSelectOption[];
26
+ needs: readonly LeadNeedOption[];
9
27
  need: string | undefined;
10
28
  /** Showing the choice again after it was made for the visitor. */
11
29
  editing: boolean;
@@ -14,8 +32,10 @@ export interface NeedFieldProps {
14
32
  changeLabel: string;
15
33
  /** Blocks the submit while no tile is chosen, in the page's words. */
16
34
  requiredText: string;
35
+ /** Enter on a tile answers it rather than submitting the form. */
36
+ enterPicks: boolean;
17
37
  /** The visitor's answer: a tap, Enter or Space — `qualify-first` moves on. */
18
- onPick: (need: string) => void;
38
+ onPick: (need: string, from: HTMLElement | null) => void;
19
39
  /** An arrow key moved the choice: chosen, but the visitor is still choosing. */
20
40
  onSelect: (need: string) => void;
21
41
  onEdit: () => void;
@@ -26,7 +46,8 @@ export interface NeedFieldProps {
26
46
  * tapped — is not asked again: it is a line with its label and, once the
27
47
  * script runs, a way to change it. Otherwise `single` asks with the kit's
28
48
  * select (first need by default, as before) and `qualify-first` with a tile
29
- * per need: one tap answers it.
49
+ * per need: one tap answers it. `display` may ask either with tiles, or with
50
+ * cards that carry the brand's icon.
30
51
  */
31
52
  export declare function NeedField(props: NeedFieldProps): import("react").JSX.Element;
32
53
  export {};
@@ -1,58 +1,27 @@
1
1
  "use client";
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { Button, cn, Field, FieldLabel } from "@evinvest/uikit";
4
- import { useEffect, useRef } from "react";
5
4
  import { FormSelect } from "./FormSelect.js";
5
+ import { TileGroup } from "./LeadCaptureTiles.js";
6
+ /** The need's tiles or cards: a list, or a grid of cards with an icon each. */
7
+ export const needGrid = (display) => (display === "cards" ? "grid grid-cols-2 gap-2 sm:grid-cols-3" : "grid grid-cols-1 gap-2 sm:grid-cols-2");
8
+ export const needTile = (display) => (display === "cards" ? "h-full flex-col justify-center gap-2 px-3 py-4 text-center" : "px-4 py-3");
6
9
  /**
7
10
  * What the job is. A need already known — from the page, `?need=` or the card
8
11
  * tapped — is not asked again: it is a line with its label and, once the
9
12
  * script runs, a way to change it. Otherwise `single` asks with the kit's
10
13
  * select (first need by default, as before) and `qualify-first` with a tile
11
- * per need: one tap answers it.
14
+ * per need: one tap answers it. `display` may ask either with tiles, or with
15
+ * cards that carry the brand's icon.
12
16
  */
13
17
  export function NeedField(props) {
14
- const { layout, name, needs, need, editing, hydrated, label, changeLabel, onPick, onSelect, onEdit, classNames: c } = props;
15
- const group = useRef(null);
16
- // A radio group answers arrows with a click (`detail` 0) and a change, the
17
- // same events a tap makes: what moves on is told apart by its key or pointer.
18
- const pointer = useRef(false);
19
- const answered = useRef(false);
20
- useEffect(() => {
21
- for (const radio of group.current?.querySelectorAll("input[type=radio]") ?? [])
22
- radio.setCustomValidity(need === undefined ? props.requiredText : "");
23
- });
18
+ const { display, name, needs, need, editing, hydrated, label, changeLabel, onPick, onSelect, onEdit, classNames: c } = props;
24
19
  const chosen = needs.find(n => n.value === need);
25
20
  if (chosen && !editing) {
26
21
  return (_jsxs("div", { className: cn("flex flex-wrap items-center gap-x-3 gap-y-1 text-ink", c?.summary), children: [_jsx("input", { type: "hidden", name: name, value: chosen.value }), _jsx("span", { className: "text-sm text-ink-soft", children: label }), _jsx("span", { className: "font-medium", children: chosen.label }), hydrated && (_jsx(Button, { type: "button", variant: "link", size: "touch", className: "px-1", onClick: onEdit, children: changeLabel }))] }));
27
22
  }
28
- if (layout === "single") {
29
- return (_jsxs(Field, { className: cn("flex flex-col gap-2", c?.field), children: [_jsx(FieldLabel, { className: c?.label, children: label }), _jsx(FormSelect, { name: name, size: "lg", defaultValue: need ?? needs[0]?.value, options: needs, onValueChange: onPick, classNames: c?.control ? { trigger: c.control } : undefined })] }));
23
+ if (display === "select") {
24
+ return (_jsxs(Field, { className: cn("flex flex-col gap-2", c?.field), children: [_jsx(FieldLabel, { className: c?.label, children: label }), _jsx(FormSelect, { name: name, size: "lg", defaultValue: need ?? needs[0]?.value, options: needs, onValueChange: v => onPick(v, null), classNames: c?.control ? { trigger: c.control } : undefined })] }));
30
25
  }
31
- return (_jsxs("fieldset", { ref: group, className: cn("flex flex-col gap-2", c?.field), children: [_jsx("legend", { className: cn("mb-2 text-sm font-medium text-ink", c?.label), children: label }), _jsx("div", { className: cn("grid grid-cols-1 gap-2 sm:grid-cols-2", c?.needs), children: needs.map(n => (_jsxs("label", { className: "relative block cursor-pointer", children: [_jsx("input", { type: "radio", name: name, value: n.value, required: true, checked: need === n.value, onPointerDown: () => {
32
- pointer.current = true;
33
- }, onKeyDown: (e) => {
34
- pointer.current = false;
35
- answered.current = false;
36
- if (e.key !== "Enter")
37
- return;
38
- // From a radio, Enter would submit the form half filled.
39
- e.preventDefault();
40
- answered.current = true;
41
- onPick(n.value);
42
- }, onKeyUp: (e) => {
43
- if (e.key !== " ")
44
- return;
45
- answered.current = true;
46
- onPick(n.value);
47
- }, onClick: (e) => {
48
- if (e.detail === 0 && !pointer.current)
49
- return;
50
- pointer.current = false;
51
- answered.current = true;
52
- onPick(n.value);
53
- }, onChange: () => {
54
- if (!answered.current)
55
- onSelect(n.value);
56
- answered.current = false;
57
- }, className: "peer absolute inset-0 opacity-0", "data-need-option": "", "data-lead-field": "need" }), _jsx("span", { className: cn("flex min-h-11 items-center rounded-[var(--control-radius)] border border-input px-4 py-3 text-ink transition-colors", "peer-checked:border-primary peer-checked:bg-hover peer-focus-visible:ring-[3px] peer-focus-visible:ring-ring/50", c?.need), children: n.label })] }, n.value))) })] }));
26
+ return (_jsx(TileGroup, { name: name, legend: label, options: needs.map(n => ({ value: n.value, label: n.label, shortLabel: n.shortLabel, ...(display === "cards" && n.icon !== undefined ? { icon: n.icon } : {}) })), value: need, required: true, requiredText: props.requiredText, field: "need", marker: "data-need-option", enterPicks: props.enterPicks, onPick: onPick, onSelect: onSelect, classNames: { field: c?.field, legend: c?.label, grid: cn(needGrid(display), c?.needs), tile: cn(needTile(display), c?.need), icon: c?.icon } }));
58
27
  }
@@ -0,0 +1,36 @@
1
+ import type { LeadCaptureFlowText } from "../core/lead-capture-text.js";
2
+ import type { LeadFlow } from "../core/pricing/flow.js";
3
+ import type { Price, PricingModel } from "../core/pricing/model.js";
4
+ import type { PartClassNames } from "./parts.js";
5
+ export type PricePart = "price" | "priceTotal" | "breakdown" | "priceNote" | "priceLine" | "priceTaxCredit" | "priceDetail";
6
+ /**
7
+ * The price, read out as it changes (`aria-live`): an estimate's total and
8
+ * how it was reached, or — until every question is answered — what is
9
+ * missing; a fixed need's price, as is. Never "from": it is the price for
10
+ * these answers.
11
+ */
12
+ export declare function PriceBox(props: {
13
+ model: PricingModel;
14
+ flow: Exclude<LeadFlow, "quote">;
15
+ price: Price | null;
16
+ locale: string;
17
+ text: LeadCaptureFlowText;
18
+ classNames?: PartClassNames<PricePart> | undefined;
19
+ }): import("react").JSX.Element;
20
+ /** What is left to pay once a tax credit of `ratio` (0.5 for half) is taken off, to the cent. */
21
+ export declare const afterCredit: (cents: number, ratio: number) => number;
22
+ /**
23
+ * `price="compact"`: the price on one line (`priceLine`), what is left after
24
+ * the brand's tax credit when it has one (`taxCredit`, a ratio), and how it
25
+ * was reached behind a disclosure — a `<details>`, so it opens without a
26
+ * script. Read out as it changes, the line only.
27
+ */
28
+ export declare function PriceCompact(props: {
29
+ model: PricingModel;
30
+ flow: Exclude<LeadFlow, "quote">;
31
+ price: Price | null;
32
+ locale: string;
33
+ taxCredit: number | undefined;
34
+ text: LeadCaptureFlowText;
35
+ classNames?: PartClassNames<PricePart> | undefined;
36
+ }): import("react").JSX.Element;
@@ -0,0 +1,47 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ import { cn } from "@evinvest/uikit";
3
+ import { fillText, formatCents } from "../core/lead-capture-format.js";
4
+ import { labelOf } from "../core/pricing/validate.js";
5
+ function lineLabel(line, model, locale, text) {
6
+ switch (line.kind) {
7
+ case "base":
8
+ return text.priceBase;
9
+ case "rounding":
10
+ return text.priceRounding;
11
+ case "minimum":
12
+ return text.priceMinimum;
13
+ case "add":
14
+ case "multiply":
15
+ case "discount": {
16
+ // The answer names itself ("2 chambres", "Toutes les 2 semaines"); the question would only repeat it.
17
+ const option = model.inputs.find(i => i.id === line.input)?.options.find(o => o.id === line.option);
18
+ return option ? labelOf(option.labels, locale) : line.option;
19
+ }
20
+ }
21
+ }
22
+ /**
23
+ * The price, read out as it changes (`aria-live`): an estimate's total and
24
+ * how it was reached, or — until every question is answered — what is
25
+ * missing; a fixed need's price, as is. Never "from": it is the price for
26
+ * these answers.
27
+ */
28
+ export function PriceBox(props) {
29
+ const { model, flow, price, locale, text, classNames: c } = props;
30
+ // A line that changed nothing (an answer worth 0) is not worth reading out.
31
+ const lines = price?.breakdown.filter((line, i) => i === 0 || line.cents !== 0) ?? [];
32
+ return (_jsxs("div", { "aria-live": "polite", "aria-atomic": "true", className: cn("flex flex-col gap-2 rounded-[var(--control-radius)] border border-border bg-card p-4", c?.price), children: [_jsx("p", { className: "text-sm text-ink-soft", children: text.priceTitle }), price ? (_jsx("p", { className: cn("font-display text-3xl font-bold text-ink", c?.priceTotal), "data-price-cents": price.cents, children: formatCents(price.cents, locale) })) : (_jsx("p", { className: "text-ink", children: text.pricePending })), flow === "estimate" && lines.length > 1 && (_jsx("ul", { className: cn("flex flex-col gap-1 text-sm text-ink-soft", c?.breakdown), children: lines.map((line, i) => (_jsxs("li", { className: "flex justify-between gap-3", children: [_jsx("span", { children: lineLabel(line, model, locale, text) }), _jsx("span", { className: "tabular-nums", children: formatCents(line.cents, locale, i > 0) })] }, i))) })), _jsx("p", { className: cn("text-sm text-ink-soft", c?.priceNote), children: text.priceNote })] }));
33
+ }
34
+ /** What is left to pay once a tax credit of `ratio` (0.5 for half) is taken off, to the cent. */
35
+ export const afterCredit = (cents, ratio) => cents - Math.round(cents * ratio);
36
+ /**
37
+ * `price="compact"`: the price on one line (`priceLine`), what is left after
38
+ * the brand's tax credit when it has one (`taxCredit`, a ratio), and how it
39
+ * was reached behind a disclosure — a `<details>`, so it opens without a
40
+ * script. Read out as it changes, the line only.
41
+ */
42
+ export function PriceCompact(props) {
43
+ const { model, flow, price, locale, taxCredit, text, classNames: c } = props;
44
+ const lines = price?.breakdown.filter((line, i) => i === 0 || line.cents !== 0) ?? [];
45
+ const credit = price && taxCredit !== undefined && taxCredit > 0 && taxCredit < 1 ? afterCredit(price.cents, taxCredit) : null;
46
+ return (_jsxs("div", { className: cn("flex flex-col gap-1", c?.price), children: [_jsx("p", { "aria-live": "polite", "aria-atomic": "true", className: cn("flex flex-wrap items-baseline gap-x-2 text-ink", c?.priceLine), children: price ? (_jsxs(_Fragment, { children: [_jsx("span", { className: cn("font-semibold", c?.priceTotal), "data-price-cents": price.cents, children: fillText(text.priceLine, { price: formatCents(price.cents, locale) }) }), credit !== null && (_jsxs("span", { className: cn("text-sm text-ink-soft", c?.priceTaxCredit), "data-credit-cents": credit, children: [_jsx("span", { "aria-hidden": "true", children: "\u00B7 " }), fillText(text.priceTaxCredit, { price: formatCents(credit, locale) })] }))] })) : (_jsx("span", { className: "text-sm text-ink-soft", children: text.pricePending })) }), flow === "estimate" && lines.length > 1 && (_jsxs("details", { className: cn("text-sm text-ink-soft", c?.priceDetail), children: [_jsx("summary", { className: "inline-flex min-h-11 cursor-pointer items-center underline underline-offset-4", children: text.priceDetail }), _jsx("ul", { className: cn("flex flex-col gap-1 pb-2", c?.breakdown), children: lines.map((line, i) => (_jsxs("li", { className: "flex justify-between gap-3", children: [_jsx("span", { children: lineLabel(line, model, locale, text) }), _jsx("span", { className: "tabular-nums", children: formatCents(line.cents, locale, i > 0) })] }, i))) }), _jsx("p", { className: c?.priceNote, children: text.priceNote })] }))] }));
47
+ }
@@ -0,0 +1,47 @@
1
+ import { type ReactNode } from "react";
2
+ import type { LeadIntro, StepId } from "./lead-steps.js";
3
+ import type { PartClassNames } from "./parts.js";
4
+ export type StepsPart = "progress" | "stepBack" | "step" | "answers" | "answer" | "intro" | "introOption" | "icon" | "label";
5
+ /** One screen: what it asks (the chip's label), what was answered (`null` while open), and the screen. */
6
+ export interface StepView {
7
+ id: StepId;
8
+ label: string;
9
+ value: string | null;
10
+ node: ReactNode;
11
+ }
12
+ /** The mark of a screen that is off, for a selector of the focus's. */
13
+ export declare const STEP_OFF = "data-lead-off";
14
+ /**
15
+ * `layout="steps"`: one question per screen. Over it a thin bar (the screen,
16
+ * read out as `stepProgress`), the way back, and the screens already
17
+ * answered as chips — each a tap back to it. Every screen stays in the form,
18
+ * hidden but posted, so what was answered three screens ago still goes with
19
+ * the lead.
20
+ */
21
+ export declare function LeadSteps(props: {
22
+ steps: readonly StepView[];
23
+ current: StepId;
24
+ /**
25
+ * How many screens the bar counts: at least those known, more while an
26
+ * answer to come may add some — so it only ever moves forward as the
27
+ * visitor answers. `null`: not said yet (the intro decides the branch).
28
+ */
29
+ total: number | null;
30
+ onBack: () => void;
31
+ onEdit: (id: StepId) => void;
32
+ text: {
33
+ stepProgress: string;
34
+ stepProgressOpen: string;
35
+ stepBack: string;
36
+ change: string;
37
+ };
38
+ classNames?: PartClassNames<StepsPart> | undefined;
39
+ }): import("react").JSX.Element;
40
+ /** The intro question: a tile per answer, the brand's icon on it, posted under its `field`. Not required: a lead without it is still a lead. */
41
+ export declare function IntroField(props: {
42
+ intro: LeadIntro;
43
+ value: string | undefined;
44
+ onPick: (value: string, radio: HTMLInputElement) => void;
45
+ onSelect: (value: string) => void;
46
+ classNames?: PartClassNames<StepsPart> | undefined;
47
+ }): import("react").JSX.Element;
@@ -0,0 +1,39 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
+ import { Button, cn, Progress } from "@evinvest/uikit";
4
+ import { Fragment } from "react";
5
+ import { fillText } from "../core/lead-capture-format.js";
6
+ import { TileGroup } from "./LeadCaptureTiles.js";
7
+ /**
8
+ * Without a script every screen is there at once — the form posts as one —
9
+ * and the bar, the back link and the chips, which need one, are not.
10
+ * Unscoped on purpose: the attributes are the steps' own. A screen off is
11
+ * hidden by an inline style, not the `hidden` attribute: Tailwind's preflight
12
+ * hides `[hidden]` with `!important` in a layer, which no unlayered rule
13
+ * overrides, and an inline style yields to this one.
14
+ */
15
+ const NO_SCRIPT = "<style>[data-lead-step]{display:flex!important}[data-lead-chrome]{display:none!important}</style>";
16
+ /** The mark of a screen that is off, for a selector of the focus's. */
17
+ export const STEP_OFF = "data-lead-off";
18
+ const OFF = { display: "none" };
19
+ /**
20
+ * `layout="steps"`: one question per screen. Over it a thin bar (the screen,
21
+ * read out as `stepProgress`), the way back, and the screens already
22
+ * answered as chips — each a tap back to it. Every screen stays in the form,
23
+ * hidden but posted, so what was answered three screens ago still goes with
24
+ * the lead.
25
+ */
26
+ export function LeadSteps(props) {
27
+ const { steps, current, text, classNames: c } = props;
28
+ const index = Math.max(0, steps.findIndex(s => s.id === current));
29
+ const n = String(index + 1);
30
+ const progress = props.total === null ? fillText(text.stepProgressOpen, { n }) : fillText(text.stepProgress, { n, total: String(props.total) });
31
+ const bar = Math.round(((index + 1) / Math.max(props.total ?? steps.length, steps.length)) * 100);
32
+ const answered = steps.slice(0, index).filter(s => s.value !== null);
33
+ return (_jsxs(_Fragment, { children: [_jsx("noscript", { dangerouslySetInnerHTML: { __html: NO_SCRIPT } }), _jsxs("div", { "data-lead-chrome": "", className: cn("flex flex-col gap-2", c?.progress), children: [_jsx(Progress, { value: bar, "aria-hidden": "true", className: "h-1" }), _jsx("p", { "aria-live": "polite", className: "sr-only", children: progress }), index > 0 && (_jsx(Button, { type: "button", variant: "link", size: "touch", className: cn("self-start px-0", c?.stepBack), onClick: props.onBack, children: text.stepBack }))] }), answered.length > 0 && (_jsx("ul", { "data-lead-chrome": "", className: cn("flex flex-wrap gap-2", c?.answers), children: answered.map(s => (_jsx("li", { children: _jsxs(Button, { type: "button", variant: "outline", size: "touch", className: cn("h-auto gap-2 px-3 text-sm", c?.answer), onClick: () => props.onEdit(s.id), children: [_jsx("span", { className: "sr-only", children: `${s.label}, ` }), _jsx("span", { className: "font-medium text-ink", children: s.value }), _jsx("span", { className: "text-ink-soft underline underline-offset-4", children: text.change })] }) }, s.id))) })), steps.map(s => (_jsx("div", { "data-lead-step": s.id, ...(s.id === current ? {} : { [STEP_OFF]: "", style: OFF }), className: cn("flex flex-col gap-5", c?.step), children: _jsx(Fragment, { children: s.node }, "screen") }, s.id)))] }));
34
+ }
35
+ /** The intro question: a tile per answer, the brand's icon on it, posted under its `field`. Not required: a lead without it is still a lead. */
36
+ export function IntroField(props) {
37
+ const { intro, classNames: c } = props;
38
+ return (_jsx(TileGroup, { name: intro.field, legend: intro.label, options: intro.options.map(o => ({ value: o.value, label: o.label, ...(o.icon !== undefined ? { icon: o.icon } : {}) })), value: props.value, required: false, field: intro.field, enterPicks: true, onPick: props.onPick, onSelect: props.onSelect, classNames: { field: c?.intro, legend: c?.label, grid: "grid grid-cols-1 gap-2", tile: cn("gap-3 px-4 py-3", c?.introOption), icon: c?.icon } }));
39
+ }
@@ -0,0 +1,53 @@
1
+ import { type ReactNode } from "react";
2
+ /** One answer as a tile: its words, and what a brand or the price adds to them. */
3
+ export interface TileOption {
4
+ value: string;
5
+ label: string;
6
+ /** Drawn instead of `label` on a phone (under `sm`); the radio is still named by the whole `label`. */
7
+ shortLabel?: string | undefined;
8
+ /** Two columns of the grid (an "I don't know" under a row of answers). */
9
+ span?: 2 | undefined;
10
+ /** More classes for this tile alone, after the group's. */
11
+ className?: string | undefined;
12
+ /** Above the label, the brand's (a need's icon). */
13
+ icon?: ReactNode;
14
+ /** A short tag beside the label ("Le plus choisi"). */
15
+ badge?: string | undefined;
16
+ /** At the end of the tile: what this answer would cost. */
17
+ aside?: string | null | undefined;
18
+ }
19
+ export interface TileGroupProps {
20
+ name: string;
21
+ legend: string;
22
+ options: readonly TileOption[];
23
+ value: string | undefined;
24
+ required: boolean;
25
+ /** Blocks the submit while nothing is chosen, in the page's words; none → the browser's own. */
26
+ requiredText?: string | undefined;
27
+ /** `data-lead-field` on each radio: the role events and the focus read. */
28
+ field: string;
29
+ /** A bare attribute on each radio, for a selector of the caller's (`data-need-option`). */
30
+ marker?: string | undefined;
31
+ /** Enter on a tile answers it, instead of submitting a form half filled. Off: Enter is the form's. */
32
+ enterPicks: boolean;
33
+ /** The visitor's answer — a tap, Enter or Space — with the radio it came from. */
34
+ onPick: (value: string, radio: HTMLInputElement) => void;
35
+ /** An arrow key moved the choice: chosen, but the visitor is still choosing. */
36
+ onSelect: (value: string) => void;
37
+ classNames: {
38
+ field?: string | undefined;
39
+ legend?: string | undefined;
40
+ grid?: string | undefined;
41
+ tile?: string | undefined;
42
+ icon?: string | undefined;
43
+ badge?: string | undefined;
44
+ aside?: string | undefined;
45
+ };
46
+ }
47
+ /**
48
+ * A question answered by a tap: touch-sized native radios, so the answer
49
+ * posts without a script, over a tile the brand dresses. A radio group
50
+ * answers arrows with a click (`detail` 0) and a change, the same events a
51
+ * tap makes: what moves on is told apart by its key or pointer.
52
+ */
53
+ export declare function TileGroup(props: TileGroupProps): import("react").JSX.Element;
@@ -0,0 +1,50 @@
1
+ "use client";
2
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { cn } from "@evinvest/uikit";
4
+ import { useEffect, useRef } from "react";
5
+ /**
6
+ * A question answered by a tap: touch-sized native radios, so the answer
7
+ * posts without a script, over a tile the brand dresses. A radio group
8
+ * answers arrows with a click (`detail` 0) and a change, the same events a
9
+ * tap makes: what moves on is told apart by its key or pointer.
10
+ */
11
+ export function TileGroup(props) {
12
+ const { name, legend, options, value, required, field, marker, enterPicks, onPick, onSelect, classNames: c } = props;
13
+ const group = useRef(null);
14
+ const pointer = useRef(false);
15
+ const answered = useRef(false);
16
+ const requiredText = props.requiredText;
17
+ useEffect(() => {
18
+ if (requiredText === undefined)
19
+ return;
20
+ for (const radio of group.current?.querySelectorAll("input[type=radio]") ?? [])
21
+ radio.setCustomValidity(value === undefined ? requiredText : "");
22
+ });
23
+ const markerProps = marker ? { [marker]: "" } : {};
24
+ return (_jsxs("fieldset", { ref: group, className: cn("flex flex-col gap-2", c.field), children: [_jsx("legend", { className: cn("mb-2 text-sm font-medium text-ink", c.legend), children: legend }), _jsx("div", { className: c.grid, children: options.map(o => (_jsxs("label", { className: cn("relative block cursor-pointer", o.span === 2 && "col-span-2"), children: [_jsx("input", { type: "radio", name: name, value: o.value, required: required, checked: value === o.value, onPointerDown: () => {
25
+ pointer.current = true;
26
+ }, onKeyDown: (e) => {
27
+ pointer.current = false;
28
+ answered.current = false;
29
+ if (e.key !== "Enter" || !enterPicks)
30
+ return;
31
+ e.preventDefault();
32
+ answered.current = true;
33
+ onPick(o.value, e.currentTarget);
34
+ }, onKeyUp: (e) => {
35
+ if (e.key !== " ")
36
+ return;
37
+ answered.current = true;
38
+ onPick(o.value, e.currentTarget);
39
+ }, onClick: (e) => {
40
+ if (e.detail === 0 && !pointer.current)
41
+ return;
42
+ pointer.current = false;
43
+ answered.current = true;
44
+ onPick(o.value, e.currentTarget);
45
+ }, onChange: () => {
46
+ if (!answered.current)
47
+ onSelect(o.value);
48
+ answered.current = false;
49
+ }, className: "peer absolute inset-0 opacity-0", "data-lead-field": field, "aria-label": o.shortLabel !== undefined ? [o.label, o.badge, o.aside].filter(Boolean).join(", ") : undefined, ...markerProps }), _jsxs("span", { className: cn("flex min-h-11 items-center rounded-[var(--control-radius)] border border-input text-ink transition-colors", "peer-checked:border-primary peer-checked:bg-hover peer-focus-visible:ring-[3px] peer-focus-visible:ring-ring/50", c.tile, o.className), children: [o.icon !== undefined && (_jsx("span", { "aria-hidden": "true", className: cn("flex shrink-0 items-center justify-center [&_svg]:size-6", c.icon), children: o.icon })), o.shortLabel !== undefined ? (_jsxs(_Fragment, { children: [_jsx("span", { "aria-hidden": "true", className: "min-w-0 sm:hidden", children: o.shortLabel }), _jsx("span", { className: "min-w-0 max-sm:hidden", children: o.label })] })) : (_jsx("span", { className: "min-w-0", children: o.label })), o.badge && _jsx("span", { className: cn("rounded-full bg-hover px-2 py-0.5 text-xs font-medium text-ink", c.badge), children: o.badge }), o.aside && _jsx("span", { className: cn("ms-auto shrink-0 font-medium tabular-nums", c.aside), children: o.aside })] })] }, o.value))) })] }));
50
+ }
@@ -1,4 +1,4 @@
1
- import type { FormEventHandler, ReactNode } from "react";
1
+ import type { FormEventHandler, KeyboardEventHandler, ReactNode } from "react";
2
2
  /**
3
3
  * The quote form's frame: a plain `<form method="post" action="/quote">`
4
4
  * answered with a 303, so it works before any JavaScript arrives — which is
@@ -27,6 +27,10 @@ export interface QuoteFormShellProps {
27
27
  className?: string;
28
28
  /** A script's own submit (an in-place success); the 303 stays the path without one. */
29
29
  onSubmit?: FormEventHandler<HTMLFormElement> | undefined;
30
+ /** A key in any field — `LeadCapture`'s Enter that moves to the next field. */
31
+ onKeyDown?: KeyboardEventHandler<HTMLFormElement> | undefined;
32
+ /** Bare `data-*` attributes on the form, for a stylesheet of the caller's. */
33
+ data?: Readonly<Record<`data-${string}`, string>> | undefined;
30
34
  children: ReactNode;
31
35
  }
32
36
  /** The mobile field's attributes: the numeric keypad and the phone's autofill. */