@evinvest/kitstart 0.16.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 +33 -10
- package/dist/core/ab-gate.js +3 -1
- package/dist/core/ab-switcher.d.ts +8 -0
- package/dist/core/ab-switcher.js +11 -0
- package/dist/core/analytics.d.ts +17 -1
- package/dist/core/analytics.js +24 -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 +3 -2
- package/dist/react/AnalyticsBoundary.js +5 -18
- package/dist/react/ab-switcher-types.d.ts +1 -1
- package/package.json +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 +2 -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 {
|
|
@@ -349,11 +349,11 @@ 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
|
|
353
|
-
`location_page_view`, when the boundary has `qaCookie` — see `AbSwitcher`);
|
|
354
|
-
and on the server `lead_form_submit
|
|
352
|
+
`callback`, with the experiment; and on the server `lead_form_submit
|
|
355
353
|
{form_id, channel}` and `lead_form_reject {form_id, channel, field,
|
|
356
|
-
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`).
|
|
357
357
|
- **Weight.** 8.9 KB gz of first-load JS on the template's place page
|
|
358
358
|
(157,287 → 166,361 B against its 158,000 B target: +5.3 %, a warning
|
|
359
359
|
within the 20 % tolerance); the refusals and the shared phone rule are
|
|
@@ -921,6 +921,15 @@ adds no server path: a tap on a variant goes to the same URL with
|
|
|
921
921
|
reads `ab_<key>` from `document.cookie` (the normal case: the page stays
|
|
922
922
|
static, the server reads no cookie). `forceParam` — `ab_` by default.
|
|
923
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`).
|
|
924
933
|
- **Turning it on, on a phone, in production:** open any page with
|
|
925
934
|
`?ab_<key>=<value>`. The proxy sets the QA cookie and the chip appears;
|
|
926
935
|
every visit after is tagged as a test in analytics. Outside production
|
|
@@ -938,16 +947,30 @@ adds no server path: a tap on a variant goes to the same URL with
|
|
|
938
947
|
too. **Minimize** and **Hide** last until the next load.
|
|
939
948
|
- Screenshots: the chip and its panel carry `data-ab-switcher`; hide it in the
|
|
940
949
|
section stylesheet, `[data-ab-switcher] { display: none !important; }`.
|
|
941
|
-
- **Pass the same `qaCookie` to `AnalyticsBoundary
|
|
942
|
-
is a full reload, so a test visit would add
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
the cookie
|
|
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.
|
|
946
964
|
|
|
947
965
|
```tsx
|
|
948
966
|
<AnalyticsBoundary target={target} placeSlug={slug} qaCookie="ab__qa">
|
|
949
967
|
```
|
|
950
968
|
|
|
969
|
+
```ts
|
|
970
|
+
// app/quote/route.ts
|
|
971
|
+
export const POST = quoteRoute(site, { env: serverEnv, notifier, unavailable, qaCookie: "ab__qa" });
|
|
972
|
+
```
|
|
973
|
+
|
|
951
974
|
The decisions are plain functions in the core (`abSwitcherVisible`,
|
|
952
975
|
`abVariantUrl`, `abReset`, `abAssignments`), for a brand's own tests.
|
|
953
976
|
|
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);
|
package/dist/core/analytics.d.ts
CHANGED
|
@@ -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
|
@@ -80,6 +80,19 @@ export function experimentProps(experiment, variant) {
|
|
|
80
80
|
export function countsAsPageView(pathname) {
|
|
81
81
|
return !pathname.replace(/\/+$/, "").endsWith(THANKS);
|
|
82
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
|
+
}
|
|
83
96
|
/**
|
|
84
97
|
* Cookieless by construction: a beacon sink keeps its `distinct_id` in memory,
|
|
85
98
|
* writes no cookie and no storage, and needs no consent banner. `sendBeacon`
|
|
@@ -87,13 +100,22 @@ export function countsAsPageView(pathname) {
|
|
|
87
100
|
* followed by the page handing the visitor to another app. `distinctId`:
|
|
88
101
|
* the visitor's id, held by the caller so a lead can name it; a random one
|
|
89
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.
|
|
90
111
|
*/
|
|
91
|
-
export function analyticsSink(target, locationId, distinctId) {
|
|
92
|
-
|
|
112
|
+
export function analyticsSink(target, locationId, distinctId, qaCookie, cookies) {
|
|
113
|
+
const sink = createBeaconSink({
|
|
93
114
|
key: target.key ?? undefined,
|
|
94
115
|
...(distinctId !== undefined ? { distinctId } : {}),
|
|
95
116
|
host: target.host,
|
|
96
117
|
allowedProps: ALLOWED_PROPS,
|
|
97
118
|
globalProps: locationId ? { brand_id: target.brandId, location_id: locationId } : { brand_id: target.brandId },
|
|
98
119
|
});
|
|
120
|
+
return { capture: (event, props, options) => sink.capture(event, qaCookie && qaVisit(cookies ?? document.cookie, qaCookie) ? { ...props, forced: true } : props, options) };
|
|
99
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
|
}
|
|
@@ -12,9 +12,10 @@ export interface AnalyticsBoundaryProps {
|
|
|
12
12
|
placeSlug: string | null;
|
|
13
13
|
/**
|
|
14
14
|
* The cookie the brand's force parameter sets (`AbSwitcher`'s `qaCookie`).
|
|
15
|
-
* A visit carrying it is a test:
|
|
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
|
|
16
17
|
* `forced: true`, so the QA menu's reloads do not count as a place's
|
|
17
|
-
* traffic. Read in the browser at each event
|
|
18
|
+
* traffic. Read in the browser at each event (`analyticsSink`).
|
|
18
19
|
*/
|
|
19
20
|
qaCookie?: string;
|
|
20
21
|
children: ReactNode;
|
|
@@ -12,15 +12,6 @@ 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
|
-
});
|
|
24
15
|
function source() {
|
|
25
16
|
const utm = new URLSearchParams(window.location.search).get("utm_source");
|
|
26
17
|
if (utm)
|
|
@@ -41,31 +32,27 @@ export function AnalyticsBoundary({ target, placeSlug, qaCookie, children }) {
|
|
|
41
32
|
// One visitor for the boundary's life, in memory only: a sink rebuilt for
|
|
42
33
|
// another place keeps it, and a lead posted from the page can name it.
|
|
43
34
|
const [distinctId] = useState(newSubmissionId);
|
|
44
|
-
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]);
|
|
45
36
|
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 } : {});
|
|
49
37
|
useEffect(() => {
|
|
50
38
|
if (!countsAsPageView(pathname))
|
|
51
39
|
return;
|
|
52
40
|
sink.capture(EVENTS.pageView, {
|
|
53
41
|
source: source(),
|
|
54
42
|
device: window.matchMedia("(max-width: 767px)").matches ? "mobile" : "desktop",
|
|
55
|
-
...forced(),
|
|
56
43
|
});
|
|
57
|
-
}, [sink, pathname
|
|
44
|
+
}, [sink, pathname]);
|
|
58
45
|
useEffect(() => {
|
|
59
46
|
const onClick = (event) => {
|
|
60
47
|
const el = event.target instanceof Element ? event.target.closest(`[${INTENT_ATTR}]`) : null;
|
|
61
48
|
const channel = el?.getAttribute(INTENT_ATTR);
|
|
62
49
|
if (channel && INTENTS.some(i => i === channel))
|
|
63
|
-
sink.capture(EVENTS.intent, { channel, ...experimentOf(el)
|
|
50
|
+
sink.capture(EVENTS.intent, { channel, ...experimentOf(el) }, { transport: "beacon" });
|
|
64
51
|
};
|
|
65
52
|
document.addEventListener("click", onClick, { capture: true });
|
|
66
53
|
return () => document.removeEventListener("click", onClick, { capture: true });
|
|
67
|
-
}, [sink
|
|
54
|
+
}, [sink]);
|
|
68
55
|
// A link inside an experiment's island (`LeadCapture`) carries the
|
|
69
56
|
// assignment on itself, which is what the tracker reads.
|
|
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"])
|
|
57
|
+
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 }) }) }));
|
|
71
58
|
}
|
|
@@ -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/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": {
|
|
@@ -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();
|