@evinvest/kitstart 0.13.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.
Files changed (71) hide show
  1. package/README.md +178 -15
  2. package/dist/core/ab-gate.d.ts +11 -0
  3. package/dist/core/ab-gate.js +19 -0
  4. package/dist/core/ab-switcher.d.ts +33 -0
  5. package/dist/core/ab-switcher.js +61 -0
  6. package/dist/core/accept.js +6 -2
  7. package/dist/core/analytics.d.ts +7 -2
  8. package/dist/core/lead-capture-text.d.ts +21 -1
  9. package/dist/core/lead-capture-text.js +16 -0
  10. package/dist/core/pricing/flow.d.ts +9 -0
  11. package/dist/core/pricing/flow.js +14 -0
  12. package/dist/core/pricing/index.d.ts +1 -1
  13. package/dist/core/pricing/index.js +1 -1
  14. package/dist/index.d.ts +3 -1
  15. package/dist/index.js +3 -1
  16. package/dist/react/AbSwitcher.d.ts +9 -0
  17. package/dist/react/AbSwitcher.js +22 -0
  18. package/dist/react/AbSwitcherPanel.d.ts +8 -0
  19. package/dist/react/AbSwitcherPanel.js +77 -0
  20. package/dist/react/FormSelect.d.ts +6 -0
  21. package/dist/react/FormSelect.js +5 -2
  22. package/dist/react/LeadCapture.d.ts +42 -8
  23. package/dist/react/LeadCapture.js +171 -26
  24. package/dist/react/LeadCaptureChannels.d.ts +8 -2
  25. package/dist/react/LeadCaptureChannels.js +8 -2
  26. package/dist/react/LeadCaptureEstimate.d.ts +59 -26
  27. package/dist/react/LeadCaptureEstimate.js +81 -41
  28. package/dist/react/LeadCaptureFields.d.ts +2 -0
  29. package/dist/react/LeadCaptureFields.js +1 -1
  30. package/dist/react/LeadCaptureNeed.d.ts +27 -6
  31. package/dist/react/LeadCaptureNeed.js +10 -41
  32. package/dist/react/LeadCapturePrice.d.ts +36 -0
  33. package/dist/react/LeadCapturePrice.js +47 -0
  34. package/dist/react/LeadCaptureSteps.d.ts +47 -0
  35. package/dist/react/LeadCaptureSteps.js +39 -0
  36. package/dist/react/LeadCaptureTiles.d.ts +53 -0
  37. package/dist/react/LeadCaptureTiles.js +50 -0
  38. package/dist/react/QuoteFormShell.d.ts +5 -1
  39. package/dist/react/QuoteFormShell.js +2 -2
  40. package/dist/react/ab-switcher-types.d.ts +39 -0
  41. package/dist/react/ab-switcher-types.js +1 -0
  42. package/dist/react/focus-next.d.ts +11 -0
  43. package/dist/react/focus-next.js +58 -0
  44. package/dist/react/form-select-value.d.ts +6 -0
  45. package/dist/react/form-select-value.js +54 -9
  46. package/dist/react/index.d.ts +11 -3
  47. package/dist/react/index.js +6 -2
  48. package/dist/react/lead-steps.d.ts +53 -0
  49. package/dist/react/lead-steps.js +58 -0
  50. package/dist/react/use-lead-context.d.ts +6 -0
  51. package/dist/react/use-lead-context.js +16 -0
  52. package/dist/react/use-lead-focus.d.ts +19 -0
  53. package/dist/react/use-lead-focus.js +70 -0
  54. package/dist/server/experiments-declared.d.ts +7 -3
  55. package/dist/server/experiments-declared.js +1 -7
  56. package/package.json +1 -1
  57. package/template/app/[locale]/[location]/layout.tsx +4 -1
  58. package/template/app/[locale]/[location]/prices/page.tsx +6 -1
  59. package/template/flake.nix +1 -1
  60. package/template/package.json +2 -2
  61. package/template/src/entities/content/model/en.ts +7 -0
  62. package/template/src/entities/content/model/fr.ts +7 -0
  63. package/template/src/entities/content/model/types.ts +8 -0
  64. package/template/src/shared/config/experiments.ts +21 -0
  65. package/template/src/shared/config/lead.ts +5 -1
  66. package/template/src/views/prices/index.ts +1 -0
  67. package/template/src/views/prices/ui/EstimateCard.tsx +66 -0
  68. package/template/tests/bundle_budget.txt +2 -1
  69. package/template/tests/e2e/ab-switcher.spec.ts +99 -0
  70. package/template/tests/e2e/screenshot-section.css +6 -0
  71. package/template/tests/e2e/steps.spec.ts +138 -0
