@evinvest/kitstart 0.11.0 → 0.13.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 CHANGED
@@ -45,7 +45,7 @@ brand's `flake.nix` is its own config plus one call — `template/flake.nix` is
45
45
  the whole of one:
46
46
 
47
47
  ```nix
48
- inputs.ev.url = "github:EV-invest/lib?ref=@evinvest/kitstart-v0.11.0"; # the version in package-lock.json
48
+ inputs.ev.url = "github:EV-invest/lib?ref=@evinvest/kitstart-v0.13.0"; # the version in package-lock.json
49
49
  inputs.ev.inputs.v_flakes.follows = "v_flakes";
50
50
 
51
51
  landing = ev.lib.mkLanding {
@@ -236,7 +236,7 @@ it is the plain POST to `/quote` it always was.
236
236
  | `bookingVariant` · `bookingEmbed` · `bookingAdapters` | the `booking_provider` variant that picks the place's provider · the providers' embeds (after cookie consent) rather than a new tab · the brand's adapters over the built-ins (from a client component) — see [Booking](#booking-manual-link-google_calendar-cal_com) |
237
237
  | `initialError` | `leadErrorOf(searchParams)` on a page that reads its query: the refusal a 303 brought back, drawn on the server by the card it names (`lead_card`) so the card says why without a script (see *Refusals*) |
238
238
  | `head`, `trust` | the brand's heading instead of the title; a slot beside the submit. Like `extras`, `done` and `booking`, any node, built on the server or not, and never asked for a `key`: each slot sits alone in a keyed fragment |
239
- | `className` · `classNames` | the root · its parts: `root`, `head`, `title`, `lede`, `form`, `contact`, `field`, `label`, `control` (every input, the need's select in both states, the callback's phone), `hint`, `error` (a refusal: under the field, or above the submit), `chips`, `chip`, `needs`, `need`, `summary`, `submit`, `trust`, `privacy`, `opening`, `others`, `channel`, `primary`, `callback`, `callbackSummary`, `callbackForm`, `callbackLede`, `callbackSubmit`, `consent`, `done`, and for the flows `estimate`, `estimateInput`, `estimateLegend`, `estimateOption`, `price`, `priceTotal`, `breakdown`, `priceNote`, `photos`, `priced`, `pricedPrice`, `pricedNote`, and for the booking `booking`, `bookingCta`, `bookingNote`, `prefer`, `preferOption`, `preferSubmit` |
239
+ | `className` · `classNames` | the root · its parts: `root`, `head`, `title`, `lede`, `form`, `contact`, `field`, `label`, `control` (every input, the need's select in both states, the callback's phone), `hint`, `error` (a refusal: under the field, or above the submit), `chips`, `chip`, `needs`, `need`, `summary`, `submit`, `trust`, `privacy`, `opening`, `others`, `channel`, `primary`, `callback`, `callbackSummary`, `callbackForm`, `callbackLede`, `callbackSubmit`, `consent`, `done`, and for the flows `estimate`, `estimateInput`, `estimateLegend`, `estimateGrid` (an input's answers: 2 columns, 3 from `sm`), `estimateOption` (an answer's tile), `price`, `priceTotal`, `breakdown`, `priceNote`, `photos`, `priced`, `pricedPrice`, `pricedNote`, and for the booking `booking`, `bookingCta`, `bookingNote`, `prefer`, `preferOption`, `preferSubmit` |
240
240
 
241
241
  - **Taps.** A need the page knows is not asked again, and a place serving one
242
242
  commune fills it: focus the phone, type, send — two taps. `qualify-first`
@@ -315,7 +315,10 @@ it is the plain POST to `/quote` it always was.
315
315
  never makes a second lead. While it runs the form's submit is disabled, `aria-busy` and
316
316
  says `sending`. No answer — no network, or none in 15 s
317
317
  (`SUBMIT_TIMEOUT_MS`) — is said in place with a retry that sends the same
318
- lead; the page is never left for the browser's error page.
318
+ lead; the page is never left for the browser's error page. While a post
319
+ runs or waits for its retry, the channels keep their order: the hours
320
+ turning (the place closing) would move the callback, and a moved form is a
321
+ new one — what was typed gone, the retry sent nowhere.
319
322
  - **Events** (through `AnalyticsBoundary`'s sink; none outside one):
320
323
  `lead_form_view` (half in view, once), `lead_form_start` (first focus),
321
324
  `lead_form_field_error {field, blocking}` (`blocking: true` the browser
@@ -374,7 +377,11 @@ export const POST = quoteRoute(site, { env: serverEnv, notifier, pricing, unavai
374
377
  need — is a `quote`. So a flow the brand has not switched on is never run
375
378
  by a posted field.
376
379
  - **The form.** An estimate's questions are a tile per answer (touch-sized
377
- radios posted as `estimate_<input>`), required once the script runs; the
380
+ radios posted as `estimate_<input>`), required once the script runs —
381
+ two columns, three from `sm`; a brand sets its own count with
382
+ `classNames={{ estimateGrid: "grid-cols-3 sm:grid-cols-4" }}` (a denser
383
+ grid, a shorter card), and a tile keeps its 44 px touch height in any
384
+ (`estimateOption` styles the tile itself); the
378
385
  price box (`aria-live`) shows the total and the lines that made it, or
379
386
  what is missing — never "from". A fixed need shows its price. Either
380
387
  submits as `bookSubmit` ("Réserver"). Without a script the answers still
@@ -650,8 +657,8 @@ export const POST = quoteRoute(site, { env: serverEnv, notifier, webhook, unavai
650
657
  loopback — the body carries PII. All checked at boot.
651
658
  - **The channel.** `channelOf(lead)` is `form` or `callback`. For the
652
659
  Service-Arb panel, map it through `panelChannel`: its `properties.channel`
653
- is a closed set and refuses the whole event outside it, so a callback goes
654
- as `form` until the panel accepts `callback` (one constant to flip).
660
+ is a closed set (`form` | `phone_inbound` | `callback`) that refuses the
661
+ whole event outside it, and a callback goes as `callback` (panel v0.3.0 on).
655
662
  - **Queued before the 303, sent after it.** The body is built once, from the
656
663
  lead and `ctx` (`leadId`, `brandId`, `locale`, `formId`, `at`, and a fresh
657
664
  `idempotencyKey` for the receiver to deduplicate by), and written to the
@@ -678,6 +685,13 @@ export const POST = quoteRoute(site, { env: serverEnv, notifier, webhook, unavai
678
685
  Every booking joins its lead by it (Cal.com's `metadata[ref]`, a link's
679
686
  `ref`, `booking.requested`'s `lead_ref`): **a brand must send this as the
680
687
  panel lead id** in `lead.created`, not an id it computes itself.
688
+ - **`ctx.analyticsId` — the visit, for PostHog.** The `distinct_id` of the
689
+ page's beacons (`AnalyticsBoundary` holds one per page, in memory), posted
690
+ by `LeadCapture`'s script as `analytics_id` and checked against
691
+ `[A-Za-z0-9._:-]{1,128}`. Write it as `lead.created`'s `analytics_id`
692
+ property so the panel's PostHog events of the lead join the visit — once
693
+ the panel accepts the property. Absent for a plain post, a page with no
694
+ analytics key, or an older page; never stored with the lead.
681
695
  - **Booking requests: `panelBooking`, off.** With `leadWebhook(…, {
682
696
  panelBooking: true, buildBookingBody })`, `bookingRoute` queues
683
697
  `booking.requested@1` through the same outbox, after the lead's
@@ -715,6 +729,54 @@ export const POST = quoteRoute(site, { env: serverEnv, notifier, webhook, unavai
715
729
  `WEBHOOK_TICK_MS` and a new process picks up what is due. Rows queued for a
716
730
  previous URL stay in place and are counted at open.
717
731
 
732
+ ## Experiments from the panel
733
+
734
+ The experiments live in code (`@evinvest/experiments`, one `as const` config);
735
+ the panel decides their weights and kill switch, and PostHog counts them.
736
+
737
+ **Overrides** (`createExperimentsSource`): `GET <base>/experiments`, the same
738
+ base as the place source, `{ "experiments": { "<key>": { enabled?, weights?,
739
+ holdout? } } }`. The proxy reads it on every request, so it answers from memory:
740
+ the first call waits for the panel (1.5 s timeout), later ones are served from
741
+ the cache, and past the 30 s TTL the stale answer is served while one refresh
742
+ runs behind it. An unreachable panel, a non-200 or a body of another shape is
743
+ logged and keeps the last good answer — a kill switch must not come back on
744
+ because the panel blinked — or `{}`, the config in code, when there was none
745
+ yet; either way the panel is not asked again before the TTL.
746
+
747
+ ```ts
748
+ // shared/config/env.ts
749
+ export const experimentOverrides = createExperimentsSource({ baseUrl: () => serverEnv().locationsApiUrl });
750
+ // proxy.ts — the same applied config wherever a variant is read
751
+ const live = applyOverrides(experiments, await experimentOverrides.overrides());
752
+ ```
753
+
754
+ Only types are checked here; whether a field fits the code (weights of the
755
+ declared length, holdout in `[0, 1)`) is `applyOverrides`' call.
756
+
757
+ **Declaration** (`declareExperiments`): at every start the landing tells the
758
+ panel which experiments this build runs — `experiments.declared@1`, one event
759
+ with every experiment's key, variants, weights, `enabled`, `holdout` and an
760
+ optional one-line `summary`, under a fresh UUIDv7. It goes through the lead
761
+ webhook's outbox, signed and retried like a lead (so it needs the same
762
+ `LEAD_WEBHOOK_URL` and sqlite lead store), and the panel keeps the latest by
763
+ `occurredAt`; an experiment missing from it is retired there.
764
+
765
+ ```ts
766
+ // instrumentation.ts
767
+ export async function register() {
768
+ if (process.env.NEXT_RUNTIME !== "nodejs") return;
769
+ declareExperiments(webhook, experiments, { summaries: { lead_layout: "Two steps beat one long form" } });
770
+ }
771
+ ```
772
+
773
+ It never throws and never holds the start: no webhook, one that cannot be
774
+ built, or a queue that refuses the row is logged and skipped — the panel keeps
775
+ the last declaration. An experiment the panel would refuse (key not
776
+ `[a-z0-9_]{1,64}`, fewer than two unique slug variants, weights not one per
777
+ variant, negative or summing to zero, holdout outside `[0, 1)`, summary over
778
+ 200 characters) is left out and logged, since the panel judges the event whole.
779
+
718
780
  ## The site
719
781
 
720
782
  ```ts
