@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
@@ -0,0 +1,20 @@
1
+ import type { BookingRequestedProperties } from "../core/booking/model.js";
2
+ /** A request as the page posts it; a provider outside the closed set is the server's to refuse. */
3
+ export type PostedBooking = Omit<BookingRequestedProperties, "provider"> & {
4
+ provider: string;
5
+ };
6
+ /** Where `LeadCapture` posts a booking request: `bookingRoute`, beside the quote route. */
7
+ export declare const BOOKING_ACTION = "/quote/booking";
8
+ export interface BookingReport {
9
+ /** The visitor opened the booking (or sent a `manual` preference): the event, and `booking.requested@1` to the site. */
10
+ requested(properties: PostedBooking): Promise<boolean>;
11
+ /** The provider said the slot is booked. */
12
+ booked(): void;
13
+ }
14
+ /**
15
+ * The page's side of a booking: `lead_booking_open` / `lead_booking_done`
16
+ * (the provider only, never the URL), and the request to the site's
17
+ * `bookingRoute`, which the panel hears as `booking.requested@1`. The post is
18
+ * first-party, and the page stays: a provider's page opens in a new tab.
19
+ */
20
+ export declare function useBooking(provider: string, submission: string, formId: string, action?: string): BookingReport;
@@ -0,0 +1,32 @@
1
+ "use client";
2
+ import { useCallback, useMemo } from "react";
3
+ import { EVENTS } from "../core/analytics.js";
4
+ import { useAnalyticsSink } from "./analytics-context.js";
5
+ /** Where `LeadCapture` posts a booking request: `bookingRoute`, beside the quote route. */
6
+ export const BOOKING_ACTION = "/quote/booking";
7
+ /**
8
+ * The page's side of a booking: `lead_booking_open` / `lead_booking_done`
9
+ * (the provider only, never the URL), and the request to the site's
10
+ * `bookingRoute`, which the panel hears as `booking.requested@1`. The post is
11
+ * first-party, and the page stays: a provider's page opens in a new tab.
12
+ */
13
+ export function useBooking(provider, submission, formId, action = BOOKING_ACTION) {
14
+ const sink = useAnalyticsSink();
15
+ const props = useMemo(() => ({ provider, form_id: formId }), [provider, formId]);
16
+ const requested = useCallback(async (properties) => {
17
+ sink?.capture(EVENTS.bookingOpen, props);
18
+ try {
19
+ const res = await fetch(action, {
20
+ method: "POST",
21
+ headers: { "Content-Type": "application/json" },
22
+ body: JSON.stringify({ submission, ...properties }),
23
+ });
24
+ return res.ok;
25
+ }
26
+ catch {
27
+ return false;
28
+ }
29
+ }, [sink, props, action, submission]);
30
+ const booked = useCallback(() => sink?.capture(EVENTS.bookingDone, props), [sink, props]);
31
+ return { requested, booked };
32
+ }
@@ -1,11 +1,7 @@
1
1
  import { type RefObject } from "react";
2
- import { type LeadChannel } from "../core/lead.js";
2
+ import { type LeadChannel, type LeadError } from "../core/lead.js";
3
3
  import type { LeadCaptureText } from "../core/lead-capture-text.js";
4
- /** A refusal the server sent back: which of the card's forms, and the field it is about. */
5
- export interface LeadError {
6
- channel: LeadChannel;
7
- field: string;
8
- }
4
+ export type { LeadError };
9
5
  /** The fields `LeadCapture` draws itself, which show a refusal under themselves. */
10
6
  export declare const OWN_FIELDS: Readonly<Record<LeadChannel, readonly string[]>>;
11
7
  /** The id of the message a refusal of a field the card does not draw is shown in, above the submit. */
@@ -21,4 +17,4 @@ export declare function errorText(field: string, text: LeadCaptureText): string;
21
17
  * `aria-invalid` and pointed at the message above the submit. Fixing that
22
18
  * control clears it.
23
19
  */