@@ -0,0 +1,70 @@
1
+ "use client";
2
+ import { useEffect, useLayoutEffect, useRef } from "react";
3
+ import { focusNext, focusStep, nextEmpty, OFF_SELECTOR } from "./focus-next.js";
4
+ /** A screen on or off as `LeadSteps` draws it: the mark and the inline style. */
5
+ function setOff(step, off) {
6
+ step.toggleAttribute("data-lead-off", off);
7
+ step.style.display = off ? "none" : "";
8
+ }
9
+ /**
10
+ * Where the focus goes once the visitor has answered: to the next screen in
11
+ * `steps`, else — with `focusNext` — to the next empty field.
12
+ *
13
+ * `moveOn(change, from, stable)` applies the answer. When it changes no
14
+ * screen (`stable`) and what comes next is a typed field, that field is
15
+ * revealed and focused inside the tap: iOS opens the keyboard only for a
16
+ * focus a gesture made. Otherwise the focus moves once React has drawn the
17
+ * change. `focusAfterSelect()`: the same after the kit's select, whose list
18
+ * hands the focus back to its trigger as it closes — so it waits for that.
19
+ */
20
+ export function useLeadFocus(form, o) {
21
+ const { steps, current } = o;
22
+ const pending = useRef(null);
23
+ const revealed = useRef(null);
24
+ const afterSelect = useRef(false);
25
+ useLayoutEffect(() => {
26
+ const el = steps ? form() : null;
27
+ // A screen revealed ahead of React is React's again: only the current one shows.
28
+ for (const step of el?.querySelectorAll("[data-lead-step]") ?? [])
29
+ setOff(step, step.dataset["leadStep"] !== current);
30
+ // Revealed ahead, but not the screen React put on (a postcode typed and never confirmed): that screen's field instead.
31
+ const lost = revealed.current?.closest(OFF_SELECTOR) != null;
32
+ revealed.current = null;
33
+ const move = pending.current ?? (lost ? { from: null } : null);
34
+ if (!move)
35
+ return;
36
+ pending.current = null;
37
+ if (steps)
38
+ focusStep(el?.querySelector("[data-lead-step]:not([data-lead-off])"));
39
+ else
40
+ focusNext(form(), move.from);
41
+ });
42
+ // Passive, so after the select's own effect has given its trigger the focus back.
43
+ useEffect(() => {
44
+ if (!afterSelect.current)
45
+ return;
46
+ afterSelect.current = false;
47
+ focusNext(form(), null);
48
+ });
49
+ return {
50
+ moveOn(change, from, stable) {
51
+ if (!steps && !o.focusNext)
52
+ return change();
53
+ change();
54
+ const el = form();
55
+ const next = stable && el ? nextEmpty(el, from, steps) : null;
56
+ if (next && next.type !== "radio") {
57
+ const step = next.closest("[data-lead-step]");
58
+ if (step)
59
+ setOff(step, false);
60
+ next.focus();
61
+ revealed.current = next;
62
+ }
63
+ else
64
+ pending.current = { from };
65
+ },
66
+ focusAfterSelect() {
67
+ afterSelect.current = o.focusNext;
68
+ },
69
+ };
70
+ }
@@ -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
- /** `ExperimentDeclaration` of the panel's `experiments.declared@1`. */
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: [...spec.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.13.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
  }
