@evinvest/kitstart 0.6.0 → 0.8.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 CHANGED
@@ -45,7 +45,7 @@ brand's `flake.nix` is its own config plus one call — `template/flake.nix` is
45
45
  the whole of one:
46
46
 
47
47
  ```nix
48
- inputs.ev.url = "github:EV-invest/lib?ref=@evinvest/kitstart-v0.6.0"; # the version in package-lock.json
48
+ inputs.ev.url = "github:EV-invest/lib?ref=@evinvest/kitstart-v0.8.0"; # the version in package-lock.json
49
49
  inputs.ev.inputs.v_flakes.follows = "v_flakes";
50
50
 
51
51
  landing = ev.lib.mkLanding {
@@ -226,10 +226,13 @@ it is the plain POST to `/quote` it always was.
226
226
  | `prefer` | a channel moved first when available (a `default_channel` arm) |
227
227
  | `experiment` | `{ name, variant }`, slugs: on every event and posted with the form |
228
228
  | `timeZone` | `Europe/Paris` by default |
229
- | `formId`, `id` | `quote` · `quote`; the callback is `<id>-callback` (its form `<id>-callback-form`) |
230
- | `text` | `LeadCaptureText`: `LEAD_CAPTURE_TEXT.fr` / `.en`, plain strings (`{need}`, `{day}`, `{time}` filled in) |
229
+ | `formId`, `id` | `quote` · `quote`: the id is the card's (root), so `#quote` scrolls to the whole card, head included; the form is `<id>-form`, the callback `<id>-callback` (its form `<id>-callback-form`) |
230
+ | `text` | `LeadCaptureText`: `LEAD_CAPTURE_TEXT.fr` / `.en`, plain strings (`{need}`, `{day}`, `{time}` filled in); optional `localityPlaceholder`, `phonePlaceholder`, `namePlaceholder` (none by default) |
231
+ | `labels` | `visible` (default) · `hidden`: every field's label `sr-only` — still the field's accessible name — for a design that draws placeholders |
232
+ | `callbackOpen` | whether the callback starts open; by default only when it leads (the place is closed). `#<id>-callback` opens it either way |
233
+ | `done` | the card after a lead is taken — a node, or `(sent: LeadSent) => node` (`{ channel, phone, name }`). With it a script posts the form itself and shows this in place on the thanks page's 303; any other answer, or no network, submits the form for real; without a script nothing changes (303 → `/thanks`). Without it, every submit goes to `/thanks` |
231
234
  | `head`, `trust` | the brand's heading instead of the title; a slot beside the submit |
232
- | `className` · `classNames` | the root · its parts (`form`, `field`, `label`, `control`, `submit`, `need`, `channel`, `primary`, `callback`…) |
235
+ | `className` · `classNames` | the root · its parts: `root`, `head`, `title`, `lede`, `form`, `contact`, `field`, `label`, `control` (every input, the need's select in both states, the callback's phone), `hint`, `chips`, `chip`, `needs`, `need`, `summary`, `submit`, `trust`, `privacy`, `opening`, `others`, `channel`, `primary`, `callback`, `callbackSummary`, `callbackForm`, `callbackLede`, `callbackSubmit`, `consent`, `done` |
233
236
 
234
237
  - **Taps.** A need the page knows is not asked again, and a place serving one
235
238
  commune fills it: focus the phone, type, send — two taps. `qualify-first`
@@ -255,6 +258,15 @@ it is the plain POST to `/quote` it always was.
255
258
  (`Lead.consent`). Then the lead is held to `lead.validateCallback` (a
256
259
  readable number, by default), not to the form's rule. The consent goes into
257
260
  no event; a webhook body built by the brand should leave `lead.consent` out.
261
+ - **Locality.** A postcode field (`autoComplete="postal-code"`). A storefront
262
+ with no named zone fills in its own postcode; a named zone fills in its one
263
+ commune or offers its few as chips, by name — and then the field takes
264
+ letters (`inputMode="text"`), since iOS's numeric keypad could not edit a
265
+ name. Otherwise it is the numeric keypad.
266
+ - **Callback, closed.** Its summary is a block (`flex`, not the button's
267
+ `inline-flex`) with `leading-6` after the brand's `channel` /
268
+ `callbackSummary` classes unless they set a line height of their own, so a
269
+ brand's type size cannot leave it on a half pixel.
258
270
  - **Phone.** `type="tel"`, required, never masked. A number that does not read
259
271
  as one gets a hint when the field is left; the server keeps it. With
260
272
  `lead.mobileFormat: "e164"` a number it can read is stored as
