@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
@@ -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
+ }
@@ -41,7 +41,7 @@ export interface LeadCaptureText extends Partial<LeadCaptureFlowText> {
41
41
  formInvalid: string;
42
42
  /** On the submit while the script posts the form. */
43
43
  sending: string;
44
- /** 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. */
45
45
  networkError: string;
46
46
  /** No answer in time: it may have arrived — a retry cannot make a second lead. */
47
47
  timeoutError: string;
@@ -98,8 +98,32 @@ export interface LeadCaptureFlowText {
98
98
  bookSubmit: string;
99
99
  /** The in-card success of a priced lead; `{price}` the server's. */
100
100
  sentPrice: string;
101
- /** After a priced lead: the slot is set by a call (no booking provider yet). */
101
+ /** After a priced lead with a `manual` booking: the slot is set by a call. */
102
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;
103
127
  /** A `quote` need that is priced from photos: the ask, and its WhatsApp link. */
104
128
  photosTitle: string;
105
129
  photosLede: string;
@@ -8,6 +8,21 @@ const FR_FLOW = {
8
8
  bookSubmit: "Réserver",
9
9
  sentPrice: "Demande enregistrée au prix de {price}.",
10
10
  slotCallback: "Nous vous rappelons pour fixer le créneau.",
11
+ priceChanged: "Le prix a changé : {price} au lieu de {shown}. Confirmez pour envoyer votre demande à ce prix.",
12
+ priceChangedGeneric: "Le prix a changé depuis l’affichage de la page. Vérifiez le nouveau prix et confirmez.",
13
+ bookCta: "Choisir un créneau",
14
+ bookPhoneHint: "Indiquez le même numéro de téléphone en réservant : il relie le créneau à votre demande.",
15
+ bookNote: "Sinon, nous vous rappelons pour fixer le créneau.",
16
+ booked: "Créneau réservé. Merci !",
17
+ preferTitle: "Une préférence pour le créneau ? (facultatif)",
18
+ preferDay: "Jour",
19
+ preferPart: "Moment de la journée",
20
+ partMorning: "Matin",
21
+ partAfternoon: "Après-midi",
22
+ partEvening: "Soir",
23
+ preferSubmit: "Envoyer ma préférence",
24
+ preferSent: "Préférence notée : nous en tenons compte en vous rappelant.",
25
+ preferFailed: "Votre préférence n’a pas pu être envoyée. Nous vous rappelons quand même pour fixer le créneau.",
11
26
  photosTitle: "Envoyez des photos",
12
27
  photosLede: "Pour ce besoin, quelques photos nous permettent de vous donner un prix juste.",
13
28
  photosCta: "Envoyer des photos sur WhatsApp",
@@ -23,6 +38,21 @@ const EN_FLOW = {
23
38
  bookSubmit: "Book",
24
39
  sentPrice: "Request saved at {price}.",
25
40
  slotCallback: "We will call you to set the slot.",
41
+ priceChanged: "The price has changed: {price} instead of {shown}. Confirm to send your request at this price.",
42
+ priceChangedGeneric: "The price has changed since the page was shown. Check the new price and confirm.",
43
+ bookCta: "Pick a slot",
44
+ bookPhoneHint: "Enter the same phone number when you book: it links the slot to your request.",
45
+ bookNote: "Otherwise we will call you to set the slot.",
46
+ booked: "Slot booked. Thank you!",
47
+ preferTitle: "A preferred slot? (optional)",
48
+ preferDay: "Day",
49
+ preferPart: "Time of day",
50
+ partMorning: "Morning",
51
+ partAfternoon: "Afternoon",
52
+ partEvening: "Evening",
53
+ preferSubmit: "Send my preference",
54
+ preferSent: "Noted: we will keep it in mind when we call.",
55
+ preferFailed: "Your preference could not be sent. We will still call you to set the slot.",
26
56
  photosTitle: "Send photos",
27
57
  photosLede: "For this job, a few photos let us give you a fair price.",
28
58
  photosCta: "Send photos on WhatsApp",
@@ -45,7 +75,7 @@ const FR = {
45
75
  fieldInvalid: "Vérifiez ce champ.",
46
76
  formInvalid: "Votre demande n’a pas pu être envoyée. Vérifiez le formulaire.",
47
77
  sending: "Envoi…",
48
- networkError: "Pas de connexion : votre demande n’est pas partie. Vérifiez le réseau et réessayez.",
78
+ networkError: "Pas de réponse du serveur. Vérifiez votre connexion et réessayez : votre demande ne sera pas envoyée deux fois.",
49
79
  timeoutError: "Le serveur ne répond pas. Réessayez : votre demande ne sera pas envoyée deux fois.",
50
80
  retry: "Réessayer",
51
81
  nameLabel: "Nom",
@@ -84,7 +114,7 @@ const EN = {
84
114
  fieldInvalid: "Check this field.",
85
115
  formInvalid: "Your request could not be sent. Check the form.",
86
116
  sending: "Sending…",
87
- networkError: "No connection: your request was not sent. Check the network and try again.",
117
+ networkError: "No answer from the server. Check your connection and try again: your request will not be sent twice.",
88
118
  timeoutError: "The server is not answering. Try again: your request will not be sent twice.",
89
119
  retry: "Try again",
90
120
  nameLabel: "Name",
@@ -109,6 +109,39 @@ export declare const SUBMISSION_FIELD = "submission_id";
109
109
  export declare const SUBMISSION_ID: RegExp;
110
110
  /** The query a refused submission is sent back to the form with: `?lead_error=<field>`. */
111
111
  export declare const LEAD_ERROR_PARAM = "lead_error";
112
+ /** A refusal the server sent back: which of the card's forms, and the field it is about. */
113
+ export interface LeadError {
114
+ channel: LeadChannel;
115
+ field: string;
116
+ }
117
+ /** What a refused field may be named: a slug, never markup. */
118
+ export declare const LEAD_ERROR_FIELD: RegExp;
119
+ /**
120
+ * The query naming where a refusal lands — the anchor the 303 points at,
121
+ * `<card>` or `<card>-callback` — because the fragment never reaches the
122
+ * server, and a page may draw more than one card.
123
+ */
124
+ export declare const LEAD_CARD_PARAM = "lead_card";
125
+ /** A refusal as a page reads it from its query: the card it is for, when the URL names one. */
126
+ export interface PageLeadError extends LeadError {
127
+ card: string | null;
128
+ }
129
+ /**
130
+ * The refusal a 303 brought the visitor back with, read from a page's
131
+ * `searchParams` on the server — `LeadCapture`'s `initialError`, so the card
132
+ * says why without a script. Only a page that reads its query can: one built
133
+ * for ISR does not, and there the card's script says it. `card` is the anchor
134
+ * the route named (`lead_card`), which each card matches against its own id;
135
+ * a URL from before it was named leaves it `null`, and then the consent is
136
+ * the callback's and every other field the form's.
137
+ */
138
+ export declare function leadErrorOf(searchParams: Readonly<Record<string, string | readonly string[] | undefined>>): PageLeadError | null;
139
+ /**
140
+ * The refusal card `id` draws: one whose `card` is this card's form or its
141
+ * callback, there; one naming no card (an older URL), as it reads; one for
142
+ * another card, none.
143
+ */
144
+ export declare function leadErrorFor(error: LeadError | PageLeadError | null, id: string): LeadError | null;
112
145
  /**
113
146
  * The card's id, posted so a refusal lands back on it (`#devis`, not a
114
147
  * hard-coded `#quote`). Only a slug is ever echoed into the redirect.
package/dist/core/lead.js CHANGED
@@ -25,6 +25,49 @@ export const SUBMISSION_FIELD = "submission_id";
25
25
  export const SUBMISSION_ID = /^[A-Za-z0-9-]{16,64}$/;
26
26
  /** The query a refused submission is sent back to the form with: `?lead_error=<field>`. */
27
27
  export const LEAD_ERROR_PARAM = "lead_error";
28
+ /** What a refused field may be named: a slug, never markup. */
29
+ export const LEAD_ERROR_FIELD = /^[a-z][a-z0-9_]{0,31}$/;
30
+ /**
31
+ * The query naming where a refusal lands — the anchor the 303 points at,
32
+ * `<card>` or `<card>-callback` — because the fragment never reaches the
33
+ * server, and a page may draw more than one card.
34
+ */
35
+ export const LEAD_CARD_PARAM = "lead_card";
36
+ const LEAD_CARD = /^[a-z0-9-]{1,80}$/;
37
+ const first = (raw) => (typeof raw === "string" ? raw : raw?.[0]);
38
+ /**
39
+ * The refusal a 303 brought the visitor back with, read from a page's
40
+ * `searchParams` on the server — `LeadCapture`'s `initialError`, so the card
41
+ * says why without a script. Only a page that reads its query can: one built
42
+ * for ISR does not, and there the card's script says it. `card` is the anchor
43
+ * the route named (`lead_card`), which each card matches against its own id;
44
+ * a URL from before it was named leaves it `null`, and then the consent is
45
+ * the callback's and every other field the form's.
46
+ */
47
+ export function leadErrorOf(searchParams) {
48
+ const field = first(searchParams[LEAD_ERROR_PARAM]);
49
+ if (field === undefined || !LEAD_ERROR_FIELD.test(field))
50
+ return null;
51
+ const card = first(searchParams[LEAD_CARD_PARAM]);
52
+ return { channel: field === "consent" ? "callback" : "form", field, card: card !== undefined && LEAD_CARD.test(card) ? card : null };
53
+ }
54
+ /**
55
+ * The refusal card `id` draws: one whose `card` is this card's form or its
56
+ * callback, there; one naming no card (an older URL), as it reads; one for
57
+ * another card, none.
58
+ */
59
+ export function leadErrorFor(error, id) {
60
+ if (!error)
61
+ return null;
62
+ const card = "card" in error ? error.card : null;
63
+ if (card === null)
64
+ return { channel: error.channel, field: error.field };
65
+ if (card === id)
66
+ return { channel: "form", field: error.field };
67
+ if (card === `${id}-callback`)
68
+ return { channel: "callback", field: error.field };
69
+ return null;
70
+ }
28
71
  /**
29
72
  * The card's id, posted so a refusal lands back on it (`#devis`, not a
30
73
  * hard-coded `#quote`). Only a slug is ever echoed into the redirect.
@@ -7,7 +7,9 @@
7
7
  * `0033 6 …`, a mobile typed without its trunk zero (`6 12 34 56 78`) — all
8
8
  * become `+33612345678`. Any other international number is a plausible E.164,
9
9
  * 8 to 15 digits after the `+`, compacted (`+44 7911 123456` →
10
- * `+447911123456`). Full-width digits read as digits; a run of one digit
10
+ * `+447911123456`) — and, for the plans a visitor here most often dials
11
+ * from (`NATIONAL_LENGTH`), as long as that plan's numbers are, so `+12345678`
12
+ * is not a number. Full-width digits read as digits; a run of one digit
11
13
  * (`00 00 00 00 00`, `06 66 66 66 66`) is a refusal to give a number, not one.
12
14
  */
13
15
  export declare function normalizePhone(raw: string): string | null;
@@ -1,3 +1,27 @@
1
+ /**
2
+ * The national number's length, after the country code, for the plans the
3
+ * vertical's visitors most often come from — bounds wide enough for every
4
+ * real number (Germany and Italy vary by area), short of a library. A plan
5
+ * not listed keeps E.164's 8 to 15 digits in all.
6
+ */
7
+ const NATIONAL_LENGTH = {
8
+ "1": [10, 10],
9
+ "32": [8, 9],
10
+ "34": [9, 9],
11
+ "39": [6, 11],
12
+ "41": [9, 9],
13
+ "44": [9, 10],
14
+ "49": [7, 13],
15
+ };
16
+ function fitsPlan(e164) {
17
+ const digits = e164.slice(1);
18
+ for (const code of [digits.slice(0, 1), digits.slice(0, 2)]) {
19
+ const bounds = NATIONAL_LENGTH[code];
20
+ if (bounds)
21
+ return digits.length - code.length >= bounds[0] && digits.length - code.length <= bounds[1];
22
+ }
23
+ return true;
24
+ }
1
25
  /**
2
26
  * A phone number as the funnel reads it: E.164 when the typed value is a
3
27
  * number someone can be reached on, otherwise `null`.
@@ -7,7 +31,9 @@
7
31
  * `0033 6 …`, a mobile typed without its trunk zero (`6 12 34 56 78`) — all
8
32
  * become `+33612345678`. Any other international number is a plausible E.164,
9
33
  * 8 to 15 digits after the `+`, compacted (`+44 7911 123456` →
10
- * `+447911123456`). Full-width digits read as digits; a run of one digit
34
+ * `+447911123456`) — and, for the plans a visitor here most often dials
35
+ * from (`NATIONAL_LENGTH`), as long as that plan's numbers are, so `+12345678`
36
+ * is not a number. Full-width digits read as digits; a run of one digit
11
37
  * (`00 00 00 00 00`, `06 66 66 66 66`) is a refusal to give a number, not one.
12
38
  */
13
39
  export function normalizePhone(raw) {
@@ -27,7 +53,7 @@ export function normalizePhone(raw) {
27
53
  e164 = `+33${trunk[1]}`;
28
54
  else if (/^[1-9]\d{8}$/.test(s))
29
55
  e164 = `+33${s}`;
30
- else if (/^\+[1-9]\d{7,14}$/.test(s))
56
+ else if (/^\+[1-9]\d{7,14}$/.test(s) && fitsPlan(s))
31
57
  e164 = s;
32
58
  }
33
59
  if (e164 === null)
@@ -1,3 +1,4 @@
1
+ import type { BookingConfig, BookingRules } from "../booking/model.js";
1
2
  import type { Geo, OpeningHours, Place, PostalAddress, Rating, ServiceArea } from "./types.js";
2
3
  /**
3
4
  * The live half of a place, as `GET <source>/locations/<slug>` answers it.
@@ -16,12 +17,15 @@ export interface PlaceLive<L extends string> {
16
17
  serviceArea?: readonly ServiceArea[];
17
18
  hours?: readonly OpeningHours[];
18
19
  rating?: Rating;
20
+ /** Validated with the site's Cal.com hosts; one that does not validate is dropped, the baked one kept. */
21
+ booking?: BookingConfig;
19
22
  }
20
23
  /**
21
24
  * Drops what does not validate instead of failing the page on one bad field.
22
- * A landmark counts only when it is given in every one of `locales`.
25
+ * A landmark counts only when it is given in every one of `locales`; a
26
+ * `cal_com` booking only on one of `rules.calComHosts` (the site's).
23
27
  */
24
- export declare function parsePlaceLive<L extends string>(body: unknown, locales: readonly L[]): PlaceLive<L>;
28
+ export declare function parsePlaceLive<L extends string>(body: unknown, locales: readonly L[], rules?: BookingRules): PlaceLive<L>;
25
29
  /**
26
30
  * The live fields over the baked ones. A service-area place takes none of the
27
31
  * storefront's: an address, a pin or a photo from the source would give it a
@@ -1,3 +1,4 @@
1
+ import { BookingConfigError, parseBookingConfig } from "../booking/validate.js";
1
2
  import { parseInstant } from "./rating.js";
2
3
  const DAYS = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"];
3
4
  const TIME = /^([01]\d|2[0-3]):[0-5]\d$/;
@@ -60,11 +61,24 @@ function landmark(v, locales) {
60
61
  function isComplete(record, locales) {
61
62
  return locales.every(locale => typeof record[locale] === "string");
62
63
  }
64
+ function booking(v, rules) {
65
+ if (v === undefined || v === null)
66
+ return undefined;
67
+ try {
68
+ return parseBookingConfig(v, rules);
69
+ }
70
+ catch (error) {
71
+ if (error instanceof BookingConfigError)
72
+ return undefined;
73
+ throw error;
74
+ }
75
+ }
63
76
  /**
64
77
  * Drops what does not validate instead of failing the page on one bad field.
65
- * A landmark counts only when it is given in every one of `locales`.
78
+ * A landmark counts only when it is given in every one of `locales`; a
79
+ * `cal_com` booking only on one of `rules.calComHosts` (the site's).
66
80
  */
67
- export function parsePlaceLive(body, locales) {
81
+ export function parsePlaceLive(body, locales, rules = {}) {
68
82
  if (!isObject(body))
69
83
  throw new Error("live location: body is not an object");
70
84
  const live = {};
@@ -104,6 +118,9 @@ export function parsePlaceLive(body, locales) {
104
118
  const r = rating(body.rating);
105
119
  if (r)
106
120
  live.rating = r;
121
+ const b = booking(body.booking, rules);
122
+ if (b)
123
+ live.booking = b;
107
124
  return live;
108
125
  }
109
126
  /**
@@ -113,6 +130,7 @@ export function parsePlaceLive(body, locales) {
113
130
  */
114
131
  export function mergeLive(baked, live) {
115
132
  const front = baked.presence;
133
+ const booking = live.booking ?? baked.booking;
116
134
  return {
117
135
  ...baked,
118
136
  presence: front.kind === "storefront"
@@ -128,5 +146,6 @@ export function mergeLive(baked, live) {
128
146
  serviceArea: live.serviceArea ?? baked.serviceArea,
129
147
  hours: live.hours ?? baked.hours,
130
148
  rating: live.rating ?? baked.rating,
149
+ ...(booking ? { booking } : {}),
131
150
  };
132
151
  }
@@ -1,3 +1,4 @@
1
+ import type { BookingConfig } from "../booking/model.js";
1
2
  /**
2
3
  * The place model. Three words, one each:
3
4
  *
@@ -79,6 +80,11 @@ export interface Place<L extends string> {
79
80
  hours: readonly OpeningHours[] | null;
80
81
  /** Live only. */
81
82
  rating: Rating | null;
83
+ /**
84
+ * How a priced lead's slot is set (`PlaceLive.booking`, the panel's place
85
+ * settings); absent → `DEFAULT_BOOKING`, a call (`bookingOf`).
86
+ */
87
+ booking?: BookingConfig;
82
88
  }
83
89
  /** The storefront half of a place, or `null` for a service-area business. */
84
90
  export declare function storefrontOf<L extends string>(place: Place<L>): Extract<Presence<L>, {
@@ -1,16 +1,3 @@
1
- /**
2
- * The place model. Three words, one each:
3
- *
4
- * - **Place** — the aggregate: one point of the business, baked config merged
5
- * with the live source;
6
- * - **Presence** — how the place exists for a visitor: a storefront with an
7
- * address, or a service area with none;
8
- * - **ServiceArea** — where the van goes.
9
- *
10
- * A service-area business has no address *in the type*, so no builder can
11
- * render one for it by accident — schema.org, the map and the footer all have
12
- * to ask `presence.kind` first.
13
- */
14
1
  /** The storefront half of a place, or `null` for a service-area business. */
15
2
  export function storefrontOf(place) {
16
3
  return place.presence.kind === "storefront" ? place.presence : null;
@@ -66,8 +66,9 @@ export declare function parseGoneHeader(value: string | null): {
66
66
  };
67
67
  /**
68
68
  * The routes every landing has outside `[locale]`, which pass untouched: the
69
- * form target, the OG card, the probe, the sitemap and robots. Anything else
70
- * without a language but `/_next/…` and the site's `publicFiles` is `gone`.
69
+ * form target, its booking request (`bookingRoute`) and its price
70
+ * confirmation (`confirmRoute`), the OG card, the probe, the sitemap and robots. Anything else without a language but
71
+ * `/_next/…` and the site's `publicFiles` is `gone`.
71
72
  */
72
73
  export declare const NON_PAGE_ROUTES: readonly string[];
73
74
  /** @deprecated The same list as {@link NON_PAGE_ROUTES}, under its old name. */
@@ -70,10 +70,11 @@ export function parseGoneHeader(value) {
70
70
  }
71
71
  /**
72
72
  * The routes every landing has outside `[locale]`, which pass untouched: the
73
- * form target, the OG card, the probe, the sitemap and robots. Anything else
74
- * without a language but `/_next/…` and the site's `publicFiles` is `gone`.
73
+ * form target, its booking request (`bookingRoute`) and its price
74
+ * confirmation (`confirmRoute`), the OG card, the probe, the sitemap and robots. Anything else without a language but
75
+ * `/_next/…` and the site's `publicFiles` is `gone`.
75
76
  */
76
- export const NON_PAGE_ROUTES = ["/quote", "/og", "/health", "/sitemap.xml", "/robots.txt"];
77
+ export const NON_PAGE_ROUTES = ["/quote", "/quote/booking", "/quote/confirm", "/og", "/health", "/sitemap.xml", "/robots.txt"];
77
78
  /** @deprecated The same list as {@link NON_PAGE_ROUTES}, under its old name. */
78
79
  export const PASS_PATHS = NON_PAGE_ROUTES;
79
80
  function isInfrastructure(pathname, publicFiles) {