mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-11-02 10:19:43 +00:00
Compare commits
5
Commits
| 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 { AiGeneratorDialog } from '@/components/ai/AiGeneratorDialog';
|
||||||
import { AIPolishButton } from '@/components/ai/AIPolishButton';
|
import { AIPolishButton } from '@/components/ai/AIPolishButton';
|
||||||
import { ReviewQuestionsDialog } from '@/components/content/ReviewQuestionsDialog';
|
import { ReviewQuestionsDialog } from '@/components/content/ReviewQuestionsDialog';
|
||||||
|
import { IconPreview } from '@/components/ui/icon-picker';
|
||||||
|
|
||||||
interface QuestionPack {
|
interface QuestionPack {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -525,7 +526,7 @@ export function QuestionsPage() {
|
|||||||
<h1 className="text-3xl font-bold tracking-tight flex items-center gap-2">
|
<h1 className="text-3xl font-bold tracking-tight flex items-center gap-2">
|
||||||
{pack && (
|
{pack && (
|
||||||
<>
|
<>
|
||||||
<span className="text-3xl">{pack.icon}</span>
|
<IconPreview value={pack.icon} fallback="heart-outline" className="text-3xl" />
|
||||||
{pack.name}
|
{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 && <Badge className="bg-amber-500"><Crown className="h-3 w-3 mr-1" />Premium</Badge>}
|
||||||
{!profile.is_premium && hasPermission(PERMISSION_KEYS.GIFT_PREMIUM) && <Dialog open={upgradeOpen} onOpenChange={setUpgradeOpen}><DialogTrigger asChild><Button size="sm" variant="outline" className="ml-2 gap-1 h-7"><Gift className="h-3.5 w-3.5" />Gift Premium</Button></DialogTrigger><DialogContent><DialogHeader><DialogTitle>Gift Premium Access</DialogTitle><DialogDescription>Manually upgrade this user to premium status.</DialogDescription></DialogHeader><div className="grid gap-4 py-4"><div className="grid gap-2"><Label htmlFor="expiry-type">Duration</Label><Select value={expiryType} onValueChange={setExpiryType}><SelectTrigger id="expiry-type"><SelectValue placeholder="Select duration" /></SelectTrigger><SelectContent><SelectItem value="forever">Lifetime (No Expiry)</SelectItem><SelectItem value="1_month">1 Month</SelectItem><SelectItem value="1_year">1 Year</SelectItem><SelectItem value="custom">Custom Date</SelectItem></SelectContent></Select></div>{expiryType === 'custom' && (<div className="grid gap-2"><Label htmlFor="custom-date">Expiry Date</Label><Input id="custom-date" type="datetime-local" value={customDate} onChange={(e) => setCustomDate(e.target.value)} /></div>)}</div><DialogFooter><Button variant="outline" onClick={() => setUpgradeOpen(false)}>Cancel</Button><Button onClick={handleUpgrade} disabled={upgrading}>{upgrading ? 'Upgrading...' : 'Confirm Upgrade'}</Button></DialogFooter></DialogContent></Dialog>}
|
{!profile.is_premium && hasPermission(PERMISSION_KEYS.GIFT_PREMIUM) && <Dialog open={upgradeOpen} onOpenChange={setUpgradeOpen}><DialogTrigger asChild><Button size="sm" variant="outline" className="ml-2 gap-1 h-7"><Gift className="h-3.5 w-3.5" />Gift Premium</Button></DialogTrigger><DialogContent><DialogHeader><DialogTitle>Gift Premium Access</DialogTitle><DialogDescription>Manually upgrade this user to premium status.</DialogDescription></DialogHeader><div className="grid gap-4 py-4"><div className="grid gap-2"><Label htmlFor="expiry-type">Duration</Label><Select value={expiryType} onValueChange={setExpiryType}><SelectTrigger id="expiry-type"><SelectValue placeholder="Select duration" /></SelectTrigger><SelectContent><SelectItem value="forever">Lifetime (No Expiry)</SelectItem><SelectItem value="1_month">1 Month</SelectItem><SelectItem value="1_year">1 Year</SelectItem><SelectItem value="custom">Custom Date</SelectItem></SelectContent></Select></div>{expiryType === 'custom' && (<div className="grid gap-2"><Label htmlFor="custom-date">Expiry Date</Label><Input id="custom-date" type="datetime-local" value={customDate} onChange={(e) => setCustomDate(e.target.value)} /></div>)}</div><DialogFooter><Button variant="outline" onClick={() => setUpgradeOpen(false)}>Cancel</Button><Button onClick={handleUpgrade} disabled={upgrading}>{upgrading ? 'Upgrading...' : 'Confirm Upgrade'}</Button></DialogFooter></DialogContent></Dialog>}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-muted-foreground mb-4">{profile.email || 'No email'}</p>
|
<div className="text-muted-foreground mb-4 flex items-center gap-2">
|
||||||
|
{profile.email ? (
|
||||||
|
<span>{profile.email}</span>
|
||||||
|
) : (
|
||||||
|
<Badge variant="secondary" className="bg-slate-100 text-slate-500 hover:bg-slate-200">Guest</Badge>
|
||||||
|
)}
|
||||||
|
{!profile.email && (
|
||||||
|
<span className="text-xs">Not recoverable unless saved in-app</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<div className="flex gap-6 text-sm">
|
<div className="flex gap-6 text-sm">
|
||||||
<div><span className="text-muted-foreground">Joined: </span><span>{profile.created_at ? format(new Date(profile.created_at), 'MMMM d, yyyy') : '—'}</span></div>
|
<div><span className="text-muted-foreground">Joined: </span><span>{profile.created_at ? format(new Date(profile.created_at), 'MMMM d, yyyy') : '—'}</span></div>
|
||||||
{partner ? <div className="flex items-center gap-1"><Users className="h-4 w-4 text-muted-foreground" /><span className="text-muted-foreground">Paired with: </span><Link to={`/users/${partner.id}`} className="text-primary hover:underline">{partner.name || 'Unnamed Partner'}</Link></div> : <div className="text-muted-foreground">Not paired</div>}
|
{partner ? <div className="flex items-center gap-1"><Users className="h-4 w-4 text-muted-foreground" /><span className="text-muted-foreground">Paired with: </span><Link to={`/users/${partner.id}`} className="text-primary hover:underline">{partner.name || 'Unnamed Partner'}</Link></div> : profile.couple_id ? <div className="text-amber-600 dark:text-amber-400 flex items-center gap-1"><Users className="h-4 w-4" />Waiting for partner</div> : <div className="text-muted-foreground">Not paired</div>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div></CardContent></Card>
|
</div></CardContent></Card>
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import {
|
|||||||
TableHeader,
|
TableHeader,
|
||||||
TableRow,
|
TableRow,
|
||||||
} from '@/components/ui/table';
|
} from '@/components/ui/table';
|
||||||
import { Search, Crown, Users, ChevronRight, HardDrive, MailWarning } from 'lucide-react';
|
import { Search, Crown, Users, ChevronRight, HardDrive, MailWarning, Hourglass } from 'lucide-react';
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
|
|
||||||
interface Profile {
|
interface Profile {
|
||||||
@@ -32,6 +32,7 @@ interface Profile {
|
|||||||
email_confirmed_at: string | null;
|
email_confirmed_at: string | null;
|
||||||
storage_bytes?: number;
|
storage_bytes?: number;
|
||||||
partner_is_premium?: boolean;
|
partner_is_premium?: boolean;
|
||||||
|
partner_count?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatBytes(bytes: number): string {
|
function formatBytes(bytes: number): string {
|
||||||
@@ -87,11 +88,13 @@ export function UsersPage() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Merge storage data and partner premium status with profiles
|
// Merge storage data, partner premium status, and partner count with profiles
|
||||||
const profilesWithExtras = profilesData.map(profile => {
|
const profilesWithExtras = profilesData.map(profile => {
|
||||||
// Check if partner has premium
|
// Check if partner has premium and count partners in couple
|
||||||
let partnerIsPremium = false;
|
let partnerIsPremium = false;
|
||||||
|
let partnerCount = 0;
|
||||||
if (profile.couple_id && couplePartners[profile.couple_id]) {
|
if (profile.couple_id && couplePartners[profile.couple_id]) {
|
||||||
|
partnerCount = couplePartners[profile.couple_id].length;
|
||||||
const partner = couplePartners[profile.couple_id].find(p => p.id !== profile.id);
|
const partner = couplePartners[profile.couple_id].find(p => p.id !== profile.id);
|
||||||
partnerIsPremium = partner?.is_premium || false;
|
partnerIsPremium = partner?.is_premium || false;
|
||||||
}
|
}
|
||||||
@@ -99,6 +102,7 @@ export function UsersPage() {
|
|||||||
...profile,
|
...profile,
|
||||||
storage_bytes: storageMap[profile.id] || 0,
|
storage_bytes: storageMap[profile.id] || 0,
|
||||||
partner_is_premium: partnerIsPremium,
|
partner_is_premium: partnerIsPremium,
|
||||||
|
partner_count: partnerCount,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -126,9 +130,12 @@ export function UsersPage() {
|
|||||||
setProfiles(prev => prev.filter(p => p.id !== deleted.id));
|
setProfiles(prev => prev.filter(p => p.id !== deleted.id));
|
||||||
setFilteredProfiles(prev => prev.filter(p => p.id !== deleted.id));
|
setFilteredProfiles(prev => prev.filter(p => p.id !== deleted.id));
|
||||||
}, []),
|
}, []),
|
||||||
insertToast: {
|
insertToast: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
message: (payload) => `New user registered: ${payload.name || payload.email || 'Unknown'}`,
|
message: (payload) => {
|
||||||
|
const label = payload.name || payload.email || 'Guest';
|
||||||
|
return payload.email ? `New user registered: ${label}` : `New guest user: ${label}`;
|
||||||
|
},
|
||||||
type: 'success',
|
type: 'success',
|
||||||
},
|
},
|
||||||
debounceMs: 1000, // Debounce to avoid excessive refetches
|
debounceMs: 1000, // Debounce to avoid excessive refetches
|
||||||
@@ -191,7 +198,15 @@ export function UsersPage() {
|
|||||||
<RealtimeStatusIndicator status={realtimeStatus} showLabel />
|
<RealtimeStatusIndicator status={realtimeStatus} showLabel />
|
||||||
</div>
|
</div>
|
||||||
<p className="text-muted-foreground text-sm">
|
<p className="text-muted-foreground text-sm">
|
||||||
{profiles.length} registered user{profiles.length !== 1 ? 's' : ''}
|
{profiles.length} account{profiles.length !== 1 ? 's' : ''}
|
||||||
|
{(() => {
|
||||||
|
const guestCount = profiles.filter(p => !p.email).length;
|
||||||
|
const savedCount = profiles.length - guestCount;
|
||||||
|
if (guestCount === 0) return null;
|
||||||
|
return (
|
||||||
|
<span> • {savedCount} saved • {guestCount} guest</span>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="relative w-full sm:w-64">
|
<div className="relative w-full sm:w-64">
|
||||||
@@ -245,11 +260,19 @@ export function UsersPage() {
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-muted-foreground">
|
<TableCell className="text-muted-foreground">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
{profile.email || '—'}
|
{profile.email ? (
|
||||||
{profile.email && !profile.email_confirmed_at && (
|
<>
|
||||||
<span title="Email not verified">
|
{profile.email}
|
||||||
<MailWarning className="h-3.5 w-3.5 text-amber-500" />
|
{!profile.email_confirmed_at && (
|
||||||
</span>
|
<span title="Email not verified">
|
||||||
|
<MailWarning className="h-3.5 w-3.5 text-amber-500" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Badge variant="secondary" className="bg-slate-100 text-slate-500 hover:bg-slate-200">
|
||||||
|
Guest
|
||||||
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</TableCell>
|
</TableCell>
|
||||||
@@ -272,10 +295,17 @@ export function UsersPage() {
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
{profile.couple_id ? (
|
{profile.couple_id ? (
|
||||||
<Badge variant="secondary">
|
profile.partner_count && profile.partner_count >= 2 ? (
|
||||||
<Users className="h-3 w-3 mr-1" />
|
<Badge variant="secondary" className="bg-green-100 text-green-700 hover:bg-green-200">
|
||||||
Paired
|
<Users className="h-3 w-3 mr-1" />
|
||||||
</Badge>
|
Paired
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variant="secondary" className="bg-amber-100 text-amber-700 hover:bg-amber-200">
|
||||||
|
<Hourglass className="h-3 w-3 mr-1" />
|
||||||
|
Pairing
|
||||||
|
</Badge>
|
||||||
|
)
|
||||||
) : (
|
) : (
|
||||||
<span className="text-muted-foreground text-sm">—</span>
|
<span className="text-muted-foreground text-sm">—</span>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -9,6 +9,18 @@ export default defineConfig({
|
|||||||
'@': path.resolve(__dirname, './src'),
|
'@': path.resolve(__dirname, './src'),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
optimizeDeps: {
|
||||||
|
include: ['react-icons/io5'],
|
||||||
|
},
|
||||||
|
build: {
|
||||||
|
rollupOptions: {
|
||||||
|
output: {
|
||||||
|
manualChunks: {
|
||||||
|
'react-icons': ['react-icons/io5'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
server: {
|
server: {
|
||||||
host: true,
|
host: true,
|
||||||
port: 3001,
|
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'
|
applicationId 'com.sauci.app'
|
||||||
minSdkVersion rootProject.ext.minSdkVersion
|
minSdkVersion rootProject.ext.minSdkVersion
|
||||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||||
versionCode 31
|
versionCode 32
|
||||||
versionName "1.0.2"
|
versionName "1.0.2"
|
||||||
}
|
}
|
||||||
signingConfigs {
|
signingConfigs {
|
||||||
|
|||||||
@@ -177,7 +177,7 @@
|
|||||||
"backgroundColor": "#1a1a2e"
|
"backgroundColor": "#1a1a2e"
|
||||||
},
|
},
|
||||||
"package": "com.sauci.app",
|
"package": "com.sauci.app",
|
||||||
"versionCode": 31,
|
"versionCode": 32,
|
||||||
"googleServicesFile": "./google-services.json",
|
"googleServicesFile": "./google-services.json",
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"RECEIVE_BOOT_COMPLETED",
|
"RECEIVE_BOOT_COMPLETED",
|
||||||
|
|||||||
@@ -392,7 +392,7 @@ export default function AppLayout() {
|
|||||||
.eq("id", payload.new.id)
|
.eq("id", payload.new.id)
|
||||||
.single();
|
.single();
|
||||||
|
|
||||||
if (matchWithQuestion) {
|
if (matchWithQuestion && matchWithQuestion.question) {
|
||||||
// Track milestone events before adding (so count is accurate)
|
// Track milestone events before adding (so count is accurate)
|
||||||
const currentMatchCount = matches.length;
|
const currentMatchCount = matches.length;
|
||||||
if (currentMatchCount === 0) {
|
if (currentMatchCount === 0) {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
Platform,
|
Platform,
|
||||||
Keyboard,
|
Keyboard,
|
||||||
ScrollView,
|
ScrollView,
|
||||||
|
Image,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { useRouter } from 'expo-router';
|
import { useRouter } from 'expo-router';
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
@@ -17,10 +18,8 @@ import Animated, {
|
|||||||
FadeIn,
|
FadeIn,
|
||||||
FadeInUp,
|
FadeInUp,
|
||||||
FadeOut,
|
FadeOut,
|
||||||
FadeOutLeft,
|
|
||||||
SlideInRight,
|
SlideInRight,
|
||||||
SlideOutLeft,
|
SlideOutLeft,
|
||||||
Layout,
|
|
||||||
} from 'react-native-reanimated';
|
} from 'react-native-reanimated';
|
||||||
import { GradientBackground } from '../../src/components/ui/GradientBackground';
|
import { GradientBackground } from '../../src/components/ui/GradientBackground';
|
||||||
import { GlassCard } from '../../src/components/ui/GlassCard';
|
import { GlassCard } from '../../src/components/ui/GlassCard';
|
||||||
@@ -32,6 +31,7 @@ import { supabase } from '../../src/lib/supabase';
|
|||||||
import { getProfileError } from '../../src/lib/errors';
|
import { getProfileError } from '../../src/lib/errors';
|
||||||
import { registerForPushNotificationsAsync, savePushToken } from '../../src/lib/notifications';
|
import { registerForPushNotificationsAsync, savePushToken } from '../../src/lib/notifications';
|
||||||
import { Events } from '../../src/lib/analytics';
|
import { Events } from '../../src/lib/analytics';
|
||||||
|
import { useAvatarPicker } from '../../src/hooks/useAvatarPicker';
|
||||||
import type { Gender, IntensityLevel } from '../../src/types';
|
import type { Gender, IntensityLevel } from '../../src/types';
|
||||||
|
|
||||||
const GENDER_OPTIONS: { value: Gender; label: string; icon: string }[] = [
|
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;
|
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() {
|
export default function OnboardingScreen() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { user, fetchUser } = useAuthStore();
|
const { user, fetchUser } = useAuthStore();
|
||||||
const [stage, setStage] = useState<Stage>('name');
|
const [stage, setStage] = useState<Stage>('avatar');
|
||||||
const [name, setName] = useState(user?.name || '');
|
const [name, setName] = useState(user?.name || '');
|
||||||
const [gender, setGender] = useState<Gender | null>(user?.gender || null);
|
const [gender, setGender] = useState<Gender | null>(user?.gender || null);
|
||||||
const [usageReason, setUsageReason] = useState<UsageReason | null>(null);
|
const [usageReason, setUsageReason] = useState<UsageReason | null>(null);
|
||||||
@@ -78,10 +78,25 @@ export default function OnboardingScreen() {
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const inputRef = useRef<TextInput>(null);
|
const inputRef = useRef<TextInput>(null);
|
||||||
|
|
||||||
|
const {
|
||||||
|
avatarUri,
|
||||||
|
isUploading: isUploadingAvatar,
|
||||||
|
showPicker: handleAvatarPress,
|
||||||
|
uploadAvatar,
|
||||||
|
} = useAvatarPicker({
|
||||||
|
userId: user?.id,
|
||||||
|
onSelect: () => setError(null),
|
||||||
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
Events.onboardingStart();
|
Events.onboardingStart();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const handleAvatarContinue = () => {
|
||||||
|
setStage('name');
|
||||||
|
Events.onboardingStageComplete('avatar');
|
||||||
|
};
|
||||||
|
|
||||||
const handleNameSubmit = () => {
|
const handleNameSubmit = () => {
|
||||||
if (!name.trim()) {
|
if (!name.trim()) {
|
||||||
setError('Please enter your name');
|
setError('Please enter your name');
|
||||||
@@ -153,19 +168,36 @@ export default function OnboardingScreen() {
|
|||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Upload avatar if one was selected
|
||||||
|
let avatarUrl: string | null = null;
|
||||||
|
if (avatarUri) {
|
||||||
|
avatarUrl = await uploadAvatar();
|
||||||
|
if (avatarUrl) {
|
||||||
|
Events.avatarUploaded();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const showExplicitContent = maxIntensity >= 3;
|
const showExplicitContent = maxIntensity >= 3;
|
||||||
|
const updatedFields: string[] = ["name", "gender", "usage_reason", "max_intensity", "show_explicit_content"];
|
||||||
|
|
||||||
|
const updateData: Record<string, any> = {
|
||||||
|
name: name.trim(),
|
||||||
|
gender,
|
||||||
|
usage_reason: usageReason,
|
||||||
|
max_intensity: maxIntensity,
|
||||||
|
show_explicit_content: showExplicitContent,
|
||||||
|
onboarding_completed: true,
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (avatarUrl) {
|
||||||
|
updateData.avatar_url = avatarUrl;
|
||||||
|
updatedFields.push("avatar_url");
|
||||||
|
}
|
||||||
|
|
||||||
const { error: updateError } = await supabase
|
const { error: updateError } = await supabase
|
||||||
.from('profiles')
|
.from('profiles')
|
||||||
.update({
|
.update(updateData)
|
||||||
name: name.trim(),
|
|
||||||
gender,
|
|
||||||
usage_reason: usageReason,
|
|
||||||
max_intensity: maxIntensity,
|
|
||||||
show_explicit_content: showExplicitContent,
|
|
||||||
onboarding_completed: true,
|
|
||||||
updated_at: new Date().toISOString(),
|
|
||||||
})
|
|
||||||
.eq('id', user.id);
|
.eq('id', user.id);
|
||||||
|
|
||||||
if (updateError) {
|
if (updateError) {
|
||||||
@@ -176,7 +208,7 @@ export default function OnboardingScreen() {
|
|||||||
console.log('[Onboarding] Profile updated successfully, fetching user...');
|
console.log('[Onboarding] Profile updated successfully, fetching user...');
|
||||||
await fetchUser();
|
await fetchUser();
|
||||||
console.log('[Onboarding] User fetched, navigating to home...');
|
console.log('[Onboarding] User fetched, navigating to home...');
|
||||||
Events.profileUpdated(["name", "gender", "usage_reason", "max_intensity", "show_explicit_content"]);
|
Events.profileUpdated(updatedFields);
|
||||||
Events.onboardingComplete();
|
Events.onboardingComplete();
|
||||||
router.replace('/');
|
router.replace('/');
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
@@ -189,10 +221,10 @@ export default function OnboardingScreen() {
|
|||||||
|
|
||||||
const renderStage = () => {
|
const renderStage = () => {
|
||||||
switch (stage) {
|
switch (stage) {
|
||||||
case 'name':
|
case 'avatar':
|
||||||
return (
|
return (
|
||||||
<Animated.View
|
<Animated.View
|
||||||
key="name"
|
key="avatar"
|
||||||
entering={FadeInUp.duration(500)}
|
entering={FadeInUp.duration(500)}
|
||||||
exiting={SlideOutLeft.duration(300)}
|
exiting={SlideOutLeft.duration(300)}
|
||||||
style={styles.stageContainer}
|
style={styles.stageContainer}
|
||||||
@@ -206,12 +238,73 @@ export default function OnboardingScreen() {
|
|||||||
</LinearGradient>
|
</LinearGradient>
|
||||||
<Text style={styles.title}>Welcome to Sauci</Text>
|
<Text style={styles.title}>Welcome to Sauci</Text>
|
||||||
<Text style={styles.subtitle}>
|
<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>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<GlassCard style={styles.card}>
|
<GlassCard style={styles.card}>
|
||||||
<Text style={styles.label}>What's your name?</Text>
|
|
||||||
<View style={styles.inputContainer}>
|
<View style={styles.inputContainer}>
|
||||||
<Ionicons
|
<Ionicons
|
||||||
name="person-outline"
|
name="person-outline"
|
||||||
@@ -224,7 +317,7 @@ export default function OnboardingScreen() {
|
|||||||
style={styles.input}
|
style={styles.input}
|
||||||
value={name}
|
value={name}
|
||||||
onChangeText={setName}
|
onChangeText={setName}
|
||||||
placeholder="Enter your name"
|
placeholder="Your name or nickname"
|
||||||
placeholderTextColor={colors.textTertiary}
|
placeholderTextColor={colors.textTertiary}
|
||||||
autoCapitalize="words"
|
autoCapitalize="words"
|
||||||
autoCorrect={false}
|
autoCorrect={false}
|
||||||
@@ -515,14 +608,16 @@ export default function OnboardingScreen() {
|
|||||||
>
|
>
|
||||||
{/* Header with back button and progress dots */}
|
{/* Header with back button and progress dots */}
|
||||||
<Animated.View entering={FadeIn.duration(600)} style={styles.headerBar}>
|
<Animated.View entering={FadeIn.duration(600)} style={styles.headerBar}>
|
||||||
{stage !== 'name' ? (
|
{stage !== 'avatar' ? (
|
||||||
<Pressable
|
<Pressable
|
||||||
style={styles.backButton}
|
style={styles.backButton}
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
|
setError(null);
|
||||||
if (stage === 'notifications') setStage('explicit');
|
if (stage === 'notifications') setStage('explicit');
|
||||||
else if (stage === 'explicit') setStage('purpose');
|
else if (stage === 'explicit') setStage('purpose');
|
||||||
else if (stage === 'purpose') setStage('gender');
|
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} />
|
<Ionicons name="chevron-back" size={24} color={colors.text} />
|
||||||
@@ -531,6 +626,7 @@ export default function OnboardingScreen() {
|
|||||||
<View style={styles.backButtonPlaceholder} />
|
<View style={styles.backButtonPlaceholder} />
|
||||||
)}
|
)}
|
||||||
<View style={styles.progressContainer}>
|
<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 === 'name' && styles.progressDotActive]} />
|
||||||
<View style={[styles.progressDot, stage === 'gender' && styles.progressDotActive]} />
|
<View style={[styles.progressDot, stage === 'gender' && styles.progressDotActive]} />
|
||||||
<View style={[styles.progressDot, stage === 'purpose' && styles.progressDotActive]} />
|
<View style={[styles.progressDot, stage === 'purpose' && styles.progressDotActive]} />
|
||||||
@@ -611,6 +707,56 @@ const styles = StyleSheet.create({
|
|||||||
marginBottom: spacing.lg,
|
marginBottom: spacing.lg,
|
||||||
...shadows.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: {
|
title: {
|
||||||
...typography.largeTitle,
|
...typography.largeTitle,
|
||||||
color: colors.text,
|
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 { usePacksStore, useAuthStore, useSubscriptionStore } from "../../../src/store";
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { router } from "expo-router";
|
import { router } from "expo-router";
|
||||||
@@ -14,7 +14,7 @@ import Animated, {
|
|||||||
Extrapolation,
|
Extrapolation,
|
||||||
} from "react-native-reanimated";
|
} from "react-native-reanimated";
|
||||||
import { BlurView } from "expo-blur";
|
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 { Paywall } from "../../../src/components/paywall";
|
||||||
import { PackTeaser } from "../../../src/components/PackTeaser";
|
import { PackTeaser } from "../../../src/components/PackTeaser";
|
||||||
import { colors, spacing, radius, typography } from "../../../src/theme";
|
import { colors, spacing, radius, typography } from "../../../src/theme";
|
||||||
@@ -461,15 +461,9 @@ export default function QuestionPacksScreen() {
|
|||||||
Show all intensities
|
Show all intensities
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<Switch
|
<GlassToggle
|
||||||
value={showAllIntensities}
|
value={showAllIntensities}
|
||||||
onValueChange={handleToggleComfortZone}
|
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>
|
</Pressable>
|
||||||
</Animated.View>
|
</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 [email, setEmail] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [isGuestLoading, setIsGuestLoading] = useState(false);
|
||||||
const [isMagicLinkSent, setIsMagicLinkSent] = useState(false);
|
const [isMagicLinkSent, setIsMagicLinkSent] = useState(false);
|
||||||
const [pendingVerification, setPendingVerification] = useState<PendingVerification | null>(null);
|
const [pendingVerification, setPendingVerification] = useState<PendingVerification | null>(null);
|
||||||
const [isCheckingVerification, setIsCheckingVerification] = useState(false);
|
const [isCheckingVerification, setIsCheckingVerification] = useState(false);
|
||||||
@@ -364,6 +365,20 @@ export default function LoginScreen() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleGuestSignIn = async () => {
|
||||||
|
clearError();
|
||||||
|
setIsGuestLoading(true);
|
||||||
|
const { error: authError } = await supabase.auth.signInAnonymously();
|
||||||
|
setIsGuestLoading(false);
|
||||||
|
|
||||||
|
if (authError) {
|
||||||
|
showError(getAuthError(authError));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Events.signIn("guest");
|
||||||
|
};
|
||||||
|
|
||||||
if (isMagicLinkSent) {
|
if (isMagicLinkSent) {
|
||||||
return (
|
return (
|
||||||
<GradientBackground showAccent>
|
<GradientBackground showAccent>
|
||||||
@@ -663,6 +678,23 @@ export default function LoginScreen() {
|
|||||||
</View>
|
</View>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<Animated.View
|
||||||
|
entering={FadeInDown.delay(400).duration(600).springify()}
|
||||||
|
style={styles.guestContainer}
|
||||||
|
>
|
||||||
|
<GlassButton
|
||||||
|
variant="ghost"
|
||||||
|
onPress={handleGuestSignIn}
|
||||||
|
loading={isGuestLoading}
|
||||||
|
disabled={isGuestLoading}
|
||||||
|
icon={<Ionicons name="person-outline" size={20} color={colors.primary} />}
|
||||||
|
fullWidth
|
||||||
|
>
|
||||||
|
Continue without Login
|
||||||
|
</GlassButton>
|
||||||
|
<Text style={styles.guestHint}>Save your account anytime in Settings.</Text>
|
||||||
|
</Animated.View>
|
||||||
</View>
|
</View>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
</KeyboardAvoidingView>
|
</KeyboardAvoidingView>
|
||||||
@@ -821,4 +853,14 @@ const styles = StyleSheet.create({
|
|||||||
flex: 1,
|
flex: 1,
|
||||||
height: 48,
|
height: 48,
|
||||||
},
|
},
|
||||||
|
guestContainer: {
|
||||||
|
marginTop: spacing.lg,
|
||||||
|
alignItems: "center",
|
||||||
|
gap: spacing.sm,
|
||||||
|
},
|
||||||
|
guestHint: {
|
||||||
|
...typography.caption1,
|
||||||
|
color: colors.textTertiary,
|
||||||
|
textAlign: "center",
|
||||||
|
},
|
||||||
});
|
});
|
||||||
+169
-4
@@ -16,8 +16,8 @@ import {
|
|||||||
handleNotificationResponse,
|
handleNotificationResponse,
|
||||||
} from "../src/lib/notifications";
|
} from "../src/lib/notifications";
|
||||||
|
|
||||||
import React, { useEffect, useRef } from "react";
|
import React, { useEffect, useRef, useState } from "react";
|
||||||
import { Platform, View, AppState, AppStateStatus } from "react-native";
|
import { Platform, View, AppState, AppStateStatus, Modal, Text, StyleSheet, Pressable, TouchableOpacity } from "react-native";
|
||||||
|
|
||||||
// Suppress useLayoutEffect warning on server-side rendering
|
// Suppress useLayoutEffect warning on server-side rendering
|
||||||
if (Platform.OS === "web" && typeof globalThis.window === "undefined") {
|
if (Platform.OS === "web" && typeof globalThis.window === "undefined") {
|
||||||
@@ -34,6 +34,7 @@ if (Platform.OS === "web" && typeof globalThis.window === "undefined") {
|
|||||||
|
|
||||||
import { Stack, router, usePathname } from "expo-router";
|
import { Stack, router, usePathname } from "expo-router";
|
||||||
import { StatusBar } from "expo-status-bar";
|
import { StatusBar } from "expo-status-bar";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
// GestureHandlerRootView is only needed on native platforms
|
// GestureHandlerRootView is only needed on native platforms
|
||||||
// On web, we use a simple View to avoid findNodeHandle errors
|
// On web, we use a simple View to avoid findNodeHandle errors
|
||||||
const GestureHandlerRootView = Platform.OS === "web"
|
const GestureHandlerRootView = Platform.OS === "web"
|
||||||
@@ -43,15 +44,26 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|||||||
import * as Linking from "expo-linking";
|
import * as Linking from "expo-linking";
|
||||||
import { useAuthStore } from "../src/store";
|
import { useAuthStore } from "../src/store";
|
||||||
import { supabase } from "../src/lib/supabase";
|
import { supabase } from "../src/lib/supabase";
|
||||||
|
import { colors, spacing, radius, typography } from "../src/theme";
|
||||||
|
import { hasSeenGuestAccountWarning, markGuestAccountWarningSeen } from "../src/lib/guestAccountWarningSeen";
|
||||||
|
|
||||||
const queryClient = new QueryClient();
|
const queryClient = new QueryClient();
|
||||||
|
|
||||||
export default function RootLayout() {
|
export default function RootLayout() {
|
||||||
const { fetchUser, setUser } = useAuthStore();
|
const { fetchUser, setUser, isAuthenticated, isAnonymous } = useAuthStore();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const appState = useRef(AppState.currentState);
|
const appState = useRef(AppState.currentState);
|
||||||
const analyticsInitialized = useRef(false);
|
const analyticsInitialized = useRef(false);
|
||||||
|
|
||||||
|
const [showGuestWarning, setShowGuestWarning] = useState(false);
|
||||||
|
const [checkingGuestWarning, setCheckingGuestWarning] = useState(false);
|
||||||
|
|
||||||
|
const dismissGuestWarning = async () => {
|
||||||
|
await markGuestAccountWarningSeen();
|
||||||
|
setShowGuestWarning(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
// Initialize analytics after native modules are ready
|
// Initialize analytics after native modules are ready
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!analyticsInitialized.current) {
|
if (!analyticsInitialized.current) {
|
||||||
@@ -68,6 +80,22 @@ export default function RootLayout() {
|
|||||||
}
|
}
|
||||||
}, [pathname]);
|
}, [pathname]);
|
||||||
|
|
||||||
|
// Warn guest users (first time only) that accounts are not recoverable
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isAuthenticated || !isAnonymous) return;
|
||||||
|
if (checkingGuestWarning) return;
|
||||||
|
if (pathname?.startsWith("/(auth)")) return;
|
||||||
|
|
||||||
|
setCheckingGuestWarning(true);
|
||||||
|
hasSeenGuestAccountWarning()
|
||||||
|
.then((seen) => {
|
||||||
|
if (!seen) {
|
||||||
|
setShowGuestWarning(true);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(() => setCheckingGuestWarning(false));
|
||||||
|
}, [isAuthenticated, isAnonymous, pathname, checkingGuestWarning]);
|
||||||
|
|
||||||
// Track warm starts when app comes back from background
|
// Track warm starts when app comes back from background
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const subscription = AppState.addEventListener("change", (nextAppState: AppStateStatus) => {
|
const subscription = AppState.addEventListener("change", (nextAppState: AppStateStatus) => {
|
||||||
@@ -181,7 +209,7 @@ export default function RootLayout() {
|
|||||||
const { data: { subscription: authSubscription } } = supabase.auth.onAuthStateChange(
|
const { data: { subscription: authSubscription } } = supabase.auth.onAuthStateChange(
|
||||||
async (event, session) => {
|
async (event, session) => {
|
||||||
console.log('[Auth] onAuthStateChange:', event, 'hasSession:', !!session, 'userId:', session?.user?.id);
|
console.log('[Auth] onAuthStateChange:', event, 'hasSession:', !!session, 'userId:', session?.user?.id);
|
||||||
if (event === "SIGNED_IN" || event === "TOKEN_REFRESHED") {
|
if (event === "SIGNED_IN" || event === "TOKEN_REFRESHED" || event === "USER_UPDATED") {
|
||||||
console.log('[Auth] Calling fetchUser...');
|
console.log('[Auth] Calling fetchUser...');
|
||||||
fetchUser();
|
fetchUser();
|
||||||
// Set Sentry user context for error tracking
|
// Set Sentry user context for error tracking
|
||||||
@@ -223,7 +251,144 @@ export default function RootLayout() {
|
|||||||
contentStyle: { backgroundColor: "#1a1a2e" },
|
contentStyle: { backgroundColor: "#1a1a2e" },
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
visible={showGuestWarning}
|
||||||
|
transparent
|
||||||
|
animationType="fade"
|
||||||
|
onRequestClose={dismissGuestWarning}
|
||||||
|
>
|
||||||
|
<Pressable
|
||||||
|
style={styles.guestWarningOverlay}
|
||||||
|
onPress={dismissGuestWarning}
|
||||||
|
>
|
||||||
|
<Pressable style={styles.guestWarningCard} onPress={() => { }}>
|
||||||
|
<View style={styles.guestWarningHeader}>
|
||||||
|
<View style={styles.guestWarningIcon}>
|
||||||
|
<Ionicons name="warning" size={22} color={colors.primary} />
|
||||||
|
</View>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={dismissGuestWarning}
|
||||||
|
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
||||||
|
style={styles.guestWarningClose}
|
||||||
|
>
|
||||||
|
<Ionicons name="close" size={20} color={colors.textSecondary} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text style={styles.guestWarningTitle}>Your account isn't saved</Text>
|
||||||
|
<Text style={styles.guestWarningBody}>
|
||||||
|
If you delete the app or switch devices, you won't be able to recover this account.
|
||||||
|
Save your account now to protect your couple, matches, and purchases.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<View style={styles.guestWarningActions}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.guestWarningPrimary}
|
||||||
|
onPress={async () => {
|
||||||
|
await markGuestAccountWarningSeen();
|
||||||
|
setShowGuestWarning(false);
|
||||||
|
router.push("/(app)/settings/save-account" as any);
|
||||||
|
}}
|
||||||
|
activeOpacity={0.8}
|
||||||
|
>
|
||||||
|
<Text style={styles.guestWarningPrimaryText}>Save account</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.guestWarningSecondary}
|
||||||
|
onPress={async () => {
|
||||||
|
await markGuestAccountWarningSeen();
|
||||||
|
setShowGuestWarning(false);
|
||||||
|
}}
|
||||||
|
activeOpacity={0.8}
|
||||||
|
>
|
||||||
|
<Text style={styles.guestWarningSecondaryText}>Not now</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
</Pressable>
|
||||||
|
</Modal>
|
||||||
</GestureHandlerRootView>
|
</GestureHandlerRootView>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
guestWarningOverlay: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
padding: spacing.lg,
|
||||||
|
backgroundColor: "rgba(0, 0, 0, 0.75)",
|
||||||
|
},
|
||||||
|
guestWarningCard: {
|
||||||
|
width: "100%",
|
||||||
|
maxWidth: 420,
|
||||||
|
backgroundColor: colors.backgroundLight,
|
||||||
|
borderRadius: radius.xl,
|
||||||
|
padding: spacing.lg,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.glass.border,
|
||||||
|
},
|
||||||
|
guestWarningHeader: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
marginBottom: spacing.md,
|
||||||
|
},
|
||||||
|
guestWarningIcon: {
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
borderRadius: 20,
|
||||||
|
backgroundColor: colors.primaryLight,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
guestWarningClose: {
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
borderRadius: 20,
|
||||||
|
backgroundColor: colors.glass.background,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
guestWarningTitle: {
|
||||||
|
...typography.headline,
|
||||||
|
color: colors.text,
|
||||||
|
marginBottom: spacing.sm,
|
||||||
|
},
|
||||||
|
guestWarningBody: {
|
||||||
|
...typography.body,
|
||||||
|
color: colors.textSecondary,
|
||||||
|
lineHeight: 22,
|
||||||
|
marginBottom: spacing.lg,
|
||||||
|
},
|
||||||
|
guestWarningActions: {
|
||||||
|
gap: spacing.sm,
|
||||||
|
},
|
||||||
|
guestWarningPrimary: {
|
||||||
|
backgroundColor: colors.primary,
|
||||||
|
paddingVertical: 14,
|
||||||
|
borderRadius: radius.md,
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
guestWarningPrimaryText: {
|
||||||
|
...typography.subhead,
|
||||||
|
color: colors.text,
|
||||||
|
fontWeight: "600",
|
||||||
|
},
|
||||||
|
guestWarningSecondary: {
|
||||||
|
backgroundColor: colors.glass.background,
|
||||||
|
paddingVertical: 14,
|
||||||
|
borderRadius: radius.md,
|
||||||
|
alignItems: "center",
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.glass.border,
|
||||||
|
},
|
||||||
|
guestWarningSecondaryText: {
|
||||||
|
...typography.subhead,
|
||||||
|
color: colors.textSecondary,
|
||||||
|
fontWeight: "600",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
@@ -15,6 +15,7 @@ describe('authStore', () => {
|
|||||||
partner: null,
|
partner: null,
|
||||||
isLoading: true,
|
isLoading: true,
|
||||||
isAuthenticated: false,
|
isAuthenticated: false,
|
||||||
|
isAnonymous: false,
|
||||||
} as any);
|
} as any);
|
||||||
|
|
||||||
useMatchStore.setState({ matches: [{ id: 'm1' }], newMatchesCount: 1 } as any);
|
useMatchStore.setState({ matches: [{ id: 'm1' }], newMatchesCount: 1 } as any);
|
||||||
@@ -31,9 +32,35 @@ describe('authStore', () => {
|
|||||||
const state = useAuthStore.getState();
|
const state = useAuthStore.getState();
|
||||||
expect(state.user).toBeNull();
|
expect(state.user).toBeNull();
|
||||||
expect(state.isAuthenticated).toBe(false);
|
expect(state.isAuthenticated).toBe(false);
|
||||||
|
expect(state.isAnonymous).toBe(false);
|
||||||
expect(state.isLoading).toBe(false);
|
expect(state.isLoading).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('sets isAnonymous from auth user', async () => {
|
||||||
|
(supabase.auth.getSession as jest.Mock).mockResolvedValueOnce({
|
||||||
|
data: { session: { user: { id: 'me' } } },
|
||||||
|
});
|
||||||
|
|
||||||
|
(supabase.auth.getUser as jest.Mock).mockResolvedValueOnce({
|
||||||
|
data: { user: { id: 'me', is_anonymous: true } },
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
const profileQuery: any = {
|
||||||
|
select: jest.fn(() => profileQuery),
|
||||||
|
eq: jest.fn(() => profileQuery),
|
||||||
|
maybeSingle: jest.fn(async () => ({ data: { id: 'me', couple_id: null } })),
|
||||||
|
};
|
||||||
|
|
||||||
|
(supabase.from as jest.Mock).mockReturnValue(profileQuery);
|
||||||
|
|
||||||
|
await useAuthStore.getState().fetchUser();
|
||||||
|
|
||||||
|
const state = useAuthStore.getState();
|
||||||
|
expect(state.isAuthenticated).toBe(true);
|
||||||
|
expect(state.isAnonymous).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it('clears stores and signs out when session invalid', async () => {
|
it('clears stores and signs out when session invalid', async () => {
|
||||||
(supabase.auth.getSession as jest.Mock).mockResolvedValueOnce({
|
(supabase.auth.getSession as jest.Mock).mockResolvedValueOnce({
|
||||||
data: { session: { user: { id: 'me' } } },
|
data: { session: { user: { id: 'me' } } },
|
||||||
@@ -50,51 +77,9 @@ describe('authStore', () => {
|
|||||||
|
|
||||||
expect(useAuthStore.getState().user).toBeNull();
|
expect(useAuthStore.getState().user).toBeNull();
|
||||||
expect(useAuthStore.getState().isAuthenticated).toBe(false);
|
expect(useAuthStore.getState().isAuthenticated).toBe(false);
|
||||||
|
expect(useAuthStore.getState().isAnonymous).toBe(false);
|
||||||
|
|
||||||
expect(useMatchStore.getState().matches).toEqual([]);
|
expect(useAuthStore.getState().isAnonymous).toBe(false);
|
||||||
expect(usePacksStore.getState().enabledPackIds).toEqual([]);
|
|
||||||
expect(useMessageStore.getState().unreadCount).toBe(0);
|
|
||||||
expect(useMessageStore.getState().activeMatchId).toBeNull();
|
|
||||||
expect(useSubscriptionStore.getState().subscription.isProUser).toBe(false);
|
|
||||||
|
|
||||||
expect(supabase.auth.signOut).toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('fetches couple and partner when couple_id exists', async () => {
|
|
||||||
useAuthStore.setState({ user: { id: 'me', couple_id: 'c1' } } as any);
|
|
||||||
|
|
||||||
const couplesQuery: any = {
|
|
||||||
select: jest.fn(() => couplesQuery),
|
|
||||||
eq: jest.fn(() => couplesQuery),
|
|
||||||
maybeSingle: jest.fn(async () => ({ data: { id: 'c1' } })),
|
|
||||||
};
|
|
||||||
|
|
||||||
const partnerQuery: any = {
|
|
||||||
select: jest.fn(() => partnerQuery),
|
|
||||||
eq: jest.fn(() => partnerQuery),
|
|
||||||
neq: jest.fn(() => partnerQuery),
|
|
||||||
maybeSingle: jest.fn(async () => ({ data: { id: 'partner' } })),
|
|
||||||
};
|
|
||||||
|
|
||||||
(supabase.from as jest.Mock).mockImplementation((table: string) => {
|
|
||||||
if (table === 'couples') return couplesQuery;
|
|
||||||
if (table === 'profiles') return partnerQuery;
|
|
||||||
return couplesQuery;
|
|
||||||
});
|
|
||||||
|
|
||||||
await useAuthStore.getState().fetchCouple();
|
|
||||||
|
|
||||||
expect(useAuthStore.getState().couple).toEqual({ id: 'c1' });
|
|
||||||
expect(useAuthStore.getState().partner).toEqual({ id: 'partner' });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('signOut clears local state even if Supabase fails', async () => {
|
|
||||||
(supabase.auth.signOut as jest.Mock).mockRejectedValueOnce(new Error('network'));
|
|
||||||
|
|
||||||
await useAuthStore.getState().signOut();
|
|
||||||
|
|
||||||
expect(useAuthStore.getState().user).toBeNull();
|
|
||||||
expect(useAuthStore.getState().isAuthenticated).toBe(false);
|
|
||||||
expect(useAuthStore.getState().isLoading).toBe(false);
|
expect(useAuthStore.getState().isLoading).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import React from 'react';
|
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 { LinearGradient } from 'expo-linear-gradient';
|
||||||
import { BlurView } from 'expo-blur';
|
import { BlurView } from 'expo-blur';
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
@@ -10,6 +10,7 @@ import Animated, {
|
|||||||
FadeIn,
|
FadeIn,
|
||||||
} from 'react-native-reanimated';
|
} from 'react-native-reanimated';
|
||||||
import { ShimmerEffect } from './ui/ShimmerEffect';
|
import { ShimmerEffect } from './ui/ShimmerEffect';
|
||||||
|
import { GlassToggle } from './ui/GlassToggle';
|
||||||
import { colors, gradients, spacing, radius, typography, blur, shadows, animations } from '../theme';
|
import { colors, gradients, spacing, radius, typography, blur, shadows, animations } from '../theme';
|
||||||
import type { QuestionPack } from '../types';
|
import type { QuestionPack } from '../types';
|
||||||
|
|
||||||
@@ -150,16 +151,10 @@ export function BoutiquePackCard({
|
|||||||
<Text style={styles.questionLabel}>questions</Text>
|
<Text style={styles.questionLabel}>questions</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<Switch
|
<GlassToggle
|
||||||
value={isEnabled}
|
value={isEnabled}
|
||||||
onValueChange={onToggle}
|
onValueChange={onToggle}
|
||||||
disabled={isPremiumLocked}
|
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}
|
style={isPremiumLocked ? { opacity: 0.4 } : undefined}
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -177,7 +177,7 @@ export const MatchNotificationModal: React.FC<Props> = ({
|
|||||||
]).start(() => onChat());
|
]).start(() => onChat());
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!visible || !match) return null;
|
if (!visible || !match || !match.question) return null;
|
||||||
|
|
||||||
// Determine responses based on match_type if not explicitly provided
|
// Determine responses based on match_type if not explicitly provided
|
||||||
const getUserResponse = (): AnswerType => {
|
const getUserResponse = (): AnswerType => {
|
||||||
@@ -266,13 +266,15 @@ export const MatchNotificationModal: React.FC<Props> = ({
|
|||||||
{/* Question text and responses */}
|
{/* Question text and responses */}
|
||||||
{match.question.partner_text ? (
|
{match.question.partner_text ? (
|
||||||
// Two-part question - show what each person saw
|
// 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.twoPartContainer}>
|
||||||
<View style={styles.responseSection}>
|
<View style={styles.responseSection}>
|
||||||
<View style={styles.responseLabelRow}>
|
<View style={styles.responseLabelRow}>
|
||||||
<Text style={styles.responsePersonLabel}>You said</Text>
|
<Text style={styles.responsePersonLabel}>You said</Text>
|
||||||
<ResponseBadge response={userResponse} />
|
<ResponseBadge response={userResponse} />
|
||||||
</View>
|
</View>
|
||||||
<Text style={styles.questionTextSmall}>"{match.question.text}"</Text>
|
<Text style={styles.questionTextSmall}>"{match.question.partner_text}"</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View style={styles.matchConnector}>
|
<View style={styles.matchConnector}>
|
||||||
@@ -288,7 +290,7 @@ export const MatchNotificationModal: React.FC<Props> = ({
|
|||||||
<Text style={styles.responsePersonLabel}>{partnerDisplayName} said</Text>
|
<Text style={styles.responsePersonLabel}>{partnerDisplayName} said</Text>
|
||||||
<ResponseBadge response={partnerResponse} isPartner />
|
<ResponseBadge response={partnerResponse} isPartner />
|
||||||
</View>
|
</View>
|
||||||
<Text style={styles.questionTextSmall}>"{match.question.partner_text}"</Text>
|
<Text style={styles.questionTextSmall}>"{match.question.text}"</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ import {
|
|||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { Ionicons } from "@expo/vector-icons";
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
import { LinearGradient } from "expo-linear-gradient";
|
import { LinearGradient } from "expo-linear-gradient";
|
||||||
import { useSubscriptionStore } from "../../store";
|
import { router } from "expo-router";
|
||||||
|
import { useAuthStore, useSubscriptionStore } from "../../store";
|
||||||
import { colors, gradients, spacing, radius, typography } from "../../theme";
|
import { colors, gradients, spacing, radius, typography } from "../../theme";
|
||||||
import type { PurchasesPackage } from "../../lib/revenuecat";
|
import type { PurchasesPackage } from "../../lib/revenuecat";
|
||||||
import { Events } from "../../lib/analytics";
|
import { Events } from "../../lib/analytics";
|
||||||
@@ -43,6 +44,8 @@ const FEATURES = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export function Paywall({ visible, onClose, onSuccess }: PaywallProps) {
|
export function Paywall({ visible, onClose, onSuccess }: PaywallProps) {
|
||||||
|
const { isAnonymous } = useAuthStore();
|
||||||
|
|
||||||
const handleClose = (reason: "dismiss" | "success" | "restore" | "system") => {
|
const handleClose = (reason: "dismiss" | "success" | "restore" | "system") => {
|
||||||
Events.paywallClosed("paywall", reason);
|
Events.paywallClosed("paywall", reason);
|
||||||
onClose();
|
onClose();
|
||||||
@@ -60,6 +63,8 @@ export function Paywall({ visible, onClose, onSuccess }: PaywallProps) {
|
|||||||
const [selectedPackage, setSelectedPackage] = useState<PurchasesPackage | null>(
|
const [selectedPackage, setSelectedPackage] = useState<PurchasesPackage | null>(
|
||||||
null
|
null
|
||||||
);
|
);
|
||||||
|
const [showGuestConfirm, setShowGuestConfirm] = useState(false);
|
||||||
|
const [guestConfirmed, setGuestConfirmed] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (visible && !offerings) {
|
if (visible && !offerings) {
|
||||||
@@ -67,6 +72,8 @@ export function Paywall({ visible, onClose, onSuccess }: PaywallProps) {
|
|||||||
}
|
}
|
||||||
if (visible) {
|
if (visible) {
|
||||||
Events.paywallShown("paywall");
|
Events.paywallShown("paywall");
|
||||||
|
setGuestConfirmed(false);
|
||||||
|
setShowGuestConfirm(false);
|
||||||
}
|
}
|
||||||
}, [visible, offerings, fetchOfferings]);
|
}, [visible, offerings, fetchOfferings]);
|
||||||
|
|
||||||
@@ -80,7 +87,7 @@ export function Paywall({ visible, onClose, onSuccess }: PaywallProps) {
|
|||||||
}
|
}
|
||||||
}, [offerings]);
|
}, [offerings]);
|
||||||
|
|
||||||
const handlePurchase = async () => {
|
const doPurchase = async () => {
|
||||||
if (!selectedPackage) return;
|
if (!selectedPackage) return;
|
||||||
|
|
||||||
const packageType = selectedPackage.packageType || "unknown";
|
const packageType = selectedPackage.packageType || "unknown";
|
||||||
@@ -102,6 +109,17 @@ export function Paywall({ visible, onClose, onSuccess }: PaywallProps) {
|
|||||||
Events.purchaseFailed(packageType, result.errorCode, result.errorMessage);
|
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 handleRestore = async () => {
|
||||||
const restored = await restorePurchases();
|
const restored = await restorePurchases();
|
||||||
if (restored) {
|
if (restored) {
|
||||||
@@ -398,6 +416,59 @@ export function Paywall({ visible, onClose, onSuccess }: PaywallProps) {
|
|||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
|
||||||
|
{showGuestConfirm && (
|
||||||
|
<View style={styles.guestConfirmOverlay}>
|
||||||
|
<Pressable
|
||||||
|
style={StyleSheet.absoluteFill}
|
||||||
|
onPress={() => setShowGuestConfirm(false)}
|
||||||
|
/>
|
||||||
|
<View style={styles.guestConfirmCard}>
|
||||||
|
<View style={styles.guestConfirmHeader}>
|
||||||
|
<View style={styles.guestConfirmIcon}>
|
||||||
|
<Ionicons name="shield-checkmark" size={22} color={colors.premium.gold} />
|
||||||
|
</View>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => setShowGuestConfirm(false)}
|
||||||
|
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
||||||
|
style={styles.guestConfirmClose}
|
||||||
|
>
|
||||||
|
<Ionicons name="close" size={18} color={colors.textSecondary} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text style={styles.guestConfirmTitle}>Save your account first?</Text>
|
||||||
|
<Text style={styles.guestConfirmBody}>
|
||||||
|
Unsaved accounts can't be recovered if you delete the app or switch devices.
|
||||||
|
Saving your account protects your access to Pro.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<View style={styles.guestConfirmActions}>
|
||||||
|
<Pressable
|
||||||
|
style={styles.guestConfirmPrimary}
|
||||||
|
onPress={async () => {
|
||||||
|
setShowGuestConfirm(false);
|
||||||
|
handleClose("dismiss");
|
||||||
|
router.push("/(app)/settings/save-account" as any);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={styles.guestConfirmPrimaryText}>Save account</Text>
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
<Pressable
|
||||||
|
style={styles.guestConfirmSecondary}
|
||||||
|
onPress={async () => {
|
||||||
|
setGuestConfirmed(true);
|
||||||
|
setShowGuestConfirm(false);
|
||||||
|
await doPurchase();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={styles.guestConfirmSecondaryText}>Continue purchase</Text>
|
||||||
|
</Pressable>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</Modal>
|
</Modal>
|
||||||
@@ -760,6 +831,86 @@ const styles = StyleSheet.create({
|
|||||||
...typography.caption1,
|
...typography.caption1,
|
||||||
color: colors.textTertiary,
|
color: colors.textTertiary,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
guestConfirmOverlay: {
|
||||||
|
...StyleSheet.absoluteFillObject,
|
||||||
|
backgroundColor: "rgba(0, 0, 0, 0.7)",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
padding: spacing.lg,
|
||||||
|
},
|
||||||
|
guestConfirmCard: {
|
||||||
|
width: "100%",
|
||||||
|
maxWidth: 420,
|
||||||
|
backgroundColor: "rgba(22, 33, 62, 0.98)",
|
||||||
|
borderRadius: radius.xl,
|
||||||
|
padding: spacing.lg,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: "rgba(212, 175, 55, 0.2)",
|
||||||
|
},
|
||||||
|
guestConfirmHeader: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
marginBottom: spacing.md,
|
||||||
|
},
|
||||||
|
guestConfirmIcon: {
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
borderRadius: 20,
|
||||||
|
backgroundColor: "rgba(212, 175, 55, 0.12)",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
guestConfirmClose: {
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
borderRadius: 20,
|
||||||
|
backgroundColor: "rgba(255, 255, 255, 0.08)",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: "rgba(255, 255, 255, 0.1)",
|
||||||
|
},
|
||||||
|
guestConfirmTitle: {
|
||||||
|
...typography.title3,
|
||||||
|
color: colors.text,
|
||||||
|
marginBottom: spacing.sm,
|
||||||
|
},
|
||||||
|
guestConfirmBody: {
|
||||||
|
...typography.subhead,
|
||||||
|
color: colors.textSecondary,
|
||||||
|
lineHeight: 20,
|
||||||
|
marginBottom: spacing.lg,
|
||||||
|
textAlign: "left",
|
||||||
|
},
|
||||||
|
guestConfirmActions: {
|
||||||
|
gap: spacing.sm,
|
||||||
|
},
|
||||||
|
guestConfirmPrimary: {
|
||||||
|
backgroundColor: colors.premium.gold,
|
||||||
|
paddingVertical: spacing.md,
|
||||||
|
borderRadius: radius.lg,
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
guestConfirmPrimaryText: {
|
||||||
|
...typography.headline,
|
||||||
|
color: colors.background,
|
||||||
|
fontWeight: "700",
|
||||||
|
},
|
||||||
|
guestConfirmSecondary: {
|
||||||
|
backgroundColor: "rgba(255, 255, 255, 0.06)",
|
||||||
|
paddingVertical: spacing.md,
|
||||||
|
borderRadius: radius.lg,
|
||||||
|
alignItems: "center",
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: "rgba(255, 255, 255, 0.12)",
|
||||||
|
},
|
||||||
|
guestConfirmSecondaryText: {
|
||||||
|
...typography.subhead,
|
||||||
|
color: colors.text,
|
||||||
|
fontWeight: "600",
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export default Paywall;
|
export default Paywall;
|
||||||
@@ -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 type { LoadingOverlayProps, LoadingVariant } from './LoadingOverlay';
|
||||||
export { ReadReceipt, getReceiptStatus } from './ReadReceipt';
|
export { ReadReceipt, getReceiptStatus } from './ReadReceipt';
|
||||||
export type { ReadReceiptProps, ReceiptStatus } from './ReadReceipt';
|
export type { ReadReceiptProps, ReceiptStatus } from './ReadReceipt';
|
||||||
|
export { GlassToggle } from './GlassToggle';
|
||||||
export { AmbientOrbs } from './AmbientOrbs';
|
export { AmbientOrbs } from './AmbientOrbs';
|
||||||
export type { AmbientOrbsProps, OrbVariant, OrbPosition } from './AmbientOrbs';
|
export type { AmbientOrbsProps, OrbVariant, OrbPosition } from './AmbientOrbs';
|
||||||
@@ -29,7 +29,7 @@ export function ProfileScreen() {
|
|||||||
const { width } = useWindowDimensions();
|
const { width } = useWindowDimensions();
|
||||||
const isWideScreen = width > MAX_CONTENT_WIDTH;
|
const isWideScreen = width > MAX_CONTENT_WIDTH;
|
||||||
|
|
||||||
const { user, partner, fetchUser } = useAuthStore();
|
const { user, partner, fetchUser, isAnonymous } = useAuthStore();
|
||||||
const { subscription } = useSubscriptionStore();
|
const { subscription } = useSubscriptionStore();
|
||||||
const settings = useProfileSettings();
|
const settings = useProfileSettings();
|
||||||
|
|
||||||
@@ -116,7 +116,9 @@ export function ProfileScreen() {
|
|||||||
</LinearGradient>
|
</LinearGradient>
|
||||||
<View style={styles.profileInfo}>
|
<View style={styles.profileInfo}>
|
||||||
<Text style={styles.userName}>{user?.name || "User"}</Text>
|
<Text style={styles.userName}>{user?.name || "User"}</Text>
|
||||||
<Text style={styles.userEmail}>{user?.email}</Text>
|
<Text style={styles.userEmail}>
|
||||||
|
{isAnonymous ? "Unsaved account" : (user?.email || "")}
|
||||||
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<View style={styles.profileChevron}>
|
<View style={styles.profileChevron}>
|
||||||
<Ionicons name="chevron-forward" size={20} color={colors.textTertiary} />
|
<Ionicons name="chevron-forward" size={20} color={colors.textTertiary} />
|
||||||
@@ -172,6 +174,17 @@ export function ProfileScreen() {
|
|||||||
|
|
||||||
{/* Other Section */}
|
{/* Other Section */}
|
||||||
<SettingsSection title="Other" delay={350}>
|
<SettingsSection title="Other" delay={350}>
|
||||||
|
{isAnonymous && (
|
||||||
|
<>
|
||||||
|
<MenuItem
|
||||||
|
icon="shield-checkmark-outline"
|
||||||
|
label="Save Account"
|
||||||
|
description="Protect access across devices"
|
||||||
|
onPress={() => router.push('/(app)/settings/save-account' as any)}
|
||||||
|
/>
|
||||||
|
<View style={styles.divider} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<MenuItem
|
<MenuItem
|
||||||
icon="help-circle-outline"
|
icon="help-circle-outline"
|
||||||
label="Help & Support"
|
label="Help & Support"
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import React from 'react';
|
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 { LinearGradient } from 'expo-linear-gradient';
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
import { colors, spacing, radius, typography, featureColors } from '../../../theme';
|
import { colors, spacing, radius, typography, featureColors } from '../../../theme';
|
||||||
|
import { GlassToggle } from '../../../components/ui';
|
||||||
|
|
||||||
export interface SwitchItemProps {
|
export interface SwitchItemProps {
|
||||||
/** Icon name (Ionicons) */
|
/** Icon name (Ionicons) */
|
||||||
@@ -20,7 +21,6 @@ export interface SwitchItemProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const ACCENT_GRADIENT = featureColors.profile.gradient as [string, string];
|
const ACCENT_GRADIENT = featureColors.profile.gradient as [string, string];
|
||||||
const ACCENT_COLOR = featureColors.profile.accent;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Reusable toggle switch row for settings screens.
|
* Reusable toggle switch row for settings screens.
|
||||||
@@ -49,13 +49,10 @@ export function SwitchItem({
|
|||||||
<Text style={styles.description}>{description}</Text>
|
<Text style={styles.description}>{description}</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
<Switch
|
<GlassToggle
|
||||||
value={value}
|
value={value}
|
||||||
onValueChange={onValueChange}
|
onValueChange={onValueChange}
|
||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
trackColor={{ false: colors.glass.border, true: colors.secondaryLight }}
|
|
||||||
thumbColor={value ? ACCENT_COLOR : colors.textTertiary}
|
|
||||||
ios_backgroundColor={colors.glass.border}
|
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import { Alert, Platform, ActionSheetIOS, Linking } from 'react-native';
|
import { Alert, Linking, Platform } from 'react-native';
|
||||||
import * as ImagePicker from 'expo-image-picker';
|
|
||||||
import * as FileSystem from 'expo-file-system';
|
|
||||||
import { decode } from 'base64-arraybuffer';
|
|
||||||
import { useAuthStore, useSubscriptionStore, usePacksStore } from '../../../store';
|
import { useAuthStore, useSubscriptionStore, usePacksStore } from '../../../store';
|
||||||
import { supabase } from '../../../lib/supabase';
|
import { supabase } from '../../../lib/supabase';
|
||||||
import { Events } from '../../../lib/analytics';
|
import { Events } from '../../../lib/analytics';
|
||||||
|
import { useAvatarPicker } from '../../../hooks/useAvatarPicker';
|
||||||
import type { IntensityLevel } from '../../../types';
|
import type { IntensityLevel } from '../../../types';
|
||||||
import {
|
import {
|
||||||
registerForPushNotificationsAsync,
|
registerForPushNotificationsAsync,
|
||||||
@@ -45,8 +43,42 @@ export function useProfileSettings() {
|
|||||||
const [newName, setNewName] = useState(user?.name || "");
|
const [newName, setNewName] = useState(user?.name || "");
|
||||||
const [isUpdatingName, setIsUpdatingName] = useState(false);
|
const [isUpdatingName, setIsUpdatingName] = useState(false);
|
||||||
|
|
||||||
// Avatar
|
// Avatar - using shared hook with immediate upload on select
|
||||||
const [isUploadingAvatar, setIsUploadingAvatar] = useState(false);
|
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
|
// Preferences
|
||||||
const [maxIntensity, setMaxIntensity] = useState<IntensityLevel>(() => getProfileMaxIntensity(user));
|
const [maxIntensity, setMaxIntensity] = useState<IntensityLevel>(() => getProfileMaxIntensity(user));
|
||||||
@@ -136,140 +168,6 @@ export function useProfileSettings() {
|
|||||||
setIsEditingName(false);
|
setIsEditingName(false);
|
||||||
}, [user?.name]);
|
}, [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
|
// Preference Actions
|
||||||
const handleIntensityChange = async (value: IntensityLevel) => {
|
const handleIntensityChange = async (value: IntensityLevel) => {
|
||||||
if (!user?.id) return;
|
if (!user?.id) return;
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { View, ScrollView, StyleSheet, Platform } from 'react-native';
|
import { View, ScrollView, StyleSheet, Platform } from 'react-native';
|
||||||
|
import { useRouter } from 'expo-router';
|
||||||
|
|
||||||
import { GradientBackground } from '../../../components/ui';
|
import { GradientBackground } from '../../../components/ui';
|
||||||
import { colors, spacing } from '../../../theme';
|
import { colors, spacing } from '../../../theme';
|
||||||
@@ -11,7 +12,8 @@ import { ScreenHeader, SettingsSection, MenuItem, DangerZone } from '../componen
|
|||||||
* Account settings sub-screen (sign out, delete account).
|
* Account settings sub-screen (sign out, delete account).
|
||||||
*/
|
*/
|
||||||
export function AccountScreen() {
|
export function AccountScreen() {
|
||||||
const { couple } = useAuthStore();
|
const router = useRouter();
|
||||||
|
const { couple, isAnonymous } = useAuthStore();
|
||||||
const { handleSignOut, handleDeleteAccount } = useCoupleManagement();
|
const { handleSignOut, handleDeleteAccount } = useCoupleManagement();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -23,13 +25,23 @@ export function AccountScreen() {
|
|||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
>
|
>
|
||||||
<SettingsSection title="Account" delay={0}>
|
<SettingsSection title="Account" delay={0}>
|
||||||
<MenuItem
|
{isAnonymous ? (
|
||||||
icon="log-out-outline"
|
<MenuItem
|
||||||
label="Sign Out"
|
icon="shield-checkmark-outline"
|
||||||
onPress={handleSignOut}
|
label="Save Account"
|
||||||
variant="danger"
|
description="Protect access across devices"
|
||||||
showChevron={false}
|
onPress={() => router.push("/(app)/settings/save-account" as any)}
|
||||||
/>
|
showChevron={false}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<MenuItem
|
||||||
|
icon="log-out-outline"
|
||||||
|
label="Sign Out"
|
||||||
|
onPress={handleSignOut}
|
||||||
|
variant="danger"
|
||||||
|
showChevron={false}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
|
|
||||||
<View style={styles.spacer} />
|
<View style={styles.spacer} />
|
||||||
|
|||||||
@@ -0,0 +1,590 @@
|
|||||||
|
import React, { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
|
import {
|
||||||
|
View,
|
||||||
|
Text,
|
||||||
|
ScrollView,
|
||||||
|
StyleSheet,
|
||||||
|
Platform,
|
||||||
|
Alert,
|
||||||
|
TouchableOpacity,
|
||||||
|
} from "react-native";
|
||||||
|
import { useRouter } from "expo-router";
|
||||||
|
import * as Linking from "expo-linking";
|
||||||
|
import { Ionicons } from "@expo/vector-icons";
|
||||||
|
|
||||||
|
// Safely import platform auth modules
|
||||||
|
let AppleAuthentication: typeof import("expo-apple-authentication") | null = null;
|
||||||
|
let GoogleSignin: typeof import("@react-native-google-signin/google-signin").GoogleSignin | null = null;
|
||||||
|
|
||||||
|
if (Platform.OS === "ios") {
|
||||||
|
try {
|
||||||
|
AppleAuthentication = require("expo-apple-authentication");
|
||||||
|
} catch {
|
||||||
|
AppleAuthentication = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Platform.OS === "android") {
|
||||||
|
try {
|
||||||
|
const googleModule = require("@react-native-google-signin/google-signin");
|
||||||
|
GoogleSignin = googleModule.GoogleSignin;
|
||||||
|
if (GoogleSignin) {
|
||||||
|
GoogleSignin.configure({
|
||||||
|
webClientId: "764866133492-e78o2rh3rdjc4rfj2j6r2j77b065kggm.apps.googleusercontent.com",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
GoogleSignin = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
import { GradientBackground, GlassButton, GlassCard, GlassInput } from "../../../components/ui";
|
||||||
|
import { colors, spacing, radius, typography } from "../../../theme";
|
||||||
|
import { supabase } from "../../../lib/supabase";
|
||||||
|
import { getAuthError } from "../../../lib/errors";
|
||||||
|
import { useAuthStore } from "../../../store";
|
||||||
|
import { ScreenHeader } from "../components";
|
||||||
|
|
||||||
|
type SaveMode = "magic-link" | "password";
|
||||||
|
|
||||||
|
export function SaveAccountScreen() {
|
||||||
|
const router = useRouter();
|
||||||
|
const { isAnonymous, fetchUser } = useAuthStore();
|
||||||
|
|
||||||
|
const [mode, setMode] = useState<SaveMode>("magic-link");
|
||||||
|
const [email, setEmail] = useState("");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
|
||||||
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
|
const [isChecking, setIsChecking] = useState(false);
|
||||||
|
const [pendingEmail, setPendingEmail] = useState<string | null>(null);
|
||||||
|
const [pendingPassword, setPendingPassword] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const [isAppleAvailable, setIsAppleAvailable] = useState(false);
|
||||||
|
const [isGoogleAvailable, setIsGoogleAvailable] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (Platform.OS === "ios" && AppleAuthentication) {
|
||||||
|
AppleAuthentication.isAvailableAsync()
|
||||||
|
.then(setIsAppleAvailable)
|
||||||
|
.catch(() => setIsAppleAvailable(false));
|
||||||
|
}
|
||||||
|
if (Platform.OS === "android" && GoogleSignin) {
|
||||||
|
setIsGoogleAvailable(true);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const emailRedirectTo = useMemo(() => Linking.createURL("/(auth)/login"), []);
|
||||||
|
|
||||||
|
const showError = useCallback((message: string) => {
|
||||||
|
Alert.alert("Error", message);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const showSuccess = useCallback((message: string) => {
|
||||||
|
Alert.alert("Success", message);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const syncProfileEmail = useCallback(async (userId: string, nextEmail: string | null | undefined) => {
|
||||||
|
if (!nextEmail) return;
|
||||||
|
try {
|
||||||
|
await supabase
|
||||||
|
.from("profiles")
|
||||||
|
.update({ email: nextEmail })
|
||||||
|
.eq("id", userId);
|
||||||
|
} catch {
|
||||||
|
// Non-blocking (some envs may restrict this by RLS)
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSaveWithEmail = useCallback(async () => {
|
||||||
|
if (!email.trim()) {
|
||||||
|
showError("Please enter your email");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (mode === "password" && password.length < 8) {
|
||||||
|
showError("Password must be at least 8 characters");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSubmitting(true);
|
||||||
|
const { error } = await supabase.auth.updateUser(
|
||||||
|
{ email: email.trim() },
|
||||||
|
{ emailRedirectTo } as any
|
||||||
|
);
|
||||||
|
setIsSubmitting(false);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
const friendly = getAuthError(error);
|
||||||
|
if (__DEV__ && friendly === "Something went wrong. Please try again.") {
|
||||||
|
showError(`${friendly}\n\n${error.message}`);
|
||||||
|
} else {
|
||||||
|
showError(friendly);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setPendingEmail(email.trim());
|
||||||
|
setPendingPassword(mode === "password" ? password : null);
|
||||||
|
|
||||||
|
Alert.alert(
|
||||||
|
"Check your email",
|
||||||
|
"We sent you a link to confirm your email. Once you confirm, come back and tap “I’ve verified my email”."
|
||||||
|
);
|
||||||
|
}, [email, password, mode, emailRedirectTo, showError]);
|
||||||
|
|
||||||
|
const handleCheckVerification = useCallback(async () => {
|
||||||
|
setIsChecking(true);
|
||||||
|
const { data: { user }, error } = await supabase.auth.getUser();
|
||||||
|
setIsChecking(false);
|
||||||
|
|
||||||
|
if (error || !user) {
|
||||||
|
showError(getAuthError(error) || "Unable to check account status");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const isStillAnonymous = !!(user as any).is_anonymous;
|
||||||
|
if (isStillAnonymous) {
|
||||||
|
Alert.alert("Not yet verified", "Please click the link in your email to finish saving your account.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// If user chose password mode, set password after email is verified
|
||||||
|
if (pendingPassword) {
|
||||||
|
setIsSubmitting(true);
|
||||||
|
const { error: passwordError } = await supabase.auth.updateUser({ password: pendingPassword });
|
||||||
|
setIsSubmitting(false);
|
||||||
|
|
||||||
|
if (passwordError) {
|
||||||
|
showError(getAuthError(passwordError));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await syncProfileEmail(user.id, user.email);
|
||||||
|
await fetchUser();
|
||||||
|
|
||||||
|
showSuccess("Account saved. You can now recover your account on any device.");
|
||||||
|
router.navigate("/(app)/profile" as any);
|
||||||
|
}, [pendingPassword, fetchUser, showError, showSuccess, router, syncProfileEmail]);
|
||||||
|
|
||||||
|
const handleLinkApple = useCallback(async () => {
|
||||||
|
if (!AppleAuthentication) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const credential = await AppleAuthentication.signInAsync({
|
||||||
|
requestedScopes: [
|
||||||
|
AppleAuthentication.AppleAuthenticationScope.FULL_NAME,
|
||||||
|
AppleAuthentication.AppleAuthenticationScope.EMAIL,
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!credential.identityToken) {
|
||||||
|
showError("No identity token received from Apple");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSubmitting(true);
|
||||||
|
const { error } = await (supabase.auth.linkIdentity as any)({
|
||||||
|
provider: "apple",
|
||||||
|
token: credential.identityToken,
|
||||||
|
});
|
||||||
|
setIsSubmitting(false);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
showError(getAuthError(error));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apple only provides name/email on first auth grant
|
||||||
|
if (credential.fullName) {
|
||||||
|
const nameParts = [
|
||||||
|
credential.fullName.givenName,
|
||||||
|
credential.fullName.middleName,
|
||||||
|
credential.fullName.familyName,
|
||||||
|
].filter(Boolean);
|
||||||
|
const fullName = nameParts.join(" ");
|
||||||
|
|
||||||
|
if (fullName) {
|
||||||
|
await supabase.auth.updateUser({
|
||||||
|
data: {
|
||||||
|
full_name: fullName,
|
||||||
|
given_name: credential.fullName.givenName,
|
||||||
|
family_name: credential.fullName.familyName,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh user/profile
|
||||||
|
const { data: { user } } = await supabase.auth.getUser();
|
||||||
|
if (user) {
|
||||||
|
await syncProfileEmail(user.id, user.email);
|
||||||
|
}
|
||||||
|
await fetchUser();
|
||||||
|
|
||||||
|
showSuccess("Account saved with Apple.");
|
||||||
|
router.navigate("/(app)/profile" as any);
|
||||||
|
} catch (e: any) {
|
||||||
|
if (e?.code === "ERR_REQUEST_CANCELED") return;
|
||||||
|
showError("Apple sign in failed. Please try again.");
|
||||||
|
}
|
||||||
|
}, [fetchUser, router, showError, showSuccess, syncProfileEmail]);
|
||||||
|
|
||||||
|
const handleLinkGoogle = useCallback(async () => {
|
||||||
|
if (!GoogleSignin) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await GoogleSignin.hasPlayServices();
|
||||||
|
const response = await GoogleSignin.signIn();
|
||||||
|
|
||||||
|
if (response.type !== "success" || !response.data.idToken) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSubmitting(true);
|
||||||
|
const { error } = await (supabase.auth.linkIdentity as any)({
|
||||||
|
provider: "google",
|
||||||
|
token: response.data.idToken,
|
||||||
|
});
|
||||||
|
setIsSubmitting(false);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
showError(getAuthError(error));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data: { user } } = await supabase.auth.getUser();
|
||||||
|
if (user) {
|
||||||
|
await syncProfileEmail(user.id, user.email);
|
||||||
|
}
|
||||||
|
await fetchUser();
|
||||||
|
|
||||||
|
showSuccess("Account saved with Google.");
|
||||||
|
router.navigate("/(app)/profile" as any);
|
||||||
|
} catch {
|
||||||
|
showError("Google sign in failed. Please try again.");
|
||||||
|
}
|
||||||
|
}, [fetchUser, router, showError, showSuccess, syncProfileEmail]);
|
||||||
|
|
||||||
|
const showSocial = Platform.select({ ios: true, android: true, default: false });
|
||||||
|
|
||||||
|
if (!isAnonymous) {
|
||||||
|
return (
|
||||||
|
<GradientBackground>
|
||||||
|
<ScreenHeader title="Save Account" />
|
||||||
|
<View style={styles.centered}>
|
||||||
|
<GlassCard variant="elevated" style={styles.readyCard}>
|
||||||
|
<Ionicons name="checkmark-circle" size={36} color={colors.success} />
|
||||||
|
<Text style={styles.readyTitle}>Your account is already saved</Text>
|
||||||
|
<Text style={styles.readyBody}>
|
||||||
|
You're signed in with a recoverable account.
|
||||||
|
</Text>
|
||||||
|
<GlassButton onPress={() => router.navigate("/(app)/profile" as any)}>
|
||||||
|
Back to Settings
|
||||||
|
</GlassButton>
|
||||||
|
</GlassCard>
|
||||||
|
</View>
|
||||||
|
</GradientBackground>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pendingEmail) {
|
||||||
|
return (
|
||||||
|
<GradientBackground>
|
||||||
|
<ScreenHeader title="Save Account" />
|
||||||
|
<View style={styles.centered}>
|
||||||
|
<GlassCard variant="elevated" style={styles.verifyCard}>
|
||||||
|
<View style={styles.verifyIcon}>
|
||||||
|
<Ionicons name="mail-unread" size={36} color={colors.primary} />
|
||||||
|
</View>
|
||||||
|
<Text style={styles.verifyTitle}>Confirm your email</Text>
|
||||||
|
<Text style={styles.verifyBody}>
|
||||||
|
We sent a confirmation link to {pendingEmail}. Tap it, then come back here.
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<GlassButton
|
||||||
|
onPress={handleCheckVerification}
|
||||||
|
loading={isChecking || isSubmitting}
|
||||||
|
disabled={isChecking || isSubmitting}
|
||||||
|
fullWidth
|
||||||
|
>
|
||||||
|
I've verified my email
|
||||||
|
</GlassButton>
|
||||||
|
<GlassButton
|
||||||
|
variant="secondary"
|
||||||
|
onPress={handleSaveWithEmail}
|
||||||
|
loading={isSubmitting}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
fullWidth
|
||||||
|
style={{ marginTop: spacing.sm }}
|
||||||
|
>
|
||||||
|
Resend email
|
||||||
|
</GlassButton>
|
||||||
|
<GlassButton
|
||||||
|
variant="ghost"
|
||||||
|
onPress={() => {
|
||||||
|
setPendingEmail(null);
|
||||||
|
setPendingPassword(null);
|
||||||
|
}}
|
||||||
|
fullWidth
|
||||||
|
style={{ marginTop: spacing.sm }}
|
||||||
|
>
|
||||||
|
Use a different email
|
||||||
|
</GlassButton>
|
||||||
|
</GlassCard>
|
||||||
|
</View>
|
||||||
|
</GradientBackground>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<GradientBackground>
|
||||||
|
<ScreenHeader title="Save Account" />
|
||||||
|
<ScrollView
|
||||||
|
style={styles.container}
|
||||||
|
contentContainerStyle={styles.content}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
>
|
||||||
|
<GlassCard variant="elevated" style={styles.heroCard}>
|
||||||
|
<Text style={styles.heroTitle}>Protect your account</Text>
|
||||||
|
<Text style={styles.heroBody}>
|
||||||
|
Unsaved accounts can't be recovered if you delete the app or switch devices.
|
||||||
|
Save your account to keep your couple, matches, and purchases.
|
||||||
|
</Text>
|
||||||
|
</GlassCard>
|
||||||
|
|
||||||
|
{showSocial && (
|
||||||
|
<GlassCard style={styles.sectionCard}>
|
||||||
|
<Text style={styles.sectionTitle}>Quick save</Text>
|
||||||
|
|
||||||
|
{Platform.OS === "ios" && (
|
||||||
|
<View style={styles.socialRow}>
|
||||||
|
{isAppleAvailable && AppleAuthentication ? (
|
||||||
|
<AppleAuthentication.AppleAuthenticationButton
|
||||||
|
buttonType={AppleAuthentication.AppleAuthenticationButtonType.SIGN_IN}
|
||||||
|
buttonStyle={AppleAuthentication.AppleAuthenticationButtonStyle.WHITE}
|
||||||
|
cornerRadius={12}
|
||||||
|
style={styles.appleButton}
|
||||||
|
onPress={handleLinkApple}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<GlassButton
|
||||||
|
variant="secondary"
|
||||||
|
onPress={handleLinkApple}
|
||||||
|
icon={<Ionicons name="logo-apple" size={20} color={colors.text} />}
|
||||||
|
fullWidth
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
Save with Apple
|
||||||
|
</GlassButton>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{Platform.OS === "android" && isGoogleAvailable && (
|
||||||
|
<GlassButton
|
||||||
|
variant="secondary"
|
||||||
|
onPress={handleLinkGoogle}
|
||||||
|
icon={<Ionicons name="logo-google" size={20} color={colors.text} />}
|
||||||
|
fullWidth
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
Save with Google
|
||||||
|
</GlassButton>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Text style={styles.sectionHint}>
|
||||||
|
Use an account that isn't already linked to Sauci.
|
||||||
|
</Text>
|
||||||
|
</GlassCard>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<GlassCard style={styles.sectionCard}>
|
||||||
|
<Text style={styles.sectionTitle}>Save with email</Text>
|
||||||
|
|
||||||
|
<View style={styles.modeToggleContainer}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.modeToggle, mode === "magic-link" && styles.modeToggleActive]}
|
||||||
|
onPress={() => setMode("magic-link")}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<Text style={[styles.modeToggleText, mode === "magic-link" && styles.modeToggleTextActive]}>
|
||||||
|
Magic Link
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[styles.modeToggle, mode === "password" && styles.modeToggleActive]}
|
||||||
|
onPress={() => setMode("password")}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<Text style={[styles.modeToggleText, mode === "password" && styles.modeToggleTextActive]}>
|
||||||
|
Password
|
||||||
|
</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.form}>
|
||||||
|
<GlassInput
|
||||||
|
placeholder="Enter your email"
|
||||||
|
value={email}
|
||||||
|
onChangeText={setEmail}
|
||||||
|
keyboardType="email-address"
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect={false}
|
||||||
|
icon={<Ionicons name="mail-outline" size={20} color={colors.textTertiary} />}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{mode === "password" && (
|
||||||
|
<GlassInput
|
||||||
|
placeholder="Create a password"
|
||||||
|
value={password}
|
||||||
|
onChangeText={setPassword}
|
||||||
|
secureTextEntry
|
||||||
|
autoCapitalize="none"
|
||||||
|
icon={<Ionicons name="lock-closed-outline" size={20} color={colors.textTertiary} />}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<GlassButton
|
||||||
|
onPress={handleSaveWithEmail}
|
||||||
|
loading={isSubmitting}
|
||||||
|
disabled={isSubmitting}
|
||||||
|
fullWidth
|
||||||
|
>
|
||||||
|
Save with Email
|
||||||
|
</GlassButton>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text style={styles.sectionHint}>
|
||||||
|
We'll send a confirmation link to your email.
|
||||||
|
</Text>
|
||||||
|
</GlassCard>
|
||||||
|
|
||||||
|
<View style={{ height: spacing.xl }} />
|
||||||
|
</ScrollView>
|
||||||
|
</GradientBackground>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
paddingTop: spacing.lg,
|
||||||
|
paddingBottom: Platform.OS === "ios" ? 40 : 20,
|
||||||
|
gap: spacing.lg,
|
||||||
|
},
|
||||||
|
heroCard: {
|
||||||
|
marginHorizontal: spacing.lg,
|
||||||
|
},
|
||||||
|
heroTitle: {
|
||||||
|
...typography.title3,
|
||||||
|
color: colors.text,
|
||||||
|
marginBottom: spacing.sm,
|
||||||
|
},
|
||||||
|
heroBody: {
|
||||||
|
...typography.body,
|
||||||
|
color: colors.textSecondary,
|
||||||
|
lineHeight: 22,
|
||||||
|
},
|
||||||
|
sectionCard: {
|
||||||
|
marginHorizontal: spacing.lg,
|
||||||
|
},
|
||||||
|
sectionTitle: {
|
||||||
|
...typography.headline,
|
||||||
|
color: colors.text,
|
||||||
|
marginBottom: spacing.md,
|
||||||
|
},
|
||||||
|
sectionHint: {
|
||||||
|
...typography.caption1,
|
||||||
|
color: colors.textTertiary,
|
||||||
|
marginTop: spacing.md,
|
||||||
|
},
|
||||||
|
socialRow: {
|
||||||
|
marginBottom: spacing.sm,
|
||||||
|
},
|
||||||
|
appleButton: {
|
||||||
|
width: "100%",
|
||||||
|
height: 48,
|
||||||
|
},
|
||||||
|
modeToggleContainer: {
|
||||||
|
flexDirection: "row",
|
||||||
|
backgroundColor: colors.glass.background,
|
||||||
|
borderRadius: radius.md,
|
||||||
|
padding: 4,
|
||||||
|
marginBottom: spacing.md,
|
||||||
|
},
|
||||||
|
modeToggle: {
|
||||||
|
flex: 1,
|
||||||
|
paddingVertical: 12,
|
||||||
|
borderRadius: radius.sm,
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
modeToggleActive: {
|
||||||
|
backgroundColor: colors.primary,
|
||||||
|
},
|
||||||
|
modeToggleText: {
|
||||||
|
...typography.subhead,
|
||||||
|
color: colors.textTertiary,
|
||||||
|
fontWeight: "600",
|
||||||
|
},
|
||||||
|
modeToggleTextActive: {
|
||||||
|
color: colors.text,
|
||||||
|
},
|
||||||
|
form: {
|
||||||
|
gap: spacing.sm,
|
||||||
|
},
|
||||||
|
centered: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
padding: spacing.lg,
|
||||||
|
},
|
||||||
|
readyCard: {
|
||||||
|
width: "100%",
|
||||||
|
maxWidth: 420,
|
||||||
|
alignItems: "center",
|
||||||
|
gap: spacing.sm,
|
||||||
|
},
|
||||||
|
readyTitle: {
|
||||||
|
...typography.title3,
|
||||||
|
color: colors.text,
|
||||||
|
marginTop: spacing.sm,
|
||||||
|
},
|
||||||
|
readyBody: {
|
||||||
|
...typography.body,
|
||||||
|
color: colors.textSecondary,
|
||||||
|
textAlign: "center",
|
||||||
|
marginBottom: spacing.md,
|
||||||
|
},
|
||||||
|
verifyCard: {
|
||||||
|
width: "100%",
|
||||||
|
maxWidth: 420,
|
||||||
|
alignItems: "center",
|
||||||
|
padding: spacing.lg,
|
||||||
|
},
|
||||||
|
verifyIcon: {
|
||||||
|
width: 90,
|
||||||
|
height: 90,
|
||||||
|
borderRadius: 45,
|
||||||
|
backgroundColor: colors.primaryLight,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
marginBottom: spacing.md,
|
||||||
|
},
|
||||||
|
verifyTitle: {
|
||||||
|
...typography.title3,
|
||||||
|
color: colors.text,
|
||||||
|
marginBottom: spacing.sm,
|
||||||
|
},
|
||||||
|
verifyBody: {
|
||||||
|
...typography.body,
|
||||||
|
color: colors.textSecondary,
|
||||||
|
textAlign: "center",
|
||||||
|
lineHeight: 22,
|
||||||
|
marginBottom: spacing.lg,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default SaveAccountScreen;
|
||||||
@@ -6,3 +6,4 @@ export { SubscriptionScreen } from './SubscriptionScreen';
|
|||||||
export { PartnerScreen } from './PartnerScreen';
|
export { PartnerScreen } from './PartnerScreen';
|
||||||
export { SupportScreen } from './SupportScreen';
|
export { SupportScreen } from './SupportScreen';
|
||||||
export { AccountScreen } from './AccountScreen';
|
export { AccountScreen } from './AccountScreen';
|
||||||
|
export { SaveAccountScreen } from './SaveAccountScreen';
|
||||||
@@ -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: /invalid login credentials/i, message: "Incorrect email or password. Please try again." },
|
||||||
{ pattern: /email not confirmed/i, message: "Please verify your email before signing in." },
|
{ pattern: /email not confirmed/i, message: "Please verify your email before signing in." },
|
||||||
{ pattern: /user already registered/i, message: "An account with this email already exists." },
|
{ pattern: /user already registered/i, message: "An account with this email already exists." },
|
||||||
|
{ pattern: /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: /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 short/i, message: "Password must be at least 8 characters." },
|
||||||
{ pattern: /password.*too weak/i, message: "Please choose a stronger password." },
|
{ 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: /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: /user not found/i, message: "No account found with this email." },
|
||||||
{ pattern: /provider.*disabled/i, message: "This sign-in method is currently unavailable." },
|
{ pattern: /provider.*disabled/i, message: "This sign-in method is currently unavailable." },
|
||||||
|
{ pattern: /manual.*link/i, message: "Saving accounts is temporarily unavailable. Please try again later." },
|
||||||
|
{ pattern: /identity.*link/i, message: "Saving accounts is temporarily unavailable. Please try again later." },
|
||||||
];
|
];
|
||||||
|
|
||||||
const PAIRING_ERROR_MAPPINGS: ErrorMapping[] = [
|
const PAIRING_ERROR_MAPPINGS: ErrorMapping[] = [
|
||||||
|
|||||||
@@ -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
|
// Types
|
||||||
export interface NotificationData {
|
export interface NotificationData {
|
||||||
type: "match" | "message";
|
type: "match" | "message" | "match_digest";
|
||||||
match_id?: string;
|
match_id?: string;
|
||||||
message_id?: string;
|
message_id?: string;
|
||||||
|
count?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Request notification permissions and get Expo push token.
|
* Request notification permissions and get Expo push token.
|
||||||
* Returns null if permissions denied, on web, or if registration fails.
|
* Returns null if permissions denied, on web, or if registration fails.
|
||||||
@@ -224,7 +226,16 @@ export function handleNotificationResponse(
|
|||||||
): void {
|
): void {
|
||||||
const data = response.notification.request.content.data as NotificationData;
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -232,6 +243,7 @@ export function handleNotificationResponse(
|
|||||||
router.push(`/(app)/chat/${data.match_id}`);
|
router.push(`/(app)/chat/${data.match_id}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set up listener for notification taps (when app is running).
|
* Set up listener for notification taps (when app is running).
|
||||||
* Returns subscription that should be cleaned up on unmount.
|
* Returns subscription that should be cleaned up on unmount.
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ interface AuthState {
|
|||||||
partner: Profile | null;
|
partner: Profile | null;
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
isAuthenticated: boolean;
|
isAuthenticated: boolean;
|
||||||
|
isAnonymous: boolean;
|
||||||
|
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
fetchUser: () => Promise<void>;
|
fetchUser: () => Promise<void>;
|
||||||
@@ -38,6 +40,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
|||||||
partner: null,
|
partner: null,
|
||||||
isLoading: true,
|
isLoading: true,
|
||||||
isAuthenticated: false,
|
isAuthenticated: false,
|
||||||
|
isAnonymous: false,
|
||||||
|
|
||||||
fetchUser: async () => {
|
fetchUser: async () => {
|
||||||
try {
|
try {
|
||||||
@@ -45,7 +48,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
|||||||
const { data: { session } } = await supabase.auth.getSession();
|
const { data: { session } } = await supabase.auth.getSession();
|
||||||
|
|
||||||
if (!session?.user) {
|
if (!session?.user) {
|
||||||
set({ user: null, isAuthenticated: false, isLoading: false });
|
set({ user: null, isAuthenticated: false, isAnonymous: false, isLoading: false });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -57,7 +60,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
|||||||
console.log("[Auth] Session invalid, signing out:", authError?.message);
|
console.log("[Auth] Session invalid, signing out:", authError?.message);
|
||||||
|
|
||||||
// Session is invalid - clear everything
|
// Session is invalid - clear everything
|
||||||
set({ user: null, couple: null, partner: null, isAuthenticated: false, isLoading: false });
|
set({ user: null, couple: null, partner: null, isAuthenticated: false, isAnonymous: false, isLoading: false });
|
||||||
// Clear other stores
|
// Clear other stores
|
||||||
const { useMatchStore, usePacksStore, useMessageStore, useSubscriptionStore, useNotificationPreferencesStore, useStreakStore } = getOtherStores();
|
const { useMatchStore, usePacksStore, useMessageStore, useSubscriptionStore, useNotificationPreferencesStore, useStreakStore } = getOtherStores();
|
||||||
useMatchStore.getState().clearMatches();
|
useMatchStore.getState().clearMatches();
|
||||||
@@ -77,9 +80,12 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
|||||||
.eq("id", authUser.id)
|
.eq("id", authUser.id)
|
||||||
.maybeSingle();
|
.maybeSingle();
|
||||||
|
|
||||||
|
const isAnonymous = !!(authUser as any).is_anonymous;
|
||||||
|
|
||||||
set({
|
set({
|
||||||
user: profile,
|
user: profile,
|
||||||
isAuthenticated: true,
|
isAuthenticated: true,
|
||||||
|
isAnonymous,
|
||||||
});
|
});
|
||||||
|
|
||||||
// If user has a couple, fetch couple data; otherwise clear couple/partner
|
// If user has a couple, fetch couple data; otherwise clear couple/partner
|
||||||
@@ -142,13 +148,14 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
|||||||
Events.signOut();
|
Events.signOut();
|
||||||
|
|
||||||
// Clear local state FIRST to ensure UI updates even if Supabase call fails
|
// Clear local state FIRST to ensure UI updates even if Supabase call fails
|
||||||
set({
|
set({
|
||||||
user: null,
|
user: null,
|
||||||
couple: null,
|
couple: null,
|
||||||
partner: null,
|
partner: null,
|
||||||
isAuthenticated: false,
|
isAuthenticated: false,
|
||||||
isLoading: false,
|
isAnonymous: false,
|
||||||
});
|
isLoading: false,
|
||||||
|
});
|
||||||
// Clear other stores
|
// Clear other stores
|
||||||
const { useMatchStore, usePacksStore, useMessageStore, useSubscriptionStore, useNotificationPreferencesStore, useStreakStore } = getOtherStores();
|
const { useMatchStore, usePacksStore, useMessageStore, useSubscriptionStore, useNotificationPreferencesStore, useStreakStore } = getOtherStores();
|
||||||
useMatchStore.getState().clearMatches();
|
useMatchStore.getState().clearMatches();
|
||||||
@@ -174,6 +181,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
|||||||
set({
|
set({
|
||||||
user,
|
user,
|
||||||
isAuthenticated: !!user,
|
isAuthenticated: !!user,
|
||||||
|
...(user === null && { isAnonymous: false }),
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
// Clear couple/partner when user is null (signed out)
|
// Clear couple/partner when user is null (signed out)
|
||||||
...(user === null && { couple: null, partner: null })
|
...(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) {
|
if (!matchError) {
|
||||||
match = newMatch;
|
match = newMatch;
|
||||||
|
|
||||||
// Trigger push notification
|
// Match push notifications are queued via a Postgres trigger
|
||||||
try {
|
// (pending_match_notifications) to allow short digesting.
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -218,14 +218,9 @@ Deno.serve(async (req) => {
|
|||||||
if (!matchError && newMatch) {
|
if (!matchError && newMatch) {
|
||||||
result.new_match = newMatch;
|
result.new_match = newMatch;
|
||||||
|
|
||||||
// Trigger push notification for new match
|
// Match push notifications are queued via a Postgres trigger
|
||||||
try {
|
// (pending_match_notifications) to allow short digesting.
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 Image from 'next/image'
|
||||||
import { motion } from 'framer-motion'
|
import { motion } from 'framer-motion'
|
||||||
|
import { Play } from 'lucide-react'
|
||||||
|
|
||||||
|
|
||||||
export default function CTA() {
|
export default function CTA() {
|
||||||
return (
|
return (
|
||||||
@@ -51,8 +53,18 @@ export default function CTA() {
|
|||||||
</svg>
|
</svg>
|
||||||
Download for iOS
|
Download for iOS
|
||||||
</a>
|
</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>
|
</div>
|
||||||
|
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|||||||
@@ -3,7 +3,8 @@
|
|||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import Image from 'next/image'
|
import Image from 'next/image'
|
||||||
import { motion } from 'framer-motion'
|
import { motion } from 'framer-motion'
|
||||||
import { Sparkles } from 'lucide-react'
|
import { Sparkles, Play } from 'lucide-react'
|
||||||
|
|
||||||
|
|
||||||
export default function Hero() {
|
export default function Hero() {
|
||||||
const [hasScrolled, setHasScrolled] = useState(false)
|
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.
|
it's a match. Start conversations you've always wanted to have.
|
||||||
</p>
|
</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 */}
|
{/* Official App Store Badge */}
|
||||||
<a
|
<a
|
||||||
href="https://apps.apple.com/app/sauci"
|
href="https://apps.apple.com/app/sauci"
|
||||||
@@ -88,9 +90,25 @@ export default function Hero() {
|
|||||||
/>
|
/>
|
||||||
</svg>
|
</svg>
|
||||||
</a>
|
</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">
|
<a href="#how-it-works" className="btn-secondary flex items-center gap-2 w-full sm:w-auto justify-center">
|
||||||
See How It Works
|
See How It Works
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
|
|||||||
@@ -6,9 +6,15 @@ import Image from 'next/image'
|
|||||||
import { motion, AnimatePresence } from 'framer-motion'
|
import { motion, AnimatePresence } from 'framer-motion'
|
||||||
import { Menu, X } from 'lucide-react'
|
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() {
|
export default function Navbar() {
|
||||||
|
|
||||||
const [isScrolled, setIsScrolled] = useState(false)
|
const [isScrolled, setIsScrolled] = useState(false)
|
||||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false)
|
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false)
|
||||||
|
const [downloadUrl, setDownloadUrl] = useState(APP_STORE_URL)
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleScroll = () => {
|
const handleScroll = () => {
|
||||||
@@ -18,6 +24,14 @@ export default function Navbar() {
|
|||||||
return () => window.removeEventListener('scroll', handleScroll)
|
return () => window.removeEventListener('scroll', handleScroll)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const userAgent = navigator.userAgent || ''
|
||||||
|
if (/Android/i.test(userAgent)) {
|
||||||
|
setDownloadUrl(PLAY_STORE_URL)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<motion.nav
|
<motion.nav
|
||||||
initial={{ y: -100 }}
|
initial={{ y: -100 }}
|
||||||
@@ -53,13 +67,14 @@ export default function Navbar() {
|
|||||||
FAQ
|
FAQ
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
href="https://apps.apple.com/app/sauci"
|
href={downloadUrl}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="btn-primary text-sm py-3 px-6"
|
className="btn-primary text-sm py-3 px-6"
|
||||||
>
|
>
|
||||||
Download App
|
Download App
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Mobile Menu Button */}
|
{/* Mobile Menu Button */}
|
||||||
@@ -111,13 +126,14 @@ export default function Navbar() {
|
|||||||
FAQ
|
FAQ
|
||||||
</a>
|
</a>
|
||||||
<a
|
<a
|
||||||
href="https://apps.apple.com/app/sauci"
|
href={downloadUrl}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="btn-primary text-sm py-3 px-6 text-center"
|
className="btn-primary text-sm py-3 px-6 text-center"
|
||||||
>
|
>
|
||||||
Download App
|
Download App
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,9 +1,14 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
import { motion } from 'framer-motion'
|
import { motion } from 'framer-motion'
|
||||||
import { Check, Crown, Heart, Sparkles, Infinity } from 'lucide-react'
|
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 = [
|
const features = [
|
||||||
|
|
||||||
'Unlock all question packs',
|
'Unlock all question packs',
|
||||||
'Access exclusive premium content',
|
'Access exclusive premium content',
|
||||||
'New packs added regularly',
|
'New packs added regularly',
|
||||||
@@ -12,7 +17,17 @@ const features = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
export default function Pricing() {
|
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 (
|
return (
|
||||||
|
|
||||||
<section id="pricing" className="relative py-24 overflow-hidden">
|
<section id="pricing" className="relative py-24 overflow-hidden">
|
||||||
{/* Background */}
|
{/* Background */}
|
||||||
<div className="absolute inset-0 bg-gradient-to-b from-background-light to-background" />
|
<div className="absolute inset-0 bg-gradient-to-b from-background-light to-background" />
|
||||||
@@ -114,7 +129,7 @@ export default function Pricing() {
|
|||||||
{/* CTA */}
|
{/* CTA */}
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<a
|
<a
|
||||||
href="https://apps.apple.com/app/sauci"
|
href={downloadUrl}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="btn-primary inline-flex items-center gap-2"
|
className="btn-primary inline-flex items-center gap-2"
|
||||||
@@ -122,6 +137,7 @@ export default function Pricing() {
|
|||||||
<Crown className="w-5 h-5" />
|
<Crown className="w-5 h-5" />
|
||||||
Get Sauci Pro
|
Get Sauci Pro
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<p className="text-sm text-white/40 mt-3">
|
<p className="text-sm text-white/40 mt-3">
|
||||||
Upgrade anytime from within the app
|
Upgrade anytime from within the app
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
Reference in new issue
Block a user