@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.
Files changed (71) hide show
  1. package/README.md +178 -15
  2. package/dist/core/ab-gate.d.ts +11 -0
  3. package/dist/core/ab-gate.js +19 -0
  4. package/dist/core/ab-switcher.d.ts +33 -0
  5. package/dist/core/ab-switcher.js +61 -0
  6. package/dist/core/accept.js +6 -2
  7. package/dist/core/analytics.d.ts +7 -2
  8. package/dist/core/lead-capture-text.d.ts +21 -1
  9. package/dist/core/lead-capture-text.js +16 -0
  10. package/dist/core/pricing/flow.d.ts +9 -0
  11. package/dist/core/pricing/flow.js +14 -0
  12. package/dist/core/pricing/index.d.ts +1 -1
  13. package/dist/core/pricing/index.js +1 -1
  14. package/dist/index.d.ts +3 -1
  15. package/dist/index.js +3 -1
  16. package/dist/react/AbSwitcher.d.ts +9 -0
  17. package/dist/react/AbSwitcher.js +22 -0
  18. package/dist/react/AbSwitcherPanel.d.ts +8 -0
  19. package/dist/react/AbSwitcherPanel.js +77 -0
  20. package/dist/react/FormSelect.d.ts +6 -0
  21. package/dist/react/FormSelect.js +5 -2
  22. package/dist/react/LeadCapture.d.ts +42 -8
  23. package/dist/react/LeadCapture.js +171 -26
  24. package/dist/react/LeadCaptureChannels.d.ts +8 -2
  25. package/dist/react/LeadCaptureChannels.js +8 -2
  26. package/dist/react/LeadCaptureEstimate.d.ts +59 -26
  27. package/dist/react/LeadCaptureEstimate.js +81 -41
  28. package/dist/react/LeadCaptureFields.d.ts +2 -0
  29. package/dist/react/LeadCaptureFields.js +1 -1
  30. package/dist/react/LeadCaptureNeed.d.ts +27 -6
  31. package/dist/react/LeadCaptureNeed.js +10 -41
  32. package/dist/react/LeadCapturePrice.d.ts +36 -0
  33. package/dist/react/LeadCapturePrice.js +47 -0
  34. package/dist/react/LeadCaptureSteps.d.ts +47 -0
  35. package/dist/react/LeadCaptureSteps.js +39 -0
  36. package/dist/react/LeadCaptureTiles.d.ts +53 -0
  37. package/dist/react/LeadCaptureTiles.js +50 -0
  38. package/dist/react/QuoteFormShell.d.ts +5 -1
  39. package/dist/react/QuoteFormShell.js +2 -2
  40. package/dist/react/ab-switcher-types.d.ts +39 -0
  41. package/dist/react/ab-switcher-types.js +1 -0
  42. package/dist/react/focus-next.d.ts +11 -0
  43. package/dist/react/focus-next.js +58 -0
  44. package/dist/react/form-select-value.d.ts +6 -0
  45. package/dist/react/form-select-value.js +54 -9
  46. package/dist/react/index.d.ts +11 -3
  47. package/dist/react/index.js +6 -2
  48. package/dist/react/lead-steps.d.ts +53 -0
  49. package/dist/react/lead-steps.js +58 -0
  50. package/dist/react/use-lead-context.d.ts +6 -0
  51. package/dist/react/use-lead-context.js +16 -0
  52. package/dist/react/use-lead-focus.d.ts +19 -0
  53. package/dist/react/use-lead-focus.js +70 -0
  54. package/dist/server/experiments-declared.d.ts +7 -3
  55. package/dist/server/experiments-declared.js +1 -7
  56. package/package.json +1 -1
  57. package/template/app/[locale]/[location]/layout.tsx +4 -1
  58. package/template/app/[locale]/[location]/prices/page.tsx +6 -1
  59. package/template/flake.nix +1 -1
  60. package/template/package.json +2 -2
  61. package/template/src/entities/content/model/en.ts +7 -0
  62. package/template/src/entities/content/model/fr.ts +7 -0
  63. package/template/src/entities/content/model/types.ts +8 -0
  64. package/template/src/shared/config/experiments.ts +21 -0
  65. package/template/src/shared/config/lead.ts +5 -1
  66. package/template/src/views/prices/index.ts +1 -0
  67. package/template/src/views/prices/ui/EstimateCard.tsx +66 -0
  68. package/template/tests/bundle_budget.txt +2 -1
  69. package/template/tests/e2e/ab-switcher.spec.ts +99 -0
  70. package/template/tests/e2e/screenshot-section.css +6 -0
  71. package/template/tests/e2e/steps.spec.ts +138 -0
