@funnelsgrove/cli 0.1.29 → 0.1.43

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.
Files changed (53) hide show
  1. package/dist/cli.d.ts +44 -0
  2. package/dist/cli.js +74 -24
  3. package/funnel-contract-compatibility.json +56 -56
  4. package/package.json +4 -3
  5. package/template_docs/.funnelsgrove-docs.json +7 -7
  6. package/template_docs/docs/funnelsgrove/START-HERE.md +4 -1
  7. package/template_docs/docs/funnelsgrove/contracts/analytics-events.md +4 -2
  8. package/template_docs/docs/funnelsgrove/contracts/payments.md +3 -1
  9. package/template_docs/docs/funnelsgrove/migrations/step-contract-v3.md +1 -1
  10. package/template_docs/docs/funnelsgrove/qa/analytics.md +3 -1
  11. package/template_docs/funnel-docs.config.json +1 -1
  12. package/template_scaffold/.funnelsgrove-docs.json +7 -7
  13. package/template_scaffold/.funnelsgrove-scaffold.json +159 -154
  14. package/template_scaffold/docs/funnelsgrove/START-HERE.md +4 -1
  15. package/template_scaffold/docs/funnelsgrove/contracts/analytics-events.md +4 -2
  16. package/template_scaffold/docs/funnelsgrove/contracts/payments.md +3 -1
  17. package/template_scaffold/docs/funnelsgrove/migrations/step-contract-v3.md +1 -1
  18. package/template_scaffold/docs/funnelsgrove/qa/analytics.md +3 -1
  19. package/template_scaffold/funnel-docs.config.json +1 -1
  20. package/template_scaffold/package-lock.json +13 -13
  21. package/template_scaffold/package.json +3 -3
  22. package/template_scaffold/public/paywall/money-back-badge.png +0 -0
  23. package/template_scaffold/src/steps/step-32-paywall.tsx +2 -2
  24. package/template_scaffold/src/steps/step-33-subscription-started.tsx +1 -43
  25. package/template_scaffold/{funnel-agent-docs.test.ts → tests/funnel-agent-docs.test.ts} +29 -19
  26. package/template_scaffold/{package.test.ts → tests/package.test.ts} +4 -4
  27. package/template_scaffold/{src → tests/src}/app/layout.test.ts +2 -2
  28. package/template_scaffold/{src → tests/src}/components/FunnelFlow.test.tsx +2 -2
  29. package/template_scaffold/{src → tests/src}/config/billing.plans.test.ts +4 -4
  30. package/template_scaffold/{src → tests/src}/config/experiments.test.ts +1 -1
  31. package/template_scaffold/{src → tests/src}/config/funnel.manifest.test.ts +1 -1
  32. package/template_scaffold/{src → tests/src}/contract/funnel-project.validation.test.ts +2 -2
  33. package/template_scaffold/{src → tests/src}/contract/golden/golden-fixtures.test.ts +16 -16
  34. package/template_scaffold/{src → tests/src}/contract/validate-funnel.cli.test.ts +2 -2
  35. package/template_scaffold/{src → tests/src}/runtime/checkout-runtime-config.test.ts +3 -3
  36. package/template_scaffold/{src → tests/src}/runtime/funnel-runtime.test.ts +1 -1
  37. package/template_scaffold/{src → tests/src}/runtime/step-content-context.test.ts +1 -1
  38. package/template_scaffold/{src → tests/src}/runtime/use-funnel-flow-controller.integration.test.ts +4 -4
  39. package/template_scaffold/{src → tests/src}/runtime/use-funnel-flow-controller.test.ts +3 -3
  40. package/template_scaffold/{src → tests/src}/steps/paywall-checkout-options.test.ts +1 -1
  41. package/template_scaffold/{src → tests/src}/steps/shared-header-contract.test.ts +3 -1
  42. package/template_scaffold/{src → tests/src}/steps/step-02.test.ts +3 -3
  43. package/template_scaffold/{src → tests/src}/steps/step-03.test.ts +1 -1
  44. package/template_scaffold/{src → tests/src}/steps/step-04.test.ts +1 -1
  45. package/template_scaffold/{src → tests/src}/steps/step-31-email-capture.test.ts +1 -1
  46. package/template_scaffold/{src → tests/src}/steps/step-32-paywall.test.ts +43 -43
  47. package/template_scaffold/{src → tests/src}/steps/step-33-subscription-started.test.ts +6 -7
  48. package/template_scaffold/{src → tests/src}/steps/step-34-upsell-form.test.ts +3 -3
  49. package/template_scaffold/{src → tests/src}/steps/step-image-migration.test.ts +1 -1
  50. package/template_scaffold/vercel.json +6 -0
  51. /package/template_scaffold/{src → tests/src}/app/[stepId]/page.test.tsx +0 -0
  52. /package/template_scaffold/{src → tests/src}/components/FunnelStepImage.test.ts +0 -0
  53. /package/template_scaffold/{src → tests/src}/steps/step-01.test.ts +0 -0
