This commit is contained in:
jjbrunton committed 2025-12-30 15:54:34 +00:00
1 parent 456459bb00
commit 5b97bc6f56
60 files changed
+1362 -1896

No files matched your search

+6 -2
View File
@@ -1,8 +1,12 @@
{ {
"mcpServers": { "mcpServers": {
"supabase": { "sauci-prod": {
"type": "http", "type": "http",
"url": "https://mcp.supabase.com/mcp?project_ref=npmmakhjmbzlkcmfyegw" "url": "https://mcp.supabase.com/mcp?project_ref=ckjcrkjpmhqhiucifukx"
},
"sauci-non-prod": {
"type": "http",
"url": "https://mcp.supabase.com/mcp?project_ref=itbzhrvlgvdmzbnhzhyx"
} }
} }
} }
+14
View File
@@ -45,6 +45,20 @@ supabase functions serve # Run edge functions locally
supabase migration new <name> # Create new migration supabase migration new <name> # Create new migration
``` ```
### Supabase Environments
There are two Supabase projects configured via MCP servers:
| Environment | Project Ref | URL | MCP Server |
|-------------|-------------|-----|------------|
| **Production** | `ckjcrkjpmhqhiucifukx` | https://ckjcrkjpmhqhiucifukx.supabase.co | `sauci-prod` |
| **Non-Production** | `itbzhrvlgvdmzbnhzhyx` | https://itbzhrvlgvdmzbnhzhyx.supabase.co | `sauci-non-prod` |
When using Claude Code with MCP tools:
- Use `mcp__sauci-prod__*` tools for production database operations
- Use `mcp__sauci-non-prod__*` tools for development/staging database operations
- **Always verify which environment you're targeting before running migrations or destructive operations**
## Architecture ## Architecture
### Monorepo Structure (Turborepo) ### Monorepo Structure (Turborepo)
+8
View File
@@ -2,4 +2,12 @@ VITE_SUPABASE_URL=your_supabase_url
VITE_SUPABASE_ANON_KEY=your_supabase_anon_key VITE_SUPABASE_ANON_KEY=your_supabase_anon_key
VITE_OPENAI_API_KEY=your_openai_api_key VITE_OPENAI_API_KEY=your_openai_api_key
VITE_OPENROUTER_API_KEY=your_openrouter_api_key VITE_OPENROUTER_API_KEY=your_openrouter_api_key
# AI Model Configuration
# VITE_AI_MODEL is the fallback for all operations
VITE_AI_MODEL=openai/gpt-4o-mini VITE_AI_MODEL=openai/gpt-4o-mini
# Granular model overrides (optional - each falls back to VITE_AI_MODEL)
# VITE_AI_MODEL_GENERATE - for pack/question/category generation
# VITE_AI_MODEL_FIX - for fix targets and fix text analysis
# VITE_AI_MODEL_POLISH - for polishing/tidying up text
+4
View File
@@ -11,9 +11,13 @@ services:
- VITE_OPENAI_API_KEY=${VITE_OPENAI_API_KEY} - VITE_OPENAI_API_KEY=${VITE_OPENAI_API_KEY}
- VITE_OPENROUTER_API_KEY=${VITE_OPENROUTER_API_KEY} - VITE_OPENROUTER_API_KEY=${VITE_OPENROUTER_API_KEY}
- VITE_AI_MODEL=${VITE_AI_MODEL} - VITE_AI_MODEL=${VITE_AI_MODEL}
- VITE_AI_MODEL_GENERATE=${VITE_AI_MODEL_GENERATE}
- VITE_AI_MODEL_FIX=${VITE_AI_MODEL_FIX}
- VITE_AI_MODEL_POLISH=${VITE_AI_MODEL_POLISH}
volumes: volumes:
- ./src:/app/apps/admin/src - ./src:/app/apps/admin/src
- ./index.html:/app/apps/admin/index.html - ./index.html:/app/apps/admin/index.html
- ./vite.config.ts:/app/apps/admin/vite.config.ts - ./vite.config.ts:/app/apps/admin/vite.config.ts
- ./tsconfig.json:/app/apps/admin/tsconfig.json - ./tsconfig.json:/app/apps/admin/tsconfig.json
- ../../packages/shared/src:/app/packages/shared/src
restart: unless-stopped restart: unless-stopped
@@ -1,7 +1,8 @@
import { useState } from 'react'; import { useState, useEffect } from 'react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Checkbox } from '@/components/ui/checkbox';
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -10,8 +11,8 @@ import {
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
} from '@/components/ui/dialog'; } from '@/components/ui/dialog';
import { Sparkles, Loader2, RefreshCw, Check } from 'lucide-react'; import { Sparkles, Loader2, RefreshCw, Check, CheckSquare, Square } from 'lucide-react';
import { generatePack, generateQuestions, suggestCategories, suggestPacks, type GeneratedPack, type GeneratedQuestion, type GeneratedCategoryIdea, type GeneratedPackIdea } from '@/lib/openai'; import { generatePack, generateQuestions, suggestCategories, suggestPacks, TONE_LEVELS, INTENSITY_LEVELS, type GeneratedPack, type GeneratedQuestion, type GeneratedCategoryIdea, type GeneratedPackIdea, type ToneLevel } from '@/lib/openai';
import { Switch } from '@/components/ui/switch'; import { Switch } from '@/components/ui/switch';
import { toast } from 'sonner'; import { toast } from 'sonner';
@@ -22,8 +23,10 @@ interface AiGeneratorDialogProps {
context?: { context?: {
categoryName?: string; categoryName?: string;
packName?: string; packName?: string;
packDescription?: string | null;
existingCategories?: string[]; existingCategories?: string[];
existingPacks?: string[]; existingPacks?: string[];
isExplicit?: boolean;
}; };
onGenerated: (result: any) => void; onGenerated: (result: any) => void;
} }
@@ -37,7 +40,16 @@ export function AiGeneratorDialog({
}: AiGeneratorDialogProps) { }: AiGeneratorDialogProps) {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [count, setCount] = useState(10); const [count, setCount] = useState(10);
const [isExplicit, setIsExplicit] = useState(false); // Default tone based on pack's explicit flag (5 for explicit packs, 3 for non-explicit)
const [tone, setTone] = useState<ToneLevel>(context?.isExplicit ? 5 : 3);
const isExplicit = tone >= 4; // For other generators that still use boolean
// Update tone when dialog opens with new context
useEffect(() => {
if (open && type === 'questions') {
setTone(context?.isExplicit ? 5 : 3);
}
}, [open, context?.isExplicit, type]);
// Generated results // Generated results
const [generatedPack, setGeneratedPack] = useState<GeneratedPack | null>(null); const [generatedPack, setGeneratedPack] = useState<GeneratedPack | null>(null);
@@ -45,6 +57,9 @@ export function AiGeneratorDialog({
const [generatedIdeas, setGeneratedIdeas] = useState<GeneratedCategoryIdea[]>([]); const [generatedIdeas, setGeneratedIdeas] = useState<GeneratedCategoryIdea[]>([]);
const [generatedPackIdeas, setGeneratedPackIdeas] = useState<GeneratedPackIdea[]>([]); const [generatedPackIdeas, setGeneratedPackIdeas] = useState<GeneratedPackIdea[]>([]);
// Selection state for questions
const [selectedQuestionIndices, setSelectedQuestionIndices] = useState<Set<number>>(new Set());
const handleGenerate = async () => { const handleGenerate = async () => {
setLoading(true); setLoading(true);
try { try {
@@ -56,8 +71,10 @@ export function AiGeneratorDialog({
toast.error('Pack name is required'); toast.error('Pack name is required');
return; return;
} }
const result = await generateQuestions(context.packName, count, undefined, isExplicit); const result = await generateQuestions(context.packName, count, undefined, tone, context.packDescription || undefined);
setGeneratedQuestions(result); setGeneratedQuestions(result);
// Select all questions by default
setSelectedQuestionIndices(new Set(result.map((_, i) => i)));
} else if (type === 'category-ideas') { } else if (type === 'category-ideas') {
const result = await suggestCategories(context?.existingCategories || [], isExplicit); const result = await suggestCategories(context?.existingCategories || [], isExplicit);
setGeneratedIdeas(result); setGeneratedIdeas(result);
@@ -81,8 +98,34 @@ export function AiGeneratorDialog({
if (type === 'pack' && generatedPack) { if (type === 'pack' && generatedPack) {
onGenerated(generatedPack); onGenerated(generatedPack);
} else if (type === 'questions' && generatedQuestions.length > 0) { } else if (type === 'questions' && generatedQuestions.length > 0) {
onGenerated(generatedQuestions); // Only pass selected questions
const selectedQuestions = generatedQuestions.filter((_, i) => selectedQuestionIndices.has(i));
if (selectedQuestions.length === 0) {
toast.error('Please select at least one question');
return;
} }
onGenerated(selectedQuestions);
}
};
const toggleQuestionSelection = (index: number) => {
setSelectedQuestionIndices(prev => {
const newSet = new Set(prev);
if (newSet.has(index)) {
newSet.delete(index);
} else {
newSet.add(index);
}
return newSet;
});
};
const selectAllQuestions = () => {
setSelectedQuestionIndices(new Set(generatedQuestions.map((_, i) => i)));
};
const deselectAllQuestions = () => {
setSelectedQuestionIndices(new Set());
}; };
const handleClose = () => { const handleClose = () => {
@@ -91,6 +134,7 @@ export function AiGeneratorDialog({
setGeneratedQuestions([]); setGeneratedQuestions([]);
setGeneratedIdeas([]); setGeneratedIdeas([]);
setGeneratedPackIdeas([]); setGeneratedPackIdeas([]);
setSelectedQuestionIndices(new Set());
onOpenChange(false); onOpenChange(false);
}; };
@@ -130,13 +174,38 @@ export function AiGeneratorDialog({
</div> </div>
)} )}
{/* Explicit Toggle */} {/* Tone Selector for Questions */}
{(type === 'category-ideas' || type === 'category-pack-ideas' || type === 'pack' || type === 'questions') && ( {type === 'questions' && (
<div className="space-y-2">
<Label>Content Tone</Label>
<div className="flex flex-wrap gap-2">
{TONE_LEVELS.map((t) => (
<Button
key={t.level}
type="button"
variant={tone === t.level ? 'default' : 'outline'}
size="sm"
onClick={() => setTone(t.level as ToneLevel)}
className={tone === t.level ? 'ring-2 ring-offset-1' : ''}
title={t.description}
>
{t.level}. {t.label}
</Button>
))}
</div>
<p className="text-xs text-muted-foreground">
{TONE_LEVELS.find(t => t.level === tone)?.description}
</p>
</div>
)}
{/* Explicit Toggle for other generators */}
{(type === 'category-ideas' || type === 'category-pack-ideas' || type === 'pack') && (
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
<Switch <Switch
id="explicit-mode" id="explicit-mode"
checked={isExplicit} checked={isExplicit}
onCheckedChange={setIsExplicit} onCheckedChange={(checked) => setTone(checked ? 5 : 3)}
/> />
<Label htmlFor="explicit-mode">Suggest Explicit Ideas</Label> <Label htmlFor="explicit-mode">Suggest Explicit Ideas</Label>
</div> </div>
@@ -149,8 +218,11 @@ export function AiGeneratorDialog({
</div> </div>
)} )}
{context?.packName && ( {context?.packName && (
<div className="rounded-md bg-muted p-3 text-sm"> <div className="rounded-md bg-muted p-3 text-sm space-y-1">
<strong>Pack:</strong> {context.packName} <div><strong>Pack:</strong> {context.packName}</div>
{context.packDescription && (
<div className="text-muted-foreground text-xs">{context.packDescription}</div>
)}
</div> </div>
)} )}
@@ -185,20 +257,67 @@ export function AiGeneratorDialog({
{/* Generated Questions Preview */} {/* Generated Questions Preview */}
{generatedQuestions.length > 0 && ( {generatedQuestions.length > 0 && (
<div className="space-y-3"> <div className="space-y-3">
<h4 className="font-medium">Generated Questions ({generatedQuestions.length})</h4> <div className="flex items-center justify-between">
<h4 className="font-medium">
Generated Questions ({selectedQuestionIndices.size}/{generatedQuestions.length} selected)
</h4>
<div className="flex gap-2">
<Button
type="button"
variant="outline"
size="sm"
onClick={selectAllQuestions}
disabled={selectedQuestionIndices.size === generatedQuestions.length}
>
<CheckSquare className="mr-1 h-3 w-3" />
All
</Button>
<Button
type="button"
variant="outline"
size="sm"
onClick={deselectAllQuestions}
disabled={selectedQuestionIndices.size === 0}
>
<Square className="mr-1 h-3 w-3" />
None
</Button>
</div>
</div>
<div className="space-y-2 max-h-64 overflow-y-auto"> <div className="space-y-2 max-h-64 overflow-y-auto">
{generatedQuestions.map((q, i) => ( {generatedQuestions.map((q, i) => (
<div key={i} className="rounded-lg border bg-card p-3 text-sm"> <div
key={i}
className={`rounded-lg border bg-card p-3 text-sm cursor-pointer transition-colors ${
selectedQuestionIndices.has(i)
? 'border-primary ring-1 ring-primary/20'
: 'opacity-60 hover:opacity-80'
}`}
onClick={() => toggleQuestionSelection(i)}
>
<div className="flex items-start gap-3">
<Checkbox
checked={selectedQuestionIndices.has(i)}
onCheckedChange={() => toggleQuestionSelection(i)}
onClick={(e) => e.stopPropagation()}
className="mt-0.5"
/>
<div className="flex-1">
<p>{q.text}</p> <p>{q.text}</p>
{q.partner_text && ( {q.partner_text && (
<p className="text-muted-foreground text-xs mt-1"> <p className="text-muted-foreground text-xs mt-1">
Partner: {q.partner_text} Partner: {q.partner_text}
</p> </p>
)} )}
<span className="inline-block mt-1 text-xs px-2 py-0.5 rounded bg-muted"> <span
{q.intensity} - {['Light', 'Mild', 'Moderate', 'Spicy', 'Very Intense'][q.intensity - 1]} className={`inline-block mt-1 text-xs px-2 py-0.5 rounded text-white ${INTENSITY_LEVELS[q.intensity - 1]?.color || 'bg-muted'}`}
title={INTENSITY_LEVELS[q.intensity - 1]?.description}
>
{q.intensity} - {INTENSITY_LEVELS[q.intensity - 1]?.label || 'Unknown'}
</span> </span>
</div> </div>
</div>
</div>
))} ))}
</div> </div>
</div> </div>
@@ -217,7 +336,10 @@ export function AiGeneratorDialog({
> >
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<span className="text-2xl">{idea.icon}</span> <span className="text-2xl">{idea.icon}</span>
<span className="font-medium">{idea.name}</span> <div>
<span className="font-medium block">{idea.name}</span>
<span className="text-xs text-muted-foreground">{idea.description}</span>
</div>
</div> </div>
<Button size="sm" variant="ghost">Use</Button> <Button size="sm" variant="ghost">Use</Button>
</div> </div>
@@ -262,9 +384,9 @@ export function AiGeneratorDialog({
Regenerate Regenerate
</Button> </Button>
{type !== 'category-ideas' && type !== 'category-pack-ideas' && ( {type !== 'category-ideas' && type !== 'category-pack-ideas' && (
<Button onClick={handleUse}> <Button onClick={handleUse} disabled={type === 'questions' && selectedQuestionIndices.size === 0}>
<Check className="mr-2 h-4 w-4" /> <Check className="mr-2 h-4 w-4" />
Use This {type === 'questions' ? `Use Selected (${selectedQuestionIndices.size})` : 'Use This'}
</Button> </Button>
)} )}
</> </>
@@ -12,7 +12,7 @@ import { Badge } from '@/components/ui/badge';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { Loader2, ShieldCheck, ArrowRight, Check } from 'lucide-react'; import { Loader2, ShieldCheck, ArrowRight, Check } from 'lucide-react';
import { analyzeQuestionTargets, TargetAnalysis } from '@/lib/openai'; import { analyzeQuestionTargets, TargetAnalysis } from '@/lib/openai';
import { supabase } from '@/config'; import { auditedSupabase } from '@/hooks/useAuditedSupabase';
import { toast } from 'sonner'; import { toast } from 'sonner';
// Define local compatible interface // Define local compatible interface
@@ -22,6 +22,7 @@ interface Question {
partner_text: string | null; partner_text: string | null;
intensity: number; intensity: number;
allowed_couple_genders: string[] | null; allowed_couple_genders: string[] | null;
target_user_genders: string[] | null;
} }
interface FixTargetsDialogProps { interface FixTargetsDialogProps {
@@ -38,13 +39,58 @@ export function FixTargetsDialog({ open, onOpenChange, questions, onUpdated }: F
const [step, setStep] = useState<'initial' | 'review'>('initial'); const [step, setStep] = useState<'initial' | 'review'>('initial');
const [applying, setApplying] = useState(false); const [applying, setApplying] = useState(false);
// Check if couple targets represent "All" (null, empty, or all three values)
const isAllCoupleTargets = (targets: string[] | null): boolean => {
if (!targets || targets.length === 0) return true;
if (targets.length === 3) {
const sorted = [...targets].sort();
return sorted[0] === 'female+female' && sorted[1] === 'female+male' && sorted[2] === 'male+male';
}
return false;
};
// Check if initiator targets represent "All" (null or empty)
const isAllInitiator = (targets: string[] | null): boolean => {
return !targets || targets.length === 0;
};
// Check if two arrays are equal
const arraysEqual = (a: string[] | null, b: string[] | null): boolean => {
if (!a && !b) return true;
if (!a || !b) return false;
if (a.length !== b.length) return false;
const sortedA = [...a].sort();
const sortedB = [...b].sort();
return sortedA.every((val, i) => val === sortedB[i]);
};
const handleAnalyze = async () => { const handleAnalyze = async () => {
setAnalyzing(true); setAnalyzing(true);
try { try {
const results = await analyzeQuestionTargets(questions); const results = await analyzeQuestionTargets(questions);
setSuggestions(results); // Filter out redundant suggestions where nothing meaningful changes
const meaningfulResults = results.filter(suggestion => {
const question = questions.find(q => q.id === suggestion.id);
if (!question) return false;
// Check if couple targets change meaningfully
const currentCoupleIsAll = isAllCoupleTargets(question.allowed_couple_genders);
const suggestedCoupleIsAll = isAllCoupleTargets(suggestion.suggested_targets);
const coupleChanged = !(currentCoupleIsAll && suggestedCoupleIsAll) &&
!arraysEqual(question.allowed_couple_genders, suggestion.suggested_targets);
// Check if initiator changes meaningfully
const currentInitiatorIsAll = isAllInitiator(question.target_user_genders);
const suggestedInitiatorIsAll = isAllInitiator(suggestion.suggested_initiator);
const initiatorChanged = !(currentInitiatorIsAll && suggestedInitiatorIsAll) &&
!arraysEqual(question.target_user_genders, suggestion.suggested_initiator);
// Include if either changed
return coupleChanged || initiatorChanged;
});
setSuggestions(meaningfulResults);
// Default select all // Default select all
setSelectedIds(new Set(results.map(r => r.id))); setSelectedIds(new Set(meaningfulResults.map(r => r.id)));
setStep('review'); setStep('review');
} catch (error) { } catch (error) {
console.error(error); console.error(error);
@@ -63,12 +109,12 @@ export function FixTargetsDialog({ open, onOpenChange, questions, onUpdated }: F
return; return;
} }
// Apply updates in parallel // Apply updates in parallel with audit logging
await Promise.all(updates.map(update => await Promise.all(updates.map(update =>
supabase auditedSupabase.update('questions', update.id, {
.from('questions') allowed_couple_genders: update.suggested_targets && update.suggested_targets.length > 0 ? update.suggested_targets : null,
.update({ allowed_couple_genders: update.suggested_targets && update.suggested_targets.length > 0 ? update.suggested_targets : null }) target_user_genders: update.suggested_initiator && update.suggested_initiator.length > 0 ? update.suggested_initiator : null,
.eq('id', update.id) })
)); ));
toast.success(`Updated ${updates.length} questions`); toast.success(`Updated ${updates.length} questions`);
@@ -103,6 +149,11 @@ export function FixTargetsDialog({ open, onOpenChange, questions, onUpdated }: F
return targets.map(t => t.replace('male+male', 'M+M').replace('female+male', 'M+F').replace('female+female', 'F+F')).join(', '); return targets.map(t => t.replace('male+male', 'M+M').replace('female+male', 'M+F').replace('female+female', 'F+F')).join(', ');
}; };
const formatInitiator = (targets: string[] | null) => {
if (!targets || targets.length === 0) return 'Any';
return targets.map(t => t.charAt(0).toUpperCase() + t.slice(1)).join(', ');
};
return ( return (
<Dialog open={open} onOpenChange={handleClose}> <Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="max-w-4xl max-h-[90vh] flex flex-col"> <DialogContent className="max-w-4xl max-h-[90vh] flex flex-col">
@@ -163,8 +214,8 @@ export function FixTargetsDialog({ open, onOpenChange, questions, onUpdated }: F
/> />
</TableHead> </TableHead>
<TableHead>Question</TableHead> <TableHead>Question</TableHead>
<TableHead>Current</TableHead> <TableHead>Couples</TableHead>
<TableHead>Suggested</TableHead> <TableHead>Initiator</TableHead>
<TableHead>Reason</TableHead> <TableHead>Reason</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
@@ -190,19 +241,28 @@ export function FixTargetsDialog({ open, onOpenChange, questions, onUpdated }: F
</div> </div>
)} )}
</TableCell> </TableCell>
<TableCell className="text-xs whitespace-nowrap"> <TableCell className="text-xs">
<div className="flex items-center gap-1">
<Badge variant="outline" className="text-muted-foreground"> <Badge variant="outline" className="text-muted-foreground">
{formatTargets(question.allowed_couple_genders)} {formatTargets(question.allowed_couple_genders)}
</Badge> </Badge>
</TableCell>
<TableCell className="text-xs whitespace-nowrap">
<div className="flex items-center gap-1">
<ArrowRight className="h-3 w-3 text-muted-foreground" /> <ArrowRight className="h-3 w-3 text-muted-foreground" />
<Badge variant="secondary" className="bg-indigo-100 text-indigo-700 hover:bg-indigo-100"> <Badge variant="secondary" className="bg-indigo-100 text-indigo-700 hover:bg-indigo-100">
{formatTargets(suggestion.suggested_targets)} {formatTargets(suggestion.suggested_targets)}
</Badge> </Badge>
</div> </div>
</TableCell> </TableCell>
<TableCell className="text-xs">
<div className="flex items-center gap-1">
<Badge variant="outline" className="text-muted-foreground border-orange-300">
{formatInitiator(question.target_user_genders)}
</Badge>
<ArrowRight className="h-3 w-3 text-muted-foreground" />
<Badge variant="secondary" className="bg-orange-100 text-orange-700 hover:bg-orange-100">
{formatInitiator(suggestion.suggested_initiator)}
</Badge>
</div>
</TableCell>
<TableCell className="text-xs text-muted-foreground"> <TableCell className="text-xs text-muted-foreground">
{suggestion.reason} {suggestion.reason}
</TableCell> </TableCell>
@@ -1,15 +1,55 @@
import { useState, useEffect } from 'react';
import { Link, useLocation, useParams } from 'react-router-dom'; import { Link, useLocation, useParams } from 'react-router-dom';
import { ChevronRight, Home } from 'lucide-react'; import { ChevronRight, Home } from 'lucide-react';
import { supabase } from '@/config';
interface BreadcrumbItem { interface BreadcrumbItem {
label: string; label: string;
href?: string; href?: string;
} }
interface PackInfo {
category_id: string | null;
category_name: string | null;
}
// Generate breadcrumbs based on current path and params // Generate breadcrumbs based on current path and params
function useBreadcrumbs(): BreadcrumbItem[] { function useBreadcrumbs(): { breadcrumbs: BreadcrumbItem[]; loading: boolean } {
const location = useLocation(); const location = useLocation();
const params = useParams(); const params = useParams();
const [packInfo, setPackInfo] = useState<PackInfo | null>(null);
const [loading, setLoading] = useState(false);
// Fetch pack info when on questions page to get category_id
useEffect(() => {
const fetchPackInfo = async () => {
if (params.packId && !params.categoryId) {
setLoading(true);
try {
const { data } = await supabase
.from('question_packs')
.select('category_id, categories(name)')
.eq('id', params.packId)
.single();
if (data) {
setPackInfo({
category_id: data.category_id,
category_name: (data.categories as any)?.name || null,
});
}
} catch (error) {
console.error('Failed to fetch pack info for breadcrumbs:', error);
} finally {
setLoading(false);
}
} else {
setPackInfo(null);
}
};
fetchPackInfo();
}, [params.packId, params.categoryId]);
const pathSegments = location.pathname.split('/').filter(Boolean); const pathSegments = location.pathname.split('/').filter(Boolean);
const breadcrumbs: BreadcrumbItem[] = []; const breadcrumbs: BreadcrumbItem[] = [];
@@ -23,6 +63,13 @@ function useBreadcrumbs(): BreadcrumbItem[] {
breadcrumbs.push({ label: 'Categories', href: '/categories' }); breadcrumbs.push({ label: 'Categories', href: '/categories' });
} else if (segment === 'packs' && params.categoryId) { } else if (segment === 'packs' && params.categoryId) {
breadcrumbs.push({ label: 'Packs', href: currentPath }); breadcrumbs.push({ label: 'Packs', href: currentPath });
} else if (segment === 'packs' && params.packId && packInfo?.category_id) {
// On questions page, link back to category's packs
breadcrumbs.push({ label: 'Categories', href: '/categories' });
breadcrumbs.push({
label: packInfo.category_name || 'Packs',
href: `/categories/${packInfo.category_id}/packs`
});
} else if (segment === 'questions' && params.packId) { } else if (segment === 'questions' && params.packId) {
breadcrumbs.push({ label: 'Questions', href: currentPath }); breadcrumbs.push({ label: 'Questions', href: currentPath });
} else if (segment === 'users') { } else if (segment === 'users') {
@@ -44,13 +91,13 @@ function useBreadcrumbs(): BreadcrumbItem[] {
breadcrumbs[breadcrumbs.length - 1].href = undefined; breadcrumbs[breadcrumbs.length - 1].href = undefined;
} }
return breadcrumbs; return { breadcrumbs, loading };
} }
export function Breadcrumbs() { export function Breadcrumbs() {
const breadcrumbs = useBreadcrumbs(); const { breadcrumbs, loading } = useBreadcrumbs();
if (breadcrumbs.length === 0) return null; if (breadcrumbs.length === 0 && !loading) return null;
return ( return (
<nav className="flex items-center space-x-1 text-sm text-muted-foreground"> <nav className="flex items-center space-x-1 text-sm text-muted-foreground">
+318 -49
View File
@@ -25,8 +25,26 @@ const getOpenAI = () => {
}); });
}; };
const getModel = () => { type ModelPurpose = 'generate' | 'fix' | 'polish';
return import.meta.env.VITE_AI_MODEL || 'gpt-4o-mini';
const getModel = (purpose?: ModelPurpose) => {
// Granular model selection by purpose
// Falls back to VITE_AI_MODEL, then to default
const defaultModel = 'gpt-4o-mini';
const fallback = import.meta.env.VITE_AI_MODEL || defaultModel;
if (!purpose) return fallback;
switch (purpose) {
case 'generate':
return import.meta.env.VITE_AI_MODEL_GENERATE || fallback;
case 'fix':
return import.meta.env.VITE_AI_MODEL_FIX || fallback;
case 'polish':
return import.meta.env.VITE_AI_MODEL_POLISH || fallback;
default:
return fallback;
}
}; };
export interface GeneratedPack { export interface GeneratedPack {
@@ -70,7 +88,7 @@ export async function generatePack(categoryName?: string, explicit: boolean = fa
Make it romantic, playful, and relationship-focused.`; Make it romantic, playful, and relationship-focused.`;
const response = await openai.chat.completions.create({ const response = await openai.chat.completions.create({
model: getModel(), model: getModel('generate'),
messages: [ messages: [
{ {
role: 'system', role: 'system',
@@ -88,25 +106,141 @@ export async function generatePack(categoryName?: string, explicit: boolean = fa
return JSON.parse(content) as GeneratedPack; return JSON.parse(content) as GeneratedPack;
} }
// Tone levels for content generation (controls language/explicitness)
export const TONE_LEVELS = [
{ level: 0, label: 'Clean', description: 'No romance or intimacy - communication, activities, fun' },
{ level: 1, label: 'Romantic', description: 'Sweet, loving, emotional connection focus' },
{ level: 2, label: 'Playful', description: 'Flirty, teasing, fun and lighthearted' },
{ level: 3, label: 'Sensual', description: 'Intimate, passionate, suggestive but tasteful' },
{ level: 4, label: 'Spicy', description: 'Bold, adventurous, mildly explicit' },
{ level: 5, label: 'Explicit', description: 'Raw, graphic, NSFW content' },
] as const;
export type ToneLevel = 0 | 1 | 2 | 3 | 4 | 5;
// Intensity levels for questions (controls physical intimacy level)
// This guide helps AI consistently grade question intensity
export const INTENSITY_GUIDE = `
INTENSITY GRADING GUIDE - Use this to assign intensity levels consistently:
Level 1 - LIGHT (Emotional/Verbal):
- Talking, sharing feelings, compliments, emotional vulnerability
- Non-physical activities: writing letters, planning dates, sharing dreams
- Examples: "Share your biggest dream with your partner", "Write a love note", "Tell your partner what you love most about them"
Level 2 - MILD (Light Physical):
- Light physical affection: holding hands, hugging, cuddling
- Gentle kisses, playing with hair, light touches
- Non-sexual physical closeness: dancing together, sleeping in each other's arms
- Examples: "Give your partner a forehead kiss", "Cuddle while watching a movie", "Hold hands during dinner"
Level 3 - MODERATE (Sensual/Intimate):
- Making out, passionate kissing, intimate massage
- Sensual touch over clothes, caressing, teasing
- Showering together (non-sexual), skinny dipping
- Suggestive activities that build tension without explicit sexual acts
- Examples: "Give your partner a sensual massage", "Make out in an unexpected place", "Slow dance intimately"
Level 4 - SPICY (Sexual without Penetration):
- Oral sex, manual stimulation, mutual masturbation
- Foreplay activities, using hands/mouth on genitals
- Using toys externally, edging, teasing to climax
- Nakedness with sexual intent
- Examples: "Give your partner oral", "Touch your partner until they climax", "Use a vibrator on your partner"
Level 5 - INTENSE (Penetration/Advanced):
- Vaginal or anal penetration (sex, toys, fingers)
- Kinks: bondage, role-play with power dynamics, BDSM
- Group activities, exhibitionism, voyeurism
- Any activity involving penetration or advanced kinks
- Examples: "Have sex in a new location", "Try anal play", "Tie your partner up and have your way with them"
IMPORTANT: Intensity is about the PHYSICAL INTIMACY level, not the language used.
A question can be intensity 5 with tasteful language (non-explicit pack) or crude language (explicit pack).
`;
// Condensed version for prompts (to save tokens)
export const INTENSITY_GUIDE_SHORT = `
INTENSITY LEVELS:
1 (Light): Talking, emotional sharing, compliments, non-physical activities
2 (Mild): Holding hands, hugging, cuddling, gentle kisses, light touch
3 (Moderate): Making out, sensual massage, passionate kissing, intimate but not sexual
4 (Spicy): Oral sex, manual stimulation, foreplay, sexual touching without penetration
5 (Intense): Penetration (vaginal/anal), advanced kinks, BDSM, group activities
`;
// Intensity levels for UI display (consistent with AI grading)
export const INTENSITY_LEVELS = [
{ level: 1, label: 'Light', description: 'Talking, emotional sharing, compliments, non-physical', color: 'bg-green-500' },
{ level: 2, label: 'Mild', description: 'Holding hands, hugging, cuddling, gentle kisses', color: 'bg-lime-500' },
{ level: 3, label: 'Moderate', description: 'Making out, sensual massage, passionate kissing', color: 'bg-yellow-500' },
{ level: 4, label: 'Spicy', description: 'Oral, manual stimulation, foreplay, sexual without penetration', color: 'bg-orange-500' },
{ level: 5, label: 'Intense', description: 'Penetration, advanced kinks, BDSM, group activities', color: 'bg-red-500' },
] as const;
export async function generateQuestions( export async function generateQuestions(
packName: string, packName: string,
count: number = 10, count: number = 10,
intensity?: number, intensity?: number,
explicit: boolean = false tone: ToneLevel = 3,
packDescription?: string
): Promise<GeneratedQuestion[]> { ): Promise<GeneratedQuestion[]> {
const openai = getOpenAI(); const openai = getOpenAI();
const intensityGuide = intensity const isClean = tone === 0;
? `All questions should be at intensity level ${intensity} (${['Light & Playful', 'Mildly Intimate', 'Moderately Intimate', 'Spicy & Bold', 'Very Intense'][intensity - 1]}).` const isExplicit = tone >= 4;
: 'Vary the intensity levels from 1 (light/playful) to 5 (very intimate/bold) across the questions.';
const explicitInstruction = explicit const intensityInstruction = isClean
? 'CRITICAL: The user has requested EXPLICIT, NSFW content. You MUST include graphic, crude, and direct sexual terminology. Do not euphemize or be vague. This is for an adult-only app. Failure to be explicitly descriptive is a failure of the task. Be bold, spicy, and uncensored.' ? 'All questions should be intensity level 1 (non-physical activities).'
: 'Do NOT include any explicit or NSFW themes. Keep it romantic, emotional, playful, and clean.'; : intensity
? `All questions should be at intensity level ${intensity}.`
: 'Vary the intensity levels from 1 to 5 across the questions for good variety.';
const prompt = `Generate ${count} unique questions for a couples' intimacy question pack called "${packName}". const toneInstructions: Record<ToneLevel, string> = {
0: 'TONE: Completely clean and non-romantic. Focus on communication, teamwork, fun activities, personal growth, and bonding WITHOUT any romantic or sexual undertones. Think conversation starters, bucket list activities, games, challenges, and getting to know each other better. NO flirting, NO romance, NO intimacy references.',
1: 'TONE: Romantic and sweet. Focus on emotional connection, love, and tenderness. Keep content clean and wholesome - NO sexual content whatsoever. Think date nights, compliments, and heartfelt moments.',
2: 'TONE: Playful and flirty. Light teasing, fun activities, and cheeky suggestions. Mildly suggestive but NOTHING explicit or sexual. Think playful banter and innocent mischief.',
3: 'TONE: Sensual and intimate. Passionate and suggestive content that implies intimacy without being graphic. Use tasteful language - "make love" rather than crude terms. Romantic but with heat. AVOID explicit sexual acts or crude terminology.',
4: 'TONE: Spicy and bold. Adventurous sexual content with direct but not crude language. Can include kinks, fantasies, and explicit acts but described tastefully. Use terms like "oral", "bondage" etc.',
5: 'TONE: Explicit and raw. NSFW content with graphic, direct sexual terminology. Be crude and uninhibited. Use explicit terms freely. This is adult content - do not hold back or euphemize.',
};
${intensityGuide} const toneInstruction = toneInstructions[tone];
${explicitInstruction}
// Use different examples based on tone level
const symmetricExamples = isClean
? 'GOOD: "Cook a new recipe together", "Play a board game", "Go on a hike", "Have a deep conversation about your goals".'
: isExplicit
? 'GOOD: "Have sex in a public place", "Roleplay a new scenario together", "Shower together".'
: 'GOOD: "Cook a romantic dinner together", "Stargaze and share dreams", "Give each other massages", "Dance together at home".';
const asymmetricExamples = isClean
? `text (The Doer): Active command/proposal (e.g., "Teach your partner something new", "Plan a surprise activity").
- partner_text (The Receiver): Passive/Receiving proposal (e.g., "Learn something new from your partner", "Be surprised with an activity").
- GOOD: "Cook your partner their favorite meal" / "Have your partner cook your favorite meal".`
: isExplicit
? `text (The Doer): Active command/proposal (e.g., "Tie your partner up", "Give your partner a massage").
- partner_text (The Receiver): Passive/Receiving proposal (e.g., "Be tied up by your partner", "Receive a massage from your partner").
- GOOD: "Spank your partner" / "Be spanked by your partner".`
: `text (The Doer): Active command/proposal (e.g., "Write a love letter to your partner", "Plan a surprise date").
- partner_text (The Receiver): Passive/Receiving proposal (e.g., "Receive a love letter from your partner", "Be surprised with a date").
- GOOD: "Give your partner a massage" / "Receive a massage from your partner".`;
const explicitWarning = isClean
? '\n\nCRITICAL: This is a CLEAN pack with NO romantic or sexual content. Focus ONLY on communication, activities, challenges, and bonding. NO romance, NO flirting, NO intimacy, NO physical affection beyond friendly gestures.'
: isExplicit
? ''
: '\n\nCRITICAL: This is a NON-EXPLICIT pack. Do NOT include any sexual acts, crude language, or NSFW content. Keep all proposals romantic, playful, or emotionally intimate without being sexually explicit.';
const descriptionContext = packDescription
? `\nPack Description: "${packDescription}"\nUse this description to guide the theme and style of questions.`
: '';
const prompt = `Generate ${count} unique questions for a couples' intimacy question pack called "${packName}".${descriptionContext}
${INTENSITY_GUIDE_SHORT}
${intensityInstruction}
${toneInstruction}${explicitWarning}
IMPORTANT: The app uses a swipe-based interface (Like/Dislike/Maybe). IMPORTANT: The app uses a swipe-based interface (Like/Dislike/Maybe).
Cards should be "Proposals" relative to a specific action, rather than interview questions. Cards should be "Proposals" relative to a specific action, rather than interview questions.
@@ -116,35 +250,41 @@ For each "question", strictly decide if it is a SYMMETRIC activity (shared) or A
1. SYMMETRIC Activities (Single Card): 1. SYMMETRIC Activities (Single Card):
- Use for shared experiences where both partners do the same thing together. - Use for shared experiences where both partners do the same thing together.
- Phrasing: Direct action proposal or simple "Would you like to...". - Phrasing: Direct action proposal or simple "Would you like to...".
- GOOD: "Have sex in a public place", "Roleplay a new scenario together", "Shower together". - ${symmetricExamples}
- BAD: "Have you ever thought about...", "Do you think we should...", "How about we...". - BAD: "Have you ever thought about...", "Do you think we should...", "How about we...".
- Structure: - Structure:
- text: The action proposal (e.g., "Watch a steamy movie together"). - text: The action proposal.
- partner_text: null. - partner_text: null.
2. ASYMMETRIC Actions (Two-Part Card): 2. ASYMMETRIC Actions (Two-Part Card):
- Use for actions where one partner does something TO the other. - Use for actions where one partner does something TO the other.
- text (The Doer): Active command/proposal (e.g., "Tie your partner up", "Give your partner a massage"). - ${asymmetricExamples}
- partner_text (The Receiver): Passive/Receiving proposal (e.g., "Be tied up by your partner", "Receive a massage from your partner").
- GOOD: "Spank your partner" / "Be spanked by your partner".
- BAD: "Would you like to spank me?" (Ambiguous who "me" is). - BAD: "Would you like to spank me?" (Ambiguous who "me" is).
Required JSON structure: Required JSON structure:
- text: The question/proposal text (see above). - text: The question/proposal text (see above).
- partner_text: (optional) The partner-facing text for two-part cards. - partner_text: (optional) The partner-facing text for two-part cards.
- intensity: A number 1-5 indicating how intimate/bold the question is - intensity: A number 1-5 based on the INTENSITY LEVELS guide above. Grade based on PHYSICAL INTIMACY, not language.
Examples: "Share a dream" = 1, "Cuddle" = 2, "Massage" = 3, "Oral sex" = 4, "Have sex" = 5
Return a JSON object with a "questions" array containing these question objects. Return a JSON object with a "questions" array containing these question objects.
Mix distinct symmetric and asymmetric proposals.`; Mix distinct symmetric and asymmetric proposals.`;
const systemMessages: Record<ToneLevel, string> = {
0: 'You are a content writer for a couples app focused on communication, activities, and bonding. Generate clean, non-romantic content like conversation starters, fun challenges, and activities. No romance or intimacy. Always respond with valid JSON only.',
1: 'You are a romantic content writer for a couples relationship app. Generate sweet, wholesome content that helps couples connect emotionally. Always respond with valid JSON only.',
2: 'You are a playful content writer for a couples relationship app. Generate fun, flirty content that helps couples have fun together. Always respond with valid JSON only.',
3: 'You are a sensual content writer for a couples relationship app. Generate passionate, intimate content that helps couples explore their desires tastefully. Always respond with valid JSON only.',
4: 'You are a bold content writer for an adult couples app. Generate spicy, adventurous content that helps couples explore kinks and fantasies. Always respond with valid JSON only.',
5: 'You are an uninhibited content writer for an adult couples app. Generate raw, explicit, and direct content that explores kinks and desires. Do not filter for politeness. Always respond with valid JSON only.',
};
const response = await openai.chat.completions.create({ const response = await openai.chat.completions.create({
model: getModel(), model: getModel('generate'),
messages: [ messages: [
{ {
role: 'system', role: 'system',
content: explicit content: systemMessages[tone],
? 'You are an uninhibited content writer for an adult couples app. Generate raw, explicit, and direct content that explores kinks and desires. Do not filter for politeness. Always respond with valid JSON only.'
: 'You are a creative content writer for a couples relationship app. Generate engaging, tasteful questions that help couples explore their desires and deepen their connection. Always respond with valid JSON only.',
}, },
{ role: 'user', content: prompt }, { role: 'user', content: prompt },
], ],
@@ -171,7 +311,7 @@ export async function generateCategory(): Promise<{ name: string; description: s
Be creative and think of categories that help couples explore different aspects of their relationship.`; Be creative and think of categories that help couples explore different aspects of their relationship.`;
const response = await openai.chat.completions.create({ const response = await openai.chat.completions.create({
model: getModel(), model: getModel('generate'),
messages: [ messages: [
{ {
role: 'system', role: 'system',
@@ -191,6 +331,7 @@ export async function generateCategory(): Promise<{ name: string; description: s
export interface GeneratedCategoryIdea { export interface GeneratedCategoryIdea {
name: string; name: string;
description: string;
icon: string; icon: string;
} }
@@ -214,12 +355,13 @@ export async function suggestCategories(
Return a JSON object with an "ideas" array containing 5 objects, where each object has: Return a JSON object with an "ideas" array containing 5 objects, where each object has:
- name: Category name (1-3 words) - name: Category name (1-3 words)
- description: Brief description of what packs in this category explore (1 sentence)
- icon: A single descriptive emoji - icon: A single descriptive emoji
Focus on diverse topics like communications, future planning, fun, conflict resolution, intimacy, etc.`; Focus on diverse topics like communications, future planning, fun, conflict resolution, intimacy, etc.`;
const response = await openai.chat.completions.create({ const response = await openai.chat.completions.create({
model: getModel(), model: getModel('generate'),
messages: [ messages: [
{ {
role: 'system', role: 'system',
@@ -271,7 +413,7 @@ export async function suggestPacks(
Make them engaging and specific to "${categoryName}".`; Make them engaging and specific to "${categoryName}".`;
const response = await openai.chat.completions.create({ const response = await openai.chat.completions.create({
model: getModel(), model: getModel('generate'),
messages: [ messages: [
{ {
role: 'system', role: 'system',
@@ -299,7 +441,7 @@ export async function polishContent(
const explicitInstruction = explicit const explicitInstruction = explicit
? 'Do not censor yourself. If the content is already explicit, keep it that way or enhance usage of bolder terms if appropriate.' ? 'Do not censor yourself. If the content is already explicit, keep it that way or enhance usage of bolder terms if appropriate.'
: 'Keep it clean, romantic, and playful. Do NOT add explicit content if it was not there.'; : 'CRITICAL: This is a NON-EXPLICIT pack. Keep it clean, romantic, and playful. Do NOT add any sexual acts, crude language, or NSFW content. If the original text contains explicit content, replace with tasteful romantic alternatives.';
const contextMap = { const contextMap = {
question: 'a question for a couples app', question: 'a question for a couples app',
@@ -339,7 +481,7 @@ export async function polishContent(
- polished: The improved text string`; - polished: The improved text string`;
const response = await openai.chat.completions.create({ const response = await openai.chat.completions.create({
model: getModel(), model: getModel('polish'),
messages: [ messages: [
{ {
role: 'system', role: 'system',
@@ -361,11 +503,12 @@ export async function polishContent(
export interface TargetAnalysis { export interface TargetAnalysis {
id: string; id: string;
suggested_targets: string[] | null; suggested_targets: string[] | null;
suggested_initiator: string[] | null;
reason: string; reason: string;
} }
export async function analyzeQuestionTargets( export async function analyzeQuestionTargets(
questions: { id: string; text: string; partner_text?: string | null; allowed_couple_genders: string[] | null }[] questions: { id: string; text: string; partner_text?: string | null; allowed_couple_genders: string[] | null; target_user_genders: string[] | null }[]
): Promise<TargetAnalysis[]> { ): Promise<TargetAnalysis[]> {
const openai = getOpenAI(); const openai = getOpenAI();
@@ -373,38 +516,63 @@ export async function analyzeQuestionTargets(
id: q.id, id: q.id,
text: q.text, text: q.text,
partner_text: q.partner_text, partner_text: q.partner_text,
current_targets: q.allowed_couple_genders current_targets: q.allowed_couple_genders,
current_initiator: q.target_user_genders
})); }));
const prompt = `Analyze the following questions for a couples app and suggest the appropriate "allowed_couple_genders". const prompt = `Analyze the following questions for a couples app and suggest:
1. "allowed_couple_genders" (which couple types can see this question)
2. "target_user_genders" (who should see this question FIRST as initiator - only relevant for two-part questions with partner_text)
Target Options: 'male+male', 'female+male', 'female+female'. COUPLE TARGET OPTIONS: 'male+male', 'female+male', 'female+female'
INITIATOR OPTIONS: 'male', 'female', 'non-binary'
Goal: Identify anatomical or gender-role constraints. RULES FOR COUPLE TARGETS (allowed_couple_genders):
- Default to ALL (null or all 3) if the action is gender-neutral/universal (e.g. kissing, massage, generic sex, anal). - Default to ALL (null) if the action is gender-neutral/universal (e.g. kissing, massage, generic sex, anal).
- Limit ONLY if there is a clear anatomical requirement (e.g. genitalia specific) or strict gendered language (he/she/him/her). - Limit ONLY if there is a clear anatomical requirement (e.g. genitalia specific).
Examples: Examples:
- "Blowjob" -> Needs penis -> Exclude F+F -> ['male+male', 'female+male'] - "Blowjob" -> Needs penis -> Exclude F+F -> ['male+male', 'female+male']
- "Lick pussy" -> Needs vagina -> Exclude M+M -> ['female+male', 'female+female'] - "Lick pussy" -> Needs vagina -> Exclude M+M -> ['female+male', 'female+female']
- "Titjob" -> Needs breasts -> Exclude M+M (typically) -> ['female+male', 'female+female']
Return a JSON object with an "analysis" array containing objects for questions where you have a recommendation. RULES FOR INITIATOR (target_user_genders):
Include a "reason" string explaining why limits were added (or removed). - ONLY relevant for TWO-PART questions (those with partner_text set).
- Analyze the "text" field to determine who should see this card FIRST as the initiator.
- Consider WHO the action is TYPICALLY intended for, not just who CAN physically perform it.
- In M+F couples, consider the typical heterosexual context of sexual acts.
- If text implies a specific gender role, set initiator accordingly.
- If text is truly gender-neutral AND role-neutral, leave as null.
Structure: Key considerations for initiator:
{ 1. Does the "text" describe receiving something from a penis? -> Female initiator (in M+F context)
2. Does the "text" describe an action typically done BY someone with a penis (e.g., "my cum", "cum on you")? -> Male initiator
3. Does the "text" describe receiving penetration? -> Consider who is typically penetrated
4. Is the action truly symmetric with no gendered context? -> null
Examples:
- text: "Swallow your partner's cum" -> Receiving cum requires partner has penis, so in M+F this is female-oriented -> initiator: ['female']
- text: "Lick my cum off your body" -> "my cum" implies initiator produces cum (has penis) -> initiator: ['male']
- text: "Deep throat your partner" -> Requires partner has penis, so receiver-focused -> initiator: ['female'] for M+F
- text: "Give your partner a massage" -> Truly gender-neutral -> initiator: null
- text: "Let her ride you" / partner_text: "Ride your partner" -> "her" implies female partner -> initiator: ['male']
- text: "Finger your partner" / partner_text: "Be fingered" -> Receiver needs vagina -> suggested_targets: exclude M+M, initiator: null (either can finger)
Return a JSON object with an "analysis" array containing objects for questions where you have a recommendation.
Include ALL fields even if null. Include a "reason" string explaining your suggestions.
Structure:
{
"analysis": [ "analysis": [
{ "id": "...", "suggested_targets": ["male+male", "female+male"], "reason": "Requires penis" } { "id": "...", "suggested_targets": ["male+male", "female+male"], "suggested_initiator": ["male"], "reason": "Text implies male initiator with female partner" }
] ]
} }
Questions: Questions:
${JSON.stringify(simplifiedQuestions)} ${JSON.stringify(simplifiedQuestions)}
`; `;
const response = await openai.chat.completions.create({ const response = await openai.chat.completions.create({
model: getModel(), model: getModel('fix'),
messages: [ messages: [
{ {
role: 'system', role: 'system',
@@ -422,3 +590,104 @@ export async function analyzeQuestionTargets(
const parsed = JSON.parse(content); const parsed = JSON.parse(content);
return parsed.analysis || []; return parsed.analysis || [];
} }
export interface TextAnalysis {
id: string;
suggested_text: string;
suggested_partner_text: string | null;
reason: string;
}
export async function analyzeQuestionText(
questions: { id: string; text: string; partner_text?: string | null }[],
isExplicit: boolean = false
): Promise<TextAnalysis[]> {
const openai = getOpenAI();
const simplifiedQuestions = questions.map(q => ({
id: q.id,
text: q.text,
partner_text: q.partner_text,
}));
const toneInstruction = isExplicit
? 'TONE: This is an EXPLICIT pack. Use raw, direct, graphic language. Do NOT sanitize or euphemize explicit content. Keep crude terms like "cum", "fuck", "cock", etc. if they appear in the original.'
: 'TONE: This is a NON-EXPLICIT pack. CRITICAL: Do NOT include any sexual acts, crude language, or NSFW content. Keep language romantic, sensual, or playful without crude/graphic terms. If explicit sexual content appears, replace with tasteful romantic alternatives or flag for removal.';
// Use different examples based on explicit/non-explicit
const symmetricExamples = isExplicit
? 'GOOD: "Have sex in a public place", "Roleplay a new scenario together", "Shower together", "Have a threesome"'
: 'GOOD: "Cook a romantic dinner together", "Stargaze and share your dreams", "Give each other massages", "Dance together at home"';
const asymmetricExamples = isExplicit
? `text (The Doer): Active command/proposal (e.g., "Tie your partner up", "Give your partner a massage").
- partner_text (The Receiver): Passive/Receiving proposal (e.g., "Be tied up by your partner", "Receive a massage from your partner").
- GOOD: "Spank your partner" / "Be spanked by your partner"`
: `text (The Doer): Active command/proposal (e.g., "Write a love letter to your partner", "Plan a surprise date for your partner").
- partner_text (The Receiver): Passive/Receiving proposal (e.g., "Receive a love letter from your partner", "Be surprised with a date by your partner").
- GOOD: "Give your partner a massage" / "Receive a massage from your partner"`;
const prompt = `Analyze the following questions for a couples app and suggest improved text that follows our style guidelines.
${toneInstruction}
The app uses a swipe-based interface (Like/Dislike/Maybe).
Cards should be "Proposals" relative to a specific action, NOT interview questions.
For each question, decide if it is a SYMMETRIC activity (shared) or ASYMMETRIC action (one-way).
1. SYMMETRIC Activities (Single Card - partner_text should be null):
- Use for shared experiences where both partners do the same thing together.
- Phrasing: Direct action proposal.
- ${symmetricExamples}
- BAD: "Have you ever thought about...", "Do you think we should...", "How about we..."
2. ASYMMETRIC Actions (Two-Part Card - needs both text and partner_text):
- Use for actions where one partner does something TO the other.
- ${asymmetricExamples}
- BAD: "Would you like to...?" (Ambiguous who "me" is)
CRITICAL RULES:
1. PRESERVE THE CORE ACTION - Do NOT change what the question is about. Only improve phrasing.
2. Keep text SHORT and DIRECT - no explanatory parentheticals.
3. Remove wishy-washy language: "Would you want to...", "Have you ever...", "Do you think...", "...fantasy"
4. Make it direct and actionable.
5. For asymmetric cards, ensure text is initiator-focused and partner_text is receiver-focused.
6. Use "your partner" instead of "me", "you", "him", "her" to keep it gender-neutral.
7. If a question is ALREADY well-phrased, do NOT include it in the results.
8. NEVER add explanatory text in parentheses - keep the proposal clean and simple.
Return a JSON object with a "suggestions" array containing ONLY questions that need improvement.
Each object should have:
- id: The question ID
- suggested_text: The improved text (short, direct, no parentheticals)
- suggested_partner_text: The improved partner_text (or null if symmetric)
- reason: Brief explanation of what was improved
Questions:
${JSON.stringify(simplifiedQuestions)}
`;
const systemMessage = isExplicit
? 'You are an uninhibited content editor for an adult couples app. You improve question phrasing to be direct, raw, and explicit while preserving the original intent. Do not censor or euphemize. Always respond with valid JSON only.'
: 'You are a professional content editor for a couples app. You improve question phrasing to be direct, actionable, and engaging while preserving the original intent. Always respond with valid JSON only.';
const response = await openai.chat.completions.create({
model: getModel('fix'),
messages: [
{
role: 'system',
content: systemMessage
},
{ role: 'user', content: prompt }
],
response_format: { type: 'json_object' },
temperature: 0.5,
});
const content = response.choices[0].message.content;
if (!content) throw new Error('No content generated');
const parsed = JSON.parse(content);
return parsed.suggestions || [];
}
+21 -14
View File
@@ -14,14 +14,15 @@ import { format } from 'date-fns';
interface AuditLog { interface AuditLog {
id: string; id: string;
user_id: string; admin_user_id: string;
admin_username: string | null;
admin_role: string;
action: string; action: string;
table_name: string; table_name: string;
record_id: string | null; old_values: Record<string, unknown> | null;
old_data: Record<string, unknown> | null; new_values: Record<string, unknown> | null;
new_data: Record<string, unknown> | null; changed_fields: string[] | null;
created_at: string; created_at: string;
user_email?: string;
} }
const actionColors: Record<string, string> = { const actionColors: Record<string, string> = {
@@ -81,9 +82,9 @@ export function AuditLogsPage() {
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead className="w-40">Time</TableHead> <TableHead className="w-40">Time</TableHead>
<TableHead className="w-32">Admin</TableHead>
<TableHead className="w-24">Action</TableHead> <TableHead className="w-24">Action</TableHead>
<TableHead>Table</TableHead> <TableHead>Table</TableHead>
<TableHead>Record ID</TableHead>
<TableHead>Changes</TableHead> <TableHead>Changes</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
@@ -100,6 +101,10 @@ export function AuditLogsPage() {
<TableCell className="text-sm text-muted-foreground"> <TableCell className="text-sm text-muted-foreground">
{format(new Date(log.created_at), 'MMM d, h:mm a')} {format(new Date(log.created_at), 'MMM d, h:mm a')}
</TableCell> </TableCell>
<TableCell>
<div className="text-sm font-medium">{log.admin_username || 'Unknown'}</div>
<div className="text-xs text-muted-foreground capitalize">{log.admin_role?.replace('_', ' ')}</div>
</TableCell>
<TableCell> <TableCell>
<Badge className={`${actionColors[log.action] || 'bg-gray-500'} text-white`}> <Badge className={`${actionColors[log.action] || 'bg-gray-500'} text-white`}>
{log.action} {log.action}
@@ -108,21 +113,23 @@ export function AuditLogsPage() {
<TableCell className="font-mono text-sm"> <TableCell className="font-mono text-sm">
{log.table_name} {log.table_name}
</TableCell> </TableCell>
<TableCell className="font-mono text-xs text-muted-foreground">
{log.record_id ? log.record_id.substring(0, 8) + '...' : '—'}
</TableCell>
<TableCell className="max-w-md"> <TableCell className="max-w-md">
{log.action === 'DELETE' && log.old_data && ( {log.action === 'DELETE' && log.old_values && (
<code className="text-xs bg-red-50 text-red-700 px-2 py-1 rounded"> <code className="text-xs bg-red-50 text-red-700 px-2 py-1 rounded">
Deleted: {JSON.stringify(log.old_data).substring(0, 100)}... Deleted: {JSON.stringify(log.old_values).substring(0, 100)}...
</code> </code>
)} )}
{log.action === 'INSERT' && log.new_data && ( {log.action === 'INSERT' && log.new_values && (
<code className="text-xs bg-green-50 text-green-700 px-2 py-1 rounded"> <code className="text-xs bg-green-50 text-green-700 px-2 py-1 rounded">
Created: {JSON.stringify(log.new_data).substring(0, 100)}... Created: {JSON.stringify(log.new_values).substring(0, 100)}...
</code> </code>
)} )}
{log.action === 'UPDATE' && ( {log.action === 'UPDATE' && log.changed_fields && (
<code className="text-xs bg-blue-50 text-blue-700 px-2 py-1 rounded">
Changed: {log.changed_fields.join(', ')}
</code>
)}
{log.action === 'UPDATE' && !log.changed_fields && (
<code className="text-xs bg-blue-50 text-blue-700 px-2 py-1 rounded"> <code className="text-xs bg-blue-50 text-blue-700 px-2 py-1 rounded">
Updated record Updated record
</code> </code>
+3 -7
View File
@@ -1,5 +1,6 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { supabase } from '@/config'; import { supabase } from '@/config';
import { auditedSupabase } from '@/hooks/useAuditedSupabase';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
@@ -153,9 +154,7 @@ export function AdminsPage() {
setIsSubmitting(true); setIsSubmitting(true);
try { try {
const { error } = await supabase const { error } = await auditedSupabase.insert('admin_users', {
.from('admin_users')
.insert({
user_id: selectedUser.id, user_id: selectedUser.id,
role: selectedRole role: selectedRole
}); });
@@ -182,10 +181,7 @@ export function AdminsPage() {
} }
try { try {
const { error } = await supabase const { error } = await auditedSupabase.deleteBy('admin_users', 'user_id', admin.user_id);
.from('admin_users')
.delete()
.eq('user_id', admin.user_id);
if (error) throw error; if (error) throw error;
@@ -1,6 +1,7 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { supabase } from '@/config'; import { supabase } from '@/config';
import { auditedSupabase } from '@/hooks/useAuditedSupabase';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
@@ -108,7 +109,7 @@ export function CategoriesPage() {
setEditingCategory(null); setEditingCategory(null);
setFormData({ setFormData({
name: idea.name, name: idea.name,
description: '', description: idea.description || '',
icon: idea.icon, icon: idea.icon,
}); });
setDialogOpen(true); setDialogOpen(true);
@@ -124,22 +125,17 @@ export function CategoriesPage() {
try { try {
if (editingCategory) { if (editingCategory) {
// Update // Update
const { error } = await supabase const { error } = await auditedSupabase.update('categories', editingCategory.id, {
.from('categories')
.update({
name: formData.name, name: formData.name,
description: formData.description || null, description: formData.description || null,
icon: formData.icon || null, icon: formData.icon || null,
}) });
.eq('id', editingCategory.id);
if (error) throw error; if (error) throw error;
toast.success('Category updated'); toast.success('Category updated');
} else { } else {
// Create // Create
const { error } = await supabase const { error } = await auditedSupabase.insert('categories', {
.from('categories')
.insert({
name: formData.name, name: formData.name,
description: formData.description || null, description: formData.description || null,
icon: formData.icon || null, icon: formData.icon || null,
@@ -166,10 +162,7 @@ export function CategoriesPage() {
} }
try { try {
const { error } = await supabase const { error } = await auditedSupabase.delete('categories', category.id);
.from('categories')
.delete()
.eq('id', category.id);
if (error) throw error; if (error) throw error;
toast.success('Category deleted'); toast.success('Category deleted');
+45 -15
View File
@@ -1,6 +1,7 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { Link, useParams } from 'react-router-dom'; import { Link, useParams } from 'react-router-dom';
import { supabase } from '@/config'; import { supabase } from '@/config';
import { auditedSupabase } from '@/hooks/useAuditedSupabase';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
@@ -18,7 +19,7 @@ import {
DialogTitle, DialogTitle,
DialogTrigger, DialogTrigger,
} from '@/components/ui/dialog'; } from '@/components/ui/dialog';
import { Plus, MessageCircle, Pencil, Trash2, Sparkles, Loader2, Crown, Eye, EyeOff } from 'lucide-react'; import { Plus, MessageCircle, Pencil, Trash2, Sparkles, Loader2, Crown, Eye, EyeOff, Flame, Heart } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { AiGeneratorDialog } from '@/components/ai/AiGeneratorDialog'; import { AiGeneratorDialog } from '@/components/ai/AiGeneratorDialog';
import { AIPolishButton } from '@/components/ai/AIPolishButton'; import { AIPolishButton } from '@/components/ai/AIPolishButton';
@@ -36,6 +37,7 @@ interface QuestionPack {
icon: string | null; icon: string | null;
is_premium: boolean; is_premium: boolean;
is_public: boolean; is_public: boolean;
is_explicit: boolean;
sort_order: number | null; sort_order: number | null;
category_id: string | null; category_id: string | null;
created_at: string | null; created_at: string | null;
@@ -59,6 +61,7 @@ export function PacksPage() {
icon: '💕', icon: '💕',
is_premium: false, is_premium: false,
is_public: true, is_public: true,
is_explicit: false,
}); });
const fetchData = async () => { const fetchData = async () => {
@@ -126,6 +129,7 @@ export function PacksPage() {
icon: '💕', icon: '💕',
is_premium: false, is_premium: false,
is_public: true, is_public: true,
is_explicit: false,
}); });
setDialogOpen(true); setDialogOpen(true);
}; };
@@ -138,6 +142,7 @@ export function PacksPage() {
icon: pack.icon || '💕', icon: pack.icon || '💕',
is_premium: pack.is_premium, is_premium: pack.is_premium,
is_public: pack.is_public, is_public: pack.is_public,
is_explicit: pack.is_explicit,
}); });
setDialogOpen(true); setDialogOpen(true);
}; };
@@ -151,28 +156,25 @@ export function PacksPage() {
setSaving(true); setSaving(true);
try { try {
if (editingPack) { if (editingPack) {
const { error } = await supabase const { error } = await auditedSupabase.update('question_packs', editingPack.id, {
.from('question_packs')
.update({
name: formData.name, name: formData.name,
description: formData.description || null, description: formData.description || null,
icon: formData.icon || null, icon: formData.icon || null,
is_premium: formData.is_premium, is_premium: formData.is_premium,
is_public: formData.is_public, is_public: formData.is_public,
}) is_explicit: formData.is_explicit,
.eq('id', editingPack.id); });
if (error) throw error; if (error) throw error;
toast.success('Pack updated'); toast.success('Pack updated');
} else { } else {
const { error } = await supabase const { error } = await auditedSupabase.insert('question_packs', {
.from('question_packs')
.insert({
name: formData.name, name: formData.name,
description: formData.description || null, description: formData.description || null,
icon: formData.icon || null, icon: formData.icon || null,
is_premium: formData.is_premium, is_premium: formData.is_premium,
is_public: formData.is_public, is_public: formData.is_public,
is_explicit: formData.is_explicit,
category_id: categoryId || null, category_id: categoryId || null,
sort_order: packs.length, sort_order: packs.length,
}); });
@@ -197,10 +199,7 @@ export function PacksPage() {
} }
try { try {
const { error } = await supabase const { error } = await auditedSupabase.delete('question_packs', pack.id);
.from('question_packs')
.delete()
.eq('id', pack.id);
if (error) throw error; if (error) throw error;
toast.success('Pack deleted'); toast.success('Pack deleted');
@@ -361,6 +360,21 @@ export function PacksPage() {
} }
/> />
</div> </div>
<div className="flex items-center justify-between">
<div className="space-y-0.5">
<Label>Explicit Content</Label>
<p className="text-sm text-muted-foreground">
Contains adult or mature content
</p>
</div>
<Switch
checked={formData.is_explicit}
onCheckedChange={(checked) =>
setFormData(d => ({ ...d, is_explicit: checked }))
}
/>
</div>
</div> </div>
<DialogFooter> <DialogFooter>
@@ -429,10 +443,26 @@ export function PacksPage() {
Premium Premium
</Badge> </Badge>
)} )}
{!pack.is_public && ( {pack.is_public ? (
<Badge variant="outline" className="border-green-500 text-green-600">
<Eye className="h-3 w-3 mr-1" />
Published
</Badge>
) : (
<Badge variant="secondary"> <Badge variant="secondary">
<EyeOff className="h-3 w-3 mr-1" /> <EyeOff className="h-3 w-3 mr-1" />
Hidden Draft
</Badge>
)}
{pack.is_explicit ? (
<Badge variant="outline" className="border-red-500 text-red-600">
<Flame className="h-3 w-3 mr-1" />
Explicit
</Badge>
) : (
<Badge variant="outline" className="border-pink-400 text-pink-500">
<Heart className="h-3 w-3 mr-1" />
Safe
</Badge> </Badge>
)} )}
</div> </div>
+95 -39
View File
@@ -1,8 +1,8 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import { supabase } from '@/config'; import { supabase } from '@/config';
import { auditedSupabase } from '@/hooks/useAuditedSupabase';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label'; import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
@@ -25,16 +25,18 @@ import {
DialogTrigger, DialogTrigger,
} from '@/components/ui/dialog'; } from '@/components/ui/dialog';
import { Card } from '@/components/ui/card'; import { Card } from '@/components/ui/card';
import { Plus, Pencil, Trash2, Sparkles, Loader2, Users, ShieldCheck } from 'lucide-react'; import { Plus, Pencil, Trash2, Sparkles, Loader2, Users } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { AiGeneratorDialog } from '@/components/ai/AiGeneratorDialog'; import { AiGeneratorDialog } from '@/components/ai/AiGeneratorDialog';
import { AIPolishButton } from '@/components/ai/AIPolishButton'; import { AIPolishButton } from '@/components/ai/AIPolishButton';
import { FixTargetsDialog } from '@/components/content/FixTargetsDialog'; import { ReviewQuestionsDialog } from '@/components/content/ReviewQuestionsDialog';
interface QuestionPack { interface QuestionPack {
id: string; id: string;
name: string; name: string;
description: string | null;
icon: string | null; icon: string | null;
is_explicit: boolean;
} }
interface Question { interface Question {
@@ -44,11 +46,14 @@ interface Question {
partner_text: string | null; partner_text: string | null;
intensity: number; intensity: number;
allowed_couple_genders: string[] | null; allowed_couple_genders: string[] | null;
target_user_genders: string[] | null;
created_at: string | null; created_at: string | null;
} }
const intensityLabels = ['', 'Light', 'Mild', 'Medium', 'Spicy', 'Intense']; import { INTENSITY_LEVELS } from '@/lib/openai';
const intensityColors = ['', 'bg-green-500', 'bg-lime-500', 'bg-yellow-500', 'bg-orange-500', 'bg-red-500'];
// Helper to get intensity info by level (1-indexed)
const getIntensity = (level: number) => INTENSITY_LEVELS[level - 1] || INTENSITY_LEVELS[0];
export function QuestionsPage() { export function QuestionsPage() {
const { packId } = useParams<{ packId: string }>(); const { packId } = useParams<{ packId: string }>();
@@ -57,7 +62,7 @@ export function QuestionsPage() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [dialogOpen, setDialogOpen] = useState(false); const [dialogOpen, setDialogOpen] = useState(false);
const [aiDialogOpen, setAiDialogOpen] = useState(false); const [aiDialogOpen, setAiDialogOpen] = useState(false);
const [fixTargetsOpen, setFixTargetsOpen] = useState(false); const [reviewOpen, setReviewOpen] = useState(false);
const [editingQuestion, setEditingQuestion] = useState<Question | null>(null); const [editingQuestion, setEditingQuestion] = useState<Question | null>(null);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
@@ -67,11 +72,13 @@ export function QuestionsPage() {
partner_text: string; partner_text: string;
intensity: number; intensity: number;
allowed_couple_genders: string[]; allowed_couple_genders: string[];
target_user_genders: string[];
}>({ }>({
text: '', text: '',
partner_text: '', partner_text: '',
intensity: 1, intensity: 1,
allowed_couple_genders: [], allowed_couple_genders: [],
target_user_genders: [],
}); });
const fetchData = async () => { const fetchData = async () => {
@@ -82,7 +89,7 @@ export function QuestionsPage() {
// Fetch pack info // Fetch pack info
const { data: packData } = await supabase const { data: packData } = await supabase
.from('question_packs') .from('question_packs')
.select('id, name, icon') .select('id, name, description, icon, is_explicit')
.eq('id', packId) .eq('id', packId)
.single(); .single();
@@ -111,7 +118,7 @@ export function QuestionsPage() {
const openCreateDialog = () => { const openCreateDialog = () => {
setEditingQuestion(null); setEditingQuestion(null);
setFormData({ text: '', partner_text: '', intensity: 1, allowed_couple_genders: [] }); setFormData({ text: '', partner_text: '', intensity: 1, allowed_couple_genders: [], target_user_genders: [] });
setDialogOpen(true); setDialogOpen(true);
}; };
@@ -122,6 +129,7 @@ export function QuestionsPage() {
partner_text: question.partner_text || '', partner_text: question.partner_text || '',
intensity: question.intensity, intensity: question.intensity,
allowed_couple_genders: question.allowed_couple_genders || [], allowed_couple_genders: question.allowed_couple_genders || [],
target_user_genders: question.target_user_genders || [],
}); });
setDialogOpen(true); setDialogOpen(true);
}; };
@@ -135,27 +143,24 @@ export function QuestionsPage() {
setSaving(true); setSaving(true);
try { try {
if (editingQuestion) { if (editingQuestion) {
const { error } = await supabase const { error } = await auditedSupabase.update('questions', editingQuestion.id, {
.from('questions')
.update({
text: formData.text, text: formData.text,
partner_text: formData.partner_text || null, partner_text: formData.partner_text || null,
intensity: formData.intensity, intensity: formData.intensity,
allowed_couple_genders: formData.allowed_couple_genders.length > 0 ? formData.allowed_couple_genders : null, allowed_couple_genders: formData.allowed_couple_genders.length > 0 ? formData.allowed_couple_genders : null,
}) target_user_genders: formData.target_user_genders.length > 0 ? formData.target_user_genders : null,
.eq('id', editingQuestion.id); });
if (error) throw error; if (error) throw error;
toast.success('Question updated'); toast.success('Question updated');
} else { } else {
const { error } = await supabase const { error } = await auditedSupabase.insert('questions', {
.from('questions')
.insert({
pack_id: packId, pack_id: packId,
text: formData.text, text: formData.text,
partner_text: formData.partner_text || null, partner_text: formData.partner_text || null,
intensity: formData.intensity, intensity: formData.intensity,
allowed_couple_genders: formData.allowed_couple_genders.length > 0 ? formData.allowed_couple_genders : null, allowed_couple_genders: formData.allowed_couple_genders.length > 0 ? formData.allowed_couple_genders : null,
target_user_genders: formData.target_user_genders.length > 0 ? formData.target_user_genders : null,
}); });
if (error) throw error; if (error) throw error;
@@ -178,10 +183,7 @@ export function QuestionsPage() {
} }
try { try {
const { error } = await supabase const { error } = await auditedSupabase.delete('questions', question.id);
.from('questions')
.delete()
.eq('id', question.id);
if (error) throw error; if (error) throw error;
toast.success('Question deleted'); toast.success('Question deleted');
@@ -196,9 +198,8 @@ export function QuestionsPage() {
// Bulk insert AI-generated questions // Bulk insert AI-generated questions
const insertQuestions = async () => { const insertQuestions = async () => {
try { try {
const { error } = await supabase const { error } = await auditedSupabase.insert(
.from('questions') 'questions',
.insert(
generatedQuestions.map(q => ({ generatedQuestions.map(q => ({
pack_id: packId, pack_id: packId,
text: q.text, text: q.text,
@@ -250,9 +251,9 @@ export function QuestionsPage() {
</p> </p>
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<Button variant="outline" onClick={() => setFixTargetsOpen(true)}> <Button variant="outline" onClick={() => setReviewOpen(true)}>
<ShieldCheck className="mr-2 h-4 w-4" /> <Sparkles className="mr-2 h-4 w-4" />
Fix Targets Review Questions
</Button> </Button>
<Button variant="outline" onClick={() => setAiDialogOpen(true)}> <Button variant="outline" onClick={() => setAiDialogOpen(true)}>
<Sparkles className="mr-2 h-4 w-4" /> <Sparkles className="mr-2 h-4 w-4" />
@@ -284,6 +285,7 @@ export function QuestionsPage() {
<AIPolishButton <AIPolishButton
text={formData.text} text={formData.text}
type="question" type="question"
explicit={pack?.is_explicit ?? false}
onPolished={(val) => setFormData(d => ({ ...d, text: val }))} onPolished={(val) => setFormData(d => ({ ...d, text: val }))}
/> />
</div> </div>
@@ -308,6 +310,7 @@ export function QuestionsPage() {
<AIPolishButton <AIPolishButton
text={formData.partner_text} text={formData.partner_text}
type="partner_text" type="partner_text"
explicit={pack?.is_explicit ?? false}
onPolished={(val) => setFormData(d => ({ ...d, partner_text: val }))} onPolished={(val) => setFormData(d => ({ ...d, partner_text: val }))}
/> />
</div> </div>
@@ -326,16 +329,17 @@ export function QuestionsPage() {
<div className="space-y-2"> <div className="space-y-2">
<Label>Intensity Level</Label> <Label>Intensity Level</Label>
<div className="flex gap-2"> <div className="flex gap-2">
{[1, 2, 3, 4, 5].map((level) => ( {INTENSITY_LEVELS.map((intensity) => (
<Button <Button
key={level} key={intensity.level}
type="button" type="button"
variant={formData.intensity === level ? 'default' : 'outline'} variant={formData.intensity === intensity.level ? 'default' : 'outline'}
size="sm" size="sm"
onClick={() => setFormData(d => ({ ...d, intensity: level }))} onClick={() => setFormData(d => ({ ...d, intensity: intensity.level }))}
className={formData.intensity === level ? intensityColors[level] : ''} className={formData.intensity === intensity.level ? intensity.color : ''}
title={intensity.description}
> >
{level} - {intensityLabels[level]} {intensity.level} - {intensity.label}
</Button> </Button>
))} ))}
</div> </div>
@@ -375,6 +379,40 @@ export function QuestionsPage() {
Select specific couple types to restrict this question. Leave all unselected to allow for everyone. Select specific couple types to restrict this question. Leave all unselected to allow for everyone.
</p> </p>
</div> </div>
<div className="space-y-2">
<Label>Initiator Gender (optional)</Label>
<div className="flex flex-wrap gap-2">
{[
{ id: 'male', label: 'Male' },
{ id: 'female', label: 'Female' },
{ id: 'non-binary', label: 'Non-binary' },
].map((type) => {
const isSelected = formData.target_user_genders.includes(type.id);
return (
<Button
key={type.id}
type="button"
variant={isSelected ? 'default' : 'outline'}
size="sm"
onClick={() => {
setFormData(d => ({
...d,
target_user_genders: isSelected
? d.target_user_genders.filter(id => id !== type.id)
: [...d.target_user_genders, type.id]
}));
}}
>
{type.label}
</Button>
);
})}
</div>
<p className="text-xs text-muted-foreground">
Who sees this question first? Their partner will see the Partner Text after they answer. Leave empty for anyone.
</p>
</div>
</div> </div>
<DialogFooter> <DialogFooter>
@@ -402,14 +440,15 @@ export function QuestionsPage() {
open={aiDialogOpen} open={aiDialogOpen}
onOpenChange={setAiDialogOpen} onOpenChange={setAiDialogOpen}
type="questions" type="questions"
context={{ packName: pack?.name }} context={{ packName: pack?.name, packDescription: pack?.description, isExplicit: pack?.is_explicit }}
onGenerated={handleAiGenerated} onGenerated={handleAiGenerated}
/> />
<FixTargetsDialog <ReviewQuestionsDialog
open={fixTargetsOpen} open={reviewOpen}
onOpenChange={setFixTargetsOpen} onOpenChange={setReviewOpen}
questions={questions} questions={questions}
isExplicit={pack?.is_explicit ?? false}
onUpdated={fetchData} onUpdated={fetchData}
/> />
@@ -441,7 +480,8 @@ export function QuestionsPage() {
<TableHead className="w-12">#</TableHead> <TableHead className="w-12">#</TableHead>
<TableHead>Question</TableHead> <TableHead>Question</TableHead>
<TableHead className="w-32">Partner Text</TableHead> <TableHead className="w-32">Partner Text</TableHead>
<TableHead className="w-24">Targets</TableHead> <TableHead className="w-24">Couples</TableHead>
<TableHead className="w-24">Initiator</TableHead>
<TableHead className="w-24">Intensity</TableHead> <TableHead className="w-24">Intensity</TableHead>
<TableHead className="w-24">Actions</TableHead> <TableHead className="w-24">Actions</TableHead>
</TableRow> </TableRow>
@@ -479,8 +519,24 @@ export function QuestionsPage() {
)} )}
</TableCell> </TableCell>
<TableCell> <TableCell>
<Badge className={`${intensityColors[question.intensity]} text-white`}> {question.target_user_genders && question.target_user_genders.length > 0 ? (
{intensityLabels[question.intensity]} <div className="flex flex-wrap gap-1">
{question.target_user_genders.map(g => (
<Badge key={g} variant="outline" className="text-[10px] px-1 py-0 h-5 border-orange-500 text-orange-500">
{g.charAt(0).toUpperCase()}
</Badge>
))}
</div>
) : (
<span className="text-muted-foreground text-xs">Any</span>
)}
</TableCell>
<TableCell>
<Badge
className={`${getIntensity(question.intensity).color} text-white`}
title={getIntensity(question.intensity).description}
>
{getIntensity(question.intensity).label}
</Badge> </Badge>
</TableCell> </TableCell>
<TableCell> <TableCell>
@@ -1,6 +1,7 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { useParams, Link } from 'react-router-dom'; import { useParams, Link } from 'react-router-dom';
import { supabase } from '@/config'; import { supabase } from '@/config';
import { auditedSupabase } from '@/hooks/useAuditedSupabase';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton'; import { Skeleton } from '@/components/ui/skeleton';
@@ -128,9 +129,7 @@ export function UserDetailPage() {
expiresAt = date.toISOString(); expiresAt = date.toISOString();
} }
const { error } = await supabase const { error } = await auditedSupabase.insert('subscriptions', {
.from('subscriptions')
.insert({
user_id: userId, user_id: userId,
revenuecat_app_user_id: 'admin_grant', revenuecat_app_user_id: 'admin_grant',
product_id: 'admin_premium', product_id: 'admin_premium',
+3 -2
View File
@@ -4,8 +4,9 @@ import { supabase } from '../config';
export { supabase }; export { supabase };
const resources = { const resources = {
question_packs: ['id', 'name', 'description', 'icon', 'is_premium', 'is_public', 'sort_order', 'created_at'], categories: ['id', 'name', 'description', 'icon', 'sort_order', 'created_at'],
questions: ['id', 'pack_id', 'text', 'partner_text', 'intensity', 'created_at'], question_packs: ['id', 'name', 'description', 'icon', 'is_premium', 'is_public', 'is_explicit', 'sort_order', 'created_at'],
questions: ['id', 'pack_id', 'text', 'partner_text', 'intensity', 'allowed_couple_genders', 'target_user_genders', 'created_at'],
profiles: ['id', 'name', 'avatar_url', 'is_premium', 'couple_id', 'created_at'], profiles: ['id', 'name', 'avatar_url', 'is_premium', 'couple_id', 'created_at'],
couples: ['id', 'invite_code', 'created_at'], couples: ['id', 'invite_code', 'created_at'],
responses: ['id', 'user_id', 'question_id', 'couple_id', 'answer', 'created_at'], responses: ['id', 'user_id', 'question_id', 'couple_id', 'answer', 'created_at'],
+125 -11
View File
@@ -1,4 +1,64 @@
import { List, Datagrid, TextField, ReferenceField, NumberField, EditButton, Create, SimpleForm, TextInput, NumberInput, ReferenceInput, SelectInput, Edit } from 'react-admin'; import {
List,
Datagrid,
TextField,
ReferenceField,
NumberField,
EditButton,
Create,
SimpleForm,
TextInput,
NumberInput,
ReferenceInput,
SelectInput,
Edit,
CheckboxGroupInput,
ArrayField,
ChipField,
SingleFieldList,
BooleanField,
FunctionField,
} from 'react-admin';
import { Typography, Box, Chip, Tooltip, IconButton } from '@mui/material';
import HelpOutlineIcon from '@mui/icons-material/HelpOutline';
const COUPLE_GENDER_CHOICES = [
{ id: 'male+female', name: 'Male + Female' },
{ id: 'male+male', name: 'Male + Male' },
{ id: 'female+female', name: 'Female + Female' },
];
const TARGET_USER_GENDER_CHOICES = [
{ id: 'male', name: 'Male' },
{ id: 'female', name: 'Female' },
{ id: 'non-binary', name: 'Non-binary' },
{ id: 'prefer-not-to-say', name: 'Prefer not to say' },
];
const LabelWithTooltip = ({ label, tooltip }: { label: string; tooltip: string }) => (
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5 }}>
<Typography component="span">{label}</Typography>
<Tooltip title={tooltip} arrow placement="right">
<IconButton size="small" sx={{ p: 0.25 }}>
<HelpOutlineIcon sx={{ fontSize: 16, color: 'text.secondary' }} />
</IconButton>
</Tooltip>
</Box>
);
const GenderChips = ({ record, source }: { record?: any; source: string }) => {
const values = record?.[source];
if (!values || values.length === 0) {
return <Chip label="All" size="small" variant="outlined" />;
}
return (
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap' }}>
{values.map((v: string) => (
<Chip key={v} label={v} size="small" />
))}
</Box>
);
};
export const QuestionList = () => ( export const QuestionList = () => (
<List> <List>
@@ -8,31 +68,85 @@ export const QuestionList = () => (
<TextField source="name" /> <TextField source="name" />
</ReferenceField> </ReferenceField>
<NumberField source="intensity" /> <NumberField source="intensity" />
<FunctionField
label="Two-part"
render={(record: any) => record?.partner_text ? 'Yes' : 'No'}
/>
<FunctionField
label="Target Users"
render={(record: any) => {
const values = record?.target_user_genders;
if (!values || values.length === 0) return 'All';
return values.join(', ');
}}
/>
<EditButton /> <EditButton />
</Datagrid> </Datagrid>
</List> </List>
); );
export const QuestionEdit = () => ( const QuestionForm = () => (
<Edit>
<SimpleForm> <SimpleForm>
<ReferenceInput source="pack_id" reference="question_packs"> <ReferenceInput source="pack_id" reference="question_packs">
<SelectInput optionText="name" /> <SelectInput optionText="name" />
</ReferenceInput> </ReferenceInput>
<TextInput source="text" multiline fullWidth />
<Typography variant="subtitle2" sx={{ mt: 2, mb: 1 }}>
Question Text
</Typography>
<TextInput
source="text"
multiline
fullWidth
helperText="Primary text shown to the initiator (first person to see this question)"
/>
<TextInput
source="partner_text"
multiline
fullWidth
helperText="Optional: Text shown to the partner after the initiator has answered. Leave blank for single-text questions."
/>
<NumberInput source="intensity" min={1} max={5} /> <NumberInput source="intensity" min={1} max={5} />
<Typography variant="subtitle2" sx={{ mt: 3, mb: 1 }}>
Targeting
</Typography>
<CheckboxGroupInput
source="target_user_genders"
label={
<LabelWithTooltip
label="Show to (as initiator)"
tooltip="The 'initiator' is the first person in the couple to see and answer a question. This filter controls which genders can be the initiator. Their partner will always see the question after the initiator answers, regardless of this setting."
/>
}
choices={TARGET_USER_GENDER_CHOICES}
helperText="Leave empty to show to all genders as initiator."
/>
<CheckboxGroupInput
source="allowed_couple_genders"
label={
<LabelWithTooltip
label="Couple types"
tooltip="Filters which couple compositions can see this question. A 'male+female' couple has one male and one female partner. This ensures questions are only shown to couples where the content is relevant to their relationship dynamic."
/>
}
choices={COUPLE_GENDER_CHOICES}
helperText="Leave empty to show to all couple types."
/>
</SimpleForm> </SimpleForm>
);
export const QuestionEdit = () => (
<Edit>
<QuestionForm />
</Edit> </Edit>
); );
export const QuestionCreate = () => ( export const QuestionCreate = () => (
<Create> <Create>
<SimpleForm> <QuestionForm />
<ReferenceInput source="pack_id" reference="question_packs">
<SelectInput optionText="name" />
</ReferenceInput>
<TextInput source="text" multiline fullWidth />
<NumberInput source="intensity" min={1} max={5} />
</SimpleForm>
</Create> </Create>
); );
+1
View File
@@ -14,6 +14,7 @@ export default defineConfig({
port: 3001, port: 3001,
watch: { watch: {
usePolling: true, usePolling: true,
interval: 100,
}, },
}, },
}); });
+8 -6
View File
@@ -1,11 +1,13 @@
EXPO_PUBLIC_SUPABASE_URL=your_supabase_url # Supabase configuration (required)
EXPO_PUBLIC_SUPABASE_ANON_KEY=your_supabase_anon_key # Get these from your Supabase project dashboard: Settings > API
EXPO_PUBLIC_SUPABASE_URL=
EXPO_PUBLIC_SUPABASE_ANON_KEY=
# Sentry error tracking (get DSN from https://sentry.io) # Sentry error tracking (optional - get DSN from https://sentry.io)
EXPO_PUBLIC_SENTRY_DSN=https://your-public-key@your-org.ingest.sentry.io/your-project-id EXPO_PUBLIC_SENTRY_DSN=
# RevenueCat (for Apple subscriptions) # RevenueCat (iOS subscriptions only - Android not supported)
# Get your API key from https://app.revenuecat.com # Get your API key from https://app.revenuecat.com
EXPO_PUBLIC_REVENUECAT_IOS_API_KEY=your_revenuecat_ios_api_key EXPO_PUBLIC_REVENUECAT_IOS_API_KEY=
# Entitlement ID from RevenueCat dashboard (defaults to "pro" if not set) # Entitlement ID from RevenueCat dashboard (defaults to "pro" if not set)
EXPO_PUBLIC_REVENUECAT_ENTITLEMENT_ID=Sauci Pro EXPO_PUBLIC_REVENUECAT_ENTITLEMENT_ID=Sauci Pro
@@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android"> <adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/iconBackground"/> <background android:drawable="@mipmap/ic_launcher_background"/>
<foreground android:drawable="@mipmap/ic_launcher_foreground"/> <foreground android:drawable="@mipmap/ic_launcher_foreground"/>
<monochrome android:drawable="@mipmap/ic_launcher_monochrome"/>
</adaptive-icon> </adaptive-icon>
+9
View File
@@ -8,6 +8,7 @@ import { useAuthStore, useMatchStore, useMessageStore, useSubscriptionStore, use
import { colors, gradients, blur, radius, spacing, typography, shadows } from "../../src/theme"; import { colors, gradients, blur, radius, spacing, typography, shadows } from "../../src/theme";
import { supabase } from "../../src/lib/supabase"; import { supabase } from "../../src/lib/supabase";
import { isBiometricEnabled } from "../../src/lib/biometricAuth"; import { isBiometricEnabled } from "../../src/lib/biometricAuth";
import { checkAndRegisterPushToken } from "../../src/lib/notifications";
import { BiometricLockScreen } from "../../src/components/BiometricLockScreen"; import { BiometricLockScreen } from "../../src/components/BiometricLockScreen";
import type { MatchWithQuestion } from "../../src/types"; import type { MatchWithQuestion } from "../../src/types";
import type { Database } from "../../src/types/supabase"; import type { Database } from "../../src/types/supabase";
@@ -182,6 +183,14 @@ export default function AppLayout() {
} }
}, [user?.id, initializeRevenueCat]); }, [user?.id, initializeRevenueCat]);
// Check and register push token for this device
// This handles the case where user onboarded on a different device
useEffect(() => {
if (user?.id && user?.onboarding_completed) {
checkAndRegisterPushToken(user.id);
}
}, [user?.id, user?.onboarding_completed]);
// Subscribe to realtime match notifications // Subscribe to realtime match notifications
useEffect(() => { useEffect(() => {
if (!user?.couple_id) return; if (!user?.couple_id) return;
+28 -3
View File
@@ -211,7 +211,25 @@ export default function ChatScreen() {
const handlePickImage = async () => { const handlePickImage = async () => {
const result = await ImagePicker.launchImageLibraryAsync({ const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ImagePicker.MediaTypeOptions.Images, mediaTypes: ImagePicker.MediaTypeOptions.Images,
allowsEditing: true, quality: 0.7,
});
if (!result.canceled) {
uploadImage(result.assets[0].uri);
}
};
const handleTakePhoto = async () => {
const { status } = await ImagePicker.requestCameraPermissionsAsync();
if (status !== 'granted') {
Alert.alert(
"Camera Permission",
"Please allow camera access to take photos."
);
return;
}
const result = await ImagePicker.launchCameraAsync({
quality: 0.7, quality: 0.7,
}); });
@@ -423,6 +441,7 @@ export default function ChatScreen() {
onChangeText={handleTyping} onChangeText={handleTyping}
onSend={handleSend} onSend={handleSend}
onPickImage={handlePickImage} onPickImage={handlePickImage}
onTakePhoto={handleTakePhoto}
/> />
</View> </View>
</KeyboardAvoidingView> </KeyboardAvoidingView>
@@ -611,23 +630,29 @@ function InputBar({
onChangeText, onChangeText,
onSend, onSend,
onPickImage, onPickImage,
onTakePhoto,
}: { }: {
inputText: string; inputText: string;
uploading: boolean; uploading: boolean;
onChangeText: (text: string) => void; onChangeText: (text: string) => void;
onSend: () => void; onSend: () => void;
onPickImage: () => void; onPickImage: () => void;
onTakePhoto: () => void;
}) { }) {
return ( return (
<View style={styles.inputContainer}> <View style={styles.inputContainer}>
<TouchableOpacity onPress={onPickImage} disabled={uploading} style={styles.attachButton}> <TouchableOpacity onPress={onTakePhoto} disabled={uploading} style={styles.attachButton}>
{uploading ? ( {uploading ? (
<ActivityIndicator color={colors.primary} size="small" /> <ActivityIndicator color={colors.primary} size="small" />
) : ( ) : (
<Ionicons name="image-outline" size={22} color={colors.textSecondary} /> <Ionicons name="camera-outline" size={22} color={colors.textSecondary} />
)} )}
</TouchableOpacity> </TouchableOpacity>
<TouchableOpacity onPress={onPickImage} disabled={uploading} style={styles.attachButton}>
<Ionicons name="image-outline" size={22} color={colors.textSecondary} />
</TouchableOpacity>
<View style={styles.inputFieldWrapper}> <View style={styles.inputFieldWrapper}>
<TextInput <TextInput
style={styles.input} style={styles.input}
+8 -3
View File
@@ -123,10 +123,13 @@ export default function PacksScreen() {
</View> </View>
<View style={styles.packInfo}> <View style={styles.packInfo}>
<View style={styles.packHeader}> <View style={styles.packHeader}>
<Text style={[ <Text
style={[
styles.packName, styles.packName,
isPremiumLocked && styles.packNameLocked isPremiumLocked && styles.packNameLocked
]}> ]}
numberOfLines={1}
>
{item.name} {item.name}
</Text> </Text>
{item.is_premium && ( {item.is_premium && (
@@ -359,7 +362,7 @@ const styles = StyleSheet.create({
}, },
packInfo: { packInfo: {
flex: 1, flex: 1,
marginRight: spacing.sm, marginRight: spacing.md,
}, },
packHeader: { packHeader: {
flexDirection: "row", flexDirection: "row",
@@ -370,6 +373,8 @@ const styles = StyleSheet.create({
...typography.headline, ...typography.headline,
color: colors.text, color: colors.text,
marginRight: spacing.sm, marginRight: spacing.sm,
flex: 1,
flexShrink: 1,
}, },
packNameLocked: { packNameLocked: {
color: colors.textSecondary, color: colors.textSecondary,
+127 -12
View File
@@ -1,4 +1,4 @@
import { View, Text, StyleSheet, FlatList, TouchableOpacity, ActivityIndicator } from "react-native"; import { View, Text, StyleSheet, FlatList, TouchableOpacity, ActivityIndicator, Modal, Pressable } from "react-native";
import { useLocalSearchParams, useRouter } from "expo-router"; import { useLocalSearchParams, useRouter } from "expo-router";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { supabase } from "../../src/lib/supabase"; import { supabase } from "../../src/lib/supabase";
@@ -6,12 +6,26 @@ import { usePacksStore } from "../../src/store";
import { Ionicons } from "@expo/vector-icons"; import { Ionicons } from "@expo/vector-icons";
import { Question } from "../../src/types"; import { Question } from "../../src/types";
type TooltipType = 'initiator' | 'coupleType' | null;
const TOOLTIP_CONTENT = {
initiator: {
title: 'Initiator Filter',
description: 'The "initiator" is the first person in the couple to see and answer this question. This icon shows which genders can be the initiator. Their partner will always see the question afterward, regardless of this setting.',
},
coupleType: {
title: 'Couple Type Filter',
description: 'This shows which couple compositions can see this question. For example, a male+female icon means the question is only shown to couples where one partner is male and one is female.',
},
};
export default function PackDetailsScreen() { export default function PackDetailsScreen() {
const { id } = useLocalSearchParams(); const { id } = useLocalSearchParams();
const router = useRouter(); const router = useRouter();
const { packs, fetchPacks } = usePacksStore(); const { packs, fetchPacks } = usePacksStore();
const [questions, setQuestions] = useState<Question[]>([]); const [questions, setQuestions] = useState<Question[]>([]);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [activeTooltip, setActiveTooltip] = useState<TooltipType>(null);
const pack = packs.find((p) => p.id === id); const pack = packs.find((p) => p.id === id);
@@ -57,8 +71,33 @@ export default function PackDetailsScreen() {
/> />
))} ))}
</View> </View>
{item.allowed_couple_genders && item.allowed_couple_genders.length > 0 ? ( {/* Initiator indicator */}
{item.target_user_genders && item.target_user_genders.length > 0 ? (
<View style={styles.targetsContainer}> <View style={styles.targetsContainer}>
<Pressable
style={[styles.targetBadge, styles.initiatorBadge]}
onPress={() => setActiveTooltip('initiator')}
>
<Ionicons name="arrow-forward" size={12} color="#f39c12" style={{ marginRight: 4 }} />
{item.target_user_genders.map((g, i) => (
<Ionicons
key={g}
name={g === 'male' ? 'male' : g === 'female' ? 'female' : 'person'}
size={14}
color="#f39c12"
style={i > 0 ? { marginLeft: 4 } : {}}
/>
))}
<Ionicons name="help-circle-outline" size={14} color="#f39c12" style={{ marginLeft: 6 }} />
</Pressable>
</View>
) : null}
{/* Couple type indicator */}
{item.allowed_couple_genders && item.allowed_couple_genders.length > 0 ? (
<Pressable
style={styles.targetsContainer}
onPress={() => setActiveTooltip('coupleType')}
>
{item.allowed_couple_genders.map((g) => { {item.allowed_couple_genders.map((g) => {
const icons = []; const icons = [];
if (g === 'male+male') { if (g === 'male+male') {
@@ -75,21 +114,26 @@ export default function PackDetailsScreen() {
<Ionicons <Ionicons
key={i} key={i}
name={icon as any} name={icon as any}
size={10} size={14}
color="#ccc" color="#ccc"
style={i > 0 ? { marginLeft: 2 } : {}} style={i > 0 ? { marginLeft: 4 } : {}}
/> />
))} ))}
</View> </View>
); );
})} })}
</View> <Ionicons name="help-circle-outline" size={14} color="#ccc" style={{ marginLeft: 6 }} />
</Pressable>
) : ( ) : (
<View style={styles.targetsContainer}> <Pressable
style={styles.targetsContainer}
onPress={() => setActiveTooltip('coupleType')}
>
<View style={styles.targetBadge}> <View style={styles.targetBadge}>
<Ionicons name="people" size={10} color="#ccc" /> <Ionicons name="people" size={14} color="#ccc" />
</View>
</View> </View>
<Ionicons name="help-circle-outline" size={14} color="#ccc" style={{ marginLeft: 6 }} />
</Pressable>
)} )}
</View> </View>
</View> </View>
@@ -132,6 +176,34 @@ export default function PackDetailsScreen() {
keyExtractor={(item) => item.id} keyExtractor={(item) => item.id}
contentContainerStyle={styles.list} contentContainerStyle={styles.list}
/> />
{/* Tooltip Modal */}
<Modal
visible={activeTooltip !== null}
transparent
animationType="fade"
onRequestClose={() => setActiveTooltip(null)}
>
<Pressable
style={styles.modalOverlay}
onPress={() => setActiveTooltip(null)}
>
<View style={styles.tooltipContainer}>
<Text style={styles.tooltipTitle}>
{activeTooltip && TOOLTIP_CONTENT[activeTooltip].title}
</Text>
<Text style={styles.tooltipDescription}>
{activeTooltip && TOOLTIP_CONTENT[activeTooltip].description}
</Text>
<TouchableOpacity
style={styles.tooltipButton}
onPress={() => setActiveTooltip(null)}
>
<Text style={styles.tooltipButtonText}>Got it</Text>
</TouchableOpacity>
</View>
</Pressable>
</Modal>
</View> </View>
); );
} }
@@ -233,16 +305,59 @@ const styles = StyleSheet.create({
targetsContainer: { targetsContainer: {
flexDirection: 'row', flexDirection: 'row',
flexWrap: 'wrap', flexWrap: 'wrap',
gap: 4, alignItems: 'center',
gap: 6,
}, },
targetBadge: { targetBadge: {
flexDirection: 'row', flexDirection: 'row',
alignItems: 'center', alignItems: 'center',
backgroundColor: 'rgba(255, 255, 255, 0.1)', backgroundColor: 'rgba(255, 255, 255, 0.1)',
paddingHorizontal: 6, paddingHorizontal: 10,
paddingVertical: 2, paddingVertical: 6,
borderRadius: 4, borderRadius: 6,
borderWidth: 1, borderWidth: 1,
borderColor: 'rgba(255, 255, 255, 0.2)', borderColor: 'rgba(255, 255, 255, 0.2)',
}, },
initiatorBadge: {
backgroundColor: 'rgba(243, 156, 18, 0.15)',
borderColor: 'rgba(243, 156, 18, 0.3)',
},
modalOverlay: {
flex: 1,
backgroundColor: 'rgba(0, 0, 0, 0.7)',
justifyContent: 'center',
alignItems: 'center',
padding: 24,
},
tooltipContainer: {
backgroundColor: '#16213e',
borderRadius: 16,
padding: 24,
maxWidth: 320,
borderWidth: 1,
borderColor: '#0f3460',
},
tooltipTitle: {
fontSize: 18,
fontWeight: 'bold',
color: '#fff',
marginBottom: 12,
},
tooltipDescription: {
fontSize: 14,
color: '#ccc',
lineHeight: 22,
marginBottom: 20,
},
tooltipButton: {
backgroundColor: '#e94560',
borderRadius: 8,
paddingVertical: 12,
alignItems: 'center',
},
tooltipButtonText: {
fontSize: 16,
fontWeight: '600',
color: '#fff',
},
}); });
Binary file not shown.

Before

Width:  |  Height:  |  Size: 161 KiB

After

Width:  |  Height:  |  Size: 688 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 161 KiB

After

Width:  |  Height:  |  Size: 688 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.2 KiB

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 161 KiB

After

Width:  |  Height:  |  Size: 688 KiB

@@ -1,14 +1,134 @@
{ {
"images": [ "images": [
{ {
"filename": "App-Icon-1024x1024@1x.png", "filename": "AppIcon@2x.png",
"idiom": "universal", "idiom": "iphone",
"platform": "ios", "scale": "2x",
"size": "60x60"
},
{
"filename": "AppIcon@3x.png",
"idiom": "iphone",
"scale": "3x",
"size": "60x60"
},
{
"filename": "AppIcon~ipad.png",
"idiom": "ipad",
"scale": "1x",
"size": "76x76"
},
{
"filename": "AppIcon@2x~ipad.png",
"idiom": "ipad",
"scale": "2x",
"size": "76x76"
},
{
"filename": "AppIcon-83.5@2x~ipad.png",
"idiom": "ipad",
"scale": "2x",
"size": "83.5x83.5"
},
{
"filename": "AppIcon-40@2x.png",
"idiom": "iphone",
"scale": "2x",
"size": "40x40"
},
{
"filename": "AppIcon-40@3x.png",
"idiom": "iphone",
"scale": "3x",
"size": "40x40"
},
{
"filename": "AppIcon-40~ipad.png",
"idiom": "ipad",
"scale": "1x",
"size": "40x40"
},
{
"filename": "AppIcon-40@2x~ipad.png",
"idiom": "ipad",
"scale": "2x",
"size": "40x40"
},
{
"filename": "AppIcon-20@2x.png",
"idiom": "iphone",
"scale": "2x",
"size": "20x20"
},
{
"filename": "AppIcon-20@3x.png",
"idiom": "iphone",
"scale": "3x",
"size": "20x20"
},
{
"filename": "AppIcon-20~ipad.png",
"idiom": "ipad",
"scale": "1x",
"size": "20x20"
},
{
"filename": "AppIcon-20@2x~ipad.png",
"idiom": "ipad",
"scale": "2x",
"size": "20x20"
},
{
"filename": "AppIcon-29.png",
"idiom": "iphone",
"scale": "1x",
"size": "29x29"
},
{
"filename": "AppIcon-29@2x.png",
"idiom": "iphone",
"scale": "2x",
"size": "29x29"
},
{
"filename": "AppIcon-29@3x.png",
"idiom": "iphone",
"scale": "3x",
"size": "29x29"
},
{
"filename": "AppIcon-29~ipad.png",
"idiom": "ipad",
"scale": "1x",
"size": "29x29"
},
{
"filename": "AppIcon-29@2x~ipad.png",
"idiom": "ipad",
"scale": "2x",
"size": "29x29"
},
{
"filename": "AppIcon-60@2x~car.png",
"idiom": "car",
"scale": "2x",
"size": "60x60"
},
{
"filename": "AppIcon-60@3x~car.png",
"idiom": "car",
"scale": "3x",
"size": "60x60"
},
{
"filename": "AppIcon~ios-marketing.png",
"idiom": "ios-marketing",
"scale": "1x",
"size": "1024x1024" "size": "1024x1024"
} }
], ],
"info": { "info": {
"version": 1, "author": "iconkitchen",
"author": "expo" "version": 1
} }
} }
+32 -15
View File
@@ -95,7 +95,6 @@
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz", "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz",
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/code-frame": "^7.27.1", "@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.5", "@babel/generator": "^7.28.5",
@@ -499,6 +498,7 @@
"resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-firefox-class-in-computed-class-key/-/plugin-bugfix-firefox-class-in-computed-class-key-7.28.5.tgz", "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-firefox-class-in-computed-class-key/-/plugin-bugfix-firefox-class-in-computed-class-key-7.28.5.tgz",
"integrity": "sha512-87GDMS3tsmMSi/3bWOte1UblL+YUTFMV8SZPZ2eSEL17s74Cw/l63rR6NmGVKMYW2GYi85nE+/d6Hw5N0bEk2Q==", "integrity": "sha512-87GDMS3tsmMSi/3bWOte1UblL+YUTFMV8SZPZ2eSEL17s74Cw/l63rR6NmGVKMYW2GYi85nE+/d6Hw5N0bEk2Q==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/helper-plugin-utils": "^7.27.1", "@babel/helper-plugin-utils": "^7.27.1",
"@babel/traverse": "^7.28.5" "@babel/traverse": "^7.28.5"
@@ -515,6 +515,7 @@
"resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-class-field-initializer-scope/-/plugin-bugfix-safari-class-field-initializer-scope-7.27.1.tgz", "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-class-field-initializer-scope/-/plugin-bugfix-safari-class-field-initializer-scope-7.27.1.tgz",
"integrity": "sha512-qNeq3bCKnGgLkEXUuFry6dPlGfCdQNZbn7yUAPCInwAJHMU7THJfrBSozkcWq5sNM6RcF3S8XyQL2A52KNR9IA==", "integrity": "sha512-qNeq3bCKnGgLkEXUuFry6dPlGfCdQNZbn7yUAPCInwAJHMU7THJfrBSozkcWq5sNM6RcF3S8XyQL2A52KNR9IA==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/helper-plugin-utils": "^7.27.1" "@babel/helper-plugin-utils": "^7.27.1"
}, },
@@ -530,6 +531,7 @@
"resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.27.1.tgz", "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.27.1.tgz",
"integrity": "sha512-g4L7OYun04N1WyqMNjldFwlfPCLVkgB54A/YCXICZYBsvJJE3kByKv9c9+R/nAfmIfjl2rKYLNyMHboYbZaWaA==", "integrity": "sha512-g4L7OYun04N1WyqMNjldFwlfPCLVkgB54A/YCXICZYBsvJJE3kByKv9c9+R/nAfmIfjl2rKYLNyMHboYbZaWaA==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/helper-plugin-utils": "^7.27.1" "@babel/helper-plugin-utils": "^7.27.1"
}, },
@@ -545,6 +547,7 @@
"resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.27.1.tgz", "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.27.1.tgz",
"integrity": "sha512-oO02gcONcD5O1iTLi/6frMJBIwWEHceWGSGqrpCmEL8nogiS6J9PBlE48CaK20/Jx1LuRml9aDftLgdjXT8+Cw==", "integrity": "sha512-oO02gcONcD5O1iTLi/6frMJBIwWEHceWGSGqrpCmEL8nogiS6J9PBlE48CaK20/Jx1LuRml9aDftLgdjXT8+Cw==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/helper-plugin-utils": "^7.27.1", "@babel/helper-plugin-utils": "^7.27.1",
"@babel/helper-skip-transparent-expression-wrappers": "^7.27.1", "@babel/helper-skip-transparent-expression-wrappers": "^7.27.1",
@@ -562,6 +565,7 @@
"resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly/-/plugin-bugfix-v8-static-class-fields-redefine-readonly-7.28.3.tgz", "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly/-/plugin-bugfix-v8-static-class-fields-redefine-readonly-7.28.3.tgz",
"integrity": "sha512-b6YTX108evsvE4YgWyQ921ZAFFQm3Bn+CA3+ZXlNVnPhx+UfsVURoPjfGAPCjBgrqo30yX/C2nZGX96DxvR9Iw==", "integrity": "sha512-b6YTX108evsvE4YgWyQ921ZAFFQm3Bn+CA3+ZXlNVnPhx+UfsVURoPjfGAPCjBgrqo30yX/C2nZGX96DxvR9Iw==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/helper-plugin-utils": "^7.27.1", "@babel/helper-plugin-utils": "^7.27.1",
"@babel/traverse": "^7.28.3" "@babel/traverse": "^7.28.3"
@@ -662,6 +666,7 @@
"resolved": "https://registry.npmjs.org/@babel/plugin-proposal-private-property-in-object/-/plugin-proposal-private-property-in-object-7.21.0-placeholder-for-preset-env.2.tgz", "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-private-property-in-object/-/plugin-proposal-private-property-in-object-7.21.0-placeholder-for-preset-env.2.tgz",
"integrity": "sha512-SOSkfJDddaM7mak6cPEpswyTRnuRltl429hMraQEglW+OkovnCzsiszTmsrlY//qLFjCpQDFRvjdm2wA5pPm9w==", "integrity": "sha512-SOSkfJDddaM7mak6cPEpswyTRnuRltl429hMraQEglW+OkovnCzsiszTmsrlY//qLFjCpQDFRvjdm2wA5pPm9w==",
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=6.9.0" "node": ">=6.9.0"
}, },
@@ -782,6 +787,7 @@
"resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.27.1.tgz", "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.27.1.tgz",
"integrity": "sha512-UT/Jrhw57xg4ILHLFnzFpPDlMbcdEicaAtjPQpbj9wa8T4r5KVWCimHcL/460g8Ht0DMxDyjsLgiWSkVjnwPFg==", "integrity": "sha512-UT/Jrhw57xg4ILHLFnzFpPDlMbcdEicaAtjPQpbj9wa8T4r5KVWCimHcL/460g8Ht0DMxDyjsLgiWSkVjnwPFg==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/helper-plugin-utils": "^7.27.1" "@babel/helper-plugin-utils": "^7.27.1"
}, },
@@ -968,6 +974,7 @@
"resolved": "https://registry.npmjs.org/@babel/plugin-syntax-unicode-sets-regex/-/plugin-syntax-unicode-sets-regex-7.18.6.tgz", "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-unicode-sets-regex/-/plugin-syntax-unicode-sets-regex-7.18.6.tgz",
"integrity": "sha512-727YkEAPwSIQTv5im8QHz3upqp92JTWhidIC81Tdx4VJYIte/VndKf1qKrfnnhPLiPghStWfvC/iFaMCQu7Nqg==", "integrity": "sha512-727YkEAPwSIQTv5im8QHz3upqp92JTWhidIC81Tdx4VJYIte/VndKf1qKrfnnhPLiPghStWfvC/iFaMCQu7Nqg==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/helper-create-regexp-features-plugin": "^7.18.6", "@babel/helper-create-regexp-features-plugin": "^7.18.6",
"@babel/helper-plugin-utils": "^7.18.6" "@babel/helper-plugin-utils": "^7.18.6"
@@ -1033,6 +1040,7 @@
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.27.1.tgz", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.27.1.tgz",
"integrity": "sha512-cnqkuOtZLapWYZUYM5rVIdv1nXYuFVIltZ6ZJ7nIj585QsjKM5dhL2Fu/lICXZ1OyIAFc7Qy+bvDAtTXqGrlhg==", "integrity": "sha512-cnqkuOtZLapWYZUYM5rVIdv1nXYuFVIltZ6ZJ7nIj585QsjKM5dhL2Fu/lICXZ1OyIAFc7Qy+bvDAtTXqGrlhg==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/helper-plugin-utils": "^7.27.1" "@babel/helper-plugin-utils": "^7.27.1"
}, },
@@ -1079,6 +1087,7 @@
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.28.3.tgz", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.28.3.tgz",
"integrity": "sha512-LtPXlBbRoc4Njl/oh1CeD/3jC+atytbnf/UqLoqTDcEYGUPj022+rvfkbDYieUrSj3CaV4yHDByPE+T2HwfsJg==", "integrity": "sha512-LtPXlBbRoc4Njl/oh1CeD/3jC+atytbnf/UqLoqTDcEYGUPj022+rvfkbDYieUrSj3CaV4yHDByPE+T2HwfsJg==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/helper-create-class-features-plugin": "^7.28.3", "@babel/helper-create-class-features-plugin": "^7.28.3",
"@babel/helper-plugin-utils": "^7.27.1" "@babel/helper-plugin-utils": "^7.27.1"
@@ -1147,6 +1156,7 @@
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.27.1.tgz", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.27.1.tgz",
"integrity": "sha512-gEbkDVGRvjj7+T1ivxrfgygpT7GUd4vmODtYpbs0gZATdkX8/iSnOtZSxiZnsgm1YjTgjI6VKBGSJJevkrclzw==", "integrity": "sha512-gEbkDVGRvjj7+T1ivxrfgygpT7GUd4vmODtYpbs0gZATdkX8/iSnOtZSxiZnsgm1YjTgjI6VKBGSJJevkrclzw==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/helper-create-regexp-features-plugin": "^7.27.1", "@babel/helper-create-regexp-features-plugin": "^7.27.1",
"@babel/helper-plugin-utils": "^7.27.1" "@babel/helper-plugin-utils": "^7.27.1"
@@ -1163,6 +1173,7 @@
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.27.1.tgz", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.27.1.tgz",
"integrity": "sha512-MTyJk98sHvSs+cvZ4nOauwTTG1JeonDjSGvGGUNHreGQns+Mpt6WX/dVzWBHgg+dYZhkC4X+zTDfkTU+Vy9y7Q==", "integrity": "sha512-MTyJk98sHvSs+cvZ4nOauwTTG1JeonDjSGvGGUNHreGQns+Mpt6WX/dVzWBHgg+dYZhkC4X+zTDfkTU+Vy9y7Q==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/helper-plugin-utils": "^7.27.1" "@babel/helper-plugin-utils": "^7.27.1"
}, },
@@ -1178,6 +1189,7 @@
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-named-capturing-groups-regex/-/plugin-transform-duplicate-named-capturing-groups-regex-7.27.1.tgz", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-named-capturing-groups-regex/-/plugin-transform-duplicate-named-capturing-groups-regex-7.27.1.tgz",
"integrity": "sha512-hkGcueTEzuhB30B3eJCbCYeCaaEQOmQR0AdvzpD4LoN0GXMWzzGSuRrxR2xTnCrvNbVwK9N6/jQ92GSLfiZWoQ==", "integrity": "sha512-hkGcueTEzuhB30B3eJCbCYeCaaEQOmQR0AdvzpD4LoN0GXMWzzGSuRrxR2xTnCrvNbVwK9N6/jQ92GSLfiZWoQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/helper-create-regexp-features-plugin": "^7.27.1", "@babel/helper-create-regexp-features-plugin": "^7.27.1",
"@babel/helper-plugin-utils": "^7.27.1" "@babel/helper-plugin-utils": "^7.27.1"
@@ -1194,6 +1206,7 @@
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-dynamic-import/-/plugin-transform-dynamic-import-7.27.1.tgz", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dynamic-import/-/plugin-transform-dynamic-import-7.27.1.tgz",
"integrity": "sha512-MHzkWQcEmjzzVW9j2q8LGjwGWpG2mjwaaB0BNQwst3FIjqsg8Ct/mIZlvSPJvfi9y2AC8mi/ktxbFVL9pZ1I4A==", "integrity": "sha512-MHzkWQcEmjzzVW9j2q8LGjwGWpG2mjwaaB0BNQwst3FIjqsg8Ct/mIZlvSPJvfi9y2AC8mi/ktxbFVL9pZ1I4A==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/helper-plugin-utils": "^7.27.1" "@babel/helper-plugin-utils": "^7.27.1"
}, },
@@ -1209,6 +1222,7 @@
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-explicit-resource-management/-/plugin-transform-explicit-resource-management-7.28.0.tgz", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-explicit-resource-management/-/plugin-transform-explicit-resource-management-7.28.0.tgz",
"integrity": "sha512-K8nhUcn3f6iB+P3gwCv/no7OdzOZQcKchW6N389V6PD8NUWKZHzndOd9sPDVbMoBsbmjMqlB4L9fm+fEFNVlwQ==", "integrity": "sha512-K8nhUcn3f6iB+P3gwCv/no7OdzOZQcKchW6N389V6PD8NUWKZHzndOd9sPDVbMoBsbmjMqlB4L9fm+fEFNVlwQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/helper-plugin-utils": "^7.27.1", "@babel/helper-plugin-utils": "^7.27.1",
"@babel/plugin-transform-destructuring": "^7.28.0" "@babel/plugin-transform-destructuring": "^7.28.0"
@@ -1225,6 +1239,7 @@
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.28.5.tgz", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.28.5.tgz",
"integrity": "sha512-D4WIMaFtwa2NizOp+dnoFjRez/ClKiC2BqqImwKd1X28nqBtZEyCYJ2ozQrrzlxAFrcrjxo39S6khe9RNDlGzw==", "integrity": "sha512-D4WIMaFtwa2NizOp+dnoFjRez/ClKiC2BqqImwKd1X28nqBtZEyCYJ2ozQrrzlxAFrcrjxo39S6khe9RNDlGzw==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/helper-plugin-utils": "^7.27.1" "@babel/helper-plugin-utils": "^7.27.1"
}, },
@@ -1304,6 +1319,7 @@
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-json-strings/-/plugin-transform-json-strings-7.27.1.tgz", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-json-strings/-/plugin-transform-json-strings-7.27.1.tgz",
"integrity": "sha512-6WVLVJiTjqcQauBhn1LkICsR2H+zm62I3h9faTDKt1qP4jn2o72tSvqMwtGFKGTpojce0gJs+76eZ2uCHRZh0Q==", "integrity": "sha512-6WVLVJiTjqcQauBhn1LkICsR2H+zm62I3h9faTDKt1qP4jn2o72tSvqMwtGFKGTpojce0gJs+76eZ2uCHRZh0Q==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/helper-plugin-utils": "^7.27.1" "@babel/helper-plugin-utils": "^7.27.1"
}, },
@@ -1349,6 +1365,7 @@
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.27.1.tgz", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.27.1.tgz",
"integrity": "sha512-hqoBX4dcZ1I33jCSWcXrP+1Ku7kdqXf1oeah7ooKOIiAdKQ+uqftgCFNOSzA5AMS2XIHEYeGFg4cKRCdpxzVOQ==", "integrity": "sha512-hqoBX4dcZ1I33jCSWcXrP+1Ku7kdqXf1oeah7ooKOIiAdKQ+uqftgCFNOSzA5AMS2XIHEYeGFg4cKRCdpxzVOQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/helper-plugin-utils": "^7.27.1" "@babel/helper-plugin-utils": "^7.27.1"
}, },
@@ -1364,6 +1381,7 @@
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.27.1.tgz", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.27.1.tgz",
"integrity": "sha512-iCsytMg/N9/oFq6n+gFTvUYDZQOMK5kEdeYxmxt91fcJGycfxVP9CnrxoliM0oumFERba2i8ZtwRUCMhvP1LnA==", "integrity": "sha512-iCsytMg/N9/oFq6n+gFTvUYDZQOMK5kEdeYxmxt91fcJGycfxVP9CnrxoliM0oumFERba2i8ZtwRUCMhvP1LnA==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/helper-module-transforms": "^7.27.1", "@babel/helper-module-transforms": "^7.27.1",
"@babel/helper-plugin-utils": "^7.27.1" "@babel/helper-plugin-utils": "^7.27.1"
@@ -1396,6 +1414,7 @@
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.28.5.tgz", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.28.5.tgz",
"integrity": "sha512-vn5Jma98LCOeBy/KpeQhXcV2WZgaRUtjwQmjoBuLNlOmkg0fB5pdvYVeWRYI69wWKwK2cD1QbMiUQnoujWvrew==", "integrity": "sha512-vn5Jma98LCOeBy/KpeQhXcV2WZgaRUtjwQmjoBuLNlOmkg0fB5pdvYVeWRYI69wWKwK2cD1QbMiUQnoujWvrew==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/helper-module-transforms": "^7.28.3", "@babel/helper-module-transforms": "^7.28.3",
"@babel/helper-plugin-utils": "^7.27.1", "@babel/helper-plugin-utils": "^7.27.1",
@@ -1414,6 +1433,7 @@
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.27.1.tgz", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.27.1.tgz",
"integrity": "sha512-iQBE/xC5BV1OxJbp6WG7jq9IWiD+xxlZhLrdwpPkTX3ydmXdvoCpyfJN7acaIBZaOqTfr76pgzqBJflNbeRK+w==", "integrity": "sha512-iQBE/xC5BV1OxJbp6WG7jq9IWiD+xxlZhLrdwpPkTX3ydmXdvoCpyfJN7acaIBZaOqTfr76pgzqBJflNbeRK+w==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/helper-module-transforms": "^7.27.1", "@babel/helper-module-transforms": "^7.27.1",
"@babel/helper-plugin-utils": "^7.27.1" "@babel/helper-plugin-utils": "^7.27.1"
@@ -1446,6 +1466,7 @@
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.27.1.tgz", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.27.1.tgz",
"integrity": "sha512-f6PiYeqXQ05lYq3TIfIDu/MtliKUbNwkGApPUvyo6+tc7uaR4cPjPe7DFPr15Uyycg2lZU6btZ575CuQoYh7MQ==", "integrity": "sha512-f6PiYeqXQ05lYq3TIfIDu/MtliKUbNwkGApPUvyo6+tc7uaR4cPjPe7DFPr15Uyycg2lZU6btZ575CuQoYh7MQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/helper-plugin-utils": "^7.27.1" "@babel/helper-plugin-utils": "^7.27.1"
}, },
@@ -1510,6 +1531,7 @@
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.27.1.tgz", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.27.1.tgz",
"integrity": "sha512-SFy8S9plRPbIcxlJ8A6mT/CxFdJx/c04JEctz4jf8YZaVS2px34j7NXRrlGlHkN/M2gnpL37ZpGRGVFLd3l8Ng==", "integrity": "sha512-SFy8S9plRPbIcxlJ8A6mT/CxFdJx/c04JEctz4jf8YZaVS2px34j7NXRrlGlHkN/M2gnpL37ZpGRGVFLd3l8Ng==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/helper-plugin-utils": "^7.27.1", "@babel/helper-plugin-utils": "^7.27.1",
"@babel/helper-replace-supers": "^7.27.1" "@babel/helper-replace-supers": "^7.27.1"
@@ -1605,6 +1627,7 @@
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.27.1.tgz", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.27.1.tgz",
"integrity": "sha512-oThy3BCuCha8kDZ8ZkgOg2exvPYUlprMukKQXI1r1pJ47NCvxfkEy8vK+r/hT9nF0Aa4H1WUPZZjHTFtAhGfmQ==", "integrity": "sha512-oThy3BCuCha8kDZ8ZkgOg2exvPYUlprMukKQXI1r1pJ47NCvxfkEy8vK+r/hT9nF0Aa4H1WUPZZjHTFtAhGfmQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/helper-plugin-utils": "^7.27.1" "@babel/helper-plugin-utils": "^7.27.1"
}, },
@@ -1730,6 +1753,7 @@
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-regexp-modifiers/-/plugin-transform-regexp-modifiers-7.27.1.tgz", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regexp-modifiers/-/plugin-transform-regexp-modifiers-7.27.1.tgz",
"integrity": "sha512-TtEciroaiODtXvLZv4rmfMhkCv8jx3wgKpL68PuiPh2M4fvz5jhsA7697N1gMvkvr/JTF13DrFYyEbY9U7cVPA==", "integrity": "sha512-TtEciroaiODtXvLZv4rmfMhkCv8jx3wgKpL68PuiPh2M4fvz5jhsA7697N1gMvkvr/JTF13DrFYyEbY9U7cVPA==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/helper-create-regexp-features-plugin": "^7.27.1", "@babel/helper-create-regexp-features-plugin": "^7.27.1",
"@babel/helper-plugin-utils": "^7.27.1" "@babel/helper-plugin-utils": "^7.27.1"
@@ -1746,6 +1770,7 @@
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.27.1.tgz", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.27.1.tgz",
"integrity": "sha512-V2ABPHIJX4kC7HegLkYoDpfg9PVmuWy/i6vUM5eGK22bx4YVFD3M5F0QQnWQoDs6AGsUWTVOopBiMFQgHaSkVw==", "integrity": "sha512-V2ABPHIJX4kC7HegLkYoDpfg9PVmuWy/i6vUM5eGK22bx4YVFD3M5F0QQnWQoDs6AGsUWTVOopBiMFQgHaSkVw==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/helper-plugin-utils": "^7.27.1" "@babel/helper-plugin-utils": "^7.27.1"
}, },
@@ -1842,6 +1867,7 @@
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.27.1.tgz", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.27.1.tgz",
"integrity": "sha512-RiSILC+nRJM7FY5srIyc4/fGIwUhyDuuBSdWn4y6yT6gm652DpCHZjIipgn6B7MQ1ITOUnAKWixEUjQRIBIcLw==", "integrity": "sha512-RiSILC+nRJM7FY5srIyc4/fGIwUhyDuuBSdWn4y6yT6gm652DpCHZjIipgn6B7MQ1ITOUnAKWixEUjQRIBIcLw==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/helper-plugin-utils": "^7.27.1" "@babel/helper-plugin-utils": "^7.27.1"
}, },
@@ -1876,6 +1902,7 @@
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.27.1.tgz", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.27.1.tgz",
"integrity": "sha512-Ysg4v6AmF26k9vpfFuTZg8HRfVWzsh1kVfowA23y9j/Gu6dOuahdUVhkLqpObp3JIv27MLSii6noRnuKN8H0Mg==", "integrity": "sha512-Ysg4v6AmF26k9vpfFuTZg8HRfVWzsh1kVfowA23y9j/Gu6dOuahdUVhkLqpObp3JIv27MLSii6noRnuKN8H0Mg==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/helper-plugin-utils": "^7.27.1" "@babel/helper-plugin-utils": "^7.27.1"
}, },
@@ -1891,6 +1918,7 @@
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-property-regex/-/plugin-transform-unicode-property-regex-7.27.1.tgz", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-property-regex/-/plugin-transform-unicode-property-regex-7.27.1.tgz",
"integrity": "sha512-uW20S39PnaTImxp39O5qFlHLS9LJEmANjMG7SxIhap8rCHqu0Ik+tLEPX5DKmHn6CsWQ7j3lix2tFOa5YtL12Q==", "integrity": "sha512-uW20S39PnaTImxp39O5qFlHLS9LJEmANjMG7SxIhap8rCHqu0Ik+tLEPX5DKmHn6CsWQ7j3lix2tFOa5YtL12Q==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/helper-create-regexp-features-plugin": "^7.27.1", "@babel/helper-create-regexp-features-plugin": "^7.27.1",
"@babel/helper-plugin-utils": "^7.27.1" "@babel/helper-plugin-utils": "^7.27.1"
@@ -1923,6 +1951,7 @@
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-sets-regex/-/plugin-transform-unicode-sets-regex-7.27.1.tgz", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-sets-regex/-/plugin-transform-unicode-sets-regex-7.27.1.tgz",
"integrity": "sha512-EtkOujbc4cgvb0mlpQefi4NTPBzhSIevblFevACNLUspmrALgmEBdL/XfnyyITfd8fKBZrZys92zOWcik7j9Tw==", "integrity": "sha512-EtkOujbc4cgvb0mlpQefi4NTPBzhSIevblFevACNLUspmrALgmEBdL/XfnyyITfd8fKBZrZys92zOWcik7j9Tw==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/helper-create-regexp-features-plugin": "^7.27.1", "@babel/helper-create-regexp-features-plugin": "^7.27.1",
"@babel/helper-plugin-utils": "^7.27.1" "@babel/helper-plugin-utils": "^7.27.1"
@@ -2041,6 +2070,7 @@
"resolved": "https://registry.npmjs.org/@babel/preset-modules/-/preset-modules-0.1.6-no-external-plugins.tgz", "resolved": "https://registry.npmjs.org/@babel/preset-modules/-/preset-modules-0.1.6-no-external-plugins.tgz",
"integrity": "sha512-HrcgcIESLm9aIR842yhJ5RWan/gebQUJ6E/E5+rf0y9o6oj7w0Br+sWuL6kEQ/o/AdfvR1Je9jG18/gnpwjEyA==", "integrity": "sha512-HrcgcIESLm9aIR842yhJ5RWan/gebQUJ6E/E5+rf0y9o6oj7w0Br+sWuL6kEQ/o/AdfvR1Je9jG18/gnpwjEyA==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/helper-plugin-utils": "^7.0.0", "@babel/helper-plugin-utils": "^7.0.0",
"@babel/types": "^7.4.4", "@babel/types": "^7.4.4",
@@ -3936,7 +3966,6 @@
"resolved": "https://registry.npmjs.org/@react-navigation/native/-/native-7.1.26.tgz", "resolved": "https://registry.npmjs.org/@react-navigation/native/-/native-7.1.26.tgz",
"integrity": "sha512-RhKmeD0E2ejzKS6z8elAfdfwShpcdkYY8zJzvHYLq+wv183BBcElTeyMLcIX6wIn7QutXeI92Yi21t7aUWfqNQ==", "integrity": "sha512-RhKmeD0E2ejzKS6z8elAfdfwShpcdkYY8zJzvHYLq+wv183BBcElTeyMLcIX6wIn7QutXeI92Yi21t7aUWfqNQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@react-navigation/core": "^7.13.7", "@react-navigation/core": "^7.13.7",
"escape-string-regexp": "^4.0.0", "escape-string-regexp": "^4.0.0",
@@ -4608,7 +4637,6 @@
"integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==", "integrity": "sha512-cisd7gxkzjBKU2GgdYrTdtQx1SORymWyaAFhaxQPK9bYO9ot3Y5OikQRvY0VYQtvwjeQnizCINJAenh/V7MK2w==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@types/prop-types": "*", "@types/prop-types": "*",
"csstype": "^3.2.2" "csstype": "^3.2.2"
@@ -4753,7 +4781,6 @@
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz",
"integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"fast-deep-equal": "^3.1.3", "fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1", "fast-uri": "^3.0.1",
@@ -5330,7 +5357,6 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"baseline-browser-mapping": "^2.9.0", "baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759", "caniuse-lite": "^1.0.30001759",
@@ -6510,6 +6536,7 @@
"resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz",
"integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==",
"license": "BSD-2-Clause", "license": "BSD-2-Clause",
"peer": true,
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
} }
@@ -6634,7 +6661,6 @@
"resolved": "https://registry.npmjs.org/expo/-/expo-52.0.48.tgz", "resolved": "https://registry.npmjs.org/expo/-/expo-52.0.48.tgz",
"integrity": "sha512-/HR/vuo57KGEWlvF3GWaquwEAjXuA5hrOCsaLcZ3pMSA8mQ27qKd1jva4GWzpxXYedlzs/7LLP1XpZo6hXTsog==", "integrity": "sha512-/HR/vuo57KGEWlvF3GWaquwEAjXuA5hrOCsaLcZ3pMSA8mQ27qKd1jva4GWzpxXYedlzs/7LLP1XpZo6hXTsog==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/runtime": "^7.20.0", "@babel/runtime": "^7.20.0",
"@expo/cli": "0.22.27", "@expo/cli": "0.22.27",
@@ -6742,7 +6768,6 @@
"resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-17.0.8.tgz", "resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-17.0.8.tgz",
"integrity": "sha512-XfWRyQAf1yUNgWZ1TnE8pFBMqGmFP5Gb+SFSgszxDdOoheB/NI5D4p7q86kI2fvGyfTrxAe+D+74nZkfsGvUlg==", "integrity": "sha512-XfWRyQAf1yUNgWZ1TnE8pFBMqGmFP5Gb+SFSgszxDdOoheB/NI5D4p7q86kI2fvGyfTrxAe+D+74nZkfsGvUlg==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@expo/config": "~10.0.11", "@expo/config": "~10.0.11",
"@expo/env": "~0.4.2" "@expo/env": "~0.4.2"
@@ -6908,7 +6933,6 @@
"resolved": "https://registry.npmjs.org/expo-linking/-/expo-linking-7.0.5.tgz", "resolved": "https://registry.npmjs.org/expo-linking/-/expo-linking-7.0.5.tgz",
"integrity": "sha512-3KptlJtcYDPWohk0MfJU75MJFh2ybavbtcSd84zEPfw9s1q3hjimw3sXnH03ZxP54kiEWldvKmmnGcVffBDB1g==", "integrity": "sha512-3KptlJtcYDPWohk0MfJU75MJFh2ybavbtcSd84zEPfw9s1q3hjimw3sXnH03ZxP54kiEWldvKmmnGcVffBDB1g==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"expo-constants": "~17.0.5", "expo-constants": "~17.0.5",
"invariant": "^2.2.4" "invariant": "^2.2.4"
@@ -10754,7 +10778,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"loose-envify": "^1.1.0" "loose-envify": "^1.1.0"
}, },
@@ -10798,7 +10821,6 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"loose-envify": "^1.1.0", "loose-envify": "^1.1.0",
"scheduler": "^0.23.2" "scheduler": "^0.23.2"
@@ -10853,7 +10875,6 @@
"resolved": "https://registry.npmjs.org/react-native/-/react-native-0.76.9.tgz", "resolved": "https://registry.npmjs.org/react-native/-/react-native-0.76.9.tgz",
"integrity": "sha512-+LRwecWmTDco7OweGsrECIqJu0iyrREd6CTCgC/uLLYipiHvk+MH9nd6drFtCw/6Blz6eoKTcH9YTTJusNtrWg==", "integrity": "sha512-+LRwecWmTDco7OweGsrECIqJu0iyrREd6CTCgC/uLLYipiHvk+MH9nd6drFtCw/6Blz6eoKTcH9YTTJusNtrWg==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@jest/create-cache-key-function": "^29.6.3", "@jest/create-cache-key-function": "^29.6.3",
"@react-native/assets-registry": "0.76.9", "@react-native/assets-registry": "0.76.9",
@@ -10979,7 +11000,6 @@
"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",
"integrity": "sha512-qoUUQOwE1pHlmQ9cXTJ2MX9FQ9eHllopCLiWOkDkp6CER95ZWeXhJCP4cSm6AD4jigL5jHcZf/SkWrg8ttZUsw==", "integrity": "sha512-qoUUQOwE1pHlmQ9cXTJ2MX9FQ9eHllopCLiWOkDkp6CER95ZWeXhJCP4cSm6AD4jigL5jHcZf/SkWrg8ttZUsw==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/plugin-transform-arrow-functions": "^7.0.0-0", "@babel/plugin-transform-arrow-functions": "^7.0.0-0",
"@babel/plugin-transform-class-properties": "^7.0.0-0", "@babel/plugin-transform-class-properties": "^7.0.0-0",
@@ -11004,7 +11024,6 @@
"resolved": "https://registry.npmjs.org/react-native-safe-area-context/-/react-native-safe-area-context-4.12.0.tgz", "resolved": "https://registry.npmjs.org/react-native-safe-area-context/-/react-native-safe-area-context-4.12.0.tgz",
"integrity": "sha512-ukk5PxcF4p3yu6qMZcmeiZgowhb5AsKRnil54YFUUAXVIS7PJcMHGGC+q44fCiBg44/1AJk5njGMez1m9H0BVQ==", "integrity": "sha512-ukk5PxcF4p3yu6qMZcmeiZgowhb5AsKRnil54YFUUAXVIS7PJcMHGGC+q44fCiBg44/1AJk5njGMez1m9H0BVQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"peerDependencies": { "peerDependencies": {
"react": "*", "react": "*",
"react-native": "*" "react-native": "*"
@@ -11015,7 +11034,6 @@
"resolved": "https://registry.npmjs.org/react-native-screens/-/react-native-screens-4.4.0.tgz", "resolved": "https://registry.npmjs.org/react-native-screens/-/react-native-screens-4.4.0.tgz",
"integrity": "sha512-c7zc7Zwjty6/pGyuuvh9gK3YBYqHPOxrhXfG1lF4gHlojQSmIx2piNbNaV+Uykj+RDTmFXK0e/hA+fucw/Qozg==", "integrity": "sha512-c7zc7Zwjty6/pGyuuvh9gK3YBYqHPOxrhXfG1lF4gHlojQSmIx2piNbNaV+Uykj+RDTmFXK0e/hA+fucw/Qozg==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"react-freeze": "^1.0.0", "react-freeze": "^1.0.0",
"warn-once": "^0.1.0" "warn-once": "^0.1.0"
@@ -11042,7 +11060,6 @@
"resolved": "https://registry.npmjs.org/react-native-web/-/react-native-web-0.19.13.tgz", "resolved": "https://registry.npmjs.org/react-native-web/-/react-native-web-0.19.13.tgz",
"integrity": "sha512-etv3bN8rJglrRCp/uL4p7l8QvUNUC++QwDbdZ8CB7BvZiMvsxfFIRM1j04vxNldG3uo2puRd6OSWR3ibtmc29A==", "integrity": "sha512-etv3bN8rJglrRCp/uL4p7l8QvUNUC++QwDbdZ8CB7BvZiMvsxfFIRM1j04vxNldG3uo2puRd6OSWR3ibtmc29A==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/runtime": "^7.18.6", "@babel/runtime": "^7.18.6",
"@react-native/normalize-colors": "^0.74.1", "@react-native/normalize-colors": "^0.74.1",
+58
View File
@@ -94,6 +94,64 @@ export async function clearPushToken(userId: string): Promise<void> {
} }
} }
/**
* Check if current device needs to register for push notifications.
* This handles the case where a user onboarded on one device (e.g., iOS)
* and then installs on another device (e.g., Android) - the new device
* needs its own push token.
*
* Returns true if a new token was registered.
*/
export async function checkAndRegisterPushToken(userId: string): Promise<boolean> {
if (Platform.OS === "web") {
return false;
}
try {
// First check if permissions are already granted
const { status } = await Notifications.getPermissionsAsync();
// If permissions not granted, we need to prompt the user
// But only do this if we don't already have a token for this device
if (status !== "granted") {
// Request permissions
const { status: newStatus } = await Notifications.requestPermissionsAsync();
if (newStatus !== "granted") {
return false;
}
}
// Get the current device's push token
const projectId = Constants.expoConfig?.extra?.eas?.projectId;
if (!projectId) {
return false;
}
const tokenData = await Notifications.getExpoPushTokenAsync({ projectId });
const currentToken = tokenData.data;
// Fetch the stored token from the database
const { data: profile } = await supabase
.from("profiles")
.select("push_token")
.eq("id", userId)
.single();
// If the stored token is different (or null), save the new one
if (profile?.push_token !== currentToken) {
await savePushToken(userId, currentToken);
console.log("Push token updated for new device");
return true;
}
return false;
} catch (error) {
console.error("Failed to check/register push token:", error);
captureError(error as Error, { context: "push_token_check" });
return false;
}
}
/** /**
* Handle notification tap - navigate to appropriate screen. * Handle notification tap - navigate to appropriate screen.
*/ */
+1
View File
@@ -57,6 +57,7 @@ export interface Question {
partner_text?: string | null; partner_text?: string | null;
intensity: 1 | 2 | 3 | 4 | 5; intensity: 1 | 2 | 3 | 4 | 5;
allowed_couple_genders?: string[] | null; allowed_couple_genders?: string[] | null;
target_user_genders?: string[] | null;
created_at: string; created_at: string;
} }
@@ -1,108 +0,0 @@
-- Enable necessary extensions
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
-- Create custom types
CREATE TYPE public.answer_type AS ENUM ('yes', 'no', 'maybe');
CREATE TYPE public.match_type AS ENUM ('yes_yes', 'yes_maybe', 'maybe_maybe');
-- Couples table (must be created first due to FK references)
CREATE TABLE public.couples (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
invite_code TEXT UNIQUE DEFAULT substr(md5(random()::text), 1, 8),
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- User profiles (extends auth.users)
CREATE TABLE public.profiles (
id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE,
name TEXT,
avatar_url TEXT,
push_token TEXT,
is_premium BOOLEAN DEFAULT FALSE,
couple_id UUID REFERENCES public.couples(id) ON DELETE SET NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Question packs
CREATE TABLE public.question_packs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
name TEXT NOT NULL,
description TEXT,
icon TEXT,
is_premium BOOLEAN DEFAULT FALSE,
is_public BOOLEAN DEFAULT TRUE,
sort_order INT DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Questions within packs
CREATE TABLE public.questions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
pack_id UUID NOT NULL REFERENCES public.question_packs(id) ON DELETE CASCADE,
text TEXT NOT NULL,
intensity INT DEFAULT 1 CHECK (intensity BETWEEN 1 AND 5),
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- User responses to questions
CREATE TABLE public.responses (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES public.profiles(id) ON DELETE CASCADE,
question_id UUID NOT NULL REFERENCES public.questions(id) ON DELETE CASCADE,
couple_id UUID NOT NULL REFERENCES public.couples(id) ON DELETE CASCADE,
answer public.answer_type NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(user_id, question_id)
);
-- Matches between partners
CREATE TABLE public.matches (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
couple_id UUID NOT NULL REFERENCES public.couples(id) ON DELETE CASCADE,
question_id UUID NOT NULL REFERENCES public.questions(id) ON DELETE CASCADE,
match_type public.match_type NOT NULL,
is_new BOOLEAN DEFAULT TRUE,
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(couple_id, question_id)
);
-- Create indexes for common queries
CREATE INDEX idx_profiles_couple_id ON public.profiles(couple_id);
CREATE INDEX idx_questions_pack_id ON public.questions(pack_id);
CREATE INDEX idx_responses_couple_question ON public.responses(couple_id, question_id);
CREATE INDEX idx_matches_couple_id ON public.matches(couple_id);
CREATE INDEX idx_matches_is_new ON public.matches(is_new) WHERE is_new = TRUE;
-- Function to auto-create profile on signup
CREATE OR REPLACE FUNCTION public.handle_new_user()
RETURNS TRIGGER AS $$
BEGIN
INSERT INTO public.profiles (id, name, avatar_url)
VALUES (
NEW.id,
COALESCE(NEW.raw_user_meta_data->>'name', NEW.raw_user_meta_data->>'full_name'),
NEW.raw_user_meta_data->>'avatar_url'
);
RETURN NEW;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- Trigger to create profile on signup
CREATE TRIGGER on_auth_user_created
AFTER INSERT ON auth.users
FOR EACH ROW EXECUTE FUNCTION public.handle_new_user();
-- Function to update updated_at timestamp
CREATE OR REPLACE FUNCTION public.update_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Trigger to auto-update updated_at on profiles
CREATE TRIGGER update_profiles_updated_at
BEFORE UPDATE ON public.profiles
FOR EACH ROW EXECUTE FUNCTION public.update_updated_at();
@@ -1,87 +0,0 @@
-- Enable Row Level Security on all tables
ALTER TABLE public.profiles ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.couples ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.question_packs ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.questions ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.responses ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.matches ENABLE ROW LEVEL SECURITY;
-- Profiles policies
CREATE POLICY "Users can view own profile"
ON public.profiles FOR SELECT
USING (auth.uid() = id);
CREATE POLICY "Users can update own profile"
ON public.profiles FOR UPDATE
USING (auth.uid() = id);
CREATE POLICY "Users can view partner profile"
ON public.profiles FOR SELECT
USING (
couple_id IS NOT NULL AND
couple_id IN (SELECT couple_id FROM public.profiles WHERE id = auth.uid())
);
-- Couples policies
CREATE POLICY "Users can view own couple"
ON public.couples FOR SELECT
USING (id IN (SELECT couple_id FROM public.profiles WHERE id = auth.uid()));
CREATE POLICY "Authenticated users can create couples"
ON public.couples FOR INSERT
WITH CHECK (auth.role() = 'authenticated');
CREATE POLICY "Users can view couple by invite code"
ON public.couples FOR SELECT
USING (TRUE); -- Allow reading by invite_code for joining
-- Question packs policies (public packs visible to all)
CREATE POLICY "Anyone can view public packs"
ON public.question_packs FOR SELECT
USING (is_public = TRUE);
CREATE POLICY "Premium users can view premium packs"
ON public.question_packs FOR SELECT
USING (
is_premium = FALSE OR
EXISTS (SELECT 1 FROM public.profiles WHERE id = auth.uid() AND is_premium = TRUE)
);
-- Questions policies
CREATE POLICY "Anyone can view questions in visible packs"
ON public.questions FOR SELECT
USING (
pack_id IN (
SELECT id FROM public.question_packs
WHERE is_public = TRUE OR (
is_premium = TRUE AND
EXISTS (SELECT 1 FROM public.profiles WHERE id = auth.uid() AND is_premium = TRUE)
)
)
);
-- Responses policies
CREATE POLICY "Users can view own responses"
ON public.responses FOR SELECT
USING (user_id = auth.uid());
CREATE POLICY "Users can insert own responses"
ON public.responses FOR INSERT
WITH CHECK (user_id = auth.uid());
CREATE POLICY "Users can update own responses"
ON public.responses FOR UPDATE
USING (user_id = auth.uid());
-- Matches policies
CREATE POLICY "Users can view couple matches"
ON public.matches FOR SELECT
USING (
couple_id IN (SELECT couple_id FROM public.profiles WHERE id = auth.uid())
);
CREATE POLICY "Users can update match seen status"
ON public.matches FOR UPDATE
USING (
couple_id IN (SELECT couple_id FROM public.profiles WHERE id = auth.uid())
);
@@ -1,58 +0,0 @@
-- Seed data: Initial question packs for testing
-- Starter Pack (free)
INSERT INTO public.question_packs (id, name, description, icon, is_premium, is_public, sort_order)
VALUES (
'a1b2c3d4-e5f6-7890-abcd-ef1234567890',
'Starter Pack',
'Perfect for couples just getting started. Light and playful questions to break the ice.',
'🌟',
FALSE,
TRUE,
1
);
INSERT INTO public.questions (pack_id, text, intensity) VALUES
('a1b2c3d4-e5f6-7890-abcd-ef1234567890', 'Would you like to try a couples massage together?', 1),
('a1b2c3d4-e5f6-7890-abcd-ef1234567890', 'Would you enjoy a candlelit dinner at home?', 1),
('a1b2c3d4-e5f6-7890-abcd-ef1234567890', 'Would you like to slow dance in the living room?', 1),
('a1b2c3d4-e5f6-7890-abcd-ef1234567890', 'Would you enjoy a bath or shower together?', 2),
('a1b2c3d4-e5f6-7890-abcd-ef1234567890', 'Would you like to give your partner a back rub?', 1);
-- Date Night Pack (free)
INSERT INTO public.question_packs (id, name, description, icon, is_premium, is_public, sort_order)
VALUES (
'b2c3d4e5-f678-9012-bcde-f23456789012',
'Date Night',
'Spice up your date nights with these romantic suggestions.',
'🌹',
FALSE,
TRUE,
2
);
INSERT INTO public.questions (pack_id, text, intensity) VALUES
('b2c3d4e5-f678-9012-bcde-f23456789012', 'Would you enjoy stargazing together?', 1),
('b2c3d4e5-f678-9012-bcde-f23456789012', 'Would you like to cook a meal together?', 1),
('b2c3d4e5-f678-9012-bcde-f23456789012', 'Would you enjoy a picnic in the park?', 1),
('b2c3d4e5-f678-9012-bcde-f23456789012', 'Would you like to watch a romantic movie together?', 1),
('b2c3d4e5-f678-9012-bcde-f23456789012', 'Would you enjoy a weekend getaway?', 2);
-- Adventure Pack (premium)
INSERT INTO public.question_packs (id, name, description, icon, is_premium, is_public, sort_order)
VALUES (
'c3d4e5f6-7890-1234-cdef-345678901234',
'Adventure',
'For couples ready to explore more adventurous activities together.',
'🔥',
TRUE,
TRUE,
3
);
INSERT INTO public.questions (pack_id, text, intensity) VALUES
('c3d4e5f6-7890-1234-cdef-345678901234', 'Would you like to try role-playing scenarios?', 3),
('c3d4e5f6-7890-1234-cdef-345678901234', 'Would you enjoy using massage oils?', 2),
('c3d4e5f6-7890-1234-cdef-345678901234', 'Would you like to write love letters to each other?', 2),
('c3d4e5f6-7890-1234-cdef-345678901234', 'Would you enjoy a blindfolded experience?', 3),
('c3d4e5f6-7890-1234-cdef-345678901234', 'Would you like to plan a surprise for your partner?', 2);
@@ -1,32 +0,0 @@
-- Add Adult / After Dark pack
INSERT INTO public.question_packs (id, name, description, icon, is_premium, is_public, sort_order)
VALUES (
'd4e5f607-8901-2345-defa-456789012345',
'After Dark',
'Our most intimate and explicit questions for couples ready to explore their deepest fantasies.',
'😈',
TRUE,
TRUE,
4
);
INSERT INTO public.questions (pack_id, text, intensity) VALUES
('d4e5f607-8901-2345-defa-456789012345', 'Would you like to try using toys during intimacy?', 3),
('d4e5f607-8901-2345-defa-456789012345', 'Would you enjoy exploring BDSM (e.g., light bondage)?', 3),
('d4e5f607-8901-2345-defa-456789012345', 'Would you like to watch adult content together?', 3),
('d4e5f607-8901-2345-defa-456789012345', 'Would you enjoy trying intimacy in a new or daring location?', 3),
('d4e5f607-8901-2345-defa-456789012345', 'Would you like to experiment with temperature play (e.g., ice or wax)?', 3),
('d4e5f607-8901-2345-defa-456789012345', 'Would you enjoy role-playing a "stranger" or "first date" scenario?', 3),
('d4e5f607-8901-2345-defa-456789012345', 'Would you like to try "dirty talk" or more explicit communication?', 3),
('d4e5f607-8901-2345-defa-456789012345', 'Would you enjoy a full-body sensory massage with oils and feathers?', 3),
('d4e5f607-8901-2345-defa-456789012345', 'Would you like to explore "edging" or delayed gratification?', 3),
('d4e5f607-8901-2345-defa-456789012345', 'Would you enjoy sharing your deepest, most explicit fantasies?', 3),
('d4e5f607-8901-2345-defa-456789012345', 'Would you like to try shower sex or water play?', 3),
('d4e5f607-8901-2345-defa-456789012345', 'Would you enjoy a roleplay involving a "student/teacher" or similar dynamic?', 3),
('d4e5f607-8901-2345-defa-456789012345', 'Would you like to experiment with leather, latex, or other textures?', 3),
('d4e5f607-8901-2345-defa-456789012345', 'Would you enjoy trying "impact play" (e.g., spanking)?', 3),
('d4e5f607-8901-2345-defa-456789012345', 'Would you enjoy a weekend dedicated entirely to intimacy and exploration?', 2),
('d4e5f607-8901-2345-defa-456789012345', 'Would you like to try "blindfolded feeding" or sensory food play?', 2),
('d4e5f607-8901-2345-defa-456789012345', 'Would you enjoy exploring your partner''s "no-go" zones with permission?', 3),
('d4e5f607-8901-2345-defa-456789012345', 'Would you like to try "synchronized breathing" or Tantric techniques?', 2),
('d4e5f607-8901-2345-defa-456789012345', 'Would you enjoy making a "video for us" to watch later?', 3);
@@ -1,49 +0,0 @@
-- Fix infinite recursion in profiles RLS policy
-- Create a secure function to look up couple_id without triggering recursion
CREATE OR REPLACE FUNCTION public.get_auth_user_couple_id()
RETURNS UUID
LANGUAGE sql
SECURITY DEFINER
STABLE
AS $$
SELECT couple_id FROM public.profiles WHERE id = auth.uid();
$$;
-- Drop the recursive policy
DROP POLICY IF EXISTS "Users can view partner profile" ON public.profiles;
-- Recreate the policy using the secure function
CREATE POLICY "Users can view partner profile"
ON public.profiles FOR SELECT
USING (
couple_id IS NOT NULL AND
couple_id = public.get_auth_user_couple_id()
);
-- Also fix the Matches policy which has the same issue
DROP POLICY IF EXISTS "Users can view couple matches" ON public.matches;
CREATE POLICY "Users can view couple matches"
ON public.matches FOR SELECT
USING (
couple_id = public.get_auth_user_couple_id()
);
-- And the update matches policy
DROP POLICY IF EXISTS "Users can update match seen status" ON public.matches;
CREATE POLICY "Users can update match seen status"
ON public.matches FOR UPDATE
USING (
couple_id = public.get_auth_user_couple_id()
);
-- And the Couples policy
DROP POLICY IF EXISTS "Users can view own couple" ON public.couples;
CREATE POLICY "Users can view own couple"
ON public.couples FOR SELECT
USING (
id = public.get_auth_user_couple_id()
);
@@ -1,41 +0,0 @@
-- Create couple_packs table to track enabled packs per couple
CREATE TABLE public.couple_packs (
couple_id UUID NOT NULL REFERENCES public.couples(id) ON DELETE CASCADE,
pack_id UUID NOT NULL REFERENCES public.question_packs(id) ON DELETE CASCADE,
enabled BOOLEAN DEFAULT TRUE,
created_at TIMESTAMPTZ DEFAULT NOW(),
PRIMARY KEY (couple_id, pack_id)
);
-- RLS Policies
ALTER TABLE public.couple_packs ENABLE ROW LEVEL SECURITY;
-- Couples can view their own pack settings
CREATE POLICY "Couples can view their own pack settings"
ON public.couple_packs
FOR SELECT
USING (
EXISTS (
SELECT 1 FROM public.profiles
WHERE profiles.id = auth.uid()
AND profiles.couple_id = couple_packs.couple_id
)
);
-- Couples can insert/update their own pack settings
CREATE POLICY "Couples can modify their own pack settings"
ON public.couple_packs
FOR ALL
USING (
EXISTS (
SELECT 1 FROM public.profiles
WHERE profiles.id = auth.uid()
AND profiles.couple_id = couple_packs.couple_id
)
);
-- Function to handle default packs for new couples (Optional, but good UX)
-- For now, we'll just assume if no record exists, it's not enabled?
-- Or maybe better: if no record exists, it IS enabled if it's not premium?
-- Actually, the user wants to "select which packs are active".
-- Let's stick to explicit records.
@@ -1,64 +0,0 @@
-- Create messages table
CREATE TABLE IF NOT EXISTS public.messages (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
match_id UUID NOT NULL REFERENCES public.matches(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
content TEXT,
media_path TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
CONSTRAINT content_or_media CHECK (content IS NOT NULL OR media_path IS NOT NULL)
);
-- RLS for messages
ALTER TABLE public.messages ENABLE ROW LEVEL SECURITY;
CREATE POLICY "Users can view messages in their matches"
ON public.messages
FOR SELECT
USING (
EXISTS (
SELECT 1 FROM public.matches m
WHERE m.id = messages.match_id
AND m.couple_id IN (
SELECT couple_id FROM public.profiles
WHERE id = auth.uid()
)
)
);
CREATE POLICY "Users can insert messages in their matches"
ON public.messages
FOR INSERT
WITH CHECK (
auth.uid() = user_id AND
EXISTS (
SELECT 1 FROM public.matches m
WHERE m.id = messages.match_id
AND m.couple_id IN (
SELECT couple_id FROM public.profiles
WHERE id = auth.uid()
)
)
);
-- Storage bucket for chat media
INSERT INTO storage.buckets (id, name, public)
VALUES ('chat-media', 'chat-media', true)
ON CONFLICT (id) DO NOTHING;
-- Storage policies
CREATE POLICY "Users can upload chat media"
ON storage.objects
FOR INSERT
WITH CHECK (
bucket_id = 'chat-media' AND
auth.role() = 'authenticated'
);
CREATE POLICY "Users can view chat media"
ON storage.objects
FOR SELECT
USING (bucket_id = 'chat-media');
-- Realtime
alter publication supabase_realtime add table messages;
@@ -1,21 +0,0 @@
-- Add read_at column
ALTER TABLE public.messages ADD COLUMN IF NOT EXISTS read_at TIMESTAMPTZ;
-- Allow users to update read_at for messages where they are NOT the sender
CREATE POLICY "Users can mark messages as read"
ON public.messages
FOR UPDATE
USING (
auth.uid() != user_id AND
EXISTS (
SELECT 1 FROM public.matches m
WHERE m.id = messages.match_id
AND m.couple_id IN (
SELECT couple_id FROM public.profiles
WHERE id = auth.uid()
)
)
)
WITH CHECK (
auth.uid() != user_id
);
@@ -1,33 +0,0 @@
-- Add Explicit / Wild pack
INSERT INTO public.question_packs (id, name, description, icon, is_premium, is_public, sort_order)
VALUES (
'e5f6a7b8-9012-3456-abcd-567890123456',
'Explicit & Wild',
'Unleash your wildest desires with our most explicit questions. Uses direct language for couples who want to get straight to the point.',
'🔥',
TRUE,
TRUE,
5
);
INSERT INTO public.questions (pack_id, text, intensity) VALUES
('e5f6a7b8-9012-3456-abcd-567890123456', 'Would you let me cum on your face?', 5),
('e5f6a7b8-9012-3456-abcd-567890123456', 'Do you want to swallow my cum?', 5),
('e5f6a7b8-9012-3456-abcd-567890123456', 'Would you like to be fucked rough, with hair pulling and choking?', 5),
('e5f6a7b8-9012-3456-abcd-567890123456', 'Do you want to use a butt plug during sex?', 4),
('e5f6a7b8-9012-3456-abcd-567890123456', 'Would you let me treat you like a slut in the bedroom?', 5),
('e5f6a7b8-9012-3456-abcd-567890123456', 'Do you want to make a sex tape where we show everything?', 4),
('e5f6a7b8-9012-3456-abcd-567890123456', 'Would you be into a gangbang fantasy?', 5),
('e5f6a7b8-9012-3456-abcd-567890123456', 'Do you want a creampie (cum inside)?', 5),
('e5f6a7b8-9012-3456-abcd-567890123456', 'Would you like to use a strap-on to fuck me?', 5),
('e5f6a7b8-9012-3456-abcd-567890123456', 'Do you want to rim my asshole?', 5),
('e5f6a7b8-9012-3456-abcd-567890123456', 'Do you want to try fisting?', 5),
('e5f6a7b8-9012-3456-abcd-567890123456', 'Would you lick my cum off your own body?', 5),
('e5f6a7b8-9012-3456-abcd-567890123456', 'Do you want to deep throat until you gag?', 5),
('e5f6a7b8-9012-3456-abcd-567890123456', 'Would you like to watch me have sex with someone else?', 5),
('e5f6a7b8-9012-3456-abcd-567890123456', 'Do you want to have anal sex until I cum?', 5),
('e5f6a7b8-9012-3456-abcd-567890123456', 'Would you be down for a threesome?', 5),
('e5f6a7b8-9012-3456-abcd-567890123456', 'Do you want me to edge you until you are begging to cum?', 4),
('e5f6a7b8-9012-3456-abcd-567890123456', 'Would you like to wake me up with oral sex?', 4),
('e5f6a7b8-9012-3456-abcd-567890123456', 'Do you want to be spanked hard enough to leave marks?', 4),
('e5f6a7b8-9012-3456-abcd-567890123456', 'Would you enjoy being tied up and helpless while I use you?', 5);
@@ -1,70 +0,0 @@
CREATE OR REPLACE FUNCTION public.get_recommended_questions(target_pack_id UUID DEFAULT NULL)
RETURNS TABLE (
id UUID,
text TEXT,
pack_id UUID,
intensity INT,
partner_answered BOOLEAN
) LANGUAGE plpgsql SECURITY DEFINER AS $$
DECLARE
v_user_id UUID;
v_couple_id UUID;
BEGIN
v_user_id := auth.uid();
-- Get couple_id
SELECT p.couple_id INTO v_couple_id
FROM public.profiles p
WHERE p.id = v_user_id;
IF v_couple_id IS NULL THEN
RETURN;
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 (
-- Case 0: Specific pack requested
SELECT target_pack_id AS pack_id
WHERE target_pack_id IS NOT NULL
UNION ALL
-- Case 1: No specific pack, config exists -> use enabled packs
SELECT cp.pack_id
FROM public.couple_packs cp
WHERE target_pack_id IS NULL
AND cp.couple_id = v_couple_id AND cp.enabled = TRUE
AND EXISTS (SELECT 1 FROM user_config_exists)
UNION ALL
-- Case 2: No specific pack, no config -> use all public packs
SELECT p.id AS pack_id
FROM public.question_packs p
WHERE target_pack_id IS NULL
AND p.is_public = TRUE
AND NOT EXISTS (SELECT 1 FROM user_config_exists)
)
SELECT
q.id,
q.text,
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
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
);
END;
$$;
@@ -1,79 +0,0 @@
-- Add partner_text column to questions table
ALTER TABLE public.questions ADD COLUMN IF NOT EXISTS partner_text TEXT;
-- Update get_recommended_questions to include partner_text
-- Must drop first because return type changes
DROP FUNCTION IF EXISTS public.get_recommended_questions(uuid);
CREATE OR REPLACE FUNCTION public.get_recommended_questions(target_pack_id UUID DEFAULT NULL)
RETURNS TABLE (
id UUID,
text TEXT,
partner_text TEXT,
pack_id UUID,
intensity INT,
partner_answered BOOLEAN
) LANGUAGE plpgsql SECURITY DEFINER AS $$
DECLARE
v_user_id UUID;
v_couple_id UUID;
BEGIN
v_user_id := auth.uid();
-- Get couple_id
SELECT p.couple_id INTO v_couple_id
FROM public.profiles p
WHERE p.id = v_user_id;
IF v_couple_id IS NULL THEN
RETURN;
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 (
-- Case 0: Specific pack requested
SELECT target_pack_id AS pack_id
WHERE target_pack_id IS NOT NULL
UNION ALL
-- Case 1: No specific pack, config exists -> use enabled packs
SELECT cp.pack_id
FROM public.couple_packs cp
WHERE target_pack_id IS NULL
AND cp.couple_id = v_couple_id AND cp.enabled = TRUE
AND EXISTS (SELECT 1 FROM user_config_exists)
UNION ALL
-- Case 2: No specific pack, no config -> use all public packs
SELECT p.id AS pack_id
FROM public.question_packs p
WHERE target_pack_id IS NULL
AND p.is_public = TRUE
AND NOT EXISTS (SELECT 1 FROM user_config_exists)
)
SELECT
q.id,
q.text,
q.partner_text,
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
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
);
END;
$$;
@@ -1,18 +0,0 @@
-- Add a new pack for two-part questions
INSERT INTO public.question_packs (id, name, description, icon, is_premium, is_public, sort_order)
VALUES (
'd4e5f6f7-8901-2345-defa-456789012345',
'Giving & Receiving',
'Explore different roles and dynamics with these two-part questions.',
'🌓',
FALSE,
TRUE,
4
) ON CONFLICT (id) DO NOTHING;
INSERT INTO public.questions (pack_id, text, partner_text, intensity) VALUES
('d4e5f6f7-8901-2345-defa-456789012345', 'Finish on your partner''s face', 'Have your partner finish on your face', 4),
('d4e5f6f7-8901-2345-defa-456789012345', 'Give your partner a full body massage', 'Receive a full body massage from your partner', 2),
('d4e5f6f7-8901-2345-defa-456789012345', 'Tie your partner up', 'Be tied up by your partner', 4),
('d4e5f6f7-8901-2345-defa-456789012345', 'Blindfold your partner', 'Be blindfolded by your partner', 3),
('d4e5f6f7-8901-2345-defa-456789012345', 'Feed your partner while they are blindfolded', 'Be fed by your partner while blindfolded', 2);
@@ -1,75 +0,0 @@
-- Update get_recommended_questions to handle two-part questions role assignment
-- And return is_two_part for prioritization in the app
DROP FUNCTION IF EXISTS public.get_recommended_questions(uuid);
CREATE OR REPLACE FUNCTION public.get_recommended_questions(target_pack_id UUID DEFAULT NULL)
RETURNS TABLE (
id UUID,
text TEXT,
partner_text TEXT, -- Keep this for reference if needed, but 'text' will be the display one
is_two_part BOOLEAN,
pack_id UUID,
intensity INT,
partner_answered BOOLEAN
) LANGUAGE plpgsql SECURITY DEFINER AS $$
DECLARE
v_user_id UUID;
v_couple_id UUID;
BEGIN
v_user_id := auth.uid();
-- Get couple_id
SELECT p.couple_id INTO v_couple_id
FROM public.profiles p
WHERE p.id = v_user_id;
IF v_couple_id IS NULL THEN
RETURN;
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
UNION ALL
SELECT cp.pack_id FROM public.couple_packs cp
WHERE target_pack_id IS NULL AND cp.couple_id = v_couple_id AND cp.enabled = 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 NOT EXISTS (SELECT 1 FROM user_config_exists)
)
SELECT
q.id,
-- Logic: Show partner_text IF partner has already answered, otherwise show text
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
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
);
END;
$$;
@@ -1,9 +0,0 @@
-- Allow users to view their partner's responses in addition to their own
DROP POLICY IF EXISTS "Users can view own responses" ON public.responses;
CREATE POLICY "Users can view couple responses"
ON public.responses FOR SELECT
USING (
user_id = auth.uid() OR
couple_id IN (SELECT couple_id FROM public.profiles WHERE id = auth.uid())
);
@@ -1,74 +0,0 @@
-- Create feedback type enum
CREATE TYPE public.feedback_type AS ENUM ('bug', 'feature_request', 'general');
-- Create feedback status enum (for future admin use)
CREATE TYPE public.feedback_status AS ENUM ('new', 'reviewed', 'in_progress', 'resolved', 'closed');
-- Create feedback table
CREATE TABLE public.feedback (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES public.profiles(id) ON DELETE CASCADE,
-- Feedback content
type public.feedback_type NOT NULL,
title TEXT NOT NULL,
description TEXT NOT NULL,
screenshot_url TEXT,
-- Device info (JSONB for flexibility)
device_info JSONB NOT NULL DEFAULT '{}',
-- Admin/tracking fields
status public.feedback_status NOT NULL DEFAULT 'new',
admin_notes TEXT,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Create indexes
CREATE INDEX idx_feedback_user_id ON public.feedback(user_id);
CREATE INDEX idx_feedback_type ON public.feedback(type);
CREATE INDEX idx_feedback_status ON public.feedback(status);
CREATE INDEX idx_feedback_created_at ON public.feedback(created_at DESC);
-- Enable RLS
ALTER TABLE public.feedback ENABLE ROW LEVEL SECURITY;
-- RLS Policies
-- Users can insert their own feedback
CREATE POLICY "Users can create feedback"
ON public.feedback FOR INSERT
WITH CHECK (auth.uid() = user_id);
-- Users can view their own feedback
CREATE POLICY "Users can view own feedback"
ON public.feedback FOR SELECT
USING (auth.uid() = user_id);
-- Users can update their own feedback (only if still 'new' status)
CREATE POLICY "Users can update own pending feedback"
ON public.feedback FOR UPDATE
USING (auth.uid() = user_id AND status = 'new');
-- Create storage bucket for feedback screenshots
INSERT INTO storage.buckets (id, name, public)
VALUES ('feedback-screenshots', 'feedback-screenshots', false)
ON CONFLICT (id) DO NOTHING;
-- Storage RLS policies for feedback screenshots
CREATE POLICY "Users can upload feedback screenshots"
ON storage.objects FOR INSERT
TO authenticated
WITH CHECK (
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
);
@@ -1,84 +0,0 @@
-- Create subscription status enum
CREATE TYPE public.subscription_status AS ENUM (
'active',
'cancelled',
'expired',
'billing_issue',
'paused'
);
-- Create subscriptions table
CREATE TABLE public.subscriptions (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES public.profiles(id) ON DELETE CASCADE,
revenuecat_app_user_id TEXT NOT NULL,
product_id TEXT NOT NULL,
status public.subscription_status NOT NULL DEFAULT 'active',
entitlement_ids TEXT[] DEFAULT '{}',
purchased_at TIMESTAMPTZ NOT NULL,
expires_at TIMESTAMPTZ,
original_transaction_id TEXT,
store TEXT DEFAULT 'APP_STORE',
is_sandbox BOOLEAN DEFAULT FALSE,
cancel_reason TEXT,
grace_period_expires_at TIMESTAMPTZ,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE(user_id, original_transaction_id)
);
-- Create webhook events table for idempotency
CREATE TABLE public.revenuecat_webhook_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
event_id TEXT UNIQUE NOT NULL,
event_type TEXT NOT NULL,
app_user_id TEXT NOT NULL,
processed_at TIMESTAMPTZ DEFAULT NOW(),
payload JSONB
);
-- Indexes
CREATE INDEX idx_subscriptions_user_id ON public.subscriptions(user_id);
CREATE INDEX idx_subscriptions_status ON public.subscriptions(status);
CREATE INDEX idx_subscriptions_expires_at ON public.subscriptions(expires_at);
CREATE INDEX idx_webhook_events_event_id ON public.revenuecat_webhook_events(event_id);
-- Update updated_at trigger for subscriptions
CREATE TRIGGER update_subscriptions_updated_at
BEFORE UPDATE ON public.subscriptions
FOR EACH ROW EXECUTE FUNCTION public.update_updated_at();
-- Enable RLS
ALTER TABLE public.subscriptions ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.revenuecat_webhook_events ENABLE ROW LEVEL SECURITY;
-- RLS Policies for subscriptions
CREATE POLICY "Users can view own subscriptions"
ON public.subscriptions FOR SELECT
USING (auth.uid() = user_id);
-- Service role only for webhook events (no user access needed)
-- Webhook events are written by service role only
-- Function to sync is_premium flag to profiles
CREATE OR REPLACE FUNCTION public.sync_premium_status()
RETURNS TRIGGER AS $$
BEGIN
-- Update the profile's is_premium based on active subscription
UPDATE public.profiles
SET is_premium = EXISTS (
SELECT 1 FROM public.subscriptions
WHERE user_id = NEW.user_id
AND status = 'active'
AND (expires_at IS NULL OR expires_at > NOW())
)
WHERE id = NEW.user_id;
RETURN NEW;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- Trigger to auto-sync premium status on subscription changes
CREATE TRIGGER sync_premium_on_subscription_change
AFTER INSERT OR UPDATE ON public.subscriptions
FOR EACH ROW EXECUTE FUNCTION public.sync_premium_status();
@@ -1,91 +0,0 @@
-- Update RLS policies to allow partner premium access
-- When either user OR their partner has is_premium = TRUE, both can access premium packs
-- Drop existing premium pack policy
DROP POLICY IF EXISTS "Premium users can view premium packs" ON public.question_packs;
-- Create new policy that checks couple premium status
-- Access granted if: pack is not premium OR user is premium OR partner is premium
CREATE POLICY "Users or partners with premium can view premium packs"
ON public.question_packs FOR SELECT
USING (
is_premium = FALSE OR
EXISTS (
SELECT 1 FROM public.profiles p
WHERE p.id = auth.uid()
AND (
p.is_premium = TRUE OR
EXISTS (
SELECT 1 FROM public.profiles partner
WHERE partner.couple_id = p.couple_id
AND partner.couple_id IS NOT NULL
AND partner.id != p.id
AND partner.is_premium = TRUE
)
)
)
);
-- Update questions policy similarly
DROP POLICY IF EXISTS "Anyone can view questions in visible packs" ON public.questions;
CREATE POLICY "Users can view questions in accessible packs"
ON public.questions FOR SELECT
USING (
pack_id IN (
SELECT qp.id FROM public.question_packs qp
WHERE qp.is_public = TRUE
AND (
qp.is_premium = FALSE OR
EXISTS (
SELECT 1 FROM public.profiles p
WHERE p.id = auth.uid()
AND (
p.is_premium = TRUE OR
EXISTS (
SELECT 1 FROM public.profiles partner
WHERE partner.couple_id = p.couple_id
AND partner.couple_id IS NOT NULL
AND partner.id != p.id
AND partner.is_premium = TRUE
)
)
)
)
)
);
-- Create a helper function to check premium access for a user
-- This can be used in application code and other policies
CREATE OR REPLACE FUNCTION public.has_premium_access(check_user_id UUID)
RETURNS BOOLEAN AS $$
DECLARE
user_premium BOOLEAN;
partner_premium BOOLEAN;
user_couple_id UUID;
BEGIN
-- Get user's premium status and couple_id
SELECT is_premium, couple_id INTO user_premium, user_couple_id
FROM public.profiles
WHERE id = check_user_id;
-- If user is premium, return true
IF user_premium = TRUE THEN
RETURN TRUE;
END IF;
-- If user has no couple, return false
IF user_couple_id IS NULL THEN
RETURN FALSE;
END IF;
-- Check if partner is premium
SELECT is_premium INTO partner_premium
FROM public.profiles
WHERE couple_id = user_couple_id
AND id != check_user_id
LIMIT 1;
RETURN COALESCE(partner_premium, FALSE);
END;
$$ LANGUAGE plpgsql SECURITY DEFINER STABLE;
@@ -1,94 +0,0 @@
-- Fix: Filter out premium packs from get_recommended_questions when user lacks premium access
-- This ensures users whose subscriptions have expired don't continue seeing premium questions
DROP FUNCTION IF EXISTS public.get_recommended_questions(uuid);
CREATE OR REPLACE FUNCTION public.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 INT,
partner_answered BOOLEAN
) LANGUAGE plpgsql SECURITY DEFINER AS $$
DECLARE
v_user_id UUID;
v_couple_id UUID;
v_has_premium BOOLEAN;
BEGIN
v_user_id := auth.uid();
-- Get couple_id
SELECT p.couple_id INTO v_couple_id
FROM public.profiles p
WHERE p.id = v_user_id;
IF v_couple_id IS NULL THEN
RETURN;
END IF;
-- Check if user or partner has premium access
v_has_premium := public.has_premium_access(v_user_id);
RETURN QUERY
WITH user_config_exists AS (
SELECT 1 FROM public.couple_packs WHERE couple_id = v_couple_id LIMIT 1
),
active_packs AS (
-- When targeting a specific pack, only include if accessible (not premium OR has premium access)
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
-- When browsing all enabled packs, filter out premium packs if no premium access
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
-- Default packs when no config exists (filter premium here too)
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
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
);
END;
$$;
@@ -1,22 +0,0 @@
-- Enforce maximum 2 members per couple at the database level
-- This prevents race conditions and direct API manipulation from allowing
-- more than 2 people to join a single couple relationship
CREATE OR REPLACE FUNCTION check_couple_size()
RETURNS TRIGGER AS $$
BEGIN
-- Only check when couple_id is being set (not when clearing it)
IF NEW.couple_id IS NOT NULL THEN
-- Count existing members excluding the current user
IF (SELECT COUNT(*) FROM profiles WHERE couple_id = NEW.couple_id AND id != NEW.id) >= 2 THEN
RAISE EXCEPTION 'A couple can only have 2 members';
END IF;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- Trigger fires before insert or update of couple_id column
CREATE TRIGGER enforce_couple_size
BEFORE INSERT OR UPDATE OF couple_id ON profiles
FOR EACH ROW EXECUTE FUNCTION check_couple_size();
@@ -1,69 +0,0 @@
-- Admin Role-Based Access Control
-- Creates admin_users table and helper functions for role management
-- Create admin role enum
CREATE TYPE public.admin_role AS ENUM ('pack_creator', 'super_admin');
-- Admin users table (tracks which auth.users are admins and their role)
CREATE TABLE public.admin_users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL UNIQUE REFERENCES auth.users(id) ON DELETE CASCADE,
role public.admin_role NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
created_by UUID REFERENCES auth.users(id),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
-- Index for quick lookups
CREATE INDEX idx_admin_users_user_id ON public.admin_users(user_id);
CREATE INDEX idx_admin_users_role ON public.admin_users(role);
-- Updated_at trigger
CREATE TRIGGER update_admin_users_updated_at
BEFORE UPDATE ON public.admin_users
FOR EACH ROW EXECUTE FUNCTION public.update_updated_at();
-- Enable RLS
ALTER TABLE public.admin_users ENABLE ROW LEVEL SECURITY;
-- Policy: Allow admins to read their own record
CREATE POLICY "Admin users can read own record"
ON public.admin_users FOR SELECT
USING (auth.uid() = user_id);
-- Helper function to check if user is admin
CREATE OR REPLACE FUNCTION public.is_admin(check_user_id UUID DEFAULT auth.uid())
RETURNS BOOLEAN AS $$
BEGIN
RETURN EXISTS (
SELECT 1 FROM public.admin_users WHERE user_id = check_user_id
);
END;
$$ LANGUAGE plpgsql SECURITY DEFINER STABLE;
-- Helper function to get admin role
CREATE OR REPLACE FUNCTION public.get_admin_role(check_user_id UUID DEFAULT auth.uid())
RETURNS public.admin_role AS $$
DECLARE
user_role public.admin_role;
BEGIN
SELECT role INTO user_role FROM public.admin_users WHERE user_id = check_user_id;
RETURN user_role;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER STABLE;
-- Helper function to check if user is super admin
CREATE OR REPLACE FUNCTION public.is_super_admin(check_user_id UUID DEFAULT auth.uid())
RETURNS BOOLEAN AS $$
BEGIN
RETURN EXISTS (
SELECT 1 FROM public.admin_users
WHERE user_id = check_user_id AND role = 'super_admin'
);
END;
$$ LANGUAGE plpgsql SECURITY DEFINER STABLE;
-- Grant execute permissions on helper functions
GRANT EXECUTE ON FUNCTION public.is_admin TO authenticated;
GRANT EXECUTE ON FUNCTION public.get_admin_role TO authenticated;
GRANT EXECUTE ON FUNCTION public.is_super_admin TO authenticated;
@@ -1,81 +0,0 @@
-- Audit Logging System
-- Creates audit_logs table and RPC function for logging admin actions
-- Create action type enum for audit logging
CREATE TYPE public.audit_action AS ENUM ('INSERT', 'UPDATE', 'DELETE');
-- Audit log table
CREATE TABLE public.audit_logs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
table_name TEXT NOT NULL,
record_id UUID NOT NULL,
action public.audit_action NOT NULL,
old_values JSONB,
new_values JSONB,
changed_fields TEXT[],
admin_user_id UUID NOT NULL REFERENCES auth.users(id),
admin_role public.admin_role NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Indexes for querying audit logs
CREATE INDEX idx_audit_logs_table_name ON public.audit_logs(table_name);
CREATE INDEX idx_audit_logs_record_id ON public.audit_logs(record_id);
CREATE INDEX idx_audit_logs_admin_user_id ON public.audit_logs(admin_user_id);
CREATE INDEX idx_audit_logs_created_at ON public.audit_logs(created_at DESC);
CREATE INDEX idx_audit_logs_action ON public.audit_logs(action);
-- Enable RLS (super_admins only can view logs)
ALTER TABLE public.audit_logs ENABLE ROW LEVEL SECURITY;
-- Policy: Only super admins can view audit logs
CREATE POLICY "Super admins can view audit logs"
ON public.audit_logs FOR SELECT
USING (public.is_super_admin());
-- Function to log admin actions (called from frontend via RPC)
CREATE OR REPLACE FUNCTION public.log_admin_action(
p_table_name TEXT,
p_record_id UUID,
p_action public.audit_action,
p_old_values JSONB DEFAULT NULL,
p_new_values JSONB DEFAULT NULL
)
RETURNS UUID AS $$
DECLARE
log_id UUID;
admin_role_val public.admin_role;
changed_fields_arr TEXT[];
BEGIN
-- Get admin role
SELECT role INTO admin_role_val FROM public.admin_users WHERE user_id = auth.uid();
IF admin_role_val IS NULL THEN
RAISE EXCEPTION 'User is not an admin';
END IF;
-- Calculate changed fields for updates
IF p_action = 'UPDATE' AND p_old_values IS NOT NULL AND p_new_values IS NOT NULL THEN
SELECT ARRAY(
SELECT key FROM jsonb_each(p_new_values)
WHERE p_old_values->key IS DISTINCT FROM p_new_values->key
) INTO changed_fields_arr;
END IF;
-- Insert audit log
INSERT INTO public.audit_logs (
table_name, record_id, action, old_values, new_values,
changed_fields, admin_user_id, admin_role
)
VALUES (
p_table_name, p_record_id, p_action, p_old_values, p_new_values,
changed_fields_arr, auth.uid(), admin_role_val
)
RETURNING id INTO log_id;
RETURN log_id;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- Grant execute permission on logging function
GRANT EXECUTE ON FUNCTION public.log_admin_action TO authenticated;
@@ -1,79 +0,0 @@
-- Admin RLS Policies
-- Adds policies for admin access to tables
-- Question Packs: All admins can manage (CRUD)
CREATE POLICY "Admins can view all question packs"
ON public.question_packs FOR SELECT
USING (public.is_admin());
CREATE POLICY "Admins can insert question packs"
ON public.question_packs FOR INSERT
WITH CHECK (public.is_admin());
CREATE POLICY "Admins can update question packs"
ON public.question_packs FOR UPDATE
USING (public.is_admin());
CREATE POLICY "Admins can delete question packs"
ON public.question_packs FOR DELETE
USING (public.is_admin());
-- Questions: All admins can manage (CRUD)
CREATE POLICY "Admins can view all questions"
ON public.questions FOR SELECT
USING (public.is_admin());
CREATE POLICY "Admins can insert questions"
ON public.questions FOR INSERT
WITH CHECK (public.is_admin());
CREATE POLICY "Admins can update questions"
ON public.questions FOR UPDATE
USING (public.is_admin());
CREATE POLICY "Admins can delete questions"
ON public.questions FOR DELETE
USING (public.is_admin());
-- Profiles: Super admins can view and update all
CREATE POLICY "Super admins can view all profiles"
ON public.profiles FOR SELECT
USING (public.is_super_admin());
CREATE POLICY "Super admins can update all profiles"
ON public.profiles FOR UPDATE
USING (public.is_super_admin());
-- Couples: Super admins can view all
CREATE POLICY "Super admins can view all couples"
ON public.couples FOR SELECT
USING (public.is_super_admin());
-- Responses: Super admins can view all
CREATE POLICY "Super admins can view all responses"
ON public.responses FOR SELECT
USING (public.is_super_admin());
-- Matches: Super admins can view all
CREATE POLICY "Super admins can view all matches"
ON public.matches FOR SELECT
USING (public.is_super_admin());
-- Messages: Super admins can view all
CREATE POLICY "Super admins can view all messages"
ON public.messages FOR SELECT
USING (public.is_super_admin());
-- Subscriptions: Super admins can view all
CREATE POLICY "Super admins can view all subscriptions"
ON public.subscriptions FOR SELECT
USING (public.is_super_admin());
-- Feedback: Super admins can view and manage all
CREATE POLICY "Super admins can view all feedback"
ON public.feedback FOR SELECT
USING (public.is_super_admin());
CREATE POLICY "Super admins can update all feedback"
ON public.feedback FOR UPDATE
USING (public.is_super_admin());
@@ -1,175 +0,0 @@
-- Security Hardening Migration
-- Fixes: Weak invite codes, overly permissive RLS, password settings
-- ============================================
-- 1. Strengthen invite code generation
-- Use crypto-secure random with alphanumeric chars (8 chars retained for UX)
-- ============================================
-- Create a function to generate secure invite codes
CREATE OR REPLACE FUNCTION generate_secure_invite_code()
RETURNS TEXT AS $$
DECLARE
chars TEXT := 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; -- Removed confusing chars: I, O, 0, 1
result TEXT := '';
i INTEGER;
rand_bytes BYTEA;
BEGIN
-- Get 8 cryptographically secure random bytes
rand_bytes := gen_random_bytes(8);
FOR i IN 1..8 LOOP
result := result || substr(chars, (get_byte(rand_bytes, i-1) % 32) + 1, 1);
END LOOP;
RETURN result;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- Update the default for new couples to use secure generation
ALTER TABLE public.couples
ALTER COLUMN invite_code SET DEFAULT generate_secure_invite_code();
-- Add rate limiting tracking table for invite code attempts
CREATE TABLE IF NOT EXISTS public.invite_code_attempts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
ip_hash TEXT NOT NULL, -- Hashed IP for privacy
user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE,
attempted_at TIMESTAMPTZ DEFAULT NOW(),
was_successful BOOLEAN DEFAULT FALSE
);
-- Index for efficient rate limiting queries
CREATE INDEX IF NOT EXISTS idx_invite_attempts_user_time
ON public.invite_code_attempts(user_id, attempted_at);
-- Enable RLS on attempts table
ALTER TABLE public.invite_code_attempts ENABLE ROW LEVEL SECURITY;
-- Only allow inserts from authenticated users
CREATE POLICY "Authenticated users can log attempts"
ON public.invite_code_attempts
FOR INSERT
WITH CHECK (auth.role() = 'authenticated');
-- ============================================
-- 2. Fix overly permissive couples RLS policy
-- ============================================
-- Drop the overly permissive policy
DROP POLICY IF EXISTS "Users can view couple by invite code" ON public.couples;
-- Replace with a more restrictive policy
-- Users can only view their own couple OR look up a couple via RPC (not direct table access)
CREATE POLICY "Users can only view their own couple"
ON public.couples FOR SELECT
USING (
id IN (SELECT couple_id FROM public.profiles WHERE id = auth.uid())
);
-- Create a secure RPC function for invite code lookup
-- This only returns success/failure, not couple data
CREATE OR REPLACE FUNCTION public.verify_invite_code(code TEXT)
RETURNS JSONB AS $$
DECLARE
couple_record RECORD;
member_count INTEGER;
BEGIN
-- Normalize the code
code := UPPER(TRIM(code));
-- Check if code exists
SELECT id INTO couple_record
FROM public.couples
WHERE UPPER(invite_code) = code;
IF couple_record.id IS NULL THEN
RETURN jsonb_build_object('valid', false, 'error', 'Invalid invite code');
END IF;
-- Check member count
SELECT COUNT(*) INTO member_count
FROM public.profiles
WHERE couple_id = couple_record.id;
IF member_count >= 2 THEN
RETURN jsonb_build_object('valid', false, 'error', 'This couple already has two partners');
END IF;
RETURN jsonb_build_object('valid', true, 'couple_id', couple_record.id);
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
-- Grant execute permission to authenticated users
GRANT EXECUTE ON FUNCTION public.verify_invite_code(TEXT) TO authenticated;
-- ============================================
-- 3. Optional: Add password policy comment
-- (Password length is enforced client-side, but should also be in Supabase Auth config)
-- ============================================
COMMENT ON SCHEMA public IS 'Password policy: Minimum 8 characters required. Configure in Supabase Dashboard > Auth > Policies';
-- ============================================
-- 4. Protect push tokens from being accessed by other users
-- Push tokens should only be accessible via service role for notifications
-- ============================================
-- Create a secure view for partner profile access that excludes sensitive fields
CREATE OR REPLACE VIEW public.partner_profiles AS
SELECT
id,
name,
avatar_url,
is_premium,
couple_id,
created_at,
updated_at,
show_explicit_content
-- Explicitly excluding: push_token
FROM public.profiles;
-- Allow authenticated users to query the view
GRANT SELECT ON public.partner_profiles TO authenticated;
-- Add comment explaining the security rationale
COMMENT ON VIEW public.partner_profiles IS
'Secure view of profiles excluding push_token. Use this view when displaying partner information. Push tokens are only accessible via service role for sending notifications.';
-- Create a function to safely get partner info without exposing push_token
CREATE OR REPLACE FUNCTION public.get_partner_profile()
RETURNS JSONB AS $$
DECLARE
current_couple_id UUID;
partner_data JSONB;
BEGIN
-- Get current user's couple_id
SELECT couple_id INTO current_couple_id
FROM public.profiles
WHERE id = auth.uid();
IF current_couple_id IS NULL THEN
RETURN NULL;
END IF;
-- Get partner profile without sensitive fields
SELECT jsonb_build_object(
'id', p.id,
'name', p.name,
'avatar_url', p.avatar_url,
'is_premium', p.is_premium,
'couple_id', p.couple_id,
'created_at', p.created_at
) INTO partner_data
FROM public.profiles p
WHERE p.couple_id = current_couple_id
AND p.id != auth.uid();
RETURN partner_data;
END;
$$ LANGUAGE plpgsql SECURITY DEFINER;
GRANT EXECUTE ON FUNCTION public.get_partner_profile() TO authenticated;
@@ -1,17 +0,0 @@
-- Allow super admins to manage subscriptions manually
-- This enables the "Grant Premium" feature in the admin dashboard
-- Allow super admins to insert new manual subscriptions
CREATE POLICY "Super admins can insert subscriptions"
ON public.subscriptions FOR INSERT
WITH CHECK (public.is_super_admin());
-- Allow super admins to update subscriptions (e.g. extending expiry)
CREATE POLICY "Super admins can update subscriptions"
ON public.subscriptions FOR UPDATE
USING (public.is_super_admin());
-- Allow super admins to delete subscriptions (e.g. revoking access)
CREATE POLICY "Super admins can delete subscriptions"
ON public.subscriptions FOR DELETE
USING (public.is_super_admin());
@@ -1,115 +0,0 @@
-- Ensure gender column exists on profiles (if not already present)
DO $$
BEGIN
IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'profiles' AND column_name = 'gender') THEN
ALTER TABLE public.profiles ADD COLUMN gender TEXT CHECK (gender IN ('male', 'female', 'non-binary', 'prefer-not-to-say'));
END IF;
END $$;
-- Add allowed_couple_genders to questions
ALTER TABLE public.questions
ADD COLUMN IF NOT EXISTS allowed_couple_genders TEXT[] DEFAULT NULL;
COMMENT ON COLUMN public.questions.allowed_couple_genders IS 'Array of allowed gender pairings (e.g., ["female+male", "female+female"]). Null means all allowed.';
-- Update get_recommended_questions to respect gender context
DROP FUNCTION IF EXISTS public.get_recommended_questions(uuid);
CREATE OR REPLACE FUNCTION public.get_recommended_questions(target_pack_id UUID DEFAULT NULL)
RETURNS TABLE (
id UUID,
text TEXT,
pack_id UUID,
intensity INT,
partner_answered BOOLEAN,
allowed_couple_genders TEXT[]
) LANGUAGE plpgsql SECURITY DEFINER AS $$
DECLARE
v_user_id UUID;
v_couple_id UUID;
v_user_gender TEXT;
v_partner_gender TEXT;
v_couple_type TEXT;
BEGIN
v_user_id := auth.uid();
-- Get user info including gender
SELECT p.couple_id, p.gender INTO v_couple_id, v_user_gender
FROM public.profiles p
WHERE p.id = v_user_id;
IF v_couple_id IS NULL THEN
RETURN;
END IF;
-- Get partner gender
SELECT p.gender INTO v_partner_gender
FROM public.profiles p
WHERE p.couple_id = v_couple_id AND p.id != v_user_id
LIMIT 1;
-- Determine couple type
IF v_user_gender IS NOT NULL AND v_partner_gender IS NOT NULL THEN
-- Sort genders to create a consistent type string
-- We assume binary genders for the primary logic as requested: male/male, male/female, female/female
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 (
-- Case 0: Specific pack requested
SELECT target_pack_id AS pack_id
WHERE target_pack_id IS NOT NULL
UNION ALL
-- Case 1: No specific pack, config exists -> use enabled packs
SELECT cp.pack_id
FROM public.couple_packs cp
WHERE target_pack_id IS NULL
AND cp.couple_id = v_couple_id AND cp.enabled = TRUE
AND EXISTS (SELECT 1 FROM user_config_exists)
UNION ALL
-- Case 2: No specific pack, no config -> use all public packs
SELECT p.id AS pack_id
FROM public.question_packs p
WHERE target_pack_id IS NULL
AND p.is_public = TRUE
AND NOT EXISTS (SELECT 1 FROM user_config_exists)
)
SELECT
q.id,
q.text,
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
FROM public.questions q
WHERE q.pack_id IN (SELECT ap.pack_id FROM active_packs ap)
AND (
q.allowed_couple_genders IS NULL
OR v_couple_type IS NULL
OR v_couple_type = ANY(q.allowed_couple_genders)
)
AND NOT EXISTS (
SELECT 1 FROM public.responses r
WHERE r.question_id = q.id
AND r.user_id = v_user_id
);
END;
$$;
+33
View File
@@ -47,6 +47,8 @@ erDiagram
text text text text
text partner_text "for two-part questions" text partner_text "for two-part questions"
int intensity "1-5" int intensity "1-5"
text[] allowed_couple_genders "couple composition filter"
text[] target_user_genders "individual user filter"
timestamptz created_at timestamptz created_at
} }
@@ -180,3 +182,34 @@ erDiagram
1. Couples can enable/disable packs via `couple_packs` junction table 1. Couples can enable/disable packs via `couple_packs` junction table
2. `get_recommended_questions()` function filters questions by enabled packs 2. `get_recommended_questions()` function filters questions by enabled packs
3. Two-part questions show `partner_text` to second responder 3. Two-part questions show `partner_text` to second responder
### Question Targeting System
Questions can be targeted at specific audiences using two complementary filters:
| Field | Purpose | Level | Example |
|-------|---------|-------|---------|
| `allowed_couple_genders` | Couple composition filter | Couple | `['male+female', 'female+female']` |
| `target_user_genders` | Individual user filter | User | `['female', 'non-binary']` |
**How targeting works:**
1. **Couple filter** (`allowed_couple_genders`): Questions only shown to couples matching the composition (e.g., same-sex specific questions)
2. **User filter** (`target_user_genders`): Controls who sees the question **as initiator** (first responder)
- If user's gender matches → they can see and answer first
- If user's gender doesn't match → they only see it after partner answers (via `partner_text`)
**Example flow:**
```
Question:
text: "Have your partner suck your tits"
partner_text: "Suck your partner's tits"
target_user_genders: ['female']
1. Female sees "Have your partner suck your tits" → swipes YES
2. Male sees "Suck your partner's tits" (partner_text, unlocked by her answer)
3. Both answered → Match created!
```
This ensures gender-appropriate phrasing while still allowing both partners to respond.
+12 -15
View File
@@ -92,7 +92,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"loose-envify": "^1.1.0" "loose-envify": "^1.1.0"
}, },
@@ -105,7 +104,6 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"loose-envify": "^1.1.0", "loose-envify": "^1.1.0",
"scheduler": "^0.23.2" "scheduler": "^0.23.2"
@@ -217,7 +215,6 @@
"integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@babel/code-frame": "^7.27.1", "@babel/code-frame": "^7.27.1",
"@babel/generator": "^7.28.5", "@babel/generator": "^7.28.5",
@@ -957,6 +954,7 @@
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"peer": true,
"dependencies": { "dependencies": {
"@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/gen-mapping": "^0.3.5",
"@jridgewell/trace-mapping": "^0.3.25" "@jridgewell/trace-mapping": "^0.3.25"
@@ -3268,7 +3266,6 @@
"integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"peer": true,
"peerDependencies": { "peerDependencies": {
"@types/react": "^18.0.0" "@types/react": "^18.0.0"
} }
@@ -3338,6 +3335,7 @@
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"peer": true,
"bin": { "bin": {
"acorn": "bin/acorn" "acorn": "bin/acorn"
}, },
@@ -3496,7 +3494,6 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"baseline-browser-mapping": "^2.9.0", "baseline-browser-mapping": "^2.9.0",
"caniuse-lite": "^1.0.30001759", "caniuse-lite": "^1.0.30001759",
@@ -3517,7 +3514,8 @@
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==", "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"optional": true "optional": true,
"peer": true
}, },
"node_modules/call-bind-apply-helpers": { "node_modules/call-bind-apply-helpers": {
"version": "1.0.2", "version": "1.0.2",
@@ -4336,7 +4334,6 @@
"integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"jiti": "bin/jiti.js" "jiti": "bin/jiti.js"
} }
@@ -4695,7 +4692,6 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"nanoid": "^3.3.7", "nanoid": "^3.3.7",
"picocolors": "^1.1.1", "picocolors": "^1.1.1",
@@ -4844,7 +4840,8 @@
"version": "0.27.0", "version": "0.27.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
"integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==",
"license": "MIT" "license": "MIT",
"peer": true
}, },
"node_modules/react-is": { "node_modules/react-is": {
"version": "19.2.3", "version": "19.2.3",
@@ -4858,7 +4855,6 @@
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@types/use-sync-external-store": "^0.0.6", "@types/use-sync-external-store": "^0.0.6",
"use-sync-external-store": "^1.4.0" "use-sync-external-store": "^1.4.0"
@@ -5018,8 +5014,7 @@
"version": "5.0.1", "version": "5.0.1",
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
"license": "MIT", "license": "MIT"
"peer": true
}, },
"node_modules/redux-thunk": { "node_modules/redux-thunk": {
"version": "3.1.0", "version": "3.1.0",
@@ -5170,6 +5165,7 @@
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"peer": true,
"dependencies": { "dependencies": {
"buffer-from": "^1.0.0", "buffer-from": "^1.0.0",
"source-map": "^0.6.0" "source-map": "^0.6.0"
@@ -5182,6 +5178,7 @@
"dev": true, "dev": true,
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
"optional": true, "optional": true,
"peer": true,
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
} }
@@ -5320,6 +5317,7 @@
"dev": true, "dev": true,
"license": "BSD-2-Clause", "license": "BSD-2-Clause",
"optional": true, "optional": true,
"peer": true,
"dependencies": { "dependencies": {
"@jridgewell/source-map": "^0.3.3", "@jridgewell/source-map": "^0.3.3",
"acorn": "^8.15.0", "acorn": "^8.15.0",
@@ -5339,7 +5337,8 @@
"integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"optional": true "optional": true,
"peer": true
}, },
"node_modules/thenify": { "node_modules/thenify": {
"version": "3.3.1", "version": "3.3.1",
@@ -5411,7 +5410,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=12" "node": ">=12"
}, },
@@ -5691,7 +5689,6 @@
"integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"esbuild": "^0.21.3", "esbuild": "^0.21.3",
"postcss": "^8.4.43", "postcss": "^8.4.43",
+3
View File
@@ -34,7 +34,10 @@ export interface Question {
id: string; id: string;
pack_id: string; pack_id: string;
text: string; text: string;
partner_text?: string | null;
intensity: 1 | 2 | 3 | 4 | 5; intensity: 1 | 2 | 3 | 4 | 5;
allowed_couple_genders?: string[] | null;
target_user_genders?: string[] | null;
created_at: string; created_at: string;
} }