@funnelsgrove/cli 0.1.10 → 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/dist/cli.js CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { readFileSync, realpathSync } from 'node:fs';
3
- import { cp, mkdir } from 'node:fs/promises';
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 {
@@ -61,6 +62,17 @@ export function isCliEntrypoint(invokedPath, modulePath, realpath = realpathSync
61
62
  return path.resolve(invokedPath) === path.resolve(modulePath);
62
63
  }
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
+ }
64
76
  const getApiUrl = () => {
65
77
  const options = program.opts();
66
78
  return options.apiUrl || process.env.FUNNELSGROVE_API_URL || DEFAULT_API_URL;
@@ -264,6 +276,34 @@ const resolveSyncTarget = async (input) => {
264
276
  manifest,
265
277
  };
266
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
+ };
267
307
  const printRows = (rows, columns) => {
268
308
  for (const row of rows) {
269
309
  console.log(columns.map((column) => row[column] || '').join('\t'));
@@ -470,6 +510,240 @@ addExamples(funnelsCommand
470
510
  });
471
511
  console.log(`${result.funnel.id}\t${result.funnel.name}\t${result.funnel.slug}`);
472
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
+ });
473
747
  const syncCommand = addExamples(program.command('sync').description('Sync funnel source'), [
474
748
  'fgrove sync down --funnel claimbee-ios --dir ./claimbee-ios',
475
749
  'fgrove sync up --message "Update copy"',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@funnelsgrove/cli",
3
- "version": "0.1.10",
3
+ "version": "0.1.11",
4
4
  "description": "FunnelsGrove command-line tools for editing, syncing, and publishing funnels",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,107 +1,56 @@
1
1
  # AGENTS.md
2
2
 
3
- Behavioral guidelines to reduce common LLM coding mistakes. Merge these rules with project-specific instructions as needed.
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
- **Tradeoff:** These guidelines bias toward caution over speed. For trivial tasks, use judgment.
5
+ ## Find Your Task
6
6
 
7
- ## Read Docs Before Editing
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
- Before changing any funnel file, read the relevant topic docs in this folder and follow their contracts. Make the edit according to those docs first; if the requested change conflicts with the docs, stop and explain the conflict before editing.
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 docs are part of the change. When you change funnel routing, runtime state, SDK API contracts, URL handoff parameters, checkout/subscription behavior, analytics events, environment config, or shared package boundaries, update `template_docs` in the same change so future agents inherit the new contract.
22
+ ## Architecture in 30 Seconds
12
23
 
13
- ## 1. Think Before Coding
24
+ The funnel is a small Next.js app on shared packages:
14
25
 
15
- **Don't assume. Don't hide confusion. Surface tradeoffs.**
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
- Before implementing:
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
- ## 2. Simplicity First
33
+ ## Standard Workflow
24
34
 
25
- **Minimum code that solves the problem. Nothing speculative.**
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
- - No features beyond what was asked.
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
- Ask yourself: "Would a senior engineer say this is overcomplicated?" If yes, simplify.
45
+ ## Behavioral Rules
34
46
 
35
- ## 3. Surgical Changes
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
- **Touch only what you must. Clean up only your own mess.**
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
- When editing existing code:
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 live in `src/config/funnel.manifest.ts`. They are routing rules, not ad hoc conditionals inside step components.
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
- ## Runtime Contract
5
+ ## Recipe: Step or Paywall Test
6
6
 
7
- An experiment attaches to a source step and maps variant keys to routable step ids:
7
+ Open `src/config/experiments.ts` and add one entry inside `defineFunnelExperiments([...])`:
8
8
 
9
9
  ```ts
10
- {
11
- experimentId: 'paywall-ab',
12
- stepId: 'paywall',
13
- variants: [
14
- { variantKey: 'control', routeToStepId: 'paywall' },
15
- { variantKey: 'variant_b', routeToStepId: 'paywall-b' },
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
- The flow controller resolves the experiment assignment through the shared analytics/runtime integration and routes to the matched variant. `@funnelsgrove/analytics` encapsulates api for experiment tracking. If the assignment is not ready on an experiment step, rendering is suspended to avoid flashing the control before the variant resolves.
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
- ## Editor and Preview
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
- When `?editor=true` is enabled, the editor panel can force runtime mode and select experiment variants. Builder preview can also navigate steps through the preview bridge. This lets agents verify each variant without changing the manifest.
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
- - Keep `control` stable.
29
- - Keep variant keys durable and readable.
30
- - Test the attachment step and every variant route.
31
- - Do not put unrelated changes in a variant; isolate one major decision.
32
- - Do not remove a live variant until analysis and rollout are approved.
33
- - If a variant changes paywall plans or checkout behavior, update `@funnelsgrove/analytics` event expectations too.
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. Funnel shells may use
11
- this data to warm likely next-step images, but first-viewport images should
12
- still use the framework's normal priority/preload mechanism.
13
- - `steps`: every routable step with `id`, `path`, `filePath`, `componentKey`, `type`, optional `kind`, and optional `assetIds`.
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. Keep the control variant stable and do not remove a running variant until analytics have been reviewed.
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.