@evinvest/kitstart 0.4.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 (69) hide show
  1. package/README.md +177 -7
  2. package/dist/cli/size.d.ts +26 -4
  3. package/dist/cli/size.js +54 -16
  4. package/dist/core/accept.d.ts +18 -1
  5. package/dist/core/accept.js +21 -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 +10 -2
  25. package/dist/next/quote-route.js +20 -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/env.d.ts +7 -1
  46. package/dist/server/env.js +15 -0
  47. package/dist/server/index.d.ts +3 -0
  48. package/dist/server/index.js +3 -0
  49. package/dist/server/lead-store-sqlite.js +12 -4
  50. package/dist/server/lead-webhook.d.ts +61 -0
  51. package/dist/server/lead-webhook.js +43 -0
  52. package/dist/server/notify.d.ts +1 -1
  53. package/dist/server/notify.js +5 -2
  54. package/dist/server/webhook-outbox.d.ts +77 -0
  55. package/dist/server/webhook-outbox.js +257 -0
  56. package/dist/server/webhook-signature.d.ts +32 -0
  57. package/dist/server/webhook-signature.js +16 -0
  58. package/dist/testing/lead-store-contract.js +4 -2
  59. package/package.json +1 -1
  60. package/template/README.md +7 -4
  61. package/template/flake.nix +1 -1
  62. package/template/package.json +2 -2
  63. package/template/src/entities/content/model/en.ts +2 -2
  64. package/template/src/entities/content/model/fr.ts +2 -2
  65. package/template/src/entities/content/model/types.ts +3 -2
  66. package/template/src/shared/config/lead.ts +2 -0
  67. package/template/src/views/home/ui/PlaceHome.tsx +25 -40
  68. package/template/tests/bundle_budget.txt +8 -3
  69. package/template/tests/e2e/quote-form.spec.ts +114 -13
@@ -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>>;
@@ -0,0 +1,57 @@
1
+ const FR = {
2
+ title: "Recevoir un prix",
3
+ lede: "Réponse rapide, sans engagement.",
4
+ needLabel: "Votre besoin",
5
+ needChange: "Modifier",
6
+ localityLabel: "Code postal",
7
+ servedLabel: "Communes desservies",
8
+ phoneLabel: "Téléphone",
9
+ phoneHint: "Vérifiez le numéro : 06 12 34 56 78 ou +33 6 12 34 56 78.",
10
+ nameLabel: "Nom",
11
+ optional: "facultatif",
12
+ submit: "Recevoir le prix",
13
+ privacy: "Votre numéro ne sert qu’à vous répondre.",
14
+ otherChannels: "Ou contactez-nous",
15
+ call: "Appeler",
16
+ whatsapp: "WhatsApp",
17
+ sms: "SMS",
18
+ callback: "Rappelez-moi",
19
+ callbackLede: "Laissez votre numéro, nous vous rappelons.",
20
+ callbackConsent: "J’accepte d’être rappelé·e à ce numéro au sujet de ma demande.",
21
+ callbackSubmit: "Être rappelé",
22
+ message: "Bonjour, j’ai besoin de : {need}.",
23
+ messageGeneric: "Bonjour, je souhaite un devis.",
24
+ openingToday: "Nous vous rappelons dès {time}.",
25
+ openingTomorrow: "Nous vous rappelons demain dès {time}.",
26
+ openingLater: "Nous vous rappelons {day} dès {time}.",
27
+ honeypotLabel: "Site web",
28
+ };
29
+ const EN = {
30
+ title: "Get a price",
31
+ lede: "A quick answer, no commitment.",
32
+ needLabel: "What you need",
33
+ needChange: "Change",
34
+ localityLabel: "Postcode",
35
+ servedLabel: "Towns we serve",
36
+ phoneLabel: "Phone",
37
+ phoneHint: "Check the number: 06 12 34 56 78 or +33 6 12 34 56 78.",
38
+ nameLabel: "Name",
39
+ optional: "optional",
40
+ submit: "Get the price",
41
+ privacy: "Your number is used only to answer you.",
42
+ otherChannels: "Or reach us",
43
+ call: "Call",
44
+ whatsapp: "WhatsApp",
45
+ sms: "Text",
46
+ callback: "Call me back",
47
+ callbackLede: "Leave your number and we will call you back.",
48
+ callbackConsent: "I agree to be called back on this number about my request.",
49
+ callbackSubmit: "Call me back",
50
+ message: "Hello, I need: {need}.",
51
+ messageGeneric: "Hello, I would like a quote.",
52
+ openingToday: "We call you back from {time}.",
53
+ openingTomorrow: "We call you back tomorrow from {time}.",
54
+ openingLater: "We call you back on {day} from {time}.",
55
+ honeypotLabel: "Website",
56
+ };
57
+ export const LEAD_CAPTURE_TEXT = { fr: FR, en: EN };
@@ -39,7 +39,43 @@ export interface LeadSchema<S extends string> {
39
39
  * `null` to accept. Absent → every candidate is a lead.
40
40
  */
41
41
  validate?: (lead: LeadCandidate) => string | null;
42
+ /**
43
+ * The rule for a callback request, which carries a phone and little else —
44
+ * a form's rule would refuse it for the locality it never asked. Absent →
45
+ * `validateCallbackLead`: a number we can read.
46
+ */
47
+ validateCallback?: (lead: LeadCandidate) => string | null;
48
+ /**
49
+ * How the mobile is kept: `typed` (default), exactly as posted, or `e164`,
50
+ * `+33612345678` when it reads as a number and as typed when it does not.
51
+ * Opt-in, because a brand's own tooling and tests look rows up by the
52
+ * number as typed.
53
+ */
54
+ mobileFormat?: "typed" | "e164";
42
55
  }
