@appright/funnelshift 0.16.0 → 0.17.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/dist/attribution.js +3 -2
- package/dist/bootstrap.d.ts +12 -0
- package/dist/cli/extract.d.ts +1 -0
- package/dist/cli/extract.js +29 -13
- package/dist/content.js +5 -1
- package/dist/experiments.js +8 -3
- package/dist/exposure.d.ts +6 -1
- package/dist/exposure.js +134 -14
- package/dist/functions.d.ts +33 -0
- package/dist/funnel.js +51 -9
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/nav.d.ts +3 -1
- package/dist/nav.js +2 -1
- package/dist/placement.d.ts +58 -0
- package/dist/placement.js +65 -0
- package/dist/server.d.ts +1 -0
- package/dist/server.js +6 -1
- package/package.json +1 -1
package/dist/attribution.js
CHANGED
|
@@ -144,8 +144,9 @@ function entryOf(loc, doc, project, funnel, mountPath) {
|
|
|
144
144
|
const fit = [];
|
|
145
145
|
for (const [k, v] of q) {
|
|
146
146
|
// __shot, __step, __build, __exp, __sig: the kit's and the router's own (a preview signature among them), not the link's;
|
|
147
|
-
// $dropped is the kit's count, never taken from a link
|
|
148
|
-
|
|
147
|
+
// $dropped is the kit's count, never taken from a link; link_token is the router's (a hosted paywall's signed placement link,
|
|
148
|
+
// kit 0.17.0), ?placement next to it stays
|
|
149
|
+
if (!k || k.startsWith('__') || k === DROPPED_KEY || k === 'link_token' || chars(k).length > MAX_PARAM_KEY || seen.has(k))
|
|
149
150
|
continue;
|
|
150
151
|
seen.add(k);
|
|
151
152
|
fit.push([k, cutTo(v, MAX_PARAM_VALUE)]);
|
package/dist/bootstrap.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { PlacementContent, URLPlacement } from './placement.js';
|
|
1
2
|
export type Env = 'prod' | 'preview' | 'dev';
|
|
2
3
|
export type Creator = {
|
|
3
4
|
code: string;
|
|
@@ -18,6 +19,11 @@ export type Bootstrap = {
|
|
|
18
19
|
fn_preview?: string;
|
|
19
20
|
creator?: Creator;
|
|
20
21
|
variants?: Record<string, string>;
|
|
22
|
+
experiments?: Record<string, {
|
|
23
|
+
type: 'build' | 'content';
|
|
24
|
+
phase: number;
|
|
25
|
+
keys: string[];
|
|
26
|
+
}>;
|
|
21
27
|
content_overrides?: Record<string, string | number | boolean>;
|
|
22
28
|
fn_arm?: string;
|
|
23
29
|
fn_base?: string;
|
|
@@ -28,6 +34,12 @@ export type Bootstrap = {
|
|
|
28
34
|
lead_step?: string;
|
|
29
35
|
checkout_step?: string;
|
|
30
36
|
};
|
|
37
|
+
placements?: Record<string, PlacementContent>;
|
|
38
|
+
placement_rejected?: {
|
|
39
|
+
placement: string;
|
|
40
|
+
reason: string;
|
|
41
|
+
};
|
|
42
|
+
placement_url?: URLPlacement;
|
|
31
43
|
};
|
|
32
44
|
export declare function readBootstrap(win?: Window): Bootstrap;
|
|
33
45
|
export declare function readCreator(win?: Window): Creator | null;
|
package/dist/cli/extract.d.ts
CHANGED
package/dist/cli/extract.js
CHANGED
|
@@ -58,18 +58,11 @@ export function extractContent(files) {
|
|
|
58
58
|
}
|
|
59
59
|
const unwrap = (n) => (n && ['TSAsExpression', 'TSSatisfiesExpression', 'TSNonNullExpression', 'ParenthesizedExpression'].includes(n.type) ? unwrap(n.expression) : n);
|
|
60
60
|
const isCall = (n, name) => n?.type === 'CallExpression' && n.callee?.type === 'Identifier' && n.callee.name === name;
|
|
61
|
-
|
|
62
|
-
//
|
|
63
|
-
//
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
if (fn?.type !== 'ArrowFunctionExpression' && fn?.type !== 'FunctionExpression')
|
|
67
|
-
return null;
|
|
68
|
-
if (fn.params.length === 0)
|
|
69
|
-
return [];
|
|
70
|
-
if (fn.params.length > 1)
|
|
71
|
-
return null;
|
|
72
|
-
let p = fn.params[0];
|
|
61
|
+
const PLACEMENT_KEY = /^[a-z][a-z0-9_]{0,63}$/;
|
|
62
|
+
// The keys a function reads of one of its parameters, when its code tells them: the parameter is only ever read as `a.key`, `a?.key`
|
|
63
|
+
// or `a['key']`, or is destructured with literal keys and no rest; an unused parameter reads none. Anything else (the parameter passed
|
|
64
|
+
// on or spread, a computed key) gives null.
|
|
65
|
+
function paramKeys(fn, p) {
|
|
73
66
|
if (p.type === 'AssignmentPattern')
|
|
74
67
|
p = p.left;
|
|
75
68
|
const keys = [];
|
|
@@ -122,7 +115,20 @@ export function whenKeys(fn) {
|
|
|
122
115
|
visit(fn.body, fn, 'body');
|
|
123
116
|
return ok ? keys : null;
|
|
124
117
|
}
|
|
125
|
-
//
|
|
118
|
+
// The keys a step's `when` reads, when its code tells them: the answers it reads (the first parameter) as their keys, the experiments
|
|
119
|
+
// it reads (the second, `(a, ab) => ab.quiz_len === 'long'`) as `ab.<experiment>`, in the order of the parameters. Anything else (a
|
|
120
|
+
// parameter passed on or spread, a computed key, a function declared elsewhere) gives null: the step is conditional, its keys unknown.
|
|
121
|
+
export function whenKeys(fn) {
|
|
122
|
+
fn = unwrap(fn);
|
|
123
|
+
if (fn?.type !== 'ArrowFunctionExpression' && fn?.type !== 'FunctionExpression')
|
|
124
|
+
return null;
|
|
125
|
+
if (fn.params.length > 2)
|
|
126
|
+
return null;
|
|
127
|
+
const answers = fn.params[0] ? paramKeys(fn, fn.params[0]) : [];
|
|
128
|
+
const arms = fn.params[1] ? paramKeys(fn, fn.params[1]) : [];
|
|
129
|
+
return answers && arms ? [...answers, ...arms.map(k => `ab.${k}`)] : null;
|
|
130
|
+
}
|
|
131
|
+
// Steps (id, optional role and placement, sensitive: true when the step is marked) in screen order, read statically from `defineFunnel({ steps: [...] })` (the manifest's `steps`, which the server uses to
|
|
126
132
|
// order the report and find the lead and paywall steps). An element is `defineStep({ id: '...' })` or an identifier declared as `const x = defineStep({...})` in the
|
|
127
133
|
// same file; anything else (imports, spreads, computed ids) is an error with file:line, never a guess.
|
|
128
134
|
export function extractSteps(files) {
|
|
@@ -199,6 +205,16 @@ export function extractSteps(files) {
|
|
|
199
205
|
}
|
|
200
206
|
out.role = role;
|
|
201
207
|
}
|
|
208
|
+
// placement (kit 0.17.0): the project's placement the step sells in; the server refuses a publish whose placement it does not
|
|
209
|
+
// have (placement_not_found), so a typo fails there and never makes a new placement.
|
|
210
|
+
const placementProp = option('placement');
|
|
211
|
+
if (placementProp) {
|
|
212
|
+
const key = literal(unwrap(placementProp.value));
|
|
213
|
+
if (key === null || !PLACEMENT_KEY.test(key)) {
|
|
214
|
+
throw new Error(`${at(placementProp)}: defineStep() placement must be a placement key as a string literal (${PLACEMENT_KEY.source})`);
|
|
215
|
+
}
|
|
216
|
+
out.placement = key;
|
|
217
|
+
}
|
|
202
218
|
// sensitive only marks the step in the admin; the manifest carries it so the server can say so (steps_order).
|
|
203
219
|
const sensProp = option('sensitive');
|
|
204
220
|
if (sensProp) {
|
package/dist/content.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { readKey } from './exposure.js';
|
|
1
2
|
let content = {};
|
|
2
3
|
let overrides = {};
|
|
3
4
|
// The reverse dictionary for $click names (clicks.ts): every text t() has produced on this page, whitespace collapsed → its key.
|
|
@@ -25,8 +26,10 @@ function valueOf(key) {
|
|
|
25
26
|
}
|
|
26
27
|
export const collapse = (s) => s.replace(/\s+/g, ' ').trim();
|
|
27
28
|
// t(key, default): the published text for key, else the default written in code. The build collects every
|
|
28
|
-
// t() call with its default into the manifest, so all texts are editable in the admin.
|
|
29
|
+
// t() call with its default into the manifest, so all texts are editable in the admin. A read of a key that an experiment's arm sets
|
|
30
|
+
// is that experiment's exposure (exposure.ts), whichever arm the visitor is in.
|
|
29
31
|
export function t(key, def, vars) {
|
|
32
|
+
readKey(key);
|
|
30
33
|
const v = valueOf(key);
|
|
31
34
|
const s = v === undefined ? def : String(v);
|
|
32
35
|
const out = vars ? s.replace(/\{\{(\w+)\}\}/g, (m, k) => (k in vars ? String(vars[k]) : m)) : s;
|
|
@@ -36,6 +39,7 @@ export function t(key, def, vars) {
|
|
|
36
39
|
// param(key, default): a number, string or boolean the admin (or an A/B arm) may change, of the default's type. A value that does
|
|
37
40
|
// not read as that type ("abc" for a number) gives the default.
|
|
38
41
|
export function param(key, def) {
|
|
42
|
+
readKey(key);
|
|
39
43
|
const v = valueOf(key);
|
|
40
44
|
if (v === undefined)
|
|
41
45
|
return def;
|
package/dist/experiments.js
CHANGED
|
@@ -1,12 +1,17 @@
|
|
|
1
1
|
// A/B in the funnel's code (kit 0.9.0, spec «A/B» §3): the visitor's arm, as the router assigned it (bootstrap.variants).
|
|
2
2
|
import { useMemo } from 'react';
|
|
3
3
|
import { readBootstrap } from './bootstrap.js';
|
|
4
|
-
|
|
4
|
+
import { readArm } from './exposure.js';
|
|
5
|
+
// The arm key of experiment `key` for this visitor, or null: no such experiment running, vite dev, an old router. Reading the arm
|
|
6
|
+
// is the first read of the experiment's value: it sends experiment_exposed (exposure.ts), except for the rule, which is no experiment.
|
|
5
7
|
export function experiment(key) {
|
|
6
8
|
const v = readBootstrap().variants;
|
|
7
9
|
if (!v || typeof v !== 'object' || !Object.prototype.hasOwnProperty.call(v, key))
|
|
8
10
|
return null;
|
|
9
|
-
|
|
11
|
+
if (typeof v[key] !== 'string')
|
|
12
|
+
return null;
|
|
13
|
+
readArm(key);
|
|
14
|
+
return v[key];
|
|
10
15
|
}
|
|
11
16
|
// The same in a component; the bootstrap does not change while the page lives.
|
|
12
17
|
export function useExperiment(key) {
|
|
@@ -14,7 +19,7 @@ export function useExperiment(key) {
|
|
|
14
19
|
}
|
|
15
20
|
// The targeting rule the router matched for this visitor (v2 track D: «Правила» in the admin), or null: no rule matched, vite dev, an
|
|
16
21
|
// old router. Rules decide the build and the t()/param() values before any experiment does; this is for code that wants to know which
|
|
17
|
-
// one it got. It is the `rule` key of the variants, so experiment('rule') answers the same, but a rule is no experiment: no
|
|
22
|
+
// one it got. It is the `rule` key of the variants, so experiment('rule') answers the same, but a rule is no experiment: no experiment events.
|
|
18
23
|
export function rule() {
|
|
19
24
|
return experiment('rule');
|
|
20
25
|
}
|
package/dist/exposure.d.ts
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
1
|
type Track = (name: string, props?: Record<string, unknown>) => void;
|
|
2
2
|
export declare const RULE_KEY = "rule";
|
|
3
|
-
export
|
|
3
|
+
export type AB = Readonly<Record<string, string | undefined>>;
|
|
4
|
+
export declare function readKey(key: string): void;
|
|
5
|
+
export declare function readArm(experiment: string): void;
|
|
6
|
+
export declare function currentAB(): AB;
|
|
7
|
+
export declare function startExposure(funnel: string, track: Track): void;
|
|
8
|
+
export declare function clearExposure(): void;
|
|
4
9
|
export {};
|
package/dist/exposure.js
CHANGED
|
@@ -1,16 +1,112 @@
|
|
|
1
|
-
|
|
2
|
-
// once per session. The variants key `rule` is the targeting rule the router matched, not an experiment: it has no $exposure: sessionStorage af_exposed:<funnel> keeps the session id (the tracker's, visit.session) and the arms already sent.
|
|
3
|
-
// A new session (30 min idle) or another arm sends it again. Blocked storage: once per page load.
|
|
1
|
+
import { readPlacement, readURLPlacement } from './placement.js';
|
|
4
2
|
import { visit } from './server.js';
|
|
5
3
|
// The variants key of the matched rule (v2 track D): it rides on every event as a variant, but a rule is no test and has no exposure.
|
|
6
4
|
export const RULE_KEY = 'rule';
|
|
7
|
-
|
|
8
|
-
|
|
5
|
+
// The bootstrap if the page has one: not in Node (the landing's prerender at build time) and not without the router (vite dev).
|
|
6
|
+
const boot = () => (typeof window === 'undefined' ? null : window.__FUNNEL__ ?? null);
|
|
7
|
+
const isObj = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
|
|
8
|
+
// The visitor's funnel experiments: { <experiment>: <arm> } without the rule and without junk.
|
|
9
|
+
function armsOf(b) {
|
|
10
|
+
const out = {};
|
|
11
|
+
if (!isObj(b.variants))
|
|
12
|
+
return out;
|
|
13
|
+
for (const [experiment, arm] of Object.entries(b.variants))
|
|
14
|
+
if (experiment !== RULE_KEY && typeof arm === 'string' && arm)
|
|
15
|
+
out[experiment] = arm;
|
|
16
|
+
return out;
|
|
17
|
+
}
|
|
18
|
+
function metaOf(b, experiment) {
|
|
19
|
+
const m = isObj(b.experiments) && Object.prototype.hasOwnProperty.call(b.experiments, experiment) ? b.experiments[experiment] : null;
|
|
20
|
+
if (!isObj(m) || (m.type !== 'build' && m.type !== 'content') || !Array.isArray(m.keys))
|
|
21
|
+
return null;
|
|
22
|
+
return { type: m.type, phase: Number.isInteger(m.phase) ? m.phase : 1, keys: m.keys.filter((k) => typeof k === 'string') };
|
|
23
|
+
}
|
|
24
|
+
const funnelProps = (b, experiment, arm) => ({
|
|
25
|
+
space: 'funnel', experiment, phase: String(metaOf(b, experiment)?.phase ?? 1), arm, content_version: b.build_id, subject: `visitor:${b.visitor_id}`,
|
|
26
|
+
});
|
|
27
|
+
// ---- exposure: the first read of a value ----
|
|
28
|
+
const exposed = new Set();
|
|
29
|
+
// Experiments read before the funnel started (a t() at module level, the first render): sent when it starts.
|
|
30
|
+
const pending = new Set();
|
|
31
|
+
let sink = null;
|
|
32
|
+
function expose(experiment) {
|
|
33
|
+
if (exposed.has(experiment) || pending.has(experiment))
|
|
34
|
+
return;
|
|
35
|
+
const b = boot();
|
|
36
|
+
const arm = b ? armsOf(b)[experiment] : undefined;
|
|
37
|
+
if (!b || !arm)
|
|
9
38
|
return;
|
|
10
|
-
|
|
11
|
-
|
|
39
|
+
if (!sink) {
|
|
40
|
+
pending.add(experiment);
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
exposed.add(experiment);
|
|
44
|
+
sink('experiment_exposed', funnelProps(b, experiment, arm));
|
|
45
|
+
}
|
|
46
|
+
// The experiments of the bootstrap by the t()/param() keys that read them; rebuilt when the page's bootstrap is another object.
|
|
47
|
+
let indexed = null;
|
|
48
|
+
function experimentsOfKey(b, key) {
|
|
49
|
+
if (indexed?.b !== b) {
|
|
50
|
+
const byKey = new Map();
|
|
51
|
+
const arms = armsOf(b);
|
|
52
|
+
for (const experiment of Object.keys(arms)) {
|
|
53
|
+
const m = metaOf(b, experiment);
|
|
54
|
+
if (m?.type === 'content')
|
|
55
|
+
for (const k of m.keys)
|
|
56
|
+
byKey.set(k, [...(byKey.get(k) ?? []), experiment]);
|
|
57
|
+
}
|
|
58
|
+
indexed = { b, byKey };
|
|
59
|
+
}
|
|
60
|
+
return indexed.byKey.get(key);
|
|
61
|
+
}
|
|
62
|
+
// A t() or param() of `key` was read (content.ts): that exposes the experiments that have the key among the values their arms set.
|
|
63
|
+
export function readKey(key) {
|
|
64
|
+
const b = boot();
|
|
65
|
+
if (!b?.experiments)
|
|
12
66
|
return;
|
|
13
|
-
const key
|
|
67
|
+
for (const experiment of experimentsOfKey(b, key) ?? [])
|
|
68
|
+
expose(experiment);
|
|
69
|
+
}
|
|
70
|
+
// The arm of `experiment` was read (experiments.ts): experiment(), useExperiment().
|
|
71
|
+
export function readArm(experiment) {
|
|
72
|
+
expose(experiment);
|
|
73
|
+
}
|
|
74
|
+
let frozen = null;
|
|
75
|
+
// The AB of this page, one object per page load (per bootstrap): a getter per experiment, which counts as the first read.
|
|
76
|
+
export function currentAB() {
|
|
77
|
+
const b = boot();
|
|
78
|
+
if (frozen && frozen.variants === (b?.variants ?? undefined))
|
|
79
|
+
return frozen.ab;
|
|
80
|
+
const ab = Object.create(null);
|
|
81
|
+
for (const [experiment, arm] of Object.entries(b ? armsOf(b) : {})) {
|
|
82
|
+
Object.defineProperty(ab, experiment, { enumerable: true, get: () => { expose(experiment); return arm; } });
|
|
83
|
+
}
|
|
84
|
+
frozen = { variants: b?.variants ?? undefined, ab: Object.freeze(ab) };
|
|
85
|
+
return frozen.ab;
|
|
86
|
+
}
|
|
87
|
+
// experiment_assigned for what the router assigned, once per session and arm.
|
|
88
|
+
function assign(funnel, b, track) {
|
|
89
|
+
const events = [];
|
|
90
|
+
for (const [experiment, arm] of Object.entries(armsOf(b)))
|
|
91
|
+
events.push({ key: `funnel:${experiment}`, arm, props: funnelProps(b, experiment, arm) });
|
|
92
|
+
if (isObj(b.placements)) {
|
|
93
|
+
for (const entry of Object.values(b.placements)) {
|
|
94
|
+
const c = readPlacement(entry);
|
|
95
|
+
if (!c || !c.experiment || !c.arm)
|
|
96
|
+
continue;
|
|
97
|
+
events.push({ key: `placement:${c.experiment}`, arm: c.arm, props: { space: 'placement', experiment: c.experiment, phase: String(c.phase), arm: c.arm,
|
|
98
|
+
content_version: `${c.paywall}@${c.version}`, subject: c.subject || `visitor:${b.visitor_id}`, placement: c.placement } });
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
// a url placement's arm: this funnel at the build served
|
|
102
|
+
const u = readURLPlacement();
|
|
103
|
+
if (u?.experiment) {
|
|
104
|
+
events.push({ key: `placement:${u.experiment}`, arm: u.arm, props: { space: 'placement', experiment: u.experiment, phase: String(u.phase), arm: u.arm,
|
|
105
|
+
content_version: b.build_id, subject: u.subject || `visitor:${b.visitor_id}`, placement: u.placement } });
|
|
106
|
+
}
|
|
107
|
+
if (!events.length)
|
|
108
|
+
return;
|
|
109
|
+
const store = `af_assigned:${funnel}`;
|
|
14
110
|
let sid = '';
|
|
15
111
|
try {
|
|
16
112
|
sid = visit.session();
|
|
@@ -18,18 +114,42 @@ export function exposeVariants(funnel, variants, track) {
|
|
|
18
114
|
catch { /* no session */ }
|
|
19
115
|
let seen = {};
|
|
20
116
|
try {
|
|
21
|
-
seen = JSON.parse(sessionStorage.getItem(
|
|
117
|
+
seen = JSON.parse(sessionStorage.getItem(store) ?? '{}') ?? {};
|
|
22
118
|
}
|
|
23
119
|
catch { /* blocked or junk */ }
|
|
24
120
|
const arms = seen.sid === sid && seen.arms && typeof seen.arms === 'object' ? { ...seen.arms } : {};
|
|
25
|
-
for (const
|
|
26
|
-
if (arms[
|
|
121
|
+
for (const e of events) {
|
|
122
|
+
if (arms[e.key] === e.arm)
|
|
27
123
|
continue;
|
|
28
|
-
track('
|
|
29
|
-
arms[
|
|
124
|
+
track('experiment_assigned', e.props);
|
|
125
|
+
arms[e.key] = e.arm;
|
|
30
126
|
}
|
|
31
127
|
try {
|
|
32
|
-
sessionStorage.setItem(
|
|
128
|
+
sessionStorage.setItem(store, JSON.stringify({ sid, arms }));
|
|
33
129
|
}
|
|
34
130
|
catch { /* storage unavailable */ }
|
|
35
131
|
}
|
|
132
|
+
// The funnel started: send what the router assigned, expose the build experiments (all of them for an older router, which does not
|
|
133
|
+
// say where an experiment is exposed), then what was read before this moment.
|
|
134
|
+
export function startExposure(funnel, track) {
|
|
135
|
+
const b = boot();
|
|
136
|
+
if (!b)
|
|
137
|
+
return;
|
|
138
|
+
assign(funnel, b, track);
|
|
139
|
+
sink = track;
|
|
140
|
+
for (const experiment of Object.keys(armsOf(b)))
|
|
141
|
+
if (!b.experiments || metaOf(b, experiment)?.type === 'build')
|
|
142
|
+
expose(experiment);
|
|
143
|
+
for (const experiment of [...pending]) {
|
|
144
|
+
pending.delete(experiment);
|
|
145
|
+
expose(experiment);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
// For tests: forget what this page load has exposed and where it sends.
|
|
149
|
+
export function clearExposure() {
|
|
150
|
+
exposed.clear();
|
|
151
|
+
pending.clear();
|
|
152
|
+
sink = null;
|
|
153
|
+
indexed = null;
|
|
154
|
+
frozen = null;
|
|
155
|
+
}
|
package/dist/functions.d.ts
CHANGED
|
@@ -25,9 +25,42 @@ export type FunctionCtx = {
|
|
|
25
25
|
variants: Readonly<Record<string, string>>;
|
|
26
26
|
params: Readonly<Record<string, string | number | boolean>>;
|
|
27
27
|
rule: string;
|
|
28
|
+
placement: Readonly<FunctionPlacement> | null;
|
|
28
29
|
reply(status: number, body?: unknown): Reply;
|
|
29
30
|
track(name: string, props?: Record<string, unknown>): void;
|
|
30
31
|
};
|
|
32
|
+
export type FunctionPlacement = {
|
|
33
|
+
placement: string;
|
|
34
|
+
paywall: string;
|
|
35
|
+
version: number;
|
|
36
|
+
experiment: string;
|
|
37
|
+
arm: string;
|
|
38
|
+
layout: string;
|
|
39
|
+
config: Readonly<Record<string, unknown>>;
|
|
40
|
+
products: ReadonlyArray<{
|
|
41
|
+
key: string;
|
|
42
|
+
name: string;
|
|
43
|
+
kind: string;
|
|
44
|
+
period: string;
|
|
45
|
+
access_levels: readonly string[];
|
|
46
|
+
phases: readonly {
|
|
47
|
+
kind: string;
|
|
48
|
+
period?: string;
|
|
49
|
+
}[];
|
|
50
|
+
price: {
|
|
51
|
+
price_id: string;
|
|
52
|
+
currency: string;
|
|
53
|
+
amount: number;
|
|
54
|
+
interval: string;
|
|
55
|
+
} | null;
|
|
56
|
+
refs: readonly {
|
|
57
|
+
provider: string;
|
|
58
|
+
ref_kind: string;
|
|
59
|
+
ref: string;
|
|
60
|
+
}[];
|
|
61
|
+
}>;
|
|
62
|
+
fs_ctx: string;
|
|
63
|
+
};
|
|
31
64
|
export type FunctionHandler<P = any, R = unknown> = (payload: P, ctx: FunctionCtx) => R | Reply | Promise<R | Reply>;
|
|
32
65
|
export type FunctionsModule<F extends Record<string, FunctionHandler> = Record<string, FunctionHandler>> = {
|
|
33
66
|
readonly __funnelFunctions: 1;
|
package/dist/funnel.js
CHANGED
|
@@ -15,12 +15,13 @@ import { installErrors } from './errors.js';
|
|
|
15
15
|
import { batchContext } from './context.js';
|
|
16
16
|
import { emailHash } from './sha256.js';
|
|
17
17
|
import { visit } from './server.js';
|
|
18
|
-
import {
|
|
18
|
+
import { currentAB, startExposure } from './exposure.js';
|
|
19
19
|
import { attribution } from './attribution.js';
|
|
20
20
|
import { encodeAnswer, packAnswers } from './answers.js';
|
|
21
21
|
import { LabelCtx, sameValue } from './offer.js';
|
|
22
22
|
import { funnelSlug, setHandoff, visitorId } from './handoff.js';
|
|
23
23
|
import { installPixel, metaCookies, readPixel, revokePixel, setPixel } from './pixel.js';
|
|
24
|
+
import { layoutOf, PaywallCtx, placementOf, readURLPlacement } from './placement.js';
|
|
24
25
|
export const defineStep = (s) => s;
|
|
25
26
|
export const defineFunnel = (f) => f;
|
|
26
27
|
export const FunnelCtx = createContext(null);
|
|
@@ -146,8 +147,14 @@ function ShotView({ funnel, stepId }) {
|
|
|
146
147
|
stepId: step.id, index, total: funnel.steps.length, progress: 0, shot: true, stopTracking: async () => { }, creator: null,
|
|
147
148
|
visitorId: '', funnelId: funnelSlug() || funnel.id, userId: '', knownUser: false };
|
|
148
149
|
}, [funnel, step, index]);
|
|
149
|
-
const
|
|
150
|
-
|
|
150
|
+
const content = useMemo(() => placementOf(step), [step]);
|
|
151
|
+
const pw = useMemo(() => (content ? { ...content, view_id: '' } : null), [content]);
|
|
152
|
+
const C = stepComponent(step, content);
|
|
153
|
+
return _jsx(FunnelCtx.Provider, { value: ctx, children: _jsx(PaywallCtx.Provider, { value: pw, children: _jsx("div", { className: "fk-shot", children: _jsx(C, {}) }) }) });
|
|
154
|
+
}
|
|
155
|
+
// The component a step draws: the layout its placement serves when this build registered it (defineLayout), else its own.
|
|
156
|
+
function stepComponent(step, content) {
|
|
157
|
+
return (content && layoutOf(content.layout)) || step.component;
|
|
151
158
|
}
|
|
152
159
|
// root: the element the funnel is drawn into. With it, every click on a button, link or option inside goes as $click (clicks.ts);
|
|
153
160
|
// mount() passes its element. A thumbnail never tracks clicks.
|
|
@@ -190,6 +197,15 @@ function LiveFunnel({ funnel, track, root, browserBack }) {
|
|
|
190
197
|
// server() sends the step on screen. Set while rendering, like onScreen: a step that calls its function in its own effect runs
|
|
191
198
|
// that effect before any effect of the funnel would.
|
|
192
199
|
visit.step = step.id;
|
|
200
|
+
// The placement of the step (placement.ts) and this view of it: a new id each time the step comes on screen (moving on to it,
|
|
201
|
+
// «Назад» to it), the same while it stays (a re-render). server() sends the placement's signed ctx, like the step.
|
|
202
|
+
const content = useMemo(() => placementOf(step), [step]);
|
|
203
|
+
const view = useRef({ step: '', id: '' });
|
|
204
|
+
if (view.current.step !== step.id)
|
|
205
|
+
view.current = { step: step.id, id: content ? crypto.randomUUID() : '' };
|
|
206
|
+
const viewId = view.current.id;
|
|
207
|
+
const pw = useMemo(() => (content ? { ...content, view_id: viewId } : null), [content, viewId]);
|
|
208
|
+
visit.placementCtx = content?.ctx ?? '';
|
|
193
209
|
// A layout effect, not a plain one: the listener is on the root before the first paint, so a click right after the screen
|
|
194
210
|
// appears is never missed (a plain effect can run after the browser has already delivered that click).
|
|
195
211
|
useLayoutEffect(() => {
|
|
@@ -244,9 +260,32 @@ function LiveFunnel({ funnel, track, root, browserBack }) {
|
|
|
244
260
|
if (!started.current) {
|
|
245
261
|
started.current = true;
|
|
246
262
|
track('$funnel_start');
|
|
247
|
-
|
|
263
|
+
startExposure(funnel.id, track);
|
|
264
|
+
// A hosted paywall's link the router did not take (not in the step placement's allowed_link_keys, a bad link token): the
|
|
265
|
+
// step shows its own placement; once per page.
|
|
266
|
+
const rej = readBootstrap().placement_rejected;
|
|
267
|
+
if (rej && typeof rej === 'object' && typeof rej.reason === 'string')
|
|
268
|
+
track('placement_rejected', { placement: String(rej.placement ?? ''), reason: rej.reason });
|
|
269
|
+
// The funnel is served at a url placement's address: the opening of the address is the placement's view (and the exposure of
|
|
270
|
+
// its experiment's arm), once per page load.
|
|
271
|
+
const u = readURLPlacement();
|
|
272
|
+
if (u) {
|
|
273
|
+
track('placement_viewed', { placement: u.placement, ...(u.experiment ? { experiment: u.experiment, arm: u.arm, phase: String(u.phase) } : {}),
|
|
274
|
+
surface: 'web_funnel', view_id: crypto.randomUUID() });
|
|
275
|
+
}
|
|
248
276
|
}
|
|
249
277
|
track('$step_view', {}, step.id);
|
|
278
|
+
// A view of a placement (spec §5): when the step is on screen, whatever its layout has loaded; one per view (a re-render is the
|
|
279
|
+
// same view). Without content (paused, nothing published) it still is a view of the placement, without a paywall.
|
|
280
|
+
if (step.placement) {
|
|
281
|
+
track('placement_viewed', content
|
|
282
|
+
? { placement: content.placement, paywall: content.paywall, paywall_version: String(content.version),
|
|
283
|
+
...(content.experiment ? { experiment: content.experiment, arm: content.arm, phase: String(content.phase) } : {}), surface: 'web_funnel', view_id: viewId }
|
|
284
|
+
: { placement: step.placement, surface: 'web_funnel' }, step.id);
|
|
285
|
+
if (content?.layout && !layoutOf(content.layout)) {
|
|
286
|
+
track('layout_missing', { placement: content.placement, paywall: content.paywall, paywall_version: String(content.version), layout: content.layout }, step.id);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
250
289
|
signals.current?.newView();
|
|
251
290
|
}, [step.id]);
|
|
252
291
|
const ctx = useMemo(() => {
|
|
@@ -285,8 +324,9 @@ function LiveFunnel({ funnel, track, root, browserBack }) {
|
|
|
285
324
|
backNav.current?.push(n);
|
|
286
325
|
}
|
|
287
326
|
};
|
|
288
|
-
const
|
|
289
|
-
const
|
|
327
|
+
const ab = currentAB();
|
|
328
|
+
const visible = funnel.steps.filter(s => !s.when || s.when(answers, ab)).length;
|
|
329
|
+
const pos = funnel.steps.slice(0, index + 1).filter(s => !s.when || s.when(answers, ab)).length;
|
|
290
330
|
const funnelId = funnelSlug() || funnel.id;
|
|
291
331
|
return {
|
|
292
332
|
answers, set, track, stepId: step.id, index, total: visible, progress: visible ? pos / visible : 0, shot: false, stopTracking, creator,
|
|
@@ -302,8 +342,8 @@ function LiveFunnel({ funnel, track, root, browserBack }) {
|
|
|
302
342
|
} },
|
|
303
343
|
};
|
|
304
344
|
}, [answers, index, step, funnel, track, creator]);
|
|
305
|
-
const C = step
|
|
306
|
-
return _jsx(LabelCtx.Provider, { value: withLabel, children: _jsx(FunnelCtx.Provider, { value: ctx, children: _jsx(C, {}) }) });
|
|
345
|
+
const C = stepComponent(step, content);
|
|
346
|
+
return _jsx(LabelCtx.Provider, { value: withLabel, children: _jsx(FunnelCtx.Provider, { value: ctx, children: _jsx(PaywallCtx.Provider, { value: pw, children: _jsx(C, {}) }) }) });
|
|
307
347
|
}
|
|
308
348
|
let current = null;
|
|
309
349
|
export function track(name, props) { current?.track(name, props); }
|
|
@@ -335,6 +375,7 @@ export function mount(funnel, el = document.getElementById('root')) {
|
|
|
335
375
|
setContent(b.content ?? {}, b.content_overrides ?? {});
|
|
336
376
|
const shot = shotRequest(location.search);
|
|
337
377
|
visit.step = '';
|
|
378
|
+
visit.placementCtx = '';
|
|
338
379
|
visit.session = () => '';
|
|
339
380
|
visit.off = () => false;
|
|
340
381
|
if (shot) {
|
|
@@ -426,7 +467,8 @@ function startsOnFirstStep(funnel) {
|
|
|
426
467
|
export function landingContext(funnel) {
|
|
427
468
|
const none = () => { };
|
|
428
469
|
const step = funnel.steps[0];
|
|
429
|
-
const
|
|
470
|
+
const ab = currentAB();
|
|
471
|
+
const visible = funnel.steps.filter(s => !s.when || s.when({}, ab)).length;
|
|
430
472
|
return { answers: {}, set: none, next: none, back: none, skip: none, track: none, stepId: step.id, index: 0, total: visible,
|
|
431
473
|
progress: visible ? 1 / visible : 0, shot: false, stopTracking: async () => { }, creator: null, visitorId: '', funnelId: funnel.id,
|
|
432
474
|
userId: '', knownUser: false };
|
package/dist/index.d.ts
CHANGED
|
@@ -5,10 +5,12 @@ export { t, param, setContent } from './content.js';
|
|
|
5
5
|
export { experiment, useExperiment, rule, useRule } from './experiments.js';
|
|
6
6
|
export { bucketOf, pickArm } from './bucket.js';
|
|
7
7
|
export { readBootstrap, readCreator, type Bootstrap, type Creator } from './bootstrap.js';
|
|
8
|
+
export { defineLayout, usePaywall, type Paywall, type PaywallProduct, type PlacementContent } from './placement.js';
|
|
8
9
|
export { server, ServerFnError } from './server.js';
|
|
9
10
|
export { autoOn, SERVICE_SIGNALS, type AutoTrack, type AutoTrackKey } from './autotrack.js';
|
|
10
11
|
export { Tracker, safeProps, type FinalEvent, type StopScope } from './tracker.js';
|
|
11
12
|
export { nextIndex, prevIndex, type StepDef, type StepRole, type Answers } from './nav.js';
|
|
13
|
+
export type { AB } from './exposure.js';
|
|
12
14
|
export { uuidv7 } from './uuid.js';
|
|
13
15
|
export { Hero } from './blocks/Hero.js';
|
|
14
16
|
export { Choice } from './blocks/Choice.js';
|
package/dist/index.js
CHANGED
|
@@ -5,6 +5,7 @@ export { t, param, setContent } from './content.js';
|
|
|
5
5
|
export { experiment, useExperiment, rule, useRule } from './experiments.js';
|
|
6
6
|
export { bucketOf, pickArm } from './bucket.js';
|
|
7
7
|
export { readBootstrap, readCreator } from './bootstrap.js';
|
|
8
|
+
export { defineLayout, usePaywall } from './placement.js';
|
|
8
9
|
export { server, ServerFnError } from './server.js';
|
|
9
10
|
export { autoOn, SERVICE_SIGNALS } from './autotrack.js';
|
|
10
11
|
export { Tracker, safeProps } from './tracker.js';
|
package/dist/nav.d.ts
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import type { ComponentType } from 'react';
|
|
2
|
+
import { type AB } from './exposure.js';
|
|
2
3
|
export type Answers = Record<string, unknown>;
|
|
3
4
|
export type StepRole = 'lead' | 'paywall' | 'landing';
|
|
4
5
|
export declare const STEP_ROLES: readonly StepRole[];
|
|
5
6
|
export type StepDef = {
|
|
6
7
|
id: string;
|
|
7
8
|
component: ComponentType;
|
|
8
|
-
when?: (a: Answers) => boolean;
|
|
9
|
+
when?: (a: Answers, ab: AB) => boolean;
|
|
9
10
|
role?: StepRole;
|
|
11
|
+
placement?: string;
|
|
10
12
|
sensitive?: boolean;
|
|
11
13
|
sample?: {
|
|
12
14
|
answers?: Answers;
|
package/dist/nav.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
|
+
import { currentAB } from './exposure.js';
|
|
1
2
|
export const STEP_ROLES = ['lead', 'paywall', 'landing'];
|
|
2
|
-
const shown = (s, a) => !s.when || s.when(a);
|
|
3
|
+
const shown = (s, a) => !s.when || s.when(a, currentAB());
|
|
3
4
|
export function nextIndex(steps, a, from) {
|
|
4
5
|
for (let i = from + 1; i < steps.length; i++)
|
|
5
6
|
if (shown(steps[i], a))
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { type ComponentType } from 'react';
|
|
2
|
+
import type { StepDef } from './nav.js';
|
|
3
|
+
export declare const PLACEMENT_KEY: RegExp;
|
|
4
|
+
export type PaywallProduct = {
|
|
5
|
+
key: string;
|
|
6
|
+
name: string;
|
|
7
|
+
kind: string;
|
|
8
|
+
period: string;
|
|
9
|
+
access_levels: string[];
|
|
10
|
+
phases: {
|
|
11
|
+
kind: string;
|
|
12
|
+
period?: string;
|
|
13
|
+
}[];
|
|
14
|
+
price: {
|
|
15
|
+
price_id: string;
|
|
16
|
+
currency: string;
|
|
17
|
+
amount: number;
|
|
18
|
+
interval: string;
|
|
19
|
+
} | null;
|
|
20
|
+
refs: {
|
|
21
|
+
provider: string;
|
|
22
|
+
ref_kind: string;
|
|
23
|
+
ref: string;
|
|
24
|
+
}[];
|
|
25
|
+
};
|
|
26
|
+
export type PlacementContent = {
|
|
27
|
+
placement: string;
|
|
28
|
+
paywall: string;
|
|
29
|
+
version: number;
|
|
30
|
+
experiment: string;
|
|
31
|
+
arm: string;
|
|
32
|
+
phase: number;
|
|
33
|
+
layout: string;
|
|
34
|
+
config: Record<string, unknown>;
|
|
35
|
+
products: PaywallProduct[];
|
|
36
|
+
subject: string;
|
|
37
|
+
ctx: string;
|
|
38
|
+
};
|
|
39
|
+
export type Paywall = Readonly<PlacementContent & {
|
|
40
|
+
view_id: string;
|
|
41
|
+
}>;
|
|
42
|
+
export declare function readPlacement(c: unknown): PlacementContent | null;
|
|
43
|
+
export type URLPlacement = {
|
|
44
|
+
placement: string;
|
|
45
|
+
experiment: string;
|
|
46
|
+
arm: string;
|
|
47
|
+
phase: number;
|
|
48
|
+
subject: string;
|
|
49
|
+
};
|
|
50
|
+
export declare function readURLPlacement(): URLPlacement | null;
|
|
51
|
+
export declare function placementOf(step: StepDef): PlacementContent | null;
|
|
52
|
+
export declare function defineLayout<C extends ComponentType<any>>(key: string, component: C): C;
|
|
53
|
+
export declare function layoutOf(key: string): ComponentType | undefined;
|
|
54
|
+
export declare function clearLayouts(): void;
|
|
55
|
+
export declare const PaywallCtx: import("react").Context<Readonly<PlacementContent & {
|
|
56
|
+
view_id: string;
|
|
57
|
+
}> | null>;
|
|
58
|
+
export declare function usePaywall(): Paywall | null;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// Placements on a funnel step (kit 0.17.0; spec 2026-11-04-paywalls-placements §2.3–§2.4, §4.1, §5):
|
|
2
|
+
// defineStep({ id: 'paywall', role: 'paywall', placement: 'onboarding_paywall', component: Fallback })
|
|
3
|
+
// defineLayout('scholartobe_main', Paywall)
|
|
4
|
+
// const pw = usePaywall()
|
|
5
|
+
// `funnel-kit build` writes the step's placement into the manifest; publishing a build whose step names a placement the project
|
|
6
|
+
// does not have fails (placement_not_found). The router puts what the placement serves now into the bootstrap
|
|
7
|
+
// (bootstrap.placements, by the step's placement key): a paywall version — its layout, JSON config and products with the
|
|
8
|
+
// mirrored prices and the payment references. The step then draws the layout registered under that id (defineLayout), else its
|
|
9
|
+
// own component: the placement serves nothing (paused, nothing published), or the layout is not in this build (layout_missing).
|
|
10
|
+
// A view of a step with a placement sends placement_viewed (funnel.tsx).
|
|
11
|
+
import { createContext, useContext } from 'react';
|
|
12
|
+
import { readBootstrap } from './bootstrap.js';
|
|
13
|
+
export const PLACEMENT_KEY = /^[a-z][a-z0-9_]{0,63}$/;
|
|
14
|
+
const LAYOUT_KEY = /^[a-z][a-z0-9_]{0,63}$/;
|
|
15
|
+
const isObj = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
|
|
16
|
+
// A bootstrap entry as content, or null when it is not one.
|
|
17
|
+
export function readPlacement(c) {
|
|
18
|
+
if (!isObj(c) || typeof c.placement !== 'string' || !PLACEMENT_KEY.test(c.placement) || typeof c.paywall !== 'string')
|
|
19
|
+
return null;
|
|
20
|
+
return {
|
|
21
|
+
placement: c.placement, paywall: c.paywall, version: Number.isInteger(c.version) ? c.version : 0,
|
|
22
|
+
experiment: typeof c.experiment === 'string' ? c.experiment : '', arm: typeof c.arm === 'string' ? c.arm : '',
|
|
23
|
+
phase: Number.isInteger(c.phase) ? c.phase : 0, layout: typeof c.layout === 'string' ? c.layout : '', config: isObj(c.config) ? c.config : {},
|
|
24
|
+
products: Array.isArray(c.products) ? c.products.filter(isObj) : [],
|
|
25
|
+
subject: typeof c.subject === 'string' ? c.subject : '', ctx: typeof c.ctx === 'string' ? c.ctx : '',
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
export function readURLPlacement() {
|
|
29
|
+
const u = readBootstrap().placement_url;
|
|
30
|
+
if (!isObj(u) || typeof u.placement !== 'string' || !PLACEMENT_KEY.test(u.placement))
|
|
31
|
+
return null;
|
|
32
|
+
const experiment = typeof u.experiment === 'string' ? u.experiment : '';
|
|
33
|
+
const arm = experiment && typeof u.arm === 'string' ? u.arm : '';
|
|
34
|
+
return { placement: u.placement, experiment: arm ? experiment : '', arm, phase: Number.isInteger(u.phase) ? u.phase : 0,
|
|
35
|
+
subject: typeof u.subject === 'string' ? u.subject : '' };
|
|
36
|
+
}
|
|
37
|
+
// The content the bootstrap has for the step's placement, or null (no placement, nothing served, vite dev, an old router).
|
|
38
|
+
export function placementOf(step) {
|
|
39
|
+
if (!step.placement)
|
|
40
|
+
return null;
|
|
41
|
+
const all = readBootstrap().placements;
|
|
42
|
+
return readPlacement(isObj(all) ? all[step.placement] : undefined);
|
|
43
|
+
}
|
|
44
|
+
const layouts = new Map();
|
|
45
|
+
// defineLayout(key, Component): this build draws the paywall layout `key` (a paywall version's `layout`) with Component. Call it
|
|
46
|
+
// at module level, next to the funnel; the same key again replaces it.
|
|
47
|
+
export function defineLayout(key, component) {
|
|
48
|
+
if (!LAYOUT_KEY.test(key))
|
|
49
|
+
throw new Error(`defineLayout: bad layout key ${JSON.stringify(key)}: use ${LAYOUT_KEY.source}`);
|
|
50
|
+
if (typeof component !== 'function' && typeof component !== 'object')
|
|
51
|
+
throw new Error(`defineLayout('${key}'): not a component`);
|
|
52
|
+
layouts.set(key, component);
|
|
53
|
+
return component;
|
|
54
|
+
}
|
|
55
|
+
export function layoutOf(key) {
|
|
56
|
+
return key ? layouts.get(key) : undefined;
|
|
57
|
+
}
|
|
58
|
+
// For tests: forget the layouts.
|
|
59
|
+
export function clearLayouts() { layouts.clear(); }
|
|
60
|
+
export const PaywallCtx = createContext(null);
|
|
61
|
+
// The placement of the step on screen with this view's id, or null: the step names no placement, it serves nothing now, outside
|
|
62
|
+
// a funnel. Read texts and the highlighted plan from config, products and their prices from products.
|
|
63
|
+
export function usePaywall() {
|
|
64
|
+
return useContext(PaywallCtx);
|
|
65
|
+
}
|
package/dist/server.d.ts
CHANGED
package/dist/server.js
CHANGED
|
@@ -8,7 +8,10 @@ const SESSION = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
|
|
|
8
8
|
// (mount() sets it). server() sends both, so the router's $fn_call and the function's ctx.track events join the person's
|
|
9
9
|
// journey. Neither is a secret. Outside a mounted funnel, and in a thumbnail, both stay empty and nothing is sent.
|
|
10
10
|
// off: stopTracking() was called; then neither is sent, so the router records no $fn_call for this visit.
|
|
11
|
-
|
|
11
|
+
// placementCtx (kit 0.17.0): the router's signed ctx of the placement on screen (placement.ts); sent as X-Placement-Ctx, so a
|
|
12
|
+
// function called from a hosted paywall opened by a link gets the link's placement (the router checks the signature).
|
|
13
|
+
export const visit = { step: '', session: () => '', off: () => false, placementCtx: '' };
|
|
14
|
+
const PLACEMENT_CTX = /^fsc1\.[A-Za-z0-9._-]{1,500}$/;
|
|
12
15
|
function visitHeaders() {
|
|
13
16
|
const out = {};
|
|
14
17
|
let off = false;
|
|
@@ -29,6 +32,8 @@ function visitHeaders() {
|
|
|
29
32
|
catch { /* storage blocked: the call goes without it */ }
|
|
30
33
|
if (SESSION.test(session))
|
|
31
34
|
out['X-Session'] = session;
|
|
35
|
+
if (PLACEMENT_CTX.test(visit.placementCtx))
|
|
36
|
+
out['X-Placement-Ctx'] = visit.placementCtx;
|
|
32
37
|
return out;
|
|
33
38
|
}
|
|
34
39
|
// status 0 means the request never got an HTTP answer: 'bad_name' (not sent) or 'network' (fetch itself failed).
|