@evinvest/kitstart 0.17.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.
- package/README.md +147 -10
- 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/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/LeadCapture.d.ts +24 -1
- package/dist/react/LeadCapture.js +96 -11
- 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/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 +24 -4
- 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/index.d.ts +1 -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 +5 -2
- package/template/flake.nix +1 -1
- package/template/package.json +2 -2
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { cn } from "@evinvest/uikit";
|
|
4
|
+
import { hrefOf, InappHint, MessengerCta, QrPanel, useDesktop, useMessage, useMessengerAction, wordsOf } from "./action.js";
|
|
5
|
+
/**
|
|
6
|
+
* AQ-3: the form is the control's; its success adds the faster way — a photo
|
|
7
|
+
* on WhatsApp, the message carrying the lead's own reference — and the bot.
|
|
8
|
+
* On a computer, WhatsApp is a QR code to scan with the phone that takes the
|
|
9
|
+
* photo. The lead is sent already: a tap here posts nothing.
|
|
10
|
+
*/
|
|
11
|
+
export default function Thanks({ kit }) {
|
|
12
|
+
const words = wordsOf(kit);
|
|
13
|
+
const message = useMessage(kit);
|
|
14
|
+
const action = useMessengerAction(kit, message, () => undefined, { followUp: true });
|
|
15
|
+
const desktop = useDesktop();
|
|
16
|
+
const whatsapp = hrefOf(kit, "whatsapp", message.message);
|
|
17
|
+
return (_jsxs("div", { className: cn("flex flex-col gap-3 border-t border-border pt-4", kit.classNames?.messenger), children: [whatsapp && !desktop && _jsx("p", { className: "font-medium text-ink", children: words.messengerThanksTitle }), whatsapp && (desktop ? _jsx(QrPanel, { href: whatsapp, words: words, onCallback: null, kit: kit }) : _jsx(MessengerCta, { kit: kit, action: action, channel: "whatsapp", label: words.messengerThanksCta })), whatsapp && !desktop && _jsx(InappHint, { kit: kit, message: message.message }), kit.facts.telegram && _jsx(MessengerCta, { kit: kit, action: action, channel: "telegram", label: desktop ? words.messengerTelegramCta : words.messengerOptionTelegram, variant: "outline" })] }));
|
|
18
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import type { MessengerKit } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* VF-2: «Recevoir mon devis par» and a tile per channel — WhatsApp, Telegram,
|
|
4
|
+
* Appel — over a slot of one height: the message, what the bot does, or the phone.
|
|
5
|
+
*/
|
|
6
|
+
export default function Tiles({ kit }: {
|
|
7
|
+
kit: MessengerKit;
|
|
8
|
+
}): import("react").JSX.Element;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { cn, ToggleGroup, ToggleGroupItem } from "@evinvest/uikit";
|
|
4
|
+
import { Glyph, labelOf, ModeBody, modesOf, toCall, useMessage, useMessengerAction, wordsOf } from "./action.js";
|
|
5
|
+
/**
|
|
6
|
+
* VF-2: «Recevoir mon devis par» and a tile per channel — WhatsApp, Telegram,
|
|
7
|
+
* Appel — over a slot of one height: the message, what the bot does, or the phone.
|
|
8
|
+
*/
|
|
9
|
+
export default function Tiles({ kit }) {
|
|
10
|
+
const words = wordsOf(kit);
|
|
11
|
+
const message = useMessage(kit);
|
|
12
|
+
const action = useMessengerAction(kit, message, () => toCall(kit));
|
|
13
|
+
const modes = modesOf(kit, ["whatsapp", "telegram", "call"]);
|
|
14
|
+
const mode = kit.mode ?? "whatsapp";
|
|
15
|
+
return (_jsxs("div", { className: cn("flex flex-col gap-3", kit.classNames?.messenger), children: [_jsx("p", { className: "text-sm font-medium text-ink", children: words.messengerTilesLabel }), _jsx(ToggleGroup, { role: "group", type: "single", value: mode, onValueChange: v => {
|
|
16
|
+
const picked = modes.find((m) => m === v);
|
|
17
|
+
if (picked)
|
|
18
|
+
kit.setMode(picked);
|
|
19
|
+
}, "aria-label": words.messengerTilesLabel, className: cn("grid w-full gap-2", modes.length === 3 ? "grid-cols-3" : "grid-cols-2", kit.classNames?.messengerPicker), children: modes.map(m => (_jsxs(ToggleGroupItem, { value: m, className: cn("h-11 gap-2 rounded-[var(--control-radius)] border border-border first:rounded-[var(--control-radius)] last:rounded-[var(--control-radius)] data-[state=on]:border-primary data-[state=on]:text-ink", kit.classNames?.messengerOption, kit.classNames?.messengerTile), children: [_jsx(Glyph, { kit: kit, channel: m }), labelOf(words, m)] }, m))) }), _jsx(ModeBody, { kit: kit, message: message, action: action }), action.overlay] }));
|
|
20
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import type { LeadWire } from "../../core/lead.js";
|
|
3
|
+
import type { LeadCaptureText } from "../../core/lead-capture-text.js";
|
|
4
|
+
import type { MessengerFacts, MessengerMode, MessengerVariant } from "../../core/messenger.js";
|
|
5
|
+
import type { ChannelIconKey, Experiment } from "../LeadCaptureChannels.js";
|
|
6
|
+
import type { LeadNeedOption } from "../LeadCaptureNeed.js";
|
|
7
|
+
import type { PartClassNames } from "../parts.js";
|
|
8
|
+
import type { LeadEvents } from "../use-lead-events.js";
|
|
9
|
+
/**
|
|
10
|
+
* Where in the card a variant draws: in place of the heading (`head`), under
|
|
11
|
+
* it (`afterHead`), before the need (`beforeNeed`), in place of the form's
|
|
12
|
+
* fields (`body`), in place of the phone and the submit (`contact`), under
|
|
13
|
+
* the submit (`afterSubmit`), or in the success (`done`).
|
|
14
|
+
*/
|
|
15
|
+
export type MessengerAt = "head" | "afterHead" | "beforeNeed" | "body" | "contact" | "afterSubmit" | "done";
|
|
16
|
+
/** A variant as drawn: its own kind, or the control with a way to the bot (`fallback`). */
|
|
17
|
+
export type MessengerShown = MessengerVariant["kind"] | "fallback";
|
|
18
|
+
/**
|
|
19
|
+
* The parts a variant dresses; a brand sizes `messengerSlot` (92 px, 72 px)
|
|
20
|
+
* so no state of a variant moves the card. `messengerCta`: a variant's main
|
|
21
|
+
* button — a messenger's, or the call's submit; `messengerSecondary`: the
|
|
22
|
+
* others (Telegram and «Être rappelé» beside it, «ou via Telegram», the way
|
|
23
|
+
* back to the chat); `messengerSquare`: the icon squares; `messengerSegment`,
|
|
24
|
+
* `messengerTile`: a segment's and a tile's item, after `messengerOption`;
|
|
25
|
+
* `messengerTrigger`: the channel picker's button (`select`, `chip`).
|
|
26
|
+
*/
|
|
27
|
+
export type MessengerPart = "messenger" | "messengerSlot" | "messengerCta" | "messengerSecondary" | "messengerSegment" | "messengerTile" | "messengerTrigger" | "messengerPreview" | "messengerPicker" | "messengerOption" | "messengerSquare" | "messengerQr" | "messengerReturn" | "messengerHint";
|
|
28
|
+
/** How a variant asks for the card's phone field. */
|
|
29
|
+
export interface PhoneOptions {
|
|
30
|
+
optional?: boolean;
|
|
31
|
+
disabled?: boolean;
|
|
32
|
+
placeholder?: string;
|
|
33
|
+
/** The variant's box around the input (`PhoneField`'s `frame`). */
|
|
34
|
+
frame?: (input: ReactNode) => ReactNode;
|
|
35
|
+
/** The form's id, for a field drawn outside the form's element (a drawer). */
|
|
36
|
+
form?: string;
|
|
37
|
+
/** Under the field instead of the brand's `afterPhone`. */
|
|
38
|
+
after?: ReactNode;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* What `LeadCapture` hands a variant: the card's facts and state, and its own
|
|
42
|
+
* pieces — the phone field and the submit — so a variant arranges them and
|
|
43
|
+
* never re-implements them.
|
|
44
|
+
*/
|
|
45
|
+
export interface MessengerKit {
|
|
46
|
+
at: MessengerAt;
|
|
47
|
+
variant: MessengerVariant;
|
|
48
|
+
shown: MessengerShown;
|
|
49
|
+
facts: MessengerFacts;
|
|
50
|
+
/** The card's id; its form is `<id>-form`. */
|
|
51
|
+
id: string;
|
|
52
|
+
locale: string;
|
|
53
|
+
/** The brand's name, the message's greeting. */
|
|
54
|
+
brand: string;
|
|
55
|
+
text: LeadCaptureText;
|
|
56
|
+
wire: LeadWire;
|
|
57
|
+
needs: readonly LeadNeedOption[];
|
|
58
|
+
/** The need on screen, by its label — for the message. */
|
|
59
|
+
needLabel: string | null;
|
|
60
|
+
/** The estimate the card shows, formatted — for the message. */
|
|
61
|
+
priceText: string | null;
|
|
62
|
+
/** The estimate's answers, by their short labels, in order — the timing question's left out. */
|
|
63
|
+
answers: readonly string[];
|
|
64
|
+
/**
|
|
65
|
+
* The answer to the estimate's question the brand names (`messengerTiming`):
|
|
66
|
+
* its label for the message's timing line, its short one for the preview.
|
|
67
|
+
*/
|
|
68
|
+
timing: {
|
|
69
|
+
label: string;
|
|
70
|
+
short: string;
|
|
71
|
+
} | null;
|
|
72
|
+
/** The channel the visitor is on; `null` before a variant asks (`saga`, `urgency`). */
|
|
73
|
+
mode: MessengerMode | null;
|
|
74
|
+
setMode: (mode: MessengerMode | null) => void;
|
|
75
|
+
/** The card's reference (`message_ref`), minted once the script runs; `null` before. */
|
|
76
|
+
messageRef: string | null;
|
|
77
|
+
/**
|
|
78
|
+
* Whether a messenger link may lead anywhere: the script runs and the
|
|
79
|
+
* reference is minted. Until then a tap would open the chat with no lead
|
|
80
|
+
* posted and no `Réf.` to find it by — the links are inert.
|
|
81
|
+
*/
|
|
82
|
+
ready: boolean;
|
|
83
|
+
/** A new reference, once a messenger lead is posted. */
|
|
84
|
+
renewRef: () => void;
|
|
85
|
+
phone: (options?: PhoneOptions) => ReactNode;
|
|
86
|
+
/** The card's submit — a call lead, posted as the control posts it. */
|
|
87
|
+
submit: (label?: string, options?: {
|
|
88
|
+
form?: string;
|
|
89
|
+
className?: string;
|
|
90
|
+
}) => ReactNode;
|
|
91
|
+
/** The default heading's words, for a variant that redraws the heading. */
|
|
92
|
+
title: string;
|
|
93
|
+
lede: string;
|
|
94
|
+
events: LeadEvents;
|
|
95
|
+
icons: Partial<Record<ChannelIconKey, ReactNode>> | undefined;
|
|
96
|
+
experiment: Experiment | undefined;
|
|
97
|
+
classNames: PartClassNames<MessengerPart> | undefined;
|
|
98
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { MessengerKit } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* AQ-6: «C’est urgent ?» picks the channel — today is a call, «je compare»
|
|
4
|
+
* the message ready in the slot — and the answer is the lead's, posted as
|
|
5
|
+
* the brand's extra (`field`, which `site.lead.extras` must list). Without
|
|
6
|
+
* WhatsApp the question stays, and both answers ask the phone.
|
|
7
|
+
*/
|
|
8
|
+
export default function Urgency({ kit }: {
|
|
9
|
+
kit: MessengerKit;
|
|
10
|
+
}): import("react").JSX.Element;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { cn, ToggleGroup, ToggleGroupItem } from "@evinvest/uikit";
|
|
4
|
+
import { fillText } from "../../core/lead-capture-format.js";
|
|
5
|
+
import { MessengerCta, InappHint, Preview, Slot, TelegramLink, toCall, useMessage, useMessengerAction, wordsOf } from "./action.js";
|
|
6
|
+
/** What the answer is posted as, in the brand's extra: a slug, never the label. */
|
|
7
|
+
const ANSWER = { call: "today", whatsapp: "later" };
|
|
8
|
+
/**
|
|
9
|
+
* AQ-6: «C’est urgent ?» picks the channel — today is a call, «je compare»
|
|
10
|
+
* the message ready in the slot — and the answer is the lead's, posted as
|
|
11
|
+
* the brand's extra (`field`, which `site.lead.extras` must list). Without
|
|
12
|
+
* WhatsApp the question stays, and both answers ask the phone.
|
|
13
|
+
*/
|
|
14
|
+
export default function Urgency({ kit }) {
|
|
15
|
+
return kit.at === "beforeNeed" ? _jsx(Question, { kit: kit }) : _jsx(UrgencyContact, { kit: kit });
|
|
16
|
+
}
|
|
17
|
+
function Question({ kit }) {
|
|
18
|
+
const words = wordsOf(kit);
|
|
19
|
+
const field = kit.variant.kind === "urgency" ? kit.variant.field : "urgency";
|
|
20
|
+
const answer = kit.mode === "call" || kit.mode === "whatsapp" ? kit.mode : null;
|
|
21
|
+
return (_jsxs("div", { className: cn("flex flex-col gap-2", kit.classNames?.messenger), children: [_jsx("p", { className: "text-sm font-medium text-ink", children: words.messengerUrgencyLabel }), _jsx(ToggleGroup, { role: "group", type: "single", value: answer ?? "", onValueChange: v => (v === "call" || v === "whatsapp" ? kit.setMode(v) : undefined), "aria-label": words.messengerUrgencyLabel, className: cn("grid w-full grid-cols-2 gap-2", kit.classNames?.messengerPicker), children: ["call", "whatsapp"].map(m => (_jsx(ToggleGroupItem, { value: m, className: cn("h-11 rounded-[var(--control-radius)] border border-border first:rounded-[var(--control-radius)] last:rounded-[var(--control-radius)] data-[state=on]:border-primary data-[state=on]:text-ink", kit.classNames?.messengerOption, kit.classNames?.messengerTile), children: m === "call" ? words.messengerUrgencyYes : words.messengerUrgencyNo }, m))) }), answer && _jsx("input", { type: "hidden", name: field, value: ANSWER[answer] })] }));
|
|
22
|
+
}
|
|
23
|
+
function UrgencyContact({ kit }) {
|
|
24
|
+
const words = wordsOf(kit);
|
|
25
|
+
const message = useMessage(kit, kit.mode === "call" ? words.messengerUrgencyToday : kit.mode === "whatsapp" ? words.messengerUrgencyLater : null);
|
|
26
|
+
const action = useMessengerAction(kit, message, () => toCall(kit));
|
|
27
|
+
const chat = kit.mode === "whatsapp" && kit.facts.whatsapp !== null;
|
|
28
|
+
return (_jsxs("div", { className: cn("flex flex-col gap-3", kit.classNames?.messenger), children: [_jsx(Slot, { kit: kit, children: chat ? (action.panel ?? _jsx(Preview, { kit: kit, preview: message.preview, title: words.messengerNotUrgentTitle, lede: fillText(words.messengerNotUrgentLede, { ref: kit.messageRef ?? "" }) })) : kit.phone() }), chat ? _jsx(MessengerCta, { kit: kit, action: action, channel: "whatsapp", label: words.messengerWhatsappCta }) : kit.submit(kit.mode === "call" ? words.messengerUrgentSubmit : undefined), chat && _jsx(InappHint, { kit: kit, message: message.message }), _jsx(TelegramLink, { kit: kit, action: action }), action.overlay] }));
|
|
29
|
+
}
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import { type RefObject } from "react";
|
|
2
|
-
import { type
|
|
2
|
+
import { type LeadError } from "../core/lead.js";
|
|
3
3
|
import type { LeadCaptureText } from "../core/lead-capture-text.js";
|
|
4
4
|
export type { LeadError };
|
|
5
5
|
/** The fields `LeadCapture` draws itself, which show a refusal under themselves. */
|
|
6
|
-
export declare const OWN_FIELDS: Readonly<Record<
|
|
6
|
+
export declare const OWN_FIELDS: Readonly<Record<LeadError["channel"], readonly string[]>>;
|
|
7
7
|
/** The id of the message a refusal of a field the card does not draw is shown in, above the submit. */
|
|
8
|
-
export declare const formMessageId: (id: string, channel:
|
|
8
|
+
export declare const formMessageId: (id: string, channel: LeadError["channel"]) => string;
|
|
9
9
|
/** The words for a refused field: the field's own rule where the form has one. */
|
|
10
10
|
export declare function errorText(field: string, text: LeadCaptureText): string;
|
|
11
11
|
/**
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { type RefObject } from "react";
|
|
2
2
|
import { type LeadField, type LeadStep } from "../core/analytics.js";
|
|
3
|
+
import type { ChannelsAvailable } from "../core/messenger.js";
|
|
3
4
|
/** The attribute a lead form's control names its role in, for `lead_form_field_error`. */
|
|
4
5
|
export declare const FIELD_ATTR = "data-lead-field";
|
|
5
6
|
export interface LeadEvents {
|
|
@@ -10,13 +11,30 @@ export interface LeadEvents {
|
|
|
10
11
|
submitError(reason: "network" | "timeout", channel: "form" | "callback"): void;
|
|
11
12
|
/** An estimate's price for a full set of answers: its band only, once per need and band. */
|
|
12
13
|
estimateShown(need: string, cents: number): void;
|
|
14
|
+
/** A messenger CTA: the lead posted, the visitor sent on — never what was typed. */
|
|
15
|
+
messengerOpen(open: {
|
|
16
|
+
channel: "whatsapp" | "telegram";
|
|
17
|
+
device: "mobile" | "desktop";
|
|
18
|
+
inapp: boolean;
|
|
19
|
+
ref: string | null;
|
|
20
|
+
}): void;
|
|
21
|
+
/** On a computer: the QR code that opens WhatsApp on the phone was shown (`contact_intent_click`, `whatsapp_qr`). */
|
|
22
|
+
qrShown(): void;
|
|
23
|
+
/** Back on the page after a messenger: what the visitor said of it. */
|
|
24
|
+
messengerReturn(back: {
|
|
25
|
+
channel: "whatsapp" | "telegram";
|
|
26
|
+
answer: "sent" | "failed";
|
|
27
|
+
ref: string | null;
|
|
28
|
+
}): void;
|
|
13
29
|
}
|
|
14
30
|
/**
|
|
15
31
|
* The lead form's own funnel, reported through the boundary's sink: seen
|
|
16
32
|
* (half of it in view, once), started (the first focus inside, once), a
|
|
17
33
|
* field the browser refused (`invalid`, by role — never the value), and the
|
|
18
34
|
* step `qualify-first` moved to. Every event carries the form, the layout and
|
|
19
|
-
* the site's experiment, so the arms of a test compare on one schema
|
|
35
|
+
* the site's experiment, so the arms of a test compare on one schema — and
|
|
36
|
+
* the messengers the card offered (`channels_available`) and its `messenger`
|
|
37
|
+
* variant, so an arm that fell back to the control is told apart.
|
|
20
38
|
*/
|
|
21
39
|
export declare function useLeadEvents(root: RefObject<HTMLElement | null>, tags: {
|
|
22
40
|
formId: string;
|
|
@@ -25,4 +43,6 @@ export declare function useLeadEvents(root: RefObject<HTMLElement | null>, tags:
|
|
|
25
43
|
name: string;
|
|
26
44
|
variant: string;
|
|
27
45
|
} | undefined;
|
|
46
|
+
channels?: ChannelsAvailable | undefined;
|
|
47
|
+
messenger?: string | undefined;
|
|
28
48
|
}): LeadEvents;
|
|
@@ -10,14 +10,16 @@ export const FIELD_ATTR = "data-lead-field";
|
|
|
10
10
|
* (half of it in view, once), started (the first focus inside, once), a
|
|
11
11
|
* field the browser refused (`invalid`, by role — never the value), and the
|
|
12
12
|
* step `qualify-first` moved to. Every event carries the form, the layout and
|
|
13
|
-
* the site's experiment, so the arms of a test compare on one schema
|
|
13
|
+
* the site's experiment, so the arms of a test compare on one schema — and
|
|
14
|
+
* the messengers the card offered (`channels_available`) and its `messenger`
|
|
15
|
+
* variant, so an arm that fell back to the control is told apart.
|
|
14
16
|
*/
|
|
15
17
|
export function useLeadEvents(root, tags) {
|
|
16
18
|
const sink = useAnalyticsSink();
|
|
17
|
-
const { formId, layout } = tags;
|
|
19
|
+
const { formId, layout, channels, messenger } = tags;
|
|
18
20
|
const name = tags.experiment?.name;
|
|
19
21
|
const variant = tags.experiment?.variant;
|
|
20
|
-
const props = useMemo(() => ({ form_id: formId, layout, ...experimentProps(name, variant) }), [formId, layout, name, variant]);
|
|
22
|
+
const props = useMemo(() => ({ form_id: formId, layout, ...experimentProps(name, variant), ...(channels ? { channels_available: channels } : {}), ...(messenger ? { messenger_variant: messenger } : {}) }), [formId, layout, name, variant, channels, messenger]);
|
|
21
23
|
const started = useRef(false);
|
|
22
24
|
const shown = useRef(new Set());
|
|
23
25
|
useEffect(() => {
|
|
@@ -69,5 +71,9 @@ export function useLeadEvents(root, tags) {
|
|
|
69
71
|
shown.current.add(key);
|
|
70
72
|
sink?.capture(EVENTS.estimateShown, { ...props, need, cents_bucket });
|
|
71
73
|
},
|
|
74
|
+
// Beacons: the page is being left for the app.
|
|
75
|
+
messengerOpen: ({ channel, device, inapp, ref }) => sink?.capture(EVENTS.messengerOpen, { ...props, channel, device, inapp, ...(ref ? { message_ref: ref } : {}) }, { transport: "beacon" }),
|
|
76
|
+
qrShown: () => sink?.capture(EVENTS.intent, { ...props, channel: "whatsapp_qr" }),
|
|
77
|
+
messengerReturn: ({ channel, answer, ref }) => sink?.capture(EVENTS.messengerReturn, { ...props, channel, answer, ...(ref ? { message_ref: ref } : {}) }),
|
|
72
78
|
}), [sink, props]);
|
|
73
79
|
}
|
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
import { type FormEventHandler } from "react";
|
|
2
2
|
import { type LeadChannel } from "../core/lead.js";
|
|
3
|
+
/**
|
|
4
|
+
* Which of the card's forms a submit posts: a messenger lead is posted in the
|
|
5
|
+
* background instead (`postInBackground`), and never answers in the card.
|
|
6
|
+
*/
|
|
7
|
+
export type FormChannel = Extract<LeadChannel, "form" | "callback">;
|
|
3
8
|
/** What the visitor just sent, for a brand's in-card success ("we call 06 … back"). */
|
|
4
9
|
export interface LeadSent {
|
|
5
|
-
channel:
|
|
10
|
+
channel: FormChannel;
|
|
6
11
|
/** As typed. */
|
|
7
12
|
phone: string;
|
|
8
13
|
/** The name field's value, when the form has one and it was filled. */
|
|
@@ -26,6 +31,15 @@ export declare const navigation: {
|
|
|
26
31
|
* over plain http is not one, but `getRandomValues` works everywhere.
|
|
27
32
|
*/
|
|
28
33
|
export declare function newSubmissionId(): string;
|
|
34
|
+
/**
|
|
35
|
+
* A messenger lead: the form as it stands, with `extra` over it (the
|
|
36
|
+
* channel, the reference), posted as the visitor leaves for the chat —
|
|
37
|
+
* `keepalive`, so the request outlives the page handing over to the app. No
|
|
38
|
+
* answer is waited for and none is shown: the chat is where it goes on. A
|
|
39
|
+
* second tap on the same lead carries the same submission id, so the route
|
|
40
|
+
* stores it once. Resolves whether the route took it.
|
|
41
|
+
*/
|
|
42
|
+
export declare function postInBackground(form: HTMLFormElement, extra: Readonly<Record<string, string>>, analyticsId: string | null): Promise<boolean>;
|
|
29
43
|
/** Why the script's post got no answer: none came back (`network`), or none in time (`timeout`). */
|
|
30
44
|
export type SendFailure = "network" | "timeout";
|
|
31
45
|
/** How long the script waits for `/quote` before it says so; the lead is a few short fields. */
|
|
@@ -33,18 +47,18 @@ export declare const SUBMIT_TIMEOUT_MS = 15000;
|
|
|
33
47
|
export interface LeadSubmitHandlers {
|
|
34
48
|
/** The route refused the lead: the field to fix. */
|
|
35
49
|
/** `cents`: with `price_changed`, the server's fresh price. */
|
|
36
|
-
onRefused: (channel:
|
|
50
|
+
onRefused: (channel: FormChannel, field: string, cents?: number) => void;
|
|
37
51
|
/** No answer: the form stays as typed, with a retry. */
|
|
38
|
-
onFailed: (channel:
|
|
52
|
+
onFailed: (channel: FormChannel, failure: SendFailure) => void;
|
|
39
53
|
}
|
|
40
54
|
export interface LeadSubmit {
|
|
41
55
|
sent: LeadSent | null;
|
|
42
56
|
onSubmit: FormEventHandler<HTMLFormElement>;
|
|
43
57
|
/** The form being posted, while it is. */
|
|
44
|
-
busy:
|
|
58
|
+
busy: FormChannel | null;
|
|
45
59
|
/** The last post that got no answer, until the next one. */
|
|
46
60
|
failure: {
|
|
47
|
-
channel:
|
|
61
|
+
channel: FormChannel;
|
|
48
62
|
failure: SendFailure;
|
|
49
63
|
} | null;
|
|
50
64
|
/** Posts that form again — the same submission id, so never a second lead. */
|
|
@@ -60,7 +74,7 @@ export interface LeadSubmit {
|
|
|
60
74
|
* form. Any other answer (the store's 500) submits the form for real, so the
|
|
61
75
|
* server's own page says what went wrong; the submission id makes that safe.
|
|
62
76
|
*/
|
|
63
|
-
export declare function useLeadSubmit(done: boolean | ((channel:
|
|
77
|
+
export declare function useLeadSubmit(done: boolean | ((channel: FormChannel) => boolean), fields: {
|
|
64
78
|
mobile: string;
|
|
65
79
|
name: string | undefined;
|
|
66
80
|
}, on: LeadSubmitHandlers): LeadSubmit;
|
|
@@ -31,18 +31,40 @@ const minted = new WeakMap();
|
|
|
31
31
|
* fixed after a timeout is another lead, which must not be answered with the
|
|
32
32
|
* first one's row.
|
|
33
33
|
*/
|
|
34
|
-
function stamp(form) {
|
|
34
|
+
function stamp(form, extra = {}) {
|
|
35
35
|
const field = form.elements.namedItem(SUBMISSION_FIELD);
|
|
36
36
|
if (!(field instanceof HTMLInputElement))
|
|
37
37
|
return;
|
|
38
38
|
const posted = new FormData(form);
|
|
39
39
|
posted.delete(SUBMISSION_FIELD);
|
|
40
|
+
for (const [key, value] of Object.entries(extra))
|
|
41
|
+
posted.set(key, value);
|
|
40
42
|
const lead = new URLSearchParams([...posted].filter((e) => typeof e[1] === "string")).toString();
|
|
41
43
|
const known = minted.get(form);
|
|
42
44
|
const id = known && known.lead === lead ? known.id : newSubmissionId();
|
|
43
45
|
minted.set(form, { id, lead });
|
|
44
46
|
field.value = id;
|
|
45
47
|
}
|
|
48
|
+
/**
|
|
49
|
+
* A messenger lead: the form as it stands, with `extra` over it (the
|
|
50
|
+
* channel, the reference), posted as the visitor leaves for the chat —
|
|
51
|
+
* `keepalive`, so the request outlives the page handing over to the app. No
|
|
52
|
+
* answer is waited for and none is shown: the chat is where it goes on. A
|
|
53
|
+
* second tap on the same lead carries the same submission id, so the route
|
|
54
|
+
* stores it once. Resolves whether the route took it.
|
|
55
|
+
*/
|
|
56
|
+
export function postInBackground(form, extra, analyticsId) {
|
|
57
|
+
stamp(form, extra);
|
|
58
|
+
const body = new URLSearchParams();
|
|
59
|
+
for (const [key, value] of new FormData(form))
|
|
60
|
+
if (typeof value === "string" && !(key in extra))
|
|
61
|
+
body.append(key, value);
|
|
62
|
+
for (const [key, value] of Object.entries(extra))
|
|
63
|
+
body.set(key, value);
|
|
64
|
+
if (analyticsId)
|
|
65
|
+
body.set(ANALYTICS_ID_FIELD, analyticsId);
|
|
66
|
+
return fetch(form.action, { method: "POST", body, headers: { Accept: "application/json" }, keepalive: true }).then(res => res.ok, () => false);
|
|
67
|
+
}
|
|
46
68
|
const LEAD_REF = /^lead-\d+-[0-9a-f]{8}$/;
|
|
47
69
|
/** A root-relative path on this origin — never `//elsewhere`. */
|
|
48
70
|
const OWN_PATH = /^\/(?!\/)/;
|
package/dist/server/index.d.ts
CHANGED
|
@@ -16,6 +16,6 @@ export { parseSmtpUrl, sendMail, type Mail, type SendOptions } from "./smtp.js";
|
|
|
16
16
|
export { defaultLeadMail, leadNotifier, MAIL_PER_MINUTE, type LeadMail, type LeadMailShown, type LeadNotifier, type NeedLabel, type NotifyEnv } from "./notify.js";
|
|
17
17
|
export { clientKey, NO_CLIENT_ADDRESS, parseProxyTrust, type ProxyTrust } from "./client-key.js";
|
|
18
18
|
export { checkLeadStore } from "./boot.js";
|
|
19
|
-
export { leadWebhook, panelChannel, panelFlowOf, panelFlowProperties, type BookingQueued, type BookingWebhookContext, type BuildBookingBody, type BuildWebhookBody, type LeadWebhook, type LeadWebhookContext, type LeadWebhookOptions, type PanelFlow, type PanelFlowProperties, } from "./lead-webhook.js";
|
|
19
|
+
export { leadWebhook, panelChannel, panelFlowOf, panelFlowProperties, type BookingQueued, type BookingWebhookContext, type BuildBookingBody, type BuildWebhookBody, type LeadWebhook, type LeadWebhookContext, type LeadWebhookOptions, type PanelChannel, type PanelFlow, type PanelFlowProperties, } from "./lead-webhook.js";
|
|
20
20
|
export { checkWebhookUrl, openWebhookOutbox, WEBHOOK_HORIZON_MS, WEBHOOK_MAX_ATTEMPTS, WEBHOOK_MAX_DELAY_MS, WEBHOOK_TICK_MS, type DeadRow, type OutboxRow, type OutboxState, type TickReport, type WebhookOutbox, type WebhookOutboxOptions, type WebhookTarget, } from "./webhook-outbox.js";
|
|
21
21
|
export { signatureHeaders, signWebhook, type WebhookSignatureHeaders, type WebhookSigning } from "./webhook-signature.js";
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import "server-only";
|
|
2
2
|
import { mkdirSync } from "node:fs";
|
|
3
3
|
import { dirname } from "node:path";
|
|
4
|
-
import { channelOf, LEAD_SCHEMA_VERSION } from "../core/lead.js";
|
|
4
|
+
import { channelOf, LEAD_CHANNELS, LEAD_SCHEMA_VERSION } from "../core/lead.js";
|
|
5
5
|
import { LEAD_FLOWS } from "../core/pricing/flow.js";
|
|
6
6
|
// `node:sqlite` is reached through `getBuiltinModule` rather than an import so
|
|
7
7
|
// the bundler never has to resolve a builtin it may not know yet; the type is
|
|
@@ -67,6 +67,10 @@ const STEPS = [
|
|
|
67
67
|
db.exec("ALTER TABLE leads ADD COLUMN pricing_valid_from TEXT");
|
|
68
68
|
db.exec("ALTER TABLE leads ADD COLUMN estimate_inputs TEXT");
|
|
69
69
|
},
|
|
70
|
+
// 8 — the reference a messenger lead's chat carries (`AQ-7K3F`), so the
|
|
71
|
+
// operator finds the lead from the message. Nullable: a row from before, a
|
|
72
|
+
// lead from a card without a reference.
|
|
73
|
+
db => db.exec("ALTER TABLE leads ADD COLUMN message_ref TEXT"),
|
|
70
74
|
];
|
|
71
75
|
if (STEPS.length !== LEAD_SCHEMA_VERSION) {
|
|
72
76
|
// The version belongs to the port, shared by every adapter; this table of
|
|
@@ -99,6 +103,7 @@ function leadOf(row) {
|
|
|
99
103
|
const submissionId = str(row, "submission_id");
|
|
100
104
|
const flow = LEAD_FLOWS.find(f => f === str(row, "flow"));
|
|
101
105
|
const price = priceOf(row);
|
|
106
|
+
const messageRef = str(row, "message_ref");
|
|
102
107
|
return {
|
|
103
108
|
subject: str(row, "job") ?? "",
|
|
104
109
|
locality: str(row, "zip") ?? "",
|
|
@@ -106,11 +111,12 @@ function leadOf(row) {
|
|
|
106
111
|
extras: strings(str(row, "extras")),
|
|
107
112
|
placeSlug: str(row, "location_id"),
|
|
108
113
|
spamVerdict: verdict !== null && VERDICTS.includes(verdict) ? verdict : null,
|
|
109
|
-
channel: str(row, "channel")
|
|
114
|
+
channel: LEAD_CHANNELS.find(c => c === str(row, "channel")) ?? "form",
|
|
110
115
|
...(consentAt !== null ? { consent: { at: consentAt, text: str(row, "consent_text") ?? "" } } : {}),
|
|
111
116
|
...(submissionId !== null ? { submissionId } : {}),
|
|
112
117
|
...(flow !== undefined ? { flow } : {}),
|
|
113
118
|
...(price !== null ? { price } : {}),
|
|
119
|
+
...(messageRef !== null ? { messageRef } : {}),
|
|
114
120
|
};
|
|
115
121
|
}
|
|
116
122
|
function userVersion(db) {
|
|
@@ -194,13 +200,13 @@ export function openSqliteLeadStore(path) {
|
|
|
194
200
|
db.close();
|
|
195
201
|
throw error;
|
|
196
202
|
}
|
|
197
|
-
const insert = db.prepare("INSERT INTO leads (job, zip, mobile, location_id, spam_verdict, extras, channel, consent_at, consent_text, submission_id, flow, quoted_cents, pricing_valid_from, estimate_inputs) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id");
|
|
198
|
-
const bySubmission = db.prepare("SELECT id, job, zip, mobile, location_id, spam_verdict, extras, channel, consent_at, consent_text, submission_id, flow, quoted_cents, pricing_valid_from, estimate_inputs FROM leads WHERE submission_id = ?");
|
|
203
|
+
const insert = db.prepare("INSERT INTO leads (job, zip, mobile, location_id, spam_verdict, extras, channel, consent_at, consent_text, submission_id, flow, quoted_cents, pricing_valid_from, estimate_inputs, message_ref) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id");
|
|
204
|
+
const bySubmission = db.prepare("SELECT id, job, zip, mobile, location_id, spam_verdict, extras, channel, consent_at, consent_text, submission_id, flow, quoted_cents, pricing_valid_from, estimate_inputs, message_ref FROM leads WHERE submission_id = ?");
|
|
199
205
|
const count = db.prepare("SELECT COUNT(*) AS n FROM leads");
|
|
200
206
|
return {
|
|
201
207
|
async insert(lead) {
|
|
202
208
|
const extras = Object.keys(lead.extras).length > 0 ? JSON.stringify(lead.extras) : null;
|
|
203
|
-
return integer(insert.get(lead.subject, lead.locality, lead.mobile, lead.placeSlug, lead.spamVerdict, extras, channelOf(lead), lead.consent?.at ?? null, lead.consent?.text ?? null, lead.submissionId ?? null, lead.flow ?? null, lead.price?.cents ?? null, lead.price?.validFrom ?? null, lead.price?.inputs ? JSON.stringify(lead.price.inputs) : null), "id");
|
|
209
|
+
return integer(insert.get(lead.subject, lead.locality, lead.mobile, lead.placeSlug, lead.spamVerdict, extras, channelOf(lead), lead.consent?.at ?? null, lead.consent?.text ?? null, lead.submissionId ?? null, lead.flow ?? null, lead.price?.cents ?? null, lead.price?.validFrom ?? null, lead.price?.inputs ? JSON.stringify(lead.price.inputs) : null, lead.messageRef ?? null), "id");
|
|
204
210
|
},
|
|
205
211
|
async findSubmission(submissionId) {
|
|
206
212
|
const row = bySubmission.get(submissionId);
|
|
@@ -43,6 +43,18 @@ export interface LeadWebhookContext {
|
|
|
43
43
|
* property: it refuses an unknown one, and the outbox would park the lead.
|
|
44
44
|
*/
|
|
45
45
|
analyticsId?: string;
|
|
46
|
+
/**
|
|
47
|
+
* The lead's channel as the panel's `properties.channel` takes it
|
|
48
|
+
* (`panelChannel`): `whatsapp` and `telegram` only under `panelMessenger`,
|
|
49
|
+
* `form` otherwise. Absent only on a context built by hand.
|
|
50
|
+
*/
|
|
51
|
+
channel?: PanelChannel;
|
|
52
|
+
/**
|
|
53
|
+
* The reference the visitor's chat carries (`Lead.messageRef`), for the
|
|
54
|
+
* panel's `properties.message_ref` — only under `panelMessenger`, and only
|
|
55
|
+
* on a lead that has one; otherwise absent, and the body must not carry it.
|
|
56
|
+
*/
|
|
57
|
+
messageRef?: string;
|
|
46
58
|
}
|
|
47
59
|
/** A lead's sale as the panel's `lead.created` may carry it. */
|
|
48
60
|
export interface PanelFlow {
|
|
@@ -69,14 +81,17 @@ export interface PanelFlowProperties {
|
|
|
69
81
|
estimate_inputs?: Readonly<Record<string, string>>;
|
|
70
82
|
}
|
|
71
83
|
export declare function panelFlowProperties(flow: PanelFlow | undefined): PanelFlowProperties | Record<string, never>;
|
|
84
|
+
/** A channel a site sends the panel; `phone_inbound` is the panel's own, for the calls it records. */
|
|
85
|
+
export type PanelChannel = "form" | "callback" | "whatsapp" | "telegram";
|
|
72
86
|
/**
|
|
73
87
|
* A lead's channel as the Service-Arb panel's `lead.created` carries it in
|
|
74
|
-
* `properties.channel` — a closed set
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
88
|
+
* `properties.channel` — a closed set that refuses the whole event outside
|
|
89
|
+
* it, so the outbox would park the lead. `messenger`: whether the panel takes
|
|
90
|
+
* `whatsapp` and `telegram` (`LeadWebhookOptions.panelMessenger`); until it
|
|
91
|
+
* does, a messenger lead is a `form` there — the leads table keeps the truth.
|
|
92
|
+
* `ctx.channel` is this, already decided.
|
|
78
93
|
*/
|
|
79
|
-
export declare function panelChannel(channel: LeadChannel):
|
|
94
|
+
export declare function panelChannel(channel: LeadChannel, messenger?: boolean): PanelChannel;
|
|
80
95
|
/**
|
|
81
96
|
* The receiver's body, built by the brand: the kit serialises and signs
|
|
82
97
|
* whatever this returns and knows nothing of its shape. Called once, when the
|
|
@@ -129,6 +144,8 @@ export interface LeadWebhook {
|
|
|
129
144
|
readonly panelFlow: boolean;
|
|
130
145
|
/** Whether `requestBooking` queues anything (`LeadWebhookOptions.panelBooking`). */
|
|
131
146
|
readonly panelBooking?: boolean;
|
|
147
|
+
/** Whether `ctx.channel` says `whatsapp` / `telegram` and `ctx.messageRef` is filled in (`LeadWebhookOptions.panelMessenger`). */
|
|
148
|
+
readonly panelMessenger?: boolean;
|
|
132
149
|
/**
|
|
133
150
|
* Queues `booking.requested@1` behind the lead's `lead.created`, through
|
|
134
151
|
* the same outbox: not sent until that row is delivered, and a `409` / `425`
|
|
@@ -173,6 +190,14 @@ export interface LeadWebhookOptions extends WebhookOutboxOptions {
|
|
|
173
190
|
* and dropped; the lead is untouched either way.
|
|
174
191
|
*/
|
|
175
192
|
panelBooking?: boolean;
|
|
193
|
+
/**
|
|
194
|
+
* The switch for the messenger channels: `ctx.channel` says `whatsapp` or
|
|
195
|
+
* `telegram`, and `ctx.messageRef` carries the lead's reference. Off (the
|
|
196
|
+
* default) until the panel's `lead.created` accepts them — it refuses an
|
|
197
|
+
* unknown channel or property, and the outbox would park the lead: a
|
|
198
|
+
* messenger lead goes as a `form`, without its reference.
|
|
199
|
+
*/
|
|
200
|
+
panelMessenger?: boolean;
|
|
176
201
|
/** The body of `booking.requested@1`; absent → booking requests are dropped whatever `panelBooking` says. */
|
|
177
202
|
buildBookingBody?: BuildBookingBody | undefined;
|
|
178
203
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import "server-only";
|
|
2
2
|
import { randomUUID } from "node:crypto";
|
|
3
|
-
import { suspectOf } from "../core/lead.js";
|
|
3
|
+
import { channelOf, suspectOf } from "../core/lead.js";
|
|
4
4
|
import { experimentsDeclaredBody } from "./experiments-declared.js";
|
|
5
5
|
import { openWebhookOutbox } from "./webhook-outbox.js";
|
|
6
6
|
/** The sale `ctx.flow` describes, or nothing for a lead without a flow. */
|
|
@@ -23,12 +23,15 @@ export function panelFlowProperties(flow) {
|
|
|
23
23
|
}
|
|
24
24
|
/**
|
|
25
25
|
* A lead's channel as the Service-Arb panel's `lead.created` carries it in
|
|
26
|
-
* `properties.channel` — a closed set
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
26
|
+
* `properties.channel` — a closed set that refuses the whole event outside
|
|
27
|
+
* it, so the outbox would park the lead. `messenger`: whether the panel takes
|
|
28
|
+
* `whatsapp` and `telegram` (`LeadWebhookOptions.panelMessenger`); until it
|
|
29
|
+
* does, a messenger lead is a `form` there — the leads table keeps the truth.
|
|
30
|
+
* `ctx.channel` is this, already decided.
|
|
30
31
|
*/
|
|
31
|
-
export function panelChannel(channel) {
|
|
32
|
+
export function panelChannel(channel, messenger = false) {
|
|
33
|
+
if (channel === "whatsapp" || channel === "telegram")
|
|
34
|
+
return messenger ? channel : "form";
|
|
32
35
|
return channel;
|
|
33
36
|
}
|
|
34
37
|
/**
|
|
@@ -37,7 +40,7 @@ export function panelChannel(channel) {
|
|
|
37
40
|
* so a broken setting fails startup rather than a lead.
|
|
38
41
|
*/
|
|
39
42
|
export function leadWebhook(site, env, options) {
|
|
40
|
-
const { buildBody, buildBookingBody, signing, panelSuspect = false, panelFlow = false, panelBooking = false, ...outboxOptions } = options;
|
|
43
|
+
const { buildBody, buildBookingBody, signing, panelSuspect = false, panelFlow = false, panelBooking = false, panelMessenger = false, ...outboxOptions } = options;
|
|
41
44
|
if (!env.leadWebhook || !buildBody)
|
|
42
45
|
return null;
|
|
43
46
|
if (env.leadsDb.kind !== "sqlite")
|
|
@@ -50,6 +53,7 @@ export function leadWebhook(site, env, options) {
|
|
|
50
53
|
panelSuspect,
|
|
51
54
|
panelFlow,
|
|
52
55
|
panelBooking,
|
|
56
|
+
panelMessenger,
|
|
53
57
|
requestBooking(request) {
|
|
54
58
|
if (!panelBooking || !buildBookingBody)
|
|
55
59
|
return { kind: "off" };
|
|
@@ -81,6 +85,8 @@ export function leadWebhook(site, env, options) {
|
|
|
81
85
|
idempotencyKey: randomUUID(),
|
|
82
86
|
...(suspect ? { suspect } : {}),
|
|
83
87
|
...(flow ? { flow } : {}),
|
|
88
|
+
channel: panelChannel(channelOf(lead), panelMessenger),
|
|
89
|
+
...(panelMessenger && lead.messageRef ? { messageRef: lead.messageRef } : {}),
|
|
84
90
|
};
|
|
85
91
|
const body = JSON.stringify(buildBody(lead, ctx));
|
|
86
92
|
if (typeof body !== "string")
|
package/dist/server/notify.js
CHANGED
|
@@ -5,16 +5,22 @@ const shownOf = (lead, needLabel) => ({ need: needLabel?.(lead.subject) ?? lead.
|
|
|
5
5
|
/** A plain default; a brand passes `format` to write it in its own language. */
|
|
6
6
|
export function defaultLeadMail(brand, lead, id, options = {}) {
|
|
7
7
|
const place = lead.placeSlug ?? "unknown";
|
|
8
|
-
// A callback request is a promise to ring the customer: it says so first.
|
|
9
|
-
|
|
8
|
+
// A callback request is a promise to ring the customer: it says so first. A
|
|
9
|
+
// messenger lead says where the customer will write, and the reference the
|
|
10
|
+
// message carries — what the operator matches the chat by.
|
|
11
|
+
const channel = channelOf(lead);
|
|
12
|
+
const messenger = channel === "whatsapp" ? "WhatsApp" : channel === "telegram" ? "Telegram" : null;
|
|
13
|
+
const kind = channel === "callback" ? "call back" : messenger ? `${messenger} lead` : "new lead";
|
|
14
|
+
const flag = channel === "callback" ? " — CALL BACK" : messenger ? ` — ${messenger.toUpperCase()}` : "";
|
|
10
15
|
return {
|
|
11
|
-
subject: `${brand.name} — ${
|
|
16
|
+
subject: `${brand.name} — ${kind} (${place})`,
|
|
12
17
|
text: [
|
|
13
|
-
`Lead #${id} — place ${place}${
|
|
18
|
+
`Lead #${id} — place ${place}${flag}${lead.spamVerdict ? ` — suspect (${lead.spamVerdict})` : ""}`,
|
|
14
19
|
"",
|
|
20
|
+
...(lead.messageRef ? [`Réf. : ${lead.messageRef}`] : []),
|
|
15
21
|
`Subject : ${shownOf(lead, options.needLabel).need}`,
|
|
16
22
|
`Locality : ${lead.locality}`,
|
|
17
|
-
`Mobile : ${lead.mobile}`,
|
|
23
|
+
`Mobile : ${lead.mobile || (messenger ? `(none — answer on ${messenger})` : "")}`,
|
|
18
24
|
...Object.entries(lead.extras).map(([name, value]) => `${name} : ${value}`),
|
|
19
25
|
].join("\n"),
|
|
20
26
|
};
|
package/dist/testing/index.d.ts
CHANGED
|
@@ -9,5 +9,5 @@
|
|
|
9
9
|
export { describeLandingContract, type LandingContractOptions } from "./landing-contract.js";
|
|
10
10
|
export { servedPaths } from "./served-files.js";
|
|
11
11
|
export { describeLeadStoreContract, type LeadStoreHarness } from "./lead-store-contract.js";
|
|
12
|
-
export { leadRuleDisagreements, PHONE_MATRIX } from "./lead-rules.js";
|
|
12
|
+
export { leadRuleDisagreements, messengerRuleDisagreements, PHONE_MATRIX } from "./lead-rules.js";
|
|
13
13
|
export { serviceAreaPlace, storefrontPlace, testLead } from "./fixtures.js";
|
package/dist/testing/index.js
CHANGED
|
@@ -9,5 +9,5 @@
|
|
|
9
9
|
export { describeLandingContract } from "./landing-contract.js";
|
|
10
10
|
export { servedPaths } from "./served-files.js";
|
|
11
11
|
export { describeLeadStoreContract } from "./lead-store-contract.js";
|
|
12
|
-
export { leadRuleDisagreements, PHONE_MATRIX } from "./lead-rules.js";
|
|
12
|
+
export { leadRuleDisagreements, messengerRuleDisagreements, PHONE_MATRIX } from "./lead-rules.js";
|
|
13
13
|
export { serviceAreaPlace, storefrontPlace, testLead } from "./fixtures.js";
|