@evinvest/kitstart 0.9.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.
- package/README.md +92 -5
- package/dist/core/accept.d.ts +23 -1
- package/dist/core/accept.js +59 -5
- package/dist/core/analytics.d.ts +2 -1
- package/dist/core/analytics.js +7 -0
- package/dist/core/lead-capture-format.d.ts +6 -0
- package/dist/core/lead-capture-format.js +13 -0
- package/dist/core/lead-capture-text.d.ts +32 -1
- package/dist/core/lead-capture-text.js +43 -0
- package/dist/core/lead.d.ts +32 -3
- package/dist/core/lead.js +3 -2
- package/dist/core/pricing/flow.d.ts +27 -0
- package/dist/core/pricing/flow.js +37 -0
- package/dist/core/pricing/index.d.ts +4 -0
- package/dist/core/pricing/index.js +4 -0
- package/dist/core/pricing/model.d.ts +129 -0
- package/dist/core/pricing/model.js +34 -0
- package/dist/core/pricing/price.d.ts +34 -0
- package/dist/core/pricing/price.js +105 -0
- package/dist/core/pricing/validate.d.ts +27 -0
- package/dist/core/pricing/validate.js +244 -0
- package/dist/core/site.d.ts +7 -0
- package/dist/core/site.js +11 -0
- package/dist/index.d.ts +5 -4
- package/dist/index.js +4 -3
- package/dist/next/quote-route.d.ts +8 -1
- package/dist/next/quote-route.js +3 -1
- package/dist/react/LeadCapture.d.ts +30 -2
- package/dist/react/LeadCapture.js +18 -5
- package/dist/react/LeadCaptureChannels.d.ts +14 -2
- package/dist/react/LeadCaptureChannels.js +13 -0
- package/dist/react/LeadCaptureEstimate.d.ts +46 -0
- package/dist/react/LeadCaptureEstimate.js +69 -0
- package/dist/react/LeadCapturePriced.d.ts +19 -0
- package/dist/react/LeadCapturePriced.js +14 -0
- package/dist/react/use-lead-events.d.ts +2 -0
- package/dist/react/use-lead-events.js +10 -0
- package/dist/react/use-lead-submit.d.ts +7 -3
- package/dist/react/use-lead-submit.js +14 -7
- package/dist/server/index.d.ts +2 -1
- package/dist/server/index.js +2 -1
- package/dist/server/lead-store-sqlite.js +31 -5
- package/dist/server/lead-webhook.d.ts +48 -0
- package/dist/server/lead-webhook.js +30 -2
- package/dist/server/pricing-source.d.ts +25 -0
- package/dist/server/pricing-source.js +48 -0
- package/dist/testing/lead-store-contract.js +9 -2
- package/package.json +1 -1
- package/template/app/[locale]/[location]/page.tsx +4 -2
- package/template/app/quote/route.ts +2 -1
- package/template/flake.nix +1 -1
- package/template/package.json +1 -1
- package/template/src/entities/content/model/en.ts +1 -1
- package/template/src/entities/content/model/fr.ts +1 -1
- package/template/src/shared/config/env.ts +4 -1
- package/template/src/shared/config/lead.ts +12 -2
- package/template/src/shared/config/pricing.ts +49 -0
- package/template/src/shared/config/site.ts +3 -0
- package/template/src/views/home/ui/PlaceHome.tsx +6 -3
- package/template/tests/bundle_budget.txt +3 -1
- package/template/tests/e2e/flows.spec.ts +106 -0
- package/template/tests/e2e/mock-panel.mjs +20 -0
- package/template/tests/e2e/playwright.config.ts +25 -9
|
@@ -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;
|
|
@@ -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
|
+
}
|
package/dist/core/site.d.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type { LocaleRegistry } from "@evinvest/i18n";
|
|
|
2
2
|
import type { LeadSchema } from "./lead.js";
|
|
3
3
|
import type { PublicationPolicy } from "./place/publication.js";
|
|
4
4
|
import type { Place } from "./place/types.js";
|
|
5
|
+
import type { PricingModel } from "./pricing/model.js";
|
|
5
6
|
/**
|
|
6
7
|
* The composition root of a landing site: every brand fact the machinery reads
|
|
7
8
|
* — routing, the lead funnel, schema.org, analytics, mail — arrives through one
|
|
@@ -62,6 +63,12 @@ export interface SiteConfig<L extends string, P extends string> {
|
|
|
62
63
|
publication: PublicationPolicy;
|
|
63
64
|
/** What the quote form asks and what a lead must have. */
|
|
64
65
|
lead: LeadSchema<string>;
|
|
66
|
+
/**
|
|
67
|
+
* The baked price list: what `estimate` and `fixed` needs (`lead.flows`)
|
|
68
|
+
* are priced from when the live source (`createPricingSource`) is absent,
|
|
69
|
+
* down, or serves nothing valid. Absent → every need is a `quote` then.
|
|
70
|
+
*/
|
|
71
|
+
pricing?: PricingModel;
|
|
65
72
|
legacyRedirects?: readonly LegacyRedirect<L>[];
|
|
66
73
|
/**
|
|
67
74
|
* Paths the brand serves as files besides the routes every landing has
|
package/dist/core/site.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { pricingProblemsFor } from "./pricing/validate.js";
|
|
1
2
|
export function defineSite(config) {
|
|
2
3
|
const pageKeys = Object.keys(config.pages).filter((k) => Object.hasOwn(config.pages, k));
|
|
3
4
|
if (!pageKeys.includes("home"))
|
|
@@ -28,6 +29,16 @@ export function defineSite(config) {
|
|
|
28
29
|
if (config.i18n.isLocale(file.split("/")[1] ?? ""))
|
|
29
30
|
throw new Error(`defineSite: public file "${file}" must not sit under a language`);
|
|
30
31
|
}
|
|
32
|
+
if (config.pricing) {
|
|
33
|
+
// Baked into the build: a model the live source would refuse must not ship.
|
|
34
|
+
const problems = pricingProblemsFor(config.pricing, config.i18n.locales);
|
|
35
|
+
if (problems.length > 0)
|
|
36
|
+
throw new Error(`defineSite: pricing: ${problems.join("; ")}`);
|
|
37
|
+
}
|
|
38
|
+
for (const need of Object.keys(config.lead.flows ?? {})) {
|
|
39
|
+
if (!config.lead.subjects.includes(need))
|
|
40
|
+
throw new Error(`defineSite: lead.flows names "${need}", which is not one of lead.subjects`);
|
|
41
|
+
}
|
|
31
42
|
if (config.topology.kind === "single" && !placeSlugs.includes(config.topology.place)) {
|
|
32
43
|
throw new Error(`defineSite: topology.place "${config.topology.place}" is not one of the places`);
|
|
33
44
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -6,13 +6,14 @@
|
|
|
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_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 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 {
|
|
12
|
-
export {
|
|
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, 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";
|
package/dist/index.js
CHANGED
|
@@ -8,11 +8,12 @@ export { createPlaceView, DEFAULT_TIME_ZONE, freshRating, isOpenAt, nextOpening,
|
|
|
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
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";
|
|
10
10
|
export { isMobilePhone, isPlausiblePhone, normalizePhone, phoneProblem } from "./core/phone.js";
|
|
11
|
-
export {
|
|
12
|
-
export {
|
|
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, 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";
|