@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
@@ -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
+ }
@@ -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
  }
@@ -1,3 +1,4 @@
1
+ import { type LeadChannel } from "./lead.js";
1
2
  import type { Place } from "./place/types.js";
2
3
  import { type Site } from "./site.js";
3
4
  /**
@@ -16,6 +17,16 @@ export interface StatusTarget<L extends string> {
16
17
  retry: string;
17
18
  langHrefs: Record<L, string>;
18
19
  }
20
+ /**
21
+ * The thanks page's suffix: a callback's carries `?channel=callback`, so the
22
+ * page can promise a call instead of the quote's SMS.
23
+ */
24
+ export declare function thanksSuffix(channel?: LeadChannel): string;
25
+ /**
26
+ * Which lead the thanks page thanks for, from its query — a page's
27
+ * `searchParams` or a `URLSearchParams`. Anything but `callback` is `form`.
28
+ */
29
+ export declare function thanksChannel(search: URLSearchParams | Readonly<Record<string, string | string[] | undefined>>): LeadChannel;
19
30
  /**
20
31
  * The brand's own status target from two facts — for a client boundary
21
32
  * (`error.tsx`, the `notFound()` boundary), which must not import the site
@@ -27,6 +38,7 @@ export declare function brandStatusTarget<L extends string>(facts: {
27
38
  }, locale: L, options?: {
28
39
  retry?: string;
29
40
  thanks?: boolean;
41
+ channel?: LeadChannel;
30
42
  }): StatusTarget<L>;
31
43
  /**
32
44
  * Server side — the global not-found page, a thank-you page: the target for
@@ -44,4 +56,5 @@ export declare function statusTarget<L extends string, P extends string>(site: S
44
56
  }, options?: {
45
57
  retry?: string;
46
58
  thanks?: boolean;
59
+ channel?: LeadChannel;
47
60
  }): StatusTarget<L>;
@@ -1,6 +1,23 @@
1
+ import { CHANNEL_FIELD } from "./lead.js";
1
2
  import { createPlaceView } from "./place/view.js";
2
3
  import { parsePlaceParam, THANKS } from "./routing.js";
3
4
  import { bakedPlace, contactOf, perLocale } from "./site.js";
5
+ /**
6
+ * The thanks page's suffix: a callback's carries `?channel=callback`, so the
7
+ * page can promise a call instead of the quote's SMS.
8
+ */
9
+ export function thanksSuffix(channel = "form") {
10
+ return channel === "callback" ? `${THANKS}?${CHANNEL_FIELD}=callback` : THANKS;
11
+ }
12
+ /**
13
+ * Which lead the thanks page thanks for, from its query — a page's
14
+ * `searchParams` or a `URLSearchParams`. Anything but `callback` is `form`.
15
+ */
16
+ export function thanksChannel(search) {
17
+ const raw = search instanceof URLSearchParams ? search.get(CHANNEL_FIELD) : search[CHANNEL_FIELD];
18
+ const value = Array.isArray(raw) ? raw[0] : raw;
19
+ return value === "callback" ? "callback" : "form";
20
+ }
4
21
  function complete(r, locales) {
5
22
  return locales.every(l => typeof r[l] === "string");
6
23
  }
