@evinvest/kitstart 0.8.0 → 0.9.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 (62) hide show
  1. package/README.md +89 -23
  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 +22 -3
  7. package/dist/core/accept.js +40 -13
  8. package/dist/core/analytics.d.ts +3 -1
  9. package/dist/core/analytics.js +7 -0
  10. package/dist/core/lead-capture-text.d.ts +24 -1
  11. package/dist/core/lead-capture-text.js +20 -0
  12. package/dist/core/lead.d.ts +71 -10
  13. package/dist/core/lead.js +65 -11
  14. package/dist/core/phone.d.ts +20 -9
  15. package/dist/core/phone.js +43 -19
  16. package/dist/core/status.d.ts +13 -0
  17. package/dist/core/status.js +19 -2
  18. package/dist/index.d.ts +3 -3
  19. package/dist/index.js +3 -3
  20. package/dist/next/quote-route.d.ts +15 -2
  21. package/dist/next/quote-route.js +70 -10
  22. package/dist/react/LeadCapture.d.ts +7 -5
  23. package/dist/react/LeadCapture.js +18 -6
  24. package/dist/react/LeadCaptureChannels.d.ts +14 -1
  25. package/dist/react/LeadCaptureChannels.js +8 -5
  26. package/dist/react/LeadCaptureFields.d.ts +39 -7
  27. package/dist/react/LeadCaptureFields.js +91 -21
  28. package/dist/react/LeadCaptureNeed.d.ts +5 -0
  29. package/dist/react/LeadCaptureNeed.js +38 -2
  30. package/dist/react/LeadCaptureSubmit.d.ts +16 -0
  31. package/dist/react/LeadCaptureSubmit.js +13 -0
  32. package/dist/react/QuoteFormShell.d.ts +2 -0
  33. package/dist/react/QuoteFormShell.js +3 -2
  34. package/dist/react/use-lead-error.d.ts +24 -0
  35. package/dist/react/use-lead-error.js +73 -0
  36. package/dist/react/use-lead-events.d.ts +3 -0
  37. package/dist/react/use-lead-events.js +3 -2
  38. package/dist/react/use-lead-submit.d.ts +44 -9
  39. package/dist/react/use-lead-submit.js +122 -13
  40. package/dist/react/use-validity.d.ts +17 -0
  41. package/dist/react/use-validity.js +38 -0
  42. package/dist/server/env.d.ts +9 -0
  43. package/dist/server/env.js +12 -0
  44. package/dist/server/index.d.ts +1 -1
  45. package/dist/server/index.js +1 -1
  46. package/dist/server/lead-store-sqlite.d.ts +5 -1
  47. package/dist/server/lead-store-sqlite.js +37 -2
  48. package/dist/server/lead-webhook.d.ts +20 -1
  49. package/dist/server/lead-webhook.js +6 -2
  50. package/dist/server/webhook-outbox.d.ts +27 -1
  51. package/dist/server/webhook-outbox.js +33 -6
  52. package/dist/testing/index.d.ts +1 -0
  53. package/dist/testing/index.js +1 -0
  54. package/dist/testing/landing-contract.js +6 -0
  55. package/dist/testing/lead-rules.d.ts +18 -0
  56. package/dist/testing/lead-rules.js +50 -0
  57. package/dist/testing/lead-store-contract.js +20 -0
  58. package/package.json +3 -2
  59. package/template/flake.nix +1 -1
  60. package/template/package.json +1 -1
  61. package/template/src/shared/config/lead.ts +6 -3
  62. package/template/tests/e2e/quote-form.spec.ts +86 -1
@@ -18,7 +18,24 @@ export type SpamVerdict = "honeypot" | "too-fast" | "rate-limited";
18
18
  export interface LeadExtra {
19
19
  name: string;
20
20
  max: number;
21
+ /**
22
+ * A free-text note keeps its line breaks. Off by default: every other field
23
+ * is one line, and a line break posted into one forges a line of the mail.
24
+ */
25
+ multiline?: boolean;
21
26
  }
