@evinvest/kitstart 0.8.0 → 0.9.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 (62) hide show
  1. package/README.md +89 -23
  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 +22 -3
  7. package/dist/core/accept.js +40 -13
  8. package/dist/core/analytics.d.ts +3 -1
  9. package/dist/core/analytics.js +7 -0
  10. package/dist/core/lead-capture-text.d.ts +24 -1
  11. package/dist/core/lead-capture-text.js +20 -0
  12. package/dist/core/lead.d.ts +71 -10
  13. package/dist/core/lead.js +65 -11
  14. package/dist/core/phone.d.ts +20 -9
  15. package/dist/core/phone.js +43 -19
  16. package/dist/core/status.d.ts +13 -0
  17. package/dist/core/status.js +19 -2
  18. package/dist/index.d.ts +3 -3
  19. package/dist/index.js +3 -3
  20. package/dist/next/quote-route.d.ts +15 -2
  21. package/dist/next/quote-route.js +70 -10
  22. package/dist/react/LeadCapture.d.ts +7 -5
  23. package/dist/react/LeadCapture.js +18 -6
  24. package/dist/react/LeadCaptureChannels.d.ts +14 -1
  25. package/dist/react/LeadCaptureChannels.js +8 -5
  26. package/dist/react/LeadCaptureFields.d.ts +39 -7
  27. package/dist/react/LeadCaptureFields.js +91 -21
  28. package/dist/react/LeadCaptureNeed.d.ts +5 -0
  29. package/dist/react/LeadCaptureNeed.js +38 -2
  30. package/dist/react/LeadCaptureSubmit.d.ts +16 -0
  31. package/dist/react/LeadCaptureSubmit.js +13 -0
  32. package/dist/react/QuoteFormShell.d.ts +2 -0
  33. package/dist/react/QuoteFormShell.js +3 -2
  34. package/dist/react/use-lead-error.d.ts +24 -0
  35. package/dist/react/use-lead-error.js +73 -0
  36. package/dist/react/use-lead-events.d.ts +3 -0
  37. package/dist/react/use-lead-events.js +3 -2
  38. package/dist/react/use-lead-submit.d.ts +44 -9
  39. package/dist/react/use-lead-submit.js +122 -13
  40. package/dist/react/use-validity.d.ts +17 -0
  41. package/dist/react/use-validity.js +38 -0
  42. package/dist/server/env.d.ts +9 -0
  43. package/dist/server/env.js +12 -0
  44. package/dist/server/index.d.ts +1 -1
  45. package/dist/server/index.js +1 -1
  46. package/dist/server/lead-store-sqlite.d.ts +5 -1
  47. package/dist/server/lead-store-sqlite.js +37 -2
  48. package/dist/server/lead-webhook.d.ts +20 -1
  49. package/dist/server/lead-webhook.js +6 -2
  50. package/dist/server/webhook-outbox.d.ts +27 -1
  51. package/dist/server/webhook-outbox.js +33 -6
  52. package/dist/testing/index.d.ts +1 -0
  53. package/dist/testing/index.js +1 -0
  54. package/dist/testing/landing-contract.js +6 -0
  55. package/dist/testing/lead-rules.d.ts +18 -0
  56. package/dist/testing/lead-rules.js +50 -0
  57. package/dist/testing/lead-store-contract.js +20 -0
  58. package/package.json +3 -2
  59. package/template/flake.nix +1 -1
  60. package/template/package.json +1 -1
  61. package/template/src/shared/config/lead.ts +6 -3
  62. package/template/tests/e2e/quote-form.spec.ts +86 -1
@@ -1,11 +1,12 @@
1
1
  import { after } from "next/server.js";
2
- import { createAcceptLead } from "../core/accept.js";
3
- import { analyticsSink, EVENTS } from "../core/analytics.js";
2
+ import { createAcceptLead, EXPERIMENT_FIELD, VARIANT_FIELD } from "../core/accept.js";
3
+ import { analyticsSink, EVENTS, experimentProps } from "../core/analytics.js";
4
4
  import { RateLimiter } from "../core/antispam.js";
5
- import { channelOf } from "../core/lead.js";
5
+ import { CARD_FIELD, CARD_ID, channelOf, LEAD_ERROR_PARAM } from "../core/lead.js";
6
6
  import { createPlaceView } from "../core/place/view.js";
