This commit is contained in:
Jamie Brunton committed 2025-12-31 14:54:28 +00:00
1 parent e7379ccdb1
commit d18f9d32b0
49 files changed
+3739 -665

No files matched your search

+2
View File
@@ -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 -1
View File
@@ -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
View File
@@ -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

+8 -3
View File
@@ -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}</>;
}
+19 -5
View File
@@ -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>
))}
+86 -31
View File
@@ -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>
+7 -7
View File
@@ -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: {
+6 -6
View File
@@ -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",
},
},
+2 -2
View File
@@ -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}
+1 -1
View File
@@ -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 -5
View File
@@ -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}
+50 -40
View File
@@ -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
View File
@@ -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;
}
+160 -156
View File
@@ -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>
);
+433
View File
@@ -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>
);
}
+133 -54
View File
@@ -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 &copy; {new Date().getFullYear()}
</p>
</div>
</div>
</div>
);
}
+386
View File
@@ -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>
);
}
+345 -43
View File
@@ -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>
);
}
+100 -1
View File
@@ -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>
+282 -161
View File
@@ -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>
);
+36 -7
View File
@@ -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