@evinvest/kitstart 0.5.0 → 0.6.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 +110 -3
- package/dist/cli/size.d.ts +26 -4
- package/dist/cli/size.js +54 -16
- package/dist/core/accept.d.ts +9 -1
- package/dist/core/accept.js +13 -2
- package/dist/core/analytics.d.ts +22 -3
- package/dist/core/analytics.js +27 -2
- package/dist/core/channels.d.ts +47 -0
- package/dist/core/channels.js +51 -0
- package/dist/core/lead-capture-format.d.ts +6 -0
- package/dist/core/lead-capture-format.js +32 -0
- package/dist/core/lead-capture-text.d.ts +50 -0
- package/dist/core/lead-capture-text.js +57 -0
- package/dist/core/lead.d.ts +59 -4
- package/dist/core/lead.js +38 -4
- package/dist/core/phone.d.ts +19 -0
- package/dist/core/phone.js +39 -0
- package/dist/core/place/hours.d.ts +15 -0
- package/dist/core/place/hours.js +68 -0
- package/dist/core/place/index.d.ts +1 -0
- package/dist/core/place/index.js +1 -0
- package/dist/index.d.ts +8 -4
- package/dist/index.js +8 -4
- package/dist/next/quote-route.d.ts +1 -1
- package/dist/next/quote-route.js +4 -1
- package/dist/react/AnalyticsBoundary.js +11 -5
- package/dist/react/CallBar.d.ts +17 -2
- package/dist/react/CallBar.js +26 -3
- package/dist/react/LeadCapture.d.ts +61 -0
- package/dist/react/LeadCapture.js +60 -0
- package/dist/react/LeadCaptureChannels.d.ts +54 -0
- package/dist/react/LeadCaptureChannels.js +37 -0
- package/dist/react/LeadCaptureFields.d.ts +32 -0
- package/dist/react/LeadCaptureFields.js +42 -0
- package/dist/react/LeadCaptureNeed.d.ts +27 -0
- package/dist/react/LeadCaptureNeed.js +22 -0
- package/dist/react/analytics-context.d.ts +11 -0
- package/dist/react/analytics-context.js +15 -0
- package/dist/react/index.d.ts +3 -1
- package/dist/react/index.js +2 -1
- package/dist/react/use-lead-context.d.ts +24 -0
- package/dist/react/use-lead-context.js +71 -0
- package/dist/react/use-lead-events.d.ts +23 -0
- package/dist/react/use-lead-events.js +62 -0
- package/dist/server/index.d.ts +1 -1
- package/dist/server/index.js +1 -1
- package/dist/server/lead-store-sqlite.js +12 -4
- package/dist/server/lead-webhook.d.ts +12 -1
- package/dist/server/lead-webhook.js +12 -0
- package/dist/server/notify.d.ts +1 -1
- package/dist/server/notify.js +5 -2
- package/dist/testing/lead-store-contract.js +4 -2
- package/package.json +1 -1
- package/template/README.md +7 -4
- package/template/flake.nix +1 -1
- package/template/package.json +1 -1
- package/template/src/entities/content/model/en.ts +2 -2
- package/template/src/entities/content/model/fr.ts +2 -2
- package/template/src/entities/content/model/types.ts +3 -2
- package/template/src/shared/config/lead.ts +2 -0
- package/template/src/views/home/ui/PlaceHome.tsx +25 -40
- package/template/tests/bundle_budget.txt +8 -3
- package/template/tests/e2e/quote-form.spec.ts +86 -4
package/README.md
CHANGED
|
@@ -18,10 +18,10 @@ stays in the brand.
|
|
|
18
18
|
| `@evinvest/kitstart/proxy` | edge | `createProxy(site)`, `PROXY_MATCHER` |
|
|
19
19
|
| `@evinvest/kitstart/next` | Next server (routes, RSC) | `quoteRoute`, `sitemapRoute`, `robotsRoute`, `ogRoute`, `healthRoute`, `createPlaceLoader`, `loadLocale`, `placeMetadata` / `brandMetadata` / `statusMetadata`, `metadataBase` |
|
|
20
20
|
| `@evinvest/kitstart/next/config` | `next.config.ts`, `vitest.config.ts` | `withLanding`, `buildEnv` and the `assets/` readers |
|
|
21
|
-
| `@evinvest/kitstart/react` | either side | `LangSwitch`, `CallBar`, `StatusScreen`, `PlaceDirectory`, `AreaChips`, `Coverage`, `MapFacade` (client), `QuoteFormShell`, `FormSelect` (client), `Faq`, `AnalyticsBoundary` (client), plus the kit and marketing pieces a landing composes with |
|
|
21
|
+
| `@evinvest/kitstart/react` | either side | `LangSwitch`, `CallBar`, `StatusScreen`, `PlaceDirectory`, `AreaChips`, `Coverage`, `MapFacade` (client), `QuoteFormShell`, `FormSelect` (client), `LeadCapture` (client), `Faq`, `AnalyticsBoundary` (client), plus the kit and marketing pieces a landing composes with |
|
|
22
22
|
| `@evinvest/kitstart/testing` | a brand's vitest | `describeLandingContract(site, { globalsCss, proxySource, text })`, `describeLeadStoreContract(name, harness)`, `storefrontPlace`, `serviceAreaPlace`, `testLead` |
|
|
23
23
|
| `@evinvest/kitstart/testing/e2e` | a brand's Playwright | `defineSectionSuite(sections)`, `settle(page, selector)`, `BREAKPOINTS` |
|
|
24
|
-
| bin `kitstart-size` | plain node | `kitstart-size [<build root>] [--route …] [--budget …]`: first-load JS of a place page against `tests/bundle_budget.txt
|
|
24
|
+
| bin `kitstart-size` | plain node | `kitstart-size [<build root>] [--route …] [--budget …]`: first-load JS of a place page against the target in `tests/bundle_budget.txt` (passes with a warning up to its tolerance, 20 % by default — see [The bundle budget](#the-bundle-budget)); fails closed |
|
|
25
25
|
|
|
26
26
|
`vitest` and `@playwright/test` are not peers at all: they are the brand's
|
|
27
27
|
own test runners, which it installs in its `devDependencies`. As peers —
|
|
@@ -45,7 +45,7 @@ brand's `flake.nix` is its own config plus one call — `template/flake.nix` is
|
|
|
45
45
|
the whole of one:
|
|
46
46
|
|
|
47
47
|
```nix
|
|
48
|
-
inputs.ev.url = "github:EV-invest/lib?ref=@evinvest/kitstart-v0.
|
|
48
|
+
inputs.ev.url = "github:EV-invest/lib?ref=@evinvest/kitstart-v0.6.0"; # the version in package-lock.json
|
|
49
49
|
inputs.ev.inputs.v_flakes.follows = "v_flakes";
|
|
50
50
|
|
|
51
51
|
landing = ev.lib.mkLanding {
|
|
@@ -77,6 +77,30 @@ landing = ev.lib.mkLanding {
|
|
|
77
77
|
whose `resolved` 404s.
|
|
78
78
|
- `container`: the OCI image on node-slim, `prodEnv` baked in.
|
|
79
79
|
- `checks.bundle-budget`: `kitstart-size` on the Nix build.
|
|
80
|
+
|
|
81
|
+
### The bundle budget
|
|
82
|
+
|
|
83
|
+
`tests/bundle_budget.txt` holds the **target** — the gzip bytes of
|
|
84
|
+
first-load JS the place page should weigh — and, optionally, how far over it
|
|
85
|
+
a build may go:
|
|
86
|
+
|
|
87
|
+
```
|
|
88
|
+
# Why the number is what it is (comments are free).
|
|
89
|
+
158000
|
|
90
|
+
tolerance 20%
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
| Measured | `kitstart-size` |
|
|
94
|
+
|---|---|
|
|
95
|
+
| ≤ target | passes |
|
|
96
|
+
| ≤ target × (1 + tolerance) | passes **with a warning**: the overshoot in bytes and %, and a `::warning::` annotation on GitHub Actions |
|
|
97
|
+
| more | fails |
|
|
98
|
+
|
|
99
|
+
Without a `tolerance` line the tolerance is 20 % — a file of one number, as
|
|
100
|
+
every budget was before, is a target. The ceiling rounds down to a whole
|
|
101
|
+
byte. The overshoot is meant to be paid back; changing the target or the
|
|
102
|
+
tolerance is a deliberate commit, with a reason, as raising the budget was.
|
|
103
|
+
`checks.bundle-budget` applies the same rule (no annotation in the sandbox).
|
|
80
104
|
- `apps.test` (tsc — the app, then `e2eConfig` as its own project with its own
|
|
81
105
|
`tsconfig.json` — lint, vitest, build, size, Playwright on the flake's
|
|
82
106
|
pinned browsers), `accept-test` (Linux only), `size`, `dev`, `help`.
|
|
@@ -169,6 +193,85 @@ which CI runs: `npm run check:template -- --e2e`).
|
|
|
169
193
|
page: 3.8 KB gz of first-load JS on the template's place page (152,774 →
|
|
170
194
|
156,565 B of its 158,000 B budget).
|
|
171
195
|
|
|
196
|
+
### `LeadCapture`: the lead form every brand shares
|
|
197
|
+
|
|
198
|
+
One form for every brand, so an experiment's results pool across sites (the
|
|
199
|
+
site is the stratum). A client island over `QuoteFormShell`: without a script
|
|
200
|
+
it is the plain POST to `/quote` it always was.
|
|
201
|
+
|
|
202
|
+
```tsx
|
|
203
|
+
<LeadCapture
|
|
204
|
+
place={view.place}
|
|
205
|
+
contact={contactOf(site, view.place)}
|
|
206
|
+
locale={view.locale}
|
|
207
|
+
renderedAt={renderedAt}
|
|
208
|
+
wire={LEAD.wire}
|
|
209
|
+
needs={LEAD.subjects.map(s => ({ value: s, label: t.subjects[s] }))}
|
|
210
|
+
text={{ ...LEAD_CAPTURE_TEXT[view.locale], submit: "…" }}
|
|
211
|
+
layout={arm === "b" ? "qualify-first" : "single"}
|
|
212
|
+
experiment={{ name: "lead_layout", variant: arm }}
|
|
213
|
+
/>
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
| Prop | |
|
|
217
|
+
|---|---|
|
|
218
|
+
| `place`, `contact` | the hours order the channels, the service area suggests the commune; `contact` is `contactOf(site, place)` — a `null` number is a channel not offered |
|
|
219
|
+
| `locale`, `renderedAt` | the page's language; the render stamp (time trap, and "now" until the script runs) |
|
|
220
|
+
| `wire`, `needs` | `site.lead.wire`; the subjects with their labels |
|
|
221
|
+
| `need` | the need the page already knows — also set by `?need=` and by a tap on any `[data-need="…"]` element |
|
|
222
|
+
| `layout` | `single` (default) · `qualify-first`: a tile per need, then the contact step |
|
|
223
|
+
| `locality` | `required` (default) · `optional` |
|
|
224
|
+
| `name` | `{ field, required? }`: a name field posted as the brand's extra; off by default |
|
|
225
|
+
| `extras` | the brand's own fields, after the phone |
|
|
226
|
+
| `prefer` | a channel moved first when available (a `default_channel` arm) |
|
|
227
|
+
| `experiment` | `{ name, variant }`, slugs: on every event and posted with the form |
|
|
228
|
+
| `timeZone` | `Europe/Paris` by default |
|
|
229
|
+
| `formId`, `id` | `quote` · `quote`; the callback is `<id>-callback` (its form `<id>-callback-form`) |
|
|
230
|
+
| `text` | `LeadCaptureText`: `LEAD_CAPTURE_TEXT.fr` / `.en`, plain strings (`{need}`, `{day}`, `{time}` filled in) |
|
|
231
|
+
| `head`, `trust` | the brand's heading instead of the title; a slot beside the submit |
|
|
232
|
+
| `className` · `classNames` | the root · its parts (`form`, `field`, `label`, `control`, `submit`, `need`, `channel`, `primary`, `callback`…) |
|
|
233
|
+
|
|
234
|
+
- **Taps.** A need the page knows is not asked again, and a place serving one
|
|
235
|
+
commune fills it: focus the phone, type, send — two taps. `qualify-first`
|
|
236
|
+
with no need: the tile, which moves the focus to the first empty field (the
|
|
237
|
+
phone when the commune is filled), then send.
|
|
238
|
+
`single` with no need takes the first one unless changed (two more taps).
|
|
239
|
+
Enter sends.
|
|
240
|
+
- **Channels** (`resolveChannels`, shared with `CallBar`): call (`tel:`),
|
|
241
|
+
WhatsApp and SMS (the need in the message; SMS only to a mobile), "call me
|
|
242
|
+
back" and the form. Open, the call leads; closed, the callback (open) and
|
|
243
|
+
WhatsApp lead and the call goes last, and the form says when the next
|
|
244
|
+
opening is — from the place's hours, never invented. No hours: no promise,
|
|
245
|
+
the open order. No number: no call, text or WhatsApp. `CallBar` orders its
|
|
246
|
+
buttons the same way when given `hours` (and `now={renderedAt}`, and
|
|
247
|
+
`callback={{ href: view.href("#quote-callback"), label }}`); without
|
|
248
|
+
`hours` it is unchanged.
|
|
249
|
+
- **Callback.** A `<details>` with its own small form: the phone and a native,
|
|
250
|
+
required consent, posted to `/quote` with `channel=callback`. The consent's
|
|
251
|
+
value is the sentence it shows (`text.callbackConsent`, in the page's
|
|
252
|
+
language); the server refuses a callback without it — back to the form, as
|
|
253
|
+
any invalid lead, whatever the brand's rule — and stores it with the lead:
|
|
254
|
+
`consent_text` word for word, `consent_at` when the server accepted it
|
|
255
|
+
(`Lead.consent`). Then the lead is held to `lead.validateCallback` (a
|
|
256
|
+
readable number, by default), not to the form's rule. The consent goes into
|
|
257
|
+
no event; a webhook body built by the brand should leave `lead.consent` out.
|
|
258
|
+
- **Phone.** `type="tel"`, required, never masked. A number that does not read
|
|
259
|
+
as one gets a hint when the field is left; the server keeps it. With
|
|
260
|
+
`lead.mobileFormat: "e164"` a number it can read is stored as
|
|
261
|
+
`+33612345678` (`normalizePhone`); the default keeps it as typed, which is
|
|
262
|
+
what a brand's tests and tooling look rows up by.
|
|
263
|
+
- **Events** (through `AnalyticsBoundary`'s sink; none outside one):
|
|
264
|
+
`lead_form_view` (half in view, once), `lead_form_start` (first focus),
|
|
265
|
+
`lead_form_field_error {field}` (the browser refused it, or the phone hint
|
|
266
|
+
showed — the field's role, never its value), `lead_form_step {step}`
|
|
267
|
+
(`contact` / `need`), each with `form_id`, `layout`, `experiment`,
|
|
268
|
+
`variant`; `contact_intent_click {channel}` for `phone`, `whatsapp`, `sms`,
|
|
269
|
+
`callback`, with the experiment; and on the server `lead_form_submit
|
|
270
|
+
{form_id, channel}` with the posted experiment.
|
|
271
|
+
- **Weight.** 5.6 KB gz of first-load JS on the template's place page
|
|
272
|
+
(157,287 → 162,985 B against its 158,000 B target: +3.2 %, a warning
|
|
273
|
+
within the 20 % tolerance).
|
|
274
|
+
|
|
172
275
|
Tailwind v4 does not scan `node_modules`; the brand's `globals.css` names the
|
|
173
276
|
package:
|
|
174
277
|
|
|
@@ -285,6 +388,10 @@ export const POST = quoteRoute(site, { env: serverEnv, notifier, webhook, unavai
|
|
|
285
388
|
`LEAD_WEBHOOK_KEY_ID` and `LEAD_WEBHOOK_SECRET` are required, and the URL
|
|
286
389
|
must be `https:`, or `http:` to a `*.svc` / `*.svc.cluster.local` host or
|
|
287
390
|
loopback — the body carries PII. All checked at boot.
|
|
391
|
+
- **The channel.** `channelOf(lead)` is `form` or `callback`. For the
|
|
392
|
+
Service-Arb panel, map it through `panelChannel`: its `properties.channel`
|
|
393
|
+
is a closed set and refuses the whole event outside it, so a callback goes
|
|
394
|
+
as `form` until the panel accepts `callback` (one constant to flip).
|
|
288
395
|
- **Queued before the 303, sent after it.** The body is built once, from the
|
|
289
396
|
lead and `ctx` (`leadId`, `brandId`, `locale`, `formId`, `at`, and a fresh
|
|
290
397
|
`idempotencyKey` for the receiver to deduplicate by), and written to the
|
package/dist/cli/size.d.ts
CHANGED
|
@@ -3,13 +3,35 @@
|
|
|
3
3
|
* page makes the browser download before it is interactive, against the
|
|
4
4
|
* brand's committed budget. Run after `next build`; `mkLanding`'s
|
|
5
5
|
* `bundle-budget` check runs it against the Nix build.
|
|
6
|
+
*
|
|
7
|
+
* The budget is a target with a bounded overshoot: at or under the target
|
|
8
|
+
* passes; up to `tolerance` over it passes with a loud warning (an annotation
|
|
9
|
+
* on GitHub Actions), so a feature can land while the weight is paid back;
|
|
10
|
+
* past that it fails.
|
|
6
11
|
*/
|
|
7
12
|
/** The page the visitor lands on; every place page shares its chunks. */
|
|
8
13
|
export declare const GATED_ROUTE = "/[locale]/[location]";
|
|
9
14
|
export declare const STATS = ".next/diagnostics/route-bundle-stats.json";
|
|
10
15
|
export declare const BUDGET = "tests/bundle_budget.txt";
|
|
11
|
-
/**
|
|
12
|
-
export declare
|
|
16
|
+
/** How far over the target a build may go, with a warning, when the file does not say. */
|
|
17
|
+
export declare const DEFAULT_TOLERANCE_PERCENT = 20;
|
|
18
|
+
export interface Budget {
|
|
19
|
+
/** The size the page should be: gzip bytes. */
|
|
20
|
+
target: number;
|
|
21
|
+
/** Percent over `target` that passes with a warning. */
|
|
22
|
+
tolerancePercent: number;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* The budget file is commented prose around a byte count — the target — and,
|
|
26
|
+
* optionally, a `tolerance <n>%` line. A file of one number, as every budget
|
|
27
|
+
* was before tolerances, is that target with the default tolerance. Any other
|
|
28
|
+
* line is an error: a gate that guesses at its own file is no gate.
|
|
29
|
+
*/
|
|
30
|
+
export declare function parseBudget(text: string, file?: string): Budget;
|
|
31
|
+
export type Verdict = "under" | "tolerated" | "over";
|
|
32
|
+
/** The largest size that still passes: the target plus its tolerance, rounded down. */
|
|
33
|
+
export declare const ceilingOf: (budget: Budget) => number;
|
|
34
|
+
export declare function judge(total: number, budget: Budget): Verdict;
|
|
13
35
|
/**
|
|
14
36
|
* The chunks Next itself lists as the route's first load. Undocumented output,
|
|
15
37
|
* which is why a missing file or route is a failure and never a pass: a gate
|
|
@@ -27,5 +49,5 @@ export interface SizeArgs {
|
|
|
27
49
|
budget: string;
|
|
28
50
|
}
|
|
29
51
|
export declare function parseArgs(argv: readonly string[], cwd: string): SizeArgs;
|
|
30
|
-
/** Prints the chunks and the total; returns the exit code. */
|
|
31
|
-
export declare function measure(args: SizeArgs, log?: Pick<Console, "log" | "error"
|
|
52
|
+
/** Prints the chunks and the total; returns the exit code. `env` says whether this is GitHub Actions. */
|
|
53
|
+
export declare function measure(args: SizeArgs, log?: Pick<Console, "log" | "warn" | "error">, env?: Readonly<Record<string, string | undefined>>): number;
|
package/dist/cli/size.js
CHANGED
|
@@ -6,22 +6,48 @@ import { gzipSync } from "node:zlib";
|
|
|
6
6
|
* page makes the browser download before it is interactive, against the
|
|
7
7
|
* brand's committed budget. Run after `next build`; `mkLanding`'s
|
|
8
8
|
* `bundle-budget` check runs it against the Nix build.
|
|
9
|
+
*
|
|
10
|
+
* The budget is a target with a bounded overshoot: at or under the target
|
|
11
|
+
* passes; up to `tolerance` over it passes with a loud warning (an annotation
|
|
12
|
+
* on GitHub Actions), so a feature can land while the weight is paid back;
|
|
13
|
+
* past that it fails.
|
|
9
14
|
*/
|
|
10
15
|
/** The page the visitor lands on; every place page shares its chunks. */
|
|
11
16
|
export const GATED_ROUTE = "/[locale]/[location]";
|
|
12
17
|
export const STATS = ".next/diagnostics/route-bundle-stats.json";
|
|
13
18
|
export const BUDGET = "tests/bundle_budget.txt";
|
|
14
|
-
/**
|
|
19
|
+
/** How far over the target a build may go, with a warning, when the file does not say. */
|
|
20
|
+
export const DEFAULT_TOLERANCE_PERCENT = 20;
|
|
21
|
+
/**
|
|
22
|
+
* The budget file is commented prose around a byte count — the target — and,
|
|
23
|
+
* optionally, a `tolerance <n>%` line. A file of one number, as every budget
|
|
24
|
+
* was before tolerances, is that target with the default tolerance. Any other
|
|
25
|
+
* line is an error: a gate that guesses at its own file is no gate.
|
|
26
|
+
*/
|
|
15
27
|
export function parseBudget(text, file = BUDGET) {
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
28
|
+
let target;
|
|
29
|
+
let tolerancePercent;
|
|
30
|
+
for (const line of text.split("\n").map(l => l.trim())) {
|
|
31
|
+
if (line === "" || line.startsWith("#"))
|
|
32
|
+
continue;
|
|
33
|
+
const tolerance = /^tolerance\s+(\d+(?:\.\d+)?)\s*%$/.exec(line);
|
|
34
|
+
if (/^\d+$/.test(line) && target === undefined)
|
|
35
|
+
target = Number(line);
|
|
36
|
+
else if (tolerance && tolerancePercent === undefined)
|
|
37
|
+
tolerancePercent = Number(tolerance[1]);
|
|
38
|
+
else
|
|
39
|
+
throw new Error(`${file}: expected one byte count and at most one \`tolerance <n>%\` line, got ${JSON.stringify(line)}`);
|
|
23
40
|
}
|
|
24
|
-
|
|
41
|
+
if (target === undefined)
|
|
42
|
+
throw new Error(`${file}: no byte count — the target is a line of digits`);
|
|
43
|
+
return { target, tolerancePercent: tolerancePercent ?? DEFAULT_TOLERANCE_PERCENT };
|
|
44
|
+
}
|
|
45
|
+
/** The largest size that still passes: the target plus its tolerance, rounded down. */
|
|
46
|
+
export const ceilingOf = (budget) => Math.floor((budget.target * (100 + budget.tolerancePercent)) / 100);
|
|
47
|
+
export function judge(total, budget) {
|
|
48
|
+
if (total <= budget.target)
|
|
49
|
+
return "under";
|
|
50
|
+
return total <= ceilingOf(budget) ? "tolerated" : "over";
|
|
25
51
|
}
|
|
26
52
|
function isRouteStats(value) {
|
|
27
53
|
if (typeof value !== "object" || value === null)
|
|
@@ -69,8 +95,8 @@ export function parseArgs(argv, cwd) {
|
|
|
69
95
|
}
|
|
70
96
|
return args;
|
|
71
97
|
}
|
|
72
|
-
/** Prints the chunks and the total; returns the exit code. */
|
|
73
|
-
export function measure(args, log = console) {
|
|
98
|
+
/** Prints the chunks and the total; returns the exit code. `env` says whether this is GitHub Actions. */
|
|
99
|
+
export function measure(args, log = console, env = process.env) {
|
|
74
100
|
let stats;
|
|
75
101
|
try {
|
|
76
102
|
stats = readFileSync(join(args.root, STATS), "utf8");
|
|
@@ -90,10 +116,22 @@ export function measure(args, log = console) {
|
|
|
90
116
|
log.log(` ${String(gz).padStart(8)} B gz ${relative(args.root, join(args.root, chunk))}`);
|
|
91
117
|
}
|
|
92
118
|
const kb = (n) => (n / 1024).toFixed(1);
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
119
|
+
const ceiling = ceilingOf(budget);
|
|
120
|
+
log.log(` ${args.route}: ${kb(total)} KB gz (${kb(raw)} KB raw) / target ${kb(budget.target)} KB gz, ceiling ${kb(ceiling)} KB gz (+${budget.tolerancePercent} %)`);
|
|
121
|
+
const over = total - budget.target;
|
|
122
|
+
const percent = ((over / budget.target) * 100).toFixed(1);
|
|
123
|
+
switch (judge(total, budget)) {
|
|
124
|
+
case "under":
|
|
125
|
+
return 0;
|
|
126
|
+
case "tolerated": {
|
|
127
|
+
const message = `${args.route} is ${over} B (${percent} %) over its target of ${budget.target} B gz — within the ${budget.tolerancePercent} % tolerance (${ceiling} B), so it passes. Pay it back, or raise the target in ${args.budget} with a reason.`;
|
|
128
|
+
log.warn(`⚠ ${message}`);
|
|
129
|
+
if (env["GITHUB_ACTIONS"] === "true")
|
|
130
|
+
log.log(`::warning file=${args.budget},title=Bundle over its target::${message}`);
|
|
131
|
+
return 0;
|
|
132
|
+
}
|
|
133
|
+
case "over":
|
|
134
|
+
log.error(`✘ ${over} B (${percent} %) over the target of ${budget.target} B gz, past its ${budget.tolerancePercent} % tolerance by ${total - ceiling} B. Raising ${args.budget} is a deliberate commit, with a reason.`);
|
|
135
|
+
return 1;
|
|
97
136
|
}
|
|
98
|
-
return 0;
|
|
99
137
|
}
|
package/dist/core/accept.d.ts
CHANGED
|
@@ -5,6 +5,14 @@ import type { Site } from "./site.js";
|
|
|
5
5
|
export declare const LOCATION_FIELD = "location";
|
|
6
6
|
export declare const LOCALE_FIELD = "locale";
|
|
7
7
|
export declare const FORM_ID_FIELD = "form_id";
|
|
8
|
+
/** The site's experiment assignment, posted so the submit counts in the right arm. */
|
|
9
|
+
export declare const EXPERIMENT_FIELD = "experiment";
|
|
10
|
+
export declare const VARIANT_FIELD = "variant";
|
|
11
|
+
/** What the submit event carries besides the form id: slugs only, never what was typed. */
|
|
12
|
+
export interface SubmitTags {
|
|
13
|
+
experiment?: string;
|
|
14
|
+
variant?: string;
|
|
15
|
+
}
|
|
8
16
|
export type Outcome<L extends string> = {
|
|
9
17
|
kind: "stored";
|
|
10
18
|
id: number;
|
|
@@ -36,7 +44,7 @@ export interface AcceptDeps {
|
|
|
36
44
|
locale: string;
|
|
37
45
|
formId: string;
|
|
38
46
|
}) => void;
|
|
39
|
-
capture: (lead: Lead, formId: string) => void;
|
|
47
|
+
capture: (lead: Lead, formId: string, tags: SubmitTags) => void;
|
|
40
48
|
limiter: RateLimiter;
|
|
41
49
|
now: number;
|
|
42
50
|
log: Pick<Console, "warn" | "error">;
|
package/dist/core/accept.js
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
|
+
import { experimentProps } from "./analytics.js";
|
|
1
2
|
import { HONEYPOT_FIELD, LEGACY_HONEYPOT_FIELDS, RENDERED_AT_FIELD, screen } from "./antispam.js";
|
|
2
3
|
import { MAX_FIELD, readCandidate, validateCandidate } from "./lead.js";
|
|
3
4
|
/** Hidden fields the form carries besides what the visitor types. */
|
|
4
5
|
export const LOCATION_FIELD = "location";
|
|
5
6
|
export const LOCALE_FIELD = "locale";
|
|
6
7
|
export const FORM_ID_FIELD = "form_id";
|
|
8
|
+
/** The site's experiment assignment, posted so the submit counts in the right arm. */
|
|
9
|
+
export const EXPERIMENT_FIELD = "experiment";
|
|
10
|
+
export const VARIANT_FIELD = "variant";
|
|
7
11
|
const FORM_ID = /^[a-z0-9_-]{1,32}$/;
|
|
8
12
|
function field(form, name) {
|
|
9
13
|
const value = form.get(name);
|
|
@@ -45,7 +49,13 @@ async function accept(site, form, clientKey, deps) {
|
|
|
45
49
|
now: deps.now,
|
|
46
50
|
limiter: deps.limiter,
|
|
47
51
|
});
|
|
48
|
-
const
|
|
52
|
+
const { consentText, ...rest } = candidate;
|
|
53
|
+
const lead = {
|
|
54
|
+
...rest,
|
|
55
|
+
spamVerdict: verdict === "ok" ? null : verdict,
|
|
56
|
+
// Stamped by the server: the moment it accepted the consent with the lead.
|
|
57
|
+
...(consentText ? { consent: { text: consentText, at: new Date(deps.now).toISOString() } } : {}),
|
|
58
|
+
};
|
|
49
59
|
let id;
|
|
50
60
|
try {
|
|
51
61
|
id = await deps.insert(lead);
|
|
@@ -81,6 +91,7 @@ async function accept(site, form, clientKey, deps) {
|
|
|
81
91
|
deps.log.error(`quote: lead ${id} is stored but its notification failed`, error);
|
|
82
92
|
}
|
|
83
93
|
});
|
|
84
|
-
|
|
94
|
+
const tags = experimentProps(field(form, EXPERIMENT_FIELD), field(form, VARIANT_FIELD));
|
|
95
|
+
deps.defer(() => deps.capture(lead, formId, tags));
|
|
85
96
|
return { kind: "stored", id, lead, locale, formId };
|
|
86
97
|
}
|
package/dist/core/analytics.d.ts
CHANGED
|
@@ -10,15 +10,34 @@ export declare const EVENTS: {
|
|
|
10
10
|
readonly pageView: "location_page_view";
|
|
11
11
|
readonly intent: "contact_intent_click";
|
|
12
12
|
readonly leadSubmit: "lead_form_submit";
|
|
13
|
+
readonly formView: "lead_form_view";
|
|
14
|
+
readonly formStart: "lead_form_start";
|
|
15
|
+
readonly fieldError: "lead_form_field_error";
|
|
16
|
+
readonly formStep: "lead_form_step";
|
|
13
17
|
};
|
|
14
|
-
export type IntentChannel = "form_open" | "whatsapp" | "phone" | "booking";
|
|
18
|
+
export type IntentChannel = "form_open" | "whatsapp" | "phone" | "sms" | "callback" | "booking";
|
|
19
|
+
/**
|
|
20
|
+
* A form field as events name it — the role, never the brand's wire name, so
|
|
21
|
+
* one field reads the same on every site. A brand's extra is its own name.
|
|
22
|
+
*/
|
|
23
|
+
export type LeadField = "need" | "locality" | "phone" | "name" | "consent" | (string & {});
|
|
24
|
+
/** `qualify-first`'s two screens; `single` has one and never reports a step. */
|
|
25
|
+
export type LeadStep = "need" | "contact";
|
|
15
26
|
/**
|
|
16
27
|
* Every property name an event may carry. A customer's phone or address comes
|
|
17
28
|
* through the same pages, and once it reaches PostHog the only remedy is
|
|
18
29
|
* deleting the project's history — so the list is enforced by the sink (dev
|
|
19
|
-
* throws, prod drops) rather than by review.
|
|
30
|
+
* throws, prod drops) rather than by review. `experiment` and `variant` are
|
|
31
|
+
* the site's assignment, passed in; `field` is a field's role, never its value.
|
|
20
32
|
*/
|
|
21
|
-
export declare const ALLOWED_PROPS: readonly ["brand_id", "location_id", "source", "device", "channel", "form_id"];
|
|
33
|
+
export declare const ALLOWED_PROPS: readonly ["brand_id", "location_id", "source", "device", "channel", "form_id", "experiment", "variant", "field", "step", "layout"];
|
|
34
|
+
/** What an experiment's assignment looks like on the wire: short slugs, nothing a person typed. */
|
|
35
|
+
export declare const EXPERIMENT_SLUG: RegExp;
|
|
36
|
+
/** An experiment's assignment as event properties, or nothing when it is not a pair of slugs. */
|
|
37
|
+
export declare function experimentProps(experiment: string | null | undefined, variant: string | null | undefined): {
|
|
38
|
+
experiment: string;
|
|
39
|
+
variant: string;
|
|
40
|
+
} | Record<string, never>;
|
|
22
41
|
/**
|
|
23
42
|
* The thank-you page is the form's receipt, not a visit to the point: counting
|
|
24
43
|
* it as `location_page_view` would add a page view to every lead.
|
package/dist/core/analytics.js
CHANGED
|
@@ -11,14 +11,39 @@ export const EVENTS = {
|
|
|
11
11
|
pageView: "location_page_view",
|
|
12
12
|
intent: "contact_intent_click",
|
|
13
13
|
leadSubmit: "lead_form_submit",
|
|
14
|
+
// The lead form's own funnel, one schema for every brand so an experiment's
|
|
15
|
+
// results pool across sites: seen → touched → stuck on a field → step → sent.
|
|
16
|
+
formView: "lead_form_view",
|
|
17
|
+
formStart: "lead_form_start",
|
|
18
|
+
fieldError: "lead_form_field_error",
|
|
19
|
+
formStep: "lead_form_step",
|
|
14
20
|
};
|
|
15
21
|
/**
|
|
16
22
|
* Every property name an event may carry. A customer's phone or address comes
|
|
17
23
|
* through the same pages, and once it reaches PostHog the only remedy is
|
|
18
24
|
* deleting the project's history — so the list is enforced by the sink (dev
|
|
19
|
-
* throws, prod drops) rather than by review.
|
|
25
|
+
* throws, prod drops) rather than by review. `experiment` and `variant` are
|
|
26
|
+
* the site's assignment, passed in; `field` is a field's role, never its value.
|
|
20
27
|
*/
|
|
21
|
-
export const ALLOWED_PROPS = [
|
|
28
|
+
export const ALLOWED_PROPS = [
|
|
29
|
+
"brand_id",
|
|
30
|
+
"location_id",
|
|
31
|
+
"source",
|
|
32
|
+
"device",
|
|
33
|
+
"channel",
|
|
34
|
+
"form_id",
|
|
35
|
+
"experiment",
|
|
36
|
+
"variant",
|
|
37
|
+
"field",
|
|
38
|
+
"step",
|
|
39
|
+
"layout",
|
|
40
|
+
];
|
|
41
|
+
/** What an experiment's assignment looks like on the wire: short slugs, nothing a person typed. */
|
|
42
|
+
export const EXPERIMENT_SLUG = /^[a-z0-9_-]{1,32}$/;
|
|
43
|
+
/** An experiment's assignment as event properties, or nothing when it is not a pair of slugs. */
|
|
44
|
+
export function experimentProps(experiment, variant) {
|
|
45
|
+
return experiment && variant && EXPERIMENT_SLUG.test(experiment) && EXPERIMENT_SLUG.test(variant) ? { experiment, variant } : {};
|
|
46
|
+
}
|
|
22
47
|
/**
|
|
23
48
|
* The thank-you page is the form's receipt, not a visit to the point: counting
|
|
24
49
|
* it as `location_page_view` would add a page view to every lead.
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { type Opening } from "./place/hours.js";
|
|
2
|
+
import type { OpeningHours } from "./place/types.js";
|
|
3
|
+
/**
|
|
4
|
+
* The ways a visitor can reach the business, named as `contact_intent_click`
|
|
5
|
+
* names them, so a channel and its event are one word. `form` is the quote
|
|
6
|
+
* form; `callback` is the form cut to a phone number ("Rappelez-moi").
|
|
7
|
+
*/
|
|
8
|
+
export type CaptureChannel = "phone" | "whatsapp" | "sms" | "callback" | "form";
|
|
9
|
+
/** What decides which channels exist: the place's numbers and hours. */
|
|
10
|
+
export interface ChannelFacts {
|
|
11
|
+
/** `contactOf(site, place)` — the place's numbers, the brand's as fallback. */
|
|
12
|
+
phone: string | null;
|
|
13
|
+
whatsapp: string | null;
|
|
14
|
+
hours: readonly OpeningHours[] | null;
|
|
15
|
+
/** Default: offered when `phone` is a French mobile — a landline drops a text. */
|
|
16
|
+
sms?: boolean;
|
|
17
|
+
/** Default: offered. A surface with no form to post it from turns it off. */
|
|
18
|
+
callback?: boolean;
|
|
19
|
+
}
|
|
20
|
+
export interface ChannelOptions {
|
|
21
|
+
now: Date;
|
|
22
|
+
timeZone?: string | undefined;
|
|
23
|
+
/** Moved to the front when available — what a `default_channel` experiment sets. */
|
|
24
|
+
prefer?: CaptureChannel | null | undefined;
|
|
25
|
+
}
|
|
26
|
+
export interface ResolvedChannels {
|
|
27
|
+
/** The available channels, most useful first. `form` is always there. */
|
|
28
|
+
order: readonly CaptureChannel[];
|
|
29
|
+
/** `null` when the place has no hours: nothing is promised from a guess. */
|
|
30
|
+
open: boolean | null;
|
|
31
|
+
/** When closed, the next opening by the real hours; otherwise `null`. */
|
|
32
|
+
nextOpening: Opening | null;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* The one channel resolver, read by `LeadCapture` and `CallBar` alike: which
|
|
36
|
+
* channels exist (a channel the place cannot answer is left out, never shown
|
|
37
|
+
* dead) and in which order (by its hours, now). Pure: the caller says when
|
|
38
|
+
* "now" is, so the server's render and a test agree.
|
|
39
|
+
*/
|
|
40
|
+
export declare function resolveChannels(facts: ChannelFacts, options: ChannelOptions): ResolvedChannels;
|
|
41
|
+
/**
|
|
42
|
+
* `sms:` with a prefilled body. `?&body=` is the form both iOS (which reads
|
|
43
|
+
* `&body=`) and Android (which reads `?body=`) accept.
|
|
44
|
+
*/
|
|
45
|
+
export declare function smsHref(phone: string, body?: string): string;
|
|
46
|
+
/** The link a channel opens, or `null` for the two that are forms on the page. */
|
|
47
|
+
export declare function channelHref(channel: CaptureChannel, facts: Pick<ChannelFacts, "phone" | "whatsapp">, message?: string): string | null;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { telHref, whatsappHref } from "@evinvest/marketing";
|
|
2
|
+
import { DEFAULT_TIME_ZONE, isOpenAt, nextOpening } from "./place/hours.js";
|
|
3
|
+
import { isMobilePhone, normalizePhone } from "./phone.js";
|
|
4
|
+
// Open: a person answers, so the call leads — calls convert many times what a
|
|
5
|
+
// form does. Closed: a call rings out, so the channels that wait for the
|
|
6
|
+
// morning lead, and the call goes last instead of disappearing.
|
|
7
|
+
const OPEN = ["phone", "whatsapp", "sms", "form", "callback"];
|
|
8
|
+
const CLOSED = ["callback", "whatsapp", "form", "sms", "phone"];
|
|
9
|
+
/**
|
|
10
|
+
* The one channel resolver, read by `LeadCapture` and `CallBar` alike: which
|
|
11
|
+
* channels exist (a channel the place cannot answer is left out, never shown
|
|
12
|
+
* dead) and in which order (by its hours, now). Pure: the caller says when
|
|
13
|
+
* "now" is, so the server's render and a test agree.
|
|
14
|
+
*/
|
|
15
|
+
export function resolveChannels(facts, options) {
|
|
16
|
+
const timeZone = options.timeZone ?? DEFAULT_TIME_ZONE;
|
|
17
|
+
const open = isOpenAt(facts.hours, options.now, timeZone);
|
|
18
|
+
const has = {
|
|
19
|
+
phone: facts.phone !== null,
|
|
20
|
+
whatsapp: facts.whatsapp !== null,
|
|
21
|
+
sms: facts.phone !== null && (facts.sms ?? isMobilePhone(facts.phone)),
|
|
22
|
+
callback: facts.callback ?? true,
|
|
23
|
+
form: true,
|
|
24
|
+
};
|
|
25
|
+
const order = (open === false ? CLOSED : OPEN).filter(c => has[c]);
|
|
26
|
+
const prefer = options.prefer;
|
|
27
|
+
const sorted = prefer && has[prefer] ? [prefer, ...order.filter(c => c !== prefer)] : order;
|
|
28
|
+
return { order: sorted, open, nextOpening: open === false ? nextOpening(facts.hours, options.now, timeZone) : null };
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* `sms:` with a prefilled body. `?&body=` is the form both iOS (which reads
|
|
32
|
+
* `&body=`) and Android (which reads `?body=`) accept.
|
|
33
|
+
*/
|
|
34
|
+
export function smsHref(phone, body) {
|
|
35
|
+
const number = normalizePhone(phone) ?? phone.replace(/[^\d+]/g, "");
|
|
36
|
+
return body ? `sms:${number}?&body=${encodeURIComponent(body)}` : `sms:${number}`;
|
|
37
|
+
}
|
|
38
|
+
/** The link a channel opens, or `null` for the two that are forms on the page. */
|
|
39
|
+
export function channelHref(channel, facts, message) {
|
|
40
|
+
switch (channel) {
|
|
41
|
+
case "phone":
|
|
42
|
+
return facts.phone ? telHref(facts.phone) : null;
|
|
43
|
+
case "whatsapp":
|
|
44
|
+
return facts.whatsapp ? whatsappHref(facts.whatsapp, message) : null;
|
|
45
|
+
case "sms":
|
|
46
|
+
return facts.phone ? smsHref(facts.phone, message) : null;
|
|
47
|
+
case "callback":
|
|
48
|
+
case "form":
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { LeadCaptureText } from "./lead-capture-text.js";
|
|
2
|
+
import type { Opening } from "./place/hours.js";
|
|
3
|
+
/** `{key}` → its value; a key with no value is left as written, visible in review. */
|
|
4
|
+
export declare function fillText(template: string, values: Readonly<Record<string, string>>): string;
|
|
5
|
+
/** The promise of the next opening, from real hours — or `null`, never an invented one. */
|
|
6
|
+
export declare function openingText(opening: Opening | null, text: Pick<LeadCaptureText, "openingToday" | "openingTomorrow" | "openingLater">, locale: string): string | null;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// Apart from the words themselves, which the client island never imports:
|
|
2
|
+
// the strings arrive as a prop, and a bundler keeps a module's constants.
|
|
3
|
+
/** `{key}` → its value; a key with no value is left as written, visible in review. */
|
|
4
|
+
export function fillText(template, values) {
|
|
5
|
+
return template.replace(/\{(\w+)\}/g, (all, key) => values[key] ?? all);
|
|
6
|
+
}
|
|
7
|
+
/** `08:00` → `8 h` in French, `8:00` elsewhere; `08:30` → `8 h 30` / `8:30`. */
|
|
8
|
+
function clockText(hhmm, locale) {
|
|
9
|
+
const [h = "0", m = "00"] = hhmm.split(":");
|
|
10
|
+
const hour = String(Number(h));
|
|
11
|
+
if (locale.startsWith("fr"))
|
|
12
|
+
return m === "00" ? `${hour} h` : `${hour} h ${m}`;
|
|
13
|
+
return `${hour}:${m}`;
|
|
14
|
+
}
|
|
15
|
+
const WEEKDAY_INDEX = { Monday: 0, Tuesday: 1, Wednesday: 2, Thursday: 3, Friday: 4, Saturday: 5, Sunday: 6 };
|
|
16
|
+
/** The weekday in the page's language: a Monday-anchored date read back through `Intl`. */
|
|
17
|
+
function weekdayText(day, locale) {
|
|
18
|
+
// 2024-01-01 was a Monday; noon UTC is the same day everywhere it matters.
|
|
19
|
+
const date = new Date(Date.UTC(2024, 0, 1 + WEEKDAY_INDEX[day], 12));
|
|
20
|
+
return new Intl.DateTimeFormat(locale, { weekday: "long", timeZone: "UTC" }).format(date);
|
|
21
|
+
}
|
|
22
|
+
/** The promise of the next opening, from real hours — or `null`, never an invented one. */
|
|
23
|
+
export function openingText(opening, text, locale) {
|
|
24
|
+
if (!opening)
|
|
25
|
+
return null;
|
|
26
|
+
const time = clockText(opening.time, locale);
|
|
27
|
+
if (opening.inDays === 0)
|
|
28
|
+
return fillText(text.openingToday, { time });
|
|
29
|
+
if (opening.inDays === 1)
|
|
30
|
+
return fillText(text.openingTomorrow, { time });
|
|
31
|
+
return fillText(text.openingLater, { time, day: weekdayText(opening.day, locale) });
|
|
32
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every word `LeadCapture` prints. Plain strings, not the copy contract's
|
|
3
|
+
* `Said<F>`: the form is a client island, and a function cannot cross from a
|
|
4
|
+
* server page into it. A sentence that quotes something names it in braces —
|
|
5
|
+
* `{need}`, `{day}`, `{time}` — and the component fills it in.
|
|
6
|
+
*
|
|
7
|
+
* The kit ships French and English (`LEAD_CAPTURE_TEXT`) so every brand asks
|
|
8
|
+
* in the same words and an experiment's arms differ only in what it tests; a
|
|
9
|
+
* brand overrides a key by spreading: `{ ...LEAD_CAPTURE_TEXT.fr, submit: "…" }`.
|
|
10
|
+
*/
|
|
11
|
+
export interface LeadCaptureText {
|
|
12
|
+
title: string;
|
|
13
|
+
lede: string;
|
|
14
|
+
needLabel: string;
|
|
15
|
+
/** Re-opens a need chosen for the visitor. */
|
|
16
|
+
needChange: string;
|
|
17
|
+
localityLabel: string;
|
|
18
|
+
/** Names the row of communes a tap fills the locality from. */
|
|
19
|
+
servedLabel: string;
|
|
20
|
+
phoneLabel: string;
|
|
21
|
+
/** The soft check under a number that does not read as one; it never blocks. */
|
|
22
|
+
phoneHint: string;
|
|
23
|
+
nameLabel: string;
|
|
24
|
+
/** After an optional field's label. */
|
|
25
|
+
optional: string;
|
|
26
|
+
submit: string;
|
|
27
|
+
privacy: string;
|
|
28
|
+
/** Heads the other ways to reach the business. */
|
|
29
|
+
otherChannels: string;
|
|
30
|
+
call: string;
|
|
31
|
+
whatsapp: string;
|
|
32
|
+
sms: string;
|
|
33
|
+
callback: string;
|
|
34
|
+
callbackLede: string;
|
|
35
|
+
/** Posted with the callback and kept with the lead word for word: what was agreed to. */
|
|
36
|
+
callbackConsent: string;
|
|
37
|
+
callbackSubmit: string;
|
|
38
|
+
/** The WhatsApp and SMS message, `{need}` the need's label. */
|
|
39
|
+
message: string;
|
|
40
|
+
/** The same when no need is known. */
|
|
41
|
+
messageGeneric: string;
|
|
42
|
+
/** When closed: the next opening, from the place's real hours only. */
|
|
43
|
+
openingToday: string;
|
|
44
|
+
openingTomorrow: string;
|
|
45
|
+
/** `{day}` is the weekday in the page's language. */
|
|
46
|
+
openingLater: string;
|
|
47
|
+
/** The honeypot's label — read only by a bot filling every field. */
|
|
48
|
+
honeypotLabel: string;
|
|
49
|
+
}
|
|
50
|
+
export declare const LEAD_CAPTURE_TEXT: Readonly<Record<"fr" | "en", LeadCaptureText>>;
|