@evinvest/sites-contract 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +25 -2
- package/dist/booking.d.ts +42 -0
- package/dist/booking.js +137 -0
- package/dist/experiments.d.ts +18 -0
- package/dist/experiments.js +56 -0
- package/dist/generated/contract.d.ts +303 -0
- package/dist/generated/contract.js +59 -0
- package/dist/generated/wire.d.ts +502 -0
- package/dist/generated/wire.js +3 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +8 -0
- package/dist/ingest.d.ts +51 -0
- package/dist/ingest.js +45 -0
- package/dist/phone.d.ts +22 -0
- package/dist/phone.js +76 -0
- package/dist/place.d.ts +22 -0
- package/dist/place.js +280 -0
- package/dist/pricing.d.ts +70 -0
- package/dist/pricing.js +273 -0
- package/dist/text.d.ts +11 -0
- package/dist/text.js +19 -0
- package/package.json +41 -4
package/README.md
CHANGED
|
@@ -1,3 +1,26 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @evinvest/sites-contract
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
The Service-Arb sites ↔ panel contract, in TS: what a landing reads from the panel and sends
|
|
4
|
+
to it. Its source is the panel — `crates/panel_core` and `contracts/proto/sa/v1` in
|
|
5
|
+
[EV-Service-Arb/sites_panel](https://github.com/EV-Service-Arb/sites_panel) — and this is its
|
|
6
|
+
only TS form. No runtime dependencies, no Node imports: it is bundled into landing pages and
|
|
7
|
+
the panel's front end.
|
|
8
|
+
|
|
9
|
+
```text
|
|
10
|
+
src/generated/contract.ts panel_core's shapes, vocabularies and limits (`nix run .#gen`)
|
|
11
|
+
src/generated/wire.ts the protos as protojson: the envelope, every type's properties
|
|
12
|
+
src/*.ts the one TS implementation of what both sides run, held to
|
|
13
|
+
contracts/vectors with panel_core:
|
|
14
|
+
pricing checkPricingModel, priceOf
|
|
15
|
+
booking checkBookingConfig, checkBookingUrl, bookingRequested, isLeadRef
|
|
16
|
+
place checkPlaceLive: a place's live settings, field by field
|
|
17
|
+
phone normalizePhone, phoneProblem
|
|
18
|
+
ingest siteEvent, ingestBody, uuidV7, isOpaqueId: the only way a site builds an event
|
|
19
|
+
experiments parseLiveExperiments, declarationProblem
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Checks answer `{ path, code }` problems, never words: a site logs them, the panel's editor says
|
|
23
|
+
each code in its own copy.
|
|
24
|
+
|
|
25
|
+
Release: bump `version`, commit, push the tag `@evinvest/sites-contract-v<version>`; CI
|
|
26
|
+
(`release-npm.yml`) publishes it.
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { type BookingCode, type BookingConfig, type BookingProvider, type DayPart, type PageProvider } from "./generated/contract.js";
|
|
2
|
+
import type { BookingRequestedV1 } from "./generated/wire.js";
|
|
3
|
+
/** One thing wrong with a place's `booking`: where below it (`""`, `default`, `providers.cal_com.url`) and why. */
|
|
4
|
+
export interface BookingProblem {
|
|
5
|
+
path: string;
|
|
6
|
+
code: BookingCode;
|
|
7
|
+
}
|
|
8
|
+
export declare const isBookingProvider: (v: unknown) => v is BookingProvider;
|
|
9
|
+
export declare const isPageProvider: (v: unknown) => v is PageProvider;
|
|
10
|
+
/** `lead-<row>-<8 lowercase hex>`, the row 1 or more: the panel's lead id for a site's lead. */
|
|
11
|
+
export declare const isLeadRef: (v: unknown) => v is string;
|
|
12
|
+
/**
|
|
13
|
+
* Why `url` may not be `provider`'s booking page, or `null` (`contracts/vectors/booking/README.md`,
|
|
14
|
+
* normative): at most 2048 printable ASCII; the literal `https://`; no `#` and no `\`; an
|
|
15
|
+
* authority without `@`, `:` or `[`; a dotted DNS name whose last label is not a number; then
|
|
16
|
+
* the provider's own rule.
|
|
17
|
+
*/
|
|
18
|
+
export declare function checkBookingUrl(provider: PageProvider, url: string): BookingCode | null;
|
|
19
|
+
/**
|
|
20
|
+
* A place's `booking` checked whole: every problem, and the config when there is none. `manual`
|
|
21
|
+
* is always available, so never a key of `providers`; the default is `manual` or one of them.
|
|
22
|
+
*/
|
|
23
|
+
export declare function checkBookingConfig(v: unknown): {
|
|
24
|
+
problems: BookingProblem[];
|
|
25
|
+
config: BookingConfig | null;
|
|
26
|
+
};
|
|
27
|
+
/** `booking.requested@1`'s properties, their vocabularies as the panel checks them. */
|
|
28
|
+
export type BookingRequested = BookingRequestedV1 & {
|
|
29
|
+
provider: BookingProvider;
|
|
30
|
+
preferred_part?: DayPart;
|
|
31
|
+
};
|
|
32
|
+
/**
|
|
33
|
+
* `booking.requested@1`'s properties, or `null` when the panel would refuse them: a lead ref, a
|
|
34
|
+
* provider and, with `manual` only, a day and a part of the day. No null, nothing else, no free
|
|
35
|
+
* text.
|
|
36
|
+
*/
|
|
37
|
+
export declare function bookingRequested(v: unknown): BookingRequested | null;
|
|
38
|
+
/**
|
|
39
|
+
* Whether a visitor's preferred day may be asked for on `today` (both `YYYY-MM-DD`): two days
|
|
40
|
+
* back (clocks, an outbox's delay) to a year and a day ahead. The panel checks it on arrival.
|
|
41
|
+
*/
|
|
42
|
+
export declare function preferredDateInWindow(day: string, today: string): boolean;
|
package/dist/booking.js
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { BOOKING_LIMITS as LIMIT, BOOKING_PROVIDERS, DAY_PARTS, PAGE_PROVIDERS, } from "./generated/contract.js";
|
|
2
|
+
import { at, isDate, isObject } from "./text.js";
|
|
3
|
+
export const isBookingProvider = (v) => BOOKING_PROVIDERS.some(p => p === v);
|
|
4
|
+
export const isPageProvider = (v) => PAGE_PROVIDERS.some(p => p === v);
|
|
5
|
+
const LEAD_REF = new RegExp(LIMIT.leadRef);
|
|
6
|
+
const CAL_SEGMENT = new RegExp(LIMIT.calComSegment);
|
|
7
|
+
const LABEL = /^[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?$/;
|
|
8
|
+
const APPOINTMENTS = "/calendar/appointments/";
|
|
9
|
+
/** `lead-<row>-<8 lowercase hex>`, the row 1 or more: the panel's lead id for a site's lead. */
|
|
10
|
+
export const isLeadRef = (v) => typeof v === "string" && LEAD_REF.test(v);
|
|
11
|
+
/**
|
|
12
|
+
* Why `url` may not be `provider`'s booking page, or `null` (`contracts/vectors/booking/README.md`,
|
|
13
|
+
* normative): at most 2048 printable ASCII; the literal `https://`; no `#` and no `\`; an
|
|
14
|
+
* authority without `@`, `:` or `[`; a dotted DNS name whose last label is not a number; then
|
|
15
|
+
* the provider's own rule.
|
|
16
|
+
*/
|
|
17
|
+
export function checkBookingUrl(provider, url) {
|
|
18
|
+
if (url.length === 0 || url.length > LIMIT.maxUrl || !/^[\x21-\x7e]+$/.test(url))
|
|
19
|
+
return "format";
|
|
20
|
+
if (!url.startsWith("https://"))
|
|
21
|
+
return "scheme";
|
|
22
|
+
if (url.includes("#"))
|
|
23
|
+
return "fragment";
|
|
24
|
+
if (url.includes("\\"))
|
|
25
|
+
return "backslash";
|
|
26
|
+
const rest = url.slice("https://".length);
|
|
27
|
+
const split = rest.search(/[/?]/);
|
|
28
|
+
const host = split < 0 ? rest : rest.slice(0, split);
|
|
29
|
+
if (host.includes("@"))
|
|
30
|
+
return "userinfo";
|
|
31
|
+
if (host.startsWith("["))
|
|
32
|
+
return "ip";
|
|
33
|
+
if (host.includes(":"))
|
|
34
|
+
return "port";
|
|
35
|
+
const labels = host.split(".");
|
|
36
|
+
if (host.length > 253 || labels.length < 2 || !labels.every(l => LABEL.test(l)))
|
|
37
|
+
return "host";
|
|
38
|
+
const last = (labels.at(-1) ?? "").toLowerCase();
|
|
39
|
+
// A WHATWG parser reads a host whose last label is a number as an IPv4 address.
|
|
40
|
+
if (/^\d+$/.test(last) || last.startsWith("0x"))
|
|
41
|
+
return "ip";
|
|
42
|
+
const name = host.toLowerCase();
|
|
43
|
+
const path = (split < 0 ? "" : rest.slice(split)).split("?")[0] ?? "";
|
|
44
|
+
switch (provider) {
|
|
45
|
+
case "link":
|
|
46
|
+
return null;
|
|
47
|
+
case "google_calendar":
|
|
48
|
+
return (name === "calendar.app.google" && path.length > 1) || (name === "calendar.google.com" && path.length > APPOINTMENTS.length && path.startsWith(APPOINTMENTS)) ? null : "google";
|
|
49
|
+
case "cal_com": {
|
|
50
|
+
if (!LIMIT.calComHosts.some(h => h === name))
|
|
51
|
+
return "cal_host";
|
|
52
|
+
const segments = path.replace(/^\//, "").split("/");
|
|
53
|
+
return path.startsWith("/") && segments.length === 2 && segments.every(s => CAL_SEGMENT.test(s)) ? null : "cal_path";
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* A place's `booking` checked whole: every problem, and the config when there is none. `manual`
|
|
59
|
+
* is always available, so never a key of `providers`; the default is `manual` or one of them.
|
|
60
|
+
*/
|
|
61
|
+
export function checkBookingConfig(v) {
|
|
62
|
+
const problems = [];
|
|
63
|
+
const fail = (path, code) => problems.push({ path, code });
|
|
64
|
+
if (!isObject(v))
|
|
65
|
+
return { problems: [{ path: "", code: "shape" }], config: null };
|
|
66
|
+
if (Object.keys(v).some(k => k !== "default" && k !== "providers"))
|
|
67
|
+
fail("", "unknown_key");
|
|
68
|
+
const def = isBookingProvider(v.default) ? v.default : null;
|
|
69
|
+
if (!Object.hasOwn(v, "default"))
|
|
70
|
+
fail("default", "required");
|
|
71
|
+
else if (def === null)
|
|
72
|
+
fail("default", "default_unknown");
|
|
73
|
+
const providers = {};
|
|
74
|
+
if (!Object.hasOwn(v, "providers"))
|
|
75
|
+
fail("providers", "required");
|
|
76
|
+
else if (!isObject(v.providers))
|
|
77
|
+
fail("providers", "shape");
|
|
78
|
+
else
|
|
79
|
+
for (const [name, conf] of Object.entries(v.providers)) {
|
|
80
|
+
const path = at("providers", name);
|
|
81
|
+
if (!isBookingProvider(name))
|
|
82
|
+
fail(path, "provider_unknown");
|
|
83
|
+
else if (!isObject(conf))
|
|
84
|
+
fail(path, "provider_shape");
|
|
85
|
+
else if (Object.keys(conf).some(k => k !== "url"))
|
|
86
|
+
fail(path, "provider_extra");
|
|
87
|
+
else if (!isPageProvider(name))
|
|
88
|
+
fail(path, "manual");
|
|
89
|
+
else if (!Object.hasOwn(conf, "url"))
|
|
90
|
+
fail(at(path, "url"), "required");
|
|
91
|
+
else if (typeof conf.url !== "string")
|
|
92
|
+
fail(at(path, "url"), "url_type");
|
|
93
|
+
else {
|
|
94
|
+
const bad = checkBookingUrl(name, conf.url);
|
|
95
|
+
if (bad)
|
|
96
|
+
fail(at(path, "url"), bad);
|
|
97
|
+
else
|
|
98
|
+
providers[name] = { url: conf.url };
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
// A default whose own entry is refused is blamed there, not twice.
|
|
102
|
+
if (isPageProvider(def) && !Object.hasOwn(providers, def) && !problems.some(p => p.path.startsWith(at("providers", def))))
|
|
103
|
+
fail("default", "default_unset");
|
|
104
|
+
return problems.length > 0 || def === null ? { problems, config: null } : { problems, config: { default: def, providers } };
|
|
105
|
+
}
|
|
106
|
+
const isDayPart = (v) => DAY_PARTS.some(p => p === v);
|
|
107
|
+
/**
|
|
108
|
+
* `booking.requested@1`'s properties, or `null` when the panel would refuse them: a lead ref, a
|
|
109
|
+
* provider and, with `manual` only, a day and a part of the day. No null, nothing else, no free
|
|
110
|
+
* text.
|
|
111
|
+
*/
|
|
112
|
+
export function bookingRequested(v) {
|
|
113
|
+
if (!isObject(v) || Object.entries(v).some(([k, x]) => x === null || !["lead_ref", "provider", "preferred_date", "preferred_part"].includes(k)))
|
|
114
|
+
return null;
|
|
115
|
+
const { lead_ref, provider, preferred_date, preferred_part } = v;
|
|
116
|
+
if (!isLeadRef(lead_ref) || !isBookingProvider(provider))
|
|
117
|
+
return null;
|
|
118
|
+
if ((preferred_date !== undefined && !isDate(preferred_date)) || (preferred_part !== undefined && !isDayPart(preferred_part)))
|
|
119
|
+
return null;
|
|
120
|
+
if (provider !== "manual" && (preferred_date !== undefined || preferred_part !== undefined))
|
|
121
|
+
return null;
|
|
122
|
+
return {
|
|
123
|
+
lead_ref,
|
|
124
|
+
provider,
|
|
125
|
+
...(preferred_date !== undefined ? { preferred_date } : {}),
|
|
126
|
+
...(preferred_part !== undefined ? { preferred_part } : {}),
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
const DAY_MS = 86_400_000;
|
|
130
|
+
/**
|
|
131
|
+
* Whether a visitor's preferred day may be asked for on `today` (both `YYYY-MM-DD`): two days
|
|
132
|
+
* back (clocks, an outbox's delay) to a year and a day ahead. The panel checks it on arrival.
|
|
133
|
+
*/
|
|
134
|
+
export function preferredDateInWindow(day, today) {
|
|
135
|
+
const days = (Date.parse(`${day}T00:00:00Z`) - Date.parse(`${today}T00:00:00Z`)) / DAY_MS;
|
|
136
|
+
return days >= -2 && days <= 366;
|
|
137
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { type LiveExperiments } from "./generated/contract.js";
|
|
2
|
+
import type { ExperimentDeclaration } from "./generated/wire.js";
|
|
3
|
+
/**
|
|
4
|
+
* The panel's answer to `GET …/brands/{brand}/experiments`, or `null` when it is not one: an
|
|
5
|
+
* override whose field has another type is not the panel's. Fields the panel added since are
|
|
6
|
+
* passed over. Whether an override fits the experiment in code (weights of the right length, a
|
|
7
|
+
* holdout in `[0, 1)`) is `applyOverrides`' call: only it knows the code's variants.
|
|
8
|
+
*/
|
|
9
|
+
export declare function parseLiveExperiments(body: unknown): LiveExperiments | null;
|
|
10
|
+
/** Why the panel would refuse an experiment as declared. */
|
|
11
|
+
export type DeclarationProblem = "key" | "variants" | "variant" | "duplicate_variant" | "weights" | "holdout" | "summary";
|
|
12
|
+
/**
|
|
13
|
+
* Why the panel would refuse this experiment of `experiments.declared@1`, or `null`
|
|
14
|
+
* (`contracts/vectors/ingest/declarations.json`): a key; 2 to 32 variants, unique, each a slug;
|
|
15
|
+
* one weight per variant, each ≥ 0, their sum > 0; a holdout in `[0, 1)`; a summary of at most
|
|
16
|
+
* 200 characters once trimmed.
|
|
17
|
+
*/
|
|
18
|
+
export declare function declarationProblem(d: ExperimentDeclaration): DeclarationProblem | null;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { EXPERIMENT_LIMITS } from "./generated/contract.js";
|
|
2
|
+
import { charCount, isObject, trim } from "./text.js";
|
|
3
|
+
/**
|
|
4
|
+
* The panel's answer to `GET …/brands/{brand}/experiments`, or `null` when it is not one: an
|
|
5
|
+
* override whose field has another type is not the panel's. Fields the panel added since are
|
|
6
|
+
* passed over. Whether an override fits the experiment in code (weights of the right length, a
|
|
7
|
+
* holdout in `[0, 1)`) is `applyOverrides`' call: only it knows the code's variants.
|
|
8
|
+
*/
|
|
9
|
+
export function parseLiveExperiments(body) {
|
|
10
|
+
if (!isObject(body) || !isObject(body.experiments))
|
|
11
|
+
return null;
|
|
12
|
+
const experiments = {};
|
|
13
|
+
for (const [key, entry] of Object.entries(body.experiments)) {
|
|
14
|
+
if (!isObject(entry))
|
|
15
|
+
return null;
|
|
16
|
+
const { enabled, weights, holdout } = entry;
|
|
17
|
+
if (enabled !== undefined && typeof enabled !== "boolean")
|
|
18
|
+
return null;
|
|
19
|
+
if (weights !== undefined && !(Array.isArray(weights) && weights.every(w => typeof w === "number")))
|
|
20
|
+
return null;
|
|
21
|
+
if (holdout !== undefined && typeof holdout !== "number")
|
|
22
|
+
return null;
|
|
23
|
+
experiments[key] = {
|
|
24
|
+
...(enabled !== undefined ? { enabled } : {}),
|
|
25
|
+
...(weights !== undefined ? { weights } : {}),
|
|
26
|
+
...(holdout !== undefined ? { holdout } : {}),
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
return { experiments };
|
|
30
|
+
}
|
|
31
|
+
const KEY = new RegExp(EXPERIMENT_LIMITS.key);
|
|
32
|
+
const VARIANT = new RegExp(EXPERIMENT_LIMITS.variant);
|
|
33
|
+
/**
|
|
34
|
+
* Why the panel would refuse this experiment of `experiments.declared@1`, or `null`
|
|
35
|
+
* (`contracts/vectors/ingest/declarations.json`): a key; 2 to 32 variants, unique, each a slug;
|
|
36
|
+
* one weight per variant, each ≥ 0, their sum > 0; a holdout in `[0, 1)`; a summary of at most
|
|
37
|
+
* 200 characters once trimmed.
|
|
38
|
+
*/
|
|
39
|
+
export function declarationProblem(d) {
|
|
40
|
+
if (!KEY.test(d.key))
|
|
41
|
+
return "key";
|
|
42
|
+
if (d.variants.length < 2 || d.variants.length > EXPERIMENT_LIMITS.maxVariants)
|
|
43
|
+
return "variants";
|
|
44
|
+
if (!d.variants.every(v => VARIANT.test(v)))
|
|
45
|
+
return "variant";
|
|
46
|
+
if (new Set(d.variants).size !== d.variants.length)
|
|
47
|
+
return "duplicate_variant";
|
|
48
|
+
const weightsOk = d.weights.length === d.variants.length && d.weights.every(w => Number.isFinite(w) && w >= 0) && d.weights.reduce((a, b) => a + b, 0) > 0;
|
|
49
|
+
if (!weightsOk)
|
|
50
|
+
return "weights";
|
|
51
|
+
if (d.holdout !== undefined && !(Number.isFinite(d.holdout) && d.holdout >= 0 && d.holdout < 1))
|
|
52
|
+
return "holdout";
|
|
53
|
+
if (d.summary !== undefined && charCount(trim(d.summary)) > EXPERIMENT_LIMITS.maxSummary)
|
|
54
|
+
return "summary";
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Adds `addCents` to the base.
|
|
3
|
+
*/
|
|
4
|
+
export type AddOption = {
|
|
5
|
+
id: string;
|
|
6
|
+
labels: {
|
|
7
|
+
[key in string]: string;
|
|
8
|
+
};
|
|
9
|
+
addCents: number;
|
|
10
|
+
};
|
|
11
|
+
/**
|
|
12
|
+
* A storefront's address; its region and country are the site's.
|
|
13
|
+
*/
|
|
14
|
+
export type Address = {
|
|
15
|
+
street: string;
|
|
16
|
+
/**
|
|
17
|
+
* A French postal code: five digits.
|
|
18
|
+
*/
|
|
19
|
+
postalCode: string;
|
|
20
|
+
locality: string;
|
|
21
|
+
};
|
|
22
|
+
/**
|
|
23
|
+
* A place's `booking` setting: the providers it has a page on, and the one it offers by
|
|
24
|
+
* default — `manual` or one of them. `manual` is always there, so never configured.
|
|
25
|
+
*/
|
|
26
|
+
export type BookingConfig = {
|
|
27
|
+
default: BookingProvider;
|
|
28
|
+
providers: {
|
|
29
|
+
[key in PageProvider]?: BookingPage;
|
|
30
|
+
};
|
|
31
|
+
};
|
|
32
|
+
/**
|
|
33
|
+
* A provider's booking page.
|
|
34
|
+
*/
|
|
35
|
+
export type BookingPage = {
|
|
36
|
+
url: string;
|
|
37
|
+
};
|
|
38
|
+
/**
|
|
39
|
+
* Where a slot is booked.
|
|
40
|
+
*/
|
|
41
|
+
export type BookingProvider = "manual" | "link" | "google_calendar" | "cal_com";
|
|
42
|
+
/**
|
|
43
|
+
* A day of the week, as the hours name it.
|
|
44
|
+
*/
|
|
45
|
+
export type DayOfWeek = "Monday" | "Tuesday" | "Wednesday" | "Thursday" | "Friday" | "Saturday" | "Sunday";
|
|
46
|
+
/**
|
|
47
|
+
* `discountBp` taken off the subtotal, in basis points: 1 000 is 10 % off.
|
|
48
|
+
*/
|
|
49
|
+
export type DiscountOption = {
|
|
50
|
+
id: string;
|
|
51
|
+
labels: {
|
|
52
|
+
[key in string]: string;
|
|
53
|
+
};
|
|
54
|
+
discountBp: number;
|
|
55
|
+
};
|
|
56
|
+
/**
|
|
57
|
+
* Degrees, on the globe.
|
|
58
|
+
*/
|
|
59
|
+
export type Geo = {
|
|
60
|
+
lat: number;
|
|
61
|
+
lng: number;
|
|
62
|
+
};
|
|
63
|
+
/**
|
|
64
|
+
* What a landing reads of its brand's experiments: the overrides of the current ones, by key.
|
|
65
|
+
*/
|
|
66
|
+
export type LiveExperiments = {
|
|
67
|
+
experiments: {
|
|
68
|
+
[key in string]: LiveOverride;
|
|
69
|
+
};
|
|
70
|
+
};
|
|
71
|
+
/**
|
|
72
|
+
* The fields of an override a landing takes, as it is sent them: only those set, each valid
|
|
73
|
+
* against the experiment's current declaration.
|
|
74
|
+
*/
|
|
75
|
+
export type LiveOverride = {
|
|
76
|
+
enabled?: boolean;
|
|
77
|
+
weights?: Array<number>;
|
|
78
|
+
holdout?: number;
|
|
79
|
+
};
|
|
80
|
+
/**
|
|
81
|
+
* A messenger the customer writes the brand on.
|
|
82
|
+
*/
|
|
83
|
+
export type Messenger = "whatsapp" | "telegram";
|
|
84
|
+
/**
|
|
85
|
+
* The subtotal times `multiplyBp`, in basis points: 11 000 is ×1.1.
|
|
86
|
+
*/
|
|
87
|
+
export type MultiplyOption = {
|
|
88
|
+
id: string;
|
|
89
|
+
labels: {
|
|
90
|
+
[key in string]: string;
|
|
91
|
+
};
|
|
92
|
+
multiplyBp: number;
|
|
93
|
+
};
|
|
94
|
+
/**
|
|
95
|
+
* How one need is priced.
|
|
96
|
+
*/
|
|
97
|
+
export type NeedPricing = {
|
|
98
|
+
"kind": "estimate";
|
|
99
|
+
baseCents: number;
|
|
100
|
+
inputs: Array<string>;
|
|
101
|
+
} | {
|
|
102
|
+
"kind": "fixed";
|
|
103
|
+
cents: number;
|
|
104
|
+
};
|
|
105
|
+
/**
|
|
106
|
+
* A row of opening hours, in the place's local time (Europe/Paris for every place in v1).
|
|
107
|
+
*/
|
|
108
|
+
export type OpeningHours = {
|
|
109
|
+
/**
|
|
110
|
+
* One at least, each once, in week order.
|
|
111
|
+
*/
|
|
112
|
+
days: Array<DayOfWeek>;
|
|
113
|
+
/**
|
|
114
|
+
* `HH:MM`.
|
|
115
|
+
*/
|
|
116
|
+
opens: string;
|
|
117
|
+
/**
|
|
118
|
+
* After `opens`: a night across midnight is two rows.
|
|
119
|
+
*/
|
|
120
|
+
closes: string;
|
|
121
|
+
};
|
|
122
|
+
/**
|
|
123
|
+
* A provider with a page of its own, which a place configures: every one but `manual`,
|
|
124
|
+
* which is always available.
|
|
125
|
+
*/
|
|
126
|
+
export type PageProvider = "link" | "google_calendar" | "cal_com";
|
|
127
|
+
/**
|
|
128
|
+
* A place's live settings: only the fields that are set. Empty: the site serves its baked
|
|
129
|
+
* config.
|
|
130
|
+
*/
|
|
131
|
+
export type PlaceLive = {
|
|
132
|
+
phone?: string;
|
|
133
|
+
whatsapp?: string;
|
|
134
|
+
/**
|
|
135
|
+
* The place's Telegram bot: its username, without the `@`.
|
|
136
|
+
*/
|
|
137
|
+
telegram?: string;
|
|
138
|
+
/**
|
|
139
|
+
* The landing's messenger buttons switched off where `false`; a messenger left out is on.
|
|
140
|
+
*/
|
|
141
|
+
messengers?: {
|
|
142
|
+
[key in Messenger]?: boolean;
|
|
143
|
+
};
|
|
144
|
+
hours?: Array<OpeningHours>;
|
|
145
|
+
/**
|
|
146
|
+
* Commune names.
|
|
147
|
+
*/
|
|
148
|
+
serviceArea?: Array<string>;
|
|
149
|
+
address?: Address;
|
|
150
|
+
geo?: Geo;
|
|
151
|
+
storefrontPhoto?: string;
|
|
152
|
+
/**
|
|
153
|
+
* A text per locale; a site keeps it only when every locale it speaks is there.
|
|
154
|
+
*/
|
|
155
|
+
landmark?: {
|
|
156
|
+
[key in string]: string;
|
|
157
|
+
};
|
|
158
|
+
rating?: Rating;
|
|
159
|
+
booking?: BookingConfig;
|
|
160
|
+
/**
|
|
161
|
+
* Where the place asks for a Google review.
|
|
162
|
+
*/
|
|
163
|
+
reviewUrl?: string;
|
|
164
|
+
};
|
|
165
|
+
/**
|
|
166
|
+
* An enum the visitor answers: bedrooms, a surface band, a zone, a frequency. Its kind says
|
|
167
|
+
* what its options do, and when: every `add` first, then every `multiply`, then every
|
|
168
|
+
* `discount`.
|
|
169
|
+
*/
|
|
170
|
+
export type PricingInput = {
|
|
171
|
+
"kind": "add";
|
|
172
|
+
id: string;
|
|
173
|
+
labels: {
|
|
174
|
+
[key in string]: string;
|
|
175
|
+
};
|
|
176
|
+
options: Array<AddOption>;
|
|
177
|
+
} | {
|
|
178
|
+
"kind": "multiply";
|
|
179
|
+
id: string;
|
|
180
|
+
labels: {
|
|
181
|
+
[key in string]: string;
|
|
182
|
+
};
|
|
183
|
+
options: Array<MultiplyOption>;
|
|
184
|
+
} | {
|
|
185
|
+
"kind": "discount";
|
|
186
|
+
id: string;
|
|
187
|
+
labels: {
|
|
188
|
+
[key in string]: string;
|
|
189
|
+
};
|
|
190
|
+
options: Array<DiscountOption>;
|
|
191
|
+
};
|
|
192
|
+
/**
|
|
193
|
+
* A checked model, in its wire shape: every value within the limits, which keeps a price
|
|
194
|
+
* within an `i64` and a double alike. Only [`PricingModel::parse`] makes one.
|
|
195
|
+
*/
|
|
196
|
+
export type PricingModel = {
|
|
197
|
+
format: 1;
|
|
198
|
+
currency: "EUR";
|
|
199
|
+
/**
|
|
200
|
+
* `YYYY-MM-DD`, a day the calendar has: when the prices hold from. Stored with every lead
|
|
201
|
+
* priced by it.
|
|
202
|
+
*/
|
|
203
|
+
validFrom: string;
|
|
204
|
+
/**
|
|
205
|
+
* The estimate's total is rounded half up to a multiple of this (1 = to the cent).
|
|
206
|
+
*/
|
|
207
|
+
roundToCents: number;
|
|
208
|
+
/**
|
|
209
|
+
* An estimate never comes out below this, after the rounding.
|
|
210
|
+
*/
|
|
211
|
+
minimumCents: number;
|
|
212
|
+
inputs: Array<PricingInput>;
|
|
213
|
+
/**
|
|
214
|
+
* By the brand's need slug; a need not here is a quote.
|
|
215
|
+
*/
|
|
216
|
+
needs: {
|
|
217
|
+
[key in string]: NeedPricing;
|
|
218
|
+
};
|
|
219
|
+
};
|
|
220
|
+
/**
|
|
221
|
+
* Google's rating for the place, with when it was read: the API's terms cap how long a copy
|
|
222
|
+
* may be shown.
|
|
223
|
+
*/
|
|
224
|
+
export type Rating = {
|
|
225
|
+
/**
|
|
226
|
+
* 1 to 5.
|
|
227
|
+
*/
|
|
228
|
+
value: number;
|
|
229
|
+
count: number;
|
|
230
|
+
/**
|
|
231
|
+
* An RFC 3339 instant, as sent.
|
|
232
|
+
*/
|
|
233
|
+
fetchedAt: string;
|
|
234
|
+
};
|
|
235
|
+
export declare const PRICING_FORMAT = 1;
|
|
236
|
+
export declare const PRICING_CURRENCY = "EUR";
|
|
237
|
+
export declare const PRICING_LIMITS: {
|
|
238
|
+
readonly locale: "^[a-z]{2}(-[A-Z]{2})?$";
|
|
239
|
+
readonly maxDiscountBp: 10000;
|
|
240
|
+
readonly maxInputs: 32;
|
|
241
|
+
readonly maxLabel: 120;
|
|
242
|
+
readonly maxMultiplyBp: 100000;
|
|
243
|
+
readonly maxNeedInputs: 12;
|
|
244
|
+
readonly maxNeeds: 64;
|
|
245
|
+
readonly maxOptions: 32;
|
|
246
|
+
readonly maxPriceCents: 100000000;
|
|
247
|
+
readonly slug: "^[a-z0-9_-]{1,40}$";
|
|
248
|
+
};
|
|
249
|
+
export declare const INPUT_KINDS: readonly ["add", "multiply", "discount"];
|
|
250
|
+
export type PricingInputKind = (typeof INPUT_KINDS)[number];
|
|
251
|
+
export declare const PRICING_CODES: readonly ["object", "missing", "unknownField", "format", "currency", "date", "int", "slug", "labels", "locale", "label", "list", "duplicate", "kind", "needKind", "noInput", "dearest", "labelMissing"];
|
|
252
|
+
export type PricingCode = (typeof PRICING_CODES)[number];
|
|
253
|
+
export declare const BOOKING_PROVIDERS: readonly ["manual", "link", "google_calendar", "cal_com"];
|
|
254
|
+
export declare const PAGE_PROVIDERS: readonly ["link", "google_calendar", "cal_com"];
|
|
255
|
+
export declare const BOOKING_LIMITS: {
|
|
256
|
+
readonly calComHosts: readonly ["cal.evinvest.ltd", "cal.com"];
|
|
257
|
+
readonly calComSegment: "^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$";
|
|
258
|
+
readonly leadRef: "^lead-[1-9][0-9]{0,18}-[0-9a-f]{8}$";
|
|
259
|
+
readonly maxUrl: 2048;
|
|
260
|
+
};
|
|
261
|
+
export declare const BOOKING_CODES: readonly ["shape", "unknown_key", "required", "provider_unknown", "provider_shape", "provider_extra", "manual", "url_type", "default_unknown", "default_unset", "format", "scheme", "fragment", "backslash", "userinfo", "ip", "port", "host", "google", "cal_host", "cal_path"];
|
|
262
|
+
export type BookingCode = (typeof BOOKING_CODES)[number];
|
|
263
|
+
export declare const BOOKING_STATUSES: readonly ["none", "requested", "booked", "canceled", "done", "no_show"];
|
|
264
|
+
export type BookingStatus = (typeof BOOKING_STATUSES)[number];
|
|
265
|
+
export declare const DAY_PARTS: readonly ["morning", "afternoon", "evening"];
|
|
266
|
+
export type DayPart = (typeof DAY_PARTS)[number];
|
|
267
|
+
export declare const DAYS: readonly ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"];
|
|
268
|
+
export declare const MESSENGERS: readonly ["whatsapp", "telegram"];
|
|
269
|
+
export declare const PLACE_FIELDS: readonly ["phone", "whatsapp", "telegram", "messengers", "hours", "serviceArea", "address", "geo", "storefrontPhoto", "landmark", "rating", "booking", "reviewUrl"];
|
|
270
|
+
export type PlaceField = (typeof PLACE_FIELDS)[number];
|
|
271
|
+
export declare const PLACE_LIMITS: {
|
|
272
|
+
readonly e164: "^\\+[1-9][0-9]{6,14}$";
|
|
273
|
+
readonly maxArea: 200;
|
|
274
|
+
readonly maxHours: 14;
|
|
275
|
+
readonly maxLocales: 8;
|
|
276
|
+
readonly maxText: 200;
|
|
277
|
+
readonly maxUrl: 2048;
|
|
278
|
+
readonly reviewHosts: readonly ["g.page", "search.google.com"];
|
|
279
|
+
readonly telegramBot: "^[A-Za-z][A-Za-z0-9_]{1,28}[Bb][Oo][Tt]$";
|
|
280
|
+
readonly time: "^([01][0-9]|2[0-3]):[0-5][0-9]$";
|
|
281
|
+
};
|
|
282
|
+
export declare const PLACE_CODES: readonly ["object", "unknown_field", "required", "type", "blank", "too_long", "control", "e164", "telegram_bot", "messenger_unknown", "boolean", "shape", "empty", "too_many", "day", "duplicate", "time", "closes_before_opens", "postal_code", "coordinate", "https_url", "review_url", "locale", "rating_value", "rating_count", "instant"];
|
|
283
|
+
export type PlaceCode = (typeof PLACE_CODES)[number];
|
|
284
|
+
export declare const EXPERIMENT_LIMITS: {
|
|
285
|
+
readonly key: "^[a-z0-9_]{1,64}$";
|
|
286
|
+
readonly maxExperiments: 64;
|
|
287
|
+
readonly maxSummary: 200;
|
|
288
|
+
readonly maxVariants: 32;
|
|
289
|
+
readonly variant: "^[a-z0-9_-]{1,32}$";
|
|
290
|
+
};
|
|
291
|
+
export declare const LEAD_LIMITS: {
|
|
292
|
+
readonly analyticsId: "^[A-Za-z0-9._:-]{1,128}$";
|
|
293
|
+
readonly maxEstimateInputs: 12;
|
|
294
|
+
readonly messageRef: "^[A-Z]{2,4}-[0-9A-HJKMNP-TV-Z]{4,8}$";
|
|
295
|
+
};
|
|
296
|
+
export declare const LEAD_CHANNELS: readonly ["form", "phone_inbound", "callback", "whatsapp", "telegram"];
|
|
297
|
+
export type LeadChannel = (typeof LEAD_CHANNELS)[number];
|
|
298
|
+
export declare const SITE_CHANNELS: readonly ["form", "callback", "whatsapp", "telegram"];
|
|
299
|
+
export type SiteChannel = (typeof SITE_CHANNELS)[number];
|
|
300
|
+
export declare const LEAD_SUSPECTS: readonly ["rate_limited", "too_fast"];
|
|
301
|
+
export type LeadSuspect = (typeof LEAD_SUSPECTS)[number];
|
|
302
|
+
export declare const LEAD_FLOWS: readonly ["quote", "estimate", "fixed"];
|
|
303
|
+
export type LeadFlow = (typeof LEAD_FLOWS)[number];
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// AUTO-GENERATED by `nix run .#gen` — do not edit.
|
|
2
|
+
export const PRICING_FORMAT = 1;
|
|
3
|
+
export const PRICING_CURRENCY = "EUR";
|
|
4
|
+
export const PRICING_LIMITS = {
|
|
5
|
+
"locale": "^[a-z]{2}(-[A-Z]{2})?$",
|
|
6
|
+
"maxDiscountBp": 10000,
|
|
7
|
+
"maxInputs": 32,
|
|
8
|
+
"maxLabel": 120,
|
|
9
|
+
"maxMultiplyBp": 100000,
|
|
10
|
+
"maxNeedInputs": 12,
|
|
11
|
+
"maxNeeds": 64,
|
|
12
|
+
"maxOptions": 32,
|
|
13
|
+
"maxPriceCents": 100000000,
|
|
14
|
+
"slug": "^[a-z0-9_-]{1,40}$",
|
|
15
|
+
};
|
|
16
|
+
export const INPUT_KINDS = ["add", "multiply", "discount"];
|
|
17
|
+
export const PRICING_CODES = ["object", "missing", "unknownField", "format", "currency", "date", "int", "slug", "labels", "locale", "label", "list", "duplicate", "kind", "needKind", "noInput", "dearest", "labelMissing"];
|
|
18
|
+
export const BOOKING_PROVIDERS = ["manual", "link", "google_calendar", "cal_com"];
|
|
19
|
+
export const PAGE_PROVIDERS = ["link", "google_calendar", "cal_com"];
|
|
20
|
+
export const BOOKING_LIMITS = {
|
|
21
|
+
"calComHosts": ["cal.evinvest.ltd", "cal.com"],
|
|
22
|
+
"calComSegment": "^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$",
|
|
23
|
+
"leadRef": "^lead-[1-9][0-9]{0,18}-[0-9a-f]{8}$",
|
|
24
|
+
"maxUrl": 2048,
|
|
25
|
+
};
|
|
26
|
+
export const BOOKING_CODES = ["shape", "unknown_key", "required", "provider_unknown", "provider_shape", "provider_extra", "manual", "url_type", "default_unknown", "default_unset", "format", "scheme", "fragment", "backslash", "userinfo", "ip", "port", "host", "google", "cal_host", "cal_path"];
|
|
27
|
+
export const BOOKING_STATUSES = ["none", "requested", "booked", "canceled", "done", "no_show"];
|
|
28
|
+
export const DAY_PARTS = ["morning", "afternoon", "evening"];
|
|
29
|
+
export const DAYS = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"];
|
|
30
|
+
export const MESSENGERS = ["whatsapp", "telegram"];
|
|
31
|
+
export const PLACE_FIELDS = ["phone", "whatsapp", "telegram", "messengers", "hours", "serviceArea", "address", "geo", "storefrontPhoto", "landmark", "rating", "booking", "reviewUrl"];
|
|
32
|
+
export const PLACE_LIMITS = {
|
|
33
|
+
"e164": "^\\+[1-9][0-9]{6,14}$",
|
|
34
|
+
"maxArea": 200,
|
|
35
|
+
"maxHours": 14,
|
|
36
|
+
"maxLocales": 8,
|
|
37
|
+
"maxText": 200,
|
|
38
|
+
"maxUrl": 2048,
|
|
39
|
+
"reviewHosts": ["g.page", "search.google.com"],
|
|
40
|
+
"telegramBot": "^[A-Za-z][A-Za-z0-9_]{1,28}[Bb][Oo][Tt]$",
|
|
41
|
+
"time": "^([01][0-9]|2[0-3]):[0-5][0-9]$",
|
|
42
|
+
};
|
|
43
|
+
export const PLACE_CODES = ["object", "unknown_field", "required", "type", "blank", "too_long", "control", "e164", "telegram_bot", "messenger_unknown", "boolean", "shape", "empty", "too_many", "day", "duplicate", "time", "closes_before_opens", "postal_code", "coordinate", "https_url", "review_url", "locale", "rating_value", "rating_count", "instant"];
|
|
44
|
+
export const EXPERIMENT_LIMITS = {
|
|
45
|
+
"key": "^[a-z0-9_]{1,64}$",
|
|
46
|
+
"maxExperiments": 64,
|
|
47
|
+
"maxSummary": 200,
|
|
48
|
+
"maxVariants": 32,
|
|
49
|
+
"variant": "^[a-z0-9_-]{1,32}$",
|
|
50
|
+
};
|
|
51
|
+
export const LEAD_LIMITS = {
|
|
52
|
+
"analyticsId": "^[A-Za-z0-9._:-]{1,128}$",
|
|
53
|
+
"maxEstimateInputs": 12,
|
|
54
|
+
"messageRef": "^[A-Z]{2,4}-[0-9A-HJKMNP-TV-Z]{4,8}$",
|
|
55
|
+
};
|
|
56
|
+
export const LEAD_CHANNELS = ["form", "phone_inbound", "callback", "whatsapp", "telegram"];
|
|
57
|
+
export const SITE_CHANNELS = ["form", "callback", "whatsapp", "telegram"];
|
|
58
|
+
export const LEAD_SUSPECTS = ["rate_limited", "too_fast"];
|
|
59
|
+
export const LEAD_FLOWS = ["quote", "estimate", "fixed"];
|