@evinvest/kitstart 0.9.0 → 0.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +237 -12
- package/dist/core/accept.d.ts +35 -2
- package/dist/core/accept.js +74 -5
- package/dist/core/analytics.d.ts +4 -1
- package/dist/core/analytics.js +13 -0
- package/dist/core/booking/index.d.ts +3 -0
- package/dist/core/booking/index.js +3 -0
- package/dist/core/booking/model.d.ts +106 -0
- package/dist/core/booking/model.js +66 -0
- package/dist/core/booking/url.d.ts +17 -0
- package/dist/core/booking/url.js +53 -0
- package/dist/core/booking/validate.d.ts +27 -0
- package/dist/core/booking/validate.js +219 -0
- package/dist/core/lead-capture-format.d.ts +6 -0
- package/dist/core/lead-capture-format.js +13 -0
- package/dist/core/lead-capture-text.d.ts +57 -2
- package/dist/core/lead-capture-text.js +75 -2
- package/dist/core/lead.d.ts +65 -3
- package/dist/core/lead.js +46 -2
- package/dist/core/phone.d.ts +3 -1
- package/dist/core/phone.js +28 -2
- package/dist/core/place/live.d.ts +6 -2
- package/dist/core/place/live.js +21 -2
- package/dist/core/place/types.d.ts +6 -0
- package/dist/core/place/types.js +0 -13
- package/dist/core/pricing/flow.d.ts +27 -0
- package/dist/core/pricing/flow.js +37 -0
- package/dist/core/pricing/index.d.ts +4 -0
- package/dist/core/pricing/index.js +4 -0
- package/dist/core/pricing/model.d.ts +129 -0
- package/dist/core/pricing/model.js +34 -0
- package/dist/core/pricing/price.d.ts +34 -0
- package/dist/core/pricing/price.js +105 -0
- package/dist/core/pricing/validate.d.ts +27 -0
- package/dist/core/pricing/validate.js +244 -0
- package/dist/core/routing.d.ts +3 -2
- package/dist/core/routing.js +4 -3
- package/dist/core/site.d.ts +15 -0
- package/dist/core/site.js +26 -0
- package/dist/index.d.ts +6 -4
- package/dist/index.js +6 -4
- package/dist/next/booking-route.d.ts +31 -0
- package/dist/next/booking-route.js +97 -0
- package/dist/next/confirm-route.d.ts +39 -0
- package/dist/next/confirm-route.js +118 -0
- package/dist/next/index.d.ts +2 -0
- package/dist/next/index.js +2 -0
- package/dist/next/quote-route.d.ts +8 -1
- package/dist/next/quote-route.js +17 -6
- package/dist/react/LeadBooking.d.ts +35 -0
- package/dist/react/LeadBooking.js +47 -0
- package/dist/react/LeadBookingManual.d.ts +17 -0
- package/dist/react/LeadBookingManual.js +50 -0
- package/dist/react/LeadCapture.d.ts +54 -3
- package/dist/react/LeadCapture.js +37 -10
- package/dist/react/LeadCaptureChannels.d.ts +14 -2
- package/dist/react/LeadCaptureChannels.js +13 -0
- package/dist/react/LeadCaptureEstimate.d.ts +46 -0
- package/dist/react/LeadCaptureEstimate.js +69 -0
- package/dist/react/LeadCaptureFields.js +60 -21
- package/dist/react/LeadCapturePriced.d.ts +18 -0
- package/dist/react/LeadCapturePriced.js +14 -0
- package/dist/react/booking-adapters.d.ts +58 -0
- package/dist/react/booking-adapters.js +104 -0
- package/dist/react/index.d.ts +5 -1
- package/dist/react/index.js +4 -1
- package/dist/react/use-booking.d.ts +20 -0
- package/dist/react/use-booking.js +32 -0
- package/dist/react/use-lead-error.d.ts +3 -7
- package/dist/react/use-lead-error.js +9 -6
- package/dist/react/use-lead-events.d.ts +2 -0
- package/dist/react/use-lead-events.js +10 -0
- package/dist/react/use-lead-submit.d.ts +14 -4
- package/dist/react/use-lead-submit.js +19 -9
- package/dist/react/use-repriced.d.ts +18 -0
- package/dist/react/use-repriced.js +27 -0
- package/dist/server/index.d.ts +3 -2
- package/dist/server/index.js +2 -1
- package/dist/server/lead-store-sqlite.js +31 -5
- package/dist/server/lead-webhook.d.ts +94 -0
- package/dist/server/lead-webhook.js +46 -2
- package/dist/server/notify.d.ts +20 -2
- package/dist/server/notify.js +6 -4
- package/dist/server/place-source.js +1 -1
- package/dist/server/pricing-source.d.ts +25 -0
- package/dist/server/pricing-source.js +48 -0
- package/dist/server/webhook-outbox.d.ts +11 -1
- package/dist/server/webhook-outbox.js +32 -5
- package/dist/testing/lead-rules.js +15 -1
- package/dist/testing/lead-store-contract.js +9 -2
- package/package.json +1 -1
- package/template/app/[locale]/[location]/page.tsx +4 -2
- package/template/app/quote/booking/route.ts +11 -0
- package/template/app/quote/confirm/route.ts +12 -0
- package/template/app/quote/route.ts +2 -1
- package/template/flake.nix +1 -1
- package/template/package.json +2 -2
- package/template/src/entities/content/model/en.ts +1 -1
- package/template/src/entities/content/model/fr.ts +1 -1
- package/template/src/shared/config/env.ts +4 -1
- package/template/src/shared/config/lead.ts +12 -2
- package/template/src/shared/config/places.ts +4 -0
- package/template/src/shared/config/pricing.ts +49 -0
- package/template/src/shared/config/site.ts +6 -0
- package/template/src/views/home/ui/PlaceHome.tsx +6 -3
- package/template/tests/bundle_budget.txt +3 -1
- package/template/tests/e2e/flows.spec.ts +183 -0
- package/template/tests/e2e/mock-panel.mjs +34 -0
- package/template/tests/e2e/playwright.config.ts +25 -9
- package/template/tests/e2e/quote-form.spec.ts +2 -2
|
@@ -1,4 +1,65 @@
|
|
|
1
|
+
const FR_FLOW = {
|
|
2
|
+
priceTitle: "Votre prix",
|
|
3
|
+
pricePending: "Répondez aux questions pour voir le prix.",
|
|
4
|
+
priceNote: "Prix TTC pour ces réponses, payé après la prestation.",
|
|
5
|
+
priceBase: "Prix de base",
|
|
6
|
+
priceRounding: "Arrondi",
|
|
7
|
+
priceMinimum: "Minimum de prestation",
|
|
8
|
+
bookSubmit: "Réserver",
|
|
9
|
+
sentPrice: "Demande enregistrée au prix de {price}.",
|
|
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.",
|
|
26
|
+
photosTitle: "Envoyez des photos",
|
|
27
|
+
photosLede: "Pour ce besoin, quelques photos nous permettent de vous donner un prix juste.",
|
|
28
|
+
photosCta: "Envoyer des photos sur WhatsApp",
|
|
29
|
+
photosMessage: "Bonjour, voici des photos pour : {need}.",
|
|
30
|
+
};
|
|
31
|
+
const EN_FLOW = {
|
|
32
|
+
priceTitle: "Your price",
|
|
33
|
+
pricePending: "Answer the questions to see the price.",
|
|
34
|
+
priceNote: "Price incl. VAT for these answers, paid after the job.",
|
|
35
|
+
priceBase: "Base price",
|
|
36
|
+
priceRounding: "Rounding",
|
|
37
|
+
priceMinimum: "Minimum charge",
|
|
38
|
+
bookSubmit: "Book",
|
|
39
|
+
sentPrice: "Request saved at {price}.",
|
|
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.",
|
|
56
|
+
photosTitle: "Send photos",
|
|
57
|
+
photosLede: "For this job, a few photos let us give you a fair price.",
|
|
58
|
+
photosCta: "Send photos on WhatsApp",
|
|
59
|
+
photosMessage: "Hello, here are photos for: {need}.",
|
|
60
|
+
};
|
|
1
61
|
const FR = {
|
|
62
|
+
...FR_FLOW,
|
|
2
63
|
title: "Recevoir un prix",
|
|
3
64
|
lede: "Réponse rapide, sans engagement.",
|
|
4
65
|
needLabel: "Votre besoin",
|
|
@@ -14,7 +75,7 @@ const FR = {
|
|
|
14
75
|
fieldInvalid: "Vérifiez ce champ.",
|
|
15
76
|
formInvalid: "Votre demande n’a pas pu être envoyée. Vérifiez le formulaire.",
|
|
16
77
|
sending: "Envoi…",
|
|
17
|
-
networkError: "Pas de connexion : votre demande
|
|
78
|
+
networkError: "Pas de réponse du serveur. Vérifiez votre connexion et réessayez : votre demande ne sera pas envoyée deux fois.",
|
|
18
79
|
timeoutError: "Le serveur ne répond pas. Réessayez : votre demande ne sera pas envoyée deux fois.",
|
|
19
80
|
retry: "Réessayer",
|
|
20
81
|
nameLabel: "Nom",
|
|
@@ -37,6 +98,7 @@ const FR = {
|
|
|
37
98
|
honeypotLabel: "Site web",
|
|
38
99
|
};
|
|
39
100
|
const EN = {
|
|
101
|
+
...EN_FLOW,
|
|
40
102
|
title: "Get a price",
|
|
41
103
|
lede: "A quick answer, no commitment.",
|
|
42
104
|
needLabel: "What you need",
|
|
@@ -52,7 +114,7 @@ const EN = {
|
|
|
52
114
|
fieldInvalid: "Check this field.",
|
|
53
115
|
formInvalid: "Your request could not be sent. Check the form.",
|
|
54
116
|
sending: "Sending…",
|
|
55
|
-
networkError: "No
|
|
117
|
+
networkError: "No answer from the server. Check your connection and try again: your request will not be sent twice.",
|
|
56
118
|
timeoutError: "The server is not answering. Try again: your request will not be sent twice.",
|
|
57
119
|
retry: "Try again",
|
|
58
120
|
nameLabel: "Name",
|
|
@@ -75,3 +137,14 @@ const EN = {
|
|
|
75
137
|
honeypotLabel: "Website",
|
|
76
138
|
};
|
|
77
139
|
export const LEAD_CAPTURE_TEXT = { fr: FR, en: EN };
|
|
140
|
+
/** The flows' words for a page: the brand's, else the kit's in its language (English for `en*`, French otherwise). */
|
|
141
|
+
export function flowTextOf(text, locale) {
|
|
142
|
+
const kit = locale.startsWith("en") ? EN_FLOW : FR_FLOW;
|
|
143
|
+
const out = { ...kit };
|
|
144
|
+
for (const key of Object.keys(kit)) {
|
|
145
|
+
const own = text[key];
|
|
146
|
+
if (own !== undefined)
|
|
147
|
+
out[key] = own;
|
|
148
|
+
}
|
|
149
|
+
return out;
|
|
150
|
+
}
|
package/dist/core/lead.d.ts
CHANGED
|
@@ -9,6 +9,8 @@
|
|
|
9
9
|
* `LeadSchema.wire` names the posted fields (a page cached before a rename
|
|
10
10
|
* still delivers) — both deliberate departures from the first design.
|
|
11
11
|
*/
|
|
12
|
+
import type { LeadFlow, LeadFlows } from "./pricing/flow.js";
|
|
13
|
+
import type { PricingInputs } from "./pricing/model.js";
|
|
12
14
|
/** Why a submission was kept but not acted on. `null` is a clean lead. */
|
|
13
15
|
export type SpamVerdict = "honeypot" | "too-fast" | "rate-limited";
|
|
14
16
|
/**
|
|
@@ -73,6 +75,13 @@ export interface LeadSchema<S extends string> {
|
|
|
73
75
|
* number as typed.
|
|
74
76
|
*/
|
|
75
77
|
mobileFormat?: "typed" | "e164";
|
|
78
|
+
/**
|
|
79
|
+
* How each need is sold (`quote` | `estimate` | `fixed`); a need left out
|
|
80
|
+
* is a `quote`. The form and the server read the same map, and a need runs
|
|
81
|
+
* its flow only when the price list prices it that way (`flowOf`) — so a
|
|
82
|
+
* flow the brand has not switched on is never run by a posted field.
|
|
83
|
+
*/
|
|
84
|
+
flows?: LeadFlows;
|
|
76
85
|
}
|
|
77
86
|
/**
|
|
78
87
|
* How the lead was asked for: the quote form, or "call me back" — the form cut
|
|
@@ -100,6 +109,39 @@ export declare const SUBMISSION_FIELD = "submission_id";
|
|
|
100
109
|
export declare const SUBMISSION_ID: RegExp;
|
|
101
110
|
/** The query a refused submission is sent back to the form with: `?lead_error=<field>`. */
|
|
102
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;
|
|
103
145
|
/**
|
|
104
146
|
* The card's id, posted so a refusal lands back on it (`#devis`, not a
|
|
105
147
|
* hard-coded `#quote`). Only a slug is ever echoed into the redirect.
|
|
@@ -139,6 +181,25 @@ export interface Lead {
|
|
|
139
181
|
* Absent on a plain post, which has no script to mint one.
|
|
140
182
|
*/
|
|
141
183
|
submissionId?: string;
|
|
184
|
+
/**
|
|
185
|
+
* How the need was sold, when the brand sells by flow (`LeadSchema.flows`)
|
|
186
|
+
* and the lead came through the form; absent otherwise — a callback, or a
|
|
187
|
+
* lead from before the field existed.
|
|
188
|
+
*/
|
|
189
|
+
flow?: LeadFlow;
|
|
190
|
+
/** Only on an `estimate` or `fixed` lead: the price the server computed. */
|
|
191
|
+
price?: LeadPrice;
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* The price a lead was taken at — the server's own number, from the posted
|
|
195
|
+
* answers, never a posted amount — and the model it came from.
|
|
196
|
+
*/
|
|
197
|
+
export interface LeadPrice {
|
|
198
|
+
cents: number;
|
|
199
|
+
/** The model's `validFrom`, `YYYY-MM-DD`. */
|
|
200
|
+
validFrom: string;
|
|
201
|
+
/** An estimate's answers, input id → option id; absent for a fixed price. */
|
|
202
|
+
inputs?: PricingInputs;
|
|
142
203
|
}
|
|
143
204
|
/** What the panel may be told of a suspected lead. */
|
|
144
205
|
export type LeadSuspect = "rate_limited" | "too_fast";
|
|
@@ -154,7 +215,7 @@ export declare function channelOf(lead: Pick<Lead, "channel">): LeadChannel;
|
|
|
154
215
|
* What the form posted, before the funnel judges it: the consent is the
|
|
155
216
|
* posted sentence, and the funnel stamps when it accepted it.
|
|
156
217
|
*/
|
|
157
|
-
export type LeadCandidate = Omit<Lead, "spamVerdict" | "consent"> & {
|
|
218
|
+
export type LeadCandidate = Omit<Lead, "spamVerdict" | "consent" | "flow" | "price"> & {
|
|
158
219
|
consentText?: string;
|
|
159
220
|
};
|
|
160
221
|
/**
|
|
@@ -189,9 +250,10 @@ export interface LeadStore {
|
|
|
189
250
|
* The lead schema's version, shared by every adapter: 1 the Rust server's
|
|
190
251
|
* table (`job`, `zip`, `mobile`, `at`), 2 + the place, 3 + the spam verdict,
|
|
191
252
|
* 4 + the brand's extras, 5 + the channel and the callback's consent, 6 + the
|
|
192
|
-
* script's submission id, unique
|
|
253
|
+
* script's submission id, unique, 7 + the flow and the price it was taken at.
|
|
254
|
+
* Append only.
|
|
193
255
|
*/
|
|
194
|
-
export declare const LEAD_SCHEMA_VERSION =
|
|
256
|
+
export declare const LEAD_SCHEMA_VERSION = 7;
|
|
195
257
|
/** A field is capped, not rejected: a long answer is still a customer. */
|
|
196
258
|
export declare const MAX_FIELD = 200;
|
|
197
259
|
/**
|
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.
|
|
@@ -70,9 +113,10 @@ export function channelOf(lead) {
|
|
|
70
113
|
* The lead schema's version, shared by every adapter: 1 the Rust server's
|
|
71
114
|
* table (`job`, `zip`, `mobile`, `at`), 2 + the place, 3 + the spam verdict,
|
|
72
115
|
* 4 + the brand's extras, 5 + the channel and the callback's consent, 6 + the
|
|
73
|
-
* script's submission id, unique
|
|
116
|
+
* script's submission id, unique, 7 + the flow and the price it was taken at.
|
|
117
|
+
* Append only.
|
|
74
118
|
*/
|
|
75
|
-
export const LEAD_SCHEMA_VERSION =
|
|
119
|
+
export const LEAD_SCHEMA_VERSION = 7;
|
|
76
120
|
/** A field is capped, not rejected: a long answer is still a customer. */
|
|
77
121
|
export const MAX_FIELD = 200;
|
|
78
122
|
// C0 and C1 controls, DEL, and the Unicode line and paragraph separators.
|
package/dist/core/phone.d.ts
CHANGED
|
@@ -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`)
|
|
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;
|
package/dist/core/phone.js
CHANGED
|
@@ -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`)
|
|
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
|
package/dist/core/place/live.js
CHANGED
|
@@ -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>, {
|
package/dist/core/place/types.js
CHANGED
|
@@ -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;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { PricingInputs, PricingModel } from "./model.js";
|
|
2
|
+
/**
|
|
3
|
+
* How a need is sold. `quote`: the price is uncertain, the lead asks for one.
|
|
4
|
+
* `estimate`: the price follows from a few enum answers, shown live, then a
|
|
5
|
+
* slot is booked. `fixed`: one price for a well-defined job, then a slot.
|
|
6
|
+
* Chosen per need, not per site: one brand can mix them.
|
|
7
|
+
*/
|
|
8
|
+
export type LeadFlow = "quote" | "estimate" | "fixed";
|
|
9
|
+
export declare const LEAD_FLOWS: readonly LeadFlow[];
|
|
10
|
+
/** A brand's flows by subject slug; a need left out is a `quote`. */
|
|
11
|
+
export type LeadFlows = Readonly<Record<string, LeadFlow>>;
|
|
12
|
+
/**
|
|
13
|
+
* The flow a need actually runs: the brand's, when the model prices the need
|
|
14
|
+
* that way — an `estimate` the model cannot price (a remote model without
|
|
15
|
+
* it, no model at all) is a `quote`, never a form with no price. The form and
|
|
16
|
+
* the server both ask this, so they agree on what was shown.
|
|
17
|
+
*/
|
|
18
|
+
export declare function flowOf(flows: LeadFlows | undefined, model: PricingModel | null, need: string | undefined): LeadFlow;
|
|
19
|
+
/** The form field an estimate's input is posted under: `estimate_<input id>`. */
|
|
20
|
+
export declare const ESTIMATE_FIELD_PREFIX = "estimate_";
|
|
21
|
+
export declare const estimateField: (input: string) => string;
|
|
22
|
+
/**
|
|
23
|
+
* The answers a form posted for the need's inputs — only those, and only
|
|
24
|
+
* slugs. Read by the server, which prices them itself: a posted amount, if
|
|
25
|
+
* any, is never read.
|
|
26
|
+
*/
|
|
27
|
+
export declare function readEstimateInputs(model: PricingModel, need: string, get: (field: string) => string | null): PricingInputs;
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { PRICING_LIMITS } from "./model.js";
|
|
2
|
+
export const LEAD_FLOWS = ["quote", "estimate", "fixed"];
|
|
3
|
+
/**
|
|
4
|
+
* The flow a need actually runs: the brand's, when the model prices the need
|
|
5
|
+
* that way — an `estimate` the model cannot price (a remote model without
|
|
6
|
+
* it, no model at all) is a `quote`, never a form with no price. The form and
|
|
7
|
+
* the server both ask this, so they agree on what was shown.
|
|
8
|
+
*/
|
|
9
|
+
export function flowOf(flows, model, need) {
|
|
10
|
+
if (!flows || !model || need === undefined || !Object.hasOwn(flows, need))
|
|
11
|
+
return "quote";
|
|
12
|
+
const wanted = flows[need];
|
|
13
|
+
if (wanted === undefined || wanted === "quote")
|
|
14
|
+
return "quote";
|
|
15
|
+
const pricing = Object.hasOwn(model.needs, need) ? model.needs[need] : undefined;
|
|
16
|
+
return pricing?.kind === wanted ? wanted : "quote";
|
|
17
|
+
}
|
|
18
|
+
/** The form field an estimate's input is posted under: `estimate_<input id>`. */
|
|
19
|
+
export const ESTIMATE_FIELD_PREFIX = "estimate_";
|
|
20
|
+
export const estimateField = (input) => `${ESTIMATE_FIELD_PREFIX}${input}`;
|
|
21
|
+
/**
|
|
22
|
+
* The answers a form posted for the need's inputs — only those, and only
|
|
23
|
+
* slugs. Read by the server, which prices them itself: a posted amount, if
|
|
24
|
+
* any, is never read.
|
|
25
|
+
*/
|
|
26
|
+
export function readEstimateInputs(model, need, get) {
|
|
27
|
+
const pricing = Object.hasOwn(model.needs, need) ? model.needs[need] : undefined;
|
|
28
|
+
if (pricing?.kind !== "estimate")
|
|
29
|
+
return {};
|
|
30
|
+
const out = {};
|
|
31
|
+
for (const id of pricing.inputs) {
|
|
32
|
+
const value = get(estimateField(id))?.trim();
|
|
33
|
+
if (value && PRICING_LIMITS.slug.test(value))
|
|
34
|
+
out[id] = value;
|
|
35
|
+
}
|
|
36
|
+
return out;
|
|
37
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { PRICING_CURRENCY, PRICING_FORMAT, PRICING_LIMITS, type AddOption, type DiscountOption, type MultiplyOption, type NeedPricing, type Price, type PriceLine, type PricingInput, type PricingInputKind, type PricingInputs, type PricingLabels, type PricingModel, } from "./model.js";
|
|
2
|
+
export { CENTS_BUCKETS, centsBucket, mulBp, priceOf, roundTo } from "./price.js";
|
|
3
|
+
export { labelOf, parsePricingModel, PricingModelError, pricingProblems, pricingProblemsFor } from "./validate.js";
|
|
4
|
+
export { ESTIMATE_FIELD_PREFIX, estimateField, flowOf, LEAD_FLOWS, readEstimateInputs, type LeadFlow, type LeadFlows } from "./flow.js";
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { PRICING_CURRENCY, PRICING_FORMAT, PRICING_LIMITS, } from "./model.js";
|
|
2
|
+
export { CENTS_BUCKETS, centsBucket, mulBp, priceOf, roundTo } from "./price.js";
|
|
3
|
+
export { labelOf, parsePricingModel, PricingModelError, pricingProblems, pricingProblemsFor } from "./validate.js";
|
|
4
|
+
export { ESTIMATE_FIELD_PREFIX, estimateField, flowOf, LEAD_FLOWS, readEstimateInputs } from "./flow.js";
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A brand's price list as data: what an `estimate` need costs for a set of
|
|
3
|
+
* enum inputs, and what a `fixed` need costs outright. JSON-serialisable on
|
|
4
|
+
* purpose — the panel edits it, serves it, and prices its own preview with a
|
|
5
|
+
* port of `priceOf` held to the same fixtures (`test/fixtures/pricing/`).
|
|
6
|
+
*
|
|
7
|
+
* Money is integer euro cents, TTC, throughout; a multiplier or a discount is
|
|
8
|
+
* in basis points (10 000 = ×1, 1 000 = 10 %), so no float ever reaches a
|
|
9
|
+
* stored or sent number.
|
|
10
|
+
*/
|
|
11
|
+
/** The model's format; a model of another format is refused whole. */
|
|
12
|
+
export declare const PRICING_FORMAT = 1;
|
|
13
|
+
/** The only currency a model may be in today: euros, all taxes included. */
|
|
14
|
+
export declare const PRICING_CURRENCY = "EUR";
|
|
15
|
+
/** A label per locale (`{ fr: "Studio", en: "Studio" }`); the page picks its own. */
|
|
16
|
+
export type PricingLabels = Readonly<Record<string, string>>;
|
|
17
|
+
/** One choice of an input: its slug, its words, and what it does to the price. */
|
|
18
|
+
export interface AddOption {
|
|
19
|
+
id: string;
|
|
20
|
+
labels: PricingLabels;
|
|
21
|
+
/** Added to the base, in cents. */
|
|
22
|
+
addCents: number;
|
|
23
|
+
}
|
|
24
|
+
export interface MultiplyOption {
|
|
25
|
+
id: string;
|
|
26
|
+
labels: PricingLabels;
|
|
27
|
+
/** The subtotal times this, in basis points: 11 000 is ×1.1. */
|
|
28
|
+
multiplyBp: number;
|
|
29
|
+
}
|
|
30
|
+
export interface DiscountOption {
|
|
31
|
+
id: string;
|
|
32
|
+
labels: PricingLabels;
|
|
33
|
+
/** Taken off the subtotal, in basis points: 1 000 is 10 % off, 0 none. */
|
|
34
|
+
discountBp: number;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* An enum the visitor answers — bedrooms, a surface band, a zone, a
|
|
38
|
+
* frequency. Its `kind` says what its options do, and when (see `priceOf`):
|
|
39
|
+
* every `add` first, then every `multiply`, then every `discount`.
|
|
40
|
+
*/
|
|
41
|
+
export type PricingInput = {
|
|
42
|
+
id: string;
|
|
43
|
+
kind: "add";
|
|
44
|
+
labels: PricingLabels;
|
|
45
|
+
options: readonly AddOption[];
|
|
46
|
+
} | {
|
|
47
|
+
id: string;
|
|
48
|
+
kind: "multiply";
|
|
49
|
+
labels: PricingLabels;
|
|
50
|
+
options: readonly MultiplyOption[];
|
|
51
|
+
} | {
|
|
52
|
+
id: string;
|
|
53
|
+
kind: "discount";
|
|
54
|
+
labels: PricingLabels;
|
|
55
|
+
options: readonly DiscountOption[];
|
|
56
|
+
};
|
|
57
|
+
export type PricingInputKind = PricingInput["kind"];
|
|
58
|
+
/**
|
|
59
|
+
* How one need is priced. `estimate`: a base and the inputs the visitor
|
|
60
|
+
* answers, in the order the form asks them. `fixed`: one price, exactly —
|
|
61
|
+
* neither rounded nor raised to the minimum, which are the estimate's.
|
|
62
|
+
*/
|
|
63
|
+
export type NeedPricing = {
|
|
64
|
+
kind: "estimate";
|
|
65
|
+
baseCents: number;
|
|
66
|
+
inputs: readonly string[];
|
|
67
|
+
} | {
|
|
68
|
+
kind: "fixed";
|
|
69
|
+
cents: number;
|
|
70
|
+
};
|
|
71
|
+
export interface PricingModel {
|
|
72
|
+
format: typeof PRICING_FORMAT;
|
|
73
|
+
currency: typeof PRICING_CURRENCY;
|
|
74
|
+
/** The date the prices hold from, `YYYY-MM-DD` — stored with every lead priced by it. */
|
|
75
|
+
validFrom: string;
|
|
76
|
+
/** The estimate's total is rounded half up to a multiple of this (1 = to the cent). */
|
|
77
|
+
roundToCents: number;
|
|
78
|
+
/** An estimate never comes out below this, after the rounding. */
|
|
79
|
+
minimumCents: number;
|
|
80
|
+
inputs: readonly PricingInput[];
|
|
81
|
+
/** By the brand's subject slug; a need not here is a `quote`. */
|
|
82
|
+
needs: Readonly<Record<string, NeedPricing>>;
|
|
83
|
+
}
|
|
84
|
+
/** The answers to an estimate's inputs: input id → option id. */
|
|
85
|
+
export type PricingInputs = Readonly<Record<string, string>>;
|
|
86
|
+
/**
|
|
87
|
+
* One line of how a price was reached; `cents` is what the line changed, so
|
|
88
|
+
* the lines add up to the total. `base` is the need's base (or the fixed
|
|
89
|
+
* price); `rounding` and `minimum` appear only when they changed something.
|
|
90
|
+
*/
|
|
91
|
+
export type PriceLine = {
|
|
92
|
+
kind: "base";
|
|
93
|
+
cents: number;
|
|
94
|
+
} | {
|
|
95
|
+
kind: "add" | "multiply" | "discount";
|
|
96
|
+
input: string;
|
|
97
|
+
option: string;
|
|
98
|
+
cents: number;
|
|
99
|
+
} | {
|
|
100
|
+
kind: "rounding";
|
|
101
|
+
cents: number;
|
|
102
|
+
} | {
|
|
103
|
+
kind: "minimum";
|
|
104
|
+
cents: number;
|
|
105
|
+
};
|
|
106
|
+
export interface Price {
|
|
107
|
+
cents: number;
|
|
108
|
+
breakdown: readonly PriceLine[];
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* The model's limits. Ids are slugs (they travel to the panel as
|
|
112
|
+
* `estimate_inputs`, which takes at most 12 pairs); every amount, and every
|
|
113
|
+
* step of the dearest combination, stays at or under `MAX_PRICE_CENTS`, so
|
|
114
|
+
* each product fits a double exactly and a 64-bit integer with room to spare.
|
|
115
|
+
*/
|
|
116
|
+
export declare const PRICING_LIMITS: {
|
|
117
|
+
readonly slug: RegExp;
|
|
118
|
+
readonly locale: RegExp;
|
|
119
|
+
readonly maxLabel: 120;
|
|
120
|
+
readonly maxInputs: 32;
|
|
121
|
+
readonly maxOptions: 32;
|
|
122
|
+
readonly maxNeeds: 64;
|
|
123
|
+
/** Per need: what `estimate_inputs` may carry. */
|
|
124
|
+
readonly maxNeedInputs: 12;
|
|
125
|
+
readonly maxPriceCents: 100000000;
|
|
126
|
+
/** ×10 at most. */
|
|
127
|
+
readonly maxMultiplyBp: 100000;
|
|
128
|
+
readonly maxDiscountBp: 10000;
|
|
129
|
+
};
|