@@ -9,6 +9,12 @@ export declare const FORM_ID_FIELD = "form_id";
9
9
  /** The site's experiment assignment, posted so the submit counts in the right arm. */
10
10
  export declare const EXPERIMENT_FIELD = "experiment";
11
11
  export declare const VARIANT_FIELD = "variant";
12
+ /**
13
+ * The page's analytics id (`ANALYTICS_ID`), posted by the script only: the
14
+ * webhook's `ctx.analyticsId`, for `lead.created`'s `analytics_id`. Never
15
+ * stored with the lead and never a reason to refuse one.
16
+ */
17
+ export declare const ANALYTICS_ID_FIELD = "analytics_id";
12
18
  /**
13
19
  * The price the form showed, in cents — compared with the server's, never
14
20
  * stored as the price: a lead is taken only at the price the visitor saw.
@@ -73,6 +79,7 @@ export interface AcceptDeps {
73
79
  locale: string;
74
80
  formId: string;
75
81
  leadRef: string;
82
+ analyticsId?: string;
76
83
  }) => void;
77
84
  /**
78
85
  * The price list to price an `estimate` or `fixed` lead with
@@ -1,4 +1,4 @@
1
- import { experimentProps } from "./analytics.js";
1
+ import { ANALYTICS_ID, experimentProps } from "./analytics.js";
2
2
  import { HONEYPOT_FIELD, LEGACY_HONEYPOT_FIELDS, RENDERED_AT_FIELD, screen } from "./antispam.js";
3
3
  import { channelOf, MAX_FIELD, readCandidate, validateCandidate } from "./lead.js";
4
4
  import { flowOf, readEstimateInputs } from "./pricing/flow.js";
@@ -10,6 +10,12 @@ export const FORM_ID_FIELD = "form_id";
10
10
  /** The site's experiment assignment, posted so the submit counts in the right arm. */
11
11
  export const EXPERIMENT_FIELD = "experiment";
12
12
  export const VARIANT_FIELD = "variant";
