@evinvest/kitstart 0.8.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +89 -23
- package/dist/cli/kitstart-outbox.d.ts +2 -0
- package/dist/cli/kitstart-outbox.js +10 -0
- package/dist/cli/outbox.d.ts +5 -0
- package/dist/cli/outbox.js +77 -0
- package/dist/core/accept.d.ts +22 -3
- package/dist/core/accept.js +40 -13
- package/dist/core/analytics.d.ts +3 -1
- package/dist/core/analytics.js +7 -0
- package/dist/core/lead-capture-text.d.ts +24 -1
- package/dist/core/lead-capture-text.js +20 -0
- package/dist/core/lead.d.ts +71 -10
- package/dist/core/lead.js +65 -11
- package/dist/core/phone.d.ts +20 -9
- package/dist/core/phone.js +43 -19
- package/dist/core/status.d.ts +13 -0
- package/dist/core/status.js +19 -2
- package/dist/index.d.ts +3 -3
- package/dist/index.js +3 -3
- package/dist/next/quote-route.d.ts +15 -2
- package/dist/next/quote-route.js +70 -10
- package/dist/react/LeadCapture.d.ts +7 -5
- package/dist/react/LeadCapture.js +18 -6
- package/dist/react/LeadCaptureChannels.d.ts +14 -1
- package/dist/react/LeadCaptureChannels.js +8 -5
- package/dist/react/LeadCaptureFields.d.ts +39 -7
- package/dist/react/LeadCaptureFields.js +91 -21
- package/dist/react/LeadCaptureNeed.d.ts +5 -0
- package/dist/react/LeadCaptureNeed.js +38 -2
- package/dist/react/LeadCaptureSubmit.d.ts +16 -0
- package/dist/react/LeadCaptureSubmit.js +13 -0
- package/dist/react/QuoteFormShell.d.ts +2 -0
- package/dist/react/QuoteFormShell.js +3 -2
- package/dist/react/use-lead-error.d.ts +24 -0
- package/dist/react/use-lead-error.js +73 -0
- package/dist/react/use-lead-events.d.ts +3 -0
- package/dist/react/use-lead-events.js +3 -2
- package/dist/react/use-lead-submit.d.ts +44 -9
- package/dist/react/use-lead-submit.js +122 -13
- package/dist/react/use-validity.d.ts +17 -0
- package/dist/react/use-validity.js +38 -0
- package/dist/server/env.d.ts +9 -0
- package/dist/server/env.js +12 -0
- package/dist/server/index.d.ts +1 -1
- package/dist/server/index.js +1 -1
- package/dist/server/lead-store-sqlite.d.ts +5 -1
- package/dist/server/lead-store-sqlite.js +37 -2
- package/dist/server/lead-webhook.d.ts +20 -1
- package/dist/server/lead-webhook.js +6 -2
- package/dist/server/webhook-outbox.d.ts +27 -1
- package/dist/server/webhook-outbox.js +33 -6
- package/dist/testing/index.d.ts +1 -0
- package/dist/testing/index.js +1 -0
- package/dist/testing/landing-contract.js +6 -0
- package/dist/testing/lead-rules.d.ts +18 -0
- package/dist/testing/lead-rules.js +50 -0
- package/dist/testing/lead-store-contract.js +20 -0
- package/package.json +3 -2
- package/template/flake.nix +1 -1
- package/template/package.json +1 -1
- package/template/src/shared/config/lead.ts +6 -3
- package/template/tests/e2e/quote-form.spec.ts +86 -1
package/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.
|
|
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 }`).
|
|
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
|
|
256
|
-
|
|
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` (
|
|
259
|
-
|
|
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
|
|
271
|
-
|
|
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}` (
|
|
278
|
-
showed — the field's role, never its
|
|
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}`
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
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`),
|
|
419
|
-
|
|
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,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
|
+
}
|
package/dist/core/accept.d.ts
CHANGED
|
@@ -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;
|
package/dist/core/accept.js
CHANGED
|
@@ -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
|
|
40
|
-
if (
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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);
|
package/dist/core/analytics.d.ts
CHANGED
|
@@ -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. */
|
package/dist/core/analytics.js
CHANGED
|
@@ -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
|
-
/**
|
|
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",
|