@evinvest/kitstart 0.8.0 → 0.10.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 (94) hide show
  1. package/README.md +178 -25
  2. package/dist/cli/kitstart-outbox.d.ts +2 -0
  3. package/dist/cli/kitstart-outbox.js +10 -0
  4. package/dist/cli/outbox.d.ts +5 -0
  5. package/dist/cli/outbox.js +77 -0
  6. package/dist/core/accept.d.ts +44 -3
  7. package/dist/core/accept.js +96 -15
  8. package/dist/core/analytics.d.ts +4 -1
  9. package/dist/core/analytics.js +14 -0
  10. package/dist/core/lead-capture-format.d.ts +6 -0
  11. package/dist/core/lead-capture-format.js +13 -0
  12. package/dist/core/lead-capture-text.d.ts +56 -2
  13. package/dist/core/lead-capture-text.js +63 -0
  14. package/dist/core/lead.d.ts +101 -11
  15. package/dist/core/lead.js +66 -11
  16. package/dist/core/phone.d.ts +20 -9
  17. package/dist/core/phone.js +43 -19
  18. package/dist/core/pricing/flow.d.ts +27 -0
  19. package/dist/core/pricing/flow.js +37 -0
  20. package/dist/core/pricing/index.d.ts +4 -0
  21. package/dist/core/pricing/index.js +4 -0
  22. package/dist/core/pricing/model.d.ts +129 -0
  23. package/dist/core/pricing/model.js +34 -0
  24. package/dist/core/pricing/price.d.ts +34 -0
  25. package/dist/core/pricing/price.js +105 -0
  26. package/dist/core/pricing/validate.d.ts +27 -0
  27. package/dist/core/pricing/validate.js +244 -0
  28. package/dist/core/site.d.ts +7 -0
  29. package/dist/core/site.js +11 -0
  30. package/dist/core/status.d.ts +13 -0
  31. package/dist/core/status.js +19 -2
  32. package/dist/index.d.ts +7 -6
  33. package/dist/index.js +7 -6
  34. package/dist/next/quote-route.d.ts +22 -2
  35. package/dist/next/quote-route.js +72 -10
  36. package/dist/react/LeadCapture.d.ts +37 -7
  37. package/dist/react/LeadCapture.js +34 -9
  38. package/dist/react/LeadCaptureChannels.d.ts +28 -3
  39. package/dist/react/LeadCaptureChannels.js +21 -5
  40. package/dist/react/LeadCaptureEstimate.d.ts +46 -0
  41. package/dist/react/LeadCaptureEstimate.js +69 -0
  42. package/dist/react/LeadCaptureFields.d.ts +39 -7
  43. package/dist/react/LeadCaptureFields.js +91 -21
  44. package/dist/react/LeadCaptureNeed.d.ts +5 -0
  45. package/dist/react/LeadCaptureNeed.js +38 -2
  46. package/dist/react/LeadCapturePriced.d.ts +19 -0
  47. package/dist/react/LeadCapturePriced.js +14 -0
  48. package/dist/react/LeadCaptureSubmit.d.ts +16 -0
  49. package/dist/react/LeadCaptureSubmit.js +13 -0
  50. package/dist/react/QuoteFormShell.d.ts +2 -0
  51. package/dist/react/QuoteFormShell.js +3 -2
  52. package/dist/react/use-lead-error.d.ts +24 -0
  53. package/dist/react/use-lead-error.js +73 -0
  54. package/dist/react/use-lead-events.d.ts +5 -0
  55. package/dist/react/use-lead-events.js +13 -2
  56. package/dist/react/use-lead-submit.d.ts +48 -9
  57. package/dist/react/use-lead-submit.js +129 -13
  58. package/dist/react/use-validity.d.ts +17 -0
  59. package/dist/react/use-validity.js +38 -0
  60. package/dist/server/env.d.ts +9 -0
  61. package/dist/server/env.js +12 -0
  62. package/dist/server/index.d.ts +3 -2
  63. package/dist/server/index.js +3 -2
  64. package/dist/server/lead-store-sqlite.d.ts +5 -1
  65. package/dist/server/lead-store-sqlite.js +63 -2
  66. package/dist/server/lead-webhook.d.ts +68 -1
  67. package/dist/server/lead-webhook.js +34 -2
  68. package/dist/server/pricing-source.d.ts +25 -0
  69. package/dist/server/pricing-source.js +48 -0
  70. package/dist/server/webhook-outbox.d.ts +27 -1
  71. package/dist/server/webhook-outbox.js +33 -6
  72. package/dist/testing/index.d.ts +1 -0
  73. package/dist/testing/index.js +1 -0
  74. package/dist/testing/landing-contract.js +6 -0
  75. package/dist/testing/lead-rules.d.ts +18 -0
  76. package/dist/testing/lead-rules.js +50 -0
  77. package/dist/testing/lead-store-contract.js +28 -1
  78. package/package.json +3 -2
  79. package/template/app/[locale]/[location]/page.tsx +4 -2
  80. package/template/app/quote/route.ts +2 -1
  81. package/template/flake.nix +1 -1
  82. package/template/package.json +1 -1
  83. package/template/src/entities/content/model/en.ts +1 -1
  84. package/template/src/entities/content/model/fr.ts +1 -1
  85. package/template/src/shared/config/env.ts +4 -1
  86. package/template/src/shared/config/lead.ts +17 -4
  87. package/template/src/shared/config/pricing.ts +49 -0
  88. package/template/src/shared/config/site.ts +3 -0
  89. package/template/src/views/home/ui/PlaceHome.tsx +6 -3
  90. package/template/tests/bundle_budget.txt +3 -1
  91. package/template/tests/e2e/flows.spec.ts +106 -0
  92. package/template/tests/e2e/mock-panel.mjs +20 -0
  93. package/template/tests/e2e/playwright.config.ts +25 -9
  94. package/template/tests/e2e/quote-form.spec.ts +86 -1
