@funnelsgrove/cli 0.1.5 → 0.1.6
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 +2 -0
- package/dist/cli.js +38 -18
- package/dist/envSync.d.ts +17 -0
- package/dist/envSync.js +20 -0
- package/dist/localSync.d.ts +2 -0
- package/dist/localSync.js +30 -0
- package/package.json +1 -1
- package/template_docs/AGENTS.md +9 -4
- package/template_docs/docs/analytics.md +4 -0
- package/template_docs/docs/funnel-runtime-architecture.md +125 -0
- package/template_docs/docs/meta-pixel-conversions-api.md +50 -0
- package/template_docs/docs/payment-plans-and-discounts.md +38 -0
- package/template_docs/docs/publishing-and-versioning.md +10 -0
- package/template_docs/docs/sdk-api-endpoints.md +30 -0
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
|
|
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, 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
|
-
|
|
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')
|
|
@@ -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>;
|
package/dist/envSync.js
ADDED
|
@@ -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
|
+
}
|
package/dist/localSync.d.ts
CHANGED
|
@@ -30,6 +30,8 @@ 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>;
|
package/dist/localSync.js
CHANGED
|
@@ -4,6 +4,16 @@ 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
6
|
const EXCLUDED_PATH_PARTS = new Set(['node_modules', '.next', 'out']);
|
|
7
|
+
const LOCAL_ONLY_GITIGNORE_LINES = [
|
|
8
|
+
'.env',
|
|
9
|
+
'.env.local',
|
|
10
|
+
'.env.*',
|
|
11
|
+
'!.env.example',
|
|
12
|
+
SYNC_MANIFEST_FILE,
|
|
13
|
+
'node_modules',
|
|
14
|
+
'.next',
|
|
15
|
+
'out',
|
|
16
|
+
];
|
|
7
17
|
export function normalizeSyncPath(filePath) {
|
|
8
18
|
const normalized = path.posix.normalize(filePath.replaceAll('\\', '/'));
|
|
9
19
|
return normalized.replace(/^(\.\/|\/)+/, '');
|
|
@@ -43,6 +53,26 @@ export async function buildSyncManifest(rootDir, input) {
|
|
|
43
53
|
export async function writeSyncManifest(rootDir, manifest) {
|
|
44
54
|
await writeFile(path.join(rootDir, SYNC_MANIFEST_FILE), `${JSON.stringify(manifest, null, 2)}\n`);
|
|
45
55
|
}
|
|
56
|
+
export async function ensureGitignore(rootDir) {
|
|
57
|
+
const gitignorePath = path.join(rootDir, '.gitignore');
|
|
58
|
+
const existing = await readFile(gitignorePath, 'utf8').catch(() => '');
|
|
59
|
+
const lines = existing.split(/\r?\n/g).filter(Boolean);
|
|
60
|
+
const seen = new Set(lines);
|
|
61
|
+
for (const line of LOCAL_ONLY_GITIGNORE_LINES) {
|
|
62
|
+
if (!seen.has(line)) {
|
|
63
|
+
lines.push(line);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
await writeFile(gitignorePath, `${lines.join('\n')}\n`, 'utf8');
|
|
67
|
+
}
|
|
68
|
+
export async function writeLocalEnvFile(rootDir, envFile) {
|
|
69
|
+
if (!envFile) {
|
|
70
|
+
return false;
|
|
71
|
+
}
|
|
72
|
+
await mkdir(rootDir, { recursive: true });
|
|
73
|
+
await writeFile(path.join(rootDir, '.env'), envFile, 'utf8');
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
46
76
|
export async function readSyncManifest(rootDir) {
|
|
47
77
|
try {
|
|
48
78
|
const raw = JSON.parse(await readFile(path.join(rootDir, SYNC_MANIFEST_FILE), 'utf8'));
|
package/package.json
CHANGED
package/template_docs/AGENTS.md
CHANGED
|
@@ -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
|
|
90
|
-
4. Run
|
|
91
|
-
5. Run `fgrove
|
|
92
|
-
6.
|
|
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.
|