@evinvest/kitstart 0.9.0 → 0.11.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 (110) hide show
  1. package/README.md +237 -12
  2. package/dist/core/accept.d.ts +35 -2
  3. package/dist/core/accept.js +74 -5
  4. package/dist/core/analytics.d.ts +4 -1
  5. package/dist/core/analytics.js +13 -0
  6. package/dist/core/booking/index.d.ts +3 -0
  7. package/dist/core/booking/index.js +3 -0
  8. package/dist/core/booking/model.d.ts +106 -0
  9. package/dist/core/booking/model.js +66 -0
  10. package/dist/core/booking/url.d.ts +17 -0
  11. package/dist/core/booking/url.js +53 -0
  12. package/dist/core/booking/validate.d.ts +27 -0
  13. package/dist/core/booking/validate.js +219 -0
  14. package/dist/core/lead-capture-format.d.ts +6 -0
  15. package/dist/core/lead-capture-format.js +13 -0
  16. package/dist/core/lead-capture-text.d.ts +57 -2
  17. package/dist/core/lead-capture-text.js +75 -2
  18. package/dist/core/lead.d.ts +65 -3
  19. package/dist/core/lead.js +46 -2
  20. package/dist/core/phone.d.ts +3 -1
  21. package/dist/core/phone.js +28 -2
  22. package/dist/core/place/live.d.ts +6 -2
  23. package/dist/core/place/live.js +21 -2
  24. package/dist/core/place/types.d.ts +6 -0
  25. package/dist/core/place/types.js +0 -13
  26. package/dist/core/pricing/flow.d.ts +27 -0
  27. package/dist/core/pricing/flow.js +37 -0
  28. package/dist/core/pricing/index.d.ts +4 -0
  29. package/dist/core/pricing/index.js +4 -0
  30. package/dist/core/pricing/model.d.ts +129 -0
  31. package/dist/core/pricing/model.js +34 -0
  32. package/dist/core/pricing/price.d.ts +34 -0
  33. package/dist/core/pricing/price.js +105 -0
  34. package/dist/core/pricing/validate.d.ts +27 -0
  35. package/dist/core/pricing/validate.js +244 -0
  36. package/dist/core/routing.d.ts +3 -2
  37. package/dist/core/routing.js +4 -3
  38. package/dist/core/site.d.ts +15 -0
  39. package/dist/core/site.js +26 -0
  40. package/dist/index.d.ts +6 -4
  41. package/dist/index.js +6 -4
  42. package/dist/next/booking-route.d.ts +31 -0
  43. package/dist/next/booking-route.js +97 -0
  44. package/dist/next/confirm-route.d.ts +39 -0
  45. package/dist/next/confirm-route.js +118 -0
  46. package/dist/next/index.d.ts +2 -0
  47. package/dist/next/index.js +2 -0
  48. package/dist/next/quote-route.d.ts +8 -1
  49. package/dist/next/quote-route.js +17 -6
  50. package/dist/react/LeadBooking.d.ts +35 -0
  51. package/dist/react/LeadBooking.js +47 -0
  52. package/dist/react/LeadBookingManual.d.ts +17 -0
  53. package/dist/react/LeadBookingManual.js +50 -0
  54. package/dist/react/LeadCapture.d.ts +54 -3
  55. package/dist/react/LeadCapture.js +37 -10
  56. package/dist/react/LeadCaptureChannels.d.ts +14 -2
  57. package/dist/react/LeadCaptureChannels.js +13 -0
  58. package/dist/react/LeadCaptureEstimate.d.ts +46 -0
  59. package/dist/react/LeadCaptureEstimate.js +69 -0
  60. package/dist/react/LeadCaptureFields.js +60 -21
  61. package/dist/react/LeadCapturePriced.d.ts +18 -0
  62. package/dist/react/LeadCapturePriced.js +14 -0
  63. package/dist/react/booking-adapters.d.ts +58 -0
  64. package/dist/react/booking-adapters.js +104 -0
  65. package/dist/react/index.d.ts +5 -1
  66. package/dist/react/index.js +4 -1
  67. package/dist/react/use-booking.d.ts +20 -0
  68. package/dist/react/use-booking.js +32 -0
  69. package/dist/react/use-lead-error.d.ts +3 -7
  70. package/dist/react/use-lead-error.js +9 -6
  71. package/dist/react/use-lead-events.d.ts +2 -0
  72. package/dist/react/use-lead-events.js +10 -0
  73. package/dist/react/use-lead-submit.d.ts +14 -4
  74. package/dist/react/use-lead-submit.js +19 -9
  75. package/dist/react/use-repriced.d.ts +18 -0
  76. package/dist/react/use-repriced.js +27 -0
  77. package/dist/server/index.d.ts +3 -2
  78. package/dist/server/index.js +2 -1
  79. package/dist/server/lead-store-sqlite.js +31 -5
  80. package/dist/server/lead-webhook.d.ts +94 -0
  81. package/dist/server/lead-webhook.js +46 -2
  82. package/dist/server/notify.d.ts +20 -2
  83. package/dist/server/notify.js +6 -4
  84. package/dist/server/place-source.js +1 -1
  85. package/dist/server/pricing-source.d.ts +25 -0
  86. package/dist/server/pricing-source.js +48 -0
  87. package/dist/server/webhook-outbox.d.ts +11 -1
  88. package/dist/server/webhook-outbox.js +32 -5
  89. package/dist/testing/lead-rules.js +15 -1
  90. package/dist/testing/lead-store-contract.js +9 -2
  91. package/package.json +1 -1
  92. package/template/app/[locale]/[location]/page.tsx +4 -2
  93. package/template/app/quote/booking/route.ts +11 -0
  94. package/template/app/quote/confirm/route.ts +12 -0
  95. package/template/app/quote/route.ts +2 -1
  96. package/template/flake.nix +1 -1
  97. package/template/package.json +2 -2
  98. package/template/src/entities/content/model/en.ts +1 -1
  99. package/template/src/entities/content/model/fr.ts +1 -1
  100. package/template/src/shared/config/env.ts +4 -1
  101. package/template/src/shared/config/lead.ts +12 -2
  102. package/template/src/shared/config/places.ts +4 -0
  103. package/template/src/shared/config/pricing.ts +49 -0
  104. package/template/src/shared/config/site.ts +6 -0
  105. package/template/src/views/home/ui/PlaceHome.tsx +6 -3
  106. package/template/tests/bundle_budget.txt +3 -1
  107. package/template/tests/e2e/flows.spec.ts +183 -0
  108. package/template/tests/e2e/mock-panel.mjs +34 -0
  109. package/template/tests/e2e/playwright.config.ts +25 -9
  110. package/template/tests/e2e/quote-form.spec.ts +2 -2
