@evinvest/kitstart 0.9.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. package/README.md +237 -12
  2. package/dist/core/accept.d.ts +35 -2
  3. package/dist/core/accept.js +74 -5
  4. package/dist/core/analytics.d.ts +4 -1
  5. package/dist/core/analytics.js +13 -0
  6. package/dist/core/booking/index.d.ts +3 -0
  7. package/dist/core/booking/index.js +3 -0
  8. package/dist/core/booking/model.d.ts +106 -0
  9. package/dist/core/booking/model.js +66 -0
  10. package/dist/core/booking/url.d.ts +17 -0
  11. package/dist/core/booking/url.js +53 -0
  12. package/dist/core/booking/validate.d.ts +27 -0
  13. package/dist/core/booking/validate.js +219 -0
  14. package/dist/core/lead-capture-format.d.ts +6 -0
  15. package/dist/core/lead-capture-format.js +13 -0
  16. package/dist/core/lead-capture-text.d.ts +57 -2
  17. package/dist/core/lead-capture-text.js +75 -2
  18. package/dist/core/lead.d.ts +65 -3
  19. package/dist/core/lead.js +46 -2
  20. package/dist/core/phone.d.ts +3 -1
  21. package/dist/core/phone.js +28 -2
  22. package/dist/core/place/live.d.ts +6 -2
  23. package/dist/core/place/live.js +21 -2
  24. package/dist/core/place/types.d.ts +6 -0
  25. package/dist/core/place/types.js +0 -13
  26. package/dist/core/pricing/flow.d.ts +27 -0
  27. package/dist/core/pricing/flow.js +37 -0
  28. package/dist/core/pricing/index.d.ts +4 -0
  29. package/dist/core/pricing/index.js +4 -0
  30. package/dist/core/pricing/model.d.ts +129 -0
  31. package/dist/core/pricing/model.js +34 -0
  32. package/dist/core/pricing/price.d.ts +34 -0
  33. package/dist/core/pricing/price.js +105 -0
  34. package/dist/core/pricing/validate.d.ts +27 -0
  35. package/dist/core/pricing/validate.js +244 -0
  36. package/dist/core/routing.d.ts +3 -2
  37. package/dist/core/routing.js +4 -3
  38. package/dist/core/site.d.ts +15 -0
  39. package/dist/core/site.js +26 -0
  40. package/dist/index.d.ts +6 -4
  41. package/dist/index.js +6 -4
  42. package/dist/next/booking-route.d.ts +31 -0
  43. package/dist/next/booking-route.js +97 -0
  44. package/dist/next/confirm-route.d.ts +39 -0
  45. package/dist/next/confirm-route.js +118 -0
  46. package/dist/next/index.d.ts +2 -0
  47. package/dist/next/index.js +2 -0
  48. package/dist/next/quote-route.d.ts +8 -1
  49. package/dist/next/quote-route.js +17 -6
  50. package/dist/react/LeadBooking.d.ts +35 -0
  51. package/dist/react/LeadBooking.js +47 -0
  52. package/dist/react/LeadBookingManual.d.ts +17 -0
  53. package/dist/react/LeadBookingManual.js +50 -0
  54. package/dist/react/LeadCapture.d.ts +54 -3
  55. package/dist/react/LeadCapture.js +37 -10
  56. package/dist/react/LeadCaptureChannels.d.ts +14 -2
  57. package/dist/react/LeadCaptureChannels.js +13 -0
  58. package/dist/react/LeadCaptureEstimate.d.ts +46 -0
  59. package/dist/react/LeadCaptureEstimate.js +69 -0
  60. package/dist/react/LeadCaptureFields.js +60 -21
  61. package/dist/react/LeadCapturePriced.d.ts +18 -0
  62. package/dist/react/LeadCapturePriced.js +14 -0
  63. package/dist/react/booking-adapters.d.ts +58 -0
  64. package/dist/react/booking-adapters.js +104 -0
  65. package/dist/react/index.d.ts +5 -1
  66. package/dist/react/index.js +4 -1
  67. package/dist/react/use-booking.d.ts +20 -0
  68. package/dist/react/use-booking.js +32 -0
  69. package/dist/react/use-lead-error.d.ts +3 -7
  70. package/dist/react/use-lead-error.js +9 -6
  71. package/dist/react/use-lead-events.d.ts +2 -0
  72. package/dist/react/use-lead-events.js +10 -0
  73. package/dist/react/use-lead-submit.d.ts +14 -4
  74. package/dist/react/use-lead-submit.js +19 -9
  75. package/dist/react/use-repriced.d.ts +18 -0
  76. package/dist/react/use-repriced.js +27 -0
  77. package/dist/server/index.d.ts +3 -2
  78. package/dist/server/index.js +2 -1
  79. package/dist/server/lead-store-sqlite.js +31 -5
  80. package/dist/server/lead-webhook.d.ts +94 -0
  81. package/dist/server/lead-webhook.js +46 -2
  82. package/dist/server/notify.d.ts +20 -2
  83. package/dist/server/notify.js +6 -4
  84. package/dist/server/place-source.js +1 -1
  85. package/dist/server/pricing-source.d.ts +25 -0
  86. package/dist/server/pricing-source.js +48 -0
  87. package/dist/server/webhook-outbox.d.ts +11 -1
  88. package/dist/server/webhook-outbox.js +32 -5
  89. package/dist/testing/lead-rules.js +15 -1
  90. package/dist/testing/lead-store-contract.js +9 -2
  91. package/package.json +1 -1
  92. package/template/app/[locale]/[location]/page.tsx +4 -2
  93. package/template/app/quote/booking/route.ts +11 -0
  94. package/template/app/quote/confirm/route.ts +12 -0
  95. package/template/app/quote/route.ts +2 -1
  96. package/template/flake.nix +1 -1
  97. package/template/package.json +2 -2
  98. package/template/src/entities/content/model/en.ts +1 -1
  99. package/template/src/entities/content/model/fr.ts +1 -1
  100. package/template/src/shared/config/env.ts +4 -1
  101. package/template/src/shared/config/lead.ts +12 -2
  102. package/template/src/shared/config/places.ts +4 -0
  103. package/template/src/shared/config/pricing.ts +49 -0
  104. package/template/src/shared/config/site.ts +6 -0
  105. package/template/src/views/home/ui/PlaceHome.tsx +6 -3
  106. package/template/tests/bundle_budget.txt +3 -1
  107. package/template/tests/e2e/flows.spec.ts +183 -0
  108. package/template/tests/e2e/mock-panel.mjs +34 -0
  109. package/template/tests/e2e/playwright.config.ts +25 -9
  110. package/template/tests/e2e/quote-form.spec.ts +2 -2
