@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 +36 -2
- package/dist/core/ab-gate.js +3 -1
- package/dist/core/ab-switcher.d.ts +8 -0
- package/dist/core/ab-switcher.js +15 -0
- package/dist/core/analytics.d.ts +18 -2
- package/dist/core/analytics.js +27 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -2
- package/dist/next/quote-route.d.ts +7 -0
- package/dist/next/quote-route.js +5 -2
- package/dist/react/AbSwitcherPanel.d.ts +3 -1
- package/dist/react/AbSwitcherPanel.js +10 -6
- package/dist/react/AnalyticsBoundary.d.ts +11 -2
- package/dist/react/AnalyticsBoundary.js +2 -9
- package/dist/react/ab-switcher-types.d.ts +1 -1
- package/dist/react/index.d.ts +1 -1
- package/package.json +1 -1
- package/template/app/[locale]/[location]/layout.tsx +1 -1
- package/template/app/quote/route.ts +3 -0
- package/template/flake.nix +1 -1
- package/template/package.json +1 -1
- package/template/tests/e2e/ab-switcher.spec.ts +17 -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.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.
|
package/dist/core/ab-gate.js
CHANGED
|
@@ -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 {
|
package/dist/core/ab-switcher.js
CHANGED
|
@@ -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
|
}
|
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
|
|
@@ -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;
|
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
|
|
@@ -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
|
-
|
|
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;
|
package/dist/next/quote-route.js
CHANGED
|
@@ -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) =>
|
|
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 =
|
|
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
|
|
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 ===
|
|
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
|
|
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
|
-
}
|
|
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
|
|
22
|
+
/** Shown for an experiment no cookie assigns — one that is off; `not running` by default. */
|
|
23
23
|
unassigned: string;
|
|
24
24
|
}
|
|
25
25
|
/**
|
package/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.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 };
|
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.17.0";
|
|
7
7
|
ev.inputs.v_flakes.follows = "v_flakes";
|
|
8
8
|
};
|
|
9
9
|
|
package/template/package.json
CHANGED
|
@@ -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");
|