@@ -54,7 +54,10 @@ Contract hash: `d761e91d5ac6ff9e72c6d49c5bcd014270912473a66f99c49d726c65998085cf
54
54
  - A paywall presents purchasable plans. A loading, reveal, or “ready” screen is an interstitial.
55
55
  - Every choice declares `choice.answerKey`, stores stable option IDs in the exact scalar/array shape, and uses `useStepChoices`.
56
56
  - Email capture uses `submitEmailCapture`; it never manually sends analytics or hard-codes paywall navigation.
57
- - `subscription-started` is the `purchase_completed` terminal. Browser terminal reach is not Quiz completed; only the trusted backend records `purchase_completed` after verifying an initial purchase.
57
+ - Quiz completed counts each unique user who reaches a paywall step.
58
+ - `purchase_completed` remains trusted-server-only purchase verification and does not define Quiz completed.
59
+ - Browser terminal reach and `funnel_completed` remain lifecycle-only and are not purchase conversions.
60
+ - `subscription-started` remains the contract-v3 `purchase_completed` terminal type, and only the trusted backend records the purchase event after verifying an initial purchase.
58
61
  - Let the controller distinguish completion from exit and suppress provider delivery in preview.
59
62
 
60
63
  ## Standard workflow
@@ -24,7 +24,9 @@ Contract hash: `d761e91d5ac6ff9e72c6d49c5bcd014270912473a66f99c49d726c65998085cf
24
24
  | `step_exited` | `step_exited` | `step_exit` | `any` | `any` | `controller` | `true` | `step-visit` | `funnelId`, `funnelVersionId`, `environment`, `stepContractVersion`, `stepId`, `stepName`, `stepType` |
25
25
  | `step_started` | `step_started` | `step_start` | `any` | `any` | `controller` | `true` | `step-visit` | `funnelId`, `funnelVersionId`, `environment`, `stepContractVersion`, `stepId`, `stepName`, `stepType` |
26
26
 
27
- The `purchase_completed` event is owned by `trusted-server` and is recorded only after the backend verifies a successful initial purchase. Entering the browser terminal or emitting `funnel_completed` does not create this business conversion. The separate `registration_completed` event remains owned by `trusted-server` for the later app-link claim. The `email_captured` event is owned by `transactional-server` and uses `logical-email-conversion` deduplication.
27
+ Quiz completed counts each unique user who reaches a paywall step. `purchase_completed` remains trusted-server-only purchase verification and does not define Quiz completed. Browser terminal reach and `funnel_completed` remain lifecycle-only and are not purchase conversions.
28
+
29
+ The `purchase_completed` event is owned by `trusted-server` and is recorded only after the backend verifies a successful initial purchase. The separate `registration_completed` event remains owned by `trusted-server` for the later app-link claim. The `email_captured` event is owned by `transactional-server` and uses `logical-email-conversion` deduplication.
28
30
  <!-- funnelsgrove:generated:end contract-v3/contract/analytics-events -->
29
31
 
30
32
  ## Ownership rules
@@ -34,7 +36,7 @@ The `purchase_completed` event is owned by `trusted-server` and is recorded only
34
36
  - `submitEmailCapture` creates `email_captured` transactionally, then routes only after success.
35
37
  - Named payment helpers emit checkout semantic events at real payment boundaries.
36
38
  - The terminal helper owns funnel completion.
37
- - A trusted backend owns `purchase_completed` after verifying an initial subscription or one-time payment; browser code never emits it, and terminal reach does not count as Quiz completed.
39
+ - A trusted backend owns `purchase_completed` after verifying an initial subscription or one-time payment; browser code never emits it. This purchase event is independent from the paywall-reach definition of Quiz completed.
38
40
  - A separate trusted server claim owns `registration_completed` when the purchased subscription is later linked in the app.
39
41
  - Provider adapters and the durable outbox own PostHog and server Meta delivery.
40
42
 
@@ -37,6 +37,8 @@ A paywall presents purchasable plans. A reveal, loading state, calculation, or
37
37
 
38
38
  ## Purchase completion separation
39
39
 
40
- The canonical `subscription-started` route keeps its stable identity but uses the `purchase_completed` terminal type in contract v3. It may emit lifecycle-only `funnel_completed`, but entering it never counts as Quiz completed. Only the trusted backend emits `purchase_completed` after verifying an initial subscription or one-time payment; renewals, pending payments, and browser checkout returns do not qualify. `registration_completed` remains the later app-link conversion.
40
+ Quiz completed counts each unique user who reaches a paywall step. `purchase_completed` remains trusted-server-only purchase verification and does not define Quiz completed. Browser terminal reach and `funnel_completed` remain lifecycle-only and are not purchase conversions.
41
+
42
+ The canonical `subscription-started` route keeps its stable identity and remains the `purchase_completed` terminal type in contract v3. Only the trusted backend emits the purchase event after verifying an initial subscription or one-time payment; renewals, pending payments, and browser checkout returns do not qualify. `registration_completed` remains the later app-link conversion.
41
43
 
42
44
  Before publishing, complete [paywall/checkout QA](../qa/paywall-checkout.md) and [publish QA](../qa/publish.md).
@@ -17,7 +17,7 @@ Supported read versions: `1`, `2`, `3`. Authoring and publish target version `3`
17
17
 
18
18
  ### Package release order
19
19
 
20
- Release `@funnelsgrove/runtime` `0.1.60` first, then `@funnelsgrove/analytics` `0.1.37`, then `@funnelsgrove/payments` `0.1.55`. Deploy the API and funnel template, then confirm production `/health` reports the new docs identity. Only then publish `@funnelsgrove/cli` `0.1.29`. Publishing packages and deploying production remain separately approved operational actions.
20
+ Release `@funnelsgrove/runtime` `0.1.63` first, then `@funnelsgrove/analytics` `0.1.41`, then `@funnelsgrove/payments` `0.1.58`. Deploy the API and funnel template, then confirm production `/health` reports the new docs identity. Only then publish `@funnelsgrove/cli` `0.1.43`. Publishing packages and deploying production remain separately approved operational actions.
21
21
  <!-- funnelsgrove:generated:end contract-v3/migration/step-contract-v3 -->
