@funnelsgrove/cli 0.1.231 → 0.1.237

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 (35) hide show
  1. package/README.md +11 -0
  2. package/dist/cli.js +10 -0
  3. package/dist/localeContentSource.d.ts +13 -0
  4. package/dist/localeContentSource.js +117 -0
  5. package/dist/localeFilePlan.d.ts +4 -0
  6. package/dist/localeFilePlan.js +63 -0
  7. package/dist/localeModuleEdits.d.ts +19 -0
  8. package/dist/localeModuleEdits.js +97 -0
  9. package/dist/locales.d.ts +6 -0
  10. package/dist/locales.js +92 -0
  11. package/funnel-contract-compatibility.json +38 -38
  12. package/package.json +4 -4
  13. package/template_docs/.funnelsgrove-docs.json +5 -5
  14. package/template_docs/AGENTS.md +1 -0
  15. package/template_docs/docs/funnelsgrove/contracts/content-answers.md +69 -0
  16. package/template_docs/docs/funnelsgrove/migrations/step-contract-v3.md +1 -1
  17. package/template_docs/funnel-docs.config.json +1 -1
  18. package/template_scaffold/.funnelsgrove-docs.json +5 -5
  19. package/template_scaffold/.funnelsgrove-scaffold.json +35 -15
  20. package/template_scaffold/AGENTS.md +1 -0
  21. package/template_scaffold/docs/funnelsgrove/contracts/content-answers.md +69 -0
  22. package/template_scaffold/docs/funnelsgrove/migrations/step-contract-v3.md +1 -1
  23. package/template_scaffold/funnel-docs.config.json +1 -1
  24. package/template_scaffold/package-lock.json +15 -15
  25. package/template_scaffold/package.json +3 -3
  26. package/template_scaffold/src/app/subscription-dashboard/page.tsx +22 -19
  27. package/template_scaffold/src/components/FunnelFlow.tsx +11 -5
  28. package/template_scaffold/src/components/paywall/PaywallRenewalDisclaimer.tsx +23 -0
  29. package/template_scaffold/src/components/paywall/localize-paywall-plan-labels.ts +15 -0
  30. package/template_scaffold/src/config/billing.plans.ts +11 -10
  31. package/template_scaffold/src/config/billing.test.plans.ts +11 -10
  32. package/template_scaffold/src/steps/content/runtime.content.ts +22 -0
  33. package/template_scaffold/src/steps/content/step-32-paywall.content.ts +41 -1
  34. package/template_scaffold/src/steps/content/subscription-dashboard.content.ts +54 -0
  35. package/template_scaffold/src/steps/step-32-paywall.tsx +12 -22
