@evinvest/kitstart 0.8.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +178 -25
- package/dist/cli/kitstart-outbox.d.ts +2 -0
- package/dist/cli/kitstart-outbox.js +10 -0
- package/dist/cli/outbox.d.ts +5 -0
- package/dist/cli/outbox.js +77 -0
- package/dist/core/accept.d.ts +44 -3
- package/dist/core/accept.js +96 -15
- package/dist/core/analytics.d.ts +4 -1
- package/dist/core/analytics.js +14 -0
- package/dist/core/lead-capture-format.d.ts +6 -0
- package/dist/core/lead-capture-format.js +13 -0
- package/dist/core/lead-capture-text.d.ts +56 -2
- package/dist/core/lead-capture-text.js +63 -0
- package/dist/core/lead.d.ts +101 -11
- package/dist/core/lead.js +66 -11
- package/dist/core/phone.d.ts +20 -9
- package/dist/core/phone.js +43 -19
- package/dist/core/pricing/flow.d.ts +27 -0
- package/dist/core/pricing/flow.js +37 -0
- package/dist/core/pricing/index.d.ts +4 -0
- package/dist/core/pricing/index.js +4 -0
- package/dist/core/pricing/model.d.ts +129 -0
- package/dist/core/pricing/model.js +34 -0
- package/dist/core/pricing/price.d.ts +34 -0
- package/dist/core/pricing/price.js +105 -0
- package/dist/core/pricing/validate.d.ts +27 -0
- package/dist/core/pricing/validate.js +244 -0
- package/dist/core/site.d.ts +7 -0
- package/dist/core/site.js +11 -0
- package/dist/core/status.d.ts +13 -0
- package/dist/core/status.js +19 -2
- package/dist/index.d.ts +7 -6
- package/dist/index.js +7 -6
- package/dist/next/quote-route.d.ts +22 -2
- package/dist/next/quote-route.js +72 -10
- package/dist/react/LeadCapture.d.ts +37 -7
- package/dist/react/LeadCapture.js +34 -9
- package/dist/react/LeadCaptureChannels.d.ts +28 -3
- package/dist/react/LeadCaptureChannels.js +21 -5
- package/dist/react/LeadCaptureEstimate.d.ts +46 -0
- package/dist/react/LeadCaptureEstimate.js +69 -0
- package/dist/react/LeadCaptureFields.d.ts +39 -7
- package/dist/react/LeadCaptureFields.js +91 -21
- package/dist/react/LeadCaptureNeed.d.ts +5 -0
- package/dist/react/LeadCaptureNeed.js +38 -2
- package/dist/react/LeadCapturePriced.d.ts +19 -0
- package/dist/react/LeadCapturePriced.js +14 -0
- package/dist/react/LeadCaptureSubmit.d.ts +16 -0
- package/dist/react/LeadCaptureSubmit.js +13 -0
- package/dist/react/QuoteFormShell.d.ts +2 -0
- package/dist/react/QuoteFormShell.js +3 -2
- package/dist/react/use-lead-error.d.ts +24 -0
- package/dist/react/use-lead-error.js +73 -0
- package/dist/react/use-lead-events.d.ts +5 -0
- package/dist/react/use-lead-events.js +13 -2
- package/dist/react/use-lead-submit.d.ts +48 -9
- package/dist/react/use-lead-submit.js +129 -13
- package/dist/react/use-validity.d.ts +17 -0
- package/dist/react/use-validity.js +38 -0
- package/dist/server/env.d.ts +9 -0
- package/dist/server/env.js +12 -0
- package/dist/server/index.d.ts +3 -2
- package/dist/server/index.js +3 -2
- package/dist/server/lead-store-sqlite.d.ts +5 -1
- package/dist/server/lead-store-sqlite.js +63 -2
- package/dist/server/lead-webhook.d.ts +68 -1
- package/dist/server/lead-webhook.js +34 -2
- package/dist/server/pricing-source.d.ts +25 -0
- package/dist/server/pricing-source.js +48 -0
- package/dist/server/webhook-outbox.d.ts +27 -1
- package/dist/server/webhook-outbox.js +33 -6
- package/dist/testing/index.d.ts +1 -0
- package/dist/testing/index.js +1 -0
- package/dist/testing/landing-contract.js +6 -0
- package/dist/testing/lead-rules.d.ts +18 -0
- package/dist/testing/lead-rules.js +50 -0
- package/dist/testing/lead-store-contract.js +28 -1
- package/package.json +3 -2
- package/template/app/[locale]/[location]/page.tsx +4 -2
- package/template/app/quote/route.ts +2 -1
- package/template/flake.nix +1 -1
- package/template/package.json +1 -1
- package/template/src/entities/content/model/en.ts +1 -1
- package/template/src/entities/content/model/fr.ts +1 -1
- package/template/src/shared/config/env.ts +4 -1
- package/template/src/shared/config/lead.ts +17 -4
- package/template/src/shared/config/pricing.ts +49 -0
- package/template/src/shared/config/site.ts +3 -0
- package/template/src/views/home/ui/PlaceHome.tsx +6 -3
- package/template/tests/bundle_budget.txt +3 -1
- package/template/tests/e2e/flows.spec.ts +106 -0
- package/template/tests/e2e/mock-panel.mjs +20 -0
- package/template/tests/e2e/playwright.config.ts +25 -9
- package/template/tests/e2e/quote-form.spec.ts +86 -1
|
@@ -1,15 +1,19 @@
|
|
|
1
1
|
import { type ReactNode } from "react";
|
|
2
2
|
import { type CaptureChannel } from "../core/channels.js";
|
|
3
3
|
import type { LeadWire } from "../core/lead.js";
|
|
4
|
-
import type
|
|
4
|
+
import { type LeadCaptureText } from "../core/lead-capture-text.js";
|
|
5
5
|
import { type Place } from "../core/place/types.js";
|
|
6
|
+
import { type LeadFlows } from "../core/pricing/flow.js";
|
|
7
|
+
import type { PricingModel } from "../core/pricing/model.js";
|
|
6
8
|
import type { FormSelectOption } from "./FormSelect.js";
|
|
9
|
+
import { type PricedPart } from "./LeadCapturePriced.js";
|
|
7
10
|
import { type ChannelPart, type Experiment } from "./LeadCaptureChannels.js";
|
|
11
|
+
import { type EstimatePart } from "./LeadCaptureEstimate.js";
|
|
8
12
|
import { type FieldPart } from "./LeadCaptureFields.js";
|
|
9
13
|
import { type LeadCaptureLayout } from "./LeadCaptureNeed.js";
|
|
10
14
|
import type { PartClassNames } from "./parts.js";
|
|
11
15
|
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;
|
|
16
|
+
export type LeadCapturePart = "root" | "head" | "title" | "lede" | "form" | "contact" | "submit" | "trust" | "privacy" | "opening" | "others" | "done" | "needs" | "need" | "summary" | FieldPart | ChannelPart | EstimatePart | PricedPart;
|
|
13
17
|
export interface LeadCaptureProps {
|
|
14
18
|
/** Its hours order the channels; its service area suggests the commune. */
|
|
15
19
|
place: Place<string>;
|
|
@@ -25,6 +29,30 @@ export interface LeadCaptureProps {
|
|
|
25
29
|
wire: LeadWire;
|
|
26
30
|
/** The brand's subjects with their labels, in order. */
|
|
27
31
|
needs: readonly FormSelectOption[];
|
|
32
|
+
/**
|
|
33
|
+
* How each need is sold — `site.lead.flows`, the map the server prices
|
|
34
|
+
* with. A need runs `estimate` or `fixed` only when `pricing` prices it so
|
|
35
|
+
* (`flowOf`); otherwise, and without the two, every need is a `quote`.
|
|
36
|
+
*/
|
|
37
|
+
flows?: LeadFlows | undefined;
|
|
38
|
+
/**
|
|
39
|
+
* The price list the page priced from — `createPricingSource(site).model()`
|
|
40
|
+
* on the server page, handed down as data. An estimate's price is computed
|
|
41
|
+
* here as the visitor answers, and again by the route, whose number is kept.
|
|
42
|
+
*/
|
|
43
|
+
pricing?: PricingModel | null | undefined;
|
|
44
|
+
/**
|
|
45
|
+
* `quote` needs a price is given from photos (a deep clean, after-works):
|
|
46
|
+
* a WhatsApp link to send them, prefilled with the need, when the place
|
|
47
|
+
* has WhatsApp. The callback is offered as always.
|
|
48
|
+
*/
|
|
49
|
+
photos?: readonly string[] | undefined;
|
|
50
|
+
/**
|
|
51
|
+
* After a priced lead, in place of "we call you to set the slot": a
|
|
52
|
+
* booking provider's widget, when the brand has one. None ships with the
|
|
53
|
+
* kit yet.
|
|
54
|
+
*/
|
|
55
|
+
booking?: ReactNode | ((sent: LeadSent) => ReactNode);
|
|
28
56
|
/** The need the page already knows; `?need=` and `[data-need]` triggers set it too. */
|
|
29
57
|
need?: string | undefined;
|
|
30
58
|
/** `single` (one screen) or `qualify-first` (the need, then the contact) — an experiment's switch. */
|
|
@@ -46,7 +74,8 @@ export interface LeadCaptureProps {
|
|
|
46
74
|
/**
|
|
47
75
|
* The card's id, the anchor a call bar links to (`#quote`): the whole card,
|
|
48
76
|
* head included, scrolls into view. The form is `<id>-form`, the callback
|
|
49
|
-
* `<id>-callback` and its form `<id>-callback-form`.
|
|
77
|
+
* `<id>-callback` and its form `<id>-callback-form`. Posted as `card`, so a
|
|
78
|
+
* refused lead comes back here (`?lead_error=<field>#<id>`) and shows why.
|
|
50
79
|
*/
|
|
51
80
|
id?: string | undefined;
|
|
52
81
|
text: LeadCaptureText;
|
|
@@ -58,10 +87,11 @@ export interface LeadCaptureProps {
|
|
|
58
87
|
/** Whether the callback starts open; by default only when it is the channel that leads (the place is closed). */
|
|
59
88
|
callbackOpen?: boolean | undefined;
|
|
60
89
|
/**
|
|
61
|
-
* The card once a lead is taken, in place of everything in it.
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
90
|
+
* The card once a lead is taken, in place of everything in it. A script
|
|
91
|
+
* posts the form itself either way, so a refusal keeps what was typed and
|
|
92
|
+
* shows why; a lead taken shows this, or — without it — goes to the thanks
|
|
93
|
+
* page. Without a script, or on an answer that is not the route's, the form
|
|
94
|
+
* posts as it always did.
|
|
65
95
|
*/
|
|
66
96
|
done?: ReactNode | ((sent: LeadSent) => ReactNode);
|
|
67
97
|
/** Replaces the title and lede — the brand's own heading. */
|
|
@@ -1,16 +1,22 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
-
import {
|
|
3
|
+
import { cn } from "@evinvest/uikit";
|
|
4
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 { flowTextOf } from "../core/lead-capture-text.js";
|
|
7
8
|
import { servedLocalities, storefrontOf } from "../core/place/types.js";
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
9
|
+
import { flowOf } from "../core/pricing/flow.js";
|
|
10
|
+
import { LeadCapturePriced } from "./LeadCapturePriced.js";
|
|
11
|
+
import { CallbackForm, ChannelLink, ExperimentFields, PhotosAsk } from "./LeadCaptureChannels.js";
|
|
12
|
+
import { EstimateInputs, PriceBox, useEstimate } from "./LeadCaptureEstimate.js";
|
|
13
|
+
import { FormMessage, LocalityField, NameField, PhoneField } from "./LeadCaptureFields.js";
|
|
10
14
|
import { NeedField } from "./LeadCaptureNeed.js";
|
|
11
15
|
import { QuoteFormShell } from "./QuoteFormShell.js";
|
|
12
16
|
import { useHydrated, useNeed, useNow, useOpenOnHash } from "./use-lead-context.js";
|
|
17
|
+
import { errorText, formMessageId, OWN_FIELDS, useLeadError } from "./use-lead-error.js";
|
|
13
18
|
import { useLeadEvents } from "./use-lead-events.js";
|
|
19
|
+
import { FailureMessage, SubmitButton } from "./LeadCaptureSubmit.js";
|
|
14
20
|
import { useLeadSubmit } from "./use-lead-submit.js";
|
|
15
21
|
/**
|
|
16
22
|
* What the locality field is offered. A storefront serving its own town knows
|
|
@@ -37,7 +43,20 @@ export function LeadCapture(props) {
|
|
|
37
43
|
const [need, setNeed] = useNeed(props.need, needs.map(n => n.value), () => setEditing(false));
|
|
38
44
|
const events = useLeadEvents(root, { formId, layout, experiment });
|
|
39
45
|
useOpenOnHash(`${id}-callback`);
|
|
40
|
-
const [
|
|
46
|
+
const [error, setError] = useLeadError(root, id);
|
|
47
|
+
const flowText = flowTextOf(text, locale);
|
|
48
|
+
// `single` shows its select at the first need until one is picked: that is the need on screen.
|
|
49
|
+
const shownNeed = need ?? (layout === "single" ? needs[0]?.value : undefined);
|
|
50
|
+
const model = props.pricing ?? null;
|
|
51
|
+
const flow = flowOf(props.flows, model, shownNeed);
|
|
52
|
+
const estimate = useEstimate(model, shownNeed, flow, events.estimateShown);
|
|
53
|
+
// A priced lead stays in the card: its price is confirmed there, and the slot promised.
|
|
54
|
+
const staysInCard = (ch) => props.done !== undefined || (ch === "form" && flow !== "quote");
|
|
55
|
+
const { sent, onSubmit, busy, failure, retry } = useLeadSubmit(staysInCard, { mobile: wire.mobile, name: props.name?.field }, { onRefused: (channel, field) => setError({ channel, field }), onFailed: (channel, why) => events.submitError(why, channel) });
|
|
56
|
+
// The form's refusal by where it shows: under the field the card draws, else above the submit.
|
|
57
|
+
const formError = error?.channel === "form" ? error.field : null;
|
|
58
|
+
const at = (field) => (formError === field ? errorText(field, text) : null);
|
|
59
|
+
const above = formError !== null && !OWN_FIELDS.form.includes(formError) ? errorText(formError, text) : null;
|
|
41
60
|
const doneRef = useRef(null);
|
|
42
61
|
// The form the focus was in is gone: the news takes it, and is read out.
|
|
43
62
|
useEffect(() => doneRef.current?.focus(), [sent]);
|
|
@@ -64,17 +83,23 @@ export function LeadCapture(props) {
|
|
|
64
83
|
setEditing(false);
|
|
65
84
|
events.step("contact");
|
|
66
85
|
};
|
|
67
|
-
|
|
86
|
+
// An arrow key in `qualify-first`: the tile is chosen, the group stays open.
|
|
87
|
+
const select = (value) => {
|
|
88
|
+
setNeed(value);
|
|
89
|
+
setEditing(true);
|
|
90
|
+
};
|
|
91
|
+
const channel = (ch, primary) => ch === "callback" ? (_jsx(CallbackForm, { id: `${id}-callback`, card: id, 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, error: error?.channel === "callback" ? { field: error.field, text: errorText(error.field, text) } : null, busy: busy === "callback", failure: failure?.channel === "callback" ? failure.failure : null, onRetry: retry, onSoftError: () => events.fieldError("phone"), classNames: c }, ch)) : ch === "form" ? null : (_jsx(ChannelLink, { channel: ch, contact: contact, message: message, text: text, primary: primary, experiment: experiment, classNames: c }, ch));
|
|
68
92
|
// `qualify-first` shows the contact step as soon as a need's radio is
|
|
69
93
|
// checked (`:has`) — without a script, and inside the tap with one.
|
|
70
94
|
const contactShown = layout === "single" || (need !== undefined && !editing);
|
|
71
95
|
const contactClass = contactShown ? "flex" : "hidden group-has-[[data-need-option]:checked]/lead:flex";
|
|
72
96
|
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 &&
|
|
74
|
-
|
|
97
|
+
if (sent && staysInCard(sent.channel)) {
|
|
98
|
+
const done = typeof props.done === "function" ? props.done(sent) : props.done;
|
|
99
|
+
return (_jsx("div", { ref: root, ...rootProps, children: _jsxs("div", { ref: doneRef, role: "status", tabIndex: -1, className: cn("flex flex-col gap-4 outline-none", c?.done), children: [done, sent.channel === "form" && flow !== "quote" && (_jsx(LeadCapturePriced, { sent: sent, booking: typeof props.booking === "function" ? props.booking(sent) : props.booking, locale: locale, text: flowText, classNames: c }))] }) }));
|
|
75
100
|
}
|
|
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: () => {
|
|
101
|
+
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, card: id, 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, requiredText: text.needRequired, onPick: pick, onSelect: select, onEdit: () => {
|
|
77
102
|
setEditing(true);
|
|
78
103
|
events.step("need");
|
|
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,
|
|
104
|
+
}, classNames: c }), _jsxs("div", { className: cn("flex-col gap-5", contactClass, c?.contact), children: [model && shownNeed !== undefined && flow === "estimate" && (_jsx(EstimateInputs, { model: model, need: shownNeed, locale: locale, answers: estimate.answers, onAnswer: estimate.answer, required: hydrated, classNames: c })), model && flow !== "quote" && _jsx(PriceBox, { model: model, flow: flow, price: estimate.price, locale: locale, text: flowText, classNames: c }), flow === "quote" && shownNeed !== undefined && props.photos?.includes(shownNeed) && (_jsx(PhotosAsk, { whatsapp: contact.whatsapp, needLabel: needs.find(n => n.value === shownNeed)?.label ?? shownNeed, text: flowText, experiment: experiment, className: c?.photos })), _jsx(LocalityField, { name: wire.locality, label: text.localityLabel, servedLabel: text.servedLabel, served: localitySuggestions(place), placeholder: text.localityPlaceholder, required: props.locality !== "optional", optional: text.optional, requiredText: text.required, error: at("locality"), hydrated: hydrated, classNames: c }), _jsx(PhoneField, { name: wire.mobile, text: text, error: at("phone"), 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, requiredText: text.required, error: at("name"), classNames: c })), props.extras, _jsx(FormMessage, { id: formMessageId(id, "form"), error: above, className: c?.error }), _jsx(FailureMessage, { failure: failure?.channel === "form" ? failure.failure : null, text: text, onRetry: retry, className: c?.error }), _jsxs("div", { className: cn("flex flex-col gap-3", c?.trust), children: [_jsx(SubmitButton, { busy: busy === "form", label: flow === "quote" ? text.submit : flowText.bookSubmit, sending: text.sending, className: c?.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)] }))] }));
|
|
80
105
|
}
|
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
import { type CaptureChannel } from "../core/channels.js";
|
|
2
|
-
import type { LeadCaptureText } from "../core/lead-capture-text.js";
|
|
2
|
+
import type { LeadCaptureFlowText, LeadCaptureText } from "../core/lead-capture-text.js";
|
|
3
3
|
import type { FormEventHandler } from "react";
|
|
4
|
-
import type
|
|
4
|
+
import { type FieldPart } from "./LeadCaptureFields.js";
|
|
5
|
+
import type { SendFailure } from "./use-lead-submit.js";
|
|
5
6
|
import { type PartClassNames } from "./parts.js";
|
|
6
7
|
/**
|
|
7
8
|
* `channel` and `primary` dress every way out, the callback's summary
|
|
8
9
|
* included; the `callback*` parts are the callback's own, after them.
|
|
9
10
|
*/
|
|
10
|
-
export type ChannelPart = "channel" | "primary" | "callback" | "callbackSummary" | "callbackForm" | "callbackLede" | "callbackSubmit" | "consent";
|
|
11
|
+
export type ChannelPart = "channel" | "primary" | "callback" | "callbackSummary" | "callbackForm" | "callbackLede" | "callbackSubmit" | "consent" | "photos";
|
|
11
12
|
export interface Experiment {
|
|
12
13
|
name: string;
|
|
13
14
|
variant: string;
|
|
@@ -44,6 +45,8 @@ export declare function ChannelLink(props: {
|
|
|
44
45
|
*/
|
|
45
46
|
export declare function CallbackForm(props: {
|
|
46
47
|
id: string;
|
|
48
|
+
/** The card's id, posted so a refusal opens this callback again. */
|
|
49
|
+
card: string;
|
|
47
50
|
/** The channel that leads: the primary face. */
|
|
48
51
|
primary: boolean;
|
|
49
52
|
open: boolean;
|
|
@@ -60,5 +63,27 @@ export declare function CallbackForm(props: {
|
|
|
60
63
|
text: LeadCaptureText;
|
|
61
64
|
experiment: Experiment | undefined;
|
|
62
65
|
onSubmit: FormEventHandler<HTMLFormElement> | undefined;
|
|
66
|
+
/** The server's refusal: the field and its words. */
|
|
67
|
+
error: {
|
|
68
|
+
field: string;
|
|
69
|
+
text: string;
|
|
70
|
+
} | null;
|
|
71
|
+
/** The script is posting this form. */
|
|
72
|
+
busy: boolean;
|
|
73
|
+
failure: SendFailure | null;
|
|
74
|
+
onRetry: () => void;
|
|
75
|
+
onSoftError: () => void;
|
|
63
76
|
classNames?: PartClassNames<ChannelPart | FieldPart> | undefined;
|
|
64
77
|
}): import("react").JSX.Element;
|
|
78
|
+
/**
|
|
79
|
+
* A `quote` need priced from photos (a deep clean, after-works): the photos
|
|
80
|
+
* go by WhatsApp, with the need already said — only when the place has
|
|
81
|
+
* WhatsApp; the callback stays where it is.
|
|
82
|
+
*/
|
|
83
|
+
export declare function PhotosAsk(props: {
|
|
84
|
+
whatsapp: string | null;
|
|
85
|
+
needLabel: string;
|
|
86
|
+
text: Pick<LeadCaptureFlowText, "photosTitle" | "photosLede" | "photosCta" | "photosMessage">;
|
|
87
|
+
experiment: Experiment | undefined;
|
|
88
|
+
className?: string | undefined;
|
|
89
|
+
}): import("react").JSX.Element | null;
|
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import { Button, buttonVariants, cn
|
|
2
|
+
import { Button, buttonVariants, cn } from "@evinvest/uikit";
|
|
3
3
|
import { EXPERIMENT_FIELD, VARIANT_FIELD } from "../core/accept.js";
|
|
4
4
|
import { channelHref } from "../core/channels.js";
|
|
5
|
-
import { CHANNEL_FIELD
|
|
5
|
+
import { CHANNEL_FIELD } from "../core/lead.js";
|
|
6
|
+
import { fillText } from "../core/lead-capture-format.js";
|
|
7
|
+
import { ConsentField, FormMessage, PhoneField } from "./LeadCaptureFields.js";
|
|
8
|
+
import { FailureMessage, SubmitButton } from "./LeadCaptureSubmit.js";
|
|
6
9
|
import { partWithLeading } from "./parts.js";
|
|
7
|
-
import {
|
|
10
|
+
import { QuoteFormShell } from "./QuoteFormShell.js";
|
|
8
11
|
/**
|
|
9
12
|
* A whole-pixel line under a brand's own type size: tailwind-merge drops a
|
|
10
13
|
* size's line height for nothing, and a 15 px face at the page's 1.5 is a
|
|
@@ -40,6 +43,19 @@ export function ChannelLink(props) {
|
|
|
40
43
|
* choose `open` itself; it is still the leading channel's face.
|
|
41
44
|
*/
|
|
42
45
|
export function CallbackForm(props) {
|
|
43
|
-
const { id, primary, open, formId, placeSlug, locale, renderedAt, mobileName, subject, opening, text, experiment, classNames: c } = props;
|
|
44
|
-
|
|
46
|
+
const { id, primary, open, formId, placeSlug, locale, renderedAt, mobileName, subject, opening, text, experiment, error, classNames: c } = props;
|
|
47
|
+
const at = (field) => (error?.field === field ? error.text : null);
|
|
48
|
+
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, card: props.card, onSubmit: props.onSubmit, className: cn("mt-4 gap-4", c?.callbackForm), children: [_jsx("input", { type: "hidden", name: CHANNEL_FIELD, value: "callback" }), subject && _jsx("input", { type: "hidden", name: subject.name, value: subject.value }), _jsx(ExperimentFields, { experiment: experiment }), _jsx("p", { className: cn("text-ink-soft", c?.callbackLede), children: opening ?? text.callbackLede }), _jsx(PhoneField, { id: `${id}-phone`, name: mobileName, text: text, error: at("phone"), onSoftError: props.onSoftError, classNames: c }), _jsx(ConsentField, { sentence: text.callbackConsent, requiredText: text.consentRequired, error: at("consent"), className: c?.consent, classNames: c }), _jsx(FormMessage, { id: `${id}-error`, error: error && error.field !== "phone" && error.field !== "consent" ? error.text : null, className: c?.error }), _jsx(FailureMessage, { failure: props.failure, text: text, onRetry: props.onRetry, className: c?.error }), _jsx(SubmitButton, { busy: props.busy, label: text.callbackSubmit, sending: text.sending, className: c?.callbackSubmit })] })] }));
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* A `quote` need priced from photos (a deep clean, after-works): the photos
|
|
52
|
+
* go by WhatsApp, with the need already said — only when the place has
|
|
53
|
+
* WhatsApp; the callback stays where it is.
|
|
54
|
+
*/
|
|
55
|
+
export function PhotosAsk(props) {
|
|
56
|
+
const { whatsapp, text, experiment } = props;
|
|
57
|
+
const href = whatsapp ? channelHref("whatsapp", { phone: null, whatsapp }, fillText(text.photosMessage, { need: props.needLabel })) : null;
|
|
58
|
+
if (!href)
|
|
59
|
+
return null;
|
|
60
|
+
return (_jsxs("div", { className: cn("flex flex-col gap-2 rounded-[var(--control-radius)] border border-border p-4", props.className), children: [_jsx("p", { className: "font-medium text-ink", children: text.photosTitle }), _jsx("p", { className: "text-sm text-ink-soft", children: text.photosLede }), _jsx(Button, { href: href, variant: "outline", size: "touch", className: "w-full", "data-experiment": experiment?.name, "data-variant": experiment?.variant, children: text.photosCta })] }));
|
|
45
61
|
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { LeadCaptureFlowText } from "../core/lead-capture-text.js";
|
|
2
|
+
import { type LeadFlow } from "../core/pricing/flow.js";
|
|
3
|
+
import type { Price, PricingModel } from "../core/pricing/model.js";
|
|
4
|
+
import type { PartClassNames } from "./parts.js";
|
|
5
|
+
export type EstimatePart = "estimate" | "estimateInput" | "estimateLegend" | "estimateOption" | "price" | "priceTotal" | "breakdown" | "priceNote";
|
|
6
|
+
/**
|
|
7
|
+
* The live price of the need on screen: its answers, as the visitor taps
|
|
8
|
+
* them, and the price `priceOf` makes of them — the same function the server
|
|
9
|
+
* stores the lead's price with. A full set of answers is reported once per
|
|
10
|
+
* band (`onShown`), never the price itself.
|
|
11
|
+
*/
|
|
12
|
+
export declare function useEstimate(model: PricingModel | null, need: string | undefined, flow: LeadFlow, onShown: (need: string, cents: number) => void): {
|
|
13
|
+
answers: Readonly<Record<string, string>>;
|
|
14
|
+
answer: (input: string, option: string) => void;
|
|
15
|
+
price: Price | null;
|
|
16
|
+
};
|
|
17
|
+
/**
|
|
18
|
+
* An estimate's questions, a tile per answer — touch-sized radios, so the
|
|
19
|
+
* answers post without a script too (the server prices them; the live price
|
|
20
|
+
* needs the script). Required once the script runs: a price needs every
|
|
21
|
+
* answer, and without the script an unanswered estimate is still a lead, a
|
|
22
|
+
* quote.
|
|
23
|
+
*/
|
|
24
|
+
export declare function EstimateInputs(props: {
|
|
25
|
+
model: PricingModel;
|
|
26
|
+
need: string;
|
|
27
|
+
locale: string;
|
|
28
|
+
answers: Readonly<Record<string, string>>;
|
|
29
|
+
onAnswer: (input: string, option: string) => void;
|
|
30
|
+
required: boolean;
|
|
31
|
+
classNames?: PartClassNames<EstimatePart> | undefined;
|
|
32
|
+
}): import("react").JSX.Element | null;
|
|
33
|
+
/**
|
|
34
|
+
* The price, read out as it changes (`aria-live`): an estimate's total and
|
|
35
|
+
* how it was reached, or — until every question is answered — what is
|
|
36
|
+
* missing; a fixed need's price, as is. Never "from": it is the price for
|
|
37
|
+
* these answers.
|
|
38
|
+
*/
|
|
39
|
+
export declare function PriceBox(props: {
|
|
40
|
+
model: PricingModel;
|
|
41
|
+
flow: Exclude<LeadFlow, "quote">;
|
|
42
|
+
price: Price | null;
|
|
43
|
+
locale: string;
|
|
44
|
+
text: LeadCaptureFlowText;
|
|
45
|
+
classNames?: PartClassNames<EstimatePart> | undefined;
|
|
46
|
+
}): import("react").JSX.Element;
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { cn } from "@evinvest/uikit";
|
|
4
|
+
import { useEffect, useMemo, useState } from "react";
|
|
5
|
+
import { formatCents } from "../core/lead-capture-format.js";
|
|
6
|
+
import { estimateField } from "../core/pricing/flow.js";
|
|
7
|
+
import { priceOf } from "../core/pricing/price.js";
|
|
8
|
+
import { labelOf } from "../core/pricing/validate.js";
|
|
9
|
+
/**
|
|
10
|
+
* The live price of the need on screen: its answers, as the visitor taps
|
|
11
|
+
* them, and the price `priceOf` makes of them — the same function the server
|
|
12
|
+
* stores the lead's price with. A full set of answers is reported once per
|
|
13
|
+
* band (`onShown`), never the price itself.
|
|
14
|
+
*/
|
|
15
|
+
export function useEstimate(model, need, flow, onShown) {
|
|
16
|
+
const [answers, setAnswers] = useState({});
|
|
17
|
+
const price = useMemo(() => (flow === "quote" || !model || need === undefined ? null : priceOf(model, need, flow === "estimate" ? answers : {})), [model, need, flow, answers]);
|
|
18
|
+
const cents = flow === "estimate" ? price?.cents : undefined;
|
|
19
|
+
useEffect(() => {
|
|
20
|
+
if (need !== undefined && cents !== undefined)
|
|
21
|
+
onShown(need, cents);
|
|
22
|
+
}, [need, cents, onShown]);
|
|
23
|
+
const answer = (input, option) => setAnswers(prev => ({ ...prev, [input]: option }));
|
|
24
|
+
return { answers, answer, price };
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* An estimate's questions, a tile per answer — touch-sized radios, so the
|
|
28
|
+
* answers post without a script too (the server prices them; the live price
|
|
29
|
+
* needs the script). Required once the script runs: a price needs every
|
|
30
|
+
* answer, and without the script an unanswered estimate is still a lead, a
|
|
31
|
+
* quote.
|
|
32
|
+
*/
|
|
33
|
+
export function EstimateInputs(props) {
|
|
34
|
+
const { model, need, locale, answers, classNames: c } = props;
|
|
35
|
+
const pricing = model.needs[need];
|
|
36
|
+
if (pricing?.kind !== "estimate")
|
|
37
|
+
return null;
|
|
38
|
+
const inputs = pricing.inputs.map(id => model.inputs.find(i => i.id === id)).filter((i) => i !== undefined);
|
|
39
|
+
return (_jsx("div", { className: cn("flex flex-col gap-5", c?.estimate), children: inputs.map(input => (_jsxs("fieldset", { className: cn("flex flex-col gap-2", c?.estimateInput), children: [_jsx("legend", { className: cn("mb-2 text-sm font-medium text-ink", c?.estimateLegend), children: labelOf(input.labels, locale) }), _jsx("div", { className: "grid grid-cols-2 gap-2 sm:grid-cols-3", children: input.options.map(option => (_jsxs("label", { className: "relative block cursor-pointer", children: [_jsx("input", { type: "radio", name: estimateField(input.id), value: option.id, required: props.required, checked: answers[input.id] === option.id, onChange: () => props.onAnswer(input.id, option.id), className: "peer absolute inset-0 opacity-0", "data-lead-field": estimateField(input.id) }), _jsx("span", { className: cn("flex h-full min-h-11 items-center rounded-[var(--control-radius)] border border-input px-3 py-2 text-ink transition-colors", "peer-checked:border-primary peer-checked:bg-hover peer-focus-visible:ring-[3px] peer-focus-visible:ring-ring/50", c?.estimateOption), children: labelOf(option.labels, locale) })] }, option.id))) })] }, input.id))) }));
|
|
40
|
+
}
|
|
41
|
+
function lineLabel(line, model, locale, text) {
|
|
42
|
+
switch (line.kind) {
|
|
43
|
+
case "base":
|
|
44
|
+
return text.priceBase;
|
|
45
|
+
case "rounding":
|
|
46
|
+
return text.priceRounding;
|
|
47
|
+
case "minimum":
|
|
48
|
+
return text.priceMinimum;
|
|
49
|
+
case "add":
|
|
50
|
+
case "multiply":
|
|
51
|
+
case "discount": {
|
|
52
|
+
// The answer names itself ("2 chambres", "Toutes les 2 semaines"); the question would only repeat it.
|
|
53
|
+
const option = model.inputs.find(i => i.id === line.input)?.options.find(o => o.id === line.option);
|
|
54
|
+
return option ? labelOf(option.labels, locale) : line.option;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* The price, read out as it changes (`aria-live`): an estimate's total and
|
|
60
|
+
* how it was reached, or — until every question is answered — what is
|
|
61
|
+
* missing; a fixed need's price, as is. Never "from": it is the price for
|
|
62
|
+
* these answers.
|
|
63
|
+
*/
|
|
64
|
+
export function PriceBox(props) {
|
|
65
|
+
const { model, flow, price, locale, text, classNames: c } = props;
|
|
66
|
+
// A line that changed nothing (an answer worth 0) is not worth reading out.
|
|
67
|
+
const lines = price?.breakdown.filter((line, i) => i === 0 || line.cents !== 0) ?? [];
|
|
68
|
+
return (_jsxs("div", { "aria-live": "polite", "aria-atomic": "true", className: cn("flex flex-col gap-2 rounded-[var(--control-radius)] border border-border bg-card p-4", c?.price), children: [_jsx("p", { className: "text-sm text-ink-soft", children: text.priceTitle }), price ? (_jsx("p", { className: cn("font-display text-3xl font-bold text-ink", c?.priceTotal), "data-price-cents": price.cents, children: formatCents(price.cents, locale) })) : (_jsx("p", { className: "text-ink", children: text.pricePending })), flow === "estimate" && lines.length > 1 && (_jsx("ul", { className: cn("flex flex-col gap-1 text-sm text-ink-soft", c?.breakdown), children: lines.map((line, i) => (_jsxs("li", { className: "flex justify-between gap-3", children: [_jsx("span", { children: lineLabel(line, model, locale, text) }), _jsx("span", { className: "tabular-nums", children: formatCents(line.cents, locale, i > 0) })] }, i))) })), _jsx("p", { className: cn("text-sm text-ink-soft", c?.priceNote), children: text.priceNote })] }));
|
|
69
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
+
import type { LeadCaptureText } from "../core/lead-capture-text.js";
|
|
1
2
|
import type { PartClassNames } from "./parts.js";
|
|
2
|
-
export type FieldPart = "field" | "label" | "control" | "hint" | "chips" | "chip";
|
|
3
|
+
export type FieldPart = "field" | "label" | "control" | "hint" | "error" | "chips" | "chip";
|
|
3
4
|
export declare function LocalityField(props: {
|
|
4
5
|
name: string;
|
|
5
6
|
label: string;
|
|
@@ -9,19 +10,26 @@ export declare function LocalityField(props: {
|
|
|
9
10
|
placeholder: string | undefined;
|
|
10
11
|
required: boolean;
|
|
11
12
|
optional: string;
|
|
13
|
+
requiredText: string;
|
|
14
|
+
/** The server's refusal of this field, in the page's words. */
|
|
15
|
+
error: string | null;
|
|
12
16
|
hydrated: boolean;
|
|
13
17
|
classNames?: PartClassNames<FieldPart> | undefined;
|
|
14
18
|
}): import("react").JSX.Element;
|
|
19
|
+
export type PhoneText = Pick<LeadCaptureText, "phoneLabel" | "phoneHint" | "phoneInvalid" | "required" | "phonePlaceholder">;
|
|
15
20
|
/**
|
|
16
|
-
* The number, required and never masked
|
|
17
|
-
*
|
|
18
|
-
*
|
|
21
|
+
* The number, required and never masked, on the rule the server holds it to
|
|
22
|
+
* (`phoneProblem`): one it would refuse blocks the submit, in the page's
|
|
23
|
+
* words. Leaving the field with such a number shows the hint already — a
|
|
24
|
+
* hint, read out, not yet an error.
|
|
19
25
|
*/
|
|
20
26
|
export declare function PhoneField(props: {
|
|
21
27
|
name: string;
|
|
22
|
-
label
|
|
23
|
-
|
|
24
|
-
|
|
28
|
+
/** Named when the label is not the `Field`'s own (the callback's). */
|
|
29
|
+
id?: string | undefined;
|
|
30
|
+
text: PhoneText;
|
|
31
|
+
/** The server's refusal of this field, in the page's words. */
|
|
32
|
+
error: string | null;
|
|
25
33
|
onSoftError: () => void;
|
|
26
34
|
classNames?: PartClassNames<FieldPart> | undefined;
|
|
27
35
|
}): import("react").JSX.Element;
|
|
@@ -31,5 +39,29 @@ export declare function NameField(props: {
|
|
|
31
39
|
placeholder: string | undefined;
|
|
32
40
|
required: boolean;
|
|
33
41
|
optional: string;
|
|
42
|
+
requiredText: string;
|
|
43
|
+
error: string | null;
|
|
34
44
|
classNames?: PartClassNames<FieldPart> | undefined;
|
|
35
45
|
}): import("react").JSX.Element;
|
|
46
|
+
/**
|
|
47
|
+
* The callback's consent: a native checkbox, so `required` holds without a
|
|
48
|
+
* script (the kit's checkbox is a button), whose value is the sentence beside
|
|
49
|
+
* it — the lead keeps what was agreed to, word for word.
|
|
50
|
+
*/
|
|
51
|
+
export declare function ConsentField(props: {
|
|
52
|
+
sentence: string;
|
|
53
|
+
requiredText: string;
|
|
54
|
+
error: string | null;
|
|
55
|
+
className: string | undefined;
|
|
56
|
+
classNames: PartClassNames<FieldPart> | undefined;
|
|
57
|
+
}): import("react").JSX.Element;
|
|
58
|
+
/**
|
|
59
|
+
* A refusal no field shows under itself — a brand's extra, the whole form —
|
|
60
|
+
* just above the submit. Only there when there is one: an alert is read out
|
|
61
|
+
* as it is inserted, and an empty box would add the form's gap.
|
|
62
|
+
*/
|
|
63
|
+
export declare function FormMessage(props: {
|
|
64
|
+
id: string;
|
|
65
|
+
error: string | null;
|
|
66
|
+
className: string | undefined;
|
|
67
|
+
}): import("react").JSX.Element | null;
|
|
@@ -1,48 +1,118 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
-
import { Button, cn, Field, FieldDescription, FieldLabel, Input } from "@evinvest/uikit";
|
|
4
|
-
import { useId, useRef, useState } from "react";
|
|
5
|
-
import {
|
|
3
|
+
import { Button, cn, Field, FieldDescription, FieldError, FieldLabel, Input } from "@evinvest/uikit";
|
|
4
|
+
import { useEffect, useId, useRef, useState } from "react";
|
|
5
|
+
import { CONSENT_FIELD } from "../core/lead.js";
|
|
6
|
+
import { phoneProblem } from "../core/phone.js";
|
|
6
7
|
import { PHONE_INPUT_PROPS } from "./QuoteFormShell.js";
|
|
8
|
+
import { useValidity } from "./use-validity.js";
|
|
7
9
|
/** More than this and the row is a list to read, not a tap to save typing. */
|
|
8
10
|
const MAX_CHIPS = 6;
|
|
9
11
|
const optionalLabel = (label, optional) => (optional ? `${label} (${optional})` : label);
|
|
10
12
|
/** Digits only: a postcode, which the numeric keypad can type and edit. */
|
|
11
13
|
const POSTCODE = /^\d+$/;
|
|
14
|
+
/**
|
|
15
|
+
* What a field says under itself: the error that blocks (the server's, or the
|
|
16
|
+
* browser's refusal in the page's words) as an alert, else a hint. Always in
|
|
17
|
+
* the DOM: a live region announces only what changes inside it.
|
|
18
|
+
*/
|
|
19
|
+
function FieldMessage(props) {
|
|
20
|
+
const { id, error, hint, classNames: c } = props;
|
|
21
|
+
return (
|
|
22
|
+
// Empty, it gives back the field's gap rather than vanish: a live region
|
|
23
|
+
// hidden while empty is not one some screen readers listen to.
|
|
24
|
+
_jsx("div", { "aria-live": "polite", className: "empty:-mt-2", children: error ? (_jsx(FieldError, { id: id, className: cn("text-accent-error", c?.error), children: error })) : hint ? (_jsx(FieldDescription, { id: id, className: cn("text-accent-error", c?.hint), children: hint })) : null }));
|
|
25
|
+
}
|
|
26
|
+
const required = (text, on) => (el) => (on && el.value.trim() === "" ? text : "");
|
|
12
27
|
export function LocalityField(props) {
|
|
13
|
-
const { name, label, servedLabel, served,
|
|
14
|
-
const
|
|
28
|
+
const { name, label, servedLabel, served, optional, hydrated, classNames: c } = props;
|
|
29
|
+
const messageId = useId();
|
|
30
|
+
const validity = useValidity(required(props.requiredText, props.required));
|
|
31
|
+
const error = props.error ?? validity.shown;
|
|
15
32
|
const chips = hydrated && served.length > 1 && served.length <= MAX_CHIPS;
|
|
16
33
|
// A commune's name filled in or a tap away must stay editable: iOS's numeric
|
|
17
34
|
// keypad has no letters, so the keypad follows what the field is offered.
|
|
18
35
|
// A list too long for chips offers nothing — the visitor types a postcode.
|
|
19
36
|
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:
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
37
|
+
return (_jsxs(Field, { className: cn("flex flex-col gap-2", c?.field), children: [_jsx(FieldLabel, { className: c?.label, children: optionalLabel(label, props.required ? null : optional) }), _jsx(Input, { ref: validity.ref, name: name, size: "lg", inputMode: numeric ? "numeric" : "text", autoComplete: "postal-code", placeholder: props.placeholder, enterKeyHint: "next", required: props.required, defaultValue: served.length === 1 ? served[0] : undefined, "data-lead-field": "locality", "aria-invalid": error ? true : undefined, "aria-describedby": error ? messageId : undefined, onInvalid: validity.onInvalid, onChange: validity.onChange, className: c?.control }), chips && (_jsx("div", { role: "group", "aria-label": servedLabel, className: cn("flex flex-wrap gap-2", c?.chips), children: served.map(commune => (_jsx(Button, { type: "button", variant: "outline", size: "touch", className: cn("px-3", c?.chip), onClick: () => {
|
|
38
|
+
const el = validity.ref.current;
|
|
39
|
+
if (!el)
|
|
40
|
+
return;
|
|
41
|
+
el.value = commune;
|
|
42
|
+
validity.recheck(el);
|
|
43
|
+
}, children: commune }, commune))) })), _jsx(FieldMessage, { id: messageId, error: error, classNames: c })] }));
|
|
24
44
|
}
|
|
25
45
|
/**
|
|
26
|
-
* The number, required and never masked
|
|
27
|
-
*
|
|
28
|
-
*
|
|
46
|
+
* The number, required and never masked, on the rule the server holds it to
|
|
47
|
+
* (`phoneProblem`): one it would refuse blocks the submit, in the page's
|
|
48
|
+
* words. Leaving the field with such a number shows the hint already — a
|
|
49
|
+
* hint, read out, not yet an error.
|
|
29
50
|
*/
|
|
30
51
|
export function PhoneField(props) {
|
|
31
|
-
const { name,
|
|
32
|
-
const
|
|
52
|
+
const { name, text, onSoftError, classNames: c } = props;
|
|
53
|
+
const messageId = useId();
|
|
33
54
|
const [doubtful, setDoubtful] = useState(false);
|
|
34
|
-
|
|
55
|
+
const validity = useValidity(el => {
|
|
56
|
+
const problem = phoneProblem(el.value);
|
|
57
|
+
return problem === "required" ? text.required : problem === "invalid" ? text.phoneInvalid : "";
|
|
58
|
+
});
|
|
59
|
+
const error = props.error ?? validity.shown;
|
|
60
|
+
const marked = Boolean(error) || doubtful;
|
|
61
|
+
// A hint drawn on the blur a press of the submit causes pushes the button
|
|
62
|
+
// down between press and release, and the click lands on nothing: the
|
|
63
|
+
// submit's own check speaks instead.
|
|
64
|
+
const submitting = useRef(false);
|
|
65
|
+
useEffect(() => {
|
|
66
|
+
const form = validity.ref.current?.form;
|
|
67
|
+
if (!form)
|
|
68
|
+
return;
|
|
69
|
+
const press = (event) => {
|
|
70
|
+
submitting.current = event.target instanceof Element && event.target.closest("[type=submit]") !== null;
|
|
71
|
+
};
|
|
72
|
+
form.addEventListener("pointerdown", press, true);
|
|
73
|
+
return () => form.removeEventListener("pointerdown", press, true);
|
|
74
|
+
}, [validity.ref]);
|
|
75
|
+
return (_jsxs(Field, { ...(props.id ? { controlId: props.id } : {}), className: cn("flex flex-col gap-2", c?.field), children: [_jsx(FieldLabel, { className: c?.label, children: text.phoneLabel }), _jsx(Input, { ref: validity.ref, name: name, size: "lg", ...PHONE_INPUT_PROPS, enterKeyHint: "send", placeholder: text.phonePlaceholder, required: true, "data-lead-field": "phone", "aria-invalid": marked || undefined, "aria-describedby": marked ? messageId : undefined, onInvalid: validity.onInvalid, onBlur: e => {
|
|
76
|
+
if (submitting.current) {
|
|
77
|
+
submitting.current = false;
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
35
80
|
const value = e.currentTarget.value;
|
|
36
|
-
const bad = value.trim() !== "" &&
|
|
81
|
+
const bad = value.trim() !== "" && phoneProblem(value) !== null;
|
|
37
82
|
if (bad && !doubtful)
|
|
38
83
|
onSoftError();
|
|
39
84
|
setDoubtful(bad);
|
|
40
|
-
},
|
|
41
|
-
|
|
85
|
+
}, onChange: e => {
|
|
86
|
+
validity.onChange(e);
|
|
87
|
+
if (doubtful && phoneProblem(e.currentTarget.value) === null)
|
|
42
88
|
setDoubtful(false);
|
|
43
|
-
}, className: c?.control }),
|
|
89
|
+
}, className: c?.control }), _jsx(FieldMessage, { id: messageId, error: error, hint: doubtful ? text.phoneHint : null, classNames: c })] }));
|
|
44
90
|
}
|
|
45
91
|
export function NameField(props) {
|
|
46
|
-
const { name, label, placeholder,
|
|
47
|
-
|
|
92
|
+
const { name, label, placeholder, optional, classNames: c } = props;
|
|
93
|
+
const messageId = useId();
|
|
94
|
+
const validity = useValidity(required(props.requiredText, props.required));
|
|
95
|
+
const error = props.error ?? validity.shown;
|
|
96
|
+
return (_jsxs(Field, { className: cn("flex flex-col gap-2", c?.field), children: [_jsx(FieldLabel, { className: c?.label, children: optionalLabel(label, props.required ? null : optional) }), _jsx(Input, { ref: validity.ref, name: name, size: "lg", autoComplete: "name", enterKeyHint: "next", placeholder: placeholder, required: props.required, "data-lead-field": "name", "aria-invalid": error ? true : undefined, "aria-describedby": error ? messageId : undefined, onInvalid: validity.onInvalid, onChange: validity.onChange, className: c?.control }), _jsx(FieldMessage, { id: messageId, error: error, classNames: c })] }));
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* The callback's consent: a native checkbox, so `required` holds without a
|
|
100
|
+
* script (the kit's checkbox is a button), whose value is the sentence beside
|
|
101
|
+
* it — the lead keeps what was agreed to, word for word.
|
|
102
|
+
*/
|
|
103
|
+
export function ConsentField(props) {
|
|
104
|
+
const messageId = useId();
|
|
105
|
+
const validity = useValidity(el => (el.checked ? "" : props.requiredText));
|
|
106
|
+
const error = props.error ?? validity.shown;
|
|
107
|
+
return (_jsxs("div", { className: "flex flex-col gap-2", children: [_jsxs("label", { className: cn("flex min-h-11 items-start gap-3 text-sm text-ink", props.className), children: [_jsx("input", { ref: validity.ref, type: "checkbox", name: CONSENT_FIELD, value: props.sentence, required: true, "data-lead-field": "consent", "aria-invalid": error ? true : undefined, "aria-describedby": error ? messageId : undefined, onInvalid: validity.onInvalid, onChange: validity.onChange, className: "mt-0.5 size-5 shrink-0 accent-primary" }), _jsx("span", { children: props.sentence })] }), _jsx(FieldMessage, { id: messageId, error: error, classNames: props.classNames })] }));
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* A refusal no field shows under itself — a brand's extra, the whole form —
|
|
111
|
+
* just above the submit. Only there when there is one: an alert is read out
|
|
112
|
+
* as it is inserted, and an empty box would add the form's gap.
|
|
113
|
+
*/
|
|
114
|
+
export function FormMessage(props) {
|
|
115
|
+
if (!props.error)
|
|
116
|
+
return null;
|
|
117
|
+
return (_jsx(FieldError, { id: props.id, tabIndex: -1, className: cn("text-accent-error outline-none", props.className), children: props.error }));
|
|
48
118
|
}
|
|
@@ -12,7 +12,12 @@ export interface NeedFieldProps {
|
|
|
12
12
|
hydrated: boolean;
|
|
13
13
|
label: string;
|
|
14
14
|
changeLabel: string;
|
|
15
|
+
/** Blocks the submit while no tile is chosen, in the page's words. */
|
|
16
|
+
requiredText: string;
|
|
17
|
+
/** The visitor's answer: a tap, Enter or Space — `qualify-first` moves on. */
|
|
15
18
|
onPick: (need: string) => void;
|
|
19
|
+
/** An arrow key moved the choice: chosen, but the visitor is still choosing. */
|
|
20
|
+
onSelect: (need: string) => void;
|
|
16
21
|
onEdit: () => void;
|
|
17
22
|
classNames?: PartClassNames<NeedPart> | undefined;
|
|
18
23
|
}
|