@funnelsgrove/cli 0.1.5 → 0.1.7

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 CHANGED
@@ -24,6 +24,8 @@ fgrove sync up --message 'Update funnel copy'
24
24
  fgrove publish --env preview
25
25
  ```
26
26
 
27
+ Use `fgrove env pull --dir <local-dir>` to refresh only the ignored local `.env` file after project settings change, without replacing source files.
28
+
27
29
  GitHub sync workflow:
28
30
 
29
31
  ```bash
package/dist/cli.js CHANGED
@@ -1,13 +1,14 @@
1
1
  #!/usr/bin/env node
2
2
  import { readFileSync } from 'node:fs';
3
- import { cp, mkdir, readFile, writeFile } from 'node:fs/promises';
3
+ import { cp, mkdir } 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';
7
7
  import { Command } from 'commander';
8
8
  import { callTrpcProcedure } from './apiClient.js';
9
9
  import { clearActiveContext, getDefaultAuthConfigPath, loadActiveContext, loadAuthToken, saveActiveContext, saveAuthToken, } from './authStore.js';
10
- import { buildSyncManifest, chunkChangedSourceFiles, collectChangedSourceFiles, collectSourceFiles, readSyncManifest, writeSourceFiles, writeSyncManifest, } from './localSync.js';
10
+ import { buildSyncManifest, chunkChangedSourceFiles, collectChangedSourceFiles, collectSourceFiles, ensureGitignore, formatSyncUploadSummary, readSyncManifest, writeLocalEnvFile, writeSourceFiles, writeSyncManifest, } from './localSync.js';
11
+ import { pullEnvFile } from './envSync.js';
11
12
  import { formatGitHubConnectRows, formatGitHubJobRows, formatGitHubStatusRows, } from './githubOutput.js';
12
13
  import { syncGitHubDraftIfConnected } from './githubSyncFlow.js';
13
14
  import { isKnownTemplate, KNOWN_TEMPLATE_SLUGS, reskinFunnel } from './reskin.js';
@@ -197,19 +198,6 @@ const resolveSyncTarget = async (input) => {
197
198
  manifest,
198
199
  };
199
200
  };
200
- const ensureGitignore = async (dir) => {
201
- const gitignorePath = path.join(dir, '.gitignore');
202
- const existing = await readFile(gitignorePath, 'utf8').catch(() => '');
203
- const lines = existing.split(/\r?\n/g).filter(Boolean);
204
- const required = ['.env', '.env.local', '.env.*', '!.env.example', '.funnelsgrove-sync.json', 'node_modules', '.next', 'out'];
205
- const seen = new Set(lines);
206
- for (const line of required) {
207
- if (!seen.has(line)) {
208
- lines.push(line);
209
- }
210
- }
211
- await writeFile(gitignorePath, `${lines.join('\n')}\n`, 'utf8');
212
- };
213
201
  const printRows = (rows, columns) => {
214
202
  for (const row of rows) {
215
203
  console.log(columns.map((column) => row[column] || '').join('\t'));
@@ -448,9 +436,7 @@ addExamples(syncCommand
448
436
  });
449
437
  await mkdir(target.sourceDir, { recursive: true });
450
438
  await writeSourceFiles(target.sourceDir, result.files);
451
- if (result.envFile) {
452
- await writeFile(path.join(target.sourceDir, '.env'), result.envFile, 'utf8');
453
- }
439
+ await writeLocalEnvFile(target.sourceDir, result.envFile);
454
440
  await ensureGitignore(target.sourceDir);
455
441
  await writeSyncManifest(target.sourceDir, await buildSyncManifest(target.sourceDir, {
456
442
  workspaceId: target.workspaceId,
@@ -459,6 +445,40 @@ addExamples(syncCommand
459
445
  }));
460
446
  console.log(`Synced ${result.files.length} files to ${target.sourceDir}`);
461
447
  });
