anhedral 0.1.9 → 0.3.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 (83) hide show
  1. package/CHANGELOG.md +97 -0
  2. package/CONTRIBUTING.md +70 -0
  3. package/LICENSE +21 -17
  4. package/README.md +151 -152
  5. package/SECURITY.md +35 -0
  6. package/SKILL.md +177 -0
  7. package/bin/anhedral.js +1 -1
  8. package/dist/architecture/contributions.d.ts +28 -0
  9. package/dist/architecture/contributions.js +118 -0
  10. package/dist/architecture/index.d.ts +12 -0
  11. package/dist/architecture/index.js +6 -0
  12. package/dist/architecture/manifest.d.ts +53 -0
  13. package/dist/architecture/manifest.js +283 -0
  14. package/dist/architecture/modules.d.ts +37 -0
  15. package/dist/architecture/modules.js +176 -0
  16. package/dist/architecture/plan.d.ts +58 -0
  17. package/dist/architecture/plan.js +145 -0
  18. package/dist/architecture/templates.d.ts +10 -0
  19. package/dist/architecture/templates.js +23 -0
  20. package/dist/bin.d.ts +2 -0
  21. package/dist/bin.js +167 -0
  22. package/dist/cli.d.ts +24 -2
  23. package/dist/cli.js +186 -21
  24. package/dist/composers/root.d.ts +6 -0
  25. package/dist/composers/root.js +34 -0
  26. package/dist/dependencies.d.ts +29 -9
  27. package/dist/dependencies.js +220 -60
  28. package/dist/index.d.ts +8 -2
  29. package/dist/index.js +5 -31
  30. package/dist/print.js +15 -0
  31. package/dist/prompts.d.ts +5 -0
  32. package/dist/prompts.js +19 -0
  33. package/dist/render.d.ts +11 -0
  34. package/dist/render.js +74 -0
  35. package/dist/scaffold.d.ts +56 -12
  36. package/dist/scaffold.js +1466 -1158
  37. package/dist/template-source.d.ts +20 -0
  38. package/dist/template-source.js +180 -0
  39. package/dist/templates/api.d.ts +2 -0
  40. package/dist/templates/api.js +3114 -0
  41. package/dist/templates/assets-private-proxy.d.ts +3 -0
  42. package/dist/templates/assets-private-proxy.js +277 -0
  43. package/dist/templates/desktop.d.ts +2 -0
  44. package/dist/templates/desktop.js +572 -0
  45. package/dist/templates/extension.d.ts +1 -1
  46. package/dist/templates/extension.js +322 -248
  47. package/dist/templates/mobile.d.ts +2 -0
  48. package/dist/templates/mobile.js +720 -0
  49. package/dist/templates/shared.d.ts +2 -0
  50. package/dist/templates/shared.js +503 -0
  51. package/dist/templates/web.d.ts +2 -0
  52. package/dist/templates/web.js +412 -0
  53. package/dist/toolchain.d.ts +0 -8
  54. package/dist/toolchain.js +0 -21
  55. package/dist/transaction.d.ts +22 -0
  56. package/dist/transaction.js +822 -0
  57. package/dist/ui.d.ts +33 -0
  58. package/dist/ui.js +199 -0
  59. package/dist/util.d.ts +1 -5
  60. package/dist/util.js +70 -159
  61. package/dist/version.d.ts +2 -0
  62. package/dist/version.js +2 -0
  63. package/dist/workspace-config.d.ts +11 -0
  64. package/dist/workspace-config.js +372 -0
  65. package/docs/conventions.md +89 -0
  66. package/docs/output-tree-contract.md +442 -0
  67. package/package.json +56 -13
  68. package/references/manual-scaffolding.md +386 -0
  69. package/templates/api-fastify/apps/api/tsconfig.json +20 -0
  70. package/templates/catalog.json +23 -0
  71. package/templates/db-drizzle/packages/db/tsconfig.json +14 -0
  72. package/templates/desktop-electron/apps/desktop/tsconfig.json +37 -0
  73. package/templates/extension-wxt/apps/extension/tsconfig.json +41 -0
  74. package/templates/mobile-expo/apps/mobile/expo-env.d.ts +1 -0
  75. package/templates/web-next/apps/web/next-env.d.ts +6 -0
  76. package/dist/branding.d.ts +0 -5
  77. package/dist/branding.js +0 -17
  78. package/dist/commands.d.ts +0 -7
  79. package/dist/commands.js +0 -24
  80. package/dist/templates/backend.d.ts +0 -2
  81. package/dist/templates/backend.js +0 -2115
  82. package/dist/templates/frontend.d.ts +0 -2
  83. package/dist/templates/frontend.js +0 -1268
