@funnelsgrove/cli 0.1.9 → 0.1.11
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/README.md +17 -0
- package/dist/analyticsOutput.d.ts +276 -0
- package/dist/analyticsOutput.js +254 -0
- package/dist/cli.d.ts +14 -0
- package/dist/cli.js +288 -3
- package/package.json +1 -1
- package/template_docs/AGENTS.md +40 -91
- package/template_docs/docs/ab-experiments.md +39 -20
- package/template_docs/docs/editing-flow.md +20 -5
- package/template_docs/docs/editing-step.md +105 -46
- package/template_docs/docs/payment-plans-and-discounts.md +11 -0
- package/template_docs/docs/publishing-and-versioning.md +12 -0
- package/template_docs/docs/qa-checklist.md +73 -0
- package/template_docs/docs/step-ui-guidelines.md +106 -0
package/dist/cli.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { readFileSync } from 'node:fs';
|
|
3
|
-
import { cp, mkdir } from 'node:fs/promises';
|
|
2
|
+
import { readFileSync, realpathSync } from 'node:fs';
|
|
3
|
+
import { cp, mkdir, writeFile } from 'node:fs/promises';
|
|
4
4
|
import { createInterface } from 'node:readline/promises';
|
|
5
5
|
import path from 'node:path';
|
|
6
6
|
import { fileURLToPath } from 'node:url';
|
|
@@ -13,6 +13,7 @@ import { formatGitHubConnectRows, formatGitHubJobRows, formatGitHubStatusRows, }
|
|
|
13
13
|
import { syncGitHubDraftIfConnected } from './githubSyncFlow.js';
|
|
14
14
|
import { isKnownTemplate, KNOWN_TEMPLATE_SLUGS, reskinFunnel } from './reskin.js';
|
|
15
15
|
import { syncTemplateDocs, TEMPLATE_DOCS_DIR } from './templateDocs.js';
|
|
16
|
+
import { assertHasCohortData, assertHasConversionData, assertHasFunnelPathData, assertHasTransitionData, buildCohortReportPayload, buildConversionReportPayload, buildFunnelPathReportPayload, buildTransitionReportPayload, formatAnalyticsJson, formatCohortTable, formatConversionsTable, formatFunnelPathTable, formatTransitionsTable, normalizeAnalyticsOutputFormat, parseAnalyticsDate, } from './analyticsOutput.js';
|
|
16
17
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
17
18
|
const readCliVersion = () => {
|
|
18
19
|
try {
|
|
@@ -50,6 +51,28 @@ export function resolveSyncTargetIds(input) {
|
|
|
50
51
|
funnelId: input.explicitFunnelId || input.manifest?.funnelId || input.active?.funnelId,
|
|
51
52
|
};
|
|
52
53
|
}
|
|
54
|
+
export function isCliEntrypoint(invokedPath, modulePath, realpath = realpathSync) {
|
|
55
|
+
if (!invokedPath) {
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
try {
|
|
59
|
+
return realpath(path.resolve(invokedPath)) === realpath(modulePath);
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
return path.resolve(invokedPath) === path.resolve(modulePath);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
export function buildAnalyticsCommandInput(options) {
|
|
66
|
+
const parsedDate = parseAnalyticsDate(options.date);
|
|
67
|
+
const timezone = options.timezone?.trim() || undefined;
|
|
68
|
+
return {
|
|
69
|
+
date: parsedDate.date,
|
|
70
|
+
from: parsedDate.fromIso,
|
|
71
|
+
to: parsedDate.toIso,
|
|
72
|
+
format: normalizeAnalyticsOutputFormat(options.format),
|
|
73
|
+
timezone,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
53
76
|
const getApiUrl = () => {
|
|
54
77
|
const options = program.opts();
|
|
55
78
|
return options.apiUrl || process.env.FUNNELSGROVE_API_URL || DEFAULT_API_URL;
|
|
@@ -253,6 +276,34 @@ const resolveSyncTarget = async (input) => {
|
|
|
253
276
|
manifest,
|
|
254
277
|
};
|
|
255
278
|
};
|
|
279
|
+
const resolveAnalyticsProject = async (input) => {
|
|
280
|
+
const active = await loadActiveContext(getConfigPath());
|
|
281
|
+
const project = input.project || (active?.workspaceId === input.workspaceId ? active.projectId : undefined);
|
|
282
|
+
if (!project) {
|
|
283
|
+
throw new Error('No project selected. Pass `--project <id-or-slug>` or set one with `fgrove use --project <id-or-slug>`.');
|
|
284
|
+
}
|
|
285
|
+
return resolveProject(input.token, input.workspaceId, project);
|
|
286
|
+
};
|
|
287
|
+
const resolveAnalyticsFunnel = async (input) => {
|
|
288
|
+
const active = await loadActiveContext(getConfigPath());
|
|
289
|
+
const funnel = input.funnel || (active?.workspaceId === input.workspaceId ? active.funnelId : undefined);
|
|
290
|
+
if (!funnel) {
|
|
291
|
+
throw new Error('No funnel selected. Pass `--funnel <id-or-slug>` or set one with `fgrove use --funnel <id-or-slug>`.');
|
|
292
|
+
}
|
|
293
|
+
return resolveFunnel(input.token, input.workspaceId, funnel);
|
|
294
|
+
};
|
|
295
|
+
const writeOrPrintAnalyticsOutput = async (input) => {
|
|
296
|
+
const output = input.format === 'json'
|
|
297
|
+
? formatAnalyticsJson(input.payload)
|
|
298
|
+
: input.table;
|
|
299
|
+
if (input.out?.trim()) {
|
|
300
|
+
const outputPath = path.resolve(process.cwd(), input.out);
|
|
301
|
+
await writeFile(outputPath, output, 'utf8');
|
|
302
|
+
console.log(`Wrote ${outputPath}`);
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
process.stdout.write(output);
|
|
306
|
+
};
|
|
256
307
|
const printRows = (rows, columns) => {
|
|
257
308
|
for (const row of rows) {
|
|
258
309
|
console.log(columns.map((column) => row[column] || '').join('\t'));
|
|
@@ -459,6 +510,240 @@ addExamples(funnelsCommand
|
|
|
459
510
|
});
|
|
460
511
|
console.log(`${result.funnel.id}\t${result.funnel.name}\t${result.funnel.slug}`);
|
|
461
512
|
});
|
|
513
|
+
const analyticsCommand = addExamples(program.command('analytics').description('Download and inspect project analytics'), [
|
|
514
|
+
'fgrove analytics conversions --project claimbee --funnel claimbee-ios --date 2026-06-11 --format json --out analytics.json',
|
|
515
|
+
'fgrove analytics funnel-path --project claimbee --funnel claimbee-ios --date 2026-06-11',
|
|
516
|
+
'fgrove analytics transitions --project claimbee --funnel claimbee-ios --date 2026-06-11',
|
|
517
|
+
'fgrove analytics cohort --project claimbee --date 2026-06-11 --format json',
|
|
518
|
+
]);
|
|
519
|
+
const loadFunnelAnalyticsContext = async (options) => {
|
|
520
|
+
const token = await readAuthToken();
|
|
521
|
+
const workspace = await resolveWorkspace(token, options.workspace);
|
|
522
|
+
const project = await resolveAnalyticsProject({
|
|
523
|
+
token,
|
|
524
|
+
workspaceId: workspace.id,
|
|
525
|
+
project: options.project,
|
|
526
|
+
});
|
|
527
|
+
const funnel = await resolveAnalyticsFunnel({
|
|
528
|
+
token,
|
|
529
|
+
workspaceId: workspace.id,
|
|
530
|
+
funnel: options.funnel,
|
|
531
|
+
});
|
|
532
|
+
return {
|
|
533
|
+
token,
|
|
534
|
+
workspace,
|
|
535
|
+
project,
|
|
536
|
+
funnel,
|
|
537
|
+
commandInput: buildAnalyticsCommandInput(options),
|
|
538
|
+
};
|
|
539
|
+
};
|
|
540
|
+
const loadProjectAnalyticsContext = async (options) => {
|
|
541
|
+
const token = await readAuthToken();
|
|
542
|
+
const workspace = await resolveWorkspace(token, options.workspace);
|
|
543
|
+
const project = await resolveAnalyticsProject({
|
|
544
|
+
token,
|
|
545
|
+
workspaceId: workspace.id,
|
|
546
|
+
project: options.project,
|
|
547
|
+
});
|
|
548
|
+
return {
|
|
549
|
+
token,
|
|
550
|
+
workspace,
|
|
551
|
+
project,
|
|
552
|
+
commandInput: buildAnalyticsCommandInput(options),
|
|
553
|
+
};
|
|
554
|
+
};
|
|
555
|
+
const fetchStepDropoffAnalytics = async (input) => {
|
|
556
|
+
return callApi({
|
|
557
|
+
path: 'projects.analyticsStepDropoff',
|
|
558
|
+
type: 'query',
|
|
559
|
+
token: input.token,
|
|
560
|
+
data: {
|
|
561
|
+
workspaceId: input.workspaceId,
|
|
562
|
+
projectId: input.projectId,
|
|
563
|
+
funnelId: input.funnelId,
|
|
564
|
+
from: input.commandInput.from,
|
|
565
|
+
to: input.commandInput.to,
|
|
566
|
+
timezone: input.commandInput.timezone,
|
|
567
|
+
},
|
|
568
|
+
});
|
|
569
|
+
};
|
|
570
|
+
addExamples(analyticsCommand
|
|
571
|
+
.command('conversions')
|
|
572
|
+
.description('Download one-day conversion data, full funnel path, and step transitions')
|
|
573
|
+
.option('--workspace <id-or-slug-or-name>', 'Workspace id, slug, or name')
|
|
574
|
+
.option('--project <id-or-slug-or-name>', 'Project id, slug, or name')
|
|
575
|
+
.option('--funnel <id-or-slug-or-name>', 'Funnel id, slug, or name')
|
|
576
|
+
.requiredOption('--date <yyyy-mm-dd>', 'Analytics date')
|
|
577
|
+
.option('--format <table-or-json>', 'Output format: table or json', 'table')
|
|
578
|
+
.option('--out <path>', 'Write output to a file')
|
|
579
|
+
.option('--timezone <iana-timezone>', 'Analytics timezone, for example America/Los_Angeles'), [
|
|
580
|
+
'fgrove analytics conversions --project claimbee --funnel claimbee-ios --date 2026-06-11',
|
|
581
|
+
'fgrove analytics conversions --project claimbee --funnel claimbee-ios --date 2026-06-11 --format json --out analytics.json',
|
|
582
|
+
])
|
|
583
|
+
.action(async (options) => {
|
|
584
|
+
const context = await loadFunnelAnalyticsContext(options);
|
|
585
|
+
const [overview, stepDropoff] = await Promise.all([
|
|
586
|
+
callApi({
|
|
587
|
+
path: 'projects.analyticsOverview',
|
|
588
|
+
type: 'query',
|
|
589
|
+
token: context.token,
|
|
590
|
+
data: {
|
|
591
|
+
workspaceId: context.workspace.id,
|
|
592
|
+
projectId: context.project.id,
|
|
593
|
+
funnelId: context.funnel.id,
|
|
594
|
+
from: context.commandInput.from,
|
|
595
|
+
to: context.commandInput.to,
|
|
596
|
+
timezone: context.commandInput.timezone,
|
|
597
|
+
limit: 20_000,
|
|
598
|
+
},
|
|
599
|
+
}),
|
|
600
|
+
fetchStepDropoffAnalytics({
|
|
601
|
+
token: context.token,
|
|
602
|
+
workspaceId: context.workspace.id,
|
|
603
|
+
projectId: context.project.id,
|
|
604
|
+
funnelId: context.funnel.id,
|
|
605
|
+
commandInput: context.commandInput,
|
|
606
|
+
}),
|
|
607
|
+
]);
|
|
608
|
+
assertHasConversionData(context.commandInput.date, overview, stepDropoff);
|
|
609
|
+
const payload = buildConversionReportPayload({
|
|
610
|
+
date: context.commandInput.date,
|
|
611
|
+
workspaceId: context.workspace.id,
|
|
612
|
+
project: context.project,
|
|
613
|
+
overview,
|
|
614
|
+
stepDropoff,
|
|
615
|
+
});
|
|
616
|
+
await writeOrPrintAnalyticsOutput({
|
|
617
|
+
format: context.commandInput.format,
|
|
618
|
+
payload,
|
|
619
|
+
table: formatConversionsTable({
|
|
620
|
+
date: context.commandInput.date,
|
|
621
|
+
overview,
|
|
622
|
+
stepDropoff,
|
|
623
|
+
}),
|
|
624
|
+
out: options.out,
|
|
625
|
+
});
|
|
626
|
+
});
|
|
627
|
+
addExamples(analyticsCommand
|
|
628
|
+
.command('funnel-path')
|
|
629
|
+
.description('Download the full one-day funnel path report')
|
|
630
|
+
.option('--workspace <id-or-slug-or-name>', 'Workspace id, slug, or name')
|
|
631
|
+
.option('--project <id-or-slug-or-name>', 'Project id, slug, or name')
|
|
632
|
+
.option('--funnel <id-or-slug-or-name>', 'Funnel id, slug, or name')
|
|
633
|
+
.requiredOption('--date <yyyy-mm-dd>', 'Analytics date')
|
|
634
|
+
.option('--format <table-or-json>', 'Output format: table or json', 'table')
|
|
635
|
+
.option('--out <path>', 'Write output to a file')
|
|
636
|
+
.option('--timezone <iana-timezone>', 'Analytics timezone, for example America/Los_Angeles'), [
|
|
637
|
+
'fgrove analytics funnel-path --project claimbee --funnel claimbee-ios --date 2026-06-11',
|
|
638
|
+
'fgrove analytics funnel-path --project claimbee --funnel claimbee-ios --date 2026-06-11 --format json',
|
|
639
|
+
])
|
|
640
|
+
.action(async (options) => {
|
|
641
|
+
const context = await loadFunnelAnalyticsContext(options);
|
|
642
|
+
const stepDropoff = await fetchStepDropoffAnalytics({
|
|
643
|
+
token: context.token,
|
|
644
|
+
workspaceId: context.workspace.id,
|
|
645
|
+
projectId: context.project.id,
|
|
646
|
+
funnelId: context.funnel.id,
|
|
647
|
+
commandInput: context.commandInput,
|
|
648
|
+
});
|
|
649
|
+
assertHasFunnelPathData(context.commandInput.date, stepDropoff);
|
|
650
|
+
const payload = buildFunnelPathReportPayload({
|
|
651
|
+
date: context.commandInput.date,
|
|
652
|
+
workspaceId: context.workspace.id,
|
|
653
|
+
project: context.project,
|
|
654
|
+
stepDropoff,
|
|
655
|
+
});
|
|
656
|
+
await writeOrPrintAnalyticsOutput({
|
|
657
|
+
format: context.commandInput.format,
|
|
658
|
+
payload,
|
|
659
|
+
table: formatFunnelPathTable({
|
|
660
|
+
date: context.commandInput.date,
|
|
661
|
+
stepDropoff,
|
|
662
|
+
}),
|
|
663
|
+
out: options.out,
|
|
664
|
+
});
|
|
665
|
+
});
|
|
666
|
+
addExamples(analyticsCommand
|
|
667
|
+
.command('transitions')
|
|
668
|
+
.description('Download one-day step transitions and drop-offs')
|
|
669
|
+
.option('--workspace <id-or-slug-or-name>', 'Workspace id, slug, or name')
|
|
670
|
+
.option('--project <id-or-slug-or-name>', 'Project id, slug, or name')
|
|
671
|
+
.option('--funnel <id-or-slug-or-name>', 'Funnel id, slug, or name')
|
|
672
|
+
.requiredOption('--date <yyyy-mm-dd>', 'Analytics date')
|
|
673
|
+
.option('--format <table-or-json>', 'Output format: table or json', 'table')
|
|
674
|
+
.option('--out <path>', 'Write output to a file')
|
|
675
|
+
.option('--timezone <iana-timezone>', 'Analytics timezone, for example America/Los_Angeles'), [
|
|
676
|
+
'fgrove analytics transitions --project claimbee --funnel claimbee-ios --date 2026-06-11',
|
|
677
|
+
'fgrove analytics transitions --project claimbee --funnel claimbee-ios --date 2026-06-11 --format json',
|
|
678
|
+
])
|
|
679
|
+
.action(async (options) => {
|
|
680
|
+
const context = await loadFunnelAnalyticsContext(options);
|
|
681
|
+
const stepDropoff = await fetchStepDropoffAnalytics({
|
|
682
|
+
token: context.token,
|
|
683
|
+
workspaceId: context.workspace.id,
|
|
684
|
+
projectId: context.project.id,
|
|
685
|
+
funnelId: context.funnel.id,
|
|
686
|
+
commandInput: context.commandInput,
|
|
687
|
+
});
|
|
688
|
+
assertHasTransitionData(context.commandInput.date, stepDropoff);
|
|
689
|
+
const payload = buildTransitionReportPayload({
|
|
690
|
+
date: context.commandInput.date,
|
|
691
|
+
workspaceId: context.workspace.id,
|
|
692
|
+
project: context.project,
|
|
693
|
+
stepDropoff,
|
|
694
|
+
});
|
|
695
|
+
await writeOrPrintAnalyticsOutput({
|
|
696
|
+
format: context.commandInput.format,
|
|
697
|
+
payload,
|
|
698
|
+
table: formatTransitionsTable({
|
|
699
|
+
date: context.commandInput.date,
|
|
700
|
+
stepDropoff,
|
|
701
|
+
}),
|
|
702
|
+
out: options.out,
|
|
703
|
+
});
|
|
704
|
+
});
|
|
705
|
+
addExamples(analyticsCommand
|
|
706
|
+
.command('cohort')
|
|
707
|
+
.description('Download one-day cohort marketing data')
|
|
708
|
+
.option('--workspace <id-or-slug-or-name>', 'Workspace id, slug, or name')
|
|
709
|
+
.option('--project <id-or-slug-or-name>', 'Project id, slug, or name')
|
|
710
|
+
.requiredOption('--date <yyyy-mm-dd>', 'Cohort date')
|
|
711
|
+
.option('--format <table-or-json>', 'Output format: table or json', 'table')
|
|
712
|
+
.option('--out <path>', 'Write output to a file')
|
|
713
|
+
.option('--timezone <iana-timezone>', 'Reserved for consistency with conversion commands'), [
|
|
714
|
+
'fgrove analytics cohort --project claimbee --date 2026-06-11',
|
|
715
|
+
'fgrove analytics cohort --project claimbee --date 2026-06-11 --format json --out cohort.json',
|
|
716
|
+
])
|
|
717
|
+
.action(async (options) => {
|
|
718
|
+
const context = await loadProjectAnalyticsContext(options);
|
|
719
|
+
const cohort = await callApi({
|
|
720
|
+
path: 'projects.marketingCohortPerformance',
|
|
721
|
+
type: 'query',
|
|
722
|
+
token: context.token,
|
|
723
|
+
data: {
|
|
724
|
+
workspaceId: context.workspace.id,
|
|
725
|
+
projectId: context.project.id,
|
|
726
|
+
from: context.commandInput.date,
|
|
727
|
+
to: context.commandInput.date,
|
|
728
|
+
},
|
|
729
|
+
});
|
|
730
|
+
assertHasCohortData(context.commandInput.date, cohort);
|
|
731
|
+
const payload = buildCohortReportPayload({
|
|
732
|
+
date: context.commandInput.date,
|
|
733
|
+
workspaceId: context.workspace.id,
|
|
734
|
+
project: context.project,
|
|
735
|
+
cohort,
|
|
736
|
+
});
|
|
737
|
+
await writeOrPrintAnalyticsOutput({
|
|
738
|
+
format: context.commandInput.format,
|
|
739
|
+
payload,
|
|
740
|
+
table: formatCohortTable({
|
|
741
|
+
date: context.commandInput.date,
|
|
742
|
+
cohort,
|
|
743
|
+
}),
|
|
744
|
+
out: options.out,
|
|
745
|
+
});
|
|
746
|
+
});
|
|
462
747
|
const syncCommand = addExamples(program.command('sync').description('Sync funnel source'), [
|
|
463
748
|
'fgrove sync down --funnel claimbee-ios --dir ./claimbee-ios',
|
|
464
749
|
'fgrove sync up --message "Update copy"',
|
|
@@ -845,7 +1130,7 @@ addExamples(program
|
|
|
845
1130
|
console.log(' npm install');
|
|
846
1131
|
console.log(' npm run dev');
|
|
847
1132
|
});
|
|
848
|
-
if (
|
|
1133
|
+
if (isCliEntrypoint(process.argv[1], fileURLToPath(import.meta.url))) {
|
|
849
1134
|
program.parseAsync().catch((error) => {
|
|
850
1135
|
console.error(error instanceof Error ? error.message : String(error));
|
|
851
1136
|
process.exitCode = 1;
|
package/package.json
CHANGED
package/template_docs/AGENTS.md
CHANGED
|
@@ -1,107 +1,56 @@
|
|
|
1
1
|
# AGENTS.md
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Source-of-truth guide for editing this synced FunnelsGrove funnel. Read the matching topic doc before editing; the docs define the contracts the runtime expects.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
## Find Your Task
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
| Task | Read | Edit | Target time |
|
|
8
|
+
| --- | --- | --- | --- |
|
|
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
|
+
| 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` | < 1 min when variant step exists |
|
|
12
|
+
| Edit copy or images | [docs/editor-and-content.md](docs/editor-and-content.md) | `src/steps/content/*.content.ts` | < 2 min |
|
|
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
|
+
| 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 |
|
|
15
|
+
| Edit paywall or checkout | [docs/payment-plans-and-discounts.md](docs/payment-plans-and-discounts.md) + [docs/step-ui-guidelines.md](docs/step-ui-guidelines.md) | `src/steps/step-32-paywall*.tsx` | careful, QA required |
|
|
16
|
+
| Change colors / fonts | [docs/theme.md](docs/theme.md) | `src/theme/theme.ts` | < 2 min |
|
|
17
|
+
| QA before publish | [docs/qa-checklist.md](docs/qa-checklist.md) | nothing — test and report | as long as it takes |
|
|
18
|
+
| Publish | [docs/publishing-and-versioning.md](docs/publishing-and-versioning.md) | nothing — `fgrove` commands | — |
|
|
8
19
|
|
|
9
|
-
|
|
20
|
+
Deep dives: [Funnel Runtime Architecture](docs/funnel-runtime-architecture.md), [SDK API Endpoints](docs/sdk-api-endpoints.md), [Analytics](docs/analytics.md), [Meta Pixel and Conversions API](docs/meta-pixel-conversions-api.md).
|
|
10
21
|
|
|
11
|
-
Architecture
|
|
22
|
+
## Architecture in 30 Seconds
|
|
12
23
|
|
|
13
|
-
|
|
24
|
+
The funnel is a small Next.js app on shared packages:
|
|
14
25
|
|
|
15
|
-
|
|
26
|
+
- `@funnelsgrove/runtime` — routing, user state, content localization, builder preview, theme.
|
|
27
|
+
- `@funnelsgrove/analytics` — event tracking and A/B assignment (wraps PostHog; never call PostHog directly).
|
|
28
|
+
- `@funnelsgrove/payments` — plans, discounts, Stripe checkout, wallet buttons, shared checkout UI.
|
|
29
|
+
- This repo — product decisions only: step views, content, editor fields, flow manifest, theme, assets, plan ids.
|
|
16
30
|
|
|
17
|
-
|
|
18
|
-
- State your assumptions explicitly. If uncertain, ask.
|
|
19
|
-
- If multiple interpretations exist, present them. Do not pick silently.
|
|
20
|
-
- If a simpler approach exists, say so. Push back when warranted.
|
|
21
|
-
- If something is unclear, stop. Name what's confusing. Ask.
|
|
31
|
+
Most edits touch one step file plus its matching `content/` and `editor/` files, or one config file. If an edit spreads wider than that, stop and re-read the topic doc.
|
|
22
32
|
|
|
23
|
-
##
|
|
33
|
+
## Standard Workflow
|
|
24
34
|
|
|
25
|
-
|
|
35
|
+
1. `fgrove status` — confirm the active project and funnel.
|
|
36
|
+
2. Make the scoped edit per the topic doc.
|
|
37
|
+
3. `npm run test:run && npm run lint` (or the checks this tree defines).
|
|
38
|
+
4. `npm run dev` — verify in local preview at 430x932, spot-check 390x844 ([docs/step-ui-guidelines.md](docs/step-ui-guidelines.md)).
|
|
39
|
+
5. Run the relevant part of [docs/qa-checklist.md](docs/qa-checklist.md).
|
|
40
|
+
6. Ask the user before publishing. Then `fgrove sync up --message '<summary>'` and `fgrove publish --env preview --message '<summary>'`.
|
|
41
|
+
7. QA the preview URL. Production publish only on explicit request, after preview QA.
|
|
26
42
|
|
|
27
|
-
|
|
28
|
-
- No abstractions for single-use code.
|
|
29
|
-
- No flexibility or configurability that was not requested.
|
|
30
|
-
- No error handling for impossible scenarios.
|
|
31
|
-
- If you write 200 lines and it could be 50, rewrite it.
|
|
43
|
+
`fgrove env pull` refreshes the local `.env` when remote project settings changed.
|
|
32
44
|
|
|
33
|
-
|
|
45
|
+
## Behavioral Rules
|
|
34
46
|
|
|
35
|
-
|
|
47
|
+
1. **Think before coding.** State assumptions. If multiple interpretations exist, present them — don't pick silently. If something is unclear, ask before editing.
|
|
48
|
+
2. **Simplicity first.** Minimum code that solves the problem. No speculative abstractions, options, or error handling for impossible states.
|
|
49
|
+
3. **Surgical changes.** Touch only what the request requires. Match existing style. Remove only orphans your own change created. Every changed line should trace to the request.
|
|
50
|
+
4. **Goal-driven execution.** Define the success check before editing ("step renders at both viewports and Continue advances to step-X"), then loop until it passes.
|
|
51
|
+
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.
|
|
52
|
+
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`.
|
|
36
53
|
|
|
37
|
-
|
|
54
|
+
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.
|
|
38
55
|
|
|
39
|
-
|
|
40
|
-
- Do not improve adjacent code, comments, or formatting.
|
|
41
|
-
- Do not refactor things that are not broken.
|
|
42
|
-
- Match existing style, even if you would do it differently.
|
|
43
|
-
- If you notice unrelated dead code, mention it. Do not delete it.
|
|
44
|
-
|
|
45
|
-
When your changes create orphans:
|
|
46
|
-
- Remove imports, variables, functions, and files that your changes made unused.
|
|
47
|
-
- Do not remove pre-existing dead code unless asked.
|
|
48
|
-
|
|
49
|
-
The test: every changed line should trace directly to the user's request.
|
|
50
|
-
|
|
51
|
-
## 4. Goal-Driven Execution
|
|
52
|
-
|
|
53
|
-
**Define success criteria. Loop until verified.**
|
|
54
|
-
|
|
55
|
-
Transform tasks into verifiable goals:
|
|
56
|
-
- "Add validation" -> "Write tests for invalid inputs, then make them pass"
|
|
57
|
-
- "Fix the bug" -> "Write a test that reproduces it, then make it pass"
|
|
58
|
-
- "Refactor X" -> "Ensure tests pass before and after"
|
|
59
|
-
|
|
60
|
-
For multi-step tasks, state a brief plan:
|
|
61
|
-
|
|
62
|
-
```text
|
|
63
|
-
1. [Step] -> verify: [check]
|
|
64
|
-
2. [Step] -> verify: [check]
|
|
65
|
-
3. [Step] -> verify: [check]
|
|
66
|
-
```
|
|
67
|
-
|
|
68
|
-
Strong success criteria let you loop independently. Weak criteria like "make it work" require constant clarification.
|
|
69
|
-
|
|
70
|
-
---
|
|
71
|
-
|
|
72
|
-
**These guidelines are working if:** fewer unnecessary changes in diffs, fewer rewrites due to overcomplication, and clarifying questions come before implementation rather than after mistakes.
|
|
73
|
-
|
|
74
|
-
## Documentation Index
|
|
75
|
-
|
|
76
|
-
Use this folder as the local source-of-truth guide when editing a synced FunnelsGrove funnel.
|
|
77
|
-
|
|
78
|
-
Current funnel templates are small Next.js apps built on shared runtime modules:
|
|
79
|
-
|
|
80
|
-
- `@funnelsgrove/runtime` owns the funnel contract: routing, user state, content localization, builder preview patches, subscriptions, analytics helpers, runtime env, and theme variables.
|
|
81
|
-
- `@funnelsgrove/analytics` owns analytics tracking and A/B experiment tracking. It encapsulates the PostHog integration so funnel steps do not call PostHog directly.
|
|
82
|
-
- `@funnelsgrove/payments` owns checkout plans, discounts, Stripe sessions/intents, wallet slots, and shared checkout UI.
|
|
83
|
-
- The local funnel owns product decisions: step views, content files, editor fields, flow manifest, theme, checked-in assets, and billing plan ids.
|
|
84
|
-
|
|
85
|
-
Keep changes simple and local. Most edits should touch one step file plus its matching `content/` and `editor/` files, or one manifest/config file for flow, theme, or billing changes.
|
|
86
|
-
|
|
87
|
-
## Funnel Workflow
|
|
88
|
-
|
|
89
|
-
1. Run `fgrove status` and confirm the active project and funnel.
|
|
90
|
-
2. Keep edits inside the synced funnel tree.
|
|
91
|
-
3. Run `fgrove env pull` when remote project settings changed and you need the latest local `.env`.
|
|
92
|
-
4. Run the funnel's local checks before syncing.
|
|
93
|
-
5. Run `fgrove sync up --message '<summary>'`.
|
|
94
|
-
6. Run `fgrove publish --env preview --message '<summary>'` and verify the preview.
|
|
95
|
-
7. Publish production only when explicitly requested.
|
|
96
|
-
|
|
97
|
-
- [Editing or Creating a Step](docs/editing-step.md)
|
|
98
|
-
- [Editing Flow](docs/editing-flow.md)
|
|
99
|
-
- [Funnel Runtime Architecture](docs/funnel-runtime-architecture.md)
|
|
100
|
-
- [Editor and Content](docs/editor-and-content.md)
|
|
101
|
-
- [Payment Plans and Discounts](docs/payment-plans-and-discounts.md)
|
|
102
|
-
- [SDK API Endpoints](docs/sdk-api-endpoints.md)
|
|
103
|
-
- [Analytics](docs/analytics.md)
|
|
104
|
-
- [Meta Pixel and Conversions API](docs/meta-pixel-conversions-api.md)
|
|
105
|
-
- [A/B Experiments](docs/ab-experiments.md)
|
|
106
|
-
- [Theme](docs/theme.md)
|
|
107
|
-
- [Publishing and Versioning](docs/publishing-and-versioning.md)
|
|
56
|
+
Preserve existing step ids, paths, and answer keys unless the task is an explicit migration — they feed routes, persisted answers, analytics, and publish history.
|
|
@@ -1,33 +1,52 @@
|
|
|
1
1
|
# A/B Experiments
|
|
2
2
|
|
|
3
|
-
Experiments
|
|
3
|
+
Experiments are config, not code: declared in `src/config/experiments.ts`, exported into the manifest, resolved by the shared runtime. Never write variant conditionals inside step components. If the variant step already exists, setting up the experiment is a one-file edit that takes under a minute.
|
|
4
4
|
|
|
5
|
-
##
|
|
5
|
+
## Recipe: Step or Paywall Test
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
Open `src/config/experiments.ts` and add one entry inside `defineFunnelExperiments([...])`:
|
|
8
8
|
|
|
9
9
|
```ts
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
}
|
|
10
|
+
export const experiments = defineFunnelExperiments([
|
|
11
|
+
{
|
|
12
|
+
id: 'paywall-ab', // durable, readable, never reused
|
|
13
|
+
name: 'Paywall copy test',
|
|
14
|
+
type: 'paywall', // 'step' for quiz steps, 'paywall' for paywall tests
|
|
15
|
+
status: 'running', // 'paused' | 'stopped' removes it from the manifest
|
|
16
|
+
launchDate: '2026-06-12T00:00:00.000Z',
|
|
17
|
+
control: { stepId: 'paywall', trafficPercent: 50 },
|
|
18
|
+
variant: { stepId: 'paywall-b', trafficPercent: 50 },
|
|
19
|
+
},
|
|
20
|
+
] as const);
|
|
18
21
|
```
|
|
19
22
|
|
|
20
|
-
|
|
23
|
+
That's it — `toManifestExperiments(experiments)` in the same file feeds the manifest, traffic percents are validated to sum to 100, and non-`running` entries are filtered out automatically. (Older funnels may declare a plain array and map it manually — follow the local file's existing shape there.) Preconditions (each is its own task if missing):
|
|
21
24
|
|
|
22
|
-
|
|
25
|
+
1. Both `control.stepId` and `variant.stepId` exist in the manifest with normal `edgesByStepId` exits ([editing-step.md](editing-step.md) to create a variant step — usually a copy of the control step with one deliberate change).
|
|
26
|
+
2. Both steps route to the same next step, unless the experiment is explicitly about the flow.
|
|
23
27
|
|
|
24
|
-
|
|
28
|
+
## How the Runtime Behaves
|
|
29
|
+
|
|
30
|
+
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
|
+
|
|
32
|
+
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
|
+
|
|
34
|
+
## Verify (part of the < 1 min setup)
|
|
35
|
+
|
|
36
|
+
In local preview or builder preview, force each side with the editor override:
|
|
37
|
+
|
|
38
|
+
```text
|
|
39
|
+
/paywall?editor=true&experimentVariant=control
|
|
40
|
+
/paywall?editor=true&experimentVariant=variant_b
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Check: opening the source URL promotes to the assigned route, each variant renders, Continue advances to the step after the variant. Then run a normal (non-editor) open to confirm the suspension → assignment → route flow works.
|
|
25
44
|
|
|
26
45
|
## Rules
|
|
27
46
|
|
|
28
|
-
-
|
|
29
|
-
-
|
|
30
|
-
-
|
|
31
|
-
-
|
|
32
|
-
-
|
|
33
|
-
-
|
|
47
|
+
- One deliberate change per experiment. Don't bundle unrelated edits into a variant.
|
|
48
|
+
- `control` stays stable; never edit the control step as part of launching a variant.
|
|
49
|
+
- Variant keys and experiment ids are durable — they flow into analytics. Never recycle an id for a different hypothesis.
|
|
50
|
+
- Keep every variant step's outgoing edge in `edgesByStepId`, or assigned visitors strand.
|
|
51
|
+
- 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)).
|
|
52
|
+
- Don't remove a running variant until the user confirms the analysis is done; route all traffic to the winner by setting `trafficPercent` rather than deleting history.
|
|
@@ -7,14 +7,20 @@ Flow is product logic. The source of truth is `src/config/funnel.manifest.ts`; r
|
|
|
7
7
|
The manifest defines:
|
|
8
8
|
|
|
9
9
|
- `viewport`: the designed shell size, usually `430 x 932`.
|
|
10
|
-
- `assets`: image metadata used by runtime/build tooling.
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
10
|
+
- `assets`: image metadata used by runtime/build tooling. Declare every
|
|
11
|
+
funnel-critical raster image here with stable `src`, `width`, and `height`
|
|
12
|
+
so publish can reduce image size during the build and generate AVIF/WebP
|
|
13
|
+
variants. Funnel shells may use this data to warm likely next-step images,
|
|
14
|
+
but first-viewport images should still use the framework's normal
|
|
15
|
+
priority/preload mechanism.
|
|
16
|
+
- `steps`: every routable step with `id`, `path`, `filePath`, `componentKey`, `type`, optional `kind`, 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.
|
|
14
17
|
- `edgesByStepId`: graph edges between steps.
|
|
15
18
|
- `experiments`: optional variant routing.
|
|
16
19
|
|
|
17
20
|
Keep `steps[].id`, `path`, and answer keys stable unless the request is a migration.
|
|
21
|
+
When adding a step, prefer a semantic path like `/motivation`, `/fitness-goal`,
|
|
22
|
+
or `/email-capture`; do not expose internal ordering through `/step-1` or
|
|
23
|
+
`/step-07` routes.
|
|
18
24
|
|
|
19
25
|
## Routing Rules
|
|
20
26
|
|
|
@@ -62,12 +68,21 @@ Experiments attach to a step and route to variant steps:
|
|
|
62
68
|
}
|
|
63
69
|
```
|
|
64
70
|
|
|
65
|
-
The runtime resolves assignments through the shared analytics/runtime integration outside preview and uses editor overrides inside preview/editor mode.
|
|
71
|
+
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.
|
|
72
|
+
|
|
73
|
+
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.
|
|
74
|
+
|
|
75
|
+
Keep the control variant stable and do not remove a running variant until analytics have been reviewed.
|
|
66
76
|
|
|
67
77
|
## Checklist
|
|
68
78
|
|
|
69
79
|
- Add or update the manifest step.
|
|
70
80
|
- Register the component in `src/runtime/step-registry.ts`.
|
|
71
81
|
- Update `edgesByStepId`, `entryPoints`, and `assetIds` if needed.
|
|
82
|
+
- Use a meaningful public `path` for every new step. Internal ids can be
|
|
83
|
+
sequential, but URLs should describe the screen.
|
|
84
|
+
- Keep image preloading manifest-driven: step images belong in
|
|
85
|
+
`funnelManifest.assets` and step `assetIds`; the shell should warm only likely
|
|
86
|
+
next-step images at low priority, not the entire funnel.
|
|
72
87
|
- Make sure skipped steps do not own required answers.
|
|
73
88
|
- Verify the step before, the edited step, and the step after.
|