@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
@@ -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
- /** The link a channel opens, or `null` for the two that are forms on the page. */
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;
@@ -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, or "call me back" — the form cut
88
- * to a phone number. Posted as `CHANNEL_FIELD`; anything else, or nothing (a
89
- * page cached before the field existed), is `form`.
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
- channel: LeadChannel;
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
- /** The default form rule: a number we can call — the one the form blocks on (`phoneProblem`). */
154
- export declare function validateLead(lead: Pick<LeadCandidate, "mobile">): LeadRejection | null;
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 = 7;
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
  /**
package/dist/core/lead.js CHANGED
@@ -9,9 +9,13 @@
9
9
  * `LeadSchema.wire` names the posted fields (a page cached before a rename
10
10
  * still delivers) — both deliberate departures from the first design.
11
11
  */
12
+ import { MESSAGE_REF_FIELD, messageRefOf } from "./messenger.js";
12
13
  import { normalizePhone, phoneProblem } from "./phone.js";
13
- export const LEAD_CHANNELS = ["form", "callback"];
14
+ export const LEAD_CHANNELS = ["form", "callback", "whatsapp", "telegram"];
14
15
  export const CHANNEL_FIELD = "channel";
16
+ export function isMessengerChannel(channel) {
17
+ return channel === "whatsapp" || channel === "telegram";
18
+ }
15
19
  /**
16
20
  * The callback's consent: a checkbox whose value is the sentence it shows, in
17
21
  * the language it shows it, so the row keeps what was agreed to. Absent → the
@@ -84,8 +88,14 @@ export function rejectionOf(verdict) {
84
88
  return { field: "form", why: verdict };
85
89
  return FIELD_SLUG.test(verdict.field) ? verdict : { field: "form", why: verdict.why };
86
90
  }
87
- /** The default form rule: a number we can call — the one the form blocks on (`phoneProblem`). */
91
+ /**
92
+ * The default form rule: a number we can call — the one the form blocks on
93
+ * (`phoneProblem`). A messenger lead needs none, but one typed must be a
94
+ * number: the operator may call it.
95
+ */
88
96
  export function validateLead(lead) {
97
+ if (isMessengerChannel(lead.channel) && lead.mobile.trim() === "")
98
+ return null;
89
99
  const problem = phoneProblem(lead.mobile);
90
100
  return problem === null ? null : { field: "phone", why: problem === "required" ? "a phone number" : "a phone number we can call" };
91
101
  }
@@ -113,10 +123,10 @@ export function channelOf(lead) {
113
123
  * The lead schema's version, shared by every adapter: 1 the Rust server's
114
124
  * table (`job`, `zip`, `mobile`, `at`), 2 + the place, 3 + the spam verdict,
115
125
  * 4 + the brand's extras, 5 + the channel and the callback's consent, 6 + the
116
- * script's submission id, unique, 7 + the flow and the price it was taken at.
117
- * Append only.
126
+ * script's submission id, unique, 7 + the flow and the price it was taken at,
127
+ * 8 + the messenger reference (`message_ref`). Append only.
118
128
  */
119
- export const LEAD_SCHEMA_VERSION = 7;
129
+ export const LEAD_SCHEMA_VERSION = 8;
120
130
  /** A field is capped, not rejected: a long answer is still a customer. */
121
131
  export const MAX_FIELD = 200;
122
132
  // C0 and C1 controls, DEL, and the Unicode line and paragraph separators.
@@ -148,18 +158,21 @@ export function readCandidate(schema, form, placeSlug) {
148
158
  extras[extra.name] = value;
149
159
  }
150
160
  const mobile = field(form, schema.wire.mobile, MAX_FIELD) ?? "";
151
- const callback = form.get(CHANNEL_FIELD) === "callback";
152
- const consent = callback ? field(form, CONSENT_FIELD, MAX_CONSENT) : null;
161
+ const posted = form.get(CHANNEL_FIELD);
162
+ const channel = LEAD_CHANNELS.find(c => c === posted) ?? "form";
163
+ const consent = channel === "callback" ? field(form, CONSENT_FIELD, MAX_CONSENT) : null;
153
164
  const submission = field(form, SUBMISSION_FIELD, 64);
165
+ const messageRef = messageRefOf(form.get(MESSAGE_REF_FIELD));
154
166
  return {
155
167
  subject: field(form, schema.wire.subject, MAX_FIELD) ?? "",
156
168
  locality: field(form, schema.wire.locality, MAX_FIELD) ?? "",
157
169
  mobile: schema.mobileFormat === "e164" ? (normalizePhone(mobile) ?? mobile) : mobile,
158
170
  extras,
159
171
  placeSlug,
160
- channel: callback ? "callback" : "form",
172
+ channel,
161
173
  ...(consent ? { consentText: consent } : {}),
162
174
  ...(submission && SUBMISSION_ID.test(submission) ? { submissionId: submission } : {}),
175
+ ...(messageRef ? { messageRef } : {}),
163
176
  };
164
177
  }
