@evinvest/kitstart 0.16.0 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/README.md +179 -19
  2. package/dist/core/ab-gate.js +3 -1
  3. package/dist/core/ab-switcher.d.ts +8 -0
  4. package/dist/core/ab-switcher.js +11 -0
  5. package/dist/core/accept.d.ts +14 -0
  6. package/dist/core/accept.js +21 -3
  7. package/dist/core/analytics.d.ts +28 -3
  8. package/dist/core/analytics.js +46 -2
  9. package/dist/core/channels.d.ts +12 -4
  10. package/dist/core/channels.js +11 -1
  11. package/dist/core/lead-capture-text.d.ts +6 -2
  12. package/dist/core/lead.d.ts +33 -10
  13. package/dist/core/lead.js +21 -8
  14. package/dist/core/messenger-message.d.ts +31 -0
  15. package/dist/core/messenger-message.js +33 -0
  16. package/dist/core/messenger-text.d.ts +104 -0
  17. package/dist/core/messenger-text.js +150 -0
  18. package/dist/core/messenger.d.ts +92 -0
  19. package/dist/core/messenger.js +55 -0
  20. package/dist/core/place/index.d.ts +1 -1
  21. package/dist/core/place/live.d.ts +5 -1
  22. package/dist/core/place/live.js +22 -1
  23. package/dist/core/place/types.d.ts +17 -1
  24. package/dist/core/site.d.ts +8 -1
  25. package/dist/core/site.js +22 -1
  26. package/dist/index.d.ts +9 -6
  27. package/dist/index.js +8 -5
  28. package/dist/next/quote-route.d.ts +7 -0
  29. package/dist/next/quote-route.js +9 -9
  30. package/dist/react/AbSwitcherPanel.d.ts +3 -1
  31. package/dist/react/AbSwitcherPanel.js +10 -6
  32. package/dist/react/AnalyticsBoundary.d.ts +3 -2
  33. package/dist/react/AnalyticsBoundary.js +7 -20
  34. package/dist/react/LeadCapture.d.ts +24 -1
  35. package/dist/react/LeadCapture.js +96 -11
  36. package/dist/react/LeadCaptureChannels.d.ts +7 -2
  37. package/dist/react/LeadCaptureChannels.js +2 -2
  38. package/dist/react/LeadCaptureEstimate.d.ts +6 -0
  39. package/dist/react/LeadCaptureFields.d.ts +17 -0
  40. package/dist/react/LeadCaptureFields.js +42 -33
  41. package/dist/react/LeadCaptureMessenger.d.ts +41 -0
  42. package/dist/react/LeadCaptureMessenger.js +133 -0
  43. package/dist/react/LeadCapturePrice.d.ts +9 -3
  44. package/dist/react/LeadCapturePrice.js +24 -4
  45. package/dist/react/LeadCaptureSubmit.d.ts +3 -0
  46. package/dist/react/LeadCaptureSubmit.js +2 -2
  47. package/dist/react/LeadCaptureTiles.js +9 -4
  48. package/dist/react/ab-switcher-types.d.ts +1 -1
  49. package/dist/react/analytics-context.d.ts +9 -3
  50. package/dist/react/analytics-context.js +7 -3
  51. package/dist/react/index.d.ts +1 -0
  52. package/dist/react/messenger/action.d.ts +144 -0
  53. package/dist/react/messenger/action.js +301 -0
  54. package/dist/react/messenger/chip.d.ts +8 -0
  55. package/dist/react/messenger/chip.js +22 -0
  56. package/dist/react/messenger/fallback.d.ts +8 -0
  57. package/dist/react/messenger/fallback.js +11 -0
  58. package/dist/react/messenger/saga.d.ts +10 -0
  59. package/dist/react/messenger/saga.js +33 -0
  60. package/dist/react/messenger/segment.d.ts +8 -0
  61. package/dist/react/messenger/segment.js +15 -0
  62. package/dist/react/messenger/select.d.ts +10 -0
  63. package/dist/react/messenger/select.js +26 -0
  64. package/dist/react/messenger/sheet.d.ts +10 -0
  65. package/dist/react/messenger/sheet.js +57 -0
  66. package/dist/react/messenger/split.d.ts +9 -0
  67. package/dist/react/messenger/split.js +17 -0
  68. package/dist/react/messenger/swap.d.ts +10 -0
  69. package/dist/react/messenger/swap.js +18 -0
  70. package/dist/react/messenger/thanks.d.ts +10 -0
  71. package/dist/react/messenger/thanks.js +18 -0
  72. package/dist/react/messenger/tiles.d.ts +8 -0
  73. package/dist/react/messenger/tiles.js +20 -0
  74. package/dist/react/messenger/types.d.ts +98 -0
  75. package/dist/react/messenger/types.js +1 -0
  76. package/dist/react/messenger/urgency.d.ts +10 -0
  77. package/dist/react/messenger/urgency.js +29 -0
  78. package/dist/react/use-lead-error.d.ts +3 -3
  79. package/dist/react/use-lead-events.d.ts +21 -1
  80. package/dist/react/use-lead-events.js +9 -3
  81. package/dist/react/use-lead-submit.d.ts +20 -6
  82. package/dist/react/use-lead-submit.js +23 -1
  83. package/dist/server/index.d.ts +1 -1
  84. package/dist/server/lead-store-sqlite.js +11 -5
  85. package/dist/server/lead-webhook.d.ts +30 -5
  86. package/dist/server/lead-webhook.js +13 -7
  87. package/dist/server/notify.js +11 -5
  88. package/dist/testing/index.d.ts +1 -1
  89. package/dist/testing/index.js +1 -1
  90. package/dist/testing/landing-contract.js +6 -1
  91. package/dist/testing/lead-rules.d.ts +8 -0
  92. package/dist/testing/lead-rules.js +30 -1
  93. package/package.json +5 -2
  94. package/template/app/quote/route.ts +3 -0
  95. package/template/flake.nix +1 -1
  96. package/template/package.json +2 -2
  97. package/template/tests/e2e/ab-switcher.spec.ts +2 -0
@@ -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.
@@ -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
- /** International numbers as printed; `null` → the brand's own. */
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";
@@ -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
- /** The numbers a place answers on: its own, or the brand's until it has one. */
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
- /** The numbers a place answers on: its own, or the brand's until it has one. */
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;
@@ -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, EXPERIMENT_FIELD, PRICE_CHANGED, VARIANT_FIELD } from "../core/accept.js";
4
- import { analyticsSink, EVENTS, experimentProps } from "../core/analytics.js";
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) => analyticsSink({ key: env.posthogKey, host: env.posthogHost, brandId: site.brand.id }, lead.placeSlug).capture(EVENTS.leadSubmit, {
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 = experimentProps(text(form, EXPERIMENT_FIELD), text(form, VARIANT_FIELD));
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 = analyticsSink({ key: env.posthogKey, host: env.posthogHost, brandId: site.brand.id }, outcome.slug);
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 assigned",
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 === 0, 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", 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)
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: its page views and intents say
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, never on the server.
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, qaCookie]);
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), ...forced() }, { transport: "beacon" });
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, qaCookie]);
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"]), ...forced() }, { transport: "beacon" }), children: children }) }) }));
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;