@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 +68 -6
- package/dist/core/accept.d.ts +7 -0
- package/dist/core/accept.js +10 -2
- package/dist/core/analytics.d.ts +10 -2
- package/dist/core/analytics.js +11 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -2
- package/dist/react/AnalyticsBoundary.js +8 -4
- package/dist/react/LeadCapture.js +4 -2
- package/dist/react/LeadCaptureEstimate.d.ts +1 -1
- package/dist/react/LeadCaptureEstimate.js +1 -1
- package/dist/react/analytics-context.d.ts +7 -0
- package/dist/react/analytics-context.js +7 -0
- package/dist/react/use-lead-context.d.ts +7 -0
- package/dist/react/use-lead-context.js +15 -0
- package/dist/react/use-lead-submit.js +7 -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 +2 -0
- package/dist/server/index.js +2 -0
- package/dist/server/lead-webhook.d.ts +23 -9
- package/dist/server/lead-webhook.js +13 -8
- package/package.json +1 -1
- package/template/flake.nix +1 -1
- package/template/package.json +1 -1
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.
|
|
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
|
|
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
|
|
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
|
|
654
|
-
|
|
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
|
package/dist/core/accept.d.ts
CHANGED
|
@@ -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
|
package/dist/core/accept.js
CHANGED
|
@@ -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);
|
package/dist/core/analytics.d.ts
CHANGED
|
@@ -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;
|
package/dist/core/analytics.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/dist/server/index.d.ts
CHANGED
|
@@ -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";
|
package/dist/server/index.js
CHANGED
|
@@ -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
|
-
*
|
|
65
|
-
* `properties.channel`
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
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
|
|
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
|
-
*
|
|
25
|
-
* `properties.channel`
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
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
|
|
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.
|
|
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": {
|
package/template/flake.nix
CHANGED
|
@@ -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.
|
|
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
|
|
package/template/package.json
CHANGED