@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.
- package/dist/bootstrap.d.ts +3 -0
- package/dist/cli/build.js +11 -2
- package/dist/cli/extract.d.ts +6 -0
- package/dist/cli/extract.js +160 -0
- package/dist/funnel.js +36 -3
- package/dist/index.d.ts +1 -0
- package/dist/layout-schema.d.ts +42 -0
- package/dist/layout-schema.js +105 -0
- package/dist/placement.d.ts +9 -1
- package/dist/placement.js +30 -5
- package/package.json +1 -1
package/dist/bootstrap.d.ts
CHANGED
|
@@ -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));
|
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;
|
|
@@ -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 {};
|
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')
|
|
@@ -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
|
|
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
|
-
|
|
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
|
+
}
|
package/dist/placement.d.ts
CHANGED
|
@@ -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
|
-
|
|
46
|
-
//
|
|
47
|
-
|
|
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.
|