@@ -0,0 +1,50 @@
1
+ import { readCandidate, validateCandidate } from "../core/lead.js";
2
+ import { phoneProblem } from "../core/phone.js";
3
+ /**
4
+ * Numbers people type, and whether each is a way to reach them — the
5
+ * review's matrix (LEAD-FORMS-REVIEW-2026-10-03) and the spellings that must
6
+ * keep working.
7
+ */
8
+ export const PHONE_MATRIX = [
9
+ { mobile: "06 12 34 56 78", valid: true },
10
+ { mobile: "0612345678", valid: true },
11
+ { mobile: "6 12 34 56 78", valid: true },
12
+ { mobile: "+33 (0)6 12 34 56 78", valid: true },
13
+ { mobile: "0033 6 12 34 56 78", valid: true },
14
+ { mobile: "06.12.34.56.78", valid: true },
15
+ { mobile: "06-12-34-56-78", valid: true },
16
+ { mobile: "01 23 45 67 89", valid: true },
17
+ { mobile: "+1 415 555 0123", valid: true },
18
+ { mobile: "+44 7911 123456", valid: true },
19
+ { mobile: "+12345678", valid: true },
20
+ { mobile: "0612345678", valid: true },
21
+ { mobile: "(415) 555-0123", valid: false },
22
+ { mobile: "+3361234567", valid: false },
23
+ { mobile: "0000000000", valid: false },
24
+ { mobile: "06 66 66 66 66", valid: false },
25
+ { mobile: "06 12 34 56 7", valid: false },
26
+ { mobile: "12 34 56 7", valid: false },
27
+ { mobile: "+1234567", valid: false },
28
+ { mobile: "06 12", valid: false },
29
+ ];
30
+ /**
31
+ * Where a brand's form rule disagrees with the form's own phone check: a
32
+ * number the form lets through that the server refuses (the visitor is sent
33
+ * back for nothing they can see), or one the form blocks that the rule would
34
+ * take. Every other field is filled as the form fills it — the first subject,
35
+ * a postcode — so only the phone is judged. Empty is agreement.
36
+ */
37
+ export function leadRuleDisagreements(schema) {
38
+ const out = [];
39
+ for (const { mobile } of PHONE_MATRIX) {
40
+ const form = new FormData();
41
+ form.set(schema.wire.subject, schema.subjects[0] ?? "");
42
+ form.set(schema.wire.locality, "75011");
43
+ form.set(schema.wire.mobile, mobile);
44
+ const blocked = phoneProblem(mobile) !== null;
45
+ const refused = validateCandidate(schema, readCandidate(schema, form, null)) !== null;
46
+ if (blocked !== refused)
47
+ out.push(`${mobile}: the form ${blocked ? "blocks" : "takes"} it, the server ${refused ? "refuses" : "takes"} it`);
48
+ }
49
+ return out;
50
+ }
@@ -29,10 +29,13 @@ export function describeLeadStoreContract(name, harness) {
29
29
  lead({ subject: "x".repeat(200), locality: "Clermont-Ferrand — 63000" }),
30
30
  lead({ channel: "callback", subject: "", locality: "", consent: { text: "J’accepte d’être rappelé·e.", at: "2026-10-03T10:00:00.000Z" } }),
31
31
  lead({ channel: "callback", consent: { text: "x".repeat(500), at: "2026-10-03T10:00:00.000Z" } }),
32
+ lead({ flow: "quote" }),
33
+ lead({ flow: "estimate", price: { cents: 8400, validFrom: "2026-10-01", inputs: { zone: "proche", frequency: "biweekly" } } }),
34
+ lead({ flow: "fixed", price: { cents: 0, validFrom: "2026-10-01" } }),
32
35
  ]) {
33
36
  expect(await store.insert(variant)).toBeGreaterThan(0);
34
37
  }
35
- expect(await store.count()).toBe(6);
38
+ expect(await store.count()).toBe(9);
36
39
  await store.close();
37
40
  });
