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