@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
@@ -15,8 +15,19 @@ export interface LeadMail {
15
15
  text: string;
16
16
  }
17
17
  export type NotifyEnv = Pick<ServerEnv, "smtpUrl" | "notifyTo" | "notifyFrom" | "smsToken">;
18
+ /**
19
+ * The need's label in the mail's language — the business's, whatever the
20
+ * visitor's — or `undefined` for one it does not know (a stale form's).
21
+ */
22
+ export type NeedLabel = (need: string) => string | undefined;
23
+ /** What a mail shows in place of the raw lead: the need as `needLabel` names it, else its id. */
24
+ export interface LeadMailShown {
25
+ need: string;
26
+ }
18
27
  /** A plain default; a brand passes `format` to write it in its own language. */
19
- export declare function defaultLeadMail(brand: Pick<BrandFacts, "name">, lead: Lead, id: number): LeadMail;
28
+ export declare function defaultLeadMail(brand: Pick<BrandFacts, "name">, lead: Lead, id: number, options?: {
29
+ needLabel?: NeedLabel | undefined;
30
+ }): LeadMail;
20
31
  /**
21
32
  * Checked when it is built — at boot, when a brand builds it there — not on
22
33
  * the first lead: with SMTP configured, a sender needs a domain to be `leads@`
@@ -29,7 +40,14 @@ export declare const MAIL_PER_MINUTE = 20;
29
40
  export declare function leadNotifier(site: {
30
41
  brand: BrandFacts;
31
42
  }, env: NotifyEnv, options?: {
32
- format?: (lead: Lead, id: number) => LeadMail;
43
+ /** The brand's mail; `shown.need` is the need's label (`needLabel`), else its id. */
44
+ format?: (lead: Lead, id: number, shown: LeadMailShown) => LeadMail;
45
+ /**
46
+ * Names the need in the mail — `lead.subject` is the id the form posted
47
+ * (`hot_water`), which the business should not have to decode. The labels
48
+ * the brand hands `LeadCapture` as `needs`, in the mail's language.
49
+ */
50
+ needLabel?: NeedLabel;
33
51
  log?: Pick<Console, "warn">;
34
52
  /** `MAIL_PER_MINUTE` by default: a flood that passes the antispam must not become a mail flood. */
35
53
  mailPerMinute?: number;
@@ -1,8 +1,9 @@
1
1
  import "server-only";
2
2
  import { channelOf } from "../core/lead.js";
3
3
  import { parseSmtpUrl, sendMail } from "./smtp.js";
4
+ const shownOf = (lead, needLabel) => ({ need: needLabel?.(lead.subject) ?? lead.subject });
4
5
  /** A plain default; a brand passes `format` to write it in its own language. */