38
41
  it("stands at the current schema version and reports itself healthy", async () => {
@@ -41,6 +44,30 @@ export function describeLeadStoreContract(name, harness) {
41
44
  await expect(store.health()).resolves.toBeUndefined();
42
45
  await store.close();
43
46
  });
47
+ // LEAD-FORMS-REVIEW-2026-10-03 #3: a resend after a lost answer is the same lead.
48
+ it("keeps one row per submission id, and finds it back", async () => {
49
+ const store = await harness().open();
50
+ if (!store.findSubmission)
51
+ return void (await store.close());
52
+ const sid = "3f2b8c1e-5d4a-4f6b-9c7e-1a2b3c4d5e6f";
53
+ const first = await store.insert(lead({ submissionId: sid, channel: "callback", consent: { text: "Oui.", at: "2026-10-03T10:00:00.000Z" }, extras: { name: "Ana" } }));
54
+ await expect(store.insert(lead({ submissionId: sid }))).rejects.toThrow();
55
+ expect(await store.count()).toBe(1);
56
+ expect(await store.findSubmission(sid)).toEqual({
57
+ id: first,
58
+ lead: lead({ submissionId: sid, channel: "callback", consent: { text: "Oui.", at: "2026-10-03T10:00:00.000Z" }, extras: { name: "Ana" } }),
59
+ });
60
+ expect(await store.findSubmission("00000000-0000-4000-8000-000000000000")).toBeNull();
61
+ // A priced lead reads back at the price it was taken at.
62
+ const priced = lead({ submissionId: "7c9e6679-7425-40de-944b-e07fc1f90ae7", flow: "estimate", price: { cents: 8400, validFrom: "2026-10-01", inputs: { zone: "proche" } } });
63
+ const pricedId = await store.insert(priced);
64
+ expect(await store.findSubmission("7c9e6679-7425-40de-944b-e07fc1f90ae7")).toEqual({ id: pricedId, lead: { ...priced, channel: "form" } });
65
+ // Leads without one (the no-JS post) never collide.
66
+ await store.insert(lead());
67
+ await store.insert(lead());
68
+ expect(await store.count()).toBe(4);
69
+ await store.close();
70
+ });
44
71
  it("keeps what it acknowledged across a close and a reopen", async () => {
45
72
  const h = harness();
46
73
  if (!h.reopen)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@evinvest/kitstart",
3
- "version": "0.8.0",
3
+ "version": "0.10.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": {
@@ -57,7 +57,8 @@
57
57
  "types": "./dist/index.d.ts",
58
58
  "main": "./dist/index.js",
59
59
  "bin": {
60
- "kitstart-size": "./dist/cli/kitstart-size.js"
60
+ "kitstart-size": "./dist/cli/kitstart-size.js",
61
+ "kitstart-outbox": "./dist/cli/kitstart-outbox.js"
61
62
  },
62
63
  "files": [
63
64
  "dist",
@@ -1,6 +1,7 @@
1
1
  import { placeMetadata } from "@evinvest/kitstart/next";
2
2
  import type { Metadata } from "next";
3
3
  import { loadPlace } from "@/views/place/server";
4
+ import { pricing } from "@/shared/config/env";
4
5
  import { site } from "@/shared/config/site";
5
6
  import { PlaceHome } from "@/views/home";
6
7
 
@@ -13,6 +14,7 @@ export async function generateMetadata({ params }: Props): Promise<Metadata> {
13
14
  }
14
15
 
15
16
  export default async function PlaceHomePage({ params }: Props) {
16
- const { view, copy, renderedAt } = await loadPlace(params);
17
- return <PlaceHome view={view} copy={copy} renderedAt={renderedAt} />;
17
+ // The price list only where the form is: the other pages of a place need none.
18
+ const [{ view, copy, renderedAt }, model] = await Promise.all([loadPlace(params), pricing.model()]);
19
+ return <PlaceHome view={view} copy={copy} renderedAt={renderedAt} model={model} />;
18
20
  }
@@ -1,6 +1,6 @@
1
1
  import { quoteRoute } from "@evinvest/kitstart/next";
2
2
  import { TEXT } from "@/entities/content";
3
- import { notifier, serverEnv } from "@/shared/config/env";
3
+ import { notifier, pricing, serverEnv } from "@/shared/config/env";
4
4
  import { site } from "@/shared/config/site";
5
5
 
6
6
  /** The no-JS path: a plain form POST answered with a 303. */
@@ -9,6 +9,7 @@ export const dynamic = "force-dynamic";
9
9
  export const POST = quoteRoute(site, {
10
10
  env: serverEnv,
11
11
  notifier,
12
+ pricing,
12
13
  unavailable: locale => {
13
14
  const t = TEXT[locale];
14
15
  const f = { place: site.brand.name, phone: site.brand.phone };
@@ -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.8.0";
6
+ ev.url = "github:EV-invest/lib?ref=@evinvest/kitstart-v0.10.0";
7
7
  ev.inputs.v_flakes.follows = "v_flakes";
8
8
  };
9
9
 
@@ -20,7 +20,7 @@
20
20
  "dependencies": {
21
21
  "@evinvest/analytics": "^0.3.0",
22
22
  "@evinvest/i18n": "^0.9.0",
23
- "@evinvest/kitstart": "^0.8.0",
23
+ "@evinvest/kitstart": "^0.10.0",
24
24
  "@evinvest/marketing": "^0.3.0",
25
25
  "@evinvest/uikit": "^0.25.0",
26
26
  "next": "16.3.6",
@@ -41,7 +41,7 @@ export const EN = {
41
41
  coverageTitle: "Where we work",
42
42
  brandPage: { title: "Brand", description: "Cleaning at a fixed price.", open: "Open" },
43
43
  contactLabel: "Contact us",
44
- subjects: { standard: "Regular cleaning", deep: "Deep cleaning", other: "Other" },
44
+ subjects: { standard: "Regular cleaning", deep: "Deep cleaning", other: "Other", recurring: "Recurring cleaning" },
45
45
  leadCapture: { ...LEAD_CAPTURE_TEXT.en, needLabel: "Service" },
46
46
  faqs: [{ q: "Can the price change?", a: "No: the price quoted is the price you pay." }],
47
47
  } satisfies Text;
@@ -41,7 +41,7 @@ export const FR = {
41
41
  coverageTitle: "Où nous intervenons",
42
42
  brandPage: { title: "Brand", description: "Le ménage à prix fixe.", open: "Ouvrir" },
43
43
  contactLabel: "Nous contacter",
44
- subjects: { standard: "Ménage courant", deep: "Grand ménage", other: "Autre" },
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
46
  faqs: [{ q: "Le prix peut-il changer ?", a: "Non : le prix annoncé est celui que vous payez." }],
47
47
  } satisfies Text;
@@ -1,5 +1,5 @@
1
1
  import "server-only";
2
- import { createPlaceSource, createServerEnv, leadNotifier, type LeadNotifier } from "@evinvest/kitstart/server";
2
+ import { createPlaceSource, createPricingSource, createServerEnv, leadNotifier, type LeadNotifier } from "@evinvest/kitstart/server";
3
3
  import { site } from "./site";
4
4
 
5
5
  /** Parsed once, lazily: `next build` imports this and must need no secrets. */
@@ -8,6 +8,9 @@ export const serverEnv = createServerEnv(site);
8
8
  /** The live place data, when `LOCATIONS_API_URL` is set; baked otherwise. */
9
9
  export const places = createPlaceSource(site, { baseUrl: () => serverEnv().locationsApiUrl });
10
10
 
11
+ /** The live price list from the same source (`<LOCATIONS_API_URL>/pricing`); `site.pricing` otherwise. */
12
+ export const pricing = createPricingSource(site, { baseUrl: () => serverEnv().locationsApiUrl });
13
+
11
14
  let built: LeadNotifier | undefined;
12
15
 
13
16
  /** Built at boot (`instrumentation.ts`), so SMTP with no sender fails startup. */
@@ -1,11 +1,23 @@
1
- import type { LeadSchema } from "@evinvest/kitstart";
1
+ import { validateLead, type LeadFlows, type LeadSchema } from "@evinvest/kitstart";
2
2
 
3
- export const SUBJECTS = ["standard", "deep", "other"] as const;
3
+ export const SUBJECTS = ["standard", "deep", "other", "recurring"] as const;
4
4
  export type Subject = (typeof SUBJECTS)[number];
5
5
 
6
+ /**
7
+ * How each need is sold. `recurring` is priced live from a few answers
8
+ * (`pricing.ts`) and booked; the rest ask for a quote, and `deep` — priced
9
+ * from photos — offers to send them on WhatsApp once the place has a number.
10
+ * The form and the quote route read the same map.
11
+ */
12
+ export const FLOWS = { recurring: "estimate", standard: "quote", deep: "quote", other: "quote" } as const satisfies LeadFlows;
13
+ export const PHOTO_NEEDS: readonly Subject[] = ["deep"];
14
+
6
15
  /**
7
16
  * What the quote form asks, and the one rule worth enforcing: a lead with no
8
- * way to reach the customer is not a lead. The reason is for the log only.
17
+ * way to reach the customer is not a lead. `validateLead` is the number rule
18
+ * the form itself blocks on; a brand rule of its own composes with it —
19
+ * `lead => validateLead(lead) ?? mine(lead)` — and names the field it refuses,
20
+ * which the form shows the error at.
9
21
  */
10
22
  export const LEAD: LeadSchema<Subject> = {
11
23
  subjects: SUBJECTS,
@@ -13,5 +25,6 @@ export const LEAD: LeadSchema<Subject> = {
13
25
  extras: [{ name: "surface_m2", max: 6 }],
14
26
  // One shape for the mail, the webhook and a dialler: `+33612345678`.
15
27
  mobileFormat: "e164",
16
- validate: lead => (lead.mobile.replace(/\D/g, "").length < 10 ? "a mobile number" : null),
28
+ validate: validateLead,
29
+ flows: FLOWS,
17
30
  };
@@ -0,0 +1,49 @@
1
+ import type { PricingModel } from "@evinvest/kitstart";
2
+
3
+ /**
4
+ * The baked price list: what `recurring` is priced from until the panel
5
+ * serves one (`GET <LOCATIONS_API_URL>/pricing`), and whenever it is down.
6
+ * OWNER_TODO: placeholder amounts — the owner sets the real ones in the panel.
7
+ */
8
+ export const PRICING: PricingModel = {
9
+ format: 1,
10
+ currency: "EUR",
11
+ validFrom: "2026-10-01",
12
+ roundToCents: 100,
13
+ minimumCents: 4900,
14
+ inputs: [
15
+ {
16
+ id: "bedrooms",
17
+ kind: "add",
18
+ labels: { fr: "Chambres", en: "Bedrooms" },
19
+ options: [
20
+ { id: "studio", labels: { fr: "Studio", en: "Studio" }, addCents: 0 },
21
+ { id: "t2", labels: { fr: "1 chambre", en: "1 bedroom" }, addCents: 1500 },
22
+ { id: "t3", labels: { fr: "2 chambres", en: "2 bedrooms" }, addCents: 3000 },
23
+ { id: "t4", labels: { fr: "3 chambres et plus", en: "3 bedrooms or more" }, addCents: 4500 },
24
+ ],
25
+ },
26
+ {
27
+ id: "surface",
28
+ kind: "add",
29
+ labels: { fr: "Surface", en: "Floor area" },
30
+ options: [
31
+ { id: "s40", labels: { fr: "Moins de 40 m²", en: "Under 40 m²" }, addCents: 0 },
32
+ { id: "s70", labels: { fr: "40 à 70 m²", en: "40 to 70 m²" }, addCents: 1000 },
33
+ { id: "s70p", labels: { fr: "Plus de 70 m²", en: "Over 70 m²" }, addCents: 2500 },
34
+ ],
35
+ },
36
+ {
37
+ id: "frequency",
38
+ kind: "discount",
39
+ labels: { fr: "Fréquence", en: "How often" },
40
+ options: [
41
+ { id: "weekly", labels: { fr: "Chaque semaine", en: "Every week" }, discountBp: 1500 },
42
+ { id: "biweekly", labels: { fr: "Toutes les 2 semaines", en: "Every 2 weeks" }, discountBp: 1000 },
43
+ { id: "monthly", labels: { fr: "Chaque mois", en: "Every month" }, discountBp: 500 },
44
+ { id: "once", labels: { fr: "Une fois", en: "Once" }, discountBp: 0 },
45
+ ],
46
+ },
47
+ ],
48
+ needs: { recurring: { kind: "estimate", baseCents: 4500, inputs: ["bedrooms", "surface", "frequency"] } },
49
+ };
@@ -2,6 +2,7 @@ import { cardFact, defineSite, SERVICE_AREA_GATE, type OwnerTodo } from "@evinve
2
2
  import { i18n } from "./i18n";
3
3
  import { LEAD } from "./lead";
4
4
  import { PLACES } from "./places";
5
+ import { PRICING } from "./pricing";
5
6
 
6
7
  /**
7
8
  * The brand as the machinery sees it — routing, the lead funnel, schema.org,
@@ -29,6 +30,7 @@ export const site = defineSite({
29
30
  places: PLACES,
30
31
  publication: SERVICE_AREA_GATE,
31
32
  lead: LEAD,
33
+ pricing: PRICING,
32
34
  });
33
35
 
34
36
  export type PageKey = (typeof site.pageKeys)[number];
@@ -36,4 +38,5 @@ export type PageKey = (typeof site.pageKeys)[number];
36
38
  export const OWNER_TODO: readonly OwnerTodo[] = [
37
39
  { field: "site.brand.legalName", why: "raison sociale as registered", blocksLaunch: true },
38
40
  { field: "assets/card.toml phone", why: "the public number", blocksLaunch: false },
41
+ { field: "shared/config/pricing.ts", why: "placeholder prices until the panel serves the real ones", blocksLaunch: false },
39
42
  ];
@@ -1,8 +1,8 @@
1
- import { contactOf, placeGraph, type PlaceView } from "@evinvest/kitstart";
1
+ import { contactOf, placeGraph, type PlaceView, type PricingModel } from "@evinvest/kitstart";
2
2
  import { CallBar, Coverage, Display, Faq, JsonLd, LeadCapture, Section } from "@evinvest/kitstart/react";
3
3
  import type { Copy } from "@/entities/content";
4
4
  import type { Locale } from "@/shared/config/i18n";
5
- import { LEAD } from "@/shared/config/lead";
5
+ import { LEAD, PHOTO_NEEDS } from "@/shared/config/lead";
6
6
  import { site } from "@/shared/config/site";
7
7
 
8
8
  /**
@@ -12,7 +12,7 @@ import { site } from "@/shared/config/site";
12
12
  * `site.lead.wire` — restyle it through `classNames`, drive `layout` and
13
13
  * `prefer` from an experiment.
14
14
  */
15
- export function PlaceHome({ view, copy, renderedAt }: { view: PlaceView<Locale>; copy: Copy; renderedAt: number }) {
15
+ export function PlaceHome({ view, copy, renderedAt, model }: { view: PlaceView<Locale>; copy: Copy; renderedAt: number; model: PricingModel | null }) {
16
16
  const { t, f } = copy;
17
17
  const contact = contactOf(site, view.place);
18
18
  const graph = placeGraph(site, view, "home", { placeName: f.place, title: t.pages.home.title(f), description: t.pages.home.description(f) }, new Date(renderedAt));
@@ -32,6 +32,9 @@ export function PlaceHome({ view, copy, renderedAt }: { view: PlaceView<Locale>;
32
32
  renderedAt={renderedAt}
33
33
  wire={LEAD.wire}
34
34
  needs={LEAD.subjects.map(s => ({ value: s, label: t.subjects[s] }))}
35
+ flows={LEAD.flows}
36
+ pricing={model}
37
+ photos={PHOTO_NEEDS}
35
38
  text={t.leadCapture}
36
39
  className="max-w-md"
37
40
  />
@@ -8,6 +8,8 @@
8
8
  # first measurement plus a few percent; change it only in a commit that says why.
9
9
  #
10
10
  # Over it now, on purpose: the form became `LeadCapture`, a client island
11
- # (162,985 B gz, +3.2 %) — a warning to pay back, not a new target.
11
+ # (162,985 B gz, +3.2 %) — a warning to pay back, not a new target. The form
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 %).
12
14
  158000
13
15
  tolerance 20%
@@ -0,0 +1,106 @@
1
+ import { expect, test, type Page } from "@playwright/test";
2
+
3
+ // The form variants: `recurring` is an estimate, priced live from the baked
4
+ // model (the mock panel's price list is down) and stored at the server's own
5
+ // price; the slot is then set by a call — no booking provider yet.
6
+
7
+ const hydrated = (page: Page) => expect(page.locator("#quote select")).toHaveCount(0);
8
+
9
+ async function pickRecurring(page: Page, locale: "fr" | "en") {
10
+ await page.goto(`/${locale}`);
11
+ await hydrated(page);
12
+ await page.getByRole("combobox", { name: locale === "fr" ? "Prestation" : "Service" }).click();
13
+ await page.getByRole("listbox").getByRole("option", { name: locale === "fr" ? "Ménage régulier" : "Recurring cleaning" }).click();
14
+ }
15
+
16
+ const form = (page: Page) => page.locator("#quote-form");
17
+ const answer = (page: Page, label: string) => form(page).getByRole("radio", { name: label, exact: true }).check();
18
+ const price = (page: Page) => form(page).locator("[data-price-cents]");
19
+
20
+ async function contact(page: Page, locale: "fr" | "en") {
21
+ await form(page).getByLabel(locale === "fr" ? "Code postal" : "Postcode").fill("75011");
22
+ await form(page).getByLabel(locale === "fr" ? "Téléphone" : "Phone").fill("0612345678");
23
+ }
24
+
25
+ const quote = (page: Page) => page.waitForResponse(r => r.request().method() === "POST" && new URL(r.url()).pathname === "/quote");
26
+
27
+ test("an estimate prices the answers live, from the baked model while the panel's is down", async ({ page }) => {
28
+ await pickRecurring(page, "fr");
29
+ await expect(form(page).getByText("Répondez aux questions pour voir le prix.")).toBeVisible();
30
+ await expect(price(page)).toHaveCount(0);
31
+ await answer(page, "2 chambres");
32
+ await answer(page, "40 à 70 m²");
33
+ await answer(page, "Toutes les 2 semaines");
34
+ // 45 € + 30 € + 10 € = 85 €, less 10 % = 76,50 €, to the euro: 77 €.
35
+ await expect(price(page)).toHaveAttribute("data-price-cents", "7700");
36
+ await answer(page, "Une fois");
37
+ await expect(price(page)).toHaveAttribute("data-price-cents", "8500");
38
+ await expect(form(page).getByRole("button", { name: "Réserver" })).toBeVisible();
39
+ });
40
+
41
+ test("the server prices the posted answers itself: a posted amount is ignored", async ({ page }) => {
42
+ await pickRecurring(page, "fr");
43
+ await answer(page, "2 chambres");
44
+ await answer(page, "40 à 70 m²");
45
+ await answer(page, "Toutes les 2 semaines");
46
+ await expect(price(page)).toHaveAttribute("data-price-cents", "7700");
47
+ await contact(page, "fr");
48
+ // A tampered page: a price of its own, and an answer the screen did not price.
49
+ await form(page).evaluate(el => {
50
+ for (const name of ["quoted_cents", "cents", "price"]) {
51
+ const input = document.createElement("input");
52
+ input.type = "hidden";
53
+ input.name = name;
54
+ input.value = "1";
55
+ el.append(input);
56
+ }
57
+ const t3 = el.querySelector<HTMLInputElement>("input[name=estimate_bedrooms][value=t3]");
58
+ if (t3) t3.value = "t4";
59
+ });
60
+ const answered = quote(page);
61
+ await form(page).getByRole("button", { name: "Réserver" }).click();
62
+ // 45 € + 45 € + 10 € = 100 €, less 10 % = 90 €.
63
+ expect(await (await answered).json()).toMatchObject({ ok: true, cents: 9000 });
64
+ await expect(page.getByRole("status")).toContainText(/Demande enregistrée au prix de 90\s€\./);
65
+ });
66
+
67
+ test("a priced lead confirms the server's price and promises a call to set the slot, with no third party", async ({ page }) => {
68
+ const outside: string[] = [];
69
+ page.on("request", r => void (new URL(r.url()).hostname !== "localhost" && outside.push(r.url())));
70
+ await pickRecurring(page, "fr");
71
+ await answer(page, "Studio");
72
+ await answer(page, "Moins de 40 m²");
73
+ await answer(page, "Une fois");
74
+ await contact(page, "fr");
75
+ const answered = quote(page);
76
+ await form(page).getByRole("button", { name: "Réserver" }).click();
77
+ expect(await (await answered).json()).toMatchObject({ ok: true, cents: 4900, lead: expect.stringMatching(/^lead-\d+-[0-9a-f]{8}$/) });
78
+ const status = page.getByRole("status");
79
+ // 45 €, under the 49 € minimum.
80
+ await expect(status).toContainText(/Demande enregistrée au prix de 49\s€\./);
81
+ await expect(status).toContainText("Nous vous rappelons pour fixer le créneau.");
82
+ await expect(status).toBeFocused();
83
+ await expect(page.locator("iframe")).toHaveCount(0);
84
+ expect(outside).toEqual([]);
85
+ });
86
+
87
+ test("in English too", async ({ page }) => {
88
+ await pickRecurring(page, "en");
89
+ await answer(page, "1 bedroom");
90
+ await answer(page, "Over 70 m²");
91
+ await answer(page, "Every week");
92
+ // 45 € + 15 € + 25 € = 85 €, less 15 % = 72,25 €, to the euro: 72 €.
93
+ await expect(price(page)).toHaveAttribute("data-price-cents", "7200");
94
+ await contact(page, "en");
95
+ await form(page).getByRole("button", { name: "Book" }).click();
96
+ const status = page.getByRole("status");
97
+ await expect(status).toContainText("Request saved at €72.");
98
+ await expect(status).toContainText("We will call you to set the slot.");
99
+ });
100
+
101
+ test("a quote need is the form it always was", async ({ page }) => {
102
+ await page.goto("/fr");
103
+ await hydrated(page);
104
+ await expect(form(page).locator("input[name^=estimate_]")).toHaveCount(0);
105
+ await expect(form(page).getByRole("button", { name: "Recevoir le prix" })).toBeVisible();
106
+ });
@@ -0,0 +1,20 @@
1
+ // The panel's internal endpoints as the e2e suite needs them, on
2
+ // `E2E_MOCK_PORT`: the place's live half (nothing over the baked place), and
3
+ // a price list that is down — so the page prices from the baked model.
4
+ import { createServer } from "node:http";
5
+
6
+ const port = Number(process.env["E2E_MOCK_PORT"]);
7
+ const json = (res, status, body) => {
8
+ res.writeHead(status, { "content-type": "application/json" });
9
+ res.end(JSON.stringify(body));
10
+ };
11
+
12
+ createServer((req, res) => {
13
+ const url = new URL(req.url ?? "/", "http://mock");
14
+ if (url.pathname === "/health") return json(res, 200, { ok: true });
15
+ if (url.pathname === "/pricing") return json(res, 503, { error: "down" });
16
+ if (url.pathname === "/locations/paris") {
17
+ return json(res, 200, {});
18
+ }
19
+ return json(res, 404, { error: "no such route" });
20
+ }).listen(port, "127.0.0.1");
@@ -4,6 +4,8 @@ import { BREAKPOINTS } from "@evinvest/kitstart/testing/e2e";
4
4
  // The standalone server under test, never `next dev`. Baselines are Linux's
5
5
  // (CI's); elsewhere the pixel comparison is skipped.
6
6
  const PORT = Number(process.env["E2E_PORT"] ?? 59089);
7
+ // The panel stand-in (`mock-panel.mjs`): the live place and the price list.
8
+ const MOCK_PORT = Number(process.env["E2E_MOCK_PORT"] ?? PORT + 1);
7
9
  const linux = process.platform === "linux";
8
10
 
9
11
  export default defineConfig({
@@ -14,13 +16,27 @@ export default defineConfig({
14
16
  expect: { toHaveScreenshot: { maxDiffPixelRatio: 0.01, animations: "disabled", stylePath: "./screenshot-section.css" } },
15
17
  use: { baseURL: `http://localhost:${PORT}`, deviceScaleFactor: 1, colorScheme: "light", locale: "fr-FR" },
16
18
  projects: BREAKPOINTS.map(b => ({ name: b.name, use: { ...devices["Desktop Chrome"], viewport: b.viewport } })),
17
- webServer: {
18
- command: "node .next/standalone/server.js",
19
- cwd: "../..",
20
- url: `http://localhost:${PORT}/health`,
21
- reuseExistingServer: false,
22
- // The prod server refuses to boot without these (instrumentation.ts); on
23
- // loopback the one hop is the runner itself.
24
- env: { PORT: String(PORT), HOSTNAME: "127.0.0.1", LEADS_DB_PATH: `/tmp/brand-e2e-${PORT}/leads.db`, TRUSTED_PROXY: "xff:1" },
25
- },
19
+ webServer: [
20
+ {
21
+ command: "node mock-panel.mjs",
22
+ url: `http://127.0.0.1:${MOCK_PORT}/health`,
23
+ reuseExistingServer: false,
24
+ env: { E2E_MOCK_PORT: String(MOCK_PORT) },
25
+ },
26
+ {
27
+ command: "node .next/standalone/server.js",
28
+ cwd: "../..",
29
+ url: `http://localhost:${PORT}/health`,
30
+ reuseExistingServer: false,
31
+ // The prod server refuses to boot without these (instrumentation.ts); on
32
+ // loopback the one hop is the runner itself.
33
+ env: {
34
+ PORT: String(PORT),
35
+ HOSTNAME: "127.0.0.1",
36
+ LEADS_DB_PATH: `/tmp/brand-e2e-${PORT}/leads.db`,
37
+ TRUSTED_PROXY: "xff:1",
38
+ LOCATIONS_API_URL: `http://127.0.0.1:${MOCK_PORT}`,
39
+ },
40
+ },
41
+ ],
26
42
  });
@@ -131,7 +131,7 @@ for (const javaScriptEnabled of [false, true]) {
131
131
  expect(body.get("location")).toBe("paris");
132
132
  // The sentence shown is what is posted, and what the lead keeps.
133
133
  expect(body.get("consent")).toBe("J’accepte d’être rappelé·e à ce numéro au sujet de ma demande.");
134
- await expect(page).toHaveURL(/\/fr\/thanks$/);
134
+ await expect(page).toHaveURL(/\/fr\/thanks\?channel=callback$/);
135
135
  });
136
136
 
137
137
  test("is refused without the consent", async ({ page }) => {
@@ -208,3 +208,88 @@ test("a scripted post of the form lands on the thanks page", async ({ page }) =>
208
208
  });
209
209
  expect(answer).toEqual({ ok: true, redirected: true, path: "/fr/thanks" });
210
210
  });
211
+
212
+ // A number the server refuses: never silent, never lost (LEAD-FORMS-REVIEW-2026-10-03 #1).
213
+ const INVALID = "Ce numéro n’est pas valide. Exemple : 06 12 34 56 78 ou +33 6 12 34 56 78.";
214
+
215
+ test.describe("a refused number, without JavaScript", () => {
216
+ test.use({ javaScriptEnabled: false, reducedMotion: "reduce" });
217
+
218
+ test("goes back to the card, naming the field and keeping the need", async ({ page }) => {
219
+ await page.goto("/fr#quote");
220
+ const form = page.locator("#quote-form");
221
+ await form.getByLabel("Code postal").fill("75011");
222
+ await form.getByLabel("Téléphone").fill("06 12 34 56 7");
223
+ await form.getByRole("button", { name: "Recevoir le prix" }).click();
224
+ await expect(page).toHaveURL(/\/fr\?lead_error=phone&need=standard#quote$/);
225
+ });
226
+ });
227
+
228
+ test.describe("a refused number, with JavaScript", () => {
229
+ const hydrated = (page: Page) => expect(page.locator("#quote select")).toHaveCount(0);
230
+
231
+ test("is blocked before it is sent, in the page's words", async ({ page }) => {
232
+ await page.goto("/fr");
233
+ await hydrated(page);
234
+ let sent = 0;
235
+ page.on("request", r => void (r.method() === "POST" && sent++));
236
+ const form = page.locator("#quote-form");
237
+ await form.getByLabel("Code postal").fill("75011");
238
+ const phone = form.getByLabel("Téléphone");
239
+ await phone.fill("+3361234567");
240
+ await form.getByRole("button", { name: "Recevoir le prix" }).click();
241
+ await expect(phone).toHaveAttribute("aria-invalid", "true");
242
+ await expect(form.getByRole("alert")).toHaveText(INVALID);
243
+ expect(await phone.evaluate(el => (el instanceof HTMLInputElement ? el.validationMessage : ""))).toBe(INVALID);
244
+ expect(sent).toBe(0);
245
+ });
246
+
247
+ test("refused by the server, shows why at the field and keeps what was typed", async ({ page }) => {
248
+ await page.goto("/fr");
249
+ await hydrated(page);
250
+ const form = page.locator("#quote-form");
251
+ // Past the form's own check, as a stale page's would be.
252
+ await form.evaluate(el => el instanceof HTMLFormElement && (el.noValidate = true));
253
+ await form.getByLabel("Code postal").fill("75011");
254
+ const phone = form.getByLabel("Téléphone");
255
+ await phone.fill("0000000000");
256
+ const answered = page.waitForResponse(r => new URL(r.url()).pathname === "/quote");
257
+ await form.getByRole("button", { name: "Recevoir le prix" }).click();
258
+ expect((await answered).status()).toBe(422);
259
+ await expect(form.getByRole("alert")).toHaveText(INVALID);
260
+ await expect(phone).toBeFocused();
261
+ await expect(phone).toHaveValue("0000000000");
262
+ await expect(form.getByLabel("Code postal")).toHaveValue("75011");
263
+ await expect(page).toHaveURL(/\/fr$/);
264
+ });
265
+
266
+ test("brought back by the no-JS refusal, shows it at the field", async ({ page }) => {
267
+ await page.goto("/fr?lead_error=phone&need=deep#quote");
268
+ const form = page.locator("#quote-form");
269
+ await expect(form.getByRole("alert")).toHaveText(INVALID);
270
+ await expect(form.getByLabel("Téléphone")).toBeFocused();
271
+ // The need came back with it: chosen, not asked again.
272
+ await expect(form.locator("input[name=subject]")).toHaveValue("deep");
273
+ await expect(form.getByText("Grand ménage")).toBeVisible();
274
+ await expect(page).toHaveURL(/\/fr\?need=deep#quote$/);
275
+ });
276
+ });
277
+
278
+ // No network at the press of submit: the form stays, says so, and sends the
279
+ // same lead once the network is back (LEAD-FORMS-REVIEW-2026-10-03 #4).
280
+ test("offline, the form says so, keeps what was typed and sends it on retry", async ({ page, context }) => {
281
+ await page.goto("/fr");
282
+ await expect(page.locator("#quote select")).toHaveCount(0);
283
+ const form = page.locator("#quote-form");
284
+ await form.getByLabel("Code postal").fill("75011");
285
+ await form.getByLabel("Téléphone").fill("0612345678");
286
+ await context.setOffline(true);
287
+ await form.getByRole("button", { name: "Recevoir le prix" }).click();
288
+ const alert = form.getByRole("alert");
289
+ await expect(alert).toContainText("Pas de connexion");
290
+ await expect(page).toHaveURL(/\/fr$/);
291
+ await expect(form.getByLabel("Téléphone")).toHaveValue("0612345678");
292
+ await context.setOffline(false);
293
+ await alert.getByRole("button", { name: "Réessayer" }).click();
294
+ await expect(page).toHaveURL(/\/fr\/thanks$/);
295
+ });