@evinvest/kitstart 0.14.0 → 0.16.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.14.0"; # the version in package-lock.json
48
+ inputs.ev.url = "github:EV-invest/lib?ref=@evinvest/kitstart-v0.16.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 {
@@ -122,7 +122,7 @@ the two must be one release. Pin the flake to the matching
122
122
  Structural only — where behaviour matters more than look. Marketing sections
123
123
  (hero, prices, reviews…) stay in the brand until two brands hold the same one.
124
124
  Each widget is a Server Component unless it needs the browser (`MapFacade`,
125
- `AnalyticsBoundary`, `FormSelect`), styled with the kit's token roles only, restyled through
125
+ `AnalyticsBoundary`, `FormSelect`, `AbSwitcher`), styled with the kit's token roles only, restyled through
126
126
  `className` — and, where a brand needs geometry inside one (`Faq`, `CallBar`,
127
127
  `StatusScreen`, `PlaceDirectory`, `Coverage`), through its named parts:
128
128
  `classNames={{ list: "rounded-none", answer: "px-2" }}`, merged after the
@@ -166,6 +166,7 @@ which CI runs: `npm run check:template -- --e2e`).
166
166
  |---|---|
167
167
  | `name`, `options: { value, label }[]` | the posted field and its choices (labels are text) |
168
168
  | `defaultValue` | without it: empty under a `placeholder`, else the first option — the native rule |
169
+ | `value` | controlled: shown and posted until the parent changes it from `onValueChange`; `""` is the placeholder, `undefined` uncontrolled |
169
170
  | `placeholder` | an empty, unpickable choice shown until one is made |
170
171
  | `required`, `disabled` | as on a `<select>`: required refuses the submit, disabled posts nothing |
171
172
  | `size` | `sm` · `md` · `lg` (the landing's) — the kit's form scale |
@@ -175,7 +176,7 @@ which CI runs: `npm run check:template -- --e2e`).
175
176
 
176
177
  - **The value survives the swap.** A choice made in the native select before
177
178
  the script arrived is read in the hydration commit; a form `reset` puts the
178
- default back in both states. With nothing chosen (under a `placeholder`) it
179
+ default back in both states (uncontrolled; under `value`, see below). With nothing chosen (under a `placeholder`) it
179
180
  posts nothing, as a native select on its disabled placeholder does.
180
181
  - **`required` still refuses the submit.** A `type="hidden"` input is never
181
182
  validated, so under `required` the value rides in a transparent input under
@@ -192,6 +193,19 @@ which CI runs: `npm run check:template -- --e2e`).
192
193
  - **Weight.** It is a client module and pulls the kit's `Select` into the
193
194
  page: 3.8 KB gz of first-load JS on the template's place page (152,774 →
194
195
  156,565 B of its 158,000 B budget).
196
+ - **Controlled, as an `<input>`.** Under `value` the prop is the value: a
197
+ pick only calls `onValueChange`, and the parent's state (or a reset of its
198
+ own, `setSubject("")` back to the placeholder) is what moves it. A form
199
+ `reset` leaves it on `value` — clear the parent's state from the form's
200
+ `onReset` if it should follow. Without JavaScript it is still the native
201
+ select, starting on `value`; a pick made there before hydration is reported
202
+ through `onValueChange`. Switching between `value` and `undefined` warns
203
+ once in development, as React does for an input, and keeps the last value.
204
+
205
+ ```tsx
206
+ const [subject, setSubject] = useState<string>(LEAD.subjects[0]);
207
+ <FormSelect name={LEAD.wire.subject} size="lg" value={subject} onValueChange={setSubject} options={subjects} />
208
+ ```
195
209
 
196
210
  ### `LeadCapture`: the lead form every brand shares
197
211
 
@@ -335,7 +349,9 @@ it is the plain POST to `/quote` it always was.
335
349
  (`contact` / `need` in `qualify-first`; in `steps` each screen moved to —
336
350
  `intro`, `need`, `estimate_<input>`, `locality`, `phone`), each with `form_id`, `layout`, `experiment`,
337
351
  `variant`; `contact_intent_click {channel}` for `phone`, `whatsapp`, `sms`,
338
- `callback`, with the experiment; and on the server `lead_form_submit
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
339
355
  {form_id, channel}` and `lead_form_reject {form_id, channel, field,
340
356
  reason}` with the posted experiment.
341
357
  - **Weight.** 8.9 KB gz of first-load JS on the template's place page
