@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,5 +1,6 @@
1
1
  import "server-only";
2
2
  import { randomUUID } from "node:crypto";
3
+ import { suspectOf } from "../core/lead.js";
3
4
  import { openWebhookOutbox } from "./webhook-outbox.js";
4
5
  /**
5
6
  * What the Service-Arb panel's `lead.created` calls a callback request. Its
@@ -19,7 +20,7 @@ export function panelChannel(channel) {
19
20
  * so a broken setting fails startup rather than a lead.
20
21
  */
21
22
  export function leadWebhook(site, env, options) {
22
- const { buildBody, signing, ...outboxOptions } = options;
23
+ const { buildBody, signing, panelSuspect = false, ...outboxOptions } = options;
23
24
  if (!env.leadWebhook || !buildBody)
24
25
  return null;
25
26
  if (env.leadsDb.kind !== "sqlite")
@@ -28,8 +29,10 @@ export function leadWebhook(site, env, options) {
28
29
  const now = options.now ?? Date.now;
29
30
  return {
30
31
  outbox,
32
+ panelSuspect,
31
33
  enqueue(lead, id, meta) {
32
- const ctx = { leadId: id, brandId: site.brand.id, ...meta, at: new Date(now()), idempotencyKey: randomUUID() };
34
+ const suspect = panelSuspect ? suspectOf(lead) : undefined;
35
+ const ctx = { leadId: id, brandId: site.brand.id, ...meta, at: new Date(now()), idempotencyKey: randomUUID(), ...(suspect ? { suspect } : {}) };
33
36
  const body = JSON.stringify(buildBody(lead, ctx));
34
37
  if (typeof body !== "string")
35
38
  throw new Error("buildWebhookBody returned nothing JSON can carry");
@@ -39,5 +42,6 @@ export function leadWebhook(site, env, options) {
39
42
  start: intervalMs => outbox.start(intervalMs),
40
43
  stop: () => outbox.stop(),
41
44
  close: () => outbox.close(),
45
+ requeueDead: () => outbox.requeueDead(),
42
46
  };
43
47
  }
@@ -18,12 +18,28 @@ export interface WebhookTarget {
18
18
  secret: string;
19
19
  signing: WebhookSigning;
20
20
  }
21
+ /** What `onDead` is told of a row given up on: its error as tagged (`HTTP 503`), never the receiver's words. */
22
+ export interface DeadRow {
23
+ id: number;
24
+ ref: string | null;
25
+ attempts: number;
26
+ error: string;
27
+ }
21
28
  export interface WebhookOutboxOptions {
22
- /** Tries per row, the first included; past it the row is `dead`. */
29
+ /**
30
+ * How long a row is retried, from when it was queued (or requeued); past it
31
+ * a failure makes it `dead`. 48 hours by default: a receiver down over a
32
+ * night and a day still gets its leads.
33
+ */
34
+ horizonMs?: number;
35
+ /** A cap on tries per row besides the horizon, the first included; none by default. */
23
36
  maxAttempts?: number;
24
37
  /** The wait after the first failure; doubled after each next one. */
25
38
  baseDelayMs?: number;
39
+ /** The longest wait between two tries: an hour by default. */
26
40
  maxDelayMs?: number;
41
+ /** Told of every row given up on, after the error log — the alert hook. */
42
+ onDead?: (row: DeadRow) => void;
27
43
  /** Per request, so a hung receiver does not hold the tick. */
28
44
  timeoutMs?: number;
29
45
  /** Rows sent per tick. */
@@ -57,9 +73,19 @@ export interface WebhookOutbox {
57
73
  start(intervalMs?: number): void;
58
74
  stop(): void;
59
75
  rows(): OutboxRow[];
76
+ /**
77
+ * Puts this target's `dead` rows back in the queue, due now, with their
78
+ * tries and horizon fresh — after a key rotation or an outage longer than
79
+ * the horizon. Answers how many. `kitstart-outbox requeue` does the same
80
+ * from a shell.
81
+ */
82
+ requeueDead(): number;
60
83
  close(): void;
61
84
  }
85
+ /** The old default cap on tries; kept for a brand that passes it as `maxAttempts`. The horizon decides now. */
62
86
  export declare const WEBHOOK_MAX_ATTEMPTS = 12;
87
+ export declare const WEBHOOK_HORIZON_MS: number;
88
+ export declare const WEBHOOK_MAX_DELAY_MS: number;
63
89
  export declare const WEBHOOK_TICK_MS = 10000;
64
90
  /**
65
91
  * `https:` anywhere; `http:` only where nothing but the cluster sees the
@@ -2,7 +2,10 @@ import "server-only";
2
2
  import { mkdirSync } from "node:fs";
3
3
  import { dirname } from "node:path";
4
4
  import { signatureHeaders } from "./webhook-signature.js";
5
+ /** The old default cap on tries; kept for a brand that passes it as `maxAttempts`. The horizon decides now. */
5
6
  export const WEBHOOK_MAX_ATTEMPTS = 12;
7
+ export const WEBHOOK_HORIZON_MS = 48 * 60 * 60_000;
8
+ export const WEBHOOK_MAX_DELAY_MS = 60 * 60_000;
6
9
  export const WEBHOOK_TICK_MS = 10_000;
7
10
  const SCHEMA = `
8
11
  CREATE TABLE IF NOT EXISTS webhook_outbox (
@@ -113,6 +116,12 @@ async function judge(response, rowId, log) {
113
116
  const why = said ? `${tag}: ${said}` : tag;
114
117
  if (status === 408 || status === 429 || status >= 500)
115
118
  return { kind: "retry", tag, why, afterMs: retryAfter(response) };
119
+ // A rotated key (401, 403) or a receiver moved or not yet deployed (404):
120
+ // fixed by a person, after which the lead must still arrive.
121
+ if (status === 401 || status === 403)
122
+ return { kind: "retry", tag, why, afterMs: null, alarm: "check LEAD_WEBHOOK_KEY_ID / LEAD_WEBHOOK_SECRET against the receiver's key" };
123
+ if (status === 404)
124
+ return { kind: "retry", tag, why, afterMs: null, alarm: "check LEAD_WEBHOOK_URL" };
116
125
  // 3xx included: redirects are not followed, since the target was vetted and its Location was not.
117
126
  return { kind: "dead", tag, why };
118
127
  }
@@ -124,9 +133,10 @@ async function judge(response, rowId, log) {
124
133
  * agreed to take them.
125
134
  */
126
135
  export function openWebhookOutbox(path, target, options = {}) {
127
- const maxAttempts = options.maxAttempts ?? WEBHOOK_MAX_ATTEMPTS;
136
+ const horizon = options.horizonMs ?? WEBHOOK_HORIZON_MS;
137
+ const maxAttempts = options.maxAttempts ?? Number.POSITIVE_INFINITY;
128
138
  const baseDelay = options.baseDelayMs ?? 5_000;
129
- const maxDelay = options.maxDelayMs ?? 60 * 60_000;
139
+ const maxDelay = options.maxDelayMs ?? WEBHOOK_MAX_DELAY_MS;
130
140
  const timeoutMs = options.timeoutMs ?? 10_000;
131
141
  const batch = options.batch ?? 20;
132
142
  const send = options.fetch ?? fetch;
@@ -146,13 +156,15 @@ export function openWebhookOutbox(path, target, options = {}) {
146
156
  throw error;
147
157
  }
148
158
  const insert = db.prepare("INSERT INTO webhook_outbox (target, ref, body, created_at, next_attempt_at) VALUES (?, ?, ?, ?, ?) RETURNING id");
149
- const due = db.prepare("SELECT id, body, attempts FROM webhook_outbox WHERE state = 'pending' AND target = ? AND next_attempt_at <= ? ORDER BY next_attempt_at, id LIMIT ?");
159
+ const due = db.prepare("SELECT id, ref, body, attempts, created_at FROM webhook_outbox WHERE state = 'pending' AND target = ? AND next_attempt_at <= ? ORDER BY next_attempt_at, id LIMIT ?");
150
160
  // A lease: another process ticking the same file skips a row while it is in flight.
151
161
  const claim = db.prepare("UPDATE webhook_outbox SET next_attempt_at = ? WHERE id = ? AND state = 'pending' AND next_attempt_at <= ?");
152
162
  const finish = db.prepare("UPDATE webhook_outbox SET state = ?, attempts = ?, last_error = ?, done_at = ? WHERE id = ?");
153
163
  const reschedule = db.prepare("UPDATE webhook_outbox SET attempts = ?, next_attempt_at = ?, last_error = ? WHERE id = ?");
154
164
  const list = db.prepare("SELECT id, ref, state, attempts, next_attempt_at, last_error FROM webhook_outbox ORDER BY id");
155
165
  const stranded = db.prepare("SELECT COUNT(*) AS n FROM webhook_outbox WHERE state = 'pending' AND target <> ?");
166
+ // `created_at` restarts the horizon: a requeued row is queued anew.
167
+ const requeue = db.prepare("UPDATE webhook_outbox SET state = 'pending', attempts = 0, created_at = ?, next_attempt_at = ?, done_at = NULL WHERE state = 'dead' AND target = ?");
156
168
  const left = int(stranded.get(target.url), "n");
157
169
  if (left > 0)
158
170
  log.warn(`webhook: ${left} pending row(s) queued for another target; left in place, not sent`);
@@ -187,6 +199,7 @@ export function openWebhookOutbox(path, target, options = {}) {
187
199
  const id = int(row, "id");
188
200
  const body = text(row, "body") ?? "";
189
201
  const attempts = int(row, "attempts") + 1;
202
+ const queuedAt = int(row, "created_at");
190
203
  if (Number(claim.run(t + timeoutMs + 5_000, id, t).changes) === 0)
191
204
  continue;
192
205
  const verdict = await deliver(id, body);
@@ -194,15 +207,25 @@ export function openWebhookOutbox(path, target, options = {}) {
194
207
  finish.run("delivered", attempts, verdict.note?.slice(0, ERROR_CAP) ?? null, now(), id);
195
208
  report.delivered += 1;
196
209
  }
197
- else if (verdict.kind === "dead" || attempts >= maxAttempts) {
210
+ else if (verdict.kind === "dead" || attempts >= maxAttempts || now() - queuedAt >= horizon) {
198
211
  finish.run("dead", attempts, verdict.why, now(), id);
199
- log.error(`webhook: outbox #${id} dead after ${attempts} attempt(s): ${verdict.tag}`);
212
+ log.error(`webhook: outbox #${id} dead after ${attempts} attempt(s): ${verdict.tag}; requeue with requeueDead() or \`kitstart-outbox requeue\``);
200
213
  report.dead += 1;
214
+ try {
215
+ options.onDead?.({ id, ref: text(row, "ref"), attempts, error: verdict.tag });
216
+ }
217
+ catch (error) {
218
+ log.error("webhook: the onDead hook failed", error);
219
+ }
201
220
  }
202
221
  else {
203
222
  const wait = backoff(attempts, verdict.afterMs);
204
223
  reschedule.run(attempts, now() + wait, verdict.why, id);
205
- log.warn(`webhook: outbox #${id} attempt ${attempts} failed (${verdict.tag}); next in ${Math.round(wait / 1000)} s`);
224
+ const line = `webhook: outbox #${id} attempt ${attempts} failed (${verdict.tag}); next in ${Math.round(wait / 1000)} s`;
225
+ if (verdict.alarm)
226
+ log.error(`${line} — ${verdict.alarm}`);
227
+ else
228
+ log.warn(line);
206
229
  report.retried += 1;
207
230
  }
208
231
  }
@@ -249,6 +272,10 @@ export function openWebhookOutbox(path, target, options = {}) {
249
272
  lastError: text(row, "last_error"),
250
273
  }));
