mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-11-02 18:19:44 +00:00
Compare commits
18
Commits
v1.0.1
..
d2db86ec7f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d2db86ec7f | ||
|
|
2ad85f6009 | ||
|
|
19a6bb7cda | ||
|
|
e28dcc1592 | ||
|
|
3e70669a27 | ||
|
|
b070a6f6a2 | ||
|
|
349f460e15 | ||
|
|
ce68d57a49 | ||
|
|
b8c271cce1 | ||
|
|
a0049b7f8a | ||
|
|
494ed17fe2 | ||
|
|
12f312421b | ||
|
|
a558f5e67e | ||
|
|
0be833bba2 | ||
|
|
1911f8f058 | ||
|
|
23e24aa4c2 | ||
|
|
e671ae9d1c | ||
|
|
a7a13c9614 |
No files matched your search
@@ -25,7 +25,7 @@
|
|||||||
"@radix-ui/react-switch": "^1.0.3",
|
"@radix-ui/react-switch": "^1.0.3",
|
||||||
"@radix-ui/react-tabs": "^1.0.4",
|
"@radix-ui/react-tabs": "^1.0.4",
|
||||||
"@radix-ui/react-tooltip": "^1.0.7",
|
"@radix-ui/react-tooltip": "^1.0.7",
|
||||||
"@sauci/shared": "workspace:*",
|
"@sauci/shared": "file:../../packages/shared",
|
||||||
"@supabase/supabase-js": "^2.39.0",
|
"@supabase/supabase-js": "^2.39.0",
|
||||||
"class-variance-authority": "^0.7.0",
|
"class-variance-authority": "^0.7.0",
|
||||||
"clsx": "^2.1.0",
|
"clsx": "^2.1.0",
|
||||||
|
|||||||
@@ -45,6 +45,8 @@ export interface PackFormData {
|
|||||||
is_public: boolean;
|
is_public: boolean;
|
||||||
is_explicit: boolean;
|
is_explicit: boolean;
|
||||||
category_id: string;
|
category_id: string;
|
||||||
|
scheduled_release_at?: string | null; // Optional for backwards compatibility
|
||||||
|
skip_notification?: boolean; // If true, sets release_notified=true to skip notifications
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PackFormDialogProps {
|
export interface PackFormDialogProps {
|
||||||
@@ -200,10 +202,63 @@ export function PackFormDialog({
|
|||||||
</div>
|
</div>
|
||||||
<Switch
|
<Switch
|
||||||
checked={formData.is_public}
|
checked={formData.is_public}
|
||||||
onCheckedChange={(checked) => setField('is_public', checked)}
|
onCheckedChange={(checked) => {
|
||||||
|
setField('is_public', checked);
|
||||||
|
// Clear scheduled release if making public immediately
|
||||||
|
if (checked) {
|
||||||
|
setField('scheduled_release_at', null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Scheduled Release - only show when not public */}
|
||||||
|
{!formData.is_public && (
|
||||||
|
<div className="space-y-4 p-3 rounded-md bg-muted/50 border border-dashed">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="scheduled_release_at">Scheduled Release</Label>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Automatically make this pack public at a specific time. Users will be notified when it becomes available.
|
||||||
|
</p>
|
||||||
|
<Input
|
||||||
|
id="scheduled_release_at"
|
||||||
|
type="datetime-local"
|
||||||
|
value={formData.scheduled_release_at ? new Date(formData.scheduled_release_at).toISOString().slice(0, 16) : ''}
|
||||||
|
onChange={(e) => {
|
||||||
|
const value = e.target.value;
|
||||||
|
setField('scheduled_release_at', value ? new Date(value).toISOString() : null);
|
||||||
|
}}
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
{formData.scheduled_release_at && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setField('scheduled_release_at', null)}
|
||||||
|
className="mt-1"
|
||||||
|
>
|
||||||
|
Clear scheduled release
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Skip Notification Toggle */}
|
||||||
|
<div className="flex items-center justify-between pt-2 border-t">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<Label>Skip release notification</Label>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Release without notifying users
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={formData.skip_notification ?? false}
|
||||||
|
onCheckedChange={(checked) => setField('skip_notification', checked)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Explicit Toggle */}
|
{/* Explicit Toggle */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ export const PERMISSION_KEYS = {
|
|||||||
VIEW_RESPONSES: 'view_responses',
|
VIEW_RESPONSES: 'view_responses',
|
||||||
VIEW_ACTIVITY: 'view_activity',
|
VIEW_ACTIVITY: 'view_activity',
|
||||||
MANAGE_FEEDBACK: 'manage_feedback',
|
MANAGE_FEEDBACK: 'manage_feedback',
|
||||||
|
GIFT_PREMIUM: 'gift_premium',
|
||||||
// System permissions
|
// System permissions
|
||||||
MANAGE_CODES: 'manage_codes',
|
MANAGE_CODES: 'manage_codes',
|
||||||
MANAGE_ADMINS: 'manage_admins',
|
MANAGE_ADMINS: 'manage_admins',
|
||||||
@@ -42,6 +43,7 @@ export const PERMISSION_METADATA: Record<PermissionKey, { label: string; descrip
|
|||||||
[PERMISSION_KEYS.VIEW_RESPONSES]: { label: 'Can view responses', description: 'View user question responses', group: 'users' },
|
[PERMISSION_KEYS.VIEW_RESPONSES]: { label: 'Can view responses', description: 'View user question responses', group: 'users' },
|
||||||
[PERMISSION_KEYS.VIEW_ACTIVITY]: { label: 'Can view activity', description: 'View user activity feed (responses, matches, messages, signups)', group: 'users' },
|
[PERMISSION_KEYS.VIEW_ACTIVITY]: { label: 'Can view activity', description: 'View user activity feed (responses, matches, messages, signups)', group: 'users' },
|
||||||
[PERMISSION_KEYS.MANAGE_FEEDBACK]: { label: 'Can manage feedback', description: 'View and respond to user feedback', group: 'users' },
|
[PERMISSION_KEYS.MANAGE_FEEDBACK]: { label: 'Can manage feedback', description: 'View and respond to user feedback', group: 'users' },
|
||||||
|
[PERMISSION_KEYS.GIFT_PREMIUM]: { label: 'Can gift premium', description: 'Manually upgrade users to premium status', group: 'users' },
|
||||||
[PERMISSION_KEYS.MANAGE_CODES]: { label: 'Can manage redemption codes', description: 'Create and view redemption codes', group: 'system' },
|
[PERMISSION_KEYS.MANAGE_CODES]: { label: 'Can manage redemption codes', description: 'Create and view redemption codes', group: 'system' },
|
||||||
[PERMISSION_KEYS.MANAGE_ADMINS]: { label: 'Can manage admins', description: 'Add/remove admin users and change permissions', group: 'system' },
|
[PERMISSION_KEYS.MANAGE_ADMINS]: { label: 'Can manage admins', description: 'Add/remove admin users and change permissions', group: 'system' },
|
||||||
[PERMISSION_KEYS.MANAGE_AI_CONFIG]: { label: 'Can manage AI settings', description: 'Configure AI models, API keys, and council mode', group: 'system' },
|
[PERMISSION_KEYS.MANAGE_AI_CONFIG]: { label: 'Can manage AI settings', description: 'Configure AI models, API keys, and council mode', group: 'system' },
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ export interface AppConfig {
|
|||||||
id: string;
|
id: string;
|
||||||
answer_gap_threshold: number;
|
answer_gap_threshold: number;
|
||||||
daily_response_limit: number;
|
daily_response_limit: number;
|
||||||
|
couple_intensity_gate_enabled: boolean | null;
|
||||||
updated_at: string | null;
|
updated_at: string | null;
|
||||||
updated_by: string | null;
|
updated_by: string | null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { useAuth, PERMISSION_KEYS } from '@/contexts/AuthContext';
|
|||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Label } from '@/components/ui/label';
|
import { Label } from '@/components/ui/label';
|
||||||
|
import { Switch } from '@/components/ui/switch';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||||
@@ -29,6 +30,7 @@ export function AppSettingsPage() {
|
|||||||
setFormData({
|
setFormData({
|
||||||
answer_gap_threshold: config.answer_gap_threshold ?? 10,
|
answer_gap_threshold: config.answer_gap_threshold ?? 10,
|
||||||
daily_response_limit: config.daily_response_limit ?? 0,
|
daily_response_limit: config.daily_response_limit ?? 0,
|
||||||
|
couple_intensity_gate_enabled: config.couple_intensity_gate_enabled ?? false,
|
||||||
});
|
});
|
||||||
setHasChanges(false);
|
setHasChanges(false);
|
||||||
}
|
}
|
||||||
@@ -61,6 +63,7 @@ export function AppSettingsPage() {
|
|||||||
setFormData({
|
setFormData({
|
||||||
answer_gap_threshold: config.answer_gap_threshold ?? 10,
|
answer_gap_threshold: config.answer_gap_threshold ?? 10,
|
||||||
daily_response_limit: config.daily_response_limit ?? 0,
|
daily_response_limit: config.daily_response_limit ?? 0,
|
||||||
|
couple_intensity_gate_enabled: config.couple_intensity_gate_enabled ?? false,
|
||||||
});
|
});
|
||||||
setHasChanges(false);
|
setHasChanges(false);
|
||||||
}
|
}
|
||||||
@@ -157,6 +160,19 @@ export function AppSettingsPage() {
|
|||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<Label>Couple Comfort Zone Gate</Label>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
When enabled, both recommended questions and answer-gap blocking use the lower of the two partners' comfort zones.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={Boolean(formData.couple_intensity_gate_enabled)}
|
||||||
|
onCheckedChange={(checked) => handleChange('couple_intensity_gate_enabled', checked)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="threshold">Answer Gap Threshold</Label>
|
<Label htmlFor="threshold">Answer Gap Threshold</Label>
|
||||||
<Input
|
<Input
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ interface ResponseActivity {
|
|||||||
id: string;
|
id: string;
|
||||||
text: string;
|
text: string;
|
||||||
pack: {
|
pack: {
|
||||||
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
} | null;
|
} | null;
|
||||||
} | null;
|
} | null;
|
||||||
@@ -155,7 +156,7 @@ export function UserActivityPage() {
|
|||||||
profile:profiles!responses_user_id_fkey(id, name, email),
|
profile:profiles!responses_user_id_fkey(id, name, email),
|
||||||
question:questions(
|
question:questions(
|
||||||
id, text,
|
id, text,
|
||||||
pack:question_packs(name)
|
pack:question_packs(id, name)
|
||||||
)
|
)
|
||||||
`, { count: 'exact' })
|
`, { count: 'exact' })
|
||||||
.order('created_at', { ascending: false })
|
.order('created_at', { ascending: false })
|
||||||
@@ -317,18 +318,22 @@ export function UserActivityPage() {
|
|||||||
const fetchSignups = useCallback(async () => {
|
const fetchSignups = useCallback(async () => {
|
||||||
setLoading(prev => ({ ...prev, signups: true }));
|
setLoading(prev => ({ ...prev, signups: true }));
|
||||||
try {
|
try {
|
||||||
const from = (signupsPage - 1) * signupsPageSize;
|
// Use RPC to get profiles with auth info (includes email from auth.users)
|
||||||
const to = from + signupsPageSize - 1;
|
const { data: allData, error } = await supabase
|
||||||
|
.rpc('get_profiles_with_auth_info');
|
||||||
const { data, error, count } = await supabase
|
|
||||||
.from('profiles')
|
|
||||||
.select('id, name, email, avatar_url, created_at, onboarding_completed, couple_id', { count: 'exact' })
|
|
||||||
.order('created_at', { ascending: false })
|
|
||||||
.range(from, to);
|
|
||||||
|
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
setSignups(data || []);
|
|
||||||
setSignupsTotal(count || 0);
|
// Sort by created_at descending and apply pagination
|
||||||
|
const sorted = (allData || []).sort((a: SignupActivity, b: SignupActivity) =>
|
||||||
|
new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
|
||||||
|
);
|
||||||
|
|
||||||
|
const from = (signupsPage - 1) * signupsPageSize;
|
||||||
|
const to = from + signupsPageSize;
|
||||||
|
|
||||||
|
setSignups(sorted.slice(from, to));
|
||||||
|
setSignupsTotal(sorted.length);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load signups:', error);
|
console.error('Failed to load signups:', error);
|
||||||
toast.error("Failed to load signups");
|
toast.error("Failed to load signups");
|
||||||
@@ -502,14 +507,31 @@ export function UserActivityPage() {
|
|||||||
<TableRow key={item.id}>
|
<TableRow key={item.id}>
|
||||||
<TableCell>{renderUserLink(item.profile)}</TableCell>
|
<TableCell>{renderUserLink(item.profile)}</TableCell>
|
||||||
<TableCell className="max-w-[300px]">
|
<TableCell className="max-w-[300px]">
|
||||||
<span className="truncate block">
|
{item.question?.pack?.id ? (
|
||||||
{item.question?.text || 'Unknown question'}
|
<Link
|
||||||
</span>
|
to={`/packs/${item.question.pack.id}/questions`}
|
||||||
|
className="truncate block text-primary hover:underline"
|
||||||
|
>
|
||||||
|
{item.question?.text || 'Unknown question'}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<span className="truncate block">
|
||||||
|
{item.question?.text || 'Unknown question'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Badge variant="outline">
|
{item.question?.pack?.id ? (
|
||||||
{item.question?.pack?.name || 'Unknown'}
|
<Link to={`/packs/${item.question.pack.id}/questions`}>
|
||||||
</Badge>
|
<Badge variant="outline" className="hover:bg-primary/10 cursor-pointer">
|
||||||
|
{item.question.pack.name}
|
||||||
|
</Badge>
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<Badge variant="outline">
|
||||||
|
{item.question?.pack?.name || 'Unknown'}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>{renderAnswerBadge(item.answer)}</TableCell>
|
<TableCell>{renderAnswerBadge(item.answer)}</TableCell>
|
||||||
<TableCell className="text-muted-foreground text-sm whitespace-nowrap">
|
<TableCell className="text-muted-foreground text-sm whitespace-nowrap">
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
|
|||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Skeleton } from '@/components/ui/skeleton';
|
import { Skeleton } from '@/components/ui/skeleton';
|
||||||
import { PaginationControls } from '@/components/ui/pagination';
|
import { PaginationControls } from '@/components/ui/pagination';
|
||||||
import { Plus, MessageCircle, Pencil, Trash2, Sparkles, Crown, Eye, EyeOff, Flame, Heart, Tags, ChevronUp, ChevronDown } from 'lucide-react';
|
import { Plus, MessageCircle, Pencil, Trash2, Sparkles, Crown, Eye, EyeOff, Flame, Heart, Tags, ChevronUp, ChevronDown, Clock, BellOff } from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { AiGeneratorDialog } from '@/components/ai/AiGeneratorDialog';
|
import { AiGeneratorDialog } from '@/components/ai/AiGeneratorDialog';
|
||||||
import { ExtractTopicsDialog } from '@/components/content/ExtractTopicsDialog';
|
import { ExtractTopicsDialog } from '@/components/content/ExtractTopicsDialog';
|
||||||
@@ -41,6 +41,8 @@ interface QuestionPack {
|
|||||||
sort_order: number | null;
|
sort_order: number | null;
|
||||||
category_id: string | null;
|
category_id: string | null;
|
||||||
created_at: string | null;
|
created_at: string | null;
|
||||||
|
scheduled_release_at?: string | null; // Optional for backwards compatibility
|
||||||
|
release_notified?: boolean; // True if notifications have been sent or should be skipped
|
||||||
question_count?: number;
|
question_count?: number;
|
||||||
topics?: Topic[];
|
topics?: Topic[];
|
||||||
}
|
}
|
||||||
@@ -76,6 +78,8 @@ export function PacksPage() {
|
|||||||
is_public: false,
|
is_public: false,
|
||||||
is_explicit: false,
|
is_explicit: false,
|
||||||
category_id: categoryId ?? '',
|
category_id: categoryId ?? '',
|
||||||
|
scheduled_release_at: null,
|
||||||
|
skip_notification: false,
|
||||||
},
|
},
|
||||||
(pack) => ({
|
(pack) => ({
|
||||||
name: pack.name,
|
name: pack.name,
|
||||||
@@ -85,6 +89,8 @@ export function PacksPage() {
|
|||||||
is_public: pack.is_public,
|
is_public: pack.is_public,
|
||||||
is_explicit: pack.is_explicit,
|
is_explicit: pack.is_explicit,
|
||||||
category_id: pack.category_id || '',
|
category_id: pack.category_id || '',
|
||||||
|
scheduled_release_at: pack.scheduled_release_at,
|
||||||
|
skip_notification: false, // Always start unchecked when editing
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -223,6 +229,13 @@ export function PacksPage() {
|
|||||||
let packId: string;
|
let packId: string;
|
||||||
|
|
||||||
if (form.editingItem) {
|
if (form.editingItem) {
|
||||||
|
// Determine release_notified value
|
||||||
|
// If skip_notification is checked, set release_notified=true to prevent notifications
|
||||||
|
// Otherwise, reset to false when scheduling a new release
|
||||||
|
const releaseNotifiedValue = form.formData.skip_notification
|
||||||
|
? true
|
||||||
|
: (form.formData.scheduled_release_at ? false : undefined);
|
||||||
|
|
||||||
const { error } = await auditedSupabase.update('question_packs', form.editingItem.id, {
|
const { error } = await auditedSupabase.update('question_packs', form.editingItem.id, {
|
||||||
name: form.formData.name,
|
name: form.formData.name,
|
||||||
description: form.formData.description || null,
|
description: form.formData.description || null,
|
||||||
@@ -231,6 +244,12 @@ export function PacksPage() {
|
|||||||
is_public: form.formData.is_public,
|
is_public: form.formData.is_public,
|
||||||
is_explicit: form.formData.is_explicit,
|
is_explicit: form.formData.is_explicit,
|
||||||
category_id: form.formData.category_id || null,
|
category_id: form.formData.category_id || null,
|
||||||
|
// Only include scheduled_release_at if the field exists in form data (backwards compatible)
|
||||||
|
...('scheduled_release_at' in form.formData && {
|
||||||
|
scheduled_release_at: form.formData.scheduled_release_at,
|
||||||
|
// Set release_notified based on skip_notification toggle
|
||||||
|
...(releaseNotifiedValue !== undefined && { release_notified: releaseNotifiedValue }),
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
packId = form.editingItem.id;
|
packId = form.editingItem.id;
|
||||||
@@ -246,6 +265,12 @@ export function PacksPage() {
|
|||||||
is_explicit: form.formData.is_explicit,
|
is_explicit: form.formData.is_explicit,
|
||||||
category_id: form.formData.category_id || null,
|
category_id: form.formData.category_id || null,
|
||||||
sort_order: totalCount,
|
sort_order: totalCount,
|
||||||
|
// Only include scheduled_release_at if the field exists in form data (backwards compatible)
|
||||||
|
...('scheduled_release_at' in form.formData && {
|
||||||
|
scheduled_release_at: form.formData.scheduled_release_at,
|
||||||
|
// If skip_notification is checked, set release_notified=true
|
||||||
|
...(form.formData.skip_notification && { release_notified: true }),
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
.select('id')
|
.select('id')
|
||||||
.single();
|
.single();
|
||||||
@@ -305,6 +330,8 @@ export function PacksPage() {
|
|||||||
is_public: false,
|
is_public: false,
|
||||||
is_explicit: false,
|
is_explicit: false,
|
||||||
category_id: categoryId ?? '',
|
category_id: categoryId ?? '',
|
||||||
|
scheduled_release_at: null,
|
||||||
|
skip_notification: false,
|
||||||
});
|
});
|
||||||
setAiDialogOpen(false);
|
setAiDialogOpen(false);
|
||||||
setSelectedTopicIds(new Set());
|
setSelectedTopicIds(new Set());
|
||||||
@@ -497,6 +524,18 @@ export function PacksPage() {
|
|||||||
<Eye className="h-3 w-3 mr-1" />
|
<Eye className="h-3 w-3 mr-1" />
|
||||||
Published
|
Published
|
||||||
</Badge>
|
</Badge>
|
||||||
|
) : pack.scheduled_release_at ? (
|
||||||
|
<>
|
||||||
|
<Badge variant="outline" className="border-blue-500 text-blue-400" title={`Releases: ${new Date(pack.scheduled_release_at).toLocaleString()}`}>
|
||||||
|
<Clock className="h-3 w-3 mr-1" />
|
||||||
|
Scheduled
|
||||||
|
</Badge>
|
||||||
|
{pack.release_notified && (
|
||||||
|
<Badge variant="outline" className="border-gray-500 text-gray-400" title="Notifications skipped">
|
||||||
|
<BellOff className="h-3 w-3" />
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<Badge variant="secondary">
|
<Badge variant="secondary">
|
||||||
<EyeOff className="h-3 w-3 mr-1" />
|
<EyeOff className="h-3 w-3 mr-1" />
|
||||||
|
|||||||
@@ -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}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import { useParams, Link } from 'react-router-dom';
|
import { useParams, Link } from 'react-router-dom';
|
||||||
import { supabase, supabaseConfig } from '@/config';
|
import { supabase, supabaseConfig } from '@/config';
|
||||||
import { auditedSupabase } from '@/hooks/useAuditedSupabase';
|
|
||||||
import { useRealtimeSubscription } from '@/hooks/useRealtimeSubscription';
|
import { useRealtimeSubscription } from '@/hooks/useRealtimeSubscription';
|
||||||
import { RealtimeStatusIndicator } from '@/components/RealtimeStatusIndicator';
|
import { RealtimeStatusIndicator } from '@/components/RealtimeStatusIndicator';
|
||||||
import { useAuth, PERMISSION_KEYS } from '@/contexts/AuthContext';
|
import { useAuth, PERMISSION_KEYS } from '@/contexts/AuthContext';
|
||||||
@@ -38,14 +37,17 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
import { Crown, Users, MessageCircle, ChevronRight, ThumbsUp, ThumbsDown, Minus, Gift, Image, Video as VideoIcon, Target, User, Eye, EyeOff, CheckCircle } from 'lucide-react';
|
import { Crown, Users, MessageCircle, ChevronRight, ThumbsUp, ThumbsDown, Minus, Gift, Image, Video as VideoIcon, Target, User, Eye, EyeOff, CheckCircle, Package, Heart, Sparkles, Flame, Trophy, Calendar } from 'lucide-react';
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
|
import { IconPreview } from '@/components/ui/icon-picker';
|
||||||
|
|
||||||
type UsageReason = 'improve_communication' | 'spice_up_intimacy' | 'deeper_connection' | 'have_fun' | 'strengthen_relationship';
|
type UsageReason = 'improve_communication' | 'spice_up_intimacy' | 'deeper_connection' | 'have_fun' | 'strengthen_relationship';
|
||||||
type Gender = 'male' | 'female' | 'non-binary' | 'prefer-not-to-say';
|
type Gender = 'male' | 'female' | 'non-binary' | 'prefer-not-to-say';
|
||||||
|
|
||||||
// ... (interfaces and constants are the same)
|
// ... (interfaces and constants are the same)
|
||||||
|
|
||||||
|
type IntensityLevel = 1 | 2 | 3 | 4 | 5;
|
||||||
|
|
||||||
interface Profile {
|
interface Profile {
|
||||||
id: string;
|
id: string;
|
||||||
name: string | null;
|
name: string | null;
|
||||||
@@ -58,6 +60,7 @@ interface Profile {
|
|||||||
usage_reason: UsageReason | null;
|
usage_reason: UsageReason | null;
|
||||||
show_explicit_content: boolean | null;
|
show_explicit_content: boolean | null;
|
||||||
onboarding_completed: boolean | null;
|
onboarding_completed: boolean | null;
|
||||||
|
max_intensity: IntensityLevel | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const usageReasonLabels: Record<UsageReason, { label: string; icon: string }> = {
|
const usageReasonLabels: Record<UsageReason, { label: string; icon: string }> = {
|
||||||
@@ -75,6 +78,14 @@ const genderLabels: Record<Gender, string> = {
|
|||||||
'prefer-not-to-say': 'Prefer not to say',
|
'prefer-not-to-say': 'Prefer not to say',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const intensityLabels: Record<IntensityLevel, { label: string; emoji: string }> = {
|
||||||
|
1: { label: 'Gentle', emoji: '💭' },
|
||||||
|
2: { label: 'Warm', emoji: '💕' },
|
||||||
|
3: { label: 'Playful', emoji: '😏' },
|
||||||
|
4: { label: 'Steamy', emoji: '🔥' },
|
||||||
|
5: { label: 'Intense', emoji: '🌶️' },
|
||||||
|
};
|
||||||
|
|
||||||
interface Partner {
|
interface Partner {
|
||||||
id: string;
|
id: string;
|
||||||
name: string | null;
|
name: string | null;
|
||||||
@@ -88,6 +99,7 @@ interface Response {
|
|||||||
id: string;
|
id: string;
|
||||||
text: string;
|
text: string;
|
||||||
pack: {
|
pack: {
|
||||||
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -105,6 +117,41 @@ interface Match {
|
|||||||
message_count?: number;
|
message_count?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface EnabledPack {
|
||||||
|
pack_id: string;
|
||||||
|
enabled: boolean;
|
||||||
|
created_at: string | null;
|
||||||
|
pack: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
icon: string | null;
|
||||||
|
is_premium: boolean;
|
||||||
|
is_explicit: boolean;
|
||||||
|
category: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
} | null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FeatureInterest {
|
||||||
|
id: string;
|
||||||
|
feature_name: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface CoupleStreak {
|
||||||
|
id: string;
|
||||||
|
couple_id: string;
|
||||||
|
current_streak: number;
|
||||||
|
longest_streak: number;
|
||||||
|
last_active_date: string | null;
|
||||||
|
last_milestone_celebrated: number | null;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
const answerIcons = {
|
const answerIcons = {
|
||||||
yes: <ThumbsUp className="h-4 w-4 text-green-500" />,
|
yes: <ThumbsUp className="h-4 w-4 text-green-500" />,
|
||||||
no: <ThumbsDown className="h-4 w-4 text-red-500" />,
|
no: <ThumbsDown className="h-4 w-4 text-red-500" />,
|
||||||
@@ -238,6 +285,9 @@ export function UserDetailPage() {
|
|||||||
const [responses, setResponses] = useState<Response[]>([]);
|
const [responses, setResponses] = useState<Response[]>([]);
|
||||||
const [matches, setMatches] = useState<Match[]>([]);
|
const [matches, setMatches] = useState<Match[]>([]);
|
||||||
const [mediaMessages, setMediaMessages] = useState<any[]>([]);
|
const [mediaMessages, setMediaMessages] = useState<any[]>([]);
|
||||||
|
const [enabledPacks, setEnabledPacks] = useState<EnabledPack[]>([]);
|
||||||
|
const [featureInterests, setFeatureInterests] = useState<FeatureInterest[]>([]);
|
||||||
|
const [streak, setStreak] = useState<CoupleStreak | null>(null);
|
||||||
const [responsesPage, setResponsesPage] = useState(1);
|
const [responsesPage, setResponsesPage] = useState(1);
|
||||||
const [responsesPageSize, setResponsesPageSize] = useState(10);
|
const [responsesPageSize, setResponsesPageSize] = useState(10);
|
||||||
const [responsesTotal, setResponsesTotal] = useState(0);
|
const [responsesTotal, setResponsesTotal] = useState(0);
|
||||||
@@ -282,17 +332,13 @@ export function UserDetailPage() {
|
|||||||
}
|
}
|
||||||
expiresAt = date.toISOString();
|
expiresAt = date.toISOString();
|
||||||
}
|
}
|
||||||
const { error } = await auditedSupabase.insert('subscriptions', {
|
// Use RPC function which handles permissions internally
|
||||||
user_id: userId,
|
const { error } = await supabase.rpc('admin_gift_premium', {
|
||||||
revenuecat_app_user_id: 'admin_grant',
|
target_user_id: userId,
|
||||||
product_id: 'admin_premium',
|
expires_at_param: expiresAt,
|
||||||
status: 'active',
|
|
||||||
store: 'manual',
|
|
||||||
purchased_at: new Date().toISOString(),
|
|
||||||
expires_at: expiresAt,
|
|
||||||
});
|
});
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
const { data: updatedProfile } = await supabase.from('profiles').select('*').eq('id', userId).single();
|
const { data: updatedProfile } = await supabase.rpc('get_profile_with_auth_info', { user_id: userId }).single<Profile>();
|
||||||
if (updatedProfile) setProfile(updatedProfile);
|
if (updatedProfile) setProfile(updatedProfile);
|
||||||
setUpgradeOpen(false);
|
setUpgradeOpen(false);
|
||||||
alert('User upgraded to premium successfully!');
|
alert('User upgraded to premium successfully!');
|
||||||
@@ -308,14 +354,31 @@ export function UserDetailPage() {
|
|||||||
if (!userId) return;
|
if (!userId) return;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const { data: profileData } = await supabase.from('profiles').select('*').eq('id', userId).single();
|
const { data: profileData } = await supabase.rpc('get_profile_with_auth_info', { user_id: userId }).single<Profile>();
|
||||||
setProfile(profileData);
|
setProfile(profileData);
|
||||||
|
|
||||||
|
// Fetch feature interests for the user
|
||||||
|
const { data: featureInterestsData } = await supabase
|
||||||
|
.from('feature_interests')
|
||||||
|
.select('id, feature_name, created_at')
|
||||||
|
.eq('user_id', userId)
|
||||||
|
.order('created_at', { ascending: false });
|
||||||
|
setFeatureInterests((featureInterestsData || []) as FeatureInterest[]);
|
||||||
|
|
||||||
if (profileData?.couple_id) {
|
if (profileData?.couple_id) {
|
||||||
const { data: partnerData } = await supabase.from('profiles').select('id, name').eq('couple_id', profileData.couple_id).neq('id', userId).single();
|
const { data: partnerData } = await supabase.from('profiles').select('id, name').eq('couple_id', profileData.couple_id).neq('id', userId).single();
|
||||||
setPartner(partnerData);
|
setPartner(partnerData);
|
||||||
|
|
||||||
|
// Fetch streak data
|
||||||
|
const { data: streakData } = await supabase
|
||||||
|
.from('couple_streaks')
|
||||||
|
.select('*')
|
||||||
|
.eq('couple_id', profileData.couple_id)
|
||||||
|
.maybeSingle();
|
||||||
|
setStreak(streakData);
|
||||||
} else {
|
} else {
|
||||||
setPartner(null);
|
setPartner(null);
|
||||||
|
setStreak(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
const responsesFrom = (responsesPage - 1) * responsesPageSize;
|
const responsesFrom = (responsesPage - 1) * responsesPageSize;
|
||||||
@@ -323,7 +386,7 @@ export function UserDetailPage() {
|
|||||||
|
|
||||||
const { data: responseData, count: responsesCount, error: responsesError } = await supabase
|
const { data: responseData, count: responsesCount, error: responsesError } = await supabase
|
||||||
.from('responses')
|
.from('responses')
|
||||||
.select('id, answer, created_at, question:questions(id, text, pack:question_packs(name))', { count: 'exact' })
|
.select('id, answer, created_at, question:questions(id, text, pack:question_packs(id, name))', { count: 'exact' })
|
||||||
.eq('user_id', userId)
|
.eq('user_id', userId)
|
||||||
.order('created_at', { ascending: false })
|
.order('created_at', { ascending: false })
|
||||||
.range(responsesFrom, responsesTo);
|
.range(responsesFrom, responsesTo);
|
||||||
@@ -388,11 +451,39 @@ export function UserDetailPage() {
|
|||||||
setMediaMessages([]);
|
setMediaMessages([]);
|
||||||
setMediaTotal(0);
|
setMediaTotal(0);
|
||||||
}
|
}
|
||||||
|
// Fetch enabled packs for the couple
|
||||||
|
const { data: enabledPacksData, error: packsError } = await supabase
|
||||||
|
.from('couple_packs')
|
||||||
|
.select(`
|
||||||
|
pack_id,
|
||||||
|
enabled,
|
||||||
|
created_at,
|
||||||
|
pack:question_packs(
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
icon,
|
||||||
|
is_premium,
|
||||||
|
is_explicit,
|
||||||
|
category:categories(id, name)
|
||||||
|
)
|
||||||
|
`)
|
||||||
|
.eq('couple_id', profileData.couple_id)
|
||||||
|
.eq('enabled', true)
|
||||||
|
.order('created_at', { ascending: false });
|
||||||
|
|
||||||
|
if (packsError) {
|
||||||
|
console.error("Error fetching enabled packs:", packsError);
|
||||||
|
setEnabledPacks([]);
|
||||||
|
} else {
|
||||||
|
setEnabledPacks((enabledPacksData || []) as unknown as EnabledPack[]);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
setMatches([]);
|
setMatches([]);
|
||||||
setMatchesTotal(0);
|
setMatchesTotal(0);
|
||||||
setMediaMessages([]);
|
setMediaMessages([]);
|
||||||
setMediaTotal(0);
|
setMediaTotal(0);
|
||||||
|
setEnabledPacks([]);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load user data:', error);
|
console.error('Failed to load user data:', error);
|
||||||
@@ -446,30 +537,117 @@ export function UserDetailPage() {
|
|||||||
<h1 className="text-2xl font-bold">{profile.name || 'Unnamed User'}</h1>
|
<h1 className="text-2xl font-bold">{profile.name || 'Unnamed User'}</h1>
|
||||||
<RealtimeStatusIndicator status={profileStatus === 'SUBSCRIBED' && responsesStatus === 'SUBSCRIBED' ? 'SUBSCRIBED' : profileStatus} showLabel />
|
<RealtimeStatusIndicator status={profileStatus === 'SUBSCRIBED' && responsesStatus === 'SUBSCRIBED' ? 'SUBSCRIBED' : profileStatus} showLabel />
|
||||||
{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 && <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 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>
|
||||||
<p className="text-muted-foreground mb-4">{profile.email || 'No email'}</p>
|
|
||||||
<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>
|
||||||
{/* Onboarding Details */}
|
{/* Onboarding Details */}
|
||||||
<Card><CardHeader className="pb-3"><CardTitle className="text-lg flex items-center gap-2"><CheckCircle className="h-5 w-5 text-primary" />Onboarding Details</CardTitle><CardDescription>Information collected during user onboarding</CardDescription></CardHeader><CardContent><div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
<Card><CardHeader className="pb-3"><CardTitle className="text-lg flex items-center gap-2"><CheckCircle className="h-5 w-5 text-primary" />Onboarding Details</CardTitle><CardDescription>Information collected during user onboarding</CardDescription></CardHeader><CardContent><div className="grid gap-4 md:grid-cols-2 lg:grid-cols-5">
|
||||||
<div className="flex items-start gap-3"><div className="p-2 rounded-lg bg-blue-100 dark:bg-blue-900"><User className="h-4 w-4 text-blue-600 dark:text-blue-400" /></div><div><p className="text-sm font-medium text-muted-foreground">Gender</p><p className="font-medium">{profile.gender ? genderLabels[profile.gender] : 'Not set'}</p></div></div>
|
<div className="flex items-start gap-3"><div className="p-2 rounded-lg bg-blue-100 dark:bg-blue-900"><User className="h-4 w-4 text-blue-600 dark:text-blue-400" /></div><div><p className="text-sm font-medium text-muted-foreground">Gender</p><p className="font-medium">{profile.gender ? genderLabels[profile.gender] : 'Not set'}</p></div></div>
|
||||||
<div className="flex items-start gap-3"><div className="p-2 rounded-lg bg-purple-100 dark:bg-purple-900"><Target className="h-4 w-4 text-purple-600 dark:text-purple-400" /></div><div><p className="text-sm font-medium text-muted-foreground">Why using Sauci</p><p className="font-medium">{profile.usage_reason ? usageReasonLabels[profile.usage_reason]?.label : 'Not set'}</p></div></div>
|
<div className="flex items-start gap-3"><div className="p-2 rounded-lg bg-purple-100 dark:bg-purple-900"><Target className="h-4 w-4 text-purple-600 dark:text-purple-400" /></div><div><p className="text-sm font-medium text-muted-foreground">Why using Sauci</p><p className="font-medium">{profile.usage_reason ? usageReasonLabels[profile.usage_reason]?.label : 'Not set'}</p></div></div>
|
||||||
|
<div className="flex items-start gap-3"><div className="p-2 rounded-lg bg-pink-100 dark:bg-pink-900"><Heart className="h-4 w-4 text-pink-600 dark:text-pink-400" /></div><div><p className="text-sm font-medium text-muted-foreground">Comfort Zone</p><p className="font-medium">{profile.max_intensity ? `${intensityLabels[profile.max_intensity].emoji} ${intensityLabels[profile.max_intensity].label} (${profile.max_intensity})` : 'Not set'}</p></div></div>
|
||||||
<div className="flex items-start gap-3"><div className={`p-2 rounded-lg ${profile.show_explicit_content ? 'bg-amber-100 dark:bg-amber-900' : 'bg-gray-100 dark:bg-gray-800'}`}>{profile.show_explicit_content ? <Eye className="h-4 w-4 text-amber-600 dark:text-amber-400" /> : <EyeOff className="h-4 w-4 text-gray-500 dark:text-gray-400" />}</div><div><p className="text-sm font-medium text-muted-foreground">Explicit Content</p><p className="font-medium">{profile.show_explicit_content === null ? 'Not set' : profile.show_explicit_content ? 'Enabled' : 'Disabled'}</p></div></div>
|
<div className="flex items-start gap-3"><div className={`p-2 rounded-lg ${profile.show_explicit_content ? 'bg-amber-100 dark:bg-amber-900' : 'bg-gray-100 dark:bg-gray-800'}`}>{profile.show_explicit_content ? <Eye className="h-4 w-4 text-amber-600 dark:text-amber-400" /> : <EyeOff className="h-4 w-4 text-gray-500 dark:text-gray-400" />}</div><div><p className="text-sm font-medium text-muted-foreground">Explicit Content</p><p className="font-medium">{profile.show_explicit_content === null ? 'Not set' : profile.show_explicit_content ? 'Enabled' : 'Disabled'}</p></div></div>
|
||||||
<div className="flex items-start gap-3"><div className={`p-2 rounded-lg ${profile.onboarding_completed ? 'bg-green-100 dark:bg-green-900' : 'bg-red-100 dark:bg-red-900'}`}><CheckCircle className={`h-4 w-4 ${profile.onboarding_completed ? 'text-green-600 dark:text-green-400' : 'text-red-500 dark:text-red-400'}`} /></div><div><p className="text-sm font-medium text-muted-foreground">Onboarding</p><p className="font-medium">{profile.onboarding_completed ? 'Completed' : 'Not completed'}</p></div></div>
|
<div className="flex items-start gap-3"><div className={`p-2 rounded-lg ${profile.onboarding_completed ? 'bg-green-100 dark:bg-green-900' : 'bg-red-100 dark:bg-red-900'}`}><CheckCircle className={`h-4 w-4 ${profile.onboarding_completed ? 'text-green-600 dark:text-green-400' : 'text-red-500 dark:text-red-400'}`} /></div><div><p className="text-sm font-medium text-muted-foreground">Onboarding</p><p className="font-medium">{profile.onboarding_completed ? 'Completed' : 'Not completed'}</p></div></div>
|
||||||
</div></CardContent></Card>
|
</div></CardContent></Card>
|
||||||
|
{/* Streak Info */}
|
||||||
|
{profile.couple_id && streak && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-lg flex items-center gap-2">
|
||||||
|
<Flame className="h-5 w-5 text-orange-500" />
|
||||||
|
Couple Streak
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>Activity streak for this couple</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="p-2 rounded-lg bg-orange-100 dark:bg-orange-900">
|
||||||
|
<Flame className="h-4 w-4 text-orange-600 dark:text-orange-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-muted-foreground">Current Streak</p>
|
||||||
|
<p className="font-medium text-xl">{streak.current_streak} day{streak.current_streak !== 1 ? 's' : ''}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="p-2 rounded-lg bg-yellow-100 dark:bg-yellow-900">
|
||||||
|
<Trophy className="h-4 w-4 text-yellow-600 dark:text-yellow-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-muted-foreground">Longest Streak</p>
|
||||||
|
<p className="font-medium text-xl">{streak.longest_streak} day{streak.longest_streak !== 1 ? 's' : ''}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="p-2 rounded-lg bg-blue-100 dark:bg-blue-900">
|
||||||
|
<Calendar className="h-4 w-4 text-blue-600 dark:text-blue-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-muted-foreground">Last Active</p>
|
||||||
|
<p className="font-medium">{streak.last_active_date ? format(new Date(streak.last_active_date), 'MMM d, yyyy') : 'Never'}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="p-2 rounded-lg bg-purple-100 dark:bg-purple-900">
|
||||||
|
<Sparkles className="h-4 w-4 text-purple-600 dark:text-purple-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-muted-foreground">Last Milestone</p>
|
||||||
|
<p className="font-medium">{streak.last_milestone_celebrated ? `${streak.last_milestone_celebrated} days` : 'None yet'}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
{/* Feature Opt-Ins */}
|
||||||
|
{featureInterests.length > 0 && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-lg flex items-center gap-2">
|
||||||
|
<Sparkles className="h-5 w-5 text-primary" />
|
||||||
|
Feature Opt-Ins
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>Features the user has expressed interest in</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{featureInterests.map((interest) => (
|
||||||
|
<Badge key={interest.id} variant="secondary" className="capitalize">
|
||||||
|
{interest.feature_name}
|
||||||
|
<span className="ml-1 text-xs text-muted-foreground">
|
||||||
|
({format(new Date(interest.created_at), 'MMM d')})
|
||||||
|
</span>
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
{/* Tabs */}
|
{/* Tabs */}
|
||||||
<Tabs defaultValue={canViewResponses ? "responses" : canViewMatches ? "matches" : canViewMedia ? "media" : "responses"}>
|
<Tabs defaultValue={canViewResponses ? "responses" : canViewMatches ? "matches" : canViewMedia ? "media" : "responses"}>
|
||||||
<TabsList>
|
<TabsList>
|
||||||
{canViewResponses && <TabsTrigger value="responses">Responses ({responsesTotal})</TabsTrigger>}
|
{canViewResponses && <TabsTrigger value="responses">Responses ({responsesTotal})</TabsTrigger>}
|
||||||
{canViewMatches && <TabsTrigger value="matches" disabled={!profile.couple_id}>Matches ({matchesTotal})</TabsTrigger>}
|
{canViewMatches && <TabsTrigger value="matches" disabled={!profile.couple_id}>Matches ({matchesTotal})</TabsTrigger>}
|
||||||
{canViewMedia && <TabsTrigger value="media">Media ({mediaTotal})</TabsTrigger>}
|
{canViewMedia && <TabsTrigger value="media">Media ({mediaTotal})</TabsTrigger>}
|
||||||
|
<TabsTrigger value="packs" disabled={!profile.couple_id}>Enabled Packs ({enabledPacks.length})</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
{canViewResponses && <TabsContent value="responses" className="mt-4">{responses.length === 0 ? <Card className="flex flex-col items-center justify-center py-12"><p className="text-muted-foreground">No responses yet</p></Card> : <div className="space-y-4"><div className="rounded-md border"><Table><TableHeader><TableRow><TableHead>Question</TableHead><TableHead>Pack</TableHead><TableHead className="w-24">Answer</TableHead><TableHead className="w-32">Date</TableHead></TableRow></TableHeader><TableBody>{responses.map((r) => (<TableRow key={r.id}><TableCell className="max-w-md"><span className="line-clamp-2">{r.question.text}</span></TableCell><TableCell className="text-muted-foreground">{r.question.pack?.name || '—'}</TableCell><TableCell><div className="flex items-center gap-2">{answerIcons[r.answer]}<span className="capitalize">{r.answer}</span></div></TableCell><TableCell className="text-muted-foreground text-sm">{r.created_at ? format(new Date(r.created_at), 'MMM d, yyyy') : '—'}</TableCell></TableRow>))}</TableBody></Table></div><PaginationControls page={responsesPage} pageSize={responsesPageSize} totalCount={responsesTotal} onPageChange={setResponsesPage} onPageSizeChange={(size) => { setResponsesPage(1); setResponsesPageSize(size); }} /></div>}</TabsContent>}
|
{canViewResponses && <TabsContent value="responses" className="mt-4">{responses.length === 0 ? <Card className="flex flex-col items-center justify-center py-12"><p className="text-muted-foreground">No responses yet</p></Card> : <div className="space-y-4"><div className="rounded-md border"><Table><TableHeader><TableRow><TableHead>Question</TableHead><TableHead>Pack</TableHead><TableHead className="w-24">Answer</TableHead><TableHead className="w-32">Date</TableHead></TableRow></TableHeader><TableBody>{responses.map((r) => (<TableRow key={r.id}><TableCell className="max-w-md">{r.question.pack?.id ? (<Link to={`/packs/${r.question.pack.id}/questions`} className="line-clamp-2 text-primary hover:underline cursor-pointer">{r.question.text}</Link>) : (<span className="line-clamp-2">{r.question.text}</span>)}</TableCell><TableCell>{r.question.pack?.id ? (<Link to={`/packs/${r.question.pack.id}/questions`} className="text-primary hover:underline">{r.question.pack.name}</Link>) : (<span className="text-muted-foreground">{r.question.pack?.name || '—'}</span>)}</TableCell><TableCell><div className="flex items-center gap-2">{answerIcons[r.answer]}<span className="capitalize">{r.answer}</span></div></TableCell><TableCell className="text-muted-foreground text-sm">{r.created_at ? format(new Date(r.created_at), 'MMM d, yyyy') : '—'}</TableCell></TableRow>))}</TableBody></Table></div><PaginationControls page={responsesPage} pageSize={responsesPageSize} totalCount={responsesTotal} onPageChange={setResponsesPage} onPageSizeChange={(size) => { setResponsesPage(1); setResponsesPageSize(size); }} /></div>}</TabsContent>}
|
||||||
{canViewMatches && <TabsContent value="matches" className="mt-4">{matches.length === 0 ? <Card className="flex flex-col items-center justify-center py-12"><p className="text-muted-foreground">No matches yet</p></Card> : <div className="space-y-4"><div className="grid gap-4 md:grid-cols-2">{matches.map((match) => (<Card key={match.id} className="hover:shadow-md transition-shadow"><CardHeader className="pb-2"><div className="flex items-start justify-between"><Badge variant="secondary">{matchTypeLabels[match.match_type]}</Badge>{match.is_new && <Badge variant="default" className="text-xs">New</Badge>}</div><CardDescription className="line-clamp-2 mt-2">{match.question?.text || 'Unknown question'}</CardDescription></CardHeader><CardContent><div className="flex items-center justify-between"><div className="flex items-center gap-1 text-sm text-muted-foreground"><MessageCircle className="h-4 w-4" />{match.message_count} message{match.message_count !== 1 ? 's' : ''}</div>{canViewChats && <Link to={`/users/${userId}/matches/${match.id}`}><Button variant="ghost" size="sm">View Chat<ChevronRight className="ml-1 h-4 w-4" /></Button></Link>}</div></CardContent></Card>))}</div><PaginationControls page={matchesPage} pageSize={matchesPageSize} totalCount={matchesTotal} onPageChange={setMatchesPage} onPageSizeChange={(size) => { setMatchesPage(1); setMatchesPageSize(size); }} /></div>}</TabsContent>}
|
{canViewMatches && <TabsContent value="matches" className="mt-4">{matches.length === 0 ? <Card className="flex flex-col items-center justify-center py-12"><p className="text-muted-foreground">No matches yet</p></Card> : <div className="space-y-4"><div className="grid gap-4 md:grid-cols-2">{matches.map((match) => (<Card key={match.id} className="hover:shadow-md transition-shadow"><CardHeader className="pb-2"><div className="flex items-start justify-between"><Badge variant="secondary">{matchTypeLabels[match.match_type]}</Badge>{match.is_new && <Badge variant="default" className="text-xs">New</Badge>}</div><CardDescription className="line-clamp-2 mt-2">{match.question?.text || 'Unknown question'}</CardDescription></CardHeader><CardContent><div className="flex items-center justify-between"><div className="flex items-center gap-1 text-sm text-muted-foreground"><MessageCircle className="h-4 w-4" />{match.message_count} message{match.message_count !== 1 ? 's' : ''}</div>{canViewChats && <Link to={`/users/${userId}/matches/${match.id}`}><Button variant="ghost" size="sm">View Chat<ChevronRight className="ml-1 h-4 w-4" /></Button></Link>}</div></CardContent></Card>))}</div><PaginationControls page={matchesPage} pageSize={matchesPageSize} totalCount={matchesTotal} onPageChange={setMatchesPage} onPageSizeChange={(size) => { setMatchesPage(1); setMatchesPageSize(size); }} /></div>}</TabsContent>}
|
||||||
{canViewMedia && <TabsContent value="media" className="mt-4">{mediaMessages.length === 0 ? <Card className="flex flex-col items-center justify-center py-12"><Image className="h-12 w-12 text-muted-foreground mb-4" /><p className="text-muted-foreground">No media uploaded</p></Card> : <div className="space-y-4"><div className="grid gap-4 grid-cols-2 md:grid-cols-3 lg:grid-cols-4">{mediaMessages.map((message) => (<Card key={message.id} className="overflow-hidden"><div className="aspect-square bg-muted relative">
|
{canViewMedia && <TabsContent value="media" className="mt-4">{mediaMessages.length === 0 ? <Card className="flex flex-col items-center justify-center py-12"><Image className="h-12 w-12 text-muted-foreground mb-4" /><p className="text-muted-foreground">No media uploaded</p></Card> : <div className="space-y-4"><div className="grid gap-4 grid-cols-2 md:grid-cols-3 lg:grid-cols-4">{mediaMessages.map((message) => (<Card key={message.id} className="overflow-hidden"><div className="aspect-square bg-muted relative">
|
||||||
{message.media_type === 'video' ? (
|
{message.media_type === 'video' ? (
|
||||||
@@ -478,6 +656,59 @@ export function UserDetailPage() {
|
|||||||
<AdminDecryptedImage messageId={message.id} alt={`Media from ${message.created_at}`} />
|
<AdminDecryptedImage messageId={message.id} alt={`Media from ${message.created_at}`} />
|
||||||
)}
|
)}
|
||||||
</div><CardContent className="p-3"><div className="flex items-center justify-between text-sm"><div className="truncate text-muted-foreground">{message.media_type === 'video' ? 'Video' : 'Image'}</div>{canViewChats && message.match_id && <Link to={`/users/${userId}/matches/${message.match_id}`} className="text-primary hover:underline text-xs">View Chat</Link>}</div><div className="text-xs text-muted-foreground mt-1">{message.created_at ? format(new Date(message.created_at), 'MMM d, yyyy') : '—'}</div></CardContent></Card>))}</div><PaginationControls page={mediaPage} pageSize={mediaPageSize} totalCount={mediaTotal} onPageChange={setMediaPage} onPageSizeChange={(size) => { setMediaPage(1); setMediaPageSize(size); }} /></div>}</TabsContent>}
|
</div><CardContent className="p-3"><div className="flex items-center justify-between text-sm"><div className="truncate text-muted-foreground">{message.media_type === 'video' ? 'Video' : 'Image'}</div>{canViewChats && message.match_id && <Link to={`/users/${userId}/matches/${message.match_id}`} className="text-primary hover:underline text-xs">View Chat</Link>}</div><div className="text-xs text-muted-foreground mt-1">{message.created_at ? format(new Date(message.created_at), 'MMM d, yyyy') : '—'}</div></CardContent></Card>))}</div><PaginationControls page={mediaPage} pageSize={mediaPageSize} totalCount={mediaTotal} onPageChange={setMediaPage} onPageSizeChange={(size) => { setMediaPage(1); setMediaPageSize(size); }} /></div>}</TabsContent>}
|
||||||
|
<TabsContent value="packs" className="mt-4">
|
||||||
|
{enabledPacks.length === 0 ? (
|
||||||
|
<Card className="flex flex-col items-center justify-center py-12">
|
||||||
|
<Package className="h-12 w-12 text-muted-foreground mb-4" />
|
||||||
|
<p className="text-muted-foreground">No packs enabled</p>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{enabledPacks.map((ep) => (
|
||||||
|
<Link key={ep.pack_id} to={`/packs/${ep.pack.id}/questions`}>
|
||||||
|
<Card className="hover:shadow-md transition-shadow cursor-pointer">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<IconPreview value={ep.pack.icon} fallback="heart-outline" className="text-2xl" />
|
||||||
|
<CardTitle className="text-base">{ep.pack.name}</CardTitle>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{ep.pack.is_premium && (
|
||||||
|
<Badge className="bg-amber-500 text-xs">
|
||||||
|
<Crown className="h-3 w-3 mr-1" />
|
||||||
|
Premium
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{ep.pack.is_explicit && (
|
||||||
|
<Badge variant="destructive" className="text-xs">
|
||||||
|
18+
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{ep.pack.description && (
|
||||||
|
<CardDescription className="line-clamp-2 mt-2">
|
||||||
|
{ep.pack.description}
|
||||||
|
</CardDescription>
|
||||||
|
)}
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{ep.pack.category?.name || 'Uncategorized'}
|
||||||
|
</span>
|
||||||
|
<span className="text-muted-foreground text-xs">
|
||||||
|
Enabled {ep.created_at ? format(new Date(ep.created_at), 'MMM d, yyyy') : '—'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -6,3 +6,6 @@ expo-env.d.ts
|
|||||||
# @end expo-cli
|
# @end expo-cli
|
||||||
*.aab
|
*.aab
|
||||||
*.ipa
|
*.ipa
|
||||||
|
*.apk
|
||||||
|
*.jks
|
||||||
|
*.keystore
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
<key>IS_ADS_ENABLED</key>
|
<key>IS_ADS_ENABLED</key>
|
||||||
<false></false>
|
<false></false>
|
||||||
<key>IS_ANALYTICS_ENABLED</key>
|
<key>IS_ANALYTICS_ENABLED</key>
|
||||||
<false></false>
|
<true></true>
|
||||||
<key>IS_APPINVITE_ENABLED</key>
|
<key>IS_APPINVITE_ENABLED</key>
|
||||||
<true></true>
|
<true></true>
|
||||||
<key>IS_GCM_ENABLED</key>
|
<key>IS_GCM_ENABLED</key>
|
||||||
|
|||||||
@@ -93,8 +93,8 @@ 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 21
|
versionCode 34
|
||||||
versionName "1.0.0"
|
versionName "1.0.2"
|
||||||
}
|
}
|
||||||
signingConfigs {
|
signingConfigs {
|
||||||
debug {
|
debug {
|
||||||
|
|||||||
@@ -13,6 +13,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"oauth_client": [
|
"oauth_client": [
|
||||||
|
{
|
||||||
|
"client_id": "764866133492-03k7q0mq6nqrv0phunncqjn85cgj6sep.apps.googleusercontent.com",
|
||||||
|
"client_type": 1,
|
||||||
|
"android_info": {
|
||||||
|
"package_name": "com.sauci.app",
|
||||||
|
"certificate_hash": "f06026c754b046f280feac438a6910bd9a62b459"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"client_id": "764866133492-4g566mrb8tm5uti4ejub6oogcvnln7kc.apps.googleusercontent.com",
|
"client_id": "764866133492-4g566mrb8tm5uti4ejub6oogcvnln7kc.apps.googleusercontent.com",
|
||||||
"client_type": 1,
|
"client_type": 1,
|
||||||
|
|||||||
+4
@@ -12,3 +12,7 @@
|
|||||||
-keep class com.facebook.react.turbomodule.** { *; }
|
-keep class com.facebook.react.turbomodule.** { *; }
|
||||||
|
|
||||||
# Add any project specific keep options here:
|
# Add any project specific keep options here:
|
||||||
|
|
||||||
|
# @generated begin expo-build-properties - expo prebuild (DO NOT MODIFY)
|
||||||
|
-keep class com.facebook.hermes.unicode.** { *; }
|
||||||
|
# @generated end expo-build-properties
|
||||||
@@ -18,7 +18,7 @@ buildscript {
|
|||||||
classpath 'com.google.gms:google-services:4.4.1'
|
classpath 'com.google.gms:google-services:4.4.1'
|
||||||
classpath('com.android.tools.build:gradle')
|
classpath('com.android.tools.build:gradle')
|
||||||
classpath('com.facebook.react:react-native-gradle-plugin')
|
classpath('com.facebook.react:react-native-gradle-plugin')
|
||||||
classpath('org.jetbrains.kotlin:kotlin-gradle-plugin')
|
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -56,4 +56,5 @@ EX_DEV_CLIENT_NETWORK_INSPECTOR=true
|
|||||||
expo.useLegacyPackaging=false
|
expo.useLegacyPackaging=false
|
||||||
|
|
||||||
android.targetSdkVersion=35
|
android.targetSdkVersion=35
|
||||||
|
android.kotlinVersion=1.9.25
|
||||||
android.extraMavenRepos=[]
|
android.extraMavenRepos=[]
|
||||||
+17
-5
@@ -2,7 +2,7 @@
|
|||||||
"expo": {
|
"expo": {
|
||||||
"name": "Sauci",
|
"name": "Sauci",
|
||||||
"slug": "sauci",
|
"slug": "sauci",
|
||||||
"version": "1.0.0",
|
"version": "1.0.2",
|
||||||
"orientation": "portrait",
|
"orientation": "portrait",
|
||||||
"icon": "./assets/icon.png",
|
"icon": "./assets/icon.png",
|
||||||
"scheme": "app.sauci",
|
"scheme": "app.sauci",
|
||||||
@@ -16,8 +16,9 @@
|
|||||||
"ios": {
|
"ios": {
|
||||||
"supportsTablet": false,
|
"supportsTablet": false,
|
||||||
"bundleIdentifier": "com.sauci.app",
|
"bundleIdentifier": "com.sauci.app",
|
||||||
"buildNumber": "25",
|
"buildNumber": "31",
|
||||||
"usesAppleSignIn": true,
|
"usesAppleSignIn": true,
|
||||||
|
"googleServicesFile": "./GoogleService-Info.plist",
|
||||||
"infoPlist": {
|
"infoPlist": {
|
||||||
"UIBackgroundModes": [
|
"UIBackgroundModes": [
|
||||||
"remote-notification"
|
"remote-notification"
|
||||||
@@ -176,7 +177,7 @@
|
|||||||
"backgroundColor": "#1a1a2e"
|
"backgroundColor": "#1a1a2e"
|
||||||
},
|
},
|
||||||
"package": "com.sauci.app",
|
"package": "com.sauci.app",
|
||||||
"versionCode": 21,
|
"versionCode": 34,
|
||||||
"googleServicesFile": "./google-services.json",
|
"googleServicesFile": "./google-services.json",
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"RECEIVE_BOOT_COMPLETED",
|
"RECEIVE_BOOT_COMPLETED",
|
||||||
@@ -192,11 +193,21 @@
|
|||||||
"favicon": "./assets/favicon.png"
|
"favicon": "./assets/favicon.png"
|
||||||
},
|
},
|
||||||
"plugins": [
|
"plugins": [
|
||||||
|
"@react-native-firebase/app",
|
||||||
[
|
[
|
||||||
"expo-build-properties",
|
"expo-build-properties",
|
||||||
{
|
{
|
||||||
"android": {
|
"android": {
|
||||||
"targetSdkVersion": 35
|
"targetSdkVersion": 35,
|
||||||
|
"kotlinVersion": "1.9.25",
|
||||||
|
"extraProguardRules": "-keep class com.facebook.hermes.unicode.** { *; }",
|
||||||
|
"useLegacyPackaging": false,
|
||||||
|
"manifestPlaceholders": {
|
||||||
|
"firebaseAnalyticsWithoutAdId": "true"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ios": {
|
||||||
|
"useFrameworks": "static"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -231,7 +242,8 @@
|
|||||||
{
|
{
|
||||||
"iosUrlScheme": "com.googleusercontent.apps.861972684340-0u2as8i5equfhmhmvjihottkf0ig7237"
|
"iosUrlScheme": "com.googleusercontent.apps.861972684340-0u2as8i5equfhmhmvjihottkf0ig7237"
|
||||||
}
|
}
|
||||||
]
|
],
|
||||||
|
"./plugins/withKotlinVersion.js"
|
||||||
],
|
],
|
||||||
"experiments": {
|
"experiments": {
|
||||||
"typedRoutes": true
|
"typedRoutes": true
|
||||||
|
|||||||
@@ -4,11 +4,12 @@ import { View, ActivityIndicator, StyleSheet, Platform, Text, Pressable, Animate
|
|||||||
import { BlurView } from "expo-blur";
|
import { BlurView } from "expo-blur";
|
||||||
import { LinearGradient } from "expo-linear-gradient";
|
import { LinearGradient } from "expo-linear-gradient";
|
||||||
import { useEffect, useState, useCallback, useRef } from "react";
|
import { useEffect, useState, useCallback, useRef } from "react";
|
||||||
import { useAuthStore, useMatchStore, useMessageStore, useSubscriptionStore, usePacksStore } from "../../src/store";
|
import { useAuthStore, useMatchStore, useMessageStore, useSubscriptionStore, usePacksStore, useStreakStore } from "../../src/store";
|
||||||
import { colors, gradients, blur, radius, spacing, typography, shadows } from "../../src/theme";
|
import { colors, gradients, blur, radius, spacing, typography, shadows } from "../../src/theme";
|
||||||
import { supabase } from "../../src/lib/supabase";
|
import { supabase } from "../../src/lib/supabase";
|
||||||
import { isBiometricEnabled } from "../../src/lib/biometricAuth";
|
import { isBiometricEnabled } from "../../src/lib/biometricAuth";
|
||||||
import { checkAndRegisterPushToken } from "../../src/lib/notifications";
|
import { checkAndRegisterPushToken } from "../../src/lib/notifications";
|
||||||
|
import { syncBadgeCount } from "../../src/lib/badge";
|
||||||
import { BiometricLockScreen } from "../../src/components/BiometricLockScreen";
|
import { BiometricLockScreen } from "../../src/components/BiometricLockScreen";
|
||||||
import { MatchNotificationModal, MatchNotificationData } from "../../src/components/MatchNotificationModal";
|
import { MatchNotificationModal, MatchNotificationData } from "../../src/components/MatchNotificationModal";
|
||||||
import { Events } from "../../src/lib/analytics";
|
import { Events } from "../../src/lib/analytics";
|
||||||
@@ -139,11 +140,12 @@ function PlayTabButton({ accessibilityState }: PlayTabButtonProps) {
|
|||||||
export default function AppLayout() {
|
export default function AppLayout() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const segments = useSegments();
|
const segments = useSegments();
|
||||||
const { isAuthenticated, isLoading, user, signOut } = useAuthStore();
|
const { isAuthenticated, isLoading, user, signOut, updateLastActive } = useAuthStore();
|
||||||
const { matches, newMatchesCount, addMatch, updateMatchUnreadCount } = useMatchStore();
|
const { matches, newMatchesCount, addMatch, updateMatchUnreadCount } = useMatchStore();
|
||||||
const { unreadCount, lastMessage, fetchUnreadCount, addMessage, clearLastMessage } = useMessageStore();
|
const { unreadCount, lastMessage, fetchUnreadCount, addMessage, clearLastMessage } = useMessageStore();
|
||||||
const { fetchEnabledPacks } = usePacksStore();
|
const { fetchEnabledPacks } = usePacksStore();
|
||||||
const { initializeRevenueCat } = useSubscriptionStore();
|
const { initializeRevenueCat } = useSubscriptionStore();
|
||||||
|
const { fetchStreak } = useStreakStore();
|
||||||
const [matchNotification, setMatchNotification] = useState<MatchNotificationData | null>(null);
|
const [matchNotification, setMatchNotification] = useState<MatchNotificationData | null>(null);
|
||||||
const messageToastAnim = useRef(new Animated.Value(-100)).current;
|
const messageToastAnim = useRef(new Animated.Value(-100)).current;
|
||||||
const messageTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
const messageTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
@@ -208,7 +210,8 @@ export default function AppLayout() {
|
|||||||
const isOnOnboarding = segmentStrings.includes("onboarding");
|
const isOnOnboarding = segmentStrings.includes("onboarding");
|
||||||
const isOnPairing = segmentStrings.includes("pairing");
|
const isOnPairing = segmentStrings.includes("pairing");
|
||||||
const isOnChat = segmentStrings.includes("chat");
|
const isOnChat = segmentStrings.includes("chat");
|
||||||
const shouldHideTabBar = isOnOnboarding || isOnPairing || isOnChat;
|
const isOnSettingsSubscreen = segmentStrings.includes("settings") && segmentStrings.length > 2;
|
||||||
|
const shouldHideTabBar = isOnOnboarding || isOnPairing || isOnChat || isOnSettingsSubscreen;
|
||||||
|
|
||||||
// Redirect to login when not authenticated, or to onboarding if not completed
|
// Redirect to login when not authenticated, or to onboarding if not completed
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -232,17 +235,22 @@ export default function AppLayout() {
|
|||||||
// Check immediately
|
// Check immediately
|
||||||
checkSession();
|
checkSession();
|
||||||
|
|
||||||
|
// Update last active timestamp on mount
|
||||||
|
updateLastActive();
|
||||||
|
|
||||||
// Check when app comes to foreground
|
// Check when app comes to foreground
|
||||||
const subscription = AppState.addEventListener('change', (nextAppState) => {
|
const subscription = AppState.addEventListener('change', (nextAppState) => {
|
||||||
if (nextAppState === 'active') {
|
if (nextAppState === 'active') {
|
||||||
checkSession();
|
checkSession();
|
||||||
|
// Update last active timestamp when app comes to foreground
|
||||||
|
updateLastActive();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
subscription.remove();
|
subscription.remove();
|
||||||
};
|
};
|
||||||
}, [signOut]);
|
}, [signOut, updateLastActive]);
|
||||||
|
|
||||||
// Handle biometric lock when app goes to background/foreground
|
// Handle biometric lock when app goes to background/foreground
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -301,6 +309,18 @@ export default function AppLayout() {
|
|||||||
}
|
}
|
||||||
}, [user?.id, fetchUnreadCount]);
|
}, [user?.id, fetchUnreadCount]);
|
||||||
|
|
||||||
|
// Fetch streak data when user is in a couple
|
||||||
|
useEffect(() => {
|
||||||
|
if (user?.couple_id) {
|
||||||
|
fetchStreak();
|
||||||
|
}
|
||||||
|
}, [user?.couple_id, fetchStreak]);
|
||||||
|
|
||||||
|
// Sync app icon badge count whenever matches or messages change
|
||||||
|
useEffect(() => {
|
||||||
|
syncBadgeCount(newMatchesCount, unreadCount);
|
||||||
|
}, [newMatchesCount, unreadCount]);
|
||||||
|
|
||||||
// Initialize RevenueCat for subscription management (iOS and Android only)
|
// Initialize RevenueCat for subscription management (iOS and Android only)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (user?.id && Platform.OS !== "web") {
|
if (user?.id && Platform.OS !== "web") {
|
||||||
@@ -372,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) {
|
||||||
@@ -692,6 +712,12 @@ export default function AppLayout() {
|
|||||||
href: null, // Hide from tab bar
|
href: null, // Hide from tab bar
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<Tabs.Screen
|
||||||
|
name="settings"
|
||||||
|
options={{
|
||||||
|
href: null, // Hide from tab bar
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|
||||||
{/* Radial Menu Overlay */}
|
{/* Radial Menu Overlay */}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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";
|
||||||
@@ -258,7 +258,7 @@ function CategorySection({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function QuestionPacksScreen() {
|
export default function QuestionPacksScreen() {
|
||||||
const { packs, categories, enabledPackIds, togglePack, isLoading, fetchPacks } = usePacksStore();
|
const { packs, categories, enabledPackIds, togglePack, isLoading, fetchPacks, showAllIntensities, setShowAllIntensities } = usePacksStore();
|
||||||
const { user, partner } = useAuthStore();
|
const { user, partner } = useAuthStore();
|
||||||
const { subscription } = useSubscriptionStore();
|
const { subscription } = useSubscriptionStore();
|
||||||
|
|
||||||
@@ -335,6 +335,10 @@ export default function QuestionPacksScreen() {
|
|||||||
setTeaserPack(null);
|
setTeaserPack(null);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const handleToggleComfortZone = useCallback((value: boolean) => {
|
||||||
|
setShowAllIntensities(value);
|
||||||
|
}, [setShowAllIntensities]);
|
||||||
|
|
||||||
const enabledPackCount = enabledPackIds.length;
|
const enabledPackCount = enabledPackIds.length;
|
||||||
const totalQuestions = packs
|
const totalQuestions = packs
|
||||||
.filter(p => enabledPackIds.includes(p.id))
|
.filter(p => enabledPackIds.includes(p.id))
|
||||||
@@ -442,6 +446,26 @@ export default function QuestionPacksScreen() {
|
|||||||
{totalQuestions} {totalQuestions === 1 ? "question" : "questions"} from {enabledPackCount} {enabledPackCount === 1 ? "pack" : "packs"}
|
{totalQuestions} {totalQuestions === 1 ? "question" : "questions"} from {enabledPackCount} {enabledPackCount === 1 ? "pack" : "packs"}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
<Pressable
|
||||||
|
style={styles.comfortZoneToggle}
|
||||||
|
onPress={() => handleToggleComfortZone(!showAllIntensities)}
|
||||||
|
>
|
||||||
|
<View style={styles.comfortZoneToggleLeft}>
|
||||||
|
<Ionicons
|
||||||
|
name={showAllIntensities ? "flame" : "flame-outline"}
|
||||||
|
size={16}
|
||||||
|
color={showAllIntensities ? colors.primary : colors.textSecondary}
|
||||||
|
/>
|
||||||
|
<Text style={styles.comfortZoneToggleText}>
|
||||||
|
Show all intensities
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<GlassToggle
|
||||||
|
value={showAllIntensities}
|
||||||
|
onValueChange={handleToggleComfortZone}
|
||||||
|
/>
|
||||||
|
</Pressable>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
|
|
||||||
{/* Categories */}
|
{/* Categories */}
|
||||||
@@ -610,6 +634,30 @@ const styles = StyleSheet.create({
|
|||||||
color: colors.textSecondary,
|
color: colors.textSecondary,
|
||||||
fontWeight: "500",
|
fontWeight: "500",
|
||||||
},
|
},
|
||||||
|
comfortZoneToggle: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "space-between",
|
||||||
|
backgroundColor: "rgba(255, 255, 255, 0.05)",
|
||||||
|
paddingHorizontal: spacing.md,
|
||||||
|
paddingVertical: spacing.sm,
|
||||||
|
borderRadius: radius.md,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.glass.border,
|
||||||
|
marginTop: spacing.md,
|
||||||
|
width: "100%",
|
||||||
|
maxWidth: 280,
|
||||||
|
},
|
||||||
|
comfortZoneToggleLeft: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: spacing.sm,
|
||||||
|
},
|
||||||
|
comfortZoneToggleText: {
|
||||||
|
...typography.subhead,
|
||||||
|
color: colors.textSecondary,
|
||||||
|
fontWeight: "500",
|
||||||
|
},
|
||||||
|
|
||||||
// Scroll Content
|
// Scroll Content
|
||||||
scrollContent: {
|
scrollContent: {
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { Stack } from 'expo-router';
|
||||||
|
|
||||||
|
export default function SettingsLayout() {
|
||||||
|
return (
|
||||||
|
<Stack
|
||||||
|
screenOptions={{
|
||||||
|
headerShown: false,
|
||||||
|
animation: 'slide_from_right',
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { AccountScreen } from "../../../src/features/profile/screens";
|
||||||
|
|
||||||
|
export default AccountScreen;
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { NotificationsScreen } from "../../../src/features/profile/screens";
|
||||||
|
|
||||||
|
export default NotificationsScreen;
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { PartnerScreen } from "../../../src/features/profile/screens";
|
||||||
|
|
||||||
|
export default PartnerScreen;
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { PreferencesScreen } from "../../../src/features/profile/screens";
|
||||||
|
|
||||||
|
export default PreferencesScreen;
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { ProfileSettingsScreen } from "../../../src/features/profile/screens";
|
||||||
|
|
||||||
|
export default ProfileSettingsScreen;
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { SaveAccountScreen } from "../../../src/features/profile/screens";
|
||||||
|
|
||||||
|
export default SaveAccountScreen;
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { SubscriptionScreen } from "../../../src/features/profile/screens";
|
||||||
|
|
||||||
|
export default SubscriptionScreen;
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
import { SupportScreen } from "../../../src/features/profile/screens";
|
||||||
|
|
||||||
|
export default SupportScreen;
|
||||||
@@ -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",
|
||||||
|
},
|
||||||
});
|
});
|
||||||
+180
-10
@@ -1,17 +1,12 @@
|
|||||||
// Polyfills must be imported FIRST before any other React Native imports
|
// Polyfills must be imported FIRST before any other React Native imports
|
||||||
import "../src/polyfills/web";
|
import "../src/polyfills/web";
|
||||||
import "../src/polyfills/crypto";
|
|
||||||
|
|
||||||
// Initialize Sentry early to capture all errors
|
// Initialize Sentry early to capture all errors
|
||||||
import { initSentry, setUserContext, clearUserContext } from "../src/lib/sentry";
|
import { initSentry, setUserContext, clearUserContext } from "../src/lib/sentry";
|
||||||
initSentry();
|
initSentry();
|
||||||
|
|
||||||
// Initialize Analytics
|
// Analytics imports (initialization deferred until app mounts)
|
||||||
import { initAnalytics, setUserId, clearUserId, logScreenView, Events } from "../src/lib/analytics";
|
import { initAnalytics, setUserId, clearUserId, logScreenView, Events } from "../src/lib/analytics";
|
||||||
initAnalytics();
|
|
||||||
|
|
||||||
// Track cold start immediately
|
|
||||||
Events.appOpened("cold");
|
|
||||||
|
|
||||||
// Import push notification utilities
|
// Import push notification utilities
|
||||||
import {
|
import {
|
||||||
@@ -21,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") {
|
||||||
@@ -39,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"
|
||||||
@@ -48,13 +44,34 @@ 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 [showGuestWarning, setShowGuestWarning] = useState(false);
|
||||||
|
const [checkingGuestWarning, setCheckingGuestWarning] = useState(false);
|
||||||
|
|
||||||
|
const dismissGuestWarning = async () => {
|
||||||
|
await markGuestAccountWarningSeen();
|
||||||
|
setShowGuestWarning(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
// Initialize analytics after native modules are ready
|
||||||
|
useEffect(() => {
|
||||||
|
if (!analyticsInitialized.current) {
|
||||||
|
analyticsInitialized.current = true;
|
||||||
|
initAnalytics();
|
||||||
|
Events.appOpened("cold");
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
// Track screen views
|
// Track screen views
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -63,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) => {
|
||||||
@@ -176,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
|
||||||
@@ -218,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",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
@@ -5,13 +5,16 @@
|
|||||||
},
|
},
|
||||||
"build": {
|
"build": {
|
||||||
"development": {
|
"development": {
|
||||||
|
"node": "20.10.0",
|
||||||
"developmentClient": true,
|
"developmentClient": true,
|
||||||
"distribution": "internal"
|
"distribution": "internal"
|
||||||
},
|
},
|
||||||
"preview": {
|
"preview": {
|
||||||
|
"node": "20.10.0",
|
||||||
"distribution": "internal"
|
"distribution": "internal"
|
||||||
},
|
},
|
||||||
"production": {
|
"production": {
|
||||||
|
"node": "20.10.0",
|
||||||
"autoIncrement": true,
|
"autoIncrement": true,
|
||||||
"env": {
|
"env": {
|
||||||
"EXPO_PUBLIC_SUPABASE_URL": "https://ckjcrkjpmhqhiucifukx.supabase.co",
|
"EXPO_PUBLIC_SUPABASE_URL": "https://ckjcrkjpmhqhiucifukx.supabase.co",
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"react-native": {
|
||||||
|
"analytics_auto_collection_enabled": true,
|
||||||
|
"google_analytics_adid_collection_enabled": false
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"oauth_client": [
|
"oauth_client": [
|
||||||
|
{
|
||||||
|
"client_id": "764866133492-03k7q0mq6nqrv0phunncqjn85cgj6sep.apps.googleusercontent.com",
|
||||||
|
"client_type": 1,
|
||||||
|
"android_info": {
|
||||||
|
"package_name": "com.sauci.app",
|
||||||
|
"certificate_hash": "f06026c754b046f280feac438a6910bd9a62b459"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"client_id": "764866133492-4g566mrb8tm5uti4ejub6oogcvnln7kc.apps.googleusercontent.com",
|
"client_id": "764866133492-4g566mrb8tm5uti4ejub6oogcvnln7kc.apps.googleusercontent.com",
|
||||||
"client_type": 1,
|
"client_type": 1,
|
||||||
|
|||||||
+187
-81
@@ -164,6 +164,7 @@ PODS:
|
|||||||
- React-jsinspector
|
- React-jsinspector
|
||||||
- React-NativeModulesApple
|
- React-NativeModulesApple
|
||||||
- React-RCTFabric
|
- React-RCTFabric
|
||||||
|
- React-rendererconsistency
|
||||||
- React-rendererdebug
|
- React-rendererdebug
|
||||||
- React-utils
|
- React-utils
|
||||||
- ReactCodegen
|
- ReactCodegen
|
||||||
@@ -303,19 +304,81 @@ PODS:
|
|||||||
- ExpoModulesCore
|
- ExpoModulesCore
|
||||||
- fast_float (6.1.4)
|
- fast_float (6.1.4)
|
||||||
- FBLazyVector (0.76.9)
|
- FBLazyVector (0.76.9)
|
||||||
|
- Firebase/CoreOnly (12.8.0):
|
||||||
|
- FirebaseCore (~> 12.8.0)
|
||||||
|
- FirebaseAnalytics/Core (12.8.0):
|
||||||
|
- FirebaseCore (~> 12.8.0)
|
||||||
|
- FirebaseInstallations (~> 12.8.0)
|
||||||
|
- GoogleAppMeasurement/Core (= 12.8.0)
|
||||||
|
- GoogleUtilities/AppDelegateSwizzler (~> 8.1)
|
||||||
|
- GoogleUtilities/MethodSwizzler (~> 8.1)
|
||||||
|
- GoogleUtilities/Network (~> 8.1)
|
||||||
|
- "GoogleUtilities/NSData+zlib (~> 8.1)"
|
||||||
|
- nanopb (~> 3.30910.0)
|
||||||
|
- FirebaseAnalytics/IdentitySupport (12.8.0):
|
||||||
|
- FirebaseCore (~> 12.8.0)
|
||||||
|
- FirebaseInstallations (~> 12.8.0)
|
||||||
|
- GoogleAppMeasurement/IdentitySupport (= 12.8.0)
|
||||||
|
- GoogleUtilities/AppDelegateSwizzler (~> 8.1)
|
||||||
|
- GoogleUtilities/MethodSwizzler (~> 8.1)
|
||||||
|
- GoogleUtilities/Network (~> 8.1)
|
||||||
|
- "GoogleUtilities/NSData+zlib (~> 8.1)"
|
||||||
|
- nanopb (~> 3.30910.0)
|
||||||
|
- FirebaseCore (12.8.0):
|
||||||
|
- FirebaseCoreInternal (~> 12.8.0)
|
||||||
|
- GoogleUtilities/Environment (~> 8.1)
|
||||||
|
- GoogleUtilities/Logger (~> 8.1)
|
||||||
|
- FirebaseCoreInternal (12.8.0):
|
||||||
|
- "GoogleUtilities/NSData+zlib (~> 8.1)"
|
||||||
|
- FirebaseInstallations (12.8.0):
|
||||||
|
- FirebaseCore (~> 12.8.0)
|
||||||
|
- GoogleUtilities/Environment (~> 8.1)
|
||||||
|
- GoogleUtilities/UserDefaults (~> 8.1)
|
||||||
|
- PromisesObjC (~> 2.4)
|
||||||
- fmt (11.0.2)
|
- fmt (11.0.2)
|
||||||
- glog (0.3.5)
|
- glog (0.3.5)
|
||||||
- GoogleSignIn (9.0.0):
|
- GoogleAppMeasurement/Core (12.8.0):
|
||||||
|
- GoogleUtilities/AppDelegateSwizzler (~> 8.1)
|
||||||
|
- GoogleUtilities/MethodSwizzler (~> 8.1)
|
||||||
|
- GoogleUtilities/Network (~> 8.1)
|
||||||
|
- "GoogleUtilities/NSData+zlib (~> 8.1)"
|
||||||
|
- nanopb (~> 3.30910.0)
|
||||||
|
- GoogleAppMeasurement/IdentitySupport (12.8.0):
|
||||||
|
- GoogleAppMeasurement/Core (= 12.8.0)
|
||||||
|
- GoogleUtilities/AppDelegateSwizzler (~> 8.1)
|
||||||
|
- GoogleUtilities/MethodSwizzler (~> 8.1)
|
||||||
|
- GoogleUtilities/Network (~> 8.1)
|
||||||
|
- "GoogleUtilities/NSData+zlib (~> 8.1)"
|
||||||
|
- nanopb (~> 3.30910.0)
|
||||||
|
- GoogleSignIn (9.1.0):
|
||||||
- AppAuth (~> 2.0)
|
- AppAuth (~> 2.0)
|
||||||
- AppCheckCore (~> 11.0)
|
- AppCheckCore (~> 11.0)
|
||||||
- GTMAppAuth (~> 5.0)
|
- GTMAppAuth (~> 5.0)
|
||||||
- GTMSessionFetcher/Core (~> 3.3)
|
- GTMSessionFetcher/Core (~> 3.3)
|
||||||
|
- GoogleUtilities/AppDelegateSwizzler (8.1.0):
|
||||||
|
- GoogleUtilities/Environment
|
||||||
|
- GoogleUtilities/Logger
|
||||||
|
- GoogleUtilities/Network
|
||||||
|
- GoogleUtilities/Privacy
|
||||||
- GoogleUtilities/Environment (8.1.0):
|
- GoogleUtilities/Environment (8.1.0):
|
||||||
- GoogleUtilities/Privacy
|
- GoogleUtilities/Privacy
|
||||||
- GoogleUtilities/Logger (8.1.0):
|
- GoogleUtilities/Logger (8.1.0):
|
||||||
- GoogleUtilities/Environment
|
- GoogleUtilities/Environment
|
||||||
- GoogleUtilities/Privacy
|
- GoogleUtilities/Privacy
|
||||||
|
- GoogleUtilities/MethodSwizzler (8.1.0):
|
||||||
|
- GoogleUtilities/Logger
|
||||||
|
- GoogleUtilities/Privacy
|
||||||
|
- GoogleUtilities/Network (8.1.0):
|
||||||
|
- GoogleUtilities/Logger
|
||||||
|
- "GoogleUtilities/NSData+zlib"
|
||||||
|
- GoogleUtilities/Privacy
|
||||||
|
- GoogleUtilities/Reachability
|
||||||
|
- "GoogleUtilities/NSData+zlib (8.1.0)":
|
||||||
|
- GoogleUtilities/Privacy
|
||||||
- GoogleUtilities/Privacy (8.1.0)
|
- GoogleUtilities/Privacy (8.1.0)
|
||||||
|
- GoogleUtilities/Reachability (8.1.0):
|
||||||
|
- GoogleUtilities/Logger
|
||||||
|
- GoogleUtilities/Privacy
|
||||||
- GoogleUtilities/UserDefaults (8.1.0):
|
- GoogleUtilities/UserDefaults (8.1.0):
|
||||||
- GoogleUtilities/Logger
|
- GoogleUtilities/Logger
|
||||||
- GoogleUtilities/Privacy
|
- GoogleUtilities/Privacy
|
||||||
@@ -331,10 +394,14 @@ PODS:
|
|||||||
- libavif/core
|
- libavif/core
|
||||||
- libdav1d (>= 0.6.0)
|
- libdav1d (>= 0.6.0)
|
||||||
- libdav1d (1.2.0)
|
- libdav1d (1.2.0)
|
||||||
- OpenSSL-Universal (3.3.3001)
|
- nanopb (3.30910.0):
|
||||||
|
- nanopb/decode (= 3.30910.0)
|
||||||
|
- nanopb/encode (= 3.30910.0)
|
||||||
|
- nanopb/decode (3.30910.0)
|
||||||
|
- nanopb/encode (3.30910.0)
|
||||||
- PromisesObjC (2.4.0)
|
- PromisesObjC (2.4.0)
|
||||||
- PurchasesHybridCommon (17.25.0):
|
- PurchasesHybridCommon (17.28.0):
|
||||||
- RevenueCat (= 5.51.1)
|
- RevenueCat (= 5.55.1)
|
||||||
- RCT-Folly (2024.10.14.00):
|
- RCT-Folly (2024.10.14.00):
|
||||||
- boost
|
- boost
|
||||||
- DoubleConversion
|
- DoubleConversion
|
||||||
@@ -1613,7 +1680,7 @@ PODS:
|
|||||||
- ReactCommon/turbomodule/bridging
|
- ReactCommon/turbomodule/bridging
|
||||||
- ReactCommon/turbomodule/core
|
- ReactCommon/turbomodule/core
|
||||||
- Yoga
|
- Yoga
|
||||||
- react-native-compressor (1.13.0):
|
- react-native-compressor (1.16.0):
|
||||||
- DoubleConversion
|
- DoubleConversion
|
||||||
- glog
|
- glog
|
||||||
- hermes-engine
|
- hermes-engine
|
||||||
@@ -1634,29 +1701,6 @@ PODS:
|
|||||||
- ReactCommon/turbomodule/bridging
|
- ReactCommon/turbomodule/bridging
|
||||||
- ReactCommon/turbomodule/core
|
- ReactCommon/turbomodule/core
|
||||||
- Yoga
|
- Yoga
|
||||||
- react-native-quick-crypto (0.7.17):
|
|
||||||
- DoubleConversion
|
|
||||||
- glog
|
|
||||||
- hermes-engine
|
|
||||||
- OpenSSL-Universal
|
|
||||||
- RCT-Folly (= 2024.10.14.00)
|
|
||||||
- RCTRequired
|
|
||||||
- RCTTypeSafety
|
|
||||||
- React
|
|
||||||
- React-Core
|
|
||||||
- React-debug
|
|
||||||
- React-Fabric
|
|
||||||
- React-featureflags
|
|
||||||
- React-graphics
|
|
||||||
- React-ImageManager
|
|
||||||
- React-NativeModulesApple
|
|
||||||
- React-RCTFabric
|
|
||||||
- React-rendererdebug
|
|
||||||
- React-utils
|
|
||||||
- ReactCodegen
|
|
||||||
- ReactCommon/turbomodule/bridging
|
|
||||||
- ReactCommon/turbomodule/core
|
|
||||||
- Yoga
|
|
||||||
- react-native-safe-area-context (4.12.0):
|
- react-native-safe-area-context (4.12.0):
|
||||||
- React-Core
|
- React-Core
|
||||||
- React-nativeconfig (0.76.9)
|
- React-nativeconfig (0.76.9)
|
||||||
@@ -1931,9 +1975,55 @@ PODS:
|
|||||||
- React-logger
|
- React-logger
|
||||||
- React-perflogger
|
- React-perflogger
|
||||||
- React-utils (= 0.76.9)
|
- React-utils (= 0.76.9)
|
||||||
- RevenueCat (5.51.1)
|
- RevenueCat (5.55.1)
|
||||||
- RNCAsyncStorage (1.23.1):
|
- RNCAsyncStorage (1.23.1):
|
||||||
- React-Core
|
- React-Core
|
||||||
|
- RNFBAnalytics (23.8.3):
|
||||||
|
- DoubleConversion
|
||||||
|
- FirebaseAnalytics/Core (= 12.8.0)
|
||||||
|
- FirebaseAnalytics/IdentitySupport (= 12.8.0)
|
||||||
|
- glog
|
||||||
|
- hermes-engine
|
||||||
|
- RCT-Folly (= 2024.10.14.00)
|
||||||
|
- RCTRequired
|
||||||
|
- RCTTypeSafety
|
||||||
|
- React-Core
|
||||||
|
- React-debug
|
||||||
|
- React-Fabric
|
||||||
|
- React-featureflags
|
||||||
|
- React-graphics
|
||||||
|
- React-ImageManager
|
||||||
|
- React-NativeModulesApple
|
||||||
|
- React-RCTFabric
|
||||||
|
- React-rendererdebug
|
||||||
|
- React-utils
|
||||||
|
- ReactCodegen
|
||||||
|
- ReactCommon/turbomodule/bridging
|
||||||
|
- ReactCommon/turbomodule/core
|
||||||
|
- RNFBApp
|
||||||
|
- Yoga
|
||||||
|
- RNFBApp (23.8.3):
|
||||||
|
- DoubleConversion
|
||||||
|
- Firebase/CoreOnly (= 12.8.0)
|
||||||
|
- glog
|
||||||
|
- hermes-engine
|
||||||
|
- RCT-Folly (= 2024.10.14.00)
|
||||||
|
- RCTRequired
|
||||||
|
- RCTTypeSafety
|
||||||
|
- React-Core
|
||||||
|
- React-debug
|
||||||
|
- React-Fabric
|
||||||
|
- React-featureflags
|
||||||
|
- React-graphics
|
||||||
|
- React-ImageManager
|
||||||
|
- React-NativeModulesApple
|
||||||
|
- React-RCTFabric
|
||||||
|
- React-rendererdebug
|
||||||
|
- React-utils
|
||||||
|
- ReactCodegen
|
||||||
|
- ReactCommon/turbomodule/bridging
|
||||||
|
- ReactCommon/turbomodule/core
|
||||||
|
- Yoga
|
||||||
- RNGestureHandler (2.20.2):
|
- RNGestureHandler (2.20.2):
|
||||||
- DoubleConversion
|
- DoubleConversion
|
||||||
- glog
|
- glog
|
||||||
@@ -1958,8 +2048,8 @@ PODS:
|
|||||||
- RNGoogleSignin (16.1.1):
|
- RNGoogleSignin (16.1.1):
|
||||||
- GoogleSignIn (~> 9.0)
|
- GoogleSignIn (~> 9.0)
|
||||||
- React-Core
|
- React-Core
|
||||||
- RNPurchases (9.6.12):
|
- RNPurchases (9.7.1):
|
||||||
- PurchasesHybridCommon (= 17.25.0)
|
- PurchasesHybridCommon (= 17.28.0)
|
||||||
- React-Core
|
- React-Core
|
||||||
- RNReanimated (3.16.7):
|
- RNReanimated (3.16.7):
|
||||||
- DoubleConversion
|
- DoubleConversion
|
||||||
@@ -2176,7 +2266,6 @@ DEPENDENCIES:
|
|||||||
- React-Mapbuffer (from `../../../node_modules/react-native/ReactCommon`)
|
- React-Mapbuffer (from `../../../node_modules/react-native/ReactCommon`)
|
||||||
- React-microtasksnativemodule (from `../../../node_modules/react-native/ReactCommon/react/nativemodule/microtasks`)
|
- React-microtasksnativemodule (from `../../../node_modules/react-native/ReactCommon/react/nativemodule/microtasks`)
|
||||||
- react-native-compressor (from `../../../node_modules/react-native-compressor`)
|
- react-native-compressor (from `../../../node_modules/react-native-compressor`)
|
||||||
- react-native-quick-crypto (from `../../../node_modules/react-native-quick-crypto`)
|
|
||||||
- react-native-safe-area-context (from `../../../node_modules/react-native-safe-area-context`)
|
- react-native-safe-area-context (from `../../../node_modules/react-native-safe-area-context`)
|
||||||
- React-nativeconfig (from `../../../node_modules/react-native/ReactCommon`)
|
- React-nativeconfig (from `../../../node_modules/react-native/ReactCommon`)
|
||||||
- React-NativeModulesApple (from `../../../node_modules/react-native/ReactCommon/react/nativemodule/core/platform/ios`)
|
- React-NativeModulesApple (from `../../../node_modules/react-native/ReactCommon/react/nativemodule/core/platform/ios`)
|
||||||
@@ -2206,6 +2295,8 @@ DEPENDENCIES:
|
|||||||
- ReactCodegen (from `build/generated/ios`)
|
- ReactCodegen (from `build/generated/ios`)
|
||||||
- ReactCommon/turbomodule/core (from `../../../node_modules/react-native/ReactCommon`)
|
- ReactCommon/turbomodule/core (from `../../../node_modules/react-native/ReactCommon`)
|
||||||
- "RNCAsyncStorage (from `../../../node_modules/@react-native-async-storage/async-storage`)"
|
- "RNCAsyncStorage (from `../../../node_modules/@react-native-async-storage/async-storage`)"
|
||||||
|
- "RNFBAnalytics (from `../../../node_modules/@react-native-firebase/analytics`)"
|
||||||
|
- "RNFBApp (from `../../../node_modules/@react-native-firebase/app`)"
|
||||||
- RNGestureHandler (from `../../../node_modules/react-native-gesture-handler`)
|
- RNGestureHandler (from `../../../node_modules/react-native-gesture-handler`)
|
||||||
- "RNGoogleSignin (from `../../../node_modules/@react-native-google-signin/google-signin`)"
|
- "RNGoogleSignin (from `../../../node_modules/@react-native-google-signin/google-signin`)"
|
||||||
- RNPurchases (from `../../../node_modules/react-native-purchases`)
|
- RNPurchases (from `../../../node_modules/react-native-purchases`)
|
||||||
@@ -2218,13 +2309,19 @@ SPEC REPOS:
|
|||||||
trunk:
|
trunk:
|
||||||
- AppAuth
|
- AppAuth
|
||||||
- AppCheckCore
|
- AppCheckCore
|
||||||
|
- Firebase
|
||||||
|
- FirebaseAnalytics
|
||||||
|
- FirebaseCore
|
||||||
|
- FirebaseCoreInternal
|
||||||
|
- FirebaseInstallations
|
||||||
|
- GoogleAppMeasurement
|
||||||
- GoogleSignIn
|
- GoogleSignIn
|
||||||
- GoogleUtilities
|
- GoogleUtilities
|
||||||
- GTMAppAuth
|
- GTMAppAuth
|
||||||
- GTMSessionFetcher
|
- GTMSessionFetcher
|
||||||
- libavif
|
- libavif
|
||||||
- libdav1d
|
- libdav1d
|
||||||
- OpenSSL-Universal
|
- nanopb
|
||||||
- PromisesObjC
|
- PromisesObjC
|
||||||
- PurchasesHybridCommon
|
- PurchasesHybridCommon
|
||||||
- RevenueCat
|
- RevenueCat
|
||||||
@@ -2372,8 +2469,6 @@ EXTERNAL SOURCES:
|
|||||||
:path: "../../../node_modules/react-native/ReactCommon/react/nativemodule/microtasks"
|
:path: "../../../node_modules/react-native/ReactCommon/react/nativemodule/microtasks"
|
||||||
react-native-compressor:
|
react-native-compressor:
|
||||||
:path: "../../../node_modules/react-native-compressor"
|
:path: "../../../node_modules/react-native-compressor"
|
||||||
react-native-quick-crypto:
|
|
||||||
:path: "../../../node_modules/react-native-quick-crypto"
|
|
||||||
react-native-safe-area-context:
|
react-native-safe-area-context:
|
||||||
:path: "../../../node_modules/react-native-safe-area-context"
|
:path: "../../../node_modules/react-native-safe-area-context"
|
||||||
React-nativeconfig:
|
React-nativeconfig:
|
||||||
@@ -2432,6 +2527,10 @@ EXTERNAL SOURCES:
|
|||||||
:path: "../../../node_modules/react-native/ReactCommon"
|
:path: "../../../node_modules/react-native/ReactCommon"
|
||||||
RNCAsyncStorage:
|
RNCAsyncStorage:
|
||||||
:path: "../../../node_modules/@react-native-async-storage/async-storage"
|
:path: "../../../node_modules/@react-native-async-storage/async-storage"
|
||||||
|
RNFBAnalytics:
|
||||||
|
:path: "../../../node_modules/@react-native-firebase/analytics"
|
||||||
|
RNFBApp:
|
||||||
|
:path: "../../../node_modules/@react-native-firebase/app"
|
||||||
RNGestureHandler:
|
RNGestureHandler:
|
||||||
:path: "../../../node_modules/react-native-gesture-handler"
|
:path: "../../../node_modules/react-native-gesture-handler"
|
||||||
RNGoogleSignin:
|
RNGoogleSignin:
|
||||||
@@ -2461,8 +2560,8 @@ SPEC CHECKSUMS:
|
|||||||
EXNotifications: 9d3d17e52c95f377750d4a2ae553a716313dd4aa
|
EXNotifications: 9d3d17e52c95f377750d4a2ae553a716313dd4aa
|
||||||
Expo: 3889933926fade17d8607f449c57b87c4022433f
|
Expo: 3889933926fade17d8607f449c57b87c4022433f
|
||||||
expo-dev-client: db44302cdbe0ec55b0ef1849c9a23a76dec6dbac
|
expo-dev-client: db44302cdbe0ec55b0ef1849c9a23a76dec6dbac
|
||||||
expo-dev-launcher: 82de1bf29e91f4794bcc65a697e7066d9a826bee
|
expo-dev-launcher: c67c6a87f8a8ff54abda104abdc057ee4565a3f4
|
||||||
expo-dev-menu: 3fda3eb2b3394dc92955f6ff60cb278f843641be
|
expo-dev-menu: 1426660b67d2d594f70ec1d8a85bc26cafcf1bce
|
||||||
expo-dev-menu-interface: 00dc42302a72722fdecec3fa048de84a9133bcc4
|
expo-dev-menu-interface: 00dc42302a72722fdecec3fa048de84a9133bcc4
|
||||||
ExpoAdapterGoogleSignIn: 21ff1daeef3d21cb0b45772d8029102cc728d6b1
|
ExpoAdapterGoogleSignIn: 21ff1daeef3d21cb0b45772d8029102cc728d6b1
|
||||||
ExpoAppleAuthentication: 52631ed9dcb71c65712a447bbb9a5667bb8fcf0c
|
ExpoAppleAuthentication: 52631ed9dcb71c65712a447bbb9a5667bb8fcf0c
|
||||||
@@ -2480,23 +2579,29 @@ SPEC CHECKSUMS:
|
|||||||
ExpoLinking: 8d12bee174ba0cdf31239706578e29e74a417402
|
ExpoLinking: 8d12bee174ba0cdf31239706578e29e74a417402
|
||||||
ExpoLocalAuthentication: eb2be9c7bcdc68e9434d4be4bb5aa5cf7943e816
|
ExpoLocalAuthentication: eb2be9c7bcdc68e9434d4be4bb5aa5cf7943e816
|
||||||
ExpoMediaLibrary: 2709fc2e4fcf87d28767714abcc603c4bec228c6
|
ExpoMediaLibrary: 2709fc2e4fcf87d28767714abcc603c4bec228c6
|
||||||
ExpoModulesCore: 725faec070d590810d2ea5983d9f78f7cf6a38ec
|
ExpoModulesCore: 2d8d1c8e238bd5e1448b06c284ac896b162fb856
|
||||||
ExpoSecureStore: 9a3665d7161dd1031be386e278bffd623658f316
|
ExpoSecureStore: 9a3665d7161dd1031be386e278bffd623658f316
|
||||||
EXUpdatesInterface: 7c977640bdd8b85833c19e3959ba46145c5719db
|
EXUpdatesInterface: 7c977640bdd8b85833c19e3959ba46145c5719db
|
||||||
fast_float: 06eeec4fe712a76acc9376682e4808b05ce978b6
|
fast_float: 06eeec4fe712a76acc9376682e4808b05ce978b6
|
||||||
FBLazyVector: 7605ea4810e0e10ae4815292433c09bf4324ba45
|
FBLazyVector: 7605ea4810e0e10ae4815292433c09bf4324ba45
|
||||||
|
Firebase: 9a58fdbc9d8655ed7b79a19cf9690bb007d3d46d
|
||||||
|
FirebaseAnalytics: f20bbad8cb7f65d8a5eaefeb424ae8800a31bdfc
|
||||||
|
FirebaseCore: 0dbad74bda10b8fb9ca34ad8f375fb9dd3ebef7c
|
||||||
|
FirebaseCoreInternal: fe5fa466aeb314787093a7dce9f0beeaad5a2a21
|
||||||
|
FirebaseInstallations: 6a14ab3d694ebd9f839c48d330da5547e9ca9dc0
|
||||||
fmt: 01b82d4ca6470831d1cc0852a1af644be019e8f6
|
fmt: 01b82d4ca6470831d1cc0852a1af644be019e8f6
|
||||||
glog: 08b301085f15bcbb6ff8632a8ebaf239aae04e6a
|
glog: 08b301085f15bcbb6ff8632a8ebaf239aae04e6a
|
||||||
GoogleSignIn: c7f09cfbc85a1abf69187be091997c317cc33b77
|
GoogleAppMeasurement: 72c9a682fec6290327ea5e3c4b829b247fcb2c17
|
||||||
|
GoogleSignIn: fcee2257188d5eda57a5e2b6a715550ffff9206d
|
||||||
GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1
|
GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1
|
||||||
GTMAppAuth: 217a876b249c3c585a54fd6f73e6b58c4f5c4238
|
GTMAppAuth: 217a876b249c3c585a54fd6f73e6b58c4f5c4238
|
||||||
GTMSessionFetcher: 5aea5ba6bd522a239e236100971f10cb71b96ab6
|
GTMSessionFetcher: 5aea5ba6bd522a239e236100971f10cb71b96ab6
|
||||||
hermes-engine: 9e868dc7be781364296d6ee2f56d0c1a9ef0bb11
|
hermes-engine: 9e868dc7be781364296d6ee2f56d0c1a9ef0bb11
|
||||||
libavif: 84bbb62fb232c3018d6f1bab79beea87e35de7b7
|
libavif: 84bbb62fb232c3018d6f1bab79beea87e35de7b7
|
||||||
libdav1d: 23581a4d8ec811ff171ed5e2e05cd27bad64c39f
|
libdav1d: 23581a4d8ec811ff171ed5e2e05cd27bad64c39f
|
||||||
OpenSSL-Universal: 6082b0bf950e5636fe0d78def171184e2b3899c2
|
nanopb: fad817b59e0457d11a5dfbde799381cd727c1275
|
||||||
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
|
PromisesObjC: f5707f49cb48b9636751c5b2e7d227e43fba9f47
|
||||||
PurchasesHybridCommon: 6a79a873ab52f777bfa36e9516f3fcd84d3b3428
|
PurchasesHybridCommon: c2f8f5fcd2e472c585fccd1ab2fe317a3dc1171e
|
||||||
RCT-Folly: ea9d9256ba7f9322ef911169a9f696e5857b9e17
|
RCT-Folly: ea9d9256ba7f9322ef911169a9f696e5857b9e17
|
||||||
RCTDeprecation: ebe712bb05077934b16c6bf25228bdec34b64f83
|
RCTDeprecation: ebe712bb05077934b16c6bf25228bdec34b64f83
|
||||||
RCTRequired: ca91e5dd26b64f577b528044c962baf171c6b716
|
RCTRequired: ca91e5dd26b64f577b528044c962baf171c6b716
|
||||||
@@ -2506,70 +2611,71 @@ SPEC CHECKSUMS:
|
|||||||
React-Core: a68cea3e762814e60ecc3fa521c7f14c36c99245
|
React-Core: a68cea3e762814e60ecc3fa521c7f14c36c99245
|
||||||
React-CoreModules: d81b1eaf8066add66299bab9d23c9f00c9484c7c
|
React-CoreModules: d81b1eaf8066add66299bab9d23c9f00c9484c7c
|
||||||
React-cxxreact: 984f8b1feeca37181d4e95301fcd6f5f6501c6ab
|
React-cxxreact: 984f8b1feeca37181d4e95301fcd6f5f6501c6ab
|
||||||
React-debug: 817160c07dc8d24d020fbd1eac7b3558ffc08964
|
React-debug: f1956672cf545729afb71ef838a9789c2a9088f5
|
||||||
React-defaultsnativemodule: 21f216e8db975897eb32b5f13247f5bbfaa97f41
|
React-defaultsnativemodule: 30db2698d8b37831cf97098978ddc8ea97251898
|
||||||
React-domnativemodule: 19270ad4b8d33312838d257f24731a0026809d49
|
React-domnativemodule: 1c018f599b60aa292d764bcee1c52716919940c6
|
||||||
React-Fabric: f6dade7007533daeb785ba5925039d83f343be4b
|
React-Fabric: f637bbb636e3348a66d269f179cd39be1d31ee3d
|
||||||
React-FabricComponents: b0655cc3e1b5ae12a4a1119aa7d8308f0ad33520
|
React-FabricComponents: 9542268e7cc59a49f0c2c1906d8ab9729a979b5a
|
||||||
React-FabricImage: 9b157c4c01ac2bf433f834f0e1e5fe234113a576
|
React-FabricImage: e2f72efe22e23ff3740502553614f1c2d86132c9
|
||||||
React-featureflags: f2792b067a351d86fdc7bec23db3b9a2f2c8d26c
|
React-featureflags: dc9c91fcd6a75cc95bd7fec184d0be8ad0f26db5
|
||||||
React-featureflagsnativemodule: 3a8731d8fd9f755be57e00d9fa8a7f92aa77e87d
|
React-featureflagsnativemodule: 1c6ca282bd47de71a16420e29e7de28d46e59110
|
||||||
React-graphics: 68969e4e49d73f89da7abef4116c9b5f466aa121
|
React-graphics: 85866d95589b775bcb5f02946a96e948f53088f1
|
||||||
React-hermes: ac0bcba26a5d288ebc99b500e1097da2d0297ddf
|
React-hermes: ac0bcba26a5d288ebc99b500e1097da2d0297ddf
|
||||||
React-idlecallbacksnativemodule: 9a2c5b5c174c0c476f039bedc1b9497a8272133e
|
React-idlecallbacksnativemodule: d8e52f9e2bbc15f57714a6def9ba7ecea0f59f9f
|
||||||
React-ImageManager: e906eec93a9eb6102a06576b89d48d80a4683020
|
React-ImageManager: 5b3843bea322e4884400f1d78cfde7c435c374bc
|
||||||
React-jserrorhandler: ac5dde01104ff444e043cad8f574ca02756e20d6
|
React-jserrorhandler: 23161cf9fef2da6d56bf5cfda14f4abc8d62b286
|
||||||
React-jsi: 496fa2b9d63b726aeb07d0ac800064617d71211d
|
React-jsi: 496fa2b9d63b726aeb07d0ac800064617d71211d
|
||||||
React-jsiexecutor: dd22ab48371b80f37a0a30d0e8915b6d0f43a893
|
React-jsiexecutor: dd22ab48371b80f37a0a30d0e8915b6d0f43a893
|
||||||
React-jsinspector: 4629ac376f5765e684d19064f2093e55c97fd086
|
React-jsinspector: 3f8d148785a57f082e71382657c5fbe1be75bcdf
|
||||||
React-jsitracing: 7a1c9cd484248870cf660733cd3b8114d54c035f
|
React-jsitracing: 69456514c86311eb042d824e4cf69f9b524b5b97
|
||||||
React-logger: c4052eb941cca9a097ef01b59543a656dc088559
|
React-logger: c4052eb941cca9a097ef01b59543a656dc088559
|
||||||
React-Mapbuffer: 33546a3ebefbccb8770c33a1f8a5554fa96a54de
|
React-Mapbuffer: 9343a5c14536d4463c80f09a960653d754daae21
|
||||||
React-microtasksnativemodule: 5c3d795318c22ab8df55100e50b151384a4a60b3
|
React-microtasksnativemodule: 338bfa0a7d8cd1ed212b055f2143a6789252137a
|
||||||
react-native-compressor: 2c3a41768bdd367f6e121f555dff9d3eda1810e5
|
react-native-compressor: 40b3e2b942aebac3daaae9549b23a13510c67a6f
|
||||||
react-native-quick-crypto: 7bb26af9b3705dbb9277c09305104d37ede74916
|
|
||||||
react-native-safe-area-context: 8b8404e70b0cbf2a56428a17017c14c1dcc16448
|
react-native-safe-area-context: 8b8404e70b0cbf2a56428a17017c14c1dcc16448
|
||||||
React-nativeconfig: 8efdb1ef1e9158c77098a93085438f7e7b463678
|
React-nativeconfig: 415626a63057638759bcc75e0a96e2e07771a479
|
||||||
React-NativeModulesApple: cebca2e5320a3d66e123cade23bd90a167ffce5e
|
React-NativeModulesApple: d33b55553c6957ff94835574636838d78121a1c6
|
||||||
React-perflogger: 72e653eb3aba9122f9e57cf012d22d2486f33358
|
React-perflogger: 72e653eb3aba9122f9e57cf012d22d2486f33358
|
||||||
React-performancetimeline: cd6a9374a72001165995d2ab632f672df04076dc
|
React-performancetimeline: f831af5af289407b87cdfbceafb7623e5960f9f3
|
||||||
React-RCTActionSheet: aacf2375084dea6e7c221f4a727e579f732ff342
|
React-RCTActionSheet: aacf2375084dea6e7c221f4a727e579f732ff342
|
||||||
React-RCTAnimation: 395ab53fd064dff81507c15efb781c8684d9a585
|
React-RCTAnimation: 395ab53fd064dff81507c15efb781c8684d9a585
|
||||||
React-RCTAppDelegate: 1e5b43833e3e36e9fa34eec20be98174bc0e14a2
|
React-RCTAppDelegate: 1e5b43833e3e36e9fa34eec20be98174bc0e14a2
|
||||||
React-RCTBlob: 13311e554c1a367de063c10ee7c5e6573b2dd1d6
|
React-RCTBlob: 13311e554c1a367de063c10ee7c5e6573b2dd1d6
|
||||||
React-RCTFabric: bd906861a4e971e21d8df496c2d8f3ca6956f840
|
React-RCTFabric: 07e1434e6bd8124460c4b4eb3bdb82b58bfafaee
|
||||||
React-RCTImage: 1b1f914bcc12187c49ba5d949dac38c2eb9f5cc8
|
React-RCTImage: 1b1f914bcc12187c49ba5d949dac38c2eb9f5cc8
|
||||||
React-RCTLinking: 4ac7c42beb65e36fba0376f3498f3cd8dd0be7fa
|
React-RCTLinking: 4ac7c42beb65e36fba0376f3498f3cd8dd0be7fa
|
||||||
React-RCTNetwork: 938902773add4381e84426a7aa17a2414f5f94f7
|
React-RCTNetwork: 938902773add4381e84426a7aa17a2414f5f94f7
|
||||||
React-RCTSettings: e848f1ba17a7a18479cf5a31d28145f567da8223
|
React-RCTSettings: e848f1ba17a7a18479cf5a31d28145f567da8223
|
||||||
React-RCTText: 7e98fafdde7d29e888b80f0b35544e0cb07913cf
|
React-RCTText: 7e98fafdde7d29e888b80f0b35544e0cb07913cf
|
||||||
React-RCTVibration: cd7d80affd97dc7afa62f9acd491419558b64b78
|
React-RCTVibration: cd7d80affd97dc7afa62f9acd491419558b64b78
|
||||||
React-rendererconsistency: b4917053ecbaa91469c67a4319701c9dc0d40be6
|
React-rendererconsistency: b89c074e22ca2b9eaa8013e1310c69db85364893
|
||||||
React-rendererdebug: aa181c36dd6cf5b35511d1ed875d6638fd38f0ec
|
React-rendererdebug: 25290691d85200d9d229f2c41c24b285a9f04e4b
|
||||||
React-rncore: 120d21715c9b4ba8f798bffe986cb769b988dd74
|
React-rncore: 2a6efd857e10a3b299f8a7fe912fd332aa7e515a
|
||||||
React-RuntimeApple: d033becbbd1eba6f9f6e3af6f1893030ce203edd
|
React-RuntimeApple: 383f3b59440644f555d0a475379c5ee97c3173f6
|
||||||
React-RuntimeCore: 38af280bb678e66ba000a3c3d42920b2a138eebb
|
React-RuntimeCore: 753cb0bcae285509787d465d176c074ef7a6c537
|
||||||
React-runtimeexecutor: 877596f82f5632d073e121cba2d2084b76a76899
|
React-runtimeexecutor: 877596f82f5632d073e121cba2d2084b76a76899
|
||||||
React-RuntimeHermes: 37aad735ff21ca6de2d8450a96de1afe9f86c385
|
React-RuntimeHermes: bad4e404e8a6c25bc6ebcfd5eccb671bed76a67d
|
||||||
React-runtimescheduler: 8ec34cc885281a34696ea16c4fd86892d631f38d
|
React-runtimescheduler: a352af9ab3939273ee0e02650cfc1c8ee6e4d0c9
|
||||||
React-timing: 331cbf9f2668c67faddfd2e46bb7f41cbd9320b9
|
React-timing: a693c531e5627dcc200fc7286cbbebf73d73469d
|
||||||
React-utils: ed818f19ab445000d6b5c4efa9d462449326cc9f
|
React-utils: 59c5bbbc0e72be22c9d6eceb40afadf9be872819
|
||||||
ReactCodegen: 2a46abb2e345dc8efaff0a724f5f8639230eb974
|
ReactCodegen: 52b654894b5771d0085b24d4b1b897ebeaf939dc
|
||||||
ReactCommon: 300d8d9c5cb1a6cd79a67cf5d8f91e4d477195f9
|
ReactCommon: b2eb96a61b826ff327a773a74357b302cf6da678
|
||||||
RevenueCat: eab035bbab271faccfef5c36eaff2a1ffef14dc0
|
RevenueCat: 347221fad5ab0ed5636546867deae2c32c64749f
|
||||||
RNCAsyncStorage: aa75595c1aefa18f868452091fa0c411a516ce11
|
RNCAsyncStorage: aa75595c1aefa18f868452091fa0c411a516ce11
|
||||||
RNGestureHandler: 32336458ef842088a459ccfcc10b583186de2633
|
RNFBAnalytics: d1c906bbb01b3a6f6b99d6eaa977f04b1ae5d279
|
||||||
|
RNFBApp: fecce94543a693bcc73807a83ce5e9d70ff68725
|
||||||
|
RNGestureHandler: 2dd124adb171aab16629e9387cf1830957d242c2
|
||||||
RNGoogleSignin: 5782dd7dad559608a06f3c6b1d4142c395fb5308
|
RNGoogleSignin: 5782dd7dad559608a06f3c6b1d4142c395fb5308
|
||||||
RNPurchases: cc3333cede6c0f040545a95c38ae627d0174c058
|
RNPurchases: e265e2ed27349b567b91b89d8712aa8c9c57f5b0
|
||||||
RNReanimated: 82bf292e3c590d2d9886039f83e1baf0c1b2cdc4
|
RNReanimated: 38e033fd1790c817678bc8a075277cdc49d6817c
|
||||||
RNScreens: 295d9c0aaeb7f680d03d7e9b476569a4959aae89
|
RNScreens: ad2d0cfb3f15c9ed56ffb43651366ec0dcd70c36
|
||||||
RNSentry: 41eab31e46a191a4f9296d64a318b2a6bf0f98c5
|
RNSentry: a8f0d86b88675719ce96cb31bd9258dbd793e535
|
||||||
SDWebImage: 8a6b7b160b4d710e2a22b6900e25301075c34cb3
|
SDWebImage: 8a6b7b160b4d710e2a22b6900e25301075c34cb3
|
||||||
SDWebImageAVIFCoder: afe194a084e851f70228e4be35ef651df0fc5c57
|
SDWebImageAVIFCoder: afe194a084e851f70228e4be35ef651df0fc5c57
|
||||||
SDWebImageSVGCoder: 15a300a97ec1c8ac958f009c02220ac0402e936c
|
SDWebImageSVGCoder: 15a300a97ec1c8ac958f009c02220ac0402e936c
|
||||||
Sentry: 1ca8405451040482877dcd344dfa3ef80b646631
|
Sentry: 1ca8405451040482877dcd344dfa3ef80b646631
|
||||||
SocketRocket: d4aabe649be1e368d1318fdf28a022d714d65748
|
SocketRocket: d4aabe649be1e368d1318fdf28a022d714d65748
|
||||||
Yoga: feb4910aba9742cfedc059e2b2902e22ffe9954a
|
Yoga: 1259c7a8cbaccf7b4c3ddf8ee36ca11be9dee407
|
||||||
|
|
||||||
PODFILE CHECKSUM: 085b90662c05f70d43685ff8bec1ea714a98abb3
|
PODFILE CHECKSUM: 085b90662c05f70d43685ff8bec1ea714a98abb3
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
"expo.jsEngine": "hermes",
|
"expo.jsEngine": "hermes",
|
||||||
"EX_DEV_CLIENT_NETWORK_INSPECTOR": "true",
|
"EX_DEV_CLIENT_NETWORK_INSPECTOR": "true",
|
||||||
"newArchEnabled": "false",
|
"newArchEnabled": "false",
|
||||||
|
"ios.useFrameworks": "static",
|
||||||
"apple.extraPods": "[]",
|
"apple.extraPods": "[]",
|
||||||
"apple.ccacheEnabled": "false",
|
"apple.ccacheEnabled": "false",
|
||||||
"apple.privacyManifestAggregationEnabled": "true"
|
"apple.privacyManifestAggregationEnabled": "true"
|
||||||
|
|||||||
@@ -3,36 +3,38 @@
|
|||||||
archiveVersion = 1;
|
archiveVersion = 1;
|
||||||
classes = {
|
classes = {
|
||||||
};
|
};
|
||||||
objectVersion = 54;
|
objectVersion = 46;
|
||||||
objects = {
|
objects = {
|
||||||
|
|
||||||
/* Begin PBXBuildFile section */
|
/* Begin PBXBuildFile section */
|
||||||
13B07FBC1A68108700A75B9A /* AppDelegate.mm in Sources */ = {isa = PBXBuildFile; fileRef = 13B07FB01A68108700A75B9A /* AppDelegate.mm */; };
|
13B07FBC1A68108700A75B9A /* AppDelegate.mm in Sources */ = {isa = PBXBuildFile; fileRef = 13B07FB01A68108700A75B9A /* AppDelegate.mm */; };
|
||||||
13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB51A68108700A75B9A /* Images.xcassets */; };
|
13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB51A68108700A75B9A /* Images.xcassets */; };
|
||||||
13B07FC11A68108700A75B9A /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = 13B07FB71A68108700A75B9A /* main.m */; };
|
13B07FC11A68108700A75B9A /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = 13B07FB71A68108700A75B9A /* main.m */; };
|
||||||
|
1AFB24071BC9A2F5B90C1951 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 787323E49260E99741AD6413 /* PrivacyInfo.xcprivacy */; };
|
||||||
|
1F3D2CE02DD8CC2E2BA9F93A /* Pods_Sauci.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 27B40580D18D29CBD94DD2C3 /* Pods_Sauci.framework */; };
|
||||||
3E461D99554A48A4959DE609 /* SplashScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */; };
|
3E461D99554A48A4959DE609 /* SplashScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */; };
|
||||||
656E142A44D7042462875BC2 /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 0FE9A3D4025061824A4C95F7 /* PrivacyInfo.xcprivacy */; };
|
47690AB7B3224CD3B1655332 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 2EDE49AC9AAC445892628B2B /* GoogleService-Info.plist */; };
|
||||||
96905EF65AED1B983A6B3ABC /* libPods-Sauci.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 58EEBF8E8E6FB1BC6CAF49B5 /* libPods-Sauci.a */; };
|
|
||||||
B18059E884C0ABDD17F3DC3D /* ExpoModulesProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAC715A2D49A985799AEE119 /* ExpoModulesProvider.swift */; };
|
B18059E884C0ABDD17F3DC3D /* ExpoModulesProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAC715A2D49A985799AEE119 /* ExpoModulesProvider.swift */; };
|
||||||
BB2F792D24A3F905000567C9 /* Expo.plist in Resources */ = {isa = PBXBuildFile; fileRef = BB2F792C24A3F905000567C9 /* Expo.plist */; };
|
BB2F792D24A3F905000567C9 /* Expo.plist in Resources */ = {isa = PBXBuildFile; fileRef = BB2F792C24A3F905000567C9 /* Expo.plist */; };
|
||||||
BBC1EC435C914364BBC9058D /* noop-file.swift in Sources */ = {isa = PBXBuildFile; fileRef = 157EFF60C4BA4BA694688B2F /* noop-file.swift */; };
|
E52FFD661AC14FC08C4F239D /* noop-file.swift in Sources */ = {isa = PBXBuildFile; fileRef = 31F85F9C76D34B859549F3A7 /* noop-file.swift */; };
|
||||||
/* End PBXBuildFile section */
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
/* Begin PBXFileReference section */
|
/* Begin PBXFileReference section */
|
||||||
0FE9A3D4025061824A4C95F7 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xml; name = PrivacyInfo.xcprivacy; path = Sauci/PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
|
|
||||||
13B07F961A680F5B00A75B9A /* Sauci.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Sauci.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
13B07F961A680F5B00A75B9A /* Sauci.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Sauci.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
13B07FAF1A68108700A75B9A /* AppDelegate.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = AppDelegate.h; path = Sauci/AppDelegate.h; sourceTree = "<group>"; };
|
13B07FAF1A68108700A75B9A /* AppDelegate.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = AppDelegate.h; path = Sauci/AppDelegate.h; sourceTree = "<group>"; };
|
||||||
13B07FB01A68108700A75B9A /* AppDelegate.mm */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.objcpp; name = AppDelegate.mm; path = Sauci/AppDelegate.mm; sourceTree = "<group>"; };
|
13B07FB01A68108700A75B9A /* AppDelegate.mm */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.cpp.objcpp; name = AppDelegate.mm; path = Sauci/AppDelegate.mm; sourceTree = "<group>"; };
|
||||||
13B07FB51A68108700A75B9A /* Images.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Images.xcassets; path = Sauci/Images.xcassets; sourceTree = "<group>"; };
|
13B07FB51A68108700A75B9A /* Images.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; name = Images.xcassets; path = Sauci/Images.xcassets; sourceTree = "<group>"; };
|
||||||
13B07FB61A68108700A75B9A /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = Info.plist; path = Sauci/Info.plist; sourceTree = "<group>"; };
|
13B07FB61A68108700A75B9A /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = Info.plist; path = Sauci/Info.plist; sourceTree = "<group>"; };
|
||||||
13B07FB71A68108700A75B9A /* main.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = main.m; path = Sauci/main.m; sourceTree = "<group>"; };
|
13B07FB71A68108700A75B9A /* main.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = main.m; path = Sauci/main.m; sourceTree = "<group>"; };
|
||||||
157EFF60C4BA4BA694688B2F /* noop-file.swift */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 4; includeInIndex = 0; lastKnownFileType = sourcecode.swift; name = "noop-file.swift"; path = "Sauci/noop-file.swift"; sourceTree = "<group>"; };
|
27B40580D18D29CBD94DD2C3 /* Pods_Sauci.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Sauci.framework; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||||
58EEBF8E8E6FB1BC6CAF49B5 /* libPods-Sauci.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-Sauci.a"; sourceTree = BUILT_PRODUCTS_DIR; };
|
2EDE49AC9AAC445892628B2B /* GoogleService-Info.plist */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 4; includeInIndex = 0; lastKnownFileType = text.plist.xml; name = "GoogleService-Info.plist"; path = "Sauci/GoogleService-Info.plist"; sourceTree = "<group>"; };
|
||||||
|
31F85F9C76D34B859549F3A7 /* noop-file.swift */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 4; includeInIndex = 0; lastKnownFileType = sourcecode.swift; name = "noop-file.swift"; path = "Sauci/noop-file.swift"; sourceTree = "<group>"; };
|
||||||
6C2E3173556A471DD304B334 /* Pods-Sauci.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Sauci.debug.xcconfig"; path = "Target Support Files/Pods-Sauci/Pods-Sauci.debug.xcconfig"; sourceTree = "<group>"; };
|
6C2E3173556A471DD304B334 /* Pods-Sauci.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Sauci.debug.xcconfig"; path = "Target Support Files/Pods-Sauci/Pods-Sauci.debug.xcconfig"; sourceTree = "<group>"; };
|
||||||
|
787323E49260E99741AD6413 /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; includeInIndex = 1; name = PrivacyInfo.xcprivacy; path = Sauci/PrivacyInfo.xcprivacy; sourceTree = "<group>"; };
|
||||||
7A4D352CD337FB3A3BF06240 /* Pods-Sauci.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Sauci.release.xcconfig"; path = "Target Support Files/Pods-Sauci/Pods-Sauci.release.xcconfig"; sourceTree = "<group>"; };
|
7A4D352CD337FB3A3BF06240 /* Pods-Sauci.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Sauci.release.xcconfig"; path = "Target Support Files/Pods-Sauci/Pods-Sauci.release.xcconfig"; sourceTree = "<group>"; };
|
||||||
|
A9934E72B0154500A352F0F1 /* Sauci-Bridging-Header.h */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 4; includeInIndex = 0; lastKnownFileType = sourcecode.c.h; name = "Sauci-Bridging-Header.h"; path = "Sauci/Sauci-Bridging-Header.h"; sourceTree = "<group>"; };
|
||||||
AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = SplashScreen.storyboard; path = Sauci/SplashScreen.storyboard; sourceTree = "<group>"; };
|
AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = SplashScreen.storyboard; path = Sauci/SplashScreen.storyboard; sourceTree = "<group>"; };
|
||||||
BB2F792C24A3F905000567C9 /* Expo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Expo.plist; sourceTree = "<group>"; };
|
BB2F792C24A3F905000567C9 /* Expo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Expo.plist; sourceTree = "<group>"; };
|
||||||
DFD02E18A6174627BE6CBF41 /* Sauci-Bridging-Header.h */ = {isa = PBXFileReference; explicitFileType = undefined; fileEncoding = 4; includeInIndex = 0; lastKnownFileType = sourcecode.c.h; name = "Sauci-Bridging-Header.h"; path = "Sauci/Sauci-Bridging-Header.h"; sourceTree = "<group>"; };
|
|
||||||
ED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; };
|
ED297162215061F000B7C4FE /* JavaScriptCore.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = JavaScriptCore.framework; path = System/Library/Frameworks/JavaScriptCore.framework; sourceTree = SDKROOT; };
|
||||||
FAC715A2D49A985799AEE119 /* ExpoModulesProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ExpoModulesProvider.swift; path = "Pods/Target Support Files/Pods-Sauci/ExpoModulesProvider.swift"; sourceTree = "<group>"; };
|
FAC715A2D49A985799AEE119 /* ExpoModulesProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = ExpoModulesProvider.swift; path = "Pods/Target Support Files/Pods-Sauci/ExpoModulesProvider.swift"; sourceTree = "<group>"; };
|
||||||
/* End PBXFileReference section */
|
/* End PBXFileReference section */
|
||||||
@@ -42,7 +44,7 @@
|
|||||||
isa = PBXFrameworksBuildPhase;
|
isa = PBXFrameworksBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
96905EF65AED1B983A6B3ABC /* libPods-Sauci.a in Frameworks */,
|
1F3D2CE02DD8CC2E2BA9F93A /* Pods_Sauci.framework in Frameworks */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
@@ -59,9 +61,10 @@
|
|||||||
13B07FB61A68108700A75B9A /* Info.plist */,
|
13B07FB61A68108700A75B9A /* Info.plist */,
|
||||||
13B07FB71A68108700A75B9A /* main.m */,
|
13B07FB71A68108700A75B9A /* main.m */,
|
||||||
AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */,
|
AA286B85B6C04FC6940260E9 /* SplashScreen.storyboard */,
|
||||||
157EFF60C4BA4BA694688B2F /* noop-file.swift */,
|
2EDE49AC9AAC445892628B2B /* GoogleService-Info.plist */,
|
||||||
DFD02E18A6174627BE6CBF41 /* Sauci-Bridging-Header.h */,
|
31F85F9C76D34B859549F3A7 /* noop-file.swift */,
|
||||||
0FE9A3D4025061824A4C95F7 /* PrivacyInfo.xcprivacy */,
|
A9934E72B0154500A352F0F1 /* Sauci-Bridging-Header.h */,
|
||||||
|
787323E49260E99741AD6413 /* PrivacyInfo.xcprivacy */,
|
||||||
);
|
);
|
||||||
name = Sauci;
|
name = Sauci;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -70,7 +73,7 @@
|
|||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
ED297162215061F000B7C4FE /* JavaScriptCore.framework */,
|
ED297162215061F000B7C4FE /* JavaScriptCore.framework */,
|
||||||
58EEBF8E8E6FB1BC6CAF49B5 /* libPods-Sauci.a */,
|
27B40580D18D29CBD94DD2C3 /* Pods_Sauci.framework */,
|
||||||
);
|
);
|
||||||
name = Frameworks;
|
name = Frameworks;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -147,14 +150,15 @@
|
|||||||
buildConfigurationList = 13B07F931A680F5B00A75B9A /* Build configuration list for PBXNativeTarget "Sauci" */;
|
buildConfigurationList = 13B07F931A680F5B00A75B9A /* Build configuration list for PBXNativeTarget "Sauci" */;
|
||||||
buildPhases = (
|
buildPhases = (
|
||||||
08A4A3CD28434E44B6B9DE2E /* [CP] Check Pods Manifest.lock */,
|
08A4A3CD28434E44B6B9DE2E /* [CP] Check Pods Manifest.lock */,
|
||||||
B4F8858AB365CBF283E38EB6 /* [Expo] Configure project */,
|
DB0F43D5EFF9CB0780FDD6A5 /* [Expo] Configure project */,
|
||||||
13B07F871A680F5B00A75B9A /* Sources */,
|
13B07F871A680F5B00A75B9A /* Sources */,
|
||||||
13B07F8C1A680F5B00A75B9A /* Frameworks */,
|
13B07F8C1A680F5B00A75B9A /* Frameworks */,
|
||||||
13B07F8E1A680F5B00A75B9A /* Resources */,
|
13B07F8E1A680F5B00A75B9A /* Resources */,
|
||||||
00DD1BFF1BD5951E006B06BC /* Bundle React Native code and images */,
|
00DD1BFF1BD5951E006B06BC /* Bundle React Native code and images */,
|
||||||
800E24972A6A228C8D4807E9 /* [CP] Copy Pods Resources */,
|
800E24972A6A228C8D4807E9 /* [CP] Copy Pods Resources */,
|
||||||
92A7F2AE90734CFDBD5EA084 /* Upload Debug Symbols to Sentry */,
|
F051EF99F56144029D3DFD83 /* Upload Debug Symbols to Sentry */,
|
||||||
EE1F9249669B846739AA38F5 /* [CP] Embed Pods Frameworks */,
|
D1E774350CB6864AD97A24B4 /* [CP] Embed Pods Frameworks */,
|
||||||
|
658D10E2FBC1B3A1C410A9EB /* [CP-User] [RNFB] Core Configuration */,
|
||||||
);
|
);
|
||||||
buildRules = (
|
buildRules = (
|
||||||
);
|
);
|
||||||
@@ -175,6 +179,8 @@
|
|||||||
TargetAttributes = {
|
TargetAttributes = {
|
||||||
13B07F861A680F5B00A75B9A = {
|
13B07F861A680F5B00A75B9A = {
|
||||||
LastSwiftMigration = 1250;
|
LastSwiftMigration = 1250;
|
||||||
|
DevelopmentTeam = "BXVPBATDZY";
|
||||||
|
ProvisioningStyle = Automatic;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -204,7 +210,8 @@
|
|||||||
BB2F792D24A3F905000567C9 /* Expo.plist in Resources */,
|
BB2F792D24A3F905000567C9 /* Expo.plist in Resources */,
|
||||||
13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */,
|
13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */,
|
||||||
3E461D99554A48A4959DE609 /* SplashScreen.storyboard in Resources */,
|
3E461D99554A48A4959DE609 /* SplashScreen.storyboard in Resources */,
|
||||||
656E142A44D7042462875BC2 /* PrivacyInfo.xcprivacy in Resources */,
|
47690AB7B3224CD3B1655332 /* GoogleService-Info.plist in Resources */,
|
||||||
|
1AFB24071BC9A2F5B90C1951 /* PrivacyInfo.xcprivacy in Resources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
@@ -248,6 +255,19 @@
|
|||||||
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
|
shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
|
||||||
showEnvVarsInLog = 0;
|
showEnvVarsInLog = 0;
|
||||||
};
|
};
|
||||||
|
658D10E2FBC1B3A1C410A9EB /* [CP-User] [RNFB] Core Configuration */ = {
|
||||||
|
isa = PBXShellScriptBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
);
|
||||||
|
inputPaths = (
|
||||||
|
"$(BUILT_PRODUCTS_DIR)/$(INFOPLIST_PATH)",
|
||||||
|
);
|
||||||
|
name = "[CP-User] [RNFB] Core Configuration";
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
shellPath = /bin/sh;
|
||||||
|
shellScript = "#!/usr/bin/env bash\n#\n# Copyright (c) 2016-present Invertase Limited & Contributors\n#\n# Licensed under the Apache License, Version 2.0 (the \"License\");\n# you may not use this library except in compliance with the License.\n# You may obtain a copy of the License at\n#\n# http://www.apache.org/licenses/LICENSE-2.0\n#\n# Unless required by applicable law or agreed to in writing, software\n# distributed under the License is distributed on an \"AS IS\" BASIS,\n# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.\n# See the License for the specific language governing permissions and\n# limitations under the License.\n#\n\n##########################################################################\n##########################################################################\n#\n# NOTE THAT IF YOU CHANGE THIS FILE YOU MUST RUN pod install AFTERWARDS\n#\n# This file is installed as an Xcode build script in the project file\n# by cocoapods, and you will not see your changes until you pod install\n#\n##########################################################################\n##########################################################################\n\nset -e\n\n_MAX_LOOKUPS=2;\n_SEARCH_RESULT=''\n_RN_ROOT_EXISTS=''\n_CURRENT_LOOKUPS=1\n_JSON_ROOT=\"'react-native'\"\n_JSON_FILE_NAME='firebase.json'\n_JSON_OUTPUT_BASE64='e30=' # { }\n_CURRENT_SEARCH_DIR=${PROJECT_DIR}\n_PLIST_BUDDY=/usr/libexec/PlistBuddy\n_TARGET_PLIST=\"${BUILT_PRODUCTS_DIR}/${INFOPLIST_PATH}\"\n_DSYM_PLIST=\"${DWARF_DSYM_FOLDER_PATH}/${DWARF_DSYM_FILE_NAME}/Contents/Info.plist\"\n\n# plist arrays\n_PLIST_ENTRY_KEYS=()\n_PLIST_ENTRY_TYPES=()\n_PLIST_ENTRY_VALUES=()\n\nfunction setPlistValue {\n echo \"note: setting plist entry '$1' of type '$2' in file '$4'\"\n ${_PLIST_BUDDY} -c \"Add :$1 $2 '$3'\" $4 || echo \"note: '$1' already exists\"\n}\n\nfunction getFirebaseJsonKeyValue () {\n if [[ ${_RN_ROOT_EXISTS} ]]; then\n ruby -Ku -e \"require 'rubygems';require 'json'; output=JSON.parse('$1'); puts output[$_JSON_ROOT]['$2']\"\n else\n echo \"\"\n fi;\n}\n\nfunction jsonBoolToYesNo () {\n if [[ $1 == \"false\" ]]; then\n echo \"NO\"\n elif [[ $1 == \"true\" ]]; then\n echo \"YES\"\n else echo \"NO\"\n fi\n}\n\necho \"note: -> RNFB build script started\"\necho \"note: 1) Locating ${_JSON_FILE_NAME} file:\"\n\nif [[ -z ${_CURRENT_SEARCH_DIR} ]]; then\n _CURRENT_SEARCH_DIR=$(pwd)\nfi;\n\nwhile true; do\n _CURRENT_SEARCH_DIR=$(dirname \"$_CURRENT_SEARCH_DIR\")\n if [[ \"$_CURRENT_SEARCH_DIR\" == \"/\" ]] || [[ ${_CURRENT_LOOKUPS} -gt ${_MAX_LOOKUPS} ]]; then break; fi;\n echo \"note: ($_CURRENT_LOOKUPS of $_MAX_LOOKUPS) Searching in '$_CURRENT_SEARCH_DIR' for a ${_JSON_FILE_NAME} file.\"\n _SEARCH_RESULT=$(find \"$_CURRENT_SEARCH_DIR\" -maxdepth 2 -name ${_JSON_FILE_NAME} -print | /usr/bin/head -n 1)\n if [[ ${_SEARCH_RESULT} ]]; then\n echo \"note: ${_JSON_FILE_NAME} found at $_SEARCH_RESULT\"\n break;\n fi;\n _CURRENT_LOOKUPS=$((_CURRENT_LOOKUPS+1))\ndone\n\nif [[ ${_SEARCH_RESULT} ]]; then\n _JSON_OUTPUT_RAW=$(cat \"${_SEARCH_RESULT}\")\n if ! _RN_ROOT_EXISTS=$(ruby -Ku -e \"require 'json'; output=JSON.parse('$_JSON_OUTPUT_RAW'); puts output[$_JSON_ROOT]\"); then\n echo \"error: Failed to parse firebase.json, check for syntax errors.\"\n exit 1\n fi\n\n if [[ ${_RN_ROOT_EXISTS} ]]; then\n if ! python3 --version >/dev/null 2>&1; then echo \"error: python3 not found, firebase.json file processing error.\" && exit 1; fi\n _JSON_OUTPUT_BASE64=$(python3 -c 'import json,sys,base64;print(base64.b64encode(bytes(json.dumps(json.loads(open('\"'${_SEARCH_RESULT}'\"', '\"'rb'\"').read())['${_JSON_ROOT}']), '\"'utf-8'\"')).decode())' || echo \"e30=\")\n fi\n\n _PLIST_ENTRY_KEYS+=(\"firebase_json_raw\")\n _PLIST_ENTRY_TYPES+=(\"string\")\n _PLIST_ENTRY_VALUES+=(\"$_JSON_OUTPUT_BASE64\")\n\n # config.app_data_collection_default_enabled\n _APP_DATA_COLLECTION_ENABLED=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"app_data_collection_default_enabled\")\n if [[ $_APP_DATA_COLLECTION_ENABLED ]]; then\n _PLIST_ENTRY_KEYS+=(\"FirebaseDataCollectionDefaultEnabled\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_APP_DATA_COLLECTION_ENABLED\")\")\n fi\n\n # config.analytics_auto_collection_enabled\n _ANALYTICS_AUTO_COLLECTION=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"analytics_auto_collection_enabled\")\n if [[ $_ANALYTICS_AUTO_COLLECTION ]]; then\n _PLIST_ENTRY_KEYS+=(\"FIREBASE_ANALYTICS_COLLECTION_ENABLED\")\n _PLIST_ENTRY_TYPES+=(\"bool\")\n _PLIST_ENTRY_VALUES+=(\"$(jsonBoolToYesNo \"$_ANALYTICS_AUTO_COLLECTION\")\")\n fi\n\n # config.analytics_collection_deactivated\n _ANALYTICS_DEACTIVATED=$(getFirebaseJsonKeyValue \"$_JSON_OUTPUT_RAW\" \"analytics_collection_deactivated\")\n if [[ $_ANALYTICS_DEACTIVATED ]]; then\n _PLIST_ENTRY_KEYS+=(\"FIREBASE_ANALYTICS_COLLECTION_DEACLine truncated
|
||||||
|
};
|
||||||
800E24972A6A228C8D4807E9 /* [CP] Copy Pods Resources */ = {
|
800E24972A6A228C8D4807E9 /* [CP] Copy Pods Resources */ = {
|
||||||
isa = PBXShellScriptBuildPhase;
|
isa = PBXShellScriptBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
@@ -262,6 +282,9 @@
|
|||||||
"${PODS_CONFIGURATION_BUILD_DIR}/EXNotifications/ExpoNotifications_privacy.bundle",
|
"${PODS_CONFIGURATION_BUILD_DIR}/EXNotifications/ExpoNotifications_privacy.bundle",
|
||||||
"${PODS_CONFIGURATION_BUILD_DIR}/ExpoFileSystem/ExpoFileSystem_privacy.bundle",
|
"${PODS_CONFIGURATION_BUILD_DIR}/ExpoFileSystem/ExpoFileSystem_privacy.bundle",
|
||||||
"${PODS_CONFIGURATION_BUILD_DIR}/ExpoMediaLibrary/ExpoMediaLibrary_privacy.bundle",
|
"${PODS_CONFIGURATION_BUILD_DIR}/ExpoMediaLibrary/ExpoMediaLibrary_privacy.bundle",
|
||||||
|
"${PODS_CONFIGURATION_BUILD_DIR}/FirebaseCore/FirebaseCore_Privacy.bundle",
|
||||||
|
"${PODS_CONFIGURATION_BUILD_DIR}/FirebaseCoreInternal/FirebaseCoreInternal_Privacy.bundle",
|
||||||
|
"${PODS_CONFIGURATION_BUILD_DIR}/FirebaseInstallations/FirebaseInstallations_Privacy.bundle",
|
||||||
"${PODS_CONFIGURATION_BUILD_DIR}/GTMAppAuth/GTMAppAuth_Privacy.bundle",
|
"${PODS_CONFIGURATION_BUILD_DIR}/GTMAppAuth/GTMAppAuth_Privacy.bundle",
|
||||||
"${PODS_CONFIGURATION_BUILD_DIR}/GTMSessionFetcher/GTMSessionFetcher_Core_Privacy.bundle",
|
"${PODS_CONFIGURATION_BUILD_DIR}/GTMSessionFetcher/GTMSessionFetcher_Core_Privacy.bundle",
|
||||||
"${PODS_CONFIGURATION_BUILD_DIR}/GoogleSignIn/GoogleSignIn.bundle",
|
"${PODS_CONFIGURATION_BUILD_DIR}/GoogleSignIn/GoogleSignIn.bundle",
|
||||||
@@ -279,6 +302,7 @@
|
|||||||
"${PODS_CONFIGURATION_BUILD_DIR}/expo-dev-launcher/EXDevLauncher.bundle",
|
"${PODS_CONFIGURATION_BUILD_DIR}/expo-dev-launcher/EXDevLauncher.bundle",
|
||||||
"${PODS_CONFIGURATION_BUILD_DIR}/expo-dev-menu/EXDevMenu.bundle",
|
"${PODS_CONFIGURATION_BUILD_DIR}/expo-dev-menu/EXDevMenu.bundle",
|
||||||
"${PODS_CONFIGURATION_BUILD_DIR}/glog/glog_privacy.bundle",
|
"${PODS_CONFIGURATION_BUILD_DIR}/glog/glog_privacy.bundle",
|
||||||
|
"${PODS_CONFIGURATION_BUILD_DIR}/nanopb/nanopb_Privacy.bundle",
|
||||||
);
|
);
|
||||||
name = "[CP] Copy Pods Resources";
|
name = "[CP] Copy Pods Resources";
|
||||||
outputPaths = (
|
outputPaths = (
|
||||||
@@ -289,6 +313,9 @@
|
|||||||
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoNotifications_privacy.bundle",
|
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoNotifications_privacy.bundle",
|
||||||
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoFileSystem_privacy.bundle",
|
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoFileSystem_privacy.bundle",
|
||||||
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoMediaLibrary_privacy.bundle",
|
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/ExpoMediaLibrary_privacy.bundle",
|
||||||
|
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/FirebaseCore_Privacy.bundle",
|
||||||
|
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/FirebaseCoreInternal_Privacy.bundle",
|
||||||
|
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/FirebaseInstallations_Privacy.bundle",
|
||||||
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/GTMAppAuth_Privacy.bundle",
|
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/GTMAppAuth_Privacy.bundle",
|
||||||
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/GTMSessionFetcher_Core_Privacy.bundle",
|
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/GTMSessionFetcher_Core_Privacy.bundle",
|
||||||
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/GoogleSignIn.bundle",
|
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/GoogleSignIn.bundle",
|
||||||
@@ -306,27 +333,32 @@
|
|||||||
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/EXDevLauncher.bundle",
|
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/EXDevLauncher.bundle",
|
||||||
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/EXDevMenu.bundle",
|
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/EXDevMenu.bundle",
|
||||||
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/glog_privacy.bundle",
|
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/glog_privacy.bundle",
|
||||||
|
"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}/nanopb_Privacy.bundle",
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
shellPath = /bin/sh;
|
shellPath = /bin/sh;
|
||||||
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Sauci/Pods-Sauci-resources.sh\"\n";
|
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Sauci/Pods-Sauci-resources.sh\"\n";
|
||||||
showEnvVarsInLog = 0;
|
showEnvVarsInLog = 0;
|
||||||
};
|
};
|
||||||
92A7F2AE90734CFDBD5EA084 /* Upload Debug Symbols to Sentry */ = {
|
D1E774350CB6864AD97A24B4 /* [CP] Embed Pods Frameworks */ = {
|
||||||
isa = PBXShellScriptBuildPhase;
|
isa = PBXShellScriptBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
);
|
);
|
||||||
inputPaths = (
|
inputPaths = (
|
||||||
|
"${PODS_ROOT}/Target Support Files/Pods-Sauci/Pods-Sauci-frameworks.sh",
|
||||||
|
"${PODS_XCFRAMEWORKS_BUILD_DIR}/hermes-engine/Pre-built/hermes.framework/hermes",
|
||||||
);
|
);
|
||||||
name = "Upload Debug Symbols to Sentry";
|
name = "[CP] Embed Pods Frameworks";
|
||||||
outputPaths = (
|
outputPaths = (
|
||||||
|
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/hermes.framework",
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
shellPath = /bin/sh;
|
shellPath = /bin/sh;
|
||||||
shellScript = "/bin/sh `${NODE_BINARY:-node} --print \"require('path').dirname(require.resolve('@sentry/react-native/package.json')) + '/scripts/sentry-xcode-debug-files.sh'\"`";
|
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Sauci/Pods-Sauci-frameworks.sh\"\n";
|
||||||
|
showEnvVarsInLog = 0;
|
||||||
};
|
};
|
||||||
B4F8858AB365CBF283E38EB6 /* [Expo] Configure project */ = {
|
DB0F43D5EFF9CB0780FDD6A5 /* [Expo] Configure project */ = {
|
||||||
isa = PBXShellScriptBuildPhase;
|
isa = PBXShellScriptBuildPhase;
|
||||||
alwaysOutOfDate = 1;
|
alwaysOutOfDate = 1;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
@@ -345,25 +377,19 @@
|
|||||||
shellPath = /bin/sh;
|
shellPath = /bin/sh;
|
||||||
shellScript = "# This script configures Expo modules and generates the modules provider file.\nbash -l -c \"./Pods/Target\\ Support\\ Files/Pods-Sauci/expo-configure-project.sh\"\n";
|
shellScript = "# This script configures Expo modules and generates the modules provider file.\nbash -l -c \"./Pods/Target\\ Support\\ Files/Pods-Sauci/expo-configure-project.sh\"\n";
|
||||||
};
|
};
|
||||||
EE1F9249669B846739AA38F5 /* [CP] Embed Pods Frameworks */ = {
|
F051EF99F56144029D3DFD83 /* Upload Debug Symbols to Sentry */ = {
|
||||||
isa = PBXShellScriptBuildPhase;
|
isa = PBXShellScriptBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
);
|
);
|
||||||
inputPaths = (
|
inputPaths = (
|
||||||
"${PODS_ROOT}/Target Support Files/Pods-Sauci/Pods-Sauci-frameworks.sh",
|
|
||||||
"${PODS_XCFRAMEWORKS_BUILD_DIR}/OpenSSL-Universal/OpenSSL.framework/OpenSSL",
|
|
||||||
"${PODS_XCFRAMEWORKS_BUILD_DIR}/hermes-engine/Pre-built/hermes.framework/hermes",
|
|
||||||
);
|
);
|
||||||
name = "[CP] Embed Pods Frameworks";
|
name = "Upload Debug Symbols to Sentry";
|
||||||
outputPaths = (
|
outputPaths = (
|
||||||
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/OpenSSL.framework",
|
|
||||||
"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/hermes.framework",
|
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
shellPath = /bin/sh;
|
shellPath = /bin/sh;
|
||||||
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Sauci/Pods-Sauci-frameworks.sh\"\n";
|
shellScript = "/bin/sh `${NODE_BINARY:-node} --print \"require('path').dirname(require.resolve('@sentry/react-native/package.json')) + '/scripts/sentry-xcode-debug-files.sh'\"`";
|
||||||
showEnvVarsInLog = 0;
|
|
||||||
};
|
};
|
||||||
/* End PBXShellScriptBuildPhase section */
|
/* End PBXShellScriptBuildPhase section */
|
||||||
|
|
||||||
@@ -375,7 +401,7 @@
|
|||||||
13B07FBC1A68108700A75B9A /* AppDelegate.mm in Sources */,
|
13B07FBC1A68108700A75B9A /* AppDelegate.mm in Sources */,
|
||||||
13B07FC11A68108700A75B9A /* main.m in Sources */,
|
13B07FC11A68108700A75B9A /* main.m in Sources */,
|
||||||
B18059E884C0ABDD17F3DC3D /* ExpoModulesProvider.swift in Sources */,
|
B18059E884C0ABDD17F3DC3D /* ExpoModulesProvider.swift in Sources */,
|
||||||
BBC1EC435C914364BBC9058D /* noop-file.swift in Sources */,
|
E52FFD661AC14FC08C4F239D /* noop-file.swift in Sources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
@@ -389,8 +415,7 @@
|
|||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
CODE_SIGN_ENTITLEMENTS = Sauci/Sauci.entitlements;
|
CODE_SIGN_ENTITLEMENTS = Sauci/Sauci.entitlements;
|
||||||
CURRENT_PROJECT_VERSION = 18;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEVELOPMENT_TEAM = BXVPBATDZY;
|
|
||||||
ENABLE_BITCODE = NO;
|
ENABLE_BITCODE = NO;
|
||||||
GCC_PREPROCESSOR_DEFINITIONS = (
|
GCC_PREPROCESSOR_DEFINITIONS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
@@ -398,10 +423,7 @@
|
|||||||
);
|
);
|
||||||
INFOPLIST_FILE = Sauci/Info.plist;
|
INFOPLIST_FILE = Sauci/Info.plist;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
|
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
|
||||||
"$(inherited)",
|
|
||||||
"@executable_path/Frameworks",
|
|
||||||
);
|
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
OTHER_LDFLAGS = (
|
OTHER_LDFLAGS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
@@ -416,6 +438,9 @@
|
|||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TARGETED_DEVICE_FAMILY = "1";
|
TARGETED_DEVICE_FAMILY = "1";
|
||||||
VERSIONING_SYSTEM = "apple-generic";
|
VERSIONING_SYSTEM = "apple-generic";
|
||||||
|
DEVELOPMENT_TEAM = "BXVPBATDZY";
|
||||||
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
};
|
};
|
||||||
name = Debug;
|
name = Debug;
|
||||||
};
|
};
|
||||||
@@ -426,14 +451,10 @@
|
|||||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||||
CLANG_ENABLE_MODULES = YES;
|
CLANG_ENABLE_MODULES = YES;
|
||||||
CODE_SIGN_ENTITLEMENTS = Sauci/Sauci.entitlements;
|
CODE_SIGN_ENTITLEMENTS = Sauci/Sauci.entitlements;
|
||||||
CURRENT_PROJECT_VERSION = 18;
|
CURRENT_PROJECT_VERSION = 1;
|
||||||
DEVELOPMENT_TEAM = BXVPBATDZY;
|
|
||||||
INFOPLIST_FILE = Sauci/Info.plist;
|
INFOPLIST_FILE = Sauci/Info.plist;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
|
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
|
||||||
"$(inherited)",
|
|
||||||
"@executable_path/Frameworks",
|
|
||||||
);
|
|
||||||
MARKETING_VERSION = 1.0;
|
MARKETING_VERSION = 1.0;
|
||||||
OTHER_LDFLAGS = (
|
OTHER_LDFLAGS = (
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
@@ -447,6 +468,9 @@
|
|||||||
SWIFT_VERSION = 5.0;
|
SWIFT_VERSION = 5.0;
|
||||||
TARGETED_DEVICE_FAMILY = "1";
|
TARGETED_DEVICE_FAMILY = "1";
|
||||||
VERSIONING_SYSTEM = "apple-generic";
|
VERSIONING_SYSTEM = "apple-generic";
|
||||||
|
DEVELOPMENT_TEAM = "BXVPBATDZY";
|
||||||
|
CODE_SIGN_IDENTITY = "Apple Development";
|
||||||
|
CODE_SIGN_STYLE = Automatic;
|
||||||
};
|
};
|
||||||
name = Release;
|
name = Release;
|
||||||
};
|
};
|
||||||
@@ -497,11 +521,19 @@
|
|||||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
|
HEADER_SEARCH_PATHS = (
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
|
||||||
/usr/lib/swift,
|
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
|
"${PODS_CONFIGURATION_BUILD_DIR}/ReactCommon/ReactCommon.framework/Headers",
|
||||||
|
"${PODS_CONFIGURATION_BUILD_DIR}/ReactCommon/ReactCommon.framework/Headers/react/nativemodule/core",
|
||||||
|
"${PODS_CONFIGURATION_BUILD_DIR}/ReactCommon-Samples/ReactCommon_Samples.framework/Headers",
|
||||||
|
"${PODS_CONFIGURATION_BUILD_DIR}/ReactCommon-Samples/ReactCommon_Samples.framework/Headers/platform/ios",
|
||||||
|
"${PODS_CONFIGURATION_BUILD_DIR}/React-Fabric/React_Fabric.framework/Headers/react/renderer/components/view/platform/cxx",
|
||||||
|
"${PODS_CONFIGURATION_BUILD_DIR}/React-NativeModulesApple/React_NativeModulesApple.framework/Headers",
|
||||||
|
"${PODS_CONFIGURATION_BUILD_DIR}/React-graphics/React_graphics.framework/Headers",
|
||||||
|
"${PODS_CONFIGURATION_BUILD_DIR}/React-graphics/React_graphics.framework/Headers/react/renderer/graphics/platform/ios",
|
||||||
);
|
);
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = "/usr/lib/swift $(inherited)";
|
||||||
LIBRARY_SEARCH_PATHS = "$(SDKROOT)/usr/lib/swift\"$(inherited)\"";
|
LIBRARY_SEARCH_PATHS = "$(SDKROOT)/usr/lib/swift\"$(inherited)\"";
|
||||||
MTL_ENABLE_DEBUG_INFO = YES;
|
MTL_ENABLE_DEBUG_INFO = YES;
|
||||||
ONLY_ACTIVE_ARCH = YES;
|
ONLY_ACTIVE_ARCH = YES;
|
||||||
@@ -556,11 +588,19 @@
|
|||||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
|
HEADER_SEARCH_PATHS = (
|
||||||
LD_RUNPATH_SEARCH_PATHS = (
|
|
||||||
/usr/lib/swift,
|
|
||||||
"$(inherited)",
|
"$(inherited)",
|
||||||
|
"${PODS_CONFIGURATION_BUILD_DIR}/ReactCommon/ReactCommon.framework/Headers",
|
||||||
|
"${PODS_CONFIGURATION_BUILD_DIR}/ReactCommon/ReactCommon.framework/Headers/react/nativemodule/core",
|
||||||
|
"${PODS_CONFIGURATION_BUILD_DIR}/ReactCommon-Samples/ReactCommon_Samples.framework/Headers",
|
||||||
|
"${PODS_CONFIGURATION_BUILD_DIR}/ReactCommon-Samples/ReactCommon_Samples.framework/Headers/platform/ios",
|
||||||
|
"${PODS_CONFIGURATION_BUILD_DIR}/React-Fabric/React_Fabric.framework/Headers/react/renderer/components/view/platform/cxx",
|
||||||
|
"${PODS_CONFIGURATION_BUILD_DIR}/React-NativeModulesApple/React_NativeModulesApple.framework/Headers",
|
||||||
|
"${PODS_CONFIGURATION_BUILD_DIR}/React-graphics/React_graphics.framework/Headers",
|
||||||
|
"${PODS_CONFIGURATION_BUILD_DIR}/React-graphics/React_graphics.framework/Headers/react/renderer/graphics/platform/ios",
|
||||||
);
|
);
|
||||||
|
IPHONEOS_DEPLOYMENT_TARGET = 15.1;
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = "/usr/lib/swift $(inherited)";
|
||||||
LIBRARY_SEARCH_PATHS = "$(SDKROOT)/usr/lib/swift\"$(inherited)\"";
|
LIBRARY_SEARCH_PATHS = "$(SDKROOT)/usr/lib/swift\"$(inherited)\"";
|
||||||
MTL_ENABLE_DEBUG_INFO = NO;
|
MTL_ENABLE_DEBUG_INFO = NO;
|
||||||
OTHER_LDFLAGS = (
|
OTHER_LDFLAGS = (
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
#import "AppDelegate.h"
|
#import "AppDelegate.h"
|
||||||
|
#import <Firebase/Firebase.h>
|
||||||
|
|
||||||
#import <React/RCTBundleURLProvider.h>
|
#import <React/RCTBundleURLProvider.h>
|
||||||
#import <React/RCTLinkingManager.h>
|
#import <React/RCTLinkingManager.h>
|
||||||
@@ -7,6 +8,9 @@
|
|||||||
|
|
||||||
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions
|
- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions
|
||||||
{
|
{
|
||||||
|
// @generated begin @react-native-firebase/app-didFinishLaunchingWithOptions - expo prebuild (DO NOT MODIFY) sync-ecd111c37e49fdd1ed6354203cd6b1e2a38cccda
|
||||||
|
[FIRApp configure];
|
||||||
|
// @generated end @react-native-firebase/app-didFinishLaunchingWithOptions
|
||||||
self.moduleName = @"main";
|
self.moduleName = @"main";
|
||||||
|
|
||||||
// You can add your custom initial props in the dictionary below.
|
// You can add your custom initial props in the dictionary below.
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>API_KEY</key>
|
||||||
|
<string>AIzaSyDlJoqvK9ONUVAKxDBFTGZg1I7slm2DuJ4</string>
|
||||||
|
<key>GCM_SENDER_ID</key>
|
||||||
|
<string>764866133492</string>
|
||||||
|
<key>PLIST_VERSION</key>
|
||||||
|
<string>1</string>
|
||||||
|
<key>BUNDLE_ID</key>
|
||||||
|
<string>com.sauci.app</string>
|
||||||
|
<key>PROJECT_ID</key>
|
||||||
|
<string>sauci-6840e</string>
|
||||||
|
<key>STORAGE_BUCKET</key>
|
||||||
|
<string>sauci-6840e.firebasestorage.app</string>
|
||||||
|
<key>IS_ADS_ENABLED</key>
|
||||||
|
<false></false>
|
||||||
|
<key>IS_ANALYTICS_ENABLED</key>
|
||||||
|
<true></true>
|
||||||
|
<key>IS_APPINVITE_ENABLED</key>
|
||||||
|
<true></true>
|
||||||
|
<key>IS_GCM_ENABLED</key>
|
||||||
|
<true></true>
|
||||||
|
<key>IS_SIGNIN_ENABLED</key>
|
||||||
|
<true></true>
|
||||||
|
<key>GOOGLE_APP_ID</key>
|
||||||
|
<string>1:764866133492:ios:0290bfbe613a4f48fe4da1</string>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -21,7 +21,7 @@
|
|||||||
<key>CFBundlePackageType</key>
|
<key>CFBundlePackageType</key>
|
||||||
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
<string>1.0.0</string>
|
<string>1.0.2</string>
|
||||||
<key>CFBundleSignature</key>
|
<key>CFBundleSignature</key>
|
||||||
<string>????</string>
|
<string>????</string>
|
||||||
<key>CFBundleURLTypes</key>
|
<key>CFBundleURLTypes</key>
|
||||||
@@ -47,7 +47,7 @@
|
|||||||
</dict>
|
</dict>
|
||||||
</array>
|
</array>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>25</string>
|
<string>31</string>
|
||||||
<key>LSMinimumSystemVersion</key>
|
<key>LSMinimumSystemVersion</key>
|
||||||
<string>12.0</string>
|
<string>12.0</string>
|
||||||
<key>LSRequiresIPhoneOS</key>
|
<key>LSRequiresIPhoneOS</key>
|
||||||
|
|||||||
@@ -20,6 +20,7 @@
|
|||||||
<key>NSPrivacyAccessedAPITypeReasons</key>
|
<key>NSPrivacyAccessedAPITypeReasons</key>
|
||||||
<array>
|
<array>
|
||||||
<string>CA92.1</string>
|
<string>CA92.1</string>
|
||||||
|
<string>1C8F.1</string>
|
||||||
<string>C56D.1</string>
|
<string>C56D.1</string>
|
||||||
</array>
|
</array>
|
||||||
</dict>
|
</dict>
|
||||||
|
|||||||
@@ -25,11 +25,9 @@ config.resolver.nodeModulesPaths = [
|
|||||||
// of the workspace root, unless they are installed in the project root.
|
// of the workspace root, unless they are installed in the project root.
|
||||||
config.resolver.disableHierarchicalLookup = true;
|
config.resolver.disableHierarchicalLookup = true;
|
||||||
|
|
||||||
// 4. E2EE crypto polyfill: resolve 'crypto' to react-native-quick-crypto
|
// 4. Keep extraNodeModules in case we need to add monorepo overrides later
|
||||||
// This enables Web Crypto API for encryption operations
|
|
||||||
config.resolver.extraNodeModules = {
|
config.resolver.extraNodeModules = {
|
||||||
...config.resolver.extraNodeModules,
|
...config.resolver.extraNodeModules,
|
||||||
crypto: require.resolve('react-native-quick-crypto'),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
module.exports = config;
|
module.exports = config;
|
||||||
@@ -14,12 +14,16 @@
|
|||||||
"test:watch": "jest --watch",
|
"test:watch": "jest --watch",
|
||||||
"test:ci": "jest --runInBand --ci",
|
"test:ci": "jest --runInBand --ci",
|
||||||
"release:ios": "dotenv -e .env -- npx expo prebuild --platform ios && dotenv -e .env -- eas build --platform ios --profile production --local",
|
"release:ios": "dotenv -e .env -- npx expo prebuild --platform ios && dotenv -e .env -- eas build --platform ios --profile production --local",
|
||||||
"release:android": "dotenv -e .env -- npx expo prebuild --platform android && dotenv -e .env -- eas build --platform android --profile production --local"
|
"release:ios:preview": "dotenv -e .env -- npx expo prebuild --platform ios && dotenv -e .env -- eas build --platform ios --profile preview --local",
|
||||||
|
"release:android": "dotenv -e .env -- npx expo prebuild --platform android && dotenv -e .env -- eas build --platform android --profile production --local",
|
||||||
|
"release:android:preview": "dotenv -e .env -- npx expo prebuild --platform android && dotenv -e .env -- eas build --platform android --profile preview --local"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@expo/ngrok": "^4.1.0",
|
"@expo/ngrok": "^4.1.0",
|
||||||
"@expo/vector-icons": "~14.0.4",
|
"@expo/vector-icons": "~14.0.4",
|
||||||
"@react-native-async-storage/async-storage": "1.23.1",
|
"@react-native-async-storage/async-storage": "1.23.1",
|
||||||
|
"@react-native-firebase/analytics": "^23.8.2",
|
||||||
|
"@react-native-firebase/app": "^23.8.2",
|
||||||
"@react-native-google-signin/google-signin": "^16.1.1",
|
"@react-native-google-signin/google-signin": "^16.1.1",
|
||||||
"@react-navigation/bottom-tabs": "^7.0.0",
|
"@react-navigation/bottom-tabs": "^7.0.0",
|
||||||
"@react-navigation/native": "^7.0.0",
|
"@react-navigation/native": "^7.0.0",
|
||||||
@@ -51,14 +55,12 @@
|
|||||||
"expo-router": "~4.0.0",
|
"expo-router": "~4.0.0",
|
||||||
"expo-secure-store": "~14.0.0",
|
"expo-secure-store": "~14.0.0",
|
||||||
"expo-status-bar": "~2.0.0",
|
"expo-status-bar": "~2.0.0",
|
||||||
"posthog-react-native": "^4.17.1",
|
|
||||||
"react": "18.3.1",
|
"react": "18.3.1",
|
||||||
"react-dom": "18.3.1",
|
"react-dom": "18.3.1",
|
||||||
"react-native": "0.76.9",
|
"react-native": "0.76.9",
|
||||||
"react-native-compressor": "^1.13.0",
|
"react-native-compressor": "^1.13.0",
|
||||||
"react-native-gesture-handler": "~2.20.0",
|
"react-native-gesture-handler": "~2.20.0",
|
||||||
"react-native-purchases": "^9.6.12",
|
"react-native-purchases": "^9.6.12",
|
||||||
"react-native-quick-crypto": "^0.7.11",
|
|
||||||
"react-native-reanimated": "~3.16.0",
|
"react-native-reanimated": "~3.16.0",
|
||||||
"react-native-safe-area-context": "4.12.0",
|
"react-native-safe-area-context": "4.12.0",
|
||||||
"react-native-screens": "~4.4.0",
|
"react-native-screens": "~4.4.0",
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
const { withProjectBuildGradle } = require("expo/config-plugins");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Config plugin to ensure Kotlin 1.9.x is used in the Android build.
|
||||||
|
* This provides a safety net in case the default version causes issues.
|
||||||
|
*/
|
||||||
|
function withKotlinVersion(config, kotlinVersion = "1.9.25") {
|
||||||
|
return withProjectBuildGradle(config, (config) => {
|
||||||
|
if (config.modResults.language === "groovy") {
|
||||||
|
// Ensure Kotlin version is set correctly
|
||||||
|
config.modResults.contents = config.modResults.contents.replace(
|
||||||
|
/kotlinVersion = findProperty\('android\.kotlinVersion'\) \?: ['"][\d.]+['"]/,
|
||||||
|
`kotlinVersion = findProperty('android.kotlinVersion') ?: '${kotlinVersion}'`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = withKotlinVersion;
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# Sauci Mobile Release Notes
|
||||||
|
|
||||||
|
## v1.0.1 (Build 30) - January 2025
|
||||||
|
|
||||||
|
### New Features
|
||||||
|
- **Match Archiving** - Archive old matches to keep your active list focused
|
||||||
|
- **Daily Swipe Limits** - Pace your discovery with daily question limits
|
||||||
|
- **Intensity Preferences** - Set your comfort level for question intensity (1-5 scale)
|
||||||
|
- **Infinite Scroll** - Smoother browsing through Matches and Answers with infinite scroll
|
||||||
|
- **Chat Date Separators** - Easily see when conversations happened
|
||||||
|
- **Lifecycle Notifications** - Get reminded to check in with your partner
|
||||||
|
- **Message Reporting** - Report inappropriate messages for review
|
||||||
|
- **Account Deletion** - Full account deletion option in settings
|
||||||
|
- **Reset Progress** - Start fresh by resetting your question progress
|
||||||
|
|
||||||
|
### Improvements
|
||||||
|
- **Enhanced Swipe UI** - Smoother swiping experience with visual refinements
|
||||||
|
- **Improved Chat Layout** - Match card moved into message list for cleaner design
|
||||||
|
- **Keyboard Behavior** - Chat header collapses when keyboard opens for more screen space
|
||||||
|
- **Profile Settings** - New appearance and notification preferences
|
||||||
|
- **Premium Design** - Updated subscription card with gold styling
|
||||||
|
- **Version Display** - App version now visible in settings
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
- Fixed subscription status incorrectly showing "Via partner's" after direct purchase
|
||||||
|
- Fixed Android navigation issues
|
||||||
|
- Various performance improvements and stability fixes
|
||||||
|
|
||||||
|
### Privacy
|
||||||
|
- Removed partner answered indicator for enhanced privacy
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v1.0.0 (Build 25) - January 8, 2025
|
||||||
|
|
||||||
|
Initial release.
|
||||||
@@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -163,7 +163,7 @@ describe('subscriptionStore', () => {
|
|||||||
|
|
||||||
const result = await useSubscriptionStore.getState().purchasePackage(mockPackage as any);
|
const result = await useSubscriptionStore.getState().purchasePackage(mockPackage as any);
|
||||||
|
|
||||||
expect(result).toBe(true);
|
expect(result.success).toBe(true);
|
||||||
const state = useSubscriptionStore.getState();
|
const state = useSubscriptionStore.getState();
|
||||||
expect(state.subscription.isProUser).toBe(true);
|
expect(state.subscription.isProUser).toBe(true);
|
||||||
expect(state.isPurchasing).toBe(false);
|
expect(state.isPurchasing).toBe(false);
|
||||||
@@ -178,7 +178,8 @@ describe('subscriptionStore', () => {
|
|||||||
|
|
||||||
const result = await useSubscriptionStore.getState().purchasePackage(mockPackage as any);
|
const result = await useSubscriptionStore.getState().purchasePackage(mockPackage as any);
|
||||||
|
|
||||||
expect(result).toBe(false);
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.cancelled).toBe(true);
|
||||||
const state = useSubscriptionStore.getState();
|
const state = useSubscriptionStore.getState();
|
||||||
expect(state.error).toBe('Purchase was cancelled');
|
expect(state.error).toBe('Purchase was cancelled');
|
||||||
expect(state.isPurchasing).toBe(false);
|
expect(state.isPurchasing).toBe(false);
|
||||||
@@ -192,7 +193,8 @@ describe('subscriptionStore', () => {
|
|||||||
|
|
||||||
const result = await useSubscriptionStore.getState().purchasePackage(mockPackage as any);
|
const result = await useSubscriptionStore.getState().purchasePackage(mockPackage as any);
|
||||||
|
|
||||||
expect(result).toBe(false);
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.cancelled).toBe(false);
|
||||||
const state = useSubscriptionStore.getState();
|
const state = useSubscriptionStore.getState();
|
||||||
expect(state.error).toBe('Purchase failed. Please try again.');
|
expect(state.error).toBe('Purchase failed. Please try again.');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
StyleSheet,
|
StyleSheet,
|
||||||
Pressable,
|
Pressable,
|
||||||
Animated,
|
Animated,
|
||||||
|
Keyboard,
|
||||||
} from 'react-native';
|
} 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';
|
||||||
@@ -106,6 +107,9 @@ export const MatchNotificationModal: React.FC<Props> = ({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (visible) {
|
if (visible) {
|
||||||
|
// Dismiss keyboard so it doesn't block the modal buttons
|
||||||
|
Keyboard.dismiss();
|
||||||
|
|
||||||
// Reset animations
|
// Reset animations
|
||||||
modalScale.setValue(0.8);
|
modalScale.setValue(0.8);
|
||||||
modalOpacity.setValue(0);
|
modalOpacity.setValue(0);
|
||||||
@@ -173,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 => {
|
||||||
@@ -262,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}>
|
||||||
@@ -284,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>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -0,0 +1,193 @@
|
|||||||
|
import React, { useEffect } from 'react';
|
||||||
|
import { View, Text, StyleSheet } from 'react-native';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import Animated, { FadeInDown, useSharedValue, useAnimatedStyle, withRepeat, withSequence, withTiming, Easing } from 'react-native-reanimated';
|
||||||
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
|
import { useStreakStore } from '../store';
|
||||||
|
import { colors, spacing, radius, typography } from '../theme';
|
||||||
|
|
||||||
|
interface StreakDisplayProps {
|
||||||
|
/** Whether to show the "longest streak" indicator */
|
||||||
|
showLongest?: boolean;
|
||||||
|
/** Animation delay for entry animation */
|
||||||
|
delay?: number;
|
||||||
|
/** Compact mode for smaller displays */
|
||||||
|
compact?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Displays the couple's current streak with a flame icon.
|
||||||
|
* The flame pulses when there's an active streak.
|
||||||
|
*/
|
||||||
|
export function StreakDisplay({ showLongest = false, delay = 0, compact = false }: StreakDisplayProps) {
|
||||||
|
const { streak, isLoading, fetchStreak } = useStreakStore();
|
||||||
|
|
||||||
|
// Fetch streak on mount
|
||||||
|
useEffect(() => {
|
||||||
|
fetchStreak();
|
||||||
|
}, [fetchStreak]);
|
||||||
|
|
||||||
|
// Flame pulse animation
|
||||||
|
const scale = useSharedValue(1);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (streak && streak.current_streak > 0) {
|
||||||
|
scale.value = withRepeat(
|
||||||
|
withSequence(
|
||||||
|
withTiming(1.1, { duration: 800, easing: Easing.inOut(Easing.ease) }),
|
||||||
|
withTiming(1, { duration: 800, easing: Easing.inOut(Easing.ease) })
|
||||||
|
),
|
||||||
|
-1,
|
||||||
|
true
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
scale.value = 1;
|
||||||
|
}
|
||||||
|
}, [streak?.current_streak, scale]);
|
||||||
|
|
||||||
|
const flameStyle = useAnimatedStyle(() => ({
|
||||||
|
transform: [{ scale: scale.value }],
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Don't show if loading or no couple
|
||||||
|
if (isLoading || !streak) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasStreak = streak.current_streak > 0;
|
||||||
|
const currentStreak = streak.current_streak;
|
||||||
|
const longestStreak = streak.longest_streak;
|
||||||
|
|
||||||
|
if (compact) {
|
||||||
|
return (
|
||||||
|
<Animated.View entering={FadeInDown.delay(delay).duration(400)}>
|
||||||
|
<View style={styles.compactContainer}>
|
||||||
|
<Animated.View style={flameStyle}>
|
||||||
|
<Ionicons
|
||||||
|
name="flame"
|
||||||
|
size={16}
|
||||||
|
color={hasStreak ? colors.warning : colors.textTertiary}
|
||||||
|
/>
|
||||||
|
</Animated.View>
|
||||||
|
<Text style={[styles.compactCount, !hasStreak && styles.inactiveText]}>
|
||||||
|
{currentStreak}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</Animated.View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Animated.View entering={FadeInDown.delay(delay).duration(500)}>
|
||||||
|
<View style={styles.container}>
|
||||||
|
{/* Main streak display */}
|
||||||
|
<LinearGradient
|
||||||
|
colors={hasStreak ? ['rgba(243, 156, 18, 0.2)', 'rgba(233, 69, 96, 0.1)'] : ['rgba(255, 255, 255, 0.05)', 'rgba(255, 255, 255, 0.02)']}
|
||||||
|
style={styles.card}
|
||||||
|
start={{ x: 0, y: 0 }}
|
||||||
|
end={{ x: 1, y: 1 }}
|
||||||
|
>
|
||||||
|
<View style={styles.content}>
|
||||||
|
{/* Flame icon */}
|
||||||
|
<Animated.View style={[styles.iconContainer, flameStyle]}>
|
||||||
|
<Ionicons
|
||||||
|
name="flame"
|
||||||
|
size={32}
|
||||||
|
color={hasStreak ? colors.warning : colors.textTertiary}
|
||||||
|
/>
|
||||||
|
</Animated.View>
|
||||||
|
|
||||||
|
{/* Streak info */}
|
||||||
|
<View style={styles.info}>
|
||||||
|
<Text style={[styles.count, !hasStreak && styles.inactiveText]}>
|
||||||
|
{currentStreak}
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.label}>
|
||||||
|
day{currentStreak !== 1 ? 's' : ''} together
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{/* Longest streak */}
|
||||||
|
{showLongest && longestStreak > 0 && (
|
||||||
|
<View style={styles.longest}>
|
||||||
|
<Ionicons name="trophy-outline" size={12} color={colors.textTertiary} />
|
||||||
|
<Text style={styles.longestText}>
|
||||||
|
Best: {longestStreak} day{longestStreak !== 1 ? 's' : ''}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</LinearGradient>
|
||||||
|
</View>
|
||||||
|
</Animated.View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
marginVertical: spacing.sm,
|
||||||
|
},
|
||||||
|
card: {
|
||||||
|
borderRadius: radius.lg,
|
||||||
|
padding: spacing.md,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.glass.border,
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: spacing.md,
|
||||||
|
},
|
||||||
|
iconContainer: {
|
||||||
|
width: 48,
|
||||||
|
height: 48,
|
||||||
|
borderRadius: radius.full,
|
||||||
|
backgroundColor: 'rgba(243, 156, 18, 0.15)',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
info: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
count: {
|
||||||
|
...typography.title1,
|
||||||
|
color: colors.text,
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
...typography.subhead,
|
||||||
|
color: colors.textSecondary,
|
||||||
|
},
|
||||||
|
inactiveText: {
|
||||||
|
color: colors.textTertiary,
|
||||||
|
},
|
||||||
|
longest: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: spacing.xs,
|
||||||
|
marginTop: spacing.sm,
|
||||||
|
paddingTop: spacing.sm,
|
||||||
|
borderTopWidth: 1,
|
||||||
|
borderTopColor: colors.glass.border,
|
||||||
|
},
|
||||||
|
longestText: {
|
||||||
|
...typography.caption1,
|
||||||
|
color: colors.textTertiary,
|
||||||
|
},
|
||||||
|
// Compact styles
|
||||||
|
compactContainer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: spacing.xs,
|
||||||
|
backgroundColor: 'rgba(243, 156, 18, 0.15)',
|
||||||
|
paddingHorizontal: spacing.sm,
|
||||||
|
paddingVertical: spacing.xs,
|
||||||
|
borderRadius: radius.full,
|
||||||
|
},
|
||||||
|
compactCount: {
|
||||||
|
...typography.caption1,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: colors.warning,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default StreakDisplay;
|
||||||
@@ -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,12 @@ 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") => {
|
||||||
|
Events.paywallClosed("paywall", reason);
|
||||||
|
onClose();
|
||||||
|
};
|
||||||
const {
|
const {
|
||||||
offerings,
|
offerings,
|
||||||
isLoadingOfferings,
|
isLoadingOfferings,
|
||||||
@@ -56,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) {
|
||||||
@@ -63,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]);
|
||||||
|
|
||||||
@@ -76,16 +87,37 @@ export function Paywall({ visible, onClose, onSuccess }: PaywallProps) {
|
|||||||
}
|
}
|
||||||
}, [offerings]);
|
}, [offerings]);
|
||||||
|
|
||||||
|
const doPurchase = async () => {
|
||||||
|
if (!selectedPackage) return;
|
||||||
|
|
||||||
|
const packageType = selectedPackage.packageType || "unknown";
|
||||||
|
Events.purchaseInitiated(packageType);
|
||||||
|
|
||||||
|
const result = await purchasePackage(selectedPackage);
|
||||||
|
if (result.success) {
|
||||||
|
Events.purchaseCompleted(packageType);
|
||||||
|
onSuccess?.();
|
||||||
|
handleClose("success");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.cancelled) {
|
||||||
|
Events.purchaseCancelled(packageType, result.errorCode);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Events.purchaseFailed(packageType, result.errorCode, result.errorMessage);
|
||||||
|
};
|
||||||
|
|
||||||
const handlePurchase = async () => {
|
const handlePurchase = async () => {
|
||||||
if (!selectedPackage) return;
|
if (!selectedPackage) return;
|
||||||
|
|
||||||
Events.purchaseInitiated(selectedPackage.packageType || "unknown");
|
if (isAnonymous && !guestConfirmed) {
|
||||||
const success = await purchasePackage(selectedPackage);
|
setShowGuestConfirm(true);
|
||||||
if (success) {
|
return;
|
||||||
Events.purchaseCompleted(selectedPackage.packageType || "unknown");
|
|
||||||
onSuccess?.();
|
|
||||||
onClose();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await doPurchase();
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRestore = async () => {
|
const handleRestore = async () => {
|
||||||
@@ -93,7 +125,7 @@ export function Paywall({ visible, onClose, onSuccess }: PaywallProps) {
|
|||||||
if (restored) {
|
if (restored) {
|
||||||
Events.purchaseRestored();
|
Events.purchaseRestored();
|
||||||
onSuccess?.();
|
onSuccess?.();
|
||||||
onClose();
|
handleClose("restore");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -133,7 +165,7 @@ export function Paywall({ visible, onClose, onSuccess }: PaywallProps) {
|
|||||||
visible={visible}
|
visible={visible}
|
||||||
transparent
|
transparent
|
||||||
animationType="slide"
|
animationType="slide"
|
||||||
onRequestClose={onClose}
|
onRequestClose={() => handleClose("system")}
|
||||||
>
|
>
|
||||||
<View style={styles.overlay}>
|
<View style={styles.overlay}>
|
||||||
<View style={styles.content}>
|
<View style={styles.content}>
|
||||||
@@ -157,7 +189,7 @@ export function Paywall({ visible, onClose, onSuccess }: PaywallProps) {
|
|||||||
{/* Close Button */}
|
{/* Close Button */}
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={styles.closeButton}
|
style={styles.closeButton}
|
||||||
onPress={onClose}
|
onPress={() => handleClose("dismiss")}
|
||||||
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
||||||
>
|
>
|
||||||
<Ionicons name="close" size={24} color={colors.textSecondary} />
|
<Ionicons name="close" size={24} color={colors.textSecondary} />
|
||||||
@@ -384,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>
|
||||||
@@ -746,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,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -249,7 +249,7 @@ export function IntensitySlider({
|
|||||||
)}
|
)}
|
||||||
<Text style={styles.modalTitle}>
|
<Text style={styles.modalTitle}>
|
||||||
{levelsDiffer
|
{levelsDiffer
|
||||||
? (userIsHigher ? "Different Comfort Zones" : "You're in Sync")
|
? (userIsHigher ? "Different Comfort Zones" : "Room to Explore")
|
||||||
: "You're Perfectly Matched"
|
: "You're Perfectly Matched"
|
||||||
}
|
}
|
||||||
</Text>
|
</Text>
|
||||||
@@ -306,29 +306,27 @@ export function IntensitySlider({
|
|||||||
) : userIsHigher ? (
|
) : userIsHigher ? (
|
||||||
<>
|
<>
|
||||||
<Text style={styles.modalText}>
|
<Text style={styles.modalText}>
|
||||||
Your comfort zone is set higher than {displayPartnerName}'s.
|
You're both seeing questions up to {partnerLevel?.label} level because that's {displayPartnerName}'s comfort zone.
|
||||||
This means some questions you see won't appear for them.
|
|
||||||
</Text>
|
</Text>
|
||||||
<View style={[styles.modalTip, styles.modalTipWarning]}>
|
<View style={[styles.modalTip, styles.modalTipWarning]}>
|
||||||
<Ionicons name="information-circle" size={16} color={colors.warning} />
|
<Ionicons name="information-circle" size={16} color={colors.warning} />
|
||||||
<Text style={[styles.modalTipText, styles.modalTipTextWarning]}>
|
<Text style={[styles.modalTipText, styles.modalTipTextWarning]}>
|
||||||
Swipes on {current.label} content won't match since {displayPartnerName} is at {partnerLevel?.label}
|
You won't see {current.label} content until {displayPartnerName} raises their level
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
<Text style={styles.modalSubtext}>
|
<Text style={styles.modalSubtext}>
|
||||||
Consider aligning your levels, or enjoy different paces — it's your choice!
|
The lower setting between you determines what you both see.
|
||||||
</Text>
|
</Text>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Text style={styles.modalText}>
|
<Text style={styles.modalText}>
|
||||||
{displayPartnerName}'s comfort zone is higher than yours.
|
You're both seeing questions up to {current.label} level based on your comfort zone.
|
||||||
They might swipe on questions you don't see.
|
|
||||||
</Text>
|
</Text>
|
||||||
<View style={styles.modalTip}>
|
<View style={styles.modalTip}>
|
||||||
<Ionicons name="shield-checkmark" size={16} color={colors.secondary} />
|
<Ionicons name="arrow-up-circle" size={16} color={colors.secondary} />
|
||||||
<Text style={styles.modalTipText}>
|
<Text style={styles.modalTipText}>
|
||||||
You'll only see content within your comfort zone
|
{displayPartnerName} is at {partnerLevel?.label} — raise yours to unlock more content together
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -139,6 +139,7 @@ const ChatMessagesComponent: React.FC<ChatMessagesProps> = ({
|
|||||||
style={styles.messageList}
|
style={styles.messageList}
|
||||||
contentContainerStyle={styles.listContent}
|
contentContainerStyle={styles.listContent}
|
||||||
keyboardShouldPersistTaps="handled"
|
keyboardShouldPersistTaps="handled"
|
||||||
|
keyboardDismissMode="on-drag"
|
||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
// Performance optimizations
|
// Performance optimizations
|
||||||
removeClippedSubviews={true}
|
removeClippedSubviews={true}
|
||||||
|
|||||||
@@ -1,62 +1,42 @@
|
|||||||
import React from 'react';
|
import React, { useState } from 'react';
|
||||||
import { View, Text, StyleSheet, ScrollView, TouchableOpacity, useWindowDimensions, Platform, Alert, Linking } from 'react-native';
|
import { View, Text, StyleSheet, ScrollView, useWindowDimensions, Platform, Alert, TouchableOpacity } from 'react-native';
|
||||||
|
import { useRouter } from 'expo-router';
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
import Constants from 'expo-constants';
|
import Constants from 'expo-constants';
|
||||||
import Animated, {
|
import Animated, { FadeInDown } from 'react-native-reanimated';
|
||||||
FadeInDown,
|
import { Image } from 'expo-image';
|
||||||
useSharedValue,
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
useAnimatedScrollHandler,
|
|
||||||
useAnimatedStyle,
|
|
||||||
interpolate,
|
|
||||||
Extrapolation
|
|
||||||
} from 'react-native-reanimated';
|
|
||||||
import { BlurView } from 'expo-blur';
|
|
||||||
|
|
||||||
import { GradientBackground } from '../../components/ui';
|
import { GradientBackground } from '../../components/ui';
|
||||||
import { FeedbackModal } from '../../components/feedback';
|
|
||||||
import { Paywall } from '../../components/paywall';
|
import { Paywall } from '../../components/paywall';
|
||||||
import { colors, featureColors, spacing, typography, radius } from '../../theme';
|
import { colors, featureColors, spacing, typography, radius } from '../../theme';
|
||||||
import { useAuthStore, useSubscriptionStore } from '../../store';
|
import { useAuthStore, useSubscriptionStore } from '../../store';
|
||||||
import { resetSwipeTutorial } from '../../lib/swipeTutorialSeen';
|
import { resetSwipeTutorial } from '../../lib/swipeTutorialSeen';
|
||||||
import { resetMatchesTutorial } from '../../lib/matchesTutorialSeen';
|
import { resetMatchesTutorial } from '../../lib/matchesTutorialSeen';
|
||||||
import { supabase } from '../../lib/supabase'; // Needed for debug reset
|
import { supabase } from '../../lib/supabase';
|
||||||
|
|
||||||
// Hooks
|
|
||||||
import { useProfileSettings, useCoupleManagement } from './hooks';
|
|
||||||
|
|
||||||
// Components
|
// Components
|
||||||
import { AppearanceSettings, CoupleStatus, NotificationSettings, PrivacySettings, DangerZone, SettingsSection, MenuItem, SubscriptionCard } from './components';
|
import { SettingsSection, MenuItem, SubscriptionCard } from './components';
|
||||||
|
|
||||||
|
// Hooks
|
||||||
|
import { useProfileSettings } from './hooks';
|
||||||
|
|
||||||
const MAX_CONTENT_WIDTH = 500;
|
const MAX_CONTENT_WIDTH = 500;
|
||||||
const NAV_BAR_HEIGHT = 44;
|
const ACCENT_GRADIENT = featureColors.profile.gradient as [string, string];
|
||||||
const STATUS_BAR_HEIGHT = 60;
|
|
||||||
const HEADER_SCROLL_DISTANCE = 100;
|
|
||||||
|
|
||||||
const AnimatedScrollView = Animated.createAnimatedComponent(ScrollView);
|
|
||||||
|
|
||||||
export function ProfileScreen() {
|
export function ProfileScreen() {
|
||||||
|
const router = useRouter();
|
||||||
const { width } = useWindowDimensions();
|
const { width } = useWindowDimensions();
|
||||||
const isWideScreen = width > MAX_CONTENT_WIDTH;
|
const isWideScreen = width > MAX_CONTENT_WIDTH;
|
||||||
|
|
||||||
const { user, partner, couple, fetchUser } = useAuthStore();
|
const { user, partner, fetchUser, isAnonymous } = useAuthStore();
|
||||||
const { subscription } = useSubscriptionStore();
|
const { subscription } = useSubscriptionStore();
|
||||||
|
|
||||||
// Hooks
|
|
||||||
const settings = useProfileSettings();
|
const settings = useProfileSettings();
|
||||||
const {
|
|
||||||
handleUnpair,
|
|
||||||
handleDeleteRelationship,
|
|
||||||
handleResetProgress,
|
|
||||||
handleSignOut,
|
|
||||||
handleDeleteAccount,
|
|
||||||
navigateToPairing
|
|
||||||
} = useCoupleManagement();
|
|
||||||
|
|
||||||
const [showFeedbackModal, setShowFeedbackModal] = React.useState(false);
|
// Paywall state
|
||||||
|
const [showPaywall, setShowPaywall] = useState(false);
|
||||||
|
|
||||||
// Derived formatting
|
// Derived state
|
||||||
// Use OR: if RevenueCat says they're subscribed OR database says premium, it's their own
|
|
||||||
// This handles the race condition after purchase (RevenueCat updates before webhook syncs DB)
|
|
||||||
const isOwnSubscription = subscription.isProUser || user?.is_premium;
|
const isOwnSubscription = subscription.isProUser || user?.is_premium;
|
||||||
const hasPremiumAccess = user?.is_premium || partner?.is_premium || subscription.isProUser;
|
const hasPremiumAccess = user?.is_premium || partner?.is_premium || subscription.isProUser;
|
||||||
|
|
||||||
@@ -79,81 +59,20 @@ export function ProfileScreen() {
|
|||||||
});
|
});
|
||||||
const versionString = buildNumber ? `v${version} (${buildNumber})` : `v${version}`;
|
const versionString = buildNumber ? `v${version} (${buildNumber})` : `v${version}`;
|
||||||
|
|
||||||
// Scroll animation
|
|
||||||
const scrollY = useSharedValue(0);
|
|
||||||
const scrollHandler = useAnimatedScrollHandler({
|
|
||||||
onScroll: (event) => {
|
|
||||||
scrollY.value = event.contentOffset.y;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const heroStyle = useAnimatedStyle(() => {
|
|
||||||
const opacity = interpolate(
|
|
||||||
scrollY.value,
|
|
||||||
[0, HEADER_SCROLL_DISTANCE * 0.7],
|
|
||||||
[1, 0],
|
|
||||||
Extrapolation.CLAMP
|
|
||||||
);
|
|
||||||
const scale = interpolate(
|
|
||||||
scrollY.value,
|
|
||||||
[0, HEADER_SCROLL_DISTANCE],
|
|
||||||
[1, 0.95],
|
|
||||||
Extrapolation.CLAMP
|
|
||||||
);
|
|
||||||
return { opacity, transform: [{ scale }] };
|
|
||||||
});
|
|
||||||
|
|
||||||
const compactHeaderStyle = useAnimatedStyle(() => {
|
|
||||||
const opacity = interpolate(
|
|
||||||
scrollY.value,
|
|
||||||
[HEADER_SCROLL_DISTANCE * 0.5, HEADER_SCROLL_DISTANCE],
|
|
||||||
[0, 1],
|
|
||||||
Extrapolation.CLAMP
|
|
||||||
);
|
|
||||||
return { opacity };
|
|
||||||
});
|
|
||||||
|
|
||||||
const navBarBackgroundStyle = useAnimatedStyle(() => {
|
|
||||||
const opacity = interpolate(
|
|
||||||
scrollY.value,
|
|
||||||
[0, HEADER_SCROLL_DISTANCE * 0.8],
|
|
||||||
[0, 1],
|
|
||||||
Extrapolation.CLAMP
|
|
||||||
);
|
|
||||||
return { opacity };
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<GradientBackground>
|
<GradientBackground>
|
||||||
{/* Fixed Nav Bar */}
|
<ScrollView
|
||||||
<View style={styles.navBar}>
|
|
||||||
<Animated.View style={[styles.navBarBackground, navBarBackgroundStyle]}>
|
|
||||||
{Platform.OS === "ios" ? (
|
|
||||||
<BlurView intensity={80} tint="dark" style={StyleSheet.absoluteFill} />
|
|
||||||
) : (
|
|
||||||
<View style={[StyleSheet.absoluteFill, { backgroundColor: "rgba(13, 13, 26, 0.95)" }]} />
|
|
||||||
)}
|
|
||||||
</Animated.View>
|
|
||||||
|
|
||||||
<Animated.Text style={[styles.navBarTitle, compactHeaderStyle]} numberOfLines={1}>
|
|
||||||
Settings
|
|
||||||
</Animated.Text>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<AnimatedScrollView
|
|
||||||
style={styles.container}
|
style={styles.container}
|
||||||
contentContainerStyle={[
|
contentContainerStyle={[
|
||||||
styles.contentContainer,
|
styles.contentContainer,
|
||||||
isWideScreen && styles.contentContainerWide,
|
isWideScreen && styles.contentContainerWide,
|
||||||
]}
|
]}
|
||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
onScroll={scrollHandler}
|
|
||||||
scrollEventThrottle={16}
|
|
||||||
>
|
>
|
||||||
{/* Header title */}
|
{/* Header with user info (compact) */}
|
||||||
<Animated.View
|
<Animated.View
|
||||||
entering={FadeInDown.delay(100).duration(500)}
|
entering={FadeInDown.delay(100).duration(500)}
|
||||||
style={[styles.header, heroStyle]}
|
style={styles.header}
|
||||||
>
|
>
|
||||||
<Text style={styles.headerLabel}>YOUR SAUCI</Text>
|
<Text style={styles.headerLabel}>YOUR SAUCI</Text>
|
||||||
<Text style={styles.title}>Settings</Text>
|
<Text style={styles.title}>Settings</Text>
|
||||||
@@ -164,98 +83,124 @@ export function ProfileScreen() {
|
|||||||
</View>
|
</View>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
|
|
||||||
{/* Profile Header */}
|
{/* Compact Profile Preview */}
|
||||||
<AppearanceSettings
|
<Animated.View
|
||||||
user={user}
|
entering={FadeInDown.delay(150).duration(500)}
|
||||||
isUploadingAvatar={settings.isUploadingAvatar}
|
style={styles.profilePreview}
|
||||||
isEditingName={settings.isEditingName}
|
>
|
||||||
newName={settings.newName}
|
<TouchableOpacity
|
||||||
isUpdatingName={settings.isUpdatingName}
|
style={styles.profilePreviewContent}
|
||||||
onAvatarPress={settings.handleAvatarPress}
|
onPress={() => router.push('/(app)/settings/profile')}
|
||||||
onNewNameChange={settings.setNewName}
|
activeOpacity={0.7}
|
||||||
onUpdateName={settings.handleUpdateName}
|
>
|
||||||
onCancelEditName={settings.handleCancelEditName}
|
<LinearGradient
|
||||||
onStartEditingName={() => settings.setIsEditingName(true)}
|
colors={ACCENT_GRADIENT}
|
||||||
/>
|
style={styles.avatarGradient}
|
||||||
|
start={{ x: 0, y: 0 }}
|
||||||
|
end={{ x: 1, y: 1 }}
|
||||||
|
>
|
||||||
|
{user?.avatar_url ? (
|
||||||
|
<Image
|
||||||
|
source={{ uri: user.avatar_url }}
|
||||||
|
style={styles.avatar}
|
||||||
|
cachePolicy="disk"
|
||||||
|
transition={200}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<View style={styles.avatarInner}>
|
||||||
|
<Text style={styles.avatarText}>
|
||||||
|
{user?.name?.[0]?.toUpperCase() || "U"}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</LinearGradient>
|
||||||
|
<View style={styles.profileInfo}>
|
||||||
|
<Text style={styles.userName}>{user?.name || "User"}</Text>
|
||||||
|
<Text style={styles.userEmail}>
|
||||||
|
{isAnonymous ? "Unsaved account" : (user?.email || "")}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.profileChevron}>
|
||||||
|
<Ionicons name="chevron-forward" size={20} color={colors.textTertiary} />
|
||||||
|
</View>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</Animated.View>
|
||||||
|
|
||||||
{/* Partner */}
|
{/* Subscription CTA - prominent upsell */}
|
||||||
<CoupleStatus
|
|
||||||
partner={partner}
|
|
||||||
couple={couple}
|
|
||||||
onUnpair={handleUnpair}
|
|
||||||
onPairingPress={navigateToPairing}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Subscription Section */}
|
|
||||||
<SubscriptionCard
|
<SubscriptionCard
|
||||||
hasPremiumAccess={hasPremiumAccess}
|
hasPremiumAccess={hasPremiumAccess}
|
||||||
isOwnSubscription={!!isOwnSubscription}
|
isOwnSubscription={!!isOwnSubscription}
|
||||||
expirationDate={formatExpirationDate(subscription.expirationDate)}
|
expirationDate={formatExpirationDate(subscription.expirationDate)}
|
||||||
onUpgradePress={() => settings.setShowPaywall(true)}
|
onUpgradePress={() => setShowPaywall(true)}
|
||||||
onManagePress={settings.handleManageSubscription}
|
onManagePress={settings.handleManageSubscription}
|
||||||
onRestorePress={settings.handleRestorePurchases}
|
onRestorePress={settings.handleRestorePurchases}
|
||||||
isRestoring={settings.isPurchasing}
|
isRestoring={settings.isPurchasing}
|
||||||
delay={350}
|
delay={200}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Notifications */}
|
{/* Settings Navigation */}
|
||||||
<NotificationSettings
|
<SettingsSection title="Settings" delay={250}>
|
||||||
pushEnabled={settings.pushEnabled}
|
|
||||||
isUpdatingPush={settings.isUpdatingPush}
|
|
||||||
onPushToggle={settings.handlePushToggle}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Preferences */}
|
|
||||||
<PrivacySettings
|
|
||||||
maxIntensity={settings.maxIntensity}
|
|
||||||
isUpdatingIntensity={settings.isUpdatingIntensity}
|
|
||||||
onIntensityChange={settings.handleIntensityChange}
|
|
||||||
biometricAvailable={settings.biometricAvailable}
|
|
||||||
biometricEnabled={settings.biometricEnabled}
|
|
||||||
biometricType={settings.biometricType}
|
|
||||||
isUpdatingBiometric={settings.isUpdatingBiometric}
|
|
||||||
onBiometricToggle={settings.handleBiometricToggle}
|
|
||||||
partnerIntensity={settings.partnerIntensity}
|
|
||||||
partnerName={settings.partnerName}
|
|
||||||
partnerAvatar={settings.partnerAvatar}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Account */}
|
|
||||||
<SettingsSection title="Account" delay={425}>
|
|
||||||
<MenuItem
|
<MenuItem
|
||||||
icon="log-out-outline"
|
icon="person-circle-outline"
|
||||||
label="Sign Out"
|
label="Profile"
|
||||||
onPress={handleSignOut}
|
description="Avatar and display name"
|
||||||
variant="danger"
|
onPress={() => router.push('/(app)/settings/profile')}
|
||||||
showChevron={true}
|
/>
|
||||||
|
<View style={styles.divider} />
|
||||||
|
<MenuItem
|
||||||
|
icon="notifications-outline"
|
||||||
|
label="Notifications"
|
||||||
|
description="Push notification preferences"
|
||||||
|
onPress={() => router.push('/(app)/settings/notifications')}
|
||||||
|
/>
|
||||||
|
<View style={styles.divider} />
|
||||||
|
<MenuItem
|
||||||
|
icon="options-outline"
|
||||||
|
label="Preferences"
|
||||||
|
description="Comfort zone and security"
|
||||||
|
onPress={() => router.push('/(app)/settings/preferences')}
|
||||||
/>
|
/>
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
|
|
||||||
{/* Support */}
|
{/* Relationship Section */}
|
||||||
<SettingsSection title="Support" delay={475}>
|
<SettingsSection title="Relationship" delay={300}>
|
||||||
<MenuItem
|
<MenuItem
|
||||||
icon="chatbubble-ellipses-outline"
|
icon="heart-outline"
|
||||||
label="Send Feedback"
|
label="Partner"
|
||||||
description="Report bugs or request features"
|
rightText={partner?.name || (user?.couple_id ? 'Waiting...' : undefined)}
|
||||||
onPress={() => setShowFeedbackModal(true)}
|
onPress={() => router.push('/(app)/settings/partner')}
|
||||||
/>
|
|
||||||
<View style={styles.preferencesDivider} />
|
|
||||||
<MenuItem
|
|
||||||
icon="shield-checkmark-outline"
|
|
||||||
label="Privacy Policy"
|
|
||||||
onPress={() => Linking.openURL('https://sauci.app/privacy')}
|
|
||||||
/>
|
|
||||||
<View style={styles.preferencesDivider} />
|
|
||||||
<MenuItem
|
|
||||||
icon="document-text-outline"
|
|
||||||
label="Terms of Service"
|
|
||||||
onPress={() => Linking.openURL('https://sauci.app/terms')}
|
|
||||||
/>
|
/>
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
|
|
||||||
{/* Debug Section */}
|
{/* Other Section */}
|
||||||
|
<SettingsSection title="Other" delay={350}>
|
||||||
|
{isAnonymous && (
|
||||||
|
<>
|
||||||
|
<MenuItem
|
||||||
|
icon="shield-checkmark-outline"
|
||||||
|
label="Save Account"
|
||||||
|
description="Protect access across devices"
|
||||||
|
onPress={() => router.push('/(app)/settings/save-account' as any)}
|
||||||
|
/>
|
||||||
|
<View style={styles.divider} />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<MenuItem
|
||||||
|
icon="help-circle-outline"
|
||||||
|
label="Help & Support"
|
||||||
|
onPress={() => router.push('/(app)/settings/support')}
|
||||||
|
/>
|
||||||
|
<View style={styles.divider} />
|
||||||
|
<MenuItem
|
||||||
|
icon="settings-outline"
|
||||||
|
label="Account"
|
||||||
|
onPress={() => router.push('/(app)/settings/account')}
|
||||||
|
/>
|
||||||
|
</SettingsSection>
|
||||||
|
|
||||||
|
{/* Debug Section - Dev only */}
|
||||||
{__DEV__ && (
|
{__DEV__ && (
|
||||||
<SettingsSection title="Debug" delay={550}>
|
<SettingsSection title="Debug" delay={400}>
|
||||||
<MenuItem
|
<MenuItem
|
||||||
icon="refresh-outline"
|
icon="refresh-outline"
|
||||||
label="Reset Swipe Tutorial"
|
label="Reset Swipe Tutorial"
|
||||||
@@ -265,7 +210,7 @@ export function ProfileScreen() {
|
|||||||
Alert.alert("Success", "Swipe tutorial has been reset.");
|
Alert.alert("Success", "Swipe tutorial has been reset.");
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<View style={styles.preferencesDivider} />
|
<View style={styles.divider} />
|
||||||
<MenuItem
|
<MenuItem
|
||||||
icon="heart-outline"
|
icon="heart-outline"
|
||||||
label="Reset Matches Tutorial"
|
label="Reset Matches Tutorial"
|
||||||
@@ -275,7 +220,7 @@ export function ProfileScreen() {
|
|||||||
Alert.alert("Success", "Matches tutorial has been reset.");
|
Alert.alert("Success", "Matches tutorial has been reset.");
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<View style={styles.preferencesDivider} />
|
<View style={styles.divider} />
|
||||||
<MenuItem
|
<MenuItem
|
||||||
icon="school-outline"
|
icon="school-outline"
|
||||||
label="Reset Onboarding"
|
label="Reset Onboarding"
|
||||||
@@ -298,17 +243,9 @@ export function ProfileScreen() {
|
|||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Danger Zone */}
|
|
||||||
<DangerZone
|
|
||||||
onResetProgress={couple ? handleResetProgress : undefined}
|
|
||||||
onDeleteRelationship={couple ? handleDeleteRelationship : undefined}
|
|
||||||
onDeleteAccount={handleDeleteAccount}
|
|
||||||
hasRelationship={!!couple}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Version */}
|
{/* Version */}
|
||||||
<Animated.View
|
<Animated.View
|
||||||
entering={FadeInDown.delay(couple ? 650 : 525).duration(500)}
|
entering={FadeInDown.delay(__DEV__ ? 450 : 400).duration(500)}
|
||||||
style={styles.versionContainer}
|
style={styles.versionContainer}
|
||||||
>
|
>
|
||||||
<View style={styles.versionBadge}>
|
<View style={styles.versionBadge}>
|
||||||
@@ -318,16 +255,11 @@ export function ProfileScreen() {
|
|||||||
</Animated.View>
|
</Animated.View>
|
||||||
|
|
||||||
<View style={styles.bottomSpacer} />
|
<View style={styles.bottomSpacer} />
|
||||||
</AnimatedScrollView>
|
</ScrollView>
|
||||||
|
|
||||||
{/* Modals */}
|
|
||||||
<FeedbackModal
|
|
||||||
visible={showFeedbackModal}
|
|
||||||
onClose={() => setShowFeedbackModal(false)}
|
|
||||||
/>
|
|
||||||
<Paywall
|
<Paywall
|
||||||
visible={settings.showPaywall}
|
visible={showPaywall}
|
||||||
onClose={() => settings.setShowPaywall(false)}
|
onClose={() => setShowPaywall(false)}
|
||||||
/>
|
/>
|
||||||
</GradientBackground>
|
</GradientBackground>
|
||||||
);
|
);
|
||||||
@@ -337,33 +269,7 @@ const styles = StyleSheet.create({
|
|||||||
container: {
|
container: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
},
|
},
|
||||||
// Fixed Nav Bar
|
|
||||||
navBar: {
|
|
||||||
position: "absolute",
|
|
||||||
top: 0,
|
|
||||||
left: 0,
|
|
||||||
right: 0,
|
|
||||||
height: STATUS_BAR_HEIGHT + NAV_BAR_HEIGHT,
|
|
||||||
flexDirection: "row",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
paddingTop: STATUS_BAR_HEIGHT - 10,
|
|
||||||
paddingHorizontal: spacing.md,
|
|
||||||
zIndex: 100,
|
|
||||||
},
|
|
||||||
navBarBackground: {
|
|
||||||
...StyleSheet.absoluteFillObject,
|
|
||||||
borderBottomWidth: 1,
|
|
||||||
borderBottomColor: 'rgba(155, 89, 182, 0.15)', // Purple tint for profile
|
|
||||||
overflow: "hidden",
|
|
||||||
},
|
|
||||||
navBarTitle: {
|
|
||||||
...typography.headline,
|
|
||||||
color: colors.text,
|
|
||||||
textAlign: "center",
|
|
||||||
},
|
|
||||||
contentContainer: {
|
contentContainer: {
|
||||||
// Remove top padding here as the header adds it
|
|
||||||
paddingBottom: Platform.OS === 'ios' ? 100 : 80,
|
paddingBottom: Platform.OS === 'ios' ? 100 : 80,
|
||||||
},
|
},
|
||||||
contentContainerWide: {
|
contentContainerWide: {
|
||||||
@@ -372,7 +278,7 @@ const styles = StyleSheet.create({
|
|||||||
maxWidth: MAX_CONTENT_WIDTH,
|
maxWidth: MAX_CONTENT_WIDTH,
|
||||||
},
|
},
|
||||||
header: {
|
header: {
|
||||||
paddingTop: STATUS_BAR_HEIGHT + spacing.md,
|
paddingTop: Platform.OS === 'ios' ? 60 : 40,
|
||||||
paddingHorizontal: spacing.lg,
|
paddingHorizontal: spacing.lg,
|
||||||
paddingBottom: spacing.md,
|
paddingBottom: spacing.md,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
@@ -409,7 +315,65 @@ const styles = StyleSheet.create({
|
|||||||
marginHorizontal: spacing.sm,
|
marginHorizontal: spacing.sm,
|
||||||
opacity: 0.6,
|
opacity: 0.6,
|
||||||
},
|
},
|
||||||
preferencesDivider: {
|
// Profile Preview
|
||||||
|
profilePreview: {
|
||||||
|
marginHorizontal: spacing.lg,
|
||||||
|
marginBottom: spacing.lg,
|
||||||
|
backgroundColor: colors.glass.background,
|
||||||
|
borderRadius: radius.lg,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.glass.border,
|
||||||
|
overflow: 'hidden',
|
||||||
|
},
|
||||||
|
profilePreviewContent: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: spacing.md,
|
||||||
|
},
|
||||||
|
avatarGradient: {
|
||||||
|
width: 56,
|
||||||
|
height: 56,
|
||||||
|
borderRadius: 28,
|
||||||
|
padding: 2,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
avatar: {
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
borderRadius: 28,
|
||||||
|
backgroundColor: colors.background,
|
||||||
|
},
|
||||||
|
avatarInner: {
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
borderRadius: 28,
|
||||||
|
backgroundColor: colors.background,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
avatarText: {
|
||||||
|
...typography.title2,
|
||||||
|
fontSize: 24,
|
||||||
|
color: colors.text,
|
||||||
|
},
|
||||||
|
profileInfo: {
|
||||||
|
flex: 1,
|
||||||
|
marginLeft: spacing.md,
|
||||||
|
},
|
||||||
|
userName: {
|
||||||
|
...typography.headline,
|
||||||
|
color: colors.text,
|
||||||
|
},
|
||||||
|
userEmail: {
|
||||||
|
...typography.caption1,
|
||||||
|
color: colors.textSecondary,
|
||||||
|
marginTop: 2,
|
||||||
|
},
|
||||||
|
profileChevron: {
|
||||||
|
padding: spacing.xs,
|
||||||
|
},
|
||||||
|
divider: {
|
||||||
height: 1,
|
height: 1,
|
||||||
backgroundColor: colors.glass.border,
|
backgroundColor: colors.glass.border,
|
||||||
marginVertical: spacing.md,
|
marginVertical: spacing.md,
|
||||||
|
|||||||
@@ -15,6 +15,10 @@ export interface MenuItemProps {
|
|||||||
onPress: () => void;
|
onPress: () => void;
|
||||||
/** Optional element to show on the right */
|
/** Optional element to show on the right */
|
||||||
rightElement?: React.ReactNode;
|
rightElement?: React.ReactNode;
|
||||||
|
/** Text to display on the right side (e.g., partner name) */
|
||||||
|
rightText?: string;
|
||||||
|
/** Badge label to display (e.g., "Premium") */
|
||||||
|
badge?: string;
|
||||||
/** Whether to show chevron arrow */
|
/** Whether to show chevron arrow */
|
||||||
showChevron?: boolean;
|
showChevron?: boolean;
|
||||||
/** Visual variant */
|
/** Visual variant */
|
||||||
@@ -34,6 +38,8 @@ export function MenuItem({
|
|||||||
description,
|
description,
|
||||||
onPress,
|
onPress,
|
||||||
rightElement,
|
rightElement,
|
||||||
|
rightText,
|
||||||
|
badge,
|
||||||
showChevron = true,
|
showChevron = true,
|
||||||
variant = 'default',
|
variant = 'default',
|
||||||
disabled = false,
|
disabled = false,
|
||||||
@@ -65,17 +71,29 @@ export function MenuItem({
|
|||||||
</LinearGradient>
|
</LinearGradient>
|
||||||
)}
|
)}
|
||||||
<View style={styles.textContainer}>
|
<View style={styles.textContainer}>
|
||||||
<Text style={[styles.label, { color: labelColor }]}>{label}</Text>
|
<View style={styles.labelRow}>
|
||||||
|
<Text style={[styles.label, { color: labelColor }]}>{label}</Text>
|
||||||
|
{badge && (
|
||||||
|
<View style={styles.badge}>
|
||||||
|
<Text style={styles.badgeText}>{badge}</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
{description && (
|
{description && (
|
||||||
<Text style={styles.description}>{description}</Text>
|
<Text style={styles.description}>{description}</Text>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
{rightElement || (showChevron && (
|
<View style={styles.rightSection}>
|
||||||
<View style={styles.chevron}>
|
{rightText && (
|
||||||
<Ionicons name="chevron-forward" size={18} color={colors.textTertiary} />
|
<Text style={styles.rightText} numberOfLines={1}>{rightText}</Text>
|
||||||
</View>
|
)}
|
||||||
))}
|
{rightElement || (showChevron && (
|
||||||
|
<View style={styles.chevron}>
|
||||||
|
<Ionicons name="chevron-forward" size={18} color={colors.textTertiary} />
|
||||||
|
</View>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -113,15 +131,43 @@ const styles = StyleSheet.create({
|
|||||||
textContainer: {
|
textContainer: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
},
|
},
|
||||||
|
labelRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: spacing.sm,
|
||||||
|
},
|
||||||
label: {
|
label: {
|
||||||
...typography.body,
|
...typography.body,
|
||||||
fontWeight: '500',
|
fontWeight: '500',
|
||||||
},
|
},
|
||||||
|
badge: {
|
||||||
|
backgroundColor: colors.premium.goldLight,
|
||||||
|
paddingHorizontal: spacing.sm,
|
||||||
|
paddingVertical: 2,
|
||||||
|
borderRadius: radius.full,
|
||||||
|
},
|
||||||
|
badgeText: {
|
||||||
|
...typography.caption2,
|
||||||
|
color: colors.premium.gold,
|
||||||
|
fontWeight: '600',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
letterSpacing: 0.5,
|
||||||
|
},
|
||||||
description: {
|
description: {
|
||||||
...typography.caption1,
|
...typography.caption1,
|
||||||
color: colors.textSecondary,
|
color: colors.textSecondary,
|
||||||
marginTop: 2,
|
marginTop: 2,
|
||||||
},
|
},
|
||||||
|
rightSection: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: spacing.sm,
|
||||||
|
},
|
||||||
|
rightText: {
|
||||||
|
...typography.subhead,
|
||||||
|
color: colors.textSecondary,
|
||||||
|
maxWidth: 120,
|
||||||
|
},
|
||||||
chevron: {
|
chevron: {
|
||||||
padding: spacing.xs,
|
padding: spacing.xs,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import React from 'react';
|
import React, { useEffect, useMemo } from 'react';
|
||||||
|
import { View, StyleSheet, Text } from 'react-native';
|
||||||
|
import { colors, spacing, typography } from '../../../theme';
|
||||||
|
import { useNotificationPreferencesStore } from '../../../store';
|
||||||
|
import { useAuthStore } from '../../../store';
|
||||||
import { SettingsSection } from './SettingsSection';
|
import { SettingsSection } from './SettingsSection';
|
||||||
import { SwitchItem } from './SwitchItem';
|
import { SwitchItem } from './SwitchItem';
|
||||||
|
|
||||||
@@ -16,16 +19,132 @@ export const NotificationSettings: React.FC<NotificationSettingsProps> = ({
|
|||||||
onPushToggle,
|
onPushToggle,
|
||||||
delay = 360,
|
delay = 360,
|
||||||
}) => {
|
}) => {
|
||||||
|
const { user } = useAuthStore();
|
||||||
|
const { preferences, isUpdating, fetchPreferences, updatePreference } = useNotificationPreferencesStore();
|
||||||
|
|
||||||
|
// Fetch notification preferences on mount when user is available
|
||||||
|
useEffect(() => {
|
||||||
|
if (user?.id && !preferences) {
|
||||||
|
fetchPreferences();
|
||||||
|
}
|
||||||
|
}, [user?.id, preferences, fetchPreferences]);
|
||||||
|
|
||||||
|
// Memoize handlers to prevent unnecessary re-renders
|
||||||
|
const handlers = useMemo(() => ({
|
||||||
|
onMatchesToggle: (value: boolean) => updatePreference('matches_enabled', value),
|
||||||
|
onMessagesToggle: (value: boolean) => updatePreference('messages_enabled', value),
|
||||||
|
onPartnerActivityToggle: (value: boolean) => updatePreference('partner_activity_enabled', value),
|
||||||
|
onPackChangesToggle: (value: boolean) => updatePreference('pack_changes_enabled', value),
|
||||||
|
onNewPacksToggle: (value: boolean) => updatePreference('new_packs_enabled', value),
|
||||||
|
onStreakMilestonesToggle: (value: boolean) => updatePreference('streak_milestones_enabled', value),
|
||||||
|
}), [updatePreference]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SettingsSection title="Notifications" delay={delay}>
|
<SettingsSection title="Notifications" delay={delay}>
|
||||||
|
{/* Master push toggle */}
|
||||||
<SwitchItem
|
<SwitchItem
|
||||||
icon="notifications-outline"
|
icon="notifications-outline"
|
||||||
label="Push Notifications"
|
label="Push Notifications"
|
||||||
description="Get alerts for matches and messages"
|
description="Enable notifications on this device"
|
||||||
value={pushEnabled}
|
value={pushEnabled}
|
||||||
onValueChange={onPushToggle}
|
onValueChange={onPushToggle}
|
||||||
disabled={isUpdatingPush}
|
disabled={isUpdatingPush}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Granular settings - only show when push is enabled and preferences are loaded */}
|
||||||
|
{pushEnabled && preferences && (
|
||||||
|
<>
|
||||||
|
<View style={styles.sectionDivider}>
|
||||||
|
<Text style={styles.subsectionLabel}>Notification Types</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<SwitchItem
|
||||||
|
icon="heart-outline"
|
||||||
|
label="Matches"
|
||||||
|
description="When you and your partner match"
|
||||||
|
value={preferences.matches_enabled}
|
||||||
|
onValueChange={handlers.onMatchesToggle}
|
||||||
|
disabled={isUpdating}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<View style={styles.itemDivider} />
|
||||||
|
|
||||||
|
<SwitchItem
|
||||||
|
icon="chatbubble-outline"
|
||||||
|
label="Messages"
|
||||||
|
description="When your partner sends you a message"
|
||||||
|
value={preferences.messages_enabled}
|
||||||
|
onValueChange={handlers.onMessagesToggle}
|
||||||
|
disabled={isUpdating}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<View style={styles.itemDivider} />
|
||||||
|
|
||||||
|
<SwitchItem
|
||||||
|
icon="person-outline"
|
||||||
|
label="Partner Activity"
|
||||||
|
description="When your partner answers questions"
|
||||||
|
value={preferences.partner_activity_enabled}
|
||||||
|
onValueChange={handlers.onPartnerActivityToggle}
|
||||||
|
disabled={isUpdating}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<View style={styles.itemDivider} />
|
||||||
|
|
||||||
|
<SwitchItem
|
||||||
|
icon="layers-outline"
|
||||||
|
label="Pack Updates"
|
||||||
|
description="When your partner enables new packs"
|
||||||
|
value={preferences.pack_changes_enabled}
|
||||||
|
onValueChange={handlers.onPackChangesToggle}
|
||||||
|
disabled={isUpdating}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<View style={styles.itemDivider} />
|
||||||
|
|
||||||
|
<SwitchItem
|
||||||
|
icon="sparkles-outline"
|
||||||
|
label="New Packs"
|
||||||
|
description="When new question packs are available"
|
||||||
|
value={preferences.new_packs_enabled}
|
||||||
|
onValueChange={handlers.onNewPacksToggle}
|
||||||
|
disabled={isUpdating}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<View style={styles.itemDivider} />
|
||||||
|
|
||||||
|
<SwitchItem
|
||||||
|
icon="flame-outline"
|
||||||
|
label="Streak Milestones"
|
||||||
|
description="Celebrate your streak achievements"
|
||||||
|
value={preferences.streak_milestones_enabled}
|
||||||
|
onValueChange={handlers.onStreakMilestonesToggle}
|
||||||
|
disabled={isUpdating}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
sectionDivider: {
|
||||||
|
marginTop: spacing.lg,
|
||||||
|
marginBottom: spacing.md,
|
||||||
|
paddingTop: spacing.md,
|
||||||
|
borderTopWidth: 1,
|
||||||
|
borderTopColor: colors.glass.border,
|
||||||
|
},
|
||||||
|
subsectionLabel: {
|
||||||
|
...typography.caption1,
|
||||||
|
color: colors.textTertiary,
|
||||||
|
fontWeight: '600',
|
||||||
|
letterSpacing: 1,
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
},
|
||||||
|
itemDivider: {
|
||||||
|
height: 1,
|
||||||
|
backgroundColor: colors.glass.border,
|
||||||
|
marginVertical: spacing.sm,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
import React from 'react';
|
import React, { useState } from 'react';
|
||||||
import { View, Text, StyleSheet } from 'react-native';
|
import { View, Text, StyleSheet, TouchableOpacity, Modal, Platform, ScrollView } from 'react-native';
|
||||||
import { LinearGradient } from 'expo-linear-gradient';
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
|
import { BlurView } from 'expo-blur';
|
||||||
import { Ionicons } from '@expo/vector-icons';
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
import { SettingsSection } from './SettingsSection';
|
import { SettingsSection } from './SettingsSection';
|
||||||
import { SwitchItem } from './SwitchItem';
|
import { SwitchItem } from './SwitchItem';
|
||||||
import { IntensitySlider } from '../../../components/ui/IntensitySlider';
|
import { IntensitySlider } from '../../../components/ui/IntensitySlider';
|
||||||
|
import { GlassButton } from '../../../components/ui';
|
||||||
import { colors, spacing, radius, typography, featureColors } from '../../../theme';
|
import { colors, spacing, radius, typography, featureColors } from '../../../theme';
|
||||||
import type { IntensityLevel } from '../../../types';
|
import type { IntensityLevel } from '../../../types';
|
||||||
|
|
||||||
@@ -38,7 +40,10 @@ export const PrivacySettings: React.FC<PrivacySettingsProps> = ({
|
|||||||
partnerName,
|
partnerName,
|
||||||
partnerAvatar,
|
partnerAvatar,
|
||||||
}) => {
|
}) => {
|
||||||
|
const [showHelpModal, setShowHelpModal] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<SettingsSection title="Preferences" delay={375}>
|
<SettingsSection title="Preferences" delay={375}>
|
||||||
{/* Comfort Zone Header */}
|
{/* Comfort Zone Header */}
|
||||||
<View style={styles.comfortHeader}>
|
<View style={styles.comfortHeader}>
|
||||||
@@ -51,7 +56,16 @@ export const PrivacySettings: React.FC<PrivacySettingsProps> = ({
|
|||||||
<Ionicons name="heart-circle" size={22} color={colors.text} />
|
<Ionicons name="heart-circle" size={22} color={colors.text} />
|
||||||
</LinearGradient>
|
</LinearGradient>
|
||||||
<View style={styles.comfortText}>
|
<View style={styles.comfortText}>
|
||||||
<Text style={styles.comfortLabel}>Comfort Zone</Text>
|
<View style={styles.comfortLabelRow}>
|
||||||
|
<Text style={styles.comfortLabel}>Comfort Zone</Text>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => setShowHelpModal(true)}
|
||||||
|
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<Ionicons name="help-circle-outline" size={20} color={colors.textSecondary} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
<Text style={styles.comfortDescription}>
|
<Text style={styles.comfortDescription}>
|
||||||
Set the intimacy level you're comfortable with
|
Set the intimacy level you're comfortable with
|
||||||
</Text>
|
</Text>
|
||||||
@@ -85,6 +99,100 @@ export const PrivacySettings: React.FC<PrivacySettingsProps> = ({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
|
|
||||||
|
{/* Help Modal */}
|
||||||
|
<Modal
|
||||||
|
visible={showHelpModal}
|
||||||
|
transparent
|
||||||
|
animationType="fade"
|
||||||
|
onRequestClose={() => setShowHelpModal(false)}
|
||||||
|
>
|
||||||
|
<BlurView
|
||||||
|
intensity={Platform.OS === 'ios' ? 20 : 0}
|
||||||
|
tint="dark"
|
||||||
|
style={StyleSheet.absoluteFill}
|
||||||
|
>
|
||||||
|
<View style={styles.modalOverlay}>
|
||||||
|
<View style={styles.modalContent}>
|
||||||
|
<View style={styles.modalHeader}>
|
||||||
|
<Text style={styles.modalTitle}>How Comfort Zone Works</Text>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={() => setShowHelpModal(false)}
|
||||||
|
style={styles.closeButton}
|
||||||
|
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
||||||
|
>
|
||||||
|
<Ionicons name="close" size={24} color={colors.textSecondary} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<ScrollView
|
||||||
|
style={styles.helpScrollView}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
>
|
||||||
|
<View style={styles.helpSection}>
|
||||||
|
<View style={styles.helpItem}>
|
||||||
|
<Ionicons name="layers-outline" size={20} color={colors.primary} style={styles.helpIcon} />
|
||||||
|
<View style={styles.helpTextContainer}>
|
||||||
|
<Text style={styles.helpItemTitle}>Intensity Levels</Text>
|
||||||
|
<Text style={styles.helpItemDescription}>
|
||||||
|
Questions range from 1 (mild) to 5 (spicy). You'll only see questions at or below your comfort level.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.helpItem}>
|
||||||
|
<Ionicons name="albums-outline" size={20} color={colors.primary} style={styles.helpIcon} />
|
||||||
|
<View style={styles.helpTextContainer}>
|
||||||
|
<Text style={styles.helpItemTitle}>Hidden Packs</Text>
|
||||||
|
<Text style={styles.helpItemDescription}>
|
||||||
|
Some question packs are designed for higher intensity levels. These packs won't appear until you raise your comfort zone.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.helpItem}>
|
||||||
|
<Ionicons name="people-outline" size={20} color={colors.primary} style={styles.helpIcon} />
|
||||||
|
<View style={styles.helpTextContainer}>
|
||||||
|
<Text style={styles.helpItemTitle}>Comfort Zone Gate</Text>
|
||||||
|
<Text style={styles.helpItemDescription}>
|
||||||
|
You both see the same questions based on the lower comfort zone between you. If you're at 5 and your partner is at 3, you'll both only see questions up to level 3.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.helpItem}>
|
||||||
|
<Ionicons name="eye-outline" size={20} color={colors.primary} style={styles.helpIcon} />
|
||||||
|
<View style={styles.helpTextContainer}>
|
||||||
|
<Text style={styles.helpItemTitle}>Visible to Partner</Text>
|
||||||
|
<Text style={styles.helpItemDescription}>
|
||||||
|
Your partner can see the comfort zone level you choose so you can stay aligned. This doesn’t reveal your individual swipes.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={[styles.helpItem, styles.helpItemLast]}>
|
||||||
|
<Ionicons name="refresh-outline" size={20} color={colors.primary} style={styles.helpIcon} />
|
||||||
|
<View style={styles.helpTextContainer}>
|
||||||
|
<Text style={styles.helpItemTitle}>Change Anytime</Text>
|
||||||
|
<Text style={styles.helpItemDescription}>
|
||||||
|
Adjust whenever you want. New questions and packs will appear or hide based on your new setting.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</ScrollView>
|
||||||
|
|
||||||
|
<GlassButton
|
||||||
|
onPress={() => setShowHelpModal(false)}
|
||||||
|
fullWidth
|
||||||
|
>
|
||||||
|
Got it
|
||||||
|
</GlassButton>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</BlurView>
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -105,6 +213,11 @@ const styles = StyleSheet.create({
|
|||||||
comfortText: {
|
comfortText: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
},
|
},
|
||||||
|
comfortLabelRow: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: spacing.xs,
|
||||||
|
},
|
||||||
comfortLabel: {
|
comfortLabel: {
|
||||||
...typography.headline,
|
...typography.headline,
|
||||||
color: colors.text,
|
color: colors.text,
|
||||||
@@ -122,4 +235,71 @@ const styles = StyleSheet.create({
|
|||||||
backgroundColor: colors.glass.border,
|
backgroundColor: colors.glass.border,
|
||||||
marginVertical: spacing.lg,
|
marginVertical: spacing.lg,
|
||||||
},
|
},
|
||||||
|
// Modal styles
|
||||||
|
modalOverlay: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: 'rgba(0, 0, 0, 0.7)',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
padding: spacing.lg,
|
||||||
|
},
|
||||||
|
modalContent: {
|
||||||
|
width: '100%',
|
||||||
|
maxWidth: 400,
|
||||||
|
maxHeight: '80%',
|
||||||
|
backgroundColor: colors.backgroundLight,
|
||||||
|
borderRadius: radius.xl,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.glass.border,
|
||||||
|
padding: spacing.lg,
|
||||||
|
},
|
||||||
|
modalHeader: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
marginBottom: spacing.md,
|
||||||
|
paddingBottom: spacing.md,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: colors.glass.border,
|
||||||
|
},
|
||||||
|
modalTitle: {
|
||||||
|
...typography.headline,
|
||||||
|
color: colors.text,
|
||||||
|
},
|
||||||
|
closeButton: {
|
||||||
|
padding: spacing.xs,
|
||||||
|
},
|
||||||
|
helpScrollView: {
|
||||||
|
flexGrow: 0,
|
||||||
|
marginBottom: spacing.md,
|
||||||
|
},
|
||||||
|
helpSection: {
|
||||||
|
gap: spacing.sm,
|
||||||
|
},
|
||||||
|
helpItem: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
paddingVertical: spacing.sm,
|
||||||
|
},
|
||||||
|
helpItemLast: {
|
||||||
|
paddingBottom: 0,
|
||||||
|
},
|
||||||
|
helpIcon: {
|
||||||
|
marginRight: spacing.sm,
|
||||||
|
marginTop: 2,
|
||||||
|
},
|
||||||
|
helpTextContainer: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
helpItemTitle: {
|
||||||
|
...typography.subhead,
|
||||||
|
fontWeight: '600',
|
||||||
|
color: colors.text,
|
||||||
|
marginBottom: 2,
|
||||||
|
},
|
||||||
|
helpItemDescription: {
|
||||||
|
...typography.caption1,
|
||||||
|
color: colors.textSecondary,
|
||||||
|
lineHeight: 18,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
@@ -0,0 +1,584 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { View, Text, StyleSheet, Modal, Platform, KeyboardAvoidingView, TouchableOpacity, ActivityIndicator, TextInput, ViewStyle } from 'react-native';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import Animated, { FadeInDown } from 'react-native-reanimated';
|
||||||
|
import { BlurView } from 'expo-blur';
|
||||||
|
import { GlassCard, GlassButton } from '../../../components/ui';
|
||||||
|
import { colors, spacing, radius, typography } from '../../../theme';
|
||||||
|
|
||||||
|
interface RelationshipDangerZoneProps {
|
||||||
|
onResetProgress?: () => Promise<void>;
|
||||||
|
onDeleteRelationship?: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Danger zone component specifically for relationship-related actions.
|
||||||
|
* Does NOT include delete account - that's in AccountScreen.
|
||||||
|
*/
|
||||||
|
export const RelationshipDangerZone: React.FC<RelationshipDangerZoneProps> = ({
|
||||||
|
onResetProgress,
|
||||||
|
onDeleteRelationship,
|
||||||
|
}) => {
|
||||||
|
const [showResetModal, setShowResetModal] = useState(false);
|
||||||
|
const [showDeleteRelationshipModal, setShowDeleteRelationshipModal] = useState(false);
|
||||||
|
const [confirmText, setConfirmText] = useState("");
|
||||||
|
const [isResetting, setIsResetting] = useState(false);
|
||||||
|
const [isDeletingRelationship, setIsDeletingRelationship] = useState(false);
|
||||||
|
|
||||||
|
const handleResetProgress = async () => {
|
||||||
|
if (confirmText !== "RESET" || !onResetProgress) return;
|
||||||
|
setIsResetting(true);
|
||||||
|
try {
|
||||||
|
await onResetProgress();
|
||||||
|
setShowResetModal(false);
|
||||||
|
setConfirmText("");
|
||||||
|
} catch (error) {
|
||||||
|
// Error handling relies on parent
|
||||||
|
} finally {
|
||||||
|
setIsResetting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeResetModal = () => {
|
||||||
|
if (!isResetting) {
|
||||||
|
setShowResetModal(false);
|
||||||
|
setConfirmText("");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDeleteRelationship = async () => {
|
||||||
|
if (confirmText !== "DELETE" || !onDeleteRelationship) return;
|
||||||
|
setIsDeletingRelationship(true);
|
||||||
|
try {
|
||||||
|
await onDeleteRelationship();
|
||||||
|
setShowDeleteRelationshipModal(false);
|
||||||
|
setConfirmText("");
|
||||||
|
} catch (error) {
|
||||||
|
// Error handling relies on parent
|
||||||
|
} finally {
|
||||||
|
setIsDeletingRelationship(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeRelationshipModal = () => {
|
||||||
|
if (!isDeletingRelationship) {
|
||||||
|
setShowDeleteRelationshipModal(false);
|
||||||
|
setConfirmText("");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Animated.View
|
||||||
|
entering={FadeInDown.delay(100).duration(500)}
|
||||||
|
style={styles.section}
|
||||||
|
>
|
||||||
|
<Text style={styles.dangerSectionTitle}>Danger Zone</Text>
|
||||||
|
|
||||||
|
{/* Reset Progress */}
|
||||||
|
{onResetProgress && (
|
||||||
|
<GlassCard style={styles.warningCard}>
|
||||||
|
<View style={styles.dangerContent}>
|
||||||
|
<View style={styles.dangerInfo}>
|
||||||
|
<View style={styles.warningIconContainer}>
|
||||||
|
<Ionicons name="refresh" size={20} color={colors.warning} />
|
||||||
|
</View>
|
||||||
|
<View style={styles.dangerTextContainer}>
|
||||||
|
<Text style={styles.dangerTitle}>Reset Progress</Text>
|
||||||
|
<Text style={styles.dangerDescription}>
|
||||||
|
Start fresh by clearing all swipes, matches, and chats. Your partner connection stays.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.warningButton}
|
||||||
|
onPress={() => setShowResetModal(true)}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<Ionicons name="refresh-outline" size={16} color={colors.warning} />
|
||||||
|
<Text style={styles.warningButtonText}>Reset</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</GlassCard>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Delete Relationship */}
|
||||||
|
{onDeleteRelationship && (
|
||||||
|
<GlassCard style={StyleSheet.flatten([styles.dangerCard, onResetProgress && styles.dangerCardSpacing]) as ViewStyle}>
|
||||||
|
<View style={styles.dangerContent}>
|
||||||
|
<View style={styles.dangerInfo}>
|
||||||
|
<View style={styles.dangerIconContainer}>
|
||||||
|
<Ionicons name="heart-dislike" size={20} color={colors.error} />
|
||||||
|
</View>
|
||||||
|
<View style={styles.dangerTextContainer}>
|
||||||
|
<Text style={styles.dangerTitle}>Delete Relationship</Text>
|
||||||
|
<Text style={styles.dangerDescription}>
|
||||||
|
End your connection and delete all shared data. Your account will remain.
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<GlassButton
|
||||||
|
variant="danger"
|
||||||
|
size="sm"
|
||||||
|
onPress={() => setShowDeleteRelationshipModal(true)}
|
||||||
|
icon={<Ionicons name="trash-outline" size={16} color={colors.text} />}
|
||||||
|
style={styles.dangerButton}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</GlassButton>
|
||||||
|
</View>
|
||||||
|
</GlassCard>
|
||||||
|
)}
|
||||||
|
</Animated.View>
|
||||||
|
|
||||||
|
{/* Reset Progress Modal */}
|
||||||
|
<Modal
|
||||||
|
visible={showResetModal}
|
||||||
|
transparent
|
||||||
|
animationType="fade"
|
||||||
|
onRequestClose={closeResetModal}
|
||||||
|
>
|
||||||
|
<BlurView
|
||||||
|
intensity={Platform.OS === 'ios' ? 20 : 0}
|
||||||
|
tint="dark"
|
||||||
|
style={StyleSheet.absoluteFill}
|
||||||
|
>
|
||||||
|
<KeyboardAvoidingView
|
||||||
|
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||||
|
style={styles.modalOverlay}
|
||||||
|
>
|
||||||
|
<View style={styles.modalContent}>
|
||||||
|
<View style={styles.modalHeader}>
|
||||||
|
<View style={styles.warningModalIconContainer}>
|
||||||
|
<Ionicons name="refresh" size={32} color={colors.warning} />
|
||||||
|
</View>
|
||||||
|
<Text style={styles.modalTitle}>Reset Progress?</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text style={styles.modalDescription}>
|
||||||
|
This will permanently delete:
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<View style={styles.warningList}>
|
||||||
|
<View style={styles.deleteListItem}>
|
||||||
|
<Ionicons name="swap-horizontal" size={16} color={colors.warning} />
|
||||||
|
<Text style={styles.deleteListText}>All your swipes</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.deleteListItem}>
|
||||||
|
<Ionicons name="heart" size={16} color={colors.warning} />
|
||||||
|
<Text style={styles.deleteListText}>All matches</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.deleteListItem}>
|
||||||
|
<Ionicons name="chatbubbles" size={16} color={colors.warning} />
|
||||||
|
<Text style={styles.deleteListText}>All chat messages</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={styles.keepList}>
|
||||||
|
<View style={styles.deleteListItem}>
|
||||||
|
<Ionicons name="checkmark-circle" size={16} color={colors.success} />
|
||||||
|
<Text style={styles.deleteListText}>Your partner connection stays</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text style={styles.confirmLabel}>
|
||||||
|
Type <Text style={styles.warningKeyword}>RESET</Text> to confirm:
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
style={styles.warningInput}
|
||||||
|
value={confirmText}
|
||||||
|
onChangeText={setConfirmText}
|
||||||
|
placeholder="Type RESET"
|
||||||
|
placeholderTextColor={colors.textTertiary}
|
||||||
|
autoCapitalize="characters"
|
||||||
|
autoCorrect={false}
|
||||||
|
editable={!isResetting}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<View style={styles.modalButtons}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.cancelButton}
|
||||||
|
onPress={closeResetModal}
|
||||||
|
disabled={isResetting}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<Text style={styles.cancelButtonText}>Cancel</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[
|
||||||
|
styles.warningActionButton,
|
||||||
|
(confirmText !== "RESET" || isResetting) && styles.deleteButtonDisabled,
|
||||||
|
]}
|
||||||
|
onPress={handleResetProgress}
|
||||||
|
disabled={confirmText !== "RESET" || isResetting}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
{isResetting ? (
|
||||||
|
<ActivityIndicator size="small" color={colors.text} />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Ionicons name="refresh" size={16} color={colors.text} />
|
||||||
|
<Text style={styles.deleteButtonText}>Reset</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</KeyboardAvoidingView>
|
||||||
|
</BlurView>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* Delete Relationship Modal */}
|
||||||
|
<Modal
|
||||||
|
visible={showDeleteRelationshipModal}
|
||||||
|
transparent
|
||||||
|
animationType="fade"
|
||||||
|
onRequestClose={closeRelationshipModal}
|
||||||
|
>
|
||||||
|
<BlurView
|
||||||
|
intensity={Platform.OS === 'ios' ? 20 : 0}
|
||||||
|
tint="dark"
|
||||||
|
style={StyleSheet.absoluteFill}
|
||||||
|
>
|
||||||
|
<KeyboardAvoidingView
|
||||||
|
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
|
||||||
|
style={styles.modalOverlay}
|
||||||
|
>
|
||||||
|
<View style={styles.modalContent}>
|
||||||
|
<View style={styles.modalHeader}>
|
||||||
|
<View style={styles.modalIconContainer}>
|
||||||
|
<Ionicons name="heart-dislike" size={32} color={colors.error} />
|
||||||
|
</View>
|
||||||
|
<Text style={styles.modalTitle}>Delete Relationship Data?</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text style={styles.modalDescription}>
|
||||||
|
This action cannot be undone. This will permanently delete:
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<View style={styles.deleteList}>
|
||||||
|
<View style={styles.deleteListItem}>
|
||||||
|
<Ionicons name="heart-dislike" size={16} color={colors.error} />
|
||||||
|
<Text style={styles.deleteListText}>Your couple connection</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.deleteListItem}>
|
||||||
|
<Ionicons name="checkmark-circle" size={16} color={colors.error} />
|
||||||
|
<Text style={styles.deleteListText}>All matches and responses</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.deleteListItem}>
|
||||||
|
<Ionicons name="chatbubbles" size={16} color={colors.error} />
|
||||||
|
<Text style={styles.deleteListText}>All chat messages</Text>
|
||||||
|
</View>
|
||||||
|
<View style={styles.deleteListItem}>
|
||||||
|
<Ionicons name="images" size={16} color={colors.error} />
|
||||||
|
<Text style={styles.deleteListText}>All shared photos</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text style={styles.confirmLabel}>
|
||||||
|
Type <Text style={styles.confirmKeyword}>DELETE</Text> to confirm:
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<TextInput
|
||||||
|
style={styles.confirmInput}
|
||||||
|
value={confirmText}
|
||||||
|
onChangeText={setConfirmText}
|
||||||
|
placeholder="Type DELETE"
|
||||||
|
placeholderTextColor={colors.textTertiary}
|
||||||
|
autoCapitalize="characters"
|
||||||
|
autoCorrect={false}
|
||||||
|
editable={!isDeletingRelationship}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<View style={styles.modalButtons}>
|
||||||
|
<TouchableOpacity
|
||||||
|
style={styles.cancelButton}
|
||||||
|
onPress={closeRelationshipModal}
|
||||||
|
disabled={isDeletingRelationship}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
<Text style={styles.cancelButtonText}>Cancel</Text>
|
||||||
|
</TouchableOpacity>
|
||||||
|
|
||||||
|
<TouchableOpacity
|
||||||
|
style={[
|
||||||
|
styles.deleteButton,
|
||||||
|
(confirmText !== "DELETE" || isDeletingRelationship) && styles.deleteButtonDisabled,
|
||||||
|
]}
|
||||||
|
onPress={handleDeleteRelationship}
|
||||||
|
disabled={confirmText !== "DELETE" || isDeletingRelationship}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
{isDeletingRelationship ? (
|
||||||
|
<ActivityIndicator size="small" color={colors.text} />
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Ionicons name="trash" size={16} color={colors.text} />
|
||||||
|
<Text style={styles.deleteButtonText}>Delete</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</KeyboardAvoidingView>
|
||||||
|
</BlurView>
|
||||||
|
</Modal>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
section: {
|
||||||
|
paddingHorizontal: spacing.lg,
|
||||||
|
marginBottom: spacing.lg,
|
||||||
|
},
|
||||||
|
dangerSectionTitle: {
|
||||||
|
...typography.caption1,
|
||||||
|
color: colors.error,
|
||||||
|
textTransform: "uppercase",
|
||||||
|
letterSpacing: 1.5,
|
||||||
|
marginBottom: spacing.sm,
|
||||||
|
marginLeft: spacing.xs,
|
||||||
|
},
|
||||||
|
warningCard: {
|
||||||
|
borderColor: 'rgba(243, 156, 18, 0.3)',
|
||||||
|
borderWidth: 1,
|
||||||
|
},
|
||||||
|
warningIconContainer: {
|
||||||
|
width: 44,
|
||||||
|
height: 44,
|
||||||
|
borderRadius: 22,
|
||||||
|
backgroundColor: 'rgba(243, 156, 18, 0.15)',
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
warningButton: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
backgroundColor: 'rgba(243, 156, 18, 0.15)',
|
||||||
|
paddingVertical: spacing.sm,
|
||||||
|
paddingHorizontal: spacing.md,
|
||||||
|
borderRadius: radius.md,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: 'rgba(243, 156, 18, 0.3)',
|
||||||
|
gap: spacing.xs,
|
||||||
|
alignSelf: "flex-end",
|
||||||
|
},
|
||||||
|
warningButtonText: {
|
||||||
|
...typography.subhead,
|
||||||
|
color: colors.warning,
|
||||||
|
fontWeight: "600",
|
||||||
|
},
|
||||||
|
dangerCard: {
|
||||||
|
borderColor: 'rgba(231, 76, 60, 0.3)',
|
||||||
|
borderWidth: 1,
|
||||||
|
},
|
||||||
|
dangerCardSpacing: {
|
||||||
|
marginTop: spacing.md,
|
||||||
|
},
|
||||||
|
dangerContent: {
|
||||||
|
gap: spacing.md,
|
||||||
|
},
|
||||||
|
dangerInfo: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "flex-start",
|
||||||
|
},
|
||||||
|
dangerIconContainer: {
|
||||||
|
width: 44,
|
||||||
|
height: 44,
|
||||||
|
borderRadius: 22,
|
||||||
|
backgroundColor: colors.errorLight,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
},
|
||||||
|
dangerTextContainer: {
|
||||||
|
flex: 1,
|
||||||
|
marginLeft: spacing.md,
|
||||||
|
},
|
||||||
|
dangerTitle: {
|
||||||
|
...typography.headline,
|
||||||
|
color: colors.text,
|
||||||
|
marginBottom: spacing.xs,
|
||||||
|
},
|
||||||
|
dangerDescription: {
|
||||||
|
...typography.subhead,
|
||||||
|
color: colors.textSecondary,
|
||||||
|
lineHeight: 20,
|
||||||
|
},
|
||||||
|
dangerButton: {
|
||||||
|
alignSelf: "flex-end",
|
||||||
|
},
|
||||||
|
// Modal styles
|
||||||
|
modalOverlay: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: "rgba(0, 0, 0, 0.7)",
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
padding: spacing.lg,
|
||||||
|
},
|
||||||
|
modalContent: {
|
||||||
|
width: "100%",
|
||||||
|
maxWidth: 400,
|
||||||
|
padding: spacing.xl,
|
||||||
|
backgroundColor: colors.backgroundLight,
|
||||||
|
borderRadius: radius.xl,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.glass.border,
|
||||||
|
},
|
||||||
|
modalHeader: {
|
||||||
|
alignItems: "center",
|
||||||
|
marginBottom: spacing.lg,
|
||||||
|
},
|
||||||
|
modalIconContainer: {
|
||||||
|
width: 64,
|
||||||
|
height: 64,
|
||||||
|
borderRadius: 32,
|
||||||
|
backgroundColor: colors.errorLight,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
marginBottom: spacing.md,
|
||||||
|
},
|
||||||
|
modalTitle: {
|
||||||
|
...typography.title2,
|
||||||
|
color: colors.text,
|
||||||
|
textAlign: "center",
|
||||||
|
},
|
||||||
|
modalDescription: {
|
||||||
|
...typography.body,
|
||||||
|
color: colors.textSecondary,
|
||||||
|
marginBottom: spacing.lg,
|
||||||
|
textAlign: "center",
|
||||||
|
},
|
||||||
|
deleteList: {
|
||||||
|
backgroundColor: "rgba(231, 76, 60, 0.1)",
|
||||||
|
borderRadius: radius.lg,
|
||||||
|
padding: spacing.md,
|
||||||
|
gap: spacing.sm,
|
||||||
|
marginBottom: spacing.xl,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: 'rgba(231, 76, 60, 0.2)',
|
||||||
|
},
|
||||||
|
warningList: {
|
||||||
|
backgroundColor: "rgba(243, 156, 18, 0.1)",
|
||||||
|
borderRadius: radius.lg,
|
||||||
|
padding: spacing.md,
|
||||||
|
gap: spacing.sm,
|
||||||
|
marginBottom: spacing.md,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: 'rgba(243, 156, 18, 0.2)',
|
||||||
|
},
|
||||||
|
keepList: {
|
||||||
|
backgroundColor: "rgba(46, 204, 113, 0.1)",
|
||||||
|
borderRadius: radius.lg,
|
||||||
|
padding: spacing.md,
|
||||||
|
gap: spacing.sm,
|
||||||
|
marginBottom: spacing.xl,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: 'rgba(46, 204, 113, 0.2)',
|
||||||
|
},
|
||||||
|
warningModalIconContainer: {
|
||||||
|
width: 64,
|
||||||
|
height: 64,
|
||||||
|
borderRadius: 32,
|
||||||
|
backgroundColor: 'rgba(243, 156, 18, 0.15)',
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center",
|
||||||
|
marginBottom: spacing.md,
|
||||||
|
},
|
||||||
|
warningKeyword: {
|
||||||
|
fontWeight: "bold",
|
||||||
|
color: colors.warning,
|
||||||
|
},
|
||||||
|
warningInput: {
|
||||||
|
backgroundColor: colors.glass.background,
|
||||||
|
borderRadius: radius.md,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.warning,
|
||||||
|
padding: spacing.md,
|
||||||
|
...typography.body,
|
||||||
|
color: colors.text,
|
||||||
|
textAlign: 'center',
|
||||||
|
marginBottom: spacing.xl,
|
||||||
|
},
|
||||||
|
warningActionButton: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: colors.warning,
|
||||||
|
paddingVertical: spacing.md,
|
||||||
|
borderRadius: radius.md,
|
||||||
|
alignItems: "center",
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "center",
|
||||||
|
gap: spacing.xs,
|
||||||
|
},
|
||||||
|
deleteListItem: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: spacing.sm,
|
||||||
|
},
|
||||||
|
deleteListText: {
|
||||||
|
...typography.subhead,
|
||||||
|
color: colors.text,
|
||||||
|
},
|
||||||
|
confirmLabel: {
|
||||||
|
...typography.subhead,
|
||||||
|
color: colors.textSecondary,
|
||||||
|
marginBottom: spacing.xs,
|
||||||
|
textAlign: "center",
|
||||||
|
},
|
||||||
|
confirmKeyword: {
|
||||||
|
fontWeight: "bold",
|
||||||
|
color: colors.error,
|
||||||
|
},
|
||||||
|
confirmInput: {
|
||||||
|
backgroundColor: colors.glass.background,
|
||||||
|
borderRadius: radius.md,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.error,
|
||||||
|
padding: spacing.md,
|
||||||
|
...typography.body,
|
||||||
|
color: colors.text,
|
||||||
|
textAlign: 'center',
|
||||||
|
marginBottom: spacing.xl,
|
||||||
|
},
|
||||||
|
modalButtons: {
|
||||||
|
flexDirection: "row",
|
||||||
|
gap: spacing.md,
|
||||||
|
},
|
||||||
|
cancelButton: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: colors.glass.backgroundLight,
|
||||||
|
paddingVertical: spacing.md,
|
||||||
|
borderRadius: radius.md,
|
||||||
|
alignItems: "center",
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.glass.borderLight,
|
||||||
|
},
|
||||||
|
cancelButtonText: {
|
||||||
|
...typography.body,
|
||||||
|
color: colors.textSecondary,
|
||||||
|
fontWeight: "600",
|
||||||
|
},
|
||||||
|
deleteButton: {
|
||||||
|
flex: 1,
|
||||||
|
backgroundColor: colors.error,
|
||||||
|
paddingVertical: spacing.md,
|
||||||
|
borderRadius: radius.md,
|
||||||
|
alignItems: "center",
|
||||||
|
flexDirection: "row",
|
||||||
|
justifyContent: "center",
|
||||||
|
gap: spacing.xs,
|
||||||
|
},
|
||||||
|
deleteButtonDisabled: {
|
||||||
|
opacity: 0.5,
|
||||||
|
},
|
||||||
|
deleteButtonText: {
|
||||||
|
...typography.body,
|
||||||
|
color: colors.text,
|
||||||
|
fontWeight: "600",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default RelationshipDangerZone;
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { View, Text, TouchableOpacity, StyleSheet, Platform } from 'react-native';
|
||||||
|
import { useRouter } from 'expo-router';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||||
|
import { colors, spacing, typography, featureColors } from '../../../theme';
|
||||||
|
|
||||||
|
export interface ScreenHeaderProps {
|
||||||
|
/** Screen title */
|
||||||
|
title: string;
|
||||||
|
/** Optional element to render on the right side */
|
||||||
|
rightElement?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reusable header with back navigation for settings sub-screens.
|
||||||
|
*/
|
||||||
|
export function ScreenHeader({ title, rightElement }: ScreenHeaderProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const insets = useSafeAreaInsets();
|
||||||
|
|
||||||
|
const handleBack = () => {
|
||||||
|
// Navigate back to the settings hub (profile tab)
|
||||||
|
router.navigate('/(app)/profile');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={[styles.container, { paddingTop: insets.top + spacing.sm }]}>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={handleBack}
|
||||||
|
style={styles.backButton}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
||||||
|
>
|
||||||
|
<Ionicons name="chevron-back" size={24} color={colors.text} />
|
||||||
|
</TouchableOpacity>
|
||||||
|
<Text style={styles.title} numberOfLines={1}>
|
||||||
|
{title}
|
||||||
|
</Text>
|
||||||
|
<View style={styles.rightContainer}>
|
||||||
|
{rightElement || <View style={styles.placeholder} />}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
paddingHorizontal: spacing.md,
|
||||||
|
paddingBottom: spacing.md,
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: `${featureColors.profile.accent}15`,
|
||||||
|
},
|
||||||
|
backButton: {
|
||||||
|
width: 40,
|
||||||
|
height: 40,
|
||||||
|
borderRadius: 20,
|
||||||
|
backgroundColor: colors.glass.background,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
...typography.headline,
|
||||||
|
color: colors.text,
|
||||||
|
flex: 1,
|
||||||
|
textAlign: 'center',
|
||||||
|
marginHorizontal: spacing.md,
|
||||||
|
},
|
||||||
|
rightContainer: {
|
||||||
|
minWidth: 40,
|
||||||
|
alignItems: 'flex-end',
|
||||||
|
},
|
||||||
|
placeholder: {
|
||||||
|
width: 40,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default ScreenHeader;
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export type { NotificationSettingsProps } from './NotificationSettings';
|
|||||||
export { CoupleStatus } from './CoupleStatus';
|
export { CoupleStatus } from './CoupleStatus';
|
||||||
export { PrivacySettings } from './PrivacySettings';
|
export { PrivacySettings } from './PrivacySettings';
|
||||||
export { DangerZone } from './DangerZone';
|
export { DangerZone } from './DangerZone';
|
||||||
|
export { RelationshipDangerZone } from './RelationshipDangerZone';
|
||||||
export { ResetProgress } from './ResetProgress';
|
export { ResetProgress } from './ResetProgress';
|
||||||
|
|
||||||
export { SettingsSection } from './SettingsSection';
|
export { SettingsSection } from './SettingsSection';
|
||||||
@@ -18,3 +19,5 @@ export { MenuItem } from './MenuItem';
|
|||||||
export type { MenuItemProps } from './MenuItem';
|
export type { MenuItemProps } from './MenuItem';
|
||||||
export { SubscriptionCard } from './SubscriptionCard';
|
export { SubscriptionCard } from './SubscriptionCard';
|
||||||
export type { SubscriptionCardProps } from './SubscriptionCard';
|
export type { SubscriptionCardProps } from './SubscriptionCard';
|
||||||
|
export { ScreenHeader } from './ScreenHeader';
|
||||||
|
export type { ScreenHeaderProps } from './ScreenHeader';
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { View, ScrollView, StyleSheet, Platform } from 'react-native';
|
||||||
|
import { useRouter } from 'expo-router';
|
||||||
|
|
||||||
|
import { GradientBackground } from '../../../components/ui';
|
||||||
|
import { colors, spacing } from '../../../theme';
|
||||||
|
import { useAuthStore } from '../../../store';
|
||||||
|
import { useCoupleManagement } from '../hooks';
|
||||||
|
import { ScreenHeader, SettingsSection, MenuItem, DangerZone } from '../components';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Account settings sub-screen (sign out, delete account).
|
||||||
|
*/
|
||||||
|
export function AccountScreen() {
|
||||||
|
const router = useRouter();
|
||||||
|
const { couple, isAnonymous } = useAuthStore();
|
||||||
|
const { handleSignOut, handleDeleteAccount } = useCoupleManagement();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<GradientBackground>
|
||||||
|
<ScreenHeader title="Account" />
|
||||||
|
<ScrollView
|
||||||
|
style={styles.container}
|
||||||
|
contentContainerStyle={styles.content}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
>
|
||||||
|
<SettingsSection title="Account" delay={0}>
|
||||||
|
{isAnonymous ? (
|
||||||
|
<MenuItem
|
||||||
|
icon="shield-checkmark-outline"
|
||||||
|
label="Save Account"
|
||||||
|
description="Protect access across devices"
|
||||||
|
onPress={() => router.push("/(app)/settings/save-account" as any)}
|
||||||
|
showChevron={false}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<MenuItem
|
||||||
|
icon="log-out-outline"
|
||||||
|
label="Sign Out"
|
||||||
|
onPress={handleSignOut}
|
||||||
|
variant="danger"
|
||||||
|
showChevron={false}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</SettingsSection>
|
||||||
|
|
||||||
|
<View style={styles.spacer} />
|
||||||
|
|
||||||
|
{/* Account Danger Zone - delete account only */}
|
||||||
|
<DangerZone
|
||||||
|
onDeleteAccount={handleDeleteAccount}
|
||||||
|
hasRelationship={!!couple}
|
||||||
|
/>
|
||||||
|
</ScrollView>
|
||||||
|
</GradientBackground>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
paddingTop: spacing.lg,
|
||||||
|
paddingBottom: Platform.OS === 'ios' ? 40 : 20,
|
||||||
|
},
|
||||||
|
spacer: {
|
||||||
|
height: spacing.md,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default AccountScreen;
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { ScrollView, StyleSheet, Platform } from 'react-native';
|
||||||
|
|
||||||
|
import { GradientBackground } from '../../../components/ui';
|
||||||
|
import { spacing } from '../../../theme';
|
||||||
|
import { useProfileSettings } from '../hooks';
|
||||||
|
import { ScreenHeader, NotificationSettings } from '../components';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Notifications settings sub-screen for push notification preferences.
|
||||||
|
*/
|
||||||
|
export function NotificationsScreen() {
|
||||||
|
const settings = useProfileSettings();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<GradientBackground>
|
||||||
|
<ScreenHeader title="Notifications" />
|
||||||
|
<ScrollView
|
||||||
|
style={styles.container}
|
||||||
|
contentContainerStyle={styles.content}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
>
|
||||||
|
<NotificationSettings
|
||||||
|
pushEnabled={settings.pushEnabled}
|
||||||
|
isUpdatingPush={settings.isUpdatingPush}
|
||||||
|
onPushToggle={settings.handlePushToggle}
|
||||||
|
delay={0}
|
||||||
|
/>
|
||||||
|
</ScrollView>
|
||||||
|
</GradientBackground>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
paddingTop: spacing.lg,
|
||||||
|
paddingBottom: Platform.OS === 'ios' ? 40 : 20,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default NotificationsScreen;
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { View, ScrollView, StyleSheet, Platform } from 'react-native';
|
||||||
|
|
||||||
|
import { GradientBackground } from '../../../components/ui';
|
||||||
|
import { colors, spacing } from '../../../theme';
|
||||||
|
import { useAuthStore } from '../../../store';
|
||||||
|
import { useCoupleManagement } from '../hooks';
|
||||||
|
import { ScreenHeader, CoupleStatus, RelationshipDangerZone } from '../components';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Partner/relationship management sub-screen.
|
||||||
|
*/
|
||||||
|
export function PartnerScreen() {
|
||||||
|
const { partner, couple } = useAuthStore();
|
||||||
|
const {
|
||||||
|
handleUnpair,
|
||||||
|
handleDeleteRelationship,
|
||||||
|
handleResetProgress,
|
||||||
|
navigateToPairing,
|
||||||
|
} = useCoupleManagement();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<GradientBackground>
|
||||||
|
<ScreenHeader title="Partner" />
|
||||||
|
<ScrollView
|
||||||
|
style={styles.container}
|
||||||
|
contentContainerStyle={styles.content}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
>
|
||||||
|
{/* Partner Info */}
|
||||||
|
<CoupleStatus
|
||||||
|
partner={partner}
|
||||||
|
couple={couple}
|
||||||
|
onUnpair={handleUnpair}
|
||||||
|
onPairingPress={navigateToPairing}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Relationship Danger Zone - only show if in a relationship */}
|
||||||
|
{couple && (
|
||||||
|
<>
|
||||||
|
<View style={styles.spacer} />
|
||||||
|
<RelationshipDangerZone
|
||||||
|
onResetProgress={handleResetProgress}
|
||||||
|
onDeleteRelationship={handleDeleteRelationship}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</ScrollView>
|
||||||
|
</GradientBackground>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
paddingTop: spacing.lg,
|
||||||
|
paddingBottom: Platform.OS === 'ios' ? 40 : 20,
|
||||||
|
},
|
||||||
|
spacer: {
|
||||||
|
height: spacing.lg,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default PartnerScreen;
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { ScrollView, StyleSheet, Platform } from 'react-native';
|
||||||
|
|
||||||
|
import { GradientBackground } from '../../../components/ui';
|
||||||
|
import { spacing } from '../../../theme';
|
||||||
|
import { useProfileSettings } from '../hooks';
|
||||||
|
import { ScreenHeader, PrivacySettings } from '../components';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Preferences sub-screen for comfort zone and biometric settings.
|
||||||
|
*/
|
||||||
|
export function PreferencesScreen() {
|
||||||
|
const settings = useProfileSettings();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<GradientBackground>
|
||||||
|
<ScreenHeader title="Preferences" />
|
||||||
|
<ScrollView
|
||||||
|
style={styles.container}
|
||||||
|
contentContainerStyle={styles.content}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
>
|
||||||
|
<PrivacySettings
|
||||||
|
maxIntensity={settings.maxIntensity}
|
||||||
|
isUpdatingIntensity={settings.isUpdatingIntensity}
|
||||||
|
onIntensityChange={settings.handleIntensityChange}
|
||||||
|
biometricAvailable={settings.biometricAvailable}
|
||||||
|
biometricEnabled={settings.biometricEnabled}
|
||||||
|
biometricType={settings.biometricType}
|
||||||
|
isUpdatingBiometric={settings.isUpdatingBiometric}
|
||||||
|
onBiometricToggle={settings.handleBiometricToggle}
|
||||||
|
partnerIntensity={settings.partnerIntensity}
|
||||||
|
partnerName={settings.partnerName}
|
||||||
|
partnerAvatar={settings.partnerAvatar}
|
||||||
|
/>
|
||||||
|
</ScrollView>
|
||||||
|
</GradientBackground>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
paddingTop: spacing.lg,
|
||||||
|
paddingBottom: Platform.OS === 'ios' ? 40 : 20,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default PreferencesScreen;
|
||||||
@@ -0,0 +1,437 @@
|
|||||||
|
import React, { useState, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
View,
|
||||||
|
Text,
|
||||||
|
StyleSheet,
|
||||||
|
ScrollView,
|
||||||
|
Platform,
|
||||||
|
TextInput,
|
||||||
|
Pressable,
|
||||||
|
ActivityIndicator,
|
||||||
|
Alert,
|
||||||
|
TouchableOpacity,
|
||||||
|
} from 'react-native';
|
||||||
|
import { Image } from 'expo-image';
|
||||||
|
import { Ionicons } from '@expo/vector-icons';
|
||||||
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
|
import Animated, { FadeInDown } from 'react-native-reanimated';
|
||||||
|
|
||||||
|
import { GradientBackground, GlassCard, GlassButton } from '../../../components/ui';
|
||||||
|
import { colors, featureColors, spacing, typography, radius } from '../../../theme';
|
||||||
|
import { useAuthStore } from '../../../store';
|
||||||
|
import { supabase } from '../../../lib/supabase';
|
||||||
|
import { Events } from '../../../lib/analytics';
|
||||||
|
import { useProfileSettings } from '../hooks';
|
||||||
|
import { ScreenHeader, SettingsSection } from '../components';
|
||||||
|
import type { Gender } from '../../../types';
|
||||||
|
|
||||||
|
// Hook to get email from auth session
|
||||||
|
function useAuthEmail() {
|
||||||
|
const [email, setEmail] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const getEmail = async () => {
|
||||||
|
const { data: { user } } = await supabase.auth.getUser();
|
||||||
|
setEmail(user?.email ?? null);
|
||||||
|
};
|
||||||
|
getEmail();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return email;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ACCENT_GRADIENT = featureColors.profile.gradient as [string, string];
|
||||||
|
|
||||||
|
const GENDER_OPTIONS: { value: Gender; label: string; icon: string }[] = [
|
||||||
|
{ value: 'male', label: 'Male', icon: 'male' },
|
||||||
|
{ value: 'female', label: 'Female', icon: 'female' },
|
||||||
|
{ value: 'non-binary', label: 'Non-binary', icon: 'male-female' },
|
||||||
|
{ value: 'prefer-not-to-say', label: 'Prefer not to say', icon: 'remove-circle-outline' },
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Profile settings sub-screen for editing avatar, display name, and gender.
|
||||||
|
*/
|
||||||
|
export function ProfileSettingsScreen() {
|
||||||
|
const { user, fetchUser } = useAuthStore();
|
||||||
|
const settings = useProfileSettings();
|
||||||
|
const authEmail = useAuthEmail();
|
||||||
|
|
||||||
|
// Local form state
|
||||||
|
const [name, setName] = useState(user?.name || '');
|
||||||
|
const [gender, setGender] = useState<Gender | null>(user?.gender || null);
|
||||||
|
const [isSaving, setIsSaving] = useState(false);
|
||||||
|
|
||||||
|
// Track if form has changes
|
||||||
|
const hasChanges = name.trim() !== (user?.name || '') || gender !== (user?.gender || null);
|
||||||
|
|
||||||
|
// Reset form when user changes
|
||||||
|
useEffect(() => {
|
||||||
|
setName(user?.name || '');
|
||||||
|
setGender(user?.gender || null);
|
||||||
|
}, [user?.name, user?.gender]);
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!user?.id) return;
|
||||||
|
|
||||||
|
const trimmedName = name.trim();
|
||||||
|
if (!trimmedName) {
|
||||||
|
Alert.alert('Error', 'Name cannot be empty');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSaving(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const updates: Record<string, any> = {
|
||||||
|
updated_at: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const changedFields: string[] = [];
|
||||||
|
|
||||||
|
if (trimmedName !== user.name) {
|
||||||
|
updates.name = trimmedName;
|
||||||
|
changedFields.push('name');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (gender !== user.gender) {
|
||||||
|
updates.gender = gender;
|
||||||
|
changedFields.push('gender');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (changedFields.length === 0) {
|
||||||
|
setIsSaving(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { error } = await supabase
|
||||||
|
.from('profiles')
|
||||||
|
.update(updates)
|
||||||
|
.eq('id', user.id);
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
|
||||||
|
await fetchUser();
|
||||||
|
Events.profileUpdated(changedFields);
|
||||||
|
Alert.alert('Success', 'Profile updated successfully');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to update profile:', error);
|
||||||
|
Alert.alert('Error', 'Failed to update profile. Please try again.');
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<GradientBackground>
|
||||||
|
<ScreenHeader
|
||||||
|
title="Profile"
|
||||||
|
rightElement={
|
||||||
|
hasChanges ? (
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={handleSave}
|
||||||
|
disabled={isSaving}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
>
|
||||||
|
{isSaving ? (
|
||||||
|
<ActivityIndicator size="small" color={featureColors.profile.accent} />
|
||||||
|
) : (
|
||||||
|
<Text style={styles.saveButton}>Save</Text>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<ScrollView
|
||||||
|
style={styles.container}
|
||||||
|
contentContainerStyle={styles.content}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
keyboardShouldPersistTaps="handled"
|
||||||
|
>
|
||||||
|
{/* Avatar Section */}
|
||||||
|
<Animated.View
|
||||||
|
entering={FadeInDown.delay(100).duration(500)}
|
||||||
|
style={styles.avatarSection}
|
||||||
|
>
|
||||||
|
<TouchableOpacity
|
||||||
|
onPress={settings.handleAvatarPress}
|
||||||
|
disabled={settings.isUploadingAvatar}
|
||||||
|
activeOpacity={0.7}
|
||||||
|
style={styles.avatarTouchable}
|
||||||
|
>
|
||||||
|
<LinearGradient
|
||||||
|
colors={ACCENT_GRADIENT}
|
||||||
|
style={styles.avatarGradient}
|
||||||
|
start={{ x: 0, y: 0 }}
|
||||||
|
end={{ x: 1, y: 1 }}
|
||||||
|
>
|
||||||
|
{user?.avatar_url ? (
|
||||||
|
<Image
|
||||||
|
source={{ uri: user.avatar_url }}
|
||||||
|
style={styles.avatarImage}
|
||||||
|
cachePolicy="disk"
|
||||||
|
transition={200}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<View style={styles.avatarInner}>
|
||||||
|
<Text style={styles.avatarText}>
|
||||||
|
{user?.name?.[0]?.toUpperCase() || 'U'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</LinearGradient>
|
||||||
|
{settings.isUploadingAvatar ? (
|
||||||
|
<View style={styles.avatarOverlay}>
|
||||||
|
<ActivityIndicator size="large" color={colors.text} />
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<View style={styles.avatarEditBadge}>
|
||||||
|
<Ionicons name="camera" size={16} color={colors.text} />
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</TouchableOpacity>
|
||||||
|
<Text style={styles.avatarHint}>Tap to change photo</Text>
|
||||||
|
</Animated.View>
|
||||||
|
|
||||||
|
{/* Name Section */}
|
||||||
|
<SettingsSection title="Display Name" delay={150}>
|
||||||
|
<View style={styles.inputContainer}>
|
||||||
|
<Ionicons
|
||||||
|
name="person-outline"
|
||||||
|
size={20}
|
||||||
|
color={colors.textSecondary}
|
||||||
|
style={styles.inputIcon}
|
||||||
|
/>
|
||||||
|
<TextInput
|
||||||
|
style={styles.input}
|
||||||
|
value={name}
|
||||||
|
onChangeText={setName}
|
||||||
|
placeholder="Enter your name"
|
||||||
|
placeholderTextColor={colors.textTertiary}
|
||||||
|
autoCapitalize="words"
|
||||||
|
autoCorrect={false}
|
||||||
|
returnKeyType="done"
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
<Text style={styles.inputHint}>
|
||||||
|
This is how your partner will see you
|
||||||
|
</Text>
|
||||||
|
</SettingsSection>
|
||||||
|
|
||||||
|
{/* Gender Section */}
|
||||||
|
<SettingsSection title="Gender" delay={200}>
|
||||||
|
<Text style={styles.genderDescription}>
|
||||||
|
This helps us show you the right questions
|
||||||
|
</Text>
|
||||||
|
<View style={styles.genderGrid}>
|
||||||
|
{GENDER_OPTIONS.map((option) => (
|
||||||
|
<Pressable
|
||||||
|
key={option.value}
|
||||||
|
style={[
|
||||||
|
styles.genderOption,
|
||||||
|
gender === option.value && styles.genderOptionSelected,
|
||||||
|
]}
|
||||||
|
onPress={() => setGender(option.value)}
|
||||||
|
>
|
||||||
|
<Ionicons
|
||||||
|
name={option.icon as any}
|
||||||
|
size={24}
|
||||||
|
color={gender === option.value ? colors.primary : colors.textSecondary}
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
style={[
|
||||||
|
styles.genderLabel,
|
||||||
|
gender === option.value && styles.genderLabelSelected,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</Text>
|
||||||
|
</Pressable>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
</SettingsSection>
|
||||||
|
|
||||||
|
{/* Email (read-only) */}
|
||||||
|
<SettingsSection title="Email" delay={250}>
|
||||||
|
<View style={styles.readOnlyField}>
|
||||||
|
<Ionicons
|
||||||
|
name="mail-outline"
|
||||||
|
size={20}
|
||||||
|
color={colors.textTertiary}
|
||||||
|
style={styles.inputIcon}
|
||||||
|
/>
|
||||||
|
<Text style={styles.readOnlyText}>{authEmail || 'Loading...'}</Text>
|
||||||
|
</View>
|
||||||
|
<Text style={styles.inputHint}>
|
||||||
|
Email cannot be changed
|
||||||
|
</Text>
|
||||||
|
</SettingsSection>
|
||||||
|
|
||||||
|
<View style={styles.bottomSpacer} />
|
||||||
|
</ScrollView>
|
||||||
|
</GradientBackground>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
paddingTop: spacing.lg,
|
||||||
|
paddingBottom: Platform.OS === 'ios' ? 40 : 20,
|
||||||
|
},
|
||||||
|
saveButton: {
|
||||||
|
...typography.headline,
|
||||||
|
color: featureColors.profile.accent,
|
||||||
|
},
|
||||||
|
// Avatar
|
||||||
|
avatarSection: {
|
||||||
|
alignItems: 'center',
|
||||||
|
marginBottom: spacing.xl,
|
||||||
|
},
|
||||||
|
avatarTouchable: {
|
||||||
|
marginBottom: spacing.sm,
|
||||||
|
},
|
||||||
|
avatarGradient: {
|
||||||
|
width: 120,
|
||||||
|
height: 120,
|
||||||
|
borderRadius: 60,
|
||||||
|
padding: 4,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
shadowColor: featureColors.profile.accent,
|
||||||
|
shadowOffset: { width: 0, height: 4 },
|
||||||
|
shadowOpacity: 0.3,
|
||||||
|
shadowRadius: 8,
|
||||||
|
elevation: 6,
|
||||||
|
},
|
||||||
|
avatarImage: {
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
borderRadius: 60,
|
||||||
|
backgroundColor: colors.background,
|
||||||
|
},
|
||||||
|
avatarInner: {
|
||||||
|
width: '100%',
|
||||||
|
height: '100%',
|
||||||
|
borderRadius: 60,
|
||||||
|
backgroundColor: colors.background,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
avatarText: {
|
||||||
|
...typography.largeTitle,
|
||||||
|
fontSize: 48,
|
||||||
|
color: colors.text,
|
||||||
|
},
|
||||||
|
avatarOverlay: {
|
||||||
|
position: 'absolute',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
borderRadius: 60,
|
||||||
|
backgroundColor: 'rgba(0,0,0,0.5)',
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
},
|
||||||
|
avatarEditBadge: {
|
||||||
|
position: 'absolute',
|
||||||
|
bottom: 4,
|
||||||
|
right: 4,
|
||||||
|
backgroundColor: colors.surfaceSolid,
|
||||||
|
width: 36,
|
||||||
|
height: 36,
|
||||||
|
borderRadius: 18,
|
||||||
|
justifyContent: 'center',
|
||||||
|
alignItems: 'center',
|
||||||
|
borderWidth: 3,
|
||||||
|
borderColor: colors.background,
|
||||||
|
},
|
||||||
|
avatarHint: {
|
||||||
|
...typography.caption1,
|
||||||
|
color: colors.textTertiary,
|
||||||
|
},
|
||||||
|
// Input
|
||||||
|
inputContainer: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
backgroundColor: colors.glass.background,
|
||||||
|
borderRadius: radius.md,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.glass.border,
|
||||||
|
paddingHorizontal: spacing.md,
|
||||||
|
},
|
||||||
|
inputIcon: {
|
||||||
|
marginRight: spacing.sm,
|
||||||
|
},
|
||||||
|
input: {
|
||||||
|
flex: 1,
|
||||||
|
...typography.body,
|
||||||
|
color: colors.text,
|
||||||
|
paddingVertical: spacing.md,
|
||||||
|
},
|
||||||
|
inputHint: {
|
||||||
|
...typography.caption1,
|
||||||
|
color: colors.textTertiary,
|
||||||
|
marginTop: spacing.sm,
|
||||||
|
marginLeft: spacing.xs,
|
||||||
|
},
|
||||||
|
// Read-only field
|
||||||
|
readOnlyField: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
alignItems: 'center',
|
||||||
|
backgroundColor: colors.glass.background,
|
||||||
|
borderRadius: radius.md,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.glass.border,
|
||||||
|
paddingHorizontal: spacing.md,
|
||||||
|
paddingVertical: spacing.md,
|
||||||
|
opacity: 0.7,
|
||||||
|
},
|
||||||
|
readOnlyText: {
|
||||||
|
...typography.body,
|
||||||
|
color: colors.textSecondary,
|
||||||
|
},
|
||||||
|
// Gender
|
||||||
|
genderDescription: {
|
||||||
|
...typography.caption1,
|
||||||
|
color: colors.textSecondary,
|
||||||
|
marginBottom: spacing.md,
|
||||||
|
},
|
||||||
|
genderGrid: {
|
||||||
|
flexDirection: 'row',
|
||||||
|
flexWrap: 'wrap',
|
||||||
|
gap: spacing.sm,
|
||||||
|
},
|
||||||
|
genderOption: {
|
||||||
|
width: '48%',
|
||||||
|
alignItems: 'center',
|
||||||
|
backgroundColor: colors.glass.background,
|
||||||
|
borderRadius: radius.lg,
|
||||||
|
borderWidth: 2,
|
||||||
|
borderColor: colors.glass.border,
|
||||||
|
paddingVertical: spacing.md,
|
||||||
|
paddingHorizontal: spacing.sm,
|
||||||
|
gap: spacing.xs,
|
||||||
|
},
|
||||||
|
genderOptionSelected: {
|
||||||
|
borderColor: colors.primary,
|
||||||
|
backgroundColor: colors.primaryLight,
|
||||||
|
},
|
||||||
|
genderLabel: {
|
||||||
|
...typography.caption1,
|
||||||
|
color: colors.textSecondary,
|
||||||
|
textAlign: 'center',
|
||||||
|
},
|
||||||
|
genderLabelSelected: {
|
||||||
|
color: colors.primary,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
bottomSpacer: {
|
||||||
|
height: spacing.xl,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default ProfileSettingsScreen;
|
||||||
@@ -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;
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { ScrollView, StyleSheet, Platform } from 'react-native';
|
||||||
|
|
||||||
|
import { GradientBackground } from '../../../components/ui';
|
||||||
|
import { Paywall } from '../../../components/paywall';
|
||||||
|
import { spacing } from '../../../theme';
|
||||||
|
import { useAuthStore, useSubscriptionStore } from '../../../store';
|
||||||
|
import { useProfileSettings } from '../hooks';
|
||||||
|
import { ScreenHeader, SubscriptionCard } from '../components';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscription management sub-screen.
|
||||||
|
*/
|
||||||
|
export function SubscriptionScreen() {
|
||||||
|
const { user, partner } = useAuthStore();
|
||||||
|
const { subscription } = useSubscriptionStore();
|
||||||
|
const settings = useProfileSettings();
|
||||||
|
|
||||||
|
// Use OR: if RevenueCat says they're subscribed OR database says premium, it's their own
|
||||||
|
const isOwnSubscription = subscription.isProUser || user?.is_premium;
|
||||||
|
const hasPremiumAccess = user?.is_premium || partner?.is_premium || subscription.isProUser;
|
||||||
|
|
||||||
|
const formatExpirationDate = (date: Date | null | string) => {
|
||||||
|
if (!date) return "Never";
|
||||||
|
const d = new Date(date);
|
||||||
|
return d.toLocaleDateString("en-US", {
|
||||||
|
year: "numeric",
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<GradientBackground>
|
||||||
|
<ScreenHeader title="Subscription" />
|
||||||
|
<ScrollView
|
||||||
|
style={styles.container}
|
||||||
|
contentContainerStyle={styles.content}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
>
|
||||||
|
<SubscriptionCard
|
||||||
|
hasPremiumAccess={hasPremiumAccess}
|
||||||
|
isOwnSubscription={!!isOwnSubscription}
|
||||||
|
expirationDate={formatExpirationDate(subscription.expirationDate)}
|
||||||
|
onUpgradePress={() => settings.setShowPaywall(true)}
|
||||||
|
onManagePress={settings.handleManageSubscription}
|
||||||
|
onRestorePress={settings.handleRestorePurchases}
|
||||||
|
isRestoring={settings.isPurchasing}
|
||||||
|
delay={0}
|
||||||
|
/>
|
||||||
|
</ScrollView>
|
||||||
|
<Paywall
|
||||||
|
visible={settings.showPaywall}
|
||||||
|
onClose={() => settings.setShowPaywall(false)}
|
||||||
|
/>
|
||||||
|
</GradientBackground>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
paddingTop: spacing.lg,
|
||||||
|
paddingBottom: Platform.OS === 'ios' ? 40 : 20,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default SubscriptionScreen;
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { View, ScrollView, StyleSheet, Platform, Linking } from 'react-native';
|
||||||
|
|
||||||
|
import { GradientBackground } from '../../../components/ui';
|
||||||
|
import { FeedbackModal } from '../../../components/feedback';
|
||||||
|
import { colors, spacing } from '../../../theme';
|
||||||
|
import { ScreenHeader, SettingsSection, MenuItem } from '../components';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Help & Support sub-screen.
|
||||||
|
*/
|
||||||
|
export function SupportScreen() {
|
||||||
|
const [showFeedbackModal, setShowFeedbackModal] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<GradientBackground>
|
||||||
|
<ScreenHeader title="Help & Support" />
|
||||||
|
<ScrollView
|
||||||
|
style={styles.container}
|
||||||
|
contentContainerStyle={styles.content}
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
>
|
||||||
|
<SettingsSection title="Support" delay={0}>
|
||||||
|
<MenuItem
|
||||||
|
icon="chatbubble-ellipses-outline"
|
||||||
|
label="Send Feedback"
|
||||||
|
description="Report bugs or request features"
|
||||||
|
onPress={() => setShowFeedbackModal(true)}
|
||||||
|
/>
|
||||||
|
<View style={styles.divider} />
|
||||||
|
<MenuItem
|
||||||
|
icon="shield-checkmark-outline"
|
||||||
|
label="Privacy Policy"
|
||||||
|
onPress={() => Linking.openURL('https://sauci.app/privacy')}
|
||||||
|
/>
|
||||||
|
<View style={styles.divider} />
|
||||||
|
<MenuItem
|
||||||
|
icon="document-text-outline"
|
||||||
|
label="Terms of Service"
|
||||||
|
onPress={() => Linking.openURL('https://sauci.app/terms')}
|
||||||
|
/>
|
||||||
|
</SettingsSection>
|
||||||
|
</ScrollView>
|
||||||
|
|
||||||
|
<FeedbackModal
|
||||||
|
visible={showFeedbackModal}
|
||||||
|
onClose={() => setShowFeedbackModal(false)}
|
||||||
|
/>
|
||||||
|
</GradientBackground>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create({
|
||||||
|
container: {
|
||||||
|
flex: 1,
|
||||||
|
},
|
||||||
|
content: {
|
||||||
|
paddingTop: spacing.lg,
|
||||||
|
paddingBottom: Platform.OS === 'ios' ? 40 : 20,
|
||||||
|
},
|
||||||
|
divider: {
|
||||||
|
height: 1,
|
||||||
|
backgroundColor: colors.glass.border,
|
||||||
|
marginVertical: spacing.md,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export default SupportScreen;
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
// Profile sub-screens
|
||||||
|
export { ProfileSettingsScreen } from './ProfileSettingsScreen';
|
||||||
|
export { NotificationsScreen } from './NotificationsScreen';
|
||||||
|
export { PreferencesScreen } from './PreferencesScreen';
|
||||||
|
export { SubscriptionScreen } from './SubscriptionScreen';
|
||||||
|
export { PartnerScreen } from './PartnerScreen';
|
||||||
|
export { SupportScreen } from './SupportScreen';
|
||||||
|
export { AccountScreen } from './AccountScreen';
|
||||||
|
export { SaveAccountScreen } from './SaveAccountScreen';
|
||||||
@@ -0,0 +1,221 @@
|
|||||||
|
import { useState, useCallback, useRef } from 'react';
|
||||||
|
import { Alert, Platform, ActionSheetIOS } from 'react-native';
|
||||||
|
import * as ImagePicker from 'expo-image-picker';
|
||||||
|
import * as FileSystem from 'expo-file-system';
|
||||||
|
import { decode } from 'base64-arraybuffer';
|
||||||
|
import { supabase } from '../lib/supabase';
|
||||||
|
|
||||||
|
export interface UseAvatarPickerOptions {
|
||||||
|
/** User ID for storage path. Required for upload. */
|
||||||
|
userId?: string;
|
||||||
|
/** If true, automatically uploads after selection. Default: false */
|
||||||
|
autoUpload?: boolean;
|
||||||
|
/** Called when an image is selected (before upload) */
|
||||||
|
onSelect?: (uri: string) => void;
|
||||||
|
/** Called after successful upload */
|
||||||
|
onUploadComplete?: (publicUrl: string) => void;
|
||||||
|
/** Called on upload error */
|
||||||
|
onUploadError?: (error: Error) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UseAvatarPickerReturn {
|
||||||
|
/** Local URI of selected image (before upload) */
|
||||||
|
avatarUri: string | null;
|
||||||
|
/** Set avatar URI directly */
|
||||||
|
setAvatarUri: (uri: string | null) => void;
|
||||||
|
/** Whether an upload is in progress */
|
||||||
|
isUploading: boolean;
|
||||||
|
/** Show the picker action sheet/dialog */
|
||||||
|
showPicker: () => void;
|
||||||
|
/** Upload the currently selected image to storage */
|
||||||
|
uploadAvatar: () => Promise<string | null>;
|
||||||
|
/** Clear the selected avatar */
|
||||||
|
clearAvatar: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook for avatar image picking and uploading.
|
||||||
|
* Handles cross-platform image selection (camera/library) and Supabase storage upload.
|
||||||
|
*/
|
||||||
|
export function useAvatarPicker(options?: UseAvatarPickerOptions): UseAvatarPickerReturn {
|
||||||
|
const [avatarUri, setAvatarUri] = useState<string | null>(null);
|
||||||
|
const [isUploading, setIsUploading] = useState(false);
|
||||||
|
|
||||||
|
// Use ref for options to avoid stale closures in callbacks
|
||||||
|
const optionsRef = useRef(options);
|
||||||
|
optionsRef.current = options;
|
||||||
|
|
||||||
|
// Internal upload function that takes a URI parameter
|
||||||
|
const uploadAvatarWithUri = useCallback(async (uri: string): Promise<string | null> => {
|
||||||
|
const currentOptions = optionsRef.current;
|
||||||
|
if (!uri || !currentOptions?.userId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsUploading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
let fileBody;
|
||||||
|
let ext = 'jpg';
|
||||||
|
|
||||||
|
if (Platform.OS === 'web') {
|
||||||
|
const response = await fetch(uri);
|
||||||
|
const blob = await response.blob();
|
||||||
|
fileBody = blob;
|
||||||
|
|
||||||
|
if (blob.type === 'image/png') ext = 'png';
|
||||||
|
else if (blob.type === 'image/webp') ext = 'webp';
|
||||||
|
} else {
|
||||||
|
const base64 = await FileSystem.readAsStringAsync(uri, {
|
||||||
|
encoding: FileSystem.EncodingType.Base64
|
||||||
|
});
|
||||||
|
fileBody = decode(base64);
|
||||||
|
|
||||||
|
const uriExt = uri.split('.').pop()?.toLowerCase();
|
||||||
|
if (uriExt && ['jpg', 'jpeg', 'png', 'webp'].includes(uriExt)) {
|
||||||
|
ext = uriExt === 'jpeg' ? 'jpg' : uriExt;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileName = `${currentOptions.userId}/${Date.now()}.${ext}`;
|
||||||
|
const contentType = `image/${ext === 'jpg' ? 'jpeg' : ext}`;
|
||||||
|
|
||||||
|
const { error: uploadError } = await supabase.storage
|
||||||
|
.from('avatars')
|
||||||
|
.upload(fileName, fileBody, {
|
||||||
|
contentType,
|
||||||
|
upsert: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (uploadError) throw uploadError;
|
||||||
|
|
||||||
|
const { data: { publicUrl } } = supabase.storage
|
||||||
|
.from('avatars')
|
||||||
|
.getPublicUrl(fileName);
|
||||||
|
|
||||||
|
currentOptions?.onUploadComplete?.(publicUrl);
|
||||||
|
return publicUrl;
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error uploading avatar:', error);
|
||||||
|
currentOptions?.onUploadError?.(error as Error);
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
setIsUploading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const pickImage = useCallback(async (source: 'camera' | 'library') => {
|
||||||
|
const currentOptions = optionsRef.current;
|
||||||
|
|
||||||
|
try {
|
||||||
|
let result: ImagePicker.ImagePickerResult;
|
||||||
|
|
||||||
|
if (source === 'camera') {
|
||||||
|
const { status } = await ImagePicker.requestCameraPermissionsAsync();
|
||||||
|
if (status !== 'granted') {
|
||||||
|
Alert.alert('Permission Denied', 'Camera access is required to take a photo.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
result = await ImagePicker.launchCameraAsync({
|
||||||
|
mediaTypes: ['images'],
|
||||||
|
allowsEditing: true,
|
||||||
|
aspect: [1, 1],
|
||||||
|
quality: 0.8,
|
||||||
|
exif: false,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const { status } = await ImagePicker.requestMediaLibraryPermissionsAsync();
|
||||||
|
if (status !== 'granted') {
|
||||||
|
Alert.alert('Permission Denied', 'Photo library access is required to choose a photo.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
result = await ImagePicker.launchImageLibraryAsync({
|
||||||
|
mediaTypes: ['images'],
|
||||||
|
allowsEditing: true,
|
||||||
|
aspect: [1, 1],
|
||||||
|
quality: 0.8,
|
||||||
|
exif: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!result.canceled && result.assets[0]) {
|
||||||
|
const uri = result.assets[0].uri;
|
||||||
|
|
||||||
|
// Validate image is accessible on native platforms
|
||||||
|
if (Platform.OS !== 'web') {
|
||||||
|
try {
|
||||||
|
const info = await FileSystem.getInfoAsync(uri);
|
||||||
|
if (!info.exists) {
|
||||||
|
Alert.alert('Error', 'Selected image could not be accessed.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Warn if file is very large (> 10MB)
|
||||||
|
if (info.size && info.size > 10 * 1024 * 1024) {
|
||||||
|
Alert.alert('Large Image', 'This image is quite large and may take longer to upload.');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Could not validate image:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setAvatarUri(uri);
|
||||||
|
currentOptions?.onSelect?.(uri);
|
||||||
|
|
||||||
|
// Auto-upload if enabled
|
||||||
|
if (currentOptions?.autoUpload) {
|
||||||
|
await uploadAvatarWithUri(uri);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error picking avatar:', error);
|
||||||
|
Alert.alert('Error', 'Failed to pick image. Please try again.');
|
||||||
|
}
|
||||||
|
}, [uploadAvatarWithUri]);
|
||||||
|
|
||||||
|
const showPicker = useCallback(() => {
|
||||||
|
if (Platform.OS === 'ios') {
|
||||||
|
ActionSheetIOS.showActionSheetWithOptions(
|
||||||
|
{
|
||||||
|
options: ['Cancel', 'Take Photo', 'Choose from Library'],
|
||||||
|
cancelButtonIndex: 0,
|
||||||
|
},
|
||||||
|
(buttonIndex) => {
|
||||||
|
if (buttonIndex === 1) {
|
||||||
|
pickImage('camera');
|
||||||
|
} else if (buttonIndex === 2) {
|
||||||
|
pickImage('library');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
Alert.alert(
|
||||||
|
'Add Profile Photo',
|
||||||
|
'Choose an option',
|
||||||
|
[
|
||||||
|
{ text: 'Cancel', style: 'cancel' },
|
||||||
|
{ text: 'Take Photo', onPress: () => pickImage('camera') },
|
||||||
|
{ text: 'Choose from Library', onPress: () => pickImage('library') },
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [pickImage]);
|
||||||
|
|
||||||
|
// Public upload function uses stored avatarUri
|
||||||
|
const uploadAvatar = useCallback(async (): Promise<string | null> => {
|
||||||
|
if (!avatarUri) return null;
|
||||||
|
return uploadAvatarWithUri(avatarUri);
|
||||||
|
}, [avatarUri, uploadAvatarWithUri]);
|
||||||
|
|
||||||
|
const clearAvatar = useCallback(() => {
|
||||||
|
setAvatarUri(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return {
|
||||||
|
avatarUri,
|
||||||
|
setAvatarUri,
|
||||||
|
isUploading,
|
||||||
|
showPicker,
|
||||||
|
uploadAvatar,
|
||||||
|
clearAvatar,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,7 +1,77 @@
|
|||||||
import PostHog from "posthog-react-native";
|
import { Platform } from "react-native";
|
||||||
|
import Constants from "expo-constants";
|
||||||
|
// Import default export to ensure namespace registration runs
|
||||||
|
import analytics, {
|
||||||
|
getAnalytics,
|
||||||
|
logEvent as firebaseLogEvent,
|
||||||
|
setUserId as firebaseSetUserId,
|
||||||
|
setUserProperty,
|
||||||
|
setAnalyticsCollectionEnabled,
|
||||||
|
resetAnalyticsData,
|
||||||
|
FirebaseAnalyticsTypes,
|
||||||
|
} from "@react-native-firebase/analytics";
|
||||||
|
|
||||||
|
// Force the analytics module to be retained (prevents tree-shaking)
|
||||||
|
const _analyticsModule = analytics;
|
||||||
|
|
||||||
let posthog: PostHog | null = null;
|
|
||||||
let isInitialized = false;
|
let isInitialized = false;
|
||||||
|
let analyticsInstance: FirebaseAnalyticsTypes.Module | null = null;
|
||||||
|
let analyticsUnavailable = false;
|
||||||
|
let analyticsUnavailableLogged = false;
|
||||||
|
|
||||||
|
function getErrorMessage(error: unknown): string {
|
||||||
|
if (!error) return "";
|
||||||
|
if (typeof error === "string") return error;
|
||||||
|
if (error instanceof Error) return error.message;
|
||||||
|
if (typeof (error as { message?: unknown }).message === "string") {
|
||||||
|
return (error as { message?: string }).message || "";
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAnalyticsSupported(): boolean {
|
||||||
|
if (Platform.OS === "web") return false;
|
||||||
|
if (Constants.appOwnership === "expo") return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function logAnalyticsUnavailableOnce(reason: string) {
|
||||||
|
if (analyticsUnavailableLogged) return;
|
||||||
|
analyticsUnavailableLogged = true;
|
||||||
|
if (__DEV__) console.log("[Analytics] " + reason);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAnalyticsInstance(): FirebaseAnalyticsTypes.Module | null {
|
||||||
|
if (analyticsUnavailable) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isAnalyticsSupported()) {
|
||||||
|
analyticsUnavailable = true;
|
||||||
|
logAnalyticsUnavailableOnce("Skipping Firebase Analytics on Expo Go or web.");
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (analyticsInstance) {
|
||||||
|
return analyticsInstance;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
analyticsInstance = getAnalytics();
|
||||||
|
return analyticsInstance;
|
||||||
|
} catch (error) {
|
||||||
|
const message = getErrorMessage(error);
|
||||||
|
if (message.includes("module could not be found")) {
|
||||||
|
analyticsUnavailable = true;
|
||||||
|
logAnalyticsUnavailableOnce(
|
||||||
|
"Firebase Analytics native module missing. Rebuild with a dev client or native app."
|
||||||
|
);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
console.warn("[Analytics] Failed to get analytics instance:", error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export async function initAnalytics() {
|
export async function initAnalytics() {
|
||||||
if (isInitialized) {
|
if (isInitialized) {
|
||||||
@@ -9,30 +79,19 @@ export async function initAnalytics() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Skip initialization in non-RN environments (e.g., during tsc/Node.js)
|
// Skip initialization in non-RN environments (e.g., during tsc/Node.js)
|
||||||
// AsyncStorage requires window to be defined
|
|
||||||
if (typeof window === "undefined" && typeof global !== "undefined" && !("__BUNDLE_START_TIME__" in global)) {
|
if (typeof window === "undefined" && typeof global !== "undefined" && !("__BUNDLE_START_TIME__" in global)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const apiKey = process.env.EXPO_PUBLIC_POSTHOG_API_KEY;
|
|
||||||
const host = process.env.EXPO_PUBLIC_POSTHOG_HOST || "https://us.i.posthog.com";
|
|
||||||
|
|
||||||
if (!apiKey) {
|
|
||||||
console.warn("[Analytics] PostHog API key not configured");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
posthog = new PostHog(apiKey, {
|
const analytics = getAnalyticsInstance();
|
||||||
host,
|
if (analytics) {
|
||||||
// Disable automatic capture in production for privacy
|
await setAnalyticsCollectionEnabled(analytics, true);
|
||||||
captureAppLifecycleEvents: true,
|
isInitialized = true;
|
||||||
});
|
console.log("[Analytics] Firebase Analytics initialized");
|
||||||
|
}
|
||||||
isInitialized = true;
|
|
||||||
console.log("[Analytics] PostHog initialized");
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn("[Analytics] Failed to initialize PostHog:", error);
|
console.warn("[Analytics] Failed to initialize Firebase Analytics:", error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,7 +102,10 @@ export function logEvent(
|
|||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
if (__DEV__) console.log("[Analytics] Event:", name, params);
|
if (__DEV__) console.log("[Analytics] Event:", name, params);
|
||||||
posthog?.capture(name, params);
|
const analytics = getAnalyticsInstance();
|
||||||
|
if (analytics) {
|
||||||
|
firebaseLogEvent(analytics, name, params);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn("[Analytics] Failed to log event:", error);
|
console.warn("[Analytics] Failed to log event:", error);
|
||||||
}
|
}
|
||||||
@@ -53,7 +115,10 @@ export function logEvent(
|
|||||||
export function setUserId(userId: string) {
|
export function setUserId(userId: string) {
|
||||||
try {
|
try {
|
||||||
if (__DEV__) console.log("[Analytics] Set user ID:", userId);
|
if (__DEV__) console.log("[Analytics] Set user ID:", userId);
|
||||||
posthog?.identify(userId);
|
const analytics = getAnalyticsInstance();
|
||||||
|
if (analytics) {
|
||||||
|
firebaseSetUserId(analytics, userId);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn("[Analytics] Failed to set user ID:", error);
|
console.warn("[Analytics] Failed to set user ID:", error);
|
||||||
}
|
}
|
||||||
@@ -63,7 +128,11 @@ export function setUserId(userId: string) {
|
|||||||
export function clearUserId() {
|
export function clearUserId() {
|
||||||
try {
|
try {
|
||||||
if (__DEV__) console.log("[Analytics] Cleared user ID");
|
if (__DEV__) console.log("[Analytics] Cleared user ID");
|
||||||
posthog?.reset();
|
const analytics = getAnalyticsInstance();
|
||||||
|
if (analytics) {
|
||||||
|
firebaseSetUserId(analytics, null);
|
||||||
|
resetAnalyticsData(analytics);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn("[Analytics] Failed to clear user ID:", error);
|
console.warn("[Analytics] Failed to clear user ID:", error);
|
||||||
}
|
}
|
||||||
@@ -75,14 +144,13 @@ export function setUserProperties(
|
|||||||
) {
|
) {
|
||||||
try {
|
try {
|
||||||
if (__DEV__) console.log("[Analytics] Set user properties:", properties);
|
if (__DEV__) console.log("[Analytics] Set user properties:", properties);
|
||||||
// Filter out null values for PostHog
|
const analytics = getAnalyticsInstance();
|
||||||
const filteredProps: Record<string, string> = {};
|
if (analytics) {
|
||||||
for (const [key, value] of Object.entries(properties)) {
|
// Firebase Analytics requires setting user properties one at a time
|
||||||
if (value !== null) {
|
for (const [key, value] of Object.entries(properties)) {
|
||||||
filteredProps[key] = value;
|
setUserProperty(analytics, key, value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
posthog?.capture("$set", { $set: filteredProps });
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn("[Analytics] Failed to set user properties:", error);
|
console.warn("[Analytics] Failed to set user properties:", error);
|
||||||
}
|
}
|
||||||
@@ -92,7 +160,13 @@ export function setUserProperties(
|
|||||||
export function logScreenView(screenName: string, screenClass?: string) {
|
export function logScreenView(screenName: string, screenClass?: string) {
|
||||||
try {
|
try {
|
||||||
if (__DEV__) console.log("[Analytics] Screen view:", screenName);
|
if (__DEV__) console.log("[Analytics] Screen view:", screenName);
|
||||||
posthog?.screen(screenName, { screen_class: screenClass ?? screenName });
|
const analytics = getAnalyticsInstance();
|
||||||
|
if (analytics) {
|
||||||
|
firebaseLogEvent(analytics, "screen_view", {
|
||||||
|
screen_name: screenName,
|
||||||
|
screen_class: screenClass ?? screenName,
|
||||||
|
});
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn("[Analytics] Failed to log screen view:", error);
|
console.warn("[Analytics] Failed to log screen view:", error);
|
||||||
}
|
}
|
||||||
@@ -100,11 +174,21 @@ export function logScreenView(screenName: string, screenClass?: string) {
|
|||||||
|
|
||||||
// Flush events (useful before app backgrounding)
|
// Flush events (useful before app backgrounding)
|
||||||
export function flush() {
|
export function flush() {
|
||||||
try {
|
// Firebase Analytics batches and sends events automatically
|
||||||
posthog?.flush();
|
// No manual flush needed, but we keep the function for API compatibility
|
||||||
} catch (error) {
|
if (__DEV__) console.log("[Analytics] Flush called (no-op for Firebase)");
|
||||||
console.warn("[Analytics] Failed to flush:", error);
|
}
|
||||||
}
|
|
||||||
|
function truncateParam(value: string, maxLength = 100) {
|
||||||
|
if (value.length <= maxLength) return value;
|
||||||
|
return value.slice(0, maxLength - 1) + "…";
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeParam(value: unknown, maxLength = 100): string {
|
||||||
|
if (typeof value === "string") return truncateParam(value, maxLength);
|
||||||
|
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
||||||
|
if (value instanceof Error) return truncateParam(value.message, maxLength);
|
||||||
|
return value == null ? "" : truncateParam(String(value), maxLength);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pre-defined event helpers for common actions
|
// Pre-defined event helpers for common actions
|
||||||
@@ -140,8 +224,21 @@ export const Events = {
|
|||||||
|
|
||||||
// Subscription events
|
// Subscription events
|
||||||
paywallShown: (source: string) => logEvent("paywall_shown", { source }),
|
paywallShown: (source: string) => logEvent("paywall_shown", { source }),
|
||||||
|
paywallClosed: (source: string, reason: string) =>
|
||||||
|
logEvent("paywall_closed", { source, reason }),
|
||||||
purchaseInitiated: (packageType: string) =>
|
purchaseInitiated: (packageType: string) =>
|
||||||
logEvent("purchase_initiated", { package_type: packageType }),
|
logEvent("purchase_initiated", { package_type: packageType }),
|
||||||
|
purchaseCancelled: (packageType: string, errorCode?: unknown) =>
|
||||||
|
logEvent("purchase_cancelled", {
|
||||||
|
package_type: packageType,
|
||||||
|
error_code: safeParam(errorCode) || "unknown",
|
||||||
|
}),
|
||||||
|
purchaseFailed: (packageType: string, errorCode?: unknown, errorMessage?: unknown) =>
|
||||||
|
logEvent("purchase_failed", {
|
||||||
|
package_type: packageType,
|
||||||
|
error_code: safeParam(errorCode) || "unknown",
|
||||||
|
error_message: safeParam(errorMessage),
|
||||||
|
}),
|
||||||
purchaseCompleted: (packageType: string) =>
|
purchaseCompleted: (packageType: string) =>
|
||||||
logEvent("purchase", { package_type: packageType }),
|
logEvent("purchase", { package_type: packageType }),
|
||||||
purchaseRestored: () => logEvent("purchase_restored"),
|
purchaseRestored: () => logEvent("purchase_restored"),
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import * as Notifications from 'expo-notifications';
|
||||||
|
import { Platform } from 'react-native';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* App icon badge management utility.
|
||||||
|
* Sets the badge count on the app icon (iOS only, Android has no equivalent).
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the app icon badge count.
|
||||||
|
* On iOS, this updates the red badge number on the app icon.
|
||||||
|
* On Android, this is a no-op as Android doesn't have app icon badges.
|
||||||
|
*/
|
||||||
|
export async function setBadgeCount(count: number): Promise<void> {
|
||||||
|
// Badge count is iOS only
|
||||||
|
if (Platform.OS !== 'ios') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await Notifications.setBadgeCountAsync(Math.max(0, count));
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to set badge count:', error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear the app icon badge.
|
||||||
|
*/
|
||||||
|
export async function clearBadge(): Promise<void> {
|
||||||
|
await setBadgeCount(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the current badge count.
|
||||||
|
*/
|
||||||
|
export async function getBadgeCount(): Promise<number> {
|
||||||
|
if (Platform.OS !== 'ios') {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await Notifications.getBadgeCountAsync();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to get badge count:', error);
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sync badge count with the total of new matches and unread messages.
|
||||||
|
* This should be called whenever either count changes.
|
||||||
|
*/
|
||||||
|
export async function syncBadgeCount(newMatchesCount: number, unreadMessagesCount: number): Promise<void> {
|
||||||
|
const total = newMatchesCount + unreadMessagesCount;
|
||||||
|
await setBadgeCount(total);
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -1,32 +0,0 @@
|
|||||||
/**
|
|
||||||
* Crypto polyfill for React Native
|
|
||||||
*
|
|
||||||
* This file must be imported BEFORE any code that uses crypto.
|
|
||||||
* It installs react-native-quick-crypto as the global crypto implementation.
|
|
||||||
*
|
|
||||||
* On web, we skip this since the native Web Crypto API is available.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Platform } from 'react-native';
|
|
||||||
|
|
||||||
if (Platform.OS !== 'web') {
|
|
||||||
try {
|
|
||||||
// Import react-native-quick-crypto to install its polyfill
|
|
||||||
const QuickCrypto = require('react-native-quick-crypto');
|
|
||||||
|
|
||||||
// Install the polyfill globally if not already present
|
|
||||||
if (typeof global.crypto === 'undefined') {
|
|
||||||
(global as any).crypto = QuickCrypto;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Also ensure subtle is available
|
|
||||||
if (global.crypto && !global.crypto.subtle) {
|
|
||||||
(global.crypto as any).subtle = QuickCrypto.subtle;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('[Crypto] react-native-quick-crypto polyfill installed');
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[Crypto] Failed to install crypto polyfill:', error);
|
|
||||||
console.error('[Crypto] Make sure you have run: npx expo prebuild --clean');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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>;
|
||||||
@@ -17,6 +19,7 @@ interface AuthState {
|
|||||||
refreshPartner: () => Promise<Profile | null>;
|
refreshPartner: () => Promise<Profile | null>;
|
||||||
signOut: () => Promise<void>;
|
signOut: () => Promise<void>;
|
||||||
setUser: (user: Profile | null) => void;
|
setUser: (user: Profile | null) => void;
|
||||||
|
updateLastActive: () => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Import other stores lazily to avoid circular dependency issues
|
// Import other stores lazily to avoid circular dependency issues
|
||||||
@@ -25,7 +28,9 @@ const getOtherStores = () => {
|
|||||||
const { usePacksStore } = require("./packsStore");
|
const { usePacksStore } = require("./packsStore");
|
||||||
const { useMessageStore } = require("./messageStore");
|
const { useMessageStore } = require("./messageStore");
|
||||||
const { useSubscriptionStore } = require("./subscriptionStore");
|
const { useSubscriptionStore } = require("./subscriptionStore");
|
||||||
return { useMatchStore, usePacksStore, useMessageStore, useSubscriptionStore };
|
const { useNotificationPreferencesStore } = require("./notificationPreferencesStore");
|
||||||
|
const { useStreakStore } = require("./streakStore");
|
||||||
|
return { useMatchStore, usePacksStore, useMessageStore, useSubscriptionStore, useNotificationPreferencesStore, useStreakStore };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
@@ -35,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 {
|
||||||
@@ -42,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,13 +60,15 @@ 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 } = getOtherStores();
|
const { useMatchStore, usePacksStore, useMessageStore, useSubscriptionStore, useNotificationPreferencesStore, useStreakStore } = getOtherStores();
|
||||||
useMatchStore.getState().clearMatches();
|
useMatchStore.getState().clearMatches();
|
||||||
usePacksStore.getState().clearPacks();
|
usePacksStore.getState().clearPacks();
|
||||||
useMessageStore.getState().clearMessages();
|
useMessageStore.getState().clearMessages();
|
||||||
useSubscriptionStore.getState().clearSubscription();
|
useSubscriptionStore.getState().clearSubscription();
|
||||||
|
useNotificationPreferencesStore.getState().clearPreferences();
|
||||||
|
useStreakStore.getState().clearStreak();
|
||||||
// Sign out from Supabase (clears storage)
|
// Sign out from Supabase (clears storage)
|
||||||
await supabase.auth.signOut();
|
await supabase.auth.signOut();
|
||||||
return;
|
return;
|
||||||
@@ -72,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
|
||||||
@@ -137,19 +148,26 @@ 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 } = getOtherStores();
|
const { useMatchStore, usePacksStore, useMessageStore, useSubscriptionStore, useNotificationPreferencesStore, useStreakStore } = getOtherStores();
|
||||||
useMatchStore.getState().clearMatches();
|
useMatchStore.getState().clearMatches();
|
||||||
usePacksStore.getState().clearPacks();
|
usePacksStore.getState().clearPacks();
|
||||||
useMessageStore.getState().clearMessages();
|
useMessageStore.getState().clearMessages();
|
||||||
useSubscriptionStore.getState().clearSubscription();
|
useSubscriptionStore.getState().clearSubscription();
|
||||||
|
useNotificationPreferencesStore.getState().clearPreferences();
|
||||||
|
useStreakStore.getState().clearStreak();
|
||||||
|
|
||||||
|
// Clear badge on sign out
|
||||||
|
const { clearBadge } = require("../lib/badge");
|
||||||
|
await clearBadge();
|
||||||
|
|
||||||
// Then try to sign out from Supabase (don't block on this)
|
// Then try to sign out from Supabase (don't block on this)
|
||||||
try {
|
try {
|
||||||
@@ -163,17 +181,43 @@ 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 })
|
||||||
});
|
});
|
||||||
// Clear other stores when user signs out
|
// Clear other stores when user signs out
|
||||||
if (user === null) {
|
if (user === null) {
|
||||||
const { useMatchStore, usePacksStore, useMessageStore, useSubscriptionStore } = getOtherStores();
|
const { useMatchStore, usePacksStore, useMessageStore, useSubscriptionStore, useNotificationPreferencesStore, useStreakStore } = getOtherStores();
|
||||||
useMatchStore.getState().clearMatches();
|
useMatchStore.getState().clearMatches();
|
||||||
usePacksStore.getState().clearPacks();
|
usePacksStore.getState().clearPacks();
|
||||||
useMessageStore.getState().clearMessages();
|
useMessageStore.getState().clearMessages();
|
||||||
useSubscriptionStore.getState().clearSubscription();
|
useSubscriptionStore.getState().clearSubscription();
|
||||||
|
useNotificationPreferencesStore.getState().clearPreferences();
|
||||||
|
useStreakStore.getState().clearStreak();
|
||||||
|
// Clear badge on sign out
|
||||||
|
const { clearBadge } = require("../lib/badge");
|
||||||
|
clearBadge();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the user's last_active_at timestamp.
|
||||||
|
* Called when the app comes to the foreground to track user activity.
|
||||||
|
* This helps prevent sending notifications to users who are already in the app.
|
||||||
|
*/
|
||||||
|
updateLastActive: async () => {
|
||||||
|
const userId = get().user?.id;
|
||||||
|
if (!userId) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await supabase
|
||||||
|
.from("profiles")
|
||||||
|
.update({ last_active_at: new Date().toISOString() })
|
||||||
|
.eq("id", userId);
|
||||||
|
} catch (error) {
|
||||||
|
// Silently fail - this is not critical
|
||||||
|
console.error("Failed to update last_active_at:", error);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -6,3 +6,7 @@ export { useMessageStore } from "./messageStore";
|
|||||||
export { useSubscriptionStore } from "./subscriptionStore";
|
export { useSubscriptionStore } from "./subscriptionStore";
|
||||||
export { useResponsesStore, groupResponses } from "./responsesStore";
|
export { useResponsesStore, groupResponses } from "./responsesStore";
|
||||||
export type { ResponseWithQuestion, UpdateResponseResult, GroupByOption, DateSortOrder } from "./responsesStore";
|
export type { ResponseWithQuestion, UpdateResponseResult, GroupByOption, DateSortOrder } from "./responsesStore";
|
||||||
|
export { useNotificationPreferencesStore } from "./notificationPreferencesStore";
|
||||||
|
export type { NotificationPreferences } from "./notificationPreferencesStore";
|
||||||
|
export { useStreakStore } from "./streakStore";
|
||||||
|
export type { CoupleStreak } from "./streakStore";
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import { create } from "zustand";
|
||||||
|
import { supabase } from "../lib/supabase";
|
||||||
|
import { useAuthStore } from "./authStore";
|
||||||
|
|
||||||
|
export interface NotificationPreferences {
|
||||||
|
user_id: string;
|
||||||
|
matches_enabled: boolean;
|
||||||
|
messages_enabled: boolean;
|
||||||
|
partner_activity_enabled: boolean;
|
||||||
|
pack_changes_enabled: boolean;
|
||||||
|
new_packs_enabled: boolean;
|
||||||
|
streak_milestones_enabled: boolean;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NotificationPreferencesState {
|
||||||
|
preferences: NotificationPreferences | null;
|
||||||
|
isLoading: boolean;
|
||||||
|
isUpdating: boolean;
|
||||||
|
error: string | null;
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
fetchPreferences: () => Promise<void>;
|
||||||
|
updatePreference: (key: keyof Omit<NotificationPreferences, 'user_id' | 'created_at' | 'updated_at'>, value: boolean) => Promise<void>;
|
||||||
|
clearPreferences: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useNotificationPreferencesStore = create<NotificationPreferencesState>((set, get) => ({
|
||||||
|
preferences: null,
|
||||||
|
isLoading: false,
|
||||||
|
isUpdating: false,
|
||||||
|
error: null,
|
||||||
|
|
||||||
|
fetchPreferences: async () => {
|
||||||
|
const userId = useAuthStore.getState().user?.id;
|
||||||
|
if (!userId) {
|
||||||
|
set({ preferences: null, isLoading: false });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
set({ isLoading: true, error: null });
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Use the RPC function to get or create preferences
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.rpc('get_or_create_notification_preferences', { p_user_id: userId });
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
|
||||||
|
set({ preferences: data, isLoading: false });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching notification preferences:', error);
|
||||||
|
set({ error: 'Failed to load notification preferences', isLoading: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
updatePreference: async (key, value) => {
|
||||||
|
const userId = useAuthStore.getState().user?.id;
|
||||||
|
const currentPreferences = get().preferences;
|
||||||
|
|
||||||
|
if (!userId) return;
|
||||||
|
|
||||||
|
// Optimistic update
|
||||||
|
if (currentPreferences) {
|
||||||
|
set({
|
||||||
|
preferences: { ...currentPreferences, [key]: value },
|
||||||
|
isUpdating: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { error } = await supabase
|
||||||
|
.from('notification_preferences')
|
||||||
|
.update({ [key]: value })
|
||||||
|
.eq('user_id', userId);
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
|
||||||
|
set({ isUpdating: false });
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating notification preference:', error);
|
||||||
|
// Revert optimistic update
|
||||||
|
set({
|
||||||
|
preferences: currentPreferences,
|
||||||
|
isUpdating: false,
|
||||||
|
error: 'Failed to update preference',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
clearPreferences: () => {
|
||||||
|
set({ preferences: null, isLoading: false, isUpdating: false, error: null });
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -25,10 +25,12 @@ interface PacksState {
|
|||||||
categories: Category[];
|
categories: Category[];
|
||||||
enabledPackIds: string[];
|
enabledPackIds: string[];
|
||||||
isLoading: boolean;
|
isLoading: boolean;
|
||||||
|
showAllIntensities: boolean;
|
||||||
fetchPacks: () => Promise<void>;
|
fetchPacks: () => Promise<void>;
|
||||||
fetchEnabledPacks: () => Promise<void>;
|
fetchEnabledPacks: () => Promise<void>;
|
||||||
ensureEnabledPacksLoaded: () => Promise<void>;
|
ensureEnabledPacksLoaded: () => Promise<void>;
|
||||||
togglePack: (packId: string) => Promise<{ success: boolean; reason?: string }>;
|
togglePack: (packId: string) => Promise<{ success: boolean; reason?: string }>;
|
||||||
|
setShowAllIntensities: (value: boolean) => void;
|
||||||
clearPacks: () => void;
|
clearPacks: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,12 +39,14 @@ export const usePacksStore = create<PacksState>((set, get) => ({
|
|||||||
categories: [],
|
categories: [],
|
||||||
enabledPackIds: [],
|
enabledPackIds: [],
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
|
showAllIntensities: false,
|
||||||
|
|
||||||
fetchPacks: async () => {
|
fetchPacks: async () => {
|
||||||
set({ isLoading: true });
|
set({ isLoading: true });
|
||||||
|
|
||||||
// Get user's max intensity preference
|
// Get user's max intensity preference
|
||||||
const maxIntensity = getUserMaxIntensity();
|
const maxIntensity = getUserMaxIntensity();
|
||||||
|
const showAllIntensities = get().showAllIntensities;
|
||||||
|
|
||||||
|
|
||||||
// Fetch categories
|
// Fetch categories
|
||||||
@@ -62,8 +66,8 @@ export const usePacksStore = create<PacksState>((set, get) => ({
|
|||||||
.eq("is_public", true)
|
.eq("is_public", true)
|
||||||
.order("sort_order");
|
.order("sort_order");
|
||||||
|
|
||||||
// Filter out packs above user's intensity preference
|
// Filter out packs above user's intensity preference (unless showing all)
|
||||||
if (maxIntensity < 5) {
|
if (maxIntensity < 5 && !showAllIntensities) {
|
||||||
query = query.or(`max_intensity.is.null,max_intensity.lte.${maxIntensity}`);
|
query = query.or(`max_intensity.is.null,max_intensity.lte.${maxIntensity}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,6 +159,12 @@ export const usePacksStore = create<PacksState>((set, get) => ({
|
|||||||
return { success: true };
|
return { success: true };
|
||||||
},
|
},
|
||||||
|
|
||||||
|
setShowAllIntensities: (value: boolean) => {
|
||||||
|
set({ showAllIntensities: value });
|
||||||
|
// Refetch packs with new filter setting
|
||||||
|
get().fetchPacks();
|
||||||
|
},
|
||||||
|
|
||||||
clearPacks: () => {
|
clearPacks: () => {
|
||||||
set({ enabledPackIds: [] });
|
set({ enabledPackIds: [] });
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
import { create } from "zustand";
|
||||||
|
import { supabase } from "../lib/supabase";
|
||||||
|
import { useAuthStore } from "./authStore";
|
||||||
|
|
||||||
|
export interface CoupleStreak {
|
||||||
|
couple_id: string;
|
||||||
|
current_streak: number;
|
||||||
|
longest_streak: number;
|
||||||
|
last_active_date: string | null;
|
||||||
|
user1_answered_today: boolean;
|
||||||
|
user2_answered_today: boolean;
|
||||||
|
streak_celebrated_at: number;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StreakState {
|
||||||
|
streak: CoupleStreak | null;
|
||||||
|
isLoading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
|
||||||
|
// Actions
|
||||||
|
fetchStreak: () => Promise<void>;
|
||||||
|
clearStreak: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useStreakStore = create<StreakState>((set) => ({
|
||||||
|
streak: null,
|
||||||
|
isLoading: false,
|
||||||
|
error: null,
|
||||||
|
|
||||||
|
fetchStreak: async () => {
|
||||||
|
const coupleId = useAuthStore.getState().user?.couple_id;
|
||||||
|
if (!coupleId) {
|
||||||
|
set({ streak: null, isLoading: false });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
set({ isLoading: true, error: null });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { data, error } = await supabase
|
||||||
|
.from("couple_streaks")
|
||||||
|
.select("*")
|
||||||
|
.eq("couple_id", coupleId)
|
||||||
|
.maybeSingle();
|
||||||
|
|
||||||
|
if (error) throw error;
|
||||||
|
|
||||||
|
set({ streak: data, isLoading: false });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Error fetching streak:", error);
|
||||||
|
set({ error: "Failed to load streak", isLoading: false });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
clearStreak: () => {
|
||||||
|
set({ streak: null, isLoading: false, error: null });
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -7,6 +7,13 @@ import revenueCatService, {
|
|||||||
} from "../lib/revenuecat";
|
} from "../lib/revenuecat";
|
||||||
import { useAuthStore } from "./authStore";
|
import { useAuthStore } from "./authStore";
|
||||||
|
|
||||||
|
export type PurchaseResult = {
|
||||||
|
success: boolean;
|
||||||
|
cancelled?: boolean;
|
||||||
|
errorCode?: unknown;
|
||||||
|
errorMessage?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
interface SubscriptionStoreState {
|
interface SubscriptionStoreState {
|
||||||
subscription: SubscriptionState;
|
subscription: SubscriptionState;
|
||||||
offerings: PurchasesOffering | null;
|
offerings: PurchasesOffering | null;
|
||||||
@@ -17,7 +24,7 @@ interface SubscriptionStoreState {
|
|||||||
// Actions
|
// Actions
|
||||||
initializeRevenueCat: (userId: string) => Promise<void>;
|
initializeRevenueCat: (userId: string) => Promise<void>;
|
||||||
fetchOfferings: () => Promise<void>;
|
fetchOfferings: () => Promise<void>;
|
||||||
purchasePackage: (pkg: PurchasesPackage) => Promise<boolean>;
|
purchasePackage: (pkg: PurchasesPackage) => Promise<PurchaseResult>;
|
||||||
restorePurchases: () => Promise<boolean>;
|
restorePurchases: () => Promise<boolean>;
|
||||||
refreshSubscriptionStatus: () => Promise<void>;
|
refreshSubscriptionStatus: () => Promise<void>;
|
||||||
clearSubscription: () => void;
|
clearSubscription: () => void;
|
||||||
@@ -131,14 +138,24 @@ export const useSubscriptionStore = create<SubscriptionStoreState>((set, get) =>
|
|||||||
// Refresh user profile to get updated is_premium
|
// Refresh user profile to get updated is_premium
|
||||||
await useAuthStore.getState().fetchUser();
|
await useAuthStore.getState().fetchUser();
|
||||||
|
|
||||||
return true;
|
return { success: true };
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
const message =
|
const isCancelled =
|
||||||
error.message === "Purchase cancelled"
|
error?.userCancelled === true ||
|
||||||
? "Purchase was cancelled"
|
error?.message === "Purchase cancelled" ||
|
||||||
: "Purchase failed. Please try again.";
|
error?.code === "PURCHASE_CANCELLED";
|
||||||
|
|
||||||
|
const message = isCancelled
|
||||||
|
? "Purchase was cancelled"
|
||||||
|
: "Purchase failed. Please try again.";
|
||||||
|
|
||||||
set({ error: message, isPurchasing: false });
|
set({ error: message, isPurchasing: false });
|
||||||
return false;
|
return {
|
||||||
|
success: false,
|
||||||
|
cancelled: isCancelled,
|
||||||
|
errorCode: error?.code,
|
||||||
|
errorMessage: error?.message,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -120,16 +120,15 @@ jest.mock('base64-arraybuffer', () => ({
|
|||||||
decode: jest.fn(() => new ArrayBuffer(0)),
|
decode: jest.fn(() => new ArrayBuffer(0)),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
jest.mock('posthog-react-native', () => {
|
jest.mock('@react-native-firebase/app', () => ({
|
||||||
return class PostHog {
|
getApp: jest.fn(() => ({})),
|
||||||
capture = jest.fn();
|
}));
|
||||||
identify = jest.fn();
|
|
||||||
reset = jest.fn();
|
|
||||||
screen = jest.fn();
|
|
||||||
flush = jest.fn();
|
|
||||||
|
|
||||||
constructor() {
|
jest.mock('@react-native-firebase/analytics', () => ({
|
||||||
// no-op
|
getAnalytics: jest.fn(() => ({})),
|
||||||
}
|
logEvent: jest.fn(),
|
||||||
};
|
setUserId: jest.fn(),
|
||||||
});
|
setUserProperty: jest.fn(),
|
||||||
|
setAnalyticsCollectionEnabled: jest.fn(() => Promise.resolve()),
|
||||||
|
resetAnalyticsData: jest.fn(),
|
||||||
|
}));
|
||||||
@@ -0,0 +1,206 @@
|
|||||||
|
import "jsr:@supabase/functions-js/edge-runtime.d.ts";
|
||||||
|
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check for streak milestones and send celebration notifications.
|
||||||
|
* Called by pg_cron at 00:05 UTC daily.
|
||||||
|
*
|
||||||
|
* Milestones: 7, 14, 30, 60, 100 days
|
||||||
|
*
|
||||||
|
* Key principle: Celebrate achievements, never guilt-trip about breaks.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const MILESTONE_DAYS = [7, 14, 30, 60, 100];
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStreakMessage(days: number): { title: string; body: string } {
|
||||||
|
switch (days) {
|
||||||
|
case 7:
|
||||||
|
return {
|
||||||
|
title: "1 week streak!",
|
||||||
|
body: "You and your partner have been connecting for 7 days straight. Keep it up!",
|
||||||
|
};
|
||||||
|
case 14:
|
||||||
|
return {
|
||||||
|
title: "2 week streak!",
|
||||||
|
body: "Two weeks of daily connection. You two are on fire!",
|
||||||
|
};
|
||||||
|
case 30:
|
||||||
|
return {
|
||||||
|
title: "1 month streak!",
|
||||||
|
body: "A whole month of daily connection! You're building something special.",
|
||||||
|
};
|
||||||
|
case 60:
|
||||||
|
return {
|
||||||
|
title: "2 month streak!",
|
||||||
|
body: "60 days of daily connection. Your commitment is inspiring!",
|
||||||
|
};
|
||||||
|
case 100:
|
||||||
|
return {
|
||||||
|
title: "100 day streak!",
|
||||||
|
body: "100 days! You've made connection a habit. Incredible!",
|
||||||
|
};
|
||||||
|
default:
|
||||||
|
return {
|
||||||
|
title: `${days} day streak!`,
|
||||||
|
body: `${days} days of connecting with your partner. Amazing!`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
// Get all streaks that hit a milestone and haven't been celebrated yet
|
||||||
|
const { data: streaks, error: fetchError } = await supabase
|
||||||
|
.from("couple_streaks")
|
||||||
|
.select("couple_id, current_streak, streak_celebrated_at")
|
||||||
|
.in("current_streak", MILESTONE_DAYS)
|
||||||
|
.filter("current_streak", "gt", supabase.rpc("coalesce_int", { val: "streak_celebrated_at", fallback: 0 }));
|
||||||
|
|
||||||
|
// Actually, we need a different approach since we can't use coalesce in filters
|
||||||
|
// Let's fetch all and filter in code
|
||||||
|
const { data: allStreaks, error: allStreaksError } = await supabase
|
||||||
|
.from("couple_streaks")
|
||||||
|
.select("couple_id, current_streak, streak_celebrated_at");
|
||||||
|
|
||||||
|
if (allStreaksError) {
|
||||||
|
console.error("Error fetching streaks:", allStreaksError);
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: allStreaksError.message }),
|
||||||
|
{ status: 500, headers: { "Content-Type": "application/json" } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter for uncelebrated milestones
|
||||||
|
const uncelebratedMilestones = (allStreaks || []).filter(streak =>
|
||||||
|
MILESTONE_DAYS.includes(streak.current_streak) &&
|
||||||
|
streak.current_streak > (streak.streak_celebrated_at || 0)
|
||||||
|
);
|
||||||
|
|
||||||
|
if (uncelebratedMilestones.length === 0) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ celebrated: 0, message: "No milestones to celebrate" }),
|
||||||
|
{ headers: { "Content-Type": "application/json" } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const messages: ExpoPushMessage[] = [];
|
||||||
|
const celebratedCouples: string[] = [];
|
||||||
|
|
||||||
|
for (const streak of uncelebratedMilestones) {
|
||||||
|
// Get both partners' profiles
|
||||||
|
const { data: profiles, error: profilesError } = await supabase
|
||||||
|
.from("profiles")
|
||||||
|
.select(`
|
||||||
|
id,
|
||||||
|
push_token,
|
||||||
|
notification_preferences:notification_preferences(streak_milestones_enabled)
|
||||||
|
`)
|
||||||
|
.eq("couple_id", streak.couple_id);
|
||||||
|
|
||||||
|
if (profilesError) {
|
||||||
|
console.error(`Error fetching profiles for couple ${streak.couple_id}:`, profilesError);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { title, body } = getStreakMessage(streak.current_streak);
|
||||||
|
|
||||||
|
for (const profile of profiles || []) {
|
||||||
|
// Check notification preferences
|
||||||
|
const prefs = Array.isArray(profile.notification_preferences)
|
||||||
|
? profile.notification_preferences[0]
|
||||||
|
: profile.notification_preferences;
|
||||||
|
|
||||||
|
// Skip if user has explicitly disabled streak milestone notifications
|
||||||
|
if (prefs && prefs.streak_milestones_enabled === false) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (profile.push_token) {
|
||||||
|
messages.push({
|
||||||
|
to: profile.push_token,
|
||||||
|
title,
|
||||||
|
body,
|
||||||
|
sound: "default",
|
||||||
|
data: {
|
||||||
|
type: "streak_milestone",
|
||||||
|
streak: streak.current_streak,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
celebratedCouples.push(streak.couple_id);
|
||||||
|
|
||||||
|
// Mark milestone as celebrated
|
||||||
|
await supabase
|
||||||
|
.from("couple_streaks")
|
||||||
|
.update({ streak_celebrated_at: streak.current_streak })
|
||||||
|
.eq("couple_id", streak.couple_id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send all notifications
|
||||||
|
if (messages.length > 0) {
|
||||||
|
await sendExpoPushNotifications(messages);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Celebrated ${celebratedCouples.length} milestones, sent ${messages.length} notifications`);
|
||||||
|
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
celebrated: celebratedCouples.length,
|
||||||
|
notificationsSent: messages.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" } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
import "jsr:@supabase/functions-js/edge-runtime.d.ts";
|
||||||
|
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process scheduled pack releases.
|
||||||
|
* Called by pg_cron every 5 minutes to check for packs with scheduled_release_at in the past.
|
||||||
|
*
|
||||||
|
* When a pack's release time arrives:
|
||||||
|
* 1. Set is_public = true to make the pack available
|
||||||
|
* 2. Send notification to all users who have new_packs_enabled
|
||||||
|
* 3. Set release_notified = true to prevent duplicate notifications
|
||||||
|
*/
|
||||||
|
|
||||||
|
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 {
|
||||||
|
// Get all packs due for release
|
||||||
|
const { data: duePacks, error: fetchError } = await supabase
|
||||||
|
.from("question_packs")
|
||||||
|
.select("id, name, description")
|
||||||
|
.eq("is_public", false)
|
||||||
|
.eq("release_notified", false)
|
||||||
|
.not("scheduled_release_at", "is", null)
|
||||||
|
.lte("scheduled_release_at", new Date().toISOString());
|
||||||
|
|
||||||
|
if (fetchError) {
|
||||||
|
console.error("Error fetching due packs:", fetchError);
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: fetchError.message }),
|
||||||
|
{ status: 500, headers: { "Content-Type": "application/json" } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!duePacks || duePacks.length === 0) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ released: 0, message: "No packs due for release" }),
|
||||||
|
{ headers: { "Content-Type": "application/json" } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const packIds = duePacks.map(p => p.id);
|
||||||
|
|
||||||
|
// Make packs public
|
||||||
|
const { error: updateError } = await supabase
|
||||||
|
.from("question_packs")
|
||||||
|
.update({
|
||||||
|
is_public: true,
|
||||||
|
release_notified: true,
|
||||||
|
})
|
||||||
|
.in("id", packIds);
|
||||||
|
|
||||||
|
if (updateError) {
|
||||||
|
console.error("Error updating packs:", updateError);
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: updateError.message }),
|
||||||
|
{ status: 500, headers: { "Content-Type": "application/json" } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get all users with push tokens who want new pack notifications
|
||||||
|
// We join with notification_preferences and filter for new_packs_enabled = true (or null, which defaults to true)
|
||||||
|
const { data: users, error: usersError } = await supabase
|
||||||
|
.from("profiles")
|
||||||
|
.select(`
|
||||||
|
id,
|
||||||
|
push_token,
|
||||||
|
notification_preferences:notification_preferences(new_packs_enabled)
|
||||||
|
`)
|
||||||
|
.not("push_token", "is", null);
|
||||||
|
|
||||||
|
if (usersError) {
|
||||||
|
console.error("Error fetching users:", usersError);
|
||||||
|
// Packs are already released, just log error and continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const messages: ExpoPushMessage[] = [];
|
||||||
|
|
||||||
|
if (users && users.length > 0) {
|
||||||
|
for (const user of users) {
|
||||||
|
// Check notification preferences
|
||||||
|
// notification_preferences is an array (due to how Supabase returns joined data)
|
||||||
|
const prefs = Array.isArray(user.notification_preferences)
|
||||||
|
? user.notification_preferences[0]
|
||||||
|
: user.notification_preferences;
|
||||||
|
|
||||||
|
// Skip if user has explicitly disabled new_packs notifications
|
||||||
|
if (prefs && prefs.new_packs_enabled === false) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send notification for each released pack
|
||||||
|
for (const pack of duePacks) {
|
||||||
|
messages.push({
|
||||||
|
to: user.push_token!,
|
||||||
|
title: "New pack available",
|
||||||
|
body: `${pack.name} is now available to play!`,
|
||||||
|
sound: "default",
|
||||||
|
data: { type: "new_pack", packId: pack.id },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send all notifications
|
||||||
|
if (messages.length > 0) {
|
||||||
|
await sendExpoPushNotifications(messages);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Released ${duePacks.length} packs, sent ${messages.length} notifications`);
|
||||||
|
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
released: duePacks.length,
|
||||||
|
packNames: duePacks.map(p => p.name),
|
||||||
|
notificationsSent: messages.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" } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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" } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
import "jsr:@supabase/functions-js/edge-runtime.d.ts";
|
||||||
|
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send delayed pack change notifications.
|
||||||
|
* Called by pg_cron every 5 minutes to check for pending notifications
|
||||||
|
* where notify_at has passed.
|
||||||
|
*
|
||||||
|
* Notifications are sent when a user enables new question packs. The notification
|
||||||
|
* is delayed by 30 minutes, with the timer resetting on each new pack enable.
|
||||||
|
* This prevents spam when a user enables multiple packs in quick succession.
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface ExpoPushMessage {
|
||||||
|
to: string;
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
sound?: string;
|
||||||
|
data?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendExpoPushNotifications(messages: ExpoPushMessage[]) {
|
||||||
|
if (messages.length === 0) return;
|
||||||
|
|
||||||
|
const response = await fetch("https://exp.host/--/api/v2/push/send", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Accept: "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(messages),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.text();
|
||||||
|
console.error("Expo push error:", error);
|
||||||
|
throw new Error(`Failed to send push notification: ${error}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
// Get all pending notifications where notify_at has passed
|
||||||
|
const { data: pendingNotifications, error: fetchError } = await supabase
|
||||||
|
.from("pending_pack_notifications")
|
||||||
|
.select("id, couple_id, changed_by_user_id, notify_at")
|
||||||
|
.lte("notify_at", new Date().toISOString());
|
||||||
|
|
||||||
|
if (fetchError) {
|
||||||
|
console.error("Error fetching pending 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 messages: ExpoPushMessage[] = [];
|
||||||
|
const processedIds: string[] = [];
|
||||||
|
|
||||||
|
for (const notification of pendingNotifications) {
|
||||||
|
// Get the partner's push token (not the user who made changes)
|
||||||
|
const { data: partner, error: partnerError } = await supabase
|
||||||
|
.from("profiles")
|
||||||
|
.select("push_token")
|
||||||
|
.eq("couple_id", notification.couple_id)
|
||||||
|
.neq("id", notification.changed_by_user_id)
|
||||||
|
.maybeSingle();
|
||||||
|
|
||||||
|
if (partnerError) {
|
||||||
|
console.error(
|
||||||
|
`Error fetching partner for couple ${notification.couple_id}:`,
|
||||||
|
partnerError
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (partner?.push_token) {
|
||||||
|
messages.push({
|
||||||
|
to: partner.push_token,
|
||||||
|
title: "Question packs updated",
|
||||||
|
body: "Your partner updated the question packs",
|
||||||
|
sound: "default",
|
||||||
|
data: { type: "pack_change" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
processedIds.push(notification.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send all notifications
|
||||||
|
if (messages.length > 0) {
|
||||||
|
await sendExpoPushNotifications(messages);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete processed notifications
|
||||||
|
if (processedIds.length > 0) {
|
||||||
|
const { error: deleteError } = await supabase
|
||||||
|
.from("pending_pack_notifications")
|
||||||
|
.delete()
|
||||||
|
.in("id", processedIds);
|
||||||
|
|
||||||
|
if (deleteError) {
|
||||||
|
console.error("Error deleting processed notifications:", deleteError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Sent ${messages.length} pack change notifications`);
|
||||||
|
|
||||||
|
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" } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
import "jsr:@supabase/functions-js/edge-runtime.d.ts";
|
||||||
|
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send batched partner activity notifications.
|
||||||
|
* Called by pg_cron every 5 minutes to check for pending notifications
|
||||||
|
* where notify_at has passed.
|
||||||
|
*
|
||||||
|
* Notifications are sent when a user answers questions. The notification
|
||||||
|
* is delayed by 1 hour, with the timer resetting on each new response.
|
||||||
|
* This prevents spam when a user answers multiple questions in quick succession.
|
||||||
|
*
|
||||||
|
* Skips sending if the partner was active in the last 15 minutes (they're already in app).
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface ExpoPushMessage {
|
||||||
|
to: string;
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
sound?: string;
|
||||||
|
data?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendExpoPushNotifications(messages: ExpoPushMessage[]) {
|
||||||
|
if (messages.length === 0) return;
|
||||||
|
|
||||||
|
const response = await fetch("https://exp.host/--/api/v2/push/send", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Accept: "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(messages),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.text();
|
||||||
|
console.error("Expo push error:", error);
|
||||||
|
throw new Error(`Failed to send push notification: ${error}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
// Get all pending notifications where notify_at has passed
|
||||||
|
const { data: pendingNotifications, error: fetchError } = await supabase
|
||||||
|
.from("pending_activity_notifications")
|
||||||
|
.select("id, couple_id, active_user_id, response_count, notify_at")
|
||||||
|
.lte("notify_at", new Date().toISOString());
|
||||||
|
|
||||||
|
if (fetchError) {
|
||||||
|
console.error("Error fetching pending 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 messages: ExpoPushMessage[] = [];
|
||||||
|
const processedIds: string[] = [];
|
||||||
|
const skippedPartnerActive: string[] = [];
|
||||||
|
const skippedNoToken: string[] = [];
|
||||||
|
const skippedPrefsDisabled: string[] = [];
|
||||||
|
|
||||||
|
// Time threshold for "partner is active" check (15 minutes ago)
|
||||||
|
const activeThreshold = new Date(Date.now() - 15 * 60 * 1000).toISOString();
|
||||||
|
|
||||||
|
for (const notification of pendingNotifications) {
|
||||||
|
// Get the partner (not the user who made changes)
|
||||||
|
const { data: partner, error: partnerError } = await supabase
|
||||||
|
.from("profiles")
|
||||||
|
.select("id, push_token, name, last_active_at")
|
||||||
|
.eq("couple_id", notification.couple_id)
|
||||||
|
.neq("id", notification.active_user_id)
|
||||||
|
.maybeSingle();
|
||||||
|
|
||||||
|
if (partnerError) {
|
||||||
|
console.error(
|
||||||
|
`Error fetching partner for couple ${notification.couple_id}:`,
|
||||||
|
partnerError
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip if partner was active recently (they're already in the app)
|
||||||
|
if (partner?.last_active_at && partner.last_active_at > activeThreshold) {
|
||||||
|
console.log(
|
||||||
|
`Skipping notification for couple ${notification.couple_id}: partner was active recently`
|
||||||
|
);
|
||||||
|
skippedPartnerActive.push(notification.id);
|
||||||
|
processedIds.push(notification.id); // Still delete the pending notification
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip if no push token
|
||||||
|
if (!partner?.push_token) {
|
||||||
|
skippedNoToken.push(notification.id);
|
||||||
|
processedIds.push(notification.id);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check notification preferences
|
||||||
|
const { data: prefs } = await supabase
|
||||||
|
.from("notification_preferences")
|
||||||
|
.select("partner_activity_enabled")
|
||||||
|
.eq("user_id", partner.id)
|
||||||
|
.maybeSingle();
|
||||||
|
|
||||||
|
// Skip if partner has disabled partner_activity notifications
|
||||||
|
if (prefs && prefs.partner_activity_enabled === false) {
|
||||||
|
skippedPrefsDisabled.push(notification.id);
|
||||||
|
processedIds.push(notification.id);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the active user's name for the notification
|
||||||
|
const { data: activeUser } = await supabase
|
||||||
|
.from("profiles")
|
||||||
|
.select("name")
|
||||||
|
.eq("id", notification.active_user_id)
|
||||||
|
.maybeSingle();
|
||||||
|
|
||||||
|
const userName = activeUser?.name || "Your partner";
|
||||||
|
const responseCount = notification.response_count;
|
||||||
|
|
||||||
|
// Craft the notification message
|
||||||
|
const body = responseCount === 1
|
||||||
|
? `${userName} answered a question`
|
||||||
|
: `${userName} answered ${responseCount} questions`;
|
||||||
|
|
||||||
|
messages.push({
|
||||||
|
to: partner.push_token,
|
||||||
|
title: "Partner activity",
|
||||||
|
body,
|
||||||
|
sound: "default",
|
||||||
|
data: { type: "partner_activity" },
|
||||||
|
});
|
||||||
|
|
||||||
|
processedIds.push(notification.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send all notifications
|
||||||
|
if (messages.length > 0) {
|
||||||
|
await sendExpoPushNotifications(messages);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete processed notifications
|
||||||
|
if (processedIds.length > 0) {
|
||||||
|
const { error: deleteError } = await supabase
|
||||||
|
.from("pending_activity_notifications")
|
||||||
|
.delete()
|
||||||
|
.in("id", processedIds);
|
||||||
|
|
||||||
|
if (deleteError) {
|
||||||
|
console.error("Error deleting processed notifications:", deleteError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Sent ${messages.length} partner activity notifications, skipped: partner active=${skippedPartnerActive.length}, no token=${skippedNoToken.length}, prefs disabled=${skippedPrefsDisabled.length}`);
|
||||||
|
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
sent: messages.length,
|
||||||
|
processed: processedIds.length,
|
||||||
|
skipped: {
|
||||||
|
partnerActive: skippedPartnerActive.length,
|
||||||
|
noToken: skippedNoToken.length,
|
||||||
|
prefsDisabled: skippedPrefsDisabled.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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,6 @@
|
|||||||
-- EXTENSIONS
|
-- EXTENSIONS
|
||||||
-- ============================================================================
|
-- ============================================================================
|
||||||
CREATE EXTENSION IF NOT EXISTS "uuid-ossp" WITH SCHEMA extensions;
|
CREATE EXTENSION IF NOT EXISTS "uuid-ossp" WITH SCHEMA extensions;
|
||||||
|
|
||||||
-- ============================================================================
|
-- ============================================================================
|
||||||
-- CUSTOM TYPES
|
-- CUSTOM TYPES
|
||||||
-- ============================================================================
|
-- ============================================================================
|
||||||
@@ -16,7 +15,6 @@ CREATE TYPE feedback_status AS ENUM ('new', 'reviewed', 'in_progress', 'resolved
|
|||||||
CREATE TYPE subscription_status AS ENUM ('active', 'cancelled', 'expired', 'billing_issue', 'paused');
|
CREATE TYPE subscription_status AS ENUM ('active', 'cancelled', 'expired', 'billing_issue', 'paused');
|
||||||
CREATE TYPE admin_role AS ENUM ('pack_creator', 'super_admin');
|
CREATE TYPE admin_role AS ENUM ('pack_creator', 'super_admin');
|
||||||
CREATE TYPE audit_action AS ENUM ('INSERT', 'UPDATE', 'DELETE');
|
CREATE TYPE audit_action AS ENUM ('INSERT', 'UPDATE', 'DELETE');
|
||||||
|
|
||||||
-- ============================================================================
|
-- ============================================================================
|
||||||
-- TABLES
|
-- TABLES
|
||||||
-- ============================================================================
|
-- ============================================================================
|
||||||
@@ -27,7 +25,6 @@ CREATE TABLE couples (
|
|||||||
invite_code TEXT UNIQUE DEFAULT substr(md5(random()::text), 1, 8),
|
invite_code TEXT UNIQUE DEFAULT substr(md5(random()::text), 1, 8),
|
||||||
created_at TIMESTAMPTZ DEFAULT now()
|
created_at TIMESTAMPTZ DEFAULT now()
|
||||||
);
|
);
|
||||||
|
|
||||||
-- Profiles table (extends auth.users)
|
-- Profiles table (extends auth.users)
|
||||||
CREATE TABLE profiles (
|
CREATE TABLE profiles (
|
||||||
id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
|
id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||||
@@ -40,11 +37,11 @@ CREATE TABLE profiles (
|
|||||||
updated_at TIMESTAMPTZ DEFAULT now(),
|
updated_at TIMESTAMPTZ DEFAULT now(),
|
||||||
email TEXT,
|
email TEXT,
|
||||||
gender TEXT,
|
gender TEXT,
|
||||||
show_explicit_content BOOLEAN DEFAULT false,
|
show_explicit_content BOOLEAN DEFAULT true,
|
||||||
onboarding_completed BOOLEAN DEFAULT false,
|
onboarding_completed BOOLEAN DEFAULT false,
|
||||||
|
usage_reason TEXT,
|
||||||
CONSTRAINT valid_gender CHECK (gender IS NULL OR gender = ANY(ARRAY['male', 'female', 'non-binary', 'prefer-not-to-say']))
|
CONSTRAINT valid_gender CHECK (gender IS NULL OR gender = ANY(ARRAY['male', 'female', 'non-binary', 'prefer-not-to-say']))
|
||||||
);
|
);
|
||||||
|
|
||||||
-- Categories table
|
-- Categories table
|
||||||
CREATE TABLE categories (
|
CREATE TABLE categories (
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
@@ -54,7 +51,6 @@ CREATE TABLE categories (
|
|||||||
sort_order INTEGER DEFAULT 0,
|
sort_order INTEGER DEFAULT 0,
|
||||||
created_at TIMESTAMPTZ DEFAULT now()
|
created_at TIMESTAMPTZ DEFAULT now()
|
||||||
);
|
);
|
||||||
|
|
||||||
-- Question packs table
|
-- Question packs table
|
||||||
CREATE TABLE question_packs (
|
CREATE TABLE question_packs (
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
@@ -68,7 +64,6 @@ CREATE TABLE question_packs (
|
|||||||
category_id UUID REFERENCES categories(id),
|
category_id UUID REFERENCES categories(id),
|
||||||
is_explicit BOOLEAN NOT NULL DEFAULT false
|
is_explicit BOOLEAN NOT NULL DEFAULT false
|
||||||
);
|
);
|
||||||
|
|
||||||
-- Questions table
|
-- Questions table
|
||||||
CREATE TABLE questions (
|
CREATE TABLE questions (
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
@@ -80,10 +75,8 @@ CREATE TABLE questions (
|
|||||||
allowed_couple_genders TEXT[],
|
allowed_couple_genders TEXT[],
|
||||||
target_user_genders TEXT[]
|
target_user_genders TEXT[]
|
||||||
);
|
);
|
||||||
|
|
||||||
COMMENT ON COLUMN questions.allowed_couple_genders IS 'Array of allowed couple gender pairings (e.g., ["female+male", "female+female"]). Null means all couple types allowed.';
|
COMMENT ON COLUMN questions.allowed_couple_genders IS 'Array of allowed couple gender pairings (e.g., ["female+male", "female+female"]). Null means all couple types allowed.';
|
||||||
COMMENT ON COLUMN questions.target_user_genders IS 'Array of genders that can see this question first (as initiator). NULL = all genders. Partners still see it via partner_text after their partner answers.';
|
COMMENT ON COLUMN questions.target_user_genders IS 'Array of genders that can see this question first (as initiator). NULL = all genders. Partners still see it via partner_text after their partner answers.';
|
||||||
|
|
||||||
-- Responses table
|
-- Responses table
|
||||||
CREATE TABLE responses (
|
CREATE TABLE responses (
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
@@ -94,7 +87,6 @@ CREATE TABLE responses (
|
|||||||
created_at TIMESTAMPTZ DEFAULT now(),
|
created_at TIMESTAMPTZ DEFAULT now(),
|
||||||
UNIQUE(user_id, question_id)
|
UNIQUE(user_id, question_id)
|
||||||
);
|
);
|
||||||
|
|
||||||
-- Matches table
|
-- Matches table
|
||||||
CREATE TABLE matches (
|
CREATE TABLE matches (
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
@@ -105,7 +97,6 @@ CREATE TABLE matches (
|
|||||||
created_at TIMESTAMPTZ DEFAULT now(),
|
created_at TIMESTAMPTZ DEFAULT now(),
|
||||||
UNIQUE(couple_id, question_id)
|
UNIQUE(couple_id, question_id)
|
||||||
);
|
);
|
||||||
|
|
||||||
-- Messages table
|
-- Messages table
|
||||||
CREATE TABLE messages (
|
CREATE TABLE messages (
|
||||||
id UUID PRIMARY KEY DEFAULT extensions.uuid_generate_v4(),
|
id UUID PRIMARY KEY DEFAULT extensions.uuid_generate_v4(),
|
||||||
@@ -119,7 +110,6 @@ CREATE TABLE messages (
|
|||||||
delivered_at TIMESTAMPTZ,
|
delivered_at TIMESTAMPTZ,
|
||||||
CONSTRAINT content_or_media CHECK (content IS NOT NULL OR media_path IS NOT NULL)
|
CONSTRAINT content_or_media CHECK (content IS NOT NULL OR media_path IS NOT NULL)
|
||||||
);
|
);
|
||||||
|
|
||||||
-- Couple packs table (which packs a couple has enabled)
|
-- Couple packs table (which packs a couple has enabled)
|
||||||
CREATE TABLE couple_packs (
|
CREATE TABLE couple_packs (
|
||||||
couple_id UUID NOT NULL REFERENCES couples(id) ON DELETE CASCADE,
|
couple_id UUID NOT NULL REFERENCES couples(id) ON DELETE CASCADE,
|
||||||
@@ -128,7 +118,6 @@ CREATE TABLE couple_packs (
|
|||||||
created_at TIMESTAMPTZ DEFAULT now(),
|
created_at TIMESTAMPTZ DEFAULT now(),
|
||||||
PRIMARY KEY (couple_id, pack_id)
|
PRIMARY KEY (couple_id, pack_id)
|
||||||
);
|
);
|
||||||
|
|
||||||
-- Feedback table
|
-- Feedback table
|
||||||
CREATE TABLE feedback (
|
CREATE TABLE feedback (
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
@@ -144,7 +133,6 @@ CREATE TABLE feedback (
|
|||||||
updated_at TIMESTAMPTZ DEFAULT now(),
|
updated_at TIMESTAMPTZ DEFAULT now(),
|
||||||
question_id UUID REFERENCES questions(id) ON DELETE SET NULL
|
question_id UUID REFERENCES questions(id) ON DELETE SET NULL
|
||||||
);
|
);
|
||||||
|
|
||||||
-- Subscriptions table
|
-- Subscriptions table
|
||||||
CREATE TABLE subscriptions (
|
CREATE TABLE subscriptions (
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
@@ -164,7 +152,6 @@ CREATE TABLE subscriptions (
|
|||||||
updated_at TIMESTAMPTZ DEFAULT now(),
|
updated_at TIMESTAMPTZ DEFAULT now(),
|
||||||
UNIQUE(user_id, original_transaction_id)
|
UNIQUE(user_id, original_transaction_id)
|
||||||
);
|
);
|
||||||
|
|
||||||
-- RevenueCat webhook events table (for idempotency)
|
-- RevenueCat webhook events table (for idempotency)
|
||||||
CREATE TABLE revenuecat_webhook_events (
|
CREATE TABLE revenuecat_webhook_events (
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
@@ -174,7 +161,6 @@ CREATE TABLE revenuecat_webhook_events (
|
|||||||
processed_at TIMESTAMPTZ DEFAULT now(),
|
processed_at TIMESTAMPTZ DEFAULT now(),
|
||||||
payload JSONB
|
payload JSONB
|
||||||
);
|
);
|
||||||
|
|
||||||
-- Admin users table
|
-- Admin users table
|
||||||
CREATE TABLE admin_users (
|
CREATE TABLE admin_users (
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
@@ -184,7 +170,6 @@ CREATE TABLE admin_users (
|
|||||||
created_by UUID REFERENCES auth.users(id),
|
created_by UUID REFERENCES auth.users(id),
|
||||||
updated_at TIMESTAMPTZ DEFAULT now()
|
updated_at TIMESTAMPTZ DEFAULT now()
|
||||||
);
|
);
|
||||||
|
|
||||||
-- Audit logs table
|
-- Audit logs table
|
||||||
CREATE TABLE audit_logs (
|
CREATE TABLE audit_logs (
|
||||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
@@ -198,7 +183,6 @@ CREATE TABLE audit_logs (
|
|||||||
admin_role admin_role NOT NULL,
|
admin_role admin_role NOT NULL,
|
||||||
created_at TIMESTAMPTZ DEFAULT now()
|
created_at TIMESTAMPTZ DEFAULT now()
|
||||||
);
|
);
|
||||||
|
|
||||||
-- ============================================================================
|
-- ============================================================================
|
||||||
-- INDEXES
|
-- INDEXES
|
||||||
-- ============================================================================
|
-- ============================================================================
|
||||||
@@ -225,7 +209,6 @@ CREATE INDEX idx_audit_logs_record_id ON audit_logs(record_id);
|
|||||||
CREATE INDEX idx_audit_logs_admin_user_id ON audit_logs(admin_user_id);
|
CREATE INDEX idx_audit_logs_admin_user_id ON audit_logs(admin_user_id);
|
||||||
CREATE INDEX idx_audit_logs_action ON audit_logs(action);
|
CREATE INDEX idx_audit_logs_action ON audit_logs(action);
|
||||||
CREATE INDEX idx_audit_logs_created_at ON audit_logs(created_at DESC);
|
CREATE INDEX idx_audit_logs_created_at ON audit_logs(created_at DESC);
|
||||||
|
|
||||||
-- ============================================================================
|
-- ============================================================================
|
||||||
-- FUNCTIONS
|
-- FUNCTIONS
|
||||||
-- ============================================================================
|
-- ============================================================================
|
||||||
@@ -240,7 +223,6 @@ SET search_path = ''
|
|||||||
AS $$
|
AS $$
|
||||||
SELECT couple_id FROM public.profiles WHERE id = auth.uid();
|
SELECT couple_id FROM public.profiles WHERE id = auth.uid();
|
||||||
$$;
|
$$;
|
||||||
|
|
||||||
-- Check if user is any admin
|
-- Check if user is any admin
|
||||||
CREATE OR REPLACE FUNCTION is_admin(check_user_id UUID DEFAULT auth.uid())
|
CREATE OR REPLACE FUNCTION is_admin(check_user_id UUID DEFAULT auth.uid())
|
||||||
RETURNS BOOLEAN
|
RETURNS BOOLEAN
|
||||||
@@ -255,7 +237,6 @@ BEGIN
|
|||||||
);
|
);
|
||||||
END;
|
END;
|
||||||
$$;
|
$$;
|
||||||
|
|
||||||
-- Check if user is super admin
|
-- Check if user is super admin
|
||||||
CREATE OR REPLACE FUNCTION is_super_admin(check_user_id UUID DEFAULT auth.uid())
|
CREATE OR REPLACE FUNCTION is_super_admin(check_user_id UUID DEFAULT auth.uid())
|
||||||
RETURNS BOOLEAN
|
RETURNS BOOLEAN
|
||||||
@@ -271,7 +252,6 @@ BEGIN
|
|||||||
);
|
);
|
||||||
END;
|
END;
|
||||||
$$;
|
$$;
|
||||||
|
|
||||||
-- Get admin role for user
|
-- Get admin role for user
|
||||||
CREATE OR REPLACE FUNCTION get_admin_role(check_user_id UUID DEFAULT auth.uid())
|
CREATE OR REPLACE FUNCTION get_admin_role(check_user_id UUID DEFAULT auth.uid())
|
||||||
RETURNS admin_role
|
RETURNS admin_role
|
||||||
@@ -287,7 +267,6 @@ BEGIN
|
|||||||
RETURN user_role;
|
RETURN user_role;
|
||||||
END;
|
END;
|
||||||
$$;
|
$$;
|
||||||
|
|
||||||
-- Check if user or partner has premium access
|
-- Check if user or partner has premium access
|
||||||
CREATE OR REPLACE FUNCTION has_premium_access(check_user_id UUID)
|
CREATE OR REPLACE FUNCTION has_premium_access(check_user_id UUID)
|
||||||
RETURNS BOOLEAN
|
RETURNS BOOLEAN
|
||||||
@@ -322,7 +301,6 @@ BEGIN
|
|||||||
RETURN COALESCE(partner_premium, FALSE);
|
RETURN COALESCE(partner_premium, FALSE);
|
||||||
END;
|
END;
|
||||||
$$;
|
$$;
|
||||||
|
|
||||||
-- Get recommended questions for a user
|
-- Get recommended questions for a user
|
||||||
CREATE OR REPLACE FUNCTION get_recommended_questions(target_pack_id UUID DEFAULT NULL)
|
CREATE OR REPLACE FUNCTION get_recommended_questions(target_pack_id UUID DEFAULT NULL)
|
||||||
RETURNS TABLE(
|
RETURNS TABLE(
|
||||||
@@ -447,7 +425,6 @@ BEGIN
|
|||||||
);
|
);
|
||||||
END;
|
END;
|
||||||
$$;
|
$$;
|
||||||
|
|
||||||
-- Get pack teaser questions (bypasses premium for preview)
|
-- Get pack teaser questions (bypasses premium for preview)
|
||||||
CREATE OR REPLACE FUNCTION get_pack_teaser_questions(target_pack_id UUID)
|
CREATE OR REPLACE FUNCTION get_pack_teaser_questions(target_pack_id UUID)
|
||||||
RETURNS TABLE(id UUID, text TEXT, intensity INTEGER)
|
RETURNS TABLE(id UUID, text TEXT, intensity INTEGER)
|
||||||
@@ -469,7 +446,6 @@ BEGIN
|
|||||||
LIMIT 3;
|
LIMIT 3;
|
||||||
END;
|
END;
|
||||||
$$;
|
$$;
|
||||||
|
|
||||||
-- Handle new user signup (create profile)
|
-- Handle new user signup (create profile)
|
||||||
CREATE OR REPLACE FUNCTION handle_new_user()
|
CREATE OR REPLACE FUNCTION handle_new_user()
|
||||||
RETURNS TRIGGER
|
RETURNS TRIGGER
|
||||||
@@ -488,7 +464,6 @@ BEGIN
|
|||||||
RETURN NEW;
|
RETURN NEW;
|
||||||
END;
|
END;
|
||||||
$$;
|
$$;
|
||||||
|
|
||||||
-- Update updated_at timestamp
|
-- Update updated_at timestamp
|
||||||
CREATE OR REPLACE FUNCTION update_updated_at()
|
CREATE OR REPLACE FUNCTION update_updated_at()
|
||||||
RETURNS TRIGGER
|
RETURNS TRIGGER
|
||||||
@@ -500,7 +475,6 @@ BEGIN
|
|||||||
RETURN NEW;
|
RETURN NEW;
|
||||||
END;
|
END;
|
||||||
$$;
|
$$;
|
||||||
|
|
||||||
-- Check couple size (max 2 members)
|
-- Check couple size (max 2 members)
|
||||||
CREATE OR REPLACE FUNCTION check_couple_size()
|
CREATE OR REPLACE FUNCTION check_couple_size()
|
||||||
RETURNS TRIGGER
|
RETURNS TRIGGER
|
||||||
@@ -516,7 +490,6 @@ BEGIN
|
|||||||
RETURN NEW;
|
RETURN NEW;
|
||||||
END;
|
END;
|
||||||
$$;
|
$$;
|
||||||
|
|
||||||
-- Sync premium status from subscriptions
|
-- Sync premium status from subscriptions
|
||||||
CREATE OR REPLACE FUNCTION sync_premium_status()
|
CREATE OR REPLACE FUNCTION sync_premium_status()
|
||||||
RETURNS TRIGGER
|
RETURNS TRIGGER
|
||||||
@@ -537,7 +510,6 @@ BEGIN
|
|||||||
RETURN NEW;
|
RETURN NEW;
|
||||||
END;
|
END;
|
||||||
$$;
|
$$;
|
||||||
|
|
||||||
-- Disable premium packs when subscription is lost
|
-- Disable premium packs when subscription is lost
|
||||||
CREATE OR REPLACE FUNCTION disable_premium_packs_on_subscription_loss()
|
CREATE OR REPLACE FUNCTION disable_premium_packs_on_subscription_loss()
|
||||||
RETURNS TRIGGER
|
RETURNS TRIGGER
|
||||||
@@ -566,7 +538,6 @@ BEGIN
|
|||||||
RETURN NEW;
|
RETURN NEW;
|
||||||
END;
|
END;
|
||||||
$$;
|
$$;
|
||||||
|
|
||||||
-- Log admin actions
|
-- Log admin actions
|
||||||
CREATE OR REPLACE FUNCTION log_admin_action(
|
CREATE OR REPLACE FUNCTION log_admin_action(
|
||||||
p_table_name TEXT,
|
p_table_name TEXT,
|
||||||
@@ -611,7 +582,6 @@ BEGIN
|
|||||||
RETURN log_id;
|
RETURN log_id;
|
||||||
END;
|
END;
|
||||||
$$;
|
$$;
|
||||||
|
|
||||||
-- Notify on new message (calls edge function)
|
-- Notify on new message (calls edge function)
|
||||||
CREATE OR REPLACE FUNCTION notify_new_message()
|
CREATE OR REPLACE FUNCTION notify_new_message()
|
||||||
RETURNS TRIGGER
|
RETURNS TRIGGER
|
||||||
@@ -634,7 +604,6 @@ BEGIN
|
|||||||
RETURN NEW;
|
RETURN NEW;
|
||||||
END;
|
END;
|
||||||
$$;
|
$$;
|
||||||
|
|
||||||
-- ============================================================================
|
-- ============================================================================
|
||||||
-- TRIGGERS
|
-- TRIGGERS
|
||||||
-- ============================================================================
|
-- ============================================================================
|
||||||
@@ -643,41 +612,33 @@ $$;
|
|||||||
CREATE TRIGGER update_profiles_updated_at
|
CREATE TRIGGER update_profiles_updated_at
|
||||||
BEFORE UPDATE ON profiles
|
BEFORE UPDATE ON profiles
|
||||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
|
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
|
||||||
|
|
||||||
CREATE TRIGGER enforce_couple_size
|
CREATE TRIGGER enforce_couple_size
|
||||||
BEFORE INSERT OR UPDATE OF couple_id ON profiles
|
BEFORE INSERT OR UPDATE OF couple_id ON profiles
|
||||||
FOR EACH ROW EXECUTE FUNCTION check_couple_size();
|
FOR EACH ROW EXECUTE FUNCTION check_couple_size();
|
||||||
|
|
||||||
CREATE TRIGGER disable_premium_packs_on_sub_loss
|
CREATE TRIGGER disable_premium_packs_on_sub_loss
|
||||||
AFTER UPDATE OF is_premium ON profiles
|
AFTER UPDATE OF is_premium ON profiles
|
||||||
FOR EACH ROW
|
FOR EACH ROW
|
||||||
WHEN (OLD.is_premium IS DISTINCT FROM NEW.is_premium)
|
WHEN (OLD.is_premium IS DISTINCT FROM NEW.is_premium)
|
||||||
EXECUTE FUNCTION disable_premium_packs_on_subscription_loss();
|
EXECUTE FUNCTION disable_premium_packs_on_subscription_loss();
|
||||||
|
|
||||||
-- Subscription triggers
|
-- Subscription triggers
|
||||||
CREATE TRIGGER update_subscriptions_updated_at
|
CREATE TRIGGER update_subscriptions_updated_at
|
||||||
BEFORE UPDATE ON subscriptions
|
BEFORE UPDATE ON subscriptions
|
||||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
|
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
|
||||||
|
|
||||||
CREATE TRIGGER sync_premium_on_subscription_change
|
CREATE TRIGGER sync_premium_on_subscription_change
|
||||||
AFTER INSERT OR UPDATE ON subscriptions
|
AFTER INSERT OR UPDATE ON subscriptions
|
||||||
FOR EACH ROW EXECUTE FUNCTION sync_premium_status();
|
FOR EACH ROW EXECUTE FUNCTION sync_premium_status();
|
||||||
|
|
||||||
-- Admin users trigger
|
-- Admin users trigger
|
||||||
CREATE TRIGGER update_admin_users_updated_at
|
CREATE TRIGGER update_admin_users_updated_at
|
||||||
BEFORE UPDATE ON admin_users
|
BEFORE UPDATE ON admin_users
|
||||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
|
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
|
||||||
|
|
||||||
-- Message notification trigger
|
-- Message notification trigger
|
||||||
CREATE TRIGGER on_new_message
|
CREATE TRIGGER on_new_message
|
||||||
AFTER INSERT ON messages
|
AFTER INSERT ON messages
|
||||||
FOR EACH ROW EXECUTE FUNCTION notify_new_message();
|
FOR EACH ROW EXECUTE FUNCTION notify_new_message();
|
||||||
|
|
||||||
-- Auth trigger to create profile on signup
|
-- Auth trigger to create profile on signup
|
||||||
CREATE TRIGGER on_auth_user_created
|
CREATE TRIGGER on_auth_user_created
|
||||||
AFTER INSERT ON auth.users
|
AFTER INSERT ON auth.users
|
||||||
FOR EACH ROW EXECUTE FUNCTION handle_new_user();
|
FOR EACH ROW EXECUTE FUNCTION handle_new_user();
|
||||||
|
|
||||||
-- ============================================================================
|
-- ============================================================================
|
||||||
-- ENABLE ROW LEVEL SECURITY
|
-- ENABLE ROW LEVEL SECURITY
|
||||||
-- ============================================================================
|
-- ============================================================================
|
||||||
@@ -695,102 +656,81 @@ ALTER TABLE subscriptions ENABLE ROW LEVEL SECURITY;
|
|||||||
ALTER TABLE revenuecat_webhook_events ENABLE ROW LEVEL SECURITY;
|
ALTER TABLE revenuecat_webhook_events ENABLE ROW LEVEL SECURITY;
|
||||||
ALTER TABLE admin_users ENABLE ROW LEVEL SECURITY;
|
ALTER TABLE admin_users ENABLE ROW LEVEL SECURITY;
|
||||||
ALTER TABLE audit_logs ENABLE ROW LEVEL SECURITY;
|
ALTER TABLE audit_logs ENABLE ROW LEVEL SECURITY;
|
||||||
|
|
||||||
-- ============================================================================
|
-- ============================================================================
|
||||||
-- RLS POLICIES: COUPLES
|
-- RLS POLICIES: COUPLES
|
||||||
-- ============================================================================
|
-- ============================================================================
|
||||||
CREATE POLICY "Authenticated users can create couples"
|
CREATE POLICY "Authenticated users can create couples"
|
||||||
ON couples FOR INSERT
|
ON couples FOR INSERT
|
||||||
WITH CHECK (auth.role() = 'authenticated');
|
WITH CHECK (auth.role() = 'authenticated');
|
||||||
|
|
||||||
CREATE POLICY "Users can view own couple"
|
CREATE POLICY "Users can view own couple"
|
||||||
ON couples FOR SELECT
|
ON couples FOR SELECT
|
||||||
USING (id = get_auth_user_couple_id());
|
USING (id = get_auth_user_couple_id());
|
||||||
|
|
||||||
CREATE POLICY "Users can lookup couple by exact invite code"
|
CREATE POLICY "Users can lookup couple by exact invite code"
|
||||||
ON couples FOR SELECT
|
ON couples FOR SELECT
|
||||||
USING (id = get_auth_user_couple_id());
|
USING (id = get_auth_user_couple_id());
|
||||||
|
|
||||||
CREATE POLICY "Super admins can view all couples"
|
CREATE POLICY "Super admins can view all couples"
|
||||||
ON couples FOR SELECT
|
ON couples FOR SELECT
|
||||||
USING (is_super_admin());
|
USING (is_super_admin());
|
||||||
|
|
||||||
-- ============================================================================
|
-- ============================================================================
|
||||||
-- RLS POLICIES: PROFILES
|
-- RLS POLICIES: PROFILES
|
||||||
-- ============================================================================
|
-- ============================================================================
|
||||||
CREATE POLICY "Users can view own profile"
|
CREATE POLICY "Users can view own profile"
|
||||||
ON profiles FOR SELECT
|
ON profiles FOR SELECT
|
||||||
USING (auth.uid() = id);
|
USING (auth.uid() = id);
|
||||||
|
|
||||||
CREATE POLICY "Users can view partner profile"
|
CREATE POLICY "Users can view partner profile"
|
||||||
ON profiles FOR SELECT
|
ON profiles FOR SELECT
|
||||||
USING (couple_id IS NOT NULL AND couple_id = get_auth_user_couple_id());
|
USING (couple_id IS NOT NULL AND couple_id = get_auth_user_couple_id());
|
||||||
|
|
||||||
CREATE POLICY "Users can update own profile"
|
CREATE POLICY "Users can update own profile"
|
||||||
ON profiles FOR UPDATE
|
ON profiles FOR UPDATE
|
||||||
USING (auth.uid() = id);
|
USING (auth.uid() = id);
|
||||||
|
|
||||||
CREATE POLICY "Super admins can view all profiles"
|
CREATE POLICY "Super admins can view all profiles"
|
||||||
ON profiles FOR SELECT
|
ON profiles FOR SELECT
|
||||||
USING (is_super_admin());
|
USING (is_super_admin());
|
||||||
|
|
||||||
CREATE POLICY "Super admins can update all profiles"
|
CREATE POLICY "Super admins can update all profiles"
|
||||||
ON profiles FOR UPDATE
|
ON profiles FOR UPDATE
|
||||||
USING (is_super_admin());
|
USING (is_super_admin());
|
||||||
|
|
||||||
-- ============================================================================
|
-- ============================================================================
|
||||||
-- RLS POLICIES: CATEGORIES
|
-- RLS POLICIES: CATEGORIES
|
||||||
-- ============================================================================
|
-- ============================================================================
|
||||||
CREATE POLICY "Categories are viewable by everyone"
|
CREATE POLICY "Categories are viewable by everyone"
|
||||||
ON categories FOR SELECT
|
ON categories FOR SELECT
|
||||||
USING (true);
|
USING (true);
|
||||||
|
|
||||||
CREATE POLICY "Admins can view all categories"
|
CREATE POLICY "Admins can view all categories"
|
||||||
ON categories FOR SELECT
|
ON categories FOR SELECT
|
||||||
USING (is_admin());
|
USING (is_admin());
|
||||||
|
|
||||||
CREATE POLICY "Admins can insert categories"
|
CREATE POLICY "Admins can insert categories"
|
||||||
ON categories FOR INSERT
|
ON categories FOR INSERT
|
||||||
WITH CHECK (is_admin());
|
WITH CHECK (is_admin());
|
||||||
|
|
||||||
CREATE POLICY "Admins can update categories"
|
CREATE POLICY "Admins can update categories"
|
||||||
ON categories FOR UPDATE
|
ON categories FOR UPDATE
|
||||||
USING (is_admin())
|
USING (is_admin())
|
||||||
WITH CHECK (is_admin());
|
WITH CHECK (is_admin());
|
||||||
|
|
||||||
CREATE POLICY "Admins can delete categories"
|
CREATE POLICY "Admins can delete categories"
|
||||||
ON categories FOR DELETE
|
ON categories FOR DELETE
|
||||||
USING (is_admin());
|
USING (is_admin());
|
||||||
|
|
||||||
-- ============================================================================
|
-- ============================================================================
|
||||||
-- RLS POLICIES: QUESTION PACKS
|
-- RLS POLICIES: QUESTION PACKS
|
||||||
-- ============================================================================
|
-- ============================================================================
|
||||||
CREATE POLICY "Anyone can view public packs"
|
CREATE POLICY "Anyone can view public packs"
|
||||||
ON question_packs FOR SELECT
|
ON question_packs FOR SELECT
|
||||||
USING (is_public = true);
|
USING (is_public = true);
|
||||||
|
|
||||||
CREATE POLICY "Premium users can view premium packs"
|
CREATE POLICY "Premium users can view premium packs"
|
||||||
ON question_packs FOR SELECT
|
ON question_packs FOR SELECT
|
||||||
USING (is_premium = false OR EXISTS (
|
USING (is_premium = false OR EXISTS (
|
||||||
SELECT 1 FROM profiles WHERE profiles.id = auth.uid() AND profiles.is_premium = true
|
SELECT 1 FROM profiles WHERE profiles.id = auth.uid() AND profiles.is_premium = true
|
||||||
));
|
));
|
||||||
|
|
||||||
CREATE POLICY "Admins can view all question packs"
|
CREATE POLICY "Admins can view all question packs"
|
||||||
ON question_packs FOR SELECT
|
ON question_packs FOR SELECT
|
||||||
USING (is_admin());
|
USING (is_admin());
|
||||||
|
|
||||||
CREATE POLICY "Admins can insert question packs"
|
CREATE POLICY "Admins can insert question packs"
|
||||||
ON question_packs FOR INSERT
|
ON question_packs FOR INSERT
|
||||||
WITH CHECK (is_admin());
|
WITH CHECK (is_admin());
|
||||||
|
|
||||||
CREATE POLICY "Admins can update question packs"
|
CREATE POLICY "Admins can update question packs"
|
||||||
ON question_packs FOR UPDATE
|
ON question_packs FOR UPDATE
|
||||||
USING (is_admin());
|
USING (is_admin());
|
||||||
|
|
||||||
CREATE POLICY "Admins can delete question packs"
|
CREATE POLICY "Admins can delete question packs"
|
||||||
ON question_packs FOR DELETE
|
ON question_packs FOR DELETE
|
||||||
USING (is_admin());
|
USING (is_admin());
|
||||||
|
|
||||||
-- ============================================================================
|
-- ============================================================================
|
||||||
-- RLS POLICIES: QUESTIONS
|
-- RLS POLICIES: QUESTIONS
|
||||||
-- ============================================================================
|
-- ============================================================================
|
||||||
@@ -802,23 +742,18 @@ CREATE POLICY "Anyone can view questions in visible packs"
|
|||||||
SELECT 1 FROM profiles WHERE profiles.id = auth.uid() AND profiles.is_premium = true
|
SELECT 1 FROM profiles WHERE profiles.id = auth.uid() AND profiles.is_premium = true
|
||||||
))
|
))
|
||||||
));
|
));
|
||||||
|
|
||||||
CREATE POLICY "Admins can view all questions"
|
CREATE POLICY "Admins can view all questions"
|
||||||
ON questions FOR SELECT
|
ON questions FOR SELECT
|
||||||
USING (is_admin());
|
USING (is_admin());
|
||||||
|
|
||||||
CREATE POLICY "Admins can insert questions"
|
CREATE POLICY "Admins can insert questions"
|
||||||
ON questions FOR INSERT
|
ON questions FOR INSERT
|
||||||
WITH CHECK (is_admin());
|
WITH CHECK (is_admin());
|
||||||
|
|
||||||
CREATE POLICY "Admins can update questions"
|
CREATE POLICY "Admins can update questions"
|
||||||
ON questions FOR UPDATE
|
ON questions FOR UPDATE
|
||||||
USING (is_admin());
|
USING (is_admin());
|
||||||
|
|
||||||
CREATE POLICY "Admins can delete questions"
|
CREATE POLICY "Admins can delete questions"
|
||||||
ON questions FOR DELETE
|
ON questions FOR DELETE
|
||||||
USING (is_admin());
|
USING (is_admin());
|
||||||
|
|
||||||
-- ============================================================================
|
-- ============================================================================
|
||||||
-- RLS POLICIES: RESPONSES
|
-- RLS POLICIES: RESPONSES
|
||||||
-- ============================================================================
|
-- ============================================================================
|
||||||
@@ -827,42 +762,33 @@ CREATE POLICY "Users can view couple responses"
|
|||||||
USING (user_id = auth.uid() OR couple_id IN (
|
USING (user_id = auth.uid() OR couple_id IN (
|
||||||
SELECT couple_id FROM profiles WHERE id = auth.uid()
|
SELECT couple_id FROM profiles WHERE id = auth.uid()
|
||||||
));
|
));
|
||||||
|
|
||||||
CREATE POLICY "Users can insert own responses"
|
CREATE POLICY "Users can insert own responses"
|
||||||
ON responses FOR INSERT
|
ON responses FOR INSERT
|
||||||
WITH CHECK (user_id = auth.uid() AND couple_id = get_auth_user_couple_id());
|
WITH CHECK (user_id = auth.uid() AND couple_id = get_auth_user_couple_id());
|
||||||
|
|
||||||
CREATE POLICY "Users can update own responses"
|
CREATE POLICY "Users can update own responses"
|
||||||
ON responses FOR UPDATE
|
ON responses FOR UPDATE
|
||||||
USING (user_id = auth.uid());
|
USING (user_id = auth.uid());
|
||||||
|
|
||||||
CREATE POLICY "Users can delete own responses"
|
CREATE POLICY "Users can delete own responses"
|
||||||
ON responses FOR DELETE
|
ON responses FOR DELETE
|
||||||
USING (user_id = auth.uid());
|
USING (user_id = auth.uid());
|
||||||
|
|
||||||
CREATE POLICY "Super admins can view all responses"
|
CREATE POLICY "Super admins can view all responses"
|
||||||
ON responses FOR SELECT
|
ON responses FOR SELECT
|
||||||
USING (is_super_admin());
|
USING (is_super_admin());
|
||||||
|
|
||||||
-- ============================================================================
|
-- ============================================================================
|
||||||
-- RLS POLICIES: MATCHES
|
-- RLS POLICIES: MATCHES
|
||||||
-- ============================================================================
|
-- ============================================================================
|
||||||
CREATE POLICY "Users can view couple matches"
|
CREATE POLICY "Users can view couple matches"
|
||||||
ON matches FOR SELECT
|
ON matches FOR SELECT
|
||||||
USING (couple_id = get_auth_user_couple_id());
|
USING (couple_id = get_auth_user_couple_id());
|
||||||
|
|
||||||
CREATE POLICY "Users can update match seen status"
|
CREATE POLICY "Users can update match seen status"
|
||||||
ON matches FOR UPDATE
|
ON matches FOR UPDATE
|
||||||
USING (couple_id = get_auth_user_couple_id());
|
USING (couple_id = get_auth_user_couple_id());
|
||||||
|
|
||||||
CREATE POLICY "Users can delete own couple matches"
|
CREATE POLICY "Users can delete own couple matches"
|
||||||
ON matches FOR DELETE
|
ON matches FOR DELETE
|
||||||
USING (couple_id = get_auth_user_couple_id());
|
USING (couple_id = get_auth_user_couple_id());
|
||||||
|
|
||||||
CREATE POLICY "Super admins can view all matches"
|
CREATE POLICY "Super admins can view all matches"
|
||||||
ON matches FOR SELECT
|
ON matches FOR SELECT
|
||||||
USING (is_super_admin());
|
USING (is_super_admin());
|
||||||
|
|
||||||
-- ============================================================================
|
-- ============================================================================
|
||||||
-- RLS POLICIES: MESSAGES
|
-- RLS POLICIES: MESSAGES
|
||||||
-- ============================================================================
|
-- ============================================================================
|
||||||
@@ -873,7 +799,6 @@ CREATE POLICY "Users can view messages in their matches"
|
|||||||
WHERE m.id = messages.match_id
|
WHERE m.id = messages.match_id
|
||||||
AND m.couple_id IN (SELECT couple_id FROM profiles WHERE id = auth.uid())
|
AND m.couple_id IN (SELECT couple_id FROM profiles WHERE id = auth.uid())
|
||||||
));
|
));
|
||||||
|
|
||||||
CREATE POLICY "Users can insert messages in their matches"
|
CREATE POLICY "Users can insert messages in their matches"
|
||||||
ON messages FOR INSERT
|
ON messages FOR INSERT
|
||||||
WITH CHECK (auth.uid() = user_id AND EXISTS (
|
WITH CHECK (auth.uid() = user_id AND EXISTS (
|
||||||
@@ -881,7 +806,6 @@ CREATE POLICY "Users can insert messages in their matches"
|
|||||||
WHERE m.id = messages.match_id
|
WHERE m.id = messages.match_id
|
||||||
AND m.couple_id IN (SELECT couple_id FROM profiles WHERE id = auth.uid())
|
AND m.couple_id IN (SELECT couple_id FROM profiles WHERE id = auth.uid())
|
||||||
));
|
));
|
||||||
|
|
||||||
CREATE POLICY "Users can mark messages as read"
|
CREATE POLICY "Users can mark messages as read"
|
||||||
ON messages FOR UPDATE
|
ON messages FOR UPDATE
|
||||||
USING (auth.uid() <> user_id AND EXISTS (
|
USING (auth.uid() <> user_id AND EXISTS (
|
||||||
@@ -890,15 +814,12 @@ CREATE POLICY "Users can mark messages as read"
|
|||||||
AND m.couple_id IN (SELECT couple_id FROM profiles WHERE id = auth.uid())
|
AND m.couple_id IN (SELECT couple_id FROM profiles WHERE id = auth.uid())
|
||||||
))
|
))
|
||||||
WITH CHECK (auth.uid() <> user_id);
|
WITH CHECK (auth.uid() <> user_id);
|
||||||
|
|
||||||
CREATE POLICY "Users can delete own messages"
|
CREATE POLICY "Users can delete own messages"
|
||||||
ON messages FOR DELETE
|
ON messages FOR DELETE
|
||||||
USING (user_id = auth.uid());
|
USING (user_id = auth.uid());
|
||||||
|
|
||||||
CREATE POLICY "Super admins can view all messages"
|
CREATE POLICY "Super admins can view all messages"
|
||||||
ON messages FOR SELECT
|
ON messages FOR SELECT
|
||||||
USING (is_super_admin());
|
USING (is_super_admin());
|
||||||
|
|
||||||
-- ============================================================================
|
-- ============================================================================
|
||||||
-- RLS POLICIES: COUPLE PACKS
|
-- RLS POLICIES: COUPLE PACKS
|
||||||
-- ============================================================================
|
-- ============================================================================
|
||||||
@@ -908,67 +829,54 @@ CREATE POLICY "Couples can view their own pack settings"
|
|||||||
SELECT 1 FROM profiles
|
SELECT 1 FROM profiles
|
||||||
WHERE profiles.id = auth.uid() AND profiles.couple_id = couple_packs.couple_id
|
WHERE profiles.id = auth.uid() AND profiles.couple_id = couple_packs.couple_id
|
||||||
));
|
));
|
||||||
|
|
||||||
CREATE POLICY "Couples can modify their own pack settings"
|
CREATE POLICY "Couples can modify their own pack settings"
|
||||||
ON couple_packs FOR ALL
|
ON couple_packs FOR ALL
|
||||||
USING (EXISTS (
|
USING (EXISTS (
|
||||||
SELECT 1 FROM profiles
|
SELECT 1 FROM profiles
|
||||||
WHERE profiles.id = auth.uid() AND profiles.couple_id = couple_packs.couple_id
|
WHERE profiles.id = auth.uid() AND profiles.couple_id = couple_packs.couple_id
|
||||||
));
|
));
|
||||||
|
|
||||||
CREATE POLICY "Users can delete own couple pack settings"
|
CREATE POLICY "Users can delete own couple pack settings"
|
||||||
ON couple_packs FOR DELETE
|
ON couple_packs FOR DELETE
|
||||||
USING (EXISTS (
|
USING (EXISTS (
|
||||||
SELECT 1 FROM profiles
|
SELECT 1 FROM profiles
|
||||||
WHERE profiles.id = auth.uid() AND profiles.couple_id = couple_packs.couple_id
|
WHERE profiles.id = auth.uid() AND profiles.couple_id = couple_packs.couple_id
|
||||||
));
|
));
|
||||||
|
|
||||||
-- ============================================================================
|
-- ============================================================================
|
||||||
-- RLS POLICIES: FEEDBACK
|
-- RLS POLICIES: FEEDBACK
|
||||||
-- ============================================================================
|
-- ============================================================================
|
||||||
CREATE POLICY "Users can create feedback"
|
CREATE POLICY "Users can create feedback"
|
||||||
ON feedback FOR INSERT
|
ON feedback FOR INSERT
|
||||||
WITH CHECK (auth.uid() = user_id);
|
WITH CHECK (auth.uid() = user_id);
|
||||||
|
|
||||||
CREATE POLICY "Users can view own feedback"
|
CREATE POLICY "Users can view own feedback"
|
||||||
ON feedback FOR SELECT
|
ON feedback FOR SELECT
|
||||||
USING (auth.uid() = user_id);
|
USING (auth.uid() = user_id);
|
||||||
|
|
||||||
CREATE POLICY "Users can update own pending feedback"
|
CREATE POLICY "Users can update own pending feedback"
|
||||||
ON feedback FOR UPDATE
|
ON feedback FOR UPDATE
|
||||||
USING (auth.uid() = user_id AND status = 'new');
|
USING (auth.uid() = user_id AND status = 'new');
|
||||||
|
|
||||||
CREATE POLICY "Super admins can view all feedback"
|
CREATE POLICY "Super admins can view all feedback"
|
||||||
ON feedback FOR SELECT
|
ON feedback FOR SELECT
|
||||||
USING (is_super_admin());
|
USING (is_super_admin());
|
||||||
|
|
||||||
CREATE POLICY "Super admins can update all feedback"
|
CREATE POLICY "Super admins can update all feedback"
|
||||||
ON feedback FOR UPDATE
|
ON feedback FOR UPDATE
|
||||||
USING (is_super_admin());
|
USING (is_super_admin());
|
||||||
|
|
||||||
-- ============================================================================
|
-- ============================================================================
|
||||||
-- RLS POLICIES: SUBSCRIPTIONS
|
-- RLS POLICIES: SUBSCRIPTIONS
|
||||||
-- ============================================================================
|
-- ============================================================================
|
||||||
CREATE POLICY "Users can view own subscriptions"
|
CREATE POLICY "Users can view own subscriptions"
|
||||||
ON subscriptions FOR SELECT
|
ON subscriptions FOR SELECT
|
||||||
USING (auth.uid() = user_id);
|
USING (auth.uid() = user_id);
|
||||||
|
|
||||||
CREATE POLICY "Super admins can view all subscriptions"
|
CREATE POLICY "Super admins can view all subscriptions"
|
||||||
ON subscriptions FOR SELECT
|
ON subscriptions FOR SELECT
|
||||||
USING (is_super_admin());
|
USING (is_super_admin());
|
||||||
|
|
||||||
CREATE POLICY "Super admins can insert subscriptions"
|
CREATE POLICY "Super admins can insert subscriptions"
|
||||||
ON subscriptions FOR INSERT
|
ON subscriptions FOR INSERT
|
||||||
WITH CHECK (is_super_admin());
|
WITH CHECK (is_super_admin());
|
||||||
|
|
||||||
CREATE POLICY "Super admins can update subscriptions"
|
CREATE POLICY "Super admins can update subscriptions"
|
||||||
ON subscriptions FOR UPDATE
|
ON subscriptions FOR UPDATE
|
||||||
USING (is_super_admin());
|
USING (is_super_admin());
|
||||||
|
|
||||||
CREATE POLICY "Super admins can delete subscriptions"
|
CREATE POLICY "Super admins can delete subscriptions"
|
||||||
ON subscriptions FOR DELETE
|
ON subscriptions FOR DELETE
|
||||||
USING (is_super_admin());
|
USING (is_super_admin());
|
||||||
|
|
||||||
-- ============================================================================
|
-- ============================================================================
|
||||||
-- RLS POLICIES: REVENUECAT WEBHOOK EVENTS
|
-- RLS POLICIES: REVENUECAT WEBHOOK EVENTS
|
||||||
-- ============================================================================
|
-- ============================================================================
|
||||||
@@ -981,14 +889,12 @@ CREATE POLICY "Super admins can delete subscriptions"
|
|||||||
CREATE POLICY "Admin users can read own record"
|
CREATE POLICY "Admin users can read own record"
|
||||||
ON admin_users FOR SELECT
|
ON admin_users FOR SELECT
|
||||||
USING (auth.uid() = user_id);
|
USING (auth.uid() = user_id);
|
||||||
|
|
||||||
-- ============================================================================
|
-- ============================================================================
|
||||||
-- RLS POLICIES: AUDIT LOGS
|
-- RLS POLICIES: AUDIT LOGS
|
||||||
-- ============================================================================
|
-- ============================================================================
|
||||||
CREATE POLICY "Super admins can view audit logs"
|
CREATE POLICY "Super admins can view audit logs"
|
||||||
ON audit_logs FOR SELECT
|
ON audit_logs FOR SELECT
|
||||||
USING (is_super_admin());
|
USING (is_super_admin());
|
||||||
|
|
||||||
-- ============================================================================
|
-- ============================================================================
|
||||||
-- REALTIME SUBSCRIPTIONS
|
-- REALTIME SUBSCRIPTIONS
|
||||||
-- ============================================================================
|
-- ============================================================================
|
||||||
@@ -996,18 +902,15 @@ ALTER PUBLICATION supabase_realtime ADD TABLE profiles;
|
|||||||
ALTER PUBLICATION supabase_realtime ADD TABLE matches;
|
ALTER PUBLICATION supabase_realtime ADD TABLE matches;
|
||||||
ALTER PUBLICATION supabase_realtime ADD TABLE messages;
|
ALTER PUBLICATION supabase_realtime ADD TABLE messages;
|
||||||
ALTER PUBLICATION supabase_realtime ADD TABLE couple_packs;
|
ALTER PUBLICATION supabase_realtime ADD TABLE couple_packs;
|
||||||
|
|
||||||
-- ============================================================================
|
-- ============================================================================
|
||||||
-- STORAGE BUCKETS
|
-- STORAGE BUCKETS
|
||||||
-- ============================================================================
|
-- ============================================================================
|
||||||
INSERT INTO storage.buckets (id, name, public)
|
INSERT INTO storage.buckets (id, name, public)
|
||||||
VALUES ('feedback-screenshots', 'feedback-screenshots', false)
|
VALUES ('feedback-screenshots', 'feedback-screenshots', false)
|
||||||
ON CONFLICT (id) DO NOTHING;
|
ON CONFLICT (id) DO NOTHING;
|
||||||
|
|
||||||
INSERT INTO storage.buckets (id, name, public)
|
INSERT INTO storage.buckets (id, name, public)
|
||||||
VALUES ('chat-media', 'chat-media', false)
|
VALUES ('chat-media', 'chat-media', false)
|
||||||
ON CONFLICT (id) DO NOTHING;
|
ON CONFLICT (id) DO NOTHING;
|
||||||
|
|
||||||
-- ============================================================================
|
-- ============================================================================
|
||||||
-- STORAGE POLICIES
|
-- STORAGE POLICIES
|
||||||
-- ============================================================================
|
-- ============================================================================
|
||||||
@@ -1019,14 +922,12 @@ CREATE POLICY "Users can upload feedback screenshots"
|
|||||||
bucket_id = 'feedback-screenshots'
|
bucket_id = 'feedback-screenshots'
|
||||||
AND (storage.foldername(name))[1] = auth.uid()::text
|
AND (storage.foldername(name))[1] = auth.uid()::text
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE POLICY "Users can view own feedback screenshots"
|
CREATE POLICY "Users can view own feedback screenshots"
|
||||||
ON storage.objects FOR SELECT TO authenticated
|
ON storage.objects FOR SELECT TO authenticated
|
||||||
USING (
|
USING (
|
||||||
bucket_id = 'feedback-screenshots'
|
bucket_id = 'feedback-screenshots'
|
||||||
AND (storage.foldername(name))[1] = auth.uid()::text
|
AND (storage.foldername(name))[1] = auth.uid()::text
|
||||||
);
|
);
|
||||||
|
|
||||||
-- Chat media policies
|
-- Chat media policies
|
||||||
-- IMPORTANT: Use storage.objects.name (not just "name") to avoid ambiguity with profiles.name
|
-- IMPORTANT: Use storage.objects.name (not just "name") to avoid ambiguity with profiles.name
|
||||||
CREATE POLICY "Users can view chat media in their matches"
|
CREATE POLICY "Users can view chat media in their matches"
|
||||||
@@ -1040,7 +941,6 @@ CREATE POLICY "Users can view chat media in their matches"
|
|||||||
AND p.id = auth.uid()
|
AND p.id = auth.uid()
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE POLICY "Users can upload chat media to their matches"
|
CREATE POLICY "Users can upload chat media to their matches"
|
||||||
ON storage.objects FOR INSERT TO authenticated
|
ON storage.objects FOR INSERT TO authenticated
|
||||||
WITH CHECK (
|
WITH CHECK (
|
||||||
@@ -1052,7 +952,6 @@ CREATE POLICY "Users can upload chat media to their matches"
|
|||||||
AND p.id = auth.uid()
|
AND p.id = auth.uid()
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE POLICY "Users can delete their own chat media"
|
CREATE POLICY "Users can delete their own chat media"
|
||||||
ON storage.objects FOR DELETE TO authenticated
|
ON storage.objects FOR DELETE TO authenticated
|
||||||
USING (
|
USING (
|
||||||
@@ -1064,7 +963,6 @@ CREATE POLICY "Users can delete their own chat media"
|
|||||||
AND p.id = auth.uid()
|
AND p.id = auth.uid()
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
CREATE POLICY "Admins can view all chat media"
|
CREATE POLICY "Admins can view all chat media"
|
||||||
ON storage.objects FOR SELECT TO authenticated
|
ON storage.objects FOR SELECT TO authenticated
|
||||||
USING (
|
USING (
|
||||||
|
|||||||
Loaded 100 of 149 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user