@@ -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 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.",
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 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.",
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
+ }
@@ -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. Append only.
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 = 6;
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. Append only.
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 = 6;
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.
@@ -7,7 +7,9 @@
7
7
  * `0033 6 …`, a mobile typed without its trunk zero (`6 12 34 56 78`) — all
8
8
  * become `+33612345678`. Any other international number is a plausible E.164,
9
9
  * 8 to 15 digits after the `+`, compacted (`+44 7911 123456` →
10
- * `+447911123456`). Full-width digits read as digits; a run of one digit
10
+ * `+447911123456`) — and, for the plans a visitor here most often dials
11
+ * from (`NATIONAL_LENGTH`), as long as that plan's numbers are, so `+12345678`
12
+ * is not a number. Full-width digits read as digits; a run of one digit
11
13
  * (`00 00 00 00 00`, `06 66 66 66 66`) is a refusal to give a number, not one.
12
14
  */
13
15
  export declare function normalizePhone(raw: string): string | null;
@@ -1,3 +1,27 @@
1
+ /**
2
+ * The national number's length, after the country code, for the plans the
3
+ * vertical's visitors most often come from — bounds wide enough for every
4
+ * real number (Germany and Italy vary by area), short of a library. A plan
5
+ * not listed keeps E.164's 8 to 15 digits in all.
6
+ */
7
+ const NATIONAL_LENGTH = {
8
+ "1": [10, 10],
9
+ "32": [8, 9],
10
+ "34": [9, 9],
11
+ "39": [6, 11],
12
+ "41": [9, 9],
13
+ "44": [9, 10],
14
+ "49": [7, 13],
15
+ };
16
+ function fitsPlan(e164) {
17
+ const digits = e164.slice(1);
18
+ for (const code of [digits.slice(0, 1), digits.slice(0, 2)]) {
19
+ const bounds = NATIONAL_LENGTH[code];
20
+ if (bounds)
21
+ return digits.length - code.length >= bounds[0] && digits.length - code.length <= bounds[1];
22
+ }
23
+ return true;
24
+ }
1
25
  /**
2
26
  * A phone number as the funnel reads it: E.164 when the typed value is a
3
27
  * number someone can be reached on, otherwise `null`.
@@ -7,7 +31,9 @@
7
31
  * `0033 6 …`, a mobile typed without its trunk zero (`6 12 34 56 78`) — all
8
32
  * become `+33612345678`. Any other international number is a plausible E.164,
9
33
  * 8 to 15 digits after the `+`, compacted (`+44 7911 123456` →
10
- * `+447911123456`). Full-width digits read as digits; a run of one digit
34
+ * `+447911123456`) — and, for the plans a visitor here most often dials
35
+ * from (`NATIONAL_LENGTH`), as long as that plan's numbers are, so `+12345678`
36
+ * is not a number. Full-width digits read as digits; a run of one digit
11
37
  * (`00 00 00 00 00`, `06 66 66 66 66`) is a refusal to give a number, not one.
12
38
  */
