@evinvest/kitstart 0.5.0 → 0.7.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 (67) hide show
  1. package/README.md +122 -3
  2. package/dist/cli/size.d.ts +26 -4
  3. package/dist/cli/size.js +54 -16
  4. package/dist/core/accept.d.ts +9 -1
  5. package/dist/core/accept.js +13 -2
  6. package/dist/core/analytics.d.ts +22 -3
  7. package/dist/core/analytics.js +27 -2
  8. package/dist/core/channels.d.ts +47 -0
  9. package/dist/core/channels.js +51 -0
  10. package/dist/core/lead-capture-format.d.ts +6 -0
  11. package/dist/core/lead-capture-format.js +32 -0
  12. package/dist/core/lead-capture-text.d.ts +58 -0
  13. package/dist/core/lead-capture-text.js +57 -0
  14. package/dist/core/lead.d.ts +59 -4
  15. package/dist/core/lead.js +38 -4
  16. package/dist/core/phone.d.ts +19 -0
  17. package/dist/core/phone.js +39 -0
  18. package/dist/core/place/hours.d.ts +15 -0
  19. package/dist/core/place/hours.js +68 -0
  20. package/dist/core/place/index.d.ts +1 -0
  21. package/dist/core/place/index.js +1 -0
  22. package/dist/index.d.ts +8 -4
  23. package/dist/index.js +8 -4
  24. package/dist/next/quote-route.d.ts +1 -1
  25. package/dist/next/quote-route.js +4 -1
  26. package/dist/react/AnalyticsBoundary.js +11 -5
  27. package/dist/react/CallBar.d.ts +17 -2
  28. package/dist/react/CallBar.js +26 -3
  29. package/dist/react/LeadCapture.d.ts +80 -0
  30. package/dist/react/LeadCapture.js +80 -0
  31. package/dist/react/LeadCaptureChannels.d.ts +64 -0
  32. package/dist/react/LeadCaptureChannels.js +45 -0
  33. package/dist/react/LeadCaptureFields.d.ts +35 -0
  34. package/dist/react/LeadCaptureFields.js +48 -0
  35. package/dist/react/LeadCaptureNeed.d.ts +27 -0
  36. package/dist/react/LeadCaptureNeed.js +22 -0
  37. package/dist/react/QuoteFormShell.d.ts +3 -1
  38. package/dist/react/QuoteFormShell.js +2 -2
  39. package/dist/react/analytics-context.d.ts +11 -0
  40. package/dist/react/analytics-context.js +15 -0
  41. package/dist/react/index.d.ts +4 -1
  42. package/dist/react/index.js +2 -1
  43. package/dist/react/use-lead-context.d.ts +24 -0
  44. package/dist/react/use-lead-context.js +71 -0
  45. package/dist/react/use-lead-events.d.ts +23 -0
  46. package/dist/react/use-lead-events.js +62 -0
  47. package/dist/react/use-lead-submit.d.ts +21 -0
  48. package/dist/react/use-lead-submit.js +42 -0
  49. package/dist/server/index.d.ts +1 -1
  50. package/dist/server/index.js +1 -1
  51. package/dist/server/lead-store-sqlite.js +12 -4
  52. package/dist/server/lead-webhook.d.ts +12 -1
  53. package/dist/server/lead-webhook.js +12 -0
  54. package/dist/server/notify.d.ts +1 -1
  55. package/dist/server/notify.js +5 -2
  56. package/dist/testing/lead-store-contract.js +4 -2
  57. package/package.json +1 -1
  58. package/template/README.md +7 -4
  59. package/template/flake.nix +1 -1
  60. package/template/package.json +1 -1
  61. package/template/src/entities/content/model/en.ts +2 -2
  62. package/template/src/entities/content/model/fr.ts +2 -2
  63. package/template/src/entities/content/model/types.ts +3 -2
  64. package/template/src/shared/config/lead.ts +2 -0
  65. package/template/src/views/home/ui/PlaceHome.tsx +25 -40
  66. package/template/tests/bundle_budget.txt +8 -3
  67. package/template/tests/e2e/quote-form.spec.ts +113 -4
