@evinvest/kitstart 0.9.0 → 0.11.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 +237 -12
- package/dist/core/accept.d.ts +35 -2
- package/dist/core/accept.js +74 -5
- package/dist/core/analytics.d.ts +4 -1
- package/dist/core/analytics.js +13 -0
- package/dist/core/booking/index.d.ts +3 -0
- package/dist/core/booking/index.js +3 -0
- package/dist/core/booking/model.d.ts +106 -0
- package/dist/core/booking/model.js +66 -0
- package/dist/core/booking/url.d.ts +17 -0
- package/dist/core/booking/url.js +53 -0
- package/dist/core/booking/validate.d.ts +27 -0
- package/dist/core/booking/validate.js +219 -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 +57 -2
- package/dist/core/lead-capture-text.js +75 -2
- package/dist/core/lead.d.ts +65 -3
- package/dist/core/lead.js +46 -2
- package/dist/core/phone.d.ts +3 -1
- package/dist/core/phone.js +28 -2
- package/dist/core/place/live.d.ts +6 -2
- package/dist/core/place/live.js +21 -2
- package/dist/core/place/types.d.ts +6 -0
- package/dist/core/place/types.js +0 -13
- 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/routing.d.ts +3 -2
- package/dist/core/routing.js +4 -3
- package/dist/core/site.d.ts +15 -0
- package/dist/core/site.js +26 -0
- package/dist/index.d.ts +6 -4
- package/dist/index.js +6 -4
- package/dist/next/booking-route.d.ts +31 -0
- package/dist/next/booking-route.js +97 -0
- package/dist/next/confirm-route.d.ts +39 -0
- package/dist/next/confirm-route.js +118 -0
- package/dist/next/index.d.ts +2 -0
- package/dist/next/index.js +2 -0
- package/dist/next/quote-route.d.ts +8 -1
- package/dist/next/quote-route.js +17 -6
- package/dist/react/LeadBooking.d.ts +35 -0
- package/dist/react/LeadBooking.js +47 -0
- package/dist/react/LeadBookingManual.d.ts +17 -0
- package/dist/react/LeadBookingManual.js +50 -0
- package/dist/react/LeadCapture.d.ts +54 -3
- package/dist/react/LeadCapture.js +37 -10
- package/dist/react/LeadCaptureChannels.d.ts +14 -2
- package/dist/react/LeadCaptureChannels.js +13 -0
- package/dist/react/LeadCaptureEstimate.d.ts +46 -0
- package/dist/react/LeadCaptureEstimate.js +69 -0
- package/dist/react/LeadCaptureFields.js +60 -21
- package/dist/react/LeadCapturePriced.d.ts +18 -0
- package/dist/react/LeadCapturePriced.js +14 -0
- package/dist/react/booking-adapters.d.ts +58 -0
- package/dist/react/booking-adapters.js +104 -0
- package/dist/react/index.d.ts +5 -1
- package/dist/react/index.js +4 -1
- package/dist/react/use-booking.d.ts +20 -0
- package/dist/react/use-booking.js +32 -0
- package/dist/react/use-lead-error.d.ts +3 -7
- package/dist/react/use-lead-error.js +9 -6
- package/dist/react/use-lead-events.d.ts +2 -0
- package/dist/react/use-lead-events.js +10 -0
- package/dist/react/use-lead-submit.d.ts +14 -4
- package/dist/react/use-lead-submit.js +19 -9
- package/dist/react/use-repriced.d.ts +18 -0
- package/dist/react/use-repriced.js +27 -0
- package/dist/server/index.d.ts +3 -2
- package/dist/server/index.js +2 -1
- package/dist/server/lead-store-sqlite.js +31 -5
- package/dist/server/lead-webhook.d.ts +94 -0
- package/dist/server/lead-webhook.js +46 -2
- package/dist/server/notify.d.ts +20 -2
- package/dist/server/notify.js +6 -4
- package/dist/server/place-source.js +1 -1
- package/dist/server/pricing-source.d.ts +25 -0
- package/dist/server/pricing-source.js +48 -0
- package/dist/server/webhook-outbox.d.ts +11 -1
- package/dist/server/webhook-outbox.js +32 -5
- package/dist/testing/lead-rules.js +15 -1
- package/dist/testing/lead-store-contract.js +9 -2
- package/package.json +1 -1
- package/template/app/[locale]/[location]/page.tsx +4 -2
- package/template/app/quote/booking/route.ts +11 -0
- package/template/app/quote/confirm/route.ts +12 -0
- package/template/app/quote/route.ts +2 -1
- package/template/flake.nix +1 -1
- package/template/package.json +2 -2
- 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 +12 -2
- package/template/src/shared/config/places.ts +4 -0
- package/template/src/shared/config/pricing.ts +49 -0
- package/template/src/shared/config/site.ts +6 -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 +183 -0
- package/template/tests/e2e/mock-panel.mjs +34 -0
- package/template/tests/e2e/playwright.config.ts +25 -9
- package/template/tests/e2e/quote-form.spec.ts +2 -2
package/README.md
CHANGED
|
@@ -13,12 +13,12 @@ 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
|
-
| `@evinvest/kitstart/next` | Next server (routes, RSC) | `quoteRoute`, `sitemapRoute`, `robotsRoute`, `ogRoute`, `healthRoute`, `createPlaceLoader`, `loadLocale`, `placeMetadata` / `brandMetadata` / `statusMetadata`, `metadataBase` |
|
|
19
|
+
| `@evinvest/kitstart/next` | Next server (routes, RSC) | `quoteRoute`, `bookingRoute`, `confirmRoute`, `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 |
|
|
21
|
-
| `@evinvest/kitstart/react` | either side | `LangSwitch`, `CallBar`, `StatusScreen`, `PlaceDirectory`, `AreaChips`, `Coverage`, `MapFacade` (client), `QuoteFormShell`, `FormSelect` (client), `LeadCapture` (client), `Faq`, `AnalyticsBoundary` (client), plus the kit and marketing pieces a landing composes with |
|
|
21
|
+
| `@evinvest/kitstart/react` | either side | `LangSwitch`, `CallBar`, `StatusScreen`, `PlaceDirectory`, `AreaChips`, `Coverage`, `MapFacade` (client), `QuoteFormShell`, `FormSelect` (client), `LeadCapture` (client), `LeadBooking` (client) and its adapters, `Faq`, `AnalyticsBoundary` (client), plus the kit and marketing pieces a landing composes with |
|
|
22
22
|
| `@evinvest/kitstart/testing` | a brand's vitest | `describeLandingContract(site, { globalsCss, proxySource, text })`, `describeLeadStoreContract(name, harness)`, `storefrontPlace`, `serviceAreaPlace`, `testLead` |
|
|
23
23
|
| `@evinvest/kitstart/testing/e2e` | a brand's Playwright | `defineSectionSuite(sections)`, `settle(page, selector)`, `BREAKPOINTS` |
|
|
24
24
|
| bin `kitstart-size` | plain node | `kitstart-size [<build root>] [--route …] [--budget …]`: first-load JS of a place page against the target in `tests/bundle_budget.txt` (passes with a warning up to its tolerance, 20 % by default — see [The bundle budget](#the-bundle-budget)); fails closed |
|
|
@@ -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.11.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 {
|
|
@@ -231,8 +231,12 @@ it is the plain POST to `/quote` it always was.
|
|
|
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
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
|
-
| `
|
|
235
|
-
| `
|
|
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 the place's booking (`LeadBooking`, from `place.booking`): a node or `(sent) => node` |
|
|
236
|
+
| `bookingVariant` · `bookingEmbed` · `bookingAdapters` | the `booking_provider` variant that picks the place's provider · the providers' embeds (after cookie consent) rather than a new tab · the brand's adapters over the built-ins (from a client component) — see [Booking](#booking-manual-link-google_calendar-cal_com) |
|
|
237
|
+
| `initialError` | `leadErrorOf(searchParams)` on a page that reads its query: the refusal a 303 brought back, drawn on the server by the card it names (`lead_card`) so the card says why without a script (see *Refusals*) |
|
|
238
|
+
| `head`, `trust` | the brand's heading instead of the title; a slot beside the submit. Like `extras`, `done` and `booking`, any node, built on the server or not, and never asked for a `key`: each slot sits alone in a keyed fragment |
|
|
239
|
+
| `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`, and for the booking `booking`, `bookingCta`, `bookingNote`, `prefer`, `preferOption`, `preferSubmit` |
|
|
236
240
|
|
|
237
241
|
- **Taps.** A need the page knows is not asked again, and a place serving one
|
|
238
242
|
commune fills it: focus the phone, type, send — two taps. `qualify-first`
|
|
@@ -271,21 +275,37 @@ it is the plain POST to `/quote` it always was.
|
|
|
271
275
|
brand's type size cannot leave it on a half pixel.
|
|
272
276
|
- **Phone.** `type="tel"`, required, never masked, held to one rule on both
|
|
273
277
|
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
|
|
278
|
+
`0[1-9]` (or `+33` and nine), any other a `+` and 8 to 15 digits — and for
|
|
279
|
+
the plans visitors most often dial from, that plan's national length after
|
|
280
|
+
the code: `+1` ten, `+44` nine or ten, `+34` and `+41` nine, `+32` eight or
|
|
281
|
+
nine, `+49` 7 to 13, `+39` 6 to 11 (so `+12345678` is no number); full-width
|
|
275
282
|
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
|
|
283
|
+
a hint when the field is left (`aria-live`, read out; when a tap left it,
|
|
284
|
+
only once the tap is over, so nothing moves under the finger) and blocks the submit
|
|
277
285
|
in the page's words — the browser's bubble would speak its own language. With
|
|
278
286
|
`lead.mobileFormat: "e164"` a number it can read is stored as
|
|
279
287
|
`+33612345678` (`normalizePhone`); the default keeps it as typed, which is
|
|
280
288
|
what a brand's tests and tooling look rows up by.
|
|
289
|
+
- **Field messages.** Under each field, a live region (`aria-live`) always
|
|
290
|
+
in the DOM, so a message appearing in it is read out. Empty, it is out of
|
|
291
|
+
the field's flex flow (`empty:absolute`, never `hidden`): it takes no gap,
|
|
292
|
+
so a brand's `classNames.field` may set any gap with no override for it.
|
|
281
293
|
- **Refusals are never silent.** Every required field blocks with the page's
|
|
282
294
|
words and is marked `aria-invalid` once refused. What the server still
|
|
283
295
|
refuses (a brand's own rule, a stale page) comes back to the card: the
|
|
284
296
|
script's post gets a `422 { field }` and the form, as typed, shows the error
|
|
285
297
|
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,
|
|
298
|
+
`?lead_error=<field>&need=<need>&lead_card=<id>#<id>` (`#<id>-callback` for a callback,
|
|
287
299
|
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.
|
|
300
|
+
URL. Only slugs ride in that URL — never a phone or a name. Without a
|
|
301
|
+
script that text is the page's to draw: a page that reads its query passes
|
|
302
|
+
`initialError={leadErrorOf(await searchParams)}` and the server renders it
|
|
303
|
+
in the card the 303 named (`lead_card=<id>` or `<id>-callback`, beside the
|
|
304
|
+
fragment the server never sees): only that card draws it, at its form or
|
|
305
|
+
its callback (opened). A URL from before `lead_card` is drawn by every card
|
|
306
|
+
as it reads — the consent at the callback, anything else at the form. A page built for ISR
|
|
307
|
+
reads no query, so without a script it lands on the card with the field
|
|
308
|
+
marked by nothing — the limit of a cached page. A field the card
|
|
289
309
|
does not draw (a brand's `extras`) shows its error above the submit and is
|
|
290
310
|
marked itself.
|
|
291
311
|
- **Sending.** The script's post (asking `/quote` for JSON) carries a
|
|
@@ -312,6 +332,169 @@ it is the plain POST to `/quote` it always was.
|
|
|
312
332
|
within the 20 % tolerance); the refusals and the shared phone rule are
|
|
313
333
|
2.0 KB of it, sending (the id, the busy state, the retry) 0.6 KB.
|
|
314
334
|
|
|
335
|
+
### Form variants: quote, estimate, fixed
|
|
336
|
+
|
|
337
|
+
A need is sold one of three ways, chosen per need — one brand can mix them:
|
|
338
|
+
|
|
339
|
+
- `quote` — the price is uncertain; the lead asks for one. The form as it was.
|
|
340
|
+
- `estimate` — the price follows from a few enum answers (zone, bedrooms,
|
|
341
|
+
surface band, frequency), shown live as the visitor taps; then the slot.
|
|
342
|
+
- `fixed` — one price for a well-defined job, shown as is; then the slot.
|
|
343
|
+
|
|
344
|
+
```ts
|
|
345
|
+
// shared/config/lead.ts — the form and the route read the same map
|
|
346
|
+
export const LEAD: LeadSchema<Subject> = { …, flows: { standard: "estimate", deep: "quote" } };
|
|
347
|
+
// shared/config/site.ts — the baked price list
|
|
348
|
+
export const site = defineSite({ …, lead: LEAD, pricing: PRICING });
|
|
349
|
+
// shared/config/env.ts — the live one, from the place source's base URL
|
|
350
|
+
export const pricing = createPricingSource(site, { baseUrl: () => serverEnv().locationsApiUrl });
|
|
351
|
+
// app/quote/route.ts
|
|
352
|
+
export const POST = quoteRoute(site, { env: serverEnv, notifier, pricing, unavailable });
|
|
353
|
+
// the page: <LeadCapture … flows={LEAD.flows} pricing={await pricing.model()} photos={["deep"]} />
|
|
354
|
+
```
|
|
355
|
+
|
|
356
|
+
- **The price list** (`PricingModel`) is JSON: `format` 1, `currency` `EUR`
|
|
357
|
+
(TTC), `validFrom`, `roundToCents`, `minimumCents`, `inputs` (`add` in
|
|
358
|
+
cents, `multiply` and `discount` in basis points, labels per locale) and
|
|
359
|
+
`needs` (`{ kind: "estimate", baseCents, inputs }` or `{ kind: "fixed",
|
|
360
|
+
cents }`). `priceOf(model, need, answers) → { cents, breakdown } | null`
|
|
361
|
+
is integer cents throughout, rounding half up at every product, then to
|
|
362
|
+
`roundToCents`, then the minimum. The rules and the shared fixtures the
|
|
363
|
+
panel holds its own port to are in
|
|
364
|
+
[`test/fixtures/pricing/`](./test/fixtures/pricing/README.md).
|
|
365
|
+
`defineSite` refuses a baked model that does not validate or lacks a label
|
|
366
|
+
in one of the site's locales.
|
|
367
|
+
- **The live list** (`createPricingSource`): `GET <base>/pricing`, TTL
|
|
368
|
+
600 s, 3 s timeout; an unreachable source, a non-200, a body that is not
|
|
369
|
+
JSON or a model that does not validate (for every locale of the site) all
|
|
370
|
+
serve the baked model, logged — whole or not at all, never half a model.
|
|
371
|
+
`{}` is "nothing set": the baked model, quietly.
|
|
372
|
+
- **A need runs its flow only when the list prices it so** (`flowOf`): an
|
|
373
|
+
`estimate` the model cannot price — no model, a remote model without the
|
|
374
|
+
need — is a `quote`. So a flow the brand has not switched on is never run
|
|
375
|
+
by a posted field.
|
|
376
|
+
- **The form.** An estimate's questions are a tile per answer (touch-sized
|
|
377
|
+
radios posted as `estimate_<input>`), required once the script runs; the
|
|
378
|
+
price box (`aria-live`) shows the total and the lines that made it, or
|
|
379
|
+
what is missing — never "from". A fixed need shows its price. Either
|
|
380
|
+
submits as `bookSubmit` ("Réserver"). Without a script the answers still
|
|
381
|
+
post and the server prices them; the live price needs the script.
|
|
382
|
+
- **The server prices the lead itself.** `/quote` reads the posted answers
|
|
383
|
+
for the need's inputs only and runs the same `priceOf`; a posted amount is
|
|
384
|
+
never read. The lead is stored with `flow`, `price.cents`,
|
|
385
|
+
`price.validFrom` and an estimate's `price.inputs` (schema 7: `flow`,
|
|
386
|
+
`quoted_cents`, `pricing_valid_from`, `estimate_inputs`). An estimate
|
|
387
|
+
whose answers do not price (a stale page, a forged answer) is kept as a
|
|
388
|
+
`quote` — never refused. A callback has no flow.
|
|
389
|
+
- **The price shown is the price recorded.** The form also posts the price
|
|
390
|
+
it showed, `shown_cents` (`SHOWN_CENTS_FIELD`) — compared, never stored.
|
|
391
|
+
When the server's price differs (the panel changed the list under an open
|
|
392
|
+
page), the lead is not taken: a script gets `422 { ok: false, field:
|
|
393
|
+
"price_changed", reason: "price_changed", cents }`, and the card shows the
|
|
394
|
+
fresh price in place of the old one with `priceChanged` ("Le prix a
|
|
395
|
+
changé : 86 € au lieu de 77 €"); the next submit posts it and is taken. A
|
|
396
|
+
plain post goes to `/quote/confirm` (`confirmRoute`) — never back to its
|
|
397
|
+
card, whose page may be a cached render still showing the old price, so
|
|
398
|
+
every resubmit would be refused again. The confirmation is never cached:
|
|
399
|
+
it prices the answers afresh, says the price changed, and asks again for
|
|
400
|
+
the phone and the postcode — the URL carries only the need, the answers,
|
|
401
|
+
the page's ids and the price seen, nothing typed (a brand's extra fields
|
|
402
|
+
are not carried). Its form posts the fresh `shown_cents` and is taken. A
|
|
403
|
+
page cached before the field posts none and is taken as before; so does an
|
|
404
|
+
estimate posted without a script, whose page could not show a price. Counted as `lead_form_reject
|
|
405
|
+
{reason: "price_changed"}`.
|
|
406
|
+
- **After a priced lead** the card stays: the script's answer carries the
|
|
407
|
+
server's `cents` and the lead's reference (`lead`, `leadRef`:
|
|
408
|
+
`lead-<row>-<8 hex>`), and the card confirms the price (`sentPrice`) and
|
|
409
|
+
offers the place's booking (below) — or shows the brand's own `booking`.
|
|
410
|
+
The lead is stored first, so an abandoned booking is still a lead to
|
|
411
|
+
call. A `quote` need, and any callback, behave as before.
|
|
412
|
+
- **Photos.** A `quote` need listed in `photos` offers "Envoyez des photos"
|
|
413
|
+
— a WhatsApp link with the need in the message — when the place has
|
|
414
|
+
WhatsApp; the callback stays where it is.
|
|
415
|
+
- **Words.** `LEAD_CAPTURE_TEXT` carries them (`priceTitle`, `pricePending`,
|
|
416
|
+
`priceNote`, `priceBase`, `priceRounding`, `priceMinimum`, `bookSubmit`,
|
|
417
|
+
`sentPrice`, `priceChanged*`, `slotCallback`, `book*` (`bookPhoneHint` too), `booked`,
|
|
418
|
+
`prefer*`, `part*`, `photos*`); optional in `LeadCaptureText`, so
|
|
419
|
+
a brand's own text from before them falls back to the kit's in the page's
|
|
420
|
+
language (`flowTextOf`).
|
|
421
|
+
- **Events.** `lead_estimate_shown {need, cents_bucket}` once per need and
|
|
422
|
+
price band (`centsBucket`: `"7500-10000"`), never the price;
|
|
423
|
+
`lead_booking_open {provider}` and `lead_booking_done {provider}` (below).
|
|
424
|
+
- **Weight.** About 3.9 KB gz of first-load JS on the template's place page
|
|
425
|
+
(166,459 → 170,375 B against its 158,000 B target: +7.8 %, within the 20 %
|
|
426
|
+
tolerance).
|
|
427
|
+
|
|
428
|
+
### Booking: `manual`, `link`, `google_calendar`, `cal_com`
|
|
429
|
+
|
|
430
|
+
How a priced lead's slot is set, per place, provider-agnostic. The default is
|
|
431
|
+
`manual` — a call. A place may also have pages on `link`, `google_calendar`
|
|
432
|
+
and `cal_com`, and the `booking_provider` experiment picks among them; the
|
|
433
|
+
first A/B is `google_calendar` against `manual`. `calendly` is the next
|
|
434
|
+
adapter, on the same seams.
|
|
435
|
+
|
|
436
|
+
```ts
|
|
437
|
+
// shared/config/places.ts — baked, optional; the panel's place settings override it (`PlaceLive.booking`)
|
|
438
|
+
{ slug: "paris", …, booking: { default: "manual", providers: { google_calendar: { url: "https://calendar.app.google/…" } } } }
|
|
439
|
+
// shared/config/site.ts — the Cal.com hosts a place may book on (default ["cal.evinvest.ltd", "cal.com"])
|
|
440
|
+
export const site = defineSite({ …, booking: { calComHosts: ["cal.com", "cal.brand.fr"] } });
|
|
441
|
+
// the page: the experiment's variant picks the provider among the place's
|
|
442
|
+
<LeadCapture … bookingVariant={assignment(BOOKING_EXPERIMENT)} />
|
|
443
|
+
// app/quote/booking/route.ts
|
|
444
|
+
export const dynamic = "force-dynamic";
|
|
445
|
+
export const POST = bookingRoute({ env: serverEnv, webhook });
|
|
446
|
+
```
|
|
447
|
+
|
|
448
|
+
- **The config** (`BookingConfig`): `{ default, providers: { <provider>: { url } } }`.
|
|
449
|
+
`manual` has no page and is always available: the card promises a call and
|
|
450
|
+
offers an optional preference — a day of the coming week and a part of the
|
|
451
|
+
day (`morning | afternoon | evening`), no free text. `link` — any booking
|
|
452
|
+
page, opened with `ref=<leadRef>`. `google_calendar` — a schedule on
|
|
453
|
+
`calendar.app.google` or `calendar.google.com/calendar/appointments/…`,
|
|
454
|
+
opened as is: it takes no parameter, so the card asks the visitor to type
|
|
455
|
+
the same phone there (`bookPhoneHint`) and the panel matches by contact and
|
|
456
|
+
time. `cal_com` — `https://<allowed host>/<user>/<event>`, opened with
|
|
457
|
+
`name`, `attendeePhoneNumber` (E.164) and `metadata[ref]=<leadRef>`.
|
|
458
|
+
`default` is `manual` or one of `providers`; a place with no booking is
|
|
459
|
+
`manual` (`DEFAULT_BOOKING`). A ref always rides in the query, never the
|
|
460
|
+
fragment. `bookingConfigProblems` holds the strict URL rules; the panel
|
|
461
|
+
mirrors them from [`test/fixtures/booking/`](./test/fixtures/booking/README.md).
|
|
462
|
+
`defineSite` refuses a baked booking or host list that does not validate;
|
|
463
|
+
a live one that does not is dropped and the baked one kept.
|
|
464
|
+
- **The choice: one entry point.** `bookingOf(place, variant)` answers the
|
|
465
|
+
provider the `booking_provider` variant (`BOOKING_EXPERIMENT`, the same key
|
|
466
|
+
on every brand) names, when the place has it (`manual` always), else the
|
|
467
|
+
place's `default`. `LeadCapture` calls it with `bookingVariant`. The chosen
|
|
468
|
+
provider is what `booking.requested@1` and `lead_booking_open` /
|
|
469
|
+
`lead_booking_done {provider}` carry, so the arms compare.
|
|
470
|
+
- **Nothing third-party before the click.** Every provider with a page is a
|
|
471
|
+
plain link the browser opens in a new tab. `bookingEmbed` on `LeadCapture`
|
|
472
|
+
(`BOOKING_EMBEDS`: Cal.com's modal, `calComEmbedAdapter`) opens it on the
|
|
473
|
+
page instead — only once the visitor accepted the provider's cookies: its
|
|
474
|
+
script loads on the click, and its `bookingSuccessful` is `onBooked` (once
|
|
475
|
+
per lead, however often the modal opens), the one way the page learns a
|
|
476
|
+
slot was taken (`lead_booking_done`). In an A/B
|
|
477
|
+
test both arms must open the same way, or it measures the opening.
|
|
478
|
+
- **Adapters** (`BookingAdapter = { provider, href?, open?, prefillsPhone? }`),
|
|
479
|
+
picked by the chosen provider: `manualAdapter`, `linkAdapter`,
|
|
480
|
+
`googleCalendarAdapter`, `calComAdapter`; `bookingAdapters` on
|
|
481
|
+
`LeadCapture` registers a brand's own by provider name (from a client
|
|
482
|
+
component — a server one cannot pass functions). A provider no adapter
|
|
483
|
+
knows promises the call.
|
|
484
|
+
- **`booking.requested@1`.** Opening a provider's page, or sending a
|
|
485
|
+
`manual` preference, posts `{ submission, lead_ref, provider,
|
|
486
|
+
preferred_date?, preferred_part? }` to `/quote/booking` (`bookingRoute`).
|
|
487
|
+
The submission id proves the lead is this page's (a `lead_ref` alone books
|
|
488
|
+
nothing); only a priced lead (`estimate`, `fixed`) books; one request per
|
|
489
|
+
lead. It is queued on the lead webhook's outbox behind `panelBooking` (off)
|
|
490
|
+
— see the lead webhook.
|
|
491
|
+
- **`ctx.leadRef` is the panel's lead id.** A booking comes back to the panel
|
|
492
|
+
carrying `leadRef` (`metadata[ref]`, `ref`, `lead_ref`), so a brand's
|
|
493
|
+
`lead.created` must send `ctx.leadRef` as its lead id — not an id of its own.
|
|
494
|
+
- **Weight.** The booking and the price confirmation add about 3.1 KB gz of
|
|
495
|
+
first-load JS to the template's place page (170,853 → 173,979 B against
|
|
496
|
+
its 158,000 B target: +10.1 %, within the 20 % tolerance).
|
|
497
|
+
|
|
315
498
|
Tailwind v4 does not scan `node_modules`; the brand's `globals.css` names the
|
|
316
499
|
package:
|
|
317
500
|
|
|
@@ -343,6 +526,14 @@ export const config = { matcher: ["/((?!_next/).*)"] };
|
|
|
343
526
|
export const dynamic = "force-dynamic";
|
|
344
527
|
export const POST = quoteRoute(site, { env: serverEnv, notifier, unavailable, anchor: "devis" });
|
|
345
528
|
|
|
529
|
+
// app/quote/confirm/route.ts — a plain post whose price changed confirms here, uncached
|
|
530
|
+
export const dynamic = "force-dynamic";
|
|
531
|
+
export const GET = confirmRoute(site, { pricing, text: locale => TEXT[locale].leadCapture });
|
|
532
|
+
|
|
533
|
+
// app/quote/booking/route.ts — a priced lead's booking request (`booking.requested@1`)
|
|
534
|
+
export const dynamic = "force-dynamic";
|
|
535
|
+
export const POST = bookingRoute({ env: serverEnv, webhook });
|
|
536
|
+
|
|
346
537
|
// app/sitemap.ts — reads the Host header, so it is dynamic; pages are not
|
|
347
538
|
export const dynamic = "force-dynamic";
|
|
348
539
|
export default sitemapRoute(site, places);
|
|
@@ -399,13 +590,22 @@ chooses per visitor answer `Cache-Control: private, no-store`.
|
|
|
399
590
|
`LEAD_SCHEMA_VERSION` on open. SQLite keeps the Rust server's columns
|
|
400
591
|
(`job`, `zip`, `mobile`) and recognises every earlier table in place.
|
|
401
592
|
Schema 6 adds `submission_id` under a unique index (where present): one
|
|
402
|
-
row per script submission, read back by `findSubmission`.
|
|
593
|
+
row per script submission, read back by `findSubmission`. Schema 7 adds
|
|
594
|
+
the flow and the price a lead was taken at (`flow`, `quoted_cents`,
|
|
595
|
+
`pricing_valid_from`, `estimate_inputs`), all nullable.
|
|
403
596
|
- **Mail is checked at boot.** With `SMTP_URL` set, `leadNotifier` throws when
|
|
404
597
|
it is built if the URL is malformed (the error never repeats it), there is
|
|
405
598
|
no sender (`LEAD_NOTIFY_FROM`, or `leads@<domain>`) or no recipient
|
|
406
599
|
(`LEAD_NOTIFY_TO`, or the brand's email). Both are bare addresses
|
|
407
600
|
(`leads@brand.fr`), not `Name <…>`. Off loopback, a server without TLS gets
|
|
408
601
|
nothing; past `MAIL_PER_MINUTE` a minute the lead is stored and only logged.
|
|
602
|
+
- **The mail names the need.** `lead.subject` is the id the form posted
|
|
603
|
+
(`hot_water`); `leadNotifier(site, env, { needLabel })` names it instead —
|
|
604
|
+
`needLabel(need)` answers the label in the mail's language (the business's,
|
|
605
|
+
not the visitor's), `undefined` for a need it does not know, which the mail
|
|
606
|
+
prints as the id. The default mail uses it, `defaultLeadMail(brand, lead, id,
|
|
607
|
+
{ needLabel })` too, and a brand's `format` gets it as its third argument:
|
|
608
|
+
`(lead, id, { need }) => LeadMail`.
|
|
409
609
|
- **The rate limit knows whose address it counts.** `TRUSTED_PROXY` is
|
|
410
610
|
`cloudflare` (only `CF-Connecting-IP`) or `xff:<n>` (the n-th
|
|
411
611
|
`X-Forwarded-For` hop from the right); production refuses to boot without it.
|
|
@@ -465,6 +665,31 @@ export const POST = quoteRoute(site, { env: serverEnv, notifier, webhook, unavai
|
|
|
465
665
|
suspect, for the body's `suspect` property. Off by default, and to stay off
|
|
466
666
|
until the panel's `lead.created` accepts the property: it refuses an
|
|
467
667
|
unknown one, and the outbox would park the lead. Off, nothing changes.
|
|
668
|
+
- **The sale: `panelFlow`, off.** With `leadWebhook(…, { panelFlow: true })`
|
|
669
|
+
`ctx.flow` says how the need was sold (`panelFlowOf(lead)`), and
|
|
670
|
+
`panelFlowProperties(ctx.flow)` writes it as `lead.created`'s properties:
|
|
671
|
+
`flow`; `quoted_cents` and `pricing_valid_from` together, for `estimate`
|
|
672
|
+
and `fixed` only; `estimate_inputs` (input → answer, slugs, at most 12)
|
|
673
|
+
for an `estimate` that asked anything. Off by default, and to stay off
|
|
674
|
+
until the panel accepts them — it refuses unknown properties, and the
|
|
675
|
+
outbox would park the lead.
|
|
676
|
+
- **`ctx.leadRef` — send it as the panel's lead id.** The lead's reference
|
|
677
|
+
the page was answered with (`lead-<row>-<8 hex>`), whatever the switch.
|
|
678
|
+
Every booking joins its lead by it (Cal.com's `metadata[ref]`, a link's
|
|
679
|
+
`ref`, `booking.requested`'s `lead_ref`): **a brand must send this as the
|
|
680
|
+
panel lead id** in `lead.created`, not an id it computes itself.
|
|
681
|
+
- **Booking requests: `panelBooking`, off.** With `leadWebhook(…, {
|
|
682
|
+
panelBooking: true, buildBookingBody })`, `bookingRoute` queues
|
|
683
|
+
`booking.requested@1` through the same outbox, after the lead's
|
|
684
|
+
`lead.created`, once per lead (`requestBooking`; the row's ref is
|
|
685
|
+
`booking:<leadRef>`), held until the lead's own row (`lead:<row>`) is
|
|
686
|
+
delivered — a `409` or `425` for it is retried, the panel not having the
|
|
687
|
+
lead yet — and never queued for a lead whose `lead.created` was not
|
|
688
|
+
(a suspect held back). `buildBookingBody(request, ctx)` builds the event —
|
|
689
|
+
`bookingRequestedProperties(request)` writes `{ lead_ref, provider,
|
|
690
|
+
preferred_date?, preferred_part? }`, `ctx` has `brandId`, `at`,
|
|
691
|
+
`idempotencyKey`. Off by default, and to stay off until the panel accepts
|
|
692
|
+
the event type: off, a request is answered and dropped.
|
|
468
693
|
- **The rate limit** is `LEAD_RATE_LIMIT` (`<count>/<seconds>`): 5 per 10
|
|
469
694
|
minutes per address in production, 100 outside it, where every request of
|
|
470
695
|
a local stack shares one address. Outside production a lead held back as
|
package/dist/core/accept.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { type RateLimiter } from "./antispam.js";
|
|
2
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";
|
|
@@ -8,22 +9,36 @@ export declare const FORM_ID_FIELD = "form_id";
|
|
|
8
9
|
/** The site's experiment assignment, posted so the submit counts in the right arm. */
|
|
9
10
|
export declare const EXPERIMENT_FIELD = "experiment";
|
|
10
11
|
export declare const VARIANT_FIELD = "variant";
|
|
12
|
+
/**
|
|
13
|
+
* The price the form showed, in cents — compared with the server's, never
|
|
14
|
+
* stored as the price: a lead is taken only at the price the visitor saw.
|
|
15
|
+
*/
|
|
16
|
+
export declare const SHOWN_CENTS_FIELD = "shown_cents";
|
|
17
|
+
/** The refusal of a lead shown at a price that has since changed (`lead_error=price_changed`). */
|
|
18
|
+
export declare const PRICE_CHANGED = "price_changed";
|
|
11
19
|
/** What the submit event carries besides the form id: slugs only, never what was typed. */
|
|
12
20
|
export interface SubmitTags {
|
|
13
21
|
experiment?: string;
|
|
14
22
|
variant?: string;
|
|
15
23
|
}
|
|
16
24
|
export type Outcome<L extends string> =
|
|
17
|
-
/**
|
|
25
|
+
/**
|
|
26
|
+
* `ref`: the lead's public reference (`leadRef`). `duplicate`: a resend of
|
|
27
|
+
* a submission already stored — answered as the first was, nothing sent again.
|
|
28
|
+
*/
|
|
18
29
|
{
|
|
19
30
|
kind: "stored";
|
|
20
31
|
id: number;
|
|
32
|
+
ref: string;
|
|
21
33
|
lead: Lead;
|
|
22
34
|
locale: L;
|
|
23
35
|
formId: string;
|
|
24
36
|
duplicate?: true;
|
|
25
37
|
}
|
|
26
|
-
/**
|
|
38
|
+
/**
|
|
39
|
+
* `field` names what to fix (`phone`, `consent`, … or `form`); `why` is for
|
|
40
|
+
* the log. `cents`: with `price_changed`, the price the lead would be taken at now.
|
|
41
|
+
*/
|
|
27
42
|
| {
|
|
28
43
|
kind: "invalid";
|
|
29
44
|
why: string;
|
|
@@ -32,6 +47,7 @@ export type Outcome<L extends string> =
|
|
|
32
47
|
formId: string;
|
|
33
48
|
locale: L;
|
|
34
49
|
slug: string | null;
|
|
50
|
+
cents?: number;
|
|
35
51
|
} | {
|
|
36
52
|
kind: "failed";
|
|
37
53
|
locale: L;
|
|
@@ -56,7 +72,14 @@ export interface AcceptDeps {
|
|
|
56
72
|
enqueue?: (lead: Lead, id: number, meta: {
|
|
57
73
|
locale: string;
|
|
58
74
|
formId: string;
|
|
75
|
+
leadRef: string;
|
|
59
76
|
}) => void;
|
|
77
|
+
/**
|
|
78
|
+
* The price list to price an `estimate` or `fixed` lead with
|
|
79
|
+
* (`createPricingSource(…).model`); absent → `site.pricing`. A failure is
|
|
80
|
+
* no model: the lead is kept as a `quote`, never refused.
|
|
81
|
+
*/
|
|
82
|
+
pricing?: () => Promise<PricingModel | null>;
|
|
60
83
|
/**
|
|
61
84
|
* Queue a rate-limited lead too, for the panel to show as suspect
|
|
62
85
|
* (`LeadWebhookOptions.panelSuspect`). Off: it waits in the table, as before.
|
|
@@ -68,6 +91,16 @@ export interface AcceptDeps {
|
|
|
68
91
|
now: number;
|
|
69
92
|
log: Pick<Console, "warn" | "error">;
|
|
70
93
|
}
|
|
94
|
+
/**
|
|
95
|
+
* The lead's public reference, `lead-<row>-<8 hex>`: the one the script's
|
|
96
|
+
* answer gives the page (`LeadSent.lead`) and the webhook offers the panel
|
|
97
|
+
* (`LeadWebhookContext.leadRef`), so whatever the page does next — a booking,
|
|
98
|
+
* once there is a provider — can name its lead. The
|
|
99
|
+
* row id for a person matching it to the mail; the tag because row ids start
|
|
100
|
+
* over if the leads file is ever recreated. Seeded by the submission id when
|
|
101
|
+
* there is one, so a resend is answered with the same reference.
|
|
102
|
+
*/
|
|
103
|
+
export declare function leadRef(id: number, seed: string): string;
|
|
71
104
|
/**
|
|
72
105
|
* The funnel's commit point, independent of HTTP. Order is the invariant:
|
|
73
106
|
* 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
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";
|
|
@@ -8,11 +10,67 @@ export const FORM_ID_FIELD = "form_id";
|
|
|
8
10
|
/** The site's experiment assignment, posted so the submit counts in the right arm. */
|
|
9
11
|
export const EXPERIMENT_FIELD = "experiment";
|
|
10
12
|
export const VARIANT_FIELD = "variant";
|
|
13
|
+
/**
|
|
14
|
+
* The price the form showed, in cents — compared with the server's, never
|
|
15
|
+
* stored as the price: a lead is taken only at the price the visitor saw.
|
|
16
|
+
*/
|
|
17
|
+
export const SHOWN_CENTS_FIELD = "shown_cents";
|
|
18
|
+
/** The refusal of a lead shown at a price that has since changed (`lead_error=price_changed`). */
|
|
19
|
+
export const PRICE_CHANGED = "price_changed";
|
|
11
20
|
const FORM_ID = /^[a-z0-9_-]{1,32}$/;
|
|
12
21
|
function field(form, name) {
|
|
13
22
|
const value = form.get(name);
|
|
14
23
|
return typeof value === "string" ? value.trim().slice(0, MAX_FIELD) : null;
|
|
15
24
|
}
|
|
25
|
+
/** FNV-1a, 32 bits, as 8 hex: a tag, not a secret — the core has no `node:crypto`. */
|
|
26
|
+
function tag(seed) {
|
|
27
|
+
let h = 0x811c9dc5;
|
|
28
|
+
for (let i = 0; i < seed.length; i++)
|
|
29
|
+
h = Math.imul(h ^ seed.charCodeAt(i), 0x01000193);
|
|
30
|
+
return (h >>> 0).toString(16).padStart(8, "0");
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* The lead's public reference, `lead-<row>-<8 hex>`: the one the script's
|
|
34
|
+
* answer gives the page (`LeadSent.lead`) and the webhook offers the panel
|
|
35
|
+
* (`LeadWebhookContext.leadRef`), so whatever the page does next — a booking,
|
|
36
|
+
* once there is a provider — can name its lead. The
|
|
37
|
+
* row id for a person matching it to the mail; the tag because row ids start
|
|
38
|
+
* over if the leads file is ever recreated. Seeded by the submission id when
|
|
39
|
+
* there is one, so a resend is answered with the same reference.
|
|
40
|
+
*/
|
|
41
|
+
export function leadRef(id, seed) {
|
|
42
|
+
return `lead-${id}-${tag(seed)}`;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* How the lead's need is sold and at what price — the server's own number,
|
|
46
|
+
* from the posted answers through the same `priceOf` the form showed; a
|
|
47
|
+
* posted amount is never read. A callback asks for a call, not a price: no
|
|
48
|
+
* flow. An estimate whose answers do not price (a stale form, a tampered
|
|
49
|
+
* field) is kept as a `quote`: the customer is still a customer.
|
|
50
|
+
*/
|
|
51
|
+
async function priced(site, form, lead, deps) {
|
|
52
|
+
const flows = site.lead.flows;
|
|
53
|
+
if (!flows || channelOf(lead) === "callback")
|
|
54
|
+
return {};
|
|
55
|
+
let model;
|
|
56
|
+
try {
|
|
57
|
+
model = deps.pricing ? await deps.pricing() : (site.pricing ?? null);
|
|
58
|
+
}
|
|
59
|
+
catch (error) {
|
|
60
|
+
deps.log.error("quote: the price list could not be read; the lead is kept as a quote", error);
|
|
61
|
+
model = null;
|
|
62
|
+
}
|
|
63
|
+
const flow = flowOf(flows, model, lead.subject);
|
|
64
|
+
if (flow === "quote" || !model)
|
|
65
|
+
return { flow: "quote" };
|
|
66
|
+
const inputs = flow === "estimate" ? readEstimateInputs(model, lead.subject, name => field(form, name)) : {};
|
|
67
|
+
const price = priceOf(model, lead.subject, inputs);
|
|
68
|
+
if (!price) {
|
|
69
|
+
deps.log.warn(`quote: an ${flow} for ${lead.subject} did not price from what was posted; kept as a quote`);
|
|
70
|
+
return { flow: "quote" };
|
|
71
|
+
}
|
|
72
|
+
return { flow, price: { cents: price.cents, validFrom: model.validFrom, ...(flow === "estimate" ? { inputs } : {}) } };
|
|
73
|
+
}
|
|
16
74
|
/**
|
|
17
75
|
* The funnel's commit point, independent of HTTP. Order is the invariant:
|
|
18
76
|
* validate → screen → **insert** → (deferred) notify and capture.
|
|
@@ -60,7 +118,16 @@ async function accept(site, form, clientKey, deps) {
|
|
|
60
118
|
// second row, no second mail, the limit not spent again.
|
|
61
119
|
const prior = await stored(candidate.submissionId, deps);
|
|
62
120
|
if (prior)
|
|
63
|
-
return { kind: "stored", id: prior.id, lead: prior.lead, locale, formId, duplicate: true };
|
|
121
|
+
return { kind: "stored", id: prior.id, ref: leadRef(prior.id, candidate.submissionId ?? ""), lead: prior.lead, locale, formId, duplicate: true };
|
|
122
|
+
const sale = await priced(site, form, candidate, deps);
|
|
123
|
+
// The price shown is the price recorded: a form priced from an older list
|
|
124
|
+
// is refused with the fresh price, for the visitor to confirm. A page from
|
|
125
|
+
// before the field posts none, and is taken as before.
|
|
126
|
+
const shown = field(form, SHOWN_CENTS_FIELD);
|
|
127
|
+
if (sale.price && shown !== null && /^\d{1,12}$/.test(shown) && Number(shown) !== sale.price.cents) {
|
|
128
|
+
deps.log.warn(`quote: a ${sale.flow} for ${candidate.subject} was shown at ${shown} and is now ${sale.price.cents}; sent back to confirm`);
|
|
129
|
+
return { kind: "invalid", why: "price changed", field: PRICE_CHANGED, channel: channelOf(candidate), formId, locale, slug, cents: sale.price.cents };
|
|
130
|
+
}
|
|
64
131
|
// After validation, so a typo corrected and resent does not spend the limit.
|
|
65
132
|
const verdict = screen({
|
|
66
133
|
honeypot: [HONEYPOT_FIELD, ...LEGACY_HONEYPOT_FIELDS].map(name => field(form, name)).find(v => v) ?? null,
|
|
@@ -72,6 +139,7 @@ async function accept(site, form, clientKey, deps) {
|
|
|
72
139
|
const { consentText, ...rest } = candidate;
|
|
73
140
|
const lead = {
|
|
74
141
|
...rest,
|
|
142
|
+
...sale,
|
|
75
143
|
spamVerdict: verdict === "ok" ? null : verdict,
|
|
76
144
|
// Stamped by the server: the moment it accepted the consent with the lead.
|
|
77
145
|
...(consentText ? { consent: { text: consentText, at: new Date(deps.now).toISOString() } } : {}),
|
|
@@ -84,10 +152,11 @@ async function accept(site, form, clientKey, deps) {
|
|
|
84
152
|
// Two posts of one submission raced past the lookup; the unique index let one in.
|
|
85
153
|
const winner = await stored(candidate.submissionId, deps);
|
|
86
154
|
if (winner)
|
|
87
|
-
return { kind: "stored", id: winner.id, lead: winner.lead, locale, formId, duplicate: true };
|
|
155
|
+
return { kind: "stored", id: winner.id, ref: leadRef(winner.id, candidate.submissionId ?? ""), lead: winner.lead, locale, formId, duplicate: true };
|
|
88
156
|
deps.log.error(`quote: the lead store rejected a submission for ${slug ?? "no point"}`, error);
|
|
89
157
|
return { kind: "failed", locale, slug };
|
|
90
158
|
}
|
|
159
|
+
const ref = leadRef(id, candidate.submissionId ?? `${id}:${deps.now}`);
|
|
91
160
|
// The render stamp comes from the client, so it only marks: a `too-fast`
|
|
92
161
|
// lead (or one from a page cached before the stamp existed) is still sent
|
|
93
162
|
// on, flagged. The honeypot and the rate limit are the server's own
|
|
@@ -96,7 +165,7 @@ async function accept(site, form, clientKey, deps) {
|
|
|
96
165
|
if (!deps.enqueue)
|
|
97
166
|
return;
|
|
98
167
|
try {
|
|
99
|
-
deps.enqueue(lead, id, { locale, formId });
|
|
168
|
+
deps.enqueue(lead, id, { locale, formId, leadRef: ref });
|
|
100
169
|
}
|
|
101
170
|
catch (error) {
|
|
102
171
|
deps.log.error(`quote: lead ${id} is stored but could not be queued for its webhook`, error);
|
|
@@ -107,7 +176,7 @@ async function accept(site, form, clientKey, deps) {
|
|
|
107
176
|
deps.log.warn(`quote: lead ${id} stored as suspected spam (${lead.spamVerdict}); not notified${marked ? ", queued marked suspect" : ""}`);
|
|
108
177
|
if (marked)
|
|
109
178
|
queue();
|
|
110
|
-
return { kind: "stored", id, lead, locale, formId };
|
|
179
|
+
return { kind: "stored", id, ref, lead, locale, formId };
|
|
111
180
|
}
|
|
112
181
|
queue();
|
|
113
182
|
deps.defer(async () => {
|
|
@@ -120,5 +189,5 @@ async function accept(site, form, clientKey, deps) {
|
|
|
120
189
|
});
|
|
121
190
|
const tags = experimentProps(field(form, EXPERIMENT_FIELD), field(form, VARIANT_FIELD));
|
|
122
191
|
deps.defer(() => deps.capture(lead, formId, tags));
|
|
123
|
-
return { kind: "stored", id, lead, locale, formId };
|
|
192
|
+
return { kind: "stored", id, ref, lead, locale, formId };
|
|
124
193
|
}
|
package/dist/core/analytics.d.ts
CHANGED
|
@@ -16,6 +16,9 @@ export declare const EVENTS: {
|
|
|
16
16
|
readonly formStep: "lead_form_step";
|
|
17
17
|
readonly submitError: "lead_form_submit_error";
|
|
18
18
|
readonly formReject: "lead_form_reject";
|
|
19
|
+
readonly estimateShown: "lead_estimate_shown";
|
|
20
|
+
readonly bookingOpen: "lead_booking_open";
|
|
21
|
+
readonly bookingDone: "lead_booking_done";
|
|
19
22
|
};
|
|
20
23
|
export type IntentChannel = "form_open" | "whatsapp" | "phone" | "sms" | "callback" | "booking";
|
|
21
24
|
/**
|
|
@@ -32,7 +35,7 @@ export type LeadStep = "need" | "contact";
|
|
|
32
35
|
* throws, prod drops) rather than by review. `experiment` and `variant` are
|
|
33
36
|
* the site's assignment, passed in; `field` is a field's role, never its value.
|
|
34
37
|
*/
|
|
35
|
-
export declare const ALLOWED_PROPS: readonly ["brand_id", "location_id", "source", "device", "channel", "form_id", "experiment", "variant", "field", "step", "layout", "reason", "blocking"];
|
|
38
|
+
export declare const ALLOWED_PROPS: readonly ["brand_id", "location_id", "source", "device", "channel", "form_id", "experiment", "variant", "field", "step", "layout", "reason", "blocking", "need", "cents_bucket", "provider"];
|
|
36
39
|
/** What an experiment's assignment looks like on the wire: short slugs, nothing a person typed. */
|
|
37
40
|
export declare const EXPERIMENT_SLUG: RegExp;
|
|
38
41
|
/** An experiment's assignment as event properties, or nothing when it is not a pair of slugs. */
|
package/dist/core/analytics.js
CHANGED
|
@@ -21,6 +21,13 @@ export const EVENTS = {
|
|
|
21
21
|
submitError: "lead_form_submit_error",
|
|
22
22
|
// The server's refusal (`reason`, the `field`): the visitor was sent back.
|
|
23
23
|
formReject: "lead_form_reject",
|
|
24
|
+
// An estimate's price, shown for a full set of answers: the need and the
|
|
25
|
+
// price's band (`centsBucket`), never the price — once per band and need.
|
|
26
|
+
estimateShown: "lead_estimate_shown",
|
|
27
|
+
// After a priced lead: the visitor opened the place's booking (`provider`),
|
|
28
|
+
// and — only where the provider says so on the page (Cal.com's embed) — booked.
|
|
29
|
+
bookingOpen: "lead_booking_open",
|
|
30
|
+
bookingDone: "lead_booking_done",
|
|
24
31
|
};
|
|
25
32
|
/**
|
|
26
33
|
* Every property name an event may carry. A customer's phone or address comes
|
|
@@ -44,6 +51,12 @@ export const ALLOWED_PROPS = [
|
|
|
44
51
|
"reason",
|
|
45
52
|
// A field error that blocked the submit (`true`), or a hint (`false`).
|
|
46
53
|
"blocking",
|
|
54
|
+
// The brand's subject slug.
|
|
55
|
+
"need",
|
|
56
|
+
// A price's band, `"5000-7500"` (`centsBucket`) — never the price.
|
|
57
|
+
"cents_bucket",
|
|
58
|
+
// The booking's provider, `manual` | `link` | `cal_com` — never its URL.
|
|
59
|
+
"provider",
|
|
47
60
|
];
|
|
48
61
|
/** What an experiment's assignment looks like on the wire: short slugs, nothing a person typed. */
|
|
49
62
|
export const EXPERIMENT_SLUG = /^[a-z0-9_-]{1,32}$/;
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
export { BOOKING_LIMITS, BOOKING_PROVIDERS, BOOKING_STATUSES, BOOKING_EXPERIMENT, bookingOf, DEFAULT_BOOKING, DEFAULT_CAL_COM_HOSTS, isBookingProvider, isLeadRef, isPageProvider, isPreferredPart, PREFERRED_PARTS, type BookingChoice, type BookingConfig, type BookingProvider, type BookingRequest, type BookingRequestedProperties, type BookingRules, type BookingStatus, type OpenBookingConfig, type PageProvider, type PreferredPart, } from "./model.js";
|
|
2
|
+
export { BookingConfigError, bookingConfigProblems, bookingRequestedProperties, bookingRequestProblems, calComHostsProblems, parseBookingConfig, parseBookingRequest, } from "./validate.js";
|
|
3
|
+
export { bookingHref, type BookingPrefill } from "./url.js";
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
export { BOOKING_LIMITS, BOOKING_PROVIDERS, BOOKING_STATUSES, BOOKING_EXPERIMENT, bookingOf, DEFAULT_BOOKING, DEFAULT_CAL_COM_HOSTS, isBookingProvider, isLeadRef, isPageProvider, isPreferredPart, PREFERRED_PARTS, } from "./model.js";
|
|
2
|
+
export { BookingConfigError, bookingConfigProblems, bookingRequestedProperties, bookingRequestProblems, calComHostsProblems, parseBookingConfig, parseBookingRequest, } from "./validate.js";
|
|
3
|
+
export { bookingHref } from "./url.js";
|