@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.
- package/README.md +210 -17
- package/dist/core/accept.d.ts +19 -1
- package/dist/core/accept.js +25 -2
- package/dist/core/analytics.d.ts +13 -3
- package/dist/core/analytics.js +17 -2
- package/dist/core/booking/index.d.ts +3 -0
- package/dist/core/booking/index.js +3 -0
- package/dist/core/booking/model.d.ts +106 -0
- package/dist/core/booking/model.js +66 -0
- package/dist/core/booking/url.d.ts +17 -0
- package/dist/core/booking/url.js +53 -0
- package/dist/core/booking/validate.d.ts +27 -0
- package/dist/core/booking/validate.js +219 -0
- package/dist/core/lead-capture-text.d.ts +26 -2
- package/dist/core/lead-capture-text.js +32 -2
- package/dist/core/lead.d.ts +33 -0
- package/dist/core/lead.js +43 -0
- package/dist/core/phone.d.ts +3 -1
- package/dist/core/phone.js +28 -2
- package/dist/core/place/live.d.ts +6 -2
- package/dist/core/place/live.js +21 -2
- package/dist/core/place/types.d.ts +6 -0
- package/dist/core/place/types.js +0 -13
- package/dist/core/routing.d.ts +3 -2
- package/dist/core/routing.js +4 -3
- package/dist/core/site.d.ts +8 -0
- package/dist/core/site.js +15 -0
- package/dist/index.d.ts +4 -3
- package/dist/index.js +4 -3
- package/dist/next/booking-route.d.ts +31 -0
- package/dist/next/booking-route.js +97 -0
- package/dist/next/confirm-route.d.ts +39 -0
- package/dist/next/confirm-route.js +118 -0
- package/dist/next/index.d.ts +2 -0
- package/dist/next/index.js +2 -0
- package/dist/next/quote-route.js +14 -5
- package/dist/react/AnalyticsBoundary.js +8 -4
- package/dist/react/LeadBooking.d.ts +35 -0
- package/dist/react/LeadBooking.js +47 -0
- package/dist/react/LeadBookingManual.d.ts +17 -0
- package/dist/react/LeadBookingManual.js +50 -0
- package/dist/react/LeadCapture.d.ts +28 -5
- package/dist/react/LeadCapture.js +22 -8
- package/dist/react/LeadCaptureFields.js +60 -21
- package/dist/react/LeadCapturePriced.d.ts +3 -4
- package/dist/react/LeadCapturePriced.js +4 -4
- package/dist/react/analytics-context.d.ts +7 -0
- package/dist/react/analytics-context.js +7 -0
- package/dist/react/booking-adapters.d.ts +58 -0
- package/dist/react/booking-adapters.js +104 -0
- package/dist/react/index.d.ts +5 -1
- package/dist/react/index.js +4 -1
- package/dist/react/use-booking.d.ts +20 -0
- package/dist/react/use-booking.js +32 -0
- package/dist/react/use-lead-error.d.ts +3 -7
- package/dist/react/use-lead-error.js +9 -6
- package/dist/react/use-lead-submit.d.ts +7 -1
- package/dist/react/use-lead-submit.js +13 -3
- package/dist/react/use-repriced.d.ts +18 -0
- package/dist/react/use-repriced.js +27 -0
- package/dist/server/experiments-declared.d.ts +106 -0
- package/dist/server/experiments-declared.js +124 -0
- package/dist/server/experiments-source.d.ts +49 -0
- package/dist/server/experiments-source.js +88 -0
- package/dist/server/index.d.ts +4 -2
- package/dist/server/index.js +2 -0
- package/dist/server/lead-webhook.d.ts +63 -0
- package/dist/server/lead-webhook.js +24 -1
- package/dist/server/notify.d.ts +20 -2
- package/dist/server/notify.js +6 -4
- package/dist/server/place-source.js +1 -1
- package/dist/server/webhook-outbox.d.ts +11 -1
- package/dist/server/webhook-outbox.js +32 -5
- package/dist/testing/lead-rules.js +15 -1
- package/package.json +1 -1
- package/template/app/quote/booking/route.ts +11 -0
- package/template/app/quote/confirm/route.ts +12 -0
- package/template/flake.nix +1 -1
- package/template/package.json +2 -2
- package/template/src/shared/config/places.ts +4 -0
- package/template/src/shared/config/site.ts +3 -0
- package/template/tests/e2e/flows.spec.ts +86 -9
- package/template/tests/e2e/mock-panel.mjs +18 -4
- 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
|
|
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
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
page
|
|
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
|
-
|
|
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
|
|
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
|
|
3
|
-
//
|
|
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"
|
|
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
|
|
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);
|