@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
package/README.md CHANGED
@@ -45,7 +45,7 @@ brand's `flake.nix` is its own config plus one call — `template/flake.nix` is
45
45
  the whole of one:
46
46
 
47
47
  ```nix
48
- inputs.ev.url = "github:EV-invest/lib?ref=@evinvest/kitstart-v0.8.0"; # the version in package-lock.json
48
+ inputs.ev.url = "github:EV-invest/lib?ref=@evinvest/kitstart-v0.9.0"; # the version in package-lock.json
49
49
  inputs.ev.inputs.v_flakes.follows = "v_flakes";
50
50
 
51
51
  landing = ev.lib.mkLanding {
@@ -226,20 +226,21 @@ it is the plain POST to `/quote` it always was.
226
226
  | `prefer` | a channel moved first when available (a `default_channel` arm) |
227
227
  | `experiment` | `{ name, variant }`, slugs: on every event and posted with the form |
228
228
  | `timeZone` | `Europe/Paris` by default |
229
- | `formId`, `id` | `quote` · `quote`: the id is the card's (root), so `#quote` scrolls to the whole card, head included; the form is `<id>-form`, the callback `<id>-callback` (its form `<id>-callback-form`) |
230
- | `text` | `LeadCaptureText`: `LEAD_CAPTURE_TEXT.fr` / `.en`, plain strings (`{need}`, `{day}`, `{time}` filled in); optional `localityPlaceholder`, `phonePlaceholder`, `namePlaceholder` (none by default) |
229
+ | `formId`, `id` | `quote` · `quote`: the id is the card's (root), so `#quote` scrolls to the whole card, head included; the form is `<id>-form`, the callback `<id>-callback` (its form `<id>-callback-form`). Both forms post it as `card`, so a refused lead comes back to `#<id>` |
230
+ | `text` | `LeadCaptureText`: `LEAD_CAPTURE_TEXT.fr` / `.en`, plain strings (`{need}`, `{day}`, `{time}` filled in); optional `localityPlaceholder`, `phonePlaceholder`, `namePlaceholder` (none by default). The errors are words of the page too: `phoneInvalid`, `required`, `needRequired`, `consentRequired`, `fieldInvalid`, `formInvalid`, and the post's `sending`, `networkError`, `timeoutError`, `retry` — a brand spreading `LEAD_CAPTURE_TEXT` has them |
231
231
  | `labels` | `visible` (default) · `hidden`: every field's label `sr-only` — still the field's accessible name — for a design that draws placeholders |
232
232
  | `callbackOpen` | whether the callback starts open; by default only when it leads (the place is closed). `#<id>-callback` opens it either way |
233
- | `done` | the card after a lead is taken — a node, or `(sent: LeadSent) => node` (`{ channel, phone, name }`). With it a script posts the form itself and shows this in place on the thanks page's 303; any other answer, or no network, submits the form for real; without a script nothing changes (303 → `/thanks`). Without it, every submit goes to `/thanks` |
233
+ | `done` | the card after a lead is taken — a node, or `(sent: LeadSent) => node` (`{ channel, phone, name }`), shown in place. Without it, a lead taken goes to the thanks page the route names. Either way a script posts the form itself (asking `/quote` for JSON), so a refusal keeps what was typed; an answer that is not the route's, or no network, submits the form for real; without a script nothing changes (303) |
234
234
  | `head`, `trust` | the brand's heading instead of the title; a slot beside the submit |
235
- | `className` · `classNames` | the root · its parts: `root`, `head`, `title`, `lede`, `form`, `contact`, `field`, `label`, `control` (every input, the need's select in both states, the callback's phone), `hint`, `chips`, `chip`, `needs`, `need`, `summary`, `submit`, `trust`, `privacy`, `opening`, `others`, `channel`, `primary`, `callback`, `callbackSummary`, `callbackForm`, `callbackLede`, `callbackSubmit`, `consent`, `done` |
235
+ | `className` · `classNames` | the root · its parts: `root`, `head`, `title`, `lede`, `form`, `contact`, `field`, `label`, `control` (every input, the need's select in both states, the callback's phone), `hint`, `error` (a refusal: under the field, or above the submit), `chips`, `chip`, `needs`, `need`, `summary`, `submit`, `trust`, `privacy`, `opening`, `others`, `channel`, `primary`, `callback`, `callbackSummary`, `callbackForm`, `callbackLede`, `callbackSubmit`, `consent`, `done` |
236
236
 
237
237
  - **Taps.** A need the page knows is not asked again, and a place serving one
238
238
  commune fills it: focus the phone, type, send — two taps. `qualify-first`