22
22
 
23
23
  ## Version-last policy
@@ -32,7 +32,9 @@ Contract hash: `d761e91d5ac6ff9e72c6d49c5bcd014270912473a66f99c49d726c65998085cf
32
32
  - Choice steps save the exact stable ID shape before completion.
33
33
  - Email success creates one server conversion; duplicate capture skips browser fanout; failure changes no state/lifecycle/route.
34
34
  - Checkout helpers fire only at real boundaries with complete metadata and stable event IDs.
35
- - Quiz completed requires trusted-server `purchase_completed`, deduplicated by `provider-payment`; browser `funnel_completed` is lifecycle-only and never satisfies it.
35
+ - Quiz completed counts each unique user who reaches a paywall step.
36
+ - `purchase_completed` remains trusted-server-only purchase verification and does not define Quiz completed.
37
+ - Browser terminal reach and `funnel_completed` remain lifecycle-only and are not purchase conversions.
36
38
  - `registration_completed` remains the separate later app-link claim.
37
39
  - Preview sends no API/provider analytics.
38
40
  - No component sends PostHog or server Meta directly.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "bundleVersion": "2.0.17",
3
+ "bundleVersion": "2.0.31",
4
4
  "contractSource": "funnelsgrove-repository://apps/funnel-runtime/contracts/step-contract-v2.json",
5
5
  "fullyGenerated": [
6
6
  ".funnelsgrove-docs.json",
@@ -8,9 +8,9 @@
8
8
  "name": "funnel-template",
9
9
  "version": "0.1.0",
10
10
  "dependencies": {
11
- "@funnelsgrove/analytics": "^0.1.37",
12
- "@funnelsgrove/payments": "^0.1.55",
13
- "@funnelsgrove/runtime": "^0.1.60",
11
+ "@funnelsgrove/analytics": "^0.1.41",
12
+ "@funnelsgrove/payments": "^0.1.58",
13
+ "@funnelsgrove/runtime": "^0.1.63",
14
14
  "@stripe/react-stripe-js": "^5.6.0",
15
15
  "@stripe/stripe-js": "^8.7.0",
16
16
  "lucide-react": "^0.553.0",
@@ -891,17 +891,17 @@
891
891
  }
892
892
  },
893
893
  "node_modules/@funnelsgrove/analytics": {
894
- "version": "0.1.37",
895
- "resolved": "https://registry.npmjs.org/@funnelsgrove/analytics/-/analytics-0.1.37.tgz",
896
- "integrity": "sha512-MlXNdsGmNZNpDl5k1J4jKMsB8PYXVTZ2QZjhvSL3Ii0QKoCD9Gru/PprS/TlJ6jQ0Hscy/0vqGB3FZ6sa2Fwag==",
894
+ "version": "0.1.41",
895
+ "resolved": "https://registry.npmjs.org/@funnelsgrove/analytics/-/analytics-0.1.41.tgz",
896
+ "integrity": "sha512-RrAlBajsJawxpG1ijthRhGcuyqoPCP29r4XNu3K980zrtkWidYu0E4TqBPdPHTmhBb6WNnMcfVHV6H7XaqkQ8A==",
897
897
  "dependencies": {
898
- "@funnelsgrove/runtime": "0.1.60"
898
+ "@funnelsgrove/runtime": "0.1.63"
899
899
  }
900
900
  },
901
901
  "node_modules/@funnelsgrove/payments": {
902
- "version": "0.1.55",
903
- "resolved": "https://registry.npmjs.org/@funnelsgrove/payments/-/payments-0.1.55.tgz",
904
- "integrity": "sha512-zrlnyPwUsqSYkaDYASBkB3bzNVSzS0+4glKC2+ai/+fIi4SZqEOAEo51WxR3jt0s5dAX2nFdX08r/YlvY7XqyA==",
902
+ "version": "0.1.58",
903
+ "resolved": "https://registry.npmjs.org/@funnelsgrove/payments/-/payments-0.1.58.tgz",
904
+ "integrity": "sha512-y1caUil8SFNiuYf/TxptheEf/cnK2urafy2eF2gFdfugfaybFiqUiyTzEVen+K+uKtX8tDCk2UtNeyWMO8inAA==",
905
905
  "dependencies": {
906
906
  "@funnelsgrove/analytics": "^0.1.28",
907
907
  "@funnelsgrove/runtime": "^0.1.54",
@@ -913,9 +913,9 @@
913
913
  }
914
914
  },
915
915
  "node_modules/@funnelsgrove/runtime": {
916
- "version": "0.1.60",
917
- "resolved": "https://registry.npmjs.org/@funnelsgrove/runtime/-/runtime-0.1.60.tgz",
918
- "integrity": "sha512-ftjWXqcXmzPKlESo57B4o6VHc0NFiyxJrKB0CYMqVcxgBo9O5d7h/lZhs/1Wi3pQNRsBSGOfs//MUC/hJQ/NOw==",
916
+ "version": "0.1.63",
917
+ "resolved": "https://registry.npmjs.org/@funnelsgrove/runtime/-/runtime-0.1.63.tgz",
918
+ "integrity": "sha512-D0y7l/GwVc519CBr3TCxsXvr3H+B9RmcvebBUKks+FqdDx5j2AO5qdUbB838bqkBNCCjHOtcbs3u2Gsm4Qhk4w==",
919
919
  "dependencies": {
920
920
  "posthog-js": "^1.369.2",
921
921
  "react": "19.2.3",
@@ -12,9 +12,9 @@
12
12
  "validate:funnel": "vite-node --config src/contract/funnel-validator.vite.config.ts src/contract/validate-funnel.cli.ts"
13
13
  },
14
14
  "dependencies": {
15
- "@funnelsgrove/analytics": "^0.1.37",
16
- "@funnelsgrove/payments": "^0.1.55",
17
- "@funnelsgrove/runtime": "^0.1.60",
15
+ "@funnelsgrove/analytics": "^0.1.41",
16
+ "@funnelsgrove/payments": "^0.1.58",
17
+ "@funnelsgrove/runtime": "^0.1.63",
18
18
  "@stripe/react-stripe-js": "^5.6.0",
19
19
  "@stripe/stripe-js": "^8.7.0",
20
20
  "lucide-react": "^0.553.0",
@@ -463,7 +463,7 @@ export function StepPaywall({
463
463
  return;
464
464
  }
465
465
 
466
- const readyClientSecret = await checkoutSession.prepareCardCheckout();
466
+ const readyClientSecret = await checkoutSession.prepareCardCheckout({ checkoutStartSource: 'checkout_render' });
467
467
  if (readyClientSecret) {
468
468
  setCheckoutDialogOpen(true);
469
469
  }
@@ -510,7 +510,7 @@ export function StepPaywall({
510
510
  }
511
511
 
512
512
  const readyClientSecret =
513
- checkoutSession.activeClientSecret ?? await checkoutSession.prepareCardCheckout();
513
+ checkoutSession.activeClientSecret ?? await checkoutSession.prepareCardCheckout({ checkoutStartSource: 'checkout_render' });
514
514
  if (readyClientSecret) {
515
515
  setCheckoutDialogOpen(true);
516
516
  }
@@ -1,8 +1,6 @@
1
1
  // PLAN_STEP_PLACEHOLDER_ID:subscription-started
2
2
  'use client';
3
3
 
4
- import { useEffect } from 'react';
5
- import { publicAnalyticsSdk } from '@funnelsgrove/analytics';
6
4
  import {
7
5
  SubscriptionHandoffScreen,
8
6
  usePreviewStepLocalizedContent,
@@ -11,7 +9,6 @@ import {
11
9
  } from '@funnelsgrove/runtime';
12
10
  import { getStepContentLocale } from '@/runtime/step-content-context';
13
11
  import { subscriptionStartedContent } from '@/steps/content/subscription-started.content';
14
- import { funnelManifest } from '@/config/funnel.manifest';
15
12
 
16
13
  export const stepSubscriptionStartedId = 'subscription-started';
17
14
 
@@ -29,52 +26,13 @@ export const stepSubscriptionStarted: FunnelStepMeta = {
29
26
  };
30
27
 
31
28
  export function StepSubscriptionStarted() {
32
- const { user, attributes } = useFunnel();
29
+ const { attributes } = useFunnel();
33
30
  const content = usePreviewStepLocalizedContent(
34
31
  stepSubscriptionStartedId,
35
32
  subscriptionStartedContent,
36
33
  getStepContentLocale(attributes),
37
34
  );
38
35
 
39
- useEffect(() => {
40
- if (typeof window === 'undefined') {
41
- return;
42
- }
43
-
44
- const searchParams = new URLSearchParams(window.location.search);
45
- const paymentIntent = searchParams.get('payment_intent');
46
- const redirectStatus = searchParams.get('redirect_status');
47
- if (!paymentIntent || !redirectStatus) {
48
- return;
49
- }
50
-
51
- const dedupeKey = `app-deals:payment-intent:${paymentIntent}`;
52
- if (window.sessionStorage.getItem(dedupeKey) === '1') {
53
- return;
54
- }
55
- window.sessionStorage.setItem(dedupeKey, '1');
56
-
57
- const eventInput = {
58
- stepId: stepSubscriptionStartedId,
59
- stepName: stepSubscriptionStarted.name || stepSubscriptionStarted.title,
60
- stepType: stepSubscriptionStarted.type,
61
- stepContractVersion: funnelManifest.stepContractVersion,
62
- metadata: {
63
- source: searchParams.get('source') || null,
64
- paymentIntent,
65
- redirectStatus,
66
- user_id: searchParams.get('user_id') || null,
67
- funnelUserId: user.id || null,
68
- },
69
- };
70
-
71
- if (redirectStatus === 'succeeded') {
72
- publicAnalyticsSdk.trackPaymentCheckoutSucceeded(eventInput);
73
- } else {
74
- publicAnalyticsSdk.trackPaymentCheckoutReturned(eventInput);
75
- }
76
- }, [user.id]);
77
-
78
36
  return (
79
37
  <SubscriptionHandoffScreen
80
38
  stepId={stepSubscriptionStartedId}
@@ -7,9 +7,9 @@ import { promisify } from 'node:util';
7
7
  import { fileURLToPath } from 'node:url';
8
8
  import { describe, expect, it } from 'vitest';
9
9
 
10
- import contract from '../funnel-runtime/contracts/step-contract-v2.json';
10
+ import contract from '../../funnel-runtime/contracts/step-contract-v2.json';
11
11
 
12
- const templateRoot = dirname(fileURLToPath(import.meta.url));
12
+ const templateRoot = dirname(dirname(fileURLToPath(import.meta.url)));
13
13
  const repoRoot = resolve(templateRoot, '../..');
14
14
  const cliDocsRoot = join(repoRoot, 'apps/cli/template_docs');
15
15
 
@@ -34,7 +34,7 @@ const renderCodes = (values: readonly unknown[]): string => (
34
34
  );
35
35
 
36
36
  async function loadGenerator() {
37
- return import('../../scripts/generate-funnel-agent-docs.mjs');
37
+ return import('../../../scripts/generate-funnel-agent-docs.mjs');
38
38
  }
39
39
 
40
40
  describe('funnel agent documentation supply', () => {
@@ -300,21 +300,31 @@ describe('funnel agent documentation supply', () => {
300
300
  }
301
301
  });
302
302
 
303
- it('requires trusted purchase completion for the Quiz completed QA assertion', async () => {
304
- const content = await readFile(
305
- join(templateRoot, 'docs/funnelsgrove/qa/analytics.md'),
306
- 'utf8',
307
- );
308
-
309
- expect(content).toContain(
310
- 'Quiz completed requires trusted-server `purchase_completed`, deduplicated by `provider-payment`',
311
- );
312
- expect(content).toContain(
313
- 'browser `funnel_completed` is lifecycle-only and never satisfies it',
314
- );
315
- expect(content).toContain(
316
- '`registration_completed` remains the separate later app-link claim',
317
- );
303
+ it('defines Quiz completed by unique paywall reach without conflating purchase verification', async () => {
304
+ const paths = [
305
+ 'docs/funnelsgrove/qa/analytics.md',
306
+ 'docs/funnelsgrove/contracts/analytics-events.md',
307
+ 'docs/funnelsgrove/START-HERE.md',
308
+ 'docs/funnelsgrove/contracts/payments.md',
309
+ ];
310
+ const contents = await Promise.all(paths.map(async (path) => ({
311
+ path,
312
+ content: await readFile(join(templateRoot, path), 'utf8'),
313
+ })));
314
+
315
+ for (const { path, content } of contents) {
316
+ expect(content, path).toContain(
317
+ 'Quiz completed counts each unique user who reaches a paywall step.',
318
+ );
319
+ expect(content, path).toContain(
320
+ '`purchase_completed` remains trusted-server-only purchase verification and does not define Quiz completed.',
321
+ );
322
+ expect(content, path).toContain(
323
+ 'Browser terminal reach and `funnel_completed` remain lifecycle-only and are not purchase conversions.',
324
+ );
325
+ expect(content, path).not.toMatch(/Quiz completed requires[^.]*purchase_completed/i);
326
+ expect(content, path).not.toMatch(/terminal reach[^.]*not (?:count|counts) as Quiz completed/i);
327
+ }
318
328
  });
319
329
 
320
330
  it('preserves the implementation-owned visitor metric while linking event ownership', async () => {
@@ -340,7 +350,7 @@ describe('funnel agent documentation supply', () => {
340
350
 
341
351
  expect(manifest).toMatchObject({
342
352
  schemaVersion: 1,
343
- bundleVersion: '2.0.17',
353
+ bundleVersion: '2.0.31',
344
354
  stepContractVersion: contract.stepContractVersion,
345
355
  contractHash: contract.contractHash,
346
356
  });
@@ -11,10 +11,10 @@ const readPackageJson = (url: URL): PackageJson =>
11
11
 
12
12
  describe('funnel-template shared package dependencies', () => {
13
13
  it('tracks the current shared runtime, analytics, and payments package versions', () => {
14
- const templatePackage = readPackageJson(new URL('./package.json', import.meta.url));
15
- const runtimePackage = readPackageJson(new URL('../funnel-runtime/package.json', import.meta.url));
16
- const analyticsPackage = readPackageJson(new URL('../funnel-analytics/package.json', import.meta.url));
17
- const paymentsPackage = readPackageJson(new URL('../funnel-payments/package.json', import.meta.url));
14
+ const templatePackage = readPackageJson(new URL('../package.json', import.meta.url));
15
+ const runtimePackage = readPackageJson(new URL('../../funnel-runtime/package.json', import.meta.url));
16
+ const analyticsPackage = readPackageJson(new URL('../../funnel-analytics/package.json', import.meta.url));
17
+ const paymentsPackage = readPackageJson(new URL('../../funnel-payments/package.json', import.meta.url));
18
18
 
19
19
  expect(templatePackage.dependencies?.['@funnelsgrove/runtime']).toBe(`^${runtimePackage.version}`);
20
20
  expect(templatePackage.dependencies?.['@funnelsgrove/analytics']).toBe(`^${analyticsPackage.version}`);
@@ -4,7 +4,7 @@ import { describe, expect, it } from 'vitest';
4
4
 
5
5
  describe('RootLayout metadata source', () => {
6
6
  it('wires project page metadata settings into Next metadata', () => {
7
- const layoutSource = readFileSync(path.resolve(__dirname, './layout.tsx'), 'utf8');
7
+ const layoutSource = readFileSync(path.resolve(__dirname, '../../../src/app/layout.tsx'), 'utf8');
8
8
 
9
9
  expect(layoutSource).toContain('runtimePublicConfig.pageTitle');
10
10
  expect(layoutSource).toContain('runtimePublicConfig.pageDescription');
@@ -15,7 +15,7 @@ describe('RootLayout metadata source', () => {
15
15
  });
16
16
 
17
17
  it('renders Google tag head scripts through the shared analytics package', () => {
18
- const layoutSource = readFileSync(path.resolve(__dirname, './layout.tsx'), 'utf8');
18
+ const layoutSource = readFileSync(path.resolve(__dirname, '../../../src/app/layout.tsx'), 'utf8');
19
19
 
20
20
  expect(layoutSource).toContain("import { buildGoogleTagHeadScripts } from '@funnelsgrove/analytics'");
21
21
  expect(layoutSource).toContain('const googleTagHeadScripts = buildGoogleTagHeadScripts();');
@@ -3,9 +3,9 @@ import { describe, expect, it } from 'vitest';
3
3
 
4
4
  describe('FunnelFlow lifecycle wiring', () => {
5
5
  it('delegates one analytics adapter and the authoritative manifest version to the shared controller', () => {
6
- const flowSource = readFileSync(new URL('./FunnelFlow.tsx', import.meta.url), 'utf8');
6
+ const flowSource = readFileSync(new URL('../../../src/components/FunnelFlow.tsx', import.meta.url), 'utf8');
7
7
  const adapterSource = readFileSync(
8
- new URL('../runtime/use-funnel-flow-controller.ts', import.meta.url),
8
+ new URL('../../../src/runtime/use-funnel-flow-controller.ts', import.meta.url),
9
9
  'utf8',
10
10
  );
11
11
 
@@ -45,7 +45,7 @@ describe('claimbee billing contract', () => {
45
45
  });
46
46
 
47
47
  it('exports minimal live billing config and normalized discounts', async () => {
48
- const billingModule = await import('./billing.plans');
48
+ const billingModule = await import('../../../src/config/billing.plans');
49
49
  const {
50
50
  billing,
51
51
  billingDiscountList,
@@ -87,7 +87,7 @@ describe('claimbee billing contract', () => {
87
87
 
88
88
  it('uses test provider price ids when Stripe mode is test', async () => {
89
89
  stubRuntimeMode('test');
90
- const { billing } = await import('./billing.plans');
90
+ const { billing } = await import('../../../src/config/billing.plans');
91
91
 
92
92
  expect(billing.plans.primary.projectPlanId).toBe(CLAIMBEE_PROJECT_PLAN_IDS.weekly);
93
93
  expect(billing.plans.primary.providerPlanId).toBe(CLAIMBEE_TEST_PROVIDER_PLAN_IDS.weekly);
@@ -98,7 +98,7 @@ describe('claimbee billing contract', () => {
98
98
  });
99
99
 
100
100
  it('exports the split test plan catalog under the generic billingTestPlans name', async () => {
101
- const billingTestModule = await import('./billing.test.plans') as Record<string, unknown>;
101
+ const billingTestModule = await import('../../../src/config/billing.test.plans') as Record<string, unknown>;
102
102
 
103
103
  expect(billingTestModule.billingTestPlans).toEqual({
104
104
  primary: expect.objectContaining({
@@ -117,7 +117,7 @@ describe('claimbee billing contract', () => {
117
117
  });
118
118
 
119
119
  it('lets payments helpers derive ordered paywall plans from semantic keys', async () => {
120
- const { billingPlans } = await import('./billing.plans');
120
+ const { billingPlans } = await import('../../../src/config/billing.plans');
121
121
 
122
122
  expect(buildConfigPaywallPlans(billingPlans, ['primary', 'secondary', 'tertiary'])).toEqual([
123
123
  expect.objectContaining({
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it } from 'vitest';
2
- import { experiments, manifestExperiments } from './experiments';
2
+ import { experiments, manifestExperiments } from '../../../src/config/experiments';
3
3
 
4
4
  describe('funnel experiments config', () => {
5
5
  it('ships the generic template without brand-specific experiments', () => {
@@ -8,7 +8,7 @@ import {
8
8
  import { stepRegistry } from '@/runtime/step-registry';
9
9
  import { stepTwoContent, stepTwoOptions } from '@/steps/content/step-02.content';
10
10
  import { contentOptionsByStepId } from '@/steps/step-content.registry';
11
- import { funnelManifest } from './funnel.manifest';
11
+ import { funnelManifest } from '../../../src/config/funnel.manifest';
12
12
 
13
13
  describe('funnel manifest breakpoints', () => {
14
14
  it('ships the four default QA breakpoints for new funnels', () => {
@@ -5,14 +5,14 @@ import {
5
5
  CURRENT_STEP_CONTRACT_VERSION,
6
6
  type FunnelStepContractProjectSnapshot,
7
7
  } from '@funnelsgrove/runtime';
8
- import docsManifest from '../../.funnelsgrove-docs.json';
8
+ import docsManifest from '../../../.funnelsgrove-docs.json';
9
9
  import { manifestExperiments } from '@/config/experiments';
10
10
  import { funnelManifest, rawFunnelManifest } from '@/config/funnel.manifest';
11
11
  import { stepRegistry } from '@/runtime/step-registry';
12
12
  import { contentOptionsByStepId } from '@/steps/step-content.registry';
13
13
 
14
14
  const loadProjectValidation = async () => {
15
- return import('./funnel-project.validation');
15
+ return import('../../../src/contract/funnel-project.validation');
16
16
  };
17
17
 
18
18
  describe('funnel project validation', () => {
@@ -9,22 +9,22 @@ import {
9
9
  type FunnelManifest,
10
10
  } from '@funnelsgrove/runtime';
11
11
  import { publicAnalyticsSdk } from '@funnelsgrove/analytics';
12
- import { goldenFixture as cancellationOffer } from './cancellation_offer.fixture';
13
- import { goldenFixture as checkout } from './checkout.fixture';
14
- import { goldenFixture as purchaseCompleted } from './purchase_completed.fixture';
15
- import { goldenFixture as formInput } from './form_input.fixture';
16
- import { goldenFixture as introHero } from './intro_hero.fixture';
17
- import { goldenFixture as multiSelectChoice } from './multi_select_choice.fixture';
18
- import { goldenFixture as paywallOffer } from './paywall_offer.fixture';
19
- import { goldenFixture as progressInterstitial } from './progress_interstitial.fixture';
20
- import { goldenFixture as singleStepChoice } from './single_step_choice.fixture';
21
- import { goldenFixture as singleStepChoiceEmoji } from './single_step_choice_emoji.fixture';
22
- import { goldenFixture as socialProof } from './social_proof.fixture';
23
- import { goldenFixture as subscriptionHandoff } from './subscription_handoff.fixture';
24
- import { goldenFixture as subscriptionManagement } from './subscription_management.fixture';
25
- import { goldenFixture as summaryConfirmation } from './summary_confirmation.fixture';
26
- import { goldenFixture as upsellOffer } from './upsell_offer.fixture';
27
- import { goldenFixture as valuePropStory } from './value_prop_story.fixture';
12
+ import { goldenFixture as cancellationOffer } from '../../../../src/contract/golden/cancellation_offer.fixture';
13
+ import { goldenFixture as checkout } from '../../../../src/contract/golden/checkout.fixture';
14
+ import { goldenFixture as purchaseCompleted } from '../../../../src/contract/golden/purchase_completed.fixture';
15
+ import { goldenFixture as formInput } from '../../../../src/contract/golden/form_input.fixture';
16
+ import { goldenFixture as introHero } from '../../../../src/contract/golden/intro_hero.fixture';
17
+ import { goldenFixture as multiSelectChoice } from '../../../../src/contract/golden/multi_select_choice.fixture';
18
+ import { goldenFixture as paywallOffer } from '../../../../src/contract/golden/paywall_offer.fixture';
19
+ import { goldenFixture as progressInterstitial } from '../../../../src/contract/golden/progress_interstitial.fixture';
20
+ import { goldenFixture as singleStepChoice } from '../../../../src/contract/golden/single_step_choice.fixture';
21
+ import { goldenFixture as singleStepChoiceEmoji } from '../../../../src/contract/golden/single_step_choice_emoji.fixture';
22
+ import { goldenFixture as socialProof } from '../../../../src/contract/golden/social_proof.fixture';
23
+ import { goldenFixture as subscriptionHandoff } from '../../../../src/contract/golden/subscription_handoff.fixture';
24
+ import { goldenFixture as subscriptionManagement } from '../../../../src/contract/golden/subscription_management.fixture';
25
+ import { goldenFixture as summaryConfirmation } from '../../../../src/contract/golden/summary_confirmation.fixture';
26
+ import { goldenFixture as upsellOffer } from '../../../../src/contract/golden/upsell_offer.fixture';
27
+ import { goldenFixture as valuePropStory } from '../../../../src/contract/golden/value_prop_story.fixture';
28
28
 
29
29
  const fixtures = [
30
30
  cancellationOffer,
@@ -12,7 +12,7 @@ import {
12
12
  const execFileAsync = promisify(execFile);
13
13
 
14
14
  const loadCli = async () => {
15
- return import('./validate-funnel.cli');
15
+ return import('../../../src/contract/validate-funnel.cli');
16
16
  };
17
17
 
18
18
  const diagnostic = createFunnelContractDiagnostic({
@@ -150,7 +150,7 @@ describe('validate funnel CLI protocol', () => {
150
150
  });
151
151
 
152
152
  it('runs through vite-node with one clean stdout protocol object', async () => {
153
- const templateRoot = path.resolve(fileURLToPath(new URL('../..', import.meta.url)));
153
+ const templateRoot = path.resolve(fileURLToPath(new URL('../../..', import.meta.url)));
154
154
  const viteNode = path.resolve(templateRoot, '../../node_modules/.bin/vite-node');
155
155
  const script = path.resolve(templateRoot, 'src/contract/validate-funnel.cli.ts');
156
156
 
@@ -23,7 +23,7 @@ describe('checkoutRuntimeConfig', () => {
23
23
  it('does not invent a checkout SDK publishable key from generic Stripe env', async () => {
24
24
  process.env.NEXT_PUBLIC_STRIPE_TEST_PUBLISHABLE_KEY = 'pk_test_generic_runtime_value';
25
25
 
26
- const { checkoutRuntimeConfig } = await import('./checkout-runtime-config');
26
+ const { checkoutRuntimeConfig } = await import('../../../src/runtime/checkout-runtime-config');
27
27
 
28
28
  expect(checkoutRuntimeConfig.funnelSdkPublishableKey).toBeNull();
29
29
  });
@@ -43,7 +43,7 @@ describe('checkoutRuntimeConfig', () => {
43
43
  };
44
44
  });
45
45
 
46
- const { checkoutRuntimeConfig, resolveClaimbeeCheckoutMode } = await import('./checkout-runtime-config');
46
+ const { checkoutRuntimeConfig, resolveClaimbeeCheckoutMode } = await import('../../../src/runtime/checkout-runtime-config');
47
47
 
48
48
  expect(checkoutRuntimeConfig.funnelSdkPublishableKey).toBe(
49
49
  'sdk_pub_claimbee_project',
@@ -52,7 +52,7 @@ describe('checkoutRuntimeConfig', () => {
52
52
  });
53
53
 
54
54
  it('uses the server-provided runtime mode when no preview key is injected', async () => {
55
- const { resolveClaimbeeCheckoutMode } = await import('./checkout-runtime-config');
55
+ const { resolveClaimbeeCheckoutMode } = await import('../../../src/runtime/checkout-runtime-config');
56
56
 
57
57
  expect(resolveClaimbeeCheckoutMode('live')).toBe('live');
58
58
  expect(resolveClaimbeeCheckoutMode('test')).toBe('test');
@@ -4,7 +4,7 @@ import {
4
4
  defaultFunnelStepId,
5
5
  resolveConfiguredNextStep,
6
6
  resolveRuntimeInitialStepId,
7
- } from './funnel-runtime';
7
+ } from '../../../src/runtime/funnel-runtime';
8
8
 
9
9
  describe('funnel runtime', () => {
10
10
  it('falls back to the first manifest step when no valid step is requested', () => {
@@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest';
2
2
  import {
3
3
  getStepContentLocale,
4
4
  getStepCountryCode,
5
- } from './step-content-context';
5
+ } from '../../../src/runtime/step-content-context';
6
6
 
7
7
  describe('step content context helpers', () => {
8
8
  it('prefers stored browser language and explicit country when present', () => {
@@ -3,7 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
3
3
 
4
4
  const { posthogFlagsPath } = vi.hoisted(() => ({
5
5
  posthogFlagsPath: new URL(
6
- '../../../funnel-runtime/src/runtime/posthog-flags.ts',
6
+ '../../../../funnel-runtime/src/runtime/posthog-flags.ts',
7
7
  import.meta.url,
8
8
  ).pathname,
9
9
  }));
@@ -31,7 +31,7 @@ import {
31
31
  resolveExperimentVariant,
32
32
  resolveNextStepId,
33
33
  } from '@funnelsgrove/runtime';
34
- import { computeRenderSuspended } from './use-funnel-flow-controller';
34
+ import { computeRenderSuspended } from '../../../src/runtime/use-funnel-flow-controller';
35
35
 
36
36
  const mockedResolveVariant = vi.mocked(resolveExperimentVariant);
37
37
 
@@ -187,9 +187,9 @@ describe.skipIf(!insideMonorepo)('PostHog variant resolution (integration)', ()
187
187
  });
188
188
 
189
189
  it('renders experiment variants on the source URL before normal continuation', () => {
190
- const adapterSource = readFileSync(new URL('./use-funnel-flow-controller.ts', import.meta.url), 'utf8');
190
+ const adapterSource = readFileSync(new URL('../../../src/runtime/use-funnel-flow-controller.ts', import.meta.url), 'utf8');
191
191
  const runtimeSource = readFileSync(
192
- new URL('../../../funnel-runtime/src/runtime/use-funnel-flow-controller.ts', import.meta.url),
192
+ new URL('../../../../funnel-runtime/src/runtime/use-funnel-flow-controller.ts', import.meta.url),
193
193
  'utf8',
194
194
  );
195
195
 
@@ -1,6 +1,6 @@
1
1
  import { describe, expect, it } from 'vitest';
2
2
  import { readFileSync } from 'node:fs';
3
- import { computeRenderSuspended } from './use-funnel-flow-controller';
3
+ import { computeRenderSuspended } from '../../../src/runtime/use-funnel-flow-controller';
4
4
 
5
5
  describe('computeRenderSuspended', () => {
6
6
  const experiments = [
@@ -52,7 +52,7 @@ describe('computeRenderSuspended', () => {
52
52
 
53
53
  describe('qualified visitor analytics', () => {
54
54
  it('delegates flow behavior to the shared runtime controller with template analytics', () => {
55
- const source = readFileSync(new URL('./use-funnel-flow-controller.ts', import.meta.url), 'utf8');
55
+ const source = readFileSync(new URL('../../../src/runtime/use-funnel-flow-controller.ts', import.meta.url), 'utf8');
56
56
 
57
57
  expect(source).toContain('useFunnelFlowController as useSharedFunnelFlowController');
58
58
  expect(source).toContain('return useSharedFunnelFlowController<FunnelStepId>({');
@@ -66,7 +66,7 @@ describe('qualified visitor analytics', () => {
66
66
  });
67
67
 
68
68
  it('does not carry a local copy of shared controller internals', () => {
69
- const source = readFileSync(new URL('./use-funnel-flow-controller.ts', import.meta.url), 'utf8');
69
+ const source = readFileSync(new URL('../../../src/runtime/use-funnel-flow-controller.ts', import.meta.url), 'utf8');
70
70
 
71
71
  expect(source).not.toContain('FIRST_STEP_ENGAGEMENT_THRESHOLD_MS');
72
72
  expect(source).not.toContain('apiService.bootstrapSession');
@@ -3,7 +3,7 @@ import {
3
3
  buildPaywallCheckoutBillingDetails,
4
4
  buildPaywallCheckoutExpressOptions,
5
5
  buildPaywallCheckoutPaymentElementOptions,
6
- } from './paywall-checkout-options';
6
+ } from '../../../src/steps/paywall-checkout-options';
7
7
 
8
8
  describe('paywall checkout options', () => {
9
9
  it('prioritizes Link and stacked express checkout buttons for the checkout dialog', () => {