@funnelsgrove/cli 0.1.229 → 0.1.233

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 (29) hide show
  1. package/README.md +9 -0
  2. package/dist/cli.js +10 -0
  3. package/dist/locales.d.ts +6 -0
  4. package/dist/locales.js +126 -0
  5. package/funnel-contract-compatibility.json +32 -32
  6. package/package.json +4 -4
  7. package/template_docs/.funnelsgrove-docs.json +5 -5
  8. package/template_docs/AGENTS.md +1 -0
  9. package/template_docs/docs/funnelsgrove/contracts/content-answers.md +50 -0
  10. package/template_docs/docs/funnelsgrove/migrations/step-contract-v3.md +1 -1
  11. package/template_docs/funnel-docs.config.json +1 -1
  12. package/template_scaffold/.funnelsgrove-docs.json +5 -5
  13. package/template_scaffold/.funnelsgrove-scaffold.json +35 -15
  14. package/template_scaffold/AGENTS.md +1 -0
  15. package/template_scaffold/docs/funnelsgrove/contracts/content-answers.md +50 -0
  16. package/template_scaffold/docs/funnelsgrove/migrations/step-contract-v3.md +1 -1
  17. package/template_scaffold/funnel-docs.config.json +1 -1
  18. package/template_scaffold/package-lock.json +15 -15
  19. package/template_scaffold/package.json +3 -3
  20. package/template_scaffold/src/app/subscription-dashboard/page.tsx +22 -19
  21. package/template_scaffold/src/components/FunnelFlow.tsx +11 -5
  22. package/template_scaffold/src/components/paywall/PaywallRenewalDisclaimer.tsx +23 -0
  23. package/template_scaffold/src/components/paywall/localize-paywall-plan-labels.ts +15 -0
  24. package/template_scaffold/src/config/billing.plans.ts +11 -10
  25. package/template_scaffold/src/config/billing.test.plans.ts +11 -10
  26. package/template_scaffold/src/steps/content/runtime.content.ts +22 -0
  27. package/template_scaffold/src/steps/content/step-32-paywall.content.ts +41 -1
  28. package/template_scaffold/src/steps/content/subscription-dashboard.content.ts +54 -0
  29. package/template_scaffold/src/steps/step-32-paywall.tsx +12 -22
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "sourceTreeHash": "bdd5f1d836e83dc9ee5c577deb6baef15b809a708bd5073ddaee4646785a71bb",
3
+ "sourceTreeHash": "30901fb7b8177a1ba9430b78ba2fc14dfddd9b664eaaea88ddb33f60aa0a3da5",
4
4
  "stepContractVersion": 3,
