@evinvest/kitstart 0.10.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. package/README.md +210 -17
  2. package/dist/core/accept.d.ts +19 -1
  3. package/dist/core/accept.js +25 -2
  4. package/dist/core/analytics.d.ts +13 -3
  5. package/dist/core/analytics.js +17 -2
  6. package/dist/core/booking/index.d.ts +3 -0
  7. package/dist/core/booking/index.js +3 -0
  8. package/dist/core/booking/model.d.ts +106 -0
  9. package/dist/core/booking/model.js +66 -0
  10. package/dist/core/booking/url.d.ts +17 -0
  11. package/dist/core/booking/url.js +53 -0
  12. package/dist/core/booking/validate.d.ts +27 -0
  13. package/dist/core/booking/validate.js +219 -0
  14. package/dist/core/lead-capture-text.d.ts +26 -2
  15. package/dist/core/lead-capture-text.js +32 -2
  16. package/dist/core/lead.d.ts +33 -0
  17. package/dist/core/lead.js +43 -0
  18. package/dist/core/phone.d.ts +3 -1
  19. package/dist/core/phone.js +28 -2
  20. package/dist/core/place/live.d.ts +6 -2
  21. package/dist/core/place/live.js +21 -2
  22. package/dist/core/place/types.d.ts +6 -0
  23. package/dist/core/place/types.js +0 -13
  24. package/dist/core/routing.d.ts +3 -2
  25. package/dist/core/routing.js +4 -3
  26. package/dist/core/site.d.ts +8 -0
  27. package/dist/core/site.js +15 -0
  28. package/dist/index.d.ts +4 -3
  29. package/dist/index.js +4 -3
  30. package/dist/next/booking-route.d.ts +31 -0
  31. package/dist/next/booking-route.js +97 -0
  32. package/dist/next/confirm-route.d.ts +39 -0
  33. package/dist/next/confirm-route.js +118 -0
  34. package/dist/next/index.d.ts +2 -0
  35. package/dist/next/index.js +2 -0
  36. package/dist/next/quote-route.js +14 -5
  37. package/dist/react/AnalyticsBoundary.js +8 -4
  38. package/dist/react/LeadBooking.d.ts +35 -0
  39. package/dist/react/LeadBooking.js +47 -0
  40. package/dist/react/LeadBookingManual.d.ts +17 -0
  41. package/dist/react/LeadBookingManual.js +50 -0
  42. package/dist/react/LeadCapture.d.ts +28 -5
  43. package/dist/react/LeadCapture.js +22 -8
  44. package/dist/react/LeadCaptureFields.js +60 -21
  45. package/dist/react/LeadCapturePriced.d.ts +3 -4
  46. package/dist/react/LeadCapturePriced.js +4 -4
  47. package/dist/react/analytics-context.d.ts +7 -0
  48. package/dist/react/analytics-context.js +7 -0
  49. package/dist/react/booking-adapters.d.ts +58 -0
  50. package/dist/react/booking-adapters.js +104 -0
  51. package/dist/react/index.d.ts +5 -1
  52. package/dist/react/index.js +4 -1
  53. package/dist/react/use-booking.d.ts +20 -0
  54. package/dist/react/use-booking.js +32 -0
  55. package/dist/react/use-lead-error.d.ts +3 -7
  56. package/dist/react/use-lead-error.js +9 -6
  57. package/dist/react/use-lead-submit.d.ts +7 -1
  58. package/dist/react/use-lead-submit.js +13 -3
  59. package/dist/react/use-repriced.d.ts +18 -0
  60. package/dist/react/use-repriced.js +27 -0
  61. package/dist/server/experiments-declared.d.ts +106 -0
  62. package/dist/server/experiments-declared.js +124 -0
  63. package/dist/server/experiments-source.d.ts +49 -0
  64. package/dist/server/experiments-source.js +88 -0
  65. package/dist/server/index.d.ts +4 -2
  66. package/dist/server/index.js +2 -0
  67. package/dist/server/lead-webhook.d.ts +63 -0
  68. package/dist/server/lead-webhook.js +24 -1
  69. package/dist/server/notify.d.ts +20 -2
  70. package/dist/server/notify.js +6 -4
  71. package/dist/server/place-source.js +1 -1
  72. package/dist/server/webhook-outbox.d.ts +11 -1
  73. package/dist/server/webhook-outbox.js +32 -5
  74. package/dist/testing/lead-rules.js +15 -1
  75. package/package.json +1 -1
  76. package/template/app/quote/booking/route.ts +11 -0
  77. package/template/app/quote/confirm/route.ts +12 -0
  78. package/template/flake.nix +1 -1
  79. package/template/package.json +2 -2
  80. package/template/src/shared/config/places.ts +4 -0
  81. package/template/src/shared/config/site.ts +3 -0
  82. package/template/tests/e2e/flows.spec.ts +86 -9
  83. package/template/tests/e2e/mock-panel.mjs +18 -4
  84. package/template/tests/e2e/quote-form.spec.ts +2 -2
