@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,106 @@
1
+ /**
2
+ * Booking, provider-agnostic: how a place's slot is set once a priced lead is
3
+ * stored. The vocabulary is the panel's (snake_case, closed sets) and the
4
+ * fixtures in `test/fixtures/booking/` are the contract it mirrors: a new
5
+ * provider extends the closed set with its own URL rule, and nothing else on
6
+ * either side changes.
7
+ */
8
+ /**
9
+ * - `manual`: no external page — the site promises a call to set the slot, and
10
+ * the operator records it in the panel. Always available; carries no URL.
11
+ * - `link`: any external booking page (strict https), opened with
12
+ * `ref=<leadRef>` in its query.
13
+ * - `google_calendar`: a Google appointment schedule, opened as is — it takes
14
+ * no parameter, so the panel matches its bookings by contact and time.
15
+ * - `cal_com`: a Cal.com event, opened with the name, the phone and
16
+ * `metadata[ref]=<leadRef>` prefilled.
17
+ */
18
+ export declare const BOOKING_PROVIDERS: readonly ["manual", "link", "google_calendar", "cal_com"];
19
+ export type BookingProvider = (typeof BOOKING_PROVIDERS)[number];
20
+ /** The providers with a page to open — every one but `manual`. */
21
+ export type PageProvider = Exclude<BookingProvider, "manual">;
22
+ /**
23
+ * A place's booking, as `PlaceLive.booking` serves it and the panel's place
24
+ * settings edit it: the providers it has a page on, and the one it offers by
25
+ * default — `manual` or one of them. An experiment (`booking_provider`) may
26
+ * pick another of the place's providers (`bookingOf`).
27
+ */
28
+ export interface BookingConfig {
29
+ default: BookingProvider;
30
+ providers: Readonly<Partial<Record<PageProvider, {
31
+ url: string;
32
+ }>>>;
33
+ }
34
+ /** The booking one page offers: the provider chosen for this visitor, and its page. */
35
+ export type BookingChoice = {
36
+ provider: "manual";
37
+ } | {
38
+ provider: PageProvider;
39
+ url: string;
40
+ };
41
+ /**
42
+ * A choice a brand's own adapter makes sense of — what `LeadBooking` takes.
43
+ * Every `BookingChoice` is one; the closed set is what the place settings
44
+ * and `booking.requested@1` accept.
45
+ */
46
+ export interface OpenBookingConfig {
47
+ provider: string;
48
+ url?: string;
49
+ }
50
+ /** A place with no booking of its own — baked or live — promises a call. */
51
+ export declare const DEFAULT_BOOKING: BookingConfig;
52
+ /** The experiment whose variant names the provider a visitor is offered, the same key on every brand. */
53
+ export declare const BOOKING_EXPERIMENT = "booking_provider";
54
+ /** A lead's booking, as the panel's projection tracks it; the site only ever says `requested`. */
55
+ export declare const BOOKING_STATUSES: readonly ["none", "requested", "booked", "canceled", "done", "no_show"];
56
+ export type BookingStatus = (typeof BOOKING_STATUSES)[number];
57
+ /** The part of the day a visitor may prefer for a `manual` slot. */
58
+ export declare const PREFERRED_PARTS: readonly ["morning", "afternoon", "evening"];
59
+ export type PreferredPart = (typeof PREFERRED_PARTS)[number];
60
+ /**
61
+ * The site's `booking.requested@1`: a lead asked for a slot. A provider with
62
+ * a page sends it when the page is opened; `manual` when the visitor sends
63
+ * the (optional) preference — which only `manual` carries.
64
+ */
65
+ export interface BookingRequest {
66
+ /** The lead's public reference (`leadRef`) — the panel's lead id. */
67
+ leadRef: string;
68
+ provider: BookingProvider;
69
+ /** `YYYY-MM-DD`; `manual` only. */
70
+ preferredDate?: string;
71
+ /** `manual` only. */
72
+ preferredPart?: PreferredPart;
73
+ }
74
+ /** `booking.requested@1`'s properties as the panel reads them. */
75
+ export interface BookingRequestedProperties {
76
+ lead_ref: string;
77
+ provider: BookingProvider;
78
+ preferred_date?: string;
79
+ preferred_part?: PreferredPart;
80
+ }
81
+ /** The Cal.com hosts a `cal_com` URL may point at unless the site names its own: ours, and the hosted cloud. */
82
+ export declare const DEFAULT_CAL_COM_HOSTS: readonly string[];
83
+ /** What a validator is told besides the value: the site's (or brand's) Cal.com hosts. */
84
+ export interface BookingRules {
85
+ /** Exact hostnames, lowercase; a subdomain is another host. Default `DEFAULT_CAL_COM_HOSTS`. */
86
+ calComHosts?: readonly string[];
87
+ }
88
+ export declare const BOOKING_LIMITS: {
89
+ readonly maxUrl: 2048;
90
+ /** A Cal.com username or event slug: one path segment, no dot segment. */
91
+ readonly calComSegment: RegExp;
92
+ };
93
+ export declare function isLeadRef(value: unknown): value is string;
94
+ export declare function isBookingProvider(value: unknown): value is BookingProvider;
95
+ export declare function isPageProvider(value: unknown): value is PageProvider;
96
+ export declare function isPreferredPart(value: unknown): value is PreferredPart;
97
+ /**
98
+ * The one entry point for which booking a visitor is offered: the provider
99
+ * the experiment's variant (`booking_provider`) names, when the place has it
100
+ * (`manual` it always has), else the place's default. The chosen provider is
101
+ * what `booking.requested@1` and `lead_booking_open/done` report, so the arms
102
+ * compare.
103
+ */
104
+ export declare function bookingOf(place: {
105
+ booking?: BookingConfig | undefined;
106
+ }, variant?: string | null): BookingChoice;
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Booking, provider-agnostic: how a place's slot is set once a priced lead is
3
+ * stored. The vocabulary is the panel's (snake_case, closed sets) and the
4
+ * fixtures in `test/fixtures/booking/` are the contract it mirrors: a new
5
+ * provider extends the closed set with its own URL rule, and nothing else on
6
+ * either side changes.
7
+ */
8
+ /**
9
+ * - `manual`: no external page — the site promises a call to set the slot, and
10
+ * the operator records it in the panel. Always available; carries no URL.
11
+ * - `link`: any external booking page (strict https), opened with
12
+ * `ref=<leadRef>` in its query.
13
+ * - `google_calendar`: a Google appointment schedule, opened as is — it takes
14
+ * no parameter, so the panel matches its bookings by contact and time.
15
+ * - `cal_com`: a Cal.com event, opened with the name, the phone and
16
+ * `metadata[ref]=<leadRef>` prefilled.
17
+ */
18
+ export const BOOKING_PROVIDERS = ["manual", "link", "google_calendar", "cal_com"];
19
+ /** A place with no booking of its own — baked or live — promises a call. */
20
+ export const DEFAULT_BOOKING = { default: "manual", providers: {} };
21
+ /** The experiment whose variant names the provider a visitor is offered, the same key on every brand. */
22
+ export const BOOKING_EXPERIMENT = "booking_provider";
23
+ /** A lead's booking, as the panel's projection tracks it; the site only ever says `requested`. */
24
+ export const BOOKING_STATUSES = ["none", "requested", "booked", "canceled", "done", "no_show"];
25
+ /** The part of the day a visitor may prefer for a `manual` slot. */
26
+ export const PREFERRED_PARTS = ["morning", "afternoon", "evening"];
27
+ /** The Cal.com hosts a `cal_com` URL may point at unless the site names its own: ours, and the hosted cloud. */
28
+ export const DEFAULT_CAL_COM_HOSTS = ["cal.evinvest.ltd", "cal.com"];
29
+ export const BOOKING_LIMITS = {
30
+ maxUrl: 2048,
31
+ /** A Cal.com username or event slug: one path segment, no dot segment. */
32
+ calComSegment: /^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$/,
33
+ };
34
+ /** `lead-<row>-<8 hex>`, as `leadRef` mints it. */
35
+ const LEAD_REF = /^lead-[1-9]\d{0,14}-[0-9a-f]{8}$/;
36
+ export function isLeadRef(value) {
37
+ return typeof value === "string" && LEAD_REF.test(value);
38
+ }
39
+ export function isBookingProvider(value) {
40
+ return BOOKING_PROVIDERS.some(p => p === value);
41
+ }
42
+ export function isPageProvider(value) {
43
+ return value !== "manual" && isBookingProvider(value);
44
+ }
45
+ export function isPreferredPart(value) {
46
+ return PREFERRED_PARTS.some(p => p === value);
47
+ }
48
+ /**
49
+ * The one entry point for which booking a visitor is offered: the provider
50
+ * the experiment's variant (`booking_provider`) names, when the place has it
51
+ * (`manual` it always has), else the place's default. The chosen provider is
52
+ * what `booking.requested@1` and `lead_booking_open/done` report, so the arms
53
+ * compare.
54
+ */
55
+ export function bookingOf(place, variant) {
56
+ const booking = place.booking ?? DEFAULT_BOOKING;
57
+ const pick = (provider) => {
58
+ if (provider === "manual")
59
+ return { provider };
60
+ if (!isPageProvider(provider))
61
+ return null;
62
+ const page = booking.providers[provider];
63
+ return page ? { provider, url: page.url } : null;
64
+ };
65
+ return pick(variant) ?? pick(booking.default) ?? { provider: "manual" };
66
+ }
@@ -0,0 +1,17 @@
1
+ import type { BookingChoice } from "./model.js";
2
+ /** What the page knows of the lead when the visitor asks for a slot. */
3
+ export interface BookingPrefill {
4
+ leadRef: string;
5
+ /** As typed; `cal_com` only. */
6
+ name?: string | null | undefined;
7
+ /** As typed; `cal_com` only, and only as a number `normalizePhone` reads (E.164). */
8
+ phone?: string | null | undefined;
9
+ }
10
+ /**
11
+ * The page a click opens, or `null` for `manual`. The lead's reference goes
12
+ * in the QUERY, never the fragment: `link` as `ref=<leadRef>` (and nothing
13
+ * else — an arbitrary host gets no personal data); `cal_com` as
14
+ * `metadata[ref]` with the name and the phone. `google_calendar` takes no
15
+ * parameter: its URL as is, and the panel matches by contact and time.
16
+ */
17
+ export declare function bookingHref(choice: BookingChoice, prefill: BookingPrefill): string | null;
@@ -0,0 +1,53 @@
1
+ import { normalizePhone } from "../phone.js";
2
+ function keyOf(pair) {
3
+ const key = pair.split("=", 1)[0] ?? "";
4
+ try {
5
+ return decodeURIComponent(key.replaceAll("+", " "));
6
+ }
7
+ catch {
8
+ return key;
9
+ }
10
+ }
11
+ /**
12
+ * `url` with `pairs` in its query, each replacing any pair of the same key
13
+ * the URL already had; the rest of the query is kept byte for byte. Keys go
14
+ * as written (`metadata[ref]`, as Cal.com documents it), values encoded.
15
+ */
16
+ function withQuery(url, pairs) {
17
+ const u = new URL(url);
18
+ const keys = new Set(pairs.map(([k]) => k));
19
+ const kept = u.search
20
+ .slice(1)
21
+ .split("&")
22
+ .filter(p => p !== "" && !keys.has(keyOf(p)));
23
+ u.search = [...kept, ...pairs.map(([k, v]) => `${k}=${encodeURIComponent(v)}`)].join("&");
24
+ return u.toString();
25
+ }
26
+ /**
27
+ * The page a click opens, or `null` for `manual`. The lead's reference goes
28
+ * in the QUERY, never the fragment: `link` as `ref=<leadRef>` (and nothing
29
+ * else — an arbitrary host gets no personal data); `cal_com` as
30
+ * `metadata[ref]` with the name and the phone. `google_calendar` takes no
31
+ * parameter: its URL as is, and the panel matches by contact and time.
32
+ */
33
+ export function bookingHref(choice, prefill) {
34
+ const name = prefill.name?.trim();
35
+ const phone = prefill.phone ? normalizePhone(prefill.phone) : null;
36
+ switch (choice.provider) {
37
+ case "manual":
38
+ return null;
39
+ case "link":
40
+ return withQuery(choice.url, [["ref", prefill.leadRef]]);
41
+ case "google_calendar":
42
+ return choice.url;
43
+ case "cal_com": {
44
+ const pairs = [];
45
+ if (name)
46
+ pairs.push(["name", name]);
47
+ if (phone)
48
+ pairs.push(["attendeePhoneNumber", phone]);
49
+ pairs.push(["metadata[ref]", prefill.leadRef]);
50
+ return withQuery(choice.url, pairs);
51
+ }
52
+ }
53
+ }
@@ -0,0 +1,27 @@
1
+ import { type BookingConfig, type BookingRequest, type BookingRequestedProperties, type BookingRules } from "./model.js";
2
+ /** A booking config that does not validate, with every reason found. */
3
+ export declare class BookingConfigError extends Error {
4
+ readonly problems: readonly string[];
5
+ readonly name = "BookingConfigError";
6
+ constructor(problems: readonly string[]);
7
+ }
8
+ /**
9
+ * Every reason `value` is not a place's booking, or none. Unknown fields are
10
+ * refused, not ignored — `manual` has no page entry — so the site and the
11
+ * panel never disagree on what a config says.
12
+ */
13
+ export declare function bookingConfigProblems(value: unknown, rules?: BookingRules): string[];
14
+ /** The config `value` is, rebuilt from what was checked; throws `BookingConfigError` with every problem. */
15
+ export declare function parseBookingConfig(value: unknown, rules?: BookingRules): BookingConfig;
16
+ /** Every reason a host list is not one a site may allow Cal.com on. */
17
+ export declare function calComHostsProblems(hosts: readonly string[]): string[];
18
+ /**
19
+ * Every reason `value` is not `booking.requested@1`'s properties, or none:
20
+ * a `lead_ref`, a provider of the closed set and, with `manual` only, an
21
+ * optional day and part of the day. No free text — nothing a person typed.
22
+ */
23
+ export declare function bookingRequestProblems(value: unknown): string[];
24
+ /** The request `value` is; throws `BookingConfigError` with every problem. */
25
+ export declare function parseBookingRequest(value: unknown): BookingRequest;
26
+ /** A request as `booking.requested@1`'s properties: snake_case, absent fields left out. */
27
+ export declare function bookingRequestedProperties(request: BookingRequest): BookingRequestedProperties;
@@ -0,0 +1,219 @@
1
+ import { BOOKING_LIMITS as LIMIT, DEFAULT_CAL_COM_HOSTS, isBookingProvider, isLeadRef, isPageProvider, isPreferredPart, } from "./model.js";
2
+ /** A booking config that does not validate, with every reason found. */
3
+ export class BookingConfigError extends Error {
4
+ problems;
5
+ name = "BookingConfigError";
6
+ constructor(problems) {
7
+ super(`booking: ${problems.join("; ")}`);
8
+ this.problems = problems;
9
+ }
10
+ }
11
+ const isObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
12
+ function unknownKeys(path, v, allowed) {
13
+ return Object.keys(v)
14
+ .filter(key => !allowed.includes(key))
15
+ .map(key => `${path}.${key}: unknown field`);
16
+ }
17
+ // Printable ASCII only: the URL parser would quietly drop a tab or a line
18
+ // break and re-encode the rest, so what was checked would not be what opens.
19
+ const PRINTABLE_ASCII = /^[\x21-\x7e]+$/;
20
+ const LABEL = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?$/;
21
+ // A last label that is a number (or `0x…`) makes the WHATWG parser read the
22
+ // host as an IPv4 address: `https://0x7f000001/` is 127.0.0.1.
23
+ const NUMERIC_LABEL = /^(?:\d+|0x[0-9a-f]*)$/i;
24
+ /**
25
+ * Every reason `value` is not a booking URL. The rules are the panel's too
26
+ * (`test/fixtures/booking/README.md`): a literal lowercase `https://`, a
27
+ * dotted DNS name for a host — no userinfo, no port, no IP literal — no
28
+ * fragment, printable ASCII, at most 2048 characters.
29
+ */
30
+ function urlProblems(path, value) {
31
+ const fail = (why) => ({ problems: [`${path}: ${why}`], url: null, rawPath: "" });
32
+ if (typeof value !== "string")
33
+ return fail("a URL string");
34
+ if (value.length > LIMIT.maxUrl)
35
+ return fail(`at most ${LIMIT.maxUrl} characters`);
36
+ if (!PRINTABLE_ASCII.test(value))
37
+ return fail("printable ASCII only, no spaces or control characters");
38
+ if (!value.startsWith("https://"))
39
+ return fail('must start with "https://"');
40
+ if (value.includes("#"))
41
+ return fail("no fragment");
42
+ if (value.includes("\\"))
43
+ return fail("no backslash");
44
+ const rest = value.slice("https://".length);
45
+ const end = rest.search(/[/?]/);
46
+ const authority = end === -1 ? rest : rest.slice(0, end);
47
+ if (authority.includes("@"))
48
+ return fail("no userinfo");
49
+ if (authority.includes(":") || authority.startsWith("["))
50
+ return fail("no port and no IP literal");
51
+ const labels = authority.split(".");
52
+ if (labels.length < 2 || authority.length > 253 || !labels.every(l => LABEL.test(l)))
53
+ return fail("the host must be a dotted DNS name");
54
+ if (NUMERIC_LABEL.test(labels[labels.length - 1] ?? ""))
55
+ return fail("the host must not be an IP literal");
56
+ let url;
57
+ try {
58
+ url = new URL(value);
59
+ }
60
+ catch {
61
+ return fail("not a URL");
62
+ }
63
+ const query = rest.indexOf("?");
64
+ const rawPath = end === -1 ? "" : rest.slice(end, query === -1 ? undefined : query);
65
+ return { problems: [], url, rawPath };
66
+ }
67
+ function calComProblems(path, url, rawPath, rules) {
68
+ const hosts = rules.calComHosts ?? DEFAULT_CAL_COM_HOSTS;
69
+ const out = [];
70
+ if (!hosts.includes(url.hostname))
71
+ out.push(`${path}: the host must be one of ${hosts.join(", ")}`);
72
+ const segments = rawPath.split("/").slice(1);
73
+ if (!rawPath.startsWith("/") || segments.length !== 2 || !segments.every(s => LIMIT.calComSegment.test(s))) {
74
+ out.push(`${path}: the path must be /<user>/<event>`);
75
+ }
76
+ return out;
77
+ }
78
+ /** A non-empty path: a page on the host, not the host's home. */
79
+ const hasPath = (rawPath) => rawPath !== "" && rawPath !== "/";
80
+ const GOOGLE_SHORT = "calendar.app.google";
81
+ const GOOGLE_LONG = "calendar.google.com";
82
+ const GOOGLE_PATH = "/calendar/appointments/";
83
+ /** The provider's own rule over the URL rule every provider shares. */
84
+ function providerProblems(path, provider, url, rawPath, rules) {
85
+ switch (provider) {
86
+ case "link":
87
+ return [];
88
+ case "google_calendar":
89
+ if (url.hostname === GOOGLE_SHORT)
90
+ return hasPath(rawPath) ? [] : [`${path}: the path must name a schedule`];
91
+ if (url.hostname === GOOGLE_LONG)
92
+ return rawPath.startsWith(GOOGLE_PATH) && rawPath.length > GOOGLE_PATH.length ? [] : [`${path}: the path must be ${GOOGLE_PATH}…`];
93
+ return [`${path}: the host must be ${GOOGLE_SHORT} or ${GOOGLE_LONG}`];
94
+ case "cal_com":
95
+ return calComProblems(path, url, rawPath, rules);
96
+ }
97
+ }
98
+ function check(value, rules) {
99
+ if (!isObject(value))
100
+ return { problems: ["booking: an object"], config: null };
101
+ const problems = unknownKeys("booking", value, ["default", "providers"]);
102
+ const providers = {};
103
+ if (!isObject(value.providers))
104
+ problems.push("booking.providers: an object by provider");
105
+ else {
106
+ for (const [name, page] of Object.entries(value.providers)) {
107
+ const path = `booking.providers.${name}`;
108
+ if (name === "manual") {
109
+ problems.push(`${path}: manual has no page — it is always available`);
110
+ continue;
111
+ }
112
+ if (!isPageProvider(name)) {
113
+ problems.push(`${path}: not a provider (link, google_calendar, cal_com)`);
114
+ continue;
115
+ }
116
+ if (!isObject(page)) {
117
+ problems.push(`${path}: an object, { url }`);
118
+ continue;
119
+ }
120
+ problems.push(...unknownKeys(path, page, ["url"]));
121
+ if (!Object.hasOwn(page, "url")) {
122
+ problems.push(`${path}.url: missing`);
123
+ continue;
124
+ }
125
+ const parsed = urlProblems(`${path}.url`, page.url);
126
+ problems.push(...parsed.problems);
127
+ if (parsed.url)
128
+ problems.push(...providerProblems(`${path}.url`, name, parsed.url, parsed.rawPath, rules));
129
+ if (typeof page.url === "string")
130
+ providers[name] = { url: page.url };
131
+ }
132
+ }
133
+ const fallback = value.default;
134
+ if (!isBookingProvider(fallback))
135
+ problems.push("booking.default: one of manual, link, google_calendar, cal_com");
136
+ else if (fallback !== "manual" && !Object.hasOwn(providers, fallback))
137
+ problems.push(`booking.default: "${fallback}" is not one of the providers`);
138
+ if (problems.length > 0 || !isBookingProvider(fallback))
139
+ return { problems, config: null };
140
+ return { problems: [], config: { default: fallback, providers } };
141
+ }
142
+ /**
143
+ * Every reason `value` is not a place's booking, or none. Unknown fields are
144
+ * refused, not ignored — `manual` has no page entry — so the site and the
145
+ * panel never disagree on what a config says.
146
+ */
147
+ export function bookingConfigProblems(value, rules = {}) {
148
+ return check(value, rules).problems;
149
+ }
150
+ /** The config `value` is, rebuilt from what was checked; throws `BookingConfigError` with every problem. */
151
+ export function parseBookingConfig(value, rules = {}) {
152
+ const { problems, config } = check(value, rules);
153
+ if (!config)
154
+ throw new BookingConfigError(problems);
155
+ return config;
156
+ }
157
+ /** Every reason a host list is not one a site may allow Cal.com on. */
158
+ export function calComHostsProblems(hosts) {
159
+ return hosts.flatMap((host, i) => {
160
+ const labels = host.split(".");
161
+ const dns = host === host.toLowerCase() && labels.length >= 2 && labels.every(l => LABEL.test(l)) && !NUMERIC_LABEL.test(labels[labels.length - 1] ?? "");
162
+ return dns ? [] : [`calComHosts[${i}]: a lowercase dotted DNS name, not ${JSON.stringify(host)}`];
163
+ });
164
+ }
165
+ /** `YYYY-MM-DD`, and a day the calendar has. */
166
+ function isDay(v) {
167
+ if (typeof v !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(v))
168
+ return false;
169
+ const day = new Date(`${v}T00:00:00Z`);
170
+ return !Number.isNaN(day.getTime()) && day.toISOString().slice(0, 10) === v;
171
+ }
172
+ function checkRequest(value) {
173
+ if (!isObject(value))
174
+ return { problems: ["booking.requested: an object"], request: null };
175
+ const problems = unknownKeys("booking.requested", value, ["lead_ref", "provider", "preferred_date", "preferred_part"]);
176
+ const { lead_ref: leadRef, provider, preferred_date: date, preferred_part: part } = value;
177
+ if (!isLeadRef(leadRef))
178
+ problems.push("booking.requested.lead_ref: lead-<row>-<8 hex>");
179
+ if (!isBookingProvider(provider))
180
+ problems.push("booking.requested.provider: one of manual, link, google_calendar, cal_com");
181
+ if (date !== undefined && !isDay(date))
182
+ problems.push("booking.requested.preferred_date: a date, YYYY-MM-DD");
183
+ if (part !== undefined && !isPreferredPart(part))
184
+ problems.push("booking.requested.preferred_part: morning, afternoon or evening");
185
+ if ((date !== undefined || part !== undefined) && provider !== "manual")
186
+ problems.push("booking.requested: a preference only with manual");
187
+ if (problems.length > 0 || !isLeadRef(leadRef) || !isBookingProvider(provider))
188
+ return { problems, request: null };
189
+ const request = { leadRef, provider };
190
+ if (typeof date === "string")
191
+ request.preferredDate = date;
192
+ if (isPreferredPart(part))
193
+ request.preferredPart = part;
194
+ return { problems: [], request };
195
+ }
196
+ /**
197
+ * Every reason `value` is not `booking.requested@1`'s properties, or none:
198
+ * a `lead_ref`, a provider of the closed set and, with `manual` only, an
199
+ * optional day and part of the day. No free text — nothing a person typed.
200
+ */
201
+ export function bookingRequestProblems(value) {
202
+ return checkRequest(value).problems;
203
+ }
204
+ /** The request `value` is; throws `BookingConfigError` with every problem. */
205
+ export function parseBookingRequest(value) {
206
+ const { problems, request } = checkRequest(value);
207
+ if (!request)
208
+ throw new BookingConfigError(problems);
209
+ return request;
210
+ }
211
+ /** A request as `booking.requested@1`'s properties: snake_case, absent fields left out. */
212
+ export function bookingRequestedProperties(request) {
213
+ return {
214
+ lead_ref: request.leadRef,
215
+ provider: request.provider,
216
+ ...(request.preferredDate !== undefined ? { preferred_date: request.preferredDate } : {}),
217
+ ...(request.preferredPart !== undefined ? { preferred_part: request.preferredPart } : {}),
218
+ };
219
+ }
@@ -4,3 +4,9 @@ import type { Opening } from "./place/hours.js";
4
4
  export declare function fillText(template: string, values: Readonly<Record<string, string>>): string;