@@ -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. */
@@ -6,6 +6,6 @@ import { CARD_FIELD, SUBMISSION_FIELD } from "../core/lead.js";
6
6
  /** The mobile field's attributes: the numeric keypad and the phone's autofill. */
7
7
  export const PHONE_INPUT_PROPS = { type: "tel", inputMode: "tel", autoComplete: "tel" };
8
8
  export function QuoteFormShell(props) {
9
- const { placeSlug, locale, renderedAt, honeypotLabel, formId = "quote", card, action = "/quote", id = "quote", className, onSubmit, children } = props;
10
- return (_jsxs("form", { id: id, method: "post", action: action, onSubmit: onSubmit, className: cn("relative flex w-full flex-col gap-5", className), children: [placeSlug && _jsx("input", { type: "hidden", name: LOCATION_FIELD, value: placeSlug }), _jsx("input", { type: "hidden", name: LOCALE_FIELD, value: locale }), _jsx("input", { type: "hidden", name: FORM_ID_FIELD, value: formId }), _jsx("input", { type: "hidden", name: RENDERED_AT_FIELD, value: String(renderedAt) }), card && _jsx("input", { type: "hidden", name: CARD_FIELD, value: card }), _jsx("input", { type: "hidden", name: SUBMISSION_FIELD }), children, _jsx("div", { "aria-hidden": "true", style: { position: "absolute", left: "-10000px", width: 1, height: 1, overflow: "hidden" }, children: _jsxs("label", { children: [honeypotLabel, _jsx("input", { type: "text", name: HONEYPOT_FIELD, tabIndex: -1, autoComplete: "off", defaultValue: "" })] }) })] }));
9
+ const { placeSlug, locale, renderedAt, honeypotLabel, formId = "quote", card, action = "/quote", id = "quote", className, onSubmit, onKeyDown, children } = props;
10
+ return (_jsxs("form", { id: id, method: "post", action: action, onSubmit: onSubmit, onKeyDown: onKeyDown, ...props.data, className: cn("relative flex w-full flex-col gap-5", className), children: [placeSlug && _jsx("input", { type: "hidden", name: LOCATION_FIELD, value: placeSlug }), _jsx("input", { type: "hidden", name: LOCALE_FIELD, value: locale }), _jsx("input", { type: "hidden", name: FORM_ID_FIELD, value: formId }), _jsx("input", { type: "hidden", name: RENDERED_AT_FIELD, value: String(renderedAt) }), card && _jsx("input", { type: "hidden", name: CARD_FIELD, value: card }), _jsx("input", { type: "hidden", name: SUBMISSION_FIELD }), children, _jsx("div", { "aria-hidden": "true", style: { position: "absolute", left: "-10000px", width: 1, height: 1, overflow: "hidden" }, children: _jsxs("label", { children: [honeypotLabel, _jsx("input", { type: "text", name: HONEYPOT_FIELD, tabIndex: -1, autoComplete: "off", defaultValue: "" })] }) })] }));
11
11
  }
