@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.
- package/README.md +89 -23
- package/dist/cli/kitstart-outbox.d.ts +2 -0
- package/dist/cli/kitstart-outbox.js +10 -0
- package/dist/cli/outbox.d.ts +5 -0
- package/dist/cli/outbox.js +77 -0
- package/dist/core/accept.d.ts +22 -3
- package/dist/core/accept.js +40 -13
- package/dist/core/analytics.d.ts +3 -1
- package/dist/core/analytics.js +7 -0
- package/dist/core/lead-capture-text.d.ts +24 -1
- package/dist/core/lead-capture-text.js +20 -0
- package/dist/core/lead.d.ts +71 -10
- package/dist/core/lead.js +65 -11
- package/dist/core/phone.d.ts +20 -9
- package/dist/core/phone.js +43 -19
- package/dist/core/status.d.ts +13 -0
- package/dist/core/status.js +19 -2
- package/dist/index.d.ts +3 -3
- package/dist/index.js +3 -3
- package/dist/next/quote-route.d.ts +15 -2
- package/dist/next/quote-route.js +70 -10
- package/dist/react/LeadCapture.d.ts +7 -5
- package/dist/react/LeadCapture.js +18 -6
- package/dist/react/LeadCaptureChannels.d.ts +14 -1
- package/dist/react/LeadCaptureChannels.js +8 -5
- package/dist/react/LeadCaptureFields.d.ts +39 -7
- package/dist/react/LeadCaptureFields.js +91 -21
- package/dist/react/LeadCaptureNeed.d.ts +5 -0
- package/dist/react/LeadCaptureNeed.js +38 -2
- package/dist/react/LeadCaptureSubmit.d.ts +16 -0
- package/dist/react/LeadCaptureSubmit.js +13 -0
- package/dist/react/QuoteFormShell.d.ts +2 -0
- package/dist/react/QuoteFormShell.js +3 -2
- package/dist/react/use-lead-error.d.ts +24 -0
- package/dist/react/use-lead-error.js +73 -0
- package/dist/react/use-lead-events.d.ts +3 -0
- package/dist/react/use-lead-events.js +3 -2
- package/dist/react/use-lead-submit.d.ts +44 -9
- package/dist/react/use-lead-submit.js +122 -13
- package/dist/react/use-validity.d.ts +17 -0
- package/dist/react/use-validity.js +38 -0
- package/dist/server/env.d.ts +9 -0
- package/dist/server/env.js +12 -0
- package/dist/server/index.d.ts +1 -1
- package/dist/server/index.js +1 -1
- package/dist/server/lead-store-sqlite.d.ts +5 -1
- package/dist/server/lead-store-sqlite.js +37 -2
- package/dist/server/lead-webhook.d.ts +20 -1
- package/dist/server/lead-webhook.js +6 -2
- package/dist/server/webhook-outbox.d.ts +27 -1
- package/dist/server/webhook-outbox.js +33 -6
- package/dist/testing/index.d.ts +1 -0
- package/dist/testing/index.js +1 -0
- package/dist/testing/landing-contract.js +6 -0
- package/dist/testing/lead-rules.d.ts +18 -0
- package/dist/testing/lead-rules.js +50 -0
- package/dist/testing/lead-store-contract.js +20 -0
- package/package.json +3 -2
- package/template/flake.nix +1 -1
- package/template/package.json +1 -1
- package/template/src/shared/config/lead.ts +6 -3
- package/template/tests/e2e/quote-form.spec.ts +86 -1
package/dist/core/lead.d.ts
CHANGED
|
@@ -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
|
|
39
|
-
* `
|
|
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) =>
|
|
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
|
|
66
|
+
* `validateCallbackLead`: a number we can call.
|
|
46
67
|
*/
|
|
47
|
-
validateCallback?: (lead: LeadCandidate) =>
|
|
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
|
|
78
|
-
export declare
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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 {
|
|
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
|
|
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
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
129
|
+
return rejectionOf((schema.validate ?? validateLead)(lead));
|
|
76
130
|
}
|
package/dist/core/phone.d.ts
CHANGED
|
@@ -1,17 +1,28 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* A phone number as the funnel
|
|
3
|
-
*
|
|
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
|
|
7
|
-
* France): `06 12 34 56 78`, `+33 (0)6 …`,
|
|
8
|
-
* without its trunk zero (`6 12 34 56 78`) all
|
|
9
|
-
* other international number is
|
|
10
|
-
*
|
|
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
|
-
/**
|
|
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.
|
package/dist/core/phone.js
CHANGED
|
@@ -1,35 +1,59 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* A phone number as the funnel
|
|
3
|
-
*
|
|
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
|
|
7
|
-
* France): `06 12 34 56 78`, `+33 (0)6 …`,
|
|
8
|
-
* without its trunk zero (`6 12 34 56 78`) all
|
|
9
|
-
* other international number is
|
|
10
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
22
|
+
e164 = national ? `+33${national[1]}` : null;
|
|
19
23
|
}
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
/**
|
|
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.
|
package/dist/core/status.d.ts
CHANGED
|
@@ -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>;
|
package/dist/core/status.js
CHANGED
|
@@ -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 ?
|
|
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 ?
|
|
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
|
-
|
|
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;
|