5
- export function defaultLeadMail(brand, lead, id) {
6
+ export function defaultLeadMail(brand, lead, id, options = {}) {
6
7
  const place = lead.placeSlug ?? "unknown";
7
8
  // A callback request is a promise to ring the customer: it says so first.
8
9
  const callback = channelOf(lead) === "callback";
@@ -11,7 +12,7 @@ export function defaultLeadMail(brand, lead, id) {
11
12
  text: [
12
13
  `Lead #${id} — place ${place}${callback ? " — CALL BACK" : ""}${lead.spamVerdict ? ` — suspect (${lead.spamVerdict})` : ""}`,
13
14
  "",
14
- `Subject : ${lead.subject}`,
15
+ `Subject : ${shownOf(lead, options.needLabel).need}`,
15
16
  `Locality : ${lead.locality}`,
16
17
  `Mobile : ${lead.mobile}`,
17
18
  ...Object.entries(lead.extras).map(([name, value]) => `${name} : ${value}`),
@@ -32,7 +33,8 @@ export function leadNotifier(site, env, options = {}) {
32
33
  const log = options.log ?? console;
33
34
  const now = options.now ?? Date.now;
34
35
  const cap = options.mailPerMinute ?? MAIL_PER_MINUTE;
35
- const format = options.format ?? ((lead, id) => defaultLeadMail(brand, lead, id));
36
+ const { needLabel } = options;
37
+ const format = options.format ?? ((lead, id) => defaultLeadMail(brand, lead, id, { needLabel }));
36
38
  let window = { start: Number.NEGATIVE_INFINITY, sent: 0 };
37
39
  const spend = () => {
38
40
  const t = now();
@@ -59,7 +61,7 @@ export function leadNotifier(site, env, options = {}) {
59
61
  log.warn(`lead ${id}: over ${cap} notification mails a minute; stored, not mailed`);
60
62
  }
61
63
  else if (mail) {
62
- const { subject, text } = format(lead, id);
64
+ const { subject, text } = format(lead, id, shownOf(lead, needLabel));
63
65
  channels.push(sendMail(mail.url, { from: mail.from, to: mail.to, subject, text }, { helo: mail.helo }));
64
66
  }
65
67
  if (env.smsToken) {
@@ -29,7 +29,7 @@ export function createPlaceSource(site, options) {
29
29
  if (!response.ok)
30
30
  return { kind: "failed", status: response.status };
31
31
  try {
32
- return { kind: "live", place: mergeLive(baked, parsePlaceLive(await response.json(), site.i18n.locales)) };
32
+ return { kind: "live", place: mergeLive(baked, parsePlaceLive(await response.json(), site.i18n.locales, site.booking ?? {})) };
33
33
  }
34
34
  catch (cause) {
35
35
  // A body that is not the source's shape is the source failing, not the place.
@@ -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
+ }
@@ -67,6 +67,16 @@ export interface TickReport {
67
67
  export interface WebhookOutbox {
68
68
  /** Queues a body; synchronous and local, so it can sit before the response. */
69
69
  enqueue(body: string, ref?: string | null): number;
70
+ /**
71
+ * Queues a body unless a row with the same `ref` was ever queued, in one
72
+ * statement — two pods racing on one ref queue it once. Answers the new
73
+ * row, or `null` when the ref was there. With `after`, the row is not sent
74
+ * until the row of that ref is delivered, and a `409` or `425` for it is
75
+ * retried: the receiver has not seen what it depends on yet.
76
+ */
77
+ enqueueOnce?(body: string, ref: string, after?: string): number | null;
78
+ /** Whether a row with this `ref` was ever queued, whatever its state. */
79
+ hasRef?(ref: string): boolean;
70
80
  /** One pass over the rows that are due; concurrent calls share the running one. */
71
81
  tick(): Promise<TickReport>;
72
82
  /** Ticks every `intervalMs` until `stop`; a second call is a no-op. */
@@ -100,4 +110,4 @@ export declare function checkWebhookUrl(value: string, name?: string): string;
100
110
  * rows stay, are counted at open, and are not sent to a receiver that never
101
111
  * agreed to take them.
102
112
  */
103
- export declare function openWebhookOutbox(path: string, target: WebhookTarget, options?: WebhookOutboxOptions): WebhookOutbox;
113
+ export declare function openWebhookOutbox(path: string, target: WebhookTarget, options?: WebhookOutboxOptions): WebhookOutbox & Required<Pick<WebhookOutbox, "enqueueOnce" | "hasRef">>;
@@ -21,7 +21,17 @@ CREATE TABLE IF NOT EXISTS webhook_outbox (
21
21
  done_at INTEGER
22
22
  );
23
23
  CREATE INDEX IF NOT EXISTS webhook_outbox_due ON webhook_outbox (state, target, next_attempt_at);
24
+ CREATE INDEX IF NOT EXISTS webhook_outbox_ref ON webhook_outbox (ref);
24
25
  `;
26
+ /**
27
+ * Added after the table shipped: a file from before gets it on open. A row's
28
+ * `after_ref` holds it until the row of that ref is delivered.
29
+ */
30
+ function addAfterRef(db) {
31
+ const columns = db.prepare("PRAGMA table_info(webhook_outbox)").all();
32
+ if (!columns.some(c => column(c, "name") === "after_ref"))
33
+ db.exec("ALTER TABLE webhook_outbox ADD COLUMN after_ref TEXT");
34
+ }
25
35
  /** How much of a receiver's answer a row keeps. */
26
36
  const ERROR_CAP = 500;
27
37
  const IN_CLUSTER = /(^|\.)svc(\.cluster\.local)?$/;
@@ -103,7 +113,7 @@ async function multiStatus(response, rowId, log) {
103
113
  const reasons = rejected.map(r => `#${String(column(r, "index"))}: ${String(column(r, "reason") ?? "rejected")}`);
104
114
  return { kind: "delivered", note: `rejected ${reasons.join("; ")}` };
105
115
  }
106
- async function judge(response, rowId, log) {
116
+ async function judge(response, rowId, log, dependent) {
107
117
  const status = response.status;
108
118
  if (status === 207)
109
119
  return multiStatus(response, rowId, log);
@@ -116,6 +126,9 @@ async function judge(response, rowId, log) {
116
126
  const why = said ? `${tag}: ${said}` : tag;
117
127
  if (status === 408 || status === 429 || status >= 500)
118
128
  return { kind: "retry", tag, why, afterMs: retryAfter(response) };
129
+ // A row that follows another: the receiver may not have taken that one yet.
130
+ if (dependent && (status === 409 || status === 425))
131
+ return { kind: "retry", tag, why, afterMs: retryAfter(response) };
119
132
  // A rotated key (401, 403) or a receiver moved or not yet deployed (404):
120
133
  // fixed by a person, after which the lead must still arrive.
121
134
  if (status === 401 || status === 403)
@@ -150,13 +163,19 @@ export function openWebhookOutbox(path, target, options = {}) {
150
163
  db.exec("PRAGMA busy_timeout = 5000");
151
164
  db.exec("PRAGMA journal_mode = WAL");
152
165
  db.exec(SCHEMA);
166
+ addAfterRef(db);
153
167
  }
154
168
  catch (error) {
155
169
  db.close();
156
170
  throw error;
157
171
  }
158
172
  const insert = db.prepare("INSERT INTO webhook_outbox (target, ref, body, created_at, next_attempt_at) VALUES (?, ?, ?, ?, ?) RETURNING id");
159
- const due = db.prepare("SELECT id, ref, body, attempts, created_at FROM webhook_outbox WHERE state = 'pending' AND target = ? AND next_attempt_at <= ? ORDER BY next_attempt_at, id LIMIT ?");
173
+ const insertOnce = db.prepare("INSERT INTO webhook_outbox (target, ref, body, created_at, next_attempt_at, after_ref) SELECT ?, ?, ?, ?, ?, ? WHERE NOT EXISTS (SELECT 1 FROM webhook_outbox WHERE ref = ?) RETURNING id");
174
+ const byRef = db.prepare("SELECT 1 FROM webhook_outbox WHERE ref = ? LIMIT 1");
175
+ // A row waits for the row it follows to be delivered; held, it is not due.
176
+ const due = db.prepare(`SELECT id, ref, body, attempts, created_at, after_ref FROM webhook_outbox AS o WHERE state = 'pending' AND target = ? AND next_attempt_at <= ?
177
+ AND (after_ref IS NULL OR EXISTS (SELECT 1 FROM webhook_outbox AS d WHERE d.ref = o.after_ref AND d.state = 'delivered'))
178
+ ORDER BY next_attempt_at, id LIMIT ?`);
160
179
  // A lease: another process ticking the same file skips a row while it is in flight.
161
180
  const claim = db.prepare("UPDATE webhook_outbox SET next_attempt_at = ? WHERE id = ? AND state = 'pending' AND next_attempt_at <= ?");
162
181
  const finish = db.prepare("UPDATE webhook_outbox SET state = ?, attempts = ?, last_error = ?, done_at = ? WHERE id = ?");
@@ -173,7 +192,7 @@ export function openWebhookOutbox(path, target, options = {}) {
173
192
  const plain = Math.min(maxDelay, baseDelay * 2 ** Math.max(0, attempts - 1));
174
193
  return Math.min(maxDelay, Math.max(plain + Math.floor(plain * 0.2 * random()), hinted ?? 0));
175
194
  }
176
- async function deliver(id, body) {
195
+ async function deliver(id, body, dependent) {
177
196
  try {
178
197
  const response = await send(target.url, {
179
198
  method: "POST",
@@ -182,7 +201,7 @@ export function openWebhookOutbox(path, target, options = {}) {
182
201
  redirect: "manual",
183
202
  signal: AbortSignal.timeout(timeoutMs),
184
203
  });
185
- return await judge(response, id, log);
204
+ return await judge(response, id, log, dependent);
186
205
  }
187
206
  catch (error) {
188
207
  // The name and code only: a fetch error's message is safe, but its cause chain is the runtime's to word.
@@ -202,7 +221,7 @@ export function openWebhookOutbox(path, target, options = {}) {
202
221
  const queuedAt = int(row, "created_at");
203
222
  if (Number(claim.run(t + timeoutMs + 5_000, id, t).changes) === 0)
204
223
  continue;
205
- const verdict = await deliver(id, body);
224
+ const verdict = await deliver(id, body, text(row, "after_ref") !== null);
206
225
  if (verdict.kind === "delivered") {
207
226
  finish.run("delivered", attempts, verdict.note?.slice(0, ERROR_CAP) ?? null, now(), id);
208
227
  report.delivered += 1;
@@ -249,6 +268,14 @@ export function openWebhookOutbox(path, target, options = {}) {
249
268
  const t = now();
250
269
  return int(insert.get(target.url, ref, body, t, t), "id");
251
270
  },
271
+ enqueueOnce(body, ref, after) {
272
+ const t = now();
273
+ const row = insertOnce.get(target.url, ref, body, t, t, after ?? null, ref);
274
+ return row === undefined ? null : int(row, "id");
275
+ },
276
+ hasRef(ref) {
277
+ return byRef.get(ref) !== undefined;
278
+ },
252
279
  tick,
253
280
  start(intervalMs = WEBHOOK_TICK_MS) {
254
281
  if (timer)
@@ -16,7 +16,13 @@ export const PHONE_MATRIX = [
16
16
  { mobile: "01 23 45 67 89", valid: true },
17
17
  { mobile: "+1 415 555 0123", valid: true },
18
18
  { mobile: "+44 7911 123456", valid: true },
19
- { mobile: "+12345678", valid: true },
19
+ { mobile: "+32 470 12 34 56", valid: true },
20
+ { mobile: "+41 44 668 18 00", valid: true },
21
+ { mobile: "+49 30 1234567", valid: true },
22
+ { mobile: "+34 612 345 678", valid: true },
23
+ { mobile: "+39 06 1234 5678", valid: true },
24
+ { mobile: "+44 20 7946 0958", valid: true },
25
+ { mobile: "+352 621 123 456", valid: true },
20
26
  { mobile: "0612345678", valid: true },
21
27
  { mobile: "(415) 555-0123", valid: false },
22
28
  { mobile: "+3361234567", valid: false },
@@ -25,6 +31,14 @@ export const PHONE_MATRIX = [
25
31
  { mobile: "06 12 34 56 7", valid: false },
26
32
  { mobile: "12 34 56 7", valid: false },
27
33
  { mobile: "+1234567", valid: false },
34
+ // A known plan's number short of its national length (LEAD-FORMS-RETEST-2026-10-03).
35
+ { mobile: "+12345678", valid: false },
36
+ { mobile: "+1 415 555 012", valid: false },
37
+ { mobile: "+44 7911 1234", valid: false },
38
+ { mobile: "+34 612 345 67", valid: false },
39
+ { mobile: "+41 44 668 18 0", valid: false },
40
+ { mobile: "+32 470 123", valid: false },
41
+ { mobile: "+49 30 1234", valid: false },
28
42
  { mobile: "06 12", valid: false },
29
43
  ];
30
44
  /**
@@ -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.11.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
  }
@@ -0,0 +1,11 @@
1
+ import { bookingRoute } from "@evinvest/kitstart/next";
2
+ import { serverEnv } from "@/shared/config/env";
3
+
4
+ /**
5
+ * A priced lead asking for a slot: `booking.requested@1` to the panel through
6
+ * the lead webhook's outbox, once its `panelBooking` is on. This template has
7
+ * no webhook, so the request is answered and dropped.
8
+ */
9
+ export const dynamic = "force-dynamic";
10
+
11
+ export const POST = bookingRoute({ env: serverEnv });
@@ -0,0 +1,12 @@
1
+ import { confirmRoute } from "@evinvest/kitstart/next";
2
+ import { TEXT } from "@/entities/content";
3
+ import { pricing } from "@/shared/config/env";
4
+ import { site } from "@/shared/config/site";
5
+
6
+ /**
7
+ * A form posted without a script whose price changed lands here: a page that
8
+ * is never cached, with the fresh price to confirm.
9
+ */
10
+ export const dynamic = "force-dynamic";
11
+
12
+ export const GET = confirmRoute(site, { pricing, text: locale => TEXT[locale].leadCapture });
@@ -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.11.0";
7
7
  ev.inputs.v_flakes.follows = "v_flakes";
8
8
  };
9
9
 
@@ -20,9 +20,9 @@
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.11.0",
24
24
  "@evinvest/marketing": "^0.3.0",
25
- "@evinvest/uikit": "^0.25.0",
25
+ "@evinvest/uikit": "^0.26.0",
26
26
  "next": "16.3.6",
27
27
  "react": "19.3.0",
28
28
  "react-dom": "19.3.0",
@@ -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
  };
@@ -16,5 +16,9 @@ export const PLACES: readonly Place<Locale>[] = [
16
16
  channels: { phone: null, whatsapp: null },
17
17
  hours: [{ days: ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday"], opens: "08:00", closes: "19:00" }],
18
18
  rating: null,
19
+ // How a priced lead's slot is set. Left out, the card promises a call
20
+ // (`manual`); the panel's place settings set it live (`PlaceLive.booking`),
21
+ // e.g. `{ default: "manual", providers: { google_calendar: { url: "https://calendar.app.google/…" } } }`,
22
+ // and the `booking_provider` experiment picks among them (`bookingOf`).
19
23
  },
20
24
  ];
@@ -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,10 @@ export const site = defineSite({
29
30
  places: PLACES,
30
31
  publication: SERVICE_AREA_GATE,
31
32
  lead: LEAD,
33
+ pricing: PRICING,
34
+ // The Cal.com hosts a place's `cal_com` booking may be on. A brand on its
35
+ // own Cal.com adds its domain: ["cal.com", "cal.brand.fr"].
36
+ booking: { calComHosts: ["cal.com"] },
32
37
  });
33
38
 
34
39
  export type PageKey = (typeof site.pageKeys)[number];
@@ -36,4 +41,5 @@ export type PageKey = (typeof site.pageKeys)[number];
36
41
  export const OWNER_TODO: readonly OwnerTodo[] = [
37
42
  { field: "site.brand.legalName", why: "raison sociale as registered", blocksLaunch: true },
38
43
  { field: "assets/card.toml phone", why: "the public number", blocksLaunch: false },
44
+ { field: "shared/config/pricing.ts", why: "placeholder prices until the panel serves the real ones", blocksLaunch: false },
39
45
  ];
@@ -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%