@evinvest/kitstart 0.10.0 → 0.12.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 (84) hide show
  1. package/README.md +210 -17
  2. package/dist/core/accept.d.ts +19 -1
  3. package/dist/core/accept.js +25 -2
  4. package/dist/core/analytics.d.ts +13 -3
  5. package/dist/core/analytics.js +17 -2
  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 +4 -3
  29. package/dist/index.js +4 -3
  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/AnalyticsBoundary.js +8 -4
  38. package/dist/react/LeadBooking.d.ts +35 -0
  39. package/dist/react/LeadBooking.js +47 -0
  40. package/dist/react/LeadBookingManual.d.ts +17 -0
  41. package/dist/react/LeadBookingManual.js +50 -0
  42. package/dist/react/LeadCapture.d.ts +28 -5
  43. package/dist/react/LeadCapture.js +22 -8
  44. package/dist/react/LeadCaptureFields.js +60 -21
  45. package/dist/react/LeadCapturePriced.d.ts +3 -4
  46. package/dist/react/LeadCapturePriced.js +4 -4
  47. package/dist/react/analytics-context.d.ts +7 -0
  48. package/dist/react/analytics-context.js +7 -0
  49. package/dist/react/booking-adapters.d.ts +58 -0
  50. package/dist/react/booking-adapters.js +104 -0
  51. package/dist/react/index.d.ts +5 -1
  52. package/dist/react/index.js +4 -1
  53. package/dist/react/use-booking.d.ts +20 -0
  54. package/dist/react/use-booking.js +32 -0
  55. package/dist/react/use-lead-error.d.ts +3 -7
  56. package/dist/react/use-lead-error.js +9 -6
  57. package/dist/react/use-lead-submit.d.ts +7 -1
  58. package/dist/react/use-lead-submit.js +13 -3
  59. package/dist/react/use-repriced.d.ts +18 -0
  60. package/dist/react/use-repriced.js +27 -0
  61. package/dist/server/experiments-declared.d.ts +106 -0
  62. package/dist/server/experiments-declared.js +124 -0
  63. package/dist/server/experiments-source.d.ts +49 -0
  64. package/dist/server/experiments-source.js +88 -0
  65. package/dist/server/index.d.ts +4 -2
  66. package/dist/server/index.js +2 -0
  67. package/dist/server/lead-webhook.d.ts +63 -0
  68. package/dist/server/lead-webhook.js +24 -1
  69. package/dist/server/notify.d.ts +20 -2
  70. package/dist/server/notify.js +6 -4
  71. package/dist/server/place-source.js +1 -1
  72. package/dist/server/webhook-outbox.d.ts +11 -1
  73. package/dist/server/webhook-outbox.js +32 -5
  74. package/dist/testing/lead-rules.js +15 -1
  75. package/package.json +1 -1
  76. package/template/app/quote/booking/route.ts +11 -0
  77. package/template/app/quote/confirm/route.ts +12 -0
  78. package/template/flake.nix +1 -1
  79. package/template/package.json +2 -2
  80. package/template/src/shared/config/places.ts +4 -0
  81. package/template/src/shared/config/site.ts +3 -0
  82. package/template/tests/e2e/flows.spec.ts +86 -9
  83. package/template/tests/e2e/mock-panel.mjs +18 -4
  84. package/template/tests/e2e/quote-form.spec.ts +2 -2
@@ -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
+ }
@@ -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)