@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,97 @@
1
+ import { leadRef } from "../core/accept.js";
2
+ import { bookingRequestProblems, parseBookingRequest } from "../core/booking/validate.js";
3
+ import { openLeadStore } from "../server/lead-store.js";
4
+ const MAX_BODY = 4 * 1024;
5
+ const DAY_MS = 86_400_000;
6
+ /** How far ahead a preferred day may be; a day already past (a day's slack for time zones) is refused. */
7
+ const MAX_AHEAD_DAYS = 366;
8
+ const SUBMISSION = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
9
+ function json(status, body) {
10
+ return new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json", "Cache-Control": "no-store" } });
11
+ }
12
+ const isObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
13
+ function inWindow(day, now) {
14
+ const at = Date.parse(`${day}T00:00:00Z`);
15
+ return at >= now - 2 * DAY_MS && at <= now + MAX_AHEAD_DAYS * DAY_MS;
16
+ }
17
+ /** The body as text, or `null` past `MAX_BODY` — counted as it arrives, since a chunked body has no length. */
18
+ async function readCapped(request) {
19
+ if (Number(request.headers.get("content-length") ?? 0) > MAX_BODY)
20
+ return null;
21
+ if (!request.body)
22
+ return "";
23
+ const reader = request.body.getReader();
24
+ const chunks = [];
25
+ let size = 0;
26
+ for (;;) {
27
+ const { done, value } = await reader.read();
28
+ if (done)
29
+ break;
30
+ size += value.byteLength;
31
+ if (size > MAX_BODY) {
32
+ await reader.cancel();
33
+ return null;
34
+ }
35
+ chunks.push(value);
36
+ }
37
+ const decoder = new TextDecoder();
38
+ return chunks.map(c => decoder.decode(c, { stream: true })).join("") + decoder.decode();
39
+ }
40
+ export function bookingRoute(deps) {
41
+ const log = deps.log ?? console;
42
+ const now = deps.now ?? Date.now;
43
+ let store;
44
+ let webhook;
45
+ return async (request) => {
46
+ // A cross-origin page cannot send this type without a preflight, which
47
+ // this route never answers: the JSON type is the CSRF check.
48
+ if (!(request.headers.get("content-type") ?? "").startsWith("application/json"))
49
+ return json(415, { ok: false });
50
+ const text = await readCapped(request);
51
+ if (text === null)
52
+ return json(413, { ok: false });
53
+ let body;
54
+ try {
55
+ body = JSON.parse(text);
56
+ }
57
+ catch {
58
+ return json(400, { ok: false });
59
+ }
60
+ if (!isObject(body))
61
+ return json(400, { ok: false });
62
+ const { submission, ...properties } = body;
63
+ if (typeof submission !== "string" || !SUBMISSION.test(submission))
64
+ return json(422, { ok: false });
65
+ if (bookingRequestProblems(properties).length > 0)
66
+ return json(422, { ok: false });
67
+ const booking = parseBookingRequest(properties);
68
+ if (booking.preferredDate !== undefined && !inWindow(booking.preferredDate, now()))
69
+ return json(422, { ok: false });
70
+ let found;
71
+ try {
72
+ store ??= deps.store ? deps.store() : openLeadStore(deps.env().leadsDb);
73
+ if (!store.findSubmission)
74
+ return json(501, { ok: false });
75
+ found = await store.findSubmission(submission);
76
+ }
77
+ catch (error) {
78
+ log.error("booking: the lead store could not be read", error);
79
+ return json(503, { ok: false });
80
+ }
81
+ if (!found || leadRef(found.id, submission) !== booking.leadRef)
82
+ return json(404, { ok: false });
83
+ // Booking follows a priced lead; a quote's slot is set on the call.
84
+ if (found.lead.flow !== "estimate" && found.lead.flow !== "fixed")
85
+ return json(409, { ok: false });
86
+ if (webhook === undefined) {
87
+ webhook = deps.webhook?.() ?? null;
88
+ webhook?.start();
89
+ }
90
+ // Sent by the outbox's own timer, like a lead's retry: the visitor waits for nothing.
91
+ const queued = webhook?.requestBooking?.(booking) ?? { kind: "off" };
92
+ // The lead stays in the leads file for a person; the panel never heard of it.
93
+ if (queued.kind === "unqueued")
94
+ log.warn(`booking: lead ${found.id} asked for a ${booking.provider} slot but was never sent to the panel; not queued`);
95
+ return json(200, { ok: true, queued: queued.kind === "queued" });
96
+ };
97
+ }
@@ -0,0 +1,39 @@
1
+ import { type LeadCaptureText } from "../core/lead-capture-text.js";
2
+ import { type Site } from "../core/site.js";
3
+ import type { PricingSource } from "../server/pricing-source.js";
4
+ /**
5
+ * `app/quote/confirm/route.ts` — where a form posted without a script lands
6
+ * when the price it showed has changed (`price_changed`). Its page may be a
7
+ * cached render (ISR) that still shows the old price, so sending it back there
8
+ * would refuse every resubmit; this page is never cached. It prices the
9
+ * answers afresh, says the price changed, and asks to confirm: a form posting
10
+ * to `/quote` with the fresh price as `shown_cents`.
11
+ *
12
+ * ```ts
13
+ * export const dynamic = "force-dynamic";
14
+ * export const GET = confirmRoute(site, { pricing });
15
+ * ```
16
+ *
17
+ * The URL carries only what the visitor chose — the need, the answers, the
18
+ * page's ids and the price they saw — never what they typed: the phone and
19
+ * the postcode are asked again. A brand's extra fields are not carried.
20
+ */
21
+ export interface ConfirmRouteDeps<L extends string> {
22
+ /** The price list the quote route prices with; absent → `site.pricing`. */
23
+ pricing?: PricingSource;
24
+ /** The form's words; the kit's in the page's language by default. */
25
+ text?: (locale: L) => LeadCaptureText;
26
+ /** The quote route's path; `/quote`. */
27
+ action?: string;
28
+ now?: () => number;
29
+ }
30
+ export declare const CONFIRM_PATH = "/quote/confirm";
31
+ /** The price the visitor saw, in the confirmation's query. */
32
+ export declare const SHOWN_PARAM = "shown";
33
+ /**
34
+ * The confirmation's query for a refused form: the fields the visitor chose
35
+ * and the page's own ids, slugs only, and the price shown. Used by the quote
36
+ * route; nothing typed goes in it.
37
+ */
38
+ export declare function confirmQuery(site: Pick<Site<string, string>, "lead">, form: FormData): URLSearchParams;
39
+ export declare function confirmRoute<L extends string, P extends string>(site: Site<L, P>, deps?: ConfirmRouteDeps<L>): (request: Request) => Promise<Response>;
@@ -0,0 +1,118 @@
1
+ import { EXPERIMENT_FIELD, FORM_ID_FIELD, LOCALE_FIELD, LOCATION_FIELD, SHOWN_CENTS_FIELD, VARIANT_FIELD } from "../core/accept.js";
2
+ import { HONEYPOT_FIELD, RENDERED_AT_FIELD } from "../core/antispam.js";
3
+ import { CARD_FIELD, CARD_ID, SUBMISSION_FIELD, SUBMISSION_ID } from "../core/lead.js";
4
+ import { fillText, formatCents } from "../core/lead-capture-format.js";
5
+ import { flowTextOf, LEAD_CAPTURE_TEXT } from "../core/lead-capture-text.js";
6
+ import { createPlaceView } from "../core/place/view.js";
7
+ import { estimateField, flowOf, readEstimateInputs } from "../core/pricing/flow.js";
8
+ import { PRICING_LIMITS } from "../core/pricing/model.js";
9
+ import { priceOf } from "../core/pricing/price.js";
10
+ import { createRouting } from "../core/routing.js";
11
+ import { bakedPlace } from "../core/site.js";
12
+ export const CONFIRM_PATH = "/quote/confirm";
13
+ /** The price the visitor saw, in the confirmation's query. */
14
+ export const SHOWN_PARAM = "shown";
15
+ const escape = (s) => s.replace(/[&<>"']/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[c] ?? c);
16
+ const SLUG = /^[a-z0-9_-]{1,40}$/;
17
+ const CENTS = /^\d{1,12}$/;
18
+ /**
19
+ * The confirmation's query for a refused form: the fields the visitor chose
20
+ * and the page's own ids, slugs only, and the price shown. Used by the quote
21
+ * route; nothing typed goes in it.
22
+ */
23
+ export function confirmQuery(site, form) {
24
+ const query = new URLSearchParams();
25
+ const keep = (name, test) => {
26
+ const value = form.get(name);
27
+ if (typeof value === "string" && test.test(value))
28
+ query.set(name, value);
29
+ };
30
+ for (const name of [LOCALE_FIELD, LOCATION_FIELD, FORM_ID_FIELD, EXPERIMENT_FIELD, VARIANT_FIELD])
31
+ keep(name, SLUG);
32
+ keep(CARD_FIELD, CARD_ID);
33
+ keep(SUBMISSION_FIELD, SUBMISSION_ID);
34
+ const subject = form.get(site.lead.wire.subject);
35
+ if (typeof subject === "string" && site.lead.subjects.includes(subject))
36
+ query.set(site.lead.wire.subject, subject);
37
+ for (const [name, value] of form) {
38
+ if (name.startsWith("estimate_") && typeof value === "string" && PRICING_LIMITS.slug.test(value) && SLUG.test(name.slice("estimate_".length)))
39
+ query.set(name, value);
40
+ }
41
+ const shown = form.get(SHOWN_CENTS_FIELD);
42
+ if (typeof shown === "string" && CENTS.test(shown))
43
+ query.set(SHOWN_PARAM, shown);
44
+ return query;
45
+ }
46
+ function page(lang, title, body) {
47
+ const html = `<!doctype html><html lang="${escape(lang)}"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><meta name="robots" content="noindex"><title>${escape(title)}</title></head><body style="font-family:system-ui,sans-serif;max-width:32rem;margin:2rem auto;padding:0 1rem;line-height:1.5">${body}</body></html>`;
48
+ return new Response(html, { status: 200, headers: { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store", "X-Robots-Tag": "noindex" } });
49
+ }
50
+ export function confirmRoute(site, deps = {}) {
51
+ const routing = createRouting(site);
52
+ const action = deps.action ?? "/quote";
53
+ const now = deps.now ?? Date.now;
54
+ return async (request) => {
55
+ const url = new URL(request.url);
56
+ const q = (name) => url.searchParams.get(name);
57
+ const rawLocale = q(LOCALE_FIELD);
58
+ const locale = site.i18n.isLocale(rawLocale) ? rawLocale : site.i18n.defaultLocale;
59
+ const rawSlug = q(LOCATION_FIELD);
60
+ const place = rawSlug ? bakedPlace(site, rawSlug) : undefined;
61
+ const card = q(CARD_FIELD);
62
+ const anchor = card !== null && CARD_ID.test(card) ? card : "quote";
63
+ const host = request.headers.get("host") ?? url.host;
64
+ const back = () => {
65
+ const mode = place && routing.hostSlug(host) === place.slug ? "host" : "path";
66
+ const location = place ? createPlaceView(site, place, locale, mode).href(`#${anchor}`) : `/${locale}#${anchor}`;
67
+ return new Response(null, { status: 303, headers: { Location: location, "Cache-Control": "no-store" } });
68
+ };
69
+ const need = q(site.lead.wire.subject);
70
+ if (need === null)
71
+ return back();
72
+ let model;
73
+ try {
74
+ model = deps.pricing ? await deps.pricing.model() : (site.pricing ?? null);
75
+ }
76
+ catch {
77
+ model = null;
78
+ }
79
+ const flow = flowOf(site.lead.flows, model, need);
80
+ if (flow === "quote" || !model)
81
+ return back();
82
+ const inputs = flow === "estimate" ? readEstimateInputs(model, need, name => q(name)) : {};
83
+ const price = priceOf(model, need, inputs);
84
+ if (!price)
85
+ return back();
86
+ const kit = locale.startsWith("en") ? LEAD_CAPTURE_TEXT.en : LEAD_CAPTURE_TEXT.fr;
87
+ const base = deps.text?.(locale) ?? kit;
88
+ const flowText = flowTextOf(base, locale);
89
+ const fresh = formatCents(price.cents, locale);
90
+ const shown = q(SHOWN_PARAM);
91
+ const said = shown !== null && CENTS.test(shown) ? fillText(flowText.priceChanged, { price: fresh, shown: formatCents(Number(shown), locale) }) : flowText.priceChangedGeneric;
92
+ const hidden = [
93
+ [site.lead.wire.subject, need],
94
+ ...Object.entries(inputs).map(([input, option]) => [estimateField(input), option]),
95
+ [SHOWN_CENTS_FIELD, String(price.cents)],
96
+ [RENDERED_AT_FIELD, String(now())],
97
+ ];
98
+ for (const name of [LOCALE_FIELD, LOCATION_FIELD, FORM_ID_FIELD, EXPERIMENT_FIELD, VARIANT_FIELD, CARD_FIELD, SUBMISSION_FIELD]) {
99
+ const value = q(name);
100
+ if (value !== null)
101
+ hidden.push([name, value]);
102
+ }
103
+ const field = (name, label, type, autocomplete) => `<p><label for="${escape(name)}">${escape(label)}</label><br><input id="${escape(name)}" name="${escape(name)}" type="${type}" autocomplete="${autocomplete}" required style="font-size:1rem;padding:.5rem;width:100%"></p>`;
104
+ const body = [
105
+ `<h1>${escape(flowText.priceTitle)} : ${escape(fresh)}</h1>`,
106
+ `<p role="alert">${escape(said)}</p>`,
107
+ `<p>${escape(flowText.priceNote)}</p>`,
108
+ `<form method="post" action="${escape(action)}">`,
109
+ ...hidden.map(([name, value]) => `<input type="hidden" name="${escape(name)}" value="${escape(value)}">`),
110
+ `<input type="text" name="${HONEYPOT_FIELD}" tabindex="-1" autocomplete="off" value="" aria-hidden="true" style="position:absolute;left:-9999px">`,
111
+ field(site.lead.wire.locality, base.localityLabel, "text", "postal-code"),
112
+ field(site.lead.wire.mobile, base.phoneLabel, "tel", "tel"),
113
+ `<p><button type="submit" style="font-size:1rem;padding:.75rem 1.5rem">${escape(flowText.bookSubmit)}</button></p>`,
114
+ "</form>",
115
+ ].join("");
116
+ return page(site.i18n.hreflangOf(locale), flowText.priceTitle, body);
117
+ };
118
+ }
@@ -10,3 +10,5 @@ export { healthRoute, robotsRoute, sitemapRoute } from "./routes.js";
10
10
  export { ogRoute, type OgCard, type OgFont, type OgOptions } from "./og.js";
11
11
  export { ogPalette, type OgColours } from "./og-palette.js";
12
12
  export { quoteRoute, type QuoteRouteDeps, type UnavailableCopy } from "./quote-route.js";
13
+ export { bookingRoute, type BookingRouteDeps } from "./booking-route.js";
14
+ export { CONFIRM_PATH, confirmQuery, confirmRoute, SHOWN_PARAM, type ConfirmRouteDeps } from "./confirm-route.js";
@@ -10,3 +10,5 @@ export { healthRoute, robotsRoute, sitemapRoute } from "./routes.js";
10
10
  export { ogRoute } from "./og.js";
11
11
  export { ogPalette } from "./og-palette.js";
12
12
  export { quoteRoute } from "./quote-route.js";
13
+ export { bookingRoute } from "./booking-route.js";
14
+ export { CONFIRM_PATH, confirmQuery, confirmRoute, SHOWN_PARAM } from "./confirm-route.js";
@@ -5,13 +5,15 @@ import { type Site } from "../core/site.js";
5
5
  import type { ServerEnv } from "../server/env.js";
6
6
  import type { LeadWebhook } from "../server/lead-webhook.js";
7
7
  import type { LeadNotifier } from "../server/notify.js";
8
+ import type { PricingSource } from "../server/pricing-source.js";
8
9
  /**
9
10
  * `app/quote/route.ts` — the no-JS path, and the one that has to keep working:
10
11
  * a plain form POST answered with a 303, so a refresh does not resubmit.
11
12
  * A refused lead goes back to its card with the field to fix
12
13
  * (`/fr?lead_error=phone#devis`); a script asking for JSON (`Accept:
13
14
  * application/json`) is answered `422 { ok: false, field }` instead, and
14
- * `200 { ok: true, location }` for a lead taken.
15
+ * `200 { ok: true, location, lead, cents? }` for a lead taken — `lead` its
16
+ * public reference (`leadRef`), `cents` the price the server took it at.
15
17
  *
16
18
  * ```ts
17
19
  * export const dynamic = "force-dynamic";
@@ -38,6 +40,11 @@ export interface QuoteRouteDeps<L extends string> {
38
40
  webhook?: () => LeadWebhook | null;
39
41
  /** The self-contained 500's words, when the store refused the lead. */
40
42
  unavailable: (locale: L, place: Place<L> | null) => UnavailableCopy;
43
+ /**
44
+ * The price list an `estimate` or `fixed` lead is priced from — the same
45
+ * source the page showed (`createPricingSource`); absent → `site.pricing`.
46
+ */
47
+ pricing?: PricingSource;
41
48
  /** Opened on first use (`next build` imports the route); defaults to `LEADS_DB_URL`'s. */
42
49
  store?: () => LeadStore;
43
50
  /** Defaults to `env().leadRateLimit`, built on the first lead. */
@@ -1,8 +1,9 @@
1
1
  import { after } from "next/server.js";
2
- import { createAcceptLead, EXPERIMENT_FIELD, VARIANT_FIELD } from "../core/accept.js";
2
+ import { CONFIRM_PATH, confirmQuery } from "./confirm-route.js";
3
+ import { createAcceptLead, EXPERIMENT_FIELD, PRICE_CHANGED, VARIANT_FIELD } from "../core/accept.js";
3
4
  import { analyticsSink, EVENTS, experimentProps } from "../core/analytics.js";
4
5
  import { RateLimiter } from "../core/antispam.js";
5
- import { CARD_FIELD, CARD_ID, channelOf, LEAD_ERROR_PARAM } from "../core/lead.js";
6
+ import { CARD_FIELD, CARD_ID, channelOf, LEAD_CARD_PARAM, LEAD_ERROR_PARAM } from "../core/lead.js";
6
7
  import { createPlaceView } from "../core/place/view.js";
7
8
  import { createRouting } from "../core/routing.js";
8
9
  import { bakedPlace, contactOf } from "../core/site.js";
@@ -115,7 +116,10 @@ export function quoteRoute(site, deps) {
115
116
  query.set("need", subject);
116
117
  const posted = form.get(CARD_FIELD);
117
118
  const card = typeof posted === "string" && CARD_ID.test(posted) ? posted : anchor;
118
- return `?${query}#${card}${channel === "callback" ? "-callback" : ""}`;
119
+ const target = `${card}${channel === "callback" ? "-callback" : ""}`;
120
+ // The fragment never reaches the server: the query names the card too.
121
+ query.set(LEAD_CARD_PARAM, target);
122
+ return `?${query}#${target}`;
119
123
  }
120
124
  /** Self-contained: the thing that failed may be the thing that renders pages. */
121
125
  function unavailable(slug, locale) {
@@ -148,6 +152,7 @@ export function quoteRoute(site, deps) {
148
152
  insert: lead => leadStore().insert(lead),
149
153
  findSubmission: async (submissionId) => (await leadStore().findSubmission?.(submissionId)) ?? null,
150
154
  sendSuspect: suspectQueued(),
155
+ ...(deps.pricing ? { pricing: () => deps.pricing?.model() ?? Promise.resolve(null) } : {}),
151
156
  defer,
152
157
  notify: (lead, id) => {
153
158
  notifier ??= deps.notifier();
@@ -182,18 +187,24 @@ export function quoteRoute(site, deps) {
182
187
  // A suspected bot is answered exactly as a person is.
183
188
  case "stored": {
184
189
  const location = href(request, outcome.lead.placeSlug, outcome.locale, thanksSuffix(channelOf(outcome.lead)));
185
- return scripted ? json(200, { ok: true, location }) : seeOther(location);
190
+ const price = outcome.lead.price ? { cents: outcome.lead.price.cents } : {};
191
+ return scripted ? json(200, { ok: true, location, lead: outcome.ref, ...price }) : seeOther(location);
186
192
  }
187
193
  case "invalid": {
188
194
  const tags = experimentProps(text(form, EXPERIMENT_FIELD), text(form, VARIANT_FIELD));
189
195
  // The field's role and why — never what was typed.
190
- const props = { form_id: outcome.formId, channel: outcome.channel, field: outcome.field, reason: "invalid", ...tags };
196
+ const repriced = outcome.field === PRICE_CHANGED;
197
+ const props = { form_id: outcome.formId, channel: outcome.channel, field: outcome.field, reason: repriced ? PRICE_CHANGED : "invalid", ...tags };
191
198
  if (env.posthogKey) {
192
199
  const sink = analyticsSink({ key: env.posthogKey, host: env.posthogHost, brandId: site.brand.id }, outcome.slug);
193
200
  defer(() => sink.capture(EVENTS.formReject, props));
194
201
  }
195
202
  if (scripted)
196
- return json(422, { ok: false, field: outcome.field });
203
+ return json(422, { ok: false, field: outcome.field, ...(repriced && outcome.cents !== undefined ? { reason: PRICE_CHANGED, cents: outcome.cents } : {}) });
204
+ // The card's page may be a cached render still showing the old price:
205
+ // a plain post confirms on a page that is never cached instead.
206
+ if (repriced)
207
+ return seeOther(`${CONFIRM_PATH}?${confirmQuery(site, form)}`);
197
208
  return seeOther(href(request, outcome.slug, outcome.locale, refused(form, outcome.field, outcome.channel)));
198
209
  }
199
210
  case "failed":
@@ -0,0 +1,35 @@
1
+ import type { OpenBookingConfig } from "../core/booking/model.js";
2
+ import type { LeadCaptureFlowText } from "../core/lead-capture-text.js";
3
+ import { type BookingAdapters } from "./booking-adapters.js";
4
+ import { type BookingPart } from "./LeadBookingManual.js";
5
+ import type { PartClassNames } from "./parts.js";
6
+ import type { LeadSent } from "./use-lead-submit.js";
7
+ export interface LeadBookingProps {
8
+ /** The booking chosen for this visitor (`bookingOf(place, variant)`). */
9
+ booking: OpenBookingConfig;
10
+ sent: LeadSent;
11
+ locale: string;
12
+ text: LeadCaptureFlowText;
13
+ /** The built-in adapters with the brand's own over them, by provider. */
14
+ adapters?: BookingAdapters | undefined;
15
+ /**
16
+ * The providers' embeds (`BOOKING_EMBEDS`) rather than a new tab — only
17
+ * once the visitor accepted the provider's cookies. A flag, because a
18
+ * server component cannot hand a client island an adapter.
19
+ */
20
+ embed?: boolean | undefined;
21
+ formId?: string | undefined;
22
+ /** Where the request posts; `BOOKING_ACTION` by default. */
23
+ action?: string | undefined;
24
+ /** For the `manual` day tiles; the click's clock by default. */
25
+ now?: number | undefined;
26
+ classNames?: PartClassNames<BookingPart> | undefined;
27
+ }
28
+ /**
29
+ * After a priced lead: how the slot is set, through the adapter of the
30
+ * config's provider. Nothing a provider serves is requested before the visitor's click
31
+ * — a link the browser opens, or an embed whose script loads on the click.
32
+ * Without the lead's reference (an old route's answer) there is nothing to
33
+ * join a booking to, and the card only promises the call.
34
+ */
35
+ export declare function LeadBooking(props: LeadBookingProps): import("react").JSX.Element | null;
@@ -0,0 +1,47 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { buttonVariants, cn } from "@evinvest/uikit";
4
+ import { useState } from "react";
5
+ import { BOOKING_EMBEDS, bookingAdapters } from "./booking-adapters.js";
6
+ import { LeadBookingManual } from "./LeadBookingManual.js";
7
+ import { useBooking } from "./use-booking.js";
8
+ function OpenBooking(props) {
9
+ const { adapter, ctx, report, text, classNames: c } = props;
10
+ const [booked, setBooked] = useState(false);
11
+ const onBooked = () => {
12
+ setBooked(true);
13
+ report.booked();
14
+ };
15
+ const click = () => {
16
+ void report.requested({ lead_ref: ctx.leadRef, provider: adapter.provider });
17
+ void adapter.open?.({ ...ctx, onBooked });
18
+ };
19
+ const href = adapter.href?.(ctx) ?? null;
20
+ const look = buttonVariants({ variant: "primary", size: "touch", className: cn("w-full", c?.bookingCta) });
21
+ if (booked)
22
+ return _jsx("p", { className: cn("font-medium text-ink", c?.bookingNote), children: text.booked });
23
+ return (_jsxs("div", { className: cn("flex flex-col gap-3", c?.booking), children: [href !== null ? (_jsx("a", { href: href, target: "_blank", rel: "noopener noreferrer", onClick: click, className: look, "data-booking-provider": adapter.provider, children: text.bookCta })) : (_jsx("button", { type: "button", onClick: click, className: look, "data-booking-provider": adapter.provider, children: text.bookCta })), !adapter.prefillsPhone?.(ctx) && _jsx("p", { className: cn("text-sm text-ink-soft", c?.bookingNote), children: text.bookPhoneHint }), _jsx("p", { className: cn("text-sm text-ink-soft", c?.bookingNote), children: text.bookNote })] }));
24
+ }
25
+ /**
26
+ * After a priced lead: how the slot is set, through the adapter of the
27
+ * config's provider. Nothing a provider serves is requested before the visitor's click
28
+ * — a link the browser opens, or an embed whose script loads on the click.
29
+ * Without the lead's reference (an old route's answer) there is nothing to
30
+ * join a booking to, and the card only promises the call.
31
+ */
32
+ export function LeadBooking(props) {
33
+ const { booking, sent, text, classNames: c } = props;
34
+ const provider = booking.provider;
35
+ const report = useBooking(provider, sent.submission ?? "", props.formId ?? "quote", props.action);
36
+ const proven = sent.lead !== undefined && sent.submission !== undefined;
37
+ const adapter = bookingAdapters({ ...(props.embed ? BOOKING_EMBEDS : {}), ...props.adapters })[provider];
38
+ // No reference to join a booking to, or a provider no adapter knows: the call is promised.
39
+ if (sent.lead === undefined || (provider !== "manual" && (!proven || !adapter)))
40
+ return _jsx("p", { className: cn("text-ink", c?.bookingNote), children: text.slotCallback });
41
+ if (provider === "manual")
42
+ return _jsx(LeadBookingManual, { leadRef: sent.lead, locale: props.locale, now: props.now ?? Date.now(), text: text, report: proven ? report : null, classNames: c });
43
+ const ctx = { config: booking, leadRef: sent.lead, name: sent.name, phone: sent.phone, locale: props.locale };
44
+ if (!adapter)
45
+ return null;
46
+ return _jsx(OpenBooking, { adapter: adapter, ctx: ctx, report: report, text: text, classNames: c });
47
+ }
@@ -0,0 +1,17 @@
1
+ import type { LeadCaptureFlowText } from "../core/lead-capture-text.js";
2
+ import type { PartClassNames } from "./parts.js";
3
+ import type { BookingReport } from "./use-booking.js";
4
+ export type BookingPart = "booking" | "bookingCta" | "bookingNote" | "prefer" | "preferOption" | "preferSubmit";
5
+ /**
6
+ * `manual`: the call is promised, and the visitor may say when suits them —
7
+ * a day of the coming week and a part of the day, both optional, no free
8
+ * text. Sent as `booking.requested@1`; the operator sets the slot on the call.
9
+ */
10
+ export declare function LeadBookingManual(props: {
11
+ leadRef: string;
12
+ locale: string;
13
+ now: number;
14
+ text: LeadCaptureFlowText;
15
+ report: BookingReport | null;
16
+ classNames?: PartClassNames<BookingPart> | undefined;
17
+ }): import("react").JSX.Element;
@@ -0,0 +1,50 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { Button, cn } from "@evinvest/uikit";
4
+ import { useMemo, useState } from "react";
5
+ import { PREFERRED_PARTS } from "../core/booking/model.js";
6
+ /** How many days ahead a visitor may prefer: tiles, not a calendar — a tap each. */
7
+ const DAYS_AHEAD = 7;
8
+ /** `YYYY-MM-DD` in the visitor's own calendar, `n` days from `now`. */
9
+ function dayFrom(now, n) {
10
+ const d = new Date(now);
11
+ d.setDate(d.getDate() + n);
12
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
13
+ }
14
+ function Tiles(props) {
15
+ return (_jsxs("fieldset", { className: "flex flex-col gap-2", children: [_jsx("legend", { className: "mb-2 text-sm font-medium text-ink", children: props.legend }), _jsx("div", { className: "grid grid-cols-3 gap-2 sm:grid-cols-4", children: props.options.map(o => (_jsxs("label", { className: "relative block cursor-pointer", children: [_jsx("input", { type: "radio", name: props.name, value: o.value, checked: props.value === o.value, onChange: () => props.onChange(o.value), className: "peer absolute inset-0 opacity-0" }), _jsx("span", { className: cn("flex h-full min-h-11 items-center justify-center rounded-[var(--control-radius)] border border-input px-2 py-2 text-center text-sm text-ink transition-colors", "peer-checked:border-primary peer-checked:bg-hover peer-focus-visible:ring-[3px] peer-focus-visible:ring-ring/50", props.className), children: o.label })] }, o.value))) })] }));
16
+ }
17
+ /**
18
+ * `manual`: the call is promised, and the visitor may say when suits them —
19
+ * a day of the coming week and a part of the day, both optional, no free
20
+ * text. Sent as `booking.requested@1`; the operator sets the slot on the call.
21
+ */
22
+ export function LeadBookingManual(props) {
23
+ const { text, locale, classNames: c } = props;
24
+ const [day, setDay] = useState(null);
25
+ const [part, setPart] = useState(null);
26
+ const [state, setState] = useState("idle");
27
+ const days = useMemo(() => {
28
+ const fmt = new Intl.DateTimeFormat(locale, { weekday: "short", day: "numeric" });
29
+ return Array.from({ length: DAYS_AHEAD }, (_, i) => {
30
+ const value = dayFrom(props.now, i + 1);
31
+ return { value, label: fmt.format(new Date(`${value}T12:00:00`)) };
32
+ });
33
+ }, [locale, props.now]);
34
+ const parts = PREFERRED_PARTS.map(value => ({ value, label: { morning: text.partMorning, afternoon: text.partAfternoon, evening: text.partEvening }[value] }));
35
+ const report = props.report;
36
+ const send = async (event) => {
37
+ event.preventDefault();
38
+ if (!report || (day === null && part === null))
39
+ return;
40
+ setState("busy");
41
+ const ok = await report.requested({ lead_ref: props.leadRef, provider: "manual", ...(day ? { preferred_date: day } : {}), ...(part ? { preferred_part: part } : {}) });
42
+ setState(ok ? "sent" : "failed");
43
+ };
44
+ let body = null;
45
+ if (state === "sent")
46
+ body = _jsx("p", { className: "text-ink", children: text.preferSent });
47
+ else if (report)
48
+ body = (_jsxs("form", { onSubmit: send, className: cn("flex flex-col gap-4", c?.prefer), "data-booking-prefer": "", children: [_jsx("p", { className: "text-sm text-ink-soft", children: text.preferTitle }), _jsx(Tiles, { legend: text.preferDay, name: "preferred_date", options: days, value: day, onChange: setDay, className: c?.preferOption }), _jsx(Tiles, { legend: text.preferPart, name: "preferred_part", options: parts, value: part, onChange: setPart, className: c?.preferOption }), state === "failed" && (_jsx("p", { role: "alert", className: "text-sm text-ink", children: text.preferFailed })), _jsx(Button, { type: "submit", variant: "outline", size: "touch", disabled: state === "busy" || (day === null && part === null), className: cn("w-full", c?.preferSubmit), children: text.preferSubmit })] }));
49
+ return (_jsxs("div", { className: cn("flex flex-col gap-4", c?.booking), children: [_jsx("p", { className: cn("text-ink", c?.bookingNote), children: text.slotCallback }), body] }));
50
+ }
@@ -1,15 +1,21 @@
1
1
  import { type ReactNode } from "react";
2
2
  import { type CaptureChannel } from "../core/channels.js";
3
- import type { LeadWire } from "../core/lead.js";
4
- import type { LeadCaptureText } from "../core/lead-capture-text.js";
3
+ import { type LeadError, type LeadWire, type PageLeadError } from "../core/lead.js";
4
+ import { type LeadCaptureText } from "../core/lead-capture-text.js";
5
5
  import { type Place } from "../core/place/types.js";
6
+ import { type LeadFlows } from "../core/pricing/flow.js";
7
+ import type { PricingModel } from "../core/pricing/model.js";
6
8
  import type { FormSelectOption } from "./FormSelect.js";
9
+ import type { BookingAdapters } from "./booking-adapters.js";
10
+ import type { BookingPart } from "./LeadBookingManual.js";
11
+ import { type PricedPart } from "./LeadCapturePriced.js";
7
12
  import { type ChannelPart, type Experiment } from "./LeadCaptureChannels.js";
13
+ import { type EstimatePart } from "./LeadCaptureEstimate.js";
8
14
  import { type FieldPart } from "./LeadCaptureFields.js";
9
15
  import { type LeadCaptureLayout } from "./LeadCaptureNeed.js";
10
16
  import type { PartClassNames } from "./parts.js";
11
17
  import { type LeadSent } from "./use-lead-submit.js";
12
- export type LeadCapturePart = "root" | "head" | "title" | "lede" | "form" | "contact" | "submit" | "trust" | "privacy" | "opening" | "others" | "done" | "needs" | "need" | "summary" | FieldPart | ChannelPart;
18
+ export type LeadCapturePart = "root" | "head" | "title" | "lede" | "form" | "contact" | "submit" | "trust" | "privacy" | "opening" | "others" | "done" | "needs" | "need" | "summary" | FieldPart | ChannelPart | EstimatePart | PricedPart | BookingPart;
13
19
  export interface LeadCaptureProps {
14
20
  /** Its hours order the channels; its service area suggests the commune. */
15
21
  place: Place<string>;
@@ -25,6 +31,44 @@ export interface LeadCaptureProps {
25
31
  wire: LeadWire;
26
32
  /** The brand's subjects with their labels, in order. */
27
33
  needs: readonly FormSelectOption[];
34
+ /**
35
+ * How each need is sold — `site.lead.flows`, the map the server prices
36
+ * with. A need runs `estimate` or `fixed` only when `pricing` prices it so
37
+ * (`flowOf`); otherwise, and without the two, every need is a `quote`.
38
+ */
39
+ flows?: LeadFlows | undefined;
40
+ /**
41
+ * The price list the page priced from — `createPricingSource(site).model()`
42
+ * on the server page, handed down as data. An estimate's price is computed
43
+ * here as the visitor answers, and again by the route, whose number is kept.
44
+ */
45
+ pricing?: PricingModel | null | undefined;
46
+ /**
47
+ * `quote` needs a price is given from photos (a deep clean, after-works):
48
+ * a WhatsApp link to send them, prefilled with the need, when the place
49
+ * has WhatsApp. The callback is offered as always.
50
+ */
51
+ photos?: readonly string[] | undefined;
52
+ /**
53
+ * After a priced lead: how the slot is set. By default the place's booking
54
+ * (`place.booking`, `manual` when it has none) for `bookingVariant`,
55
+ * through its provider's adapter — `LeadBooking`. A node here replaces it
56
+ * whole.
57
+ */
58
+ booking?: ReactNode | ((sent: LeadSent) => ReactNode);
59
+ /**
60
+ * The built-in booking adapters with the brand's own over them, by
61
+ * provider — from a client component only: a server one cannot pass functions.
62
+ */
63
+ bookingAdapters?: BookingAdapters | undefined;
64
+ /**
65
+ * The variant of the `booking_provider` experiment (`BOOKING_EXPERIMENT`):
66
+ * the provider this visitor is offered, when the place has it; else the
67
+ * place's default (`bookingOf`).
68
+ */
69
+ bookingVariant?: string | null | undefined;
70
+ /** The providers' embeds instead of a new tab — only once the visitor accepted their cookies. */
71
+ bookingEmbed?: boolean | undefined;
28
72
  /** The need the page already knows; `?need=` and `[data-need]` triggers set it too. */
29
73
  need?: string | undefined;
30
74
  /** `single` (one screen) or `qualify-first` (the need, then the contact) — an experiment's switch. */
@@ -66,6 +110,13 @@ export interface LeadCaptureProps {
66
110
  * posts as it always did.
67
111
  */
68
112
  done?: ReactNode | ((sent: LeadSent) => ReactNode);
113
+ /**
114
+ * The refusal the page read from its query — `leadErrorOf(searchParams)` —
115
+ * drawn on the server, so the card says why without a script — by this card
116
+ * only when the query names it (`lead_card`, `leadErrorFor`). A page built
117
+ * for ISR has no query to read; there the script says it once it runs.
118
+ */
119
+ initialError?: PageLeadError | LeadError | null | undefined;
69
120
  /** Replaces the title and lede — the brand's own heading. */
70
121
  head?: ReactNode;
71
122
  /** Beside the submit: a guarantee, a live rating. */