27
+ /**
28
+ * Why a candidate is refused, and the field it is about — `phone`, `locality`,
29
+ * `need`, `name`, `consent`, an extra's name, or `form` when the rule cannot
30
+ * say. The field goes back to the form, which shows the error there; `why` is
31
+ * for the log only, never rendered.
32
+ */
33
+ export interface LeadRejection {
34
+ field: string;
35
+ why: string;
36
+ }
37
+ /** What a rule may answer: a rejection, a bare reason (read as `form`), or `null` to accept. */
38
+ export type LeadVerdict = LeadRejection | string | null;
22
39
  /**
23
40
  * The form field each core value is posted under. Brand config, not a
24
41
  * constant: a page cached before a rename still posts the old names, and a
@@ -35,16 +52,20 @@ export interface LeadSchema<S extends string> {
35
52
  wire: LeadWire;
36
53
  extras?: readonly LeadExtra[];
37
54
  /**
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.
55
+ * The rule a form lead must pass, or `null` to accept. Absent →
56
+ * `validateLead`: the number the form itself blocks on. A brand adding its
57
+ * own rule composes it — `lead => validateLead(lead) ?? mine(lead)` — and
58
+ * the landing contract holds it to the form's phone rule, so the server
59
+ * never refuses a number the form let through. A bare string is a reason
60
+ * about the whole form (`field: "form"`).
40
61
  */
41
- validate?: (lead: LeadCandidate) => string | null;
62
+ validate?: (lead: LeadCandidate) => LeadVerdict;
42
63
  /**
43
64
  * The rule for a callback request, which carries a phone and little else —
44
65
  * a form's rule would refuse it for the locality it never asked. Absent →
45
- * `validateCallbackLead`: a number we can read.
66
+ * `validateCallbackLead`: a number we can call.
46
67
  */
47
- validateCallback?: (lead: LeadCandidate) => string | null;
68
+ validateCallback?: (lead: LeadCandidate) => LeadVerdict;
48
69
  /**
49
70
  * How the mobile is kept: `typed` (default), exactly as posted, or `e164`,
50
71
  * `+33612345678` when it reads as a number and as typed when it does not.
@@ -74,8 +95,23 @@ export interface LeadConsent {
74
95
  text: string;
75
96
  at: string;
76
97
  }
77
- /** The default callback rule: a phone number that reads as one. */
78
- export declare function validateCallbackLead(lead: Pick<LeadCandidate, "mobile">): string | null;
98
+ /** The script's id for one submission, minted once and kept across its resends. */
99
+ export declare const SUBMISSION_FIELD = "submission_id";
100
+ export declare const SUBMISSION_ID: RegExp;
101
+ /** The query a refused submission is sent back to the form with: `?lead_error=<field>`. */
102
+ export declare const LEAD_ERROR_PARAM = "lead_error";
103
+ /**
104
+ * The card's id, posted so a refusal lands back on it (`#devis`, not a
105
+ * hard-coded `#quote`). Only a slug is ever echoed into the redirect.
106
+ */
107
+ export declare const CARD_FIELD = "card";
108
+ export declare const CARD_ID: RegExp;
109
+ /** A rule's answer as a rejection: a bare reason, or a field that is not a slug, is about the `form`. */
110
+ export declare function rejectionOf(verdict: LeadVerdict): LeadRejection | null;
111
+ /** The default form rule: a number we can call — the one the form blocks on (`phoneProblem`). */
112
+ export declare function validateLead(lead: Pick<LeadCandidate, "mobile">): LeadRejection | null;
113
+ /** The default callback rule: the same number rule, worded for the log. */
114
+ export declare function validateCallbackLead(lead: Pick<LeadCandidate, "mobile">): LeadRejection | null;
79
115
  /**
80
116
  * A submitted lead. Values are trimmed and capped but not otherwise parsed: a
81
117
  * lead that cannot be fully validated is still a lead, and rejecting it loses
@@ -97,7 +133,21 @@ export interface Lead {
97
133
  channel?: LeadChannel;
98
134
  /** Only on a callback lead: the consent it was posted with. */
99
135
  consent?: LeadConsent;
136
+ /**
137
+ * The script's id for this submission (`SUBMISSION_FIELD`): a resend after
138
+ * a lost answer carries the same one, and the store keeps one row per id.
139
+ * Absent on a plain post, which has no script to mint one.
140
+ */
141
+ submissionId?: string;
100
142
  }
