@evinvest/kitstart 0.10.0 → 0.12.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 (84) hide show
  1. package/README.md +210 -17
  2. package/dist/core/accept.d.ts +19 -1
  3. package/dist/core/accept.js +25 -2
  4. package/dist/core/analytics.d.ts +13 -3
  5. package/dist/core/analytics.js +17 -2
  6. package/dist/core/booking/index.d.ts +3 -0
  7. package/dist/core/booking/index.js +3 -0
  8. package/dist/core/booking/model.d.ts +106 -0
  9. package/dist/core/booking/model.js +66 -0
  10. package/dist/core/booking/url.d.ts +17 -0
  11. package/dist/core/booking/url.js +53 -0
  12. package/dist/core/booking/validate.d.ts +27 -0
  13. package/dist/core/booking/validate.js +219 -0
  14. package/dist/core/lead-capture-text.d.ts +26 -2
  15. package/dist/core/lead-capture-text.js +32 -2
  16. package/dist/core/lead.d.ts +33 -0
  17. package/dist/core/lead.js +43 -0
  18. package/dist/core/phone.d.ts +3 -1
  19. package/dist/core/phone.js +28 -2
  20. package/dist/core/place/live.d.ts +6 -2
  21. package/dist/core/place/live.js +21 -2
  22. package/dist/core/place/types.d.ts +6 -0
  23. package/dist/core/place/types.js +0 -13
  24. package/dist/core/routing.d.ts +3 -2
  25. package/dist/core/routing.js +4 -3
  26. package/dist/core/site.d.ts +8 -0
  27. package/dist/core/site.js +15 -0
  28. package/dist/index.d.ts +4 -3
  29. package/dist/index.js +4 -3
  30. package/dist/next/booking-route.d.ts +31 -0
  31. package/dist/next/booking-route.js +97 -0
  32. package/dist/next/confirm-route.d.ts +39 -0
  33. package/dist/next/confirm-route.js +118 -0
  34. package/dist/next/index.d.ts +2 -0
  35. package/dist/next/index.js +2 -0
  36. package/dist/next/quote-route.js +14 -5
  37. package/dist/react/AnalyticsBoundary.js +8 -4
  38. package/dist/react/LeadBooking.d.ts +35 -0
  39. package/dist/react/LeadBooking.js +47 -0
  40. package/dist/react/LeadBookingManual.d.ts +17 -0
  41. package/dist/react/LeadBookingManual.js +50 -0
  42. package/dist/react/LeadCapture.d.ts +28 -5
  43. package/dist/react/LeadCapture.js +22 -8
  44. package/dist/react/LeadCaptureFields.js +60 -21
  45. package/dist/react/LeadCapturePriced.d.ts +3 -4
  46. package/dist/react/LeadCapturePriced.js +4 -4
  47. package/dist/react/analytics-context.d.ts +7 -0
  48. package/dist/react/analytics-context.js +7 -0
  49. package/dist/react/booking-adapters.d.ts +58 -0
  50. package/dist/react/booking-adapters.js +104 -0
  51. package/dist/react/index.d.ts +5 -1
  52. package/dist/react/index.js +4 -1
  53. package/dist/react/use-booking.d.ts +20 -0
  54. package/dist/react/use-booking.js +32 -0
  55. package/dist/react/use-lead-error.d.ts +3 -7
  56. package/dist/react/use-lead-error.js +9 -6
  57. package/dist/react/use-lead-submit.d.ts +7 -1
  58. package/dist/react/use-lead-submit.js +13 -3
  59. package/dist/react/use-repriced.d.ts +18 -0
  60. package/dist/react/use-repriced.js +27 -0
  61. package/dist/server/experiments-declared.d.ts +106 -0
  62. package/dist/server/experiments-declared.js +124 -0
  63. package/dist/server/experiments-source.d.ts +49 -0
  64. package/dist/server/experiments-source.js +88 -0
  65. package/dist/server/index.d.ts +4 -2
  66. package/dist/server/index.js +2 -0
  67. package/dist/server/lead-webhook.d.ts +63 -0
  68. package/dist/server/lead-webhook.js +24 -1
  69. package/dist/server/notify.d.ts +20 -2
  70. package/dist/server/notify.js +6 -4
  71. package/dist/server/place-source.js +1 -1
  72. package/dist/server/webhook-outbox.d.ts +11 -1
  73. package/dist/server/webhook-outbox.js +32 -5
  74. package/dist/testing/lead-rules.js +15 -1
  75. package/package.json +1 -1
  76. package/template/app/quote/booking/route.ts +11 -0
  77. package/template/app/quote/confirm/route.ts +12 -0
  78. package/template/flake.nix +1 -1
  79. package/template/package.json +2 -2
  80. package/template/src/shared/config/places.ts +4 -0
  81. package/template/src/shared/config/site.ts +3 -0
  82. package/template/tests/e2e/flows.spec.ts +86 -9
  83. package/template/tests/e2e/mock-panel.mjs +18 -4
  84. package/template/tests/e2e/quote-form.spec.ts +2 -2
