mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-10-31 18:19:43 +00:00
Update
This commit is contained in:
1 parent
e7379ccdb1
commit
d18f9d32b0
49 files changed
+3739
-665
No files matched your search
@@ -3,6 +3,8 @@ services:
|
||||
build:
|
||||
context: ../..
|
||||
dockerfile: apps/admin/Dockerfile.dev
|
||||
ports:
|
||||
- "3005:3001"
|
||||
environment:
|
||||
- VITE_SUPABASE_URL=${VITE_SUPABASE_URL}
|
||||
- VITE_SUPABASE_ANON_KEY=${VITE_SUPABASE_ANON_KEY}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<html lang="en" class="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 68 KiB |
+51
-12
@@ -1,5 +1,5 @@
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { AuthProvider } from '@/contexts/AuthContext';
|
||||
import { AuthProvider, PERMISSION_KEYS } from '@/contexts/AuthContext';
|
||||
import { ProtectedRoute } from '@/components/ProtectedRoute';
|
||||
import { AppLayout } from '@/components/layout/AppLayout';
|
||||
import { LoginPage } from '@/pages/LoginPage';
|
||||
@@ -13,6 +13,8 @@ import { MatchChatPage } from '@/pages/users/MatchChatPage';
|
||||
import { AuditLogsPage } from '@/pages/AuditLogsPage';
|
||||
import { AdminsPage } from '@/pages/admins/AdminsPage';
|
||||
import { RedemptionCodesPage } from '@/pages/RedemptionCodesPage';
|
||||
import { FeedbackPage } from '@/pages/FeedbackPage';
|
||||
import { UsageInsightsPage } from '@/pages/UsageInsightsPage';
|
||||
|
||||
function App() {
|
||||
return (
|
||||
@@ -34,15 +36,36 @@ function App() {
|
||||
<Route path="/" element={<DashboardPage />} />
|
||||
|
||||
{/* Content management */}
|
||||
<Route path="/categories" element={<CategoriesPage />} />
|
||||
<Route path="/categories/:categoryId/packs" element={<PacksPage />} />
|
||||
<Route path="/packs/:packId/questions" element={<QuestionsPage />} />
|
||||
<Route
|
||||
path="/categories"
|
||||
element={
|
||||
<ProtectedRoute requiredPermission={PERMISSION_KEYS.MANAGE_CATEGORIES}>
|
||||
<CategoriesPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/categories/:categoryId/packs"
|
||||
element={
|
||||
<ProtectedRoute requiredPermission={PERMISSION_KEYS.MANAGE_PACKS}>
|
||||
<PacksPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/packs/:packId/questions"
|
||||
element={
|
||||
<ProtectedRoute requiredPermission={PERMISSION_KEYS.MANAGE_QUESTIONS}>
|
||||
<QuestionsPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* User management (super_admin only) */}
|
||||
{/* User management */}
|
||||
<Route
|
||||
path="/users"
|
||||
element={
|
||||
<ProtectedRoute requireSuperAdmin>
|
||||
<ProtectedRoute requiredPermission={PERMISSION_KEYS.VIEW_USERS}>
|
||||
<UsersPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
@@ -50,7 +73,7 @@ function App() {
|
||||
<Route
|
||||
path="/users/:userId"
|
||||
element={
|
||||
<ProtectedRoute requireSuperAdmin>
|
||||
<ProtectedRoute requiredPermission={PERMISSION_KEYS.VIEW_USERS}>
|
||||
<UserDetailPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
@@ -58,17 +81,25 @@ function App() {
|
||||
<Route
|
||||
path="/users/:userId/matches/:matchId"
|
||||
element={
|
||||
<ProtectedRoute requireSuperAdmin>
|
||||
<ProtectedRoute requiredPermission={PERMISSION_KEYS.VIEW_CHATS}>
|
||||
<MatchChatPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/usage-insights"
|
||||
element={
|
||||
<ProtectedRoute requiredPermission={PERMISSION_KEYS.VIEW_USERS}>
|
||||
<UsageInsightsPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* System (super_admin only) */}
|
||||
{/* System */}
|
||||
<Route
|
||||
path="/admins"
|
||||
element={
|
||||
<ProtectedRoute requireSuperAdmin>
|
||||
<ProtectedRoute requiredPermission={PERMISSION_KEYS.MANAGE_ADMINS}>
|
||||
<AdminsPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
@@ -76,7 +107,7 @@ function App() {
|
||||
<Route
|
||||
path="/redemption-codes"
|
||||
element={
|
||||
<ProtectedRoute requireSuperAdmin>
|
||||
<ProtectedRoute requiredPermission={PERMISSION_KEYS.MANAGE_CODES}>
|
||||
<RedemptionCodesPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
@@ -84,11 +115,19 @@ function App() {
|
||||
<Route
|
||||
path="/audit-logs"
|
||||
element={
|
||||
<ProtectedRoute requireSuperAdmin>
|
||||
<ProtectedRoute requiredPermission={PERMISSION_KEYS.VIEW_AUDIT_LOGS}>
|
||||
<AuditLogsPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/feedback"
|
||||
element={
|
||||
<ProtectedRoute requiredPermission={PERMISSION_KEYS.VIEW_USERS}>
|
||||
<FeedbackPage />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
</Route>
|
||||
|
||||
{/* Catch all - redirect to dashboard */}
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 68 KiB |
@@ -1,14 +1,15 @@
|
||||
import { Navigate } from 'react-router-dom';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { useAuth, PermissionKey } from '@/contexts/AuthContext';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
interface ProtectedRouteProps {
|
||||
children: React.ReactNode;
|
||||
requireSuperAdmin?: boolean;
|
||||
requiredPermission?: PermissionKey;
|
||||
}
|
||||
|
||||
export function ProtectedRoute({ children, requireSuperAdmin = false }: ProtectedRouteProps) {
|
||||
const { session, loading, isSuperAdmin } = useAuth();
|
||||
export function ProtectedRoute({ children, requireSuperAdmin = false, requiredPermission }: ProtectedRouteProps) {
|
||||
const { session, loading, isSuperAdmin, hasPermission } = useAuth();
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
@@ -26,5 +27,9 @@ export function ProtectedRoute({ children, requireSuperAdmin = false }: Protecte
|
||||
return <Navigate to="/" replace />;
|
||||
}
|
||||
|
||||
if (requiredPermission && !hasPermission(requiredPermission)) {
|
||||
return <Navigate to="/" replace />;
|
||||
}
|
||||
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -6,19 +6,33 @@ import { Toaster } from 'sonner';
|
||||
export function AppLayout() {
|
||||
return (
|
||||
<div className="flex h-screen overflow-hidden bg-background">
|
||||
{/* Subtle gradient overlay */}
|
||||
<div className="fixed inset-0 bg-gradient-to-br from-primary/[0.02] via-transparent to-transparent pointer-events-none" />
|
||||
|
||||
<Sidebar />
|
||||
<div className="flex flex-1 flex-col overflow-hidden">
|
||||
<div className="flex flex-1 flex-col overflow-hidden relative">
|
||||
{/* Header */}
|
||||
<header className="flex h-16 items-center border-b px-6">
|
||||
<header className="flex h-16 items-center border-b border-white/5 px-8 bg-card/50 backdrop-blur-sm">
|
||||
<Breadcrumbs />
|
||||
</header>
|
||||
|
||||
{/* Main content */}
|
||||
<main className="flex-1 overflow-auto p-6">
|
||||
<Outlet />
|
||||
<main className="flex-1 overflow-auto p-8">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
<Outlet />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
<Toaster position="top-right" richColors />
|
||||
<Toaster
|
||||
position="top-right"
|
||||
toastOptions={{
|
||||
style: {
|
||||
background: 'hsl(240 8% 10%)',
|
||||
border: '1px solid rgba(255,255,255,0.1)',
|
||||
color: 'hsl(30 20% 95%)',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -13,11 +13,17 @@ interface PackInfo {
|
||||
category_name: string | null;
|
||||
}
|
||||
|
||||
interface UserInfo {
|
||||
id: string;
|
||||
name: string | null;
|
||||
}
|
||||
|
||||
// Generate breadcrumbs based on current path and params
|
||||
function useBreadcrumbs(): { breadcrumbs: BreadcrumbItem[]; loading: boolean } {
|
||||
const location = useLocation();
|
||||
const params = useParams();
|
||||
const [packInfo, setPackInfo] = useState<PackInfo | null>(null);
|
||||
const [userInfo, setUserInfo] = useState<UserInfo | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// Fetch pack info when on questions page to get category_id
|
||||
@@ -51,6 +57,37 @@ function useBreadcrumbs(): { breadcrumbs: BreadcrumbItem[]; loading: boolean } {
|
||||
fetchPackInfo();
|
||||
}, [params.packId, params.categoryId]);
|
||||
|
||||
// Fetch user info when on user detail or match chat page
|
||||
useEffect(() => {
|
||||
const fetchUserInfo = async () => {
|
||||
if (params.userId) {
|
||||
setLoading(true);
|
||||
try {
|
||||
const { data } = await supabase
|
||||
.from('profiles')
|
||||
.select('id, name')
|
||||
.eq('id', params.userId)
|
||||
.single();
|
||||
|
||||
if (data) {
|
||||
setUserInfo({
|
||||
id: data.id,
|
||||
name: data.name,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch user info for breadcrumbs:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
} else {
|
||||
setUserInfo(null);
|
||||
}
|
||||
};
|
||||
|
||||
fetchUserInfo();
|
||||
}, [params.userId]);
|
||||
|
||||
const pathSegments = location.pathname.split('/').filter(Boolean);
|
||||
const breadcrumbs: BreadcrumbItem[] = [];
|
||||
|
||||
@@ -74,6 +111,13 @@ function useBreadcrumbs(): { breadcrumbs: BreadcrumbItem[]; loading: boolean } {
|
||||
breadcrumbs.push({ label: 'Questions', href: currentPath });
|
||||
} else if (segment === 'users') {
|
||||
breadcrumbs.push({ label: 'Users', href: '/users' });
|
||||
// If we have a userId, add the user's name as a breadcrumb
|
||||
if (params.userId && userInfo) {
|
||||
breadcrumbs.push({
|
||||
label: userInfo.name || 'User',
|
||||
href: `/users/${userInfo.id}`,
|
||||
});
|
||||
}
|
||||
} else if (segment === 'matches' && params.userId) {
|
||||
breadcrumbs.push({ label: 'Match Chat' });
|
||||
} else if (segment === 'audit-logs') {
|
||||
@@ -100,26 +144,28 @@ export function Breadcrumbs() {
|
||||
if (breadcrumbs.length === 0 && !loading) return null;
|
||||
|
||||
return (
|
||||
<nav className="flex items-center space-x-1 text-sm text-muted-foreground">
|
||||
<nav className="flex items-center space-x-1 text-sm">
|
||||
<Link
|
||||
to="/"
|
||||
className="flex items-center hover:text-foreground transition-colors"
|
||||
className="flex items-center text-muted-foreground hover:text-primary transition-colors p-1 rounded-md hover:bg-white/5"
|
||||
>
|
||||
<Home className="h-4 w-4" />
|
||||
</Link>
|
||||
|
||||
{breadcrumbs.map((item, index) => (
|
||||
<div key={index} className="flex items-center">
|
||||
<ChevronRight className="h-4 w-4 mx-1" />
|
||||
<ChevronRight className="h-4 w-4 mx-1 text-muted-foreground/50" />
|
||||
{item.href ? (
|
||||
<Link
|
||||
to={item.href}
|
||||
className="hover:text-foreground transition-colors"
|
||||
className="text-muted-foreground hover:text-primary transition-colors px-2 py-1 rounded-md hover:bg-white/5"
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="text-foreground font-medium">{item.label}</span>
|
||||
<span className="text-foreground font-medium px-2 py-1">
|
||||
{item.label}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Link, useLocation } from 'react-router-dom';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { useAuth, PERMISSION_KEYS, PermissionKey } from '@/contexts/AuthContext';
|
||||
import {
|
||||
LayoutGrid,
|
||||
Users,
|
||||
@@ -8,20 +8,22 @@ import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
LogOut,
|
||||
Sparkles,
|
||||
Shield,
|
||||
Ticket,
|
||||
MessageSquareText,
|
||||
Target,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { Avatar, AvatarFallback } from '@/components/ui/avatar';
|
||||
import { useState } from 'react';
|
||||
import logoImage from '@/assets/logo.png';
|
||||
|
||||
interface NavItem {
|
||||
label: string;
|
||||
href: string;
|
||||
icon: React.ReactNode;
|
||||
requireSuperAdmin?: boolean;
|
||||
requiredPermission?: PermissionKey;
|
||||
}
|
||||
|
||||
const contentNav: NavItem[] = [
|
||||
@@ -29,6 +31,7 @@ const contentNav: NavItem[] = [
|
||||
label: 'Categories',
|
||||
href: '/categories',
|
||||
icon: <LayoutGrid className="h-5 w-5" />,
|
||||
requiredPermission: PERMISSION_KEYS.MANAGE_CATEGORIES,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -37,7 +40,19 @@ const usersNav: NavItem[] = [
|
||||
label: 'Users',
|
||||
href: '/users',
|
||||
icon: <Users className="h-5 w-5" />,
|
||||
requireSuperAdmin: true,
|
||||
requiredPermission: PERMISSION_KEYS.VIEW_USERS,
|
||||
},
|
||||
{
|
||||
label: 'Usage Insights',
|
||||
href: '/usage-insights',
|
||||
icon: <Target className="h-5 w-5" />,
|
||||
requiredPermission: PERMISSION_KEYS.VIEW_USERS,
|
||||
},
|
||||
{
|
||||
label: 'Feedback',
|
||||
href: '/feedback',
|
||||
icon: <MessageSquareText className="h-5 w-5" />,
|
||||
requiredPermission: PERMISSION_KEYS.VIEW_USERS,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -46,29 +61,29 @@ const systemNav: NavItem[] = [
|
||||
label: 'Admins',
|
||||
href: '/admins',
|
||||
icon: <Shield className="h-5 w-5" />,
|
||||
requireSuperAdmin: true,
|
||||
requiredPermission: PERMISSION_KEYS.MANAGE_ADMINS,
|
||||
},
|
||||
{
|
||||
label: 'Redemption Codes',
|
||||
href: '/redemption-codes',
|
||||
icon: <Ticket className="h-5 w-5" />,
|
||||
requireSuperAdmin: true,
|
||||
requiredPermission: PERMISSION_KEYS.MANAGE_CODES,
|
||||
},
|
||||
{
|
||||
label: 'Audit Logs',
|
||||
href: '/audit-logs',
|
||||
icon: <ClipboardList className="h-5 w-5" />,
|
||||
requireSuperAdmin: true,
|
||||
requiredPermission: PERMISSION_KEYS.VIEW_AUDIT_LOGS,
|
||||
},
|
||||
];
|
||||
|
||||
export function Sidebar() {
|
||||
const location = useLocation();
|
||||
const { user, permissions, logout, isSuperAdmin } = useAuth();
|
||||
const { user, permissions, logout, hasPermission } = useAuth();
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
|
||||
const NavLink = ({ item }: { item: NavItem }) => {
|
||||
if (item.requireSuperAdmin && !isSuperAdmin) return null;
|
||||
if (item.requiredPermission && !hasPermission(item.requiredPermission)) return null;
|
||||
|
||||
const isActive = location.pathname.startsWith(item.href);
|
||||
|
||||
@@ -76,27 +91,32 @@ export function Sidebar() {
|
||||
<Link
|
||||
to={item.href}
|
||||
className={cn(
|
||||
"flex items-center gap-3 rounded-lg px-3 py-2 text-sm transition-colors",
|
||||
"group flex items-center gap-3 rounded-xl px-3 py-2.5 text-sm font-medium transition-all duration-200",
|
||||
isActive
|
||||
? "bg-primary text-primary-foreground"
|
||||
: "text-muted-foreground hover:bg-accent hover:text-accent-foreground",
|
||||
? "bg-gradient-rose text-white shadow-lg glow-rose-sm"
|
||||
: "text-muted-foreground hover:bg-white/5 hover:text-foreground",
|
||||
collapsed && "justify-center px-2"
|
||||
)}
|
||||
>
|
||||
{item.icon}
|
||||
<span className={cn(
|
||||
"transition-transform duration-200",
|
||||
!isActive && "group-hover:scale-110"
|
||||
)}>
|
||||
{item.icon}
|
||||
</span>
|
||||
{!collapsed && <span>{item.label}</span>}
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
|
||||
const NavSection = ({ title, items }: { title: string; items: NavItem[] }) => {
|
||||
const visibleItems = items.filter(item => !item.requireSuperAdmin || isSuperAdmin);
|
||||
const visibleItems = items.filter(item => !item.requiredPermission || hasPermission(item.requiredPermission));
|
||||
if (visibleItems.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
{!collapsed && (
|
||||
<h4 className="px-3 text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
<h4 className="px-3 py-2 text-[11px] font-semibold uppercase tracking-widest text-muted-foreground">
|
||||
{title}
|
||||
</h4>
|
||||
)}
|
||||
@@ -110,23 +130,50 @@ export function Sidebar() {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex h-screen flex-col border-r bg-card transition-all duration-300",
|
||||
collapsed ? "w-16" : "w-64"
|
||||
"flex h-screen flex-col border-r border-white/5 bg-gradient-to-b from-card to-background transition-all duration-300",
|
||||
collapsed ? "w-20" : "w-72"
|
||||
)}
|
||||
>
|
||||
{/* Logo */}
|
||||
<div className="flex h-16 items-center justify-between border-b px-4">
|
||||
<div className="flex h-20 items-center justify-between px-4 border-b border-white/5">
|
||||
{!collapsed && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles className="h-6 w-6 text-primary" />
|
||||
<span className="text-lg font-bold">Sauci Admin</span>
|
||||
</div>
|
||||
<Link to="/" className="flex items-center gap-3 group">
|
||||
<div className="relative">
|
||||
<img
|
||||
src={logoImage}
|
||||
alt="Sauci"
|
||||
className="h-10 w-10 object-contain transition-transform duration-300 group-hover:scale-105"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-rose opacity-0 group-hover:opacity-20 rounded-full blur-xl transition-opacity duration-300" />
|
||||
</div>
|
||||
<div className="flex flex-col">
|
||||
<span className="text-xl font-bold tracking-tight text-gradient-sunset">
|
||||
Sauci
|
||||
</span>
|
||||
<span className="text-[10px] font-medium uppercase tracking-widest text-muted-foreground">
|
||||
Admin Portal
|
||||
</span>
|
||||
</div>
|
||||
</Link>
|
||||
)}
|
||||
{collapsed && (
|
||||
<Link to="/" className="mx-auto">
|
||||
<img
|
||||
src={logoImage}
|
||||
alt="Sauci"
|
||||
className="h-10 w-10 object-contain"
|
||||
/>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Collapse button */}
|
||||
<div className="flex justify-end px-2 py-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setCollapsed(!collapsed)}
|
||||
className={cn(collapsed && "mx-auto")}
|
||||
className="h-8 w-8 rounded-lg text-muted-foreground hover:text-foreground hover:bg-white/5"
|
||||
>
|
||||
{collapsed ? (
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
@@ -137,25 +184,30 @@ export function Sidebar() {
|
||||
</div>
|
||||
|
||||
{/* Navigation */}
|
||||
<div className="flex-1 space-y-6 overflow-auto p-4">
|
||||
<div className="flex-1 space-y-6 overflow-auto px-3 py-2">
|
||||
<NavSection title="Content" items={contentNav} />
|
||||
<NavSection title="Users" items={usersNav} />
|
||||
<NavSection title="System" items={systemNav} />
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
<Separator className="bg-white/5" />
|
||||
|
||||
{/* User info */}
|
||||
<div className={cn("p-4", collapsed && "flex flex-col items-center")}>
|
||||
<div className={cn("flex items-center gap-3", collapsed && "flex-col")}>
|
||||
<Avatar className="h-8 w-8">
|
||||
<AvatarFallback>
|
||||
<div className={cn(
|
||||
"flex items-center gap-3 rounded-xl p-2 transition-colors hover:bg-white/5",
|
||||
collapsed && "flex-col p-2"
|
||||
)}>
|
||||
<Avatar className="h-9 w-9 ring-2 ring-primary/20">
|
||||
<AvatarFallback className="bg-gradient-rose text-white font-semibold">
|
||||
{user?.email?.charAt(0).toUpperCase() || 'A'}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
{!collapsed && (
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<p className="truncate text-sm font-medium">{user?.email}</p>
|
||||
<p className="truncate text-sm font-medium text-foreground">
|
||||
{user?.email}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground capitalize">
|
||||
{permissions?.role?.replace('_', ' ')}
|
||||
</p>
|
||||
@@ -165,11 +217,14 @@ export function Sidebar() {
|
||||
<Button
|
||||
variant="ghost"
|
||||
size={collapsed ? "icon" : "sm"}
|
||||
className={cn("mt-3", collapsed ? "w-8 h-8" : "w-full")}
|
||||
className={cn(
|
||||
"mt-2 text-muted-foreground hover:text-destructive hover:bg-destructive/10 transition-colors",
|
||||
collapsed ? "w-9 h-9" : "w-full justify-start"
|
||||
)}
|
||||
onClick={logout}
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
{!collapsed && <span className="ml-2">Logout</span>}
|
||||
{!collapsed && <span className="ml-2">Sign Out</span>}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3,21 +3,21 @@ import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||
"inline-flex items-center rounded-lg border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
|
||||
"border-primary/30 bg-primary text-white",
|
||||
secondary:
|
||||
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
"border-white/20 bg-white/10 text-white",
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
|
||||
outline: "text-foreground",
|
||||
"border-red-500/30 bg-red-500 text-white",
|
||||
outline: "border-white/30 bg-transparent text-white",
|
||||
success:
|
||||
"border-transparent bg-green-500 text-white hover:bg-green-500/80",
|
||||
"border-emerald-500/30 bg-emerald-500 text-white",
|
||||
warning:
|
||||
"border-transparent bg-yellow-500 text-white hover:bg-yellow-500/80",
|
||||
"border-amber-500/30 bg-amber-500 text-black",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
|
||||
@@ -4,24 +4,24 @@ import { cva, type VariantProps } from "class-variance-authority";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const buttonVariants = cva(
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
|
||||
"inline-flex items-center justify-center whitespace-nowrap rounded-xl text-sm font-medium ring-offset-background transition-all duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90",
|
||||
default: "bg-primary text-primary-foreground hover:bg-primary/90 shadow-sm hover:shadow-md",
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
|
||||
outline:
|
||||
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
|
||||
"border border-white/10 bg-transparent hover:bg-white/5 hover:border-white/20",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
ghost: "hover:bg-white/5 hover:text-foreground",
|
||||
link: "text-primary underline-offset-4 hover:underline",
|
||||
},
|
||||
size: {
|
||||
default: "h-10 px-4 py-2",
|
||||
sm: "h-9 rounded-md px-3",
|
||||
lg: "h-11 rounded-md px-8",
|
||||
sm: "h-9 rounded-lg px-3",
|
||||
lg: "h-11 rounded-xl px-8",
|
||||
icon: "h-10 w-10",
|
||||
},
|
||||
},
|
||||
|
||||
@@ -8,7 +8,7 @@ const Card = React.forwardRef<
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"rounded-lg border bg-card text-card-foreground shadow-sm",
|
||||
"rounded-xl border border-white/5 bg-card text-card-foreground shadow-sm transition-all duration-200",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -35,7 +35,7 @@ const CardTitle = React.forwardRef<
|
||||
<h3
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"text-2xl font-semibold leading-none tracking-tight",
|
||||
"text-xl font-semibold leading-none tracking-tight",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -10,7 +10,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
"flex h-10 w-full rounded-xl border border-white/10 bg-white/5 px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/30 focus-visible:border-primary/50 disabled:cursor-not-allowed disabled:opacity-50 transition-all duration-200",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
|
||||
@@ -5,7 +5,7 @@ const Table = React.forwardRef<
|
||||
HTMLTableElement,
|
||||
React.HTMLAttributes<HTMLTableElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div className="relative w-full overflow-auto">
|
||||
<div className="relative w-full overflow-auto rounded-xl border border-white/5">
|
||||
<table
|
||||
ref={ref}
|
||||
className={cn("w-full caption-bottom text-sm", className)}
|
||||
@@ -19,7 +19,7 @@ const TableHeader = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
|
||||
<thead ref={ref} className={cn("[&_tr]:border-b [&_tr]:border-white/5 bg-white/[0.02]", className)} {...props} />
|
||||
));
|
||||
TableHeader.displayName = "TableHeader";
|
||||
|
||||
@@ -42,7 +42,7 @@ const TableFooter = React.forwardRef<
|
||||
<tfoot
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
|
||||
"border-t border-white/5 bg-white/[0.02] font-medium [&>tr]:last:border-b-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -57,7 +57,7 @@ const TableRow = React.forwardRef<
|
||||
<tr
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
|
||||
"border-b border-white/5 transition-colors hover:bg-white/[0.02] data-[state=selected]:bg-white/5",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
@@ -72,7 +72,7 @@ const TableHead = React.forwardRef<
|
||||
<th
|
||||
ref={ref}
|
||||
className={cn(
|
||||
"h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0",
|
||||
"h-12 px-4 text-left align-middle font-semibold text-muted-foreground text-xs uppercase tracking-wider [&:has([role=checkbox])]:pr-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
@@ -1,37 +1,51 @@
|
||||
import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react';
|
||||
import { createContext, useContext, useState, useEffect, ReactNode } from 'react';
|
||||
import { supabase } from '../config';
|
||||
import type { User, Session } from '@supabase/supabase-js';
|
||||
|
||||
// Define admin roles
|
||||
export type AdminRole = 'pack_creator' | 'super_admin';
|
||||
|
||||
// Available permission keys
|
||||
export const PERMISSION_KEYS = {
|
||||
// Content permissions
|
||||
MANAGE_PACKS: 'manage_packs',
|
||||
MANAGE_QUESTIONS: 'manage_questions',
|
||||
MANAGE_CATEGORIES: 'manage_categories',
|
||||
// User permissions
|
||||
VIEW_USERS: 'view_users',
|
||||
VIEW_CHATS: 'view_chats',
|
||||
VIEW_MEDIA: 'view_media',
|
||||
VIEW_MATCHES: 'view_matches',
|
||||
VIEW_RESPONSES: 'view_responses',
|
||||
// System permissions
|
||||
MANAGE_CODES: 'manage_codes',
|
||||
MANAGE_ADMINS: 'manage_admins',
|
||||
VIEW_AUDIT_LOGS: 'view_audit_logs',
|
||||
} as const;
|
||||
|
||||
export type PermissionKey = typeof PERMISSION_KEYS[keyof typeof PERMISSION_KEYS];
|
||||
|
||||
// Permission metadata for UI
|
||||
export const PERMISSION_METADATA: Record<PermissionKey, { label: string; description: string; group: 'content' | 'users' | 'system' }> = {
|
||||
[PERMISSION_KEYS.MANAGE_CATEGORIES]: { label: 'Can manage categories', description: 'Create, edit, delete categories', group: 'content' },
|
||||
[PERMISSION_KEYS.MANAGE_PACKS]: { label: 'Can manage packs', description: 'Create, edit, delete question packs', group: 'content' },
|
||||
[PERMISSION_KEYS.MANAGE_QUESTIONS]: { label: 'Can manage questions', description: 'Create, edit, delete questions', group: 'content' },
|
||||
[PERMISSION_KEYS.VIEW_USERS]: { label: 'Can view users', description: 'View user profiles and list', group: 'users' },
|
||||
[PERMISSION_KEYS.VIEW_CHATS]: { label: 'Can view chats', description: 'View chat messages between users', group: 'users' },
|
||||
[PERMISSION_KEYS.VIEW_MEDIA]: { label: 'Can view media', description: 'View user photos and media', group: 'users' },
|
||||
[PERMISSION_KEYS.VIEW_MATCHES]: { label: 'Can view matches', description: 'View user matches and match details', group: 'users' },
|
||||
[PERMISSION_KEYS.VIEW_RESPONSES]: { label: 'Can view responses', description: 'View user question responses', group: 'users' },
|
||||
[PERMISSION_KEYS.MANAGE_CODES]: { label: 'Can manage redemption codes', description: 'Create and view redemption codes', group: 'system' },
|
||||
[PERMISSION_KEYS.MANAGE_ADMINS]: { label: 'Can manage admins', description: 'Add/remove admin users and change permissions', group: 'system' },
|
||||
[PERMISSION_KEYS.VIEW_AUDIT_LOGS]: { label: 'Can view audit logs', description: 'Access audit log history', group: 'system' },
|
||||
};
|
||||
|
||||
// Permission structure
|
||||
export interface AdminPermissions {
|
||||
role: AdminRole;
|
||||
permissions: Array<{ action: string; resource: string }>;
|
||||
permissions: string[];
|
||||
}
|
||||
|
||||
// Define permissions by role
|
||||
const ROLE_PERMISSIONS: Record<AdminRole, Array<{ action: string; resource: string }>> = {
|
||||
pack_creator: [
|
||||
{ action: 'list', resource: 'categories' },
|
||||
{ action: 'create', resource: 'categories' },
|
||||
{ action: 'edit', resource: 'categories' },
|
||||
{ action: 'list', resource: 'question_packs' },
|
||||
{ action: 'create', resource: 'question_packs' },
|
||||
{ action: 'edit', resource: 'question_packs' },
|
||||
{ action: 'delete', resource: 'question_packs' },
|
||||
{ action: 'list', resource: 'questions' },
|
||||
{ action: 'create', resource: 'questions' },
|
||||
{ action: 'edit', resource: 'questions' },
|
||||
{ action: 'delete', resource: 'questions' },
|
||||
],
|
||||
super_admin: [
|
||||
// Full access to all resources
|
||||
{ action: '*', resource: '*' },
|
||||
],
|
||||
};
|
||||
|
||||
interface AuthContextType {
|
||||
user: User | null;
|
||||
session: Session | null;
|
||||
@@ -39,7 +53,7 @@ interface AuthContextType {
|
||||
loading: boolean;
|
||||
login: (email: string, password: string) => Promise<void>;
|
||||
logout: () => Promise<void>;
|
||||
canAccess: (resource: string, action: string) => boolean;
|
||||
hasPermission: (permission: PermissionKey) => boolean;
|
||||
isSuperAdmin: boolean;
|
||||
}
|
||||
|
||||
@@ -94,10 +108,10 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
throw new Error(error.message);
|
||||
}
|
||||
|
||||
// Fetch admin role
|
||||
// Fetch admin role and permissions
|
||||
const { data: adminData, error: adminError } = await supabase
|
||||
.from('admin_users')
|
||||
.select('role')
|
||||
.select('role, permissions')
|
||||
.eq('user_id', data.user.id)
|
||||
.single();
|
||||
|
||||
@@ -106,10 +120,10 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
throw new Error('Access denied. You are not an administrator.');
|
||||
}
|
||||
|
||||
// Store permissions
|
||||
// Store permissions from database
|
||||
const perms: AdminPermissions = {
|
||||
role: adminData.role as AdminRole,
|
||||
permissions: ROLE_PERMISSIONS[adminData.role as AdminRole],
|
||||
permissions: (adminData.permissions as string[]) || [],
|
||||
};
|
||||
|
||||
localStorage.setItem(PERMISSIONS_KEY, JSON.stringify(perms));
|
||||
@@ -122,22 +136,18 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
await supabase.auth.signOut();
|
||||
};
|
||||
|
||||
const canAccess = (resource: string, action: string): boolean => {
|
||||
const isSuperAdmin = permissions?.role === 'super_admin';
|
||||
|
||||
const hasPermission = (permission: PermissionKey): boolean => {
|
||||
if (!permissions) return false;
|
||||
|
||||
// Check for wildcard (super_admin)
|
||||
if (permissions.permissions.some(p => p.action === '*' && p.resource === '*')) {
|
||||
return true;
|
||||
}
|
||||
// Super admins have all permissions
|
||||
if (isSuperAdmin) return true;
|
||||
|
||||
// Check specific permission
|
||||
return permissions.permissions.some(
|
||||
p => p.resource === resource && (p.action === action || p.action === '*')
|
||||
);
|
||||
// Check if user has the specific permission
|
||||
return permissions.permissions.includes(permission);
|
||||
};
|
||||
|
||||
const isSuperAdmin = permissions?.role === 'super_admin';
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{
|
||||
user,
|
||||
@@ -146,7 +156,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
loading,
|
||||
login,
|
||||
logout,
|
||||
canAccess,
|
||||
hasPermission,
|
||||
isSuperAdmin,
|
||||
}}>
|
||||
{children}
|
||||
|
||||
+182
-38
@@ -1,51 +1,55 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=DM+Sans:wght@300;400;500;600;700&family=DM+Serif+Display&display=swap');
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 222.2 84% 4.9%;
|
||||
/* Premium Light Mode - Soft cream with rose gold */
|
||||
--background: 30 20% 98%;
|
||||
--foreground: 0 0% 10%;
|
||||
--card: 0 0% 100%;
|
||||
--card-foreground: 222.2 84% 4.9%;
|
||||
--card-foreground: 0 0% 10%;
|
||||
--popover: 0 0% 100%;
|
||||
--popover-foreground: 222.2 84% 4.9%;
|
||||
--primary: 346.8 77.2% 49.8%;
|
||||
--primary-foreground: 355.7 100% 97.3%;
|
||||
--secondary: 210 40% 96.1%;
|
||||
--secondary-foreground: 222.2 47.4% 11.2%;
|
||||
--muted: 210 40% 96.1%;
|
||||
--muted-foreground: 215.4 16.3% 46.9%;
|
||||
--accent: 210 40% 96.1%;
|
||||
--accent-foreground: 222.2 47.4% 11.2%;
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--border: 214.3 31.8% 91.4%;
|
||||
--input: 214.3 31.8% 91.4%;
|
||||
--ring: 346.8 77.2% 49.8%;
|
||||
--radius: 0.5rem;
|
||||
--popover-foreground: 0 0% 10%;
|
||||
--primary: 340 65% 47%;
|
||||
--primary-foreground: 0 0% 100%;
|
||||
--secondary: 30 15% 94%;
|
||||
--secondary-foreground: 0 0% 20%;
|
||||
--muted: 30 10% 94%;
|
||||
--muted-foreground: 0 0% 45%;
|
||||
--accent: 340 40% 94%;
|
||||
--accent-foreground: 340 65% 35%;
|
||||
--destructive: 0 72% 51%;
|
||||
--destructive-foreground: 0 0% 100%;
|
||||
--border: 30 15% 90%;
|
||||
--input: 30 15% 90%;
|
||||
--ring: 340 65% 47%;
|
||||
--radius: 0.75rem;
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: 222.2 84% 4.9%;
|
||||
--foreground: 210 40% 98%;
|
||||
--card: 222.2 84% 4.9%;
|
||||
--card-foreground: 210 40% 98%;
|
||||
--popover: 222.2 84% 4.9%;
|
||||
--popover-foreground: 210 40% 98%;
|
||||
--primary: 346.8 77.2% 49.8%;
|
||||
--primary-foreground: 355.7 100% 97.3%;
|
||||
--secondary: 217.2 32.6% 17.5%;
|
||||
--secondary-foreground: 210 40% 98%;
|
||||
--muted: 217.2 32.6% 17.5%;
|
||||
--muted-foreground: 215 20.2% 65.1%;
|
||||
--accent: 217.2 32.6% 17.5%;
|
||||
--accent-foreground: 210 40% 98%;
|
||||
--destructive: 0 62.8% 30.6%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--border: 217.2 32.6% 17.5%;
|
||||
--input: 217.2 32.6% 17.5%;
|
||||
--ring: 346.8 77.2% 49.8%;
|
||||
/* Premium Dark Mode - Deep charcoal with rose gold */
|
||||
--background: 240 10% 6%;
|
||||
--foreground: 0 0% 98%;
|
||||
--card: 240 8% 10%;
|
||||
--card-foreground: 0 0% 98%;
|
||||
--popover: 240 8% 10%;
|
||||
--popover-foreground: 0 0% 98%;
|
||||
--primary: 340 65% 55%;
|
||||
--primary-foreground: 0 0% 100%;
|
||||
--secondary: 240 6% 15%;
|
||||
--secondary-foreground: 0 0% 95%;
|
||||
--muted: 240 6% 15%;
|
||||
--muted-foreground: 0 0% 75%;
|
||||
--accent: 340 30% 20%;
|
||||
--accent-foreground: 340 65% 75%;
|
||||
--destructive: 0 62% 50%;
|
||||
--destructive-foreground: 0 0% 100%;
|
||||
--border: 240 6% 20%;
|
||||
--input: 240 6% 20%;
|
||||
--ring: 340 65% 55%;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,8 +57,148 @@
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
@apply bg-background text-foreground antialiased;
|
||||
font-family: 'DM Sans', system-ui, sans-serif;
|
||||
font-feature-settings: "rlig" 1, "calt" 1;
|
||||
}
|
||||
|
||||
h1, h2, h3 {
|
||||
font-family: 'DM Serif Display', Georgia, serif;
|
||||
@apply tracking-tight;
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
/* Premium gradient backgrounds */
|
||||
.bg-gradient-premium {
|
||||
background: linear-gradient(135deg,
|
||||
hsl(240 10% 6%) 0%,
|
||||
hsl(340 20% 10%) 50%,
|
||||
hsl(240 10% 6%) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.bg-gradient-card {
|
||||
background: linear-gradient(145deg,
|
||||
hsl(240 8% 12%) 0%,
|
||||
hsl(240 8% 10%) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.bg-gradient-rose {
|
||||
background: linear-gradient(135deg,
|
||||
hsl(340 65% 55%) 0%,
|
||||
hsl(320 60% 45%) 100%
|
||||
);
|
||||
}
|
||||
|
||||
.bg-gradient-sunset {
|
||||
background: linear-gradient(135deg,
|
||||
hsl(25 90% 55%) 0%,
|
||||
hsl(340 65% 50%) 50%,
|
||||
hsl(280 50% 40%) 100%
|
||||
);
|
||||
}
|
||||
|
||||
/* Glass effect */
|
||||
.glass {
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
|
||||
.glass-rose {
|
||||
background: rgba(200, 100, 130, 0.08);
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(200, 100, 130, 0.15);
|
||||
}
|
||||
|
||||
/* Glow effects */
|
||||
.glow-rose {
|
||||
box-shadow: 0 0 40px rgba(200, 80, 120, 0.15);
|
||||
}
|
||||
|
||||
.glow-rose-sm {
|
||||
box-shadow: 0 0 20px rgba(200, 80, 120, 0.1);
|
||||
}
|
||||
|
||||
/* Text gradients */
|
||||
.text-gradient-rose {
|
||||
background: linear-gradient(135deg, hsl(340 65% 60%) 0%, hsl(320 60% 50%) 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.text-gradient-sunset {
|
||||
background: linear-gradient(135deg, hsl(25 90% 60%) 0%, hsl(340 65% 55%) 50%, hsl(280 50% 50%) 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
/* Shine animation for buttons */
|
||||
@keyframes shine {
|
||||
0% { background-position: -100% 0; }
|
||||
100% { background-position: 200% 0; }
|
||||
}
|
||||
|
||||
.btn-shine {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.btn-shine::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
transparent 0%,
|
||||
rgba(255, 255, 255, 0.1) 50%,
|
||||
transparent 100%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
animation: shine 3s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Subtle pulse for active states */
|
||||
@keyframes subtle-pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.85; }
|
||||
}
|
||||
|
||||
.animate-subtle-pulse {
|
||||
animation: subtle-pulse 2s ease-in-out infinite;
|
||||
}
|
||||
}
|
||||
|
||||
/* Custom scrollbar for premium feel */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: hsl(240 10% 8%);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: hsl(240 6% 20%);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: hsl(340 30% 30%);
|
||||
}
|
||||
|
||||
/* Selection styling */
|
||||
::selection {
|
||||
background: hsl(340 65% 55% / 0.3);
|
||||
color: inherit;
|
||||
}
|
||||
@@ -1,16 +1,15 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { useAuth, PERMISSION_KEYS } from '@/contexts/AuthContext';
|
||||
import { supabase } from '@/config';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { LayoutGrid, Users, ClipboardList, ArrowRight, Activity } from 'lucide-react';
|
||||
import { LayoutGrid, Users, ClipboardList, ArrowRight, Activity, Target, TrendingUp, Heart, MessageCircle } from 'lucide-react';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Bar, BarChart, ResponsiveContainer, XAxis, YAxis, Tooltip } from 'recharts';
|
||||
import { format, subDays, startOfDay, endOfDay } from 'date-fns';
|
||||
|
||||
export function DashboardPage() {
|
||||
const { isSuperAdmin } = useAuth();
|
||||
const { hasPermission } = useAuth();
|
||||
const [stats, setStats] = useState({
|
||||
categories: 0,
|
||||
packs: 0,
|
||||
@@ -86,185 +85,190 @@ export function DashboardPage() {
|
||||
fetchStats();
|
||||
}, []);
|
||||
|
||||
const contentCards = [
|
||||
const quickLinks = [
|
||||
{
|
||||
title: 'Categories',
|
||||
description: 'Manage question categories and organize your packs',
|
||||
icon: <LayoutGrid className="h-8 w-8" />,
|
||||
description: 'Organize your question packs into themed collections',
|
||||
icon: LayoutGrid,
|
||||
href: '/categories',
|
||||
color: 'bg-rose-500',
|
||||
gradient: 'from-rose-500 to-pink-600',
|
||||
permission: PERMISSION_KEYS.MANAGE_CATEGORIES,
|
||||
},
|
||||
];
|
||||
|
||||
const adminCards = [
|
||||
{
|
||||
title: 'Users',
|
||||
description: 'View user profiles, responses, and chat history',
|
||||
icon: <Users className="h-8 w-8" />,
|
||||
description: 'View user profiles, responses, and activity',
|
||||
icon: Users,
|
||||
href: '/users',
|
||||
color: 'bg-blue-500',
|
||||
gradient: 'from-violet-500 to-purple-600',
|
||||
permission: PERMISSION_KEYS.VIEW_USERS,
|
||||
},
|
||||
{
|
||||
title: 'Usage Insights',
|
||||
description: 'Understand why users join and how they engage',
|
||||
icon: Target,
|
||||
href: '/usage-insights',
|
||||
gradient: 'from-amber-500 to-orange-600',
|
||||
permission: PERMISSION_KEYS.VIEW_USERS,
|
||||
},
|
||||
{
|
||||
title: 'Audit Logs',
|
||||
description: 'Track all admin actions and changes',
|
||||
icon: <ClipboardList className="h-8 w-8" />,
|
||||
description: 'Track all admin actions and system changes',
|
||||
icon: ClipboardList,
|
||||
href: '/audit-logs',
|
||||
color: 'bg-amber-500',
|
||||
gradient: 'from-emerald-500 to-teal-600',
|
||||
permission: PERMISSION_KEYS.VIEW_AUDIT_LOGS,
|
||||
},
|
||||
];
|
||||
|
||||
const statCards = [
|
||||
{ label: 'Categories', value: stats.categories, icon: LayoutGrid, color: 'text-rose-400' },
|
||||
{ label: 'Packs', value: stats.packs, icon: Heart, color: 'text-violet-400' },
|
||||
{ label: 'Questions', value: stats.questions, icon: MessageCircle, color: 'text-amber-400' },
|
||||
{ label: 'Active Users', value: stats.users, icon: Users, color: 'text-emerald-400', permission: PERMISSION_KEYS.VIEW_USERS },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">Dashboard</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Welcome back! Manage your Sauci content and users.
|
||||
</p>
|
||||
{/* Header */}
|
||||
<div className="relative">
|
||||
<div className="absolute -top-20 -left-20 w-64 h-64 bg-primary/5 rounded-full blur-3xl" />
|
||||
<div className="relative">
|
||||
<h1 className="text-4xl font-bold tracking-tight mb-2">
|
||||
Dashboard
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-lg">
|
||||
Welcome back. Here's what's happening with Sauci.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content Management */}
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold mb-4">Content Management</h2>
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{contentCards.map((card) => (
|
||||
<Card key={card.href} className="group hover:shadow-md transition-shadow">
|
||||
<CardHeader>
|
||||
<div className={`w-14 h-14 rounded-lg ${card.color} text-white flex items-center justify-center mb-2`}>
|
||||
{card.icon}
|
||||
{/* Stats Grid */}
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
{statCards
|
||||
.filter(card => !card.permission || hasPermission(card.permission))
|
||||
.map((stat) => (
|
||||
<Card key={stat.label} className="glass border-white/5 hover:border-white/10 transition-colors">
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground mb-1">
|
||||
{stat.label}
|
||||
</p>
|
||||
<div className="text-3xl font-bold">
|
||||
{loading ? (
|
||||
<Skeleton className="h-9 w-16" />
|
||||
) : (
|
||||
stat.value.toLocaleString()
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className={`p-3 rounded-xl bg-white/5 ${stat.color}`}>
|
||||
<stat.icon className="h-6 w-6" />
|
||||
</div>
|
||||
</div>
|
||||
<CardTitle>{card.title}</CardTitle>
|
||||
<CardDescription>{card.description}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Link to={card.href}>
|
||||
<Button variant="ghost" className="group-hover:translate-x-1 transition-transform">
|
||||
Open
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{/* Super Admin Only */}
|
||||
{isSuperAdmin && (
|
||||
<section>
|
||||
<h2 className="text-xl font-semibold mb-4">Administration</h2>
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{adminCards.map((card) => (
|
||||
<Card key={card.href} className="group hover:shadow-md transition-shadow">
|
||||
<CardHeader>
|
||||
<div className={`w-14 h-14 rounded-lg ${card.color} text-white flex items-center justify-center mb-2`}>
|
||||
{card.icon}
|
||||
</div>
|
||||
<CardTitle>{card.title}</CardTitle>
|
||||
<CardDescription>{card.description}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Link to={card.href}>
|
||||
<Button variant="ghost" className="group-hover:translate-x-1 transition-transform">
|
||||
Open
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
{/* Main Content Grid */}
|
||||
<div className="grid gap-6 lg:grid-cols-7">
|
||||
{/* Quick Links - 4 columns */}
|
||||
<div className="lg:col-span-4 space-y-4">
|
||||
<h2 className="text-xl font-semibold flex items-center gap-2">
|
||||
<TrendingUp className="h-5 w-5 text-primary" />
|
||||
Quick Access
|
||||
</h2>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
{quickLinks
|
||||
.filter(link => hasPermission(link.permission))
|
||||
.map((link) => (
|
||||
<Link key={link.href} to={link.href} className="group">
|
||||
<Card className="h-full glass border-white/5 hover:border-white/10 transition-all duration-300 hover:glow-rose-sm overflow-hidden">
|
||||
<CardHeader className="pb-2">
|
||||
<div className={`w-12 h-12 rounded-xl bg-gradient-to-br ${link.gradient} flex items-center justify-center mb-3 shadow-lg group-hover:scale-105 transition-transform duration-300`}>
|
||||
<link.icon className="h-6 w-6 text-white" />
|
||||
</div>
|
||||
<CardTitle className="text-lg group-hover:text-primary transition-colors">
|
||||
{link.title}
|
||||
</CardTitle>
|
||||
<CardDescription className="text-sm">
|
||||
{link.description}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
<div className="flex items-center text-sm text-muted-foreground group-hover:text-primary transition-colors">
|
||||
<span>Open</span>
|
||||
<ArrowRight className="ml-1 h-4 w-4 group-hover:translate-x-1 transition-transform" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-7">
|
||||
{/* Quick Stats - Takes up 4 columns */}
|
||||
<Card className="col-span-4">
|
||||
<CardHeader>
|
||||
<CardTitle>Quick Stats</CardTitle>
|
||||
<CardDescription>Overview of your platform's content and users</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium text-muted-foreground">Categories</p>
|
||||
<div className="text-2xl font-bold">
|
||||
{loading ? <Skeleton className="h-8 w-16" /> : stats.categories}
|
||||
</div>
|
||||
{/* Activity Chart - 3 columns */}
|
||||
<div className="lg:col-span-3">
|
||||
<Card className="h-full glass border-white/5">
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<Activity className="h-5 w-5 text-primary" />
|
||||
<CardTitle>Message Activity</CardTitle>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium text-muted-foreground">Packs</p>
|
||||
<div className="text-2xl font-bold">
|
||||
{loading ? <Skeleton className="h-8 w-16" /> : stats.packs}
|
||||
</div>
|
||||
<CardDescription>
|
||||
Messages sent over the last 7 days
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-[280px]">
|
||||
{loading ? (
|
||||
<Skeleton className="h-full w-full rounded-xl" />
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={messageStats}>
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
stroke="hsl(var(--muted-foreground))"
|
||||
fontSize={11}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
stroke="hsl(var(--muted-foreground))"
|
||||
fontSize={11}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickFormatter={(value) => `${value}`}
|
||||
/>
|
||||
<Tooltip
|
||||
cursor={{ fill: 'rgba(255,255,255,0.03)' }}
|
||||
contentStyle={{
|
||||
borderRadius: '12px',
|
||||
border: '1px solid rgba(255,255,255,0.1)',
|
||||
background: 'hsl(240 8% 10%)',
|
||||
boxShadow: '0 4px 20px rgba(0,0,0,0.3)',
|
||||
}}
|
||||
labelStyle={{ color: 'hsl(var(--foreground))' }}
|
||||
/>
|
||||
<defs>
|
||||
<linearGradient id="barGradient" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="0%" stopColor="hsl(340 65% 55%)" />
|
||||
<stop offset="100%" stopColor="hsl(320 60% 45%)" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<Bar
|
||||
dataKey="count"
|
||||
fill="url(#barGradient)"
|
||||
radius={[6, 6, 0, 0]}
|
||||
/>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium text-muted-foreground">Questions</p>
|
||||
<div className="text-2xl font-bold">
|
||||
{loading ? <Skeleton className="h-8 w-16" /> : stats.questions}
|
||||
</div>
|
||||
</div>
|
||||
{isSuperAdmin && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium text-muted-foreground">Active Users</p>
|
||||
<div className="text-2xl font-bold">
|
||||
{loading ? <Skeleton className="h-8 w-16" /> : stats.users}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Message Activity Chart - Takes up 3 columns */}
|
||||
<Card className="col-span-3">
|
||||
<CardHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<Activity className="h-5 w-5 text-primary" />
|
||||
<CardTitle>Message Activity</CardTitle>
|
||||
</div>
|
||||
<CardDescription>Messages sent over the last 7 days</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="h-[200px]">
|
||||
{loading ? (
|
||||
<Skeleton className="h-full w-full" />
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={messageStats}>
|
||||
<XAxis
|
||||
dataKey="date"
|
||||
stroke="#888888"
|
||||
fontSize={12}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
/>
|
||||
<YAxis
|
||||
stroke="#888888"
|
||||
fontSize={12}
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickFormatter={(value) => `${value}`}
|
||||
/>
|
||||
<Tooltip
|
||||
cursor={{ fill: 'transparent' }}
|
||||
contentStyle={{
|
||||
borderRadius: '8px',
|
||||
border: '1px solid #e2e8f0',
|
||||
boxShadow: '0 4px 6px -1px rgb(0 0 0 / 0.1)',
|
||||
}}
|
||||
/>
|
||||
<Bar
|
||||
dataKey="count"
|
||||
fill="currentColor"
|
||||
radius={[4, 4, 0, 0]}
|
||||
className="fill-primary"
|
||||
/>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,433 @@
|
||||
import { useState, useEffect } 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 { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
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');
|
||||
|
||||
// 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('');
|
||||
|
||||
useEffect(() => {
|
||||
fetchFeedback();
|
||||
}, []);
|
||||
|
||||
const fetchFeedback = 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: any) {
|
||||
console.error('Failed to load feedback:', error);
|
||||
toast.error("Failed to load feedback");
|
||||
} finally {
|
||||
setLoading(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 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 filteredFeedback = feedback.filter(item => {
|
||||
if (typeFilter !== 'all' && item.type !== typeFilter) return false;
|
||||
if (statusFilter !== 'all' && item.status !== statusFilter) return false;
|
||||
if (!search) return true;
|
||||
|
||||
const query = search.toLowerCase();
|
||||
return (
|
||||
item.title.toLowerCase().includes(query) ||
|
||||
item.description.toLowerCase().includes(query) ||
|
||||
item.profile?.name?.toLowerCase().includes(query) ||
|
||||
item.profile?.email?.toLowerCase().includes(query)
|
||||
);
|
||||
});
|
||||
|
||||
const 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 items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">User Feedback</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Review and respond to user feedback submissions
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filters */}
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="relative flex-1 max-w-sm">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search feedback..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
<Select value={typeFilter} onValueChange={setTypeFilter}>
|
||||
<SelectTrigger className="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>
|
||||
</Select>
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger className="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 */}
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Type</TableHead>
|
||||
<TableHead>Title</TableHead>
|
||||
<TableHead>User</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Created</TableHead>
|
||||
<TableHead className="w-[100px]">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{loading ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="text-center py-8">
|
||||
<div className="flex justify-center"><Skeleton className="h-6 w-32" /></div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : filteredFeedback.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="text-center py-8 text-muted-foreground">
|
||||
{search || typeFilter !== 'all' || statusFilter !== 'all'
|
||||
? 'No feedback matches your filters.'
|
||||
: 'No feedback submissions yet.'}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
filteredFeedback.map((item) => (
|
||||
<TableRow key={item.id} className="cursor-pointer hover:bg-muted/50" onClick={() => handleOpenDetail(item)}>
|
||||
<TableCell>{getTypeBadge(item.type)}</TableCell>
|
||||
<TableCell>
|
||||
<div className="max-w-[300px]">
|
||||
<div className="font-medium truncate">{item.title}</div>
|
||||
<div className="text-sm text-muted-foreground truncate">
|
||||
{item.description.slice(0, 80)}
|
||||
{item.description.length > 80 && '...'}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="text-sm">
|
||||
<div className="font-medium">{item.profile?.name || 'Unknown'}</div>
|
||||
<div className="text-muted-foreground">{item.profile?.email}</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>{getStatusBadge(item.status)}</TableCell>
|
||||
<TableCell className="text-muted-foreground text-sm">
|
||||
{format(new Date(item.created_at), 'MMM d, yyyy')}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Button variant="ghost" size="sm" onClick={(e) => { e.stopPropagation(); handleOpenDetail(item); }}>
|
||||
View
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -4,8 +4,8 @@ import { useAuth } from '@/contexts/AuthContext';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Sparkles, Loader2 } from 'lucide-react';
|
||||
import { Loader2, Lock, Mail } from 'lucide-react';
|
||||
import logoImage from '@/assets/logo.png';
|
||||
|
||||
export function LoginPage() {
|
||||
const [email, setEmail] = useState('');
|
||||
@@ -31,63 +31,142 @@ export function LoginPage() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-rose-50 to-pink-100 dark:from-gray-900 dark:to-gray-800 p-4">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader className="text-center">
|
||||
<div className="mx-auto mb-4 flex h-12 w-12 items-center justify-center rounded-full bg-primary/10">
|
||||
<Sparkles className="h-6 w-6 text-primary" />
|
||||
<div className="min-h-screen flex bg-background">
|
||||
{/* Left side - Branding */}
|
||||
<div className="hidden lg:flex lg:w-1/2 relative overflow-hidden">
|
||||
{/* Gradient background */}
|
||||
<div className="absolute inset-0 bg-gradient-to-br from-background via-[hsl(340,30%,12%)] to-background" />
|
||||
|
||||
{/* Decorative elements */}
|
||||
<div className="absolute top-1/4 left-1/4 w-96 h-96 bg-primary/10 rounded-full blur-3xl" />
|
||||
<div className="absolute bottom-1/4 right-1/4 w-72 h-72 bg-[hsl(25,80%,50%)]/10 rounded-full blur-3xl" />
|
||||
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[500px] h-[500px] bg-[hsl(280,50%,40%)]/5 rounded-full blur-3xl" />
|
||||
|
||||
{/* Content */}
|
||||
<div className="relative z-10 flex flex-col items-center justify-center w-full p-12">
|
||||
<div className="text-center max-w-md">
|
||||
{/* Logo */}
|
||||
<div className="relative mb-8 inline-block">
|
||||
<img
|
||||
src={logoImage}
|
||||
alt="Sauci"
|
||||
className="w-32 h-32 object-contain mx-auto"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-rose opacity-20 rounded-full blur-2xl scale-150" />
|
||||
</div>
|
||||
|
||||
<h1 className="text-5xl font-bold mb-4 text-gradient-sunset">
|
||||
Sauci
|
||||
</h1>
|
||||
<p className="text-xl text-muted-foreground mb-8 font-light">
|
||||
Admin Portal
|
||||
</p>
|
||||
|
||||
<div className="h-px w-24 mx-auto bg-gradient-to-r from-transparent via-primary/50 to-transparent mb-8" />
|
||||
|
||||
<p className="text-muted-foreground/80 text-sm leading-relaxed">
|
||||
Manage your content, monitor user engagement, and deliver exceptional experiences to couples everywhere.
|
||||
</p>
|
||||
</div>
|
||||
<CardTitle className="text-2xl">Sauci Admin</CardTitle>
|
||||
<CardDescription>
|
||||
Sign in to access the admin dashboard
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{error && (
|
||||
<div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email">Email</Label>
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="admin@example.com"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
disabled={loading}
|
||||
/>
|
||||
{/* Bottom decoration */}
|
||||
<div className="absolute bottom-0 left-0 right-0 h-px bg-gradient-to-r from-transparent via-primary/30 to-transparent" />
|
||||
</div>
|
||||
|
||||
{/* Right side - Login form */}
|
||||
<div className="flex-1 flex items-center justify-center p-8">
|
||||
<div className="w-full max-w-md">
|
||||
{/* Mobile logo */}
|
||||
<div className="lg:hidden text-center mb-12">
|
||||
<img
|
||||
src={logoImage}
|
||||
alt="Sauci"
|
||||
className="w-20 h-20 object-contain mx-auto mb-4"
|
||||
/>
|
||||
<h1 className="text-3xl font-bold text-gradient-sunset">Sauci</h1>
|
||||
<p className="text-muted-foreground text-sm mt-1">Admin Portal</p>
|
||||
</div>
|
||||
|
||||
{/* Form card */}
|
||||
<div className="glass rounded-2xl p-8 glow-rose">
|
||||
<div className="text-center mb-8">
|
||||
<h2 className="text-2xl font-bold text-foreground mb-2">
|
||||
Welcome Back
|
||||
</h2>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Sign in to continue to your dashboard
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password">Password</Label>
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button type="submit" className="w-full" disabled={loading}>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Signing in...
|
||||
</>
|
||||
) : (
|
||||
'Sign In'
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{error && (
|
||||
<div className="rounded-xl bg-destructive/10 border border-destructive/20 p-4 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="email" className="text-sm font-medium text-foreground">
|
||||
Email Address
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Mail className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-muted-foreground" />
|
||||
<Input
|
||||
id="email"
|
||||
type="email"
|
||||
placeholder="you@example.com"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
required
|
||||
disabled={loading}
|
||||
className="pl-11 h-12 bg-background/50 border-white/10 rounded-xl focus:border-primary/50 focus:ring-primary/20 transition-all"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="password" className="text-sm font-medium text-foreground">
|
||||
Password
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 h-5 w-5 text-muted-foreground" />
|
||||
<Input
|
||||
id="password"
|
||||
type="password"
|
||||
placeholder="Enter your password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
disabled={loading}
|
||||
className="pl-11 h-12 bg-background/50 border-white/10 rounded-xl focus:border-primary/50 focus:ring-primary/20 transition-all"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full h-12 bg-gradient-rose hover:opacity-90 text-white font-semibold rounded-xl shadow-lg transition-all duration-200 btn-shine"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
|
||||
Signing in...
|
||||
</>
|
||||
) : (
|
||||
'Sign In'
|
||||
)}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<p className="text-center text-xs text-muted-foreground/60 mt-8">
|
||||
Sauci Admin Portal © {new Date().getFullYear()}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +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>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { Input } from '@/components/ui/input';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -23,23 +24,18 @@ import {
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Search, Shield, Trash2, UserPlus } from 'lucide-react';
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Search, Shield, Trash2, UserPlus, Settings } from 'lucide-react';
|
||||
import { format } from 'date-fns';
|
||||
import { useAuth, AdminRole } from '@/contexts/AuthContext';
|
||||
import { useAuth, AdminRole, PERMISSION_METADATA, PermissionKey } from '@/contexts/AuthContext';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface AdminUser {
|
||||
id: string; // admin_users PK
|
||||
user_id: string;
|
||||
role: AdminRole;
|
||||
permissions: string[];
|
||||
created_at: string;
|
||||
profile?: {
|
||||
name: string | null;
|
||||
@@ -67,9 +63,16 @@ export function AdminsPage() {
|
||||
const [searchResults, setSearchResults] = useState<Profile[]>([]);
|
||||
const [searchingUsers, setSearchingUsers] = useState(false);
|
||||
const [selectedUser, setSelectedUser] = useState<Profile | null>(null);
|
||||
const [selectedRole, setSelectedRole] = useState<AdminRole>('pack_creator');
|
||||
const [isSuperAdminChecked, setIsSuperAdminChecked] = useState(false);
|
||||
const [selectedPermissions, setSelectedPermissions] = useState<string[]>([]);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
// Edit Permissions State
|
||||
const [isEditDialogOpen, setIsEditDialogOpen] = useState(false);
|
||||
const [editingAdmin, setEditingAdmin] = useState<AdminUser | null>(null);
|
||||
const [editIsSuperAdmin, setEditIsSuperAdmin] = useState(false);
|
||||
const [editPermissions, setEditPermissions] = useState<string[]>([]);
|
||||
|
||||
// Initial load
|
||||
useEffect(() => {
|
||||
fetchAdmins();
|
||||
@@ -113,10 +116,10 @@ export function AdminsPage() {
|
||||
const fetchAdmins = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
// First fetch admin users
|
||||
// First fetch admin users with permissions
|
||||
const { data: adminData, error: adminError } = await supabase
|
||||
.from('admin_users')
|
||||
.select('*')
|
||||
.select('id, user_id, role, permissions, created_at')
|
||||
.order('created_at', { ascending: false });
|
||||
|
||||
if (adminError) throw adminError;
|
||||
@@ -135,6 +138,7 @@ export function AdminsPage() {
|
||||
// Merge data
|
||||
const joinedAdmins = adminData.map(admin => ({
|
||||
...admin,
|
||||
permissions: (admin.permissions as string[]) || [],
|
||||
profile: profileData?.find(p => p.id === admin.user_id)
|
||||
}));
|
||||
setAdmins(joinedAdmins);
|
||||
@@ -154,18 +158,25 @@ export function AdminsPage() {
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const role: AdminRole = isSuperAdminChecked ? 'super_admin' : 'pack_creator';
|
||||
const permissions = isSuperAdminChecked ? [] : selectedPermissions;
|
||||
|
||||
const { error } = await auditedSupabase.insert('admin_users', {
|
||||
user_id: selectedUser.id,
|
||||
role: selectedRole
|
||||
role,
|
||||
permissions
|
||||
});
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
toast.success(`${selectedUser.name || selectedUser.email} added as ${selectedRole.replace('_', ' ')}`);
|
||||
const roleLabel = isSuperAdminChecked ? 'Super Admin' : 'Admin';
|
||||
toast.success(`${selectedUser.name || selectedUser.email} added as ${roleLabel}`);
|
||||
|
||||
setIsAddDialogOpen(false);
|
||||
setSelectedUser(null);
|
||||
setUserSearch('');
|
||||
setIsSuperAdminChecked(false);
|
||||
setSelectedPermissions([]);
|
||||
fetchAdmins();
|
||||
} catch (error: any) {
|
||||
console.error('Failed to add admin:', error);
|
||||
@@ -175,6 +186,57 @@ export function AdminsPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleEditPermissions = (admin: AdminUser) => {
|
||||
setEditingAdmin(admin);
|
||||
setEditIsSuperAdmin(admin.role === 'super_admin');
|
||||
setEditPermissions(admin.permissions || []);
|
||||
setIsEditDialogOpen(true);
|
||||
};
|
||||
|
||||
const handleSavePermissions = async () => {
|
||||
if (!editingAdmin) return;
|
||||
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const role: AdminRole = editIsSuperAdmin ? 'super_admin' : 'pack_creator';
|
||||
const permissions = editIsSuperAdmin ? [] : editPermissions;
|
||||
|
||||
const { error } = await auditedSupabase.update('admin_users', editingAdmin.id, {
|
||||
role,
|
||||
permissions
|
||||
});
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
toast.success("Permissions updated successfully");
|
||||
|
||||
setIsEditDialogOpen(false);
|
||||
setEditingAdmin(null);
|
||||
fetchAdmins();
|
||||
} catch (error: any) {
|
||||
console.error('Failed to update permissions:', error);
|
||||
toast.error(error.message || "Failed to update permissions");
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const togglePermission = (permission: string, isEdit: boolean) => {
|
||||
if (isEdit) {
|
||||
setEditPermissions(prev =>
|
||||
prev.includes(permission)
|
||||
? prev.filter(p => p !== permission)
|
||||
: [...prev, permission]
|
||||
);
|
||||
} else {
|
||||
setSelectedPermissions(prev =>
|
||||
prev.includes(permission)
|
||||
? prev.filter(p => p !== permission)
|
||||
: [...prev, permission]
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveAdmin = async (admin: AdminUser) => {
|
||||
if (!confirm(`Are you sure you want to remove access for ${admin.profile?.name || admin.profile?.email || 'this user'}?`)) {
|
||||
return;
|
||||
@@ -229,7 +291,7 @@ export function AdminsPage() {
|
||||
Add Admin
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add Administrator</DialogTitle>
|
||||
<DialogDescription>
|
||||
@@ -289,17 +351,107 @@ export function AdminsPage() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>Role</Label>
|
||||
<Select value={selectedRole} onValueChange={(v) => setSelectedRole(v as AdminRole)}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="pack_creator">Pack Creator</SelectItem>
|
||||
<SelectItem value="super_admin">Super Admin</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="grid gap-3">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="super-admin"
|
||||
checked={isSuperAdminChecked}
|
||||
onCheckedChange={(checked) => setIsSuperAdminChecked(checked === true)}
|
||||
/>
|
||||
<label
|
||||
htmlFor="super-admin"
|
||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
>
|
||||
Super Admin (grants all permissions)
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{!isSuperAdminChecked && (
|
||||
<>
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-3">
|
||||
<Label className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Content Permissions
|
||||
</Label>
|
||||
{Object.entries(PERMISSION_METADATA)
|
||||
.filter(([_, meta]) => meta.group === 'content')
|
||||
.map(([key, meta]) => (
|
||||
<div key={key} className="flex items-start space-x-2">
|
||||
<Checkbox
|
||||
id={`add-${key}`}
|
||||
checked={selectedPermissions.includes(key)}
|
||||
onCheckedChange={() => togglePermission(key, false)}
|
||||
/>
|
||||
<div className="grid gap-0.5 leading-none">
|
||||
<label
|
||||
htmlFor={`add-${key}`}
|
||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
>
|
||||
{meta.label}
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground">{meta.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Label className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
User Permissions
|
||||
</Label>
|
||||
{Object.entries(PERMISSION_METADATA)
|
||||
.filter(([_, meta]) => meta.group === 'users')
|
||||
.map(([key, meta]) => (
|
||||
<div key={key} className="flex items-start space-x-2">
|
||||
<Checkbox
|
||||
id={`add-${key}`}
|
||||
checked={selectedPermissions.includes(key)}
|
||||
onCheckedChange={() => togglePermission(key, false)}
|
||||
/>
|
||||
<div className="grid gap-0.5 leading-none">
|
||||
<label
|
||||
htmlFor={`add-${key}`}
|
||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
>
|
||||
{meta.label}
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground">{meta.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Label className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
System Permissions
|
||||
</Label>
|
||||
{Object.entries(PERMISSION_METADATA)
|
||||
.filter(([_, meta]) => meta.group === 'system')
|
||||
.map(([key, meta]) => (
|
||||
<div key={key} className="flex items-start space-x-2">
|
||||
<Checkbox
|
||||
id={`add-${key}`}
|
||||
checked={selectedPermissions.includes(key)}
|
||||
onCheckedChange={() => togglePermission(key, false)}
|
||||
/>
|
||||
<div className="grid gap-0.5 leading-none">
|
||||
<label
|
||||
htmlFor={`add-${key}`}
|
||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
>
|
||||
{meta.label}
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground">{meta.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
@@ -320,9 +472,9 @@ export function AdminsPage() {
|
||||
<TableRow>
|
||||
<TableHead>Admin User</TableHead>
|
||||
<TableHead>Email</TableHead>
|
||||
<TableHead>Role</TableHead>
|
||||
<TableHead>Role / Permissions</TableHead>
|
||||
<TableHead>Added On</TableHead>
|
||||
<TableHead className="w-[100px]">Actions</TableHead>
|
||||
<TableHead className="w-[120px]">Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
@@ -361,26 +513,57 @@ export function AdminsPage() {
|
||||
{admin.profile?.email || '—'}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={admin.role === 'super_admin' ? 'default' : 'secondary'}>
|
||||
<Shield className="h-3 w-3 mr-1" />
|
||||
{admin.role.replace('_', ' ')}
|
||||
</Badge>
|
||||
{admin.role === 'super_admin' ? (
|
||||
<Badge variant="default">
|
||||
<Shield className="h-3 w-3 mr-1" />
|
||||
Super Admin
|
||||
</Badge>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{admin.permissions.length === 0 ? (
|
||||
<span className="text-muted-foreground text-sm">No permissions</span>
|
||||
) : (
|
||||
admin.permissions.slice(0, 3).map(perm => (
|
||||
<Badge key={perm} variant="secondary" className="text-xs">
|
||||
{PERMISSION_METADATA[perm as PermissionKey]?.label.replace('Can ', '') || perm}
|
||||
</Badge>
|
||||
))
|
||||
)}
|
||||
{admin.permissions.length > 3 && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
+{admin.permissions.length - 3} more
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground text-sm">
|
||||
{admin.created_at ? format(new Date(admin.created_at), 'MMM d, yyyy') : '—'}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{admin.user_id !== currentUser?.id && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||
onClick={() => handleRemoveAdmin(admin)}
|
||||
title="Revoke Access"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
<div className="flex items-center gap-1">
|
||||
{admin.user_id !== currentUser?.id && (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleEditPermissions(admin)}
|
||||
title="Edit Permissions"
|
||||
>
|
||||
<Settings className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="text-destructive hover:text-destructive hover:bg-destructive/10"
|
||||
onClick={() => handleRemoveAdmin(admin)}
|
||||
title="Revoke Access"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
@@ -388,6 +571,125 @@ export function AdminsPage() {
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* Edit Permissions Dialog */}
|
||||
<Dialog open={isEditDialogOpen} onOpenChange={setIsEditDialogOpen}>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit Permissions</DialogTitle>
|
||||
<DialogDescription>
|
||||
Update permissions for {editingAdmin?.profile?.name || editingAdmin?.profile?.email || 'this admin'}.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<div className="grid gap-3">
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="edit-super-admin"
|
||||
checked={editIsSuperAdmin}
|
||||
onCheckedChange={(checked) => setEditIsSuperAdmin(checked === true)}
|
||||
/>
|
||||
<label
|
||||
htmlFor="edit-super-admin"
|
||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
>
|
||||
Super Admin (grants all permissions)
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{!editIsSuperAdmin && (
|
||||
<>
|
||||
<Separator />
|
||||
|
||||
<div className="space-y-3">
|
||||
<Label className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
Content Permissions
|
||||
</Label>
|
||||
{Object.entries(PERMISSION_METADATA)
|
||||
.filter(([_, meta]) => meta.group === 'content')
|
||||
.map(([key, meta]) => (
|
||||
<div key={key} className="flex items-start space-x-2">
|
||||
<Checkbox
|
||||
id={`edit-${key}`}
|
||||
checked={editPermissions.includes(key)}
|
||||
onCheckedChange={() => togglePermission(key, true)}
|
||||
/>
|
||||
<div className="grid gap-0.5 leading-none">
|
||||
<label
|
||||
htmlFor={`edit-${key}`}
|
||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
>
|
||||
{meta.label}
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground">{meta.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Label className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
User Permissions
|
||||
</Label>
|
||||
{Object.entries(PERMISSION_METADATA)
|
||||
.filter(([_, meta]) => meta.group === 'users')
|
||||
.map(([key, meta]) => (
|
||||
<div key={key} className="flex items-start space-x-2">
|
||||
<Checkbox
|
||||
id={`edit-${key}`}
|
||||
checked={editPermissions.includes(key)}
|
||||
onCheckedChange={() => togglePermission(key, true)}
|
||||
/>
|
||||
<div className="grid gap-0.5 leading-none">
|
||||
<label
|
||||
htmlFor={`edit-${key}`}
|
||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
>
|
||||
{meta.label}
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground">{meta.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<Label className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
|
||||
System Permissions
|
||||
</Label>
|
||||
{Object.entries(PERMISSION_METADATA)
|
||||
.filter(([_, meta]) => meta.group === 'system')
|
||||
.map(([key, meta]) => (
|
||||
<div key={key} className="flex items-start space-x-2">
|
||||
<Checkbox
|
||||
id={`edit-${key}`}
|
||||
checked={editPermissions.includes(key)}
|
||||
onCheckedChange={() => togglePermission(key, true)}
|
||||
/>
|
||||
<div className="grid gap-0.5 leading-none">
|
||||
<label
|
||||
htmlFor={`edit-${key}`}
|
||||
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
||||
>
|
||||
{meta.label}
|
||||
</label>
|
||||
<p className="text-xs text-muted-foreground">{meta.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setIsEditDialogOpen(false)}>Cancel</Button>
|
||||
<Button onClick={handleSavePermissions} disabled={isSubmitting}>
|
||||
{isSubmitting ? 'Saving...' : 'Save Changes'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
} from '@/components/ui/dialog';
|
||||
import { Card } from '@/components/ui/card';
|
||||
import { Plus, Pencil, Trash2, Sparkles, Loader2, Users } from 'lucide-react';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { toast } from 'sonner';
|
||||
import { AiGeneratorDialog } from '@/components/ai/AiGeneratorDialog';
|
||||
import { AIPolishButton } from '@/components/ai/AIPolishButton';
|
||||
@@ -65,6 +66,8 @@ export function QuestionsPage() {
|
||||
const [reviewOpen, setReviewOpen] = useState(false);
|
||||
const [editingQuestion, setEditingQuestion] = useState<Question | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
const [bulkDeleting, setBulkDeleting] = useState(false);
|
||||
|
||||
// Form state
|
||||
const [formData, setFormData] = useState<{
|
||||
@@ -221,6 +224,49 @@ export function QuestionsPage() {
|
||||
setAiDialogOpen(false);
|
||||
};
|
||||
|
||||
// Selection handlers
|
||||
const toggleSelection = (id: string) => {
|
||||
const next = new Set(selectedIds);
|
||||
if (next.has(id)) {
|
||||
next.delete(id);
|
||||
} else {
|
||||
next.add(id);
|
||||
}
|
||||
setSelectedIds(next);
|
||||
};
|
||||
|
||||
const toggleSelectAll = () => {
|
||||
if (selectedIds.size === questions.length) {
|
||||
setSelectedIds(new Set());
|
||||
} else {
|
||||
setSelectedIds(new Set(questions.map(q => q.id)));
|
||||
}
|
||||
};
|
||||
|
||||
const handleBulkDelete = async () => {
|
||||
if (selectedIds.size === 0) return;
|
||||
|
||||
const count = selectedIds.size;
|
||||
if (!confirm(`Delete ${count} question${count !== 1 ? 's' : ''}? This cannot be undone.`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setBulkDeleting(true);
|
||||
try {
|
||||
await Promise.all(
|
||||
Array.from(selectedIds).map(id => auditedSupabase.delete('questions', id))
|
||||
);
|
||||
toast.success(`${count} question${count !== 1 ? 's' : ''} deleted`);
|
||||
setSelectedIds(new Set());
|
||||
fetchData();
|
||||
} catch (error) {
|
||||
toast.error('Failed to delete questions');
|
||||
console.error(error);
|
||||
} finally {
|
||||
setBulkDeleting(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -435,6 +481,42 @@ export function QuestionsPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bulk Action Bar */}
|
||||
{selectedIds.size > 0 && (
|
||||
<div className="flex items-center justify-between bg-muted/50 border rounded-lg px-4 py-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm font-medium">
|
||||
{selectedIds.size} question{selectedIds.size !== 1 ? 's' : ''} selected
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setSelectedIds(new Set())}
|
||||
>
|
||||
Clear selection
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={handleBulkDelete}
|
||||
disabled={bulkDeleting}
|
||||
>
|
||||
{bulkDeleting ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
Deleting...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Trash2 className="mr-2 h-4 w-4" />
|
||||
Delete {selectedIds.size} Question{selectedIds.size !== 1 ? 's' : ''}
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* AI Generator Dialog */}
|
||||
<AiGeneratorDialog
|
||||
open={aiDialogOpen}
|
||||
@@ -477,6 +559,13 @@ export function QuestionsPage() {
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-12">
|
||||
<Checkbox
|
||||
checked={selectedIds.size === questions.length && questions.length > 0}
|
||||
onCheckedChange={toggleSelectAll}
|
||||
aria-label="Select all"
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead className="w-12">#</TableHead>
|
||||
<TableHead>Question</TableHead>
|
||||
<TableHead className="w-32">Partner Text</TableHead>
|
||||
@@ -488,7 +577,17 @@ export function QuestionsPage() {
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{questions.map((question, index) => (
|
||||
<TableRow key={question.id}>
|
||||
<TableRow
|
||||
key={question.id}
|
||||
className={selectedIds.has(question.id) ? 'bg-muted/50' : ''}
|
||||
>
|
||||
<TableCell>
|
||||
<Checkbox
|
||||
checked={selectedIds.has(question.id)}
|
||||
onCheckedChange={() => toggleSelection(question.id)}
|
||||
aria-label={`Select question ${index + 1}`}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="font-medium text-muted-foreground">
|
||||
{index + 1}
|
||||
</TableCell>
|
||||
|
||||
@@ -2,11 +2,12 @@ import { useState, useEffect } from 'react';
|
||||
import { useParams, Link } from 'react-router-dom';
|
||||
import { supabase } from '@/config';
|
||||
import { auditedSupabase } from '@/hooks/useAuditedSupabase';
|
||||
import { useAuth, PERMISSION_KEYS } from '@/contexts/AuthContext';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||
import { Card, CardContent, CardDescription, CardHeader } from '@/components/ui/card';
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import {
|
||||
Table,
|
||||
@@ -34,9 +35,12 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Crown, Users, MessageCircle, ChevronRight, ThumbsUp, ThumbsDown, Minus, Gift, Image, HardDrive, ExternalLink } from 'lucide-react';
|
||||
import { Crown, Users, MessageCircle, ChevronRight, ThumbsUp, ThumbsDown, Minus, Gift, Image, HardDrive, ExternalLink, Target, User, Eye, EyeOff, CheckCircle } from 'lucide-react';
|
||||
import { format } from 'date-fns';
|
||||
|
||||
type UsageReason = 'improve_communication' | 'spice_up_intimacy' | 'deeper_connection' | 'have_fun' | 'strengthen_relationship';
|
||||
type Gender = 'male' | 'female' | 'non-binary' | 'prefer-not-to-say';
|
||||
|
||||
interface Profile {
|
||||
id: string;
|
||||
name: string | null;
|
||||
@@ -45,8 +49,27 @@ interface Profile {
|
||||
is_premium: boolean | null;
|
||||
couple_id: string | null;
|
||||
created_at: string | null;
|
||||
gender: Gender | null;
|
||||
usage_reason: UsageReason | null;
|
||||
show_explicit_content: boolean | null;
|
||||
onboarding_completed: boolean | null;
|
||||
}
|
||||
|
||||
const usageReasonLabels: Record<UsageReason, { label: string; icon: string }> = {
|
||||
improve_communication: { label: 'Improve communication', icon: 'chatbubbles' },
|
||||
spice_up_intimacy: { label: 'Spice up intimacy', icon: 'flame' },
|
||||
deeper_connection: { label: 'Build deeper connection', icon: 'heart' },
|
||||
have_fun: { label: 'Have fun together', icon: 'happy' },
|
||||
strengthen_relationship: { label: 'Strengthen relationship', icon: 'shield-checkmark' },
|
||||
};
|
||||
|
||||
const genderLabels: Record<Gender, string> = {
|
||||
male: 'Male',
|
||||
female: 'Female',
|
||||
'non-binary': 'Non-binary',
|
||||
'prefer-not-to-say': 'Prefer not to say',
|
||||
};
|
||||
|
||||
interface Partner {
|
||||
id: string;
|
||||
name: string | null;
|
||||
@@ -111,6 +134,7 @@ function formatBytes(bytes: number): string {
|
||||
|
||||
export function UserDetailPage() {
|
||||
const { userId } = useParams<{ userId: string }>();
|
||||
const { hasPermission } = useAuth();
|
||||
const [profile, setProfile] = useState<Profile | null>(null);
|
||||
const [partner, setPartner] = useState<Partner | null>(null);
|
||||
const [responses, setResponses] = useState<Response[]>([]);
|
||||
@@ -119,6 +143,11 @@ export function UserDetailPage() {
|
||||
const [totalStorage, setTotalStorage] = useState(0);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const canViewResponses = hasPermission(PERMISSION_KEYS.VIEW_RESPONSES);
|
||||
const canViewMatches = hasPermission(PERMISSION_KEYS.VIEW_MATCHES);
|
||||
const canViewMedia = hasPermission(PERMISSION_KEYS.VIEW_MEDIA);
|
||||
const canViewChats = hasPermission(PERMISSION_KEYS.VIEW_CHATS);
|
||||
|
||||
const [upgradeOpen, setUpgradeOpen] = useState(false);
|
||||
const [expiryType, setExpiryType] = useState('forever');
|
||||
const [customDate, setCustomDate] = useState('');
|
||||
@@ -430,178 +459,270 @@ export function UserDetailPage() {
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Onboarding Details */}
|
||||
<Card>
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<CheckCircle className="h-5 w-5 text-primary" />
|
||||
Onboarding Details
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Information collected during user onboarding
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
{/* Gender */}
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="p-2 rounded-lg bg-blue-100 dark:bg-blue-900">
|
||||
<User className="h-4 w-4 text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">Gender</p>
|
||||
<p className="font-medium">
|
||||
{profile.gender ? genderLabels[profile.gender] : 'Not set'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Usage Reason */}
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="p-2 rounded-lg bg-purple-100 dark:bg-purple-900">
|
||||
<Target className="h-4 w-4 text-purple-600 dark:text-purple-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">Why using Sauci</p>
|
||||
<p className="font-medium">
|
||||
{profile.usage_reason
|
||||
? usageReasonLabels[profile.usage_reason]?.label
|
||||
: 'Not set'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Explicit Content */}
|
||||
<div className="flex items-start gap-3">
|
||||
<div className={`p-2 rounded-lg ${profile.show_explicit_content ? 'bg-amber-100 dark:bg-amber-900' : 'bg-gray-100 dark:bg-gray-800'}`}>
|
||||
{profile.show_explicit_content ? (
|
||||
<Eye className="h-4 w-4 text-amber-600 dark:text-amber-400" />
|
||||
) : (
|
||||
<EyeOff className="h-4 w-4 text-gray-500 dark:text-gray-400" />
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">Explicit Content</p>
|
||||
<p className="font-medium">
|
||||
{profile.show_explicit_content === null
|
||||
? 'Not set'
|
||||
: profile.show_explicit_content
|
||||
? 'Enabled'
|
||||
: 'Disabled'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Onboarding Status */}
|
||||
<div className="flex items-start gap-3">
|
||||
<div className={`p-2 rounded-lg ${profile.onboarding_completed ? 'bg-green-100 dark:bg-green-900' : 'bg-red-100 dark:bg-red-900'}`}>
|
||||
<CheckCircle className={`h-4 w-4 ${profile.onboarding_completed ? 'text-green-600 dark:text-green-400' : 'text-red-500 dark:text-red-400'}`} />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-muted-foreground">Onboarding</p>
|
||||
<p className="font-medium">
|
||||
{profile.onboarding_completed ? 'Completed' : 'Not completed'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Tabs */}
|
||||
<Tabs defaultValue="responses">
|
||||
<Tabs defaultValue={canViewResponses ? "responses" : canViewMatches ? "matches" : canViewMedia ? "media" : "responses"}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="responses">
|
||||
Responses ({responses.length})
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="matches" disabled={!profile.couple_id}>
|
||||
Matches ({matches.length})
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="media">
|
||||
Media ({media.length})
|
||||
</TabsTrigger>
|
||||
{canViewResponses && (
|
||||
<TabsTrigger value="responses">
|
||||
Responses ({responses.length})
|
||||
</TabsTrigger>
|
||||
)}
|
||||
{canViewMatches && (
|
||||
<TabsTrigger value="matches" disabled={!profile.couple_id}>
|
||||
Matches ({matches.length})
|
||||
</TabsTrigger>
|
||||
)}
|
||||
{canViewMedia && (
|
||||
<TabsTrigger value="media">
|
||||
Media ({media.length})
|
||||
</TabsTrigger>
|
||||
)}
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="responses" className="mt-4">
|
||||
{responses.length === 0 ? (
|
||||
<Card className="flex flex-col items-center justify-center py-12">
|
||||
<p className="text-muted-foreground">No responses yet</p>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Question</TableHead>
|
||||
<TableHead>Pack</TableHead>
|
||||
<TableHead className="w-24">Answer</TableHead>
|
||||
<TableHead className="w-32">Date</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{responses.map((r) => (
|
||||
<TableRow key={r.id}>
|
||||
<TableCell className="max-w-md">
|
||||
<span className="line-clamp-2">{r.question.text}</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{r.question.pack?.name || '—'}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
{answerIcons[r.answer]}
|
||||
<span className="capitalize">{r.answer}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground text-sm">
|
||||
{r.created_at
|
||||
? format(new Date(r.created_at), 'MMM d, yyyy')
|
||||
: '—'}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="matches" className="mt-4">
|
||||
{matches.length === 0 ? (
|
||||
<Card className="flex flex-col items-center justify-center py-12">
|
||||
<p className="text-muted-foreground">No matches yet</p>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{matches.map((match) => (
|
||||
<Card key={match.id} className="hover:shadow-md transition-shadow">
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-start justify-between">
|
||||
<Badge variant="secondary">
|
||||
{matchTypeLabels[match.match_type]}
|
||||
</Badge>
|
||||
{match.is_new && (
|
||||
<Badge variant="default" className="text-xs">New</Badge>
|
||||
)}
|
||||
</div>
|
||||
<CardDescription className="line-clamp-2 mt-2">
|
||||
{match.question?.text || 'Unknown question'}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-1 text-sm text-muted-foreground">
|
||||
<MessageCircle className="h-4 w-4" />
|
||||
{match.message_count} message{match.message_count !== 1 ? 's' : ''}
|
||||
</div>
|
||||
<Link to={`/users/${userId}/matches/${match.id}`}>
|
||||
<Button variant="ghost" size="sm">
|
||||
View Chat
|
||||
<ChevronRight className="ml-1 h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="media" className="mt-4">
|
||||
{media.length === 0 ? (
|
||||
<Card className="flex flex-col items-center justify-center py-12">
|
||||
<Image className="h-12 w-12 text-muted-foreground mb-4" />
|
||||
<p className="text-muted-foreground">No media uploaded</p>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{/* Storage Summary */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<HardDrive className="h-5 w-5 text-muted-foreground" />
|
||||
<div>
|
||||
<p className="font-medium">Total Storage Used</p>
|
||||
<p className="text-2xl font-bold">{formatBytes(totalStorage)}</p>
|
||||
</div>
|
||||
<div className="ml-auto text-sm text-muted-foreground">
|
||||
{media.length} file{media.length !== 1 ? 's' : ''}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
{canViewResponses && (
|
||||
<TabsContent value="responses" className="mt-4">
|
||||
{responses.length === 0 ? (
|
||||
<Card className="flex flex-col items-center justify-center py-12">
|
||||
<p className="text-muted-foreground">No responses yet</p>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>Question</TableHead>
|
||||
<TableHead>Pack</TableHead>
|
||||
<TableHead className="w-24">Answer</TableHead>
|
||||
<TableHead className="w-32">Date</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{responses.map((r) => (
|
||||
<TableRow key={r.id}>
|
||||
<TableCell className="max-w-md">
|
||||
<span className="line-clamp-2">{r.question.text}</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{r.question.pack?.name || '—'}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
{answerIcons[r.answer]}
|
||||
<span className="capitalize">{r.answer}</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground text-sm">
|
||||
{r.created_at
|
||||
? format(new Date(r.created_at), 'MMM d, yyyy')
|
||||
: '—'}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
)}
|
||||
|
||||
{/* Media Grid */}
|
||||
<div className="grid gap-4 grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
|
||||
{media.map((file) => (
|
||||
<Card key={file.id} className="overflow-hidden">
|
||||
<div className="aspect-square bg-muted relative">
|
||||
{file.signedUrl ? (
|
||||
<img
|
||||
src={file.signedUrl}
|
||||
alt={file.name}
|
||||
className="w-full h-full object-cover"
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<Image className="h-8 w-8 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<CardContent className="p-3">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<div className="truncate text-muted-foreground">
|
||||
{formatBytes(file.metadata?.size || 0)}
|
||||
</div>
|
||||
{file.signedUrl && (
|
||||
<a
|
||||
href={file.signedUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline flex items-center gap-1"
|
||||
>
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
{canViewMatches && (
|
||||
<TabsContent value="matches" className="mt-4">
|
||||
{matches.length === 0 ? (
|
||||
<Card className="flex flex-col items-center justify-center py-12">
|
||||
<p className="text-muted-foreground">No matches yet</p>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{matches.map((match) => (
|
||||
<Card key={match.id} className="hover:shadow-md transition-shadow">
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-start justify-between">
|
||||
<Badge variant="secondary">
|
||||
{matchTypeLabels[match.match_type]}
|
||||
</Badge>
|
||||
{match.is_new && (
|
||||
<Badge variant="default" className="text-xs">New</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
{file.created_at
|
||||
? format(new Date(file.created_at), 'MMM d, yyyy')
|
||||
: '—'}
|
||||
<CardDescription className="line-clamp-2 mt-2">
|
||||
{match.question?.text || 'Unknown question'}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-1 text-sm text-muted-foreground">
|
||||
<MessageCircle className="h-4 w-4" />
|
||||
{match.message_count} message{match.message_count !== 1 ? 's' : ''}
|
||||
</div>
|
||||
{canViewChats && (
|
||||
<Link to={`/users/${userId}/matches/${match.id}`}>
|
||||
<Button variant="ghost" size="sm">
|
||||
View Chat
|
||||
<ChevronRight className="ml-1 h-4 w-4" />
|
||||
</Button>
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
)}
|
||||
</TabsContent>
|
||||
)}
|
||||
|
||||
{canViewMedia && (
|
||||
<TabsContent value="media" className="mt-4">
|
||||
{media.length === 0 ? (
|
||||
<Card className="flex flex-col items-center justify-center py-12">
|
||||
<Image className="h-12 w-12 text-muted-foreground mb-4" />
|
||||
<p className="text-muted-foreground">No media uploaded</p>
|
||||
</Card>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{/* Storage Summary */}
|
||||
<Card>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<HardDrive className="h-5 w-5 text-muted-foreground" />
|
||||
<div>
|
||||
<p className="font-medium">Total Storage Used</p>
|
||||
<p className="text-2xl font-bold">{formatBytes(totalStorage)}</p>
|
||||
</div>
|
||||
<div className="ml-auto text-sm text-muted-foreground">
|
||||
{media.length} file{media.length !== 1 ? 's' : ''}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Media Grid */}
|
||||
<div className="grid gap-4 grid-cols-2 md:grid-cols-3 lg:grid-cols-4">
|
||||
{media.map((file) => (
|
||||
<Card key={file.id} className="overflow-hidden">
|
||||
<div className="aspect-square bg-muted relative">
|
||||
{file.signedUrl ? (
|
||||
<img
|
||||
src={file.signedUrl}
|
||||
alt={file.name}
|
||||
className="w-full h-full object-cover"
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<Image className="h-8 w-8 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<CardContent className="p-3">
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<div className="truncate text-muted-foreground">
|
||||
{formatBytes(file.metadata?.size || 0)}
|
||||
</div>
|
||||
{file.signedUrl && (
|
||||
<a
|
||||
href={file.signedUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-primary hover:underline flex items-center gap-1"
|
||||
>
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
{file.created_at
|
||||
? format(new Date(file.created_at), 'MMM d, yyyy')
|
||||
: '—'}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
)}
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -26,6 +26,7 @@ interface Profile {
|
||||
couple_id: string | null;
|
||||
created_at: string | null;
|
||||
storage_bytes?: number;
|
||||
partner_is_premium?: boolean;
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
@@ -66,14 +67,37 @@ export function UsersPage() {
|
||||
});
|
||||
}
|
||||
|
||||
// Merge storage data with profiles
|
||||
const profilesWithStorage = (profilesData || []).map(profile => ({
|
||||
...profile,
|
||||
storage_bytes: storageMap[profile.id] || 0,
|
||||
}));
|
||||
// Build a map of couple_id -> users with premium in that couple
|
||||
const couplePartners: Record<string, { id: string; is_premium: boolean }[]> = {};
|
||||
(profilesData || []).forEach(profile => {
|
||||
if (profile.couple_id) {
|
||||
if (!couplePartners[profile.couple_id]) {
|
||||
couplePartners[profile.couple_id] = [];
|
||||
}
|
||||
couplePartners[profile.couple_id].push({
|
||||
id: profile.id,
|
||||
is_premium: profile.is_premium || false,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
setProfiles(profilesWithStorage);
|
||||
setFilteredProfiles(profilesWithStorage);
|
||||
// Merge storage data and partner premium status with profiles
|
||||
const profilesWithExtras = (profilesData || []).map(profile => {
|
||||
// Check if partner has premium
|
||||
let partnerIsPremium = false;
|
||||
if (profile.couple_id && couplePartners[profile.couple_id]) {
|
||||
const partner = couplePartners[profile.couple_id].find(p => p.id !== profile.id);
|
||||
partnerIsPremium = partner?.is_premium || false;
|
||||
}
|
||||
return {
|
||||
...profile,
|
||||
storage_bytes: storageMap[profile.id] || 0,
|
||||
partner_is_premium: partnerIsPremium,
|
||||
};
|
||||
});
|
||||
|
||||
setProfiles(profilesWithExtras);
|
||||
setFilteredProfiles(profilesWithExtras);
|
||||
} catch (error) {
|
||||
console.error('Failed to load profiles:', error);
|
||||
} finally {
|
||||
@@ -180,6 +204,11 @@ export function UsersPage() {
|
||||
<Crown className="h-3 w-3 mr-1" />
|
||||
Premium
|
||||
</Badge>
|
||||
) : profile.partner_is_premium ? (
|
||||
<Badge className="bg-amber-500/70">
|
||||
<Crown className="h-3 w-3 mr-1" />
|
||||
Partner
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge variant="secondary" className="bg-slate-100 text-slate-500 hover:bg-slate-200">
|
||||
Free
|
||||
|
||||
Reference in new issue
Block a user