@@ -0,0 +1,49 @@
1
+ import "server-only";
2
+ /**
3
+ * An operator's override of one experiment, as the panel serves it — the
4
+ * shape `applyOverrides` of `@evinvest/experiments` takes. Only the types are
5
+ * checked here; whether a field fits the experiment in code (weights of the
6
+ * right length, a holdout in `[0, 1)`) is `applyOverrides`' call, because only
7
+ * it knows the code's variants.
8
+ */
9
+ export interface ExperimentOverride {
10
+ readonly enabled?: boolean;
11
+ readonly weights?: readonly number[];
12
+ readonly holdout?: number;
13
+ }
14
+ export type ExperimentOverrides = Readonly<Record<string, ExperimentOverride>>;
15
+ /**
16
+ * The operator's overrides of the brand's experiments: `GET
17
+ * <base>/experiments`, the panel's `{ "experiments": { "<key>": {…} } }`.
18
+ *
19
+ * Read by the proxy on every request, so the answer comes from memory: the
20
+ * first call waits for the panel (bounded by `timeoutMs`), every later one is
21
+ * served from the cache — past the TTL too, while one refresh runs behind it
22
+ * (stale-while-revalidate). The source never throws and never makes a page
23
+ * wait on the panel twice. A failed refresh — an unreachable panel, a
24
+ * non-200, a body that is not the panel's shape — keeps the last good answer:
25
+ * a kill switch must not turn itself back on because the panel blinked. With
26
+ * no good answer yet (or no base URL) it is `{}`, the config in code.
27
+ */
28
+ export interface ExperimentsSourceOptions {
29
+ /** The brand's base on the panel (`LOCATIONS_API_URL`), read when asked; `null` → no overrides. */
30
+ baseUrl: () => string | null;
31
+ /** How long an answer is fresh; 30 s. */
32
+ ttlMs?: number;
33
+ /** How long the panel is given; 1.5 s. The proxy is waiting on the first call. */
34
+ timeoutMs?: number;
35
+ now?: () => number;
36
+ log?: Pick<Console, "error">;
37
+ }
38
+ export interface ExperimentsSource {
39
+ /** The overrides to apply now; `{}` when there are none or the panel cannot say. Never throws. */
40
+ overrides(): Promise<ExperimentOverrides>;
41
+ }
42
+ export declare const EXPERIMENTS_TTL_MS = 30000;
43
+ /**
44
+ * The panel's answer as overrides, or `null` when it is not the panel's shape.
45
+ * A field of the wrong type is dropped, an entry that is not an object is
46
+ * skipped; the rest stands.
47
+ */
48
+ export declare function parseExperimentOverrides(body: unknown): ExperimentOverrides | null;
49
+ export declare function createExperimentsSource(options: ExperimentsSourceOptions): ExperimentsSource;
@@ -0,0 +1,88 @@
1
+ import "server-only";
2
+ export const EXPERIMENTS_TTL_MS = 30_000;
3
+ const TIMEOUT_MS = 1_500;
4
+ const NONE = Object.freeze({});
5
+ const isRecord = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
6
+ /**
7
+ * The panel's answer as overrides, or `null` when it is not the panel's shape.
8
+ * A field of the wrong type is dropped, an entry that is not an object is
9
+ * skipped; the rest stands.
10
+ */
11
+ export function parseExperimentOverrides(body) {
12
+ if (!isRecord(body) || !isRecord(body["experiments"]))
13
+ return null;
14
+ const out = {};
15
+ for (const [key, entry] of Object.entries(body["experiments"])) {
16
+ if (!isRecord(entry))
17
+ continue;
18
+ const { enabled, weights, holdout } = entry;
19
+ out[key] = {
20
+ ...(typeof enabled === "boolean" ? { enabled } : {}),
21
+ ...(Array.isArray(weights) && weights.every((w) => typeof w === "number") ? { weights } : {}),
22
+ ...(typeof holdout === "number" ? { holdout } : {}),
23
+ };
24
+ }
25
+ return out;
26
+ }
27
+ export function createExperimentsSource(options) {
28
+ const ttl = options.ttlMs ?? EXPERIMENTS_TTL_MS;
29
+ const timeoutMs = options.timeoutMs ?? TIMEOUT_MS;
30
+ const now = options.now ?? Date.now;
31
+ const log = options.log ?? console;
32
+ let cached = null;
33
+ let inflight = null;
34
+ /** The panel's overrides, or `null` when it could not say. */
35
+ async function load(base) {
36
+ let response;
37
+ try {
38
+ // `no-store`: the cache is this module's; Next's data cache would add a second TTL.
39
+ response = await fetch(`${base}/experiments`, { cache: "no-store", headers: { Accept: "application/json" }, signal: AbortSignal.timeout(timeoutMs) });
40
+ }
41
+ catch (cause) {
42
+ log.error("experiments: the panel is unreachable, keeping the last good overrides", cause);
43
+ return null;
44
+ }
45
+ if (response.status !== 200) {
46
+ log.error(`experiments: the panel answered ${response.status}, keeping the last good overrides`);
47
+ return null;
48
+ }
49
+ let body;
50
+ try {
51
+ body = await response.json();
52
+ }
53
+ catch (cause) {
54
+ log.error("experiments: the panel's body is not JSON, keeping the last good overrides", cause);
55
+ return null;
56
+ }
57
+ const parsed = parseExperimentOverrides(body);
58
+ if (parsed === null)
59
+ log.error("experiments: the panel's body is not { experiments: {…} }, keeping the last good overrides");
60
+ return parsed;
61
+ }
62
+ // One request at a time, however many visitors arrive while it runs.
63
+ function refresh(base) {
64
+ inflight ??= load(base)
65
+ .then(loaded => {
66
+ // A failure still restarts the clock, so a down panel is asked once per TTL.
67
+ const value = loaded ?? (cached?.base === base ? cached.value : NONE);
68
+ cached = { base, at: now(), value };
69
+ return value;
70
+ })
71
+ .finally(() => {
72
+ inflight = null;
73
+ });
74
+ return inflight;
75
+ }
76
+ return {
77
+ async overrides() {
78
+ const base = options.baseUrl();
79
+ if (!base)
80
+ return NONE;
81
+ if (cached?.base !== base)
82
+ return refresh(base);
83
+ if (now() - cached.at >= ttl)
84
+ void refresh(base);
85
+ return cached.value;
86
+ },
87
+ };
88
+ }
@@ -6,14 +6,16 @@
6
6
  import "server-only";