@@ -7,9 +7,11 @@ import { expect, test, type Locator, type Page } from "@playwright/test";
7
7
  const SUBJECT = "Prestation";
8
8
 
9
9
  async function fillAndSend(page: Page) {
10
- await page.getByLabel("Ville ou code postal").fill("75011");
11
- await page.getByLabel("Mobile").fill("0612345678");
12
- await page.getByRole("button", { name: "Recevoir le prix" }).click();
10
+ // `#quote` is the card, the callback's phone in it too: the form is its own.
11
+ const form = page.locator("#quote-form");
12
+ await form.getByLabel("Code postal").fill("75011");
13
+ await form.getByLabel("Téléphone").fill("0612345678");
14
+ await form.getByRole("button", { name: "Recevoir le prix" }).click();
13
15
  }
14
16
 
15
17
  test.describe("without JavaScript", () => {
@@ -63,7 +65,7 @@ test.describe("with JavaScript", () => {
63
65
  await expect(trigger).toBeFocused();
64
66
  await expect(trigger).toHaveText("Grand ménage");
65
67
  await page.keyboard.press("Tab");
66
- await expect(page.getByLabel("Ville ou code postal")).toBeFocused();
68
+ await expect(page.locator("#quote-form").getByLabel("Code postal")).toBeFocused();
67
69
  });
68
70
  });
69
71
 
@@ -99,3 +101,110 @@ test("the field is the same box before and after hydration", async ({ browser })
99
101
 
100
102
  expect(hydrated).toEqual(server);
101
103
  });
