@evinvest/kitstart 0.16.0 → 0.17.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 CHANGED
@@ -45,7 +45,7 @@ brand's `flake.nix` is its own config plus one call — `template/flake.nix` is
45
45
  the whole of one:
46
46
 
47
47
  ```nix
48
- inputs.ev.url = "github:EV-invest/lib?ref=@evinvest/kitstart-v0.16.0"; # the version in package-lock.json
48
+ inputs.ev.url = "github:EV-invest/lib?ref=@evinvest/kitstart-v0.17.0"; # the version in package-lock.json
49
49
  inputs.ev.inputs.v_flakes.follows = "v_flakes";
50
50
 
51
51
  landing = ev.lib.mkLanding {
@@ -349,11 +349,11 @@ it is the plain POST to `/quote` it always was.
349
349
  (`contact` / `need` in `qualify-first`; in `steps` each screen moved to —
350
350
  `intro`, `need`, `estimate_<input>`, `locality`, `phone`), each with `form_id`, `layout`, `experiment`,
351
351
  `variant`; `contact_intent_click {channel}` for `phone`, `whatsapp`, `sms`,
352
- `callback`, with the experiment (and `forced: true` on a test visit, as
353
- `location_page_view`, when the boundary has `qaCookie` — see `AbSwitcher`);
354
- and on the server `lead_form_submit
352
+ `callback`, with the experiment; and on the server `lead_form_submit
355
353
  {form_id, channel}` and `lead_form_reject {form_id, channel, field,
356
- reason}` with the posted experiment.
354
+ reason}` with the posted experiment. On a test visit every one of them —
355
+ page view, intents, funnel, booking, and the server's two when
356
+ `quoteRoute` has `qaCookie` — carries `forced: true` (see `AbSwitcher`).
357
357
  - **Weight.** 8.9 KB gz of first-load JS on the template's place page
358
358
  (157,287 → 166,361 B against its 158,000 B target: +5.3 %, a warning
359
359
  within the 20 % tolerance); the refusals and the shared phone rule are
@@ -921,6 +921,15 @@ adds no server path: a tap on a variant goes to the same URL with
921
921
  reads `ab_<key>` from `document.cookie` (the normal case: the page stays
922
922
  static, the server reads no cookie). `forceParam` — `ab_` by default.
923
923
  `text` — the menu's words, English by default.
924
+ - **An experiment with no assignment is off.** The proxy assigns every
925
+ running experiment on the page the menu mounts on, so one with no
926
+ `ab_<key>` cookie (or missing from `current`) is shown `not running`
927
+ (`text.unassigned`), its variants disabled — the proxy would refuse the
928
+ force — and `–` on the chip. A test the panel only **paused** keeps its
929
+ visitors' cookies (dropping them would redraw everyone on resume), so the
930
+ brand's proxy lists paused tests for a QA browser only in the cookie
931
+ `<qaCookie>_off` (`ab__qa_off=lead_form,hero`); those read `not running`
932
+ too (`abRunning`).
924
933
  - **Turning it on, on a phone, in production:** open any page with
925
934
  `?ab_<key>=<value>`. The proxy sets the QA cookie and the chip appears;
926
935
  every visit after is tagged as a test in analytics. Outside production
@@ -938,16 +947,30 @@ adds no server path: a tap on a variant goes to the same URL with
938
947
  too. **Minimize** and **Hide** last until the next load.
939
948
  - Screenshots: the chip and its panel carry `data-ab-switcher`; hide it in the
940
949
  section stylesheet, `[data-ab-switcher] { display: none !important; }`.
941
- - **Pass the same `qaCookie` to `AnalyticsBoundary`.** Every tap in the menu
942
- is a full reload, so a test visit would add page views to the place's
943
- traffic; with the cookie set, the boundary's `location_page_view` and
944
- `contact_intent_click` carry `forced: true`. Without the prop, or without
945
- the cookie, the events are as before — no `forced` key at all.
950
+ - **Pass the same `qaCookie` to `AnalyticsBoundary` and `quoteRoute`
951
+ (#219).** Every tap in the menu is a full reload, so a test visit would add
952
+ page views — and form events — to the place's traffic. The mark is the
953
+ sink's, not each event's: `analyticsSink(target, slug, id, qaCookie)` reads
954
+ the cookie at every capture and adds `forced: true` on a test visit, so the
955
+ boundary's page views and intents, the lead form's funnel
956
+ (`lead_form_view` … `lead_estimate_shown`) and the booking's
957
+ `lead_booking_open` / `lead_booking_done` all say it, and so will any event
958
+ added later. `quoteRoute`'s `qaCookie` reads the post's `Cookie` header the
959
+ same way for `lead_form_submit` and `lead_form_reject`. A test visit is the
960
+ cookie set with a non-empty value — any value — on both sides
961
+ (`qaVisit(cookies, name)`, exported for a brand's own server events).
962
+ Without the prop, or without the cookie, the events are as before — no
963
+ `forced` key at all.
946
964
 
947
965
  ```tsx
