@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
@@ -14,6 +14,9 @@ export declare const EVENTS: {
14
14
  readonly formStart: "lead_form_start";
15
15
  readonly fieldError: "lead_form_field_error";
16
16
  readonly formStep: "lead_form_step";
17
+ readonly submitError: "lead_form_submit_error";
18
+ readonly formReject: "lead_form_reject";
19
+ readonly estimateShown: "lead_estimate_shown";
17
20
  };
18
21
  export type IntentChannel = "form_open" | "whatsapp" | "phone" | "sms" | "callback" | "booking";
19
22
  /**
@@ -30,7 +33,7 @@ export type LeadStep = "need" | "contact";
30
33
  * throws, prod drops) rather than by review. `experiment` and `variant` are
31
34
  * the site's assignment, passed in; `field` is a field's role, never its value.
32
35
  */
33
- export declare const ALLOWED_PROPS: readonly ["brand_id", "location_id", "source", "device", "channel", "form_id", "experiment", "variant", "field", "step", "layout"];
36
+ export declare const ALLOWED_PROPS: readonly ["brand_id", "location_id", "source", "device", "channel", "form_id", "experiment", "variant", "field", "step", "layout", "reason", "blocking", "need", "cents_bucket"];
34
37
  /** What an experiment's assignment looks like on the wire: short slugs, nothing a person typed. */
35
38
  export declare const EXPERIMENT_SLUG: RegExp;
36
39
  /** An experiment's assignment as event properties, or nothing when it is not a pair of slugs. */
@@ -17,6 +17,13 @@ export const EVENTS = {
17
17
  formStart: "lead_form_start",
18
18
  fieldError: "lead_form_field_error",
19
19
  formStep: "lead_form_step",
20
+ // The script's post that never got an answer (no network, no answer in time).
21
+ submitError: "lead_form_submit_error",
22
+ // The server's refusal (`reason`, the `field`): the visitor was sent back.
23
+ formReject: "lead_form_reject",
24
+ // An estimate's price, shown for a full set of answers: the need and the
25
+ // price's band (`centsBucket`), never the price — once per band and need.
26
+ estimateShown: "lead_estimate_shown",
20
27
  };
21
28
  /**
22
29
  * Every property name an event may carry. A customer's phone or address comes
@@ -37,6 +44,13 @@ export const ALLOWED_PROPS = [
37
44
  "field",
38
45
  "step",
39
46
  "layout",
47
+ "reason",
48
+ // A field error that blocked the submit (`true`), or a hint (`false`).
49
+ "blocking",
50
+ // The brand's subject slug.
51
+ "need",
52
+ // A price's band, `"5000-7500"` (`centsBucket`) — never the price.
53
+ "cents_bucket",
40
54
  ];
41
55
  /** What an experiment's assignment looks like on the wire: short slugs, nothing a person typed. */
42
56
  export const EXPERIMENT_SLUG = /^[a-z0-9_-]{1,32}$/;
@@ -4,3 +4,9 @@ import type { Opening } from "./place/hours.js";
4
4
  export declare function fillText(template: string, values: Readonly<Record<string, string>>): string;
5
5
  /** The promise of the next opening, from real hours — or `null`, never an invented one. */
6
6
  export declare function openingText(opening: Opening | null, text: Pick<LeadCaptureText, "openingToday" | "openingTomorrow" | "openingLater">, locale: string): string | null;
7
+ /**
8
+ * Cents as the page's language writes euros: `84 €`, `84,50 €`, `€84.50`.
9
+ * Whole euros drop the cents a rounded price never has; a signed line of a
10
+ * breakdown is `+8,50 €` / `−9,35 €`.
11
+ */
12
+ export declare function formatCents(cents: number, locale: string, signed?: boolean): string;
@@ -30,3 +30,16 @@ export function openingText(opening, text, locale) {
30
30
  return fillText(text.openingTomorrow, { time });
31
31
  return fillText(text.openingLater, { time, day: weekdayText(opening.day, locale) });
32
32
  }
