@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.
- package/README.md +210 -17
- package/dist/core/accept.d.ts +19 -1
- package/dist/core/accept.js +25 -2
- package/dist/core/analytics.d.ts +13 -3
- package/dist/core/analytics.js +17 -2
- package/dist/core/booking/index.d.ts +3 -0
- package/dist/core/booking/index.js +3 -0
- package/dist/core/booking/model.d.ts +106 -0
- package/dist/core/booking/model.js +66 -0
- package/dist/core/booking/url.d.ts +17 -0
- package/dist/core/booking/url.js +53 -0
- package/dist/core/booking/validate.d.ts +27 -0
- package/dist/core/booking/validate.js +219 -0
- package/dist/core/lead-capture-text.d.ts +26 -2
- package/dist/core/lead-capture-text.js +32 -2
- package/dist/core/lead.d.ts +33 -0
- package/dist/core/lead.js +43 -0
- package/dist/core/phone.d.ts +3 -1
- package/dist/core/phone.js +28 -2
- package/dist/core/place/live.d.ts +6 -2
- package/dist/core/place/live.js +21 -2
- package/dist/core/place/types.d.ts +6 -0
- package/dist/core/place/types.js +0 -13
- package/dist/core/routing.d.ts +3 -2
- package/dist/core/routing.js +4 -3
- package/dist/core/site.d.ts +8 -0
- package/dist/core/site.js +15 -0
- package/dist/index.d.ts +4 -3
- package/dist/index.js +4 -3
- package/dist/next/booking-route.d.ts +31 -0
- package/dist/next/booking-route.js +97 -0
- package/dist/next/confirm-route.d.ts +39 -0
- package/dist/next/confirm-route.js +118 -0
- package/dist/next/index.d.ts +2 -0
- package/dist/next/index.js +2 -0
- package/dist/next/quote-route.js +14 -5
- package/dist/react/AnalyticsBoundary.js +8 -4
- package/dist/react/LeadBooking.d.ts +35 -0
- package/dist/react/LeadBooking.js +47 -0
- package/dist/react/LeadBookingManual.d.ts +17 -0
- package/dist/react/LeadBookingManual.js +50 -0
- package/dist/react/LeadCapture.d.ts +28 -5
- package/dist/react/LeadCapture.js +22 -8
- package/dist/react/LeadCaptureFields.js +60 -21
- package/dist/react/LeadCapturePriced.d.ts +3 -4
- package/dist/react/LeadCapturePriced.js +4 -4
- package/dist/react/analytics-context.d.ts +7 -0
- package/dist/react/analytics-context.js +7 -0
- package/dist/react/booking-adapters.d.ts +58 -0
- package/dist/react/booking-adapters.js +104 -0
- package/dist/react/index.d.ts +5 -1
- package/dist/react/index.js +4 -1
- package/dist/react/use-booking.d.ts +20 -0
- package/dist/react/use-booking.js +32 -0
- package/dist/react/use-lead-error.d.ts +3 -7
- package/dist/react/use-lead-error.js +9 -6
- package/dist/react/use-lead-submit.d.ts +7 -1
- package/dist/react/use-lead-submit.js +13 -3
- package/dist/react/use-repriced.d.ts +18 -0
- package/dist/react/use-repriced.js +27 -0
- package/dist/server/experiments-declared.d.ts +106 -0
- package/dist/server/experiments-declared.js +124 -0
- package/dist/server/experiments-source.d.ts +49 -0
- package/dist/server/experiments-source.js +88 -0
- package/dist/server/index.d.ts +4 -2
- package/dist/server/index.js +2 -0
- package/dist/server/lead-webhook.d.ts +63 -0
- package/dist/server/lead-webhook.js +24 -1
- package/dist/server/notify.d.ts +20 -2
- package/dist/server/notify.js +6 -4
- package/dist/server/place-source.js +1 -1
- package/dist/server/webhook-outbox.d.ts +11 -1
- package/dist/server/webhook-outbox.js +32 -5
- package/dist/testing/lead-rules.js +15 -1
- package/package.json +1 -1
- package/template/app/quote/booking/route.ts +11 -0
- package/template/app/quote/confirm/route.ts +12 -0
- package/template/flake.nix +1 -1
- package/template/package.json +2 -2
- package/template/src/shared/config/places.ts +4 -0
- package/template/src/shared/config/site.ts +3 -0
- package/template/tests/e2e/flows.spec.ts +86 -9
- package/template/tests/e2e/mock-panel.mjs +18 -4
- 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
|
+
}
|
package/dist/react/index.d.ts
CHANGED
|
@@ -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 `
|
|
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";
|
package/dist/react/index.js
CHANGED
|
@@ -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 `
|
|
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
|
-
|
|
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 {
|
|
4
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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, ...
|
|
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
|
+
}
|