251
274
  },
275
+ requeueDead() {
276
+ const t = now();
277
+ return Number(requeue.run(t, t, target.url).changes);
278
+ },
252
279
  close() {
253
280
  stop();
254
281
  db.close();
@@ -9,4 +9,5 @@
9
9
  export { describeLandingContract, type LandingContractOptions } from "./landing-contract.js";
10
10
  export { servedPaths } from "./served-files.js";
11
11
  export { describeLeadStoreContract, type LeadStoreHarness } from "./lead-store-contract.js";
12
+ export { leadRuleDisagreements, PHONE_MATRIX } from "./lead-rules.js";
12
13
  export { serviceAreaPlace, storefrontPlace, testLead } from "./fixtures.js";
@@ -9,4 +9,5 @@
9
9
  export { describeLandingContract } from "./landing-contract.js";
10
10
  export { servedPaths } from "./served-files.js";
11
11
  export { describeLeadStoreContract } from "./lead-store-contract.js";
12
+ export { leadRuleDisagreements, PHONE_MATRIX } from "./lead-rules.js";
12
13
  export { serviceAreaPlace, storefrontPlace, testLead } from "./fixtures.js";
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
2
2
  import { isPublished, publicationGaps } from "../core/place/publication.js";
3
3
  import { createRouting, NON_PAGE_ROUTES, pointSuffixes, THANKS } from "../core/routing.js";
4
4
  import { openLaunchBlockers } from "../core/site.js";
5
+ import { leadRuleDisagreements } from "./lead-rules.js";
5
6
  import { servedPaths } from "./served-files.js";
6
7
  const SOURCE_LINE = /@source\s+["'][^"']*node_modules\/@evinvest\/kitstart\/dist["']/;
7
8
  const MATCHER = "/((?!_next/).*)";
@@ -42,6 +43,11 @@ export function describeLandingContract(site, options = {}) {
42
43
  expect(moves && suffixes.includes(redirect.from), redirect.from).toBe(false);
43
44
  }
44
45
  });