@@ -21,6 +21,14 @@ export interface LeadCaptureText {
21
21
  /** The soft check under a number that does not read as one; it never blocks. */
22
22
  phoneHint: string;
23
23
  nameLabel: string;
24
+ /**
25
+ * Shown in the empty field. None by default: a placeholder is not a label —
26
+ * the label stays (visually hidden under `labels="hidden"`) for a brand
27
+ * whose design draws placeholders instead.
28
+ */
29
+ localityPlaceholder?: string | undefined;
30
+ phonePlaceholder?: string | undefined;
31
+ namePlaceholder?: string | undefined;
24
32
  /** After an optional field's label. */
25
33
  optional: string;
26
34
  submit: string;
@@ -8,7 +8,8 @@ import { type ChannelPart, type Experiment } from "./LeadCaptureChannels.js";
8
8
  import { type FieldPart } from "./LeadCaptureFields.js";
9
9
  import { type LeadCaptureLayout } from "./LeadCaptureNeed.js";
10
10
  import type { PartClassNames } from "./parts.js";
11
- export type LeadCapturePart = "root" | "head" | "title" | "lede" | "form" | "contact" | "submit" | "trust" | "privacy" | "opening" | "others" | "needs" | "need" | "summary" | FieldPart | ChannelPart;
11
+ import { type LeadSent } from "./use-lead-submit.js";
12
+ export type LeadCapturePart = "root" | "head" | "title" | "lede" | "form" | "contact" | "submit" | "trust" | "privacy" | "opening" | "others" | "done" | "needs" | "need" | "summary" | FieldPart | ChannelPart;
12
13
  export interface LeadCaptureProps {
13
14
  /** Its hours order the channels; its service area suggests the commune. */
14
15
  place: Place<string>;
@@ -42,9 +43,27 @@ export interface LeadCaptureProps {
42
43
  experiment?: Experiment | undefined;
43
44
  timeZone?: string | undefined;
44
45
  formId?: string | undefined;
45
- /** The form's id, the anchor a call bar links to (`#quote`); the callback's is `<id>-callback`. */
46
+ /**
47
+ * The card's id, the anchor a call bar links to (`#quote`): the whole card,
48
+ * head included, scrolls into view. The form is `<id>-form`, the callback
49
+ * `<id>-callback` and its form `<id>-callback-form`.
50
+ */
46
51
  id?: string | undefined;
47
52
  text: LeadCaptureText;
53
+ /**
54
+ * `hidden`: every field's label for assistive technology only (`sr-only`) —
55
+ * for a design that draws placeholders instead (`text.*Placeholder`).
56
+ */
57
+ labels?: "visible" | "hidden" | undefined;
58
+ /** Whether the callback starts open; by default only when it is the channel that leads (the place is closed). */
59
+ callbackOpen?: boolean | undefined;
60
+ /**
61
+ * The card once a lead is taken, in place of everything in it. With it, a
62
+ * script posts the form itself and shows this on the thanks page's 303;
63
+ * without a script, or on any other answer, the form posts as it always
64
+ * did. Without it, a submit goes to the thanks page.
65
+ */
66
+ done?: ReactNode | ((sent: LeadSent) => ReactNode);
48
67
  /** Replaces the title and lede — the brand's own heading. */
49
68
  head?: ReactNode;
50
69
  /** Beside the submit: a guarantee, a live rating. */
@@ -1,16 +1,26 @@
1
1
  "use client";
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { Button, cn } from "@evinvest/uikit";
4
- import { useRef, useState } from "react";
4
+ import { useEffect, useRef, useState } from "react";
5
5
  import { resolveChannels } from "../core/channels.js";
6
6
  import { fillText, openingText } from "../core/lead-capture-format.js";
7
- import { servedLocalities } from "../core/place/types.js";
7
+ import { servedLocalities, storefrontOf } from "../core/place/types.js";
8
8
  import { CallbackForm, ChannelLink, ExperimentFields } from "./LeadCaptureChannels.js";
9
9
  import { LocalityField, NameField, PhoneField } from "./LeadCaptureFields.js";
10
10
  import { NeedField } from "./LeadCaptureNeed.js";
11
11
  import { QuoteFormShell } from "./QuoteFormShell.js";
12
12
  import { useHydrated, useNeed, useNow, useOpenOnHash } from "./use-lead-context.js";
13
13
  import { useLeadEvents } from "./use-lead-events.js";
14
+ import { useLeadSubmit } from "./use-lead-submit.js";
15
+ /**
16
+ * What the locality field is offered. A storefront serving its own town knows
17
+ * that town's postcode — what the field asks — so it offers that, not the
18
+ * town's name; a named zone offers its communes, by name.
19
+ */
20
+ function localitySuggestions(place) {
21
+ const postcode = place.serviceArea === null ? storefrontOf(place)?.address.postalCode : undefined;
22
+ return postcode ? [postcode] : servedLocalities(place);
23
+ }
14
24
  /**
15
25
  * The lead form every brand shares: the need (asked once, or not at all when
16
26
  * the page knows it), the postcode, the phone, and the other ways to reach the
@@ -18,14 +28,19 @@ import { useLeadEvents } from "./use-lead-events.js";
18
28
  * script it is the plain POST to `/quote` it always was.
19
29
  */
20
30
  export function LeadCapture(props) {
21
- const { place, contact, locale, renderedAt, wire, needs, layout = "single", text, classNames: c } = props;
31
+ const { place, contact, locale, renderedAt, wire, needs, layout = "single", text } = props;
22
32
  const { formId = "quote", id = "quote", experiment } = props;
33
+ const c = props.labels === "hidden" ? { ...props.classNames, label: cn("sr-only", props.classNames?.label) } : props.classNames;
23
34
  const root = useRef(null);
24
35
  const hydrated = useHydrated();
25
36
  const [editing, setEditing] = useState(false);
26
37
  const [need, setNeed] = useNeed(props.need, needs.map(n => n.value), () => setEditing(false));
27
38
  const events = useLeadEvents(root, { formId, layout, experiment });
28
39
  useOpenOnHash(`${id}-callback`);
40
+ const [sent, onSubmit] = useLeadSubmit(props.done !== undefined, { mobile: wire.mobile, name: props.name?.field });
41
+ const doneRef = useRef(null);
42
+ // The form the focus was in is gone: the news takes it, and is read out.
43
+ useEffect(() => doneRef.current?.focus(), [sent]);
29
44
  const resolved = resolveChannels({ ...contact, hours: place.hours }, { now: new Date(useNow(renderedAt)), timeZone: props.timeZone, prefer: props.prefer });
30
45
  const opening = openingText(resolved.nextOpening, text, locale);
31
46
  const needLabel = needs.find(n => n.value === need)?.label;
@@ -42,19 +57,24 @@ export function LeadCapture(props) {
42
57
  // the first empty field — the postcode unless filled, else the phone —
43
58
  // takes focus inside the tap: iOS opens the keyboard only for a focus a
44
59
  // gesture made, and a re-render is too late for it.
45
- const fields = root.current?.querySelectorAll(`#${CSS.escape(id)} [data-lead-field="locality"], #${CSS.escape(id)} [data-lead-field="phone"]`);
60
+ const form = `#${CSS.escape(`${id}-form`)}`;
61
+ const fields = root.current?.querySelectorAll(`${form} [data-lead-field="locality"], ${form} [data-lead-field="phone"]`);
46
62
  [...(fields ?? [])].find(f => f.value === "")?.focus();
47
63
  setNeed(value);
48
64
  setEditing(false);
49
65
  events.step("contact");
50
66
  };
51
- const channel = (ch, primary) => ch === "callback" ? (_jsx(CallbackForm, { id: `${id}-callback`, open: 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, classNames: c }, ch)) : ch === "form" ? null : (_jsx(ChannelLink, { channel: ch, contact: contact, message: message, text: text, primary: primary, experiment: experiment, classNames: c }, ch));
67
+ const channel = (ch, primary) => ch === "callback" ? (_jsx(CallbackForm, { id: `${id}-callback`, primary: primary, open: 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, classNames: c }, ch)) : ch === "form" ? null : (_jsx(ChannelLink, { channel: ch, contact: contact, message: message, text: text, primary: primary, experiment: experiment, classNames: c }, ch));
52
68
  // `qualify-first` shows the contact step as soon as a need's radio is
53
69
  // checked (`:has`) — without a script, and inside the tap with one.
54
70
  const contactShown = layout === "single" || (need !== undefined && !editing);
55
71
  const contactClass = contactShown ? "flex" : "hidden group-has-[[data-need-option]:checked]/lead:flex";
56
- return (_jsxs("div", { ref: root, className: cn("flex w-full flex-col gap-6", props.className, c?.root), "data-experiment": experiment?.name, "data-variant": experiment?.variant, 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 })] })), lead !== "form" && channel(lead, true), _jsxs(QuoteFormShell, { id: id, placeSlug: place.slug, locale: locale, renderedAt: renderedAt, honeypotLabel: text.honeypotLabel, formId: formId, className: cn("group/lead", c?.form), children: [_jsx(ExperimentFields, { experiment: experiment }), _jsx(NeedField, { layout: layout, name: wire.subject, needs: needs, need: need, editing: editing, hydrated: hydrated, label: text.needLabel, changeLabel: text.needChange, onPick: pick, onEdit: () => {
72
+ const rootProps = { id, className: cn("flex w-full flex-col gap-6", props.className, c?.root), "data-experiment": experiment?.name, "data-variant": experiment?.variant };
73
+ if (sent && props.done !== undefined) {
74
+ return (_jsx("div", { ref: root, ...rootProps, children: _jsx("div", { ref: doneRef, role: "status", tabIndex: -1, className: cn("outline-none", c?.done), children: typeof props.done === "function" ? props.done(sent) : props.done }) }));
75
+ }
76
+ return (_jsxs("div", { ref: root, ...rootProps, 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 })] })), lead !== "form" && channel(lead, true), _jsxs(QuoteFormShell, { id: `${id}-form`, placeSlug: place.slug, locale: locale, renderedAt: renderedAt, honeypotLabel: text.honeypotLabel, formId: formId, onSubmit: onSubmit, className: cn("group/lead", c?.form), children: [_jsx(ExperimentFields, { experiment: experiment }), _jsx(NeedField, { layout: layout, name: wire.subject, needs: needs, need: need, editing: editing, hydrated: hydrated, label: text.needLabel, changeLabel: text.needChange, onPick: pick, onEdit: () => {
57
77
  setEditing(true);
58
78
  events.step("need");
59
- }, classNames: c }), _jsxs("div", { className: cn("flex-col gap-5", contactClass, c?.contact), children: [_jsx(LocalityField, { name: wire.locality, label: text.localityLabel, servedLabel: text.servedLabel, served: servedLocalities(place), required: props.locality !== "optional", optional: text.optional, hydrated: hydrated, classNames: c }), _jsx(PhoneField, { name: wire.mobile, label: text.phoneLabel, hint: text.phoneHint, onSoftError: () => events.fieldError("phone"), classNames: c }), props.name && _jsx(NameField, { name: props.name.field, label: text.nameLabel, required: props.name.required ?? false, optional: text.optional, classNames: c }), props.extras, _jsxs("div", { className: cn("flex flex-col gap-3", c?.trust), children: [_jsx(Button, { type: "submit", size: "touch", className: cn("w-full", c?.submit), children: text.submit }), props.trust] }), opening && lead !== "callback" && _jsx("p", { className: cn("text-sm text-ink-soft", c?.opening), children: opening }), _jsx("p", { className: cn("text-sm text-ink-soft", c?.privacy), children: text.privacy })] })] }), (links.length > 0 || rest.includes("callback")) && (_jsxs("div", { className: cn("flex flex-col gap-3", c?.others), children: [_jsx("p", { className: "text-sm font-medium text-ink", children: text.otherChannels }), links.length > 0 && _jsx("div", { className: "flex flex-wrap gap-2", children: links.map(ch => channel(ch, false)) }), rest.includes("callback") && channel("callback", false)] }))] }));
79
+ }, classNames: c }), _jsxs("div", { className: cn("flex-col gap-5", contactClass, c?.contact), children: [_jsx(LocalityField, { name: wire.locality, label: text.localityLabel, servedLabel: text.servedLabel, served: localitySuggestions(place), placeholder: text.localityPlaceholder, required: props.locality !== "optional", optional: text.optional, hydrated: hydrated, classNames: c }), _jsx(PhoneField, { name: wire.mobile, label: text.phoneLabel, hint: text.phoneHint, placeholder: text.phonePlaceholder, onSoftError: () => events.fieldError("phone"), classNames: c }), props.name && (_jsx(NameField, { name: props.name.field, label: text.nameLabel, placeholder: text.namePlaceholder, required: props.name.required ?? false, optional: text.optional, classNames: c })), props.extras, _jsxs("div", { className: cn("flex flex-col gap-3", c?.trust), children: [_jsx(Button, { type: "submit", size: "touch", className: cn("w-full", c?.submit), children: text.submit }), props.trust] }), opening && lead !== "callback" && _jsx("p", { className: cn("text-sm text-ink-soft", c?.opening), children: opening }), _jsx("p", { className: cn("text-sm text-ink-soft", c?.privacy), children: text.privacy })] })] }), (links.length > 0 || rest.includes("callback")) && (_jsxs("div", { className: cn("flex flex-col gap-3", c?.others), children: [_jsx("p", { className: "text-sm font-medium text-ink", children: text.otherChannels }), links.length > 0 && _jsx("div", { className: "flex flex-wrap gap-2", children: links.map(ch => channel(ch, false)) }), rest.includes("callback") && channel("callback", false)] }))] }));
60
80
  }
