mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-11-01 18:19:43 +00:00
feat(backend): add standalone data plane
Replace the Supabase application data plane with the Node/PostgreSQL API, migrate mobile, admin, web, and MCP callers, add controlled cutover tooling, and define hardened Dokploy infrastructure.
This commit is contained in:
1 parent
01bb8225ae
commit
006761d3b0
294 files changed
+16259
-6138
No files matched your search
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { supabase } from '@/config';
|
||||
import { adminData } from '@/lib/adminApi';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { PaginationControls } from '@/components/ui/pagination';
|
||||
@@ -45,7 +45,7 @@ export function AuditLogsPage() {
|
||||
const from = (page - 1) * pageSize;
|
||||
const to = from + pageSize - 1;
|
||||
|
||||
const { data, error, count } = await supabase
|
||||
const { data, error, count } = await adminData
|
||||
.from('audit_logs')
|
||||
.select('*', { count: 'exact' })
|
||||
.order('created_at', { ascending: false })
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useAuth, PERMISSION_KEYS } from '@/contexts/AuthContext';
|
||||
import { supabase } from '@/config';
|
||||
import { adminData, adminRequest } from '@/lib/adminApi';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { LayoutGrid, Users, ClipboardList, ArrowRight, Activity, Target, TrendingUp, Heart, MessageCircle, Bell } from 'lucide-react';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
@@ -43,6 +43,7 @@ function formatFeatureName(featureName: string) {
|
||||
export function DashboardPage() {
|
||||
const { hasPermission } = useAuth();
|
||||
const canViewUsers = hasPermission(PERMISSION_KEYS.VIEW_USERS);
|
||||
const canViewChats = hasPermission(PERMISSION_KEYS.VIEW_CHATS);
|
||||
const [stats, setStats] = useState({
|
||||
categories: 0,
|
||||
packs: 0,
|
||||
@@ -64,10 +65,12 @@ export function DashboardPage() {
|
||||
{ count: questionsCount },
|
||||
{ count: usersCount },
|
||||
] = await Promise.all([
|
||||
supabase.from('categories').select('*', { count: 'exact', head: true }),
|
||||
supabase.from('question_packs').select('*', { count: 'exact', head: true }),
|
||||
supabase.from('questions').select('*', { count: 'exact', head: true }),
|
||||
supabase.from('profiles').select('*', { count: 'exact', head: true }),
|
||||
adminData.from('categories').select('*', { count: 'exact', head: true }),
|
||||
adminData.from('question_packs').select('*', { count: 'exact', head: true }),
|
||||
adminData.from('questions').select('*', { count: 'exact', head: true }),
|
||||
canViewUsers
|
||||
? adminData.from('profiles').select('*', { count: 'exact', head: true })
|
||||
: Promise.resolve({ count: 0 }),
|
||||
]);
|
||||
|
||||
setStats({
|
||||
@@ -85,31 +88,24 @@ export function DashboardPage() {
|
||||
{ data: featureInterests },
|
||||
{ data: recentInterests },
|
||||
] = await Promise.all([
|
||||
supabase
|
||||
.from('messages')
|
||||
.select('created_at')
|
||||
.gte('created_at', startDate.toISOString())
|
||||
.lte('created_at', endDate.toISOString()),
|
||||
supabase
|
||||
.from('feature_interests')
|
||||
.select('created_at, user_id')
|
||||
.gte('created_at', startDate.toISOString())
|
||||
.lte('created_at', endDate.toISOString()),
|
||||
supabase
|
||||
.from('feature_interests')
|
||||
.select('id, user_id, feature_name, created_at')
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(10),
|
||||
canViewChats
|
||||
? adminData.from('messages').select('created_at').gte('created_at', startDate.toISOString()).lte('created_at', endDate.toISOString())
|
||||
: Promise.resolve({ data: [] }),
|
||||
canViewUsers
|
||||
? adminData.from('feature_interests').select('created_at, user_id').gte('created_at', startDate.toISOString()).lte('created_at', endDate.toISOString())
|
||||
: Promise.resolve({ data: [] }),
|
||||
canViewUsers
|
||||
? adminData.from('feature_interests').select('id, user_id, feature_name, created_at').order('created_at', { ascending: false }).limit(10)
|
||||
: Promise.resolve({ data: [] }),
|
||||
]);
|
||||
|
||||
if (canViewUsers) {
|
||||
const { data: countsData, error: countsError } = await supabase.rpc('get_feature_interest_counts');
|
||||
|
||||
if (countsError) {
|
||||
try {
|
||||
const { counts } = await adminRequest<{ counts: FeatureInterestCountsRow[] }>('/v1/admin/feature-interest-counts');
|
||||
setFeatureInterestCounts(counts);
|
||||
} catch (countsError) {
|
||||
console.error('Failed to fetch feature interest counts:', countsError);
|
||||
setFeatureInterestCounts([]);
|
||||
} else {
|
||||
setFeatureInterestCounts((countsData ?? []) as FeatureInterestCountsRow[]);
|
||||
}
|
||||
} else {
|
||||
setFeatureInterestCounts([]);
|
||||
@@ -172,7 +168,7 @@ export function DashboardPage() {
|
||||
if (canViewUsers && recentActivity.length > 0) {
|
||||
const userIds = Array.from(new Set(recentActivity.map(item => item.user_id)));
|
||||
|
||||
const { data: profiles } = await supabase
|
||||
const { data: profiles } = await adminData
|
||||
.from('profiles')
|
||||
.select('id, name, email, avatar_url')
|
||||
.in('id', userIds);
|
||||
@@ -193,11 +189,12 @@ export function DashboardPage() {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [canViewUsers]);
|
||||
}, [canViewChats, canViewUsers]);
|
||||
|
||||
// Real-time subscriptions for dashboard stats (with debouncing)
|
||||
const { status: profilesStatus } = useRealtimeSubscription({
|
||||
table: 'profiles',
|
||||
enabled: canViewUsers,
|
||||
onInsert: fetchStats,
|
||||
onDelete: fetchStats,
|
||||
debounceMs: 2000,
|
||||
@@ -205,6 +202,7 @@ export function DashboardPage() {
|
||||
|
||||
const { status: messagesStatus } = useRealtimeSubscription({
|
||||
table: 'messages',
|
||||
enabled: canViewChats,
|
||||
onInsert: fetchStats,
|
||||
debounceMs: 2000,
|
||||
});
|
||||
|
||||
@@ -1,146 +1,146 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { supabase } from '@/config';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { adminData } from '@/lib/adminApi';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { PaginationControls } from '@/components/ui/pagination';
|
||||
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { useRealtimeSubscription } from '@/hooks/useRealtimeSubscription';
|
||||
import { RealtimeStatusIndicator } from '@/components/RealtimeStatusIndicator';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Search, Bug, Lightbulb, MessageSquare, HelpCircle, ExternalLink, Smartphone, User } from 'lucide-react';
|
||||
import { format } from 'date-fns';
|
||||
import { toast } from 'sonner';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
type FeedbackType = 'bug' | 'feature_request' | 'general' | 'question';
|
||||
type FeedbackStatus = 'new' | 'reviewed' | 'in_progress' | 'resolved' | 'closed';
|
||||
|
||||
interface Feedback {
|
||||
id: string;
|
||||
user_id: string;
|
||||
type: FeedbackType;
|
||||
title: string;
|
||||
description: string;
|
||||
screenshot_url: string | null;
|
||||
device_info: Record<string, unknown>;
|
||||
status: FeedbackStatus;
|
||||
admin_notes: string | null;
|
||||
question_id: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
profile?: {
|
||||
id: string;
|
||||
name: string | null;
|
||||
email: string | null;
|
||||
};
|
||||
question?: {
|
||||
id: string;
|
||||
text: string;
|
||||
pack?: {
|
||||
name: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
const feedbackTypeConfig: Record<FeedbackType, { label: string; icon: React.ReactNode; variant: 'default' | 'secondary' | 'destructive' | 'outline' }> = {
|
||||
bug: { label: 'Bug', icon: <Bug className="h-3 w-3" />, variant: 'destructive' },
|
||||
feature_request: { label: 'Feature', icon: <Lightbulb className="h-3 w-3" />, variant: 'default' },
|
||||
general: { label: 'General', icon: <MessageSquare className="h-3 w-3" />, variant: 'secondary' },
|
||||
question: { label: 'Question', icon: <HelpCircle className="h-3 w-3" />, variant: 'outline' },
|
||||
};
|
||||
|
||||
const statusConfig: Record<FeedbackStatus, { label: string; variant: 'default' | 'secondary' | 'destructive' | 'outline' }> = {
|
||||
new: { label: 'New', variant: 'destructive' },
|
||||
reviewed: { label: 'Reviewed', variant: 'secondary' },
|
||||
in_progress: { label: 'In Progress', variant: 'default' },
|
||||
resolved: { label: 'Resolved', variant: 'outline' },
|
||||
closed: { label: 'Closed', variant: 'outline' },
|
||||
};
|
||||
|
||||
export function FeedbackPage() {
|
||||
const [feedback, setFeedback] = useState<Feedback[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { useRealtimeSubscription } from '@/hooks/useRealtimeSubscription';
|
||||
import { RealtimeStatusIndicator } from '@/components/RealtimeStatusIndicator';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Search, Bug, Lightbulb, MessageSquare, HelpCircle, ExternalLink, Smartphone, User } from 'lucide-react';
|
||||
import { format } from 'date-fns';
|
||||
import { toast } from 'sonner';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
type FeedbackType = 'bug' | 'feature_request' | 'general' | 'question';
|
||||
type FeedbackStatus = 'new' | 'reviewed' | 'in_progress' | 'resolved' | 'closed';
|
||||
|
||||
interface Feedback {
|
||||
id: string;
|
||||
user_id: string;
|
||||
type: FeedbackType;
|
||||
title: string;
|
||||
description: string;
|
||||
screenshot_url: string | null;
|
||||
device_info: Record<string, unknown>;
|
||||
status: FeedbackStatus;
|
||||
admin_notes: string | null;
|
||||
question_id: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
profile?: {
|
||||
id: string;
|
||||
name: string | null;
|
||||
email: string | null;
|
||||
};
|
||||
question?: {
|
||||
id: string;
|
||||
text: string;
|
||||
pack?: {
|
||||
name: string;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
const feedbackTypeConfig: Record<FeedbackType, { label: string; icon: React.ReactNode; variant: 'default' | 'secondary' | 'destructive' | 'outline' }> = {
|
||||
bug: { label: 'Bug', icon: <Bug className="h-3 w-3" />, variant: 'destructive' },
|
||||
feature_request: { label: 'Feature', icon: <Lightbulb className="h-3 w-3" />, variant: 'default' },
|
||||
general: { label: 'General', icon: <MessageSquare className="h-3 w-3" />, variant: 'secondary' },
|
||||
question: { label: 'Question', icon: <HelpCircle className="h-3 w-3" />, variant: 'outline' },
|
||||
};
|
||||
|
||||
const statusConfig: Record<FeedbackStatus, { label: string; variant: 'default' | 'secondary' | 'destructive' | 'outline' }> = {
|
||||
new: { label: 'New', variant: 'destructive' },
|
||||
reviewed: { label: 'Reviewed', variant: 'secondary' },
|
||||
in_progress: { label: 'In Progress', variant: 'default' },
|
||||
resolved: { label: 'Resolved', variant: 'outline' },
|
||||
closed: { label: 'Closed', variant: 'outline' },
|
||||
};
|
||||
|
||||
export function FeedbackPage() {
|
||||
const [feedback, setFeedback] = useState<Feedback[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [search, setSearch] = useState('');
|
||||
const [typeFilter, setTypeFilter] = useState<string>('all');
|
||||
const [statusFilter, setStatusFilter] = useState<string>('all');
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(25);
|
||||
|
||||
|
||||
// Detail Dialog State
|
||||
const [selectedFeedback, setSelectedFeedback] = useState<Feedback | null>(null);
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
const [editStatus, setEditStatus] = useState<FeedbackStatus>('new');
|
||||
const [editNotes, setEditNotes] = useState('');
|
||||
|
||||
const fetchFeedback = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
.from('feedback')
|
||||
.select(`
|
||||
*,
|
||||
profile:profiles!feedback_user_id_fkey(id, name, email),
|
||||
question:questions!feedback_question_id_fkey(id, text, pack:question_packs(name))
|
||||
`)
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
if (error) throw error;
|
||||
setFeedback(data || []);
|
||||
} catch (error: unknown) {
|
||||
console.error('Failed to load feedback:', error);
|
||||
toast.error("Failed to load feedback");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Real-time subscription for feedback table
|
||||
const { status: realtimeStatus } = useRealtimeSubscription<Feedback>({
|
||||
table: 'feedback',
|
||||
onInsert: useCallback(() => {
|
||||
// Refetch to get the full data with profile/question relations
|
||||
fetchFeedback();
|
||||
}, [fetchFeedback]),
|
||||
onUpdate: useCallback(({ new: updated }: { old: Feedback; new: Feedback }) => {
|
||||
setFeedback(prev => prev.map(f => f.id === updated.id ? { ...f, ...updated } : f));
|
||||
}, []),
|
||||
onDelete: useCallback((deleted: Feedback) => {
|
||||
setFeedback(prev => prev.filter(f => f.id !== deleted.id));
|
||||
}, []),
|
||||
insertToast: {
|
||||
enabled: true,
|
||||
message: (payload) => `New ${payload.type.replace('_', ' ')} feedback: "${payload.title}"`,
|
||||
type: 'info',
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
// Detail Dialog State
|
||||
const [selectedFeedback, setSelectedFeedback] = useState<Feedback | null>(null);
|
||||
const [isUpdating, setIsUpdating] = useState(false);
|
||||
const [editStatus, setEditStatus] = useState<FeedbackStatus>('new');
|
||||
const [editNotes, setEditNotes] = useState('');
|
||||
|
||||
const fetchFeedback = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const { data, error } = await adminData
|
||||
.from('feedback')
|
||||
.select(`
|
||||
*,
|
||||
profile:profiles!feedback_user_id_fkey(id, name, email),
|
||||
question:questions!feedback_question_id_fkey(id, text, pack:question_packs(name))
|
||||
`)
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
if (error) throw error;
|
||||
setFeedback(data || []);
|
||||
} catch (error: unknown) {
|
||||
console.error('Failed to load feedback:', error);
|
||||
toast.error("Failed to load feedback");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Real-time subscription for feedback table
|
||||
const { status: realtimeStatus } = useRealtimeSubscription<Feedback>({
|
||||
table: 'feedback',
|
||||
onInsert: useCallback(() => {
|
||||
// Refetch to get the full data with profile/question relations
|
||||
fetchFeedback();
|
||||
}, [fetchFeedback]),
|
||||
onUpdate: useCallback(({ new: updated }: { old: Feedback; new: Feedback }) => {
|
||||
setFeedback(prev => prev.map(f => f.id === updated.id ? { ...f, ...updated } : f));
|
||||
}, []),
|
||||
onDelete: useCallback((deleted: Feedback) => {
|
||||
setFeedback(prev => prev.filter(f => f.id !== deleted.id));
|
||||
}, []),
|
||||
insertToast: {
|
||||
enabled: true,
|
||||
message: (payload) => `New ${payload.type.replace('_', ' ')} feedback: "${payload.title}"`,
|
||||
type: 'info',
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
fetchFeedback();
|
||||
}, [fetchFeedback]);
|
||||
@@ -149,40 +149,40 @@ export function FeedbackPage() {
|
||||
setPage(1);
|
||||
}, [search, typeFilter, statusFilter]);
|
||||
|
||||
|
||||
const handleOpenDetail = (item: Feedback) => {
|
||||
setSelectedFeedback(item);
|
||||
setEditStatus(item.status);
|
||||
setEditNotes(item.admin_notes || '');
|
||||
};
|
||||
|
||||
const handleUpdateFeedback = async () => {
|
||||
if (!selectedFeedback) return;
|
||||
|
||||
setIsUpdating(true);
|
||||
try {
|
||||
const { error } = await supabase
|
||||
.from('feedback')
|
||||
.update({
|
||||
status: editStatus,
|
||||
admin_notes: editNotes.trim() || null,
|
||||
updated_at: new Date().toISOString(),
|
||||
})
|
||||
.eq('id', selectedFeedback.id);
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
toast.success("Feedback updated");
|
||||
setSelectedFeedback(null);
|
||||
fetchFeedback();
|
||||
} catch (error: any) {
|
||||
console.error('Failed to update feedback:', error);
|
||||
toast.error("Failed to update feedback");
|
||||
} finally {
|
||||
setIsUpdating(false);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const handleOpenDetail = (item: Feedback) => {
|
||||
setSelectedFeedback(item);
|
||||
setEditStatus(item.status);
|
||||
setEditNotes(item.admin_notes || '');
|
||||
};
|
||||
|
||||
const handleUpdateFeedback = async () => {
|
||||
if (!selectedFeedback) return;
|
||||
|
||||
setIsUpdating(true);
|
||||
try {
|
||||
const { error } = await adminData
|
||||
.from('feedback')
|
||||
.update({
|
||||
status: editStatus,
|
||||
admin_notes: editNotes.trim() || null,
|
||||
updated_at: new Date().toISOString(),
|
||||
})
|
||||
.eq('id', selectedFeedback.id);
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
toast.success("Feedback updated");
|
||||
setSelectedFeedback(null);
|
||||
fetchFeedback();
|
||||
} catch (error: any) {
|
||||
console.error('Failed to update feedback:', error);
|
||||
toast.error("Failed to update feedback");
|
||||
} finally {
|
||||
setIsUpdating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const filteredFeedback = feedback.filter(item => {
|
||||
if (typeFilter !== 'all' && item.type !== typeFilter) return false;
|
||||
if (statusFilter !== 'all' && item.status !== statusFilter) return false;
|
||||
@@ -210,35 +210,35 @@ export function FeedbackPage() {
|
||||
}
|
||||
}, [page, pageSize, totalCount]);
|
||||
|
||||
|
||||
const getTypeBadge = (type: FeedbackType) => {
|
||||
const config = feedbackTypeConfig[type];
|
||||
return (
|
||||
<Badge variant={config.variant} className="gap-1">
|
||||
{config.icon}
|
||||
{config.label}
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: FeedbackStatus) => {
|
||||
const config = statusConfig[status];
|
||||
return <Badge variant={config.variant}>{config.label}</Badge>;
|
||||
};
|
||||
|
||||
const formatDeviceInfo = (info: Record<string, unknown>) => {
|
||||
if (!info || Object.keys(info).length === 0) return null;
|
||||
|
||||
const lines: string[] = [];
|
||||
if (info.platform) lines.push(`Platform: ${info.platform}`);
|
||||
if (info.osVersion) lines.push(`OS Version: ${info.osVersion}`);
|
||||
if (info.appVersion) lines.push(`App Version: ${info.appVersion}`);
|
||||
if (info.deviceModel) lines.push(`Device: ${info.deviceModel}`);
|
||||
|
||||
return lines.length > 0 ? lines.join('\n') : JSON.stringify(info, null, 2);
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
const getTypeBadge = (type: FeedbackType) => {
|
||||
const config = feedbackTypeConfig[type];
|
||||
return (
|
||||
<Badge variant={config.variant} className="gap-1">
|
||||
{config.icon}
|
||||
{config.label}
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: FeedbackStatus) => {
|
||||
const config = statusConfig[status];
|
||||
return <Badge variant={config.variant}>{config.label}</Badge>;
|
||||
};
|
||||
|
||||
const formatDeviceInfo = (info: Record<string, unknown>) => {
|
||||
if (!info || Object.keys(info).length === 0) return null;
|
||||
|
||||
const lines: string[] = [];
|
||||
if (info.platform) lines.push(`Platform: ${info.platform}`);
|
||||
if (info.osVersion) lines.push(`OS Version: ${info.osVersion}`);
|
||||
if (info.appVersion) lines.push(`App Version: ${info.appVersion}`);
|
||||
if (info.deviceModel) lines.push(`Device: ${info.deviceModel}`);
|
||||
|
||||
return lines.length > 0 ? lines.join('\n') : JSON.stringify(info, null, 2);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
@@ -246,13 +246,13 @@ export function FeedbackPage() {
|
||||
<h1 className="text-3xl font-bold tracking-tight">User Feedback</h1>
|
||||
<RealtimeStatusIndicator status={realtimeStatus} showLabel />
|
||||
</div>
|
||||
<p className="text-muted-foreground">
|
||||
Review and respond to user feedback submissions
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<p className="text-muted-foreground">
|
||||
Review and respond to user feedback submissions
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-center">
|
||||
<div className="relative w-full md:flex-1 md:max-w-sm">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
@@ -267,30 +267,30 @@ export function FeedbackPage() {
|
||||
<SelectTrigger className="w-full md:w-[150px]">
|
||||
<SelectValue placeholder="Type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Types</SelectItem>
|
||||
<SelectItem value="bug">Bug</SelectItem>
|
||||
<SelectItem value="feature_request">Feature Request</SelectItem>
|
||||
<SelectItem value="general">General</SelectItem>
|
||||
<SelectItem value="question">Question</SelectItem>
|
||||
</SelectContent>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Types</SelectItem>
|
||||
<SelectItem value="bug">Bug</SelectItem>
|
||||
<SelectItem value="feature_request">Feature Request</SelectItem>
|
||||
<SelectItem value="general">General</SelectItem>
|
||||
<SelectItem value="question">Question</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger className="w-full md:w-[150px]">
|
||||
<SelectValue placeholder="Status" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Status</SelectItem>
|
||||
<SelectItem value="new">New</SelectItem>
|
||||
<SelectItem value="reviewed">Reviewed</SelectItem>
|
||||
<SelectItem value="in_progress">In Progress</SelectItem>
|
||||
<SelectItem value="resolved">Resolved</SelectItem>
|
||||
<SelectItem value="closed">Closed</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Status</SelectItem>
|
||||
<SelectItem value="new">New</SelectItem>
|
||||
<SelectItem value="reviewed">Reviewed</SelectItem>
|
||||
<SelectItem value="in_progress">In Progress</SelectItem>
|
||||
<SelectItem value="resolved">Resolved</SelectItem>
|
||||
<SelectItem value="closed">Closed</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
@@ -373,131 +373,131 @@ export function FeedbackPage() {
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Detail Dialog */}
|
||||
<Dialog open={!!selectedFeedback} onOpenChange={(open) => !open && setSelectedFeedback(null)}>
|
||||
<DialogContent className="sm:max-w-[600px] max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
{selectedFeedback && getTypeBadge(selectedFeedback.type)}
|
||||
<span className="truncate">{selectedFeedback?.title}</span>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Submitted {selectedFeedback && format(new Date(selectedFeedback.created_at), 'MMMM d, yyyy \'at\' h:mm a')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{selectedFeedback && (
|
||||
<div className="space-y-6">
|
||||
{/* User Info */}
|
||||
<div className="flex items-start gap-3 p-3 bg-muted/50 rounded-lg">
|
||||
<User className="h-5 w-5 text-muted-foreground mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<div className="font-medium">{selectedFeedback.profile?.name || 'Unknown User'}</div>
|
||||
<div className="text-sm text-muted-foreground">{selectedFeedback.profile?.email}</div>
|
||||
{selectedFeedback.profile?.id && (
|
||||
<Link
|
||||
to={`/users/${selectedFeedback.profile.id}`}
|
||||
className="text-sm text-primary hover:underline inline-flex items-center gap-1 mt-1"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
View user profile <ExternalLink className="h-3 w-3" />
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div>
|
||||
<Label className="text-sm font-medium">Description</Label>
|
||||
<p className="mt-1 text-sm whitespace-pre-wrap">{selectedFeedback.description}</p>
|
||||
</div>
|
||||
|
||||
{/* Related Question */}
|
||||
{selectedFeedback.question && (
|
||||
<div className="p-3 bg-muted/50 rounded-lg">
|
||||
<Label className="text-sm font-medium">Related Question</Label>
|
||||
<p className="mt-1 text-sm">{selectedFeedback.question.text}</p>
|
||||
{selectedFeedback.question.pack && (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
From pack: {selectedFeedback.question.pack.name}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Screenshot */}
|
||||
{selectedFeedback.screenshot_url && (
|
||||
<div>
|
||||
<Label className="text-sm font-medium">Screenshot</Label>
|
||||
<a
|
||||
href={selectedFeedback.screenshot_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="mt-1 block"
|
||||
>
|
||||
<img
|
||||
src={selectedFeedback.screenshot_url}
|
||||
alt="Screenshot"
|
||||
className="max-w-full max-h-[200px] rounded border object-contain"
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Device Info */}
|
||||
{selectedFeedback.device_info && Object.keys(selectedFeedback.device_info).length > 0 && (
|
||||
<div className="flex items-start gap-3 p-3 bg-muted/50 rounded-lg">
|
||||
<Smartphone className="h-5 w-5 text-muted-foreground mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<Label className="text-sm font-medium">Device Info</Label>
|
||||
<pre className="mt-1 text-xs text-muted-foreground whitespace-pre-wrap font-mono">
|
||||
{formatDeviceInfo(selectedFeedback.device_info)}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Status Update */}
|
||||
<div className="space-y-4 pt-4 border-t">
|
||||
<div className="grid gap-2">
|
||||
<Label>Status</Label>
|
||||
<Select value={editStatus} onValueChange={(v) => setEditStatus(v as FeedbackStatus)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="new">New</SelectItem>
|
||||
<SelectItem value="reviewed">Reviewed</SelectItem>
|
||||
<SelectItem value="in_progress">In Progress</SelectItem>
|
||||
<SelectItem value="resolved">Resolved</SelectItem>
|
||||
<SelectItem value="closed">Closed</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>Admin Notes</Label>
|
||||
<Textarea
|
||||
value={editNotes}
|
||||
onChange={(e) => setEditNotes(e.target.value)}
|
||||
placeholder="Add internal notes about this feedback..."
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setSelectedFeedback(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleUpdateFeedback} disabled={isUpdating}>
|
||||
{isUpdating ? 'Saving...' : 'Save Changes'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
{/* Detail Dialog */}
|
||||
<Dialog open={!!selectedFeedback} onOpenChange={(open) => !open && setSelectedFeedback(null)}>
|
||||
<DialogContent className="sm:max-w-[600px] max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
{selectedFeedback && getTypeBadge(selectedFeedback.type)}
|
||||
<span className="truncate">{selectedFeedback?.title}</span>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
Submitted {selectedFeedback && format(new Date(selectedFeedback.created_at), 'MMMM d, yyyy \'at\' h:mm a')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{selectedFeedback && (
|
||||
<div className="space-y-6">
|
||||
{/* User Info */}
|
||||
<div className="flex items-start gap-3 p-3 bg-muted/50 rounded-lg">
|
||||
<User className="h-5 w-5 text-muted-foreground mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<div className="font-medium">{selectedFeedback.profile?.name || 'Unknown User'}</div>
|
||||
<div className="text-sm text-muted-foreground">{selectedFeedback.profile?.email}</div>
|
||||
{selectedFeedback.profile?.id && (
|
||||
<Link
|
||||
to={`/users/${selectedFeedback.profile.id}`}
|
||||
className="text-sm text-primary hover:underline inline-flex items-center gap-1 mt-1"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
View user profile <ExternalLink className="h-3 w-3" />
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div>
|
||||
<Label className="text-sm font-medium">Description</Label>
|
||||
<p className="mt-1 text-sm whitespace-pre-wrap">{selectedFeedback.description}</p>
|
||||
</div>
|
||||
|
||||
{/* Related Question */}
|
||||
{selectedFeedback.question && (
|
||||
<div className="p-3 bg-muted/50 rounded-lg">
|
||||
<Label className="text-sm font-medium">Related Question</Label>
|
||||
<p className="mt-1 text-sm">{selectedFeedback.question.text}</p>
|
||||
{selectedFeedback.question.pack && (
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
From pack: {selectedFeedback.question.pack.name}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Screenshot */}
|
||||
{selectedFeedback.screenshot_url && (
|
||||
<div>
|
||||
<Label className="text-sm font-medium">Screenshot</Label>
|
||||
<a
|
||||
href={selectedFeedback.screenshot_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="mt-1 block"
|
||||
>
|
||||
<img
|
||||
src={selectedFeedback.screenshot_url}
|
||||
alt="Screenshot"
|
||||
className="max-w-full max-h-[200px] rounded border object-contain"
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Device Info */}
|
||||
{selectedFeedback.device_info && Object.keys(selectedFeedback.device_info).length > 0 && (
|
||||
<div className="flex items-start gap-3 p-3 bg-muted/50 rounded-lg">
|
||||
<Smartphone className="h-5 w-5 text-muted-foreground mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<Label className="text-sm font-medium">Device Info</Label>
|
||||
<pre className="mt-1 text-xs text-muted-foreground whitespace-pre-wrap font-mono">
|
||||
{formatDeviceInfo(selectedFeedback.device_info)}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Status Update */}
|
||||
<div className="space-y-4 pt-4 border-t">
|
||||
<div className="grid gap-2">
|
||||
<Label>Status</Label>
|
||||
<Select value={editStatus} onValueChange={(v) => setEditStatus(v as FeedbackStatus)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="new">New</SelectItem>
|
||||
<SelectItem value="reviewed">Reviewed</SelectItem>
|
||||
<SelectItem value="in_progress">In Progress</SelectItem>
|
||||
<SelectItem value="resolved">Resolved</SelectItem>
|
||||
<SelectItem value="closed">Closed</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>Admin Notes</Label>
|
||||
<Textarea
|
||||
value={editNotes}
|
||||
onChange={(e) => setEditNotes(e.target.value)}
|
||||
placeholder="Add internal notes about this feedback..."
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setSelectedFeedback(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleUpdateFeedback} disabled={isUpdating}>
|
||||
{isUpdating ? 'Saving...' : 'Save Changes'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { supabase } from '@/config';
|
||||
import { adminBinaryRequest, adminData, adminRequest } from '@/lib/adminApi';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -95,7 +95,7 @@ export function FlaggedMessagesPage() {
|
||||
const from = (aiPage - 1) * aiPageSize;
|
||||
const to = from + aiPageSize - 1;
|
||||
|
||||
const { data, error, count } = await supabase
|
||||
const { data, error, count } = await adminData
|
||||
.from('messages')
|
||||
.select('id, created_at, user_id, match_id, moderation_status, flag_reason, content, media_path, media_type', { count: 'exact' })
|
||||
.eq('moderation_status', 'flagged')
|
||||
@@ -120,7 +120,7 @@ export function FlaggedMessagesPage() {
|
||||
const from = (reportsPage - 1) * reportsPageSize;
|
||||
const to = from + reportsPageSize - 1;
|
||||
|
||||
const { data, error, count } = await supabase
|
||||
const { data, error, count } = await adminData
|
||||
.from('message_reports')
|
||||
.select(`
|
||||
id,
|
||||
@@ -166,24 +166,11 @@ export function FlaggedMessagesPage() {
|
||||
}
|
||||
}, [reportsPage, reportsPageSize, reportsTotal]);
|
||||
|
||||
// Custom media fetcher because supabase.functions.invoke is JSON-centric
|
||||
const fetchDecryptedMedia = async (messageId: string) => {
|
||||
try {
|
||||
const { data: { session } } = await supabase.auth.getSession();
|
||||
if (!session) return null;
|
||||
|
||||
const response = await fetch(`https://ckjcrkjpmhqhiucifukx.supabase.co/functions/v1/admin-decrypt-media`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${session.access_token}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ messageId })
|
||||
const blob = await adminBinaryRequest('/v1/admin/actions/decrypt-media', {
|
||||
method: 'POST', body: { messageId },
|
||||
});
|
||||
|
||||
if (!response.ok) throw new Error('Failed to fetch media');
|
||||
|
||||
const blob = await response.blob();
|
||||
return URL.createObjectURL(blob);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
@@ -199,8 +186,8 @@ export function FlaggedMessagesPage() {
|
||||
setImageDescription(null);
|
||||
|
||||
try {
|
||||
const { data: textData, error: textError } = await supabase.functions.invoke('admin-decrypt-message', {
|
||||
body: { messageId: message.id }
|
||||
const textData = await adminRequest<{ content: string | null }>('/v1/admin/actions/decrypt-message', {
|
||||
method: 'POST', body: { messageId: message.id },
|
||||
});
|
||||
|
||||
let mediaUrl = null;
|
||||
@@ -209,7 +196,7 @@ export function FlaggedMessagesPage() {
|
||||
}
|
||||
|
||||
setDecryptedContent({
|
||||
text: textData?.content || (textError ? 'Error decrypting text' : null),
|
||||
text: textData.content,
|
||||
mediaUrl
|
||||
});
|
||||
|
||||
@@ -233,8 +220,8 @@ export function FlaggedMessagesPage() {
|
||||
setImageDescription(null);
|
||||
|
||||
try {
|
||||
const { data: textData, error: textError } = await supabase.functions.invoke('admin-decrypt-message', {
|
||||
body: { messageId: report.message.id }
|
||||
const textData = await adminRequest<{ content: string | null }>('/v1/admin/actions/decrypt-message', {
|
||||
method: 'POST', body: { messageId: report.message.id },
|
||||
});
|
||||
|
||||
let mediaUrl = null;
|
||||
@@ -243,7 +230,7 @@ export function FlaggedMessagesPage() {
|
||||
}
|
||||
|
||||
setDecryptedContent({
|
||||
text: textData?.content || (textError ? 'Error decrypting text' : null),
|
||||
text: textData.content,
|
||||
mediaUrl
|
||||
});
|
||||
|
||||
@@ -274,27 +261,9 @@ export function FlaggedMessagesPage() {
|
||||
|
||||
setDescribingInline(message.id);
|
||||
try {
|
||||
const { data: { session } } = await supabase.auth.getSession();
|
||||
if (!session) {
|
||||
toast.error('Not authenticated');
|
||||
return;
|
||||
}
|
||||
|
||||
const response = await fetch(`https://ckjcrkjpmhqhiucifukx.supabase.co/functions/v1/admin-decrypt-media`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${session.access_token}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify({ messageId: message.id })
|
||||
const blob = await adminBinaryRequest('/v1/admin/actions/decrypt-media', {
|
||||
method: 'POST', body: { messageId: message.id },
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
toast.error('Failed to decrypt media');
|
||||
return;
|
||||
}
|
||||
|
||||
const blob = await response.blob();
|
||||
const base64 = await new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
@@ -319,7 +288,7 @@ export function FlaggedMessagesPage() {
|
||||
const handleMarkSafe = async (messageId: string) => {
|
||||
setProcessing(messageId);
|
||||
try {
|
||||
const { error } = await supabase
|
||||
const { error } = await adminData
|
||||
.from('messages')
|
||||
.update({ moderation_status: 'safe' })
|
||||
.eq('id', messageId);
|
||||
@@ -339,7 +308,7 @@ export function FlaggedMessagesPage() {
|
||||
const handleDismissReport = async (reportId: string) => {
|
||||
setProcessing(reportId);
|
||||
try {
|
||||
const { error } = await supabase
|
||||
const { error } = await adminData
|
||||
.from('message_reports')
|
||||
.update({ status: 'dismissed', reviewed_at: new Date().toISOString() })
|
||||
.eq('id', reportId);
|
||||
@@ -359,7 +328,7 @@ export function FlaggedMessagesPage() {
|
||||
const handleReviewReport = async (reportId: string) => {
|
||||
setProcessing(reportId);
|
||||
try {
|
||||
const { error } = await supabase
|
||||
const { error } = await adminData
|
||||
.from('message_reports')
|
||||
.update({ status: 'reviewed', reviewed_at: new Date().toISOString() })
|
||||
.eq('id', reportId);
|
||||
|
||||
@@ -1,69 +1,69 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
|
||||
import { supabase } from '@/config';
|
||||
import { auditedSupabase } from '@/hooks/useAuditedSupabase';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { adminData } from '@/lib/adminApi';
|
||||
import { auditedAdminData } from '@/hooks/useAuditedAdminData';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { PaginationControls } from '@/components/ui/pagination';
|
||||
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Search, Ticket, Trash2, Plus, Copy, Users } from 'lucide-react';
|
||||
import { format } from 'date-fns';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface RedemptionCode {
|
||||
id: string;
|
||||
code: string;
|
||||
description: string | null;
|
||||
max_uses: number;
|
||||
current_uses: number;
|
||||
expires_at: string | null;
|
||||
is_active: boolean;
|
||||
created_by: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
interface CodeRedemption {
|
||||
id: string;
|
||||
user_id: string;
|
||||
redeemed_at: string;
|
||||
profile?: {
|
||||
name: string | null;
|
||||
email: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
function generateCode(length: number = 8): string {
|
||||
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; // Removed confusing chars like 0, O, I, 1
|
||||
let result = '';
|
||||
for (let i = 0; i < length; i++) {
|
||||
result += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function RedemptionCodesPage() {
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Search, Ticket, Trash2, Plus, Copy, Users } from 'lucide-react';
|
||||
import { format } from 'date-fns';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface RedemptionCode {
|
||||
id: string;
|
||||
code: string;
|
||||
description: string | null;
|
||||
max_uses: number;
|
||||
current_uses: number;
|
||||
expires_at: string | null;
|
||||
is_active: boolean;
|
||||
created_by: string | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
interface CodeRedemption {
|
||||
id: string;
|
||||
user_id: string;
|
||||
redeemed_at: string;
|
||||
profile?: {
|
||||
name: string | null;
|
||||
email: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
function generateCode(length: number = 8): string {
|
||||
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'; // Removed confusing chars like 0, O, I, 1
|
||||
let result = '';
|
||||
for (let i = 0; i < length; i++) {
|
||||
result += chars.charAt(Math.floor(Math.random() * chars.length));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function RedemptionCodesPage() {
|
||||
const [codes, setCodes] = useState<RedemptionCode[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [search, setSearch] = useState('');
|
||||
@@ -71,18 +71,18 @@ export function RedemptionCodesPage() {
|
||||
const [pageSize, setPageSize] = useState(25);
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
|
||||
|
||||
// Add Code State
|
||||
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [formData, setFormData] = useState({
|
||||
code: '',
|
||||
description: '',
|
||||
max_uses: 1,
|
||||
expires_at: '',
|
||||
});
|
||||
|
||||
// View Redemptions State
|
||||
|
||||
// Add Code State
|
||||
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [formData, setFormData] = useState({
|
||||
code: '',
|
||||
description: '',
|
||||
max_uses: 1,
|
||||
expires_at: '',
|
||||
});
|
||||
|
||||
// View Redemptions State
|
||||
const [viewingCode, setViewingCode] = useState<RedemptionCode | null>(null);
|
||||
const [redemptions, setRedemptions] = useState<CodeRedemption[]>([]);
|
||||
const [loadingRedemptions, setLoadingRedemptions] = useState(false);
|
||||
@@ -90,9 +90,9 @@ export function RedemptionCodesPage() {
|
||||
const [redemptionsPageSize, setRedemptionsPageSize] = useState(10);
|
||||
const [redemptionsTotal, setRedemptionsTotal] = useState(0);
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
const fetchCodes = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
@@ -100,7 +100,7 @@ export function RedemptionCodesPage() {
|
||||
const to = from + pageSize - 1;
|
||||
const trimmedSearch = search.trim();
|
||||
|
||||
let query = supabase
|
||||
let query = adminData
|
||||
.from('redemption_codes')
|
||||
.select('*', { count: 'exact' })
|
||||
.order('created_at', { ascending: false });
|
||||
@@ -122,14 +122,14 @@ export function RedemptionCodesPage() {
|
||||
}
|
||||
}, [page, pageSize, search]);
|
||||
|
||||
|
||||
|
||||
const fetchRedemptions = useCallback(async (codeId: string) => {
|
||||
setLoadingRedemptions(true);
|
||||
try {
|
||||
const from = (redemptionsPage - 1) * redemptionsPageSize;
|
||||
const to = from + redemptionsPageSize - 1;
|
||||
|
||||
const { data: redemptionData, error: redemptionError, count } = await supabase
|
||||
const { data: redemptionData, error: redemptionError, count } = await adminData
|
||||
.from('code_redemptions')
|
||||
.select('id, user_id, redeemed_at', { count: 'exact' })
|
||||
.eq('code_id', codeId)
|
||||
@@ -142,7 +142,7 @@ export function RedemptionCodesPage() {
|
||||
|
||||
if (redemptionData && redemptionData.length > 0) {
|
||||
const userIds = redemptionData.map(r => r.user_id);
|
||||
const { data: profileData } = await supabase
|
||||
const { data: profileData } = await adminData
|
||||
.from('profiles')
|
||||
.select('id, name, email')
|
||||
.in('id', userIds);
|
||||
@@ -191,117 +191,117 @@ export function RedemptionCodesPage() {
|
||||
}
|
||||
}, [redemptionsPage, redemptionsPageSize, redemptionsTotal]);
|
||||
|
||||
|
||||
const handleAddCode = async () => {
|
||||
if (!formData.code.trim()) {
|
||||
toast.error("Please enter a code");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const { error } = await auditedSupabase.insert('redemption_codes', {
|
||||
code: formData.code.toUpperCase().trim(),
|
||||
description: formData.description.trim() || null,
|
||||
max_uses: formData.max_uses,
|
||||
current_uses: 0,
|
||||
expires_at: formData.expires_at || null,
|
||||
is_active: true,
|
||||
});
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
toast.success("Redemption code created");
|
||||
setIsAddDialogOpen(false);
|
||||
resetForm();
|
||||
fetchCodes();
|
||||
} catch (error: any) {
|
||||
console.error('Failed to create code:', error);
|
||||
if (error.message?.includes('duplicate') || error.code === '23505') {
|
||||
toast.error("This code already exists");
|
||||
} else {
|
||||
toast.error(error.message || "Failed to create code");
|
||||
}
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleActive = async (code: RedemptionCode) => {
|
||||
try {
|
||||
const { error } = await auditedSupabase.update('redemption_codes', code.id, {
|
||||
is_active: !code.is_active,
|
||||
});
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
toast.success(code.is_active ? "Code deactivated" : "Code activated");
|
||||
fetchCodes();
|
||||
} catch (error: any) {
|
||||
toast.error("Failed to update code");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteCode = async (code: RedemptionCode) => {
|
||||
if (!confirm(`Are you sure you want to delete the code "${code.code}"? This cannot be undone.`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { error } = await auditedSupabase.delete('redemption_codes', code.id);
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
toast.success("Code deleted");
|
||||
fetchCodes();
|
||||
} catch (error: any) {
|
||||
toast.error("Failed to delete code");
|
||||
}
|
||||
};
|
||||
|
||||
const copyToClipboard = (text: string) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
toast.success("Copied to clipboard");
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
setFormData({
|
||||
code: '',
|
||||
description: '',
|
||||
max_uses: 1,
|
||||
expires_at: '',
|
||||
});
|
||||
};
|
||||
|
||||
const handleOpenAddDialog = () => {
|
||||
resetForm();
|
||||
setFormData(prev => ({ ...prev, code: generateCode() }));
|
||||
setIsAddDialogOpen(true);
|
||||
};
|
||||
|
||||
|
||||
const handleAddCode = async () => {
|
||||
if (!formData.code.trim()) {
|
||||
toast.error("Please enter a code");
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const { error } = await auditedAdminData.insert('redemption_codes', {
|
||||
code: formData.code.toUpperCase().trim(),
|
||||
description: formData.description.trim() || null,
|
||||
max_uses: formData.max_uses,
|
||||
current_uses: 0,
|
||||
expires_at: formData.expires_at || null,
|
||||
is_active: true,
|
||||
});
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
toast.success("Redemption code created");
|
||||
setIsAddDialogOpen(false);
|
||||
resetForm();
|
||||
fetchCodes();
|
||||
} catch (error: any) {
|
||||
console.error('Failed to create code:', error);
|
||||
if (error.message?.includes('duplicate') || error.code === '23505') {
|
||||
toast.error("This code already exists");
|
||||
} else {
|
||||
toast.error(error.message || "Failed to create code");
|
||||
}
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleActive = async (code: RedemptionCode) => {
|
||||
try {
|
||||
const { error } = await auditedAdminData.update('redemption_codes', code.id, {
|
||||
is_active: !code.is_active,
|
||||
});
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
toast.success(code.is_active ? "Code deactivated" : "Code activated");
|
||||
fetchCodes();
|
||||
} catch (error: any) {
|
||||
toast.error("Failed to update code");
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteCode = async (code: RedemptionCode) => {
|
||||
if (!confirm(`Are you sure you want to delete the code "${code.code}"? This cannot be undone.`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { error } = await auditedAdminData.delete('redemption_codes', code.id);
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
toast.success("Code deleted");
|
||||
fetchCodes();
|
||||
} catch (error: any) {
|
||||
toast.error("Failed to delete code");
|
||||
}
|
||||
};
|
||||
|
||||
const copyToClipboard = (text: string) => {
|
||||
navigator.clipboard.writeText(text);
|
||||
toast.success("Copied to clipboard");
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
setFormData({
|
||||
code: '',
|
||||
description: '',
|
||||
max_uses: 1,
|
||||
expires_at: '',
|
||||
});
|
||||
};
|
||||
|
||||
const handleOpenAddDialog = () => {
|
||||
resetForm();
|
||||
setFormData(prev => ({ ...prev, code: generateCode() }));
|
||||
setIsAddDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleViewRedemptions = (code: RedemptionCode) => {
|
||||
setViewingCode(code);
|
||||
setRedemptionsPage(1);
|
||||
};
|
||||
|
||||
|
||||
|
||||
const filteredCodes = codes;
|
||||
|
||||
|
||||
const getStatusBadge = (code: RedemptionCode) => {
|
||||
if (!code.is_active) {
|
||||
return <Badge variant="secondary">Inactive</Badge>;
|
||||
}
|
||||
if (code.expires_at && new Date(code.expires_at) < new Date()) {
|
||||
return <Badge variant="destructive">Expired</Badge>;
|
||||
}
|
||||
if (code.current_uses >= code.max_uses) {
|
||||
return <Badge variant="outline">Exhausted</Badge>;
|
||||
}
|
||||
return <Badge variant="default">Active</Badge>;
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
const getStatusBadge = (code: RedemptionCode) => {
|
||||
if (!code.is_active) {
|
||||
return <Badge variant="secondary">Inactive</Badge>;
|
||||
}
|
||||
if (code.expires_at && new Date(code.expires_at) < new Date()) {
|
||||
return <Badge variant="destructive">Expired</Badge>;
|
||||
}
|
||||
if (code.current_uses >= code.max_uses) {
|
||||
return <Badge variant="outline">Exhausted</Badge>;
|
||||
}
|
||||
return <Badge variant="default">Active</Badge>;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div>
|
||||
@@ -317,9 +317,9 @@ export function RedemptionCodesPage() {
|
||||
placeholder="Search codes..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<Dialog open={isAddDialogOpen} onOpenChange={setIsAddDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button onClick={handleOpenAddDialog} className="w-full sm:w-auto">
|
||||
@@ -327,14 +327,14 @@ export function RedemptionCodesPage() {
|
||||
Generate Code
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Generate Redemption Code</DialogTitle>
|
||||
<DialogDescription>
|
||||
Create a new code that users can redeem for premium access.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Generate Redemption Code</DialogTitle>
|
||||
<DialogDescription>
|
||||
Create a new code that users can redeem for premium access.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-2">
|
||||
<Label>Code</Label>
|
||||
<div className="flex flex-col gap-2 sm:flex-row">
|
||||
@@ -343,57 +343,57 @@ export function RedemptionCodesPage() {
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, code: e.target.value.toUpperCase() }))}
|
||||
placeholder="e.g., PREMIUM2024"
|
||||
className="font-mono"
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => setFormData(prev => ({ ...prev, code: generateCode() }))}
|
||||
title="Generate random code"
|
||||
>
|
||||
<Ticket className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>Description (optional)</Label>
|
||||
<Input
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, description: e.target.value }))}
|
||||
placeholder="e.g., Early adopter reward"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>Max Uses</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={formData.max_uses}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, max_uses: parseInt(e.target.value) || 1 }))}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
How many times this code can be redeemed
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>Expires At (optional)</Label>
|
||||
<Input
|
||||
type="datetime-local"
|
||||
value={formData.expires_at}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, expires_at: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setIsAddDialogOpen(false)}>Cancel</Button>
|
||||
<Button onClick={handleAddCode} disabled={!formData.code.trim() || isSubmitting}>
|
||||
{isSubmitting ? 'Creating...' : 'Create Code'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
/>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => setFormData(prev => ({ ...prev, code: generateCode() }))}
|
||||
title="Generate random code"
|
||||
>
|
||||
<Ticket className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>Description (optional)</Label>
|
||||
<Input
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, description: e.target.value }))}
|
||||
placeholder="e.g., Early adopter reward"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>Max Uses</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={formData.max_uses}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, max_uses: parseInt(e.target.value) || 1 }))}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
How many times this code can be redeemed
|
||||
</p>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>Expires At (optional)</Label>
|
||||
<Input
|
||||
type="datetime-local"
|
||||
value={formData.expires_at}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, expires_at: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setIsAddDialogOpen(false)}>Cancel</Button>
|
||||
<Button onClick={handleAddCode} disabled={!formData.code.trim() || isSubmitting}>
|
||||
{isSubmitting ? 'Creating...' : 'Create Code'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
@@ -545,18 +545,18 @@ export function RedemptionCodesPage() {
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
{/* View Redemptions Dialog */}
|
||||
<Dialog open={!!viewingCode} onOpenChange={(open) => !open && setViewingCode(null)}>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
Redemptions for <code className="font-mono bg-muted px-2 py-0.5 rounded">{viewingCode?.code}</code>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{viewingCode?.current_uses || 0} of {viewingCode?.max_uses} uses
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{/* View Redemptions Dialog */}
|
||||
<Dialog open={!!viewingCode} onOpenChange={(open) => !open && setViewingCode(null)}>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
Redemptions for <code className="font-mono bg-muted px-2 py-0.5 rounded">{viewingCode?.code}</code>
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{viewingCode?.current_uses || 0} of {viewingCode?.max_uses} uses
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="max-h-[300px] overflow-y-auto">
|
||||
{loadingRedemptions ? (
|
||||
<div className="flex justify-center py-8">
|
||||
@@ -607,10 +607,10 @@ export function RedemptionCodesPage() {
|
||||
)}
|
||||
<DialogFooter>
|
||||
|
||||
<Button variant="outline" onClick={() => setViewingCode(null)}>Close</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
<Button variant="outline" onClick={() => setViewingCode(null)}>Close</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,386 +1,386 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { supabase } from '@/config';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Target, Users, MessageSquare, Flame, Heart, Smile, Shield, TrendingUp, PieChart } from 'lucide-react';
|
||||
import { Bar, BarChart, ResponsiveContainer, XAxis, YAxis, Tooltip, Cell, Pie, PieChart as RechartsPieChart } from 'recharts';
|
||||
|
||||
type UsageReason = 'improve_communication' | 'spice_up_intimacy' | 'deeper_connection' | 'have_fun' | 'strengthen_relationship';
|
||||
|
||||
interface UsageReasonStat {
|
||||
reason: UsageReason | null;
|
||||
count: number;
|
||||
percentage: number;
|
||||
}
|
||||
|
||||
interface GenderStat {
|
||||
gender: string | null;
|
||||
count: number;
|
||||
percentage: number;
|
||||
}
|
||||
|
||||
const usageReasonConfig: Record<UsageReason, { label: string; icon: React.ReactNode; color: string }> = {
|
||||
improve_communication: {
|
||||
label: 'Improve communication',
|
||||
icon: <MessageSquare className="h-5 w-5" />,
|
||||
color: '#3b82f6',
|
||||
},
|
||||
spice_up_intimacy: {
|
||||
label: 'Spice up intimacy',
|
||||
icon: <Flame className="h-5 w-5" />,
|
||||
color: '#ef4444',
|
||||
},
|
||||
deeper_connection: {
|
||||
label: 'Build deeper connection',
|
||||
icon: <Heart className="h-5 w-5" />,
|
||||
color: '#ec4899',
|
||||
},
|
||||
have_fun: {
|
||||
label: 'Have fun together',
|
||||
icon: <Smile className="h-5 w-5" />,
|
||||
color: '#f59e0b',
|
||||
},
|
||||
strengthen_relationship: {
|
||||
label: 'Strengthen relationship',
|
||||
icon: <Shield className="h-5 w-5" />,
|
||||
color: '#10b981',
|
||||
},
|
||||
};
|
||||
|
||||
const genderConfig: Record<string, { label: string; color: string }> = {
|
||||
male: { label: 'Male', color: '#3b82f6' },
|
||||
female: { label: 'Female', color: '#ec4899' },
|
||||
'non-binary': { label: 'Non-binary', color: '#8b5cf6' },
|
||||
'prefer-not-to-say': { label: 'Prefer not to say', color: '#6b7280' },
|
||||
};
|
||||
|
||||
export function UsageInsightsPage() {
|
||||
const [usageStats, setUsageStats] = useState<UsageReasonStat[]>([]);
|
||||
const [genderStats, setGenderStats] = useState<GenderStat[]>([]);
|
||||
const [totalUsers, setTotalUsers] = useState(0);
|
||||
const [onboardedUsers, setOnboardedUsers] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchStats = async () => {
|
||||
try {
|
||||
// Get total users count
|
||||
const { count: total } = await supabase
|
||||
.from('profiles')
|
||||
.select('*', { count: 'exact', head: true });
|
||||
|
||||
setTotalUsers(total || 0);
|
||||
|
||||
// Get onboarded users count
|
||||
const { count: onboarded } = await supabase
|
||||
.from('profiles')
|
||||
.select('*', { count: 'exact', head: true })
|
||||
.eq('onboarding_completed', true);
|
||||
|
||||
setOnboardedUsers(onboarded || 0);
|
||||
|
||||
// Get usage reason breakdown
|
||||
const { data: usageData } = await supabase
|
||||
.from('profiles')
|
||||
.select('usage_reason')
|
||||
.not('usage_reason', 'is', null);
|
||||
|
||||
const usageCounts: Record<string, number> = {};
|
||||
usageData?.forEach(profile => {
|
||||
const reason = profile.usage_reason as string;
|
||||
usageCounts[reason] = (usageCounts[reason] || 0) + 1;
|
||||
});
|
||||
|
||||
const totalWithReason = Object.values(usageCounts).reduce((a, b) => a + b, 0);
|
||||
const usageStatsArray: UsageReasonStat[] = Object.entries(usageCounts)
|
||||
.map(([reason, count]) => ({
|
||||
reason: reason as UsageReason,
|
||||
count,
|
||||
percentage: totalWithReason > 0 ? Math.round((count / totalWithReason) * 100) : 0,
|
||||
}))
|
||||
.sort((a, b) => b.count - a.count);
|
||||
|
||||
setUsageStats(usageStatsArray);
|
||||
|
||||
// Get gender breakdown
|
||||
const { data: genderData } = await supabase
|
||||
.from('profiles')
|
||||
.select('gender')
|
||||
.not('gender', 'is', null);
|
||||
|
||||
const genderCounts: Record<string, number> = {};
|
||||
genderData?.forEach(profile => {
|
||||
const gender = profile.gender as string;
|
||||
genderCounts[gender] = (genderCounts[gender] || 0) + 1;
|
||||
});
|
||||
|
||||
const totalWithGender = Object.values(genderCounts).reduce((a, b) => a + b, 0);
|
||||
const genderStatsArray: GenderStat[] = Object.entries(genderCounts)
|
||||
.map(([gender, count]) => ({
|
||||
gender,
|
||||
count,
|
||||
percentage: totalWithGender > 0 ? Math.round((count / totalWithGender) * 100) : 0,
|
||||
}))
|
||||
.sort((a, b) => b.count - a.count);
|
||||
|
||||
setGenderStats(genderStatsArray);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch usage stats:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchStats();
|
||||
}, []);
|
||||
|
||||
const chartData = usageStats.map(stat => ({
|
||||
name: stat.reason ? usageReasonConfig[stat.reason]?.label || stat.reason : 'Unknown',
|
||||
value: stat.count,
|
||||
color: stat.reason ? usageReasonConfig[stat.reason]?.color : '#6b7280',
|
||||
}));
|
||||
|
||||
const pieData = genderStats.map(stat => ({
|
||||
name: stat.gender ? genderConfig[stat.gender]?.label || stat.gender : 'Unknown',
|
||||
value: stat.count,
|
||||
color: stat.gender ? genderConfig[stat.gender]?.color : '#6b7280',
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight flex items-center gap-2">
|
||||
<Target className="h-8 w-8 text-primary" />
|
||||
Usage Insights
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Understand why users are using Sauci based on onboarding responses
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Quick Stats */}
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Total Users</CardTitle>
|
||||
<Users className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<Skeleton className="h-8 w-16" />
|
||||
) : (
|
||||
<div className="text-2xl font-bold">{totalUsers.toLocaleString()}</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Completed Onboarding</CardTitle>
|
||||
<TrendingUp className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<Skeleton className="h-8 w-16" />
|
||||
) : (
|
||||
<>
|
||||
<div className="text-2xl font-bold">{onboardedUsers.toLocaleString()}</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{totalUsers > 0 ? Math.round((onboardedUsers / totalUsers) * 100) : 0}% of total users
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Response Rate</CardTitle>
|
||||
<PieChart className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<Skeleton className="h-8 w-16" />
|
||||
) : (
|
||||
<>
|
||||
<div className="text-2xl font-bold">
|
||||
{usageStats.reduce((sum, s) => sum + s.count, 0).toLocaleString()}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
users provided usage reason
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
{/* Usage Reasons Chart */}
|
||||
<Card className="col-span-1">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Target className="h-5 w-5 text-primary" />
|
||||
Why Users Join Sauci
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Distribution of user motivations from onboarding
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<Skeleton className="h-[300px] w-full" />
|
||||
) : usageStats.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-[300px] text-muted-foreground">
|
||||
No usage data available yet
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-[300px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={chartData} layout="vertical" margin={{ left: 20 }}>
|
||||
<XAxis type="number" />
|
||||
<YAxis
|
||||
dataKey="name"
|
||||
type="category"
|
||||
width={150}
|
||||
tick={{ fontSize: 12 }}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
borderRadius: '8px',
|
||||
border: '1px solid #e2e8f0',
|
||||
boxShadow: '0 4px 6px -1px rgb(0 0 0 / 0.1)',
|
||||
}}
|
||||
/>
|
||||
<Bar dataKey="value" radius={[0, 4, 4, 0]}>
|
||||
{chartData.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={entry.color} />
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Gender Distribution */}
|
||||
<Card className="col-span-1">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Users className="h-5 w-5 text-primary" />
|
||||
Gender Distribution
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
User demographics from onboarding
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<Skeleton className="h-[300px] w-full" />
|
||||
) : genderStats.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-[300px] text-muted-foreground">
|
||||
No gender data available yet
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-[300px] flex items-center justify-center">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<RechartsPieChart>
|
||||
<Pie
|
||||
data={pieData}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={60}
|
||||
outerRadius={100}
|
||||
paddingAngle={2}
|
||||
dataKey="value"
|
||||
label={({ name, percent }) =>
|
||||
`${name}: ${((percent ?? 0) * 100).toFixed(0)}%`
|
||||
}
|
||||
>
|
||||
{pieData.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={entry.color} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
borderRadius: '8px',
|
||||
border: '1px solid #e2e8f0',
|
||||
boxShadow: '0 4px 6px -1px rgb(0 0 0 / 0.1)',
|
||||
}}
|
||||
/>
|
||||
</RechartsPieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Detailed Breakdown */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Detailed Breakdown</CardTitle>
|
||||
<CardDescription>
|
||||
Individual statistics for each usage reason
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<div className="space-y-3">
|
||||
{[1, 2, 3, 4, 5].map(i => (
|
||||
<Skeleton key={i} className="h-16 w-full" />
|
||||
))}
|
||||
</div>
|
||||
) : usageStats.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
No usage data available yet
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{usageStats.map(stat => {
|
||||
const config = stat.reason ? usageReasonConfig[stat.reason] : null;
|
||||
return (
|
||||
<div
|
||||
key={stat.reason}
|
||||
className="flex items-center gap-4 p-4 rounded-lg border"
|
||||
>
|
||||
<div
|
||||
className="p-3 rounded-lg"
|
||||
style={{ backgroundColor: `${config?.color}20` }}
|
||||
>
|
||||
<div style={{ color: config?.color }}>
|
||||
{config?.icon}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="font-medium">{config?.label || stat.reason}</p>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<div className="flex-1 h-2 bg-muted rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full transition-all"
|
||||
style={{
|
||||
width: `${stat.percentage}%`,
|
||||
backgroundColor: config?.color,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground w-12 text-right">
|
||||
{stat.percentage}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-2xl font-bold">{stat.count}</p>
|
||||
<p className="text-xs text-muted-foreground">users</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
import { useState, useEffect } from 'react';
|
||||
import { adminData } from '@/lib/adminApi';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Target, Users, MessageSquare, Flame, Heart, Smile, Shield, TrendingUp, PieChart } from 'lucide-react';
|
||||
import { Bar, BarChart, ResponsiveContainer, XAxis, YAxis, Tooltip, Cell, Pie, PieChart as RechartsPieChart } from 'recharts';
|
||||
|
||||
type UsageReason = 'improve_communication' | 'spice_up_intimacy' | 'deeper_connection' | 'have_fun' | 'strengthen_relationship';
|
||||
|
||||
interface UsageReasonStat {
|
||||
reason: UsageReason | null;
|
||||
count: number;
|
||||
percentage: number;
|
||||
}
|
||||
|
||||
interface GenderStat {
|
||||
gender: string | null;
|
||||
count: number;
|
||||
percentage: number;
|
||||
}
|
||||
|
||||
const usageReasonConfig: Record<UsageReason, { label: string; icon: React.ReactNode; color: string }> = {
|
||||
improve_communication: {
|
||||
label: 'Improve communication',
|
||||
icon: <MessageSquare className="h-5 w-5" />,
|
||||
color: '#3b82f6',
|
||||
},
|
||||
spice_up_intimacy: {
|
||||
label: 'Spice up intimacy',
|
||||
icon: <Flame className="h-5 w-5" />,
|
||||
color: '#ef4444',
|
||||
},
|
||||
deeper_connection: {
|
||||
label: 'Build deeper connection',
|
||||
icon: <Heart className="h-5 w-5" />,
|
||||
color: '#ec4899',
|
||||
},
|
||||
have_fun: {
|
||||
label: 'Have fun together',
|
||||
icon: <Smile className="h-5 w-5" />,
|
||||
color: '#f59e0b',
|
||||
},
|
||||
strengthen_relationship: {
|
||||
label: 'Strengthen relationship',
|
||||
icon: <Shield className="h-5 w-5" />,
|
||||
color: '#10b981',
|
||||
},
|
||||
};
|
||||
|
||||
const genderConfig: Record<string, { label: string; color: string }> = {
|
||||
male: { label: 'Male', color: '#3b82f6' },
|
||||
female: { label: 'Female', color: '#ec4899' },
|
||||
'non-binary': { label: 'Non-binary', color: '#8b5cf6' },
|
||||
'prefer-not-to-say': { label: 'Prefer not to say', color: '#6b7280' },
|
||||
};
|
||||
|
||||
export function UsageInsightsPage() {
|
||||
const [usageStats, setUsageStats] = useState<UsageReasonStat[]>([]);
|
||||
const [genderStats, setGenderStats] = useState<GenderStat[]>([]);
|
||||
const [totalUsers, setTotalUsers] = useState(0);
|
||||
const [onboardedUsers, setOnboardedUsers] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
const fetchStats = async () => {
|
||||
try {
|
||||
// Get total users count
|
||||
const { count: total } = await adminData
|
||||
.from('profiles')
|
||||
.select('*', { count: 'exact', head: true });
|
||||
|
||||
setTotalUsers(total || 0);
|
||||
|
||||
// Get onboarded users count
|
||||
const { count: onboarded } = await adminData
|
||||
.from('profiles')
|
||||
.select('*', { count: 'exact', head: true })
|
||||
.eq('onboarding_completed', true);
|
||||
|
||||
setOnboardedUsers(onboarded || 0);
|
||||
|
||||
// Get usage reason breakdown
|
||||
const { data: usageData } = await adminData
|
||||
.from('profiles')
|
||||
.select('usage_reason')
|
||||
.not('usage_reason', 'is', null);
|
||||
|
||||
const usageCounts: Record<string, number> = {};
|
||||
usageData?.forEach(profile => {
|
||||
const reason = profile.usage_reason as string;
|
||||
usageCounts[reason] = (usageCounts[reason] || 0) + 1;
|
||||
});
|
||||
|
||||
const totalWithReason = Object.values(usageCounts).reduce((a, b) => a + b, 0);
|
||||
const usageStatsArray: UsageReasonStat[] = Object.entries(usageCounts)
|
||||
.map(([reason, count]) => ({
|
||||
reason: reason as UsageReason,
|
||||
count,
|
||||
percentage: totalWithReason > 0 ? Math.round((count / totalWithReason) * 100) : 0,
|
||||
}))
|
||||
.sort((a, b) => b.count - a.count);
|
||||
|
||||
setUsageStats(usageStatsArray);
|
||||
|
||||
// Get gender breakdown
|
||||
const { data: genderData } = await adminData
|
||||
.from('profiles')
|
||||
.select('gender')
|
||||
.not('gender', 'is', null);
|
||||
|
||||
const genderCounts: Record<string, number> = {};
|
||||
genderData?.forEach(profile => {
|
||||
const gender = profile.gender as string;
|
||||
genderCounts[gender] = (genderCounts[gender] || 0) + 1;
|
||||
});
|
||||
|
||||
const totalWithGender = Object.values(genderCounts).reduce((a, b) => a + b, 0);
|
||||
const genderStatsArray: GenderStat[] = Object.entries(genderCounts)
|
||||
.map(([gender, count]) => ({
|
||||
gender,
|
||||
count,
|
||||
percentage: totalWithGender > 0 ? Math.round((count / totalWithGender) * 100) : 0,
|
||||
}))
|
||||
.sort((a, b) => b.count - a.count);
|
||||
|
||||
setGenderStats(genderStatsArray);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch usage stats:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
fetchStats();
|
||||
}, []);
|
||||
|
||||
const chartData = usageStats.map(stat => ({
|
||||
name: stat.reason ? usageReasonConfig[stat.reason]?.label || stat.reason : 'Unknown',
|
||||
value: stat.count,
|
||||
color: stat.reason ? usageReasonConfig[stat.reason]?.color : '#6b7280',
|
||||
}));
|
||||
|
||||
const pieData = genderStats.map(stat => ({
|
||||
name: stat.gender ? genderConfig[stat.gender]?.label || stat.gender : 'Unknown',
|
||||
value: stat.count,
|
||||
color: stat.gender ? genderConfig[stat.gender]?.color : '#6b7280',
|
||||
}));
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight flex items-center gap-2">
|
||||
<Target className="h-8 w-8 text-primary" />
|
||||
Usage Insights
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Understand why users are using Sauci based on onboarding responses
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Quick Stats */}
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Total Users</CardTitle>
|
||||
<Users className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<Skeleton className="h-8 w-16" />
|
||||
) : (
|
||||
<div className="text-2xl font-bold">{totalUsers.toLocaleString()}</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Completed Onboarding</CardTitle>
|
||||
<TrendingUp className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<Skeleton className="h-8 w-16" />
|
||||
) : (
|
||||
<>
|
||||
<div className="text-2xl font-bold">{onboardedUsers.toLocaleString()}</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{totalUsers > 0 ? Math.round((onboardedUsers / totalUsers) * 100) : 0}% of total users
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">Response Rate</CardTitle>
|
||||
<PieChart className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<Skeleton className="h-8 w-16" />
|
||||
) : (
|
||||
<>
|
||||
<div className="text-2xl font-bold">
|
||||
{usageStats.reduce((sum, s) => sum + s.count, 0).toLocaleString()}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
users provided usage reason
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2">
|
||||
{/* Usage Reasons Chart */}
|
||||
<Card className="col-span-1">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Target className="h-5 w-5 text-primary" />
|
||||
Why Users Join Sauci
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Distribution of user motivations from onboarding
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<Skeleton className="h-[300px] w-full" />
|
||||
) : usageStats.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-[300px] text-muted-foreground">
|
||||
No usage data available yet
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-[300px]">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={chartData} layout="vertical" margin={{ left: 20 }}>
|
||||
<XAxis type="number" />
|
||||
<YAxis
|
||||
dataKey="name"
|
||||
type="category"
|
||||
width={150}
|
||||
tick={{ fontSize: 12 }}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
borderRadius: '8px',
|
||||
border: '1px solid #e2e8f0',
|
||||
boxShadow: '0 4px 6px -1px rgb(0 0 0 / 0.1)',
|
||||
}}
|
||||
/>
|
||||
<Bar dataKey="value" radius={[0, 4, 4, 0]}>
|
||||
{chartData.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={entry.color} />
|
||||
))}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Gender Distribution */}
|
||||
<Card className="col-span-1">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Users className="h-5 w-5 text-primary" />
|
||||
Gender Distribution
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
User demographics from onboarding
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<Skeleton className="h-[300px] w-full" />
|
||||
) : genderStats.length === 0 ? (
|
||||
<div className="flex items-center justify-center h-[300px] text-muted-foreground">
|
||||
No gender data available yet
|
||||
</div>
|
||||
) : (
|
||||
<div className="h-[300px] flex items-center justify-center">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<RechartsPieChart>
|
||||
<Pie
|
||||
data={pieData}
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
innerRadius={60}
|
||||
outerRadius={100}
|
||||
paddingAngle={2}
|
||||
dataKey="value"
|
||||
label={({ name, percent }) =>
|
||||
`${name}: ${((percent ?? 0) * 100).toFixed(0)}%`
|
||||
}
|
||||
>
|
||||
{pieData.map((entry, index) => (
|
||||
<Cell key={`cell-${index}`} fill={entry.color} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
borderRadius: '8px',
|
||||
border: '1px solid #e2e8f0',
|
||||
boxShadow: '0 4px 6px -1px rgb(0 0 0 / 0.1)',
|
||||
}}
|
||||
/>
|
||||
</RechartsPieChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Detailed Breakdown */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Detailed Breakdown</CardTitle>
|
||||
<CardDescription>
|
||||
Individual statistics for each usage reason
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<div className="space-y-3">
|
||||
{[1, 2, 3, 4, 5].map(i => (
|
||||
<Skeleton key={i} className="h-16 w-full" />
|
||||
))}
|
||||
</div>
|
||||
) : usageStats.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
No usage data available yet
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{usageStats.map(stat => {
|
||||
const config = stat.reason ? usageReasonConfig[stat.reason] : null;
|
||||
return (
|
||||
<div
|
||||
key={stat.reason}
|
||||
className="flex items-center gap-4 p-4 rounded-lg border"
|
||||
>
|
||||
<div
|
||||
className="p-3 rounded-lg"
|
||||
style={{ backgroundColor: `${config?.color}20` }}
|
||||
>
|
||||
<div style={{ color: config?.color }}>
|
||||
{config?.icon}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="font-medium">{config?.label || stat.reason}</p>
|
||||
<div className="flex items-center gap-2 mt-1">
|
||||
<div className="flex-1 h-2 bg-muted rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full rounded-full transition-all"
|
||||
style={{
|
||||
width: `${stat.percentage}%`,
|
||||
backgroundColor: config?.color,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm text-muted-foreground w-12 text-right">
|
||||
{stat.percentage}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-2xl font-bold">{stat.count}</p>
|
||||
<p className="text-xs text-muted-foreground">users</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { supabase } from '@/config';
|
||||
import { adminData, adminRequest } from '@/lib/adminApi';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
@@ -149,7 +149,7 @@ export function UserActivityPage() {
|
||||
const from = (responsesPage - 1) * responsesPageSize;
|
||||
const to = from + responsesPageSize - 1;
|
||||
|
||||
const { data, error, count } = await supabase
|
||||
const { data, error, count } = await adminData
|
||||
.from('responses')
|
||||
.select(`
|
||||
id, user_id, answer, created_at,
|
||||
@@ -166,7 +166,7 @@ export function UserActivityPage() {
|
||||
|
||||
setResponsesTotal(count || 0);
|
||||
|
||||
// Transform data to match our types (Supabase returns nested objects, not arrays for 1:1 relations)
|
||||
// Transform the API's nested relationship objects to the view model.
|
||||
const transformed: ResponseActivity[] = (data || []).map((item: any) => ({
|
||||
id: item.id,
|
||||
user_id: item.user_id,
|
||||
@@ -195,7 +195,7 @@ export function UserActivityPage() {
|
||||
const from = (matchesPage - 1) * matchesPageSize;
|
||||
const to = from + matchesPageSize - 1;
|
||||
|
||||
const { data: matchesData, error: matchesError, count } = await supabase
|
||||
const { data: matchesData, error: matchesError, count } = await adminData
|
||||
.from('matches')
|
||||
.select(`
|
||||
id, couple_id, match_type, is_new, created_at,
|
||||
@@ -211,7 +211,7 @@ export function UserActivityPage() {
|
||||
// Get unique couple IDs and fetch profiles
|
||||
const coupleIds = [...new Set((matchesData || []).map((m: any) => m.couple_id))];
|
||||
if (coupleIds.length > 0) {
|
||||
const { data: profiles, error: profilesError } = await supabase
|
||||
const { data: profiles, error: profilesError } = await adminData
|
||||
.from('profiles')
|
||||
.select('id, name, email, couple_id')
|
||||
.in('couple_id', coupleIds);
|
||||
@@ -257,7 +257,7 @@ export function UserActivityPage() {
|
||||
const to = from + messagesPageSize - 1;
|
||||
|
||||
// Fetch messages with match data
|
||||
const { data: messagesData, error: messagesError, count } = await supabase
|
||||
const { data: messagesData, error: messagesError, count } = await adminData
|
||||
.from('messages')
|
||||
.select(`
|
||||
id, user_id, content, media_path, media_type, created_at,
|
||||
@@ -278,7 +278,7 @@ export function UserActivityPage() {
|
||||
let profilesMap: Record<string, { id: string; name: string | null; email: string | null }> = {};
|
||||
|
||||
if (userIds.length > 0) {
|
||||
const { data: profiles, error: profilesError } = await supabase
|
||||
const { data: profiles, error: profilesError } = await adminData
|
||||
.from('profiles')
|
||||
.select('id, name, email')
|
||||
.in('id', userIds);
|
||||
@@ -319,10 +319,7 @@ export function UserActivityPage() {
|
||||
setLoading(prev => ({ ...prev, signups: true }));
|
||||
try {
|
||||
// Use RPC to get profiles with auth info (includes email from auth.users)
|
||||
const { data: allData, error } = await supabase
|
||||
.rpc('get_profiles_with_auth_info');
|
||||
|
||||
if (error) throw error;
|
||||
const { users: allData } = await adminRequest<{ users: SignupActivity[] }>('/v1/admin/users');
|
||||
|
||||
// Sort by created_at descending and apply pagination
|
||||
const sorted = (allData || []).sort((a: SignupActivity, b: SignupActivity) =>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { supabase } from '@/config';
|
||||
import { auditedSupabase } from '@/hooks/useAuditedSupabase';
|
||||
import { adminData } from '@/lib/adminApi';
|
||||
import { auditedAdminData } from '@/hooks/useAuditedAdminData';
|
||||
import { useRealtimeSubscription } from '@/hooks/useRealtimeSubscription';
|
||||
import { RealtimeStatusIndicator } from '@/components/RealtimeStatusIndicator';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -82,7 +82,7 @@ export function AdminsPage() {
|
||||
setLoading(true);
|
||||
try {
|
||||
// First fetch admin users with permissions
|
||||
const { data: adminData, error: adminError } = await supabase
|
||||
const { data: adminRows, error: adminError } = await adminData
|
||||
.from('admin_users')
|
||||
.select('id, user_id, role, permissions, created_at')
|
||||
.order('created_at', { ascending: false });
|
||||
@@ -91,9 +91,9 @@ export function AdminsPage() {
|
||||
|
||||
// Then fetch profiles for these users
|
||||
// We do this manually to avoid relying on foreign key relationships if they aren't perfect
|
||||
if (adminData && adminData.length > 0) {
|
||||
const userIds = adminData.map(a => a.user_id);
|
||||
const { data: profileData, error: profileError } = await supabase
|
||||
if (adminRows && adminRows.length > 0) {
|
||||
const userIds = adminRows.map(a => a.user_id);
|
||||
const { data: profileData, error: profileError } = await adminData
|
||||
.from('profiles')
|
||||
.select('id, name, email, avatar_url')
|
||||
.in('id', userIds);
|
||||
@@ -101,7 +101,7 @@ export function AdminsPage() {
|
||||
if (profileError) throw profileError;
|
||||
|
||||
// Merge data
|
||||
const joinedAdmins = adminData.map(admin => ({
|
||||
const joinedAdmins = adminRows.map(admin => ({
|
||||
...admin,
|
||||
permissions: (admin.permissions as string[]) || [],
|
||||
profile: profileData?.find(p => p.id === admin.user_id)
|
||||
@@ -149,7 +149,7 @@ export function AdminsPage() {
|
||||
// Determine which users are already admins to exclude them
|
||||
const adminUserIds = new Set(admins.map(a => a.user_id));
|
||||
|
||||
const { data, error } = await supabase
|
||||
const { data, error } = await adminData
|
||||
.from('profiles')
|
||||
.select('id, name, email, avatar_url')
|
||||
.or(`email.ilike.%${userSearch}%,name.ilike.%${userSearch}%`)
|
||||
@@ -179,7 +179,7 @@ export function AdminsPage() {
|
||||
const role: AdminRole = isSuperAdminChecked ? 'super_admin' : 'pack_creator';
|
||||
const permissions = isSuperAdminChecked ? [] : selectedPermissions;
|
||||
|
||||
const { error } = await auditedSupabase.insert('admin_users', {
|
||||
const { error } = await auditedAdminData.insert('admin_users', {
|
||||
user_id: selectedUser.id,
|
||||
role,
|
||||
permissions
|
||||
@@ -219,7 +219,7 @@ export function AdminsPage() {
|
||||
const role: AdminRole = editIsSuperAdmin ? 'super_admin' : 'pack_creator';
|
||||
const permissions = editIsSuperAdmin ? [] : editPermissions;
|
||||
|
||||
const { error } = await auditedSupabase.update('admin_users', editingAdmin.id, {
|
||||
const { error } = await auditedAdminData.update('admin_users', editingAdmin.id, {
|
||||
role,
|
||||
permissions
|
||||
});
|
||||
@@ -261,7 +261,7 @@ export function AdminsPage() {
|
||||
}
|
||||
|
||||
try {
|
||||
const { error } = await auditedSupabase.deleteBy('admin_users', 'user_id', admin.user_id);
|
||||
const { error } = await auditedAdminData.deleteBy('admin_users', 'user_id', admin.user_id);
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { supabase } from '@/config';
|
||||
import { auditedSupabase } from '@/hooks/useAuditedSupabase';
|
||||
import { adminData } from '@/lib/adminApi';
|
||||
import { auditedAdminData } from '@/hooks/useAuditedAdminData';
|
||||
import { useEntityForm } from '@/hooks/useEntityForm';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -215,7 +215,7 @@ export function CategoriesPage() {
|
||||
const from = (page - 1) * pageSize;
|
||||
const to = from + pageSize - 1;
|
||||
|
||||
const { data: cats, error, count } = await supabase
|
||||
const { data: cats, error, count } = await adminData
|
||||
.from('categories')
|
||||
.select('*', { count: 'exact' })
|
||||
.order('sort_order', { ascending: true })
|
||||
@@ -231,11 +231,11 @@ export function CategoriesPage() {
|
||||
|
||||
if (categoryIds.length > 0) {
|
||||
const [{ data: packs }, { data: darePacks }] = await Promise.all([
|
||||
supabase
|
||||
adminData
|
||||
.from('question_packs')
|
||||
.select('category_id')
|
||||
.in('category_id', categoryIds),
|
||||
supabase
|
||||
adminData
|
||||
.from('dare_packs')
|
||||
.select('category_id')
|
||||
.in('category_id', categoryIds),
|
||||
@@ -310,11 +310,11 @@ export function CategoriesPage() {
|
||||
};
|
||||
|
||||
if (form.editingItem) {
|
||||
const { error } = await auditedSupabase.update('categories', form.editingItem.id, data);
|
||||
const { error } = await auditedAdminData.update('categories', form.editingItem.id, data);
|
||||
if (error) throw error;
|
||||
toast.success('Category updated');
|
||||
} else {
|
||||
const { error } = await auditedSupabase.insert('categories', {
|
||||
const { error } = await auditedAdminData.insert('categories', {
|
||||
...data,
|
||||
sort_order: totalCount,
|
||||
});
|
||||
@@ -338,7 +338,7 @@ export function CategoriesPage() {
|
||||
}
|
||||
|
||||
try {
|
||||
const { error } = await auditedSupabase.delete('categories', category.id);
|
||||
const { error } = await auditedAdminData.delete('categories', category.id);
|
||||
if (error) throw error;
|
||||
toast.success('Category deleted');
|
||||
fetchCategories();
|
||||
@@ -355,7 +355,7 @@ export function CategoriesPage() {
|
||||
c.id === category.id ? { ...c, is_public: !c.is_public } : c
|
||||
));
|
||||
|
||||
const { error } = await auditedSupabase.update('categories', category.id, {
|
||||
const { error } = await auditedAdminData.update('categories', category.id, {
|
||||
is_public: !category.is_public
|
||||
});
|
||||
|
||||
@@ -399,7 +399,7 @@ export function CategoriesPage() {
|
||||
// Persist new order to database
|
||||
try {
|
||||
const updates = newCategories.map((category, index) =>
|
||||
auditedSupabase.update('categories', category.id, { sort_order: index })
|
||||
auditedAdminData.update('categories', category.id, { sort_order: index })
|
||||
);
|
||||
|
||||
const results = await Promise.all(updates);
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { Link, useParams } from 'react-router-dom';
|
||||
import { supabase } from '@/config';
|
||||
import { auditedSupabase } from '@/hooks/useAuditedSupabase';
|
||||
import { adminData } from '@/lib/adminApi';
|
||||
import { auditedAdminData } from '@/hooks/useAuditedAdminData';
|
||||
import { useEntityForm } from '@/hooks/useEntityForm';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
@@ -116,7 +116,7 @@ export function DarePacksPage() {
|
||||
|
||||
// Fetch category info
|
||||
if (categoryId) {
|
||||
const { data: categoryData } = await supabase
|
||||
const { data: categoryData } = await adminData
|
||||
.from('categories')
|
||||
.select('id, name, icon')
|
||||
.eq('id', categoryId)
|
||||
@@ -127,7 +127,7 @@ export function DarePacksPage() {
|
||||
}
|
||||
|
||||
// Fetch dare packs
|
||||
let query = supabase
|
||||
let query = adminData
|
||||
.from('dare_packs')
|
||||
.select('*', { count: 'exact' })
|
||||
.order('sort_order', { ascending: true })
|
||||
@@ -148,7 +148,7 @@ export function DarePacksPage() {
|
||||
const dareCounts: Record<string, number> = {};
|
||||
|
||||
if (packIds.length > 0) {
|
||||
const { data: dares } = await supabase
|
||||
const { data: dares } = await adminData
|
||||
.from('dares')
|
||||
.select('pack_id')
|
||||
.in('pack_id', packIds);
|
||||
@@ -159,7 +159,7 @@ export function DarePacksPage() {
|
||||
}
|
||||
|
||||
// Fetch categories
|
||||
const { data: categoryData } = await supabase
|
||||
const { data: categoryData } = await adminData
|
||||
.from('categories')
|
||||
.select('id, name')
|
||||
.order('name');
|
||||
@@ -208,7 +208,7 @@ export function DarePacksPage() {
|
||||
form.setSaving(true);
|
||||
try {
|
||||
if (form.editingItem) {
|
||||
const { error } = await auditedSupabase.update('dare_packs', form.editingItem.id, {
|
||||
const { error } = await auditedAdminData.update('dare_packs', form.editingItem.id, {
|
||||
name: form.formData.name,
|
||||
description: form.formData.description || null,
|
||||
icon: form.formData.icon || null,
|
||||
@@ -220,7 +220,7 @@ export function DarePacksPage() {
|
||||
if (error) throw error;
|
||||
toast.success('Dare pack updated');
|
||||
} else {
|
||||
const { error } = await auditedSupabase.insert('dare_packs', {
|
||||
const { error } = await auditedAdminData.insert('dare_packs', {
|
||||
name: form.formData.name,
|
||||
description: form.formData.description || null,
|
||||
icon: form.formData.icon || null,
|
||||
@@ -250,7 +250,7 @@ export function DarePacksPage() {
|
||||
}
|
||||
|
||||
try {
|
||||
const { error } = await auditedSupabase.delete('dare_packs', pack.id);
|
||||
const { error } = await auditedAdminData.delete('dare_packs', pack.id);
|
||||
if (error) throw error;
|
||||
toast.success('Dare pack deleted');
|
||||
fetchData();
|
||||
@@ -273,8 +273,8 @@ export function DarePacksPage() {
|
||||
const targetOrder = targetPack.sort_order ?? targetIndex;
|
||||
|
||||
const [currentUpdate, targetUpdate] = await Promise.all([
|
||||
auditedSupabase.update('dare_packs', pack.id, { sort_order: targetOrder }),
|
||||
auditedSupabase.update('dare_packs', targetPack.id, { sort_order: currentOrder }),
|
||||
auditedAdminData.update('dare_packs', pack.id, { sort_order: targetOrder }),
|
||||
auditedAdminData.update('dare_packs', targetPack.id, { sort_order: currentOrder }),
|
||||
]);
|
||||
|
||||
if (currentUpdate.error || targetUpdate.error) {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { supabase } from '@/config';
|
||||
import { auditedSupabase } from '@/hooks/useAuditedSupabase';
|
||||
import { adminData } from '@/lib/adminApi';
|
||||
import { auditedAdminData } from '@/hooks/useAuditedAdminData';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
@@ -110,7 +110,7 @@ export function DaresPage() {
|
||||
setLoading(true);
|
||||
try {
|
||||
// Fetch pack info
|
||||
const { data: packData } = await supabase
|
||||
const { data: packData } = await adminData
|
||||
.from('dare_packs')
|
||||
.select('id, name, description, icon')
|
||||
.eq('id', packId)
|
||||
@@ -122,7 +122,7 @@ export function DaresPage() {
|
||||
const to = from + pageSize - 1;
|
||||
|
||||
// Fetch dares
|
||||
const { data: dareData, error, count } = await supabase
|
||||
const { data: dareData, error, count } = await adminData
|
||||
.from('dares')
|
||||
.select('*', { count: 'exact' })
|
||||
.eq('pack_id', packId)
|
||||
@@ -191,11 +191,11 @@ export function DaresPage() {
|
||||
};
|
||||
|
||||
if (editingDare) {
|
||||
const { error } = await auditedSupabase.update('dares', editingDare.id, dareData);
|
||||
const { error } = await auditedAdminData.update('dares', editingDare.id, dareData);
|
||||
if (error) throw error;
|
||||
toast.success('Dare updated');
|
||||
} else {
|
||||
const { error } = await auditedSupabase.insert('dares', {
|
||||
const { error } = await auditedAdminData.insert('dares', {
|
||||
...dareData,
|
||||
pack_id: packId,
|
||||
});
|
||||
@@ -219,7 +219,7 @@ export function DaresPage() {
|
||||
}
|
||||
|
||||
try {
|
||||
const { error } = await auditedSupabase.delete('dares', dare.id);
|
||||
const { error } = await auditedAdminData.delete('dares', dare.id);
|
||||
if (error) throw error;
|
||||
toast.success('Dare deleted');
|
||||
fetchData();
|
||||
@@ -259,7 +259,7 @@ export function DaresPage() {
|
||||
setBulkDeleting(true);
|
||||
try {
|
||||
await Promise.all(
|
||||
Array.from(selectedIds).map(id => auditedSupabase.delete('dares', id))
|
||||
Array.from(selectedIds).map(id => auditedAdminData.delete('dares', id))
|
||||
);
|
||||
toast.success(`${count} dare${count !== 1 ? 's' : ''} deleted`);
|
||||
setSelectedIds(new Set());
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Link, useParams } from 'react-router-dom';
|
||||
import { supabase } from '@/config';
|
||||
import { auditedSupabase } from '@/hooks/useAuditedSupabase';
|
||||
import { adminData } from '@/lib/adminApi';
|
||||
import { auditedAdminData } from '@/hooks/useAuditedAdminData';
|
||||
import { useEntityForm } from '@/hooks/useEntityForm';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
@@ -103,7 +103,7 @@ export function PacksPage() {
|
||||
try {
|
||||
// Fetch category info
|
||||
if (categoryId) {
|
||||
const { data: cat } = await supabase
|
||||
const { data: cat } = await adminData
|
||||
.from('categories')
|
||||
.select('id, name, icon')
|
||||
.eq('id', categoryId)
|
||||
@@ -111,7 +111,7 @@ export function PacksPage() {
|
||||
setCategory(cat);
|
||||
}
|
||||
|
||||
const { data: categoriesData } = await supabase
|
||||
const { data: categoriesData } = await adminData
|
||||
.from('categories')
|
||||
.select('id, name')
|
||||
.order('name');
|
||||
@@ -121,7 +121,7 @@ export function PacksPage() {
|
||||
const to = from + pageSize - 1;
|
||||
|
||||
// Fetch packs
|
||||
let query = supabase
|
||||
let query = adminData
|
||||
.from('question_packs')
|
||||
.select('*', { count: 'exact' })
|
||||
.order('sort_order', { ascending: true })
|
||||
@@ -141,7 +141,7 @@ export function PacksPage() {
|
||||
const questionCounts: Record<string, number> = {};
|
||||
|
||||
if (packIds.length > 0) {
|
||||
const { data: questions } = await supabase
|
||||
const { data: questions } = await adminData
|
||||
.from('questions')
|
||||
.select('pack_id')
|
||||
.in('pack_id', packIds);
|
||||
@@ -152,7 +152,7 @@ export function PacksPage() {
|
||||
}
|
||||
|
||||
// Fetch all topics
|
||||
const { data: topicsData } = await supabase
|
||||
const { data: topicsData } = await adminData
|
||||
.from('topics')
|
||||
.select('id, name')
|
||||
.order('name');
|
||||
@@ -161,7 +161,7 @@ export function PacksPage() {
|
||||
// Fetch pack_topics relationships
|
||||
let packTopicsData: any[] = [];
|
||||
if (packIds.length > 0) {
|
||||
const { data } = await supabase
|
||||
const { data } = await adminData
|
||||
.from('pack_topics')
|
||||
.select('pack_id, topic_id, topics(id, name)')
|
||||
.in('pack_id', packIds);
|
||||
@@ -236,7 +236,7 @@ export function PacksPage() {
|
||||
? true
|
||||
: (form.formData.scheduled_release_at ? false : undefined);
|
||||
|
||||
const { error } = await auditedSupabase.update('question_packs', form.editingItem.id, {
|
||||
const { error } = await auditedAdminData.update('question_packs', form.editingItem.id, {
|
||||
name: form.formData.name,
|
||||
description: form.formData.description || null,
|
||||
icon: form.formData.icon || null,
|
||||
@@ -254,7 +254,7 @@ export function PacksPage() {
|
||||
if (error) throw error;
|
||||
packId = form.editingItem.id;
|
||||
} else {
|
||||
const { data, error } = await supabase
|
||||
const { data, error } = await adminData
|
||||
.from('question_packs')
|
||||
.insert({
|
||||
name: form.formData.name,
|
||||
@@ -279,10 +279,10 @@ export function PacksPage() {
|
||||
}
|
||||
|
||||
// Update topics
|
||||
await supabase.from('pack_topics').delete().eq('pack_id', packId);
|
||||
await adminData.from('pack_topics').delete().eq('pack_id', packId);
|
||||
|
||||
if (selectedTopicIds.size > 0) {
|
||||
const { error: topicsError } = await supabase
|
||||
const { error: topicsError } = await adminData
|
||||
.from('pack_topics')
|
||||
.insert(
|
||||
Array.from(selectedTopicIds).map(topicId => ({
|
||||
@@ -310,7 +310,7 @@ export function PacksPage() {
|
||||
}
|
||||
|
||||
try {
|
||||
const { error } = await auditedSupabase.delete('question_packs', pack.id);
|
||||
const { error } = await auditedAdminData.delete('question_packs', pack.id);
|
||||
if (error) throw error;
|
||||
toast.success('Pack deleted');
|
||||
fetchData();
|
||||
@@ -351,8 +351,8 @@ export function PacksPage() {
|
||||
const targetOrder = targetPack.sort_order ?? targetIndex;
|
||||
|
||||
const [currentUpdate, targetUpdate] = await Promise.all([
|
||||
auditedSupabase.update('question_packs', pack.id, { sort_order: targetOrder }),
|
||||
auditedSupabase.update('question_packs', targetPack.id, { sort_order: currentOrder }),
|
||||
auditedAdminData.update('question_packs', pack.id, { sort_order: targetOrder }),
|
||||
auditedAdminData.update('question_packs', targetPack.id, { sort_order: currentOrder }),
|
||||
]);
|
||||
|
||||
if (currentUpdate.error || targetUpdate.error) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { supabase } from '@/config';
|
||||
import { adminData } from '@/lib/adminApi';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -126,7 +126,7 @@ export function QuestionAnalyticsPage() {
|
||||
while (true) {
|
||||
const from = page * pageSize;
|
||||
const to = from + pageSize - 1;
|
||||
const { data, error } = await supabase
|
||||
const { data, error } = await adminData
|
||||
.from('questions')
|
||||
.select('id, pack_id, intensity, partner_text, inverse_of, question_type')
|
||||
.range(from, to);
|
||||
@@ -150,7 +150,7 @@ export function QuestionAnalyticsPage() {
|
||||
setLoading(true);
|
||||
try {
|
||||
// Build pack query with optional is_public filter
|
||||
let packQuery = supabase
|
||||
let packQuery = adminData
|
||||
.from('question_packs')
|
||||
.select('id, name, is_premium, is_explicit, is_public, category_id, category:categories(name)');
|
||||
|
||||
@@ -162,11 +162,11 @@ export function QuestionAnalyticsPage() {
|
||||
|
||||
const [packsResult, topicsResult, packTopicsResult, questions] = await Promise.all([
|
||||
packQuery,
|
||||
supabase
|
||||
adminData
|
||||
.from('topics')
|
||||
.select('id, name')
|
||||
.order('name'),
|
||||
supabase
|
||||
adminData
|
||||
.from('pack_topics')
|
||||
.select('pack_id, topic_id, topics(id, name)'),
|
||||
fetchAllQuestions(),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { supabase } from '@/config';
|
||||
import { auditedSupabase } from '@/hooks/useAuditedSupabase';
|
||||
import { adminData } from '@/lib/adminApi';
|
||||
import { auditedAdminData } from '@/hooks/useAuditedAdminData';
|
||||
import { useRealtimeSubscription } from '@/hooks/useRealtimeSubscription';
|
||||
import { RealtimeStatusIndicator } from '@/components/RealtimeStatusIndicator';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -150,12 +150,12 @@ export function QuestionsPage() {
|
||||
const from = (page - 1) * pageSize;
|
||||
const to = from + pageSize - 1;
|
||||
|
||||
const packPromise = supabase
|
||||
const packPromise = adminData
|
||||
.from('question_packs')
|
||||
.select('id, name, description, icon')
|
||||
.eq('id', packId)
|
||||
.single();
|
||||
let questionsQuery = supabase
|
||||
let questionsQuery = adminData
|
||||
.from('questions')
|
||||
.select('*', { count: 'exact' })
|
||||
.eq('pack_id', packId)
|
||||
@@ -167,7 +167,7 @@ export function QuestionsPage() {
|
||||
}
|
||||
|
||||
const questionsPromise = questionsQuery;
|
||||
const allPackPromise = supabase
|
||||
const allPackPromise = adminData
|
||||
.from('questions')
|
||||
.select('id, text, inverse_of')
|
||||
.eq('pack_id', packId)
|
||||
@@ -250,7 +250,7 @@ export function QuestionsPage() {
|
||||
// Fetch all questions in pack for inverse_of dropdown
|
||||
const fetchAllPackQuestions = useCallback(async () => {
|
||||
if (!packId) return;
|
||||
const { data } = await supabase
|
||||
const { data } = await adminData
|
||||
.from('questions')
|
||||
.select('id, text, inverse_of')
|
||||
.eq('pack_id', packId)
|
||||
@@ -317,7 +317,7 @@ export function QuestionsPage() {
|
||||
setSaving(true);
|
||||
try {
|
||||
if (editingQuestion) {
|
||||
const { error } = await auditedSupabase.update('questions', editingQuestion.id, {
|
||||
const { error } = await auditedAdminData.update('questions', editingQuestion.id, {
|
||||
text: formData.text,
|
||||
partner_text: formData.partner_text || null,
|
||||
allowed_couple_genders: formData.allowed_couple_genders.length > 0 ? formData.allowed_couple_genders : null,
|
||||
@@ -331,7 +331,7 @@ export function QuestionsPage() {
|
||||
if (error) throw error;
|
||||
toast.success('Question updated');
|
||||
} else {
|
||||
const { error } = await auditedSupabase.insert('questions', {
|
||||
const { error } = await auditedAdminData.insert('questions', {
|
||||
pack_id: packId,
|
||||
text: formData.text,
|
||||
partner_text: formData.partner_text || null,
|
||||
@@ -363,7 +363,7 @@ export function QuestionsPage() {
|
||||
}
|
||||
|
||||
try {
|
||||
const { error } = await auditedSupabase.delete('questions', question.id);
|
||||
const { error } = await auditedAdminData.delete('questions', question.id);
|
||||
|
||||
if (error) throw error;
|
||||
toast.success('Question deleted');
|
||||
@@ -396,7 +396,7 @@ export function QuestionsPage() {
|
||||
|
||||
// Insert standalone questions first (no inverse_of)
|
||||
if (standaloneQuestions.length > 0) {
|
||||
const { error: standaloneError } = await auditedSupabase.insert(
|
||||
const { error: standaloneError } = await auditedAdminData.insert(
|
||||
'questions',
|
||||
standaloneQuestions.map(q => ({
|
||||
pack_id: packId,
|
||||
@@ -415,7 +415,7 @@ export function QuestionsPage() {
|
||||
if (pair.length >= 2) {
|
||||
// First question is primary (inverse_of = NULL)
|
||||
const primary = pair[0];
|
||||
const { data: primaryData, error: primaryError } = await auditedSupabase.insert(
|
||||
const { data: primaryData, error: primaryError } = await auditedAdminData.insert(
|
||||
'questions',
|
||||
[{
|
||||
pack_id: packId,
|
||||
@@ -432,7 +432,7 @@ export function QuestionsPage() {
|
||||
const primaryId = primaryData?.[0]?.id;
|
||||
if (primaryId) {
|
||||
const inverse = pair[1];
|
||||
const { error: inverseError } = await auditedSupabase.insert(
|
||||
const { error: inverseError } = await auditedAdminData.insert(
|
||||
'questions',
|
||||
[{
|
||||
pack_id: packId,
|
||||
@@ -449,7 +449,7 @@ export function QuestionsPage() {
|
||||
// Handle any additional questions in the pair (shouldn't happen, but just in case)
|
||||
for (let i = 2; i < pair.length; i++) {
|
||||
const extra = pair[i];
|
||||
const { error: extraError } = await auditedSupabase.insert(
|
||||
const { error: extraError } = await auditedAdminData.insert(
|
||||
'questions',
|
||||
[{
|
||||
pack_id: packId,
|
||||
@@ -464,7 +464,7 @@ export function QuestionsPage() {
|
||||
}
|
||||
} else if (pair.length === 1) {
|
||||
// Single question with pair_id but no pair - treat as standalone
|
||||
const { error } = await auditedSupabase.insert(
|
||||
const { error } = await auditedAdminData.insert(
|
||||
'questions',
|
||||
[{
|
||||
pack_id: packId,
|
||||
@@ -497,7 +497,7 @@ export function QuestionsPage() {
|
||||
|
||||
setReviewLoading(true);
|
||||
try {
|
||||
const { data, error } = await supabase
|
||||
const { data, error } = await adminData
|
||||
.from('questions')
|
||||
.select('id, pack_id, text, partner_text, intensity, allowed_couple_genders, target_user_genders, required_props, question_type, created_at')
|
||||
.eq('pack_id', packId)
|
||||
@@ -544,7 +544,7 @@ export function QuestionsPage() {
|
||||
setBulkDeleting(true);
|
||||
try {
|
||||
await Promise.all(
|
||||
Array.from(selectedIds).map(id => auditedSupabase.delete('questions', id))
|
||||
Array.from(selectedIds).map(id => auditedAdminData.delete('questions', id))
|
||||
);
|
||||
toast.success(`${count} question${count !== 1 ? 's' : ''} deleted`);
|
||||
setSelectedIds(new Set());
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { supabase } from '@/config';
|
||||
import { adminData } from '@/lib/adminApi';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -64,9 +64,9 @@ export function TagsPage() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [topicsResult, packTopicsResult, packsResult] = await Promise.all([
|
||||
supabase.from('topics').select('id, name').order('name'),
|
||||
supabase.from('pack_topics').select('pack_id, topic_id'),
|
||||
supabase
|
||||
adminData.from('topics').select('id, name').order('name'),
|
||||
adminData.from('pack_topics').select('pack_id, topic_id'),
|
||||
adminData
|
||||
.from('question_packs')
|
||||
.select('id, name, description, is_public, category:categories(name)')
|
||||
.order('name'),
|
||||
@@ -106,7 +106,7 @@ export function TagsPage() {
|
||||
const questionCounts: Record<string, number> = {};
|
||||
|
||||
if (untaggedPackIds.length > 0) {
|
||||
const { data: questions, error: questionsError } = await supabase
|
||||
const { data: questions, error: questionsError } = await adminData
|
||||
.from('questions')
|
||||
.select('pack_id')
|
||||
.in('pack_id', untaggedPackIds);
|
||||
@@ -167,7 +167,7 @@ export function TagsPage() {
|
||||
setTaggingProgress({ current: 0, total: eligiblePacks.length, packName: '' });
|
||||
|
||||
try {
|
||||
const { data: topicsData, error: topicsError } = await supabase
|
||||
const { data: topicsData, error: topicsError } = await adminData
|
||||
.from('topics')
|
||||
.select('id, name')
|
||||
.order('name');
|
||||
@@ -188,7 +188,7 @@ export function TagsPage() {
|
||||
});
|
||||
|
||||
try {
|
||||
const { data: questions, error: questionsError } = await supabase
|
||||
const { data: questions, error: questionsError } = await adminData
|
||||
.from('questions')
|
||||
.select('text')
|
||||
.eq('pack_id', pack.id);
|
||||
@@ -237,7 +237,7 @@ export function TagsPage() {
|
||||
});
|
||||
|
||||
if (newTopics.length > 0) {
|
||||
const { data: createdTopics, error: createError } = await supabase
|
||||
const { data: createdTopics, error: createError } = await adminData
|
||||
.from('topics')
|
||||
.insert(newTopics)
|
||||
.select('id, name');
|
||||
@@ -250,14 +250,14 @@ export function TagsPage() {
|
||||
});
|
||||
}
|
||||
|
||||
await supabase.from('pack_topics').delete().eq('pack_id', pack.id);
|
||||
await adminData.from('pack_topics').delete().eq('pack_id', pack.id);
|
||||
|
||||
if (topicIds.length === 0) {
|
||||
skippedCount += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const { error: linkError } = await supabase
|
||||
const { error: linkError } = await adminData
|
||||
.from('pack_topics')
|
||||
.insert(topicIds.map((topicId) => ({
|
||||
pack_id: pack.id,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useParams, Link } from 'react-router-dom';
|
||||
import { supabase, supabaseConfig } from '@/config';
|
||||
import { adminData, adminRequest, adminBinaryRequest } from '@/lib/adminApi';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -87,29 +87,9 @@ function ChatImage({ messageId }: { messageId: string }) {
|
||||
setError(null);
|
||||
setUrl(null);
|
||||
|
||||
const res = await fetch(`${supabaseConfig.url}/functions/v1/admin-decrypt-media`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
apikey: supabaseConfig.anonKey,
|
||||
Authorization: `Bearer ${session.access_token}`,
|
||||
},
|
||||
body: JSON.stringify({ messageId }),
|
||||
const blob = await adminBinaryRequest('/v1/admin/actions/decrypt-media', {
|
||||
method: 'POST', body: { messageId },
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const raw = await res.text().catch(() => '');
|
||||
let message = raw;
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as { error?: unknown };
|
||||
if (typeof parsed?.error === 'string') message = parsed.error;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
throw new Error(message || `Failed to load media (${res.status})`);
|
||||
}
|
||||
|
||||
const blob = await res.blob();
|
||||
objectUrl = URL.createObjectURL(blob);
|
||||
|
||||
if (cancelled) return;
|
||||
@@ -178,29 +158,9 @@ function ChatVideo({ messageId }: { messageId: string }) {
|
||||
setError(null);
|
||||
setUrl(null);
|
||||
|
||||
const res = await fetch(`${supabaseConfig.url}/functions/v1/admin-decrypt-media`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
apikey: supabaseConfig.anonKey,
|
||||
Authorization: `Bearer ${session.access_token}`,
|
||||
},
|
||||
body: JSON.stringify({ messageId }),
|
||||
const blob = await adminBinaryRequest('/v1/admin/actions/decrypt-media', {
|
||||
method: 'POST', body: { messageId },
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const raw = await res.text().catch(() => '');
|
||||
let message = raw;
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as { error?: unknown };
|
||||
if (typeof parsed?.error === 'string') message = parsed.error;
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
throw new Error(message || `Failed to load media (${res.status})`);
|
||||
}
|
||||
|
||||
const blob = await res.blob();
|
||||
objectUrl = URL.createObjectURL(blob);
|
||||
|
||||
if (cancelled) return;
|
||||
@@ -287,18 +247,13 @@ function DecryptedMessageText({ message }: { message: Message }) {
|
||||
const decrypt = async () => {
|
||||
setIsDecrypting(true);
|
||||
try {
|
||||
const { data, error } = await supabase.functions.invoke('admin-decrypt-message', {
|
||||
body: { messageId: message.id },
|
||||
const data = await adminRequest<{ content?: string | null }>('/v1/admin/actions/decrypt-message', {
|
||||
method: 'POST', body: { messageId: message.id },
|
||||
});
|
||||
|
||||
if (cancelled) return;
|
||||
|
||||
if (error) {
|
||||
setText('[Unable to decrypt]');
|
||||
return;
|
||||
}
|
||||
|
||||
setText((data as { content?: string | null } | null)?.content ?? null);
|
||||
setText(data.content ?? null);
|
||||
} catch (err) {
|
||||
if (cancelled) return;
|
||||
setText('[Unable to decrypt]');
|
||||
@@ -342,7 +297,7 @@ export function MatchChatPage() {
|
||||
setLoading(true);
|
||||
try {
|
||||
// Fetch user profile
|
||||
const { data: userData } = await supabase
|
||||
const { data: userData } = await adminData
|
||||
.from('profiles')
|
||||
.select('id, name, avatar_url, couple_id')
|
||||
.eq('id', userId)
|
||||
@@ -352,7 +307,7 @@ export function MatchChatPage() {
|
||||
|
||||
// Fetch partner
|
||||
if (userData?.couple_id) {
|
||||
const { data: partnerData } = await supabase
|
||||
const { data: partnerData } = await adminData
|
||||
.from('profiles')
|
||||
.select('id, name, avatar_url')
|
||||
.eq('couple_id', userData.couple_id)
|
||||
@@ -365,7 +320,7 @@ export function MatchChatPage() {
|
||||
}
|
||||
|
||||
// Fetch match with question
|
||||
const { data: matchData } = await supabase
|
||||
const { data: matchData } = await adminData
|
||||
.from('matches')
|
||||
.select(`
|
||||
id, match_type,
|
||||
@@ -380,7 +335,7 @@ export function MatchChatPage() {
|
||||
const to = from + pageSize - 1;
|
||||
|
||||
// Fetch messages
|
||||
const { data: messageData, error: messagesError, count } = await supabase
|
||||
const { data: messageData, error: messagesError, count } = await adminData
|
||||
.from('messages')
|
||||
.select('*', { count: 'exact' })
|
||||
.eq('match_id', matchId)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { useParams, Link } from 'react-router-dom';
|
||||
import { supabase, supabaseConfig } from '@/config';
|
||||
import { adminData, adminRequest, adminBinaryRequest } from '@/lib/adminApi';
|
||||
import { useRealtimeSubscription } from '@/hooks/useRealtimeSubscription';
|
||||
import { RealtimeStatusIndicator } from '@/components/RealtimeStatusIndicator';
|
||||
import { useAuth, PERMISSION_KEYS } from '@/contexts/AuthContext';
|
||||
@@ -190,16 +190,9 @@ function AdminDecryptedImage({ messageId, alt }: { messageId: string; alt: strin
|
||||
const load = async () => {
|
||||
try {
|
||||
setError(null);
|
||||
const res = await fetch(`${supabaseConfig.url}/functions/v1/admin-decrypt-media`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', apikey: supabaseConfig.anonKey, Authorization: `Bearer ${session.access_token}` },
|
||||
body: JSON.stringify({ messageId }),
|
||||
const blob = await adminBinaryRequest('/v1/admin/actions/decrypt-media', {
|
||||
method: 'POST', body: { messageId },
|
||||
});
|
||||
if (!res.ok) {
|
||||
const message = (await res.json().catch(() => ({})))?.error || `Failed to load media (${res.status})`;
|
||||
throw new Error(message);
|
||||
}
|
||||
const blob = await res.blob();
|
||||
objectUrl = URL.createObjectURL(blob);
|
||||
if (!cancelled) setUrl(objectUrl);
|
||||
} catch (err) {
|
||||
@@ -246,16 +239,9 @@ function AdminDecryptedVideo({ messageId }: { messageId: string }) {
|
||||
const load = async () => {
|
||||
try {
|
||||
setError(null);
|
||||
const res = await fetch(`${supabaseConfig.url}/functions/v1/admin-decrypt-media`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', apikey: supabaseConfig.anonKey, Authorization: `Bearer ${session.access_token}` },
|
||||
body: JSON.stringify({ messageId }),
|
||||
const blob = await adminBinaryRequest('/v1/admin/actions/decrypt-media', {
|
||||
method: 'POST', body: { messageId },
|
||||
});
|
||||
if (!res.ok) {
|
||||
const message = (await res.json().catch(() => ({})))?.error || `Failed to load media (${res.status})`;
|
||||
throw new Error(message);
|
||||
}
|
||||
const blob = await res.blob();
|
||||
objectUrl = URL.createObjectURL(blob);
|
||||
if (!cancelled) setUrl(objectUrl);
|
||||
} catch (err) {
|
||||
@@ -308,7 +294,7 @@ function LiveDrawCanvas({ coupleId }: { coupleId: string }) {
|
||||
const fetchStrokes = async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const { data, error: err } = await supabase
|
||||
const { data, error: err } = await adminData
|
||||
.from('live_draw_sessions')
|
||||
.select('strokes')
|
||||
.eq('couple_id', coupleId)
|
||||
@@ -322,26 +308,14 @@ function LiveDrawCanvas({ coupleId }: { coupleId: string }) {
|
||||
setStrokes((data?.strokes as StrokeSegment[]) || []);
|
||||
setLoading(false);
|
||||
};
|
||||
fetchStrokes();
|
||||
|
||||
// Subscribe to real-time updates
|
||||
const channel = supabase
|
||||
.channel(`admin-livedraw-${coupleId}`)
|
||||
.on('postgres_changes', {
|
||||
event: '*',
|
||||
schema: 'public',
|
||||
table: 'live_draw_sessions',
|
||||
filter: `couple_id=eq.${coupleId}`,
|
||||
}, (payload: any) => {
|
||||
if (payload.new?.strokes) {
|
||||
setStrokes(payload.new.strokes as StrokeSegment[]);
|
||||
}
|
||||
})
|
||||
.subscribe();
|
||||
void fetchStrokes();
|
||||
const interval = window.setInterval(() => {
|
||||
if (document.visibilityState === 'visible') void fetchStrokes();
|
||||
}, 5_000);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
supabase.removeChannel(channel);
|
||||
window.clearInterval(interval);
|
||||
};
|
||||
}, [coupleId]);
|
||||
|
||||
@@ -482,13 +456,12 @@ export function UserDetailPage() {
|
||||
}
|
||||
expiresAt = date.toISOString();
|
||||
}
|
||||
// Use RPC function which handles permissions internally
|
||||
const { error } = await supabase.rpc('admin_gift_premium', {
|
||||
target_user_id: userId,
|
||||
expires_at_param: expiresAt,
|
||||
const days = expiresAt === null ? null : Math.max(1, Math.ceil((new Date(expiresAt).getTime() - now.getTime()) / 86_400_000));
|
||||
await adminRequest(`/v1/admin/users/${encodeURIComponent(userId)}/gift-premium`, {
|
||||
method: 'POST', body: { days, reason: 'Granted from admin user detail' },
|
||||
});
|
||||
if (error) throw error;
|
||||
const { data: updatedProfile } = await supabase.rpc('get_profile_with_auth_info', { user_id: userId }).single<Profile>();
|
||||
const { users } = await adminRequest<{ users: Profile[] }>(`/v1/admin/users?userId=${encodeURIComponent(userId)}`);
|
||||
const updatedProfile = users[0] ?? null;
|
||||
if (updatedProfile) setProfile(updatedProfile);
|
||||
setUpgradeOpen(false);
|
||||
alert('User upgraded to premium successfully!');
|
||||
@@ -504,11 +477,12 @@ export function UserDetailPage() {
|
||||
if (!userId) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
const { data: profileData } = await supabase.rpc('get_profile_with_auth_info', { user_id: userId }).single<Profile>();
|
||||
const { users } = await adminRequest<{ users: Profile[] }>(`/v1/admin/users?userId=${encodeURIComponent(userId)}`);
|
||||
const profileData = users[0] ?? null;
|
||||
setProfile(profileData);
|
||||
|
||||
// Fetch feature interests for the user
|
||||
const { data: featureInterestsData } = await supabase
|
||||
const { data: featureInterestsData } = await adminData
|
||||
.from('feature_interests')
|
||||
.select('id, feature_name, created_at')
|
||||
.eq('user_id', userId)
|
||||
@@ -516,11 +490,11 @@ export function UserDetailPage() {
|
||||
setFeatureInterests((featureInterestsData || []) as FeatureInterest[]);
|
||||
|
||||
if (profileData?.couple_id) {
|
||||
const { data: partnerData } = await supabase.from('profiles').select('id, name').eq('couple_id', profileData.couple_id).neq('id', userId).single();
|
||||
const { data: partnerData } = await adminData.from('profiles').select('id, name').eq('couple_id', profileData.couple_id).neq('id', userId).single();
|
||||
setPartner(partnerData);
|
||||
|
||||
// Fetch streak data
|
||||
const { data: streakData } = await supabase
|
||||
const { data: streakData } = await adminData
|
||||
.from('couple_streaks')
|
||||
.select('*')
|
||||
.eq('couple_id', profileData.couple_id)
|
||||
@@ -534,7 +508,7 @@ export function UserDetailPage() {
|
||||
const responsesFrom = (responsesPage - 1) * responsesPageSize;
|
||||
const responsesTo = responsesFrom + responsesPageSize - 1;
|
||||
|
||||
const { data: responseData, count: responsesCount, error: responsesError } = await supabase
|
||||
const { data: responseData, count: responsesCount, error: responsesError } = await adminData
|
||||
.from('responses')
|
||||
.select('id, answer, created_at, question:questions(id, text, pack:question_packs(id, name))', { count: 'exact' })
|
||||
.eq('user_id', userId)
|
||||
@@ -550,7 +524,7 @@ export function UserDetailPage() {
|
||||
const matchesFrom = (matchesPage - 1) * matchesPageSize;
|
||||
const matchesTo = matchesFrom + matchesPageSize - 1;
|
||||
|
||||
const { data: matchData, count: matchesCount, error: matchesError } = await supabase
|
||||
const { data: matchData, count: matchesCount, error: matchesError } = await adminData
|
||||
.from('matches')
|
||||
.select('id, match_type, is_new, created_at, question:questions(id, text)', { count: 'exact' })
|
||||
.eq('couple_id', profileData.couple_id)
|
||||
@@ -563,7 +537,7 @@ export function UserDetailPage() {
|
||||
|
||||
const matchIds = (matchData || []).map(m => m.id);
|
||||
if (matchIds.length > 0) {
|
||||
const { data: messages } = await supabase.from('messages').select('match_id').in('match_id', matchIds);
|
||||
const { data: messages } = await adminData.from('messages').select('match_id').in('match_id', matchIds);
|
||||
const messageCounts: Record<string, number> = {};
|
||||
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[]);
|
||||
@@ -571,7 +545,7 @@ export function UserDetailPage() {
|
||||
setMatches([]);
|
||||
}
|
||||
|
||||
const { data: allMatchIdsData } = await supabase
|
||||
const { data: allMatchIdsData } = await adminData
|
||||
.from('matches')
|
||||
.select('id')
|
||||
.eq('couple_id', profileData.couple_id);
|
||||
@@ -581,7 +555,7 @@ export function UserDetailPage() {
|
||||
const mediaFrom = (mediaPage - 1) * mediaPageSize;
|
||||
const mediaTo = mediaFrom + mediaPageSize - 1;
|
||||
|
||||
const { data: mediaMessagesData, error: mediaError, count: mediaCount } = await supabase
|
||||
const { data: mediaMessagesData, error: mediaError, count: mediaCount } = await adminData
|
||||
.from('messages')
|
||||
.select('id, user_id, media_path, media_type, version, created_at, keys_metadata, match_id', { count: 'exact' })
|
||||
.in('match_id', allMatchIds)
|
||||
@@ -602,7 +576,7 @@ export function UserDetailPage() {
|
||||
setMediaTotal(0);
|
||||
}
|
||||
// Fetch enabled packs for the couple
|
||||
const { data: enabledPacksData, error: packsError } = await supabase
|
||||
const { data: enabledPacksData, error: packsError } = await adminData
|
||||
.from('couple_packs')
|
||||
.select(`
|
||||
pack_id,
|
||||
@@ -631,17 +605,17 @@ export function UserDetailPage() {
|
||||
|
||||
// Fetch outstanding questions (partner answered, user hasn't)
|
||||
// Use partner from state (set earlier in the first couple_id block)
|
||||
const partnerProfile = partner || (await supabase.from('profiles').select('id, name').eq('couple_id', profileData.couple_id).neq('id', userId).maybeSingle()).data;
|
||||
const partnerProfile = partner || (await adminData.from('profiles').select('id, name').eq('couple_id', profileData.couple_id).neq('id', userId).maybeSingle()).data;
|
||||
if (partnerProfile) {
|
||||
// Get question IDs user has already answered
|
||||
const { data: userResponseIds } = await supabase
|
||||
const { data: userResponseIds } = await adminData
|
||||
.from('responses')
|
||||
.select('question_id')
|
||||
.eq('user_id', userId);
|
||||
const answeredIds = new Set((userResponseIds || []).map((r: any) => r.question_id));
|
||||
|
||||
// Get all partner responses in this couple
|
||||
const { data: partnerResponses } = await supabase
|
||||
const { data: partnerResponses } = await adminData
|
||||
.from('responses')
|
||||
.select(`
|
||||
question_id,
|
||||
@@ -658,7 +632,7 @@ export function UserDetailPage() {
|
||||
|
||||
if (unansweredQuestionIds.length > 0) {
|
||||
// Fetch question details (no pack filter — admin sees all)
|
||||
const { data: questionDetails } = await supabase
|
||||
const { data: questionDetails } = await adminData
|
||||
.from('questions')
|
||||
.select('id, text, intensity, pack:question_packs(id, name)')
|
||||
.in('id', unansweredQuestionIds);
|
||||
@@ -690,7 +664,7 @@ export function UserDetailPage() {
|
||||
}
|
||||
|
||||
// Calculate gap status
|
||||
const { data: appConfig } = await supabase
|
||||
const { data: appConfig } = await adminData
|
||||
.from('app_config')
|
||||
.select('answer_gap_threshold')
|
||||
.limit(1)
|
||||
@@ -702,7 +676,7 @@ export function UserDetailPage() {
|
||||
if (threshold > 0) {
|
||||
// Count questions user answered that partner hasn't
|
||||
const partnerAllAnswered = new Set((partnerResponses || []).map((r: any) => r.question_id));
|
||||
const { data: userResponses } = await supabase
|
||||
const { data: userResponses } = await adminData
|
||||
.from('responses')
|
||||
.select('question_id')
|
||||
.eq('user_id', userId)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { supabase } from '@/config';
|
||||
import { adminRequest } from '@/lib/adminApi';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -55,24 +55,7 @@ export function UsersPage() {
|
||||
setLoading(true);
|
||||
try {
|
||||
// Fetch profiles with auth info (includes last_sign_in_at)
|
||||
const { data: profilesDataRaw, error } = await supabase
|
||||
.rpc('get_profiles_with_auth_info');
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
const profilesData = (profilesDataRaw || []) as Profile[];
|
||||
|
||||
// Fetch storage usage per user from storage.objects
|
||||
const { data: storageData } = await supabase
|
||||
.rpc('get_user_storage_usage');
|
||||
|
||||
// Create a map of user_id -> storage_bytes
|
||||
const storageMap: Record<string, number> = {};
|
||||
if (storageData) {
|
||||
storageData.forEach((row: { owner: string; total_bytes: number }) => {
|
||||
storageMap[row.owner] = row.total_bytes;
|
||||
});
|
||||
}
|
||||
const { users: profilesData } = await adminRequest<{ users: Profile[] }>('/v1/admin/users');
|
||||
|
||||
// Build a map of couple_id -> users with premium in that couple
|
||||
const couplePartners: Record<string, { id: string; is_premium: boolean }[]> = {};
|
||||
@@ -100,7 +83,7 @@ export function UsersPage() {
|
||||
}
|
||||
return {
|
||||
...profile,
|
||||
storage_bytes: storageMap[profile.id] || 0,
|
||||
storage_bytes: Number(profile.storage_bytes || 0),
|
||||
partner_is_premium: partnerIsPremium,
|
||||
partner_count: partnerCount,
|
||||
};
|
||||
|
||||
Reference in new issue
Block a user