anhedral 0.1.5 → 0.1.7

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.
@@ -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
- anhedralPrint.step('Installing Expo native packages');
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, useState } from 'react';
597
- import { Linking, NativeModules, Platform } from 'react-native';
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, redeem, redeemOfferCode, resetRedeem, refresh: ctx.refresh, manageSubscription,
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)/dashboard" options={{ headerShown: false }} />
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, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
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
- const FEATURES = [
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 flex w-full max-w-5xl gap-6">
844
- <View className="flex-row items-center justify-between rounded-[28px] border border-border/70 bg-card px-4 py-3">
845
- <View>
846
- <Text className="text-xs uppercase tracking-[3px] text-muted-foreground">anhedral crossplatform</Text>
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="rounded-[32px] border-border/70 bg-card">
875
- <CardHeader className="gap-4 px-6 pt-8 sm:px-8">
876
- <Text className="text-xs uppercase tracking-[3px] text-muted-foreground">Starter shell</Text>
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-3 px-6 pb-8 sm:px-8">
885
- {isSignedIn ? (
886
- <Link href="/(app)/dashboard" asChild>
887
- <Button className="self-start">
888
- <Text>Continue to the app</Text>
889
- </Button>
890
- </Link>
891
- ) : (
892
- <Link href="/(auth)/sign-up" asChild>
893
- <Button className="self-start">
894
- <Text>Create an account</Text>
895
- </Button>
896
- </Link>
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)/dashboard.tsx'), `import { ThemeToggle } from '@/components/theme-toggle';
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, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
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 { CoinsIcon, CreditCardIcon, HardDriveUploadIcon, SparklesIcon } from 'lucide-react-native';
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 'RevenueCat Pro';
939
- if (subscription.isTrial) return \`Trial • \${subscription.trialDaysRemaining} days left\`;
940
- if (subscription.isRedeemed) return 'Promo access';
941
- if (subscription.canAccess) return 'Access active';
942
- return 'Free';
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-5xl gap-4">
980
- <Card className="rounded-[28px] border-border/70 bg-card">
981
- <CardHeader className="gap-4 px-6 pt-8 sm:px-8">
982
- <View className="flex-row items-center gap-2">
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-3 px-6 pb-8 sm:px-8">
816
+ <CardContent className="flex-row flex-wrap gap-2">
994
817
  <Button onPress={() => void runSubscriptionAction()}>
995
- <Text>{subscription.managementUrl ? 'Manage subscription' : 'Unlock pro'}</Text>
818
+ <Text>{subscription.managementUrl ? 'Manage subscription' : 'Open paywall'}</Text>
996
819
  </Button>
997
820
  <Button variant="outline" onPress={() => void refresh()}>
998
- <Text>Refresh profile</Text>
821
+ <Text>Refresh</Text>
999
822
  </Button>
1000
823
  </CardContent>
1001
824
  </Card>
1002
825
 
1003
- <View className="gap-3 sm:flex-row sm:flex-wrap">
1004
- <Card className="flex-1 rounded-[24px] border-border/70 bg-card sm:min-w-[240px]">
1005
- <CardHeader className="px-5 pt-5">
1006
- <View className="mb-2 size-11 items-center justify-center rounded-2xl bg-muted">
1007
- <CreditCardIcon size={20} color="currentColor" />
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-2 px-5 pb-5">
1012
- <Text className="text-sm text-muted-foreground">{subscriptionStatus}</Text>
1013
- {subscription.expiresAt ? (
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="flex-1 rounded-[24px] border-border/70 bg-card sm:min-w-[240px]">
1020
- <CardHeader className="px-5 pt-5">
1021
- <View className="mb-2 size-11 items-center justify-center rounded-2xl bg-muted">
1022
- <CoinsIcon size={20} color="currentColor" />
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="px-5 pb-5">
1027
- <Text className="text-sm leading-6 text-muted-foreground">
1028
- Credits are seeded in the backend user record so you can connect real usage accounting later without redesigning the shell.
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="flex-1 rounded-[24px] border-border/70 bg-card sm:min-w-[240px]">
1034
- <CardHeader className="px-5 pt-5">
1035
- <View className="mb-2 size-11 items-center justify-center rounded-2xl bg-muted">
1036
- <HardDriveUploadIcon size={20} color="currentColor" />
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="px-5 pb-5">
1041
- <Text className="text-sm leading-6 text-muted-foreground">
1042
- The header menu uploads profile photos into R2 and swaps the avatar immediately when the upload returns.
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
- Backend profile data is not ready yet: {error}
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 rounded-[28px] border border-border/70 bg-card px-4 py-3">
896
+ <View className="flex-row items-center justify-between border-b border-border py-3">
1077
897
  <View>
1078
- <Text className="text-xs uppercase tracking-[3px] text-muted-foreground">Clerk custom auth</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 rounded-[28px] border border-border/70 bg-card px-6 py-8">
1086
- <Text className="text-xs uppercase tracking-[3px] text-muted-foreground">Sign in</Text>
1087
- <Text className="mt-4 text-4xl font-semibold leading-tight">Return to the app shell without rebuilding auth from scratch.</Text>
1088
- <Text className="mt-4 text-base leading-7 text-muted-foreground">
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 to landing
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 rounded-[28px] border border-border/70 bg-card px-4 py-3">
935
+ <View className="flex-row items-center justify-between border-b border-border py-3">
1118
936
  <View>
1119
- <Text className="text-xs uppercase tracking-[3px] text-muted-foreground">Starter onboarding</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 rounded-[28px] border border-border/70 bg-card px-6 py-8 sm:order-1">
1131
- <Text className="text-xs uppercase tracking-[3px] text-muted-foreground">Create account</Text>
1132
- <Text className="mt-4 text-4xl font-semibold leading-tight">Get users into the signed-in product shell immediately.</Text>
1133
- <Text className="mt-4 text-base leading-7 text-muted-foreground">
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 to keep building</CardTitle>
1005
+ <CardTitle className="text-center text-2xl sm:text-left">Sign in</CardTitle>
1190
1006
  <CardDescription className="text-center sm:text-left">
1191
- Use your existing account to return to the starter dashboard.
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
- Start with a real signed-in shell instead of another empty Expo project.
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 * as ImagePicker from 'expo-image-picker';
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 { CameraIcon, CreditCardIcon, CoinsIcon, LoaderCircleIcon, LogOutIcon } from 'lucide-react-native';
1184
+ import { CreditCardIcon, LogOutIcon, UserIcon } from 'lucide-react-native';
1371
1185
  import * as React from 'react';
1372
- import { Alert, Platform, View } from 'react-native';
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, refresh, uploadAvatar } = useAccount();
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="icon" className="size-9 rounded-full">
1466
- <Avatar className="size-9" alt={userName}>
1467
- <AvatarImage source={avatarSource ? { uri: avatarSource } : undefined} />
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-80 gap-0 p-0">
1476
- <View className="gap-4 border-b border-border p-4">
1477
- <View className="flex-row items-center gap-3">
1478
- <Avatar className="size-12" alt={userName}>
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>