@evinvest/kitstart 0.10.0 → 0.12.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 +210 -17
- package/dist/core/accept.d.ts +19 -1
- package/dist/core/accept.js +25 -2
- package/dist/core/analytics.d.ts +13 -3
- package/dist/core/analytics.js +17 -2
- 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-text.d.ts +26 -2
- package/dist/core/lead-capture-text.js +32 -2
- package/dist/core/lead.d.ts +33 -0
- package/dist/core/lead.js +43 -0
- 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/routing.d.ts +3 -2
- package/dist/core/routing.js +4 -3
- package/dist/core/site.d.ts +8 -0
- package/dist/core/site.js +15 -0
- package/dist/index.d.ts +4 -3
- package/dist/index.js +4 -3
- 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.js +14 -5
- package/dist/react/AnalyticsBoundary.js +8 -4
- 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 +28 -5
- package/dist/react/LeadCapture.js +22 -8
- package/dist/react/LeadCaptureFields.js +60 -21
- package/dist/react/LeadCapturePriced.d.ts +3 -4
- package/dist/react/LeadCapturePriced.js +4 -4
- package/dist/react/analytics-context.d.ts +7 -0
- package/dist/react/analytics-context.js +7 -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-submit.d.ts +7 -1
- package/dist/react/use-lead-submit.js +13 -3
- package/dist/react/use-repriced.d.ts +18 -0
- package/dist/react/use-repriced.js +27 -0
- package/dist/server/experiments-declared.d.ts +106 -0
- package/dist/server/experiments-declared.js +124 -0
- package/dist/server/experiments-source.d.ts +49 -0
- package/dist/server/experiments-source.js +88 -0
- package/dist/server/index.d.ts +4 -2
- package/dist/server/index.js +2 -0
- package/dist/server/lead-webhook.d.ts +63 -0
- package/dist/server/lead-webhook.js +24 -1
- 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/webhook-outbox.d.ts +11 -1
- package/dist/server/webhook-outbox.js +32 -5
- package/dist/testing/lead-rules.js +15 -1
- package/package.json +1 -1
- package/template/app/quote/booking/route.ts +11 -0
- package/template/app/quote/confirm/route.ts +12 -0
- package/template/flake.nix +1 -1
- package/template/package.json +2 -2
- package/template/src/shared/config/places.ts +4 -0
- package/template/src/shared/config/site.ts +3 -0
- package/template/tests/e2e/flows.spec.ts +86 -9
- package/template/tests/e2e/mock-panel.mjs +18 -4
- package/template/tests/e2e/quote-form.spec.ts +2 -2
package/README.md
CHANGED
|
@@ -16,9 +16,9 @@ stays in the brand.
|
|
|
16
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
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.12.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 {
|
|
@@ -232,9 +232,11 @@ it is the plain POST to `/quote` it always was.
|
|
|
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
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
|
|
236
|
-
| `
|
|
237
|
-
| `
|
|
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` |
|
|
238
240
|
|
|
239
241
|
- **Taps.** A need the page knows is not asked again, and a place serving one
|
|
240
242
|
commune fills it: focus the phone, type, send — two taps. `qualify-first`
|
|
@@ -273,21 +275,37 @@ it is the plain POST to `/quote` it always was.
|
|
|
273
275
|
brand's type size cannot leave it on a half pixel.
|
|
274
276
|
- **Phone.** `type="tel"`, required, never masked, held to one rule on both
|
|
275
277
|
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
|
|
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
|
|
277
282
|
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
|
|
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
|
|
279
285
|
in the page's words — the browser's bubble would speak its own language. With
|
|
280
286
|
`lead.mobileFormat: "e164"` a number it can read is stored as
|
|
281
287
|
`+33612345678` (`normalizePhone`); the default keeps it as typed, which is
|
|
282
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.
|
|
283
293
|
- **Refusals are never silent.** Every required field blocks with the page's
|
|
284
294
|
words and is marked `aria-invalid` once refused. What the server still
|
|
285
295
|
refuses (a brand's own rule, a stale page) comes back to the card: the
|
|
286
296
|
script's post gets a `422 { field }` and the form, as typed, shows the error
|
|
287
297
|
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,
|
|
298
|
+
`?lead_error=<field>&need=<need>&lead_card=<id>#<id>` (`#<id>-callback` for a callback,
|
|
289
299
|
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.
|
|
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
|
|
291
309
|
does not draw (a brand's `extras`) shows its error above the submit and is
|
|
292
310
|
marked itself.
|
|
293
311
|
- **Sending.** The script's post (asking `/quote` for JSON) carries a
|
|
@@ -368,26 +386,115 @@ export const POST = quoteRoute(site, { env: serverEnv, notifier, pricing, unavai
|
|
|
368
386
|
`quoted_cents`, `pricing_valid_from`, `estimate_inputs`). An estimate
|
|
369
387
|
whose answers do not price (a stale page, a forged answer) is kept as a
|
|
370
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"}`.
|
|
371
406
|
- **After a priced lead** the card stays: the script's answer carries the
|
|
372
407
|
server's `cents` and the lead's reference (`lead`, `leadRef`:
|
|
373
408
|
`lead-<row>-<8 hex>`), and the card confirms the price (`sentPrice`) and
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
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.
|
|
377
412
|
- **Photos.** A `quote` need listed in `photos` offers "Envoyez des photos"
|
|
378
413
|
— a WhatsApp link with the need in the message — when the place has
|
|
379
414
|
WhatsApp; the callback stays where it is.
|
|
380
415
|
- **Words.** `LEAD_CAPTURE_TEXT` carries them (`priceTitle`, `pricePending`,
|
|
381
416
|
`priceNote`, `priceBase`, `priceRounding`, `priceMinimum`, `bookSubmit`,
|
|
382
|
-
`sentPrice`, `slotCallback`, `
|
|
417
|
+
`sentPrice`, `priceChanged*`, `slotCallback`, `book*` (`bookPhoneHint` too), `booked`,
|
|
418
|
+
`prefer*`, `part*`, `photos*`); optional in `LeadCaptureText`, so
|
|
383
419
|
a brand's own text from before them falls back to the kit's in the page's
|
|
384
420
|
language (`flowTextOf`).
|
|
385
421
|
- **Events.** `lead_estimate_shown {need, cents_bucket}` once per need and
|
|
386
|
-
price band (`centsBucket`: `"7500-10000"`), never the price
|
|
422
|
+
price band (`centsBucket`: `"7500-10000"`), never the price;
|
|
423
|
+
`lead_booking_open {provider}` and `lead_booking_done {provider}` (below).
|
|
387
424
|
- **Weight.** About 3.9 KB gz of first-load JS on the template's place page
|
|
388
425
|
(166,459 → 170,375 B against its 158,000 B target: +7.8 %, within the 20 %
|
|
389
426
|
tolerance).
|
|
390
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
|
+
|
|
391
498
|
Tailwind v4 does not scan `node_modules`; the brand's `globals.css` names the
|
|
392
499
|
package:
|
|
393
500
|
|
|
@@ -419,6 +526,14 @@ export const config = { matcher: ["/((?!_next/).*)"] };
|
|
|
419
526
|
export const dynamic = "force-dynamic";
|
|
420
527
|
export const POST = quoteRoute(site, { env: serverEnv, notifier, unavailable, anchor: "devis" });
|
|
421
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
|
+
|
|
422
537
|
// app/sitemap.ts — reads the Host header, so it is dynamic; pages are not
|
|
423
538
|
export const dynamic = "force-dynamic";
|
|
424
539
|
export default sitemapRoute(site, places);
|
|
@@ -484,6 +599,13 @@ chooses per visitor answer `Cache-Control: private, no-store`.
|
|
|
484
599
|
(`LEAD_NOTIFY_TO`, or the brand's email). Both are bare addresses
|
|
485
600
|
(`leads@brand.fr`), not `Name <…>`. Off loopback, a server without TLS gets
|
|
486
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`.
|
|
487
609
|
- **The rate limit knows whose address it counts.** `TRUSTED_PROXY` is
|
|
488
610
|
`cloudflare` (only `CF-Connecting-IP`) or `xff:<n>` (the n-th
|
|
489
611
|
`X-Forwarded-For` hop from the right); production refuses to boot without it.
|
|
@@ -550,8 +672,31 @@ export const POST = quoteRoute(site, { env: serverEnv, notifier, webhook, unavai
|
|
|
550
672
|
and `fixed` only; `estimate_inputs` (input → answer, slugs, at most 12)
|
|
551
673
|
for an `estimate` that asked anything. Off by default, and to stay off
|
|
552
674
|
until the panel accepts them — it refuses unknown properties, and the
|
|
553
|
-
outbox would park the lead.
|
|
554
|
-
|
|
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
|
+
- **`ctx.analyticsId` — the visit, for PostHog.** The `distinct_id` of the
|
|
682
|
+
page's beacons (`AnalyticsBoundary` holds one per page, in memory), posted
|
|
683
|
+
by `LeadCapture`'s script as `analytics_id` and checked against
|
|
684
|
+
`[A-Za-z0-9._:-]{1,128}`. Write it as `lead.created`'s `analytics_id`
|
|
685
|
+
property so the panel's PostHog events of the lead join the visit — once
|
|
686
|
+
the panel accepts the property. Absent for a plain post, a page with no
|
|
687
|
+
analytics key, or an older page; never stored with the lead.
|
|
688
|
+
- **Booking requests: `panelBooking`, off.** With `leadWebhook(…, {
|
|
689
|
+
panelBooking: true, buildBookingBody })`, `bookingRoute` queues
|
|
690
|
+
`booking.requested@1` through the same outbox, after the lead's
|
|
691
|
+
`lead.created`, once per lead (`requestBooking`; the row's ref is
|
|
692
|
+
`booking:<leadRef>`), held until the lead's own row (`lead:<row>`) is
|
|
693
|
+
delivered — a `409` or `425` for it is retried, the panel not having the
|
|
694
|
+
lead yet — and never queued for a lead whose `lead.created` was not
|
|
695
|
+
(a suspect held back). `buildBookingBody(request, ctx)` builds the event —
|
|
696
|
+
`bookingRequestedProperties(request)` writes `{ lead_ref, provider,
|
|
697
|
+
preferred_date?, preferred_part? }`, `ctx` has `brandId`, `at`,
|
|
698
|
+
`idempotencyKey`. Off by default, and to stay off until the panel accepts
|
|
699
|
+
the event type: off, a request is answered and dropped.
|
|
555
700
|
- **The rate limit** is `LEAD_RATE_LIMIT` (`<count>/<seconds>`): 5 per 10
|
|
556
701
|
minutes per address in production, 100 outside it, where every request of
|
|
557
702
|
a local stack shares one address. Outside production a lead held back as
|
|
@@ -577,6 +722,54 @@ export const POST = quoteRoute(site, { env: serverEnv, notifier, webhook, unavai
|
|
|
577
722
|
`WEBHOOK_TICK_MS` and a new process picks up what is due. Rows queued for a
|
|
578
723
|
previous URL stay in place and are counted at open.
|
|
579
724
|
|
|
725
|
+
## Experiments from the panel
|
|
726
|
+
|
|
727
|
+
The experiments live in code (`@evinvest/experiments`, one `as const` config);
|
|
728
|
+
the panel decides their weights and kill switch, and PostHog counts them.
|
|
729
|
+
|
|
730
|
+
**Overrides** (`createExperimentsSource`): `GET <base>/experiments`, the same
|
|
731
|
+
base as the place source, `{ "experiments": { "<key>": { enabled?, weights?,
|
|
732
|
+
holdout? } } }`. The proxy reads it on every request, so it answers from memory:
|
|
733
|
+
the first call waits for the panel (1.5 s timeout), later ones are served from
|
|
734
|
+
the cache, and past the 30 s TTL the stale answer is served while one refresh
|
|
735
|
+
runs behind it. An unreachable panel, a non-200 or a body of another shape is
|
|
736
|
+
logged and keeps the last good answer — a kill switch must not come back on
|
|
737
|
+
because the panel blinked — or `{}`, the config in code, when there was none
|
|
738
|
+
yet; either way the panel is not asked again before the TTL.
|
|
739
|
+
|
|
740
|
+
```ts
|
|
741
|
+
// shared/config/env.ts
|
|
742
|
+
export const experimentOverrides = createExperimentsSource({ baseUrl: () => serverEnv().locationsApiUrl });
|
|
743
|
+
// proxy.ts — the same applied config wherever a variant is read
|
|
744
|
+
const live = applyOverrides(experiments, await experimentOverrides.overrides());
|
|
745
|
+
```
|
|
746
|
+
|
|
747
|
+
Only types are checked here; whether a field fits the code (weights of the
|
|
748
|
+
declared length, holdout in `[0, 1)`) is `applyOverrides`' call.
|
|
749
|
+
|
|
750
|
+
**Declaration** (`declareExperiments`): at every start the landing tells the
|
|
751
|
+
panel which experiments this build runs — `experiments.declared@1`, one event
|
|
752
|
+
with every experiment's key, variants, weights, `enabled`, `holdout` and an
|
|
753
|
+
optional one-line `summary`, under a fresh UUIDv7. It goes through the lead
|
|
754
|
+
webhook's outbox, signed and retried like a lead (so it needs the same
|
|
755
|
+
`LEAD_WEBHOOK_URL` and sqlite lead store), and the panel keeps the latest by
|
|
756
|
+
`occurredAt`; an experiment missing from it is retired there.
|
|
757
|
+
|
|
758
|
+
```ts
|
|
759
|
+
// instrumentation.ts
|
|
760
|
+
export async function register() {
|
|
761
|
+
if (process.env.NEXT_RUNTIME !== "nodejs") return;
|
|
762
|
+
declareExperiments(webhook, experiments, { summaries: { lead_layout: "Two steps beat one long form" } });
|
|
763
|
+
}
|
|
764
|
+
```
|
|
765
|
+
|
|
766
|
+
It never throws and never holds the start: no webhook, one that cannot be
|
|
767
|
+
built, or a queue that refuses the row is logged and skipped — the panel keeps
|
|
768
|
+
the last declaration. An experiment the panel would refuse (key not
|
|
769
|
+
`[a-z0-9_]{1,64}`, fewer than two unique slug variants, weights not one per
|
|
770
|
+
variant, negative or summing to zero, holdout outside `[0, 1)`, summary over
|
|
771
|
+
200 characters) is left out and logged, since the panel judges the event whole.
|
|
772
|
+
|
|
580
773
|
## The site
|
|
581
774
|
|
|
582
775
|
```ts
|
package/dist/core/accept.d.ts
CHANGED
|
@@ -9,6 +9,19 @@ export declare const FORM_ID_FIELD = "form_id";
|
|
|
9
9
|
/** The site's experiment assignment, posted so the submit counts in the right arm. */
|
|
10
10
|
export declare const EXPERIMENT_FIELD = "experiment";
|
|
11
11
|
export declare const VARIANT_FIELD = "variant";
|
|
12
|
+
/**
|
|
13
|
+
* The page's analytics id (`ANALYTICS_ID`), posted by the script only: the
|
|
14
|
+
* webhook's `ctx.analyticsId`, for `lead.created`'s `analytics_id`. Never
|
|
15
|
+
* stored with the lead and never a reason to refuse one.
|
|
16
|
+
*/
|
|
17
|
+
export declare const ANALYTICS_ID_FIELD = "analytics_id";
|
|
18
|
+
/**
|
|
19
|
+
* The price the form showed, in cents — compared with the server's, never
|
|
20
|
+
* stored as the price: a lead is taken only at the price the visitor saw.
|
|
21
|
+
*/
|
|
22
|
+
export declare const SHOWN_CENTS_FIELD = "shown_cents";
|
|
23
|
+
/** The refusal of a lead shown at a price that has since changed (`lead_error=price_changed`). */
|
|
24
|
+
export declare const PRICE_CHANGED = "price_changed";
|
|
12
25
|
/** What the submit event carries besides the form id: slugs only, never what was typed. */
|
|
13
26
|
export interface SubmitTags {
|
|
14
27
|
experiment?: string;
|
|
@@ -28,7 +41,10 @@ export type Outcome<L extends string> =
|
|
|
28
41
|
formId: string;
|
|
29
42
|
duplicate?: true;
|
|
30
43
|
}
|
|
31
|
-
/**
|
|
44
|
+
/**
|
|
45
|
+
* `field` names what to fix (`phone`, `consent`, … or `form`); `why` is for
|
|
46
|
+
* the log. `cents`: with `price_changed`, the price the lead would be taken at now.
|
|
47
|
+
*/
|
|
32
48
|
| {
|
|
33
49
|
kind: "invalid";
|
|
34
50
|
why: string;
|
|
@@ -37,6 +53,7 @@ export type Outcome<L extends string> =
|
|
|
37
53
|
formId: string;
|
|
38
54
|
locale: L;
|
|
39
55
|
slug: string | null;
|
|
56
|
+
cents?: number;
|
|
40
57
|
} | {
|
|
41
58
|
kind: "failed";
|
|
42
59
|
locale: L;
|
|
@@ -62,6 +79,7 @@ export interface AcceptDeps {
|
|
|
62
79
|
locale: string;
|
|
63
80
|
formId: string;
|
|
64
81
|
leadRef: string;
|
|
82
|
+
analyticsId?: string;
|
|
65
83
|
}) => void;
|
|
66
84
|
/**
|
|
67
85
|
* The price list to price an `estimate` or `fixed` lead with
|
package/dist/core/accept.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { experimentProps } from "./analytics.js";
|
|
1
|
+
import { ANALYTICS_ID, 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
4
|
import { flowOf, readEstimateInputs } from "./pricing/flow.js";
|
|
@@ -10,6 +10,19 @@ export const FORM_ID_FIELD = "form_id";
|
|
|
10
10
|
/** The site's experiment assignment, posted so the submit counts in the right arm. */
|
|
11
11
|
export const EXPERIMENT_FIELD = "experiment";
|
|
12
12
|
export const VARIANT_FIELD = "variant";
|
|
13
|
+
/**
|
|
14
|
+
* The page's analytics id (`ANALYTICS_ID`), posted by the script only: the
|
|
15
|
+
* webhook's `ctx.analyticsId`, for `lead.created`'s `analytics_id`. Never
|
|
16
|
+
* stored with the lead and never a reason to refuse one.
|
|
17
|
+
*/
|
|
18
|
+
export const ANALYTICS_ID_FIELD = "analytics_id";
|
|
19
|
+
/**
|
|
20
|
+
* The price the form showed, in cents — compared with the server's, never
|
|
21
|
+
* stored as the price: a lead is taken only at the price the visitor saw.
|
|
22
|
+
*/
|
|
23
|
+
export const SHOWN_CENTS_FIELD = "shown_cents";
|
|
24
|
+
/** The refusal of a lead shown at a price that has since changed (`lead_error=price_changed`). */
|
|
25
|
+
export const PRICE_CHANGED = "price_changed";
|
|
13
26
|
const FORM_ID = /^[a-z0-9_-]{1,32}$/;
|
|
14
27
|
function field(form, name) {
|
|
15
28
|
const value = form.get(name);
|
|
@@ -113,6 +126,14 @@ async function accept(site, form, clientKey, deps) {
|
|
|
113
126
|
if (prior)
|
|
114
127
|
return { kind: "stored", id: prior.id, ref: leadRef(prior.id, candidate.submissionId ?? ""), lead: prior.lead, locale, formId, duplicate: true };
|
|
115
128
|
const sale = await priced(site, form, candidate, deps);
|
|
129
|
+
// The price shown is the price recorded: a form priced from an older list
|
|
130
|
+
// is refused with the fresh price, for the visitor to confirm. A page from
|
|
131
|
+
// before the field posts none, and is taken as before.
|
|
132
|
+
const shown = field(form, SHOWN_CENTS_FIELD);
|
|
133
|
+
if (sale.price && shown !== null && /^\d{1,12}$/.test(shown) && Number(shown) !== sale.price.cents) {
|
|
134
|
+
deps.log.warn(`quote: a ${sale.flow} for ${candidate.subject} was shown at ${shown} and is now ${sale.price.cents}; sent back to confirm`);
|
|
135
|
+
return { kind: "invalid", why: "price changed", field: PRICE_CHANGED, channel: channelOf(candidate), formId, locale, slug, cents: sale.price.cents };
|
|
136
|
+
}
|
|
116
137
|
// After validation, so a typo corrected and resent does not spend the limit.
|
|
117
138
|
const verdict = screen({
|
|
118
139
|
honeypot: [HONEYPOT_FIELD, ...LEGACY_HONEYPOT_FIELDS].map(name => field(form, name)).find(v => v) ?? null,
|
|
@@ -146,11 +167,13 @@ async function accept(site, form, clientKey, deps) {
|
|
|
146
167
|
// lead (or one from a page cached before the stamp existed) is still sent
|
|
147
168
|
// on, flagged. The honeypot and the rate limit are the server's own
|
|
148
169
|
// evidence, and those leads wait in the table for a reviewer.
|
|
170
|
+
const rawAnalyticsId = form.get(ANALYTICS_ID_FIELD);
|
|
171
|
+
const analyticsId = typeof rawAnalyticsId === "string" && ANALYTICS_ID.test(rawAnalyticsId) ? rawAnalyticsId : undefined;
|
|
149
172
|
const queue = () => {
|
|
150
173
|
if (!deps.enqueue)
|
|
151
174
|
return;
|
|
152
175
|
try {
|
|
153
|
-
deps.enqueue(lead, id, { locale, formId, leadRef: ref });
|
|
176
|
+
deps.enqueue(lead, id, { locale, formId, leadRef: ref, ...(analyticsId ? { analyticsId } : {}) });
|
|
154
177
|
}
|
|
155
178
|
catch (error) {
|
|
156
179
|
deps.log.error(`quote: lead ${id} is stored but could not be queued for its webhook`, error);
|
package/dist/core/analytics.d.ts
CHANGED
|
@@ -17,6 +17,8 @@ export declare const EVENTS: {
|
|
|
17
17
|
readonly submitError: "lead_form_submit_error";
|
|
18
18
|
readonly formReject: "lead_form_reject";
|
|
19
19
|
readonly estimateShown: "lead_estimate_shown";
|
|
20
|
+
readonly bookingOpen: "lead_booking_open";
|
|
21
|
+
readonly bookingDone: "lead_booking_done";
|
|
20
22
|
};
|
|
21
23
|
export type IntentChannel = "form_open" | "whatsapp" | "phone" | "sms" | "callback" | "booking";
|
|
22
24
|
/**
|
|
@@ -33,7 +35,13 @@ export type LeadStep = "need" | "contact";
|
|
|
33
35
|
* throws, prod drops) rather than by review. `experiment` and `variant` are
|
|
34
36
|
* the site's assignment, passed in; `field` is a field's role, never its value.
|
|
35
37
|
*/
|
|
36
|
-
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"];
|
|
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"];
|
|
39
|
+
/**
|
|
40
|
+
* The beacon's `distinct_id` as a lead may carry it to the panel
|
|
41
|
+
* (`lead.created`'s `analytics_id`), so PostHog can join the lead to the
|
|
42
|
+
* visit. The panel's own rule; anything else is dropped, never refused.
|
|
43
|
+
*/
|
|
44
|
+
export declare const ANALYTICS_ID: RegExp;
|
|
37
45
|
/** What an experiment's assignment looks like on the wire: short slugs, nothing a person typed. */
|
|
38
46
|
export declare const EXPERIMENT_SLUG: RegExp;
|
|
39
47
|
/** An experiment's assignment as event properties, or nothing when it is not a pair of slugs. */
|
|
@@ -60,6 +68,8 @@ export interface AnalyticsTarget {
|
|
|
60
68
|
* Cookieless by construction: a beacon sink keeps its `distinct_id` in memory,
|
|
61
69
|
* writes no cookie and no storage, and needs no consent banner. `sendBeacon`
|
|
62
70
|
* because the events that matter most — a tap on `tel:` or `wa.me` — are
|
|
63
|
-
* followed by the page handing the visitor to another app.
|
|
71
|
+
* followed by the page handing the visitor to another app. `distinctId`:
|
|
72
|
+
* the visitor's id, held by the caller so a lead can name it; a random one
|
|
73
|
+
* per sink without it.
|
|
64
74
|
*/
|
|
65
|
-
export declare function analyticsSink(target: AnalyticsTarget, locationId: string | null): AnalyticsSink;
|
|
75
|
+
export declare function analyticsSink(target: AnalyticsTarget, locationId: string | null, distinctId?: string): AnalyticsSink;
|
package/dist/core/analytics.js
CHANGED
|
@@ -24,6 +24,10 @@ export const EVENTS = {
|
|
|
24
24
|
// An estimate's price, shown for a full set of answers: the need and the
|
|
25
25
|
// price's band (`centsBucket`), never the price — once per band and need.
|
|
26
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",
|
|
27
31
|
};
|
|
28
32
|
/**
|
|
29
33
|
* Every property name an event may carry. A customer's phone or address comes
|
|
@@ -51,7 +55,15 @@ export const ALLOWED_PROPS = [
|
|
|
51
55
|
"need",
|
|
52
56
|
// A price's band, `"5000-7500"` (`centsBucket`) — never the price.
|
|
53
57
|
"cents_bucket",
|
|
58
|
+
// The booking's provider, `manual` | `link` | `cal_com` — never its URL.
|
|
59
|
+
"provider",
|
|
54
60
|
];
|
|
61
|
+
/**
|
|
62
|
+
* The beacon's `distinct_id` as a lead may carry it to the panel
|
|
63
|
+
* (`lead.created`'s `analytics_id`), so PostHog can join the lead to the
|
|
64
|
+
* visit. The panel's own rule; anything else is dropped, never refused.
|
|
65
|
+
*/
|
|
66
|
+
export const ANALYTICS_ID = /^[A-Za-z0-9._:-]{1,128}$/;
|
|
55
67
|
/** What an experiment's assignment looks like on the wire: short slugs, nothing a person typed. */
|
|
56
68
|
export const EXPERIMENT_SLUG = /^[a-z0-9_-]{1,32}$/;
|
|
57
69
|
/** An experiment's assignment as event properties, or nothing when it is not a pair of slugs. */
|
|
@@ -69,11 +81,14 @@ export function countsAsPageView(pathname) {
|
|
|
69
81
|
* Cookieless by construction: a beacon sink keeps its `distinct_id` in memory,
|
|
70
82
|
* writes no cookie and no storage, and needs no consent banner. `sendBeacon`
|
|
71
83
|
* because the events that matter most — a tap on `tel:` or `wa.me` — are
|
|
72
|
-
* followed by the page handing the visitor to another app.
|
|
84
|
+
* followed by the page handing the visitor to another app. `distinctId`:
|
|
85
|
+
* the visitor's id, held by the caller so a lead can name it; a random one
|
|
86
|
+
* per sink without it.
|
|
73
87
|
*/
|
|
74
|
-
export function analyticsSink(target, locationId) {
|
|
88
|
+
export function analyticsSink(target, locationId, distinctId) {
|
|
75
89
|
return createBeaconSink({
|
|
76
90
|
key: target.key ?? undefined,
|
|
91
|
+
...(distinctId !== undefined ? { distinctId } : {}),
|
|
77
92
|
host: target.host,
|
|
78
93
|
allowedProps: ALLOWED_PROPS,
|
|
79
94
|
globalProps: locationId ? { brand_id: target.brandId, location_id: locationId } : { brand_id: target.brandId },
|
|
@@ -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";
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Booking, provider-agnostic: how a place's slot is set once a priced lead is
|
|
3
|
+
* stored. The vocabulary is the panel's (snake_case, closed sets) and the
|
|
4
|
+
* fixtures in `test/fixtures/booking/` are the contract it mirrors: a new
|
|
5
|
+
* provider extends the closed set with its own URL rule, and nothing else on
|
|
6
|
+
* either side changes.
|
|
7
|
+
*/
|
|
8
|
+
/**
|
|
9
|
+
* - `manual`: no external page — the site promises a call to set the slot, and
|
|
10
|
+
* the operator records it in the panel. Always available; carries no URL.
|
|
11
|
+
* - `link`: any external booking page (strict https), opened with
|
|
12
|
+
* `ref=<leadRef>` in its query.
|
|
13
|
+
* - `google_calendar`: a Google appointment schedule, opened as is — it takes
|
|
14
|
+
* no parameter, so the panel matches its bookings by contact and time.
|
|
15
|
+
* - `cal_com`: a Cal.com event, opened with the name, the phone and
|
|
16
|
+
* `metadata[ref]=<leadRef>` prefilled.
|
|
17
|
+
*/
|
|
18
|
+
export declare const BOOKING_PROVIDERS: readonly ["manual", "link", "google_calendar", "cal_com"];
|
|
19
|
+
export type BookingProvider = (typeof BOOKING_PROVIDERS)[number];
|
|
20
|
+
/** The providers with a page to open — every one but `manual`. */
|
|
21
|
+
export type PageProvider = Exclude<BookingProvider, "manual">;
|
|
22
|
+
/**
|
|
23
|
+
* A place's booking, as `PlaceLive.booking` serves it and the panel's place
|
|
24
|
+
* settings edit it: the providers it has a page on, and the one it offers by
|
|
25
|
+
* default — `manual` or one of them. An experiment (`booking_provider`) may
|
|
26
|
+
* pick another of the place's providers (`bookingOf`).
|
|
27
|
+
*/
|
|
28
|
+
export interface BookingConfig {
|
|
29
|
+
default: BookingProvider;
|
|
30
|
+
providers: Readonly<Partial<Record<PageProvider, {
|
|
31
|
+
url: string;
|
|
32
|
+
}>>>;
|
|
33
|
+
}
|
|
34
|
+
/** The booking one page offers: the provider chosen for this visitor, and its page. */
|
|
35
|
+
export type BookingChoice = {
|
|
36
|
+
provider: "manual";
|
|
37
|
+
} | {
|
|
38
|
+
provider: PageProvider;
|
|
39
|
+
url: string;
|
|
40
|
+
};
|
|
41
|
+
/**
|
|
42
|
+
* A choice a brand's own adapter makes sense of — what `LeadBooking` takes.
|
|
43
|
+
* Every `BookingChoice` is one; the closed set is what the place settings
|
|
44
|
+
* and `booking.requested@1` accept.
|
|
45
|
+
*/
|
|
46
|
+
export interface OpenBookingConfig {
|
|
47
|
+
provider: string;
|
|
48
|
+
url?: string;
|
|
49
|
+
}
|
|
50
|
+
/** A place with no booking of its own — baked or live — promises a call. */
|
|
51
|
+
export declare const DEFAULT_BOOKING: BookingConfig;
|
|
52
|
+
/** The experiment whose variant names the provider a visitor is offered, the same key on every brand. */
|
|
53
|
+
export declare const BOOKING_EXPERIMENT = "booking_provider";
|
|
54
|
+
/** A lead's booking, as the panel's projection tracks it; the site only ever says `requested`. */
|
|
55
|
+
export declare const BOOKING_STATUSES: readonly ["none", "requested", "booked", "canceled", "done", "no_show"];
|
|
56
|
+
export type BookingStatus = (typeof BOOKING_STATUSES)[number];
|
|
57
|
+
/** The part of the day a visitor may prefer for a `manual` slot. */
|
|
58
|
+
export declare const PREFERRED_PARTS: readonly ["morning", "afternoon", "evening"];
|
|
59
|
+
export type PreferredPart = (typeof PREFERRED_PARTS)[number];
|
|
60
|
+
/**
|
|
61
|
+
* The site's `booking.requested@1`: a lead asked for a slot. A provider with
|
|
62
|
+
* a page sends it when the page is opened; `manual` when the visitor sends
|
|
63
|
+
* the (optional) preference — which only `manual` carries.
|
|
64
|
+
*/
|
|
65
|
+
export interface BookingRequest {
|
|
66
|
+
/** The lead's public reference (`leadRef`) — the panel's lead id. */
|
|
67
|
+
leadRef: string;
|
|
68
|
+
provider: BookingProvider;
|
|
69
|
+
/** `YYYY-MM-DD`; `manual` only. */
|
|
70
|
+
preferredDate?: string;
|
|
71
|
+
/** `manual` only. */
|
|
72
|
+
preferredPart?: PreferredPart;
|
|
73
|
+
}
|
|
74
|
+
/** `booking.requested@1`'s properties as the panel reads them. */
|
|
75
|
+
export interface BookingRequestedProperties {
|
|
76
|
+
lead_ref: string;
|
|
77
|
+
provider: BookingProvider;
|
|
78
|
+
preferred_date?: string;
|
|
79
|
+
preferred_part?: PreferredPart;
|
|
80
|
+
}
|
|
81
|
+
/** The Cal.com hosts a `cal_com` URL may point at unless the site names its own: ours, and the hosted cloud. */
|
|
82
|
+
export declare const DEFAULT_CAL_COM_HOSTS: readonly string[];
|
|
83
|
+
/** What a validator is told besides the value: the site's (or brand's) Cal.com hosts. */
|
|
84
|
+
export interface BookingRules {
|
|
85
|
+
/** Exact hostnames, lowercase; a subdomain is another host. Default `DEFAULT_CAL_COM_HOSTS`. */
|
|
86
|
+
calComHosts?: readonly string[];
|
|
87
|
+
}
|
|
88
|
+
export declare const BOOKING_LIMITS: {
|
|
89
|
+
readonly maxUrl: 2048;
|
|
90
|
+
/** A Cal.com username or event slug: one path segment, no dot segment. */
|
|
91
|
+
readonly calComSegment: RegExp;
|
|
92
|
+
};
|
|
93
|
+
export declare function isLeadRef(value: unknown): value is string;
|
|
94
|
+
export declare function isBookingProvider(value: unknown): value is BookingProvider;
|
|
95
|
+
export declare function isPageProvider(value: unknown): value is PageProvider;
|
|
96
|
+
export declare function isPreferredPart(value: unknown): value is PreferredPart;
|
|
97
|
+
/**
|
|
98
|
+
* The one entry point for which booking a visitor is offered: the provider
|
|
99
|
+
* the experiment's variant (`booking_provider`) names, when the place has it
|
|
100
|
+
* (`manual` it always has), else the place's default. The chosen provider is
|
|
101
|
+
* what `booking.requested@1` and `lead_booking_open/done` report, so the arms
|
|
102
|
+
* compare.
|
|
103
|
+
*/
|
|
104
|
+
export declare function bookingOf(place: {
|
|
105
|
+
booking?: BookingConfig | undefined;
|
|
106
|
+
}, variant?: string | null): BookingChoice;
|