create-expo-boilerplate 1.1.2 → 1.2.0

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 (99) hide show
  1. package/README.md +38 -0
  2. package/dist/add.js +218 -0
  3. package/dist/generate.js +3 -1
  4. package/dist/index.js +35 -5
  5. package/dist/manifest.js +51 -0
  6. package/dist/modules/index.js +7 -0
  7. package/dist/modules/notifications.js +241 -0
  8. package/dist/modules/shared.js +67 -0
  9. package/dist/modules/subscriptions.js +223 -0
  10. package/dist/modules/types.js +57 -0
  11. package/dist/steps/format.js +11 -2
  12. package/dist/steps/javascript.js +14 -7
  13. package/dist/steps/readme.js +1 -0
  14. package/dist/steps/styling.js +1 -0
  15. package/dist/template.js +2 -0
  16. package/dist/versions.js +8 -0
  17. package/modules/notifications/files/plugins/withAndroidDefaultNotificationChannel.js +64 -0
  18. package/modules/notifications/files/src/notifications/NotificationProvider.tsx +309 -0
  19. package/modules/notifications/files/src/notifications/NotificationRoot.tsx +21 -0
  20. package/modules/notifications/files/src/notifications/README.md +763 -0
  21. package/modules/notifications/files/src/notifications/__tests__/NotificationProvider.test.tsx +358 -0
  22. package/modules/notifications/files/src/notifications/__tests__/pendingPress.test.ts +61 -0
  23. package/modules/notifications/files/src/notifications/__tests__/permissionState.test.ts +92 -0
  24. package/modules/notifications/files/src/notifications/__tests__/routing.test.ts +109 -0
  25. package/modules/notifications/files/src/notifications/__tests__/service.test.ts +189 -0
  26. package/modules/notifications/files/src/notifications/backgroundHandler.ts +158 -0
  27. package/modules/notifications/files/src/notifications/channels.ts +70 -0
  28. package/modules/notifications/files/src/notifications/components/NotificationPermissionBanner.tsx +134 -0
  29. package/modules/notifications/files/src/notifications/config.ts +179 -0
  30. package/modules/notifications/files/src/notifications/index.ts +90 -0
  31. package/modules/notifications/files/src/notifications/log.ts +57 -0
  32. package/modules/notifications/files/src/notifications/navigation.ts +11 -0
  33. package/modules/notifications/files/src/notifications/pendingPress.ts +86 -0
  34. package/modules/notifications/files/src/notifications/permissionState.ts +75 -0
  35. package/modules/notifications/files/src/notifications/routing.ts +99 -0
  36. package/modules/notifications/files/src/notifications/service.ts +476 -0
  37. package/modules/notifications/files/src/notifications/setup.ts +18 -0
  38. package/modules/notifications/files/src/notifications/types.ts +74 -0
  39. package/modules/notifications/files/src/notifications/useNotificationPermission.ts +80 -0
  40. package/modules/notifications/files/src/notifications/useNotificationSync.ts +71 -0
  41. package/modules/notifications/files/src/notifications/useNotifications.ts +13 -0
  42. package/modules/notifications/overlays/react-navigation/src/navigation/navigationRef.ts +6 -0
  43. package/modules/notifications/overlays/react-navigation/src/notifications/navigation.ts +38 -0
  44. package/modules/notifications/overlays/styling/stylesheet/src/notifications/components/NotificationPermissionBanner.tsx +134 -0
  45. package/modules/notifications/overlays/styling/tailwind/src/notifications/components/NotificationPermissionBanner.tsx +108 -0
  46. package/modules/notifications/translations/ar.json +12 -0
  47. package/modules/notifications/translations/en.json +12 -0
  48. package/modules/notifications/translations/es.json +12 -0
  49. package/modules/subscriptions/files/src/app/subscription.tsx +1 -0
  50. package/modules/subscriptions/files/src/subscription/README.md +215 -0
  51. package/modules/subscriptions/files/src/subscription/SubscriptionProvider.tsx +562 -0
  52. package/modules/subscriptions/files/src/subscription/SubscriptionRoot.tsx +30 -0
  53. package/modules/subscriptions/files/src/subscription/__tests__/SubscriptionProvider.test.tsx +931 -0
  54. package/modules/subscriptions/files/src/subscription/__tests__/UnlockModal.test.tsx +79 -0
  55. package/modules/subscriptions/files/src/subscription/__tests__/config.test.ts +23 -0
  56. package/modules/subscriptions/files/src/subscription/__tests__/introOffer.test.ts +93 -0
  57. package/modules/subscriptions/files/src/subscription/__tests__/offeringContent.test.ts +215 -0
  58. package/modules/subscriptions/files/src/subscription/__tests__/planChange.test.ts +356 -0
  59. package/modules/subscriptions/files/src/subscription/__tests__/purchaseErrors.test.ts +111 -0
  60. package/modules/subscriptions/files/src/subscription/__tests__/service.test.ts +369 -0
  61. package/modules/subscriptions/files/src/subscription/__tests__/statusNotice.test.ts +259 -0
  62. package/modules/subscriptions/files/src/subscription/__tests__/subscriptionState.test.ts +690 -0
  63. package/modules/subscriptions/files/src/subscription/components/CrossPlatformNotice.tsx +94 -0
  64. package/modules/subscriptions/files/src/subscription/components/PremiumGate.tsx +74 -0
  65. package/modules/subscriptions/files/src/subscription/components/SubscriptionStatusNotice.tsx +153 -0
  66. package/modules/subscriptions/files/src/subscription/components/UnlockModal.tsx +223 -0
  67. package/modules/subscriptions/files/src/subscription/config.ts +54 -0
  68. package/modules/subscriptions/files/src/subscription/index.ts +95 -0
  69. package/modules/subscriptions/files/src/subscription/introOffer.ts +61 -0
  70. package/modules/subscriptions/files/src/subscription/management.ts +30 -0
  71. package/modules/subscriptions/files/src/subscription/navigation.ts +15 -0
  72. package/modules/subscriptions/files/src/subscription/offeringContent.ts +259 -0
  73. package/modules/subscriptions/files/src/subscription/planChange.ts +219 -0
  74. package/modules/subscriptions/files/src/subscription/purchaseErrors.ts +140 -0
  75. package/modules/subscriptions/files/src/subscription/screens/PaywallScreen.tsx +466 -0
  76. package/modules/subscriptions/files/src/subscription/service.ts +270 -0
  77. package/modules/subscriptions/files/src/subscription/statusNotice.ts +189 -0
  78. package/modules/subscriptions/files/src/subscription/subscriptionState.ts +485 -0
  79. package/modules/subscriptions/files/src/subscription/types.ts +164 -0
  80. package/modules/subscriptions/files/src/subscription/useSubscriptionUser.ts +29 -0
  81. package/modules/subscriptions/overlays/react-navigation/src/navigation/navigationRef.ts +6 -0
  82. package/modules/subscriptions/overlays/react-navigation/src/subscription/navigation.ts +14 -0
  83. package/modules/subscriptions/overlays/styling/stylesheet/src/subscription/components/CrossPlatformNotice.tsx +94 -0
  84. package/modules/subscriptions/overlays/styling/stylesheet/src/subscription/components/SubscriptionStatusNotice.tsx +153 -0
  85. package/modules/subscriptions/overlays/styling/stylesheet/src/subscription/components/UnlockModal.tsx +227 -0
  86. package/modules/subscriptions/overlays/styling/stylesheet/src/subscription/screens/PaywallScreen.tsx +467 -0
  87. package/modules/subscriptions/overlays/styling/tailwind/src/subscription/components/CrossPlatformNotice.tsx +74 -0
  88. package/modules/subscriptions/overlays/styling/tailwind/src/subscription/components/SubscriptionStatusNotice.tsx +136 -0
  89. package/modules/subscriptions/overlays/styling/tailwind/src/subscription/components/UnlockModal.tsx +181 -0
  90. package/modules/subscriptions/overlays/styling/tailwind/src/subscription/screens/PaywallScreen.tsx +364 -0
  91. package/modules/subscriptions/translations/ar.json +128 -0
  92. package/modules/subscriptions/translations/en.json +112 -0
  93. package/modules/subscriptions/translations/es.json +112 -0
  94. package/package.json +3 -2
  95. package/template/jest.config.js +3 -1
  96. package/template/jest.setup.ts +36 -10
  97. package/template/src/localization/__tests__/i18n.test.ts +25 -0
  98. package/template/src/services/client.ts +14 -5
  99. package/template/src/shared/components/__tests__/Button.test.tsx +24 -0
