@evinvest/kitstart 0.17.0 → 0.19.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +163 -20
- package/dist/core/accept.d.ts +14 -0
- package/dist/core/accept.js +21 -3
- package/dist/core/analytics.d.ts +11 -2
- package/dist/core/analytics.js +22 -0
- package/dist/core/channels.d.ts +12 -4
- package/dist/core/channels.js +11 -1
- package/dist/core/lead-capture-text.d.ts +6 -2
- package/dist/core/lead.d.ts +33 -10
- package/dist/core/lead.js +21 -8
- package/dist/core/messenger-message.d.ts +31 -0
- package/dist/core/messenger-message.js +33 -0
- package/dist/core/messenger-text.d.ts +104 -0
- package/dist/core/messenger-text.js +150 -0
- package/dist/core/messenger.d.ts +92 -0
- package/dist/core/messenger.js +55 -0
- package/dist/core/place/index.d.ts +1 -1
- package/dist/core/place/live.d.ts +5 -1
- package/dist/core/place/live.js +22 -1
- package/dist/core/place/types.d.ts +17 -1
- package/dist/core/pricing/index.d.ts +2 -1
- package/dist/core/pricing/index.js +2 -1
- package/dist/core/pricing/label.d.ts +7 -0
- package/dist/core/pricing/label.js +8 -0
- package/dist/core/pricing/validate.d.ts +1 -3
- package/dist/core/pricing/validate.js +0 -4
- package/dist/core/site.d.ts +8 -1
- package/dist/core/site.js +22 -1
- package/dist/index.d.ts +8 -5
- package/dist/index.js +7 -4
- package/dist/next/quote-route.js +4 -7
- package/dist/react/AnalyticsBoundary.js +3 -3
- package/dist/react/FormSelect.d.ts +5 -3
- package/dist/react/FormSelect.js +70 -24
- package/dist/react/FormSelectKit.d.ts +24 -0
- package/dist/react/FormSelectKit.js +28 -0
- package/dist/react/LeadCapture.d.ts +24 -1
- package/dist/react/LeadCapture.js +97 -12
- package/dist/react/LeadCaptureChannels.d.ts +7 -2
- package/dist/react/LeadCaptureChannels.js +2 -2
- package/dist/react/LeadCaptureEstimate.d.ts +6 -0
- package/dist/react/LeadCaptureEstimate.js +1 -1
- package/dist/react/LeadCaptureFields.d.ts +17 -0
- package/dist/react/LeadCaptureFields.js +42 -33
- package/dist/react/LeadCaptureMessenger.d.ts +41 -0
- package/dist/react/LeadCaptureMessenger.js +133 -0
- package/dist/react/LeadCapturePrice.d.ts +9 -3
- package/dist/react/LeadCapturePrice.js +25 -5
- package/dist/react/LeadCaptureSubmit.d.ts +3 -0
- package/dist/react/LeadCaptureSubmit.js +2 -2
- package/dist/react/LeadCaptureTiles.js +9 -4
- package/dist/react/analytics-context.d.ts +9 -3
- package/dist/react/analytics-context.js +7 -3
- package/dist/react/form-select-value.d.ts +3 -0
- package/dist/react/form-select-value.js +6 -6
- package/dist/react/index.d.ts +2 -0
- package/dist/react/index.js +1 -0
- package/dist/react/lazy-part.d.ts +33 -0
- package/dist/react/lazy-part.js +66 -0
- package/dist/react/messenger/action.d.ts +144 -0
- package/dist/react/messenger/action.js +301 -0
- package/dist/react/messenger/chip.d.ts +8 -0
- package/dist/react/messenger/chip.js +22 -0
- package/dist/react/messenger/fallback.d.ts +8 -0
- package/dist/react/messenger/fallback.js +11 -0
- package/dist/react/messenger/saga.d.ts +10 -0
- package/dist/react/messenger/saga.js +33 -0
- package/dist/react/messenger/segment.d.ts +8 -0
- package/dist/react/messenger/segment.js +15 -0
- package/dist/react/messenger/select.d.ts +10 -0
- package/dist/react/messenger/select.js +26 -0
- package/dist/react/messenger/sheet.d.ts +10 -0
- package/dist/react/messenger/sheet.js +57 -0
- package/dist/react/messenger/split.d.ts +9 -0
- package/dist/react/messenger/split.js +17 -0
- package/dist/react/messenger/swap.d.ts +10 -0
- package/dist/react/messenger/swap.js +18 -0
- package/dist/react/messenger/thanks.d.ts +10 -0
- package/dist/react/messenger/thanks.js +18 -0
- package/dist/react/messenger/tiles.d.ts +8 -0
- package/dist/react/messenger/tiles.js +20 -0
- package/dist/react/messenger/types.d.ts +98 -0
- package/dist/react/messenger/types.js +1 -0
- package/dist/react/messenger/urgency.d.ts +10 -0
- package/dist/react/messenger/urgency.js +29 -0
- package/dist/react/use-lead-error.d.ts +3 -3
- package/dist/react/use-lead-events.d.ts +21 -1
- package/dist/react/use-lead-events.js +9 -3
- package/dist/react/use-lead-submit.d.ts +20 -6
- package/dist/react/use-lead-submit.js +23 -1
- package/dist/server/index.d.ts +1 -1
- package/dist/server/lead-store-sqlite.js +11 -5
- package/dist/server/lead-webhook.d.ts +30 -5
- package/dist/server/lead-webhook.js +13 -7
- package/dist/server/notify.js +11 -5
- package/dist/testing/index.d.ts +1 -1
- package/dist/testing/index.js +1 -1
- package/dist/testing/landing-contract.js +6 -1
- package/dist/testing/lead-rules.d.ts +8 -0
- package/dist/testing/lead-rules.js +30 -1
- package/package.json +6 -3
- package/template/flake.nix +1 -1
- package/template/package.json +5 -5
package/README.md
CHANGED
|
@@ -18,7 +18,7 @@ stays in the brand.
|
|
|
18
18
|
| `@evinvest/kitstart/proxy` | edge | `createProxy(site)`, `PROXY_MATCHER` |
|
|
19
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), `LeadBooking` (client) and its adapters, `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) with `loadLazyParts` for tests, `LeadCapture` (client), `LeadBooking` (client) and its adapters, `Faq`, `AnalyticsBoundary` (client), plus the kit and marketing pieces a landing composes with |
|
|
22
22
|
| `@evinvest/kitstart/testing` | a brand's vitest | `describeLandingContract(site, { globalsCss, proxySource, text })`, `describeLeadStoreContract(name, harness)`, `storefrontPlace`, `serviceAreaPlace`, `testLead` |
|
|
23
23
|
| `@evinvest/kitstart/testing/e2e` | a brand's Playwright | `defineSectionSuite(sections)`, `settle(page, selector)`, `BREAKPOINTS` |
|
|
24
24
|
| bin `kitstart-size` | plain node | `kitstart-size [<build root>] [--route …] [--budget …]`: first-load JS of a place page against the target in `tests/bundle_budget.txt` (passes with a warning up to its tolerance, 20 % by default — see [The bundle budget](#the-bundle-budget)); fails closed |
|
|
@@ -45,7 +45,7 @@ brand's `flake.nix` is its own config plus one call — `template/flake.nix` is
|
|
|
45
45
|
the whole of one:
|
|
46
46
|
|
|
47
47
|
```nix
|
|
48
|
-
inputs.ev.url = "github:EV-invest/lib?ref=@evinvest/kitstart-v0.
|
|
48
|
+
inputs.ev.url = "github:EV-invest/lib?ref=@evinvest/kitstart-v0.19.1"; # the version in package-lock.json
|
|
49
49
|
inputs.ev.inputs.v_flakes.follows = "v_flakes";
|
|
50
50
|
|
|
51
51
|
landing = ev.lib.mkLanding {
|
|
@@ -143,12 +143,16 @@ boundary never imports the site config.
|
|
|
143
143
|
|
|
144
144
|
A form's select, for the fields inside `QuoteFormShell`. On the server and
|
|
145
145
|
without JavaScript it is the kit's `NativeSelect` — a real `<select name>`
|
|
146
|
-
the form posts as is.
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
146
|
+
the form posts as is. Hydrated, it stays that select, live — a choice there
|
|
147
|
+
reaches `onValueChange` — until the kit's `Select` is here: a chunk of its
|
|
148
|
+
own, asked for once the page is idle, or sooner when a mouse moves over the
|
|
149
|
+
field or it takes the focus. Then it is the kit's `Select`: the list drawn in
|
|
150
|
+
the palette, never the platform's menu, and the value in an input of the
|
|
151
|
+
same `name`. A press before that opens the platform's list at once, never a
|
|
152
|
+
wait on the chunk; a select holding the focus (its picker may be open) is
|
|
153
|
+
swapped only once the focus leaves. Both states are one box — height, width,
|
|
154
|
+
border, radius and inset — so the swap moves nothing (checked by the
|
|
155
|
+
template's e2e, which CI runs: `npm run check:template -- --e2e`).
|
|
152
156
|
|
|
153
157
|
```tsx
|
|
154
158
|
<Field className="flex flex-col gap-2">
|
|
@@ -190,9 +194,11 @@ which CI runs: `npm run check:template -- --e2e`).
|
|
|
190
194
|
letters jump, Enter chooses, Escape and Tab close with focus back on the
|
|
191
195
|
trigger; the trigger is a `combobox` named by the `FieldLabel`, and so is
|
|
192
196
|
its list, with the kit's focus ring.
|
|
193
|
-
- **Weight.**
|
|
194
|
-
|
|
195
|
-
|
|
197
|
+
- **Weight.** The kit's `Select` — the list's placing, dismissal and keys —
|
|
198
|
+
is out of the first load, in `FormSelectKit`'s chunk; the native select and
|
|
199
|
+
the value's plumbing stay. With `@evinvest/uikit` before 0.28 a page whose
|
|
200
|
+
form uses the kit's `Field` still loads `Select` up front: that `Field`
|
|
201
|
+
imported `SelectTrigger` to recognise the control it labels.
|
|
196
202
|
- **Controlled, as an `<input>`.** Under `value` the prop is the value: a
|
|
197
203
|
pick only calls `onValueChange`, and the parent's state (or a reset of its
|
|
198
204
|
own, `setSubject("")` back to the placeholder) is what moves it. A form
|
|
@@ -237,10 +243,10 @@ it is the plain POST to `/quote` it always was.
|
|
|
237
243
|
| `needDisplay` | how the need is asked: `select` (default on `single`) · `tiles` (default otherwise) · `cards`, a grid with each need's `icon` |
|
|
238
244
|
| `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
245
|
| `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) |
|
|
246
|
+
| `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
247
|
| `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
248
|
| `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`) |
|
|
249
|
+
| `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
250
|
| `locality` | `required` (default) · `optional` |
|
|
245
251
|
| `name` | `{ field, required? }`: a name field posted as the brand's extra; off by default |
|
|
246
252
|
| `extras` | the brand's own fields, after the phone |
|
|
@@ -257,7 +263,8 @@ it is the plain POST to `/quote` it always was.
|
|
|
257
263
|
| `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
264
|
| `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
265
|
| `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` |
|
|
266
|
+
| `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` |
|
|
267
|
+
| `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
268
|
|
|
262
269
|
- **Taps.** A need the page knows is not asked again, and a place serving one
|
|
263
270
|
commune fills it: focus the phone, type, send — two taps. `qualify-first`
|
|
@@ -359,6 +366,130 @@ it is the plain POST to `/quote` it always was.
|
|
|
359
366
|
within the 20 % tolerance); the refusals and the shared phone rule are
|
|
360
367
|
2.0 KB of it, sending (the id, the busy state, the retry) 0.6 KB.
|
|
361
368
|
|
|
369
|
+
### Messengers: `messenger`, WhatsApp and the bot
|
|
370
|
+
|
|
371
|
+
The `lead_channel` experiment's arms. WhatsApp: the customer sends the brand's
|
|
372
|
+
prefilled message, answered by hand in WhatsApp Business. Telegram: the
|
|
373
|
+
brand's bot, opened as `t.me/<bot>?start=<ref>`. The phone is asked only for
|
|
374
|
+
a call; no text messages. Either way the lead is **posted first**, in the
|
|
375
|
+
background, so the panel has it before the chat starts.
|
|
376
|
+
|
|
377
|
+
```tsx
|
|
378
|
+
import { messengerFacts } from "@evinvest/kitstart";
|
|
379
|
+
|
|
380
|
+
<LeadCapture
|
|
381
|
+
{...controlProps}
|
|
382
|
+
messenger={arm === "c" ? { kind: "segment" } : undefined} // undefined → the control
|
|
383
|
+
messengers={messengerFacts(site, place)} // the place's own WhatsApp, its bot — each `null` when off
|
|
384
|
+
// (a bot's name: `TELEGRAM_BOT`, 5–32 chars ending in `bot`)
|
|
385
|
+
refPrefix="AQ" // the lead's chat reference: AQ-7K3F
|
|
386
|
+
brand="Aquafix" // the message's greeting
|
|
387
|
+
channelIcons={{ whatsapp: <WhatsAppIcon />, telegram: <TelegramIcon />, callback: <PhoneIcon /> }}
|
|
388
|
+
classNames={{ messengerSlot: "h-[92px]" }} // every state of a variant one height
|
|
389
|
+
/>
|
|
390
|
+
```
|
|
391
|
+
|
|
392
|
+
| `kind` | Figma | the card |
|
|
393
|
+
|---|---|---|
|
|
394
|
+
| `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 |
|
|
395
|
+
| `segment` | AQ-2 | «WhatsApp \| Appel» over the slot (the message ready / the phone); «ou via Telegram» under the button |
|
|
396
|
+
| `tiles` | VF-2 | «Recevoir mon devis par» and a tile per channel over the slot (the message / what the bot does / the phone) |
|
|
397
|
+
| `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`) |
|
|
398
|
+
| `swap` | AQ-4 / VF-3 | no phone: the WhatsApp button, then [Telegram][Être rappelé]; «Être rappelé» swaps in the phone and [WhatsApp square][submit] |
|
|
399
|
+
| `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 |
|
|
400
|
+
| `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 |
|
|
401
|
+
| `sheet` | VF-4 | the estimate, the postcode and one button; the kit's `Drawer`: WhatsApp / Telegram / a call, the phone asked in the drawer |
|
|
402
|
+
| `chip` | VF-5 | the lede becomes a chip «Réponse sur WhatsApp ▾» (a `Select` overlay); the slot follows it |
|
|
403
|
+
| `split` | VF-6 | the slot over one row: «Devis sur WhatsApp» with [Telegram][📞] squares; 📞 turns it into «Être rappelé» with [WhatsApp][Telegram] |
|
|
404
|
+
|
|
405
|
+
- **Fallback** (Figma «Выключено в панели»). No WhatsApp for the place — no
|
|
406
|
+
number of its own, or `messengers.whatsapp: false` from the panel — and
|
|
407
|
+
every kind is the control, with «ou via Telegram» under the submit when the
|
|
408
|
+
bot is on; `urgency` keeps its question (both answers ask the phone) and
|
|
409
|
+
`thanks` keeps the bot. No bot — every Telegram piece is left out. The arm
|
|
410
|
+
stays assigned; every event says what the card had: `channels_available`
|
|
411
|
+
(`wa,tg` | `wa` | `tg` | `none`). The switches are the form's only: `contactOf` — the
|
|
412
|
+
header, the call bar, the control's own WhatsApp link — keeps the number.
|
|
413
|
+
- **The reference** `message_ref`: `<refPrefix>-<4 Crockford base32>`,
|
|
414
|
+
minted in the browser once the script runs, and again only once the
|
|
415
|
+
visitor is back from the chat and has answered «Message envoyé ?» — never
|
|
416
|
+
in the tap: the reference posted is the one in the link, the QR code and
|
|
417
|
+
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
|
|
418
|
+
`start` is the reference itself. Posted with every lead of the card (a
|
|
419
|
+
`thanks` success sends the form lead's own), stored (`message_ref`, schema
|
|
420
|
+
8), mailed (`Réf.`), and sent to the panel under `panelMessenger`.
|
|
421
|
+
- **The message** (`messengerMessage`, ≤ 400 characters, nothing typed about
|
|
422
|
+
the person): `Bonjour {brand} 👋`, the need with the estimate's answers
|
|
423
|
+
(«Ménage standard · 2 ch. · 40–70 m²»: each answer as
|
|
424
|
+
`questions[id].previewLabels` says it — a tile's «2» is «2 ch.» here — else
|
|
425
|
+
its `shortLabels`, else its label; «Je ne sais pas» left out),
|
|
426
|
+
the postcode, the estimate shown (a priced need), the timing, `Réf. {ref}` —
|
|
427
|
+
a line without its value left out. The slot shows it as a preview, so the
|
|
428
|
+
visitor sees there is nothing to write: «Bonjour Vifnet 👋 · Ménage
|
|
429
|
+
standard · 2 ch. · 40–70 m² · 2 sem. · env. 77 € · 63130 · Réf. VF-7K3F»
|
|
430
|
+
(the timing by its `previewLabels` / `shortLabels`, the price through `messengerPreviewPrice`,
|
|
431
|
+
`"env. {price}"`). The timing is `urgency`'s answer («aujourd’hui» / «pas
|
|
432
|
+
pressé»), else the answer to the estimate's question `messengerTiming`
|
|
433
|
+
names — `messengerTiming={{ input: "frequency" }}`, its whole label on a line
|
|
434
|
+
of its own, out of the need's list; none until it is answered, nor for «Je
|
|
435
|
+
ne sais pas». Each line is a key a brand rewords: `{ ...LEAD_CAPTURE_TEXT.fr,
|
|
436
|
+
messageTiming: "Fréquence : {timing}", messagePrice: "Estimation vue sur le
|
|
437
|
+
site : env. {price} / passage" }`.
|
|
438
|
+
- **Icons** (`channelIcons`): `whatsapp` and `telegram` before their
|
|
439
|
+
buttons, items, tiles and segments; `phone` (else `callback`) before every
|
|
440
|
+
call inside a variant — «Être rappelé», its submit, the squares.
|
|
441
|
+
- **The tap.** A real `<a href>` — the OS opens the app from the tap. A
|
|
442
|
+
number typed must be one (the browser says so, nothing leaves). The lead is
|
|
443
|
+
posted (`keepalive`, `channel=whatsapp|telegram`, the reference, what was
|
|
444
|
+
filled; the same submission id for a second tap). On a computer (`hover:
|
|
445
|
+
hover`, `pointer: fine`, ≥ 768 px) WhatsApp becomes a QR code in the slot
|
|
446
|
+
(`qrcode-generator`, loaded on that tap only) with «ou ouvrir WhatsApp Web»
|
|
447
|
+
and «Être rappelé»; the bot opens in a new tab. In an app's own browser
|
|
448
|
+
(Instagram, Facebook, TikTok, Line, Snapchat) a hint says how to leave it,
|
|
449
|
+
with «Copier le message».
|
|
450
|
+
- **Back on the page** (`visibilitychange`), the card asks «Message
|
|
451
|
+
envoyé ?» over the form: reopen the chat, «Je n’ai pas pu envoyer → être
|
|
452
|
+
rappelé» (the variant's call state, the phone focused), or «C’est envoyé».
|
|
453
|
+
- **The server.** A `whatsapp` / `telegram` lead needs no phone (one typed
|
|
454
|
+
is held to the rule) and no consent, and is never sent back for a changed
|
|
455
|
+
price; `validateLead` knows the channel. A brand's own rule must not
|
|
456
|
+
refuse one for the postcode or the phone it was not asked — the testing
|
|
457
|
+
subpath's `messengerRuleDisagreements(site.lead)` lists where it does.
|
|
458
|
+
- **Words**: `LeadCaptureMessengerText` — optional keys of the card's
|
|
459
|
+
`text`, a key left out the kit's in the page's language (`messengerTextOf`;
|
|
460
|
+
the defaults are `LEAD_CAPTURE_MESSENGER_TEXT`, kept out of
|
|
461
|
+
`LEAD_CAPTURE_TEXT` so the control's island never carries them). A brand
|
|
462
|
+
overrides by spreading: `{ ...LEAD_CAPTURE_TEXT.fr, messengerWhatsappCta: "Envoyer sur WhatsApp →" }`. **Parts**:
|
|
463
|
+
`messenger` (the variant's column), `messengerSlot` (the fixed box),
|
|
464
|
+
`messengerCta` (the main button: a messenger's, or the call's submit inside
|
|
465
|
+
the variant — its height and type, as `channel` / `primary` are the
|
|
466
|
+
control's), `messengerSecondary` (Telegram and «Être rappelé» beside it,
|
|
467
|
+
«ou via Telegram», «Rouvrir WhatsApp», the QR's «Être rappelé»),
|
|
468
|
+
`messengerSquare` (the icon squares of `split` / `swap`), `messengerSegment`
|
|
469
|
+
(a `segment` item), `messengerTile` (a `tiles` tile, an `urgency` answer) —
|
|
470
|
+
both after `messengerOption`, `messengerTrigger` (the channel picker's
|
|
471
|
+
button: `select`'s in the field — its tint — and `chip`'s),
|
|
472
|
+
`messengerPreview` (the message, the bot's card), `messengerPicker` (the
|
|
473
|
+
segment, the tiles, `select`'s field box, the chip, the drawer's body),
|
|
474
|
+
`messengerOption` (a picker's item, a `saga` / `sheet` card, a segment's
|
|
475
|
+
item, a tile), `messengerQr`,
|
|
476
|
+
`messengerReturn`, `messengerHint`. A part with a size keeps its line
|
|
477
|
+
height (`leading-6`) unless it sets one.
|
|
478
|
+
- **Events**: every `LeadCapture` event carries `channels_available` and,
|
|
479
|
+
with a variant, `messenger_variant`; `lead_messenger_open {channel, device,
|
|
480
|
+
inapp, message_ref}`, `lead_messenger_return {channel, answer: sent |
|
|
481
|
+
failed, message_ref}`, `contact_intent_click {channel: telegram |
|
|
482
|
+
whatsapp_qr}`; `lead_form_submit` has `message_ref` and the posted
|
|
483
|
+
`channels_available`.
|
|
484
|
+
- **Weight.** Each kind is its own chunk, fetched only by the arm that draws
|
|
485
|
+
it; the control pays for the dispatch alone, and the QR encoder for nobody
|
|
486
|
+
until a desktop tap. The server writes the variant into the page whole (no
|
|
487
|
+
boundary of its own: React would reveal a large one late, the contact block
|
|
488
|
+
missing for a frame); hydrating, the card waits for its variant's chunk,
|
|
489
|
+
the server's markup kept as it is. Until the chunk and the reference are
|
|
490
|
+
there, its messenger links lead nowhere (`aria-disabled`, no `href`); a
|
|
491
|
+
browser without scripts gets the control's phone and submit (`<noscript>`).
|
|
492
|
+
|
|
362
493
|
### One question per screen: `layout="steps"`
|
|
363
494
|
|
|
364
495
|
```tsx
|
|
@@ -417,8 +548,13 @@ Pieces any layout takes, for a card that fits a phone's screen:
|
|
|
417
548
|
{price}" — a brand may write "≈ {price}"), then, with `taxCredit` (a ratio
|
|
418
549
|
of the price a tax credit gives back, `0.5` for the French *crédit
|
|
419
550
|
d'impôt*), what is left to pay (`priceTaxCredit`, `data-credit-cents`),
|
|
420
|
-
and the breakdown and `priceNote` behind
|
|
421
|
-
`
|
|
551
|
+
and the breakdown and `priceNote` behind «Détail» (`text.priceDetail`): the
|
|
552
|
+
kit's `Popover`, over the card — opening it moves nothing — closed by a tap
|
|
553
|
+
outside or Escape and kept 8 px inside the screen; without a script (and
|
|
554
|
+
until hydration) a `<details>` opening in place. Parts: `priceDetail` the
|
|
555
|
+
trigger in both (the button, or the summary — style it directly, not
|
|
556
|
+
through `[&>summary]`), `priceDetailContent` the popover's box (or the
|
|
557
|
+
details' body). Read out as it changes, the line only.
|
|
422
558
|
- **A question as cards** (`questions: { frequency: { display: "cards" } }`):
|
|
423
559
|
one answer a line, each with the total it would make given the other
|
|
424
560
|
answers (once they are given), and an optional `badges` tag per option.
|
|
@@ -777,10 +913,17 @@ export const POST = quoteRoute(site, { env: serverEnv, notifier, webhook, unavai
|
|
|
777
913
|
`LEAD_WEBHOOK_KEY_ID` and `LEAD_WEBHOOK_SECRET` are required, and the URL
|
|
778
914
|
must be `https:`, or `http:` to a `*.svc` / `*.svc.cluster.local` host or
|
|
779
915
|
loopback — the body carries PII. All checked at boot.
|
|
780
|
-
- **The channel.** `channelOf(lead)` is `form
|
|
781
|
-
Service-Arb panel
|
|
782
|
-
|
|
783
|
-
whole event outside it
|
|
916
|
+
- **The channel.** `channelOf(lead)` is `form`, `callback`, `whatsapp` or
|
|
917
|
+
`telegram`. For the Service-Arb panel use `ctx.channel` (or
|
|
918
|
+
`panelChannel(channel, panelMessenger)`): its `properties.channel` is a
|
|
919
|
+
closed set that refuses the whole event outside it; a callback goes as
|
|
920
|
+
`callback` (panel v0.3.0 on), a messenger lead as `form` until
|
|
921
|
+
`panelMessenger` is on.
|
|
922
|
+
- **Messenger leads: `panelMessenger`, off.** On: `ctx.channel` says
|
|
923
|
+
`whatsapp` / `telegram`, and `ctx.messageRef` carries the lead's chat
|
|
924
|
+
reference for `properties.message_ref`. Off (the default, until the panel
|
|
925
|
+
takes them): a messenger lead goes as a `form`, without its reference —
|
|
926
|
+
the leads table and the mail keep both either way.
|
|
784
927
|
- **Queued before the 303, sent after it.** The body is built once, from the
|
|
785
928
|
lead and `ctx` (`leadId`, `brandId`, `locale`, `formId`, `at`, and a fresh
|
|
786
929
|
`idempotencyKey` for the receiver to deduplicate by), and written to the
|
package/dist/core/accept.d.ts
CHANGED
|
@@ -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
|
package/dist/core/accept.js
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
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
|
}
|
package/dist/core/analytics.d.ts
CHANGED
|
@@ -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
|
-
|
|
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;
|
|
@@ -94,3 +102,4 @@ export declare function qaVisit(cookies: string, qaCookie: string): boolean;
|
|
|
94
102
|
* gz less than returning the bare sink.
|
|
95
103
|
*/
|
|
96
104
|
export declare function analyticsSink(target: AnalyticsTarget, locationId: string | null, distinctId?: string, qaCookie?: string, cookies?: string): AnalyticsSink;
|
|
105
|
+
export {};
|
package/dist/core/analytics.js
CHANGED
|
@@ -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 } : {};
|
package/dist/core/channels.d.ts
CHANGED
|
@@ -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
|
-
/**
|
|
47
|
-
|
|
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;
|
package/dist/core/channels.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { telHref, whatsappHref } from "@evinvest/marketing";
|
|
2
2
|
import { DEFAULT_TIME_ZONE, isOpenAt, nextOpening } from "./place/hours.js";
|
|
3
|
+
import { telegramHref } from "./messenger.js";
|
|
3
4
|
import { isMobilePhone, normalizePhone } from "./phone.js";
|
|
4
5
|
// Open: a person answers, so the call leads — calls convert many times what a
|
|
5
6
|
// form does. Closed: a call rings out, so the channels that wait for the
|
|
@@ -21,6 +22,9 @@ export function resolveChannels(facts, options) {
|
|
|
21
22
|
sms: facts.phone !== null && (facts.sms ?? isMobilePhone(facts.phone)),
|
|
22
23
|
callback: facts.callback ?? true,
|
|
23
24
|
form: true,
|
|
25
|
+
// Never in the lists below: a bot is offered by a `messenger` variant,
|
|
26
|
+
// with the lead posted first, not as one more bare link.
|
|
27
|
+
telegram: false,
|
|
24
28
|
};
|
|
25
29
|
const order = (open === false ? CLOSED : OPEN).filter(c => has[c]);
|
|
26
30
|
const prefer = options.prefer;
|
|
@@ -35,7 +39,11 @@ export function smsHref(phone, body) {
|
|
|
35
39
|
const number = normalizePhone(phone) ?? phone.replace(/[^\d+]/g, "");
|
|
36
40
|
return body ? `sms:${number}?&body=${encodeURIComponent(body)}` : `sms:${number}`;
|
|
37
41
|
}
|
|
38
|
-
/**
|
|
42
|
+
/**
|
|
43
|
+
* The link a channel opens, or `null` for the two that are forms on the page.
|
|
44
|
+
* `message` is the prefilled text — for `telegram`, the bot's `start`
|
|
45
|
+
* parameter: the lead's `message_ref`.
|
|
46
|
+
*/
|
|
39
47
|
export function channelHref(channel, facts, message) {
|
|
40
48
|
switch (channel) {
|
|
41
49
|
case "phone":
|
|
@@ -44,6 +52,8 @@ export function channelHref(channel, facts, message) {
|
|
|
44
52
|
return facts.whatsapp ? whatsappHref(facts.whatsapp, message) : null;
|
|
45
53
|
case "sms":
|
|
46
54
|
return facts.phone ? smsHref(facts.phone, message) : null;
|
|
55
|
+
case "telegram":
|
|
56
|
+
return facts.telegram ? telegramHref(facts.telegram, message) : null;
|
|
47
57
|
case "callback":
|
|
48
58
|
case "form":
|
|
49
59
|
return null;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { LeadCaptureMessengerText } from "./messenger-text.js";
|
|
1
2
|
/**
|
|
2
3
|
* Every word `LeadCapture` prints. Plain strings, not the copy contract's
|
|
3
4
|
* `Said<F>`: the form is a client island, and a function cannot cross from a
|
|
@@ -10,9 +11,12 @@
|
|
|
10
11
|
*
|
|
11
12
|
* The flows' words (`LeadCaptureFlowText`) are optional here so a brand's own
|
|
12
13
|
* text object written before them still type-checks; a key left out falls
|
|
13
|
-
* back to the kit's, in the page's language (`flowTextOf`).
|
|
14
|
+
* back to the kit's, in the page's language (`flowTextOf`). The `messenger`
|
|
15
|
+
* variants' words (`LeadCaptureMessengerText`) likewise (`messengerTextOf`),
|
|
16
|
+
* their defaults in `LEAD_CAPTURE_MESSENGER_TEXT` — not spread in here: this
|
|
17
|
+
* module reaches the client island, and only a variant's chunk needs them.
|
|
14
18
|
*/
|
|
15
|
-
export interface LeadCaptureText extends Partial<LeadCaptureFlowText> {
|
|
19
|
+
export interface LeadCaptureText extends Partial<LeadCaptureFlowText>, Partial<LeadCaptureMessengerText> {
|
|
16
20
|
title: string;
|
|
17
21
|
lede: string;
|
|
18
22
|
needLabel: string;
|
package/dist/core/lead.d.ts
CHANGED
|
@@ -60,6 +60,12 @@ export interface LeadSchema<S extends string> {
|
|
|
60
60
|
* the landing contract holds it to the form's phone rule, so the server
|
|
61
61
|
* never refuses a number the form let through. A bare string is a reason
|
|
62
62
|
* about the whole form (`field: "form"`).
|
|
63
|
+
*
|
|
64
|
+
* A messenger lead (`whatsapp`, `telegram`) answers to it too: the
|
|
65
|
+
* candidate carries its `channel`, and `validateLead` asks no phone of one
|
|
66
|
+
* — the chat is how we answer. The contract holds a brand's own rule to
|
|
67
|
+
* that (`messengerRuleDisagreements`): a messenger lead is never refused
|
|
68
|
+
* for a postcode or a phone it was not asked for.
|
|
63
69
|
*/
|
|
64
70
|
validate?: (lead: LeadCandidate) => LeadVerdict;
|
|
65
71
|
/**
|
|
@@ -84,13 +90,17 @@ export interface LeadSchema<S extends string> {
|
|
|
84
90
|
flows?: LeadFlows;
|
|
85
91
|
}
|
|
86
92
|
/**
|
|
87
|
-
* How the lead was asked for: the quote form,
|
|
88
|
-
* to a phone number
|
|
89
|
-
*
|
|
93
|
+
* How the lead was asked for: the quote form, "call me back" — the form cut
|
|
94
|
+
* to a phone number — or a messenger the visitor was sent on to, the lead
|
|
95
|
+
* posted first (`whatsapp`, `telegram`). Posted as `CHANNEL_FIELD`; anything
|
|
96
|
+
* else, or nothing (a page cached before the field existed), is `form`.
|
|
90
97
|
*/
|
|
91
|
-
export type LeadChannel = "form" | "callback";
|
|
98
|
+
export type LeadChannel = "form" | "callback" | "whatsapp" | "telegram";
|
|
92
99
|
export declare const LEAD_CHANNELS: readonly LeadChannel[];
|
|
93
100
|
export declare const CHANNEL_FIELD = "channel";
|
|
101
|
+
/** A lead whose answer goes by chat: the phone is optional, no consent is asked. */
|
|
102
|
+
export type MessengerChannel = Extract<LeadChannel, "whatsapp" | "telegram">;
|
|
103
|
+
export declare function isMessengerChannel(channel: LeadChannel | undefined): channel is MessengerChannel;
|
|
94
104
|
/**
|
|
95
105
|
* The callback's consent: a checkbox whose value is the sentence it shows, in
|
|
96
106
|
* the language it shows it, so the row keeps what was agreed to. Absent → the
|
|
@@ -111,7 +121,8 @@ export declare const SUBMISSION_ID: RegExp;
|
|
|
111
121
|
export declare const LEAD_ERROR_PARAM = "lead_error";
|
|
112
122
|
/** A refusal the server sent back: which of the card's forms, and the field it is about. */
|
|
113
123
|
export interface LeadError {
|
|
114
|
-
|
|
124
|
+
/** A messenger lead is posted behind the visitor's back: no form of the card shows its refusal. */
|
|
125
|
+
channel: Extract<LeadChannel, "form" | "callback">;
|
|
115
126
|
field: string;
|
|
116
127
|
}
|
|
117
128
|
/** What a refused field may be named: a slug, never markup. */
|
|
@@ -150,8 +161,14 @@ export declare const CARD_FIELD = "card";
|
|
|
150
161
|
export declare const CARD_ID: RegExp;
|
|
151
162
|
/** A rule's answer as a rejection: a bare reason, or a field that is not a slug, is about the `form`. */
|
|
152
163
|
export declare function rejectionOf(verdict: LeadVerdict): LeadRejection | null;
|
|
153
|
-
/**
|
|
154
|
-
|
|
164
|
+
/**
|
|
165
|
+
* The default form rule: a number we can call — the one the form blocks on
|
|
166
|
+
* (`phoneProblem`). A messenger lead needs none, but one typed must be a
|
|
167
|
+
* number: the operator may call it.
|
|
168
|
+
*/
|
|
169
|
+
export declare function validateLead(lead: Pick<LeadCandidate, "mobile"> & {
|
|
170
|
+
channel?: LeadChannel | undefined;
|
|
171
|
+
}): LeadRejection | null;
|
|
155
172
|
/** The default callback rule: the same number rule, worded for the log. */
|
|
156
173
|
export declare function validateCallbackLead(lead: Pick<LeadCandidate, "mobile">): LeadRejection | null;
|
|
157
174
|
/**
|
|
@@ -189,6 +206,12 @@ export interface Lead {
|
|
|
189
206
|
flow?: LeadFlow;
|
|
190
207
|
/** Only on an `estimate` or `fixed` lead: the price the server computed. */
|
|
191
208
|
price?: LeadPrice;
|
|
209
|
+
/**
|
|
210
|
+
* The reference the visitor's chat message carries (`AQ-7K3F`,
|
|
211
|
+
* `MESSAGE_REF`), posted by the card that minted it — on a form lead too,
|
|
212
|
+
* when its success sends the visitor on to a messenger. Not personal data.
|
|
213
|
+
*/
|
|
214
|
+
messageRef?: string;
|
|
192
215
|
}
|
|
193
216
|
/**
|
|
194
217
|
* The price a lead was taken at — the server's own number, from the posted
|
|
@@ -250,10 +273,10 @@ export interface LeadStore {
|
|
|
250
273
|
* The lead schema's version, shared by every adapter: 1 the Rust server's
|
|
251
274
|
* table (`job`, `zip`, `mobile`, `at`), 2 + the place, 3 + the spam verdict,
|
|
252
275
|
* 4 + the brand's extras, 5 + the channel and the callback's consent, 6 + the
|
|
253
|
-
* script's submission id, unique, 7 + the flow and the price it was taken at
|
|
254
|
-
* Append only.
|
|
276
|
+
* script's submission id, unique, 7 + the flow and the price it was taken at,
|
|
277
|
+
* 8 + the messenger reference (`message_ref`). Append only.
|
|
255
278
|
*/
|
|
256
|
-
export declare const LEAD_SCHEMA_VERSION =
|
|
279
|
+
export declare const LEAD_SCHEMA_VERSION = 8;
|
|
257
280
|
/** A field is capped, not rejected: a long answer is still a customer. */
|
|
258
281
|
export declare const MAX_FIELD = 200;
|
|
259
282
|
/**
|