@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.
- package/README.md +178 -25
- 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 +44 -3
- package/dist/core/accept.js +96 -15
- package/dist/core/analytics.d.ts +4 -1
- package/dist/core/analytics.js +14 -0
- package/dist/core/lead-capture-format.d.ts +6 -0
- package/dist/core/lead-capture-format.js +13 -0
- package/dist/core/lead-capture-text.d.ts +56 -2
- package/dist/core/lead-capture-text.js +63 -0
- package/dist/core/lead.d.ts +101 -11
- package/dist/core/lead.js +66 -11
- package/dist/core/phone.d.ts +20 -9
- package/dist/core/phone.js +43 -19
- package/dist/core/pricing/flow.d.ts +27 -0
- package/dist/core/pricing/flow.js +37 -0
- package/dist/core/pricing/index.d.ts +4 -0
- package/dist/core/pricing/index.js +4 -0
- package/dist/core/pricing/model.d.ts +129 -0
- package/dist/core/pricing/model.js +34 -0
- package/dist/core/pricing/price.d.ts +34 -0
- package/dist/core/pricing/price.js +105 -0
- package/dist/core/pricing/validate.d.ts +27 -0
- package/dist/core/pricing/validate.js +244 -0
- package/dist/core/site.d.ts +7 -0
- package/dist/core/site.js +11 -0
- package/dist/core/status.d.ts +13 -0
- package/dist/core/status.js +19 -2
- package/dist/index.d.ts +7 -6
- package/dist/index.js +7 -6
- package/dist/next/quote-route.d.ts +22 -2
- package/dist/next/quote-route.js +72 -10
- package/dist/react/LeadCapture.d.ts +37 -7
- package/dist/react/LeadCapture.js +34 -9
- package/dist/react/LeadCaptureChannels.d.ts +28 -3
- package/dist/react/LeadCaptureChannels.js +21 -5
- package/dist/react/LeadCaptureEstimate.d.ts +46 -0
- package/dist/react/LeadCaptureEstimate.js +69 -0
- 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/LeadCapturePriced.d.ts +19 -0
- package/dist/react/LeadCapturePriced.js +14 -0
- 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 +5 -0
- package/dist/react/use-lead-events.js +13 -2
- package/dist/react/use-lead-submit.d.ts +48 -9
- package/dist/react/use-lead-submit.js +129 -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 +3 -2
- package/dist/server/index.js +3 -2
- package/dist/server/lead-store-sqlite.d.ts +5 -1
- package/dist/server/lead-store-sqlite.js +63 -2
- package/dist/server/lead-webhook.d.ts +68 -1
- package/dist/server/lead-webhook.js +34 -2
- package/dist/server/pricing-source.d.ts +25 -0
- package/dist/server/pricing-source.js +48 -0
- 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 +28 -1
- package/package.json +3 -2
- package/template/app/[locale]/[location]/page.tsx +4 -2
- package/template/app/quote/route.ts +2 -1
- package/template/flake.nix +1 -1
- package/template/package.json +1 -1
- package/template/src/entities/content/model/en.ts +1 -1
- package/template/src/entities/content/model/fr.ts +1 -1
- package/template/src/shared/config/env.ts +4 -1
- package/template/src/shared/config/lead.ts +17 -4
- package/template/src/shared/config/pricing.ts +49 -0
- package/template/src/shared/config/site.ts +3 -0
- package/template/src/views/home/ui/PlaceHome.tsx +6 -3
- package/template/tests/bundle_budget.txt +3 -1
- package/template/tests/e2e/flows.spec.ts +106 -0
- package/template/tests/e2e/mock-panel.mjs +20 -0
- package/template/tests/e2e/playwright.config.ts +25 -9
- package/template/tests/e2e/quote-form.spec.ts +86 -1
package/dist/core/analytics.d.ts
CHANGED
|
@@ -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. */
|
package/dist/core/analytics.js
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
+
}
|
package/dist/core/lead.d.ts
CHANGED
|
@@ -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
|
|
39
|
-
* `
|
|
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) =>
|
|
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
|
|
68
|
+
* `validateCallbackLead`: a number we can call.
|
|
46
69
|
*/
|
|
47
|
-
validateCallback?: (lead: LeadCandidate) =>
|
|
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
|
|
78
|
-
export declare
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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 {
|
|
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,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
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
130
|
+
return rejectionOf((schema.validate ?? validateLead)(lead));
|
|
76
131
|
}
|
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.
|