@evinvest/kitstart 0.16.0 → 0.18.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 (97) hide show
  1. package/README.md +179 -19
  2. package/dist/core/ab-gate.js +3 -1
  3. package/dist/core/ab-switcher.d.ts +8 -0
  4. package/dist/core/ab-switcher.js +11 -0
  5. package/dist/core/accept.d.ts +14 -0
  6. package/dist/core/accept.js +21 -3
  7. package/dist/core/analytics.d.ts +28 -3
  8. package/dist/core/analytics.js +46 -2
  9. package/dist/core/channels.d.ts +12 -4
  10. package/dist/core/channels.js +11 -1
  11. package/dist/core/lead-capture-text.d.ts +6 -2
  12. package/dist/core/lead.d.ts +33 -10
  13. package/dist/core/lead.js +21 -8
  14. package/dist/core/messenger-message.d.ts +31 -0
  15. package/dist/core/messenger-message.js +33 -0
  16. package/dist/core/messenger-text.d.ts +104 -0
  17. package/dist/core/messenger-text.js +150 -0
  18. package/dist/core/messenger.d.ts +92 -0
  19. package/dist/core/messenger.js +55 -0
  20. package/dist/core/place/index.d.ts +1 -1
  21. package/dist/core/place/live.d.ts +5 -1
  22. package/dist/core/place/live.js +22 -1
  23. package/dist/core/place/types.d.ts +17 -1
  24. package/dist/core/site.d.ts +8 -1
  25. package/dist/core/site.js +22 -1
  26. package/dist/index.d.ts +9 -6
  27. package/dist/index.js +8 -5
  28. package/dist/next/quote-route.d.ts +7 -0
  29. package/dist/next/quote-route.js +9 -9
  30. package/dist/react/AbSwitcherPanel.d.ts +3 -1
  31. package/dist/react/AbSwitcherPanel.js +10 -6
  32. package/dist/react/AnalyticsBoundary.d.ts +3 -2
  33. package/dist/react/AnalyticsBoundary.js +7 -20
  34. package/dist/react/LeadCapture.d.ts +24 -1
  35. package/dist/react/LeadCapture.js +96 -11
  36. package/dist/react/LeadCaptureChannels.d.ts +7 -2
  37. package/dist/react/LeadCaptureChannels.js +2 -2
  38. package/dist/react/LeadCaptureEstimate.d.ts +6 -0
  39. package/dist/react/LeadCaptureFields.d.ts +17 -0
  40. package/dist/react/LeadCaptureFields.js +42 -33
  41. package/dist/react/LeadCaptureMessenger.d.ts +41 -0
  42. package/dist/react/LeadCaptureMessenger.js +133 -0
  43. package/dist/react/LeadCapturePrice.d.ts +9 -3
  44. package/dist/react/LeadCapturePrice.js +24 -4
  45. package/dist/react/LeadCaptureSubmit.d.ts +3 -0
  46. package/dist/react/LeadCaptureSubmit.js +2 -2
  47. package/dist/react/LeadCaptureTiles.js +9 -4
  48. package/dist/react/ab-switcher-types.d.ts +1 -1
  49. package/dist/react/analytics-context.d.ts +9 -3
  50. package/dist/react/analytics-context.js +7 -3
  51. package/dist/react/index.d.ts +1 -0
  52. package/dist/react/messenger/action.d.ts +144 -0
  53. package/dist/react/messenger/action.js +301 -0
  54. package/dist/react/messenger/chip.d.ts +8 -0
  55. package/dist/react/messenger/chip.js +22 -0
  56. package/dist/react/messenger/fallback.d.ts +8 -0
  57. package/dist/react/messenger/fallback.js +11 -0
  58. package/dist/react/messenger/saga.d.ts +10 -0
  59. package/dist/react/messenger/saga.js +33 -0
  60. package/dist/react/messenger/segment.d.ts +8 -0
  61. package/dist/react/messenger/segment.js +15 -0
  62. package/dist/react/messenger/select.d.ts +10 -0
  63. package/dist/react/messenger/select.js +26 -0
  64. package/dist/react/messenger/sheet.d.ts +10 -0
  65. package/dist/react/messenger/sheet.js +57 -0
  66. package/dist/react/messenger/split.d.ts +9 -0
  67. package/dist/react/messenger/split.js +17 -0
  68. package/dist/react/messenger/swap.d.ts +10 -0
  69. package/dist/react/messenger/swap.js +18 -0
  70. package/dist/react/messenger/thanks.d.ts +10 -0
  71. package/dist/react/messenger/thanks.js +18 -0
  72. package/dist/react/messenger/tiles.d.ts +8 -0
  73. package/dist/react/messenger/tiles.js +20 -0
  74. package/dist/react/messenger/types.d.ts +98 -0
  75. package/dist/react/messenger/types.js +1 -0
  76. package/dist/react/messenger/urgency.d.ts +10 -0
  77. package/dist/react/messenger/urgency.js +29 -0
  78. package/dist/react/use-lead-error.d.ts +3 -3
  79. package/dist/react/use-lead-events.d.ts +21 -1
  80. package/dist/react/use-lead-events.js +9 -3
  81. package/dist/react/use-lead-submit.d.ts +20 -6
  82. package/dist/react/use-lead-submit.js +23 -1
  83. package/dist/server/index.d.ts +1 -1
  84. package/dist/server/lead-store-sqlite.js +11 -5
  85. package/dist/server/lead-webhook.d.ts +30 -5
  86. package/dist/server/lead-webhook.js +13 -7
  87. package/dist/server/notify.js +11 -5
  88. package/dist/testing/index.d.ts +1 -1
  89. package/dist/testing/index.js +1 -1
  90. package/dist/testing/landing-contract.js +6 -1
  91. package/dist/testing/lead-rules.d.ts +8 -0
  92. package/dist/testing/lead-rules.js +30 -1
  93. package/package.json +5 -2
  94. package/template/app/quote/route.ts +3 -0
  95. package/template/flake.nix +1 -1
  96. package/template/package.json +2 -2
  97. package/template/tests/e2e/ab-switcher.spec.ts +2 -0
package/README.md CHANGED
@@ -45,7 +45,7 @@ brand's `flake.nix` is its own config plus one call — `template/flake.nix` is
45
45
  the whole of one:
46
46
 