56
+ /**
57
+ * How the lead was asked for: the quote form, or "call me back" — the form cut
58
+ * to a phone number. Posted as `CHANNEL_FIELD`; anything else, or nothing (a
59
+ * page cached before the field existed), is `form`.
60
+ */
61
+ export type LeadChannel = "form" | "callback";
62
+ export declare const LEAD_CHANNELS: readonly LeadChannel[];
63
+ export declare const CHANNEL_FIELD = "channel";
64
+ /**
65
+ * The callback's consent: a checkbox whose value is the sentence it shows, in
66
+ * the language it shows it, so the row keeps what was agreed to. Absent → the
67
+ * callback is refused, as any invalid lead is.
68
+ */
69
+ export declare const CONSENT_FIELD = "consent";
70
+ /** Longer than any consent sentence; past it, the text is cut, not refused. */
71
+ export declare const MAX_CONSENT = 500;
72
+ /** What a callback lead was agreed to, and when the server accepted it (ISO 8601). */
73
+ export interface LeadConsent {
74
+ text: string;
75
+ at: string;
76
+ }
77
+ /** The default callback rule: a phone number that reads as one. */
78
+ export declare function validateCallbackLead(lead: Pick<LeadCandidate, "mobile">): string | null;
43
79
  /**
44
80
  * A submitted lead. Values are trimmed and capped but not otherwise parsed: a
45
81
  * lead that cannot be fully validated is still a lead, and rejecting it loses
@@ -54,8 +90,23 @@ export interface Lead {
54
90
  /** The place it was submitted from — its slug — or `null` when unknown. */
55
91
  placeSlug: string | null;
56
92
  spamVerdict: SpamVerdict | null;
93
+ /**
94
+ * Absent on a lead built before the field existed — read it as `form`
95
+ * (`channelOf`). Every lead the funnel reads has it.
96
+ */
97
+ channel?: LeadChannel;
98
+ /** Only on a callback lead: the consent it was posted with. */
99
+ consent?: LeadConsent;
57
100
  }
58
- export type LeadCandidate = Omit<Lead, "spamVerdict">;
101
+ /** The lead's channel, `form` for one that predates the field. */
102
+ export declare function channelOf(lead: Pick<Lead, "channel">): LeadChannel;
103
+ /**
104
+ * What the form posted, before the funnel judges it: the consent is the
105
+ * posted sentence, and the funnel stamps when it accepted it.
106
+ */
107
+ export type LeadCandidate = Omit<Lead, "spamVerdict" | "consent"> & {
108
+ consentText?: string;
109
+ };
59
110
  /**
60
111
  * The port the funnel writes through — the commit point. `insert` resolves
61
112
  * with the row's id only once the lead is durable; a rejection is a lead the
@@ -78,11 +129,15 @@ export interface LeadStore {
78
129
  /**
79
130
  * The lead schema's version, shared by every adapter: 1 the Rust server's
80
131
  * table (`job`, `zip`, `mobile`, `at`), 2 + the place, 3 + the spam verdict,
81
- * 4 + the brand's extras. Append only.
132
+ * 4 + the brand's extras, 5 + the channel and the callback's consent. Append only.
82
133
  */
83
- export declare const LEAD_SCHEMA_VERSION = 4;
134
+ export declare const LEAD_SCHEMA_VERSION = 5;
84
135
  /** A field is capped, not rejected: a long answer is still a customer. */
85
136
  export declare const MAX_FIELD = 200;
86
- /** The candidate a form posted, read through the schema's field names. */
137
+ /**
138
+ * The candidate a form posted, read through the schema's field names. Under
139
+ * `mobileFormat: "e164"` the mobile is normalised before the brand's rule
140
+ * sees it.
141
+ */
87
142
  export declare function readCandidate(schema: LeadSchema<string>, form: FormData, placeSlug: string | null): LeadCandidate;
88
143
  export declare function validateCandidate(schema: LeadSchema<string>, lead: LeadCandidate): string | null;
package/dist/core/lead.js CHANGED
@@ -9,19 +9,42 @@
9
9
  * `LeadSchema.wire` names the posted fields (a page cached before a rename
10
10
  * still delivers) — both deliberate departures from the first design.
11
11
  */