@@ -0,0 +1,104 @@
1
+ import { isPageProvider } from "../core/booking/model.js";
2
+ import { bookingHref } from "../core/booking/url.js";
3
+ /** The built-in choice a context carries, or `null` when it is not one. */
4
+ function choiceOf(config) {
5
+ if (config.provider === "manual")
6
+ return { provider: "manual" };
7
+ return isPageProvider(config.provider) && config.url ? { provider: config.provider, url: config.url } : null;
8
+ }
9
+ const hrefOf = (ctx) => {
10
+ const choice = choiceOf(ctx.config);
11
+ return choice && bookingHref(choice, { leadRef: ctx.leadRef, name: ctx.name, phone: ctx.phone });
12
+ };
13
+ /** `manual`: no page to open — the card promises the call and offers a preference. */
14
+ export const manualAdapter = { provider: "manual" };
15
+ /** `link`: the page with `ref=<leadRef>`. */
16
+ export const linkAdapter = { provider: "link", href: hrefOf };
17
+ /** `google_calendar`: the schedule as is — it takes no parameter, so the visitor types the same number there. */
18
+ export const googleCalendarAdapter = { provider: "google_calendar", href: hrefOf };
19
+ /** `cal_com`: the event with the name, the phone and `metadata[ref]`. */
20
+ export const calComAdapter = { provider: "cal_com", href: hrefOf, prefillsPhone: () => true };
21
+ const isObject = (v) => typeof v === "object" && v !== null;
22
+ function addScript(src) {
23
+ const el = document.createElement("script");
24
+ el.src = src;
25
+ el.async = true;
26
+ document.head.appendChild(el);
27
+ }
28
+ /** Cal.com's own queue stub, as its embed snippet installs it; the script is added on the first call. */
29
+ function calQueue(w, script) {
30
+ if (w.Cal)
31
+ return w.Cal;
32
+ const cal = (...args) => {
33
+ if (!cal.loaded) {
34
+ cal.ns = {};
35
+ addScript(script);
36
+ cal.loaded = true;
37
+ }
38
+ (cal.q ??= []).push(args);
39
+ };
40
+ w.Cal = cal;
41
+ return cal;
42
+ }
43
+ /** The hosted cloud serves its embed from `app.cal.com`; a self-hosted Cal.com from its own host. */
44
+ export function calComEmbedOrigin(url) {
45
+ const u = new URL(url);
46
+ return u.hostname === "cal.com" ? "https://app.cal.com" : u.origin;
47
+ }
48
+ /** Per `Cal` instance — one per page — so the listeners are registered once however often the modal opens. */
49
+ const calPages = new WeakMap();
50
+ function calPage(cal, origin) {
51
+ const known = calPages.get(cal);
52
+ if (known)
53
+ return known;
54
+ const page = { current: null, booked: new Set() };
55
+ calPages.set(cal, page);
56
+ const booked = (e) => {
57
+ const ctx = page.current;
58
+ // Cal.com may fire both versions of the event for one booking.
59
+ if (!ctx || page.booked.has(ctx.leadRef))
60
+ return;
61
+ page.booked.add(ctx.leadRef);
62
+ const detail = isObject(e) ? e.detail : undefined;
63
+ const data = isObject(detail) && isObject(detail.data) ? detail.data : undefined;
64
+ const startAt = data?.startTime ?? data?.date;
65
+ const endAt = data?.endTime;
66
+ ctx.onBooked?.({ ...(typeof startAt === "string" ? { startAt } : {}), ...(typeof endAt === "string" ? { endAt } : {}) });
67
+ };
68
+ cal("init", { origin });
69
+ cal("on", { action: "bookingSuccessful", callback: booked });
70
+ cal("on", { action: "bookingSuccessfulV2", callback: booked });
71
+ return page;
72
+ }
73
+ /**
74
+ * `cal_com` as Cal.com's modal on the page: its script loads only on the
75
+ * click, and its `bookingSuccessful` is `onBooked` — once per lead, however
76
+ * often the modal is opened and closed. Opt-in (`bookingEmbed`), and only
77
+ * once the visitor accepted the provider's cookies.
78
+ */
79
+ export const calComEmbedAdapter = {
80
+ provider: "cal_com",
81
+ prefillsPhone: () => true,
82
+ open: ctx => {
83
+ const href = hrefOf(ctx);
84
+ if (href === null)
85
+ return;
86
+ const u = new URL(href);
87
+ const origin = calComEmbedOrigin(href);
88
+ const cal = calQueue(window, `${origin}/embed/embed.js`);
89
+ calPage(cal, origin).current = ctx;
90
+ cal("modal", { calLink: u.pathname.slice(1), config: Object.fromEntries(u.searchParams) });
91
+ },
92
+ };
93
+ export const BOOKING_ADAPTERS = {
94
+ manual: manualAdapter,
95
+ link: linkAdapter,
96
+ google_calendar: googleCalendarAdapter,
97
+ cal_com: calComAdapter,
98
+ };
99
+ /** The embeds `bookingEmbed` turns on. */
100
+ export const BOOKING_EMBEDS = { cal_com: calComEmbedAdapter };
101
+ /** The built-ins with a brand's own over them, by provider. */
102
+ export function bookingAdapters(over = {}) {
103
+ return { ...BOOKING_ADAPTERS, ...over };
104
+ }
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * `@evinvest/kitstart/react` — the structural widgets every landing shares,
3
3
  * where behaviour matters more than look. Server components by default; only
