anhedral 0.1.6 → 0.1.8
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/README.md +120 -155
- package/dist/dependencies.d.ts +2 -0
- package/dist/dependencies.js +12 -0
- package/dist/scaffold.js +158 -116
- package/dist/templates/backend.js +135 -234
- package/dist/templates/frontend.js +117 -411
- package/package.json +1 -1
|
@@ -41,16 +41,7 @@ export async function scaffoldFrontend(root, { projectName, displayName, skipIns
|
|
|
41
41
|
writeAnhedralLogo(dir);
|
|
42
42
|
writeSubscriptionProvider(dir);
|
|
43
43
|
writeUseSubscription(dir);
|
|
44
|
-
|
|
45
|
-
if (skipInstall) {
|
|
46
|
-
anhedralPrint.info('Skipping Expo native package install (--skip-install)');
|
|
47
|
-
anhedralPrint.info('Run after init: pnpm --filter ./Frontend exec expo install --pnpm expo-image-picker');
|
|
48
|
-
}
|
|
49
|
-
else {
|
|
50
|
-
exec('pnpm exec expo install --pnpm expo-image-picker', dir);
|
|
51
|
-
exec('pnpm exec expo install --fix --pnpm', dir);
|
|
52
|
-
}
|
|
53
|
-
anhedralPrint.done(skipInstall ? 'Expo native package install skipped' : 'Expo native packages installed');
|
|
44
|
+
removeUnusedFrontendFiles(dir);
|
|
54
45
|
anhedralPrint.step('Installing RevenueCat SDKs');
|
|
55
46
|
const revenueCatDependencies = withVersions({
|
|
56
47
|
'react-native-purchases': FRONTEND_ADDON_DEPENDENCIES['react-native-purchases'],
|
|
@@ -76,6 +67,8 @@ function patchFrontendPackageJson(dir) {
|
|
|
76
67
|
'react-native-purchases-ui': FRONTEND_ADDON_DEPENDENCIES['react-native-purchases-ui'],
|
|
77
68
|
'@revenuecat/purchases-js': FRONTEND_ADDON_DEPENDENCIES['@revenuecat/purchases-js'],
|
|
78
69
|
};
|
|
70
|
+
delete packageJson.dependencies['@rn-primitives/avatar'];
|
|
71
|
+
delete packageJson.dependencies['expo-image-picker'];
|
|
79
72
|
packageJson.scripts = {
|
|
80
73
|
...(packageJson.scripts ?? {}),
|
|
81
74
|
build: 'pnpm typecheck && pnpm build:web',
|
|
@@ -84,6 +77,12 @@ function patchFrontendPackageJson(dir) {
|
|
|
84
77
|
};
|
|
85
78
|
writeFile(filePath, JSON.stringify(packageJson, null, 2) + '\n');
|
|
86
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
|
+
}
|
|
87
86
|
function patchFrontendTsConfig(dir) {
|
|
88
87
|
const filePath = path.join(dir, 'tsconfig.json');
|
|
89
88
|
if (!fs.existsSync(filePath)) {
|
|
@@ -120,10 +119,6 @@ export class APIClient extends ApiClient {
|
|
|
120
119
|
constructor(baseUrl: string, getToken: () => Promise<string | null>) {
|
|
121
120
|
super({ baseUrl, getToken, platform: 'frontend' });
|
|
122
121
|
}
|
|
123
|
-
|
|
124
|
-
redeemPromoCode(code: string) {
|
|
125
|
-
return this.redeemCode(code);
|
|
126
|
-
}
|
|
127
122
|
}
|
|
128
123
|
|
|
129
124
|
export { APIRequestError };
|
|
@@ -251,10 +246,6 @@ export type AccountSummary = {
|
|
|
251
246
|
lastName?: string | null;
|
|
252
247
|
displayName: string;
|
|
253
248
|
imageUrl?: string | null;
|
|
254
|
-
avatarUrl?: string | null;
|
|
255
|
-
creditsBalance: number;
|
|
256
|
-
subscriptionTier: string;
|
|
257
|
-
subscriptionStatus: string;
|
|
258
249
|
};
|
|
259
250
|
|
|
260
251
|
export function useAccount() {
|
|
@@ -290,22 +281,11 @@ export function useAccount() {
|
|
|
290
281
|
void refresh();
|
|
291
282
|
}, [refresh]);
|
|
292
283
|
|
|
293
|
-
const uploadAvatar = React.useCallback(async (input: {
|
|
294
|
-
base64: string;
|
|
295
|
-
mimeType: string;
|
|
296
|
-
fileName?: string;
|
|
297
|
-
}) => {
|
|
298
|
-
const response = await api.uploadAvatar(input);
|
|
299
|
-
setAccount((prev) => prev ? { ...prev, avatarUrl: response.avatarUrl } : prev);
|
|
300
|
-
return response;
|
|
301
|
-
}, [api]);
|
|
302
|
-
|
|
303
284
|
return {
|
|
304
285
|
account,
|
|
305
286
|
loading,
|
|
306
287
|
error,
|
|
307
288
|
refresh,
|
|
308
|
-
uploadAvatar,
|
|
309
289
|
};
|
|
310
290
|
}
|
|
311
291
|
`);
|
|
@@ -593,37 +573,14 @@ export function useSubscriptionContext() {
|
|
|
593
573
|
`);
|
|
594
574
|
}
|
|
595
575
|
function writeUseSubscription(dir) {
|
|
596
|
-
writeFile(path.join(dir, 'hooks/useSubscription.ts'), `import { useCallback
|
|
597
|
-
import { Linking,
|
|
598
|
-
import Purchases from 'react-native-purchases';
|
|
576
|
+
writeFile(path.join(dir, 'hooks/useSubscription.ts'), `import { useCallback } from 'react';
|
|
577
|
+
import { Linking, Platform } from 'react-native';
|
|
599
578
|
import { useSubscriptionContext } from '@/contexts/SubscriptionProvider';
|
|
600
579
|
import { useAPI } from '@/hooks/useAPI';
|
|
601
|
-
import { APIRequestError } from '@/api/client';
|
|
602
|
-
|
|
603
|
-
export type RedeemError =
|
|
604
|
-
| 'invalid_code' | 'code_expired' | 'code_fully_used' | 'already_redeemed'
|
|
605
|
-
| 'redemption_failed' | 'offer_code_only' | 'offer_code_unavailable' | 'network_error';
|
|
606
|
-
|
|
607
|
-
const REDEEM_ERROR_MESSAGES: Record<RedeemError, string> = {
|
|
608
|
-
invalid_code: 'This code is not valid.',
|
|
609
|
-
code_expired: 'This code has expired.',
|
|
610
|
-
code_fully_used: 'This code has already been used.',
|
|
611
|
-
already_redeemed: 'You have already redeemed this code.',
|
|
612
|
-
redemption_failed: 'Failed to redeem code. Please try again.',
|
|
613
|
-
offer_code_only: 'Promo codes must be redeemed through the App Store on iOS.',
|
|
614
|
-
offer_code_unavailable: 'Offer code redemption requires a development build.',
|
|
615
|
-
network_error: 'Network error. Please check your connection.',
|
|
616
|
-
};
|
|
617
580
|
|
|
618
581
|
export function useSubscription() {
|
|
619
582
|
const ctx = useSubscriptionContext();
|
|
620
583
|
const api = useAPI();
|
|
621
|
-
const hasNativePurchases = Platform.OS !== 'web' && Boolean((NativeModules as { RNPurchases?: unknown }).RNPurchases);
|
|
622
|
-
|
|
623
|
-
const [redeemState, setRedeemState] = useState({
|
|
624
|
-
loading: false, error: null as RedeemError | null, errorMessage: null as string | null,
|
|
625
|
-
success: false, expiresAt: null as string | null,
|
|
626
|
-
});
|
|
627
584
|
|
|
628
585
|
const accessUntilMs = (() => {
|
|
629
586
|
const until = (ctx.method === 'trialing' ? (ctx.trialEndsAt ?? ctx.periodEnd) : (ctx.expiresAt ?? ctx.periodEnd)) ?? null;
|
|
@@ -655,61 +612,6 @@ export function useSubscription() {
|
|
|
655
612
|
await ctx.openPaywall(plan);
|
|
656
613
|
}, [ctx]);
|
|
657
614
|
|
|
658
|
-
const redeem = useCallback(async (code: string): Promise<boolean> => {
|
|
659
|
-
setRedeemState(s => ({ ...s, loading: true, error: null, errorMessage: null, success: false, expiresAt: null }));
|
|
660
|
-
if (Platform.OS === 'ios') {
|
|
661
|
-
setRedeemState(s => ({ ...s, loading: false, error: 'offer_code_only', errorMessage: REDEEM_ERROR_MESSAGES.offer_code_only }));
|
|
662
|
-
return false;
|
|
663
|
-
}
|
|
664
|
-
try {
|
|
665
|
-
try { await ctx.refresh(); } catch {}
|
|
666
|
-
const result = await api.redeemPromoCode(code.trim());
|
|
667
|
-
await ctx.refresh();
|
|
668
|
-
setRedeemState(s => ({ ...s, loading: false, success: true, expiresAt: result.expiresAt }));
|
|
669
|
-
return true;
|
|
670
|
-
} catch (err) {
|
|
671
|
-
const errorCode = err instanceof APIRequestError
|
|
672
|
-
? (err.errorCode as RedeemError) || 'redemption_failed'
|
|
673
|
-
: 'network_error';
|
|
674
|
-
setRedeemState(s => ({ ...s, loading: false, error: errorCode, errorMessage: REDEEM_ERROR_MESSAGES[errorCode] || 'Unknown error' }));
|
|
675
|
-
return false;
|
|
676
|
-
}
|
|
677
|
-
}, [api, ctx]);
|
|
678
|
-
|
|
679
|
-
const redeemOfferCode = useCallback(async (): Promise<boolean> => {
|
|
680
|
-
setRedeemState(s => ({ ...s, loading: true, error: null, errorMessage: null, success: false, expiresAt: null }));
|
|
681
|
-
if (Platform.OS !== 'ios') {
|
|
682
|
-
setRedeemState(s => ({ ...s, loading: false, error: 'offer_code_only', errorMessage: REDEEM_ERROR_MESSAGES.offer_code_only }));
|
|
683
|
-
return false;
|
|
684
|
-
}
|
|
685
|
-
if (!hasNativePurchases) {
|
|
686
|
-
setRedeemState(s => ({ ...s, loading: false, error: 'offer_code_unavailable', errorMessage: REDEEM_ERROR_MESSAGES.offer_code_unavailable }));
|
|
687
|
-
return false;
|
|
688
|
-
}
|
|
689
|
-
try {
|
|
690
|
-
await Purchases.presentCodeRedemptionSheet();
|
|
691
|
-
try { await Purchases.syncPurchases(); } catch {}
|
|
692
|
-
const wait = (ms: number) => new Promise<void>(r => setTimeout(r, ms));
|
|
693
|
-
let refreshed: Awaited<ReturnType<typeof api.getSubscriptionEntitlements>> | null = null;
|
|
694
|
-
for (let i = 0; i < 6; i++) {
|
|
695
|
-
refreshed = await api.getSubscriptionEntitlements({ refresh: true });
|
|
696
|
-
if (refreshed?.pro) break;
|
|
697
|
-
if (i < 5) await wait(1500);
|
|
698
|
-
}
|
|
699
|
-
const redeemed = Boolean(refreshed?.pro) && (refreshed?.method === 'redeemed' || refreshed?.method === 'paid');
|
|
700
|
-
await ctx.refresh();
|
|
701
|
-
setRedeemState(s => ({ ...s, loading: false, success: redeemed }));
|
|
702
|
-
return redeemed;
|
|
703
|
-
} catch {
|
|
704
|
-
setRedeemState(s => ({ ...s, loading: false, error: 'redemption_failed', errorMessage: REDEEM_ERROR_MESSAGES.redemption_failed }));
|
|
705
|
-
return false;
|
|
706
|
-
}
|
|
707
|
-
}, [api, ctx, hasNativePurchases]);
|
|
708
|
-
|
|
709
|
-
const resetRedeem = useCallback(() => {
|
|
710
|
-
setRedeemState({ loading: false, error: null, errorMessage: null, success: false, expiresAt: null });
|
|
711
|
-
}, []);
|
|
712
|
-
|
|
713
615
|
const manageSubscription = useCallback(async () => {
|
|
714
616
|
let url: string | null = managementUrl ?? null;
|
|
715
617
|
if (!url && Platform.OS === 'web' && ctx.entitlementActive) {
|
|
@@ -726,10 +628,7 @@ export function useSubscription() {
|
|
|
726
628
|
trialDaysRemaining, expiresAt: ctx.expiresAt, periodStart: ctx.periodStart,
|
|
727
629
|
periodEnd: ctx.periodEnd, managementUrl, cancelAtPeriodEnd: ctx.cancelAtPeriodEnd,
|
|
728
630
|
pricing: ctx.pricing, offerings: ctx.offerings, lastError: ctx.lastError,
|
|
729
|
-
subscribe,
|
|
730
|
-
redeemLoading: redeemState.loading, redeemError: redeemState.error,
|
|
731
|
-
redeemErrorMessage: redeemState.errorMessage, redeemSuccess: redeemState.success,
|
|
732
|
-
redeemExpiresAt: redeemState.expiresAt,
|
|
631
|
+
subscribe, refresh: ctx.refresh, manageSubscription,
|
|
733
632
|
};
|
|
734
633
|
}
|
|
735
634
|
|
|
@@ -797,7 +696,7 @@ function Routes() {
|
|
|
797
696
|
</Stack.Protected>
|
|
798
697
|
|
|
799
698
|
<Stack.Protected guard={isSignedIn}>
|
|
800
|
-
<Stack.Screen name="(app)/
|
|
699
|
+
<Stack.Screen name="(app)/system" options={{ headerShown: false }} />
|
|
801
700
|
</Stack.Protected>
|
|
802
701
|
</Stack>
|
|
803
702
|
);
|
|
@@ -805,128 +704,69 @@ function Routes() {
|
|
|
805
704
|
`);
|
|
806
705
|
writeFile(path.join(dir, 'app/index.tsx'), `import { ThemeToggle } from '@/components/theme-toggle';
|
|
807
706
|
import { Button } from '@/components/ui/button';
|
|
808
|
-
import { Card, CardContent,
|
|
707
|
+
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|
809
708
|
import { Text } from '@/components/ui/text';
|
|
810
709
|
import { useAuth } from '@clerk/expo';
|
|
811
710
|
import { Link } from 'expo-router';
|
|
812
|
-
import { CloudIcon, CreditCardIcon, ShieldCheckIcon, DatabaseIcon } from 'lucide-react-native';
|
|
813
711
|
import { ScrollView, View } from 'react-native';
|
|
814
712
|
|
|
815
|
-
|
|
816
|
-
{
|
|
817
|
-
title: 'Landing page here',
|
|
818
|
-
description: 'Replace this placeholder copy with the actual acquisition story for your Expo app.',
|
|
819
|
-
icon: CloudIcon,
|
|
820
|
-
},
|
|
821
|
-
{
|
|
822
|
-
title: 'Clerk custom auth',
|
|
823
|
-
description: 'Custom sign-in and sign-up screens are already wired into the Expo starter.',
|
|
824
|
-
icon: ShieldCheckIcon,
|
|
825
|
-
},
|
|
826
|
-
{
|
|
827
|
-
title: 'RevenueCat subscriptions',
|
|
828
|
-
description: 'The signed-in shell already knows how to open a RevenueCat paywall and management flow.',
|
|
829
|
-
icon: CreditCardIcon,
|
|
830
|
-
},
|
|
831
|
-
{
|
|
832
|
-
title: 'Neon + Drizzle + R2',
|
|
833
|
-
description: 'Backend routes for profile data, credits, and avatar upload are scaffolded for you.',
|
|
834
|
-
icon: DatabaseIcon,
|
|
835
|
-
},
|
|
836
|
-
];
|
|
837
|
-
|
|
838
|
-
export default function LandingScreen() {
|
|
713
|
+
export default function HomeScreen() {
|
|
839
714
|
const { isSignedIn } = useAuth();
|
|
840
715
|
|
|
841
716
|
return (
|
|
842
717
|
<ScrollView className="flex-1 bg-background" contentContainerClassName="min-h-full px-4 pb-10 pt-6 sm:px-6">
|
|
843
|
-
<View className="mx-auto
|
|
844
|
-
<View className="flex-row items-center justify-between
|
|
845
|
-
<
|
|
846
|
-
|
|
847
|
-
<Text className="mt-1 text-lg font-semibold">${displayName}</Text>
|
|
848
|
-
</View>
|
|
849
|
-
<View className="flex-row items-center gap-2">
|
|
850
|
-
<ThemeToggle />
|
|
851
|
-
{isSignedIn ? (
|
|
852
|
-
<Link href="/(app)/dashboard" asChild>
|
|
853
|
-
<Button size="sm">
|
|
854
|
-
<Text>Open dashboard</Text>
|
|
855
|
-
</Button>
|
|
856
|
-
</Link>
|
|
857
|
-
) : (
|
|
858
|
-
<>
|
|
859
|
-
<Link href="/(auth)/sign-in" asChild>
|
|
860
|
-
<Button size="sm" variant="ghost">
|
|
861
|
-
<Text>Sign in</Text>
|
|
862
|
-
</Button>
|
|
863
|
-
</Link>
|
|
864
|
-
<Link href="/(auth)/sign-up" asChild>
|
|
865
|
-
<Button size="sm">
|
|
866
|
-
<Text>Get started</Text>
|
|
867
|
-
</Button>
|
|
868
|
-
</Link>
|
|
869
|
-
</>
|
|
870
|
-
)}
|
|
871
|
-
</View>
|
|
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 />
|
|
872
722
|
</View>
|
|
873
723
|
|
|
874
|
-
<Card className="
|
|
875
|
-
<CardHeader
|
|
876
|
-
<
|
|
877
|
-
<CardTitle className="text-left text-4xl leading-tight sm:text-5xl">
|
|
878
|
-
Landing page here. Replace the story, keep the plumbing.
|
|
879
|
-
</CardTitle>
|
|
880
|
-
<CardDescription className="max-w-3xl text-base leading-7">
|
|
881
|
-
This crossplatform starter already gives you a real auth flow, a protected dashboard, RevenueCat subscription plumbing, seeded credits, and avatar upload to R2.
|
|
882
|
-
</CardDescription>
|
|
724
|
+
<Card className="border-border bg-card">
|
|
725
|
+
<CardHeader>
|
|
726
|
+
<CardTitle className="text-left text-2xl">Application foundation ready</CardTitle>
|
|
883
727
|
</CardHeader>
|
|
884
|
-
<CardContent className="gap-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
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>
|
|
898
754
|
</CardContent>
|
|
899
755
|
</Card>
|
|
900
|
-
|
|
901
|
-
<View className="gap-3 sm:flex-row sm:flex-wrap">
|
|
902
|
-
{FEATURES.map(({ title, description, icon: Icon }) => (
|
|
903
|
-
<Card key={title} className="flex-1 rounded-[24px] border-border/70 bg-card sm:min-w-[240px]">
|
|
904
|
-
<CardHeader className="px-5 pt-5">
|
|
905
|
-
<View className="mb-2 size-11 items-center justify-center rounded-2xl bg-muted">
|
|
906
|
-
<Icon size={20} color="currentColor" />
|
|
907
|
-
</View>
|
|
908
|
-
<CardTitle className="text-left text-xl">{title}</CardTitle>
|
|
909
|
-
</CardHeader>
|
|
910
|
-
<CardContent className="px-5 pb-5">
|
|
911
|
-
<Text className="text-sm leading-6 text-muted-foreground">{description}</Text>
|
|
912
|
-
</CardContent>
|
|
913
|
-
</Card>
|
|
914
|
-
))}
|
|
915
|
-
</View>
|
|
916
756
|
</View>
|
|
917
757
|
</ScrollView>
|
|
918
758
|
);
|
|
919
759
|
}
|
|
920
760
|
`);
|
|
921
|
-
writeFile(path.join(dir, 'app/(app)/
|
|
761
|
+
writeFile(path.join(dir, 'app/(app)/system.tsx'), `import { ThemeToggle } from '@/components/theme-toggle';
|
|
922
762
|
import { UserMenu } from '@/components/user-menu';
|
|
923
763
|
import { Button } from '@/components/ui/button';
|
|
924
|
-
import { Card, CardContent,
|
|
764
|
+
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|
925
765
|
import { Text } from '@/components/ui/text';
|
|
926
766
|
import { useAccount } from '@/hooks/useAccount';
|
|
927
767
|
import { useSubscription } from '@/hooks/useSubscription';
|
|
928
768
|
import { Stack } from 'expo-router';
|
|
929
|
-
import {
|
|
769
|
+
import { CheckCircleIcon, CircleAlertIcon, CreditCardIcon, DatabaseIcon } from 'lucide-react-native';
|
|
930
770
|
import * as React from 'react';
|
|
931
771
|
import { RefreshControl, ScrollView, View } from 'react-native';
|
|
932
772
|
|
|
@@ -935,21 +775,13 @@ export default function DashboardScreen() {
|
|
|
935
775
|
const subscription = useSubscription();
|
|
936
776
|
|
|
937
777
|
const subscriptionLabel = React.useMemo(() => {
|
|
938
|
-
if (subscription.isPaid) return '
|
|
939
|
-
if (subscription.isTrial) return \`Trial
|
|
940
|
-
if (subscription.isRedeemed) return '
|
|
941
|
-
if (subscription.canAccess) return '
|
|
942
|
-
return '
|
|
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';
|
|
943
783
|
}, [subscription.canAccess, subscription.isPaid, subscription.isRedeemed, subscription.isTrial, subscription.trialDaysRemaining]);
|
|
944
784
|
|
|
945
|
-
const subscriptionStatus = React.useMemo(() => {
|
|
946
|
-
if (subscription.isPaid || subscription.isRedeemed) {
|
|
947
|
-
return subscription.cancelAtPeriodEnd ? 'Cancels at period end' : 'Active';
|
|
948
|
-
}
|
|
949
|
-
if (subscription.isTrial) return 'Trialing';
|
|
950
|
-
return 'Setup required';
|
|
951
|
-
}, [subscription.cancelAtPeriodEnd, subscription.isPaid, subscription.isRedeemed, subscription.isTrial]);
|
|
952
|
-
|
|
953
785
|
const runSubscriptionAction = React.useCallback(async () => {
|
|
954
786
|
if (subscription.managementUrl) {
|
|
955
787
|
await subscription.manageSubscription();
|
|
@@ -976,71 +808,59 @@ export default function DashboardScreen() {
|
|
|
976
808
|
className="flex-1 bg-background"
|
|
977
809
|
contentContainerClassName="px-4 pb-10 pt-4 sm:px-6"
|
|
978
810
|
refreshControl={<RefreshControl refreshing={loading} onRefresh={() => void refresh()} />}>
|
|
979
|
-
<View className="mx-auto w-full max-w-
|
|
980
|
-
<Card className="
|
|
981
|
-
<CardHeader
|
|
982
|
-
<
|
|
983
|
-
<SparklesIcon size={18} color="currentColor" />
|
|
984
|
-
<Text className="text-xs uppercase tracking-[3px] text-muted-foreground">Signed-in shell</Text>
|
|
985
|
-
</View>
|
|
986
|
-
<CardTitle className="text-left text-4xl leading-tight">
|
|
987
|
-
{account?.displayName ? \`Make \${account.displayName} yours.\` : 'Make this app yours.'}
|
|
988
|
-
</CardTitle>
|
|
989
|
-
<CardDescription className="max-w-3xl text-base leading-7">
|
|
990
|
-
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.
|
|
991
|
-
</CardDescription>
|
|
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>
|
|
992
815
|
</CardHeader>
|
|
993
|
-
<CardContent className="flex-row flex-wrap gap-
|
|
816
|
+
<CardContent className="flex-row flex-wrap gap-2">
|
|
994
817
|
<Button onPress={() => void runSubscriptionAction()}>
|
|
995
|
-
<Text>{subscription.managementUrl ? 'Manage subscription' : '
|
|
818
|
+
<Text>{subscription.managementUrl ? 'Manage subscription' : 'Open paywall'}</Text>
|
|
996
819
|
</Button>
|
|
997
820
|
<Button variant="outline" onPress={() => void refresh()}>
|
|
998
|
-
<Text>Refresh
|
|
821
|
+
<Text>Refresh</Text>
|
|
999
822
|
</Button>
|
|
1000
823
|
</CardContent>
|
|
1001
824
|
</Card>
|
|
1002
825
|
|
|
1003
|
-
<View className="gap-3
|
|
1004
|
-
<Card className="
|
|
1005
|
-
<CardHeader
|
|
1006
|
-
<View className="
|
|
1007
|
-
<
|
|
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>
|
|
1008
832
|
</View>
|
|
1009
|
-
<CardTitle className="text-left text-xl">{subscriptionLabel}</CardTitle>
|
|
1010
833
|
</CardHeader>
|
|
1011
|
-
<CardContent className="gap-
|
|
1012
|
-
<Text className="text-sm text-muted-foreground">{
|
|
1013
|
-
{
|
|
1014
|
-
<Text className="text-sm text-muted-foreground">Access until {new Date(subscription.expiresAt).toLocaleDateString()}</Text>
|
|
1015
|
-
) : null}
|
|
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}
|
|
1016
837
|
</CardContent>
|
|
1017
838
|
</Card>
|
|
1018
839
|
|
|
1019
|
-
<Card className="
|
|
1020
|
-
<CardHeader
|
|
1021
|
-
<View className="
|
|
1022
|
-
<
|
|
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>
|
|
1023
845
|
</View>
|
|
1024
|
-
<CardTitle className="text-left text-xl">{account?.creditsBalance ?? 250} credits</CardTitle>
|
|
1025
846
|
</CardHeader>
|
|
1026
|
-
<CardContent className="
|
|
1027
|
-
<Text
|
|
1028
|
-
|
|
1029
|
-
</Text>
|
|
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}
|
|
1030
851
|
</CardContent>
|
|
1031
852
|
</Card>
|
|
1032
853
|
|
|
1033
|
-
<Card className="
|
|
1034
|
-
<CardHeader
|
|
1035
|
-
<View className="
|
|
1036
|
-
<
|
|
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>
|
|
1037
859
|
</View>
|
|
1038
|
-
<CardTitle className="text-left text-xl">Avatar upload</CardTitle>
|
|
1039
860
|
</CardHeader>
|
|
1040
|
-
<CardContent className="
|
|
1041
|
-
<Text
|
|
1042
|
-
|
|
1043
|
-
</Text>
|
|
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>
|
|
1044
864
|
</CardContent>
|
|
1045
865
|
</Card>
|
|
1046
866
|
</View>
|
|
@@ -1049,7 +869,7 @@ export default function DashboardScreen() {
|
|
|
1049
869
|
<Card className="rounded-[24px] border-amber-300/60 bg-amber-50 dark:bg-amber-500/10">
|
|
1050
870
|
<CardContent className="px-5 py-5">
|
|
1051
871
|
<Text className="text-sm leading-6 text-amber-900 dark:text-amber-100">
|
|
1052
|
-
|
|
872
|
+
API error: {error}
|
|
1053
873
|
</Text>
|
|
1054
874
|
</CardContent>
|
|
1055
875
|
</Card>
|
|
@@ -1073,23 +893,21 @@ export default function SignInScreen() {
|
|
|
1073
893
|
contentContainerClassName="min-h-full bg-background px-4 pb-10 pt-6 sm:px-6"
|
|
1074
894
|
keyboardDismissMode="interactive">
|
|
1075
895
|
<View className="mx-auto w-full max-w-5xl gap-6">
|
|
1076
|
-
<View className="flex-row items-center justify-between
|
|
896
|
+
<View className="flex-row items-center justify-between border-b border-border py-3">
|
|
1077
897
|
<View>
|
|
1078
|
-
<Text className="text-
|
|
1079
|
-
<Text className="mt-1 text-lg font-semibold">${displayName}</Text>
|
|
898
|
+
<Text className="text-lg font-semibold">${displayName}</Text>
|
|
1080
899
|
</View>
|
|
1081
900
|
<ThemeToggle />
|
|
1082
901
|
</View>
|
|
1083
902
|
|
|
1084
903
|
<View className="gap-4 sm:flex-row">
|
|
1085
|
-
<View className="flex-1
|
|
1086
|
-
<Text className="text-
|
|
1087
|
-
<Text className="mt-
|
|
1088
|
-
|
|
1089
|
-
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.
|
|
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.
|
|
1090
908
|
</Text>
|
|
1091
909
|
<Link href="/" className="mt-6 text-sm underline underline-offset-4">
|
|
1092
|
-
Back
|
|
910
|
+
Back
|
|
1093
911
|
</Link>
|
|
1094
912
|
</View>
|
|
1095
913
|
|
|
@@ -1114,10 +932,9 @@ export default function SignUpScreen() {
|
|
|
1114
932
|
contentContainerClassName="min-h-full bg-background px-4 pb-10 pt-6 sm:px-6"
|
|
1115
933
|
keyboardDismissMode="interactive">
|
|
1116
934
|
<View className="mx-auto w-full max-w-5xl gap-6">
|
|
1117
|
-
<View className="flex-row items-center justify-between
|
|
935
|
+
<View className="flex-row items-center justify-between border-b border-border py-3">
|
|
1118
936
|
<View>
|
|
1119
|
-
<Text className="text-
|
|
1120
|
-
<Text className="mt-1 text-lg font-semibold">${displayName}</Text>
|
|
937
|
+
<Text className="text-lg font-semibold">${displayName}</Text>
|
|
1121
938
|
</View>
|
|
1122
939
|
<ThemeToggle />
|
|
1123
940
|
</View>
|
|
@@ -1127,11 +944,10 @@ export default function SignUpScreen() {
|
|
|
1127
944
|
<SignUpForm />
|
|
1128
945
|
</View>
|
|
1129
946
|
|
|
1130
|
-
<View className="flex-1
|
|
1131
|
-
<Text className="text-
|
|
1132
|
-
<Text className="mt-
|
|
1133
|
-
|
|
1134
|
-
New accounts land in a dashboard that already exposes RevenueCat subscription state, credits, and an R2 avatar flow.
|
|
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.
|
|
1135
951
|
</Text>
|
|
1136
952
|
</View>
|
|
1137
953
|
</View>
|
|
@@ -1186,9 +1002,9 @@ export function SignInForm() {
|
|
|
1186
1002
|
<View className="gap-6">
|
|
1187
1003
|
<Card className="rounded-[28px] border-border/70 bg-card shadow-sm shadow-black/5">
|
|
1188
1004
|
<CardHeader className="px-6 pt-8">
|
|
1189
|
-
<CardTitle className="text-center text-2xl sm:text-left">Sign in
|
|
1005
|
+
<CardTitle className="text-center text-2xl sm:text-left">Sign in</CardTitle>
|
|
1190
1006
|
<CardDescription className="text-center sm:text-left">
|
|
1191
|
-
|
|
1007
|
+
Continue with your account.
|
|
1192
1008
|
</CardDescription>
|
|
1193
1009
|
</CardHeader>
|
|
1194
1010
|
<CardContent className="gap-6 px-6 pb-8">
|
|
@@ -1298,7 +1114,7 @@ export function SignUpForm() {
|
|
|
1298
1114
|
<CardHeader className="px-6 pt-8">
|
|
1299
1115
|
<CardTitle className="text-center text-2xl sm:text-left">Create your account</CardTitle>
|
|
1300
1116
|
<CardDescription className="text-center sm:text-left">
|
|
1301
|
-
|
|
1117
|
+
Enter an email and password to continue.
|
|
1302
1118
|
</CardDescription>
|
|
1303
1119
|
</CardHeader>
|
|
1304
1120
|
<CardContent className="gap-6 px-6 pb-8">
|
|
@@ -1357,9 +1173,7 @@ export function SignUpForm() {
|
|
|
1357
1173
|
);
|
|
1358
1174
|
}
|
|
1359
1175
|
`);
|
|
1360
|
-
writeFile(path.join(dir, 'components/user-menu.tsx'), `import
|
|
1361
|
-
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
|
1362
|
-
import { Button } from '@/components/ui/button';
|
|
1176
|
+
writeFile(path.join(dir, 'components/user-menu.tsx'), `import { Button } from '@/components/ui/button';
|
|
1363
1177
|
import { Icon } from '@/components/ui/icon';
|
|
1364
1178
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
|
1365
1179
|
import { Text } from '@/components/ui/text';
|
|
@@ -1367,79 +1181,21 @@ import { useAccount } from '@/hooks/useAccount';
|
|
|
1367
1181
|
import { useSubscription } from '@/hooks/useSubscription';
|
|
1368
1182
|
import { useAuth } from '@clerk/expo';
|
|
1369
1183
|
import type { TriggerRef } from '@rn-primitives/popover';
|
|
1370
|
-
import {
|
|
1184
|
+
import { CreditCardIcon, LogOutIcon, UserIcon } from 'lucide-react-native';
|
|
1371
1185
|
import * as React from 'react';
|
|
1372
|
-
import {
|
|
1373
|
-
|
|
1374
|
-
function showNotice(title: string, message: string) {
|
|
1375
|
-
if (Platform.OS === 'web' && typeof window !== 'undefined') {
|
|
1376
|
-
window.alert(\`\${title}\\n\\n\${message}\`);
|
|
1377
|
-
return;
|
|
1378
|
-
}
|
|
1379
|
-
|
|
1380
|
-
Alert.alert(title, message);
|
|
1381
|
-
}
|
|
1186
|
+
import { View } from 'react-native';
|
|
1382
1187
|
|
|
1383
1188
|
export function UserMenu() {
|
|
1384
1189
|
const { signOut } = useAuth();
|
|
1385
|
-
const { account
|
|
1190
|
+
const { account } = useAccount();
|
|
1386
1191
|
const subscription = useSubscription();
|
|
1387
1192
|
const popoverTriggerRef = React.useRef<TriggerRef>(null);
|
|
1388
|
-
const [uploading, setUploading] = React.useState(false);
|
|
1389
|
-
|
|
1390
|
-
const subscriptionLabel = subscription.isPaid
|
|
1391
|
-
? 'RevenueCat Pro'
|
|
1392
|
-
: subscription.isTrial
|
|
1393
|
-
? 'Trial'
|
|
1394
|
-
: subscription.isRedeemed
|
|
1395
|
-
? 'Promo'
|
|
1396
|
-
: 'Free';
|
|
1397
1193
|
|
|
1398
1194
|
async function onSignOut() {
|
|
1399
1195
|
popoverTriggerRef.current?.close();
|
|
1400
1196
|
await signOut();
|
|
1401
1197
|
}
|
|
1402
1198
|
|
|
1403
|
-
async function onUploadAvatar() {
|
|
1404
|
-
popoverTriggerRef.current?.close();
|
|
1405
|
-
const permission = await ImagePicker.requestMediaLibraryPermissionsAsync();
|
|
1406
|
-
if (!permission.granted) {
|
|
1407
|
-
showNotice('Permission required', 'Allow photo library access to upload an avatar.');
|
|
1408
|
-
return;
|
|
1409
|
-
}
|
|
1410
|
-
|
|
1411
|
-
const result = await ImagePicker.launchImageLibraryAsync({
|
|
1412
|
-
mediaTypes: ImagePicker.MediaTypeOptions.Images,
|
|
1413
|
-
allowsEditing: true,
|
|
1414
|
-
aspect: [1, 1],
|
|
1415
|
-
quality: 0.85,
|
|
1416
|
-
base64: true,
|
|
1417
|
-
});
|
|
1418
|
-
|
|
1419
|
-
if (result.canceled || !result.assets[0]) return;
|
|
1420
|
-
|
|
1421
|
-
const asset = result.assets[0];
|
|
1422
|
-
if (!asset.base64) {
|
|
1423
|
-
showNotice('Upload failed', 'Could not read the selected image.');
|
|
1424
|
-
return;
|
|
1425
|
-
}
|
|
1426
|
-
|
|
1427
|
-
setUploading(true);
|
|
1428
|
-
try {
|
|
1429
|
-
await uploadAvatar({
|
|
1430
|
-
base64: asset.base64,
|
|
1431
|
-
mimeType: asset.mimeType ?? 'image/jpeg',
|
|
1432
|
-
fileName: asset.fileName ?? 'avatar.jpg',
|
|
1433
|
-
});
|
|
1434
|
-
await refresh();
|
|
1435
|
-
showNotice('Avatar updated', 'Your profile photo is now stored in R2.');
|
|
1436
|
-
} catch (err) {
|
|
1437
|
-
showNotice('Upload failed', err instanceof Error ? err.message : 'Avatar upload failed.');
|
|
1438
|
-
} finally {
|
|
1439
|
-
setUploading(false);
|
|
1440
|
-
}
|
|
1441
|
-
}
|
|
1442
|
-
|
|
1443
1199
|
async function onSubscriptionAction() {
|
|
1444
1200
|
popoverTriggerRef.current?.close();
|
|
1445
1201
|
if (subscription.managementUrl) {
|
|
@@ -1450,64 +1206,19 @@ export function UserMenu() {
|
|
|
1450
1206
|
await subscription.subscribe('monthly');
|
|
1451
1207
|
}
|
|
1452
1208
|
|
|
1453
|
-
const avatarSource = account?.avatarUrl || account?.imageUrl;
|
|
1454
|
-
const userName = account?.displayName || account?.email || 'Builder';
|
|
1455
|
-
const initials = userName
|
|
1456
|
-
.split(' ')
|
|
1457
|
-
.map((name) => name[0])
|
|
1458
|
-
.join('')
|
|
1459
|
-
.slice(0, 2)
|
|
1460
|
-
.toUpperCase();
|
|
1461
|
-
|
|
1462
1209
|
return (
|
|
1463
1210
|
<Popover>
|
|
1464
1211
|
<PopoverTrigger asChild ref={popoverTriggerRef}>
|
|
1465
|
-
<Button variant="ghost" size="
|
|
1466
|
-
<
|
|
1467
|
-
|
|
1468
|
-
<AvatarFallback>
|
|
1469
|
-
<Text>{initials}</Text>
|
|
1470
|
-
</AvatarFallback>
|
|
1471
|
-
</Avatar>
|
|
1212
|
+
<Button variant="ghost" size="sm">
|
|
1213
|
+
<Icon as={UserIcon} className="size-4" />
|
|
1214
|
+
<Text>Account</Text>
|
|
1472
1215
|
</Button>
|
|
1473
1216
|
</PopoverTrigger>
|
|
1474
1217
|
|
|
1475
|
-
<PopoverContent align="end" side="bottom" className="w-
|
|
1476
|
-
<View className="gap-
|
|
1477
|
-
<
|
|
1478
|
-
|
|
1479
|
-
<AvatarImage source={avatarSource ? { uri: avatarSource } : undefined} />
|
|
1480
|
-
<AvatarFallback>
|
|
1481
|
-
<Text>{initials}</Text>
|
|
1482
|
-
</AvatarFallback>
|
|
1483
|
-
</Avatar>
|
|
1484
|
-
<View className="flex-1">
|
|
1485
|
-
<Text className="font-medium leading-5">{userName}</Text>
|
|
1486
|
-
<Text className="text-sm text-muted-foreground">{account?.email ?? 'Loading account...'}</Text>
|
|
1487
|
-
</View>
|
|
1488
|
-
</View>
|
|
1489
|
-
|
|
1490
|
-
<View className="flex-row gap-3">
|
|
1491
|
-
<View className="flex-1 rounded-2xl border border-border bg-muted/40 p-3">
|
|
1492
|
-
<View className="flex-row items-center gap-2">
|
|
1493
|
-
<CreditCardIcon size={16} color="currentColor" />
|
|
1494
|
-
<Text className="text-xs uppercase tracking-[2px] text-muted-foreground">Subscription</Text>
|
|
1495
|
-
</View>
|
|
1496
|
-
<Text className="mt-3 font-semibold">{subscriptionLabel}</Text>
|
|
1497
|
-
<Text className="text-sm text-muted-foreground">
|
|
1498
|
-
{subscription.isTrial ? \`\${subscription.trialDaysRemaining} days left\` : (account?.subscriptionStatus ?? 'active')}
|
|
1499
|
-
</Text>
|
|
1500
|
-
</View>
|
|
1501
|
-
|
|
1502
|
-
<View className="flex-1 rounded-2xl border border-border bg-muted/40 p-3">
|
|
1503
|
-
<View className="flex-row items-center gap-2">
|
|
1504
|
-
<CoinsIcon size={16} color="currentColor" />
|
|
1505
|
-
<Text className="text-xs uppercase tracking-[2px] text-muted-foreground">Credits</Text>
|
|
1506
|
-
</View>
|
|
1507
|
-
<Text className="mt-3 font-semibold">{account?.creditsBalance ?? 250}</Text>
|
|
1508
|
-
<Text className="text-sm text-muted-foreground">Starter balance</Text>
|
|
1509
|
-
</View>
|
|
1510
|
-
</View>
|
|
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>
|
|
1511
1222
|
</View>
|
|
1512
1223
|
|
|
1513
1224
|
<View className="gap-2 p-3">
|
|
@@ -1516,11 +1227,6 @@ export function UserMenu() {
|
|
|
1516
1227
|
<Text>{subscription.managementUrl ? 'Manage subscription' : 'Open paywall'}</Text>
|
|
1517
1228
|
</Button>
|
|
1518
1229
|
|
|
1519
|
-
<Button variant="outline" onPress={() => void onUploadAvatar()} disabled={uploading}>
|
|
1520
|
-
<Icon as={uploading ? LoaderCircleIcon : CameraIcon} className={uploading ? 'size-4 animate-spin' : 'size-4'} />
|
|
1521
|
-
<Text>{uploading ? 'Uploading avatar...' : 'Upload avatar to R2'}</Text>
|
|
1522
|
-
</Button>
|
|
1523
|
-
|
|
1524
1230
|
<Button variant="outline" onPress={() => void onSignOut()}>
|
|
1525
1231
|
<Icon as={LogOutIcon} className="size-4" />
|
|
1526
1232
|
<Text>Sign out</Text>
|