@evinvest/kitstart 0.8.0 → 0.10.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 (94) hide show
  1. package/README.md +178 -25
  2. package/dist/cli/kitstart-outbox.d.ts +2 -0
  3. package/dist/cli/kitstart-outbox.js +10 -0
  4. package/dist/cli/outbox.d.ts +5 -0
  5. package/dist/cli/outbox.js +77 -0
  6. package/dist/core/accept.d.ts +44 -3
  7. package/dist/core/accept.js +96 -15
  8. package/dist/core/analytics.d.ts +4 -1
  9. package/dist/core/analytics.js +14 -0
  10. package/dist/core/lead-capture-format.d.ts +6 -0
  11. package/dist/core/lead-capture-format.js +13 -0
  12. package/dist/core/lead-capture-text.d.ts +56 -2
  13. package/dist/core/lead-capture-text.js +63 -0
  14. package/dist/core/lead.d.ts +101 -11
  15. package/dist/core/lead.js +66 -11
  16. package/dist/core/phone.d.ts +20 -9
  17. package/dist/core/phone.js +43 -19
  18. package/dist/core/pricing/flow.d.ts +27 -0
  19. package/dist/core/pricing/flow.js +37 -0
  20. package/dist/core/pricing/index.d.ts +4 -0
  21. package/dist/core/pricing/index.js +4 -0
  22. package/dist/core/pricing/model.d.ts +129 -0
  23. package/dist/core/pricing/model.js +34 -0
  24. package/dist/core/pricing/price.d.ts +34 -0
  25. package/dist/core/pricing/price.js +105 -0
  26. package/dist/core/pricing/validate.d.ts +27 -0
  27. package/dist/core/pricing/validate.js +244 -0
  28. package/dist/core/site.d.ts +7 -0
  29. package/dist/core/site.js +11 -0
  30. package/dist/core/status.d.ts +13 -0
  31. package/dist/core/status.js +19 -2
  32. package/dist/index.d.ts +7 -6
  33. package/dist/index.js +7 -6
  34. package/dist/next/quote-route.d.ts +22 -2
  35. package/dist/next/quote-route.js +72 -10
  36. package/dist/react/LeadCapture.d.ts +37 -7
  37. package/dist/react/LeadCapture.js +34 -9
  38. package/dist/react/LeadCaptureChannels.d.ts +28 -3
  39. package/dist/react/LeadCaptureChannels.js +21 -5
  40. package/dist/react/LeadCaptureEstimate.d.ts +46 -0
  41. package/dist/react/LeadCaptureEstimate.js +69 -0
  42. package/dist/react/LeadCaptureFields.d.ts +39 -7
  43. package/dist/react/LeadCaptureFields.js +91 -21
  44. package/dist/react/LeadCaptureNeed.d.ts +5 -0
  45. package/dist/react/LeadCaptureNeed.js +38 -2
  46. package/dist/react/LeadCapturePriced.d.ts +19 -0
  47. package/dist/react/LeadCapturePriced.js +14 -0
  48. package/dist/react/LeadCaptureSubmit.d.ts +16 -0
  49. package/dist/react/LeadCaptureSubmit.js +13 -0
  50. package/dist/react/QuoteFormShell.d.ts +2 -0
  51. package/dist/react/QuoteFormShell.js +3 -2
  52. package/dist/react/use-lead-error.d.ts +24 -0
  53. package/dist/react/use-lead-error.js +73 -0
  54. package/dist/react/use-lead-events.d.ts +5 -0
  55. package/dist/react/use-lead-events.js +13 -2
  56. package/dist/react/use-lead-submit.d.ts +48 -9
  57. package/dist/react/use-lead-submit.js +129 -13
  58. package/dist/react/use-validity.d.ts +17 -0
  59. package/dist/react/use-validity.js +38 -0
  60. package/dist/server/env.d.ts +9 -0
  61. package/dist/server/env.js +12 -0
  62. package/dist/server/index.d.ts +3 -2
  63. package/dist/server/index.js +3 -2
  64. package/dist/server/lead-store-sqlite.d.ts +5 -1
  65. package/dist/server/lead-store-sqlite.js +63 -2
  66. package/dist/server/lead-webhook.d.ts +68 -1
  67. package/dist/server/lead-webhook.js +34 -2
  68. package/dist/server/pricing-source.d.ts +25 -0
  69. package/dist/server/pricing-source.js +48 -0
  70. package/dist/server/webhook-outbox.d.ts +27 -1
  71. package/dist/server/webhook-outbox.js +33 -6
  72. package/dist/testing/index.d.ts +1 -0
  73. package/dist/testing/index.js +1 -0
  74. package/dist/testing/landing-contract.js +6 -0
  75. package/dist/testing/lead-rules.d.ts +18 -0
  76. package/dist/testing/lead-rules.js +50 -0
  77. package/dist/testing/lead-store-contract.js +28 -1
  78. package/package.json +3 -2
  79. package/template/app/[locale]/[location]/page.tsx +4 -2
  80. package/template/app/quote/route.ts +2 -1
  81. package/template/flake.nix +1 -1
  82. package/template/package.json +1 -1
  83. package/template/src/entities/content/model/en.ts +1 -1
  84. package/template/src/entities/content/model/fr.ts +1 -1
  85. package/template/src/shared/config/env.ts +4 -1
  86. package/template/src/shared/config/lead.ts +17 -4
  87. package/template/src/shared/config/pricing.ts +49 -0
  88. package/template/src/shared/config/site.ts +3 -0
  89. package/template/src/views/home/ui/PlaceHome.tsx +6 -3
  90. package/template/tests/bundle_budget.txt +3 -1
  91. package/template/tests/e2e/flows.spec.ts +106 -0
  92. package/template/tests/e2e/mock-panel.mjs +20 -0
  93. package/template/tests/e2e/playwright.config.ts +25 -9
  94. package/template/tests/e2e/quote-form.spec.ts +86 -1