46
+ // A number the form lets through and the server refuses sends the visitor
47
+ // back for nothing they can see; `validateLead` is the shared rule.
48
+ it("refuses exactly the phone numbers the form blocks", () => {
49
+ expect(leadRuleDisagreements(site.lead)).toEqual([]);
50
+ });
45
51
  it("publishes nothing without a domain", () => {
46
52
  if (site.brand.domain !== null)
47
53
  return;
@@ -0,0 +1,18 @@
1
+ import { type LeadSchema } from "../core/lead.js";
2
+ /**
3
+ * Numbers people type, and whether each is a way to reach them — the
4
+ * review's matrix (LEAD-FORMS-REVIEW-2026-10-03) and the spellings that must
5
+ * keep working.
6
+ */
7
+ export declare const PHONE_MATRIX: readonly {
8
+ mobile: string;
9
+ valid: boolean;
10
+ }[];
11
+ /**
12
+ * Where a brand's form rule disagrees with the form's own phone check: a
13
+ * number the form lets through that the server refuses (the visitor is sent
14
+ * back for nothing they can see), or one the form blocks that the rule would
15
+ * take. Every other field is filled as the form fills it — the first subject,
16
+ * a postcode — so only the phone is judged. Empty is agreement.
17
+ */
18
+ export declare function leadRuleDisagreements(schema: LeadSchema<string>): string[];
@@ -0,0 +1,50 @@
1
+ import { readCandidate, validateCandidate } from "../core/lead.js";
2
+ import { phoneProblem } from "../core/phone.js";
3
+ /**
4
+ * Numbers people type, and whether each is a way to reach them — the
5
+ * review's matrix (LEAD-FORMS-REVIEW-2026-10-03) and the spellings that must
6
+ * keep working.
7
+ */
8
+ export const PHONE_MATRIX = [
9
+ { mobile: "06 12 34 56 78", valid: true },
10
+ { mobile: "0612345678", valid: true },
11
+ { mobile: "6 12 34 56 78", valid: true },
12
+ { mobile: "+33 (0)6 12 34 56 78", valid: true },
13
+ { mobile: "0033 6 12 34 56 78", valid: true },
14
+ { mobile: "06.12.34.56.78", valid: true },
15
+ { mobile: "06-12-34-56-78", valid: true },
16
+ { mobile: "01 23 45 67 89", valid: true },
17
+ { mobile: "+1 415 555 0123", valid: true },
18
+ { mobile: "+44 7911 123456", valid: true },
19
+ { mobile: "+12345678", valid: true },
20
+ { mobile: "0612345678", valid: true },
21
+ { mobile: "(415) 555-0123", valid: false },
22
+ { mobile: "+3361234567", valid: false },
23
+ { mobile: "0000000000", valid: false },
24
+ { mobile: "06 66 66 66 66", valid: false },
25
+ { mobile: "06 12 34 56 7", valid: false },
26
+ { mobile: "12 34 56 7", valid: false },
27
+ { mobile: "+1234567", valid: false },
28
+ { mobile: "06 12", valid: false },
29
+ ];
30
+ /**
31
+ * Where a brand's form rule disagrees with the form's own phone check: a
32
+ * number the form lets through that the server refuses (the visitor is sent
33
+ * back for nothing they can see), or one the form blocks that the rule would
34
+ * take. Every other field is filled as the form fills it — the first subject,
35
+ * a postcode — so only the phone is judged. Empty is agreement.
36
+ */
37
+ export function leadRuleDisagreements(schema) {
38
+ const out = [];
39
+ for (const { mobile } of PHONE_MATRIX) {
40
+ const form = new FormData();
41
+ form.set(schema.wire.subject, schema.subjects[0] ?? "");
42
+ form.set(schema.wire.locality, "75011");
43
+ form.set(schema.wire.mobile, mobile);
44
+ const blocked = phoneProblem(mobile) !== null;
45
+ const refused = validateCandidate(schema, readCandidate(schema, form, null)) !== null;
46
+ if (blocked !== refused)
47
+ out.push(`${mobile}: the form ${blocked ? "blocks" : "takes"} it, the server ${refused ? "refuses" : "takes"} it`);
48
+ }
49
+ return out;
50
+ }
@@ -41,6 +41,26 @@ export function describeLeadStoreContract(name, harness) {
41
41
  await expect(store.health()).resolves.toBeUndefined();
42
42
  await store.close();
43
43
  });
44
+ // LEAD-FORMS-REVIEW-2026-10-03 #3: a resend after a lost answer is the same lead.
45
+ it("keeps one row per submission id, and finds it back", async () => {
46
+ const store = await harness().open();
47
+ if (!store.findSubmission)
48
+ return void (await store.close());
49
+ const sid = "3f2b8c1e-5d4a-4f6b-9c7e-1a2b3c4d5e6f";
50
+ const first = await store.insert(lead({ submissionId: sid, channel: "callback", consent: { text: "Oui.", at: "2026-10-03T10:00:00.000Z" }, extras: { name: "Ana" } }));
51
+ await expect(store.insert(lead({ submissionId: sid }))).rejects.toThrow();
52
+ expect(await store.count()).toBe(1);
53
+ expect(await store.findSubmission(sid)).toEqual({
54
+ id: first,
55
+ lead: lead({ submissionId: sid, channel: "callback", consent: { text: "Oui.", at: "2026-10-03T10:00:00.000Z" }, extras: { name: "Ana" } }),
56
+ });
57
+ expect(await store.findSubmission("00000000-0000-4000-8000-000000000000")).toBeNull();
58
+ // Leads without one (the no-JS post) never collide.
59
+ await store.insert(lead());
60
+ await store.insert(lead());
61
+ expect(await store.count()).toBe(3);
62
+ await store.close();
63
+ });
44
64
  it("keeps what it acknowledged across a close and a reopen", async () => {
45
65
  const h = harness();
46
66
  if (!h.reopen)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@evinvest/kitstart",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "Landing-site machinery for local-service brands: a site composition root, the Place/Presence/ServiceArea model with a publication gate, host/locale routing, schema.org and SEO builders, the lead funnel with its antispam, and analytics events. Pure core at '.', more subpaths per runtime.",
5
5
  "license": "BlueOak-1.0.0",
6
6
  "publishConfig": {
@@ -57,7 +57,8 @@
57
57
  "types": "./dist/index.d.ts",
58
58
  "main": "./dist/index.js",
59
59
  "bin": {
60
- "kitstart-size": "./dist/cli/kitstart-size.js"
60
+ "kitstart-size": "./dist/cli/kitstart-size.js",
61
+ "kitstart-outbox": "./dist/cli/kitstart-outbox.js"
61
62
  },
62
63
  "files": [
63
64
  "dist",
@@ -3,7 +3,7 @@
3
3
  v_flakes.url = "github:valeratrades/v_flakes?ref=v1.6";
4
4
  # The lib flake at the tag of the @evinvest/kitstart version in
5
5
  # package-lock.json — mkLanding refuses a mismatch.
6
- ev.url = "github:EV-invest/lib?ref=@evinvest/kitstart-v0.8.0";
6
+ ev.url = "github:EV-invest/lib?ref=@evinvest/kitstart-v0.9.0";
7
7
  ev.inputs.v_flakes.follows = "v_flakes";
8
8
  };
9
9
 
@@ -20,7 +20,7 @@
20
20
  "dependencies": {
21
21
  "@evinvest/analytics": "^0.3.0",
22
22
  "@evinvest/i18n": "^0.9.0",
23
- "@evinvest/kitstart": "^0.8.0",
23
+ "@evinvest/kitstart": "^0.9.0",
24
24
  "@evinvest/marketing": "^0.3.0",
25
25
  "@evinvest/uikit": "^0.25.0",
26
26
  "next": "16.3.6",
@@ -1,11 +1,14 @@
1
- import type { LeadSchema } from "@evinvest/kitstart";
1
+ import { validateLead, type LeadSchema } from "@evinvest/kitstart";
2
2
 
3
3
  export const SUBJECTS = ["standard", "deep", "other"] as const;
4
4
  export type Subject = (typeof SUBJECTS)[number];
5
5
 
6
6
  /**
7
7
  * What the quote form asks, and the one rule worth enforcing: a lead with no
8
- * way to reach the customer is not a lead. The reason is for the log only.
8
+ * way to reach the customer is not a lead. `validateLead` is the number rule
9
+ * the form itself blocks on; a brand rule of its own composes with it —
10
+ * `lead => validateLead(lead) ?? mine(lead)` — and names the field it refuses,
11
+ * which the form shows the error at.
9
12
  */
10
13
  export const LEAD: LeadSchema<Subject> = {
11
14
  subjects: SUBJECTS,
@@ -13,5 +16,5 @@ export const LEAD: LeadSchema<Subject> = {
13
16
  extras: [{ name: "surface_m2", max: 6 }],
14
17
  // One shape for the mail, the webhook and a dialler: `+33612345678`.
15
18
  mobileFormat: "e164",
16
- validate: lead => (lead.mobile.replace(/\D/g, "").length < 10 ? "a mobile number" : null),
19
+ validate: validateLead,
17
20
  };
@@ -131,7 +131,7 @@ for (const javaScriptEnabled of [false, true]) {
131
131
  expect(body.get("location")).toBe("paris");
132
132
  // The sentence shown is what is posted, and what the lead keeps.
133
133
  expect(body.get("consent")).toBe("J’accepte d’être rappelé·e à ce numéro au sujet de ma demande.");
134
- await expect(page).toHaveURL(/\/fr\/thanks$/);
134
+ await expect(page).toHaveURL(/\/fr\/thanks\?channel=callback$/);
135
135
  });
136
136
 
137
137
  test("is refused without the consent", async ({ page }) => {
@@ -208,3 +208,88 @@ test("a scripted post of the form lands on the thanks page", async ({ page }) =>
208
208
  });
209
209
  expect(answer).toEqual({ ok: true, redirected: true, path: "/fr/thanks" });
210
210
  });
211
+
212
+ // A number the server refuses: never silent, never lost (LEAD-FORMS-REVIEW-2026-10-03 #1).
213
+ const INVALID = "Ce numéro n’est pas valide. Exemple : 06 12 34 56 78 ou +33 6 12 34 56 78.";
214
+
215
+ test.describe("a refused number, without JavaScript", () => {
216
+ test.use({ javaScriptEnabled: false, reducedMotion: "reduce" });
217
+
218
+ test("goes back to the card, naming the field and keeping the need", async ({ page }) => {
219
+ await page.goto("/fr#quote");
220
+ const form = page.locator("#quote-form");
221
+ await form.getByLabel("Code postal").fill("75011");
222
+ await form.getByLabel("Téléphone").fill("06 12 34 56 7");
223
+ await form.getByRole("button", { name: "Recevoir le prix" }).click();
224
+ await expect(page).toHaveURL(/\/fr\?lead_error=phone&need=standard#quote$/);
225
+ });
226
+ });
227
+
228
+ test.describe("a refused number, with JavaScript", () => {
229
+ const hydrated = (page: Page) => expect(page.locator("#quote select")).toHaveCount(0);
230
+
231
+ test("is blocked before it is sent, in the page's words", async ({ page }) => {
232
+ await page.goto("/fr");
233
+ await hydrated(page);
234
+ let sent = 0;
235
+ page.on("request", r => void (r.method() === "POST" && sent++));
236
+ const form = page.locator("#quote-form");
237
+ await form.getByLabel("Code postal").fill("75011");
238
+ const phone = form.getByLabel("Téléphone");
239
+ await phone.fill("+3361234567");
240
+ await form.getByRole("button", { name: "Recevoir le prix" }).click();
241
+ await expect(phone).toHaveAttribute("aria-invalid", "true");
242
+ await expect(form.getByRole("alert")).toHaveText(INVALID);
243
+ expect(await phone.evaluate(el => (el instanceof HTMLInputElement ? el.validationMessage : ""))).toBe(INVALID);
244
+ expect(sent).toBe(0);
245
+ });
246
+
247
+ test("refused by the server, shows why at the field and keeps what was typed", async ({ page }) => {
248
+ await page.goto("/fr");
249
+ await hydrated(page);
250
+ const form = page.locator("#quote-form");
251
+ // Past the form's own check, as a stale page's would be.
252
+ await form.evaluate(el => el instanceof HTMLFormElement && (el.noValidate = true));
253
+ await form.getByLabel("Code postal").fill("75011");
254
+ const phone = form.getByLabel("Téléphone");
255
+ await phone.fill("0000000000");
256
+ const answered = page.waitForResponse(r => new URL(r.url()).pathname === "/quote");
257
+ await form.getByRole("button", { name: "Recevoir le prix" }).click();
258
+ expect((await answered).status()).toBe(422);
259
+ await expect(form.getByRole("alert")).toHaveText(INVALID);
260
+ await expect(phone).toBeFocused();
261
+ await expect(phone).toHaveValue("0000000000");
262
+ await expect(form.getByLabel("Code postal")).toHaveValue("75011");
263
+ await expect(page).toHaveURL(/\/fr$/);
264
+ });
265
+
266
+ test("brought back by the no-JS refusal, shows it at the field", async ({ page }) => {
267
+ await page.goto("/fr?lead_error=phone&need=deep#quote");
268
+ const form = page.locator("#quote-form");
269
+ await expect(form.getByRole("alert")).toHaveText(INVALID);
270
+ await expect(form.getByLabel("Téléphone")).toBeFocused();
271
+ // The need came back with it: chosen, not asked again.
272
+ await expect(form.locator("input[name=subject]")).toHaveValue("deep");
273
+ await expect(form.getByText("Grand ménage")).toBeVisible();
274
+ await expect(page).toHaveURL(/\/fr\?need=deep#quote$/);
275
+ });
276
+ });
277
+
278
+ // No network at the press of submit: the form stays, says so, and sends the
279
+ // same lead once the network is back (LEAD-FORMS-REVIEW-2026-10-03 #4).
280
+ test("offline, the form says so, keeps what was typed and sends it on retry", async ({ page, context }) => {
281
+ await page.goto("/fr");
282
+ await expect(page.locator("#quote select")).toHaveCount(0);
283
+ const form = page.locator("#quote-form");
284
+ await form.getByLabel("Code postal").fill("75011");
285
+ await form.getByLabel("Téléphone").fill("0612345678");
286
+ await context.setOffline(true);
287
+ await form.getByRole("button", { name: "Recevoir le prix" }).click();
288
+ const alert = form.getByRole("alert");
289
+ await expect(alert).toContainText("Pas de connexion");
290
+ await expect(page).toHaveURL(/\/fr$/);
291
+ await expect(form.getByLabel("Téléphone")).toHaveValue("0612345678");
292
+ await context.setOffline(false);
293
+ await alert.getByRole("button", { name: "Réessayer" }).click();
294
+ await expect(page).toHaveURL(/\/fr\/thanks$/);
295
+ });