@evinvest/kitstart 0.16.0 → 0.18.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 (97) hide show
  1. package/README.md +179 -19
  2. package/dist/core/ab-gate.js +3 -1
  3. package/dist/core/ab-switcher.d.ts +8 -0
  4. package/dist/core/ab-switcher.js +11 -0
  5. package/dist/core/accept.d.ts +14 -0
  6. package/dist/core/accept.js +21 -3
  7. package/dist/core/analytics.d.ts +28 -3
  8. package/dist/core/analytics.js +46 -2
  9. package/dist/core/channels.d.ts +12 -4
  10. package/dist/core/channels.js +11 -1
  11. package/dist/core/lead-capture-text.d.ts +6 -2
  12. package/dist/core/lead.d.ts +33 -10
  13. package/dist/core/lead.js +21 -8
  14. package/dist/core/messenger-message.d.ts +31 -0
  15. package/dist/core/messenger-message.js +33 -0
  16. package/dist/core/messenger-text.d.ts +104 -0
  17. package/dist/core/messenger-text.js +150 -0
  18. package/dist/core/messenger.d.ts +92 -0
  19. package/dist/core/messenger.js +55 -0
  20. package/dist/core/place/index.d.ts +1 -1
  21. package/dist/core/place/live.d.ts +5 -1
  22. package/dist/core/place/live.js +22 -1
  23. package/dist/core/place/types.d.ts +17 -1
  24. package/dist/core/site.d.ts +8 -1
  25. package/dist/core/site.js +22 -1
  26. package/dist/index.d.ts +9 -6
  27. package/dist/index.js +8 -5
  28. package/dist/next/quote-route.d.ts +7 -0
  29. package/dist/next/quote-route.js +9 -9
  30. package/dist/react/AbSwitcherPanel.d.ts +3 -1
  31. package/dist/react/AbSwitcherPanel.js +10 -6
  32. package/dist/react/AnalyticsBoundary.d.ts +3 -2
  33. package/dist/react/AnalyticsBoundary.js +7 -20
  34. package/dist/react/LeadCapture.d.ts +24 -1
  35. package/dist/react/LeadCapture.js +96 -11
  36. package/dist/react/LeadCaptureChannels.d.ts +7 -2
  37. package/dist/react/LeadCaptureChannels.js +2 -2
  38. package/dist/react/LeadCaptureEstimate.d.ts +6 -0
  39. package/dist/react/LeadCaptureFields.d.ts +17 -0
  40. package/dist/react/LeadCaptureFields.js +42 -33
  41. package/dist/react/LeadCaptureMessenger.d.ts +41 -0
  42. package/dist/react/LeadCaptureMessenger.js +133 -0
  43. package/dist/react/LeadCapturePrice.d.ts +9 -3
  44. package/dist/react/LeadCapturePrice.js +24 -4
  45. package/dist/react/LeadCaptureSubmit.d.ts +3 -0
  46. package/dist/react/LeadCaptureSubmit.js +2 -2
  47. package/dist/react/LeadCaptureTiles.js +9 -4
  48. package/dist/react/ab-switcher-types.d.ts +1 -1
  49. package/dist/react/analytics-context.d.ts +9 -3
  50. package/dist/react/analytics-context.js +7 -3
  51. package/dist/react/index.d.ts +1 -0
  52. package/dist/react/messenger/action.d.ts +144 -0
  53. package/dist/react/messenger/action.js +301 -0
  54. package/dist/react/messenger/chip.d.ts +8 -0
  55. package/dist/react/messenger/chip.js +22 -0
  56. package/dist/react/messenger/fallback.d.ts +8 -0
  57. package/dist/react/messenger/fallback.js +11 -0
  58. package/dist/react/messenger/saga.d.ts +10 -0
  59. package/dist/react/messenger/saga.js +33 -0
  60. package/dist/react/messenger/segment.d.ts +8 -0
  61. package/dist/react/messenger/segment.js +15 -0
  62. package/dist/react/messenger/select.d.ts +10 -0
  63. package/dist/react/messenger/select.js +26 -0
  64. package/dist/react/messenger/sheet.d.ts +10 -0
  65. package/dist/react/messenger/sheet.js +57 -0
  66. package/dist/react/messenger/split.d.ts +9 -0
  67. package/dist/react/messenger/split.js +17 -0
  68. package/dist/react/messenger/swap.d.ts +10 -0
  69. package/dist/react/messenger/swap.js +18 -0
  70. package/dist/react/messenger/thanks.d.ts +10 -0
  71. package/dist/react/messenger/thanks.js +18 -0
  72. package/dist/react/messenger/tiles.d.ts +8 -0
  73. package/dist/react/messenger/tiles.js +20 -0
  74. package/dist/react/messenger/types.d.ts +98 -0
  75. package/dist/react/messenger/types.js +1 -0
  76. package/dist/react/messenger/urgency.d.ts +10 -0
  77. package/dist/react/messenger/urgency.js +29 -0
  78. package/dist/react/use-lead-error.d.ts +3 -3
  79. package/dist/react/use-lead-events.d.ts +21 -1
  80. package/dist/react/use-lead-events.js +9 -3
  81. package/dist/react/use-lead-submit.d.ts +20 -6
  82. package/dist/react/use-lead-submit.js +23 -1
  83. package/dist/server/index.d.ts +1 -1
  84. package/dist/server/lead-store-sqlite.js +11 -5
  85. package/dist/server/lead-webhook.d.ts +30 -5
  86. package/dist/server/lead-webhook.js +13 -7
  87. package/dist/server/notify.js +11 -5
  88. package/dist/testing/index.d.ts +1 -1
  89. package/dist/testing/index.js +1 -1
  90. package/dist/testing/landing-contract.js +6 -1
  91. package/dist/testing/lead-rules.d.ts +8 -0
  92. package/dist/testing/lead-rules.js +30 -1
  93. package/package.json +5 -2
  94. package/template/app/quote/route.ts +3 -0
  95. package/template/flake.nix +1 -1
  96. package/template/package.json +2 -2
  97. package/template/tests/e2e/ab-switcher.spec.ts +2 -0