@@ -0,0 +1,241 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { copyFile } from 'node:fs/promises';
3
+ import { join, resolve } from 'node:path';
4
+ import * as p from '@clack/prompts';
5
+ import { MODULES_DIR } from '../template.js';
6
+ import { pick } from '../versions.js';
7
+ import { mergeTranslations, wireNavigationRef, wrapAppChildren } from './shared.js';
8
+ import { CancelledError } from './types.js';
9
+ const DIR = join(MODULES_DIR, 'notifications');
10
+ const DEFAULT_ENDPOINT = 'devices/push-token';
11
+ const FIREBASE_FILES = [
12
+ { name: 'google-services.json', platform: 'Android', flag: 'googleServices' },
13
+ { name: 'GoogleService-Info.plist', platform: 'iOS', flag: 'googleServiceInfo' },
14
+ ];
15
+ const JEST_MOCKS = `
16
+ // Push notifications (added by \`create-expo-boilerplate add notifications\`).
17
+ // NotifyKit ships its own mock with real enum values. Firebase is stubbed to
18
+ // exactly the modular surface \`src/notifications/service.ts\` calls, so a
19
+ // regression to the deprecated namespaced API fails here rather than on device.
20
+ jest.mock('react-native-notify-kit', () => require('react-native-notify-kit/jest-mock'));
21
+
22
+ jest.mock('@react-native-firebase/app', () => ({
23
+ __esModule: true,
24
+ getApp: jest.fn().mockReturnValue({ name: '[DEFAULT]' }),
25
+ }));
26
+
27
+ jest.mock('@react-native-firebase/messaging', () => ({
28
+ __esModule: true,
29
+ getMessaging: jest.fn().mockReturnValue({}),
30
+ getToken: jest.fn().mockResolvedValue('test-fcm-token'),
31
+ registerDeviceForRemoteMessages: jest.fn().mockResolvedValue(undefined),
32
+ onTokenRefresh: jest.fn().mockReturnValue(jest.fn()),
33
+ onMessage: jest.fn().mockReturnValue(jest.fn()),
34
+ onNotificationOpenedApp: jest.fn().mockReturnValue(jest.fn()),
35
+ getInitialNotification: jest.fn().mockResolvedValue(null),
36
+ setBackgroundMessageHandler: jest.fn(),
37
+ }));
38
+ `;
39
+ const firebaseHelper = (ts) => `
40
+ // Firebase config files live in ./firebase/ (added by \`create-expo-boilerplate
41
+ // add notifications\`). A missing file only warns here so Metro and
42
+ // \`expo config\` keep working; \`expo prebuild\` fails until both are added.
43
+ const firebaseFile = (name${ts ? ': string' : ''}) => {
44
+ const path = resolve(__dirname, 'firebase', name);
45
+ if (existsSync(path)) return path;
46
+ console.warn(\`[app.config] firebase/\${name} not found — native builds fail until you add it.\`);
47
+ return undefined;
48
+ };
49
+ `;
50
+ async function askEndpoint(ctx, flag) {
51
+ const validate = (v) => !v?.trim()
52
+ ? 'An endpoint is required.'
53
+ : /[\s'"`\\]/.test(v)
54
+ ? 'No spaces, quotes or backslashes.'
55
+ : undefined;
56
+ if (typeof flag === 'string') {
57
+ const error = validate(flag);
58
+ if (error)
59
+ throw new Error(`--token-endpoint: ${error}`);
60
+ return flag.trim();
61
+ }
62
+ if (!ctx.interactive)
63
+ return DEFAULT_ENDPOINT;
64
+ const answer = await p.text({
65
+ message: 'Backend endpoint that receives the push token (POST, relative to EXPO_PUBLIC_API_URL)',
66
+ initialValue: DEFAULT_ENDPOINT,
67
+ validate,
68
+ });
69
+ if (p.isCancel(answer))
70
+ throw new CancelledError();
71
+ return answer.trim();
72
+ }
73
+ async function askBanner(ctx, flag) {
74
+ if (typeof flag === 'boolean')
75
+ return flag;
76
+ if (!ctx.interactive)
77
+ return true;
78
+ const answer = await p.confirm({
79
+ message: 'Show the notification permission banner on the Settings screen?',
80
+ initialValue: true,
81
+ });
82
+ if (p.isCancel(answer))
83
+ throw new CancelledError();
84
+ return answer;
85
+ }
86
+ /** Copies the Firebase files the user points at into ./firebase/. Returns the ones still missing. */
87
+ async function copyFirebaseFiles(ctx, flags) {
88
+ const missing = [];
89
+ for (const file of FIREBASE_FILES) {
90
+ const dest = `firebase/${file.name}`;
91
+ if (ctx.files.exists(dest))
92
+ continue;
93
+ let source = typeof flags[file.flag] === 'string' ? flags[file.flag] : '';
94
+ if (!source && ctx.interactive) {
95
+ const answer = await p.text({
96
+ message: `Path to ${file.name} (${file.platform}) — press Enter to skip and add it later`,
97
+ placeholder: `~/Downloads/${file.name}`,
98
+ validate: (v) => {
99
+ if (!v?.trim())
100
+ return undefined;
101
+ return existsSync(expand(v, ctx.cwd)) ? undefined : 'No file at that path.';
102
+ },
103
+ });
104
+ if (p.isCancel(answer))
105
+ throw new CancelledError();
106
+ source = answer ?? '';
107
+ }
108
+ if (!source.trim()) {
109
+ missing.push(file.name);
110
+ continue;
111
+ }
112
+ const abs = expand(source, ctx.cwd);
113
+ if (!existsSync(abs))
114
+ throw new Error(`${file.name}: no file at ${abs}`);
115
+ await ctx.files.write(dest, '');
116
+ await copyFile(abs, ctx.files.abs(dest));
117
+ }
118
+ return missing;
119
+ }
120
+ function expand(path, cwd) {
121
+ const trimmed = path.trim().replace(/^['"]|['"]$/g, '');
122
+ const home = process.env.HOME ?? '';
123
+ return resolve(cwd, trimmed.startsWith('~/') ? join(home, trimmed.slice(2)) : trimmed);
124
+ }
125
+ export const notifications = {
126
+ name: 'notifications',
127
+ description: 'Push + local notifications (Firebase Cloud Messaging + NotifyKit)',
128
+ flags: [
129
+ {
130
+ flags: '--token-endpoint <path>',
131
+ description: `notifications: token API (default ${DEFAULT_ENDPOINT})`,
132
+ },
133
+ {
134
+ flags: '--google-services <path>',
135
+ description: 'notifications: google-services.json to copy in',
136
+ },
137
+ {
138
+ flags: '--google-service-info <path>',
139
+ description: 'notifications: GoogleService-Info.plist to copy in',
140
+ },
141
+ // Both forms, so the prompt still runs when neither is passed.
142
+ { flags: '--banner', description: 'notifications: add the permission banner to Settings' },
143
+ { flags: '--no-banner', description: 'notifications: skip the permission banner' },
144
+ ],
145
+ async apply(ctx, flags) {
146
+ const { files, pkg, stack, patch } = ctx;
147
+ const ts = stack.language === 'ts';
148
+ const ext = (base) => ts ? base : base.replace(/\.ts$/, '.js').replace(/\.tsx$/, '.jsx');
149
+ const endpoint = await askEndpoint(ctx, flags.tokenEndpoint);
150
+ const banner = await askBanner(ctx, flags.banner);
151
+ const missingFirebase = await copyFirebaseFiles(ctx, flags);
152
+ // ── Module files ─────────────────────────────────────────
153
+ await files.overlay(join(DIR, 'files'));
154
+ if (stack.navigation === 'react-navigation') {
155
+ await files.overlay(join(DIR, 'overlays', 'react-navigation'));
156
+ await files.replace('src/notifications/useNotificationPermission.ts', "import { useFocusEffect } from 'expo-router';", "import { useFocusEffect } from '@react-navigation/native';");
157
+ }
158
+ if (stack.styling !== 'unistyles') {
159
+ await files.overlay(join(DIR, 'overlays', 'styling', stack.styling));
160
+ }
161
+ await files.replace('src/notifications/useNotificationSync.ts', '__TOKEN_ENDPOINT__', endpoint);
162
+ pkg.addDeps(pick('@react-native-firebase/app', '@react-native-firebase/messaging', 'react-native-notify-kit'));
163
+ // ── Copy ─────────────────────────────────────────────────
164
+ await mergeTranslations(ctx, DIR);
165
+ // ── Wiring ───────────────────────────────────────────────
166
+ await patch.insertBefore(patch.find('index.ts', 'index.js'), /^import (?:'expo-router\/entry'|\{ registerRootComponent \} from 'expo');/m,
167
+ // The blank line keeps it out of the sorted `expo` import group (React Navigation entry).
168
+ "// Push notifications: background handlers + Android channels, before anything renders.\nimport '@/notifications/setup';\n\n", "add `import '@/notifications/setup';` to the entry file, above the router/app import.");
169
+ await wrapAppChildren(patch, 'NotificationRoot', '@/notifications');
170
+ if (stack.navigation === 'react-navigation') {
171
+ await wireNavigationRef(patch, 'notification taps can navigate');
172
+ }
173
+ if (banner) {
174
+ const settings = patch.find('src/features/settings/screens/SettingsScreen.tsx', 'src/features/settings/screens/SettingsScreen.jsx');
175
+ const bannerMsg = 'render `<NotificationPermissionBanner />` (from `@/notifications`) on the screen that should ask for permission.';
176
+ if (await patch.insertAfter(settings, /<ScreenWrapper[^>]*>\n/, '<NotificationPermissionBanner />\n', bannerMsg)) {
177
+ await patch.insertBefore(settings, /^import Button from/m, "import { NotificationPermissionBanner } from '@/notifications';\n", bannerMsg);
178
+ }
179
+ }
180
+ // ── Native config ────────────────────────────────────────
181
+ const appConfig = patch.find('app.config.ts', 'app.config.js');
182
+ const cfgMsg = (what) => `${what} — see src/notifications/README.md § Native setup.`;
183
+ await patch.insertAfter(appConfig, /^import Env from '\.\/env';\n/m, `${firebaseHelper(ts)}`, cfgMsg('add a `firebaseFile()` helper that resolves files in ./firebase/'));
184
+ const nodeImports = "import { existsSync } from 'node:fs';\nimport { resolve } from 'node:path';\n";
185
+ await patch.edit(appConfig, (src) => {
186
+ if (src.includes("from 'node:fs'"))
187
+ return src;
188
+ // Sorted the way the project's simple-import-sort wants: with the other
189
+ // packages (`expo/config`, TS only), above the relative `./env` group.
190
+ const expoConfig = /^import \{[^}]*\} from 'expo\/config';\n/m.exec(src);
191
+ if (expoConfig) {
192
+ const end = expoConfig.index + expoConfig[0].length;
193
+ return src.slice(0, end) + nodeImports + src.slice(end);
194
+ }
195
+ const env = /^import Env from '\.\/env';/m.exec(src);
196
+ return env ? `${src.slice(0, env.index)}${nodeImports}\n${src.slice(env.index)}` : null;
197
+ }, cfgMsg("import `existsSync` from 'node:fs' and `resolve` from 'node:path'"));
198
+ await patch.insertAfter(appConfig, /^ {2}ios: \{\n/m, [
199
+ " googleServicesFile: firebaseFile('GoogleService-Info.plist'),",
200
+ ' // No config plugin adds these. Without `aps-environment` iOS never issues an',
201
+ ' // APNs token, so FCM has nothing to send to. `development` is right for',
202
+ ' // App Store builds too — Xcode rewrites it to `production` when archiving.',
203
+ " entitlements: { 'aps-environment': 'development' },",
204
+ " infoPlist: { UIBackgroundModes: ['remote-notification'] },",
205
+ '',
206
+ ].join('\n'), cfgMsg("set `ios.googleServicesFile`, `ios.entitlements['aps-environment']` and `ios.infoPlist.UIBackgroundModes`"));
207
+ await patch.insertAfter(appConfig, /^ {2}android: \{\n/m, " googleServicesFile: firebaseFile('google-services.json'),\n", cfgMsg('set `android.googleServicesFile`'));
208
+ await patch.insertBefore(appConfig, /^ {4}\[\n {6}'expo-build-properties',/m, [
209
+ " '@react-native-firebase/app',",
210
+ " '@react-native-firebase/messaging',",
211
+ " 'react-native-notify-kit',",
212
+ ' // Must match DEFAULT_CHANNEL_ID in src/notifications/config.ts.',
213
+ " './plugins/withAndroidDefaultNotificationChannel',",
214
+ '',
215
+ ].join('\n'), cfgMsg('add the `@react-native-firebase/app`, `@react-native-firebase/messaging`, `react-native-notify-kit` and `./plugins/withAndroidDefaultNotificationChannel` plugins'));
216
+ await patch.edit(appConfig, (src) => {
217
+ if (src.includes('useFrameworks'))
218
+ return src;
219
+ const anchor = /'expo-build-properties',\n {6}\{\n/;
220
+ return anchor.test(src)
221
+ ? src.replace(anchor, (m) => `${m} // React Native Firebase v27 pulls Firebase in via SPM, which needs frameworks.\n ios: { useFrameworks: 'dynamic' },\n`)
222
+ : null;
223
+ }, cfgMsg("set `ios.useFrameworks: 'dynamic'` in the `expo-build-properties` plugin options"));
224
+ // ── Tests ────────────────────────────────────────────────
225
+ await patch.edit(patch.find('jest.setup.ts', 'jest.setup.js'), (src) => src.includes("jest.mock('react-native-notify-kit'")
226
+ ? src
227
+ : `${src.trimEnd()}\n${JEST_MOCKS}`, 'add the Firebase and NotifyKit mocks from src/notifications/README.md § Testing.');
228
+ await patch.edit('jest.config.js', (src) => src.includes('react-native-notify-kit')
229
+ ? src
230
+ : src.includes("'node_modules/(?!(' +\n")
231
+ ? src.replace("'node_modules/(?!(' +\n", "'node_modules/(?!(' +\n 'react-native-notify-kit|' +\n")
232
+ : null, 'add `react-native-notify-kit` to `transformIgnorePatterns`.');
233
+ // ── What's left ──────────────────────────────────────────
234
+ const steps = [];
235
+ if (missingFirebase.length) {
236
+ steps.push(`Required before the next native build: download ${missingFirebase.join(' and ')} from Firebase console → Project settings → Your apps (register the app with your bundle id / package first), and save ${missingFirebase.length > 1 ? 'them' : 'it'} in firebase/.`);
237
+ }
238
+ steps.push('Upload your APNs auth key (.p8) in Firebase console → Project settings → Cloud Messaging, so FCM can deliver to iOS.', `Make your backend accept POST ${endpoint} with { token, platform } (or edit ${ext('src/notifications/useNotificationSync.ts')}).`, 'Rebuild the dev client — these are new native modules: npx expo prebuild --clean, then run ios:development / android:development.', 'Test remote push on a physical iPhone; the iOS simulator gets no push token.');
239
+ return steps;
240
+ },
241
+ };
@@ -0,0 +1,67 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { readFile } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+ /** Adds `source`'s keys to `target`, recursively. Existing values always win. */
5
+ export function deepMerge(target, source) {
6
+ const out = { ...target };
7
+ for (const [key, value] of Object.entries(source)) {
8
+ const existing = out[key];
9
+ out[key] =
10
+ value && typeof value === 'object' && existing && typeof existing === 'object'
11
+ ? deepMerge(existing, value)
12
+ : (existing ?? value);
13
+ }
14
+ return out;
15
+ }
16
+ /**
17
+ * Merges `<moduleDir>/translations/<lang>.json` into every translation file the
18
+ * project has. A language the module doesn't ship gets the English strings.
19
+ */
20
+ export async function mergeTranslations({ files }, moduleDir) {
21
+ const dir = join(moduleDir, 'translations');
22
+ const fallback = JSON.parse(await readFile(join(dir, 'en.json'), 'utf8'));
23
+ for (const rel of await files.list('src/localization/translations')) {
24
+ if (!rel.endsWith('.json'))
25
+ continue;
26
+ const lang = rel.split('/').pop().replace('.json', '');
27
+ const own = join(dir, `${lang}.json`);
28
+ const strings = existsSync(own) ? JSON.parse(await readFile(own, 'utf8')) : fallback;
29
+ const merged = deepMerge(JSON.parse(await files.read(rel)), strings);
30
+ await files.write(rel, `${JSON.stringify(merged, null, 2)}\n`);
31
+ }
32
+ }
33
+ /**
34
+ * Wraps the navigation root (`{children}` inside `<APIProvider>` in
35
+ * AppProviders) in `<Tag>`, and imports it. Works whichever modules already
36
+ * wrapped it: the new wrapper goes innermost.
37
+ */
38
+ export async function wrapAppChildren(patch, tag, from) {
39
+ const providers = patch.find('src/providers/AppProviders.tsx', 'src/providers/AppProviders.jsx');
40
+ const manual = `wrap the navigation root in \`<${tag}>\` (from \`${from}\`), inside \`<APIProvider>\`.`;
41
+ const wrapped = await patch.edit(providers, (src) => {
42
+ if (src.includes(`<${tag}>`))
43
+ return src;
44
+ const re = /(<APIProvider>[\s\S]*?)\{children\}/;
45
+ return re.test(src) ? src.replace(re, `$1<${tag}>{children}</${tag}>`) : null;
46
+ }, manual);
47
+ if (wrapped) {
48
+ await patch.insertBefore(providers, /^import \{ APIProvider \}/m, `import { ${tag} } from '${from}';\n`, manual);
49
+ }
50
+ }
51
+ /**
52
+ * React Navigation only: passes `src/navigation/navigationRef` to the
53
+ * `NavigationContainer`, so code outside a screen can navigate. The ref file
54
+ * itself comes from the module's react-navigation overlay.
55
+ */
56
+ export async function wireNavigationRef(patch, why) {
57
+ const app = patch.find('src/App.tsx', 'src/App.jsx');
58
+ const manual = `pass \`ref={navigationRef}\` (from \`@/navigation/navigationRef\`) to \`<NavigationContainer>\` so ${why}.`;
59
+ const wired = await patch.edit(app, (src) => src.includes('ref={navigationRef}')
60
+ ? src
61
+ : /<NavigationContainer(\s)/.test(src)
62
+ ? src.replace(/<NavigationContainer(\s)/, '<NavigationContainer ref={navigationRef}$1')
63
+ : null, manual);
64
+ if (wired) {
65
+ await patch.insertBefore(app, /^import \{ AppProviders/m, "import { navigationRef } from '@/navigation/navigationRef';\n", manual);
66
+ }
67
+ }
@@ -0,0 +1,223 @@
1
+ import { join } from 'node:path';
2
+ import * as p from '@clack/prompts';
3
+ import { MODULES_DIR } from '../template.js';
4
+ import { pick } from '../versions.js';
5
+ import { mergeTranslations, wireNavigationRef, wrapAppChildren } from './shared.js';
6
+ import { CancelledError } from './types.js';
7
+ const DIR = join(MODULES_DIR, 'subscriptions');
8
+ const DEFAULT_ENTITLEMENT = 'premium';
9
+ const ENV_FILES = ['.env.development', '.env.staging', '.env.production'];
10
+ const KEY_IOS = 'EXPO_PUBLIC_REVENUECAT_API_KEY_IOS';
11
+ const KEY_ANDROID = 'EXPO_PUBLIC_REVENUECAT_API_KEY_ANDROID';
12
+ const JEST_MOCKS = `
13
+ // Subscriptions (added by \`create-expo-boilerplate add subscriptions\`).
14
+ // react-native-purchases needs native modules and ships untranspiled ESM. The
15
+ // enums are mocked whole, with the SDK's real values: a partial mock leaves
16
+ // codes \`undefined\`, so distinct \`switch\` cases collapse into the first one.
17
+ jest.mock('@gorhom/bottom-sheet', () => require('@gorhom/bottom-sheet/mock'));
18
+ jest.mock('react-native-purchases', () => ({
19
+ __esModule: true,
20
+ LOG_LEVEL: { DEBUG: 'DEBUG', ERROR: 'ERROR' },
21
+ PURCHASES_ERROR_CODE: {
22
+ UNKNOWN_ERROR: '0',
23
+ PURCHASE_CANCELLED_ERROR: '1',
24
+ STORE_PROBLEM_ERROR: '2',
25
+ PURCHASE_NOT_ALLOWED_ERROR: '3',
26
+ PURCHASE_INVALID_ERROR: '4',
27
+ PRODUCT_NOT_AVAILABLE_FOR_PURCHASE_ERROR: '5',
28
+ PRODUCT_ALREADY_PURCHASED_ERROR: '6',
29
+ RECEIPT_ALREADY_IN_USE_ERROR: '7',
30
+ INVALID_RECEIPT_ERROR: '8',
31
+ MISSING_RECEIPT_FILE_ERROR: '9',
32
+ NETWORK_ERROR: '10',
33
+ INVALID_CREDENTIALS_ERROR: '11',
34
+ UNEXPECTED_BACKEND_RESPONSE_ERROR: '12',
35
+ RECEIPT_IN_USE_BY_OTHER_SUBSCRIBER_ERROR: '13',
36
+ INVALID_APP_USER_ID_ERROR: '14',
37
+ OPERATION_ALREADY_IN_PROGRESS_ERROR: '15',
38
+ UNKNOWN_BACKEND_ERROR: '16',
39
+ INVALID_APPLE_SUBSCRIPTION_KEY_ERROR: '17',
40
+ INELIGIBLE_ERROR: '18',
41
+ INSUFFICIENT_PERMISSIONS_ERROR: '19',
42
+ PAYMENT_PENDING_ERROR: '20',
43
+ INVALID_SUBSCRIBER_ATTRIBUTES_ERROR: '21',
44
+ LOG_OUT_ANONYMOUS_USER_ERROR: '22',
45
+ CONFIGURATION_ERROR: '23',
46
+ UNSUPPORTED_ERROR: '24',
47
+ EMPTY_SUBSCRIBER_ATTRIBUTES_ERROR: '25',
48
+ PRODUCT_DISCOUNT_MISSING_IDENTIFIER_ERROR: '26',
49
+ PRODUCT_DISCOUNT_MISSING_SUBSCRIPTION_GROUP_IDENTIFIER_ERROR: '28',
50
+ CUSTOMER_INFO_ERROR: '29',
51
+ SYSTEM_INFO_ERROR: '30',
52
+ BEGIN_REFUND_REQUEST_ERROR: '31',
53
+ PRODUCT_REQUEST_TIMED_OUT_ERROR: '32',
54
+ API_ENDPOINT_BLOCKED: '33',
55
+ INVALID_PROMOTIONAL_OFFER_ERROR: '34',
56
+ OFFLINE_CONNECTION_ERROR: '35',
57
+ TEST_STORE_SIMULATED_PURCHASE_ERROR: '42',
58
+ },
59
+ PACKAGE_TYPE: {
60
+ UNKNOWN: 'UNKNOWN',
61
+ CUSTOM: 'CUSTOM',
62
+ LIFETIME: 'LIFETIME',
63
+ ANNUAL: 'ANNUAL',
64
+ SIX_MONTH: 'SIX_MONTH',
65
+ THREE_MONTH: 'THREE_MONTH',
66
+ TWO_MONTH: 'TWO_MONTH',
67
+ MONTHLY: 'MONTHLY',
68
+ WEEKLY: 'WEEKLY',
69
+ },
70
+ INTRO_ELIGIBILITY_STATUS: {
71
+ INTRO_ELIGIBILITY_STATUS_UNKNOWN: 0,
72
+ INTRO_ELIGIBILITY_STATUS_INELIGIBLE: 1,
73
+ INTRO_ELIGIBILITY_STATUS_ELIGIBLE: 2,
74
+ INTRO_ELIGIBILITY_STATUS_NO_INTRO_OFFER_EXISTS: 3,
75
+ },
76
+ STORE_REPLACEMENT_MODE: {
77
+ WITHOUT_PRORATION: 'WITHOUT_PRORATION',
78
+ WITH_TIME_PRORATION: 'WITH_TIME_PRORATION',
79
+ CHARGE_FULL_PRICE: 'CHARGE_FULL_PRICE',
80
+ CHARGE_PRORATED_PRICE: 'CHARGE_PRORATED_PRICE',
81
+ DEFERRED: 'DEFERRED',
82
+ },
83
+ default: {
84
+ addCustomerInfoUpdateListener: jest.fn(),
85
+ checkTrialOrIntroductoryPriceEligibility: jest.fn().mockResolvedValue({}),
86
+ configure: jest.fn(),
87
+ getAppUserID: jest.fn().mockResolvedValue('mock-app-user-id'),
88
+ getCustomerInfo: jest.fn().mockResolvedValue(null),
89
+ getOfferings: jest.fn().mockResolvedValue({ current: null, all: {} }),
90
+ invalidateCustomerInfoCache: jest.fn().mockResolvedValue(undefined),
91
+ isAnonymous: jest.fn().mockResolvedValue(true),
92
+ logIn: jest.fn().mockResolvedValue({ customerInfo: null, created: false }),
93
+ logOut: jest.fn().mockResolvedValue(null),
94
+ purchasePackage: jest.fn().mockResolvedValue({ customerInfo: null }),
95
+ removeCustomerInfoUpdateListener: jest.fn(),
96
+ restorePurchases: jest.fn().mockResolvedValue(null),
97
+ setDisplayName: jest.fn().mockResolvedValue(undefined),
98
+ setEmail: jest.fn().mockResolvedValue(undefined),
99
+ setLogLevel: jest.fn(),
100
+ syncPurchases: jest.fn().mockResolvedValue(undefined),
101
+ },
102
+ }));
103
+ `;
104
+ async function ask(ctx, flag, prompt, fallback) {
105
+ if (typeof flag === 'string')
106
+ return flag.trim();
107
+ if (!ctx.interactive)
108
+ return fallback;
109
+ const answer = await p.text(prompt);
110
+ if (p.isCancel(answer))
111
+ throw new CancelledError();
112
+ return (answer ?? '').trim();
113
+ }
114
+ const NO_QUOTES = /^[^'"`\\\n]*$/;
115
+ export const subscriptions = {
116
+ name: 'subscriptions',
117
+ dir: 'subscription',
118
+ description: 'In-app subscriptions (RevenueCat): paywall, premium gate, every store state',
119
+ flags: [
120
+ {
121
+ flags: '--entitlement <id>',
122
+ description: `subscriptions: RevenueCat entitlement id (default ${DEFAULT_ENTITLEMENT})`,
123
+ },
124
+ { flags: '--revenuecat-ios-key <key>', description: 'subscriptions: public SDK key (appl_…)' },
125
+ {
126
+ flags: '--revenuecat-android-key <key>',
127
+ description: 'subscriptions: public SDK key (goog_…)',
128
+ },
129
+ ],
130
+ async apply(ctx, flags) {
131
+ const { files, pkg, stack, patch } = ctx;
132
+ const ts = stack.language === 'ts';
133
+ const ext = (rel) => (ts ? rel : rel.replace(/\.ts$/, '.js').replace(/\.tsx$/, '.jsx'));
134
+ const entitlement = await ask(ctx, flags.entitlement, {
135
+ message: 'RevenueCat entitlement identifier (RevenueCat → Product catalog → Entitlements)',
136
+ initialValue: DEFAULT_ENTITLEMENT,
137
+ validate: (v) => !v?.trim() ? 'Required.' : NO_QUOTES.test(v) ? undefined : 'No quotes or backslashes.',
138
+ }, DEFAULT_ENTITLEMENT);
139
+ if (!entitlement || !NO_QUOTES.test(entitlement)) {
140
+ throw new Error('--entitlement: a non-empty id without quotes or backslashes.');
141
+ }
142
+ const keyPrompt = (platform, prefix) => ({
143
+ message: `RevenueCat ${platform} public SDK key (${prefix}…) — press Enter to skip and add it later`,
144
+ validate: (v) => !v?.trim() || /^[A-Za-z0-9_]+$/.test(v.trim()) ? undefined : 'Letters, digits and _ only.',
145
+ });
146
+ const iosKey = await ask(ctx, flags.revenuecatIosKey, keyPrompt('iOS', 'appl_'), '');
147
+ const androidKey = await ask(ctx, flags.revenuecatAndroidKey, keyPrompt('Android', 'goog_'), '');
148
+ // ── Module files ─────────────────────────────────────────
149
+ await files.overlay(join(DIR, 'files'));
150
+ if (stack.navigation === 'react-navigation') {
151
+ await files.remove('src/app'); // the Expo Router route file
152
+ await files.overlay(join(DIR, 'overlays', 'react-navigation'));
153
+ }
154
+ if (stack.styling !== 'unistyles') {
155
+ await files.overlay(join(DIR, 'overlays', 'styling', stack.styling));
156
+ }
157
+ await files.replace('src/subscription/config.ts', '__ENTITLEMENT_ID__', entitlement);
158
+ pkg.addDeps(pick('react-native-purchases', '@gorhom/bottom-sheet'));
159
+ await mergeTranslations(ctx, DIR);
160
+ // ── Env: the RevenueCat keys ─────────────────────────────
161
+ const envFile = patch.find('env.ts', 'env.js');
162
+ const envMsg = `add optional \`${KEY_IOS}\` / \`${KEY_ANDROID}\` to the env schema and object in env.ts.`;
163
+ await patch.insertAfter(envFile, /^ *EXPO_PUBLIC_API_URL: z\.string\(\)[^\n]*\n/m, `\n // RevenueCat public SDK keys (optional — subscriptions stay off without them)\n ${KEY_IOS}: z.string().optional(),\n ${KEY_ANDROID}: z.string().optional(),\n`, envMsg);
164
+ await patch.insertAfter(envFile, /^ *EXPO_PUBLIC_API_URL: process\.env\.EXPO_PUBLIC_API_URL[^\n]*\n/m, ` ${KEY_IOS}: process.env.${KEY_IOS},\n ${KEY_ANDROID}: process.env.${KEY_ANDROID},\n`, envMsg);
165
+ await patch.edit('.env.example', (src) => src.includes(KEY_IOS)
166
+ ? src
167
+ : `${src.trimEnd()}\n\n# RevenueCat public SDK keys (Project settings → API keys). Public by design.\n${KEY_IOS}=\n${KEY_ANDROID}=\n`, `add \`${KEY_IOS}=\` and \`${KEY_ANDROID}=\` to .env.example.`);
168
+ for (const rel of ENV_FILES) {
169
+ if (!files.exists(rel))
170
+ continue;
171
+ const src = await files.read(rel);
172
+ if (src.includes(KEY_IOS))
173
+ continue;
174
+ await files.write(rel, `${src.trimEnd()}\n\n# RevenueCat public SDK keys\n${KEY_IOS}=${iosKey}\n${KEY_ANDROID}=${androidKey}\n`);
175
+ }
176
+ // ── Wiring ───────────────────────────────────────────────
177
+ await wrapAppChildren(patch, 'SubscriptionRoot', '@/subscription');
178
+ if (stack.navigation === 'expo-router') {
179
+ await patch.insertAfter(patch.find('src/app/_layout.tsx', 'src/app/_layout.jsx'), /^ *<Stack\.Screen name="\(tabs\)" \/>\n/m, ' <Stack.Screen name="subscription" />\n', 'register `<Stack.Screen name="subscription" />` inside the signed-in `Stack.Protected` guard.');
180
+ }
181
+ else {
182
+ await wireNavigationRef(patch, 'the premium gate can open the paywall');
183
+ const routeMsg = "register the paywall: `subscription: 'Subscription'` in NAVIGATION, a `[NAVIGATION.subscription]: undefined` param, `[NAVIGATION.subscription]: 'subscription'` in linking.ts, and `<Stack.Screen component={PaywallScreen} name={NAVIGATION.subscription} />` in the signed-in branch of RootNavigator.";
184
+ await patch.insertBefore(patch.find('src/shared/constants/navigation.ts', 'src/shared/constants/navigation.js'),
185
+ // `} as const;` in TypeScript, `};` once transpiled to JavaScript.
186
+ /^}( as const)?;/m, " subscription: 'Subscription',\n", routeMsg);
187
+ if (ts) {
188
+ await patch.insertAfter('src/navigation/types.ts', /^ *\[NAVIGATION\.main\]: NavigatorScreenParams<MainTabParamList>;\n/m, ' [NAVIGATION.subscription]: undefined;\n', routeMsg);
189
+ }
190
+ await patch.insertAfter(patch.find('src/navigation/linking.ts', 'src/navigation/linking.js'), /^ {4}screens: \{\n/m, " [NAVIGATION.subscription]: 'subscription',\n", routeMsg);
191
+ const rootNavigator = patch.find('src/navigation/RootNavigator.tsx', 'src/navigation/RootNavigator.jsx');
192
+ const mainScreen = '<Stack.Screen component={MainTabNavigator} name={NAVIGATION.main} />';
193
+ if (await patch.edit(rootNavigator, (src) => src.includes('NAVIGATION.subscription')
194
+ ? src
195
+ : src.includes(mainScreen)
196
+ ? src.replace(mainScreen, `<>\n${mainScreen}\n<Stack.Screen component={PaywallScreen} name={NAVIGATION.subscription} />\n</>`)
197
+ : null, routeMsg)) {
198
+ await patch.insertAfter(rootNavigator, /^import \{ NAVIGATION \} from '@\/shared\/constants';\n/m, "import { PaywallScreen } from '@/subscription';\n", routeMsg);
199
+ }
200
+ }
201
+ // ── Tests ────────────────────────────────────────────────
202
+ await patch.edit(patch.find('jest.setup.ts', 'jest.setup.js'), (src) => src.includes("jest.mock('react-native-purchases'")
203
+ ? src
204
+ : `${src.trimEnd()}\n${JEST_MOCKS}`, 'add the react-native-purchases and @gorhom/bottom-sheet mocks to jest.setup.');
205
+ // The tests format real copy through i18n, which loads expo-localization
206
+ // and, through it, other expo-* packages — all untranspiled ESM. Projects
207
+ // created before the template covered expo-* need it added.
208
+ await patch.edit('jest.config.js', (src) =>
209
+ // Templates from this version on already include it.
210
+ src.includes('expo-[a-z-]+')
211
+ ? src
212
+ : src.includes("'node_modules/(?!(' +\n")
213
+ ? src.replace("'node_modules/(?!(' +\n", "'node_modules/(?!(' +\n 'expo-[a-z-]+|' +\n")
214
+ : null, 'add `expo-[a-z-]+` to `transformIgnorePatterns`, so expo-* packages are transpiled.');
215
+ // ── What's left ──────────────────────────────────────────
216
+ const steps = [];
217
+ if (!iosKey || !androidKey) {
218
+ steps.push(`Add your RevenueCat public SDK keys to .env.development / .staging / .production (${KEY_IOS}, ${KEY_ANDROID}). Until then subscriptions are switched off and the paywall says plans couldn't load.`);
219
+ }
220
+ steps.push(`Map your user to a RevenueCat id in ${ext('src/subscription/useSubscriptionUser.ts')} — until then purchases are attributed to an anonymous id per install.`, `Set TERMS_OF_USE_URL and PRIVACY_POLICY_URL in ${ext('src/subscription/config.ts')} — both stores reject a paywall without them.`, `In RevenueCat: create the "${entitlement}" entitlement, attach your products, and mark an offering as current.`, 'Gate a feature: const { requirePremium } = useSubscription(); requirePremium(() => doIt()).', 'Rebuild the dev client — react-native-purchases is a native module: npx expo prebuild --clean, then run ios:development / android:development.', 'Test purchases with sandbox/test accounts (StoreKit sandbox on iOS, a license tester on Play); the simulator can show offerings but not complete a real purchase.');
221
+ return steps;
222
+ },
223
+ };
@@ -0,0 +1,57 @@
1
+ /** Thrown when the user cancels a prompt. */
2
+ export class CancelledError extends Error {
3
+ }
4
+ /** Edits to files the user owns, with a manual fallback when one no longer matches. */
5
+ export class Patcher {
6
+ files;
7
+ /** Instructions for edits that couldn't be applied automatically. */
8
+ manual = [];
9
+ constructor(files) {
10
+ this.files = files;
11
+ }
12
+ /** The first of `candidates` that exists (e.g. `index.ts` / `index.js`). */
13
+ find(...candidates) {
14
+ return candidates.find((rel) => this.files.exists(rel)) ?? null;
15
+ }
16
+ /**
17
+ * Applies `edit` to `rel`. `edit` returns the new content, or `null` when its
18
+ * anchor isn't there (the file was customised) — then `manual` is recorded
19
+ * for the user instead of guessing. Returns whether the edit was applied.
20
+ */
21
+ async edit(rel, edit, manual) {
22
+ if (!rel || !this.files.exists(rel)) {
23
+ this.manual.push(manual);
24
+ return false;
25
+ }
26
+ const source = await this.files.read(rel);
27
+ const next = edit(source);
28
+ if (next === null) {
29
+ this.manual.push(`${rel}: ${manual}`);
30
+ return false;
31
+ }
32
+ if (next !== source)
33
+ await this.files.write(rel, next);
34
+ return true;
35
+ }
36
+ /** Inserts `text` before the first match of `anchor`, unless `text` is already present. */
37
+ insertBefore(rel, anchor, text, manual) {
38
+ return this.edit(rel, (src) => {
39
+ if (src.includes(text.trim()))
40
+ return src;
41
+ const match = anchor.exec(src);
42
+ return match ? src.slice(0, match.index) + text + src.slice(match.index) : null;
43
+ }, manual);
44
+ }
45
+ /** Inserts `text` after the first match of `anchor`, unless `text` is already present. */
46
+ insertAfter(rel, anchor, text, manual) {
47
+ return this.edit(rel, (src) => {
48
+ if (src.includes(text.trim()))
49
+ return src;
50
+ const match = anchor.exec(src);
51
+ if (!match)
52
+ return null;
53
+ const end = match.index + match[0].length;
54
+ return src.slice(0, end) + text + src.slice(end);
55
+ }, manual);
56
+ }
57
+ }
@@ -8,11 +8,20 @@ const JSON_FILES = ['package.json', 'eas.json', 'jsconfig.json', 'tsconfig.json'
8
8
  * for files nobody touched.
9
9
  */
10
10
  export async function formatProject(files) {
11
- const config = (await prettier.resolveConfig(files.abs('.prettierrc'))) ?? {};
12
11
  const targets = (await files.list()).filter((rel) => (CODE_RE.test(rel) && !rel.startsWith('scripts/')) || JSON_FILES.includes(rel));
12
+ await formatFiles(files, targets);
13
+ }
14
+ /**
15
+ * Prettier over `rels` (code and JSON only), with the project's own config.
16
+ * Pass the project's own Prettier when it has one installed: another version
17
+ * can format the same code differently, and the project's ESLint would flag it.
18
+ */
19
+ export async function formatFiles(files, rels, engine = prettier) {
20
+ const config = (await engine.resolveConfig(files.abs('.prettierrc'))) ?? {};
21
+ const targets = rels.filter((rel) => CODE_RE.test(rel) || rel.endsWith('.json'));
13
22
  for (const rel of targets) {
14
23
  const source = await files.read(rel);
15
- const formatted = await prettier.format(source, { ...config, filepath: files.abs(rel) });
24
+ const formatted = await engine.format(source, { ...config, filepath: files.abs(rel) });
16
25
  if (formatted !== source)
17
26
  await files.write(rel, formatted);
18
27
  }