@@ -0,0 +1,39 @@
1
+ /** One variant of an experiment, as the menu offers it. */
2
+ export interface AbSwitcherVariant {
3
+ /** The value the force parameter carries and the cookie holds: `b`. */
4
+ value: string;
5
+ label: string;
6
+ }
7
+ /** One experiment the menu switches. */
8
+ export interface AbSwitcherExperiment {
9
+ /** The experiment's key: the cookie is `ab_<key>`, the force parameter `<forceParam><key>`. */
10
+ key: string;
11
+ label?: string;
12
+ variants: readonly AbSwitcherVariant[];
13
+ }
14
+ /** The menu's words; English unless the brand says otherwise. */
15
+ export interface AbSwitcherText {
16
+ /** The chip's and the panel's accessible name. */
17
+ title: string;
18
+ reset: string;
19
+ leave: string;
20
+ minimize: string;
21
+ hide: string;
22
+ /** Shown for an experiment no cookie assigns yet. */
23
+ unassigned: string;
24
+ }
25
+ /**
26
+ * Plain data, so a server layout mounts it. Positioned by `className`: a brand
27
+ * lifts the chip over its own sticky bars.
28
+ */
29
+ export interface AbSwitcherProps {
30
+ experiments: readonly AbSwitcherExperiment[];
31
+ /** The cookie the brand's force parameter sets to mark a test visit: `ab__qa`. */
32
+ qaCookie: string;
33
+ /** The assignments, when the caller knows them; otherwise read from `ab_<key>` cookies. */
34
+ current?: Readonly<Record<string, string>>;
35
+ /** The prefix of the parameter that forces a variant; `ab_` by default. */
36
+ forceParam?: string;
37
+ className?: string;
38
+ text?: Partial<AbSwitcherText>;
39
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,11 @@
1
+ /** `LeadSteps`' mark of a screen that is off (`STEP_OFF`), and the attribute. */
2
+ export declare const OFF_SELECTOR = "[data-lead-off], [hidden]";
3
+ /**
4
+ * The next empty field of `form` after `from` (from the top without one), or
5
+ * `null`. `hiddenToo`: on a screen not shown yet, too — the next screen's.
6
+ */
7
+ export declare function nextEmpty(form: HTMLFormElement, from: Element | null, hiddenToo?: boolean): HTMLInputElement | null;
8
+ /** Focuses the next empty field after `from`; `true` when there was one. */
9
+ export declare function focusNext(form: HTMLFormElement | null | undefined, from: Element | null): boolean;
10
+ /** Focuses a step's first empty field, else its first field: what the screen asks. */
11
+ export declare function focusStep(step: Element | null | undefined): void;
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Where the focus goes after an answer: the next field the visitor has not
3
+ * filled — a radio group with nothing checked takes it at its first radio, a
4
+ * typed field when empty. Read from the DOM, in document order, so a brand's
5
+ * extras take their turn too; a field on a screen that is off (another step) is
6
+ * not there to be filled.
7
+ */
8
+ const FIELDS = "[data-lead-field]";
9
+ function empty(el, form) {
10
+ if (el.type === "radio")
11
+ return !form.querySelector(`input[type=radio][name="${CSS.escape(el.name)}"]:checked`);
12
+ if (el.type === "checkbox")
13
+ return !el.checked;
14
+ return el.value.trim() === "";
15
+ }
16
+ /** `LeadSteps`' mark of a screen that is off (`STEP_OFF`), and the attribute. */
17
+ export const OFF_SELECTOR = "[data-lead-off], [hidden]";
18
+ const shown = (el) => el.closest(OFF_SELECTOR) === null;
19
+ /** The fields of `scope` in document order, a radio group once — at its checked radio, else its first. */
20
+ function fields(scope, hiddenToo = false) {
21
+ const seen = new Set();
22
+ const out = [];
23
+ for (const el of scope.querySelectorAll(FIELDS)) {
24
+ if (!(el instanceof HTMLInputElement) || el.disabled || el.type === "hidden" || (!hiddenToo && !shown(el)))
25
+ continue;
26
+ if (el.type === "radio") {
27
+ if (seen.has(el.name))
28
+ continue;
29
+ seen.add(el.name);
30
+ out.push(el.form?.querySelector(`input[type=radio][name="${CSS.escape(el.name)}"]:checked`) ?? el);
31
+ continue;
32
+ }
33
+ out.push(el);
34
+ }
35
+ return out;
36
+ }
37
+ /**
38
+ * The next empty field of `form` after `from` (from the top without one), or
39
+ * `null`. `hiddenToo`: on a screen not shown yet, too — the next screen's.
40
+ */
41
+ export function nextEmpty(form, from, hiddenToo = false) {
42
+ const after = (el) => from === null || (from.compareDocumentPosition(el) & Node.DOCUMENT_POSITION_FOLLOWING) !== 0;
43
+ return fields(form, hiddenToo).find(el => after(el) && !from?.contains(el) && empty(el, form)) ?? null;
44
+ }
45
+ /** Focuses the next empty field after `from`; `true` when there was one. */
46
+ export function focusNext(form, from) {
47
+ const next = form ? nextEmpty(form, from) : null;
48
+ next?.focus();
49
+ return next !== null;
50
+ }
51
+ /** Focuses a step's first empty field, else its first field: what the screen asks. */
52
+ export function focusStep(step) {
53
+ const form = step?.closest("form");
54
+ if (!step || !form)
55
+ return;
56
+ const all = fields(step);
57
+ (all.find(el => empty(el, form)) ?? all[0])?.focus();
58
+ }
@@ -3,9 +3,15 @@ import { type InvalidEvent, type RefObject } from "react";
3
3
  * The value `FormSelect` posts, across the swap from the native select and
4
4
  * through the form's own life: a reset, and a required check that points at
5
5
  * the kit's trigger instead of an input nobody sees.
6
+ *
7
+ * `controlled` (a `value` prop) follows React's input idiom: the prop is the
8
+ * value, a choice reaches the parent only through `onValueChange`, and a reset
9
+ * leaves it where the parent holds it. The own state still follows the prop,
10
+ * so a switch back to uncontrolled keeps the last value, as an `<input>` does.
6
11
  */
7
12
  export declare function useFormSelectValue(opts: {
8
13
  initial: string;
14
+ controlled: string | undefined;
9
15
  native: RefObject<HTMLSelectElement | null>;
10
16
  box: RefObject<HTMLElement | null>;
11
17
  scripted: boolean;
@@ -1,23 +1,45 @@
1
1
  "use client";
2
2
  import { useEffect, useLayoutEffect, useRef, useState } from "react";
3
+ const DEV = typeof process !== "undefined" && process.env.NODE_ENV !== "production";
3
4
  /**
4
5
  * The value `FormSelect` posts, across the swap from the native select and
5
6
  * through the form's own life: a reset, and a required check that points at
6
7
  * the kit's trigger instead of an input nobody sees.
8
+ *
9
+ * `controlled` (a `value` prop) follows React's input idiom: the prop is the
10
+ * value, a choice reaches the parent only through `onValueChange`, and a reset
11
+ * leaves it where the parent holds it. The own state still follows the prop,
12
+ * so a switch back to uncontrolled keeps the last value, as an `<input>` does.
7
13
  */
8
14
  export function useFormSelectValue(opts) {
9
- const { initial, native, box, scripted, onValueChange } = opts;
10
- const [value, setValue] = useState(initial);
15
+ const { initial, controlled, native, box, scripted, onValueChange } = opts;
16
+ const [own, setOwn] = useState(controlled ?? initial);
11
17
  const [invalid, setInvalid] = useState(false);
12
18
  const [open, setOpen] = useState(false);
13
- const initialRef = useRef(initial);
14
- initialRef.current = initial;
19
+ const isControlled = controlled !== undefined;
20
+ if (isControlled && own !== controlled)
21
+ setOwn(controlled);
22
+ const value = isControlled ? controlled : own;
23
+ useModeWarning(isControlled);
24
+ // The effects below run once or on a form event: they read the latest props here.
25
+ const latest = useRef({ initial, isControlled, value, onValueChange });
26
+ latest.current = { initial, isControlled, value, onValueChange };
15
27
  // A choice made in the native select before the script arrived survives the
16
28
  // swap: read in the hydration commit, before the scripted render. Once —
17
- // later renders never hold the native select.
29
+ // later renders never hold the native select. Under a `value` it is the
30
+ // parent's to take, so it is reported instead of kept — and only a real
31
+ // pick: a `value` no option carries leaves the browser on its own default,
32
+ // which is no choice of the visitor's.
18
33
  useLayoutEffect(() => {
19
- if (native.current)
20
- setValue(native.current.value);
34
+ const select = native.current;
35
+ if (!select)
36
+ return;
37
+ const picked = select.value;
38
+ const { isControlled: held, value: shown, onValueChange: report } = latest.current;
39
+ if (!held)
40
+ setOwn(picked);
41
+ else if (picked !== shown && picked !== untouched(select))
42
+ report?.(picked);
21
43
  }, [native]);
22
44
  // A form reset puts a native select back on its default; so does this.
23
45
  // Interactive validation — the browser's own, on a submit — is told apart
@@ -27,7 +49,10 @@ export function useFormSelectValue(opts) {
27
49
  const form = box.current?.closest("form");
28
50
  if (!scripted || !form)
29
51
  return;
30
- const reset = () => setValue(initialRef.current);
52
+ const reset = () => {
53
+ if (!latest.current.isControlled)
54
+ setOwn(latest.current.initial);
55
+ };
31
56
  // On the document, not the form: a submit button may sit outside it
32
57
  // (`form="…"`), and the click may land on an icon inside the button.
33
58
  const submit = (e) => {
@@ -48,7 +73,8 @@ export function useFormSelectValue(opts) {
48
73
  };
49
74
  }, [box, scripted]);
50
75
  const choose = (next) => {
51
- setValue(next);
76
+ if (!isControlled)
77
+ setOwn(next);
52
78
  setInvalid(false);
53
79
  onValueChange?.(next);
54
80
  };
@@ -70,3 +96,22 @@ export function useFormSelectValue(opts) {
70
96
  };
71
97
  return { value, choose, invalid, open, setOpen, onInvalid };
72
98
  }
99
+ /**
100
+ * What a single select shows before anyone touches it: the option marked
101
+ * `selected` in the markup, else the first enabled one (HTML's selectedness
102
+ * setting). A pick of that same option is indistinguishable, and harmless.
103
+ */
104
+ function untouched(select) {
105
+ const options = Array.from(select.options);
106
+ return (options.find(o => o.defaultSelected) ?? options.find(o => !o.disabled))?.value;
107
+ }
108
+ /** React's warning for an `<input>` that changes mode: once, and nothing else changes. */
109
+ function useModeWarning(isControlled) {
110
+ const first = useRef(isControlled);
111
+ const warned = useRef(false);
112
+ if (!DEV || warned.current || first.current === isControlled)
113
+ return;
114
+ warned.current = true;
115
+ console.error(`@evinvest/kitstart: a FormSelect is changing from ${first.current ? "controlled to uncontrolled" : "uncontrolled to controlled"}. ` +
116
+ "Keep one of `value` and `defaultValue` for its lifetime; `value={undefined}` makes it uncontrolled.");
117
+ }
@@ -1,8 +1,11 @@
1
1
  /**
2
2
  * `@evinvest/kitstart/react` — the structural widgets every landing shares,
3
3
  * where behaviour matters more than look. Server components by default; only
4
- * `MapFacade`, `AnalyticsBoundary`, `FormSelect`, `LeadCapture` and `LeadBooking` are client modules, each its own file
5
- * with its own `"use client"`, so a page pays for what it renders.
4
+ * `MapFacade`, `AnalyticsBoundary`, `FormSelect`, `LeadCapture`, `LeadBooking`
5
+ * and `AbSwitcher` are client modules, each its own file with its own
6
+ * `"use client"`, so a page pays for what it renders. `AbSwitcher` is a gate
7
+ * of a few hundred bytes; its panel (`AbSwitcherPanel`) is a dynamic import,
8
+ * fetched only by a QA visit.
6
9
  *
7
10
  * Styled with the kit's token roles only (`text-ink`, `border-border`,
8
11
  * `bg-card`…); a brand restyles through `className`, a widget's named parts
@@ -10,6 +13,8 @@
10
13
  * must scan the package: `@source "<path to>/node_modules/@evinvest/kitstart/dist";`,
11
14
  * relative to the stylesheet (`../../node_modules/…` from `src/app/`).
12
15
  */
16
+ export { AbSwitcher } from "./AbSwitcher.js";
17
+ export type { AbSwitcherExperiment, AbSwitcherProps, AbSwitcherText, AbSwitcherVariant } from "./ab-switcher-types.js";
13
18
  export { AnalyticsBoundary } from "./AnalyticsBoundary.js";
14
19
  export { AreaChips } from "./AreaChips.js";
15
20
  export { CallBar, type CallBarPart, type CallBarProps } from "./CallBar.js";
@@ -23,7 +28,10 @@ export type { BookingPart } from "./LeadBookingManual.js";
23
28
  export { BOOKING_ADAPTERS, BOOKING_EMBEDS, bookingAdapters, calComAdapter, calComEmbedAdapter, calComEmbedOrigin, googleCalendarAdapter, linkAdapter, manualAdapter, type BookedSlot, type BookingAdapter, type BookingAdapters, type BookingContext, } from "./booking-adapters.js";
24
29
  export { BOOKING_ACTION } from "./use-booking.js";
25
30
  export type { LeadSent } from "./use-lead-submit.js";
26
- export type { LeadCaptureLayout } from "./LeadCaptureNeed.js";
31
+ export type { LeadCaptureLayout, LeadNeedDisplay, LeadNeedOption } from "./LeadCaptureNeed.js";
32
+ export type { EstimateQuestion, EstimateQuestions } from "./LeadCaptureEstimate.js";
33
+ export type { LeadIntro, LeadIntroOption } from "./lead-steps.js";
34
+ export type { ChannelIconKey } from "./LeadCaptureChannels.js";
27
35
  export { MapFacade, type MapFacadePart, type MapFacadeProps } from "./MapFacade.js";
28
36
  export { PlaceDirectory, type PlaceDirectoryPart, type PlaceDirectoryProps } from "./PlaceDirectory.js";
29
37
  export { PHONE_INPUT_PROPS, QuoteFormShell, type QuoteFormShellProps } from "./QuoteFormShell.js";
@@ -1,8 +1,11 @@
1
1
  /**
2
2
  * `@evinvest/kitstart/react` — the structural widgets every landing shares,
3
3
  * where behaviour matters more than look. Server components by default; only
4
- * `MapFacade`, `AnalyticsBoundary`, `FormSelect`, `LeadCapture` and `LeadBooking` are client modules, each its own file
5
- * with its own `"use client"`, so a page pays for what it renders.
4
+ * `MapFacade`, `AnalyticsBoundary`, `FormSelect`, `LeadCapture`, `LeadBooking`
5
+ * and `AbSwitcher` are client modules, each its own file with its own
6
+ * `"use client"`, so a page pays for what it renders. `AbSwitcher` is a gate
7
+ * of a few hundred bytes; its panel (`AbSwitcherPanel`) is a dynamic import,
8
+ * fetched only by a QA visit.
6
9
  *
7
10
  * Styled with the kit's token roles only (`text-ink`, `border-border`,
8
11
  * `bg-card`…); a brand restyles through `className`, a widget's named parts
@@ -10,6 +13,7 @@
10
13
  * must scan the package: `@source "<path to>/node_modules/@evinvest/kitstart/dist";`,
11
14
  * relative to the stylesheet (`../../node_modules/…` from `src/app/`).
12
15
  */
16
+ export { AbSwitcher } from "./AbSwitcher.js";
13
17
  export { AnalyticsBoundary } from "./AnalyticsBoundary.js";
14
18
  export { AreaChips } from "./AreaChips.js";
15
19
  export { CallBar } from "./CallBar.js";
@@ -0,0 +1,53 @@
1
+ import { type ReactNode } from "react";
2
+ import type { LeadStep } from "../core/analytics.js";
3
+ /** One answer to the intro question. */
4
+ export interface LeadIntroOption {
5
+ /** Posted under the intro's `field`: a slug. */
6
+ value: string;
7
+ label: string;
8
+ /** Drawn on its tile, the brand's own. */
9
+ icon?: ReactNode;
10
+ /** `callback`: the rest of the form is the phone and its consent, posted as a callback. `form` (default): the form. */
11
+ channel?: "form" | "callback" | undefined;
12
+ }
13
+ /**
14
+ * A question asked before the need (`steps`) — how urgent, who is asking —
15
+ * whose answer goes with the lead as one of the brand's extras: declare
16
+ * `field` in `site.lead.extras`, or the server drops it.
17
+ */
18
+ export interface LeadIntro {
19
+ label: string;
20
+ field: string;
21
+ options: readonly LeadIntroOption[];
22
+ }
23
+ /** A screen of `layout="steps"`, named as `lead_form_step` reports it. */
24
+ export type StepId = Exclude<LeadStep, "contact">;
25
+ /**
26
+ * The screens, in order: the intro question when there is one; then — unless
27
+ * its answer asked for a call back, which needs the phone alone — the need,
28
+ * the estimate's screens asked now (a question each, or a few sharing one,
29
+ * named by the first), the postcode on its own screen (`localityStep="own"`),
30
+ * and the phone, always last.
31
+ */
32
+ export declare function stepsOf(o: {
33
+ intro: boolean;
34
+ callback: boolean;
35
+ estimate: readonly (readonly string[])[];
36
+ localityOwn: boolean;
37
+ }): StepId[];
38
+ /** The screen a refused field is shown on: its own (an estimate's, the screen it shares), else the last. */
39
+ export declare function stepOfField(steps: readonly StepId[], field: string, estimate?: readonly (readonly string[])[]): StepId | null;
40
+ /**
41
+ * Which screen is on: the one a refusal points at, else the one the visitor
42
+ * went back to (`back`, `edit`), else the first not yet answered. An answer
43
+ * calls `next`, and the first open screen comes back on. Each move is
44
+ * reported once (`onStep`) — never the first screen, which `lead_form_view`
45
+ * already counts.
46
+ */
47
+ export declare function useLeadSteps(steps: readonly StepId[], answered: (id: StepId) => boolean, refused: StepId | null, onStep: (id: StepId) => void): {
48
+ current: StepId;
49
+ index: number;
50
+ back: () => void;
51
+ edit: (id: StepId) => void;
52
+ next: () => void;
53
+ };
@@ -0,0 +1,58 @@
1
+ "use client";
2
+ import { useEffect, useRef, useState } from "react";
3
+ import { estimateField } from "../core/pricing/flow.js";
4
+ /**
5
+ * The screens, in order: the intro question when there is one; then — unless
6
+ * its answer asked for a call back, which needs the phone alone — the need,
7
+ * the estimate's screens asked now (a question each, or a few sharing one,
8
+ * named by the first), the postcode on its own screen (`localityStep="own"`),
9
+ * and the phone, always last.
10
+ */
11
+ export function stepsOf(o) {
12
+ const out = o.intro ? ["intro"] : [];
13
+ if (o.callback)
14
+ return [...out, "phone"];
15
+ out.push("need", ...o.estimate.flatMap(screen => (screen[0] === undefined ? [] : [estimateField(screen[0])])));
16
+ if (o.localityOwn)
17
+ out.push("locality");
18
+ out.push("phone");
19
+ return out;
20
+ }
21
+ /** The screen a refused field is shown on: its own (an estimate's, the screen it shares), else the last. */
22
+ export function stepOfField(steps, field, estimate = []) {
23
+ if (steps.length === 0)
24
+ return null;
25
+ const shared = estimate.find(screen => screen.some(id => estimateField(id) === field))?.[0];
26
+ const own = field === "need" || field === "locality" ? field : shared !== undefined ? estimateField(shared) : null;
27
+ const step = own !== null ? steps.find(s => s === own) : undefined;
28
+ return step ?? steps[steps.length - 1] ?? null;
29
+ }
30
+ /**
31
+ * Which screen is on: the one a refusal points at, else the one the visitor
32
+ * went back to (`back`, `edit`), else the first not yet answered. An answer
33
+ * calls `next`, and the first open screen comes back on. Each move is
34
+ * reported once (`onStep`) — never the first screen, which `lead_form_view`
35
+ * already counts.
36
+ */
37
+ export function useLeadSteps(steps, answered, refused, onStep) {
38
+ const [at, setAt] = useState(null);
39
+ const open = steps.find(s => !answered(s)) ?? steps[steps.length - 1] ?? "phone";
40
+ const current = refused !== null && steps.includes(refused) ? refused : at !== null && steps.includes(at) ? at : open;
41
+ const index = steps.indexOf(current);
42
+ const shown = useRef(current);
43
+ const report = useRef(onStep);
44
+ report.current = onStep;
45
+ useEffect(() => {
46
+ if (shown.current === current)
47
+ return;
48
+ shown.current = current;
49
+ report.current(current);
50
+ }, [current]);
51
+ return {
52
+ current,
53
+ index,
54
+ back: () => setAt(steps[index - 1] ?? null),
55
+ edit: (id) => setAt(id),
56
+ next: () => setAt(null),
57
+ };
58
+ }
@@ -29,3 +29,9 @@ onPreset: () => void): [string | undefined, (need: string) => void];
29
29
  * callback by id, and not every browser opens a closed one it scrolls to.
30
30
  */
31
31
  export declare function useOpenOnHash(id: string): void;
32
+ /**
33
+ * The postcode the page already knows: `?postcode=` once the script runs (a
34
+ * cached page cannot read the query) — the link from an ad or a card that
35
+ * asked it already. Anything that does not read as a postcode is ignored.
36
+ */
37
+ export declare function usePostcode(): string | undefined;
@@ -84,3 +84,19 @@ export function useOpenOnHash(id) {
84
84
  return () => window.removeEventListener("hashchange", open);
85
85
  }, [id]);
86
86
  }
