From aff0bc08886efc08a281e38b76bbaab14e653abe Mon Sep 17 00:00:00 2001 From: Jamie Brunton Date: Thu, 8 Jan 2026 21:15:34 +0000 Subject: [PATCH] Add admin pagination and update configs --- apps/admin/src/components/ui/pagination.tsx | 97 +++ apps/admin/src/hooks/useAuditedSupabase.ts | 4 +- apps/admin/src/hooks/useEntityForm.ts | 4 +- apps/admin/src/pages/AuditLogsPage.tsx | 68 +- apps/admin/src/pages/FeedbackPage.tsx | 199 +++--- apps/admin/src/pages/FlaggedMessagesPage.tsx | 87 ++- apps/admin/src/pages/RedemptionCodesPage.tsx | 488 ++++++++------ .../src/pages/activity/UserActivityPage.tsx | 144 ++++- apps/admin/src/pages/admins/AdminsPage.tsx | 35 +- .../src/pages/content/CategoriesPage.tsx | 63 +- apps/admin/src/pages/content/PacksPage.tsx | 73 ++- .../admin/src/pages/content/QuestionsPage.tsx | 66 +- apps/admin/src/pages/users/MatchChatPage.tsx | 168 +++-- apps/admin/src/pages/users/UserDetailPage.tsx | 109 +++- apps/admin/src/pages/users/UsersPage.tsx | 40 +- apps/mobile/android/app/build.gradle | 2 +- apps/mobile/app.json | 2 +- apps/web/.env.local.example | 5 +- apps/web/Dockerfile | 14 +- apps/web/docker-compose.prod.yml | 7 +- docs/dares-feature.md | 599 ++++++++++++++++++ 21 files changed, 1793 insertions(+), 481 deletions(-) create mode 100644 apps/admin/src/components/ui/pagination.tsx create mode 100644 docs/dares-feature.md diff --git a/apps/admin/src/components/ui/pagination.tsx b/apps/admin/src/components/ui/pagination.tsx new file mode 100644 index 0000000..c972b32 --- /dev/null +++ b/apps/admin/src/components/ui/pagination.tsx @@ -0,0 +1,97 @@ +import { ChevronLeft, ChevronRight } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { cn } from '@/lib/utils'; + +type PaginationControlsProps = { + page: number; + pageSize: number; + totalCount: number; + onPageChange: (page: number) => void; + onPageSizeChange?: (pageSize: number) => void; + pageSizeOptions?: number[]; + showPageSize?: boolean; + className?: string; +}; + +export function PaginationControls({ + page, + pageSize, + totalCount, + onPageChange, + onPageSizeChange, + pageSizeOptions = [10, 25, 50], + showPageSize = true, + className, +}: PaginationControlsProps) { + const totalPages = Math.max(1, Math.ceil(totalCount / pageSize)); + const safePage = Math.min(Math.max(page, 1), totalPages); + const start = totalCount === 0 ? 0 : (safePage - 1) * pageSize + 1; + const end = totalCount === 0 ? 0 : Math.min(safePage * pageSize, totalCount); + + return ( +
+
+ {totalCount === 0 + ? 'No results' + : `Showing ${start} to ${end} of ${totalCount}`} +
+
+ {showPageSize && onPageSizeChange && ( +
+ Rows + +
+ )} +
+ + + Page {safePage} of {totalPages} + + +
+
+
+ ); +} diff --git a/apps/admin/src/hooks/useAuditedSupabase.ts b/apps/admin/src/hooks/useAuditedSupabase.ts index fa14327..c24329d 100644 --- a/apps/admin/src/hooks/useAuditedSupabase.ts +++ b/apps/admin/src/hooks/useAuditedSupabase.ts @@ -86,10 +86,10 @@ export const auditedSupabase = { /** * Update a single record with audit logging */ - async update( + async update>( table: string, id: string, - updates: Partial + updates: Partial | Record ): Promise> { // Fetch old values before update let oldValues: Record | null = null; diff --git a/apps/admin/src/hooks/useEntityForm.ts b/apps/admin/src/hooks/useEntityForm.ts index 3c4c1fc..421d6e3 100644 --- a/apps/admin/src/hooks/useEntityForm.ts +++ b/apps/admin/src/hooks/useEntityForm.ts @@ -43,8 +43,8 @@ import { useState, useCallback } from 'react'; * ``` */ export function useEntityForm< - TFormData extends Record, - TEntity extends Record = TFormData + TFormData extends object, + TEntity extends object = TFormData >( initialFormData: TFormData, /** diff --git a/apps/admin/src/pages/AuditLogsPage.tsx b/apps/admin/src/pages/AuditLogsPage.tsx index 0a5b5b2..8866cec 100644 --- a/apps/admin/src/pages/AuditLogsPage.tsx +++ b/apps/admin/src/pages/AuditLogsPage.tsx @@ -1,7 +1,8 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useCallback } from 'react'; import { supabase } from '@/config'; import { Badge } from '@/components/ui/badge'; import { Skeleton } from '@/components/ui/skeleton'; +import { PaginationControls } from '@/components/ui/pagination'; import { Table, TableBody, @@ -34,28 +35,42 @@ const actionColors: Record = { export function AuditLogsPage() { const [logs, setLogs] = useState([]); const [loading, setLoading] = useState(true); + const [page, setPage] = useState(1); + const [pageSize, setPageSize] = useState(50); + const [totalCount, setTotalCount] = useState(0); + + const fetchLogs = useCallback(async () => { + setLoading(true); + try { + const from = (page - 1) * pageSize; + const to = from + pageSize - 1; + + const { data, error, count } = await supabase + .from('audit_logs') + .select('*', { count: 'exact' }) + .order('created_at', { ascending: false }) + .range(from, to); + + if (error) throw error; + setLogs(data || []); + setTotalCount(count || 0); + } catch (error) { + console.error('Failed to load audit logs:', error); + } finally { + setLoading(false); + } + }, [page, pageSize]); useEffect(() => { - const fetchLogs = async () => { - setLoading(true); - try { - const { data, error } = await supabase - .from('audit_logs') - .select('*') - .order('created_at', { ascending: false }) - .limit(100); - - if (error) throw error; - setLogs(data || []); - } catch (error) { - console.error('Failed to load audit logs:', error); - } finally { - setLoading(false); - } - }; - fetchLogs(); - }, []); + }, [fetchLogs]); + + useEffect(() => { + const totalPages = Math.max(1, Math.ceil(totalCount / pageSize)); + if (page > totalPages) { + setPage(totalPages); + } + }, [page, pageSize, totalCount]); if (loading) { return ( @@ -141,6 +156,19 @@ export function AuditLogsPage() { + + {logs.length > 0 && ( + { + setPage(1); + setPageSize(size); + }} + /> + )} ); } diff --git a/apps/admin/src/pages/FeedbackPage.tsx b/apps/admin/src/pages/FeedbackPage.tsx index 0b57f92..c9f571f 100644 --- a/apps/admin/src/pages/FeedbackPage.tsx +++ b/apps/admin/src/pages/FeedbackPage.tsx @@ -3,7 +3,9 @@ import { supabase } from '@/config'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Badge } from '@/components/ui/badge'; -import { Skeleton } from '@/components/ui/skeleton'; +import { Skeleton } from '@/components/ui/skeleton'; +import { PaginationControls } from '@/components/ui/pagination'; + import { Textarea } from '@/components/ui/textarea'; import { useRealtimeSubscription } from '@/hooks/useRealtimeSubscription'; import { RealtimeStatusIndicator } from '@/components/RealtimeStatusIndicator'; @@ -84,9 +86,12 @@ const statusConfig: Record([]); const [loading, setLoading] = useState(true); - const [search, setSearch] = useState(''); - const [typeFilter, setTypeFilter] = useState('all'); - const [statusFilter, setStatusFilter] = useState('all'); + const [search, setSearch] = useState(''); + const [typeFilter, setTypeFilter] = useState('all'); + const [statusFilter, setStatusFilter] = useState('all'); + const [page, setPage] = useState(1); + const [pageSize, setPageSize] = useState(25); + // Detail Dialog State const [selectedFeedback, setSelectedFeedback] = useState(null); @@ -136,9 +141,14 @@ export function FeedbackPage() { }, }); - useEffect(() => { - fetchFeedback(); - }, [fetchFeedback]); + useEffect(() => { + fetchFeedback(); + }, [fetchFeedback]); + + useEffect(() => { + setPage(1); + }, [search, typeFilter, statusFilter]); + const handleOpenDetail = (item: Feedback) => { setSelectedFeedback(item); @@ -173,19 +183,33 @@ export function FeedbackPage() { } }; - const filteredFeedback = feedback.filter(item => { - if (typeFilter !== 'all' && item.type !== typeFilter) return false; - if (statusFilter !== 'all' && item.status !== statusFilter) return false; - if (!search) return true; - - const query = search.toLowerCase(); - return ( - item.title.toLowerCase().includes(query) || - item.description.toLowerCase().includes(query) || - item.profile?.name?.toLowerCase().includes(query) || - item.profile?.email?.toLowerCase().includes(query) - ); - }); + const filteredFeedback = feedback.filter(item => { + if (typeFilter !== 'all' && item.type !== typeFilter) return false; + if (statusFilter !== 'all' && item.status !== statusFilter) return false; + if (!search) return true; + + const query = search.toLowerCase(); + return ( + item.title.toLowerCase().includes(query) || + item.description.toLowerCase().includes(query) || + item.profile?.name?.toLowerCase().includes(query) || + item.profile?.email?.toLowerCase().includes(query) + ); + }); + + const totalCount = filteredFeedback.length; + const paginatedFeedback = filteredFeedback.slice( + (page - 1) * pageSize, + page * pageSize + ); + + useEffect(() => { + const totalPages = Math.max(1, Math.ceil(totalCount / pageSize)); + if (page > totalPages) { + setPage(totalPages); + } + }, [page, pageSize, totalCount]); + const getTypeBadge = (type: FeedbackType) => { const config = feedbackTypeConfig[type]; @@ -267,67 +291,80 @@ export function FeedbackPage() { {/* Table */} -
- - - - Type - Title - User - Status - Created - Actions - - - - {loading ? ( - - -
-
-
- ) : filteredFeedback.length === 0 ? ( - - - {search || typeFilter !== 'all' || statusFilter !== 'all' - ? 'No feedback matches your filters.' - : 'No feedback submissions yet.'} - - - ) : ( - filteredFeedback.map((item) => ( - handleOpenDetail(item)}> - {getTypeBadge(item.type)} - -
-
{item.title}
-
- {item.description.slice(0, 80)} - {item.description.length > 80 && '...'} -
-
-
- -
-
{item.profile?.name || 'Unknown'}
-
{item.profile?.email}
-
-
- {getStatusBadge(item.status)} - - {format(new Date(item.created_at), 'MMM d, yyyy')} - - - - -
- )) - )} -
-
-
+
+ + + + Type + Title + User + Status + Created + Actions + + + + {loading ? ( + + +
+
+
+ ) : filteredFeedback.length === 0 ? ( + + + {search || typeFilter !== 'all' || statusFilter !== 'all' + ? 'No feedback matches your filters.' + : 'No feedback submissions yet.'} + + + ) : ( + paginatedFeedback.map((item) => ( + handleOpenDetail(item)}> + {getTypeBadge(item.type)} + +
+
{item.title}
+
+ {item.description.slice(0, 80)} + {item.description.length > 80 && '...'} +
+
+
+ +
+
{item.profile?.name || 'Unknown'}
+
{item.profile?.email}
+
+
+ {getStatusBadge(item.status)} + + {format(new Date(item.created_at), 'MMM d, yyyy')} + + + + +
+ )) + )} +
+
+
+ + {filteredFeedback.length > 0 && ( + { + setPage(1); + setPageSize(size); + }} + /> + )} {/* Detail Dialog */} !open && setSelectedFeedback(null)}> diff --git a/apps/admin/src/pages/FlaggedMessagesPage.tsx b/apps/admin/src/pages/FlaggedMessagesPage.tsx index 6d39cb2..1c6042b 100644 --- a/apps/admin/src/pages/FlaggedMessagesPage.tsx +++ b/apps/admin/src/pages/FlaggedMessagesPage.tsx @@ -1,9 +1,10 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useCallback } from 'react'; import { supabase } from '@/config'; import { useNavigate } from 'react-router-dom'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { Card } from '@/components/ui/card'; +import { PaginationControls } from '@/components/ui/pagination'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Table, @@ -65,10 +66,16 @@ export function FlaggedMessagesPage() { // AI Flagged state const [aiMessages, setAiMessages] = useState([]); const [aiLoading, setAiLoading] = useState(true); + const [aiPage, setAiPage] = useState(1); + const [aiPageSize, setAiPageSize] = useState(25); + const [aiTotal, setAiTotal] = useState(0); // User Reports state const [userReports, setUserReports] = useState([]); const [reportsLoading, setReportsLoading] = useState(true); + const [reportsPage, setReportsPage] = useState(1); + const [reportsPageSize, setReportsPageSize] = useState(25); + const [reportsTotal, setReportsTotal] = useState(0); // Shared state const [selectedMessage, setSelectedMessage] = useState(null); @@ -82,30 +89,38 @@ export function FlaggedMessagesPage() { const [describingInline, setDescribingInline] = useState(null); // Fetch AI flagged messages - const fetchAiMessages = async () => { + const fetchAiMessages = useCallback(async () => { setAiLoading(true); try { - const { data, error } = await supabase + const from = (aiPage - 1) * aiPageSize; + const to = from + aiPageSize - 1; + + const { data, error, count } = await supabase .from('messages') - .select('id, created_at, user_id, match_id, moderation_status, flag_reason, content, media_path, media_type') + .select('id, created_at, user_id, match_id, moderation_status, flag_reason, content, media_path, media_type', { count: 'exact' }) .eq('moderation_status', 'flagged') - .order('created_at', { ascending: false }); + .order('created_at', { ascending: false }) + .range(from, to); if (error) throw error; setAiMessages(data || []); + setAiTotal(count || 0); } catch (error) { console.error('Failed to load flagged messages:', error); toast.error('Failed to load messages'); } finally { setAiLoading(false); } - }; + }, [aiPage, aiPageSize]); // Fetch user reports - const fetchUserReports = async () => { + const fetchUserReports = useCallback(async () => { setReportsLoading(true); try { - const { data, error } = await supabase + const from = (reportsPage - 1) * reportsPageSize; + const to = from + reportsPageSize - 1; + + const { data, error, count } = await supabase .from('message_reports') .select(` id, @@ -116,24 +131,40 @@ export function FlaggedMessagesPage() { created_at, message:messages(id, user_id, match_id, content, media_path, media_type), reporter_profile:profiles!reporter_id(name, email) - `) + `, { count: 'exact' }) .eq('status', 'pending') - .order('created_at', { ascending: false }); + .order('created_at', { ascending: false }) + .range(from, to); if (error) throw error; setUserReports((data || []) as unknown as UserReport[]); + setReportsTotal(count || 0); } catch (error) { console.error('Failed to load user reports:', error); toast.error('Failed to load reports'); } finally { setReportsLoading(false); } - }; + }, [reportsPage, reportsPageSize]); useEffect(() => { fetchAiMessages(); fetchUserReports(); - }, []); + }, [fetchAiMessages, fetchUserReports]); + + useEffect(() => { + const totalPages = Math.max(1, Math.ceil(aiTotal / aiPageSize)); + if (aiPage > totalPages) { + setAiPage(totalPages); + } + }, [aiPage, aiPageSize, aiTotal]); + + useEffect(() => { + const totalPages = Math.max(1, Math.ceil(reportsTotal / reportsPageSize)); + if (reportsPage > totalPages) { + setReportsPage(totalPages); + } + }, [reportsPage, reportsPageSize, reportsTotal]); // Custom media fetcher because supabase.functions.invoke is JSON-centric const fetchDecryptedMedia = async (messageId: string) => { @@ -296,8 +327,8 @@ export function FlaggedMessagesPage() { if (error) throw error; toast.success('Message marked as safe'); - setAiMessages(prev => prev.filter(m => m.id !== messageId)); if (selectedMessage?.id === messageId) setSelectedMessage(null); + fetchAiMessages(); } catch (error) { toast.error('Failed to update status'); } finally { @@ -316,8 +347,8 @@ export function FlaggedMessagesPage() { if (error) throw error; toast.success('Report dismissed'); - setUserReports(prev => prev.filter(r => r.id !== reportId)); if (selectedReport?.id === reportId) setSelectedReport(null); + fetchUserReports(); } catch (error) { toast.error('Failed to dismiss report'); } finally { @@ -336,8 +367,8 @@ export function FlaggedMessagesPage() { if (error) throw error; toast.success('Report marked as reviewed'); - setUserReports(prev => prev.filter(r => r.id !== reportId)); if (selectedReport?.id === reportId) setSelectedReport(null); + fetchUserReports(); } catch (error) { toast.error('Failed to update report'); } finally { @@ -491,6 +522,19 @@ export function FlaggedMessagesPage() { + + {aiTotal > 0 && ( + { + setAiPage(1); + setAiPageSize(size); + }} + /> + )} @@ -579,6 +623,19 @@ export function FlaggedMessagesPage() { + + {reportsTotal > 0 && ( + { + setReportsPage(1); + setReportsPageSize(size); + }} + /> + )} diff --git a/apps/admin/src/pages/RedemptionCodesPage.tsx b/apps/admin/src/pages/RedemptionCodesPage.tsx index 2e1b3c6..be5c8a4 100644 --- a/apps/admin/src/pages/RedemptionCodesPage.tsx +++ b/apps/admin/src/pages/RedemptionCodesPage.tsx @@ -1,10 +1,13 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useCallback } from 'react'; + import { supabase } from '@/config'; import { auditedSupabase } from '@/hooks/useAuditedSupabase'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Badge } from '@/components/ui/badge'; -import { Skeleton } from '@/components/ui/skeleton'; +import { Skeleton } from '@/components/ui/skeleton'; +import { PaginationControls } from '@/components/ui/pagination'; + import { Switch } from '@/components/ui/switch'; import { Table, @@ -61,9 +64,13 @@ function generateCode(length: number = 8): string { } export function RedemptionCodesPage() { - const [codes, setCodes] = useState([]); - const [loading, setLoading] = useState(true); - const [search, setSearch] = useState(''); + const [codes, setCodes] = useState([]); + const [loading, setLoading] = useState(true); + const [search, setSearch] = useState(''); + const [page, setPage] = useState(1); + const [pageSize, setPageSize] = useState(25); + const [totalCount, setTotalCount] = useState(0); + // Add Code State const [isAddDialogOpen, setIsAddDialogOpen] = useState(false); @@ -76,66 +83,114 @@ export function RedemptionCodesPage() { }); // View Redemptions State - const [viewingCode, setViewingCode] = useState(null); - const [redemptions, setRedemptions] = useState([]); - const [loadingRedemptions, setLoadingRedemptions] = useState(false); + const [viewingCode, setViewingCode] = useState(null); + const [redemptions, setRedemptions] = useState([]); + const [loadingRedemptions, setLoadingRedemptions] = useState(false); + const [redemptionsPage, setRedemptionsPage] = useState(1); + const [redemptionsPageSize, setRedemptionsPageSize] = useState(10); + const [redemptionsTotal, setRedemptionsTotal] = useState(0); + - // Initial load - useEffect(() => { - fetchCodes(); - }, []); + - const fetchCodes = async () => { - setLoading(true); - try { - const { data, error } = await supabase - .from('redemption_codes') - .select('*') - .order('created_at', { ascending: false }); + const fetchCodes = useCallback(async () => { + setLoading(true); + try { + const from = (page - 1) * pageSize; + const to = from + pageSize - 1; + const trimmedSearch = search.trim(); + + let query = supabase + .from('redemption_codes') + .select('*', { count: 'exact' }) + .order('created_at', { ascending: false }); + + if (trimmedSearch) { + query = query.or(`code.ilike.%${trimmedSearch}%,description.ilike.%${trimmedSearch}%`); + } + + const { data, error, count } = await query.range(from, to); + + if (error) throw error; + setCodes(data || []); + setTotalCount(count || 0); + } catch (error: any) { + console.error('Failed to load codes:', error); + toast.error("Failed to load redemption codes"); + } finally { + setLoading(false); + } + }, [page, pageSize, search]); + - if (error) throw error; - setCodes(data || []); - } catch (error: any) { - console.error('Failed to load codes:', error); - toast.error("Failed to load redemption codes"); - } finally { - setLoading(false); - } - }; - - const fetchRedemptions = async (codeId: string) => { - setLoadingRedemptions(true); - try { - const { data: redemptionData, error: redemptionError } = await supabase - .from('code_redemptions') - .select('id, user_id, redeemed_at') - .eq('code_id', codeId) - .order('redeemed_at', { ascending: false }); - - if (redemptionError) throw redemptionError; - - if (redemptionData && redemptionData.length > 0) { - const userIds = redemptionData.map(r => r.user_id); - const { data: profileData } = await supabase - .from('profiles') - .select('id, name, email') - .in('id', userIds); - - const joined = redemptionData.map(r => ({ - ...r, - profile: profileData?.find(p => p.id === r.user_id) - })); - setRedemptions(joined); - } else { - setRedemptions([]); - } - } catch (error: any) { - console.error('Failed to load redemptions:', error); - toast.error("Failed to load redemptions"); - } finally { - setLoadingRedemptions(false); - } - }; + const fetchRedemptions = useCallback(async (codeId: string) => { + setLoadingRedemptions(true); + try { + const from = (redemptionsPage - 1) * redemptionsPageSize; + const to = from + redemptionsPageSize - 1; + + const { data: redemptionData, error: redemptionError, count } = await supabase + .from('code_redemptions') + .select('id, user_id, redeemed_at', { count: 'exact' }) + .eq('code_id', codeId) + .order('redeemed_at', { ascending: false }) + .range(from, to); + + if (redemptionError) throw redemptionError; + + setRedemptionsTotal(count || 0); + + if (redemptionData && redemptionData.length > 0) { + const userIds = redemptionData.map(r => r.user_id); + const { data: profileData } = await supabase + .from('profiles') + .select('id, name, email') + .in('id', userIds); + + const joined = redemptionData.map(r => ({ + ...r, + profile: profileData?.find(p => p.id === r.user_id) + })); + setRedemptions(joined); + } else { + setRedemptions([]); + } + } catch (error: any) { + console.error('Failed to load redemptions:', error); + toast.error("Failed to load redemptions"); + } finally { + setLoadingRedemptions(false); + } + }, [redemptionsPage, redemptionsPageSize]); + + useEffect(() => { + fetchCodes(); + }, [fetchCodes]); + + useEffect(() => { + setPage(1); + }, [search]); + + useEffect(() => { + const totalPages = Math.max(1, Math.ceil(totalCount / pageSize)); + if (page > totalPages) { + setPage(totalPages); + } + }, [page, pageSize, totalCount]); + + useEffect(() => { + if (viewingCode) { + fetchRedemptions(viewingCode.id); + } + }, [viewingCode, fetchRedemptions]); + + useEffect(() => { + const totalPages = Math.max(1, Math.ceil(redemptionsTotal / redemptionsPageSize)); + if (redemptionsPage > totalPages) { + setRedemptionsPage(totalPages); + } + }, [redemptionsPage, redemptionsPageSize, redemptionsTotal]); + const handleAddCode = async () => { if (!formData.code.trim()) { @@ -224,19 +279,14 @@ export function RedemptionCodesPage() { setIsAddDialogOpen(true); }; - const handleViewRedemptions = (code: RedemptionCode) => { - setViewingCode(code); - fetchRedemptions(code.id); - }; + const handleViewRedemptions = (code: RedemptionCode) => { + setViewingCode(code); + setRedemptionsPage(1); + }; + - const filteredCodes = codes.filter(code => { - if (!search) return true; - const query = search.toLowerCase(); - return ( - code.code.toLowerCase().includes(query) || - code.description?.toLowerCase().includes(query) - ); - }); + const filteredCodes = codes; + const getStatusBadge = (code: RedemptionCode) => { if (!code.is_active) { @@ -344,101 +394,115 @@ export function RedemptionCodesPage() { -
- - - - Code - Description - Usage - Expires - Status - Created - Actions - - - - {loading ? ( - - -
-
-
- ) : filteredCodes.length === 0 ? ( - - - {search ? 'No codes match your search.' : 'No redemption codes yet. Generate your first one!'} - - - ) : ( - filteredCodes.map((code) => ( - - -
- - {code.code} - - -
-
- - {code.description || '—'} - - - {code.current_uses} - / {code.max_uses} - - - {code.expires_at - ? format(new Date(code.expires_at), 'MMM d, yyyy HH:mm') - : 'Never'} - - - {getStatusBadge(code)} - - - {format(new Date(code.created_at), 'MMM d, yyyy')} - - -
- - handleToggleActive(code)} - title={code.is_active ? "Deactivate" : "Activate"} - /> - -
-
-
- )) - )} -
-
-
+
+ + + + Code + Description + Usage + Expires + Status + Created + Actions + + + + {loading ? ( + + +
+
+
+ ) : filteredCodes.length === 0 ? ( + + + {search ? 'No codes match your search.' : 'No redemption codes yet. Generate your first one!'} + + + ) : ( + filteredCodes.map((code) => ( + + +
+ + {code.code} + + +
+
+ + {code.description || '—'} + + + {code.current_uses} + / {code.max_uses} + + + {code.expires_at + ? format(new Date(code.expires_at), 'MMM d, yyyy HH:mm') + : 'Never'} + + + {getStatusBadge(code)} + + + {format(new Date(code.created_at), 'MMM d, yyyy')} + + +
+ + handleToggleActive(code)} + title={code.is_active ? "Deactivate" : "Activate"} + /> + +
+
+
+ )) + )} +
+
+
+ + {filteredCodes.length > 0 && ( + { + setPage(1); + setPageSize(size); + }} + /> + )} + {/* View Redemptions Dialog */} !open && setViewingCode(null)}> @@ -451,42 +515,56 @@ export function RedemptionCodesPage() { {viewingCode?.current_uses || 0} of {viewingCode?.max_uses} uses -
- {loadingRedemptions ? ( -
- -
- ) : redemptions.length === 0 ? ( -

- No one has redeemed this code yet. -

- ) : ( - - - - User - Redeemed At - - - - {redemptions.map((r) => ( - - -
-
{r.profile?.name || 'Unknown'}
-
{r.profile?.email}
-
-
- - {format(new Date(r.redeemed_at), 'MMM d, yyyy HH:mm')} - -
- ))} -
-
- )} -
- +
+ {loadingRedemptions ? ( +
+ +
+ ) : redemptions.length === 0 ? ( +

+ No one has redeemed this code yet. +

+ ) : ( + + + + User + Redeemed At + + + + {redemptions.map((r) => ( + + +
+
{r.profile?.name || 'Unknown'}
+
{r.profile?.email}
+
+
+ + {format(new Date(r.redeemed_at), 'MMM d, yyyy HH:mm')} + +
+ ))} +
+
+ )} +
+ + {redemptionsTotal > 0 && ( + { + setRedemptionsPage(1); + setRedemptionsPageSize(size); + }} + /> + )} + + diff --git a/apps/admin/src/pages/activity/UserActivityPage.tsx b/apps/admin/src/pages/activity/UserActivityPage.tsx index fd5d612..eaa0869 100644 --- a/apps/admin/src/pages/activity/UserActivityPage.tsx +++ b/apps/admin/src/pages/activity/UserActivityPage.tsx @@ -6,6 +6,7 @@ import { Skeleton } from '@/components/ui/skeleton'; import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; import { useRealtimeSubscription } from '@/hooks/useRealtimeSubscription'; import { RealtimeStatusIndicator } from '@/components/RealtimeStatusIndicator'; +import { PaginationControls } from '@/components/ui/pagination'; import { Table, TableBody, @@ -121,6 +122,18 @@ export function UserActivityPage() { const [matches, setMatches] = useState([]); const [messages, setMessages] = useState([]); const [signups, setSignups] = useState([]); + const [responsesPage, setResponsesPage] = useState(1); + const [responsesPageSize, setResponsesPageSize] = useState(25); + const [responsesTotal, setResponsesTotal] = useState(0); + const [matchesPage, setMatchesPage] = useState(1); + const [matchesPageSize, setMatchesPageSize] = useState(25); + const [matchesTotal, setMatchesTotal] = useState(0); + const [messagesPage, setMessagesPage] = useState(1); + const [messagesPageSize, setMessagesPageSize] = useState(25); + const [messagesTotal, setMessagesTotal] = useState(0); + const [signupsPage, setSignupsPage] = useState(1); + const [signupsPageSize, setSignupsPageSize] = useState(25); + const [signupsTotal, setSignupsTotal] = useState(0); const [loading, setLoading] = useState({ responses: true, matches: true, @@ -132,7 +145,10 @@ export function UserActivityPage() { const fetchResponses = useCallback(async () => { setLoading(prev => ({ ...prev, responses: true })); try { - const { data, error } = await supabase + const from = (responsesPage - 1) * responsesPageSize; + const to = from + responsesPageSize - 1; + + const { data, error, count } = await supabase .from('responses') .select(` id, user_id, answer, created_at, @@ -141,12 +157,14 @@ export function UserActivityPage() { id, text, pack:question_packs(name) ) - `) + `, { count: 'exact' }) .order('created_at', { ascending: false }) - .limit(50); + .range(from, to); if (error) throw error; + setResponsesTotal(count || 0); + // Transform data to match our types (Supabase returns nested objects, not arrays for 1:1 relations) const transformed: ResponseActivity[] = (data || []).map((item: any) => ({ id: item.id, @@ -167,23 +185,28 @@ export function UserActivityPage() { } finally { setLoading(prev => ({ ...prev, responses: false })); } - }, []); + }, [responsesPage, responsesPageSize]); // Fetch matches with couple profiles const fetchMatches = useCallback(async () => { setLoading(prev => ({ ...prev, matches: true })); try { - const { data: matchesData, error: matchesError } = await supabase + const from = (matchesPage - 1) * matchesPageSize; + const to = from + matchesPageSize - 1; + + const { data: matchesData, error: matchesError, count } = await supabase .from('matches') .select(` id, couple_id, match_type, is_new, created_at, question:questions(id, text) - `) + `, { count: 'exact' }) .order('created_at', { ascending: false }) - .limit(50); + .range(from, to); if (matchesError) throw matchesError; + setMatchesTotal(count || 0); + // Get unique couple IDs and fetch profiles const coupleIds = [...new Set((matchesData || []).map((m: any) => m.couple_id))]; if (coupleIds.length > 0) { @@ -223,14 +246,17 @@ export function UserActivityPage() { } finally { setLoading(prev => ({ ...prev, matches: false })); } - }, []); + }, [matchesPage, matchesPageSize]); // Fetch messages const fetchMessages = useCallback(async () => { setLoading(prev => ({ ...prev, messages: true })); try { + const from = (messagesPage - 1) * messagesPageSize; + const to = from + messagesPageSize - 1; + // Fetch messages with match data - const { data: messagesData, error: messagesError } = await supabase + const { data: messagesData, error: messagesError, count } = await supabase .from('messages') .select(` id, user_id, content, media_path, media_type, created_at, @@ -238,12 +264,14 @@ export function UserActivityPage() { id, question:questions(text) ) - `) + `, { count: 'exact' }) .order('created_at', { ascending: false }) - .limit(50); + .range(from, to); if (messagesError) throw messagesError; + setMessagesTotal(count || 0); + // Get unique user IDs and fetch profiles const userIds = [...new Set((messagesData || []).map((m: any) => m.user_id))]; let profilesMap: Record = {}; @@ -283,27 +311,31 @@ export function UserActivityPage() { } finally { setLoading(prev => ({ ...prev, messages: false })); } - }, []); + }, [messagesPage, messagesPageSize]); // Fetch signups const fetchSignups = useCallback(async () => { setLoading(prev => ({ ...prev, signups: true })); try { - const { data, error } = await supabase + const from = (signupsPage - 1) * signupsPageSize; + const to = from + signupsPageSize - 1; + + const { data, error, count } = await supabase .from('profiles') - .select('id, name, email, avatar_url, created_at, onboarding_completed, couple_id') + .select('id, name, email, avatar_url, created_at, onboarding_completed, couple_id', { count: 'exact' }) .order('created_at', { ascending: false }) - .limit(50); + .range(from, to); if (error) throw error; setSignups(data || []); + setSignupsTotal(count || 0); } catch (error) { console.error('Failed to load signups:', error); toast.error("Failed to load signups"); } finally { setLoading(prev => ({ ...prev, signups: false })); } - }, []); + }, [signupsPage, signupsPageSize]); // Real-time subscriptions const { status: responsesStatus } = useRealtimeSubscription({ @@ -338,6 +370,34 @@ export function UserActivityPage() { fetchSignups(); }, [fetchResponses, fetchMatches, fetchMessages, fetchSignups]); + useEffect(() => { + const totalPages = Math.max(1, Math.ceil(responsesTotal / responsesPageSize)); + if (responsesPage > totalPages) { + setResponsesPage(totalPages); + } + }, [responsesPage, responsesPageSize, responsesTotal]); + + useEffect(() => { + const totalPages = Math.max(1, Math.ceil(matchesTotal / matchesPageSize)); + if (matchesPage > totalPages) { + setMatchesPage(totalPages); + } + }, [matchesPage, matchesPageSize, matchesTotal]); + + useEffect(() => { + const totalPages = Math.max(1, Math.ceil(messagesTotal / messagesPageSize)); + if (messagesPage > totalPages) { + setMessagesPage(totalPages); + } + }, [messagesPage, messagesPageSize, messagesTotal]); + + useEffect(() => { + const totalPages = Math.max(1, Math.ceil(signupsTotal / signupsPageSize)); + if (signupsPage > totalPages) { + setSignupsPage(totalPages); + } + }, [signupsPage, signupsPageSize, signupsTotal]); + // Get current realtime status based on active tab const getCurrentRealtimeStatus = () => { switch (activeTab) { @@ -461,6 +521,19 @@ export function UserActivityPage() { + + {responsesTotal > 0 && ( + { + setResponsesPage(1); + setResponsesPageSize(size); + }} + /> + )} {/* Matches Tab */} @@ -535,6 +608,19 @@ export function UserActivityPage() { + + {matchesTotal > 0 && ( + { + setMatchesPage(1); + setMatchesPageSize(size); + }} + /> + )} {/* Messages Tab */} @@ -593,6 +679,19 @@ export function UserActivityPage() { + + {messagesTotal > 0 && ( + { + setMessagesPage(1); + setMessagesPageSize(size); + }} + /> + )} {/* Signups Tab */} @@ -666,6 +765,19 @@ export function UserActivityPage() { + + {signupsTotal > 0 && ( + { + setSignupsPage(1); + setSignupsPageSize(size); + }} + /> + )} diff --git a/apps/admin/src/pages/admins/AdminsPage.tsx b/apps/admin/src/pages/admins/AdminsPage.tsx index 8064e74..f478e84 100644 --- a/apps/admin/src/pages/admins/AdminsPage.tsx +++ b/apps/admin/src/pages/admins/AdminsPage.tsx @@ -7,6 +7,7 @@ import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Badge } from '@/components/ui/badge'; import { Skeleton } from '@/components/ui/skeleton'; +import { PaginationControls } from '@/components/ui/pagination'; import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; import { Checkbox } from '@/components/ui/checkbox'; import { @@ -58,6 +59,8 @@ export function AdminsPage() { const [admins, setAdmins] = useState([]); const [loading, setLoading] = useState(true); const [search, setSearch] = useState(''); + const [page, setPage] = useState(1); + const [pageSize, setPageSize] = useState(25); // Add Admin State const [isAddDialogOpen, setIsAddDialogOpen] = useState(false); @@ -280,6 +283,23 @@ export function AdminsPage() { ); }); + const totalCount = filteredAdmins.length; + const paginatedAdmins = filteredAdmins.slice( + (page - 1) * pageSize, + page * pageSize + ); + + useEffect(() => { + const totalPages = Math.max(1, Math.ceil(totalCount / pageSize)); + if (page > totalPages) { + setPage(totalPages); + } + }, [page, pageSize, totalCount]); + + useEffect(() => { + setPage(1); + }, [search]); + return (
@@ -509,7 +529,7 @@ export function AdminsPage() { ) : ( - filteredAdmins.map((admin) => ( + paginatedAdmins.map((admin) => (
@@ -590,6 +610,19 @@ export function AdminsPage() {
+ {filteredAdmins.length > 0 && ( + { + setPage(1); + setPageSize(size); + }} + /> + )} + {/* Edit Permissions Dialog */} diff --git a/apps/admin/src/pages/content/CategoriesPage.tsx b/apps/admin/src/pages/content/CategoriesPage.tsx index cb7e1e1..d822d40 100644 --- a/apps/admin/src/pages/content/CategoriesPage.tsx +++ b/apps/admin/src/pages/content/CategoriesPage.tsx @@ -10,6 +10,7 @@ import { Label } from '@/components/ui/label'; import { Textarea } from '@/components/ui/textarea'; import { IconPicker, IconPreview } from '@/components/ui/icon-picker'; import { Skeleton } from '@/components/ui/skeleton'; +import { PaginationControls } from '@/components/ui/pagination'; import { Dialog, DialogContent, @@ -53,6 +54,9 @@ export function CategoriesPage() { const [categories, setCategories] = useState([]); const [loading, setLoading] = useState(true); const [aiIdeasOpen, setAiIdeasOpen] = useState(false); + const [page, setPage] = useState(1); + const [pageSize, setPageSize] = useState(12); + const [totalCount, setTotalCount] = useState(0); // Form state using the new hook const form = useEntityForm( @@ -71,25 +75,34 @@ export function CategoriesPage() { const fetchCategories = useCallback(async () => { setLoading(true); try { - // Fetch categories with pack count - const { data: cats, error } = await supabase + const from = (page - 1) * pageSize; + const to = from + pageSize - 1; + + const { data: cats, error, count } = await supabase .from('categories') - .select('*') - .order('sort_order', { ascending: true }); + .select('*', { count: 'exact' }) + .order('sort_order', { ascending: true }) + .range(from, to); if (error) throw error; - // Fetch pack counts per category - const { data: packs } = await supabase - .from('question_packs') - .select('category_id'); + setTotalCount(count || 0); + const categoryIds = (cats || []).map(c => c.id); const packCounts: Record = {}; - packs?.forEach(p => { - if (p.category_id) { - packCounts[p.category_id] = (packCounts[p.category_id] || 0) + 1; - } - }); + + if (categoryIds.length > 0) { + const { data: packs } = await supabase + .from('question_packs') + .select('category_id') + .in('category_id', categoryIds); + + packs?.forEach(p => { + if (p.category_id) { + packCounts[p.category_id] = (packCounts[p.category_id] || 0) + 1; + } + }); + } setCategories( (cats || []).map(c => ({ @@ -103,12 +116,19 @@ export function CategoriesPage() { } finally { setLoading(false); } - }, []); + }, [page, pageSize]); useEffect(() => { fetchCategories(); }, [fetchCategories]); + useEffect(() => { + const totalPages = Math.max(1, Math.ceil(totalCount / pageSize)); + if (page > totalPages) { + setPage(totalPages); + } + }, [page, pageSize, totalCount]); + // ============================================================================= // Handlers // ============================================================================= @@ -143,7 +163,7 @@ export function CategoriesPage() { } else { const { error } = await auditedSupabase.insert('categories', { ...data, - sort_order: categories.length, + sort_order: totalCount, }); if (error) throw error; toast.success('Category created'); @@ -406,6 +426,19 @@ export function CategoriesPage() {
)} + {categories.length > 0 && ( + { + setPage(1); + setPageSize(size); + }} + /> + )} + {/* AI Dialog */} (null); const [allTopics, setAllTopics] = useState([]); const [selectedTopicIds, setSelectedTopicIds] = useState>(new Set()); + const [page, setPage] = useState(1); + const [pageSize, setPageSize] = useState(12); + const [totalCount, setTotalCount] = useState(0); // Form state using the hook const form = useEntityForm( @@ -98,30 +102,39 @@ export function PacksPage() { setCategory(cat); } + const from = (page - 1) * pageSize; + const to = from + pageSize - 1; + // Fetch packs let query = supabase .from('question_packs') - .select('*') - .order('sort_order', { ascending: true }); + .select('*', { count: 'exact' }) + .order('sort_order', { ascending: true }) + .range(from, to); if (categoryId) { query = query.eq('category_id', categoryId); } - const { data: packData, error } = await query; + const { data: packData, error, count } = await query; if (error) throw error; + setTotalCount(count || 0); + // Fetch question counts const packIds = (packData || []).map(p => p.id); - const { data: questions } = await supabase - .from('questions') - .select('pack_id') - .in('pack_id', packIds); - const questionCounts: Record = {}; - questions?.forEach(q => { - questionCounts[q.pack_id] = (questionCounts[q.pack_id] || 0) + 1; - }); + + if (packIds.length > 0) { + const { data: questions } = await supabase + .from('questions') + .select('pack_id') + .in('pack_id', packIds); + + questions?.forEach(q => { + questionCounts[q.pack_id] = (questionCounts[q.pack_id] || 0) + 1; + }); + } // Fetch all topics const { data: topicsData } = await supabase @@ -131,13 +144,17 @@ export function PacksPage() { setAllTopics(topicsData || []); // Fetch pack_topics relationships - const { data: packTopicsData } = await supabase - .from('pack_topics') - .select('pack_id, topic_id, topics(id, name)') - .in('pack_id', packIds); + let packTopicsData: any[] = []; + if (packIds.length > 0) { + const { data } = await supabase + .from('pack_topics') + .select('pack_id, topic_id, topics(id, name)') + .in('pack_id', packIds); + packTopicsData = data || []; + } const packTopicsMap: Record = {}; - packTopicsData?.forEach((pt: any) => { + packTopicsData.forEach((pt: any) => { if (!packTopicsMap[pt.pack_id]) { packTopicsMap[pt.pack_id] = []; } @@ -159,12 +176,19 @@ export function PacksPage() { } finally { setLoading(false); } - }, [categoryId]); + }, [categoryId, page, pageSize]); useEffect(() => { fetchData(); }, [fetchData]); + useEffect(() => { + const totalPages = Math.max(1, Math.ceil(totalCount / pageSize)); + if (page > totalPages) { + setPage(totalPages); + } + }, [page, pageSize, totalCount]); + // ============================================================================= // Handlers // ============================================================================= @@ -211,7 +235,7 @@ export function PacksPage() { is_public: form.formData.is_public, is_explicit: form.formData.is_explicit, category_id: categoryId || null, - sort_order: packs.length, + sort_order: totalCount, }) .select('id') .single(); @@ -535,6 +559,19 @@ export function PacksPage() { ))}
)} + + {packs.length > 0 && ( + { + setPage(1); + setPageSize(size); + }} + /> + )} ); } diff --git a/apps/admin/src/pages/content/QuestionsPage.tsx b/apps/admin/src/pages/content/QuestionsPage.tsx index 2838bd8..256cdb9 100644 --- a/apps/admin/src/pages/content/QuestionsPage.tsx +++ b/apps/admin/src/pages/content/QuestionsPage.tsx @@ -9,6 +9,7 @@ import { Label } from '@/components/ui/label'; import { Textarea } from '@/components/ui/textarea'; import { Badge } from '@/components/ui/badge'; import { Skeleton } from '@/components/ui/skeleton'; +import { PaginationControls } from '@/components/ui/pagination'; import { Table, TableBody, @@ -70,6 +71,9 @@ export function QuestionsPage() { const [saving, setSaving] = useState(false); const [selectedIds, setSelectedIds] = useState>(new Set()); const [bulkDeleting, setBulkDeleting] = useState(false); + const [page, setPage] = useState(1); + const [pageSize, setPageSize] = useState(25); + const [totalCount, setTotalCount] = useState(0); // Form state const [formData, setFormData] = useState<{ @@ -100,14 +104,19 @@ export function QuestionsPage() { setPack(packData); + const from = (page - 1) * pageSize; + const to = from + pageSize - 1; + // Fetch questions - const { data: questionData, error } = await supabase + const { data: questionData, error, count } = await supabase .from('questions') - .select('*') + .select('*', { count: 'exact' }) .eq('pack_id', packId) - .order('created_at', { ascending: true }); + .order('created_at', { ascending: true }) + .range(from, to); if (error) throw error; + setTotalCount(count || 0); setQuestions(questionData || []); } catch (error) { toast.error('Failed to load questions'); @@ -115,33 +124,49 @@ export function QuestionsPage() { } finally { setLoading(false); } - }, [packId]); + }, [packId, page, pageSize]); // Real-time subscription for questions in this pack const { status: realtimeStatus } = useRealtimeSubscription({ table: 'questions', filter: packId ? `pack_id=eq.${packId}` : undefined, enabled: !!packId, - onInsert: useCallback((newQuestion: Question) => { - setQuestions(prev => [...prev, newQuestion]); - }, []), - onUpdate: useCallback(({ new: updated }: { old: Question; new: Question }) => { - setQuestions(prev => prev.map(q => q.id === updated.id ? updated : q)); - }, []), + onInsert: useCallback(() => { + fetchData(); + }, [fetchData]), + onUpdate: useCallback(() => { + fetchData(); + }, [fetchData]), onDelete: useCallback((deleted: Question) => { - setQuestions(prev => prev.filter(q => q.id !== deleted.id)); setSelectedIds(prev => { const next = new Set(prev); next.delete(deleted.id); return next; }); - }, []), + fetchData(); + }, [fetchData]), }); useEffect(() => { fetchData(); }, [fetchData]); + useEffect(() => { + const totalPages = Math.max(1, Math.ceil(totalCount / pageSize)); + if (page > totalPages) { + setPage(totalPages); + } + }, [page, pageSize, totalCount]); + + useEffect(() => { + setPage(1); + setSelectedIds(new Set()); + }, [packId]); + + useEffect(() => { + setSelectedIds(new Set()); + }, [page, pageSize]); + const openCreateDialog = () => { setEditingQuestion(null); setFormData({ text: '', partner_text: '', intensity: 1, allowed_couple_genders: [], target_user_genders: [] }); @@ -319,7 +344,7 @@ export function QuestionsPage() {

- {questions.length} question{questions.length !== 1 ? 's' : ''} in this pack + {totalCount} question{totalCount !== 1 ? 's' : ''} in this pack

@@ -615,7 +640,7 @@ export function QuestionsPage() { /> - {index + 1} + {(page - 1) * pageSize + index + 1} {question.text} @@ -689,6 +714,19 @@ export function QuestionsPage() {
) } + + {questions.length > 0 && ( + { + setPage(1); + setPageSize(size); + }} + /> + )} ); } diff --git a/apps/admin/src/pages/users/MatchChatPage.tsx b/apps/admin/src/pages/users/MatchChatPage.tsx index bba129e..15d419d 100644 --- a/apps/admin/src/pages/users/MatchChatPage.tsx +++ b/apps/admin/src/pages/users/MatchChatPage.tsx @@ -12,6 +12,7 @@ import { format, isToday, isYesterday } from 'date-fns'; import { cn } from '@/lib/utils'; import { useRealtimeSubscription } from '@/hooks/useRealtimeSubscription'; import { RealtimeStatusIndicator } from '@/components/RealtimeStatusIndicator'; +import { PaginationControls } from '@/components/ui/pagination'; interface Profile { id: string; @@ -331,79 +332,103 @@ export function MatchChatPage() { const [match, setMatch] = useState(null); const [messages, setMessages] = useState([]); const [loading, setLoading] = useState(true); + const [page, setPage] = useState(1); + const [pageSize, setPageSize] = useState(50); + const [totalCount, setTotalCount] = useState(0); + + const fetchData = useCallback(async () => { + if (!userId || !matchId) return; + + setLoading(true); + try { + // Fetch user profile + const { data: userData } = await supabase + .from('profiles') + .select('id, name, avatar_url, couple_id') + .eq('id', userId) + .single(); + + setUser(userData); + + // Fetch partner + if (userData?.couple_id) { + const { data: partnerData } = await supabase + .from('profiles') + .select('id, name, avatar_url') + .eq('couple_id', userData.couple_id) + .neq('id', userId) + .single(); + + setPartner(partnerData); + } else { + setPartner(null); + } + + // Fetch match with question + const { data: matchData } = await supabase + .from('matches') + .select(` + id, match_type, + question:questions (id, text) + `) + .eq('id', matchId) + .single(); + + setMatch(matchData as unknown as Match); + + const from = (page - 1) * pageSize; + const to = from + pageSize - 1; + + // Fetch messages + const { data: messageData, error: messagesError, count } = await supabase + .from('messages') + .select('*', { count: 'exact' }) + .eq('match_id', matchId) + .order('created_at', { ascending: false }) + .range(from, to); + + if (messagesError) throw messagesError; + + setTotalCount(count || 0); + const orderedMessages = (messageData || []).slice().reverse(); + setMessages(orderedMessages); + } catch (error) { + console.error('Failed to load chat data:', error); + } finally { + setLoading(false); + } + }, [userId, matchId, page, pageSize]); // Real-time subscription for messages in this chat const { status: realtimeStatus } = useRealtimeSubscription({ table: 'messages', filter: matchId ? `match_id=eq.${matchId}` : undefined, enabled: !!matchId, - onInsert: useCallback((newMessage: Message) => { - setMessages(prev => [...prev, newMessage]); - }, []), - onUpdate: useCallback(({ new: updated }: { old: Message; new: Message }) => { - setMessages(prev => prev.map(m => m.id === updated.id ? updated : m)); - }, []), - onDelete: useCallback((deleted: Message) => { - setMessages(prev => prev.filter(m => m.id !== deleted.id)); - }, []), + onInsert: useCallback(() => { + fetchData(); + }, [fetchData]), + onUpdate: useCallback(() => { + fetchData(); + }, [fetchData]), + onDelete: useCallback(() => { + fetchData(); + }, [fetchData]), }); useEffect(() => { - const fetchData = async () => { - if (!userId || !matchId) return; - - setLoading(true); - try { - // Fetch user profile - const { data: userData } = await supabase - .from('profiles') - .select('id, name, avatar_url, couple_id') - .eq('id', userId) - .single(); - - setUser(userData); - - // Fetch partner - if (userData?.couple_id) { - const { data: partnerData } = await supabase - .from('profiles') - .select('id, name, avatar_url') - .eq('couple_id', userData.couple_id) - .neq('id', userId) - .single(); - - setPartner(partnerData); - } - - // Fetch match with question - const { data: matchData } = await supabase - .from('matches') - .select(` - id, match_type, - question:questions (id, text) - `) - .eq('id', matchId) - .single(); - - setMatch(matchData as unknown as Match); - - // Fetch messages - const { data: messageData } = await supabase - .from('messages') - .select('*') - .eq('match_id', matchId) - .order('created_at', { ascending: true }); - - setMessages(messageData || []); - } catch (error) { - console.error('Failed to load chat data:', error); - } finally { - setLoading(false); - } - }; - fetchData(); - }, [userId, matchId]); + }, [fetchData]); + + useEffect(() => { + const totalPages = Math.max(1, Math.ceil(totalCount / pageSize)); + if (page > totalPages) { + setPage(totalPages); + } + }, [page, pageSize, totalCount]); + + useEffect(() => { + setPage(1); + }, [matchId]); const getProfile = (senderId: string): Profile | null => { if (senderId === user?.id) return user; @@ -497,7 +522,7 @@ export function MatchChatPage() { - Messages ({messages.length}) + Messages ({totalCount}) @@ -580,6 +605,21 @@ export function MatchChatPage() { })} )} + + {totalCount > 0 && ( +
+ { + setPage(1); + setPageSize(size); + }} + /> +
+ )}
diff --git a/apps/admin/src/pages/users/UserDetailPage.tsx b/apps/admin/src/pages/users/UserDetailPage.tsx index c1dc032..a9ff75d 100644 --- a/apps/admin/src/pages/users/UserDetailPage.tsx +++ b/apps/admin/src/pages/users/UserDetailPage.tsx @@ -8,6 +8,7 @@ import { useAuth, PERMISSION_KEYS } from '@/contexts/AuthContext'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { Skeleton } from '@/components/ui/skeleton'; +import { PaginationControls } from '@/components/ui/pagination'; import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; @@ -237,6 +238,15 @@ export function UserDetailPage() { const [responses, setResponses] = useState([]); const [matches, setMatches] = useState([]); const [mediaMessages, setMediaMessages] = useState([]); + const [responsesPage, setResponsesPage] = useState(1); + const [responsesPageSize, setResponsesPageSize] = useState(10); + const [responsesTotal, setResponsesTotal] = useState(0); + const [matchesPage, setMatchesPage] = useState(1); + const [matchesPageSize, setMatchesPageSize] = useState(10); + const [matchesTotal, setMatchesTotal] = useState(0); + const [mediaPage, setMediaPage] = useState(1); + const [mediaPageSize, setMediaPageSize] = useState(12); + const [mediaTotal, setMediaTotal] = useState(0); const [loading, setLoading] = useState(true); const canViewResponses = hasPermission(PERMISSION_KEYS.VIEW_RESPONSES); @@ -308,44 +318,121 @@ export function UserDetailPage() { setPartner(null); } - const { data: responseData } = await supabase.from('responses').select('id, answer, created_at, question:questions(id, text, pack:question_packs(name))').eq('user_id', userId).order('created_at', { ascending: false }).limit(50); + const responsesFrom = (responsesPage - 1) * responsesPageSize; + const responsesTo = responsesFrom + responsesPageSize - 1; + + const { data: responseData, count: responsesCount, error: responsesError } = await supabase + .from('responses') + .select('id, answer, created_at, question:questions(id, text, pack:question_packs(name))', { count: 'exact' }) + .eq('user_id', userId) + .order('created_at', { ascending: false }) + .range(responsesFrom, responsesTo); + + if (responsesError) throw responsesError; + setResponses((responseData || []) as unknown as Response[]); + setResponsesTotal(responsesCount || 0); if (profileData?.couple_id) { - const { data: matchData } = await supabase.from('matches').select('id, match_type, is_new, created_at, question:questions(id, text)').eq('couple_id', profileData.couple_id).order('created_at', { ascending: false }).limit(50); + const matchesFrom = (matchesPage - 1) * matchesPageSize; + const matchesTo = matchesFrom + matchesPageSize - 1; + + const { data: matchData, count: matchesCount, error: matchesError } = await supabase + .from('matches') + .select('id, match_type, is_new, created_at, question:questions(id, text)', { count: 'exact' }) + .eq('couple_id', profileData.couple_id) + .order('created_at', { ascending: false }) + .range(matchesFrom, matchesTo); + + if (matchesError) throw matchesError; + + setMatchesTotal(matchesCount || 0); + const matchIds = (matchData || []).map(m => m.id); if (matchIds.length > 0) { const { data: messages } = await supabase.from('messages').select('match_id').in('match_id', matchIds); const messageCounts: Record = {}; messages?.forEach(m => { messageCounts[m.match_id] = (messageCounts[m.match_id] || 0) + 1; }); setMatches((matchData || []).map(m => ({ ...m, message_count: messageCounts[m.id] || 0 })) as unknown as Match[]); - const { data: mediaMessagesData, error: mediaError } = await supabase.from('messages').select('id, user_id, media_path, media_type, version, created_at, keys_metadata, match_id').in('match_id', matchIds).not('media_path', 'is', null).order('created_at', { ascending: false }); + } else { + setMatches([]); + } + + const { data: allMatchIdsData } = await supabase + .from('matches') + .select('id') + .eq('couple_id', profileData.couple_id); + + const allMatchIds = (allMatchIdsData || []).map(m => m.id); + if (allMatchIds.length > 0) { + const mediaFrom = (mediaPage - 1) * mediaPageSize; + const mediaTo = mediaFrom + mediaPageSize - 1; + + const { data: mediaMessagesData, error: mediaError, count: mediaCount } = await supabase + .from('messages') + .select('id, user_id, media_path, media_type, version, created_at, keys_metadata, match_id', { count: 'exact' }) + .in('match_id', allMatchIds) + .not('media_path', 'is', null) + .order('created_at', { ascending: false }) + .range(mediaFrom, mediaTo); + if (mediaError) { console.error("Error fetching media messages:", mediaError); setMediaMessages([]); + setMediaTotal(0); } else { setMediaMessages(mediaMessagesData || []); + setMediaTotal(mediaCount || 0); } } else { - setMatches([]); setMediaMessages([]); + setMediaTotal(0); } } else { setMatches([]); + setMatchesTotal(0); setMediaMessages([]); + setMediaTotal(0); } } catch (error) { console.error('Failed to load user data:', error); } finally { setLoading(false); } - }, [userId]); + }, [userId, responsesPage, responsesPageSize, matchesPage, matchesPageSize, mediaPage, mediaPageSize]); const { status: profileStatus } = useRealtimeSubscription({ table: 'profiles', filter: userId ? `id=eq.${userId}` : undefined, enabled: !!userId, onUpdate: useCallback(({ new: updated }: { old: Profile; new: Profile }) => { setProfile(updated); }, []) }); const { status: responsesStatus } = useRealtimeSubscription({ table: 'responses', filter: userId ? `user_id=eq.${userId}` : undefined, enabled: !!userId, onInsert: fetchData, onDelete: fetchData }); useEffect(() => { fetchData(); }, [fetchData]); + useEffect(() => { + setResponsesPage(1); + setMatchesPage(1); + setMediaPage(1); + }, [userId]); + + useEffect(() => { + const totalPages = Math.max(1, Math.ceil(responsesTotal / responsesPageSize)); + if (responsesPage > totalPages) { + setResponsesPage(totalPages); + } + }, [responsesPage, responsesPageSize, responsesTotal]); + + useEffect(() => { + const totalPages = Math.max(1, Math.ceil(matchesTotal / matchesPageSize)); + if (matchesPage > totalPages) { + setMatchesPage(totalPages); + } + }, [matchesPage, matchesPageSize, matchesTotal]); + + useEffect(() => { + const totalPages = Math.max(1, Math.ceil(mediaTotal / mediaPageSize)); + if (mediaPage > totalPages) { + setMediaPage(totalPages); + } + }, [mediaPage, mediaPageSize, mediaTotal]); + if (loading) return
; if (!profile) return

User not found

; @@ -378,19 +465,19 @@ export function UserDetailPage() { {/* Tabs */} - {canViewResponses && Responses ({responses.length})} - {canViewMatches && Matches ({matches.length})} - {canViewMedia && Media ({mediaMessages.length})} + {canViewResponses && Responses ({responsesTotal})} + {canViewMatches && Matches ({matchesTotal})} + {canViewMedia && Media ({mediaTotal})} - {canViewResponses && {responses.length === 0 ?

No responses yet

:
QuestionPackAnswerDate{responses.map((r) => ({r.question.text}{r.question.pack?.name || '—'}
{answerIcons[r.answer]}{r.answer}
{r.created_at ? format(new Date(r.created_at), 'MMM d, yyyy') : '—'}
))}
}
} - {canViewMatches && {matches.length === 0 ?

No matches yet

:
{matches.map((match) => (
{matchTypeLabels[match.match_type]}{match.is_new && New}
{match.question?.text || 'Unknown question'}
{match.message_count} message{match.message_count !== 1 ? 's' : ''}
{canViewChats && }
))}
}
} + {canViewResponses && {responses.length === 0 ?

No responses yet

:
QuestionPackAnswerDate{responses.map((r) => ({r.question.text}{r.question.pack?.name || '—'}
{answerIcons[r.answer]}{r.answer}
{r.created_at ? format(new Date(r.created_at), 'MMM d, yyyy') : '—'}
))}
{ setResponsesPage(1); setResponsesPageSize(size); }} />
}
} + {canViewMatches && {matches.length === 0 ?

No matches yet

:
{matches.map((match) => (
{matchTypeLabels[match.match_type]}{match.is_new && New}
{match.question?.text || 'Unknown question'}
{match.message_count} message{match.message_count !== 1 ? 's' : ''}
{canViewChats && }
))}
{ setMatchesPage(1); setMatchesPageSize(size); }} />
}
} {canViewMedia && {mediaMessages.length === 0 ?

No media uploaded

:
{mediaMessages.map((message) => (
{message.media_type === 'video' ? ( ) : ( )} -
{message.media_type === 'video' ? 'Video' : 'Image'}
{canViewChats && message.match_id && View Chat}
{message.created_at ? format(new Date(message.created_at), 'MMM d, yyyy') : '—'}
))}
}
} +
{message.media_type === 'video' ? 'Video' : 'Image'}
{canViewChats && message.match_id && View Chat}
{message.created_at ? format(new Date(message.created_at), 'MMM d, yyyy') : '—'}
))} { setMediaPage(1); setMediaPageSize(size); }} />}}
); diff --git a/apps/admin/src/pages/users/UsersPage.tsx b/apps/admin/src/pages/users/UsersPage.tsx index 6abfefd..42dc141 100644 --- a/apps/admin/src/pages/users/UsersPage.tsx +++ b/apps/admin/src/pages/users/UsersPage.tsx @@ -5,6 +5,7 @@ import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Badge } from '@/components/ui/badge'; import { Skeleton } from '@/components/ui/skeleton'; +import { PaginationControls } from '@/components/ui/pagination'; import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; import { useRealtimeSubscription } from '@/hooks/useRealtimeSubscription'; import { RealtimeStatusIndicator } from '@/components/RealtimeStatusIndicator'; @@ -46,16 +47,20 @@ export function UsersPage() { const [loading, setLoading] = useState(true); const [search, setSearch] = useState(''); const [filteredProfiles, setFilteredProfiles] = useState([]); + const [page, setPage] = useState(1); + const [pageSize, setPageSize] = useState(25); const fetchProfiles = useCallback(async () => { setLoading(true); try { // Fetch profiles with auth info (includes last_sign_in_at) - const { data: profilesData, error } = await supabase + const { data: profilesDataRaw, error } = await supabase .rpc('get_profiles_with_auth_info'); if (error) throw error; + const profilesData = (profilesDataRaw || []) as Profile[]; + // Fetch storage usage per user from storage.objects const { data: storageData } = await supabase .rpc('get_user_storage_usage'); @@ -70,7 +75,7 @@ export function UsersPage() { // Build a map of couple_id -> users with premium in that couple const couplePartners: Record = {}; - (profilesData || []).forEach(profile => { + profilesData.forEach(profile => { if (profile.couple_id) { if (!couplePartners[profile.couple_id]) { couplePartners[profile.couple_id] = []; @@ -83,7 +88,7 @@ export function UsersPage() { }); // Merge storage data and partner premium status with profiles - const profilesWithExtras = (profilesData || []).map(profile => { + const profilesWithExtras = profilesData.map(profile => { // Check if partner has premium let partnerIsPremium = false; if (profile.couple_id && couplePartners[profile.couple_id]) { @@ -134,6 +139,7 @@ export function UsersPage() { }, [fetchProfiles]); useEffect(() => { + setPage(1); if (!search.trim()) { setFilteredProfiles(profiles); return; @@ -150,6 +156,19 @@ export function UsersPage() { ); }, [search, profiles]); + const totalCount = filteredProfiles.length; + const paginatedProfiles = filteredProfiles.slice( + (page - 1) * pageSize, + page * pageSize + ); + + useEffect(() => { + const totalPages = Math.max(1, Math.ceil(totalCount / pageSize)); + if (page > totalPages) { + setPage(totalPages); + } + }, [page, pageSize, totalCount]); + if (loading) { return (
@@ -209,7 +228,7 @@ export function UsersPage() { ) : ( - filteredProfiles.map((profile) => ( + paginatedProfiles.map((profile) => ( @@ -294,6 +313,19 @@ export function UsersPage() {
+ + {filteredProfiles.length > 0 && ( + { + setPage(1); + setPageSize(size); + }} + /> + )} ); } diff --git a/apps/mobile/android/app/build.gradle b/apps/mobile/android/app/build.gradle index 23297f9..11e7dae 100644 --- a/apps/mobile/android/app/build.gradle +++ b/apps/mobile/android/app/build.gradle @@ -93,7 +93,7 @@ android { applicationId 'com.sauci.app' minSdkVersion rootProject.ext.minSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion - versionCode 17 + versionCode 21 versionName "1.0.0" } signingConfigs { diff --git a/apps/mobile/app.json b/apps/mobile/app.json index bf94234..db2e1d4 100644 --- a/apps/mobile/app.json +++ b/apps/mobile/app.json @@ -176,7 +176,7 @@ "backgroundColor": "#1a1a2e" }, "package": "com.sauci.app", - "versionCode": 20, + "versionCode": 21, "googleServicesFile": "./google-services.json", "permissions": [ "RECEIVE_BOOT_COMPLETED", diff --git a/apps/web/.env.local.example b/apps/web/.env.local.example index 4f8a81b..4b6f29f 100644 --- a/apps/web/.env.local.example +++ b/apps/web/.env.local.example @@ -3,5 +3,6 @@ NEXT_PUBLIC_POSTHOG_KEY=phc_your_project_key_here NEXT_PUBLIC_POSTHOG_HOST=https://eu.i.posthog.com -# Supabase -NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co +# Supabase +# Production: https://ckjcrkjpmhqhiucifukx.supabase.co +NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co diff --git a/apps/web/Dockerfile b/apps/web/Dockerfile index 599bf4a..135ae57 100644 --- a/apps/web/Dockerfile +++ b/apps/web/Dockerfile @@ -3,12 +3,14 @@ FROM node:20-alpine AS builder WORKDIR /app -# Build args for Next.js public env vars (inlined at build time) -ARG NEXT_PUBLIC_POSTHOG_KEY -ARG NEXT_PUBLIC_POSTHOG_HOST=https://eu.i.posthog.com - -ENV NEXT_PUBLIC_POSTHOG_KEY=$NEXT_PUBLIC_POSTHOG_KEY -ENV NEXT_PUBLIC_POSTHOG_HOST=$NEXT_PUBLIC_POSTHOG_HOST +# Build args for Next.js public env vars (inlined at build time) +ARG NEXT_PUBLIC_POSTHOG_KEY +ARG NEXT_PUBLIC_POSTHOG_HOST=https://eu.i.posthog.com +ARG NEXT_PUBLIC_SUPABASE_URL=https://ckjcrkjpmhqhiucifukx.supabase.co + +ENV NEXT_PUBLIC_POSTHOG_KEY=$NEXT_PUBLIC_POSTHOG_KEY +ENV NEXT_PUBLIC_POSTHOG_HOST=$NEXT_PUBLIC_POSTHOG_HOST +ENV NEXT_PUBLIC_SUPABASE_URL=$NEXT_PUBLIC_SUPABASE_URL # Install dependencies COPY package*.json ./ diff --git a/apps/web/docker-compose.prod.yml b/apps/web/docker-compose.prod.yml index b0727c9..2da8ddf 100644 --- a/apps/web/docker-compose.prod.yml +++ b/apps/web/docker-compose.prod.yml @@ -5,9 +5,10 @@ services: build: context: . dockerfile: Dockerfile - args: - - NEXT_PUBLIC_POSTHOG_KEY=${NEXT_PUBLIC_POSTHOG_KEY} - - NEXT_PUBLIC_POSTHOG_HOST=${NEXT_PUBLIC_POSTHOG_HOST:-https://eu.i.posthog.com} + args: + - NEXT_PUBLIC_POSTHOG_KEY=${NEXT_PUBLIC_POSTHOG_KEY} + - NEXT_PUBLIC_POSTHOG_HOST=${NEXT_PUBLIC_POSTHOG_HOST:-https://eu.i.posthog.com} + - NEXT_PUBLIC_SUPABASE_URL=${NEXT_PUBLIC_SUPABASE_URL:-https://ckjcrkjpmhqhiucifukx.supabase.co} environment: - NODE_ENV=production - NEXT_TELEMETRY_DISABLED=1 diff --git a/docs/dares-feature.md b/docs/dares-feature.md new file mode 100644 index 0000000..80387a7 --- /dev/null +++ b/docs/dares-feature.md @@ -0,0 +1,599 @@ +# Dares Feature - Architecture & Implementation Plan + +> **Status**: Planning Complete - Ready for Implementation +> **Created**: 2026-01-08 +> **Target**: Schema + Admin Portal (Phase 1), Mobile App (Phase 2 - Later) + +## Overview + +The Dares feature allows users to send challenges/dares to their partner from curated packs. Partners can accept or decline dares, and the sender marks them as complete. A scoreboard tracks total completed dares per user/couple. + +### Key Decisions + +| Aspect | Decision | +|--------|----------| +| Dare Selection | User browses list, picks dare to send | +| Decline | Recipient can decline (no negative impact) | +| Cancel | Sender can cancel before completion | +| Timeframes | Pick from preset list + "no time limit" option | +| Scoreboard | Simple count of completed dares | +| Custom Dares | Premium users can create their own | +| Gender Filtering | Not needed (user chooses which dare to send) | +| Premium Model | Same as question packs (free/premium packs) | +| Chat | Auto-create chat thread when dare is sent | +| Completion | Sender marks dare as complete | + +--- + +## Database Schema + +### New Enum Types + +```sql +CREATE TYPE dare_status AS ENUM ( + 'pending', -- Sent, waiting for recipient to accept/decline + 'active', -- Recipient accepted, timer started (if applicable) + 'completed', -- Sender marked as complete + 'expired', -- Time ran out (only if had deadline) + 'declined', -- Recipient declined + 'cancelled' -- Sender cancelled before completion +); +``` + +### Table: `dare_packs` + +Mirrors `question_packs` structure for consistency. + +| Column | Type | Nullable | Default | Description | +|--------|------|----------|---------|-------------| +| `id` | UUID | NO | `gen_random_uuid()` | Primary key | +| `name` | TEXT | NO | | Pack name | +| `description` | TEXT | YES | | Pack description | +| `icon` | TEXT | YES | | Emoji icon | +| `is_premium` | BOOLEAN | NO | `false` | Requires premium access | +| `is_public` | BOOLEAN | NO | `true` | Visible to users | +| `is_explicit` | BOOLEAN | NO | `false` | Contains explicit content | +| `sort_order` | INTEGER | NO | `0` | Display ordering | +| `category_id` | UUID | YES | | FK to `categories` (optional) | +| `min_intensity` | INTEGER | YES | | Auto-calculated from dares | +| `max_intensity` | INTEGER | YES | | Auto-calculated from dares | +| `avg_intensity` | NUMERIC(3,2) | YES | | Auto-calculated from dares | +| `created_at` | TIMESTAMPTZ | NO | `now()` | Creation timestamp | + +**Indexes:** +- Primary key on `id` +- Index on `sort_order` for ordering +- Index on `is_public, is_premium` for filtering + +### Table: `dares` + +Individual dare content within packs. + +| Column | Type | Nullable | Default | Description | +|--------|------|----------|---------|-------------| +| `id` | UUID | NO | `gen_random_uuid()` | Primary key | +| `pack_id` | UUID | NO | | FK to `dare_packs` (CASCADE delete) | +| `text` | TEXT | NO | | Dare description/instruction | +| `intensity` | INTEGER | NO | `1` | 1-5 scale (CHECK constraint) | +| `suggested_duration_hours` | INTEGER | YES | | Suggested timeframe in hours | +| `created_at` | TIMESTAMPTZ | NO | `now()` | Creation timestamp | + +**Constraints:** +- `CHECK (intensity >= 1 AND intensity <= 5)` +- Foreign key to `dare_packs` with `ON DELETE CASCADE` + +**Indexes:** +- Primary key on `id` +- Index on `pack_id` for lookups + +### Table: `sent_dares` + +Tracks dare instances sent between partners. + +| Column | Type | Nullable | Default | Description | +|--------|------|----------|---------|-------------| +| `id` | UUID | NO | `gen_random_uuid()` | Primary key | +| `couple_id` | UUID | NO | | FK to `couples` (CASCADE delete) | +| `dare_id` | UUID | YES | | FK to `dares` (null for custom dares) | +| `custom_dare_text` | TEXT | YES | | Text for custom dares | +| `custom_dare_intensity` | INTEGER | YES | | Intensity for custom dares (1-5) | +| `sender_id` | UUID | NO | | FK to `profiles` | +| `recipient_id` | UUID | NO | | FK to `profiles` | +| `status` | `dare_status` | NO | `'pending'` | Current status | +| `sent_at` | TIMESTAMPTZ | NO | `now()` | When dare was sent | +| `accepted_at` | TIMESTAMPTZ | YES | | When recipient accepted | +| `expires_at` | TIMESTAMPTZ | YES | | Deadline (null = no limit) | +| `completed_at` | TIMESTAMPTZ | YES | | When marked complete | +| `sender_notes` | TEXT | YES | | Optional message from sender | +| `created_at` | TIMESTAMPTZ | NO | `now()` | Creation timestamp | + +**Constraints:** +- `CHECK (custom_dare_intensity IS NULL OR (custom_dare_intensity >= 1 AND custom_dare_intensity <= 5))` +- `CHECK ((dare_id IS NOT NULL) OR (custom_dare_text IS NOT NULL))` -- Must have either a dare_id or custom text +- Foreign keys with appropriate cascade behavior + +**Indexes:** +- Primary key on `id` +- Index on `couple_id` for couple lookups +- Index on `sender_id` for sender stats +- Index on `recipient_id` for recipient stats +- Index on `status` for filtering active dares +- Index on `expires_at` for expiry job + +### Table: `dare_messages` + +Chat messages for each dare instance. + +| Column | Type | Nullable | Default | Description | +|--------|------|----------|---------|-------------| +| `id` | UUID | NO | `gen_random_uuid()` | Primary key | +| `sent_dare_id` | UUID | NO | | FK to `sent_dares` (CASCADE delete) | +| `sender_id` | UUID | NO | | FK to `profiles` | +| `content` | TEXT | NO | | Message content | +| `read_at` | TIMESTAMPTZ | YES | | When recipient read message | +| `created_at` | TIMESTAMPTZ | NO | `now()` | Creation timestamp | + +**Indexes:** +- Primary key on `id` +- Index on `sent_dare_id` for message lookups +- Index on `created_at` for ordering + +--- + +## Database Functions & Triggers + +### 1. Intensity Stats Trigger + +Auto-calculate `min_intensity`, `max_intensity`, `avg_intensity` on `dare_packs` when dares are inserted/updated/deleted. + +```sql +CREATE OR REPLACE FUNCTION update_dare_pack_intensity_stats() +RETURNS TRIGGER AS $$ +BEGIN + UPDATE dare_packs + SET + min_intensity = (SELECT MIN(intensity) FROM dares WHERE pack_id = COALESCE(NEW.pack_id, OLD.pack_id)), + max_intensity = (SELECT MAX(intensity) FROM dares WHERE pack_id = COALESCE(NEW.pack_id, OLD.pack_id)), + avg_intensity = (SELECT ROUND(AVG(intensity)::numeric, 2) FROM dares WHERE pack_id = COALESCE(NEW.pack_id, OLD.pack_id)) + WHERE id = COALESCE(NEW.pack_id, OLD.pack_id); + RETURN COALESCE(NEW, OLD); +END; +$$ LANGUAGE plpgsql; + +CREATE TRIGGER dare_intensity_stats_trigger +AFTER INSERT OR UPDATE OR DELETE ON dares +FOR EACH ROW EXECUTE FUNCTION update_dare_pack_intensity_stats(); +``` + +### 2. User Dare Stats Function + +```sql +CREATE OR REPLACE FUNCTION get_user_dare_stats(p_user_id UUID) +RETURNS TABLE ( + dares_sent_count BIGINT, + dares_received_count BIGINT, + dares_completed_as_sender BIGINT, + dares_completed_as_recipient BIGINT +) AS $$ +BEGIN + RETURN QUERY + SELECT + (SELECT COUNT(*) FROM sent_dares WHERE sender_id = p_user_id), + (SELECT COUNT(*) FROM sent_dares WHERE recipient_id = p_user_id), + (SELECT COUNT(*) FROM sent_dares WHERE sender_id = p_user_id AND status = 'completed'), + (SELECT COUNT(*) FROM sent_dares WHERE recipient_id = p_user_id AND status = 'completed'); +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; +``` + +### 3. Couple Dare Stats Function + +```sql +CREATE OR REPLACE FUNCTION get_couple_dare_stats(p_couple_id UUID) +RETURNS TABLE ( + total_dares_sent BIGINT, + total_dares_completed BIGINT, + total_dares_active BIGINT +) AS $$ +BEGIN + RETURN QUERY + SELECT + (SELECT COUNT(*) FROM sent_dares WHERE couple_id = p_couple_id), + (SELECT COUNT(*) FROM sent_dares WHERE couple_id = p_couple_id AND status = 'completed'), + (SELECT COUNT(*) FROM sent_dares WHERE couple_id = p_couple_id AND status = 'active'); +END; +$$ LANGUAGE plpgsql SECURITY DEFINER; +``` + +--- + +## RLS Policies + +### `dare_packs` + +```sql +-- Anyone can view public packs +CREATE POLICY "Public dare packs are viewable by everyone" +ON dare_packs FOR SELECT +USING (is_public = true); + +-- Admins can do everything +CREATE POLICY "Admins can manage dare packs" +ON dare_packs FOR ALL +USING (is_admin()); +``` + +### `dares` + +```sql +-- View dares if pack is accessible (public, or premium pack + premium user) +CREATE POLICY "Users can view dares in accessible packs" +ON dares FOR SELECT +USING ( + EXISTS ( + SELECT 1 FROM dare_packs dp + WHERE dp.id = dares.pack_id + AND dp.is_public = true + AND ( + dp.is_premium = false + OR EXISTS (SELECT 1 FROM profiles WHERE id = auth.uid() AND is_premium = true) + ) + ) +); + +-- Admins can do everything +CREATE POLICY "Admins can manage dares" +ON dares FOR ALL +USING (is_admin()); +``` + +### `sent_dares` + +```sql +-- Users can view sent dares in their couple +CREATE POLICY "Users can view their couple's sent dares" +ON sent_dares FOR SELECT +USING (couple_id = get_auth_user_couple_id()); + +-- Users can update sent dares in their couple (for status changes) +CREATE POLICY "Users can update their couple's sent dares" +ON sent_dares FOR UPDATE +USING (couple_id = get_auth_user_couple_id()); + +-- Insert via edge function only (service role) +``` + +### `dare_messages` + +```sql +-- Users can view messages for their couple's dares +CREATE POLICY "Users can view messages for their dares" +ON dare_messages FOR SELECT +USING ( + EXISTS ( + SELECT 1 FROM sent_dares sd + WHERE sd.id = dare_messages.sent_dare_id + AND sd.couple_id = get_auth_user_couple_id() + ) +); + +-- Users can insert messages for their couple's dares +CREATE POLICY "Users can send messages for their dares" +ON dare_messages FOR INSERT +WITH CHECK ( + sender_id = auth.uid() + AND EXISTS ( + SELECT 1 FROM sent_dares sd + WHERE sd.id = dare_messages.sent_dare_id + AND sd.couple_id = get_auth_user_couple_id() + ) +); + +-- Users can update their own messages (for read_at) +CREATE POLICY "Users can update read status" +ON dare_messages FOR UPDATE +USING ( + EXISTS ( + SELECT 1 FROM sent_dares sd + WHERE sd.id = dare_messages.sent_dare_id + AND sd.couple_id = get_auth_user_couple_id() + ) +); +``` + +--- + +## Preset Duration Options + +App constants (not in database, but used in app/admin): + +| Label | Value (hours) | Display | +|-------|---------------|---------| +| 1 hour | `1` | "1 hour" | +| 6 hours | `6` | "6 hours" | +| 12 hours | `12` | "12 hours" | +| 24 hours | `24` | "24 hours" | +| 3 days | `72` | "3 days" | +| 1 week | `168` | "1 week" | +| No time limit | `null` | "No time limit" | + +--- + +## Edge Functions (Future - App Phase) + +### `send-dare` + +Send a dare to partner. + +**Input:** +```typescript +interface SendDareInput { + dare_id?: string; // null for custom dare + custom_dare_text?: string; // required if dare_id is null + custom_dare_intensity?: number; // 1-5, required if custom + duration_hours?: number; // null = no limit + sender_notes?: string; // optional message +} +``` + +**Logic:** +1. Validate user is in a couple +2. Get partner ID from couple +3. If custom dare, validate user is premium +4. Create `sent_dares` record with status `'pending'` +5. Calculate `expires_at` if duration provided +6. Trigger push notification to recipient + +### `respond-dare` + +Accept or decline a dare. + +**Input:** +```typescript +interface RespondDareInput { + sent_dare_id: string; + action: 'accept' | 'decline'; +} +``` + +**Logic:** +1. Validate user is recipient of the dare +2. Validate status is `'pending'` +3. If accept: set status to `'active'`, set `accepted_at` +4. If decline: set status to `'declined'` +5. Trigger push notification to sender + +### `complete-dare` + +Sender marks dare as complete. + +**Input:** +```typescript +interface CompleteDareInput { + sent_dare_id: string; +} +``` + +**Logic:** +1. Validate user is sender of the dare +2. Validate status is `'active'` +3. Set status to `'completed'`, set `completed_at` +4. Trigger push notification to recipient + +### `cancel-dare` + +Sender cancels a dare. + +**Input:** +```typescript +interface CancelDareInput { + sent_dare_id: string; +} +``` + +**Logic:** +1. Validate user is sender +2. Validate status is `'pending'` or `'active'` +3. Set status to `'cancelled'` +4. Trigger push notification to recipient + +### Background Job: Expire Dares + +Cron job (pg_cron or external) to expire overdue dares: + +```sql +UPDATE sent_dares +SET status = 'expired' +WHERE status = 'active' + AND expires_at IS NOT NULL + AND expires_at < NOW(); +``` + +--- + +## Notifications (Future - App Phase) + +| Event | Recipient | Title | Body | +|-------|-----------|-------|------| +| Dare sent | Partner | "New Dare!" | "{name} sent you a dare" | +| Dare accepted | Sender | "Dare Accepted" | "{name} accepted your dare" | +| Dare declined | Sender | "Dare Declined" | "{name} declined your dare" | +| Dare completed | Recipient | "Dare Complete!" | "Your dare was marked complete" | +| Dare cancelled | Recipient | "Dare Cancelled" | "{name} cancelled the dare" | +| Dare expiring (1hr) | Recipient | "Dare Expiring Soon" | "Your dare expires in 1 hour" | +| Dare expired | Both | "Dare Expired" | "The dare has expired" | + +--- + +## Admin Portal Implementation + +### New Files Structure + +``` +apps/admin/src/ +├── pages/ +│ ├── DarePacksPage.tsx # List all dare packs +│ ├── DarePackDetailPage.tsx # Create/edit dare pack +│ ├── DaresPage.tsx # List dares in a pack +│ └── DareDetailPage.tsx # Create/edit individual dare +├── components/ +│ └── IntensityIndicator.tsx # Shared intensity display (1-5) +└── App.tsx # Add new routes +``` + +### Routes to Add + +```typescript +// In App.tsx +} /> +} /> +} /> +} /> +} /> +} /> +``` + +### Sidebar Navigation Update + +Add under "Content" section: +``` +📦 Content + ├── Question Packs + └── Dare Packs (new) +``` + +### Page Specifications + +#### DarePacksPage + +- **Table columns**: Icon, Name, Description (truncated), Dares Count, Intensity Range, Premium, Public, Actions +- **Actions**: Edit, Delete, View Dares +- **Create button**: Top right +- **Empty state**: "No dare packs yet. Create your first pack!" + +#### DarePackDetailPage + +- **Form fields**: + - Name (text input, required) + - Description (textarea) + - Icon (emoji picker or text input) + - Category (dropdown, optional) + - Is Premium (toggle) + - Is Public (toggle) + - Is Explicit (toggle) + - Sort Order (number input) +- **Read-only display**: Intensity stats (min/max/avg) +- **Actions**: Save, Cancel, Delete (if editing) + +#### DaresPage + +- **Table columns**: Text (truncated), Intensity (visual indicator), Suggested Duration, Actions +- **Actions**: Edit, Delete +- **Create button**: Top right +- **Back link**: To dare pack detail + +#### DareDetailPage + +- **Form fields**: + - Text (textarea, required) + - Intensity (slider 1-5 with labels) + - Suggested Duration (dropdown with preset options + "None") +- **Actions**: Save, Cancel, Delete (if editing) + +--- + +## Implementation Tasks + +### Phase 1: Database Schema (Supabase) + +- [ ] **1.1** Create migration: `dare_status` enum type +- [ ] **1.2** Create migration: `dare_packs` table +- [ ] **1.3** Create migration: `dares` table with intensity trigger +- [ ] **1.4** Create migration: `sent_dares` table +- [ ] **1.5** Create migration: `dare_messages` table +- [ ] **1.6** Create migration: RLS policies for all tables +- [ ] **1.7** Create migration: Stats functions (`get_user_dare_stats`, `get_couple_dare_stats`) +- [ ] **1.8** Create migration: Add tables to realtime publication +- [ ] **1.9** Test migrations in non-prod environment +- [ ] **1.10** Apply migrations to production + +### Phase 2: Admin Portal + +- [ ] **2.1** Create `IntensityIndicator` component (if not exists) +- [ ] **2.2** Create `DarePacksPage` - list view +- [ ] **2.3** Create `DarePackDetailPage` - create/edit form +- [ ] **2.4** Create `DaresPage` - list dares in pack +- [ ] **2.5** Create `DareDetailPage` - create/edit dare form +- [ ] **2.6** Add routes to `App.tsx` +- [ ] **2.7** Update sidebar navigation +- [ ] **2.8** Test full CRUD flow in admin portal + +### Phase 3: Edge Functions (Future - App Phase) + +- [ ] **3.1** Create `send-dare` edge function +- [ ] **3.2** Create `respond-dare` edge function +- [ ] **3.3** Create `complete-dare` edge function +- [ ] **3.4** Create `cancel-dare` edge function +- [ ] **3.5** Set up dare expiry cron job +- [ ] **3.6** Integrate with notification system + +### Phase 4: Mobile App (Future) + +- [ ] **4.1** Create dare packs browsing UI +- [ ] **4.2** Create dare selection/sending flow +- [ ] **4.3** Create incoming dare notification + response UI +- [ ] **4.4** Create active dare tracking UI +- [ ] **4.5** Create dare chat integration +- [ ] **4.6** Create scoreboard/stats display +- [ ] **4.7** Create custom dare creation (premium) +- [ ] **4.8** Add to Zustand stores + +--- + +## Competitor Analysis Notes + +Features observed in competitor apps (Desire, Kindu, Spicer, Lovewick): + +| Feature | In Our Plan | Notes | +|---------|-------------|-------| +| Dare Packs/Categories | ✅ | Themed collections | +| Intensity Levels | ✅ | 1-5 scale, reuses user preference | +| Time Limits | ✅ | Preset options + no limit | +| Points/Scoring | ✅ Simple | Just count, not weighted points | +| Custom Dares | ✅ Premium | Users create their own | +| Decline Option | ✅ | No negative impact | +| Dare History | ✅ | Via `sent_dares` table | +| Chat per Dare | ✅ | `dare_messages` table | +| Proof/Verification | ❌ | Not in v1 (could add photo proof later) | +| Daily/Random Dares | ❌ | Not in v1 (could add "random dare" button) | +| Dare Rewards | ❌ | Not in v1 (could unlock content) | +| Streak Tracking | ❌ | Not in v1 (could add later) | + +### Potential Future Enhancements + +1. **Photo/Video Proof**: Allow recipient to submit proof of completion +2. **Random Dare Button**: "Surprise me" feature that picks a random dare +3. **Daily Dare Suggestions**: Push notification with suggested dare +4. **Streak Tracking**: Consecutive days/weeks with completed dares +5. **Achievements/Badges**: Unlock badges for milestones (10 dares, 50 dares, etc.) +6. **Couple Leaderboard**: Compare with other couples (opt-in) + +--- + +## Open Questions (Resolved) + +| Question | Answer | +|----------|--------| +| Should dare packs use same `categories` table? | Yes, reuse existing categories | +| Can same dare be sent multiple times? | Yes, each is a new instance | +| Bulk import for dares in admin? | Not in v1, can add later | +| Migration target | Non-prod first, then prod | + +--- + +## References + +- Existing schema patterns: `apps/supabase/migrations/20251230000000_v1_initial_schema.sql` +- Question packs structure: `question_packs`, `questions` tables +- Match/messaging pattern: `matches`, `messages` tables +- Admin portal patterns: `apps/admin/src/pages/QuestionPacksPage.tsx`