@evinvest/kitstart 0.9.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 (63) hide show
  1. package/README.md +92 -5
  2. package/dist/core/accept.d.ts +23 -1
  3. package/dist/core/accept.js +59 -5
  4. package/dist/core/analytics.d.ts +2 -1
  5. package/dist/core/analytics.js +7 -0
  6. package/dist/core/lead-capture-format.d.ts +6 -0
  7. package/dist/core/lead-capture-format.js +13 -0
  8. package/dist/core/lead-capture-text.d.ts +32 -1
  9. package/dist/core/lead-capture-text.js +43 -0
  10. package/dist/core/lead.d.ts +32 -3
  11. package/dist/core/lead.js +3 -2
  12. package/dist/core/pricing/flow.d.ts +27 -0
  13. package/dist/core/pricing/flow.js +37 -0
  14. package/dist/core/pricing/index.d.ts +4 -0
  15. package/dist/core/pricing/index.js +4 -0
  16. package/dist/core/pricing/model.d.ts +129 -0
  17. package/dist/core/pricing/model.js +34 -0
  18. package/dist/core/pricing/price.d.ts +34 -0
  19. package/dist/core/pricing/price.js +105 -0
  20. package/dist/core/pricing/validate.d.ts +27 -0
  21. package/dist/core/pricing/validate.js +244 -0
  22. package/dist/core/site.d.ts +7 -0
  23. package/dist/core/site.js +11 -0
  24. package/dist/index.d.ts +5 -4
  25. package/dist/index.js +4 -3
  26. package/dist/next/quote-route.d.ts +8 -1
  27. package/dist/next/quote-route.js +3 -1
  28. package/dist/react/LeadCapture.d.ts +30 -2
  29. package/dist/react/LeadCapture.js +18 -5
  30. package/dist/react/LeadCaptureChannels.d.ts +14 -2
  31. package/dist/react/LeadCaptureChannels.js +13 -0
  32. package/dist/react/LeadCaptureEstimate.d.ts +46 -0
  33. package/dist/react/LeadCaptureEstimate.js +69 -0
  34. package/dist/react/LeadCapturePriced.d.ts +19 -0
  35. package/dist/react/LeadCapturePriced.js +14 -0
  36. package/dist/react/use-lead-events.d.ts +2 -0
  37. package/dist/react/use-lead-events.js +10 -0
  38. package/dist/react/use-lead-submit.d.ts +7 -3
  39. package/dist/react/use-lead-submit.js +14 -7
  40. package/dist/server/index.d.ts +2 -1
  41. package/dist/server/index.js +2 -1
  42. package/dist/server/lead-store-sqlite.js +31 -5
  43. package/dist/server/lead-webhook.d.ts +48 -0
  44. package/dist/server/lead-webhook.js +30 -2
  45. package/dist/server/pricing-source.d.ts +25 -0
  46. package/dist/server/pricing-source.js +48 -0
  47. package/dist/testing/lead-store-contract.js +9 -2
  48. package/package.json +1 -1
  49. package/template/app/[locale]/[location]/page.tsx +4 -2
  50. package/template/app/quote/route.ts +2 -1
  51. package/template/flake.nix +1 -1
  52. package/template/package.json +1 -1
  53. package/template/src/entities/content/model/en.ts +1 -1
  54. package/template/src/entities/content/model/fr.ts +1 -1
  55. package/template/src/shared/config/env.ts +4 -1
  56. package/template/src/shared/config/lead.ts +12 -2
  57. package/template/src/shared/config/pricing.ts +49 -0
  58. package/template/src/shared/config/site.ts +3 -0
  59. package/template/src/views/home/ui/PlaceHome.tsx +6 -3
  60. package/template/tests/bundle_budget.txt +3 -1
  61. package/template/tests/e2e/flows.spec.ts +106 -0
  62. package/template/tests/e2e/mock-panel.mjs +20 -0
  63. package/template/tests/e2e/playwright.config.ts +25 -9
@@ -1,5 +1,6 @@
1
1
  import "server-only";
2
2
  import { type Lead, type LeadChannel, type LeadSuspect } from "../core/lead.js";