5
5
  /** The promise of the next opening, from real hours — or `null`, never an invented one. */
6
6
  export declare function openingText(opening: Opening | null, text: Pick<LeadCaptureText, "openingToday" | "openingTomorrow" | "openingLater">, locale: string): string | null;
7
+ /**
8
+ * Cents as the page's language writes euros: `84 €`, `84,50 €`, `€84.50`.
9
+ * Whole euros drop the cents a rounded price never has; a signed line of a
10
+ * breakdown is `+8,50 €` / `−9,35 €`.
11
+ */
12
+ export declare function formatCents(cents: number, locale: string, signed?: boolean): string;
@@ -30,3 +30,16 @@ export function openingText(opening, text, locale) {
30
30
  return fillText(text.openingTomorrow, { time });
31
31
  return fillText(text.openingLater, { time, day: weekdayText(opening.day, locale) });
32
32
  }
33
+ /**
34
+ * Cents as the page's language writes euros: `84 €`, `84,50 €`, `€84.50`.
35
+ * Whole euros drop the cents a rounded price never has; a signed line of a
36
+ * breakdown is `+8,50 €` / `−9,35 €`.
37
+ */
38
+ export function formatCents(cents, locale, signed = false) {
39
+ const digits = cents % 100 === 0 ? 0 : 2;
40
+ const format = new Intl.NumberFormat(locale, { style: "currency", currency: "EUR", minimumFractionDigits: digits, maximumFractionDigits: digits });
41
+ const text = format.format(Math.abs(cents) / 100);
42
+ if (!signed)
43
+ return cents < 0 ? `−${text}` : text;
44
+ return `${cents < 0 ? "−" : "+"}${text}`;
45
+ }
@@ -7,8 +7,12 @@
7
7
  * The kit ships French and English (`LEAD_CAPTURE_TEXT`) so every brand asks