47
47
  ```nix
48
- inputs.ev.url = "github:EV-invest/lib?ref=@evinvest/kitstart-v0.16.0"; # the version in package-lock.json
48
+ inputs.ev.url = "github:EV-invest/lib?ref=@evinvest/kitstart-v0.18.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 {
@@ -237,10 +237,10 @@ it is the plain POST to `/quote` it always was.
237
237
  | `needDisplay` | how the need is asked: `select` (default on `single`) · `tiles` (default otherwise) · `cards`, a grid with each need's `icon` |
238
238
  | `intro`, `localityStep` | `steps` only: a question before the need, posted as a brand's extra, whose answer may turn the form into a callback · the postcode on its own screen (`own`, default) or on the phone's (`with-phone`) |
239
239
  | `focusNext` | on one screen: after a choice the focus moves to the next empty field, and Enter in a field moves to the next empty one before it submits. Off by default; always so in `steps` |
240
- | `questions` | by estimate input id: `{ display?: "tiles" \| "cards", unknown?: true, unknownSpan?: 2, badges?: { [option]: text }, shortLabels?: { [option]: text }, step?: number, next?: string }` — see [The compact form](#the-compact-form) and [steps](#one-question-per-screen-layoutsteps) |
240
+ | `questions` | by estimate input id: `{ display?: "tiles" \| "cards", unknown?: true, unknownSpan?: 2, badges?: { [option]: text }, shortLabels?: { [option]: text }, previewLabels?: { [option]: text }, step?: number, next?: string }` — see [The compact form](#the-compact-form) and [steps](#one-question-per-screen-layoutsteps) |
241
241
  | `price`, `taxCredit` | `box` (default) · `compact`: the price on one line, what is left after a tax credit of `taxCredit` (a ratio, `0.5`), the breakdown behind "Détail" |
242
242
  | `afterPhone` | a node right under the phone field: one line of reassurance |
243
- | `channelsDisplay`, `channelIcons` | the other ways out: `stack` (default, under `otherChannels`) · `row`, one row of compact buttons named by `otherChannels` · the brand's icon per channel (`phone`, `whatsapp`, `sms`, `callback`) |
243
+ | `channelsDisplay`, `channelIcons` | the other ways out: `stack` (default, under `otherChannels`) · `row`, one row of compact buttons named by `otherChannels` · the brand's icon per channel (`phone`, `whatsapp`, `sms`, `callback`, `telegram`) |
244
244
  | `locality` | `required` (default) · `optional` |
245
245
  | `name` | `{ field, required? }`: a name field posted as the brand's extra; off by default |
246
246
  | `extras` | the brand's own fields, after the phone |
@@ -257,7 +257,8 @@ it is the plain POST to `/quote` it always was.
257
257
  | `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) |
258
258
  | `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*) |
259
259
  | `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 |