13
39
  export function normalizePhone(raw) {
@@ -27,7 +53,7 @@ export function normalizePhone(raw) {
27
53
  e164 = `+33${trunk[1]}`;
28
54
  else if (/^[1-9]\d{8}$/.test(s))
29
55
  e164 = `+33${s}`;
30
- else if (/^\+[1-9]\d{7,14}$/.test(s))
56
+ else if (/^\+[1-9]\d{7,14}$/.test(s) && fitsPlan(s))
31
57
  e164 = s;
32
58
  }
33
59
  if (e164 === null)
@@ -1,3 +1,4 @@
1
+ import type { BookingConfig, BookingRules } from "../booking/model.js";
1
2
  import type { Geo, OpeningHours, Place, PostalAddress, Rating, ServiceArea } from "./types.js";
2
3
  /**
3
4
  * The live half of a place, as `GET <source>/locations/<slug>` answers it.
@@ -16,12 +17,15 @@ export interface PlaceLive<L extends string> {
16
17
  serviceArea?: readonly ServiceArea[];
17
18
  hours?: readonly OpeningHours[];
18
19
  rating?: Rating;
20
+ /** Validated with the site's Cal.com hosts; one that does not validate is dropped, the baked one kept. */
21
+ booking?: BookingConfig;
19
22
  }
20
23
  /**
21
24
  * Drops what does not validate instead of failing the page on one bad field.
22
- * A landmark counts only when it is given in every one of `locales`.
25
+ * A landmark counts only when it is given in every one of `locales`; a
26
+ * `cal_com` booking only on one of `rules.calComHosts` (the site's).
23
27
  */