@@ -42,3 +42,72 @@ 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. Keep original `*.content.ts` files at their existing locations alongside the
56
+ funnel's steps/shared code. They own the default English content and exported
57
+ content types. Run `fgrove locales add ru --dir .` to create translations under
58
+ `src/localization/ru/`, mirroring each original path relative to `src/`:
59
+ `src/steps/content/intro.content.ts` →
60
+ `src/localization/ru/steps/content/intro.content.ts`.
61
+ Use BCP 47 tags such as `pt-BR`. CLI copies source only: no translation, login,
62
+ sync or publication. Repeating the command preserves translated values, adds missing exports and
63
+ repairs missing registrations in the originals.
64
+ An existing inline locale is extracted with its authored text preserved.
65
+ 2. Translate **every** generated content file, including shared shell and
66
+ returning-subscriber content. Each translated export imports its named type
67
+ with `import type` from the original file. Original definitions import these
68
+ translated values into their `locales` map; the reverse import is type-only.
69
+ Preserve keys, option IDs, placeholders such as `{amount}`, URLs and tokens.
70
+ Same-file inheritance from the default or requested locale refers to the
71
+ translated export. References to other locales require explicit copy before
72
+ running the command. Relative imports
73
+ are rebased; imported expressions remain authored references. Inspect those
74
+ references for remaining visitor copy before completing a translation.
75
+ 3. Define the complete structure in an exported type in the original content file
76
+ and use `satisfies LocalizedStepContent<YourContentType>`. When adding a field,
77
+ update this type and the English content, then supply the field in **every**
78
+ translation. Keep required fields required; do not silence errors with casts,
79
+ optional fields or English spreads. Run `npx tsc --noEmit` at the funnel root:
80
+ an incomplete translation must fail with its file and missing field.
81
+ Keep `defaultLocale` and `locales` as explicit literal properties. CLI preflights
82
+ unsupported definitions before writing. Use named non-generic content types
83
+ and one default language per file. Local helpers referenced by copied
84
+ content (including enums, classes and type casts) must be inlined or moved to
85
+ independent imported modules. If a registered translation file is lost, restore
86
+ it before retrying; CLI cannot recover translated text from an import. A new step
87
+ must include every supported locale: rerun `locales add` for each language.
88
+ 4. Resolve step copy with `usePreviewStepLocalizedContent(stepId, definition,
89
+ getStepContentLocale(attributes))`. Runtime versions supporting localization
90
+ give `?locale=ru` priority; otherwise the requested/browser language applies.
91
+ Lookup uses exact match, base language (`pt-BR` → `pt`), then `defaultLocale`.
92
+ The override persists in step URLs and never changes country, currency or offers.
93
+ 5. Review each language with `?locale=ru`, including direct step links, reload,
94
+ next/back, validation errors, long text and checkout. Builder **Preview language**
95
+ selects languages announced by its live frame and carries the choice into the
96
+ external preview link. Static screenshots cannot switch language. Older
97
+ published runtimes need an SDK update and republish before exposing this control.
98
+ 6. Run `fgrove validate`, sync through the funnel's normal CLI/GitHub workflow,
99
+ then publish preview. Existing funnels keep their own source and package pins;
100
+ adding a locale does not upgrade their SDK or migrate hard-coded JSX. Update
101
+ those explicitly and review the diff. Refresh this managed guide with `fgrove docs`.
102
+
103
+ New funnel code must route shell buttons and custom action-bar text through
104
+ content too. `src/steps/content/runtime.content.ts` owns the starter's shared
105
+ Continue/loading/unavailable messages. Server-returned display labels need
106
+ locale-owned presentation overrides; changing copy must not change plan IDs,
107
+ amounts, billing intervals or provider configuration.
108
+
109
+ The starter paywall's `catalogLabels` maps exact catalog display text to localized
110
+ text. Keep its keys unchanged and translate the values, preserving monetary
111
+ amounts. Add mappings for new admin-authored labels as needed. Unrecognized
112
+ catalog labels remain unchanged; translation never substitutes one offer for
113
+ 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.3` first, then `@funnelsgrove/analytics` `0.1.105`, then `@funnelsgrove/payments` `0.15.10`. The production deploy verifies the zero-traffic API candidate, publishes and verifies `@funnelsgrove/cli` `0.1.231`, 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/sdk` `0.4.0` first, then `@funnelsgrove/runtime` `0.19.0`, then `@funnelsgrove/analytics` `0.1.110`, then `@funnelsgrove/payments` `0.20.0`. The production deploy verifies the zero-traffic API candidate, publishes and verifies `@funnelsgrove/cli` `0.1.237`, 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.223",
3
+ "bundleVersion": "2.0.229",
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
+ export 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>;
@@ -7,11 +7,15 @@ import type {
7
7
  StepImage,
8
8
  } from '@funnelsgrove/runtime';
9
9
 
