@evinvest/kitstart 0.10.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/README.md +155 -17
  2. package/dist/core/accept.d.ts +12 -1
  3. package/dist/core/accept.js +15 -0
  4. package/dist/core/analytics.d.ts +3 -1
  5. package/dist/core/analytics.js +6 -0
  6. package/dist/core/booking/index.d.ts +3 -0
  7. package/dist/core/booking/index.js +3 -0
  8. package/dist/core/booking/model.d.ts +106 -0
  9. package/dist/core/booking/model.js +66 -0
  10. package/dist/core/booking/url.d.ts +17 -0
  11. package/dist/core/booking/url.js +53 -0
  12. package/dist/core/booking/validate.d.ts +27 -0
  13. package/dist/core/booking/validate.js +219 -0
  14. package/dist/core/lead-capture-text.d.ts +26 -2
  15. package/dist/core/lead-capture-text.js +32 -2
  16. package/dist/core/lead.d.ts +33 -0
  17. package/dist/core/lead.js +43 -0
  18. package/dist/core/phone.d.ts +3 -1
  19. package/dist/core/phone.js +28 -2
  20. package/dist/core/place/live.d.ts +6 -2
  21. package/dist/core/place/live.js +21 -2
  22. package/dist/core/place/types.d.ts +6 -0
  23. package/dist/core/place/types.js +0 -13
  24. package/dist/core/routing.d.ts +3 -2
  25. package/dist/core/routing.js +4 -3
  26. package/dist/core/site.d.ts +8 -0
  27. package/dist/core/site.js +15 -0
  28. package/dist/index.d.ts +3 -2
  29. package/dist/index.js +3 -2
  30. package/dist/next/booking-route.d.ts +31 -0
  31. package/dist/next/booking-route.js +97 -0
  32. package/dist/next/confirm-route.d.ts +39 -0
  33. package/dist/next/confirm-route.js +118 -0
  34. package/dist/next/index.d.ts +2 -0
  35. package/dist/next/index.js +2 -0
  36. package/dist/next/quote-route.js +14 -5
  37. package/dist/react/LeadBooking.d.ts +35 -0
  38. package/dist/react/LeadBooking.js +47 -0
  39. package/dist/react/LeadBookingManual.d.ts +17 -0
  40. package/dist/react/LeadBookingManual.js +50 -0
  41. package/dist/react/LeadCapture.d.ts +28 -5
  42. package/dist/react/LeadCapture.js +22 -8
  43. package/dist/react/LeadCaptureFields.js +60 -21
  44. package/dist/react/LeadCapturePriced.d.ts +3 -4
  45. package/dist/react/LeadCapturePriced.js +4 -4
  46. package/dist/react/booking-adapters.d.ts +58 -0
  47. package/dist/react/booking-adapters.js +104 -0
  48. package/dist/react/index.d.ts +5 -1
  49. package/dist/react/index.js +4 -1
  50. package/dist/react/use-booking.d.ts +20 -0
  51. package/dist/react/use-booking.js +32 -0
  52. package/dist/react/use-lead-error.d.ts +3 -7
  53. package/dist/react/use-lead-error.js +9 -6
  54. package/dist/react/use-lead-submit.d.ts +7 -1
  55. package/dist/react/use-lead-submit.js +6 -3
  56. package/dist/react/use-repriced.d.ts +18 -0
  57. package/dist/react/use-repriced.js +27 -0
  58. package/dist/server/index.d.ts +2 -2
  59. package/dist/server/lead-webhook.d.ts +46 -0
  60. package/dist/server/lead-webhook.js +17 -1
  61. package/dist/server/notify.d.ts +20 -2
  62. package/dist/server/notify.js +6 -4
  63. package/dist/server/place-source.js +1 -1
  64. package/dist/server/webhook-outbox.d.ts +11 -1
  65. package/dist/server/webhook-outbox.js +32 -5
  66. package/dist/testing/lead-rules.js +15 -1
  67. package/package.json +1 -1
  68. package/template/app/quote/booking/route.ts +11 -0
  69. package/template/app/quote/confirm/route.ts +12 -0
  70. package/template/flake.nix +1 -1
  71. package/template/package.json +2 -2
  72. package/template/src/shared/config/places.ts +4 -0
  73. package/template/src/shared/config/site.ts +3 -0
  74. package/template/tests/e2e/flows.spec.ts +86 -9
  75. package/template/tests/e2e/mock-panel.mjs +18 -4
  76. package/template/tests/e2e/quote-form.spec.ts +2 -2