8
8
  * in the same words and an experiment's arms differ only in what it tests; a
9
9
  * brand overrides a key by spreading: `{ ...LEAD_CAPTURE_TEXT.fr, submit: "…" }`.
10
+ *
11
+ * The flows' words (`LeadCaptureFlowText`) are optional here so a brand's own
12
+ * text object written before them still type-checks; a key left out falls
13
+ * back to the kit's, in the page's language (`flowTextOf`).
10
14
  */
11
- export interface LeadCaptureText {
15
+ export interface LeadCaptureText extends Partial<LeadCaptureFlowText> {
12
16
  title: string;
13
17
  lede: string;
14
18
  needLabel: string;
@@ -37,7 +41,7 @@ export interface LeadCaptureText {
37
41
  formInvalid: string;
38
42
  /** On the submit while the script posts the form. */
39
43
  sending: string;
40
- /** The post never reached the server: nothing was sent, the form is as typed. */
44
+ /** The post got no answer — the lead may be stored already: a retry cannot make a second one. */
41
45
  networkError: string;
42
46
  /** No answer in time: it may have arrived — a retry cannot make a second lead. */
43
47
  timeoutError: string;
@@ -78,4 +82,55 @@ export interface LeadCaptureText {
78
82
  /** The honeypot's label — read only by a bot filling every field. */
79
83
  honeypotLabel: string;
80
84
  }
85
+ /** What the `estimate` and `fixed` flows, the photos ask and the booking print. */
86
+ export interface LeadCaptureFlowText {
87
+ /** Heads the live price. */
88
+ priceTitle: string;
89
+ /** In place of the price until every input is answered. */
90
+ pricePending: string;
91
+ /** Under the price: what it includes and when it is paid. */
92
+ priceNote: string;
93
+ /** The breakdown's first line: the need's base. */
94
+ priceBase: string;
95
+ priceRounding: string;
96
+ priceMinimum: string;
97
+ /** The submit of a priced need. */
98
+ bookSubmit: string;
99
+ /** The in-card success of a priced lead; `{price}` the server's. */
100
+ sentPrice: string;
101
+ /** After a priced lead with a `manual` booking: the slot is set by a call. */
102
+ slotCallback: string;
103
+ /** The price changed under the form: `{price}` the fresh one, `{shown}` the one shown; the submit confirms it. */
104
+ priceChanged: string;
105
+ /** The same, without a script — the page shows the fresh price for the answers given again. */
106
+ priceChangedGeneric: string;
107
+ /** Opens the place's booking page (`link`, `cal_com`). */
108
+ bookCta: string;
109
+ /** Under it, for a provider the number cannot be handed to (Google): type the same one there. */
110
+ bookPhoneHint: string;
111
+ /** Under it: the call still comes if the visitor books nothing. */
112
+ bookNote: string;
113
+ /** Once the provider says the slot is booked. */
114
+ booked: string;
115
+ /** `manual`: the optional preference, its day and part of the day. */
116
+ preferTitle: string;
117
+ preferDay: string;
118
+ preferPart: string;
119
+ partMorning: string;
120
+ partAfternoon: string;
121
+ partEvening: string;
122
+ preferSubmit: string;
123
+ /** After the preference is sent. */
124
+ preferSent: string;
125
+ /** The preference did not reach the site; the call is still coming. */
126
+ preferFailed: string;
127
+ /** A `quote` need that is priced from photos: the ask, and its WhatsApp link. */
128
+ photosTitle: string;
129
+ photosLede: string;
130
+ photosCta: string;
131
+ /** The WhatsApp message, `{need}` the need's label. */
132
+ photosMessage: string;
133
+ }
81
134
  export declare const LEAD_CAPTURE_TEXT: Readonly<Record<"fr" | "en", LeadCaptureText>>;
135
+ /** The flows' words for a page: the brand's, else the kit's in its language (English for `en*`, French otherwise). */
136
+ export declare function flowTextOf(text: LeadCaptureText, locale: string): LeadCaptureFlowText;