@@ -3,6 +3,8 @@ import { placeMetadata } from "@evinvest/kitstart/next";
3
3
  import { Faq, JsonLd, Section } from "@evinvest/kitstart/react";
4
4
  import type { Metadata } from "next";
5
5
  import { loadPlace } from "@/views/place/server";
6
+ import { EstimateCard } from "@/views/prices";
7
+ import { pricing } from "@/shared/config/env";
6
8
  import { site } from "@/shared/config/site";
7
9
 
8
10
  type Props = { params: Promise<{ locale: string; location: string }> };
@@ -14,10 +16,13 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
14
16
  }
15
17
 
16
18
  export default async function PricesPage({ params }: Props) {
17
- const { copy } = await loadPlace(params);
19
+ const [{ view, copy, renderedAt }, model] = await Promise.all([loadPlace(params), pricing.model()]);
18
20
  return (
19
21
  <main>
20
22
  <JsonLd data={faqPageNode(copy.t.faqs)} />
23
+ <Section surface="card">
24
+ <EstimateCard view={view} copy={copy} renderedAt={renderedAt} model={model} />
25
+ </Section>
21
26
  <Section>
22
27
  <Faq items={copy.t.faqs} />
23
28
  </Section>
@@ -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.13.0";
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
 
@@ -18,9 +18,9 @@
18
18
  "lint": "eslint . && steiger ./src"
19
19
  },
20
20
  "dependencies": {
21
- "@evinvest/analytics": "^0.3.0",
21
+ "@evinvest/analytics": "^0.4.0",
22
22
  "@evinvest/i18n": "^0.9.0",
23
- "@evinvest/kitstart": "^0.13.0",
23
+ "@evinvest/kitstart": "^0.15.0",
24
24
  "@evinvest/marketing": "^0.3.0",
25
25
  "@evinvest/uikit": "^0.26.0",
26
26
  "next": "16.3.6",
@@ -43,5 +43,12 @@ export const EN = {
43
43
  contactLabel: "Contact us",
44
44
  subjects: { standard: "Regular cleaning", deep: "Deep cleaning", other: "Other", recurring: "Recurring cleaning" },
45
45
  leadCapture: { ...LEAD_CAPTURE_TEXT.en, needLabel: "Service" },
46
+ estimateForm: {
47
+ title: "Your price in four questions",
48
+ lede: "One question at a time, the price at the end.",
49
+ when: { label: "When do you need it?", today: "Urgent — today", week: "This week", compare: "Just comparing" },
50
+ afterPhone: "Your number stays with us — a call back within 15 min.",
51
+ badge: "Best value",
52
+ },
46
53
  faqs: [{ q: "Can the price change?", a: "No: the price quoted is the price you pay." }],
47
54
  } satisfies Text;
@@ -43,5 +43,12 @@ export const FR = {
43
43
  contactLabel: "Nous contacter",
44
44
  subjects: { standard: "Ménage courant", deep: "Grand ménage", other: "Autre", recurring: "Ménage régulier" },
45
45
  leadCapture: { ...LEAD_CAPTURE_TEXT.fr, needLabel: "Prestation" },
46
+ estimateForm: {
47
+ title: "Votre prix en quatre questions",
48
+ lede: "Une question à la fois, le prix à la fin.",
49
+ when: { label: "C’est pour quand ?", today: "Urgent — aujourd’hui", week: "Cette semaine", compare: "Je compare" },
50
+ afterPhone: "Votre numéro reste entre nous — rappel sous 15 min.",
51
+ badge: "Le plus avantageux",
52
+ },
46
53
  faqs: [{ q: "Le prix peut-il changer ?", a: "Non : le prix annoncé est celui que vous payez." }],
47
54
  } satisfies Text;
@@ -32,6 +32,14 @@ export interface Text extends CoreText<PageKey, Facts> {
32
32
  subjects: Record<Subject, string>;
33
33
  /** The lead form's words: the kit's, with the brand's overrides spread over them. */
34
34
  leadCapture: LeadCaptureText;
35
+ /** The prices page's estimate, one question a screen: its heading, the intro question, the line under the phone. */
36
+ estimateForm: {
37
+ title: string;
38
+ lede: string;
39
+ when: { label: string; today: string; week: string; compare: string };
40
+ afterPhone: string;
41
+ badge: string;
42
+ };
35
43
  faqs: readonly { q: string; a: string }[];
36
44
  }
37
45
 
@@ -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";
@@ -22,7 +22,11 @@ export const PHOTO_NEEDS: readonly Subject[] = ["deep"];
22
22
  export const LEAD: LeadSchema<Subject> = {
23
23
  subjects: SUBJECTS,
24
24
  wire: { subject: "subject", locality: "locality", mobile: "mobile" },
25
- extras: [{ name: "surface_m2", max: 6 }],
25
+ // `urgency`: the prices page's intro question (`today` | `week` | `compare`), kept with the lead.
26
+ extras: [
27
+ { name: "surface_m2", max: 6 },
28
+ { name: "urgency", max: 16 },
29
+ ],
26
30
  // One shape for the mail, the webhook and a dialler: `+33612345678`.
27
31
  mobileFormat: "e164",
28
32
  validate: validateLead,
@@ -0,0 +1 @@
1
+ export { EstimateCard } from "./ui/EstimateCard";
@@ -0,0 +1,66 @@
1
+ import { contactOf, type PlaceView, type PricingModel } from "@evinvest/kitstart";
2
+ import { LeadCapture } from "@evinvest/kitstart/react";
3
+ import type { ReactNode } from "react";
4
+ import type { Copy } from "@/entities/content";
5
+ import type { Locale } from "@/shared/config/i18n";
6
+ import { LEAD, PHOTO_NEEDS, type Subject } from "@/shared/config/lead";
7
+ import { site } from "@/shared/config/site";
8
+
9
+ /** A placeholder mark per need: a brand draws its own. Hidden from assistive technology by the card. */
10
+ const icon = (d: string): ReactNode => (
11
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
12
+ <path d={d} />
13
+ </svg>
14
+ );
15
+ const ICONS: Record<Subject, ReactNode> = {
16
+ standard: icon("M4 20h16M6 20V9l6-5 6 5v11"),
17
+ deep: icon("M12 3v18M3 12h18"),
18
+ other: icon("M12 8v4m0 4h.01M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18"),
19
+ recurring: icon("M4 12a8 8 0 0 1 14-5.3L20 9M20 12a8 8 0 0 1-14 5.3L4 15"),
20
+ };
21
+
22
+ /**
23
+ * The prices page's estimate: the kit's `LeadCapture` one question a screen
24
+ * (`steps`) — how urgent first (an urgent job is a call back, the phone
25
+ * alone), the need as cards, the regular clean's answers with "I don't know"
26
+ * for the surface and the frequency as cards that carry their price, and the
27
+ * price on one line with the tax credit. A card of its own (`#estimate`), so
28
+ * the home page's form and its call bar are untouched.
29
+ */
30
+ export function EstimateCard({ view, copy, renderedAt, model }: { view: PlaceView<Locale>; copy: Copy; renderedAt: number; model: PricingModel | null }) {
31
+ const { t } = copy;
32
+ const e = t.estimateForm;
33
+ return (
34
+ <LeadCapture
35
+ id="estimate"
36
+ formId="estimate"
37
+ layout="steps"
38
+ place={view.place}
39
+ contact={contactOf(site, view.place)}
40
+ locale={view.locale}
41
+ renderedAt={renderedAt}
42
+ wire={LEAD.wire}
43
+ needs={LEAD.subjects.map(s => ({ value: s, label: t.subjects[s], icon: ICONS[s] }))}
44
+ needDisplay="cards"
45
+ intro={{
46
+ label: e.when.label,
47
+ field: "urgency",
48
+ options: [
49
+ { value: "today", label: e.when.today, channel: "callback" },
50
+ { value: "week", label: e.when.week },
51
+ { value: "compare", label: e.when.compare },
52
+ ],
53
+ }}
54
+ flows={LEAD.flows}
55
+ pricing={model}
56
+ photos={PHOTO_NEEDS}
57
+ questions={{ surface: { unknown: true }, frequency: { display: "cards", badges: { biweekly: e.badge } } }}
58
+ price="compact"
59
+ taxCredit={0.5}
60
+ afterPhone={<p className="text-sm text-ink-soft">{e.afterPhone}</p>}
61
+ channelsDisplay="row"
62
+ text={{ ...t.leadCapture, title: e.title, lede: e.lede, privacy: "" }}
63
+ className="max-w-md"
64
+ />
65
+ );
66
+ }
@@ -10,6 +10,7 @@
10
10
  # Over it now, on purpose: the form became `LeadCapture`, a client island
11
11
  # (162,985 B gz, +3.2 %) — a warning to pay back, not a new target. The form
12
12
  # variants (an estimate's tiles and live price, the priced success) add
13
- # about 3.9 KB gz more (170,375 B gz, +7.8 %).
13
+ # about 3.9 KB gz more (170,375 B gz, +7.8 %); one question a screen and the
14
+ # compact form's pieces about 9 KB more (179,430 B gz, +13.6 %).
14
15
  158000
15
16
  tolerance 20%
@@ -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
+ });
@@ -3,3 +3,9 @@
3
3
  #callbar {
4
4
  display: none !important;
5
5
  }