@@ -1,35 +1,59 @@
1
1
  /**
2
- * A phone number as the funnel keeps it: E.164 when the typed value is one we
3
- * can read, otherwise exactly as typed. Soft by design — a number we cannot
4
- * parse is still a way to reach someone, and refusing it loses the customer.
2
+ * A phone number as the funnel reads it: E.164 when the typed value is a
3
+ * number someone can be reached on, otherwise `null`.
5
4
  *
6
- * Only the French national plan is read (every brand of the vertical is in
7
- * France): `06 12 34 56 78`, `+33 (0)6 …`, `0033 6 …` and a mobile typed
8
- * without its trunk zero (`6 12 34 56 78`) all become `+33612345678`. Any
9
- * other international number is compacted (`+44 7911 123456` →
10
- * `+447911123456`); anything else is `null`.
5
+ * Only the French national plan is read closely (every brand of the vertical
6
+ * is in France): ten digits from `0[1-9]` — `06 12 34 56 78`, `+33 (0)6 …`,
7
+ * `0033 6 …`, a mobile typed without its trunk zero (`6 12 34 56 78`) — all
8
+ * become `+33612345678`. Any other international number is a plausible E.164,
9
+ * 8 to 15 digits after the `+`, compacted (`+44 7911 123456` →
10
+ * `+447911123456`). Full-width digits read as digits; a run of one digit
11
+ * (`00 00 00 00 00`, `06 66 66 66 66`) is a refusal to give a number, not one.
11
12
  */
12
13
  export function normalizePhone(raw) {
13
- let s = raw.replace(/\(0\)/g, "").replace(/[\s.\-()/ ]/g, "");
14
+ // NFKC: a Japanese or Chinese keyboard types full-width digits and `+`,
15
+ // and a pasted number may carry no-break spaces. `\s` takes those too.
16
+ let s = raw.normalize("NFKC").replace(/\(0\)/g, "").replace(/[\s.\-()/]/g, "");
14
17
  if (s.startsWith("00"))
15
18
  s = `+${s.slice(2)}`;
19
+ let e164 = null;
16
20
  if (s.startsWith("+33")) {
17
21
  const national = /^\+330?([1-9]\d{8})$/.exec(s);
18
- return national ? `+33${national[1]}` : null;
22
+ e164 = national ? `+33${national[1]}` : null;
19
23
  }
20
- const trunk = /^0([1-9]\d{8})$/.exec(s);
21
- if (trunk)
22
- return `+33${trunk[1]}`;
23
- if (/^[1-9]\d{8}$/.test(s))
24
- return `+33${s}`;
25
- if (/^\+[1-9]\d{7,14}$/.test(s))
26
- return s;
27
- return null;
24
+ else {
25
+ const trunk = /^0([1-9]\d{8})$/.exec(s);
26
+ if (trunk)
27
+ e164 = `+33${trunk[1]}`;
28
+ else if (/^[1-9]\d{8}$/.test(s))
29
+ e164 = `+33${s}`;
30
+ else if (/^\+[1-9]\d{7,14}$/.test(s))
31
+ e164 = s;
32
+ }
33
+ if (e164 === null)
34
+ return null;
35
+ // The subscriber's digits: past `+33` for a French number, past `+` otherwise.
36
+ const subscriber = e164.startsWith("+33") ? e164.slice(3) : e164.slice(1);
37
+ return /^(\d)\1+$/.test(subscriber) ? null : e164;
28
38
  }
29
- /** Whether a typed number reads as one — the form's soft check, never a block. */
39
+ /**
40
+ * Whether a typed number is one we can call back — the one rule the form,
41
+ * the callback and the server share (`phoneProblem`, `validateLead`).
42
+ */
30
43
  export function isPlausiblePhone(raw) {
31
44
  return normalizePhone(raw) !== null;
32
45
  }
46
+ /**
47
+ * What is wrong with a typed number, or `null`: `required` when nothing was
48
+ * typed, `invalid` when it is not a number we can call. The form blocks on it
49
+ * in the page's words and the server refuses on it — one rule, so a number
50
+ * the form lets through is never refused behind the visitor's back.
51
+ */
52
+ export function phoneProblem(raw) {
53
+ if (raw.trim() === "")
54
+ return "required";
55
+ return isPlausiblePhone(raw) ? null : "invalid";
56
+ }
33
57
  /**
34
58
  * A French mobile (`+33 6…`, `+33 7…`) — the one kind an SMS reaches. A
35
59
  * landline answers a text with silence, so the channel is not offered for it.
@@ -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
+ };
@@ -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;