24
- export declare function parsePlaceLive<L extends string>(body: unknown, locales: readonly L[]): PlaceLive<L>;
28
+ export declare function parsePlaceLive<L extends string>(body: unknown, locales: readonly L[], rules?: BookingRules): PlaceLive<L>;
25
29
  /**
26
30
  * The live fields over the baked ones. A service-area place takes none of the
27
31
  * storefront's: an address, a pin or a photo from the source would give it a
@@ -1,3 +1,4 @@
1
+ import { BookingConfigError, parseBookingConfig } from "../booking/validate.js";
1
2
  import { parseInstant } from "./rating.js";
2
3
  const DAYS = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"];
3
4
  const TIME = /^([01]\d|2[0-3]):[0-5]\d$/;
@@ -60,11 +61,24 @@ function landmark(v, locales) {
60
61
  function isComplete(record, locales) {
61
62
  return locales.every(locale => typeof record[locale] === "string");
62
63
  }
64
+ function booking(v, rules) {
65
+ if (v === undefined || v === null)
66
+ return undefined;
67
+ try {
68
+ return parseBookingConfig(v, rules);
69
+ }
70
+ catch (error) {
71
+ if (error instanceof BookingConfigError)
72
+ return undefined;
73
+ throw error;
74
+ }
75
+ }
63
76
  /**
64
77
  * Drops what does not validate instead of failing the page on one bad field.
65
- * A landmark counts only when it is given in every one of `locales`.
78
+ * A landmark counts only when it is given in every one of `locales`; a
79
+ * `cal_com` booking only on one of `rules.calComHosts` (the site's).
66
80
  */
67
- export function parsePlaceLive(body, locales) {
81
+ export function parsePlaceLive(body, locales, rules = {}) {
68
82
  if (!isObject(body))
69
83
  throw new Error("live location: body is not an object");
70
84
  const live = {};
@@ -104,6 +118,9 @@ export function parsePlaceLive(body, locales) {
104
118
  const r = rating(body.rating);
105
119
  if (r)
106
120
  live.rating = r;
121
+ const b = booking(body.booking, rules);
122
+ if (b)
123
+ live.booking = b;
107
124
  return live;
108
125
  }
109
126
  /**
@@ -113,6 +130,7 @@ export function parsePlaceLive(body, locales) {
113
130
  */
114
131
  export function mergeLive(baked, live) {
115
132
  const front = baked.presence;
133
+ const booking = live.booking ?? baked.booking;
116
134
  return {
117
135
  ...baked,
118
136
  presence: front.kind === "storefront"
@@ -128,5 +146,6 @@ export function mergeLive(baked, live) {
128
146
  serviceArea: live.serviceArea ?? baked.serviceArea,
129
147
  hours: live.hours ?? baked.hours,
130
148
  rating: live.rating ?? baked.rating,
149
+ ...(booking ? { booking } : {}),
131
150
  };
132
151
  }
@@ -1,3 +1,4 @@
1
+ import type { BookingConfig } from "../booking/model.js";
1
2
  /**
2
3
  * The place model. Three words, one each:
3
4
  *
@@ -79,6 +80,11 @@ export interface Place<L extends string> {
79
80
  hours: readonly OpeningHours[] | null;
80
81
  /** Live only. */
81
82
  rating: Rating | null;
83
+ /**
84
+ * How a priced lead's slot is set (`PlaceLive.booking`, the panel's place
85
+ * settings); absent → `DEFAULT_BOOKING`, a call (`bookingOf`).
86
+ */
87
+ booking?: BookingConfig;
82
88
  }
83
89
  /** The storefront half of a place, or `null` for a service-area business. */
84
90
  export declare function storefrontOf<L extends string>(place: Place<L>): Extract<Presence<L>, {
@@ -1,16 +1,3 @@
1
- /**
2
- * The place model. Three words, one each:
3
- *
4
- * - **Place** — the aggregate: one point of the business, baked config merged
5
- * with the live source;
6
- * - **Presence** — how the place exists for a visitor: a storefront with an
7
- * address, or a service area with none;
8
- * - **ServiceArea** — where the van goes.
9
- *
10
- * A service-area business has no address *in the type*, so no builder can
11
- * render one for it by accident — schema.org, the map and the footer all have
12
- * to ask `presence.kind` first.
13
- */
14
1
  /** The storefront half of a place, or `null` for a service-area business. */
15
2
  export function storefrontOf(place) {
16
3
  return place.presence.kind === "storefront" ? place.presence : null;
@@ -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
+ };