@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
@@ -0,0 +1,34 @@
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 const PRICING_FORMAT = 1;
13
+ /** The only currency a model may be in today: euros, all taxes included. */
14
+ export const PRICING_CURRENCY = "EUR";
15
+ /**
16
+ * The model's limits. Ids are slugs (they travel to the panel as
17
+ * `estimate_inputs`, which takes at most 12 pairs); every amount, and every
18
+ * step of the dearest combination, stays at or under `MAX_PRICE_CENTS`, so
19
+ * each product fits a double exactly and a 64-bit integer with room to spare.
20
+ */
21
+ export const PRICING_LIMITS = {
22
+ slug: /^[a-z0-9_-]{1,40}$/,
23
+ locale: /^[a-z]{2}(-[A-Z]{2})?$/,
24
+ maxLabel: 120,
25
+ maxInputs: 32,
26
+ maxOptions: 32,
27
+ maxNeeds: 64,
28
+ /** Per need: what `estimate_inputs` may carry. */
29
+ maxNeedInputs: 12,
30
+ maxPriceCents: 100_000_000,
31
+ /** ×10 at most. */
32
+ maxMultiplyBp: 100_000,
33
+ maxDiscountBp: 10_000,
34
+ };
@@ -0,0 +1,34 @@
1
+ import type { Price, PricingInputs, PricingModel } from "./model.js";
2
+ /**
3
+ * `cents × bp / 10 000`, rounded half up to the cent. Integers throughout:
4
+ * the product is exact (the model's limits keep it under 2^53), and the
5
+ * division is done on the remainder, never on a float.
6
+ */
7
+ export declare function mulBp(cents: number, bp: number): number;
8
+ /** Half up to a multiple of `step` (≥ 1). */
9
+ export declare function roundTo(cents: number, step: number): number;
10
+ /**
11
+ * What a need costs for these answers — the one function the form shows and
12
+ * the server stores, and the one the panel's preview ports. Normative order:
13
+ *
14
+ * 1. the need's base, plus every `add` input, in the need's input order;
15
+ * 2. times every `multiply` input, in order, each product rounded half up to
16
+ * the cent at once;
17
+ * 3. less every `discount` input, in order, each as a multiplication by
18
+ * `10 000 − discountBp` rounded the same way;
19
+ * 4. the total rounded half up to a multiple of `roundToCents`;
20
+ * 5. raised to `minimumCents`.
21
+ *
22
+ * A `fixed` need is its price, as is. `null`: the need is not priced, or an
23
+ * input is unanswered or answered with an option it does not have — never a
24
+ * guess. Answers to inputs the need does not ask are ignored.
25
+ */
26
+ export declare function priceOf(model: PricingModel, need: string, inputs: PricingInputs): Price | null;
27
+ /**
28
+ * The band a price falls in, for analytics: `"5000-7500"` (cents, lower
29
+ * bound included), never the price itself — a funnel needs the shape of
30
+ * what was shown, not a number that, with a time and a place, singles a
31
+ * visitor out.
32
+ */
33
+ export declare const CENTS_BUCKETS: readonly number[];
34
+ export declare function centsBucket(cents: number): string;
@@ -0,0 +1,105 @@
1
+ const BP = 10_000;
2
+ /**
3
+ * `cents × bp / 10 000`, rounded half up to the cent. Integers throughout:
4
+ * the product is exact (the model's limits keep it under 2^53), and the
5
+ * division is done on the remainder, never on a float.
6
+ */
7
+ export function mulBp(cents, bp) {
8
+ const n = cents * bp + BP / 2;
9
+ return (n - (n % BP)) / BP;
10
+ }
11
+ /** Half up to a multiple of `step` (≥ 1). */
12
+ export function roundTo(cents, step) {
13
+ const rest = cents % step;
14
+ return rest * 2 >= step ? cents - rest + step : cents - rest;
15
+ }
16
+ /** The option answered for each of the need's inputs, or `null` when one is missing or unknown. */
17
+ function chosen(model, ids, inputs) {
18
+ const out = [];
19
+ for (const id of ids) {
20
+ const input = model.inputs.find(i => i.id === id);
21
+ const answer = Object.hasOwn(inputs, id) ? inputs[id] : undefined;
22
+ if (!input || answer === undefined)
23
+ return null;
24
+ const effect = effectOf(input, answer);
25
+ if (effect === null)
26
+ return null;
27
+ out.push({ input, option: answer, effect });
28
+ }
29
+ return out;
30
+ }
31
+ function effectOf(input, option) {
32
+ switch (input.kind) {
33
+ case "add":
34
+ return input.options.find(o => o.id === option)?.addCents ?? null;
35
+ case "multiply":
36
+ return input.options.find(o => o.id === option)?.multiplyBp ?? null;
37
+ case "discount":
38
+ return input.options.find(o => o.id === option)?.discountBp ?? null;
39
+ }
40
+ }
41
+ /**
42
+ * What a need costs for these answers — the one function the form shows and
43
+ * the server stores, and the one the panel's preview ports. Normative order:
44
+ *
45
+ * 1. the need's base, plus every `add` input, in the need's input order;
46
+ * 2. times every `multiply` input, in order, each product rounded half up to
47
+ * the cent at once;
48
+ * 3. less every `discount` input, in order, each as a multiplication by
49
+ * `10 000 − discountBp` rounded the same way;
50
+ * 4. the total rounded half up to a multiple of `roundToCents`;
51
+ * 5. raised to `minimumCents`.
52
+ *
53
+ * A `fixed` need is its price, as is. `null`: the need is not priced, or an
54
+ * input is unanswered or answered with an option it does not have — never a
55
+ * guess. Answers to inputs the need does not ask are ignored.
56
+ */
57
+ export function priceOf(model, need, inputs) {
58
+ const pricing = Object.hasOwn(model.needs, need) ? model.needs[need] : undefined;
59
+ if (!pricing)
60
+ return null;
61
+ if (pricing.kind === "fixed")
62
+ return { cents: pricing.cents, breakdown: [{ kind: "base", cents: pricing.cents }] };
63
+ const answers = chosen(model, pricing.inputs, inputs);
64
+ if (!answers)
65
+ return null;
66
+ const lines = [{ kind: "base", cents: pricing.baseCents }];
67
+ let total = pricing.baseCents;
68
+ const step = (kind, apply) => {
69
+ for (const a of answers) {
70
+ if (a.input.kind !== kind)
71
+ continue;
72
+ const next = apply(total, a.effect);
73
+ lines.push({ kind, input: a.input.id, option: a.option, cents: next - total });
74
+ total = next;
75
+ }
76
+ };
77
+ step("add", (t, cents) => t + cents);
78
+ step("multiply", (t, bp) => mulBp(t, bp));
79
+ step("discount", (t, bp) => mulBp(t, BP - bp));
80
+ const rounded = roundTo(total, model.roundToCents);
81
+ if (rounded !== total)
82
+ lines.push({ kind: "rounding", cents: rounded - total });
83
+ total = rounded;
84
+ if (total < model.minimumCents) {
85
+ lines.push({ kind: "minimum", cents: model.minimumCents - total });
86
+ total = model.minimumCents;
87
+ }
88
+ return { cents: total, breakdown: lines };
89
+ }
90
+ /**
91
+ * The band a price falls in, for analytics: `"5000-7500"` (cents, lower
92
+ * bound included), never the price itself — a funnel needs the shape of
93
+ * what was shown, not a number that, with a time and a place, singles a
94
+ * visitor out.
95
+ */
96
+ export const CENTS_BUCKETS = [0, 2_500, 5_000, 7_500, 10_000, 15_000, 20_000, 30_000, 50_000, 100_000];
97
+ export function centsBucket(cents) {
98
+ let at = 0;
99
+ for (let i = 0; i < CENTS_BUCKETS.length; i++)
100
+ if (cents >= (CENTS_BUCKETS[i] ?? 0))
101
+ at = i;
102
+ const low = CENTS_BUCKETS[at] ?? 0;
103
+ const high = CENTS_BUCKETS[at + 1];
104
+ return high === undefined ? `${low}+` : `${low}-${high}`;
105
+ }
@@ -0,0 +1,27 @@
1
+ import { type PricingLabels, type PricingModel } from "./model.js";
2
+ /**
3
+ * A model that does not validate, with every reason found. A model is taken
4
+ * whole or not at all: one bad field refuses it, so a page never prices from
5
+ * half a model.
6
+ */
7
+ export declare class PricingModelError extends Error {
8
+ readonly problems: readonly string[];
9
+ readonly name = "PricingModelError";
10
+ constructor(problems: readonly string[]);
11
+ }
12
+ /**
13
+ * Every reason `value` is not a pricing model, or none. The panel's editor
14
+ * holds a model to the same rules (`test/fixtures/pricing/`): unknown fields
15
+ * are refused, not ignored, so the two never disagree on what a model says.
16
+ */
17
+ export declare function pricingProblems(value: unknown): string[];
18
+ /** The model `value` is, rebuilt from what was checked; throws `PricingModelError` with every problem. */
19
+ export declare function parsePricingModel(value: unknown): PricingModel;
20
+ /**
21
+ * A model a site can show: valid, and labelled in every one of its locales —
22
+ * the panel may serve a model before a language is filled in, and a page
23
+ * must not show an option with no words.
24
+ */
25
+ export declare function pricingProblemsFor(value: unknown, locales: readonly string[]): string[];
26
+ /** The label for `locale`, or the first one the model has. */
27
+ export declare function labelOf(labels: PricingLabels, locale: string): string;
@@ -0,0 +1,244 @@
1
+ import { PRICING_CURRENCY, PRICING_FORMAT, PRICING_LIMITS as LIMIT, } from "./model.js";
2
+ import { mulBp } from "./price.js";
3
+ /**
4
+ * A model that does not validate, with every reason found. A model is taken
5
+ * whole or not at all: one bad field refuses it, so a page never prices from
6
+ * half a model.
7
+ */
8
+ export class PricingModelError extends Error {
9
+ problems;
10
+ name = "PricingModelError";
11
+ constructor(problems) {
12
+ super(`pricing model: ${problems.join("; ")}`);
13
+ this.problems = problems;
14
+ }
15
+ }
16
+ const isObject = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
17
+ /** Collects problems at a path, so the panel's editor can say which field. */
18
+ class Check {
19
+ problems = [];
20
+ fail(path, why) {
21
+ this.problems.push(`${path}: ${why}`);
22
+ }
23
+ /** Only these keys, all of them required unless listed as optional. */
24
+ keys(path, v, required, optional = []) {
25
+ for (const key of required)
26
+ if (!Object.hasOwn(v, key))
27
+ this.fail(`${path}.${key}`, "missing");
28
+ for (const key of Object.keys(v))
29
+ if (!required.includes(key) && !optional.includes(key))
30
+ this.fail(`${path}.${key}`, "unknown field");
31
+ }
32
+ int(path, v, min, max) {
33
+ if (typeof v !== "number" || !Number.isSafeInteger(v) || v < min || v > max) {
34
+ this.fail(path, `an integer from ${min} to ${max}`);
35
+ return min;
36
+ }
37
+ return v;
38
+ }
39
+ slug(path, v) {
40
+ if (typeof v !== "string" || !LIMIT.slug.test(v)) {
41
+ this.fail(path, "a slug, [a-z0-9_-]{1,40}");
42
+ return "";
43
+ }
44
+ return v;
45
+ }
46
+ labels(path, v) {
47
+ if (!isObject(v) || Object.keys(v).length === 0) {
48
+ this.fail(path, "an object of labels by locale, at least one");
49
+ return {};
50
+ }
51
+ const out = {};
52
+ for (const [locale, text] of Object.entries(v)) {
53
+ if (!LIMIT.locale.test(locale))
54
+ this.fail(`${path}.${locale}`, "not a locale (fr, en, fr-FR)");
55
+ else if (typeof text !== "string" || text.trim() === "" || text.length > LIMIT.maxLabel)
56
+ this.fail(`${path}.${locale}`, `a label of 1 to ${LIMIT.maxLabel} characters`);
57
+ else
58
+ out[locale] = text;
59
+ }
60
+ return out;
61
+ }
62
+ list(path, v, min, max) {
63
+ if (!Array.isArray(v) || v.length < min || v.length > max) {
64
+ this.fail(path, `a list of ${min} to ${max}`);
65
+ return [];
66
+ }
67
+ return v;
68
+ }
69
+ unique(path, ids) {
70
+ const dup = ids.find((id, i) => id !== "" && ids.indexOf(id) !== i);
71
+ if (dup !== undefined)
72
+ this.fail(path, `"${dup}" is listed twice`);
73
+ }
74
+ }
75
+ /** `YYYY-MM-DD`, and a day the calendar has. */
76
+ function isDate(v) {
77
+ if (typeof v !== "string" || !/^\d{4}-\d{2}-\d{2}$/.test(v))
78
+ return false;
79
+ const day = new Date(`${v}T00:00:00Z`);
80
+ return !Number.isNaN(day.getTime()) && day.toISOString().slice(0, 10) === v;
81
+ }
82
+ const EFFECT = { add: "addCents", multiply: "multiplyBp", discount: "discountBp" };
83
+ const EFFECT_MAX = { add: LIMIT.maxPriceCents, multiply: LIMIT.maxMultiplyBp, discount: LIMIT.maxDiscountBp };
84
+ function input(c, path, v) {
85
+ if (!isObject(v)) {
86
+ c.fail(path, "an object");
87
+ return null;
88
+ }
89
+ c.keys(path, v, ["id", "kind", "labels", "options"]);
90
+ const id = c.slug(`${path}.id`, v.id);
91
+ const labels = c.labels(`${path}.labels`, v.labels);
92
+ const kind = v.kind;
93
+ if (kind !== "add" && kind !== "multiply" && kind !== "discount") {
94
+ c.fail(`${path}.kind`, "add, multiply or discount");
95
+ return null;
96
+ }
97
+ const effect = EFFECT[kind];
98
+ const options = c.list(`${path}.options`, v.options, 1, LIMIT.maxOptions).map((o, i) => {
99
+ const at = `${path}.options[${i}]`;
100
+ if (!isObject(o)) {
101
+ c.fail(at, "an object");
102
+ return { id: "", labels: {}, value: 0 };
103
+ }
104
+ c.keys(at, o, ["id", "labels", effect]);
105
+ return { id: c.slug(`${at}.id`, o.id), labels: c.labels(`${at}.labels`, o.labels), value: c.int(`${at}.${effect}`, o[effect], 0, EFFECT_MAX[kind]) };
106
+ });
107
+ c.unique(`${path}.options`, options.map(o => o.id));
108
+ switch (kind) {
109
+ case "add":
110
+ return { id, kind, labels, options: options.map(o => ({ id: o.id, labels: o.labels, addCents: o.value })) };
111
+ case "multiply":
112
+ return { id, kind, labels, options: options.map(o => ({ id: o.id, labels: o.labels, multiplyBp: o.value })) };
113
+ case "discount":
114
+ return { id, kind, labels, options: options.map(o => ({ id: o.id, labels: o.labels, discountBp: o.value })) };
115
+ }
116
+ }
117
+ function need(c, path, v, inputs) {
118
+ if (!isObject(v)) {
119
+ c.fail(path, "an object");
120
+ return null;
121
+ }
122
+ if (v.kind === "fixed") {
123
+ c.keys(path, v, ["kind", "cents"]);
124
+ return { kind: "fixed", cents: c.int(`${path}.cents`, v.cents, 0, LIMIT.maxPriceCents) };
125
+ }
126
+ if (v.kind !== "estimate") {
127
+ c.fail(`${path}.kind`, "estimate or fixed");
128
+ return null;
129
+ }
130
+ c.keys(path, v, ["kind", "baseCents", "inputs"]);
131
+ const baseCents = c.int(`${path}.baseCents`, v.baseCents, 0, LIMIT.maxPriceCents);
132
+ const ids = c.list(`${path}.inputs`, v.inputs, 0, LIMIT.maxNeedInputs).map((id, i) => c.slug(`${path}.inputs[${i}]`, id));
133
+ c.unique(`${path}.inputs`, ids);
134
+ for (const [i, id] of ids.entries())
135
+ if (id !== "" && !inputs.some(input => input.id === id))
136
+ c.fail(`${path}.inputs[${i}]`, `no input "${id}"`);
137
+ return { kind: "estimate", baseCents, inputs: ids };
138
+ }
139
+ /**
140
+ * The dearest answer to every input — each effect only raises the price with
141
+ * its value, and rounding half up is monotonic — walked step by step: if no
142
+ * step of it passes the cap, no combination does.
143
+ */
144
+ function dearest(c, path, pricing, inputs) {
145
+ const asked = pricing.inputs.map(id => inputs.find(i => i.id === id)).filter((i) => i !== undefined);
146
+ const max = (i) => Math.max(...i.options.map(o => ("addCents" in o ? o.addCents : "multiplyBp" in o ? o.multiplyBp : 0)));
147
+ let total = pricing.baseCents;
148
+ const steps = [total];
149
+ for (const i of asked)
150
+ if (i.kind === "add")
151
+ steps.push((total += max(i)));
152
+ for (const i of asked)
153
+ if (i.kind === "multiply")
154
+ steps.push((total = mulBp(total, max(i))));
155
+ if (steps.some(s => s > LIMIT.maxPriceCents))
156
+ c.fail(path, `its dearest combination passes ${LIMIT.maxPriceCents} cents`);
157
+ }
158
+ /**
159
+ * Every reason `value` is not a pricing model, or none. The panel's editor
160
+ * holds a model to the same rules (`test/fixtures/pricing/`): unknown fields
161
+ * are refused, not ignored, so the two never disagree on what a model says.
162
+ */
163
+ export function pricingProblems(value) {
164
+ return check(value).problems;
165
+ }
166
+ function check(value) {
167
+ const c = new Check();
168
+ if (!isObject(value))
169
+ return { problems: ["model: an object"], model: null };
170
+ c.keys("model", value, ["format", "currency", "validFrom", "roundToCents", "minimumCents", "inputs", "needs"]);
171
+ if (value.format !== PRICING_FORMAT)
172
+ c.fail("model.format", `${PRICING_FORMAT}`);
173
+ if (value.currency !== PRICING_CURRENCY)
174
+ c.fail("model.currency", PRICING_CURRENCY);
175
+ if (!isDate(value.validFrom))
176
+ c.fail("model.validFrom", "a date, YYYY-MM-DD");
177
+ const roundToCents = c.int("model.roundToCents", value.roundToCents, 1, LIMIT.maxPriceCents);
178
+ const minimumCents = c.int("model.minimumCents", value.minimumCents, 0, LIMIT.maxPriceCents);
179
+ const inputs = c
180
+ .list("model.inputs", value.inputs, 0, LIMIT.maxInputs)
181
+ .map((v, i) => input(c, `model.inputs[${i}]`, v))
182
+ .filter((i) => i !== null);
183
+ c.unique("model.inputs", inputs.map(i => i.id));
184
+ const needs = {};
185
+ if (!isObject(value.needs))
186
+ c.fail("model.needs", "an object by need slug");
187
+ else {
188
+ const entries = Object.entries(value.needs);
189
+ if (entries.length > LIMIT.maxNeeds)
190
+ c.fail("model.needs", `at most ${LIMIT.maxNeeds}`);
191
+ for (const [slug, v] of entries) {
192
+ const path = `model.needs.${slug}`;
193
+ if (!LIMIT.slug.test(slug))
194
+ c.fail(path, "the need must be a slug, [a-z0-9_-]{1,40}");
195
+ const pricing = need(c, path, v, inputs);
196
+ if (pricing)
197
+ needs[slug] = pricing;
198
+ }
199
+ }
200
+ if (c.problems.length > 0)
201
+ return { problems: c.problems, model: null };
202
+ for (const [slug, pricing] of Object.entries(needs))
203
+ if (pricing.kind === "estimate")
204
+ dearest(c, `model.needs.${slug}`, pricing, inputs);
205
+ if (c.problems.length > 0)
206
+ return { problems: c.problems, model: null };
207
+ return {
208
+ problems: [],
209
+ model: { format: PRICING_FORMAT, currency: PRICING_CURRENCY, validFrom: String(value.validFrom), roundToCents, minimumCents, inputs, needs },
210
+ };
211
+ }
212
+ /** The model `value` is, rebuilt from what was checked; throws `PricingModelError` with every problem. */
213
+ export function parsePricingModel(value) {
214
+ const { problems, model } = check(value);
215
+ if (!model)
216
+ throw new PricingModelError(problems);
217
+ return model;
218
+ }
219
+ /**
220
+ * A model a site can show: valid, and labelled in every one of its locales —
221
+ * the panel may serve a model before a language is filled in, and a page
222
+ * must not show an option with no words.
223
+ */
224
+ export function pricingProblemsFor(value, locales) {
225
+ const { problems, model } = check(value);
226
+ if (!model)
227
+ return problems;
228
+ const out = [];
229
+ const labelled = (path, labels) => {
230
+ for (const locale of locales)
231
+ if (!Object.hasOwn(labels, locale))
232
+ out.push(`${path}.labels: no "${locale}" label`);
233
+ };
234
+ for (const [i, inp] of model.inputs.entries()) {
235
+ labelled(`model.inputs[${i}]`, inp.labels);
236
+ for (const [j, o] of inp.options.entries())
237
+ labelled(`model.inputs[${i}].options[${j}]`, o.labels);
238
+ }
239
+ return out;
240
+ }
241
+ /** The label for `locale`, or the first one the model has. */
242
+ export function labelOf(labels, locale) {
243
+ return (Object.hasOwn(labels, locale) ? labels[locale] : undefined) ?? Object.values(labels)[0] ?? "";
244
+ }
@@ -66,8 +66,9 @@ export declare function parseGoneHeader(value: string | null): {
66
66
  };
