@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.
Files changed (94) hide show
  1. package/README.md +178 -25
  2. package/dist/cli/kitstart-outbox.d.ts +2 -0
  3. package/dist/cli/kitstart-outbox.js +10 -0
  4. package/dist/cli/outbox.d.ts +5 -0
  5. package/dist/cli/outbox.js +77 -0
  6. package/dist/core/accept.d.ts +44 -3
  7. package/dist/core/accept.js +96 -15
  8. package/dist/core/analytics.d.ts +4 -1
  9. package/dist/core/analytics.js +14 -0
  10. package/dist/core/lead-capture-format.d.ts +6 -0
  11. package/dist/core/lead-capture-format.js +13 -0
  12. package/dist/core/lead-capture-text.d.ts +56 -2
  13. package/dist/core/lead-capture-text.js +63 -0
  14. package/dist/core/lead.d.ts +101 -11
  15. package/dist/core/lead.js +66 -11
  16. package/dist/core/phone.d.ts +20 -9
  17. package/dist/core/phone.js +43 -19
  18. package/dist/core/pricing/flow.d.ts +27 -0
  19. package/dist/core/pricing/flow.js +37 -0
  20. package/dist/core/pricing/index.d.ts +4 -0
  21. package/dist/core/pricing/index.js +4 -0
  22. package/dist/core/pricing/model.d.ts +129 -0
  23. package/dist/core/pricing/model.js +34 -0
  24. package/dist/core/pricing/price.d.ts +34 -0
  25. package/dist/core/pricing/price.js +105 -0
  26. package/dist/core/pricing/validate.d.ts +27 -0
  27. package/dist/core/pricing/validate.js +244 -0
  28. package/dist/core/site.d.ts +7 -0
  29. package/dist/core/site.js +11 -0
  30. package/dist/core/status.d.ts +13 -0
  31. package/dist/core/status.js +19 -2
  32. package/dist/index.d.ts +7 -6
  33. package/dist/index.js +7 -6
  34. package/dist/next/quote-route.d.ts +22 -2
  35. package/dist/next/quote-route.js +72 -10
  36. package/dist/react/LeadCapture.d.ts +37 -7
  37. package/dist/react/LeadCapture.js +34 -9
  38. package/dist/react/LeadCaptureChannels.d.ts +28 -3
  39. package/dist/react/LeadCaptureChannels.js +21 -5
  40. package/dist/react/LeadCaptureEstimate.d.ts +46 -0
  41. package/dist/react/LeadCaptureEstimate.js +69 -0
  42. package/dist/react/LeadCaptureFields.d.ts +39 -7
  43. package/dist/react/LeadCaptureFields.js +91 -21
  44. package/dist/react/LeadCaptureNeed.d.ts +5 -0
  45. package/dist/react/LeadCaptureNeed.js +38 -2
  46. package/dist/react/LeadCapturePriced.d.ts +19 -0
  47. package/dist/react/LeadCapturePriced.js +14 -0
  48. package/dist/react/LeadCaptureSubmit.d.ts +16 -0
  49. package/dist/react/LeadCaptureSubmit.js +13 -0
  50. package/dist/react/QuoteFormShell.d.ts +2 -0
  51. package/dist/react/QuoteFormShell.js +3 -2
  52. package/dist/react/use-lead-error.d.ts +24 -0
  53. package/dist/react/use-lead-error.js +73 -0
  54. package/dist/react/use-lead-events.d.ts +5 -0
  55. package/dist/react/use-lead-events.js +13 -2
  56. package/dist/react/use-lead-submit.d.ts +48 -9
  57. package/dist/react/use-lead-submit.js +129 -13
  58. package/dist/react/use-validity.d.ts +17 -0
  59. package/dist/react/use-validity.js +38 -0
  60. package/dist/server/env.d.ts +9 -0
  61. package/dist/server/env.js +12 -0
  62. package/dist/server/index.d.ts +3 -2
  63. package/dist/server/index.js +3 -2
  64. package/dist/server/lead-store-sqlite.d.ts +5 -1
  65. package/dist/server/lead-store-sqlite.js +63 -2
  66. package/dist/server/lead-webhook.d.ts +68 -1
  67. package/dist/server/lead-webhook.js +34 -2
  68. package/dist/server/pricing-source.d.ts +25 -0
  69. package/dist/server/pricing-source.js +48 -0
  70. package/dist/server/webhook-outbox.d.ts +27 -1
  71. package/dist/server/webhook-outbox.js +33 -6
  72. package/dist/testing/index.d.ts +1 -0
  73. package/dist/testing/index.js +1 -0
  74. package/dist/testing/landing-contract.js +6 -0
  75. package/dist/testing/lead-rules.d.ts +18 -0
  76. package/dist/testing/lead-rules.js +50 -0
  77. package/dist/testing/lead-store-contract.js +28 -1
  78. package/package.json +3 -2
  79. package/template/app/[locale]/[location]/page.tsx +4 -2
  80. package/template/app/quote/route.ts +2 -1
  81. package/template/flake.nix +1 -1
  82. package/template/package.json +1 -1
  83. package/template/src/entities/content/model/en.ts +1 -1
  84. package/template/src/entities/content/model/fr.ts +1 -1
  85. package/template/src/shared/config/env.ts +4 -1
  86. package/template/src/shared/config/lead.ts +17 -4
  87. package/template/src/shared/config/pricing.ts +49 -0
  88. package/template/src/shared/config/site.ts +3 -0
  89. package/template/src/views/home/ui/PlaceHome.tsx +6 -3
  90. package/template/tests/bundle_budget.txt +3 -1
  91. package/template/tests/e2e/flows.spec.ts +106 -0
  92. package/template/tests/e2e/mock-panel.mjs +20 -0
  93. package/template/tests/e2e/playwright.config.ts +25 -9
  94. 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 { LeadCaptureText } from "../core/lead-capture-text.js";
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. 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.
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 { Button, cn } from "@evinvest/uikit";
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 { CallbackForm, ChannelLink, ExperimentFields } from "./LeadCaptureChannels.js";
9
- import { LocalityField, NameField, PhoneField } from "./LeadCaptureFields.js";
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 [sent, onSubmit] = useLeadSubmit(props.done !== undefined, { mobile: wire.mobile, name: props.name?.field });
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
- 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));
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 && 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 }) }));
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, 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)] }))] }));
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 { FieldPart } from "./LeadCaptureFields.js";
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, FieldLabel, Input } from "@evinvest/uikit";
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, CONSENT_FIELD } from "../core/lead.js";
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 { PHONE_INPUT_PROPS, QuoteFormShell } from "./QuoteFormShell.js";
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
- 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 })] })] }));
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. A value that does not read as a
17
- * phone gets a hint under it when the visitor leaves the field — a hint, not
18
- * a refusal: the server keeps what it cannot parse, as typed.
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: string;
23
- hint: string;
24
- placeholder: string | undefined;
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 { isPlausiblePhone } from "../core/phone.js";
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, required, optional, hydrated, classNames: c } = props;
14
- const input = useRef(null);
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: 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: () => {
21
- if (input.current)
22
- input.current.value = commune;
23
- }, children: commune }, commune))) }))] }));
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. A value that does not read as a
27
- * phone gets a hint under it when the visitor leaves the field — a hint, not
28
- * a refusal: the server keeps what it cannot parse, as typed.
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, label, hint, onSoftError, classNames: c } = props;
32
- const hintId = useId();
52
+ const { name, text, onSoftError, classNames: c } = props;
53
+ const messageId = useId();
33
54
  const [doubtful, setDoubtful] = useState(false);
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 => {
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() !== "" && !isPlausiblePhone(value);
81
+ const bad = value.trim() !== "" && phoneProblem(value) !== null;
37
82
  if (bad && !doubtful)
38
83
  onSoftError();
39
84
  setDoubtful(bad);
40
- }, onInput: e => {
41
- if (doubtful && isPlausiblePhone(e.currentTarget.value))
85
+ }, onChange: e => {
86
+ validity.onChange(e);
87
+ if (doubtful && phoneProblem(e.currentTarget.value) === null)
42
88
  setDoubtful(false);
43
- }, className: c?.control }), doubtful && (_jsx(FieldDescription, { id: hintId, className: cn("text-accent-error", c?.hint), children: hint }))] }));
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, 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 })] }));
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
  }