5
- "docsBundleVersion": "2.0.221",
5
+ "docsBundleVersion": "2.0.225",
6
6
  "files": [
7
7
  {
8
8
  "path": ".env.example",
@@ -16,12 +16,12 @@
16
16
  },
17
17
  {
18
18
  "path": ".funnelsgrove-docs.json",
19
- "sha256": "856b6f77ac9e1cfb10112624544b3af970734bf71a8e9be67fa4099f2e5778e9",
19
+ "sha256": "bd3b4bfecc4384106929d79f0b9c3e20cbf959faba28efe513f8419e719d5b7e",
20
20
  "mode": "100644"
21
21
  },
22
22
  {
23
23
  "path": "AGENTS.md",
24
- "sha256": "22118f0f909a747f8a27f4eed16ceb5ec2a9283fd9645114fda961c0405a2a6b",
24
+ "sha256": "3860d21f4a3f9b0c25ca54f7a87762d801f14b82a181b4c0b941c57187908f99",
25
25
  "mode": "100644"
26
26
  },
27
27
  {
@@ -81,7 +81,7 @@
81
81
  },
82
82
  {
83
83
  "path": "docs/funnelsgrove/contracts/content-answers.md",
84
- "sha256": "403b286060dd6cda33757df7c55c5b0e17395f2d90aad0fc133d6ba1919a04b3",
84
+ "sha256": "44686934b2a6837142a12e76f9fc84bd5181179bcd3bf82d636a8ad26b72ce01",
85
85
  "mode": "100644"
86
86
  },
87
87
  {
@@ -101,7 +101,7 @@
101
101
  },
102
102
  {
103
103
  "path": "docs/funnelsgrove/migrations/step-contract-v3.md",
104
- "sha256": "d86fab03c5a3bb31b2b62302a4fba47422c7f39d049decf4604b052cd55ae363",
104
+ "sha256": "02e1409d391497b8860b5c2942bc07881853f53480daa4fb2972f561e8baa047",
105
105
  "mode": "100644"
106
106
  },
107
107
  {
@@ -236,7 +236,7 @@
236
236
  },
237
237
  {
238
238
  "path": "funnel-docs.config.json",
239
- "sha256": "d59565cd958ed65a1794437e1c69ba5b1d2a0d4465b30e52073374caf0130095",
239
+ "sha256": "6767f2a0ee02bc43a4fd4b1b9c07e938099fac90976796636ebc66202050fe54",
240
240
  "mode": "100644"
241
241
  },
242
242
  {
@@ -261,12 +261,12 @@
261
261
  },
262
262
  {
263
263
  "path": "package-lock.json",
264
- "sha256": "fec6165d3f1b8a1a691008252ffa04d72660fc2e4ff64a9085468f3475967aa7",
264
+ "sha256": "dc0ad84d804b31f929acd7a5ab149000c2dd1c30c9c2f3f4e4a999579a700d35",
265
265
  "mode": "100644"
266
266
  },
267
267
  {
268
268
  "path": "package.json",
269
- "sha256": "e616a7a9d0659e307dee1199a5d674f2fa12c9db1c00df4b6732887e5efd957e",
269
+ "sha256": "235b4a5ea331121ecd52af8534e90e6fbca5523644f7694191a747a539737383",
270
270
  "mode": "100644"
271
271
  },
272
272
  {
@@ -311,7 +311,7 @@
311
311
  },
312
312
  {
313
313
  "path": "src/app/subscription-dashboard/page.tsx",
314
- "sha256": "765ebe25423289453891afa711423dd09e0e9a50cd12b55c2a1c878a12193ea9",
314
+ "sha256": "a8ecbb077fe6baf0a7eaa9e762dd4e7c61db67009be0be8cf15273fb3d4364f7",
315
315
  "mode": "100644"
316
316
  },
317
317
  {
@@ -321,7 +321,17 @@
321
321
  },
322
322
  {
323
323
  "path": "src/components/FunnelFlow.tsx",
324
- "sha256": "846aa4002cdc893cc0805483c58bc0ff8b18e50d20e8482ea1877aad6a9aca77",
324
+ "sha256": "64424cf80a192ccec542ed1a188ab143a2bb33261a8717b8daa50726c848f0d3",
325
+ "mode": "100644"
326
+ },
327
+ {
328
+ "path": "src/components/paywall/PaywallRenewalDisclaimer.tsx",
329
+ "sha256": "d163e0220b655fcb2b057f50655bac1ed141ff2d5805010431c8e280570901ba",
330
+ "mode": "100644"
331
+ },
332
+ {
333
+ "path": "src/components/paywall/localize-paywall-plan-labels.ts",
334
+ "sha256": "f1969b2657d235518c0f0f83f79bb31464f46035b01da45fc7e0f11afe18a626",
325
335
  "mode": "100644"
326
336
  },
327
337
  {
@@ -331,12 +341,12 @@
331
341
  },
332
342
  {
333
343
  "path": "src/config/billing.plans.ts",
334
- "sha256": "8b25bf44bf8b04275a6a978b76f2a9f7c0be7187e7578ab41c54fd5edca7ed65",
344
+ "sha256": "71a268bbefd624b58f5d63a66a4f35c36973931ddac4f3f49d38f03576dbe36a",
335
345
  "mode": "100644"
336
346
  },
337
347
  {
338
348
  "path": "src/config/billing.test.plans.ts",
339
- "sha256": "1a5ac5f14321748a7d19631adf32ca4412f2d129ed31080683ae2e25b21a1ef8",
349
+ "sha256": "921f3c2e523606f292091762501f256b87479461fbf4e6327cd8fc0c8fb10a68",
340
350
  "mode": "100644"
341
351
  },
342
352
  {
@@ -479,6 +489,11 @@
479
489
  "sha256": "b5770c01f838edc543fd32ea80a251d652586525029bff42cd0533a9bdd660e4",
480
490
  "mode": "100644"
481
491
  },
492
+ {
493
+ "path": "src/steps/content/runtime.content.ts",
494
+ "sha256": "c0db996c0040b0ed50349017249c8f418332e0a27410ab70dac64cc65b2e6dc6",
495
+ "mode": "100644"
496
+ },
482
497
  {
483
498
  "path": "src/steps/content/step-01.content.ts",
484
499
  "sha256": "a663c938ad341c21e84eb49b17b31287448e3a8abd68ccd7a294a7b820a3e14f",
@@ -486,7 +501,12 @@
486
501
  },
487
502
  {
488
503
  "path": "src/steps/content/step-32-paywall.content.ts",
489
- "sha256": "509ae6f98854ff024e14a1c53e04f583c2f29fa4cc409f432eba7fe845e3d320",
504
+ "sha256": "1a763425633ad6b8ece3d8f800f4272a72e7dc93cf845d379e9f23faee0d4cd5",
505
+ "mode": "100644"
506
+ },
507
+ {
508
+ "path": "src/steps/content/subscription-dashboard.content.ts",
509
+ "sha256": "ef47843635e5a3dd6c184ededb40c64e081e61281a5e0056213feaf75265f251",
490
510
  "mode": "100644"
491
511
  },
492
512
  {
@@ -551,7 +571,7 @@
551
571
  },
552
572
  {
553
573
  "path": "src/steps/step-32-paywall.tsx",
554
- "sha256": "283a035dacfb9b4d56f269c1176ac84d8519a69b67eb11e0d392ada4535e2f62",
574
+ "sha256": "46d1a44a37b71f4e73fe5dd7d6ff4d3956929943364b1d285caa9e6846d213a9",
555
575
  "mode": "100644"
556
576
  },
557
577
  {
@@ -9,6 +9,7 @@ This file is generated from the FunnelsGrove step contract. Start at [the agent
9
9
  | Add a step | [Add a step](docs/funnelsgrove/recipes/add-step.md) |
10
10
  | Edit a step | [Edit a step](docs/funnelsgrove/recipes/edit-step.md) |
11
11
  | Change metadata or routing | [Step metadata](docs/funnelsgrove/contracts/step-metadata.md) and [flow routing](docs/funnelsgrove/contracts/flow-routing.md) |
12
+ | Add a locale or translate visitor copy | [Localization](docs/funnelsgrove/contracts/content-answers.md#localization) |
12
13
  | Add or edit choices | [Content and answers](docs/funnelsgrove/contracts/content-answers.md) |
13
14
  | Edit product strategy, plan, or copy | [Content and answers](docs/funnelsgrove/contracts/content-answers.md) |
14
15
  | Add an experiment | [Add an experiment](docs/funnelsgrove/recipes/add-experiment.md) |
@@ -42,3 +42,53 @@ Keep builder-editable values in content definitions, expose matching editor sect
42
42
  - Update the owning artifact in the same change. Never implement a step absent from `PLAN.md`.
43
43
 
44
44
  See the exact choice and form pages in the [step-type index](../steps/INDEX.md). Run `fgrove validate` and test reload persistence before completion.
45
+
46
+ ## Localization
47
+
48
+ All funnel-authored visitor copy belongs in `src/**/*.content.ts` localized
49
+ content definitions: headings, options, CTA labels, placeholders, validation and
50
+ error messages, loading/empty states, accessible labels, shared shell copy, and
51
+ paywall/legal copy. JSX renders resolved content; metadata IDs, answer keys,
52
+ provider IDs, prices and billing rules retain their existing owners. Provider-hosted
53
+ payment fields and messages are configured through that provider's locale support.
54
+
55
+ 1. Run `fgrove locales add ru --dir .` to copy each definition's `defaultLocale`
56
+ into `locales.ru`. Use BCP 47 tags such as `pt-BR`. The command edits local source
57
+ only, requires no login, and never translates, syncs or publishes. Repeating it
58
+ keeps existing translations and fills only missing locale entries.
59
+ 2. Translate the new entries in **every** content module, including shared shell
60
+ and returning-subscriber content. Preserve object keys, option IDs, placeholders
61
+ such as `{amount}`, URLs and variable tokens. Same-file references to another
62
+ definition's base locale are copied as references to its new locale. Imported
63
+ content and other expressions remain references: inspect them and move any
64
+ remaining visitor copy into locale-owned definitions before completion.
65
+ 3. Keep `defaultLocale` and `locales` as explicit literal properties. Locale maps
66
+ use explicit keys; CLI rejects dynamic maps, spreads at the locale-map level,
67
+ and non-object base content before writing files. Nested content may contain
68
+ expressions. A new step must include every supported funnel locale.
69
+ 4. Resolve step copy with `usePreviewStepLocalizedContent(stepId, definition,
70
+ getStepContentLocale(attributes))`. Runtime versions supporting localization
71
+ give `?locale=ru` priority; otherwise the requested/browser language applies.
72
+ Lookup uses exact match, base language (`pt-BR` → `pt`), then `defaultLocale`.
73
+ The override persists in step URLs and never changes country, currency or offers.
74
+ 5. Review each language with `?locale=ru`, including direct step links, reload,
75
+ next/back, validation errors, long text and checkout. Builder **Preview language**
76
+ selects languages announced by its live frame and carries the choice into the
77
+ external preview link. Static screenshots cannot switch language. Older
78
+ published runtimes need an SDK update and republish before exposing this control.
79
+ 6. Run `fgrove validate`, sync through the funnel's normal CLI/GitHub workflow,
80
+ then publish preview. Existing funnels keep their own source and package pins;
81
+ adding a locale does not upgrade their SDK or migrate hard-coded JSX. Update
82
+ those explicitly and review the diff. Refresh this managed guide with `fgrove docs`.
83
+
84
+ New funnel code must route shell buttons and custom action-bar text through
85
+ content too. `src/steps/content/runtime.content.ts` owns the starter's shared
86
+ Continue/loading/unavailable messages. Server-returned display labels need
87
+ locale-owned presentation overrides; changing copy must not change plan IDs,
88
+ amounts, billing intervals or provider configuration.
89
+
90
+ The starter paywall's `catalogLabels` maps exact catalog display text to localized
91
+ text. Keep its keys unchanged and translate the values, preserving monetary
92
+ amounts. Add mappings for new admin-authored labels as needed. Unrecognized
93
+ catalog labels remain unchanged; translation never substitutes one offer for
94
+ another or alters payment identifiers and amounts.
@@ -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.14.2` first, then `@funnelsgrove/analytics` `0.1.104`, then `@funnelsgrove/payments` `0.15.8`. The production deploy verifies the zero-traffic API candidate, publishes and verifies `@funnelsgrove/cli` `0.1.229`, and only then promotes the candidate to production traffic. The serving API must never advertise an unpublished preferred CLI. Publishing packages and deploying production remain separately approved operational actions.
20
+ Release `@funnelsgrove/runtime` `0.14.4` first, then `@funnelsgrove/analytics` `0.1.106`, then `@funnelsgrove/payments` `0.15.11`. The production deploy verifies the zero-traffic API candidate, publishes and verifies `@funnelsgrove/cli` `0.1.233`, and only then promotes the candidate to production traffic. The serving API must never advertise an unpublished preferred CLI. 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "bundleVersion": "2.0.221",
3
+ "bundleVersion": "2.0.225",
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.97",
12
- "@funnelsgrove/payments": "^0.12.0",
13
- "@funnelsgrove/runtime": "^0.12.0",
11
+ "@funnelsgrove/analytics": "^0.1.106",
12
+ "@funnelsgrove/payments": "^0.15.11",
13
+ "@funnelsgrove/runtime": "^0.14.4",
14
14
  "@stripe/react-stripe-js": "^5.6.0",
15
15
  "@stripe/stripe-js": "^8.7.0",
16
16
  "lucide-react": "^0.553.0",
@@ -938,20 +938,20 @@
938
938
  }
939
939
  },
940
940
  "node_modules/@funnelsgrove/analytics": {
941
- "version": "0.1.97",
942
- "resolved": "https://registry.npmjs.org/@funnelsgrove/analytics/-/analytics-0.1.97.tgz",
943
- "integrity": "sha512-9FtZrzkjpwtfGZIbpYsnutK8T870MalxkWiGGzYGkzVJcoBfeCKTPEDXRwfNiaAyLUCDbeY7/gheJac2MllZAw==",
941
+ "version": "0.1.106",
942
+ "resolved": "https://registry.npmjs.org/@funnelsgrove/analytics/-/analytics-0.1.106.tgz",
943
+ "integrity": "sha512-S2hzj6C7FFz9Ui1GIE+NGMCVWnpWR81dZ4/HNSakedTSYcnP5Pu9JYtVLD+EXtiYRclF5PDgIIUbhkQxIuO8Mw==",
944
944
  "dependencies": {
945
- "@funnelsgrove/runtime": "0.12.0"
945
+ "@funnelsgrove/runtime": "0.14.4"
946
946
  }
947
947
  },
948
948
  "node_modules/@funnelsgrove/payments": {
949
- "version": "0.12.0",
950
- "resolved": "https://registry.npmjs.org/@funnelsgrove/payments/-/payments-0.12.0.tgz",
951
- "integrity": "sha512-i1rmGFNcSAr54qn5Q+QUiqzK2zYCNXr8uUMKN7UhEPEHYzP8cir2KR6XgI0rqQA3BOvDkqTpMNZOoE15uiPWIA==",
949
+ "version": "0.15.11",
950
+ "resolved": "https://registry.npmjs.org/@funnelsgrove/payments/-/payments-0.15.11.tgz",
951
+ "integrity": "sha512-X9k+/6NduXXQa2RTf+bbZenbckxunULCazROXsjpWcuJDkby/DdPH4Y+OZMNBpS/BIp3tbTO9z877p+7wz41/g==",
952
952
  "dependencies": {
953
- "@funnelsgrove/analytics": "^0.1.97",
954
- "@funnelsgrove/runtime": "^0.12.0",
953
+ "@funnelsgrove/analytics": "0.1.106",
954
+ "@funnelsgrove/runtime": "0.14.4",
955
955
  "@solidgate/react-sdk": "1.34.0",
956
956
  "@stripe/react-stripe-js": "^5.6.0",
957
957
  "@stripe/stripe-js": "^8.7.0",
@@ -961,9 +961,9 @@
961
961
  }
962
962
  },
963
963
  "node_modules/@funnelsgrove/runtime": {
964
- "version": "0.12.0",
965
- "resolved": "https://registry.npmjs.org/@funnelsgrove/runtime/-/runtime-0.12.0.tgz",
966
- "integrity": "sha512-WyuSRoYyDFcndMPEW+swoXhbYrWLG2icN4IgYv8KKQ3q/3mR8jAjlsQLY0hTdaaz6UxnV2w5+NXfYVB8eDxQBg==",
964
+ "version": "0.14.4",
965
+ "resolved": "https://registry.npmjs.org/@funnelsgrove/runtime/-/runtime-0.14.4.tgz",
966
+ "integrity": "sha512-Rloc3yKoe0mUDxiHTsFKuUt+cspzFAJHkUUkjV5Z+v79ogjW/K+1LxRG6vnwghB4QWj24Fjp5l1FKwREqqERIA==",
967
967
  "dependencies": {
968
968
  "posthog-js": "^1.369.2",
969
969
  "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.97",
16
- "@funnelsgrove/payments": "^0.12.0",
17
- "@funnelsgrove/runtime": "^0.12.0",
15
+ "@funnelsgrove/analytics": "^0.1.106",
16
+ "@funnelsgrove/payments": "^0.15.11",
17
+ "@funnelsgrove/runtime": "^0.14.4",
18
18
  "@stripe/react-stripe-js": "^5.6.0",
19
19
  "@stripe/stripe-js": "^8.7.0",
20
20
  "lucide-react": "^0.553.0",
@@ -1,9 +1,12 @@
1
1
  'use client';
2
2
 
3
3
  import { useCallback, useEffect, useState } from 'react';
4
- import { apiService, type ManageSubscriptionsResponse } from '@funnelsgrove/runtime';
4
+ import { apiService, usePreviewStepLocalizedContent, type ManageSubscriptionsResponse } from '@funnelsgrove/runtime';
5
+
6
+ import { subscriptionDashboardContent } from '@/steps/content/subscription-dashboard.content';
5
7
 
6
8
  export default function ManageSubscriptionPage() {
9
+ const content = usePreviewStepLocalizedContent('subscription-dashboard', subscriptionDashboardContent);
7
10
  const [data, setData] = useState<ManageSubscriptionsResponse | null>(null);
8
11
  const [loading, setLoading] = useState(true);
9
12
  const [error, setError] = useState<string | null>(null);
@@ -17,11 +20,11 @@ export default function ManageSubscriptionPage() {
17
20
  const payload = await apiService.getManageSubscriptions();
18
21
  setData(payload);
19
22
  } catch (err) {
20
- setError(err instanceof Error ? err.message : 'Failed to load subscriptions');
23
+ setError(err instanceof Error ? err.message : content.loadError);
21
24
  } finally {
22
25
  setLoading(false);
23
26
  }
24
- }, []);
27
+ }, [content.loadError]);
25
28
 
26
29
  useEffect(() => {
27
30
  void load();
@@ -38,7 +41,7 @@ export default function ManageSubscriptionPage() {
38
41
  });
39
42
  setData(payload);
40
43
  } catch (err) {
41
- setError(err instanceof Error ? err.message : `Failed to ${action} subscription`);
44
+ setError(err instanceof Error ? err.message : (action === 'cancel' ? content.cancelError : content.renewError));
42
45
  } finally {
43
46
  setUpdatingSubscriptionId(null);
44
47
  }
@@ -48,35 +51,35 @@ export default function ManageSubscriptionPage() {
48
51
  <main className="manage-subscription-page">
49
52
  <section className="manage-subscription-card">
50
53
  <header>
51
- <p className="manage-subscription-tag">App Deals</p>
52
- <h1 className="manage-subscription-title">Manage Subscription</h1>
54
+ <p className="manage-subscription-tag">{content.brand}</p>
55
+ <h1 className="manage-subscription-title">{content.title}</h1>
53
56
  <p className="manage-subscription-description">
54
- View active plans and manage cancellation or renewal.
57
+ {content.description}
55
58
  </p>
56
59
  </header>
57
60
 
58
- {loading ? <p className="manage-subscription-muted">Loading subscription data...</p> : null}
61
+ {loading ? <p className="manage-subscription-muted">{content.loading}</p> : null}
59
62
  {error ? <p className="manage-subscription-error">{error}</p> : null}
60
63
 
61
64
  {!loading && data?.user ? (
62
65
  <section className="manage-subscription-user">
63
66
  <p>
64
- <strong>User ID:</strong> {data.user.user_id}
67
+ <strong>{content.userId}</strong> {data.user.user_id}
65
68
  </p>
66
69
  <p>
67
- <strong>Email:</strong> {data.user.email || 'Not provided'}
70
+ <strong>{content.email}</strong> {data.user.email || content.notProvided}
68
71
  </p>
69
72
  <p>
70
- <strong>Status:</strong> {data.user.subscriptionStatus || 'unknown'}
73
+ <strong>{content.status}</strong> {data.user.subscriptionStatus || content.unknown}
71
74
  </p>
72
75
  <p>
73
- <strong>Tracked events:</strong> {data.eventsCount}
76
+ <strong>{content.events}</strong> {data.eventsCount}
74
77
  </p>
75
78
  </section>
76
79
  ) : null}
77
80
 
78
81
  {!loading && (!data || data.subscriptions.length === 0) ? (
79
- <p className="manage-subscription-muted">No subscriptions found for this user.</p>
82
+ <p className="manage-subscription-muted">{content.empty}</p>
80
83
  ) : null}
81
84
 
82
85
  {!loading && data?.subscriptions.length ? (
@@ -92,12 +95,12 @@ export default function ManageSubscriptionPage() {
92
95
  </p>
93
96
  <p className="manage-subscription-item-meta">
94
97
  {subscription.cancelAtPeriodEnd
95
- ? 'Will cancel at period end'
96
- : 'Auto-renew is active'}
98
+ ? content.cancelAtEnd
99
+ : content.autoRenew}
97
100
  </p>
98
101
  {subscription.currentPeriodEnd ? (
99
102
  <p className="manage-subscription-item-meta">
100
- Current period ends:{' '}
103
+ {content.periodEnds}{' '}
101
104
  {new Date(subscription.currentPeriodEnd).toLocaleString()}
102
105
  </p>
103
106
  ) : null}
@@ -111,7 +114,7 @@ export default function ManageSubscriptionPage() {
111
114
  disabled={updatingSubscriptionId === subscription.id}
112
115
  onClick={() => mutate(subscription.id, 'renew')}
113
116
  >
114
- {updatingSubscriptionId === subscription.id ? 'Updating...' : 'Renew'}
117
+ {updatingSubscriptionId === subscription.id ? content.updating : content.renew}
115
118
  </button>
116
119
  ) : (
117
120
  <button
@@ -120,7 +123,7 @@ export default function ManageSubscriptionPage() {
120
123
  disabled={updatingSubscriptionId === subscription.id}
121
124
  onClick={() => mutate(subscription.id, 'cancel')}
122
125
  >
123
- {updatingSubscriptionId === subscription.id ? 'Updating...' : 'Cancel'}
126
+ {updatingSubscriptionId === subscription.id ? content.updating : content.cancel}
124
127
  </button>
125
128
  )}
126
129
  </div>
@@ -130,7 +133,7 @@ export default function ManageSubscriptionPage() {
130
133
  ) : null}
131
134
 
132
135
  <button type="button" className="manage-subscription-refresh" onClick={() => void load()}>
133
- Refresh
136
+ {content.refresh}
134
137
  </button>
135
138
  </section>
136
139
 
@@ -9,8 +9,11 @@ import {
9
9
  runtimePublicConfig,
10
10
  scheduleNextStepImagePreload,
11
11
  usePreviewDefinitionOverrideRevision,
12
+ usePreviewStepLocalizedContent,
12
13
  } from '@funnelsgrove/runtime';
13
14
  import { FunnelEditorPanel, FunnelEditorPanelStyles } from './FunnelEditorPanel';
15
+ import { runtimeContent } from '@/steps/content/runtime.content';
16
+ import { getStepContentLocale } from '@/runtime/step-content-context';
14
17
  import { funnelManifest } from '@/config/funnel.manifest';
15
18
  import type { FunnelStepId } from '@/runtime/funnel-runtime';
16
19
  import { useFunnelFlowController } from '@/runtime/use-funnel-flow-controller';
@@ -23,21 +26,23 @@ type FunnelFlowProps = {
23
26
  };
24
27
 
25
28
  function RuntimeConfigLoading() {
29
+ const content = usePreviewStepLocalizedContent('runtime', runtimeContent, getStepContentLocale({}));
26
30
  return (
27
- <section className="runtime-config-loading" role="status" aria-label="Loading" aria-busy="true">
31
+ <section className="runtime-config-loading" role="status" aria-label={content.loadingLabel} aria-busy="true">
28
32
  <span className="runtime-config-loading__progress" />
29
33
  </section>
30
34
  );
31
35
  }
32
36
 
33
37
  function RuntimeConfigStatus() {
38
+ const content = usePreviewStepLocalizedContent('runtime', runtimeContent, getStepContentLocale({}));
34
39
  return (
35
40
  <section className="runtime-config-status" role="alert" aria-live="polite">
36
41
  <div className="runtime-config-status__content">
37
- <h1>This experience is temporarily unavailable</h1>
38
- <p>Please check your connection and try again.</p>
42
+ <h1>{content.unavailableTitle}</h1>
43
+ <p>{content.unavailableDescription}</p>
39
44
  <button type="button" onClick={() => window.location.reload()}>
40
- Try again
45
+ {content.retryLabel}
41
46
  </button>
42
47
  </div>
43
48
  </section>
@@ -88,6 +93,7 @@ function FunnelFlowRuntime({
88
93
  previewFrameMounted: boolean;
89
94
  }) {
90
95
  const previewDefinitionRevision = usePreviewDefinitionOverrideRevision();
96
+ const content = usePreviewStepLocalizedContent('runtime', runtimeContent, getStepContentLocale({}));
91
97
  const controller = useFunnelFlowController({
92
98
  initialStepId,
93
99
  lockToInitialStep,
@@ -144,7 +150,7 @@ function FunnelFlowRuntime({
144
150
  onClick={controller.handleContinue}
145
151
  className={controller.buttonVariant === 'muted' ? 'is-muted' : undefined}
146
152
  >
147
- {controller.buttonText}
153
+ {controller.activeStepMeta.actionBar?.buttonText ? controller.buttonText : content.continueLabel}
148
154
  </PrimaryButton>
149
155
  </footer>
150
156
  ) : null}
@@ -0,0 +1,23 @@
1
+ import type { PaywallPlan } from '@funnelsgrove/payments';
2
+ import type { StepPaywallLocaleContent } from '@/steps/content/step-32-paywall.content';
3
+
4
+ type Props = {
5
+ plan: Pick<PaywallPlan, 'priceLabel' | 'billingInterval' | 'billingIntervalCount'> | null;
6
+ hasActiveDiscount: boolean;
7
+ copy: StepPaywallLocaleContent['checkout'];
8
+ catalogLabels: Record<string, string>;
9
+ };
10
+
11
+ export function PaywallRenewalDisclaimer({ plan, hasActiveDiscount, copy, catalogLabels }: Props) {
12
+ if (!plan) return <p className='paywall-v2-disclaimer' />;
13
+ const count = Math.max(1, plan.billingIntervalCount || 1);
14
+ const cadenceCopy = plan.billingInterval ? copy.cadence[plan.billingInterval] : null;
15
+ const cadence = cadenceCopy
16
+ ? (count === 1 ? cadenceCopy.one : cadenceCopy.many).replace('{count}', String(count))
17
+ : copy.cadenceFallback;
18
+ const text = copy.renewalConsentTemplate
19
+ .replace('{intro}', hasActiveDiscount ? copy.discountedIntroLabel : copy.selectedIntroLabel)
20
+ .replace('{amount}', catalogLabels[plan.priceLabel] ?? plan.priceLabel)
21
+ .replace('{cadence}', cadence);
22
+ return <p className='paywall-v2-disclaimer'>{text}</p>;
23
+ }
@@ -0,0 +1,15 @@
1
+ import type { PaywallDisplayPlan } from '@funnelsgrove/payments';
2
+
3
+ /** Translate recognized display labels while retaining arbitrary catalog copy and payment data. */
4
+ export function localizePaywallPlanLabels(plan: PaywallDisplayPlan, labels: Record<string, string>): PaywallDisplayPlan {
5
+ const translate = (value: string) => typeof labels[value] === 'string' ? labels[value] : value;
6
+ return {
7
+ ...plan,
8
+ title: translate(plan.title),
9
+ priceLabel: translate(plan.priceLabel),
10
+ basePriceLabel: translate(plan.basePriceLabel),
11
+ oldPriceLabel: plan.oldPriceLabel ? translate(plan.oldPriceLabel) : plan.oldPriceLabel,
12
+ checkoutSummaryLabel: plan.checkoutSummaryLabel ? translate(plan.checkoutSummaryLabel) : plan.checkoutSummaryLabel,
13
+ featuredTag: plan.featuredTag ? translate(plan.featuredTag) : plan.featuredTag,
14
+ };
15
+ }
@@ -1,3 +1,4 @@
1
+ import { stepPaywallContent } from '@/steps/content/step-32-paywall.content';
1
2
  import {
2
3
  buildBillingDiscountCatalog,
3
4
  createRuntimeModeBillingPlanCatalog,
@@ -19,36 +20,36 @@ const starterLivePlans = {
19
20
  offerSetId: STARTER_OFFER_SET_ID,
20
21
  offerSetKey: STARTER_OFFER_SET_KEY,
21
22
  projectPlanId: '32b31f69-5907-4736-89c7-76d3acb38813',
22
- title: '1-Week Plan',
23
+ title: stepPaywallContent.locales.en.catalogLabels["1-Week Plan"],
23
24
  providerPlanId: 'price_1T9WL2PPRN41Ty94g2NNOgif',
24
- priceLabel: '$7.99/week',
25
+ priceLabel: stepPaywallContent.locales.en.catalogLabels["$7.99/week"],
25
26
  perDayAmount: '$1.14',
26
27
  amountCents: 799,
27
- checkoutSummaryLabel: '$7.99 today',
28
+ checkoutSummaryLabel: stepPaywallContent.locales.en.catalogLabels["$7.99 today"],
28
29
  },
29
30
  secondary: {
30
31
  offerSetId: STARTER_OFFER_SET_ID,
31
32
  offerSetKey: STARTER_OFFER_SET_KEY,
32
33
  projectPlanId: 'ce0a0022-7bf0-41f2-a707-edc5e6113a07',
33
- title: '1-Month Plan',
34
+ title: stepPaywallContent.locales.en.catalogLabels["1-Month Plan"],
34
35
  providerPlanId: 'price_1TP0IIPPRN41Ty94npkzVeDR',
35
- featuredTag: '🔥 Most Popular Choice',
36
- priceLabel: '$29.99/month',
36
+ featuredTag: stepPaywallContent.locales.en.catalogLabels["🔥 Most Popular Choice"],
37
+ priceLabel: stepPaywallContent.locales.en.catalogLabels["$29.99/month"],
37
38
  perDayAmount: '$1.00',
38
39
  amountCents: 2999,
39
- checkoutSummaryLabel: '$29.99 today',
40
+ checkoutSummaryLabel: stepPaywallContent.locales.en.catalogLabels["$29.99 today"],
40
41
  isDefault: true,
41
42
  },
42
43
  tertiary: {
43
44
  offerSetId: STARTER_OFFER_SET_ID,
44
45
  offerSetKey: STARTER_OFFER_SET_KEY,
45
46
  projectPlanId: 'fb623ebc-c2fd-4e6a-8eb6-b8ed92db1b21',
46
- title: '1-Year Plan',
47
+ title: stepPaywallContent.locales.en.catalogLabels["1-Year Plan"],
47
48
  providerPlanId: 'price_1T06AEPPRN41Ty94zvVt5xrk',
48
- priceLabel: '$59.99/year',
49
+ priceLabel: stepPaywallContent.locales.en.catalogLabels["$59.99/year"],
49
50
  perDayAmount: '$0.16',
50
51
  amountCents: 5999,
51
- checkoutSummaryLabel: '$59.99 today',
52
+ checkoutSummaryLabel: stepPaywallContent.locales.en.catalogLabels["$59.99 today"],
52
53
  },
53
54
  } satisfies BillingPlanCatalog;
54
55
 
@@ -1,32 +1,33 @@
1
+ import { stepPaywallContent } from '@/steps/content/step-32-paywall.content';
1
2
  import type { BillingPlanCatalog } from '@funnelsgrove/payments';
2
3
 
3
4
  export const billingTestPlans = {
4
5
  primary: {
5
6
  projectPlanId: '32b31f69-5907-4736-89c7-76d3acb38813',
6
- title: '1-Week Plan',
7
+ title: stepPaywallContent.locales.en.catalogLabels["1-Week Plan"],
7
8
  providerPlanId: 'price_1TOQ2fPEfkEVo5khumapOSsI',
8
- priceLabel: '$9.99/week',
9
+ priceLabel: stepPaywallContent.locales.en.catalogLabels["$9.99/week"],
9
10
  perDayAmount: '$1.43',
10
11
  amountCents: 999,
11
- checkoutSummaryLabel: '$9.99 today',
12
+ checkoutSummaryLabel: stepPaywallContent.locales.en.catalogLabels["$9.99 today"],
12
13
  },
13
14
  secondary: {
14
15
  projectPlanId: 'ce0a0022-7bf0-41f2-a707-edc5e6113a07',
15
- title: '1-Month Plan',
16
+ title: stepPaywallContent.locales.en.catalogLabels["1-Month Plan"],
16
17
  providerPlanId: 'price_1TSgPZPEfkEVo5khMbtAlqsG',
17
- featuredTag: '🔥 Most Popular Choice',
18
- priceLabel: '$29.99/month',
18
+ featuredTag: stepPaywallContent.locales.en.catalogLabels["🔥 Most Popular Choice"],
19
+ priceLabel: stepPaywallContent.locales.en.catalogLabels["$29.99/month"],
19
20
  perDayAmount: '$1.00',
20
21
  amountCents: 2999,
21
- checkoutSummaryLabel: '$29.99 today',
22
+ checkoutSummaryLabel: stepPaywallContent.locales.en.catalogLabels["$29.99 today"],
22
23
  },
23
24
  tertiary: {
24
25
  projectPlanId: 'fb623ebc-c2fd-4e6a-8eb6-b8ed92db1b21',
25
- title: '1-Year Plan',
26
+ title: stepPaywallContent.locales.en.catalogLabels["1-Year Plan"],
26
27
  providerPlanId: 'price_1TBgcaPEfkEVo5khbGnGhrBA',
27
- priceLabel: '$59.99/year',
28
+ priceLabel: stepPaywallContent.locales.en.catalogLabels["$59.99/year"],
28
29
  perDayAmount: '$0.16',
29
30
  amountCents: 5999,
30
- checkoutSummaryLabel: '$59.99 today',
31
+ checkoutSummaryLabel: stepPaywallContent.locales.en.catalogLabels["$59.99 today"],
31
32
  },
32
33
  } satisfies BillingPlanCatalog;
@@ -0,0 +1,22 @@
1
+ import type { LocalizedStepContent } from '@funnelsgrove/runtime';
2
+
3
+ type RuntimeContent = {
4
+ loadingLabel: string;
5
+ unavailableTitle: string;
6
+ unavailableDescription: string;
7
+ retryLabel: string;
8
+ continueLabel: string;
9
+ };
10
+
11
+ export const runtimeContent = {
12
+ defaultLocale: 'en',
13
+ locales: {
14
+ en: {
15
+ loadingLabel: 'Loading',
16
+ unavailableTitle: 'This experience is temporarily unavailable',
17
+ unavailableDescription: 'Please check your connection and try again.',
18
+ retryLabel: 'Try again',
19
+ continueLabel: 'Continue',
20
+ },
21
+ },
22
+ } as const satisfies LocalizedStepContent<RuntimeContent>;