67
67
  /**
68
68
  * The routes every landing has outside `[locale]`, which pass untouched: the
69
- * form target, the OG card, the probe, the sitemap and robots. Anything else
70
- * without a language but `/_next/…` and the site's `publicFiles` is `gone`.
69
+ * form target, its booking request (`bookingRoute`) and its price
70
+ * confirmation (`confirmRoute`), the OG card, the probe, the sitemap and robots. Anything else without a language but
71
+ * `/_next/…` and the site's `publicFiles` is `gone`.
71
72
  */
72
73
  export declare const NON_PAGE_ROUTES: readonly string[];
73
74
  /** @deprecated The same list as {@link NON_PAGE_ROUTES}, under its old name. */
@@ -70,10 +70,11 @@ export function parseGoneHeader(value) {
70
70
  }
71
71
  /**
72
72
  * The routes every landing has outside `[locale]`, which pass untouched: the
73
- * form target, the OG card, the probe, the sitemap and robots. Anything else
74
- * without a language but `/_next/…` and the site's `publicFiles` is `gone`.
73
+ * form target, its booking request (`bookingRoute`) and its price
74
+ * confirmation (`confirmRoute`), the OG card, the probe, the sitemap and robots. Anything else without a language but
75
+ * `/_next/…` and the site's `publicFiles` is `gone`.
75
76
  */
