@appright/funnelshift 0.16.0 → 0.18.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 +15 -0
- package/dist/cli/build.js +11 -2
- package/dist/cli/extract.d.ts +7 -0
- package/dist/cli/extract.js +189 -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 +84 -9
- package/dist/index.d.ts +3 -0
- package/dist/index.js +1 -0
- package/dist/layout-schema.d.ts +42 -0
- package/dist/layout-schema.js +105 -0
- package/dist/nav.d.ts +3 -1
- package/dist/nav.js +2 -1
- package/dist/placement.d.ts +66 -0
- package/dist/placement.js +90 -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,15 @@ 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;
|
|
43
|
+
paywall_preview?: {
|
|
44
|
+
origin: string;
|
|
45
|
+
};
|
|
31
46
|
};
|
|
32
47
|
export declare function readBootstrap(win?: Window): Bootstrap;
|
|
33
48
|
export declare function readCreator(win?: Window): Creator | null;
|
package/dist/cli/build.js
CHANGED
|
@@ -2,9 +2,9 @@ import { build } from 'vite';
|
|
|
2
2
|
import react from '@vitejs/plugin-react';
|
|
3
3
|
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
|
|
4
4
|
import { isBuiltin } from 'node:module';
|
|
5
|
-
import { join, relative } from 'node:path';
|
|
5
|
+
import { join, relative, resolve, sep } from 'node:path';
|
|
6
6
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
7
|
-
import { extractContent, extractPrerender, extractSteps } from './extract.js';
|
|
7
|
+
import { extractContent, extractLayouts, extractPrerender, extractSteps } from './extract.js';
|
|
8
8
|
import { check, formatLint, formatWarnings } from './lint.js';
|
|
9
9
|
// The source files under dir, without node_modules; noTests also leaves out *.test.* and *.spec.* (functions/ may keep its tests
|
|
10
10
|
// next to the code, and they are not part of the bundle).
|
|
@@ -163,6 +163,14 @@ export async function buildFunnel(root, slug, info, opts = {}) {
|
|
|
163
163
|
const content = extractContent(srcs);
|
|
164
164
|
const steps = extractSteps(srcs);
|
|
165
165
|
const prerender = extractPrerender(srcs);
|
|
166
|
+
// layouts and their schemas (kit 0.18.0): a schema imported from another file of the project (the hosted funnel's paywall reads
|
|
167
|
+
// the main funnel's) is read from the root, never from outside it or node_modules
|
|
168
|
+
const layouts = extractLayouts(srcs, p => {
|
|
169
|
+
const abs = resolve(root, p);
|
|
170
|
+
if (!abs.startsWith(resolve(root) + sep) || abs.split(sep).includes('node_modules') || !/\.(tsx?|jsx?)$/.test(abs) || !existsSync(abs) || !statSync(abs).isFile())
|
|
171
|
+
return null;
|
|
172
|
+
return read(abs);
|
|
173
|
+
});
|
|
166
174
|
if (prerender && steps[0]?.role !== 'landing') {
|
|
167
175
|
throw new Error(`funnels/${slug}: prerender: true draws the landing step into index.html, so the first step needs role: 'landing'`);
|
|
168
176
|
}
|
|
@@ -193,6 +201,7 @@ export async function buildFunnel(root, slug, info, opts = {}) {
|
|
|
193
201
|
writeFileSync(join(out, '_functions.js'), fn.code);
|
|
194
202
|
writeFileSync(join(out, 'manifest.json'), JSON.stringify({
|
|
195
203
|
funnel: slug, kit_version: kitVersion, built_at: new Date().toISOString(), commit: info.commit, branch: info.branch, steps, content,
|
|
204
|
+
...(layouts.length ? { layouts } : {}),
|
|
196
205
|
...(prerender ? { prerender: true } : {}),
|
|
197
206
|
...(fn ? { functions: fn.names } : {}),
|
|
198
207
|
}, null, 2));
|
package/dist/cli/extract.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { type LayoutSchema } from '../layout-schema.js';
|
|
1
2
|
import { type StepRole } from '../nav.js';
|
|
2
3
|
type Item = {
|
|
3
4
|
key: string;
|
|
@@ -22,6 +23,7 @@ type Src = {
|
|
|
22
23
|
export type ManifestStep = {
|
|
23
24
|
id: string;
|
|
24
25
|
role?: StepRole;
|
|
26
|
+
placement?: string;
|
|
25
27
|
sensitive?: true;
|
|
26
28
|
conditional?: true;
|
|
27
29
|
when_keys?: string[];
|
|
@@ -29,4 +31,9 @@ export type ManifestStep = {
|
|
|
29
31
|
export declare function whenKeys(fn: any): string[] | null;
|
|
30
32
|
export declare function extractSteps(files: Src[]): ManifestStep[];
|
|
31
33
|
export declare function extractPrerender(files: Src[]): boolean;
|
|
34
|
+
export type ManifestLayout = {
|
|
35
|
+
key: string;
|
|
36
|
+
schema?: LayoutSchema;
|
|
37
|
+
};
|
|
38
|
+
export declare function extractLayouts(files: Src[], load?: (path: string) => Src | null): ManifestLayout[];
|
|
32
39
|
export {};
|
package/dist/cli/extract.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
import { posix } from 'node:path';
|
|
1
2
|
import { parse } from '@babel/parser';
|
|
3
|
+
import { layoutSchemaProblem } from '../layout-schema.js';
|
|
2
4
|
import { STEP_ROLES } from '../nav.js';
|
|
3
5
|
export function literal(n) {
|
|
4
6
|
if (n?.type === 'StringLiteral')
|
|
@@ -58,18 +60,11 @@ export function extractContent(files) {
|
|
|
58
60
|
}
|
|
59
61
|
const unwrap = (n) => (n && ['TSAsExpression', 'TSSatisfiesExpression', 'TSNonNullExpression', 'ParenthesizedExpression'].includes(n.type) ? unwrap(n.expression) : n);
|
|
60
62
|
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];
|
|
63
|
+
const PLACEMENT_KEY = /^[a-z][a-z0-9_]{0,63}$/;
|
|
64
|
+
// 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`
|
|
65
|
+
// or `a['key']`, or is destructured with literal keys and no rest; an unused parameter reads none. Anything else (the parameter passed
|
|
66
|
+
// on or spread, a computed key) gives null.
|
|
67
|
+
function paramKeys(fn, p) {
|
|
73
68
|
if (p.type === 'AssignmentPattern')
|
|
74
69
|
p = p.left;
|
|
75
70
|
const keys = [];
|
|
@@ -122,7 +117,20 @@ export function whenKeys(fn) {
|
|
|
122
117
|
visit(fn.body, fn, 'body');
|
|
123
118
|
return ok ? keys : null;
|
|
124
119
|
}
|
|
125
|
-
//
|
|
120
|
+
// The keys a step's `when` reads, when its code tells them: the answers it reads (the first parameter) as their keys, the experiments
|
|
121
|
+
// it reads (the second, `(a, ab) => ab.quiz_len === 'long'`) as `ab.<experiment>`, in the order of the parameters. Anything else (a
|
|
122
|
+
// parameter passed on or spread, a computed key, a function declared elsewhere) gives null: the step is conditional, its keys unknown.
|
|
123
|
+
export function whenKeys(fn) {
|
|
124
|
+
fn = unwrap(fn);
|
|
125
|
+
if (fn?.type !== 'ArrowFunctionExpression' && fn?.type !== 'FunctionExpression')
|
|
126
|
+
return null;
|
|
127
|
+
if (fn.params.length > 2)
|
|
128
|
+
return null;
|
|
129
|
+
const answers = fn.params[0] ? paramKeys(fn, fn.params[0]) : [];
|
|
130
|
+
const arms = fn.params[1] ? paramKeys(fn, fn.params[1]) : [];
|
|
131
|
+
return answers && arms ? [...answers, ...arms.map(k => `ab.${k}`)] : null;
|
|
132
|
+
}
|
|
133
|
+
// 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
134
|
// 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
135
|
// same file; anything else (imports, spreads, computed ids) is an error with file:line, never a guess.
|
|
128
136
|
export function extractSteps(files) {
|
|
@@ -199,6 +207,16 @@ export function extractSteps(files) {
|
|
|
199
207
|
}
|
|
200
208
|
out.role = role;
|
|
201
209
|
}
|
|
210
|
+
// placement (kit 0.17.0): the project's placement the step sells in; the server refuses a publish whose placement it does not
|
|
211
|
+
// have (placement_not_found), so a typo fails there and never makes a new placement.
|
|
212
|
+
const placementProp = option('placement');
|
|
213
|
+
if (placementProp) {
|
|
214
|
+
const key = literal(unwrap(placementProp.value));
|
|
215
|
+
if (key === null || !PLACEMENT_KEY.test(key)) {
|
|
216
|
+
throw new Error(`${at(placementProp)}: defineStep() placement must be a placement key as a string literal (${PLACEMENT_KEY.source})`);
|
|
217
|
+
}
|
|
218
|
+
out.placement = key;
|
|
219
|
+
}
|
|
202
220
|
// sensitive only marks the step in the admin; the manifest carries it so the server can say so (steps_order).
|
|
203
221
|
const sensProp = option('sensitive');
|
|
204
222
|
if (sensProp) {
|
|
@@ -251,3 +269,161 @@ export function extractPrerender(files) {
|
|
|
251
269
|
}
|
|
252
270
|
return false;
|
|
253
271
|
}
|
|
272
|
+
const LAYOUT_KEY = /^[a-z][a-z0-9_]{0,63}$/;
|
|
273
|
+
const SOURCE_EXT = ['', '.tsx', '.ts', '.jsx', '.js', '/index.tsx', '/index.ts', '/index.jsx', '/index.js'];
|
|
274
|
+
function scopeOf(f, program) {
|
|
275
|
+
const consts = new Map(), exported = new Map(), imports = new Map();
|
|
276
|
+
for (const st of program.body) {
|
|
277
|
+
if (st.type === 'ImportDeclaration' && st.importKind !== 'type') {
|
|
278
|
+
for (const sp of st.specifiers) {
|
|
279
|
+
if (sp.type !== 'ImportSpecifier')
|
|
280
|
+
continue;
|
|
281
|
+
const name = sp.imported.type === 'Identifier' ? sp.imported.name : sp.imported.value;
|
|
282
|
+
imports.set(sp.local.name, { from: st.source.value, name });
|
|
283
|
+
}
|
|
284
|
+
continue;
|
|
285
|
+
}
|
|
286
|
+
if (st.type === 'ExportNamedDeclaration' && !st.declaration && !st.source) {
|
|
287
|
+
for (const sp of st.specifiers)
|
|
288
|
+
if (sp.type === 'ExportSpecifier')
|
|
289
|
+
exported.set(sp.exported.type === 'Identifier' ? sp.exported.name : sp.exported.value, sp.local.name);
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
292
|
+
const d = st.type === 'ExportNamedDeclaration' ? st.declaration : st;
|
|
293
|
+
if (d?.type !== 'VariableDeclaration' || d.kind !== 'const')
|
|
294
|
+
continue;
|
|
295
|
+
for (const v of d.declarations) {
|
|
296
|
+
if (v.id?.type !== 'Identifier' || !v.init)
|
|
297
|
+
continue;
|
|
298
|
+
consts.set(v.id.name, v.init);
|
|
299
|
+
if (st.type === 'ExportNamedDeclaration')
|
|
300
|
+
exported.set(v.id.name, v.id.name);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
return { f, consts, exported, imports };
|
|
304
|
+
}
|
|
305
|
+
// The paywall layouts of the funnel, read statically from every defineLayout('key', Component, schema?) of its code: the key as a
|
|
306
|
+
// string literal and the schema (layout-schema.ts) as a literal — written in place, or a const of one in the same file, or imported
|
|
307
|
+
// by name from another file of the project (`import { schema } from './paywall'`; load reads it, a path relative to the root).
|
|
308
|
+
// Anything computed is an error with file:line, never a guess. The same key twice must carry the same schema (or one of them none).
|
|
309
|
+
export function extractLayouts(files, load = () => null) {
|
|
310
|
+
const scopes = new Map();
|
|
311
|
+
const scopeFor = (f) => {
|
|
312
|
+
let s = scopes.get(f.path);
|
|
313
|
+
if (!s) {
|
|
314
|
+
s = scopeOf(f, parseSource(f).program);
|
|
315
|
+
scopes.set(f.path, s);
|
|
316
|
+
}
|
|
317
|
+
return s;
|
|
318
|
+
};
|
|
319
|
+
const fileAt = (path) => files.find(x => x.path === path) ?? load(path);
|
|
320
|
+
const resolveImport = (from, spec) => {
|
|
321
|
+
if (!spec.startsWith('.'))
|
|
322
|
+
return null;
|
|
323
|
+
const base = posix.normalize(posix.join(posix.dirname(from), spec));
|
|
324
|
+
for (const ext of SOURCE_EXT) {
|
|
325
|
+
const hit = fileAt(base + ext);
|
|
326
|
+
if (hit)
|
|
327
|
+
return hit;
|
|
328
|
+
}
|
|
329
|
+
return null;
|
|
330
|
+
};
|
|
331
|
+
const found = new Map();
|
|
332
|
+
for (const f of files) {
|
|
333
|
+
const program = parseSource(f).program;
|
|
334
|
+
const calls = [];
|
|
335
|
+
walk(program, n => { if (isCall(n, 'defineLayout'))
|
|
336
|
+
calls.push(n); });
|
|
337
|
+
if (!calls.length)
|
|
338
|
+
continue;
|
|
339
|
+
const scope = scopeFor(f);
|
|
340
|
+
for (const call of calls) {
|
|
341
|
+
const at = `${f.path}:${call.loc?.start.line}`;
|
|
342
|
+
const key = literal(unwrap(call.arguments[0]));
|
|
343
|
+
if (key === null || !LAYOUT_KEY.test(key)) {
|
|
344
|
+
throw new Error(`${at}: defineLayout() key must be a layout key as a string literal (${LAYOUT_KEY.source})`);
|
|
345
|
+
}
|
|
346
|
+
const arg = unwrap(call.arguments[2]);
|
|
347
|
+
let schema;
|
|
348
|
+
if (arg && !(arg.type === 'Identifier' && arg.name === 'undefined')) {
|
|
349
|
+
const seen = new Set();
|
|
350
|
+
// a literal value; identifiers are followed to a const of this file or a named import of another file of the project
|
|
351
|
+
const value = (n, s) => {
|
|
352
|
+
n = unwrap(n);
|
|
353
|
+
const where = `${s.f.path}:${n?.loc?.start.line ?? call.loc?.start.line}`;
|
|
354
|
+
const notLiteral = (what) => new Error(`${where}: defineLayout('${key}') schema must be a literal — an object of strings, numbers, ` +
|
|
355
|
+
`booleans, arrays and objects, written in place or as a const of one (in this file or imported by name) — but ${what}; ` +
|
|
356
|
+
`funnel-kit build reads it without running the code`);
|
|
357
|
+
switch (n?.type) {
|
|
358
|
+
case 'StringLiteral':
|
|
359
|
+
case 'NumericLiteral':
|
|
360
|
+
case 'BooleanLiteral': return n.value;
|
|
361
|
+
case 'NullLiteral': return null;
|
|
362
|
+
case 'TemplateLiteral':
|
|
363
|
+
if (n.expressions.length)
|
|
364
|
+
throw notLiteral('a template string has ${…} in it');
|
|
365
|
+
return n.quasis[0].value.cooked;
|
|
366
|
+
case 'UnaryExpression':
|
|
367
|
+
if (n.operator === '-' && n.argument?.type === 'NumericLiteral')
|
|
368
|
+
return -n.argument.value;
|
|
369
|
+
throw notLiteral(`it has the expression ${n.operator}…`);
|
|
370
|
+
case 'ArrayExpression':
|
|
371
|
+
return n.elements.map((el) => {
|
|
372
|
+
if (!el || el.type === 'SpreadElement')
|
|
373
|
+
throw notLiteral('an array has a spread or a hole');
|
|
374
|
+
return value(el, s);
|
|
375
|
+
});
|
|
376
|
+
case 'ObjectExpression': {
|
|
377
|
+
const out = {};
|
|
378
|
+
for (const p of n.properties) {
|
|
379
|
+
if (p.type !== 'ObjectProperty')
|
|
380
|
+
throw notLiteral(p.type === 'SpreadElement' ? 'an object has a spread (...)' : 'an object has a method');
|
|
381
|
+
const k = p.computed ? null : p.key.type === 'Identifier' ? p.key.name : literal(p.key) ?? (p.key.type === 'NumericLiteral' ? String(p.key.value) : null);
|
|
382
|
+
if (k === null)
|
|
383
|
+
throw notLiteral('an object has a computed key');
|
|
384
|
+
out[k] = value(p.value, s);
|
|
385
|
+
}
|
|
386
|
+
return out;
|
|
387
|
+
}
|
|
388
|
+
case 'Identifier': {
|
|
389
|
+
const id = `${s.f.path}#${n.name}`;
|
|
390
|
+
if (seen.has(id))
|
|
391
|
+
throw notLiteral(`'${n.name}' refers to itself`);
|
|
392
|
+
seen.add(id);
|
|
393
|
+
const init = s.consts.get(n.name);
|
|
394
|
+
if (init)
|
|
395
|
+
return value(init, s);
|
|
396
|
+
const imp = s.imports.get(n.name);
|
|
397
|
+
if (imp) {
|
|
398
|
+
const other = resolveImport(s.f.path, imp.from);
|
|
399
|
+
if (!other)
|
|
400
|
+
throw notLiteral(`'${n.name}' is imported from '${imp.from}', which is not a file of the project funnel-kit reads`);
|
|
401
|
+
const os = scopeFor(other);
|
|
402
|
+
const local = os.exported.get(imp.name);
|
|
403
|
+
const init2 = local ? os.consts.get(local) : undefined;
|
|
404
|
+
if (!init2)
|
|
405
|
+
throw notLiteral(`'${imp.name}' of ${other.path} is not an exported const`);
|
|
406
|
+
return value(init2, os);
|
|
407
|
+
}
|
|
408
|
+
throw notLiteral(`'${n.name}' is not a const of ${s.f.path} or a named import`);
|
|
409
|
+
}
|
|
410
|
+
default:
|
|
411
|
+
throw notLiteral(`it is ${n?.type ?? 'missing'}`);
|
|
412
|
+
}
|
|
413
|
+
};
|
|
414
|
+
const v = value(arg, scope);
|
|
415
|
+
const bad = layoutSchemaProblem(v);
|
|
416
|
+
if (bad)
|
|
417
|
+
throw new Error(`${at}: defineLayout('${key}') schema: ${bad}`);
|
|
418
|
+
schema = v;
|
|
419
|
+
}
|
|
420
|
+
const prev = found.get(key);
|
|
421
|
+
if (prev && prev.schema && schema && JSON.stringify(prev.schema) !== JSON.stringify(schema)) {
|
|
422
|
+
throw new Error(`${at}: defineLayout('${key}') has another schema than at ${prev.at}; one layout has one schema`);
|
|
423
|
+
}
|
|
424
|
+
if (!prev || (!prev.schema && schema))
|
|
425
|
+
found.set(key, { schema, at });
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
return [...found.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([key, { schema }]) => (schema ? { key, schema } : { key }));
|
|
429
|
+
}
|
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, paywallPreviewOrigin, placementOf, readPlacement, readURLPlacement, PREVIEW_MSG } 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,47 @@ 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 given = usePaywallPreview(step);
|
|
151
|
+
const content = useMemo(() => given?.content ?? placementOf(step), [step, given]);
|
|
152
|
+
const pw = useMemo(() => (content ? { ...content, view_id: '' } : null), [content]);
|
|
153
|
+
const C = stepComponent(step, content);
|
|
154
|
+
// a new content from the admin draws the layout anew (its own state — the chosen plan — starts from the config again)
|
|
155
|
+
return _jsx(FunnelCtx.Provider, { value: ctx, children: _jsx(PaywallCtx.Provider, { value: pw, children: _jsx("div", { className: "fk-shot", children: _jsx(C, {}) }, given?.seq ?? 0) }) });
|
|
156
|
+
}
|
|
157
|
+
// The admin's live preview of a paywall version (kit 0.18.0): a shot page the router marks (bootstrap.paywall_preview, only with
|
|
158
|
+
// ?__pw=1, framed only by the admin) takes the content to draw from the admin's frame by postMessage — the layout, the config
|
|
159
|
+
// being edited and the products with their mirrored prices — instead of what the step's placement serves. Only a message from the
|
|
160
|
+
// parent frame on the admin's origin is read; the page says it is ready, and after each content whether this build draws that
|
|
161
|
+
// layout. A shot is no visit: nothing is tracked or stored, the buttons do nothing.
|
|
162
|
+
function usePaywallPreview(step) {
|
|
163
|
+
const [given, setGiven] = useState(null);
|
|
164
|
+
const origin = useMemo(() => paywallPreviewOrigin(), []);
|
|
165
|
+
useEffect(() => {
|
|
166
|
+
if (!origin)
|
|
167
|
+
return;
|
|
168
|
+
const on = (e) => {
|
|
169
|
+
if (e.origin !== origin || e.source !== window.parent)
|
|
170
|
+
return;
|
|
171
|
+
const d = e.data;
|
|
172
|
+
if (!d || d.type !== PREVIEW_MSG.content || !d.content || typeof d.content !== 'object')
|
|
173
|
+
return;
|
|
174
|
+
const c = readPlacement({ ...d.content, placement: step.placement ?? 'preview' });
|
|
175
|
+
if (c)
|
|
176
|
+
setGiven(g => ({ content: c, seq: (g?.seq ?? 0) + 1 }));
|
|
177
|
+
};
|
|
178
|
+
window.addEventListener('message', on);
|
|
179
|
+
window.parent.postMessage({ type: PREVIEW_MSG.ready, step: step.id }, origin);
|
|
180
|
+
return () => window.removeEventListener('message', on);
|
|
181
|
+
}, [origin, step]);
|
|
182
|
+
useEffect(() => {
|
|
183
|
+
if (origin && given)
|
|
184
|
+
window.parent.postMessage({ type: PREVIEW_MSG.shown, layout: given.content.layout, drawn: !!layoutOf(given.content.layout) }, origin);
|
|
185
|
+
}, [origin, given]);
|
|
186
|
+
return given;
|
|
187
|
+
}
|
|
188
|
+
// The component a step draws: the layout its placement serves when this build registered it (defineLayout), else its own.
|
|
189
|
+
function stepComponent(step, content) {
|
|
190
|
+
return (content && layoutOf(content.layout)) || step.component;
|
|
151
191
|
}
|
|
152
192
|
// 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
193
|
// mount() passes its element. A thumbnail never tracks clicks.
|
|
@@ -190,6 +230,15 @@ function LiveFunnel({ funnel, track, root, browserBack }) {
|
|
|
190
230
|
// server() sends the step on screen. Set while rendering, like onScreen: a step that calls its function in its own effect runs
|
|
191
231
|
// that effect before any effect of the funnel would.
|
|
192
232
|
visit.step = step.id;
|
|
233
|
+
// 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,
|
|
234
|
+
// «Назад» to it), the same while it stays (a re-render). server() sends the placement's signed ctx, like the step.
|
|
235
|
+
const content = useMemo(() => placementOf(step), [step]);
|
|
236
|
+
const view = useRef({ step: '', id: '' });
|
|
237
|
+
if (view.current.step !== step.id)
|
|
238
|
+
view.current = { step: step.id, id: content ? crypto.randomUUID() : '' };
|
|
239
|
+
const viewId = view.current.id;
|
|
240
|
+
const pw = useMemo(() => (content ? { ...content, view_id: viewId } : null), [content, viewId]);
|
|
241
|
+
visit.placementCtx = content?.ctx ?? '';
|
|
193
242
|
// 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
243
|
// appears is never missed (a plain effect can run after the browser has already delivered that click).
|
|
195
244
|
useLayoutEffect(() => {
|
|
@@ -244,9 +293,32 @@ function LiveFunnel({ funnel, track, root, browserBack }) {
|
|
|
244
293
|
if (!started.current) {
|
|
245
294
|
started.current = true;
|
|
246
295
|
track('$funnel_start');
|
|
247
|
-
|
|
296
|
+
startExposure(funnel.id, track);
|
|
297
|
+
// A hosted paywall's link the router did not take (not in the step placement's allowed_link_keys, a bad link token): the
|
|
298
|
+
// step shows its own placement; once per page.
|
|
299
|
+
const rej = readBootstrap().placement_rejected;
|
|
300
|
+
if (rej && typeof rej === 'object' && typeof rej.reason === 'string')
|
|
301
|
+
track('placement_rejected', { placement: String(rej.placement ?? ''), reason: rej.reason });
|
|
302
|
+
// The funnel is served at a url placement's address: the opening of the address is the placement's view (and the exposure of
|
|
303
|
+
// its experiment's arm), once per page load.
|
|
304
|
+
const u = readURLPlacement();
|
|
305
|
+
if (u) {
|
|
306
|
+
track('placement_viewed', { placement: u.placement, ...(u.experiment ? { experiment: u.experiment, arm: u.arm, phase: String(u.phase) } : {}),
|
|
307
|
+
surface: 'web_funnel', view_id: crypto.randomUUID() });
|
|
308
|
+
}
|
|
248
309
|
}
|
|
249
310
|
track('$step_view', {}, step.id);
|
|
311
|
+
// 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
|
|
312
|
+
// same view). Without content (paused, nothing published) it still is a view of the placement, without a paywall.
|
|
313
|
+
if (step.placement) {
|
|
314
|
+
track('placement_viewed', content
|
|
315
|
+
? { placement: content.placement, paywall: content.paywall, paywall_version: String(content.version),
|
|
316
|
+
...(content.experiment ? { experiment: content.experiment, arm: content.arm, phase: String(content.phase) } : {}), surface: 'web_funnel', view_id: viewId }
|
|
317
|
+
: { placement: step.placement, surface: 'web_funnel' }, step.id);
|
|
318
|
+
if (content?.layout && !layoutOf(content.layout)) {
|
|
319
|
+
track('layout_missing', { placement: content.placement, paywall: content.paywall, paywall_version: String(content.version), layout: content.layout }, step.id);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
250
322
|
signals.current?.newView();
|
|
251
323
|
}, [step.id]);
|
|
252
324
|
const ctx = useMemo(() => {
|
|
@@ -285,8 +357,9 @@ function LiveFunnel({ funnel, track, root, browserBack }) {
|
|
|
285
357
|
backNav.current?.push(n);
|
|
286
358
|
}
|
|
287
359
|
};
|
|
288
|
-
const
|
|
289
|
-
const
|
|
360
|
+
const ab = currentAB();
|
|
361
|
+
const visible = funnel.steps.filter(s => !s.when || s.when(answers, ab)).length;
|
|
362
|
+
const pos = funnel.steps.slice(0, index + 1).filter(s => !s.when || s.when(answers, ab)).length;
|
|
290
363
|
const funnelId = funnelSlug() || funnel.id;
|
|
291
364
|
return {
|
|
292
365
|
answers, set, track, stepId: step.id, index, total: visible, progress: visible ? pos / visible : 0, shot: false, stopTracking, creator,
|
|
@@ -302,8 +375,8 @@ function LiveFunnel({ funnel, track, root, browserBack }) {
|
|
|
302
375
|
} },
|
|
303
376
|
};
|
|
304
377
|
}, [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, {}) }) });
|
|
378
|
+
const C = stepComponent(step, content);
|
|
379
|
+
return _jsx(LabelCtx.Provider, { value: withLabel, children: _jsx(FunnelCtx.Provider, { value: ctx, children: _jsx(PaywallCtx.Provider, { value: pw, children: _jsx(C, {}) }) }) });
|
|
307
380
|
}
|
|
308
381
|
let current = null;
|
|
309
382
|
export function track(name, props) { current?.track(name, props); }
|
|
@@ -335,6 +408,7 @@ export function mount(funnel, el = document.getElementById('root')) {
|
|
|
335
408
|
setContent(b.content ?? {}, b.content_overrides ?? {});
|
|
336
409
|
const shot = shotRequest(location.search);
|
|
337
410
|
visit.step = '';
|
|
411
|
+
visit.placementCtx = '';
|
|
338
412
|
visit.session = () => '';
|
|
339
413
|
visit.off = () => false;
|
|
340
414
|
if (shot) {
|
|
@@ -426,7 +500,8 @@ function startsOnFirstStep(funnel) {
|
|
|
426
500
|
export function landingContext(funnel) {
|
|
427
501
|
const none = () => { };
|
|
428
502
|
const step = funnel.steps[0];
|
|
429
|
-
const
|
|
503
|
+
const ab = currentAB();
|
|
504
|
+
const visible = funnel.steps.filter(s => !s.when || s.when({}, ab)).length;
|
|
430
505
|
return { answers: {}, set: none, next: none, back: none, skip: none, track: none, stepId: step.id, index: 0, total: visible,
|
|
431
506
|
progress: visible ? 1 / visible : 0, shot: false, stopTracking: async () => { }, creator: null, visitorId: '', funnelId: funnel.id,
|
|
432
507
|
userId: '', knownUser: false };
|
package/dist/index.d.ts
CHANGED
|
@@ -5,10 +5,13 @@ 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';
|
|
9
|
+
export type { LayoutField, LayoutSchema, LayoutText } from './layout-schema.js';
|
|
8
10
|
export { server, ServerFnError } from './server.js';
|
|
9
11
|
export { autoOn, SERVICE_SIGNALS, type AutoTrack, type AutoTrackKey } from './autotrack.js';
|
|
10
12
|
export { Tracker, safeProps, type FinalEvent, type StopScope } from './tracker.js';
|
|
11
13
|
export { nextIndex, prevIndex, type StepDef, type StepRole, type Answers } from './nav.js';
|
|
14
|
+
export type { AB } from './exposure.js';
|
|
12
15
|
export { uuidv7 } from './uuid.js';
|
|
13
16
|
export { Hero } from './blocks/Hero.js';
|
|
14
17
|
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';
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
export type LayoutText = string | {
|
|
2
|
+
ru?: string;
|
|
3
|
+
en?: string;
|
|
4
|
+
};
|
|
5
|
+
type FieldBase = {
|
|
6
|
+
label?: LayoutText;
|
|
7
|
+
hint?: LayoutText;
|
|
8
|
+
example?: string | number | boolean | string[];
|
|
9
|
+
required?: boolean;
|
|
10
|
+
};
|
|
11
|
+
type TextField = FieldBase & {
|
|
12
|
+
max?: number;
|
|
13
|
+
vars?: string[];
|
|
14
|
+
};
|
|
15
|
+
export type LayoutField = (TextField & {
|
|
16
|
+
type: 'string';
|
|
17
|
+
}) | (TextField & {
|
|
18
|
+
type: 'text';
|
|
19
|
+
}) | (TextField & {
|
|
20
|
+
type: 'list';
|
|
21
|
+
item_max?: number;
|
|
22
|
+
}) | (FieldBase & {
|
|
23
|
+
type: 'product';
|
|
24
|
+
}) | (FieldBase & {
|
|
25
|
+
type: 'flag';
|
|
26
|
+
}) | (FieldBase & {
|
|
27
|
+
type: 'number';
|
|
28
|
+
min?: number;
|
|
29
|
+
max?: number;
|
|
30
|
+
integer?: boolean;
|
|
31
|
+
}) | (FieldBase & {
|
|
32
|
+
type: 'per_product';
|
|
33
|
+
fields: Record<string, LayoutField>;
|
|
34
|
+
});
|
|
35
|
+
export type LayoutSchema = {
|
|
36
|
+
fields: Record<string, LayoutField>;
|
|
37
|
+
vars?: Record<string, LayoutText>;
|
|
38
|
+
};
|
|
39
|
+
export declare const FIELD_TYPES: readonly ['string', 'text', 'list', 'product', 'flag', 'number', 'per_product'];
|
|
40
|
+
export declare const SCHEMA_MAX_BYTES: number;
|
|
41
|
+
export declare function layoutSchemaProblem(schema: unknown): string | null;
|
|
42
|
+
export {};
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// The schema of a paywall layout's config (kit 0.18.0): defineLayout('scholartobe_main', Paywall, schema). It tells the admin which
|
|
2
|
+
// fields the layout reads from a paywall version's `config` — the form «Конфиг» of a version draws them, the server checks a new
|
|
3
|
+
// version against them (a wrong type is an error, an unknown key a warning). `funnel-kit build` writes it into the manifest
|
|
4
|
+
// statically (manifest.layouts), so it must be a literal: an object of strings, numbers, booleans, arrays and objects. No React here:
|
|
5
|
+
// the CLI imports it.
|
|
6
|
+
export const FIELD_TYPES = ['string', 'text', 'list', 'product', 'flag', 'number', 'per_product'];
|
|
7
|
+
const FIELD_KEY = /^[a-z][a-z0-9_]{0,63}$/;
|
|
8
|
+
const VAR_NAME = /^[a-z][a-z0-9_]{0,31}$/;
|
|
9
|
+
const MAX_FIELDS = 64;
|
|
10
|
+
// the size of a schema in the manifest
|
|
11
|
+
export const SCHEMA_MAX_BYTES = 16 << 10;
|
|
12
|
+
const isObj = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
|
|
13
|
+
const isText = (v) => typeof v === 'string' || (isObj(v) && Object.keys(v).every(k => (k === 'ru' || k === 'en') && typeof v[k] === 'string'));
|
|
14
|
+
const count = (v) => typeof v === 'number' && Number.isInteger(v) && v > 0;
|
|
15
|
+
function fieldProblem(at, f, nested) {
|
|
16
|
+
if (!isObj(f))
|
|
17
|
+
return `${at}: a field is an object { type: '${FIELD_TYPES.join("' | '")}', label?, hint? }`;
|
|
18
|
+
const type = f.type;
|
|
19
|
+
if (typeof type !== 'string' || !FIELD_TYPES.includes(type)) {
|
|
20
|
+
return `${at}: type ${JSON.stringify(type)} is unknown; use one of ${FIELD_TYPES.join(', ')}`;
|
|
21
|
+
}
|
|
22
|
+
if (nested && type === 'per_product')
|
|
23
|
+
return `${at}: per_product fields cannot nest another per_product`;
|
|
24
|
+
const allowed = ['type', 'label', 'hint', 'example', 'required'];
|
|
25
|
+
if (type === 'string' || type === 'text' || type === 'list')
|
|
26
|
+
allowed.push('max', 'vars');
|
|
27
|
+
if (type === 'list')
|
|
28
|
+
allowed.push('item_max');
|
|
29
|
+
if (type === 'number')
|
|
30
|
+
allowed.push('min', 'max', 'integer');
|
|
31
|
+
if (type === 'per_product')
|
|
32
|
+
allowed.push('fields');
|
|
33
|
+
const extra = Object.keys(f).find(k => !allowed.includes(k));
|
|
34
|
+
if (extra)
|
|
35
|
+
return `${at}: ${type} field has no option ${extra} (it takes ${allowed.join(', ')})`;
|
|
36
|
+
if (f.label !== undefined && !isText(f.label))
|
|
37
|
+
return `${at}: label is a string or { ru, en }`;
|
|
38
|
+
if (f.hint !== undefined && !isText(f.hint))
|
|
39
|
+
return `${at}: hint is a string or { ru, en }`;
|
|
40
|
+
if (f.required !== undefined && typeof f.required !== 'boolean')
|
|
41
|
+
return `${at}: required is true or false`;
|
|
42
|
+
if (f.example !== undefined && !['string', 'number', 'boolean'].includes(typeof f.example) &&
|
|
43
|
+
!(Array.isArray(f.example) && f.example.every(x => typeof x === 'string')))
|
|
44
|
+
return `${at}: example is a string, a number, a boolean or a list of strings`;
|
|
45
|
+
if (type === 'number') {
|
|
46
|
+
for (const k of ['min', 'max'])
|
|
47
|
+
if (f[k] !== undefined && (typeof f[k] !== 'number' || !Number.isFinite(f[k])))
|
|
48
|
+
return `${at}: ${k} is a number`;
|
|
49
|
+
if (typeof f.min === 'number' && typeof f.max === 'number' && f.min > f.max)
|
|
50
|
+
return `${at}: min is more than max`;
|
|
51
|
+
if (f.integer !== undefined && typeof f.integer !== 'boolean')
|
|
52
|
+
return `${at}: integer is true or false`;
|
|
53
|
+
}
|
|
54
|
+
else if (f.max !== undefined && !count(f.max))
|
|
55
|
+
return `${at}: max is a whole number above 0`;
|
|
56
|
+
if (f.item_max !== undefined && !count(f.item_max))
|
|
57
|
+
return `${at}: item_max is a whole number above 0`;
|
|
58
|
+
if (f.vars !== undefined && !(Array.isArray(f.vars) && f.vars.every(v => typeof v === 'string' && VAR_NAME.test(v)))) {
|
|
59
|
+
return `${at}: vars is a list of substitution names (${VAR_NAME.source})`;
|
|
60
|
+
}
|
|
61
|
+
if (type === 'per_product')
|
|
62
|
+
return fieldsProblem(`${at}.fields`, f.fields, true);
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
function fieldsProblem(at, fields, nested) {
|
|
66
|
+
if (!isObj(fields))
|
|
67
|
+
return `${at}: fields is an object { <config key>: { type, … } }`;
|
|
68
|
+
const keys = Object.keys(fields);
|
|
69
|
+
if (keys.length === 0)
|
|
70
|
+
return `${at}: no fields`;
|
|
71
|
+
if (keys.length > MAX_FIELDS)
|
|
72
|
+
return `${at}: ${keys.length} fields, the limit is ${MAX_FIELDS}`;
|
|
73
|
+
for (const k of keys) {
|
|
74
|
+
if (!FIELD_KEY.test(k))
|
|
75
|
+
return `${at}: field key ${JSON.stringify(k)} must match ${FIELD_KEY.source}`;
|
|
76
|
+
const p = fieldProblem(`${at}.${k}`, fields[k], nested);
|
|
77
|
+
if (p)
|
|
78
|
+
return p;
|
|
79
|
+
}
|
|
80
|
+
return null;
|
|
81
|
+
}
|
|
82
|
+
// What is wrong with a schema, in words, or null. The same check runs in defineLayout and in `funnel-kit build`.
|
|
83
|
+
export function layoutSchemaProblem(schema) {
|
|
84
|
+
if (!isObj(schema))
|
|
85
|
+
return 'the schema is an object { fields: { … }, vars?: { … } }';
|
|
86
|
+
const extra = Object.keys(schema).find(k => k !== 'fields' && k !== 'vars');
|
|
87
|
+
if (extra)
|
|
88
|
+
return `the schema has no option ${extra} (it takes fields and vars)`;
|
|
89
|
+
const p = fieldsProblem('fields', schema.fields, false);
|
|
90
|
+
if (p)
|
|
91
|
+
return p;
|
|
92
|
+
if (schema.vars !== undefined) {
|
|
93
|
+
if (!isObj(schema.vars))
|
|
94
|
+
return 'vars is an object { <name>: <what it is> }';
|
|
95
|
+
for (const [k, v] of Object.entries(schema.vars)) {
|
|
96
|
+
if (!VAR_NAME.test(k))
|
|
97
|
+
return `vars: name ${JSON.stringify(k)} must match ${VAR_NAME.source}`;
|
|
98
|
+
if (!isText(v))
|
|
99
|
+
return `vars.${k}: what it is — a string or { ru, en }`;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
if (new TextEncoder().encode(JSON.stringify(schema)).length > SCHEMA_MAX_BYTES)
|
|
103
|
+
return `the schema is over ${SCHEMA_MAX_BYTES >> 10} KB`;
|
|
104
|
+
return null;
|
|
105
|
+
}
|
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,66 @@
|
|
|
1
|
+
import { type ComponentType } from 'react';
|
|
2
|
+
import { type LayoutSchema } from './layout-schema.js';
|
|
3
|
+
import type { StepDef } from './nav.js';
|
|
4
|
+
export declare const PLACEMENT_KEY: RegExp;
|
|
5
|
+
export type PaywallProduct = {
|
|
6
|
+
key: string;
|
|
7
|
+
name: string;
|
|
8
|
+
kind: string;
|
|
9
|
+
period: string;
|
|
10
|
+
access_levels: string[];
|
|
11
|
+
phases: {
|
|
12
|
+
kind: string;
|
|
13
|
+
period?: string;
|
|
14
|
+
}[];
|
|
15
|
+
price: {
|
|
16
|
+
price_id: string;
|
|
17
|
+
currency: string;
|
|
18
|
+
amount: number;
|
|
19
|
+
interval: string;
|
|
20
|
+
} | null;
|
|
21
|
+
refs: {
|
|
22
|
+
provider: string;
|
|
23
|
+
ref_kind: string;
|
|
24
|
+
ref: string;
|
|
25
|
+
}[];
|
|
26
|
+
};
|
|
27
|
+
export type PlacementContent = {
|
|
28
|
+
placement: string;
|
|
29
|
+
paywall: string;
|
|
30
|
+
version: number;
|
|
31
|
+
experiment: string;
|
|
32
|
+
arm: string;
|
|
33
|
+
phase: number;
|
|
34
|
+
layout: string;
|
|
35
|
+
config: Record<string, unknown>;
|
|
36
|
+
products: PaywallProduct[];
|
|
37
|
+
subject: string;
|
|
38
|
+
ctx: string;
|
|
39
|
+
};
|
|
40
|
+
export type Paywall = Readonly<PlacementContent & {
|
|
41
|
+
view_id: string;
|
|
42
|
+
}>;
|
|
43
|
+
export declare function readPlacement(c: unknown): PlacementContent | null;
|
|
44
|
+
export type URLPlacement = {
|
|
45
|
+
placement: string;
|
|
46
|
+
experiment: string;
|
|
47
|
+
arm: string;
|
|
48
|
+
phase: number;
|
|
49
|
+
subject: string;
|
|
50
|
+
};
|
|
51
|
+
export declare function readURLPlacement(): URLPlacement | null;
|
|
52
|
+
export declare function placementOf(step: StepDef): PlacementContent | null;
|
|
53
|
+
export declare function defineLayout<C extends ComponentType<any>>(key: string, component: C, schema?: LayoutSchema): C;
|
|
54
|
+
export declare function layoutSchemaOf(key: string): LayoutSchema | undefined;
|
|
55
|
+
export declare function layoutOf(key: string): ComponentType | undefined;
|
|
56
|
+
export declare function clearLayouts(): void;
|
|
57
|
+
export declare const PREVIEW_MSG: {
|
|
58
|
+
readonly ready: 'funnelshift:paywall-ready';
|
|
59
|
+
readonly content: 'funnelshift:paywall';
|
|
60
|
+
readonly shown: 'funnelshift:paywall-shown';
|
|
61
|
+
};
|
|
62
|
+
export declare function paywallPreviewOrigin(): string | null;
|
|
63
|
+
export declare const PaywallCtx: import("react").Context<Readonly<PlacementContent & {
|
|
64
|
+
view_id: string;
|
|
65
|
+
}> | null>;
|
|
66
|
+
export declare function usePaywall(): Paywall | null;
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// Placements on a funnel step (kit 0.17.0, the layout's schema and the admin's live preview 0.18.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
|
+
import { layoutSchemaProblem } from './layout-schema.js';
|
|
14
|
+
export const PLACEMENT_KEY = /^[a-z][a-z0-9_]{0,63}$/;
|
|
15
|
+
const LAYOUT_KEY = /^[a-z][a-z0-9_]{0,63}$/;
|
|
16
|
+
const isObj = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
|
|
17
|
+
// A bootstrap entry as content, or null when it is not one.
|
|
18
|
+
export function readPlacement(c) {
|
|
19
|
+
if (!isObj(c) || typeof c.placement !== 'string' || !PLACEMENT_KEY.test(c.placement) || typeof c.paywall !== 'string')
|
|
20
|
+
return null;
|
|
21
|
+
return {
|
|
22
|
+
placement: c.placement, paywall: c.paywall, version: Number.isInteger(c.version) ? c.version : 0,
|
|
23
|
+
experiment: typeof c.experiment === 'string' ? c.experiment : '', arm: typeof c.arm === 'string' ? c.arm : '',
|
|
24
|
+
phase: Number.isInteger(c.phase) ? c.phase : 0, layout: typeof c.layout === 'string' ? c.layout : '', config: isObj(c.config) ? c.config : {},
|
|
25
|
+
products: Array.isArray(c.products) ? c.products.filter(isObj) : [],
|
|
26
|
+
subject: typeof c.subject === 'string' ? c.subject : '', ctx: typeof c.ctx === 'string' ? c.ctx : '',
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
export function readURLPlacement() {
|
|
30
|
+
const u = readBootstrap().placement_url;
|
|
31
|
+
if (!isObj(u) || typeof u.placement !== 'string' || !PLACEMENT_KEY.test(u.placement))
|
|
32
|
+
return null;
|
|
33
|
+
const experiment = typeof u.experiment === 'string' ? u.experiment : '';
|
|
34
|
+
const arm = experiment && typeof u.arm === 'string' ? u.arm : '';
|
|
35
|
+
return { placement: u.placement, experiment: arm ? experiment : '', arm, phase: Number.isInteger(u.phase) ? u.phase : 0,
|
|
36
|
+
subject: typeof u.subject === 'string' ? u.subject : '' };
|
|
37
|
+
}
|
|
38
|
+
// The content the bootstrap has for the step's placement, or null (no placement, nothing served, vite dev, an old router).
|
|
39
|
+
export function placementOf(step) {
|
|
40
|
+
if (!step.placement)
|
|
41
|
+
return null;
|
|
42
|
+
const all = readBootstrap().placements;
|
|
43
|
+
return readPlacement(isObj(all) ? all[step.placement] : undefined);
|
|
44
|
+
}
|
|
45
|
+
const layouts = new Map();
|
|
46
|
+
const schemas = new Map();
|
|
47
|
+
// defineLayout(key, Component, schema?): this build draws the paywall layout `key` (a paywall version's `layout`) with Component.
|
|
48
|
+
// Call it at module level, next to the funnel; the same key again replaces it. schema (kit 0.18.0, layout-schema.ts) — the fields
|
|
49
|
+
// the layout reads from config: the admin draws a form of them and checks a version against them; `funnel-kit build` writes it
|
|
50
|
+
// into the manifest, so write it as a literal (or a const of one, in this file or imported by name).
|
|
51
|
+
export function defineLayout(key, component, schema) {
|
|
52
|
+
if (!LAYOUT_KEY.test(key))
|
|
53
|
+
throw new Error(`defineLayout: bad layout key ${JSON.stringify(key)}: use ${LAYOUT_KEY.source}`);
|
|
54
|
+
if (typeof component !== 'function' && typeof component !== 'object')
|
|
55
|
+
throw new Error(`defineLayout('${key}'): not a component`);
|
|
56
|
+
if (schema !== undefined) {
|
|
57
|
+
const bad = layoutSchemaProblem(schema);
|
|
58
|
+
if (bad)
|
|
59
|
+
throw new Error(`defineLayout('${key}'): bad schema: ${bad}`);
|
|
60
|
+
schemas.set(key, schema);
|
|
61
|
+
}
|
|
62
|
+
else
|
|
63
|
+
schemas.delete(key);
|
|
64
|
+
layouts.set(key, component);
|
|
65
|
+
return component;
|
|
66
|
+
}
|
|
67
|
+
// The schema defineLayout gave the layout, or undefined.
|
|
68
|
+
export function layoutSchemaOf(key) {
|
|
69
|
+
return schemas.get(key);
|
|
70
|
+
}
|
|
71
|
+
export function layoutOf(key) {
|
|
72
|
+
return key ? layouts.get(key) : undefined;
|
|
73
|
+
}
|
|
74
|
+
// For tests: forget the layouts.
|
|
75
|
+
export function clearLayouts() { layouts.clear(); schemas.clear(); }
|
|
76
|
+
// The messages of the admin's live preview (funnel.tsx usePaywallPreview): the page says it is ready, the admin sends the content
|
|
77
|
+
// to draw, the page says whether this build draws its layout.
|
|
78
|
+
export const PREVIEW_MSG = { ready: 'funnelshift:paywall-ready', content: 'funnelshift:paywall', shown: 'funnelshift:paywall-shown' };
|
|
79
|
+
const ORIGIN = /^https?:\/\/[a-z0-9.-]+(:[0-9]{1,5})?$/i;
|
|
80
|
+
// The admin's origin when this page is its live preview of a paywall (bootstrap.paywall_preview, a shot page with ?__pw=1), else null.
|
|
81
|
+
export function paywallPreviewOrigin() {
|
|
82
|
+
const o = readBootstrap().paywall_preview?.origin;
|
|
83
|
+
return typeof o === 'string' && ORIGIN.test(o) ? o : null;
|
|
84
|
+
}
|
|
85
|
+
export const PaywallCtx = createContext(null);
|
|
86
|
+
// The placement of the step on screen with this view's id, or null: the step names no placement, it serves nothing now, outside
|
|
87
|
+
// a funnel. Read texts and the highlighted plan from config, products and their prices from products.
|
|
88
|
+
export function usePaywall() {
|
|
89
|
+
return useContext(PaywallCtx);
|
|
90
|
+
}
|
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).
|