24
- export declare function useLeadError(root: RefObject<HTMLElement | null>, id: string): [LeadError | null, (error: LeadError | null) => void];
20
+ export declare function useLeadError(root: RefObject<HTMLElement | null>, id: string, initial?: LeadError | null): [LeadError | null, (error: LeadError | null) => void];
@@ -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);
@@ -8,6 +8,8 @@ export interface LeadEvents {
8
8
  fieldError(field: LeadField): void;
9
9
  /** The script's post got no answer: `network` or `timeout`, and which form. */
10
10
  submitError(reason: "network" | "timeout", channel: "form" | "callback"): void;
11
+ /** An estimate's price for a full set of answers: its band only, once per need and band. */
12
+ estimateShown(need: string, cents: number): void;
11
13
  }
12
14
  /**
13
15
  * The lead form's own funnel, reported through the boundary's sink: seen
@@ -1,6 +1,7 @@
1
1
  "use client";
2
2
  import { useEffect, useMemo, useRef } from "react";
3
3
  import { EVENTS, experimentProps } from "../core/analytics.js";
4
+ import { centsBucket } from "../core/pricing/price.js";
4
5
  import { useAnalyticsSink } from "./analytics-context.js";
5
6
  /** The attribute a lead form's control names its role in, for `lead_form_field_error`. */
6
7
  export const FIELD_ATTR = "data-lead-field";
@@ -18,6 +19,7 @@ export function useLeadEvents(root, tags) {
18
19
  const variant = tags.experiment?.variant;
19
20
  const props = useMemo(() => ({ form_id: formId, layout, ...experimentProps(name, variant) }), [formId, layout, name, variant]);
20
21
  const started = useRef(false);
22
+ const shown = useRef(new Set());
21
23
  useEffect(() => {
22
24
  const el = root.current;
23
25
  if (!sink || !el)
@@ -59,5 +61,13 @@ export function useLeadEvents(root, tags) {
59
61
  step: step => sink?.capture(EVENTS.formStep, { ...props, step }),
60
62
  fieldError: field => sink?.capture(EVENTS.fieldError, { ...props, field, blocking: false }),
61
63
  submitError: (reason, channel) => sink?.capture(EVENTS.submitError, { ...props, reason, channel }),
64
+ estimateShown: (need, cents) => {
65
+ const cents_bucket = centsBucket(cents);
66
+ const key = `${need}\n${cents_bucket}`;
67
+ if (shown.current.has(key))
68
+ return;
69
+ shown.current.add(key);
70
+ sink?.capture(EVENTS.estimateShown, { ...props, need, cents_bucket });
71
+ },
62
72
  }), [sink, props]);
63
73
  }
@@ -7,6 +7,15 @@ export interface LeadSent {
7
7
  phone: string;
8
8
  /** The name field's value, when the form has one and it was filled. */
9
9
  name: string | null;
10
+ /** The lead's public reference, as the route answered it (`leadRef`), for a `booking` to name the lead. */
11
+ lead?: string;
12
+ /** The price the server took the lead at, for an `estimate` or `fixed` need. */
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;
10
19
  }
11
20
  /** Leaving the page, behind a seam a test can stand in for (jsdom cannot navigate). */