4
- * `MapFacade`, `AnalyticsBoundary`, `FormSelect` and `LeadCapture` are client modules, each its own file
4
+ * `MapFacade`, `AnalyticsBoundary`, `FormSelect`, `LeadCapture` and `LeadBooking` are client modules, each its own file
5
5
  * with its own `"use client"`, so a page pays for what it renders.
6
6
  *
7
7
  * Styled with the kit's token roles only (`text-ink`, `border-border`,
@@ -18,6 +18,10 @@ export { Faq, type FaqPart, type FaqProps } from "./Faq.js";
18
18
  export { FormSelect, type FormSelectOption, type FormSelectPart, type FormSelectProps } from "./FormSelect.js";
19
19
  export { LangSwitch, withLang, type LangSwitchProps } from "./LangSwitch.js";
20
20
  export { LeadCapture, type LeadCapturePart, type LeadCaptureProps } from "./LeadCapture.js";
21
+ export { LeadBooking, type LeadBookingProps } from "./LeadBooking.js";
22
+ export type { BookingPart } from "./LeadBookingManual.js";
23
+ export { BOOKING_ADAPTERS, BOOKING_EMBEDS, bookingAdapters, calComAdapter, calComEmbedAdapter, calComEmbedOrigin, googleCalendarAdapter, linkAdapter, manualAdapter, type BookedSlot, type BookingAdapter, type BookingAdapters, type BookingContext, } from "./booking-adapters.js";
24
+ export { BOOKING_ACTION } from "./use-booking.js";
21
25
  export type { LeadSent } from "./use-lead-submit.js";
22
26
  export type { LeadCaptureLayout } from "./LeadCaptureNeed.js";
23
27
  export { MapFacade, type MapFacadePart, type MapFacadeProps } from "./MapFacade.js";
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * `@evinvest/kitstart/react` — the structural widgets every landing shares,
3
3
  * where behaviour matters more than look. Server components by default; only
4
- * `MapFacade`, `AnalyticsBoundary`, `FormSelect` and `LeadCapture` are client modules, each its own file
4
+ * `MapFacade`, `AnalyticsBoundary`, `FormSelect`, `LeadCapture` and `LeadBooking` are client modules, each its own file
5
5
  * with its own `"use client"`, so a page pays for what it renders.
6
6
  *
7
7
  * Styled with the kit's token roles only (`text-ink`, `border-border`,
@@ -18,6 +18,9 @@ export { Faq } from "./Faq.js";
18
18
  export { FormSelect } from "./FormSelect.js";
19
19
  export { LangSwitch, withLang } from "./LangSwitch.js";
20
20
  export { LeadCapture } from "./LeadCapture.js";
21
+ export { LeadBooking } from "./LeadBooking.js";
22
+ export { BOOKING_ADAPTERS, BOOKING_EMBEDS, bookingAdapters, calComAdapter, calComEmbedAdapter, calComEmbedOrigin, googleCalendarAdapter, linkAdapter, manualAdapter, } from "./booking-adapters.js";
23
+ export { BOOKING_ACTION } from "./use-booking.js";
21
24
  export { MapFacade } from "./MapFacade.js";
22
25
  export { PlaceDirectory } from "./PlaceDirectory.js";
23
26
  export { PHONE_INPUT_PROPS, QuoteFormShell } from "./QuoteFormShell.js";
