11 Commits
Author SHA1 Message Date
jjbrunton ce68d57a49 Update comfort zone dialogs to explain gate logic 2026-01-17 17:21:04 +00:00
jjbrunton b8c271cce1 Add show all intensities toggle on packs page 2026-01-17 17:12:10 +00:00
jjbruntonandClaude Opus 4.5 a0049b7f8a Add couple comfort zone gate toggle and bump version
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-17 16:59:02 +00:00
jjbrunton 494ed17fe2 Add streaks, scheduled releases, and notification settings
- Couple streaks with milestone notifications
- Scheduled pack releases with admin UI
- Notification preferences screen
- Refactored profile settings screens
- Partner activity notifications
2026-01-17 16:38:47 +00:00
jjbrunton 12f312421b Fix Firebase Analytics setup and disable ad ID 2026-01-16 23:36:52 +00:00
jjbrunton a558f5e67e Use RPC function for gift premium to fix RLS issues 2026-01-16 18:41:21 +00:00
jjbrunton 0be833bba2 Add gift_premium permission for admin users 2026-01-16 18:31:47 +00:00
jjbrunton 1911f8f058 Switch analytics from PostHog to Firebase 2026-01-16 15:38:17 +00:00
jjbrunton 23e24aa4c2 Fix admin user detail page not showing email 2026-01-16 14:57:40 +00:00
jjbruntonandClaude Opus 4.5 e671ae9d1c Add admin user details, pack notifications, remove crypto
- Add enabled packs and feature interests to admin user page
- Add clickable links from responses to packs in admin
- Add pack change notification edge function
- Remove react-native-quick-crypto (unused E2EE)
- Bump mobile version to 1.0.1 (versionCode 22)
- Add EAS config and preview build scripts
- Gitignore keystore and APK files

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 19:03:34 +00:00
jjbrunton a7a13c9614 Add release notes and fix answer gap deadlock
- Add mobile release notes for v1.0.1
- Fix answer gap deadlock with NET gap calculation
- Randomize question order for variety
- Dismiss keyboard when match modal appears
- Add keyboard dismiss on chat scroll
- Pin EAS build node version
2026-01-11 15:10:14 +00:00
122 changed files with 10550 additions and 2647 deletions

No files matched your search

