@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.
- package/README.md +89 -23
- package/dist/cli/kitstart-outbox.d.ts +2 -0
- package/dist/cli/kitstart-outbox.js +10 -0
- package/dist/cli/outbox.d.ts +5 -0
- package/dist/cli/outbox.js +77 -0
- package/dist/core/accept.d.ts +22 -3
- package/dist/core/accept.js +40 -13
- package/dist/core/analytics.d.ts +3 -1
- package/dist/core/analytics.js +7 -0
- package/dist/core/lead-capture-text.d.ts +24 -1
- package/dist/core/lead-capture-text.js +20 -0
- package/dist/core/lead.d.ts +71 -10
- package/dist/core/lead.js +65 -11
- package/dist/core/phone.d.ts +20 -9
- package/dist/core/phone.js +43 -19
- package/dist/core/status.d.ts +13 -0
- package/dist/core/status.js +19 -2
- package/dist/index.d.ts +3 -3
- package/dist/index.js +3 -3
- package/dist/next/quote-route.d.ts +15 -2
- package/dist/next/quote-route.js +70 -10
- package/dist/react/LeadCapture.d.ts +7 -5
- package/dist/react/LeadCapture.js +18 -6
- package/dist/react/LeadCaptureChannels.d.ts +14 -1
- package/dist/react/LeadCaptureChannels.js +8 -5
- package/dist/react/LeadCaptureFields.d.ts +39 -7
- package/dist/react/LeadCaptureFields.js +91 -21
- package/dist/react/LeadCaptureNeed.d.ts +5 -0
- package/dist/react/LeadCaptureNeed.js +38 -2
- package/dist/react/LeadCaptureSubmit.d.ts +16 -0
- package/dist/react/LeadCaptureSubmit.js +13 -0
- package/dist/react/QuoteFormShell.d.ts +2 -0
- package/dist/react/QuoteFormShell.js +3 -2
- package/dist/react/use-lead-error.d.ts +24 -0
- package/dist/react/use-lead-error.js +73 -0
- package/dist/react/use-lead-events.d.ts +3 -0
- package/dist/react/use-lead-events.js +3 -2
- package/dist/react/use-lead-submit.d.ts +44 -9
- package/dist/react/use-lead-submit.js +122 -13
- package/dist/react/use-validity.d.ts +17 -0
- package/dist/react/use-validity.js +38 -0
- package/dist/server/env.d.ts +9 -0
- package/dist/server/env.js +12 -0
- package/dist/server/index.d.ts +1 -1
- package/dist/server/index.js +1 -1
- package/dist/server/lead-store-sqlite.d.ts +5 -1
- package/dist/server/lead-store-sqlite.js +37 -2
- package/dist/server/lead-webhook.d.ts +20 -1
- package/dist/server/lead-webhook.js +6 -2
- package/dist/server/webhook-outbox.d.ts +27 -1
- package/dist/server/webhook-outbox.js +33 -6
- package/dist/testing/index.d.ts +1 -0
- package/dist/testing/index.js +1 -0
- package/dist/testing/landing-contract.js +6 -0
- package/dist/testing/lead-rules.d.ts +18 -0
- package/dist/testing/lead-rules.js +50 -0
- package/dist/testing/lead-store-contract.js +20 -0
- package/package.json +3 -2
- package/template/flake.nix +1 -1
- package/template/package.json +1 -1
- package/template/src/shared/config/lead.ts +6 -3
- package/template/tests/e2e/quote-form.spec.ts +86 -1
package/dist/next/quote-route.js
CHANGED
|
@@ -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
|
|
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 => ({ "&": "&", "<": "<", "
|
|
|
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
|
-
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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.
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
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 {
|
|
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 [
|
|
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
|
-
|
|
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,
|
|
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
|
|
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
|
|
2
|
+
import { Button, buttonVariants, cn } from "@evinvest/uikit";
|
|
3
3
|
import { EXPERIMENT_FIELD, VARIANT_FIELD } from "../core/accept.js";
|
|
4
4
|
import { channelHref } from "../core/channels.js";
|
|
5
|
-
import { CHANNEL_FIELD
|
|
5
|
+
import { CHANNEL_FIELD } from "../core/lead.js";
|
|
6
|
+
import { ConsentField, FormMessage, PhoneField } from "./LeadCaptureFields.js";
|
|
7
|
+
import { FailureMessage, SubmitButton } from "./LeadCaptureSubmit.js";
|
|
6
8
|
import { partWithLeading } from "./parts.js";
|
|
7
|
-
import {
|
|
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
|
-
|
|
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
|
|
17
|
-
*
|
|
18
|
-
*
|
|
21
|
+
* The number, required and never masked, on the rule the server holds it to
|
|
22
|
+
* (`phoneProblem`): one it would refuse blocks the submit, in the page's
|
|
23
|
+
* words. Leaving the field with such a number shows the hint already — a
|
|
24
|
+
* hint, read out, not yet an error.
|
|
19
25
|
*/
|
|
20
26
|
export declare function PhoneField(props: {
|
|
21
27
|
name: string;
|
|
22
|
-
label
|
|
23
|
-
|
|
24
|
-
|
|
28
|
+
/** Named when the label is not the `Field`'s own (the callback's). */
|
|
29
|
+
id?: string | undefined;
|
|
30
|
+
text: PhoneText;
|
|
31
|
+
/** The server's refusal of this field, in the page's words. */
|
|
32
|
+
error: string | null;
|
|
25
33
|
onSoftError: () => void;
|
|
26
34
|
classNames?: PartClassNames<FieldPart> | undefined;
|
|
27
35
|
}): import("react").JSX.Element;
|
|
@@ -31,5 +39,29 @@ export declare function NameField(props: {
|
|
|
31
39
|
placeholder: string | undefined;
|
|
32
40
|
required: boolean;
|
|
33
41
|
optional: string;
|
|
42
|
+
requiredText: string;
|
|
43
|
+
error: string | null;
|
|
34
44
|
classNames?: PartClassNames<FieldPart> | undefined;
|
|
35
45
|
}): import("react").JSX.Element;
|
|
46
|
+
/**
|
|
47
|
+
* The callback's consent: a native checkbox, so `required` holds without a
|
|
48
|
+
* script (the kit's checkbox is a button), whose value is the sentence beside
|
|
49
|
+
* it — the lead keeps what was agreed to, word for word.
|
|
50
|
+
*/
|
|
51
|
+
export declare function ConsentField(props: {
|
|
52
|
+
sentence: string;
|
|
53
|
+
requiredText: string;
|
|
54
|
+
error: string | null;
|
|
55
|
+
className: string | undefined;
|
|
56
|
+
classNames: PartClassNames<FieldPart> | undefined;
|
|
57
|
+
}): import("react").JSX.Element;
|
|
58
|
+
/**
|
|
59
|
+
* A refusal no field shows under itself — a brand's extra, the whole form —
|
|
60
|
+
* just above the submit. Only there when there is one: an alert is read out
|
|
61
|
+
* as it is inserted, and an empty box would add the form's gap.
|
|
62
|
+
*/
|
|
63
|
+
export declare function FormMessage(props: {
|
|
64
|
+
id: string;
|
|
65
|
+
error: string | null;
|
|
66
|
+
className: string | undefined;
|
|
67
|
+
}): import("react").JSX.Element | null;
|
|
@@ -1,48 +1,118 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
-
import { Button, cn, Field, FieldDescription, FieldLabel, Input } from "@evinvest/uikit";
|
|
4
|
-
import { useId, useRef, useState } from "react";
|
|
5
|
-
import {
|
|
3
|
+
import { Button, cn, Field, FieldDescription, FieldError, FieldLabel, Input } from "@evinvest/uikit";
|
|
4
|
+
import { useEffect, useId, useRef, useState } from "react";
|
|
5
|
+
import { CONSENT_FIELD } from "../core/lead.js";
|
|
6
|
+
import { phoneProblem } from "../core/phone.js";
|
|
6
7
|
import { PHONE_INPUT_PROPS } from "./QuoteFormShell.js";
|
|
8
|
+
import { useValidity } from "./use-validity.js";
|
|
7
9
|
/** More than this and the row is a list to read, not a tap to save typing. */
|
|
8
10
|
const MAX_CHIPS = 6;
|
|
9
11
|
const optionalLabel = (label, optional) => (optional ? `${label} (${optional})` : label);
|
|
10
12
|
/** Digits only: a postcode, which the numeric keypad can type and edit. */
|
|
11
13
|
const POSTCODE = /^\d+$/;
|
|
14
|
+
/**
|
|
15
|
+
* What a field says under itself: the error that blocks (the server's, or the
|
|
16
|
+
* browser's refusal in the page's words) as an alert, else a hint. Always in
|
|
17
|
+
* the DOM: a live region announces only what changes inside it.
|
|
18
|
+
*/
|
|
19
|
+
function FieldMessage(props) {
|
|
20
|
+
const { id, error, hint, classNames: c } = props;
|
|
21
|
+
return (
|
|
22
|
+
// Empty, it gives back the field's gap rather than vanish: a live region
|
|
23
|
+
// hidden while empty is not one some screen readers listen to.
|
|
24
|
+
_jsx("div", { "aria-live": "polite", className: "empty:-mt-2", children: error ? (_jsx(FieldError, { id: id, className: cn("text-accent-error", c?.error), children: error })) : hint ? (_jsx(FieldDescription, { id: id, className: cn("text-accent-error", c?.hint), children: hint })) : null }));
|
|
25
|
+
}
|
|
26
|
+
const required = (text, on) => (el) => (on && el.value.trim() === "" ? text : "");
|
|
12
27
|
export function LocalityField(props) {
|
|
13
|
-
const { name, label, servedLabel, served,
|
|
14
|
-
const
|
|
28
|
+
const { name, label, servedLabel, served, optional, hydrated, classNames: c } = props;
|
|
29
|
+
const messageId = useId();
|
|
30
|
+
const validity = useValidity(required(props.requiredText, props.required));
|
|
31
|
+
const error = props.error ?? validity.shown;
|
|
15
32
|
const chips = hydrated && served.length > 1 && served.length <= MAX_CHIPS;
|
|
16
33
|
// A commune's name filled in or a tap away must stay editable: iOS's numeric
|
|
17
34
|
// keypad has no letters, so the keypad follows what the field is offered.
|
|
18
35
|
// A list too long for chips offers nothing — the visitor types a postcode.
|
|
19
36
|
const numeric = served.length > MAX_CHIPS || served.every(s => POSTCODE.test(s));
|
|
20
|
-
return (_jsxs(Field, { className: cn("flex flex-col gap-2", c?.field), children: [_jsx(FieldLabel, { className: c?.label, children: optionalLabel(label, required ? null : optional) }), _jsx(Input, { ref:
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
37
|
+
return (_jsxs(Field, { className: cn("flex flex-col gap-2", c?.field), children: [_jsx(FieldLabel, { className: c?.label, children: optionalLabel(label, props.required ? null : optional) }), _jsx(Input, { ref: validity.ref, name: name, size: "lg", inputMode: numeric ? "numeric" : "text", autoComplete: "postal-code", placeholder: props.placeholder, enterKeyHint: "next", required: props.required, defaultValue: served.length === 1 ? served[0] : undefined, "data-lead-field": "locality", "aria-invalid": error ? true : undefined, "aria-describedby": error ? messageId : undefined, onInvalid: validity.onInvalid, onChange: validity.onChange, className: c?.control }), chips && (_jsx("div", { role: "group", "aria-label": servedLabel, className: cn("flex flex-wrap gap-2", c?.chips), children: served.map(commune => (_jsx(Button, { type: "button", variant: "outline", size: "touch", className: cn("px-3", c?.chip), onClick: () => {
|
|
38
|
+
const el = validity.ref.current;
|
|
39
|
+
if (!el)
|
|
40
|
+
return;
|
|
41
|
+
el.value = commune;
|
|
42
|
+
validity.recheck(el);
|
|
43
|
+
}, children: commune }, commune))) })), _jsx(FieldMessage, { id: messageId, error: error, classNames: c })] }));
|
|
24
44
|
}
|
|
25
45
|
/**
|
|
26
|
-
* The number, required and never masked
|
|
27
|
-
*
|
|
28
|
-
*
|
|
46
|
+
* The number, required and never masked, on the rule the server holds it to
|
|
47
|
+
* (`phoneProblem`): one it would refuse blocks the submit, in the page's
|
|
48
|
+
* words. Leaving the field with such a number shows the hint already — a
|
|
49
|
+
* hint, read out, not yet an error.
|
|
29
50
|
*/
|
|
30
51
|
export function PhoneField(props) {
|
|
31
|
-
const { name,
|
|
32
|
-
const
|
|
52
|
+
const { name, text, onSoftError, classNames: c } = props;
|
|
53
|
+
const messageId = useId();
|
|
33
54
|
const [doubtful, setDoubtful] = useState(false);
|
|
34
|
-
|
|
55
|
+
const validity = useValidity(el => {
|
|
56
|
+
const problem = phoneProblem(el.value);
|
|
57
|
+
return problem === "required" ? text.required : problem === "invalid" ? text.phoneInvalid : "";
|
|
58
|
+
});
|
|
59
|
+
const error = props.error ?? validity.shown;
|
|
60
|
+
const marked = Boolean(error) || doubtful;
|
|
61
|
+
// A hint drawn on the blur a press of the submit causes pushes the button
|
|
62
|
+
// down between press and release, and the click lands on nothing: the
|
|
63
|
+
// submit's own check speaks instead.
|
|
64
|
+
const submitting = useRef(false);
|
|
65
|
+
useEffect(() => {
|
|
66
|
+
const form = validity.ref.current?.form;
|
|
67
|
+
if (!form)
|
|
68
|
+
return;
|
|
69
|
+
const press = (event) => {
|
|
70
|
+
submitting.current = event.target instanceof Element && event.target.closest("[type=submit]") !== null;
|
|
71
|
+
};
|
|
72
|
+
form.addEventListener("pointerdown", press, true);
|
|
73
|
+
return () => form.removeEventListener("pointerdown", press, true);
|
|
74
|
+
}, [validity.ref]);
|
|
75
|
+
return (_jsxs(Field, { ...(props.id ? { controlId: props.id } : {}), className: cn("flex flex-col gap-2", c?.field), children: [_jsx(FieldLabel, { className: c?.label, children: text.phoneLabel }), _jsx(Input, { ref: validity.ref, name: name, size: "lg", ...PHONE_INPUT_PROPS, enterKeyHint: "send", placeholder: text.phonePlaceholder, required: true, "data-lead-field": "phone", "aria-invalid": marked || undefined, "aria-describedby": marked ? messageId : undefined, onInvalid: validity.onInvalid, onBlur: e => {
|
|
76
|
+
if (submitting.current) {
|
|
77
|
+
submitting.current = false;
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
35
80
|
const value = e.currentTarget.value;
|
|
36
|
-
const bad = value.trim() !== "" &&
|
|
81
|
+
const bad = value.trim() !== "" && phoneProblem(value) !== null;
|
|
37
82
|
if (bad && !doubtful)
|
|
38
83
|
onSoftError();
|
|
39
84
|
setDoubtful(bad);
|
|
40
|
-
},
|
|
41
|
-
|
|
85
|
+
}, onChange: e => {
|
|
86
|
+
validity.onChange(e);
|
|
87
|
+
if (doubtful && phoneProblem(e.currentTarget.value) === null)
|
|
42
88
|
setDoubtful(false);
|
|
43
|
-
}, className: c?.control }),
|
|
89
|
+
}, className: c?.control }), _jsx(FieldMessage, { id: messageId, error: error, hint: doubtful ? text.phoneHint : null, classNames: c })] }));
|
|
44
90
|
}
|
|
45
91
|
export function NameField(props) {
|
|
46
|
-
const { name, label, placeholder,
|
|
47
|
-
|
|
92
|
+
const { name, label, placeholder, optional, classNames: c } = props;
|
|
93
|
+
const messageId = useId();
|
|
94
|
+
const validity = useValidity(required(props.requiredText, props.required));
|
|
95
|
+
const error = props.error ?? validity.shown;
|
|
96
|
+
return (_jsxs(Field, { className: cn("flex flex-col gap-2", c?.field), children: [_jsx(FieldLabel, { className: c?.label, children: optionalLabel(label, props.required ? null : optional) }), _jsx(Input, { ref: validity.ref, name: name, size: "lg", autoComplete: "name", enterKeyHint: "next", placeholder: placeholder, required: props.required, "data-lead-field": "name", "aria-invalid": error ? true : undefined, "aria-describedby": error ? messageId : undefined, onInvalid: validity.onInvalid, onChange: validity.onChange, className: c?.control }), _jsx(FieldMessage, { id: messageId, error: error, classNames: c })] }));
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* The callback's consent: a native checkbox, so `required` holds without a
|
|
100
|
+
* script (the kit's checkbox is a button), whose value is the sentence beside
|
|
101
|
+
* it — the lead keeps what was agreed to, word for word.
|
|
102
|
+
*/
|
|
103
|
+
export function ConsentField(props) {
|
|
104
|
+
const messageId = useId();
|
|
105
|
+
const validity = useValidity(el => (el.checked ? "" : props.requiredText));
|
|
106
|
+
const error = props.error ?? validity.shown;
|
|
107
|
+
return (_jsxs("div", { className: "flex flex-col gap-2", children: [_jsxs("label", { className: cn("flex min-h-11 items-start gap-3 text-sm text-ink", props.className), children: [_jsx("input", { ref: validity.ref, type: "checkbox", name: CONSENT_FIELD, value: props.sentence, required: true, "data-lead-field": "consent", "aria-invalid": error ? true : undefined, "aria-describedby": error ? messageId : undefined, onInvalid: validity.onInvalid, onChange: validity.onChange, className: "mt-0.5 size-5 shrink-0 accent-primary" }), _jsx("span", { children: props.sentence })] }), _jsx(FieldMessage, { id: messageId, error: error, classNames: props.classNames })] }));
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* A refusal no field shows under itself — a brand's extra, the whole form —
|
|
111
|
+
* just above the submit. Only there when there is one: an alert is read out
|
|
112
|
+
* as it is inserted, and an empty box would add the form's gap.
|
|
113
|
+
*/
|
|
114
|
+
export function FormMessage(props) {
|
|
115
|
+
if (!props.error)
|
|
116
|
+
return null;
|
|
117
|
+
return (_jsx(FieldError, { id: props.id, tabIndex: -1, className: cn("text-accent-error outline-none", props.className), children: props.error }));
|
|
48
118
|
}
|
|
@@ -12,7 +12,12 @@ export interface NeedFieldProps {
|
|
|
12
12
|
hydrated: boolean;
|
|
13
13
|
label: string;
|
|
14
14
|
changeLabel: string;
|
|
15
|
+
/** Blocks the submit while no tile is chosen, in the page's words. */
|
|
16
|
+
requiredText: string;
|
|
17
|
+
/** The visitor's answer: a tap, Enter or Space — `qualify-first` moves on. */
|
|
15
18
|
onPick: (need: string) => void;
|
|
19
|
+
/** An arrow key moved the choice: chosen, but the visitor is still choosing. */
|
|
20
|
+
onSelect: (need: string) => void;
|
|
16
21
|
onEdit: () => void;
|
|
17
22
|
classNames?: PartClassNames<NeedPart> | undefined;
|
|
18
23
|
}
|
|
@@ -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,
|
|
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
|
+
}
|