@@ -0,0 +1,20 @@
1
+ import type { BookingRequestedProperties } from "../core/booking/model.js";
2
+ /** A request as the page posts it; a provider outside the closed set is the server's to refuse. */
3
+ export type PostedBooking = Omit<BookingRequestedProperties, "provider"> & {
4
+ provider: string;
5
+ };
6
+ /** Where `LeadCapture` posts a booking request: `bookingRoute`, beside the quote route. */
7
+ export declare const BOOKING_ACTION = "/quote/booking";
8
+ export interface BookingReport {
9
+ /** The visitor opened the booking (or sent a `manual` preference): the event, and `booking.requested@1` to the site. */
10
+ requested(properties: PostedBooking): Promise<boolean>;
11
+ /** The provider said the slot is booked. */
12
+ booked(): void;
13
+ }
14
+ /**
15
+ * The page's side of a booking: `lead_booking_open` / `lead_booking_done`
16
+ * (the provider only, never the URL), and the request to the site's
17
+ * `bookingRoute`, which the panel hears as `booking.requested@1`. The post is
18
+ * first-party, and the page stays: a provider's page opens in a new tab.
19
+ */
20
+ export declare function useBooking(provider: string, submission: string, formId: string, action?: string): BookingReport;
@@ -0,0 +1,32 @@
1
+ "use client";
2
+ import { useCallback, useMemo } from "react";
3
+ import { EVENTS } from "../core/analytics.js";
4
+ import { useAnalyticsSink } from "./analytics-context.js";
5
+ /** Where `LeadCapture` posts a booking request: `bookingRoute`, beside the quote route. */
6
+ export const BOOKING_ACTION = "/quote/booking";
7
+ /**
8
+ * The page's side of a booking: `lead_booking_open` / `lead_booking_done`
9
+ * (the provider only, never the URL), and the request to the site's
10
+ * `bookingRoute`, which the panel hears as `booking.requested@1`. The post is
11
+ * first-party, and the page stays: a provider's page opens in a new tab.
12
+ */
13
+ export function useBooking(provider, submission, formId, action = BOOKING_ACTION) {
14
+ const sink = useAnalyticsSink();
15
+ const props = useMemo(() => ({ provider, form_id: formId }), [provider, formId]);
16
+ const requested = useCallback(async (properties) => {
17
+ sink?.capture(EVENTS.bookingOpen, props);
18
+ try {
19
+ const res = await fetch(action, {
20
+ method: "POST",
21
+ headers: { "Content-Type": "application/json" },
22
+ body: JSON.stringify({ submission, ...properties }),
23
+ });
24
+ return res.ok;
25
+ }
26
+ catch {
27
+ return false;
28
+ }
29
+ }, [sink, props, action, submission]);
30
+ const booked = useCallback(() => sink?.capture(EVENTS.bookingDone, props), [sink, props]);
31
+ return { requested, booked };
32
+ }
@@ -1,11 +1,7 @@
1
1
  import { type RefObject } from "react";
2
- import { type LeadChannel } from "../core/lead.js";
2
+ import { type LeadChannel, type LeadError } from "../core/lead.js";
3
3
  import type { LeadCaptureText } from "../core/lead-capture-text.js";
4
- /** A refusal the server sent back: which of the card's forms, and the field it is about. */
5
- export interface LeadError {
6
- channel: LeadChannel;
7
- field: string;
8
- }
4
+ export type { LeadError };
9
5
  /** The fields `LeadCapture` draws itself, which show a refusal under themselves. */
10
6
  export declare const OWN_FIELDS: Readonly<Record<LeadChannel, readonly string[]>>;
11
7
  /** The id of the message a refusal of a field the card does not draw is shown in, above the submit. */
@@ -21,4 +17,4 @@ export declare function errorText(field: string, text: LeadCaptureText): string;
21
17
  * `aria-invalid` and pointed at the message above the submit. Fixing that
22
18
  * control clears it.
23
19
  */
24
- export declare function useLeadError(root: RefObject<HTMLElement | null>, id: string): [LeadError | null, (error: LeadError | null) => void];
20
+ export declare function useLeadError(root: RefObject<HTMLElement | null>, id: string, initial?: LeadError | null): [LeadError | null, (error: LeadError | null) => void];
@@ -1,7 +1,7 @@
1
1
  "use client";
2
2
  import { useEffect, useState } from "react";
3
- import { LEAD_ERROR_PARAM } from "../core/lead.js";
4
- const FIELD = /^[a-z][a-z0-9_]{0,31}$/;
3
+ import { PRICE_CHANGED } from "../core/accept.js";
4
+ import { LEAD_CARD_PARAM, LEAD_ERROR_FIELD, LEAD_ERROR_PARAM } from "../core/lead.js";
5
5
  /** The fields `LeadCapture` draws itself, which show a refusal under themselves. */
6
6
  export const OWN_FIELDS = { form: ["phone", "locality", "name"], callback: ["phone", "consent"] };
7
7
  /** The id of the message a refusal of a field the card does not draw is shown in, above the submit. */