13
+ /**
14
+ * The page's analytics id (`ANALYTICS_ID`), posted by the script only: the
15
+ * webhook's `ctx.analyticsId`, for `lead.created`'s `analytics_id`. Never
16
+ * stored with the lead and never a reason to refuse one.
17
+ */
18
+ export const ANALYTICS_ID_FIELD = "analytics_id";
13
19
  /**
14
20
  * The price the form showed, in cents — compared with the server's, never
15
21
  * stored as the price: a lead is taken only at the price the visitor saw.
@@ -161,11 +167,13 @@ async function accept(site, form, clientKey, deps) {
161
167
  // lead (or one from a page cached before the stamp existed) is still sent
162
168
  // on, flagged. The honeypot and the rate limit are the server's own
163
169
  // evidence, and those leads wait in the table for a reviewer.
170
+ const rawAnalyticsId = form.get(ANALYTICS_ID_FIELD);
171
+ const analyticsId = typeof rawAnalyticsId === "string" && ANALYTICS_ID.test(rawAnalyticsId) ? rawAnalyticsId : undefined;
164
172
  const queue = () => {
165
173
  if (!deps.enqueue)
166
174
  return;
167
175
  try {
168
- deps.enqueue(lead, id, { locale, formId, leadRef: ref });
176
+ deps.enqueue(lead, id, { locale, formId, leadRef: ref, ...(analyticsId ? { analyticsId } : {}) });
169
177
  }
170
178
  catch (error) {
171
179
  deps.log.error(`quote: lead ${id} is stored but could not be queued for its webhook`, error);
@@ -36,6 +36,12 @@ export type LeadStep = "need" | "contact";
36
36
  * the site's assignment, passed in; `field` is a field's role, never its value.
37
37
  */
38
38
  export declare const ALLOWED_PROPS: readonly ["brand_id", "location_id", "source", "device", "channel", "form_id", "experiment", "variant", "field", "step", "layout", "reason", "blocking", "need", "cents_bucket", "provider"];
39
+ /**
40
+ * The beacon's `distinct_id` as a lead may carry it to the panel
41
+ * (`lead.created`'s `analytics_id`), so PostHog can join the lead to the
42
+ * visit. The panel's own rule; anything else is dropped, never refused.
43
+ */
44
+ export declare const ANALYTICS_ID: RegExp;
39
45
  /** What an experiment's assignment looks like on the wire: short slugs, nothing a person typed. */
40
46
  export declare const EXPERIMENT_SLUG: RegExp;
41
47
  /** An experiment's assignment as event properties, or nothing when it is not a pair of slugs. */