@@ -1,7 +1,13 @@
1
1
  import { type CaptureChannel } from "../core/channels.js";
2
2
  import type { LeadCaptureText } from "../core/lead-capture-text.js";
3
- import type { PartClassNames } from "./parts.js";
4
- export type ChannelPart = "channel" | "primary" | "callback" | "consent";
3
+ import type { FormEventHandler } from "react";
4
+ import type { FieldPart } from "./LeadCaptureFields.js";
5
+ import { type PartClassNames } from "./parts.js";
6
+ /**
7
+ * `channel` and `primary` dress every way out, the callback's summary
8
+ * included; the `callback*` parts are the callback's own, after them.
9
+ */
10
+ export type ChannelPart = "channel" | "primary" | "callback" | "callbackSummary" | "callbackForm" | "callbackLede" | "callbackSubmit" | "consent";
5
11
  export interface Experiment {
6
12
  name: string;
7
13
  variant: string;
@@ -33,10 +39,13 @@ export declare function ChannelLink(props: {
33
39
  * same `/quote` with `channel=callback` — a lead like any other, without a
34
40
  * script. A `<details>` so it opens without one too; open from the start when
35
41
  * it is the channel that leads (the place is closed), or when the page is
36
- * opened at its id (`#quote-callback`, the call bar's link).
42
+ * opened at its id (`#quote-callback`, the call bar's link). A brand may
43
+ * choose `open` itself; it is still the leading channel's face.
37
44
  */
38
45
  export declare function CallbackForm(props: {
39
46
  id: string;
47
+ /** The channel that leads: the primary face. */
48
+ primary: boolean;
40
49
  open: boolean;
41
50
  formId: string;
42
51
  placeSlug: string | null;
@@ -50,5 +59,6 @@ export declare function CallbackForm(props: {
50
59
  opening: string | null;
51
60
  text: LeadCaptureText;
52
61
  experiment: Experiment | undefined;
53
- classNames?: PartClassNames<ChannelPart> | undefined;
62
+ onSubmit: FormEventHandler<HTMLFormElement> | undefined;
63
+ classNames?: PartClassNames<ChannelPart | FieldPart> | undefined;
54
64
  }): import("react").JSX.Element;
@@ -3,7 +3,14 @@ import { Button, buttonVariants, cn, FieldLabel, Input } from "@evinvest/uikit";
3
3
  import { EXPERIMENT_FIELD, VARIANT_FIELD } from "../core/accept.js";
4
4
  import { channelHref } from "../core/channels.js";
5
5
  import { CHANNEL_FIELD, CONSENT_FIELD } from "../core/lead.js";
6
+ import { partWithLeading } from "./parts.js";
6
7
  import { PHONE_INPUT_PROPS, QuoteFormShell } from "./QuoteFormShell.js";
8
+ /**
9
+ * A whole-pixel line under a brand's own type size: tailwind-merge drops a
10
+ * size's line height for nothing, and a 15 px face at the page's 1.5 is a
11
+ * 52.5 px button that puts every band below it off the pixel grid.
12
+ */
13
+ const CHANNEL_LEADING = "leading-6";
7
14
  /** The site's assignment, posted with the form so the submit counts in its arm. */
8
15
  export function ExperimentFields({ experiment }) {
9
16
  if (!experiment)
@@ -22,16 +29,17 @@ export function ChannelLink(props) {
22
29
  const href = channelHref(channel, contact, message);
23
30
  if (!href)
24
31
  return null;
25
- return (_jsx(Button, { href: href, variant: primary ? "primary" : "outline", size: "touch", className: cn(primary ? "w-full" : "flex-1", c?.channel, primary && c?.primary), "data-intent": channel === "sms" ? "sms" : undefined, "data-experiment": experiment?.name, "data-variant": experiment?.variant, children: text[LABEL[channel]] }));
32
+ return (_jsx(Button, { href: href, variant: primary ? "primary" : "outline", size: "touch", className: partWithLeading(primary ? "w-full" : "flex-1", CHANNEL_LEADING, cn(c?.channel, primary && c?.primary) || undefined), "data-intent": channel === "sms" ? "sms" : undefined, "data-experiment": experiment?.name, "data-variant": experiment?.variant, children: text[LABEL[channel]] }));
26
33
  }
27
34
  /**
28
35
  * "Call me back": the form cut to a phone number and a consent, posted to the
29
36
  * same `/quote` with `channel=callback` — a lead like any other, without a
30
37
  * script. A `<details>` so it opens without one too; open from the start when
31
38
  * it is the channel that leads (the place is closed), or when the page is
32
- * opened at its id (`#quote-callback`, the call bar's link).
39
+ * opened at its id (`#quote-callback`, the call bar's link). A brand may
40
+ * choose `open` itself; it is still the leading channel's face.
33
41
  */
34
42
  export function CallbackForm(props) {
35
- const { id, open, formId, placeSlug, locale, renderedAt, mobileName, subject, opening, text, experiment, classNames: c } = props;
36
- return (_jsxs("details", { id: id, open: open, className: cn("w-full", c?.callback), children: [_jsx("summary", { "data-intent": "callback", className: buttonVariants({ variant: open ? "primary" : "outline", size: "touch", className: cn("w-full list-none [&::-webkit-details-marker]:hidden", c?.channel, open && c?.primary) }), children: text.callback }), _jsxs(QuoteFormShell, { id: `${id}-form`, placeSlug: placeSlug, locale: locale, renderedAt: renderedAt, honeypotLabel: text.honeypotLabel, formId: formId, className: "mt-4 gap-4", 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: "text-ink-soft", children: opening ?? text.callbackLede }), _jsxs("div", { className: "flex flex-col gap-2", children: [_jsx(FieldLabel, { htmlFor: `${id}-phone`, children: text.phoneLabel }), _jsx(Input, { id: `${id}-phone`, name: mobileName, size: "lg", ...PHONE_INPUT_PROPS, enterKeyHint: "send", required: true, "data-lead-field": "phone" })] }), _jsxs("label", { className: cn("flex min-h-11 items-start gap-3 text-sm text-ink", c?.consent), children: [_jsx("input", { type: "checkbox", name: CONSENT_FIELD, value: text.callbackConsent, required: true, "data-lead-field": "consent", className: "mt-0.5 size-5 shrink-0 accent-primary" }), _jsx("span", { children: text.callbackConsent })] }), _jsx(Button, { type: "submit", size: "touch", className: "w-full", children: text.callbackSubmit })] })] }));
43
+ const { id, primary, open, formId, placeSlug, locale, renderedAt, mobileName, subject, opening, text, experiment, classNames: c } = props;
44
+ return (_jsxs("details", { id: id, open: open, className: cn("w-full", c?.callback), children: [_jsx("summary", { "data-intent": "callback", className: partWithLeading(buttonVariants({ variant: primary ? "primary" : "outline", size: "touch", className: "flex w-full list-none [&::-webkit-details-marker]:hidden" }), CHANNEL_LEADING, cn(c?.channel, primary && c?.primary, c?.callbackSummary) || undefined), children: text.callback }), _jsxs(QuoteFormShell, { id: `${id}-form`, placeSlug: placeSlug, locale: locale, renderedAt: renderedAt, honeypotLabel: text.honeypotLabel, formId: formId, 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 }), _jsxs("div", { className: cn("flex flex-col gap-2", c?.field), children: [_jsx(FieldLabel, { htmlFor: `${id}-phone`, className: c?.label, children: text.phoneLabel }), _jsx(Input, { id: `${id}-phone`, name: mobileName, size: "lg", ...PHONE_INPUT_PROPS, enterKeyHint: "send", placeholder: text.phonePlaceholder, required: true, "data-lead-field": "phone", className: c?.control })] }), _jsxs("label", { className: cn("flex min-h-11 items-start gap-3 text-sm text-ink", c?.consent), children: [_jsx("input", { type: "checkbox", name: CONSENT_FIELD, value: text.callbackConsent, required: true, "data-lead-field": "consent", className: "mt-0.5 size-5 shrink-0 accent-primary" }), _jsx("span", { children: text.callbackConsent })] }), _jsx(Button, { type: "submit", size: "touch", className: cn("w-full", c?.callbackSubmit), children: text.callbackSubmit })] })] }));
37
45
  }
@@ -4,8 +4,9 @@ export declare function LocalityField(props: {
4
4
  name: string;
5
5
  label: string;
6
6
  servedLabel: string;
7
- /** The communes the place names: one is filled in, a few are a tap each. */
7
+ /** What the place knows of where it goes — a postcode or communes: one is filled in, a few are a tap each. */
8
8
  served: readonly string[];
9
+ placeholder: string | undefined;
9
10
  required: boolean;
10
11
  optional: string;
11
12
  hydrated: boolean;
@@ -20,12 +21,14 @@ export declare function PhoneField(props: {
20
21
  name: string;
21
22
  label: string;
22
23
  hint: string;
24
+ placeholder: string | undefined;
23
25
  onSoftError: () => void;
24
26
  classNames?: PartClassNames<FieldPart> | undefined;
25
27
  }): import("react").JSX.Element;
26
28
  export declare function NameField(props: {
27
29
  name: string;
28
30
  label: string;
31
+ placeholder: string | undefined;
29
32
  required: boolean;
30
33
  optional: string;
31
34
  classNames?: PartClassNames<FieldPart> | undefined;
@@ -7,11 +7,17 @@ import { PHONE_INPUT_PROPS } from "./QuoteFormShell.js";
7
7
  /** More than this and the row is a list to read, not a tap to save typing. */
8
8
  const MAX_CHIPS = 6;
9
9
  const optionalLabel = (label, optional) => (optional ? `${label} (${optional})` : label);
10
+ /** Digits only: a postcode, which the numeric keypad can type and edit. */
11
+ const POSTCODE = /^\d+$/;
10
12
  export function LocalityField(props) {
11
13
  const { name, label, servedLabel, served, required, optional, hydrated, classNames: c } = props;
12
14
  const input = useRef(null);
13
15
  const chips = hydrated && served.length > 1 && served.length <= MAX_CHIPS;
14
- return (_jsxs(Field, { className: cn("flex flex-col gap-2", c?.field), children: [_jsx(FieldLabel, { className: c?.label, children: optionalLabel(label, required ? null : optional) }), _jsx(Input, { ref: input, name: name, size: "lg", inputMode: "numeric", autoComplete: "postal-code", enterKeyHint: "next", required: required, defaultValue: served.length === 1 ? served[0] : undefined, "data-lead-field": "locality", 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: () => {
16
+ // A commune's name filled in or a tap away must stay editable: iOS's numeric
17
+ // keypad has no letters, so the keypad follows what the field is offered.
18
+ // A list too long for chips offers nothing — the visitor types a postcode.
19
+ const numeric = served.length > MAX_CHIPS || served.every(s => POSTCODE.test(s));
20
+ return (_jsxs(Field, { className: cn("flex flex-col gap-2", c?.field), children: [_jsx(FieldLabel, { className: c?.label, children: optionalLabel(label, required ? null : optional) }), _jsx(Input, { ref: input, name: name, size: "lg", inputMode: numeric ? "numeric" : "text", autoComplete: "postal-code", placeholder: props.placeholder, enterKeyHint: "next", required: required, defaultValue: served.length === 1 ? served[0] : undefined, "data-lead-field": "locality", 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: () => {
15
21
  if (input.current)
16
22
  input.current.value = commune;
17
23
  }, children: commune }, commune))) }))] }));
@@ -25,7 +31,7 @@ export function PhoneField(props) {
25
31
  const { name, label, hint, onSoftError, classNames: c } = props;
26
32
  const hintId = useId();
27
33
  const [doubtful, setDoubtful] = useState(false);
28
- return (_jsxs(Field, { className: cn("flex flex-col gap-2", c?.field), children: [_jsx(FieldLabel, { className: c?.label, children: label }), _jsx(Input, { name: name, size: "lg", ...PHONE_INPUT_PROPS, enterKeyHint: "send", required: true, "data-lead-field": "phone", "aria-invalid": doubtful || undefined, "aria-describedby": doubtful ? hintId : undefined, onBlur: e => {
34
+ return (_jsxs(Field, { className: cn("flex flex-col gap-2", c?.field), children: [_jsx(FieldLabel, { className: c?.label, children: label }), _jsx(Input, { name: name, size: "lg", ...PHONE_INPUT_PROPS, enterKeyHint: "send", placeholder: props.placeholder, required: true, "data-lead-field": "phone", "aria-invalid": doubtful || undefined, "aria-describedby": doubtful ? hintId : undefined, onBlur: e => {
29
35
  const value = e.currentTarget.value;
30
36
  const bad = value.trim() !== "" && !isPlausiblePhone(value);
31
37
  if (bad && !doubtful)
@@ -37,6 +43,6 @@ export function PhoneField(props) {
37
43
  }, className: c?.control }), doubtful && (_jsx(FieldDescription, { id: hintId, className: cn("text-accent-error", c?.hint), children: hint }))] }));
38
44
  }
39
45
  export function NameField(props) {
40
- const { name, label, required, optional, classNames: c } = props;
41
- return (_jsxs(Field, { className: cn("flex flex-col gap-2", c?.field), children: [_jsx(FieldLabel, { className: c?.label, children: optionalLabel(label, required ? null : optional) }), _jsx(Input, { name: name, size: "lg", autoComplete: "name", enterKeyHint: "next", required: required, "data-lead-field": "name", className: c?.control })] }));
46
+ const { name, label, placeholder, required, optional, classNames: c } = props;
47
+ return (_jsxs(Field, { className: cn("flex flex-col gap-2", c?.field), children: [_jsx(FieldLabel, { className: c?.label, children: optionalLabel(label, required ? null : optional) }), _jsx(Input, { name: name, size: "lg", autoComplete: "name", enterKeyHint: "next", placeholder: placeholder, required: required, "data-lead-field": "name", className: c?.control })] }));
42
48
  }
@@ -1,7 +1,7 @@
1
1
  import { type FormSelectOption } from "./FormSelect.js";
2
2
  import type { PartClassNames } from "./parts.js";
3
3
  export type LeadCaptureLayout = "single" | "qualify-first";
4
- type NeedPart = "field" | "label" | "need" | "needs" | "summary";
4
+ type NeedPart = "field" | "label" | "control" | "need" | "needs" | "summary";
5
5
  export interface NeedFieldProps {
6
6
  layout: LeadCaptureLayout;
7
7
  name: string;
@@ -16,7 +16,7 @@ export function NeedField(props) {
16
16
  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 }))] }));
17
17
  }
18
18
  if (layout === "single") {
19
- 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 })] }));
19
+ 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 })] }));
20
20
  }
