@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.
- package/README.md +237 -12
- package/dist/core/accept.d.ts +35 -2
- package/dist/core/accept.js +74 -5
- package/dist/core/analytics.d.ts +4 -1
- package/dist/core/analytics.js +13 -0
- package/dist/core/booking/index.d.ts +3 -0
- package/dist/core/booking/index.js +3 -0
- package/dist/core/booking/model.d.ts +106 -0
- package/dist/core/booking/model.js +66 -0
- package/dist/core/booking/url.d.ts +17 -0
- package/dist/core/booking/url.js +53 -0
- package/dist/core/booking/validate.d.ts +27 -0
- package/dist/core/booking/validate.js +219 -0
- package/dist/core/lead-capture-format.d.ts +6 -0
- package/dist/core/lead-capture-format.js +13 -0
- package/dist/core/lead-capture-text.d.ts +57 -2
- package/dist/core/lead-capture-text.js +75 -2
- package/dist/core/lead.d.ts +65 -3
- package/dist/core/lead.js +46 -2
- package/dist/core/phone.d.ts +3 -1
- package/dist/core/phone.js +28 -2
- package/dist/core/place/live.d.ts +6 -2
- package/dist/core/place/live.js +21 -2
- package/dist/core/place/types.d.ts +6 -0
- package/dist/core/place/types.js +0 -13
- package/dist/core/pricing/flow.d.ts +27 -0
- package/dist/core/pricing/flow.js +37 -0
- package/dist/core/pricing/index.d.ts +4 -0
- package/dist/core/pricing/index.js +4 -0
- package/dist/core/pricing/model.d.ts +129 -0
- package/dist/core/pricing/model.js +34 -0
- package/dist/core/pricing/price.d.ts +34 -0
- package/dist/core/pricing/price.js +105 -0
- package/dist/core/pricing/validate.d.ts +27 -0
- package/dist/core/pricing/validate.js +244 -0
- package/dist/core/routing.d.ts +3 -2
- package/dist/core/routing.js +4 -3
- package/dist/core/site.d.ts +15 -0
- package/dist/core/site.js +26 -0
- package/dist/index.d.ts +6 -4
- package/dist/index.js +6 -4
- package/dist/next/booking-route.d.ts +31 -0
- package/dist/next/booking-route.js +97 -0
- package/dist/next/confirm-route.d.ts +39 -0
- package/dist/next/confirm-route.js +118 -0
- package/dist/next/index.d.ts +2 -0
- package/dist/next/index.js +2 -0
- package/dist/next/quote-route.d.ts +8 -1
- package/dist/next/quote-route.js +17 -6
- package/dist/react/LeadBooking.d.ts +35 -0
- package/dist/react/LeadBooking.js +47 -0
- package/dist/react/LeadBookingManual.d.ts +17 -0
- package/dist/react/LeadBookingManual.js +50 -0
- package/dist/react/LeadCapture.d.ts +54 -3
- package/dist/react/LeadCapture.js +37 -10
- package/dist/react/LeadCaptureChannels.d.ts +14 -2
- package/dist/react/LeadCaptureChannels.js +13 -0
- package/dist/react/LeadCaptureEstimate.d.ts +46 -0
- package/dist/react/LeadCaptureEstimate.js +69 -0
- package/dist/react/LeadCaptureFields.js +60 -21
- package/dist/react/LeadCapturePriced.d.ts +18 -0
- package/dist/react/LeadCapturePriced.js +14 -0
- package/dist/react/booking-adapters.d.ts +58 -0
- package/dist/react/booking-adapters.js +104 -0
- package/dist/react/index.d.ts +5 -1
- package/dist/react/index.js +4 -1
- package/dist/react/use-booking.d.ts +20 -0
- package/dist/react/use-booking.js +32 -0
- package/dist/react/use-lead-error.d.ts +3 -7
- package/dist/react/use-lead-error.js +9 -6
- package/dist/react/use-lead-events.d.ts +2 -0
- package/dist/react/use-lead-events.js +10 -0
- package/dist/react/use-lead-submit.d.ts +14 -4
- package/dist/react/use-lead-submit.js +19 -9
- package/dist/react/use-repriced.d.ts +18 -0
- package/dist/react/use-repriced.js +27 -0
- package/dist/server/index.d.ts +3 -2
- package/dist/server/index.js +2 -1
- package/dist/server/lead-store-sqlite.js +31 -5
- package/dist/server/lead-webhook.d.ts +94 -0
- package/dist/server/lead-webhook.js +46 -2
- package/dist/server/notify.d.ts +20 -2
- package/dist/server/notify.js +6 -4
- package/dist/server/place-source.js +1 -1
- package/dist/server/pricing-source.d.ts +25 -0
- package/dist/server/pricing-source.js +48 -0
- package/dist/server/webhook-outbox.d.ts +11 -1
- package/dist/server/webhook-outbox.js +32 -5
- package/dist/testing/lead-rules.js +15 -1
- package/dist/testing/lead-store-contract.js +9 -2
- package/package.json +1 -1
- package/template/app/[locale]/[location]/page.tsx +4 -2
- package/template/app/quote/booking/route.ts +11 -0
- package/template/app/quote/confirm/route.ts +12 -0
- package/template/app/quote/route.ts +2 -1
- package/template/flake.nix +1 -1
- package/template/package.json +2 -2
- package/template/src/entities/content/model/en.ts +1 -1
- package/template/src/entities/content/model/fr.ts +1 -1
- package/template/src/shared/config/env.ts +4 -1
- package/template/src/shared/config/lead.ts +12 -2
- package/template/src/shared/config/places.ts +4 -0
- package/template/src/shared/config/pricing.ts +49 -0
- package/template/src/shared/config/site.ts +6 -0
- package/template/src/views/home/ui/PlaceHome.tsx +6 -3
- package/template/tests/bundle_budget.txt +3 -1
- package/template/tests/e2e/flows.spec.ts +183 -0
- package/template/tests/e2e/mock-panel.mjs +34 -0
- package/template/tests/e2e/playwright.config.ts +25 -9
- 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 => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[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
|
+
}
|
package/dist/next/index.d.ts
CHANGED
|
@@ -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";
|
package/dist/next/index.js
CHANGED
|
@@ -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. */
|
package/dist/next/quote-route.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { after } from "next/server.js";
|
|
2
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
4
|
-
import type
|
|
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. */
|