@evinvest/kitstart 0.10.0 → 0.12.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 (84) hide show
  1. package/README.md +210 -17
  2. package/dist/core/accept.d.ts +19 -1
  3. package/dist/core/accept.js +25 -2
  4. package/dist/core/analytics.d.ts +13 -3
  5. package/dist/core/analytics.js +17 -2
  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-text.d.ts +26 -2
  15. package/dist/core/lead-capture-text.js +32 -2
  16. package/dist/core/lead.d.ts +33 -0
  17. package/dist/core/lead.js +43 -0
  18. package/dist/core/phone.d.ts +3 -1
  19. package/dist/core/phone.js +28 -2
  20. package/dist/core/place/live.d.ts +6 -2
  21. package/dist/core/place/live.js +21 -2
  22. package/dist/core/place/types.d.ts +6 -0
  23. package/dist/core/place/types.js +0 -13
  24. package/dist/core/routing.d.ts +3 -2
  25. package/dist/core/routing.js +4 -3
  26. package/dist/core/site.d.ts +8 -0
  27. package/dist/core/site.js +15 -0
  28. package/dist/index.d.ts +4 -3
  29. package/dist/index.js +4 -3
  30. package/dist/next/booking-route.d.ts +31 -0
  31. package/dist/next/booking-route.js +97 -0
  32. package/dist/next/confirm-route.d.ts +39 -0
  33. package/dist/next/confirm-route.js +118 -0
  34. package/dist/next/index.d.ts +2 -0
  35. package/dist/next/index.js +2 -0
  36. package/dist/next/quote-route.js +14 -5
  37. package/dist/react/AnalyticsBoundary.js +8 -4
  38. package/dist/react/LeadBooking.d.ts +35 -0
  39. package/dist/react/LeadBooking.js +47 -0
  40. package/dist/react/LeadBookingManual.d.ts +17 -0
  41. package/dist/react/LeadBookingManual.js +50 -0
  42. package/dist/react/LeadCapture.d.ts +28 -5
  43. package/dist/react/LeadCapture.js +22 -8
  44. package/dist/react/LeadCaptureFields.js +60 -21
  45. package/dist/react/LeadCapturePriced.d.ts +3 -4
  46. package/dist/react/LeadCapturePriced.js +4 -4
  47. package/dist/react/analytics-context.d.ts +7 -0
  48. package/dist/react/analytics-context.js +7 -0
  49. package/dist/react/booking-adapters.d.ts +58 -0
  50. package/dist/react/booking-adapters.js +104 -0
  51. package/dist/react/index.d.ts +5 -1
  52. package/dist/react/index.js +4 -1
  53. package/dist/react/use-booking.d.ts +20 -0
  54. package/dist/react/use-booking.js +32 -0
  55. package/dist/react/use-lead-error.d.ts +3 -7
  56. package/dist/react/use-lead-error.js +9 -6
  57. package/dist/react/use-lead-submit.d.ts +7 -1
  58. package/dist/react/use-lead-submit.js +13 -3
  59. package/dist/react/use-repriced.d.ts +18 -0
  60. package/dist/react/use-repriced.js +27 -0
  61. package/dist/server/experiments-declared.d.ts +106 -0
  62. package/dist/server/experiments-declared.js +124 -0
  63. package/dist/server/experiments-source.d.ts +49 -0
  64. package/dist/server/experiments-source.js +88 -0
  65. package/dist/server/index.d.ts +4 -2
  66. package/dist/server/index.js +2 -0
  67. package/dist/server/lead-webhook.d.ts +63 -0
  68. package/dist/server/lead-webhook.js +24 -1
  69. package/dist/server/notify.d.ts +20 -2
  70. package/dist/server/notify.js +6 -4
  71. package/dist/server/place-source.js +1 -1
  72. package/dist/server/webhook-outbox.d.ts +11 -1
  73. package/dist/server/webhook-outbox.js +32 -5
  74. package/dist/testing/lead-rules.js +15 -1
  75. package/package.json +1 -1
  76. package/template/app/quote/booking/route.ts +11 -0
  77. package/template/app/quote/confirm/route.ts +12 -0
  78. package/template/flake.nix +1 -1
  79. package/template/package.json +2 -2
  80. package/template/src/shared/config/places.ts +4 -0
  81. package/template/src/shared/config/site.ts +3 -0
  82. package/template/tests/e2e/flows.spec.ts +86 -9
  83. package/template/tests/e2e/mock-panel.mjs +18 -4
  84. package/template/tests/e2e/quote-form.spec.ts +2 -2