21
21
  return (_jsxs("fieldset", { 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, onChange: () => onPick(n.value), 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))) })] }));
22
22
  }
@@ -1,4 +1,4 @@
1
- import type { ReactNode } from "react";
1
+ import type { FormEventHandler, 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
@@ -23,6 +23,8 @@ export interface QuoteFormShellProps {
23
23
  action?: string;
24
24
  id?: string;
25
25
  className?: string;
26
+ /** A script's own submit (an in-place success); the 303 stays the path without one. */
27
+ onSubmit?: FormEventHandler<HTMLFormElement> | undefined;
26
28
  children: ReactNode;
27
29
  }
28
30
  /** The mobile field's attributes: the numeric keypad and the phone's autofill. */
@@ -5,6 +5,6 @@ import { HONEYPOT_FIELD, RENDERED_AT_FIELD } from "../core/antispam.js";
5
5
  /** The mobile field's attributes: the numeric keypad and the phone's autofill. */
6
6
  export const PHONE_INPUT_PROPS = { type: "tel", inputMode: "tel", autoComplete: "tel" };
7
7
  export function QuoteFormShell(props) {
8
- const { placeSlug, locale, renderedAt, honeypotLabel, formId = "quote", action = "/quote", id = "quote", className, children } = props;
9
- return (_jsxs("form", { id: id, method: "post", action: action, 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) }), 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: "" })] }) })] }));
8
+ const { placeSlug, locale, renderedAt, honeypotLabel, formId = "quote", action = "/quote", id = "quote", className, onSubmit, children } = props;
9
+ 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) }), 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: "" })] }) })] }));
10
10
  }