+1 -1
View File
@@ -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">
+2
View File
@@ -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' },
+1
View File
@@ -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;
} }
+16
View File
@@ -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">
+40 -1
View File
@@ -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" />
+238 -16
View File
@@ -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,7 +537,7 @@ 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>
<p className="text-muted-foreground mb-4">{profile.email || 'No email'}</p> <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">
@@ -456,20 +547,98 @@ export function UserDetailPage() {
</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 +647,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>
); );
+4 -1
View File
@@ -5,4 +5,7 @@
expo-env.d.ts expo-env.d.ts
# @end expo-cli # @end expo-cli
*.aab *.aab
*.ipa *.ipa
*.apk
*.jks
*.keystore
+1 -1
View File
@@ -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>
+2 -2
View File
@@ -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 31
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
View File
@@ -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
+1 -1
View File
@@ -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")
} }
} }
+1
View File
@@ -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
View File
@@ -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": "30",
"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": 31,
"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
+30 -4
View File
@@ -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") {
@@ -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 */}
+56 -2
View File
@@ -1,4 +1,4 @@
import { View, Text, StyleSheet, RefreshControl, Platform, Alert, Pressable } from "react-native"; import { View, Text, StyleSheet, RefreshControl, Platform, Alert, Pressable, Switch } from "react-native";
import { usePacksStore, useAuthStore, useSubscriptionStore } from "../../../src/store"; import { usePacksStore, useAuthStore, useSubscriptionStore } from "../../../src/store";
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import { router } from "expo-router"; import { router } from "expo-router";
@@ -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,32 @@ 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>
<Switch
value={showAllIntensities}
onValueChange={handleToggleComfortZone}
trackColor={{
false: "rgba(255, 255, 255, 0.1)",
true: "rgba(233, 69, 96, 0.4)"
}}
thumbColor={showAllIntensities ? colors.primary : colors.textTertiary}
ios_backgroundColor="rgba(255, 255, 255, 0.1)"
/>
</Pressable>
</Animated.View> </Animated.View>
{/* Categories */} {/* Categories */}
@@ -610,6 +640,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 { SubscriptionScreen } from "../../../src/features/profile/screens";
export default SubscriptionScreen;
@@ -0,0 +1,3 @@
import { SupportScreen } from "../../../src/features/profile/screens";
export default SupportScreen;
+11 -6
View File
@@ -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 {
@@ -55,6 +50,16 @@ export default function RootLayout() {
const { fetchUser, setUser } = useAuthStore(); const { fetchUser, setUser } = useAuthStore();
const pathname = usePathname(); const pathname = usePathname();
const appState = useRef(AppState.currentState); const appState = useRef(AppState.currentState);
const analyticsInitialized = useRef(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(() => {
+3
View File
@@ -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",
+6
View File
@@ -0,0 +1,6 @@
{
"react-native": {
"analytics_auto_collection_enabled": true,
"google_analytics_adid_collection_enabled": false
}
}
+8
View File
@@ -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
View File
@@ -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
+1
View File
@@ -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"
+89 -49
View File
@@ -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 = (
+4
View File
@@ -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>
+2 -2
View File
@@ -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>30</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>
+1 -3
View File
@@ -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;
+5 -3
View File
@@ -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",
+20
View File
@@ -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;
+36
View File
@@ -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.
@@ -162,8 +162,8 @@ 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.');
}); });
@@ -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);
@@ -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;
+45 -31
View File
@@ -42,7 +42,11 @@ const FEATURES = [
}, },
]; ];
export function Paywall({ visible, onClose, onSuccess }: PaywallProps) { export function Paywall({ visible, onClose, onSuccess }: PaywallProps) {
const handleClose = (reason: "dismiss" | "success" | "restore" | "system") => {
Events.paywallClosed("paywall", reason);
onClose();
};
const { const {
offerings, offerings,
isLoadingOfferings, isLoadingOfferings,
@@ -76,26 +80,36 @@ export function Paywall({ visible, onClose, onSuccess }: PaywallProps) {
} }
}, [offerings]); }, [offerings]);
const handlePurchase = async () => { const handlePurchase = async () => {
if (!selectedPackage) return; if (!selectedPackage) return;
const packageType = selectedPackage.packageType || "unknown";
Events.purchaseInitiated(packageType);
const result = await purchasePackage(selectedPackage);
if (result.success) {
Events.purchaseCompleted(packageType);
onSuccess?.();
handleClose("success");
return;
}
if (result.cancelled) {
Events.purchaseCancelled(packageType, result.errorCode);
return;
}
Events.purchaseFailed(packageType, result.errorCode, result.errorMessage);
};
Events.purchaseInitiated(selectedPackage.packageType || "unknown"); const handleRestore = async () => {
const success = await purchasePackage(selectedPackage); const restored = await restorePurchases();
if (success) { if (restored) {
Events.purchaseCompleted(selectedPackage.packageType || "unknown"); Events.purchaseRestored();
onSuccess?.(); onSuccess?.();
onClose(); handleClose("restore");
} }
}; };
const handleRestore = async () => {
const restored = await restorePurchases();
if (restored) {
Events.purchaseRestored();
onSuccess?.();
onClose();
}
};
const formatPrice = (pkg: PurchasesPackage) => { const formatPrice = (pkg: PurchasesPackage) => {
const price = pkg.product.priceString; const price = pkg.product.priceString;
@@ -129,12 +143,12 @@ export function Paywall({ visible, onClose, onSuccess }: PaywallProps) {
return ( return (
<Modal <Modal
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}>
{/* Premium gradient background */} {/* Premium gradient background */}
@@ -155,11 +169,11 @@ export function Paywall({ visible, onClose, onSuccess }: PaywallProps) {
contentContainerStyle={styles.scrollContent} contentContainerStyle={styles.scrollContent}
> >
{/* Close Button */} {/* Close Button */}
<TouchableOpacity <TouchableOpacity
style={styles.closeButton} style={styles.closeButton}
onPress={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} />
</TouchableOpacity> </TouchableOpacity>
@@ -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>
</> </>
@@ -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}
+172 -221
View File
@@ -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 } = 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,111 @@ 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}>{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}>
<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 +197,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 +207,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 +230,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 +242,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 +256,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 +265,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 +302,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;
@@ -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';
@@ -0,0 +1,60 @@
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, SettingsSection, MenuItem, DangerZone } from '../components';
/**
* Account settings sub-screen (sign out, delete account).
*/
export function AccountScreen() {
const { couple } = useAuthStore();
const { handleSignOut, handleDeleteAccount } = useCoupleManagement();
return (
<GradientBackground>
<ScreenHeader title="Account" />
<ScrollView
style={styles.container}
contentContainerStyle={styles.content}
showsVerticalScrollIndicator={false}
>
<SettingsSection title="Account" delay={0}>
<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,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,8 @@
// 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';
+166 -69
View File
@@ -1,7 +1,77 @@
import PostHog from "posthog-react-native"; import { Platform } from "react-native";
import Constants from "expo-constants";
let posthog: PostHog | null = null; // Import default export to ensure namespace registration runs
let isInitialized = false; 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 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);
} }
} }
@@ -42,31 +101,41 @@ export function logEvent(
params?: Record<string, string | number | boolean> params?: Record<string, string | number | boolean>
) { ) {
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();
} catch (error) { if (analytics) {
console.warn("[Analytics] Failed to log event:", error); firebaseLogEvent(analytics, name, params);
} }
} catch (error) {
console.warn("[Analytics] Failed to log event:", error);
}
} }
// Set user ID for tracking // Set user ID for tracking
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();
} catch (error) { if (analytics) {
console.warn("[Analytics] Failed to set user ID:", error); firebaseSetUserId(analytics, userId);
} }
} catch (error) {
console.warn("[Analytics] Failed to set user ID:", error);
}
} }
// Clear user ID on logout // Clear user ID on logout
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();
} catch (error) { if (analytics) {
console.warn("[Analytics] Failed to clear user ID:", error); firebaseSetUserId(analytics, null);
} resetAnalyticsData(analytics);
}
} catch (error) {
console.warn("[Analytics] Failed to clear user ID:", error);
}
} }
// Set user properties // Set user properties
@@ -74,41 +143,56 @@ export function setUserProperties(
properties: Record<string, string | null> properties: Record<string, string | null>
) { ) {
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); }
}
} }
// Log screen view // Log screen view
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);
} }
} }
// 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;
// Pre-defined event helpers for common actions return value.slice(0, maxLength - 1) + "…";
export const Events = { }
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
export const Events = {
// Auth events // Auth events
signUp: (method: string) => logEvent("sign_up", { method }), signUp: (method: string) => logEvent("sign_up", { method }),
signIn: (method: string) => logEvent("login", { method }), signIn: (method: string) => logEvent("login", { method }),
@@ -138,13 +222,26 @@ export const Events = {
messageSent: () => logEvent("message_sent"), messageSent: () => logEvent("message_sent"),
mediaUploaded: () => logEvent("chat_media_uploaded"), mediaUploaded: () => logEvent("chat_media_uploaded"),
// Subscription events // Subscription events
paywallShown: (source: string) => logEvent("paywall_shown", { source }), paywallShown: (source: string) => logEvent("paywall_shown", { source }),
purchaseInitiated: (packageType: string) => paywallClosed: (source: string, reason: string) =>
logEvent("purchase_initiated", { package_type: packageType }), logEvent("paywall_closed", { source, reason }),
purchaseCompleted: (packageType: string) => purchaseInitiated: (packageType: string) =>
logEvent("purchase", { package_type: packageType }), logEvent("purchase_initiated", { package_type: packageType }),
purchaseRestored: () => logEvent("purchase_restored"), 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) =>
logEvent("purchase", { package_type: packageType }),
purchaseRestored: () => logEvent("purchase_restored"),
// Pack events // Pack events
packEnabled: (packId: string) => logEvent("pack_enabled", { pack_id: packId }), packEnabled: (packId: string) => logEvent("pack_enabled", { pack_id: packId }),
+57
View File
@@ -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);
}
-32
View File
@@ -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');
}
}
+40 -4
View File
@@ -17,6 +17,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 +26,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 };
}; };
@@ -56,11 +59,13 @@ export const useAuthStore = create<AuthState>((set, get) => ({
// 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, 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;
@@ -145,11 +150,17 @@ export const useAuthStore = create<AuthState>((set, get) => ({
isLoading: 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 {
@@ -169,11 +180,36 @@ export const useAuthStore = create<AuthState>((set, get) => ({
}); });
// 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);
} }
}, },
})); }));
+4
View File
@@ -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 });
},
}));
+110 -100
View File
@@ -1,7 +1,7 @@
import { create } from "zustand"; import { create } from "zustand";
import { supabase } from "../lib/supabase"; import { supabase } from "../lib/supabase";
import { Events } from "../lib/analytics"; import { Events } from "../lib/analytics";
import type { QuestionPack, Category } from "@/types"; import type { QuestionPack, Category } from "@/types";
import { useAuthStore } from "./authStore"; import { useAuthStore } from "./authStore";
const DEFAULT_MAX_INTENSITY = 2; const DEFAULT_MAX_INTENSITY = 2;
@@ -21,30 +21,34 @@ const getUserMaxIntensity = () => {
}; };
interface PacksState { interface PacksState {
packs: QuestionPack[]; packs: QuestionPack[];
categories: Category[]; categories: Category[];
enabledPackIds: string[]; enabledPackIds: string[];
isLoading: boolean; isLoading: boolean;
fetchPacks: () => Promise<void>; showAllIntensities: boolean;
fetchEnabledPacks: () => Promise<void>; fetchPacks: () => Promise<void>;
ensureEnabledPacksLoaded: () => Promise<void>; fetchEnabledPacks: () => Promise<void>;
togglePack: (packId: string) => Promise<{ success: boolean; reason?: string }>; ensureEnabledPacksLoaded: () => Promise<void>;
clearPacks: () => void; togglePack: (packId: string) => Promise<{ success: boolean; reason?: string }>;
} setShowAllIntensities: (value: boolean) => void;
clearPacks: () => void;
export const usePacksStore = create<PacksState>((set, get) => ({ }
packs: [],
categories: [], export const usePacksStore = create<PacksState>((set, get) => ({
enabledPackIds: [], packs: [],
isLoading: false, categories: [],
enabledPackIds: [],
fetchPacks: async () => { isLoading: false,
set({ isLoading: true }); showAllIntensities: false,
fetchPacks: async () => {
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
const { data: categories } = await supabase const { data: categories } = await supabase
.from("categories") .from("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}`);
} }
@@ -86,76 +90,82 @@ export const usePacksStore = create<PacksState>((set, get) => ({
isLoading: false isLoading: false
}); });
}, },
fetchEnabledPacks: async () => { fetchEnabledPacks: async () => {
const coupleId = useAuthStore.getState().user?.couple_id; const coupleId = useAuthStore.getState().user?.couple_id;
if (!coupleId) { if (!coupleId) {
set({ enabledPackIds: [] }); set({ enabledPackIds: [] });
return; return;
} }
const { data: couplePacks } = await supabase const { data: couplePacks } = await supabase
.from("couple_packs") .from("couple_packs")
.select("pack_id") .select("pack_id")
.eq("couple_id", coupleId) .eq("couple_id", coupleId)
.eq("enabled", true); .eq("enabled", true);
const ids = couplePacks?.map(cp => cp.pack_id) || []; const ids = couplePacks?.map(cp => cp.pack_id) || [];
set({ enabledPackIds: ids }); set({ enabledPackIds: ids });
}, },
ensureEnabledPacksLoaded: async () => { ensureEnabledPacksLoaded: async () => {
// Only fetch if not already loaded - lightweight check for swipe screen // Only fetch if not already loaded - lightweight check for swipe screen
const coupleId = useAuthStore.getState().user?.couple_id; const coupleId = useAuthStore.getState().user?.couple_id;
if (!coupleId) return; if (!coupleId) return;
if (get().enabledPackIds.length === 0) { if (get().enabledPackIds.length === 0) {
await get().fetchEnabledPacks(); await get().fetchEnabledPacks();
} }
}, },
togglePack: async (packId: string): Promise<{ success: boolean; reason?: string }> => { togglePack: async (packId: string): Promise<{ success: boolean; reason?: string }> => {
const coupleId = useAuthStore.getState().user?.couple_id; const coupleId = useAuthStore.getState().user?.couple_id;
if (!coupleId) { if (!coupleId) {
return { success: false, reason: "no_couple" }; return { success: false, reason: "no_couple" };
} }
const isEnabled = get().enabledPackIds.includes(packId); const isEnabled = get().enabledPackIds.includes(packId);
const newValue = !isEnabled; const newValue = !isEnabled;
// Optimistic update // Optimistic update
const newIds = newValue const newIds = newValue
? [...get().enabledPackIds, packId] ? [...get().enabledPackIds, packId]
: get().enabledPackIds.filter(id => id !== packId); : get().enabledPackIds.filter(id => id !== packId);
set({ enabledPackIds: newIds }); set({ enabledPackIds: newIds });
const { error } = await supabase const { error } = await supabase
.from("couple_packs") .from("couple_packs")
.upsert({ .upsert({
couple_id: coupleId, couple_id: coupleId,
pack_id: packId, pack_id: packId,
enabled: newValue enabled: newValue
}); });
if (error) { if (error) {
console.error("Error toggling pack:", error); console.error("Error toggling pack:", error);
// Revert by refetching // Revert by refetching
get().fetchEnabledPacks(); get().fetchEnabledPacks();
return { success: false, reason: "error" }; return { success: false, reason: "error" };
} }
// Track pack enable/disable // Track pack enable/disable
if (newValue) { if (newValue) {
Events.packEnabled(packId); Events.packEnabled(packId);
} else { } else {
Events.packDisabled(packId); Events.packDisabled(packId);
} }
return { success: true }; return { success: true };
}, },
clearPacks: () => { setShowAllIntensities: (value: boolean) => {
set({ enabledPackIds: [] }); set({ showAllIntensities: value });
}, // Refetch packs with new filter setting
})); get().fetchPacks();
},
clearPacks: () => {
set({ enabledPackIds: [] });
},
}));
+60
View File
@@ -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 });
},
}));
+52 -35
View File
@@ -7,21 +7,28 @@ import revenueCatService, {
} from "../lib/revenuecat"; } from "../lib/revenuecat";
import { useAuthStore } from "./authStore"; import { useAuthStore } from "./authStore";
interface SubscriptionStoreState { export type PurchaseResult = {
subscription: SubscriptionState; success: boolean;
offerings: PurchasesOffering | null; cancelled?: boolean;
isLoadingOfferings: boolean; errorCode?: unknown;
isPurchasing: boolean; errorMessage?: unknown;
error: string | null; };
// Actions interface SubscriptionStoreState {
initializeRevenueCat: (userId: string) => Promise<void>; subscription: SubscriptionState;
fetchOfferings: () => Promise<void>; offerings: PurchasesOffering | null;
purchasePackage: (pkg: PurchasesPackage) => Promise<boolean>; isLoadingOfferings: boolean;
restorePurchases: () => Promise<boolean>; isPurchasing: boolean;
refreshSubscriptionStatus: () => Promise<void>; error: string | null;
clearSubscription: () => void;
} // Actions
initializeRevenueCat: (userId: string) => Promise<void>;
fetchOfferings: () => Promise<void>;
purchasePackage: (pkg: PurchasesPackage) => Promise<PurchaseResult>;
restorePurchases: () => Promise<boolean>;
refreshSubscriptionStatus: () => Promise<void>;
clearSubscription: () => void;
}
const defaultSubscription: SubscriptionState = { const defaultSubscription: SubscriptionState = {
isProUser: false, isProUser: false,
@@ -121,26 +128,36 @@ export const useSubscriptionStore = create<SubscriptionStoreState>((set, get) =>
} }
}, },
purchasePackage: async (pkg: PurchasesPackage) => { purchasePackage: async (pkg: PurchasesPackage) => {
set({ isPurchasing: true, error: null }); set({ isPurchasing: true, error: null });
try { try {
const customerInfo = await revenueCatService.purchasePackage(pkg); const customerInfo = await revenueCatService.purchasePackage(pkg);
const subscription = revenueCatService.parseCustomerInfo(customerInfo); const subscription = revenueCatService.parseCustomerInfo(customerInfo);
set({ subscription, isPurchasing: false }); set({ subscription, isPurchasing: false });
// 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";
set({ error: message, isPurchasing: false });
return false; const message = isCancelled
} ? "Purchase was cancelled"
}, : "Purchase failed. Please try again.";
set({ error: message, isPurchasing: false });
return {
success: false,
cancelled: isCancelled,
errorCode: error?.code,
errorMessage: error?.message,
};
}
},
restorePurchases: async () => { restorePurchases: async () => {
set({ isPurchasing: true, error: null }); set({ isPurchasing: true, error: null });
+11 -12
View File
@@ -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,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" } }
);
}
});
@@ -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 (
@@ -4,10 +4,9 @@
-- Fix: Explicitly qualify as storage.objects.name to reference the storage object's file path -- Fix: Explicitly qualify as storage.objects.name to reference the storage object's file path
-- Drop existing broken policies -- Drop existing broken policies
DROP POLICY IF EXISTS "Users can upload chat media to their matches" ON storage.objects; DROP POLICY IF EXISTS "Users can upload chat media to their matches" ON storage.objects;
DROP POLICY IF EXISTS "Users can view chat media in their matches" ON storage.objects; DROP POLICY IF EXISTS "Users can view chat media in their matches" ON storage.objects;
DROP POLICY IF EXISTS "Users can delete their own chat media" ON storage.objects; DROP POLICY IF EXISTS "Users can delete their own chat media" ON storage.objects;
-- Recreate with explicit table qualification to avoid column name ambiguity -- Recreate with explicit table qualification to avoid column name ambiguity
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
@@ -19,8 +18,7 @@ CREATE POLICY "Users can upload chat media to their matches"
WHERE (storage.foldername(storage.objects.name))[1] = m.id::text WHERE (storage.foldername(storage.objects.name))[1] = m.id::text
AND p.id = auth.uid() AND p.id = auth.uid()
) )
); );
CREATE POLICY "Users can view chat media in their matches" CREATE POLICY "Users can view chat media in their matches"
ON storage.objects FOR SELECT TO authenticated ON storage.objects FOR SELECT TO authenticated
USING ( USING (
@@ -31,8 +29,7 @@ CREATE POLICY "Users can view chat media in their matches"
WHERE (storage.foldername(storage.objects.name))[1] = m.id::text WHERE (storage.foldername(storage.objects.name))[1] = m.id::text
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 (
@@ -43,4 +40,4 @@ CREATE POLICY "Users can delete their own chat media"
WHERE (storage.foldername(storage.objects.name))[1] = m.id::text WHERE (storage.foldername(storage.objects.name))[1] = m.id::text
AND p.id = auth.uid() AND p.id = auth.uid()
) )
); );
@@ -22,17 +22,13 @@ CREATE TABLE IF NOT EXISTS ai_config (
updated_at timestamptz DEFAULT now(), updated_at timestamptz DEFAULT now(),
updated_by uuid REFERENCES auth.users(id) updated_by uuid REFERENCES auth.users(id)
); );
-- Only allow one row in this table (singleton pattern) -- Only allow one row in this table (singleton pattern)
CREATE UNIQUE INDEX IF NOT EXISTS ai_config_singleton ON ai_config ((true)); CREATE UNIQUE INDEX IF NOT EXISTS ai_config_singleton ON ai_config ((true));
-- Insert default config row -- Insert default config row
INSERT INTO ai_config (id) VALUES (gen_random_uuid()) INSERT INTO ai_config (id) VALUES (gen_random_uuid())
ON CONFLICT DO NOTHING; ON CONFLICT DO NOTHING;
-- Enable RLS -- Enable RLS
ALTER TABLE ai_config ENABLE ROW LEVEL SECURITY; ALTER TABLE ai_config ENABLE ROW LEVEL SECURITY;
-- Only super admins can read/write (checked via admin_users table) -- Only super admins can read/write (checked via admin_users table)
CREATE POLICY "Super admins can read ai_config" CREATE POLICY "Super admins can read ai_config"
ON ai_config FOR SELECT ON ai_config FOR SELECT
@@ -43,7 +39,6 @@ CREATE POLICY "Super admins can read ai_config"
AND admin_users.role = 'super_admin' AND admin_users.role = 'super_admin'
) )
); );
CREATE POLICY "Super admins can update ai_config" CREATE POLICY "Super admins can update ai_config"
ON ai_config FOR UPDATE ON ai_config FOR UPDATE
USING ( USING (
@@ -53,7 +48,6 @@ CREATE POLICY "Super admins can update ai_config"
AND admin_users.role = 'super_admin' AND admin_users.role = 'super_admin'
) )
); );
-- Create function to update the updated_at timestamp -- Create function to update the updated_at timestamp
CREATE OR REPLACE FUNCTION update_ai_config_timestamp() CREATE OR REPLACE FUNCTION update_ai_config_timestamp()
RETURNS TRIGGER AS $$ RETURNS TRIGGER AS $$
@@ -63,13 +57,11 @@ BEGIN
RETURN NEW; RETURN NEW;
END; END;
$$ LANGUAGE plpgsql SECURITY DEFINER; $$ LANGUAGE plpgsql SECURITY DEFINER;
-- Create trigger to auto-update timestamp -- Create trigger to auto-update timestamp
DROP TRIGGER IF EXISTS ai_config_updated_at ON ai_config; DROP TRIGGER IF EXISTS ai_config_updated_at ON ai_config;
CREATE TRIGGER ai_config_updated_at CREATE TRIGGER ai_config_updated_at
BEFORE UPDATE ON ai_config BEFORE UPDATE ON ai_config
FOR EACH ROW FOR EACH ROW
EXECUTE FUNCTION update_ai_config_timestamp(); EXECUTE FUNCTION update_ai_config_timestamp();
-- Add comment for documentation -- Add comment for documentation
COMMENT ON TABLE ai_config IS 'Singleton table storing AI configuration for admin portal. Only super admins can access.'; COMMENT ON TABLE ai_config IS 'Singleton table storing AI configuration for admin portal. Only super admins can access.';
@@ -5,17 +5,14 @@
-- Add the new column (migrate existing single generator model to array format) -- Add the new column (migrate existing single generator model to array format)
ALTER TABLE ai_config ALTER TABLE ai_config
ADD COLUMN IF NOT EXISTS council_generators jsonb; ADD COLUMN IF NOT EXISTS council_generators jsonb;
-- Set default value based on existing council_generator_model or default -- Set default value based on existing council_generator_model or default
UPDATE ai_config UPDATE ai_config
SET council_generators = jsonb_build_array( SET council_generators = jsonb_build_array(
jsonb_build_object('model', COALESCE(council_generator_model, 'anthropic/claude-3.5-sonnet')) jsonb_build_object('model', COALESCE(council_generator_model, 'anthropic/claude-3.5-sonnet'))
) )
WHERE council_generators IS NULL; WHERE council_generators IS NULL;
-- Set the default for new rows -- Set the default for new rows
ALTER TABLE ai_config ALTER TABLE ai_config
ALTER COLUMN council_generators SET DEFAULT '[{"model": "anthropic/claude-3.5-sonnet"}]'::jsonb; ALTER COLUMN council_generators SET DEFAULT '[{"model": "anthropic/claude-3.5-sonnet"}]'::jsonb;
-- Add comment for documentation -- Add comment for documentation
COMMENT ON COLUMN ai_config.council_generators IS 'Array of generator configurations. Each object has a "model" field. Default is one generator with Claude 3.5 Sonnet.'; COMMENT ON COLUMN ai_config.council_generators IS 'Array of generator configurations. Each object has a "model" field. Default is one generator with Claude 3.5 Sonnet.';
@@ -12,37 +12,29 @@ CREATE TABLE IF NOT EXISTS app_config (
updated_at timestamptz DEFAULT now(), updated_at timestamptz DEFAULT now(),
updated_by uuid REFERENCES auth.users(id) updated_by uuid REFERENCES auth.users(id)
); );
-- Only allow one row in this table (singleton pattern) -- Only allow one row in this table (singleton pattern)
CREATE UNIQUE INDEX IF NOT EXISTS app_config_singleton ON app_config ((true)); CREATE UNIQUE INDEX IF NOT EXISTS app_config_singleton ON app_config ((true));
-- Insert default config row -- Insert default config row
INSERT INTO app_config (id) VALUES (gen_random_uuid()) INSERT INTO app_config (id) VALUES (gen_random_uuid())
ON CONFLICT DO NOTHING; ON CONFLICT DO NOTHING;
-- Enable RLS -- Enable RLS
ALTER TABLE app_config ENABLE ROW LEVEL SECURITY; ALTER TABLE app_config ENABLE ROW LEVEL SECURITY;
-- All authenticated users can read (needed for mobile app) -- All authenticated users can read (needed for mobile app)
CREATE POLICY "Authenticated users can read app_config" CREATE POLICY "Authenticated users can read app_config"
ON app_config FOR SELECT ON app_config FOR SELECT
USING (auth.role() = 'authenticated'); USING (auth.role() = 'authenticated');
-- Only super admins can update -- Only super admins can update
CREATE POLICY "Super admins can update app_config" CREATE POLICY "Super admins can update app_config"
ON app_config FOR UPDATE ON app_config FOR UPDATE
USING (is_super_admin()); USING (is_super_admin());
-- Create trigger to auto-update timestamp (reuses existing function) -- Create trigger to auto-update timestamp (reuses existing function)
CREATE TRIGGER app_config_updated_at CREATE TRIGGER app_config_updated_at
BEFORE UPDATE ON app_config BEFORE UPDATE ON app_config
FOR EACH ROW FOR EACH ROW
EXECUTE FUNCTION update_updated_at(); EXECUTE FUNCTION update_updated_at();
-- Add comment for documentation -- Add comment for documentation
COMMENT ON TABLE app_config IS 'Singleton table storing mobile app configuration. Readable by all authenticated users, writable by super admins only.'; COMMENT ON TABLE app_config IS 'Singleton table storing mobile app configuration. Readable by all authenticated users, writable by super admins only.';
COMMENT ON COLUMN app_config.answer_gap_threshold IS 'Maximum number of questions a user can answer ahead of their partner. Set to 0 to disable answer spam prevention.'; COMMENT ON COLUMN app_config.answer_gap_threshold IS 'Maximum number of questions a user can answer ahead of their partner. Set to 0 to disable answer spam prevention.';
-- ============================================================================ -- ============================================================================
-- RPC FUNCTION: get_answer_gap_status -- RPC FUNCTION: get_answer_gap_status
-- Returns the number of questions user has answered that partner hasn't, -- Returns the number of questions user has answered that partner hasn't,
@@ -131,5 +123,4 @@ BEGIN
RETURN QUERY SELECT v_gap_count, v_threshold, (v_gap_count >= v_threshold); RETURN QUERY SELECT v_gap_count, v_threshold, (v_gap_count >= v_threshold);
END; END;
$$; $$;
COMMENT ON FUNCTION get_answer_gap_status() IS 'Returns answer gap status for the current user. Used by mobile app to prevent answer spam.'; COMMENT ON FUNCTION get_answer_gap_status() IS 'Returns answer gap status for the current user. Used by mobile app to prevent answer spam.';
@@ -163,5 +163,4 @@ BEGIN
); );
END; END;
$$; $$;
COMMENT ON FUNCTION get_recommended_questions(UUID) IS 'Returns recommended questions for user. When user is too far ahead of partner (answer gap threshold exceeded), returns empty. Old apps see "All caught up!", new apps can call get_answer_gap_status to determine blocking reason.'; COMMENT ON FUNCTION get_recommended_questions(UUID) IS 'Returns recommended questions for user. When user is too far ahead of partner (answer gap threshold exceeded), returns empty. Old apps see "All caught up!", new apps can call get_answer_gap_status to determine blocking reason.';
@@ -2,20 +2,15 @@
-- Videos expire 30 days after being viewed by the recipient -- Videos expire 30 days after being viewed by the recipient
-- Add media type to distinguish images from videos -- Add media type to distinguish images from videos
ALTER TABLE messages ADD COLUMN media_type TEXT CHECK (media_type IN ('image', 'video')); ALTER TABLE messages ADD COLUMN media_type TEXT CHECK (media_type IN ('image', 'video'));
-- Track when media expires (set when video is viewed: media_viewed_at + 30 days) -- Track when media expires (set when video is viewed: media_viewed_at + 30 days)
ALTER TABLE messages ADD COLUMN media_expires_at TIMESTAMPTZ; ALTER TABLE messages ADD COLUMN media_expires_at TIMESTAMPTZ;
-- Track if media was deleted due to expiration -- Track if media was deleted due to expiration
ALTER TABLE messages ADD COLUMN media_expired BOOLEAN DEFAULT false; ALTER TABLE messages ADD COLUMN media_expired BOOLEAN DEFAULT false;
-- Backfill existing media as images -- Backfill existing media as images
UPDATE messages SET media_type = 'image' WHERE media_path IS NOT NULL; UPDATE messages SET media_type = 'image' WHERE media_path IS NOT NULL;
-- Enable pg_cron extension for scheduled cleanup -- Enable pg_cron extension for scheduled cleanup
CREATE EXTENSION IF NOT EXISTS pg_cron WITH SCHEMA extensions; CREATE EXTENSION IF NOT EXISTS pg_cron WITH SCHEMA extensions;
-- Create cleanup function that handles both DB updates and storage deletion tracking -- Create cleanup function that handles both DB updates and storage deletion tracking
CREATE OR REPLACE FUNCTION cleanup_expired_videos() CREATE OR REPLACE FUNCTION cleanup_expired_videos()
RETURNS TABLE(deleted_count int, deleted_paths text[]) AS $$ RETURNS TABLE(deleted_count int, deleted_paths text[]) AS $$
@@ -48,12 +43,11 @@ BEGIN
RETURN QUERY SELECT count, COALESCE(expired_paths, ARRAY[]::text[]); RETURN QUERY SELECT count, COALESCE(expired_paths, ARRAY[]::text[]);
END; END;
$$ LANGUAGE plpgsql SECURITY DEFINER; $$ LANGUAGE plpgsql SECURITY DEFINER;
-- Schedule daily cleanup at 3 AM UTC -- Schedule daily cleanup at 3 AM UTC
-- The database function marks videos as expired, edge function handles storage deletion -- The database function marks videos as expired, edge function handles storage deletion
SELECT cron.schedule( SELECT cron.schedule(
'cleanup-expired-videos', 'cleanup-expired-videos',
'0 3 * * *', '0 3 * * *',
'SELECT * FROM cleanup_expired_videos()' 'SELECT * FROM cleanup_expired_videos()'
); );
@@ -30,29 +30,24 @@ BEGIN
RETURN v_permissions @> jsonb_build_array(check_permission); RETURN v_permissions @> jsonb_build_array(check_permission);
END; END;
$$; $$;
-- Update RLS policies to use permissions -- Update RLS policies to use permissions
-- Profiles -- Profiles
CREATE POLICY "Admins with view_users permission can view all profiles" CREATE POLICY "Admins with view_users permission can view all profiles"
ON profiles FOR SELECT ON profiles FOR SELECT
USING (has_permission('view_users')); USING (has_permission('view_users'));
-- Messages -- Messages
CREATE POLICY "Admins with view_chats permission can view all messages" CREATE POLICY "Admins with view_chats permission can view all messages"
ON messages FOR SELECT ON messages FOR SELECT
USING (has_permission('view_chats')); USING (has_permission('view_chats'));
-- Responses -- Responses
CREATE POLICY "Admins with view_responses permission can view all responses" CREATE POLICY "Admins with view_responses permission can view all responses"
ON responses FOR SELECT ON responses FOR SELECT
USING (has_permission('view_responses')); USING (has_permission('view_responses'));
-- Matches -- Matches
CREATE POLICY "Admins with view_matches permission can view all matches" CREATE POLICY "Admins with view_matches permission can view all matches"
ON matches FOR SELECT ON matches FOR SELECT
USING (has_permission('view_matches')); USING (has_permission('view_matches'));
-- Audit Logs -- Audit Logs
CREATE POLICY "Admins with view_audit_logs permission can view audit logs" CREATE POLICY "Admins with view_audit_logs permission can view audit logs"
ON audit_logs FOR SELECT ON audit_logs FOR SELECT
@@ -3,7 +3,6 @@
CREATE POLICY "Admins with manage_feedback permission can view all feedback" CREATE POLICY "Admins with manage_feedback permission can view all feedback"
ON feedback FOR SELECT ON feedback FOR SELECT
USING (has_permission('manage_feedback')); USING (has_permission('manage_feedback'));
CREATE POLICY "Admins with manage_feedback permission can update all feedback" CREATE POLICY "Admins with manage_feedback permission can update all feedback"
ON feedback FOR UPDATE ON feedback FOR UPDATE
USING (has_permission('manage_feedback')); USING (has_permission('manage_feedback'));
@@ -1,17 +1,138 @@
-- Add RLS policies for redemption codes -- Redemption codes table
CREATE TABLE redemption_codes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
code TEXT NOT NULL UNIQUE,
description TEXT,
max_uses INTEGER DEFAULT 1,
current_uses INTEGER DEFAULT 0,
expires_at TIMESTAMPTZ,
is_active BOOLEAN DEFAULT true,
created_by UUID REFERENCES auth.users(id),
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now()
);
-- Code redemptions table
CREATE TABLE code_redemptions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
code_id UUID NOT NULL REFERENCES redemption_codes(id),
user_id UUID NOT NULL REFERENCES auth.users(id),
redeemed_at TIMESTAMPTZ DEFAULT now(),
UNIQUE(code_id, user_id)
);
ALTER TABLE redemption_codes ENABLE ROW LEVEL SECURITY;
ALTER TABLE code_redemptions ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Admins with manage_codes permission can view redemption codes" CREATE POLICY "Admins with manage_codes permission can view redemption codes"
ON redemption_codes FOR SELECT ON redemption_codes FOR SELECT
USING (has_permission('manage_codes')); USING (has_permission('manage_codes'::text));
CREATE POLICY "Admins with manage_codes permission can insert redemption codes" CREATE POLICY "Admins with manage_codes permission can insert redemption codes"
ON redemption_codes FOR INSERT ON redemption_codes FOR INSERT
WITH CHECK (has_permission('manage_codes')); WITH CHECK (has_permission('manage_codes'::text));
CREATE POLICY "Admins with manage_codes permission can update redemption codes" CREATE POLICY "Admins with manage_codes permission can update redemption codes"
ON redemption_codes FOR UPDATE ON redemption_codes FOR UPDATE
USING (has_permission('manage_codes')); USING (has_permission('manage_codes'::text));
CREATE POLICY "Admins with manage_codes permission can delete redemption codes" CREATE POLICY "Admins with manage_codes permission can delete redemption codes"
ON redemption_codes FOR DELETE ON redemption_codes FOR DELETE
USING (has_permission('manage_codes')); USING (has_permission('manage_codes'::text));
CREATE POLICY "Super admins can manage redemption codes"
ON redemption_codes FOR ALL
USING (
EXISTS (
SELECT 1
FROM admin_users
WHERE admin_users.user_id = auth.uid()
AND admin_users.role = 'super_admin'::admin_role
)
);
CREATE POLICY "Users can view own redemptions"
ON code_redemptions FOR SELECT
USING (auth.uid() = user_id);
CREATE POLICY "Super admins can view all redemptions"
ON code_redemptions FOR SELECT
USING (
EXISTS (
SELECT 1
FROM admin_users
WHERE admin_users.user_id = auth.uid()
AND admin_users.role = 'super_admin'::admin_role
)
);
CREATE OR REPLACE FUNCTION public.redeem_code_by_email(p_email text, p_code text)
RETURNS jsonb
LANGUAGE plpgsql
SECURITY DEFINER
AS $function$
DECLARE
v_code_record record;
v_user_record record;
v_existing_redemption record;
BEGIN
-- Find the user by email
SELECT id INTO v_user_record
FROM public.profiles
WHERE LOWER(email) = LOWER(p_email);
IF v_user_record IS NULL THEN
RETURN jsonb_build_object('success', false, 'error', 'No account found with this email address');
END IF;
-- Find the code
SELECT * INTO v_code_record
FROM public.redemption_codes
WHERE UPPER(code) = UPPER(p_code);
IF v_code_record IS NULL THEN
RETURN jsonb_build_object('success', false, 'error', 'Invalid redemption code');
END IF;
-- Check if code is active
IF NOT v_code_record.is_active THEN
RETURN jsonb_build_object('success', false, 'error', 'This code is no longer active');
END IF;
-- Check if code has expired
IF v_code_record.expires_at IS NOT NULL AND v_code_record.expires_at < now() THEN
RETURN jsonb_build_object('success', false, 'error', 'This code has expired');
END IF;
-- Check if code has uses remaining
IF v_code_record.max_uses IS NOT NULL AND v_code_record.current_uses >= v_code_record.max_uses THEN
RETURN jsonb_build_object('success', false, 'error', 'This code has reached its maximum number of uses');
END IF;
-- Check if user has already redeemed this code
SELECT * INTO v_existing_redemption
FROM public.code_redemptions
WHERE code_id = v_code_record.id AND user_id = v_user_record.id;
IF v_existing_redemption IS NOT NULL THEN
RETURN jsonb_build_object('success', false, 'error', 'You have already redeemed this code');
END IF;
-- All checks passed - redeem the code
-- 1. Create redemption record
INSERT INTO public.code_redemptions (code_id, user_id)
VALUES (v_code_record.id, v_user_record.id);
-- 2. Increment current_uses
UPDATE public.redemption_codes
SET current_uses = current_uses + 1,
updated_at = now()
WHERE id = v_code_record.id;
-- 3. Set user as premium
UPDATE public.profiles
SET is_premium = true,
updated_at = now()
WHERE id = v_user_record.id;
RETURN jsonb_build_object('success', true, 'message', 'Code redeemed successfully! You now have premium access.');
END;
$function$;
GRANT EXECUTE ON FUNCTION public.redeem_code_by_email(text, text) TO PUBLIC;
GRANT EXECUTE ON FUNCTION public.redeem_code_by_email(text, text) TO anon, authenticated, service_role;
@@ -1,27 +1,43 @@
-- Track user opt-ins for upcoming/coming-soon features -- Table to track user interest in upcoming features
CREATE TABLE feature_interests ( CREATE TABLE feature_interests (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
feature_name TEXT NOT NULL, feature_name TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), created_at TIMESTAMPTZ DEFAULT NOW() NOT NULL,
UNIQUE (user_id, feature_name)
-- Ensure a user can only express interest in a feature once
UNIQUE(user_id, feature_name)
); );
CREATE INDEX idx_feature_interests_user_id ON feature_interests(user_id); -- Index for querying by feature
CREATE INDEX idx_feature_interests_feature_name ON feature_interests(feature_name); CREATE INDEX idx_feature_interests_feature_name ON feature_interests(feature_name);
-- Index for querying by user
CREATE INDEX idx_feature_interests_user_id ON feature_interests(user_id);
-- Enable RLS
ALTER TABLE feature_interests ENABLE ROW LEVEL SECURITY; ALTER TABLE feature_interests ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Users can view their own interests" -- Users can insert their own interest
ON feature_interests FOR SELECT
USING (auth.uid() = user_id);
CREATE POLICY "Users can express interest in features" CREATE POLICY "Users can express interest in features"
ON feature_interests FOR INSERT ON feature_interests
WITH CHECK (auth.uid() = user_id); FOR INSERT
TO authenticated
WITH CHECK (auth.uid() = user_id);
-- Users can view their own interests
CREATE POLICY "Users can view their own interests"
ON feature_interests
FOR SELECT
TO authenticated
USING (auth.uid() = user_id);
-- Users can delete their own interests
CREATE POLICY "Users can remove their interest" CREATE POLICY "Users can remove their interest"
ON feature_interests FOR DELETE ON feature_interests
USING (auth.uid() = user_id); FOR DELETE
TO authenticated
USING (auth.uid() = user_id);
-- Grant service role full access for admin queries
GRANT ALL ON feature_interests TO service_role;;
@@ -25,5 +25,4 @@ BEGIN
ORDER BY opt_in_count DESC, fi.feature_name ASC; ORDER BY opt_in_count DESC, fi.feature_name ASC;
END; END;
$$; $$;
GRANT EXECUTE ON FUNCTION public.get_feature_interest_counts() TO authenticated; GRANT EXECUTE ON FUNCTION public.get_feature_interest_counts() TO authenticated;
@@ -3,20 +3,15 @@
-- Version indicator: 1 = plaintext (legacy), 2 = E2EE -- Version indicator: 1 = plaintext (legacy), 2 = E2EE
ALTER TABLE messages ADD COLUMN IF NOT EXISTS version INTEGER DEFAULT 1; ALTER TABLE messages ADD COLUMN IF NOT EXISTS version INTEGER DEFAULT 1;
-- Encrypted message content (base64 encoded ciphertext) -- Encrypted message content (base64 encoded ciphertext)
ALTER TABLE messages ADD COLUMN IF NOT EXISTS encrypted_content TEXT; ALTER TABLE messages ADD COLUMN IF NOT EXISTS encrypted_content TEXT;
-- Initialization vector for AES-GCM (base64 encoded, 12 bytes) -- Initialization vector for AES-GCM (base64 encoded, 12 bytes)
ALTER TABLE messages ADD COLUMN IF NOT EXISTS encryption_iv TEXT; ALTER TABLE messages ADD COLUMN IF NOT EXISTS encryption_iv TEXT;
-- Triple-wrapped encryption keys and metadata -- Triple-wrapped encryption keys and metadata
ALTER TABLE messages ADD COLUMN IF NOT EXISTS keys_metadata JSONB; ALTER TABLE messages ADD COLUMN IF NOT EXISTS keys_metadata JSONB;
-- Add comment for documentation -- Add comment for documentation
COMMENT ON COLUMN messages.version IS COMMENT ON COLUMN messages.version IS
'Encryption version: 1 = plaintext (legacy), 2 = E2EE with triple-wrapped keys'; 'Encryption version: 1 = plaintext (legacy), 2 = E2EE with triple-wrapped keys';
-- Constraint: v2 messages must have encryption fields -- Constraint: v2 messages must have encryption fields
DO $$ DO $$
BEGIN BEGIN
@@ -3,10 +3,8 @@
-- RSA public key in JWK format (JSON Web Key) -- RSA public key in JWK format (JSON Web Key)
ALTER TABLE profiles ADD COLUMN IF NOT EXISTS public_key_jwk JSONB; ALTER TABLE profiles ADD COLUMN IF NOT EXISTS public_key_jwk JSONB;
-- Index for efficient lookups when encrypting for partner -- Index for efficient lookups when encrypting for partner
CREATE INDEX IF NOT EXISTS idx_profiles_couple_id_public_key ON profiles(couple_id) CREATE INDEX IF NOT EXISTS idx_profiles_couple_id_public_key ON profiles(couple_id)
WHERE public_key_jwk IS NOT NULL; WHERE public_key_jwk IS NOT NULL;
COMMENT ON COLUMN profiles.public_key_jwk IS COMMENT ON COLUMN profiles.public_key_jwk IS
'RSA-2048 public key in JWK format for E2EE. Private key stored on device only.'; 'RSA-2048 public key in JWK format for E2EE. Private key stored on device only.';
@@ -9,14 +9,11 @@ CREATE TABLE IF NOT EXISTS master_keys (
rotated_at TIMESTAMPTZ, rotated_at TIMESTAMPTZ,
is_active BOOLEAN DEFAULT true is_active BOOLEAN DEFAULT true
); );
-- Add table comment -- Add table comment
COMMENT ON TABLE master_keys IS COMMENT ON TABLE master_keys IS
'Stores admin RSA public keys for E2EE moderation access. Private key stored securely in environment variables.'; 'Stores admin RSA public keys for E2EE moderation access. Private key stored securely in environment variables.';
-- Enable RLS -- Enable RLS
ALTER TABLE master_keys ENABLE ROW LEVEL SECURITY; ALTER TABLE master_keys ENABLE ROW LEVEL SECURITY;
-- Super admins can view master keys -- Super admins can view master keys
DO $$ DO $$
BEGIN BEGIN
@@ -29,7 +26,6 @@ BEGIN
USING (is_super_admin(auth.uid())); USING (is_super_admin(auth.uid()));
END IF; END IF;
END $$; END $$;
-- Super admins can insert master keys -- Super admins can insert master keys
DO $$ DO $$
BEGIN BEGIN
@@ -42,7 +38,6 @@ BEGIN
WITH CHECK (is_super_admin(auth.uid())); WITH CHECK (is_super_admin(auth.uid()));
END IF; END IF;
END $$; END $$;
-- Super admins can update master keys -- Super admins can update master keys
DO $$ DO $$
BEGIN BEGIN
@@ -55,7 +50,6 @@ BEGIN
USING (is_super_admin(auth.uid())); USING (is_super_admin(auth.uid()));
END IF; END IF;
END $$; END $$;
-- Allow all authenticated users to read active master key public keys (needed for encryption) -- Allow all authenticated users to read active master key public keys (needed for encryption)
DO $$ DO $$
BEGIN BEGIN
@@ -1,8 +1,7 @@
-- Update the content_or_media constraint to allow E2EE messages -- Update the content_or_media constraint to allow E2EE messages
-- E2EE messages have encrypted_content instead of content -- E2EE messages have encrypted_content instead of content
ALTER TABLE public.messages DROP CONSTRAINT IF EXISTS content_or_media; ALTER TABLE public.messages DROP CONSTRAINT IF EXISTS content_or_media;
ALTER TABLE public.messages ADD CONSTRAINT content_or_media CHECK ( ALTER TABLE public.messages ADD CONSTRAINT content_or_media CHECK (
-- v1 (plaintext): must have content or media_path -- v1 (plaintext): must have content or media_path
(version = 1 AND (content IS NOT NULL OR media_path IS NOT NULL)) (version = 1 AND (content IS NOT NULL OR media_path IS NOT NULL))
@@ -12,4 +11,4 @@ ALTER TABLE public.messages ADD CONSTRAINT content_or_media CHECK (
OR OR
-- Legacy messages without version: require content or media_path -- Legacy messages without version: require content or media_path
(version IS NULL AND (content IS NOT NULL OR media_path IS NOT NULL)) (version IS NULL AND (content IS NOT NULL OR media_path IS NOT NULL))
); );
@@ -2,7 +2,6 @@
ALTER TABLE messages ALTER TABLE messages
ADD COLUMN moderation_status text DEFAULT 'unmoderated' CHECK (moderation_status IN ('unmoderated', 'safe', 'flagged')), ADD COLUMN moderation_status text DEFAULT 'unmoderated' CHECK (moderation_status IN ('unmoderated', 'safe', 'flagged')),
ADD COLUMN flag_reason text; ADD COLUMN flag_reason text;
-- Add classifier configuration to ai_config table -- Add classifier configuration to ai_config table
ALTER TABLE ai_config ALTER TABLE ai_config
ADD COLUMN classifier_enabled boolean DEFAULT true, ADD COLUMN classifier_enabled boolean DEFAULT true,
@@ -22,7 +21,6 @@ If the content is safe (including consensual adult sexual content), respond with
If the content is illegal/dangerous, respond with {"status": "flagged", "reason": "Brief explanation"}. If the content is illegal/dangerous, respond with {"status": "flagged", "reason": "Brief explanation"}.
Response must be valid JSON.'; Response must be valid JSON.';
-- Function to trigger classification -- Function to trigger classification
CREATE OR REPLACE FUNCTION trigger_classify_message() CREATE OR REPLACE FUNCTION trigger_classify_message()
RETURNS TRIGGER RETURNS TRIGGER
@@ -54,13 +52,11 @@ BEGIN
RETURN NEW; RETURN NEW;
END; END;
$$; $$;
-- Create trigger on messages table -- Create trigger on messages table
CREATE TRIGGER on_message_created_classify CREATE TRIGGER on_message_created_classify
AFTER INSERT ON messages AFTER INSERT ON messages
FOR EACH ROW FOR EACH ROW
EXECUTE FUNCTION trigger_classify_message(); EXECUTE FUNCTION trigger_classify_message();
-- Add comments -- Add comments
COMMENT ON COLUMN messages.moderation_status IS 'Content moderation status: unmoderated, safe, or flagged.'; COMMENT ON COLUMN messages.moderation_status IS 'Content moderation status: unmoderated, safe, or flagged.';
COMMENT ON COLUMN messages.flag_reason IS 'Reason for flagging the content (if status is flagged).'; COMMENT ON COLUMN messages.flag_reason IS 'Reason for flagging the content (if status is flagged).';
@@ -1,9 +1,7 @@
-- Add category column to messages table -- Add category column to messages table
ALTER TABLE messages ALTER TABLE messages
ADD COLUMN category text; ADD COLUMN category text;
COMMENT ON COLUMN messages.category IS 'Content category: Neutral, Romantic, Playful, Explicit, etc.'; COMMENT ON COLUMN messages.category IS 'Content category: Neutral, Romantic, Playful, Explicit, etc.';
-- Update the default classifier prompt in ai_config -- Update the default classifier prompt in ai_config
UPDATE ai_config UPDATE ai_config
SET classifier_prompt = 'You are a content moderator for a couples app. Your task is to analyze the following message (text and/or image) and determine if it contains ILLEGAL or DANGEROUS content, and also categorize the tone/nature of the message. SET classifier_prompt = 'You are a content moderator for a couples app. Your task is to analyze the following message (text and/or image) and determine if it contains ILLEGAL or DANGEROUS content, and also categorize the tone/nature of the message.
@@ -1,6 +1,5 @@
-- Add deleted_at column to messages table for "delete for everyone" -- Add deleted_at column to messages table for "delete for everyone"
ALTER TABLE messages ADD COLUMN deleted_at TIMESTAMPTZ; ALTER TABLE messages ADD COLUMN deleted_at TIMESTAMPTZ;
-- Create message_deletions table for "delete for self" (per-user deletions) -- Create message_deletions table for "delete for self" (per-user deletions)
CREATE TABLE message_deletions ( CREATE TABLE message_deletions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
@@ -8,22 +7,18 @@ CREATE TABLE message_deletions (
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
deleted_at TIMESTAMPTZ DEFAULT now(), deleted_at TIMESTAMPTZ DEFAULT now(),
UNIQUE(message_id, user_id) UNIQUE(message_id, user_id)
); );
-- Index for efficient lookup when filtering messages -- Index for efficient lookup when filtering messages
CREATE INDEX idx_message_deletions_user_message ON message_deletions(user_id, message_id); CREATE INDEX idx_message_deletions_user_message ON message_deletions(user_id, message_id);
CREATE INDEX idx_messages_deleted_at ON messages(deleted_at) WHERE deleted_at IS NOT NULL; CREATE INDEX idx_messages_deleted_at ON messages(deleted_at) WHERE deleted_at IS NOT NULL;
-- Enable RLS on message_deletions -- Enable RLS on message_deletions
ALTER TABLE message_deletions ENABLE ROW LEVEL SECURITY; ALTER TABLE message_deletions ENABLE ROW LEVEL SECURITY;
-- RLS Policies for message_deletions -- RLS Policies for message_deletions
-- Users can view their own deletions (needed for filtering) -- Users can view their own deletions (needed for filtering)
CREATE POLICY "Users can view own deletions" CREATE POLICY "Users can view own deletions"
ON message_deletions FOR SELECT ON message_deletions FOR SELECT
USING (user_id = auth.uid()); USING (user_id = auth.uid());
-- Users can insert their own deletions (for messages in their matches) -- Users can insert their own deletions (for messages in their matches)
CREATE POLICY "Users can delete messages for self" CREATE POLICY "Users can delete messages for self"
ON message_deletions FOR INSERT ON message_deletions FOR INSERT
@@ -36,17 +31,14 @@ CREATE POLICY "Users can delete messages for self"
WHERE m.id = message_deletions.message_id WHERE m.id = message_deletions.message_id
AND p.id = auth.uid() AND p.id = auth.uid()
) )
); );
-- Update messages RLS policy to allow authors to set deleted_at -- Update messages RLS policy to allow authors to set deleted_at
CREATE POLICY "Authors can soft delete messages for everyone" CREATE POLICY "Authors can soft delete messages for everyone"
ON messages FOR UPDATE ON messages FOR UPDATE
USING (user_id = auth.uid()) USING (user_id = auth.uid())
WITH CHECK (user_id = auth.uid()); WITH CHECK (user_id = auth.uid());
-- Add to realtime for live updates -- Add to realtime for live updates
ALTER PUBLICATION supabase_realtime ADD TABLE message_deletions; ALTER PUBLICATION supabase_realtime ADD TABLE message_deletions;
-- Comments for documentation -- Comments for documentation
COMMENT ON COLUMN messages.deleted_at IS 'Timestamp when message was deleted for everyone (author-only action). Shows "Message deleted" to all users.'; COMMENT ON COLUMN messages.deleted_at IS 'Timestamp when message was deleted for everyone (author-only action). Shows "Message deleted" to all users.';
COMMENT ON TABLE message_deletions IS 'Per-user message deletions for "delete for self" functionality. Message still visible to other user.'; COMMENT ON TABLE message_deletions IS 'Per-user message deletions for "delete for self" functionality. Message still visible to other user.';
@@ -1,6 +1,5 @@
-- Create enum for report reasons -- Create enum for report reasons
CREATE TYPE report_reason AS ENUM ('harassment', 'spam', 'inappropriate_content', 'other'); CREATE TYPE report_reason AS ENUM ('harassment', 'spam', 'inappropriate_content', 'other');
-- Create message_reports table -- Create message_reports table
CREATE TABLE message_reports ( CREATE TABLE message_reports (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(), id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
@@ -14,23 +13,19 @@ CREATE TABLE message_reports (
-- Prevent duplicate reports from same user -- Prevent duplicate reports from same user
UNIQUE(message_id, reporter_id) UNIQUE(message_id, reporter_id)
); );
-- Indexes for efficient queries -- Indexes for efficient queries
CREATE INDEX idx_message_reports_status ON message_reports(status); CREATE INDEX idx_message_reports_status ON message_reports(status);
CREATE INDEX idx_message_reports_created ON message_reports(created_at DESC); CREATE INDEX idx_message_reports_created ON message_reports(created_at DESC);
CREATE INDEX idx_message_reports_message ON message_reports(message_id); CREATE INDEX idx_message_reports_message ON message_reports(message_id);
-- Enable RLS -- Enable RLS
ALTER TABLE message_reports ENABLE ROW LEVEL SECURITY; ALTER TABLE message_reports ENABLE ROW LEVEL SECURITY;
-- RLS Policies -- RLS Policies
-- Users can view their own reports -- Users can view their own reports
CREATE POLICY "Users can view own reports" CREATE POLICY "Users can view own reports"
ON message_reports FOR SELECT ON message_reports FOR SELECT
USING (reporter_id = auth.uid()); USING (reporter_id = auth.uid());
-- Users can create reports for messages in their couple's matches (but NOT their own messages) -- Users can create reports for messages in their couple's matches (but NOT their own messages)
CREATE POLICY "Users can report partner messages" CREATE POLICY "Users can report partner messages"
ON message_reports FOR INSERT ON message_reports FOR INSERT
@@ -44,22 +39,18 @@ CREATE POLICY "Users can report partner messages"
AND p.id = auth.uid() AND p.id = auth.uid()
AND m.user_id != auth.uid() -- Cannot report own messages AND m.user_id != auth.uid() -- Cannot report own messages
) )
); );
-- Admins can view all reports -- Admins can view all reports
CREATE POLICY "Admins can view all reports" CREATE POLICY "Admins can view all reports"
ON message_reports FOR SELECT ON message_reports FOR SELECT
USING (is_admin()); USING (is_admin());
-- Admins can update reports (for review status) -- Admins can update reports (for review status)
CREATE POLICY "Admins can update reports" CREATE POLICY "Admins can update reports"
ON message_reports FOR UPDATE ON message_reports FOR UPDATE
USING (is_admin()); USING (is_admin());
-- Add to realtime for live updates in admin -- Add to realtime for live updates in admin
ALTER PUBLICATION supabase_realtime ADD TABLE message_reports; ALTER PUBLICATION supabase_realtime ADD TABLE message_reports;
-- Comments -- Comments
COMMENT ON TABLE message_reports IS 'User-submitted reports for messages. Separate from AI moderation (moderation_status on messages table).'; COMMENT ON TABLE message_reports IS 'User-submitted reports for messages. Separate from AI moderation (moderation_status on messages table).';
COMMENT ON COLUMN message_reports.status IS 'Report status: pending (new), reviewed (action taken), dismissed (no action needed).'; COMMENT ON COLUMN message_reports.status IS 'Report status: pending (new), reviewed (action taken), dismissed (no action needed).';
COMMENT ON COLUMN message_reports.reason IS 'User-selected reason for reporting: harassment, spam, inappropriate_content, or other.'; COMMENT ON COLUMN message_reports.reason IS 'User-selected reason for reporting: harassment, spam, inappropriate_content, or other.';
@@ -2,4 +2,4 @@
-- This allows Supabase to join with profiles table in admin queries -- This allows Supabase to join with profiles table in admin queries
ALTER TABLE message_reports ALTER TABLE message_reports
ADD CONSTRAINT message_reports_reporter_profile_fk ADD CONSTRAINT message_reports_reporter_profile_fk
FOREIGN KEY (reporter_id) REFERENCES profiles(id) ON DELETE CASCADE; FOREIGN KEY (reporter_id) REFERENCES profiles(id) ON DELETE CASCADE;
@@ -3,19 +3,16 @@
ALTER TABLE profiles ALTER TABLE profiles
ADD COLUMN IF NOT EXISTS max_intensity INTEGER DEFAULT 2 ADD COLUMN IF NOT EXISTS max_intensity INTEGER DEFAULT 2
CHECK (max_intensity >= 1 AND max_intensity <= 5); CHECK (max_intensity >= 1 AND max_intensity <= 5);
UPDATE profiles UPDATE profiles
SET max_intensity = CASE SET max_intensity = CASE
WHEN show_explicit_content IS TRUE THEN 5 WHEN show_explicit_content IS TRUE THEN 5
ELSE 2 ELSE 2
END END
WHERE max_intensity IS NULL; WHERE max_intensity IS NULL;
ALTER TABLE question_packs ALTER TABLE question_packs
ADD COLUMN IF NOT EXISTS min_intensity INTEGER, ADD COLUMN IF NOT EXISTS min_intensity INTEGER,
ADD COLUMN IF NOT EXISTS max_intensity INTEGER, ADD COLUMN IF NOT EXISTS max_intensity INTEGER,
ADD COLUMN IF NOT EXISTS avg_intensity NUMERIC(3,2); ADD COLUMN IF NOT EXISTS avg_intensity NUMERIC(3,2);
CREATE OR REPLACE FUNCTION update_question_pack_intensity_stats(target_pack_id UUID) CREATE OR REPLACE FUNCTION update_question_pack_intensity_stats(target_pack_id UUID)
RETURNS void RETURNS void
LANGUAGE plpgsql LANGUAGE plpgsql
@@ -46,7 +43,6 @@ BEGIN
END IF; END IF;
END; END;
$$; $$;
CREATE OR REPLACE FUNCTION handle_question_intensity_change() CREATE OR REPLACE FUNCTION handle_question_intensity_change()
RETURNS TRIGGER RETURNS TRIGGER
LANGUAGE plpgsql LANGUAGE plpgsql
@@ -66,13 +62,10 @@ BEGIN
RETURN NEW; RETURN NEW;
END; END;
$$; $$;
DROP TRIGGER IF EXISTS question_pack_intensity_stats ON questions; DROP TRIGGER IF EXISTS question_pack_intensity_stats ON questions;
CREATE TRIGGER question_pack_intensity_stats CREATE TRIGGER question_pack_intensity_stats
AFTER INSERT OR UPDATE OR DELETE ON questions AFTER INSERT OR UPDATE OR DELETE ON questions
FOR EACH ROW EXECUTE FUNCTION handle_question_intensity_change(); FOR EACH ROW EXECUTE FUNCTION handle_question_intensity_change();
UPDATE question_packs qp UPDATE question_packs qp
SET min_intensity = stats.min_intensity, SET min_intensity = stats.min_intensity,
max_intensity = stats.max_intensity, max_intensity = stats.max_intensity,
@@ -169,5 +169,4 @@ BEGIN
); );
END; END;
$$; $$;
COMMENT ON FUNCTION get_recommended_questions(UUID) IS 'Returns recommended questions for user filtered by their intensity preference. When user is too far ahead of partner (answer gap threshold exceeded), returns empty.'; COMMENT ON FUNCTION get_recommended_questions(UUID) IS 'Returns recommended questions for user filtered by their intensity preference. When user is too far ahead of partner (answer gap threshold exceeded), returns empty.';
@@ -1,14 +1,12 @@
-- Add admin_username column to audit_logs table -- Add admin_username column to audit_logs table
ALTER TABLE audit_logs ALTER TABLE audit_logs
ADD COLUMN admin_username TEXT; ADD COLUMN admin_username TEXT;
-- Backfill existing audit logs with admin names from profiles -- Backfill existing audit logs with admin names from profiles
UPDATE audit_logs UPDATE audit_logs
SET admin_username = COALESCE(p.name, p.email, 'Unknown') SET admin_username = COALESCE(p.name, p.email, 'Unknown')
FROM profiles p FROM profiles p
WHERE audit_logs.admin_user_id = p.id WHERE audit_logs.admin_user_id = p.id
AND audit_logs.admin_username IS NULL; AND audit_logs.admin_username IS NULL;
-- Update the log_admin_action function to populate admin_username from profiles -- Update the log_admin_action function to populate admin_username from profiles
CREATE OR REPLACE FUNCTION log_admin_action( CREATE OR REPLACE FUNCTION log_admin_action(
p_table_name TEXT, p_table_name TEXT,
Loaded 100 of 122 files, more files were not shown because too many files have changed in this diff. Show more