239
239
  with no need: the tile, which moves the focus to the first empty field (the
240
240
  phone when the commune is filled), then send.
241
241
  `single` with no need takes the first one unless changed (two more taps).
242
- Enter sends.
242
+ Enter sends. From the keyboard the tiles are a radio group: the arrows move
243
+ the choice and stay in it; Enter or Space answers and moves on.
243
244
  - **Channels** (`resolveChannels`, shared with `CallBar`): call (`tel:`),
244
245
  WhatsApp and SMS (the need in the message; SMS only to a mobile), "call me
245
246
  back" and the form. Open, the call leads; closed, the callback (open) and
@@ -252,11 +253,12 @@ it is the plain POST to `/quote` it always was.
252
253
  - **Callback.** A `<details>` with its own small form: the phone and a native,
253
254
  required consent, posted to `/quote` with `channel=callback`. The consent's
254
255
  value is the sentence it shows (`text.callbackConsent`, in the page's
255
- language); the server refuses a callback without it — back to the form, as
256
- any invalid lead, whatever the brand's rule — and stores it with the lead:
256
+ language); the server refuses a callback without it — back to the callback,
257
+ open, naming the consent, whatever the brand's rule — and stores it with the lead:
257
258
  `consent_text` word for word, `consent_at` when the server accepted it
258
- (`Lead.consent`). Then the lead is held to `lead.validateCallback` (a
259
- readable number, by default), not to the form's rule. The consent goes into
259
+ (`Lead.consent`). Then the lead is held to `lead.validateCallback` (the
260
+ phone rule, by default), not to the form's rule. Its phone is the form's
261
+ field: the same hint, the same block. The consent goes into
260
262
  no event; a webhook body built by the brand should leave `lead.consent` out.
261
263
  - **Locality.** A postcode field (`autoComplete="postal-code"`). A storefront
262
264
  with no named zone fills in its own postcode; a named zone fills in its one
@@ -267,22 +269,48 @@ it is the plain POST to `/quote` it always was.
267
269
  `inline-flex`) with `leading-6` after the brand's `channel` /
268
270
  `callbackSummary` classes unless they set a line height of their own, so a
269
271
  brand's type size cannot leave it on a half pixel.
270
- - **Phone.** `type="tel"`, required, never masked. A number that does not read
271
- as one gets a hint when the field is left; the server keeps it. With
272
+ - **Phone.** `type="tel"`, required, never masked, held to one rule on both
273
+ sides (`phoneProblem`, `validateLead`): a French number is ten digits from
274
+ `0[1-9]` (or `+33` and nine), any other a `+` and 8 to 15 digits; full-width
275
+ digits read as digits; a run of one digit is no number. One that fails gets
276
+ a hint when the field is left (`aria-live`, read out) and blocks the submit
277
+ in the page's words — the browser's bubble would speak its own language. With
272
278
  `lead.mobileFormat: "e164"` a number it can read is stored as
273
279
  `+33612345678` (`normalizePhone`); the default keeps it as typed, which is
274
280
  what a brand's tests and tooling look rows up by.