33
+ /**
34
+ * Cents as the page's language writes euros: `84 €`, `84,50 €`, `€84.50`.
35
+ * Whole euros drop the cents a rounded price never has; a signed line of a
36
+ * breakdown is `+8,50 €` / `−9,35 €`.
37
+ */
38
+ export function formatCents(cents, locale, signed = false) {
39
+ const digits = cents % 100 === 0 ? 0 : 2;
40
+ const format = new Intl.NumberFormat(locale, { style: "currency", currency: "EUR", minimumFractionDigits: digits, maximumFractionDigits: digits });
41
+ const text = format.format(Math.abs(cents) / 100);
42
+ if (!signed)
43
+ return cents < 0 ? `−${text}` : text;
44
+ return `${cents < 0 ? "−" : "+"}${text}`;
45
+ }
@@ -7,8 +7,12 @@
7
7
  * The kit ships French and English (`LEAD_CAPTURE_TEXT`) so every brand asks
8
8
  * in the same words and an experiment's arms differ only in what it tests; a
9
9
  * brand overrides a key by spreading: `{ ...LEAD_CAPTURE_TEXT.fr, submit: "…" }`.
10
+ *
11
+ * The flows' words (`LeadCaptureFlowText`) are optional here so a brand's own
12
+ * text object written before them still type-checks; a key left out falls
13
+ * back to the kit's, in the page's language (`flowTextOf`).
10
14
  */
