@funnelsgrove/cli 0.1.13 → 0.1.15
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/cli.d.ts +22 -0
- package/dist/cli.js +123 -0
- package/dist/localSync.js +28 -24
- package/package.json +1 -1
- package/template_docs/AGENTS.md +2 -2
- package/template_docs/docs/ab-experiments.md +66 -8
- package/template_docs/docs/editing-flow.md +13 -2
package/dist/cli.d.ts
CHANGED
|
@@ -17,6 +17,8 @@ type SyncTargetIdInput = {
|
|
|
17
17
|
active?: Pick<ActiveContext, 'workspaceId' | 'funnelId'> | null;
|
|
18
18
|
defaultWorkspaceId?: string;
|
|
19
19
|
};
|
|
20
|
+
type GeneratedConfigKind = 'offerSets' | 'experiments';
|
|
21
|
+
export declare const SYNC_DOWN_GENERATED_CONFIG_KINDS: GeneratedConfigKind[];
|
|
20
22
|
export declare function resolveSyncTargetIds(input: SyncTargetIdInput): {
|
|
21
23
|
workspaceId: string;
|
|
22
24
|
funnelId?: string;
|
|
@@ -42,5 +44,25 @@ export declare function buildPatchSourceInput(input: {
|
|
|
42
44
|
files: SourceFile[];
|
|
43
45
|
deletedPaths: string[];
|
|
44
46
|
};
|
|
47
|
+
export declare function buildSyncOfferSetPlansInput(input: {
|
|
48
|
+
workspaceId: string;
|
|
49
|
+
funnelId: string;
|
|
50
|
+
offerSet: string;
|
|
51
|
+
}): {
|
|
52
|
+
workspaceId: string;
|
|
53
|
+
funnelId: string;
|
|
54
|
+
offerSetId?: string;
|
|
55
|
+
offerSetKey?: string;
|
|
56
|
+
};
|
|
57
|
+
export declare function buildGeneratedConfigSyncInput(input: {
|
|
58
|
+
workspaceId: string;
|
|
59
|
+
funnelId: string;
|
|
60
|
+
kinds: GeneratedConfigKind[];
|
|
61
|
+
}): {
|
|
62
|
+
workspaceId: string;
|
|
63
|
+
funnelId: string;
|
|
64
|
+
kinds: GeneratedConfigKind[];
|
|
65
|
+
};
|
|
66
|
+
export declare function selectGeneratedConfigFiles(files: SourceFile[], kinds: GeneratedConfigKind[]): SourceFile[];
|
|
45
67
|
export declare function assertCanDraftSyncSource(status: GitHubStatusResponse): void;
|
|
46
68
|
export {};
|
package/dist/cli.js
CHANGED
|
@@ -38,6 +38,11 @@ const DEFAULT_API_URL = 'https://api.funnelsgrove.com/trpc';
|
|
|
38
38
|
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
39
39
|
const PUBLISH_POLL_INTERVAL_MS = 2_000;
|
|
40
40
|
const PUBLISH_WAIT_TIMEOUT_MS = 30 * 60_000;
|
|
41
|
+
const GENERATED_CONFIG_PATH_BY_KIND = {
|
|
42
|
+
offerSets: 'src/config/offer-sets.generated.ts',
|
|
43
|
+
experiments: 'src/config/experiments.generated.ts',
|
|
44
|
+
};
|
|
45
|
+
export const SYNC_DOWN_GENERATED_CONFIG_KINDS = ['offerSets', 'experiments'];
|
|
41
46
|
export function resolveSyncTargetIds(input) {
|
|
42
47
|
const workspaceId = input.explicitWorkspaceId ||
|
|
43
48
|
input.manifest?.workspaceId ||
|
|
@@ -83,6 +88,28 @@ export function buildPatchSourceInput(input) {
|
|
|
83
88
|
deletedPaths: input.deletedPaths,
|
|
84
89
|
};
|
|
85
90
|
}
|
|
91
|
+
export function buildSyncOfferSetPlansInput(input) {
|
|
92
|
+
const offerSet = input.offerSet.trim();
|
|
93
|
+
if (!offerSet) {
|
|
94
|
+
throw new Error('--offer-set is required');
|
|
95
|
+
}
|
|
96
|
+
return {
|
|
97
|
+
workspaceId: input.workspaceId,
|
|
98
|
+
funnelId: input.funnelId,
|
|
99
|
+
...(UUID_PATTERN.test(offerSet) ? { offerSetId: offerSet } : { offerSetKey: offerSet }),
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
export function buildGeneratedConfigSyncInput(input) {
|
|
103
|
+
return {
|
|
104
|
+
workspaceId: input.workspaceId,
|
|
105
|
+
funnelId: input.funnelId,
|
|
106
|
+
kinds: input.kinds,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
export function selectGeneratedConfigFiles(files, kinds) {
|
|
110
|
+
const allowedPaths = new Set(kinds.map((kind) => GENERATED_CONFIG_PATH_BY_KIND[kind]));
|
|
111
|
+
return files.filter((file) => allowedPaths.has(file.path));
|
|
112
|
+
}
|
|
86
113
|
export function assertCanDraftSyncSource(status) {
|
|
87
114
|
if (status.connection && status.connection.status !== 'disconnected') {
|
|
88
115
|
throw new Error('This funnel is connected to GitHub. Commit and push changes with git, then run `fgrove github pull` to sync the hosted draft. Do not use `fgrove sync up` for the same change.');
|
|
@@ -352,6 +379,22 @@ const syncGitHubDraftForCli = async (input) => {
|
|
|
352
379
|
console.log(summary);
|
|
353
380
|
}
|
|
354
381
|
};
|
|
382
|
+
const formatGeneratedFileCount = (count) => `${count} generated config ${count === 1 ? 'file' : 'files'}`;
|
|
383
|
+
const syncGeneratedConfigFilesForCli = async (input) => {
|
|
384
|
+
const result = await callApi({
|
|
385
|
+
path: 'funnels.generatedConfigFiles',
|
|
386
|
+
type: 'query',
|
|
387
|
+
token: input.token,
|
|
388
|
+
data: buildGeneratedConfigSyncInput({
|
|
389
|
+
workspaceId: input.target.workspaceId,
|
|
390
|
+
funnelId: input.target.funnelId,
|
|
391
|
+
kinds: input.kinds,
|
|
392
|
+
}),
|
|
393
|
+
});
|
|
394
|
+
const files = selectGeneratedConfigFiles(result.files, input.kinds);
|
|
395
|
+
await writeSourceFiles(input.target.sourceDir, files);
|
|
396
|
+
return files;
|
|
397
|
+
};
|
|
355
398
|
const addExamples = (command, examples) => command.addHelpText('after', `\nExamples:\n${examples.map((example) => ` $ ${example}`).join('\n')}`);
|
|
356
399
|
const program = new Command();
|
|
357
400
|
program
|
|
@@ -525,6 +568,80 @@ addExamples(funnelsCommand
|
|
|
525
568
|
});
|
|
526
569
|
console.log(`${result.funnel.id}\t${result.funnel.name}\t${result.funnel.slug}`);
|
|
527
570
|
});
|
|
571
|
+
const offerSetsCommand = addExamples(program.command('offer-sets').alias('offers').description('Manage project offer sets'), [
|
|
572
|
+
'fgrove offer-sets sync --offer-set default-paywall',
|
|
573
|
+
'fgrove offers sync --funnel claimbee-ios --offer-set three-month-test',
|
|
574
|
+
]);
|
|
575
|
+
addExamples(offerSetsCommand
|
|
576
|
+
.command('sync')
|
|
577
|
+
.description('Apply an offer set to a funnel draft billing snapshot')
|
|
578
|
+
.option('--workspace <id-or-slug-or-name>', 'Workspace id, slug, or name')
|
|
579
|
+
.option('--funnel <id-or-slug>', 'Funnel id or slug')
|
|
580
|
+
.option('--dir <path>', 'Local source directory for reading sync manifest', '.')
|
|
581
|
+
.requiredOption('--offer-set <key-or-id>', 'Offer set key or id'), [
|
|
582
|
+
'fgrove offer-sets sync --offer-set default-paywall',
|
|
583
|
+
'fgrove offers sync --funnel claimbee-ios --offer-set quarterly-test',
|
|
584
|
+
])
|
|
585
|
+
.action(async (options) => {
|
|
586
|
+
const token = await readAuthToken();
|
|
587
|
+
const target = await resolveSyncTarget({
|
|
588
|
+
token,
|
|
589
|
+
workspace: options.workspace,
|
|
590
|
+
funnel: options.funnel,
|
|
591
|
+
dir: options.dir,
|
|
592
|
+
});
|
|
593
|
+
const result = await callApi({
|
|
594
|
+
path: 'funnels.syncOfferSetPlans',
|
|
595
|
+
type: 'mutation',
|
|
596
|
+
token,
|
|
597
|
+
data: buildSyncOfferSetPlansInput({
|
|
598
|
+
workspaceId: target.workspaceId,
|
|
599
|
+
funnelId: target.funnelId,
|
|
600
|
+
offerSet: options.offerSet,
|
|
601
|
+
}),
|
|
602
|
+
});
|
|
603
|
+
const generatedFiles = await syncGeneratedConfigFilesForCli({
|
|
604
|
+
token,
|
|
605
|
+
target,
|
|
606
|
+
kinds: ['offerSets'],
|
|
607
|
+
});
|
|
608
|
+
await syncGitHubDraftForCli({
|
|
609
|
+
token,
|
|
610
|
+
workspaceId: target.workspaceId,
|
|
611
|
+
funnelId: target.funnelId,
|
|
612
|
+
});
|
|
613
|
+
const offerSetLabel = result.offerSet?.key || result.offerSet?.display_name || options.offerSet.trim();
|
|
614
|
+
console.log(`Synced offer set ${offerSetLabel} to funnel draft`);
|
|
615
|
+
console.log(`Wrote ${formatGeneratedFileCount(generatedFiles.length)}`);
|
|
616
|
+
});
|
|
617
|
+
const experimentsCommand = addExamples(program.command('experiments').description('Manage funnel experiments'), [
|
|
618
|
+
'fgrove experiments sync',
|
|
619
|
+
'fgrove experiments sync --funnel claimbee-ios',
|
|
620
|
+
]);
|
|
621
|
+
addExamples(experimentsCommand
|
|
622
|
+
.command('sync')
|
|
623
|
+
.description('Write generated experiment config for a funnel')
|
|
624
|
+
.option('--workspace <id-or-slug-or-name>', 'Workspace id, slug, or name')
|
|
625
|
+
.option('--funnel <id-or-slug>', 'Funnel id or slug')
|
|
626
|
+
.option('--dir <path>', 'Local source directory for reading sync manifest', '.'), [
|
|
627
|
+
'fgrove experiments sync',
|
|
628
|
+
'fgrove experiments sync --funnel claimbee-ios --dir ./claimbee-ios',
|
|
629
|
+
])
|
|
630
|
+
.action(async (options) => {
|
|
631
|
+
const token = await readAuthToken();
|
|
632
|
+
const target = await resolveSyncTarget({
|
|
633
|
+
token,
|
|
634
|
+
workspace: options.workspace,
|
|
635
|
+
funnel: options.funnel,
|
|
636
|
+
dir: options.dir,
|
|
637
|
+
});
|
|
638
|
+
const generatedFiles = await syncGeneratedConfigFilesForCli({
|
|
639
|
+
token,
|
|
640
|
+
target,
|
|
641
|
+
kinds: ['experiments'],
|
|
642
|
+
});
|
|
643
|
+
console.log(`Wrote ${formatGeneratedFileCount(generatedFiles.length)}`);
|
|
644
|
+
});
|
|
528
645
|
const analyticsCommand = addExamples(program.command('analytics').description('Download and inspect project analytics'), [
|
|
529
646
|
'fgrove analytics conversions --project claimbee --funnel claimbee-ios --date 2026-06-11 --format json --out analytics.json',
|
|
530
647
|
'fgrove analytics funnel-path --project claimbee --funnel claimbee-ios --date 2026-06-11',
|
|
@@ -794,6 +911,11 @@ addExamples(syncCommand
|
|
|
794
911
|
});
|
|
795
912
|
await mkdir(target.sourceDir, { recursive: true });
|
|
796
913
|
await writeSourceFiles(target.sourceDir, result.files);
|
|
914
|
+
const generatedFiles = await syncGeneratedConfigFilesForCli({
|
|
915
|
+
token,
|
|
916
|
+
target,
|
|
917
|
+
kinds: SYNC_DOWN_GENERATED_CONFIG_KINDS,
|
|
918
|
+
});
|
|
797
919
|
await writeLocalEnvFile(target.sourceDir, result.envFile);
|
|
798
920
|
await ensureGitignore(target.sourceDir);
|
|
799
921
|
await writeSyncManifest(target.sourceDir, await buildSyncManifest(target.sourceDir, {
|
|
@@ -802,6 +924,7 @@ addExamples(syncCommand
|
|
|
802
924
|
draftVersionId: result.draftVersionId,
|
|
803
925
|
}));
|
|
804
926
|
console.log(`Synced ${result.files.length} files to ${target.sourceDir}`);
|
|
927
|
+
console.log(`Wrote ${formatGeneratedFileCount(generatedFiles.length)}`);
|
|
805
928
|
});
|
|
806
929
|
const envCommand = addExamples(program.command('env').description('Manage local funnel environment files'), [
|
|
807
930
|
'fgrove env pull --dir ./claimbee-ios',
|
package/dist/localSync.js
CHANGED
|
@@ -198,12 +198,12 @@ function formatByteSize(byteCount) {
|
|
|
198
198
|
async function readSourceFiles(rootDir, manifestFiles) {
|
|
199
199
|
const files = await Promise.all(manifestFiles.map(async (file) => {
|
|
200
200
|
const absolutePath = path.join(rootDir, assertSafeSyncPath(file.path));
|
|
201
|
-
const
|
|
201
|
+
const binaryContentType = inferBinaryContentType(file.path);
|
|
202
202
|
const buffer = await readFile(absolutePath);
|
|
203
203
|
return {
|
|
204
204
|
path: file.path,
|
|
205
|
-
content:
|
|
206
|
-
contentType:
|
|
205
|
+
content: binaryContentType ? `data:${binaryContentType};base64,${buffer.toString('base64')}` : buffer.toString('utf8'),
|
|
206
|
+
contentType: binaryContentType || 'text/plain',
|
|
207
207
|
};
|
|
208
208
|
}));
|
|
209
209
|
return files.sort((left, right) => left.path.localeCompare(right.path));
|
|
@@ -242,27 +242,31 @@ async function collectSyncFiles(rootDir, dir) {
|
|
|
242
242
|
async function hashFile(filePath) {
|
|
243
243
|
return createHash('sha256').update(await readFile(filePath)).digest('hex');
|
|
244
244
|
}
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
245
|
+
const BINARY_CONTENT_TYPE_BY_EXTENSION = {
|
|
246
|
+
'.aac': 'audio/aac',
|
|
247
|
+
'.avif': 'image/avif',
|
|
248
|
+
'.flac': 'audio/flac',
|
|
249
|
+
'.gif': 'image/gif',
|
|
250
|
+
'.jpeg': 'image/jpeg',
|
|
251
|
+
'.jpg': 'image/jpeg',
|
|
252
|
+
'.m4a': 'audio/mp4',
|
|
253
|
+
'.m4v': 'video/mp4',
|
|
254
|
+
'.mov': 'video/quicktime',
|
|
255
|
+
'.mp3': 'audio/mpeg',
|
|
256
|
+
'.mp4': 'video/mp4',
|
|
257
|
+
'.oga': 'audio/ogg',
|
|
258
|
+
'.ogg': 'audio/ogg',
|
|
259
|
+
'.ogv': 'video/ogg',
|
|
260
|
+
'.opus': 'audio/ogg',
|
|
261
|
+
'.png': 'image/png',
|
|
262
|
+
'.svg': 'image/svg+xml',
|
|
263
|
+
'.wav': 'audio/wav',
|
|
264
|
+
'.weba': 'audio/webm',
|
|
265
|
+
'.webm': 'video/webm',
|
|
266
|
+
'.webp': 'image/webp',
|
|
267
|
+
};
|
|
268
|
+
function inferBinaryContentType(filePath) {
|
|
269
|
+
return BINARY_CONTENT_TYPE_BY_EXTENSION[path.extname(filePath).toLowerCase()] || null;
|
|
266
270
|
}
|
|
267
271
|
function decodeDataUrl(content) {
|
|
268
272
|
const match = content.trim().match(/^data:([^;,]+);base64,([A-Za-z0-9+/=\s_-]+)$/);
|
package/package.json
CHANGED
package/template_docs/AGENTS.md
CHANGED
|
@@ -8,7 +8,7 @@ Source-of-truth guide for editing this synced FunnelsGrove funnel. Read the matc
|
|
|
8
8
|
| --- | --- | --- | --- |
|
|
9
9
|
| Add or edit a step | [docs/editing-step.md](docs/editing-step.md) + [docs/step-ui-guidelines.md](docs/step-ui-guidelines.md) | `src/steps/*` + registries + manifest | < 3 min to first preview |
|
|
10
10
|
| Change step order / branching | [docs/editing-flow.md](docs/editing-flow.md) | `src/config/funnel.manifest.ts` | < 2 min |
|
|
11
|
-
| Set up an A/B experiment | [docs/ab-experiments.md](docs/ab-experiments.md) | `src/config/experiments.ts
|
|
11
|
+
| Set up an A/B experiment | [docs/ab-experiments.md](docs/ab-experiments.md) | FunnelsGrove UI/API + `src/config/experiments.generated.ts`; manifest steps/edges | < 1 min when variant step exists |
|
|
12
12
|
| Edit copy or images | [docs/editor-and-content.md](docs/editor-and-content.md) | `src/steps/content/*.content.ts` | < 2 min |
|
|
13
13
|
| Edit image loading/performance | [docs/editing-flow.md](docs/editing-flow.md) + [docs/step-ui-guidelines.md](docs/step-ui-guidelines.md) + [docs/publishing-and-versioning.md](docs/publishing-and-versioning.md) | `src/config/funnel.manifest.ts`, `src/components/FunnelFlow.tsx`, image assets | careful, QA required |
|
|
14
14
|
| Change plans, prices, discounts | [docs/payment-plans-and-discounts.md](docs/payment-plans-and-discounts.md) | `src/config/billing.plans.ts` | careful, read doc fully |
|
|
@@ -63,7 +63,7 @@ with the same local source diff.
|
|
|
63
63
|
4. **Goal-driven execution.** Define the success check before editing ("step renders at all default breakpoints and Continue advances to step-X"), then loop until it passes.
|
|
64
64
|
5. **Image performance locked.** Keep build-time raster compression plus AVIF/WebP variants enabled. For funnel step images, use `funnelManifest.assets` + step `assetIds`, priority/preload only for first-viewport images, and low-priority next-step warming from the shell. Do not preload the whole funnel image set.
|
|
65
65
|
6. **Meaningful URLs.** New step `path` values are public product routes, so use readable slugs like `/motivation`, `/fitness-goal`, or `/email-capture`. Sequential ids and `step-NN-*` filenames are okay for ordering, but do not create public routes like `/step-1`.
|
|
66
|
-
7. **Flow labeling hygiene.** When changing `edgesByStepId`, keep manifest `branches` current for conditional paths that own steps before reconverging. Keep builder metadata ClaimBee-style and source-readable: `edgesByStepId` keys/targets and `branches: [...]` must use inline string literals, not `someStep.id` variables or
|
|
66
|
+
7. **Flow labeling hygiene.** When changing `edgesByStepId`, keep manifest `branches` current for conditional paths that own steps before reconverging. Keep builder metadata ClaimBee-style and source-readable: `edgesByStepId` keys/targets and `branches: [...]` must use inline string literals, not `someStep.id` variables or indirection like `branches: flowBranches`. Each branch needs a readable `name`, answer-derived `label` such as `yes-branch`, useful `tags`, and the owned `stepIds`. Every experiment control or variant route target must be a real manifest step with normal outgoing edges. Prefer creating/updating experiments through the FunnelsGrove UI/API so the platform owns the database row, PostHog flag, and generated `src/config/experiments.generated.ts`; keep `src/config/experiments.ts` as the generated compatibility wrapper when possible. If a code-authored experiment is necessary, keep `sourceStepId`/`stepId`, variant labels, traffic percentages, and route target ids as explicit literals so deploy sync and analytics can import it. Running experiment variants need labels/tags like `paywall-test-control` and `paywall-test-variant-b`; stopped A/B variants or other inactive screens should keep stable tags and remain unreachable from the default flow so builder marks them `unused`.
|
|
67
67
|
|
|
68
68
|
Architecture docs are part of the change: if you change routing, runtime state, SDK contracts, URL handoff parameters, checkout behavior, analytics events, or shared package boundaries, update the matching doc in the same change.
|
|
69
69
|
|
|
@@ -1,10 +1,53 @@
|
|
|
1
1
|
# A/B Experiments
|
|
2
2
|
|
|
3
|
-
Experiments are config, not code
|
|
3
|
+
Experiments are config, not component code. Prefer creating or updating the
|
|
4
|
+
experiment through the FunnelsGrove UI/API, which owns the database row,
|
|
5
|
+
PostHog flag, and generated `src/config/experiments.generated.ts` file. Keep
|
|
6
|
+
`src/config/experiments.ts` as the generated compatibility wrapper when
|
|
7
|
+
possible. Make sure every control or variant route target is a real manifest
|
|
8
|
+
step with normal outgoing edges. Never write variant conditionals inside step
|
|
9
|
+
components. If the variant step already exists, setting up the experiment is
|
|
10
|
+
mostly config plus publish sync.
|
|
4
11
|
|
|
5
12
|
## Recipe: Step or Paywall Test
|
|
6
13
|
|
|
7
|
-
Open `src/config/
|
|
14
|
+
Open `src/config/funnel.manifest.ts` and make sure every control and variant
|
|
15
|
+
screen exists in `steps` with readable tags:
|
|
16
|
+
|
|
17
|
+
```ts
|
|
18
|
+
steps: [
|
|
19
|
+
{ id: 'paywall', path: '/offer', tags: ['paywall-ab-control'], /* ... */ },
|
|
20
|
+
{ id: 'paywall-b', path: '/offer-b', tags: ['paywall-ab-variant-b'], /* ... */ },
|
|
21
|
+
]
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Add normal outgoing edges for both routes:
|
|
25
|
+
|
|
26
|
+
```ts
|
|
27
|
+
edgesByStepId: {
|
|
28
|
+
paywall: [{ toStepId: 'checkout' }],
|
|
29
|
+
'paywall-b': [{ toStepId: 'checkout' }],
|
|
30
|
+
}
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Then create the experiment in the FunnelsGrove UI/API and pull the generated
|
|
34
|
+
config. The synced source should export it from `src/config/experiments.generated.ts`
|
|
35
|
+
and keep `src/config/experiments.ts` as a wrapper:
|
|
36
|
+
|
|
37
|
+
```ts
|
|
38
|
+
import {
|
|
39
|
+
defineFunnelExperiments,
|
|
40
|
+
toManifestExperiments,
|
|
41
|
+
} from '@funnelsgrove/runtime';
|
|
42
|
+
import { generatedExperiments } from './experiments.generated';
|
|
43
|
+
|
|
44
|
+
export const experiments = defineFunnelExperiments(generatedExperiments);
|
|
45
|
+
|
|
46
|
+
export const manifestExperiments = toManifestExperiments(experiments);
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
If a code-authored experiment is necessary, add one explicit object inside the
|
|
50
|
+
local experiment array or `defineFunnelExperiments([...])` call:
|
|
8
51
|
|
|
9
52
|
```ts
|
|
10
53
|
export const experiments = defineFunnelExperiments([
|
|
@@ -12,23 +55,37 @@ export const experiments = defineFunnelExperiments([
|
|
|
12
55
|
id: 'paywall-ab', // durable, readable, never reused
|
|
13
56
|
name: 'Paywall copy test',
|
|
14
57
|
type: 'paywall', // 'step' for quiz steps, 'paywall' for paywall tests
|
|
15
|
-
status: 'running', //
|
|
58
|
+
status: 'running', // non-running entries stay out of active routing
|
|
16
59
|
launchDate: '2026-06-12T00:00:00.000Z',
|
|
17
|
-
|
|
18
|
-
|
|
60
|
+
stepId: 'paywall',
|
|
61
|
+
variants: [
|
|
62
|
+
{ variantKey: 'control', stepId: 'paywall', label: 'paywall-ab-control', trafficPercent: 50 },
|
|
63
|
+
{ variantKey: 'variant_b', stepId: 'paywall-b', label: 'paywall-ab-variant-b', trafficPercent: 50 },
|
|
64
|
+
],
|
|
19
65
|
},
|
|
20
66
|
] as const);
|
|
21
67
|
```
|
|
22
68
|
|
|
23
|
-
|
|
69
|
+
Preconditions:
|
|
24
70
|
|
|
25
|
-
1.
|
|
71
|
+
1. Every control or variant `stepId` exists in the manifest `steps` array.
|
|
26
72
|
2. Both steps route to the same next step, unless the experiment is explicitly about the flow.
|
|
73
|
+
3. `steps`, `edgesByStepId`, experiment `stepId`, and experiment `label` values use explicit string literals that analytics can read.
|
|
74
|
+
|
|
75
|
+
The manifest may keep `experiments: manifestExperiments` and import the
|
|
76
|
+
runnable experiment shape from `src/config/experiments.ts`. That is fine as
|
|
77
|
+
long as the separate file exports a generated wrapper or a source-readable
|
|
78
|
+
`experiments` array. Do not hide step ids or labels behind computed variables.
|
|
79
|
+
For route experiments where the source step differs from the rendered control
|
|
80
|
+
step, set `sourceStepId` or `stepId` to the source step and set each variant's
|
|
81
|
+
`stepId` to its rendered route.
|
|
27
82
|
|
|
28
83
|
## How the Runtime Behaves
|
|
29
84
|
|
|
30
85
|
When a visitor opens the source step, the runtime suspends rendering until the assignment from `@funnelsgrove/analytics` is ready, then routes to the assigned `stepId` and syncs the URL. After assignment, navigation continues through the assigned step's normal edges — the experiment is not re-evaluated mid-flow. Assignments are sticky per visitor.
|
|
31
86
|
|
|
87
|
+
Feature-flag evaluation is scoped by the public project and funnel ids (`NEXT_PUBLIC_PROJECT_ID` and `NEXT_PUBLIC_FUNNEL_ID`). The shared runtime bootstrap fills that scope from public env when callers omit it; if a local funnel owns a custom controller, keep those ids wired into PostHog flag bootstrap or verify the runtime fallback is still in place.
|
|
88
|
+
|
|
32
89
|
The paywall runtime is experiment-ready out of the box: paywall variants can differ in copy, layout, plan presentation, or pricing source, and checkout/discount state stays scoped per funnel.
|
|
33
90
|
|
|
34
91
|
## Verify (part of the < 1 min setup)
|
|
@@ -46,7 +103,8 @@ Check: opening the source URL promotes to the assigned route, each variant rende
|
|
|
46
103
|
|
|
47
104
|
- One deliberate change per experiment. Don't bundle unrelated edits into a variant.
|
|
48
105
|
- `control` stays stable; never edit the control step as part of launching a variant.
|
|
49
|
-
- Add stable
|
|
106
|
+
- Add stable variant `label` values in the `<experiment-id>-control` / `<experiment-id>-variant-b` format so builder labels and analytics views stay readable.
|
|
107
|
+
- Add matching `steps[].tags` to every control and variant step.
|
|
50
108
|
- Variant keys and experiment ids are durable — they flow into analytics. Never recycle an id for a different hypothesis.
|
|
51
109
|
- Keep every variant step's outgoing edge in `edgesByStepId`, or assigned visitors strand.
|
|
52
110
|
- If a paywall variant changes plans or pricing, follow [payment-plans-and-discounts.md](payment-plans-and-discounts.md) for the plan/discount sync rules and QA both variants' checkout ([qa-checklist.md](qa-checklist.md)).
|
|
@@ -15,10 +15,10 @@ The manifest defines:
|
|
|
15
15
|
variants. Funnel shells may use this data to warm likely next-step images,
|
|
16
16
|
but first-viewport images should still use the framework's normal
|
|
17
17
|
priority/preload mechanism.
|
|
18
|
-
- `steps`: every routable step with `id`, `path`, `filePath`, `componentKey`, `type`, optional `kind`, optional `tags`, and optional `assetIds`. New `path` values must be meaningful public route slugs, not `/step-1` style URLs. Sequential ids are acceptable when the funnel uses them internally.
|
|
18
|
+
- `steps`: every routable step with `id`, `path`, `filePath`, `componentKey`, `type`, optional `kind`, optional `tags`, and optional `assetIds`. This includes experiment variant screens and intentionally inactive screens that should appear as `unused` in builder. New `path` values must be meaningful public route slugs, not `/step-1` style URLs. Sequential ids are acceptable when the funnel uses them internally.
|
|
19
19
|
- `edgesByStepId`: graph edges between steps.
|
|
20
20
|
- `branches`: builder metadata for conditional paths that own one or more steps before reconverging.
|
|
21
|
-
- `experiments`: optional variant routing.
|
|
21
|
+
- `experiments`: optional variant routing. This may be imported from `src/config/experiments.ts` when that file keeps source-readable experiment definitions.
|
|
22
22
|
|
|
23
23
|
Keep `steps[].id`, `path`, and answer keys stable unless the request is a migration.
|
|
24
24
|
When adding a step, prefer a semantic path like `/motivation`, `/fitness-goal`,
|
|
@@ -101,6 +101,13 @@ Experiments attach to a step and route to variant steps:
|
|
|
101
101
|
}
|
|
102
102
|
```
|
|
103
103
|
|
|
104
|
+
Keep every control and variant route target as a real step in
|
|
105
|
+
`funnel.manifest.ts`. The runnable experiment entry may live in
|
|
106
|
+
`src/config/experiments.ts` and be imported into the manifest as
|
|
107
|
+
`experiments: manifestExperiments`; if so, keep the experiment `stepId`, labels,
|
|
108
|
+
and traffic as explicit string/number literals so analytics can parse them.
|
|
109
|
+
Do not hide step ids or labels behind computed variables.
|
|
110
|
+
|
|
104
111
|
The runtime resolves assignments through the shared analytics/runtime integration outside preview and uses editor overrides inside preview/editor mode. Opening the source step waits for the assignment, then opens the assigned route and keeps the URL in sync. Continue then advances from the assigned route's normal graph edge, with the already-applied experiment ignored for that continuation.
|
|
105
112
|
|
|
106
113
|
Keep experiment redirects out of step components. Add normal `edgesByStepId` entries for the source/control step and every variant step, then let `goNext()` or the shell Continue button use the shared runtime.
|
|
@@ -112,6 +119,10 @@ Keep the control variant stable and do not remove a running variant until analyt
|
|
|
112
119
|
- Add or update the manifest step.
|
|
113
120
|
- Register the component in `src/runtime/step-registry.ts`.
|
|
114
121
|
- Update `edgesByStepId`, `entryPoints`, `branches`, step `tags`, and `assetIds` if needed.
|
|
122
|
+
- Add every experiment control and variant route target to `steps`.
|
|
123
|
+
- Add normal outgoing `edgesByStepId` entries for every experiment route target.
|
|
124
|
+
- Add each running experiment to `src/config/experiments.ts` or the manifest's inline `experiments` array, matching the local funnel pattern.
|
|
125
|
+
- Keep experiment config synchronized with manifest steps, tags, and edges.
|
|
115
126
|
- Label every branch-owned path with `branches[].name`, answer-derived `label`, useful `tags`, and owned `stepIds`.
|
|
116
127
|
- Keep builder metadata inline/literal so groups, labels, and `unused` badges are visible in builder.
|
|
117
128
|
- Label every running experiment variant with stable tags/labels like `<experiment-id>-control` and `<experiment-id>-variant-b`.
|