281
+ - **Refusals are never silent.** Every required field blocks with the page's
282
+ words and is marked `aria-invalid` once refused. What the server still
283
+ refuses (a brand's own rule, a stale page) comes back to the card: the
284
+ script's post gets a `422 { field }` and the form, as typed, shows the error
285
+ at that field (`role="alert"`, focused); the plain post gets a 303 to
286
+ `?lead_error=<field>&need=<need>#<id>` (`#<id>-callback` for a callback,
287
+ opened), which the card reads once the script runs and then drops from the
288
+ URL. Only slugs ride in that URL — never a phone or a name. A field the card
289
+ does not draw (a brand's `extras`) shows its error above the submit and is
290
+ marked itself.
291
+ - **Sending.** The script's post (asking `/quote` for JSON) carries a
292
+ `submission_id` it mints once per lead — a new one as soon as anything
293
+ posted changes, and never one the browser restored; the store keeps one row
294
+ per id, so a resend after a lost answer is answered as the first was and
295
+ never makes a second lead. While it runs the form's submit is disabled, `aria-busy` and
296
+ says `sending`. No answer — no network, or none in 15 s
297
+ (`SUBMIT_TIMEOUT_MS`) — is said in place with a retry that sends the same
298
+ lead; the page is never left for the browser's error page.
275
299
  - **Events** (through `AnalyticsBoundary`'s sink; none outside one):
276
300
  `lead_form_view` (half in view, once), `lead_form_start` (first focus),
277
- `lead_form_field_error {field}` (the browser refused it, or the phone hint
278
- showed — the field's role, never its value), `lead_form_step {step}`
301
+ `lead_form_field_error {field, blocking}` (`blocking: true` the browser
302
+ refused it, `false` the phone hint showed — the field's role, never its
303
+ value), `lead_form_submit_error {reason, channel}` (`network` /
304
+ `timeout`), `lead_form_step {step}`
279
305
  (`contact` / `need`), each with `form_id`, `layout`, `experiment`,
280
306
  `variant`; `contact_intent_click {channel}` for `phone`, `whatsapp`, `sms`,
281
307
  `callback`, with the experiment; and on the server `lead_form_submit
282
- {form_id, channel}` with the posted experiment.
283
- - **Weight.** 5.6 KB gz of first-load JS on the template's place page
284
- (157,287 → 162,985 B against its 158,000 B target: +3.2 %, a warning
285
- within the 20 % tolerance).
308
+ {form_id, channel}` and `lead_form_reject {form_id, channel, field,
309
+ reason}` with the posted experiment.
310
+ - **Weight.** 8.9 KB gz of first-load JS on the template's place page
311
+ (157,287 → 166,361 B against its 158,000 B target: +5.3 %, a warning
312
+ within the 20 % tolerance); the refusals and the shared phone rule are
313
+ 2.0 KB of it, sending (the id, the busy state, the retry) 0.6 KB.
286
314
 
287
315
  Tailwind v4 does not scan `node_modules`; the brand's `globals.css` names the
288
316
  package:
@@ -311,9 +339,9 @@ export const proxy = createProxy(site);
311
339
  // included — let past, it would be a cached bare 404 without the phone.
312
340
  export const config = { matcher: ["/((?!_next/).*)"] };
313
341
 
314
- // app/quote/route.ts
342
+ // app/quote/route.ts — `anchor`: the card's id, when it is not `quote`
315
343
  export const dynamic = "force-dynamic";
316
- export const POST = quoteRoute(site, { env: serverEnv, notifier, unavailable });
344
+ export const POST = quoteRoute(site, { env: serverEnv, notifier, unavailable, anchor: "devis" });
317
345
 
318
346
  // app/sitemap.ts — reads the Host header, so it is dynamic; pages are not
319
347
  export const dynamic = "force-dynamic";
@@ -324,6 +352,24 @@ export function generateStaticParams() { return []; }
324
352
  export const revalidate = 600;
325
353
  ```
326
354
 
355
+ **The lead rule.** `site.lead.validate` defaults to `validateLead`: the
356
+ number the form itself blocks on. A brand's own rule composes with it and
357
+ names the field it refuses, which the form shows the error at:
358
+
359
+ ```ts
360
+ validate: lead => validateLead(lead) ?? (lead.locality === "" ? { field: "locality", why: "a postcode" } : null),
361
+ ```
362
+
363
+ A bare string still works — a refusal of the whole `form`. The landing
364
+ contract (`describeLandingContract`) holds the rule to the form's phone check
365
+ over `PHONE_MATRIX` (`leadRuleDisagreements`), so the server never refuses a
366
+ number the form let through.
367
+
368
+ **The thanks page.** A callback lands on `/thanks?channel=callback`; read it
369
+ with `thanksChannel(searchParams)` to promise a call instead of a quote, and
370
+ pass `{ thanks: true, channel }` to `statusTarget` so the language switch
371
+ keeps it.
372
+
327
373
  `createPlaceLoader(site, places)(params)` returns the `PlaceView` for a page,
328
374
  reading the link mode from the `location` param (`_royat` = host mode).
329
375
 
@@ -352,6 +398,8 @@ chooses per visitor answer `Cache-Control: private, no-store`.
352
398
  adapter passes `describeLeadStoreContract` and migrates itself to
353
399
  `LEAD_SCHEMA_VERSION` on open. SQLite keeps the Rust server's columns
354
400
  (`job`, `zip`, `mobile`) and recognises every earlier table in place.
401
+ Schema 6 adds `submission_id` under a unique index (where present): one
402
+ row per script submission, read back by `findSubmission`.
355
403
  - **Mail is checked at boot.** With `SMTP_URL` set, `leadNotifier` throws when
356
404
  it is built if the URL is malformed (the error never repeats it), there is
357
405
  no sender (`LEAD_NOTIFY_FROM`, or `leads@<domain>`) or no recipient
@@ -408,15 +456,33 @@ export const POST = quoteRoute(site, { env: serverEnv, notifier, webhook, unavai
408
456
  lead and `ctx` (`leadId`, `brandId`, `locale`, `formId`, `at`, and a fresh
409
457
  `idempotencyKey` for the receiver to deduplicate by), and written to the
410
458
  `webhook_outbox` table before the visitor is thanked; the send runs in
411
- `after`. A lead the notifier skips (honeypot, rate limit) is not queued.
459
+ `after`. A lead the notifier skips (honeypot, rate limit) is not queued —
460
+ unless `panelSuspect` (below) queues the rate-limited one.
461
+ - **Suspect leads: `panelSuspect`, off.** `suspectOf(lead)` is
462
+ `rate_limited`, `too_fast` or nothing — never `honeypot`, whose lead goes
463
+ nowhere. With `leadWebhook(…, { panelSuspect: true })` a rate-limited lead
464
+ is queued too (still never mailed) and `ctx.suspect` names why a lead is
465
+ suspect, for the body's `suspect` property. Off by default, and to stay off
466
+ until the panel's `lead.created` accepts the property: it refuses an
467
+ unknown one, and the outbox would park the lead. Off, nothing changes.
468
+ - **The rate limit** is `LEAD_RATE_LIMIT` (`<count>/<seconds>`): 5 per 10
469
+ minutes per address in production, 100 outside it, where every request of
470
+ a local stack shares one address. Outside production a lead held back as
471
+ rate-limited or honeypot is logged loudly.
412
472
  - **Signature.** Three headers named by `signing.headers`: the key id, the
413
473
  timestamp (unix seconds) and `hex(HMAC-SHA256(secret, prefix + timestamp +
414
474
  "." + body))`, signed afresh on each attempt. Prefix and names are required.
415
475
  - **At-least-once.** 2xx is delivered. A `207` is delivered too, and an item
416
476
  it marks `rejected` is final — logged by index, not retried. 5xx, 408, 429
417
477
  and network errors retry, doubling from 5 s up to an hour (with jitter, and
418
- at least `Retry-After`), `WEBHOOK_MAX_ATTEMPTS` (12) times; any other status,
419
- 3xx included, is final. A final row stays as `dead` with its `last_error`.
478
+ at least `Retry-After`), for 48 hours from queueing (`horizonMs`;
479
+ `maxAttempts` is an optional cap). 401, 403 and 404 retry too, logged as
480
+ errors naming the setting to check — a rotated key, a moved receiver. Any
481
+ other status, 3xx included, is final. A final row stays as `dead` with its
482
+ `last_error`, `onDead({ id, ref, attempts, error })` is told (an alert), and
483
+ `requeueDead()` — or `kitstart-outbox requeue` on the leads file (the rows
484
+ of `--target`, else `LEAD_WEBHOOK_URL`), with `kitstart-outbox status` to
485
+ count rows — puts the dead rows back, due now, their horizon fresh.
420
486
  - **PII stays in the body.** Logs name the row, the attempt and the status;
421
487
  the receiver's own words (which may echo a field) go to the row's
422
488
  `last_error`, never to the log.
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -0,0 +1,10 @@
1
+ #!/usr/bin/env node
2
+ // kitstart-outbox <status | requeue> [--db <leads.db>] — see `outbox.ts`.
3
+ import { runOutboxCli } from "./outbox.js";
4
+ try {
5
+ process.exitCode = runOutboxCli(process.argv.slice(2), process.env, line => console.log(line));
6
+ }
7
+ catch (error) {
8
+ console.error(`✘ ${error instanceof Error ? error.message : String(error)}`);
9
+ process.exitCode = 1;
10
+ }
@@ -0,0 +1,5 @@
1
+ export declare const USAGE = "kitstart-outbox <status | requeue> [--db <leads.db>]\n\n status rows of webhook_outbox by state and target\n requeue dead rows back to pending, due now, their tries and horizon fresh \u2014\n only the receiver's of --target, else LEAD_WEBHOOK_URL; all\n without either (a running site sends only its own target's)\n\nThe file is --db, else LEADS_DB_URL (sqlite:///<path>), else LEADS_DB_PATH.";
2
+ type Env = Readonly<Record<string, string | undefined>>;
3
+ /** Runs the command; answers the exit code. `print` takes each line of output. */
4
+ export declare function runOutboxCli(args: readonly string[], env: Env, print: (line: string) => void, now?: number): number;
5
+ export {};
@@ -0,0 +1,77 @@
1
+ // kitstart-outbox — the webhook outbox from a shell, on the leads file itself.
2
+ // Plain node and its builtins only, like every bin here: the server layer
3
+ // imports `server-only`, which throws outside Next, so the two statements
4
+ // below repeat what `requeueDead()` and `rows()` do in
5
+ // `server/webhook-outbox.ts`; a test runs both on one file.
6
+ import { existsSync } from "node:fs";
7
+ export const USAGE = `kitstart-outbox <status | requeue> [--db <leads.db>]
8
+
9
+ status rows of webhook_outbox by state and target
10
+ requeue dead rows back to pending, due now, their tries and horizon fresh —
11
+ only the receiver's of --target, else LEAD_WEBHOOK_URL; all
12
+ without either (a running site sends only its own target's)
13
+
14
+ The file is --db, else LEADS_DB_URL (sqlite:///<path>), else LEADS_DB_PATH.`;
15
+ function leadsFile(args, env) {
16
+ const at = args.indexOf("--db");
17
+ if (at !== -1)
18
+ return args[at + 1] ?? null;
19
+ const url = env["LEADS_DB_URL"]?.trim();
20
+ if (url) {
21
+ const parsed = new URL(url);
22
+ if (parsed.protocol !== "sqlite:" || parsed.host !== "")
23
+ throw new Error("LEADS_DB_URL is not sqlite:///<absolute path>");
24
+ return decodeURIComponent(parsed.pathname);
25
+ }
26
+ return env["LEADS_DB_PATH"]?.trim() || null;
27
+ }
28
+ function open(path) {
29
+ const sqlite = process.getBuiltinModule("node:sqlite");
30
+ if (!sqlite)
31
+ throw new Error("node:sqlite is unavailable — Node ≥ 22.13 is required");
32
+ const db = new sqlite.DatabaseSync(path);
33
+ db.exec("PRAGMA busy_timeout = 5000");
34
+ return db;
35
+ }
36
+ /** Runs the command; answers the exit code. `print` takes each line of output. */
37
+ export function runOutboxCli(args, env, print, now = Date.now()) {
38
+ const [command] = args;
39
+ if (command !== "status" && command !== "requeue") {
40
+ print(USAGE);
41
+ return 2;
42
+ }
43
+ const path = leadsFile(args, env);
44
+ if (!path) {
45
+ print(`no leads file: pass --db, or set LEADS_DB_URL or LEADS_DB_PATH\n\n${USAGE}`);
46
+ return 2;
47
+ }
48
+ // Opening a path that is not there would create an empty file there.
49
+ if (!existsSync(path)) {
50
+ print(`no leads file at ${path}`);
51
+ return 2;
52
+ }
53
+ const db = open(path);
54
+ try {
55
+ if (command === "requeue") {
56
+ const at = args.indexOf("--target");
57
+ const raw = at !== -1 ? args[at + 1] : env["LEAD_WEBHOOK_URL"]?.trim();
58
+ // Spelt as the server stores it (`checkWebhookUrl`), or nothing would match.
59
+ const target = raw ? new URL(raw).toString() : null;
60
+ const sql = "UPDATE webhook_outbox SET state = 'pending', attempts = 0, created_at = ?, next_attempt_at = ?, done_at = NULL WHERE state = 'dead'";
61
+ const done = target === null ? db.prepare(sql).run(now, now) : db.prepare(`${sql} AND target = ?`).run(now, now, target);
62
+ print(target === null
63
+ ? `requeued ${Number(done.changes)} dead row(s) of every target; a running site sends those of its own LEAD_WEBHOOK_URL on its next tick`
64
+ : `requeued ${Number(done.changes)} dead row(s) for ${target}; the running site sends them on its next tick`);
65
+ return 0;
66
+ }
67
+ const rows = db.prepare("SELECT state, target, COUNT(*) AS n FROM webhook_outbox GROUP BY state, target ORDER BY state, target").all();
68
+ if (rows.length === 0)
69
+ print("the outbox is empty");
70
+ for (const row of rows)
71
+ print(`${String(Reflect.get(row, "state")).padEnd(10)}${String(Reflect.get(row, "n")).padStart(6)} ${String(Reflect.get(row, "target"))}`);
72
+ return 0;
73
+ }
74
+ finally {
75
+ db.close();
76
+ }
77
+ }
@@ -1,5 +1,5 @@
1
1
  import { type RateLimiter } from "./antispam.js";
2
- import { type Lead } from "./lead.js";
2
+ import { type Lead, type LeadChannel } from "./lead.js";
3
3
  import type { Site } from "./site.js";
4
4
  /** Hidden fields the form carries besides what the visitor types. */
5
5
  export declare const LOCATION_FIELD = "location";
@@ -13,15 +13,23 @@ export interface SubmitTags {
13
13
  experiment?: string;
14
14
  variant?: string;
15
15
  }
16
- export type Outcome<L extends string> = {
16
+ export type Outcome<L extends string> =
17
+ /** `duplicate`: a resend of a submission already stored — answered as the first was, nothing sent again. */
18
+ {
17
19
  kind: "stored";
18
20
  id: number;
19
21
  lead: Lead;
20
22
  locale: L;
21
23
  formId: string;
22
- } | {
24
+ duplicate?: true;
25
+ }
26
+ /** `field` names what to fix (`phone`, `consent`, … or `form`); `why` is for the log. */
27
+ | {
23
28
  kind: "invalid";
24
29
  why: string;
30
+ field: string;
31
+ channel: LeadChannel;
32
+ formId: string;
25
33
  locale: L;
26
34
  slug: string | null;
27
35
  } | {
@@ -32,6 +40,11 @@ export type Outcome<L extends string> = {
32
40
  export interface AcceptDeps {
33
41
  /** The commit point: resolves once the lead is durable. */
34
42
  insert: (lead: Lead) => Promise<number>;
43
+ /** The lead a submission id was stored as (`LeadStore.findSubmission`); absent → no deduplication. */
44
+ findSubmission?: (submissionId: string) => Promise<{
45
+ id: number;
46
+ lead: Lead;
47
+ } | null>;
35
48
  /** Runs after the response is sent; see the route handler. */
36
49
  defer: (task: () => Promise<void> | void) => void;
37
50
  notify: (lead: Lead, id: number) => Promise<void>;
@@ -44,6 +57,12 @@ export interface AcceptDeps {
44
57
  locale: string;
45
58
  formId: string;
46
59
  }) => void;
60
+ /**
61
+ * Queue a rate-limited lead too, for the panel to show as suspect
62
+ * (`LeadWebhookOptions.panelSuspect`). Off: it waits in the table, as before.
63
+ * It is never mailed; a honeypot lead is never sent anywhere.
64
+ */
65
+ sendSuspect?: boolean;
47
66
  capture: (lead: Lead, formId: string, tags: SubmitTags) => void;
48
67
  limiter: RateLimiter;
49
68
  now: number;
@@ -1,6 +1,6 @@
1
1
  import { experimentProps } from "./analytics.js";
2
2
  import { HONEYPOT_FIELD, LEGACY_HONEYPOT_FIELDS, RENDERED_AT_FIELD, screen } from "./antispam.js";
3
- import { MAX_FIELD, readCandidate, validateCandidate } from "./lead.js";
3
+ import { channelOf, MAX_FIELD, readCandidate, validateCandidate } from "./lead.js";
4
4
  /** Hidden fields the form carries besides what the visitor types. */
5
5
  export const LOCATION_FIELD = "location";
6
6
  export const LOCALE_FIELD = "locale";
@@ -30,17 +30,37 @@ function field(form, name) {
30
30
  export function createAcceptLead(site) {
31
31
  return (form, clientKey, deps) => accept(site, form, clientKey, deps);
32
32
  }
33
+ /** The stored lead for a submission id; a failed lookup is no lookup — the insert's index still holds. */
34
+ async function stored(submissionId, deps) {
35
+ if (!submissionId || !deps.findSubmission)
36
+ return null;
37
+ try {
38
+ return await deps.findSubmission(submissionId);
39
+ }
40
+ catch (error) {
41
+ deps.log.error("quote: the lookup of a resent submission failed", error);
42
+ return null;
43
+ }
44
+ }
33
45
  async function accept(site, form, clientKey, deps) {
34
46
  const rawSlug = field(form, LOCATION_FIELD);
35
47
  const slug = rawSlug && site.placeSlugs.includes(rawSlug) ? rawSlug : null;
36
48
  const rawLocale = field(form, LOCALE_FIELD);
37
49
  const locale = site.i18n.isLocale(rawLocale) ? rawLocale : site.i18n.defaultLocale;
50
+ // It reaches analytics as a property; anything but a short slug is dropped.
51
+ const posted = field(form, FORM_ID_FIELD);
52
+ const formId = posted !== null && FORM_ID.test(posted) ? posted : "quote";
38
53
  const candidate = readCandidate(site.lead, form, slug);
39
- const why = validateCandidate(site.lead, candidate);
40
- if (why) {
41
- deps.log.warn(`quote: rejected a submission for ${slug ?? "no point"}: missing ${why}`);
42
- return { kind: "invalid", why, locale, slug };
54
+ const rejection = validateCandidate(site.lead, candidate);
55
+ if (rejection) {
56
+ deps.log.warn(`quote: rejected a submission for ${slug ?? "no point"} at ${rejection.field}: missing ${rejection.why}`);
57
+ return { kind: "invalid", why: rejection.why, field: rejection.field, channel: channelOf(candidate), formId, locale, slug };
43
58
  }
59
+ // A resend of a stored submission (its answer was lost) is that lead: no
60
+ // second row, no second mail, the limit not spent again.
61
+ const prior = await stored(candidate.submissionId, deps);
62
+ if (prior)
63
+ return { kind: "stored", id: prior.id, lead: prior.lead, locale, formId, duplicate: true };
44
64
  // After validation, so a typo corrected and resent does not spend the limit.
45
65
  const verdict = screen({
46
66
  honeypot: [HONEYPOT_FIELD, ...LEGACY_HONEYPOT_FIELDS].map(name => field(form, name)).find(v => v) ?? null,
@@ -61,28 +81,35 @@ async function accept(site, form, clientKey, deps) {
61
81
  id = await deps.insert(lead);
62
82
  }
63
83
  catch (error) {
84
+ // Two posts of one submission raced past the lookup; the unique index let one in.
85
+ const winner = await stored(candidate.submissionId, deps);
86
+ if (winner)
87
+ return { kind: "stored", id: winner.id, lead: winner.lead, locale, formId, duplicate: true };
64
88
  deps.log.error(`quote: the lead store rejected a submission for ${slug ?? "no point"}`, error);
65
89
  return { kind: "failed", locale, slug };
66
90
  }
67
- // It reaches analytics as a property; anything but a short slug is dropped.
68
- const posted = field(form, FORM_ID_FIELD);
69
- const formId = posted !== null && FORM_ID.test(posted) ? posted : "quote";
70
91
  // The render stamp comes from the client, so it only marks: a `too-fast`
71
92
  // lead (or one from a page cached before the stamp existed) is still sent
72
93
  // on, flagged. The honeypot and the rate limit are the server's own
73
94
  // evidence, and those leads wait in the table for a reviewer.
74
- if (lead.spamVerdict && lead.spamVerdict !== "too-fast") {
75
- deps.log.warn(`quote: lead ${id} stored as suspected spam (${lead.spamVerdict}); not notified`);
76
- return { kind: "stored", id, lead, locale, formId };
77
- }
78
- if (deps.enqueue) {
95
+ const queue = () => {
96
+ if (!deps.enqueue)
97
+ return;
79
98
  try {
80
99
  deps.enqueue(lead, id, { locale, formId });
81
100
  }
82
101
  catch (error) {
83
102
  deps.log.error(`quote: lead ${id} is stored but could not be queued for its webhook`, error);
84
103
  }
104
+ };
105
+ if (lead.spamVerdict && lead.spamVerdict !== "too-fast") {
106
+ const marked = lead.spamVerdict === "rate-limited" && deps.sendSuspect === true;
107
+ deps.log.warn(`quote: lead ${id} stored as suspected spam (${lead.spamVerdict}); not notified${marked ? ", queued marked suspect" : ""}`);
108
+ if (marked)
109
+ queue();
110
+ return { kind: "stored", id, lead, locale, formId };
85
111
  }
112
+ queue();
86
113
  deps.defer(async () => {
87
114
  try {
88
115
  await deps.notify(lead, id);
@@ -14,6 +14,8 @@ export declare const EVENTS: {
14
14
  readonly formStart: "lead_form_start";
15
15
  readonly fieldError: "lead_form_field_error";
16
16
  readonly formStep: "lead_form_step";
17
+ readonly submitError: "lead_form_submit_error";
18
+ readonly formReject: "lead_form_reject";
17
19
  };
18
20
  export type IntentChannel = "form_open" | "whatsapp" | "phone" | "sms" | "callback" | "booking";
19
21
  /**
@@ -30,7 +32,7 @@ export type LeadStep = "need" | "contact";
30
32
  * throws, prod drops) rather than by review. `experiment` and `variant` are
31
33
  * the site's assignment, passed in; `field` is a field's role, never its value.
32
34
  */
33
- export declare const ALLOWED_PROPS: readonly ["brand_id", "location_id", "source", "device", "channel", "form_id", "experiment", "variant", "field", "step", "layout"];
35
+ export declare const ALLOWED_PROPS: readonly ["brand_id", "location_id", "source", "device", "channel", "form_id", "experiment", "variant", "field", "step", "layout", "reason", "blocking"];
34
36
  /** What an experiment's assignment looks like on the wire: short slugs, nothing a person typed. */
35
37
  export declare const EXPERIMENT_SLUG: RegExp;
36
38
  /** An experiment's assignment as event properties, or nothing when it is not a pair of slugs. */
@@ -17,6 +17,10 @@ export const EVENTS = {
17
17
  formStart: "lead_form_start",
18
18
  fieldError: "lead_form_field_error",
19
19
  formStep: "lead_form_step",
20
+ // The script's post that never got an answer (no network, no answer in time).
21
+ submitError: "lead_form_submit_error",
22
+ // The server's refusal (`reason`, the `field`): the visitor was sent back.
23
+ formReject: "lead_form_reject",
20
24
  };
21
25
  /**
22
26
  * Every property name an event may carry. A customer's phone or address comes
@@ -37,6 +41,9 @@ export const ALLOWED_PROPS = [
37
41
  "field",
38
42
  "step",
39
43
  "layout",
44
+ "reason",
45
+ // A field error that blocked the submit (`true`), or a hint (`false`).
46
+ "blocking",
40
47
  ];
41
48
  /** What an experiment's assignment looks like on the wire: short slugs, nothing a person typed. */
42
49
  export const EXPERIMENT_SLUG = /^[a-z0-9_-]{1,32}$/;
@@ -18,8 +18,31 @@ export interface LeadCaptureText {
18
18
  /** Names the row of communes a tap fills the locality from. */
19
19
  servedLabel: string;
20
20
  phoneLabel: string;
21
- /** The soft check under a number that does not read as one; it never blocks. */
21
+ /**
22
+ * Under a number that does not read as one, once the visitor leaves the
23
+ * field — a hint while typing; the submit is blocked by `phoneInvalid`.
24
+ */
22
25
  phoneHint: string;
26
+ /** Blocks the submit: a number the server would refuse. */
27
+ phoneInvalid: string;
28
+ /** Blocks the submit: a required field left empty — the browser's own words are its UI language, not the page's. */
29
+ required: string;
30
+ /** Blocks the submit: no need picked. */
31
+ needRequired: string;
32
+ /** Blocks the callback: the consent box unticked. */
33
+ consentRequired: string;
34
+ /** The server refused a field the form cannot word better (a brand's own). */
35
+ fieldInvalid: string;
36
+ /** The server refused the request without naming a field. */
37
+ formInvalid: string;
38
+ /** On the submit while the script posts the form. */
39
+ sending: string;
40
+ /** The post never reached the server: nothing was sent, the form is as typed. */
41
+ networkError: string;
42
+ /** No answer in time: it may have arrived — a retry cannot make a second lead. */
43
+ timeoutError: string;
44
+ /** Beside the two above: sends the same lead again. */
45
+ retry: string;
23
46
  nameLabel: string;
24
47
  /**
25
48
  * Shown in the empty field. None by default: a placeholder is not a label —
@@ -7,6 +7,16 @@ const FR = {
7
7
  servedLabel: "Communes desservies",
8
8
  phoneLabel: "Téléphone",
9
9
  phoneHint: "Vérifiez le numéro : 06 12 34 56 78 ou +33 6 12 34 56 78.",
10
+ phoneInvalid: "Ce numéro n’est pas valide. Exemple : 06 12 34 56 78 ou +33 6 12 34 56 78.",
11
+ required: "Ce champ est obligatoire.",
12
+ needRequired: "Choisissez votre besoin.",
13
+ consentRequired: "Cochez la case pour que nous puissions vous rappeler.",
14
+ fieldInvalid: "Vérifiez ce champ.",
15
+ formInvalid: "Votre demande n’a pas pu être envoyée. Vérifiez le formulaire.",
16
+ sending: "Envoi…",
17
+ networkError: "Pas de connexion : votre demande n’est pas partie. Vérifiez le réseau et réessayez.",
18
+ timeoutError: "Le serveur ne répond pas. Réessayez : votre demande ne sera pas envoyée deux fois.",
19
+ retry: "Réessayer",
10
20
  nameLabel: "Nom",
11
21
  optional: "facultatif",
12
22
  submit: "Recevoir le prix",
@@ -35,6 +45,16 @@ const EN = {
35
45
  servedLabel: "Towns we serve",
36
46
  phoneLabel: "Phone",
37
47
  phoneHint: "Check the number: 06 12 34 56 78 or +33 6 12 34 56 78.",
48
+ phoneInvalid: "This number is not valid. For example: 06 12 34 56 78 or +33 6 12 34 56 78.",
49
+ required: "This field is required.",
50
+ needRequired: "Choose what you need.",
51
+ consentRequired: "Tick the box so we can call you back.",
52
+ fieldInvalid: "Check this field.",
53
+ formInvalid: "Your request could not be sent. Check the form.",
54
+ sending: "Sending…",
55
+ networkError: "No connection: your request was not sent. Check the network and try again.",
56
+ timeoutError: "The server is not answering. Try again: your request will not be sent twice.",
57
+ retry: "Try again",
38
58
  nameLabel: "Name",
39
59
  optional: "optional",
40
60
  submit: "Get the price",