@evinvest/kitstart 0.15.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.15.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 {
@@ -349,7 +349,9 @@ it is the plain POST to `/quote` it always was.
349
349
  (`contact` / `need` in `qualify-first`; in `steps` each screen moved to —
350
350
  `intro`, `need`, `estimate_<input>`, `locality`, `phone`), each with `form_id`, `layout`, `experiment`,
351
351
  `variant`; `contact_intent_click {channel}` for `phone`, `whatsapp`, `sms`,
352
- `callback`, with the experiment; and 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
353
355
  {form_id, channel}` and `lead_form_reject {form_id, channel, field,
354
356
  reason}` with the posted experiment.
355
357
  - **Weight.** 8.9 KB gz of first-load JS on the template's place page
@@ -936,6 +938,15 @@ adds no server path: a tap on a variant goes to the same URL with
936
938
  too. **Minimize** and **Hide** last until the next load.
937
939
  - Screenshots: the chip and its panel carry `data-ab-switcher`; hide it in the
938
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
+ ```
939
950
 
940
951
  The decisions are plain functions in the core (`abSwitcherVisible`,
941
952
  `abVariantUrl`, `abReset`, `abAssignments`), for a brand's own tests.
@@ -57,5 +57,9 @@ export function abReset(href, keys, qaCookie, mode, forceParam = AB_FORCE_PARAM)
57
57
  const url = new URL(href);
58
58
  for (const key of keys)
59
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 = "";
60
64
  return { cookies: names.map(n => `${n}=; path=/; max-age=0`), url: url.toString() };
61
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
@@ -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
  }
@@ -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.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": {
@@ -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" />
@@ -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.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.15.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",
@@ -83,6 +83,21 @@ test("Leave test drops the QA cookie too: after the load there is no chip", asyn
83
83
  await expect(chip(page)).toHaveCount(0);
84
84
  });
85
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
+
86
101
  test("Escape closes the panel and gives the focus back to the chip", async ({ page, context, baseURL }) => {
87
102
  await assignedB(page, context, baseURL);
88
103
  await page.keyboard.press("Escape");