6
+
7
+ /* The QA menu (`AbSwitcher`) rides the viewport too, and only a test visit
8
+ * sees it: never part of a section. */
9
+ [data-ab-switcher] {
10
+ display: none !important;
11
+ }
@@ -0,0 +1,138 @@
1
+ import { expect, test, type Locator, type Page } from "@playwright/test";
2
+
3
+ // The prices page's estimate (`EstimateCard`): `LeadCapture` one question a
4
+ // screen. The intro first — urgent is a call back, the phone alone — then the
5
+ // need as cards, the regular clean's answers (the surface may be "I don't
6
+ // know", the frequency is cards that carry their price), the postcode, and
7
+ // the phone last under the price on one line.
8
+
9
+ const form = (page: Page) => page.locator("#estimate-form");
10
+ const screenOn = (page: Page) => form(page).locator("[data-lead-step]:not([data-lead-off])");
11
+ // The radio lies over its tile, as a finger meets it: a tap on the tile is a tap on the radio.
12
+ const tile = (page: Page, name: string) =>
13
+ screenOn(page)
14
+ .locator("label")
15
+ .filter({ has: page.getByText(name, { exact: true }) })
16
+ .locator("input[type=radio]");
17
+ const quote = (page: Page) => page.waitForResponse(r => r.request().method() === "POST" && new URL(r.url()).pathname === "/quote");
18
+
19
+ async function open(page: Page) {
20
+ // Monday 10:00 in Paris: open, so the folded callback is below the form, not leading it.
21
+ await page.clock.setFixedTime(new Date("2026-10-05T08:00:00Z"));
22
+ await page.goto("/fr/prices");
23
+ // Hydrated: the bar and the way back are the script's; without it the noscript style hides them.
24
+ await expect(form(page).locator("[data-lead-chrome]").first()).toBeVisible();
25
+ }
26
+
27
+ const height = (l: Locator) => l.evaluate(el => el.getBoundingClientRect().height);
28
+
29
+ test("one question a screen, the answers kept as chips, the price on one line with the tax credit, and booked", async ({ page }) => {
30
+ await open(page);
31
+ await expect(screenOn(page)).toHaveAttribute("data-lead-step", "intro");
32
+ // The intro picks the branch, and so how many screens follow: no total said before it.
33
+ await expect(form(page).getByText("Étape 1", { exact: true })).toBeAttached();
34
+ // Touch-sized on a phone as on a desk.
35
+ expect(await height(tile(page, "Cette semaine").locator(".."))).toBeGreaterThanOrEqual(44);
36
+ expect(await height(form(page).getByRole("button", { name: "Retour" }).or(tile(page, "Je compare")).first())).toBeGreaterThanOrEqual(44);
37
+ await tile(page, "Cette semaine").click();
38
+ await expect(screenOn(page)).toHaveAttribute("data-lead-step", "need");
39
+ // Cards, each with the brand's icon.
40
+ await expect(screenOn(page).locator("svg")).toHaveCount(4);
41
+ await tile(page, "Ménage régulier").click();
42
+ await tile(page, "2 chambres").click();
43
+ await tile(page, "40 à 70 m²").click();
44
+ await expect(screenOn(page)).toHaveAttribute("data-lead-step", "estimate_frequency");
45
+ // 45 € + 30 € + 10 € = 85 €: less 15 % → 72 €, 10 % → 77 €, 5 % → 81 €.
46
+ const card = (name: string) => screenOn(page).locator("label").filter({ hasText: name });
47
+ await expect(card("Chaque semaine")).toContainText(/72\s€/);
48
+ await expect(card("Toutes les 2 semaines")).toContainText(/77\s€/);
49
+ await expect(card("Toutes les 2 semaines")).toContainText("Le plus avantageux");
50
+ await expect(card("Une fois")).toContainText(/85\s€/);
51
+ await tile(page, "Toutes les 2 semaines").click();
52
+ // The place serves one commune: the postcode is answered already, a chip — on to the phone.
53
+ await expect(screenOn(page)).toHaveAttribute("data-lead-step", "phone");
54
+ await expect(form(page).getByLabel("Téléphone")).toBeFocused();
55
+ await expect(form(page).locator("[data-price-cents]")).toHaveText(/Votre prix : 77\s€/);
56
+ await expect(form(page).locator("[data-credit-cents]")).toContainText(/38,50\s€ après crédit d’impôt/);
57
+ await expect(screenOn(page).getByText("Votre numéro reste entre nous — rappel sous 15 min.")).toBeVisible();
58
+ // The answers, a tap back each; the way back one screen.
59
+ const chips = form(page).locator("[data-lead-chrome] li");
60
+ await expect(chips).toHaveCount(6);
61
+ await expect(chips.last()).toContainText("Paris");
62
+ await form(page).getByRole("button", { name: "Retour" }).click();
63
+ await expect(screenOn(page)).toHaveAttribute("data-lead-step", "locality");
64
+ await expect(form(page).getByLabel("Code postal")).toHaveValue("Paris");
65
+ await form(page).getByLabel("Code postal").fill("75011");
66
+ await form(page).getByLabel("Code postal").press("Enter");
67
+ await expect(screenOn(page)).toHaveAttribute("data-lead-step", "phone");
68
+ await expect(form(page).getByLabel("Téléphone")).toBeFocused();
69
+ await form(page).getByLabel("Téléphone").fill("0612345678");
70
+ const answered = quote(page);
71
+ await form(page).getByRole("button", { name: "Réserver" }).click();
72
+ const res = await answered;
73
+ const body = Object.fromEntries(new URLSearchParams(res.request().postData() ?? ""));
74
+ expect(body).toMatchObject({ urgency: "week", subject: "recurring", estimate_bedrooms: "t3", estimate_surface: "s70", estimate_frequency: "biweekly", locality: "75011", shown_cents: "7700" });
75
+ expect(await res.json()).toMatchObject({ ok: true, cents: 7700 });
76
+ await expect(page.getByRole("status")).toContainText(/Demande enregistrée au prix de 77\s€\./);
77
+ });
78
+
79
+ test("\"Je ne sais pas\" makes the regular clean a quote: the questions after it go, no price, the thanks page", async ({ page }) => {
80
+ await open(page);
81
+ await tile(page, "Je compare").click();
82
+ await tile(page, "Ménage régulier").click();
83
+ await tile(page, "Studio").click();
84
+ await tile(page, "Je ne sais pas").click();
85
+ // The questions after it go; the commune the place serves is answered already.
86
+ await expect(screenOn(page)).toHaveAttribute("data-lead-step", "phone");
87
+ await expect(form(page).getByText("Étape 6/6")).toBeAttached();
88
+ await expect(form(page).locator("[data-price-cents]")).toHaveCount(0);
89
+ await form(page).getByLabel("Téléphone").fill("0612345678");
90
+ const answered = quote(page);
91
+ await form(page).getByRole("button", { name: "Recevoir le prix" }).click();
92
+ const res = await answered;
93
+ const body = new URLSearchParams(res.request().postData() ?? "");
94
+ expect(body.get("estimate_surface")).toBe("?");
95
+ expect(body.has("estimate_frequency")).toBe(false);
96
+ expect(body.has("shown_cents")).toBe(false);
97
+ // A quote leaves for the thanks page; a priced lead would have stayed in the card.
98
+ await expect(page).toHaveURL(/\/fr\/thanks$/);
99
+ });
100
+
101
+ test("urgent is a call back: the phone and its consent, posted as a callback with the urgency", async ({ page }) => {
102
+ await open(page);
103
+ await tile(page, "Urgent — aujourd’hui").click();
104
+ await expect(screenOn(page)).toHaveAttribute("data-lead-step", "phone");
105
+ await expect(form(page).getByText("Étape 2/2")).toBeAttached();
106
+ await expect(page.locator("#estimate-callback")).toHaveCount(0);
107
+ await form(page).getByLabel("Téléphone").fill("0712345678");
108
+ await form(page).getByLabel("J’accepte d’être rappelé·e à ce numéro au sujet de ma demande.").check();
109
+ const answered = quote(page);
110
+ await form(page).getByRole("button", { name: "Être rappelé" }).click();
111
+ const body = new URLSearchParams((await answered).request().postData() ?? "");
112
+ expect(body.get("channel")).toBe("callback");
113
+ expect(body.get("urgency")).toBe("today");
114
+ await expect(page).toHaveURL(/\/fr\/thanks\?channel=callback$/);
115
+ });
116
+
117
+ test.describe("without JavaScript", () => {
118
+ test.use({ javaScriptEnabled: false, reducedMotion: "reduce" });
119
+
120
+ test("every screen is there at once, and the form posts as one", async ({ page }) => {
121
+ await page.goto("/fr/prices");
122
+ await expect(form(page).locator("[data-lead-chrome]").first()).toBeHidden();
123
+ await expect(form(page).getByLabel("Téléphone")).toBeVisible();
124
+ await form(page).locator("label").filter({ has: page.getByText("Autre", { exact: true }) }).locator("input").check();
125
+ await form(page).getByLabel("Code postal").fill("75011");
126
+ await form(page).getByLabel("Téléphone").fill("0612345678");
127
+ const posted = page.waitForRequest(r => r.method() === "POST" && new URL(r.url()).pathname === "/quote");
128
+ await form(page).getByRole("button", { name: "Recevoir le prix" }).click();
129
+ expect(new URLSearchParams((await posted).postData() ?? "").get("subject")).toBe("other");
130
+ await expect(page).toHaveURL(/\/fr\/thanks$/);
131
+ });
132
+ });
133
+
134
+ test("the home page's form takes the postcode the link carries", async ({ page }) => {
135
+ await page.goto("/fr?postcode=75011");
136
+ await expect(page.locator("#quote select")).toHaveCount(0);
137
+ await expect(page.locator("#quote-form").getByLabel("Code postal")).toHaveValue("75011");
138
+ });