@evinvest/kitstart 0.10.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 (76) hide show
  1. package/README.md +155 -17
  2. package/dist/core/accept.d.ts +12 -1
  3. package/dist/core/accept.js +15 -0
  4. package/dist/core/analytics.d.ts +3 -1
  5. package/dist/core/analytics.js +6 -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-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 +3 -2
  29. package/dist/index.js +3 -2
  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/LeadBooking.d.ts +35 -0
  38. package/dist/react/LeadBooking.js +47 -0
  39. package/dist/react/LeadBookingManual.d.ts +17 -0
  40. package/dist/react/LeadBookingManual.js +50 -0
  41. package/dist/react/LeadCapture.d.ts +28 -5
  42. package/dist/react/LeadCapture.js +22 -8
  43. package/dist/react/LeadCaptureFields.js +60 -21
  44. package/dist/react/LeadCapturePriced.d.ts +3 -4
  45. package/dist/react/LeadCapturePriced.js +4 -4
  46. package/dist/react/booking-adapters.d.ts +58 -0
  47. package/dist/react/booking-adapters.js +104 -0
  48. package/dist/react/index.d.ts +5 -1
  49. package/dist/react/index.js +4 -1
  50. package/dist/react/use-booking.d.ts +20 -0
  51. package/dist/react/use-booking.js +32 -0
  52. package/dist/react/use-lead-error.d.ts +3 -7
  53. package/dist/react/use-lead-error.js +9 -6
  54. package/dist/react/use-lead-submit.d.ts +7 -1
  55. package/dist/react/use-lead-submit.js +6 -3
  56. package/dist/react/use-repriced.d.ts +18 -0
  57. package/dist/react/use-repriced.js +27 -0
  58. package/dist/server/index.d.ts +2 -2
  59. package/dist/server/lead-webhook.d.ts +46 -0
  60. package/dist/server/lead-webhook.js +17 -1
  61. package/dist/server/notify.d.ts +20 -2
  62. package/dist/server/notify.js +6 -4
  63. package/dist/server/place-source.js +1 -1
  64. package/dist/server/webhook-outbox.d.ts +11 -1
  65. package/dist/server/webhook-outbox.js +32 -5
  66. package/dist/testing/lead-rules.js +15 -1
  67. package/package.json +1 -1
  68. package/template/app/quote/booking/route.ts +11 -0
  69. package/template/app/quote/confirm/route.ts +12 -0
  70. package/template/flake.nix +1 -1
  71. package/template/package.json +2 -2
  72. package/template/src/shared/config/places.ts +4 -0
  73. package/template/src/shared/config/site.ts +3 -0
  74. package/template/tests/e2e/flows.spec.ts +86 -9
  75. package/template/tests/e2e/mock-panel.mjs +18 -4
  76. package/template/tests/e2e/quote-form.spec.ts +2 -2
@@ -1,7 +1,7 @@
1
1
  "use client";
2
2
  import { useEffect, useState } from "react";
3
- import { LEAD_ERROR_PARAM } from "../core/lead.js";
4
- const FIELD = /^[a-z][a-z0-9_]{0,31}$/;
3
+ import { PRICE_CHANGED } from "../core/accept.js";
4
+ import { LEAD_CARD_PARAM, LEAD_ERROR_FIELD, LEAD_ERROR_PARAM } from "../core/lead.js";
5
5
  /** The fields `LeadCapture` draws itself, which show a refusal under themselves. */
6
6
  export const OWN_FIELDS = { form: ["phone", "locality", "name"], callback: ["phone", "consent"] };
7
7
  /** The id of the message a refusal of a field the card does not draw is shown in, above the submit. */