7
- import { createRouting, THANKS } from "../core/routing.js";
7
+ import { createRouting } from "../core/routing.js";
8
8
  import { bakedPlace, contactOf } from "../core/site.js";
9
+ import { thanksSuffix } from "../core/status.js";
9
10
  import { clientKey } from "../server/client-key.js";
10
11
  import { openLeadStore } from "../server/lead-store.js";
11
12
  /** The Rust server's body limit; a quote is a few short fields. */
@@ -49,10 +50,22 @@ const escape = (s) => s.replace(/[&<>"']/g, c => ({ "&": "&amp;", "<": "&lt;", "
49
50
  function seeOther(location) {
50
51
  return new Response(null, { status: 303, headers: { Location: location } });
51
52
  }
53
+ function json(status, body) {
54
+ return new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json", "Cache-Control": "no-store" } });
55
+ }
56
+ const text = (form, name) => {
57
+ const value = form.get(name);
58
+ return typeof value === "string" ? value : null;
59
+ };
60
+ /** `LeadCapture`'s own post asks for JSON; a plain form post never does. */
61
+ const wantsJson = (request) => (request.headers.get("accept") ?? "").includes("application/json");
52
62
  export function quoteRoute(site, deps) {
63
+ const anchor = deps.anchor ?? "quote";
64
+ if (!CARD_ID.test(anchor))
65
+ throw new Error(`quoteRoute: the anchor must be a slug ([a-z0-9-]), got ${JSON.stringify(anchor)}`);
53
66
  const accept = createAcceptLead(site);
54
67
  const routing = createRouting(site);
55
- const limiter = deps.limiter ?? new RateLimiter(5, 10 * 60_000);
68
+ let limiter = deps.limiter;
56
69
  const defer = deps.defer ?? after;
57
70
  const log = deps.log ?? console;
58
71
  let store;
@@ -66,6 +79,16 @@ export function quoteRoute(site, deps) {
66
79
  }
67
80
  return webhook;
68
81
  };
82
+ // Read before the lead is stored, so a webhook that cannot be built must
83
+ // not throw here: it would cost the lead. `enqueue` says why, after the insert.
84
+ const suspectQueued = () => {
85
+ try {
86
+ return leadWebhook()?.panelSuspect ?? false;
87
+ }
88
+ catch {
89
+ return false;
90
+ }
91
+ };
69
92
  const leadStore = () => {
70
93
  store ??= deps.store ? deps.store() : openLeadStore(deps.env().leadsDb);
71
94
  return store;
@@ -80,6 +103,20 @@ export function quoteRoute(site, deps) {
80
103
  const mode = routing.hostSlug(request.headers.get("host") ?? "") === place.slug ? "host" : "path";
81
104
  return createPlaceView(site, place, locale, mode).href(suffix);
82
105
  }
106
+ /**
107
+ * Back to the card, with the field to fix and the need already chosen —
108
+ * slugs only: a phone or a name in a URL ends up in logs and analytics. The
109
+ * card is the one the form posted, if its id is a slug.
110
+ */
111
+ function refused(form, field, channel) {
112
+ const query = new URLSearchParams({ [LEAD_ERROR_PARAM]: field });
113
+ const subject = form.get(site.lead.wire.subject);
114
+ if (channel === "form" && typeof subject === "string" && site.lead.subjects.includes(subject))
115
+ query.set("need", subject);
116
+ const posted = form.get(CARD_FIELD);
117
+ const card = typeof posted === "string" && CARD_ID.test(posted) ? posted : anchor;
118
+ return `?${query}#${card}${channel === "callback" ? "-callback" : ""}`;
119
+ }
83
120
  /** Self-contained: the thing that failed may be the thing that renders pages. */
84
121
  function unavailable(slug, locale) {
85
122
  const place = (slug ? bakedPlace(site, slug) : undefined) ?? null;
@@ -109,6 +146,8 @@ export function quoteRoute(site, deps) {
109
146
  }
110
147
  const outcome = await accept(form, clientKey(request.headers, env.trustedProxy ?? DEV_TRUST), {
111
148
  insert: lead => leadStore().insert(lead),
149
+ findSubmission: async (submissionId) => (await leadStore().findSubmission?.(submissionId)) ?? null,
150
+ sendSuspect: suspectQueued(),
112
151
  defer,
113
152
  notify: (lead, id) => {
114
153
  notifier ??= deps.notifier();
@@ -126,16 +165,37 @@ export function quoteRoute(site, deps) {
126
165
  channel: channelOf(lead),
127
166
  ...tags,
128
167
  }),
129
- limiter,
168
+ limiter: (limiter ??= new RateLimiter(env.leadRateLimit?.limit ?? 5, env.leadRateLimit?.windowMs ?? 600_000)),
130
169
  now: deps.now?.() ?? Date.now(),
131
170
  log,
132
171
  });
172
+ const held = outcome.kind === "stored" && !outcome.duplicate && (outcome.lead.spamVerdict === "honeypot" || (outcome.lead.spamVerdict === "rate-limited" && !suspectQueued()));
173
+ if (held && outcome.kind === "stored" && env.production === false) {
174
+ // A local stack posts every test lead from one address: say it, loudly.
175
+ log.warn(`\n!!! quote (development): lead ${outcome.id} stored as ${outcome.lead.spamVerdict} and NOT sent on — no mail, no webhook.\n` +
176
+ (outcome.lead.spamVerdict === "rate-limited"
177
+ ? `!!! The visitor saw the thanks page. Raise LEAD_RATE_LIMIT (<count>/<seconds>, now ${env.leadRateLimit?.limit ?? 5}/${(env.leadRateLimit?.windowMs ?? 600_000) / 1000}) for a local stack.\n`
178
+ : "!!! The visitor saw the thanks page. The hidden trap field was filled — a browser's autofill, or a test filling every field.\n"));
179
+ }
180
+ const scripted = wantsJson(request);
133
181
  switch (outcome.kind) {
134
182
  // A suspected bot is answered exactly as a person is.
135
- case "stored":
136
- return seeOther(href(request, outcome.lead.placeSlug, outcome.locale, THANKS));
137
- case "invalid":
138
- return seeOther(href(request, outcome.slug, outcome.locale, outcome.slug ? "#quote" : ""));
183
+ case "stored": {
184
+ const location = href(request, outcome.lead.placeSlug, outcome.locale, thanksSuffix(channelOf(outcome.lead)));
185
+ return scripted ? json(200, { ok: true, location }) : seeOther(location);
186
+ }
187
+ case "invalid": {
188
+ const tags = experimentProps(text(form, EXPERIMENT_FIELD), text(form, VARIANT_FIELD));
189
+ // The field's role and why — never what was typed.
190
+ const props = { form_id: outcome.formId, channel: outcome.channel, field: outcome.field, reason: "invalid", ...tags };
191
+ if (env.posthogKey) {
192
+ const sink = analyticsSink({ key: env.posthogKey, host: env.posthogHost, brandId: site.brand.id }, outcome.slug);
193
+ defer(() => sink.capture(EVENTS.formReject, props));
194
+ }
195
+ if (scripted)
196
+ return json(422, { ok: false, field: outcome.field });
197
+ return seeOther(href(request, outcome.slug, outcome.locale, refused(form, outcome.field, outcome.channel)));
198
+ }
139
199
  case "failed":
140
200
  return unavailable(outcome.slug, outcome.locale);
141
201
  }
@@ -46,7 +46,8 @@ export interface LeadCaptureProps {
46
46
  /**
47
47
  * The card's id, the anchor a call bar links to (`#quote`): the whole card,
48
48
  * head included, scrolls into view. The form is `<id>-form`, the callback
49
- * `<id>-callback` and its form `<id>-callback-form`.
49
+ * `<id>-callback` and its form `<id>-callback-form`. Posted as `card`, so a
50
+ * refused lead comes back here (`?lead_error=<field>#<id>`) and shows why.
50
51
  */
51
52
  id?: string | undefined;
52
53
  text: LeadCaptureText;
@@ -58,10 +59,11 @@ export interface LeadCaptureProps {
58
59
  /** Whether the callback starts open; by default only when it is the channel that leads (the place is closed). */
59
60
  callbackOpen?: boolean | undefined;
60
61
  /**
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.
62
+ * The card once a lead is taken, in place of everything in it. A script
63
+ * posts the form itself either way, so a refusal keeps what was typed and
64
+ * shows why; a lead taken shows this, or — without it — goes to the thanks
65
+ * page. Without a script, or on an answer that is not the route's, the form
66
+ * posts as it always did.
65
67
  */
66
68
  done?: ReactNode | ((sent: LeadSent) => ReactNode);
67
69
  /** Replaces the title and lede — the brand's own heading. */
@@ -1,16 +1,18 @@
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
7
  import { servedLocalities, storefrontOf } from "../core/place/types.js";
8
8
  import { CallbackForm, ChannelLink, ExperimentFields } from "./LeadCaptureChannels.js";
9
- import { LocalityField, NameField, PhoneField } from "./LeadCaptureFields.js";
9
+ import { FormMessage, LocalityField, NameField, PhoneField } from "./LeadCaptureFields.js";
10
10
  import { NeedField } from "./LeadCaptureNeed.js";
11
11
  import { QuoteFormShell } from "./QuoteFormShell.js";
12
12
  import { useHydrated, useNeed, useNow, useOpenOnHash } from "./use-lead-context.js";
13
+ import { errorText, formMessageId, OWN_FIELDS, useLeadError } from "./use-lead-error.js";
13
14
  import { useLeadEvents } from "./use-lead-events.js";
15
+ import { FailureMessage, SubmitButton } from "./LeadCaptureSubmit.js";
14
16
  import { useLeadSubmit } from "./use-lead-submit.js";
15
17
  /**
16
18
  * What the locality field is offered. A storefront serving its own town knows
@@ -37,7 +39,12 @@ export function LeadCapture(props) {
37
39
  const [need, setNeed] = useNeed(props.need, needs.map(n => n.value), () => setEditing(false));
38
40
  const events = useLeadEvents(root, { formId, layout, experiment });
39
41
  useOpenOnHash(`${id}-callback`);
40
- const [sent, onSubmit] = useLeadSubmit(props.done !== undefined, { mobile: wire.mobile, name: props.name?.field });
42
+ const [error, setError] = useLeadError(root, id);
43
+ const { sent, onSubmit, busy, failure, retry } = useLeadSubmit(props.done !== undefined, { mobile: wire.mobile, name: props.name?.field }, { onRefused: (channel, field) => setError({ channel, field }), onFailed: (channel, why) => events.submitError(why, channel) });
44
+ // The form's refusal by where it shows: under the field the card draws, else above the submit.
45
+ const formError = error?.channel === "form" ? error.field : null;
46
+ const at = (field) => (formError === field ? errorText(field, text) : null);
47
+ const above = formError !== null && !OWN_FIELDS.form.includes(formError) ? errorText(formError, text) : null;
41
48
  const doneRef = useRef(null);
42
49
  // The form the focus was in is gone: the news takes it, and is read out.
43
50
  useEffect(() => doneRef.current?.focus(), [sent]);
@@ -64,7 +71,12 @@ export function LeadCapture(props) {
64
71
  setEditing(false);
65
72
  events.step("contact");
66
73
  };
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));
74
+ // An arrow key in `qualify-first`: the tile is chosen, the group stays open.
75
+ const select = (value) => {
76
+ setNeed(value);
77
+ setEditing(true);
78
+ };
79
+ 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
80
  // `qualify-first` shows the contact step as soon as a need's radio is
69
81
  // checked (`:has`) — without a script, and inside the tap with one.
70
82
  const contactShown = layout === "single" || (need !== undefined && !editing);
@@ -73,8 +85,8 @@ export function LeadCapture(props) {
73
85
  if (sent && props.done !== undefined) {
74
86
  return (_jsx("div", { ref: root, ...rootProps, children: _jsx("div", { ref: doneRef, role: "status", tabIndex: -1, className: cn("outline-none", c?.done), children: typeof props.done === "function" ? props.done(sent) : props.done }) }));
75
87
  }
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: () => {
88
+ 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
89
  setEditing(true);
78
90
  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)] }))] }));
91
+ }, 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, 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: text.submit, 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
92
  }
@@ -1,7 +1,8 @@
1
1
  import { type CaptureChannel } from "../core/channels.js";
2
2
  import type { 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
@@ -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,15 @@ 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;
@@ -1,10 +1,12 @@
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 { ConsentField, FormMessage, PhoneField } from "./LeadCaptureFields.js";
7
+ import { FailureMessage, SubmitButton } from "./LeadCaptureSubmit.js";
6
8
  import { partWithLeading } from "./parts.js";
7
- import { PHONE_INPUT_PROPS, QuoteFormShell } from "./QuoteFormShell.js";
9
+ import { QuoteFormShell } from "./QuoteFormShell.js";
8
10
  /**
9
11
  * A whole-pixel line under a brand's own type size: tailwind-merge drops a
10
12
  * size's line height for nothing, and a 15 px face at the page's 1.5 is a
@@ -40,6 +42,7 @@ export function ChannelLink(props) {
40
42
  * choose `open` itself; it is still the leading channel's face.
41
43
  */
42
44
  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 })] })] }));
