@evinvest/kitstart 0.15.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.15.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 {
@@ -351,7 +351,9 @@ it is the plain POST to `/quote` it always was.
351
351
  `variant`; `contact_intent_click {channel}` for `phone`, `whatsapp`, `sms`,
352
352
  `callback`, with the experiment; and on the server `lead_form_submit
353
353
  {form_id, channel}` and `lead_form_reject {form_id, channel, field,
354
- 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`).
355
357
  - **Weight.** 8.9 KB gz of first-load JS on the template's place page
356
358
  (157,287 → 166,361 B against its 158,000 B target: +5.3 %, a warning
357
359
  within the 20 % tolerance); the refusals and the shared phone rule are
@@ -919,6 +921,15 @@ adds no server path: a tap on a variant goes to the same URL with
919
921
  reads `ab_<key>` from `document.cookie` (the normal case: the page stays
920
922
  static, the server reads no cookie). `forceParam` — `ab_` by default.
921
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`).
922
933
  - **Turning it on, on a phone, in production:** open any page with
923
934
  `?ab_<key>=<value>`. The proxy sets the QA cookie and the chip appears;
924
935
  every visit after is tagged as a test in analytics. Outside production
@@ -936,6 +947,29 @@ adds no server path: a tap on a variant goes to the same URL with
936
947
  too. **Minimize** and **Hide** last until the next load.
937
948
  - Screenshots: the chip and its panel carry `data-ab-switcher`; hide it in the
938
949
  section stylesheet, `[data-ab-switcher] { display: none !important; }`.
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.
964
+
965
+ ```tsx
966
+ <AnalyticsBoundary target={target} placeSlug={slug} qaCookie="ab__qa">
967
+ ```
968
+
969
+ ```ts
970
+ // app/quote/route.ts
971
+ export const POST = quoteRoute(site, { env: serverEnv, notifier, unavailable, qaCookie: "ab__qa" });
972
+ ```
939
973
 
940
974
  The decisions are plain functions in the core (`abSwitcherVisible`,
941
975
  `abVariantUrl`, `abReset`, `abAssignments`), for a brand's own tests.
@@ -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);
@@ -57,5 +68,9 @@ export function abReset(href, keys, qaCookie, mode, forceParam = AB_FORCE_PARAM)
57
68
  const url = new URL(href);
58
69
  for (const key of keys)
59
70
  url.searchParams.delete(`${forceParam}${key}`);
71
+ // `/fr#quote` with no force parameter would otherwise be the URL itself: the
72
+ // browser only scrolls to the anchor, the page never reloads, and the cookies
73
+ // just dropped are never re-drawn.
74
+ url.hash = "";
60
75
  return { cookies: names.map(n => `${n}=; path=/; max-age=0`), url: url.toString() };
61
76
  }
