@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.
Files changed (84) hide show
  1. package/README.md +210 -17
  2. package/dist/core/accept.d.ts +19 -1
  3. package/dist/core/accept.js +25 -2
  4. package/dist/core/analytics.d.ts +13 -3
  5. package/dist/core/analytics.js +17 -2
  6. package/dist/core/booking/index.d.ts +3 -0
  7. package/dist/core/booking/index.js +3 -0
  8. package/dist/core/booking/model.d.ts +106 -0
  9. package/dist/core/booking/model.js +66 -0
  10. package/dist/core/booking/url.d.ts +17 -0
  11. package/dist/core/booking/url.js +53 -0
  12. package/dist/core/booking/validate.d.ts +27 -0
  13. package/dist/core/booking/validate.js +219 -0
  14. package/dist/core/lead-capture-text.d.ts +26 -2
  15. package/dist/core/lead-capture-text.js +32 -2
  16. package/dist/core/lead.d.ts +33 -0
  17. package/dist/core/lead.js +43 -0
  18. package/dist/core/phone.d.ts +3 -1
  19. package/dist/core/phone.js +28 -2
  20. package/dist/core/place/live.d.ts +6 -2
  21. package/dist/core/place/live.js +21 -2
  22. package/dist/core/place/types.d.ts +6 -0
  23. package/dist/core/place/types.js +0 -13
  24. package/dist/core/routing.d.ts +3 -2
  25. package/dist/core/routing.js +4 -3
  26. package/dist/core/site.d.ts +8 -0
  27. package/dist/core/site.js +15 -0
  28. package/dist/index.d.ts +4 -3
  29. package/dist/index.js +4 -3
  30. package/dist/next/booking-route.d.ts +31 -0
  31. package/dist/next/booking-route.js +97 -0
  32. package/dist/next/confirm-route.d.ts +39 -0
  33. package/dist/next/confirm-route.js +118 -0
  34. package/dist/next/index.d.ts +2 -0
  35. package/dist/next/index.js +2 -0
  36. package/dist/next/quote-route.js +14 -5
  37. package/dist/react/AnalyticsBoundary.js +8 -4
  38. package/dist/react/LeadBooking.d.ts +35 -0
  39. package/dist/react/LeadBooking.js +47 -0
  40. package/dist/react/LeadBookingManual.d.ts +17 -0
  41. package/dist/react/LeadBookingManual.js +50 -0
  42. package/dist/react/LeadCapture.d.ts +28 -5
  43. package/dist/react/LeadCapture.js +22 -8
  44. package/dist/react/LeadCaptureFields.js +60 -21
  45. package/dist/react/LeadCapturePriced.d.ts +3 -4
  46. package/dist/react/LeadCapturePriced.js +4 -4
  47. package/dist/react/analytics-context.d.ts +7 -0
  48. package/dist/react/analytics-context.js +7 -0
  49. package/dist/react/booking-adapters.d.ts +58 -0
  50. package/dist/react/booking-adapters.js +104 -0
  51. package/dist/react/index.d.ts +5 -1
  52. package/dist/react/index.js +4 -1
  53. package/dist/react/use-booking.d.ts +20 -0
  54. package/dist/react/use-booking.js +32 -0
  55. package/dist/react/use-lead-error.d.ts +3 -7
  56. package/dist/react/use-lead-error.js +9 -6
  57. package/dist/react/use-lead-submit.d.ts +7 -1
  58. package/dist/react/use-lead-submit.js +13 -3
  59. package/dist/react/use-repriced.d.ts +18 -0
  60. package/dist/react/use-repriced.js +27 -0
  61. package/dist/server/experiments-declared.d.ts +106 -0
  62. package/dist/server/experiments-declared.js +124 -0
  63. package/dist/server/experiments-source.d.ts +49 -0
  64. package/dist/server/experiments-source.js +88 -0
  65. package/dist/server/index.d.ts +4 -2
  66. package/dist/server/index.js +2 -0
  67. package/dist/server/lead-webhook.d.ts +63 -0
  68. package/dist/server/lead-webhook.js +24 -1
  69. package/dist/server/notify.d.ts +20 -2
  70. package/dist/server/notify.js +6 -4
  71. package/dist/server/place-source.js +1 -1
  72. package/dist/server/webhook-outbox.d.ts +11 -1
  73. package/dist/server/webhook-outbox.js +32 -5
  74. package/dist/testing/lead-rules.js +15 -1
  75. package/package.json +1 -1
  76. package/template/app/quote/booking/route.ts +11 -0
  77. package/template/app/quote/confirm/route.ts +12 -0
  78. package/template/flake.nix +1 -1
  79. package/template/package.json +2 -2
  80. package/template/src/shared/config/places.ts +4 -0
  81. package/template/src/shared/config/site.ts +3 -0
  82. package/template/tests/e2e/flows.spec.ts +86 -9
  83. package/template/tests/e2e/mock-panel.mjs +18 -4
  84. 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.10.0"; # the version in package-lock.json
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 "we call you to set the slot": a node or `(sent) => node` — a booking provider's widget, when the brand has one (none ships with the kit) |
236
- | `head`, `trust` | the brand's heading instead of the title; a slot beside the submit |
237
- | `className` · `classNames` | the root · its parts: `root`, `head`, `title`, `lede`, `form`, `contact`, `field`, `label`, `control` (every input, the need's select in both states, the callback's phone), `hint`, `error` (a refusal: under the field, or above the submit), `chips`, `chip`, `needs`, `need`, `summary`, `submit`, `trust`, `privacy`, `opening`, `others`, `channel`, `primary`, `callback`, `callbackSummary`, `callbackForm`, `callbackLede`, `callbackSubmit`, `consent`, `done`, and for the flows `estimate`, `estimateInput`, `estimateLegend`, `estimateOption`, `price`, `priceTotal`, `breakdown`, `priceNote`, `photos`, `priced`, `pricedPrice`, `pricedNote` |
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; full-width
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) and blocks the submit
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. A field the card
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
- promises a call to set the slot (`slotCallback`) — or shows the brand's
375
- `booking`. The lead is stored first, so an abandoned booking is still a
376
- lead to call. A `quote` need, and any callback, behave as before.
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`, `photos*`); optional in `LeadCaptureText`, so
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. `ctx.leadRef` is the lead's reference the page
554
- was answered with, whatever the switch.
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
@@ -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
- /** `field` names what to fix (`phone`, `consent`, … or `form`); `why` is for the log. */
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
@@ -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);
@@ -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;
@@ -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;