@@ -31,8 +31,9 @@ function controlOf(form, field) {
31
31
  * `aria-invalid` and pointed at the message above the submit. Fixing that
32
32
  * control clears it.
33
33
  */
34
- export function useLeadError(root, id) {
35
- const [error, setError] = useState(null);
34
+ export function useLeadError(root, id, initial = null) {
35
+ // Drawn on the server too when the page read it from its query (`leadErrorOf`).
36
+ const [error, setError] = useState(initial);
36
37
  useEffect(() => {
37
38
  const url = new URL(window.location.href);
38
39
  const field = url.searchParams.get(LEAD_ERROR_PARAM);
@@ -40,8 +41,9 @@ export function useLeadError(root, id) {
40
41
  if (field === null || channel === null)
41
42
  return;
42
43
  url.searchParams.delete(LEAD_ERROR_PARAM);
44
+ url.searchParams.delete(LEAD_CARD_PARAM);
43
45
  window.history.replaceState(window.history.state, "", url);
44
- if (FIELD.test(field))
46
+ if (LEAD_ERROR_FIELD.test(field))
45
47
  setError({ channel, field });
46
48
  }, [id]);
47
49
  useEffect(() => {
@@ -57,7 +59,8 @@ export function useLeadError(root, id) {
57
59
  foreign?.setAttribute("aria-describedby", formMessageId(id, error.channel));
58
60
  (control ?? document.getElementById(formMessageId(id, error.channel)))?.focus();
59
61
  const fixed = (event) => {
60
- if (error.field === "form" || (event.target instanceof Element && control?.contains(event.target)))
62
+ // A refusal of the whole form, or of its price, is about every answer: any change answers it.
63
+ if (error.field === "form" || error.field === PRICE_CHANGED || (event.target instanceof Element && control?.contains(event.target)))
61
64
  setError(null);
62
65
  };
63
66
  form.addEventListener("input", fixed);
@@ -11,6 +11,11 @@ export interface LeadSent {
11
11
  lead?: string;
12
12
  /** The price the server took the lead at, for an `estimate` or `fixed` need. */
13
13
  cents?: number;
14
+ /**
15
+ * The form's submission id, with `lead` only: the secret a booking request
16
+ * (`bookingRoute`) proves the lead is this page's with.
17
+ */
18
+ submission?: string;
14
19
  }
15
20
  /** Leaving the page, behind a seam a test can stand in for (jsdom cannot navigate). */
16
21
  export declare const navigation: {
@@ -27,7 +32,8 @@ export type SendFailure = "network" | "timeout";
27
32
  export declare const SUBMIT_TIMEOUT_MS = 15000;
28
33
  export interface LeadSubmitHandlers {
29
34
  /** The route refused the lead: the field to fix. */
30
- onRefused: (channel: LeadChannel, field: string) => void;
35
+ /** `cents`: with `price_changed`, the server's fresh price. */
36
+ onRefused: (channel: LeadChannel, field: string, cents?: number) => void;
31
37
  /** No answer: the form stays as typed, with a retry. */
32
38
  onFailed: (channel: LeadChannel, failure: SendFailure) => void;
33
39
  }
@@ -68,8 +68,9 @@ async function readAnswer(res) {
68
68
  ...(typeof cents === "number" && Number.isSafeInteger(cents) && cents >= 0 ? { cents } : {}),
69
69
  };
70
70
  }
71
+ // `cents`: with `price_changed`, the price the lead would be taken at now.
71
72
  if (ok === false && typeof field === "string")
72
- return { ok, field };
73
+ return { ok, field, ...(typeof cents === "number" && Number.isSafeInteger(cents) && cents >= 0 ? { cents } : {}) };
73
74
  return null;
74
75
  }
75
76
  if (res.ok && res.redirected) {
@@ -136,13 +137,15 @@ export function useLeadSubmit(done, fields, on) {
136
137
  return fallBack();
137
138
  if (!answer.ok) {
138
139
  settle();
139
- return on.onRefused(lead.channel, answer.field);
140
+ return on.onRefused(lead.channel, answer.field, answer.cents);
140
141
  }
141
142
  // Still pending: the page is leaving, or the form is gone. The next
142
143
  // lead from this form (Back, another need) gets an id of its own.
143
144
  minted.delete(form);
145
+ const submission = data.get(SUBMISSION_FIELD);
146
+ const ref = answer.lead && typeof submission === "string" && submission !== "" ? { lead: answer.lead, submission } : answer.lead ? { lead: answer.lead } : {};
144
147
  if (typeof done === "function" ? done(lead.channel) : done)
145
- setSent({ ...lead, ...(answer.lead ? { lead: answer.lead } : {}), ...(answer.cents !== undefined ? { cents: answer.cents } : {}) });
148
+ setSent({ ...lead, ...ref, ...(answer.cents !== undefined ? { cents: answer.cents } : {}) });
146
149
  else
147
150
  navigation.assign(answer.location);
148
151
  }, () => {
@@ -0,0 +1,18 @@
1
+ import type { LeadCaptureFlowText } from "../core/lead-capture-text.js";
2
+ import type { Price } from "../core/pricing/model.js";
3
+ /**
4
+ * The price the server answered when the one on screen was stale
5
+ * (`price_changed`): shown in its place, and posted back as `shown_cents` —
6
+ * a resubmit is the visitor confirming it. Only while the answers are the
7
+ * ones it was refused for; a change and the page prices on its own again.
8
+ */
9
+ export declare function useRepriced(price: Price | null, need: string | undefined, text: LeadCaptureFlowText, locale: string): {
10
+ /** The price to show: the server's fresh one, else the page's own. */
11
+ price: Price | null;
12
+ /** What the form posts as `shown_cents`. */
13
+ shownCents: number | undefined;
14
+ /** The words for a `price_changed` refusal, with both prices when the page has them. */
15
+ message: () => string;
16
+ /** A refusal: remembers the fresh price when it is `price_changed`. */
17
+ refused(field: string, cents: number | undefined): void;
18
+ };
@@ -0,0 +1,27 @@
1
+ "use client";
2
+ import { useState } from "react";
3
+ import { PRICE_CHANGED } from "../core/accept.js";
4
+ import { fillText, formatCents } from "../core/lead-capture-format.js";
5
+ /**
6
+ * The price the server answered when the one on screen was stale
7
+ * (`price_changed`): shown in its place, and posted back as `shown_cents` —
8
+ * a resubmit is the visitor confirming it. Only while the answers are the
9
+ * ones it was refused for; a change and the page prices on its own again.
10
+ */
11
+ export function useRepriced(price, need, text, locale) {
12
+ const [repriced, setRepriced] = useState(null);
13
+ const current = repriced && price && repriced.shown === price.cents && repriced.need === need ? repriced : null;
14
+ return {
15
+ /** The price to show: the server's fresh one, else the page's own. */
16
+ price: current ? { cents: current.cents, breakdown: [] } : price,
17
+ /** What the form posts as `shown_cents`. */
18
+ shownCents: current?.cents ?? price?.cents,
19
+ /** The words for a `price_changed` refusal, with both prices when the page has them. */
20
+ message: () => current ? fillText(text.priceChanged, { price: formatCents(current.cents, locale), shown: formatCents(current.shown, locale) }) : text.priceChangedGeneric,
21
+ /** A refusal: remembers the fresh price when it is `price_changed`. */
22
+ refused(field, cents) {
23
+ if (field === PRICE_CHANGED && cents !== undefined && price)
24
+ setRepriced({ cents, shown: price.cents, need });
25
+ },
26
+ };
27
+ }
@@ -11,9 +11,9 @@ export { describeLeadDb, openLeadStore, parseLeadDb, type LeadDb } from "./lead-
11
11
  export { openSqliteLeadStore, type SqliteLeadStore } from "./lead-store-sqlite.js";
12
12
  export { LeadStoreNotImplemented, openPostgresLeadStore } from "./lead-store-postgres.js";
13
13
  export { parseSmtpUrl, sendMail, type Mail, type SendOptions } from "./smtp.js";
14
- export { defaultLeadMail, leadNotifier, MAIL_PER_MINUTE, type LeadMail, type LeadNotifier, type NotifyEnv } from "./notify.js";
14
+ export { defaultLeadMail, leadNotifier, MAIL_PER_MINUTE, type LeadMail, type LeadMailShown, type LeadNotifier, type NeedLabel, type NotifyEnv } from "./notify.js";
15
15
  export { clientKey, NO_CLIENT_ADDRESS, parseProxyTrust, type ProxyTrust } from "./client-key.js";
16
16
  export { checkLeadStore } from "./boot.js";
17
- export { leadWebhook, panelChannel, panelFlowOf, panelFlowProperties, type BuildWebhookBody, type LeadWebhook, type LeadWebhookContext, type LeadWebhookOptions, type PanelFlow, type PanelFlowProperties, } from "./lead-webhook.js";
17
+ export { leadWebhook, panelChannel, panelFlowOf, panelFlowProperties, type BookingQueued, type BookingWebhookContext, type BuildBookingBody, type BuildWebhookBody, type LeadWebhook, type LeadWebhookContext, type LeadWebhookOptions, type PanelFlow, type PanelFlowProperties, } from "./lead-webhook.js";
18
18
  export { checkWebhookUrl, openWebhookOutbox, WEBHOOK_HORIZON_MS, WEBHOOK_MAX_ATTEMPTS, WEBHOOK_MAX_DELAY_MS, WEBHOOK_TICK_MS, type DeadRow, type OutboxRow, type OutboxState, type TickReport, type WebhookOutbox, type WebhookOutboxOptions, type WebhookTarget, } from "./webhook-outbox.js";
19
19
  export { signatureHeaders, signWebhook, type WebhookSignatureHeaders, type WebhookSigning } from "./webhook-signature.js";
@@ -1,4 +1,5 @@
1
1
  import "server-only";
2
+ import type { BookingRequest } from "../core/booking/model.js";
2
3
  import { type Lead, type LeadChannel, type LeadSuspect } from "../core/lead.js";
3
4
  import type { LeadFlow } from "../core/pricing/flow.js";
4
5
  import type { ServerEnv } from "./env.js";
@@ -75,6 +76,32 @@ export declare function panelChannel(channel: LeadChannel): "form" | typeof PANE
75
76
  * lead is queued — the stored body is what every attempt sends.
76
77
  */
77
78
  export type BuildWebhookBody = (lead: Lead, ctx: LeadWebhookContext) => unknown;
79
+ /** What the brand's booking body builder is told besides the request. */
80
+ export interface BookingWebhookContext {
81
+ brandId: string;
82
+ /** When the site took the request. */
83
+ at: Date;
84
+ /** Fresh per request and stored with the body, like a lead's. */
85
+ idempotencyKey: string;
86
+ }
87
+ /**
88
+ * `booking.requested@1`'s body, built by the brand like `BuildWebhookBody`:
89
+ * `bookingRequestedProperties(request)` writes the properties as the panel
90
+ * reads them, and `request.leadRef` is the lead id the brand's
91
+ * `lead.created` must have carried for the two to join.
92
+ */
93
+ export type BuildBookingBody = (request: BookingRequest, ctx: BookingWebhookContext) => unknown;
94
+ /** Why `requestBooking` queued nothing. */
95
+ export type BookingQueued = {
96
+ kind: "queued";
97
+ row: number;
98
+ } | {
99
+ kind: "off";
100
+ } | {
101
+ kind: "duplicate";
102
+ } | {
103
+ kind: "unqueued";
104
+ };
78
105
  export interface LeadWebhook {
79
106
  /** Builds, serialises and queues the lead's body; returns the outbox row. */
80
107
  enqueue(lead: Lead, id: number, meta: {
@@ -92,6 +119,16 @@ export interface LeadWebhook {
92
119
  readonly panelSuspect: boolean;
93
120
  /** Whether `ctx.flow` is filled in (`LeadWebhookOptions.panelFlow`). */
94
121
  readonly panelFlow: boolean;
122
+ /** Whether `requestBooking` queues anything (`LeadWebhookOptions.panelBooking`). */
123
+ readonly panelBooking?: boolean;
124
+ /**
125
+ * Queues `booking.requested@1` behind the lead's `lead.created`, through
126
+ * the same outbox: not sent until that row is delivered, and a `409` / `425`
127
+ * for it retried. Once per lead: a second request is a `duplicate`,
128
+ * whatever it asks. `unqueued` when the lead's own row was never queued (a
129
+ * suspect held back); `off` without `panelBooking` or a `buildBookingBody`.
130
+ */
131
+ requestBooking?(request: BookingRequest): BookingQueued;
95
132
  readonly outbox: WebhookOutbox;
96
133
  }
97
134
  export interface LeadWebhookOptions extends WebhookOutboxOptions {
@@ -114,6 +151,15 @@ export interface LeadWebhookOptions extends WebhookOutboxOptions {
114
151
  * the outbox would park the lead. The leads table keeps them either way.
115
152
  */
116
153
  panelFlow?: boolean;
154
+ /**
155
+ * The switch for `booking.requested@1` (`requestBooking`). Off (the
156
+ * default) until the panel accepts the event type — it refuses an unknown
157
+ * one, and the outbox would park it. Off, a booking request is answered
158
+ * and dropped; the lead is untouched either way.
159
+ */
160
+ panelBooking?: boolean;
161
+ /** The body of `booking.requested@1`; absent → booking requests are dropped whatever `panelBooking` says. */
162
+ buildBookingBody?: BuildBookingBody | undefined;
117
163
  }
118
164
  /**
119
165
  * The lead webhook, or `null` when it is off: no `LEAD_WEBHOOK_URL`, or no
@@ -38,7 +38,7 @@ export function panelChannel(channel) {
38
38
  * so a broken setting fails startup rather than a lead.
39
39
  */
40
40
  export function leadWebhook(site, env, options) {
41
- const { buildBody, signing, panelSuspect = false, panelFlow = false, ...outboxOptions } = options;
41
+ const { buildBody, buildBookingBody, signing, panelSuspect = false, panelFlow = false, panelBooking = false, ...outboxOptions } = options;
42
42
  if (!env.leadWebhook || !buildBody)
43
43
  return null;
44
44
  if (env.leadsDb.kind !== "sqlite")
@@ -49,6 +49,22 @@ export function leadWebhook(site, env, options) {
49
49
  outbox,
50
50
  panelSuspect,
51
51
  panelFlow,
52
+ panelBooking,
53
+ requestBooking(request) {
54
+ if (!panelBooking || !buildBookingBody)
55
+ return { kind: "off" };
56
+ // The lead's own row, as `enqueue` refs it: the booking follows it, and
57
+ // a lead never queued (held back as suspect) is never booked to the panel.
58
+ const lead = `lead:${/^lead-(\d+)-/.exec(request.leadRef)?.[1] ?? ""}`;
59
+ if (!outbox.hasRef(lead))
60
+ return { kind: "unqueued" };
61
+ const ctx = { brandId: site.brand.id, at: new Date(now()), idempotencyKey: randomUUID() };
62
+ const body = JSON.stringify(buildBookingBody(request, ctx));
63
+ if (typeof body !== "string")
64
+ throw new Error("buildBookingBody returned nothing JSON can carry");
65
+ const row = outbox.enqueueOnce(body, `booking:${request.leadRef}`, lead);
66
+ return row === null ? { kind: "duplicate" } : { kind: "queued", row };
67
+ },
52
68
  enqueue(lead, id, meta) {
53
69
  const suspect = panelSuspect ? suspectOf(lead) : undefined;
54
70
  const flow = panelFlow ? panelFlowOf(lead) : undefined;
@@ -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.
@@ -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
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@evinvest/kitstart",
3
- "version": "0.10.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": {
@@ -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 });
@@ -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.10.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.10.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",
@@ -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
  ];
@@ -31,6 +31,9 @@ export const site = defineSite({
31
31
  publication: SERVICE_AREA_GATE,
32
32
  lead: LEAD,
33
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"] },
34
37
  });
35
38
 
36
39
  export type PageKey = (typeof site.pageKeys)[number];