@@ -40,7 +40,7 @@ export type LeadStep = "need" | "contact" | "intro" | "locality" | "phone" | `es
40
40
  * throws, prod drops) rather than by review. `experiment` and `variant` are
41
41
  * the site's assignment, passed in; `field` is a field's role, never its value.
42
42
  */
43
- export declare const ALLOWED_PROPS: readonly ["brand_id", "location_id", "source", "device", "channel", "form_id", "experiment", "variant", "field", "step", "layout", "reason", "blocking", "need", "cents_bucket", "provider"];
43
+ export declare const ALLOWED_PROPS: readonly ["brand_id", "location_id", "source", "device", "channel", "form_id", "experiment", "variant", "field", "step", "layout", "reason", "blocking", "need", "cents_bucket", "provider", "forced"];
44
44
  /**
45
45
  * The beacon's `distinct_id` as a lead may carry it to the panel
46
46
  * (`lead.created`'s `analytics_id`), so PostHog can join the lead to the
@@ -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;
@@ -57,6 +57,9 @@ export const ALLOWED_PROPS = [
57
57
  "cents_bucket",
58
58
  // The booking's provider, `manual` | `link` | `cal_com` — never its URL.
59
59
  "provider",
60
+ // A test visit (`true`): the brand's QA cookie was set, by a forced variant
61
+ // or the QA menu. Absent otherwise, never `false`.
62
+ "forced",
60
63
  ];
61
64
  /**
62
65
  * The beacon's `distinct_id` as a lead may carry it to the panel
@@ -77,6 +80,19 @@ export function experimentProps(experiment, variant) {
77
80
  export function countsAsPageView(pathname) {
78
81
  return !pathname.replace(/\/+$/, "").endsWith(THANKS);
79
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
+ }
80
96
  /**
81
97
  * Cookieless by construction: a beacon sink keeps its `distinct_id` in memory,
82
98
  * writes no cookie and no storage, and needs no consent banner. `sendBeacon`
@@ -84,13 +100,22 @@ export function countsAsPageView(pathname) {
84
100
  * followed by the page handing the visitor to another app. `distinctId`:
85
101
  * the visitor's id, held by the caller so a lead can name it; a random one
86
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.
87
111
  */
88
- export function analyticsSink(target, locationId, distinctId) {
89
- return createBeaconSink({
112
+ export function analyticsSink(target, locationId, distinctId, qaCookie, cookies) {
113
+ const sink = createBeaconSink({
90
114
  key: target.key ?? undefined,
91
115
  ...(distinctId !== undefined ? { distinctId } : {}),
92
116
  host: target.host,
93
117
  allowedProps: ALLOWED_PROPS,
94
118
  globalProps: locationId ? { brand_id: target.brandId, location_id: locationId } : { brand_id: target.brandId },
95
119
  });
120
+ return { capture: (event, props, options) => sink.capture(event, qaCookie && qaVisit(cookies ?? document.cookie, qaCookie) ? { ...props, forced: true } : props, options) };
96
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
  }
@@ -7,8 +7,17 @@ import { type AnalyticsTarget } from "../core/analytics.js";
7
7
  * writes no cookie. `target` is plain data from the server layout — the key
8
8
  * is read from the container when the page renders, never inlined.
9
9
  */
10
- export declare function AnalyticsBoundary({ target, placeSlug, children }: {
10
+ export interface AnalyticsBoundaryProps {
11
11
  target: AnalyticsTarget;
12
12
  placeSlug: string | null;
13
+ /**
14
+ * The cookie the brand's force parameter sets (`AbSwitcher`'s `qaCookie`).
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
17
+ * `forced: true`, so the QA menu's reloads do not count as a place's
18
+ * traffic. Read in the browser at each event (`analyticsSink`).
19
+ */
20
+ qaCookie?: string;
13
21
  children: ReactNode;
14
- }): import("react").JSX.Element;
22
+ }
23
+ export declare function AnalyticsBoundary({ target, placeSlug, qaCookie, children }: AnalyticsBoundaryProps): import("react").JSX.Element;
@@ -26,20 +26,13 @@ function source() {
26
26
  return "unknown";
27
27
  }
28
28
  }
29
- /**
30
- * The only client island analytics needs. Everything it wraps stays server
31
- * rendered; this adds one delegated listener (capture phase, never cancelling
32
- * the navigation) and a page-view beacon. With no key it sends nothing, and it
33
- * writes no cookie. `target` is plain data from the server layout — the key
34
- * is read from the container when the page renders, never inlined.
35
- */
36
- export function AnalyticsBoundary({ target, placeSlug, children }) {
29
+ export function AnalyticsBoundary({ target, placeSlug, qaCookie, children }) {
37
30
  // On the target's values: a server layout hands a fresh object every render.
38
31
  const { key, host, brandId } = target;
39
32
  // One visitor for the boundary's life, in memory only: a sink rebuilt for
40
33
  // another place keeps it, and a lead posted from the page can name it.
41
34
  const [distinctId] = useState(newSubmissionId);
42
- 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]);
43
36
  const pathname = usePathname();
44
37
  useEffect(() => {
45
38
  if (!countsAsPageView(pathname))
@@ -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
  /**
@@ -15,7 +15,7 @@
15
15
  */
16
16
  export { AbSwitcher } from "./AbSwitcher.js";
17
17
  export type { AbSwitcherExperiment, AbSwitcherProps, AbSwitcherText, AbSwitcherVariant } from "./ab-switcher-types.js";
18
- export { AnalyticsBoundary } from "./AnalyticsBoundary.js";
18
+ export { AnalyticsBoundary, type AnalyticsBoundaryProps } from "./AnalyticsBoundary.js";
19
19
  export { AreaChips } from "./AreaChips.js";
20
20
  export { CallBar, type CallBarPart, type CallBarProps } from "./CallBar.js";
21
21
  export { Coverage, type CoveragePart, type CoverageProps } from "./Coverage.js";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@evinvest/kitstart",
3
- "version": "0.15.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": {
@@ -23,7 +23,7 @@ export default async function PlaceLayout({ children, params }: { children: Reac
23
23
  const env = serverEnv();
24
24
  // The key is read from the container when the page renders, never inlined.
25
25
  return (
26
- <AnalyticsBoundary target={{ key: env.posthogKey, host: env.posthogHost, brandId: site.brand.id }} placeSlug={view.place.slug}>
26
+ <AnalyticsBoundary target={{ key: env.posthogKey, host: env.posthogHost, brandId: site.brand.id }} placeSlug={view.place.slug} qaCookie={QA_COOKIE}>
27
27
  {children}
28
28
  {/* Above the phone's call bar; nothing at all for a visit without the QA cookie. */}
29
29
  <AbSwitcher experiments={EXPERIMENTS} qaCookie={QA_COOKIE} className="bottom-20 md:bottom-4" />
@@ -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.15.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.15.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();
@@ -83,6 +85,21 @@ test("Leave test drops the QA cookie too: after the load there is no chip", asyn
83
85
  await expect(chip(page)).toHaveCount(0);
84
86
  });
85
87
 
88
+ test("Leave test from a page reached by an anchor still reloads it, and the chip goes", async ({ page, context, baseURL }) => {
89
+ await asTester(context, baseURL);
90
+ await page.goto("/fr#quote");
91
+ await chip(page).click();
92
+ // A same-URL replace with the anchor kept is a scroll, not a load: this waits forever.
93
+ const reloaded = page.waitForEvent("load");
94
+ await panel(page).getByRole("button", { name: "Leave test" }).click();
95
+ await reloaded;
96
+ await expect(page).toHaveURL(/\/fr$/);
97
+ await hydrated(page);
98
+ await page.waitForLoadState("networkidle");
99
+ expect(await cookieNames(context)).not.toContain(QA_COOKIE);
100
+ await expect(chip(page)).toHaveCount(0);
101
+ });
102
+
86
103
  test("Escape closes the panel and gives the focus back to the chip", async ({ page, context, baseURL }) => {
87
104
  await assignedB(page, context, baseURL);
88
105
  await page.keyboard.press("Escape");