@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 +13 -2
- package/dist/core/ab-switcher.js +4 -0
- package/dist/core/analytics.d.ts +1 -1
- package/dist/core/analytics.js +3 -0
- package/dist/react/AnalyticsBoundary.d.ts +10 -2
- package/dist/react/AnalyticsBoundary.js +18 -12
- package/dist/react/index.d.ts +1 -1
- package/package.json +1 -1
- package/template/app/[locale]/[location]/layout.tsx +1 -1
- package/template/flake.nix +1 -1
- package/template/package.json +1 -1
- package/template/tests/e2e/ab-switcher.spec.ts +15 -0
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.
|
|
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
|
|
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.
|
package/dist/core/ab-switcher.js
CHANGED
|
@@ -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
|
}
|
package/dist/core/analytics.d.ts
CHANGED
|
@@ -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
|
package/dist/core/analytics.js
CHANGED
|
@@ -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
|
|
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
|
-
}
|
|
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
|
}
|
package/dist/react/index.d.ts
CHANGED
|
@@ -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.
|
|
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" />
|
package/template/flake.nix
CHANGED
|
@@ -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.
|
|
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
|
|
package/template/package.json
CHANGED
|
@@ -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");
|