448
+ const envCommand = addExamples(program.command('env').description('Manage local funnel environment files'), [
449
+ 'fgrove env pull --dir ./claimbee-ios',
450
+ ]);
451
+ addExamples(envCommand
452
+ .command('pull')
453
+ .description('Refresh the ignored local .env file without downloading source files')
454
+ .option('--workspace <id-or-slug-or-name>', 'Workspace id, slug, or name')
455
+ .option('--funnel <id-or-slug>', 'Funnel id or slug')
456
+ .option('--dir <path>', 'Local funnel directory', '.'), [
457
+ 'fgrove env pull',
458
+ 'fgrove env pull --dir ./claimbee-ios',
459
+ 'fgrove env pull --funnel claimbee-ios --dir ./claimbee-ios',
460
+ ])
461
+ .action(async (options) => {
462
+ const token = await readAuthToken();
463
+ const target = await resolveSyncTarget({
464
+ token,
465
+ workspace: options.workspace,
466
+ funnel: options.funnel,
467
+ dir: options.dir,
468
+ });
469
+ const result = await pullEnvFile({
470
+ callApi,
471
+ token,
472
+ workspaceId: target.workspaceId,
473
+ funnelId: target.funnelId,
474
+ sourceDir: target.sourceDir,
475
+ });
476
+ if (!result.wroteEnvFile) {
477
+ console.log(`No env file returned for ${result.sourceDir}`);
478
+ return;
479
+ }
480
+ console.log(`Updated ${path.join(result.sourceDir, '.env')}`);
481
+ });
462
482
  addExamples(syncCommand
463
483
  .command('up')
464
484
  .description('Upload local source into a new funnel draft version')
@@ -493,6 +513,7 @@ addExamples(syncCommand
493
513
  let syncedFileCount = 0;
494
514
  let deletedFileCount = 0;
495
515
  if (changes) {
516
+ console.log(formatSyncUploadSummary(changes).join('\n'));
496
517
  const batches = chunkChangedSourceFiles(changes);
497
518
  for (const batch of batches) {
498
519
  result = await callApi({
@@ -512,6 +533,8 @@ addExamples(syncCommand
512
533
  }
513
534
  }
514
535
  else {
536
+ const files = await collectSourceFiles(target.sourceDir);
537
+ console.log(formatSyncUploadSummary({ deletedPaths: [], files }).join('\n'));
515
538
  result = await callApi({
516
539
  path: 'funnels.importSource',
517
540
  type: 'mutation',
@@ -520,7 +543,7 @@ addExamples(syncCommand
520
543
  workspaceId: target.workspaceId,
521
544
  funnelId: target.funnelId,
522
545
  message: options.message,
523
- files: await collectSourceFiles(target.sourceDir),
546
+ files,
524
547
  },
525
548
  });
526
549
  syncedFileCount = result.syncedFiles.length;
@@ -0,0 +1,17 @@
1
+ export type EnvPullCallApi = <T>(input: {
2
+ path: string;
3
+ type: 'query' | 'mutation';
4
+ data?: unknown;
5
+ token?: string | null;
6
+ }) => Promise<T>;
7
+ export type PullEnvFileResult = {
8
+ sourceDir: string;
9
+ wroteEnvFile: boolean;
10
+ };
11
+ export declare function pullEnvFile(input: {
12
+ callApi: EnvPullCallApi;
13
+ token: string;
14
+ workspaceId: string;
15
+ funnelId: string;
16
+ sourceDir: string;
17
+ }): Promise<PullEnvFileResult>;
@@ -0,0 +1,20 @@
1
+ import { mkdir } from 'node:fs/promises';
2
+ import { ensureGitignore, writeLocalEnvFile } from './localSync.js';
3
+ export async function pullEnvFile(input) {
4
+ await mkdir(input.sourceDir, { recursive: true });
5
+ const result = await input.callApi({
6
+ path: 'funnels.exportSource',
7
+ type: 'query',
8
+ token: input.token,
9
+ data: {
10
+ workspaceId: input.workspaceId,
11
+ funnelId: input.funnelId,
12
+ },
13
+ });
14
+ const wroteEnvFile = await writeLocalEnvFile(input.sourceDir, result.envFile);
15
+ await ensureGitignore(input.sourceDir);
16
+ return {
17
+ sourceDir: input.sourceDir,
18
+ wroteEnvFile,
19
+ };
20
+ }
@@ -30,8 +30,11 @@ export declare function normalizeSyncPath(filePath: string): string;
30
30
  export declare function shouldSyncFile(filePath: string): boolean;
31
31
  export declare function buildSyncManifest(rootDir: string, input: SyncManifestInput): Promise<SyncManifest>;
32
32
  export declare function writeSyncManifest(rootDir: string, manifest: SyncManifest): Promise<void>;
33
+ export declare function ensureGitignore(rootDir: string): Promise<void>;
34
+ export declare function writeLocalEnvFile(rootDir: string, envFile: string | null | undefined): Promise<boolean>;
33
35
  export declare function readSyncManifest(rootDir: string): Promise<SyncManifest | null>;
34
36
  export declare function collectSourceFiles(rootDir: string): Promise<SourceFile[]>;
35
37
  export declare function collectChangedSourceFiles(rootDir: string, previousManifest: SyncManifest): Promise<ChangedSourceFiles>;
36
38
  export declare function chunkChangedSourceFiles(changes: Pick<ChangedSourceFiles, 'deletedPaths' | 'files'>, maxContentChars?: number): SourceFilePatchBatch[];
39
+ export declare function formatSyncUploadSummary(changes: Pick<ChangedSourceFiles, 'deletedPaths' | 'files'>, largestFileLimit?: number): string[];
37
40
  export declare function writeSourceFiles(rootDir: string, files: SourceFile[]): Promise<void>;
package/dist/localSync.js CHANGED
@@ -3,7 +3,16 @@ import { mkdir, readdir, readFile, stat, writeFile } from 'node:fs/promises';
3
3
  import path from 'node:path';
4
4
  export const SYNC_MANIFEST_FILE = '.funnelsgrove-sync.json';
5
5
  export const DEFAULT_PATCH_BATCH_CONTENT_CHARS = 12_000_000;
6
- const EXCLUDED_PATH_PARTS = new Set(['node_modules', '.next', 'out']);
6
+ const EXCLUDED_LOCAL_DIRECTORY_NAMES = ['.git', '.playwright-cli', 'node_modules', '.next', 'out', 'output'];
7
+ const EXCLUDED_PATH_PARTS = new Set(EXCLUDED_LOCAL_DIRECTORY_NAMES);
8
+ const LOCAL_ONLY_GITIGNORE_LINES = [
9
+ '.env',
10
+ '.env.local',
11
+ '.env.*',
12
+ '!.env.example',
13
+ SYNC_MANIFEST_FILE,
14
+ ...EXCLUDED_LOCAL_DIRECTORY_NAMES.filter((directoryName) => directoryName !== '.git'),
15
+ ];
7
16
  export function normalizeSyncPath(filePath) {
8
17
  const normalized = path.posix.normalize(filePath.replaceAll('\\', '/'));
9
18
  return normalized.replace(/^(\.\/|\/)+/, '');
@@ -43,6 +52,26 @@ export async function buildSyncManifest(rootDir, input) {
43
52
  export async function writeSyncManifest(rootDir, manifest) {
44
53
  await writeFile(path.join(rootDir, SYNC_MANIFEST_FILE), `${JSON.stringify(manifest, null, 2)}\n`);
45
54
  }
55
+ export async function ensureGitignore(rootDir) {
56
+ const gitignorePath = path.join(rootDir, '.gitignore');
57
+ const existing = await readFile(gitignorePath, 'utf8').catch(() => '');
58
+ const lines = existing.split(/\r?\n/g).filter(Boolean);
59
+ const seen = new Set(lines);
60
+ for (const line of LOCAL_ONLY_GITIGNORE_LINES) {
61
+ if (!seen.has(line)) {
62
+ lines.push(line);
63
+ }
64
+ }
65
+ await writeFile(gitignorePath, `${lines.join('\n')}\n`, 'utf8');
66
+ }
67
+ export async function writeLocalEnvFile(rootDir, envFile) {
68
+ if (!envFile) {
69
+ return false;
70
+ }
71
+ await mkdir(rootDir, { recursive: true });
72
+ await writeFile(path.join(rootDir, '.env'), envFile, 'utf8');
73
+ return true;
74
+ }
46
75
  export async function readSyncManifest(rootDir) {
47
76
  try {
48
77
  const raw = JSON.parse(await readFile(path.join(rootDir, SYNC_MANIFEST_FILE), 'utf8'));
@@ -127,6 +156,41 @@ export function chunkChangedSourceFiles(changes, maxContentChars = DEFAULT_PATCH
127
156
  pushCurrentBatch();
128
157
  return batches;
129
158
  }
159
+ export function formatSyncUploadSummary(changes, largestFileLimit = 5) {
160
+ const fileSizes = changes.files
161
+ .map((file) => ({
162
+ path: file.path,
163
+ bytes: Buffer.byteLength(file.content, 'utf8'),
164
+ }))
165
+ .sort((left, right) => right.bytes - left.bytes || left.path.localeCompare(right.path));
166
+ const totalBytes = fileSizes.reduce((total, file) => total + file.bytes, 0);
167
+ const lines = [
168
+ `Preparing sync upload: ${formatCount(fileSizes.length, 'file')}, ${formatCount(changes.deletedPaths.length, 'deleted path')}, ${formatByteSize(totalBytes)} content.`,
169
+ ];
170
+ if (fileSizes.length > 0 && largestFileLimit > 0) {
171
+ lines.push('Largest included files:');
172
+ for (const file of fileSizes.slice(0, largestFileLimit)) {
173
+ lines.push(` ${formatByteSize(file.bytes).padStart(6)} ${file.path}`);
174
+ }
175
+ }
176
+ return lines;
177
+ }
178
+ function formatCount(count, singular) {
179
+ return `${count} ${count === 1 ? singular : `${singular}s`}`;
180
+ }
181
+ function formatByteSize(byteCount) {
182
+ if (byteCount < 1024) {
183
+ return `${byteCount} B`;
184
+ }
185
+ const units = ['KB', 'MB', 'GB'];
186
+ let value = byteCount / 1024;
187
+ let unitIndex = 0;
188
+ while (value >= 1024 && unitIndex < units.length - 1) {
189
+ value /= 1024;
190
+ unitIndex += 1;
191
+ }
192
+ return `${value.toFixed(1)} ${units[unitIndex]}`;
193
+ }
130
194
  async function readSourceFiles(rootDir, manifestFiles) {
131
195
  const files = await Promise.all(manifestFiles.map(async (file) => {
132
196
  const absolutePath = path.join(rootDir, assertSafeSyncPath(file.path));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@funnelsgrove/cli",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
4
4
  "description": "FunnelsGrove command-line tools for editing, syncing, and publishing funnels",
5
5
  "type": "module",
6
6
  "bin": {
@@ -8,6 +8,8 @@ Behavioral guidelines to reduce common LLM coding mistakes. Merge these rules wi
8
8
 
9
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.
10
10
 
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.
12
+
11
13
  ## 1. Think Before Coding
12
14
 
13
15
  **Don't assume. Don't hide confusion. Surface tradeoffs.**
@@ -86,17 +88,20 @@ Keep changes simple and local. Most edits should touch one step file plus its ma
86
88
 
87
89
  1. Run `fgrove status` and confirm the active project and funnel.
88
90
  2. Keep edits inside the synced funnel tree.
89
- 3. Run the funnel's local checks before syncing.
90
- 4. Run `fgrove sync up --message '<summary>'`.
91
- 5. Run `fgrove publish --env preview --message '<summary>'` and verify the preview.
92
- 6. Publish production only when explicitly requested.
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.
93
96
 
94
97
  - [Editing or Creating a Step](docs/editing-step.md)
95
98
  - [Editing Flow](docs/editing-flow.md)
99
+ - [Funnel Runtime Architecture](docs/funnel-runtime-architecture.md)
96
100
  - [Editor and Content](docs/editor-and-content.md)
97
101
  - [Payment Plans and Discounts](docs/payment-plans-and-discounts.md)
98
102
  - [SDK API Endpoints](docs/sdk-api-endpoints.md)
99
103
  - [Analytics](docs/analytics.md)
104
+ - [Meta Pixel and Conversions API](docs/meta-pixel-conversions-api.md)
100
105
  - [A/B Experiments](docs/ab-experiments.md)
101
106
  - [Theme](docs/theme.md)
102
107
  - [Publishing and Versioning](docs/publishing-and-versioning.md)
@@ -14,6 +14,7 @@ Analytics should describe meaningful user behavior. `@funnelsgrove/analytics` is
14
14
  - `step_start` -> `step_started`.
15
15
  - `step_end` -> `step_completed`.
16
16
  - `funnel_start` -> `funnel_started`.
17
+ - Successful `POST /sdk/public/users/:user_id/claim_subscription` calls emit `registration_completed` from the server.
17
18
 
18
19
  Experiment assignments are attached as feature flag properties when available. Preview runtime skips normal analytics delivery.
19
20
 
@@ -48,9 +49,12 @@ Flow and experiment assignment are configured in `src/config/funnel.manifest.ts`
48
49
 
49
50
  Agents should only document and verify the analytics package surface: stable event names, step ids, selected answers, checkout metadata, and experiment metadata. Treat the underlying provider as an implementation detail.
50
51
 
52
+ Provider-specific behavior is documented separately. Read [Meta Pixel and Conversions API](meta-pixel-conversions-api.md) before changing Meta event mapping, Pixel behavior, server-side conversions, or attribution matching.
53
+
51
54
  ## Agent Rules
52
55
 
53
56
  - Keep event names stable.
57
+ - Count Completed Registration only from successful subscription claim requests after app login/signup.
54
58
  - Use `metadata`, `payload`, `context`, and `selected` for small analysis fields only.
55
59
  - Do not send personal data, secrets, full payment objects, SDK keys, or raw Stripe responses.
56
60
  - Use `@funnelsgrove/analytics` for custom tracking; do not call the provider SDK directly.
@@ -0,0 +1,125 @@
1
+ # Funnel Runtime Architecture
2
+
3
+ This doc explains the runtime contracts a synced funnel should preserve. Update this file when changing funnel architecture, routing, identity handoff, SDK payloads, payments, subscriptions, or analytics.
4
+
5
+ ## Runtime Boundary
6
+
7
+ A funnel is a small Next.js app that owns product-specific steps, content, assets, theme, and billing plan ids. Shared packages own the reusable runtime behavior:
8
+
9
+ - `@funnelsgrove/runtime`: routing, user state, URL user attributes, SDK calls, preview mode, content localization, subscription handoff, runtime config, and theme variables.
10
+ - `@funnelsgrove/payments`: Stripe plan resolution, discounts, checkout sessions, wallet/card checkout UI, and manage-subscription UI.
11
+ - `@funnelsgrove/analytics`: canonical analytics events and provider delivery.
12
+
13
+ Keep funnel code thin. Step files should use the shared runtime/payment/analytics surfaces instead of calling raw SDK endpoints, Stripe, PostHog, or browser routing directly.
14
+
15
+ ## Flow And Routing
16
+
17
+ The local source of truth is `src/config/funnel.manifest.ts`.
18
+
19
+ - `steps[]` defines every routable step with stable `id`, `path`, `filePath`, `componentKey`, `type`, optional `kind`, and asset references.
20
+ - `edgesByStepId` defines normal and conditional navigation.
21
+ - `entryPoints` lets public routes such as `/paywall` and `/manage-subscription` land directly on a step.
22
+ - `src/runtime/step-registry.ts` maps manifest step ids to React components.
23
+
24
+ Use `useFunnel()` navigation (`goNext`, `goChoice`, `goToStep`) so runtime history, experiments, and step tracking remain consistent.
25
+
26
+ ## User Identity
27
+
28
+ The runtime persists a local user id under the funnel-scoped local storage key `funnel:<funnelId>:user-id`. Public URLs can also pass user attributes:
29
+
30
+ - `user_id`: canonical public funnel user id when a known user is being resumed.
31
+ - `email`: shopper/contact email. The runtime validates it and syncs it into the funnel user profile.
32
+ - `name`, `fullName`, `full_name`: optional profile name aliases.
33
+ - `stripe_customer_id`: Stripe customer id used by subscription management links.
34
+
35
+ At startup, the flow controller bootstraps a user through `apiService.bootstrapSession(...)` with the URL or local user id, email/name, and first-touch attribution. Step answers are stored in runtime attributes and persisted through `apiService.updateUser(...)` as `document.progress.attributes` plus completed step records.
36
+
37
+ Do not invent new identity query names casually. If a new URL parameter becomes part of the funnel contract, add it to the URL attribute parser, SDK docs, and this file.
38
+
39
+ ## Paywall Entry
40
+
41
+ Paywall routes can be opened directly, usually as `/paywall`.
42
+
43
+ Supported identity handoff:
44
+
45
+ - `/paywall?user_id=u_123`: resume a known funnel user and preserve that id through checkout metadata.
46
+ - `/paywall?email=user@example.com`: seed/sync the shopper email and pass it as Stripe `customerEmail`.
47
+ - `/paywall?user_id=u_123&email=user@example.com`: preferred when both are known.
48
+
49
+ Email-only links are useful for prefilled checkout and later private user lookup by email, but `user_id` is the stronger join key for existing answers, attribution, and subscription reconciliation.
50
+
51
+ Paywall checkout should use `@funnelsgrove/payments` helpers. Checkout payloads must include the selected plan, amount, coupon, checkout mode, runtime config, `user_id` when available, and `customerEmail` when available. Stripe sessions receive `client_reference_id` and FunnelsGrove metadata with plan id, price id, user id, environment, funnel id, funnel version id, coupon id, and customer email.
52
+
53
+ Success/return URLs should route to the subscription-started step and preserve `user_id`, for example `/subscription-started?user_id=u_123&source=stripe-elements`.
54
+
55
+ ## Subscription Started Handoff
56
+
57
+ The subscription-started step uses `SubscriptionHandoffScreen` and `resolveSubscriptionHandoff(...)`.
58
+
59
+ The handoff requires a user id. App store URLs, universal links, and deep links may include `{user_id}` and `{email}` templates; runtime also appends `user_id` and first-touch attribution parameters when it can parse the URL.
60
+
61
+ This step is also the right place to track payment return events such as `payment_checkout_succeeded` or `payment_checkout_returned`. Keep raw payment objects and secrets out of analytics metadata.
62
+
63
+ ## Manage Subscription
64
+
65
+ The manage-subscription route is a public customer support route, usually `/manage-subscription`.
66
+
67
+ Supported identity handoff:
68
+
69
+ - `/manage-subscription?user_id=u_123`: resolve the funnel user, then resolve Stripe customer/subscriptions from the linked user, user document, or billing records.
70
+ - `/manage-subscription?stripe_customer_id=cus_123`: resolve subscriptions directly by Stripe customer id when the funnel user id is unavailable.
71
+ - `/manage-subscription?user_id=u_123&stripe_customer_id=cus_123`: preferred for support/admin generated links.
72
+
73
+ The runtime calls:
74
+
75
+ - `GET /sdk/public/subscriptions?user_id=...&stripe_customer_id=...&funnelId=...`
76
+ - `POST /sdk/public/subscriptions/:subscriptionId/:action` with `action` of `cancel` or `renew`
77
+
78
+ If neither `user_id` nor `stripe_customer_id` is available, the list endpoint returns an empty public summary and mutation rejects the request. The UI should show an empty/error state and the support email from runtime config.
79
+
80
+ ## Webhooks And Reconciliation
81
+
82
+ Stripe webhooks reconcile payment/subscription state back into the funnel user. The important join points are:
83
+
84
+ - Stripe metadata `userId` and `customerEmail`.
85
+ - Stripe `client_reference_id`.
86
+ - provider customer id (`stripe_customer_id` / `providerCustomerId`).
87
+ - stored funnel user subscription fields and document subscription snapshots.
88
+
89
+ Webhook reconciliation updates the funnel user subscription status, Stripe customer/subscription ids, subscription document snapshots, billing records, analytics, and project webhooks. When editing checkout metadata or URL handoff, verify the webhook still has enough information to find the correct funnel user.
90
+
91
+ ## Analytics
92
+
93
+ The flow controller emits canonical runtime events:
94
+
95
+ - `step_start`
96
+ - `step_end`
97
+ - `step_engaged` on the first step after the engagement threshold
98
+
99
+ The analytics provider may receive mapped names such as `step_started` and `step_completed`.
100
+ Paywalls should track checkout intent and completion through `@funnelsgrove/analytics` with plan id, provider plan id, coupon id, amount, and environment. Do not send personal data, SDK keys, Stripe secrets, or raw provider responses.
101
+ Completed Registration is tracked by the server only when the app successfully calls `POST /sdk/public/users/:user_id/claim_subscription` after login/signup.
102
+
103
+ ## Preview Runtime
104
+
105
+ Builder preview is intentionally different from production:
106
+
107
+ - normal SDK writes are skipped or replaced with local fallbacks;
108
+ - manage-subscription uses a preview fallback payload;
109
+ - runtime mode and paywall plans can be patched by the builder preview bridge;
110
+ - checkout may force test mode when the preview payment key is active.
111
+
112
+ Do not make preview-only shortcuts the production contract.
113
+
114
+ ## Architecture Change Checklist
115
+
116
+ When a funnel architecture change touches any item below, update `template_docs` with the new behavior:
117
+
118
+ - route paths, manifest step ids, entry points, or step kinds;
119
+ - runtime user state, URL parameters, attribution, or answer persistence;
120
+ - SDK endpoint paths, query/body fields, response shape, or auth headers;
121
+ - payment plan mapping, checkout metadata, return URLs, discounts, or Stripe mode resolution;
122
+ - manage-subscription identity resolution or mutation behavior;
123
+ - subscription handoff links, app/deep-link templates, or post-checkout routing;
124
+ - analytics event names or required metadata;
125
+ - preview/editor runtime behavior.
@@ -0,0 +1,50 @@
1
+ # Meta Pixel and Conversions API
2
+
3
+ Meta analytics is provider-specific. Keep funnel code on the `@funnelsgrove/analytics` API surface; do not call `fbq`, Meta Pixel, or Conversions API directly from funnel steps.
4
+
5
+ ## Browser Pixel Events
6
+
7
+ The browser Pixel is initialized by `@funnelsgrove/analytics` when both public env vars are set:
8
+
9
+ - `NEXT_PUBLIC_META_PIXEL_ENABLED=true`
10
+ - `NEXT_PUBLIC_META_PIXEL_ID=<pixel id>`
11
+
12
+ Browser events:
13
+
14
+ - `publicAnalyticsSdk.trackFirstStepViewed(...)` sends Meta `ViewContent` when the user lands on the real first funnel page.
15
+ - `publicAnalyticsSdk.trackFirstStepClicked(...)` is still supported for funnels or experiments that need click-specific first-step analytics. It also maps to Meta `ViewContent`.
16
+ - `checkout_started` maps to Meta `InitiateCheckout`.
17
+ - `checkout_completed` maps to Meta `AddPaymentInfo`.
18
+
19
+ Do not fire both `trackFirstStepViewed(...)` and `trackFirstStepClicked(...)` for the same intended conversion unless the product explicitly wants two separate analytics events. Preview runtime must not send normal Pixel events.
20
+
21
+ The browser Pixel owns browser attribution automatically through Meta cookies such as `_fbp` and `_fbc`.
22
+
23
+ ## Server Conversions API Events
24
+
25
+ Server-side Meta events go through the API analytics service and Facebook Conversions API. Project env requires:
26
+
27
+ - `META_CONVERSIONS_ACCESS_TOKEN=<server token>`
28
+ - Optional test helper: `META_TEST_EVENT_CODE=<Meta test code>`
29
+
30
+ Trusted server events currently come from Stripe webhooks:
31
+
32
+ - Paid checkout session, invoice, or payment intent sends Meta `Purchase`.
33
+ - Active or trialing subscription webhook sends Meta `Subscribe`.
34
+
35
+ Server events use the funnel user id as Meta `external_id`; do not use the funnel user id as an event id. Event ids must be unique per event. Use one generated event id for a browser/server pair only when both sides represent the same user action and should be deduplicated.
36
+
37
+ ## Attribution Matching
38
+
39
+ Runtime stores URL/referrer attribution in `funnelUser.document.attribution`.
40
+
41
+ Keep `fbclid` in attribution. The API can derive Meta `fbc` from stored `fbclid` and the attribution capture time for later Stripe webhook conversions. The API also sends known email, funnel user id, user agent, and other available match fields.
42
+
43
+ Do not invent or strip attribution URL params unless the funnel runtime attribution docs and SDK contract are updated in the same change.
44
+
45
+ ## Agent Rules
46
+
47
+ - Use analytics package helpers instead of raw Meta calls.
48
+ - When changing first-page routing, verify `ViewContent` still fires only for the real first landing page and not preview runtime.
49
+ - When changing checkout or subscription behavior, verify the corresponding server CAPI event still has project id, funnel user id, event id, currency/value where available, and stored attribution.
50
+ - Do not send secrets, raw Stripe objects, access tokens, or full payment payloads to browser analytics.
@@ -44,10 +44,48 @@ Prefer shared Stripe surfaces:
44
44
 
45
45
  Runtime config should come from `runtimePublicConfig` through a local `src/runtime/checkout-runtime-config.ts`. Include `apiBaseUrl`, `funnelId`, and the SDK publishable key. Preview seed keys may resolve to the preview payment key and force test checkout mode.
46
46
 
47
+ ### Shared Checkout V2 and Wallet Slots
48
+
49
+ Subscription paywalls with on-page wallet buttons should share one `useStripeSubscriptionCheckoutSession(...)` instance across the manual card checkout and wallet checkout. Do not build funnel-local Stripe PaymentIntent or Express Checkout flows.
50
+
51
+ - Use `SharedStripeCheckoutV2Dialog` for the manual/card checkout modal. Open it from the paywall CTA after the checkout session has an `activeClientSecret` and `stripePromise`.
52
+ - Render real wallet slots on the paywall with `ApplePaySubscriptionCheckoutSlot` and `GooglePaySubscriptionCheckoutSlot`, usually above the manual/card CTA. Pass the same checkout session, selected plan amount, return URL, customer details, summary label, success handler, and any `beforeConfirm` logic used to finalize email or analytics metadata.
53
+ - Use `usePlatformWalletPaymentMethods()` to decide which slots to render. Desktop web can show Apple Pay and Google Pay; iOS shows Apple Pay; Android shows Google Pay.
54
+ - Track each slot's `onAvailabilityChange` result by the current `checkoutSession.intentKey`. When opening `SharedStripeCheckoutV2Dialog`, derive `initialWalletAvailable` from the current intent's confirmed wallet availability and pass it into the dialog so checkout can show the wallet tab immediately when a wallet is already known to be available.
55
+ - Treat unknown wallet availability as `null`, not `false`. Only hide/fallback from a wallet after Stripe or the shared slot reports that wallet unavailable.
56
+ - Suspend paywall wallet slots while the shared checkout dialog or another blocking offer modal is open, so two Stripe Express Checkout elements do not compete for the same active checkout session.
57
+
58
+ ## Paywall Identity Handoff
59
+
60
+ Paywall steps may be opened directly by support/admin links:
61
+
62
+ - `/paywall?user_id=u_123` resumes a known funnel user and carries that id into checkout metadata.
63
+ - `/paywall?email=user@example.com` seeds the runtime user email and Stripe `customerEmail`.
64
+ - `/paywall?user_id=u_123&email=user@example.com` is preferred when both are known.
65
+
66
+ `user_id` is the strongest join key for answers, attribution, checkout metadata, and webhook reconciliation. Email-only handoff is useful for prefilled checkout and private user lookup, but do not treat it as a full replacement for `user_id` when exact progress/subscription state is required.
67
+
68
+ Checkout return URLs should route to the subscription-started step and preserve the user id, for example `/subscription-started?user_id=u_123&source=stripe-elements`.
69
+
70
+ ## Manage Subscription Handoff
71
+
72
+ Manage subscription is a customer support route, usually `/manage-subscription`.
73
+
74
+ Supported links:
75
+
76
+ - `/manage-subscription?user_id=u_123`
77
+ - `/manage-subscription?stripe_customer_id=cus_123`
78
+ - `/manage-subscription?user_id=u_123&stripe_customer_id=cus_123`
79
+
80
+ The runtime calls `apiService.getManageSubscriptions()` and `apiService.updateSubscription(...)`, which send `user_id`, `stripe_customer_id`, and `funnelId` to the public subscriptions SDK endpoints. If neither id is present, the list endpoint returns an empty summary and mutation is rejected.
81
+
82
+ Generated support/admin links should include `user_id` and add `stripe_customer_id` when the paid customer id is known.
83
+
47
84
  ## Agent Rules
48
85
 
49
86
  - Never change a live `projectPlanId`, `providerPlanId`, coupon id, or amount casually.
50
87
  - Keep displayed price, discounted price, amount cents, coupon id, checkout payload, and analytics metadata in sync.
88
+ - Keep paywall, checkout, subscription-started, and manage-subscription identity handoff parameters in sync with [Funnel Runtime Architecture](funnel-runtime-architecture.md).
51
89
  - Track checkout starts/completions with plan id, provider plan id, coupon id, amount, and environment.
52
90
  - Keep legal, renewal, guarantee, support, and button labels in content files.
53
91
  - Test test-mode and live-mode plan resolution separately when a payment change affects both.
@@ -10,6 +10,14 @@ fgrove sync up --message '<summary>'
10
10
  fgrove publish --env preview --message '<summary>'
11
11
  ```
12
12
 
13
+ Refresh the ignored local `.env` from the remote project when project settings change:
14
+
15
+ ```bash
16
+ fgrove env pull
17
+ # or, from outside the synced funnel directory:
18
+ fgrove env pull --dir ./path/to/local-funnel
19
+ ```
20
+
13
21
  Only publish production when explicitly requested:
14
22
 
15
23
  ```bash
@@ -30,6 +38,8 @@ Local-only files are excluded from upload:
30
38
  - `.next`.
31
39
  - `out`.
32
40
 
41
+ The local `.env` is runtime material generated from remote project/funnel settings. Use `fgrove env pull` to refresh it without replacing source files.
42
+
33
43
  Do not edit generated build output as the source of truth.
34
44
 
35
45
  ## Agent Rules
@@ -26,6 +26,17 @@ Use `apiService` from `@funnelsgrove/runtime`:
26
26
 
27
27
  The flow controller already bootstraps the user, persists answer attributes, records completed steps, and connects analytics identity through the shared analytics/runtime integration. Steps should only call `apiService.updateUser(...)` directly when they collect profile data such as checkout email.
28
28
 
29
+ ## URL User Attributes
30
+
31
+ The runtime understands these public URL attributes:
32
+
33
+ - `user_id`: canonical funnel user id.
34
+ - `email`: validated user/customer email.
35
+ - `name`, `fullName`, `full_name`: profile name aliases.
36
+ - `stripe_customer_id`: Stripe customer id for subscription management.
37
+
38
+ Keep these names stable. If an SDK endpoint or route starts accepting a new identity parameter, update the URL parser, shared services, and docs together.
39
+
29
40
  ## Payment APIs
30
41
 
31
42
  Use `@funnelsgrove/payments`:
@@ -37,6 +48,25 @@ Use `@funnelsgrove/payments`:
37
48
 
38
49
  Always pass runtime checkout config so published funnels, preview funnels, and local builds resolve the correct funnel id and SDK key.
39
50
 
51
+ Checkout endpoints receive `user_id` and `customerEmail` when available. Stripe metadata and `client_reference_id` rely on these values for webhook reconciliation, analytics, and project user support links.
52
+
53
+ ## Subscription APIs
54
+
55
+ Use the runtime service methods instead of calling the endpoints directly:
56
+
57
+ - `GET /sdk/public/users/:user_id/subscription` or `GET /sdk/public/users/subscription?email=...` -> lightweight entitlement status with the resolved `user_id`, `active`, `activeUntil`, and active Stripe subscription ids.
58
+ - `POST /sdk/public/users/:user_id/claim_subscription` -> claim the first unclaimed active subscription for that `user_id`.
59
+ - `apiService.getManageSubscriptions()` -> `GET /sdk/public/subscriptions`.
60
+ - `apiService.updateSubscription(...)` -> `POST /sdk/public/subscriptions/:subscriptionId/:action`.
61
+
62
+ The status endpoint accepts `user_id` or `email` and returns the resolved `user.user_id` when a funnel user is found. The claim endpoint accepts `funnelId` in the JSON body and only resolves by path `user_id`; it returns `409` if every active subscription is already claimed.
63
+
64
+ The subscription list endpoint accepts `user_id`, `stripe_customer_id`, and `funnelId`. `user_id` resolves the funnel user first; `stripe_customer_id` can resolve the Stripe customer directly when user id is unavailable. Mutation requires `user_id` or `stripe_customer_id` and supports `cancel` or `renew`.
65
+
66
+ ## Private User Reads
67
+
68
+ Private SDK user reads are for server-side/admin use only. They can resolve a funnel user by `funnelUserId`, `user_id`, `email`, or `stripeCustomerId` when scoped by the SDK secret key and funnel. This is why paywall email handoff is still useful even when a public link does not know the user id yet.
69
+
40
70
  ## Rules
41
71
 
42
72
  - Keep payloads minimal and typed.