@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
|
@@ -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
|
|
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
|
-
/**
|
|
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
|
|
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 ??
|
|
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
|
-
|
|
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();
|
package/dist/testing/index.d.ts
CHANGED
|
@@ -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";
|
package/dist/testing/index.js
CHANGED
|
@@ -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.
|
|
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",
|
package/template/flake.nix
CHANGED
|
@@ -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.
|
|
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
|
|
package/template/package.json
CHANGED
|
@@ -1,11 +1,14 @@
|
|
|
1
|
-
import type
|
|
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.
|
|
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:
|
|
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
|
+
});
|