@@ -1,8 +1,13 @@
1
1
  import { type FormEventHandler } from "react";
2
2
  import { type LeadChannel } from "../core/lead.js";
3
+ /**
4
+ * Which of the card's forms a submit posts: a messenger lead is posted in the
5
+ * background instead (`postInBackground`), and never answers in the card.
6
+ */
7
+ export type FormChannel = Extract<LeadChannel, "form" | "callback">;
3
8
  /** What the visitor just sent, for a brand's in-card success ("we call 06 … back"). */
4
9
  export interface LeadSent {
5
- channel: LeadChannel;
10
+ channel: FormChannel;
6
11
  /** As typed. */
7
12
  phone: string;
8
13
  /** The name field's value, when the form has one and it was filled. */
@@ -26,6 +31,15 @@ export declare const navigation: {
26
31
  * over plain http is not one, but `getRandomValues` works everywhere.
27
32
  */
28
33
  export declare function newSubmissionId(): string;
34
+ /**
35
+ * A messenger lead: the form as it stands, with `extra` over it (the
36
+ * channel, the reference), posted as the visitor leaves for the chat —
37
+ * `keepalive`, so the request outlives the page handing over to the app. No
38
+ * answer is waited for and none is shown: the chat is where it goes on. A
39
+ * second tap on the same lead carries the same submission id, so the route
40
+ * stores it once. Resolves whether the route took it.
41
+ */
42
+ export declare function postInBackground(form: HTMLFormElement, extra: Readonly<Record<string, string>>, analyticsId: string | null): Promise<boolean>;
29
43
  /** Why the script's post got no answer: none came back (`network`), or none in time (`timeout`). */
30
44
  export type SendFailure = "network" | "timeout";
31
45
  /** How long the script waits for `/quote` before it says so; the lead is a few short fields. */
@@ -33,18 +47,18 @@ export declare const SUBMIT_TIMEOUT_MS = 15000;
33
47
  export interface LeadSubmitHandlers {
34
48
  /** The route refused the lead: the field to fix. */
35
49
  /** `cents`: with `price_changed`, the server's fresh price. */
36
- onRefused: (channel: LeadChannel, field: string, cents?: number) => void;
50
+ onRefused: (channel: FormChannel, field: string, cents?: number) => void;
37
51
  /** No answer: the form stays as typed, with a retry. */
38
- onFailed: (channel: LeadChannel, failure: SendFailure) => void;
52
+ onFailed: (channel: FormChannel, failure: SendFailure) => void;
39
53
  }
40
54
  export interface LeadSubmit {
41
55
  sent: LeadSent | null;
42
56
  onSubmit: FormEventHandler<HTMLFormElement>;
43
57
  /** The form being posted, while it is. */
44
- busy: LeadChannel | null;
58
+ busy: FormChannel | null;
45
59
  /** The last post that got no answer, until the next one. */
46
60
  failure: {
47
- channel: LeadChannel;
61
+ channel: FormChannel;
48
62
  failure: SendFailure;
49
63
  } | null;
50
64
  /** Posts that form again — the same submission id, so never a second lead. */
@@ -60,7 +74,7 @@ export interface LeadSubmit {
60
74
  * form. Any other answer (the store's 500) submits the form for real, so the
61
75
  * server's own page says what went wrong; the submission id makes that safe.
62
76
  */
63
- export declare function useLeadSubmit(done: boolean | ((channel: LeadChannel) => boolean), fields: {
77
+ export declare function useLeadSubmit(done: boolean | ((channel: FormChannel) => boolean), fields: {
64
78
  mobile: string;
65
79
  name: string | undefined;
66
80
  }, on: LeadSubmitHandlers): LeadSubmit;
@@ -31,18 +31,40 @@ const minted = new WeakMap();
31
31
  * fixed after a timeout is another lead, which must not be answered with the
32
32
  * first one's row.
33
33
  */
34
- function stamp(form) {
34
+ function stamp(form, extra = {}) {
35
35
  const field = form.elements.namedItem(SUBMISSION_FIELD);
36
36
  if (!(field instanceof HTMLInputElement))
37
37
  return;
38
38
  const posted = new FormData(form);
39
39
  posted.delete(SUBMISSION_FIELD);
40
+ for (const [key, value] of Object.entries(extra))
41
+ posted.set(key, value);
40
42
  const lead = new URLSearchParams([...posted].filter((e) => typeof e[1] === "string")).toString();
41
43
  const known = minted.get(form);
42
44
  const id = known && known.lead === lead ? known.id : newSubmissionId();
43
45
  minted.set(form, { id, lead });
44
46
  field.value = id;
45
47
  }
48
+ /**
49
+ * A messenger lead: the form as it stands, with `extra` over it (the
50
+ * channel, the reference), posted as the visitor leaves for the chat —
51
+ * `keepalive`, so the request outlives the page handing over to the app. No
52
+ * answer is waited for and none is shown: the chat is where it goes on. A
53
+ * second tap on the same lead carries the same submission id, so the route
54
+ * stores it once. Resolves whether the route took it.
55
+ */
56
+ export function postInBackground(form, extra, analyticsId) {
57
+ stamp(form, extra);
58
+ const body = new URLSearchParams();
59
+ for (const [key, value] of new FormData(form))
60
+ if (typeof value === "string" && !(key in extra))
61
+ body.append(key, value);
62
+ for (const [key, value] of Object.entries(extra))
63
+ body.set(key, value);
64
+ if (analyticsId)
65
+ body.set(ANALYTICS_ID_FIELD, analyticsId);
66
+ return fetch(form.action, { method: "POST", body, headers: { Accept: "application/json" }, keepalive: true }).then(res => res.ok, () => false);
67
+ }
46
68
  const LEAD_REF = /^lead-\d+-[0-9a-f]{8}$/;
47
69
  /** A root-relative path on this origin — never `//elsewhere`. */
48
70
  const OWN_PATH = /^\/(?!\/)/;
@@ -16,6 +16,6 @@ export { parseSmtpUrl, sendMail, type Mail, type SendOptions } from "./smtp.js";
16
16
  export { defaultLeadMail, leadNotifier, MAIL_PER_MINUTE, type LeadMail, type LeadMailShown, type LeadNotifier, type NeedLabel, type NotifyEnv } from "./notify.js";
17
17
  export { clientKey, NO_CLIENT_ADDRESS, parseProxyTrust, type ProxyTrust } from "./client-key.js";
18
18
  export { checkLeadStore } from "./boot.js";
19
- export { leadWebhook, panelChannel, panelFlowOf, panelFlowProperties, type BookingQueued, type BookingWebhookContext, type BuildBookingBody, type BuildWebhookBody, type LeadWebhook, type LeadWebhookContext, type LeadWebhookOptions, type PanelFlow, type PanelFlowProperties, } from "./lead-webhook.js";
19
+ export { leadWebhook, panelChannel, panelFlowOf, panelFlowProperties, type BookingQueued, type BookingWebhookContext, type BuildBookingBody, type BuildWebhookBody, type LeadWebhook, type LeadWebhookContext, type LeadWebhookOptions, type PanelChannel, type PanelFlow, type PanelFlowProperties, } from "./lead-webhook.js";
20
20
  export { checkWebhookUrl, openWebhookOutbox, WEBHOOK_HORIZON_MS, WEBHOOK_MAX_ATTEMPTS, WEBHOOK_MAX_DELAY_MS, WEBHOOK_TICK_MS, type DeadRow, type OutboxRow, type OutboxState, type TickReport, type WebhookOutbox, type WebhookOutboxOptions, type WebhookTarget, } from "./webhook-outbox.js";
21
21
  export { signatureHeaders, signWebhook, type WebhookSignatureHeaders, type WebhookSigning } from "./webhook-signature.js";
@@ -1,7 +1,7 @@
1
1
  import "server-only";
2
2
  import { mkdirSync } from "node:fs";
3
3
  import { dirname } from "node:path";
4
- import { channelOf, LEAD_SCHEMA_VERSION } from "../core/lead.js";
4
+ import { channelOf, LEAD_CHANNELS, LEAD_SCHEMA_VERSION } from "../core/lead.js";
5
5
  import { LEAD_FLOWS } from "../core/pricing/flow.js";
6
6
  // `node:sqlite` is reached through `getBuiltinModule` rather than an import so
7
7
  // the bundler never has to resolve a builtin it may not know yet; the type is
@@ -67,6 +67,10 @@ const STEPS = [
67
67
  db.exec("ALTER TABLE leads ADD COLUMN pricing_valid_from TEXT");
68
68
  db.exec("ALTER TABLE leads ADD COLUMN estimate_inputs TEXT");
69
69
  },
70
+ // 8 — the reference a messenger lead's chat carries (`AQ-7K3F`), so the
71
+ // operator finds the lead from the message. Nullable: a row from before, a
72
+ // lead from a card without a reference.
73
+ db => db.exec("ALTER TABLE leads ADD COLUMN message_ref TEXT"),
70
74
  ];
71
75
  if (STEPS.length !== LEAD_SCHEMA_VERSION) {
72
76
  // The version belongs to the port, shared by every adapter; this table of
@@ -99,6 +103,7 @@ function leadOf(row) {
99
103
  const submissionId = str(row, "submission_id");
100
104
  const flow = LEAD_FLOWS.find(f => f === str(row, "flow"));
101
105
  const price = priceOf(row);
106
+ const messageRef = str(row, "message_ref");
102
107
  return {
103
108
  subject: str(row, "job") ?? "",
104
109
  locality: str(row, "zip") ?? "",
@@ -106,11 +111,12 @@ function leadOf(row) {
106
111
  extras: strings(str(row, "extras")),
107
112
  placeSlug: str(row, "location_id"),
108
113
  spamVerdict: verdict !== null && VERDICTS.includes(verdict) ? verdict : null,
109
- channel: str(row, "channel") === "callback" ? "callback" : "form",
114
+ channel: LEAD_CHANNELS.find(c => c === str(row, "channel")) ?? "form",
110
115
  ...(consentAt !== null ? { consent: { at: consentAt, text: str(row, "consent_text") ?? "" } } : {}),
111
116
  ...(submissionId !== null ? { submissionId } : {}),
112
117
  ...(flow !== undefined ? { flow } : {}),
113
118
  ...(price !== null ? { price } : {}),
119
+ ...(messageRef !== null ? { messageRef } : {}),
114
120
  };
115
121
  }
116
122
  function userVersion(db) {
@@ -194,13 +200,13 @@ export function openSqliteLeadStore(path) {
194
200
  db.close();
195
201
  throw error;
196
202
  }
197
- const insert = db.prepare("INSERT INTO leads (job, zip, mobile, location_id, spam_verdict, extras, channel, consent_at, consent_text, submission_id, flow, quoted_cents, pricing_valid_from, estimate_inputs) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id");
198
- const bySubmission = db.prepare("SELECT id, job, zip, mobile, location_id, spam_verdict, extras, channel, consent_at, consent_text, submission_id, flow, quoted_cents, pricing_valid_from, estimate_inputs FROM leads WHERE submission_id = ?");
203
+ const insert = db.prepare("INSERT INTO leads (job, zip, mobile, location_id, spam_verdict, extras, channel, consent_at, consent_text, submission_id, flow, quoted_cents, pricing_valid_from, estimate_inputs, message_ref) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id");
204
+ const bySubmission = db.prepare("SELECT id, job, zip, mobile, location_id, spam_verdict, extras, channel, consent_at, consent_text, submission_id, flow, quoted_cents, pricing_valid_from, estimate_inputs, message_ref FROM leads WHERE submission_id = ?");
199
205
  const count = db.prepare("SELECT COUNT(*) AS n FROM leads");
200
206
  return {
201
207
  async insert(lead) {
202
208
  const extras = Object.keys(lead.extras).length > 0 ? JSON.stringify(lead.extras) : null;
203
- return integer(insert.get(lead.subject, lead.locality, lead.mobile, lead.placeSlug, lead.spamVerdict, extras, channelOf(lead), lead.consent?.at ?? null, lead.consent?.text ?? null, lead.submissionId ?? null, lead.flow ?? null, lead.price?.cents ?? null, lead.price?.validFrom ?? null, lead.price?.inputs ? JSON.stringify(lead.price.inputs) : null), "id");
209
+ return integer(insert.get(lead.subject, lead.locality, lead.mobile, lead.placeSlug, lead.spamVerdict, extras, channelOf(lead), lead.consent?.at ?? null, lead.consent?.text ?? null, lead.submissionId ?? null, lead.flow ?? null, lead.price?.cents ?? null, lead.price?.validFrom ?? null, lead.price?.inputs ? JSON.stringify(lead.price.inputs) : null, lead.messageRef ?? null), "id");
204
210
  },
205
211
  async findSubmission(submissionId) {
206
212
  const row = bySubmission.get(submissionId);
@@ -43,6 +43,18 @@ export interface LeadWebhookContext {
43
43
  * property: it refuses an unknown one, and the outbox would park the lead.
44
44
  */
45
45
  analyticsId?: string;
46
+ /**
47
+ * The lead's channel as the panel's `properties.channel` takes it
48
+ * (`panelChannel`): `whatsapp` and `telegram` only under `panelMessenger`,
49
+ * `form` otherwise. Absent only on a context built by hand.
50
+ */
51
+ channel?: PanelChannel;
52
+ /**
53
+ * The reference the visitor's chat carries (`Lead.messageRef`), for the
54
+ * panel's `properties.message_ref` — only under `panelMessenger`, and only
55
+ * on a lead that has one; otherwise absent, and the body must not carry it.
56
+ */
57
+ messageRef?: string;
46
58
  }
47
59
  /** A lead's sale as the panel's `lead.created` may carry it. */
48
60
  export interface PanelFlow {
@@ -69,14 +81,17 @@ export interface PanelFlowProperties {
69
81
  estimate_inputs?: Readonly<Record<string, string>>;
70
82
  }
71
83
  export declare function panelFlowProperties(flow: PanelFlow | undefined): PanelFlowProperties | Record<string, never>;
84
+ /** A channel a site sends the panel; `phone_inbound` is the panel's own, for the calls it records. */
85
+ export type PanelChannel = "form" | "callback" | "whatsapp" | "telegram";
72
86
  /**
73
87
  * A lead's channel as the Service-Arb panel's `lead.created` carries it in
74
- * `properties.channel` — a closed set (`form` | `phone_inbound` | `callback`)
75
- * that refuses the whole event outside it, so the outbox would park the lead.
76
- * A site's lead is a form or a callback, each its own: `phone_inbound` is the
77
- * panel's, for the calls it records itself. `panelChannel(channelOf(lead))`.
88
+ * `properties.channel` — a closed set that refuses the whole event outside
89
+ * it, so the outbox would park the lead. `messenger`: whether the panel takes
90
+ * `whatsapp` and `telegram` (`LeadWebhookOptions.panelMessenger`); until it
91
+ * does, a messenger lead is a `form` there — the leads table keeps the truth.
92
+ * `ctx.channel` is this, already decided.
78
93
  */
79
- export declare function panelChannel(channel: LeadChannel): "form" | "callback";
94
+ export declare function panelChannel(channel: LeadChannel, messenger?: boolean): PanelChannel;
80
95
  /**
81
96
  * The receiver's body, built by the brand: the kit serialises and signs
82
97
  * whatever this returns and knows nothing of its shape. Called once, when the
@@ -129,6 +144,8 @@ export interface LeadWebhook {
129
144
  readonly panelFlow: boolean;
130
145
  /** Whether `requestBooking` queues anything (`LeadWebhookOptions.panelBooking`). */
131
146
  readonly panelBooking?: boolean;
147
+ /** Whether `ctx.channel` says `whatsapp` / `telegram` and `ctx.messageRef` is filled in (`LeadWebhookOptions.panelMessenger`). */
148
+ readonly panelMessenger?: boolean;
132
149
  /**
133
150
  * Queues `booking.requested@1` behind the lead's `lead.created`, through
134
151
  * the same outbox: not sent until that row is delivered, and a `409` / `425`
@@ -173,6 +190,14 @@ export interface LeadWebhookOptions extends WebhookOutboxOptions {
173
190
  * and dropped; the lead is untouched either way.
174
191
  */
175
192
  panelBooking?: boolean;
193
+ /**
194
+ * The switch for the messenger channels: `ctx.channel` says `whatsapp` or
195
+ * `telegram`, and `ctx.messageRef` carries the lead's reference. Off (the
196
+ * default) until the panel's `lead.created` accepts them — it refuses an
197
+ * unknown channel or property, and the outbox would park the lead: a
198
+ * messenger lead goes as a `form`, without its reference.
199
+ */
200
+ panelMessenger?: boolean;
176
201
  /** The body of `booking.requested@1`; absent → booking requests are dropped whatever `panelBooking` says. */
177
202
  buildBookingBody?: BuildBookingBody | undefined;
178
203
  }
@@ -1,6 +1,6 @@
1
1
  import "server-only";
2
2
  import { randomUUID } from "node:crypto";
3
- import { suspectOf } from "../core/lead.js";
3
+ import { channelOf, suspectOf } from "../core/lead.js";
4
4
  import { experimentsDeclaredBody } from "./experiments-declared.js";
5
5
  import { openWebhookOutbox } from "./webhook-outbox.js";
6
6
  /** The sale `ctx.flow` describes, or nothing for a lead without a flow. */
@@ -23,12 +23,15 @@ export function panelFlowProperties(flow) {
23
23
  }
24
24
  /**
25
25
  * A lead's channel as the Service-Arb panel's `lead.created` carries it in
26
- * `properties.channel` — a closed set (`form` | `phone_inbound` | `callback`)
27
- * that refuses the whole event outside it, so the outbox would park the lead.
28
- * A site's lead is a form or a callback, each its own: `phone_inbound` is the
29
- * panel's, for the calls it records itself. `panelChannel(channelOf(lead))`.
26
+ * `properties.channel` — a closed set that refuses the whole event outside
27
+ * it, so the outbox would park the lead. `messenger`: whether the panel takes
28
+ * `whatsapp` and `telegram` (`LeadWebhookOptions.panelMessenger`); until it
29
+ * does, a messenger lead is a `form` there — the leads table keeps the truth.
30
+ * `ctx.channel` is this, already decided.
30
31
  */
31
- export function panelChannel(channel) {
32
+ export function panelChannel(channel, messenger = false) {
33
+ if (channel === "whatsapp" || channel === "telegram")
34
+ return messenger ? channel : "form";
32
35
  return channel;
33
36
  }
34
37
  /**
@@ -37,7 +40,7 @@ export function panelChannel(channel) {
37
40
  * so a broken setting fails startup rather than a lead.
38
41
  */
39
42
  export function leadWebhook(site, env, options) {
40
- const { buildBody, buildBookingBody, signing, panelSuspect = false, panelFlow = false, panelBooking = false, ...outboxOptions } = options;
43
+ const { buildBody, buildBookingBody, signing, panelSuspect = false, panelFlow = false, panelBooking = false, panelMessenger = false, ...outboxOptions } = options;
41
44
  if (!env.leadWebhook || !buildBody)
42
45
  return null;
43
46
  if (env.leadsDb.kind !== "sqlite")
@@ -50,6 +53,7 @@ export function leadWebhook(site, env, options) {
50
53
  panelSuspect,
51
54
  panelFlow,
52
55
  panelBooking,
56
+ panelMessenger,
53
57
  requestBooking(request) {
54
58
  if (!panelBooking || !buildBookingBody)
55
59
  return { kind: "off" };
@@ -81,6 +85,8 @@ export function leadWebhook(site, env, options) {
81
85
  idempotencyKey: randomUUID(),
82
86
  ...(suspect ? { suspect } : {}),
83
87
  ...(flow ? { flow } : {}),
88
+ channel: panelChannel(channelOf(lead), panelMessenger),
89
+ ...(panelMessenger && lead.messageRef ? { messageRef: lead.messageRef } : {}),
84
90
  };
85
91
  const body = JSON.stringify(buildBody(lead, ctx));
86
92
  if (typeof body !== "string")
@@ -5,16 +5,22 @@ const shownOf = (lead, needLabel) => ({ need: needLabel?.(lead.subject) ?? lead.
5
5
  /** A plain default; a brand passes `format` to write it in its own language. */
6
6
  export function defaultLeadMail(brand, lead, id, options = {}) {
7
7
  const place = lead.placeSlug ?? "unknown";
8
- // A callback request is a promise to ring the customer: it says so first.
9
- const callback = channelOf(lead) === "callback";
8
+ // A callback request is a promise to ring the customer: it says so first. A
9
+ // messenger lead says where the customer will write, and the reference the
10
+ // message carries — what the operator matches the chat by.
11
+ const channel = channelOf(lead);
12
+ const messenger = channel === "whatsapp" ? "WhatsApp" : channel === "telegram" ? "Telegram" : null;
13
+ const kind = channel === "callback" ? "call back" : messenger ? `${messenger} lead` : "new lead";
14
+ const flag = channel === "callback" ? " — CALL BACK" : messenger ? ` — ${messenger.toUpperCase()}` : "";
10
15
  return {
11
- subject: `${brand.name} — ${callback ? "call back" : "new lead"} (${place})`,
16
+ subject: `${brand.name} — ${kind} (${place})`,
12
17
  text: [
13
- `Lead #${id} — place ${place}${callback ? " — CALL BACK" : ""}${lead.spamVerdict ? ` — suspect (${lead.spamVerdict})` : ""}`,
18
+ `Lead #${id} — place ${place}${flag}${lead.spamVerdict ? ` — suspect (${lead.spamVerdict})` : ""}`,
14
19
  "",
20
+ ...(lead.messageRef ? [`Réf. : ${lead.messageRef}`] : []),
15
21
  `Subject : ${shownOf(lead, options.needLabel).need}`,
16
22
  `Locality : ${lead.locality}`,
17
- `Mobile : ${lead.mobile}`,
23
+ `Mobile : ${lead.mobile || (messenger ? `(none — answer on ${messenger})` : "")}`,
18
24
  ...Object.entries(lead.extras).map(([name, value]) => `${name} : ${value}`),
19
25
  ].join("\n"),
20
26
  };
@@ -9,5 +9,5 @@
9
9
  export { describeLandingContract, type LandingContractOptions } from "./landing-contract.js";
10
10
  export { servedPaths } from "./served-files.js";
11
11
  export { describeLeadStoreContract, type LeadStoreHarness } from "./lead-store-contract.js";
12
- export { leadRuleDisagreements, PHONE_MATRIX } from "./lead-rules.js";
12
+ export { leadRuleDisagreements, messengerRuleDisagreements, PHONE_MATRIX } from "./lead-rules.js";
13
13
  export { serviceAreaPlace, storefrontPlace, testLead } from "./fixtures.js";
@@ -9,5 +9,5 @@
9
9
  export { describeLandingContract } from "./landing-contract.js";
10
10
  export { servedPaths } from "./served-files.js";
11
11
  export { describeLeadStoreContract } from "./lead-store-contract.js";
12
- export { leadRuleDisagreements, PHONE_MATRIX } from "./lead-rules.js";
12
+ export { leadRuleDisagreements, messengerRuleDisagreements, PHONE_MATRIX } from "./lead-rules.js";
13
13
  export { serviceAreaPlace, storefrontPlace, testLead } from "./fixtures.js";
@@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest";
2
2
  import { isPublished, publicationGaps } from "../core/place/publication.js";
3
3
  import { createRouting, NON_PAGE_ROUTES, pointSuffixes, THANKS } from "../core/routing.js";
4
4
  import { openLaunchBlockers } from "../core/site.js";
5
- import { leadRuleDisagreements } from "./lead-rules.js";
5
+ import { leadRuleDisagreements, messengerRuleDisagreements } from "./lead-rules.js";
6
6
  import { servedPaths } from "./served-files.js";
7
7
  const SOURCE_LINE = /@source\s+["'][^"']*node_modules\/@evinvest\/kitstart\/dist["']/;
8
8
  const MATCHER = "/((?!_next/).*)";
@@ -48,6 +48,11 @@ export function describeLandingContract(site, options = {}) {
48
48
  it("refuses exactly the phone numbers the form blocks", () => {
49
49
  expect(leadRuleDisagreements(site.lead)).toEqual([]);
50
50
  });
51
+ // A messenger lead is posted as the visitor leaves for the chat: the
52
+ // message carries the rest, and a refusal would be shown to no one.
53
+ it("takes a messenger lead without a postcode or a phone", () => {
54
+ expect(messengerRuleDisagreements(site.lead)).toEqual([]);
55
+ });
51
56
  it("publishes nothing without a domain", () => {
52
57
  if (site.brand.domain !== null)
53
58
  return;
@@ -16,3 +16,11 @@ export declare const PHONE_MATRIX: readonly {
16
16
  * a postcode — so only the phone is judged. Empty is agreement.
17
17
  */
18
18
  export declare function leadRuleDisagreements(schema: LeadSchema<string>): string[];
19
+ /**
20
+ * Where a brand's form rule refuses a messenger lead it must take: one with
21
+ * the subject chosen and nothing else — no postcode, no phone — since the
22
+ * chat carries the rest and is how the business answers. And where it takes
23
+ * a typed number the form would block: a phone given to a messenger lead is
24
+ * still one the operator may call. Empty is agreement.
25
+ */
26
+ export declare function messengerRuleDisagreements(schema: LeadSchema<string>): string[];
@@ -1,4 +1,4 @@
1
- import { readCandidate, validateCandidate } from "../core/lead.js";
1
+ import { CHANNEL_FIELD, readCandidate, validateCandidate } from "../core/lead.js";
2
2
  import { phoneProblem } from "../core/phone.js";
3
3
  /**
4
4
  * Numbers people type, and whether each is a way to reach them — the
@@ -62,3 +62,32 @@ export function leadRuleDisagreements(schema) {
62
62
  }
63
63
  return out;
64
64
  }
65
+ /**
66
+ * Where a brand's form rule refuses a messenger lead it must take: one with
67
+ * the subject chosen and nothing else — no postcode, no phone — since the
68
+ * chat carries the rest and is how the business answers. And where it takes
69
+ * a typed number the form would block: a phone given to a messenger lead is
70
+ * still one the operator may call. Empty is agreement.
71
+ */
72
+ export function messengerRuleDisagreements(schema) {
73
+ const out = [];
74
+ for (const channel of ["whatsapp", "telegram"]) {
75
+ const bare = new FormData();
76
+ bare.set(schema.wire.subject, schema.subjects[0] ?? "");
77
+ bare.set(CHANNEL_FIELD, channel);
78
+ const refused = validateCandidate(schema, readCandidate(schema, bare, null));
79
+ if (refused)
80
+ out.push(`${channel}: a lead with no postcode and no phone is refused at ${refused.field} (${refused.why})`);
81
+ for (const { mobile } of PHONE_MATRIX) {
82
+ const form = new FormData();
83
+ form.set(schema.wire.subject, schema.subjects[0] ?? "");
84
+ form.set(CHANNEL_FIELD, channel);
85
+ form.set(schema.wire.mobile, mobile);
86
+ const blocked = phoneProblem(mobile) !== null;
87
+ const refused = validateCandidate(schema, readCandidate(schema, form, null)) !== null;
88
+ if (blocked !== refused)
89
+ out.push(`${channel} ${mobile}: the form ${blocked ? "blocks" : "takes"} it, the server ${refused ? "refuses" : "takes"} it`);
90
+ }
91
+ }
92
+ return out;
93
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@evinvest/kitstart",
3
- "version": "0.16.0",
3
+ "version": "0.18.0",
4
4
  "description": "Landing-site machinery for local-service brands: a site composition root, the Place/Presence/ServiceArea model with a publication gate, host/locale routing, schema.org and SEO builders, the lead funnel with its antispam, and analytics events. Pure core at '.', more subpaths per runtime.",
5
5
  "license": "BlueOak-1.0.0",
6
6
  "publishConfig": {
@@ -126,5 +126,8 @@
126
126
  "template/proxy.ts",
127
127
  "template/app/[locale]/[location]/layout.tsx",
128
128
  "dist/cli/kitstart-size.js"
129
- ]
129
+ ],
130
+ "dependencies": {
131
+ "qrcode-generator": "^2.0.4"
132
+ }
130
133
  }
@@ -1,6 +1,7 @@
1
1
  import { quoteRoute } from "@evinvest/kitstart/next";
2
2
  import { TEXT } from "@/entities/content";
3
3
  import { notifier, pricing, serverEnv } from "@/shared/config/env";
4
+ import { QA_COOKIE } from "@/shared/config/experiments";
4
5
  import { site } from "@/shared/config/site";
5
6
 
6
7
  /** The no-JS path: a plain form POST answered with a 303. */
@@ -10,6 +11,8 @@ export const POST = quoteRoute(site, {
10
11
  env: serverEnv,
11
12
  notifier,
12
13
  pricing,
14
+ // The layout's `AnalyticsBoundary` gets the same: a QA visit's lead says `forced: true`.
15
+ qaCookie: QA_COOKIE,
13
16
  unavailable: locale => {
14
17
  const t = TEXT[locale];
15
18
  const f = { place: site.brand.name, phone: site.brand.phone };
@@ -3,7 +3,7 @@
3
3
  v_flakes.url = "github:valeratrades/v_flakes?ref=v1.6";
4
4
  # The lib flake at the tag of the @evinvest/kitstart version in
5
5
  # package-lock.json — mkLanding refuses a mismatch.
6
- ev.url = "github:EV-invest/lib?ref=@evinvest/kitstart-v0.16.0";
6
+ ev.url = "github:EV-invest/lib?ref=@evinvest/kitstart-v0.18.0";
7
7
  ev.inputs.v_flakes.follows = "v_flakes";
8
8
  };
9
9
 
@@ -20,9 +20,9 @@
20
20
  "dependencies": {
21
21
  "@evinvest/analytics": "^0.4.0",
22
22
  "@evinvest/i18n": "^0.9.0",
23
- "@evinvest/kitstart": "^0.16.0",
23
+ "@evinvest/kitstart": "^0.18.0",
24
24
  "@evinvest/marketing": "^0.3.0",
25
- "@evinvest/uikit": "^0.26.0",
25
+ "@evinvest/uikit": "^0.27.0",
26
26
  "next": "16.3.6",
27
27
  "react": "19.3.0",
28
28
  "react-dom": "19.3.0",
@@ -48,6 +48,8 @@ test("a new visitor gets no menu and never fetches its panel; a test visit gets
48
48
 
49
49
  test("a variant's tap forces it by the URL; the reset drops the force and keeps the visit a test", async ({ page, context, baseURL }) => {
50
50
  await asTester(context, baseURL);
51
+ // Assigned `a`, as a brand's proxy assigns every running experiment: one without a cookie is off in the menu.
52
+ await context.addCookies([{ name: "ab_lead_form", value: "a", url: baseURL ?? "" }]);
51
53
  await page.goto("/fr?utm_source=qa");
52
54
  await chip(page).click();
53
55
  await panel(page).getByRole("button", { name: "Steps" }).click();