mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-11-02 18:19:44 +00:00
Compare commits
2
Commits
v1.0.1
..
e671ae9d1c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e671ae9d1c | ||
|
|
a7a13c9614 |
No files matched your search
@@ -25,7 +25,7 @@
|
|||||||
"@radix-ui/react-switch": "^1.0.3",
|
"@radix-ui/react-switch": "^1.0.3",
|
||||||
"@radix-ui/react-tabs": "^1.0.4",
|
"@radix-ui/react-tabs": "^1.0.4",
|
||||||
"@radix-ui/react-tooltip": "^1.0.7",
|
"@radix-ui/react-tooltip": "^1.0.7",
|
||||||
"@sauci/shared": "workspace:*",
|
"@sauci/shared": "file:../../packages/shared",
|
||||||
"@supabase/supabase-js": "^2.39.0",
|
"@supabase/supabase-js": "^2.39.0",
|
||||||
"class-variance-authority": "^0.7.0",
|
"class-variance-authority": "^0.7.0",
|
||||||
"clsx": "^2.1.0",
|
"clsx": "^2.1.0",
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ interface ResponseActivity {
|
|||||||
id: string;
|
id: string;
|
||||||
text: string;
|
text: string;
|
||||||
pack: {
|
pack: {
|
||||||
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
} | null;
|
} | null;
|
||||||
} | null;
|
} | null;
|
||||||
@@ -155,7 +156,7 @@ export function UserActivityPage() {
|
|||||||
profile:profiles!responses_user_id_fkey(id, name, email),
|
profile:profiles!responses_user_id_fkey(id, name, email),
|
||||||
question:questions(
|
question:questions(
|
||||||
id, text,
|
id, text,
|
||||||
pack:question_packs(name)
|
pack:question_packs(id, name)
|
||||||
)
|
)
|
||||||
`, { count: 'exact' })
|
`, { count: 'exact' })
|
||||||
.order('created_at', { ascending: false })
|
.order('created_at', { ascending: false })
|
||||||
@@ -317,18 +318,22 @@ export function UserActivityPage() {
|
|||||||
const fetchSignups = useCallback(async () => {
|
const fetchSignups = useCallback(async () => {
|
||||||
setLoading(prev => ({ ...prev, signups: true }));
|
setLoading(prev => ({ ...prev, signups: true }));
|
||||||
try {
|
try {
|
||||||
const from = (signupsPage - 1) * signupsPageSize;
|
// Use RPC to get profiles with auth info (includes email from auth.users)
|
||||||
const to = from + signupsPageSize - 1;
|
const { data: allData, error } = await supabase
|
||||||
|
.rpc('get_profiles_with_auth_info');
|
||||||
const { data, error, count } = await supabase
|
|
||||||
.from('profiles')
|
|
||||||
.select('id, name, email, avatar_url, created_at, onboarding_completed, couple_id', { count: 'exact' })
|
|
||||||
.order('created_at', { ascending: false })
|
|
||||||
.range(from, to);
|
|
||||||
|
|
||||||
if (error) throw error;
|
if (error) throw error;
|
||||||
setSignups(data || []);
|
|
||||||
setSignupsTotal(count || 0);
|
// Sort by created_at descending and apply pagination
|
||||||
|
const sorted = (allData || []).sort((a: SignupActivity, b: SignupActivity) =>
|
||||||
|
new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
|
||||||
|
);
|
||||||
|
|
||||||
|
const from = (signupsPage - 1) * signupsPageSize;
|
||||||
|
const to = from + signupsPageSize;
|
||||||
|
|
||||||
|
setSignups(sorted.slice(from, to));
|
||||||
|
setSignupsTotal(sorted.length);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load signups:', error);
|
console.error('Failed to load signups:', error);
|
||||||
toast.error("Failed to load signups");
|
toast.error("Failed to load signups");
|
||||||
@@ -502,14 +507,31 @@ export function UserActivityPage() {
|
|||||||
<TableRow key={item.id}>
|
<TableRow key={item.id}>
|
||||||
<TableCell>{renderUserLink(item.profile)}</TableCell>
|
<TableCell>{renderUserLink(item.profile)}</TableCell>
|
||||||
<TableCell className="max-w-[300px]">
|
<TableCell className="max-w-[300px]">
|
||||||
<span className="truncate block">
|
{item.question?.pack?.id ? (
|
||||||
{item.question?.text || 'Unknown question'}
|
<Link
|
||||||
</span>
|
to={`/packs/${item.question.pack.id}/questions`}
|
||||||
|
className="truncate block text-primary hover:underline"
|
||||||
|
>
|
||||||
|
{item.question?.text || 'Unknown question'}
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<span className="truncate block">
|
||||||
|
{item.question?.text || 'Unknown question'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<Badge variant="outline">
|
{item.question?.pack?.id ? (
|
||||||
{item.question?.pack?.name || 'Unknown'}
|
<Link to={`/packs/${item.question.pack.id}/questions`}>
|
||||||
</Badge>
|
<Badge variant="outline" className="hover:bg-primary/10 cursor-pointer">
|
||||||
|
{item.question.pack.name}
|
||||||
|
</Badge>
|
||||||
|
</Link>
|
||||||
|
) : (
|
||||||
|
<Badge variant="outline">
|
||||||
|
{item.question?.pack?.name || 'Unknown'}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>{renderAnswerBadge(item.answer)}</TableCell>
|
<TableCell>{renderAnswerBadge(item.answer)}</TableCell>
|
||||||
<TableCell className="text-muted-foreground text-sm whitespace-nowrap">
|
<TableCell className="text-muted-foreground text-sm whitespace-nowrap">
|
||||||
|
|||||||
@@ -38,14 +38,17 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
import { Crown, Users, MessageCircle, ChevronRight, ThumbsUp, ThumbsDown, Minus, Gift, Image, Video as VideoIcon, Target, User, Eye, EyeOff, CheckCircle } from 'lucide-react';
|
import { Crown, Users, MessageCircle, ChevronRight, ThumbsUp, ThumbsDown, Minus, Gift, Image, Video as VideoIcon, Target, User, Eye, EyeOff, CheckCircle, Package, Heart, Sparkles } from 'lucide-react';
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
|
import { IconPreview } from '@/components/ui/icon-picker';
|
||||||
|
|
||||||
type UsageReason = 'improve_communication' | 'spice_up_intimacy' | 'deeper_connection' | 'have_fun' | 'strengthen_relationship';
|
type UsageReason = 'improve_communication' | 'spice_up_intimacy' | 'deeper_connection' | 'have_fun' | 'strengthen_relationship';
|
||||||
type Gender = 'male' | 'female' | 'non-binary' | 'prefer-not-to-say';
|
type Gender = 'male' | 'female' | 'non-binary' | 'prefer-not-to-say';
|
||||||
|
|
||||||
// ... (interfaces and constants are the same)
|
// ... (interfaces and constants are the same)
|
||||||
|
|
||||||
|
type IntensityLevel = 1 | 2 | 3 | 4 | 5;
|
||||||
|
|
||||||
interface Profile {
|
interface Profile {
|
||||||
id: string;
|
id: string;
|
||||||
name: string | null;
|
name: string | null;
|
||||||
@@ -58,6 +61,7 @@ interface Profile {
|
|||||||
usage_reason: UsageReason | null;
|
usage_reason: UsageReason | null;
|
||||||
show_explicit_content: boolean | null;
|
show_explicit_content: boolean | null;
|
||||||
onboarding_completed: boolean | null;
|
onboarding_completed: boolean | null;
|
||||||
|
max_intensity: IntensityLevel | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const usageReasonLabels: Record<UsageReason, { label: string; icon: string }> = {
|
const usageReasonLabels: Record<UsageReason, { label: string; icon: string }> = {
|
||||||
@@ -75,6 +79,14 @@ const genderLabels: Record<Gender, string> = {
|
|||||||
'prefer-not-to-say': 'Prefer not to say',
|
'prefer-not-to-say': 'Prefer not to say',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const intensityLabels: Record<IntensityLevel, { label: string; emoji: string }> = {
|
||||||
|
1: { label: 'Gentle', emoji: '💭' },
|
||||||
|
2: { label: 'Warm', emoji: '💕' },
|
||||||
|
3: { label: 'Playful', emoji: '😏' },
|
||||||
|
4: { label: 'Steamy', emoji: '🔥' },
|
||||||
|
5: { label: 'Intense', emoji: '🌶️' },
|
||||||
|
};
|
||||||
|
|
||||||
interface Partner {
|
interface Partner {
|
||||||
id: string;
|
id: string;
|
||||||
name: string | null;
|
name: string | null;
|
||||||
@@ -88,6 +100,7 @@ interface Response {
|
|||||||
id: string;
|
id: string;
|
||||||
text: string;
|
text: string;
|
||||||
pack: {
|
pack: {
|
||||||
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -105,6 +118,30 @@ interface Match {
|
|||||||
message_count?: number;
|
message_count?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface EnabledPack {
|
||||||
|
pack_id: string;
|
||||||
|
enabled: boolean;
|
||||||
|
created_at: string | null;
|
||||||
|
pack: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
icon: string | null;
|
||||||
|
is_premium: boolean;
|
||||||
|
is_explicit: boolean;
|
||||||
|
category: {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
} | null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FeatureInterest {
|
||||||
|
id: string;
|
||||||
|
feature_name: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
const answerIcons = {
|
const answerIcons = {
|
||||||
yes: <ThumbsUp className="h-4 w-4 text-green-500" />,
|
yes: <ThumbsUp className="h-4 w-4 text-green-500" />,
|
||||||
no: <ThumbsDown className="h-4 w-4 text-red-500" />,
|
no: <ThumbsDown className="h-4 w-4 text-red-500" />,
|
||||||
@@ -238,6 +275,8 @@ export function UserDetailPage() {
|
|||||||
const [responses, setResponses] = useState<Response[]>([]);
|
const [responses, setResponses] = useState<Response[]>([]);
|
||||||
const [matches, setMatches] = useState<Match[]>([]);
|
const [matches, setMatches] = useState<Match[]>([]);
|
||||||
const [mediaMessages, setMediaMessages] = useState<any[]>([]);
|
const [mediaMessages, setMediaMessages] = useState<any[]>([]);
|
||||||
|
const [enabledPacks, setEnabledPacks] = useState<EnabledPack[]>([]);
|
||||||
|
const [featureInterests, setFeatureInterests] = useState<FeatureInterest[]>([]);
|
||||||
const [responsesPage, setResponsesPage] = useState(1);
|
const [responsesPage, setResponsesPage] = useState(1);
|
||||||
const [responsesPageSize, setResponsesPageSize] = useState(10);
|
const [responsesPageSize, setResponsesPageSize] = useState(10);
|
||||||
const [responsesTotal, setResponsesTotal] = useState(0);
|
const [responsesTotal, setResponsesTotal] = useState(0);
|
||||||
@@ -311,6 +350,14 @@ export function UserDetailPage() {
|
|||||||
const { data: profileData } = await supabase.from('profiles').select('*').eq('id', userId).single();
|
const { data: profileData } = await supabase.from('profiles').select('*').eq('id', userId).single();
|
||||||
setProfile(profileData);
|
setProfile(profileData);
|
||||||
|
|
||||||
|
// Fetch feature interests for the user
|
||||||
|
const { data: featureInterestsData } = await supabase
|
||||||
|
.from('feature_interests')
|
||||||
|
.select('id, feature_name, created_at')
|
||||||
|
.eq('user_id', userId)
|
||||||
|
.order('created_at', { ascending: false });
|
||||||
|
setFeatureInterests((featureInterestsData || []) as FeatureInterest[]);
|
||||||
|
|
||||||
if (profileData?.couple_id) {
|
if (profileData?.couple_id) {
|
||||||
const { data: partnerData } = await supabase.from('profiles').select('id, name').eq('couple_id', profileData.couple_id).neq('id', userId).single();
|
const { data: partnerData } = await supabase.from('profiles').select('id, name').eq('couple_id', profileData.couple_id).neq('id', userId).single();
|
||||||
setPartner(partnerData);
|
setPartner(partnerData);
|
||||||
@@ -323,7 +370,7 @@ export function UserDetailPage() {
|
|||||||
|
|
||||||
const { data: responseData, count: responsesCount, error: responsesError } = await supabase
|
const { data: responseData, count: responsesCount, error: responsesError } = await supabase
|
||||||
.from('responses')
|
.from('responses')
|
||||||
.select('id, answer, created_at, question:questions(id, text, pack:question_packs(name))', { count: 'exact' })
|
.select('id, answer, created_at, question:questions(id, text, pack:question_packs(id, name))', { count: 'exact' })
|
||||||
.eq('user_id', userId)
|
.eq('user_id', userId)
|
||||||
.order('created_at', { ascending: false })
|
.order('created_at', { ascending: false })
|
||||||
.range(responsesFrom, responsesTo);
|
.range(responsesFrom, responsesTo);
|
||||||
@@ -388,11 +435,39 @@ export function UserDetailPage() {
|
|||||||
setMediaMessages([]);
|
setMediaMessages([]);
|
||||||
setMediaTotal(0);
|
setMediaTotal(0);
|
||||||
}
|
}
|
||||||
|
// Fetch enabled packs for the couple
|
||||||
|
const { data: enabledPacksData, error: packsError } = await supabase
|
||||||
|
.from('couple_packs')
|
||||||
|
.select(`
|
||||||
|
pack_id,
|
||||||
|
enabled,
|
||||||
|
created_at,
|
||||||
|
pack:question_packs(
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
icon,
|
||||||
|
is_premium,
|
||||||
|
is_explicit,
|
||||||
|
category:categories(id, name)
|
||||||
|
)
|
||||||
|
`)
|
||||||
|
.eq('couple_id', profileData.couple_id)
|
||||||
|
.eq('enabled', true)
|
||||||
|
.order('created_at', { ascending: false });
|
||||||
|
|
||||||
|
if (packsError) {
|
||||||
|
console.error("Error fetching enabled packs:", packsError);
|
||||||
|
setEnabledPacks([]);
|
||||||
|
} else {
|
||||||
|
setEnabledPacks((enabledPacksData || []) as unknown as EnabledPack[]);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
setMatches([]);
|
setMatches([]);
|
||||||
setMatchesTotal(0);
|
setMatchesTotal(0);
|
||||||
setMediaMessages([]);
|
setMediaMessages([]);
|
||||||
setMediaTotal(0);
|
setMediaTotal(0);
|
||||||
|
setEnabledPacks([]);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load user data:', error);
|
console.error('Failed to load user data:', error);
|
||||||
@@ -456,20 +531,46 @@ export function UserDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div></CardContent></Card>
|
</div></CardContent></Card>
|
||||||
{/* Onboarding Details */}
|
{/* Onboarding Details */}
|
||||||
<Card><CardHeader className="pb-3"><CardTitle className="text-lg flex items-center gap-2"><CheckCircle className="h-5 w-5 text-primary" />Onboarding Details</CardTitle><CardDescription>Information collected during user onboarding</CardDescription></CardHeader><CardContent><div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
<Card><CardHeader className="pb-3"><CardTitle className="text-lg flex items-center gap-2"><CheckCircle className="h-5 w-5 text-primary" />Onboarding Details</CardTitle><CardDescription>Information collected during user onboarding</CardDescription></CardHeader><CardContent><div className="grid gap-4 md:grid-cols-2 lg:grid-cols-5">
|
||||||
<div className="flex items-start gap-3"><div className="p-2 rounded-lg bg-blue-100 dark:bg-blue-900"><User className="h-4 w-4 text-blue-600 dark:text-blue-400" /></div><div><p className="text-sm font-medium text-muted-foreground">Gender</p><p className="font-medium">{profile.gender ? genderLabels[profile.gender] : 'Not set'}</p></div></div>
|
<div className="flex items-start gap-3"><div className="p-2 rounded-lg bg-blue-100 dark:bg-blue-900"><User className="h-4 w-4 text-blue-600 dark:text-blue-400" /></div><div><p className="text-sm font-medium text-muted-foreground">Gender</p><p className="font-medium">{profile.gender ? genderLabels[profile.gender] : 'Not set'}</p></div></div>
|
||||||
<div className="flex items-start gap-3"><div className="p-2 rounded-lg bg-purple-100 dark:bg-purple-900"><Target className="h-4 w-4 text-purple-600 dark:text-purple-400" /></div><div><p className="text-sm font-medium text-muted-foreground">Why using Sauci</p><p className="font-medium">{profile.usage_reason ? usageReasonLabels[profile.usage_reason]?.label : 'Not set'}</p></div></div>
|
<div className="flex items-start gap-3"><div className="p-2 rounded-lg bg-purple-100 dark:bg-purple-900"><Target className="h-4 w-4 text-purple-600 dark:text-purple-400" /></div><div><p className="text-sm font-medium text-muted-foreground">Why using Sauci</p><p className="font-medium">{profile.usage_reason ? usageReasonLabels[profile.usage_reason]?.label : 'Not set'}</p></div></div>
|
||||||
|
<div className="flex items-start gap-3"><div className="p-2 rounded-lg bg-pink-100 dark:bg-pink-900"><Heart className="h-4 w-4 text-pink-600 dark:text-pink-400" /></div><div><p className="text-sm font-medium text-muted-foreground">Comfort Zone</p><p className="font-medium">{profile.max_intensity ? `${intensityLabels[profile.max_intensity].emoji} ${intensityLabels[profile.max_intensity].label} (${profile.max_intensity})` : 'Not set'}</p></div></div>
|
||||||
<div className="flex items-start gap-3"><div className={`p-2 rounded-lg ${profile.show_explicit_content ? 'bg-amber-100 dark:bg-amber-900' : 'bg-gray-100 dark:bg-gray-800'}`}>{profile.show_explicit_content ? <Eye className="h-4 w-4 text-amber-600 dark:text-amber-400" /> : <EyeOff className="h-4 w-4 text-gray-500 dark:text-gray-400" />}</div><div><p className="text-sm font-medium text-muted-foreground">Explicit Content</p><p className="font-medium">{profile.show_explicit_content === null ? 'Not set' : profile.show_explicit_content ? 'Enabled' : 'Disabled'}</p></div></div>
|
<div className="flex items-start gap-3"><div className={`p-2 rounded-lg ${profile.show_explicit_content ? 'bg-amber-100 dark:bg-amber-900' : 'bg-gray-100 dark:bg-gray-800'}`}>{profile.show_explicit_content ? <Eye className="h-4 w-4 text-amber-600 dark:text-amber-400" /> : <EyeOff className="h-4 w-4 text-gray-500 dark:text-gray-400" />}</div><div><p className="text-sm font-medium text-muted-foreground">Explicit Content</p><p className="font-medium">{profile.show_explicit_content === null ? 'Not set' : profile.show_explicit_content ? 'Enabled' : 'Disabled'}</p></div></div>
|
||||||
<div className="flex items-start gap-3"><div className={`p-2 rounded-lg ${profile.onboarding_completed ? 'bg-green-100 dark:bg-green-900' : 'bg-red-100 dark:bg-red-900'}`}><CheckCircle className={`h-4 w-4 ${profile.onboarding_completed ? 'text-green-600 dark:text-green-400' : 'text-red-500 dark:text-red-400'}`} /></div><div><p className="text-sm font-medium text-muted-foreground">Onboarding</p><p className="font-medium">{profile.onboarding_completed ? 'Completed' : 'Not completed'}</p></div></div>
|
<div className="flex items-start gap-3"><div className={`p-2 rounded-lg ${profile.onboarding_completed ? 'bg-green-100 dark:bg-green-900' : 'bg-red-100 dark:bg-red-900'}`}><CheckCircle className={`h-4 w-4 ${profile.onboarding_completed ? 'text-green-600 dark:text-green-400' : 'text-red-500 dark:text-red-400'}`} /></div><div><p className="text-sm font-medium text-muted-foreground">Onboarding</p><p className="font-medium">{profile.onboarding_completed ? 'Completed' : 'Not completed'}</p></div></div>
|
||||||
</div></CardContent></Card>
|
</div></CardContent></Card>
|
||||||
|
{/* Feature Opt-Ins */}
|
||||||
|
{featureInterests.length > 0 && (
|
||||||
|
<Card>
|
||||||
|
<CardHeader className="pb-3">
|
||||||
|
<CardTitle className="text-lg flex items-center gap-2">
|
||||||
|
<Sparkles className="h-5 w-5 text-primary" />
|
||||||
|
Feature Opt-Ins
|
||||||
|
</CardTitle>
|
||||||
|
<CardDescription>Features the user has expressed interest in</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{featureInterests.map((interest) => (
|
||||||
|
<Badge key={interest.id} variant="secondary" className="capitalize">
|
||||||
|
{interest.feature_name}
|
||||||
|
<span className="ml-1 text-xs text-muted-foreground">
|
||||||
|
({format(new Date(interest.created_at), 'MMM d')})
|
||||||
|
</span>
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
{/* Tabs */}
|
{/* Tabs */}
|
||||||
<Tabs defaultValue={canViewResponses ? "responses" : canViewMatches ? "matches" : canViewMedia ? "media" : "responses"}>
|
<Tabs defaultValue={canViewResponses ? "responses" : canViewMatches ? "matches" : canViewMedia ? "media" : "responses"}>
|
||||||
<TabsList>
|
<TabsList>
|
||||||
{canViewResponses && <TabsTrigger value="responses">Responses ({responsesTotal})</TabsTrigger>}
|
{canViewResponses && <TabsTrigger value="responses">Responses ({responsesTotal})</TabsTrigger>}
|
||||||
{canViewMatches && <TabsTrigger value="matches" disabled={!profile.couple_id}>Matches ({matchesTotal})</TabsTrigger>}
|
{canViewMatches && <TabsTrigger value="matches" disabled={!profile.couple_id}>Matches ({matchesTotal})</TabsTrigger>}
|
||||||
{canViewMedia && <TabsTrigger value="media">Media ({mediaTotal})</TabsTrigger>}
|
{canViewMedia && <TabsTrigger value="media">Media ({mediaTotal})</TabsTrigger>}
|
||||||
|
<TabsTrigger value="packs" disabled={!profile.couple_id}>Enabled Packs ({enabledPacks.length})</TabsTrigger>
|
||||||
</TabsList>
|
</TabsList>
|
||||||
{canViewResponses && <TabsContent value="responses" className="mt-4">{responses.length === 0 ? <Card className="flex flex-col items-center justify-center py-12"><p className="text-muted-foreground">No responses yet</p></Card> : <div className="space-y-4"><div className="rounded-md border"><Table><TableHeader><TableRow><TableHead>Question</TableHead><TableHead>Pack</TableHead><TableHead className="w-24">Answer</TableHead><TableHead className="w-32">Date</TableHead></TableRow></TableHeader><TableBody>{responses.map((r) => (<TableRow key={r.id}><TableCell className="max-w-md"><span className="line-clamp-2">{r.question.text}</span></TableCell><TableCell className="text-muted-foreground">{r.question.pack?.name || '—'}</TableCell><TableCell><div className="flex items-center gap-2">{answerIcons[r.answer]}<span className="capitalize">{r.answer}</span></div></TableCell><TableCell className="text-muted-foreground text-sm">{r.created_at ? format(new Date(r.created_at), 'MMM d, yyyy') : '—'}</TableCell></TableRow>))}</TableBody></Table></div><PaginationControls page={responsesPage} pageSize={responsesPageSize} totalCount={responsesTotal} onPageChange={setResponsesPage} onPageSizeChange={(size) => { setResponsesPage(1); setResponsesPageSize(size); }} /></div>}</TabsContent>}
|
{canViewResponses && <TabsContent value="responses" className="mt-4">{responses.length === 0 ? <Card className="flex flex-col items-center justify-center py-12"><p className="text-muted-foreground">No responses yet</p></Card> : <div className="space-y-4"><div className="rounded-md border"><Table><TableHeader><TableRow><TableHead>Question</TableHead><TableHead>Pack</TableHead><TableHead className="w-24">Answer</TableHead><TableHead className="w-32">Date</TableHead></TableRow></TableHeader><TableBody>{responses.map((r) => (<TableRow key={r.id}><TableCell className="max-w-md">{r.question.pack?.id ? (<Link to={`/packs/${r.question.pack.id}/questions`} className="line-clamp-2 text-primary hover:underline cursor-pointer">{r.question.text}</Link>) : (<span className="line-clamp-2">{r.question.text}</span>)}</TableCell><TableCell>{r.question.pack?.id ? (<Link to={`/packs/${r.question.pack.id}/questions`} className="text-primary hover:underline">{r.question.pack.name}</Link>) : (<span className="text-muted-foreground">{r.question.pack?.name || '—'}</span>)}</TableCell><TableCell><div className="flex items-center gap-2">{answerIcons[r.answer]}<span className="capitalize">{r.answer}</span></div></TableCell><TableCell className="text-muted-foreground text-sm">{r.created_at ? format(new Date(r.created_at), 'MMM d, yyyy') : '—'}</TableCell></TableRow>))}</TableBody></Table></div><PaginationControls page={responsesPage} pageSize={responsesPageSize} totalCount={responsesTotal} onPageChange={setResponsesPage} onPageSizeChange={(size) => { setResponsesPage(1); setResponsesPageSize(size); }} /></div>}</TabsContent>}
|
||||||
{canViewMatches && <TabsContent value="matches" className="mt-4">{matches.length === 0 ? <Card className="flex flex-col items-center justify-center py-12"><p className="text-muted-foreground">No matches yet</p></Card> : <div className="space-y-4"><div className="grid gap-4 md:grid-cols-2">{matches.map((match) => (<Card key={match.id} className="hover:shadow-md transition-shadow"><CardHeader className="pb-2"><div className="flex items-start justify-between"><Badge variant="secondary">{matchTypeLabels[match.match_type]}</Badge>{match.is_new && <Badge variant="default" className="text-xs">New</Badge>}</div><CardDescription className="line-clamp-2 mt-2">{match.question?.text || 'Unknown question'}</CardDescription></CardHeader><CardContent><div className="flex items-center justify-between"><div className="flex items-center gap-1 text-sm text-muted-foreground"><MessageCircle className="h-4 w-4" />{match.message_count} message{match.message_count !== 1 ? 's' : ''}</div>{canViewChats && <Link to={`/users/${userId}/matches/${match.id}`}><Button variant="ghost" size="sm">View Chat<ChevronRight className="ml-1 h-4 w-4" /></Button></Link>}</div></CardContent></Card>))}</div><PaginationControls page={matchesPage} pageSize={matchesPageSize} totalCount={matchesTotal} onPageChange={setMatchesPage} onPageSizeChange={(size) => { setMatchesPage(1); setMatchesPageSize(size); }} /></div>}</TabsContent>}
|
{canViewMatches && <TabsContent value="matches" className="mt-4">{matches.length === 0 ? <Card className="flex flex-col items-center justify-center py-12"><p className="text-muted-foreground">No matches yet</p></Card> : <div className="space-y-4"><div className="grid gap-4 md:grid-cols-2">{matches.map((match) => (<Card key={match.id} className="hover:shadow-md transition-shadow"><CardHeader className="pb-2"><div className="flex items-start justify-between"><Badge variant="secondary">{matchTypeLabels[match.match_type]}</Badge>{match.is_new && <Badge variant="default" className="text-xs">New</Badge>}</div><CardDescription className="line-clamp-2 mt-2">{match.question?.text || 'Unknown question'}</CardDescription></CardHeader><CardContent><div className="flex items-center justify-between"><div className="flex items-center gap-1 text-sm text-muted-foreground"><MessageCircle className="h-4 w-4" />{match.message_count} message{match.message_count !== 1 ? 's' : ''}</div>{canViewChats && <Link to={`/users/${userId}/matches/${match.id}`}><Button variant="ghost" size="sm">View Chat<ChevronRight className="ml-1 h-4 w-4" /></Button></Link>}</div></CardContent></Card>))}</div><PaginationControls page={matchesPage} pageSize={matchesPageSize} totalCount={matchesTotal} onPageChange={setMatchesPage} onPageSizeChange={(size) => { setMatchesPage(1); setMatchesPageSize(size); }} /></div>}</TabsContent>}
|
||||||
{canViewMedia && <TabsContent value="media" className="mt-4">{mediaMessages.length === 0 ? <Card className="flex flex-col items-center justify-center py-12"><Image className="h-12 w-12 text-muted-foreground mb-4" /><p className="text-muted-foreground">No media uploaded</p></Card> : <div className="space-y-4"><div className="grid gap-4 grid-cols-2 md:grid-cols-3 lg:grid-cols-4">{mediaMessages.map((message) => (<Card key={message.id} className="overflow-hidden"><div className="aspect-square bg-muted relative">
|
{canViewMedia && <TabsContent value="media" className="mt-4">{mediaMessages.length === 0 ? <Card className="flex flex-col items-center justify-center py-12"><Image className="h-12 w-12 text-muted-foreground mb-4" /><p className="text-muted-foreground">No media uploaded</p></Card> : <div className="space-y-4"><div className="grid gap-4 grid-cols-2 md:grid-cols-3 lg:grid-cols-4">{mediaMessages.map((message) => (<Card key={message.id} className="overflow-hidden"><div className="aspect-square bg-muted relative">
|
||||||
{message.media_type === 'video' ? (
|
{message.media_type === 'video' ? (
|
||||||
@@ -478,6 +579,59 @@ export function UserDetailPage() {
|
|||||||
<AdminDecryptedImage messageId={message.id} alt={`Media from ${message.created_at}`} />
|
<AdminDecryptedImage messageId={message.id} alt={`Media from ${message.created_at}`} />
|
||||||
)}
|
)}
|
||||||
</div><CardContent className="p-3"><div className="flex items-center justify-between text-sm"><div className="truncate text-muted-foreground">{message.media_type === 'video' ? 'Video' : 'Image'}</div>{canViewChats && message.match_id && <Link to={`/users/${userId}/matches/${message.match_id}`} className="text-primary hover:underline text-xs">View Chat</Link>}</div><div className="text-xs text-muted-foreground mt-1">{message.created_at ? format(new Date(message.created_at), 'MMM d, yyyy') : '—'}</div></CardContent></Card>))}</div><PaginationControls page={mediaPage} pageSize={mediaPageSize} totalCount={mediaTotal} onPageChange={setMediaPage} onPageSizeChange={(size) => { setMediaPage(1); setMediaPageSize(size); }} /></div>}</TabsContent>}
|
</div><CardContent className="p-3"><div className="flex items-center justify-between text-sm"><div className="truncate text-muted-foreground">{message.media_type === 'video' ? 'Video' : 'Image'}</div>{canViewChats && message.match_id && <Link to={`/users/${userId}/matches/${message.match_id}`} className="text-primary hover:underline text-xs">View Chat</Link>}</div><div className="text-xs text-muted-foreground mt-1">{message.created_at ? format(new Date(message.created_at), 'MMM d, yyyy') : '—'}</div></CardContent></Card>))}</div><PaginationControls page={mediaPage} pageSize={mediaPageSize} totalCount={mediaTotal} onPageChange={setMediaPage} onPageSizeChange={(size) => { setMediaPage(1); setMediaPageSize(size); }} /></div>}</TabsContent>}
|
||||||
|
<TabsContent value="packs" className="mt-4">
|
||||||
|
{enabledPacks.length === 0 ? (
|
||||||
|
<Card className="flex flex-col items-center justify-center py-12">
|
||||||
|
<Package className="h-12 w-12 text-muted-foreground mb-4" />
|
||||||
|
<p className="text-muted-foreground">No packs enabled</p>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{enabledPacks.map((ep) => (
|
||||||
|
<Link key={ep.pack_id} to={`/packs/${ep.pack.id}/questions`}>
|
||||||
|
<Card className="hover:shadow-md transition-shadow cursor-pointer">
|
||||||
|
<CardHeader className="pb-2">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<IconPreview value={ep.pack.icon} fallback="heart-outline" className="text-2xl" />
|
||||||
|
<CardTitle className="text-base">{ep.pack.name}</CardTitle>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{ep.pack.is_premium && (
|
||||||
|
<Badge className="bg-amber-500 text-xs">
|
||||||
|
<Crown className="h-3 w-3 mr-1" />
|
||||||
|
Premium
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{ep.pack.is_explicit && (
|
||||||
|
<Badge variant="destructive" className="text-xs">
|
||||||
|
18+
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{ep.pack.description && (
|
||||||
|
<CardDescription className="line-clamp-2 mt-2">
|
||||||
|
{ep.pack.description}
|
||||||
|
</CardDescription>
|
||||||
|
)}
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{ep.pack.category?.name || 'Uncategorized'}
|
||||||
|
</span>
|
||||||
|
<span className="text-muted-foreground text-xs">
|
||||||
|
Enabled {ep.created_at ? format(new Date(ep.created_at), 'MMM d, yyyy') : '—'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -6,3 +6,6 @@ expo-env.d.ts
|
|||||||
# @end expo-cli
|
# @end expo-cli
|
||||||
*.aab
|
*.aab
|
||||||
*.ipa
|
*.ipa
|
||||||
|
*.apk
|
||||||
|
*.jks
|
||||||
|
*.keystore
|
||||||
@@ -93,8 +93,8 @@ android {
|
|||||||
applicationId 'com.sauci.app'
|
applicationId 'com.sauci.app'
|
||||||
minSdkVersion rootProject.ext.minSdkVersion
|
minSdkVersion rootProject.ext.minSdkVersion
|
||||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||||
versionCode 21
|
versionCode 22
|
||||||
versionName "1.0.0"
|
versionName "1.0.1"
|
||||||
}
|
}
|
||||||
signingConfigs {
|
signingConfigs {
|
||||||
debug {
|
debug {
|
||||||
|
|||||||
@@ -13,6 +13,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"oauth_client": [
|
"oauth_client": [
|
||||||
|
{
|
||||||
|
"client_id": "764866133492-03k7q0mq6nqrv0phunncqjn85cgj6sep.apps.googleusercontent.com",
|
||||||
|
"client_type": 1,
|
||||||
|
"android_info": {
|
||||||
|
"package_name": "com.sauci.app",
|
||||||
|
"certificate_hash": "f06026c754b046f280feac438a6910bd9a62b459"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"client_id": "764866133492-4g566mrb8tm5uti4ejub6oogcvnln7kc.apps.googleusercontent.com",
|
"client_id": "764866133492-4g566mrb8tm5uti4ejub6oogcvnln7kc.apps.googleusercontent.com",
|
||||||
"client_type": 1,
|
"client_type": 1,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"expo": {
|
"expo": {
|
||||||
"name": "Sauci",
|
"name": "Sauci",
|
||||||
"slug": "sauci",
|
"slug": "sauci",
|
||||||
"version": "1.0.0",
|
"version": "1.0.1",
|
||||||
"orientation": "portrait",
|
"orientation": "portrait",
|
||||||
"icon": "./assets/icon.png",
|
"icon": "./assets/icon.png",
|
||||||
"scheme": "app.sauci",
|
"scheme": "app.sauci",
|
||||||
@@ -16,7 +16,7 @@
|
|||||||
"ios": {
|
"ios": {
|
||||||
"supportsTablet": false,
|
"supportsTablet": false,
|
||||||
"bundleIdentifier": "com.sauci.app",
|
"bundleIdentifier": "com.sauci.app",
|
||||||
"buildNumber": "25",
|
"buildNumber": "30",
|
||||||
"usesAppleSignIn": true,
|
"usesAppleSignIn": true,
|
||||||
"infoPlist": {
|
"infoPlist": {
|
||||||
"UIBackgroundModes": [
|
"UIBackgroundModes": [
|
||||||
@@ -176,7 +176,7 @@
|
|||||||
"backgroundColor": "#1a1a2e"
|
"backgroundColor": "#1a1a2e"
|
||||||
},
|
},
|
||||||
"package": "com.sauci.app",
|
"package": "com.sauci.app",
|
||||||
"versionCode": 21,
|
"versionCode": 22,
|
||||||
"googleServicesFile": "./google-services.json",
|
"googleServicesFile": "./google-services.json",
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"RECEIVE_BOOT_COMPLETED",
|
"RECEIVE_BOOT_COMPLETED",
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
// Polyfills must be imported FIRST before any other React Native imports
|
// Polyfills must be imported FIRST before any other React Native imports
|
||||||
import "../src/polyfills/web";
|
import "../src/polyfills/web";
|
||||||
import "../src/polyfills/crypto";
|
|
||||||
|
|
||||||
// Initialize Sentry early to capture all errors
|
// Initialize Sentry early to capture all errors
|
||||||
import { initSentry, setUserContext, clearUserContext } from "../src/lib/sentry";
|
import { initSentry, setUserContext, clearUserContext } from "../src/lib/sentry";
|
||||||
|
|||||||
@@ -5,13 +5,16 @@
|
|||||||
},
|
},
|
||||||
"build": {
|
"build": {
|
||||||
"development": {
|
"development": {
|
||||||
|
"node": "20.10.0",
|
||||||
"developmentClient": true,
|
"developmentClient": true,
|
||||||
"distribution": "internal"
|
"distribution": "internal"
|
||||||
},
|
},
|
||||||
"preview": {
|
"preview": {
|
||||||
|
"node": "20.10.0",
|
||||||
"distribution": "internal"
|
"distribution": "internal"
|
||||||
},
|
},
|
||||||
"production": {
|
"production": {
|
||||||
|
"node": "20.10.0",
|
||||||
"autoIncrement": true,
|
"autoIncrement": true,
|
||||||
"env": {
|
"env": {
|
||||||
"EXPO_PUBLIC_SUPABASE_URL": "https://ckjcrkjpmhqhiucifukx.supabase.co",
|
"EXPO_PUBLIC_SUPABASE_URL": "https://ckjcrkjpmhqhiucifukx.supabase.co",
|
||||||
|
|||||||
@@ -13,6 +13,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"oauth_client": [
|
"oauth_client": [
|
||||||
|
{
|
||||||
|
"client_id": "764866133492-03k7q0mq6nqrv0phunncqjn85cgj6sep.apps.googleusercontent.com",
|
||||||
|
"client_type": 1,
|
||||||
|
"android_info": {
|
||||||
|
"package_name": "com.sauci.app",
|
||||||
|
"certificate_hash": "f06026c754b046f280feac438a6910bd9a62b459"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"client_id": "764866133492-4g566mrb8tm5uti4ejub6oogcvnln7kc.apps.googleusercontent.com",
|
"client_id": "764866133492-4g566mrb8tm5uti4ejub6oogcvnln7kc.apps.googleusercontent.com",
|
||||||
"client_type": 1,
|
"client_type": 1,
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
<key>CFBundlePackageType</key>
|
<key>CFBundlePackageType</key>
|
||||||
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||||
<key>CFBundleShortVersionString</key>
|
<key>CFBundleShortVersionString</key>
|
||||||
<string>1.0.0</string>
|
<string>1.0.1</string>
|
||||||
<key>CFBundleSignature</key>
|
<key>CFBundleSignature</key>
|
||||||
<string>????</string>
|
<string>????</string>
|
||||||
<key>CFBundleURLTypes</key>
|
<key>CFBundleURLTypes</key>
|
||||||
@@ -47,7 +47,7 @@
|
|||||||
</dict>
|
</dict>
|
||||||
</array>
|
</array>
|
||||||
<key>CFBundleVersion</key>
|
<key>CFBundleVersion</key>
|
||||||
<string>25</string>
|
<string>30</string>
|
||||||
<key>LSMinimumSystemVersion</key>
|
<key>LSMinimumSystemVersion</key>
|
||||||
<string>12.0</string>
|
<string>12.0</string>
|
||||||
<key>LSRequiresIPhoneOS</key>
|
<key>LSRequiresIPhoneOS</key>
|
||||||
|
|||||||
@@ -25,11 +25,9 @@ config.resolver.nodeModulesPaths = [
|
|||||||
// of the workspace root, unless they are installed in the project root.
|
// of the workspace root, unless they are installed in the project root.
|
||||||
config.resolver.disableHierarchicalLookup = true;
|
config.resolver.disableHierarchicalLookup = true;
|
||||||
|
|
||||||
// 4. E2EE crypto polyfill: resolve 'crypto' to react-native-quick-crypto
|
// 4. Keep extraNodeModules in case we need to add monorepo overrides later
|
||||||
// This enables Web Crypto API for encryption operations
|
|
||||||
config.resolver.extraNodeModules = {
|
config.resolver.extraNodeModules = {
|
||||||
...config.resolver.extraNodeModules,
|
...config.resolver.extraNodeModules,
|
||||||
crypto: require.resolve('react-native-quick-crypto'),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
module.exports = config;
|
module.exports = config;
|
||||||
@@ -14,7 +14,9 @@
|
|||||||
"test:watch": "jest --watch",
|
"test:watch": "jest --watch",
|
||||||
"test:ci": "jest --runInBand --ci",
|
"test:ci": "jest --runInBand --ci",
|
||||||
"release:ios": "dotenv -e .env -- npx expo prebuild --platform ios && dotenv -e .env -- eas build --platform ios --profile production --local",
|
"release:ios": "dotenv -e .env -- npx expo prebuild --platform ios && dotenv -e .env -- eas build --platform ios --profile production --local",
|
||||||
"release:android": "dotenv -e .env -- npx expo prebuild --platform android && dotenv -e .env -- eas build --platform android --profile production --local"
|
"release:ios:preview": "dotenv -e .env -- npx expo prebuild --platform ios && dotenv -e .env -- eas build --platform ios --profile preview --local",
|
||||||
|
"release:android": "dotenv -e .env -- npx expo prebuild --platform android && dotenv -e .env -- eas build --platform android --profile production --local",
|
||||||
|
"release:android:preview": "dotenv -e .env -- npx expo prebuild --platform android && dotenv -e .env -- eas build --platform android --profile preview --local"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@expo/ngrok": "^4.1.0",
|
"@expo/ngrok": "^4.1.0",
|
||||||
@@ -58,7 +60,6 @@
|
|||||||
"react-native-compressor": "^1.13.0",
|
"react-native-compressor": "^1.13.0",
|
||||||
"react-native-gesture-handler": "~2.20.0",
|
"react-native-gesture-handler": "~2.20.0",
|
||||||
"react-native-purchases": "^9.6.12",
|
"react-native-purchases": "^9.6.12",
|
||||||
"react-native-quick-crypto": "^0.7.11",
|
|
||||||
"react-native-reanimated": "~3.16.0",
|
"react-native-reanimated": "~3.16.0",
|
||||||
"react-native-safe-area-context": "4.12.0",
|
"react-native-safe-area-context": "4.12.0",
|
||||||
"react-native-screens": "~4.4.0",
|
"react-native-screens": "~4.4.0",
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
# Sauci Mobile Release Notes
|
||||||
|
|
||||||
|
## v1.0.1 (Build 30) - January 2025
|
||||||
|
|
||||||
|
### New Features
|
||||||
|
- **Match Archiving** - Archive old matches to keep your active list focused
|
||||||
|
- **Daily Swipe Limits** - Pace your discovery with daily question limits
|
||||||
|
- **Intensity Preferences** - Set your comfort level for question intensity (1-5 scale)
|
||||||
|
- **Infinite Scroll** - Smoother browsing through Matches and Answers with infinite scroll
|
||||||
|
- **Chat Date Separators** - Easily see when conversations happened
|
||||||
|
- **Lifecycle Notifications** - Get reminded to check in with your partner
|
||||||
|
- **Message Reporting** - Report inappropriate messages for review
|
||||||
|
- **Account Deletion** - Full account deletion option in settings
|
||||||
|
- **Reset Progress** - Start fresh by resetting your question progress
|
||||||
|
|
||||||
|
### Improvements
|
||||||
|
- **Enhanced Swipe UI** - Smoother swiping experience with visual refinements
|
||||||
|
- **Improved Chat Layout** - Match card moved into message list for cleaner design
|
||||||
|
- **Keyboard Behavior** - Chat header collapses when keyboard opens for more screen space
|
||||||
|
- **Profile Settings** - New appearance and notification preferences
|
||||||
|
- **Premium Design** - Updated subscription card with gold styling
|
||||||
|
- **Version Display** - App version now visible in settings
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
- Fixed subscription status incorrectly showing "Via partner's" after direct purchase
|
||||||
|
- Fixed Android navigation issues
|
||||||
|
- Various performance improvements and stability fixes
|
||||||
|
|
||||||
|
### Privacy
|
||||||
|
- Removed partner answered indicator for enhanced privacy
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v1.0.0 (Build 25) - January 8, 2025
|
||||||
|
|
||||||
|
Initial release.
|
||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
StyleSheet,
|
StyleSheet,
|
||||||
Pressable,
|
Pressable,
|
||||||
Animated,
|
Animated,
|
||||||
|
Keyboard,
|
||||||
} from 'react-native';
|
} from 'react-native';
|
||||||
import { LinearGradient } from 'expo-linear-gradient';
|
import { LinearGradient } from 'expo-linear-gradient';
|
||||||
import { BlurView } from 'expo-blur';
|
import { BlurView } from 'expo-blur';
|
||||||
@@ -106,6 +107,9 @@ export const MatchNotificationModal: React.FC<Props> = ({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (visible) {
|
if (visible) {
|
||||||
|
// Dismiss keyboard so it doesn't block the modal buttons
|
||||||
|
Keyboard.dismiss();
|
||||||
|
|
||||||
// Reset animations
|
// Reset animations
|
||||||
modalScale.setValue(0.8);
|
modalScale.setValue(0.8);
|
||||||
modalOpacity.setValue(0);
|
modalOpacity.setValue(0);
|
||||||
|
|||||||
@@ -139,6 +139,7 @@ const ChatMessagesComponent: React.FC<ChatMessagesProps> = ({
|
|||||||
style={styles.messageList}
|
style={styles.messageList}
|
||||||
contentContainerStyle={styles.listContent}
|
contentContainerStyle={styles.listContent}
|
||||||
keyboardShouldPersistTaps="handled"
|
keyboardShouldPersistTaps="handled"
|
||||||
|
keyboardDismissMode="on-drag"
|
||||||
showsVerticalScrollIndicator={false}
|
showsVerticalScrollIndicator={false}
|
||||||
// Performance optimizations
|
// Performance optimizations
|
||||||
removeClippedSubviews={true}
|
removeClippedSubviews={true}
|
||||||
|
|||||||
@@ -1,32 +0,0 @@
|
|||||||
/**
|
|
||||||
* Crypto polyfill for React Native
|
|
||||||
*
|
|
||||||
* This file must be imported BEFORE any code that uses crypto.
|
|
||||||
* It installs react-native-quick-crypto as the global crypto implementation.
|
|
||||||
*
|
|
||||||
* On web, we skip this since the native Web Crypto API is available.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { Platform } from 'react-native';
|
|
||||||
|
|
||||||
if (Platform.OS !== 'web') {
|
|
||||||
try {
|
|
||||||
// Import react-native-quick-crypto to install its polyfill
|
|
||||||
const QuickCrypto = require('react-native-quick-crypto');
|
|
||||||
|
|
||||||
// Install the polyfill globally if not already present
|
|
||||||
if (typeof global.crypto === 'undefined') {
|
|
||||||
(global as any).crypto = QuickCrypto;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Also ensure subtle is available
|
|
||||||
if (global.crypto && !global.crypto.subtle) {
|
|
||||||
(global.crypto as any).subtle = QuickCrypto.subtle;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log('[Crypto] react-native-quick-crypto polyfill installed');
|
|
||||||
} catch (error) {
|
|
||||||
console.error('[Crypto] Failed to install crypto polyfill:', error);
|
|
||||||
console.error('[Crypto] Make sure you have run: npx expo prebuild --clean');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
import "jsr:@supabase/functions-js/edge-runtime.d.ts";
|
||||||
|
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send delayed pack change notifications.
|
||||||
|
* Called by pg_cron every 5 minutes to check for pending notifications
|
||||||
|
* where notify_at has passed.
|
||||||
|
*
|
||||||
|
* Notifications are sent when a user enables new question packs. The notification
|
||||||
|
* is delayed by 30 minutes, with the timer resetting on each new pack enable.
|
||||||
|
* This prevents spam when a user enables multiple packs in quick succession.
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface ExpoPushMessage {
|
||||||
|
to: string;
|
||||||
|
title: string;
|
||||||
|
body: string;
|
||||||
|
sound?: string;
|
||||||
|
data?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sendExpoPushNotifications(messages: ExpoPushMessage[]) {
|
||||||
|
if (messages.length === 0) return;
|
||||||
|
|
||||||
|
const response = await fetch("https://exp.host/--/api/v2/push/send", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Accept: "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(messages),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const error = await response.text();
|
||||||
|
console.error("Expo push error:", error);
|
||||||
|
throw new Error(`Failed to send push notification: ${error}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
Deno.serve(async (req) => {
|
||||||
|
// Only allow POST (from cron) or GET (for manual testing)
|
||||||
|
if (req.method !== "POST" && req.method !== "GET") {
|
||||||
|
return new Response("Method not allowed", { status: 405 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const supabase = createClient(
|
||||||
|
Deno.env.get("SUPABASE_URL")!,
|
||||||
|
Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Get all pending notifications where notify_at has passed
|
||||||
|
const { data: pendingNotifications, error: fetchError } = await supabase
|
||||||
|
.from("pending_pack_notifications")
|
||||||
|
.select("id, couple_id, changed_by_user_id, notify_at")
|
||||||
|
.lte("notify_at", new Date().toISOString());
|
||||||
|
|
||||||
|
if (fetchError) {
|
||||||
|
console.error("Error fetching pending notifications:", fetchError);
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: fetchError.message }),
|
||||||
|
{ status: 500, headers: { "Content-Type": "application/json" } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!pendingNotifications || pendingNotifications.length === 0) {
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ sent: 0, message: "No pending notifications" }),
|
||||||
|
{ headers: { "Content-Type": "application/json" } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const messages: ExpoPushMessage[] = [];
|
||||||
|
const processedIds: string[] = [];
|
||||||
|
|
||||||
|
for (const notification of pendingNotifications) {
|
||||||
|
// Get the partner's push token (not the user who made changes)
|
||||||
|
const { data: partner, error: partnerError } = await supabase
|
||||||
|
.from("profiles")
|
||||||
|
.select("push_token")
|
||||||
|
.eq("couple_id", notification.couple_id)
|
||||||
|
.neq("id", notification.changed_by_user_id)
|
||||||
|
.maybeSingle();
|
||||||
|
|
||||||
|
if (partnerError) {
|
||||||
|
console.error(
|
||||||
|
`Error fetching partner for couple ${notification.couple_id}:`,
|
||||||
|
partnerError
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (partner?.push_token) {
|
||||||
|
messages.push({
|
||||||
|
to: partner.push_token,
|
||||||
|
title: "Question packs updated",
|
||||||
|
body: "Your partner updated the question packs",
|
||||||
|
sound: "default",
|
||||||
|
data: { type: "pack_change" },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
processedIds.push(notification.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send all notifications
|
||||||
|
if (messages.length > 0) {
|
||||||
|
await sendExpoPushNotifications(messages);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete processed notifications
|
||||||
|
if (processedIds.length > 0) {
|
||||||
|
const { error: deleteError } = await supabase
|
||||||
|
.from("pending_pack_notifications")
|
||||||
|
.delete()
|
||||||
|
.in("id", processedIds);
|
||||||
|
|
||||||
|
if (deleteError) {
|
||||||
|
console.error("Error deleting processed notifications:", deleteError);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Sent ${messages.length} pack change notifications`);
|
||||||
|
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
sent: messages.length,
|
||||||
|
processed: processedIds.length,
|
||||||
|
}),
|
||||||
|
{ headers: { "Content-Type": "application/json" } }
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Unexpected error:", err);
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({ error: "Internal server error" }),
|
||||||
|
{ status: 500, headers: { "Content-Type": "application/json" } }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
File renamed without changes.
File renamed without changes.
@@ -0,0 +1,228 @@
|
|||||||
|
-- Randomize question order for variety
|
||||||
|
-- Changes ORDER BY from intensity ASC to RANDOM()
|
||||||
|
-- Keeps partner-answered priority for gap resolution
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION get_recommended_questions(target_pack_id UUID DEFAULT NULL)
|
||||||
|
RETURNS TABLE(
|
||||||
|
id UUID,
|
||||||
|
text TEXT,
|
||||||
|
partner_text TEXT,
|
||||||
|
is_two_part BOOLEAN,
|
||||||
|
pack_id UUID,
|
||||||
|
intensity INTEGER,
|
||||||
|
partner_answered BOOLEAN,
|
||||||
|
allowed_couple_genders TEXT[],
|
||||||
|
target_user_genders TEXT[]
|
||||||
|
)
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY DEFINER
|
||||||
|
SET search_path = 'public'
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_user_id UUID;
|
||||||
|
v_couple_id UUID;
|
||||||
|
v_partner_id UUID;
|
||||||
|
v_user_gender TEXT;
|
||||||
|
v_partner_gender TEXT;
|
||||||
|
v_couple_type TEXT;
|
||||||
|
v_has_premium BOOLEAN;
|
||||||
|
v_gap_threshold INTEGER;
|
||||||
|
v_my_unanswered INTEGER;
|
||||||
|
v_partner_unanswered INTEGER;
|
||||||
|
v_net_gap INTEGER;
|
||||||
|
v_user_max_intensity INTEGER;
|
||||||
|
v_daily_limit INTEGER;
|
||||||
|
v_daily_count INTEGER;
|
||||||
|
v_today_start TIMESTAMPTZ;
|
||||||
|
v_tomorrow_start TIMESTAMPTZ;
|
||||||
|
BEGIN
|
||||||
|
v_user_id := auth.uid();
|
||||||
|
|
||||||
|
SELECT p.couple_id, p.gender, COALESCE(p.max_intensity, 5)
|
||||||
|
INTO v_couple_id, v_user_gender, v_user_max_intensity
|
||||||
|
FROM public.profiles p
|
||||||
|
WHERE p.id = v_user_id;
|
||||||
|
|
||||||
|
IF v_couple_id IS NULL THEN
|
||||||
|
RETURN;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
SELECT p.id, p.gender INTO v_partner_id, v_partner_gender
|
||||||
|
FROM public.profiles p
|
||||||
|
WHERE p.couple_id = v_couple_id AND p.id != v_user_id
|
||||||
|
LIMIT 1;
|
||||||
|
|
||||||
|
-- Check premium access (used for both answer gap and daily limit)
|
||||||
|
v_has_premium := public.has_premium_access(v_user_id);
|
||||||
|
|
||||||
|
-- Check daily response limit (only when not browsing a specific pack and user is non-premium)
|
||||||
|
IF v_partner_id IS NOT NULL AND target_pack_id IS NULL AND NOT v_has_premium THEN
|
||||||
|
-- Get daily limit from app_config
|
||||||
|
SELECT COALESCE(ac.daily_response_limit, 0) INTO v_daily_limit
|
||||||
|
FROM app_config ac
|
||||||
|
LIMIT 1;
|
||||||
|
|
||||||
|
-- If daily limit > 0, check if user has reached it
|
||||||
|
IF v_daily_limit > 0 THEN
|
||||||
|
-- Calculate UTC day boundaries
|
||||||
|
v_today_start := date_trunc('day', NOW() AT TIME ZONE 'UTC') AT TIME ZONE 'UTC';
|
||||||
|
v_tomorrow_start := v_today_start + INTERVAL '1 day';
|
||||||
|
|
||||||
|
-- Count responses created today (UTC)
|
||||||
|
SELECT COUNT(*)::INTEGER INTO v_daily_count
|
||||||
|
FROM responses r
|
||||||
|
WHERE r.user_id = v_user_id
|
||||||
|
AND r.created_at >= v_today_start
|
||||||
|
AND r.created_at < v_tomorrow_start;
|
||||||
|
|
||||||
|
-- If over daily limit, return empty (old apps see "All caught up!")
|
||||||
|
IF v_daily_count >= v_daily_limit THEN
|
||||||
|
RETURN;
|
||||||
|
END IF;
|
||||||
|
END IF;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Check answer gap threshold using NET gap (only when not browsing a specific pack)
|
||||||
|
IF v_partner_id IS NOT NULL AND target_pack_id IS NULL THEN
|
||||||
|
-- Get threshold from app_config
|
||||||
|
SELECT COALESCE(ac.answer_gap_threshold, 10) INTO v_gap_threshold
|
||||||
|
FROM app_config ac
|
||||||
|
LIMIT 1;
|
||||||
|
|
||||||
|
-- If threshold > 0, check the NET gap
|
||||||
|
IF v_gap_threshold > 0 THEN
|
||||||
|
-- Calculate NET gap to prevent deadlocks
|
||||||
|
WITH enabled_packs AS (
|
||||||
|
SELECT cp.pack_id
|
||||||
|
FROM couple_packs cp
|
||||||
|
WHERE cp.couple_id = v_couple_id AND cp.enabled = true
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
-- Questions I answered that partner hasn't
|
||||||
|
COALESCE(SUM(CASE WHEN r.user_id = v_user_id AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM responses pr
|
||||||
|
WHERE pr.question_id = r.question_id
|
||||||
|
AND pr.user_id = v_partner_id
|
||||||
|
) THEN 1 ELSE 0 END), 0)::integer,
|
||||||
|
-- Questions partner answered that I haven't
|
||||||
|
COALESCE(SUM(CASE WHEN r.user_id = v_partner_id AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM responses pr
|
||||||
|
WHERE pr.question_id = r.question_id
|
||||||
|
AND pr.user_id = v_user_id
|
||||||
|
) THEN 1 ELSE 0 END), 0)::integer
|
||||||
|
INTO v_my_unanswered, v_partner_unanswered
|
||||||
|
FROM responses r
|
||||||
|
INNER JOIN questions q ON q.id = r.question_id
|
||||||
|
WHERE r.couple_id = v_couple_id
|
||||||
|
AND r.user_id IN (v_user_id, v_partner_id)
|
||||||
|
AND EXISTS (SELECT 1 FROM enabled_packs)
|
||||||
|
AND q.pack_id IN (SELECT ep.pack_id FROM enabled_packs ep);
|
||||||
|
|
||||||
|
-- Net gap: only positive values mean user is truly ahead
|
||||||
|
v_net_gap := GREATEST(0, v_my_unanswered - v_partner_unanswered);
|
||||||
|
|
||||||
|
-- If over threshold, return empty (old apps see "All caught up!")
|
||||||
|
IF v_net_gap >= v_gap_threshold THEN
|
||||||
|
RETURN;
|
||||||
|
END IF;
|
||||||
|
END IF;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF v_user_gender IS NOT NULL AND v_partner_gender IS NOT NULL THEN
|
||||||
|
IF v_user_gender < v_partner_gender THEN
|
||||||
|
v_couple_type := v_user_gender || '+' || v_partner_gender;
|
||||||
|
ELSE
|
||||||
|
v_couple_type := v_partner_gender || '+' || v_user_gender;
|
||||||
|
END IF;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
RETURN QUERY
|
||||||
|
WITH user_config_exists AS (
|
||||||
|
SELECT 1 FROM public.couple_packs WHERE couple_id = v_couple_id LIMIT 1
|
||||||
|
),
|
||||||
|
active_packs AS (
|
||||||
|
SELECT target_pack_id AS pack_id
|
||||||
|
WHERE target_pack_id IS NOT NULL
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1 FROM public.question_packs qp
|
||||||
|
WHERE qp.id = target_pack_id
|
||||||
|
AND (qp.is_premium = FALSE OR v_has_premium = TRUE)
|
||||||
|
)
|
||||||
|
UNION ALL
|
||||||
|
SELECT cp.pack_id FROM public.couple_packs cp
|
||||||
|
INNER JOIN public.question_packs qp ON qp.id = cp.pack_id
|
||||||
|
WHERE target_pack_id IS NULL
|
||||||
|
AND cp.couple_id = v_couple_id
|
||||||
|
AND cp.enabled = TRUE
|
||||||
|
AND (qp.is_premium = FALSE OR v_has_premium = TRUE)
|
||||||
|
AND EXISTS (SELECT 1 FROM user_config_exists)
|
||||||
|
UNION ALL
|
||||||
|
SELECT p.id AS pack_id FROM public.question_packs p
|
||||||
|
WHERE target_pack_id IS NULL
|
||||||
|
AND p.is_public = TRUE
|
||||||
|
AND (p.is_premium = FALSE OR v_has_premium = TRUE)
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM user_config_exists)
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
q.id,
|
||||||
|
CASE
|
||||||
|
WHEN q.partner_text IS NOT NULL AND EXISTS (
|
||||||
|
SELECT 1 FROM public.responses r
|
||||||
|
WHERE r.question_id = q.id
|
||||||
|
AND r.couple_id = v_couple_id
|
||||||
|
AND r.user_id != v_user_id
|
||||||
|
) THEN q.partner_text
|
||||||
|
ELSE q.text
|
||||||
|
END as text,
|
||||||
|
q.partner_text,
|
||||||
|
q.partner_text IS NOT NULL as is_two_part,
|
||||||
|
q.pack_id,
|
||||||
|
q.intensity,
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM public.responses r
|
||||||
|
WHERE r.question_id = q.id
|
||||||
|
AND r.couple_id = v_couple_id
|
||||||
|
AND r.user_id != v_user_id
|
||||||
|
) AS partner_answered,
|
||||||
|
q.allowed_couple_genders,
|
||||||
|
q.target_user_genders
|
||||||
|
FROM public.questions q
|
||||||
|
WHERE q.pack_id IN (SELECT ap.pack_id FROM active_packs ap)
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM public.responses r
|
||||||
|
WHERE r.question_id = q.id
|
||||||
|
AND r.user_id = v_user_id
|
||||||
|
)
|
||||||
|
AND (
|
||||||
|
q.allowed_couple_genders IS NULL
|
||||||
|
OR v_couple_type IS NULL
|
||||||
|
OR v_couple_type = ANY(q.allowed_couple_genders)
|
||||||
|
)
|
||||||
|
AND (
|
||||||
|
q.target_user_genders IS NULL
|
||||||
|
OR v_user_gender = ANY(q.target_user_genders)
|
||||||
|
OR EXISTS (
|
||||||
|
SELECT 1 FROM public.responses r
|
||||||
|
WHERE r.question_id = q.id
|
||||||
|
AND r.couple_id = v_couple_id
|
||||||
|
AND r.user_id != v_user_id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
-- Filter by user's max_intensity preference
|
||||||
|
AND (
|
||||||
|
q.intensity IS NULL
|
||||||
|
OR q.intensity <= v_user_max_intensity
|
||||||
|
)
|
||||||
|
-- ORDER BY: Partner-answered questions first, then RANDOM for variety
|
||||||
|
ORDER BY
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM public.responses r
|
||||||
|
WHERE r.question_id = q.id
|
||||||
|
AND r.couple_id = v_couple_id
|
||||||
|
AND r.user_id != v_user_id
|
||||||
|
) DESC,
|
||||||
|
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.';
|
||||||
@@ -0,0 +1,341 @@
|
|||||||
|
-- Fix answer gap deadlock by calculating NET gap instead of independent gaps
|
||||||
|
-- Also add ORDER BY to get_recommended_questions to prioritize partner-answered questions
|
||||||
|
|
||||||
|
-- ============================================================================
|
||||||
|
-- Part 1: Fix get_answer_gap_status() to calculate NET gap
|
||||||
|
-- ============================================================================
|
||||||
|
--
|
||||||
|
-- Problem: Both partners can be "ahead" simultaneously when they answer different questions
|
||||||
|
-- Example: User A answers Q1-Q10, User B answers Q11-Q20
|
||||||
|
-- - Old: Both have gap=10, both blocked (deadlock!)
|
||||||
|
-- - New: Both have net_gap=0, neither blocked
|
||||||
|
--
|
||||||
|
-- NET gap = (questions I answered that partner hasn't) - (questions partner answered that I haven't)
|
||||||
|
-- Only positive values mean user is truly ahead
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION get_answer_gap_status()
|
||||||
|
RETURNS TABLE(
|
||||||
|
unanswered_by_partner integer,
|
||||||
|
threshold integer,
|
||||||
|
is_blocked boolean
|
||||||
|
)
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY DEFINER
|
||||||
|
SET search_path = 'public'
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_user_id UUID;
|
||||||
|
v_couple_id UUID;
|
||||||
|
v_partner_id UUID;
|
||||||
|
v_my_unanswered integer;
|
||||||
|
v_partner_unanswered integer;
|
||||||
|
v_net_gap integer;
|
||||||
|
v_threshold integer;
|
||||||
|
BEGIN
|
||||||
|
v_user_id := auth.uid();
|
||||||
|
|
||||||
|
-- Get user's couple_id
|
||||||
|
SELECT p.couple_id INTO v_couple_id
|
||||||
|
FROM profiles p
|
||||||
|
WHERE p.id = v_user_id;
|
||||||
|
|
||||||
|
-- No couple = no blocking needed
|
||||||
|
IF v_couple_id IS NULL THEN
|
||||||
|
RETURN QUERY SELECT 0, 0, false;
|
||||||
|
RETURN;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Get partner's ID
|
||||||
|
SELECT p.id INTO v_partner_id
|
||||||
|
FROM profiles p
|
||||||
|
WHERE p.couple_id = v_couple_id AND p.id != v_user_id
|
||||||
|
LIMIT 1;
|
||||||
|
|
||||||
|
-- No partner = no blocking needed
|
||||||
|
IF v_partner_id IS NULL THEN
|
||||||
|
RETURN QUERY SELECT 0, 0, false;
|
||||||
|
RETURN;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Get threshold from app_config
|
||||||
|
SELECT COALESCE(ac.answer_gap_threshold, 10) INTO v_threshold
|
||||||
|
FROM app_config ac
|
||||||
|
LIMIT 1;
|
||||||
|
|
||||||
|
-- If threshold is 0, feature is disabled
|
||||||
|
IF v_threshold = 0 THEN
|
||||||
|
RETURN QUERY SELECT 0, 0, false;
|
||||||
|
RETURN;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Calculate NET gap to prevent deadlocks
|
||||||
|
WITH enabled_packs AS (
|
||||||
|
-- Get packs that the couple has explicitly enabled
|
||||||
|
SELECT cp.pack_id
|
||||||
|
FROM couple_packs cp
|
||||||
|
WHERE cp.couple_id = v_couple_id AND cp.enabled = true
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
-- Questions I answered that partner hasn't
|
||||||
|
COALESCE(SUM(CASE WHEN r.user_id = v_user_id AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM responses pr
|
||||||
|
WHERE pr.question_id = r.question_id
|
||||||
|
AND pr.user_id = v_partner_id
|
||||||
|
) THEN 1 ELSE 0 END), 0)::integer,
|
||||||
|
-- Questions partner answered that I haven't
|
||||||
|
COALESCE(SUM(CASE WHEN r.user_id = v_partner_id AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM responses pr
|
||||||
|
WHERE pr.question_id = r.question_id
|
||||||
|
AND pr.user_id = v_user_id
|
||||||
|
) THEN 1 ELSE 0 END), 0)::integer
|
||||||
|
INTO v_my_unanswered, v_partner_unanswered
|
||||||
|
FROM responses r
|
||||||
|
INNER JOIN questions q ON q.id = r.question_id
|
||||||
|
WHERE r.couple_id = v_couple_id
|
||||||
|
AND r.user_id IN (v_user_id, v_partner_id)
|
||||||
|
AND EXISTS (SELECT 1 FROM enabled_packs)
|
||||||
|
AND q.pack_id IN (SELECT pack_id FROM enabled_packs);
|
||||||
|
|
||||||
|
-- Net gap: only positive values mean user is truly ahead
|
||||||
|
-- If partner has answered more, user's net gap is 0 (they're behind or even)
|
||||||
|
v_net_gap := GREATEST(0, v_my_unanswered - v_partner_unanswered);
|
||||||
|
|
||||||
|
-- Return net gap (displayed as "unanswered_by_partner" for backwards compatibility)
|
||||||
|
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
|
||||||
|
-- ============================================================================
|
||||||
|
--
|
||||||
|
-- Add ORDER BY to ensure:
|
||||||
|
-- 1. Questions partner has already answered come first (helps resolve gaps)
|
||||||
|
-- 2. Lower intensity questions shown first within each group
|
||||||
|
|
||||||
|
CREATE OR REPLACE FUNCTION get_recommended_questions(target_pack_id UUID DEFAULT NULL)
|
||||||
|
RETURNS TABLE(
|
||||||
|
id UUID,
|
||||||
|
text TEXT,
|
||||||
|
partner_text TEXT,
|
||||||
|
is_two_part BOOLEAN,
|
||||||
|
pack_id UUID,
|
||||||
|
intensity INTEGER,
|
||||||
|
partner_answered BOOLEAN,
|
||||||
|
allowed_couple_genders TEXT[],
|
||||||
|
target_user_genders TEXT[]
|
||||||
|
)
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY DEFINER
|
||||||
|
SET search_path = 'public'
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_user_id UUID;
|
||||||
|
v_couple_id UUID;
|
||||||
|
v_partner_id UUID;
|
||||||
|
v_user_gender TEXT;
|
||||||
|
v_partner_gender TEXT;
|
||||||
|
v_couple_type TEXT;
|
||||||
|
v_has_premium BOOLEAN;
|
||||||
|
v_gap_threshold INTEGER;
|
||||||
|
v_my_unanswered INTEGER;
|
||||||
|
v_partner_unanswered INTEGER;
|
||||||
|
v_net_gap INTEGER;
|
||||||
|
v_user_max_intensity INTEGER;
|
||||||
|
v_daily_limit INTEGER;
|
||||||
|
v_daily_count INTEGER;
|
||||||
|
v_today_start TIMESTAMPTZ;
|
||||||
|
v_tomorrow_start TIMESTAMPTZ;
|
||||||
|
BEGIN
|
||||||
|
v_user_id := auth.uid();
|
||||||
|
|
||||||
|
SELECT p.couple_id, p.gender, COALESCE(p.max_intensity, 5)
|
||||||
|
INTO v_couple_id, v_user_gender, v_user_max_intensity
|
||||||
|
FROM public.profiles p
|
||||||
|
WHERE p.id = v_user_id;
|
||||||
|
|
||||||
|
IF v_couple_id IS NULL THEN
|
||||||
|
RETURN;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
SELECT p.id, p.gender INTO v_partner_id, v_partner_gender
|
||||||
|
FROM public.profiles p
|
||||||
|
WHERE p.couple_id = v_couple_id AND p.id != v_user_id
|
||||||
|
LIMIT 1;
|
||||||
|
|
||||||
|
-- Check premium access (used for both answer gap and daily limit)
|
||||||
|
v_has_premium := public.has_premium_access(v_user_id);
|
||||||
|
|
||||||
|
-- Check daily response limit (only when not browsing a specific pack and user is non-premium)
|
||||||
|
IF v_partner_id IS NOT NULL AND target_pack_id IS NULL AND NOT v_has_premium THEN
|
||||||
|
-- Get daily limit from app_config
|
||||||
|
SELECT COALESCE(ac.daily_response_limit, 0) INTO v_daily_limit
|
||||||
|
FROM app_config ac
|
||||||
|
LIMIT 1;
|
||||||
|
|
||||||
|
-- If daily limit > 0, check if user has reached it
|
||||||
|
IF v_daily_limit > 0 THEN
|
||||||
|
-- Calculate UTC day boundaries
|
||||||
|
v_today_start := date_trunc('day', NOW() AT TIME ZONE 'UTC') AT TIME ZONE 'UTC';
|
||||||
|
v_tomorrow_start := v_today_start + INTERVAL '1 day';
|
||||||
|
|
||||||
|
-- Count responses created today (UTC)
|
||||||
|
SELECT COUNT(*)::INTEGER INTO v_daily_count
|
||||||
|
FROM responses r
|
||||||
|
WHERE r.user_id = v_user_id
|
||||||
|
AND r.created_at >= v_today_start
|
||||||
|
AND r.created_at < v_tomorrow_start;
|
||||||
|
|
||||||
|
-- If over daily limit, return empty (old apps see "All caught up!")
|
||||||
|
IF v_daily_count >= v_daily_limit THEN
|
||||||
|
RETURN;
|
||||||
|
END IF;
|
||||||
|
END IF;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Check answer gap threshold using NET gap (only when not browsing a specific pack)
|
||||||
|
IF v_partner_id IS NOT NULL AND target_pack_id IS NULL THEN
|
||||||
|
-- Get threshold from app_config
|
||||||
|
SELECT COALESCE(ac.answer_gap_threshold, 10) INTO v_gap_threshold
|
||||||
|
FROM app_config ac
|
||||||
|
LIMIT 1;
|
||||||
|
|
||||||
|
-- If threshold > 0, check the NET gap
|
||||||
|
IF v_gap_threshold > 0 THEN
|
||||||
|
-- Calculate NET gap to prevent deadlocks
|
||||||
|
WITH enabled_packs AS (
|
||||||
|
SELECT cp.pack_id
|
||||||
|
FROM couple_packs cp
|
||||||
|
WHERE cp.couple_id = v_couple_id AND cp.enabled = true
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
-- Questions I answered that partner hasn't
|
||||||
|
COALESCE(SUM(CASE WHEN r.user_id = v_user_id AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM responses pr
|
||||||
|
WHERE pr.question_id = r.question_id
|
||||||
|
AND pr.user_id = v_partner_id
|
||||||
|
) THEN 1 ELSE 0 END), 0)::integer,
|
||||||
|
-- Questions partner answered that I haven't
|
||||||
|
COALESCE(SUM(CASE WHEN r.user_id = v_partner_id AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM responses pr
|
||||||
|
WHERE pr.question_id = r.question_id
|
||||||
|
AND pr.user_id = v_user_id
|
||||||
|
) THEN 1 ELSE 0 END), 0)::integer
|
||||||
|
INTO v_my_unanswered, v_partner_unanswered
|
||||||
|
FROM responses r
|
||||||
|
INNER JOIN questions q ON q.id = r.question_id
|
||||||
|
WHERE r.couple_id = v_couple_id
|
||||||
|
AND r.user_id IN (v_user_id, v_partner_id)
|
||||||
|
AND EXISTS (SELECT 1 FROM enabled_packs)
|
||||||
|
AND q.pack_id IN (SELECT ep.pack_id FROM enabled_packs ep);
|
||||||
|
|
||||||
|
-- Net gap: only positive values mean user is truly ahead
|
||||||
|
v_net_gap := GREATEST(0, v_my_unanswered - v_partner_unanswered);
|
||||||
|
|
||||||
|
-- If over threshold, return empty (old apps see "All caught up!")
|
||||||
|
IF v_net_gap >= v_gap_threshold THEN
|
||||||
|
RETURN;
|
||||||
|
END IF;
|
||||||
|
END IF;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
IF v_user_gender IS NOT NULL AND v_partner_gender IS NOT NULL THEN
|
||||||
|
IF v_user_gender < v_partner_gender THEN
|
||||||
|
v_couple_type := v_user_gender || '+' || v_partner_gender;
|
||||||
|
ELSE
|
||||||
|
v_couple_type := v_partner_gender || '+' || v_user_gender;
|
||||||
|
END IF;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
RETURN QUERY
|
||||||
|
WITH user_config_exists AS (
|
||||||
|
SELECT 1 FROM public.couple_packs WHERE couple_id = v_couple_id LIMIT 1
|
||||||
|
),
|
||||||
|
active_packs AS (
|
||||||
|
SELECT target_pack_id AS pack_id
|
||||||
|
WHERE target_pack_id IS NOT NULL
|
||||||
|
AND EXISTS (
|
||||||
|
SELECT 1 FROM public.question_packs qp
|
||||||
|
WHERE qp.id = target_pack_id
|
||||||
|
AND (qp.is_premium = FALSE OR v_has_premium = TRUE)
|
||||||
|
)
|
||||||
|
UNION ALL
|
||||||
|
SELECT cp.pack_id FROM public.couple_packs cp
|
||||||
|
INNER JOIN public.question_packs qp ON qp.id = cp.pack_id
|
||||||
|
WHERE target_pack_id IS NULL
|
||||||
|
AND cp.couple_id = v_couple_id
|
||||||
|
AND cp.enabled = TRUE
|
||||||
|
AND (qp.is_premium = FALSE OR v_has_premium = TRUE)
|
||||||
|
AND EXISTS (SELECT 1 FROM user_config_exists)
|
||||||
|
UNION ALL
|
||||||
|
SELECT p.id AS pack_id FROM public.question_packs p
|
||||||
|
WHERE target_pack_id IS NULL
|
||||||
|
AND p.is_public = TRUE
|
||||||
|
AND (p.is_premium = FALSE OR v_has_premium = TRUE)
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM user_config_exists)
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
q.id,
|
||||||
|
CASE
|
||||||
|
WHEN q.partner_text IS NOT NULL AND EXISTS (
|
||||||
|
SELECT 1 FROM public.responses r
|
||||||
|
WHERE r.question_id = q.id
|
||||||
|
AND r.couple_id = v_couple_id
|
||||||
|
AND r.user_id != v_user_id
|
||||||
|
) THEN q.partner_text
|
||||||
|
ELSE q.text
|
||||||
|
END as text,
|
||||||
|
q.partner_text,
|
||||||
|
q.partner_text IS NOT NULL as is_two_part,
|
||||||
|
q.pack_id,
|
||||||
|
q.intensity,
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM public.responses r
|
||||||
|
WHERE r.question_id = q.id
|
||||||
|
AND r.couple_id = v_couple_id
|
||||||
|
AND r.user_id != v_user_id
|
||||||
|
) AS partner_answered,
|
||||||
|
q.allowed_couple_genders,
|
||||||
|
q.target_user_genders
|
||||||
|
FROM public.questions q
|
||||||
|
WHERE q.pack_id IN (SELECT ap.pack_id FROM active_packs ap)
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM public.responses r
|
||||||
|
WHERE r.question_id = q.id
|
||||||
|
AND r.user_id = v_user_id
|
||||||
|
)
|
||||||
|
AND (
|
||||||
|
q.allowed_couple_genders IS NULL
|
||||||
|
OR v_couple_type IS NULL
|
||||||
|
OR v_couple_type = ANY(q.allowed_couple_genders)
|
||||||
|
)
|
||||||
|
AND (
|
||||||
|
q.target_user_genders IS NULL
|
||||||
|
OR v_user_gender = ANY(q.target_user_genders)
|
||||||
|
OR EXISTS (
|
||||||
|
SELECT 1 FROM public.responses r
|
||||||
|
WHERE r.question_id = q.id
|
||||||
|
AND r.couple_id = v_couple_id
|
||||||
|
AND r.user_id != v_user_id
|
||||||
|
)
|
||||||
|
)
|
||||||
|
-- Filter by user's max_intensity preference
|
||||||
|
AND (
|
||||||
|
q.intensity IS NULL
|
||||||
|
OR q.intensity <= v_user_max_intensity
|
||||||
|
)
|
||||||
|
-- ORDER BY: Partner-answered questions first to help resolve gaps, then by intensity
|
||||||
|
ORDER BY
|
||||||
|
EXISTS (
|
||||||
|
SELECT 1 FROM public.responses r
|
||||||
|
WHERE r.question_id = q.id
|
||||||
|
AND r.couple_id = v_couple_id
|
||||||
|
AND r.user_id != v_user_id
|
||||||
|
) DESC,
|
||||||
|
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.';
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
-- Add delayed pack change notification system
|
||||||
|
-- Notifies partner when a user enables new question packs (with 30-min debounce)
|
||||||
|
|
||||||
|
-- Create pending notifications table
|
||||||
|
CREATE TABLE pending_pack_notifications (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
couple_id UUID NOT NULL REFERENCES couples(id) ON DELETE CASCADE,
|
||||||
|
changed_by_user_id UUID NOT NULL REFERENCES profiles(id) ON DELETE CASCADE,
|
||||||
|
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_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
|
||||||
|
LANGUAGE plpgsql
|
||||||
|
SECURITY DEFINER
|
||||||
|
SET search_path = 'public'
|
||||||
|
AS $$
|
||||||
|
DECLARE
|
||||||
|
v_user_id UUID;
|
||||||
|
BEGIN
|
||||||
|
-- Only trigger when enabling a pack (not disabling)
|
||||||
|
IF NEW.enabled = false THEN
|
||||||
|
RETURN NEW;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- For UPDATE, only proceed if changing from disabled to enabled
|
||||||
|
IF TG_OP = 'UPDATE' AND OLD.enabled = true THEN
|
||||||
|
RETURN NEW;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- Get the user making the change
|
||||||
|
v_user_id := auth.uid();
|
||||||
|
|
||||||
|
-- Skip if no auth context (service role operations)
|
||||||
|
IF v_user_id IS NULL THEN
|
||||||
|
RETURN NEW;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- UPSERT: Insert new notification or reset timer on existing one
|
||||||
|
INSERT INTO pending_pack_notifications (
|
||||||
|
couple_id,
|
||||||
|
changed_by_user_id,
|
||||||
|
notify_at
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
NEW.couple_id,
|
||||||
|
v_user_id,
|
||||||
|
now() + interval '30 minutes'
|
||||||
|
)
|
||||||
|
ON CONFLICT (couple_id) DO UPDATE SET
|
||||||
|
changed_by_user_id = EXCLUDED.changed_by_user_id,
|
||||||
|
notify_at = now() + interval '30 minutes',
|
||||||
|
updated_at = now();
|
||||||
|
|
||||||
|
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',
|
||||||
|
'*/5 * * * *',
|
||||||
|
$$
|
||||||
|
SELECT net.http_post(
|
||||||
|
url := 'https://ckjcrkjpmhqhiucifukx.supabase.co/functions/v1/send-pack-change-notification',
|
||||||
|
headers := jsonb_build_object('Content-Type', 'application/json'),
|
||||||
|
body := '{}'::jsonb
|
||||||
|
)
|
||||||
|
$$
|
||||||
|
);
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"cli": {
|
||||||
|
"version": ">= 16.28.0",
|
||||||
|
"appVersionSource": "remote"
|
||||||
|
},
|
||||||
|
"build": {
|
||||||
|
"development": {
|
||||||
|
"developmentClient": true,
|
||||||
|
"distribution": "internal"
|
||||||
|
},
|
||||||
|
"preview": {
|
||||||
|
"distribution": "internal"
|
||||||
|
},
|
||||||
|
"production": {
|
||||||
|
"autoIncrement": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"submit": {
|
||||||
|
"production": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+31
-117
@@ -15,12 +15,12 @@
|
|||||||
"turbo": "^2.3.3",
|
"turbo": "^2.3.3",
|
||||||
"typescript": "^5.3.3"
|
"typescript": "^5.3.3"
|
||||||
},
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@rollup/rollup-linux-arm64-musl": "4.54.0",
|
"@rollup/rollup-linux-arm64-musl": "4.54.0",
|
||||||
"@rollup/rollup-linux-x64-musl": "4.54.0"
|
"@rollup/rollup-linux-x64-musl": "4.54.0"
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">=20"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"apps/admin": {
|
"apps/admin": {
|
||||||
@@ -42,7 +42,7 @@
|
|||||||
"@radix-ui/react-switch": "^1.0.3",
|
"@radix-ui/react-switch": "^1.0.3",
|
||||||
"@radix-ui/react-tabs": "^1.0.4",
|
"@radix-ui/react-tabs": "^1.0.4",
|
||||||
"@radix-ui/react-tooltip": "^1.0.7",
|
"@radix-ui/react-tooltip": "^1.0.7",
|
||||||
"@sauci/shared": "*",
|
"@sauci/shared": "file:../../packages/shared",
|
||||||
"@supabase/supabase-js": "^2.39.0",
|
"@supabase/supabase-js": "^2.39.0",
|
||||||
"class-variance-authority": "^0.7.0",
|
"class-variance-authority": "^0.7.0",
|
||||||
"clsx": "^2.1.0",
|
"clsx": "^2.1.0",
|
||||||
@@ -114,7 +114,6 @@
|
|||||||
"react-native-compressor": "^1.13.0",
|
"react-native-compressor": "^1.13.0",
|
||||||
"react-native-gesture-handler": "~2.20.0",
|
"react-native-gesture-handler": "~2.20.0",
|
||||||
"react-native-purchases": "^9.6.12",
|
"react-native-purchases": "^9.6.12",
|
||||||
"react-native-quick-crypto": "^0.7.11",
|
|
||||||
"react-native-reanimated": "~3.16.0",
|
"react-native-reanimated": "~3.16.0",
|
||||||
"react-native-safe-area-context": "4.12.0",
|
"react-native-safe-area-context": "4.12.0",
|
||||||
"react-native-screens": "~4.4.0",
|
"react-native-screens": "~4.4.0",
|
||||||
@@ -2288,30 +2287,6 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@craftzdog/react-native-buffer": {
|
|
||||||
"version": "6.1.1",
|
|
||||||
"resolved": "https://registry.npmjs.org/@craftzdog/react-native-buffer/-/react-native-buffer-6.1.1.tgz",
|
|
||||||
"integrity": "sha512-YXJ0Jr4V+Hk2CZXpQw0A0NJeuiW2Rv6rAAutJCZ2k/JG13vLsppUibkJ8exSMxODtH9yJUrLiR96rilG3pFZ4Q==",
|
|
||||||
"funding": [
|
|
||||||
{
|
|
||||||
"type": "github",
|
|
||||||
"url": "https://github.com/sponsors/feross"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "patreon",
|
|
||||||
"url": "https://www.patreon.com/feross"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "consulting",
|
|
||||||
"url": "https://feross.org/support"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"ieee754": "^1.2.1",
|
|
||||||
"react-native-quick-base64": "^2.2.2"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/@dnd-kit/accessibility": {
|
"node_modules/@dnd-kit/accessibility": {
|
||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
|
||||||
@@ -6384,6 +6359,32 @@
|
|||||||
"darwin"
|
"darwin"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"node_modules/@rollup/rollup-linux-arm64-musl": {
|
||||||
|
"version": "4.54.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.54.0.tgz",
|
||||||
|
"integrity": "sha512-gr5vDbg3Bakga5kbdpqx81m2n9IX8M6gIMlQQIXiLTNeQW6CucvuInJ91EuCJ/JYvc+rcLLsDFcfAD1K7fMofg==",
|
||||||
|
"cpu": [
|
||||||
|
"arm64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"node_modules/@rollup/rollup-linux-x64-musl": {
|
||||||
|
"version": "4.54.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.54.0.tgz",
|
||||||
|
"integrity": "sha512-JzQmb38ATzHjxlPHuTH6tE7ojnMKM2kYNzt44LO/jJi8BpceEC8QuXYA908n8r3CNuG/B3BV8VR3Hi1rYtmPiw==",
|
||||||
|
"cpu": [
|
||||||
|
"x64"
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"linux"
|
||||||
|
]
|
||||||
|
},
|
||||||
"node_modules/@sauci/admin": {
|
"node_modules/@sauci/admin": {
|
||||||
"resolved": "apps/admin",
|
"resolved": "apps/admin",
|
||||||
"link": true
|
"link": true
|
||||||
@@ -10364,6 +10365,7 @@
|
|||||||
"version": "3.3.0",
|
"version": "3.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
|
||||||
"integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==",
|
"integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==",
|
||||||
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=0.8.x"
|
"node": ">=0.8.x"
|
||||||
@@ -16145,15 +16147,6 @@
|
|||||||
"node": "^14.17.0 || ^16.13.0 || >=18.0.0"
|
"node": "^14.17.0 || ^16.13.0 || >=18.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/process": {
|
|
||||||
"version": "0.11.10",
|
|
||||||
"resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz",
|
|
||||||
"integrity": "sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==",
|
|
||||||
"license": "MIT",
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 0.6.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/progress": {
|
"node_modules/progress": {
|
||||||
"version": "2.0.3",
|
"version": "2.0.3",
|
||||||
"resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz",
|
||||||
@@ -16613,36 +16606,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/react-native-quick-base64": {
|
|
||||||
"version": "2.2.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/react-native-quick-base64/-/react-native-quick-base64-2.2.2.tgz",
|
|
||||||
"integrity": "sha512-WLHSifHLoamr2kF00Gov0W9ud6CfPshe1rmqWTquVIi9c62qxOaJCFVDrXFZhEBU8B8PvGLVuOlVKH78yhY0Fg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"workspaces": [
|
|
||||||
"example"
|
|
||||||
],
|
|
||||||
"peerDependencies": {
|
|
||||||
"react": "*",
|
|
||||||
"react-native": "*"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/react-native-quick-crypto": {
|
|
||||||
"version": "0.7.17",
|
|
||||||
"resolved": "https://registry.npmjs.org/react-native-quick-crypto/-/react-native-quick-crypto-0.7.17.tgz",
|
|
||||||
"integrity": "sha512-cJzp6oA/dM1lujt+Rwtn46Mgcs3w9F/0oQvNz1jcADc/AXktveAOUTzzKrDMxyg6YPziCYnoqMDzHBo6OLSU1g==",
|
|
||||||
"license": "MIT",
|
|
||||||
"workspaces": [
|
|
||||||
".",
|
|
||||||
"example"
|
|
||||||
],
|
|
||||||
"dependencies": {
|
|
||||||
"@craftzdog/react-native-buffer": "^6.0.5",
|
|
||||||
"events": "^3.3.0",
|
|
||||||
"readable-stream": "^4.5.2",
|
|
||||||
"string_decoder": "^1.3.0",
|
|
||||||
"util": "^0.12.5"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/react-native-reanimated": {
|
"node_modules/react-native-reanimated": {
|
||||||
"version": "3.16.7",
|
"version": "3.16.7",
|
||||||
"resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-3.16.7.tgz",
|
"resolved": "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-3.16.7.tgz",
|
||||||
@@ -17020,46 +16983,6 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/readable-stream": {
|
|
||||||
"version": "4.7.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz",
|
|
||||||
"integrity": "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"abort-controller": "^3.0.0",
|
|
||||||
"buffer": "^6.0.3",
|
|
||||||
"events": "^3.3.0",
|
|
||||||
"process": "^0.11.10",
|
|
||||||
"string_decoder": "^1.3.0"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/readable-stream/node_modules/buffer": {
|
|
||||||
"version": "6.0.3",
|
|
||||||
"resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz",
|
|
||||||
"integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==",
|
|
||||||
"funding": [
|
|
||||||
{
|
|
||||||
"type": "github",
|
|
||||||
"url": "https://github.com/sponsors/feross"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "patreon",
|
|
||||||
"url": "https://www.patreon.com/feross"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "consulting",
|
|
||||||
"url": "https://feross.org/support"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"base64-js": "^1.3.1",
|
|
||||||
"ieee754": "^1.2.1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/readdirp": {
|
"node_modules/readdirp": {
|
||||||
"version": "3.6.0",
|
"version": "3.6.0",
|
||||||
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
|
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
|
||||||
@@ -18053,15 +17976,6 @@
|
|||||||
"node": ">=4"
|
"node": ">=4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/string_decoder": {
|
|
||||||
"version": "1.3.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
|
|
||||||
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
|
|
||||||
"license": "MIT",
|
|
||||||
"dependencies": {
|
|
||||||
"safe-buffer": "~5.2.0"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/string-length": {
|
"node_modules/string-length": {
|
||||||
"version": "4.0.2",
|
"version": "4.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/string-length/-/string-length-4.0.2.tgz",
|
||||||
|
|||||||
Reference in new issue
Block a user