mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-10-31 18:19:43 +00:00
Add guest auth and save-account flow
Introduce anonymous sign-in with in-app saving, guest recovery warnings, paywall prompts, and admin guest labeling.
This commit is contained in:
1 parent
ce68d57a49
commit
349f460e15
14 files changed
+1236
-176
No files matched your search
@@ -539,10 +539,19 @@ export function UserDetailPage() {
|
|||||||
{profile.is_premium && <Badge className="bg-amber-500"><Crown className="h-3 w-3 mr-1" />Premium</Badge>}
|
{profile.is_premium && <Badge className="bg-amber-500"><Crown className="h-3 w-3 mr-1" />Premium</Badge>}
|
||||||
{!profile.is_premium && hasPermission(PERMISSION_KEYS.GIFT_PREMIUM) && <Dialog open={upgradeOpen} onOpenChange={setUpgradeOpen}><DialogTrigger asChild><Button size="sm" variant="outline" className="ml-2 gap-1 h-7"><Gift className="h-3.5 w-3.5" />Gift Premium</Button></DialogTrigger><DialogContent><DialogHeader><DialogTitle>Gift Premium Access</DialogTitle><DialogDescription>Manually upgrade this user to premium status.</DialogDescription></DialogHeader><div className="grid gap-4 py-4"><div className="grid gap-2"><Label htmlFor="expiry-type">Duration</Label><Select value={expiryType} onValueChange={setExpiryType}><SelectTrigger id="expiry-type"><SelectValue placeholder="Select duration" /></SelectTrigger><SelectContent><SelectItem value="forever">Lifetime (No Expiry)</SelectItem><SelectItem value="1_month">1 Month</SelectItem><SelectItem value="1_year">1 Year</SelectItem><SelectItem value="custom">Custom Date</SelectItem></SelectContent></Select></div>{expiryType === 'custom' && (<div className="grid gap-2"><Label htmlFor="custom-date">Expiry Date</Label><Input id="custom-date" type="datetime-local" value={customDate} onChange={(e) => setCustomDate(e.target.value)} /></div>)}</div><DialogFooter><Button variant="outline" onClick={() => setUpgradeOpen(false)}>Cancel</Button><Button onClick={handleUpgrade} disabled={upgrading}>{upgrading ? 'Upgrading...' : 'Confirm Upgrade'}</Button></DialogFooter></DialogContent></Dialog>}
|
{!profile.is_premium && hasPermission(PERMISSION_KEYS.GIFT_PREMIUM) && <Dialog open={upgradeOpen} onOpenChange={setUpgradeOpen}><DialogTrigger asChild><Button size="sm" variant="outline" className="ml-2 gap-1 h-7"><Gift className="h-3.5 w-3.5" />Gift Premium</Button></DialogTrigger><DialogContent><DialogHeader><DialogTitle>Gift Premium Access</DialogTitle><DialogDescription>Manually upgrade this user to premium status.</DialogDescription></DialogHeader><div className="grid gap-4 py-4"><div className="grid gap-2"><Label htmlFor="expiry-type">Duration</Label><Select value={expiryType} onValueChange={setExpiryType}><SelectTrigger id="expiry-type"><SelectValue placeholder="Select duration" /></SelectTrigger><SelectContent><SelectItem value="forever">Lifetime (No Expiry)</SelectItem><SelectItem value="1_month">1 Month</SelectItem><SelectItem value="1_year">1 Year</SelectItem><SelectItem value="custom">Custom Date</SelectItem></SelectContent></Select></div>{expiryType === 'custom' && (<div className="grid gap-2"><Label htmlFor="custom-date">Expiry Date</Label><Input id="custom-date" type="datetime-local" value={customDate} onChange={(e) => setCustomDate(e.target.value)} /></div>)}</div><DialogFooter><Button variant="outline" onClick={() => setUpgradeOpen(false)}>Cancel</Button><Button onClick={handleUpgrade} disabled={upgrading}>{upgrading ? 'Upgrading...' : 'Confirm Upgrade'}</Button></DialogFooter></DialogContent></Dialog>}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-muted-foreground mb-4">{profile.email || 'No email'}</p>
|
<div className="text-muted-foreground mb-4 flex items-center gap-2">
|
||||||
|
{profile.email ? (
|
||||||
|
<span>{profile.email}</span>
|
||||||
|
) : (
|
||||||
|
<Badge variant="secondary" className="bg-slate-100 text-slate-500 hover:bg-slate-200">Guest</Badge>
|
||||||
|
)}
|
||||||
|
{!profile.email && (
|
||||||
|
<span className="text-xs">Not recoverable unless saved in-app</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<div className="flex gap-6 text-sm">
|
<div className="flex gap-6 text-sm">
|
||||||
<div><span className="text-muted-foreground">Joined: </span><span>{profile.created_at ? format(new Date(profile.created_at), 'MMMM d, yyyy') : '—'}</span></div>
|
<div><span className="text-muted-foreground">Joined: </span><span>{profile.created_at ? format(new Date(profile.created_at), 'MMMM d, yyyy') : '—'}</span></div>
|
||||||
{partner ? <div className="flex items-center gap-1"><Users className="h-4 w-4 text-muted-foreground" /><span className="text-muted-foreground">Paired with: </span><Link to={`/users/${partner.id}`} className="text-primary hover:underline">{partner.name || 'Unnamed Partner'}</Link></div> : <div className="text-muted-foreground">Not paired</div>}
|
{partner ? <div className="flex items-center gap-1"><Users className="h-4 w-4 text-muted-foreground" /><span className="text-muted-foreground">Paired with: </span><Link to={`/users/${partner.id}`} className="text-primary hover:underline">{partner.name || 'Unnamed Partner'}</Link></div> : profile.couple_id ? <div className="text-amber-600 dark:text-amber-400 flex items-center gap-1"><Users className="h-4 w-4" />Waiting for partner</div> : <div className="text-muted-foreground">Not paired</div>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div></CardContent></Card>
|
</div></CardContent></Card>
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import {
|
|||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from '@/components/ui/table';
|
} from '@/components/ui/table';
|
||||||
import { Search, Crown, Users, ChevronRight, HardDrive, MailWarning } from 'lucide-react';
|
import { Search, Crown, Users, ChevronRight, HardDrive, MailWarning, Hourglass } from 'lucide-react';
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
|
|
||||||
interface Profile {
|
interface Profile {
|
||||||
@@ -32,6 +32,7 @@ interface Profile {
|
|||||||
email_confirmed_at: string | null;
|
email_confirmed_at: string | null;
|
||||||
storage_bytes?: number;
|
storage_bytes?: number;
|
||||||
partner_is_premium?: boolean;
|
partner_is_premium?: boolean;
|
||||||
|
partner_count?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatBytes(bytes: number): string {
|
function formatBytes(bytes: number): string {
|
||||||
@@ -87,11 +88,13 @@ export function UsersPage() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Merge storage data and partner premium status with profiles
|
// Merge storage data, partner premium status, and partner count with profiles
|
||||||
const profilesWithExtras = profilesData.map(profile => {
|
const profilesWithExtras = profilesData.map(profile => {
|
||||||
// Check if partner has premium
|
// Check if partner has premium and count partners in couple
|
||||||
let partnerIsPremium = false;
|
let partnerIsPremium = false;
|
||||||
|
let partnerCount = 0;
|
||||||
if (profile.couple_id && couplePartners[profile.couple_id]) {
|
if (profile.couple_id && couplePartners[profile.couple_id]) {
|
||||||
|
partnerCount = couplePartners[profile.couple_id].length;
|
||||||
const partner = couplePartners[profile.couple_id].find(p => p.id !== profile.id);
|
const partner = couplePartners[profile.couple_id].find(p => p.id !== profile.id);
|
||||||
partnerIsPremium = partner?.is_premium || false;
|
partnerIsPremium = partner?.is_premium || false;
|
||||||
}
|
}
|
||||||
@@ -99,6 +102,7 @@ export function UsersPage() {
|
|||||||
...profile,
|
...profile,
|
||||||
storage_bytes: storageMap[profile.id] || 0,
|
storage_bytes: storageMap[profile.id] || 0,
|
||||||
partner_is_premium: partnerIsPremium,
|
partner_is_premium: partnerIsPremium,
|
||||||
|
partner_count: partnerCount,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -126,9 +130,12 @@ export function UsersPage() {
|
|||||||
setProfiles(prev => prev.filter(p => p.id !== deleted.id));
|
setProfiles(prev => prev.filter(p => p.id !== deleted.id));
|
||||||
setFilteredProfiles(prev => prev.filter(p => p.id !== deleted.id));
|
setFilteredProfiles(prev => prev.filter(p => p.id !== deleted.id));
|
||||||
}, []),
|
}, []),
|
||||||
insertToast: {
|
insertToast: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
message: (payload) => `New user registered: ${payload.name || payload.email || 'Unknown'}`,
|
message: (payload) => {
|
||||||
|
const label = payload.name || payload.email || 'Guest';
|
||||||
|
return payload.email ? `New user registered: ${label}` : `New guest user: ${label}`;
|
||||||
|
},
|
||||||
type: 'success',
|
type: 'success',
|
||||||
},
|
},
|
||||||
debounceMs: 1000, // Debounce to avoid excessive refetches
|
debounceMs: 1000, // Debounce to avoid excessive refetches
|
||||||
@@ -191,7 +198,15 @@ export function UsersPage() {
|
|||||||
<RealtimeStatusIndicator status={realtimeStatus} showLabel />
|
<RealtimeStatusIndicator status={realtimeStatus} showLabel />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-muted-foreground text-sm">
|
<p className="text-muted-foreground text-sm">
|
||||||
{profiles.length} registered user{profiles.length !== 1 ? 's' : ''}
|
{profiles.length} account{profiles.length !== 1 ? 's' : ''}
|
||||||
|
{(() => {
|
||||||
|
const guestCount = profiles.filter(p => !p.email).length;
|
||||||
|
const savedCount = profiles.length - guestCount;
|
||||||
|
if (guestCount === 0) return null;
|
||||||
|
return (
|
||||||
|
<span> • {savedCount} saved • {guestCount} guest</span>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="relative w-full sm:w-64">
|
<div className="relative w-full sm:w-64">
|
||||||
@@ -245,11 +260,19 @@ export function UsersPage() {
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-muted-foreground">
|
<TableCell className="text-muted-foreground">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
{profile.email || '—'}
|
{profile.email ? (
|
||||||
{profile.email && !profile.email_confirmed_at && (
|
<>
|
||||||
<span title="Email not verified">
|
{profile.email}
|
||||||
<MailWarning className="h-3.5 w-3.5 text-amber-500" />
|
{!profile.email_confirmed_at && (
|
||||||
</span>
|
<span title="Email not verified">
|
||||||
|
<MailWarning className="h-3.5 w-3.5 text-amber-500" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Badge variant="secondary" className="bg-slate-100 text-slate-500 hover:bg-slate-200">
|
||||||
|
Guest
|
||||||
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
@@ -272,10 +295,17 @@ export function UsersPage() {
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
{profile.couple_id ? (
|
{profile.couple_id ? (
|
||||||
<Badge variant="secondary">
|
profile.partner_count && profile.partner_count >= 2 ? (
|
||||||
<Users className="h-3 w-3 mr-1" />
|
<Badge variant="secondary" className="bg-green-100 text-green-700 hover:bg-green-200">
|
||||||
Paired
|
<Users className="h-3 w-3 mr-1" />
|
||||||
</Badge>
|
Paired
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variant="secondary" className="bg-amber-100 text-amber-700 hover:bg-amber-200">
|
||||||
|
<Hourglass className="h-3 w-3 mr-1" />
|
||||||
|
Pairing
|
||||||
|
</Badge>
|
||||||
|
)
|
||||||
) : (
|
) : (
|
||||||
<span className="text-muted-foreground text-sm">—</span>
|
<span className="text-muted-foreground text-sm">—</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { SaveAccountScreen } from "../../../src/features/profile/screens";
|
||||||
|
|
||||||
|
export default SaveAccountScreen;
|
||||||
@@ -61,6 +61,7 @@ export default function LoginScreen() {
|
|||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [isGuestLoading, setIsGuestLoading] = useState(false);
|
||||||
const [isMagicLinkSent, setIsMagicLinkSent] = useState(false);
|
const [isMagicLinkSent, setIsMagicLinkSent] = useState(false);
|
||||||
const [pendingVerification, setPendingVerification] = useState<PendingVerification | null>(null);
|
const [pendingVerification, setPendingVerification] = useState<PendingVerification | null>(null);
|
||||||
const [isCheckingVerification, setIsCheckingVerification] = useState(false);
|
const [isCheckingVerification, setIsCheckingVerification] = useState(false);
|
||||||
@@ -364,6 +365,20 @@ export default function LoginScreen() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleGuestSignIn = async () => {
|
||||||
|
clearError();
|
||||||
|
setIsGuestLoading(true);
|
||||||
|
const { error: authError } = await supabase.auth.signInAnonymously();
|
||||||
|
setIsGuestLoading(false);
|
||||||
|
|
||||||
|
if (authError) {
|
||||||
|
showError(getAuthError(authError));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Events.signIn("guest");
|
||||||
|
};
|
||||||
|
|
||||||
if (isMagicLinkSent) {
|
if (isMagicLinkSent) {
|
||||||
return (
|
return (
|
||||||
<GradientBackground showAccent>
|
<GradientBackground showAccent>
|
||||||
@@ -663,6 +678,23 @@ export default function LoginScreen() {
|
|||||||
</View>
|
</View>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<Animated.View
|
||||||
|
entering={FadeInDown.delay(400).duration(600).springify()}
|
||||||
|
style={styles.guestContainer}
|
||||||
|
>
|
||||||
|
<GlassButton
|
||||||
|
variant="ghost"
|
||||||
|
onPress={handleGuestSignIn}
|
||||||
|
loading={isGuestLoading}
|
||||||
|
disabled={isGuestLoading}
|
||||||
|
icon={<Ionicons name="person-outline" size={20} color={colors.primary} />}
|
||||||
|
fullWidth
|
||||||
|
>
|
||||||
|
Continue without Login
|
||||||
|
</GlassButton>
|
||||||
|
<Text style={styles.guestHint}>Save your account anytime in Settings.</Text>
|
||||||
|
</Animated.View>
|
||||||
</View>
|
</View>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
</KeyboardAvoidingView>
|
</KeyboardAvoidingView>
|
||||||
@@ -821,4 +853,14 @@ const styles = StyleSheet.create({
|
|||||||
flex: 1,
|
flex: 1,
|
||||||
height: 48,
|
height: 48,
|
||||||
},
|
},
|
||||||
|
guestContainer: {
|
||||||
|
marginTop: spacing.lg,
|
||||||
|
alignItems: "center",
|
||||||
|
gap: spacing.sm,
|
||||||
|
},
|
||||||
|
guestHint: {
|
||||||
|
...typography.caption1,
|
||||||
|
color: colors.textTertiary,
|
||||||
|
textAlign: "center",
|
||||||
|
},
|
||||||
});
|
});
|
||||||
+169
-4
@@ -16,8 +16,8 @@ import {
|
|||||||
handleNotificationResponse,
|
handleNotificationResponse,
|
||||||
} from "../src/lib/notifications";
|
} from "../src/lib/notifications";
|
||||||
|
|
||||||
import React, { useEffect, useRef } from "react";
|
import React, { useEffect, useRef, useState } from "react";
|
||||||
import { Platform, View, AppState, AppStateStatus } from "react-native";
|
import { Platform, View, AppState, AppStateStatus, Modal, Text, StyleSheet, Pressable, TouchableOpacity } from "react-native";
|
||||||
|
|
||||||
// Suppress useLayoutEffect warning on server-side rendering
|
// Suppress useLayoutEffect warning on server-side rendering
|
||||||
if (Platform.OS === "web" && typeof globalThis.window === "undefined") {
|
if (Platform.OS === "web" && typeof globalThis.window === "undefined") {
|
||||||
@@ -34,6 +34,7 @@ if (Platform.OS === "web" && typeof globalThis.window === "undefined") {
|
|||||||
|
|
||||||
import { Stack, router, usePathname } from "expo-router";
|
import { Stack, router, usePathname } from "expo-router";
|
||||||
import { StatusBar } from "expo-status-bar";
|
import { StatusBar } from "expo-status-bar";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
// GestureHandlerRootView is only needed on native platforms
|
// GestureHandlerRootView is only needed on native platforms
|
||||||
// On web, we use a simple View to avoid findNodeHandle errors
|
// On web, we use a simple View to avoid findNodeHandle errors
|
||||||
const GestureHandlerRootView = Platform.OS === "web"
|
const GestureHandlerRootView = Platform.OS === "web"
|
||||||
@@ -43,15 +44,26 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|||||||
import * as Linking from "expo-linking";
|
import * as Linking from "expo-linking";
|
||||||
import { useAuthStore } from "../src/store";
|
import { useAuthStore } from "../src/store";
|
||||||
import { supabase } from "../src/lib/supabase";
|
import { supabase } from "../src/lib/supabase";
|
||||||
|
import { colors, spacing, radius, typography } from "../src/theme";
|
||||||
|
import { hasSeenGuestAccountWarning, markGuestAccountWarningSeen } from "../src/lib/guestAccountWarningSeen";
|
||||||
|
|
||||||
const queryClient = new QueryClient();
|
const queryClient = new QueryClient();
|
||||||
|
|
||||||
export default function RootLayout() {
|
export default function RootLayout() {
|
||||||
const { fetchUser, setUser } = useAuthStore();
|
const { fetchUser, setUser, isAuthenticated, isAnonymous } = useAuthStore();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const appState = useRef(AppState.currentState);
|
const appState = useRef(AppState.currentState);
|
||||||
const analyticsInitialized = useRef(false);
|
const analyticsInitialized = useRef(false);
|
||||||
|
|
||||||
|
const [showGuestWarning, setShowGuestWarning] = useState(false);
|
||||||
|
const [checkingGuestWarning, setCheckingGuestWarning] = useState(false);
|
||||||
|
|
||||||
|
const dismissGuestWarning = async () => {
|
||||||
|
await markGuestAccountWarningSeen();
|
||||||
|
setShowGuestWarning(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
// Initialize analytics after native modules are ready
|
// Initialize analytics after native modules are ready
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!analyticsInitialized.current) {
|
if (!analyticsInitialized.current) {
|
||||||
@@ -68,6 +80,22 @@ export default function RootLayout() {
|
|||||||
}
|
}
|
||||||
}, [pathname]);
|
}, [pathname]);
|
||||||
|
|
||||||
|
// Warn guest users (first time only) that accounts are not recoverable
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isAuthenticated || !isAnonymous) return;
|
||||||
|
if (checkingGuestWarning) return;
|
||||||
|
if (pathname?.startsWith("/(auth)")) return;
|
||||||
|
|
||||||
|
setCheckingGuestWarning(true);
|
||||||
|
hasSeenGuestAccountWarning()
|
||||||
|
.then((seen) => {
|
||||||
|
if (!seen) {
|
||||||
|
setShowGuestWarning(true);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(() => setCheckingGuestWarning(false));
|
||||||
|
}, [isAuthenticated, isAnonymous, pathname, checkingGuestWarning]);
|
||||||
|
|
||||||
// Track warm starts when app comes back from background
|
// Track warm starts when app comes back from background
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const subscription = AppState.addEventListener("change", (nextAppState: AppStateStatus) => {
|
const subscription = AppState.addEventListener("change", (nextAppState: AppStateStatus) => {
|
||||||
@@ -181,7 +209,7 @@ export default function RootLayout() {
|
|||||||
const { data: { subscription: authSubscription } } = supabase.auth.onAuthStateChange(
|
const { data: { subscription: authSubscription } } = supabase.auth.onAuthStateChange(
|
||||||
async (event, session) => {
|
async (event, session) => {
|
||||||
console.log('[Auth] onAuthStateChange:', event, 'hasSession:', !!session, 'userId:', session?.user?.id);
|
console.log('[Auth] onAuthStateChange:', event, 'hasSession:', !!session, 'userId:', session?.user?.id);
|
||||||
if (event === "SIGNED_IN" || event === "TOKEN_REFRESHED") {
|
if (event === "SIGNED_IN" || event === "TOKEN_REFRESHED" || event === "USER_UPDATED") {
|
||||||
console.log('[Auth] Calling fetchUser...');
|
console.log('[Auth] Calling fetchUser...');
|
||||||
fetchUser();
|
fetchUser();
|
||||||
// Set Sentry user context for error tracking
|
// Set Sentry user context for error tracking
|
||||||
@@ -223,7 +251,144 @@ export default function RootLayout() {
|
|||||||
contentStyle: { backgroundColor: "#1a1a2e" },
|
contentStyle: { backgroundColor: "#1a1a2e" },
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
visible={showGuestWarning}
|
||||||
|
transparent
|
||||||
|
animationType="fade"
|
||||||
|
onRequestClose={dismissGuestWarning}
|
||||||
|
>
|
||||||
|
<Pressable
|
||||||
|
style={styles.guestWarningOverlay}
|
||||||
|
onPress={dismissGuestWarning}
|
||||||
|
>
|
||||||
|
<Pressable style={styles.guestWarningCard} onPress={() => { }}>
|
||||||
|
<View style={styles.guestWarningHeader}>
|
||||||
|
<View style={styles.guestWarningIcon}>
|
||||||
|
<Ionicons name="warning" size={22} color={colors.primary} />
|
||||||
|
</View>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={dismissGuestWarning}
|
||||||
|
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
||||||
|
style={styles.guestWarningClose}
|
||||||
|
>
|
||||||
|
<Ionicons name="close" size={20} color={colors.textSecondary} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text style={styles.guestWarningTitle}>Your account isn't saved</Text>
|
||||||
|
<Text style={styles.guestWarningBody}>
|
||||||
|
If you delete the app or switch devices, you won't be able to recover this account.
|
||||||
|
Save your account now to protect your couple, matches, and purchases.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<View style={styles.guestWarningActions}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.guestWarningPrimary}
|
||||||
|
onPress={async () => {
|
||||||
|
await markGuestAccountWarningSeen();
|
||||||
|
setShowGuestWarning(false);
|
||||||
|
router.push("/(app)/settings/save-account" as any);
|
||||||
|
}}
|
||||||
|
activeOpacity={0.8}
|
||||||
|
>
|
||||||
|
<Text style={styles.guestWarningPrimaryText}>Save account</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.guestWarningSecondary}
|
||||||
|
onPress={async () => {
|
||||||
|
await markGuestAccountWarningSeen();
|
||||||
|
setShowGuestWarning(false);
|
||||||
|
}}
|
||||||
|
activeOpacity={0.8}
|
||||||
|
>
|
||||||
|
<Text style={styles.guestWarningSecondaryText}>Not now</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
</Pressable>
|
||||||
|
</Modal>
|
||||||
</GestureHandlerRootView>
|
</GestureHandlerRootView>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
guestWarningOverlay: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
padding: spacing.lg,
|
||||||
|
backgroundColor: "rgba(0, 0, 0, 0.75)",
|
||||||
|
},
|
||||||
|
guestWarningCard: {
|
||||||
|
width: "100%",
|
||||||
|
maxWidth: 420,
|
||||||
|
backgroundColor: colors.backgroundLight,
|
||||||
|
borderRadius: radius.xl,
|
||||||
|
padding: spacing.lg,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.glass.border,
|
||||||
|
},
|
||||||
|
guestWarningHeader: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
marginBottom: spacing.md,
|
||||||
|
},
|
||||||
|
guestWarningIcon: {
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
borderRadius: 20,
|
||||||
|
backgroundColor: colors.primaryLight,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
guestWarningClose: {
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
borderRadius: 20,
|
||||||
|
backgroundColor: colors.glass.background,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
guestWarningTitle: {
|
||||||
|
...typography.headline,
|
||||||
|
color: colors.text,
|
||||||
|
marginBottom: spacing.sm,
|
||||||
|
},
|
||||||
|
guestWarningBody: {
|
||||||
|
...typography.body,
|
||||||
|
color: colors.textSecondary,
|
||||||
|
lineHeight: 22,
|
||||||
|
marginBottom: spacing.lg,
|
||||||
|
},
|
||||||
|
guestWarningActions: {
|
||||||
|
gap: spacing.sm,
|
||||||
|
},
|
||||||
|
guestWarningPrimary: {
|
||||||
|
backgroundColor: colors.primary,
|
||||||
|
paddingVertical: 14,
|
||||||
|
borderRadius: radius.md,
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
guestWarningPrimaryText: {
|
||||||
|
...typography.subhead,
|
||||||
|
color: colors.text,
|
||||||
|
fontWeight: "600",
|
||||||
|
},
|
||||||
|
guestWarningSecondary: {
|
||||||
|
backgroundColor: colors.glass.background,
|
||||||
|
paddingVertical: 14,
|
||||||
|
borderRadius: radius.md,
|
||||||
|
alignItems: "center",
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.glass.border,
|
||||||
|
},
|
||||||
|
guestWarningSecondaryText: {
|
||||||
|
...typography.subhead,
|
||||||
|
color: colors.textSecondary,
|
||||||
|
fontWeight: "600",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
@@ -15,6 +15,7 @@ describe('authStore', () => {
|
|||||||
partner: null,
|
partner: null,
|
||||||
isLoading: true,
|
isLoading: true,
|
||||||
isAuthenticated: false,
|
isAuthenticated: false,
|
||||||
|
isAnonymous: false,
|
||||||
} as any);
|
} as any);
|
||||||
|
|
||||||
useMatchStore.setState({ matches: [{ id: 'm1' }], newMatchesCount: 1 } as any);
|
useMatchStore.setState({ matches: [{ id: 'm1' }], newMatchesCount: 1 } as any);
|
||||||
@@ -31,9 +32,35 @@ describe('authStore', () => {
|
|||||||
const state = useAuthStore.getState();
|
const state = useAuthStore.getState();
|
||||||
expect(state.user).toBeNull();
|
expect(state.user).toBeNull();
|
||||||
expect(state.isAuthenticated).toBe(false);
|
expect(state.isAuthenticated).toBe(false);
|
||||||
|
expect(state.isAnonymous).toBe(false);
|
||||||
expect(state.isLoading).toBe(false);
|
expect(state.isLoading).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('sets isAnonymous from auth user', async () => {
|
||||||
|
(supabase.auth.getSession as jest.Mock).mockResolvedValueOnce({
|
||||||
|
data: { session: { user: { id: 'me' } } },
|
||||||
|
});
|
||||||
|
|
||||||
|
(supabase.auth.getUser as jest.Mock).mockResolvedValueOnce({
|
||||||
|
data: { user: { id: 'me', is_anonymous: true } },
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const profileQuery: any = {
|
||||||
|
select: jest.fn(() => profileQuery),
|
||||||
|
eq: jest.fn(() => profileQuery),
|
||||||
|
maybeSingle: jest.fn(async () => ({ data: { id: 'me', couple_id: null } })),
|
||||||
|
};
|
||||||
|
|
||||||
|
(supabase.from as jest.Mock).mockReturnValue(profileQuery);
|
||||||
|
|
||||||
|
await useAuthStore.getState().fetchUser();
|
||||||
|
|
||||||
|
const state = useAuthStore.getState();
|
||||||
|
expect(state.isAuthenticated).toBe(true);
|
||||||
|
expect(state.isAnonymous).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it('clears stores and signs out when session invalid', async () => {
|
it('clears stores and signs out when session invalid', async () => {
|
||||||
(supabase.auth.getSession as jest.Mock).mockResolvedValueOnce({
|
(supabase.auth.getSession as jest.Mock).mockResolvedValueOnce({
|
||||||
data: { session: { user: { id: 'me' } } },
|
data: { session: { user: { id: 'me' } } },
|
||||||
@@ -50,51 +77,9 @@ describe('authStore', () => {
|
|||||||
|
|
||||||
expect(useAuthStore.getState().user).toBeNull();
|
expect(useAuthStore.getState().user).toBeNull();
|
||||||
expect(useAuthStore.getState().isAuthenticated).toBe(false);
|
expect(useAuthStore.getState().isAuthenticated).toBe(false);
|
||||||
|
expect(useAuthStore.getState().isAnonymous).toBe(false);
|
||||||
|
|
||||||
expect(useMatchStore.getState().matches).toEqual([]);
|
expect(useAuthStore.getState().isAnonymous).toBe(false);
|
||||||
expect(usePacksStore.getState().enabledPackIds).toEqual([]);
|
|
||||||
expect(useMessageStore.getState().unreadCount).toBe(0);
|
|
||||||
expect(useMessageStore.getState().activeMatchId).toBeNull();
|
|
||||||
expect(useSubscriptionStore.getState().subscription.isProUser).toBe(false);
|
|
||||||
|
|
||||||
expect(supabase.auth.signOut).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('fetches couple and partner when couple_id exists', async () => {
|
|
||||||
useAuthStore.setState({ user: { id: 'me', couple_id: 'c1' } } as any);
|
|
||||||
|
|
||||||
const couplesQuery: any = {
|
|
||||||
select: jest.fn(() => couplesQuery),
|
|
||||||
eq: jest.fn(() => couplesQuery),
|
|
||||||
maybeSingle: jest.fn(async () => ({ data: { id: 'c1' } })),
|
|
||||||
};
|
|
||||||
|
|
||||||
const partnerQuery: any = {
|
|
||||||
select: jest.fn(() => partnerQuery),
|
|
||||||
eq: jest.fn(() => partnerQuery),
|
|
||||||
neq: jest.fn(() => partnerQuery),
|
|
||||||
maybeSingle: jest.fn(async () => ({ data: { id: 'partner' } })),
|
|
||||||
};
|
|
||||||
|
|
||||||
(supabase.from as jest.Mock).mockImplementation((table: string) => {
|
|
||||||
if (table === 'couples') return couplesQuery;
|
|
||||||
if (table === 'profiles') return partnerQuery;
|
|
||||||
return couplesQuery;
|
|
||||||
});
|
|
||||||
|
|
||||||
await useAuthStore.getState().fetchCouple();
|
|
||||||
|
|
||||||
expect(useAuthStore.getState().couple).toEqual({ id: 'c1' });
|
|
||||||
expect(useAuthStore.getState().partner).toEqual({ id: 'partner' });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('signOut clears local state even if Supabase fails', async () => {
|
|
||||||
(supabase.auth.signOut as jest.Mock).mockRejectedValueOnce(new Error('network'));
|
|
||||||
|
|
||||||
await useAuthStore.getState().signOut();
|
|
||||||
|
|
||||||
expect(useAuthStore.getState().user).toBeNull();
|
|
||||||
expect(useAuthStore.getState().isAuthenticated).toBe(false);
|
|
||||||
expect(useAuthStore.getState().isLoading).toBe(false);
|
expect(useAuthStore.getState().isLoading).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -13,7 +13,8 @@ import {
|
|||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { LinearGradient } from "expo-linear-gradient";
|
import { LinearGradient } from "expo-linear-gradient";
|
||||||
import { useSubscriptionStore } from "../../store";
|
import { router } from "expo-router";
|
||||||
|
import { useAuthStore, useSubscriptionStore } from "../../store";
|
||||||
import { colors, gradients, spacing, radius, typography } from "../../theme";
|
import { colors, gradients, spacing, radius, typography } from "../../theme";
|
||||||
import type { PurchasesPackage } from "../../lib/revenuecat";
|
import type { PurchasesPackage } from "../../lib/revenuecat";
|
||||||
import { Events } from "../../lib/analytics";
|
import { Events } from "../../lib/analytics";
|
||||||
@@ -42,10 +43,12 @@ const FEATURES = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export function Paywall({ visible, onClose, onSuccess }: PaywallProps) {
|
export function Paywall({ visible, onClose, onSuccess }: PaywallProps) {
|
||||||
const handleClose = (reason: "dismiss" | "success" | "restore" | "system") => {
|
const { isAnonymous } = useAuthStore();
|
||||||
Events.paywallClosed("paywall", reason);
|
|
||||||
onClose();
|
const handleClose = (reason: "dismiss" | "success" | "restore" | "system") => {
|
||||||
|
Events.paywallClosed("paywall", reason);
|
||||||
|
onClose();
|
||||||
};
|
};
|
||||||
const {
|
const {
|
||||||
offerings,
|
offerings,
|
||||||
@@ -60,6 +63,8 @@ export function Paywall({ visible, onClose, onSuccess }: PaywallProps) {
|
|||||||
const [selectedPackage, setSelectedPackage] = useState<PurchasesPackage | null>(
|
const [selectedPackage, setSelectedPackage] = useState<PurchasesPackage | null>(
|
||||||
null
|
null
|
||||||
);
|
);
|
||||||
|
const [showGuestConfirm, setShowGuestConfirm] = useState(false);
|
||||||
|
const [guestConfirmed, setGuestConfirmed] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (visible && !offerings) {
|
if (visible && !offerings) {
|
||||||
@@ -67,6 +72,8 @@ export function Paywall({ visible, onClose, onSuccess }: PaywallProps) {
|
|||||||
}
|
}
|
||||||
if (visible) {
|
if (visible) {
|
||||||
Events.paywallShown("paywall");
|
Events.paywallShown("paywall");
|
||||||
|
setGuestConfirmed(false);
|
||||||
|
setShowGuestConfirm(false);
|
||||||
}
|
}
|
||||||
}, [visible, offerings, fetchOfferings]);
|
}, [visible, offerings, fetchOfferings]);
|
||||||
|
|
||||||
@@ -80,36 +87,47 @@ export function Paywall({ visible, onClose, onSuccess }: PaywallProps) {
|
|||||||
}
|
}
|
||||||
}, [offerings]);
|
}, [offerings]);
|
||||||
|
|
||||||
const handlePurchase = async () => {
|
const doPurchase = async () => {
|
||||||
if (!selectedPackage) return;
|
if (!selectedPackage) return;
|
||||||
|
|
||||||
const packageType = selectedPackage.packageType || "unknown";
|
|
||||||
Events.purchaseInitiated(packageType);
|
|
||||||
|
|
||||||
const result = await purchasePackage(selectedPackage);
|
|
||||||
if (result.success) {
|
|
||||||
Events.purchaseCompleted(packageType);
|
|
||||||
onSuccess?.();
|
|
||||||
handleClose("success");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (result.cancelled) {
|
|
||||||
Events.purchaseCancelled(packageType, result.errorCode);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
Events.purchaseFailed(packageType, result.errorCode, result.errorMessage);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRestore = async () => {
|
const packageType = selectedPackage.packageType || "unknown";
|
||||||
const restored = await restorePurchases();
|
Events.purchaseInitiated(packageType);
|
||||||
if (restored) {
|
|
||||||
Events.purchaseRestored();
|
const result = await purchasePackage(selectedPackage);
|
||||||
onSuccess?.();
|
if (result.success) {
|
||||||
handleClose("restore");
|
Events.purchaseCompleted(packageType);
|
||||||
}
|
onSuccess?.();
|
||||||
};
|
handleClose("success");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.cancelled) {
|
||||||
|
Events.purchaseCancelled(packageType, result.errorCode);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Events.purchaseFailed(packageType, result.errorCode, result.errorMessage);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handlePurchase = async () => {
|
||||||
|
if (!selectedPackage) return;
|
||||||
|
|
||||||
|
if (isAnonymous && !guestConfirmed) {
|
||||||
|
setShowGuestConfirm(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await doPurchase();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRestore = async () => {
|
||||||
|
const restored = await restorePurchases();
|
||||||
|
if (restored) {
|
||||||
|
Events.purchaseRestored();
|
||||||
|
onSuccess?.();
|
||||||
|
handleClose("restore");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const formatPrice = (pkg: PurchasesPackage) => {
|
const formatPrice = (pkg: PurchasesPackage) => {
|
||||||
const price = pkg.product.priceString;
|
const price = pkg.product.priceString;
|
||||||
@@ -143,12 +161,12 @@ export function Paywall({ visible, onClose, onSuccess }: PaywallProps) {
|
|||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
visible={visible}
|
visible={visible}
|
||||||
transparent
|
transparent
|
||||||
animationType="slide"
|
animationType="slide"
|
||||||
onRequestClose={() => handleClose("system")}
|
onRequestClose={() => handleClose("system")}
|
||||||
>
|
>
|
||||||
<View style={styles.overlay}>
|
<View style={styles.overlay}>
|
||||||
<View style={styles.content}>
|
<View style={styles.content}>
|
||||||
{/* Premium gradient background */}
|
{/* Premium gradient background */}
|
||||||
@@ -169,11 +187,11 @@ export function Paywall({ visible, onClose, onSuccess }: PaywallProps) {
|
|||||||
contentContainerStyle={styles.scrollContent}
|
contentContainerStyle={styles.scrollContent}
|
||||||
>
|
>
|
||||||
{/* Close Button */}
|
{/* Close Button */}
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={styles.closeButton}
|
style={styles.closeButton}
|
||||||
onPress={() => handleClose("dismiss")}
|
onPress={() => handleClose("dismiss")}
|
||||||
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
||||||
>
|
>
|
||||||
<Ionicons name="close" size={24} color={colors.textSecondary} />
|
<Ionicons name="close" size={24} color={colors.textSecondary} />
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|
||||||
@@ -398,6 +416,59 @@ export function Paywall({ visible, onClose, onSuccess }: PaywallProps) {
|
|||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
|
||||||
|
{showGuestConfirm && (
|
||||||
|
<View style={styles.guestConfirmOverlay}>
|
||||||
|
<Pressable
|
||||||
|
style={StyleSheet.absoluteFill}
|
||||||
|
onPress={() => setShowGuestConfirm(false)}
|
||||||
|
/>
|
||||||
|
<View style={styles.guestConfirmCard}>
|
||||||
|
<View style={styles.guestConfirmHeader}>
|
||||||
|
<View style={styles.guestConfirmIcon}>
|
||||||
|
<Ionicons name="shield-checkmark" size={22} color={colors.premium.gold} />
|
||||||
|
</View>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => setShowGuestConfirm(false)}
|
||||||
|
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
||||||
|
style={styles.guestConfirmClose}
|
||||||
|
>
|
||||||
|
<Ionicons name="close" size={18} color={colors.textSecondary} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text style={styles.guestConfirmTitle}>Save your account first?</Text>
|
||||||
|
<Text style={styles.guestConfirmBody}>
|
||||||
|
Unsaved accounts can't be recovered if you delete the app or switch devices.
|
||||||
|
Saving your account protects your access to Pro.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<View style={styles.guestConfirmActions}>
|
||||||
|
<Pressable
|
||||||
|
style={styles.guestConfirmPrimary}
|
||||||
|
onPress={async () => {
|
||||||
|
setShowGuestConfirm(false);
|
||||||
|
handleClose("dismiss");
|
||||||
|
router.push("/(app)/settings/save-account" as any);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={styles.guestConfirmPrimaryText}>Save account</Text>
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
<Pressable
|
||||||
|
style={styles.guestConfirmSecondary}
|
||||||
|
onPress={async () => {
|
||||||
|
setGuestConfirmed(true);
|
||||||
|
setShowGuestConfirm(false);
|
||||||
|
await doPurchase();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={styles.guestConfirmSecondaryText}>Continue purchase</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</Modal>
|
</Modal>
|
||||||
@@ -760,6 +831,86 @@ const styles = StyleSheet.create({
|
|||||||
...typography.caption1,
|
...typography.caption1,
|
||||||
color: colors.textTertiary,
|
color: colors.textTertiary,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
guestConfirmOverlay: {
|
||||||
|
...StyleSheet.absoluteFillObject,
|
||||||
|
backgroundColor: "rgba(0, 0, 0, 0.7)",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
padding: spacing.lg,
|
||||||
|
},
|
||||||
|
guestConfirmCard: {
|
||||||
|
width: "100%",
|
||||||
|
maxWidth: 420,
|
||||||
|
backgroundColor: "rgba(22, 33, 62, 0.98)",
|
||||||
|
borderRadius: radius.xl,
|
||||||
|
padding: spacing.lg,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: "rgba(212, 175, 55, 0.2)",
|
||||||
|
},
|
||||||
|
guestConfirmHeader: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
marginBottom: spacing.md,
|
||||||
|
},
|
||||||
|
guestConfirmIcon: {
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
borderRadius: 20,
|
||||||
|
backgroundColor: "rgba(212, 175, 55, 0.12)",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
guestConfirmClose: {
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
borderRadius: 20,
|
||||||
|
backgroundColor: "rgba(255, 255, 255, 0.08)",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: "rgba(255, 255, 255, 0.1)",
|
||||||
|
},
|
||||||
|
guestConfirmTitle: {
|
||||||
|
...typography.title3,
|
||||||
|
color: colors.text,
|
||||||
|
marginBottom: spacing.sm,
|
||||||
|
},
|
||||||
|
guestConfirmBody: {
|
||||||
|
...typography.subhead,
|
||||||
|
color: colors.textSecondary,
|
||||||
|
lineHeight: 20,
|
||||||
|
marginBottom: spacing.lg,
|
||||||
|
textAlign: "left",
|
||||||
|
},
|
||||||
|
guestConfirmActions: {
|
||||||
|
gap: spacing.sm,
|
||||||
|
},
|
||||||
|
guestConfirmPrimary: {
|
||||||
|
backgroundColor: colors.premium.gold,
|
||||||
|
paddingVertical: spacing.md,
|
||||||
|
borderRadius: radius.lg,
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
guestConfirmPrimaryText: {
|
||||||
|
...typography.headline,
|
||||||
|
color: colors.background,
|
||||||
|
fontWeight: "700",
|
||||||
|
},
|
||||||
|
guestConfirmSecondary: {
|
||||||
|
backgroundColor: "rgba(255, 255, 255, 0.06)",
|
||||||
|
paddingVertical: spacing.md,
|
||||||
|
borderRadius: radius.lg,
|
||||||
|
alignItems: "center",
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: "rgba(255, 255, 255, 0.12)",
|
||||||
|
},
|
||||||
|
guestConfirmSecondaryText: {
|
||||||
|
...typography.subhead,
|
||||||
|
color: colors.text,
|
||||||
|
fontWeight: "600",
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export default Paywall;
|
export default Paywall;
|
||||||
@@ -29,7 +29,7 @@ export function ProfileScreen() {
|
|||||||
const { width } = useWindowDimensions();
|
const { width } = useWindowDimensions();
|
||||||
const isWideScreen = width > MAX_CONTENT_WIDTH;
|
const isWideScreen = width > MAX_CONTENT_WIDTH;
|
||||||
|
|
||||||
const { user, partner, fetchUser } = useAuthStore();
|
const { user, partner, fetchUser, isAnonymous } = useAuthStore();
|
||||||
const { subscription } = useSubscriptionStore();
|
const { subscription } = useSubscriptionStore();
|
||||||
const settings = useProfileSettings();
|
const settings = useProfileSettings();
|
||||||
|
|
||||||
@@ -116,7 +116,9 @@ export function ProfileScreen() {
|
|||||||
</LinearGradient>
|
</LinearGradient>
|
||||||
<View style={styles.profileInfo}>
|
<View style={styles.profileInfo}>
|
||||||
<Text style={styles.userName}>{user?.name || "User"}</Text>
|
<Text style={styles.userName}>{user?.name || "User"}</Text>
|
||||||
<Text style={styles.userEmail}>{user?.email}</Text>
|
<Text style={styles.userEmail}>
|
||||||
|
{isAnonymous ? "Unsaved account" : (user?.email || "")}
|
||||||
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<View style={styles.profileChevron}>
|
<View style={styles.profileChevron}>
|
||||||
<Ionicons name="chevron-forward" size={20} color={colors.textTertiary} />
|
<Ionicons name="chevron-forward" size={20} color={colors.textTertiary} />
|
||||||
@@ -172,6 +174,17 @@ export function ProfileScreen() {
|
|||||||
|
|
||||||
{/* Other Section */}
|
{/* Other Section */}
|
||||||
<SettingsSection title="Other" delay={350}>
|
<SettingsSection title="Other" delay={350}>
|
||||||
|
{isAnonymous && (
|
||||||
|
<>
|
||||||
|
<MenuItem
|
||||||
|
icon="shield-checkmark-outline"
|
||||||
|
label="Save Account"
|
||||||
|
description="Protect access across devices"
|
||||||
|
onPress={() => router.push('/(app)/settings/save-account' as any)}
|
||||||
|
/>
|
||||||
|
<View style={styles.divider} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<MenuItem
|
<MenuItem
|
||||||
icon="help-circle-outline"
|
icon="help-circle-outline"
|
||||||
label="Help & Support"
|
label="Help & Support"
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { View, ScrollView, StyleSheet, Platform } from 'react-native';
|
import { View, ScrollView, StyleSheet, Platform } from 'react-native';
|
||||||
|
import { useRouter } from 'expo-router';
|
||||||
|
|
||||||
import { GradientBackground } from '../../../components/ui';
|
import { GradientBackground } from '../../../components/ui';
|
||||||
import { colors, spacing } from '../../../theme';
|
import { colors, spacing } from '../../../theme';
|
||||||
@@ -10,9 +11,10 @@ import { ScreenHeader, SettingsSection, MenuItem, DangerZone } from '../componen
|
|||||||
/**
|
/**
|
||||||
* Account settings sub-screen (sign out, delete account).
|
* Account settings sub-screen (sign out, delete account).
|
||||||
*/
|
*/
|
||||||
export function AccountScreen() {
|
export function AccountScreen() {
|
||||||
const { couple } = useAuthStore();
|
const router = useRouter();
|
||||||
const { handleSignOut, handleDeleteAccount } = useCoupleManagement();
|
const { couple, isAnonymous } = useAuthStore();
|
||||||
|
const { handleSignOut, handleDeleteAccount } = useCoupleManagement();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<GradientBackground>
|
<GradientBackground>
|
||||||
@@ -22,15 +24,25 @@ export function AccountScreen() {
|
|||||||
contentContainerStyle={styles.content}
|
contentContainerStyle={styles.content}
|
||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
>
|
>
|
||||||
<SettingsSection title="Account" delay={0}>
|
<SettingsSection title="Account" delay={0}>
|
||||||
<MenuItem
|
{isAnonymous ? (
|
||||||
icon="log-out-outline"
|
<MenuItem
|
||||||
label="Sign Out"
|
icon="shield-checkmark-outline"
|
||||||
onPress={handleSignOut}
|
label="Save Account"
|
||||||
variant="danger"
|
description="Protect access across devices"
|
||||||
showChevron={false}
|
onPress={() => router.push("/(app)/settings/save-account" as any)}
|
||||||
/>
|
showChevron={false}
|
||||||
</SettingsSection>
|
/>
|
||||||
|
) : (
|
||||||
|
<MenuItem
|
||||||
|
icon="log-out-outline"
|
||||||
|
label="Sign Out"
|
||||||
|
onPress={handleSignOut}
|
||||||
|
variant="danger"
|
||||||
|
showChevron={false}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</SettingsSection>
|
||||||
|
|
||||||
<View style={styles.spacer} />
|
<View style={styles.spacer} />
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,590 @@
|
|||||||
|
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import {
|
||||||
|
View,
|
||||||
|
Text,
|
||||||
|
ScrollView,
|
||||||
|
StyleSheet,
|
||||||
|
Platform,
|
||||||
|
Alert,
|
||||||
|
TouchableOpacity,
|
||||||
|
} from "react-native";
|
||||||
|
import { useRouter } from "expo-router";
|
||||||
|
import * as Linking from "expo-linking";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
|
||||||
|
// Safely import platform auth modules
|
||||||
|
let AppleAuthentication: typeof import("expo-apple-authentication") | null = null;
|
||||||
|
let GoogleSignin: typeof import("@react-native-google-signin/google-signin").GoogleSignin | null = null;
|
||||||
|
|
||||||
|
if (Platform.OS === "ios") {
|
||||||
|
try {
|
||||||
|
AppleAuthentication = require("expo-apple-authentication");
|
||||||
|
} catch {
|
||||||
|
AppleAuthentication = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Platform.OS === "android") {
|
||||||
|
try {
|
||||||
|
const googleModule = require("@react-native-google-signin/google-signin");
|
||||||
|
GoogleSignin = googleModule.GoogleSignin;
|
||||||
|
if (GoogleSignin) {
|
||||||
|
GoogleSignin.configure({
|
||||||
|
webClientId: "764866133492-e78o2rh3rdjc4rfj2j6r2j77b065kggm.apps.googleusercontent.com",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
GoogleSignin = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
import { GradientBackground, GlassButton, GlassCard, GlassInput } from "../../../components/ui";
|
||||||
|
import { colors, spacing, radius, typography } from "../../../theme";
|
||||||
|
import { supabase } from "../../../lib/supabase";
|
||||||
|
import { getAuthError } from "../../../lib/errors";
|
||||||
|
import { useAuthStore } from "../../../store";
|
||||||
|
import { ScreenHeader } from "../components";
|
||||||
|
|
||||||
|
type SaveMode = "magic-link" | "password";
|
||||||
|
|
||||||
|
export function SaveAccountScreen() {
|
||||||
|
const router = useRouter();
|
||||||
|
const { isAnonymous, fetchUser } = useAuthStore();
|
||||||
|
|
||||||
|
const [mode, setMode] = useState<SaveMode>("magic-link");
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const [isChecking, setIsChecking] = useState(false);
|
||||||
|
const [pendingEmail, setPendingEmail] = useState<string | null>(null);
|
||||||
|
const [pendingPassword, setPendingPassword] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const [isAppleAvailable, setIsAppleAvailable] = useState(false);
|
||||||
|
const [isGoogleAvailable, setIsGoogleAvailable] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (Platform.OS === "ios" && AppleAuthentication) {
|
||||||
|
AppleAuthentication.isAvailableAsync()
|
||||||
|
.then(setIsAppleAvailable)
|
||||||
|
.catch(() => setIsAppleAvailable(false));
|
||||||
|
}
|
||||||
|
if (Platform.OS === "android" && GoogleSignin) {
|
||||||
|
setIsGoogleAvailable(true);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const emailRedirectTo = useMemo(() => Linking.createURL("/(auth)/login"), []);
|
||||||
|
|
||||||
|
const showError = useCallback((message: string) => {
|
||||||
|
Alert.alert("Error", message);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const showSuccess = useCallback((message: string) => {
|
||||||
|
Alert.alert("Success", message);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const syncProfileEmail = useCallback(async (userId: string, nextEmail: string | null | undefined) => {
|
||||||
|
if (!nextEmail) return;
|
||||||
|
try {
|
||||||
|
await supabase
|
||||||
|
.from("profiles")
|
||||||
|
.update({ email: nextEmail })
|
||||||
|
.eq("id", userId);
|
||||||
|
} catch {
|
||||||
|
// Non-blocking (some envs may restrict this by RLS)
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSaveWithEmail = useCallback(async () => {
|
||||||
|
if (!email.trim()) {
|
||||||
|
showError("Please enter your email");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (mode === "password" && password.length < 8) {
|
||||||
|
showError("Password must be at least 8 characters");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSubmitting(true);
|
||||||
|
const { error } = await supabase.auth.updateUser(
|
||||||
|
{ email: email.trim() },
|
||||||
|
{ emailRedirectTo } as any
|
||||||
|
);
|
||||||
|
setIsSubmitting(false);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
const friendly = getAuthError(error);
|
||||||
|
if (__DEV__ && friendly === "Something went wrong. Please try again.") {
|
||||||
|
showError(`${friendly}\n\n${error.message}`);
|
||||||
|
} else {
|
||||||
|
showError(friendly);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setPendingEmail(email.trim());
|
||||||
|
setPendingPassword(mode === "password" ? password : null);
|
||||||
|
|
||||||
|
Alert.alert(
|
||||||
|
"Check your email",
|
||||||
|
"We sent you a link to confirm your email. Once you confirm, come back and tap “I’ve verified my email”."
|
||||||
|
);
|
||||||
|
}, [email, password, mode, emailRedirectTo, showError]);
|
||||||
|
|
||||||
|
const handleCheckVerification = useCallback(async () => {
|
||||||
|
setIsChecking(true);
|
||||||
|
const { data: { user }, error } = await supabase.auth.getUser();
|
||||||
|
setIsChecking(false);
|
||||||
|
|
||||||
|
if (error || !user) {
|
||||||
|
showError(getAuthError(error) || "Unable to check account status");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isStillAnonymous = !!(user as any).is_anonymous;
|
||||||
|
if (isStillAnonymous) {
|
||||||
|
Alert.alert("Not yet verified", "Please click the link in your email to finish saving your account.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If user chose password mode, set password after email is verified
|
||||||
|
if (pendingPassword) {
|
||||||
|
setIsSubmitting(true);
|
||||||
|
const { error: passwordError } = await supabase.auth.updateUser({ password: pendingPassword });
|
||||||
|
setIsSubmitting(false);
|
||||||
|
|
||||||
|
if (passwordError) {
|
||||||
|
showError(getAuthError(passwordError));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await syncProfileEmail(user.id, user.email);
|
||||||
|
await fetchUser();
|
||||||
|
|
||||||
|
showSuccess("Account saved. You can now recover your account on any device.");
|
||||||
|
router.navigate("/(app)/profile" as any);
|
||||||
|
}, [pendingPassword, fetchUser, showError, showSuccess, router, syncProfileEmail]);
|
||||||
|
|
||||||
|
const handleLinkApple = useCallback(async () => {
|
||||||
|
if (!AppleAuthentication) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const credential = await AppleAuthentication.signInAsync({
|
||||||
|
requestedScopes: [
|
||||||
|
AppleAuthentication.AppleAuthenticationScope.FULL_NAME,
|
||||||
|
AppleAuthentication.AppleAuthenticationScope.EMAIL,
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!credential.identityToken) {
|
||||||
|
showError("No identity token received from Apple");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSubmitting(true);
|
||||||
|
const { error } = await (supabase.auth.linkIdentity as any)({
|
||||||
|
provider: "apple",
|
||||||
|
token: credential.identityToken,
|
||||||
|
});
|
||||||
|
setIsSubmitting(false);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
showError(getAuthError(error));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apple only provides name/email on first auth grant
|
||||||
|
if (credential.fullName) {
|
||||||
|
const nameParts = [
|
||||||
|
credential.fullName.givenName,
|
||||||
|
credential.fullName.middleName,
|
||||||
|
credential.fullName.familyName,
|
||||||
|
].filter(Boolean);
|
||||||
|
const fullName = nameParts.join(" ");
|
||||||
|
|
||||||
|
if (fullName) {
|
||||||
|
await supabase.auth.updateUser({
|
||||||
|
data: {
|
||||||
|
full_name: fullName,
|
||||||
|
given_name: credential.fullName.givenName,
|
||||||
|
family_name: credential.fullName.familyName,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh user/profile
|
||||||
|
const { data: { user } } = await supabase.auth.getUser();
|
||||||
|
if (user) {
|
||||||
|
await syncProfileEmail(user.id, user.email);
|
||||||
|
}
|
||||||
|
await fetchUser();
|
||||||
|
|
||||||
|
showSuccess("Account saved with Apple.");
|
||||||
|
router.navigate("/(app)/profile" as any);
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e?.code === "ERR_REQUEST_CANCELED") return;
|
||||||
|
showError("Apple sign in failed. Please try again.");
|
||||||
|
}
|
||||||
|
}, [fetchUser, router, showError, showSuccess, syncProfileEmail]);
|
||||||
|
|
||||||
|
const handleLinkGoogle = useCallback(async () => {
|
||||||
|
if (!GoogleSignin) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await GoogleSignin.hasPlayServices();
|
||||||
|
const response = await GoogleSignin.signIn();
|
||||||
|
|
||||||
|
if (response.type !== "success" || !response.data.idToken) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSubmitting(true);
|
||||||
|
const { error } = await (supabase.auth.linkIdentity as any)({
|
||||||
|
provider: "google",
|
||||||
|
token: response.data.idToken,
|
||||||
|
});
|
||||||
|
setIsSubmitting(false);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
showError(getAuthError(error));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data: { user } } = await supabase.auth.getUser();
|
||||||
|
if (user) {
|
||||||
|
await syncProfileEmail(user.id, user.email);
|
||||||
|
}
|
||||||
|
await fetchUser();
|
||||||
|
|
||||||
|
showSuccess("Account saved with Google.");
|
||||||
|
router.navigate("/(app)/profile" as any);
|
||||||
|
} catch {
|
||||||
|
showError("Google sign in failed. Please try again.");
|
||||||
|
}
|
||||||
|
}, [fetchUser, router, showError, showSuccess, syncProfileEmail]);
|
||||||
|
|
||||||
|
const showSocial = Platform.select({ ios: true, android: true, default: false });
|
||||||
|
|
||||||
|
if (!isAnonymous) {
|
||||||
|
return (
|
||||||
|
<GradientBackground>
|
||||||
|
<ScreenHeader title="Save Account" />
|
||||||
|
<View style={styles.centered}>
|
||||||
|
<GlassCard variant="elevated" style={styles.readyCard}>
|
||||||
|
<Ionicons name="checkmark-circle" size={36} color={colors.success} />
|
||||||
|
<Text style={styles.readyTitle}>Your account is already saved</Text>
|
||||||
|
<Text style={styles.readyBody}>
|
||||||
|
You're signed in with a recoverable account.
|
||||||
|
</Text>
|
||||||
|
<GlassButton onPress={() => router.navigate("/(app)/profile" as any)}>
|
||||||
|
Back to Settings
|
||||||
|
</GlassButton>
|
||||||
|
</GlassCard>
|
||||||
|
</View>
|
||||||
|
</GradientBackground>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pendingEmail) {
|
||||||
|
return (
|
||||||
|
<GradientBackground>
|
||||||
|
<ScreenHeader title="Save Account" />
|
||||||
|
<View style={styles.centered}>
|
||||||
|
<GlassCard variant="elevated" style={styles.verifyCard}>
|
||||||
|
<View style={styles.verifyIcon}>
|
||||||
|
<Ionicons name="mail-unread" size={36} color={colors.primary} />
|
||||||
|
</View>
|
||||||
|
<Text style={styles.verifyTitle}>Confirm your email</Text>
|
||||||
|
<Text style={styles.verifyBody}>
|
||||||
|
We sent a confirmation link to {pendingEmail}. Tap it, then come back here.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<GlassButton
|
||||||
|
onPress={handleCheckVerification}
|
||||||
|
loading={isChecking || isSubmitting}
|
||||||
|
disabled={isChecking || isSubmitting}
|
||||||
|
fullWidth
|
||||||
|
>
|
||||||
|
I've verified my email
|
||||||
|
</GlassButton>
|
||||||
|
<GlassButton
|
||||||
|
variant="secondary"
|
||||||
|
onPress={handleSaveWithEmail}
|
||||||
|
loading={isSubmitting}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
fullWidth
|
||||||
|
style={{ marginTop: spacing.sm }}
|
||||||
|
>
|
||||||
|
Resend email
|
||||||
|
</GlassButton>
|
||||||
|
<GlassButton
|
||||||
|
variant="ghost"
|
||||||
|
onPress={() => {
|
||||||
|
setPendingEmail(null);
|
||||||
|
setPendingPassword(null);
|
||||||
|
}}
|
||||||
|
fullWidth
|
||||||
|
style={{ marginTop: spacing.sm }}
|
||||||
|
>
|
||||||
|
Use a different email
|
||||||
|
</GlassButton>
|
||||||
|
</GlassCard>
|
||||||
|
</View>
|
||||||
|
</GradientBackground>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<GradientBackground>
|
||||||
|
<ScreenHeader title="Save Account" />
|
||||||
|
<ScrollView
|
||||||
|
style={styles.container}
|
||||||
|
contentContainerStyle={styles.content}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
>
|
||||||
|
<GlassCard variant="elevated" style={styles.heroCard}>
|
||||||
|
<Text style={styles.heroTitle}>Protect your account</Text>
|
||||||
|
<Text style={styles.heroBody}>
|
||||||
|
Unsaved accounts can't be recovered if you delete the app or switch devices.
|
||||||
|
Save your account to keep your couple, matches, and purchases.
|
||||||
|
</Text>
|
||||||
|
</GlassCard>
|
||||||
|
|
||||||
|
{showSocial && (
|
||||||
|
<GlassCard style={styles.sectionCard}>
|
||||||
|
<Text style={styles.sectionTitle}>Quick save</Text>
|
||||||
|
|
||||||
|
{Platform.OS === "ios" && (
|
||||||
|
<View style={styles.socialRow}>
|
||||||
|
{isAppleAvailable && AppleAuthentication ? (
|
||||||
|
<AppleAuthentication.AppleAuthenticationButton
|
||||||
|
buttonType={AppleAuthentication.AppleAuthenticationButtonType.SIGN_IN}
|
||||||
|
buttonStyle={AppleAuthentication.AppleAuthenticationButtonStyle.WHITE}
|
||||||
|
cornerRadius={12}
|
||||||
|
style={styles.appleButton}
|
||||||
|
onPress={handleLinkApple}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<GlassButton
|
||||||
|
variant="secondary"
|
||||||
|
onPress={handleLinkApple}
|
||||||
|
icon={<Ionicons name="logo-apple" size={20} color={colors.text} />}
|
||||||
|
fullWidth
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
Save with Apple
|
||||||
|
</GlassButton>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{Platform.OS === "android" && isGoogleAvailable && (
|
||||||
|
<GlassButton
|
||||||
|
variant="secondary"
|
||||||
|
onPress={handleLinkGoogle}
|
||||||
|
icon={<Ionicons name="logo-google" size={20} color={colors.text} />}
|
||||||
|
fullWidth
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
Save with Google
|
||||||
|
</GlassButton>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Text style={styles.sectionHint}>
|
||||||
|
Use an account that isn't already linked to Sauci.
|
||||||
|
</Text>
|
||||||
|
</GlassCard>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<GlassCard style={styles.sectionCard}>
|
||||||
|
<Text style={styles.sectionTitle}>Save with email</Text>
|
||||||
|
|
||||||
|
<View style={styles.modeToggleContainer}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.modeToggle, mode === "magic-link" && styles.modeToggleActive]}
|
||||||
|
onPress={() => setMode("magic-link")}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<Text style={[styles.modeToggleText, mode === "magic-link" && styles.modeToggleTextActive]}>
|
||||||
|
Magic Link
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.modeToggle, mode === "password" && styles.modeToggleActive]}
|
||||||
|
onPress={() => setMode("password")}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<Text style={[styles.modeToggleText, mode === "password" && styles.modeToggleTextActive]}>
|
||||||
|
Password
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.form}>
|
||||||
|
<GlassInput
|
||||||
|
placeholder="Enter your email"
|
||||||
|
value={email}
|
||||||
|
onChangeText={setEmail}
|
||||||
|
keyboardType="email-address"
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect={false}
|
||||||
|
icon={<Ionicons name="mail-outline" size={20} color={colors.textTertiary} />}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{mode === "password" && (
|
||||||
|
<GlassInput
|
||||||
|
placeholder="Create a password"
|
||||||
|
value={password}
|
||||||
|
onChangeText={setPassword}
|
||||||
|
secureTextEntry
|
||||||
|
autoCapitalize="none"
|
||||||
|
icon={<Ionicons name="lock-closed-outline" size={20} color={colors.textTertiary} />}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<GlassButton
|
||||||
|
onPress={handleSaveWithEmail}
|
||||||
|
loading={isSubmitting}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
fullWidth
|
||||||
|
>
|
||||||
|
Save with Email
|
||||||
|
</GlassButton>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text style={styles.sectionHint}>
|
||||||
|
We'll send a confirmation link to your email.
|
||||||
|
</Text>
|
||||||
|
</GlassCard>
|
||||||
|
|
||||||
|
<View style={{ height: spacing.xl }} />
|
||||||
|
</ScrollView>
|
||||||
|
</GradientBackground>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
paddingTop: spacing.lg,
|
||||||
|
paddingBottom: Platform.OS === "ios" ? 40 : 20,
|
||||||
|
gap: spacing.lg,
|
||||||
|
},
|
||||||
|
heroCard: {
|
||||||
|
marginHorizontal: spacing.lg,
|
||||||
|
},
|
||||||
|
heroTitle: {
|
||||||
|
...typography.title3,
|
||||||
|
color: colors.text,
|
||||||
|
marginBottom: spacing.sm,
|
||||||
|
},
|
||||||
|
heroBody: {
|
||||||
|
...typography.body,
|
||||||
|
color: colors.textSecondary,
|
||||||
|
lineHeight: 22,
|
||||||
|
},
|
||||||
|
sectionCard: {
|
||||||
|
marginHorizontal: spacing.lg,
|
||||||
|
},
|
||||||
|
sectionTitle: {
|
||||||
|
...typography.headline,
|
||||||
|
color: colors.text,
|
||||||
|
marginBottom: spacing.md,
|
||||||
|
},
|
||||||
|
sectionHint: {
|
||||||
|
...typography.caption1,
|
||||||
|
color: colors.textTertiary,
|
||||||
|
marginTop: spacing.md,
|
||||||
|
},
|
||||||
|
socialRow: {
|
||||||
|
marginBottom: spacing.sm,
|
||||||
|
},
|
||||||
|
appleButton: {
|
||||||
|
width: "100%",
|
||||||
|
height: 48,
|
||||||
|
},
|
||||||
|
modeToggleContainer: {
|
||||||
|
flexDirection: "row",
|
||||||
|
backgroundColor: colors.glass.background,
|
||||||
|
borderRadius: radius.md,
|
||||||
|
padding: 4,
|
||||||
|
marginBottom: spacing.md,
|
||||||
|
},
|
||||||
|
modeToggle: {
|
||||||
|
flex: 1,
|
||||||
|
paddingVertical: 12,
|
||||||
|
borderRadius: radius.sm,
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
modeToggleActive: {
|
||||||
|
backgroundColor: colors.primary,
|
||||||
|
},
|
||||||
|
modeToggleText: {
|
||||||
|
...typography.subhead,
|
||||||
|
color: colors.textTertiary,
|
||||||
|
fontWeight: "600",
|
||||||
|
},
|
||||||
|
modeToggleTextActive: {
|
||||||
|
color: colors.text,
|
||||||
|
},
|
||||||
|
form: {
|
||||||
|
gap: spacing.sm,
|
||||||
|
},
|
||||||
|
centered: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
padding: spacing.lg,
|
||||||
|
},
|
||||||
|
readyCard: {
|
||||||
|
width: "100%",
|
||||||
|
maxWidth: 420,
|
||||||
|
alignItems: "center",
|
||||||
|
gap: spacing.sm,
|
||||||
|
},
|
||||||
|
readyTitle: {
|
||||||
|
...typography.title3,
|
||||||
|
color: colors.text,
|
||||||
|
marginTop: spacing.sm,
|
||||||
|
},
|
||||||
|
readyBody: {
|
||||||
|
...typography.body,
|
||||||
|
color: colors.textSecondary,
|
||||||
|
textAlign: "center",
|
||||||
|
marginBottom: spacing.md,
|
||||||
|
},
|
||||||
|
verifyCard: {
|
||||||
|
width: "100%",
|
||||||
|
maxWidth: 420,
|
||||||
|
alignItems: "center",
|
||||||
|
padding: spacing.lg,
|
||||||
|
},
|
||||||
|
verifyIcon: {
|
||||||
|
width: 90,
|
||||||
|
height: 90,
|
||||||
|
borderRadius: 45,
|
||||||
|
backgroundColor: colors.primaryLight,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
marginBottom: spacing.md,
|
||||||
|
},
|
||||||
|
verifyTitle: {
|
||||||
|
...typography.title3,
|
||||||
|
color: colors.text,
|
||||||
|
marginBottom: spacing.sm,
|
||||||
|
},
|
||||||
|
verifyBody: {
|
||||||
|
...typography.body,
|
||||||
|
color: colors.textSecondary,
|
||||||
|
textAlign: "center",
|
||||||
|
lineHeight: 22,
|
||||||
|
marginBottom: spacing.lg,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default SaveAccountScreen;
|
||||||
@@ -5,4 +5,5 @@ export { PreferencesScreen } from './PreferencesScreen';
|
|||||||
export { SubscriptionScreen } from './SubscriptionScreen';
|
export { SubscriptionScreen } from './SubscriptionScreen';
|
||||||
export { PartnerScreen } from './PartnerScreen';
|
export { PartnerScreen } from './PartnerScreen';
|
||||||
export { SupportScreen } from './SupportScreen';
|
export { SupportScreen } from './SupportScreen';
|
||||||
export { AccountScreen } from './AccountScreen';
|
export { AccountScreen } from './AccountScreen';
|
||||||
|
export { SaveAccountScreen } from './SaveAccountScreen';
|
||||||
@@ -10,23 +10,27 @@ interface ErrorMapping {
|
|||||||
message: string;
|
message: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const AUTH_ERROR_MAPPINGS: ErrorMapping[] = [
|
const AUTH_ERROR_MAPPINGS: ErrorMapping[] = [
|
||||||
{ pattern: /invalid login credentials/i, message: "Incorrect email or password. Please try again." },
|
{ pattern: /invalid login credentials/i, message: "Incorrect email or password. Please try again." },
|
||||||
{ pattern: /email not confirmed/i, message: "Please verify your email before signing in." },
|
{ pattern: /email not confirmed/i, message: "Please verify your email before signing in." },
|
||||||
{ pattern: /user already registered/i, message: "An account with this email already exists." },
|
{ pattern: /user already registered/i, message: "An account with this email already exists." },
|
||||||
{ pattern: /invalid email/i, message: "Please enter a valid email address." },
|
{ pattern: /email.*already.*(registered|exists|in use|used)/i, message: "This email is already in use. Please use a different one." },
|
||||||
{ pattern: /password.*too short/i, message: "Password must be at least 8 characters." },
|
{ pattern: /duplicate key value violates unique constraint.*users_email_key/i, message: "This email is already in use. Please use a different one." },
|
||||||
{ pattern: /password.*too weak/i, message: "Please choose a stronger password." },
|
{ pattern: /invalid email/i, message: "Please enter a valid email address." },
|
||||||
{ pattern: /rate limit/i, message: "Too many attempts. Please wait a moment and try again." },
|
{ pattern: /password.*too short/i, message: "Password must be at least 8 characters." },
|
||||||
{ pattern: /email rate limit/i, message: "Too many emails sent. Please wait before requesting another." },
|
{ pattern: /password.*too weak/i, message: "Please choose a stronger password." },
|
||||||
{ pattern: /same as/i, message: "New password must be different from your current password." },
|
{ pattern: /rate limit/i, message: "Too many attempts. Please wait a moment and try again." },
|
||||||
{ pattern: /session.*expired/i, message: "Your session has expired. Please log in again." },
|
{ pattern: /email rate limit/i, message: "Too many emails sent. Please wait before requesting another." },
|
||||||
{ pattern: /invalid.*token/i, message: "This link has expired. Please request a new one." },
|
{ pattern: /same as/i, message: "New password must be different from your current password." },
|
||||||
{ pattern: /otp.*expired/i, message: "This code has expired. Please request a new one." },
|
{ pattern: /session.*expired/i, message: "Your session has expired. Please log in again." },
|
||||||
{ pattern: /signups.*disabled/i, message: "Sign ups are currently disabled. Please try again later." },
|
{ pattern: /invalid.*token/i, message: "This link has expired. Please request a new one." },
|
||||||
{ pattern: /user not found/i, message: "No account found with this email." },
|
{ pattern: /otp.*expired/i, message: "This code has expired. Please request a new one." },
|
||||||
{ pattern: /provider.*disabled/i, message: "This sign-in method is currently unavailable." },
|
{ pattern: /signups.*disabled/i, message: "Sign ups are currently disabled. Please try again later." },
|
||||||
];
|
{ pattern: /user not found/i, message: "No account found with this email." },
|
||||||
|
{ pattern: /provider.*disabled/i, message: "This sign-in method is currently unavailable." },
|
||||||
|
{ pattern: /manual.*link/i, message: "Saving accounts is temporarily unavailable. Please try again later." },
|
||||||
|
{ pattern: /identity.*link/i, message: "Saving accounts is temporarily unavailable. Please try again later." },
|
||||||
|
];
|
||||||
|
|
||||||
const PAIRING_ERROR_MAPPINGS: ErrorMapping[] = [
|
const PAIRING_ERROR_MAPPINGS: ErrorMapping[] = [
|
||||||
{ pattern: /invalid.*invite.*code/i, message: "This invite code is invalid. Please check and try again." },
|
{ pattern: /invalid.*invite.*code/i, message: "This invite code is invalid. Please check and try again." },
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import * as SecureStore from "expo-secure-store";
|
||||||
|
import { Platform } from "react-native";
|
||||||
|
|
||||||
|
const STORAGE_KEY = "guest_account_recovery_warning_seen";
|
||||||
|
|
||||||
|
async function getStorage(): Promise<boolean> {
|
||||||
|
try {
|
||||||
|
let data: string | null = null;
|
||||||
|
|
||||||
|
if (Platform.OS === "web") {
|
||||||
|
if (typeof window !== "undefined" && typeof window.localStorage !== "undefined") {
|
||||||
|
data = window.localStorage.getItem(STORAGE_KEY);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
data = await SecureStore.getItemAsync(STORAGE_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
return data === "true";
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error reading guest warning state:", error);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setStorage(seen: boolean): Promise<void> {
|
||||||
|
try {
|
||||||
|
const data = seen ? "true" : "false";
|
||||||
|
|
||||||
|
if (Platform.OS === "web") {
|
||||||
|
if (typeof window !== "undefined" && typeof window.localStorage !== "undefined") {
|
||||||
|
window.localStorage.setItem(STORAGE_KEY, data);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await SecureStore.setItemAsync(STORAGE_KEY, data);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error saving guest warning state:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function hasSeenGuestAccountWarning(): Promise<boolean> {
|
||||||
|
return getStorage();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function markGuestAccountWarningSeen(): Promise<void> {
|
||||||
|
await setStorage(true);
|
||||||
|
}
|
||||||
@@ -4,12 +4,14 @@ import { Events } from "../lib/analytics";
|
|||||||
import type { Profile, Couple } from "@/types";
|
import type { Profile, Couple } from "@/types";
|
||||||
|
|
||||||
|
|
||||||
interface AuthState {
|
interface AuthState {
|
||||||
user: Profile | null;
|
user: Profile | null;
|
||||||
couple: Couple | null;
|
couple: Couple | null;
|
||||||
partner: Profile | null;
|
partner: Profile | null;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
isAuthenticated: boolean;
|
isAuthenticated: boolean;
|
||||||
|
isAnonymous: boolean;
|
||||||
|
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
fetchUser: () => Promise<void>;
|
fetchUser: () => Promise<void>;
|
||||||
@@ -36,18 +38,19 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
|||||||
user: null,
|
user: null,
|
||||||
couple: null,
|
couple: null,
|
||||||
partner: null,
|
partner: null,
|
||||||
isLoading: true,
|
isLoading: true,
|
||||||
isAuthenticated: false,
|
isAuthenticated: false,
|
||||||
|
isAnonymous: false,
|
||||||
|
|
||||||
fetchUser: async () => {
|
fetchUser: async () => {
|
||||||
try {
|
try {
|
||||||
// First check if we have a session in storage
|
// First check if we have a session in storage
|
||||||
const { data: { session } } = await supabase.auth.getSession();
|
const { data: { session } } = await supabase.auth.getSession();
|
||||||
|
|
||||||
if (!session?.user) {
|
if (!session?.user) {
|
||||||
set({ user: null, isAuthenticated: false, isLoading: false });
|
set({ user: null, isAuthenticated: false, isAnonymous: false, isLoading: false });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate the session by fetching the user from the server
|
// Validate the session by fetching the user from the server
|
||||||
// This will fail if the session was deleted server-side
|
// This will fail if the session was deleted server-side
|
||||||
@@ -56,8 +59,8 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
|||||||
if (authError || !authUser) {
|
if (authError || !authUser) {
|
||||||
console.log("[Auth] Session invalid, signing out:", authError?.message);
|
console.log("[Auth] Session invalid, signing out:", authError?.message);
|
||||||
|
|
||||||
// Session is invalid - clear everything
|
// Session is invalid - clear everything
|
||||||
set({ user: null, couple: null, partner: null, isAuthenticated: false, isLoading: false });
|
set({ user: null, couple: null, partner: null, isAuthenticated: false, isAnonymous: false, isLoading: false });
|
||||||
// Clear other stores
|
// Clear other stores
|
||||||
const { useMatchStore, usePacksStore, useMessageStore, useSubscriptionStore, useNotificationPreferencesStore, useStreakStore } = getOtherStores();
|
const { useMatchStore, usePacksStore, useMessageStore, useSubscriptionStore, useNotificationPreferencesStore, useStreakStore } = getOtherStores();
|
||||||
useMatchStore.getState().clearMatches();
|
useMatchStore.getState().clearMatches();
|
||||||
@@ -77,10 +80,13 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
|||||||
.eq("id", authUser.id)
|
.eq("id", authUser.id)
|
||||||
.maybeSingle();
|
.maybeSingle();
|
||||||
|
|
||||||
set({
|
const isAnonymous = !!(authUser as any).is_anonymous;
|
||||||
user: profile,
|
|
||||||
isAuthenticated: true,
|
set({
|
||||||
});
|
user: profile,
|
||||||
|
isAuthenticated: true,
|
||||||
|
isAnonymous,
|
||||||
|
});
|
||||||
|
|
||||||
// If user has a couple, fetch couple data; otherwise clear couple/partner
|
// If user has a couple, fetch couple data; otherwise clear couple/partner
|
||||||
if (profile?.couple_id) {
|
if (profile?.couple_id) {
|
||||||
@@ -142,13 +148,14 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
|||||||
Events.signOut();
|
Events.signOut();
|
||||||
|
|
||||||
// Clear local state FIRST to ensure UI updates even if Supabase call fails
|
// Clear local state FIRST to ensure UI updates even if Supabase call fails
|
||||||
set({
|
set({
|
||||||
user: null,
|
user: null,
|
||||||
couple: null,
|
couple: null,
|
||||||
partner: null,
|
partner: null,
|
||||||
isAuthenticated: false,
|
isAuthenticated: false,
|
||||||
isLoading: false,
|
isAnonymous: false,
|
||||||
});
|
isLoading: false,
|
||||||
|
});
|
||||||
// Clear other stores
|
// Clear other stores
|
||||||
const { useMatchStore, usePacksStore, useMessageStore, useSubscriptionStore, useNotificationPreferencesStore, useStreakStore } = getOtherStores();
|
const { useMatchStore, usePacksStore, useMessageStore, useSubscriptionStore, useNotificationPreferencesStore, useStreakStore } = getOtherStores();
|
||||||
useMatchStore.getState().clearMatches();
|
useMatchStore.getState().clearMatches();
|
||||||
@@ -171,13 +178,14 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
|||||||
},
|
},
|
||||||
|
|
||||||
setUser: (user) => {
|
setUser: (user) => {
|
||||||
set({
|
set({
|
||||||
user,
|
user,
|
||||||
isAuthenticated: !!user,
|
isAuthenticated: !!user,
|
||||||
isLoading: false,
|
...(user === null && { isAnonymous: false }),
|
||||||
// Clear couple/partner when user is null (signed out)
|
isLoading: false,
|
||||||
...(user === null && { couple: null, partner: null })
|
// Clear couple/partner when user is null (signed out)
|
||||||
});
|
...(user === null && { couple: null, partner: null })
|
||||||
|
});
|
||||||
// Clear other stores when user signs out
|
// Clear other stores when user signs out
|
||||||
if (user === null) {
|
if (user === null) {
|
||||||
const { useMatchStore, usePacksStore, useMessageStore, useSubscriptionStore, useNotificationPreferencesStore, useStreakStore } = getOtherStores();
|
const { useMatchStore, usePacksStore, useMessageStore, useSubscriptionStore, useNotificationPreferencesStore, useStreakStore } = getOtherStores();
|
||||||
|
|||||||
Reference in new issue
Block a user