104
+
105
+ // `LeadCapture`'s other ways in. The template's place has no number and keeps
106
+ // office hours, which is the case these hold: no call, text or WhatsApp
107
+ // anywhere, and the callback leading only while it is closed.
108
+ const posts = (page: Page) => page.waitForRequest(r => r.method() === "POST" && new URL(r.url()).pathname === "/quote");
109
+
110
+ async function askForCallback(page: Page) {
111
+ const details = page.locator("details#quote-callback");
112
+ // The server orders by its own clock: the callback may already be open.
113
+ if (!(await details.evaluate(d => (d instanceof HTMLDetailsElement ? d.open : false)))) await details.locator("summary").click();
114
+ const form = page.locator("#quote-callback-form");
115
+ await form.getByLabel("Téléphone").fill("07 12 34 56 78");
116
+ await form.getByLabel("J’accepte d’être rappelé·e à ce numéro au sujet de ma demande.").check();
117
+ const posted = posts(page);
118
+ await form.getByRole("button", { name: "Être rappelé" }).click();
119
+ return new URLSearchParams((await posted).postData() ?? "");
120
+ }
121
+
122
+ for (const javaScriptEnabled of [false, true]) {
123
+ test.describe(`the callback, ${javaScriptEnabled ? "with" : "without"} JavaScript`, () => {
124
+ test.use({ javaScriptEnabled, reducedMotion: "reduce" });
125
+
126
+ test("posts the phone as a callback lead", async ({ page }) => {
127
+ await page.goto("/fr");
128
+ const body = await askForCallback(page);
129
+ expect(body.get("channel")).toBe("callback");
130
+ expect(body.get("mobile")).toBe("07 12 34 56 78");
131
+ expect(body.get("location")).toBe("paris");
132
+ // The sentence shown is what is posted, and what the lead keeps.
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$/);
135
+ });
136
+
137
+ test("is refused without the consent", async ({ page }) => {
138
+ await page.goto("/fr");
139
+ const details = page.locator("details#quote-callback");
140
+ if (!(await details.evaluate(d => (d instanceof HTMLDetailsElement ? d.open : false)))) await details.locator("summary").click();
141
+ const form = page.locator("#quote-callback-form");
142
+ await form.getByLabel("Téléphone").fill("07 12 34 56 78");
143
+ await form.getByRole("button", { name: "Être rappelé" }).click();
144
+ await expect(page).toHaveURL(/\/fr$/);
145
+ expect(await form.getByLabel("J’accepte d’être rappelé·e à ce numéro au sujet de ma demande.").evaluate(el => (el instanceof HTMLInputElement ? el.validity.valueMissing : false))).toBe(true);
146
+ });
147
+ });
148
+ }
149
+
150
+ test.describe("the channel order", () => {
151
+ const hydrated = (page: Page) => expect(page.locator("#quote select")).toHaveCount(0);
152
+ const callbackFirst = (page: Page) =>
153
+ page.evaluate(() => {
154
+ const callback = document.getElementById("quote-callback");
155
+ const form = document.getElementById("quote-form");
156
+ if (!callback || !form) throw new Error("missing the form or the callback");
157
+ return { first: Boolean(callback.compareDocumentPosition(form) & Node.DOCUMENT_POSITION_FOLLOWING), open: callback.hasAttribute("open") };
158
+ });
159
+
160
+ test("leads with the callback, open, and the next opening while closed", async ({ page }) => {
161
+ // Friday 20:00 in Paris: closed until Monday 08:00.
162
+ await page.clock.setFixedTime(new Date("2026-10-09T18:00:00Z"));
163
+ await page.goto("/fr");
164
+ await hydrated(page);
165
+ expect(await callbackFirst(page)).toEqual({ first: true, open: true });
166
+ await expect(page.getByText("Nous vous rappelons lundi dès 8 h.")).toBeVisible();
167
+ });
168
+
169
+ test("leads with the form while open, the callback closed after it", async ({ page }) => {
170
+ // Monday 10:00 in Paris.
171
+ await page.clock.setFixedTime(new Date("2026-10-05T08:00:00Z"));
172
+ await page.goto("/fr");
173
+ await hydrated(page);
174
+ expect(await callbackFirst(page)).toEqual({ first: false, open: false });
175
+ await expect(page.getByText(/Nous vous rappelons/)).toHaveCount(0);
176
+ });
177
+ });
178
+
179
+ test("a place with no number offers no call, text or WhatsApp anywhere", async ({ page }) => {
180
+ await page.goto("/fr");
181
+ await expect(page.locator("#quote")).toBeVisible();
182
+ await expect(page.locator('a[href^="tel:"], a[href^="sms:"], a[href*="wa.me"]')).toHaveCount(0);
183
+ await expect(page.locator("#callbar").getByText("☎")).toHaveCount(0);
184
+ });
185
+
186
+ test("#quote is the whole card: the call bar's link lands on its head", async ({ page }) => {
187
+ await page.goto("/fr#quote");
188
+ const card = page.locator("#quote");
189
+ await expect(card.getByText("Recevoir un prix", { exact: true })).toBeInViewport();
190
+ await expect(card.locator("form#quote-form")).toHaveCount(1);
191
+ await expect(card.locator("details#quote-callback")).toHaveCount(1);
192
+ });
193
+
194
+ // `LeadCapture`'s `done` posts the form itself and trusts one answer: the
195
+ // thanks page, reached through the 303. Held here against the real route.
196
+ test("a scripted post of the form lands on the thanks page", async ({ page }) => {
197
+ await page.goto("/fr");
198
+ await expect(page.locator("#quote select")).toHaveCount(0);
199
+ const form = page.locator("#quote-form");
200
+ await form.getByLabel("Code postal").fill("75011");
201
+ await form.getByLabel("Téléphone").fill("0612345678");
202
+ const answer = await form.evaluate(async el => {
203
+ if (!(el instanceof HTMLFormElement)) throw new Error("not a form");
204
+ const body = new URLSearchParams();
205
+ for (const [key, value] of new FormData(el)) if (typeof value === "string") body.append(key, value);
206
+ const res = await fetch(el.action, { method: "POST", body });
207
+ return { ok: res.ok, redirected: res.redirected, path: new URL(res.url).pathname };
208
+ });
209
+ expect(answer).toEqual({ ok: true, redirected: true, path: "/fr/thanks" });
210
+ });