@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.
- package/README.md +179 -19
- package/dist/core/ab-gate.js +3 -1
- package/dist/core/ab-switcher.d.ts +8 -0
- package/dist/core/ab-switcher.js +11 -0
- package/dist/core/accept.d.ts +14 -0
- package/dist/core/accept.js +21 -3
- package/dist/core/analytics.d.ts +28 -3
- package/dist/core/analytics.js +46 -2
- 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 +9 -6
- package/dist/index.js +8 -5
- package/dist/next/quote-route.d.ts +7 -0
- package/dist/next/quote-route.js +9 -9
- package/dist/react/AbSwitcherPanel.d.ts +3 -1
- package/dist/react/AbSwitcherPanel.js +10 -6
- package/dist/react/AnalyticsBoundary.d.ts +3 -2
- package/dist/react/AnalyticsBoundary.js +7 -20
- 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/ab-switcher-types.d.ts +1 -1
- 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/app/quote/route.ts +3 -0
- package/template/flake.nix +1 -1
- package/template/package.json +2 -2
- package/template/tests/e2e/ab-switcher.spec.ts +2 -0
|
@@ -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";
|
package/dist/core/site.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { LocaleRegistry } from "@evinvest/i18n";
|
|
2
2
|
import type { BookingRules } from "./booking/model.js";
|
|
3
3
|
import type { LeadSchema } from "./lead.js";
|
|
4
|
+
import { type MessengerFacts } from "./messenger.js";
|
|
4
5
|
import type { PublicationPolicy } from "./place/publication.js";
|
|
5
6
|
import type { Place } from "./place/types.js";
|
|
6
7
|
import type { PricingModel } from "./pricing/model.js";
|
|
@@ -126,8 +127,14 @@ export declare function assertLaunchable(site: {
|
|
|
126
127
|
}, todos: readonly OwnerTodo[]): void;
|
|
127
128
|
/** The baked place for a slug, if the site has it. */
|
|
128
129
|
export declare function bakedPlace<L extends string, P extends string>(site: Site<L, P>, slug: string): Place<L> | undefined;
|
|
129
|
-
/**
|
|
130
|
+
/**
|
|
131
|
+
* The numbers a place answers on: its own, or the brand's until it has one.
|
|
132
|
+
* The panel's messenger switches (`place.messengers`) do not reach here — they
|
|
133
|
+
* are the lead form's (`messengerFacts`); the header, the call bar and the
|
|
134
|
+
* control's channels keep the number.
|
|
135
|
+
*/
|
|
130
136
|
export declare function contactOf(site: Pick<SiteConfig<string, string>, "brand">, place: Place<string>): {
|
|
131
137
|
phone: string | null;
|
|
132
138
|
whatsapp: string | null;
|
|
133
139
|
};
|
|
140
|
+
export declare function messengerFacts(_site: Pick<SiteConfig<string, string>, "brand">, place: Place<string>): MessengerFacts;
|
package/dist/core/site.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { bookingConfigProblems, calComHostsProblems } from "./booking/validate.js";
|
|
2
|
+
import { TELEGRAM_BOT } from "./messenger.js";
|
|
2
3
|
import { pricingProblemsFor } from "./pricing/validate.js";
|
|
3
4
|
export function defineSite(config) {
|
|
4
5
|
const pageKeys = Object.keys(config.pages).filter((k) => Object.hasOwn(config.pages, k));
|
|
@@ -101,10 +102,30 @@ export function assertLaunchable(site, todos) {
|
|
|
101
102
|
export function bakedPlace(site, slug) {
|
|
102
103
|
return site.places.find(p => p.slug === slug);
|
|
103
104
|
}
|
|
104
|
-
/**
|
|
105
|
+
/**
|
|
106
|
+
* The numbers a place answers on: its own, or the brand's until it has one.
|
|
107
|
+
* The panel's messenger switches (`place.messengers`) do not reach here — they
|
|
108
|
+
* are the lead form's (`messengerFacts`); the header, the call bar and the
|
|
109
|
+
* control's channels keep the number.
|
|
110
|
+
*/
|
|
105
111
|
export function contactOf(site, place) {
|
|
106
112
|
return {
|
|
107
113
|
phone: place.channels.phone ?? site.brand.phone,
|
|
108
114
|
whatsapp: place.channels.whatsapp ?? site.brand.phone,
|
|
109
115
|
};
|
|
110
116
|
}
|
|
117
|
+
/**
|
|
118
|
+
* What a `LeadCapture` `messenger` variant may offer (`messengers`): the
|
|
119
|
+
* place's own WhatsApp number — never the brand's phone, the chat is answered
|
|
120
|
+
* by hand at the place — and its bot, each unless the panel switched it off.
|
|
121
|
+
* A baked bot whose name is not one (`TELEGRAM_BOT`) is none, as a live one is.
|
|
122
|
+
* Not a fallback for `contactOf`: the control's WhatsApp link keeps the brand's.
|
|
123
|
+
*/
|
|
124
|
+
const validBot = (bot) => (bot && TELEGRAM_BOT.test(bot) ? bot : null);
|
|
125
|
+
export function messengerFacts(_site, place) {
|
|
126
|
+
const on = place.messengers ?? {};
|
|
127
|
+
return {
|
|
128
|
+
whatsapp: on.whatsapp === false ? null : place.channels.whatsapp,
|
|
129
|
+
telegram: on.telegram === false ? null : validBot(place.channels.telegram),
|
|
130
|
+
};
|
|
131
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -3,21 +3,24 @@
|
|
|
3
3
|
* the proxy (edge), a client island and a server module can all import it.
|
|
4
4
|
* Runtime-bound halves live on their own subpaths.
|
|
5
5
|
*/
|
|
6
|
-
export { assertLaunchable, bakedPlace, cardFact, contactOf, defineSite, ogLocaleOf, openLaunchBlockers, perLocale, type BrandFacts, type LegacyRedirect, type OwnerTodo, type PageSuffix, type Site, type SiteConfig, type Topology, } from "./core/site.js";
|
|
7
|
-
export { createPlaceView, DEFAULT_TIME_ZONE, freshRating, isOpenAt, nextOpening, parseInstant, isPublished, mergeLive, parsePlaceLive, placeHref, placeOrigin, placeUrl, publicationGaps, RATING_MAX_AGE_DAYS, servedLocalities, SERVICE_AREA_GATE, siteOrigin, STOREFRONT_GATE, storefrontOf, type DayOfWeek, type Geo, type LinkBase, type LinkMode, type Opening, type OpeningHours, type OriginFacts, type Place, type PlaceLive, type PlaceView, type PostalAddress, type Presence, type PublicationField, type PublicationPolicy, type Rating, type ServiceArea, } from "./core/place/index.js";
|
|
6
|
+
export { assertLaunchable, bakedPlace, cardFact, contactOf, defineSite, messengerFacts, ogLocaleOf, openLaunchBlockers, perLocale, type BrandFacts, type LegacyRedirect, type OwnerTodo, type PageSuffix, type Site, type SiteConfig, type Topology, } from "./core/site.js";
|
|
7
|
+
export { createPlaceView, DEFAULT_TIME_ZONE, freshRating, isOpenAt, nextOpening, parseInstant, isPublished, mergeLive, parsePlaceLive, placeHref, placeOrigin, placeUrl, publicationGaps, RATING_MAX_AGE_DAYS, servedLocalities, SERVICE_AREA_GATE, siteOrigin, STOREFRONT_GATE, storefrontOf, type DayOfWeek, type Geo, type LinkBase, type MessengerSwitches, type LinkMode, type Opening, type OpeningHours, type OriginFacts, type Place, type PlaceLive, type PlaceView, type PostalAddress, type Presence, type PublicationField, type PublicationPolicy, type Rating, type ServiceArea, } from "./core/place/index.js";
|
|
8
8
|
export { createRouting, GONE, GONE_HEADER, gonePath, goneHeader, HOST_MARK, LANG_COOKIE, LANG_COOKIE_MAX_AGE, NON_PAGE_ROUTES, parseGoneHeader, parsePlaceParam, PASS_PATHS, placeParam, pointSuffixes, THANKS, type Decision, type RequestFacts, type Routing, } from "./core/routing.js";
|
|
9
|
-
export { CARD_FIELD, CARD_ID, CHANNEL_FIELD, channelOf, CONSENT_FIELD, LEAD_CHANNELS, LEAD_ERROR_PARAM, LEAD_CARD_PARAM, leadErrorFor, leadErrorOf, LEAD_SCHEMA_VERSION, MAX_CONSENT, MAX_FIELD, readCandidate, rejectionOf, suspectOf, SUBMISSION_FIELD, SUBMISSION_ID, validateCallbackLead, validateCandidate, validateLead, type Lead, type LeadCandidate, type LeadChannel, type LeadError, type PageLeadError, type LeadConsent, type LeadExtra, type LeadPrice, type LeadRejection, type LeadSchema, type LeadStore, type LeadSuspect, type LeadVerdict, type LeadWire, type SpamVerdict, } from "./core/lead.js";
|
|
9
|
+
export { CARD_FIELD, CARD_ID, CHANNEL_FIELD, channelOf, CONSENT_FIELD, isMessengerChannel, LEAD_CHANNELS, LEAD_ERROR_PARAM, LEAD_CARD_PARAM, leadErrorFor, leadErrorOf, LEAD_SCHEMA_VERSION, MAX_CONSENT, MAX_FIELD, readCandidate, rejectionOf, suspectOf, SUBMISSION_FIELD, SUBMISSION_ID, validateCallbackLead, validateCandidate, validateLead, type Lead, type LeadCandidate, type LeadChannel, type LeadError, type PageLeadError, type LeadConsent, type MessengerChannel, type LeadExtra, type LeadPrice, type LeadRejection, type LeadSchema, type LeadStore, type LeadSuspect, type LeadVerdict, type LeadWire, type SpamVerdict, } from "./core/lead.js";
|
|
10
10
|
export { isMobilePhone, isPlausiblePhone, normalizePhone, phoneProblem } from "./core/phone.js";
|
|
11
11
|
export { answeredUnknown, CENTS_BUCKETS, centsBucket, ESTIMATE_FIELD_PREFIX, ESTIMATE_UNKNOWN, estimateField, flowOf, labelOf, LEAD_FLOWS, mulBp, parsePricingModel, priceOf, PRICING_CURRENCY, PRICING_FORMAT, PRICING_LIMITS, PricingModelError, pricingProblems, pricingProblemsFor, readEstimateInputs, roundTo, type AddOption, type DiscountOption, type LeadFlow, type LeadFlows, type MultiplyOption, type NeedPricing, type Price, type PriceLine, type PricingInput, type PricingInputKind, type PricingInputs, type PricingLabels, type PricingModel, } from "./core/pricing/index.js";
|
|
12
12
|
export { flowTextOf, LEAD_CAPTURE_TEXT, type LeadCaptureFlowText, type LeadCaptureText } from "./core/lead-capture-text.js";
|
|
13
|
+
export { LEAD_CAPTURE_MESSENGER_TEXT, messengerTextOf, type LeadCaptureMessengerText } from "./core/messenger-text.js";
|
|
14
|
+
export { channelsAvailable, MESSAGE_REF, MESSAGE_REF_FIELD, MESSAGE_REF_PREFIX, messageRefOf, newMessageRef, TELEGRAM_BOT, telegramHref, type ChannelsAvailable, type MessengerFacts, type MessengerKind, type MessengerMode, type MessengerVariant, type RandomBytes, } from "./core/messenger.js";
|
|
15
|
+
export { MAX_MESSENGER_MESSAGE, messengerMessage, type MessengerMessageInput, type MessengerMessageText } from "./core/messenger-message.js";
|
|
13
16
|
export { fillText, formatCents, openingText } from "./core/lead-capture-format.js";
|
|
14
17
|
export { channelHref, resolveChannels, smsHref, type CaptureChannel, type ChannelFacts, type ChannelOptions, type ResolvedChannels, } from "./core/channels.js";
|
|
15
18
|
export { checkTiming, HONEYPOT_FIELD, LEGACY_HONEYPOT_FIELDS, MIN_FILL_MS, RATE_LIMIT_MAX_KEYS, RateLimiter, RENDERED_AT_FIELD, screen, type Screening, } from "./core/antispam.js";
|
|
16
|
-
export { createAcceptLead, ANALYTICS_ID_FIELD, EXPERIMENT_FIELD, FORM_ID_FIELD, leadRef, LOCALE_FIELD, LOCATION_FIELD, PRICE_CHANGED, SHOWN_CENTS_FIELD, VARIANT_FIELD, type AcceptDeps, type Outcome, type SubmitTags, } from "./core/accept.js";
|
|
19
|
+
export { createAcceptLead, ANALYTICS_ID_FIELD, CHANNELS_FIELD, EXPERIMENT_FIELD, FORM_ID_FIELD, leadRef, LOCALE_FIELD, LOCATION_FIELD, PRICE_CHANGED, SHOWN_CENTS_FIELD, submitTagsOf, VARIANT_FIELD, type AcceptDeps, type Outcome, type SubmitTags, } from "./core/accept.js";
|
|
17
20
|
export { areaServedNodes, breadcrumbNode, businessId, businessNode, faqPageNode, offerNodes, organizationId, organizationNode, placeGraph, websiteId, type OfferInput, type PageGraphCopy, type QuestionAnswer, } from "./core/seo/ld.js";
|
|
18
21
|
export { AI_CRAWLERS, ogImageUrl, robotsFor, sitemapFor, type RobotsFile, type RobotsRule, type SitemapEntry, } from "./core/seo/sitemap.js";
|
|
19
|
-
export { ALLOWED_PROPS, ANALYTICS_ID, analyticsSink, countsAsPageView, EVENTS, EXPERIMENT_SLUG, experimentProps, type AnalyticsTarget, type IntentChannel, type LeadField, type LeadStep, } from "./core/analytics.js";
|
|
20
|
-
export { AB_COOKIE_PREFIX, AB_FORCE_PARAM, abAssignments, abReset, abVariantUrl, cookieValue, type AbReset, } from "./core/ab-switcher.js";
|
|
22
|
+
export { ALLOWED_PROPS, ANALYTICS_ID, analyticsSink, channelsProps, countsAsPageView, EVENTS, EXPERIMENT_SLUG, experimentProps, qaVisit, type AnalyticsTarget, type IntentChannel, type LeadField, type LeadStep, } from "./core/analytics.js";
|
|
23
|
+
export { AB_COOKIE_PREFIX, AB_FORCE_PARAM, abAssignments, abRunning, abReset, abVariantUrl, cookieValue, type AbReset, } from "./core/ab-switcher.js";
|
|
21
24
|
export { abSwitcherVisible } from "./core/ab-gate.js";
|
|
22
25
|
export type { CallBarText, CopySlice, CoreText, PageMetaCopy, QuoteFormCopy, Said, StatusAction, StatusCopy, StatusScreenText, } from "./core/content.js";
|
|
23
26
|
export { BOOKING_LIMITS, BOOKING_PROVIDERS, BOOKING_STATUSES, BookingConfigError, bookingConfigProblems, BOOKING_EXPERIMENT, bookingHref, bookingOf, bookingRequestedProperties, bookingRequestProblems, calComHostsProblems, DEFAULT_BOOKING, DEFAULT_CAL_COM_HOSTS, isBookingProvider, isLeadRef, isPageProvider, isPreferredPart, parseBookingConfig, parseBookingRequest, PREFERRED_PARTS, type BookingChoice, type BookingConfig, type BookingPrefill, type BookingProvider, type BookingRequest, type BookingRequestedProperties, type BookingRules, type BookingStatus, type OpenBookingConfig, type PageProvider, type PreferredPart, } from "./core/booking/index.js";
|
package/dist/index.js
CHANGED
|
@@ -3,21 +3,24 @@
|
|
|
3
3
|
* the proxy (edge), a client island and a server module can all import it.
|
|
4
4
|
* Runtime-bound halves live on their own subpaths.
|
|
5
5
|
*/
|
|
6
|
-
export { assertLaunchable, bakedPlace, cardFact, contactOf, defineSite, ogLocaleOf, openLaunchBlockers, perLocale, } from "./core/site.js";
|
|
6
|
+
export { assertLaunchable, bakedPlace, cardFact, contactOf, defineSite, messengerFacts, ogLocaleOf, openLaunchBlockers, perLocale, } from "./core/site.js";
|
|
7
7
|
export { createPlaceView, DEFAULT_TIME_ZONE, freshRating, isOpenAt, nextOpening, parseInstant, isPublished, mergeLive, parsePlaceLive, placeHref, placeOrigin, placeUrl, publicationGaps, RATING_MAX_AGE_DAYS, servedLocalities, SERVICE_AREA_GATE, siteOrigin, STOREFRONT_GATE, storefrontOf, } from "./core/place/index.js";
|
|
8
8
|
export { createRouting, GONE, GONE_HEADER, gonePath, goneHeader, HOST_MARK, LANG_COOKIE, LANG_COOKIE_MAX_AGE, NON_PAGE_ROUTES, parseGoneHeader, parsePlaceParam, PASS_PATHS, placeParam, pointSuffixes, THANKS, } from "./core/routing.js";
|
|
9
|
-
export { CARD_FIELD, CARD_ID, CHANNEL_FIELD, channelOf, CONSENT_FIELD, LEAD_CHANNELS, LEAD_ERROR_PARAM, LEAD_CARD_PARAM, leadErrorFor, leadErrorOf, LEAD_SCHEMA_VERSION, MAX_CONSENT, MAX_FIELD, readCandidate, rejectionOf, suspectOf, SUBMISSION_FIELD, SUBMISSION_ID, validateCallbackLead, validateCandidate, validateLead, } from "./core/lead.js";
|
|
9
|
+
export { CARD_FIELD, CARD_ID, CHANNEL_FIELD, channelOf, CONSENT_FIELD, isMessengerChannel, LEAD_CHANNELS, LEAD_ERROR_PARAM, LEAD_CARD_PARAM, leadErrorFor, leadErrorOf, LEAD_SCHEMA_VERSION, MAX_CONSENT, MAX_FIELD, readCandidate, rejectionOf, suspectOf, SUBMISSION_FIELD, SUBMISSION_ID, validateCallbackLead, validateCandidate, validateLead, } from "./core/lead.js";
|
|
10
10
|
export { isMobilePhone, isPlausiblePhone, normalizePhone, phoneProblem } from "./core/phone.js";
|
|
11
11
|
export { answeredUnknown, CENTS_BUCKETS, centsBucket, ESTIMATE_FIELD_PREFIX, ESTIMATE_UNKNOWN, estimateField, flowOf, labelOf, LEAD_FLOWS, mulBp, parsePricingModel, priceOf, PRICING_CURRENCY, PRICING_FORMAT, PRICING_LIMITS, PricingModelError, pricingProblems, pricingProblemsFor, readEstimateInputs, roundTo, } from "./core/pricing/index.js";
|
|
12
12
|
export { flowTextOf, LEAD_CAPTURE_TEXT } from "./core/lead-capture-text.js";
|
|
13
|
+
export { LEAD_CAPTURE_MESSENGER_TEXT, messengerTextOf } from "./core/messenger-text.js";
|
|
14
|
+
export { channelsAvailable, MESSAGE_REF, MESSAGE_REF_FIELD, MESSAGE_REF_PREFIX, messageRefOf, newMessageRef, TELEGRAM_BOT, telegramHref, } from "./core/messenger.js";
|
|
15
|
+
export { MAX_MESSENGER_MESSAGE, messengerMessage } from "./core/messenger-message.js";
|
|
13
16
|
export { fillText, formatCents, openingText } from "./core/lead-capture-format.js";
|
|
14
17
|
export { channelHref, resolveChannels, smsHref, } from "./core/channels.js";
|
|
15
18
|
export { checkTiming, HONEYPOT_FIELD, LEGACY_HONEYPOT_FIELDS, MIN_FILL_MS, RATE_LIMIT_MAX_KEYS, RateLimiter, RENDERED_AT_FIELD, screen, } from "./core/antispam.js";
|
|
16
|
-
export { createAcceptLead, ANALYTICS_ID_FIELD, EXPERIMENT_FIELD, FORM_ID_FIELD, leadRef, LOCALE_FIELD, LOCATION_FIELD, PRICE_CHANGED, SHOWN_CENTS_FIELD, VARIANT_FIELD, } from "./core/accept.js";
|
|
19
|
+
export { createAcceptLead, ANALYTICS_ID_FIELD, CHANNELS_FIELD, EXPERIMENT_FIELD, FORM_ID_FIELD, leadRef, LOCALE_FIELD, LOCATION_FIELD, PRICE_CHANGED, SHOWN_CENTS_FIELD, submitTagsOf, VARIANT_FIELD, } from "./core/accept.js";
|
|
17
20
|
export { areaServedNodes, breadcrumbNode, businessId, businessNode, faqPageNode, offerNodes, organizationId, organizationNode, placeGraph, websiteId, } from "./core/seo/ld.js";
|
|
18
21
|
export { AI_CRAWLERS, ogImageUrl, robotsFor, sitemapFor, } from "./core/seo/sitemap.js";
|
|
19
|
-
export { ALLOWED_PROPS, ANALYTICS_ID, analyticsSink, countsAsPageView, EVENTS, EXPERIMENT_SLUG, experimentProps, } from "./core/analytics.js";
|
|
20
|
-
export { AB_COOKIE_PREFIX, AB_FORCE_PARAM, abAssignments, abReset, abVariantUrl, cookieValue, } from "./core/ab-switcher.js";
|
|
22
|
+
export { ALLOWED_PROPS, ANALYTICS_ID, analyticsSink, channelsProps, countsAsPageView, EVENTS, EXPERIMENT_SLUG, experimentProps, qaVisit, } from "./core/analytics.js";
|
|
23
|
+
export { AB_COOKIE_PREFIX, AB_FORCE_PARAM, abAssignments, abRunning, abReset, abVariantUrl, cookieValue, } from "./core/ab-switcher.js";
|
|
21
24
|
export { abSwitcherVisible } from "./core/ab-gate.js";
|
|
22
25
|
export { BOOKING_LIMITS, BOOKING_PROVIDERS, BOOKING_STATUSES, BookingConfigError, bookingConfigProblems, BOOKING_EXPERIMENT, bookingHref, bookingOf, bookingRequestedProperties, bookingRequestProblems, calComHostsProblems, DEFAULT_BOOKING, DEFAULT_CAL_COM_HOSTS, isBookingProvider, isLeadRef, isPageProvider, isPreferredPart, parseBookingConfig, parseBookingRequest, PREFERRED_PARTS, } from "./core/booking/index.js";
|
|
23
26
|
export { brandStatusTarget, statusTarget, thanksChannel, thanksSuffix } from "./core/status.js";
|
|
@@ -56,6 +56,13 @@ export interface QuoteRouteDeps<L extends string> {
|
|
|
56
56
|
* `<anchor>-callback`.
|
|
57
57
|
*/
|
|
58
58
|
anchor?: string;
|
|
59
|
+
/**
|
|
60
|
+
* The brand's QA cookie (`AnalyticsBoundary`'s and `AbSwitcher`'s
|
|
61
|
+
* `qaCookie`). A post carrying it with a value is a test visit: its
|
|
62
|
+
* `lead_form_submit` and `lead_form_reject` say `forced: true`, as the
|
|
63
|
+
* browser's events of the same visit do. Absent → no `forced` key.
|
|
64
|
+
*/
|
|
65
|
+
qaCookie?: string;
|
|
59
66
|
/** Work after the response; `after` from `next/server` by default. */
|
|
60
67
|
defer?: (task: () => Promise<void> | void) => void;
|
|
61
68
|
now?: () => number;
|
package/dist/next/quote-route.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { after } from "next/server.js";
|
|
2
2
|
import { CONFIRM_PATH, confirmQuery } from "./confirm-route.js";
|
|
3
|
-
import { createAcceptLead,
|
|
4
|
-
import { analyticsSink, EVENTS
|
|
3
|
+
import { createAcceptLead, PRICE_CHANGED, submitTagsOf } from "../core/accept.js";
|
|
4
|
+
import { analyticsSink, EVENTS } from "../core/analytics.js";
|
|
5
5
|
import { RateLimiter } from "../core/antispam.js";
|
|
6
6
|
import { CARD_FIELD, CARD_ID, channelOf, LEAD_CARD_PARAM, LEAD_ERROR_PARAM } from "../core/lead.js";
|
|
7
7
|
import { createPlaceView } from "../core/place/view.js";
|
|
@@ -54,10 +54,6 @@ function seeOther(location) {
|
|
|
54
54
|
function json(status, body) {
|
|
55
55
|
return new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json", "Cache-Control": "no-store" } });
|
|
56
56
|
}
|
|
57
|
-
const text = (form, name) => {
|
|
58
|
-
const value = form.get(name);
|
|
59
|
-
return typeof value === "string" ? value : null;
|
|
60
|
-
};
|
|
61
57
|
/** `LeadCapture`'s own post asks for JSON; a plain form post never does. */
|
|
62
58
|
const wantsJson = (request) => (request.headers.get("accept") ?? "").includes("application/json");
|
|
63
59
|
export function quoteRoute(site, deps) {
|
|
@@ -148,6 +144,9 @@ export function quoteRoute(site, deps) {
|
|
|
148
144
|
const locale = form.get("locale");
|
|
149
145
|
return unavailable(null, typeof locale === "string" && site.i18n.isLocale(locale) ? locale : site.i18n.defaultLocale);
|
|
150
146
|
}
|
|
147
|
+
// The request's events, marked as the browser's are (`analyticsSink`).
|
|
148
|
+
const cookies = request.headers.get("cookie") ?? "";
|
|
149
|
+
const sinkFor = (slug) => analyticsSink({ key: env.posthogKey, host: env.posthogHost, brandId: site.brand.id }, slug, undefined, deps.qaCookie, cookies);
|
|
151
150
|
const outcome = await accept(form, clientKey(request.headers, env.trustedProxy ?? DEV_TRUST), {
|
|
152
151
|
insert: lead => leadStore().insert(lead),
|
|
153
152
|
findSubmission: async (submissionId) => (await leadStore().findSubmission?.(submissionId)) ?? null,
|
|
@@ -165,9 +164,10 @@ export function quoteRoute(site, deps) {
|
|
|
165
164
|
hook.enqueue(lead, id, meta);
|
|
166
165
|
defer(() => hook.tick().then(() => undefined, error => log.error("quote: the webhook delivery after a lead failed", error)));
|
|
167
166
|
},
|
|
168
|
-
capture: (lead, formId, tags) =>
|
|
167
|
+
capture: (lead, formId, tags) => sinkFor(lead.placeSlug).capture(EVENTS.leadSubmit, {
|
|
169
168
|
form_id: formId,
|
|
170
169
|
channel: channelOf(lead),
|
|
170
|
+
...(lead.messageRef ? { message_ref: lead.messageRef } : {}),
|
|
171
171
|
...tags,
|
|
172
172
|
}),
|
|
173
173
|
limiter: (limiter ??= new RateLimiter(env.leadRateLimit?.limit ?? 5, env.leadRateLimit?.windowMs ?? 600_000)),
|
|
@@ -191,12 +191,12 @@ export function quoteRoute(site, deps) {
|
|
|
191
191
|
return scripted ? json(200, { ok: true, location, lead: outcome.ref, ...price }) : seeOther(location);
|
|
192
192
|
}
|
|
193
193
|
case "invalid": {
|
|
194
|
-
const tags =
|
|
194
|
+
const tags = submitTagsOf(form);
|
|
195
195
|
// The field's role and why — never what was typed.
|
|
196
196
|
const repriced = outcome.field === PRICE_CHANGED;
|
|
197
197
|
const props = { form_id: outcome.formId, channel: outcome.channel, field: outcome.field, reason: repriced ? PRICE_CHANGED : "invalid", ...tags };
|
|
198
198
|
if (env.posthogKey) {
|
|
199
|
-
const sink =
|
|
199
|
+
const sink = sinkFor(outcome.slug);
|
|
200
200
|
defer(() => sink.capture(EVENTS.formReject, props));
|
|
201
201
|
}
|
|
202
202
|
if (scripted)
|
|
@@ -3,6 +3,8 @@ import type { AbSwitcherProps } from "./ab-switcher-types.js";
|
|
|
3
3
|
* The QA menu itself, loaded only when `AbSwitcher` lets it: a chip in a
|
|
4
4
|
* corner and, on a tap, each experiment's variants. A tap on a variant is the
|
|
5
5
|
* brand's own force parameter — no other way in. Minimized and hidden live in
|
|
6
|
-
* memory only: a reload brings the chip back.
|
|
6
|
+
* memory only: a reload brings the chip back. An experiment no cookie assigns
|
|
7
|
+
* is off: the proxy assigns every running one on the page the menu mounts on,
|
|
8
|
+
* and would refuse a force for it, so its variants are disabled.
|
|
7
9
|
*/
|
|
8
10
|
export declare function AbSwitcherPanel({ experiments, qaCookie, current, forceParam, className, text }: AbSwitcherProps): import("react").JSX.Element | null;
|
|
@@ -2,25 +2,27 @@
|
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
3
|
import { Badge, Button, cn } from "@evinvest/uikit";
|
|
4
4
|
import { useEffect, useRef, useState } from "react";
|
|
5
|
-
import { abAssignments, abReset, abVariantUrl, AB_FORCE_PARAM } from "../core/ab-switcher.js";
|
|
5
|
+
import { abAssignments, abReset, abRunning, abVariantUrl, AB_FORCE_PARAM } from "../core/ab-switcher.js";
|
|
6
6
|
const TEXT = {
|
|
7
7
|
title: "A/B test switcher",
|
|
8
8
|
reset: "Reset",
|
|
9
9
|
leave: "Leave test",
|
|
10
10
|
minimize: "Minimize",
|
|
11
11
|
hide: "Hide",
|
|
12
|
-
unassigned: "not
|
|
12
|
+
unassigned: "not running",
|
|
13
13
|
};
|
|
14
14
|
/**
|
|
15
15
|
* The QA menu itself, loaded only when `AbSwitcher` lets it: a chip in a
|
|
16
16
|
* corner and, on a tap, each experiment's variants. A tap on a variant is the
|
|
17
17
|
* brand's own force parameter — no other way in. Minimized and hidden live in
|
|
18
|
-
* memory only: a reload brings the chip back.
|
|
18
|
+
* memory only: a reload brings the chip back. An experiment no cookie assigns
|
|
19
|
+
* is off: the proxy assigns every running one on the page the menu mounts on,
|
|
20
|
+
* and would refuse a force for it, so its variants are disabled.
|
|
19
21
|
*/
|
|
20
22
|
export function AbSwitcherPanel({ experiments, qaCookie, current, forceParam = AB_FORCE_PARAM, className, text }) {
|
|
21
23
|
const t = { ...TEXT, ...text };
|
|
22
24
|
const keys = experiments.map(e => e.key);
|
|
23
|
-
const [assigned] = useState(() => current ?? abAssignments(document.cookie, keys));
|
|
25
|
+
const [assigned] = useState(() => abRunning(current ?? abAssignments(document.cookie, keys), document.cookie, qaCookie));
|
|
24
26
|
const [open, setOpen] = useState(false);
|
|
25
27
|
const [minimized, setMinimized] = useState(false);
|
|
26
28
|
const [hidden, setHidden] = useState(false);
|
|
@@ -38,9 +40,11 @@ export function AbSwitcherPanel({ experiments, qaCookie, current, forceParam = A
|
|
|
38
40
|
document.cookie = c;
|
|
39
41
|
location.replace(plan.url);
|
|
40
42
|
};
|
|
43
|
+
// The first choice a tap can take gets the focus; with none, the reset.
|
|
44
|
+
const live = experiments.findIndex(e => assigned[e.key] !== undefined);
|
|
41
45
|
if (hidden)
|
|
42
46
|
return null;
|
|
43
|
-
return (_jsxs("div", { "data-ab-switcher": "", ref: root, className: cn("fixed right-4 bottom-4 z-40", className), children: [open && (_jsxs("div", { role: "dialog", "aria-label": t.title, className: "absolute right-0 bottom-full mb-2 flex w-80 max-w-[calc(100vw-2rem)] flex-col gap-4 rounded-md border border-border bg-popover p-3 text-ink shadow-md", children: [experiments.map((e, i) => (_jsx(ExperimentRow, { experiment: e, value: assigned[e.key], unassigned: t.unassigned, autoFocus: i ===
|
|
47
|
+
return (_jsxs("div", { "data-ab-switcher": "", ref: root, className: cn("fixed right-4 bottom-4 z-40", className), children: [open && (_jsxs("div", { role: "dialog", "aria-label": t.title, className: "absolute right-0 bottom-full mb-2 flex w-80 max-w-[calc(100vw-2rem)] flex-col gap-4 rounded-md border border-border bg-popover p-3 text-ink shadow-md", children: [experiments.map((e, i) => (_jsx(ExperimentRow, { experiment: e, value: assigned[e.key], unassigned: t.unassigned, autoFocus: i === live, onPick: v => location.assign(abVariantUrl(location.href, e.key, v, forceParam)) }, e.key))), _jsxs("div", { className: "flex flex-wrap gap-2", children: [_jsx(Button, { variant: "primary", size: "touch", autoFocus: live < 0, className: "flex-1", onClick: () => reset("reassign"), children: t.reset }), _jsx(Button, { variant: "outline", size: "touch", className: "flex-1", onClick: () => reset("leave"), children: t.leave })] }), _jsxs("div", { className: "flex gap-2", children: [_jsx(Button, { variant: "ghost", size: "touch", className: "flex-1", onClick: () => { setMinimized(true); close(true); }, children: t.minimize }), _jsx(Button, { variant: "ghost", size: "touch", className: "flex-1", onClick: () => setHidden(true), children: t.hide })] })] })), _jsxs(Button, { ref: chip, variant: "secondary", size: "touch", icon: minimized, "aria-label": t.title, "aria-expanded": open, className: cn("shadow-md", !minimized && "gap-1.5 px-3"), onClick: () => { if (open)
|
|
44
48
|
close(false);
|
|
45
49
|
else {
|
|
46
50
|
setOpen(true);
|
|
@@ -72,6 +76,6 @@ function ExperimentRow({ experiment: e, value, unassigned, autoFocus, onPick })
|
|
|
72
76
|
const labelId = `ab-switcher-${e.key}`;
|
|
73
77
|
return (_jsxs("div", { role: "group", "aria-labelledby": labelId, className: "flex flex-col gap-2", children: [_jsxs("div", { className: "flex items-baseline justify-between gap-2 text-sm", children: [_jsx("span", { id: labelId, className: "font-medium text-ink", children: e.label ?? e.key }), value === undefined && _jsx("span", { className: "text-xs text-ink-soft", children: unassigned })] }), _jsx("div", { className: "flex flex-wrap gap-2", children: e.variants.map((v, i) => {
|
|
74
78
|
const on = v.value === value;
|
|
75
|
-
return (_jsx(Button, { variant: on ? "primary" : "outline", size: "touch", "aria-pressed": on, autoFocus: autoFocus && i === 0, className: "flex-1", onClick: () => onPick(v.value), children: v.label }, v.value));
|
|
79
|
+
return (_jsx(Button, { variant: on ? "primary" : "outline", size: "touch", "aria-pressed": on, disabled: value === undefined, autoFocus: autoFocus && i === 0, className: "flex-1", onClick: () => onPick(v.value), children: v.label }, v.value));
|
|
76
80
|
}) })] }));
|
|
77
81
|
}
|
|
@@ -12,9 +12,10 @@ export interface AnalyticsBoundaryProps {
|
|
|
12
12
|
placeSlug: string | null;
|
|
13
13
|
/**
|
|
14
14
|
* The cookie the brand's force parameter sets (`AbSwitcher`'s `qaCookie`).
|
|
15
|
-
* A visit carrying it is a test:
|
|
15
|
+
* A visit carrying it is a test: every event through the boundary's sink —
|
|
16
|
+
* page views, intents, the lead form's funnel and booking — says
|
|
16
17
|
* `forced: true`, so the QA menu's reloads do not count as a place's
|
|
17
|
-
* traffic. Read in the browser at each event
|
|
18
|
+
* traffic. Read in the browser at each event (`analyticsSink`).
|
|
18
19
|
*/
|
|
19
20
|
qaCookie?: string;
|
|
20
21
|
children: ReactNode;
|
|
@@ -3,7 +3,7 @@ import { jsx as _jsx } from "react/jsx-runtime";
|
|
|
3
3
|
import { ContactLinkTracker } from "@evinvest/marketing/tracker";
|
|
4
4
|
import { usePathname } from "next/navigation.js";
|
|
5
5
|
import { useEffect, useMemo, useState } from "react";
|
|
6
|
-
import { analyticsSink, countsAsPageView, EVENTS, experimentProps } from "../core/analytics.js";
|
|
6
|
+
import { analyticsSink, channelsProps, countsAsPageView, EVENTS, experimentProps } from "../core/analytics.js";
|
|
7
7
|
import { AnalyticsIdContext, AnalyticsSinkContext, experimentOf } from "./analytics-context.js";
|
|
8
8
|
import { newSubmissionId } from "./use-lead-submit.js";
|
|
9
9
|
/**
|
|
@@ -11,16 +11,7 @@ import { newSubmissionId } from "./use-lead-submit.js";
|
|
|
11
11
|
* contact link — or one the tracker cannot classify (`sms:`).
|
|
12
12
|
*/
|
|
13
13
|
const INTENT_ATTR = "data-intent";
|
|
14
|
-
const INTENTS = ["form_open", "booking", "sms", "callback"];
|
|
15
|
-
/**
|
|
16
|
-
* Whether the QA cookie is set with a value. Its own copy, not `ab-gate`'s:
|
|
17
|
-
* a module shared with `AbSwitcher` makes Turbopack split it into a chunk of
|
|
18
|
-
* its own, which costs every visitor more than these lines.
|
|
19
|
-
*/
|
|
20
|
-
const qaVisit = (name) => document.cookie.split(";").some(c => {
|
|
21
|
-
const eq = c.indexOf("=");
|
|
22
|
-
return eq > 0 && c.slice(0, eq).trim() === name && c.slice(eq + 1).trim() !== "";
|
|
23
|
-
});
|
|
14
|
+
const INTENTS = ["form_open", "booking", "sms", "callback", "telegram", "whatsapp_qr"];
|
|
24
15
|
function source() {
|
|
25
16
|
const utm = new URLSearchParams(window.location.search).get("utm_source");
|
|
26
17
|
if (utm)
|
|
@@ -41,31 +32,27 @@ export function AnalyticsBoundary({ target, placeSlug, qaCookie, children }) {
|
|
|
41
32
|
// One visitor for the boundary's life, in memory only: a sink rebuilt for
|
|
42
33
|
// another place keeps it, and a lead posted from the page can name it.
|
|
43
34
|
const [distinctId] = useState(newSubmissionId);
|
|
44
|
-
const sink = useMemo(() => analyticsSink({ key, host, brandId }, placeSlug, distinctId), [key, host, brandId, placeSlug, distinctId]);
|
|
35
|
+
const sink = useMemo(() => analyticsSink({ key, host, brandId }, placeSlug, distinctId, qaCookie), [key, host, brandId, placeSlug, distinctId, qaCookie]);
|
|
45
36
|
const pathname = usePathname();
|
|
46
|
-
// Spread into an event: `{}` without the cookie, so a brand that never
|
|
47
|
-
// passes `qaCookie` sends exactly what it sent before.
|
|
48
|
-
const forced = () => (qaCookie && qaVisit(qaCookie) ? { forced: true } : {});
|
|
49
37
|
useEffect(() => {
|
|
50
38
|
if (!countsAsPageView(pathname))
|
|
51
39
|
return;
|
|
52
40
|
sink.capture(EVENTS.pageView, {
|
|
53
41
|
source: source(),
|
|
54
42
|
device: window.matchMedia("(max-width: 767px)").matches ? "mobile" : "desktop",
|
|
55
|
-
...forced(),
|
|
56
43
|
});
|
|
57
|
-
}, [sink, pathname
|
|
44
|
+
}, [sink, pathname]);
|
|
58
45
|
useEffect(() => {
|
|
59
46
|
const onClick = (event) => {
|
|
60
47
|
const el = event.target instanceof Element ? event.target.closest(`[${INTENT_ATTR}]`) : null;
|
|
61
48
|
const channel = el?.getAttribute(INTENT_ATTR);
|
|
62
49
|
if (channel && INTENTS.some(i => i === channel))
|
|
63
|
-
sink.capture(EVENTS.intent, { channel, ...experimentOf(el)
|
|
50
|
+
sink.capture(EVENTS.intent, { channel, ...experimentOf(el) }, { transport: "beacon" });
|
|
64
51
|
};
|
|
65
52
|
document.addEventListener("click", onClick, { capture: true });
|
|
66
53
|
return () => document.removeEventListener("click", onClick, { capture: true });
|
|
67
|
-
}, [sink
|
|
54
|
+
}, [sink]);
|
|
68
55
|
// A link inside an experiment's island (`LeadCapture`) carries the
|
|
69
56
|
// assignment on itself, which is what the tracker reads.
|
|
70
|
-
return (_jsx(AnalyticsIdContext.Provider, { value: key ? distinctId : null, children: _jsx(AnalyticsSinkContext.Provider, { value: sink, children: _jsx(ContactLinkTracker, { channels: ["phone", "whatsapp"], onContact: ({ channel, data }) => sink.capture(EVENTS.intent, { channel, ...experimentProps(data["experiment"], data["variant"]), ...
|
|
57
|
+
return (_jsx(AnalyticsIdContext.Provider, { value: key ? distinctId : null, children: _jsx(AnalyticsSinkContext.Provider, { value: sink, children: _jsx(ContactLinkTracker, { channels: ["phone", "whatsapp"], onContact: ({ channel, data }) => sink.capture(EVENTS.intent, { channel, ...experimentProps(data["experiment"], data["variant"]), ...channelsProps(data["channelsAvailable"]) }, { transport: "beacon" }), children: children }) }) }));
|
|
71
58
|
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { type ReactNode } from "react";
|
|
2
2
|
import { type CaptureChannel } from "../core/channels.js";
|
|
3
3
|
import { type LeadError, type LeadWire, type PageLeadError } from "../core/lead.js";
|
|
4
|
+
import { type MessengerFacts, type MessengerVariant } from "../core/messenger.js";
|
|
4
5
|
import { type LeadCaptureText } from "../core/lead-capture-text.js";
|
|
5
6
|
import { type Place } from "../core/place/types.js";
|
|
6
7
|
import { type LeadFlows } from "../core/pricing/flow.js";
|
|
@@ -14,10 +15,11 @@ import { type FieldPart } from "./LeadCaptureFields.js";
|
|
|
14
15
|
import { type LeadCaptureLayout, type LeadNeedDisplay, type LeadNeedOption } from "./LeadCaptureNeed.js";
|
|
15
16
|
import { type PricePart } from "./LeadCapturePrice.js";
|
|
16
17
|
import { type StepsPart } from "./LeadCaptureSteps.js";
|
|
18
|
+
import type { MessengerPart } from "./messenger/types.js";
|
|
17
19
|
import { type LeadIntro } from "./lead-steps.js";
|
|
18
20
|
import type { PartClassNames } from "./parts.js";
|
|
19
21
|
import { type LeadSent } from "./use-lead-submit.js";
|
|
20
|
-
export type LeadCapturePart = "root" | "head" | "title" | "lede" | "form" | "contact" | "submit" | "trust" | "privacy" | "opening" | "others" | "done" | "needs" | "need" | "summary" | "icon" | "intro" | "introOption" | "stepNext" | FieldPart | ChannelPart | EstimatePart | PricePart | StepsPart | PricedPart | BookingPart;
|
|
22
|
+
export type LeadCapturePart = "root" | "head" | "title" | "lede" | "form" | "contact" | "submit" | "trust" | "privacy" | "opening" | "others" | "done" | "needs" | "need" | "summary" | "icon" | "intro" | "introOption" | "stepNext" | FieldPart | ChannelPart | EstimatePart | PricePart | StepsPart | PricedPart | BookingPart | MessengerPart;
|
|
21
23
|
export interface LeadCaptureProps {
|
|
22
24
|
/** Its hours order the channels; its service area suggests the commune. */
|
|
23
25
|
place: Place<string>;
|
|
@@ -117,6 +119,27 @@ export interface LeadCaptureProps {
|
|
|
117
119
|
channelIcons?: Partial<Record<ChannelIconKey, ReactNode>> | undefined;
|
|
118
120
|
/** Moved first when available — a `default_channel` experiment's arm. */
|
|
119
121
|
prefer?: CaptureChannel | undefined;
|
|
122
|
+
/**
|
|
123
|
+
* The `lead_channel` experiment's arm: how the card offers WhatsApp and the
|
|
124
|
+
* brand's bot (`MessengerVariant`); absent → the control. A place without
|
|
125
|
+
* WhatsApp falls back to the control (`messengerShownOf`), and every event
|
|
126
|
+
* says which messengers were there (`channels_available`).
|
|
127
|
+
*/
|
|
128
|
+
messenger?: MessengerVariant | undefined;
|
|
129
|
+
/** `messengerFacts(site, place)`: the place's own WhatsApp number and bot, each `null` when off. */
|
|
130
|
+
messengers?: MessengerFacts | undefined;
|
|
131
|
+
/** The brand's prefix of the lead's chat reference (`AQ` → `AQ-7K3F`, `message_ref`); without it no reference. */
|
|
132
|
+
refPrefix?: string | undefined;
|
|
133
|
+
/** The brand's name: the prefilled message's greeting («Bonjour Aquafix»). */
|
|
134
|
+
brand?: string | undefined;
|
|
135
|
+
/**
|
|
136
|
+
* The estimate's question whose answer is the message's timing line and
|
|
137
|
+
* in its preview (`{ input: "frequency" }` → «Délai souhaité : 2 sem.»);
|
|
138
|
+
* nothing until it is answered, nor for «I don't know».
|
|
139
|
+
*/
|
|
140
|
+
messengerTiming?: {
|
|
141
|
+
input: string;
|
|
142
|
+
} | undefined;
|
|
120
143
|
/** The site's assignment: on every event, and posted with the form. Slugs only. */
|
|
121
144
|
experiment?: Experiment | undefined;
|
|
122
145
|
timeZone?: string | undefined;
|