7
7
  export { createServerEnv, parseServerEnv, type EnvSource, type ServerEnv } from "./env.js";
8
8
  export { createPlaceSource, PLACE_REVALIDATE_SECONDS, PlaceSourceError, type PlaceSource, type PlaceSourceOptions } from "./place-source.js";
9
+ export { createExperimentsSource, EXPERIMENTS_TTL_MS, parseExperimentOverrides, type ExperimentOverride, type ExperimentOverrides, type ExperimentsSource, type ExperimentsSourceOptions, } from "./experiments-source.js";
10
+ export { declarationProblem, declareExperiments, experimentsDeclaredBody, type DeclarationContext, type DeclaredExperiment, type DeclareOptions, type DeclareOutcome, type ExperimentDeclarationV1, type ExperimentsDeclaration, type ExperimentsDeclaredEvent, } from "./experiments-declared.js";
9
11
  export { createPricingSource, type PricingSource, type PricingSourceOptions } from "./pricing-source.js";
10
12
  export { describeLeadDb, openLeadStore, parseLeadDb, type LeadDb } from "./lead-store.js";
11
13
  export { openSqliteLeadStore, type SqliteLeadStore } from "./lead-store-sqlite.js";
12
14
  export { LeadStoreNotImplemented, openPostgresLeadStore } from "./lead-store-postgres.js";
13
15
  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";
16
+ export { defaultLeadMail, leadNotifier, MAIL_PER_MINUTE, type LeadMail, type LeadMailShown, type LeadNotifier, type NeedLabel, type NotifyEnv } from "./notify.js";
15
17
  export { clientKey, NO_CLIENT_ADDRESS, parseProxyTrust, type ProxyTrust } from "./client-key.js";
16
18
  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";
19
+ 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
20
  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
21
  export { signatureHeaders, signWebhook, type WebhookSignatureHeaders, type WebhookSigning } from "./webhook-signature.js";
@@ -6,6 +6,8 @@
6
6
  import "server-only";
7
7
  export { createServerEnv, parseServerEnv } from "./env.js";
8
8
  export { createPlaceSource, PLACE_REVALIDATE_SECONDS, PlaceSourceError } from "./place-source.js";
