mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-11-02 10:19:43 +00:00
Compare commits
5
Commits
ce68d57a49
...
19a6bb7cda
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
19a6bb7cda | ||
|
|
e28dcc1592 | ||
|
|
3e70669a27 | ||
|
|
b070a6f6a2 | ||
|
|
349f460e15 |
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}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -128,7 +132,10 @@ export function UsersPage() {
|
||||
}, []),
|
||||
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,12 +260,20 @@ 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 && (
|
||||
{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>
|
||||
<TableCell>
|
||||
@@ -272,10 +295,17 @@ export function UsersPage() {
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{profile.couple_id ? (
|
||||
<Badge variant="secondary">
|
||||
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>
|
||||
)}
|
||||
|
||||
@@ -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.
|
||||
@@ -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 {
|
||||
|
||||
@@ -177,7 +177,7 @@
|
||||
"backgroundColor": "#1a1a2e"
|
||||
},
|
||||
"package": "com.sauci.app",
|
||||
"versionCode": 31,
|
||||
"versionCode": 32,
|
||||
"googleServicesFile": "./google-services.json",
|
||||
"permissions": [
|
||||
"RECEIVE_BOOT_COMPLETED",
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,11 +168,19 @@ export default function OnboardingScreen() {
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const showExplicitContent = maxIntensity >= 3;
|
||||
// Upload avatar if one was selected
|
||||
let avatarUrl: string | null = null;
|
||||
if (avatarUri) {
|
||||
avatarUrl = await uploadAvatar();
|
||||
if (avatarUrl) {
|
||||
Events.avatarUploaded();
|
||||
}
|
||||
}
|
||||
|
||||
const { error: updateError } = await supabase
|
||||
.from('profiles')
|
||||
.update({
|
||||
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,
|
||||
@@ -165,7 +188,16 @@ export default function OnboardingScreen() {
|
||||
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(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,
|
||||
|
||||
@@ -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;
|
||||
@@ -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
@@ -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>
|
||||
) : (
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
@@ -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,6 +25,15 @@ export function AccountScreen() {
|
||||
showsVerticalScrollIndicator={false}
|
||||
>
|
||||
<SettingsSection title="Account" delay={0}>
|
||||
{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"
|
||||
@@ -30,6 +41,7 @@ export function AccountScreen() {
|
||||
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';
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
@@ -147,6 +153,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
couple: null,
|
||||
partner: null,
|
||||
isAuthenticated: false,
|
||||
isAnonymous: false,
|
||||
isLoading: false,
|
||||
});
|
||||
// Clear other stores
|
||||
@@ -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
|
||||
)
|
||||
$$
|
||||
);
|
||||
@@ -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>
|
||||
|
||||
@@ -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's a match. Start conversations you'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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in new issue
Block a user