143
+ /** What the panel may be told of a suspected lead. */
144
+ export type LeadSuspect = "rate_limited" | "too_fast";
145
+ /**
146
+ * Why a lead the panel receives is suspect, for its `suspect` property, or
147
+ * nothing for a clean one. Never `honeypot`: a lead that filled the trap is a
148
+ * bot's, kept in the table and sent nowhere.
149
+ */
150
+ export declare function suspectOf(lead: Pick<Lead, "spamVerdict">): LeadSuspect | undefined;
101
151
  /** The lead's channel, `form` for one that predates the field. */
102
152
  export declare function channelOf(lead: Pick<Lead, "channel">): LeadChannel;
103
153
  /**
@@ -118,7 +168,16 @@ export type LeadCandidate = Omit<Lead, "spamVerdict" | "consent"> & {
118
168
  * (`@evinvest/kitstart/testing`) before `LEADS_DB_URL` may select it.
119
169
  */
120
170
  export interface LeadStore {
171
+ /** Rejects a second lead with a `submissionId` the store already holds (a unique index). */
121
172
  insert(lead: Lead): Promise<number>;
173
+ /**
174
+ * The row a submission id was stored under, read back as a lead, or `null`.
175
+ * Absent on an adapter that does not dedupe: every post is then a lead.
176
+ */
177
+ findSubmission?(submissionId: string): Promise<{
178
+ id: number;
179
+ lead: Lead;
180
+ } | null>;
122
181
  count(): Promise<number>;
123
182
  /** The schema version the storage stands at after opening. */
124
183
  schemaVersion(): Promise<number>;
@@ -129,9 +188,10 @@ export interface LeadStore {
129
188
  /**
130
189
  * The lead schema's version, shared by every adapter: 1 the Rust server's
131
190
  * 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.
191
+ * 4 + the brand's extras, 5 + the channel and the callback's consent, 6 + the
192
+ * script's submission id, unique. Append only.
133
193
  */
134
- export declare const LEAD_SCHEMA_VERSION = 5;
194
+ export declare const LEAD_SCHEMA_VERSION = 6;
135
195
  /** A field is capped, not rejected: a long answer is still a customer. */
136
196
  export declare const MAX_FIELD = 200;
137
197
  /**
@@ -140,4 +200,5 @@ export declare const MAX_FIELD = 200;
140
200
  * sees it.
141
201
  */
142
202
  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;
203
+ /** The rule the candidate's channel answers to: the field a refusal is about, or `null`. */
204
+ 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,27 @@ 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. Append only.
35
74
  */
36
- export const LEAD_SCHEMA_VERSION = 5;
75
+ export const LEAD_SCHEMA_VERSION = 6;
37
76
  /** A field is capped, not rejected: a long answer is still a customer. */
38
77
  export const MAX_FIELD = 200;
39
- function field(form, name, max) {
78
+ // C0 and C1 controls, DEL, and the Unicode line and paragraph separators.
79
+ const CONTROL = /[\u0000-\u001f\u007f-\u009f\u2028\u2029]+/g;
80
+ // The same less the line feed: what a multi-line note keeps.
81
+ const CONTROL_BUT_NEWLINE = /[\u0000-\u0009\u000b-\u001f\u007f-\u009f\u2028\u2029]+/g;
82
+ /**
83
+ * A posted value, trimmed and capped. One line unless `multiline`: a line
84
+ * break or a control character posted into a one-line field (`63130\nMobile
85
+ * : 07…`) would forge a line of the mail, so each run becomes one space.
86
+ */
87
+ function field(form, name, max, multiline = false) {
40
88
  const value = form.get(name);
41
- return typeof value === "string" ? value.trim().slice(0, max) : null;
89
+ if (typeof value !== "string")
90
+ return null;
91
+ const clean = multiline ? value.replace(/\r\n?/g, "\n").replace(CONTROL_BUT_NEWLINE, "") : value.replace(CONTROL, " ").replace(/ {2,}/g, " ");
92
+ return clean.trim().slice(0, max);
42
93
  }
43
94
  /**
44
95
  * The candidate a form posted, read through the schema's field names. Under
@@ -48,13 +99,14 @@ function field(form, name, max) {
48
99
  export function readCandidate(schema, form, placeSlug) {
49
100
  const extras = {};
50
101
  for (const extra of schema.extras ?? []) {
51
- const value = field(form, extra.name, extra.max);
102
+ const value = field(form, extra.name, extra.max, extra.multiline);
52
103
  if (value)
53
104
  extras[extra.name] = value;
54
105
  }
55
106
  const mobile = field(form, schema.wire.mobile, MAX_FIELD) ?? "";
56
107
  const callback = form.get(CHANNEL_FIELD) === "callback";
57
108
  const consent = callback ? field(form, CONSENT_FIELD, MAX_CONSENT) : null;
109
+ const submission = field(form, SUBMISSION_FIELD, 64);
58
110
  return {
59
111
  subject: field(form, schema.wire.subject, MAX_FIELD) ?? "",
60
112
  locality: field(form, schema.wire.locality, MAX_FIELD) ?? "",
@@ -63,14 +115,16 @@ export function readCandidate(schema, form, placeSlug) {
63
115
  placeSlug,
64
116
  channel: callback ? "callback" : "form",
65
117
  ...(consent ? { consentText: consent } : {}),
118
+ ...(submission && SUBMISSION_ID.test(submission) ? { submissionId: submission } : {}),
66
119
  };
67
120
  }
121
+ /** The rule the candidate's channel answers to: the field a refusal is about, or `null`. */
68
122
  export function validateCandidate(schema, lead) {
69
123
  if (channelOf(lead) === "callback") {
70
124
  // Not the brand's to waive: calling someone back needs their word for it.
71
125
  if (!lead.consentText)
72
- return "consent to be called back";
73
- return (schema.validateCallback ?? validateCallbackLead)(lead);
126
+ return { field: "consent", why: "consent to be called back" };
127
+ return rejectionOf((schema.validateCallback ?? validateCallbackLead)(lead));
74
128
  }
75
- return schema.validate?.(lead) ?? null;
129
+ return rejectionOf((schema.validate ?? validateLead)(lead));
76
130
  }
@@ -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.
@@ -1,35 +1,59 @@
1
1
  /**
2
- * A phone number as the funnel keeps it: E.164 when the typed value is one we
3
- * can read, otherwise exactly as typed. Soft by design — a number we cannot
4
- * parse is still a way to reach someone, and refusing it loses the customer.
2
+ * A phone number as the funnel reads it: E.164 when the typed value is a
3
+ * number someone can be reached on, otherwise `null`.
5
4
  *
6
- * Only the French national plan is read (every brand of the vertical is in
7
- * France): `06 12 34 56 78`, `+33 (0)6 …`, `0033 6 …` and a mobile typed
8
- * without its trunk zero (`6 12 34 56 78`) all become `+33612345678`. Any
9
- * other international number is compacted (`+44 7911 123456` →
10
- * `+447911123456`); anything else is `null`.
5
+ * Only the French national plan is read closely (every brand of the vertical
6
+ * is in France): ten digits from `0[1-9]` — `06 12 34 56 78`, `+33 (0)6 …`,
7
+ * `0033 6 …`, a mobile typed without its trunk zero (`6 12 34 56 78`) — all
8
+ * become `+33612345678`. Any other international number is a plausible E.164,
9
+ * 8 to 15 digits after the `+`, compacted (`+44 7911 123456` →
10
+ * `+447911123456`). Full-width digits read as digits; a run of one digit
11
+ * (`00 00 00 00 00`, `06 66 66 66 66`) is a refusal to give a number, not one.
11
12
  */
12
13
  export function normalizePhone(raw) {
13
- let s = raw.replace(/\(0\)/g, "").replace(/[\s.\-()/ ]/g, "");
14
+ // NFKC: a Japanese or Chinese keyboard types full-width digits and `+`,
15
+ // and a pasted number may carry no-break spaces. `\s` takes those too.
16
+ let s = raw.normalize("NFKC").replace(/\(0\)/g, "").replace(/[\s.\-()/]/g, "");
14
17
  if (s.startsWith("00"))
15
18
  s = `+${s.slice(2)}`;
19
+ let e164 = null;
16
20
  if (s.startsWith("+33")) {
17
21
  const national = /^\+330?([1-9]\d{8})$/.exec(s);
18
- return national ? `+33${national[1]}` : null;
22
+ e164 = national ? `+33${national[1]}` : null;
19
23
  }
20
- const trunk = /^0([1-9]\d{8})$/.exec(s);
21
- if (trunk)
22
- return `+33${trunk[1]}`;
23
- if (/^[1-9]\d{8}$/.test(s))
24
- return `+33${s}`;
25
- if (/^\+[1-9]\d{7,14}$/.test(s))
26
- return s;
27
- return null;
24
+ else {
25
+ const trunk = /^0([1-9]\d{8})$/.exec(s);
26
+ if (trunk)
27
+ e164 = `+33${trunk[1]}`;
28
+ else if (/^[1-9]\d{8}$/.test(s))
29
+ e164 = `+33${s}`;
30
+ else if (/^\+[1-9]\d{7,14}$/.test(s))
31
+ e164 = s;
32
+ }
33
+ if (e164 === null)
34
+ return null;
35
+ // The subscriber's digits: past `+33` for a French number, past `+` otherwise.
36
+ const subscriber = e164.startsWith("+33") ? e164.slice(3) : e164.slice(1);
37
+ return /^(\d)\1+$/.test(subscriber) ? null : e164;
28
38
  }
29
- /** Whether a typed number reads as one — the form's soft check, never a block. */
39
+ /**
40
+ * Whether a typed number is one we can call back — the one rule the form,
41
+ * the callback and the server share (`phoneProblem`, `validateLead`).
42
+ */
30
43
  export function isPlausiblePhone(raw) {
31
44
  return normalizePhone(raw) !== null;
32
45
  }
46
+ /**
47
+ * What is wrong with a typed number, or `null`: `required` when nothing was
48
+ * typed, `invalid` when it is not a number we can call. The form blocks on it
49
+ * in the page's words and the server refuses on it — one rule, so a number
50
+ * the form lets through is never refused behind the visitor's back.
51
+ */
52
+ export function phoneProblem(raw) {
53
+ if (raw.trim() === "")
54
+ return "required";
55
+ return isPlausiblePhone(raw) ? null : "invalid";
56
+ }
33
57
  /**
34
58
  * A French mobile (`+33 6…`, `+33 7…`) — the one kind an SMS reaches. A
35
59
  * landline answers a text with silence, so the channel is not offered for it.
@@ -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,8 +6,8 @@
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";
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";
10
+ export { isMobilePhone, isPlausiblePhone, normalizePhone, phoneProblem } from "./core/phone.js";
11
11
  export { LEAD_CAPTURE_TEXT, type LeadCaptureText } from "./core/lead-capture-text.js";
12
12
  export { fillText, openingText } from "./core/lead-capture-format.js";
13
13
  export { channelHref, resolveChannels, smsHref, type CaptureChannel, type ChannelFacts, type ChannelOptions, type ResolvedChannels, } from "./core/channels.js";
@@ -17,7 +17,7 @@ export { areaServedNodes, breadcrumbNode, businessId, businessNode, faqPageNode,
17
17
  export { AI_CRAWLERS, ogImageUrl, robotsFor, sitemapFor, type RobotsFile, type RobotsRule, type SitemapEntry, } from "./core/seo/sitemap.js";
18
18
  export { ALLOWED_PROPS, analyticsSink, countsAsPageView, EVENTS, EXPERIMENT_SLUG, experimentProps, type AnalyticsTarget, type IntentChannel, type LeadField, type LeadStep, } from "./core/analytics.js";
19
19
  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";
20
+ export { brandStatusTarget, statusTarget, thanksChannel, thanksSuffix, type StatusTarget } from "./core/status.js";
21
21
  export { createLocaleRegistry, type LocaleRegistry } from "@evinvest/i18n";
22
22
  export { contactChannel, ldCompact, localBusiness, telHref, whatsappHref, type ContactChannel, type JsonLdNode, } from "@evinvest/marketing";
23
23
  export { createBeaconSink, noopSink, type AnalyticsSink } from "@evinvest/analytics";
package/dist/index.js CHANGED
@@ -6,8 +6,8 @@
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";
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
11
  export { LEAD_CAPTURE_TEXT } from "./core/lead-capture-text.js";
12
12
  export { fillText, openingText } from "./core/lead-capture-format.js";
13
13
  export { channelHref, resolveChannels, smsHref, } from "./core/channels.js";
@@ -16,7 +16,7 @@ export { createAcceptLead, EXPERIMENT_FIELD, FORM_ID_FIELD, LOCALE_FIELD, LOCATI
16
16
  export { areaServedNodes, breadcrumbNode, businessId, businessNode, faqPageNode, offerNodes, organizationId, organizationNode, placeGraph, websiteId, } from "./core/seo/ld.js";
17
17
  export { AI_CRAWLERS, ogImageUrl, robotsFor, sitemapFor, } from "./core/seo/sitemap.js";
18
18
  export { ALLOWED_PROPS, analyticsSink, countsAsPageView, EVENTS, EXPERIMENT_SLUG, experimentProps, } from "./core/analytics.js";
19
- export { brandStatusTarget, statusTarget } from "./core/status.js";
19
+ export { brandStatusTarget, statusTarget, thanksChannel, thanksSuffix } from "./core/status.js";
20
20
  // A landing needs these beside the machinery; re-exported by name so a brand
21
21
  // imports one package and the list here is the whole surface.
22
22
  export { createLocaleRegistry } from "@evinvest/i18n";
@@ -8,10 +8,14 @@ import type { LeadNotifier } from "../server/notify.js";
8
8
  /**
9
9
  * `app/quote/route.ts` — the no-JS path, and the one that has to keep working:
10
10
  * a plain form POST answered with a 303, so a refresh does not resubmit.
11
+ * A refused lead goes back to its card with the field to fix
12
+ * (`/fr?lead_error=phone#devis`); a script asking for JSON (`Accept:
13
+ * application/json`) is answered `422 { ok: false, field }` instead, and
14
+ * `200 { ok: true, location }` for a lead taken.
11
15
  *
12
16
  * ```ts
13
17
  * export const dynamic = "force-dynamic";
14
- * export const POST = quoteRoute(site, { env: serverEnv, notifier, webhook, unavailable });
18
+ * export const POST = quoteRoute(site, { env: serverEnv, notifier, webhook, unavailable, anchor: "devis" });
15
19
  * ```
16
20
  */
17
21
  export interface UnavailableCopy {
@@ -21,7 +25,8 @@ export interface UnavailableCopy {
21
25
  callLabel: string;
22
26
  }
23
27
  export interface QuoteRouteDeps<L extends string> {
24
- env: () => Pick<ServerEnv, "leadsDb" | "posthogKey" | "posthogHost" | "trustedProxy">;
28
+ /** `production` and `leadRateLimit` may be left out (a test's env): production's limit, no development warnings. */
29
+ env: () => Pick<ServerEnv, "leadsDb" | "posthogKey" | "posthogHost" | "trustedProxy"> & Partial<Pick<ServerEnv, "production" | "leadRateLimit">>;
25
30
  /** Built once (and at boot, by the brand) so a missing sender fails startup, not a lead. */
26
31
  notifier: () => LeadNotifier;
27
32
  /**
@@ -35,7 +40,15 @@ export interface QuoteRouteDeps<L extends string> {
35
40
  unavailable: (locale: L, place: Place<L> | null) => UnavailableCopy;
36
41
  /** Opened on first use (`next build` imports the route); defaults to `LEADS_DB_URL`'s. */
37
42
  store?: () => LeadStore;
43
+ /** Defaults to `env().leadRateLimit`, built on the first lead. */
38
44
  limiter?: RateLimiter;
45
+ /**
46
+ * The card's id a refused lead is sent back to, when the form did not post
47
+ * its own (`card`, which `LeadCapture` does): `quote` by default, the
48
+ * `LeadCapture` default. A slug, `[a-z0-9-]`; a callback lands on
49
+ * `<anchor>-callback`.
50
+ */
51
+ anchor?: string;
39
52
  /** Work after the response; `after` from `next/server` by default. */
40
53
  defer?: (task: () => Promise<void> | void) => void;
41
54
  now?: () => number;