@@ -18,6 +18,7 @@ export { Faq, type FaqPart, type FaqProps } from "./Faq.js";
18
18
  export { FormSelect, type FormSelectOption, type FormSelectPart, type FormSelectProps } from "./FormSelect.js";
19
19
  export { LangSwitch, withLang, type LangSwitchProps } from "./LangSwitch.js";
20
20
  export { LeadCapture, type LeadCapturePart, type LeadCaptureProps } from "./LeadCapture.js";
21
+ export type { LeadSent } from "./use-lead-submit.js";
21
22
  export type { LeadCaptureLayout } from "./LeadCaptureNeed.js";
22
23
  export { MapFacade, type MapFacadePart, type MapFacadeProps } from "./MapFacade.js";
23
24
  export { PlaceDirectory, type PlaceDirectoryPart, type PlaceDirectoryProps } from "./PlaceDirectory.js";
@@ -0,0 +1,21 @@
1
+ import { type FormEventHandler } from "react";
2
+ /** What the visitor just sent, for a brand's in-card success ("we call 06 … back"). */
3
+ export interface LeadSent {
4
+ channel: "form" | "callback";
5
+ /** As typed. */
6
+ phone: string;
7
+ /** The name field's value, when the form has one and it was filled. */
8
+ name: string | null;
9
+ }
10
+ /**
11
+ * The lead posted by a script, so the card can say "done" in place. The body
12
+ * and the endpoint are the form's own, and the server answers it as it answers
13
+ * the plain post: a 303 to the thanks page means the lead was taken. Anything
14
+ * else — a refusal, the store's 500, no network — submits the form for real,
15
+ * so the server's own page says what went wrong. Off (`undefined` handler)
16
+ * when the brand gave no in-card success: the 303 to `/thanks` stays the path.
17
+ */
18
+ export declare function useLeadSubmit(enabled: boolean, fields: {
19
+ mobile: string;
20
+ name: string | undefined;
21
+ }): [LeadSent | null, FormEventHandler<HTMLFormElement> | undefined];
@@ -0,0 +1,42 @@
1
+ "use client";
2
+ import { useRef, useState } from "react";
3
+ import { CHANNEL_FIELD } from "../core/lead.js";
4
+ import { THANKS } from "../core/routing.js";
5
+ /**
6
+ * The lead posted by a script, so the card can say "done" in place. The body
7
+ * and the endpoint are the form's own, and the server answers it as it answers
8
+ * the plain post: a 303 to the thanks page means the lead was taken. Anything
9
+ * else — a refusal, the store's 500, no network — submits the form for real,
10
+ * so the server's own page says what went wrong. Off (`undefined` handler)
11
+ * when the brand gave no in-card success: the 303 to `/thanks` stays the path.
12
+ */
13
+ export function useLeadSubmit(enabled, fields) {
14
+ const [sent, setSent] = useState(null);
15
+ const pending = useRef(false);
16
+ if (!enabled)
17
+ return [sent, undefined];
18
+ const submit = (event) => {
19
+ event.preventDefault();
20
+ if (pending.current)
21
+ return;
22
+ pending.current = true;
23
+ const form = event.currentTarget;
24
+ const data = new FormData(form);
25
+ const body = new URLSearchParams();
26
+ for (const [key, value] of data)
27
+ if (typeof value === "string")
28
+ body.append(key, value);
29
+ const read = (field) => {
30
+ const value = field === undefined ? null : data.get(field);
31
+ return typeof value === "string" ? value.trim() : "";
32
+ };
33
+ const lead = { channel: read(CHANNEL_FIELD) === "callback" ? "callback" : "form", phone: read(fields.mobile), name: read(fields.name) || null };
34
+ // `form.submit()` neither validates again nor comes back here.
35
+ const fallBack = () => {
36
+ pending.current = false;
37
+ form.submit();
38
+ };
39
+ fetch(form.action, { method: "POST", body }).then(res => (res.ok && res.redirected && new URL(res.url).pathname.endsWith(THANKS) ? setSent(lead) : fallBack()), fallBack);
40
+ };
41
+ return [sent, submit];
42
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@evinvest/kitstart",
3
- "version": "0.6.0",
3
+ "version": "0.8.0",
4
4
  "description": "Landing-site machinery for local-service brands: a site composition root, the Place/Presence/ServiceArea model with a publication gate, host/locale routing, schema.org and SEO builders, the lead funnel with its antispam, and analytics events. Pure core at '.', more subpaths per runtime.",
5
5
  "license": "BlueOak-1.0.0",
6
6
  "publishConfig": {
@@ -3,7 +3,7 @@
3
3
  v_flakes.url = "github:valeratrades/v_flakes?ref=v1.6";
4
4
  # The lib flake at the tag of the @evinvest/kitstart version in
5
5
  # package-lock.json — mkLanding refuses a mismatch.
6
- ev.url = "github:EV-invest/lib?ref=@evinvest/kitstart-v0.6.0";
6
+ ev.url = "github:EV-invest/lib?ref=@evinvest/kitstart-v0.8.0";
7
7
  ev.inputs.v_flakes.follows = "v_flakes";
8
8
  };
9
9
 
@@ -20,9 +20,9 @@
20
20
  "dependencies": {
21
21
  "@evinvest/analytics": "^0.3.0",
22
22
  "@evinvest/i18n": "^0.9.0",
23
- "@evinvest/kitstart": "^0.6.0",
23
+ "@evinvest/kitstart": "^0.8.0",
24
24
  "@evinvest/marketing": "^0.3.0",
25
- "@evinvest/uikit": "^0.24.0",
25
+ "@evinvest/uikit": "^0.25.0",
26
26
  "next": "16.3.6",
27
27
  "react": "19.3.0",
28
28
  "react-dom": "19.3.0",
@@ -7,7 +7,8 @@ import { expect, test, type Locator, type Page } from "@playwright/test";
7
7
  const SUBJECT = "Prestation";
8
8
 
9
9
  async function fillAndSend(page: Page) {
10
- const form = page.locator("#quote");
10
+ // `#quote` is the card, the callback's phone in it too: the form is its own.
11
+ const form = page.locator("#quote-form");
11
12
  await form.getByLabel("Code postal").fill("75011");
12
13
  await form.getByLabel("Téléphone").fill("0612345678");
13
14
  await form.getByRole("button", { name: "Recevoir le prix" }).click();
@@ -64,7 +65,7 @@ test.describe("with JavaScript", () => {
64
65
  await expect(trigger).toBeFocused();
65
66
  await expect(trigger).toHaveText("Grand ménage");
66
67
  await page.keyboard.press("Tab");
67
- await expect(page.locator("#quote").getByLabel("Code postal")).toBeFocused();
68
+ await expect(page.locator("#quote-form").getByLabel("Code postal")).toBeFocused();
68
69
  });
69
70
  });
70
71
 
@@ -151,7 +152,7 @@ test.describe("the channel order", () => {
151
152
  const callbackFirst = (page: Page) =>
152
153
  page.evaluate(() => {
153
154
  const callback = document.getElementById("quote-callback");
154
- const form = document.getElementById("quote");
155
+ const form = document.getElementById("quote-form");
155
156
  if (!callback || !form) throw new Error("missing the form or the callback");
156
157
  return { first: Boolean(callback.compareDocumentPosition(form) & Node.DOCUMENT_POSITION_FOLLOWING), open: callback.hasAttribute("open") };
157
158
  });
@@ -181,3 +182,29 @@ test("a place with no number offers no call, text or WhatsApp anywhere", async (
181
182
  await expect(page.locator('a[href^="tel:"], a[href^="sms:"], a[href*="wa.me"]')).toHaveCount(0);
182
183
  await expect(page.locator("#callbar").getByText("☎")).toHaveCount(0);
183
184
  });
185
+
186
+ test("#quote is the whole card: the call bar's link lands on its head", async ({ page }) => {
187
+ await page.goto("/fr#quote");
188
+ const card = page.locator("#quote");
189
+ await expect(card.getByText("Recevoir un prix", { exact: true })).toBeInViewport();
190
+ await expect(card.locator("form#quote-form")).toHaveCount(1);
191
+ await expect(card.locator("details#quote-callback")).toHaveCount(1);
192
+ });
193
+
194
+ // `LeadCapture`'s `done` posts the form itself and trusts one answer: the
195
+ // thanks page, reached through the 303. Held here against the real route.
196
+ test("a scripted post of the form lands on the thanks page", async ({ page }) => {
197
+ await page.goto("/fr");
198
+ await expect(page.locator("#quote select")).toHaveCount(0);
199
+ const form = page.locator("#quote-form");
200
+ await form.getByLabel("Code postal").fill("75011");
201
+ await form.getByLabel("Téléphone").fill("0612345678");
202
+ const answer = await form.evaluate(async el => {
203
+ if (!(el instanceof HTMLFormElement)) throw new Error("not a form");
204
+ const body = new URLSearchParams();
205
+ for (const [key, value] of new FormData(el)) if (typeof value === "string") body.append(key, value);
206
+ const res = await fetch(el.action, { method: "POST", body });
207
+ return { ok: res.ok, redirected: res.redirected, path: new URL(res.url).pathname };
208
+ });
209
+ expect(answer).toEqual({ ok: true, redirected: true, path: "/fr/thanks" });
210
+ });