@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 +16 -4
- package/dist/core/lead-capture-text.d.ts +8 -0
- package/dist/react/LeadCapture.d.ts +21 -2
- package/dist/react/LeadCapture.js +27 -7
- package/dist/react/LeadCaptureChannels.d.ts +14 -4
- package/dist/react/LeadCaptureChannels.js +12 -4
- package/dist/react/LeadCaptureFields.d.ts +4 -1
- package/dist/react/LeadCaptureFields.js +10 -4
- package/dist/react/LeadCaptureNeed.d.ts +1 -1
- package/dist/react/LeadCaptureNeed.js +1 -1
- package/dist/react/QuoteFormShell.d.ts +3 -1
- package/dist/react/QuoteFormShell.js +2 -2
- package/dist/react/index.d.ts +1 -0
- package/dist/react/use-lead-submit.d.ts +21 -0
- package/dist/react/use-lead-submit.js +42 -0
- package/package.json +1 -1
- package/template/flake.nix +1 -1
- package/template/package.json +2 -2
- package/template/tests/e2e/quote-form.spec.ts +30 -3
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.
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
|
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
|
|
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
|
-
|
|
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:
|
|
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 {
|
|
4
|
-
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
}
|
package/dist/react/index.d.ts
CHANGED
|
@@ -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.
|
|
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": {
|
package/template/flake.nix
CHANGED
|
@@ -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
|
+
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
|
|
package/template/package.json
CHANGED
|
@@ -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.
|
|
23
|
+
"@evinvest/kitstart": "^0.8.0",
|
|
24
24
|
"@evinvest/marketing": "^0.3.0",
|
|
25
|
-
"@evinvest/uikit": "^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
|
-
|
|
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
|
+
});
|