260
- | `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`, `icon` (a need's or an intro answer's icon), `submit`, `trust`, `privacy` (not drawn when `text.privacy` is `""`), `opening`, `others`, `channel`, `channelIcon`, `primary`, `callback`, `callbackSummary`, `callbackForm`, `callbackLede`, `callbackSubmit`, `consent`, `done`, for `steps` `progress`, `stepBack`, `step` (each screen), `answers`, `answer` (the chips of the screens answered), `stepNext`, `intro`, `introOption`, and for the flows `estimate`, `estimateInput`, `estimateLegend`, `estimateGrid` (an input's answers: 2 columns, 3 from `sm`), `estimateOption` (an answer's tile), `estimateUnknown` (the "I don't know" tile, after `estimateOption`), `badge`, `optionPrice` (a card's total), `price`, `priceTotal`, `breakdown`, `priceNote`, `priceLine`, `priceTaxCredit`, `priceDetail` (`price="compact"`), `photos`, `priced`, `pricedPrice`, `pricedNote`, and for the booking `booking`, `bookingCta`, `bookingNote`, `prefer`, `preferOption`, `preferSubmit` |
260
+ | `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`, `icon` (a need's or an intro answer's icon), `submit`, `trust`, `privacy` (not drawn when `text.privacy` is `""`), `opening`, `others`, `channel`, `channelIcon`, `primary`, `callback`, `callbackSummary`, `callbackForm`, `callbackLede`, `callbackSubmit`, `consent`, `done`, for `steps` `progress`, `stepBack`, `step` (each screen), `answers`, `answer` (the chips of the screens answered), `stepNext`, `intro`, `introOption`, and for the flows `estimate`, `estimateInput`, `estimateLegend`, `estimateGrid` (an input's answers: 2 columns, 3 from `sm`), `estimateOption` (an answer's tile), `estimateUnknown` (the "I don't know" tile, after `estimateOption`), `badge`, `optionPrice` (a card's total), `price`, `priceTotal`, `breakdown`, `priceNote`, `priceLine`, `priceTaxCredit`, `priceDetail` (`price="compact"`: the «Détail» trigger), `priceDetailContent` (its popover), `photos`, `priced`, `pricedPrice`, `pricedNote`, and for the booking `booking`, `bookingCta`, `bookingNote`, `prefer`, `preferOption`, `preferSubmit`, and for a `messenger` variant `messenger`, `messengerSlot`, `messengerCta`, `messengerSecondary`, `messengerSquare`, `messengerSegment`, `messengerTile`, `messengerTrigger`, `messengerPreview`, `messengerPicker`, `messengerOption`, `messengerQr`, `messengerReturn`, `messengerHint` |
261
+ | `messenger`, `messengers`, `refPrefix`, `brand`, `messengerTiming` | the `lead_channel` arm (`MessengerVariant`, absent → the control) · `messengerFacts(site, place)` · the chat reference's prefix (`AQ`) · the message's greeting · the estimate's question whose answer is the message's timing line (`{ input: "frequency" }`) — see *Messengers* below |
261
262
 
262
263
  - **Taps.** A need the page knows is not asked again, and a place serving one
263
264
  commune fills it: focus the phone, type, send — two taps. `qualify-first`
@@ -349,16 +350,140 @@ it is the plain POST to `/quote` it always was.
349
350
  (`contact` / `need` in `qualify-first`; in `steps` each screen moved to —
350
351
  `intro`, `need`, `estimate_<input>`, `locality`, `phone`), each with `form_id`, `layout`, `experiment`,
351
352
  `variant`; `contact_intent_click {channel}` for `phone`, `whatsapp`, `sms`,
352
- `callback`, with the experiment (and `forced: true` on a test visit, as
353
- `location_page_view`, when the boundary has `qaCookie` — see `AbSwitcher`);
354
- and on the server `lead_form_submit
353
+ `callback`, with the experiment; and on the server `lead_form_submit
355
354
  {form_id, channel}` and `lead_form_reject {form_id, channel, field,
356
- reason}` with the posted experiment.
355
+ reason}` with the posted experiment. On a test visit every one of them —
356
+ page view, intents, funnel, booking, and the server's two when
357
+ `quoteRoute` has `qaCookie` — carries `forced: true` (see `AbSwitcher`).
357
358
  - **Weight.** 8.9 KB gz of first-load JS on the template's place page
358
359
  (157,287 → 166,361 B against its 158,000 B target: +5.3 %, a warning
359
360
  within the 20 % tolerance); the refusals and the shared phone rule are
360
361
  2.0 KB of it, sending (the id, the busy state, the retry) 0.6 KB.
361
362
 
363
+ ### Messengers: `messenger`, WhatsApp and the bot
364
+
365
+ The `lead_channel` experiment's arms. WhatsApp: the customer sends the brand's
366
+ prefilled message, answered by hand in WhatsApp Business. Telegram: the
367
+ brand's bot, opened as `t.me/<bot>?start=<ref>`. The phone is asked only for
368
+ a call; no text messages. Either way the lead is **posted first**, in the
369
+ background, so the panel has it before the chat starts.
370
+
371
+ ```tsx
372
+ import { messengerFacts } from "@evinvest/kitstart";
373
+
374
+ <LeadCapture
375
+ {...controlProps}
376
+ messenger={arm === "c" ? { kind: "segment" } : undefined} // undefined → the control
377
+ messengers={messengerFacts(site, place)} // the place's own WhatsApp, its bot — each `null` when off
378
+ // (a bot's name: `TELEGRAM_BOT`, 5–32 chars ending in `bot`)
379
+ refPrefix="AQ" // the lead's chat reference: AQ-7K3F
380
+ brand="Aquafix" // the message's greeting
381
+ channelIcons={{ whatsapp: <WhatsAppIcon />, telegram: <TelegramIcon />, callback: <PhoneIcon /> }}
382
+ classNames={{ messengerSlot: "h-[92px]" }} // every state of a variant one height
383
+ />
384
+ ```
385
+
386
+ | `kind` | Figma | the card |
387
+ |---|---|---|
388
+ | `select` `{side: "prefix" \| "suffix"}` | AQ-1 / VF-1 | the channel picked in the phone field (the kit's `Select`, an overlay): WhatsApp — the number optional, the button opens the chat; Appel — the control; Telegram — no number, the button opens the bot |
389
+ | `segment` | AQ-2 | «WhatsApp \| Appel» over the slot (the message ready / the phone); «ou via Telegram» under the button |
390
+ | `tiles` | VF-2 | «Recevoir mon devis par» and a tile per channel over the slot (the message / what the bot does / the phone) |
391
+ | `thanks` | AQ-3 | the control's form; the in-card success adds a photo on WhatsApp (a QR code on a computer) and the bot. The card stays (`done`) |
392
+ | `swap` | AQ-4 / VF-3 | no phone: the WhatsApp button, then [Telegram][Être rappelé]; «Être rappelé» swaps in the phone and [WhatsApp square][submit] |
393
+ | `saga` | AQ-5 | the channel first, on a screen of its own (cards), then the job and the channel's slot; «Changer de canal» back |
394
+ | `urgency` `{field}` | AQ-6 | «C’est urgent ?» — posted as the brand's extra `field` (`today` / `later`; list it in `site.lead.extras`) — today a call, «je compare» the message |
395
+ | `sheet` | VF-4 | the estimate, the postcode and one button; the kit's `Drawer`: WhatsApp / Telegram / a call, the phone asked in the drawer |
396
+ | `chip` | VF-5 | the lede becomes a chip «Réponse sur WhatsApp ▾» (a `Select` overlay); the slot follows it |
397
+ | `split` | VF-6 | the slot over one row: «Devis sur WhatsApp» with [Telegram][📞] squares; 📞 turns it into «Être rappelé» with [WhatsApp][Telegram] |
398
+
399
+ - **Fallback** (Figma «Выключено в панели»). No WhatsApp for the place — no
400
+ number of its own, or `messengers.whatsapp: false` from the panel — and
401
+ every kind is the control, with «ou via Telegram» under the submit when the
402
+ bot is on; `urgency` keeps its question (both answers ask the phone) and
403
+ `thanks` keeps the bot. No bot — every Telegram piece is left out. The arm
404
+ stays assigned; every event says what the card had: `channels_available`
405
+ (`wa,tg` | `wa` | `tg` | `none`). The switches are the form's only: `contactOf` — the
406
+ header, the call bar, the control's own WhatsApp link — keeps the number.
407
+ - **The reference** `message_ref`: `<refPrefix>-<4 Crockford base32>`,
408
+ minted in the browser once the script runs, and again only once the
409
+ visitor is back from the chat and has answered «Message envoyé ?» — never
410
+ in the tap: the reference posted is the one in the link, the QR code and
411
+ the return screen, and a second tap on the same lead is the same lead; the WhatsApp message ends with `Réf. AQ-7K3F`, the bot's
412
+ `start` is the reference itself. Posted with every lead of the card (a
413
+ `thanks` success sends the form lead's own), stored (`message_ref`, schema
414
+ 8), mailed (`Réf.`), and sent to the panel under `panelMessenger`.
415
+ - **The message** (`messengerMessage`, ≤ 400 characters, nothing typed about
416
+ the person): `Bonjour {brand} 👋`, the need with the estimate's answers
417
+ («Ménage standard · 2 ch. · 40–70 m²»: each answer as
418
+ `questions[id].previewLabels` says it — a tile's «2» is «2 ch.» here — else
419
+ its `shortLabels`, else its label; «Je ne sais pas» left out),
420
+ the postcode, the estimate shown (a priced need), the timing, `Réf. {ref}` —
421
+ a line without its value left out. The slot shows it as a preview, so the
422
+ visitor sees there is nothing to write: «Bonjour Vifnet 👋 · Ménage
423
+ standard · 2 ch. · 40–70 m² · 2 sem. · env. 77 € · 63130 · Réf. VF-7K3F»
424
+ (the timing by its `previewLabels` / `shortLabels`, the price through `messengerPreviewPrice`,
425
+ `"env. {price}"`). The timing is `urgency`'s answer («aujourd’hui» / «pas
426
+ pressé»), else the answer to the estimate's question `messengerTiming`
427
+ names — `messengerTiming={{ input: "frequency" }}`, its whole label on a line
428
+ of its own, out of the need's list; none until it is answered, nor for «Je
429
+ ne sais pas». Each line is a key a brand rewords: `{ ...LEAD_CAPTURE_TEXT.fr,
430
+ messageTiming: "Fréquence : {timing}", messagePrice: "Estimation vue sur le
431
+ site : env. {price} / passage" }`.
432
+ - **Icons** (`channelIcons`): `whatsapp` and `telegram` before their
433
+ buttons, items, tiles and segments; `phone` (else `callback`) before every
434
+ call inside a variant — «Être rappelé», its submit, the squares.
435
+ - **The tap.** A real `<a href>` — the OS opens the app from the tap. A
436
+ number typed must be one (the browser says so, nothing leaves). The lead is
437
+ posted (`keepalive`, `channel=whatsapp|telegram`, the reference, what was
438
+ filled; the same submission id for a second tap). On a computer (`hover:
439
+ hover`, `pointer: fine`, ≥ 768 px) WhatsApp becomes a QR code in the slot
440
+ (`qrcode-generator`, loaded on that tap only) with «ou ouvrir WhatsApp Web»
441
+ and «Être rappelé»; the bot opens in a new tab. In an app's own browser
442
+ (Instagram, Facebook, TikTok, Line, Snapchat) a hint says how to leave it,
443
+ with «Copier le message».
444
+ - **Back on the page** (`visibilitychange`), the card asks «Message
445
+ envoyé ?» over the form: reopen the chat, «Je n’ai pas pu envoyer → être
446
+ rappelé» (the variant's call state, the phone focused), or «C’est envoyé».
447
+ - **The server.** A `whatsapp` / `telegram` lead needs no phone (one typed
448
+ is held to the rule) and no consent, and is never sent back for a changed
449
+ price; `validateLead` knows the channel. A brand's own rule must not
450
+ refuse one for the postcode or the phone it was not asked — the testing
451
+ subpath's `messengerRuleDisagreements(site.lead)` lists where it does.
452
+ - **Words**: `LeadCaptureMessengerText` — optional keys of the card's
453
+ `text`, a key left out the kit's in the page's language (`messengerTextOf`;
454
+ the defaults are `LEAD_CAPTURE_MESSENGER_TEXT`, kept out of
455
+ `LEAD_CAPTURE_TEXT` so the control's island never carries them). A brand
456
+ overrides by spreading: `{ ...LEAD_CAPTURE_TEXT.fr, messengerWhatsappCta: "Envoyer sur WhatsApp →" }`. **Parts**:
457
+ `messenger` (the variant's column), `messengerSlot` (the fixed box),
458
+ `messengerCta` (the main button: a messenger's, or the call's submit inside
459
+ the variant — its height and type, as `channel` / `primary` are the
460
+ control's), `messengerSecondary` (Telegram and «Être rappelé» beside it,
461
+ «ou via Telegram», «Rouvrir WhatsApp», the QR's «Être rappelé»),
462
+ `messengerSquare` (the icon squares of `split` / `swap`), `messengerSegment`
463
+ (a `segment` item), `messengerTile` (a `tiles` tile, an `urgency` answer) —
464
+ both after `messengerOption`, `messengerTrigger` (the channel picker's
465
+ button: `select`'s in the field — its tint — and `chip`'s),
466
+ `messengerPreview` (the message, the bot's card), `messengerPicker` (the
467
+ segment, the tiles, `select`'s field box, the chip, the drawer's body),
468
+ `messengerOption` (a picker's item, a `saga` / `sheet` card, a segment's
469
+ item, a tile), `messengerQr`,
470
+ `messengerReturn`, `messengerHint`. A part with a size keeps its line
471
+ height (`leading-6`) unless it sets one.
472
+ - **Events**: every `LeadCapture` event carries `channels_available` and,
473
+ with a variant, `messenger_variant`; `lead_messenger_open {channel, device,
474
+ inapp, message_ref}`, `lead_messenger_return {channel, answer: sent |
475
+ failed, message_ref}`, `contact_intent_click {channel: telegram |
476
+ whatsapp_qr}`; `lead_form_submit` has `message_ref` and the posted
477
+ `channels_available`.
478
+ - **Weight.** Each kind is its own chunk, fetched only by the arm that draws
479
+ it; the control pays for the dispatch alone, and the QR encoder for nobody
480
+ until a desktop tap. The server writes the variant into the page whole (no
481
+ boundary of its own: React would reveal a large one late, the contact block
482
+ missing for a frame); hydrating, the card waits for its variant's chunk,
483
+ the server's markup kept as it is. Until the chunk and the reference are
484
+ there, its messenger links lead nowhere (`aria-disabled`, no `href`); a
485
+ browser without scripts gets the control's phone and submit (`<noscript>`).
486
+
362
487
  ### One question per screen: `layout="steps"`
363
488
 
364
489
  ```tsx
@@ -417,8 +542,13 @@ Pieces any layout takes, for a card that fits a phone's screen:
417
542
  {price}" — a brand may write "≈ {price}"), then, with `taxCredit` (a ratio
418
543
  of the price a tax credit gives back, `0.5` for the French *crédit
419
544
  d'impôt*), what is left to pay (`priceTaxCredit`, `data-credit-cents`),
420
- and the breakdown and `priceNote` behind a `<details>` ("Détail",
421
- `priceDetail`). Read out as it changes, the line only.
545
+ and the breakdown and `priceNote` behind «Détail» (`text.priceDetail`): the
546
+ kit's `Popover`, over the card — opening it moves nothing — closed by a tap
547
+ outside or Escape and kept 8 px inside the screen; without a script (and
548
+ until hydration) a `<details>` opening in place. Parts: `priceDetail` the
549
+ trigger in both (the button, or the summary — style it directly, not
550
+ through `[&>summary]`), `priceDetailContent` the popover's box (or the
551
+ details' body). Read out as it changes, the line only.
422
552
  - **A question as cards** (`questions: { frequency: { display: "cards" } }`):
423
553
  one answer a line, each with the total it would make given the other
424
554
  answers (once they are given), and an optional `badges` tag per option.
@@ -777,10 +907,17 @@ export const POST = quoteRoute(site, { env: serverEnv, notifier, webhook, unavai
777
907
  `LEAD_WEBHOOK_KEY_ID` and `LEAD_WEBHOOK_SECRET` are required, and the URL
778
908
  must be `https:`, or `http:` to a `*.svc` / `*.svc.cluster.local` host or
779
909
  loopback — the body carries PII. All checked at boot.
780
- - **The channel.** `channelOf(lead)` is `form` or `callback`. For the
781
- Service-Arb panel, map it through `panelChannel`: its `properties.channel`
782
- is a closed set (`form` | `phone_inbound` | `callback`) that refuses the
783
- whole event outside it, and a callback goes as `callback` (panel v0.3.0 on).
910
+ - **The channel.** `channelOf(lead)` is `form`, `callback`, `whatsapp` or
911
+ `telegram`. For the Service-Arb panel use `ctx.channel` (or
912
+ `panelChannel(channel, panelMessenger)`): its `properties.channel` is a
913
+ closed set that refuses the whole event outside it; a callback goes as
914
+ `callback` (panel v0.3.0 on), a messenger lead as `form` until
915
+ `panelMessenger` is on.
916
+ - **Messenger leads: `panelMessenger`, off.** On: `ctx.channel` says
917
+ `whatsapp` / `telegram`, and `ctx.messageRef` carries the lead's chat
918
+ reference for `properties.message_ref`. Off (the default, until the panel
919
+ takes them): a messenger lead goes as a `form`, without its reference —
920
+ the leads table and the mail keep both either way.
784
921
  - **Queued before the 303, sent after it.** The body is built once, from the
785
922
  lead and `ctx` (`leadId`, `brandId`, `locale`, `formId`, `at`, and a fresh
786
923
  `idempotencyKey` for the receiver to deduplicate by), and written to the
@@ -921,6 +1058,15 @@ adds no server path: a tap on a variant goes to the same URL with
921
1058
  reads `ab_<key>` from `document.cookie` (the normal case: the page stays
922
1059
  static, the server reads no cookie). `forceParam` — `ab_` by default.
923
1060
  `text` — the menu's words, English by default.
1061
+ - **An experiment with no assignment is off.** The proxy assigns every
1062
+ running experiment on the page the menu mounts on, so one with no
1063
+ `ab_<key>` cookie (or missing from `current`) is shown `not running`
1064
+ (`text.unassigned`), its variants disabled — the proxy would refuse the
1065
+ force — and `–` on the chip. A test the panel only **paused** keeps its
1066
+ visitors' cookies (dropping them would redraw everyone on resume), so the
1067
+ brand's proxy lists paused tests for a QA browser only in the cookie
1068
+ `<qaCookie>_off` (`ab__qa_off=lead_form,hero`); those read `not running`
1069
+ too (`abRunning`).
924
1070
  - **Turning it on, on a phone, in production:** open any page with
925
1071
  `?ab_<key>=<value>`. The proxy sets the QA cookie and the chip appears;
926
1072
  every visit after is tagged as a test in analytics. Outside production
@@ -938,16 +1084,30 @@ adds no server path: a tap on a variant goes to the same URL with
938
1084
  too. **Minimize** and **Hide** last until the next load.
939
1085
  - Screenshots: the chip and its panel carry `data-ab-switcher`; hide it in the
940
1086
  section stylesheet, `[data-ab-switcher] { display: none !important; }`.
941
- - **Pass the same `qaCookie` to `AnalyticsBoundary`.** Every tap in the menu
942
- is a full reload, so a test visit would add page views to the place's
943
- traffic; with the cookie set, the boundary's `location_page_view` and
944
- `contact_intent_click` carry `forced: true`. Without the prop, or without
945
- the cookie, the events are as before — no `forced` key at all.
1087
+ - **Pass the same `qaCookie` to `AnalyticsBoundary` and `quoteRoute`
1088
+ (#219).** Every tap in the menu is a full reload, so a test visit would add
1089
+ page views — and form events — to the place's traffic. The mark is the
1090
+ sink's, not each event's: `analyticsSink(target, slug, id, qaCookie)` reads
1091
+ the cookie at every capture and adds `forced: true` on a test visit, so the
1092
+ boundary's page views and intents, the lead form's funnel
1093
+ (`lead_form_view` … `lead_estimate_shown`) and the booking's
1094
+ `lead_booking_open` / `lead_booking_done` all say it, and so will any event
1095
+ added later. `quoteRoute`'s `qaCookie` reads the post's `Cookie` header the
1096
+ same way for `lead_form_submit` and `lead_form_reject`. A test visit is the
1097
+ cookie set with a non-empty value — any value — on both sides
1098
+ (`qaVisit(cookies, name)`, exported for a brand's own server events).
1099
+ Without the prop, or without the cookie, the events are as before — no
1100
+ `forced` key at all.
946
1101
 
947
1102
  ```tsx
948
1103
  <AnalyticsBoundary target={target} placeSlug={slug} qaCookie="ab__qa">
949
1104
  ```
950
1105
 
1106
+ ```ts
1107
+ // app/quote/route.ts
1108
+ export const POST = quoteRoute(site, { env: serverEnv, notifier, unavailable, qaCookie: "ab__qa" });
1109
+ ```
1110
+
951
1111
  The decisions are plain functions in the core (`abSwitcherVisible`,
952
1112
  `abVariantUrl`, `abReset`, `abAssignments`), for a brand's own tests.
953
1113
 
@@ -11,7 +11,9 @@
11
11
  export function abSwitcherVisible(cookies, qaCookie, nodeEnv) {
12
12
  if (nodeEnv !== "production")
13
13
  return true;
14
- // Presence and non-empty only, no decoding: every visitor runs this.
14
+ // Presence and non-empty only, no decoding: every visitor runs this. The
15
+ // rule is `qaVisit`'s (analytics.ts), copied so the switcher and the
16
+ // analytics island share no module; `forced.node.test.ts` holds them equal.
15
17
  return cookies.split(";").some(pair => {
16
18
  const eq = pair.indexOf("=");
17
19
  return eq > 0 && pair.slice(0, eq).trim() === qaCookie && pair.slice(eq + 1).trim() !== "";
@@ -16,6 +16,14 @@ export declare const AB_FORCE_PARAM = "ab_";
16
16
  export declare function cookieValue(cookies: string, name: string): string | null;
17
17
  /** Each key's assigned variant read off the cookies; a key with none is left out. */
18
18
  export declare function abAssignments(cookies: string, keys: readonly string[]): Record<string, string>;
19
+ /**
20
+ * `assigned` without the tests the brand's panel has paused. The brand's proxy
21
+ * lists them, for a QA browser only, in the cookie `<qaCookie>_off`
22
+ * (`lead_form,hero`): a paused test keeps every visitor's assignment cookie —
23
+ * dropping it would redraw them on resume — so its cookie alone cannot tell
24
+ * the menu it is off, and a force of it is refused.
25
+ */
26
+ export declare function abRunning(assigned: Readonly<Record<string, string>>, cookies: string, qaCookie: string): Record<string, string>;
19
27
  /** The same page forcing `key` to `value`: the other parameters and the hash kept, an earlier force replaced. */
20
28
  export declare function abVariantUrl(href: string, key: string, value: string, forceParam?: string): string;
21
29
  export interface AbReset {
@@ -38,6 +38,17 @@ export function abAssignments(cookies, keys) {
38
38
  }
39
39
  return out;
40
40
  }
41
+ /**
42
+ * `assigned` without the tests the brand's panel has paused. The brand's proxy
43
+ * lists them, for a QA browser only, in the cookie `<qaCookie>_off`
44
+ * (`lead_form,hero`): a paused test keeps every visitor's assignment cookie —
45
+ * dropping it would redraw them on resume — so its cookie alone cannot tell
46
+ * the menu it is off, and a force of it is refused.
47
+ */
48
+ export function abRunning(assigned, cookies, qaCookie) {
49
+ const paused = new Set((cookieValue(cookies, `${qaCookie}_off`) ?? "").split(","));
50
+ return Object.fromEntries(Object.entries(assigned).filter(([key]) => !paused.has(key)));
51
+ }
41
52
  /** The same page forcing `key` to `value`: the other parameters and the hash kept, an earlier force replaced. */
42
53
  export function abVariantUrl(href, key, value, forceParam = AB_FORCE_PARAM) {
43
54
  const url = new URL(href);
@@ -1,6 +1,7 @@
1
1
  import { type RateLimiter } from "./antispam.js";
2
2
  import { type Lead, type LeadChannel } from "./lead.js";
3
3
  import type { PricingModel } from "./pricing/model.js";
4
+ import type { ChannelsAvailable } from "./messenger.js";
4
5
  import type { Site } from "./site.js";
5
6
  /** Hidden fields the form carries besides what the visitor types. */
6
7
  export declare const LOCATION_FIELD = "location";
@@ -9,6 +10,12 @@ export declare const FORM_ID_FIELD = "form_id";
9
10
  /** The site's experiment assignment, posted so the submit counts in the right arm. */
10
11
  export declare const EXPERIMENT_FIELD = "experiment";
11
12
  export declare const VARIANT_FIELD = "variant";
13
+ /**
14
+ * Which messengers the card offered (`channels_available`), posted so the
15
+ * submit's event says it as the card's own events do: an arm whose place had
16
+ * no WhatsApp drew the control, and its numbers must not mix with the arm's.
17
+ */
18
+ export declare const CHANNELS_FIELD = "channels_available";
12
19
  /**
13
20
  * The page's analytics id (`ANALYTICS_ID`), posted by the script only: the
14
21
  * webhook's `ctx.analyticsId`, for `lead.created`'s `analytics_id`. Never
@@ -26,7 +33,14 @@ export declare const PRICE_CHANGED = "price_changed";
26
33
  export interface SubmitTags {
27
34
  experiment?: string;
28
35
  variant?: string;
36
+ channels_available?: ChannelsAvailable;
29
37
  }
38
+ /**
39
+ * What a post says about the card it came from, for its events: the site's
40
+ * assignment and the messengers the card offered — slugs only, anything else
41
+ * dropped.
42
+ */
43
+ export declare function submitTagsOf(form: FormData): SubmitTags;
30
44
  export type Outcome<L extends string> =
31
45
  /**
32
46
  * `ref`: the lead's public reference (`leadRef`). `duplicate`: a resend of
@@ -1,6 +1,6 @@
1
1
  import { ANALYTICS_ID, experimentProps } from "./analytics.js";
2
2
  import { HONEYPOT_FIELD, LEGACY_HONEYPOT_FIELDS, RENDERED_AT_FIELD, screen } from "./antispam.js";
3
- import { channelOf, MAX_FIELD, readCandidate, validateCandidate } from "./lead.js";
3
+ import { channelOf, isMessengerChannel, MAX_FIELD, readCandidate, validateCandidate } from "./lead.js";
4
4
  import { answeredUnknown, flowOf, readEstimateInputs } from "./pricing/flow.js";
5
5
  import { priceOf } from "./pricing/price.js";
6
6
  /** Hidden fields the form carries besides what the visitor types. */
@@ -10,6 +10,12 @@ 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
+ * Which messengers the card offered (`channels_available`), posted so the
15
+ * submit's event says it as the card's own events do: an arm whose place had
16
+ * no WhatsApp drew the control, and its numbers must not mix with the arm's.
17
+ */
18
+ export const CHANNELS_FIELD = "channels_available";
13
19
  /**
14
20
  * The page's analytics id (`ANALYTICS_ID`), posted by the script only: the
15
21
  * webhook's `ctx.analyticsId`, for `lead.created`'s `analytics_id`. Never
@@ -23,6 +29,16 @@ export const ANALYTICS_ID_FIELD = "analytics_id";
23
29
  export const SHOWN_CENTS_FIELD = "shown_cents";
24
30
  /** The refusal of a lead shown at a price that has since changed (`lead_error=price_changed`). */
25
31
  export const PRICE_CHANGED = "price_changed";
32
+ const CHANNELS_AVAILABLE = ["wa,tg", "wa", "tg", "none"];
33
+ /**
34
+ * What a post says about the card it came from, for its events: the site's
35
+ * assignment and the messengers the card offered — slugs only, anything else
36
+ * dropped.
37
+ */
38
+ export function submitTagsOf(form) {
39
+ const channels = CHANNELS_AVAILABLE.find(c => c === form.get(CHANNELS_FIELD));
40
+ return { ...experimentProps(field(form, EXPERIMENT_FIELD), field(form, VARIANT_FIELD)), ...(channels ? { channels_available: channels } : {}) };
41
+ }
26
42
  const FORM_ID = /^[a-z0-9_-]{1,32}$/;
27
43
  function field(form, name) {
28
44
  const value = form.get(name);
@@ -134,7 +150,9 @@ async function accept(site, form, clientKey, deps) {
134
150
  // is refused with the fresh price, for the visitor to confirm. A page from
135
151
  // before the field posts none, and is taken as before.
136
152
  const shown = field(form, SHOWN_CENTS_FIELD);
137
- if (sale.price && shown !== null && /^\d{1,12}$/.test(shown) && Number(shown) !== sale.price.cents) {
153
+ // A messenger lead is posted as the visitor leaves for the chat: nobody is
154
+ // left to confirm a price, so it is taken at the server's, as any quote is.
155
+ if (sale.price && !isMessengerChannel(candidate.channel) && shown !== null && /^\d{1,12}$/.test(shown) && Number(shown) !== sale.price.cents) {
138
156
  deps.log.warn(`quote: a ${sale.flow} for ${candidate.subject} was shown at ${shown} and is now ${sale.price.cents}; sent back to confirm`);
139
157
  return { kind: "invalid", why: "price changed", field: PRICE_CHANGED, channel: channelOf(candidate), formId, locale, slug, cents: sale.price.cents };
140
158
  }
@@ -199,7 +217,7 @@ async function accept(site, form, clientKey, deps) {
199
217
  deps.log.error(`quote: lead ${id} is stored but its notification failed`, error);
200
218
  }
201
219
  });
202
- const tags = experimentProps(field(form, EXPERIMENT_FIELD), field(form, VARIANT_FIELD));
220
+ const tags = submitTagsOf(form);
203
221
  deps.defer(() => deps.capture(lead, formId, tags));
204
222
  return { kind: "stored", id, ref, lead, locale, formId };
205
223
  }
@@ -19,8 +19,11 @@ export declare const EVENTS: {
19
19
  readonly estimateShown: "lead_estimate_shown";
20
20
  readonly bookingOpen: "lead_booking_open";
21
21
  readonly bookingDone: "lead_booking_done";
22
+ readonly messengerOpen: "lead_messenger_open";
23
+ readonly messengerReturn: "lead_messenger_return";
22
24
  };
23
- export type IntentChannel = "form_open" | "whatsapp" | "phone" | "sms" | "callback" | "booking";
25
+ /** `whatsapp_qr`: on a computer, the QR code that opens WhatsApp on the phone was shown. */
26
+ export type IntentChannel = "form_open" | "whatsapp" | "phone" | "sms" | "callback" | "booking" | "telegram" | "whatsapp_qr";
24
27
  /**
25
28
  * A form field as events name it — the role, never the brand's wire name, so
26
29
  * one field reads the same on every site. A brand's extra is its own name.
@@ -40,7 +43,7 @@ export type LeadStep = "need" | "contact" | "intro" | "locality" | "phone" | `es
40
43
  * throws, prod drops) rather than by review. `experiment` and `variant` are
41
44
  * the site's assignment, passed in; `field` is a field's role, never its value.
42
45
  */
43
- 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", "forced"];
46
+ 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", "forced", "channels_available", "message_ref", "messenger_variant", "inapp", "answer"];
44
47
  /**
45
48
  * The beacon's `distinct_id` as a lead may carry it to the panel
46
49
  * (`lead.created`'s `analytics_id`), so PostHog can join the lead to the
@@ -49,6 +52,11 @@ export declare const ALLOWED_PROPS: readonly ["brand_id", "location_id", "source
49
52
  export declare const ANALYTICS_ID: RegExp;
50
53
  /** What an experiment's assignment looks like on the wire: short slugs, nothing a person typed. */
51
54
  export declare const EXPERIMENT_SLUG: RegExp;
55
+ declare const CHANNELS_AVAILABLE: readonly ["wa,tg", "wa", "tg", "none"];
56
+ /** `channels_available` as an event property, or nothing when it is not one of the four. */
57
+ export declare function channelsProps(value: string | null | undefined): {
58
+ channels_available: (typeof CHANNELS_AVAILABLE)[number];
59
+ } | Record<string, never>;
52
60
  /** An experiment's assignment as event properties, or nothing when it is not a pair of slugs. */
53
61
  export declare function experimentProps(experiment: string | null | undefined, variant: string | null | undefined): {
54
62
  experiment: string;
@@ -69,6 +77,14 @@ export interface AnalyticsTarget {
69
77
  host: string;
70
78
  brandId: string;
71
79
  }
80
+ /**
81
+ * Whether a `Cookie` string — `document.cookie` or a request's header — makes
82
+ * the visit a test: the QA cookie is set with a non-empty value, any value.
83
+ * The one rule both sides apply (the sink in the browser, `quoteRoute` on the
84
+ * server). `abSwitcherVisible` keeps its own copy on purpose: a module the
85
+ * switcher shared with the analytics island cost a chunk of its own.
86
+ */
87
+ export declare function qaVisit(cookies: string, qaCookie: string): boolean;
72
88
  /**
73
89
  * Cookieless by construction: a beacon sink keeps its `distinct_id` in memory,
74
90
  * writes no cookie and no storage, and needs no consent banner. `sendBeacon`
@@ -76,5 +92,14 @@ export interface AnalyticsTarget {
76
92
  * followed by the page handing the visitor to another app. `distinctId`:
77
93
  * the visitor's id, held by the caller so a lead can name it; a random one
78
94
  * per sink without it.
95
+ *
96
+ * `qaCookie`: the brand's QA cookie (`AbSwitcher`'s). Every capture then checks
97
+ * `cookies` — a request's `Cookie` header on the server; left out, the
98
+ * browser's `document.cookie`, read at that capture — and adds `forced: true`
99
+ * on a test visit (`qaVisit`), so no event, present or future, has to
100
+ * remember it. Without `qaCookie` no cookie is read and no `forced` key sent.
101
+ * One wrapper for both cases, not a branch: on the place page that is ~10 B
102
+ * gz less than returning the bare sink.
79
103
  */
80
- export declare function analyticsSink(target: AnalyticsTarget, locationId: string | null, distinctId?: string): AnalyticsSink;
104
+ export declare function analyticsSink(target: AnalyticsTarget, locationId: string | null, distinctId?: string, qaCookie?: string, cookies?: string): AnalyticsSink;
105
+ export {};
@@ -28,6 +28,11 @@ export const EVENTS = {
28
28
  // and — only where the provider says so on the page (Cal.com's embed) — booked.
29
29
  bookingOpen: "lead_booking_open",
30
30
  bookingDone: "lead_booking_done",
31
+ // A messenger CTA of a `messenger` variant: the lead was posted and the
32
+ // visitor sent on (`channel`, `device`, `inapp`) — and, back on the page,
33
+ // what they said of it (`answer`: `sent` | `failed`).
34
+ messengerOpen: "lead_messenger_open",
35
+ messengerReturn: "lead_messenger_return",
31
36
  };
32
37
  /**
33
38
  * Every property name an event may carry. A customer's phone or address comes
@@ -60,6 +65,17 @@ export const ALLOWED_PROPS = [
60
65
  // A test visit (`true`): the brand's QA cookie was set, by a forced variant
61
66
  // or the QA menu. Absent otherwise, never `false`.
62
67
  "forced",
68
+ // The messengers the card offered: `wa,tg` | `wa` | `tg` | `none` — on every
69
+ // event of a `LeadCapture`, so an arm that fell back to the control is told apart.
70
+ "channels_available",
71
+ // The lead's chat reference (`AQ-7K3F`): drawn at random, not personal data.
72
+ "message_ref",
73
+ // The `messenger` variant's kind (`segment`, `tiles`…).
74
+ "messenger_variant",
75
+ // `lead_messenger_open`: opened from an app's own browser (Instagram, TikTok…).
76
+ "inapp",
77
+ // `lead_messenger_return`: `sent` | `failed`.
78
+ "answer",
63
79
  ];
64
80
  /**
65
81
  * The beacon's `distinct_id` as a lead may carry it to the panel
@@ -69,6 +85,12 @@ export const ALLOWED_PROPS = [
69
85
  export const ANALYTICS_ID = /^[A-Za-z0-9._:-]{1,128}$/;
70
86
  /** What an experiment's assignment looks like on the wire: short slugs, nothing a person typed. */
71
87
  export const EXPERIMENT_SLUG = /^[a-z0-9_-]{1,32}$/;
88
+ const CHANNELS_AVAILABLE = ["wa,tg", "wa", "tg", "none"];
89
+ /** `channels_available` as an event property, or nothing when it is not one of the four. */
90
+ export function channelsProps(value) {
91
+ const known = CHANNELS_AVAILABLE.find(c => c === value);
92
+ return known ? { channels_available: known } : {};
93
+ }
72
94
  /** An experiment's assignment as event properties, or nothing when it is not a pair of slugs. */
73
95
  export function experimentProps(experiment, variant) {
74
96
  return experiment && variant && EXPERIMENT_SLUG.test(experiment) && EXPERIMENT_SLUG.test(variant) ? { experiment, variant } : {};
@@ -80,6 +102,19 @@ export function experimentProps(experiment, variant) {
80
102
  export function countsAsPageView(pathname) {
81
103
  return !pathname.replace(/\/+$/, "").endsWith(THANKS);
82
104
  }
105
+ /**
106
+ * Whether a `Cookie` string — `document.cookie` or a request's header — makes
107
+ * the visit a test: the QA cookie is set with a non-empty value, any value.
108
+ * The one rule both sides apply (the sink in the browser, `quoteRoute` on the
109
+ * server). `abSwitcherVisible` keeps its own copy on purpose: a module the
110
+ * switcher shared with the analytics island cost a chunk of its own.
111
+ */
112
+ export function qaVisit(cookies, qaCookie) {
113
+ return cookies.split(";").some(pair => {
114
+ const eq = pair.indexOf("=");
115
+ return eq > 0 && pair.slice(0, eq).trim() === qaCookie && pair.slice(eq + 1).trim() !== "";
116
+ });
117
+ }
83
118
  /**
84
119
  * Cookieless by construction: a beacon sink keeps its `distinct_id` in memory,
85
120
  * writes no cookie and no storage, and needs no consent banner. `sendBeacon`
@@ -87,13 +122,22 @@ export function countsAsPageView(pathname) {
87
122
  * followed by the page handing the visitor to another app. `distinctId`:
88
123
  * the visitor's id, held by the caller so a lead can name it; a random one
89
124
  * per sink without it.
125
+ *
126
+ * `qaCookie`: the brand's QA cookie (`AbSwitcher`'s). Every capture then checks
127
+ * `cookies` — a request's `Cookie` header on the server; left out, the
128
+ * browser's `document.cookie`, read at that capture — and adds `forced: true`
129
+ * on a test visit (`qaVisit`), so no event, present or future, has to
130
+ * remember it. Without `qaCookie` no cookie is read and no `forced` key sent.
131
+ * One wrapper for both cases, not a branch: on the place page that is ~10 B
132
+ * gz less than returning the bare sink.
90
133
  */
91
- export function analyticsSink(target, locationId, distinctId) {
92
- return createBeaconSink({
134
+ export function analyticsSink(target, locationId, distinctId, qaCookie, cookies) {
135
+ const sink = createBeaconSink({
93
136
  key: target.key ?? undefined,
94
137
  ...(distinctId !== undefined ? { distinctId } : {}),
95
138
  host: target.host,
96
139
  allowedProps: ALLOWED_PROPS,
97
140
  globalProps: locationId ? { brand_id: target.brandId, location_id: locationId } : { brand_id: target.brandId },
98
141
  });
142
+ return { capture: (event, props, options) => sink.capture(event, qaCookie && qaVisit(cookies ?? document.cookie, qaCookie) ? { ...props, forced: true } : props, options) };
99
143
  }
@@ -3,9 +3,11 @@ import type { OpeningHours } from "./place/types.js";
3
3
  /**
4
4
  * The ways a visitor can reach the business, named as `contact_intent_click`
5
5
  * names them, so a channel and its event are one word. `form` is the quote
6
- * form; `callback` is the form cut to a phone number ("Rappelez-moi").
6
+ * form; `callback` is the form cut to a phone number ("Rappelez-moi");
7
+ * `telegram` is the brand's bot, opened with the lead's `message_ref` — drawn
8
+ * by a `messenger` variant only, never by the resolver's list.
7
9
  */
8
- export type CaptureChannel = "phone" | "whatsapp" | "sms" | "callback" | "form";
10
+ export type CaptureChannel = "phone" | "whatsapp" | "sms" | "callback" | "form" | "telegram";
9
11
  /** What decides which channels exist: the place's numbers and hours. */
10
12
  export interface ChannelFacts {
11
13
  /** `contactOf(site, place)` — the place's numbers, the brand's as fallback. */
@@ -16,6 +18,8 @@ export interface ChannelFacts {
16
18
  sms?: boolean;
17
19
  /** Default: offered. A surface with no form to post it from turns it off. */
18
20
  callback?: boolean;
21
+ /** The bot's username, without `@` (`messengerFacts`); absent or `null` → no Telegram. */
22
+ telegram?: string | null;
19
23
  }
20
24
  export interface ChannelOptions {
21
25
  now: Date;
@@ -43,5 +47,9 @@ export declare function resolveChannels(facts: ChannelFacts, options: ChannelOpt
43
47
  * `&body=`) and Android (which reads `?body=`) accept.
44
48
  */
45
49
  export declare function smsHref(phone: string, body?: string): string;
46
- /** The link a channel opens, or `null` for the two that are forms on the page. */
47
- export declare function channelHref(channel: CaptureChannel, facts: Pick<ChannelFacts, "phone" | "whatsapp">, message?: string): string | null;
50
+ /**
51
+ * The link a channel opens, or `null` for the two that are forms on the page.
52
+ * `message` is the prefilled text — for `telegram`, the bot's `start`
53
+ * parameter: the lead's `message_ref`.
54
+ */
55
+ export declare function channelHref(channel: CaptureChannel, facts: Pick<ChannelFacts, "phone" | "whatsapp" | "telegram">, message?: string): string | null;