@evinvest/kitstart 0.8.0 → 0.10.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 +178 -25
- 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 +44 -3
- package/dist/core/accept.js +96 -15
- package/dist/core/analytics.d.ts +4 -1
- package/dist/core/analytics.js +14 -0
- package/dist/core/lead-capture-format.d.ts +6 -0
- package/dist/core/lead-capture-format.js +13 -0
- package/dist/core/lead-capture-text.d.ts +56 -2
- package/dist/core/lead-capture-text.js +63 -0
- package/dist/core/lead.d.ts +101 -11
- package/dist/core/lead.js +66 -11
- package/dist/core/phone.d.ts +20 -9
- package/dist/core/phone.js +43 -19
- package/dist/core/pricing/flow.d.ts +27 -0
- package/dist/core/pricing/flow.js +37 -0
- package/dist/core/pricing/index.d.ts +4 -0
- package/dist/core/pricing/index.js +4 -0
- package/dist/core/pricing/model.d.ts +129 -0
- package/dist/core/pricing/model.js +34 -0
- package/dist/core/pricing/price.d.ts +34 -0
- package/dist/core/pricing/price.js +105 -0
- package/dist/core/pricing/validate.d.ts +27 -0
- package/dist/core/pricing/validate.js +244 -0
- package/dist/core/site.d.ts +7 -0
- package/dist/core/site.js +11 -0
- package/dist/core/status.d.ts +13 -0
- package/dist/core/status.js +19 -2
- package/dist/index.d.ts +7 -6
- package/dist/index.js +7 -6
- package/dist/next/quote-route.d.ts +22 -2
- package/dist/next/quote-route.js +72 -10
- package/dist/react/LeadCapture.d.ts +37 -7
- package/dist/react/LeadCapture.js +34 -9
- package/dist/react/LeadCaptureChannels.d.ts +28 -3
- package/dist/react/LeadCaptureChannels.js +21 -5
- package/dist/react/LeadCaptureEstimate.d.ts +46 -0
- package/dist/react/LeadCaptureEstimate.js +69 -0
- 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/LeadCapturePriced.d.ts +19 -0
- package/dist/react/LeadCapturePriced.js +14 -0
- 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 +5 -0
- package/dist/react/use-lead-events.js +13 -2
- package/dist/react/use-lead-submit.d.ts +48 -9
- package/dist/react/use-lead-submit.js +129 -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 +3 -2
- package/dist/server/index.js +3 -2
- package/dist/server/lead-store-sqlite.d.ts +5 -1
- package/dist/server/lead-store-sqlite.js +63 -2
- package/dist/server/lead-webhook.d.ts +68 -1
- package/dist/server/lead-webhook.js +34 -2
- package/dist/server/pricing-source.d.ts +25 -0
- package/dist/server/pricing-source.js +48 -0
- 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 +28 -1
- package/package.json +3 -2
- package/template/app/[locale]/[location]/page.tsx +4 -2
- package/template/app/quote/route.ts +2 -1
- package/template/flake.nix +1 -1
- package/template/package.json +1 -1
- package/template/src/entities/content/model/en.ts +1 -1
- package/template/src/entities/content/model/fr.ts +1 -1
- package/template/src/shared/config/env.ts +4 -1
- package/template/src/shared/config/lead.ts +17 -4
- package/template/src/shared/config/pricing.ts +49 -0
- package/template/src/shared/config/site.ts +3 -0
- package/template/src/views/home/ui/PlaceHome.tsx +6 -3
- package/template/tests/bundle_budget.txt +3 -1
- package/template/tests/e2e/flows.spec.ts +106 -0
- package/template/tests/e2e/mock-panel.mjs +20 -0
- package/template/tests/e2e/playwright.config.ts +25 -9
- package/template/tests/e2e/quote-form.spec.ts +86 -1
package/README.md
CHANGED
|
@@ -13,8 +13,8 @@ stays in the brand.
|
|
|
13
13
|
|
|
14
14
|
| Import | Runtime | What |
|
|
15
15
|
|---|---|---|
|
|
16
|
-
| `@evinvest/kitstart` | anywhere (edge, client, server) | `defineSite`, places, routing (`createRouting`), the lead schema and funnel (`createAcceptLead`), antispam, JSON-LD / sitemap / robots builders, analytics events, the copy contract |
|
|
17
|
-
| `@evinvest/kitstart/server` | Node, `server-only` | `createServerEnv`, `createPlaceSource`, the lead store (`openLeadStore` by `LEADS_DB_URL`: `sqlite:` today, `postgres://` a stub that refuses at boot), `checkLeadStore`, `leadNotifier`, `leadWebhook` (signed, outboxed), `sendMail`, `clientKey` |
|
|
16
|
+
| `@evinvest/kitstart` | anywhere (edge, client, server) | `defineSite`, places, routing (`createRouting`), the lead schema and funnel (`createAcceptLead`), the price list (`PricingModel`, `priceOf`, `flowOf`), antispam, JSON-LD / sitemap / robots builders, analytics events, the copy contract |
|
|
17
|
+
| `@evinvest/kitstart/server` | Node, `server-only` | `createServerEnv`, `createPlaceSource`, `createPricingSource`, the lead store (`openLeadStore` by `LEADS_DB_URL`: `sqlite:` today, `postgres://` a stub that refuses at boot), `checkLeadStore`, `leadNotifier`, `leadWebhook` (signed, outboxed), `sendMail`, `clientKey` |
|
|
18
18
|
| `@evinvest/kitstart/proxy` | edge | `createProxy(site)`, `PROXY_MATCHER` |
|
|
19
19
|
| `@evinvest/kitstart/next` | Next server (routes, RSC) | `quoteRoute`, `sitemapRoute`, `robotsRoute`, `ogRoute`, `healthRoute`, `createPlaceLoader`, `loadLocale`, `placeMetadata` / `brandMetadata` / `statusMetadata`, `metadataBase` |
|
|
20
20
|
| `@evinvest/kitstart/next/config` | `next.config.ts`, `vitest.config.ts` | `withLanding`, `buildEnv` and the `assets/` readers |
|
|
@@ -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.10.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,23 @@ 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
|
+
| `flows`, `pricing`, `photos` | how each need is sold (`site.lead.flows`), the page's price list (`createPricingSource(site).model()`), and the `quote` needs priced from photos — see [Form variants](#form-variants-quote-estimate-fixed) |
|
|
235
|
+
| `booking` | after a priced lead, in place of "we call you to set the slot": a node or `(sent) => node` — a booking provider's widget, when the brand has one (none ships with the kit) |
|
|
234
236
|
| `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` |
|
|
237
|
+
| `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`, and for the flows `estimate`, `estimateInput`, `estimateLegend`, `estimateOption`, `price`, `priceTotal`, `breakdown`, `priceNote`, `photos`, `priced`, `pricedPrice`, `pricedNote` |
|
|
236
238
|
|
|
237
239
|
- **Taps.** A need the page knows is not asked again, and a place serving one
|
|
238
240
|
commune fills it: focus the phone, type, send — two taps. `qualify-first`
|
|
239
241
|
with no need: the tile, which moves the focus to the first empty field (the
|
|
240
242
|
phone when the commune is filled), then send.
|
|
241
243
|
`single` with no need takes the first one unless changed (two more taps).
|
|
242
|
-
Enter sends.
|
|
244
|
+
Enter sends. From the keyboard the tiles are a radio group: the arrows move
|
|
245
|
+
the choice and stay in it; Enter or Space answers and moves on.
|
|
243
246
|
- **Channels** (`resolveChannels`, shared with `CallBar`): call (`tel:`),
|
|
244
247
|
WhatsApp and SMS (the need in the message; SMS only to a mobile), "call me
|
|
245
248
|
back" and the form. Open, the call leads; closed, the callback (open) and
|
|
@@ -252,11 +255,12 @@ it is the plain POST to `/quote` it always was.
|
|
|
252
255
|
- **Callback.** A `<details>` with its own small form: the phone and a native,
|
|
253
256
|
required consent, posted to `/quote` with `channel=callback`. The consent's
|
|
254
257
|
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
|
-
|
|
258
|
+
language); the server refuses a callback without it — back to the callback,
|
|
259
|
+
open, naming the consent, whatever the brand's rule — and stores it with the lead:
|
|
257
260
|
`consent_text` word for word, `consent_at` when the server accepted it
|
|
258
|
-
(`Lead.consent`). Then the lead is held to `lead.validateCallback` (
|
|
259
|
-
|
|
261
|
+
(`Lead.consent`). Then the lead is held to `lead.validateCallback` (the
|
|
262
|
+
phone rule, by default), not to the form's rule. Its phone is the form's
|
|
263
|
+
field: the same hint, the same block. The consent goes into
|
|
260
264
|
no event; a webhook body built by the brand should leave `lead.consent` out.
|
|
261
265
|
- **Locality.** A postcode field (`autoComplete="postal-code"`). A storefront
|
|
262
266
|
with no named zone fills in its own postcode; a named zone fills in its one
|
|
@@ -267,22 +271,122 @@ it is the plain POST to `/quote` it always was.
|
|
|
267
271
|
`inline-flex`) with `leading-6` after the brand's `channel` /
|
|
268
272
|
`callbackSummary` classes unless they set a line height of their own, so a
|
|
269
273
|
brand's type size cannot leave it on a half pixel.
|
|
270
|
-
- **Phone.** `type="tel"`, required, never masked
|
|
271
|
-
|
|
274
|
+
- **Phone.** `type="tel"`, required, never masked, held to one rule on both
|
|
275
|
+
sides (`phoneProblem`, `validateLead`): a French number is ten digits from
|
|
276
|
+
`0[1-9]` (or `+33` and nine), any other a `+` and 8 to 15 digits; full-width
|
|
277
|
+
digits read as digits; a run of one digit is no number. One that fails gets
|
|
278
|
+
a hint when the field is left (`aria-live`, read out) and blocks the submit
|
|
279
|
+
in the page's words — the browser's bubble would speak its own language. With
|
|
272
280
|
`lead.mobileFormat: "e164"` a number it can read is stored as
|
|
273
281
|
`+33612345678` (`normalizePhone`); the default keeps it as typed, which is
|
|
274
282
|
what a brand's tests and tooling look rows up by.
|
|
283
|
+
- **Refusals are never silent.** Every required field blocks with the page's
|
|
284
|
+
words and is marked `aria-invalid` once refused. What the server still
|
|
285
|
+
refuses (a brand's own rule, a stale page) comes back to the card: the
|
|
286
|
+
script's post gets a `422 { field }` and the form, as typed, shows the error
|
|
287
|
+
at that field (`role="alert"`, focused); the plain post gets a 303 to
|
|
288
|
+
`?lead_error=<field>&need=<need>#<id>` (`#<id>-callback` for a callback,
|
|
289
|
+
opened), which the card reads once the script runs and then drops from the
|
|
290
|
+
URL. Only slugs ride in that URL — never a phone or a name. A field the card
|
|
291
|
+
does not draw (a brand's `extras`) shows its error above the submit and is
|
|
292
|
+
marked itself.
|
|
293
|
+
- **Sending.** The script's post (asking `/quote` for JSON) carries a
|
|
294
|
+
`submission_id` it mints once per lead — a new one as soon as anything
|
|
295
|
+
posted changes, and never one the browser restored; the store keeps one row
|
|
296
|
+
per id, so a resend after a lost answer is answered as the first was and
|
|
297
|
+
never makes a second lead. While it runs the form's submit is disabled, `aria-busy` and
|
|
298
|
+
says `sending`. No answer — no network, or none in 15 s
|
|
299
|
+
(`SUBMIT_TIMEOUT_MS`) — is said in place with a retry that sends the same
|
|
300
|
+
lead; the page is never left for the browser's error page.
|
|
275
301
|
- **Events** (through `AnalyticsBoundary`'s sink; none outside one):
|
|
276
302
|
`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
|
|
303
|
+
`lead_form_field_error {field, blocking}` (`blocking: true` the browser
|
|
304
|
+
refused it, `false` the phone hint showed — the field's role, never its
|
|
305
|
+
value), `lead_form_submit_error {reason, channel}` (`network` /
|
|
306
|
+
`timeout`), `lead_form_step {step}`
|
|
279
307
|
(`contact` / `need`), each with `form_id`, `layout`, `experiment`,
|
|
280
308
|
`variant`; `contact_intent_click {channel}` for `phone`, `whatsapp`, `sms`,
|
|
281
309
|
`callback`, with the experiment; and on the server `lead_form_submit
|
|
282
|
-
{form_id, channel}`
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
310
|
+
{form_id, channel}` and `lead_form_reject {form_id, channel, field,
|
|
311
|
+
reason}` with the posted experiment.
|
|
312
|
+
- **Weight.** 8.9 KB gz of first-load JS on the template's place page
|
|
313
|
+
(157,287 → 166,361 B against its 158,000 B target: +5.3 %, a warning
|
|
314
|
+
within the 20 % tolerance); the refusals and the shared phone rule are
|
|
315
|
+
2.0 KB of it, sending (the id, the busy state, the retry) 0.6 KB.
|
|
316
|
+
|
|
317
|
+
### Form variants: quote, estimate, fixed
|
|
318
|
+
|
|
319
|
+
A need is sold one of three ways, chosen per need — one brand can mix them:
|
|
320
|
+
|
|
321
|
+
- `quote` — the price is uncertain; the lead asks for one. The form as it was.
|
|
322
|
+
- `estimate` — the price follows from a few enum answers (zone, bedrooms,
|
|
323
|
+
surface band, frequency), shown live as the visitor taps; then the slot.
|
|
324
|
+
- `fixed` — one price for a well-defined job, shown as is; then the slot.
|
|
325
|
+
|
|
326
|
+
```ts
|
|
327
|
+
// shared/config/lead.ts — the form and the route read the same map
|
|
328
|
+
export const LEAD: LeadSchema<Subject> = { …, flows: { standard: "estimate", deep: "quote" } };
|
|
329
|
+
// shared/config/site.ts — the baked price list
|
|
330
|
+
export const site = defineSite({ …, lead: LEAD, pricing: PRICING });
|
|
331
|
+
// shared/config/env.ts — the live one, from the place source's base URL
|
|
332
|
+
export const pricing = createPricingSource(site, { baseUrl: () => serverEnv().locationsApiUrl });
|
|
333
|
+
// app/quote/route.ts
|
|
334
|
+
export const POST = quoteRoute(site, { env: serverEnv, notifier, pricing, unavailable });
|
|
335
|
+
// the page: <LeadCapture … flows={LEAD.flows} pricing={await pricing.model()} photos={["deep"]} />
|
|
336
|
+
```
|
|
337
|
+
|
|
338
|
+
- **The price list** (`PricingModel`) is JSON: `format` 1, `currency` `EUR`
|
|
339
|
+
(TTC), `validFrom`, `roundToCents`, `minimumCents`, `inputs` (`add` in
|
|
340
|
+
cents, `multiply` and `discount` in basis points, labels per locale) and
|
|
341
|
+
`needs` (`{ kind: "estimate", baseCents, inputs }` or `{ kind: "fixed",
|
|
342
|
+
cents }`). `priceOf(model, need, answers) → { cents, breakdown } | null`
|
|
343
|
+
is integer cents throughout, rounding half up at every product, then to
|
|
344
|
+
`roundToCents`, then the minimum. The rules and the shared fixtures the
|
|
345
|
+
panel holds its own port to are in
|
|
346
|
+
[`test/fixtures/pricing/`](./test/fixtures/pricing/README.md).
|
|
347
|
+
`defineSite` refuses a baked model that does not validate or lacks a label
|
|
348
|
+
in one of the site's locales.
|
|
349
|
+
- **The live list** (`createPricingSource`): `GET <base>/pricing`, TTL
|
|
350
|
+
600 s, 3 s timeout; an unreachable source, a non-200, a body that is not
|
|
351
|
+
JSON or a model that does not validate (for every locale of the site) all
|
|
352
|
+
serve the baked model, logged — whole or not at all, never half a model.
|
|
353
|
+
`{}` is "nothing set": the baked model, quietly.
|
|
354
|
+
- **A need runs its flow only when the list prices it so** (`flowOf`): an
|
|
355
|
+
`estimate` the model cannot price — no model, a remote model without the
|
|
356
|
+
need — is a `quote`. So a flow the brand has not switched on is never run
|
|
357
|
+
by a posted field.
|
|
358
|
+
- **The form.** An estimate's questions are a tile per answer (touch-sized
|
|
359
|
+
radios posted as `estimate_<input>`), required once the script runs; the
|
|
360
|
+
price box (`aria-live`) shows the total and the lines that made it, or
|
|
361
|
+
what is missing — never "from". A fixed need shows its price. Either
|
|
362
|
+
submits as `bookSubmit` ("Réserver"). Without a script the answers still
|
|
363
|
+
post and the server prices them; the live price needs the script.
|
|
364
|
+
- **The server prices the lead itself.** `/quote` reads the posted answers
|
|
365
|
+
for the need's inputs only and runs the same `priceOf`; a posted amount is
|
|
366
|
+
never read. The lead is stored with `flow`, `price.cents`,
|
|
367
|
+
`price.validFrom` and an estimate's `price.inputs` (schema 7: `flow`,
|
|
368
|
+
`quoted_cents`, `pricing_valid_from`, `estimate_inputs`). An estimate
|
|
369
|
+
whose answers do not price (a stale page, a forged answer) is kept as a
|
|
370
|
+
`quote` — never refused. A callback has no flow.
|
|
371
|
+
- **After a priced lead** the card stays: the script's answer carries the
|
|
372
|
+
server's `cents` and the lead's reference (`lead`, `leadRef`:
|
|
373
|
+
`lead-<row>-<8 hex>`), and the card confirms the price (`sentPrice`) and
|
|
374
|
+
promises a call to set the slot (`slotCallback`) — or shows the brand's
|
|
375
|
+
`booking`. The lead is stored first, so an abandoned booking is still a
|
|
376
|
+
lead to call. A `quote` need, and any callback, behave as before.
|
|
377
|
+
- **Photos.** A `quote` need listed in `photos` offers "Envoyez des photos"
|
|
378
|
+
— a WhatsApp link with the need in the message — when the place has
|
|
379
|
+
WhatsApp; the callback stays where it is.
|
|
380
|
+
- **Words.** `LEAD_CAPTURE_TEXT` carries them (`priceTitle`, `pricePending`,
|
|
381
|
+
`priceNote`, `priceBase`, `priceRounding`, `priceMinimum`, `bookSubmit`,
|
|
382
|
+
`sentPrice`, `slotCallback`, `photos*`); optional in `LeadCaptureText`, so
|
|
383
|
+
a brand's own text from before them falls back to the kit's in the page's
|
|
384
|
+
language (`flowTextOf`).
|
|
385
|
+
- **Events.** `lead_estimate_shown {need, cents_bucket}` once per need and
|
|
386
|
+
price band (`centsBucket`: `"7500-10000"`), never the price.
|
|
387
|
+
- **Weight.** About 3.9 KB gz of first-load JS on the template's place page
|
|
388
|
+
(166,459 → 170,375 B against its 158,000 B target: +7.8 %, within the 20 %
|
|
389
|
+
tolerance).
|
|
286
390
|
|
|
287
391
|
Tailwind v4 does not scan `node_modules`; the brand's `globals.css` names the
|
|
288
392
|
package:
|
|
@@ -311,9 +415,9 @@ export const proxy = createProxy(site);
|
|
|
311
415
|
// included — let past, it would be a cached bare 404 without the phone.
|
|
312
416
|
export const config = { matcher: ["/((?!_next/).*)"] };
|
|
313
417
|
|
|
314
|
-
// app/quote/route.ts
|
|
418
|
+
// app/quote/route.ts — `anchor`: the card's id, when it is not `quote`
|
|
315
419
|
export const dynamic = "force-dynamic";
|
|
316
|
-
export const POST = quoteRoute(site, { env: serverEnv, notifier, unavailable });
|
|
420
|
+
export const POST = quoteRoute(site, { env: serverEnv, notifier, unavailable, anchor: "devis" });
|
|
317
421
|
|
|
318
422
|
// app/sitemap.ts — reads the Host header, so it is dynamic; pages are not
|
|
319
423
|
export const dynamic = "force-dynamic";
|
|
@@ -324,6 +428,24 @@ export function generateStaticParams() { return []; }
|
|
|
324
428
|
export const revalidate = 600;
|
|
325
429
|
```
|
|
326
430
|
|
|
431
|
+
**The lead rule.** `site.lead.validate` defaults to `validateLead`: the
|
|
432
|
+
number the form itself blocks on. A brand's own rule composes with it and
|
|
433
|
+
names the field it refuses, which the form shows the error at:
|
|
434
|
+
|
|
435
|
+
```ts
|
|
436
|
+
validate: lead => validateLead(lead) ?? (lead.locality === "" ? { field: "locality", why: "a postcode" } : null),
|
|
437
|
+
```
|
|
438
|
+
|
|
439
|
+
A bare string still works — a refusal of the whole `form`. The landing
|
|
440
|
+
contract (`describeLandingContract`) holds the rule to the form's phone check
|
|
441
|
+
over `PHONE_MATRIX` (`leadRuleDisagreements`), so the server never refuses a
|
|
442
|
+
number the form let through.
|
|
443
|
+
|
|
444
|
+
**The thanks page.** A callback lands on `/thanks?channel=callback`; read it
|
|
445
|
+
with `thanksChannel(searchParams)` to promise a call instead of a quote, and
|
|
446
|
+
pass `{ thanks: true, channel }` to `statusTarget` so the language switch
|
|
447
|
+
keeps it.
|
|
448
|
+
|
|
327
449
|
`createPlaceLoader(site, places)(params)` returns the `PlaceView` for a page,
|
|
328
450
|
reading the link mode from the `location` param (`_royat` = host mode).
|
|
329
451
|
|
|
@@ -352,6 +474,10 @@ chooses per visitor answer `Cache-Control: private, no-store`.
|
|
|
352
474
|
adapter passes `describeLeadStoreContract` and migrates itself to
|
|
353
475
|
`LEAD_SCHEMA_VERSION` on open. SQLite keeps the Rust server's columns
|
|
354
476
|
(`job`, `zip`, `mobile`) and recognises every earlier table in place.
|
|
477
|
+
Schema 6 adds `submission_id` under a unique index (where present): one
|
|
478
|
+
row per script submission, read back by `findSubmission`. Schema 7 adds
|
|
479
|
+
the flow and the price a lead was taken at (`flow`, `quoted_cents`,
|
|
480
|
+
`pricing_valid_from`, `estimate_inputs`), all nullable.
|
|
355
481
|
- **Mail is checked at boot.** With `SMTP_URL` set, `leadNotifier` throws when
|
|
356
482
|
it is built if the URL is malformed (the error never repeats it), there is
|
|
357
483
|
no sender (`LEAD_NOTIFY_FROM`, or `leads@<domain>`) or no recipient
|
|
@@ -408,15 +534,42 @@ export const POST = quoteRoute(site, { env: serverEnv, notifier, webhook, unavai
|
|
|
408
534
|
lead and `ctx` (`leadId`, `brandId`, `locale`, `formId`, `at`, and a fresh
|
|
409
535
|
`idempotencyKey` for the receiver to deduplicate by), and written to the
|
|
410
536
|
`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
|
|
537
|
+
`after`. A lead the notifier skips (honeypot, rate limit) is not queued —
|
|
538
|
+
unless `panelSuspect` (below) queues the rate-limited one.
|
|
539
|
+
- **Suspect leads: `panelSuspect`, off.** `suspectOf(lead)` is
|
|
540
|
+
`rate_limited`, `too_fast` or nothing — never `honeypot`, whose lead goes
|
|
541
|
+
nowhere. With `leadWebhook(…, { panelSuspect: true })` a rate-limited lead
|
|
542
|
+
is queued too (still never mailed) and `ctx.suspect` names why a lead is
|
|
543
|
+
suspect, for the body's `suspect` property. Off by default, and to stay off
|
|
544
|
+
until the panel's `lead.created` accepts the property: it refuses an
|
|
545
|
+
unknown one, and the outbox would park the lead. Off, nothing changes.
|
|
546
|
+
- **The sale: `panelFlow`, off.** With `leadWebhook(…, { panelFlow: true })`
|
|
547
|
+
`ctx.flow` says how the need was sold (`panelFlowOf(lead)`), and
|
|
548
|
+
`panelFlowProperties(ctx.flow)` writes it as `lead.created`'s properties:
|
|
549
|
+
`flow`; `quoted_cents` and `pricing_valid_from` together, for `estimate`
|
|
550
|
+
and `fixed` only; `estimate_inputs` (input → answer, slugs, at most 12)
|
|
551
|
+
for an `estimate` that asked anything. Off by default, and to stay off
|
|
552
|
+
until the panel accepts them — it refuses unknown properties, and the
|
|
553
|
+
outbox would park the lead. `ctx.leadRef` is the lead's reference the page
|
|
554
|
+
was answered with, whatever the switch.
|
|
555
|
+
- **The rate limit** is `LEAD_RATE_LIMIT` (`<count>/<seconds>`): 5 per 10
|
|
556
|
+
minutes per address in production, 100 outside it, where every request of
|
|
557
|
+
a local stack shares one address. Outside production a lead held back as
|
|
558
|
+
rate-limited or honeypot is logged loudly.
|
|
412
559
|
- **Signature.** Three headers named by `signing.headers`: the key id, the
|
|
413
560
|
timestamp (unix seconds) and `hex(HMAC-SHA256(secret, prefix + timestamp +
|
|
414
561
|
"." + body))`, signed afresh on each attempt. Prefix and names are required.
|
|
415
562
|
- **At-least-once.** 2xx is delivered. A `207` is delivered too, and an item
|
|
416
563
|
it marks `rejected` is final — logged by index, not retried. 5xx, 408, 429
|
|
417
564
|
and network errors retry, doubling from 5 s up to an hour (with jitter, and
|
|
418
|
-
at least `Retry-After`),
|
|
419
|
-
|
|
565
|
+
at least `Retry-After`), for 48 hours from queueing (`horizonMs`;
|
|
566
|
+
`maxAttempts` is an optional cap). 401, 403 and 404 retry too, logged as
|
|
567
|
+
errors naming the setting to check — a rotated key, a moved receiver. Any
|
|
568
|
+
other status, 3xx included, is final. A final row stays as `dead` with its
|
|
569
|
+
`last_error`, `onDead({ id, ref, attempts, error })` is told (an alert), and
|
|
570
|
+
`requeueDead()` — or `kitstart-outbox requeue` on the leads file (the rows
|
|
571
|
+
of `--target`, else `LEAD_WEBHOOK_URL`), with `kitstart-outbox status` to
|
|
572
|
+
count rows — puts the dead rows back, due now, their horizon fresh.
|
|
420
573
|
- **PII stays in the body.** Logs name the row, the attempt and the status;
|
|
421
574
|
the receiver's own words (which may echo a field) go to the row's
|
|
422
575
|
`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,6 @@
|
|
|
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
|
+
import type { PricingModel } from "./pricing/model.js";
|
|
3
4
|
import type { Site } from "./site.js";
|
|
4
5
|
/** Hidden fields the form carries besides what the visitor types. */
|
|
5
6
|
export declare const LOCATION_FIELD = "location";
|
|
@@ -13,15 +14,27 @@ export interface SubmitTags {
|
|
|
13
14
|
experiment?: string;
|
|
14
15
|
variant?: string;
|
|
15
16
|
}
|
|
16
|
-
export type Outcome<L extends string> =
|
|
17
|
+
export type Outcome<L extends string> =
|
|
18
|
+
/**
|
|
19
|
+
* `ref`: the lead's public reference (`leadRef`). `duplicate`: a resend of
|
|
20
|
+
* a submission already stored — answered as the first was, nothing sent again.
|
|
21
|
+
*/
|
|
22
|
+
{
|
|
17
23
|
kind: "stored";
|
|
18
24
|
id: number;
|
|
25
|
+
ref: string;
|
|
19
26
|
lead: Lead;
|
|
20
27
|
locale: L;
|
|
21
28
|
formId: string;
|
|
22
|
-
|
|
29
|
+
duplicate?: true;
|
|
30
|
+
}
|
|
31
|
+
/** `field` names what to fix (`phone`, `consent`, … or `form`); `why` is for the log. */
|
|
32
|
+
| {
|
|
23
33
|
kind: "invalid";
|
|
24
34
|
why: string;
|
|
35
|
+
field: string;
|
|
36
|
+
channel: LeadChannel;
|
|
37
|
+
formId: string;
|
|
25
38
|
locale: L;
|
|
26
39
|
slug: string | null;
|
|
27
40
|
} | {
|
|
@@ -32,6 +45,11 @@ export type Outcome<L extends string> = {
|
|
|
32
45
|
export interface AcceptDeps {
|
|
33
46
|
/** The commit point: resolves once the lead is durable. */
|
|
34
47
|
insert: (lead: Lead) => Promise<number>;
|
|
48
|
+
/** The lead a submission id was stored as (`LeadStore.findSubmission`); absent → no deduplication. */
|
|
49
|
+
findSubmission?: (submissionId: string) => Promise<{
|
|
50
|
+
id: number;
|
|
51
|
+
lead: Lead;
|
|
52
|
+
} | null>;
|
|
35
53
|
/** Runs after the response is sent; see the route handler. */
|
|
36
54
|
defer: (task: () => Promise<void> | void) => void;
|
|
37
55
|
notify: (lead: Lead, id: number) => Promise<void>;
|
|
@@ -43,12 +61,35 @@ export interface AcceptDeps {
|
|
|
43
61
|
enqueue?: (lead: Lead, id: number, meta: {
|
|
44
62
|
locale: string;
|
|
45
63
|
formId: string;
|
|
64
|
+
leadRef: string;
|
|
46
65
|
}) => void;
|
|
66
|
+
/**
|
|
67
|
+
* The price list to price an `estimate` or `fixed` lead with
|
|
68
|
+
* (`createPricingSource(…).model`); absent → `site.pricing`. A failure is
|
|
69
|
+
* no model: the lead is kept as a `quote`, never refused.
|
|
70
|
+
*/
|
|
71
|
+
pricing?: () => Promise<PricingModel | null>;
|
|
72
|
+
/**
|
|
73
|
+
* Queue a rate-limited lead too, for the panel to show as suspect
|
|
74
|
+
* (`LeadWebhookOptions.panelSuspect`). Off: it waits in the table, as before.
|
|
75
|
+
* It is never mailed; a honeypot lead is never sent anywhere.
|
|
76
|
+
*/
|
|
77
|
+
sendSuspect?: boolean;
|
|
47
78
|
capture: (lead: Lead, formId: string, tags: SubmitTags) => void;
|
|
48
79
|
limiter: RateLimiter;
|
|
49
80
|
now: number;
|
|
50
81
|
log: Pick<Console, "warn" | "error">;
|
|
51
82
|
}
|
|
83
|
+
/**
|
|
84
|
+
* The lead's public reference, `lead-<row>-<8 hex>`: the one the script's
|
|
85
|
+
* answer gives the page (`LeadSent.lead`) and the webhook offers the panel
|
|
86
|
+
* (`LeadWebhookContext.leadRef`), so whatever the page does next — a booking,
|
|
87
|
+
* once there is a provider — can name its lead. The
|
|
88
|
+
* row id for a person matching it to the mail; the tag because row ids start
|
|
89
|
+
* over if the leads file is ever recreated. Seeded by the submission id when
|
|
90
|
+
* there is one, so a resend is answered with the same reference.
|
|
91
|
+
*/
|
|
92
|
+
export declare function leadRef(id: number, seed: string): string;
|
|
52
93
|
/**
|
|
53
94
|
* The funnel's commit point, independent of HTTP. Order is the invariant:
|
|
54
95
|
* validate → screen → **insert** → (deferred) notify and capture.
|
package/dist/core/accept.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
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
|
+
import { flowOf, readEstimateInputs } from "./pricing/flow.js";
|
|
5
|
+
import { priceOf } from "./pricing/price.js";
|
|
4
6
|
/** Hidden fields the form carries besides what the visitor types. */
|
|
5
7
|
export const LOCATION_FIELD = "location";
|
|
6
8
|
export const LOCALE_FIELD = "locale";
|
|
@@ -13,6 +15,55 @@ function field(form, name) {
|
|
|
13
15
|
const value = form.get(name);
|
|
14
16
|
return typeof value === "string" ? value.trim().slice(0, MAX_FIELD) : null;
|
|
15
17
|
}
|
|
18
|
+
/** FNV-1a, 32 bits, as 8 hex: a tag, not a secret — the core has no `node:crypto`. */
|
|
19
|
+
function tag(seed) {
|
|
20
|
+
let h = 0x811c9dc5;
|
|
21
|
+
for (let i = 0; i < seed.length; i++)
|
|
22
|
+
h = Math.imul(h ^ seed.charCodeAt(i), 0x01000193);
|
|
23
|
+
return (h >>> 0).toString(16).padStart(8, "0");
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* The lead's public reference, `lead-<row>-<8 hex>`: the one the script's
|
|
27
|
+
* answer gives the page (`LeadSent.lead`) and the webhook offers the panel
|
|
28
|
+
* (`LeadWebhookContext.leadRef`), so whatever the page does next — a booking,
|
|
29
|
+
* once there is a provider — can name its lead. The
|
|
30
|
+
* row id for a person matching it to the mail; the tag because row ids start
|
|
31
|
+
* over if the leads file is ever recreated. Seeded by the submission id when
|
|
32
|
+
* there is one, so a resend is answered with the same reference.
|
|
33
|
+
*/
|
|
34
|
+
export function leadRef(id, seed) {
|
|
35
|
+
return `lead-${id}-${tag(seed)}`;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* How the lead's need is sold and at what price — the server's own number,
|
|
39
|
+
* from the posted answers through the same `priceOf` the form showed; a
|
|
40
|
+
* posted amount is never read. A callback asks for a call, not a price: no
|
|
41
|
+
* flow. An estimate whose answers do not price (a stale form, a tampered
|
|
42
|
+
* field) is kept as a `quote`: the customer is still a customer.
|
|
43
|
+
*/
|
|
44
|
+
async function priced(site, form, lead, deps) {
|
|
45
|
+
const flows = site.lead.flows;
|
|
46
|
+
if (!flows || channelOf(lead) === "callback")
|
|
47
|
+
return {};
|
|
48
|
+
let model;
|
|
49
|
+
try {
|
|
50
|
+
model = deps.pricing ? await deps.pricing() : (site.pricing ?? null);
|
|
51
|
+
}
|
|
52
|
+
catch (error) {
|
|
53
|
+
deps.log.error("quote: the price list could not be read; the lead is kept as a quote", error);
|
|
54
|
+
model = null;
|
|
55
|
+
}
|
|
56
|
+
const flow = flowOf(flows, model, lead.subject);
|
|
57
|
+
if (flow === "quote" || !model)
|
|
58
|
+
return { flow: "quote" };
|
|
59
|
+
const inputs = flow === "estimate" ? readEstimateInputs(model, lead.subject, name => field(form, name)) : {};
|
|
60
|
+
const price = priceOf(model, lead.subject, inputs);
|
|
61
|
+
if (!price) {
|
|
62
|
+
deps.log.warn(`quote: an ${flow} for ${lead.subject} did not price from what was posted; kept as a quote`);
|
|
63
|
+
return { flow: "quote" };
|
|
64
|
+
}
|
|
65
|
+
return { flow, price: { cents: price.cents, validFrom: model.validFrom, ...(flow === "estimate" ? { inputs } : {}) } };
|
|
66
|
+
}
|
|
16
67
|
/**
|
|
17
68
|
* The funnel's commit point, independent of HTTP. Order is the invariant:
|
|
18
69
|
* validate → screen → **insert** → (deferred) notify and capture.
|
|
@@ -30,17 +81,38 @@ function field(form, name) {
|
|
|
30
81
|
export function createAcceptLead(site) {
|
|
31
82
|
return (form, clientKey, deps) => accept(site, form, clientKey, deps);
|
|
32
83
|
}
|
|
84
|
+
/** The stored lead for a submission id; a failed lookup is no lookup — the insert's index still holds. */
|
|
85
|
+
async function stored(submissionId, deps) {
|
|
86
|
+
if (!submissionId || !deps.findSubmission)
|
|
87
|
+
return null;
|
|
88
|
+
try {
|
|
89
|
+
return await deps.findSubmission(submissionId);
|
|
90
|
+
}
|
|
91
|
+
catch (error) {
|
|
92
|
+
deps.log.error("quote: the lookup of a resent submission failed", error);
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
33
96
|
async function accept(site, form, clientKey, deps) {
|
|
34
97
|
const rawSlug = field(form, LOCATION_FIELD);
|
|
35
98
|
const slug = rawSlug && site.placeSlugs.includes(rawSlug) ? rawSlug : null;
|
|
36
99
|
const rawLocale = field(form, LOCALE_FIELD);
|
|
37
100
|
const locale = site.i18n.isLocale(rawLocale) ? rawLocale : site.i18n.defaultLocale;
|
|
101
|
+
// It reaches analytics as a property; anything but a short slug is dropped.
|
|
102
|
+
const posted = field(form, FORM_ID_FIELD);
|
|
103
|
+
const formId = posted !== null && FORM_ID.test(posted) ? posted : "quote";
|
|
38
104
|
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 };
|
|
105
|
+
const rejection = validateCandidate(site.lead, candidate);
|
|
106
|
+
if (rejection) {
|
|
107
|
+
deps.log.warn(`quote: rejected a submission for ${slug ?? "no point"} at ${rejection.field}: missing ${rejection.why}`);
|
|
108
|
+
return { kind: "invalid", why: rejection.why, field: rejection.field, channel: channelOf(candidate), formId, locale, slug };
|
|
43
109
|
}
|
|
110
|
+
// A resend of a stored submission (its answer was lost) is that lead: no
|
|
111
|
+
// second row, no second mail, the limit not spent again.
|
|
112
|
+
const prior = await stored(candidate.submissionId, deps);
|
|
113
|
+
if (prior)
|
|
114
|
+
return { kind: "stored", id: prior.id, ref: leadRef(prior.id, candidate.submissionId ?? ""), lead: prior.lead, locale, formId, duplicate: true };
|
|
115
|
+
const sale = await priced(site, form, candidate, deps);
|
|
44
116
|
// After validation, so a typo corrected and resent does not spend the limit.
|
|
45
117
|
const verdict = screen({
|
|
46
118
|
honeypot: [HONEYPOT_FIELD, ...LEGACY_HONEYPOT_FIELDS].map(name => field(form, name)).find(v => v) ?? null,
|
|
@@ -52,6 +124,7 @@ async function accept(site, form, clientKey, deps) {
|
|
|
52
124
|
const { consentText, ...rest } = candidate;
|
|
53
125
|
const lead = {
|
|
54
126
|
...rest,
|
|
127
|
+
...sale,
|
|
55
128
|
spamVerdict: verdict === "ok" ? null : verdict,
|
|
56
129
|
// Stamped by the server: the moment it accepted the consent with the lead.
|
|
57
130
|
...(consentText ? { consent: { text: consentText, at: new Date(deps.now).toISOString() } } : {}),
|
|
@@ -61,28 +134,36 @@ async function accept(site, form, clientKey, deps) {
|
|
|
61
134
|
id = await deps.insert(lead);
|
|
62
135
|
}
|
|
63
136
|
catch (error) {
|
|
137
|
+
// Two posts of one submission raced past the lookup; the unique index let one in.
|
|
138
|
+
const winner = await stored(candidate.submissionId, deps);
|
|
139
|
+
if (winner)
|
|
140
|
+
return { kind: "stored", id: winner.id, ref: leadRef(winner.id, candidate.submissionId ?? ""), lead: winner.lead, locale, formId, duplicate: true };
|
|
64
141
|
deps.log.error(`quote: the lead store rejected a submission for ${slug ?? "no point"}`, error);
|
|
65
142
|
return { kind: "failed", locale, slug };
|
|
66
143
|
}
|
|
67
|
-
|
|
68
|
-
const posted = field(form, FORM_ID_FIELD);
|
|
69
|
-
const formId = posted !== null && FORM_ID.test(posted) ? posted : "quote";
|
|
144
|
+
const ref = leadRef(id, candidate.submissionId ?? `${id}:${deps.now}`);
|
|
70
145
|
// The render stamp comes from the client, so it only marks: a `too-fast`
|
|
71
146
|
// lead (or one from a page cached before the stamp existed) is still sent
|
|
72
147
|
// on, flagged. The honeypot and the rate limit are the server's own
|
|
73
148
|
// evidence, and those leads wait in the table for a reviewer.
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
}
|
|
78
|
-
if (deps.enqueue) {
|
|
149
|
+
const queue = () => {
|
|
150
|
+
if (!deps.enqueue)
|
|
151
|
+
return;
|
|
79
152
|
try {
|
|
80
|
-
deps.enqueue(lead, id, { locale, formId });
|
|
153
|
+
deps.enqueue(lead, id, { locale, formId, leadRef: ref });
|
|
81
154
|
}
|
|
82
155
|
catch (error) {
|
|
83
156
|
deps.log.error(`quote: lead ${id} is stored but could not be queued for its webhook`, error);
|
|
84
157
|
}
|
|
158
|
+
};
|
|
159
|
+
if (lead.spamVerdict && lead.spamVerdict !== "too-fast") {
|
|
160
|
+
const marked = lead.spamVerdict === "rate-limited" && deps.sendSuspect === true;
|
|
161
|
+
deps.log.warn(`quote: lead ${id} stored as suspected spam (${lead.spamVerdict}); not notified${marked ? ", queued marked suspect" : ""}`);
|
|
162
|
+
if (marked)
|
|
163
|
+
queue();
|
|
164
|
+
return { kind: "stored", id, ref, lead, locale, formId };
|
|
85
165
|
}
|
|
166
|
+
queue();
|
|
86
167
|
deps.defer(async () => {
|
|
87
168
|
try {
|
|
88
169
|
await deps.notify(lead, id);
|
|
@@ -93,5 +174,5 @@ async function accept(site, form, clientKey, deps) {
|
|
|
93
174
|
});
|
|
94
175
|
const tags = experimentProps(field(form, EXPERIMENT_FIELD), field(form, VARIANT_FIELD));
|
|
95
176
|
deps.defer(() => deps.capture(lead, formId, tags));
|
|
96
|
-
return { kind: "stored", id, lead, locale, formId };
|
|
177
|
+
return { kind: "stored", id, ref, lead, locale, formId };
|
|
97
178
|
}
|