@@ -10,7 +27,7 @@ function complete(r, locales) {
10
27
  * config: it would carry every place into every page's bundle.
11
28
  */
12
29
  export function brandStatusTarget(facts, locale, options = {}) {
13
- const suffix = options.thanks ? THANKS : "";
30
+ const suffix = options.thanks ? thanksSuffix(options.channel) : "";
14
31
  const langHrefs = {};
15
32
  for (const l of facts.locales)
16
33
  langHrefs[l] = `/${l}${suffix}`;
@@ -37,7 +54,7 @@ export function brandStatusTarget(facts, locale, options = {}) {
37
54
  */
38
55
  export function statusTarget(site, params, options = {}) {
39
56
  const locale = typeof params.locale === "string" && site.i18n.isLocale(params.locale) ? params.locale : site.i18n.defaultLocale;
40
- const suffix = options.thanks ? THANKS : "";
57
+ const suffix = options.thanks ? thanksSuffix(options.channel) : "";
41
58
  const param = typeof params.location === "string" ? parsePlaceParam(params.location) : null;
42
59
  const place = param ? (bakedPlace(site, param.slug) ?? null) : null;
43
60
  if (place && param) {
package/dist/index.d.ts CHANGED
@@ -6,18 +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 { CHANNEL_FIELD, channelOf, CONSENT_FIELD, LEAD_CHANNELS, LEAD_SCHEMA_VERSION, MAX_CONSENT, MAX_FIELD, readCandidate, validateCallbackLead, validateCandidate, type Lead, type LeadCandidate, type LeadChannel, type LeadConsent, type LeadExtra, type LeadSchema, type LeadStore, type LeadWire, type SpamVerdict, } from "./core/lead.js";
10
- export { isMobilePhone, isPlausiblePhone, normalizePhone } 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";
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
+ export { isMobilePhone, isPlausiblePhone, normalizePhone, phoneProblem } from "./core/phone.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, 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";
20
- export { brandStatusTarget, statusTarget, type StatusTarget } from "./core/status.js";
21
+ export { brandStatusTarget, statusTarget, thanksChannel, thanksSuffix, type StatusTarget } from "./core/status.js";
21
22
  export { createLocaleRegistry, type LocaleRegistry } from "@evinvest/i18n";
22
23
  export { contactChannel, ldCompact, localBusiness, telHref, whatsappHref, type ContactChannel, type JsonLdNode, } from "@evinvest/marketing";
23
24
  export { createBeaconSink, noopSink, type AnalyticsSink } from "@evinvest/analytics";
package/dist/index.js CHANGED
@@ -6,17 +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 { CHANNEL_FIELD, channelOf, CONSENT_FIELD, LEAD_CHANNELS, LEAD_SCHEMA_VERSION, MAX_CONSENT, MAX_FIELD, readCandidate, validateCallbackLead, validateCandidate, } from "./core/lead.js";
10
- export { isMobilePhone, isPlausiblePhone, normalizePhone } 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";
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
+ export { isMobilePhone, isPlausiblePhone, normalizePhone, phoneProblem } from "./core/phone.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, 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";
19
- export { brandStatusTarget, statusTarget } from "./core/status.js";
20
+ export { brandStatusTarget, statusTarget, thanksChannel, thanksSuffix } from "./core/status.js";
20
21
  // A landing needs these beside the machinery; re-exported by name so a brand
21
22
  // imports one package and the list here is the whole surface.
22
23
  export { createLocaleRegistry } from "@evinvest/i18n";
@@ -5,13 +5,19 @@ import { type Site } from "../core/site.js";
5
5
  import type { ServerEnv } from "../server/env.js";
6
6
  import type { LeadWebhook } from "../server/lead-webhook.js";
7
7
  import type { LeadNotifier } from "../server/notify.js";
8
+ import type { PricingSource } from "../server/pricing-source.js";
8
9
  /**
9
10
  * `app/quote/route.ts` — the no-JS path, and the one that has to keep working:
10
11
  * a plain form POST answered with a 303, so a refresh does not resubmit.
12
+ * A refused lead goes back to its card with the field to fix
13
+ * (`/fr?lead_error=phone#devis`); a script asking for JSON (`Accept:
14
+ * application/json`) is answered `422 { ok: false, field }` instead, and
15
+ * `200 { ok: true, location, lead, cents? }` for a lead taken — `lead` its
16
+ * public reference (`leadRef`), `cents` the price the server took it at.
11
17
  *
12
18
  * ```ts
13
19
  * export const dynamic = "force-dynamic";
14
- * export const POST = quoteRoute(site, { env: serverEnv, notifier, webhook, unavailable });
20
+ * export const POST = quoteRoute(site, { env: serverEnv, notifier, webhook, unavailable, anchor: "devis" });
15
21
  * ```
16
22
  */
17
23
  export interface UnavailableCopy {
@@ -21,7 +27,8 @@ export interface UnavailableCopy {
21
27
  callLabel: string;
22
28
  }
23
29
  export interface QuoteRouteDeps<L extends string> {
24
- env: () => Pick<ServerEnv, "leadsDb" | "posthogKey" | "posthogHost" | "trustedProxy">;
30
+ /** `production` and `leadRateLimit` may be left out (a test's env): production's limit, no development warnings. */
31
+ env: () => Pick<ServerEnv, "leadsDb" | "posthogKey" | "posthogHost" | "trustedProxy"> & Partial<Pick<ServerEnv, "production" | "leadRateLimit">>;
25
32
  /** Built once (and at boot, by the brand) so a missing sender fails startup, not a lead. */
26
33
  notifier: () => LeadNotifier;
27
34
  /**
@@ -33,9 +40,22 @@ export interface QuoteRouteDeps<L extends string> {
33
40
  webhook?: () => LeadWebhook | null;
34
41
  /** The self-contained 500's words, when the store refused the lead. */
35
42
  unavailable: (locale: L, place: Place<L> | null) => UnavailableCopy;
43
+ /**
44
+ * The price list an `estimate` or `fixed` lead is priced from — the same
45
+ * source the page showed (`createPricingSource`); absent → `site.pricing`.
46
+ */
47
+ pricing?: PricingSource;
36
48
  /** Opened on first use (`next build` imports the route); defaults to `LEADS_DB_URL`'s. */
37
49
  store?: () => LeadStore;
50
+ /** Defaults to `env().leadRateLimit`, built on the first lead. */
38
51
  limiter?: RateLimiter;
52
+ /**
53
+ * The card's id a refused lead is sent back to, when the form did not post
54
+ * its own (`card`, which `LeadCapture` does): `quote` by default, the
55
+ * `LeadCapture` default. A slug, `[a-z0-9-]`; a callback lands on
56
+ * `<anchor>-callback`.
57
+ */
58
+ anchor?: string;
39
59
  /** Work after the response; `after` from `next/server` by default. */
40
60
  defer?: (task: () => Promise<void> | void) => void;
41
61
  now?: () => number;
@@ -1,11 +1,12 @@
1
1
  import { after } from "next/server.js";
2
- import { createAcceptLead } from "../core/accept.js";
3
- import { analyticsSink, EVENTS } from "../core/analytics.js";
2
+ import { createAcceptLead, EXPERIMENT_FIELD, VARIANT_FIELD } from "../core/accept.js";
3
+ import { analyticsSink, EVENTS, experimentProps } from "../core/analytics.js";
4
4
  import { RateLimiter } from "../core/antispam.js";
5
- import { channelOf } from "../core/lead.js";
5
+ import { CARD_FIELD, CARD_ID, channelOf, LEAD_ERROR_PARAM } from "../core/lead.js";
6
6
  import { createPlaceView } from "../core/place/view.js";
7
- import { createRouting, THANKS } from "../core/routing.js";
7
+ import { createRouting } from "../core/routing.js";
8
8
  import { bakedPlace, contactOf } from "../core/site.js";
9
+ import { thanksSuffix } from "../core/status.js";
9
10
  import { clientKey } from "../server/client-key.js";
10
11
  import { openLeadStore } from "../server/lead-store.js";
11
12
  /** The Rust server's body limit; a quote is a few short fields. */
@@ -49,10 +50,22 @@ const escape = (s) => s.replace(/[&<>"']/g, c => ({ "&": "&amp;", "<": "&lt;", "
49
50
  function seeOther(location) {
50
51
  return new Response(null, { status: 303, headers: { Location: location } });
51
52
  }
53
+ function json(status, body) {
54
+ return new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json", "Cache-Control": "no-store" } });
55
+ }
56
+ const text = (form, name) => {
57
+ const value = form.get(name);
58
+ return typeof value === "string" ? value : null;
59
+ };
60
+ /** `LeadCapture`'s own post asks for JSON; a plain form post never does. */
61
+ const wantsJson = (request) => (request.headers.get("accept") ?? "").includes("application/json");
52
62
  export function quoteRoute(site, deps) {
63
+ const anchor = deps.anchor ?? "quote";
64
+ if (!CARD_ID.test(anchor))
65
+ throw new Error(`quoteRoute: the anchor must be a slug ([a-z0-9-]), got ${JSON.stringify(anchor)}`);
53
66
  const accept = createAcceptLead(site);
54
67
  const routing = createRouting(site);
55
- const limiter = deps.limiter ?? new RateLimiter(5, 10 * 60_000);
68
+ let limiter = deps.limiter;
56
69
  const defer = deps.defer ?? after;
57
70
  const log = deps.log ?? console;
58
71
  let store;
@@ -66,6 +79,16 @@ export function quoteRoute(site, deps) {
66
79
  }
67
80
  return webhook;
68
81
  };
82
+ // Read before the lead is stored, so a webhook that cannot be built must
83
+ // not throw here: it would cost the lead. `enqueue` says why, after the insert.
84
+ const suspectQueued = () => {
85
+ try {
86
+ return leadWebhook()?.panelSuspect ?? false;
87
+ }
88
+ catch {
89
+ return false;
90
+ }
91
+ };
69
92
  const leadStore = () => {
70
93
  store ??= deps.store ? deps.store() : openLeadStore(deps.env().leadsDb);
71
94
  return store;
@@ -80,6 +103,20 @@ export function quoteRoute(site, deps) {
80
103
  const mode = routing.hostSlug(request.headers.get("host") ?? "") === place.slug ? "host" : "path";
81
104
  return createPlaceView(site, place, locale, mode).href(suffix);
82
105
  }
106
+ /**
107
+ * Back to the card, with the field to fix and the need already chosen —
108
+ * slugs only: a phone or a name in a URL ends up in logs and analytics. The
109
+ * card is the one the form posted, if its id is a slug.
110
+ */
111
+ function refused(form, field, channel) {
112
+ const query = new URLSearchParams({ [LEAD_ERROR_PARAM]: field });
113
+ const subject = form.get(site.lead.wire.subject);
114
+ if (channel === "form" && typeof subject === "string" && site.lead.subjects.includes(subject))
115
+ query.set("need", subject);
116
+ const posted = form.get(CARD_FIELD);
117
+ const card = typeof posted === "string" && CARD_ID.test(posted) ? posted : anchor;
118
+ return `?${query}#${card}${channel === "callback" ? "-callback" : ""}`;
119
+ }
83
120
  /** Self-contained: the thing that failed may be the thing that renders pages. */
84
121
  function unavailable(slug, locale) {
85
122
  const place = (slug ? bakedPlace(site, slug) : undefined) ?? null;
@@ -109,6 +146,9 @@ export function quoteRoute(site, deps) {
109
146
  }
110
147
  const outcome = await accept(form, clientKey(request.headers, env.trustedProxy ?? DEV_TRUST), {
111
148
  insert: lead => leadStore().insert(lead),
149
+ findSubmission: async (submissionId) => (await leadStore().findSubmission?.(submissionId)) ?? null,
150
+ sendSuspect: suspectQueued(),
151
+ ...(deps.pricing ? { pricing: () => deps.pricing?.model() ?? Promise.resolve(null) } : {}),
112
152
  defer,
113
153
  notify: (lead, id) => {
114
154
  notifier ??= deps.notifier();
@@ -126,16 +166,38 @@ export function quoteRoute(site, deps) {
126
166
  channel: channelOf(lead),
127
167
  ...tags,
128
168
  }),
129
- limiter,
169
+ limiter: (limiter ??= new RateLimiter(env.leadRateLimit?.limit ?? 5, env.leadRateLimit?.windowMs ?? 600_000)),
130
170
  now: deps.now?.() ?? Date.now(),
131
171
  log,
132
172
  });
173
+ const held = outcome.kind === "stored" && !outcome.duplicate && (outcome.lead.spamVerdict === "honeypot" || (outcome.lead.spamVerdict === "rate-limited" && !suspectQueued()));
174
+ if (held && outcome.kind === "stored" && env.production === false) {
175
+ // A local stack posts every test lead from one address: say it, loudly.
176
+ log.warn(`\n!!! quote (development): lead ${outcome.id} stored as ${outcome.lead.spamVerdict} and NOT sent on — no mail, no webhook.\n` +
177
+ (outcome.lead.spamVerdict === "rate-limited"
178
+ ? `!!! The visitor saw the thanks page. Raise LEAD_RATE_LIMIT (<count>/<seconds>, now ${env.leadRateLimit?.limit ?? 5}/${(env.leadRateLimit?.windowMs ?? 600_000) / 1000}) for a local stack.\n`
179
+ : "!!! The visitor saw the thanks page. The hidden trap field was filled — a browser's autofill, or a test filling every field.\n"));
180
+ }
181
+ const scripted = wantsJson(request);
133
182
  switch (outcome.kind) {
134
183
  // A suspected bot is answered exactly as a person is.
135
- case "stored":
136
- return seeOther(href(request, outcome.lead.placeSlug, outcome.locale, THANKS));
137
- case "invalid":
138
- return seeOther(href(request, outcome.slug, outcome.locale, outcome.slug ? "#quote" : ""));
184
+ case "stored": {
185
+ const location = href(request, outcome.lead.placeSlug, outcome.locale, thanksSuffix(channelOf(outcome.lead)));
186
+ const price = outcome.lead.price ? { cents: outcome.lead.price.cents } : {};
187
+ return scripted ? json(200, { ok: true, location, lead: outcome.ref, ...price }) : seeOther(location);
188
+ }
189
+ case "invalid": {
190
+ const tags = experimentProps(text(form, EXPERIMENT_FIELD), text(form, VARIANT_FIELD));
191
+ // The field's role and why — never what was typed.
192
+ const props = { form_id: outcome.formId, channel: outcome.channel, field: outcome.field, reason: "invalid", ...tags };
193
+ if (env.posthogKey) {
194
+ const sink = analyticsSink({ key: env.posthogKey, host: env.posthogHost, brandId: site.brand.id }, outcome.slug);
195
+ defer(() => sink.capture(EVENTS.formReject, props));
196
+ }
197
+ if (scripted)
198
+ return json(422, { ok: false, field: outcome.field });
199
+ return seeOther(href(request, outcome.slug, outcome.locale, refused(form, outcome.field, outcome.channel)));
200
+ }
139
201
  case "failed":
140
202
  return unavailable(outcome.slug, outcome.locale);
141
203
  }