10
- type StepPaywallLocaleContent = {
10
+ export type StepPaywallLocaleContent = {
11
+ catalogLabels: Record<string, string>;
11
12
  loadingLabel: string;
12
13
  topBarDiscountLabel: string;
13
14
  topBarCtaLabel: string;
14
15
  countdownUnits: readonly string[];
16
+ countdownAriaTemplate: string;
17
+ selectedPlanLabel: string;
18
+ refundPolicyLabel: string;
15
19
  hero: {
16
20
  pills: readonly string[];
17
21
  image: StepImage;
@@ -30,6 +34,7 @@ type StepPaywallLocaleContent = {
30
34
  cardButtonLabel: string;
31
35
  metaLabels: readonly string[];
32
36
  stripeUnavailableNote: string;
37
+ perDayLabel: string;
33
38
  };
34
39
  checkout: {
35
40
  title: string;
@@ -67,6 +72,11 @@ type StepPaywallLocaleContent = {
67
72
  legalLinks: readonly LinkItem[];
68
73
  supportText: string;
69
74
  renewalTemplate: string;
75
+ renewalConsentTemplate: string;
76
+ discountedIntroLabel: string;
77
+ selectedIntroLabel: string;
78
+ cadenceFallback: string;
79
+ cadence: Record<'day' | 'week' | 'month' | 'year', { one: string; many: string }>;
70
80
  specialOffer: {
71
81
  image: StepImage;
72
82
  discountLabel: string;
@@ -94,6 +104,7 @@ type StepPaywallLocaleContent = {
94
104
  title: string;
95
105
  description: string;
96
106
  links: readonly LinkItem[];
107
+ detailsSuffix: string;
97
108
  };
98
109
  };
99
110
 
@@ -101,10 +112,27 @@ export const stepPaywallContent = {
101
112
  defaultLocale: 'en',
102
113
  locales: {
103
114
  en: {
115
+ catalogLabels: {
116
+ "1-Week Plan": "1-Week Plan",
117
+ "1-Month Plan": "1-Month Plan",
118
+ "1-Year Plan": "1-Year Plan",
119
+ "🔥 Most Popular Choice": "🔥 Most Popular Choice",
120
+ "$7.99/week": "$7.99/week",
121
+ "$9.99/week": "$9.99/week",
122
+ "$29.99/month": "$29.99/month",
123
+ "$59.99/year": "$59.99/year",
124
+ "$7.99 today": "$7.99 today",
125
+ "$9.99 today": "$9.99 today",
126
+ "$29.99 today": "$29.99 today",
127
+ "$59.99 today": "$59.99 today",
128
+ },
104
129
  loadingLabel: 'Loading...',
105
130
  topBarDiscountLabel: '⏰ Discount Expires in',
106
131
  topBarCtaLabel: 'GET MY PLAN',
107
132
  countdownUnits: ['minutes', 'seconds'],
133
+ countdownAriaTemplate: 'Time left {minutes} minutes {seconds} seconds',
134
+ selectedPlanLabel: 'Selected plan',
135
+ refundPolicyLabel: 'refund policy',
108
136
  hero: {
109
137
  pills: ['Find hidden money and', "claim what's yours"],
110
138
  image: {
@@ -124,6 +152,7 @@ export const stepPaywallContent = {
124
152
  },
125
153
  plans: {
126
154
  title: 'Choose your plan',
155
+ perDayLabel: 'per day',
127
156
  cardButtonLabel: 'GET MY PLAN',
128
157
  metaLabels: ['Money-back guarantee', 'Cancel anytime'],
129
158
  stripeUnavailableNote:
@@ -180,6 +209,16 @@ export const stepPaywallContent = {
180
209
  },
181
210
  ],
182
211
  supportText: 'Need help? Contact us at',
212
+ renewalConsentTemplate: 'Without cancellation, before the {intro} ends, I accept that the app will automatically charge {amount} {cadence} until I cancel. Cancel online via the profile on the website or app.',
213
+ discountedIntroLabel: 'discounted intro plan',
214
+ selectedIntroLabel: 'selected plan',
215
+ cadenceFallback: 'on your selected billing schedule',
216
+ cadence: {
217
+ day: { one: 'every day', many: 'every {count} days' },
218
+ week: { one: 'every week', many: 'every {count} weeks' },
219
+ month: { one: 'every month', many: 'every {count} months' },
220
+ year: { one: 'every year', many: 'every {count} years' },
221
+ },
183
222
  renewalTemplate:
184
223
  "You'll be charged {amount} today for your selected plan. Your subscription renews automatically unless cancelled at least 24 hours before renewal date.",
185
224
  specialOffer: {
@@ -302,6 +341,7 @@ export const stepPaywallContent = {
302
341
  ],
303
342
  },
304
343
  guarantee: {
344
+ detailsSuffix: 'for details and limitations.',
305
345
  image: {
306
346
  src: '/paywall/money-back-badge.jpg',
307
347
  alt: '30 day money back guarantee badge',