mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-10-31 18:19:43 +00:00
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
This commit is contained in:
1 parent
12f312421b
commit
494ed17fe2
81 files changed
+4013
-549
No files matched your search
@@ -45,6 +45,8 @@ export interface PackFormData {
|
||||
is_public: boolean;
|
||||
is_explicit: boolean;
|
||||
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 {
|
||||
@@ -200,10 +202,63 @@ export function PackFormDialog({
|
||||
</div>
|
||||
<Switch
|
||||
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>
|
||||
|
||||
{/* 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 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-0.5">
|
||||
|
||||
@@ -8,7 +8,7 @@ import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/com
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
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 { AiGeneratorDialog } from '@/components/ai/AiGeneratorDialog';
|
||||
import { ExtractTopicsDialog } from '@/components/content/ExtractTopicsDialog';
|
||||
@@ -41,6 +41,8 @@ interface QuestionPack {
|
||||
sort_order: number | null;
|
||||
category_id: 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;
|
||||
topics?: Topic[];
|
||||
}
|
||||
@@ -76,6 +78,8 @@ export function PacksPage() {
|
||||
is_public: false,
|
||||
is_explicit: false,
|
||||
category_id: categoryId ?? '',
|
||||
scheduled_release_at: null,
|
||||
skip_notification: false,
|
||||
},
|
||||
(pack) => ({
|
||||
name: pack.name,
|
||||
@@ -85,6 +89,8 @@ export function PacksPage() {
|
||||
is_public: pack.is_public,
|
||||
is_explicit: pack.is_explicit,
|
||||
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;
|
||||
|
||||
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, {
|
||||
name: form.formData.name,
|
||||
description: form.formData.description || null,
|
||||
@@ -231,6 +244,12 @@ export function PacksPage() {
|
||||
is_public: form.formData.is_public,
|
||||
is_explicit: form.formData.is_explicit,
|
||||
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;
|
||||
packId = form.editingItem.id;
|
||||
@@ -246,6 +265,12 @@ export function PacksPage() {
|
||||
is_explicit: form.formData.is_explicit,
|
||||
category_id: form.formData.category_id || null,
|
||||
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')
|
||||
.single();
|
||||
@@ -305,6 +330,8 @@ export function PacksPage() {
|
||||
is_public: false,
|
||||
is_explicit: false,
|
||||
category_id: categoryId ?? '',
|
||||
scheduled_release_at: null,
|
||||
skip_notification: false,
|
||||
});
|
||||
setAiDialogOpen(false);
|
||||
setSelectedTopicIds(new Set());
|
||||
@@ -497,6 +524,18 @@ export function PacksPage() {
|
||||
<Eye className="h-3 w-3 mr-1" />
|
||||
Published
|
||||
</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">
|
||||
<EyeOff className="h-3 w-3 mr-1" />
|
||||
|
||||
@@ -38,7 +38,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Crown, Users, MessageCircle, ChevronRight, ThumbsUp, ThumbsDown, Minus, Gift, Image, Video as VideoIcon, Target, User, Eye, EyeOff, CheckCircle, Package, Heart, Sparkles } 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 { IconPreview } from '@/components/ui/icon-picker';
|
||||
|
||||
@@ -142,6 +142,17 @@ interface FeatureInterest {
|
||||
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 = {
|
||||
yes: <ThumbsUp className="h-4 w-4 text-green-500" />,
|
||||
no: <ThumbsDown className="h-4 w-4 text-red-500" />,
|
||||
@@ -277,6 +288,7 @@ export function UserDetailPage() {
|
||||
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 [responsesPageSize, setResponsesPageSize] = useState(10);
|
||||
const [responsesTotal, setResponsesTotal] = useState(0);
|
||||
@@ -357,8 +369,17 @@ export function UserDetailPage() {
|
||||
if (profileData?.couple_id) {
|
||||
const { data: partnerData } = await supabase.from('profiles').select('id, name').eq('couple_id', profileData.couple_id).neq('id', userId).single();
|
||||
setPartner(partnerData);
|
||||
|
||||
// Fetch streak data
|
||||
const { data: streakData } = await supabase
|
||||
.from('couple_streaks')
|
||||
.select('*')
|
||||
.eq('couple_id', profileData.couple_id)
|
||||
.maybeSingle();
|
||||
setStreak(streakData);
|
||||
} else {
|
||||
setPartner(null);
|
||||
setStreak(null);
|
||||
}
|
||||
|
||||
const responsesFrom = (responsesPage - 1) * responsesPageSize;
|
||||
@@ -534,6 +555,58 @@ export function UserDetailPage() {
|
||||
<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></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>
|
||||
|
||||
@@ -4,11 +4,12 @@ import { View, ActivityIndicator, StyleSheet, Platform, Text, Pressable, Animate
|
||||
import { BlurView } from "expo-blur";
|
||||
import { LinearGradient } from "expo-linear-gradient";
|
||||
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 { supabase } from "../../src/lib/supabase";
|
||||
import { isBiometricEnabled } from "../../src/lib/biometricAuth";
|
||||
import { checkAndRegisterPushToken } from "../../src/lib/notifications";
|
||||
import { syncBadgeCount } from "../../src/lib/badge";
|
||||
import { BiometricLockScreen } from "../../src/components/BiometricLockScreen";
|
||||
import { MatchNotificationModal, MatchNotificationData } from "../../src/components/MatchNotificationModal";
|
||||
import { Events } from "../../src/lib/analytics";
|
||||
@@ -139,11 +140,12 @@ function PlayTabButton({ accessibilityState }: PlayTabButtonProps) {
|
||||
export default function AppLayout() {
|
||||
const router = useRouter();
|
||||
const segments = useSegments();
|
||||
const { isAuthenticated, isLoading, user, signOut } = useAuthStore();
|
||||
const { isAuthenticated, isLoading, user, signOut, updateLastActive } = useAuthStore();
|
||||
const { matches, newMatchesCount, addMatch, updateMatchUnreadCount } = useMatchStore();
|
||||
const { unreadCount, lastMessage, fetchUnreadCount, addMessage, clearLastMessage } = useMessageStore();
|
||||
const { fetchEnabledPacks } = usePacksStore();
|
||||
const { initializeRevenueCat } = useSubscriptionStore();
|
||||
const { fetchStreak } = useStreakStore();
|
||||
const [matchNotification, setMatchNotification] = useState<MatchNotificationData | null>(null);
|
||||
const messageToastAnim = useRef(new Animated.Value(-100)).current;
|
||||
const messageTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
@@ -208,7 +210,8 @@ export default function AppLayout() {
|
||||
const isOnOnboarding = segmentStrings.includes("onboarding");
|
||||
const isOnPairing = segmentStrings.includes("pairing");
|
||||
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
|
||||
useEffect(() => {
|
||||
@@ -232,17 +235,22 @@ export default function AppLayout() {
|
||||
// Check immediately
|
||||
checkSession();
|
||||
|
||||
// Update last active timestamp on mount
|
||||
updateLastActive();
|
||||
|
||||
// Check when app comes to foreground
|
||||
const subscription = AppState.addEventListener('change', (nextAppState) => {
|
||||
if (nextAppState === 'active') {
|
||||
checkSession();
|
||||
// Update last active timestamp when app comes to foreground
|
||||
updateLastActive();
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
subscription.remove();
|
||||
};
|
||||
}, [signOut]);
|
||||
}, [signOut, updateLastActive]);
|
||||
|
||||
// Handle biometric lock when app goes to background/foreground
|
||||
useEffect(() => {
|
||||
@@ -301,6 +309,18 @@ export default function AppLayout() {
|
||||
}
|
||||
}, [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)
|
||||
useEffect(() => {
|
||||
if (user?.id && Platform.OS !== "web") {
|
||||
@@ -692,6 +712,12 @@ export default function AppLayout() {
|
||||
href: null, // Hide from tab bar
|
||||
}}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="settings"
|
||||
options={{
|
||||
href: null, // Hide from tab bar
|
||||
}}
|
||||
/>
|
||||
</Tabs>
|
||||
|
||||
{/* Radial Menu Overlay */}
|
||||
|
||||
@@ -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;
|
||||
@@ -163,7 +163,7 @@ describe('subscriptionStore', () => {
|
||||
|
||||
const result = await useSubscriptionStore.getState().purchasePackage(mockPackage as any);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(result.success).toBe(true);
|
||||
const state = useSubscriptionStore.getState();
|
||||
expect(state.subscription.isProUser).toBe(true);
|
||||
expect(state.isPurchasing).toBe(false);
|
||||
@@ -178,7 +178,8 @@ describe('subscriptionStore', () => {
|
||||
|
||||
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();
|
||||
expect(state.error).toBe('Purchase was cancelled');
|
||||
expect(state.isPurchasing).toBe(false);
|
||||
@@ -192,7 +193,8 @@ describe('subscriptionStore', () => {
|
||||
|
||||
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();
|
||||
expect(state.error).toBe('Purchase failed. Please try again.');
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
@@ -43,6 +43,10 @@ const FEATURES = [
|
||||
];
|
||||
|
||||
export function Paywall({ visible, onClose, onSuccess }: PaywallProps) {
|
||||
const handleClose = (reason: "dismiss" | "success" | "restore" | "system") => {
|
||||
Events.paywallClosed("paywall", reason);
|
||||
onClose();
|
||||
};
|
||||
const {
|
||||
offerings,
|
||||
isLoadingOfferings,
|
||||
@@ -79,13 +83,23 @@ export function Paywall({ visible, onClose, onSuccess }: PaywallProps) {
|
||||
const handlePurchase = async () => {
|
||||
if (!selectedPackage) return;
|
||||
|
||||
Events.purchaseInitiated(selectedPackage.packageType || "unknown");
|
||||
const success = await purchasePackage(selectedPackage);
|
||||
if (success) {
|
||||
Events.purchaseCompleted(selectedPackage.packageType || "unknown");
|
||||
const packageType = selectedPackage.packageType || "unknown";
|
||||
Events.purchaseInitiated(packageType);
|
||||
|
||||
const result = await purchasePackage(selectedPackage);
|
||||
if (result.success) {
|
||||
Events.purchaseCompleted(packageType);
|
||||
onSuccess?.();
|
||||
onClose();
|
||||
handleClose("success");
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.cancelled) {
|
||||
Events.purchaseCancelled(packageType, result.errorCode);
|
||||
return;
|
||||
}
|
||||
|
||||
Events.purchaseFailed(packageType, result.errorCode, result.errorMessage);
|
||||
};
|
||||
|
||||
const handleRestore = async () => {
|
||||
@@ -93,7 +107,7 @@ export function Paywall({ visible, onClose, onSuccess }: PaywallProps) {
|
||||
if (restored) {
|
||||
Events.purchaseRestored();
|
||||
onSuccess?.();
|
||||
onClose();
|
||||
handleClose("restore");
|
||||
}
|
||||
};
|
||||
|
||||
@@ -133,7 +147,7 @@ export function Paywall({ visible, onClose, onSuccess }: PaywallProps) {
|
||||
visible={visible}
|
||||
transparent
|
||||
animationType="slide"
|
||||
onRequestClose={onClose}
|
||||
onRequestClose={() => handleClose("system")}
|
||||
>
|
||||
<View style={styles.overlay}>
|
||||
<View style={styles.content}>
|
||||
@@ -157,7 +171,7 @@ export function Paywall({ visible, onClose, onSuccess }: PaywallProps) {
|
||||
{/* Close Button */}
|
||||
<TouchableOpacity
|
||||
style={styles.closeButton}
|
||||
onPress={onClose}
|
||||
onPress={() => handleClose("dismiss")}
|
||||
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
|
||||
>
|
||||
<Ionicons name="close" size={24} color={colors.textSecondary} />
|
||||
|
||||
@@ -1,62 +1,42 @@
|
||||
import React from 'react';
|
||||
import { View, Text, StyleSheet, ScrollView, TouchableOpacity, useWindowDimensions, Platform, Alert, Linking } from 'react-native';
|
||||
import React, { useState } from 'react';
|
||||
import { View, Text, StyleSheet, ScrollView, useWindowDimensions, Platform, Alert, TouchableOpacity } from 'react-native';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import Constants from 'expo-constants';
|
||||
import Animated, {
|
||||
FadeInDown,
|
||||
useSharedValue,
|
||||
useAnimatedScrollHandler,
|
||||
useAnimatedStyle,
|
||||
interpolate,
|
||||
Extrapolation
|
||||
} from 'react-native-reanimated';
|
||||
import { BlurView } from 'expo-blur';
|
||||
import Animated, { FadeInDown } from 'react-native-reanimated';
|
||||
import { Image } from 'expo-image';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
|
||||
import { GradientBackground } from '../../components/ui';
|
||||
import { FeedbackModal } from '../../components/feedback';
|
||||
import { Paywall } from '../../components/paywall';
|
||||
import { colors, featureColors, spacing, typography, radius } from '../../theme';
|
||||
import { useAuthStore, useSubscriptionStore } from '../../store';
|
||||
import { resetSwipeTutorial } from '../../lib/swipeTutorialSeen';
|
||||
import { resetMatchesTutorial } from '../../lib/matchesTutorialSeen';
|
||||
import { supabase } from '../../lib/supabase'; // Needed for debug reset
|
||||
|
||||
// Hooks
|
||||
import { useProfileSettings, useCoupleManagement } from './hooks';
|
||||
import { supabase } from '../../lib/supabase';
|
||||
|
||||
// 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 NAV_BAR_HEIGHT = 44;
|
||||
const STATUS_BAR_HEIGHT = 60;
|
||||
const HEADER_SCROLL_DISTANCE = 100;
|
||||
|
||||
const AnimatedScrollView = Animated.createAnimatedComponent(ScrollView);
|
||||
const ACCENT_GRADIENT = featureColors.profile.gradient as [string, string];
|
||||
|
||||
export function ProfileScreen() {
|
||||
const router = useRouter();
|
||||
const { width } = useWindowDimensions();
|
||||
const isWideScreen = width > MAX_CONTENT_WIDTH;
|
||||
|
||||
const { user, partner, couple, fetchUser } = useAuthStore();
|
||||
const { user, partner, fetchUser } = useAuthStore();
|
||||
const { subscription } = useSubscriptionStore();
|
||||
|
||||
// Hooks
|
||||
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
|
||||
// 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)
|
||||
// Derived state
|
||||
const isOwnSubscription = subscription.isProUser || user?.is_premium;
|
||||
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}`;
|
||||
|
||||
// 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 (
|
||||
<GradientBackground>
|
||||
{/* Fixed Nav Bar */}
|
||||
<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
|
||||
<ScrollView
|
||||
style={styles.container}
|
||||
contentContainerStyle={[
|
||||
styles.contentContainer,
|
||||
isWideScreen && styles.contentContainerWide,
|
||||
]}
|
||||
showsVerticalScrollIndicator={false}
|
||||
onScroll={scrollHandler}
|
||||
scrollEventThrottle={16}
|
||||
>
|
||||
{/* Header title */}
|
||||
{/* Header with user info (compact) */}
|
||||
<Animated.View
|
||||
entering={FadeInDown.delay(100).duration(500)}
|
||||
style={[styles.header, heroStyle]}
|
||||
style={styles.header}
|
||||
>
|
||||
<Text style={styles.headerLabel}>YOUR SAUCI</Text>
|
||||
<Text style={styles.title}>Settings</Text>
|
||||
@@ -164,98 +83,111 @@ export function ProfileScreen() {
|
||||
</View>
|
||||
</Animated.View>
|
||||
|
||||
{/* Profile Header */}
|
||||
<AppearanceSettings
|
||||
user={user}
|
||||
isUploadingAvatar={settings.isUploadingAvatar}
|
||||
isEditingName={settings.isEditingName}
|
||||
newName={settings.newName}
|
||||
isUpdatingName={settings.isUpdatingName}
|
||||
onAvatarPress={settings.handleAvatarPress}
|
||||
onNewNameChange={settings.setNewName}
|
||||
onUpdateName={settings.handleUpdateName}
|
||||
onCancelEditName={settings.handleCancelEditName}
|
||||
onStartEditingName={() => settings.setIsEditingName(true)}
|
||||
/>
|
||||
{/* Compact Profile Preview */}
|
||||
<Animated.View
|
||||
entering={FadeInDown.delay(150).duration(500)}
|
||||
style={styles.profilePreview}
|
||||
>
|
||||
<TouchableOpacity
|
||||
style={styles.profilePreviewContent}
|
||||
onPress={() => router.push('/(app)/settings/profile')}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<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.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 */}
|
||||
<CoupleStatus
|
||||
partner={partner}
|
||||
couple={couple}
|
||||
onUnpair={handleUnpair}
|
||||
onPairingPress={navigateToPairing}
|
||||
/>
|
||||
|
||||
{/* Subscription Section */}
|
||||
{/* Subscription CTA - prominent upsell */}
|
||||
<SubscriptionCard
|
||||
hasPremiumAccess={hasPremiumAccess}
|
||||
isOwnSubscription={!!isOwnSubscription}
|
||||
expirationDate={formatExpirationDate(subscription.expirationDate)}
|
||||
onUpgradePress={() => settings.setShowPaywall(true)}
|
||||
onUpgradePress={() => setShowPaywall(true)}
|
||||
onManagePress={settings.handleManageSubscription}
|
||||
onRestorePress={settings.handleRestorePurchases}
|
||||
isRestoring={settings.isPurchasing}
|
||||
delay={350}
|
||||
delay={200}
|
||||
/>
|
||||
|
||||
{/* Notifications */}
|
||||
<NotificationSettings
|
||||
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}>
|
||||
{/* Settings Navigation */}
|
||||
<SettingsSection title="Settings" delay={250}>
|
||||
<MenuItem
|
||||
icon="log-out-outline"
|
||||
label="Sign Out"
|
||||
onPress={handleSignOut}
|
||||
variant="danger"
|
||||
showChevron={true}
|
||||
icon="person-circle-outline"
|
||||
label="Profile"
|
||||
description="Avatar and display name"
|
||||
onPress={() => router.push('/(app)/settings/profile')}
|
||||
/>
|
||||
<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>
|
||||
|
||||
{/* Support */}
|
||||
<SettingsSection title="Support" delay={475}>
|
||||
{/* Relationship Section */}
|
||||
<SettingsSection title="Relationship" delay={300}>
|
||||
<MenuItem
|
||||
icon="chatbubble-ellipses-outline"
|
||||
label="Send Feedback"
|
||||
description="Report bugs or request features"
|
||||
onPress={() => setShowFeedbackModal(true)}
|
||||
/>
|
||||
<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')}
|
||||
icon="heart-outline"
|
||||
label="Partner"
|
||||
rightText={partner?.name || (user?.couple_id ? 'Waiting...' : undefined)}
|
||||
onPress={() => router.push('/(app)/settings/partner')}
|
||||
/>
|
||||
</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__ && (
|
||||
<SettingsSection title="Debug" delay={550}>
|
||||
<SettingsSection title="Debug" delay={400}>
|
||||
<MenuItem
|
||||
icon="refresh-outline"
|
||||
label="Reset Swipe Tutorial"
|
||||
@@ -265,7 +197,7 @@ export function ProfileScreen() {
|
||||
Alert.alert("Success", "Swipe tutorial has been reset.");
|
||||
}}
|
||||
/>
|
||||
<View style={styles.preferencesDivider} />
|
||||
<View style={styles.divider} />
|
||||
<MenuItem
|
||||
icon="heart-outline"
|
||||
label="Reset Matches Tutorial"
|
||||
@@ -275,7 +207,7 @@ export function ProfileScreen() {
|
||||
Alert.alert("Success", "Matches tutorial has been reset.");
|
||||
}}
|
||||
/>
|
||||
<View style={styles.preferencesDivider} />
|
||||
<View style={styles.divider} />
|
||||
<MenuItem
|
||||
icon="school-outline"
|
||||
label="Reset Onboarding"
|
||||
@@ -298,17 +230,9 @@ export function ProfileScreen() {
|
||||
</SettingsSection>
|
||||
)}
|
||||
|
||||
{/* Danger Zone */}
|
||||
<DangerZone
|
||||
onResetProgress={couple ? handleResetProgress : undefined}
|
||||
onDeleteRelationship={couple ? handleDeleteRelationship : undefined}
|
||||
onDeleteAccount={handleDeleteAccount}
|
||||
hasRelationship={!!couple}
|
||||
/>
|
||||
|
||||
{/* Version */}
|
||||
<Animated.View
|
||||
entering={FadeInDown.delay(couple ? 650 : 525).duration(500)}
|
||||
entering={FadeInDown.delay(__DEV__ ? 450 : 400).duration(500)}
|
||||
style={styles.versionContainer}
|
||||
>
|
||||
<View style={styles.versionBadge}>
|
||||
@@ -318,16 +242,11 @@ export function ProfileScreen() {
|
||||
</Animated.View>
|
||||
|
||||
<View style={styles.bottomSpacer} />
|
||||
</AnimatedScrollView>
|
||||
</ScrollView>
|
||||
|
||||
{/* Modals */}
|
||||
<FeedbackModal
|
||||
visible={showFeedbackModal}
|
||||
onClose={() => setShowFeedbackModal(false)}
|
||||
/>
|
||||
<Paywall
|
||||
visible={settings.showPaywall}
|
||||
onClose={() => settings.setShowPaywall(false)}
|
||||
visible={showPaywall}
|
||||
onClose={() => setShowPaywall(false)}
|
||||
/>
|
||||
</GradientBackground>
|
||||
);
|
||||
@@ -337,33 +256,7 @@ const styles = StyleSheet.create({
|
||||
container: {
|
||||
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: {
|
||||
// Remove top padding here as the header adds it
|
||||
paddingBottom: Platform.OS === 'ios' ? 100 : 80,
|
||||
},
|
||||
contentContainerWide: {
|
||||
@@ -372,7 +265,7 @@ const styles = StyleSheet.create({
|
||||
maxWidth: MAX_CONTENT_WIDTH,
|
||||
},
|
||||
header: {
|
||||
paddingTop: STATUS_BAR_HEIGHT + spacing.md,
|
||||
paddingTop: Platform.OS === 'ios' ? 60 : 40,
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingBottom: spacing.md,
|
||||
alignItems: 'center',
|
||||
@@ -409,7 +302,65 @@ const styles = StyleSheet.create({
|
||||
marginHorizontal: spacing.sm,
|
||||
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,
|
||||
backgroundColor: colors.glass.border,
|
||||
marginVertical: spacing.md,
|
||||
|
||||
@@ -15,6 +15,10 @@ export interface MenuItemProps {
|
||||
onPress: () => void;
|
||||
/** Optional element to show on the right */
|
||||
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 */
|
||||
showChevron?: boolean;
|
||||
/** Visual variant */
|
||||
@@ -34,6 +38,8 @@ export function MenuItem({
|
||||
description,
|
||||
onPress,
|
||||
rightElement,
|
||||
rightText,
|
||||
badge,
|
||||
showChevron = true,
|
||||
variant = 'default',
|
||||
disabled = false,
|
||||
@@ -65,17 +71,29 @@ export function MenuItem({
|
||||
</LinearGradient>
|
||||
)}
|
||||
<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 && (
|
||||
<Text style={styles.description}>{description}</Text>
|
||||
)}
|
||||
</View>
|
||||
</View>
|
||||
{rightElement || (showChevron && (
|
||||
<View style={styles.chevron}>
|
||||
<Ionicons name="chevron-forward" size={18} color={colors.textTertiary} />
|
||||
</View>
|
||||
))}
|
||||
<View style={styles.rightSection}>
|
||||
{rightText && (
|
||||
<Text style={styles.rightText} numberOfLines={1}>{rightText}</Text>
|
||||
)}
|
||||
{rightElement || (showChevron && (
|
||||
<View style={styles.chevron}>
|
||||
<Ionicons name="chevron-forward" size={18} color={colors.textTertiary} />
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
</TouchableOpacity>
|
||||
);
|
||||
}
|
||||
@@ -113,15 +131,43 @@ const styles = StyleSheet.create({
|
||||
textContainer: {
|
||||
flex: 1,
|
||||
},
|
||||
labelRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm,
|
||||
},
|
||||
label: {
|
||||
...typography.body,
|
||||
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: {
|
||||
...typography.caption1,
|
||||
color: colors.textSecondary,
|
||||
marginTop: 2,
|
||||
},
|
||||
rightSection: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm,
|
||||
},
|
||||
rightText: {
|
||||
...typography.subhead,
|
||||
color: colors.textSecondary,
|
||||
maxWidth: 120,
|
||||
},
|
||||
chevron: {
|
||||
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 { SwitchItem } from './SwitchItem';
|
||||
|
||||
@@ -16,16 +19,132 @@ export const NotificationSettings: React.FC<NotificationSettingsProps> = ({
|
||||
onPushToggle,
|
||||
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 (
|
||||
<SettingsSection title="Notifications" delay={delay}>
|
||||
{/* Master push toggle */}
|
||||
<SwitchItem
|
||||
icon="notifications-outline"
|
||||
label="Push Notifications"
|
||||
description="Get alerts for matches and messages"
|
||||
description="Enable notifications on this device"
|
||||
value={pushEnabled}
|
||||
onValueChange={onPushToggle}
|
||||
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>
|
||||
);
|
||||
};
|
||||
|
||||
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 { View, Text, StyleSheet } from 'react-native';
|
||||
import React, { useState } from 'react';
|
||||
import { View, Text, StyleSheet, TouchableOpacity, Modal, Platform, ScrollView } from 'react-native';
|
||||
import { LinearGradient } from 'expo-linear-gradient';
|
||||
import { BlurView } from 'expo-blur';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { SettingsSection } from './SettingsSection';
|
||||
import { SwitchItem } from './SwitchItem';
|
||||
import { IntensitySlider } from '../../../components/ui/IntensitySlider';
|
||||
import { GlassButton } from '../../../components/ui';
|
||||
import { colors, spacing, radius, typography, featureColors } from '../../../theme';
|
||||
import type { IntensityLevel } from '../../../types';
|
||||
|
||||
@@ -38,7 +40,10 @@ export const PrivacySettings: React.FC<PrivacySettingsProps> = ({
|
||||
partnerName,
|
||||
partnerAvatar,
|
||||
}) => {
|
||||
const [showHelpModal, setShowHelpModal] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsSection title="Preferences" delay={375}>
|
||||
{/* Comfort Zone Header */}
|
||||
<View style={styles.comfortHeader}>
|
||||
@@ -51,7 +56,16 @@ export const PrivacySettings: React.FC<PrivacySettingsProps> = ({
|
||||
<Ionicons name="heart-circle" size={22} color={colors.text} />
|
||||
</LinearGradient>
|
||||
<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}>
|
||||
Set the intimacy level you're comfortable with
|
||||
</Text>
|
||||
@@ -85,6 +99,100 @@ export const PrivacySettings: React.FC<PrivacySettingsProps> = ({
|
||||
</>
|
||||
)}
|
||||
</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}>Partner Sync</Text>
|
||||
<Text style={styles.helpItemDescription}>
|
||||
Questions only appear when both of you are comfortable. The lower setting between you and your partner determines what you both see.
|
||||
</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: {
|
||||
flex: 1,
|
||||
},
|
||||
comfortLabelRow: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.xs,
|
||||
},
|
||||
comfortLabel: {
|
||||
...typography.headline,
|
||||
color: colors.text,
|
||||
@@ -122,4 +235,71 @@ const styles = StyleSheet.create({
|
||||
backgroundColor: colors.glass.border,
|
||||
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 { PrivacySettings } from './PrivacySettings';
|
||||
export { DangerZone } from './DangerZone';
|
||||
export { RelationshipDangerZone } from './RelationshipDangerZone';
|
||||
export { ResetProgress } from './ResetProgress';
|
||||
|
||||
export { SettingsSection } from './SettingsSection';
|
||||
@@ -18,3 +19,5 @@ export { MenuItem } from './MenuItem';
|
||||
export type { MenuItemProps } from './MenuItem';
|
||||
export { SubscriptionCard } 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';
|
||||
@@ -179,6 +179,18 @@ export function flush() {
|
||||
if (__DEV__) console.log("[Analytics] Flush called (no-op for Firebase)");
|
||||
}
|
||||
|
||||
function truncateParam(value: string, maxLength = 100) {
|
||||
if (value.length <= maxLength) return value;
|
||||
return value.slice(0, maxLength - 1) + "…";
|
||||
}
|
||||
|
||||
function safeParam(value: unknown, maxLength = 100): string {
|
||||
if (typeof value === "string") return truncateParam(value, maxLength);
|
||||
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
||||
if (value instanceof Error) return truncateParam(value.message, maxLength);
|
||||
return value == null ? "" : truncateParam(String(value), maxLength);
|
||||
}
|
||||
|
||||
// Pre-defined event helpers for common actions
|
||||
export const Events = {
|
||||
// Auth events
|
||||
@@ -212,8 +224,21 @@ export const Events = {
|
||||
|
||||
// Subscription events
|
||||
paywallShown: (source: string) => logEvent("paywall_shown", { source }),
|
||||
paywallClosed: (source: string, reason: string) =>
|
||||
logEvent("paywall_closed", { source, reason }),
|
||||
purchaseInitiated: (packageType: string) =>
|
||||
logEvent("purchase_initiated", { package_type: packageType }),
|
||||
purchaseCancelled: (packageType: string, errorCode?: unknown) =>
|
||||
logEvent("purchase_cancelled", {
|
||||
package_type: packageType,
|
||||
error_code: safeParam(errorCode) || "unknown",
|
||||
}),
|
||||
purchaseFailed: (packageType: string, errorCode?: unknown, errorMessage?: unknown) =>
|
||||
logEvent("purchase_failed", {
|
||||
package_type: packageType,
|
||||
error_code: safeParam(errorCode) || "unknown",
|
||||
error_message: safeParam(errorMessage),
|
||||
}),
|
||||
purchaseCompleted: (packageType: string) =>
|
||||
logEvent("purchase", { package_type: packageType }),
|
||||
purchaseRestored: () => logEvent("purchase_restored"),
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import * as Notifications from 'expo-notifications';
|
||||
import { Platform } from 'react-native';
|
||||
|
||||
/**
|
||||
* App icon badge management utility.
|
||||
* Sets the badge count on the app icon (iOS only, Android has no equivalent).
|
||||
*/
|
||||
|
||||
/**
|
||||
* Set the app icon badge count.
|
||||
* On iOS, this updates the red badge number on the app icon.
|
||||
* On Android, this is a no-op as Android doesn't have app icon badges.
|
||||
*/
|
||||
export async function setBadgeCount(count: number): Promise<void> {
|
||||
// Badge count is iOS only
|
||||
if (Platform.OS !== 'ios') {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await Notifications.setBadgeCountAsync(Math.max(0, count));
|
||||
} catch (error) {
|
||||
console.error('Failed to set badge count:', error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the app icon badge.
|
||||
*/
|
||||
export async function clearBadge(): Promise<void> {
|
||||
await setBadgeCount(0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current badge count.
|
||||
*/
|
||||
export async function getBadgeCount(): Promise<number> {
|
||||
if (Platform.OS !== 'ios') {
|
||||
return 0;
|
||||
}
|
||||
|
||||
try {
|
||||
return await Notifications.getBadgeCountAsync();
|
||||
} catch (error) {
|
||||
console.error('Failed to get badge count:', error);
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync badge count with the total of new matches and unread messages.
|
||||
* This should be called whenever either count changes.
|
||||
*/
|
||||
export async function syncBadgeCount(newMatchesCount: number, unreadMessagesCount: number): Promise<void> {
|
||||
const total = newMatchesCount + unreadMessagesCount;
|
||||
await setBadgeCount(total);
|
||||
}
|
||||
@@ -17,6 +17,7 @@ interface AuthState {
|
||||
refreshPartner: () => Promise<Profile | null>;
|
||||
signOut: () => Promise<void>;
|
||||
setUser: (user: Profile | null) => void;
|
||||
updateLastActive: () => Promise<void>;
|
||||
}
|
||||
|
||||
// Import other stores lazily to avoid circular dependency issues
|
||||
@@ -25,7 +26,9 @@ const getOtherStores = () => {
|
||||
const { usePacksStore } = require("./packsStore");
|
||||
const { useMessageStore } = require("./messageStore");
|
||||
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
|
||||
set({ user: null, couple: null, partner: null, isAuthenticated: false, isLoading: false });
|
||||
// Clear other stores
|
||||
const { useMatchStore, usePacksStore, useMessageStore, useSubscriptionStore } = getOtherStores();
|
||||
const { useMatchStore, usePacksStore, useMessageStore, useSubscriptionStore, useNotificationPreferencesStore, useStreakStore } = getOtherStores();
|
||||
useMatchStore.getState().clearMatches();
|
||||
usePacksStore.getState().clearPacks();
|
||||
useMessageStore.getState().clearMessages();
|
||||
useSubscriptionStore.getState().clearSubscription();
|
||||
useNotificationPreferencesStore.getState().clearPreferences();
|
||||
useStreakStore.getState().clearStreak();
|
||||
// Sign out from Supabase (clears storage)
|
||||
await supabase.auth.signOut();
|
||||
return;
|
||||
@@ -145,11 +150,17 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
isLoading: false,
|
||||
});
|
||||
// Clear other stores
|
||||
const { useMatchStore, usePacksStore, useMessageStore, useSubscriptionStore } = getOtherStores();
|
||||
const { useMatchStore, usePacksStore, useMessageStore, useSubscriptionStore, useNotificationPreferencesStore, useStreakStore } = getOtherStores();
|
||||
useMatchStore.getState().clearMatches();
|
||||
usePacksStore.getState().clearPacks();
|
||||
useMessageStore.getState().clearMessages();
|
||||
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)
|
||||
try {
|
||||
@@ -169,11 +180,36 @@ export const useAuthStore = create<AuthState>((set, get) => ({
|
||||
});
|
||||
// Clear other stores when user signs out
|
||||
if (user === null) {
|
||||
const { useMatchStore, usePacksStore, useMessageStore, useSubscriptionStore } = getOtherStores();
|
||||
const { useMatchStore, usePacksStore, useMessageStore, useSubscriptionStore, useNotificationPreferencesStore, useStreakStore } = getOtherStores();
|
||||
useMatchStore.getState().clearMatches();
|
||||
usePacksStore.getState().clearPacks();
|
||||
useMessageStore.getState().clearMessages();
|
||||
useSubscriptionStore.getState().clearSubscription();
|
||||
useNotificationPreferencesStore.getState().clearPreferences();
|
||||
useStreakStore.getState().clearStreak();
|
||||
// Clear badge on sign out
|
||||
const { clearBadge } = require("../lib/badge");
|
||||
clearBadge();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Update the user's last_active_at timestamp.
|
||||
* Called when the app comes to the foreground to track user activity.
|
||||
* This helps prevent sending notifications to users who are already in the app.
|
||||
*/
|
||||
updateLastActive: async () => {
|
||||
const userId = get().user?.id;
|
||||
if (!userId) return;
|
||||
|
||||
try {
|
||||
await supabase
|
||||
.from("profiles")
|
||||
.update({ last_active_at: new Date().toISOString() })
|
||||
.eq("id", userId);
|
||||
} catch (error) {
|
||||
// Silently fail - this is not critical
|
||||
console.error("Failed to update last_active_at:", error);
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -6,3 +6,7 @@ export { useMessageStore } from "./messageStore";
|
||||
export { useSubscriptionStore } from "./subscriptionStore";
|
||||
export { useResponsesStore, groupResponses } 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 });
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,60 @@
|
||||
import { create } from "zustand";
|
||||
import { supabase } from "../lib/supabase";
|
||||
import { useAuthStore } from "./authStore";
|
||||
|
||||
export interface CoupleStreak {
|
||||
couple_id: string;
|
||||
current_streak: number;
|
||||
longest_streak: number;
|
||||
last_active_date: string | null;
|
||||
user1_answered_today: boolean;
|
||||
user2_answered_today: boolean;
|
||||
streak_celebrated_at: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
interface StreakState {
|
||||
streak: CoupleStreak | null;
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
|
||||
// Actions
|
||||
fetchStreak: () => Promise<void>;
|
||||
clearStreak: () => void;
|
||||
}
|
||||
|
||||
export const useStreakStore = create<StreakState>((set) => ({
|
||||
streak: null,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
|
||||
fetchStreak: async () => {
|
||||
const coupleId = useAuthStore.getState().user?.couple_id;
|
||||
if (!coupleId) {
|
||||
set({ streak: null, isLoading: false });
|
||||
return;
|
||||
}
|
||||
|
||||
set({ isLoading: true, error: null });
|
||||
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from("couple_streaks")
|
||||
.select("*")
|
||||
.eq("couple_id", coupleId)
|
||||
.maybeSingle();
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
set({ streak: data, isLoading: false });
|
||||
} catch (error) {
|
||||
console.error("Error fetching streak:", error);
|
||||
set({ error: "Failed to load streak", isLoading: false });
|
||||
}
|
||||
},
|
||||
|
||||
clearStreak: () => {
|
||||
set({ streak: null, isLoading: false, error: null });
|
||||
},
|
||||
}));
|
||||
@@ -7,6 +7,13 @@ import revenueCatService, {
|
||||
} from "../lib/revenuecat";
|
||||
import { useAuthStore } from "./authStore";
|
||||
|
||||
export type PurchaseResult = {
|
||||
success: boolean;
|
||||
cancelled?: boolean;
|
||||
errorCode?: unknown;
|
||||
errorMessage?: unknown;
|
||||
};
|
||||
|
||||
interface SubscriptionStoreState {
|
||||
subscription: SubscriptionState;
|
||||
offerings: PurchasesOffering | null;
|
||||
@@ -17,7 +24,7 @@ interface SubscriptionStoreState {
|
||||
// Actions
|
||||
initializeRevenueCat: (userId: string) => Promise<void>;
|
||||
fetchOfferings: () => Promise<void>;
|
||||
purchasePackage: (pkg: PurchasesPackage) => Promise<boolean>;
|
||||
purchasePackage: (pkg: PurchasesPackage) => Promise<PurchaseResult>;
|
||||
restorePurchases: () => Promise<boolean>;
|
||||
refreshSubscriptionStatus: () => Promise<void>;
|
||||
clearSubscription: () => void;
|
||||
@@ -131,14 +138,24 @@ export const useSubscriptionStore = create<SubscriptionStoreState>((set, get) =>
|
||||
// Refresh user profile to get updated is_premium
|
||||
await useAuthStore.getState().fetchUser();
|
||||
|
||||
return true;
|
||||
return { success: true };
|
||||
} catch (error: any) {
|
||||
const message =
|
||||
error.message === "Purchase cancelled"
|
||||
? "Purchase was cancelled"
|
||||
: "Purchase failed. Please try again.";
|
||||
const isCancelled =
|
||||
error?.userCancelled === true ||
|
||||
error?.message === "Purchase cancelled" ||
|
||||
error?.code === "PURCHASE_CANCELLED";
|
||||
|
||||
const message = isCancelled
|
||||
? "Purchase was cancelled"
|
||||
: "Purchase failed. Please try again.";
|
||||
|
||||
set({ error: message, isPurchasing: false });
|
||||
return false;
|
||||
return {
|
||||
success: false,
|
||||
cancelled: isCancelled,
|
||||
errorCode: error?.code,
|
||||
errorMessage: error?.message,
|
||||
};
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -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,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
|
||||
-- ============================================================================
|
||||
CREATE EXTENSION IF NOT EXISTS "uuid-ossp" WITH SCHEMA extensions;
|
||||
|
||||
-- ============================================================================
|
||||
-- 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 admin_role AS ENUM ('pack_creator', 'super_admin');
|
||||
CREATE TYPE audit_action AS ENUM ('INSERT', 'UPDATE', 'DELETE');
|
||||
|
||||
-- ============================================================================
|
||||
-- TABLES
|
||||
-- ============================================================================
|
||||
@@ -27,7 +25,6 @@ CREATE TABLE couples (
|
||||
invite_code TEXT UNIQUE DEFAULT substr(md5(random()::text), 1, 8),
|
||||
created_at TIMESTAMPTZ DEFAULT now()
|
||||
);
|
||||
|
||||
-- Profiles table (extends auth.users)
|
||||
CREATE TABLE profiles (
|
||||
id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||
@@ -40,11 +37,11 @@ CREATE TABLE profiles (
|
||||
updated_at TIMESTAMPTZ DEFAULT now(),
|
||||
email TEXT,
|
||||
gender TEXT,
|
||||
show_explicit_content BOOLEAN DEFAULT false,
|
||||
show_explicit_content BOOLEAN DEFAULT true,
|
||||
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']))
|
||||
);
|
||||
|
||||
-- Categories table
|
||||
CREATE TABLE categories (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
@@ -54,7 +51,6 @@ CREATE TABLE categories (
|
||||
sort_order INTEGER DEFAULT 0,
|
||||
created_at TIMESTAMPTZ DEFAULT now()
|
||||
);
|
||||
|
||||
-- Question packs table
|
||||
CREATE TABLE question_packs (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
@@ -68,7 +64,6 @@ CREATE TABLE question_packs (
|
||||
category_id UUID REFERENCES categories(id),
|
||||
is_explicit BOOLEAN NOT NULL DEFAULT false
|
||||
);
|
||||
|
||||
-- Questions table
|
||||
CREATE TABLE questions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
@@ -80,10 +75,8 @@ CREATE TABLE questions (
|
||||
allowed_couple_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.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
|
||||
CREATE TABLE responses (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
@@ -94,7 +87,6 @@ CREATE TABLE responses (
|
||||
created_at TIMESTAMPTZ DEFAULT now(),
|
||||
UNIQUE(user_id, question_id)
|
||||
);
|
||||
|
||||
-- Matches table
|
||||
CREATE TABLE matches (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
@@ -105,7 +97,6 @@ CREATE TABLE matches (
|
||||
created_at TIMESTAMPTZ DEFAULT now(),
|
||||
UNIQUE(couple_id, question_id)
|
||||
);
|
||||
|
||||
-- Messages table
|
||||
CREATE TABLE messages (
|
||||
id UUID PRIMARY KEY DEFAULT extensions.uuid_generate_v4(),
|
||||
@@ -119,7 +110,6 @@ CREATE TABLE messages (
|
||||
delivered_at TIMESTAMPTZ,
|
||||
CONSTRAINT content_or_media CHECK (content IS NOT NULL OR media_path IS NOT NULL)
|
||||
);
|
||||
|
||||
-- Couple packs table (which packs a couple has enabled)
|
||||
CREATE TABLE couple_packs (
|
||||
couple_id UUID NOT NULL REFERENCES couples(id) ON DELETE CASCADE,
|
||||
@@ -128,7 +118,6 @@ CREATE TABLE couple_packs (
|
||||
created_at TIMESTAMPTZ DEFAULT now(),
|
||||
PRIMARY KEY (couple_id, pack_id)
|
||||
);
|
||||
|
||||
-- Feedback table
|
||||
CREATE TABLE feedback (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
@@ -144,7 +133,6 @@ CREATE TABLE feedback (
|
||||
updated_at TIMESTAMPTZ DEFAULT now(),
|
||||
question_id UUID REFERENCES questions(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
-- Subscriptions table
|
||||
CREATE TABLE subscriptions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
@@ -164,7 +152,6 @@ CREATE TABLE subscriptions (
|
||||
updated_at TIMESTAMPTZ DEFAULT now(),
|
||||
UNIQUE(user_id, original_transaction_id)
|
||||
);
|
||||
|
||||
-- RevenueCat webhook events table (for idempotency)
|
||||
CREATE TABLE revenuecat_webhook_events (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
@@ -174,7 +161,6 @@ CREATE TABLE revenuecat_webhook_events (
|
||||
processed_at TIMESTAMPTZ DEFAULT now(),
|
||||
payload JSONB
|
||||
);
|
||||
|
||||
-- Admin users table
|
||||
CREATE TABLE admin_users (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
@@ -184,7 +170,6 @@ CREATE TABLE admin_users (
|
||||
created_by UUID REFERENCES auth.users(id),
|
||||
updated_at TIMESTAMPTZ DEFAULT now()
|
||||
);
|
||||
|
||||
-- Audit logs table
|
||||
CREATE TABLE audit_logs (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
@@ -198,7 +183,6 @@ CREATE TABLE audit_logs (
|
||||
admin_role admin_role NOT NULL,
|
||||
created_at TIMESTAMPTZ DEFAULT now()
|
||||
);
|
||||
|
||||
-- ============================================================================
|
||||
-- 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_action ON audit_logs(action);
|
||||
CREATE INDEX idx_audit_logs_created_at ON audit_logs(created_at DESC);
|
||||
|
||||
-- ============================================================================
|
||||
-- FUNCTIONS
|
||||
-- ============================================================================
|
||||
@@ -240,7 +223,6 @@ SET search_path = ''
|
||||
AS $$
|
||||
SELECT couple_id FROM public.profiles WHERE id = auth.uid();
|
||||
$$;
|
||||
|
||||
-- Check if user is any admin
|
||||
CREATE OR REPLACE FUNCTION is_admin(check_user_id UUID DEFAULT auth.uid())
|
||||
RETURNS BOOLEAN
|
||||
@@ -255,7 +237,6 @@ BEGIN
|
||||
);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Check if user is super admin
|
||||
CREATE OR REPLACE FUNCTION is_super_admin(check_user_id UUID DEFAULT auth.uid())
|
||||
RETURNS BOOLEAN
|
||||
@@ -271,7 +252,6 @@ BEGIN
|
||||
);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Get admin role for user
|
||||
CREATE OR REPLACE FUNCTION get_admin_role(check_user_id UUID DEFAULT auth.uid())
|
||||
RETURNS admin_role
|
||||
@@ -287,7 +267,6 @@ BEGIN
|
||||
RETURN user_role;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Check if user or partner has premium access
|
||||
CREATE OR REPLACE FUNCTION has_premium_access(check_user_id UUID)
|
||||
RETURNS BOOLEAN
|
||||
@@ -322,7 +301,6 @@ BEGIN
|
||||
RETURN COALESCE(partner_premium, FALSE);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Get recommended questions for a user
|
||||
CREATE OR REPLACE FUNCTION get_recommended_questions(target_pack_id UUID DEFAULT NULL)
|
||||
RETURNS TABLE(
|
||||
@@ -447,7 +425,6 @@ BEGIN
|
||||
);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Get pack teaser questions (bypasses premium for preview)
|
||||
CREATE OR REPLACE FUNCTION get_pack_teaser_questions(target_pack_id UUID)
|
||||
RETURNS TABLE(id UUID, text TEXT, intensity INTEGER)
|
||||
@@ -469,7 +446,6 @@ BEGIN
|
||||
LIMIT 3;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Handle new user signup (create profile)
|
||||
CREATE OR REPLACE FUNCTION handle_new_user()
|
||||
RETURNS TRIGGER
|
||||
@@ -488,7 +464,6 @@ BEGIN
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Update updated_at timestamp
|
||||
CREATE OR REPLACE FUNCTION update_updated_at()
|
||||
RETURNS TRIGGER
|
||||
@@ -500,7 +475,6 @@ BEGIN
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Check couple size (max 2 members)
|
||||
CREATE OR REPLACE FUNCTION check_couple_size()
|
||||
RETURNS TRIGGER
|
||||
@@ -516,7 +490,6 @@ BEGIN
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Sync premium status from subscriptions
|
||||
CREATE OR REPLACE FUNCTION sync_premium_status()
|
||||
RETURNS TRIGGER
|
||||
@@ -537,7 +510,6 @@ BEGIN
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Disable premium packs when subscription is lost
|
||||
CREATE OR REPLACE FUNCTION disable_premium_packs_on_subscription_loss()
|
||||
RETURNS TRIGGER
|
||||
@@ -566,7 +538,6 @@ BEGIN
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Log admin actions
|
||||
CREATE OR REPLACE FUNCTION log_admin_action(
|
||||
p_table_name TEXT,
|
||||
@@ -611,7 +582,6 @@ BEGIN
|
||||
RETURN log_id;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Notify on new message (calls edge function)
|
||||
CREATE OR REPLACE FUNCTION notify_new_message()
|
||||
RETURNS TRIGGER
|
||||
@@ -634,7 +604,6 @@ BEGIN
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- ============================================================================
|
||||
-- TRIGGERS
|
||||
-- ============================================================================
|
||||
@@ -643,41 +612,33 @@ $$;
|
||||
CREATE TRIGGER update_profiles_updated_at
|
||||
BEFORE UPDATE ON profiles
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
|
||||
|
||||
CREATE TRIGGER enforce_couple_size
|
||||
BEFORE INSERT OR UPDATE OF couple_id ON profiles
|
||||
FOR EACH ROW EXECUTE FUNCTION check_couple_size();
|
||||
|
||||
CREATE TRIGGER disable_premium_packs_on_sub_loss
|
||||
AFTER UPDATE OF is_premium ON profiles
|
||||
FOR EACH ROW
|
||||
WHEN (OLD.is_premium IS DISTINCT FROM NEW.is_premium)
|
||||
EXECUTE FUNCTION disable_premium_packs_on_subscription_loss();
|
||||
|
||||
-- Subscription triggers
|
||||
CREATE TRIGGER update_subscriptions_updated_at
|
||||
BEFORE UPDATE ON subscriptions
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
|
||||
|
||||
CREATE TRIGGER sync_premium_on_subscription_change
|
||||
AFTER INSERT OR UPDATE ON subscriptions
|
||||
FOR EACH ROW EXECUTE FUNCTION sync_premium_status();
|
||||
|
||||
-- Admin users trigger
|
||||
CREATE TRIGGER update_admin_users_updated_at
|
||||
BEFORE UPDATE ON admin_users
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
|
||||
|
||||
-- Message notification trigger
|
||||
CREATE TRIGGER on_new_message
|
||||
AFTER INSERT ON messages
|
||||
FOR EACH ROW EXECUTE FUNCTION notify_new_message();
|
||||
|
||||
-- Auth trigger to create profile on signup
|
||||
CREATE TRIGGER on_auth_user_created
|
||||
AFTER INSERT ON auth.users
|
||||
FOR EACH ROW EXECUTE FUNCTION handle_new_user();
|
||||
|
||||
-- ============================================================================
|
||||
-- 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 admin_users ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE audit_logs ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- ============================================================================
|
||||
-- RLS POLICIES: COUPLES
|
||||
-- ============================================================================
|
||||
CREATE POLICY "Authenticated users can create couples"
|
||||
ON couples FOR INSERT
|
||||
WITH CHECK (auth.role() = 'authenticated');
|
||||
|
||||
CREATE POLICY "Users can view own couple"
|
||||
ON couples FOR SELECT
|
||||
USING (id = get_auth_user_couple_id());
|
||||
|
||||
CREATE POLICY "Users can lookup couple by exact invite code"
|
||||
ON couples FOR SELECT
|
||||
USING (id = get_auth_user_couple_id());
|
||||
|
||||
CREATE POLICY "Super admins can view all couples"
|
||||
ON couples FOR SELECT
|
||||
USING (is_super_admin());
|
||||
|
||||
-- ============================================================================
|
||||
-- RLS POLICIES: PROFILES
|
||||
-- ============================================================================
|
||||
CREATE POLICY "Users can view own profile"
|
||||
ON profiles FOR SELECT
|
||||
USING (auth.uid() = id);
|
||||
|
||||
CREATE POLICY "Users can view partner profile"
|
||||
ON profiles FOR SELECT
|
||||
USING (couple_id IS NOT NULL AND couple_id = get_auth_user_couple_id());
|
||||
|
||||
CREATE POLICY "Users can update own profile"
|
||||
ON profiles FOR UPDATE
|
||||
USING (auth.uid() = id);
|
||||
|
||||
CREATE POLICY "Super admins can view all profiles"
|
||||
ON profiles FOR SELECT
|
||||
USING (is_super_admin());
|
||||
|
||||
CREATE POLICY "Super admins can update all profiles"
|
||||
ON profiles FOR UPDATE
|
||||
USING (is_super_admin());
|
||||
|
||||
-- ============================================================================
|
||||
-- RLS POLICIES: CATEGORIES
|
||||
-- ============================================================================
|
||||
CREATE POLICY "Categories are viewable by everyone"
|
||||
ON categories FOR SELECT
|
||||
USING (true);
|
||||
|
||||
CREATE POLICY "Admins can view all categories"
|
||||
ON categories FOR SELECT
|
||||
USING (is_admin());
|
||||
|
||||
CREATE POLICY "Admins can insert categories"
|
||||
ON categories FOR INSERT
|
||||
WITH CHECK (is_admin());
|
||||
|
||||
CREATE POLICY "Admins can update categories"
|
||||
ON categories FOR UPDATE
|
||||
USING (is_admin())
|
||||
WITH CHECK (is_admin());
|
||||
|
||||
CREATE POLICY "Admins can delete categories"
|
||||
ON categories FOR DELETE
|
||||
USING (is_admin());
|
||||
|
||||
-- ============================================================================
|
||||
-- RLS POLICIES: QUESTION PACKS
|
||||
-- ============================================================================
|
||||
CREATE POLICY "Anyone can view public packs"
|
||||
ON question_packs FOR SELECT
|
||||
USING (is_public = true);
|
||||
|
||||
CREATE POLICY "Premium users can view premium packs"
|
||||
ON question_packs FOR SELECT
|
||||
USING (is_premium = false OR EXISTS (
|
||||
SELECT 1 FROM profiles WHERE profiles.id = auth.uid() AND profiles.is_premium = true
|
||||
));
|
||||
|
||||
CREATE POLICY "Admins can view all question packs"
|
||||
ON question_packs FOR SELECT
|
||||
USING (is_admin());
|
||||
|
||||
CREATE POLICY "Admins can insert question packs"
|
||||
ON question_packs FOR INSERT
|
||||
WITH CHECK (is_admin());
|
||||
|
||||
CREATE POLICY "Admins can update question packs"
|
||||
ON question_packs FOR UPDATE
|
||||
USING (is_admin());
|
||||
|
||||
CREATE POLICY "Admins can delete question packs"
|
||||
ON question_packs FOR DELETE
|
||||
USING (is_admin());
|
||||
|
||||
-- ============================================================================
|
||||
-- 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
|
||||
))
|
||||
));
|
||||
|
||||
CREATE POLICY "Admins can view all questions"
|
||||
ON questions FOR SELECT
|
||||
USING (is_admin());
|
||||
|
||||
CREATE POLICY "Admins can insert questions"
|
||||
ON questions FOR INSERT
|
||||
WITH CHECK (is_admin());
|
||||
|
||||
CREATE POLICY "Admins can update questions"
|
||||
ON questions FOR UPDATE
|
||||
USING (is_admin());
|
||||
|
||||
CREATE POLICY "Admins can delete questions"
|
||||
ON questions FOR DELETE
|
||||
USING (is_admin());
|
||||
|
||||
-- ============================================================================
|
||||
-- RLS POLICIES: RESPONSES
|
||||
-- ============================================================================
|
||||
@@ -827,42 +762,33 @@ CREATE POLICY "Users can view couple responses"
|
||||
USING (user_id = auth.uid() OR couple_id IN (
|
||||
SELECT couple_id FROM profiles WHERE id = auth.uid()
|
||||
));
|
||||
|
||||
CREATE POLICY "Users can insert own responses"
|
||||
ON responses FOR INSERT
|
||||
WITH CHECK (user_id = auth.uid() AND couple_id = get_auth_user_couple_id());
|
||||
|
||||
CREATE POLICY "Users can update own responses"
|
||||
ON responses FOR UPDATE
|
||||
USING (user_id = auth.uid());
|
||||
|
||||
CREATE POLICY "Users can delete own responses"
|
||||
ON responses FOR DELETE
|
||||
USING (user_id = auth.uid());
|
||||
|
||||
CREATE POLICY "Super admins can view all responses"
|
||||
ON responses FOR SELECT
|
||||
USING (is_super_admin());
|
||||
|
||||
-- ============================================================================
|
||||
-- RLS POLICIES: MATCHES
|
||||
-- ============================================================================
|
||||
CREATE POLICY "Users can view couple matches"
|
||||
ON matches FOR SELECT
|
||||
USING (couple_id = get_auth_user_couple_id());
|
||||
|
||||
CREATE POLICY "Users can update match seen status"
|
||||
ON matches FOR UPDATE
|
||||
USING (couple_id = get_auth_user_couple_id());
|
||||
|
||||
CREATE POLICY "Users can delete own couple matches"
|
||||
ON matches FOR DELETE
|
||||
USING (couple_id = get_auth_user_couple_id());
|
||||
|
||||
CREATE POLICY "Super admins can view all matches"
|
||||
ON matches FOR SELECT
|
||||
USING (is_super_admin());
|
||||
|
||||
-- ============================================================================
|
||||
-- RLS POLICIES: MESSAGES
|
||||
-- ============================================================================
|
||||
@@ -873,7 +799,6 @@ CREATE POLICY "Users can view messages in their matches"
|
||||
WHERE m.id = messages.match_id
|
||||
AND m.couple_id IN (SELECT couple_id FROM profiles WHERE id = auth.uid())
|
||||
));
|
||||
|
||||
CREATE POLICY "Users can insert messages in their matches"
|
||||
ON messages FOR INSERT
|
||||
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
|
||||
AND m.couple_id IN (SELECT couple_id FROM profiles WHERE id = auth.uid())
|
||||
));
|
||||
|
||||
CREATE POLICY "Users can mark messages as read"
|
||||
ON messages FOR UPDATE
|
||||
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())
|
||||
))
|
||||
WITH CHECK (auth.uid() <> user_id);
|
||||
|
||||
CREATE POLICY "Users can delete own messages"
|
||||
ON messages FOR DELETE
|
||||
USING (user_id = auth.uid());
|
||||
|
||||
CREATE POLICY "Super admins can view all messages"
|
||||
ON messages FOR SELECT
|
||||
USING (is_super_admin());
|
||||
|
||||
-- ============================================================================
|
||||
-- RLS POLICIES: COUPLE PACKS
|
||||
-- ============================================================================
|
||||
@@ -908,67 +829,54 @@ CREATE POLICY "Couples can view their own pack settings"
|
||||
SELECT 1 FROM profiles
|
||||
WHERE profiles.id = auth.uid() AND profiles.couple_id = couple_packs.couple_id
|
||||
));
|
||||
|
||||
CREATE POLICY "Couples can modify their own pack settings"
|
||||
ON couple_packs FOR ALL
|
||||
USING (EXISTS (
|
||||
SELECT 1 FROM profiles
|
||||
WHERE profiles.id = auth.uid() AND profiles.couple_id = couple_packs.couple_id
|
||||
));
|
||||
|
||||
CREATE POLICY "Users can delete own couple pack settings"
|
||||
ON couple_packs FOR DELETE
|
||||
USING (EXISTS (
|
||||
SELECT 1 FROM profiles
|
||||
WHERE profiles.id = auth.uid() AND profiles.couple_id = couple_packs.couple_id
|
||||
));
|
||||
|
||||
-- ============================================================================
|
||||
-- RLS POLICIES: FEEDBACK
|
||||
-- ============================================================================
|
||||
CREATE POLICY "Users can create feedback"
|
||||
ON feedback FOR INSERT
|
||||
WITH CHECK (auth.uid() = user_id);
|
||||
|
||||
CREATE POLICY "Users can view own feedback"
|
||||
ON feedback FOR SELECT
|
||||
USING (auth.uid() = user_id);
|
||||
|
||||
CREATE POLICY "Users can update own pending feedback"
|
||||
ON feedback FOR UPDATE
|
||||
USING (auth.uid() = user_id AND status = 'new');
|
||||
|
||||
CREATE POLICY "Super admins can view all feedback"
|
||||
ON feedback FOR SELECT
|
||||
USING (is_super_admin());
|
||||
|
||||
CREATE POLICY "Super admins can update all feedback"
|
||||
ON feedback FOR UPDATE
|
||||
USING (is_super_admin());
|
||||
|
||||
-- ============================================================================
|
||||
-- RLS POLICIES: SUBSCRIPTIONS
|
||||
-- ============================================================================
|
||||
CREATE POLICY "Users can view own subscriptions"
|
||||
ON subscriptions FOR SELECT
|
||||
USING (auth.uid() = user_id);
|
||||
|
||||
CREATE POLICY "Super admins can view all subscriptions"
|
||||
ON subscriptions FOR SELECT
|
||||
USING (is_super_admin());
|
||||
|
||||
CREATE POLICY "Super admins can insert subscriptions"
|
||||
ON subscriptions FOR INSERT
|
||||
WITH CHECK (is_super_admin());
|
||||
|
||||
CREATE POLICY "Super admins can update subscriptions"
|
||||
ON subscriptions FOR UPDATE
|
||||
USING (is_super_admin());
|
||||
|
||||
CREATE POLICY "Super admins can delete subscriptions"
|
||||
ON subscriptions FOR DELETE
|
||||
USING (is_super_admin());
|
||||
|
||||
-- ============================================================================
|
||||
-- RLS POLICIES: REVENUECAT WEBHOOK EVENTS
|
||||
-- ============================================================================
|
||||
@@ -981,14 +889,12 @@ CREATE POLICY "Super admins can delete subscriptions"
|
||||
CREATE POLICY "Admin users can read own record"
|
||||
ON admin_users FOR SELECT
|
||||
USING (auth.uid() = user_id);
|
||||
|
||||
-- ============================================================================
|
||||
-- RLS POLICIES: AUDIT LOGS
|
||||
-- ============================================================================
|
||||
CREATE POLICY "Super admins can view audit logs"
|
||||
ON audit_logs FOR SELECT
|
||||
USING (is_super_admin());
|
||||
|
||||
-- ============================================================================
|
||||
-- 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 messages;
|
||||
ALTER PUBLICATION supabase_realtime ADD TABLE couple_packs;
|
||||
|
||||
-- ============================================================================
|
||||
-- STORAGE BUCKETS
|
||||
-- ============================================================================
|
||||
INSERT INTO storage.buckets (id, name, public)
|
||||
VALUES ('feedback-screenshots', 'feedback-screenshots', false)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
INSERT INTO storage.buckets (id, name, public)
|
||||
VALUES ('chat-media', 'chat-media', false)
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
-- ============================================================================
|
||||
-- STORAGE POLICIES
|
||||
-- ============================================================================
|
||||
@@ -1019,14 +922,12 @@ CREATE POLICY "Users can upload feedback screenshots"
|
||||
bucket_id = 'feedback-screenshots'
|
||||
AND (storage.foldername(name))[1] = auth.uid()::text
|
||||
);
|
||||
|
||||
CREATE POLICY "Users can view own feedback screenshots"
|
||||
ON storage.objects FOR SELECT TO authenticated
|
||||
USING (
|
||||
bucket_id = 'feedback-screenshots'
|
||||
AND (storage.foldername(name))[1] = auth.uid()::text
|
||||
);
|
||||
|
||||
-- Chat media policies
|
||||
-- IMPORTANT: Use storage.objects.name (not just "name") to avoid ambiguity with profiles.name
|
||||
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()
|
||||
)
|
||||
);
|
||||
|
||||
CREATE POLICY "Users can upload chat media to their matches"
|
||||
ON storage.objects FOR INSERT TO authenticated
|
||||
WITH CHECK (
|
||||
@@ -1052,7 +952,6 @@ CREATE POLICY "Users can upload chat media to their matches"
|
||||
AND p.id = auth.uid()
|
||||
)
|
||||
);
|
||||
|
||||
CREATE POLICY "Users can delete their own chat media"
|
||||
ON storage.objects FOR DELETE TO authenticated
|
||||
USING (
|
||||
@@ -1064,7 +963,6 @@ CREATE POLICY "Users can delete their own chat media"
|
||||
AND p.id = auth.uid()
|
||||
)
|
||||
);
|
||||
|
||||
CREATE POLICY "Admins can view all chat media"
|
||||
ON storage.objects FOR SELECT TO authenticated
|
||||
USING (
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
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 delete their own chat media" ON storage.objects;
|
||||
|
||||
-- Recreate with explicit table qualification to avoid column name ambiguity
|
||||
CREATE POLICY "Users can upload chat media to their matches"
|
||||
ON storage.objects FOR INSERT TO authenticated
|
||||
@@ -20,7 +19,6 @@ CREATE POLICY "Users can upload chat media to their matches"
|
||||
AND p.id = auth.uid()
|
||||
)
|
||||
);
|
||||
|
||||
CREATE POLICY "Users can view chat media in their matches"
|
||||
ON storage.objects FOR SELECT TO authenticated
|
||||
USING (
|
||||
@@ -32,7 +30,6 @@ CREATE POLICY "Users can view chat media in their matches"
|
||||
AND p.id = auth.uid()
|
||||
)
|
||||
);
|
||||
|
||||
CREATE POLICY "Users can delete their own chat media"
|
||||
ON storage.objects FOR DELETE TO authenticated
|
||||
USING (
|
||||
|
||||
@@ -22,17 +22,13 @@ CREATE TABLE IF NOT EXISTS ai_config (
|
||||
updated_at timestamptz DEFAULT now(),
|
||||
updated_by uuid REFERENCES auth.users(id)
|
||||
);
|
||||
|
||||
-- Only allow one row in this table (singleton pattern)
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS ai_config_singleton ON ai_config ((true));
|
||||
|
||||
-- Insert default config row
|
||||
INSERT INTO ai_config (id) VALUES (gen_random_uuid())
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- Enable RLS
|
||||
ALTER TABLE ai_config ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Only super admins can read/write (checked via admin_users table)
|
||||
CREATE POLICY "Super admins can read ai_config"
|
||||
ON ai_config FOR SELECT
|
||||
@@ -43,7 +39,6 @@ CREATE POLICY "Super admins can read ai_config"
|
||||
AND admin_users.role = 'super_admin'
|
||||
)
|
||||
);
|
||||
|
||||
CREATE POLICY "Super admins can update ai_config"
|
||||
ON ai_config FOR UPDATE
|
||||
USING (
|
||||
@@ -53,7 +48,6 @@ CREATE POLICY "Super admins can update ai_config"
|
||||
AND admin_users.role = 'super_admin'
|
||||
)
|
||||
);
|
||||
|
||||
-- Create function to update the updated_at timestamp
|
||||
CREATE OR REPLACE FUNCTION update_ai_config_timestamp()
|
||||
RETURNS TRIGGER AS $$
|
||||
@@ -63,13 +57,11 @@ BEGIN
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||
|
||||
-- Create trigger to auto-update timestamp
|
||||
DROP TRIGGER IF EXISTS ai_config_updated_at ON ai_config;
|
||||
CREATE TRIGGER ai_config_updated_at
|
||||
BEFORE UPDATE ON ai_config
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_ai_config_timestamp();
|
||||
|
||||
-- Add comment for documentation
|
||||
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)
|
||||
ALTER TABLE ai_config
|
||||
ADD COLUMN IF NOT EXISTS council_generators jsonb;
|
||||
|
||||
-- Set default value based on existing council_generator_model or default
|
||||
UPDATE ai_config
|
||||
SET council_generators = jsonb_build_array(
|
||||
jsonb_build_object('model', COALESCE(council_generator_model, 'anthropic/claude-3.5-sonnet'))
|
||||
)
|
||||
WHERE council_generators IS NULL;
|
||||
|
||||
-- Set the default for new rows
|
||||
ALTER TABLE ai_config
|
||||
ALTER COLUMN council_generators SET DEFAULT '[{"model": "anthropic/claude-3.5-sonnet"}]'::jsonb;
|
||||
|
||||
-- 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.';
|
||||
@@ -12,37 +12,29 @@ CREATE TABLE IF NOT EXISTS app_config (
|
||||
updated_at timestamptz DEFAULT now(),
|
||||
updated_by uuid REFERENCES auth.users(id)
|
||||
);
|
||||
|
||||
-- Only allow one row in this table (singleton pattern)
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS app_config_singleton ON app_config ((true));
|
||||
|
||||
-- Insert default config row
|
||||
INSERT INTO app_config (id) VALUES (gen_random_uuid())
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- Enable RLS
|
||||
ALTER TABLE app_config ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- All authenticated users can read (needed for mobile app)
|
||||
CREATE POLICY "Authenticated users can read app_config"
|
||||
ON app_config FOR SELECT
|
||||
USING (auth.role() = 'authenticated');
|
||||
|
||||
-- Only super admins can update
|
||||
CREATE POLICY "Super admins can update app_config"
|
||||
ON app_config FOR UPDATE
|
||||
USING (is_super_admin());
|
||||
|
||||
-- Create trigger to auto-update timestamp (reuses existing function)
|
||||
CREATE TRIGGER app_config_updated_at
|
||||
BEFORE UPDATE ON app_config
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_updated_at();
|
||||
|
||||
-- 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 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
|
||||
-- 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);
|
||||
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.';
|
||||
@@ -163,5 +163,4 @@ BEGIN
|
||||
);
|
||||
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.';
|
||||
@@ -3,19 +3,14 @@
|
||||
|
||||
-- Add media type to distinguish images from videos
|
||||
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)
|
||||
ALTER TABLE messages ADD COLUMN media_expires_at TIMESTAMPTZ;
|
||||
|
||||
-- Track if media was deleted due to expiration
|
||||
ALTER TABLE messages ADD COLUMN media_expired BOOLEAN DEFAULT false;
|
||||
|
||||
-- Backfill existing media as images
|
||||
UPDATE messages SET media_type = 'image' WHERE media_path IS NOT NULL;
|
||||
|
||||
-- Enable pg_cron extension for scheduled cleanup
|
||||
CREATE EXTENSION IF NOT EXISTS pg_cron WITH SCHEMA extensions;
|
||||
|
||||
-- Create cleanup function that handles both DB updates and storage deletion tracking
|
||||
CREATE OR REPLACE FUNCTION cleanup_expired_videos()
|
||||
RETURNS TABLE(deleted_count int, deleted_paths text[]) AS $$
|
||||
@@ -49,7 +44,6 @@ BEGIN
|
||||
RETURN QUERY SELECT count, COALESCE(expired_paths, ARRAY[]::text[]);
|
||||
END;
|
||||
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||
|
||||
-- Schedule daily cleanup at 3 AM UTC
|
||||
-- The database function marks videos as expired, edge function handles storage deletion
|
||||
SELECT cron.schedule(
|
||||
|
||||
@@ -30,29 +30,24 @@ BEGIN
|
||||
RETURN v_permissions @> jsonb_build_array(check_permission);
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Update RLS policies to use permissions
|
||||
|
||||
-- Profiles
|
||||
CREATE POLICY "Admins with view_users permission can view all profiles"
|
||||
ON profiles FOR SELECT
|
||||
USING (has_permission('view_users'));
|
||||
|
||||
-- Messages
|
||||
CREATE POLICY "Admins with view_chats permission can view all messages"
|
||||
ON messages FOR SELECT
|
||||
USING (has_permission('view_chats'));
|
||||
|
||||
-- Responses
|
||||
CREATE POLICY "Admins with view_responses permission can view all responses"
|
||||
ON responses FOR SELECT
|
||||
USING (has_permission('view_responses'));
|
||||
|
||||
-- Matches
|
||||
CREATE POLICY "Admins with view_matches permission can view all matches"
|
||||
ON matches FOR SELECT
|
||||
USING (has_permission('view_matches'));
|
||||
|
||||
-- Audit Logs
|
||||
CREATE POLICY "Admins with view_audit_logs permission can view audit logs"
|
||||
ON audit_logs FOR SELECT
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
CREATE POLICY "Admins with manage_feedback permission can view all feedback"
|
||||
ON feedback FOR SELECT
|
||||
USING (has_permission('manage_feedback'));
|
||||
|
||||
CREATE POLICY "Admins with manage_feedback permission can update all feedback"
|
||||
ON feedback FOR UPDATE
|
||||
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"
|
||||
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"
|
||||
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"
|
||||
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"
|
||||
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 (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||
feature_name TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE (user_id, feature_name)
|
||||
id UUID DEFAULT gen_random_uuid() PRIMARY KEY,
|
||||
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
|
||||
feature_name TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW() NOT NULL,
|
||||
|
||||
-- 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);
|
||||
|
||||
-- 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;
|
||||
|
||||
CREATE POLICY "Users can view their own interests"
|
||||
ON feature_interests FOR SELECT
|
||||
USING (auth.uid() = user_id);
|
||||
|
||||
-- Users can insert their own interest
|
||||
CREATE POLICY "Users can express interest in features"
|
||||
ON feature_interests FOR INSERT
|
||||
WITH CHECK (auth.uid() = user_id);
|
||||
ON feature_interests
|
||||
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"
|
||||
ON feature_interests FOR DELETE
|
||||
USING (auth.uid() = user_id);
|
||||
ON feature_interests
|
||||
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;
|
||||
END;
|
||||
$$;
|
||||
|
||||
GRANT EXECUTE ON FUNCTION public.get_feature_interest_counts() TO authenticated;
|
||||
@@ -3,20 +3,15 @@
|
||||
|
||||
-- Version indicator: 1 = plaintext (legacy), 2 = E2EE
|
||||
ALTER TABLE messages ADD COLUMN IF NOT EXISTS version INTEGER DEFAULT 1;
|
||||
|
||||
-- Encrypted message content (base64 encoded ciphertext)
|
||||
ALTER TABLE messages ADD COLUMN IF NOT EXISTS encrypted_content TEXT;
|
||||
|
||||
-- Initialization vector for AES-GCM (base64 encoded, 12 bytes)
|
||||
ALTER TABLE messages ADD COLUMN IF NOT EXISTS encryption_iv TEXT;
|
||||
|
||||
-- Triple-wrapped encryption keys and metadata
|
||||
ALTER TABLE messages ADD COLUMN IF NOT EXISTS keys_metadata JSONB;
|
||||
|
||||
-- Add comment for documentation
|
||||
COMMENT ON COLUMN messages.version IS
|
||||
'Encryption version: 1 = plaintext (legacy), 2 = E2EE with triple-wrapped keys';
|
||||
|
||||
-- Constraint: v2 messages must have encryption fields
|
||||
DO $$
|
||||
BEGIN
|
||||
|
||||
@@ -3,10 +3,8 @@
|
||||
|
||||
-- RSA public key in JWK format (JSON Web Key)
|
||||
ALTER TABLE profiles ADD COLUMN IF NOT EXISTS public_key_jwk JSONB;
|
||||
|
||||
-- Index for efficient lookups when encrypting for partner
|
||||
CREATE INDEX IF NOT EXISTS idx_profiles_couple_id_public_key ON profiles(couple_id)
|
||||
WHERE public_key_jwk IS NOT NULL;
|
||||
|
||||
COMMENT ON COLUMN profiles.public_key_jwk IS
|
||||
'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,
|
||||
is_active BOOLEAN DEFAULT true
|
||||
);
|
||||
|
||||
-- Add table comment
|
||||
COMMENT ON TABLE master_keys IS
|
||||
'Stores admin RSA public keys for E2EE moderation access. Private key stored securely in environment variables.';
|
||||
|
||||
-- Enable RLS
|
||||
ALTER TABLE master_keys ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- Super admins can view master keys
|
||||
DO $$
|
||||
BEGIN
|
||||
@@ -29,7 +26,6 @@ BEGIN
|
||||
USING (is_super_admin(auth.uid()));
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- Super admins can insert master keys
|
||||
DO $$
|
||||
BEGIN
|
||||
@@ -42,7 +38,6 @@ BEGIN
|
||||
WITH CHECK (is_super_admin(auth.uid()));
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- Super admins can update master keys
|
||||
DO $$
|
||||
BEGIN
|
||||
@@ -55,7 +50,6 @@ BEGIN
|
||||
USING (is_super_admin(auth.uid()));
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- Allow all authenticated users to read active master key public keys (needed for encryption)
|
||||
DO $$
|
||||
BEGIN
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
-- E2EE messages have encrypted_content instead of content
|
||||
|
||||
ALTER TABLE public.messages DROP CONSTRAINT IF EXISTS content_or_media;
|
||||
|
||||
ALTER TABLE public.messages ADD CONSTRAINT content_or_media CHECK (
|
||||
-- v1 (plaintext): must have content or media_path
|
||||
(version = 1 AND (content IS NOT NULL OR media_path IS NOT NULL))
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
ALTER TABLE messages
|
||||
ADD COLUMN moderation_status text DEFAULT 'unmoderated' CHECK (moderation_status IN ('unmoderated', 'safe', 'flagged')),
|
||||
ADD COLUMN flag_reason text;
|
||||
|
||||
-- Add classifier configuration to ai_config table
|
||||
ALTER TABLE ai_config
|
||||
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"}.
|
||||
|
||||
Response must be valid JSON.';
|
||||
|
||||
-- Function to trigger classification
|
||||
CREATE OR REPLACE FUNCTION trigger_classify_message()
|
||||
RETURNS TRIGGER
|
||||
@@ -54,13 +52,11 @@ BEGIN
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Create trigger on messages table
|
||||
CREATE TRIGGER on_message_created_classify
|
||||
AFTER INSERT ON messages
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION trigger_classify_message();
|
||||
|
||||
-- Add comments
|
||||
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).';
|
||||
@@ -1,9 +1,7 @@
|
||||
-- Add category column to messages table
|
||||
ALTER TABLE messages
|
||||
ADD COLUMN category text;
|
||||
|
||||
COMMENT ON COLUMN messages.category IS 'Content category: Neutral, Romantic, Playful, Explicit, etc.';
|
||||
|
||||
-- Update the default classifier prompt in 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.
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
-- Add deleted_at column to messages table for "delete for everyone"
|
||||
ALTER TABLE messages ADD COLUMN deleted_at TIMESTAMPTZ;
|
||||
|
||||
-- Create message_deletions table for "delete for self" (per-user deletions)
|
||||
CREATE TABLE message_deletions (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
@@ -9,21 +8,17 @@ CREATE TABLE message_deletions (
|
||||
deleted_at TIMESTAMPTZ DEFAULT now(),
|
||||
UNIQUE(message_id, user_id)
|
||||
);
|
||||
|
||||
-- Index for efficient lookup when filtering messages
|
||||
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;
|
||||
|
||||
-- Enable RLS on message_deletions
|
||||
ALTER TABLE message_deletions ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- RLS Policies for message_deletions
|
||||
|
||||
-- Users can view their own deletions (needed for filtering)
|
||||
CREATE POLICY "Users can view own deletions"
|
||||
ON message_deletions FOR SELECT
|
||||
USING (user_id = auth.uid());
|
||||
|
||||
-- Users can insert their own deletions (for messages in their matches)
|
||||
CREATE POLICY "Users can delete messages for self"
|
||||
ON message_deletions FOR INSERT
|
||||
@@ -37,16 +32,13 @@ CREATE POLICY "Users can delete messages for self"
|
||||
AND p.id = auth.uid()
|
||||
)
|
||||
);
|
||||
|
||||
-- Update messages RLS policy to allow authors to set deleted_at
|
||||
CREATE POLICY "Authors can soft delete messages for everyone"
|
||||
ON messages FOR UPDATE
|
||||
USING (user_id = auth.uid())
|
||||
WITH CHECK (user_id = auth.uid());
|
||||
|
||||
-- Add to realtime for live updates
|
||||
ALTER PUBLICATION supabase_realtime ADD TABLE message_deletions;
|
||||
|
||||
-- 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 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 TYPE report_reason AS ENUM ('harassment', 'spam', 'inappropriate_content', 'other');
|
||||
|
||||
-- Create message_reports table
|
||||
CREATE TABLE message_reports (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
@@ -15,22 +14,18 @@ CREATE TABLE message_reports (
|
||||
-- Prevent duplicate reports from same user
|
||||
UNIQUE(message_id, reporter_id)
|
||||
);
|
||||
|
||||
-- Indexes for efficient queries
|
||||
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_message ON message_reports(message_id);
|
||||
|
||||
-- Enable RLS
|
||||
ALTER TABLE message_reports ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- RLS Policies
|
||||
|
||||
-- Users can view their own reports
|
||||
CREATE POLICY "Users can view own reports"
|
||||
ON message_reports FOR SELECT
|
||||
USING (reporter_id = auth.uid());
|
||||
|
||||
-- Users can create reports for messages in their couple's matches (but NOT their own messages)
|
||||
CREATE POLICY "Users can report partner messages"
|
||||
ON message_reports FOR INSERT
|
||||
@@ -45,20 +40,16 @@ CREATE POLICY "Users can report partner messages"
|
||||
AND m.user_id != auth.uid() -- Cannot report own messages
|
||||
)
|
||||
);
|
||||
|
||||
-- Admins can view all reports
|
||||
CREATE POLICY "Admins can view all reports"
|
||||
ON message_reports FOR SELECT
|
||||
USING (is_admin());
|
||||
|
||||
-- Admins can update reports (for review status)
|
||||
CREATE POLICY "Admins can update reports"
|
||||
ON message_reports FOR UPDATE
|
||||
USING (is_admin());
|
||||
|
||||
-- Add to realtime for live updates in admin
|
||||
ALTER PUBLICATION supabase_realtime ADD TABLE message_reports;
|
||||
|
||||
-- Comments
|
||||
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).';
|
||||
|
||||
@@ -3,19 +3,16 @@
|
||||
ALTER TABLE profiles
|
||||
ADD COLUMN IF NOT EXISTS max_intensity INTEGER DEFAULT 2
|
||||
CHECK (max_intensity >= 1 AND max_intensity <= 5);
|
||||
|
||||
UPDATE profiles
|
||||
SET max_intensity = CASE
|
||||
WHEN show_explicit_content IS TRUE THEN 5
|
||||
ELSE 2
|
||||
END
|
||||
WHERE max_intensity IS NULL;
|
||||
|
||||
ALTER TABLE question_packs
|
||||
ADD COLUMN IF NOT EXISTS min_intensity INTEGER,
|
||||
ADD COLUMN IF NOT EXISTS max_intensity INTEGER,
|
||||
ADD COLUMN IF NOT EXISTS avg_intensity NUMERIC(3,2);
|
||||
|
||||
CREATE OR REPLACE FUNCTION update_question_pack_intensity_stats(target_pack_id UUID)
|
||||
RETURNS void
|
||||
LANGUAGE plpgsql
|
||||
@@ -46,7 +43,6 @@ BEGIN
|
||||
END IF;
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION handle_question_intensity_change()
|
||||
RETURNS TRIGGER
|
||||
LANGUAGE plpgsql
|
||||
@@ -66,13 +62,10 @@ BEGIN
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
DROP TRIGGER IF EXISTS question_pack_intensity_stats ON questions;
|
||||
|
||||
CREATE TRIGGER question_pack_intensity_stats
|
||||
AFTER INSERT OR UPDATE OR DELETE ON questions
|
||||
FOR EACH ROW EXECUTE FUNCTION handle_question_intensity_change();
|
||||
|
||||
UPDATE question_packs qp
|
||||
SET min_intensity = stats.min_intensity,
|
||||
max_intensity = stats.max_intensity,
|
||||
|
||||
@@ -169,5 +169,4 @@ BEGIN
|
||||
);
|
||||
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.';
|
||||
@@ -1,14 +1,12 @@
|
||||
-- Add admin_username column to audit_logs table
|
||||
ALTER TABLE audit_logs
|
||||
ADD COLUMN admin_username TEXT;
|
||||
|
||||
-- Backfill existing audit logs with admin names from profiles
|
||||
UPDATE audit_logs
|
||||
SET admin_username = COALESCE(p.name, p.email, 'Unknown')
|
||||
FROM profiles p
|
||||
WHERE audit_logs.admin_user_id = p.id
|
||||
AND audit_logs.admin_username IS NULL;
|
||||
|
||||
-- Update the log_admin_action function to populate admin_username from profiles
|
||||
CREATE OR REPLACE FUNCTION log_admin_action(
|
||||
p_table_name TEXT,
|
||||
|
||||
@@ -13,7 +13,6 @@ CREATE TYPE dare_status AS ENUM (
|
||||
'declined', -- Recipient declined
|
||||
'cancelled' -- Sender cancelled before completion
|
||||
);
|
||||
|
||||
-- ============================================================================
|
||||
-- TABLES
|
||||
-- ============================================================================
|
||||
@@ -34,17 +33,14 @@ CREATE TABLE dare_packs (
|
||||
avg_intensity NUMERIC(3,2),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
COMMENT ON TABLE dare_packs IS 'Collections of dares organized by theme or intensity level';
|
||||
COMMENT ON COLUMN dare_packs.min_intensity IS 'Auto-calculated minimum intensity from dares in this pack';
|
||||
COMMENT ON COLUMN dare_packs.max_intensity IS 'Auto-calculated maximum intensity from dares in this pack';
|
||||
COMMENT ON COLUMN dare_packs.avg_intensity IS 'Auto-calculated average intensity from dares in this pack';
|
||||
|
||||
-- Create indexes for dare_packs
|
||||
CREATE INDEX idx_dare_packs_sort_order ON dare_packs(sort_order);
|
||||
CREATE INDEX idx_dare_packs_visibility ON dare_packs(is_public, is_premium);
|
||||
CREATE INDEX idx_dare_packs_category ON dare_packs(category_id);
|
||||
|
||||
-- Dares table (individual dare content)
|
||||
CREATE TABLE dares (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
@@ -54,15 +50,12 @@ CREATE TABLE dares (
|
||||
suggested_duration_hours INTEGER,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
COMMENT ON TABLE dares IS 'Individual dares within dare packs';
|
||||
COMMENT ON COLUMN dares.intensity IS 'Difficulty/spiciness level from 1 (mild) to 5 (intense)';
|
||||
COMMENT ON COLUMN dares.suggested_duration_hours IS 'Suggested time to complete the dare in hours (null = no suggestion)';
|
||||
|
||||
-- Create indexes for dares
|
||||
CREATE INDEX idx_dares_pack_id ON dares(pack_id);
|
||||
CREATE INDEX idx_dares_intensity ON dares(intensity);
|
||||
|
||||
-- Sent dares table (tracks dare instances between partners)
|
||||
CREATE TABLE sent_dares (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
@@ -81,21 +74,18 @@ CREATE TABLE sent_dares (
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
CONSTRAINT dare_or_custom CHECK (dare_id IS NOT NULL OR custom_dare_text IS NOT NULL)
|
||||
);
|
||||
|
||||
COMMENT ON TABLE sent_dares IS 'Dare instances sent between partners in a couple';
|
||||
COMMENT ON COLUMN sent_dares.dare_id IS 'Reference to the dare from a pack (null for custom dares)';
|
||||
COMMENT ON COLUMN sent_dares.custom_dare_text IS 'Text for custom dares created by premium users';
|
||||
COMMENT ON COLUMN sent_dares.custom_dare_intensity IS 'Intensity for custom dares (1-5)';
|
||||
COMMENT ON COLUMN sent_dares.expires_at IS 'Deadline for completing the dare (null = no time limit)';
|
||||
COMMENT ON COLUMN sent_dares.sender_notes IS 'Optional message from sender when sending the dare';
|
||||
|
||||
-- Create indexes for sent_dares
|
||||
CREATE INDEX idx_sent_dares_couple_id ON sent_dares(couple_id);
|
||||
CREATE INDEX idx_sent_dares_sender_id ON sent_dares(sender_id);
|
||||
CREATE INDEX idx_sent_dares_recipient_id ON sent_dares(recipient_id);
|
||||
CREATE INDEX idx_sent_dares_status ON sent_dares(status);
|
||||
CREATE INDEX idx_sent_dares_expires_at ON sent_dares(expires_at) WHERE expires_at IS NOT NULL AND status = 'active';
|
||||
|
||||
-- Dare messages table (chat for each dare)
|
||||
CREATE TABLE dare_messages (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
@@ -105,13 +95,10 @@ CREATE TABLE dare_messages (
|
||||
read_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
COMMENT ON TABLE dare_messages IS 'Chat messages associated with each sent dare';
|
||||
|
||||
-- Create indexes for dare_messages
|
||||
CREATE INDEX idx_dare_messages_sent_dare_id ON dare_messages(sent_dare_id);
|
||||
CREATE INDEX idx_dare_messages_created_at ON dare_messages(sent_dare_id, created_at);
|
||||
|
||||
-- ============================================================================
|
||||
-- TRIGGERS
|
||||
-- ============================================================================
|
||||
@@ -136,12 +123,10 @@ BEGIN
|
||||
RETURN COALESCE(NEW, OLD);
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Create trigger on dares table
|
||||
CREATE TRIGGER dare_intensity_stats_trigger
|
||||
AFTER INSERT OR UPDATE OR DELETE ON dares
|
||||
FOR EACH ROW EXECUTE FUNCTION update_dare_pack_intensity_stats();
|
||||
|
||||
-- ============================================================================
|
||||
-- STATS FUNCTIONS
|
||||
-- ============================================================================
|
||||
@@ -163,7 +148,6 @@ BEGIN
|
||||
(SELECT COUNT(*) FROM sent_dares WHERE recipient_id = p_user_id AND status = 'completed')::BIGINT;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||
|
||||
-- Function to get dare stats for a couple
|
||||
CREATE OR REPLACE FUNCTION get_couple_dare_stats(p_couple_id UUID)
|
||||
RETURNS TABLE (
|
||||
@@ -179,7 +163,6 @@ BEGIN
|
||||
(SELECT COUNT(*) FROM sent_dares WHERE couple_id = p_couple_id AND status = 'active')::BIGINT;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql SECURITY DEFINER;
|
||||
|
||||
-- ============================================================================
|
||||
-- ROW LEVEL SECURITY
|
||||
-- ============================================================================
|
||||
@@ -189,7 +172,6 @@ ALTER TABLE dare_packs ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE dares ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE sent_dares ENABLE ROW LEVEL SECURITY;
|
||||
ALTER TABLE dare_messages ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- -------------------------
|
||||
-- dare_packs policies
|
||||
-- -------------------------
|
||||
@@ -198,7 +180,6 @@ ALTER TABLE dare_messages ENABLE ROW LEVEL SECURITY;
|
||||
CREATE POLICY "Public dare packs are viewable by everyone"
|
||||
ON dare_packs FOR SELECT
|
||||
USING (is_public = true);
|
||||
|
||||
-- Admins can view all dare packs
|
||||
CREATE POLICY "Admins can view all dare packs"
|
||||
ON dare_packs FOR SELECT
|
||||
@@ -207,7 +188,6 @@ USING (
|
||||
SELECT 1 FROM admin_users WHERE user_id = auth.uid()
|
||||
)
|
||||
);
|
||||
|
||||
-- Admins can insert dare packs
|
||||
CREATE POLICY "Admins can insert dare packs"
|
||||
ON dare_packs FOR INSERT
|
||||
@@ -216,7 +196,6 @@ WITH CHECK (
|
||||
SELECT 1 FROM admin_users WHERE user_id = auth.uid()
|
||||
)
|
||||
);
|
||||
|
||||
-- Admins can update dare packs
|
||||
CREATE POLICY "Admins can update dare packs"
|
||||
ON dare_packs FOR UPDATE
|
||||
@@ -225,7 +204,6 @@ USING (
|
||||
SELECT 1 FROM admin_users WHERE user_id = auth.uid()
|
||||
)
|
||||
);
|
||||
|
||||
-- Admins can delete dare packs
|
||||
CREATE POLICY "Admins can delete dare packs"
|
||||
ON dare_packs FOR DELETE
|
||||
@@ -234,7 +212,6 @@ USING (
|
||||
SELECT 1 FROM admin_users WHERE user_id = auth.uid()
|
||||
)
|
||||
);
|
||||
|
||||
-- -------------------------
|
||||
-- dares policies
|
||||
-- -------------------------
|
||||
@@ -253,7 +230,6 @@ USING (
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
-- Admins can view all dares
|
||||
CREATE POLICY "Admins can view all dares"
|
||||
ON dares FOR SELECT
|
||||
@@ -262,7 +238,6 @@ USING (
|
||||
SELECT 1 FROM admin_users WHERE user_id = auth.uid()
|
||||
)
|
||||
);
|
||||
|
||||
-- Admins can insert dares
|
||||
CREATE POLICY "Admins can insert dares"
|
||||
ON dares FOR INSERT
|
||||
@@ -271,7 +246,6 @@ WITH CHECK (
|
||||
SELECT 1 FROM admin_users WHERE user_id = auth.uid()
|
||||
)
|
||||
);
|
||||
|
||||
-- Admins can update dares
|
||||
CREATE POLICY "Admins can update dares"
|
||||
ON dares FOR UPDATE
|
||||
@@ -280,7 +254,6 @@ USING (
|
||||
SELECT 1 FROM admin_users WHERE user_id = auth.uid()
|
||||
)
|
||||
);
|
||||
|
||||
-- Admins can delete dares
|
||||
CREATE POLICY "Admins can delete dares"
|
||||
ON dares FOR DELETE
|
||||
@@ -289,7 +262,6 @@ USING (
|
||||
SELECT 1 FROM admin_users WHERE user_id = auth.uid()
|
||||
)
|
||||
);
|
||||
|
||||
-- -------------------------
|
||||
-- sent_dares policies
|
||||
-- -------------------------
|
||||
@@ -298,7 +270,6 @@ USING (
|
||||
CREATE POLICY "Users can view their couple's sent dares"
|
||||
ON sent_dares FOR SELECT
|
||||
USING (couple_id = get_auth_user_couple_id());
|
||||
|
||||
-- Users can insert sent dares (sender must be the authenticated user)
|
||||
CREATE POLICY "Users can send dares to their partner"
|
||||
ON sent_dares FOR INSERT
|
||||
@@ -306,12 +277,10 @@ WITH CHECK (
|
||||
sender_id = auth.uid()
|
||||
AND couple_id = get_auth_user_couple_id()
|
||||
);
|
||||
|
||||
-- Users can update sent dares in their couple
|
||||
CREATE POLICY "Users can update their couple's sent dares"
|
||||
ON sent_dares FOR UPDATE
|
||||
USING (couple_id = get_auth_user_couple_id());
|
||||
|
||||
-- Admins can view all sent dares
|
||||
CREATE POLICY "Admins can view all sent dares"
|
||||
ON sent_dares FOR SELECT
|
||||
@@ -320,7 +289,6 @@ USING (
|
||||
SELECT 1 FROM admin_users WHERE user_id = auth.uid()
|
||||
)
|
||||
);
|
||||
|
||||
-- -------------------------
|
||||
-- dare_messages policies
|
||||
-- -------------------------
|
||||
@@ -335,7 +303,6 @@ USING (
|
||||
AND sd.couple_id = get_auth_user_couple_id()
|
||||
)
|
||||
);
|
||||
|
||||
-- Users can insert messages for their couple's dares
|
||||
CREATE POLICY "Users can send messages for their dares"
|
||||
ON dare_messages FOR INSERT
|
||||
@@ -347,7 +314,6 @@ WITH CHECK (
|
||||
AND sd.couple_id = get_auth_user_couple_id()
|
||||
)
|
||||
);
|
||||
|
||||
-- Users can update messages in their dares (for read_at)
|
||||
CREATE POLICY "Users can update message read status"
|
||||
ON dare_messages FOR UPDATE
|
||||
@@ -358,7 +324,6 @@ USING (
|
||||
AND sd.couple_id = get_auth_user_couple_id()
|
||||
)
|
||||
);
|
||||
|
||||
-- Admins can view all dare messages
|
||||
CREATE POLICY "Admins can view all dare messages"
|
||||
ON dare_messages FOR SELECT
|
||||
@@ -367,7 +332,6 @@ USING (
|
||||
SELECT 1 FROM admin_users WHERE user_id = auth.uid()
|
||||
)
|
||||
);
|
||||
|
||||
-- ============================================================================
|
||||
-- REALTIME
|
||||
-- ============================================================================
|
||||
|
||||
@@ -7,20 +7,16 @@ CREATE TABLE match_archives (
|
||||
archived_at TIMESTAMPTZ DEFAULT now(),
|
||||
UNIQUE(match_id, user_id)
|
||||
);
|
||||
|
||||
-- Index for efficient lookup when filtering matches
|
||||
CREATE INDEX idx_match_archives_user_match ON match_archives(user_id, match_id);
|
||||
|
||||
-- Enable RLS on match_archives
|
||||
ALTER TABLE match_archives ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- RLS Policies for match_archives
|
||||
|
||||
-- Users can view their own archives (needed for filtering)
|
||||
CREATE POLICY "Users can view own archives"
|
||||
ON match_archives FOR SELECT
|
||||
USING (user_id = auth.uid());
|
||||
|
||||
-- Users can insert their own archives (for matches in their couples)
|
||||
CREATE POLICY "Users can archive matches"
|
||||
ON match_archives FOR INSERT
|
||||
@@ -33,14 +29,11 @@ CREATE POLICY "Users can archive matches"
|
||||
AND p.id = auth.uid()
|
||||
)
|
||||
);
|
||||
|
||||
-- Users can delete their own archives (for unarchiving)
|
||||
CREATE POLICY "Users can unarchive matches"
|
||||
ON match_archives FOR DELETE
|
||||
USING (user_id = auth.uid());
|
||||
|
||||
-- Add to realtime for live updates
|
||||
ALTER PUBLICATION supabase_realtime ADD TABLE match_archives;
|
||||
|
||||
-- Documentation
|
||||
COMMENT ON TABLE match_archives IS 'Per-user match archives. Partner still sees the match. Delete record to unarchive.';
|
||||
@@ -1,13 +1,11 @@
|
||||
-- Add visibility toggle for categories
|
||||
ALTER TABLE public.categories
|
||||
ADD COLUMN IF NOT EXISTS is_public BOOLEAN NOT NULL DEFAULT true;
|
||||
|
||||
-- Categories: hide non-public categories from non-admins
|
||||
DROP POLICY IF EXISTS "Categories are viewable by everyone" ON public.categories;
|
||||
CREATE POLICY "Categories are viewable by everyone"
|
||||
ON public.categories FOR SELECT
|
||||
USING (is_public = true);
|
||||
|
||||
-- Question packs: require public categories for visibility
|
||||
DROP POLICY IF EXISTS "Anyone can view public packs" ON public.question_packs;
|
||||
CREATE POLICY "Anyone can view public packs"
|
||||
@@ -24,7 +22,6 @@ CREATE POLICY "Anyone can view public packs"
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
DROP POLICY IF EXISTS "Premium users can view premium packs" ON public.question_packs;
|
||||
CREATE POLICY "Premium users can view premium packs"
|
||||
ON public.question_packs FOR SELECT
|
||||
@@ -44,7 +41,6 @@ CREATE POLICY "Premium users can view premium packs"
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
-- Questions: require visible packs and public categories
|
||||
DROP POLICY IF EXISTS "Anyone can view questions in visible packs" ON public.questions;
|
||||
CREATE POLICY "Anyone can view questions in visible packs"
|
||||
@@ -71,7 +67,6 @@ CREATE POLICY "Anyone can view questions in visible packs"
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
-- Ensure recommendation queries honor category visibility
|
||||
CREATE OR REPLACE FUNCTION get_recommended_questions(target_pack_id UUID DEFAULT NULL)
|
||||
RETURNS TABLE(
|
||||
@@ -223,7 +218,6 @@ BEGIN
|
||||
);
|
||||
END;
|
||||
$$;
|
||||
|
||||
CREATE OR REPLACE FUNCTION get_pack_teaser_questions(target_pack_id UUID)
|
||||
RETURNS TABLE(id UUID, text TEXT, intensity INTEGER)
|
||||
LANGUAGE plpgsql
|
||||
|
||||
@@ -201,5 +201,4 @@ BEGIN
|
||||
);
|
||||
END;
|
||||
$$;
|
||||
|
||||
COMMENT ON FUNCTION get_recommended_questions(UUID) IS 'Returns recommended questions for user filtered by their intensity preference. When daily limit or answer gap threshold is exceeded for non-premium users, returns empty.';
|
||||
@@ -4,9 +4,7 @@
|
||||
-- Add daily_response_limit column to app_config
|
||||
ALTER TABLE app_config
|
||||
ADD COLUMN daily_response_limit INTEGER DEFAULT 0;
|
||||
|
||||
COMMENT ON COLUMN app_config.daily_response_limit IS 'Maximum number of questions a non-premium user can answer per UTC day. Set to 0 to disable daily limits.';
|
||||
|
||||
-- ============================================================================
|
||||
-- RPC FUNCTION: get_daily_response_limit_status
|
||||
-- Returns the current daily response limit status for the authenticated user
|
||||
@@ -73,5 +71,4 @@ BEGIN
|
||||
(v_count >= v_limit);
|
||||
END;
|
||||
$$;
|
||||
|
||||
COMMENT ON FUNCTION get_daily_response_limit_status() IS 'Returns daily response limit status for the current user. Premium users and disabled limits return is_blocked=false.';
|
||||
@@ -1,5 +1,4 @@
|
||||
ALTER TABLE public.questions
|
||||
ADD COLUMN required_props TEXT[];
|
||||
|
||||
COMMENT ON COLUMN public.questions.required_props IS
|
||||
'Array of props/accessories required to perform the activity.';
|
||||
@@ -224,5 +224,4 @@ BEGIN
|
||||
RANDOM();
|
||||
END;
|
||||
$$;
|
||||
|
||||
COMMENT ON FUNCTION get_recommended_questions(UUID) IS 'Returns recommended questions with partner-answered first, then randomized for variety. Uses NET gap calculation for blocking.';
|
||||
@@ -104,9 +104,7 @@ BEGIN
|
||||
RETURN QUERY SELECT v_net_gap, v_threshold, (v_net_gap >= v_threshold);
|
||||
END;
|
||||
$$;
|
||||
|
||||
COMMENT ON FUNCTION get_answer_gap_status() IS 'Returns answer gap status using NET gap calculation. Prevents deadlocks when both partners answer different questions.';
|
||||
|
||||
-- ============================================================================
|
||||
-- Part 2: Update get_recommended_questions() to prioritize partner-answered questions
|
||||
-- ============================================================================
|
||||
@@ -337,5 +335,4 @@ BEGIN
|
||||
q.intensity ASC;
|
||||
END;
|
||||
$$;
|
||||
|
||||
COMMENT ON FUNCTION get_recommended_questions(UUID) IS 'Returns recommended questions prioritizing partner-answered questions first to prevent deadlocks. Uses NET gap calculation for blocking.';
|
||||
@@ -4,16 +4,16 @@
|
||||
-- The "primary" question has inverse_of = NULL
|
||||
-- The "secondary" (inverse) question points to its primary
|
||||
|
||||
-- Add the column
|
||||
-- Add the columns
|
||||
ALTER TABLE questions
|
||||
ADD COLUMN inverse_of UUID REFERENCES questions(id) ON DELETE SET NULL;
|
||||
|
||||
-- Add a comment to document the column
|
||||
ALTER TABLE questions
|
||||
ADD COLUMN deleted_at TIMESTAMPTZ;
|
||||
-- Add comments to document the columns
|
||||
COMMENT ON COLUMN questions.inverse_of IS 'References the primary question that this question is an inverse of. Used to track question pairs (e.g., give/receive). Primary questions have inverse_of = NULL, secondary questions point to their primary.';
|
||||
|
||||
COMMENT ON COLUMN questions.deleted_at IS 'Soft delete timestamp. NULL means the question is active.';
|
||||
-- Create an index for efficient lookups
|
||||
CREATE INDEX idx_questions_inverse_of ON questions(inverse_of) WHERE inverse_of IS NOT NULL;
|
||||
|
||||
-- Create a function to get unique question count for a pack
|
||||
-- Unique count = questions that are NOT inverses of another question (i.e., inverse_of IS NULL)
|
||||
CREATE OR REPLACE FUNCTION get_pack_unique_question_count(pack_uuid UUID)
|
||||
@@ -27,10 +27,8 @@ AS $$
|
||||
AND inverse_of IS NULL
|
||||
AND deleted_at IS NULL;
|
||||
$$;
|
||||
|
||||
-- Grant execute permission to authenticated users
|
||||
GRANT EXECUTE ON FUNCTION get_pack_unique_question_count(UUID) TO authenticated;
|
||||
|
||||
-- Create a view to get pack stats including both total and unique counts
|
||||
CREATE OR REPLACE VIEW pack_question_stats AS
|
||||
SELECT
|
||||
@@ -40,6 +38,5 @@ SELECT
|
||||
COUNT(*) FILTER (WHERE inverse_of IS NOT NULL AND deleted_at IS NULL) AS inverse_questions
|
||||
FROM questions
|
||||
GROUP BY pack_id;
|
||||
|
||||
-- Grant select on the view
|
||||
GRANT SELECT ON pack_question_stats TO authenticated;
|
||||
@@ -15,7 +15,6 @@ CREATE INDEX IF NOT EXISTS idx_messages_user_id ON public.messages(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_redemption_codes_created_by ON public.redemption_codes(created_by);
|
||||
CREATE INDEX IF NOT EXISTS idx_responses_question_id ON public.responses(question_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_sent_dares_dare_id ON public.sent_dares(dare_id);
|
||||
|
||||
-- Drop unused indexes (identified by Supabase advisor as never used)
|
||||
-- These are just consuming storage with no benefit
|
||||
|
||||
|
||||
@@ -11,28 +11,22 @@ CREATE TABLE pending_pack_notifications (
|
||||
updated_at TIMESTAMPTZ DEFAULT now(),
|
||||
UNIQUE(couple_id) -- Only one pending notification per couple (enables UPSERT for timer reset)
|
||||
);
|
||||
|
||||
-- Index for efficient cron queries
|
||||
CREATE INDEX idx_pending_pack_notifications_notify_at
|
||||
ON pending_pack_notifications(notify_at);
|
||||
|
||||
-- Enable RLS
|
||||
ALTER TABLE pending_pack_notifications ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- RLS policies
|
||||
CREATE POLICY "Users can view their couple's pending notifications"
|
||||
ON pending_pack_notifications FOR SELECT
|
||||
USING (couple_id = get_auth_user_couple_id());
|
||||
|
||||
CREATE POLICY "Super admins can view all pending notifications"
|
||||
ON pending_pack_notifications FOR SELECT
|
||||
USING (is_super_admin());
|
||||
|
||||
-- Updated_at trigger
|
||||
CREATE TRIGGER update_pending_pack_notifications_updated_at
|
||||
BEFORE UPDATE ON pending_pack_notifications
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
|
||||
|
||||
-- Function to queue pack change notifications
|
||||
CREATE OR REPLACE FUNCTION queue_pack_change_notification()
|
||||
RETURNS TRIGGER
|
||||
@@ -80,13 +74,11 @@ BEGIN
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Create trigger on couple_packs
|
||||
CREATE TRIGGER on_couple_pack_enabled
|
||||
AFTER INSERT OR UPDATE OF enabled ON couple_packs
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION queue_pack_change_notification();
|
||||
|
||||
-- Schedule cron job (runs every 5 minutes)
|
||||
SELECT cron.schedule(
|
||||
'send-pack-change-notifications',
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
-- Create RPC function to get a single profile with auth info
|
||||
CREATE OR REPLACE FUNCTION get_profile_with_auth_info(user_id uuid)
|
||||
RETURNS TABLE (
|
||||
id uuid,
|
||||
name text,
|
||||
email text,
|
||||
avatar_url text,
|
||||
is_premium boolean,
|
||||
couple_id uuid,
|
||||
created_at timestamptz,
|
||||
gender text,
|
||||
usage_reason text,
|
||||
show_explicit_content boolean,
|
||||
onboarding_completed boolean,
|
||||
max_intensity smallint,
|
||||
last_sign_in_at timestamptz,
|
||||
email_confirmed_at timestamptz
|
||||
)
|
||||
LANGUAGE sql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
SELECT
|
||||
p.id,
|
||||
COALESCE(p.name, au.raw_user_meta_data->>'name')::text as name,
|
||||
COALESCE(p.email, au.email)::text as email,
|
||||
p.avatar_url::text,
|
||||
p.is_premium,
|
||||
p.couple_id,
|
||||
p.created_at,
|
||||
p.gender::text,
|
||||
p.usage_reason::text,
|
||||
p.show_explicit_content,
|
||||
p.onboarding_completed,
|
||||
p.max_intensity,
|
||||
au.last_sign_in_at,
|
||||
au.email_confirmed_at
|
||||
FROM profiles p
|
||||
LEFT JOIN auth.users au ON au.id = p.id
|
||||
WHERE p.id = user_id;
|
||||
$$;;
|
||||
@@ -0,0 +1,7 @@
|
||||
|
||||
-- Add RLS policy for admins with gift_premium permission to insert subscriptions
|
||||
CREATE POLICY "Admins with gift_premium can insert subscriptions"
|
||||
ON public.subscriptions
|
||||
FOR INSERT
|
||||
WITH CHECK (has_permission('gift_premium'));
|
||||
;
|
||||
@@ -0,0 +1,58 @@
|
||||
|
||||
-- Create a function that admins with gift_premium permission can use to gift premium
|
||||
-- This bypasses RLS issues by using SECURITY DEFINER and checking permissions internally
|
||||
CREATE OR REPLACE FUNCTION admin_gift_premium(
|
||||
target_user_id UUID,
|
||||
expires_at_param TIMESTAMPTZ DEFAULT NULL
|
||||
)
|
||||
RETURNS UUID
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = public
|
||||
AS $$
|
||||
DECLARE
|
||||
v_subscription_id UUID;
|
||||
v_caller_role admin_role;
|
||||
v_caller_permissions JSONB;
|
||||
BEGIN
|
||||
-- Check if caller has permission
|
||||
SELECT role, permissions INTO v_caller_role, v_caller_permissions
|
||||
FROM admin_users
|
||||
WHERE user_id = auth.uid();
|
||||
|
||||
-- Must be super_admin OR have gift_premium permission
|
||||
IF v_caller_role IS NULL THEN
|
||||
RAISE EXCEPTION 'Access denied: not an admin user';
|
||||
END IF;
|
||||
|
||||
IF v_caller_role != 'super_admin' AND NOT (v_caller_permissions @> '["gift_premium"]'::jsonb) THEN
|
||||
RAISE EXCEPTION 'Access denied: missing gift_premium permission';
|
||||
END IF;
|
||||
|
||||
-- Insert the subscription
|
||||
INSERT INTO subscriptions (
|
||||
user_id,
|
||||
revenuecat_app_user_id,
|
||||
product_id,
|
||||
status,
|
||||
store,
|
||||
purchased_at,
|
||||
expires_at
|
||||
) VALUES (
|
||||
target_user_id,
|
||||
'admin_grant',
|
||||
'admin_premium',
|
||||
'active',
|
||||
'manual',
|
||||
NOW(),
|
||||
expires_at_param
|
||||
)
|
||||
RETURNING id INTO v_subscription_id;
|
||||
|
||||
RETURN v_subscription_id;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Grant execute permission to authenticated users (the function checks permissions internally)
|
||||
GRANT EXECUTE ON FUNCTION admin_gift_premium(UUID, TIMESTAMPTZ) TO authenticated;
|
||||
;
|
||||
@@ -0,0 +1,74 @@
|
||||
-- Add notification preferences table
|
||||
-- Allows users to opt out of specific notification types (opt-out model, all enabled by default)
|
||||
|
||||
CREATE TABLE notification_preferences (
|
||||
user_id UUID PRIMARY KEY REFERENCES profiles(id) ON DELETE CASCADE,
|
||||
-- Core notifications (matching + messaging)
|
||||
matches_enabled BOOLEAN DEFAULT true,
|
||||
messages_enabled BOOLEAN DEFAULT true,
|
||||
-- Re-engagement notifications
|
||||
partner_activity_enabled BOOLEAN DEFAULT true,
|
||||
pack_changes_enabled BOOLEAN DEFAULT true,
|
||||
new_packs_enabled BOOLEAN DEFAULT true,
|
||||
streak_milestones_enabled BOOLEAN DEFAULT true,
|
||||
-- Timestamps
|
||||
created_at TIMESTAMPTZ DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ DEFAULT now()
|
||||
);
|
||||
|
||||
-- Enable RLS
|
||||
ALTER TABLE notification_preferences ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- RLS policies: users can only manage their own preferences
|
||||
CREATE POLICY "Users can view their own notification preferences"
|
||||
ON notification_preferences FOR SELECT
|
||||
USING (user_id = auth.uid());
|
||||
|
||||
CREATE POLICY "Users can insert their own notification preferences"
|
||||
ON notification_preferences FOR INSERT
|
||||
WITH CHECK (user_id = auth.uid());
|
||||
|
||||
CREATE POLICY "Users can update their own notification preferences"
|
||||
ON notification_preferences FOR UPDATE
|
||||
USING (user_id = auth.uid())
|
||||
WITH CHECK (user_id = auth.uid());
|
||||
|
||||
-- Super admins can view all preferences
|
||||
CREATE POLICY "Super admins can view all notification preferences"
|
||||
ON notification_preferences FOR SELECT
|
||||
USING (is_super_admin());
|
||||
|
||||
-- Updated_at trigger
|
||||
CREATE TRIGGER update_notification_preferences_updated_at
|
||||
BEFORE UPDATE ON notification_preferences
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
|
||||
|
||||
-- Function to get notification preferences with defaults
|
||||
-- Returns preferences if they exist, or creates a default row
|
||||
CREATE OR REPLACE FUNCTION get_or_create_notification_preferences(p_user_id UUID)
|
||||
RETURNS notification_preferences
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = 'public'
|
||||
AS $$
|
||||
DECLARE
|
||||
v_prefs notification_preferences;
|
||||
BEGIN
|
||||
-- Try to get existing preferences
|
||||
SELECT * INTO v_prefs
|
||||
FROM notification_preferences
|
||||
WHERE user_id = p_user_id;
|
||||
|
||||
-- If not found, create default preferences
|
||||
IF NOT FOUND THEN
|
||||
INSERT INTO notification_preferences (user_id)
|
||||
VALUES (p_user_id)
|
||||
RETURNING * INTO v_prefs;
|
||||
END IF;
|
||||
|
||||
RETURN v_prefs;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Grant execute permission to authenticated users
|
||||
GRANT EXECUTE ON FUNCTION get_or_create_notification_preferences(UUID) TO authenticated;
|
||||
@@ -0,0 +1,110 @@
|
||||
-- Add partner activity notification system
|
||||
-- Notifies partner when a user answers questions (batched with 1-hour debounce)
|
||||
|
||||
-- Add last_active_at to profiles for "is user in app" check
|
||||
ALTER TABLE profiles ADD COLUMN IF NOT EXISTS last_active_at TIMESTAMPTZ;
|
||||
|
||||
-- Create pending activity notifications table
|
||||
CREATE TABLE pending_activity_notifications (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
couple_id UUID NOT NULL REFERENCES couples(id) ON DELETE CASCADE,
|
||||
active_user_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
|
||||
response_count INTEGER DEFAULT 1,
|
||||
notify_at TIMESTAMPTZ NOT NULL,
|
||||
created_at TIMESTAMPTZ DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ DEFAULT now(),
|
||||
UNIQUE(couple_id) -- Only one pending notification per couple (enables UPSERT for timer reset)
|
||||
);
|
||||
|
||||
-- Index for efficient cron queries
|
||||
CREATE INDEX idx_pending_activity_notifications_notify_at
|
||||
ON pending_activity_notifications(notify_at);
|
||||
|
||||
-- Enable RLS
|
||||
ALTER TABLE pending_activity_notifications ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- RLS policies
|
||||
CREATE POLICY "Users can view their couple's pending activity notifications"
|
||||
ON pending_activity_notifications FOR SELECT
|
||||
USING (couple_id = get_auth_user_couple_id());
|
||||
|
||||
CREATE POLICY "Super admins can view all pending activity notifications"
|
||||
ON pending_activity_notifications FOR SELECT
|
||||
USING (is_super_admin());
|
||||
|
||||
-- Updated_at trigger
|
||||
CREATE TRIGGER update_pending_activity_notifications_updated_at
|
||||
BEFORE UPDATE ON pending_activity_notifications
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
|
||||
|
||||
-- Function to queue partner activity notifications
|
||||
-- Called via trigger when a response is inserted or updated
|
||||
CREATE OR REPLACE FUNCTION queue_partner_activity_notification()
|
||||
RETURNS TRIGGER
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = 'public'
|
||||
AS $$
|
||||
DECLARE
|
||||
v_user_id UUID;
|
||||
v_couple_id UUID;
|
||||
BEGIN
|
||||
-- Get the user making the response
|
||||
v_user_id := auth.uid();
|
||||
|
||||
-- Skip if no auth context (service role operations)
|
||||
IF v_user_id IS NULL THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
-- Get user's couple_id
|
||||
SELECT couple_id INTO v_couple_id
|
||||
FROM profiles
|
||||
WHERE id = v_user_id;
|
||||
|
||||
-- Skip if user is not in a couple
|
||||
IF v_couple_id IS NULL THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
-- UPSERT: Insert new notification or reset timer and increment count
|
||||
INSERT INTO pending_activity_notifications (
|
||||
couple_id,
|
||||
active_user_id,
|
||||
response_count,
|
||||
notify_at
|
||||
)
|
||||
VALUES (
|
||||
v_couple_id,
|
||||
v_user_id,
|
||||
1,
|
||||
now() + interval '1 hour'
|
||||
)
|
||||
ON CONFLICT (couple_id) DO UPDATE SET
|
||||
active_user_id = EXCLUDED.active_user_id,
|
||||
response_count = pending_activity_notifications.response_count + 1,
|
||||
notify_at = now() + interval '1 hour',
|
||||
updated_at = now();
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Create trigger on responses table
|
||||
CREATE TRIGGER on_response_submitted
|
||||
AFTER INSERT OR UPDATE OF answer ON responses
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION queue_partner_activity_notification();
|
||||
|
||||
-- Schedule cron job (runs every 5 minutes)
|
||||
SELECT cron.schedule(
|
||||
'send-partner-activity-notifications',
|
||||
'*/5 * * * *',
|
||||
$$
|
||||
SELECT net.http_post(
|
||||
url := 'https://ckjcrkjpmhqhiucifukx.supabase.co/functions/v1/send-partner-activity-notification',
|
||||
headers := jsonb_build_object('Content-Type', 'application/json'),
|
||||
body := '{}'::jsonb
|
||||
)
|
||||
$$
|
||||
);
|
||||
@@ -0,0 +1,25 @@
|
||||
-- Add scheduled pack release system
|
||||
-- Allows admins to schedule pack releases and automatically notifies users
|
||||
|
||||
-- Add scheduling columns to question_packs
|
||||
ALTER TABLE question_packs
|
||||
ADD COLUMN IF NOT EXISTS scheduled_release_at TIMESTAMPTZ,
|
||||
ADD COLUMN IF NOT EXISTS release_notified BOOLEAN DEFAULT false;
|
||||
|
||||
-- Index for efficient cron queries
|
||||
CREATE INDEX idx_question_packs_scheduled_release
|
||||
ON question_packs(scheduled_release_at)
|
||||
WHERE scheduled_release_at IS NOT NULL AND is_public = false;
|
||||
|
||||
-- Schedule cron job for processing scheduled releases (runs every 5 minutes)
|
||||
SELECT cron.schedule(
|
||||
'process-scheduled-releases',
|
||||
'*/5 * * * *',
|
||||
$$
|
||||
SELECT net.http_post(
|
||||
url := 'https://ckjcrkjpmhqhiucifukx.supabase.co/functions/v1/process-scheduled-releases',
|
||||
headers := jsonb_build_object('Content-Type', 'application/json'),
|
||||
body := '{}'::jsonb
|
||||
)
|
||||
$$
|
||||
);
|
||||
@@ -0,0 +1,197 @@
|
||||
-- Add gentle streaks system
|
||||
-- Tracks consecutive days both partners answered questions
|
||||
-- Celebrates milestones without guilt-tripping on breaks
|
||||
|
||||
-- Create couple_streaks table
|
||||
CREATE TABLE couple_streaks (
|
||||
couple_id UUID PRIMARY KEY REFERENCES couples(id) ON DELETE CASCADE,
|
||||
current_streak INTEGER DEFAULT 0,
|
||||
longest_streak INTEGER DEFAULT 0,
|
||||
last_active_date DATE,
|
||||
user1_answered_today BOOLEAN DEFAULT false,
|
||||
user2_answered_today BOOLEAN DEFAULT false,
|
||||
streak_celebrated_at INTEGER DEFAULT 0, -- Last milestone that was celebrated
|
||||
created_at TIMESTAMPTZ DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ DEFAULT now()
|
||||
);
|
||||
|
||||
-- Enable RLS
|
||||
ALTER TABLE couple_streaks ENABLE ROW LEVEL SECURITY;
|
||||
|
||||
-- RLS policies
|
||||
CREATE POLICY "Users can view their couple's streaks"
|
||||
ON couple_streaks FOR SELECT
|
||||
USING (couple_id = get_auth_user_couple_id());
|
||||
|
||||
CREATE POLICY "Super admins can view all streaks"
|
||||
ON couple_streaks FOR SELECT
|
||||
USING (is_super_admin());
|
||||
|
||||
-- Updated_at trigger
|
||||
CREATE TRIGGER update_couple_streaks_updated_at
|
||||
BEFORE UPDATE ON couple_streaks
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
|
||||
|
||||
-- Function to update streak when a response is submitted
|
||||
CREATE OR REPLACE FUNCTION update_couple_streak()
|
||||
RETURNS TRIGGER
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = 'public'
|
||||
AS $$
|
||||
DECLARE
|
||||
v_user_id UUID;
|
||||
v_couple_id UUID;
|
||||
v_partner_id UUID;
|
||||
v_today DATE;
|
||||
v_streak RECORD;
|
||||
v_user_is_user1 BOOLEAN;
|
||||
BEGIN
|
||||
-- Get the user making the response
|
||||
v_user_id := auth.uid();
|
||||
|
||||
-- Skip if no auth context
|
||||
IF v_user_id IS NULL THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
-- Get user's couple_id
|
||||
SELECT couple_id INTO v_couple_id
|
||||
FROM profiles
|
||||
WHERE id = v_user_id;
|
||||
|
||||
-- Skip if user is not in a couple
|
||||
IF v_couple_id IS NULL THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
v_today := CURRENT_DATE;
|
||||
|
||||
-- Get or create streak record
|
||||
INSERT INTO couple_streaks (couple_id, last_active_date)
|
||||
VALUES (v_couple_id, v_today)
|
||||
ON CONFLICT (couple_id) DO NOTHING;
|
||||
|
||||
SELECT * INTO v_streak
|
||||
FROM couple_streaks
|
||||
WHERE couple_id = v_couple_id
|
||||
FOR UPDATE;
|
||||
|
||||
-- Determine if this user is "user1" or "user2"
|
||||
-- We use a deterministic method: compare UUIDs
|
||||
SELECT id INTO v_partner_id
|
||||
FROM profiles
|
||||
WHERE couple_id = v_couple_id AND id != v_user_id;
|
||||
|
||||
-- User with smaller UUID is user1
|
||||
v_user_is_user1 := v_user_id < COALESCE(v_partner_id, v_user_id);
|
||||
|
||||
-- Check if streak needs to be reset (missed a day)
|
||||
IF v_streak.last_active_date IS NOT NULL AND v_streak.last_active_date < v_today - 1 THEN
|
||||
-- Streak is broken - silently reset (no guilt-tripping!)
|
||||
UPDATE couple_streaks
|
||||
SET
|
||||
current_streak = 0,
|
||||
user1_answered_today = CASE WHEN v_user_is_user1 THEN true ELSE false END,
|
||||
user2_answered_today = CASE WHEN v_user_is_user1 THEN false ELSE true END,
|
||||
last_active_date = v_today
|
||||
WHERE couple_id = v_couple_id;
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
|
||||
-- Check if this is a new day
|
||||
IF v_streak.last_active_date IS NULL OR v_streak.last_active_date < v_today THEN
|
||||
-- New day: reset daily flags, keep streak
|
||||
UPDATE couple_streaks
|
||||
SET
|
||||
user1_answered_today = CASE WHEN v_user_is_user1 THEN true ELSE false END,
|
||||
user2_answered_today = CASE WHEN v_user_is_user1 THEN false ELSE true END,
|
||||
last_active_date = v_today
|
||||
WHERE couple_id = v_couple_id;
|
||||
ELSE
|
||||
-- Same day: mark this user as having answered
|
||||
IF v_user_is_user1 THEN
|
||||
UPDATE couple_streaks
|
||||
SET user1_answered_today = true
|
||||
WHERE couple_id = v_couple_id;
|
||||
ELSE
|
||||
UPDATE couple_streaks
|
||||
SET user2_answered_today = true
|
||||
WHERE couple_id = v_couple_id;
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
-- Check if BOTH partners have now answered today
|
||||
SELECT * INTO v_streak
|
||||
FROM couple_streaks
|
||||
WHERE couple_id = v_couple_id;
|
||||
|
||||
IF v_streak.user1_answered_today AND v_streak.user2_answered_today THEN
|
||||
-- Both answered! Increment streak if not already counted for today
|
||||
IF v_streak.last_active_date = v_today AND v_streak.current_streak = 0 THEN
|
||||
-- First time both answered on a new streak
|
||||
UPDATE couple_streaks
|
||||
SET
|
||||
current_streak = 1,
|
||||
longest_streak = GREATEST(longest_streak, 1)
|
||||
WHERE couple_id = v_couple_id;
|
||||
ELSIF v_streak.last_active_date = v_today THEN
|
||||
-- Already counted for today, nothing to do
|
||||
NULL;
|
||||
ELSE
|
||||
-- Continuing streak from previous day
|
||||
UPDATE couple_streaks
|
||||
SET
|
||||
current_streak = current_streak + 1,
|
||||
longest_streak = GREATEST(longest_streak, current_streak + 1)
|
||||
WHERE couple_id = v_couple_id;
|
||||
END IF;
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Create trigger on responses table
|
||||
CREATE TRIGGER on_response_update_streak
|
||||
AFTER INSERT OR UPDATE OF answer ON responses
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_couple_streak();
|
||||
|
||||
-- Function to reset daily flags (called by cron at UTC midnight)
|
||||
CREATE OR REPLACE FUNCTION reset_daily_streak_flags()
|
||||
RETURNS void
|
||||
LANGUAGE plpgsql
|
||||
SECURITY DEFINER
|
||||
SET search_path = 'public'
|
||||
AS $$
|
||||
BEGIN
|
||||
UPDATE couple_streaks
|
||||
SET
|
||||
user1_answered_today = false,
|
||||
user2_answered_today = false
|
||||
WHERE last_active_date < CURRENT_DATE;
|
||||
END;
|
||||
$$;
|
||||
|
||||
-- Schedule cron job for daily flag reset (runs at 00:00 UTC)
|
||||
SELECT cron.schedule(
|
||||
'reset-daily-streak-flags',
|
||||
'0 0 * * *',
|
||||
$$
|
||||
SELECT reset_daily_streak_flags();
|
||||
$$
|
||||
);
|
||||
|
||||
-- Schedule cron job for checking streak milestones (runs at 00:05 UTC)
|
||||
SELECT cron.schedule(
|
||||
'check-streak-milestones',
|
||||
'5 0 * * *',
|
||||
$$
|
||||
SELECT net.http_post(
|
||||
url := 'https://ckjcrkjpmhqhiucifukx.supabase.co/functions/v1/check-streak-milestones',
|
||||
headers := jsonb_build_object('Content-Type', 'application/json'),
|
||||
body := '{}'::jsonb
|
||||
)
|
||||
$$
|
||||
);
|
||||
Reference in new issue
Block a user