@@ -1,8 +1,12 @@
1
1
  import { expect, test, type Page } from "@playwright/test";
2
+ import { PRICING } from "../../src/shared/config/pricing";
2
3
 
3
4
  // The form variants: `recurring` is an estimate, priced live from the baked
4
5
  // 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
+ // 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.
6
10
 
7
11
  const hydrated = (page: Page) => expect(page.locator("#quote select")).toHaveCount(0);
8
12
 
@@ -22,7 +26,18 @@ async function contact(page: Page, locale: "fr" | "en") {
22
26
  await form(page).getByLabel(locale === "fr" ? "Téléphone" : "Phone").fill("0612345678");
23
27
  }
24
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
+
25
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
+ };
26
41
 
27
42
  test("an estimate prices the answers live, from the baked model while the panel's is down", async ({ page }) => {
28
43
  await pickRecurring(page, "fr");
@@ -38,7 +53,7 @@ test("an estimate prices the answers live, from the baked model while the panel'
38
53
  await expect(form(page).getByRole("button", { name: "Réserver" })).toBeVisible();
39
54
  });
40
55
 
41
- test("the server prices the posted answers itself: a posted amount is ignored", async ({ page }) => {
56
+ test("the server prices the posted answers itself, and takes a lead only at the price shown", async ({ page }) => {
42
57
  await pickRecurring(page, "fr");
43
58
  await answer(page, "2 chambres");
44
59
  await answer(page, "40 à 70 m²");
@@ -57,16 +72,50 @@ test("the server prices the posted answers itself: a posted amount is ignored",
57
72
  const t3 = el.querySelector<HTMLInputElement>("input[name=estimate_bedrooms][value=t3]");
58
73
  if (t3) t3.value = "t4";
59
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
+ });
60
86
  const answered = quote(page);
61
87
  await form(page).getByRole("button", { name: "Réserver" }).click();
62
- // 45 € + 45 € + 10 € = 100 €, less 10 % = 90 €.
63
88
  expect(await (await answered).json()).toMatchObject({ ok: true, cents: 9000 });
64
89
  await expect(page.getByRole("status")).toContainText(/Demande enregistrée au prix de 90\s€\./);
65
90
  });
66
91
 
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())));
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>" }));
70
119
  await pickRecurring(page, "fr");
71
120
  await answer(page, "Studio");
72
121
  await answer(page, "Moins de 40 m²");
@@ -74,17 +123,34 @@ test("a priced lead confirms the server's price and promises a call to set the s
74
123
  await contact(page, "fr");
75
124
  const answered = quote(page);
76
125
  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}$/) });
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}$/) });
78
128
  const status = page.getByRole("status");
79
129
  // 45 €, under the 49 € minimum.
80
130
  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
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.");
83
137
  await expect(page.locator("iframe")).toHaveCount(0);
84
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);
85
150
  });
86
151
 
87
- test("in English too", async ({ page }) => {
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);
88
154
  await pickRecurring(page, "en");
89
155
  await answer(page, "1 bedroom");
90
156
  await answer(page, "Over 70 m²");
@@ -96,6 +162,17 @@ test("in English too", async ({ page }) => {
96
162
  const status = page.getByRole("status");
97
163
  await expect(status).toContainText("Request saved at €72.");
98
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([]);
99
176
  });
100
177
 
101
178
  test("a quote need is the form it always was", async ({ page }) => {
@@ -1,6 +1,9 @@
1
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.
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.
4
7
  import { createServer } from "node:http";
5
8
 
6
9
  const port = Number(process.env["E2E_MOCK_PORT"]);
@@ -9,12 +12,23 @@ const json = (res, status, body) => {
9
12
  res.end(JSON.stringify(body));
10
13
  };
11
14
 
15
+ let pricing = null;
16
+
12
17
  createServer((req, res) => {
13
18
  const url = new URL(req.url ?? "/", "http://mock");
14
19
  if (url.pathname === "/health") return json(res, 200, { ok: true });
15
- if (url.pathname === "/pricing") return json(res, 503, { error: "down" });
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" });
16
30
  if (url.pathname === "/locations/paris") {
17
- return json(res, 200, {});
31
+ return json(res, 200, url.searchParams.get("locale") === "fr" ? { booking: { default: "google_calendar", providers: { google_calendar: { url: "https://calendar.app.google/BrandMenage" } } } } : {});
18
32
  }
19
33
  return json(res, 404, { error: "no such route" });
20
34
  }).listen(port, "127.0.0.1");
@@ -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);