Compare commits

...
5 Commits
Author SHA1 Message Date
jjbrunton 19a6bb7cda Add Google Play links to web 2026-01-17 23:27:44 +00:00
jjbrunton e28dcc1592 Fix match modal question display 2026-01-17 23:00:36 +00:00
jjbrunton 3e70669a27 Add match notification digests 2026-01-17 20:16:05 +00:00
jjbrunton b070a6f6a2 Add profile photo to onboarding flow 2026-01-17 19:22:36 +00:00
jjbrunton 349f460e15 Add guest auth and save-account flow
Introduce anonymous sign-in with in-app saving, guest recovery warnings, paywall prompts, and admin guest labeling.
2026-01-17 17:55:06 +00:00
40 changed files with 2237 additions and 301 deletions

No files matched your search

@@ -42,6 +42,7 @@ import { toast } from 'sonner';
import { AiGeneratorDialog } from '@/components/ai/AiGeneratorDialog';
import { AIPolishButton } from '@/components/ai/AIPolishButton';
import { ReviewQuestionsDialog } from '@/components/content/ReviewQuestionsDialog';
import { IconPreview } from '@/components/ui/icon-picker';
interface QuestionPack {
id: string;
@@ -525,7 +526,7 @@ export function QuestionsPage() {
<h1 className="text-3xl font-bold tracking-tight flex items-center gap-2">
{pack && (
<>
<span className="text-3xl">{pack.icon}</span>
<IconPreview value={pack.icon} fallback="heart-outline" className="text-3xl" />
{pack.name}
</>
)}
+11 -2
View File
@@ -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 && 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>
<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><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></CardContent></Card>
+45 -15
View File
@@ -17,7 +17,7 @@ import {
TableHeader,
TableRow,
} 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';
interface Profile {
@@ -32,6 +32,7 @@ interface Profile {
email_confirmed_at: string | null;
storage_bytes?: number;
partner_is_premium?: boolean;
partner_count?: number;
}
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 => {
// Check if partner has premium
// Check if partner has premium and count partners in couple
let partnerIsPremium = false;
let partnerCount = 0;
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);
partnerIsPremium = partner?.is_premium || false;
}
@@ -99,6 +102,7 @@ export function UsersPage() {
...profile,
storage_bytes: storageMap[profile.id] || 0,
partner_is_premium: partnerIsPremium,
partner_count: partnerCount,
};
});
@@ -126,9 +130,12 @@ export function UsersPage() {
setProfiles(prev => prev.filter(p => p.id !== deleted.id));
setFilteredProfiles(prev => prev.filter(p => p.id !== deleted.id));
}, []),
insertToast: {
insertToast: {
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',
},
debounceMs: 1000, // Debounce to avoid excessive refetches
@@ -191,7 +198,15 @@ export function UsersPage() {
<RealtimeStatusIndicator status={realtimeStatus} showLabel />
</div>
<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>
</div>
<div className="relative w-full sm:w-64">
@@ -245,11 +260,19 @@ export function UsersPage() {
</TableCell>
<TableCell className="text-muted-foreground">
<div className="flex items-center gap-1.5">
{profile.email || '—'}
{profile.email && !profile.email_confirmed_at && (
<span title="Email not verified">
<MailWarning className="h-3.5 w-3.5 text-amber-500" />
</span>
{profile.email ? (
<>
{profile.email}
{!profile.email_confirmed_at && (
<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>
</TableCell>
@@ -272,10 +295,17 @@ export function UsersPage() {
</TableCell>
<TableCell>
{profile.couple_id ? (
<Badge variant="secondary">
<Users className="h-3 w-3 mr-1" />
Paired
</Badge>
profile.partner_count && profile.partner_count >= 2 ? (
<Badge variant="secondary" className="bg-green-100 text-green-700 hover:bg-green-200">
<Users className="h-3 w-3 mr-1" />
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>
)}
+12
View File
@@ -9,6 +9,18 @@ export default defineConfig({
'@': path.resolve(__dirname, './src'),
},
},
optimizeDeps: {
include: ['react-icons/io5'],
},
build: {
rollupOptions: {
output: {
manualChunks: {
'react-icons': ['react-icons/io5'],
},
},
},
},
server: {
host: true,
port: 3001,
@@ -0,0 +1,39 @@
---
name: web-design-guidelines
description: Review UI code for Web Interface Guidelines compliance. Use when asked to "review my UI", "check accessibility", "audit design", "review UX", or "check my site against best practices".
metadata:
author: vercel
version: "1.0.0"
argument-hint: <file-or-pattern>
---
# Web Interface Guidelines
Review files for compliance with Web Interface Guidelines.
## How It Works
1. Fetch the latest guidelines from the source URL below
2. Read the specified files (or prompt user for files/pattern)
3. Check against all rules in the fetched guidelines
4. Output findings in the terse `file:line` format
## Guidelines Source
Fetch fresh guidelines before each review:
```
https://raw.githubusercontent.com/vercel-labs/web-interface-guidelines/main/command.md
```
Use WebFetch to retrieve the latest rules. The fetched content contains all the rules and output format instructions.
## Usage
When a user provides a file or pattern argument:
1. Fetch guidelines from the source URL above
2. Read the specified files
3. Apply all rules from the fetched guidelines
4. Output findings using the format specified in the guidelines
If no files specified, ask the user which files to review.
@@ -0,0 +1,39 @@
---
name: web-design-guidelines
description: Review UI code for Web Interface Guidelines compliance. Use when asked to "review my UI", "check accessibility", "audit design", "review UX", or "check my site against best practices".
metadata:
author: vercel
version: "1.0.0"
argument-hint: <file-or-pattern>
---
# Web Interface Guidelines
Review files for compliance with Web Interface Guidelines.
## How It Works
1. Fetch the latest guidelines from the source URL below
2. Read the specified files (or prompt user for files/pattern)
3. Check against all rules in the fetched guidelines
4. Output findings in the terse `file:line` format
## Guidelines Source
Fetch fresh guidelines before each review:
```
https://raw.githubusercontent.com/vercel-labs/web-interface-guidelines/main/command.md
```
Use WebFetch to retrieve the latest rules. The fetched content contains all the rules and output format instructions.
## Usage
When a user provides a file or pattern argument:
1. Fetch guidelines from the source URL above
2. Read the specified files
3. Apply all rules from the fetched guidelines
4. Output findings using the format specified in the guidelines
If no files specified, ask the user which files to review.
@@ -0,0 +1,39 @@
---
name: web-design-guidelines
description: Review UI code for Web Interface Guidelines compliance. Use when asked to "review my UI", "check accessibility", "audit design", "review UX", or "check my site against best practices".
metadata:
author: vercel
version: "1.0.0"
argument-hint: <file-or-pattern>
---
# Web Interface Guidelines
Review files for compliance with Web Interface Guidelines.
## How It Works
1. Fetch the latest guidelines from the source URL below
2. Read the specified files (or prompt user for files/pattern)
3. Check against all rules in the fetched guidelines
4. Output findings in the terse `file:line` format
## Guidelines Source
Fetch fresh guidelines before each review:
```
https://raw.githubusercontent.com/vercel-labs/web-interface-guidelines/main/command.md
```
Use WebFetch to retrieve the latest rules. The fetched content contains all the rules and output format instructions.
## Usage
When a user provides a file or pattern argument:
1. Fetch guidelines from the source URL above
2. Read the specified files
3. Apply all rules from the fetched guidelines
4. Output findings using the format specified in the guidelines
If no files specified, ask the user which files to review.
+1 -1
View File
@@ -93,7 +93,7 @@ android {
applicationId 'com.sauci.app'
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 31
versionCode 32
versionName "1.0.2"
}
signingConfigs {
+1 -1
View File
@@ -177,7 +177,7 @@
"backgroundColor": "#1a1a2e"
},
"package": "com.sauci.app",
"versionCode": 31,
"versionCode": 32,
"googleServicesFile": "./google-services.json",
"permissions": [
"RECEIVE_BOOT_COMPLETED",
+1 -1
View File
@@ -392,7 +392,7 @@ export default function AppLayout() {
.eq("id", payload.new.id)
.single();
if (matchWithQuestion) {
if (matchWithQuestion && matchWithQuestion.question) {
// Track milestone events before adding (so count is accurate)
const currentMatchCount = matches.length;
if (currentMatchCount === 0) {
+167 -21
View File
@@ -9,6 +9,7 @@ import {
Platform,
Keyboard,
ScrollView,
Image,
} from 'react-native';
import { useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
@@ -17,10 +18,8 @@ import Animated, {
FadeIn,
FadeInUp,
FadeOut,
FadeOutLeft,
SlideInRight,
SlideOutLeft,
Layout,
} from 'react-native-reanimated';
import { GradientBackground } from '../../src/components/ui/GradientBackground';
import { GlassCard } from '../../src/components/ui/GlassCard';
@@ -32,6 +31,7 @@ import { supabase } from '../../src/lib/supabase';
import { getProfileError } from '../../src/lib/errors';
import { registerForPushNotificationsAsync, savePushToken } from '../../src/lib/notifications';
import { Events } from '../../src/lib/analytics';
import { useAvatarPicker } from '../../src/hooks/useAvatarPicker';
import type { Gender, IntensityLevel } from '../../src/types';
const GENDER_OPTIONS: { value: Gender; label: string; icon: string }[] = [
@@ -64,12 +64,12 @@ const getInitialIntensity = (profile?: { max_intensity?: number | null; show_exp
return profile?.show_explicit_content ? 5 : DEFAULT_MAX_INTENSITY;
};
type Stage = 'name' | 'gender' | 'purpose' | 'explicit' | 'notifications';
type Stage = 'avatar' | 'name' | 'gender' | 'purpose' | 'explicit' | 'notifications';
export default function OnboardingScreen() {
const router = useRouter();
const { user, fetchUser } = useAuthStore();
const [stage, setStage] = useState<Stage>('name');
const [stage, setStage] = useState<Stage>('avatar');
const [name, setName] = useState(user?.name || '');
const [gender, setGender] = useState<Gender | null>(user?.gender || null);
const [usageReason, setUsageReason] = useState<UsageReason | null>(null);
@@ -78,10 +78,25 @@ export default function OnboardingScreen() {
const [error, setError] = useState<string | null>(null);
const inputRef = useRef<TextInput>(null);
const {
avatarUri,
isUploading: isUploadingAvatar,
showPicker: handleAvatarPress,
uploadAvatar,
} = useAvatarPicker({
userId: user?.id,
onSelect: () => setError(null),
});
useEffect(() => {
Events.onboardingStart();
}, []);
const handleAvatarContinue = () => {
setStage('name');
Events.onboardingStageComplete('avatar');
};
const handleNameSubmit = () => {
if (!name.trim()) {
setError('Please enter your name');
@@ -153,19 +168,36 @@ export default function OnboardingScreen() {
setError(null);
try {
// Upload avatar if one was selected
let avatarUrl: string | null = null;
if (avatarUri) {
avatarUrl = await uploadAvatar();
if (avatarUrl) {
Events.avatarUploaded();
}
}
const showExplicitContent = maxIntensity >= 3;
const updatedFields: string[] = ["name", "gender", "usage_reason", "max_intensity", "show_explicit_content"];
const updateData: Record<string, any> = {
name: name.trim(),
gender,
usage_reason: usageReason,
max_intensity: maxIntensity,
show_explicit_content: showExplicitContent,
onboarding_completed: true,
updated_at: new Date().toISOString(),
};
if (avatarUrl) {
updateData.avatar_url = avatarUrl;
updatedFields.push("avatar_url");
}
const { error: updateError } = await supabase
.from('profiles')
.update({
name: name.trim(),
gender,
usage_reason: usageReason,
max_intensity: maxIntensity,
show_explicit_content: showExplicitContent,
onboarding_completed: true,
updated_at: new Date().toISOString(),
})
.update(updateData)
.eq('id', user.id);
if (updateError) {
@@ -176,7 +208,7 @@ export default function OnboardingScreen() {
console.log('[Onboarding] Profile updated successfully, fetching user...');
await fetchUser();
console.log('[Onboarding] User fetched, navigating to home...');
Events.profileUpdated(["name", "gender", "usage_reason", "max_intensity", "show_explicit_content"]);
Events.profileUpdated(updatedFields);
Events.onboardingComplete();
router.replace('/');
} catch (err: any) {
@@ -189,10 +221,10 @@ export default function OnboardingScreen() {
const renderStage = () => {
switch (stage) {
case 'name':
case 'avatar':
return (
<Animated.View
key="name"
key="avatar"
entering={FadeInUp.duration(500)}
exiting={SlideOutLeft.duration(300)}
style={styles.stageContainer}
@@ -206,12 +238,73 @@ export default function OnboardingScreen() {
</LinearGradient>
<Text style={styles.title}>Welcome to Sauci</Text>
<Text style={styles.subtitle}>
Let's get to know you
Add a profile photo so your partner knows it's you
</Text>
</View>
<GlassCard style={styles.card}>
<View style={styles.avatarSection}>
<Pressable onPress={handleAvatarPress} style={styles.avatarTouchable}>
{avatarUri ? (
<Image source={{ uri: avatarUri }} style={styles.avatarImage} />
) : (
<LinearGradient
colors={[colors.glass.background, colors.glass.backgroundLight]}
style={styles.avatarPlaceholder}
>
<Ionicons name="camera" size={40} color={colors.textSecondary} />
</LinearGradient>
)}
<View style={styles.avatarBadge}>
<Ionicons name={avatarUri ? "pencil" : "add"} size={16} color={colors.text} />
</View>
</Pressable>
<Text style={styles.avatarHint}>
{avatarUri ? 'Tap to change photo' : 'Tap to add a photo'}
</Text>
</View>
{error && (
<View style={styles.errorContainer}>
<Ionicons name="alert-circle" size={16} color={colors.error} />
<Text style={styles.errorText}>{error}</Text>
</View>
)}
</GlassCard>
<View style={styles.footer}>
<GlassButton onPress={handleAvatarContinue} fullWidth size="lg">
{avatarUri ? 'Continue' : 'Skip for Now'}
</GlassButton>
</View>
</Animated.View>
);
case 'name':
return (
<Animated.View
key="name"
entering={SlideInRight.duration(400)}
exiting={SlideOutLeft.duration(300)}
style={styles.stageContainer}
>
<View style={styles.header}>
{avatarUri ? (
<Image source={{ uri: avatarUri }} style={styles.headerAvatar} />
) : (
<LinearGradient
colors={gradients.primary as [string, string]}
style={styles.iconContainer}
>
<Ionicons name="person" size={40} color={colors.text} />
</LinearGradient>
)}
<Text style={styles.title}>What can we call you?</Text>
<Text style={styles.subtitle}>
This is how your partner will see you
</Text>
</View>
<GlassCard style={styles.card}>
<Text style={styles.label}>What's your name?</Text>
<View style={styles.inputContainer}>
<Ionicons
name="person-outline"
@@ -224,7 +317,7 @@ export default function OnboardingScreen() {
style={styles.input}
value={name}
onChangeText={setName}
placeholder="Enter your name"
placeholder="Your name or nickname"
placeholderTextColor={colors.textTertiary}
autoCapitalize="words"
autoCorrect={false}
@@ -515,14 +608,16 @@ export default function OnboardingScreen() {
>
{/* Header with back button and progress dots */}
<Animated.View entering={FadeIn.duration(600)} style={styles.headerBar}>
{stage !== 'name' ? (
{stage !== 'avatar' ? (
<Pressable
style={styles.backButton}
onPress={() => {
setError(null);
if (stage === 'notifications') setStage('explicit');
else if (stage === 'explicit') setStage('purpose');
else if (stage === 'purpose') setStage('gender');
else setStage('name');
else if (stage === 'gender') setStage('name');
else if (stage === 'name') setStage('avatar');
}}
>
<Ionicons name="chevron-back" size={24} color={colors.text} />
@@ -531,6 +626,7 @@ export default function OnboardingScreen() {
<View style={styles.backButtonPlaceholder} />
)}
<View style={styles.progressContainer}>
<View style={[styles.progressDot, stage === 'avatar' && styles.progressDotActive]} />
<View style={[styles.progressDot, stage === 'name' && styles.progressDotActive]} />
<View style={[styles.progressDot, stage === 'gender' && styles.progressDotActive]} />
<View style={[styles.progressDot, stage === 'purpose' && styles.progressDotActive]} />
@@ -611,6 +707,56 @@ const styles = StyleSheet.create({
marginBottom: spacing.lg,
...shadows.lg,
},
headerAvatar: {
width: 80,
height: 80,
borderRadius: 40,
marginBottom: spacing.lg,
borderWidth: 2,
borderColor: colors.primary,
},
avatarSection: {
alignItems: 'center',
paddingVertical: spacing.lg,
},
avatarTouchable: {
position: 'relative',
},
avatarImage: {
width: 120,
height: 120,
borderRadius: 60,
borderWidth: 3,
borderColor: colors.primary,
},
avatarPlaceholder: {
width: 120,
height: 120,
borderRadius: 60,
alignItems: 'center',
justifyContent: 'center',
borderWidth: 2,
borderColor: colors.glass.border,
borderStyle: 'dashed',
},
avatarBadge: {
position: 'absolute',
bottom: 4,
right: 4,
width: 32,
height: 32,
borderRadius: 16,
backgroundColor: colors.primary,
alignItems: 'center',
justifyContent: 'center',
borderWidth: 2,
borderColor: colors.background,
},
avatarHint: {
...typography.caption1,
color: colors.textSecondary,
marginTop: spacing.md,
},
title: {
...typography.largeTitle,
color: colors.text,
+3 -9
View File
@@ -1,4 +1,4 @@
import { View, Text, StyleSheet, RefreshControl, Platform, Alert, Pressable, Switch } from "react-native";
import { View, Text, StyleSheet, RefreshControl, Platform, Alert, Pressable } from "react-native";
import { usePacksStore, useAuthStore, useSubscriptionStore } from "../../../src/store";
import { Ionicons } from "@expo/vector-icons";
import { router } from "expo-router";
@@ -14,7 +14,7 @@ import Animated, {
Extrapolation,
} from "react-native-reanimated";
import { BlurView } from "expo-blur";
import { GradientBackground } from "../../../src/components/ui";
import { GradientBackground, GlassToggle } from "../../../src/components/ui";
import { Paywall } from "../../../src/components/paywall";
import { PackTeaser } from "../../../src/components/PackTeaser";
import { colors, spacing, radius, typography } from "../../../src/theme";
@@ -461,15 +461,9 @@ export default function QuestionPacksScreen() {
Show all intensities
</Text>
</View>
<Switch
<GlassToggle
value={showAllIntensities}
onValueChange={handleToggleComfortZone}
trackColor={{
false: "rgba(255, 255, 255, 0.1)",
true: "rgba(233, 69, 96, 0.4)"
}}
thumbColor={showAllIntensities ? colors.primary : colors.textTertiary}
ios_backgroundColor="rgba(255, 255, 255, 0.1)"
/>
</Pressable>
</Animated.View>
@@ -0,0 +1,3 @@
import { SaveAccountScreen } from "../../../src/features/profile/screens";
export default SaveAccountScreen;
+42
View File
@@ -61,6 +61,7 @@ export default function LoginScreen() {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [isLoading, setIsLoading] = useState(false);
const [isGuestLoading, setIsGuestLoading] = useState(false);
const [isMagicLinkSent, setIsMagicLinkSent] = useState(false);
const [pendingVerification, setPendingVerification] = useState<PendingVerification | null>(null);
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) {
return (
<GradientBackground showAccent>
@@ -663,6 +678,23 @@ export default function LoginScreen() {
</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>
</ScrollView>
</KeyboardAvoidingView>
@@ -821,4 +853,14 @@ const styles = StyleSheet.create({
flex: 1,
height: 48,
},
guestContainer: {
marginTop: spacing.lg,
alignItems: "center",
gap: spacing.sm,
},
guestHint: {
...typography.caption1,
color: colors.textTertiary,
textAlign: "center",
},
});
+169 -4
View File
@@ -16,8 +16,8 @@ import {
handleNotificationResponse,
} from "../src/lib/notifications";
import React, { useEffect, useRef } from "react";
import { Platform, View, AppState, AppStateStatus } from "react-native";
import React, { useEffect, useRef, useState } from "react";
import { Platform, View, AppState, AppStateStatus, Modal, Text, StyleSheet, Pressable, TouchableOpacity } from "react-native";
// Suppress useLayoutEffect warning on server-side rendering
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 { StatusBar } from "expo-status-bar";
import { Ionicons } from "@expo/vector-icons";
// GestureHandlerRootView is only needed on native platforms
// On web, we use a simple View to avoid findNodeHandle errors
const GestureHandlerRootView = Platform.OS === "web"
@@ -43,15 +44,26 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import * as Linking from "expo-linking";
import { useAuthStore } from "../src/store";
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();
export default function RootLayout() {
const { fetchUser, setUser } = useAuthStore();
const { fetchUser, setUser, isAuthenticated, isAnonymous } = useAuthStore();
const pathname = usePathname();
const appState = useRef(AppState.currentState);
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
useEffect(() => {
if (!analyticsInitialized.current) {
@@ -68,6 +80,22 @@ export default function RootLayout() {
}
}, [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
useEffect(() => {
const subscription = AppState.addEventListener("change", (nextAppState: AppStateStatus) => {
@@ -181,7 +209,7 @@ export default function RootLayout() {
const { data: { subscription: authSubscription } } = supabase.auth.onAuthStateChange(
async (event, session) => {
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...');
fetchUser();
// Set Sentry user context for error tracking
@@ -223,7 +251,144 @@ export default function RootLayout() {
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>
</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,
isLoading: true,
isAuthenticated: false,
isAnonymous: false,
} as any);
useMatchStore.setState({ matches: [{ id: 'm1' }], newMatchesCount: 1 } as any);
@@ -31,9 +32,35 @@ describe('authStore', () => {
const state = useAuthStore.getState();
expect(state.user).toBeNull();
expect(state.isAuthenticated).toBe(false);
expect(state.isAnonymous).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 () => {
(supabase.auth.getSession as jest.Mock).mockResolvedValueOnce({
data: { session: { user: { id: 'me' } } },
@@ -50,51 +77,9 @@ describe('authStore', () => {
expect(useAuthStore.getState().user).toBeNull();
expect(useAuthStore.getState().isAuthenticated).toBe(false);
expect(useAuthStore.getState().isAnonymous).toBe(false);
expect(useMatchStore.getState().matches).toEqual([]);
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().isAnonymous).toBe(false);
expect(useAuthStore.getState().isLoading).toBe(false);
});
});
@@ -1,5 +1,5 @@
import React from 'react';
import { StyleSheet, View, Text, TouchableOpacity, Switch, Platform } from 'react-native';
import { StyleSheet, View, Text, TouchableOpacity, Platform } from 'react-native';
import { LinearGradient } from 'expo-linear-gradient';
import { BlurView } from 'expo-blur';
import { Ionicons } from '@expo/vector-icons';
@@ -10,6 +10,7 @@ import Animated, {
FadeIn,
} from 'react-native-reanimated';
import { ShimmerEffect } from './ui/ShimmerEffect';
import { GlassToggle } from './ui/GlassToggle';
import { colors, gradients, spacing, radius, typography, blur, shadows, animations } from '../theme';
import type { QuestionPack } from '../types';
@@ -150,16 +151,10 @@ export function BoutiquePackCard({
<Text style={styles.questionLabel}>questions</Text>
</View>
<Switch
<GlassToggle
value={isEnabled}
onValueChange={onToggle}
disabled={isPremiumLocked}
trackColor={{
false: 'rgba(255, 255, 255, 0.15)',
true: pack.is_premium ? colors.premium.gold : colors.primary,
}}
thumbColor={colors.text}
ios_backgroundColor="rgba(255, 255, 255, 0.15)"
style={isPremiumLocked ? { opacity: 0.4 } : undefined}
/>
</View>
@@ -177,7 +177,7 @@ export const MatchNotificationModal: React.FC<Props> = ({
]).start(() => onChat());
};
if (!visible || !match) return null;
if (!visible || !match || !match.question) return null;
// Determine responses based on match_type if not explicitly provided
const getUserResponse = (): AnswerType => {
@@ -266,13 +266,15 @@ export const MatchNotificationModal: React.FC<Props> = ({
{/* Question text and responses */}
{match.question.partner_text ? (
// Two-part question - show what each person saw
// The current user triggered the match (answered second), so they saw partner_text
// The partner answered first, so they saw text
<View style={styles.twoPartContainer}>
<View style={styles.responseSection}>
<View style={styles.responseLabelRow}>
<Text style={styles.responsePersonLabel}>You said</Text>
<ResponseBadge response={userResponse} />
</View>
<Text style={styles.questionTextSmall}>"{match.question.text}"</Text>
<Text style={styles.questionTextSmall}>"{match.question.partner_text}"</Text>
</View>
<View style={styles.matchConnector}>
@@ -288,7 +290,7 @@ export const MatchNotificationModal: React.FC<Props> = ({
<Text style={styles.responsePersonLabel}>{partnerDisplayName} said</Text>
<ResponseBadge response={partnerResponse} isPartner />
</View>
<Text style={styles.questionTextSmall}>"{match.question.partner_text}"</Text>
<Text style={styles.questionTextSmall}>"{match.question.text}"</Text>
</View>
</View>
) : (
+153 -2
View File
@@ -13,7 +13,8 @@ import {
} from "react-native";
import { Ionicons } from "@expo/vector-icons";
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 type { PurchasesPackage } from "../../lib/revenuecat";
import { Events } from "../../lib/analytics";
@@ -43,6 +44,8 @@ const FEATURES = [
];
export function Paywall({ visible, onClose, onSuccess }: PaywallProps) {
const { isAnonymous } = useAuthStore();
const handleClose = (reason: "dismiss" | "success" | "restore" | "system") => {
Events.paywallClosed("paywall", reason);
onClose();
@@ -60,6 +63,8 @@ export function Paywall({ visible, onClose, onSuccess }: PaywallProps) {
const [selectedPackage, setSelectedPackage] = useState<PurchasesPackage | null>(
null
);
const [showGuestConfirm, setShowGuestConfirm] = useState(false);
const [guestConfirmed, setGuestConfirmed] = useState(false);
useEffect(() => {
if (visible && !offerings) {
@@ -67,6 +72,8 @@ export function Paywall({ visible, onClose, onSuccess }: PaywallProps) {
}
if (visible) {
Events.paywallShown("paywall");
setGuestConfirmed(false);
setShowGuestConfirm(false);
}
}, [visible, offerings, fetchOfferings]);
@@ -80,7 +87,7 @@ export function Paywall({ visible, onClose, onSuccess }: PaywallProps) {
}
}, [offerings]);
const handlePurchase = async () => {
const doPurchase = async () => {
if (!selectedPackage) return;
const packageType = selectedPackage.packageType || "unknown";
@@ -102,6 +109,17 @@ export function Paywall({ visible, onClose, onSuccess }: PaywallProps) {
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) {
@@ -398,6 +416,59 @@ export function Paywall({ visible, onClose, onSuccess }: PaywallProps) {
</View>
</View>
</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>
</Modal>
@@ -760,6 +831,86 @@ const styles = StyleSheet.create({
...typography.caption1,
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;
@@ -0,0 +1,139 @@
import React, { useEffect } from 'react';
import { StyleSheet, Pressable, ViewStyle, Platform } from 'react-native';
import { LinearGradient } from 'expo-linear-gradient';
import Animated, {
useSharedValue,
useAnimatedStyle,
withSpring,
interpolateColor,
} from 'react-native-reanimated';
import { colors, gradients, radius, animations } from '../../theme';
// Haptics is not supported on web
const triggerHaptic = async () => {
if (Platform.OS === 'web') return;
const Haptics = await import('expo-haptics');
await Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
};
interface GlassToggleProps {
value: boolean;
onValueChange: (value: boolean) => void;
disabled?: boolean;
style?: ViewStyle;
}
const TRACK_WIDTH = 52;
const TRACK_HEIGHT = 30;
const THUMB_SIZE = 26;
const PADDING = 2;
export function GlassToggle({
value,
onValueChange,
disabled = false,
style,
}: GlassToggleProps) {
// Shared values for animation
const progress = useSharedValue(value ? 1 : 0);
// Sync shared value when prop changes
useEffect(() => {
progress.value = withSpring(value ? 1 : 0, {
mass: 0.8,
damping: 15,
stiffness: 120,
});
}, [value]);
const handlePress = async () => {
if (disabled) return;
await triggerHaptic();
onValueChange(!value);
};
const thumbStyle = useAnimatedStyle(() => {
return {
transform: [
{
translateX: progress.value * (TRACK_WIDTH - THUMB_SIZE - (PADDING * 2)),
},
],
};
});
const trackStyle = useAnimatedStyle(() => {
const backgroundColor = interpolateColor(
progress.value,
[0, 1],
['rgba(255, 255, 255, 0.1)', 'transparent'] // We use LinearGradient for active state
);
return {
backgroundColor,
};
});
const activeGradientStyle = useAnimatedStyle(() => {
return {
opacity: progress.value,
};
});
return (
<Pressable
onPress={handlePress}
disabled={disabled}
style={[styles.container, style, disabled && styles.disabled]}
>
<Animated.View style={[styles.track, trackStyle]}>
{/* Active Gradient Background (fades in) */}
<Animated.View style={[StyleSheet.absoluteFill, activeGradientStyle]}>
<LinearGradient
colors={gradients.primary as [string, string, ...string[]]}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 0 }}
style={StyleSheet.absoluteFill}
/>
</Animated.View>
{/* Thumb */}
<Animated.View style={[styles.thumb, thumbStyle]} />
</Animated.View>
</Pressable>
);
}
const styles = StyleSheet.create({
container: {
width: TRACK_WIDTH,
height: TRACK_HEIGHT,
},
track: {
width: '100%',
height: '100%',
borderRadius: radius.full,
borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.1)',
overflow: 'hidden',
justifyContent: 'center',
padding: PADDING,
},
thumb: {
width: THUMB_SIZE,
height: THUMB_SIZE,
borderRadius: radius.full,
backgroundColor: '#FFFFFF',
shadowColor: '#000',
shadowOffset: {
width: 0,
height: 2,
},
shadowOpacity: 0.25,
shadowRadius: 3.84,
elevation: 5,
},
disabled: {
opacity: 0.5,
},
});
+1
View File
@@ -10,5 +10,6 @@ export { LoadingOverlay } from './LoadingOverlay';
export type { LoadingOverlayProps, LoadingVariant } from './LoadingOverlay';
export { ReadReceipt, getReceiptStatus } from './ReadReceipt';
export type { ReadReceiptProps, ReceiptStatus } from './ReadReceipt';
export { GlassToggle } from './GlassToggle';
export { AmbientOrbs } from './AmbientOrbs';
export type { AmbientOrbsProps, OrbVariant, OrbPosition } from './AmbientOrbs';
@@ -29,7 +29,7 @@ export function ProfileScreen() {
const { width } = useWindowDimensions();
const isWideScreen = width > MAX_CONTENT_WIDTH;
const { user, partner, fetchUser } = useAuthStore();
const { user, partner, fetchUser, isAnonymous } = useAuthStore();
const { subscription } = useSubscriptionStore();
const settings = useProfileSettings();
@@ -116,7 +116,9 @@ export function ProfileScreen() {
</LinearGradient>
<View style={styles.profileInfo}>
<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 style={styles.profileChevron}>
<Ionicons name="chevron-forward" size={20} color={colors.textTertiary} />
@@ -172,6 +174,17 @@ export function ProfileScreen() {
{/* Other Section */}
<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
icon="help-circle-outline"
label="Help & Support"
@@ -1,8 +1,9 @@
import React from 'react';
import { View, Text, Switch, StyleSheet } from 'react-native';
import { View, Text, StyleSheet } from 'react-native';
import { LinearGradient } from 'expo-linear-gradient';
import { Ionicons } from '@expo/vector-icons';
import { colors, spacing, radius, typography, featureColors } from '../../../theme';
import { GlassToggle } from '../../../components/ui';
export interface SwitchItemProps {
/** Icon name (Ionicons) */
@@ -20,7 +21,6 @@ export interface SwitchItemProps {
}
const ACCENT_GRADIENT = featureColors.profile.gradient as [string, string];
const ACCENT_COLOR = featureColors.profile.accent;
/**
* Reusable toggle switch row for settings screens.
@@ -49,13 +49,10 @@ export function SwitchItem({
<Text style={styles.description}>{description}</Text>
</View>
</View>
<Switch
<GlassToggle
value={value}
onValueChange={onValueChange}
disabled={disabled}
trackColor={{ false: colors.glass.border, true: colors.secondaryLight }}
thumbColor={value ? ACCENT_COLOR : colors.textTertiary}
ios_backgroundColor={colors.glass.border}
/>
</View>
);
@@ -1,11 +1,9 @@
import { useState, useEffect, useCallback } from 'react';
import { Alert, Platform, ActionSheetIOS, Linking } from 'react-native';
import * as ImagePicker from 'expo-image-picker';
import * as FileSystem from 'expo-file-system';
import { decode } from 'base64-arraybuffer';
import { Alert, Linking, Platform } from 'react-native';
import { useAuthStore, useSubscriptionStore, usePacksStore } from '../../../store';
import { supabase } from '../../../lib/supabase';
import { Events } from '../../../lib/analytics';
import { useAvatarPicker } from '../../../hooks/useAvatarPicker';
import type { IntensityLevel } from '../../../types';
import {
registerForPushNotificationsAsync,
@@ -45,8 +43,42 @@ export function useProfileSettings() {
const [newName, setNewName] = useState(user?.name || "");
const [isUpdatingName, setIsUpdatingName] = useState(false);
// Avatar
const [isUploadingAvatar, setIsUploadingAvatar] = useState(false);
// Avatar - using shared hook with immediate upload on select
const handleAvatarUploadComplete = useCallback(async (publicUrl: string) => {
if (!user?.id) return;
try {
const { error: updateError } = await supabase
.from('profiles')
.update({
avatar_url: publicUrl,
updated_at: new Date().toISOString(),
})
.eq('id', user.id);
if (updateError) throw updateError;
await fetchUser();
Events.avatarUploaded();
} catch (error) {
console.error('Error updating profile with avatar:', error);
Alert.alert('Error', 'Failed to save photo. Please try again.');
}
}, [user?.id, fetchUser]);
const handleAvatarUploadError = useCallback(() => {
Alert.alert('Error', 'Failed to upload photo. Please try again.');
}, []);
const {
isUploading: isUploadingAvatar,
showPicker: handleAvatarPress,
} = useAvatarPicker({
userId: user?.id,
autoUpload: true,
onUploadComplete: handleAvatarUploadComplete,
onUploadError: handleAvatarUploadError,
});
// Preferences
const [maxIntensity, setMaxIntensity] = useState<IntensityLevel>(() => getProfileMaxIntensity(user));
@@ -136,140 +168,6 @@ export function useProfileSettings() {
setIsEditingName(false);
}, [user?.name]);
// Avatar Actions
const handleAvatarPress = () => {
if (Platform.OS === 'ios') {
ActionSheetIOS.showActionSheetWithOptions(
{
options: ['Cancel', 'Take Photo', 'Choose from Library'],
cancelButtonIndex: 0,
},
(buttonIndex) => {
if (buttonIndex === 1) {
pickAvatar('camera');
} else if (buttonIndex === 2) {
pickAvatar('library');
}
}
);
} else {
Alert.alert(
'Change Profile Photo',
'Choose an option',
[
{ text: 'Cancel', style: 'cancel' },
{ text: 'Take Photo', onPress: () => pickAvatar('camera') },
{ text: 'Choose from Library', onPress: () => pickAvatar('library') },
]
);
}
};
const pickAvatar = async (source: 'camera' | 'library') => {
try {
let result: ImagePicker.ImagePickerResult;
if (source === 'camera') {
const { status } = await ImagePicker.requestCameraPermissionsAsync();
if (status !== 'granted') {
Alert.alert('Permission Denied', 'Camera access is required to take a photo.');
return;
}
result = await ImagePicker.launchCameraAsync({
mediaTypes: ['images'],
allowsEditing: true,
aspect: [1, 1],
quality: 0.8,
});
} else {
const { status } = await ImagePicker.requestMediaLibraryPermissionsAsync();
if (status !== 'granted') {
Alert.alert('Permission Denied', 'Photo library access is required to choose a photo.');
return;
}
result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ['images'],
allowsEditing: true,
aspect: [1, 1],
quality: 0.8,
});
}
if (!result.canceled && result.assets[0]) {
await uploadAvatar(result.assets[0].uri);
}
} catch (error) {
console.error('Error picking avatar:', error);
Alert.alert('Error', 'Failed to pick image. Please try again.');
}
};
const uploadAvatar = async (uri: string) => {
if (!user?.id) return;
setIsUploadingAvatar(true);
try {
let fileBody;
let ext = 'jpg';
if (Platform.OS === 'web') {
const response = await fetch(uri);
const blob = await response.blob();
fileBody = blob;
if (blob.type === 'image/png') ext = 'png';
else if (blob.type === 'image/webp') ext = 'webp';
} else {
const base64 = await FileSystem.readAsStringAsync(uri, { encoding: FileSystem.EncodingType.Base64 });
fileBody = decode(base64);
const uriExt = uri.split('.').pop()?.toLowerCase();
if (uriExt && ['jpg', 'jpeg', 'png', 'webp'].includes(uriExt)) {
ext = uriExt === 'jpeg' ? 'jpg' : uriExt;
}
}
const fileName = `${user.id}/${Date.now()}.${ext}`;
const contentType = `image/${ext === 'jpg' ? 'jpeg' : ext}`;
// Upload to storage
const { error: uploadError } = await supabase.storage
.from('avatars')
.upload(fileName, fileBody, {
contentType,
upsert: true,
});
if (uploadError) throw uploadError;
// Get public URL
const { data: { publicUrl } } = supabase.storage
.from('avatars')
.getPublicUrl(fileName);
// Update profile with avatar URL
const { error: updateError } = await supabase
.from('profiles')
.update({
avatar_url: publicUrl,
updated_at: new Date().toISOString(),
})
.eq('id', user.id);
if (updateError) throw updateError;
// Refresh user data
await fetchUser();
Events.avatarUploaded();
} catch (error) {
console.error('Error uploading avatar:', error);
Alert.alert('Error', 'Failed to upload photo. Please try again.');
} finally {
setIsUploadingAvatar(false);
}
};
// Preference Actions
const handleIntensityChange = async (value: IntensityLevel) => {
if (!user?.id) return;
@@ -1,5 +1,6 @@
import React from 'react';
import { View, ScrollView, StyleSheet, Platform } from 'react-native';
import { useRouter } from 'expo-router';
import { GradientBackground } from '../../../components/ui';
import { colors, spacing } from '../../../theme';
@@ -11,7 +12,8 @@ import { ScreenHeader, SettingsSection, MenuItem, DangerZone } from '../componen
* Account settings sub-screen (sign out, delete account).
*/
export function AccountScreen() {
const { couple } = useAuthStore();
const router = useRouter();
const { couple, isAnonymous } = useAuthStore();
const { handleSignOut, handleDeleteAccount } = useCoupleManagement();
return (
@@ -23,13 +25,23 @@ export function AccountScreen() {
showsVerticalScrollIndicator={false}
>
<SettingsSection title="Account" delay={0}>
<MenuItem
icon="log-out-outline"
label="Sign Out"
onPress={handleSignOut}
variant="danger"
showChevron={false}
/>
{isAnonymous ? (
<MenuItem
icon="shield-checkmark-outline"
label="Save Account"
description="Protect access across devices"
onPress={() => router.push("/(app)/settings/save-account" as any)}
showChevron={false}
/>
) : (
<MenuItem
icon="log-out-outline"
label="Sign Out"
onPress={handleSignOut}
variant="danger"
showChevron={false}
/>
)}
</SettingsSection>
<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;
@@ -6,3 +6,4 @@ export { SubscriptionScreen } from './SubscriptionScreen';
export { PartnerScreen } from './PartnerScreen';
export { SupportScreen } from './SupportScreen';
export { AccountScreen } from './AccountScreen';
export { SaveAccountScreen } from './SaveAccountScreen';
+221
View File
@@ -0,0 +1,221 @@
import { useState, useCallback, useRef } from 'react';
import { Alert, Platform, ActionSheetIOS } from 'react-native';
import * as ImagePicker from 'expo-image-picker';
import * as FileSystem from 'expo-file-system';
import { decode } from 'base64-arraybuffer';
import { supabase } from '../lib/supabase';
export interface UseAvatarPickerOptions {
/** User ID for storage path. Required for upload. */
userId?: string;
/** If true, automatically uploads after selection. Default: false */
autoUpload?: boolean;
/** Called when an image is selected (before upload) */
onSelect?: (uri: string) => void;
/** Called after successful upload */
onUploadComplete?: (publicUrl: string) => void;
/** Called on upload error */
onUploadError?: (error: Error) => void;
}
export interface UseAvatarPickerReturn {
/** Local URI of selected image (before upload) */
avatarUri: string | null;
/** Set avatar URI directly */
setAvatarUri: (uri: string | null) => void;
/** Whether an upload is in progress */
isUploading: boolean;
/** Show the picker action sheet/dialog */
showPicker: () => void;
/** Upload the currently selected image to storage */
uploadAvatar: () => Promise<string | null>;
/** Clear the selected avatar */
clearAvatar: () => void;
}
/**
* Hook for avatar image picking and uploading.
* Handles cross-platform image selection (camera/library) and Supabase storage upload.
*/
export function useAvatarPicker(options?: UseAvatarPickerOptions): UseAvatarPickerReturn {
const [avatarUri, setAvatarUri] = useState<string | null>(null);
const [isUploading, setIsUploading] = useState(false);
// Use ref for options to avoid stale closures in callbacks
const optionsRef = useRef(options);
optionsRef.current = options;
// Internal upload function that takes a URI parameter
const uploadAvatarWithUri = useCallback(async (uri: string): Promise<string | null> => {
const currentOptions = optionsRef.current;
if (!uri || !currentOptions?.userId) {
return null;
}
setIsUploading(true);
try {
let fileBody;
let ext = 'jpg';
if (Platform.OS === 'web') {
const response = await fetch(uri);
const blob = await response.blob();
fileBody = blob;
if (blob.type === 'image/png') ext = 'png';
else if (blob.type === 'image/webp') ext = 'webp';
} else {
const base64 = await FileSystem.readAsStringAsync(uri, {
encoding: FileSystem.EncodingType.Base64
});
fileBody = decode(base64);
const uriExt = uri.split('.').pop()?.toLowerCase();
if (uriExt && ['jpg', 'jpeg', 'png', 'webp'].includes(uriExt)) {
ext = uriExt === 'jpeg' ? 'jpg' : uriExt;
}
}
const fileName = `${currentOptions.userId}/${Date.now()}.${ext}`;
const contentType = `image/${ext === 'jpg' ? 'jpeg' : ext}`;
const { error: uploadError } = await supabase.storage
.from('avatars')
.upload(fileName, fileBody, {
contentType,
upsert: true,
});
if (uploadError) throw uploadError;
const { data: { publicUrl } } = supabase.storage
.from('avatars')
.getPublicUrl(fileName);
currentOptions?.onUploadComplete?.(publicUrl);
return publicUrl;
} catch (error) {
console.error('Error uploading avatar:', error);
currentOptions?.onUploadError?.(error as Error);
return null;
} finally {
setIsUploading(false);
}
}, []);
const pickImage = useCallback(async (source: 'camera' | 'library') => {
const currentOptions = optionsRef.current;
try {
let result: ImagePicker.ImagePickerResult;
if (source === 'camera') {
const { status } = await ImagePicker.requestCameraPermissionsAsync();
if (status !== 'granted') {
Alert.alert('Permission Denied', 'Camera access is required to take a photo.');
return;
}
result = await ImagePicker.launchCameraAsync({
mediaTypes: ['images'],
allowsEditing: true,
aspect: [1, 1],
quality: 0.8,
exif: false,
});
} else {
const { status } = await ImagePicker.requestMediaLibraryPermissionsAsync();
if (status !== 'granted') {
Alert.alert('Permission Denied', 'Photo library access is required to choose a photo.');
return;
}
result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ['images'],
allowsEditing: true,
aspect: [1, 1],
quality: 0.8,
exif: false,
});
}
if (!result.canceled && result.assets[0]) {
const uri = result.assets[0].uri;
// Validate image is accessible on native platforms
if (Platform.OS !== 'web') {
try {
const info = await FileSystem.getInfoAsync(uri);
if (!info.exists) {
Alert.alert('Error', 'Selected image could not be accessed.');
return;
}
// Warn if file is very large (> 10MB)
if (info.size && info.size > 10 * 1024 * 1024) {
Alert.alert('Large Image', 'This image is quite large and may take longer to upload.');
}
} catch (e) {
console.warn('Could not validate image:', e);
}
}
setAvatarUri(uri);
currentOptions?.onSelect?.(uri);
// Auto-upload if enabled
if (currentOptions?.autoUpload) {
await uploadAvatarWithUri(uri);
}
}
} catch (error) {
console.error('Error picking avatar:', error);
Alert.alert('Error', 'Failed to pick image. Please try again.');
}
}, [uploadAvatarWithUri]);
const showPicker = useCallback(() => {
if (Platform.OS === 'ios') {
ActionSheetIOS.showActionSheetWithOptions(
{
options: ['Cancel', 'Take Photo', 'Choose from Library'],
cancelButtonIndex: 0,
},
(buttonIndex) => {
if (buttonIndex === 1) {
pickImage('camera');
} else if (buttonIndex === 2) {
pickImage('library');
}
}
);
} else {
Alert.alert(
'Add Profile Photo',
'Choose an option',
[
{ text: 'Cancel', style: 'cancel' },
{ text: 'Take Photo', onPress: () => pickImage('camera') },
{ text: 'Choose from Library', onPress: () => pickImage('library') },
]
);
}
}, [pickImage]);
// Public upload function uses stored avatarUri
const uploadAvatar = useCallback(async (): Promise<string | null> => {
if (!avatarUri) return null;
return uploadAvatarWithUri(avatarUri);
}, [avatarUri, uploadAvatarWithUri]);
const clearAvatar = useCallback(() => {
setAvatarUri(null);
}, []);
return {
avatarUri,
setAvatarUri,
isUploading,
showPicker,
uploadAvatar,
clearAvatar,
};
}
+4
View File
@@ -14,6 +14,8 @@ const AUTH_ERROR_MAPPINGS: ErrorMapping[] = [
{ 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: /user already registered/i, message: "An account with this email already exists." },
{ pattern: /email.*already.*(registered|exists|in use|used)/i, message: "This email is already in use. Please use a different one." },
{ pattern: /duplicate key value violates unique constraint.*users_email_key/i, message: "This email is already in use. Please use a different one." },
{ pattern: /invalid email/i, message: "Please enter a valid email address." },
{ pattern: /password.*too short/i, message: "Password must be at least 8 characters." },
{ pattern: /password.*too weak/i, message: "Please choose a stronger password." },
@@ -26,6 +28,8 @@ const AUTH_ERROR_MAPPINGS: ErrorMapping[] = [
{ 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[] = [
@@ -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);
}
+14 -2
View File
@@ -18,11 +18,13 @@ Notifications.setNotificationHandler({
// Types
export interface NotificationData {
type: "match" | "message";
type: "match" | "message" | "match_digest";
match_id?: string;
message_id?: string;
count?: number;
}
/**
* Request notification permissions and get Expo push token.
* Returns null if permissions denied, on web, or if registration fails.
@@ -224,7 +226,16 @@ export function handleNotificationResponse(
): void {
const data = response.notification.request.content.data as NotificationData;
if (!data?.match_id) {
if (!data) {
return;
}
if (data.type === "match_digest") {
router.push("/(app)/matches");
return;
}
if (!data.match_id) {
return;
}
@@ -232,6 +243,7 @@ export function handleNotificationResponse(
router.push(`/(app)/chat/${data.match_id}`);
}
/**
* Set up listener for notification taps (when app is running).
* Returns subscription that should be cleaned up on unmount.
+17 -9
View File
@@ -10,6 +10,8 @@ interface AuthState {
partner: Profile | null;
isLoading: boolean;
isAuthenticated: boolean;
isAnonymous: boolean;
// Actions
fetchUser: () => Promise<void>;
@@ -38,6 +40,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
partner: null,
isLoading: true,
isAuthenticated: false,
isAnonymous: false,
fetchUser: async () => {
try {
@@ -45,7 +48,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
const { data: { session } } = await supabase.auth.getSession();
if (!session?.user) {
set({ user: null, isAuthenticated: false, isLoading: false });
set({ user: null, isAuthenticated: false, isAnonymous: false, isLoading: false });
return;
}
@@ -57,7 +60,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
console.log("[Auth] Session invalid, signing out:", authError?.message);
// 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
const { useMatchStore, usePacksStore, useMessageStore, useSubscriptionStore, useNotificationPreferencesStore, useStreakStore } = getOtherStores();
useMatchStore.getState().clearMatches();
@@ -77,9 +80,12 @@ export const useAuthStore = create<AuthState>((set, get) => ({
.eq("id", authUser.id)
.maybeSingle();
const isAnonymous = !!(authUser as any).is_anonymous;
set({
user: profile,
isAuthenticated: true,
isAnonymous,
});
// If user has a couple, fetch couple data; otherwise clear couple/partner
@@ -142,13 +148,14 @@ export const useAuthStore = create<AuthState>((set, get) => ({
Events.signOut();
// Clear local state FIRST to ensure UI updates even if Supabase call fails
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
const { useMatchStore, usePacksStore, useMessageStore, useSubscriptionStore, useNotificationPreferencesStore, useStreakStore } = getOtherStores();
useMatchStore.getState().clearMatches();
@@ -174,6 +181,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
set({
user,
isAuthenticated: !!user,
...(user === null && { isAnonymous: false }),
isLoading: false,
// Clear couple/partner when user is null (signed out)
...(user === null && { couple: null, partner: null })
@@ -0,0 +1,197 @@
import "jsr:@supabase/functions-js/edge-runtime.d.ts";
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
/**
* Send coalesced match notifications.
* Called by pg_cron every minute and processes any pending notifications
* whose notify_at has passed.
*/
interface PendingMatchNotification {
id: string;
couple_id: string;
match_count: number;
latest_match_id: string | null;
notify_at: string;
}
interface ExpoPushMessage {
to: string;
title: string;
body: string;
sound?: string;
data?: Record<string, unknown>;
}
async function sendExpoPushNotifications(messages: ExpoPushMessage[]) {
if (messages.length === 0) return;
// Expo accepts max 100 messages per request
const chunks: ExpoPushMessage[][] = [];
for (let i = 0; i < messages.length; i += 100) {
chunks.push(messages.slice(i, i + 100));
}
for (const chunk of chunks) {
const response = await fetch("https://exp.host/--/api/v2/push/send", {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
body: JSON.stringify(chunk),
});
if (!response.ok) {
const error = await response.text();
console.error("Expo push error:", error);
// Continue with other chunks even if one fails
}
}
}
Deno.serve(async (req) => {
// Only allow POST (from cron) or GET (for manual testing)
if (req.method !== "POST" && req.method !== "GET") {
return new Response("Method not allowed", { status: 405 });
}
const supabase = createClient(
Deno.env.get("SUPABASE_URL")!,
Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!
);
try {
const nowIso = new Date().toISOString();
// Get all pending notifications where notify_at has passed
const { data: pendingNotifications, error: fetchError } = await supabase
.from("pending_match_notifications")
.select("id, couple_id, match_count, latest_match_id, notify_at")
.lte("notify_at", nowIso);
if (fetchError) {
console.error("Error fetching pending match notifications:", fetchError);
return new Response(
JSON.stringify({ error: fetchError.message }),
{ status: 500, headers: { "Content-Type": "application/json" } }
);
}
if (!pendingNotifications || pendingNotifications.length === 0) {
return new Response(
JSON.stringify({ sent: 0, message: "No pending notifications" }),
{ headers: { "Content-Type": "application/json" } }
);
}
const coupleIds = Array.from(new Set(pendingNotifications.map((n) => n.couple_id)));
// Fetch all push tokens + match notification preference for couples
const { data: profiles, error: profilesError } = await supabase
.from("profiles")
.select(
`
id,
couple_id,
push_token,
notification_preferences:notification_preferences(matches_enabled)
`
)
.in("couple_id", coupleIds)
.not("push_token", "is", null);
if (profilesError) {
console.error("Error fetching profiles:", profilesError);
return new Response(
JSON.stringify({ error: profilesError.message }),
{ status: 500, headers: { "Content-Type": "application/json" } }
);
}
const profilesByCouple = new Map<string, any[]>();
for (const profile of profiles || []) {
const list = profilesByCouple.get(profile.couple_id) ?? [];
list.push(profile);
profilesByCouple.set(profile.couple_id, list);
}
const messages: ExpoPushMessage[] = [];
const processedIds: string[] = [];
for (const notification of pendingNotifications as PendingMatchNotification[]) {
const coupleProfiles = profilesByCouple.get(notification.couple_id) ?? [];
for (const profile of coupleProfiles) {
const prefs = Array.isArray(profile.notification_preferences)
? profile.notification_preferences[0]
: profile.notification_preferences;
// Skip if user has explicitly disabled match notifications
if (prefs && prefs.matches_enabled === false) {
continue;
}
const count = Math.max(1, Number(notification.match_count) || 1);
const latestMatchId = notification.latest_match_id;
// If the match was deleted before the digest fired, don't notify.
if (!latestMatchId) {
continue;
}
if (count === 1) {
messages.push({
to: profile.push_token,
title: "It's a match! 💕",
body: "You and your partner matched on something new!",
sound: "default",
data: { type: "match", match_id: latestMatchId },
});
} else {
messages.push({
to: profile.push_token,
title: "New matches 💕",
body: `You have ${count} new matches`,
sound: "default",
// Include latest_match_id for backwards compatibility
data: { type: "match_digest", count, match_id: latestMatchId },
});
}
}
processedIds.push(notification.id);
}
if (messages.length > 0) {
await sendExpoPushNotifications(messages);
}
// Delete processed notifications (even if skipped due to no token/prefs)
if (processedIds.length > 0) {
const { error: deleteError } = await supabase
.from("pending_match_notifications")
.delete()
.in("id", processedIds);
if (deleteError) {
console.error("Error deleting processed match notifications:", deleteError);
}
}
console.log(
`Sent ${messages.length} match notifications from ${pendingNotifications.length} pending rows`
);
return new Response(
JSON.stringify({ sent: messages.length, processed: processedIds.length }),
{ headers: { "Content-Type": "application/json" } }
);
} catch (err) {
console.error("Unexpected error:", err);
return new Response(
JSON.stringify({ error: "Internal server error" }),
{ status: 500, headers: { "Content-Type": "application/json" } }
);
}
});
@@ -196,14 +196,9 @@ Deno.serve(async (req) => {
if (!matchError) {
match = newMatch;
// Trigger push notification
try {
await supabase.functions.invoke("send-notification", {
body: { couple_id: profile.couple_id, match_id: newMatch.id },
});
} catch (notifyError) {
console.error("Failed to send notification:", notifyError);
}
// Match push notifications are queued via a Postgres trigger
// (pending_match_notifications) to allow short digesting.
}
}
}
@@ -218,14 +218,9 @@ Deno.serve(async (req) => {
if (!matchError && newMatch) {
result.new_match = newMatch;
// Trigger push notification for new match
try {
await supabase.functions.invoke("send-notification", {
body: { couple_id: profile.couple_id, match_id: newMatch.id },
});
} catch (notifyError) {
console.error("Failed to send notification:", notifyError);
}
// Match push notifications are queued via a Postgres trigger
// (pending_match_notifications) to allow short digesting.
}
}
}
@@ -0,0 +1,92 @@
-- Add match notification digest system
-- Coalesces multiple match events into a single push notification.
--
-- Design goals:
-- - Reduce push spam when many matches occur quickly
-- - Keep delivery latency bounded (<= ~2 minutes)
-- - Keep match creation real-time; only push notifications are delayed
-- Create pending match notifications table
CREATE TABLE pending_match_notifications (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
couple_id UUID NOT NULL REFERENCES couples(id) ON DELETE CASCADE,
match_count INTEGER NOT NULL DEFAULT 1,
latest_match_id UUID REFERENCES matches(id) ON DELETE SET NULL,
notify_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now(),
UNIQUE(couple_id) -- One pending notification per couple (UPSERT timer reset)
);
-- Index for efficient cron queries
CREATE INDEX idx_pending_match_notifications_notify_at
ON pending_match_notifications(notify_at);
-- Enable RLS
ALTER TABLE pending_match_notifications ENABLE ROW LEVEL SECURITY;
-- RLS policies
CREATE POLICY "Users can view their couple's pending match notifications"
ON pending_match_notifications FOR SELECT
USING (couple_id = get_auth_user_couple_id());
CREATE POLICY "Super admins can view all pending match notifications"
ON pending_match_notifications FOR SELECT
USING (is_super_admin());
-- Updated_at trigger
CREATE TRIGGER update_pending_match_notifications_updated_at
BEFORE UPDATE ON pending_match_notifications
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
-- Function to queue match notifications
-- Runs whenever a new match row is inserted.
-- The notify_at value is aligned to minute boundaries so that a once-per-minute cron
-- can deliver notifications within ~1-2 minutes.
CREATE OR REPLACE FUNCTION queue_match_notification()
RETURNS TRIGGER
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = 'public'
AS $$
BEGIN
INSERT INTO pending_match_notifications (
couple_id,
match_count,
latest_match_id,
notify_at
)
VALUES (
NEW.couple_id,
1,
NEW.id,
date_trunc('minute', now()) + interval '2 minutes'
)
ON CONFLICT (couple_id) DO UPDATE SET
match_count = pending_match_notifications.match_count + 1,
latest_match_id = NEW.id,
notify_at = date_trunc('minute', now()) + interval '2 minutes',
updated_at = now();
RETURN NEW;
END;
$$;
-- Trigger on matches table
CREATE TRIGGER on_match_created
AFTER INSERT ON matches
FOR EACH ROW
EXECUTE FUNCTION queue_match_notification();
-- Schedule cron job (runs every minute)
SELECT cron.schedule(
'send-match-notification-digests',
'* * * * *',
$$
SELECT net.http_post(
url := 'https://ckjcrkjpmhqhiucifukx.supabase.co/functions/v1/send-match-notification-digest',
headers := jsonb_build_object('Content-Type', 'application/json'),
body := '{}'::jsonb
)
$$
);
+13 -1
View File
@@ -2,6 +2,8 @@
import Image from 'next/image'
import { motion } from 'framer-motion'
import { Play } from 'lucide-react'
export default function CTA() {
return (
@@ -51,8 +53,18 @@ export default function CTA() {
</svg>
Download for iOS
</a>
<span className="text-white/40">Coming soon to Android</span>
<a
href="https://play.google.com/store/apps/details?id=com.sauci.app"
target="_blank"
rel="noopener noreferrer"
className="btn-secondary flex items-center gap-2"
>
<Play className="w-5 h-5" />
Download for Android
</a>
</div>
</motion.div>
</div>
</section>
+20 -2
View File
@@ -3,7 +3,8 @@
import { useEffect, useState } from 'react'
import Image from 'next/image'
import { motion } from 'framer-motion'
import { Sparkles } from 'lucide-react'
import { Sparkles, Play } from 'lucide-react'
export default function Hero() {
const [hasScrolled, setHasScrolled] = useState(false)
@@ -53,7 +54,8 @@ export default function Hero() {
it&apos;s a match. Start conversations you&apos;ve always wanted to have.
</p>
<div className="flex flex-col sm:flex-row items-center justify-center lg:justify-start gap-4">
<div className="flex flex-col sm:flex-row flex-wrap items-center justify-center lg:justify-start gap-4">
{/* Official App Store Badge */}
<a
href="https://apps.apple.com/app/sauci"
@@ -88,9 +90,25 @@ export default function Hero() {
/>
</svg>
</a>
<a
href="https://play.google.com/store/apps/details?id=com.sauci.app"
target="_blank"
rel="noopener noreferrer"
className="h-[50px] inline-flex items-center gap-3 rounded-xl bg-black/80 border border-white/10 px-5 transition-transform hover:scale-105 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-background"
aria-label="Get it on Google Play"
>
<Play className="w-6 h-6 text-white" />
<span className="flex flex-col leading-none">
<span className="text-[10px] tracking-wide text-white/60">GET IT ON</span>
<span className="text-sm font-semibold text-white">Google Play</span>
</span>
</a>
<a href="#how-it-works" className="btn-secondary flex items-center gap-2 w-full sm:w-auto justify-center">
See How It Works
</a>
</div>
</motion.div>
+18 -2
View File
@@ -6,9 +6,15 @@ import Image from 'next/image'
import { motion, AnimatePresence } from 'framer-motion'
import { Menu, X } from 'lucide-react'
const APP_STORE_URL = 'https://apps.apple.com/app/sauci'
const PLAY_STORE_URL = 'https://play.google.com/store/apps/details?id=com.sauci.app'
export default function Navbar() {
const [isScrolled, setIsScrolled] = useState(false)
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false)
const [downloadUrl, setDownloadUrl] = useState(APP_STORE_URL)
useEffect(() => {
const handleScroll = () => {
@@ -18,6 +24,14 @@ export default function Navbar() {
return () => window.removeEventListener('scroll', handleScroll)
}, [])
useEffect(() => {
const userAgent = navigator.userAgent || ''
if (/Android/i.test(userAgent)) {
setDownloadUrl(PLAY_STORE_URL)
}
}, [])
return (
<motion.nav
initial={{ y: -100 }}
@@ -53,13 +67,14 @@ export default function Navbar() {
FAQ
</a>
<a
href="https://apps.apple.com/app/sauci"
href={downloadUrl}
target="_blank"
rel="noopener noreferrer"
className="btn-primary text-sm py-3 px-6"
>
Download App
</a>
</div>
{/* Mobile Menu Button */}
@@ -111,13 +126,14 @@ export default function Navbar() {
FAQ
</a>
<a
href="https://apps.apple.com/app/sauci"
href={downloadUrl}
target="_blank"
rel="noopener noreferrer"
className="btn-primary text-sm py-3 px-6 text-center"
>
Download App
</a>
</div>
</motion.div>
)}
+17 -1
View File
@@ -1,9 +1,14 @@
'use client'
import { useEffect, useState } from 'react'
import { motion } from 'framer-motion'
import { Check, Crown, Heart, Sparkles, Infinity } from 'lucide-react'
const APP_STORE_URL = 'https://apps.apple.com/app/sauci'
const PLAY_STORE_URL = 'https://play.google.com/store/apps/details?id=com.sauci.app'
const features = [
'Unlock all question packs',
'Access exclusive premium content',
'New packs added regularly',
@@ -12,7 +17,17 @@ const features = [
]
export default function Pricing() {
const [downloadUrl, setDownloadUrl] = useState(APP_STORE_URL)
useEffect(() => {
const userAgent = navigator.userAgent || ''
if (/Android/i.test(userAgent)) {
setDownloadUrl(PLAY_STORE_URL)
}
}, [])
return (
<section id="pricing" className="relative py-24 overflow-hidden">
{/* Background */}
<div className="absolute inset-0 bg-gradient-to-b from-background-light to-background" />
@@ -114,7 +129,7 @@ export default function Pricing() {
{/* CTA */}
<div className="text-center">
<a
href="https://apps.apple.com/app/sauci"
href={downloadUrl}
target="_blank"
rel="noopener noreferrer"
className="btn-primary inline-flex items-center gap-2"
@@ -122,6 +137,7 @@ export default function Pricing() {
<Crown className="w-5 h-5" />
Get Sauci Pro
</a>
<p className="text-sm text-white/40 mt-3">
Upgrade anytime from within the app
</p>