12
+ import { isPlausiblePhone, normalizePhone } from "./phone.js";
13
+ export const LEAD_CHANNELS = ["form", "callback"];
14
+ export const CHANNEL_FIELD = "channel";
15
+ /**
16
+ * The callback's consent: a checkbox whose value is the sentence it shows, in
17
+ * the language it shows it, so the row keeps what was agreed to. Absent → the
18
+ * callback is refused, as any invalid lead is.
19
+ */
20
+ export const CONSENT_FIELD = "consent";
21
+ /** Longer than any consent sentence; past it, the text is cut, not refused. */
22
+ export const MAX_CONSENT = 500;
23
+ /** The default callback rule: a phone number that reads as one. */
24
+ export function validateCallbackLead(lead) {
25
+ return isPlausiblePhone(lead.mobile) ? null : "a phone number to call back";
26
+ }
27
+ /** The lead's channel, `form` for one that predates the field. */
28
+ export function channelOf(lead) {
29
+ return lead.channel ?? "form";
30
+ }
12
31
  /**
13
32
  * The lead schema's version, shared by every adapter: 1 the Rust server's
14
33
  * table (`job`, `zip`, `mobile`, `at`), 2 + the place, 3 + the spam verdict,
15
- * 4 + the brand's extras. Append only.
34
+ * 4 + the brand's extras, 5 + the channel and the callback's consent. Append only.
16
35
  */
17
- export const LEAD_SCHEMA_VERSION = 4;
36
+ export const LEAD_SCHEMA_VERSION = 5;
18
37
  /** A field is capped, not rejected: a long answer is still a customer. */
19
38
  export const MAX_FIELD = 200;
20
39
  function field(form, name, max) {
21
40
  const value = form.get(name);
22
41
  return typeof value === "string" ? value.trim().slice(0, max) : null;
23
42
  }