9
+ export { createExperimentsSource, EXPERIMENTS_TTL_MS, parseExperimentOverrides, } from "./experiments-source.js";
10
+ export { declarationProblem, declareExperiments, experimentsDeclaredBody, } from "./experiments-declared.js";
9
11
  export { createPricingSource } from "./pricing-source.js";
10
12
  export { describeLeadDb, openLeadStore, parseLeadDb } from "./lead-store.js";
11
13
  export { openSqliteLeadStore } from "./lead-store-sqlite.js";
@@ -1,7 +1,9 @@
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";
6
+ import { type DeclareOutcome, type ExperimentsDeclaration } from "./experiments-declared.js";
5
7
  import { type TickReport, type WebhookOutbox, type WebhookOutboxOptions } from "./webhook-outbox.js";
6
8
  import type { WebhookSigning } from "./webhook-signature.js";
7
9
  /** What the brand's body builder is told besides the lead. */
@@ -33,6 +35,14 @@ export interface LeadWebhookContext {
33
35
  * the properties. `panelFlowProperties` writes them as the panel reads them.
34
36
  */
35
37
  flow?: PanelFlow;
38
+ /**
39
+ * The visitor's analytics id as the page posted it (`ANALYTICS_ID_FIELD`),
40
+ * for `lead.created`'s `analytics_id` — so PostHog joins the lead to the
41
+ * visit. Absent from a page without the script, an older page, or one
42
+ * without analytics. Write it only once the panel's contract has the
43
+ * property: it refuses an unknown one, and the outbox would park the lead.
44
+ */
45
+ analyticsId?: string;
36
46
  }
37
47
  /** A lead's sale as the panel's `lead.created` may carry it. */