45
+ const { id, primary, open, formId, placeSlug, locale, renderedAt, mobileName, subject, opening, text, experiment, error, classNames: c } = props;
46
+ const at = (field) => (error?.field === field ? error.text : null);
47
+ 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 })] })] }));
45
48
  }
@@ -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
  }
@@ -1,6 +1,7 @@
1
1
  "use client";
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { Button, cn, Field, FieldLabel } from "@evinvest/uikit";
4
+ import { useEffect, useRef } from "react";
4
5
  import { FormSelect } from "./FormSelect.js";
5
6
  /**
6
7
  * What the job is. A need already known — from the page, `?need=` or the card
@@ -10,7 +11,16 @@ import { FormSelect } from "./FormSelect.js";
10
11
  * per need: one tap answers it.
11
12
  */
12
13
  export function NeedField(props) {
13
- const { layout, name, needs, need, editing, hydrated, label, changeLabel, onPick, onEdit, classNames: c } = props;
14
+ const { layout, name, needs, need, editing, hydrated, label, changeLabel, onPick, onSelect, onEdit, classNames: c } = props;
15
+ const group = useRef(null);
16
+ // A radio group answers arrows with a click (`detail` 0) and a change, the
17
+ // same events a tap makes: what moves on is told apart by its key or pointer.
18
+ const pointer = useRef(false);
19
+ const answered = useRef(false);
20
+ useEffect(() => {
21
+ for (const radio of group.current?.querySelectorAll("input[type=radio]") ?? [])
22
+ radio.setCustomValidity(need === undefined ? props.requiredText : "");
23
+ });
14
24
  const chosen = needs.find(n => n.value === need);
15
25
  if (chosen && !editing) {
16
26
  return (_jsxs("div", { className: cn("flex flex-wrap items-center gap-x-3 gap-y-1 text-ink", c?.summary), children: [_jsx("input", { type: "hidden", name: name, value: chosen.value }), _jsx("span", { className: "text-sm text-ink-soft", children: label }), _jsx("span", { className: "font-medium", children: chosen.label }), hydrated && (_jsx(Button, { type: "button", variant: "link", size: "touch", className: "px-1", onClick: onEdit, children: changeLabel }))] }));
@@ -18,5 +28,31 @@ export function NeedField(props) {
18
28
  if (layout === "single") {
19
29
  return (_jsxs(Field, { className: cn("flex flex-col gap-2", c?.field), children: [_jsx(FieldLabel, { className: c?.label, children: label }), _jsx(FormSelect, { name: name, size: "lg", defaultValue: need ?? needs[0]?.value, options: needs, onValueChange: onPick, classNames: c?.control ? { trigger: c.control } : undefined })] }));
20
30
  }
21
- return (_jsxs("fieldset", { className: cn("flex flex-col gap-2", c?.field), children: [_jsx("legend", { className: cn("mb-2 text-sm font-medium text-ink", c?.label), children: label }), _jsx("div", { className: cn("grid grid-cols-1 gap-2 sm:grid-cols-2", c?.needs), children: needs.map(n => (_jsxs("label", { className: "relative block cursor-pointer", children: [_jsx("input", { type: "radio", name: name, value: n.value, required: true, checked: need === n.value, onChange: () => onPick(n.value), className: "peer absolute inset-0 opacity-0", "data-need-option": "", "data-lead-field": "need" }), _jsx("span", { className: cn("flex min-h-11 items-center rounded-[var(--control-radius)] border border-input px-4 py-3 text-ink transition-colors", "peer-checked:border-primary peer-checked:bg-hover peer-focus-visible:ring-[3px] peer-focus-visible:ring-ring/50", c?.need), children: n.label })] }, n.value))) })] }));
31
+ return (_jsxs("fieldset", { ref: group, className: cn("flex flex-col gap-2", c?.field), children: [_jsx("legend", { className: cn("mb-2 text-sm font-medium text-ink", c?.label), children: label }), _jsx("div", { className: cn("grid grid-cols-1 gap-2 sm:grid-cols-2", c?.needs), children: needs.map(n => (_jsxs("label", { className: "relative block cursor-pointer", children: [_jsx("input", { type: "radio", name: name, value: n.value, required: true, checked: need === n.value, onPointerDown: () => {
32
+ pointer.current = true;
33
+ }, onKeyDown: (e) => {
34
+ pointer.current = false;
35
+ answered.current = false;
36
+ if (e.key !== "Enter")
37
+ return;
38
+ // From a radio, Enter would submit the form half filled.
39
+ e.preventDefault();
40
+ answered.current = true;
41
+ onPick(n.value);
42
+ }, onKeyUp: (e) => {
43
+ if (e.key !== " ")
44
+ return;
45
+ answered.current = true;
46
+ onPick(n.value);
47
+ }, onClick: (e) => {
48
+ if (e.detail === 0 && !pointer.current)
49
+ return;
50
+ pointer.current = false;
51
+ answered.current = true;
52
+ onPick(n.value);
53
+ }, onChange: () => {
54
+ if (!answered.current)
55
+ onSelect(n.value);
56
+ answered.current = false;
57
+ }, className: "peer absolute inset-0 opacity-0", "data-need-option": "", "data-lead-field": "need" }), _jsx("span", { className: cn("flex min-h-11 items-center rounded-[var(--control-radius)] border border-input px-4 py-3 text-ink transition-colors", "peer-checked:border-primary peer-checked:bg-hover peer-focus-visible:ring-[3px] peer-focus-visible:ring-ring/50", c?.need), children: n.label })] }, n.value))) })] }));
22
58
  }
@@ -0,0 +1,16 @@
1
+ import type { LeadCaptureText } from "../core/lead-capture-text.js";
2
+ import type { SendFailure } from "./use-lead-submit.js";
3
+ /** The submit, busy while the script posts: disabled, `aria-busy`, and saying so. */
4
+ export declare function SubmitButton(props: {
5
+ busy: boolean;
6
+ label: string;
7
+ sending: string;
8
+ className: string | undefined;
9
+ }): import("react").JSX.Element;
10
+ /** Why the post got no answer, and the way to send the same lead again. */
11
+ export declare function FailureMessage(props: {
12
+ failure: SendFailure | null;
13
+ text: Pick<LeadCaptureText, "networkError" | "timeoutError" | "retry">;
14
+ onRetry: () => void;
15
+ className: string | undefined;
16
+ }): import("react").JSX.Element | null;
@@ -0,0 +1,13 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { Button, cn, FieldError } from "@evinvest/uikit";
3
+ /** The submit, busy while the script posts: disabled, `aria-busy`, and saying so. */
4
+ export function SubmitButton(props) {
5
+ return (_jsx(Button, { type: "submit", size: "touch", disabled: props.busy, "aria-busy": props.busy || undefined, className: cn("w-full", props.className), children: props.busy ? props.sending : props.label }));
6
+ }
7
+ /** Why the post got no answer, and the way to send the same lead again. */
8
+ export function FailureMessage(props) {
9
+ const { failure, text } = props;
10
+ if (!failure)
11
+ return null;
12
+ return (_jsxs(FieldError, { className: cn("flex flex-wrap items-center gap-x-3 gap-y-1 text-accent-error", props.className), children: [_jsx("span", { children: failure === "timeout" ? text.timeoutError : text.networkError }), _jsx(Button, { type: "button", variant: "link", size: "touch", className: "px-1", onClick: props.onRetry, children: text.retry })] }));
13
+ }