76
- export const NON_PAGE_ROUTES = ["/quote", "/og", "/health", "/sitemap.xml", "/robots.txt"];
77
+ export const NON_PAGE_ROUTES = ["/quote", "/quote/booking", "/quote/confirm", "/og", "/health", "/sitemap.xml", "/robots.txt"];
77
78
  /** @deprecated The same list as {@link NON_PAGE_ROUTES}, under its old name. */
78
79
  export const PASS_PATHS = NON_PAGE_ROUTES;
79
80
  function isInfrastructure(pathname, publicFiles) {
@@ -1,7 +1,9 @@
1
1
  import type { LocaleRegistry } from "@evinvest/i18n";
2
+ import type { BookingRules } from "./booking/model.js";
2
3
  import type { LeadSchema } from "./lead.js";
3
4
  import type { PublicationPolicy } from "./place/publication.js";
4
5
  import type { Place } from "./place/types.js";
6
+ import type { PricingModel } from "./pricing/model.js";
5
7
  /**
6
8
  * The composition root of a landing site: every brand fact the machinery reads
7
9
  * — routing, the lead funnel, schema.org, analytics, mail — arrives through one
@@ -62,6 +64,19 @@ export interface SiteConfig<L extends string, P extends string> {
62
64
  publication: PublicationPolicy;
63
65
  /** What the quote form asks and what a lead must have. */
64
66
  lead: LeadSchema<string>;
67
+ /**
68
+ * The baked price list: what `estimate` and `fixed` needs (`lead.flows`)
69
+ * are priced from when the live source (`createPricingSource`) is absent,
70
+ * down, or serves nothing valid. Absent → every need is a `quote` then.
71
+ */
72
+ pricing?: PricingModel;
73
+ /**
74
+ * The booking rules a place's `booking` is held to, baked or live:
75
+ * `calComHosts` are the Cal.com hosts a `cal_com` page may be on — the
76
+ * hosted cloud by default (`DEFAULT_CAL_COM_HOSTS`); a brand on its own
77
+ * Cal.com lists both, `["cal.com", "cal.brand.fr"]`.
78
+ */
79
+ booking?: BookingRules;
65
80
  legacyRedirects?: readonly LegacyRedirect<L>[];
66
81
  /**
67
82
  * Paths the brand serves as files besides the routes every landing has
package/dist/core/site.js CHANGED
@@ -1,3 +1,5 @@
1
+ import { bookingConfigProblems, calComHostsProblems } from "./booking/validate.js";
2
+ import { pricingProblemsFor } from "./pricing/validate.js";
1
3
  export function defineSite(config) {
2
4
  const pageKeys = Object.keys(config.pages).filter((k) => Object.hasOwn(config.pages, k));
3
5
  if (!pageKeys.includes("home"))
@@ -28,6 +30,30 @@ export function defineSite(config) {
28
30
  if (config.i18n.isLocale(file.split("/")[1] ?? ""))
29
31
  throw new Error(`defineSite: public file "${file}" must not sit under a language`);
30
32
  }
33
+ if (config.pricing) {
34
+ // Baked into the build: a model the live source would refuse must not ship.
35
+ const problems = pricingProblemsFor(config.pricing, config.i18n.locales);
36
+ if (problems.length > 0)
37
+ throw new Error(`defineSite: pricing: ${problems.join("; ")}`);
38
+ }
39
+ const calComHosts = config.booking?.calComHosts;
40
+ if (calComHosts) {
41
+ const problems = calComHostsProblems(calComHosts);
42
+ if (problems.length > 0)
43
+ throw new Error(`defineSite: booking.${problems.join("; ")}`);
44
+ }
45
+ for (const place of config.places) {
46
+ // Baked into the build: a booking the live source would refuse must not ship.
47
+ if (place.booking === undefined)
48
+ continue;
49
+ const problems = bookingConfigProblems(place.booking, config.booking ?? {});
50
+ if (problems.length > 0)
51
+ throw new Error(`defineSite: place "${place.slug}": ${problems.join("; ")}`);
52
+ }
53
+ for (const need of Object.keys(config.lead.flows ?? {})) {
54
+ if (!config.lead.subjects.includes(need))
55
+ throw new Error(`defineSite: lead.flows names "${need}", which is not one of lead.subjects`);
56
+ }
31
57
  if (config.topology.kind === "single" && !placeSlugs.includes(config.topology.place)) {
32
58
  throw new Error(`defineSite: topology.place "${config.topology.place}" is not one of the places`);
33
59
  }
package/dist/index.d.ts CHANGED
@@ -6,17 +6,19 @@
6
6
  export { assertLaunchable, bakedPlace, cardFact, contactOf, defineSite, ogLocaleOf, openLaunchBlockers, perLocale, type BrandFacts, type LegacyRedirect, type OwnerTodo, type PageSuffix, type Site, type SiteConfig, type Topology, } from "./core/site.js";
7
7
  export { createPlaceView, DEFAULT_TIME_ZONE, freshRating, isOpenAt, nextOpening, parseInstant, isPublished, mergeLive, parsePlaceLive, placeHref, placeOrigin, placeUrl, publicationGaps, RATING_MAX_AGE_DAYS, servedLocalities, SERVICE_AREA_GATE, siteOrigin, STOREFRONT_GATE, storefrontOf, type DayOfWeek, type Geo, type LinkBase, type LinkMode, type Opening, type OpeningHours, type OriginFacts, type Place, type PlaceLive, type PlaceView, type PostalAddress, type Presence, type PublicationField, type PublicationPolicy, type Rating, type ServiceArea, } from "./core/place/index.js";
8
8
  export { createRouting, GONE, GONE_HEADER, gonePath, goneHeader, HOST_MARK, LANG_COOKIE, LANG_COOKIE_MAX_AGE, NON_PAGE_ROUTES, parseGoneHeader, parsePlaceParam, PASS_PATHS, placeParam, pointSuffixes, THANKS, type Decision, type RequestFacts, type Routing, } from "./core/routing.js";
9
- export { CARD_FIELD, CARD_ID, CHANNEL_FIELD, channelOf, CONSENT_FIELD, LEAD_CHANNELS, LEAD_ERROR_PARAM, LEAD_SCHEMA_VERSION, MAX_CONSENT, MAX_FIELD, readCandidate, rejectionOf, suspectOf, SUBMISSION_FIELD, SUBMISSION_ID, validateCallbackLead, validateCandidate, validateLead, type Lead, type LeadCandidate, type LeadChannel, type LeadConsent, type LeadExtra, type LeadRejection, type LeadSchema, type LeadStore, type LeadSuspect, type LeadVerdict, type LeadWire, type SpamVerdict, } from "./core/lead.js";
9
+ export { CARD_FIELD, CARD_ID, CHANNEL_FIELD, channelOf, CONSENT_FIELD, LEAD_CHANNELS, LEAD_ERROR_PARAM, LEAD_CARD_PARAM, leadErrorFor, leadErrorOf, LEAD_SCHEMA_VERSION, MAX_CONSENT, MAX_FIELD, readCandidate, rejectionOf, suspectOf, SUBMISSION_FIELD, SUBMISSION_ID, validateCallbackLead, validateCandidate, validateLead, type Lead, type LeadCandidate, type LeadChannel, type LeadError, type PageLeadError, type LeadConsent, type LeadExtra, type LeadPrice, type LeadRejection, type LeadSchema, type LeadStore, type LeadSuspect, type LeadVerdict, type LeadWire, type SpamVerdict, } from "./core/lead.js";
10
10
  export { isMobilePhone, isPlausiblePhone, normalizePhone, phoneProblem } from "./core/phone.js";
11
- export { LEAD_CAPTURE_TEXT, type LeadCaptureText } from "./core/lead-capture-text.js";
12
- export { fillText, openingText } from "./core/lead-capture-format.js";
11
+ export { CENTS_BUCKETS, centsBucket, ESTIMATE_FIELD_PREFIX, estimateField, flowOf, labelOf, LEAD_FLOWS, mulBp, parsePricingModel, priceOf, PRICING_CURRENCY, PRICING_FORMAT, PRICING_LIMITS, PricingModelError, pricingProblems, pricingProblemsFor, readEstimateInputs, roundTo, type AddOption, type DiscountOption, type LeadFlow, type LeadFlows, type MultiplyOption, type NeedPricing, type Price, type PriceLine, type PricingInput, type PricingInputKind, type PricingInputs, type PricingLabels, type PricingModel, } from "./core/pricing/index.js";
12
+ export { flowTextOf, LEAD_CAPTURE_TEXT, type LeadCaptureFlowText, type LeadCaptureText } from "./core/lead-capture-text.js";
13
+ export { fillText, formatCents, openingText } from "./core/lead-capture-format.js";
13
14
  export { channelHref, resolveChannels, smsHref, type CaptureChannel, type ChannelFacts, type ChannelOptions, type ResolvedChannels, } from "./core/channels.js";
14
15
  export { checkTiming, HONEYPOT_FIELD, LEGACY_HONEYPOT_FIELDS, MIN_FILL_MS, RATE_LIMIT_MAX_KEYS, RateLimiter, RENDERED_AT_FIELD, screen, type Screening, } from "./core/antispam.js";
15
- export { createAcceptLead, EXPERIMENT_FIELD, FORM_ID_FIELD, LOCALE_FIELD, LOCATION_FIELD, VARIANT_FIELD, type AcceptDeps, type Outcome, type SubmitTags, } from "./core/accept.js";
16
+ export { createAcceptLead, EXPERIMENT_FIELD, FORM_ID_FIELD, leadRef, LOCALE_FIELD, LOCATION_FIELD, PRICE_CHANGED, SHOWN_CENTS_FIELD, VARIANT_FIELD, type AcceptDeps, type Outcome, type SubmitTags, } from "./core/accept.js";
16
17
  export { areaServedNodes, breadcrumbNode, businessId, businessNode, faqPageNode, offerNodes, organizationId, organizationNode, placeGraph, websiteId, type OfferInput, type PageGraphCopy, type QuestionAnswer, } from "./core/seo/ld.js";
17
18
  export { AI_CRAWLERS, ogImageUrl, robotsFor, sitemapFor, type RobotsFile, type RobotsRule, type SitemapEntry, } from "./core/seo/sitemap.js";
18
19
  export { ALLOWED_PROPS, analyticsSink, countsAsPageView, EVENTS, EXPERIMENT_SLUG, experimentProps, type AnalyticsTarget, type IntentChannel, type LeadField, type LeadStep, } from "./core/analytics.js";
19
20
  export type { CallBarText, CopySlice, CoreText, PageMetaCopy, QuoteFormCopy, Said, StatusAction, StatusCopy, StatusScreenText, } from "./core/content.js";
21
+ export { BOOKING_LIMITS, BOOKING_PROVIDERS, BOOKING_STATUSES, BookingConfigError, bookingConfigProblems, BOOKING_EXPERIMENT, bookingHref, bookingOf, bookingRequestedProperties, bookingRequestProblems, calComHostsProblems, DEFAULT_BOOKING, DEFAULT_CAL_COM_HOSTS, isBookingProvider, isLeadRef, isPageProvider, isPreferredPart, parseBookingConfig, parseBookingRequest, PREFERRED_PARTS, type BookingChoice, type BookingConfig, type BookingPrefill, type BookingProvider, type BookingRequest, type BookingRequestedProperties, type BookingRules, type BookingStatus, type OpenBookingConfig, type PageProvider, type PreferredPart, } from "./core/booking/index.js";
20
22
  export { brandStatusTarget, statusTarget, thanksChannel, thanksSuffix, type StatusTarget } from "./core/status.js";
21
23
  export { createLocaleRegistry, type LocaleRegistry } from "@evinvest/i18n";
22
24
  export { contactChannel, ldCompact, localBusiness, telHref, whatsappHref, type ContactChannel, type JsonLdNode, } from "@evinvest/marketing";
package/dist/index.js CHANGED
@@ -6,16 +6,18 @@
6
6
  export { assertLaunchable, bakedPlace, cardFact, contactOf, defineSite, ogLocaleOf, openLaunchBlockers, perLocale, } from "./core/site.js";
7
7
  export { createPlaceView, DEFAULT_TIME_ZONE, freshRating, isOpenAt, nextOpening, parseInstant, isPublished, mergeLive, parsePlaceLive, placeHref, placeOrigin, placeUrl, publicationGaps, RATING_MAX_AGE_DAYS, servedLocalities, SERVICE_AREA_GATE, siteOrigin, STOREFRONT_GATE, storefrontOf, } from "./core/place/index.js";
8
8
  export { createRouting, GONE, GONE_HEADER, gonePath, goneHeader, HOST_MARK, LANG_COOKIE, LANG_COOKIE_MAX_AGE, NON_PAGE_ROUTES, parseGoneHeader, parsePlaceParam, PASS_PATHS, placeParam, pointSuffixes, THANKS, } from "./core/routing.js";
9
- export { CARD_FIELD, CARD_ID, CHANNEL_FIELD, channelOf, CONSENT_FIELD, LEAD_CHANNELS, LEAD_ERROR_PARAM, LEAD_SCHEMA_VERSION, MAX_CONSENT, MAX_FIELD, readCandidate, rejectionOf, suspectOf, SUBMISSION_FIELD, SUBMISSION_ID, validateCallbackLead, validateCandidate, validateLead, } from "./core/lead.js";
9
+ export { CARD_FIELD, CARD_ID, CHANNEL_FIELD, channelOf, CONSENT_FIELD, LEAD_CHANNELS, LEAD_ERROR_PARAM, LEAD_CARD_PARAM, leadErrorFor, leadErrorOf, LEAD_SCHEMA_VERSION, MAX_CONSENT, MAX_FIELD, readCandidate, rejectionOf, suspectOf, SUBMISSION_FIELD, SUBMISSION_ID, validateCallbackLead, validateCandidate, validateLead, } from "./core/lead.js";
10
10
  export { isMobilePhone, isPlausiblePhone, normalizePhone, phoneProblem } from "./core/phone.js";
11
- export { LEAD_CAPTURE_TEXT } from "./core/lead-capture-text.js";
12
- export { fillText, openingText } from "./core/lead-capture-format.js";
11
+ export { CENTS_BUCKETS, centsBucket, ESTIMATE_FIELD_PREFIX, estimateField, flowOf, labelOf, LEAD_FLOWS, mulBp, parsePricingModel, priceOf, PRICING_CURRENCY, PRICING_FORMAT, PRICING_LIMITS, PricingModelError, pricingProblems, pricingProblemsFor, readEstimateInputs, roundTo, } from "./core/pricing/index.js";
12
+ export { flowTextOf, LEAD_CAPTURE_TEXT } from "./core/lead-capture-text.js";
13
+ export { fillText, formatCents, openingText } from "./core/lead-capture-format.js";
13
14
  export { channelHref, resolveChannels, smsHref, } from "./core/channels.js";
14
15
  export { checkTiming, HONEYPOT_FIELD, LEGACY_HONEYPOT_FIELDS, MIN_FILL_MS, RATE_LIMIT_MAX_KEYS, RateLimiter, RENDERED_AT_FIELD, screen, } from "./core/antispam.js";
15
- export { createAcceptLead, EXPERIMENT_FIELD, FORM_ID_FIELD, LOCALE_FIELD, LOCATION_FIELD, VARIANT_FIELD, } from "./core/accept.js";
16
+ export { createAcceptLead, EXPERIMENT_FIELD, FORM_ID_FIELD, leadRef, LOCALE_FIELD, LOCATION_FIELD, PRICE_CHANGED, SHOWN_CENTS_FIELD, VARIANT_FIELD, } from "./core/accept.js";
16
17
  export { areaServedNodes, breadcrumbNode, businessId, businessNode, faqPageNode, offerNodes, organizationId, organizationNode, placeGraph, websiteId, } from "./core/seo/ld.js";
17
18
  export { AI_CRAWLERS, ogImageUrl, robotsFor, sitemapFor, } from "./core/seo/sitemap.js";
18
19
  export { ALLOWED_PROPS, analyticsSink, countsAsPageView, EVENTS, EXPERIMENT_SLUG, experimentProps, } from "./core/analytics.js";
20
+ export { BOOKING_LIMITS, BOOKING_PROVIDERS, BOOKING_STATUSES, BookingConfigError, bookingConfigProblems, BOOKING_EXPERIMENT, bookingHref, bookingOf, bookingRequestedProperties, bookingRequestProblems, calComHostsProblems, DEFAULT_BOOKING, DEFAULT_CAL_COM_HOSTS, isBookingProvider, isLeadRef, isPageProvider, isPreferredPart, parseBookingConfig, parseBookingRequest, PREFERRED_PARTS, } from "./core/booking/index.js";
19
21
  export { brandStatusTarget, statusTarget, thanksChannel, thanksSuffix } from "./core/status.js";
20
22
  // A landing needs these beside the machinery; re-exported by name so a brand
21
23
  // imports one package and the list here is the whole surface.
@@ -0,0 +1,31 @@
1
+ import type { LeadStore } from "../core/lead.js";
2
+ import type { ServerEnv } from "../server/env.js";
3
+ import type { LeadWebhook } from "../server/lead-webhook.js";
4
+ /**
5
+ * `app/quote/booking/route.ts` — the page's word that a priced lead asked for
6
+ * a slot: `booking.requested@1` to the panel, after the lead's `lead.created`,
7
+ * through the same outbox. `LeadCapture` posts it when the visitor opens a
8
+ * booking page (`link`, `cal_com`) or sends a `manual` preference.
9
+ *
10
+ * ```ts
11
+ * export const dynamic = "force-dynamic";
12
+ * export const POST = bookingRoute({ env: serverEnv, webhook });
13
+ * ```
14
+ *
15
+ * The body is JSON: `booking.requested@1`'s properties (`lead_ref`,
16
+ * `provider`, `preferred_date?`, `preferred_part?`) plus `submission`, the
17
+ * form's submission id. The id is what proves the post comes from the page
18
+ * that sent the lead: it is a random UUID only that page knew, and the lead's
19
+ * reference is derived from it — a guessed `lead_ref` alone books nothing.
20
+ * Answers `200 { ok: true, queued }`; `queued` is `false` when the switch is
21
+ * off (`panelBooking`) or the lead already asked.
22
+ */
23
+ export interface BookingRouteDeps {
24
+ env: () => Pick<ServerEnv, "leadsDb">;
25
+ /** The same webhook the quote route queues `lead.created` on. */
26
+ webhook?: () => LeadWebhook | null;
27
+ store?: () => LeadStore;
28
+ now?: () => number;
29
+ log?: Pick<Console, "warn" | "error">;
30
+ }
31
+ export declare function bookingRoute(deps: BookingRouteDeps): (request: Request) => Promise<Response>;