948
966
  <AnalyticsBoundary target={target} placeSlug={slug} qaCookie="ab__qa">
949
967
  ```
950
968
 
969
+ ```ts
970
+ // app/quote/route.ts
971
+ export const POST = quoteRoute(site, { env: serverEnv, notifier, unavailable, qaCookie: "ab__qa" });
972
+ ```
973
+
951
974
  The decisions are plain functions in the core (`abSwitcherVisible`,
952
975
  `abVariantUrl`, `abReset`, `abAssignments`), for a brand's own tests.
953
976
 
@@ -11,7 +11,9 @@
11
11
  export function abSwitcherVisible(cookies, qaCookie, nodeEnv) {
12
12
  if (nodeEnv !== "production")
13
13
  return true;
14
- // Presence and non-empty only, no decoding: every visitor runs this.
14
+ // Presence and non-empty only, no decoding: every visitor runs this. The
15
+ // rule is `qaVisit`'s (analytics.ts), copied so the switcher and the
16
+ // analytics island share no module; `forced.node.test.ts` holds them equal.
15
17
  return cookies.split(";").some(pair => {
16
18
  const eq = pair.indexOf("=");
17
19
  return eq > 0 && pair.slice(0, eq).trim() === qaCookie && pair.slice(eq + 1).trim() !== "";
@@ -16,6 +16,14 @@ export declare const AB_FORCE_PARAM = "ab_";
16
16
  export declare function cookieValue(cookies: string, name: string): string | null;
17
17
  /** Each key's assigned variant read off the cookies; a key with none is left out. */
18
18
  export declare function abAssignments(cookies: string, keys: readonly string[]): Record<string, string>;
19
+ /**
20
+ * `assigned` without the tests the brand's panel has paused. The brand's proxy
21
+ * lists them, for a QA browser only, in the cookie `<qaCookie>_off`
22
+ * (`lead_form,hero`): a paused test keeps every visitor's assignment cookie —
23
+ * dropping it would redraw them on resume — so its cookie alone cannot tell
24
+ * the menu it is off, and a force of it is refused.
25
+ */
26
+ export declare function abRunning(assigned: Readonly<Record<string, string>>, cookies: string, qaCookie: string): Record<string, string>;
19
27
  /** The same page forcing `key` to `value`: the other parameters and the hash kept, an earlier force replaced. */
20
28
  export declare function abVariantUrl(href: string, key: string, value: string, forceParam?: string): string;
21
29
  export interface AbReset {
@@ -38,6 +38,17 @@ export function abAssignments(cookies, keys) {
38
38
  }
39
39
  return out;
40
40
  }
41
+ /**
42
+ * `assigned` without the tests the brand's panel has paused. The brand's proxy
43
+ * lists them, for a QA browser only, in the cookie `<qaCookie>_off`
44
+ * (`lead_form,hero`): a paused test keeps every visitor's assignment cookie —
45
+ * dropping it would redraw them on resume — so its cookie alone cannot tell
46
+ * the menu it is off, and a force of it is refused.
47
+ */
48
+ export function abRunning(assigned, cookies, qaCookie) {
49
+ const paused = new Set((cookieValue(cookies, `${qaCookie}_off`) ?? "").split(","));
50
+ return Object.fromEntries(Object.entries(assigned).filter(([key]) => !paused.has(key)));
51
+ }
41
52
  /** The same page forcing `key` to `value`: the other parameters and the hash kept, an earlier force replaced. */
42
53
  export function abVariantUrl(href, key, value, forceParam = AB_FORCE_PARAM) {
43
54
  const url = new URL(href);
@@ -69,6 +69,14 @@ export interface AnalyticsTarget {
69
69
  host: string;
70
70
  brandId: string;
71
71
  }
72
+ /**
73
+ * Whether a `Cookie` string — `document.cookie` or a request's header — makes
74
+ * the visit a test: the QA cookie is set with a non-empty value, any value.
75
+ * The one rule both sides apply (the sink in the browser, `quoteRoute` on the
76
+ * server). `abSwitcherVisible` keeps its own copy on purpose: a module the
77
+ * switcher shared with the analytics island cost a chunk of its own.
78
+ */
79
+ export declare function qaVisit(cookies: string, qaCookie: string): boolean;
72
80
  /**
73
81
  * Cookieless by construction: a beacon sink keeps its `distinct_id` in memory,
74
82
  * writes no cookie and no storage, and needs no consent banner. `sendBeacon`
@@ -76,5 +84,13 @@ export interface AnalyticsTarget {
76
84
  * followed by the page handing the visitor to another app. `distinctId`:
77
85
  * the visitor's id, held by the caller so a lead can name it; a random one
78
86
  * per sink without it.
87
+ *
88
+ * `qaCookie`: the brand's QA cookie (`AbSwitcher`'s). Every capture then checks
89
+ * `cookies` — a request's `Cookie` header on the server; left out, the
90
+ * browser's `document.cookie`, read at that capture — and adds `forced: true`
91
+ * on a test visit (`qaVisit`), so no event, present or future, has to
92
+ * remember it. Without `qaCookie` no cookie is read and no `forced` key sent.
93
+ * One wrapper for both cases, not a branch: on the place page that is ~10 B
94
+ * gz less than returning the bare sink.
79
95
  */
80
- export declare function analyticsSink(target: AnalyticsTarget, locationId: string | null, distinctId?: string): AnalyticsSink;
96
+ export declare function analyticsSink(target: AnalyticsTarget, locationId: string | null, distinctId?: string, qaCookie?: string, cookies?: string): AnalyticsSink;
@@ -80,6 +80,19 @@ export function experimentProps(experiment, variant) {
80
80
  export function countsAsPageView(pathname) {
81
81
  return !pathname.replace(/\/+$/, "").endsWith(THANKS);
82
82
  }
83
+ /**
84
+ * Whether a `Cookie` string — `document.cookie` or a request's header — makes
85
+ * the visit a test: the QA cookie is set with a non-empty value, any value.
86
+ * The one rule both sides apply (the sink in the browser, `quoteRoute` on the
87
+ * server). `abSwitcherVisible` keeps its own copy on purpose: a module the
88
+ * switcher shared with the analytics island cost a chunk of its own.
89
+ */
90
+ export function qaVisit(cookies, qaCookie) {
91
+ return cookies.split(";").some(pair => {
92
+ const eq = pair.indexOf("=");
93
+ return eq > 0 && pair.slice(0, eq).trim() === qaCookie && pair.slice(eq + 1).trim() !== "";
94
+ });
95
+ }
83
96
  /**
84
97
  * Cookieless by construction: a beacon sink keeps its `distinct_id` in memory,
85
98
  * writes no cookie and no storage, and needs no consent banner. `sendBeacon`
@@ -87,13 +100,22 @@ export function countsAsPageView(pathname) {
87
100
  * followed by the page handing the visitor to another app. `distinctId`:
88
101
  * the visitor's id, held by the caller so a lead can name it; a random one
89
102
  * per sink without it.
103
+ *
104
+ * `qaCookie`: the brand's QA cookie (`AbSwitcher`'s). Every capture then checks
105
+ * `cookies` — a request's `Cookie` header on the server; left out, the
106
+ * browser's `document.cookie`, read at that capture — and adds `forced: true`
107
+ * on a test visit (`qaVisit`), so no event, present or future, has to
108
+ * remember it. Without `qaCookie` no cookie is read and no `forced` key sent.
109
+ * One wrapper for both cases, not a branch: on the place page that is ~10 B
110
+ * gz less than returning the bare sink.
90
111
  */
91
- export function analyticsSink(target, locationId, distinctId) {
92
- return createBeaconSink({
112
+ export function analyticsSink(target, locationId, distinctId, qaCookie, cookies) {
113
+ const sink = createBeaconSink({
93
114
  key: target.key ?? undefined,
94
115
  ...(distinctId !== undefined ? { distinctId } : {}),
95
116
  host: target.host,
96
117
  allowedProps: ALLOWED_PROPS,
97
118
  globalProps: locationId ? { brand_id: target.brandId, location_id: locationId } : { brand_id: target.brandId },
98
119
  });
120
+ return { capture: (event, props, options) => sink.capture(event, qaCookie && qaVisit(cookies ?? document.cookie, qaCookie) ? { ...props, forced: true } : props, options) };
99
121
  }
package/dist/index.d.ts CHANGED
@@ -16,8 +16,8 @@ export { checkTiming, HONEYPOT_FIELD, LEGACY_HONEYPOT_FIELDS, MIN_FILL_MS, RATE_
16
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";
17
17
  export { areaServedNodes, breadcrumbNode, businessId, businessNode, faqPageNode, offerNodes, organizationId, organizationNode, placeGraph, websiteId, type OfferInput, type PageGraphCopy, type QuestionAnswer, } from "./core/seo/ld.js";
18
18
  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";
19
+ export { ALLOWED_PROPS, ANALYTICS_ID, analyticsSink, countsAsPageView, EVENTS, EXPERIMENT_SLUG, experimentProps, qaVisit, type AnalyticsTarget, type IntentChannel, type LeadField, type LeadStep, } from "./core/analytics.js";
20
+ export { AB_COOKIE_PREFIX, AB_FORCE_PARAM, abAssignments, abRunning, abReset, abVariantUrl, cookieValue, type AbReset, } from "./core/ab-switcher.js";
21
21
  export { abSwitcherVisible } from "./core/ab-gate.js";
22
22
  export type { CallBarText, CopySlice, CoreText, PageMetaCopy, QuoteFormCopy, Said, StatusAction, StatusCopy, StatusScreenText, } from "./core/content.js";
23
23
  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
@@ -16,8 +16,8 @@ export { checkTiming, HONEYPOT_FIELD, LEGACY_HONEYPOT_FIELDS, MIN_FILL_MS, RATE_
16
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";
17
17
  export { areaServedNodes, breadcrumbNode, businessId, businessNode, faqPageNode, offerNodes, organizationId, organizationNode, placeGraph, websiteId, } from "./core/seo/ld.js";
18
18
  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";
19
+ export { ALLOWED_PROPS, ANALYTICS_ID, analyticsSink, countsAsPageView, EVENTS, EXPERIMENT_SLUG, experimentProps, qaVisit, } from "./core/analytics.js";
20
+ export { AB_COOKIE_PREFIX, AB_FORCE_PARAM, abAssignments, abRunning, abReset, abVariantUrl, cookieValue, } from "./core/ab-switcher.js";
21
21
  export { abSwitcherVisible } from "./core/ab-gate.js";
22
22
  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
23
  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;
@@ -148,6 +148,9 @@ export function quoteRoute(site, deps) {
148
148
  const locale = form.get("locale");
149
149
  return unavailable(null, typeof locale === "string" && site.i18n.isLocale(locale) ? locale : site.i18n.defaultLocale);
150
150
  }
151
+ // The request's events, marked as the browser's are (`analyticsSink`).
152
+ const cookies = request.headers.get("cookie") ?? "";
153
+ const sinkFor = (slug) => analyticsSink({ key: env.posthogKey, host: env.posthogHost, brandId: site.brand.id }, slug, undefined, deps.qaCookie, cookies);
151
154
  const outcome = await accept(form, clientKey(request.headers, env.trustedProxy ?? DEV_TRUST), {
152
155
  insert: lead => leadStore().insert(lead),
153
156
  findSubmission: async (submissionId) => (await leadStore().findSubmission?.(submissionId)) ?? null,
@@ -165,7 +168,7 @@ export function quoteRoute(site, deps) {
165
168
  hook.enqueue(lead, id, meta);
166
169
  defer(() => hook.tick().then(() => undefined, error => log.error("quote: the webhook delivery after a lead failed", error)));
167
170
  },
168
- capture: (lead, formId, tags) => analyticsSink({ key: env.posthogKey, host: env.posthogHost, brandId: site.brand.id }, lead.placeSlug).capture(EVENTS.leadSubmit, {
171
+ capture: (lead, formId, tags) => sinkFor(lead.placeSlug).capture(EVENTS.leadSubmit, {
169
172
  form_id: formId,
170
173
  channel: channelOf(lead),
171
174
  ...tags,
@@ -196,7 +199,7 @@ export function quoteRoute(site, deps) {
196
199
  const repriced = outcome.field === PRICE_CHANGED;
197
200
  const props = { form_id: outcome.formId, channel: outcome.channel, field: outcome.field, reason: repriced ? PRICE_CHANGED : "invalid", ...tags };
198
201
  if (env.posthogKey) {
199
- const sink = analyticsSink({ key: env.posthogKey, host: env.posthogHost, brandId: site.brand.id }, outcome.slug);
202
+ const sink = sinkFor(outcome.slug);
200
203
  defer(() => sink.capture(EVENTS.formReject, props));
201
204
  }
202
205
  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;
@@ -12,15 +12,6 @@ import { newSubmissionId } from "./use-lead-submit.js";
12
12
  */
13
13
  const INTENT_ATTR = "data-intent";
14
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
- });
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"]) }, { transport: "beacon" }), children: children }) }) }));
71
58
  }
@@ -19,7 +19,7 @@ export interface AbSwitcherText {
19
19
  leave: string;
20
20
  minimize: string;
21
21
  hide: string;
22
- /** Shown for an experiment no cookie assigns yet. */
22
+ /** Shown for an experiment no cookie assigns — one that is off; `not running` by default. */
23
23
  unassigned: string;
24
24
  }
25
25
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@evinvest/kitstart",
3
- "version": "0.16.0",
3
+ "version": "0.17.0",
4
4
  "description": "Landing-site machinery for local-service brands: a site composition root, the Place/Presence/ServiceArea model with a publication gate, host/locale routing, schema.org and SEO builders, the lead funnel with its antispam, and analytics events. Pure core at '.', more subpaths per runtime.",
5
5
  "license": "BlueOak-1.0.0",
6
6
  "publishConfig": {
@@ -1,6 +1,7 @@
1
1
  import { quoteRoute } from "@evinvest/kitstart/next";
2
2
  import { TEXT } from "@/entities/content";
3
3
  import { notifier, pricing, serverEnv } from "@/shared/config/env";
4
+ import { QA_COOKIE } from "@/shared/config/experiments";
4
5
  import { site } from "@/shared/config/site";
5
6
 
6
7
  /** The no-JS path: a plain form POST answered with a 303. */
@@ -10,6 +11,8 @@ export const POST = quoteRoute(site, {
10
11
  env: serverEnv,
11
12
  notifier,
12
13
  pricing,
14
+ // The layout's `AnalyticsBoundary` gets the same: a QA visit's lead says `forced: true`.
15
+ qaCookie: QA_COOKIE,
13
16
  unavailable: locale => {
14
17
  const t = TEXT[locale];
15
18
  const f = { place: site.brand.name, phone: site.brand.phone };
@@ -3,7 +3,7 @@
3
3
  v_flakes.url = "github:valeratrades/v_flakes?ref=v1.6";
4
4
  # The lib flake at the tag of the @evinvest/kitstart version in
5
5
  # package-lock.json — mkLanding refuses a mismatch.
6
- ev.url = "github:EV-invest/lib?ref=@evinvest/kitstart-v0.16.0";
6
+ ev.url = "github:EV-invest/lib?ref=@evinvest/kitstart-v0.17.0";
7
7
  ev.inputs.v_flakes.follows = "v_flakes";
8
8
  };
9
9
 
@@ -20,7 +20,7 @@
20
20
  "dependencies": {
21
21
  "@evinvest/analytics": "^0.4.0",
22
22
  "@evinvest/i18n": "^0.9.0",
23
- "@evinvest/kitstart": "^0.16.0",
23
+ "@evinvest/kitstart": "^0.17.0",
24
24
  "@evinvest/marketing": "^0.3.0",
25
25
  "@evinvest/uikit": "^0.26.0",
26
26
  "next": "16.3.6",
@@ -48,6 +48,8 @@ test("a new visitor gets no menu and never fetches its panel; a test visit gets
48
48
 
49
49
  test("a variant's tap forces it by the URL; the reset drops the force and keeps the visit a test", async ({ page, context, baseURL }) => {
50
50
  await asTester(context, baseURL);
51
+ // Assigned `a`, as a brand's proxy assigns every running experiment: one without a cookie is off in the menu.
52
+ await context.addCookies([{ name: "ab_lead_form", value: "a", url: baseURL ?? "" }]);
51
53
  await page.goto("/fr?utm_source=qa");
52
54
  await chip(page).click();
53
55
  await panel(page).getByRole("button", { name: "Steps" }).click();