87
+ /** What `?postcode=` may carry into the form: a short postcode, letters for the few countries that use them, never more. */
88
+ const POSTCODE_PARAM = /^[0-9A-Za-z][0-9A-Za-z -]{1,9}$/;
89
+ /**
90
+ * The postcode the page already knows: `?postcode=` once the script runs (a
91
+ * cached page cannot read the query) — the link from an ad or a card that
92
+ * asked it already. Anything that does not read as a postcode is ignored.
93
+ */
94
+ export function usePostcode() {
95
+ const [postcode, setPostcode] = useState(undefined);
96
+ useEffect(() => {
97
+ const value = new URLSearchParams(window.location.search).get("postcode")?.trim();
98
+ if (value && POSTCODE_PARAM.test(value))
99
+ setPostcode(value);
100
+ }, []);
101
+ return postcode;
102
+ }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Where the focus goes once the visitor has answered: to the next screen in
3
+ * `steps`, else — with `focusNext` — to the next empty field.
4
+ *
5
+ * `moveOn(change, from, stable)` applies the answer. When it changes no
6
+ * screen (`stable`) and what comes next is a typed field, that field is
7
+ * revealed and focused inside the tap: iOS opens the keyboard only for a
8
+ * focus a gesture made. Otherwise the focus moves once React has drawn the
9
+ * change. `focusAfterSelect()`: the same after the kit's select, whose list
10
+ * hands the focus back to its trigger as it closes — so it waits for that.
11
+ */
12
+ export declare function useLeadFocus(form: () => HTMLFormElement | null, o: {
13
+ steps: boolean;
14
+ focusNext: boolean;
15
+ current: string;
16
+ }): {
17
+ moveOn(change: () => void, from: Element | null, stable: boolean): void;
18
+ focusAfterSelect(): void;
19
+ };