3
+ import type { LeadFlow } from "../core/pricing/flow.js";
3
4
  import type { ServerEnv } from "./env.js";
4
5
  import { type TickReport, type WebhookOutbox, type WebhookOutboxOptions } from "./webhook-outbox.js";
5
6
  import type { WebhookSigning } from "./webhook-signature.js";
@@ -20,7 +21,44 @@ export interface LeadWebhookContext {
20
21
  * body must not carry the property at all.
21
22
  */
22
23
  suspect?: LeadSuspect;
24
+ /**
25
+ * The lead's public reference (`leadRef`, `lead-<row>-<8 hex>`): the one
26
+ * the page was answered with (`LeadSent.lead`), so a later step on the page
27
+ * can name the lead the panel knows. Absent only on a context built by hand.
28
+ */
29
+ leadRef?: string;
30
+ /**
31
+ * How the need was sold and at what price — only under `panelFlow`, and
32
+ * only on a lead with a flow; otherwise absent, and the body must not carry
33
+ * the properties. `panelFlowProperties` writes them as the panel reads them.
34
+ */
35
+ flow?: PanelFlow;
36
+ }
37
+ /** A lead's sale as the panel's `lead.created` may carry it. */
38
+ export interface PanelFlow {
39
+ flow: LeadFlow;
40
+ /** With `estimate` and `fixed` only, always with `pricingValidFrom`. */
41
+ quotedCents?: number;
42
+ /** The price list's date, `YYYY-MM-DD`. */
43
+ pricingValidFrom?: string;
44
+ /** With `estimate` only, and only when it asked anything: input id → option id, slugs. */
45
+ estimateInputs?: Readonly<Record<string, string>>;
23
46
  }
47
+ /** The sale `ctx.flow` describes, or nothing for a lead without a flow. */
48
+ export declare function panelFlowOf(lead: Pick<Lead, "flow" | "price">): PanelFlow | undefined;
49
+ /**
50
+ * `ctx.flow` as `lead.created`'s properties: `flow`, then `quoted_cents` and
51
+ * `pricing_valid_from` together (estimate and fixed), then `estimate_inputs`
52
+ * (estimate) — the panel's closed vocabulary. Spread into the body's
53
+ * `properties`; `{}` without a flow.
54
+ */
55
+ export interface PanelFlowProperties {
56
+ flow: LeadFlow;
57
+ quoted_cents?: number;
58
+ pricing_valid_from?: string;
59
+ estimate_inputs?: Readonly<Record<string, string>>;
60
+ }
61
+ export declare function panelFlowProperties(flow: PanelFlow | undefined): PanelFlowProperties | Record<string, never>;
24
62
  /**
25
63
  * What the Service-Arb panel's `lead.created` calls a callback request. Its
26
64
  * `properties.channel` is a closed set (`form` | `phone_inbound`) and an event
@@ -42,6 +80,7 @@ export interface LeadWebhook {
42
80
  enqueue(lead: Lead, id: number, meta: {
43
81
  locale: string;
44
82
  formId: string;
83
+ leadRef?: string;
45
84
  }): number;
46
85
  tick(): Promise<TickReport>;
47
86
  start(intervalMs?: number): void;
@@ -51,6 +90,8 @@ export interface LeadWebhook {
51
90
  requeueDead(): number;
52
91
  /** Whether a suspect lead is queued, marked (`LeadWebhookOptions.panelSuspect`). */
53
92
  readonly panelSuspect: boolean;
93
+ /** Whether `ctx.flow` is filled in (`LeadWebhookOptions.panelFlow`). */
94
+ readonly panelFlow: boolean;
54
95
  readonly outbox: WebhookOutbox;
55
96
  }