@@ -1,4 +1,5 @@
1
1
  import type { LocaleRegistry } from "@evinvest/i18n";
2
+ import type { BookingRules } from "./booking/model.js";
2
3
  import type { LeadSchema } from "./lead.js";
3
4
  import type { PublicationPolicy } from "./place/publication.js";
4
5
  import type { Place } from "./place/types.js";
@@ -69,6 +70,13 @@ export interface SiteConfig<L extends string, P extends string> {
69
70
  * down, or serves nothing valid. Absent → every need is a `quote` then.
70
71
  */
71
72
  pricing?: PricingModel;
73
+ /**
74
+ * The booking rules a place's `booking` is held to, baked or live:
75
+ * `calComHosts` are the Cal.com hosts a `cal_com` page may be on — the
76
+ * hosted cloud by default (`DEFAULT_CAL_COM_HOSTS`); a brand on its own
77
+ * Cal.com lists both, `["cal.com", "cal.brand.fr"]`.
78
+ */
79
+ booking?: BookingRules;
72
80
  legacyRedirects?: readonly LegacyRedirect<L>[];
73
81
  /**
74
82
  * Paths the brand serves as files besides the routes every landing has
package/dist/core/site.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { bookingConfigProblems, calComHostsProblems } from "./booking/validate.js";
1
2
  import { pricingProblemsFor } from "./pricing/validate.js";
2
3
  export function defineSite(config) {
3
4
  const pageKeys = Object.keys(config.pages).filter((k) => Object.hasOwn(config.pages, k));
@@ -35,6 +36,20 @@ export function defineSite(config) {
35
36
  if (problems.length > 0)
36
37
  throw new Error(`defineSite: pricing: ${problems.join("; ")}`);
37
38
  }
39
+ const calComHosts = config.booking?.calComHosts;
40
+ if (calComHosts) {
41
+ const problems = calComHostsProblems(calComHosts);
42
+ if (problems.length > 0)
43
+ throw new Error(`defineSite: booking.${problems.join("; ")}`);
44
+ }
45
+ for (const place of config.places) {
46
+ // Baked into the build: a booking the live source would refuse must not ship.
47
+ if (place.booking === undefined)
48
+ continue;
49
+ const problems = bookingConfigProblems(place.booking, config.booking ?? {});
50
+ if (problems.length > 0)
51
+ throw new Error(`defineSite: place "${place.slug}": ${problems.join("; ")}`);
52
+ }
38
53
  for (const need of Object.keys(config.lead.flows ?? {})) {
39
54
  if (!config.lead.subjects.includes(need))
40
55
  throw new Error(`defineSite: lead.flows names "${need}", which is not one of lead.subjects`);
package/dist/index.d.ts CHANGED
@@ -6,18 +6,19 @@
6
6
  export { assertLaunchable, bakedPlace, cardFact, contactOf, defineSite, ogLocaleOf, openLaunchBlockers, perLocale, type BrandFacts, type LegacyRedirect, type OwnerTodo, type PageSuffix, type Site, type SiteConfig, type Topology, } from "./core/site.js";
7
7
  export { createPlaceView, DEFAULT_TIME_ZONE, freshRating, isOpenAt, nextOpening, parseInstant, isPublished, mergeLive, parsePlaceLive, placeHref, placeOrigin, placeUrl, publicationGaps, RATING_MAX_AGE_DAYS, servedLocalities, SERVICE_AREA_GATE, siteOrigin, STOREFRONT_GATE, storefrontOf, type DayOfWeek, type Geo, type LinkBase, type LinkMode, type Opening, type OpeningHours, type OriginFacts, type Place, type PlaceLive, type PlaceView, type PostalAddress, type Presence, type PublicationField, type PublicationPolicy, type Rating, type ServiceArea, } from "./core/place/index.js";
8
8
  export { createRouting, GONE, GONE_HEADER, gonePath, goneHeader, HOST_MARK, LANG_COOKIE, LANG_COOKIE_MAX_AGE, NON_PAGE_ROUTES, parseGoneHeader, parsePlaceParam, PASS_PATHS, placeParam, pointSuffixes, THANKS, type Decision, type RequestFacts, type Routing, } from "./core/routing.js";
9
- export { CARD_FIELD, CARD_ID, CHANNEL_FIELD, channelOf, CONSENT_FIELD, LEAD_CHANNELS, LEAD_ERROR_PARAM, LEAD_SCHEMA_VERSION, MAX_CONSENT, MAX_FIELD, readCandidate, rejectionOf, suspectOf, SUBMISSION_FIELD, SUBMISSION_ID, validateCallbackLead, validateCandidate, validateLead, type Lead, type LeadCandidate, type LeadChannel, type LeadConsent, type LeadExtra, type LeadPrice, type LeadRejection, type LeadSchema, type LeadStore, type LeadSuspect, type LeadVerdict, type LeadWire, type SpamVerdict, } from "./core/lead.js";
9
+ export { CARD_FIELD, CARD_ID, CHANNEL_FIELD, channelOf, CONSENT_FIELD, LEAD_CHANNELS, LEAD_ERROR_PARAM, LEAD_CARD_PARAM, leadErrorFor, leadErrorOf, LEAD_SCHEMA_VERSION, MAX_CONSENT, MAX_FIELD, readCandidate, rejectionOf, suspectOf, SUBMISSION_FIELD, SUBMISSION_ID, validateCallbackLead, validateCandidate, validateLead, type Lead, type LeadCandidate, type LeadChannel, type LeadError, type PageLeadError, type LeadConsent, type LeadExtra, type LeadPrice, type LeadRejection, type LeadSchema, type LeadStore, type LeadSuspect, type LeadVerdict, type LeadWire, type SpamVerdict, } from "./core/lead.js";
10
10
  export { isMobilePhone, isPlausiblePhone, normalizePhone, phoneProblem } from "./core/phone.js";
11
11
  export { CENTS_BUCKETS, centsBucket, ESTIMATE_FIELD_PREFIX, estimateField, flowOf, labelOf, LEAD_FLOWS, mulBp, parsePricingModel, priceOf, PRICING_CURRENCY, PRICING_FORMAT, PRICING_LIMITS, PricingModelError, pricingProblems, pricingProblemsFor, readEstimateInputs, roundTo, type AddOption, type DiscountOption, type LeadFlow, type LeadFlows, type MultiplyOption, type NeedPricing, type Price, type PriceLine, type PricingInput, type PricingInputKind, type PricingInputs, type PricingLabels, type PricingModel, } from "./core/pricing/index.js";
12
12
  export { flowTextOf, LEAD_CAPTURE_TEXT, type LeadCaptureFlowText, type LeadCaptureText } from "./core/lead-capture-text.js";
13
13
  export { fillText, formatCents, openingText } from "./core/lead-capture-format.js";
14
14
  export { channelHref, resolveChannels, smsHref, type CaptureChannel, type ChannelFacts, type ChannelOptions, type ResolvedChannels, } from "./core/channels.js";
15
15
  export { checkTiming, HONEYPOT_FIELD, LEGACY_HONEYPOT_FIELDS, MIN_FILL_MS, RATE_LIMIT_MAX_KEYS, RateLimiter, RENDERED_AT_FIELD, screen, type Screening, } from "./core/antispam.js";
16
- export { createAcceptLead, EXPERIMENT_FIELD, FORM_ID_FIELD, leadRef, LOCALE_FIELD, LOCATION_FIELD, VARIANT_FIELD, type AcceptDeps, type Outcome, type SubmitTags, } from "./core/accept.js";
16
+ export { createAcceptLead, EXPERIMENT_FIELD, FORM_ID_FIELD, leadRef, LOCALE_FIELD, LOCATION_FIELD, PRICE_CHANGED, SHOWN_CENTS_FIELD, VARIANT_FIELD, type AcceptDeps, type Outcome, type SubmitTags, } from "./core/accept.js";
17
17
  export { areaServedNodes, breadcrumbNode, businessId, businessNode, faqPageNode, offerNodes, organizationId, organizationNode, placeGraph, websiteId, type OfferInput, type PageGraphCopy, type QuestionAnswer, } from "./core/seo/ld.js";
18
18
  export { AI_CRAWLERS, ogImageUrl, robotsFor, sitemapFor, type RobotsFile, type RobotsRule, type SitemapEntry, } from "./core/seo/sitemap.js";
19
19
  export { ALLOWED_PROPS, analyticsSink, countsAsPageView, EVENTS, EXPERIMENT_SLUG, experimentProps, type AnalyticsTarget, type IntentChannel, type LeadField, type LeadStep, } from "./core/analytics.js";
20
20
  export type { CallBarText, CopySlice, CoreText, PageMetaCopy, QuoteFormCopy, Said, StatusAction, StatusCopy, StatusScreenText, } from "./core/content.js";
21
+ export { BOOKING_LIMITS, BOOKING_PROVIDERS, BOOKING_STATUSES, BookingConfigError, bookingConfigProblems, BOOKING_EXPERIMENT, bookingHref, bookingOf, bookingRequestedProperties, bookingRequestProblems, calComHostsProblems, DEFAULT_BOOKING, DEFAULT_CAL_COM_HOSTS, isBookingProvider, isLeadRef, isPageProvider, isPreferredPart, parseBookingConfig, parseBookingRequest, PREFERRED_PARTS, type BookingChoice, type BookingConfig, type BookingPrefill, type BookingProvider, type BookingRequest, type BookingRequestedProperties, type BookingRules, type BookingStatus, type OpenBookingConfig, type PageProvider, type PreferredPart, } from "./core/booking/index.js";
21
22
  export { brandStatusTarget, statusTarget, thanksChannel, thanksSuffix, type StatusTarget } from "./core/status.js";
22
23
  export { createLocaleRegistry, type LocaleRegistry } from "@evinvest/i18n";
23
24
  export { contactChannel, ldCompact, localBusiness, telHref, whatsappHref, type ContactChannel, type JsonLdNode, } from "@evinvest/marketing";
package/dist/index.js CHANGED
@@ -6,17 +6,18 @@
6
6
  export { assertLaunchable, bakedPlace, cardFact, contactOf, defineSite, ogLocaleOf, openLaunchBlockers, perLocale, } from "./core/site.js";
7
7
  export { createPlaceView, DEFAULT_TIME_ZONE, freshRating, isOpenAt, nextOpening, parseInstant, isPublished, mergeLive, parsePlaceLive, placeHref, placeOrigin, placeUrl, publicationGaps, RATING_MAX_AGE_DAYS, servedLocalities, SERVICE_AREA_GATE, siteOrigin, STOREFRONT_GATE, storefrontOf, } from "./core/place/index.js";
8
8
  export { createRouting, GONE, GONE_HEADER, gonePath, goneHeader, HOST_MARK, LANG_COOKIE, LANG_COOKIE_MAX_AGE, NON_PAGE_ROUTES, parseGoneHeader, parsePlaceParam, PASS_PATHS, placeParam, pointSuffixes, THANKS, } from "./core/routing.js";
9
- export { CARD_FIELD, CARD_ID, CHANNEL_FIELD, channelOf, CONSENT_FIELD, LEAD_CHANNELS, LEAD_ERROR_PARAM, LEAD_SCHEMA_VERSION, MAX_CONSENT, MAX_FIELD, readCandidate, rejectionOf, suspectOf, SUBMISSION_FIELD, SUBMISSION_ID, validateCallbackLead, validateCandidate, validateLead, } from "./core/lead.js";
9
+ export { CARD_FIELD, CARD_ID, CHANNEL_FIELD, channelOf, CONSENT_FIELD, LEAD_CHANNELS, LEAD_ERROR_PARAM, LEAD_CARD_PARAM, leadErrorFor, leadErrorOf, LEAD_SCHEMA_VERSION, MAX_CONSENT, MAX_FIELD, readCandidate, rejectionOf, suspectOf, SUBMISSION_FIELD, SUBMISSION_ID, validateCallbackLead, validateCandidate, validateLead, } from "./core/lead.js";
10
10
  export { isMobilePhone, isPlausiblePhone, normalizePhone, phoneProblem } from "./core/phone.js";
11
11
  export { CENTS_BUCKETS, centsBucket, ESTIMATE_FIELD_PREFIX, estimateField, flowOf, labelOf, LEAD_FLOWS, mulBp, parsePricingModel, priceOf, PRICING_CURRENCY, PRICING_FORMAT, PRICING_LIMITS, PricingModelError, pricingProblems, pricingProblemsFor, readEstimateInputs, roundTo, } from "./core/pricing/index.js";
12
12
  export { flowTextOf, LEAD_CAPTURE_TEXT } from "./core/lead-capture-text.js";
13
13
  export { fillText, formatCents, openingText } from "./core/lead-capture-format.js";
14
14
  export { channelHref, resolveChannels, smsHref, } from "./core/channels.js";
15
15
  export { checkTiming, HONEYPOT_FIELD, LEGACY_HONEYPOT_FIELDS, MIN_FILL_MS, RATE_LIMIT_MAX_KEYS, RateLimiter, RENDERED_AT_FIELD, screen, } from "./core/antispam.js";
16
- export { createAcceptLead, EXPERIMENT_FIELD, FORM_ID_FIELD, leadRef, LOCALE_FIELD, LOCATION_FIELD, VARIANT_FIELD, } from "./core/accept.js";
16
+ export { createAcceptLead, EXPERIMENT_FIELD, FORM_ID_FIELD, leadRef, LOCALE_FIELD, LOCATION_FIELD, PRICE_CHANGED, SHOWN_CENTS_FIELD, VARIANT_FIELD, } from "./core/accept.js";
17
17
  export { areaServedNodes, breadcrumbNode, businessId, businessNode, faqPageNode, offerNodes, organizationId, organizationNode, placeGraph, websiteId, } from "./core/seo/ld.js";
18
18
  export { AI_CRAWLERS, ogImageUrl, robotsFor, sitemapFor, } from "./core/seo/sitemap.js";
19
19
  export { ALLOWED_PROPS, analyticsSink, countsAsPageView, EVENTS, EXPERIMENT_SLUG, experimentProps, } from "./core/analytics.js";
20
+ export { BOOKING_LIMITS, BOOKING_PROVIDERS, BOOKING_STATUSES, BookingConfigError, bookingConfigProblems, BOOKING_EXPERIMENT, bookingHref, bookingOf, bookingRequestedProperties, bookingRequestProblems, calComHostsProblems, DEFAULT_BOOKING, DEFAULT_CAL_COM_HOSTS, isBookingProvider, isLeadRef, isPageProvider, isPreferredPart, parseBookingConfig, parseBookingRequest, PREFERRED_PARTS, } from "./core/booking/index.js";
20
21
  export { brandStatusTarget, statusTarget, thanksChannel, thanksSuffix } from "./core/status.js";
21
22
  // A landing needs these beside the machinery; re-exported by name so a brand
22
23
  // imports one package and the list here is the whole surface.
@@ -0,0 +1,31 @@
1
+ import type { LeadStore } from "../core/lead.js";
2
+ import type { ServerEnv } from "../server/env.js";
3
+ import type { LeadWebhook } from "../server/lead-webhook.js";
4
+ /**
5
+ * `app/quote/booking/route.ts` — the page's word that a priced lead asked for
6
+ * a slot: `booking.requested@1` to the panel, after the lead's `lead.created`,
7
+ * through the same outbox. `LeadCapture` posts it when the visitor opens a
8
+ * booking page (`link`, `cal_com`) or sends a `manual` preference.
9
+ *
10
+ * ```ts
11
+ * export const dynamic = "force-dynamic";
12
+ * export const POST = bookingRoute({ env: serverEnv, webhook });
13
+ * ```
14
+ *
15
+ * The body is JSON: `booking.requested@1`'s properties (`lead_ref`,
16
+ * `provider`, `preferred_date?`, `preferred_part?`) plus `submission`, the
17
+ * form's submission id. The id is what proves the post comes from the page
18
+ * that sent the lead: it is a random UUID only that page knew, and the lead's
19
+ * reference is derived from it — a guessed `lead_ref` alone books nothing.
20
+ * Answers `200 { ok: true, queued }`; `queued` is `false` when the switch is
21
+ * off (`panelBooking`) or the lead already asked.
22
+ */
23
+ export interface BookingRouteDeps {
24
+ env: () => Pick<ServerEnv, "leadsDb">;
25
+ /** The same webhook the quote route queues `lead.created` on. */
26
+ webhook?: () => LeadWebhook | null;
27
+ store?: () => LeadStore;
28
+ now?: () => number;
29
+ log?: Pick<Console, "warn" | "error">;
30
+ }
31
+ export declare function bookingRoute(deps: BookingRouteDeps): (request: Request) => Promise<Response>;
@@ -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";
@@ -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) {
@@ -189,13 +193,18 @@ export function quoteRoute(site, deps) {
189
193
  case "invalid": {
190
194
  const tags = experimentProps(text(form, EXPERIMENT_FIELD), text(form, VARIANT_FIELD));
191
195
  // The field's role and why — never what was typed.
192
- 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 };
193
198
  if (env.posthogKey) {
194
199
  const sink = analyticsSink({ key: env.posthogKey, host: env.posthogHost, brandId: site.brand.id }, outcome.slug);
195
200
  defer(() => sink.capture(EVENTS.formReject, props));
196
201
  }
197
202
  if (scripted)
198
- 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)}`);
199
208
  return seeOther(href(request, outcome.slug, outcome.locale, refused(form, outcome.field, outcome.channel)));
200
209
  }
201
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
+ }