@@ -0,0 +1,183 @@
1
+ import { expect, test, type Page } from "@playwright/test";
2
+ import { PRICING } from "../../src/shared/config/pricing";
3
+
4
+ // The form variants: `recurring` is an estimate, priced live from the baked
5
+ // model (the mock panel's price list is down) and stored at the server's own
6
+ // price — the price shown, or the lead is sent back to confirm a fresh one.
7
+ // The slot is then booked on the Google appointment schedule the panel set
8
+ // for the place (the French page), or — the default, `manual` (the English
9
+ // one) — promised by a call with an optional preference.
10
+
11
+ const hydrated = (page: Page) => expect(page.locator("#quote select")).toHaveCount(0);
12
+
13
+ async function pickRecurring(page: Page, locale: "fr" | "en") {
14
+ await page.goto(`/${locale}`);
15
+ await hydrated(page);
16
+ await page.getByRole("combobox", { name: locale === "fr" ? "Prestation" : "Service" }).click();
17
+ await page.getByRole("listbox").getByRole("option", { name: locale === "fr" ? "Ménage régulier" : "Recurring cleaning" }).click();
18
+ }
19
+
20
+ const form = (page: Page) => page.locator("#quote-form");
21
+ const answer = (page: Page, label: string) => form(page).getByRole("radio", { name: label, exact: true }).check();
22
+ const price = (page: Page) => form(page).locator("[data-price-cents]");
23
+
24
+ async function contact(page: Page, locale: "fr" | "en") {
25
+ await form(page).getByLabel(locale === "fr" ? "Code postal" : "Postcode").fill("75011");
26
+ await form(page).getByLabel(locale === "fr" ? "Téléphone" : "Phone").fill("0612345678");
27
+ }
28
+
29
+ /** The template's list as the panel serves it after a change: no monthly discount, nothing else. */
30
+ const LIVE_PRICING = {
31
+ ...PRICING,
32
+ inputs: PRICING.inputs.map(i => (i.id === "frequency" ? { ...i, options: i.options.map(o => (o.id === "monthly" ? { ...o, discountBp: 0 } : o)) } : i)),
33
+ };
34
+
35
+ const quote = (page: Page) => page.waitForResponse(r => r.request().method() === "POST" && new URL(r.url()).pathname === "/quote");
36
+ const outsideRequests = (page: Page): string[] => {
37
+ const outside: string[] = [];
38
+ page.on("request", r => void (new URL(r.url()).hostname !== "localhost" && outside.push(r.url())));
39
+ return outside;
40
+ };
41
+
42
+ test("an estimate prices the answers live, from the baked model while the panel's is down", async ({ page }) => {
43
+ await pickRecurring(page, "fr");
44
+ await expect(form(page).getByText("Répondez aux questions pour voir le prix.")).toBeVisible();
45
+ await expect(price(page)).toHaveCount(0);
46
+ await answer(page, "2 chambres");
47
+ await answer(page, "40 à 70 m²");
48
+ await answer(page, "Toutes les 2 semaines");
49
+ // 45 € + 30 € + 10 € = 85 €, less 10 % = 76,50 €, to the euro: 77 €.
50
+ await expect(price(page)).toHaveAttribute("data-price-cents", "7700");
51
+ await answer(page, "Une fois");
52
+ await expect(price(page)).toHaveAttribute("data-price-cents", "8500");
53
+ await expect(form(page).getByRole("button", { name: "Réserver" })).toBeVisible();
54
+ });
55
+
56
+ test("the server prices the posted answers itself, and takes a lead only at the price shown", async ({ page }) => {
57
+ await pickRecurring(page, "fr");
58
+ await answer(page, "2 chambres");
59
+ await answer(page, "40 à 70 m²");
60
+ await answer(page, "Toutes les 2 semaines");
61
+ await expect(price(page)).toHaveAttribute("data-price-cents", "7700");
62
+ await contact(page, "fr");
63
+ // A tampered page: a price of its own, and an answer the screen did not price.
64
+ await form(page).evaluate(el => {
65
+ for (const name of ["quoted_cents", "cents", "price"]) {
66
+ const input = document.createElement("input");
67
+ input.type = "hidden";
68
+ input.name = name;
69
+ input.value = "1";
70
+ el.append(input);
71
+ }
72
+ const t3 = el.querySelector<HTMLInputElement>("input[name=estimate_bedrooms][value=t3]");
73
+ if (t3) t3.value = "t4";
74
+ });
75
+ const refused = quote(page);
76
+ await form(page).getByRole("button", { name: "Réserver" }).click();
77
+ // 45 € + 45 € + 10 € = 100 €, less 10 % = 90 € — not the 77 € shown: confirmed first.
78
+ expect(await (await refused).json()).toEqual({ ok: false, field: "price_changed", reason: "price_changed", cents: 9000 });
79
+ await expect(form(page)).toContainText(/Le prix a changé : 90\s€ au lieu de 77\s€\./);
80
+ await expect(price(page)).toHaveAttribute("data-price-cents", "9000");
81
+ // The re-render put the radio's value back; the tampered answer again, then the confirm.
82
+ await form(page).evaluate(el => {
83
+ const t3 = el.querySelector<HTMLInputElement>("input[name=estimate_bedrooms][value=t3]");
84
+ if (t3) t3.value = "t4";
85
+ });
86
+ const answered = quote(page);
87
+ await form(page).getByRole("button", { name: "Réserver" }).click();
88
+ expect(await (await answered).json()).toMatchObject({ ok: true, cents: 9000 });
89
+ await expect(page.getByRole("status")).toContainText(/Demande enregistrée au prix de 90\s€\./);
90
+ });
91
+
92
+ // Live retest 2026-10-04: shown 77 €, the tariff changed in the panel, taken at 86 € without a word.
93
+ test("a tariff changed under an open page is shown and confirmed, never recorded silently", async ({ page, request }) => {
94
+ await pickRecurring(page, "fr");
95
+ await answer(page, "1 chambre");
96
+ await answer(page, "Moins de 40 m²");
97
+ await answer(page, "Chaque mois");
98
+ // 45 € + 15 € = 60 €, less 5 % = 57 €.
99
+ await expect(price(page)).toHaveAttribute("data-price-cents", "5700");
100
+ await contact(page, "fr");
101
+ // The panel serves its own list from now on. Only the monthly discount
102
+ // differs, so the other tests, sharing the server, price as before.
103
+ await request.post(`http://127.0.0.1:${process.env["E2E_MOCK_PORT"]}/_e2e/pricing`, { data: LIVE_PRICING });
104
+ const refused = quote(page);
105
+ await form(page).getByRole("button", { name: "Réserver" }).click();
106
+ expect(await (await refused).json()).toMatchObject({ ok: false, reason: "price_changed", cents: 6000 });
107
+ await expect(page.getByRole("status")).toHaveCount(0);
108
+ await expect(form(page)).toContainText(/Le prix a changé : 60\s€ au lieu de 57\s€\./);
109
+ const answered = quote(page);
110
+ await form(page).getByRole("button", { name: "Réserver" }).click();
111
+ expect(await (await answered).json()).toMatchObject({ ok: true, cents: 6000 });
112
+ await expect(page.getByRole("status")).toContainText(/Demande enregistrée au prix de 60\s€\./);
113
+ });
114
+
115
+ test("a priced lead offers the place's Google schedule, loading nothing of Google's before the click", async ({ page, context }) => {
116
+ const outside = outsideRequests(page);
117
+ // The schedule itself is not under test: answered here, never fetched.
118
+ await context.route("https://calendar.app.google/**", route => route.fulfill({ status: 200, contentType: "text/html", body: "<title>Google</title>" }));
119
+ await pickRecurring(page, "fr");
120
+ await answer(page, "Studio");
121
+ await answer(page, "Moins de 40 m²");
122
+ await answer(page, "Une fois");
123
+ await contact(page, "fr");
124
+ const answered = quote(page);
125
+ await form(page).getByRole("button", { name: "Réserver" }).click();
126
+ const lead = (await (await answered).json()) as { lead: string };
127
+ expect(lead).toMatchObject({ ok: true, cents: 4900, lead: expect.stringMatching(/^lead-\d+-[0-9a-f]{8}$/) });
128
+ const status = page.getByRole("status");
129
+ // 45 €, under the 49 € minimum.
130
+ await expect(status).toContainText(/Demande enregistrée au prix de 49\s€\./);
131
+ await expect(status).toBeFocused();
132
+ // A schedule takes no parameter: opened as is, and the visitor types the same number there.
133
+ const book = status.getByRole("link", { name: "Choisir un créneau" });
134
+ await expect(book).toHaveAttribute("href", "https://calendar.app.google/BrandMenage");
135
+ await expect(status).toContainText("Indiquez le même numéro de téléphone en réservant");
136
+ await expect(status).toContainText("Sinon, nous vous rappelons pour fixer le créneau.");
137
+ await expect(page.locator("iframe")).toHaveCount(0);
138
+ expect(outside).toEqual([]);
139
+
140
+ const requested = page.waitForResponse(r => new URL(r.url()).pathname === "/quote/booking");
141
+ const popup = page.waitForEvent("popup");
142
+ await book.click();
143
+ const tab = await popup;
144
+ await tab.waitForLoadState();
145
+ expect(tab.url()).toBe("https://calendar.app.google/BrandMenage");
146
+ const res = await requested;
147
+ expect(JSON.parse(res.request().postData() ?? "{}")).toMatchObject({ lead_ref: lead.lead, provider: "google_calendar" });
148
+ // Taken; with no lead webhook in the template, `panelBooking` is off and nothing is queued (unit-tested).
149
+ expect(res.status()).toBe(200);
150
+ });
151
+
152
+ test("in English, where the place has no booking of its own: a call, an optional preference, no third party", async ({ page }) => {
153
+ const outside = outsideRequests(page);
154
+ await pickRecurring(page, "en");
155
+ await answer(page, "1 bedroom");
156
+ await answer(page, "Over 70 m²");
157
+ await answer(page, "Every week");
158
+ // 45 € + 15 € + 25 € = 85 €, less 15 % = 72,25 €, to the euro: 72 €.
159
+ await expect(price(page)).toHaveAttribute("data-price-cents", "7200");
160
+ await contact(page, "en");
161
+ await form(page).getByRole("button", { name: "Book" }).click();
162
+ const status = page.getByRole("status");
163
+ await expect(status).toContainText("Request saved at €72.");
164
+ await expect(status).toContainText("We will call you to set the slot.");
165
+ await expect(status.getByRole("link")).toHaveCount(0);
166
+ const send = status.getByRole("button", { name: "Send my preference" });
167
+ await expect(send).toBeDisabled();
168
+ await status.getByRole("radio", { name: "Evening", exact: true }).check();
169
+ const requested = page.waitForResponse(r => new URL(r.url()).pathname === "/quote/booking");
170
+ await send.click();
171
+ const res = await requested;
172
+ expect(JSON.parse(res.request().postData() ?? "{}")).toMatchObject({ provider: "manual", preferred_part: "evening", lead_ref: expect.stringMatching(/^lead-/) });
173
+ expect(res.status()).toBe(200);
174
+ await expect(status).toContainText("Noted: we will keep it in mind when we call.");
175
+ expect(outside).toEqual([]);
176
+ });
177
+
178
+ test("a quote need is the form it always was", async ({ page }) => {
179
+ await page.goto("/fr");
180
+ await hydrated(page);
181
+ await expect(form(page).locator("input[name^=estimate_]")).toHaveCount(0);
182
+ await expect(form(page).getByRole("button", { name: "Recevoir le prix" })).toBeVisible();
183
+ });
@@ -0,0 +1,34 @@
1
+ // The panel's internal endpoints as the e2e suite needs them, on
2
+ // `E2E_MOCK_PORT`: the place's live half — in French the panel set the
3
+ // place's booking to a Google appointment schedule; in English nothing over
4
+ // the baked place, whose booking is a call (`manual`) — and a price list that is down, so the page prices from the baked model,
5
+ // until a test serves one (`POST /_e2e/pricing`): the panel changing the
6
+ // tariff under an open page.
7
+ import { createServer } from "node:http";
8
+
9
+ const port = Number(process.env["E2E_MOCK_PORT"]);
10
+ const json = (res, status, body) => {
11
+ res.writeHead(status, { "content-type": "application/json" });
12
+ res.end(JSON.stringify(body));
13
+ };
14
+
15
+ let pricing = null;
16
+
17
+ createServer((req, res) => {
18
+ const url = new URL(req.url ?? "/", "http://mock");
19
+ if (url.pathname === "/health") return json(res, 200, { ok: true });
20
+ if (url.pathname === "/_e2e/pricing" && req.method === "POST") {
21
+ let body = "";
22
+ req.on("data", chunk => (body += chunk));
23
+ req.on("end", () => {
24
+ pricing = JSON.parse(body);
25
+ json(res, 200, { ok: true });
26
+ });
27
+ return;
28
+ }
29
+ if (url.pathname === "/pricing") return pricing ? json(res, 200, pricing) : json(res, 503, { error: "down" });
30
+ if (url.pathname === "/locations/paris") {
31
+ return json(res, 200, url.searchParams.get("locale") === "fr" ? { booking: { default: "google_calendar", providers: { google_calendar: { url: "https://calendar.app.google/BrandMenage" } } } } : {});
32
+ }
33
+ return json(res, 404, { error: "no such route" });
34
+ }).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
  });
@@ -221,7 +221,7 @@ test.describe("a refused number, without JavaScript", () => {
221
221
  await form.getByLabel("Code postal").fill("75011");
222
222
  await form.getByLabel("Téléphone").fill("06 12 34 56 7");
223
223
  await form.getByRole("button", { name: "Recevoir le prix" }).click();
224
- await expect(page).toHaveURL(/\/fr\?lead_error=phone&need=standard#quote$/);
224
+ await expect(page).toHaveURL(/\/fr\?lead_error=phone&need=standard&lead_card=quote#quote$/);
225
225
  });
226
226
  });
227
227
 
@@ -286,7 +286,7 @@ test("offline, the form says so, keeps what was typed and sends it on retry", as
286
286
  await context.setOffline(true);
287
287
  await form.getByRole("button", { name: "Recevoir le prix" }).click();
288
288
  const alert = form.getByRole("alert");
289
- await expect(alert).toContainText("Pas de connexion");
289
+ await expect(alert).toContainText("Pas de réponse du serveur");
290
290
  await expect(page).toHaveURL(/\/fr$/);
291
291
  await expect(form.getByLabel("Téléphone")).toHaveValue("0612345678");
292
292
  await context.setOffline(false);