56
97
  export interface LeadWebhookOptions extends WebhookOutboxOptions {
@@ -66,6 +107,13 @@ export interface LeadWebhookOptions extends WebhookOutboxOptions {
66
107
  * suspect, for the brand's body to carry.
67
108
  */
68
109
  panelSuspect?: boolean;
110
+ /**
111
+ * The switch for the sale's properties (`ctx.flow`: `flow`, `quoted_cents`,
112
+ * `pricing_valid_from`, `estimate_inputs`). Off (the default) until the
113
+ * panel's `lead.created` accepts them — it refuses an unknown property, and
114
+ * the outbox would park the lead. The leads table keeps them either way.
115
+ */
116
+ panelFlow?: boolean;
69
117
  }
70
118
  /**
71
119
  * The lead webhook, or `null` when it is off: no `LEAD_WEBHOOK_URL`, or no
@@ -2,6 +2,24 @@ import "server-only";
2
2
  import { randomUUID } from "node:crypto";
3
3
  import { suspectOf } from "../core/lead.js";
4
4
  import { openWebhookOutbox } from "./webhook-outbox.js";
5
+ /** The sale `ctx.flow` describes, or nothing for a lead without a flow. */
6
+ export function panelFlowOf(lead) {
7
+ if (!lead.flow)
8
+ return undefined;
9
+ if (lead.flow === "quote" || !lead.price)
10
+ return { flow: "quote" };
11
+ const inputs = lead.flow === "estimate" && lead.price.inputs && Object.keys(lead.price.inputs).length > 0 ? { estimateInputs: lead.price.inputs } : {};
12
+ return { flow: lead.flow, quotedCents: lead.price.cents, pricingValidFrom: lead.price.validFrom, ...inputs };
13
+ }
14
+ export function panelFlowProperties(flow) {
15
+ if (!flow)
16
+ return {};
17
+ return {
18
+ flow: flow.flow,
19
+ ...(flow.quotedCents !== undefined && flow.pricingValidFrom !== undefined ? { quoted_cents: flow.quotedCents, pricing_valid_from: flow.pricingValidFrom } : {}),
20
+ ...(flow.estimateInputs ? { estimate_inputs: flow.estimateInputs } : {}),
21
+ };
22
+ }
5
23
  /**
6
24
  * What the Service-Arb panel's `lead.created` calls a callback request. Its
7
25
  * `properties.channel` is a closed set (`form` | `phone_inbound`) and an event
@@ -20,7 +38,7 @@ export function panelChannel(channel) {
20
38
  * so a broken setting fails startup rather than a lead.
21
39
  */
22
40
  export function leadWebhook(site, env, options) {
23
- const { buildBody, signing, panelSuspect = false, ...outboxOptions } = options;
41
+ const { buildBody, signing, panelSuspect = false, panelFlow = false, ...outboxOptions } = options;
24
42
  if (!env.leadWebhook || !buildBody)
25
43
  return null;
26
44
  if (env.leadsDb.kind !== "sqlite")
@@ -30,9 +48,19 @@ export function leadWebhook(site, env, options) {
30
48
  return {
31
49
  outbox,
32
50
  panelSuspect,
51
+ panelFlow,
33
52
  enqueue(lead, id, meta) {
34
53
  const suspect = panelSuspect ? suspectOf(lead) : undefined;
35
- const ctx = { leadId: id, brandId: site.brand.id, ...meta, at: new Date(now()), idempotencyKey: randomUUID(), ...(suspect ? { suspect } : {}) };
54
+ const flow = panelFlow ? panelFlowOf(lead) : undefined;
55
+ const ctx = {
56
+ leadId: id,
57
+ brandId: site.brand.id,
58
+ ...meta,
59
+ at: new Date(now()),
60
+ idempotencyKey: randomUUID(),
61
+ ...(suspect ? { suspect } : {}),
62
+ ...(flow ? { flow } : {}),
63
+ };
36
64
  const body = JSON.stringify(buildBody(lead, ctx));
37
65
  if (typeof body !== "string")
38
66
  throw new Error("buildWebhookBody returned nothing JSON can carry");
@@ -0,0 +1,25 @@
1
+ import "server-only";
2
+ import type { PricingModel } from "../core/pricing/model.js";
3
+ import type { Site } from "../core/site.js";
4
+ /**
5
+ * The brand's live price list: `GET <base>/pricing`, the whole model with a
6
+ * label in every locale, over the baked one (`site.pricing`). The same
7
+ * failure semantics as the place source, for the same reason — a page that
8
+ * throws under ISR is a bare 500 with no phone: an unreachable source, a
9
+ * non-200, a body that is not JSON, and a model that does not validate all
10
+ * serve the baked model. Taken whole or not at all: a page never prices from
11
+ * half a model. `{}` is the source's "nothing set": the baked model, quietly.
12
+ */
13
+ export interface PricingSourceOptions {
14
+ /** The source's base URL, read when asked; `null` → the baked model only. */
15
+ baseUrl: () => string | null;
16
+ /** TTL on the fetch itself (see `PlaceSourceOptions`); 600 s. */
17
+ revalidateSeconds?: number;
18
+ timeoutMs?: number;
19
+ log?: Pick<Console, "error">;
20
+ }
21
+ export interface PricingSource {
22
+ /** The model to price with now: live, else baked, else `null` (every need is a `quote`). Never throws. */
23
+ model(): Promise<PricingModel | null>;
24
+ }
25
+ export declare function createPricingSource<L extends string, P extends string>(site: Site<L, P>, options: PricingSourceOptions): PricingSource;
@@ -0,0 +1,48 @@
1
+ import "server-only";
2
+ import { parsePricingModel, pricingProblemsFor } from "../core/pricing/validate.js";
3
+ import { PLACE_REVALIDATE_SECONDS } from "./place-source.js";
4
+ const TIMEOUT_MS = 3_000;
5
+ const isEmptyObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v) && Object.keys(v).length === 0;
6
+ export function createPricingSource(site, options) {
7
+ const revalidate = options.revalidateSeconds ?? PLACE_REVALIDATE_SECONDS;
8
+ const timeoutMs = options.timeoutMs ?? TIMEOUT_MS;
9
+ const log = options.log ?? console;
10
+ const baked = site.pricing ?? null;
11
+ async function live(base) {
12
+ const init = { cache: "force-cache", next: { revalidate }, signal: AbortSignal.timeout(timeoutMs) };
13
+ let response;
14
+ try {
15
+ response = await fetch(`${base}/pricing`, init);
16
+ }
17
+ catch (cause) {
18
+ log.error("pricing: source unreachable, pricing from the baked model", cause);
19
+ return baked;
20
+ }
21
+ if (!response.ok) {
22
+ log.error(`pricing: source answered ${response.status}, pricing from the baked model`);
23
+ return baked;
24
+ }
25
+ let body;
26
+ try {
27
+ body = await response.json();
28
+ }
29
+ catch (cause) {
30
+ log.error("pricing: the source's body is not JSON, pricing from the baked model", cause);
31
+ return baked;
32
+ }
33
+ if (isEmptyObject(body))
34
+ return baked;
35
+ const problems = pricingProblemsFor(body, site.i18n.locales);
36
+ if (problems.length > 0) {
37
+ log.error(`pricing: the source's model is refused whole (${problems.slice(0, 5).join("; ")}), pricing from the baked model`);
38
+ return baked;
39
+ }
40
+ return parsePricingModel(body);
41
+ }
42
+ return {
43
+ async model() {
44
+ const base = options.baseUrl();
45
+ return base ? live(base) : baked;
46
+ },
47
+ };
48
+ }
@@ -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 () => {
@@ -55,10 +58,14 @@ export function describeLeadStoreContract(name, harness) {
55
58
  lead: lead({ submissionId: sid, channel: "callback", consent: { text: "Oui.", at: "2026-10-03T10:00:00.000Z" }, extras: { name: "Ana" } }),
56
59
  });
57
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" } });
58
65
  // Leads without one (the no-JS post) never collide.
59
66
  await store.insert(lead());
60
67
  await store.insert(lead());
61
- expect(await store.count()).toBe(3);
68
+ expect(await store.count()).toBe(4);
62
69
  await store.close();
63
70
  });
64
71
  it("keeps what it acknowledged across a close and a reopen", async () => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@evinvest/kitstart",
3
- "version": "0.9.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": {
@@ -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.9.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.9.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,8 +1,17 @@
1
- import { validateLead, 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
17
  * way to reach the customer is not a lead. `validateLead` is the number rule
@@ -17,4 +26,5 @@ export const LEAD: LeadSchema<Subject> = {
17
26
  // One shape for the mail, the webhook and a dialler: `+33612345678`.
18
27
  mobileFormat: "e164",
19
28
  validate: validateLead,
29
+ flows: FLOWS,
20
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");