24
- /** The candidate a form posted, read through the schema's field names. */
43
+ /**
44
+ * The candidate a form posted, read through the schema's field names. Under
45
+ * `mobileFormat: "e164"` the mobile is normalised before the brand's rule
46
+ * sees it.
47
+ */
25
48
  export function readCandidate(schema, form, placeSlug) {
26
49
  const extras = {};
27
50
  for (const extra of schema.extras ?? []) {
@@ -29,14 +52,25 @@ export function readCandidate(schema, form, placeSlug) {
29
52
  if (value)
30
53
  extras[extra.name] = value;
31
54
  }
55
+ const mobile = field(form, schema.wire.mobile, MAX_FIELD) ?? "";
56
+ const callback = form.get(CHANNEL_FIELD) === "callback";
57
+ const consent = callback ? field(form, CONSENT_FIELD, MAX_CONSENT) : null;
32
58
  return {
33
59
  subject: field(form, schema.wire.subject, MAX_FIELD) ?? "",
34
60
  locality: field(form, schema.wire.locality, MAX_FIELD) ?? "",
35
- mobile: field(form, schema.wire.mobile, MAX_FIELD) ?? "",
61
+ mobile: schema.mobileFormat === "e164" ? (normalizePhone(mobile) ?? mobile) : mobile,
36
62
  extras,
37
63
  placeSlug,
64
+ channel: callback ? "callback" : "form",
65
+ ...(consent ? { consentText: consent } : {}),
38
66
  };
39
67
  }
40
68
  export function validateCandidate(schema, lead) {
69
+ if (channelOf(lead) === "callback") {
70
+ // Not the brand's to waive: calling someone back needs their word for it.
71
+ if (!lead.consentText)
72
+ return "consent to be called back";
73
+ return (schema.validateCallback ?? validateCallbackLead)(lead);
74
+ }
41
75
  return schema.validate?.(lead) ?? null;
42
76
  }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * A phone number as the funnel keeps it: E.164 when the typed value is one we
3
+ * can read, otherwise exactly as typed. Soft by design — a number we cannot
4
+ * parse is still a way to reach someone, and refusing it loses the customer.
5
+ *
6
+ * Only the French national plan is read (every brand of the vertical is in
7
+ * France): `06 12 34 56 78`, `+33 (0)6 …`, `0033 6 …` and a mobile typed
8
+ * without its trunk zero (`6 12 34 56 78`) all become `+33612345678`. Any
9
+ * other international number is compacted (`+44 7911 123456` →
10
+ * `+447911123456`); anything else is `null`.
11
+ */
12
+ export declare function normalizePhone(raw: string): string | null;
13
+ /** Whether a typed number reads as one — the form's soft check, never a block. */
14
+ export declare function isPlausiblePhone(raw: string): boolean;
15
+ /**
16
+ * A French mobile (`+33 6…`, `+33 7…`) — the one kind an SMS reaches. A
17
+ * landline answers a text with silence, so the channel is not offered for it.
18
+ */
19
+ export declare function isMobilePhone(raw: string): boolean;
@@ -0,0 +1,39 @@
1
+ /**
2
+ * A phone number as the funnel keeps it: E.164 when the typed value is one we
3
+ * can read, otherwise exactly as typed. Soft by design — a number we cannot
4
+ * parse is still a way to reach someone, and refusing it loses the customer.
5
+ *
6
+ * Only the French national plan is read (every brand of the vertical is in
7
+ * France): `06 12 34 56 78`, `+33 (0)6 …`, `0033 6 …` and a mobile typed
8
+ * without its trunk zero (`6 12 34 56 78`) all become `+33612345678`. Any
9
+ * other international number is compacted (`+44 7911 123456` →
10
+ * `+447911123456`); anything else is `null`.
11
+ */
12
+ export function normalizePhone(raw) {
13
+ let s = raw.replace(/\(0\)/g, "").replace(/[\s.\-()/ ]/g, "");
14
+ if (s.startsWith("00"))
15
+ s = `+${s.slice(2)}`;
16
+ if (s.startsWith("+33")) {
17
+ const national = /^\+330?([1-9]\d{8})$/.exec(s);
18
+ return national ? `+33${national[1]}` : null;
19
+ }
20
+ const trunk = /^0([1-9]\d{8})$/.exec(s);
21
+ if (trunk)
22
+ return `+33${trunk[1]}`;
23
+ if (/^[1-9]\d{8}$/.test(s))
24
+ return `+33${s}`;
25
+ if (/^\+[1-9]\d{7,14}$/.test(s))
26
+ return s;
27
+ return null;
28
+ }
29
+ /** Whether a typed number reads as one — the form's soft check, never a block. */
30
+ export function isPlausiblePhone(raw) {
31
+ return normalizePhone(raw) !== null;
32
+ }
33
+ /**
34
+ * A French mobile (`+33 6…`, `+33 7…`) — the one kind an SMS reaches. A
35
+ * landline answers a text with silence, so the channel is not offered for it.
36
+ */
37
+ export function isMobilePhone(raw) {
38
+ return /^\+33[67]\d{8}$/.test(normalizePhone(raw) ?? "");
39
+ }
@@ -0,0 +1,15 @@
1
+ import type { DayOfWeek, OpeningHours } from "./types.js";
2
+ /** Every place of the vertical is in France; a brand elsewhere passes its own. */
3
+ export declare const DEFAULT_TIME_ZONE = "Europe/Paris";
4
+ /** The next opening after a closed moment, read from the place's real hours only. */
5
+ export interface Opening {
6
+ day: DayOfWeek;
7
+ /** `HH:MM`, local time, as the hours say it. */
8
+ time: string;
9
+ /** 0 today, 1 tomorrow, … up to 7. */
10
+ inDays: number;
11
+ }
12
+ /** Whether the place is open at `at`. Hours we do not have are not a guess: `null`. */
13
+ export declare function isOpenAt(hours: readonly OpeningHours[] | null, at: Date, timeZone?: string): boolean | null;
14
+ /** The first opening after `at`, within a week — `null` with no hours, or when open now. */
15
+ export declare function nextOpening(hours: readonly OpeningHours[] | null, at: Date, timeZone?: string): Opening | null;
@@ -0,0 +1,68 @@
1
+ /** Every place of the vertical is in France; a brand elsewhere passes its own. */
2
+ export const DEFAULT_TIME_ZONE = "Europe/Paris";
3
+ const WEEK = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"];
4
+ const formatters = new Map();
5
+ /** The wall clock at `timeZone`: Monday-first day index and minutes since midnight. */
6
+ function localTime(at, timeZone) {
7
+ let fmt = formatters.get(timeZone);
8
+ if (!fmt) {
9
+ fmt = new Intl.DateTimeFormat("en-US", { timeZone, weekday: "long", hour: "2-digit", minute: "2-digit", hourCycle: "h23" });
10
+ formatters.set(timeZone, fmt);
11
+ }
12
+ const parts = fmt.formatToParts(at);
13
+ const part = (type) => parts.find(p => p.type === type)?.value ?? "";
14
+ const day = WEEK.findIndex(d => d === part("weekday"));
15
+ return { day: day < 0 ? 0 : day, minutes: Number(part("hour")) * 60 + Number(part("minute")) };
16
+ }
17
+ /** `HH:MM` → minutes; `24:00` is the end of the day. `null` for anything else. */
18
+ function clock(hhmm) {
19
+ const m = /^(\d{1,2}):(\d{2})$/.exec(hhmm);
20
+ if (!m)
21
+ return null;
22
+ const minutes = Number(m[1]) * 60 + Number(m[2]);
23
+ return minutes <= 24 * 60 ? minutes : null;
24
+ }
25
+ /** The slots as minute ranges per day; a slot closing at or before it opens runs past midnight. */
26
+ function ranges(hours) {
27
+ const out = [];
28
+ for (const slot of hours) {
29
+ const from = clock(slot.opens);
30
+ const to = clock(slot.closes);
31
+ if (from === null || to === null)
32
+ continue;
33
+ for (const name of slot.days) {
34
+ const day = WEEK.indexOf(name);
35
+ if (to > from)
36
+ out.push({ day, from, to, carried: false });
37
+ else {
38
+ out.push({ day, from, to: 24 * 60, carried: false });
39
+ out.push({ day: (day + 1) % 7, from: 0, to, carried: true });
40
+ }
41
+ }
42
+ }
43
+ return out;
44
+ }
45
+ /** Whether the place is open at `at`. Hours we do not have are not a guess: `null`. */
46
+ export function isOpenAt(hours, at, timeZone = DEFAULT_TIME_ZONE) {
47
+ if (!hours || hours.length === 0)
48
+ return null;
49
+ const now = localTime(at, timeZone);
50
+ return ranges(hours).some(r => r.day === now.day && now.minutes >= r.from && now.minutes < r.to);
51
+ }
52
+ /** The first opening after `at`, within a week — `null` with no hours, or when open now. */
53
+ export function nextOpening(hours, at, timeZone = DEFAULT_TIME_ZONE) {
54
+ if (isOpenAt(hours, at, timeZone) !== false || !hours)
55
+ return null;
56
+ const now = localTime(at, timeZone);
57
+ const slots = ranges(hours).filter(r => !r.carried);
58
+ for (let inDays = 0; inDays <= 7; inDays++) {
59
+ const day = (now.day + inDays) % 7;
60
+ const starts = slots.filter(r => r.day === day && (inDays > 0 || r.from > now.minutes)).map(r => r.from);
61
+ if (starts.length === 0)
62
+ continue;
63
+ const from = Math.min(...starts);
64
+ const name = WEEK[day] ?? "Monday";
65
+ return { day: name, time: `${String(Math.floor(from / 60)).padStart(2, "0")}:${String(from % 60).padStart(2, "0")}`, inDays };
66
+ }
67
+ return null;
68
+ }
@@ -1,5 +1,6 @@
1
1
  export { servedLocalities, storefrontOf, type DayOfWeek, type Geo, type OpeningHours, type Place, type PostalAddress, type Presence, type Rating, type ServiceArea, } from "./types.js";
2
2
  export { isPublished, publicationGaps, SERVICE_AREA_GATE, STOREFRONT_GATE, type PublicationField, type PublicationPolicy, } from "./publication.js";
3
+ export { DEFAULT_TIME_ZONE, isOpenAt, nextOpening, type Opening } from "./hours.js";
3
4
  export { freshRating, parseInstant, RATING_MAX_AGE_DAYS } from "./rating.js";
4
5
  export { mergeLive, parsePlaceLive, type PlaceLive } from "./live.js";
5
6
  export { createPlaceView, placeHref, placeOrigin, placeUrl, siteOrigin, type LinkBase, type LinkMode, type OriginFacts, type PlaceView, } from "./view.js";
@@ -1,5 +1,6 @@
1
1
  export { servedLocalities, storefrontOf, } from "./types.js";
2
2
  export { isPublished, publicationGaps, SERVICE_AREA_GATE, STOREFRONT_GATE, } from "./publication.js";
3
+ export { DEFAULT_TIME_ZONE, isOpenAt, nextOpening } from "./hours.js";
3
4
  export { freshRating, parseInstant, RATING_MAX_AGE_DAYS } from "./rating.js";
4
5
  export { mergeLive, parsePlaceLive } from "./live.js";
5
6
  export { createPlaceView, placeHref, placeOrigin, placeUrl, siteOrigin, } from "./view.js";
package/dist/index.d.ts CHANGED
@@ -4,14 +4,18 @@
4
4
  * Runtime-bound halves live on their own subpaths.
5
5
  */
6
6
  export { assertLaunchable, bakedPlace, cardFact, contactOf, defineSite, ogLocaleOf, openLaunchBlockers, perLocale, type BrandFacts, type LegacyRedirect, type OwnerTodo, type PageSuffix, type Site, type SiteConfig, type Topology, } from "./core/site.js";
7
- export { createPlaceView, freshRating, parseInstant, isPublished, mergeLive, parsePlaceLive, placeHref, placeOrigin, placeUrl, publicationGaps, RATING_MAX_AGE_DAYS, servedLocalities, SERVICE_AREA_GATE, siteOrigin, STOREFRONT_GATE, storefrontOf, type DayOfWeek, type Geo, type LinkBase, type LinkMode, type OpeningHours, type OriginFacts, type Place, type PlaceLive, type PlaceView, type PostalAddress, type Presence, type PublicationField, type PublicationPolicy, type Rating, type ServiceArea, } from "./core/place/index.js";
7
+ export { createPlaceView, DEFAULT_TIME_ZONE, freshRating, isOpenAt, nextOpening, parseInstant, isPublished, mergeLive, parsePlaceLive, placeHref, placeOrigin, placeUrl, publicationGaps, RATING_MAX_AGE_DAYS, servedLocalities, SERVICE_AREA_GATE, siteOrigin, STOREFRONT_GATE, storefrontOf, type DayOfWeek, type Geo, type LinkBase, type LinkMode, type Opening, type OpeningHours, type OriginFacts, type Place, type PlaceLive, type PlaceView, type PostalAddress, type Presence, type PublicationField, type PublicationPolicy, type Rating, type ServiceArea, } from "./core/place/index.js";
8
8
  export { createRouting, GONE, GONE_HEADER, gonePath, goneHeader, HOST_MARK, LANG_COOKIE, LANG_COOKIE_MAX_AGE, NON_PAGE_ROUTES, parseGoneHeader, parsePlaceParam, PASS_PATHS, placeParam, pointSuffixes, THANKS, type Decision, type RequestFacts, type Routing, } from "./core/routing.js";
9
- export { LEAD_SCHEMA_VERSION, MAX_FIELD, readCandidate, validateCandidate, type Lead, type LeadCandidate, type LeadExtra, type LeadSchema, type LeadStore, type LeadWire, type SpamVerdict, } from "./core/lead.js";
9
+ export { CHANNEL_FIELD, channelOf, CONSENT_FIELD, LEAD_CHANNELS, LEAD_SCHEMA_VERSION, MAX_CONSENT, MAX_FIELD, readCandidate, validateCallbackLead, validateCandidate, type Lead, type LeadCandidate, type LeadChannel, type LeadConsent, type LeadExtra, type LeadSchema, type LeadStore, type LeadWire, type SpamVerdict, } from "./core/lead.js";
10
+ export { isMobilePhone, isPlausiblePhone, normalizePhone } from "./core/phone.js";
11
+ export { LEAD_CAPTURE_TEXT, type LeadCaptureText } from "./core/lead-capture-text.js";
12
+ export { fillText, openingText } from "./core/lead-capture-format.js";
13
+ export { channelHref, resolveChannels, smsHref, type CaptureChannel, type ChannelFacts, type ChannelOptions, type ResolvedChannels, } from "./core/channels.js";
10
14
  export { checkTiming, HONEYPOT_FIELD, LEGACY_HONEYPOT_FIELDS, MIN_FILL_MS, RATE_LIMIT_MAX_KEYS, RateLimiter, RENDERED_AT_FIELD, screen, type Screening, } from "./core/antispam.js";
11
- export { createAcceptLead, FORM_ID_FIELD, LOCALE_FIELD, LOCATION_FIELD, type AcceptDeps, type Outcome, } from "./core/accept.js";
15
+ export { createAcceptLead, EXPERIMENT_FIELD, FORM_ID_FIELD, LOCALE_FIELD, LOCATION_FIELD, VARIANT_FIELD, type AcceptDeps, type Outcome, type SubmitTags, } from "./core/accept.js";
12
16
  export { areaServedNodes, breadcrumbNode, businessId, businessNode, faqPageNode, offerNodes, organizationId, organizationNode, placeGraph, websiteId, type OfferInput, type PageGraphCopy, type QuestionAnswer, } from "./core/seo/ld.js";
13
17
  export { AI_CRAWLERS, ogImageUrl, robotsFor, sitemapFor, type RobotsFile, type RobotsRule, type SitemapEntry, } from "./core/seo/sitemap.js";
14
- export { ALLOWED_PROPS, analyticsSink, countsAsPageView, EVENTS, type AnalyticsTarget, type IntentChannel, } from "./core/analytics.js";
18
+ export { ALLOWED_PROPS, analyticsSink, countsAsPageView, EVENTS, EXPERIMENT_SLUG, experimentProps, type AnalyticsTarget, type IntentChannel, type LeadField, type LeadStep, } from "./core/analytics.js";
15
19
  export type { CallBarText, CopySlice, CoreText, PageMetaCopy, QuoteFormCopy, Said, StatusAction, StatusCopy, StatusScreenText, } from "./core/content.js";
16
20
  export { brandStatusTarget, statusTarget, type StatusTarget } from "./core/status.js";
17
21
  export { createLocaleRegistry, type LocaleRegistry } from "@evinvest/i18n";
package/dist/index.js CHANGED
@@ -4,14 +4,18 @@
4
4
  * Runtime-bound halves live on their own subpaths.
5
5
  */
6
6
  export { assertLaunchable, bakedPlace, cardFact, contactOf, defineSite, ogLocaleOf, openLaunchBlockers, perLocale, } from "./core/site.js";
7
- export { createPlaceView, freshRating, parseInstant, isPublished, mergeLive, parsePlaceLive, placeHref, placeOrigin, placeUrl, publicationGaps, RATING_MAX_AGE_DAYS, servedLocalities, SERVICE_AREA_GATE, siteOrigin, STOREFRONT_GATE, storefrontOf, } from "./core/place/index.js";
7
+ export { createPlaceView, DEFAULT_TIME_ZONE, freshRating, isOpenAt, nextOpening, parseInstant, isPublished, mergeLive, parsePlaceLive, placeHref, placeOrigin, placeUrl, publicationGaps, RATING_MAX_AGE_DAYS, servedLocalities, SERVICE_AREA_GATE, siteOrigin, STOREFRONT_GATE, storefrontOf, } from "./core/place/index.js";
8
8
  export { createRouting, GONE, GONE_HEADER, gonePath, goneHeader, HOST_MARK, LANG_COOKIE, LANG_COOKIE_MAX_AGE, NON_PAGE_ROUTES, parseGoneHeader, parsePlaceParam, PASS_PATHS, placeParam, pointSuffixes, THANKS, } from "./core/routing.js";
9
- export { LEAD_SCHEMA_VERSION, MAX_FIELD, readCandidate, validateCandidate, } from "./core/lead.js";
9
+ export { CHANNEL_FIELD, channelOf, CONSENT_FIELD, LEAD_CHANNELS, LEAD_SCHEMA_VERSION, MAX_CONSENT, MAX_FIELD, readCandidate, validateCallbackLead, validateCandidate, } from "./core/lead.js";
10
+ export { isMobilePhone, isPlausiblePhone, normalizePhone } from "./core/phone.js";
11
+ export { LEAD_CAPTURE_TEXT } from "./core/lead-capture-text.js";
12
+ export { fillText, openingText } from "./core/lead-capture-format.js";
13
+ export { channelHref, resolveChannels, smsHref, } from "./core/channels.js";
10
14
  export { checkTiming, HONEYPOT_FIELD, LEGACY_HONEYPOT_FIELDS, MIN_FILL_MS, RATE_LIMIT_MAX_KEYS, RateLimiter, RENDERED_AT_FIELD, screen, } from "./core/antispam.js";
11
- export { createAcceptLead, FORM_ID_FIELD, LOCALE_FIELD, LOCATION_FIELD, } from "./core/accept.js";
15
+ export { createAcceptLead, EXPERIMENT_FIELD, FORM_ID_FIELD, LOCALE_FIELD, LOCATION_FIELD, VARIANT_FIELD, } from "./core/accept.js";
12
16
  export { areaServedNodes, breadcrumbNode, businessId, businessNode, faqPageNode, offerNodes, organizationId, organizationNode, placeGraph, websiteId, } from "./core/seo/ld.js";
13
17
  export { AI_CRAWLERS, ogImageUrl, robotsFor, sitemapFor, } from "./core/seo/sitemap.js";
14
- export { ALLOWED_PROPS, analyticsSink, countsAsPageView, EVENTS, } from "./core/analytics.js";
18
+ export { ALLOWED_PROPS, analyticsSink, countsAsPageView, EVENTS, EXPERIMENT_SLUG, experimentProps, } from "./core/analytics.js";
15
19
  export { brandStatusTarget, statusTarget } from "./core/status.js";
16
20
  // A landing needs these beside the machinery; re-exported by name so a brand
17
21
  // imports one package and the list here is the whole surface.
@@ -1,8 +1,9 @@
1
1
  import { RateLimiter } from "../core/antispam.js";
2
- import type { LeadStore } from "../core/lead.js";
2
+ import { type LeadStore } from "../core/lead.js";
3
3
  import type { Place } from "../core/place/types.js";
4
4
  import { type Site } from "../core/site.js";
5
5
  import type { ServerEnv } from "../server/env.js";
6
+ import type { LeadWebhook } from "../server/lead-webhook.js";
6
7
  import type { LeadNotifier } from "../server/notify.js";
7
8
  /**
8
9
  * `app/quote/route.ts` — the no-JS path, and the one that has to keep working:
@@ -10,7 +11,7 @@ import type { LeadNotifier } from "../server/notify.js";
10
11
  *
11
12
  * ```ts
12
13
  * export const dynamic = "force-dynamic";
13
- * export const POST = quoteRoute(site, { env: serverEnv, notifier, unavailable });
14
+ * export const POST = quoteRoute(site, { env: serverEnv, notifier, webhook, unavailable });
14
15
  * ```
15
16
  */
16
17
  export interface UnavailableCopy {
@@ -23,6 +24,13 @@ export interface QuoteRouteDeps<L extends string> {
23
24
  env: () => Pick<ServerEnv, "leadsDb" | "posthogKey" | "posthogHost" | "trustedProxy">;
24
25
  /** Built once (and at boot, by the brand) so a missing sender fails startup, not a lead. */
25
26
  notifier: () => LeadNotifier;
27
+ /**
28
+ * The signed lead webhook, or `null` when it is off (`leadWebhook`). A lead
29
+ * is queued before the 303 and sent after it; the brand's
30
+ * `instrumentation.ts` should `start()` it so a restart resumes the queue
31
+ * without waiting for the next lead.
32
+ */
33
+ webhook?: () => LeadWebhook | null;
26
34
  /** The self-contained 500's words, when the store refused the lead. */
27
35
  unavailable: (locale: L, place: Place<L> | null) => UnavailableCopy;
28
36
  /** Opened on first use (`next build` imports the route); defaults to `LEADS_DB_URL`'s. */
@@ -2,6 +2,7 @@ import { after } from "next/server.js";
2
2
  import { createAcceptLead } from "../core/accept.js";
3
3
  import { analyticsSink, EVENTS } from "../core/analytics.js";
4
4
  import { RateLimiter } from "../core/antispam.js";
5
+ import { channelOf } from "../core/lead.js";
5
6
  import { createPlaceView } from "../core/place/view.js";
6
7
  import { createRouting, THANKS } from "../core/routing.js";
7
8
  import { bakedPlace, contactOf } from "../core/site.js";
@@ -56,6 +57,15 @@ export function quoteRoute(site, deps) {
56
57
  const log = deps.log ?? console;
57
58
  let store;
58
59
  let notifier;
60
+ let webhook;
61
+ const leadWebhook = () => {
62
+ if (webhook === undefined) {
63
+ webhook = deps.webhook?.() ?? null;
64
+ // Idempotent: a brand that started it at boot keeps its timer.
65
+ webhook?.start();
66
+ }
67
+ return webhook;
68
+ };
59
69
  const leadStore = () => {
60
70
  store ??= deps.store ? deps.store() : openLeadStore(deps.env().leadsDb);
61
71
  return store;
@@ -104,8 +114,17 @@ export function quoteRoute(site, deps) {
104
114
  notifier ??= deps.notifier();
105
115
  return notifier.notify(lead, id);
106
116
  },
107
- capture: (lead, formId) => analyticsSink({ key: env.posthogKey, host: env.posthogHost, brandId: site.brand.id }, lead.placeSlug).capture(EVENTS.leadSubmit, {
117
+ enqueue: (lead, id, meta) => {
118
+ const hook = leadWebhook();
119
+ if (!hook)
120
+ return;
121
+ hook.enqueue(lead, id, meta);
122
+ defer(() => hook.tick().then(() => undefined, error => log.error("quote: the webhook delivery after a lead failed", error)));
123
+ },
124
+ capture: (lead, formId, tags) => analyticsSink({ key: env.posthogKey, host: env.posthogHost, brandId: site.brand.id }, lead.placeSlug).capture(EVENTS.leadSubmit, {
108
125
  form_id: formId,
126
+ channel: channelOf(lead),
127
+ ...tags,
109
128
  }),
110
129
  limiter,
111
130
  now: deps.now?.() ?? Date.now(),
@@ -3,10 +3,14 @@ import { jsx as _jsx } from "react/jsx-runtime";
3
3
  import { ContactLinkTracker } from "@evinvest/marketing/tracker";
4
4
  import { usePathname } from "next/navigation.js";
5
5
  import { useEffect, useMemo } from "react";
6
- import { analyticsSink, countsAsPageView, EVENTS } from "../core/analytics.js";
7
- /** `data-intent` on a link or button marks a conversion intent that is not a contact link. */
6
+ import { analyticsSink, countsAsPageView, EVENTS, experimentProps } from "../core/analytics.js";
7
+ import { AnalyticsSinkContext, experimentOf } from "./analytics-context.js";
8
+ /**
9
+ * `data-intent` on a link or button marks a conversion intent that is not a
10
+ * contact link — or one the tracker cannot classify (`sms:`).
11
+ */
8
12
  const INTENT_ATTR = "data-intent";
9
- const INTENTS = ["form_open", "booking"];
13
+ const INTENTS = ["form_open", "booking", "sms", "callback"];
10
14
  function source() {
11
15
  const utm = new URLSearchParams(window.location.search).get("utm_source");
12
16
  if (utm)
@@ -46,10 +50,12 @@ export function AnalyticsBoundary({ target, placeSlug, children }) {
46
50
  const el = event.target instanceof Element ? event.target.closest(`[${INTENT_ATTR}]`) : null;
47
51
  const channel = el?.getAttribute(INTENT_ATTR);
48
52
  if (channel && INTENTS.some(i => i === channel))
49
- sink.capture(EVENTS.intent, { channel }, { transport: "beacon" });
53
+ sink.capture(EVENTS.intent, { channel, ...experimentOf(el) }, { transport: "beacon" });
50
54
  };
51
55
  document.addEventListener("click", onClick, { capture: true });
52
56
  return () => document.removeEventListener("click", onClick, { capture: true });
53
57
  }, [sink]);
54
- return (_jsx(ContactLinkTracker, { channels: ["phone", "whatsapp"], onContact: ({ channel }) => sink.capture(EVENTS.intent, { channel }, { transport: "beacon" }), children: children }));
58
+ // A link inside an experiment's island (`LeadCapture`) carries the
59
+ // assignment on itself, which is what the tracker reads.
60
+ return (_jsx(AnalyticsSinkContext.Provider, { value: sink, children: _jsx(ContactLinkTracker, { channels: ["phone", "whatsapp"], onContact: ({ channel, data }) => sink.capture(EVENTS.intent, { channel, ...experimentProps(data["experiment"], data["variant"]) }, { transport: "beacon" }), children: children }) }));
55
61
  }