@appright/funnelshift 0.17.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.
@@ -40,6 +40,9 @@ export type Bootstrap = {
40
40
  reason: string;
41
41
  };
42
42
  placement_url?: URLPlacement;
43
+ paywall_preview?: {
44
+ origin: string;
45
+ };
43
46
  };
44
47
  export declare function readBootstrap(win?: Window): Bootstrap;
45
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;
@@ -30,4 +31,9 @@ export type ManifestStep = {
30
31
  export declare function whenKeys(fn: any): string[] | null;
31
32
  export declare function extractSteps(files: Src[]): ManifestStep[];
32
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[];
33
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')
@@ -267,3 +269,161 @@ export function extractPrerender(files) {
267
269
  }
268
270
  return false;
269
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/funnel.js CHANGED
@@ -21,7 +21,7 @@ import { encodeAnswer, packAnswers } from './answers.js';
21
21
  import { LabelCtx, sameValue } from './offer.js';
22
22
  import { funnelSlug, setHandoff, visitorId } from './handoff.js';
23
23
  import { installPixel, metaCookies, readPixel, revokePixel, setPixel } from './pixel.js';
24
- import { layoutOf, PaywallCtx, placementOf, readURLPlacement } from './placement.js';
24
+ import { layoutOf, PaywallCtx, paywallPreviewOrigin, placementOf, readPlacement, readURLPlacement, PREVIEW_MSG } from './placement.js';
25
25
  export const defineStep = (s) => s;
26
26
  export const defineFunnel = (f) => f;
27
27
  export const FunnelCtx = createContext(null);
@@ -147,10 +147,43 @@ function ShotView({ funnel, stepId }) {
147
147
  stepId: step.id, index, total: funnel.steps.length, progress: 0, shot: true, stopTracking: async () => { }, creator: null,
148
148
  visitorId: '', funnelId: funnelSlug() || funnel.id, userId: '', knownUser: false };
149
149
  }, [funnel, step, index]);
150
- const content = useMemo(() => placementOf(step), [step]);
150
+ const given = usePaywallPreview(step);
151
+ const content = useMemo(() => given?.content ?? placementOf(step), [step, given]);
151
152
  const pw = useMemo(() => (content ? { ...content, view_id: '' } : null), [content]);
152
153
  const C = stepComponent(step, content);
153
- return _jsx(FunnelCtx.Provider, { value: ctx, children: _jsx(PaywallCtx.Provider, { value: pw, children: _jsx("div", { className: "fk-shot", children: _jsx(C, {}) }) }) });
154
+ // 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;
154
187
  }
155
188
  // The component a step draws: the layout its placement serves when this build registered it (defineLayout), else its own.
156
189
  function stepComponent(step, content) {
package/dist/index.d.ts CHANGED
@@ -6,6 +6,7 @@ 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
8
  export { defineLayout, usePaywall, type Paywall, type PaywallProduct, type PlacementContent } from './placement.js';
9
+ export type { LayoutField, LayoutSchema, LayoutText } from './layout-schema.js';
9
10
  export { server, ServerFnError } from './server.js';
10
11
  export { autoOn, SERVICE_SIGNALS, type AutoTrack, type AutoTrackKey } from './autotrack.js';
11
12
  export { Tracker, safeProps, type FinalEvent, type StopScope } 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
+ }
@@ -1,4 +1,5 @@
1
1
  import { type ComponentType } from 'react';
2
+ import { type LayoutSchema } from './layout-schema.js';
2
3
  import type { StepDef } from './nav.js';
3
4
  export declare const PLACEMENT_KEY: RegExp;
4
5
  export type PaywallProduct = {
@@ -49,9 +50,16 @@ export type URLPlacement = {
49
50
  };
50
51
  export declare function readURLPlacement(): URLPlacement | null;
51
52
  export declare function placementOf(step: StepDef): PlacementContent | null;
52
- export declare function defineLayout<C extends ComponentType<any>>(key: string, component: C): C;
53
+ export declare function defineLayout<C extends ComponentType<any>>(key: string, component: C, schema?: LayoutSchema): C;
54
+ export declare function layoutSchemaOf(key: string): LayoutSchema | undefined;
53
55
  export declare function layoutOf(key: string): ComponentType | undefined;
54
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;
55
63
  export declare const PaywallCtx: import("react").Context<Readonly<PlacementContent & {
56
64
  view_id: string;
57
65
  }> | null>;
package/dist/placement.js CHANGED
@@ -1,4 +1,4 @@
1
- // Placements on a funnel step (kit 0.17.0; spec 2026-11-04-paywalls-placements §2.3–§2.4, §4.1, §5):
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
2
  // defineStep({ id: 'paywall', role: 'paywall', placement: 'onboarding_paywall', component: Fallback })
3
3
  // defineLayout('scholartobe_main', Paywall)
4
4
  // const pw = usePaywall()
@@ -10,6 +10,7 @@
10
10
  // A view of a step with a placement sends placement_viewed (funnel.tsx).
11
11
  import { createContext, useContext } from 'react';
12
12
  import { readBootstrap } from './bootstrap.js';
13
+ import { layoutSchemaProblem } from './layout-schema.js';
13
14
  export const PLACEMENT_KEY = /^[a-z][a-z0-9_]{0,63}$/;
14
15
  const LAYOUT_KEY = /^[a-z][a-z0-9_]{0,63}$/;
15
16
  const isObj = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
@@ -42,21 +43,45 @@ export function placementOf(step) {
42
43
  return readPlacement(isObj(all) ? all[step.placement] : undefined);
43
44
  }
44
45
  const layouts = new Map();
45
- // defineLayout(key, Component): this build draws the paywall layout `key` (a paywall version's `layout`) with Component. Call it
46
- // at module level, next to the funnel; the same key again replaces it.
47
- export function defineLayout(key, component) {
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) {
48
52
  if (!LAYOUT_KEY.test(key))
49
53
  throw new Error(`defineLayout: bad layout key ${JSON.stringify(key)}: use ${LAYOUT_KEY.source}`);
50
54
  if (typeof component !== 'function' && typeof component !== 'object')
51
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);
52
64
  layouts.set(key, component);
53
65
  return component;
54
66
  }
67
+ // The schema defineLayout gave the layout, or undefined.
68
+ export function layoutSchemaOf(key) {
69
+ return schemas.get(key);
70
+ }
55
71
  export function layoutOf(key) {
56
72
  return key ? layouts.get(key) : undefined;
57
73
  }
58
74
  // For tests: forget the layouts.
59
- export function clearLayouts() { layouts.clear(); }
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
+ }
60
85
  export const PaywallCtx = createContext(null);
61
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
62
87
  // a funnel. Read texts and the highlighted plan from config, products and their prices from products.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@appright/funnelshift",
3
- "version": "0.17.0",
3
+ "version": "0.18.0",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",