12
21
  export declare const navigation: {
@@ -23,7 +32,8 @@ export type SendFailure = "network" | "timeout";
23
32
  export declare const SUBMIT_TIMEOUT_MS = 15000;
24
33
  export interface LeadSubmitHandlers {
25
34
  /** The route refused the lead: the field to fix. */
26
- 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;
27
37
  /** No answer: the form stays as typed, with a retry. */
28
38
  onFailed: (channel: LeadChannel, failure: SendFailure) => void;
29
39
  }
@@ -42,15 +52,15 @@ export interface LeadSubmit {
42
52
  }
43
53
  /**
44
54
  * The lead posted by a script, so a refusal keeps what was typed: the body
45
- * and the endpoint are the form's own, asked for JSON. Taken → the brand's
46
- * in-card success when it has one (`done`), else the thanks page the route
55
+ * and the endpoint are the form's own, asked for JSON. Taken → the in-card
56
+ * success when the card has one for that channel (`done`), else the thanks page the route
47
57
  * names. Refused → `onRefused` with the field, the form as it was. No answer
48
58
  * — no network, or nothing in `SUBMIT_TIMEOUT_MS` — is said in place with a
49
59
  * retry, never by leaving the page: the browser's error page would lose the
50
60
  * form. Any other answer (the store's 500) submits the form for real, so the
51
61
  * server's own page says what went wrong; the submission id makes that safe.
52
62
  */
53
- export declare function useLeadSubmit(done: boolean, fields: {
63
+ export declare function useLeadSubmit(done: boolean | ((channel: LeadChannel) => boolean), fields: {
54
64
  mobile: string;
55
65
  name: string | undefined;
56
66
  }, on: LeadSubmitHandlers): LeadSubmit;
@@ -41,6 +41,7 @@ function stamp(form) {
41
41
  minted.set(form, { id, lead });
42
42
  field.value = id;
43
43
  }
44
+ const LEAD_REF = /^lead-\d+-[0-9a-f]{8}$/;
44
45
  /** A root-relative path on this origin — never `//elsewhere`. */
45
46
  const OWN_PATH = /^\/(?!\/)/;
46
47
  /**
@@ -58,11 +59,18 @@ async function readAnswer(res) {
58
59
  });
59
60
  if (typeof body !== "object" || body === null)
60
61
  return null;
61
- const { ok, location, field } = body;
62
- if (ok === true && typeof location === "string" && OWN_PATH.test(location))
63
- return { ok, location };
62
+ const { ok, location, field, lead, cents } = body;
63
+ if (ok === true && typeof location === "string" && OWN_PATH.test(location)) {
64
+ return {
65
+ ok,
66
+ location,
67
+ ...(typeof lead === "string" && LEAD_REF.test(lead) ? { lead } : {}),
68
+ ...(typeof cents === "number" && Number.isSafeInteger(cents) && cents >= 0 ? { cents } : {}),
69
+ };
70
+ }
71
+ // `cents`: with `price_changed`, the price the lead would be taken at now.
64
72
  if (ok === false && typeof field === "string")
65
- return { ok, field };
73
+ return { ok, field, ...(typeof cents === "number" && Number.isSafeInteger(cents) && cents >= 0 ? { cents } : {}) };
66
74
  return null;
67
75
  }
68
76
  if (res.ok && res.redirected) {
@@ -76,8 +84,8 @@ async function readAnswer(res) {
76
84
  export const SUBMIT_TIMEOUT_MS = 15_000;
77
85
  /**
78
86
  * The lead posted by a script, so a refusal keeps what was typed: the body
79
- * and the endpoint are the form's own, asked for JSON. Taken → the brand's
80
- * in-card success when it has one (`done`), else the thanks page the route
87
+ * and the endpoint are the form's own, asked for JSON. Taken → the in-card
88
+ * success when the card has one for that channel (`done`), else the thanks page the route
81
89
  * names. Refused → `onRefused` with the field, the form as it was. No answer
82
90
  * — no network, or nothing in `SUBMIT_TIMEOUT_MS` — is said in place with a
83
91
  * retry, never by leaving the page: the browser's error page would lose the
@@ -129,13 +137,15 @@ export function useLeadSubmit(done, fields, on) {
129
137
  return fallBack();
130
138
  if (!answer.ok) {
131
139
  settle();
132
- return on.onRefused(lead.channel, answer.field);
140
+ return on.onRefused(lead.channel, answer.field, answer.cents);
133
141
  }
134
142
  // Still pending: the page is leaving, or the form is gone. The next
135
143
  // lead from this form (Back, another need) gets an id of its own.
136
144
  minted.delete(form);
137
- if (done)
138
- setSent(lead);
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 } : {};
147
+ if (typeof done === "function" ? done(lead.channel) : done)
148
+ setSent({ ...lead, ...ref, ...(answer.cents !== undefined ? { cents: answer.cents } : {}) });
139
149
  else
140
150
  navigation.assign(answer.location);
141
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
+ }
@@ -6,13 +6,14 @@
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 { createPricingSource, type PricingSource, type PricingSourceOptions } from "./pricing-source.js";
9
10
  export { describeLeadDb, openLeadStore, parseLeadDb, type LeadDb } from "./lead-store.js";
10
11
  export { openSqliteLeadStore, type SqliteLeadStore } from "./lead-store-sqlite.js";
11
12
  export { LeadStoreNotImplemented, openPostgresLeadStore } from "./lead-store-postgres.js";
12
13
  export { parseSmtpUrl, sendMail, type Mail, type SendOptions } from "./smtp.js";
13
- 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";
14
15
  export { clientKey, NO_CLIENT_ADDRESS, parseProxyTrust, type ProxyTrust } from "./client-key.js";
15
16
  export { checkLeadStore } from "./boot.js";
16
- export { leadWebhook, panelChannel, type BuildWebhookBody, type LeadWebhook, type LeadWebhookContext, type LeadWebhookOptions } 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";
17
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";
18
19
  export { signatureHeaders, signWebhook, type WebhookSignatureHeaders, type WebhookSigning } from "./webhook-signature.js";
@@ -6,6 +6,7 @@
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 { createPricingSource } from "./pricing-source.js";
9
10
  export { describeLeadDb, openLeadStore, parseLeadDb } from "./lead-store.js";
10
11
  export { openSqliteLeadStore } from "./lead-store-sqlite.js";
11
12
  export { LeadStoreNotImplemented, openPostgresLeadStore } from "./lead-store-postgres.js";
@@ -13,6 +14,6 @@ export { parseSmtpUrl, sendMail } from "./smtp.js";
13
14
  export { defaultLeadMail, leadNotifier, MAIL_PER_MINUTE } from "./notify.js";
14
15
  export { clientKey, NO_CLIENT_ADDRESS, parseProxyTrust } from "./client-key.js";
15
16
  export { checkLeadStore } from "./boot.js";
16
- export { leadWebhook, panelChannel } from "./lead-webhook.js";
17
+ export { leadWebhook, panelChannel, panelFlowOf, panelFlowProperties, } from "./lead-webhook.js";
17
18
  export { checkWebhookUrl, openWebhookOutbox, WEBHOOK_HORIZON_MS, WEBHOOK_MAX_ATTEMPTS, WEBHOOK_MAX_DELAY_MS, WEBHOOK_TICK_MS, } from "./webhook-outbox.js";
18
19
  export { signatureHeaders, signWebhook } from "./webhook-signature.js";
@@ -2,6 +2,7 @@ import "server-only";
2
2
  import { mkdirSync } from "node:fs";
3
3
  import { dirname } from "node:path";
4
4
  import { channelOf, LEAD_SCHEMA_VERSION } from "../core/lead.js";
5
+ import { LEAD_FLOWS } from "../core/pricing/flow.js";
5
6
  // `node:sqlite` is reached through `getBuiltinModule` rather than an import so
6
7
  // the bundler never has to resolve a builtin it may not know yet; the type is
7
8
  // the module's own.
@@ -57,6 +58,15 @@ const STEPS = [
57
58
  db.exec("ALTER TABLE leads ADD COLUMN submission_id TEXT");
58
59
  db.exec("CREATE UNIQUE INDEX leads_submission_id ON leads (submission_id) WHERE submission_id IS NOT NULL");
59
60
  },
61
+ // 7 — how the need was sold (`quote`, `estimate`, `fixed`), and for the two
62
+ // priced flows the server's price, the model's date and an estimate's
63
+ // answers (JSON). Nullable: a row from before, a callback, a quote.
64
+ db => {
65
+ db.exec("ALTER TABLE leads ADD COLUMN flow TEXT");
66
+ db.exec("ALTER TABLE leads ADD COLUMN quoted_cents INTEGER");
67
+ db.exec("ALTER TABLE leads ADD COLUMN pricing_valid_from TEXT");
68
+ db.exec("ALTER TABLE leads ADD COLUMN estimate_inputs TEXT");
69
+ },
60
70
  ];
61
71
  if (STEPS.length !== LEAD_SCHEMA_VERSION) {
62
72
  // The version belongs to the port, shared by every adapter; this table of
@@ -69,22 +79,38 @@ function str(row, key) {
69
79
  return typeof value === "string" ? value : null;
70
80
  }
71
81
  const VERDICTS = ["honeypot", "too-fast", "rate-limited"];
82
+ /** A JSON object of strings, or `{}`: what `extras` and `estimate_inputs` hold. */
83
+ function strings(text) {
84
+ const value = JSON.parse(text ?? "{}");
85
+ return Object.fromEntries(Object.entries(typeof value === "object" && value !== null ? value : {}).filter((e) => typeof e[1] === "string"));
86
+ }
87
+ function priceOf(row) {
88
+ const cents = column(row, "quoted_cents");
89
+ const validFrom = str(row, "pricing_valid_from");
90
+ if ((typeof cents !== "number" && typeof cents !== "bigint") || validFrom === null)
91
+ return null;
92
+ const inputs = str(row, "estimate_inputs");
93
+ return { cents: Number(cents), validFrom, ...(inputs !== null ? { inputs: strings(inputs) } : {}) };
94
+ }
72
95
  /** A stored row read back as the lead it was written from. */
73
96
  function leadOf(row) {
74
97
  const verdict = str(row, "spam_verdict");
75
- const extras = JSON.parse(str(row, "extras") ?? "{}");
76
98
  const consentAt = str(row, "consent_at");
77
99
  const submissionId = str(row, "submission_id");
100
+ const flow = LEAD_FLOWS.find(f => f === str(row, "flow"));
101
+ const price = priceOf(row);
78
102
  return {
79
103
  subject: str(row, "job") ?? "",
80
104
  locality: str(row, "zip") ?? "",
81
105
  mobile: str(row, "mobile") ?? "",
82
- extras: Object.fromEntries(Object.entries(typeof extras === "object" && extras !== null ? extras : {}).filter((e) => typeof e[1] === "string")),
106
+ extras: strings(str(row, "extras")),
83
107
  placeSlug: str(row, "location_id"),
84
108
  spamVerdict: verdict !== null && VERDICTS.includes(verdict) ? verdict : null,
85
109
  channel: str(row, "channel") === "callback" ? "callback" : "form",
86
110
  ...(consentAt !== null ? { consent: { at: consentAt, text: str(row, "consent_text") ?? "" } } : {}),
87
111
  ...(submissionId !== null ? { submissionId } : {}),
112
+ ...(flow !== undefined ? { flow } : {}),
113
+ ...(price !== null ? { price } : {}),
88
114
  };
89
115
  }
90
116
  function userVersion(db) {
@@ -168,13 +194,13 @@ export function openSqliteLeadStore(path) {
168
194
  db.close();
169
195
  throw error;
170
196
  }
171
- const insert = db.prepare("INSERT INTO leads (job, zip, mobile, location_id, spam_verdict, extras, channel, consent_at, consent_text, submission_id) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id");
172
- const bySubmission = db.prepare("SELECT id, job, zip, mobile, location_id, spam_verdict, extras, channel, consent_at, consent_text, submission_id FROM leads WHERE submission_id = ?");
197
+ const insert = db.prepare("INSERT INTO leads (job, zip, mobile, location_id, spam_verdict, extras, channel, consent_at, consent_text, submission_id, flow, quoted_cents, pricing_valid_from, estimate_inputs) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id");
198
+ const bySubmission = db.prepare("SELECT id, job, zip, mobile, location_id, spam_verdict, extras, channel, consent_at, consent_text, submission_id, flow, quoted_cents, pricing_valid_from, estimate_inputs FROM leads WHERE submission_id = ?");
173
199
  const count = db.prepare("SELECT COUNT(*) AS n FROM leads");
174
200
  return {
175
201
  async insert(lead) {
176
202
  const extras = Object.keys(lead.extras).length > 0 ? JSON.stringify(lead.extras) : null;
177
- return integer(insert.get(lead.subject, lead.locality, lead.mobile, lead.placeSlug, lead.spamVerdict, extras, channelOf(lead), lead.consent?.at ?? null, lead.consent?.text ?? null, lead.submissionId ?? null), "id");
203
+ return integer(insert.get(lead.subject, lead.locality, lead.mobile, lead.placeSlug, lead.spamVerdict, extras, channelOf(lead), lead.consent?.at ?? null, lead.consent?.text ?? null, lead.submissionId ?? null, lead.flow ?? null, lead.price?.cents ?? null, lead.price?.validFrom ?? null, lead.price?.inputs ? JSON.stringify(lead.price.inputs) : null), "id");
178
204
  },
179
205
  async findSubmission(submissionId) {
180
206
  const row = bySubmission.get(submissionId);
@@ -1,5 +1,7 @@
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";
4
+ import type { LeadFlow } from "../core/pricing/flow.js";
3
5
  import type { ServerEnv } from "./env.js";
4
6
  import { type TickReport, type WebhookOutbox, type WebhookOutboxOptions } from "./webhook-outbox.js";
5
7
  import type { WebhookSigning } from "./webhook-signature.js";
@@ -20,7 +22,44 @@ export interface LeadWebhookContext {
20
22
  * body must not carry the property at all.
21
23
  */
22
24
  suspect?: LeadSuspect;
25
+ /**
26
+ * The lead's public reference (`leadRef`, `lead-<row>-<8 hex>`): the one
27
+ * the page was answered with (`LeadSent.lead`), so a later step on the page
28
+ * can name the lead the panel knows. Absent only on a context built by hand.
29
+ */
30
+ leadRef?: string;
31
+ /**
32
+ * How the need was sold and at what price — only under `panelFlow`, and
33
+ * only on a lead with a flow; otherwise absent, and the body must not carry
34
+ * the properties. `panelFlowProperties` writes them as the panel reads them.
35
+ */
36
+ flow?: PanelFlow;
37
+ }
38
+ /** A lead's sale as the panel's `lead.created` may carry it. */
39
+ export interface PanelFlow {
40
+ flow: LeadFlow;
41
+ /** With `estimate` and `fixed` only, always with `pricingValidFrom`. */
42
+ quotedCents?: number;
43
+ /** The price list's date, `YYYY-MM-DD`. */
44
+ pricingValidFrom?: string;
45
+ /** With `estimate` only, and only when it asked anything: input id → option id, slugs. */
46
+ estimateInputs?: Readonly<Record<string, string>>;
23
47
  }
48
+ /** The sale `ctx.flow` describes, or nothing for a lead without a flow. */
49
+ export declare function panelFlowOf(lead: Pick<Lead, "flow" | "price">): PanelFlow | undefined;
50
+ /**
51
+ * `ctx.flow` as `lead.created`'s properties: `flow`, then `quoted_cents` and
52
+ * `pricing_valid_from` together (estimate and fixed), then `estimate_inputs`
53
+ * (estimate) — the panel's closed vocabulary. Spread into the body's
54
+ * `properties`; `{}` without a flow.
55
+ */
56
+ export interface PanelFlowProperties {
57
+ flow: LeadFlow;
58
+ quoted_cents?: number;
59
+ pricing_valid_from?: string;
60
+ estimate_inputs?: Readonly<Record<string, string>>;
61
+ }
62
+ export declare function panelFlowProperties(flow: PanelFlow | undefined): PanelFlowProperties | Record<string, never>;
24
63
  /**
25
64
  * What the Service-Arb panel's `lead.created` calls a callback request. Its
26
65
  * `properties.channel` is a closed set (`form` | `phone_inbound`) and an event
@@ -37,11 +76,38 @@ export declare function panelChannel(channel: LeadChannel): "form" | typeof PANE
37
76
  * lead is queued — the stored body is what every attempt sends.
38
77
  */
39
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
+ };
40
105
  export interface LeadWebhook {
41
106
  /** Builds, serialises and queues the lead's body; returns the outbox row. */
42
107
  enqueue(lead: Lead, id: number, meta: {
43
108
  locale: string;
44
109
  formId: string;
110
+ leadRef?: string;
45
111
  }): number;
46
112
  tick(): Promise<TickReport>;
47
113
  start(intervalMs?: number): void;
@@ -51,6 +117,18 @@ export interface LeadWebhook {
51
117
  requeueDead(): number;
52
118
  /** Whether a suspect lead is queued, marked (`LeadWebhookOptions.panelSuspect`). */
53
119
  readonly panelSuspect: boolean;
120
+ /** Whether `ctx.flow` is filled in (`LeadWebhookOptions.panelFlow`). */
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;
54
132
  readonly outbox: WebhookOutbox;
55
133
  }
56
134
  export interface LeadWebhookOptions extends WebhookOutboxOptions {
@@ -66,6 +144,22 @@ export interface LeadWebhookOptions extends WebhookOutboxOptions {
66
144
  * suspect, for the brand's body to carry.
67
145
  */
68
146
  panelSuspect?: boolean;
147
+ /**
148
+ * The switch for the sale's properties (`ctx.flow`: `flow`, `quoted_cents`,
149
+ * `pricing_valid_from`, `estimate_inputs`). Off (the default) until the
150
+ * panel's `lead.created` accepts them — it refuses an unknown property, and
151
+ * the outbox would park the lead. The leads table keeps them either way.
152
+ */
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;
69
163
  }
70
164
  /**
71
165
  * The lead webhook, or `null` when it is off: no `LEAD_WEBHOOK_URL`, or no
@@ -2,6 +2,24 @@ import "server-only";
2
2
  import { randomUUID } from "node:crypto";
3
3
  import { suspectOf } from "../core/lead.js";
4
4
  import { openWebhookOutbox } from "./webhook-outbox.js";
5
+ /** The sale `ctx.flow` describes, or nothing for a lead without a flow. */
6
+ export function panelFlowOf(lead) {
7
+ if (!lead.flow)
8
+ return undefined;
9
+ if (lead.flow === "quote" || !lead.price)
10
+ return { flow: "quote" };
11
+ const inputs = lead.flow === "estimate" && lead.price.inputs && Object.keys(lead.price.inputs).length > 0 ? { estimateInputs: lead.price.inputs } : {};
12
+ return { flow: lead.flow, quotedCents: lead.price.cents, pricingValidFrom: lead.price.validFrom, ...inputs };
13
+ }
14
+ export function panelFlowProperties(flow) {
15
+ if (!flow)
16
+ return {};
17
+ return {
18
+ flow: flow.flow,
19
+ ...(flow.quotedCents !== undefined && flow.pricingValidFrom !== undefined ? { quoted_cents: flow.quotedCents, pricing_valid_from: flow.pricingValidFrom } : {}),
20
+ ...(flow.estimateInputs ? { estimate_inputs: flow.estimateInputs } : {}),
21
+ };
22
+ }
5
23
  /**
6
24
  * What the Service-Arb panel's `lead.created` calls a callback request. Its
7
25
  * `properties.channel` is a closed set (`form` | `phone_inbound`) and an event
@@ -20,7 +38,7 @@ export function panelChannel(channel) {
20
38
  * so a broken setting fails startup rather than a lead.
21
39
  */
22
40
  export function leadWebhook(site, env, options) {
23
- const { buildBody, signing, panelSuspect = false, ...outboxOptions } = options;
41
+ const { buildBody, buildBookingBody, signing, panelSuspect = false, panelFlow = false, panelBooking = false, ...outboxOptions } = options;
24
42
  if (!env.leadWebhook || !buildBody)
25
43
  return null;
26
44
  if (env.leadsDb.kind !== "sqlite")
@@ -30,9 +48,35 @@ export function leadWebhook(site, env, options) {
30
48
  return {
31
49
  outbox,
32
50
  panelSuspect,
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
+ },
33
68
  enqueue(lead, id, meta) {
34
69
  const suspect = panelSuspect ? suspectOf(lead) : undefined;
35
- const ctx = { leadId: id, brandId: site.brand.id, ...meta, at: new Date(now()), idempotencyKey: randomUUID(), ...(suspect ? { suspect } : {}) };
70
+ const flow = panelFlow ? panelFlowOf(lead) : undefined;
71
+ const ctx = {
72
+ leadId: id,
73
+ brandId: site.brand.id,
74
+ ...meta,
75
+ at: new Date(now()),
76
+ idempotencyKey: randomUUID(),
77
+ ...(suspect ? { suspect } : {}),
78
+ ...(flow ? { flow } : {}),
79
+ };
36
80
  const body = JSON.stringify(buildBody(lead, ctx));
37
81
  if (typeof body !== "string")
38
82
  throw new Error("buildWebhookBody returned nothing JSON can carry");