11
- export interface LeadCaptureText {
15
+ export interface LeadCaptureText extends Partial<LeadCaptureFlowText> {
12
16
  title: string;
13
17
  lede: string;
14
18
  needLabel: string;
@@ -18,8 +22,31 @@ export interface LeadCaptureText {
18
22
  /** Names the row of communes a tap fills the locality from. */
19
23
  servedLabel: string;
20
24
  phoneLabel: string;
21
- /** The soft check under a number that does not read as one; it never blocks. */
25
+ /**
26
+ * Under a number that does not read as one, once the visitor leaves the
27
+ * field — a hint while typing; the submit is blocked by `phoneInvalid`.
28
+ */
22
29
  phoneHint: string;
30
+ /** Blocks the submit: a number the server would refuse. */
31
+ phoneInvalid: string;
32
+ /** Blocks the submit: a required field left empty — the browser's own words are its UI language, not the page's. */
33
+ required: string;
34
+ /** Blocks the submit: no need picked. */
35
+ needRequired: string;
36
+ /** Blocks the callback: the consent box unticked. */
37
+ consentRequired: string;
38
+ /** The server refused a field the form cannot word better (a brand's own). */
39
+ fieldInvalid: string;
40
+ /** The server refused the request without naming a field. */
41
+ formInvalid: string;
42
+ /** On the submit while the script posts the form. */
43
+ sending: string;
44
+ /** The post never reached the server: nothing was sent, the form is as typed. */
45
+ networkError: string;
46
+ /** No answer in time: it may have arrived — a retry cannot make a second lead. */
47
+ timeoutError: string;
48
+ /** Beside the two above: sends the same lead again. */
49
+ retry: string;
23
50
  nameLabel: string;
24
51
  /**
25
52
  * Shown in the empty field. None by default: a placeholder is not a label —
@@ -55,4 +82,31 @@ export interface LeadCaptureText {
55
82
  /** The honeypot's label — read only by a bot filling every field. */
56
83
  honeypotLabel: string;
57
84
  }
85
+ /** What the `estimate` and `fixed` flows, the photos ask and the booking print. */
86
+ export interface LeadCaptureFlowText {
87
+ /** Heads the live price. */
88
+ priceTitle: string;
89
+ /** In place of the price until every input is answered. */
90
+ pricePending: string;
91
+ /** Under the price: what it includes and when it is paid. */
92
+ priceNote: string;
93
+ /** The breakdown's first line: the need's base. */
94
+ priceBase: string;
95
+ priceRounding: string;
96
+ priceMinimum: string;
97
+ /** The submit of a priced need. */
98
+ bookSubmit: string;
99
+ /** The in-card success of a priced lead; `{price}` the server's. */
100
+ sentPrice: string;
101
+ /** After a priced lead: the slot is set by a call (no booking provider yet). */
102
+ slotCallback: string;
103
+ /** A `quote` need that is priced from photos: the ask, and its WhatsApp link. */
104
+ photosTitle: string;
105
+ photosLede: string;
106
+ photosCta: string;
107
+ /** The WhatsApp message, `{need}` the need's label. */
108
+ photosMessage: string;
109
+ }
58
110
  export declare const LEAD_CAPTURE_TEXT: Readonly<Record<"fr" | "en", LeadCaptureText>>;
111
+ /** The flows' words for a page: the brand's, else the kit's in its language (English for `en*`, French otherwise). */
112
+ export declare function flowTextOf(text: LeadCaptureText, locale: string): LeadCaptureFlowText;
@@ -1,4 +1,35 @@
1
+ const FR_FLOW = {
2
+ priceTitle: "Votre prix",
3
+ pricePending: "Répondez aux questions pour voir le prix.",
4
+ priceNote: "Prix TTC pour ces réponses, payé après la prestation.",
5
+ priceBase: "Prix de base",
6
+ priceRounding: "Arrondi",
7
+ priceMinimum: "Minimum de prestation",
8
+ bookSubmit: "Réserver",
9
+ sentPrice: "Demande enregistrée au prix de {price}.",
10
+ slotCallback: "Nous vous rappelons pour fixer le créneau.",
11
+ photosTitle: "Envoyez des photos",
12
+ photosLede: "Pour ce besoin, quelques photos nous permettent de vous donner un prix juste.",
13
+ photosCta: "Envoyer des photos sur WhatsApp",
14
+ photosMessage: "Bonjour, voici des photos pour : {need}.",
15
+ };
16
+ const EN_FLOW = {
17
+ priceTitle: "Your price",
18
+ pricePending: "Answer the questions to see the price.",
19
+ priceNote: "Price incl. VAT for these answers, paid after the job.",
20
+ priceBase: "Base price",
21
+ priceRounding: "Rounding",
22
+ priceMinimum: "Minimum charge",
23
+ bookSubmit: "Book",
24
+ sentPrice: "Request saved at {price}.",
25
+ slotCallback: "We will call you to set the slot.",
26
+ photosTitle: "Send photos",
27
+ photosLede: "For this job, a few photos let us give you a fair price.",
28
+ photosCta: "Send photos on WhatsApp",
29
+ photosMessage: "Hello, here are photos for: {need}.",
30
+ };
1
31
  const FR = {
32
+ ...FR_FLOW,
2
33
  title: "Recevoir un prix",
3
34
  lede: "Réponse rapide, sans engagement.",
4
35
  needLabel: "Votre besoin",
@@ -7,6 +38,16 @@ const FR = {
7
38
  servedLabel: "Communes desservies",
8
39
  phoneLabel: "Téléphone",
9
40
  phoneHint: "Vérifiez le numéro : 06 12 34 56 78 ou +33 6 12 34 56 78.",
41
+ phoneInvalid: "Ce numéro n’est pas valide. Exemple : 06 12 34 56 78 ou +33 6 12 34 56 78.",
42
+ required: "Ce champ est obligatoire.",
43
+ needRequired: "Choisissez votre besoin.",
44
+ consentRequired: "Cochez la case pour que nous puissions vous rappeler.",
45
+ fieldInvalid: "Vérifiez ce champ.",
46
+ formInvalid: "Votre demande n’a pas pu être envoyée. Vérifiez le formulaire.",
47
+ sending: "Envoi…",
48
+ networkError: "Pas de connexion : votre demande n’est pas partie. Vérifiez le réseau et réessayez.",
49
+ timeoutError: "Le serveur ne répond pas. Réessayez : votre demande ne sera pas envoyée deux fois.",
50
+ retry: "Réessayer",
10
51
  nameLabel: "Nom",
11
52
  optional: "facultatif",
12
53
  submit: "Recevoir le prix",
@@ -27,6 +68,7 @@ const FR = {
27
68
  honeypotLabel: "Site web",
28
69
  };
29
70
  const EN = {
71
+ ...EN_FLOW,
30
72
  title: "Get a price",
31
73
  lede: "A quick answer, no commitment.",
32
74
  needLabel: "What you need",
@@ -35,6 +77,16 @@ const EN = {
35
77
  servedLabel: "Towns we serve",
36
78
  phoneLabel: "Phone",
37
79
  phoneHint: "Check the number: 06 12 34 56 78 or +33 6 12 34 56 78.",
80
+ phoneInvalid: "This number is not valid. For example: 06 12 34 56 78 or +33 6 12 34 56 78.",
81
+ required: "This field is required.",
82
+ needRequired: "Choose what you need.",
83
+ consentRequired: "Tick the box so we can call you back.",
84
+ fieldInvalid: "Check this field.",
85
+ formInvalid: "Your request could not be sent. Check the form.",
86
+ sending: "Sending…",
87
+ networkError: "No connection: your request was not sent. Check the network and try again.",
88
+ timeoutError: "The server is not answering. Try again: your request will not be sent twice.",
89
+ retry: "Try again",
38
90
  nameLabel: "Name",
39
91
  optional: "optional",
40
92
  submit: "Get the price",
@@ -55,3 +107,14 @@ const EN = {
55
107
  honeypotLabel: "Website",
56
108
  };
57
109
  export const LEAD_CAPTURE_TEXT = { fr: FR, en: EN };
110
+ /** The flows' words for a page: the brand's, else the kit's in its language (English for `en*`, French otherwise). */
111
+ export function flowTextOf(text, locale) {
112
+ const kit = locale.startsWith("en") ? EN_FLOW : FR_FLOW;
113
+ const out = { ...kit };
114
+ for (const key of Object.keys(kit)) {
115
+ const own = text[key];
116
+ if (own !== undefined)
117
+ out[key] = own;
118
+ }
119
+ return out;
120
+ }
@@ -9,6 +9,8 @@
9
9
  * `LeadSchema.wire` names the posted fields (a page cached before a rename
10
10
  * still delivers) — both deliberate departures from the first design.
11
11
  */
12
+ import type { LeadFlow, LeadFlows } from "./pricing/flow.js";
13
+ import type { PricingInputs } from "./pricing/model.js";
12
14
  /** Why a submission was kept but not acted on. `null` is a clean lead. */
13
15
  export type SpamVerdict = "honeypot" | "too-fast" | "rate-limited";
14
16
  /**
@@ -18,7 +20,24 @@ export type SpamVerdict = "honeypot" | "too-fast" | "rate-limited";
18
20
  export interface LeadExtra {
19
21
  name: string;
20
22
  max: number;
23
+ /**
24
+ * A free-text note keeps its line breaks. Off by default: every other field
25
+ * is one line, and a line break posted into one forges a line of the mail.
26
+ */
27
+ multiline?: boolean;
28
+ }
29
+ /**
30
+ * Why a candidate is refused, and the field it is about — `phone`, `locality`,
31
+ * `need`, `name`, `consent`, an extra's name, or `form` when the rule cannot
32
+ * say. The field goes back to the form, which shows the error there; `why` is
33
+ * for the log only, never rendered.
34
+ */
35
+ export interface LeadRejection {
36
+ field: string;
37
+ why: string;
21
38
  }
39
+ /** What a rule may answer: a rejection, a bare reason (read as `form`), or `null` to accept. */
40
+ export type LeadVerdict = LeadRejection | string | null;
22
41
  /**
23
42
  * The form field each core value is posted under. Brand config, not a
24
43
  * constant: a page cached before a rename still posts the old names, and a
@@ -35,16 +54,20 @@ export interface LeadSchema<S extends string> {
35
54
  wire: LeadWire;
36
55
  extras?: readonly LeadExtra[];
37
56
  /**
38
- * The one rule worth enforcing, as a reason for the log (never rendered), or
39
- * `null` to accept. Absent → every candidate is a lead.
57
+ * The rule a form lead must pass, or `null` to accept. Absent →
58
+ * `validateLead`: the number the form itself blocks on. A brand adding its
59
+ * own rule composes it — `lead => validateLead(lead) ?? mine(lead)` — and
60
+ * the landing contract holds it to the form's phone rule, so the server
61
+ * never refuses a number the form let through. A bare string is a reason
62
+ * about the whole form (`field: "form"`).
40
63
  */
41
- validate?: (lead: LeadCandidate) => string | null;
64
+ validate?: (lead: LeadCandidate) => LeadVerdict;
42
65
  /**
43
66
  * The rule for a callback request, which carries a phone and little else —
44
67
  * a form's rule would refuse it for the locality it never asked. Absent →
45
- * `validateCallbackLead`: a number we can read.
68
+ * `validateCallbackLead`: a number we can call.
46
69
  */
47
- validateCallback?: (lead: LeadCandidate) => string | null;
70
+ validateCallback?: (lead: LeadCandidate) => LeadVerdict;
48
71
  /**
49
72
  * How the mobile is kept: `typed` (default), exactly as posted, or `e164`,
50
73
  * `+33612345678` when it reads as a number and as typed when it does not.
@@ -52,6 +75,13 @@ export interface LeadSchema<S extends string> {
52
75
  * number as typed.
53
76
  */
54
77
  mobileFormat?: "typed" | "e164";
78
+ /**
79
+ * How each need is sold (`quote` | `estimate` | `fixed`); a need left out
80
+ * is a `quote`. The form and the server read the same map, and a need runs
81
+ * its flow only when the price list prices it that way (`flowOf`) — so a
82
+ * flow the brand has not switched on is never run by a posted field.
83
+ */
84
+ flows?: LeadFlows;
55
85
  }
56
86
  /**
57
87
  * How the lead was asked for: the quote form, or "call me back" — the form cut
@@ -74,8 +104,23 @@ export interface LeadConsent {
74
104
  text: string;
75
105
  at: string;
76
106
  }
77
- /** The default callback rule: a phone number that reads as one. */
78
- export declare function validateCallbackLead(lead: Pick<LeadCandidate, "mobile">): string | null;
107
+ /** The script's id for one submission, minted once and kept across its resends. */
108
+ export declare const SUBMISSION_FIELD = "submission_id";
109
+ export declare const SUBMISSION_ID: RegExp;
110
+ /** The query a refused submission is sent back to the form with: `?lead_error=<field>`. */
111
+ export declare const LEAD_ERROR_PARAM = "lead_error";
112
+ /**
113
+ * The card's id, posted so a refusal lands back on it (`#devis`, not a
114
+ * hard-coded `#quote`). Only a slug is ever echoed into the redirect.
115
+ */
116
+ export declare const CARD_FIELD = "card";
117
+ export declare const CARD_ID: RegExp;
118
+ /** A rule's answer as a rejection: a bare reason, or a field that is not a slug, is about the `form`. */
119
+ export declare function rejectionOf(verdict: LeadVerdict): LeadRejection | null;
120
+ /** The default form rule: a number we can call — the one the form blocks on (`phoneProblem`). */
121
+ export declare function validateLead(lead: Pick<LeadCandidate, "mobile">): LeadRejection | null;
122
+ /** The default callback rule: the same number rule, worded for the log. */
123
+ export declare function validateCallbackLead(lead: Pick<LeadCandidate, "mobile">): LeadRejection | null;
79
124
  /**
80
125
  * A submitted lead. Values are trimmed and capped but not otherwise parsed: a
81
126
  * lead that cannot be fully validated is still a lead, and rejecting it loses
@@ -97,14 +142,47 @@ export interface Lead {
97
142
  channel?: LeadChannel;
98
143
  /** Only on a callback lead: the consent it was posted with. */
99
144
  consent?: LeadConsent;
145
+ /**
146
+ * The script's id for this submission (`SUBMISSION_FIELD`): a resend after
147
+ * a lost answer carries the same one, and the store keeps one row per id.
148
+ * Absent on a plain post, which has no script to mint one.
149
+ */
150
+ submissionId?: string;
151
+ /**
152
+ * How the need was sold, when the brand sells by flow (`LeadSchema.flows`)
153
+ * and the lead came through the form; absent otherwise — a callback, or a
154
+ * lead from before the field existed.
155
+ */
156
+ flow?: LeadFlow;
157
+ /** Only on an `estimate` or `fixed` lead: the price the server computed. */
158
+ price?: LeadPrice;
159
+ }
160
+ /**
161
+ * The price a lead was taken at — the server's own number, from the posted
162
+ * answers, never a posted amount — and the model it came from.
163
+ */
164
+ export interface LeadPrice {
165
+ cents: number;
166
+ /** The model's `validFrom`, `YYYY-MM-DD`. */
167
+ validFrom: string;
168
+ /** An estimate's answers, input id → option id; absent for a fixed price. */
169
+ inputs?: PricingInputs;
100
170
  }
171
+ /** What the panel may be told of a suspected lead. */
172
+ export type LeadSuspect = "rate_limited" | "too_fast";
173
+ /**
174
+ * Why a lead the panel receives is suspect, for its `suspect` property, or
175
+ * nothing for a clean one. Never `honeypot`: a lead that filled the trap is a
176
+ * bot's, kept in the table and sent nowhere.
177
+ */
178
+ export declare function suspectOf(lead: Pick<Lead, "spamVerdict">): LeadSuspect | undefined;
101
179
  /** The lead's channel, `form` for one that predates the field. */
102
180
  export declare function channelOf(lead: Pick<Lead, "channel">): LeadChannel;
103
181
  /**
104
182
  * What the form posted, before the funnel judges it: the consent is the
105
183
  * posted sentence, and the funnel stamps when it accepted it.
106
184
  */
107
- export type LeadCandidate = Omit<Lead, "spamVerdict" | "consent"> & {
185
+ export type LeadCandidate = Omit<Lead, "spamVerdict" | "consent" | "flow" | "price"> & {
108
186
  consentText?: string;
109
187
  };
110
188
  /**
@@ -118,7 +196,16 @@ export type LeadCandidate = Omit<Lead, "spamVerdict" | "consent"> & {
118
196
  * (`@evinvest/kitstart/testing`) before `LEADS_DB_URL` may select it.
119
197
  */
120
198
  export interface LeadStore {
199
+ /** Rejects a second lead with a `submissionId` the store already holds (a unique index). */
121
200
  insert(lead: Lead): Promise<number>;
201
+ /**
202
+ * The row a submission id was stored under, read back as a lead, or `null`.
203
+ * Absent on an adapter that does not dedupe: every post is then a lead.
204
+ */
205
+ findSubmission?(submissionId: string): Promise<{
206
+ id: number;
207
+ lead: Lead;
208
+ } | null>;
122
209
  count(): Promise<number>;
123
210
  /** The schema version the storage stands at after opening. */
124
211
  schemaVersion(): Promise<number>;
@@ -129,9 +216,11 @@ export interface LeadStore {
129
216
  /**
130
217
  * The lead schema's version, shared by every adapter: 1 the Rust server's
131
218
  * table (`job`, `zip`, `mobile`, `at`), 2 + the place, 3 + the spam verdict,
132
- * 4 + the brand's extras, 5 + the channel and the callback's consent. Append only.
219
+ * 4 + the brand's extras, 5 + the channel and the callback's consent, 6 + the
220
+ * script's submission id, unique, 7 + the flow and the price it was taken at.
221
+ * Append only.
133
222
  */
134
- export declare const LEAD_SCHEMA_VERSION = 5;
223
+ export declare const LEAD_SCHEMA_VERSION = 7;
135
224
  /** A field is capped, not rejected: a long answer is still a customer. */
136
225
  export declare const MAX_FIELD = 200;
137
226
  /**
@@ -140,4 +229,5 @@ export declare const MAX_FIELD = 200;
140
229
  * sees it.
141
230
  */
142
231
  export declare function readCandidate(schema: LeadSchema<string>, form: FormData, placeSlug: string | null): LeadCandidate;
143
- export declare function validateCandidate(schema: LeadSchema<string>, lead: LeadCandidate): string | null;
232
+ /** The rule the candidate's channel answers to: the field a refusal is about, or `null`. */
233
+ export declare function validateCandidate(schema: LeadSchema<string>, lead: LeadCandidate): LeadRejection | null;
package/dist/core/lead.js CHANGED
@@ -9,7 +9,7 @@
9
9
  * `LeadSchema.wire` names the posted fields (a page cached before a rename
10
10
  * still delivers) — both deliberate departures from the first design.
11
11
  */
12
- import { isPlausiblePhone, normalizePhone } from "./phone.js";
12
+ import { normalizePhone, phoneProblem } from "./phone.js";
13
13
  export const LEAD_CHANNELS = ["form", "callback"];
14
14
  export const CHANNEL_FIELD = "channel";
15
15
  /**
@@ -20,9 +20,47 @@ export const CHANNEL_FIELD = "channel";
20
20
  export const CONSENT_FIELD = "consent";
21
21
  /** Longer than any consent sentence; past it, the text is cut, not refused. */
22
22
  export const MAX_CONSENT = 500;
23
- /** The default callback rule: a phone number that reads as one. */
23
+ /** The script's id for one submission, minted once and kept across its resends. */
24
+ export const SUBMISSION_FIELD = "submission_id";
25
+ export const SUBMISSION_ID = /^[A-Za-z0-9-]{16,64}$/;
26
+ /** The query a refused submission is sent back to the form with: `?lead_error=<field>`. */
27
+ export const LEAD_ERROR_PARAM = "lead_error";
28
+ /**
29
+ * The card's id, posted so a refusal lands back on it (`#devis`, not a
30
+ * hard-coded `#quote`). Only a slug is ever echoed into the redirect.
31
+ */
32
+ export const CARD_FIELD = "card";
33
+ export const CARD_ID = /^[a-z0-9-]{1,64}$/;
34
+ /** A field as a refusal names it: a short slug, or the whole `form`. */
35
+ const FIELD_SLUG = /^[a-z][a-z0-9_]{0,31}$/;
36
+ /** A rule's answer as a rejection: a bare reason, or a field that is not a slug, is about the `form`. */
37
+ export function rejectionOf(verdict) {
38
+ if (verdict === null)
39
+ return null;
40
+ if (typeof verdict === "string")
41
+ return { field: "form", why: verdict };
42
+ return FIELD_SLUG.test(verdict.field) ? verdict : { field: "form", why: verdict.why };
43
+ }
44
+ /** The default form rule: a number we can call — the one the form blocks on (`phoneProblem`). */
45
+ export function validateLead(lead) {
46
+ const problem = phoneProblem(lead.mobile);
47
+ return problem === null ? null : { field: "phone", why: problem === "required" ? "a phone number" : "a phone number we can call" };
48
+ }
49
+ /** The default callback rule: the same number rule, worded for the log. */
24
50
  export function validateCallbackLead(lead) {
25
- return isPlausiblePhone(lead.mobile) ? null : "a phone number to call back";
51
+ return phoneProblem(lead.mobile) === null ? null : { field: "phone", why: "a phone number to call back" };
52
+ }
53
+ /**
54
+ * Why a lead the panel receives is suspect, for its `suspect` property, or
55
+ * nothing for a clean one. Never `honeypot`: a lead that filled the trap is a
56
+ * bot's, kept in the table and sent nowhere.
57
+ */
58
+ export function suspectOf(lead) {
59
+ if (lead.spamVerdict === "rate-limited")
60
+ return "rate_limited";
61
+ if (lead.spamVerdict === "too-fast")
62
+ return "too_fast";
63
+ return undefined;
26
64
  }
27
65
  /** The lead's channel, `form` for one that predates the field. */
28
66
  export function channelOf(lead) {
@@ -31,14 +69,28 @@ export function channelOf(lead) {
31
69
  /**
32
70
  * The lead schema's version, shared by every adapter: 1 the Rust server's
33
71
  * table (`job`, `zip`, `mobile`, `at`), 2 + the place, 3 + the spam verdict,
34
- * 4 + the brand's extras, 5 + the channel and the callback's consent. Append only.
72
+ * 4 + the brand's extras, 5 + the channel and the callback's consent, 6 + the
73
+ * script's submission id, unique, 7 + the flow and the price it was taken at.
74
+ * Append only.
35
75
  */
36
- export const LEAD_SCHEMA_VERSION = 5;
76
+ export const LEAD_SCHEMA_VERSION = 7;
37
77
  /** A field is capped, not rejected: a long answer is still a customer. */
38
78
  export const MAX_FIELD = 200;
39
- function field(form, name, max) {
79
+ // C0 and C1 controls, DEL, and the Unicode line and paragraph separators.
80
+ const CONTROL = /[\u0000-\u001f\u007f-\u009f\u2028\u2029]+/g;
81
+ // The same less the line feed: what a multi-line note keeps.
82
+ const CONTROL_BUT_NEWLINE = /[\u0000-\u0009\u000b-\u001f\u007f-\u009f\u2028\u2029]+/g;
83
+ /**
84
+ * A posted value, trimmed and capped. One line unless `multiline`: a line
85
+ * break or a control character posted into a one-line field (`63130\nMobile
86
+ * : 07…`) would forge a line of the mail, so each run becomes one space.
87
+ */
88
+ function field(form, name, max, multiline = false) {
40
89
  const value = form.get(name);
41
- return typeof value === "string" ? value.trim().slice(0, max) : null;
90
+ if (typeof value !== "string")
91
+ return null;
92
+ const clean = multiline ? value.replace(/\r\n?/g, "\n").replace(CONTROL_BUT_NEWLINE, "") : value.replace(CONTROL, " ").replace(/ {2,}/g, " ");
93
+ return clean.trim().slice(0, max);
42
94
  }
43
95
  /**
44
96
  * The candidate a form posted, read through the schema's field names. Under
@@ -48,13 +100,14 @@ function field(form, name, max) {
48
100
  export function readCandidate(schema, form, placeSlug) {
49
101
  const extras = {};
50
102
  for (const extra of schema.extras ?? []) {
51
- const value = field(form, extra.name, extra.max);
103
+ const value = field(form, extra.name, extra.max, extra.multiline);
52
104
  if (value)
53
105
  extras[extra.name] = value;
54
106
  }
55
107
  const mobile = field(form, schema.wire.mobile, MAX_FIELD) ?? "";
56
108
  const callback = form.get(CHANNEL_FIELD) === "callback";
57
109
  const consent = callback ? field(form, CONSENT_FIELD, MAX_CONSENT) : null;
110
+ const submission = field(form, SUBMISSION_FIELD, 64);
58
111
  return {
59
112
  subject: field(form, schema.wire.subject, MAX_FIELD) ?? "",
60
113
  locality: field(form, schema.wire.locality, MAX_FIELD) ?? "",
@@ -63,14 +116,16 @@ export function readCandidate(schema, form, placeSlug) {
63
116
  placeSlug,
64
117
  channel: callback ? "callback" : "form",
65
118
  ...(consent ? { consentText: consent } : {}),
119
+ ...(submission && SUBMISSION_ID.test(submission) ? { submissionId: submission } : {}),
66
120
  };
67
121
  }
122
+ /** The rule the candidate's channel answers to: the field a refusal is about, or `null`. */
68
123
  export function validateCandidate(schema, lead) {
69
124
  if (channelOf(lead) === "callback") {
70
125
  // Not the brand's to waive: calling someone back needs their word for it.
71
126
  if (!lead.consentText)
72
- return "consent to be called back";
73
- return (schema.validateCallback ?? validateCallbackLead)(lead);
127
+ return { field: "consent", why: "consent to be called back" };
128
+ return rejectionOf((schema.validateCallback ?? validateCallbackLead)(lead));
74
129
  }
75
- return schema.validate?.(lead) ?? null;
130
+ return rejectionOf((schema.validate ?? validateLead)(lead));
76
131
  }
@@ -1,17 +1,28 @@
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 declare function normalizePhone(raw: string): string | null;
13
- /** Whether a typed number reads as one — the form's soft check, never a block. */
14
+ /**
15
+ * Whether a typed number is one we can call back — the one rule the form,
16
+ * the callback and the server share (`phoneProblem`, `validateLead`).
17
+ */
14
18
  export declare function isPlausiblePhone(raw: string): boolean;
19
+ /**
20
+ * What is wrong with a typed number, or `null`: `required` when nothing was
21
+ * typed, `invalid` when it is not a number we can call. The form blocks on it
22
+ * in the page's words and the server refuses on it — one rule, so a number
23
+ * the form lets through is never refused behind the visitor's back.
24
+ */
25
+ export declare function phoneProblem(raw: string): "required" | "invalid" | null;
15
26
  /**
16
27
  * A French mobile (`+33 6…`, `+33 7…`) — the one kind an SMS reaches. A
17
28
  * landline answers a text with silence, so the channel is not offered for it.