Add admin pagination and update configs

This commit is contained in:
jjbrunton committed 2026-01-08 21:15:34 +00:00
1 parent b60b72a289
commit aff0bc0888
21 files changed
+1793 -481

No files matched your search

@@ -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 (
<div
className={cn(
'flex flex-col gap-2 text-sm text-muted-foreground sm:flex-row sm:items-center sm:justify-between',
className
)}
>
<div>
{totalCount === 0
? 'No results'
: `Showing ${start} to ${end} of ${totalCount}`}
</div>
<div className="flex flex-wrap items-center gap-2">
{showPageSize && onPageSizeChange && (
<div className="flex items-center gap-2">
<span className="text-xs uppercase tracking-wide text-muted-foreground">Rows</span>
<Select
value={String(pageSize)}
onValueChange={(value) => onPageSizeChange(Number(value))}
>
<SelectTrigger className="h-8 w-[90px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{pageSizeOptions.map((size) => (
<SelectItem key={size} value={String(size)}>
{size}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => onPageChange(Math.max(1, safePage - 1))}
disabled={safePage <= 1}
>
<ChevronLeft className="mr-1 h-4 w-4" />
Prev
</Button>
<span className="text-xs text-muted-foreground">
Page {safePage} of {totalPages}
</span>
<Button
variant="outline"
size="sm"
onClick={() => onPageChange(Math.min(totalPages, safePage + 1))}
disabled={safePage >= totalPages}
>
Next
<ChevronRight className="ml-1 h-4 w-4" />
</Button>
</div>
</div>
</div>
);
}
+2 -2
View File
@@ -86,10 +86,10 @@ export const auditedSupabase = {
/** /**
* Update a single record with audit logging * Update a single record with audit logging
*/ */
async update<T extends { id: string }>( async update<T extends object = Record<string, unknown>>(
table: string, table: string,
id: string, id: string,
updates: Partial<T> updates: Partial<T> | Record<string, unknown>
): Promise<SingleResult<T>> { ): Promise<SingleResult<T>> {
// Fetch old values before update // Fetch old values before update
let oldValues: Record<string, unknown> | null = null; let oldValues: Record<string, unknown> | null = null;
+2 -2
View File
@@ -43,8 +43,8 @@ import { useState, useCallback } from 'react';
* ``` * ```
*/ */
export function useEntityForm< export function useEntityForm<
TFormData extends Record<string, unknown>, TFormData extends object,
TEntity extends Record<string, unknown> = TFormData TEntity extends object = TFormData
>( >(
initialFormData: TFormData, initialFormData: TFormData,
/** /**
+48 -20
View File
@@ -1,7 +1,8 @@
import { useState, useEffect } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { supabase } from '@/config'; import { supabase } from '@/config';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton'; import { Skeleton } from '@/components/ui/skeleton';
import { PaginationControls } from '@/components/ui/pagination';
import { import {
Table, Table,
TableBody, TableBody,
@@ -34,28 +35,42 @@ const actionColors: Record<string, string> = {
export function AuditLogsPage() { export function AuditLogsPage() {
const [logs, setLogs] = useState<AuditLog[]>([]); const [logs, setLogs] = useState<AuditLog[]>([]);
const [loading, setLoading] = useState(true); 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(() => { 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();
}, []); }, [fetchLogs]);
useEffect(() => {
const totalPages = Math.max(1, Math.ceil(totalCount / pageSize));
if (page > totalPages) {
setPage(totalPages);
}
}, [page, pageSize, totalCount]);
if (loading) { if (loading) {
return ( return (
@@ -141,6 +156,19 @@ export function AuditLogsPage() {
</TableBody> </TableBody>
</Table> </Table>
</div> </div>
{logs.length > 0 && (
<PaginationControls
page={page}
pageSize={pageSize}
totalCount={totalCount}
onPageChange={setPage}
onPageSizeChange={(size) => {
setPage(1);
setPageSize(size);
}}
/>
)}
</div> </div>
); );
} }
+118 -81
View File
@@ -3,7 +3,9 @@ import { supabase } from '@/config';
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';
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 { Textarea } from '@/components/ui/textarea';
import { useRealtimeSubscription } from '@/hooks/useRealtimeSubscription'; import { useRealtimeSubscription } from '@/hooks/useRealtimeSubscription';
import { RealtimeStatusIndicator } from '@/components/RealtimeStatusIndicator'; import { RealtimeStatusIndicator } from '@/components/RealtimeStatusIndicator';
@@ -84,9 +86,12 @@ const statusConfig: Record<FeedbackStatus, { label: string; variant: 'default' |
export function FeedbackPage() { export function FeedbackPage() {
const [feedback, setFeedback] = useState<Feedback[]>([]); const [feedback, setFeedback] = useState<Feedback[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [typeFilter, setTypeFilter] = useState<string>('all'); const [typeFilter, setTypeFilter] = useState<string>('all');
const [statusFilter, setStatusFilter] = useState<string>('all'); const [statusFilter, setStatusFilter] = useState<string>('all');
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(25);
// Detail Dialog State // Detail Dialog State
const [selectedFeedback, setSelectedFeedback] = useState<Feedback | null>(null); const [selectedFeedback, setSelectedFeedback] = useState<Feedback | null>(null);
@@ -136,9 +141,14 @@ export function FeedbackPage() {
}, },
}); });
useEffect(() => { useEffect(() => {
fetchFeedback(); fetchFeedback();
}, [fetchFeedback]); }, [fetchFeedback]);
useEffect(() => {
setPage(1);
}, [search, typeFilter, statusFilter]);
const handleOpenDetail = (item: Feedback) => { const handleOpenDetail = (item: Feedback) => {
setSelectedFeedback(item); setSelectedFeedback(item);
@@ -173,19 +183,33 @@ export function FeedbackPage() {
} }
}; };
const filteredFeedback = feedback.filter(item => { const filteredFeedback = feedback.filter(item => {
if (typeFilter !== 'all' && item.type !== typeFilter) return false; if (typeFilter !== 'all' && item.type !== typeFilter) return false;
if (statusFilter !== 'all' && item.status !== statusFilter) return false; if (statusFilter !== 'all' && item.status !== statusFilter) return false;
if (!search) return true; if (!search) return true;
const query = search.toLowerCase(); const query = search.toLowerCase();
return ( return (
item.title.toLowerCase().includes(query) || item.title.toLowerCase().includes(query) ||
item.description.toLowerCase().includes(query) || item.description.toLowerCase().includes(query) ||
item.profile?.name?.toLowerCase().includes(query) || item.profile?.name?.toLowerCase().includes(query) ||
item.profile?.email?.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 getTypeBadge = (type: FeedbackType) => {
const config = feedbackTypeConfig[type]; const config = feedbackTypeConfig[type];
@@ -267,67 +291,80 @@ export function FeedbackPage() {
</div> </div>
{/* Table */} {/* Table */}
<div className="rounded-md border"> <div className="rounded-md border">
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead>Type</TableHead> <TableHead>Type</TableHead>
<TableHead>Title</TableHead> <TableHead>Title</TableHead>
<TableHead>User</TableHead> <TableHead>User</TableHead>
<TableHead>Status</TableHead> <TableHead>Status</TableHead>
<TableHead>Created</TableHead> <TableHead>Created</TableHead>
<TableHead className="w-[100px]">Actions</TableHead> <TableHead className="w-[100px]">Actions</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{loading ? ( {loading ? (
<TableRow> <TableRow>
<TableCell colSpan={6} className="text-center py-8"> <TableCell colSpan={6} className="text-center py-8">
<div className="flex justify-center"><Skeleton className="h-6 w-32" /></div> <div className="flex justify-center"><Skeleton className="h-6 w-32" /></div>
</TableCell> </TableCell>
</TableRow> </TableRow>
) : filteredFeedback.length === 0 ? ( ) : filteredFeedback.length === 0 ? (
<TableRow> <TableRow>
<TableCell colSpan={6} className="text-center py-8 text-muted-foreground"> <TableCell colSpan={6} className="text-center py-8 text-muted-foreground">
{search || typeFilter !== 'all' || statusFilter !== 'all' {search || typeFilter !== 'all' || statusFilter !== 'all'
? 'No feedback matches your filters.' ? 'No feedback matches your filters.'
: 'No feedback submissions yet.'} : 'No feedback submissions yet.'}
</TableCell> </TableCell>
</TableRow> </TableRow>
) : ( ) : (
filteredFeedback.map((item) => ( paginatedFeedback.map((item) => (
<TableRow key={item.id} className="cursor-pointer hover:bg-muted/50" onClick={() => handleOpenDetail(item)}> <TableRow key={item.id} className="cursor-pointer hover:bg-muted/50" onClick={() => handleOpenDetail(item)}>
<TableCell>{getTypeBadge(item.type)}</TableCell> <TableCell>{getTypeBadge(item.type)}</TableCell>
<TableCell> <TableCell>
<div className="max-w-[300px]"> <div className="max-w-[300px]">
<div className="font-medium truncate">{item.title}</div> <div className="font-medium truncate">{item.title}</div>
<div className="text-sm text-muted-foreground truncate"> <div className="text-sm text-muted-foreground truncate">
{item.description.slice(0, 80)} {item.description.slice(0, 80)}
{item.description.length > 80 && '...'} {item.description.length > 80 && '...'}
</div> </div>
</div> </div>
</TableCell> </TableCell>
<TableCell> <TableCell>
<div className="text-sm"> <div className="text-sm">
<div className="font-medium">{item.profile?.name || 'Unknown'}</div> <div className="font-medium">{item.profile?.name || 'Unknown'}</div>
<div className="text-muted-foreground">{item.profile?.email}</div> <div className="text-muted-foreground">{item.profile?.email}</div>
</div> </div>
</TableCell> </TableCell>
<TableCell>{getStatusBadge(item.status)}</TableCell> <TableCell>{getStatusBadge(item.status)}</TableCell>
<TableCell className="text-muted-foreground text-sm"> <TableCell className="text-muted-foreground text-sm">
{format(new Date(item.created_at), 'MMM d, yyyy')} {format(new Date(item.created_at), 'MMM d, yyyy')}
</TableCell> </TableCell>
<TableCell> <TableCell>
<Button variant="ghost" size="sm" onClick={(e) => { e.stopPropagation(); handleOpenDetail(item); }}> <Button variant="ghost" size="sm" onClick={(e) => { e.stopPropagation(); handleOpenDetail(item); }}>
View View
</Button> </Button>
</TableCell> </TableCell>
</TableRow> </TableRow>
)) ))
)} )}
</TableBody> </TableBody>
</Table> </Table>
</div> </div>
{filteredFeedback.length > 0 && (
<PaginationControls
page={page}
pageSize={pageSize}
totalCount={totalCount}
onPageChange={setPage}
onPageSizeChange={(size) => {
setPage(1);
setPageSize(size);
}}
/>
)}
{/* Detail Dialog */} {/* Detail Dialog */}
<Dialog open={!!selectedFeedback} onOpenChange={(open) => !open && setSelectedFeedback(null)}> <Dialog open={!!selectedFeedback} onOpenChange={(open) => !open && setSelectedFeedback(null)}>
+72 -15
View File
@@ -1,9 +1,10 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect, useCallback } from 'react';
import { supabase } from '@/config'; import { supabase } from '@/config';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
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 { Card } from '@/components/ui/card'; import { Card } from '@/components/ui/card';
import { PaginationControls } from '@/components/ui/pagination';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { import {
Table, Table,
@@ -65,10 +66,16 @@ export function FlaggedMessagesPage() {
// AI Flagged state // AI Flagged state
const [aiMessages, setAiMessages] = useState<FlaggedMessage[]>([]); const [aiMessages, setAiMessages] = useState<FlaggedMessage[]>([]);
const [aiLoading, setAiLoading] = useState(true); const [aiLoading, setAiLoading] = useState(true);
const [aiPage, setAiPage] = useState(1);
const [aiPageSize, setAiPageSize] = useState(25);
const [aiTotal, setAiTotal] = useState(0);
// User Reports state // User Reports state
const [userReports, setUserReports] = useState<UserReport[]>([]); const [userReports, setUserReports] = useState<UserReport[]>([]);
const [reportsLoading, setReportsLoading] = useState(true); const [reportsLoading, setReportsLoading] = useState(true);
const [reportsPage, setReportsPage] = useState(1);
const [reportsPageSize, setReportsPageSize] = useState(25);
const [reportsTotal, setReportsTotal] = useState(0);
// Shared state // Shared state
const [selectedMessage, setSelectedMessage] = useState<FlaggedMessage | null>(null); const [selectedMessage, setSelectedMessage] = useState<FlaggedMessage | null>(null);
@@ -82,30 +89,38 @@ export function FlaggedMessagesPage() {
const [describingInline, setDescribingInline] = useState<string | null>(null); const [describingInline, setDescribingInline] = useState<string | null>(null);
// Fetch AI flagged messages // Fetch AI flagged messages
const fetchAiMessages = async () => { const fetchAiMessages = useCallback(async () => {
setAiLoading(true); setAiLoading(true);
try { try {
const { data, error } = await supabase const from = (aiPage - 1) * aiPageSize;
const to = from + aiPageSize - 1;
const { data, error, count } = await supabase
.from('messages') .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') .eq('moderation_status', 'flagged')
.order('created_at', { ascending: false }); .order('created_at', { ascending: false })
.range(from, to);
if (error) throw error; if (error) throw error;
setAiMessages(data || []); setAiMessages(data || []);
setAiTotal(count || 0);
} catch (error) { } catch (error) {
console.error('Failed to load flagged messages:', error); console.error('Failed to load flagged messages:', error);
toast.error('Failed to load messages'); toast.error('Failed to load messages');
} finally { } finally {
setAiLoading(false); setAiLoading(false);
} }
}; }, [aiPage, aiPageSize]);
// Fetch user reports // Fetch user reports
const fetchUserReports = async () => { const fetchUserReports = useCallback(async () => {
setReportsLoading(true); setReportsLoading(true);
try { 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') .from('message_reports')
.select(` .select(`
id, id,
@@ -116,24 +131,40 @@ export function FlaggedMessagesPage() {
created_at, created_at,
message:messages(id, user_id, match_id, content, media_path, media_type), message:messages(id, user_id, match_id, content, media_path, media_type),
reporter_profile:profiles!reporter_id(name, email) reporter_profile:profiles!reporter_id(name, email)
`) `, { count: 'exact' })
.eq('status', 'pending') .eq('status', 'pending')
.order('created_at', { ascending: false }); .order('created_at', { ascending: false })
.range(from, to);
if (error) throw error; if (error) throw error;
setUserReports((data || []) as unknown as UserReport[]); setUserReports((data || []) as unknown as UserReport[]);
setReportsTotal(count || 0);
} catch (error) { } catch (error) {
console.error('Failed to load user reports:', error); console.error('Failed to load user reports:', error);
toast.error('Failed to load reports'); toast.error('Failed to load reports');
} finally { } finally {
setReportsLoading(false); setReportsLoading(false);
} }
}; }, [reportsPage, reportsPageSize]);
useEffect(() => { useEffect(() => {
fetchAiMessages(); fetchAiMessages();
fetchUserReports(); 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 // Custom media fetcher because supabase.functions.invoke is JSON-centric
const fetchDecryptedMedia = async (messageId: string) => { const fetchDecryptedMedia = async (messageId: string) => {
@@ -296,8 +327,8 @@ export function FlaggedMessagesPage() {
if (error) throw error; if (error) throw error;
toast.success('Message marked as safe'); toast.success('Message marked as safe');
setAiMessages(prev => prev.filter(m => m.id !== messageId));
if (selectedMessage?.id === messageId) setSelectedMessage(null); if (selectedMessage?.id === messageId) setSelectedMessage(null);
fetchAiMessages();
} catch (error) { } catch (error) {
toast.error('Failed to update status'); toast.error('Failed to update status');
} finally { } finally {
@@ -316,8 +347,8 @@ export function FlaggedMessagesPage() {
if (error) throw error; if (error) throw error;
toast.success('Report dismissed'); toast.success('Report dismissed');
setUserReports(prev => prev.filter(r => r.id !== reportId));
if (selectedReport?.id === reportId) setSelectedReport(null); if (selectedReport?.id === reportId) setSelectedReport(null);
fetchUserReports();
} catch (error) { } catch (error) {
toast.error('Failed to dismiss report'); toast.error('Failed to dismiss report');
} finally { } finally {
@@ -336,8 +367,8 @@ export function FlaggedMessagesPage() {
if (error) throw error; if (error) throw error;
toast.success('Report marked as reviewed'); toast.success('Report marked as reviewed');
setUserReports(prev => prev.filter(r => r.id !== reportId));
if (selectedReport?.id === reportId) setSelectedReport(null); if (selectedReport?.id === reportId) setSelectedReport(null);
fetchUserReports();
} catch (error) { } catch (error) {
toast.error('Failed to update report'); toast.error('Failed to update report');
} finally { } finally {
@@ -491,6 +522,19 @@ export function FlaggedMessagesPage() {
</TableBody> </TableBody>
</Table> </Table>
</Card> </Card>
{aiTotal > 0 && (
<PaginationControls
page={aiPage}
pageSize={aiPageSize}
totalCount={aiTotal}
onPageChange={setAiPage}
onPageSizeChange={(size) => {
setAiPage(1);
setAiPageSize(size);
}}
/>
)}
</TabsContent> </TabsContent>
<TabsContent value="user-reports" className="space-y-4"> <TabsContent value="user-reports" className="space-y-4">
@@ -579,6 +623,19 @@ export function FlaggedMessagesPage() {
</TableBody> </TableBody>
</Table> </Table>
</Card> </Card>
{reportsTotal > 0 && (
<PaginationControls
page={reportsPage}
pageSize={reportsPageSize}
totalCount={reportsTotal}
onPageChange={setReportsPage}
onPageSizeChange={(size) => {
setReportsPage(1);
setReportsPageSize(size);
}}
/>
)}
</TabsContent> </TabsContent>
</Tabs> </Tabs>
+283 -205
View File
@@ -1,10 +1,13 @@
import { useState, useEffect } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { supabase } from '@/config'; import { supabase } from '@/config';
import { auditedSupabase } from '@/hooks/useAuditedSupabase'; 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';
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 { Switch } from '@/components/ui/switch';
import { import {
Table, Table,
@@ -61,9 +64,13 @@ function generateCode(length: number = 8): string {
} }
export function RedemptionCodesPage() { export function RedemptionCodesPage() {
const [codes, setCodes] = useState<RedemptionCode[]>([]); const [codes, setCodes] = useState<RedemptionCode[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(25);
const [totalCount, setTotalCount] = useState(0);
// Add Code State // Add Code State
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false); const [isAddDialogOpen, setIsAddDialogOpen] = useState(false);
@@ -76,66 +83,114 @@ export function RedemptionCodesPage() {
}); });
// View Redemptions State // View Redemptions State
const [viewingCode, setViewingCode] = useState<RedemptionCode | null>(null); const [viewingCode, setViewingCode] = useState<RedemptionCode | null>(null);
const [redemptions, setRedemptions] = useState<CodeRedemption[]>([]); const [redemptions, setRedemptions] = useState<CodeRedemption[]>([]);
const [loadingRedemptions, setLoadingRedemptions] = useState(false); 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 () => { const fetchCodes = useCallback(async () => {
setLoading(true); setLoading(true);
try { try {
const { data, error } = await supabase const from = (page - 1) * pageSize;
.from('redemption_codes') const to = from + pageSize - 1;
.select('*') const trimmedSearch = search.trim();
.order('created_at', { ascending: false });
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; const fetchRedemptions = useCallback(async (codeId: string) => {
setCodes(data || []); setLoadingRedemptions(true);
} catch (error: any) { try {
console.error('Failed to load codes:', error); const from = (redemptionsPage - 1) * redemptionsPageSize;
toast.error("Failed to load redemption codes"); const to = from + redemptionsPageSize - 1;
} finally {
setLoading(false); const { data: redemptionData, error: redemptionError, count } = await supabase
} .from('code_redemptions')
}; .select('id, user_id, redeemed_at', { count: 'exact' })
.eq('code_id', codeId)
const fetchRedemptions = async (codeId: string) => { .order('redeemed_at', { ascending: false })
setLoadingRedemptions(true); .range(from, to);
try {
const { data: redemptionData, error: redemptionError } = await supabase if (redemptionError) throw redemptionError;
.from('code_redemptions')
.select('id, user_id, redeemed_at') setRedemptionsTotal(count || 0);
.eq('code_id', codeId)
.order('redeemed_at', { ascending: false }); if (redemptionData && redemptionData.length > 0) {
const userIds = redemptionData.map(r => r.user_id);
if (redemptionError) throw redemptionError; const { data: profileData } = await supabase
.from('profiles')
if (redemptionData && redemptionData.length > 0) { .select('id, name, email')
const userIds = redemptionData.map(r => r.user_id); .in('id', userIds);
const { data: profileData } = await supabase
.from('profiles') const joined = redemptionData.map(r => ({
.select('id, name, email') ...r,
.in('id', userIds); profile: profileData?.find(p => p.id === r.user_id)
}));
const joined = redemptionData.map(r => ({ setRedemptions(joined);
...r, } else {
profile: profileData?.find(p => p.id === r.user_id) setRedemptions([]);
})); }
setRedemptions(joined); } catch (error: any) {
} else { console.error('Failed to load redemptions:', error);
setRedemptions([]); toast.error("Failed to load redemptions");
} } finally {
} catch (error: any) { setLoadingRedemptions(false);
console.error('Failed to load redemptions:', error); }
toast.error("Failed to load redemptions"); }, [redemptionsPage, redemptionsPageSize]);
} finally {
setLoadingRedemptions(false); 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 () => { const handleAddCode = async () => {
if (!formData.code.trim()) { if (!formData.code.trim()) {
@@ -224,19 +279,14 @@ export function RedemptionCodesPage() {
setIsAddDialogOpen(true); setIsAddDialogOpen(true);
}; };
const handleViewRedemptions = (code: RedemptionCode) => { const handleViewRedemptions = (code: RedemptionCode) => {
setViewingCode(code); setViewingCode(code);
fetchRedemptions(code.id); setRedemptionsPage(1);
}; };
const filteredCodes = codes.filter(code => { const filteredCodes = codes;
if (!search) return true;
const query = search.toLowerCase();
return (
code.code.toLowerCase().includes(query) ||
code.description?.toLowerCase().includes(query)
);
});
const getStatusBadge = (code: RedemptionCode) => { const getStatusBadge = (code: RedemptionCode) => {
if (!code.is_active) { if (!code.is_active) {
@@ -344,101 +394,115 @@ export function RedemptionCodesPage() {
</div> </div>
</div> </div>
<div className="rounded-md border"> <div className="rounded-md border">
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead>Code</TableHead> <TableHead>Code</TableHead>
<TableHead>Description</TableHead> <TableHead>Description</TableHead>
<TableHead>Usage</TableHead> <TableHead>Usage</TableHead>
<TableHead>Expires</TableHead> <TableHead>Expires</TableHead>
<TableHead>Status</TableHead> <TableHead>Status</TableHead>
<TableHead>Created</TableHead> <TableHead>Created</TableHead>
<TableHead className="w-[150px]">Actions</TableHead> <TableHead className="w-[150px]">Actions</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{loading ? ( {loading ? (
<TableRow> <TableRow>
<TableCell colSpan={7} className="text-center py-8"> <TableCell colSpan={7} className="text-center py-8">
<div className="flex justify-center"><Skeleton className="h-6 w-32" /></div> <div className="flex justify-center"><Skeleton className="h-6 w-32" /></div>
</TableCell> </TableCell>
</TableRow> </TableRow>
) : filteredCodes.length === 0 ? ( ) : filteredCodes.length === 0 ? (
<TableRow> <TableRow>
<TableCell colSpan={7} className="text-center py-8 text-muted-foreground"> <TableCell colSpan={7} className="text-center py-8 text-muted-foreground">
{search ? 'No codes match your search.' : 'No redemption codes yet. Generate your first one!'} {search ? 'No codes match your search.' : 'No redemption codes yet. Generate your first one!'}
</TableCell> </TableCell>
</TableRow> </TableRow>
) : ( ) : (
filteredCodes.map((code) => ( filteredCodes.map((code) => (
<TableRow key={code.id}> <TableRow key={code.id}>
<TableCell> <TableCell>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<code className="font-mono font-medium text-sm bg-muted px-2 py-1 rounded"> <code className="font-mono font-medium text-sm bg-muted px-2 py-1 rounded">
{code.code} {code.code}
</code> </code>
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="h-6 w-6" className="h-6 w-6"
onClick={() => copyToClipboard(code.code)} onClick={() => copyToClipboard(code.code)}
title="Copy code" title="Copy code"
> >
<Copy className="h-3 w-3" /> <Copy className="h-3 w-3" />
</Button> </Button>
</div> </div>
</TableCell> </TableCell>
<TableCell className="text-muted-foreground"> <TableCell className="text-muted-foreground">
{code.description || '—'} {code.description || '—'}
</TableCell> </TableCell>
<TableCell> <TableCell>
<span className="font-medium">{code.current_uses}</span> <span className="font-medium">{code.current_uses}</span>
<span className="text-muted-foreground"> / {code.max_uses}</span> <span className="text-muted-foreground"> / {code.max_uses}</span>
</TableCell> </TableCell>
<TableCell className="text-muted-foreground text-sm"> <TableCell className="text-muted-foreground text-sm">
{code.expires_at {code.expires_at
? format(new Date(code.expires_at), 'MMM d, yyyy HH:mm') ? format(new Date(code.expires_at), 'MMM d, yyyy HH:mm')
: 'Never'} : 'Never'}
</TableCell> </TableCell>
<TableCell> <TableCell>
{getStatusBadge(code)} {getStatusBadge(code)}
</TableCell> </TableCell>
<TableCell className="text-muted-foreground text-sm"> <TableCell className="text-muted-foreground text-sm">
{format(new Date(code.created_at), 'MMM d, yyyy')} {format(new Date(code.created_at), 'MMM d, yyyy')}
</TableCell> </TableCell>
<TableCell> <TableCell>
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
onClick={() => handleViewRedemptions(code)} onClick={() => handleViewRedemptions(code)}
title="View redemptions" title="View redemptions"
> >
<Users className="h-4 w-4" /> <Users className="h-4 w-4" />
</Button> </Button>
<Switch <Switch
checked={code.is_active} checked={code.is_active}
onCheckedChange={() => handleToggleActive(code)} onCheckedChange={() => handleToggleActive(code)}
title={code.is_active ? "Deactivate" : "Activate"} title={code.is_active ? "Deactivate" : "Activate"}
/> />
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
className="text-destructive hover:text-destructive hover:bg-destructive/10" className="text-destructive hover:text-destructive hover:bg-destructive/10"
onClick={() => handleDeleteCode(code)} onClick={() => handleDeleteCode(code)}
title="Delete code" title="Delete code"
> >
<Trash2 className="h-4 w-4" /> <Trash2 className="h-4 w-4" />
</Button> </Button>
</div> </div>
</TableCell> </TableCell>
</TableRow> </TableRow>
)) ))
)} )}
</TableBody> </TableBody>
</Table> </Table>
</div> </div>
{filteredCodes.length > 0 && (
<PaginationControls
page={page}
pageSize={pageSize}
totalCount={totalCount}
onPageChange={setPage}
onPageSizeChange={(size) => {
setPage(1);
setPageSize(size);
}}
/>
)}
{/* View Redemptions Dialog */} {/* View Redemptions Dialog */}
<Dialog open={!!viewingCode} onOpenChange={(open) => !open && setViewingCode(null)}> <Dialog open={!!viewingCode} onOpenChange={(open) => !open && setViewingCode(null)}>
@@ -451,42 +515,56 @@ export function RedemptionCodesPage() {
{viewingCode?.current_uses || 0} of {viewingCode?.max_uses} uses {viewingCode?.current_uses || 0} of {viewingCode?.max_uses} uses
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="max-h-[300px] overflow-y-auto"> <div className="max-h-[300px] overflow-y-auto">
{loadingRedemptions ? ( {loadingRedemptions ? (
<div className="flex justify-center py-8"> <div className="flex justify-center py-8">
<Skeleton className="h-6 w-32" /> <Skeleton className="h-6 w-32" />
</div> </div>
) : redemptions.length === 0 ? ( ) : redemptions.length === 0 ? (
<p className="text-center py-8 text-muted-foreground"> <p className="text-center py-8 text-muted-foreground">
No one has redeemed this code yet. No one has redeemed this code yet.
</p> </p>
) : ( ) : (
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow> <TableRow>
<TableHead>User</TableHead> <TableHead>User</TableHead>
<TableHead>Redeemed At</TableHead> <TableHead>Redeemed At</TableHead>
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{redemptions.map((r) => ( {redemptions.map((r) => (
<TableRow key={r.id}> <TableRow key={r.id}>
<TableCell> <TableCell>
<div> <div>
<div className="font-medium">{r.profile?.name || 'Unknown'}</div> <div className="font-medium">{r.profile?.name || 'Unknown'}</div>
<div className="text-xs text-muted-foreground">{r.profile?.email}</div> <div className="text-xs text-muted-foreground">{r.profile?.email}</div>
</div> </div>
</TableCell> </TableCell>
<TableCell className="text-muted-foreground text-sm"> <TableCell className="text-muted-foreground text-sm">
{format(new Date(r.redeemed_at), 'MMM d, yyyy HH:mm')} {format(new Date(r.redeemed_at), 'MMM d, yyyy HH:mm')}
</TableCell> </TableCell>
</TableRow> </TableRow>
))} ))}
</TableBody> </TableBody>
</Table> </Table>
)} )}
</div> </div>
<DialogFooter>
{redemptionsTotal > 0 && (
<PaginationControls
page={redemptionsPage}
pageSize={redemptionsPageSize}
totalCount={redemptionsTotal}
onPageChange={setRedemptionsPage}
onPageSizeChange={(size) => {
setRedemptionsPage(1);
setRedemptionsPageSize(size);
}}
/>
)}
<DialogFooter>
<Button variant="outline" onClick={() => setViewingCode(null)}>Close</Button> <Button variant="outline" onClick={() => setViewingCode(null)}>Close</Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
@@ -6,6 +6,7 @@ import { Skeleton } from '@/components/ui/skeleton';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { useRealtimeSubscription } from '@/hooks/useRealtimeSubscription'; import { useRealtimeSubscription } from '@/hooks/useRealtimeSubscription';
import { RealtimeStatusIndicator } from '@/components/RealtimeStatusIndicator'; import { RealtimeStatusIndicator } from '@/components/RealtimeStatusIndicator';
import { PaginationControls } from '@/components/ui/pagination';
import { import {
Table, Table,
TableBody, TableBody,
@@ -121,6 +122,18 @@ export function UserActivityPage() {
const [matches, setMatches] = useState<MatchActivity[]>([]); const [matches, setMatches] = useState<MatchActivity[]>([]);
const [messages, setMessages] = useState<MessageActivity[]>([]); const [messages, setMessages] = useState<MessageActivity[]>([]);
const [signups, setSignups] = useState<SignupActivity[]>([]); const [signups, setSignups] = useState<SignupActivity[]>([]);
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({ const [loading, setLoading] = useState({
responses: true, responses: true,
matches: true, matches: true,
@@ -132,7 +145,10 @@ export function UserActivityPage() {
const fetchResponses = useCallback(async () => { const fetchResponses = useCallback(async () => {
setLoading(prev => ({ ...prev, responses: true })); setLoading(prev => ({ ...prev, responses: true }));
try { try {
const { data, error } = await supabase const from = (responsesPage - 1) * responsesPageSize;
const to = from + responsesPageSize - 1;
const { data, error, count } = await supabase
.from('responses') .from('responses')
.select(` .select(`
id, user_id, answer, created_at, id, user_id, answer, created_at,
@@ -141,12 +157,14 @@ export function UserActivityPage() {
id, text, id, text,
pack:question_packs(name) pack:question_packs(name)
) )
`) `, { count: 'exact' })
.order('created_at', { ascending: false }) .order('created_at', { ascending: false })
.limit(50); .range(from, to);
if (error) throw error; if (error) throw error;
setResponsesTotal(count || 0);
// Transform data to match our types (Supabase returns nested objects, not arrays for 1:1 relations) // Transform data to match our types (Supabase returns nested objects, not arrays for 1:1 relations)
const transformed: ResponseActivity[] = (data || []).map((item: any) => ({ const transformed: ResponseActivity[] = (data || []).map((item: any) => ({
id: item.id, id: item.id,
@@ -167,23 +185,28 @@ export function UserActivityPage() {
} finally { } finally {
setLoading(prev => ({ ...prev, responses: false })); setLoading(prev => ({ ...prev, responses: false }));
} }
}, []); }, [responsesPage, responsesPageSize]);
// Fetch matches with couple profiles // Fetch matches with couple profiles
const fetchMatches = useCallback(async () => { const fetchMatches = useCallback(async () => {
setLoading(prev => ({ ...prev, matches: true })); setLoading(prev => ({ ...prev, matches: true }));
try { 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') .from('matches')
.select(` .select(`
id, couple_id, match_type, is_new, created_at, id, couple_id, match_type, is_new, created_at,
question:questions(id, text) question:questions(id, text)
`) `, { count: 'exact' })
.order('created_at', { ascending: false }) .order('created_at', { ascending: false })
.limit(50); .range(from, to);
if (matchesError) throw matchesError; if (matchesError) throw matchesError;
setMatchesTotal(count || 0);
// Get unique couple IDs and fetch profiles // Get unique couple IDs and fetch profiles
const coupleIds = [...new Set((matchesData || []).map((m: any) => m.couple_id))]; const coupleIds = [...new Set((matchesData || []).map((m: any) => m.couple_id))];
if (coupleIds.length > 0) { if (coupleIds.length > 0) {
@@ -223,14 +246,17 @@ export function UserActivityPage() {
} finally { } finally {
setLoading(prev => ({ ...prev, matches: false })); setLoading(prev => ({ ...prev, matches: false }));
} }
}, []); }, [matchesPage, matchesPageSize]);
// Fetch messages // Fetch messages
const fetchMessages = useCallback(async () => { const fetchMessages = useCallback(async () => {
setLoading(prev => ({ ...prev, messages: true })); setLoading(prev => ({ ...prev, messages: true }));
try { try {
const from = (messagesPage - 1) * messagesPageSize;
const to = from + messagesPageSize - 1;
// Fetch messages with match data // Fetch messages with match data
const { data: messagesData, error: messagesError } = await supabase const { data: messagesData, error: messagesError, count } = await supabase
.from('messages') .from('messages')
.select(` .select(`
id, user_id, content, media_path, media_type, created_at, id, user_id, content, media_path, media_type, created_at,
@@ -238,12 +264,14 @@ export function UserActivityPage() {
id, id,
question:questions(text) question:questions(text)
) )
`) `, { count: 'exact' })
.order('created_at', { ascending: false }) .order('created_at', { ascending: false })
.limit(50); .range(from, to);
if (messagesError) throw messagesError; if (messagesError) throw messagesError;
setMessagesTotal(count || 0);
// Get unique user IDs and fetch profiles // Get unique user IDs and fetch profiles
const userIds = [...new Set((messagesData || []).map((m: any) => m.user_id))]; const userIds = [...new Set((messagesData || []).map((m: any) => m.user_id))];
let profilesMap: Record<string, { id: string; name: string | null; email: string | null }> = {}; let profilesMap: Record<string, { id: string; name: string | null; email: string | null }> = {};
@@ -283,27 +311,31 @@ export function UserActivityPage() {
} finally { } finally {
setLoading(prev => ({ ...prev, messages: false })); setLoading(prev => ({ ...prev, messages: false }));
} }
}, []); }, [messagesPage, messagesPageSize]);
// Fetch signups // Fetch signups
const fetchSignups = useCallback(async () => { const fetchSignups = useCallback(async () => {
setLoading(prev => ({ ...prev, signups: true })); setLoading(prev => ({ ...prev, signups: true }));
try { try {
const { data, error } = await supabase const from = (signupsPage - 1) * signupsPageSize;
const to = from + signupsPageSize - 1;
const { data, error, count } = await supabase
.from('profiles') .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 }) .order('created_at', { ascending: false })
.limit(50); .range(from, to);
if (error) throw error; if (error) throw error;
setSignups(data || []); setSignups(data || []);
setSignupsTotal(count || 0);
} catch (error) { } catch (error) {
console.error('Failed to load signups:', error); console.error('Failed to load signups:', error);
toast.error("Failed to load signups"); toast.error("Failed to load signups");
} finally { } finally {
setLoading(prev => ({ ...prev, signups: false })); setLoading(prev => ({ ...prev, signups: false }));
} }
}, []); }, [signupsPage, signupsPageSize]);
// Real-time subscriptions // Real-time subscriptions
const { status: responsesStatus } = useRealtimeSubscription({ const { status: responsesStatus } = useRealtimeSubscription({
@@ -338,6 +370,34 @@ export function UserActivityPage() {
fetchSignups(); fetchSignups();
}, [fetchResponses, fetchMatches, fetchMessages, 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 // Get current realtime status based on active tab
const getCurrentRealtimeStatus = () => { const getCurrentRealtimeStatus = () => {
switch (activeTab) { switch (activeTab) {
@@ -461,6 +521,19 @@ export function UserActivityPage() {
</TableBody> </TableBody>
</Table> </Table>
</div> </div>
{responsesTotal > 0 && (
<PaginationControls
page={responsesPage}
pageSize={responsesPageSize}
totalCount={responsesTotal}
onPageChange={setResponsesPage}
onPageSizeChange={(size) => {
setResponsesPage(1);
setResponsesPageSize(size);
}}
/>
)}
</TabsContent> </TabsContent>
{/* Matches Tab */} {/* Matches Tab */}
@@ -535,6 +608,19 @@ export function UserActivityPage() {
</TableBody> </TableBody>
</Table> </Table>
</div> </div>
{matchesTotal > 0 && (
<PaginationControls
page={matchesPage}
pageSize={matchesPageSize}
totalCount={matchesTotal}
onPageChange={setMatchesPage}
onPageSizeChange={(size) => {
setMatchesPage(1);
setMatchesPageSize(size);
}}
/>
)}
</TabsContent> </TabsContent>
{/* Messages Tab */} {/* Messages Tab */}
@@ -593,6 +679,19 @@ export function UserActivityPage() {
</TableBody> </TableBody>
</Table> </Table>
</div> </div>
{messagesTotal > 0 && (
<PaginationControls
page={messagesPage}
pageSize={messagesPageSize}
totalCount={messagesTotal}
onPageChange={setMessagesPage}
onPageSizeChange={(size) => {
setMessagesPage(1);
setMessagesPageSize(size);
}}
/>
)}
</TabsContent> </TabsContent>
{/* Signups Tab */} {/* Signups Tab */}
@@ -666,6 +765,19 @@ export function UserActivityPage() {
</TableBody> </TableBody>
</Table> </Table>
</div> </div>
{signupsTotal > 0 && (
<PaginationControls
page={signupsPage}
pageSize={signupsPageSize}
totalCount={signupsTotal}
onPageChange={setSignupsPage}
onPageSizeChange={(size) => {
setSignupsPage(1);
setSignupsPageSize(size);
}}
/>
)}
</TabsContent> </TabsContent>
</Tabs> </Tabs>
</div> </div>
+34 -1
View File
@@ -7,6 +7,7 @@ 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';
import { Skeleton } from '@/components/ui/skeleton'; import { Skeleton } from '@/components/ui/skeleton';
import { PaginationControls } from '@/components/ui/pagination';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Checkbox } from '@/components/ui/checkbox'; import { Checkbox } from '@/components/ui/checkbox';
import { import {
@@ -58,6 +59,8 @@ export function AdminsPage() {
const [admins, setAdmins] = useState<AdminUser[]>([]); const [admins, setAdmins] = useState<AdminUser[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(25);
// Add Admin State // Add Admin State
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false); 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 ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@@ -509,7 +529,7 @@ export function AdminsPage() {
</TableCell> </TableCell>
</TableRow> </TableRow>
) : ( ) : (
filteredAdmins.map((admin) => ( paginatedAdmins.map((admin) => (
<TableRow key={admin.user_id}> <TableRow key={admin.user_id}>
<TableCell> <TableCell>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
@@ -590,6 +610,19 @@ export function AdminsPage() {
</Table> </Table>
</div> </div>
{filteredAdmins.length > 0 && (
<PaginationControls
page={page}
pageSize={pageSize}
totalCount={totalCount}
onPageChange={setPage}
onPageSizeChange={(size) => {
setPage(1);
setPageSize(size);
}}
/>
)}
{/* Edit Permissions Dialog */} {/* Edit Permissions Dialog */}
<Dialog open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen}> <Dialog open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen}>
<DialogContent className="sm:max-w-[500px]"> <DialogContent className="sm:max-w-[500px]">
+48 -15
View File
@@ -10,6 +10,7 @@ import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea'; import { Textarea } from '@/components/ui/textarea';
import { IconPicker, IconPreview } from '@/components/ui/icon-picker'; import { IconPicker, IconPreview } from '@/components/ui/icon-picker';
import { Skeleton } from '@/components/ui/skeleton'; import { Skeleton } from '@/components/ui/skeleton';
import { PaginationControls } from '@/components/ui/pagination';
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -53,6 +54,9 @@ export function CategoriesPage() {
const [categories, setCategories] = useState<Category[]>([]); const [categories, setCategories] = useState<Category[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [aiIdeasOpen, setAiIdeasOpen] = useState(false); 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 // Form state using the new hook
const form = useEntityForm<CategoryFormData, Category>( const form = useEntityForm<CategoryFormData, Category>(
@@ -71,25 +75,34 @@ export function CategoriesPage() {
const fetchCategories = useCallback(async () => { const fetchCategories = useCallback(async () => {
setLoading(true); setLoading(true);
try { try {
// Fetch categories with pack count const from = (page - 1) * pageSize;
const { data: cats, error } = await supabase const to = from + pageSize - 1;
const { data: cats, error, count } = await supabase
.from('categories') .from('categories')
.select('*') .select('*', { count: 'exact' })
.order('sort_order', { ascending: true }); .order('sort_order', { ascending: true })
.range(from, to);
if (error) throw error; if (error) throw error;
// Fetch pack counts per category setTotalCount(count || 0);
const { data: packs } = await supabase
.from('question_packs')
.select('category_id');
const categoryIds = (cats || []).map(c => c.id);
const packCounts: Record<string, number> = {}; const packCounts: Record<string, number> = {};
packs?.forEach(p => {
if (p.category_id) { if (categoryIds.length > 0) {
packCounts[p.category_id] = (packCounts[p.category_id] || 0) + 1; 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( setCategories(
(cats || []).map(c => ({ (cats || []).map(c => ({
@@ -103,12 +116,19 @@ export function CategoriesPage() {
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, []); }, [page, pageSize]);
useEffect(() => { useEffect(() => {
fetchCategories(); fetchCategories();
}, [fetchCategories]); }, [fetchCategories]);
useEffect(() => {
const totalPages = Math.max(1, Math.ceil(totalCount / pageSize));
if (page > totalPages) {
setPage(totalPages);
}
}, [page, pageSize, totalCount]);
// ============================================================================= // =============================================================================
// Handlers // Handlers
// ============================================================================= // =============================================================================
@@ -143,7 +163,7 @@ export function CategoriesPage() {
} else { } else {
const { error } = await auditedSupabase.insert('categories', { const { error } = await auditedSupabase.insert('categories', {
...data, ...data,
sort_order: categories.length, sort_order: totalCount,
}); });
if (error) throw error; if (error) throw error;
toast.success('Category created'); toast.success('Category created');
@@ -406,6 +426,19 @@ export function CategoriesPage() {
</div> </div>
)} )}
{categories.length > 0 && (
<PaginationControls
page={page}
pageSize={pageSize}
totalCount={totalCount}
onPageChange={setPage}
onPageSizeChange={(size) => {
setPage(1);
setPageSize(size);
}}
/>
)}
{/* AI Dialog */} {/* AI Dialog */}
<AiGeneratorDialog <AiGeneratorDialog
open={aiIdeasOpen} open={aiIdeasOpen}
+55 -18
View File
@@ -7,6 +7,7 @@ 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 { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton'; import { Skeleton } from '@/components/ui/skeleton';
import { PaginationControls } from '@/components/ui/pagination';
import { Plus, MessageCircle, Pencil, Trash2, Sparkles, Crown, Eye, EyeOff, Flame, Heart, Tags, ChevronUp, ChevronDown } from 'lucide-react'; import { Plus, MessageCircle, Pencil, Trash2, Sparkles, Crown, Eye, EyeOff, Flame, Heart, Tags, ChevronUp, ChevronDown } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { AiGeneratorDialog } from '@/components/ai/AiGeneratorDialog'; import { AiGeneratorDialog } from '@/components/ai/AiGeneratorDialog';
@@ -60,6 +61,9 @@ export function PacksPage() {
const [extractTopicsPack, setExtractTopicsPack] = useState<QuestionPack | null>(null); const [extractTopicsPack, setExtractTopicsPack] = useState<QuestionPack | null>(null);
const [allTopics, setAllTopics] = useState<Topic[]>([]); const [allTopics, setAllTopics] = useState<Topic[]>([]);
const [selectedTopicIds, setSelectedTopicIds] = useState<Set<string>>(new Set()); const [selectedTopicIds, setSelectedTopicIds] = useState<Set<string>>(new Set());
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(12);
const [totalCount, setTotalCount] = useState(0);
// Form state using the hook // Form state using the hook
const form = useEntityForm<PackFormData, QuestionPack>( const form = useEntityForm<PackFormData, QuestionPack>(
@@ -98,30 +102,39 @@ export function PacksPage() {
setCategory(cat); setCategory(cat);
} }
const from = (page - 1) * pageSize;
const to = from + pageSize - 1;
// Fetch packs // Fetch packs
let query = supabase let query = supabase
.from('question_packs') .from('question_packs')
.select('*') .select('*', { count: 'exact' })
.order('sort_order', { ascending: true }); .order('sort_order', { ascending: true })
.range(from, to);
if (categoryId) { if (categoryId) {
query = query.eq('category_id', categoryId); query = query.eq('category_id', categoryId);
} }
const { data: packData, error } = await query; const { data: packData, error, count } = await query;
if (error) throw error; if (error) throw error;
setTotalCount(count || 0);
// Fetch question counts // Fetch question counts
const packIds = (packData || []).map(p => p.id); const packIds = (packData || []).map(p => p.id);
const { data: questions } = await supabase
.from('questions')
.select('pack_id')
.in('pack_id', packIds);
const questionCounts: Record<string, number> = {}; const questionCounts: Record<string, number> = {};
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 // Fetch all topics
const { data: topicsData } = await supabase const { data: topicsData } = await supabase
@@ -131,13 +144,17 @@ export function PacksPage() {
setAllTopics(topicsData || []); setAllTopics(topicsData || []);
// Fetch pack_topics relationships // Fetch pack_topics relationships
const { data: packTopicsData } = await supabase let packTopicsData: any[] = [];
.from('pack_topics') if (packIds.length > 0) {
.select('pack_id, topic_id, topics(id, name)') const { data } = await supabase
.in('pack_id', packIds); .from('pack_topics')
.select('pack_id, topic_id, topics(id, name)')
.in('pack_id', packIds);
packTopicsData = data || [];
}
const packTopicsMap: Record<string, Topic[]> = {}; const packTopicsMap: Record<string, Topic[]> = {};
packTopicsData?.forEach((pt: any) => { packTopicsData.forEach((pt: any) => {
if (!packTopicsMap[pt.pack_id]) { if (!packTopicsMap[pt.pack_id]) {
packTopicsMap[pt.pack_id] = []; packTopicsMap[pt.pack_id] = [];
} }
@@ -159,12 +176,19 @@ export function PacksPage() {
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [categoryId]); }, [categoryId, page, pageSize]);
useEffect(() => { useEffect(() => {
fetchData(); fetchData();
}, [fetchData]); }, [fetchData]);
useEffect(() => {
const totalPages = Math.max(1, Math.ceil(totalCount / pageSize));
if (page > totalPages) {
setPage(totalPages);
}
}, [page, pageSize, totalCount]);
// ============================================================================= // =============================================================================
// Handlers // Handlers
// ============================================================================= // =============================================================================
@@ -211,7 +235,7 @@ export function PacksPage() {
is_public: form.formData.is_public, is_public: form.formData.is_public,
is_explicit: form.formData.is_explicit, is_explicit: form.formData.is_explicit,
category_id: categoryId || null, category_id: categoryId || null,
sort_order: packs.length, sort_order: totalCount,
}) })
.select('id') .select('id')
.single(); .single();
@@ -535,6 +559,19 @@ export function PacksPage() {
))} ))}
</div> </div>
)} )}
{packs.length > 0 && (
<PaginationControls
page={page}
pageSize={pageSize}
totalCount={totalCount}
onPageChange={setPage}
onPageSizeChange={(size) => {
setPage(1);
setPageSize(size);
}}
/>
)}
</div> </div>
); );
} }
+52 -14
View File
@@ -9,6 +9,7 @@ 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';
import { Skeleton } from '@/components/ui/skeleton'; import { Skeleton } from '@/components/ui/skeleton';
import { PaginationControls } from '@/components/ui/pagination';
import { import {
Table, Table,
TableBody, TableBody,
@@ -70,6 +71,9 @@ export function QuestionsPage() {
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set()); const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [bulkDeleting, setBulkDeleting] = useState(false); const [bulkDeleting, setBulkDeleting] = useState(false);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(25);
const [totalCount, setTotalCount] = useState(0);
// Form state // Form state
const [formData, setFormData] = useState<{ const [formData, setFormData] = useState<{
@@ -100,14 +104,19 @@ export function QuestionsPage() {
setPack(packData); setPack(packData);
const from = (page - 1) * pageSize;
const to = from + pageSize - 1;
// Fetch questions // Fetch questions
const { data: questionData, error } = await supabase const { data: questionData, error, count } = await supabase
.from('questions') .from('questions')
.select('*') .select('*', { count: 'exact' })
.eq('pack_id', packId) .eq('pack_id', packId)
.order('created_at', { ascending: true }); .order('created_at', { ascending: true })
.range(from, to);
if (error) throw error; if (error) throw error;
setTotalCount(count || 0);
setQuestions(questionData || []); setQuestions(questionData || []);
} catch (error) { } catch (error) {
toast.error('Failed to load questions'); toast.error('Failed to load questions');
@@ -115,33 +124,49 @@ export function QuestionsPage() {
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [packId]); }, [packId, page, pageSize]);
// Real-time subscription for questions in this pack // Real-time subscription for questions in this pack
const { status: realtimeStatus } = useRealtimeSubscription<Question>({ const { status: realtimeStatus } = useRealtimeSubscription<Question>({
table: 'questions', table: 'questions',
filter: packId ? `pack_id=eq.${packId}` : undefined, filter: packId ? `pack_id=eq.${packId}` : undefined,
enabled: !!packId, enabled: !!packId,
onInsert: useCallback((newQuestion: Question) => { onInsert: useCallback(() => {
setQuestions(prev => [...prev, newQuestion]); fetchData();
}, []), }, [fetchData]),
onUpdate: useCallback(({ new: updated }: { old: Question; new: Question }) => { onUpdate: useCallback(() => {
setQuestions(prev => prev.map(q => q.id === updated.id ? updated : q)); fetchData();
}, []), }, [fetchData]),
onDelete: useCallback((deleted: Question) => { onDelete: useCallback((deleted: Question) => {
setQuestions(prev => prev.filter(q => q.id !== deleted.id));
setSelectedIds(prev => { setSelectedIds(prev => {
const next = new Set(prev); const next = new Set(prev);
next.delete(deleted.id); next.delete(deleted.id);
return next; return next;
}); });
}, []), fetchData();
}, [fetchData]),
}); });
useEffect(() => { useEffect(() => {
fetchData(); fetchData();
}, [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 = () => { const openCreateDialog = () => {
setEditingQuestion(null); setEditingQuestion(null);
setFormData({ text: '', partner_text: '', intensity: 1, allowed_couple_genders: [], target_user_genders: [] }); setFormData({ text: '', partner_text: '', intensity: 1, allowed_couple_genders: [], target_user_genders: [] });
@@ -319,7 +344,7 @@ export function QuestionsPage() {
<RealtimeStatusIndicator status={realtimeStatus} showLabel /> <RealtimeStatusIndicator status={realtimeStatus} showLabel />
</div> </div>
<p className="text-muted-foreground"> <p className="text-muted-foreground">
{questions.length} question{questions.length !== 1 ? 's' : ''} in this pack {totalCount} question{totalCount !== 1 ? 's' : ''} in this pack
</p> </p>
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
@@ -615,7 +640,7 @@ export function QuestionsPage() {
/> />
</TableCell> </TableCell>
<TableCell className="font-medium text-muted-foreground"> <TableCell className="font-medium text-muted-foreground">
{index + 1} {(page - 1) * pageSize + index + 1}
</TableCell> </TableCell>
<TableCell> <TableCell>
<span className="line-clamp-2">{question.text}</span> <span className="line-clamp-2">{question.text}</span>
@@ -689,6 +714,19 @@ export function QuestionsPage() {
</div> </div>
) )
} }
{questions.length > 0 && (
<PaginationControls
page={page}
pageSize={pageSize}
totalCount={totalCount}
onPageChange={setPage}
onPageSizeChange={(size) => {
setPage(1);
setPageSize(size);
}}
/>
)}
</div > </div >
); );
} }
+104 -64
View File
@@ -12,6 +12,7 @@ import { format, isToday, isYesterday } from 'date-fns';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useRealtimeSubscription } from '@/hooks/useRealtimeSubscription'; import { useRealtimeSubscription } from '@/hooks/useRealtimeSubscription';
import { RealtimeStatusIndicator } from '@/components/RealtimeStatusIndicator'; import { RealtimeStatusIndicator } from '@/components/RealtimeStatusIndicator';
import { PaginationControls } from '@/components/ui/pagination';
interface Profile { interface Profile {
id: string; id: string;
@@ -331,79 +332,103 @@ export function MatchChatPage() {
const [match, setMatch] = useState<Match | null>(null); const [match, setMatch] = useState<Match | null>(null);
const [messages, setMessages] = useState<Message[]>([]); const [messages, setMessages] = useState<Message[]>([]);
const [loading, setLoading] = useState(true); 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 // Real-time subscription for messages in this chat
const { status: realtimeStatus } = useRealtimeSubscription<Message>({ const { status: realtimeStatus } = useRealtimeSubscription<Message>({
table: 'messages', table: 'messages',
filter: matchId ? `match_id=eq.${matchId}` : undefined, filter: matchId ? `match_id=eq.${matchId}` : undefined,
enabled: !!matchId, enabled: !!matchId,
onInsert: useCallback((newMessage: Message) => { onInsert: useCallback(() => {
setMessages(prev => [...prev, newMessage]); fetchData();
}, []), }, [fetchData]),
onUpdate: useCallback(({ new: updated }: { old: Message; new: Message }) => { onUpdate: useCallback(() => {
setMessages(prev => prev.map(m => m.id === updated.id ? updated : m)); fetchData();
}, []), }, [fetchData]),
onDelete: useCallback((deleted: Message) => { onDelete: useCallback(() => {
setMessages(prev => prev.filter(m => m.id !== deleted.id)); fetchData();
}, []), }, [fetchData]),
}); });
useEffect(() => { 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(); 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 => { const getProfile = (senderId: string): Profile | null => {
if (senderId === user?.id) return user; if (senderId === user?.id) return user;
@@ -497,7 +522,7 @@ export function MatchChatPage() {
<Card className="min-h-[400px]"> <Card className="min-h-[400px]">
<CardHeader className="py-4 border-b"> <CardHeader className="py-4 border-b">
<CardTitle className="text-sm font-medium"> <CardTitle className="text-sm font-medium">
Messages ({messages.length}) Messages ({totalCount})
</CardTitle> </CardTitle>
</CardHeader> </CardHeader>
<CardContent className="p-4"> <CardContent className="p-4">
@@ -580,6 +605,21 @@ export function MatchChatPage() {
})} })}
</div> </div>
)} )}
{totalCount > 0 && (
<div className="mt-4">
<PaginationControls
page={page}
pageSize={pageSize}
totalCount={totalCount}
onPageChange={setPage}
onPageSizeChange={(size) => {
setPage(1);
setPageSize(size);
}}
/>
</div>
)}
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
+98 -11
View File
@@ -8,6 +8,7 @@ import { useAuth, PERMISSION_KEYS } from '@/contexts/AuthContext';
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';
import { PaginationControls } from '@/components/ui/pagination';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
@@ -237,6 +238,15 @@ export function UserDetailPage() {
const [responses, setResponses] = useState<Response[]>([]); const [responses, setResponses] = useState<Response[]>([]);
const [matches, setMatches] = useState<Match[]>([]); const [matches, setMatches] = useState<Match[]>([]);
const [mediaMessages, setMediaMessages] = useState<any[]>([]); const [mediaMessages, setMediaMessages] = useState<any[]>([]);
const [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 [loading, setLoading] = useState(true);
const canViewResponses = hasPermission(PERMISSION_KEYS.VIEW_RESPONSES); const canViewResponses = hasPermission(PERMISSION_KEYS.VIEW_RESPONSES);
@@ -308,44 +318,121 @@ export function UserDetailPage() {
setPartner(null); 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[]); setResponses((responseData || []) as unknown as Response[]);
setResponsesTotal(responsesCount || 0);
if (profileData?.couple_id) { 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); const matchIds = (matchData || []).map(m => m.id);
if (matchIds.length > 0) { if (matchIds.length > 0) {
const { data: messages } = await supabase.from('messages').select('match_id').in('match_id', matchIds); const { data: messages } = await supabase.from('messages').select('match_id').in('match_id', matchIds);
const messageCounts: Record<string, number> = {}; const messageCounts: Record<string, number> = {};
messages?.forEach(m => { messageCounts[m.match_id] = (messageCounts[m.match_id] || 0) + 1; }); 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[]); 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) { if (mediaError) {
console.error("Error fetching media messages:", mediaError); console.error("Error fetching media messages:", mediaError);
setMediaMessages([]); setMediaMessages([]);
setMediaTotal(0);
} else { } else {
setMediaMessages(mediaMessagesData || []); setMediaMessages(mediaMessagesData || []);
setMediaTotal(mediaCount || 0);
} }
} else { } else {
setMatches([]);
setMediaMessages([]); setMediaMessages([]);
setMediaTotal(0);
} }
} else { } else {
setMatches([]); setMatches([]);
setMatchesTotal(0);
setMediaMessages([]); setMediaMessages([]);
setMediaTotal(0);
} }
} catch (error) { } catch (error) {
console.error('Failed to load user data:', error); console.error('Failed to load user data:', error);
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [userId]); }, [userId, responsesPage, responsesPageSize, matchesPage, matchesPageSize, mediaPage, mediaPageSize]);
const { status: profileStatus } = useRealtimeSubscription<Profile>({ table: 'profiles', filter: userId ? `id=eq.${userId}` : undefined, enabled: !!userId, onUpdate: useCallback(({ new: updated }: { old: Profile; new: Profile }) => { setProfile(updated); }, []) }); const { status: profileStatus } = useRealtimeSubscription<Profile>({ table: 'profiles', filter: userId ? `id=eq.${userId}` : undefined, enabled: !!userId, onUpdate: useCallback(({ new: updated }: { old: Profile; new: Profile }) => { setProfile(updated); }, []) });
const { status: responsesStatus } = useRealtimeSubscription<Response>({ table: 'responses', filter: userId ? `user_id=eq.${userId}` : undefined, enabled: !!userId, onInsert: fetchData, onDelete: fetchData }); const { status: responsesStatus } = useRealtimeSubscription<Response>({ table: 'responses', filter: userId ? `user_id=eq.${userId}` : undefined, enabled: !!userId, onInsert: fetchData, onDelete: fetchData });
useEffect(() => { fetchData(); }, [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 <div className="space-y-6"><Skeleton className="h-32" /><Skeleton className="h-96" /></div>; if (loading) return <div className="space-y-6"><Skeleton className="h-32" /><Skeleton className="h-96" /></div>;
if (!profile) return <div className="flex flex-col items-center justify-center py-12"><p className="text-lg font-medium">User not found</p><Link to="/users"><Button variant="link">Back to users</Button></Link></div>; if (!profile) return <div className="flex flex-col items-center justify-center py-12"><p className="text-lg font-medium">User not found</p><Link to="/users"><Button variant="link">Back to users</Button></Link></div>;
@@ -378,19 +465,19 @@ export function UserDetailPage() {
{/* Tabs */} {/* Tabs */}
<Tabs defaultValue={canViewResponses ? "responses" : canViewMatches ? "matches" : canViewMedia ? "media" : "responses"}> <Tabs defaultValue={canViewResponses ? "responses" : canViewMatches ? "matches" : canViewMedia ? "media" : "responses"}>
<TabsList> <TabsList>
{canViewResponses && <TabsTrigger value="responses">Responses ({responses.length})</TabsTrigger>} {canViewResponses && <TabsTrigger value="responses">Responses ({responsesTotal})</TabsTrigger>}
{canViewMatches && <TabsTrigger value="matches" disabled={!profile.couple_id}>Matches ({matches.length})</TabsTrigger>} {canViewMatches && <TabsTrigger value="matches" disabled={!profile.couple_id}>Matches ({matchesTotal})</TabsTrigger>}
{canViewMedia && <TabsTrigger value="media">Media ({mediaMessages.length})</TabsTrigger>} {canViewMedia && <TabsTrigger value="media">Media ({mediaTotal})</TabsTrigger>}
</TabsList> </TabsList>
{canViewResponses && <TabsContent value="responses" className="mt-4">{responses.length === 0 ? <Card className="flex flex-col items-center justify-center py-12"><p className="text-muted-foreground">No responses yet</p></Card> : <div className="rounded-md border"><Table><TableHeader><TableRow><TableHead>Question</TableHead><TableHead>Pack</TableHead><TableHead className="w-24">Answer</TableHead><TableHead className="w-32">Date</TableHead></TableRow></TableHeader><TableBody>{responses.map((r) => (<TableRow key={r.id}><TableCell className="max-w-md"><span className="line-clamp-2">{r.question.text}</span></TableCell><TableCell className="text-muted-foreground">{r.question.pack?.name || '—'}</TableCell><TableCell><div className="flex items-center gap-2">{answerIcons[r.answer]}<span className="capitalize">{r.answer}</span></div></TableCell><TableCell className="text-muted-foreground text-sm">{r.created_at ? format(new Date(r.created_at), 'MMM d, yyyy') : '—'}</TableCell></TableRow>))}</TableBody></Table></div>}</TabsContent>} {canViewResponses && <TabsContent value="responses" className="mt-4">{responses.length === 0 ? <Card className="flex flex-col items-center justify-center py-12"><p className="text-muted-foreground">No responses yet</p></Card> : <div className="space-y-4"><div className="rounded-md border"><Table><TableHeader><TableRow><TableHead>Question</TableHead><TableHead>Pack</TableHead><TableHead className="w-24">Answer</TableHead><TableHead className="w-32">Date</TableHead></TableRow></TableHeader><TableBody>{responses.map((r) => (<TableRow key={r.id}><TableCell className="max-w-md"><span className="line-clamp-2">{r.question.text}</span></TableCell><TableCell className="text-muted-foreground">{r.question.pack?.name || '—'}</TableCell><TableCell><div className="flex items-center gap-2">{answerIcons[r.answer]}<span className="capitalize">{r.answer}</span></div></TableCell><TableCell className="text-muted-foreground text-sm">{r.created_at ? format(new Date(r.created_at), 'MMM d, yyyy') : '—'}</TableCell></TableRow>))}</TableBody></Table></div><PaginationControls page={responsesPage} pageSize={responsesPageSize} totalCount={responsesTotal} onPageChange={setResponsesPage} onPageSizeChange={(size) => { setResponsesPage(1); setResponsesPageSize(size); }} /></div>}</TabsContent>}
{canViewMatches && <TabsContent value="matches" className="mt-4">{matches.length === 0 ? <Card className="flex flex-col items-center justify-center py-12"><p className="text-muted-foreground">No matches yet</p></Card> : <div className="grid gap-4 md:grid-cols-2">{matches.map((match) => (<Card key={match.id} className="hover:shadow-md transition-shadow"><CardHeader className="pb-2"><div className="flex items-start justify-between"><Badge variant="secondary">{matchTypeLabels[match.match_type]}</Badge>{match.is_new && <Badge variant="default" className="text-xs">New</Badge>}</div><CardDescription className="line-clamp-2 mt-2">{match.question?.text || 'Unknown question'}</CardDescription></CardHeader><CardContent><div className="flex items-center justify-between"><div className="flex items-center gap-1 text-sm text-muted-foreground"><MessageCircle className="h-4 w-4" />{match.message_count} message{match.message_count !== 1 ? 's' : ''}</div>{canViewChats && <Link to={`/users/${userId}/matches/${match.id}`}><Button variant="ghost" size="sm">View Chat<ChevronRight className="ml-1 h-4 w-4" /></Button></Link>}</div></CardContent></Card>))}</div>}</TabsContent>} {canViewMatches && <TabsContent value="matches" className="mt-4">{matches.length === 0 ? <Card className="flex flex-col items-center justify-center py-12"><p className="text-muted-foreground">No matches yet</p></Card> : <div className="space-y-4"><div className="grid gap-4 md:grid-cols-2">{matches.map((match) => (<Card key={match.id} className="hover:shadow-md transition-shadow"><CardHeader className="pb-2"><div className="flex items-start justify-between"><Badge variant="secondary">{matchTypeLabels[match.match_type]}</Badge>{match.is_new && <Badge variant="default" className="text-xs">New</Badge>}</div><CardDescription className="line-clamp-2 mt-2">{match.question?.text || 'Unknown question'}</CardDescription></CardHeader><CardContent><div className="flex items-center justify-between"><div className="flex items-center gap-1 text-sm text-muted-foreground"><MessageCircle className="h-4 w-4" />{match.message_count} message{match.message_count !== 1 ? 's' : ''}</div>{canViewChats && <Link to={`/users/${userId}/matches/${match.id}`}><Button variant="ghost" size="sm">View Chat<ChevronRight className="ml-1 h-4 w-4" /></Button></Link>}</div></CardContent></Card>))}</div><PaginationControls page={matchesPage} pageSize={matchesPageSize} totalCount={matchesTotal} onPageChange={setMatchesPage} onPageSizeChange={(size) => { setMatchesPage(1); setMatchesPageSize(size); }} /></div>}</TabsContent>}
{canViewMedia && <TabsContent value="media" className="mt-4">{mediaMessages.length === 0 ? <Card className="flex flex-col items-center justify-center py-12"><Image className="h-12 w-12 text-muted-foreground mb-4" /><p className="text-muted-foreground">No media uploaded</p></Card> : <div className="space-y-4"><div className="grid gap-4 grid-cols-2 md:grid-cols-3 lg:grid-cols-4">{mediaMessages.map((message) => (<Card key={message.id} className="overflow-hidden"><div className="aspect-square bg-muted relative"> {canViewMedia && <TabsContent value="media" className="mt-4">{mediaMessages.length === 0 ? <Card className="flex flex-col items-center justify-center py-12"><Image className="h-12 w-12 text-muted-foreground mb-4" /><p className="text-muted-foreground">No media uploaded</p></Card> : <div className="space-y-4"><div className="grid gap-4 grid-cols-2 md:grid-cols-3 lg:grid-cols-4">{mediaMessages.map((message) => (<Card key={message.id} className="overflow-hidden"><div className="aspect-square bg-muted relative">
{message.media_type === 'video' ? ( {message.media_type === 'video' ? (
<AdminDecryptedVideo messageId={message.id} /> <AdminDecryptedVideo messageId={message.id} />
) : ( ) : (
<AdminDecryptedImage messageId={message.id} alt={`Media from ${message.created_at}`} /> <AdminDecryptedImage messageId={message.id} alt={`Media from ${message.created_at}`} />
)} )}
</div><CardContent className="p-3"><div className="flex items-center justify-between text-sm"><div className="truncate text-muted-foreground">{message.media_type === 'video' ? 'Video' : 'Image'}</div>{canViewChats && message.match_id && <Link to={`/users/${userId}/matches/${message.match_id}`} className="text-primary hover:underline text-xs">View Chat</Link>}</div><div className="text-xs text-muted-foreground mt-1">{message.created_at ? format(new Date(message.created_at), 'MMM d, yyyy') : '—'}</div></CardContent></Card>))}</div></div>}</TabsContent>} </div><CardContent className="p-3"><div className="flex items-center justify-between text-sm"><div className="truncate text-muted-foreground">{message.media_type === 'video' ? 'Video' : 'Image'}</div>{canViewChats && message.match_id && <Link to={`/users/${userId}/matches/${message.match_id}`} className="text-primary hover:underline text-xs">View Chat</Link>}</div><div className="text-xs text-muted-foreground mt-1">{message.created_at ? format(new Date(message.created_at), 'MMM d, yyyy') : '—'}</div></CardContent></Card>))}</div><PaginationControls page={mediaPage} pageSize={mediaPageSize} totalCount={mediaTotal} onPageChange={setMediaPage} onPageSizeChange={(size) => { setMediaPage(1); setMediaPageSize(size); }} /></div>}</TabsContent>}
</Tabs> </Tabs>
</div> </div>
); );
+36 -4
View File
@@ -5,6 +5,7 @@ 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';
import { Skeleton } from '@/components/ui/skeleton'; import { Skeleton } from '@/components/ui/skeleton';
import { PaginationControls } from '@/components/ui/pagination';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { useRealtimeSubscription } from '@/hooks/useRealtimeSubscription'; import { useRealtimeSubscription } from '@/hooks/useRealtimeSubscription';
import { RealtimeStatusIndicator } from '@/components/RealtimeStatusIndicator'; import { RealtimeStatusIndicator } from '@/components/RealtimeStatusIndicator';
@@ -46,16 +47,20 @@ export function UsersPage() {
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [filteredProfiles, setFilteredProfiles] = useState<Profile[]>([]); const [filteredProfiles, setFilteredProfiles] = useState<Profile[]>([]);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(25);
const fetchProfiles = useCallback(async () => { const fetchProfiles = useCallback(async () => {
setLoading(true); setLoading(true);
try { try {
// Fetch profiles with auth info (includes last_sign_in_at) // 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'); .rpc('get_profiles_with_auth_info');
if (error) throw error; if (error) throw error;
const profilesData = (profilesDataRaw || []) as Profile[];
// Fetch storage usage per user from storage.objects // Fetch storage usage per user from storage.objects
const { data: storageData } = await supabase const { data: storageData } = await supabase
.rpc('get_user_storage_usage'); .rpc('get_user_storage_usage');
@@ -70,7 +75,7 @@ export function UsersPage() {
// Build a map of couple_id -> users with premium in that couple // Build a map of couple_id -> users with premium in that couple
const couplePartners: Record<string, { id: string; is_premium: boolean }[]> = {}; const couplePartners: Record<string, { id: string; is_premium: boolean }[]> = {};
(profilesData || []).forEach(profile => { profilesData.forEach(profile => {
if (profile.couple_id) { if (profile.couple_id) {
if (!couplePartners[profile.couple_id]) { if (!couplePartners[profile.couple_id]) {
couplePartners[profile.couple_id] = []; couplePartners[profile.couple_id] = [];
@@ -83,7 +88,7 @@ export function UsersPage() {
}); });
// Merge storage data and partner premium status with profiles // Merge storage data and partner premium status with profiles
const profilesWithExtras = (profilesData || []).map(profile => { const profilesWithExtras = profilesData.map(profile => {
// Check if partner has premium // Check if partner has premium
let partnerIsPremium = false; let partnerIsPremium = false;
if (profile.couple_id && couplePartners[profile.couple_id]) { if (profile.couple_id && couplePartners[profile.couple_id]) {
@@ -134,6 +139,7 @@ export function UsersPage() {
}, [fetchProfiles]); }, [fetchProfiles]);
useEffect(() => { useEffect(() => {
setPage(1);
if (!search.trim()) { if (!search.trim()) {
setFilteredProfiles(profiles); setFilteredProfiles(profiles);
return; return;
@@ -150,6 +156,19 @@ export function UsersPage() {
); );
}, [search, profiles]); }, [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) { if (loading) {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
@@ -209,7 +228,7 @@ export function UsersPage() {
</TableCell> </TableCell>
</TableRow> </TableRow>
) : ( ) : (
filteredProfiles.map((profile) => ( paginatedProfiles.map((profile) => (
<TableRow key={profile.id}> <TableRow key={profile.id}>
<TableCell> <TableCell>
<Link to={`/users/${profile.id}`} className="flex items-center gap-3 hover:opacity-80 transition-opacity"> <Link to={`/users/${profile.id}`} className="flex items-center gap-3 hover:opacity-80 transition-opacity">
@@ -294,6 +313,19 @@ export function UsersPage() {
</TableBody> </TableBody>
</Table> </Table>
</div> </div>
{filteredProfiles.length > 0 && (
<PaginationControls
page={page}
pageSize={pageSize}
totalCount={totalCount}
onPageChange={setPage}
onPageSizeChange={(size) => {
setPage(1);
setPageSize(size);
}}
/>
)}
</div> </div>
); );
} }
+1 -1
View File
@@ -93,7 +93,7 @@ android {
applicationId 'com.sauci.app' applicationId 'com.sauci.app'
minSdkVersion rootProject.ext.minSdkVersion minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 17 versionCode 21
versionName "1.0.0" versionName "1.0.0"
} }
signingConfigs { signingConfigs {
+1 -1
View File
@@ -176,7 +176,7 @@
"backgroundColor": "#1a1a2e" "backgroundColor": "#1a1a2e"
}, },
"package": "com.sauci.app", "package": "com.sauci.app",
"versionCode": 20, "versionCode": 21,
"googleServicesFile": "./google-services.json", "googleServicesFile": "./google-services.json",
"permissions": [ "permissions": [
"RECEIVE_BOOT_COMPLETED", "RECEIVE_BOOT_COMPLETED",
+3 -2
View File
@@ -3,5 +3,6 @@
NEXT_PUBLIC_POSTHOG_KEY=phc_your_project_key_here NEXT_PUBLIC_POSTHOG_KEY=phc_your_project_key_here
NEXT_PUBLIC_POSTHOG_HOST=https://eu.i.posthog.com NEXT_PUBLIC_POSTHOG_HOST=https://eu.i.posthog.com
# Supabase # Supabase
NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co # Production: https://ckjcrkjpmhqhiucifukx.supabase.co
NEXT_PUBLIC_SUPABASE_URL=https://your-project.supabase.co
+8 -6
View File
@@ -3,12 +3,14 @@ FROM node:20-alpine AS builder
WORKDIR /app WORKDIR /app
# Build args for Next.js public env vars (inlined at build time) # Build args for Next.js public env vars (inlined at build time)
ARG NEXT_PUBLIC_POSTHOG_KEY ARG NEXT_PUBLIC_POSTHOG_KEY
ARG NEXT_PUBLIC_POSTHOG_HOST=https://eu.i.posthog.com 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_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 # Install dependencies
COPY package*.json ./ COPY package*.json ./
+4 -3
View File
@@ -5,9 +5,10 @@ services:
build: build:
context: . context: .
dockerfile: Dockerfile dockerfile: Dockerfile
args: args:
- NEXT_PUBLIC_POSTHOG_KEY=${NEXT_PUBLIC_POSTHOG_KEY} - NEXT_PUBLIC_POSTHOG_KEY=${NEXT_PUBLIC_POSTHOG_KEY}
- NEXT_PUBLIC_POSTHOG_HOST=${NEXT_PUBLIC_POSTHOG_HOST:-https://eu.i.posthog.com} - 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: environment:
- NODE_ENV=production - NODE_ENV=production
- NEXT_TELEMETRY_DISABLED=1 - NEXT_TELEMETRY_DISABLED=1
+599
View File
@@ -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
<Route path="/dare-packs" element={<DarePacksPage />} />
<Route path="/dare-packs/new" element={<DarePackDetailPage />} />
<Route path="/dare-packs/:packId" element={<DarePackDetailPage />} />
<Route path="/dare-packs/:packId/dares" element={<DaresPage />} />
<Route path="/dare-packs/:packId/dares/new" element={<DareDetailPage />} />
<Route path="/dare-packs/:packId/dares/:dareId" element={<DareDetailPage />} />
```
### 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`