@@ -62,6 +68,8 @@ export interface AnalyticsTarget {
62
68
  * Cookieless by construction: a beacon sink keeps its `distinct_id` in memory,
63
69
  * writes no cookie and no storage, and needs no consent banner. `sendBeacon`
64
70
  * because the events that matter most — a tap on `tel:` or `wa.me` — are
65
- * followed by the page handing the visitor to another app.
71
+ * followed by the page handing the visitor to another app. `distinctId`:
72
+ * the visitor's id, held by the caller so a lead can name it; a random one
73
+ * per sink without it.
66
74
  */
67
- export declare function analyticsSink(target: AnalyticsTarget, locationId: string | null): AnalyticsSink;
75
+ export declare function analyticsSink(target: AnalyticsTarget, locationId: string | null, distinctId?: string): AnalyticsSink;
@@ -58,6 +58,12 @@ export const ALLOWED_PROPS = [
58
58
  // The booking's provider, `manual` | `link` | `cal_com` — never its URL.
59
59
  "provider",
60
60
  ];
61
+ /**
62
+ * The beacon's `distinct_id` as a lead may carry it to the panel
63
+ * (`lead.created`'s `analytics_id`), so PostHog can join the lead to the
64
+ * visit. The panel's own rule; anything else is dropped, never refused.
65
+ */
66
+ export const ANALYTICS_ID = /^[A-Za-z0-9._:-]{1,128}$/;
61
67
  /** What an experiment's assignment looks like on the wire: short slugs, nothing a person typed. */
62
68
  export const EXPERIMENT_SLUG = /^[a-z0-9_-]{1,32}$/;
63
69
  /** An experiment's assignment as event properties, or nothing when it is not a pair of slugs. */
@@ -75,11 +81,14 @@ export function countsAsPageView(pathname) {
75
81
  * Cookieless by construction: a beacon sink keeps its `distinct_id` in memory,
76
82
  * writes no cookie and no storage, and needs no consent banner. `sendBeacon`
77
83
  * because the events that matter most — a tap on `tel:` or `wa.me` — are
78
- * followed by the page handing the visitor to another app.
84
+ * followed by the page handing the visitor to another app. `distinctId`:
85
+ * the visitor's id, held by the caller so a lead can name it; a random one
86
+ * per sink without it.
79
87
  */
80
- export function analyticsSink(target, locationId) {
88
+ export function analyticsSink(target, locationId, distinctId) {
81
89
  return createBeaconSink({
82
90
  key: target.key ?? undefined,
91
+ ...(distinctId !== undefined ? { distinctId } : {}),
83
92
  host: target.host,
84
93
  allowedProps: ALLOWED_PROPS,
85
94
  globalProps: locationId ? { brand_id: target.brandId, location_id: locationId } : { brand_id: target.brandId },
package/dist/index.d.ts CHANGED
@@ -13,10 +13,10 @@ export { flowTextOf, LEAD_CAPTURE_TEXT, type LeadCaptureFlowText, type LeadCaptu
13
13
  export { fillText, formatCents, openingText } from "./core/lead-capture-format.js";
14
14
  export { channelHref, resolveChannels, smsHref, type CaptureChannel, type ChannelFacts, type ChannelOptions, type ResolvedChannels, } from "./core/channels.js";
15
15
  export { checkTiming, HONEYPOT_FIELD, LEGACY_HONEYPOT_FIELDS, MIN_FILL_MS, RATE_LIMIT_MAX_KEYS, RateLimiter, RENDERED_AT_FIELD, screen, type Screening, } from "./core/antispam.js";
16
- export { createAcceptLead, EXPERIMENT_FIELD, FORM_ID_FIELD, leadRef, LOCALE_FIELD, LOCATION_FIELD, PRICE_CHANGED, SHOWN_CENTS_FIELD, VARIANT_FIELD, type AcceptDeps, type Outcome, type SubmitTags, } from "./core/accept.js";
16
+ export { createAcceptLead, ANALYTICS_ID_FIELD, EXPERIMENT_FIELD, FORM_ID_FIELD, leadRef, LOCALE_FIELD, LOCATION_FIELD, PRICE_CHANGED, SHOWN_CENTS_FIELD, VARIANT_FIELD, type AcceptDeps, type Outcome, type SubmitTags, } from "./core/accept.js";
17
17
  export { areaServedNodes, breadcrumbNode, businessId, businessNode, faqPageNode, offerNodes, organizationId, organizationNode, placeGraph, websiteId, type OfferInput, type PageGraphCopy, type QuestionAnswer, } from "./core/seo/ld.js";
18
18
  export { AI_CRAWLERS, ogImageUrl, robotsFor, sitemapFor, type RobotsFile, type RobotsRule, type SitemapEntry, } from "./core/seo/sitemap.js";
19
- export { ALLOWED_PROPS, analyticsSink, countsAsPageView, EVENTS, EXPERIMENT_SLUG, experimentProps, type AnalyticsTarget, type IntentChannel, type LeadField, type LeadStep, } from "./core/analytics.js";
19
+ export { ALLOWED_PROPS, ANALYTICS_ID, analyticsSink, countsAsPageView, EVENTS, EXPERIMENT_SLUG, experimentProps, type AnalyticsTarget, type IntentChannel, type LeadField, type LeadStep, } from "./core/analytics.js";
20
20
  export type { CallBarText, CopySlice, CoreText, PageMetaCopy, QuoteFormCopy, Said, StatusAction, StatusCopy, StatusScreenText, } from "./core/content.js";
21
21
  export { BOOKING_LIMITS, BOOKING_PROVIDERS, BOOKING_STATUSES, BookingConfigError, bookingConfigProblems, BOOKING_EXPERIMENT, bookingHref, bookingOf, bookingRequestedProperties, bookingRequestProblems, calComHostsProblems, DEFAULT_BOOKING, DEFAULT_CAL_COM_HOSTS, isBookingProvider, isLeadRef, isPageProvider, isPreferredPart, parseBookingConfig, parseBookingRequest, PREFERRED_PARTS, type BookingChoice, type BookingConfig, type BookingPrefill, type BookingProvider, type BookingRequest, type BookingRequestedProperties, type BookingRules, type BookingStatus, type OpenBookingConfig, type PageProvider, type PreferredPart, } from "./core/booking/index.js";
22
22
  export { brandStatusTarget, statusTarget, thanksChannel, thanksSuffix, type StatusTarget } from "./core/status.js";
package/dist/index.js CHANGED
@@ -13,10 +13,10 @@ export { flowTextOf, LEAD_CAPTURE_TEXT } from "./core/lead-capture-text.js";
13
13
  export { fillText, formatCents, openingText } from "./core/lead-capture-format.js";
14
14
  export { channelHref, resolveChannels, smsHref, } from "./core/channels.js";
15
15
  export { checkTiming, HONEYPOT_FIELD, LEGACY_HONEYPOT_FIELDS, MIN_FILL_MS, RATE_LIMIT_MAX_KEYS, RateLimiter, RENDERED_AT_FIELD, screen, } from "./core/antispam.js";
16
- export { createAcceptLead, EXPERIMENT_FIELD, FORM_ID_FIELD, leadRef, LOCALE_FIELD, LOCATION_FIELD, PRICE_CHANGED, SHOWN_CENTS_FIELD, VARIANT_FIELD, } from "./core/accept.js";
16
+ export { createAcceptLead, ANALYTICS_ID_FIELD, EXPERIMENT_FIELD, FORM_ID_FIELD, leadRef, LOCALE_FIELD, LOCATION_FIELD, PRICE_CHANGED, SHOWN_CENTS_FIELD, VARIANT_FIELD, } from "./core/accept.js";
17
17
  export { areaServedNodes, breadcrumbNode, businessId, businessNode, faqPageNode, offerNodes, organizationId, organizationNode, placeGraph, websiteId, } from "./core/seo/ld.js";
18
18
  export { AI_CRAWLERS, ogImageUrl, robotsFor, sitemapFor, } from "./core/seo/sitemap.js";
19
- export { ALLOWED_PROPS, analyticsSink, countsAsPageView, EVENTS, EXPERIMENT_SLUG, experimentProps, } from "./core/analytics.js";
19
+ export { ALLOWED_PROPS, ANALYTICS_ID, analyticsSink, countsAsPageView, EVENTS, EXPERIMENT_SLUG, experimentProps, } from "./core/analytics.js";
20
20
  export { BOOKING_LIMITS, BOOKING_PROVIDERS, BOOKING_STATUSES, BookingConfigError, bookingConfigProblems, BOOKING_EXPERIMENT, bookingHref, bookingOf, bookingRequestedProperties, bookingRequestProblems, calComHostsProblems, DEFAULT_BOOKING, DEFAULT_CAL_COM_HOSTS, isBookingProvider, isLeadRef, isPageProvider, isPreferredPart, parseBookingConfig, parseBookingRequest, PREFERRED_PARTS, } from "./core/booking/index.js";
21
21
  export { brandStatusTarget, statusTarget, thanksChannel, thanksSuffix } from "./core/status.js";
22
22
  // A landing needs these beside the machinery; re-exported by name so a brand
@@ -2,9 +2,10 @@
2
2
  import { jsx as _jsx } from "react/jsx-runtime";
3
3
  import { ContactLinkTracker } from "@evinvest/marketing/tracker";
4
4
  import { usePathname } from "next/navigation.js";
5
- import { useEffect, useMemo } from "react";
5
+ import { useEffect, useMemo, useState } from "react";
6
6
  import { analyticsSink, countsAsPageView, EVENTS, experimentProps } from "../core/analytics.js";
7
- import { AnalyticsSinkContext, experimentOf } from "./analytics-context.js";
7
+ import { AnalyticsIdContext, AnalyticsSinkContext, experimentOf } from "./analytics-context.js";
8
+ import { newSubmissionId } from "./use-lead-submit.js";
8
9
  /**
9
10
  * `data-intent` on a link or button marks a conversion intent that is not a
10
11
  * contact link — or one the tracker cannot classify (`sms:`).
@@ -35,7 +36,10 @@ function source() {
35
36
  export function AnalyticsBoundary({ target, placeSlug, children }) {
36
37
  // On the target's values: a server layout hands a fresh object every render.
37
38
  const { key, host, brandId } = target;
38
- const sink = useMemo(() => analyticsSink({ key, host, brandId }, placeSlug), [key, host, brandId, placeSlug]);
39
+ // One visitor for the boundary's life, in memory only: a sink rebuilt for
40
+ // another place keeps it, and a lead posted from the page can name it.
41
+ const [distinctId] = useState(newSubmissionId);
42
+ const sink = useMemo(() => analyticsSink({ key, host, brandId }, placeSlug, distinctId), [key, host, brandId, placeSlug, distinctId]);
39
43
  const pathname = usePathname();
40
44
  useEffect(() => {
41
45
  if (!countsAsPageView(pathname))
@@ -57,5 +61,5 @@ export function AnalyticsBoundary({ target, placeSlug, children }) {
57
61
  }, [sink]);
58
62
  // A link inside an experiment's island (`LeadCapture`) carries the
59
63
  // assignment on itself, which is what the tracker reads.
60
- return (_jsx(AnalyticsSinkContext.Provider, { value: sink, children: _jsx(ContactLinkTracker, { channels: ["phone", "whatsapp"], onContact: ({ channel, data }) => sink.capture(EVENTS.intent, { channel, ...experimentProps(data["experiment"], data["variant"]) }, { transport: "beacon" }), children: children }) }));
64
+ return (_jsx(AnalyticsIdContext.Provider, { value: key ? distinctId : null, children: _jsx(AnalyticsSinkContext.Provider, { value: sink, children: _jsx(ContactLinkTracker, { channels: ["phone", "whatsapp"], onContact: ({ channel, data }) => sink.capture(EVENTS.intent, { channel, ...experimentProps(data["experiment"], data["variant"]) }, { transport: "beacon" }), children: children }) }) }));
61
65
  }
@@ -17,7 +17,7 @@ import { EstimateInputs, PriceBox, useEstimate } from "./LeadCaptureEstimate.js"
17
17
  import { FormMessage, LocalityField, NameField, PhoneField } from "./LeadCaptureFields.js";
18
18
  import { NeedField } from "./LeadCaptureNeed.js";
19
19
  import { QuoteFormShell } from "./QuoteFormShell.js";
20
- import { useHydrated, useNeed, useNow, useOpenOnHash } from "./use-lead-context.js";
20
+ import { useHeld, useHydrated, useNeed, useNow, useOpenOnHash } from "./use-lead-context.js";
21
21
  import { errorText, formMessageId, OWN_FIELDS, useLeadError } from "./use-lead-error.js";
22
22
  import { useLeadEvents } from "./use-lead-events.js";
23
23
  import { FailureMessage, SubmitButton } from "./LeadCaptureSubmit.js";
@@ -74,7 +74,9 @@ export function LeadCapture(props) {
74
74
  const doneRef = useRef(null);
75
75
  // The form the focus was in is gone: the news takes it, and is read out.
76
76
  useEffect(() => doneRef.current?.focus(), [sent]);
77
- const resolved = resolveChannels({ ...contact, hours: place.hours }, { now: new Date(useNow(renderedAt)), timeZone: props.timeZone, prefer: props.prefer });
77
+ // Held while a lead is posting or failed: its form stays where it is, for the retry.
78
+ const now = useHeld(useNow(renderedAt), busy !== null || failure !== null);
79
+ const resolved = resolveChannels({ ...contact, hours: place.hours }, { now: new Date(now), timeZone: props.timeZone, prefer: props.prefer });
78
80
  const opening = openingText(resolved.nextOpening, text, locale);
79
81
  const needLabel = needs.find(n => n.value === need)?.label;
80
82
  const message = needLabel ? fillText(text.message, { need: needLabel }) : text.messageGeneric;
@@ -2,7 +2,7 @@ import type { LeadCaptureFlowText } from "../core/lead-capture-text.js";
2
2
  import { type LeadFlow } from "../core/pricing/flow.js";
3
3
  import type { Price, PricingModel } from "../core/pricing/model.js";
4
4
  import type { PartClassNames } from "./parts.js";
5
- export type EstimatePart = "estimate" | "estimateInput" | "estimateLegend" | "estimateOption" | "price" | "priceTotal" | "breakdown" | "priceNote";
5
+ export type EstimatePart = "estimate" | "estimateInput" | "estimateLegend" | "estimateGrid" | "estimateOption" | "price" | "priceTotal" | "breakdown" | "priceNote";
6
6
  /**
7
7
  * The live price of the need on screen: its answers, as the visitor taps
8
8
  * them, and the price `priceOf` makes of them — the same function the server
@@ -36,7 +36,7 @@ export function EstimateInputs(props) {
36
36
  if (pricing?.kind !== "estimate")
37
37
  return null;
38
38
  const inputs = pricing.inputs.map(id => model.inputs.find(i => i.id === id)).filter((i) => i !== undefined);
39
- return (_jsx("div", { className: cn("flex flex-col gap-5", c?.estimate), children: inputs.map(input => (_jsxs("fieldset", { className: cn("flex flex-col gap-2", c?.estimateInput), children: [_jsx("legend", { className: cn("mb-2 text-sm font-medium text-ink", c?.estimateLegend), children: labelOf(input.labels, locale) }), _jsx("div", { className: "grid grid-cols-2 gap-2 sm:grid-cols-3", children: input.options.map(option => (_jsxs("label", { className: "relative block cursor-pointer", children: [_jsx("input", { type: "radio", name: estimateField(input.id), value: option.id, required: props.required, checked: answers[input.id] === option.id, onChange: () => props.onAnswer(input.id, option.id), className: "peer absolute inset-0 opacity-0", "data-lead-field": estimateField(input.id) }), _jsx("span", { className: cn("flex h-full min-h-11 items-center rounded-[var(--control-radius)] border border-input px-3 py-2 text-ink transition-colors", "peer-checked:border-primary peer-checked:bg-hover peer-focus-visible:ring-[3px] peer-focus-visible:ring-ring/50", c?.estimateOption), children: labelOf(option.labels, locale) })] }, option.id))) })] }, input.id))) }));
39
+ return (_jsx("div", { className: cn("flex flex-col gap-5", c?.estimate), children: inputs.map(input => (_jsxs("fieldset", { className: cn("flex flex-col gap-2", c?.estimateInput), children: [_jsx("legend", { className: cn("mb-2 text-sm font-medium text-ink", c?.estimateLegend), children: labelOf(input.labels, locale) }), _jsx("div", { className: cn("grid grid-cols-2 gap-2 sm:grid-cols-3", c?.estimateGrid), children: input.options.map(option => (_jsxs("label", { className: "relative block cursor-pointer", children: [_jsx("input", { type: "radio", name: estimateField(input.id), value: option.id, required: props.required, checked: answers[input.id] === option.id, onChange: () => props.onAnswer(input.id, option.id), className: "peer absolute inset-0 opacity-0", "data-lead-field": estimateField(input.id) }), _jsx("span", { className: cn("flex h-full min-h-11 items-center rounded-[var(--control-radius)] border border-input px-3 py-2 text-ink transition-colors", "peer-checked:border-primary peer-checked:bg-hover peer-focus-visible:ring-[3px] peer-focus-visible:ring-ring/50", c?.estimateOption), children: labelOf(option.labels, locale) })] }, option.id))) })] }, input.id))) }));
40
40
  }
41
41
  function lineLabel(line, model, locale, text) {
42
42
  switch (line.kind) {
@@ -7,5 +7,12 @@ import { experimentProps } from "../core/analytics.js";
7
7
  */
8
8
  export declare const AnalyticsSinkContext: import("react").Context<AnalyticsSink | null>;
9
9
  export declare const useAnalyticsSink: () => AnalyticsSink | null;
10
+ /**
11
+ * The `distinct_id` the boundary's sink sends, for a lead to carry to the
12
+ * panel. `null` outside a boundary or with no key: no event names it, so a
13
+ * lead has nothing to join.
14
+ */
15
+ export declare const AnalyticsIdContext: import("react").Context<string | null>;
16
+ export declare const useAnalyticsId: () => string | null;
10
17
  /** `data-experiment` / `data-variant` on an element or its ancestors, for an intent's event. */
11
18
  export declare function experimentOf(el: Element | null): ReturnType<typeof experimentProps>;
@@ -8,6 +8,13 @@ import { experimentProps } from "../core/analytics.js";
8
8
  */
9
9
  export const AnalyticsSinkContext = createContext(null);
10
10
  export const useAnalyticsSink = () => useContext(AnalyticsSinkContext);
11
+ /**
12
+ * The `distinct_id` the boundary's sink sends, for a lead to carry to the
13
+ * panel. `null` outside a boundary or with no key: no event names it, so a
14
+ * lead has nothing to join.
15
+ */
16
+ export const AnalyticsIdContext = createContext(null);
17
+ export const useAnalyticsId = () => useContext(AnalyticsIdContext);
11
18
  /** `data-experiment` / `data-variant` on an element or its ancestors, for an intent's event. */
12
19
  export function experimentOf(el) {
13
20
  const host = el?.closest("[data-experiment]");
@@ -6,6 +6,13 @@ export declare const NEED_ATTR = "data-need";
6
6
  * A page cached for ten minutes is put right once it runs.
7
7
  */
8
8
  export declare function useNow(renderedAt: number): number;
9
+ /**
10
+ * `value`, held at what it was when `hold` turned on, until it turns off. The
11
+ * channel order runs on the clock, and a channel that moves is a form React
12
+ * draws anew: what was typed is gone, and the retry of a post still waiting
13
+ * on the server would go to a form no longer on the page.
14
+ */
15
+ export declare function useHeld<T>(value: T, hold: boolean): T;
9
16
  /** `false` on the server and while hydrating, `true` from the render after. */
10
17
  export declare const useHydrated: () => boolean;
11
18
  /**
@@ -17,6 +17,21 @@ const minuteNow = () => Math.floor(Date.now() / MINUTE) * MINUTE;
17
17
  export function useNow(renderedAt) {
18
18
  return useSyncExternalStore(subscribeMinute, minuteNow, () => renderedAt);
19
19
  }
20
+ /**
21
+ * `value`, held at what it was when `hold` turned on, until it turns off. The
22
+ * channel order runs on the clock, and a channel that moves is a form React
23
+ * draws anew: what was typed is gone, and the retry of a post still waiting
24
+ * on the server would go to a form no longer on the page.
25
+ */
26
+ export function useHeld(value, hold) {
27
+ const [held, setHeld] = useState(null);
28
+ // Adjusted during render, React's own pattern for state derived from a prop.
29
+ if (hold && held === null)
30
+ setHeld({ value });
31
+ if (!hold && held !== null)
32
+ setHeld(null);
33
+ return held ? held.value : value;
34
+ }
20
35
  const subscribeNothing = () => () => { };
21
36
  /** `false` on the server and while hydrating, `true` from the render after. */
22
37
  export const useHydrated = () => useSyncExternalStore(subscribeNothing, () => true, () => false);
@@ -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
  /**
@@ -98,6 +100,7 @@ export function useLeadSubmit(done, fields, on) {
98
100
  const [failure, setFailure] = useState(null);
99
101
  const pending = useRef(false);
100
102
  const last = useRef(null);
103
+ const analyticsId = useAnalyticsId();
101
104
  const submit = (event) => {
102
105
  event.preventDefault();
103
106
  if (pending.current)
@@ -111,6 +114,10 @@ export function useLeadSubmit(done, fields, on) {
111
114
  for (const [key, value] of data)
112
115
  if (typeof value === "string")
113
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);
114
121
  const read = (field) => {
115
122
  const value = field === undefined ? null : data.get(field);
116
123
  return typeof value === "string" ? value.trim() : "";
@@ -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
+ }
@@ -0,0 +1,49 @@
1
+ import "server-only";
2
+ /**
3
+ * An operator's override of one experiment, as the panel serves it — the
4
+ * shape `applyOverrides` of `@evinvest/experiments` takes. Only the types are
5
+ * checked here; whether a field fits the experiment in code (weights of the
6
+ * right length, a holdout in `[0, 1)`) is `applyOverrides`' call, because only
7
+ * it knows the code's variants.
8
+ */
9
+ export interface ExperimentOverride {
10
+ readonly enabled?: boolean;
11
+ readonly weights?: readonly number[];
12
+ readonly holdout?: number;
13
+ }
14
+ export type ExperimentOverrides = Readonly<Record<string, ExperimentOverride>>;
15
+ /**
16
+ * The operator's overrides of the brand's experiments: `GET
17
+ * <base>/experiments`, the panel's `{ "experiments": { "<key>": {…} } }`.
18
+ *
19
+ * Read by the proxy on every request, so the answer comes from memory: the
20
+ * first call waits for the panel (bounded by `timeoutMs`), every later one is
21
+ * served from the cache — past the TTL too, while one refresh runs behind it
22
+ * (stale-while-revalidate). The source never throws and never makes a page
23
+ * wait on the panel twice. A failed refresh — an unreachable panel, a
24
+ * non-200, a body that is not the panel's shape — keeps the last good answer:
25
+ * a kill switch must not turn itself back on because the panel blinked. With
26
+ * no good answer yet (or no base URL) it is `{}`, the config in code.
27
+ */
28
+ export interface ExperimentsSourceOptions {
29
+ /** The brand's base on the panel (`LOCATIONS_API_URL`), read when asked; `null` → no overrides. */
30
+ baseUrl: () => string | null;
31
+ /** How long an answer is fresh; 30 s. */
32
+ ttlMs?: number;
33
+ /** How long the panel is given; 1.5 s. The proxy is waiting on the first call. */
34
+ timeoutMs?: number;
35
+ now?: () => number;
36
+ log?: Pick<Console, "error">;
37
+ }
38
+ export interface ExperimentsSource {
39
+ /** The overrides to apply now; `{}` when there are none or the panel cannot say. Never throws. */
40
+ overrides(): Promise<ExperimentOverrides>;
41
+ }
42
+ export declare const EXPERIMENTS_TTL_MS = 30000;
43
+ /**
44
+ * The panel's answer as overrides, or `null` when it is not the panel's shape.
45
+ * A field of the wrong type is dropped, an entry that is not an object is
46
+ * skipped; the rest stands.
47
+ */
48
+ export declare function parseExperimentOverrides(body: unknown): ExperimentOverrides | null;
49
+ export declare function createExperimentsSource(options: ExperimentsSourceOptions): ExperimentsSource;
@@ -0,0 +1,88 @@
1
+ import "server-only";
2
+ export const EXPERIMENTS_TTL_MS = 30_000;
3
+ const TIMEOUT_MS = 1_500;
4
+ const NONE = Object.freeze({});
5
+ const isRecord = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
6
+ /**
7
+ * The panel's answer as overrides, or `null` when it is not the panel's shape.
8
+ * A field of the wrong type is dropped, an entry that is not an object is
9
+ * skipped; the rest stands.
10
+ */
11
+ export function parseExperimentOverrides(body) {
12
+ if (!isRecord(body) || !isRecord(body["experiments"]))
13
+ return null;
14
+ const out = {};
15
+ for (const [key, entry] of Object.entries(body["experiments"])) {
16
+ if (!isRecord(entry))
17
+ continue;
18
+ const { enabled, weights, holdout } = entry;
19
+ out[key] = {
20
+ ...(typeof enabled === "boolean" ? { enabled } : {}),
21
+ ...(Array.isArray(weights) && weights.every((w) => typeof w === "number") ? { weights } : {}),
22
+ ...(typeof holdout === "number" ? { holdout } : {}),
23
+ };
24
+ }
25
+ return out;
26
+ }
27
+ export function createExperimentsSource(options) {
28
+ const ttl = options.ttlMs ?? EXPERIMENTS_TTL_MS;
29
+ const timeoutMs = options.timeoutMs ?? TIMEOUT_MS;
30
+ const now = options.now ?? Date.now;
31
+ const log = options.log ?? console;
32
+ let cached = null;
33
+ let inflight = null;
34
+ /** The panel's overrides, or `null` when it could not say. */
35
+ async function load(base) {
36
+ let response;
37
+ try {
38
+ // `no-store`: the cache is this module's; Next's data cache would add a second TTL.
39
+ response = await fetch(`${base}/experiments`, { cache: "no-store", headers: { Accept: "application/json" }, signal: AbortSignal.timeout(timeoutMs) });
40
+ }
41
+ catch (cause) {
42
+ log.error("experiments: the panel is unreachable, keeping the last good overrides", cause);
43
+ return null;
44
+ }
45
+ if (response.status !== 200) {
46
+ log.error(`experiments: the panel answered ${response.status}, keeping the last good overrides`);
47
+ return null;
48
+ }
49
+ let body;
50
+ try {
51
+ body = await response.json();
52
+ }
53
+ catch (cause) {
54
+ log.error("experiments: the panel's body is not JSON, keeping the last good overrides", cause);
55
+ return null;
56
+ }
57
+ const parsed = parseExperimentOverrides(body);
58
+ if (parsed === null)
59
+ log.error("experiments: the panel's body is not { experiments: {…} }, keeping the last good overrides");
60
+ return parsed;
61
+ }
62
+ // One request at a time, however many visitors arrive while it runs.
63
+ function refresh(base) {
64
+ inflight ??= load(base)
65
+ .then(loaded => {
66
+ // A failure still restarts the clock, so a down panel is asked once per TTL.
67
+ const value = loaded ?? (cached?.base === base ? cached.value : NONE);
68
+ cached = { base, at: now(), value };
69
+ return value;
70
+ })
71
+ .finally(() => {
72
+ inflight = null;
73
+ });
74
+ return inflight;
75
+ }
76
+ return {
77
+ async overrides() {
78
+ const base = options.baseUrl();
79
+ if (!base)
80
+ return NONE;
81
+ if (cached?.base !== base)
82
+ return refresh(base);
83
+ if (now() - cached.at >= ttl)
84
+ void refresh(base);
85
+ return cached.value;
86
+ },
87
+ };
88
+ }
@@ -6,6 +6,8 @@
6
6
  import "server-only";
7
7
  export { createServerEnv, parseServerEnv, type EnvSource, type ServerEnv } from "./env.js";
8
8
  export { createPlaceSource, PLACE_REVALIDATE_SECONDS, PlaceSourceError, type PlaceSource, type PlaceSourceOptions } from "./place-source.js";
9
+ export { createExperimentsSource, EXPERIMENTS_TTL_MS, parseExperimentOverrides, type ExperimentOverride, type ExperimentOverrides, type ExperimentsSource, type ExperimentsSourceOptions, } from "./experiments-source.js";
10
+ export { declarationProblem, declareExperiments, experimentsDeclaredBody, type DeclarationContext, type DeclaredExperiment, type DeclareOptions, type DeclareOutcome, type ExperimentDeclarationV1, type ExperimentsDeclaration, type ExperimentsDeclaredEvent, } from "./experiments-declared.js";
9
11
  export { createPricingSource, type PricingSource, type PricingSourceOptions } from "./pricing-source.js";
10
12
  export { describeLeadDb, openLeadStore, parseLeadDb, type LeadDb } from "./lead-store.js";
11
13
  export { openSqliteLeadStore, type SqliteLeadStore } from "./lead-store-sqlite.js";
@@ -6,6 +6,8 @@
6
6
  import "server-only";
7
7
  export { createServerEnv, parseServerEnv } from "./env.js";
8
8
  export { createPlaceSource, PLACE_REVALIDATE_SECONDS, PlaceSourceError } from "./place-source.js";
9
+ export { createExperimentsSource, EXPERIMENTS_TTL_MS, parseExperimentOverrides, } from "./experiments-source.js";
10
+ export { declarationProblem, declareExperiments, experimentsDeclaredBody, } from "./experiments-declared.js";
9
11
  export { createPricingSource } from "./pricing-source.js";
10
12
  export { describeLeadDb, openLeadStore, parseLeadDb } from "./lead-store.js";
11
13
  export { openSqliteLeadStore } from "./lead-store-sqlite.js";
@@ -3,6 +3,7 @@ import type { BookingRequest } from "../core/booking/model.js";
3
3
  import { type Lead, type LeadChannel, type LeadSuspect } from "../core/lead.js";
4
4
  import type { LeadFlow } from "../core/pricing/flow.js";
5
5
  import type { ServerEnv } from "./env.js";
6
+ import { type DeclareOutcome, type ExperimentsDeclaration } from "./experiments-declared.js";
6
7
  import { type TickReport, type WebhookOutbox, type WebhookOutboxOptions } from "./webhook-outbox.js";
7
8
  import type { WebhookSigning } from "./webhook-signature.js";
8
9
  /** What the brand's body builder is told besides the lead. */
@@ -34,6 +35,14 @@ export interface LeadWebhookContext {
34
35
  * the properties. `panelFlowProperties` writes them as the panel reads them.
35
36
  */
36
37
  flow?: PanelFlow;
38
+ /**
39
+ * The visitor's analytics id as the page posted it (`ANALYTICS_ID_FIELD`),
40
+ * for `lead.created`'s `analytics_id` — so PostHog joins the lead to the
41
+ * visit. Absent from a page without the script, an older page, or one
42
+ * without analytics. Write it only once the panel's contract has the
43
+ * property: it refuses an unknown one, and the outbox would park the lead.
44
+ */
45
+ analyticsId?: string;
37
46
  }
38
47
  /** A lead's sale as the panel's `lead.created` may carry it. */
39
48
  export interface PanelFlow {
@@ -61,15 +70,13 @@ export interface PanelFlowProperties {
61
70
  }
62
71
  export declare function panelFlowProperties(flow: PanelFlow | undefined): PanelFlowProperties | Record<string, never>;
63
72
  /**
64
- * What the Service-Arb panel's `lead.created` calls a callback request. Its
65
- * `properties.channel` is a closed set (`form` | `phone_inbound`) and an event
66
- * outside it is refused whole — the outbox would park the lead — so until the
67
- * panel's `sa.funnel.v1` accepts `callback`, a callback travels as `form`.
68
- * TODO(panel): set to "callback" once the panel accepts it; nothing else changes.
73
+ * 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))`.
69
78
  */
70
- declare const PANEL_CALLBACK = "form";
71
- /** A lead's channel as the panel's `properties.channel` may carry it: `panelChannel(channelOf(lead))`. */
72
- export declare function panelChannel(channel: LeadChannel): "form" | typeof PANEL_CALLBACK;
79
+ export declare function panelChannel(channel: LeadChannel): "form" | "callback";
73
80
  /**
74
81
  * The receiver's body, built by the brand: the kit serialises and signs
75
82
  * whatever this returns and knows nothing of its shape. Called once, when the
@@ -108,6 +115,7 @@ export interface LeadWebhook {
108
115
  locale: string;
109
116
  formId: string;
110
117
  leadRef?: string;
118
+ analyticsId?: string;
111
119
  }): number;
112
120
  tick(): Promise<TickReport>;
113
121
  start(intervalMs?: number): void;
@@ -129,6 +137,13 @@ export interface LeadWebhook {
129
137
  * suspect held back); `off` without `panelBooking` or a `buildBookingBody`.
130
138
  */
131
139
  requestBooking?(request: BookingRequest): BookingQueued;
140
+ /**
141
+ * Queues `experiments.declared@1` — the experiments this build runs — in
142
+ * the same outbox, signed as a lead is. Experiments the panel would refuse
143
+ * are left out and listed in `skipped`. `declareExperiments` is the safe
144
+ * wrapper for a start.
145
+ */
146
+ declareExperiments?(experiments: ExperimentsDeclaration, summaries?: Readonly<Record<string, string>>): DeclareOutcome;
132
147
  readonly outbox: WebhookOutbox;
133
148
  }
134
149
  export interface LeadWebhookOptions extends WebhookOutboxOptions {
@@ -171,4 +186,3 @@ export declare function leadWebhook(site: {
171
186
  id: string;
172
187
  };
173
188
  }, env: Pick<ServerEnv, "leadsDb" | "leadWebhook">, options: LeadWebhookOptions): LeadWebhook | null;
174
- export {};
@@ -1,6 +1,7 @@
1
1
  import "server-only";
2
2
  import { randomUUID } from "node:crypto";
3
3
  import { suspectOf } from "../core/lead.js";
4
+ import { experimentsDeclaredBody } from "./experiments-declared.js";
4
5
  import { openWebhookOutbox } from "./webhook-outbox.js";
5
6
  /** The sale `ctx.flow` describes, or nothing for a lead without a flow. */
6
7
  export function panelFlowOf(lead) {
@@ -21,16 +22,14 @@ export function panelFlowProperties(flow) {
21
22
  };
22
23
  }
23
24
  /**
24
- * What the Service-Arb panel's `lead.created` calls a callback request. Its
25
- * `properties.channel` is a closed set (`form` | `phone_inbound`) and an event
26
- * outside it is refused whole — the outbox would park the lead — so until the
27
- * panel's `sa.funnel.v1` accepts `callback`, a callback travels as `form`.
28
- * TODO(panel): set to "callback" once the panel accepts it; nothing else changes.
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))`.
29
30
  */
30
- const PANEL_CALLBACK = "form";
31
- /** A lead's channel as the panel's `properties.channel` may carry it: `panelChannel(channelOf(lead))`. */
32
31
  export function panelChannel(channel) {
33
- return channel === "callback" ? PANEL_CALLBACK : "form";
32
+ return channel;
34
33
  }
35
34
  /**
36
35
  * The lead webhook, or `null` when it is off: no `LEAD_WEBHOOK_URL`, or no
@@ -45,6 +44,7 @@ export function leadWebhook(site, env, options) {
45
44
  throw new Error("LEAD_WEBHOOK_URL: the webhook outbox needs the sqlite lead store");
46
45
  const outbox = openWebhookOutbox(env.leadsDb.path, { ...env.leadWebhook, signing }, outboxOptions);
47
46
  const now = options.now ?? Date.now;
47
+ const sourceId = env.leadWebhook.keyId;
48
48
  return {
49
49
  outbox,
50
50
  panelSuspect,
@@ -65,6 +65,11 @@ export function leadWebhook(site, env, options) {
65
65
  const row = outbox.enqueueOnce(body, `booking:${request.leadRef}`, lead);
66
66
  return row === null ? { kind: "duplicate" } : { kind: "queued", row };
67
67
  },
68
+ declareExperiments(experiments, summaries) {
69
+ const { body, skipped } = experimentsDeclaredBody(experiments, { brandId: site.brand.id, sourceId, at: new Date(now()), ...(summaries ? { summaries } : {}) });
70
+ const row = outbox.enqueue(JSON.stringify(body), `experiments:${body.events[0].id}`);
71
+ return { kind: "queued", row, skipped };
72
+ },
68
73
  enqueue(lead, id, meta) {
69
74
  const suspect = panelSuspect ? suspectOf(lead) : undefined;
70
75
  const flow = panelFlow ? panelFlowOf(lead) : undefined;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@evinvest/kitstart",
3
- "version": "0.11.0",
3
+ "version": "0.13.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": {
@@ -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.11.0";
6
+ ev.url = "github:EV-invest/lib?ref=@evinvest/kitstart-v0.13.0";
7
7
  ev.inputs.v_flakes.follows = "v_flakes";
8
8
  };
9
9
 
@@ -20,7 +20,7 @@
20
20
  "dependencies": {
21
21
  "@evinvest/analytics": "^0.3.0",
22
22
  "@evinvest/i18n": "^0.9.0",
23
- "@evinvest/kitstart": "^0.11.0",
23
+ "@evinvest/kitstart": "^0.13.0",
24
24
  "@evinvest/marketing": "^0.3.0",
25
25
  "@evinvest/uikit": "^0.26.0",
26
26
  "next": "16.3.6",