@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/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
|
-
/**
|
|
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 =
|
|
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
|
|
152
|
-
const
|
|
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
|
|
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";
|
|
@@ -0,0 +1,55 @@
|
|
|
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 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 const MESSAGE_REF = /^[A-Z]{2,4}-[0-9A-HJKMNP-TV-Z]{4,8}$/;
|
|
14
|
+
/** A brand's reference prefix (`AQ`, `VF`). */
|
|
15
|
+
export const MESSAGE_REF_PREFIX = /^[A-Z]{2,4}$/;
|
|
16
|
+
const CROCKFORD = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
|
|
17
|
+
const cryptoBytes = length => crypto.getRandomValues(new Uint8Array(length));
|
|
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 function newMessageRef(prefix, random = cryptoBytes) {
|
|
24
|
+
if (!MESSAGE_REF_PREFIX.test(prefix))
|
|
25
|
+
throw new Error(`message ref: the prefix must be 2–4 capital letters, got ${JSON.stringify(prefix)}`);
|
|
26
|
+
// 32 divides 256: a byte masked to five bits draws each symbol evenly.
|
|
27
|
+
const body = [...random(4)].map(b => CROCKFORD[b & 31] ?? "0").join("");
|
|
28
|
+
return `${prefix}-${body}`;
|
|
29
|
+
}
|
|
30
|
+
/** A posted reference, when it is one; anything else is dropped, never refused. */
|
|
31
|
+
export function messageRefOf(value) {
|
|
32
|
+
if (typeof value !== "string")
|
|
33
|
+
return null;
|
|
34
|
+
const ref = value.trim().toUpperCase();
|
|
35
|
+
return MESSAGE_REF.test(ref) ? ref : null;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* A Telegram bot's username, without `@`: Telegram's own rule for a bot — 5
|
|
39
|
+
* to 32 characters, a letter first, ending in `bot` — and the panel's.
|
|
40
|
+
*/
|
|
41
|
+
export const TELEGRAM_BOT = /^[A-Za-z][A-Za-z0-9_]{1,28}[Bb][Oo][Tt]$/;
|
|
42
|
+
/**
|
|
43
|
+
* The bot's deep link, `https://t.me/<bot>?start=<ref>`. The `start`
|
|
44
|
+
* parameter allows `A-Za-z0-9_-` only — a reference always is — so anything
|
|
45
|
+
* else is left off rather than encoded into a link the bot would not read.
|
|
46
|
+
*/
|
|
47
|
+
export function telegramHref(bot, start) {
|
|
48
|
+
const name = bot.replace(/^@/, "");
|
|
49
|
+
return start && /^[A-Za-z0-9_-]{1,64}$/.test(start) ? `https://t.me/${name}?start=${start}` : `https://t.me/${name}`;
|
|
50
|
+
}
|
|
51
|
+
export function channelsAvailable(facts) {
|
|
52
|
+
const wa = Boolean(facts?.whatsapp);
|
|
53
|
+
const tg = Boolean(facts?.telegram);
|
|
54
|
+
return wa && tg ? "wa,tg" : wa ? "wa" : tg ? "tg" : "none";
|
|
55
|
+
}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export { servedLocalities, storefrontOf, type DayOfWeek, type Geo, type OpeningHours, type Place, type PostalAddress, type Presence, type Rating, type ServiceArea, } from "./types.js";
|
|
1
|
+
export { servedLocalities, storefrontOf, type DayOfWeek, type Geo, type MessengerSwitches, type OpeningHours, type Place, type PostalAddress, type Presence, type Rating, type ServiceArea, } from "./types.js";
|
|
2
2
|
export { isPublished, publicationGaps, SERVICE_AREA_GATE, STOREFRONT_GATE, type PublicationField, type PublicationPolicy, } from "./publication.js";
|
|
3
3
|
export { DEFAULT_TIME_ZONE, isOpenAt, nextOpening, type Opening } from "./hours.js";
|
|
4
4
|
export { freshRating, parseInstant, RATING_MAX_AGE_DAYS } from "./rating.js";
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { BookingConfig, BookingRules } from "../booking/model.js";
|
|
2
|
-
import type { Geo, OpeningHours, Place, PostalAddress, Rating, ServiceArea } from "./types.js";
|
|
2
|
+
import type { Geo, MessengerSwitches, OpeningHours, Place, PostalAddress, Rating, ServiceArea } from "./types.js";
|
|
3
3
|
/**
|
|
4
4
|
* The live half of a place, as `GET <source>/locations/<slug>` answers it.
|
|
5
5
|
* Every field is optional: whatever the source leaves out, the baked config
|
|
@@ -19,6 +19,10 @@ export interface PlaceLive<L extends string> {
|
|
|
19
19
|
rating?: Rating;
|
|
20
20
|
/** Validated with the site's Cal.com hosts; one that does not validate is dropped, the baked one kept. */
|
|
21
21
|
booking?: BookingConfig;
|
|
22
|
+
/** The place's Telegram bot, its username without `@` (`TELEGRAM_BOT`). */
|
|
23
|
+
telegram?: string;
|
|
24
|
+
/** The panel's kill switches for the messengers; a key left out is on. */
|
|
25
|
+
messengers?: MessengerSwitches;
|
|
22
26
|
}
|
|
23
27
|
/**
|
|
24
28
|
* Drops what does not validate instead of failing the page on one bad field.
|
package/dist/core/place/live.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { BookingConfigError, parseBookingConfig } from "../booking/validate.js";
|
|
2
|
+
import { TELEGRAM_BOT } from "../messenger.js";
|
|
2
3
|
import { parseInstant } from "./rating.js";
|
|
3
4
|
const DAYS = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"];
|
|
4
5
|
const TIME = /^([01]\d|2[0-3]):[0-5]\d$/;
|
|
@@ -61,6 +62,17 @@ function landmark(v, locales) {
|
|
|
61
62
|
function isComplete(record, locales) {
|
|
62
63
|
return locales.every(locale => typeof record[locale] === "string");
|
|
63
64
|
}
|
|
65
|
+
/** Each switch only when it is a boolean: a switch that does not read is left on. */
|
|
66
|
+
function messengers(v) {
|
|
67
|
+
if (!isObject(v))
|
|
68
|
+
return undefined;
|
|
69
|
+
const out = {};
|
|
70
|
+
if (typeof v.whatsapp === "boolean")
|
|
71
|
+
out.whatsapp = v.whatsapp;
|
|
72
|
+
if (typeof v.telegram === "boolean")
|
|
73
|
+
out.telegram = v.telegram;
|
|
74
|
+
return Object.keys(out).length > 0 ? out : undefined;
|
|
75
|
+
}
|
|
64
76
|
function booking(v, rules) {
|
|
65
77
|
if (v === undefined || v === null)
|
|
66
78
|
return undefined;
|
|
@@ -121,6 +133,12 @@ export function parsePlaceLive(body, locales, rules = {}) {
|
|
|
121
133
|
const b = booking(body.booking, rules);
|
|
122
134
|
if (b)
|
|
123
135
|
live.booking = b;
|
|
136
|
+
const bot = str(body.telegram)?.replace(/^@/, "");
|
|
137
|
+
if (bot && TELEGRAM_BOT.test(bot))
|
|
138
|
+
live.telegram = bot;
|
|
139
|
+
const m = messengers(body.messengers);
|
|
140
|
+
if (m)
|
|
141
|
+
live.messengers = m;
|
|
124
142
|
return live;
|
|
125
143
|
}
|
|
126
144
|
/**
|
|
@@ -131,6 +149,8 @@ export function parsePlaceLive(body, locales, rules = {}) {
|
|
|
131
149
|
export function mergeLive(baked, live) {
|
|
132
150
|
const front = baked.presence;
|
|
133
151
|
const booking = live.booking ?? baked.booking;
|
|
152
|
+
const telegram = live.telegram ?? baked.channels.telegram;
|
|
153
|
+
const switches = live.messengers ?? baked.messengers;
|
|
134
154
|
return {
|
|
135
155
|
...baked,
|
|
136
156
|
presence: front.kind === "storefront"
|
|
@@ -142,10 +162,11 @@ export function mergeLive(baked, live) {
|
|
|
142
162
|
landmark: live.landmark ?? front.landmark,
|
|
143
163
|
}
|
|
144
164
|
: front,
|
|
145
|
-
channels: { phone: live.phone ?? baked.channels.phone, whatsapp: live.whatsapp ?? baked.channels.whatsapp },
|
|
165
|
+
channels: { phone: live.phone ?? baked.channels.phone, whatsapp: live.whatsapp ?? baked.channels.whatsapp, ...(telegram !== undefined ? { telegram } : {}) },
|
|
146
166
|
serviceArea: live.serviceArea ?? baked.serviceArea,
|
|
147
167
|
hours: live.hours ?? baked.hours,
|
|
148
168
|
rating: live.rating ?? baked.rating,
|
|
149
169
|
...(booking ? { booking } : {}),
|
|
170
|
+
...(switches ? { messengers: switches } : {}),
|
|
150
171
|
};
|
|
151
172
|
}
|
|
@@ -72,11 +72,22 @@ export interface Place<L extends string> {
|
|
|
72
72
|
name: Readonly<Record<L, string>>;
|
|
73
73
|
presence: Presence<L>;
|
|
74
74
|
serviceArea: readonly ServiceArea[] | null;
|
|
75
|
-
/**
|
|
75
|
+
/**
|
|
76
|
+
* International numbers as printed; `null` → the brand's own. `telegram`:
|
|
77
|
+
* the place's bot, its username without `@` (`PlaceLive.telegram`) —
|
|
78
|
+
* absent or `null`, no bot; never the brand's.
|
|
79
|
+
*/
|
|
76
80
|
channels: {
|
|
77
81
|
phone: string | null;
|
|
78
82
|
whatsapp: string | null;
|
|
83
|
+
telegram?: string | null;
|
|
79
84
|
};
|
|
85
|
+
/**
|
|
86
|
+
* The panel's switches for the messengers (`PlaceLive.messengers`): `false`
|
|
87
|
+
* takes one off the lead form's messenger variants (`messengerFacts`) only —
|
|
88
|
+
* `contactOf`, the header's and the call bar's numbers, keeps it; absent is on.
|
|
89
|
+
*/
|
|
90
|
+
messengers?: MessengerSwitches;
|
|
80
91
|
hours: readonly OpeningHours[] | null;
|
|
81
92
|
/** Live only. */
|
|
82
93
|
rating: Rating | null;
|
|
@@ -86,6 +97,11 @@ export interface Place<L extends string> {
|
|
|
86
97
|
*/
|
|
87
98
|
booking?: BookingConfig;
|
|
88
99
|
}
|
|
100
|
+
/** Which messengers the panel lets a place offer; a key left out is on. */
|
|
101
|
+
export interface MessengerSwitches {
|
|
102
|
+
whatsapp?: boolean;
|
|
103
|
+
telegram?: boolean;
|
|
104
|
+
}
|
|
89
105
|
/** The storefront half of a place, or `null` for a service-area business. */
|
|
90
106
|
export declare function storefrontOf<L extends string>(place: Place<L>): Extract<Presence<L>, {
|
|
91
107
|
kind: "storefront";
|