@@ -31,8 +31,9 @@ function controlOf(form, field) {
31
31
  * `aria-invalid` and pointed at the message above the submit. Fixing that
32
32
  * control clears it.
33
33
  */
34
- export function useLeadError(root, id) {
35
- const [error, setError] = useState(null);
34
+ export function useLeadError(root, id, initial = null) {
35
+ // Drawn on the server too when the page read it from its query (`leadErrorOf`).
36
+ const [error, setError] = useState(initial);
36
37
  useEffect(() => {
37
38
  const url = new URL(window.location.href);
38
39
  const field = url.searchParams.get(LEAD_ERROR_PARAM);
@@ -40,8 +41,9 @@ export function useLeadError(root, id) {
40
41
  if (field === null || channel === null)
41
42
  return;
42
43
  url.searchParams.delete(LEAD_ERROR_PARAM);
44
+ url.searchParams.delete(LEAD_CARD_PARAM);
43
45
  window.history.replaceState(window.history.state, "", url);
44
- if (FIELD.test(field))
46
+ if (LEAD_ERROR_FIELD.test(field))
45
47
  setError({ channel, field });
46
48
  }, [id]);
47
49
  useEffect(() => {
@@ -57,7 +59,8 @@ export function useLeadError(root, id) {
57
59
  foreign?.setAttribute("aria-describedby", formMessageId(id, error.channel));
58
60
  (control ?? document.getElementById(formMessageId(id, error.channel)))?.focus();
59
61
  const fixed = (event) => {
60
- if (error.field === "form" || (event.target instanceof Element && control?.contains(event.target)))
62
+ // A refusal of the whole form, or of its price, is about every answer: any change answers it.
63
+ if (error.field === "form" || error.field === PRICE_CHANGED || (event.target instanceof Element && control?.contains(event.target)))
61
64
  setError(null);
62
65
  };
63
66
  form.addEventListener("input", fixed);
@@ -11,6 +11,11 @@ export interface LeadSent {
11
11
  lead?: string;
12
12
  /** The price the server took the lead at, for an `estimate` or `fixed` need. */
13
13
  cents?: number;
14
+ /**
15
+ * The form's submission id, with `lead` only: the secret a booking request
16
+ * (`bookingRoute`) proves the lead is this page's with.
17
+ */
18
+ submission?: string;
14
19
  }
15
20
  /** Leaving the page, behind a seam a test can stand in for (jsdom cannot navigate). */
16
21
  export declare const navigation: {
@@ -27,7 +32,8 @@ export type SendFailure = "network" | "timeout";
27
32
  export declare const SUBMIT_TIMEOUT_MS = 15000;
28
33
  export interface LeadSubmitHandlers {
29
34
  /** The route refused the lead: the field to fix. */
30
- onRefused: (channel: LeadChannel, field: string) => void;
35
+ /** `cents`: with `price_changed`, the server's fresh price. */
36
+ onRefused: (channel: LeadChannel, field: string, cents?: number) => void;
31
37
  /** No answer: the form stays as typed, with a retry. */
32
38
  onFailed: (channel: LeadChannel, failure: SendFailure) => void;
33
39
  }
@@ -1,7 +1,9 @@
1
1
  "use client";
2
2
  import { useRef, useState } from "react";
3
+ import { ANALYTICS_ID_FIELD } from "../core/accept.js";
3
4
  import { CHANNEL_FIELD, SUBMISSION_FIELD } from "../core/lead.js";
4
5
  import { THANKS } from "../core/routing.js";
6
+ import { useAnalyticsId } from "./analytics-context.js";
5
7
  /** Leaving the page, behind a seam a test can stand in for (jsdom cannot navigate). */
6
8
  export const navigation = { assign: (url) => window.location.assign(url) };
7
9
  /**
@@ -68,8 +70,9 @@ async function readAnswer(res) {
68
70
  ...(typeof cents === "number" && Number.isSafeInteger(cents) && cents >= 0 ? { cents } : {}),
69
71
  };
70
72
  }
73
+ // `cents`: with `price_changed`, the price the lead would be taken at now.
71
74
  if (ok === false && typeof field === "string")
72
- return { ok, field };
75
+ return { ok, field, ...(typeof cents === "number" && Number.isSafeInteger(cents) && cents >= 0 ? { cents } : {}) };
73
76
  return null;
74
77
  }
75
78
  if (res.ok && res.redirected) {
@@ -97,6 +100,7 @@ export function useLeadSubmit(done, fields, on) {
97
100
  const [failure, setFailure] = useState(null);
98
101
  const pending = useRef(false);
99
102
  const last = useRef(null);
103
+ const analyticsId = useAnalyticsId();
100
104
  const submit = (event) => {
101
105
  event.preventDefault();
102
106
  if (pending.current)
@@ -110,6 +114,10 @@ export function useLeadSubmit(done, fields, on) {
110
114
  for (const [key, value] of data)
111
115
  if (typeof value === "string")
112
116
  body.append(key, value);
117
+ // Not a field of the form: the submission id stamps what the visitor
118
+ // posted, and a plain post (no script) has no analytics to name anyway.
119
+ if (analyticsId)
120
+ body.set(ANALYTICS_ID_FIELD, analyticsId);
113
121
  const read = (field) => {
114
122
  const value = field === undefined ? null : data.get(field);
115
123
  return typeof value === "string" ? value.trim() : "";
@@ -136,13 +144,15 @@ export function useLeadSubmit(done, fields, on) {
136
144
  return fallBack();
137
145
  if (!answer.ok) {
138
146
  settle();
139
- return on.onRefused(lead.channel, answer.field);
147
+ return on.onRefused(lead.channel, answer.field, answer.cents);
140
148
  }
141
149
  // Still pending: the page is leaving, or the form is gone. The next
142
150
  // lead from this form (Back, another need) gets an id of its own.
143
151
  minted.delete(form);
152
+ const submission = data.get(SUBMISSION_FIELD);
153
+ const ref = answer.lead && typeof submission === "string" && submission !== "" ? { lead: answer.lead, submission } : answer.lead ? { lead: answer.lead } : {};
144
154
  if (typeof done === "function" ? done(lead.channel) : done)
145
- setSent({ ...lead, ...(answer.lead ? { lead: answer.lead } : {}), ...(answer.cents !== undefined ? { cents: answer.cents } : {}) });
155
+ setSent({ ...lead, ...ref, ...(answer.cents !== undefined ? { cents: answer.cents } : {}) });
146
156
  else
147
157
  navigation.assign(answer.location);
148
158
  }, () => {
@@ -0,0 +1,18 @@
1
+ import type { LeadCaptureFlowText } from "../core/lead-capture-text.js";
2
+ import type { Price } from "../core/pricing/model.js";
3
+ /**
4
+ * The price the server answered when the one on screen was stale
5
+ * (`price_changed`): shown in its place, and posted back as `shown_cents` —
6
+ * a resubmit is the visitor confirming it. Only while the answers are the
7
+ * ones it was refused for; a change and the page prices on its own again.
8
+ */
9
+ export declare function useRepriced(price: Price | null, need: string | undefined, text: LeadCaptureFlowText, locale: string): {
10
+ /** The price to show: the server's fresh one, else the page's own. */
11
+ price: Price | null;
12
+ /** What the form posts as `shown_cents`. */
13
+ shownCents: number | undefined;
14
+ /** The words for a `price_changed` refusal, with both prices when the page has them. */
15
+ message: () => string;
16
+ /** A refusal: remembers the fresh price when it is `price_changed`. */
17
+ refused(field: string, cents: number | undefined): void;
18
+ };
@@ -0,0 +1,27 @@
1
+ "use client";
2
+ import { useState } from "react";
3
+ import { PRICE_CHANGED } from "../core/accept.js";
4
+ import { fillText, formatCents } from "../core/lead-capture-format.js";
5
+ /**
6
+ * The price the server answered when the one on screen was stale
7
+ * (`price_changed`): shown in its place, and posted back as `shown_cents` —
8
+ * a resubmit is the visitor confirming it. Only while the answers are the
9
+ * ones it was refused for; a change and the page prices on its own again.
10
+ */
11
+ export function useRepriced(price, need, text, locale) {
12
+ const [repriced, setRepriced] = useState(null);
13
+ const current = repriced && price && repriced.shown === price.cents && repriced.need === need ? repriced : null;
14
+ return {
15
+ /** The price to show: the server's fresh one, else the page's own. */
16
+ price: current ? { cents: current.cents, breakdown: [] } : price,
17
+ /** What the form posts as `shown_cents`. */
18
+ shownCents: current?.cents ?? price?.cents,
19
+ /** The words for a `price_changed` refusal, with both prices when the page has them. */
20
+ message: () => current ? fillText(text.priceChanged, { price: formatCents(current.cents, locale), shown: formatCents(current.shown, locale) }) : text.priceChangedGeneric,
21
+ /** A refusal: remembers the fresh price when it is `price_changed`. */
22
+ refused(field, cents) {
23
+ if (field === PRICE_CHANGED && cents !== undefined && price)
24
+ setRepriced({ cents, shown: price.cents, need });
25
+ },
26
+ };
27
+ }
@@ -0,0 +1,106 @@
1
+ import "server-only";
2
+ import type { LeadWebhook } from "./lead-webhook.js";
3
+ /**
4
+ * One experiment as the code declares it — the shape of an
5
+ * `@evinvest/experiments` spec, so the brand's `as const` config is passed
6
+ * as is. `variants[0]` is the control.
7
+ */
8
+ export interface DeclaredExperiment {
9
+ readonly variants: readonly string[];
10
+ readonly weights: readonly number[];
11
+ readonly enabled?: boolean;
12
+ readonly holdout?: number;
13
+ }
14
+ export type ExperimentsDeclaration = Readonly<Record<string, DeclaredExperiment>>;
15
+ /** `ExperimentDeclaration` of the panel's `experiments.declared@1`. */
16
+ export interface ExperimentDeclarationV1 {
17
+ key: string;
18
+ variants: string[];
19
+ weights: number[];
20
+ enabled: boolean;
21
+ holdout?: number;
22
+ summary?: string;
23
+ }
24
+ /** `experiments.declared@1` as protojson: `sa.v1.Event` with `ExperimentsDeclaredV1` for properties. */
25
+ export interface ExperimentsDeclaredEvent {
26
+ id: string;
27
+ schema: "sa.funnel.v1";
28
+ type: "experiments.declared";
29
+ typeVersion: 1;
30
+ occurredAt: string;
31
+ source: {
32
+ kind: "site";
33
+ id: string;
34
+ };
35
+ subject: {
36
+ brandId: string;
37
+ };
38
+ properties: {
39
+ experiments: ExperimentDeclarationV1[];
40
+ };
41
+ }
42
+ /**
43
+ * Why the panel would refuse this experiment, or `null`. The panel judges the
44
+ * event whole, so one bad experiment would lose the brand's declaration:
45
+ * it is left out instead, and said so at start.
46
+ */
47
+ export declare function declarationProblem(key: string, spec: DeclaredExperiment, summary: string | undefined): string | null;
48
+ /** RFC 9562 UUIDv7: the panel refuses any other version for an event id. */
49
+ export declare function uuidV7(nowMs: number): string;
50
+ export interface DeclarationContext {
51
+ brandId: string;
52
+ /** The key id the batch is signed with: the panel refuses any other `source.id`. */
53
+ sourceId: string;
54
+ at: Date;
55
+ /** One line of hypothesis per key, at most 200 characters. */
56
+ summaries?: Readonly<Record<string, string>>;
57
+ }
58
+ /**
59
+ * The ingest body declaring the brand's experiments, and what was left out of
60
+ * it with why. A fresh UUIDv7 each call: every start is a declaration of its
61
+ * own, and the panel keeps the latest by `occurredAt`.
62
+ */
63
+ export declare function experimentsDeclaredBody(experiments: ExperimentsDeclaration, ctx: DeclarationContext): {
64
+ body: {
65
+ events: [ExperimentsDeclaredEvent];
66
+ };
67
+ skipped: {
68
+ key: string;
69
+ why: string;
70
+ }[];
71
+ };
72
+ /** What `declareExperiments` did: queued (`row`), or why not. */
73
+ export type DeclareOutcome = {
74
+ kind: "queued";
75
+ row: number;
76
+ skipped: {
77
+ key: string;
78
+ why: string;
79
+ }[];
80
+ } | {
81
+ kind: "off";
82
+ } | {
83
+ kind: "failed";
84
+ };
85
+ export interface DeclareOptions {
86
+ summaries?: Readonly<Record<string, string>>;
87
+ log?: Pick<Console, "warn" | "error">;
88
+ }
89
+ /**
90
+ * Tells the panel which experiments this build runs — for `instrumentation.ts`
91
+ * `register()`, once per start:
92
+ *
93
+ * ```ts
94
+ * export async function register() {
95
+ * if (process.env.NEXT_RUNTIME !== "nodejs") return;
96
+ * declareExperiments(webhook, experiments, { summaries: { hero: "A shorter form converts better" } });
97
+ * }
98
+ * ```
99
+ *
100
+ * Queued in the lead webhook's outbox, signed and retried like a lead, and
101
+ * sent at once. It never throws and never holds the start: a webhook that is
102
+ * off (no `LEAD_WEBHOOK_URL`) or cannot be built is logged and skipped — the
103
+ * panel keeps the brand's last declaration. An experiment the panel would
104
+ * refuse is left out, logged.
105
+ */
106
+ export declare function declareExperiments(webhook: () => LeadWebhook | null, experiments: ExperimentsDeclaration, options?: DeclareOptions): DeclareOutcome;
@@ -0,0 +1,124 @@
1
+ import "server-only";
2
+ import { randomBytes } from "node:crypto";
3
+ import { EXPERIMENT_SLUG } from "../core/analytics.js";
4
+ const KEY = /^[a-z0-9_]{1,64}$/;
5
+ const MAX_SUMMARY = 200;
6
+ /**
7
+ * Why the panel would refuse this experiment, or `null`. The panel judges the
8
+ * event whole, so one bad experiment would lose the brand's declaration:
9
+ * it is left out instead, and said so at start.
10
+ */
11
+ export function declarationProblem(key, spec, summary) {
12
+ if (!KEY.test(key))
13
+ return "the key is not [a-z0-9_]{1,64}";
14
+ if (spec.variants.length < 2)
15
+ return "fewer than two variants";
16
+ if (new Set(spec.variants).size !== spec.variants.length)
17
+ return "a variant is declared twice";
18
+ if (!spec.variants.every(v => EXPERIMENT_SLUG.test(v)))
19
+ return "a variant is not a slug ([a-z0-9_-]{1,32})";
20
+ if (spec.weights.length !== spec.variants.length)
21
+ return "not one weight per variant";
22
+ if (!spec.weights.every(w => Number.isFinite(w) && w >= 0))
23
+ return "a weight is negative or not a number";
24
+ if (spec.weights.reduce((sum, w) => sum + w, 0) <= 0)
25
+ return "the weights sum to zero";
26
+ if (spec.holdout !== undefined && !(Number.isFinite(spec.holdout) && spec.holdout >= 0 && spec.holdout < 1))
27
+ return "the holdout is not in [0, 1)";
28
+ if (summary !== undefined && summary.length > MAX_SUMMARY)
29
+ return `the summary is over ${MAX_SUMMARY} characters`;
30
+ return null;
31
+ }
32
+ /** RFC 9562 UUIDv7: the panel refuses any other version for an event id. */
33
+ export function uuidV7(nowMs) {
34
+ const bytes = randomBytes(16);
35
+ let ms = nowMs;
36
+ for (let i = 5; i >= 0; i--) {
37
+ bytes[i] = ms % 256;
38
+ ms = Math.floor(ms / 256);
39
+ }
40
+ bytes[6] = ((bytes[6] ?? 0) & 0x0f) | 0x70;
41
+ bytes[8] = ((bytes[8] ?? 0) & 0x3f) | 0x80;
42
+ const hex = bytes.toString("hex");
43
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
44
+ }
45
+ /**
46
+ * The ingest body declaring the brand's experiments, and what was left out of
47
+ * it with why. A fresh UUIDv7 each call: every start is a declaration of its
48
+ * own, and the panel keeps the latest by `occurredAt`.
49
+ */
50
+ export function experimentsDeclaredBody(experiments, ctx) {
51
+ const declared = [];
52
+ const skipped = [];
53
+ for (const [key, spec] of Object.entries(experiments)) {
54
+ const summary = ctx.summaries && Object.hasOwn(ctx.summaries, key) ? ctx.summaries[key] : undefined;
55
+ const why = declarationProblem(key, spec, summary);
56
+ if (why !== null) {
57
+ skipped.push({ key, why });
58
+ continue;
59
+ }
60
+ declared.push({
61
+ key,
62
+ variants: [...spec.variants],
63
+ weights: [...spec.weights],
64
+ enabled: spec.enabled ?? true,
65
+ ...(spec.holdout !== undefined ? { holdout: spec.holdout } : {}),
66
+ ...(summary !== undefined && summary !== "" ? { summary } : {}),
67
+ });
68
+ }
69
+ const event = {
70
+ id: uuidV7(ctx.at.getTime()),
71
+ schema: "sa.funnel.v1",
72
+ type: "experiments.declared",
73
+ typeVersion: 1,
74
+ occurredAt: ctx.at.toISOString(),
75
+ source: { kind: "site", id: ctx.sourceId },
76
+ subject: { brandId: ctx.brandId },
77
+ properties: { experiments: declared },
78
+ };
79
+ return { body: { events: [event] }, skipped };
80
+ }
81
+ /**
82
+ * Tells the panel which experiments this build runs — for `instrumentation.ts`
83
+ * `register()`, once per start:
84
+ *
85
+ * ```ts
86
+ * export async function register() {
87
+ * if (process.env.NEXT_RUNTIME !== "nodejs") return;
88
+ * declareExperiments(webhook, experiments, { summaries: { hero: "A shorter form converts better" } });
89
+ * }
90
+ * ```
91
+ *
92
+ * Queued in the lead webhook's outbox, signed and retried like a lead, and
93
+ * sent at once. It never throws and never holds the start: a webhook that is
94
+ * off (no `LEAD_WEBHOOK_URL`) or cannot be built is logged and skipped — the
95
+ * panel keeps the brand's last declaration. An experiment the panel would
96
+ * refuse is left out, logged.
97
+ */
98
+ export function declareExperiments(webhook, experiments, options = {}) {
99
+ const log = options.log ?? console;
100
+ let hook;
101
+ try {
102
+ hook = webhook();
103
+ }
104
+ catch (error) {
105
+ log.error("experiments: the lead webhook could not be built, nothing declared to the panel", error);
106
+ return { kind: "failed" };
107
+ }
108
+ if (!hook?.declareExperiments)
109
+ return { kind: "off" };
110
+ let outcome;
111
+ try {
112
+ outcome = hook.declareExperiments(experiments, options.summaries ?? {});
113
+ }
114
+ catch (error) {
115
+ log.error("experiments: the declaration could not be queued", error);
116
+ return { kind: "failed" };
117
+ }
118
+ if (outcome.kind === "queued") {
119
+ for (const { key, why } of outcome.skipped)
120
+ log.error(`experiments: ${key} is not declared to the panel: ${why}`);
121
+ hook.tick().catch((error) => log.error("experiments: sending the declaration failed; the outbox retries it", error));
122
+ }
123
+ return outcome;
124
+ }