165
178
  /** The rule the candidate's channel answers to: the field a refusal is about, or `null`. */
@@ -0,0 +1,31 @@
1
+ /** The words of the prefilled message, one template per line; `{brand}`, `{need}`, … filled in. */
2
+ export interface MessengerMessageText {
3
+ messageHello: string;
4
+ messageNeed: string;
5
+ messagePostcode: string;
6
+ messagePrice: string;
7
+ messageTiming: string;
8
+ messageRef: string;
9
+ }
10
+ /** What the message says. Nothing typed about the person — no phone, no name. */
11
+ export interface MessengerMessageInput {
12
+ brand: string;
13
+ /** The need's label. */
14
+ need?: string | null | undefined;
15
+ postcode?: string | null | undefined;
16
+ /** The estimate the page showed, formatted (`≈ 77 €`). */
17
+ price?: string | null | undefined;
18
+ /** When the job is wanted — an urgency or a frequency label. */
19
+ timing?: string | null | undefined;
20
+ ref?: string | null | undefined;
21
+ }
22
+ /** WhatsApp shows a long message whole, but the link has to fit a QR code a phone can read off a screen. */
23
+ export declare const MAX_MESSENGER_MESSAGE = 400;
24
+ /**
25
+ * The prefilled message: a greeting, the need, the postcode, the estimate
26
+ * and the timing when known, and the reference last — the line the operator
27
+ * matches the chat by. A line without its value is left out. Past
28
+ * `MAX_MESSENGER_MESSAGE`, the optional lines go from the bottom up; the
29
+ * greeting and the reference stay.
30
+ */
31
+ export declare function messengerMessage(input: MessengerMessageInput, text: MessengerMessageText): string;
@@ -0,0 +1,33 @@
1
+ import { fillText } from "./lead-capture-format.js";
2
+ /** WhatsApp shows a long message whole, but the link has to fit a QR code a phone can read off a screen. */
3
+ export const MAX_MESSENGER_MESSAGE = 400;
4
+ // C0 and C1 controls, DEL, and the Unicode line and paragraph separators: a value is one line.
5
+ const CONTROL = /[\u0000-\u001f\u007f-\u009f\u2028\u2029]+/g;
6
+ /** One line of the message, capped: a value is a label, not a letter. */
7
+ const clean = (value, max = 80) => (value ?? "").replace(CONTROL, " ").trim().slice(0, max);
8
+ /**
9
+ * The prefilled message: a greeting, the need, the postcode, the estimate
10
+ * and the timing when known, and the reference last — the line the operator
11
+ * matches the chat by. A line without its value is left out. Past
12
+ * `MAX_MESSENGER_MESSAGE`, the optional lines go from the bottom up; the
13
+ * greeting and the reference stay.
14
+ */
15
+ export function messengerMessage(input, text) {
16
+ const line = (template, key, value) => {
17
+ const v = clean(value);
18
+ return v === "" ? null : fillText(template, { [key]: v });
19
+ };
20
+ const hello = fillText(text.messageHello, { brand: clean(input.brand) });
21
+ const ref = input.ref ? line(text.messageRef, "ref", input.ref) : null;
22
+ const optional = [
23
+ line(text.messageNeed, "need", input.need),
24
+ line(text.messagePostcode, "postcode", input.postcode),
25
+ line(text.messagePrice, "price", input.price),
26
+ line(text.messageTiming, "timing", input.timing),
27
+ ].filter((l) => l !== null);
28
+ const compose = (lines) => [hello, ...lines, ...(ref ? [ref] : [])].join("\n");
29
+ let kept = optional;
30
+ while (kept.length > 0 && compose(kept).length > MAX_MESSENGER_MESSAGE)
31
+ kept = kept.slice(0, -1);
32
+ return compose(kept).slice(0, MAX_MESSENGER_MESSAGE);
33
+ }
@@ -0,0 +1,104 @@
1
+ import type { MessengerMessageText } from "./messenger-message.js";
2
+ /**
3
+ * What a `messenger` variant of `LeadCapture` prints — and the prefilled
4
+ * message's lines. Optional in `LeadCaptureText`, as the flows' words are: a
5
+ * brand's text from before the variants still type-checks, and a key left
6
+ * out falls back to the kit's in the page's language (`messengerTextOf`).
7
+ *
8
+ * Apart from the flows' words on purpose: only a variant's own chunk reads
9
+ * them, so the control's page never downloads them.
10
+ */
11
+ export interface LeadCaptureMessengerText extends MessengerMessageText {
12
+ /** The WhatsApp button. */
13
+ messengerWhatsappCta: string;
14
+ /** `saga`: the WhatsApp step's button. */
15
+ messengerOpenWhatsapp: string;
16
+ /** The Telegram button. */
17
+ messengerTelegramCta: string;
18
+ /** Under the submit when the place has a bot: the way to it. */
19
+ messengerViaTelegram: string;
20
+ /** A channel's name in a picker, a tile, a segment — and its line under it. */
21
+ messengerOptionWhatsapp: string;
22
+ messengerOptionWhatsappNote: string;
23
+ messengerOptionCall: string;
24
+ messengerOptionCallNote: string;
25
+ messengerOptionTelegram: string;
26
+ messengerOptionTelegramNote: string;
27
+ /** The badge on the recommended channel. */
28
+ messengerRecommended: string;
29
+ /** Names a channel picker for assistive technology. */
30
+ messengerChannelLabel: string;
31
+ /** The way to the phone, beside the messengers. */
32
+ messengerCallback: string;
33
+ /** `swap`, `split`: the square back to WhatsApp, for assistive technology. */
34
+ messengerBackToWhatsapp: string;
35
+ /** The phone field's placeholder when the phone is optional. */
36
+ messengerPhoneOptional: string;
37
+ /** `select`: under the field, by channel. */
38
+ messengerWhatsappHint: string;
39
+ messengerTelegramHint: string;
40
+ /** The message's preview in the slot: its heading, and an optional line after the message. */
41
+ messengerPreviewTitle: string;
42
+ /** The estimate in the preview, `{price}` the amount («env. {price}»). */
43
+ messengerPreviewPrice: string;
44
+ messengerPreviewNote: string;
45
+ /** Telegram's card in the slot. */
46
+ messengerBotTitle: string;
47
+ messengerBotLede: string;
48
+ /** `tiles`: over the tiles. */
49
+ messengerTilesLabel: string;
50
+ /** `thanks`: the success's ask, and its WhatsApp button. */
51
+ messengerThanksTitle: string;
52
+ messengerThanksCta: string;
53
+ /** `saga`: the channel screen. */
54
+ messengerSagaTitle: string;
55
+ messengerSagaLede: string;
56
+ messengerSagaFoot: string;
57
+ /** `saga`, `sheet`: back to the channels. */
58
+ messengerChange: string;
59
+ /** `saga`, `sheet`: the second screen's heading and lede, by channel. */
60
+ messengerWhatsappTitle: string;
61
+ messengerWhatsappLede: string;
62
+ messengerTelegramTitle: string;
63
+ messengerTelegramLede: string;
64
+ messengerCallTitle: string;
65
+ messengerCallLede: string;
66
+ /** `urgency`: the question, its two answers, and what each leads to. */
67
+ messengerUrgencyLabel: string;
68
+ messengerUrgencyYes: string;
69
+ messengerUrgencyNo: string;
70
+ /** The message's timing line for an urgent need, and for one that can wait. */
71
+ messengerUrgencyToday: string;
72
+ messengerUrgencyLater: string;
73
+ messengerUrgentSubmit: string;
74
+ messengerNotUrgentTitle: string;
75
+ /** `{ref}` the reference. */
76
+ messengerNotUrgentLede: string;
77
+ /** `sheet`: the card's one button and the drawer's heading. */
78
+ messengerSheetCta: string;
79
+ messengerSheetTitle: string;
80
+ /** `chip`: the chip, by channel. */
81
+ messengerChipWhatsapp: string;
82
+ messengerChipCall: string;
83
+ messengerChipTelegram: string;
84
+ /** `split`: the WhatsApp half of the row. */
85
+ messengerSplitCta: string;
86
+ /** In an app's own browser (Instagram, TikTok…): WhatsApp may not open from it. */
87
+ messengerInappHint: string;
88
+ messengerCopy: string;
89
+ messengerCopied: string;
90
+ /** Back from the messenger: `{channel}` its name, `{ref}` the reference. */
91
+ messengerReturnTitle: string;
92
+ messengerReturnBody: string;
93
+ messengerReopen: string;
94
+ messengerReturnFailed: string;
95
+ messengerReturnDone: string;
96
+ /** On a computer: the QR code that opens WhatsApp on the phone. */
97
+ messengerQrTitle: string;
98
+ messengerQrLede: string;
99
+ messengerQrWeb: string;
100
+ messengerQrAlt: string;
101
+ }
102
+ export declare const LEAD_CAPTURE_MESSENGER_TEXT: Readonly<Record<"fr" | "en", LeadCaptureMessengerText>>;
103
+ /** The variants' words for a page: the brand's, else the kit's in its language (English for `en*`, French otherwise). */
104
+ export declare function messengerTextOf(text: Partial<LeadCaptureMessengerText>, locale: string): LeadCaptureMessengerText;
@@ -0,0 +1,150 @@
1
+ const FR = {
2
+ messageHello: "Bonjour {brand} 👋",
3
+ messageNeed: "Je souhaite un devis : {need}",
4
+ messagePostcode: "Code postal : {postcode}",
5
+ messagePrice: "Estimation vue sur le site : {price}",
6
+ messageTiming: "Délai souhaité : {timing}",
7
+ messageRef: "Réf. {ref}",
8
+ messengerWhatsappCta: "Recevoir mon devis sur WhatsApp",
9
+ messengerOpenWhatsapp: "Ouvrir WhatsApp",
10
+ messengerTelegramCta: "Ouvrir Telegram",
11
+ messengerViaTelegram: "ou via Telegram",
12
+ messengerOptionWhatsapp: "WhatsApp",
13
+ messengerOptionWhatsappNote: "Recommandé · demande déjà rédigée",
14
+ messengerOptionCall: "Appel",
15
+ messengerOptionCallNote: "On vous rappelle rapidement",
16
+ messengerOptionTelegram: "Telegram",
17
+ messengerOptionTelegramNote: "Via notre bot Telegram",
18
+ messengerRecommended: "Recommandé",
19
+ messengerChannelLabel: "Canal de réponse",
20
+ messengerCallback: "Être rappelé",
21
+ messengerBackToWhatsapp: "Revenir à WhatsApp",
22
+ messengerPhoneOptional: "Téléphone (facultatif)",
23
+ messengerWhatsappHint: "WhatsApp s’ouvre, demande déjà rédigée. Numéro facultatif.",
24
+ messengerTelegramHint: "Telegram s’ouvre : notre bot vous répond.",
25
+ messengerPreviewTitle: "Votre message est prêt",
26
+ messengerPreviewPrice: "{price}",
27
+ messengerPreviewNote: "",
28
+ messengerBotTitle: "Notre bot vous envoie le devis",
29
+ messengerBotLede: "Telegram s’ouvre : appuyez sur « Démarrer », le devis arrive dans le chat.",
30
+ messengerTilesLabel: "Recevoir mon devis par",
31
+ messengerThanksTitle: "Plus rapide : envoyez une photo",
32
+ messengerThanksCta: "Envoyer la photo sur WhatsApp",
33
+ messengerSagaTitle: "Comment voulez-vous nous joindre ?",
34
+ messengerSagaLede: "Réponse rapide, sans engagement.",
35
+ messengerSagaFoot: "Réponse rapide · sans engagement",
36
+ messengerChange: "Changer de canal",
37
+ messengerWhatsappTitle: "Sur WhatsApp",
38
+ messengerWhatsappLede: "Puis WhatsApp s’ouvre, message prêt.",
39
+ messengerTelegramTitle: "Sur Telegram",
40
+ messengerTelegramLede: "Puis Telegram s’ouvre : notre bot vous répond.",
41
+ messengerCallTitle: "On vous rappelle",
42
+ messengerCallLede: "Laissez votre numéro.",
43
+ messengerUrgencyLabel: "C’est urgent ?",
44
+ messengerUrgencyYes: "Oui, aujourd’hui",
45
+ messengerUrgencyNo: "Non, je compare",
46
+ messengerUrgencyToday: "aujourd’hui",
47
+ messengerUrgencyLater: "pas pressé",
48
+ messengerUrgentSubmit: "Rappelez-moi tout de suite",
49
+ messengerNotUrgentTitle: "Pas pressé ? Message prêt",
50
+ messengerNotUrgentLede: "Un message sur WhatsApp suffit. Réf. {ref}.",
51
+ messengerSheetCta: "Recevoir mon devis",
52
+ messengerSheetTitle: "Où recevoir votre devis ?",
53
+ messengerChipWhatsapp: "Réponse sur WhatsApp",
54
+ messengerChipCall: "Réponse par appel",
55
+ messengerChipTelegram: "Réponse sur Telegram",
56
+ messengerSplitCta: "Devis sur WhatsApp",
57
+ messengerInappHint: "WhatsApp ne s’ouvre pas ? Menu ⋯ → « Ouvrir dans le navigateur ».",
58
+ messengerCopy: "Copier le message",
59
+ messengerCopied: "Message copié",
60
+ messengerReturnTitle: "Message envoyé ?",
61
+ messengerReturnBody: "Si oui, on vous répond sur {channel} rapidement, référence {ref}.",
62
+ messengerReopen: "Rouvrir {channel}",
63
+ messengerReturnFailed: "Je n’ai pas pu envoyer → être rappelé",
64
+ messengerReturnDone: "C’est envoyé",
65
+ messengerQrTitle: "Scannez avec votre téléphone",
66
+ messengerQrLede: "WhatsApp s’ouvre avec votre demande déjà rédigée.",
67
+ messengerQrWeb: "ou ouvrir WhatsApp Web →",
68
+ messengerQrAlt: "QR code qui ouvre WhatsApp avec votre demande",
69
+ };
70
+ const EN = {
71
+ messageHello: "Hello {brand} 👋",
72
+ messageNeed: "I would like a quote: {need}",
73
+ messagePostcode: "Postcode: {postcode}",
74
+ messagePrice: "Estimate seen on the site: {price}",
75
+ messageTiming: "When: {timing}",
76
+ messageRef: "Ref. {ref}",
77
+ messengerWhatsappCta: "Get my quote on WhatsApp",
78
+ messengerOpenWhatsapp: "Open WhatsApp",
79
+ messengerTelegramCta: "Open Telegram",
80
+ messengerViaTelegram: "or via Telegram",
81
+ messengerOptionWhatsapp: "WhatsApp",
82
+ messengerOptionWhatsappNote: "Recommended · request already written",
83
+ messengerOptionCall: "Call",
84
+ messengerOptionCallNote: "We call you back shortly",
85
+ messengerOptionTelegram: "Telegram",
86
+ messengerOptionTelegramNote: "Through our Telegram bot",
87
+ messengerRecommended: "Recommended",
88
+ messengerChannelLabel: "Where to answer you",
89
+ messengerCallback: "Call me back",
90
+ messengerBackToWhatsapp: "Back to WhatsApp",
91
+ messengerPhoneOptional: "Phone (optional)",
92
+ messengerWhatsappHint: "WhatsApp opens with your request written. Phone optional.",
93
+ messengerTelegramHint: "Telegram opens: our bot answers you.",
94
+ messengerPreviewTitle: "Your message is ready",
95
+ messengerPreviewPrice: "{price}",
96
+ messengerPreviewNote: "",
97
+ messengerBotTitle: "Our bot sends you the quote",
98
+ messengerBotLede: "Telegram opens: tap “Start” and the quote arrives in the chat.",
99
+ messengerTilesLabel: "Get my quote by",
100
+ messengerThanksTitle: "Faster: send a photo",
101
+ messengerThanksCta: "Send the photo on WhatsApp",
102
+ messengerSagaTitle: "How would you like to reach us?",
103
+ messengerSagaLede: "A quick answer, no commitment.",
104
+ messengerSagaFoot: "Quick answer · no commitment",
105
+ messengerChange: "Change channel",
106
+ messengerWhatsappTitle: "On WhatsApp",
107
+ messengerWhatsappLede: "Then WhatsApp opens with the message ready.",
108
+ messengerTelegramTitle: "On Telegram",
109
+ messengerTelegramLede: "Then Telegram opens: our bot answers you.",
110
+ messengerCallTitle: "We call you back",
111
+ messengerCallLede: "Leave your number.",
112
+ messengerUrgencyLabel: "Is it urgent?",
113
+ messengerUrgencyYes: "Yes, today",
114
+ messengerUrgencyNo: "No, comparing",
115
+ messengerUrgencyToday: "today",
116
+ messengerUrgencyLater: "no rush",
117
+ messengerUrgentSubmit: "Call me right away",
118
+ messengerNotUrgentTitle: "No rush? Message ready",
119
+ messengerNotUrgentLede: "A WhatsApp message is enough. Ref. {ref}.",
120
+ messengerSheetCta: "Get my quote",
121
+ messengerSheetTitle: "Where should we send your quote?",
122
+ messengerChipWhatsapp: "Answer on WhatsApp",
123
+ messengerChipCall: "Answer by phone",
124
+ messengerChipTelegram: "Answer on Telegram",
125
+ messengerSplitCta: "Quote on WhatsApp",
126
+ messengerInappHint: "WhatsApp does not open? Menu ⋯ → “Open in browser”.",
127
+ messengerCopy: "Copy the message",
128
+ messengerCopied: "Message copied",
129
+ messengerReturnTitle: "Message sent?",
130
+ messengerReturnBody: "If so, we answer you on {channel} shortly, reference {ref}.",
131
+ messengerReopen: "Open {channel} again",
132
+ messengerReturnFailed: "I could not send it → call me back",
133
+ messengerReturnDone: "It is sent",
134
+ messengerQrTitle: "Scan with your phone",
135
+ messengerQrLede: "WhatsApp opens with your request already written.",
136
+ messengerQrWeb: "or open WhatsApp Web →",
137
+ messengerQrAlt: "QR code that opens WhatsApp with your request",
138
+ };
139
+ export const LEAD_CAPTURE_MESSENGER_TEXT = { fr: FR, en: EN };
140
+ /** The variants' words for a page: the brand's, else the kit's in its language (English for `en*`, French otherwise). */
141
+ export function messengerTextOf(text, locale) {
142
+ const kit = locale.startsWith("en") ? EN : FR;
143
+ const out = { ...kit };
144
+ for (const key of Object.keys(kit)) {
145
+ const own = text[key];
146
+ if (own !== undefined)
147
+ out[key] = own;
148
+ }
149
+ return out;
150
+ }
@@ -0,0 +1,92 @@
1
+ /**
2
+ * The messenger channels: WhatsApp, where the customer sends our prefilled
3
+ * message, and Telegram, the brand's bot opened with a deep link. Either way
4
+ * the lead is posted first, carrying a short reference the customer's message
5
+ * carries too (`message_ref`) — the one thing that joins the chat to the lead.
6
+ */
7
+ /** The form field the reference is posted under. */
8
+ export declare const MESSAGE_REF_FIELD = "message_ref";
9
+ /**
10
+ * `<PREFIX>-<4..8 chars>` in Crockford base32 without I, L, O, U — read aloud
11
+ * and typed back without a mix-up. The panel's own rule, the same regex.
12
+ */
13
+ export declare const MESSAGE_REF: RegExp;
14
+ /** A brand's reference prefix (`AQ`, `VF`). */
15
+ export declare const MESSAGE_REF_PREFIX: RegExp;
16
+ /** The random bytes a reference is drawn from; a test passes its own. */
17
+ export type RandomBytes = (length: number) => Uint8Array;
18
+ /**
19
+ * A fresh reference, `AQ-7K3F`. Not a secret and not unique — 32⁴ per prefix
20
+ * collide sooner or later, and the panel resolves a reference to the brand's
21
+ * newest lead — just short enough to survive a chat.
22
+ */
23
+ export declare function newMessageRef(prefix: string, random?: RandomBytes): string;
24
+ /** A posted reference, when it is one; anything else is dropped, never refused. */
25
+ export declare function messageRefOf(value: unknown): string | null;
26
+ /**
27
+ * A Telegram bot's username, without `@`: Telegram's own rule for a bot — 5
28
+ * to 32 characters, a letter first, ending in `bot` — and the panel's.
29
+ */
30
+ export declare const TELEGRAM_BOT: RegExp;
31
+ /**
32
+ * The bot's deep link, `https://t.me/<bot>?start=<ref>`. The `start`
33
+ * parameter allows `A-Za-z0-9_-` only — a reference always is — so anything
34
+ * else is left off rather than encoded into a link the bot would not read.
35
+ */
36
+ export declare function telegramHref(bot: string, start?: string): string;
37
+ /**
38
+ * What a card may offer for the messengers (`messengerFacts`): the place's
39
+ * own WhatsApp number — never the brand's phone as a fallback, the chat is
40
+ * answered by hand — and its bot. `null` → not offered.
41
+ */
42
+ export interface MessengerFacts {
43
+ whatsapp: string | null;
44
+ telegram: string | null;
45
+ }
46
+ /** Which messengers a card offered, as every event of a `LeadCapture` says it (`channels_available`). */
47
+ export type ChannelsAvailable = "wa,tg" | "wa" | "tg" | "none";
48
+ export declare function channelsAvailable(facts: MessengerFacts | null | undefined): ChannelsAvailable;
49
+ /**
50
+ * How a `LeadCapture` offers the messengers — an arm of the site's
51
+ * `lead_channel` experiment; `undefined` is the control. Every kind falls
52
+ * back to the control when the place has no WhatsApp (`urgency` keeps its
53
+ * question; `thanks` keeps its Telegram button), and loses its Telegram
54
+ * pieces when the place has no bot.
55
+ *
56
+ * - `select`: a channel select inside the phone field, before or after it;
57
+ * - `segment`: «WhatsApp | Appel» over a fixed slot — the message or the phone;
58
+ * - `tiles`: WhatsApp / Telegram / Appel tiles over the slot;
59
+ * - `thanks`: the control's form; its success offers WhatsApp (a photo) and Telegram;
60
+ * - `swap`: no phone until «Être rappelé» swaps the WhatsApp button for it;
61
+ * - `saga`: the channel first, on a screen of its own, then the job;
62
+ * - `urgency`: «C’est urgent ?» — posted as the brand's extra `field` — picks a call or WhatsApp;
63
+ * - `sheet`: one button, the channels in a drawer;
64
+ * - `chip`: the lede becomes a channel chip with a menu;
65
+ * - `split`: a WhatsApp button with Telegram and call squares beside it.
66
+ */
67
+ export type MessengerVariant = {
68
+ kind: "select";
69
+ side: "prefix" | "suffix";
70
+ } | {
71
+ kind: "segment";
72
+ } | {
73
+ kind: "tiles";
74
+ } | {
75
+ kind: "thanks";
76
+ } | {
77
+ kind: "swap";
78
+ } | {
79
+ kind: "saga";
80
+ } | {
81
+ kind: "urgency";
82
+ field: string;
83
+ } | {
84
+ kind: "sheet";
85
+ } | {
86
+ kind: "chip";
87
+ } | {
88
+ kind: "split";
89
+ };
90
+ export type MessengerKind = MessengerVariant["kind"];
91
+ /** A channel the visitor picked in a variant: a messenger, or the phone. */
92
+ export type MessengerMode = "whatsapp" | "telegram" | "call";