@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.
Files changed (63) hide show
  1. package/README.md +110 -3
  2. package/dist/cli/size.d.ts +26 -4
  3. package/dist/cli/size.js +54 -16
  4. package/dist/core/accept.d.ts +9 -1
  5. package/dist/core/accept.js +13 -2
  6. package/dist/core/analytics.d.ts +22 -3
  7. package/dist/core/analytics.js +27 -2
  8. package/dist/core/channels.d.ts +47 -0
  9. package/dist/core/channels.js +51 -0
  10. package/dist/core/lead-capture-format.d.ts +6 -0
  11. package/dist/core/lead-capture-format.js +32 -0
  12. package/dist/core/lead-capture-text.d.ts +50 -0
  13. package/dist/core/lead-capture-text.js +57 -0
  14. package/dist/core/lead.d.ts +59 -4
  15. package/dist/core/lead.js +38 -4
  16. package/dist/core/phone.d.ts +19 -0
  17. package/dist/core/phone.js +39 -0
  18. package/dist/core/place/hours.d.ts +15 -0
  19. package/dist/core/place/hours.js +68 -0
  20. package/dist/core/place/index.d.ts +1 -0
  21. package/dist/core/place/index.js +1 -0
  22. package/dist/index.d.ts +8 -4
  23. package/dist/index.js +8 -4
  24. package/dist/next/quote-route.d.ts +1 -1
  25. package/dist/next/quote-route.js +4 -1
  26. package/dist/react/AnalyticsBoundary.js +11 -5
  27. package/dist/react/CallBar.d.ts +17 -2
  28. package/dist/react/CallBar.js +26 -3
  29. package/dist/react/LeadCapture.d.ts +61 -0
  30. package/dist/react/LeadCapture.js +60 -0
  31. package/dist/react/LeadCaptureChannels.d.ts +54 -0
  32. package/dist/react/LeadCaptureChannels.js +37 -0
  33. package/dist/react/LeadCaptureFields.d.ts +32 -0
  34. package/dist/react/LeadCaptureFields.js +42 -0
  35. package/dist/react/LeadCaptureNeed.d.ts +27 -0
  36. package/dist/react/LeadCaptureNeed.js +22 -0
  37. package/dist/react/analytics-context.d.ts +11 -0
  38. package/dist/react/analytics-context.js +15 -0
  39. package/dist/react/index.d.ts +3 -1
  40. package/dist/react/index.js +2 -1
  41. package/dist/react/use-lead-context.d.ts +24 -0
  42. package/dist/react/use-lead-context.js +71 -0
  43. package/dist/react/use-lead-events.d.ts +23 -0
  44. package/dist/react/use-lead-events.js +62 -0
  45. package/dist/server/index.d.ts +1 -1
  46. package/dist/server/index.js +1 -1
  47. package/dist/server/lead-store-sqlite.js +12 -4
  48. package/dist/server/lead-webhook.d.ts +12 -1
  49. package/dist/server/lead-webhook.js +12 -0
  50. package/dist/server/notify.d.ts +1 -1
  51. package/dist/server/notify.js +5 -2
  52. package/dist/testing/lead-store-contract.js +4 -2
  53. package/package.json +1 -1
  54. package/template/README.md +7 -4
  55. package/template/flake.nix +1 -1
  56. package/template/package.json +1 -1
  57. package/template/src/entities/content/model/en.ts +2 -2
  58. package/template/src/entities/content/model/fr.ts +2 -2
  59. package/template/src/entities/content/model/types.ts +3 -2
  60. package/template/src/shared/config/lead.ts +2 -0
  61. package/template/src/views/home/ui/PlaceHome.tsx +25 -40
  62. package/template/tests/bundle_budget.txt +8 -3
  63. 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`; fails closed |
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.5.0"; # the version in package-lock.json
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
@@ -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
- /** The budget file is commented prose; the number is its last non-comment line. */
12
- export declare function parseBudget(text: string, file?: string): number;
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">): number;
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
- /** The budget file is commented prose; the number is its last non-comment line. */
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
- const lines = text
17
- .split("\n")
18
- .map(l => l.trim())
19
- .filter(l => l !== "" && !l.startsWith("#"));
20
- const last = lines.at(-1);
21
- if (last === undefined || !/^\d+$/.test(last)) {
22
- throw new Error(`${file}: the last non-comment line must be a byte count, got ${JSON.stringify(last)}`);
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
- return Number(last);
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
- log.log(` ${args.route}: ${kb(total)} KB gz (${kb(raw)} KB raw) / budget ${kb(budget)} KB gz`);
94
- if (total > budget) {
95
- log.error(`✘ over budget by ${total - budget} B. Raising ${args.budget} is a deliberate commit, with a reason.`);
96
- return 1;
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
  }
@@ -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">;
@@ -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 lead = { ...candidate, spamVerdict: verdict === "ok" ? null : verdict };
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
- deps.defer(() => deps.capture(lead, formId));
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
  }
@@ -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.
@@ -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 = ["brand_id", "location_id", "source", "device", "channel", "form_id"];
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>>;