@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.
@@ -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
- if (!k || k.startsWith('__') || k === DROPPED_KEY || chars(k).length > MAX_PARAM_KEY || seen.has(k))
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)]);
@@ -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));
@@ -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 {};
@@ -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
- // The answer keys a step's `when` reads, when its code tells them: an arrow or function whose one parameter is only ever read as
62
- // `a.key`, `a?.key` or `a['key']`, or is destructured with literal keys and no rest; no parameter reads none. Anything else (the
63
- // parameter passed on or spread, a computed key, a function declared elsewhere) gives null: the step is conditional, its keys unknown.
64
- export function whenKeys(fn) {
65
- fn = unwrap(fn);
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
- // Steps (id, optional role, sensitive: true when the step is marked) in screen order, read statically from `defineFunnel({ steps: [...] })` (the manifest's `steps`, which the server uses to
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;
@@ -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
- // The arm key of experiment `key` for this visitor, or null: no such experiment running, vite dev, an old router.
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
- return typeof v[key] === 'string' ? v[key] : null;
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 $exposure.
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
  }
@@ -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 declare function exposeVariants(funnel: string, variants: Record<string, string> | undefined, track: Track): void;
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
- // $exposure (kit 0.9.0, spec «A/B» §3): at the funnel's start, one event per experiment of the bootstrap, props {experiment, arm},
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
- export function exposeVariants(funnel, variants, track) {
8
- if (!variants || typeof variants !== 'object')
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
- const entries = Object.entries(variants).filter(([experiment, arm]) => experiment !== RULE_KEY && typeof arm === 'string' && arm);
11
- if (!entries.length)
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 = `af_exposed:${funnel}`;
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(key) ?? '{}') ?? {};
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 [experiment, arm] of entries) {
26
- if (arms[experiment] === arm)
121
+ for (const e of events) {
122
+ if (arms[e.key] === e.arm)
27
123
  continue;
28
- track('$exposure', { experiment, arm });
29
- arms[experiment] = arm;
124
+ track('experiment_assigned', e.props);
125
+ arms[e.key] = e.arm;
30
126
  }
31
127
  try {
32
- sessionStorage.setItem(key, JSON.stringify({ sid, arms }));
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
+ }
@@ -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 { exposeVariants } from './exposure.js';
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 C = step.component;
150
- return _jsx(FunnelCtx.Provider, { value: ctx, children: _jsx("div", { className: "fk-shot", children: _jsx(C, {}) }) });
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
- exposeVariants(funnel.id, readBootstrap().variants, track);
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 visible = funnel.steps.filter(s => !s.when || s.when(answers)).length;
289
- const pos = funnel.steps.slice(0, index + 1).filter(s => !s.when || s.when(answers)).length;
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.component;
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 visible = funnel.steps.filter(s => !s.when || s.when({})).length;
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
@@ -2,6 +2,7 @@ export declare const visit: {
2
2
  step: string;
3
3
  session: () => string;
4
4
  off: () => boolean;
5
+ placementCtx: string;
5
6
  };
6
7
  export declare class ServerFnError extends Error {
7
8
  readonly status: number;
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
- export const visit = { step: '', session: () => '', off: () => false };
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).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@appright/funnelshift",
3
- "version": "0.16.0",
3
+ "version": "0.18.0",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",