38
48
  export interface PanelFlow {
@@ -75,12 +85,39 @@ export declare function panelChannel(channel: LeadChannel): "form" | typeof PANE
75
85
  * lead is queued — the stored body is what every attempt sends.
76
86
  */
77
87
  export type BuildWebhookBody = (lead: Lead, ctx: LeadWebhookContext) => unknown;
88
+ /** What the brand's booking body builder is told besides the request. */
89
+ export interface BookingWebhookContext {
90
+ brandId: string;
91
+ /** When the site took the request. */
92
+ at: Date;
93
+ /** Fresh per request and stored with the body, like a lead's. */
94
+ idempotencyKey: string;
95
+ }
96
+ /**
97
+ * `booking.requested@1`'s body, built by the brand like `BuildWebhookBody`:
98
+ * `bookingRequestedProperties(request)` writes the properties as the panel
99
+ * reads them, and `request.leadRef` is the lead id the brand's
100
+ * `lead.created` must have carried for the two to join.
101
+ */
102
+ export type BuildBookingBody = (request: BookingRequest, ctx: BookingWebhookContext) => unknown;
103
+ /** Why `requestBooking` queued nothing. */
104
+ export type BookingQueued = {
105
+ kind: "queued";
106
+ row: number;
107
+ } | {
108
+ kind: "off";
109
+ } | {
110
+ kind: "duplicate";
111
+ } | {
112
+ kind: "unqueued";
113
+ };
78
114
  export interface LeadWebhook {
79
115
  /** Builds, serialises and queues the lead's body; returns the outbox row. */
80
116
  enqueue(lead: Lead, id: number, meta: {
81
117
  locale: string;
82
118
  formId: string;
83
119
  leadRef?: string;
120
+ analyticsId?: string;
84
121
  }): number;
85
122
  tick(): Promise<TickReport>;
86
123
  start(intervalMs?: number): void;
@@ -92,6 +129,23 @@ export interface LeadWebhook {
92
129
  readonly panelSuspect: boolean;
93
130
  /** Whether `ctx.flow` is filled in (`LeadWebhookOptions.panelFlow`). */
94
131
  readonly panelFlow: boolean;
132
+ /** Whether `requestBooking` queues anything (`LeadWebhookOptions.panelBooking`). */
133
+ readonly panelBooking?: boolean;
134
+ /**
135
+ * Queues `booking.requested@1` behind the lead's `lead.created`, through
136
+ * the same outbox: not sent until that row is delivered, and a `409` / `425`
137
+ * for it retried. Once per lead: a second request is a `duplicate`,
138
+ * whatever it asks. `unqueued` when the lead's own row was never queued (a
139
+ * suspect held back); `off` without `panelBooking` or a `buildBookingBody`.
140
+ */
141
+ requestBooking?(request: BookingRequest): BookingQueued;
142
+ /**
143
+ * Queues `experiments.declared@1` — the experiments this build runs — in
144
+ * the same outbox, signed as a lead is. Experiments the panel would refuse
145
+ * are left out and listed in `skipped`. `declareExperiments` is the safe
146
+ * wrapper for a start.
147
+ */
148
+ declareExperiments?(experiments: ExperimentsDeclaration, summaries?: Readonly<Record<string, string>>): DeclareOutcome;
95
149
  readonly outbox: WebhookOutbox;
96
150
  }
97
151
  export interface LeadWebhookOptions extends WebhookOutboxOptions {
@@ -114,6 +168,15 @@ export interface LeadWebhookOptions extends WebhookOutboxOptions {
114
168
  * the outbox would park the lead. The leads table keeps them either way.
115
169
  */
116
170
  panelFlow?: boolean;
171
+ /**
172
+ * The switch for `booking.requested@1` (`requestBooking`). Off (the
173
+ * default) until the panel accepts the event type — it refuses an unknown
174
+ * one, and the outbox would park it. Off, a booking request is answered
175
+ * and dropped; the lead is untouched either way.
176
+ */
177
+ panelBooking?: boolean;
178
+ /** The body of `booking.requested@1`; absent → booking requests are dropped whatever `panelBooking` says. */
179
+ buildBookingBody?: BuildBookingBody | undefined;
117
180
  }
118
181
  /**
119
182
  * The lead webhook, or `null` when it is off: no `LEAD_WEBHOOK_URL`, or no
@@ -1,6 +1,7 @@
1
1
  import "server-only";
2
2
  import { randomUUID } from "node:crypto";
3
3
  import { suspectOf } from "../core/lead.js";
4
+ import { experimentsDeclaredBody } from "./experiments-declared.js";
4
5
  import { openWebhookOutbox } from "./webhook-outbox.js";
5
6
  /** The sale `ctx.flow` describes, or nothing for a lead without a flow. */
6
7
  export function panelFlowOf(lead) {
@@ -38,17 +39,39 @@ export function panelChannel(channel) {
38
39
  * so a broken setting fails startup rather than a lead.
39
40
  */
40
41
  export function leadWebhook(site, env, options) {
41
- const { buildBody, signing, panelSuspect = false, panelFlow = false, ...outboxOptions } = options;
42
+ const { buildBody, buildBookingBody, signing, panelSuspect = false, panelFlow = false, panelBooking = false, ...outboxOptions } = options;
42
43
  if (!env.leadWebhook || !buildBody)
43
44
  return null;
44
45
  if (env.leadsDb.kind !== "sqlite")
45
46
  throw new Error("LEAD_WEBHOOK_URL: the webhook outbox needs the sqlite lead store");
46
47
  const outbox = openWebhookOutbox(env.leadsDb.path, { ...env.leadWebhook, signing }, outboxOptions);
47
48
  const now = options.now ?? Date.now;
49
+ const sourceId = env.leadWebhook.keyId;
48
50
  return {
49
51
  outbox,
50
52
  panelSuspect,
51
53
  panelFlow,
54
+ panelBooking,
55
+ requestBooking(request) {
56
+ if (!panelBooking || !buildBookingBody)
57
+ return { kind: "off" };
58
+ // The lead's own row, as `enqueue` refs it: the booking follows it, and
59
+ // a lead never queued (held back as suspect) is never booked to the panel.
60
+ const lead = `lead:${/^lead-(\d+)-/.exec(request.leadRef)?.[1] ?? ""}`;
61
+ if (!outbox.hasRef(lead))
62
+ return { kind: "unqueued" };
63
+ const ctx = { brandId: site.brand.id, at: new Date(now()), idempotencyKey: randomUUID() };
64
+ const body = JSON.stringify(buildBookingBody(request, ctx));
65
+ if (typeof body !== "string")
66
+ throw new Error("buildBookingBody returned nothing JSON can carry");
67
+ const row = outbox.enqueueOnce(body, `booking:${request.leadRef}`, lead);
68
+ return row === null ? { kind: "duplicate" } : { kind: "queued", row };
69
+ },
70
+ declareExperiments(experiments, summaries) {
71
+ const { body, skipped } = experimentsDeclaredBody(experiments, { brandId: site.brand.id, sourceId, at: new Date(now()), ...(summaries ? { summaries } : {}) });
72
+ const row = outbox.enqueue(JSON.stringify(body), `experiments:${body.events[0].id}`);
73
+ return { kind: "queued", row, skipped };
74
+ },
52
75
  enqueue(lead, id, meta) {
53
76
  const suspect = panelSuspect ? suspectOf(lead) : undefined;
54
77
  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.12.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.12.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.12.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];