@@ -1,1268 +0,0 @@
1
- import fs from 'node:fs';
2
- import path from 'node:path';
3
- import { writeAnhedralLogo } from '../branding.js';
4
- import { anhedralPrint } from '../print.js';
5
- import { appendGitignore, writeFile, exec, execExpect, liftNestedProject } from '../util.js';
6
- import { FRONTEND_ADDON_DEPENDENCIES, withVersions } from '../dependencies.js';
7
- import { resolveToolchainChannel, resolveToolchain, toolPackageRef } from '../toolchain.js';
8
- export async function scaffoldFrontend(root, { projectName, displayName, skipInstall }) {
9
- const dir = path.join(root, 'Frontend');
10
- const toolchain = resolveToolchain(resolveToolchainChannel(process.env.ANHEDRAL_TOOLCHAIN));
11
- anhedralPrint.section('Frontend (Expo)');
12
- anhedralPrint.step('Scaffolding Expo app with react-native-reusables');
13
- fs.mkdirSync(dir, { recursive: true });
14
- await execExpect(`pnpm dlx ${toolPackageRef('@react-native-reusables/cli', toolchain.reactNativeReusables)} init -t clerk-auth`, dir, [
15
- ['What is the name of your project?', projectName],
16
- ['Would you like to install dependencies?', 'n'],
17
- ['Would you like to initialize a Git repository?', 'n'],
18
- ]);
19
- liftNestedProject(dir, projectName);
20
- anhedralPrint.done('Expo app scaffolded');
21
- anhedralPrint.step('Installing frontend dependencies');
22
- patchFrontendPackageJson(dir);
23
- patchExpoAppConfig(dir);
24
- patchFrontendTsConfig(dir);
25
- if (!skipInstall) {
26
- exec('pnpm install --no-frozen-lockfile', dir);
27
- exec('pnpm exec expo install --fix --pnpm', dir);
28
- }
29
- appendGitignore(dir, ['.env', '.env.*', '!.env.example']);
30
- anhedralPrint.done(skipInstall ? 'Frontend dependency manifests written' : 'Frontend dependencies installed');
31
- writeApiClient(dir);
32
- writeConfigFile(dir);
33
- writeApiHook(dir);
34
- writeAccountHook(dir);
35
- writeThemeToggle(dir);
36
- writeAppShellFiles(dir, displayName);
37
- writeEnvExample(dir);
38
- writeEnvFile(dir);
39
- writeVercelConfig(dir);
40
- writeEasConfig(dir);
41
- writeAnhedralLogo(dir);
42
- writeSubscriptionProvider(dir);
43
- writeUseSubscription(dir);
44
- removeUnusedFrontendFiles(dir);
45
- anhedralPrint.step('Installing RevenueCat SDKs');
46
- const revenueCatDependencies = withVersions({
47
- 'react-native-purchases': FRONTEND_ADDON_DEPENDENCIES['react-native-purchases'],
48
- 'react-native-purchases-ui': FRONTEND_ADDON_DEPENDENCIES['react-native-purchases-ui'],
49
- '@revenuecat/purchases-js': FRONTEND_ADDON_DEPENDENCIES['@revenuecat/purchases-js'],
50
- });
51
- if (skipInstall) {
52
- anhedralPrint.info(`Skipping RevenueCat SDK install (--skip-install). Run after init: pnpm --filter ./Frontend add ${revenueCatDependencies.join(' ')}`);
53
- }
54
- else {
55
- exec(`pnpm add ${revenueCatDependencies.join(' ')}`, dir);
56
- }
57
- anhedralPrint.done(skipInstall ? 'RevenueCat SDK install skipped' : 'Additional Expo SDKs installed');
58
- }
59
- function patchFrontendPackageJson(dir) {
60
- const filePath = path.join(dir, 'package.json');
61
- const packageJson = JSON.parse(fs.readFileSync(filePath, 'utf8'));
62
- packageJson.dependencies = {
63
- ...(packageJson.dependencies ?? {}),
64
- '@shared/api-client': FRONTEND_ADDON_DEPENDENCIES['@shared/api-client'],
65
- '@clerk/expo': FRONTEND_ADDON_DEPENDENCIES['@clerk/expo'],
66
- 'react-native-purchases': FRONTEND_ADDON_DEPENDENCIES['react-native-purchases'],
67
- 'react-native-purchases-ui': FRONTEND_ADDON_DEPENDENCIES['react-native-purchases-ui'],
68
- '@revenuecat/purchases-js': FRONTEND_ADDON_DEPENDENCIES['@revenuecat/purchases-js'],
69
- };
70
- delete packageJson.dependencies['@rn-primitives/avatar'];
71
- delete packageJson.dependencies['expo-image-picker'];
72
- packageJson.scripts = {
73
- ...(packageJson.scripts ?? {}),
74
- build: 'pnpm typecheck && pnpm build:web',
75
- 'build:web': 'expo export --platform web',
76
- typecheck: 'tsc --noEmit',
77
- };
78
- writeFile(filePath, JSON.stringify(packageJson, null, 2) + '\n');
79
- }
80
- function removeUnusedFrontendFiles(dir) {
81
- const avatarComponent = path.join(dir, 'components/ui/avatar.tsx');
82
- if (fs.existsSync(avatarComponent)) {
83
- fs.rmSync(avatarComponent);
84
- }
85
- }
86
- function patchFrontendTsConfig(dir) {
87
- const filePath = path.join(dir, 'tsconfig.json');
88
- if (!fs.existsSync(filePath)) {
89
- return;
90
- }
91
- const tsConfig = JSON.parse(fs.readFileSync(filePath, 'utf8'));
92
- if (tsConfig.extends === 'expo/tsconfig.base') {
93
- tsConfig.extends = 'expo/tsconfig.base.json';
94
- }
95
- writeFile(filePath, JSON.stringify(tsConfig, null, 2) + '\n');
96
- }
97
- function patchExpoAppConfig(dir) {
98
- const filePath = path.join(dir, 'app.json');
99
- if (!fs.existsSync(filePath)) {
100
- return;
101
- }
102
- const appJson = JSON.parse(fs.readFileSync(filePath, 'utf8'));
103
- appJson.expo ??= {};
104
- appJson.expo.web = {
105
- ...(appJson.expo.web ?? {}),
106
- bundler: 'metro',
107
- output: 'static',
108
- };
109
- appJson.expo.plugins = Array.from(new Set([
110
- ...(Array.isArray(appJson.expo.plugins) ? appJson.expo.plugins : []),
111
- '@clerk/expo',
112
- ]));
113
- writeFile(filePath, JSON.stringify(appJson, null, 2) + '\n');
114
- }
115
- function writeApiClient(dir) {
116
- writeFile(path.join(dir, 'api/client.ts'), `import { ApiClient, APIRequestError } from '@shared/api-client';
117
-
118
- export class APIClient extends ApiClient {
119
- constructor(baseUrl: string, getToken: () => Promise<string | null>) {
120
- super({ baseUrl, getToken, platform: 'frontend' });
121
- }
122
- }
123
-
124
- export { APIRequestError };
125
- `);
126
- writeFile(path.join(dir, 'api/index.ts'), `export { APIClient, APIRequestError } from './client';
127
- `);
128
- }
129
- function writeConfigFile(dir) {
130
- writeFile(path.join(dir, 'lib/config.ts'), `import { Platform } from 'react-native';
131
-
132
- type EnvKey =
133
- | 'EXPO_PUBLIC_API_URL'
134
- | 'EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY'
135
- | 'EXPO_PUBLIC_RC_ENTITLEMENT_ID'
136
- | 'EXPO_PUBLIC_RC_API_KEY_IOS'
137
- | 'EXPO_PUBLIC_RC_API_KEY_ANDROID'
138
- | 'EXPO_PUBLIC_RC_WEB_API_KEY';
139
-
140
- const envValue = (key: EnvKey): string | undefined => {
141
- switch (key) {
142
- case 'EXPO_PUBLIC_API_URL': return process.env.EXPO_PUBLIC_API_URL;
143
- case 'EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY': return process.env.EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY;
144
- case 'EXPO_PUBLIC_RC_ENTITLEMENT_ID': return process.env.EXPO_PUBLIC_RC_ENTITLEMENT_ID;
145
- case 'EXPO_PUBLIC_RC_API_KEY_IOS': return process.env.EXPO_PUBLIC_RC_API_KEY_IOS;
146
- case 'EXPO_PUBLIC_RC_API_KEY_ANDROID': return process.env.EXPO_PUBLIC_RC_API_KEY_ANDROID;
147
- case 'EXPO_PUBLIC_RC_WEB_API_KEY': return process.env.EXPO_PUBLIC_RC_WEB_API_KEY;
148
- default: return undefined;
149
- }
150
- };
151
-
152
- const requireEnv = (key: EnvKey): string => {
153
- const value = envValue(key);
154
- if (!value) throw new Error(\`Missing \${key}. Set it in your environment variables.\`);
155
- return value;
156
- };
157
-
158
- export const apiBaseUrl = requireEnv('EXPO_PUBLIC_API_URL');
159
- export const clerkPublishableKey = requireEnv('EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY');
160
-
161
- export type SubscriptionConfig = {
162
- entitlementId: string;
163
- iosApiKey: string;
164
- androidApiKey: string;
165
- webApiKey: string;
166
- };
167
-
168
- export const subscriptionConfig: SubscriptionConfig = {
169
- entitlementId: requireEnv('EXPO_PUBLIC_RC_ENTITLEMENT_ID'),
170
- iosApiKey: requireEnv('EXPO_PUBLIC_RC_API_KEY_IOS'),
171
- androidApiKey: requireEnv('EXPO_PUBLIC_RC_API_KEY_ANDROID'),
172
- webApiKey: requireEnv('EXPO_PUBLIC_RC_WEB_API_KEY'),
173
- };
174
-
175
- export function getPlatformRevenueCatApiKey(): string {
176
- if (Platform.OS === 'ios') return subscriptionConfig.iosApiKey;
177
- if (Platform.OS === 'android') return subscriptionConfig.androidApiKey;
178
- if (Platform.OS === 'web') return subscriptionConfig.webApiKey;
179
- throw new Error(\`Unsupported platform: \${Platform.OS}\`);
180
- }
181
- `);
182
- }
183
- function writeApiHook(dir) {
184
- writeFile(path.join(dir, 'hooks/useAPI.ts'), `import { useAuth } from '@clerk/expo';
185
- import { useMemo } from 'react';
186
- import { APIClient } from '@/api/client';
187
- import { apiBaseUrl } from '@/lib/config';
188
-
189
- export function useAPI() {
190
- const { getToken } = useAuth();
191
- return useMemo(() => new APIClient(apiBaseUrl, getToken), [getToken]);
192
- }
193
- `);
194
- }
195
- function writeVercelConfig(dir) {
196
- writeFile(path.join(dir, 'vercel.json'), JSON.stringify({
197
- $schema: 'https://openapi.vercel.sh/vercel.json',
198
- buildCommand: 'pnpm build:web',
199
- outputDirectory: 'dist',
200
- devCommand: 'pnpm dev',
201
- cleanUrls: true,
202
- framework: null,
203
- rewrites: [
204
- {
205
- source: '/:path*',
206
- destination: '/',
207
- },
208
- ],
209
- }, null, 2) + '\n');
210
- }
211
- function writeEasConfig(dir) {
212
- writeFile(path.join(dir, 'eas.json'), JSON.stringify({
213
- cli: {
214
- version: '>= 16.24.0',
215
- appVersionSource: 'remote',
216
- },
217
- build: {
218
- development: {
219
- developmentClient: true,
220
- distribution: 'internal',
221
- android: {
222
- buildType: 'apk',
223
- },
224
- },
225
- preview: {
226
- distribution: 'internal',
227
- },
228
- production: {
229
- autoIncrement: true,
230
- },
231
- },
232
- submit: {
233
- production: {},
234
- },
235
- }, null, 2) + '\n');
236
- }
237
- function writeAccountHook(dir) {
238
- writeFile(path.join(dir, 'hooks/useAccount.ts'), `import { useAuth } from '@clerk/expo';
239
- import * as React from 'react';
240
- import { useAPI } from '@/hooks/useAPI';
241
-
242
- export type AccountSummary = {
243
- id: string;
244
- email: string;
245
- firstName?: string | null;
246
- lastName?: string | null;
247
- displayName: string;
248
- imageUrl?: string | null;
249
- };
250
-
251
- export function useAccount() {
252
- const { isLoaded, isSignedIn } = useAuth();
253
- const api = useAPI();
254
- const [account, setAccount] = React.useState<AccountSummary | null>(null);
255
- const [loading, setLoading] = React.useState(true);
256
- const [error, setError] = React.useState<string | null>(null);
257
-
258
- const refresh = React.useCallback(async () => {
259
- if (!isLoaded || !isSignedIn) {
260
- setAccount(null);
261
- setLoading(false);
262
- return null;
263
- }
264
-
265
- setLoading(true);
266
- setError(null);
267
-
268
- try {
269
- const response = await api.getMe();
270
- setAccount(response.user);
271
- return response.user;
272
- } catch (err) {
273
- setError(err instanceof Error ? err.message : 'Failed to load account');
274
- return null;
275
- } finally {
276
- setLoading(false);
277
- }
278
- }, [api, isLoaded, isSignedIn]);
279
-
280
- React.useEffect(() => {
281
- void refresh();
282
- }, [refresh]);
283
-
284
- return {
285
- account,
286
- loading,
287
- error,
288
- refresh,
289
- };
290
- }
291
- `);
292
- }
293
- function writeThemeToggle(dir) {
294
- writeFile(path.join(dir, 'components/theme-toggle.tsx'), `import { Button } from '@/components/ui/button';
295
- import { Icon } from '@/components/ui/icon';
296
- import { MoonStarIcon, SunIcon } from 'lucide-react-native';
297
- import { useColorScheme } from 'nativewind';
298
-
299
- const THEME_ICONS = {
300
- light: SunIcon,
301
- dark: MoonStarIcon,
302
- };
303
-
304
- export function ThemeToggle() {
305
- const { colorScheme, toggleColorScheme } = useColorScheme();
306
-
307
- return (
308
- <Button onPress={toggleColorScheme} size="icon" variant="ghost" className="rounded-full">
309
- <Icon as={THEME_ICONS[colorScheme ?? 'light']} className="size-6" />
310
- </Button>
311
- );
312
- }
313
- `);
314
- }
315
- function writeSubscriptionProvider(dir) {
316
- writeFile(path.join(dir, 'contexts/SubscriptionProvider.tsx'), `import * as React from 'react';
317
- import Purchases, { type CustomerInfo, type PurchasesOfferings } from 'react-native-purchases';
318
- import { Purchases as PurchasesWeb } from '@revenuecat/purchases-js';
319
- import { AppState, Platform } from 'react-native';
320
- import { subscriptionConfig, getPlatformRevenueCatApiKey } from '@/lib/config';
321
- import { useAuth, useUser } from '@clerk/expo';
322
- import { useAPI } from '@/hooks/useAPI';
323
- import RevenueCatUI, { PAYWALL_RESULT } from 'react-native-purchases-ui';
324
-
325
- export type SubscriptionState = {
326
- status: 'idle' | 'loading' | 'active' | 'inactive' | 'error';
327
- entitlementActive: boolean;
328
- inTrial: boolean;
329
- trialEndsAt?: string | null;
330
- expiresAt?: string | null;
331
- periodStart?: string | null;
332
- periodEnd?: string | null;
333
- method?: 'trialing' | 'redeemed' | 'paid' | null;
334
- managementUrl?: string | null;
335
- cancelAtPeriodEnd?: boolean;
336
- offerings?: PurchasesOfferings | null;
337
- lastError?: Error | null;
338
- pricing?: { monthly: number; yearly: number } | null;
339
- };
340
-
341
- interface SubscriptionContextValue extends SubscriptionState {
342
- refresh: () => Promise<void>;
343
- openPaywall: (plan?: 'monthly' | 'yearly') => Promise<void>;
344
- }
345
-
346
- const SubscriptionContext = React.createContext<SubscriptionContextValue | undefined>(undefined);
347
-
348
- export function SubscriptionProvider({ children }: { children: React.ReactNode }) {
349
- const { userId } = useAuth();
350
- const { user } = useUser();
351
- const api = useAPI();
352
-
353
- const [state, setState] = React.useState<SubscriptionState>({
354
- status: 'idle', entitlementActive: false, inTrial: false,
355
- periodStart: null, periodEnd: null, method: null,
356
- });
357
-
358
- const configuredRef = React.useRef<string | null>(null);
359
- const apiRef = React.useRef(api);
360
- React.useEffect(() => { apiRef.current = api; }, [api]);
361
- const userRef = React.useRef(user);
362
- React.useEffect(() => { userRef.current = user; }, [user]);
363
- const expiryTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
364
- const refreshIntervalRef = React.useRef<ReturnType<typeof setInterval> | null>(null);
365
- const inflightRef = React.useRef<Promise<unknown> | null>(null);
366
- const didSkipInitialVisibilityRef = React.useRef(false);
367
-
368
- const sleep = React.useCallback((ms: number) => new Promise<void>((r) => setTimeout(r, ms)), []);
369
-
370
- const loadEntitlements = React.useCallback(async (options?: { refresh?: boolean }) => {
371
- const forced = Boolean(options?.refresh);
372
- if (!forced && inflightRef.current) {
373
- try { return (await inflightRef.current) as Awaited<ReturnType<typeof apiRef.current.getSubscriptionEntitlements>>; } catch { return null; }
374
- }
375
- try {
376
- const p = apiRef.current.getSubscriptionEntitlements(options);
377
- if (!forced) inflightRef.current = p;
378
- const e = await p;
379
- setState(prev => ({
380
- ...prev,
381
- status: e.pro ? 'active' : 'inactive',
382
- entitlementActive: e.pro,
383
- inTrial: e.inTrial ?? false,
384
- trialEndsAt: e.trialEndsAt ?? null,
385
- expiresAt: e.expiresAt ?? null,
386
- periodStart: e.periodStart ?? null,
387
- periodEnd: e.periodEnd ?? null,
388
- method: e.pro ? (e.method ?? prev.method ?? null) : null,
389
- managementUrl: e.pro ? (e.managementUrl ?? prev.managementUrl ?? null) : null,
390
- cancelAtPeriodEnd: e.cancelAtPeriodEnd ?? (e.pro ? (prev.cancelAtPeriodEnd ?? false) : false),
391
- }));
392
- return e;
393
- } catch {
394
- setState(prev => ({ ...prev, status: prev.status === 'loading' ? 'inactive' : prev.status }));
395
- return null;
396
- } finally {
397
- if (!forced) inflightRef.current = null;
398
- }
399
- }, []);
400
-
401
- const loadPricingAndOfferings = React.useCallback(async () => {
402
- try {
403
- const [pricingRes, offerings] = await Promise.all([
404
- apiRef.current.getSubscriptionPricing(),
405
- Platform.OS === 'web'
406
- ? PurchasesWeb.getSharedInstance().getOfferings()
407
- : Purchases.getOfferings(),
408
- ]);
409
- const pro = pricingRes.tiers.find(t => t.tier === 'pro');
410
- setState(prev => ({
411
- ...prev,
412
- pricing: pro?.priceMonthly != null && pro?.priceYearly != null
413
- ? { monthly: pro.priceMonthly, yearly: pro.priceYearly } : prev.pricing,
414
- offerings: offerings as PurchasesOfferings,
415
- }));
416
- } catch (e) { console.error('[Subscription] Failed to load pricing/offerings:', e); }
417
- }, []);
418
-
419
- const refresh = React.useCallback(async () => {
420
- try {
421
- if (Platform.OS === 'web') {
422
- await PurchasesWeb.getSharedInstance().getCustomerInfo();
423
- } else {
424
- await Purchases.getCustomerInfo();
425
- }
426
- await loadPricingAndOfferings();
427
- for (let i = 0; i < 4; i++) {
428
- const e = await loadEntitlements({ refresh: true });
429
- if (e?.pro) break;
430
- if (i < 3) await sleep(1500);
431
- }
432
- } catch (e) { console.error('[Subscription] Refresh error:', e); }
433
- }, [loadEntitlements, loadPricingAndOfferings, sleep]);
434
-
435
- const openPaywall = React.useCallback(async (plan?: 'monthly' | 'yearly') => {
436
- try {
437
- const current = await apiRef.current.getSubscriptionEntitlements({ refresh: true });
438
- if (current.pro && (current.method === 'paid' || current.method === 'redeemed')) {
439
- setState(prev => ({ ...prev, status: 'active', entitlementActive: true, inTrial: false, expiresAt: current.expiresAt ?? prev.expiresAt ?? null, method: current.method ?? prev.method ?? null }));
440
- try { await userRef.current?.reload(); } catch {}
441
- return;
442
- }
443
- setState(prev => ({ ...prev, status: 'loading', lastError: null }));
444
-
445
- if (Platform.OS === 'ios' || Platform.OS === 'android') {
446
- const result = await RevenueCatUI.presentPaywallIfNeeded({ requiredEntitlementIdentifier: subscriptionConfig.entitlementId });
447
- if (result === PAYWALL_RESULT.PURCHASED || result === PAYWALL_RESULT.RESTORED || result === PAYWALL_RESULT.NOT_PRESENTED) {
448
- setState(prev => ({ ...prev, status: 'active', entitlementActive: true, inTrial: prev.inTrial ?? false }));
449
- await refresh();
450
- try { await userRef.current?.reload(); } catch {}
451
- } else {
452
- setState(prev => ({ ...prev, status: prev.entitlementActive ? 'active' : 'inactive' }));
453
- }
454
- } else {
455
- const offerings = await PurchasesWeb.getSharedInstance().getOfferings();
456
- const cur = offerings.current;
457
- if (!cur?.availablePackages.length) throw new Error('No offerings');
458
- const pkg = plan === 'monthly'
459
- ? (cur.monthly ?? cur.availablePackages.find(p => p.identifier.includes('month')))
460
- : plan === 'yearly'
461
- ? (cur.annual ?? cur.availablePackages.find(p => p.identifier.includes('year')))
462
- : cur.availablePackages[0];
463
- if (pkg) {
464
- try {
465
- await PurchasesWeb.getSharedInstance().purchase({ rcPackage: pkg });
466
- } catch (purchaseErr) {
467
- const pe = purchaseErr as { errorCode?: unknown; message?: unknown };
468
- const code = typeof pe?.errorCode === 'number' ? pe.errorCode : null;
469
- if (code === 6 || code === 7) {
470
- setState(prev => ({ ...prev, status: 'loading', lastError: null }));
471
- await refresh();
472
- try { await userRef.current?.reload(); } catch {}
473
- return;
474
- }
475
- throw purchaseErr;
476
- }
477
- setState(prev => ({ ...prev, status: 'active', entitlementActive: true, inTrial: prev.inTrial ?? false }));
478
- await refresh();
479
- try { await userRef.current?.reload(); } catch {}
480
- }
481
- }
482
- } catch (error) {
483
- const err = error as Error & { userCancelled?: boolean | null; code?: unknown };
484
- const code = typeof err.code === 'string' ? err.code : typeof err.code === 'number' ? String(err.code) : null;
485
- const cancelled = Boolean(err.userCancelled) || code === 'USER_CANCELLED';
486
- if (cancelled) {
487
- setState(prev => ({ ...prev, status: prev.entitlementActive ? 'active' : 'inactive' }));
488
- } else {
489
- setState(prev => ({ ...prev, status: 'error', lastError: error as Error }));
490
- }
491
- }
492
- }, [loadEntitlements, refresh]);
493
-
494
- // Auto-expire timer
495
- React.useEffect(() => {
496
- if (expiryTimerRef.current) { clearTimeout(expiryTimerRef.current); expiryTimerRef.current = null; }
497
- if (!userId) return;
498
- const until = (state.method === 'trialing' ? (state.trialEndsAt ?? state.periodEnd) : (state.expiresAt ?? state.periodEnd)) ?? null;
499
- if (!until) return;
500
- const untilMs = new Date(until).getTime();
501
- if (!Number.isFinite(untilMs)) return;
502
- const delay = Math.min(Math.max(untilMs - Date.now() + 1500, 0), 2147483647);
503
- if (delay <= 0) { if (state.entitlementActive) void loadEntitlements({ refresh: true }); return; }
504
- expiryTimerRef.current = setTimeout(() => {
505
- let shouldRefresh = false;
506
- setState(prev => {
507
- const prevUntil = (prev.method === 'trialing' ? (prev.trialEndsAt ?? prev.periodEnd) : (prev.expiresAt ?? prev.periodEnd)) ?? null;
508
- const prevMs = prevUntil ? new Date(prevUntil).getTime() : null;
509
- if (!prevMs || !Number.isFinite(prevMs) || prevMs > Date.now()) return prev;
510
- if (!prev.entitlementActive) return prev;
511
- shouldRefresh = true;
512
- if (prev.method === 'paid' || prev.method === 'redeemed') return prev;
513
- return { ...prev, status: 'inactive', entitlementActive: false, inTrial: false };
514
- });
515
- if (shouldRefresh) void loadEntitlements({ refresh: true });
516
- }, delay);
517
- return () => { if (expiryTimerRef.current) { clearTimeout(expiryTimerRef.current); expiryTimerRef.current = null; } };
518
- }, [loadEntitlements, state.entitlementActive, state.expiresAt, state.method, state.periodEnd, state.trialEndsAt, userId]);
519
-
520
- // Periodic refresh
521
- React.useEffect(() => {
522
- if (refreshIntervalRef.current) { clearInterval(refreshIntervalRef.current); refreshIntervalRef.current = null; }
523
- if (!userId || !state.entitlementActive || state.status === 'loading') return;
524
- const until = (state.method === 'trialing' ? (state.trialEndsAt ?? state.periodEnd) : (state.expiresAt ?? state.periodEnd)) ?? null;
525
- const untilMs = until ? new Date(until).getTime() : null;
526
- const remainingMs = untilMs != null && Number.isFinite(untilMs) ? (untilMs - Date.now()) : null;
527
- const intervalMs = remainingMs != null
528
- ? (remainingMs <= 5 * 60_000 ? 60_000 : remainingMs <= 60 * 60_000 ? 5 * 60_000 : 15 * 60_000)
529
- : 15 * 60_000;
530
- refreshIntervalRef.current = setInterval(() => { void loadEntitlements({ refresh: false }); }, intervalMs);
531
- return () => { if (refreshIntervalRef.current) { clearInterval(refreshIntervalRef.current); refreshIntervalRef.current = null; } };
532
- }, [loadEntitlements, state.entitlementActive, state.expiresAt, state.method, state.periodEnd, state.status, state.trialEndsAt, userId]);
533
-
534
- // Initialize RevenueCat SDK
535
- React.useEffect(() => {
536
- const key = getPlatformRevenueCatApiKey();
537
- if (!key || !userId || configuredRef.current === userId) return;
538
- configuredRef.current = userId;
539
- if (Platform.OS === 'web') {
540
- PurchasesWeb.configure({ apiKey: key, appUserId: userId });
541
- } else {
542
- Purchases.configure({ apiKey: key, appUserID: userId });
543
- }
544
- setState(prev => ({ ...prev, status: 'loading' }));
545
- void Promise.all([loadPricingAndOfferings(), loadEntitlements({ refresh: true })]);
546
-
547
- if (Platform.OS === 'web') {
548
- didSkipInitialVisibilityRef.current = false;
549
- const handleVisibility = () => {
550
- if (!didSkipInitialVisibilityRef.current) { didSkipInitialVisibilityRef.current = true; return; }
551
- if (document.visibilityState === 'visible') void loadEntitlements({ refresh: false });
552
- };
553
- document.addEventListener('visibilitychange', handleVisibility);
554
- return () => document.removeEventListener('visibilitychange', handleVisibility);
555
- } else {
556
- const listener = (_: CustomerInfo) => { void loadEntitlements({ refresh: false }); };
557
- Purchases.addCustomerInfoUpdateListener(listener);
558
- const appSub = AppState.addEventListener('change', s => { if (s === 'active') void loadEntitlements({ refresh: false }); });
559
- return () => { Purchases.removeCustomerInfoUpdateListener(listener); appSub.remove(); };
560
- }
561
- }, [userId, loadPricingAndOfferings, loadEntitlements]);
562
-
563
- const value = React.useMemo(() => ({ ...state, refresh, openPaywall }), [state, refresh, openPaywall]);
564
-
565
- return <SubscriptionContext.Provider value={value}>{children}</SubscriptionContext.Provider>;
566
- }
567
-
568
- export function useSubscriptionContext() {
569
- const ctx = React.useContext(SubscriptionContext);
570
- if (!ctx) throw new Error('useSubscriptionContext must be used within SubscriptionProvider');
571
- return ctx;
572
- }
573
- `);
574
- }
575
- function writeUseSubscription(dir) {
576
- writeFile(path.join(dir, 'hooks/useSubscription.ts'), `import { useCallback } from 'react';
577
- import { Linking, Platform } from 'react-native';
578
- import { useSubscriptionContext } from '@/contexts/SubscriptionProvider';
579
- import { useAPI } from '@/hooks/useAPI';
580
-
581
- export function useSubscription() {
582
- const ctx = useSubscriptionContext();
583
- const api = useAPI();
584
-
585
- const accessUntilMs = (() => {
586
- const until = (ctx.method === 'trialing' ? (ctx.trialEndsAt ?? ctx.periodEnd) : (ctx.expiresAt ?? ctx.periodEnd)) ?? null;
587
- if (!until) return null;
588
- const t = new Date(until).getTime();
589
- return Number.isFinite(t) ? t : null;
590
- })();
591
-
592
- const canAccess = ctx.entitlementActive && (
593
- ctx.method === 'paid' || ctx.method === 'redeemed' || accessUntilMs == null || accessUntilMs > Date.now() + 1500
594
- );
595
- const isLoading = ctx.status === 'idle' || ctx.status === 'loading';
596
- const isPaid = ctx.method === 'paid';
597
- const isRedeemed = ctx.method === 'redeemed';
598
- const isTrial = ctx.method === 'trialing' && ctx.inTrial;
599
- const trialDaysRemaining = ctx.trialEndsAt
600
- ? Math.max(0, Math.ceil((new Date(ctx.trialEndsAt).getTime() - Date.now()) / (1000 * 60 * 60 * 24)))
601
- : 0;
602
-
603
- const managementUrl = (() => {
604
- if (ctx.managementUrl) return ctx.managementUrl;
605
- if (!ctx.entitlementActive) return null;
606
- if (Platform.OS === 'ios') return 'https://apps.apple.com/account/subscriptions';
607
- if (Platform.OS === 'android') return 'https://play.google.com/store/account/subscriptions';
608
- return null;
609
- })();
610
-
611
- const subscribe = useCallback(async (plan?: 'monthly' | 'yearly') => {
612
- await ctx.openPaywall(plan);
613
- }, [ctx]);
614
-
615
- const manageSubscription = useCallback(async () => {
616
- let url: string | null = managementUrl ?? null;
617
- if (!url && Platform.OS === 'web' && ctx.entitlementActive) {
618
- try { const r = await api.getSubscriptionEntitlements({ refresh: true }); url = r.managementUrl ?? null; } catch {}
619
- }
620
- if (!url) return;
621
- if (Platform.OS === 'web') { window.open(url, '_blank'); return; }
622
- await Linking.openURL(url);
623
- }, [api, ctx.entitlementActive, managementUrl]);
624
-
625
- return {
626
- status: ctx.status, canAccess, isLoading, isPaid, isRedeemed, isTrial,
627
- inTrial: ctx.inTrial, method: ctx.method, trialEndsAt: ctx.trialEndsAt,
628
- trialDaysRemaining, expiresAt: ctx.expiresAt, periodStart: ctx.periodStart,
629
- periodEnd: ctx.periodEnd, managementUrl, cancelAtPeriodEnd: ctx.cancelAtPeriodEnd,
630
- pricing: ctx.pricing, offerings: ctx.offerings, lastError: ctx.lastError,
631
- subscribe, refresh: ctx.refresh, manageSubscription,
632
- };
633
- }
634
-
635
- export type { SubscriptionState } from '@/contexts/SubscriptionProvider';
636
- `);
637
- }
638
- function writeAppShellFiles(dir, displayName) {
639
- writeFile(path.join(dir, 'app/_layout.tsx'), `import '@/global.css';
640
-
641
- import { SubscriptionProvider } from '@/contexts/SubscriptionProvider';
642
- import { clerkPublishableKey } from '@/lib/config';
643
- import { NAV_THEME } from '@/lib/theme';
644
- import { ClerkProvider, useAuth } from '@clerk/expo';
645
- import { tokenCache } from '@clerk/expo/token-cache';
646
- import { ThemeProvider } from '@react-navigation/native';
647
- import { PortalHost } from '@rn-primitives/portal';
648
- import { Stack } from 'expo-router';
649
- import * as SplashScreen from 'expo-splash-screen';
650
- import { StatusBar } from 'expo-status-bar';
651
- import { useColorScheme } from 'nativewind';
652
- import * as React from 'react';
653
-
654
- export { ErrorBoundary } from 'expo-router';
655
-
656
- export default function RootLayout() {
657
- const { colorScheme } = useColorScheme();
658
-
659
- return (
660
- <ClerkProvider publishableKey={clerkPublishableKey} tokenCache={tokenCache}>
661
- <ThemeProvider value={NAV_THEME[colorScheme ?? 'light']}>
662
- <SubscriptionProvider>
663
- <StatusBar style={colorScheme === 'dark' ? 'light' : 'dark'} />
664
- <Routes />
665
- <PortalHost />
666
- </SubscriptionProvider>
667
- </ThemeProvider>
668
- </ClerkProvider>
669
- );
670
- }
671
-
672
- SplashScreen.preventAutoHideAsync();
673
-
674
- function Routes() {
675
- const { isSignedIn, isLoaded } = useAuth();
676
-
677
- React.useEffect(() => {
678
- if (isLoaded) {
679
- SplashScreen.hideAsync();
680
- }
681
- }, [isLoaded]);
682
-
683
- if (!isLoaded) {
684
- return null;
685
- }
686
-
687
- return (
688
- <Stack>
689
- <Stack.Screen name="index" options={{ headerShown: false }} />
690
-
691
- <Stack.Protected guard={!isSignedIn}>
692
- <Stack.Screen name="(auth)/sign-in" options={{ headerShown: false, title: 'Sign in' }} />
693
- <Stack.Screen name="(auth)/sign-up" options={{ presentation: 'modal', title: '', headerTransparent: true, gestureEnabled: false }} />
694
- <Stack.Screen name="(auth)/reset-password" options={{ title: '', headerShadowVisible: false, headerTransparent: true }} />
695
- <Stack.Screen name="(auth)/forgot-password" options={{ title: '', headerShadowVisible: false, headerTransparent: true }} />
696
- </Stack.Protected>
697
-
698
- <Stack.Protected guard={isSignedIn}>
699
- <Stack.Screen name="(app)/system" options={{ headerShown: false }} />
700
- </Stack.Protected>
701
- </Stack>
702
- );
703
- }
704
- `);
705
- writeFile(path.join(dir, 'app/index.tsx'), `import { ThemeToggle } from '@/components/theme-toggle';
706
- import { Button } from '@/components/ui/button';
707
- import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
708
- import { Text } from '@/components/ui/text';
709
- import { useAuth } from '@clerk/expo';
710
- import { Link } from 'expo-router';
711
- import { ScrollView, View } from 'react-native';
712
-
713
- export default function HomeScreen() {
714
- const { isSignedIn } = useAuth();
715
-
716
- return (
717
- <ScrollView className="flex-1 bg-background" contentContainerClassName="min-h-full px-4 pb-10 pt-6 sm:px-6">
718
- <View className="mx-auto w-full max-w-3xl gap-4">
719
- <View className="flex-row items-center justify-between border-b border-border py-3">
720
- <Text className="text-lg font-semibold">${displayName}</Text>
721
- <ThemeToggle />
722
- </View>
723
-
724
- <Card className="border-border bg-card">
725
- <CardHeader>
726
- <CardTitle className="text-left text-2xl">Application foundation ready</CardTitle>
727
- </CardHeader>
728
- <CardContent className="gap-4">
729
- <Text className="text-muted-foreground">
730
- Configure providers, then use the protected area to verify auth, API, subscription, database, and storage wiring.
731
- </Text>
732
- <View className="flex-row flex-wrap gap-2">
733
- {isSignedIn ? (
734
- <Link href="/system" asChild>
735
- <Button>
736
- <Text>Open app</Text>
737
- </Button>
738
- </Link>
739
- ) : (
740
- <>
741
- <Link href="/(auth)/sign-in" asChild>
742
- <Button>
743
- <Text>Sign in</Text>
744
- </Button>
745
- </Link>
746
- <Link href="/(auth)/sign-up" asChild>
747
- <Button variant="outline">
748
- <Text>Create account</Text>
749
- </Button>
750
- </Link>
751
- </>
752
- )}
753
- </View>
754
- </CardContent>
755
- </Card>
756
- </View>
757
- </ScrollView>
758
- );
759
- }
760
- `);
761
- writeFile(path.join(dir, 'app/(app)/system.tsx'), `import { ThemeToggle } from '@/components/theme-toggle';
762
- import { UserMenu } from '@/components/user-menu';
763
- import { Button } from '@/components/ui/button';
764
- import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
765
- import { Text } from '@/components/ui/text';
766
- import { useAccount } from '@/hooks/useAccount';
767
- import { useSubscription } from '@/hooks/useSubscription';
768
- import { Stack } from 'expo-router';
769
- import { CheckCircleIcon, CircleAlertIcon, CreditCardIcon, DatabaseIcon } from 'lucide-react-native';
770
- import * as React from 'react';
771
- import { RefreshControl, ScrollView, View } from 'react-native';
772
-
773
- export default function DashboardScreen() {
774
- const { account, loading, error, refresh } = useAccount();
775
- const subscription = useSubscription();
776
-
777
- const subscriptionLabel = React.useMemo(() => {
778
- if (subscription.isPaid) return 'Paid';
779
- if (subscription.isTrial) return \`Trial, \${subscription.trialDaysRemaining} days left\`;
780
- if (subscription.isRedeemed) return 'Redeemed';
781
- if (subscription.canAccess) return 'Active';
782
- return 'Inactive';
783
- }, [subscription.canAccess, subscription.isPaid, subscription.isRedeemed, subscription.isTrial, subscription.trialDaysRemaining]);
784
-
785
- const runSubscriptionAction = React.useCallback(async () => {
786
- if (subscription.managementUrl) {
787
- await subscription.manageSubscription();
788
- return;
789
- }
790
-
791
- await subscription.subscribe('monthly');
792
- }, [subscription]);
793
-
794
- return (
795
- <>
796
- <Stack.Screen
797
- options={{
798
- header: () => (
799
- <View className="top-safe flex-row items-center justify-between bg-background px-4 py-3">
800
- <ThemeToggle />
801
- <UserMenu />
802
- </View>
803
- ),
804
- }}
805
- />
806
-
807
- <ScrollView
808
- className="flex-1 bg-background"
809
- contentContainerClassName="px-4 pb-10 pt-4 sm:px-6"
810
- refreshControl={<RefreshControl refreshing={loading} onRefresh={() => void refresh()} />}>
811
- <View className="mx-auto w-full max-w-3xl gap-4">
812
- <Card className="border-border bg-card">
813
- <CardHeader>
814
- <CardTitle className="text-left text-2xl">System status</CardTitle>
815
- </CardHeader>
816
- <CardContent className="flex-row flex-wrap gap-2">
817
- <Button onPress={() => void runSubscriptionAction()}>
818
- <Text>{subscription.managementUrl ? 'Manage subscription' : 'Open paywall'}</Text>
819
- </Button>
820
- <Button variant="outline" onPress={() => void refresh()}>
821
- <Text>Refresh</Text>
822
- </Button>
823
- </CardContent>
824
- </Card>
825
-
826
- <View className="gap-3">
827
- <Card className="border-border bg-card">
828
- <CardHeader>
829
- <View className="flex-row items-center gap-2">
830
- {account ? <CheckCircleIcon size={18} color="currentColor" /> : <CircleAlertIcon size={18} color="currentColor" />}
831
- <CardTitle className="text-left text-lg">Authenticated API</CardTitle>
832
- </View>
833
- </CardHeader>
834
- <CardContent className="gap-1">
835
- <Text className="text-sm text-muted-foreground">{account ? 'Connected' : loading ? 'Loading' : 'Unavailable'}</Text>
836
- {account ? <Text>{account.email}</Text> : null}
837
- </CardContent>
838
- </Card>
839
-
840
- <Card className="border-border bg-card">
841
- <CardHeader>
842
- <View className="flex-row items-center gap-2">
843
- <CreditCardIcon size={18} color="currentColor" />
844
- <CardTitle className="text-left text-lg">Subscription entitlement</CardTitle>
845
- </View>
846
- </CardHeader>
847
- <CardContent className="gap-1">
848
- <Text>{subscriptionLabel}</Text>
849
- {subscription.expiresAt ? <Text className="text-sm text-muted-foreground">Expires {new Date(subscription.expiresAt).toLocaleDateString()}</Text> : null}
850
- {subscription.cancelAtPeriodEnd ? <Text className="text-sm text-muted-foreground">Cancels at period end</Text> : null}
851
- </CardContent>
852
- </Card>
853
-
854
- <Card className="border-border bg-card">
855
- <CardHeader>
856
- <View className="flex-row items-center gap-2">
857
- <DatabaseIcon size={18} color="currentColor" />
858
- <CardTitle className="text-left text-lg">Database record</CardTitle>
859
- </View>
860
- </CardHeader>
861
- <CardContent className="gap-1">
862
- <Text>{account?.id ?? 'Not loaded'}</Text>
863
- <Text className="text-sm text-muted-foreground">Use this route as the starting point for your product data.</Text>
864
- </CardContent>
865
- </Card>
866
- </View>
867
-
868
- {error ? (
869
- <Card className="rounded-[24px] border-amber-300/60 bg-amber-50 dark:bg-amber-500/10">
870
- <CardContent className="px-5 py-5">
871
- <Text className="text-sm leading-6 text-amber-900 dark:text-amber-100">
872
- API error: {error}
873
- </Text>
874
- </CardContent>
875
- </Card>
876
- ) : null}
877
- </View>
878
- </ScrollView>
879
- </>
880
- );
881
- }
882
- `);
883
- writeFile(path.join(dir, 'app/(auth)/sign-in.tsx'), `import { SignInForm } from '@/components/sign-in-form';
884
- import { ThemeToggle } from '@/components/theme-toggle';
885
- import { Text } from '@/components/ui/text';
886
- import { Link } from 'expo-router';
887
- import { ScrollView, View } from 'react-native';
888
-
889
- export default function SignInScreen() {
890
- return (
891
- <ScrollView
892
- keyboardShouldPersistTaps="handled"
893
- contentContainerClassName="min-h-full bg-background px-4 pb-10 pt-6 sm:px-6"
894
- keyboardDismissMode="interactive">
895
- <View className="mx-auto w-full max-w-5xl gap-6">
896
- <View className="flex-row items-center justify-between border-b border-border py-3">
897
- <View>
898
- <Text className="text-lg font-semibold">${displayName}</Text>
899
- </View>
900
- <ThemeToggle />
901
- </View>
902
-
903
- <View className="gap-4 sm:flex-row">
904
- <View className="flex-1 border border-border bg-card px-6 py-8">
905
- <Text className="text-2xl font-semibold">Sign in</Text>
906
- <Text className="mt-3 text-muted-foreground">
907
- Access the protected application area.
908
- </Text>
909
- <Link href="/" className="mt-6 text-sm underline underline-offset-4">
910
- Back
911
- </Link>
912
- </View>
913
-
914
- <View className="w-full sm:max-w-md">
915
- <SignInForm />
916
- </View>
917
- </View>
918
- </View>
919
- </ScrollView>
920
- );
921
- }
922
- `);
923
- writeFile(path.join(dir, 'app/(auth)/sign-up/index.tsx'), `import { SignUpForm } from '@/components/sign-up-form';
924
- import { ThemeToggle } from '@/components/theme-toggle';
925
- import { Text } from '@/components/ui/text';
926
- import { ScrollView, View } from 'react-native';
927
-
928
- export default function SignUpScreen() {
929
- return (
930
- <ScrollView
931
- keyboardShouldPersistTaps="handled"
932
- contentContainerClassName="min-h-full bg-background px-4 pb-10 pt-6 sm:px-6"
933
- keyboardDismissMode="interactive">
934
- <View className="mx-auto w-full max-w-5xl gap-6">
935
- <View className="flex-row items-center justify-between border-b border-border py-3">
936
- <View>
937
- <Text className="text-lg font-semibold">${displayName}</Text>
938
- </View>
939
- <ThemeToggle />
940
- </View>
941
-
942
- <View className="gap-4 sm:flex-row">
943
- <View className="w-full sm:max-w-md sm:order-2">
944
- <SignUpForm />
945
- </View>
946
-
947
- <View className="flex-1 border border-border bg-card px-6 py-8 sm:order-1">
948
- <Text className="text-2xl font-semibold">Create account</Text>
949
- <Text className="mt-3 text-muted-foreground">
950
- Create a user and continue into the protected application area.
951
- </Text>
952
- </View>
953
- </View>
954
- </View>
955
- </ScrollView>
956
- );
957
- }
958
- `);
959
- writeFile(path.join(dir, 'components/sign-in-form.tsx'), `import { SocialConnections } from '@/components/social-connections';
960
- import { Button } from '@/components/ui/button';
961
- import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
962
- import { Input } from '@/components/ui/input';
963
- import { Label } from '@/components/ui/label';
964
- import { Separator } from '@/components/ui/separator';
965
- import { Text } from '@/components/ui/text';
966
- import { useSignIn } from '@clerk/expo/legacy';
967
- import { Link } from 'expo-router';
968
- import * as React from 'react';
969
- import { type TextInput, View } from 'react-native';
970
-
971
- export function SignInForm() {
972
- const { signIn, setActive, isLoaded } = useSignIn();
973
- const [email, setEmail] = React.useState('');
974
- const [password, setPassword] = React.useState('');
975
- const passwordInputRef = React.useRef<TextInput>(null);
976
- const [error, setError] = React.useState<{ email?: string; password?: string }>({});
977
-
978
- async function onSubmit() {
979
- if (!isLoaded) return;
980
-
981
- try {
982
- const signInAttempt = await signIn.create({
983
- identifier: email,
984
- password,
985
- });
986
-
987
- if (signInAttempt.status === 'complete') {
988
- setError({ email: '', password: '' });
989
- await setActive({ session: signInAttempt.createdSessionId });
990
- return;
991
- }
992
- } catch (err) {
993
- if (err instanceof Error) {
994
- const message = err.message;
995
- const isEmailMessage = message.toLowerCase().includes('identifier') || message.toLowerCase().includes('email');
996
- setError(isEmailMessage ? { email: message } : { password: message });
997
- }
998
- }
999
- }
1000
-
1001
- return (
1002
- <View className="gap-6">
1003
- <Card className="rounded-[28px] border-border/70 bg-card shadow-sm shadow-black/5">
1004
- <CardHeader className="px-6 pt-8">
1005
- <CardTitle className="text-center text-2xl sm:text-left">Sign in</CardTitle>
1006
- <CardDescription className="text-center sm:text-left">
1007
- Continue with your account.
1008
- </CardDescription>
1009
- </CardHeader>
1010
- <CardContent className="gap-6 px-6 pb-8">
1011
- <View className="gap-4">
1012
- <View className="gap-1.5">
1013
- <Label htmlFor="email">Email</Label>
1014
- <Input
1015
- id="email"
1016
- placeholder="you@example.com"
1017
- keyboardType="email-address"
1018
- autoComplete="email"
1019
- autoCapitalize="none"
1020
- onChangeText={setEmail}
1021
- onSubmitEditing={() => passwordInputRef.current?.focus()}
1022
- returnKeyType="next"
1023
- submitBehavior="submit"
1024
- />
1025
- {error.email ? <Text className="text-sm font-medium text-destructive">{error.email}</Text> : null}
1026
- </View>
1027
-
1028
- <View className="gap-1.5">
1029
- <View className="flex-row items-center">
1030
- <Label htmlFor="password">Password</Label>
1031
- <Link asChild href={\`/(auth)/forgot-password?email=\${email}\`}>
1032
- <Button variant="link" size="sm" className="ml-auto h-4 px-1 py-0 web:h-fit sm:h-4">
1033
- <Text className="font-normal leading-4">Forgot password?</Text>
1034
- </Button>
1035
- </Link>
1036
- </View>
1037
- <Input
1038
- ref={passwordInputRef}
1039
- id="password"
1040
- secureTextEntry
1041
- onChangeText={setPassword}
1042
- returnKeyType="send"
1043
- onSubmitEditing={onSubmit}
1044
- />
1045
- {error.password ? <Text className="text-sm font-medium text-destructive">{error.password}</Text> : null}
1046
- </View>
1047
-
1048
- <Button className="w-full" onPress={onSubmit}>
1049
- <Text>Continue</Text>
1050
- </Button>
1051
- </View>
1052
-
1053
- <Text className="text-center text-sm">
1054
- Don&apos;t have an account?{' '}
1055
- <Link href="/(auth)/sign-up" className="text-sm underline underline-offset-4">
1056
- Sign up
1057
- </Link>
1058
- </Text>
1059
-
1060
- <View className="flex-row items-center">
1061
- <Separator className="flex-1" />
1062
- <Text className="px-4 text-sm text-muted-foreground">or</Text>
1063
- <Separator className="flex-1" />
1064
- </View>
1065
-
1066
- <SocialConnections />
1067
- </CardContent>
1068
- </Card>
1069
- </View>
1070
- );
1071
- }
1072
- `);
1073
- writeFile(path.join(dir, 'components/sign-up-form.tsx'), `import { SocialConnections } from '@/components/social-connections';
1074
- import { Button } from '@/components/ui/button';
1075
- import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
1076
- import { Input } from '@/components/ui/input';
1077
- import { Label } from '@/components/ui/label';
1078
- import { Separator } from '@/components/ui/separator';
1079
- import { Text } from '@/components/ui/text';
1080
- import { useSignUp } from '@clerk/expo/legacy';
1081
- import { Link, router } from 'expo-router';
1082
- import * as React from 'react';
1083
- import { TextInput, View } from 'react-native';
1084
-
1085
- export function SignUpForm() {
1086
- const { signUp, isLoaded } = useSignUp();
1087
- const [email, setEmail] = React.useState('');
1088
- const [password, setPassword] = React.useState('');
1089
- const passwordInputRef = React.useRef<TextInput>(null);
1090
- const [error, setError] = React.useState<{ email?: string; password?: string }>({});
1091
-
1092
- async function onSubmit() {
1093
- if (!isLoaded) return;
1094
-
1095
- try {
1096
- await signUp.create({
1097
- emailAddress: email,
1098
- password,
1099
- });
1100
- await signUp.prepareEmailAddressVerification({ strategy: 'email_code' });
1101
- router.push(\`/(auth)/sign-up/verify-email?email=\${email}\`);
1102
- } catch (err) {
1103
- if (err instanceof Error) {
1104
- const message = err.message;
1105
- const isEmailMessage = message.toLowerCase().includes('identifier') || message.toLowerCase().includes('email');
1106
- setError(isEmailMessage ? { email: message } : { password: message });
1107
- }
1108
- }
1109
- }
1110
-
1111
- return (
1112
- <View className="gap-6">
1113
- <Card className="rounded-[28px] border-border/70 bg-card shadow-sm shadow-black/5">
1114
- <CardHeader className="px-6 pt-8">
1115
- <CardTitle className="text-center text-2xl sm:text-left">Create your account</CardTitle>
1116
- <CardDescription className="text-center sm:text-left">
1117
- Enter an email and password to continue.
1118
- </CardDescription>
1119
- </CardHeader>
1120
- <CardContent className="gap-6 px-6 pb-8">
1121
- <View className="gap-4">
1122
- <View className="gap-1.5">
1123
- <Label htmlFor="email">Email</Label>
1124
- <Input
1125
- id="email"
1126
- placeholder="you@example.com"
1127
- keyboardType="email-address"
1128
- autoComplete="email"
1129
- autoCapitalize="none"
1130
- onChangeText={setEmail}
1131
- onSubmitEditing={() => passwordInputRef.current?.focus()}
1132
- returnKeyType="next"
1133
- submitBehavior="submit"
1134
- />
1135
- {error.email ? <Text className="text-sm font-medium text-destructive">{error.email}</Text> : null}
1136
- </View>
1137
-
1138
- <View className="gap-1.5">
1139
- <Label htmlFor="password">Password</Label>
1140
- <Input
1141
- ref={passwordInputRef}
1142
- id="password"
1143
- secureTextEntry
1144
- onChangeText={setPassword}
1145
- returnKeyType="send"
1146
- onSubmitEditing={onSubmit}
1147
- />
1148
- {error.password ? <Text className="text-sm font-medium text-destructive">{error.password}</Text> : null}
1149
- </View>
1150
-
1151
- <Button className="w-full" onPress={onSubmit}>
1152
- <Text>Continue</Text>
1153
- </Button>
1154
- </View>
1155
-
1156
- <Text className="text-center text-sm">
1157
- Already have an account?{' '}
1158
- <Link href="/(auth)/sign-in" dismissTo className="text-sm underline underline-offset-4">
1159
- Sign in
1160
- </Link>
1161
- </Text>
1162
-
1163
- <View className="flex-row items-center">
1164
- <Separator className="flex-1" />
1165
- <Text className="px-4 text-sm text-muted-foreground">or</Text>
1166
- <Separator className="flex-1" />
1167
- </View>
1168
-
1169
- <SocialConnections />
1170
- </CardContent>
1171
- </Card>
1172
- </View>
1173
- );
1174
- }
1175
- `);
1176
- writeFile(path.join(dir, 'components/user-menu.tsx'), `import { Button } from '@/components/ui/button';
1177
- import { Icon } from '@/components/ui/icon';
1178
- import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
1179
- import { Text } from '@/components/ui/text';
1180
- import { useAccount } from '@/hooks/useAccount';
1181
- import { useSubscription } from '@/hooks/useSubscription';
1182
- import { useAuth } from '@clerk/expo';
1183
- import type { TriggerRef } from '@rn-primitives/popover';
1184
- import { CreditCardIcon, LogOutIcon, UserIcon } from 'lucide-react-native';
1185
- import * as React from 'react';
1186
- import { View } from 'react-native';
1187
-
1188
- export function UserMenu() {
1189
- const { signOut } = useAuth();
1190
- const { account } = useAccount();
1191
- const subscription = useSubscription();
1192
- const popoverTriggerRef = React.useRef<TriggerRef>(null);
1193
-
1194
- async function onSignOut() {
1195
- popoverTriggerRef.current?.close();
1196
- await signOut();
1197
- }
1198
-
1199
- async function onSubscriptionAction() {
1200
- popoverTriggerRef.current?.close();
1201
- if (subscription.managementUrl) {
1202
- await subscription.manageSubscription();
1203
- return;
1204
- }
1205
-
1206
- await subscription.subscribe('monthly');
1207
- }
1208
-
1209
- return (
1210
- <Popover>
1211
- <PopoverTrigger asChild ref={popoverTriggerRef}>
1212
- <Button variant="ghost" size="sm">
1213
- <Icon as={UserIcon} className="size-4" />
1214
- <Text>Account</Text>
1215
- </Button>
1216
- </PopoverTrigger>
1217
-
1218
- <PopoverContent align="end" side="bottom" className="w-72 gap-0 p-0">
1219
- <View className="gap-1 border-b border-border p-4">
1220
- <Text className="font-medium">{account?.displayName ?? 'Account'}</Text>
1221
- <Text className="text-sm text-muted-foreground">{account?.email ?? 'Loading account...'}</Text>
1222
- </View>
1223
-
1224
- <View className="gap-2 p-3">
1225
- <Button variant="outline" onPress={() => void onSubscriptionAction()}>
1226
- <Icon as={CreditCardIcon} className="size-4" />
1227
- <Text>{subscription.managementUrl ? 'Manage subscription' : 'Open paywall'}</Text>
1228
- </Button>
1229
-
1230
- <Button variant="outline" onPress={() => void onSignOut()}>
1231
- <Icon as={LogOutIcon} className="size-4" />
1232
- <Text>Sign out</Text>
1233
- </Button>
1234
- </View>
1235
- </PopoverContent>
1236
- </Popover>
1237
- );
1238
- }
1239
- `);
1240
- }
1241
- function writeEnvExample(dir) {
1242
- writeFile(path.join(dir, '.env.example'), `# Clerk
1243
- EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_***
1244
-
1245
- # Backend API URL
1246
- EXPO_PUBLIC_API_URL=http://localhost:8787
1247
-
1248
- # RevenueCat
1249
- EXPO_PUBLIC_RC_ENTITLEMENT_ID=pro
1250
- EXPO_PUBLIC_RC_API_KEY_IOS=appl_***
1251
- EXPO_PUBLIC_RC_API_KEY_ANDROID=goog_***
1252
- EXPO_PUBLIC_RC_WEB_API_KEY=rcb_***
1253
- `);
1254
- }
1255
- function writeEnvFile(dir) {
1256
- writeFile(path.join(dir, '.env'), `# Clerk
1257
- EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_demo_placeholder
1258
-
1259
- # Backend API URL
1260
- EXPO_PUBLIC_API_URL=http://localhost:8787
1261
-
1262
- # RevenueCat
1263
- EXPO_PUBLIC_RC_ENTITLEMENT_ID=pro
1264
- EXPO_PUBLIC_RC_API_KEY_IOS=appl_demo_placeholder
1265
- EXPO_PUBLIC_RC_API_KEY_ANDROID=goog_demo_placeholder
1266
- EXPO_PUBLIC_RC_WEB_API_KEY=rcb_demo_placeholder
1267
- `);
1268
- }