anhedral 0.1.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.
- package/LICENSE +22 -0
- package/README.md +65 -0
- package/anhedral.svg +4 -0
- package/bin/anhedral.js +2 -0
- package/dist/branding.d.ts +3 -0
- package/dist/branding.js +9 -0
- package/dist/commands.d.ts +12 -0
- package/dist/commands.js +74 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +157 -0
- package/dist/scaffold.d.ts +25 -0
- package/dist/scaffold.js +629 -0
- package/dist/templates/backend.d.ts +2 -0
- package/dist/templates/backend.js +2337 -0
- package/dist/templates/default-web.d.ts +2 -0
- package/dist/templates/default-web.js +1368 -0
- package/dist/templates/extension.d.ts +2 -0
- package/dist/templates/extension.js +457 -0
- package/dist/templates/frontend.d.ts +2 -0
- package/dist/templates/frontend.js +1605 -0
- package/dist/toolchain.d.ts +14 -0
- package/dist/toolchain.js +34 -0
- package/dist/util.d.ts +6 -0
- package/dist/util.js +99 -0
- package/package.json +60 -0
|
@@ -0,0 +1,1605 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { writeAnhedralLogo } from '../branding.js';
|
|
4
|
+
import { appendGitignore, writeFile, exec, execExpect, liftNestedProject } from '../util.js';
|
|
5
|
+
import { resolveToolchainChannel, resolveToolchain, toolPackageRef } from '../toolchain.js';
|
|
6
|
+
export async function scaffoldFrontend(root, { projectName, displayName }) {
|
|
7
|
+
const dir = path.join(root, 'frontend');
|
|
8
|
+
const toolchain = resolveToolchain(resolveToolchainChannel(process.env.ANHEDRAL_TOOLCHAIN));
|
|
9
|
+
// ── 1. Scaffold via react-native-reusables CLI ────────────────────────
|
|
10
|
+
console.log(' Scaffolding Expo app with react-native-reusables...');
|
|
11
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
12
|
+
execExpect(`pnpm dlx ${toolPackageRef('@react-native-reusables/cli', toolchain.reactNativeReusables)} init -t clerk-auth`, dir, [
|
|
13
|
+
['What is the name of your project?', projectName],
|
|
14
|
+
['Would you like to install dependencies?', 'n'],
|
|
15
|
+
['Would you like to initialize a Git repository?', 'n'],
|
|
16
|
+
]);
|
|
17
|
+
// The CLI scaffolds into a subdir named after the project — move contents up
|
|
18
|
+
liftNestedProject(dir, projectName);
|
|
19
|
+
console.log(' Installing frontend dependencies...');
|
|
20
|
+
exec('pnpm install', dir);
|
|
21
|
+
patchFrontendPackageJson(dir);
|
|
22
|
+
appendGitignore(dir, ['.env', '.env.*', '!.env.example']);
|
|
23
|
+
// ── 2. Patch in API client ────────────────────────────────────────────
|
|
24
|
+
writeApiClient(dir);
|
|
25
|
+
writeConfigFile(dir);
|
|
26
|
+
writeApiHook(dir);
|
|
27
|
+
writeAccountHook(dir);
|
|
28
|
+
writeThemeToggle(dir);
|
|
29
|
+
writeAppShellFiles(dir, displayName);
|
|
30
|
+
writeEnvExample(dir);
|
|
31
|
+
writeEnvFile(dir);
|
|
32
|
+
writeAnhedralLogo(dir);
|
|
33
|
+
// ── 2b. Write subscription files ─────────────────────────────────────
|
|
34
|
+
writeSubscriptionProvider(dir);
|
|
35
|
+
writeUseSubscription(dir);
|
|
36
|
+
// ── 3. Install extra dependencies ─────────────────────────────────────
|
|
37
|
+
console.log(' Installing additional dependencies...');
|
|
38
|
+
exec('pnpm exec expo install expo-image-picker', dir);
|
|
39
|
+
exec('pnpm add react-native-purchases react-native-purchases-ui @revenuecat/purchases-js', dir);
|
|
40
|
+
}
|
|
41
|
+
function patchFrontendPackageJson(dir) {
|
|
42
|
+
const filePath = path.join(dir, 'package.json');
|
|
43
|
+
const packageJson = JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
44
|
+
packageJson.scripts = {
|
|
45
|
+
...(packageJson.scripts ?? {}),
|
|
46
|
+
typecheck: 'tsc --noEmit',
|
|
47
|
+
};
|
|
48
|
+
writeFile(filePath, JSON.stringify(packageJson, null, 2) + '\n');
|
|
49
|
+
}
|
|
50
|
+
function writeApiClient(dir) {
|
|
51
|
+
writeFile(path.join(dir, 'api/client.ts'), `/**
|
|
52
|
+
* API Client with SWR caching
|
|
53
|
+
*/
|
|
54
|
+
|
|
55
|
+
const SWR_MAX_SIZE = 200;
|
|
56
|
+
const SWR_DEFAULT_STALE_MS = 15_000;
|
|
57
|
+
const SWR_DEFAULT_MAX_AGE_MS = 300_000;
|
|
58
|
+
|
|
59
|
+
type SWREntry<T> = { data: T; fetchedAt: number; staleMs: number; maxAgeMs: number };
|
|
60
|
+
const SWR_CACHE = new Map<string, SWREntry<unknown>>();
|
|
61
|
+
const SWR_INFLIGHT = new Map<string, Promise<unknown>>();
|
|
62
|
+
|
|
63
|
+
function swrEvict() {
|
|
64
|
+
if (SWR_CACHE.size <= SWR_MAX_SIZE) return;
|
|
65
|
+
const entries = [...SWR_CACHE.entries()].sort((a, b) => a[1].fetchedAt - b[1].fetchedAt);
|
|
66
|
+
const toRemove = entries.slice(0, entries.length - SWR_MAX_SIZE);
|
|
67
|
+
for (const [key] of toRemove) SWR_CACHE.delete(key);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export class APIRequestError extends Error {
|
|
71
|
+
constructor(
|
|
72
|
+
public statusCode: number,
|
|
73
|
+
message: string,
|
|
74
|
+
public errorCode?: string,
|
|
75
|
+
) {
|
|
76
|
+
super(message);
|
|
77
|
+
this.name = 'APIRequestError';
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export class APIClient {
|
|
82
|
+
constructor(
|
|
83
|
+
private baseUrl: string,
|
|
84
|
+
private getToken: () => Promise<string | null>,
|
|
85
|
+
) {}
|
|
86
|
+
|
|
87
|
+
private async request<T>(method: string, path: string, body?: unknown): Promise<T> {
|
|
88
|
+
const token = await this.getToken();
|
|
89
|
+
const headers: Record<string, string> = {
|
|
90
|
+
'Content-Type': 'application/json',
|
|
91
|
+
'X-Platform': 'web',
|
|
92
|
+
};
|
|
93
|
+
if (token) headers['Authorization'] = \`Bearer \${token}\`;
|
|
94
|
+
|
|
95
|
+
const res = await fetch(\`\${this.baseUrl}\${path}\`, {
|
|
96
|
+
method,
|
|
97
|
+
headers,
|
|
98
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
if (!res.ok) {
|
|
102
|
+
let err: { error?: string; message?: string } = {};
|
|
103
|
+
try { err = await res.json(); } catch {}
|
|
104
|
+
throw new APIRequestError(res.status, err.message || \`Request failed (\${res.status})\`, err.error);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (res.status === 204) return {} as T;
|
|
108
|
+
return res.json();
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async get<T>(path: string): Promise<T> { return this.request<T>('GET', path); }
|
|
112
|
+
async post<T>(path: string, body?: unknown): Promise<T> { return this.request<T>('POST', path, body); }
|
|
113
|
+
async delete<T>(path: string): Promise<T> { return this.request<T>('DELETE', path); }
|
|
114
|
+
|
|
115
|
+
async getCached<T>(
|
|
116
|
+
path: string,
|
|
117
|
+
opts?: { staleMs?: number; maxAgeMs?: number },
|
|
118
|
+
): Promise<T> {
|
|
119
|
+
const staleMs = opts?.staleMs ?? SWR_DEFAULT_STALE_MS;
|
|
120
|
+
const maxAgeMs = opts?.maxAgeMs ?? SWR_DEFAULT_MAX_AGE_MS;
|
|
121
|
+
const now = Date.now();
|
|
122
|
+
const cached = SWR_CACHE.get(path) as SWREntry<T> | undefined;
|
|
123
|
+
|
|
124
|
+
if (cached) {
|
|
125
|
+
const age = now - cached.fetchedAt;
|
|
126
|
+
if (age < staleMs) return cached.data;
|
|
127
|
+
if (age < maxAgeMs) {
|
|
128
|
+
if (!SWR_INFLIGHT.has(path)) {
|
|
129
|
+
const p = this.get<T>(path).then((data) => {
|
|
130
|
+
SWR_CACHE.set(path, { data, fetchedAt: Date.now(), staleMs, maxAgeMs });
|
|
131
|
+
return data;
|
|
132
|
+
}).finally(() => SWR_INFLIGHT.delete(path));
|
|
133
|
+
SWR_INFLIGHT.set(path, p);
|
|
134
|
+
}
|
|
135
|
+
return cached.data;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const inflight = SWR_INFLIGHT.get(path) as Promise<T> | undefined;
|
|
140
|
+
if (inflight) return inflight;
|
|
141
|
+
|
|
142
|
+
const p = this.get<T>(path).then((data) => {
|
|
143
|
+
SWR_CACHE.set(path, { data, fetchedAt: Date.now(), staleMs, maxAgeMs });
|
|
144
|
+
swrEvict();
|
|
145
|
+
return data;
|
|
146
|
+
}).finally(() => SWR_INFLIGHT.delete(path));
|
|
147
|
+
SWR_INFLIGHT.set(path, p);
|
|
148
|
+
return p;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
invalidateCache(path: string): void { SWR_CACHE.delete(path); }
|
|
152
|
+
invalidateCachePrefix(prefix: string): void {
|
|
153
|
+
for (const key of SWR_CACHE.keys()) {
|
|
154
|
+
if (key.startsWith(prefix)) SWR_CACHE.delete(key);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
clearAllCaches(): void {
|
|
159
|
+
SWR_CACHE.clear();
|
|
160
|
+
SWR_INFLIGHT.clear();
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ── Auth endpoints ──────────────────────────────────────────────────
|
|
164
|
+
async getMe() {
|
|
165
|
+
return this.get<{
|
|
166
|
+
user: {
|
|
167
|
+
id: string;
|
|
168
|
+
email: string;
|
|
169
|
+
firstName?: string | null;
|
|
170
|
+
lastName?: string | null;
|
|
171
|
+
displayName: string;
|
|
172
|
+
imageUrl?: string | null;
|
|
173
|
+
avatarUrl?: string | null;
|
|
174
|
+
creditsBalance: number;
|
|
175
|
+
subscriptionTier: string;
|
|
176
|
+
subscriptionStatus: string;
|
|
177
|
+
};
|
|
178
|
+
}>('/auth/me');
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async signOut() {
|
|
182
|
+
return this.post<{ success: boolean }>('/auth/signout');
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async uploadAvatar(input: { base64: string; mimeType: string; fileName?: string }) {
|
|
186
|
+
return this.post<{ ok: boolean; avatarUrl: string }>('/auth/avatar', input);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
async deleteAccount() {
|
|
190
|
+
return this.delete<void>('/auth/account');
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// ── Subscription endpoints ───────────────────────────────────────────
|
|
194
|
+
async getSubscriptionPricing() {
|
|
195
|
+
return this.getCached<{
|
|
196
|
+
tiers: { tier: string; displayName: string; description: string; priceMonthly: number | null; priceYearly: number | null; currency: string; limits: { dailyLimit: number | null }; paymentInfo?: { revenueCatEntitlementId: string; revenueCatOfferingId: string } }[];
|
|
197
|
+
}>('/subscriptions/pricing', { staleMs: 300_000, maxAgeMs: 3_600_000 });
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async getSubscriptionEntitlements(options?: { refresh?: boolean }) {
|
|
201
|
+
const path = options?.refresh ? '/subscriptions/entitlements/me?refresh=true' : '/subscriptions/entitlements/me';
|
|
202
|
+
return this.get<{
|
|
203
|
+
pro: boolean; inTrial: boolean; trialEndsAt?: string; expiresAt?: string;
|
|
204
|
+
periodStart?: string; periodEnd?: string;
|
|
205
|
+
method?: 'trialing' | 'redeemed' | 'paid' | null;
|
|
206
|
+
managementUrl?: string; cancelAtPeriodEnd?: boolean;
|
|
207
|
+
}>(path);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async redeemPromoCode(code: string) {
|
|
211
|
+
return this.post<{ ok: boolean; expiresAt: string }>('/subscriptions/redeem', { code });
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export { SWR_CACHE };
|
|
216
|
+
`);
|
|
217
|
+
writeFile(path.join(dir, 'api/index.ts'), `export { APIClient, APIRequestError } from './client';
|
|
218
|
+
`);
|
|
219
|
+
}
|
|
220
|
+
function writeConfigFile(dir) {
|
|
221
|
+
writeFile(path.join(dir, 'lib/config.ts'), `import { Platform } from 'react-native';
|
|
222
|
+
|
|
223
|
+
type EnvKey =
|
|
224
|
+
| 'EXPO_PUBLIC_API_URL'
|
|
225
|
+
| 'EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY'
|
|
226
|
+
| 'EXPO_PUBLIC_RC_ENTITLEMENT_ID'
|
|
227
|
+
| 'EXPO_PUBLIC_RC_API_KEY_IOS'
|
|
228
|
+
| 'EXPO_PUBLIC_RC_API_KEY_ANDROID'
|
|
229
|
+
| 'EXPO_PUBLIC_RC_WEB_API_KEY';
|
|
230
|
+
|
|
231
|
+
const envValue = (key: EnvKey): string | undefined => {
|
|
232
|
+
switch (key) {
|
|
233
|
+
case 'EXPO_PUBLIC_API_URL': return process.env.EXPO_PUBLIC_API_URL;
|
|
234
|
+
case 'EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY': return process.env.EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY;
|
|
235
|
+
case 'EXPO_PUBLIC_RC_ENTITLEMENT_ID': return process.env.EXPO_PUBLIC_RC_ENTITLEMENT_ID;
|
|
236
|
+
case 'EXPO_PUBLIC_RC_API_KEY_IOS': return process.env.EXPO_PUBLIC_RC_API_KEY_IOS;
|
|
237
|
+
case 'EXPO_PUBLIC_RC_API_KEY_ANDROID': return process.env.EXPO_PUBLIC_RC_API_KEY_ANDROID;
|
|
238
|
+
case 'EXPO_PUBLIC_RC_WEB_API_KEY': return process.env.EXPO_PUBLIC_RC_WEB_API_KEY;
|
|
239
|
+
default: return undefined;
|
|
240
|
+
}
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
const requireEnv = (key: EnvKey): string => {
|
|
244
|
+
const value = envValue(key);
|
|
245
|
+
if (!value) throw new Error(\`Missing \${key}. Set it in your environment variables.\`);
|
|
246
|
+
return value;
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
export const apiBaseUrl = requireEnv('EXPO_PUBLIC_API_URL');
|
|
250
|
+
export const clerkPublishableKey = requireEnv('EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY');
|
|
251
|
+
|
|
252
|
+
export type SubscriptionConfig = {
|
|
253
|
+
entitlementId: string;
|
|
254
|
+
iosApiKey: string;
|
|
255
|
+
androidApiKey: string;
|
|
256
|
+
webApiKey: string;
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
export const subscriptionConfig: SubscriptionConfig = {
|
|
260
|
+
entitlementId: requireEnv('EXPO_PUBLIC_RC_ENTITLEMENT_ID'),
|
|
261
|
+
iosApiKey: requireEnv('EXPO_PUBLIC_RC_API_KEY_IOS'),
|
|
262
|
+
androidApiKey: requireEnv('EXPO_PUBLIC_RC_API_KEY_ANDROID'),
|
|
263
|
+
webApiKey: requireEnv('EXPO_PUBLIC_RC_WEB_API_KEY'),
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
export function getPlatformRevenueCatApiKey(): string {
|
|
267
|
+
if (Platform.OS === 'ios') return subscriptionConfig.iosApiKey;
|
|
268
|
+
if (Platform.OS === 'android') return subscriptionConfig.androidApiKey;
|
|
269
|
+
if (Platform.OS === 'web') return subscriptionConfig.webApiKey;
|
|
270
|
+
throw new Error(\`Unsupported platform: \${Platform.OS}\`);
|
|
271
|
+
}
|
|
272
|
+
`);
|
|
273
|
+
}
|
|
274
|
+
function writeApiHook(dir) {
|
|
275
|
+
writeFile(path.join(dir, 'hooks/useAPI.ts'), `import { useAuth } from '@clerk/clerk-expo';
|
|
276
|
+
import { useMemo } from 'react';
|
|
277
|
+
import { APIClient } from '@/api/client';
|
|
278
|
+
import { apiBaseUrl } from '@/lib/config';
|
|
279
|
+
|
|
280
|
+
export function useAPI() {
|
|
281
|
+
const { getToken } = useAuth();
|
|
282
|
+
return useMemo(() => new APIClient(apiBaseUrl, getToken), [getToken]);
|
|
283
|
+
}
|
|
284
|
+
`);
|
|
285
|
+
}
|
|
286
|
+
function writeAccountHook(dir) {
|
|
287
|
+
writeFile(path.join(dir, 'hooks/useAccount.ts'), `import { useAuth } from '@clerk/clerk-expo';
|
|
288
|
+
import * as React from 'react';
|
|
289
|
+
import { useAPI } from '@/hooks/useAPI';
|
|
290
|
+
|
|
291
|
+
export type AccountSummary = {
|
|
292
|
+
id: string;
|
|
293
|
+
email: string;
|
|
294
|
+
firstName?: string | null;
|
|
295
|
+
lastName?: string | null;
|
|
296
|
+
displayName: string;
|
|
297
|
+
imageUrl?: string | null;
|
|
298
|
+
avatarUrl?: string | null;
|
|
299
|
+
creditsBalance: number;
|
|
300
|
+
subscriptionTier: string;
|
|
301
|
+
subscriptionStatus: string;
|
|
302
|
+
};
|
|
303
|
+
|
|
304
|
+
export function useAccount() {
|
|
305
|
+
const { isLoaded, isSignedIn } = useAuth();
|
|
306
|
+
const api = useAPI();
|
|
307
|
+
const [account, setAccount] = React.useState<AccountSummary | null>(null);
|
|
308
|
+
const [loading, setLoading] = React.useState(true);
|
|
309
|
+
const [error, setError] = React.useState<string | null>(null);
|
|
310
|
+
|
|
311
|
+
const refresh = React.useCallback(async () => {
|
|
312
|
+
if (!isLoaded || !isSignedIn) {
|
|
313
|
+
setAccount(null);
|
|
314
|
+
setLoading(false);
|
|
315
|
+
return null;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
setLoading(true);
|
|
319
|
+
setError(null);
|
|
320
|
+
|
|
321
|
+
try {
|
|
322
|
+
const response = await api.getMe();
|
|
323
|
+
setAccount(response.user);
|
|
324
|
+
return response.user;
|
|
325
|
+
} catch (err) {
|
|
326
|
+
setError(err instanceof Error ? err.message : 'Failed to load account');
|
|
327
|
+
return null;
|
|
328
|
+
} finally {
|
|
329
|
+
setLoading(false);
|
|
330
|
+
}
|
|
331
|
+
}, [api, isLoaded, isSignedIn]);
|
|
332
|
+
|
|
333
|
+
React.useEffect(() => {
|
|
334
|
+
void refresh();
|
|
335
|
+
}, [refresh]);
|
|
336
|
+
|
|
337
|
+
const uploadAvatar = React.useCallback(async (input: {
|
|
338
|
+
base64: string;
|
|
339
|
+
mimeType: string;
|
|
340
|
+
fileName?: string;
|
|
341
|
+
}) => {
|
|
342
|
+
const response = await api.uploadAvatar(input);
|
|
343
|
+
setAccount((prev) => prev ? { ...prev, avatarUrl: response.avatarUrl } : prev);
|
|
344
|
+
return response;
|
|
345
|
+
}, [api]);
|
|
346
|
+
|
|
347
|
+
return {
|
|
348
|
+
account,
|
|
349
|
+
loading,
|
|
350
|
+
error,
|
|
351
|
+
refresh,
|
|
352
|
+
uploadAvatar,
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
`);
|
|
356
|
+
}
|
|
357
|
+
function writeThemeToggle(dir) {
|
|
358
|
+
writeFile(path.join(dir, 'components/theme-toggle.tsx'), `import { Button } from '@/components/ui/button';
|
|
359
|
+
import { Icon } from '@/components/ui/icon';
|
|
360
|
+
import { MoonStarIcon, SunIcon } from 'lucide-react-native';
|
|
361
|
+
import { useColorScheme } from 'nativewind';
|
|
362
|
+
|
|
363
|
+
const THEME_ICONS = {
|
|
364
|
+
light: SunIcon,
|
|
365
|
+
dark: MoonStarIcon,
|
|
366
|
+
};
|
|
367
|
+
|
|
368
|
+
export function ThemeToggle() {
|
|
369
|
+
const { colorScheme, toggleColorScheme } = useColorScheme();
|
|
370
|
+
|
|
371
|
+
return (
|
|
372
|
+
<Button onPress={toggleColorScheme} size="icon" variant="ghost" className="rounded-full">
|
|
373
|
+
<Icon as={THEME_ICONS[colorScheme ?? 'light']} className="size-6" />
|
|
374
|
+
</Button>
|
|
375
|
+
);
|
|
376
|
+
}
|
|
377
|
+
`);
|
|
378
|
+
}
|
|
379
|
+
function writeSubscriptionProvider(dir) {
|
|
380
|
+
writeFile(path.join(dir, 'contexts/SubscriptionProvider.tsx'), `import * as React from 'react';
|
|
381
|
+
import Purchases, { type CustomerInfo, type PurchasesOfferings } from 'react-native-purchases';
|
|
382
|
+
import { Purchases as PurchasesWeb } from '@revenuecat/purchases-js';
|
|
383
|
+
import { AppState, Platform } from 'react-native';
|
|
384
|
+
import { subscriptionConfig, getPlatformRevenueCatApiKey } from '@/lib/config';
|
|
385
|
+
import { useAuth, useUser } from '@clerk/clerk-expo';
|
|
386
|
+
import { useAPI } from '@/hooks/useAPI';
|
|
387
|
+
import RevenueCatUI, { PAYWALL_RESULT } from 'react-native-purchases-ui';
|
|
388
|
+
|
|
389
|
+
export type SubscriptionState = {
|
|
390
|
+
status: 'idle' | 'loading' | 'active' | 'inactive' | 'error';
|
|
391
|
+
entitlementActive: boolean;
|
|
392
|
+
inTrial: boolean;
|
|
393
|
+
trialEndsAt?: string | null;
|
|
394
|
+
expiresAt?: string | null;
|
|
395
|
+
periodStart?: string | null;
|
|
396
|
+
periodEnd?: string | null;
|
|
397
|
+
method?: 'trialing' | 'redeemed' | 'paid' | null;
|
|
398
|
+
managementUrl?: string | null;
|
|
399
|
+
cancelAtPeriodEnd?: boolean;
|
|
400
|
+
offerings?: PurchasesOfferings | null;
|
|
401
|
+
lastError?: Error | null;
|
|
402
|
+
pricing?: { monthly: number; yearly: number } | null;
|
|
403
|
+
};
|
|
404
|
+
|
|
405
|
+
interface SubscriptionContextValue extends SubscriptionState {
|
|
406
|
+
refresh: () => Promise<void>;
|
|
407
|
+
openPaywall: (plan?: 'monthly' | 'yearly') => Promise<void>;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
const SubscriptionContext = React.createContext<SubscriptionContextValue | undefined>(undefined);
|
|
411
|
+
|
|
412
|
+
export function SubscriptionProvider({ children }: { children: React.ReactNode }) {
|
|
413
|
+
const { userId } = useAuth();
|
|
414
|
+
const { user } = useUser();
|
|
415
|
+
const api = useAPI();
|
|
416
|
+
|
|
417
|
+
const [state, setState] = React.useState<SubscriptionState>({
|
|
418
|
+
status: 'idle', entitlementActive: false, inTrial: false,
|
|
419
|
+
periodStart: null, periodEnd: null, method: null,
|
|
420
|
+
});
|
|
421
|
+
|
|
422
|
+
const configuredRef = React.useRef<string | null>(null);
|
|
423
|
+
const apiRef = React.useRef(api);
|
|
424
|
+
React.useEffect(() => { apiRef.current = api; }, [api]);
|
|
425
|
+
const userRef = React.useRef(user);
|
|
426
|
+
React.useEffect(() => { userRef.current = user; }, [user]);
|
|
427
|
+
const expiryTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
428
|
+
const refreshIntervalRef = React.useRef<ReturnType<typeof setInterval> | null>(null);
|
|
429
|
+
const inflightRef = React.useRef<Promise<unknown> | null>(null);
|
|
430
|
+
const didSkipInitialVisibilityRef = React.useRef(false);
|
|
431
|
+
|
|
432
|
+
const sleep = React.useCallback((ms: number) => new Promise<void>((r) => setTimeout(r, ms)), []);
|
|
433
|
+
|
|
434
|
+
const loadEntitlements = React.useCallback(async (options?: { refresh?: boolean }) => {
|
|
435
|
+
const forced = Boolean(options?.refresh);
|
|
436
|
+
if (!forced && inflightRef.current) {
|
|
437
|
+
try { return (await inflightRef.current) as Awaited<ReturnType<typeof apiRef.current.getSubscriptionEntitlements>>; } catch { return null; }
|
|
438
|
+
}
|
|
439
|
+
try {
|
|
440
|
+
const p = apiRef.current.getSubscriptionEntitlements(options);
|
|
441
|
+
if (!forced) inflightRef.current = p;
|
|
442
|
+
const e = await p;
|
|
443
|
+
setState(prev => ({
|
|
444
|
+
...prev,
|
|
445
|
+
status: e.pro ? 'active' : 'inactive',
|
|
446
|
+
entitlementActive: e.pro,
|
|
447
|
+
inTrial: e.inTrial ?? false,
|
|
448
|
+
trialEndsAt: e.trialEndsAt ?? null,
|
|
449
|
+
expiresAt: e.expiresAt ?? null,
|
|
450
|
+
periodStart: e.periodStart ?? null,
|
|
451
|
+
periodEnd: e.periodEnd ?? null,
|
|
452
|
+
method: e.pro ? (e.method ?? prev.method ?? null) : null,
|
|
453
|
+
managementUrl: e.pro ? (e.managementUrl ?? prev.managementUrl ?? null) : null,
|
|
454
|
+
cancelAtPeriodEnd: e.cancelAtPeriodEnd ?? (e.pro ? (prev.cancelAtPeriodEnd ?? false) : false),
|
|
455
|
+
}));
|
|
456
|
+
return e;
|
|
457
|
+
} catch {
|
|
458
|
+
setState(prev => ({ ...prev, status: prev.status === 'loading' ? 'inactive' : prev.status }));
|
|
459
|
+
return null;
|
|
460
|
+
} finally {
|
|
461
|
+
if (!forced) inflightRef.current = null;
|
|
462
|
+
}
|
|
463
|
+
}, []);
|
|
464
|
+
|
|
465
|
+
const loadPricingAndOfferings = React.useCallback(async () => {
|
|
466
|
+
try {
|
|
467
|
+
const [pricingRes, offerings] = await Promise.all([
|
|
468
|
+
apiRef.current.getSubscriptionPricing(),
|
|
469
|
+
Platform.OS === 'web'
|
|
470
|
+
? PurchasesWeb.getSharedInstance().getOfferings()
|
|
471
|
+
: Purchases.getOfferings(),
|
|
472
|
+
]);
|
|
473
|
+
const pro = pricingRes.tiers.find(t => t.tier === 'pro');
|
|
474
|
+
setState(prev => ({
|
|
475
|
+
...prev,
|
|
476
|
+
pricing: pro?.priceMonthly != null && pro?.priceYearly != null
|
|
477
|
+
? { monthly: pro.priceMonthly, yearly: pro.priceYearly } : prev.pricing,
|
|
478
|
+
offerings: offerings as PurchasesOfferings,
|
|
479
|
+
}));
|
|
480
|
+
} catch (e) { console.error('[Subscription] Failed to load pricing/offerings:', e); }
|
|
481
|
+
}, []);
|
|
482
|
+
|
|
483
|
+
const refresh = React.useCallback(async () => {
|
|
484
|
+
try {
|
|
485
|
+
if (Platform.OS === 'web') {
|
|
486
|
+
await PurchasesWeb.getSharedInstance().getCustomerInfo();
|
|
487
|
+
} else {
|
|
488
|
+
await Purchases.getCustomerInfo();
|
|
489
|
+
}
|
|
490
|
+
await loadPricingAndOfferings();
|
|
491
|
+
for (let i = 0; i < 4; i++) {
|
|
492
|
+
const e = await loadEntitlements({ refresh: true });
|
|
493
|
+
if (e?.pro) break;
|
|
494
|
+
if (i < 3) await sleep(1500);
|
|
495
|
+
}
|
|
496
|
+
} catch (e) { console.error('[Subscription] Refresh error:', e); }
|
|
497
|
+
}, [loadEntitlements, loadPricingAndOfferings, sleep]);
|
|
498
|
+
|
|
499
|
+
const openPaywall = React.useCallback(async (plan?: 'monthly' | 'yearly') => {
|
|
500
|
+
try {
|
|
501
|
+
const current = await apiRef.current.getSubscriptionEntitlements({ refresh: true });
|
|
502
|
+
if (current.pro && (current.method === 'paid' || current.method === 'redeemed')) {
|
|
503
|
+
setState(prev => ({ ...prev, status: 'active', entitlementActive: true, inTrial: false, expiresAt: current.expiresAt ?? prev.expiresAt ?? null, method: current.method ?? prev.method ?? null }));
|
|
504
|
+
try { await userRef.current?.reload(); } catch {}
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
507
|
+
setState(prev => ({ ...prev, status: 'loading', lastError: null }));
|
|
508
|
+
|
|
509
|
+
if (Platform.OS === 'ios' || Platform.OS === 'android') {
|
|
510
|
+
const result = await RevenueCatUI.presentPaywallIfNeeded({ requiredEntitlementIdentifier: subscriptionConfig.entitlementId });
|
|
511
|
+
if (result === PAYWALL_RESULT.PURCHASED || result === PAYWALL_RESULT.RESTORED || result === PAYWALL_RESULT.NOT_PRESENTED) {
|
|
512
|
+
setState(prev => ({ ...prev, status: 'active', entitlementActive: true, inTrial: prev.inTrial ?? false }));
|
|
513
|
+
await refresh();
|
|
514
|
+
try { await userRef.current?.reload(); } catch {}
|
|
515
|
+
} else {
|
|
516
|
+
setState(prev => ({ ...prev, status: prev.entitlementActive ? 'active' : 'inactive' }));
|
|
517
|
+
}
|
|
518
|
+
} else {
|
|
519
|
+
const offerings = await PurchasesWeb.getSharedInstance().getOfferings();
|
|
520
|
+
const cur = offerings.current;
|
|
521
|
+
if (!cur?.availablePackages.length) throw new Error('No offerings');
|
|
522
|
+
const pkg = plan === 'monthly'
|
|
523
|
+
? (cur.monthly ?? cur.availablePackages.find(p => p.identifier.includes('month')))
|
|
524
|
+
: plan === 'yearly'
|
|
525
|
+
? (cur.annual ?? cur.availablePackages.find(p => p.identifier.includes('year')))
|
|
526
|
+
: cur.availablePackages[0];
|
|
527
|
+
if (pkg) {
|
|
528
|
+
try {
|
|
529
|
+
await PurchasesWeb.getSharedInstance().purchase({ rcPackage: pkg });
|
|
530
|
+
} catch (purchaseErr) {
|
|
531
|
+
const pe = purchaseErr as { errorCode?: unknown; message?: unknown };
|
|
532
|
+
const code = typeof pe?.errorCode === 'number' ? pe.errorCode : null;
|
|
533
|
+
if (code === 6 || code === 7) {
|
|
534
|
+
setState(prev => ({ ...prev, status: 'loading', lastError: null }));
|
|
535
|
+
await refresh();
|
|
536
|
+
try { await userRef.current?.reload(); } catch {}
|
|
537
|
+
return;
|
|
538
|
+
}
|
|
539
|
+
throw purchaseErr;
|
|
540
|
+
}
|
|
541
|
+
setState(prev => ({ ...prev, status: 'active', entitlementActive: true, inTrial: prev.inTrial ?? false }));
|
|
542
|
+
await refresh();
|
|
543
|
+
try { await userRef.current?.reload(); } catch {}
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
} catch (error) {
|
|
547
|
+
const err = error as Error & { userCancelled?: boolean | null; code?: unknown };
|
|
548
|
+
const code = typeof err.code === 'string' ? err.code : typeof err.code === 'number' ? String(err.code) : null;
|
|
549
|
+
const cancelled = Boolean(err.userCancelled) || code === 'USER_CANCELLED';
|
|
550
|
+
if (cancelled) {
|
|
551
|
+
setState(prev => ({ ...prev, status: prev.entitlementActive ? 'active' : 'inactive' }));
|
|
552
|
+
} else {
|
|
553
|
+
setState(prev => ({ ...prev, status: 'error', lastError: error as Error }));
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
}, [loadEntitlements, refresh]);
|
|
557
|
+
|
|
558
|
+
// Auto-expire timer
|
|
559
|
+
React.useEffect(() => {
|
|
560
|
+
if (expiryTimerRef.current) { clearTimeout(expiryTimerRef.current); expiryTimerRef.current = null; }
|
|
561
|
+
if (!userId) return;
|
|
562
|
+
const until = (state.method === 'trialing' ? (state.trialEndsAt ?? state.periodEnd) : (state.expiresAt ?? state.periodEnd)) ?? null;
|
|
563
|
+
if (!until) return;
|
|
564
|
+
const untilMs = new Date(until).getTime();
|
|
565
|
+
if (!Number.isFinite(untilMs)) return;
|
|
566
|
+
const delay = Math.min(Math.max(untilMs - Date.now() + 1500, 0), 2147483647);
|
|
567
|
+
if (delay <= 0) { if (state.entitlementActive) void loadEntitlements({ refresh: true }); return; }
|
|
568
|
+
expiryTimerRef.current = setTimeout(() => {
|
|
569
|
+
let shouldRefresh = false;
|
|
570
|
+
setState(prev => {
|
|
571
|
+
const prevUntil = (prev.method === 'trialing' ? (prev.trialEndsAt ?? prev.periodEnd) : (prev.expiresAt ?? prev.periodEnd)) ?? null;
|
|
572
|
+
const prevMs = prevUntil ? new Date(prevUntil).getTime() : null;
|
|
573
|
+
if (!prevMs || !Number.isFinite(prevMs) || prevMs > Date.now()) return prev;
|
|
574
|
+
if (!prev.entitlementActive) return prev;
|
|
575
|
+
shouldRefresh = true;
|
|
576
|
+
if (prev.method === 'paid' || prev.method === 'redeemed') return prev;
|
|
577
|
+
return { ...prev, status: 'inactive', entitlementActive: false, inTrial: false };
|
|
578
|
+
});
|
|
579
|
+
if (shouldRefresh) void loadEntitlements({ refresh: true });
|
|
580
|
+
}, delay);
|
|
581
|
+
return () => { if (expiryTimerRef.current) { clearTimeout(expiryTimerRef.current); expiryTimerRef.current = null; } };
|
|
582
|
+
}, [loadEntitlements, state.entitlementActive, state.expiresAt, state.method, state.periodEnd, state.trialEndsAt, userId]);
|
|
583
|
+
|
|
584
|
+
// Periodic refresh
|
|
585
|
+
React.useEffect(() => {
|
|
586
|
+
if (refreshIntervalRef.current) { clearInterval(refreshIntervalRef.current); refreshIntervalRef.current = null; }
|
|
587
|
+
if (!userId || !state.entitlementActive || state.status === 'loading') return;
|
|
588
|
+
const until = (state.method === 'trialing' ? (state.trialEndsAt ?? state.periodEnd) : (state.expiresAt ?? state.periodEnd)) ?? null;
|
|
589
|
+
const untilMs = until ? new Date(until).getTime() : null;
|
|
590
|
+
const remainingMs = untilMs != null && Number.isFinite(untilMs) ? (untilMs - Date.now()) : null;
|
|
591
|
+
const intervalMs = remainingMs != null
|
|
592
|
+
? (remainingMs <= 5 * 60_000 ? 60_000 : remainingMs <= 60 * 60_000 ? 5 * 60_000 : 15 * 60_000)
|
|
593
|
+
: 15 * 60_000;
|
|
594
|
+
refreshIntervalRef.current = setInterval(() => { void loadEntitlements({ refresh: false }); }, intervalMs);
|
|
595
|
+
return () => { if (refreshIntervalRef.current) { clearInterval(refreshIntervalRef.current); refreshIntervalRef.current = null; } };
|
|
596
|
+
}, [loadEntitlements, state.entitlementActive, state.expiresAt, state.method, state.periodEnd, state.status, state.trialEndsAt, userId]);
|
|
597
|
+
|
|
598
|
+
// Initialize RevenueCat SDK
|
|
599
|
+
React.useEffect(() => {
|
|
600
|
+
const key = getPlatformRevenueCatApiKey();
|
|
601
|
+
if (!key || !userId || configuredRef.current === userId) return;
|
|
602
|
+
configuredRef.current = userId;
|
|
603
|
+
if (Platform.OS === 'web') {
|
|
604
|
+
PurchasesWeb.configure({ apiKey: key, appUserId: userId });
|
|
605
|
+
} else {
|
|
606
|
+
Purchases.configure({ apiKey: key, appUserID: userId });
|
|
607
|
+
}
|
|
608
|
+
setState(prev => ({ ...prev, status: 'loading' }));
|
|
609
|
+
void Promise.all([loadPricingAndOfferings(), loadEntitlements({ refresh: true })]);
|
|
610
|
+
|
|
611
|
+
if (Platform.OS === 'web') {
|
|
612
|
+
didSkipInitialVisibilityRef.current = false;
|
|
613
|
+
const handleVisibility = () => {
|
|
614
|
+
if (!didSkipInitialVisibilityRef.current) { didSkipInitialVisibilityRef.current = true; return; }
|
|
615
|
+
if (document.visibilityState === 'visible') void loadEntitlements({ refresh: false });
|
|
616
|
+
};
|
|
617
|
+
document.addEventListener('visibilitychange', handleVisibility);
|
|
618
|
+
return () => document.removeEventListener('visibilitychange', handleVisibility);
|
|
619
|
+
} else {
|
|
620
|
+
const listener = (_: CustomerInfo) => { void loadEntitlements({ refresh: false }); };
|
|
621
|
+
Purchases.addCustomerInfoUpdateListener(listener);
|
|
622
|
+
const appSub = AppState.addEventListener('change', s => { if (s === 'active') void loadEntitlements({ refresh: false }); });
|
|
623
|
+
return () => { Purchases.removeCustomerInfoUpdateListener(listener); appSub.remove(); };
|
|
624
|
+
}
|
|
625
|
+
}, [userId, loadPricingAndOfferings, loadEntitlements]);
|
|
626
|
+
|
|
627
|
+
const value = React.useMemo(() => ({ ...state, refresh, openPaywall }), [state, refresh, openPaywall]);
|
|
628
|
+
|
|
629
|
+
return <SubscriptionContext.Provider value={value}>{children}</SubscriptionContext.Provider>;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
export function useSubscriptionContext() {
|
|
633
|
+
const ctx = React.useContext(SubscriptionContext);
|
|
634
|
+
if (!ctx) throw new Error('useSubscriptionContext must be used within SubscriptionProvider');
|
|
635
|
+
return ctx;
|
|
636
|
+
}
|
|
637
|
+
`);
|
|
638
|
+
}
|
|
639
|
+
function writeUseSubscription(dir) {
|
|
640
|
+
writeFile(path.join(dir, 'hooks/useSubscription.ts'), `import { useCallback, useState } from 'react';
|
|
641
|
+
import { Linking, NativeModules, Platform } from 'react-native';
|
|
642
|
+
import Purchases from 'react-native-purchases';
|
|
643
|
+
import { useSubscriptionContext } from '@/contexts/SubscriptionProvider';
|
|
644
|
+
import { useAPI } from '@/hooks/useAPI';
|
|
645
|
+
import { APIRequestError } from '@/api/client';
|
|
646
|
+
|
|
647
|
+
export type RedeemError =
|
|
648
|
+
| 'invalid_code' | 'code_expired' | 'code_fully_used' | 'already_redeemed'
|
|
649
|
+
| 'redemption_failed' | 'offer_code_only' | 'offer_code_unavailable' | 'network_error';
|
|
650
|
+
|
|
651
|
+
const REDEEM_ERROR_MESSAGES: Record<RedeemError, string> = {
|
|
652
|
+
invalid_code: 'This code is not valid.',
|
|
653
|
+
code_expired: 'This code has expired.',
|
|
654
|
+
code_fully_used: 'This code has already been used.',
|
|
655
|
+
already_redeemed: 'You have already redeemed this code.',
|
|
656
|
+
redemption_failed: 'Failed to redeem code. Please try again.',
|
|
657
|
+
offer_code_only: 'Promo codes must be redeemed through the App Store on iOS.',
|
|
658
|
+
offer_code_unavailable: 'Offer code redemption requires a development build.',
|
|
659
|
+
network_error: 'Network error. Please check your connection.',
|
|
660
|
+
};
|
|
661
|
+
|
|
662
|
+
export function useSubscription() {
|
|
663
|
+
const ctx = useSubscriptionContext();
|
|
664
|
+
const api = useAPI();
|
|
665
|
+
const hasNativePurchases = Platform.OS !== 'web' && Boolean((NativeModules as { RNPurchases?: unknown }).RNPurchases);
|
|
666
|
+
|
|
667
|
+
const [redeemState, setRedeemState] = useState({
|
|
668
|
+
loading: false, error: null as RedeemError | null, errorMessage: null as string | null,
|
|
669
|
+
success: false, expiresAt: null as string | null,
|
|
670
|
+
});
|
|
671
|
+
|
|
672
|
+
const accessUntilMs = (() => {
|
|
673
|
+
const until = (ctx.method === 'trialing' ? (ctx.trialEndsAt ?? ctx.periodEnd) : (ctx.expiresAt ?? ctx.periodEnd)) ?? null;
|
|
674
|
+
if (!until) return null;
|
|
675
|
+
const t = new Date(until).getTime();
|
|
676
|
+
return Number.isFinite(t) ? t : null;
|
|
677
|
+
})();
|
|
678
|
+
|
|
679
|
+
const canAccess = ctx.entitlementActive && (
|
|
680
|
+
ctx.method === 'paid' || ctx.method === 'redeemed' || accessUntilMs == null || accessUntilMs > Date.now() + 1500
|
|
681
|
+
);
|
|
682
|
+
const isLoading = ctx.status === 'idle' || ctx.status === 'loading';
|
|
683
|
+
const isPaid = ctx.method === 'paid';
|
|
684
|
+
const isRedeemed = ctx.method === 'redeemed';
|
|
685
|
+
const isTrial = ctx.method === 'trialing' && ctx.inTrial;
|
|
686
|
+
const trialDaysRemaining = ctx.trialEndsAt
|
|
687
|
+
? Math.max(0, Math.ceil((new Date(ctx.trialEndsAt).getTime() - Date.now()) / (1000 * 60 * 60 * 24)))
|
|
688
|
+
: 0;
|
|
689
|
+
|
|
690
|
+
const managementUrl = (() => {
|
|
691
|
+
if (ctx.managementUrl) return ctx.managementUrl;
|
|
692
|
+
if (!ctx.entitlementActive) return null;
|
|
693
|
+
if (Platform.OS === 'ios') return 'https://apps.apple.com/account/subscriptions';
|
|
694
|
+
if (Platform.OS === 'android') return 'https://play.google.com/store/account/subscriptions';
|
|
695
|
+
return null;
|
|
696
|
+
})();
|
|
697
|
+
|
|
698
|
+
const subscribe = useCallback(async (plan?: 'monthly' | 'yearly') => {
|
|
699
|
+
await ctx.openPaywall(plan);
|
|
700
|
+
}, [ctx]);
|
|
701
|
+
|
|
702
|
+
const redeem = useCallback(async (code: string): Promise<boolean> => {
|
|
703
|
+
setRedeemState(s => ({ ...s, loading: true, error: null, errorMessage: null, success: false, expiresAt: null }));
|
|
704
|
+
if (Platform.OS === 'ios') {
|
|
705
|
+
setRedeemState(s => ({ ...s, loading: false, error: 'offer_code_only', errorMessage: REDEEM_ERROR_MESSAGES.offer_code_only }));
|
|
706
|
+
return false;
|
|
707
|
+
}
|
|
708
|
+
try {
|
|
709
|
+
try { await ctx.refresh(); } catch {}
|
|
710
|
+
const result = await api.redeemPromoCode(code.trim());
|
|
711
|
+
await ctx.refresh();
|
|
712
|
+
setRedeemState(s => ({ ...s, loading: false, success: true, expiresAt: result.expiresAt }));
|
|
713
|
+
return true;
|
|
714
|
+
} catch (err) {
|
|
715
|
+
const errorCode = err instanceof APIRequestError
|
|
716
|
+
? (err.errorCode as RedeemError) || 'redemption_failed'
|
|
717
|
+
: 'network_error';
|
|
718
|
+
setRedeemState(s => ({ ...s, loading: false, error: errorCode, errorMessage: REDEEM_ERROR_MESSAGES[errorCode] || 'Unknown error' }));
|
|
719
|
+
return false;
|
|
720
|
+
}
|
|
721
|
+
}, [api, ctx]);
|
|
722
|
+
|
|
723
|
+
const redeemOfferCode = useCallback(async (): Promise<boolean> => {
|
|
724
|
+
setRedeemState(s => ({ ...s, loading: true, error: null, errorMessage: null, success: false, expiresAt: null }));
|
|
725
|
+
if (Platform.OS !== 'ios') {
|
|
726
|
+
setRedeemState(s => ({ ...s, loading: false, error: 'offer_code_only', errorMessage: REDEEM_ERROR_MESSAGES.offer_code_only }));
|
|
727
|
+
return false;
|
|
728
|
+
}
|
|
729
|
+
if (!hasNativePurchases) {
|
|
730
|
+
setRedeemState(s => ({ ...s, loading: false, error: 'offer_code_unavailable', errorMessage: REDEEM_ERROR_MESSAGES.offer_code_unavailable }));
|
|
731
|
+
return false;
|
|
732
|
+
}
|
|
733
|
+
try {
|
|
734
|
+
await Purchases.presentCodeRedemptionSheet();
|
|
735
|
+
try { await Purchases.syncPurchases(); } catch {}
|
|
736
|
+
const wait = (ms: number) => new Promise<void>(r => setTimeout(r, ms));
|
|
737
|
+
let refreshed: Awaited<ReturnType<typeof api.getSubscriptionEntitlements>> | null = null;
|
|
738
|
+
for (let i = 0; i < 6; i++) {
|
|
739
|
+
refreshed = await api.getSubscriptionEntitlements({ refresh: true });
|
|
740
|
+
if (refreshed?.pro) break;
|
|
741
|
+
if (i < 5) await wait(1500);
|
|
742
|
+
}
|
|
743
|
+
const redeemed = Boolean(refreshed?.pro) && (refreshed?.method === 'redeemed' || refreshed?.method === 'paid');
|
|
744
|
+
await ctx.refresh();
|
|
745
|
+
setRedeemState(s => ({ ...s, loading: false, success: redeemed }));
|
|
746
|
+
return redeemed;
|
|
747
|
+
} catch {
|
|
748
|
+
setRedeemState(s => ({ ...s, loading: false, error: 'redemption_failed', errorMessage: REDEEM_ERROR_MESSAGES.redemption_failed }));
|
|
749
|
+
return false;
|
|
750
|
+
}
|
|
751
|
+
}, [api, ctx, hasNativePurchases]);
|
|
752
|
+
|
|
753
|
+
const resetRedeem = useCallback(() => {
|
|
754
|
+
setRedeemState({ loading: false, error: null, errorMessage: null, success: false, expiresAt: null });
|
|
755
|
+
}, []);
|
|
756
|
+
|
|
757
|
+
const manageSubscription = useCallback(async () => {
|
|
758
|
+
let url: string | null = managementUrl ?? null;
|
|
759
|
+
if (!url && Platform.OS === 'web' && ctx.entitlementActive) {
|
|
760
|
+
try { const r = await api.getSubscriptionEntitlements({ refresh: true }); url = r.managementUrl ?? null; } catch {}
|
|
761
|
+
}
|
|
762
|
+
if (!url) return;
|
|
763
|
+
if (Platform.OS === 'web') { window.open(url, '_blank'); return; }
|
|
764
|
+
await Linking.openURL(url);
|
|
765
|
+
}, [api, ctx.entitlementActive, managementUrl]);
|
|
766
|
+
|
|
767
|
+
return {
|
|
768
|
+
status: ctx.status, canAccess, isLoading, isPaid, isRedeemed, isTrial,
|
|
769
|
+
inTrial: ctx.inTrial, method: ctx.method, trialEndsAt: ctx.trialEndsAt,
|
|
770
|
+
trialDaysRemaining, expiresAt: ctx.expiresAt, periodStart: ctx.periodStart,
|
|
771
|
+
periodEnd: ctx.periodEnd, managementUrl, cancelAtPeriodEnd: ctx.cancelAtPeriodEnd,
|
|
772
|
+
pricing: ctx.pricing, offerings: ctx.offerings, lastError: ctx.lastError,
|
|
773
|
+
subscribe, redeem, redeemOfferCode, resetRedeem, refresh: ctx.refresh, manageSubscription,
|
|
774
|
+
redeemLoading: redeemState.loading, redeemError: redeemState.error,
|
|
775
|
+
redeemErrorMessage: redeemState.errorMessage, redeemSuccess: redeemState.success,
|
|
776
|
+
redeemExpiresAt: redeemState.expiresAt,
|
|
777
|
+
};
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
export type { SubscriptionState } from '@/contexts/SubscriptionProvider';
|
|
781
|
+
`);
|
|
782
|
+
}
|
|
783
|
+
function writeAppShellFiles(dir, displayName) {
|
|
784
|
+
writeFile(path.join(dir, 'app/_layout.tsx'), `import '@/global.css';
|
|
785
|
+
|
|
786
|
+
import { SubscriptionProvider } from '@/contexts/SubscriptionProvider';
|
|
787
|
+
import { NAV_THEME } from '@/lib/theme';
|
|
788
|
+
import { ClerkProvider, useAuth } from '@clerk/clerk-expo';
|
|
789
|
+
import { tokenCache } from '@clerk/clerk-expo/token-cache';
|
|
790
|
+
import { ThemeProvider } from '@react-navigation/native';
|
|
791
|
+
import { PortalHost } from '@rn-primitives/portal';
|
|
792
|
+
import { Stack } from 'expo-router';
|
|
793
|
+
import * as SplashScreen from 'expo-splash-screen';
|
|
794
|
+
import { StatusBar } from 'expo-status-bar';
|
|
795
|
+
import { useColorScheme } from 'nativewind';
|
|
796
|
+
import * as React from 'react';
|
|
797
|
+
|
|
798
|
+
export { ErrorBoundary } from 'expo-router';
|
|
799
|
+
|
|
800
|
+
export default function RootLayout() {
|
|
801
|
+
const { colorScheme } = useColorScheme();
|
|
802
|
+
|
|
803
|
+
return (
|
|
804
|
+
<ClerkProvider tokenCache={tokenCache}>
|
|
805
|
+
<ThemeProvider value={NAV_THEME[colorScheme ?? 'light']}>
|
|
806
|
+
<SubscriptionProvider>
|
|
807
|
+
<StatusBar style={colorScheme === 'dark' ? 'light' : 'dark'} />
|
|
808
|
+
<Routes />
|
|
809
|
+
<PortalHost />
|
|
810
|
+
</SubscriptionProvider>
|
|
811
|
+
</ThemeProvider>
|
|
812
|
+
</ClerkProvider>
|
|
813
|
+
);
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
SplashScreen.preventAutoHideAsync();
|
|
817
|
+
|
|
818
|
+
function Routes() {
|
|
819
|
+
const { isSignedIn, isLoaded } = useAuth();
|
|
820
|
+
|
|
821
|
+
React.useEffect(() => {
|
|
822
|
+
if (isLoaded) {
|
|
823
|
+
SplashScreen.hideAsync();
|
|
824
|
+
}
|
|
825
|
+
}, [isLoaded]);
|
|
826
|
+
|
|
827
|
+
if (!isLoaded) {
|
|
828
|
+
return null;
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
return (
|
|
832
|
+
<Stack>
|
|
833
|
+
<Stack.Screen name="index" options={{ headerShown: false }} />
|
|
834
|
+
|
|
835
|
+
<Stack.Protected guard={!isSignedIn}>
|
|
836
|
+
<Stack.Screen name="(auth)/sign-in" options={{ headerShown: false, title: 'Sign in' }} />
|
|
837
|
+
<Stack.Screen name="(auth)/sign-up" options={{ presentation: 'modal', title: '', headerTransparent: true, gestureEnabled: false }} />
|
|
838
|
+
<Stack.Screen name="(auth)/reset-password" options={{ title: '', headerShadowVisible: false, headerTransparent: true }} />
|
|
839
|
+
<Stack.Screen name="(auth)/forgot-password" options={{ title: '', headerShadowVisible: false, headerTransparent: true }} />
|
|
840
|
+
</Stack.Protected>
|
|
841
|
+
|
|
842
|
+
<Stack.Protected guard={isSignedIn}>
|
|
843
|
+
<Stack.Screen name="(app)/dashboard" options={{ headerShown: false }} />
|
|
844
|
+
</Stack.Protected>
|
|
845
|
+
</Stack>
|
|
846
|
+
);
|
|
847
|
+
}
|
|
848
|
+
`);
|
|
849
|
+
writeFile(path.join(dir, 'app/index.tsx'), `import { ThemeToggle } from '@/components/theme-toggle';
|
|
850
|
+
import { Button } from '@/components/ui/button';
|
|
851
|
+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
|
852
|
+
import { Text } from '@/components/ui/text';
|
|
853
|
+
import { useAuth } from '@clerk/clerk-expo';
|
|
854
|
+
import { Link } from 'expo-router';
|
|
855
|
+
import { CloudIcon, CreditCardIcon, ShieldCheckIcon, DatabaseIcon } from 'lucide-react-native';
|
|
856
|
+
import { ScrollView, View } from 'react-native';
|
|
857
|
+
|
|
858
|
+
const FEATURES = [
|
|
859
|
+
{
|
|
860
|
+
title: 'Landing page here',
|
|
861
|
+
description: 'Replace this placeholder copy with the actual acquisition story for your mobile and web app.',
|
|
862
|
+
icon: CloudIcon,
|
|
863
|
+
},
|
|
864
|
+
{
|
|
865
|
+
title: 'Clerk custom auth',
|
|
866
|
+
description: 'Custom sign-in and sign-up screens are already wired into the Expo starter.',
|
|
867
|
+
icon: ShieldCheckIcon,
|
|
868
|
+
},
|
|
869
|
+
{
|
|
870
|
+
title: 'RevenueCat subscriptions',
|
|
871
|
+
description: 'The signed-in shell already knows how to open a RevenueCat paywall and management flow.',
|
|
872
|
+
icon: CreditCardIcon,
|
|
873
|
+
},
|
|
874
|
+
{
|
|
875
|
+
title: 'Neon + Drizzle + R2',
|
|
876
|
+
description: 'Backend routes for profile data, credits, and avatar upload are scaffolded for you.',
|
|
877
|
+
icon: DatabaseIcon,
|
|
878
|
+
},
|
|
879
|
+
];
|
|
880
|
+
|
|
881
|
+
export default function LandingScreen() {
|
|
882
|
+
const { isSignedIn } = useAuth();
|
|
883
|
+
|
|
884
|
+
return (
|
|
885
|
+
<ScrollView className="flex-1 bg-background" contentContainerClassName="min-h-full px-4 pb-10 pt-6 sm:px-6">
|
|
886
|
+
<View className="mx-auto flex w-full max-w-5xl gap-6">
|
|
887
|
+
<View className="flex-row items-center justify-between rounded-[28px] border border-border/70 bg-card px-4 py-3">
|
|
888
|
+
<View>
|
|
889
|
+
<Text className="text-xs uppercase tracking-[3px] text-muted-foreground">anhedral crossplatform</Text>
|
|
890
|
+
<Text className="mt-1 text-lg font-semibold">${displayName}</Text>
|
|
891
|
+
</View>
|
|
892
|
+
<View className="flex-row items-center gap-2">
|
|
893
|
+
<ThemeToggle />
|
|
894
|
+
{isSignedIn ? (
|
|
895
|
+
<Link href="/(app)/dashboard" asChild>
|
|
896
|
+
<Button size="sm">
|
|
897
|
+
<Text>Open dashboard</Text>
|
|
898
|
+
</Button>
|
|
899
|
+
</Link>
|
|
900
|
+
) : (
|
|
901
|
+
<>
|
|
902
|
+
<Link href="/(auth)/sign-in" asChild>
|
|
903
|
+
<Button size="sm" variant="ghost">
|
|
904
|
+
<Text>Sign in</Text>
|
|
905
|
+
</Button>
|
|
906
|
+
</Link>
|
|
907
|
+
<Link href="/(auth)/sign-up" asChild>
|
|
908
|
+
<Button size="sm">
|
|
909
|
+
<Text>Get started</Text>
|
|
910
|
+
</Button>
|
|
911
|
+
</Link>
|
|
912
|
+
</>
|
|
913
|
+
)}
|
|
914
|
+
</View>
|
|
915
|
+
</View>
|
|
916
|
+
|
|
917
|
+
<Card className="rounded-[32px] border-border/70 bg-card">
|
|
918
|
+
<CardHeader className="gap-4 px-6 pt-8 sm:px-8">
|
|
919
|
+
<Text className="text-xs uppercase tracking-[3px] text-muted-foreground">Starter shell</Text>
|
|
920
|
+
<CardTitle className="text-left text-4xl leading-tight sm:text-5xl">
|
|
921
|
+
Landing page here. Replace the story, keep the plumbing.
|
|
922
|
+
</CardTitle>
|
|
923
|
+
<CardDescription className="max-w-3xl text-base leading-7">
|
|
924
|
+
This crossplatform starter already gives you a real auth flow, a protected dashboard, RevenueCat subscription plumbing, seeded credits, and avatar upload to R2.
|
|
925
|
+
</CardDescription>
|
|
926
|
+
</CardHeader>
|
|
927
|
+
<CardContent className="gap-3 px-6 pb-8 sm:px-8">
|
|
928
|
+
{isSignedIn ? (
|
|
929
|
+
<Link href="/(app)/dashboard" asChild>
|
|
930
|
+
<Button className="self-start">
|
|
931
|
+
<Text>Continue to the app</Text>
|
|
932
|
+
</Button>
|
|
933
|
+
</Link>
|
|
934
|
+
) : (
|
|
935
|
+
<Link href="/(auth)/sign-up" asChild>
|
|
936
|
+
<Button className="self-start">
|
|
937
|
+
<Text>Create an account</Text>
|
|
938
|
+
</Button>
|
|
939
|
+
</Link>
|
|
940
|
+
)}
|
|
941
|
+
</CardContent>
|
|
942
|
+
</Card>
|
|
943
|
+
|
|
944
|
+
<View className="gap-3 sm:flex-row sm:flex-wrap">
|
|
945
|
+
{FEATURES.map(({ title, description, icon: Icon }) => (
|
|
946
|
+
<Card key={title} className="flex-1 rounded-[24px] border-border/70 bg-card sm:min-w-[240px]">
|
|
947
|
+
<CardHeader className="px-5 pt-5">
|
|
948
|
+
<View className="mb-2 size-11 items-center justify-center rounded-2xl bg-muted">
|
|
949
|
+
<Icon size={20} color="currentColor" />
|
|
950
|
+
</View>
|
|
951
|
+
<CardTitle className="text-left text-xl">{title}</CardTitle>
|
|
952
|
+
</CardHeader>
|
|
953
|
+
<CardContent className="px-5 pb-5">
|
|
954
|
+
<Text className="text-sm leading-6 text-muted-foreground">{description}</Text>
|
|
955
|
+
</CardContent>
|
|
956
|
+
</Card>
|
|
957
|
+
))}
|
|
958
|
+
</View>
|
|
959
|
+
</View>
|
|
960
|
+
</ScrollView>
|
|
961
|
+
);
|
|
962
|
+
}
|
|
963
|
+
`);
|
|
964
|
+
writeFile(path.join(dir, 'app/(app)/dashboard.tsx'), `import { ThemeToggle } from '@/components/theme-toggle';
|
|
965
|
+
import { UserMenu } from '@/components/user-menu';
|
|
966
|
+
import { Button } from '@/components/ui/button';
|
|
967
|
+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
|
968
|
+
import { Text } from '@/components/ui/text';
|
|
969
|
+
import { useAccount } from '@/hooks/useAccount';
|
|
970
|
+
import { useSubscription } from '@/hooks/useSubscription';
|
|
971
|
+
import { Stack } from 'expo-router';
|
|
972
|
+
import { CoinsIcon, CreditCardIcon, HardDriveUploadIcon, SparklesIcon } from 'lucide-react-native';
|
|
973
|
+
import * as React from 'react';
|
|
974
|
+
import { RefreshControl, ScrollView, View } from 'react-native';
|
|
975
|
+
|
|
976
|
+
export default function DashboardScreen() {
|
|
977
|
+
const { account, loading, error, refresh } = useAccount();
|
|
978
|
+
const subscription = useSubscription();
|
|
979
|
+
|
|
980
|
+
const subscriptionLabel = React.useMemo(() => {
|
|
981
|
+
if (subscription.isPaid) return 'RevenueCat Pro';
|
|
982
|
+
if (subscription.isTrial) return \`Trial • \${subscription.trialDaysRemaining} days left\`;
|
|
983
|
+
if (subscription.isRedeemed) return 'Promo access';
|
|
984
|
+
if (subscription.canAccess) return 'Access active';
|
|
985
|
+
return 'Free';
|
|
986
|
+
}, [subscription.canAccess, subscription.isPaid, subscription.isRedeemed, subscription.isTrial, subscription.trialDaysRemaining]);
|
|
987
|
+
|
|
988
|
+
const subscriptionStatus = React.useMemo(() => {
|
|
989
|
+
if (subscription.isPaid || subscription.isRedeemed) {
|
|
990
|
+
return subscription.cancelAtPeriodEnd ? 'Cancels at period end' : 'Active';
|
|
991
|
+
}
|
|
992
|
+
if (subscription.isTrial) return 'Trialing';
|
|
993
|
+
return 'Setup required';
|
|
994
|
+
}, [subscription.cancelAtPeriodEnd, subscription.isPaid, subscription.isRedeemed, subscription.isTrial]);
|
|
995
|
+
|
|
996
|
+
const runSubscriptionAction = React.useCallback(async () => {
|
|
997
|
+
if (subscription.managementUrl) {
|
|
998
|
+
await subscription.manageSubscription();
|
|
999
|
+
return;
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
await subscription.subscribe('monthly');
|
|
1003
|
+
}, [subscription]);
|
|
1004
|
+
|
|
1005
|
+
return (
|
|
1006
|
+
<>
|
|
1007
|
+
<Stack.Screen
|
|
1008
|
+
options={{
|
|
1009
|
+
header: () => (
|
|
1010
|
+
<View className="top-safe flex-row items-center justify-between bg-background px-4 py-3">
|
|
1011
|
+
<ThemeToggle />
|
|
1012
|
+
<UserMenu />
|
|
1013
|
+
</View>
|
|
1014
|
+
),
|
|
1015
|
+
}}
|
|
1016
|
+
/>
|
|
1017
|
+
|
|
1018
|
+
<ScrollView
|
|
1019
|
+
className="flex-1 bg-background"
|
|
1020
|
+
contentContainerClassName="px-4 pb-10 pt-4 sm:px-6"
|
|
1021
|
+
refreshControl={<RefreshControl refreshing={loading} onRefresh={() => void refresh()} />}>
|
|
1022
|
+
<View className="mx-auto w-full max-w-5xl gap-4">
|
|
1023
|
+
<Card className="rounded-[28px] border-border/70 bg-card">
|
|
1024
|
+
<CardHeader className="gap-4 px-6 pt-8 sm:px-8">
|
|
1025
|
+
<View className="flex-row items-center gap-2">
|
|
1026
|
+
<SparklesIcon size={18} color="currentColor" />
|
|
1027
|
+
<Text className="text-xs uppercase tracking-[3px] text-muted-foreground">Signed-in shell</Text>
|
|
1028
|
+
</View>
|
|
1029
|
+
<CardTitle className="text-left text-4xl leading-tight">
|
|
1030
|
+
{account?.displayName ? \`Make \${account.displayName} yours.\` : 'Make this app yours.'}
|
|
1031
|
+
</CardTitle>
|
|
1032
|
+
<CardDescription className="max-w-3xl text-base leading-7">
|
|
1033
|
+
This screen is the production starter: subscription state, credits, and avatar upload are already in place so your team can move straight to product work.
|
|
1034
|
+
</CardDescription>
|
|
1035
|
+
</CardHeader>
|
|
1036
|
+
<CardContent className="flex-row flex-wrap gap-3 px-6 pb-8 sm:px-8">
|
|
1037
|
+
<Button onPress={() => void runSubscriptionAction()}>
|
|
1038
|
+
<Text>{subscription.managementUrl ? 'Manage subscription' : 'Unlock pro'}</Text>
|
|
1039
|
+
</Button>
|
|
1040
|
+
<Button variant="outline" onPress={() => void refresh()}>
|
|
1041
|
+
<Text>Refresh profile</Text>
|
|
1042
|
+
</Button>
|
|
1043
|
+
</CardContent>
|
|
1044
|
+
</Card>
|
|
1045
|
+
|
|
1046
|
+
<View className="gap-3 sm:flex-row sm:flex-wrap">
|
|
1047
|
+
<Card className="flex-1 rounded-[24px] border-border/70 bg-card sm:min-w-[240px]">
|
|
1048
|
+
<CardHeader className="px-5 pt-5">
|
|
1049
|
+
<View className="mb-2 size-11 items-center justify-center rounded-2xl bg-muted">
|
|
1050
|
+
<CreditCardIcon size={20} color="currentColor" />
|
|
1051
|
+
</View>
|
|
1052
|
+
<CardTitle className="text-left text-xl">{subscriptionLabel}</CardTitle>
|
|
1053
|
+
</CardHeader>
|
|
1054
|
+
<CardContent className="gap-2 px-5 pb-5">
|
|
1055
|
+
<Text className="text-sm text-muted-foreground">{subscriptionStatus}</Text>
|
|
1056
|
+
{subscription.expiresAt ? (
|
|
1057
|
+
<Text className="text-sm text-muted-foreground">Access until {new Date(subscription.expiresAt).toLocaleDateString()}</Text>
|
|
1058
|
+
) : null}
|
|
1059
|
+
</CardContent>
|
|
1060
|
+
</Card>
|
|
1061
|
+
|
|
1062
|
+
<Card className="flex-1 rounded-[24px] border-border/70 bg-card sm:min-w-[240px]">
|
|
1063
|
+
<CardHeader className="px-5 pt-5">
|
|
1064
|
+
<View className="mb-2 size-11 items-center justify-center rounded-2xl bg-muted">
|
|
1065
|
+
<CoinsIcon size={20} color="currentColor" />
|
|
1066
|
+
</View>
|
|
1067
|
+
<CardTitle className="text-left text-xl">{account?.creditsBalance ?? 250} credits</CardTitle>
|
|
1068
|
+
</CardHeader>
|
|
1069
|
+
<CardContent className="px-5 pb-5">
|
|
1070
|
+
<Text className="text-sm leading-6 text-muted-foreground">
|
|
1071
|
+
Credits are seeded in the backend user record so you can connect real usage accounting later without redesigning the shell.
|
|
1072
|
+
</Text>
|
|
1073
|
+
</CardContent>
|
|
1074
|
+
</Card>
|
|
1075
|
+
|
|
1076
|
+
<Card className="flex-1 rounded-[24px] border-border/70 bg-card sm:min-w-[240px]">
|
|
1077
|
+
<CardHeader className="px-5 pt-5">
|
|
1078
|
+
<View className="mb-2 size-11 items-center justify-center rounded-2xl bg-muted">
|
|
1079
|
+
<HardDriveUploadIcon size={20} color="currentColor" />
|
|
1080
|
+
</View>
|
|
1081
|
+
<CardTitle className="text-left text-xl">Avatar upload</CardTitle>
|
|
1082
|
+
</CardHeader>
|
|
1083
|
+
<CardContent className="px-5 pb-5">
|
|
1084
|
+
<Text className="text-sm leading-6 text-muted-foreground">
|
|
1085
|
+
The header menu uploads profile photos into R2 and swaps the avatar immediately when the upload returns.
|
|
1086
|
+
</Text>
|
|
1087
|
+
</CardContent>
|
|
1088
|
+
</Card>
|
|
1089
|
+
</View>
|
|
1090
|
+
|
|
1091
|
+
{error ? (
|
|
1092
|
+
<Card className="rounded-[24px] border-amber-300/60 bg-amber-50 dark:bg-amber-500/10">
|
|
1093
|
+
<CardContent className="px-5 py-5">
|
|
1094
|
+
<Text className="text-sm leading-6 text-amber-900 dark:text-amber-100">
|
|
1095
|
+
Backend profile data is not ready yet: {error}
|
|
1096
|
+
</Text>
|
|
1097
|
+
</CardContent>
|
|
1098
|
+
</Card>
|
|
1099
|
+
) : null}
|
|
1100
|
+
</View>
|
|
1101
|
+
</ScrollView>
|
|
1102
|
+
</>
|
|
1103
|
+
);
|
|
1104
|
+
}
|
|
1105
|
+
`);
|
|
1106
|
+
writeFile(path.join(dir, 'app/(auth)/sign-in.tsx'), `import { SignInForm } from '@/components/sign-in-form';
|
|
1107
|
+
import { ThemeToggle } from '@/components/theme-toggle';
|
|
1108
|
+
import { Text } from '@/components/ui/text';
|
|
1109
|
+
import { Link } from 'expo-router';
|
|
1110
|
+
import { ScrollView, View } from 'react-native';
|
|
1111
|
+
|
|
1112
|
+
export default function SignInScreen() {
|
|
1113
|
+
return (
|
|
1114
|
+
<ScrollView
|
|
1115
|
+
keyboardShouldPersistTaps="handled"
|
|
1116
|
+
contentContainerClassName="min-h-full bg-background px-4 pb-10 pt-6 sm:px-6"
|
|
1117
|
+
keyboardDismissMode="interactive">
|
|
1118
|
+
<View className="mx-auto w-full max-w-5xl gap-6">
|
|
1119
|
+
<View className="flex-row items-center justify-between rounded-[28px] border border-border/70 bg-card px-4 py-3">
|
|
1120
|
+
<View>
|
|
1121
|
+
<Text className="text-xs uppercase tracking-[3px] text-muted-foreground">Clerk custom auth</Text>
|
|
1122
|
+
<Text className="mt-1 text-lg font-semibold">${displayName}</Text>
|
|
1123
|
+
</View>
|
|
1124
|
+
<ThemeToggle />
|
|
1125
|
+
</View>
|
|
1126
|
+
|
|
1127
|
+
<View className="gap-4 sm:flex-row">
|
|
1128
|
+
<View className="flex-1 rounded-[28px] border border-border/70 bg-card px-6 py-8">
|
|
1129
|
+
<Text className="text-xs uppercase tracking-[3px] text-muted-foreground">Sign in</Text>
|
|
1130
|
+
<Text className="mt-4 text-4xl font-semibold leading-tight">Return to the app shell without rebuilding auth from scratch.</Text>
|
|
1131
|
+
<Text className="mt-4 text-base leading-7 text-muted-foreground">
|
|
1132
|
+
The starter already connects Clerk, RevenueCat subscription state, credits, and R2 avatar upload. This screen is here so your team starts from a real flow instead of a blank form.
|
|
1133
|
+
</Text>
|
|
1134
|
+
<Link href="/" className="mt-6 text-sm underline underline-offset-4">
|
|
1135
|
+
Back to landing
|
|
1136
|
+
</Link>
|
|
1137
|
+
</View>
|
|
1138
|
+
|
|
1139
|
+
<View className="w-full sm:max-w-md">
|
|
1140
|
+
<SignInForm />
|
|
1141
|
+
</View>
|
|
1142
|
+
</View>
|
|
1143
|
+
</View>
|
|
1144
|
+
</ScrollView>
|
|
1145
|
+
);
|
|
1146
|
+
}
|
|
1147
|
+
`);
|
|
1148
|
+
writeFile(path.join(dir, 'app/(auth)/sign-up/index.tsx'), `import { SignUpForm } from '@/components/sign-up-form';
|
|
1149
|
+
import { ThemeToggle } from '@/components/theme-toggle';
|
|
1150
|
+
import { Text } from '@/components/ui/text';
|
|
1151
|
+
import { ScrollView, View } from 'react-native';
|
|
1152
|
+
|
|
1153
|
+
export default function SignUpScreen() {
|
|
1154
|
+
return (
|
|
1155
|
+
<ScrollView
|
|
1156
|
+
keyboardShouldPersistTaps="handled"
|
|
1157
|
+
contentContainerClassName="min-h-full bg-background px-4 pb-10 pt-6 sm:px-6"
|
|
1158
|
+
keyboardDismissMode="interactive">
|
|
1159
|
+
<View className="mx-auto w-full max-w-5xl gap-6">
|
|
1160
|
+
<View className="flex-row items-center justify-between rounded-[28px] border border-border/70 bg-card px-4 py-3">
|
|
1161
|
+
<View>
|
|
1162
|
+
<Text className="text-xs uppercase tracking-[3px] text-muted-foreground">Starter onboarding</Text>
|
|
1163
|
+
<Text className="mt-1 text-lg font-semibold">${displayName}</Text>
|
|
1164
|
+
</View>
|
|
1165
|
+
<ThemeToggle />
|
|
1166
|
+
</View>
|
|
1167
|
+
|
|
1168
|
+
<View className="gap-4 sm:flex-row">
|
|
1169
|
+
<View className="w-full sm:max-w-md sm:order-2">
|
|
1170
|
+
<SignUpForm />
|
|
1171
|
+
</View>
|
|
1172
|
+
|
|
1173
|
+
<View className="flex-1 rounded-[28px] border border-border/70 bg-card px-6 py-8 sm:order-1">
|
|
1174
|
+
<Text className="text-xs uppercase tracking-[3px] text-muted-foreground">Create account</Text>
|
|
1175
|
+
<Text className="mt-4 text-4xl font-semibold leading-tight">Get users into the signed-in product shell immediately.</Text>
|
|
1176
|
+
<Text className="mt-4 text-base leading-7 text-muted-foreground">
|
|
1177
|
+
New accounts land in a dashboard that already exposes RevenueCat subscription state, credits, and an R2 avatar flow.
|
|
1178
|
+
</Text>
|
|
1179
|
+
</View>
|
|
1180
|
+
</View>
|
|
1181
|
+
</View>
|
|
1182
|
+
</ScrollView>
|
|
1183
|
+
);
|
|
1184
|
+
}
|
|
1185
|
+
`);
|
|
1186
|
+
writeFile(path.join(dir, 'components/sign-in-form.tsx'), `import { SocialConnections } from '@/components/social-connections';
|
|
1187
|
+
import { Button } from '@/components/ui/button';
|
|
1188
|
+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
|
1189
|
+
import { Input } from '@/components/ui/input';
|
|
1190
|
+
import { Label } from '@/components/ui/label';
|
|
1191
|
+
import { Separator } from '@/components/ui/separator';
|
|
1192
|
+
import { Text } from '@/components/ui/text';
|
|
1193
|
+
import { useSignIn } from '@clerk/clerk-expo';
|
|
1194
|
+
import { Link } from 'expo-router';
|
|
1195
|
+
import * as React from 'react';
|
|
1196
|
+
import { type TextInput, View } from 'react-native';
|
|
1197
|
+
|
|
1198
|
+
export function SignInForm() {
|
|
1199
|
+
const { signIn, setActive, isLoaded } = useSignIn();
|
|
1200
|
+
const [email, setEmail] = React.useState('');
|
|
1201
|
+
const [password, setPassword] = React.useState('');
|
|
1202
|
+
const passwordInputRef = React.useRef<TextInput>(null);
|
|
1203
|
+
const [error, setError] = React.useState<{ email?: string; password?: string }>({});
|
|
1204
|
+
|
|
1205
|
+
async function onSubmit() {
|
|
1206
|
+
if (!isLoaded) return;
|
|
1207
|
+
|
|
1208
|
+
try {
|
|
1209
|
+
const signInAttempt = await signIn.create({
|
|
1210
|
+
identifier: email,
|
|
1211
|
+
password,
|
|
1212
|
+
});
|
|
1213
|
+
|
|
1214
|
+
if (signInAttempt.status === 'complete') {
|
|
1215
|
+
setError({ email: '', password: '' });
|
|
1216
|
+
await setActive({ session: signInAttempt.createdSessionId });
|
|
1217
|
+
return;
|
|
1218
|
+
}
|
|
1219
|
+
} catch (err) {
|
|
1220
|
+
if (err instanceof Error) {
|
|
1221
|
+
const message = err.message;
|
|
1222
|
+
const isEmailMessage = message.toLowerCase().includes('identifier') || message.toLowerCase().includes('email');
|
|
1223
|
+
setError(isEmailMessage ? { email: message } : { password: message });
|
|
1224
|
+
}
|
|
1225
|
+
}
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
return (
|
|
1229
|
+
<View className="gap-6">
|
|
1230
|
+
<Card className="rounded-[28px] border-border/70 bg-card shadow-sm shadow-black/5">
|
|
1231
|
+
<CardHeader className="px-6 pt-8">
|
|
1232
|
+
<CardTitle className="text-center text-2xl sm:text-left">Sign in to keep building</CardTitle>
|
|
1233
|
+
<CardDescription className="text-center sm:text-left">
|
|
1234
|
+
Use your existing account to return to the starter dashboard.
|
|
1235
|
+
</CardDescription>
|
|
1236
|
+
</CardHeader>
|
|
1237
|
+
<CardContent className="gap-6 px-6 pb-8">
|
|
1238
|
+
<View className="gap-4">
|
|
1239
|
+
<View className="gap-1.5">
|
|
1240
|
+
<Label htmlFor="email">Email</Label>
|
|
1241
|
+
<Input
|
|
1242
|
+
id="email"
|
|
1243
|
+
placeholder="you@example.com"
|
|
1244
|
+
keyboardType="email-address"
|
|
1245
|
+
autoComplete="email"
|
|
1246
|
+
autoCapitalize="none"
|
|
1247
|
+
onChangeText={setEmail}
|
|
1248
|
+
onSubmitEditing={() => passwordInputRef.current?.focus()}
|
|
1249
|
+
returnKeyType="next"
|
|
1250
|
+
submitBehavior="submit"
|
|
1251
|
+
/>
|
|
1252
|
+
{error.email ? <Text className="text-sm font-medium text-destructive">{error.email}</Text> : null}
|
|
1253
|
+
</View>
|
|
1254
|
+
|
|
1255
|
+
<View className="gap-1.5">
|
|
1256
|
+
<View className="flex-row items-center">
|
|
1257
|
+
<Label htmlFor="password">Password</Label>
|
|
1258
|
+
<Link asChild href={\`/(auth)/forgot-password?email=\${email}\`}>
|
|
1259
|
+
<Button variant="link" size="sm" className="ml-auto h-4 px-1 py-0 web:h-fit sm:h-4">
|
|
1260
|
+
<Text className="font-normal leading-4">Forgot password?</Text>
|
|
1261
|
+
</Button>
|
|
1262
|
+
</Link>
|
|
1263
|
+
</View>
|
|
1264
|
+
<Input
|
|
1265
|
+
ref={passwordInputRef}
|
|
1266
|
+
id="password"
|
|
1267
|
+
secureTextEntry
|
|
1268
|
+
onChangeText={setPassword}
|
|
1269
|
+
returnKeyType="send"
|
|
1270
|
+
onSubmitEditing={onSubmit}
|
|
1271
|
+
/>
|
|
1272
|
+
{error.password ? <Text className="text-sm font-medium text-destructive">{error.password}</Text> : null}
|
|
1273
|
+
</View>
|
|
1274
|
+
|
|
1275
|
+
<Button className="w-full" onPress={onSubmit}>
|
|
1276
|
+
<Text>Continue</Text>
|
|
1277
|
+
</Button>
|
|
1278
|
+
</View>
|
|
1279
|
+
|
|
1280
|
+
<Text className="text-center text-sm">
|
|
1281
|
+
Don't have an account?{' '}
|
|
1282
|
+
<Link href="/(auth)/sign-up" className="text-sm underline underline-offset-4">
|
|
1283
|
+
Sign up
|
|
1284
|
+
</Link>
|
|
1285
|
+
</Text>
|
|
1286
|
+
|
|
1287
|
+
<View className="flex-row items-center">
|
|
1288
|
+
<Separator className="flex-1" />
|
|
1289
|
+
<Text className="px-4 text-sm text-muted-foreground">or</Text>
|
|
1290
|
+
<Separator className="flex-1" />
|
|
1291
|
+
</View>
|
|
1292
|
+
|
|
1293
|
+
<SocialConnections />
|
|
1294
|
+
</CardContent>
|
|
1295
|
+
</Card>
|
|
1296
|
+
</View>
|
|
1297
|
+
);
|
|
1298
|
+
}
|
|
1299
|
+
`);
|
|
1300
|
+
writeFile(path.join(dir, 'components/sign-up-form.tsx'), `import { SocialConnections } from '@/components/social-connections';
|
|
1301
|
+
import { Button } from '@/components/ui/button';
|
|
1302
|
+
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
|
1303
|
+
import { Input } from '@/components/ui/input';
|
|
1304
|
+
import { Label } from '@/components/ui/label';
|
|
1305
|
+
import { Separator } from '@/components/ui/separator';
|
|
1306
|
+
import { Text } from '@/components/ui/text';
|
|
1307
|
+
import { useSignUp } from '@clerk/clerk-expo';
|
|
1308
|
+
import { Link, router } from 'expo-router';
|
|
1309
|
+
import * as React from 'react';
|
|
1310
|
+
import { TextInput, View } from 'react-native';
|
|
1311
|
+
|
|
1312
|
+
export function SignUpForm() {
|
|
1313
|
+
const { signUp, isLoaded } = useSignUp();
|
|
1314
|
+
const [email, setEmail] = React.useState('');
|
|
1315
|
+
const [password, setPassword] = React.useState('');
|
|
1316
|
+
const passwordInputRef = React.useRef<TextInput>(null);
|
|
1317
|
+
const [error, setError] = React.useState<{ email?: string; password?: string }>({});
|
|
1318
|
+
|
|
1319
|
+
async function onSubmit() {
|
|
1320
|
+
if (!isLoaded) return;
|
|
1321
|
+
|
|
1322
|
+
try {
|
|
1323
|
+
await signUp.create({
|
|
1324
|
+
emailAddress: email,
|
|
1325
|
+
password,
|
|
1326
|
+
});
|
|
1327
|
+
await signUp.prepareEmailAddressVerification({ strategy: 'email_code' });
|
|
1328
|
+
router.push(\`/(auth)/sign-up/verify-email?email=\${email}\`);
|
|
1329
|
+
} catch (err) {
|
|
1330
|
+
if (err instanceof Error) {
|
|
1331
|
+
const message = err.message;
|
|
1332
|
+
const isEmailMessage = message.toLowerCase().includes('identifier') || message.toLowerCase().includes('email');
|
|
1333
|
+
setError(isEmailMessage ? { email: message } : { password: message });
|
|
1334
|
+
}
|
|
1335
|
+
}
|
|
1336
|
+
}
|
|
1337
|
+
|
|
1338
|
+
return (
|
|
1339
|
+
<View className="gap-6">
|
|
1340
|
+
<Card className="rounded-[28px] border-border/70 bg-card shadow-sm shadow-black/5">
|
|
1341
|
+
<CardHeader className="px-6 pt-8">
|
|
1342
|
+
<CardTitle className="text-center text-2xl sm:text-left">Create your account</CardTitle>
|
|
1343
|
+
<CardDescription className="text-center sm:text-left">
|
|
1344
|
+
Start with a real signed-in shell instead of another empty Expo project.
|
|
1345
|
+
</CardDescription>
|
|
1346
|
+
</CardHeader>
|
|
1347
|
+
<CardContent className="gap-6 px-6 pb-8">
|
|
1348
|
+
<View className="gap-4">
|
|
1349
|
+
<View className="gap-1.5">
|
|
1350
|
+
<Label htmlFor="email">Email</Label>
|
|
1351
|
+
<Input
|
|
1352
|
+
id="email"
|
|
1353
|
+
placeholder="you@example.com"
|
|
1354
|
+
keyboardType="email-address"
|
|
1355
|
+
autoComplete="email"
|
|
1356
|
+
autoCapitalize="none"
|
|
1357
|
+
onChangeText={setEmail}
|
|
1358
|
+
onSubmitEditing={() => passwordInputRef.current?.focus()}
|
|
1359
|
+
returnKeyType="next"
|
|
1360
|
+
submitBehavior="submit"
|
|
1361
|
+
/>
|
|
1362
|
+
{error.email ? <Text className="text-sm font-medium text-destructive">{error.email}</Text> : null}
|
|
1363
|
+
</View>
|
|
1364
|
+
|
|
1365
|
+
<View className="gap-1.5">
|
|
1366
|
+
<Label htmlFor="password">Password</Label>
|
|
1367
|
+
<Input
|
|
1368
|
+
ref={passwordInputRef}
|
|
1369
|
+
id="password"
|
|
1370
|
+
secureTextEntry
|
|
1371
|
+
onChangeText={setPassword}
|
|
1372
|
+
returnKeyType="send"
|
|
1373
|
+
onSubmitEditing={onSubmit}
|
|
1374
|
+
/>
|
|
1375
|
+
{error.password ? <Text className="text-sm font-medium text-destructive">{error.password}</Text> : null}
|
|
1376
|
+
</View>
|
|
1377
|
+
|
|
1378
|
+
<Button className="w-full" onPress={onSubmit}>
|
|
1379
|
+
<Text>Continue</Text>
|
|
1380
|
+
</Button>
|
|
1381
|
+
</View>
|
|
1382
|
+
|
|
1383
|
+
<Text className="text-center text-sm">
|
|
1384
|
+
Already have an account?{' '}
|
|
1385
|
+
<Link href="/(auth)/sign-in" dismissTo className="text-sm underline underline-offset-4">
|
|
1386
|
+
Sign in
|
|
1387
|
+
</Link>
|
|
1388
|
+
</Text>
|
|
1389
|
+
|
|
1390
|
+
<View className="flex-row items-center">
|
|
1391
|
+
<Separator className="flex-1" />
|
|
1392
|
+
<Text className="px-4 text-sm text-muted-foreground">or</Text>
|
|
1393
|
+
<Separator className="flex-1" />
|
|
1394
|
+
</View>
|
|
1395
|
+
|
|
1396
|
+
<SocialConnections />
|
|
1397
|
+
</CardContent>
|
|
1398
|
+
</Card>
|
|
1399
|
+
</View>
|
|
1400
|
+
);
|
|
1401
|
+
}
|
|
1402
|
+
`);
|
|
1403
|
+
writeFile(path.join(dir, 'components/user-menu.tsx'), `import * as ImagePicker from 'expo-image-picker';
|
|
1404
|
+
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
|
1405
|
+
import { Button } from '@/components/ui/button';
|
|
1406
|
+
import { Icon } from '@/components/ui/icon';
|
|
1407
|
+
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
|
1408
|
+
import { Text } from '@/components/ui/text';
|
|
1409
|
+
import { useAccount } from '@/hooks/useAccount';
|
|
1410
|
+
import { useSubscription } from '@/hooks/useSubscription';
|
|
1411
|
+
import { useAuth } from '@clerk/clerk-expo';
|
|
1412
|
+
import type { TriggerRef } from '@rn-primitives/popover';
|
|
1413
|
+
import { CameraIcon, CreditCardIcon, CoinsIcon, LoaderCircleIcon, LogOutIcon } from 'lucide-react-native';
|
|
1414
|
+
import * as React from 'react';
|
|
1415
|
+
import { Alert, Platform, View } from 'react-native';
|
|
1416
|
+
|
|
1417
|
+
function showNotice(title: string, message: string) {
|
|
1418
|
+
if (Platform.OS === 'web' && typeof window !== 'undefined') {
|
|
1419
|
+
window.alert(\`\${title}\\n\\n\${message}\`);
|
|
1420
|
+
return;
|
|
1421
|
+
}
|
|
1422
|
+
|
|
1423
|
+
Alert.alert(title, message);
|
|
1424
|
+
}
|
|
1425
|
+
|
|
1426
|
+
export function UserMenu() {
|
|
1427
|
+
const { signOut } = useAuth();
|
|
1428
|
+
const { account, refresh, uploadAvatar } = useAccount();
|
|
1429
|
+
const subscription = useSubscription();
|
|
1430
|
+
const popoverTriggerRef = React.useRef<TriggerRef>(null);
|
|
1431
|
+
const [uploading, setUploading] = React.useState(false);
|
|
1432
|
+
|
|
1433
|
+
const subscriptionLabel = subscription.isPaid
|
|
1434
|
+
? 'RevenueCat Pro'
|
|
1435
|
+
: subscription.isTrial
|
|
1436
|
+
? 'Trial'
|
|
1437
|
+
: subscription.isRedeemed
|
|
1438
|
+
? 'Promo'
|
|
1439
|
+
: 'Free';
|
|
1440
|
+
|
|
1441
|
+
async function onSignOut() {
|
|
1442
|
+
popoverTriggerRef.current?.close();
|
|
1443
|
+
await signOut();
|
|
1444
|
+
}
|
|
1445
|
+
|
|
1446
|
+
async function onUploadAvatar() {
|
|
1447
|
+
popoverTriggerRef.current?.close();
|
|
1448
|
+
const permission = await ImagePicker.requestMediaLibraryPermissionsAsync();
|
|
1449
|
+
if (!permission.granted) {
|
|
1450
|
+
showNotice('Permission required', 'Allow photo library access to upload an avatar.');
|
|
1451
|
+
return;
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1454
|
+
const result = await ImagePicker.launchImageLibraryAsync({
|
|
1455
|
+
mediaTypes: ImagePicker.MediaTypeOptions.Images,
|
|
1456
|
+
allowsEditing: true,
|
|
1457
|
+
aspect: [1, 1],
|
|
1458
|
+
quality: 0.85,
|
|
1459
|
+
base64: true,
|
|
1460
|
+
});
|
|
1461
|
+
|
|
1462
|
+
if (result.canceled || !result.assets[0]) return;
|
|
1463
|
+
|
|
1464
|
+
const asset = result.assets[0];
|
|
1465
|
+
if (!asset.base64) {
|
|
1466
|
+
showNotice('Upload failed', 'Could not read the selected image.');
|
|
1467
|
+
return;
|
|
1468
|
+
}
|
|
1469
|
+
|
|
1470
|
+
setUploading(true);
|
|
1471
|
+
try {
|
|
1472
|
+
await uploadAvatar({
|
|
1473
|
+
base64: asset.base64,
|
|
1474
|
+
mimeType: asset.mimeType ?? 'image/jpeg',
|
|
1475
|
+
fileName: asset.fileName ?? 'avatar.jpg',
|
|
1476
|
+
});
|
|
1477
|
+
await refresh();
|
|
1478
|
+
showNotice('Avatar updated', 'Your profile photo is now stored in R2.');
|
|
1479
|
+
} catch (err) {
|
|
1480
|
+
showNotice('Upload failed', err instanceof Error ? err.message : 'Avatar upload failed.');
|
|
1481
|
+
} finally {
|
|
1482
|
+
setUploading(false);
|
|
1483
|
+
}
|
|
1484
|
+
}
|
|
1485
|
+
|
|
1486
|
+
async function onSubscriptionAction() {
|
|
1487
|
+
popoverTriggerRef.current?.close();
|
|
1488
|
+
if (subscription.managementUrl) {
|
|
1489
|
+
await subscription.manageSubscription();
|
|
1490
|
+
return;
|
|
1491
|
+
}
|
|
1492
|
+
|
|
1493
|
+
await subscription.subscribe('monthly');
|
|
1494
|
+
}
|
|
1495
|
+
|
|
1496
|
+
const avatarSource = account?.avatarUrl || account?.imageUrl;
|
|
1497
|
+
const userName = account?.displayName || account?.email || 'Builder';
|
|
1498
|
+
const initials = userName
|
|
1499
|
+
.split(' ')
|
|
1500
|
+
.map((name) => name[0])
|
|
1501
|
+
.join('')
|
|
1502
|
+
.slice(0, 2)
|
|
1503
|
+
.toUpperCase();
|
|
1504
|
+
|
|
1505
|
+
return (
|
|
1506
|
+
<Popover>
|
|
1507
|
+
<PopoverTrigger asChild ref={popoverTriggerRef}>
|
|
1508
|
+
<Button variant="ghost" size="icon" className="size-9 rounded-full">
|
|
1509
|
+
<Avatar className="size-9" alt={userName}>
|
|
1510
|
+
<AvatarImage source={avatarSource ? { uri: avatarSource } : undefined} />
|
|
1511
|
+
<AvatarFallback>
|
|
1512
|
+
<Text>{initials}</Text>
|
|
1513
|
+
</AvatarFallback>
|
|
1514
|
+
</Avatar>
|
|
1515
|
+
</Button>
|
|
1516
|
+
</PopoverTrigger>
|
|
1517
|
+
|
|
1518
|
+
<PopoverContent align="end" side="bottom" className="w-80 gap-0 p-0">
|
|
1519
|
+
<View className="gap-4 border-b border-border p-4">
|
|
1520
|
+
<View className="flex-row items-center gap-3">
|
|
1521
|
+
<Avatar className="size-12" alt={userName}>
|
|
1522
|
+
<AvatarImage source={avatarSource ? { uri: avatarSource } : undefined} />
|
|
1523
|
+
<AvatarFallback>
|
|
1524
|
+
<Text>{initials}</Text>
|
|
1525
|
+
</AvatarFallback>
|
|
1526
|
+
</Avatar>
|
|
1527
|
+
<View className="flex-1">
|
|
1528
|
+
<Text className="font-medium leading-5">{userName}</Text>
|
|
1529
|
+
<Text className="text-sm text-muted-foreground">{account?.email ?? 'Loading account...'}</Text>
|
|
1530
|
+
</View>
|
|
1531
|
+
</View>
|
|
1532
|
+
|
|
1533
|
+
<View className="flex-row gap-3">
|
|
1534
|
+
<View className="flex-1 rounded-2xl border border-border bg-muted/40 p-3">
|
|
1535
|
+
<View className="flex-row items-center gap-2">
|
|
1536
|
+
<CreditCardIcon size={16} color="currentColor" />
|
|
1537
|
+
<Text className="text-xs uppercase tracking-[2px] text-muted-foreground">Subscription</Text>
|
|
1538
|
+
</View>
|
|
1539
|
+
<Text className="mt-3 font-semibold">{subscriptionLabel}</Text>
|
|
1540
|
+
<Text className="text-sm text-muted-foreground">
|
|
1541
|
+
{subscription.isTrial ? \`\${subscription.trialDaysRemaining} days left\` : (account?.subscriptionStatus ?? 'active')}
|
|
1542
|
+
</Text>
|
|
1543
|
+
</View>
|
|
1544
|
+
|
|
1545
|
+
<View className="flex-1 rounded-2xl border border-border bg-muted/40 p-3">
|
|
1546
|
+
<View className="flex-row items-center gap-2">
|
|
1547
|
+
<CoinsIcon size={16} color="currentColor" />
|
|
1548
|
+
<Text className="text-xs uppercase tracking-[2px] text-muted-foreground">Credits</Text>
|
|
1549
|
+
</View>
|
|
1550
|
+
<Text className="mt-3 font-semibold">{account?.creditsBalance ?? 250}</Text>
|
|
1551
|
+
<Text className="text-sm text-muted-foreground">Starter balance</Text>
|
|
1552
|
+
</View>
|
|
1553
|
+
</View>
|
|
1554
|
+
</View>
|
|
1555
|
+
|
|
1556
|
+
<View className="gap-2 p-3">
|
|
1557
|
+
<Button variant="outline" onPress={() => void onSubscriptionAction()}>
|
|
1558
|
+
<Icon as={CreditCardIcon} className="size-4" />
|
|
1559
|
+
<Text>{subscription.managementUrl ? 'Manage subscription' : 'Open paywall'}</Text>
|
|
1560
|
+
</Button>
|
|
1561
|
+
|
|
1562
|
+
<Button variant="outline" onPress={() => void onUploadAvatar()} disabled={uploading}>
|
|
1563
|
+
<Icon as={uploading ? LoaderCircleIcon : CameraIcon} className={uploading ? 'size-4 animate-spin' : 'size-4'} />
|
|
1564
|
+
<Text>{uploading ? 'Uploading avatar...' : 'Upload avatar to R2'}</Text>
|
|
1565
|
+
</Button>
|
|
1566
|
+
|
|
1567
|
+
<Button variant="outline" onPress={() => void onSignOut()}>
|
|
1568
|
+
<Icon as={LogOutIcon} className="size-4" />
|
|
1569
|
+
<Text>Sign out</Text>
|
|
1570
|
+
</Button>
|
|
1571
|
+
</View>
|
|
1572
|
+
</PopoverContent>
|
|
1573
|
+
</Popover>
|
|
1574
|
+
);
|
|
1575
|
+
}
|
|
1576
|
+
`);
|
|
1577
|
+
}
|
|
1578
|
+
function writeEnvExample(dir) {
|
|
1579
|
+
writeFile(path.join(dir, '.env.example'), `# Clerk
|
|
1580
|
+
EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_***
|
|
1581
|
+
|
|
1582
|
+
# Backend API URL
|
|
1583
|
+
EXPO_PUBLIC_API_URL=http://localhost:8787
|
|
1584
|
+
|
|
1585
|
+
# RevenueCat
|
|
1586
|
+
EXPO_PUBLIC_RC_ENTITLEMENT_ID=pro
|
|
1587
|
+
EXPO_PUBLIC_RC_API_KEY_IOS=appl_***
|
|
1588
|
+
EXPO_PUBLIC_RC_API_KEY_ANDROID=goog_***
|
|
1589
|
+
EXPO_PUBLIC_RC_WEB_API_KEY=rcb_***
|
|
1590
|
+
`);
|
|
1591
|
+
}
|
|
1592
|
+
function writeEnvFile(dir) {
|
|
1593
|
+
writeFile(path.join(dir, '.env'), `# Clerk
|
|
1594
|
+
EXPO_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_demo_placeholder
|
|
1595
|
+
|
|
1596
|
+
# Backend API URL
|
|
1597
|
+
EXPO_PUBLIC_API_URL=http://localhost:8787
|
|
1598
|
+
|
|
1599
|
+
# RevenueCat
|
|
1600
|
+
EXPO_PUBLIC_RC_ENTITLEMENT_ID=pro
|
|
1601
|
+
EXPO_PUBLIC_RC_API_KEY_IOS=appl_demo_placeholder
|
|
1602
|
+
EXPO_PUBLIC_RC_API_KEY_ANDROID=goog_demo_placeholder
|
|
1603
|
+
EXPO_PUBLIC_RC_WEB_API_KEY=rcb_demo_placeholder
|
|
1604
|
+
`);
|
|
1605
|
+
}
|