@@ -837,8 +853,9 @@ export const POST = quoteRoute(site, { env: serverEnv, notifier, webhook, unavai
837
853
 
838
854
  ## Experiments from the panel
839
855
 
840
- The experiments live in code (`@evinvest/experiments`, one `as const` config);
841
- the panel decides their weights and kill switch, and PostHog counts them.
856
+ The experiments live in code (`@evinvest/experiments`, one `as const` config
857
+ of variants, split equally); the panel decides any other weights and the kill
858
+ switch, and PostHog counts them.
842
859
 
843
860
  **Overrides** (`createExperimentsSource`): `GET <base>/experiments`, the same
844
861
  base as the place source, `{ "experiments": { "<key>": { enabled?, weights?,
@@ -862,7 +879,8 @@ declared length, holdout in `[0, 1)`) is `applyOverrides`' call.
862
879
 
863
880
  **Declaration** (`declareExperiments`): at every start the landing tells the
864
881
  panel which experiments this build runs — `experiments.declared@1`, one event
865
- with every experiment's key, variants, weights, `enabled`, `holdout` and an
882
+ with every experiment's key, variants, weights (always one each — the code's
883
+ equal split), `enabled`, `holdout` and an
866
884
  optional one-line `summary`, under a fresh UUIDv7. It goes through the lead
867
885
  webhook's outbox, signed and retried like a lead (so it needs the same
868
886
  `LEAD_WEBHOOK_URL` and sqlite lead store), and the panel keeps the latest by
@@ -879,10 +897,60 @@ export async function register() {
879
897
  It never throws and never holds the start: no webhook, one that cannot be
880
898
  built, or a queue that refuses the row is logged and skipped — the panel keeps
881
899
  the last declaration. An experiment the panel would refuse (key not
882
- `[a-z0-9_]{1,64}`, fewer than two unique slug variants, weights not one per
883
- variant, negative or summing to zero, holdout outside `[0, 1)`, summary over
900
+ `[a-z0-9_]{1,64}`, fewer than two unique slug variants, holdout outside
901
+ `[0, 1)`, summary over
884
902
  200 characters) is left out and logged, since the panel judges the event whole.
885
903
 
904
+ ### The QA switcher: `AbSwitcher`
905
+
906
+ A chip in a corner that switches an experiment's variant in one tap — for the
907
+ owner on a phone, in production. It drives the brand proxy's own mechanism and
908
+ adds no server path: a tap on a variant goes to the same URL with
909
+ `?ab_<key>=<value>`, which forces it and sets the QA cookie.
910
+
911
+ ```tsx
912
+ // app/[locale]/[location]/layout.tsx — a server layout; the props are plain data
913
+ <AbSwitcher
914
+ experiments={[{ key: "lead_form", label: "Lead form", variants: [{ value: "a", label: "Compact" }, { value: "b", label: "Steps" }] }]}
915
+ qaCookie="ab__qa" // the cookie the brand's force parameter sets
916
+ className="bottom-20 md:bottom-4" // over the brand's sticky bars; bottom-right by default
917
+ />
918
+ ```
919
+
920
+ - `current` — the assignments, if the caller knows them; left out, the panel
921
+ reads `ab_<key>` from `document.cookie` (the normal case: the page stays
922
+ static, the server reads no cookie). `forceParam` — `ab_` by default.
923
+ `text` — the menu's words, English by default.
924
+ - **Turning it on, on a phone, in production:** open any page with
925
+ `?ab_<key>=<value>`. The proxy sets the QA cookie and the chip appears;
926
+ every visit after is tagged as a test in analytics. Outside production
927
+ (`next dev`) the chip is always there.
928
+ - **What a visitor pays:** ~190 B gz of first load on the template's place
929
+ page. After hydration the gate checks `NODE_ENV` and the QA cookie
930
+ (non-empty) and only then imports the panel — a chunk of its own, ~2.1 KB
931
+ gz, that a visitor without the cookie never requests. The panel is the
932
+ kit's `Button` and `Badge` over its own small layer, not `Popover`: sharing
933
+ the overlay primitives with the page made Turbopack re-split the page's
934
+ chunks, ~400 B gz more for every visitor.
935
+ - **Reset** drops the experiments' assignment cookies (`ab_<key>`) and the
936
+ force parameters from the URL, then reloads: the variants are drawn again
937
+ and the visit stays a test, menu included. **Leave test** drops the QA cookie
938
+ too. **Minimize** and **Hide** last until the next load.
939
+ - Screenshots: the chip and its panel carry `data-ab-switcher`; hide it in the
940
+ 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.
946
+
947
+ ```tsx
948
+ <AnalyticsBoundary target={target} placeSlug={slug} qaCookie="ab__qa">
949
+ ```
950
+
951
+ The decisions are plain functions in the core (`abSwitcherVisible`,
952
+ `abVariantUrl`, `abReset`, `abAssignments`), for a brand's own tests.
953
+
886
954
  ## The site
887
955
 
888
956
  ```ts
@@ -0,0 +1,11 @@
1
+ /**
2
+ * The QA switcher's one decision a visitor pays for, apart from the rest
3
+ * (`ab-switcher.ts`) so the gate's bundle carries this function and nothing
4
+ * else: the panel's helpers arrive with the panel.
5
+ */
6
+ /**
7
+ * Whether the switcher shows: always outside production, and in production
8
+ * only to a visit carrying the QA cookie with a value — the cookie the brand's
9
+ * force parameter sets, so nobody else ever downloads the panel.
10
+ */
11
+ export declare function abSwitcherVisible(cookies: string, qaCookie: string, nodeEnv: string | undefined): boolean;
@@ -0,0 +1,19 @@
1
+ /**
2
+ * The QA switcher's one decision a visitor pays for, apart from the rest
3
+ * (`ab-switcher.ts`) so the gate's bundle carries this function and nothing
4
+ * else: the panel's helpers arrive with the panel.
5
+ */
6
+ /**
7
+ * Whether the switcher shows: always outside production, and in production
8
+ * only to a visit carrying the QA cookie with a value — the cookie the brand's
9
+ * force parameter sets, so nobody else ever downloads the panel.
10
+ */
11
+ export function abSwitcherVisible(cookies, qaCookie, nodeEnv) {
12
+ if (nodeEnv !== "production")
13
+ return true;
14
+ // Presence and non-empty only, no decoding: every visitor runs this.
15
+ return cookies.split(";").some(pair => {
16
+ const eq = pair.indexOf("=");
17
+ return eq > 0 && pair.slice(0, eq).trim() === qaCookie && pair.slice(eq + 1).trim() !== "";
18
+ });
19
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * The QA switcher's decisions, as plain functions over strings: what the
3
+ * cookies assign, where a variant's tap leads, what a reset clears — whether
4
+ * the menu shows at all is `ab-gate.ts`. The lazy panel only applies them to
5
+ * `document.cookie` and `location`.
6
+ *
7
+ * The mechanism is the brand proxy's, unchanged: it keeps an assignment in a
8
+ * cookie `ab_<key>`, and `?ab_<key>=<value>` forces a variant and sets the QA
9
+ * cookie that marks the visit as a test.
10
+ */
11
+ /** The prefix of an assignment cookie: `ab_lead_form` holds `lead_form`'s variant. */
12
+ export declare const AB_COOKIE_PREFIX = "ab_";
13
+ /** The default prefix of the query parameter that forces a variant. */
14
+ export declare const AB_FORCE_PARAM = "ab_";
15
+ /** A cookie's value from a `document.cookie` string, by exact name, or `null`. */
16
+ export declare function cookieValue(cookies: string, name: string): string | null;
17
+ /** Each key's assigned variant read off the cookies; a key with none is left out. */
18
+ export declare function abAssignments(cookies: string, keys: readonly string[]): Record<string, string>;
19
+ /** The same page forcing `key` to `value`: the other parameters and the hash kept, an earlier force replaced. */
20
+ export declare function abVariantUrl(href: string, key: string, value: string, forceParam?: string): string;
21
+ export interface AbReset {
22
+ /** Each a `document.cookie` assignment that deletes one cookie. */
23
+ cookies: string[];
24
+ /** The page without the force parameters: left in, a reload would force the variant again. */
25
+ url: string;
26
+ }
27
+ /**
28
+ * What a reset clears. `"reassign"` drops the experiments' assignments only —
29
+ * the variant is drawn again and the visit stays a test, the menu with it;
30
+ * `"leave"` drops the QA cookie too. The QA cookie may itself start with the
31
+ * assignment prefix (`ab__qa`), so it is excluded by name, never by pattern.
32
+ */
33
+ export declare function abReset(href: string, keys: readonly string[], qaCookie: string, mode: "reassign" | "leave", forceParam?: string): AbReset;
@@ -0,0 +1,65 @@
1
+ /**
2
+ * The QA switcher's decisions, as plain functions over strings: what the
3
+ * cookies assign, where a variant's tap leads, what a reset clears — whether
4
+ * the menu shows at all is `ab-gate.ts`. The lazy panel only applies them to
5
+ * `document.cookie` and `location`.
6
+ *
7
+ * The mechanism is the brand proxy's, unchanged: it keeps an assignment in a
8
+ * cookie `ab_<key>`, and `?ab_<key>=<value>` forces a variant and sets the QA
9
+ * cookie that marks the visit as a test.
10
+ */
11
+ /** The prefix of an assignment cookie: `ab_lead_form` holds `lead_form`'s variant. */
12
+ export const AB_COOKIE_PREFIX = "ab_";
13
+ /** The default prefix of the query parameter that forces a variant. */
14
+ export const AB_FORCE_PARAM = "ab_";
15
+ /** A cookie's value from a `document.cookie` string, by exact name, or `null`. */
16
+ export function cookieValue(cookies, name) {
17
+ for (const pair of cookies.split(";")) {
18
+ const eq = pair.indexOf("=");
19
+ if (eq < 0 || pair.slice(0, eq).trim() !== name)
20
+ continue;
21
+ const raw = pair.slice(eq + 1).trim();
22
+ try {
23
+ return decodeURIComponent(raw);
24
+ }
25
+ catch {
26
+ return raw;
27
+ }
28
+ }
29
+ return null;
30
+ }
31
+ /** Each key's assigned variant read off the cookies; a key with none is left out. */
32
+ export function abAssignments(cookies, keys) {
33
+ const out = {};
34
+ for (const key of keys) {
35
+ const value = cookieValue(cookies, `${AB_COOKIE_PREFIX}${key}`);
36
+ if (value)
37
+ out[key] = value;
38
+ }
39
+ return out;
40
+ }
41
+ /** The same page forcing `key` to `value`: the other parameters and the hash kept, an earlier force replaced. */
42
+ export function abVariantUrl(href, key, value, forceParam = AB_FORCE_PARAM) {
43
+ const url = new URL(href);
44
+ url.searchParams.set(`${forceParam}${key}`, value);
45
+ return url.toString();
46
+ }
47
+ /**
48
+ * What a reset clears. `"reassign"` drops the experiments' assignments only —
49
+ * the variant is drawn again and the visit stays a test, the menu with it;
50
+ * `"leave"` drops the QA cookie too. The QA cookie may itself start with the
51
+ * assignment prefix (`ab__qa`), so it is excluded by name, never by pattern.
52
+ */
53
+ export function abReset(href, keys, qaCookie, mode, forceParam = AB_FORCE_PARAM) {
54
+ const names = keys.map(k => `${AB_COOKIE_PREFIX}${k}`).filter(n => n !== qaCookie);
55
+ if (mode === "leave")
56
+ names.push(qaCookie);
57
+ const url = new URL(href);
58
+ for (const key of keys)
59
+ url.searchParams.delete(`${forceParam}${key}`);
60
+ // `/fr#quote` with no force parameter would otherwise be the URL itself: the
61
+ // browser only scrolls to the anchor, the page never reloads, and the cookies
62
+ // just dropped are never re-drawn.
63
+ url.hash = "";
64
+ return { cookies: names.map(n => `${n}=; path=/; max-age=0`), url: url.toString() };
65
+ }
@@ -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
@@ -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
package/dist/index.d.ts CHANGED
@@ -17,6 +17,8 @@ export { createAcceptLead, ANALYTICS_ID_FIELD, EXPERIMENT_FIELD, FORM_ID_FIELD,
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
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";
21
+ export { abSwitcherVisible } from "./core/ab-gate.js";
20
22
  export type { CallBarText, CopySlice, CoreText, PageMetaCopy, QuoteFormCopy, Said, StatusAction, StatusCopy, StatusScreenText, } from "./core/content.js";
21
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";
22
24
  export { brandStatusTarget, statusTarget, thanksChannel, thanksSuffix, type StatusTarget } from "./core/status.js";
package/dist/index.js CHANGED
@@ -17,6 +17,8 @@ export { createAcceptLead, ANALYTICS_ID_FIELD, EXPERIMENT_FIELD, FORM_ID_FIELD,
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
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";
21
+ export { abSwitcherVisible } from "./core/ab-gate.js";
20
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";
21
23
  export { brandStatusTarget, statusTarget, thanksChannel, thanksSuffix } from "./core/status.js";
22
24
  // A landing needs these beside the machinery; re-exported by name so a brand
@@ -0,0 +1,9 @@
1
+ import type { AbSwitcherProps } from "./ab-switcher-types.js";
2
+ /**
3
+ * The QA menu's gate: everything a visitor downloads for it. On the server and
4
+ * on the first client render it is nothing — the page stays static, no cookie
5
+ * is read there — and after mount it loads the panel only outside production
6
+ * or for a visit carrying the QA cookie. Nothing heavy is imported here: the
7
+ * panel and the kit come with the dynamic import, in their own chunk.
8
+ */
9
+ export declare function AbSwitcher(props: AbSwitcherProps): import("react").JSX.Element | null;
@@ -0,0 +1,22 @@
1
+ "use client";
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ import { useEffect, useState } from "react";
4
+ import { abSwitcherVisible } from "../core/ab-gate.js";
5
+ /**
6
+ * The QA menu's gate: everything a visitor downloads for it. On the server and
7
+ * on the first client render it is nothing — the page stays static, no cookie
8
+ * is read there — and after mount it loads the panel only outside production
9
+ * or for a visit carrying the QA cookie. Nothing heavy is imported here: the
10
+ * panel and the kit come with the dynamic import, in their own chunk.
11
+ */
12
+ export function AbSwitcher(props) {
13
+ const [Panel, setPanel] = useState(null);
14
+ const { qaCookie } = props;
15
+ useEffect(() => {
16
+ if (!abSwitcherVisible(document.cookie, qaCookie, process.env.NODE_ENV))
17
+ return;
18
+ // An answer after unmount is dropped by React; no flag to carry.
19
+ void import("./AbSwitcherPanel.js").then(m => setPanel(() => m.AbSwitcherPanel));
20
+ }, [qaCookie]);
21
+ return Panel ? _jsx(Panel, { ...props }) : null;
22
+ }
@@ -0,0 +1,8 @@
1
+ import type { AbSwitcherProps } from "./ab-switcher-types.js";
2
+ /**
3
+ * The QA menu itself, loaded only when `AbSwitcher` lets it: a chip in a
4
+ * corner and, on a tap, each experiment's variants. A tap on a variant is the
5
+ * brand's own force parameter — no other way in. Minimized and hidden live in
6
+ * memory only: a reload brings the chip back.
7
+ */
8
+ export declare function AbSwitcherPanel({ experiments, qaCookie, current, forceParam, className, text }: AbSwitcherProps): import("react").JSX.Element | null;
@@ -0,0 +1,77 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { Badge, Button, cn } from "@evinvest/uikit";
4
+ import { useEffect, useRef, useState } from "react";
5
+ import { abAssignments, abReset, abVariantUrl, AB_FORCE_PARAM } from "../core/ab-switcher.js";
6
+ const TEXT = {
7
+ title: "A/B test switcher",
8
+ reset: "Reset",
9
+ leave: "Leave test",
10
+ minimize: "Minimize",
11
+ hide: "Hide",
12
+ unassigned: "not assigned",
13
+ };
14
+ /**
15
+ * The QA menu itself, loaded only when `AbSwitcher` lets it: a chip in a
16
+ * corner and, on a tap, each experiment's variants. A tap on a variant is the
17
+ * brand's own force parameter — no other way in. Minimized and hidden live in
18
+ * memory only: a reload brings the chip back.
19
+ */
20
+ export function AbSwitcherPanel({ experiments, qaCookie, current, forceParam = AB_FORCE_PARAM, className, text }) {
21
+ const t = { ...TEXT, ...text };
22
+ const keys = experiments.map(e => e.key);
23
+ const [assigned] = useState(() => current ?? abAssignments(document.cookie, keys));
24
+ const [open, setOpen] = useState(false);
25
+ const [minimized, setMinimized] = useState(false);
26
+ const [hidden, setHidden] = useState(false);
27
+ const root = useRef(null);
28
+ const chip = useRef(null);
29
+ const close = (refocus) => {
30
+ setOpen(false);
31
+ if (refocus)
32
+ chip.current?.focus();
33
+ };
34
+ useDismiss(open, root, close);
35
+ const reset = (mode) => {
36
+ const plan = abReset(location.href, keys, qaCookie, mode, forceParam);
37
+ for (const c of plan.cookies)
38
+ document.cookie = c;
39
+ location.replace(plan.url);
40
+ };
41
+ if (hidden)
42
+ 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)
44
+ close(false);
45
+ else {
46
+ setOpen(true);
47
+ setMinimized(false);
48
+ } }, children: [_jsx("span", { className: "font-semibold", children: "A/B" }), !minimized && keys.map(k => _jsx(Badge, { variant: "outline", className: "border-current/40 text-current", children: assigned[k] ?? "–" }, k))] })] }));
49
+ }
50
+ /**
51
+ * Escape closes and gives the focus back to the chip; a tap outside closes.
52
+ * Not the kit's `Popover`: sharing its primitives with the page makes
53
+ * Turbopack re-split the page's chunks, ~400 B gz on every visitor's first
54
+ * load for a menu almost nobody opens. The panel is not portaled — the chip's
55
+ * fixed corner is its anchor — so the root holds both.
56
+ */
57
+ function useDismiss(open, root, close) {
58
+ useEffect(() => {
59
+ if (!open)
60
+ return;
61
+ const key = (e) => e.key === "Escape" && close(true);
62
+ const down = (e) => !(e.target instanceof Node && root.current?.contains(e.target)) && close(false);
63
+ document.addEventListener("keydown", key);
64
+ document.addEventListener("pointerdown", down, true);
65
+ return () => {
66
+ document.removeEventListener("keydown", key);
67
+ document.removeEventListener("pointerdown", down, true);
68
+ };
69
+ }, [open, root, close]);
70
+ }
71
+ function ExperimentRow({ experiment: e, value, unassigned, autoFocus, onPick }) {
72
+ const labelId = `ab-switcher-${e.key}`;
73
+ 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
+ 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));
76
+ }) })] }));
77
+ }
@@ -7,8 +7,16 @@ 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: its page views and intents say
16
+ * `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
+ */
19
+ qaCookie?: string;
13
20
  children: ReactNode;
14
- }): import("react").JSX.Element;
21
+ }
22
+ export declare function AnalyticsBoundary({ target, placeSlug, qaCookie, children }: AnalyticsBoundaryProps): import("react").JSX.Element;
@@ -12,6 +12,15 @@ 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
+ });
15
24
  function source() {
16
25
  const utm = new URLSearchParams(window.location.search).get("utm_source");
17
26
  if (utm)
@@ -26,14 +35,7 @@ function source() {
26
35
  return "unknown";
27
36
  }
28
37
  }
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 }) {
38
+ export function AnalyticsBoundary({ target, placeSlug, qaCookie, children }) {
37
39
  // On the target's values: a server layout hands a fresh object every render.
38
40
  const { key, host, brandId } = target;
39
41
  // One visitor for the boundary's life, in memory only: a sink rebuilt for
@@ -41,25 +43,29 @@ export function AnalyticsBoundary({ target, placeSlug, children }) {
41
43
  const [distinctId] = useState(newSubmissionId);
42
44
  const sink = useMemo(() => analyticsSink({ key, host, brandId }, placeSlug, distinctId), [key, host, brandId, placeSlug, distinctId]);
43
45
  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 } : {});
44
49
  useEffect(() => {
45
50
  if (!countsAsPageView(pathname))
46
51
  return;
47
52
  sink.capture(EVENTS.pageView, {
48
53
  source: source(),
49
54
  device: window.matchMedia("(max-width: 767px)").matches ? "mobile" : "desktop",
55
+ ...forced(),
50
56
  });
51
- }, [sink, pathname]);
57
+ }, [sink, pathname, qaCookie]);
52
58
  useEffect(() => {
53
59
  const onClick = (event) => {
54
60
  const el = event.target instanceof Element ? event.target.closest(`[${INTENT_ATTR}]`) : null;
55
61
  const channel = el?.getAttribute(INTENT_ATTR);
56
62
  if (channel && INTENTS.some(i => i === channel))
57
- sink.capture(EVENTS.intent, { channel, ...experimentOf(el) }, { transport: "beacon" });
63
+ sink.capture(EVENTS.intent, { channel, ...experimentOf(el), ...forced() }, { transport: "beacon" });
58
64
  };
59
65
  document.addEventListener("click", onClick, { capture: true });
60
66
  return () => document.removeEventListener("click", onClick, { capture: true });
61
- }, [sink]);
67
+ }, [sink, qaCookie]);
62
68
  // A link inside an experiment's island (`LeadCapture`) carries the
63
69
  // assignment on itself, which is what the tracker reads.
64
- 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 }) }) }));
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 }) }) }));
65
71
  }
@@ -22,6 +22,12 @@ export interface FormSelectProps {
22
22
  options: readonly FormSelectOption[];
23
23
  /** Without it: empty under a `placeholder`, else the first option — a native select's own rule. */
24
24
  defaultValue?: string | undefined;
25
+ /**
26
+ * Controlled: the value shown and posted, kept until the parent changes it
27
+ * from `onValueChange` (a form reset leaves it too). `undefined` is
28
+ * uncontrolled, as on an `<input>`; `""` is the placeholder.
29
+ */
30
+ value?: string | undefined;
25
31
  /** An empty, unpickable choice shown until one is made. */
26
32
  placeholder?: string | undefined;
27
33
  required?: boolean | undefined;
@@ -26,11 +26,14 @@ export function FormSelectView({ scripted, ...props }) {
26
26
  const native = useRef(null);
27
27
  // The box, not the trigger: `SelectTrigger` keeps its own ref for placing the list.
28
28
  const box = useRef(null);
29
- const state = useFormSelectValue({ initial, native, box, scripted, onValueChange: props.onValueChange });
29
+ const state = useFormSelectValue({ initial, controlled: props.value, native, box, scripted, onValueChange: props.onValueChange });
30
30
  const hint = placeholder !== undefined ? { placeholder } : {};
31
31
  const aria = { "aria-describedby": props["aria-describedby"], "aria-invalid": props["aria-invalid"] || state.invalid || undefined };
32
32
  if (!scripted) {
33
- return (_jsx(NativeSelect, { ref: native, name: name, id: id, size: size, ...hint, required: required, disabled: disabled, defaultValue: initial, wrapperClassName: cn(BOX, className), className: classNames?.trigger, ...aria, children: options.map(o => (_jsx(NativeSelectOption, { value: o.value, children: o.label }, o.value))) }));
33
+ return (_jsx(NativeSelect, { ref: native, name: name, id: id, size: size, ...hint, required: required, disabled: disabled,
34
+ // Uncontrolled even under `value`: before the script nothing could
35
+ // follow a change, and a pick made then must survive into the swap.
36
+ defaultValue: state.value, wrapperClassName: cn(BOX, className), className: classNames?.trigger, ...aria, children: options.map(o => (_jsx(NativeSelectOption, { value: o.value, children: o.label }, o.value))) }));
34
37
  }
35
38
  return (_jsxs("span", { ref: box, "data-slot": "form-select", className: cn(BOX, className), children: [_jsxs(Select, { value: state.value, onValueChange: state.choose, open: state.open, onOpenChange: state.setOpen, children: [_jsx(SelectTrigger, { id: id, size: size, disabled: disabled, "aria-required": required || undefined, className: cn(TRIGGER, TRIGGER_TEXT[size], classNames?.trigger), ...aria, children: _jsx(SelectValue, { ...hint }) }), _jsx(SelectContent, { className: classNames?.content, children: options.map(o => (_jsx(SelectItem, { value: o.value, className: cn(size === "lg" && "py-2.5 text-base", classNames?.item), children: o.label }, o.value))) })] }), required ? (
36
39
  // Validated, so not `type="hidden"` (which the browser never checks);
@@ -0,0 +1,39 @@
1
+ /** One variant of an experiment, as the menu offers it. */
2
+ export interface AbSwitcherVariant {
3
+ /** The value the force parameter carries and the cookie holds: `b`. */
4
+ value: string;
5
+ label: string;
6
+ }
7
+ /** One experiment the menu switches. */
8
+ export interface AbSwitcherExperiment {
9
+ /** The experiment's key: the cookie is `ab_<key>`, the force parameter `<forceParam><key>`. */
10
+ key: string;
11
+ label?: string;
12
+ variants: readonly AbSwitcherVariant[];
13
+ }
14
+ /** The menu's words; English unless the brand says otherwise. */
15
+ export interface AbSwitcherText {
16
+ /** The chip's and the panel's accessible name. */
17
+ title: string;
18
+ reset: string;
19
+ leave: string;
20
+ minimize: string;
21
+ hide: string;
22
+ /** Shown for an experiment no cookie assigns yet. */
23
+ unassigned: string;
24
+ }
25
+ /**
26
+ * Plain data, so a server layout mounts it. Positioned by `className`: a brand
27
+ * lifts the chip over its own sticky bars.
28
+ */
29
+ export interface AbSwitcherProps {
30
+ experiments: readonly AbSwitcherExperiment[];
31
+ /** The cookie the brand's force parameter sets to mark a test visit: `ab__qa`. */
32
+ qaCookie: string;
33
+ /** The assignments, when the caller knows them; otherwise read from `ab_<key>` cookies. */
34
+ current?: Readonly<Record<string, string>>;
35
+ /** The prefix of the parameter that forces a variant; `ab_` by default. */
36
+ forceParam?: string;
37
+ className?: string;
38
+ text?: Partial<AbSwitcherText>;
39
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -3,9 +3,15 @@ import { type InvalidEvent, type RefObject } from "react";
3
3
  * The value `FormSelect` posts, across the swap from the native select and
4
4
  * through the form's own life: a reset, and a required check that points at
5
5
  * the kit's trigger instead of an input nobody sees.
6
+ *
7
+ * `controlled` (a `value` prop) follows React's input idiom: the prop is the
8
+ * value, a choice reaches the parent only through `onValueChange`, and a reset
9
+ * leaves it where the parent holds it. The own state still follows the prop,
10
+ * so a switch back to uncontrolled keeps the last value, as an `<input>` does.
6
11
  */
7
12
  export declare function useFormSelectValue(opts: {
8
13
  initial: string;
14
+ controlled: string | undefined;
9
15
  native: RefObject<HTMLSelectElement | null>;
10
16
  box: RefObject<HTMLElement | null>;
11
17
  scripted: boolean;
@@ -1,23 +1,45 @@
1
1
  "use client";
2
2
  import { useEffect, useLayoutEffect, useRef, useState } from "react";
3
+ const DEV = typeof process !== "undefined" && process.env.NODE_ENV !== "production";
3
4
  /**
4
5
  * The value `FormSelect` posts, across the swap from the native select and
5
6
  * through the form's own life: a reset, and a required check that points at
6
7
  * the kit's trigger instead of an input nobody sees.
8
+ *
9
+ * `controlled` (a `value` prop) follows React's input idiom: the prop is the
10
+ * value, a choice reaches the parent only through `onValueChange`, and a reset
11
+ * leaves it where the parent holds it. The own state still follows the prop,
12
+ * so a switch back to uncontrolled keeps the last value, as an `<input>` does.
7
13
  */
8
14
  export function useFormSelectValue(opts) {
9
- const { initial, native, box, scripted, onValueChange } = opts;
10
- const [value, setValue] = useState(initial);
15
+ const { initial, controlled, native, box, scripted, onValueChange } = opts;
16
+ const [own, setOwn] = useState(controlled ?? initial);
11
17
  const [invalid, setInvalid] = useState(false);
12
18
  const [open, setOpen] = useState(false);
13
- const initialRef = useRef(initial);
14
- initialRef.current = initial;
19
+ const isControlled = controlled !== undefined;
20
+ if (isControlled && own !== controlled)
21
+ setOwn(controlled);
22
+ const value = isControlled ? controlled : own;
23
+ useModeWarning(isControlled);
24
+ // The effects below run once or on a form event: they read the latest props here.
25
+ const latest = useRef({ initial, isControlled, value, onValueChange });
26
+ latest.current = { initial, isControlled, value, onValueChange };
15
27
  // A choice made in the native select before the script arrived survives the
16
28
  // swap: read in the hydration commit, before the scripted render. Once —
17
- // later renders never hold the native select.
29
+ // later renders never hold the native select. Under a `value` it is the
30
+ // parent's to take, so it is reported instead of kept — and only a real
31
+ // pick: a `value` no option carries leaves the browser on its own default,
32
+ // which is no choice of the visitor's.
18
33
  useLayoutEffect(() => {
19
- if (native.current)
20
- setValue(native.current.value);
34
+ const select = native.current;
35
+ if (!select)
36
+ return;
37
+ const picked = select.value;
38
+ const { isControlled: held, value: shown, onValueChange: report } = latest.current;
39
+ if (!held)
40
+ setOwn(picked);
41
+ else if (picked !== shown && picked !== untouched(select))
42
+ report?.(picked);
21
43
  }, [native]);
22
44
  // A form reset puts a native select back on its default; so does this.
23
45
  // Interactive validation — the browser's own, on a submit — is told apart
@@ -27,7 +49,10 @@ export function useFormSelectValue(opts) {
27
49
  const form = box.current?.closest("form");
28
50
  if (!scripted || !form)
29
51
  return;
30
- const reset = () => setValue(initialRef.current);
52
+ const reset = () => {
53
+ if (!latest.current.isControlled)
54
+ setOwn(latest.current.initial);
55
+ };
31
56
  // On the document, not the form: a submit button may sit outside it
32
57
  // (`form="…"`), and the click may land on an icon inside the button.
33
58
  const submit = (e) => {
@@ -48,7 +73,8 @@ export function useFormSelectValue(opts) {
48
73
  };
49
74
  }, [box, scripted]);
50
75
  const choose = (next) => {
51
- setValue(next);
76
+ if (!isControlled)
77
+ setOwn(next);
52
78
  setInvalid(false);
53
79
  onValueChange?.(next);
54
80
  };
@@ -70,3 +96,22 @@ export function useFormSelectValue(opts) {
70
96
  };
71
97
  return { value, choose, invalid, open, setOpen, onInvalid };
72
98
  }
99
+ /**
100
+ * What a single select shows before anyone touches it: the option marked
101
+ * `selected` in the markup, else the first enabled one (HTML's selectedness
102
+ * setting). A pick of that same option is indistinguishable, and harmless.
103
+ */
104
+ function untouched(select) {
105
+ const options = Array.from(select.options);
106
+ return (options.find(o => o.defaultSelected) ?? options.find(o => !o.disabled))?.value;
107
+ }
108
+ /** React's warning for an `<input>` that changes mode: once, and nothing else changes. */
109
+ function useModeWarning(isControlled) {
110
+ const first = useRef(isControlled);
111
+ const warned = useRef(false);
112
+ if (!DEV || warned.current || first.current === isControlled)
113
+ return;
114
+ warned.current = true;
115
+ console.error(`@evinvest/kitstart: a FormSelect is changing from ${first.current ? "controlled to uncontrolled" : "uncontrolled to controlled"}. ` +
116
+ "Keep one of `value` and `defaultValue` for its lifetime; `value={undefined}` makes it uncontrolled.");
117
+ }
@@ -1,8 +1,11 @@
1
1
  /**
2
2
  * `@evinvest/kitstart/react` — the structural widgets every landing shares,
3
3
  * where behaviour matters more than look. Server components by default; only
4
- * `MapFacade`, `AnalyticsBoundary`, `FormSelect`, `LeadCapture` and `LeadBooking` are client modules, each its own file
5
- * with its own `"use client"`, so a page pays for what it renders.
4
+ * `MapFacade`, `AnalyticsBoundary`, `FormSelect`, `LeadCapture`, `LeadBooking`
5
+ * and `AbSwitcher` are client modules, each its own file with its own
6
+ * `"use client"`, so a page pays for what it renders. `AbSwitcher` is a gate
7
+ * of a few hundred bytes; its panel (`AbSwitcherPanel`) is a dynamic import,
8
+ * fetched only by a QA visit.
6
9
  *
7
10
  * Styled with the kit's token roles only (`text-ink`, `border-border`,
8
11
  * `bg-card`…); a brand restyles through `className`, a widget's named parts
@@ -10,7 +13,9 @@
10
13
  * must scan the package: `@source "<path to>/node_modules/@evinvest/kitstart/dist";`,
11
14
  * relative to the stylesheet (`../../node_modules/…` from `src/app/`).
12
15
  */
13
- export { AnalyticsBoundary } from "./AnalyticsBoundary.js";
16
+ export { AbSwitcher } from "./AbSwitcher.js";
17
+ export type { AbSwitcherExperiment, AbSwitcherProps, AbSwitcherText, AbSwitcherVariant } from "./ab-switcher-types.js";
18
+ export { AnalyticsBoundary, type AnalyticsBoundaryProps } from "./AnalyticsBoundary.js";
14
19
  export { AreaChips } from "./AreaChips.js";
15
20
  export { CallBar, type CallBarPart, type CallBarProps } from "./CallBar.js";
16
21
  export { Coverage, type CoveragePart, type CoverageProps } from "./Coverage.js";
@@ -1,8 +1,11 @@
1
1
  /**
2
2
  * `@evinvest/kitstart/react` — the structural widgets every landing shares,
3
3
  * where behaviour matters more than look. Server components by default; only
4
- * `MapFacade`, `AnalyticsBoundary`, `FormSelect`, `LeadCapture` and `LeadBooking` are client modules, each its own file
5
- * with its own `"use client"`, so a page pays for what it renders.
4
+ * `MapFacade`, `AnalyticsBoundary`, `FormSelect`, `LeadCapture`, `LeadBooking`
5
+ * and `AbSwitcher` are client modules, each its own file with its own
6
+ * `"use client"`, so a page pays for what it renders. `AbSwitcher` is a gate
7
+ * of a few hundred bytes; its panel (`AbSwitcherPanel`) is a dynamic import,
8
+ * fetched only by a QA visit.
6
9
  *
7
10
  * Styled with the kit's token roles only (`text-ink`, `border-border`,
8
11
  * `bg-card`…); a brand restyles through `className`, a widget's named parts
@@ -10,6 +13,7 @@
10
13
  * must scan the package: `@source "<path to>/node_modules/@evinvest/kitstart/dist";`,
11
14
  * relative to the stylesheet (`../../node_modules/…` from `src/app/`).
12
15
  */
16
+ export { AbSwitcher } from "./AbSwitcher.js";
13
17
  export { AnalyticsBoundary } from "./AnalyticsBoundary.js";
14
18
  export { AreaChips } from "./AreaChips.js";
15
19
  export { CallBar } from "./CallBar.js";
@@ -3,16 +3,20 @@ import type { LeadWebhook } from "./lead-webhook.js";
3
3
  /**
4
4
  * One experiment as the code declares it — the shape of an
5
5
  * `@evinvest/experiments` spec, so the brand's `as const` config is passed
6
- * as is. `variants[0]` is the control.
6
+ * as is. `variants[0]` is the control. No weights: the code splits equally,
7
+ * and re-weighting is the panel's override.
7
8
  */
8
9
  export interface DeclaredExperiment {
9
10
  readonly variants: readonly string[];
10
- readonly weights: readonly number[];
11
11
  readonly enabled?: boolean;
12
12
  readonly holdout?: number;
13
13
  }
14
14
  export type ExperimentsDeclaration = Readonly<Record<string, DeclaredExperiment>>;
15
- /** `ExperimentDeclaration` of the panel's `experiments.declared@1`. */
15
+ /**
16
+ * `ExperimentDeclaration` of the panel's `experiments.declared@1`. `weights`
17
+ * stays in the contract; the code always sends one each (the equal split it
18
+ * runs without an override).
19
+ */
16
20
  export interface ExperimentDeclarationV1 {
17
21
  key: string;
18
22
  variants: string[];
@@ -17,12 +17,6 @@ export function declarationProblem(key, spec, summary) {
17
17
  return "a variant is declared twice";
18
18
  if (!spec.variants.every(v => EXPERIMENT_SLUG.test(v)))
19
19
  return "a variant is not a slug ([a-z0-9_-]{1,32})";
20
- if (spec.weights.length !== spec.variants.length)
21
- return "not one weight per variant";
22
- if (!spec.weights.every(w => Number.isFinite(w) && w >= 0))
23
- return "a weight is negative or not a number";
24
- if (spec.weights.reduce((sum, w) => sum + w, 0) <= 0)
25
- return "the weights sum to zero";
26
20
  if (spec.holdout !== undefined && !(Number.isFinite(spec.holdout) && spec.holdout >= 0 && spec.holdout < 1))
27
21
  return "the holdout is not in [0, 1)";
28
22
  if (summary !== undefined && summary.length > MAX_SUMMARY)
@@ -60,7 +54,7 @@ export function experimentsDeclaredBody(experiments, ctx) {
60
54
  declared.push({
61
55
  key,
62
56
  variants: [...spec.variants],
63
- weights: [...spec.weights],
57
+ weights: spec.variants.map(() => 1),
64
58
  enabled: spec.enabled ?? true,
65
59
  ...(spec.holdout !== undefined ? { holdout: spec.holdout } : {}),
66
60
  ...(summary !== undefined && summary !== "" ? { summary } : {}),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@evinvest/kitstart",
3
- "version": "0.14.0",
3
+ "version": "0.16.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,7 +1,8 @@
1
- import { AnalyticsBoundary } from "@evinvest/kitstart/react";
1
+ import { AbSwitcher, AnalyticsBoundary } from "@evinvest/kitstart/react";
2
2
  import type { ReactNode } from "react";
3
3
  import { loadPlace } from "@/views/place/server";
4
4
  import { serverEnv } from "@/shared/config/env";
5
+ import { EXPERIMENTS, QA_COOKIE } from "@/shared/config/experiments";
5
6
  import { site } from "@/shared/config/site";
6
7
 
7
8
  /**
@@ -22,8 +23,10 @@ export default async function PlaceLayout({ children, params }: { children: Reac
22
23
  const env = serverEnv();
23
24
  // The key is read from the container when the page renders, never inlined.
24
25
  return (
25
- <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}>
26
27
  {children}
28
+ {/* Above the phone's call bar; nothing at all for a visit without the QA cookie. */}
29
+ <AbSwitcher experiments={EXPERIMENTS} qaCookie={QA_COOKIE} className="bottom-20 md:bottom-4" />
27
30
  </AnalyticsBoundary>
28
31
  );
29
32
  }
@@ -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.14.0";
6
+ ev.url = "github:EV-invest/lib?ref=@evinvest/kitstart-v0.16.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.14.0",
23
+ "@evinvest/kitstart": "^0.16.0",
24
24
  "@evinvest/marketing": "^0.3.0",
25
25
  "@evinvest/uikit": "^0.26.0",
26
26
  "next": "16.3.6",
@@ -0,0 +1,21 @@
1
+ import type { AbSwitcherExperiment } from "@evinvest/kitstart/react";
2
+
3
+ /**
4
+ * The experiments the QA menu switches — a demo: the template's proxy runs
5
+ * none, so a forced `?ab_lead_form=b` changes nothing here. A brand lists the
6
+ * experiments its proxy assigns, with the QA cookie its force parameter sets.
7
+ */
8
+ export const EXPERIMENTS: readonly AbSwitcherExperiment[] = [
9
+ {
10
+ key: "lead_form",
11
+ label: "Lead form",
12
+ variants: [
13
+ { value: "a", label: "Compact" },
14
+ { value: "b", label: "Steps" },
15
+ { value: "c", label: "Price first" },
16
+ ],
17
+ },
18
+ ];
19
+
20
+ /** Set by `?ab_<key>=<value>`; a visit carrying it is a test, and sees the menu. */
21
+ export const QA_COOKIE = "ab__qa";
@@ -0,0 +1,114 @@
1
+ import { expect, test, type BrowserContext, type Page, type Response } from "@playwright/test";
2
+ import { QA_COOKIE } from "../../src/shared/config/experiments";
3
+
4
+ // The QA menu (`AbSwitcher`): a visit with the QA cookie gets the chip and
5
+ // its panel, loaded on demand; a visitor without it gets neither — the
6
+ // panel's chunk is never even requested. A tap on a variant is the brand's
7
+ // own force parameter; the reset drops it and keeps the visit a test.
8
+
9
+ const hydrated = (page: Page) => expect(page.locator("#quote select")).toHaveCount(0);
10
+ const chip = (page: Page) => page.getByRole("button", { name: "A/B test switcher" });
11
+ const panel = (page: Page) => page.getByRole("dialog", { name: "A/B test switcher" });
12
+ const scripts = (page: Page) => {
13
+ const seen: Response[] = [];
14
+ page.on("response", r => void (new URL(r.url()).pathname.endsWith(".js") && seen.push(r)));
15
+ return seen;
16
+ };
17
+ const asTester = (context: BrowserContext, baseURL: string | undefined) => context.addCookies([{ name: QA_COOKIE, value: "1", url: baseURL ?? "" }]);
18
+ const cookieNames = async (context: BrowserContext) => (await context.cookies()).map(c => c.name);
19
+
20
+ /** A test visit assigned `b`, as the brand's proxy would have left it, on a page that forces it. */
21
+ async function assignedB(page: Page, context: BrowserContext, baseURL: string | undefined) {
22
+ await asTester(context, baseURL);
23
+ await context.addCookies([{ name: "ab_lead_form", value: "b", url: baseURL ?? "" }]);
24
+ await page.goto("/fr?ab_lead_form=b&utm_x=1");
25
+ await chip(page).click();
26
+ await expect(panel(page)).toBeVisible();
27
+ }
28
+
29
+ test("a new visitor gets no menu and never fetches its panel; a test visit gets both", async ({ page, context, baseURL }) => {
30
+ const visitor = scripts(page);
31
+ await page.goto("/fr");
32
+ await hydrated(page);
33
+ // Settled: the gate's effect ran on hydration, and anything it fetched is in.
34
+ await page.waitForLoadState("networkidle");
35
+ await expect(chip(page)).toHaveCount(0);
36
+ const fetched = visitor.map(r => r.url());
37
+
38
+ await asTester(context, baseURL);
39
+ const tester = scripts(page);
40
+ await page.goto("/fr");
41
+ await chip(page).click();
42
+ await expect(panel(page)).toBeVisible();
43
+ const marked = await Promise.all(tester.map(async r => ((await r.text()).includes("data-ab-switcher") ? r.url() : null)));
44
+ const chunks = marked.filter(u => u !== null);
45
+ expect(chunks.length).toBeGreaterThan(0);
46
+ for (const url of chunks) expect(fetched).not.toContain(url);
47
+ });
48
+
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
+ await asTester(context, baseURL);
51
+ await page.goto("/fr?utm_source=qa");
52
+ await chip(page).click();
53
+ await panel(page).getByRole("button", { name: "Steps" }).click();
54
+ await expect(page).toHaveURL(/\/fr\?utm_source=qa&ab_lead_form=b$/);
55
+
56
+ await chip(page).click();
57
+ await panel(page).getByRole("button", { name: "Reset", exact: true }).click();
58
+ await expect(page).toHaveURL(/\/fr\?utm_source=qa$/);
59
+ expect((await context.cookies()).map(c => c.name)).toContain(QA_COOKIE);
60
+ await expect(chip(page)).toBeVisible();
61
+ });
62
+
63
+ test("Reset drops the assignment and its force, keeps the other parameters and the QA cookie, and the chip", async ({ page, context, baseURL }) => {
64
+ await assignedB(page, context, baseURL);
65
+ await expect(panel(page).getByRole("button", { name: "Steps" })).toHaveAttribute("aria-pressed", "true");
66
+ await panel(page).getByRole("button", { name: "Reset", exact: true }).click();
67
+ await expect(page).toHaveURL(/\/fr\?utm_x=1$/);
68
+ const names = await cookieNames(context);
69
+ expect(names).not.toContain("ab_lead_form");
70
+ expect(names).toContain(QA_COOKIE);
71
+ await expect(chip(page)).toBeVisible();
72
+ });
73
+
74
+ test("Leave test drops the QA cookie too: after the load there is no chip", async ({ page, context, baseURL }) => {
75
+ await assignedB(page, context, baseURL);
76
+ await panel(page).getByRole("button", { name: "Leave test" }).click();
77
+ await expect(page).toHaveURL(/\/fr\?utm_x=1$/);
78
+ await hydrated(page);
79
+ // Settled: the gate decided on hydration, and a panel it fetched would be in.
80
+ await page.waitForLoadState("networkidle");
81
+ expect(await cookieNames(context)).not.toContain(QA_COOKIE);
82
+ expect(await cookieNames(context)).not.toContain("ab_lead_form");
83
+ await expect(chip(page)).toHaveCount(0);
84
+ });
85
+
86
+ test("Leave test from a page reached by an anchor still reloads it, and the chip goes", async ({ page, context, baseURL }) => {
87
+ await asTester(context, baseURL);
88
+ await page.goto("/fr#quote");
89
+ await chip(page).click();
90
+ // A same-URL replace with the anchor kept is a scroll, not a load: this waits forever.
91
+ const reloaded = page.waitForEvent("load");
92
+ await panel(page).getByRole("button", { name: "Leave test" }).click();
93
+ await reloaded;
94
+ await expect(page).toHaveURL(/\/fr$/);
95
+ await hydrated(page);
96
+ await page.waitForLoadState("networkidle");
97
+ expect(await cookieNames(context)).not.toContain(QA_COOKIE);
98
+ await expect(chip(page)).toHaveCount(0);
99
+ });
100
+
101
+ test("Escape closes the panel and gives the focus back to the chip", async ({ page, context, baseURL }) => {
102
+ await assignedB(page, context, baseURL);
103
+ await page.keyboard.press("Escape");
104
+ await expect(panel(page)).toHaveCount(0);
105
+ await expect(chip(page)).toBeFocused();
106
+ });
107
+
108
+ test("Hide removes the chip until the next load", async ({ page, context, baseURL }) => {
109
+ await assignedB(page, context, baseURL);
110
+ await panel(page).getByRole("button", { name: "Hide" }).click();
111
+ await expect(chip(page)).toHaveCount(0);
112
+ await page.reload();
113
+ await expect(chip(page)).toBeVisible();
114
+ });
@@ -3,3 +3,9 @@
3
3
  #callbar {
4
4
  display: none !important;
5
5
  }
6
+
7
+ /* The QA menu (`AbSwitcher`) rides the viewport too, and only a test visit
8
+ * sees it: never part of a section. */
9
+ [data-ab-switcher] {
10
+ display: none !important;
11
+ }