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

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}</>;
}
+18 -4
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">
<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>
))}
+84 -29
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"
)}
>
<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>
<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;
}
+128 -124
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,178 +85,182 @@ 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.
{/* 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}
</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>
{/* 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>
))}
</div>
</section>
)}
<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>
{/* Stats Grid */}
<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>
</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>
</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>
{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>
</CardContent>
</Card>
))}
</div>
{/* Message Activity Chart - Takes up 3 columns */}
<Card className="col-span-3">
{/* 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>
</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>
<CardDescription>Messages sent over the last 7 days</CardDescription>
<CardDescription>
Messages sent over the last 7 days
</CardDescription>
</CardHeader>
<CardContent>
<div className="h-[200px]">
<div className="h-[280px]">
{loading ? (
<Skeleton className="h-full w-full" />
<Skeleton className="h-full w-full rounded-xl" />
) : (
<ResponsiveContainer width="100%" height="100%">
<BarChart data={messageStats}>
<XAxis
dataKey="date"
stroke="#888888"
fontSize={12}
stroke="hsl(var(--muted-foreground))"
fontSize={11}
tickLine={false}
axisLine={false}
/>
<YAxis
stroke="#888888"
fontSize={12}
stroke="hsl(var(--muted-foreground))"
fontSize={11}
tickLine={false}
axisLine={false}
tickFormatter={(value) => `${value}`}
/>
<Tooltip
cursor={{ fill: 'transparent' }}
cursor={{ fill: 'rgba(255,255,255,0.03)' }}
contentStyle={{
borderRadius: '8px',
border: '1px solid #e2e8f0',
boxShadow: '0 4px 6px -1px rgb(0 0 0 / 0.1)',
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="currentColor"
radius={[4, 4, 0, 0]}
className="fill-primary"
fill="url(#barGradient)"
radius={[6, 6, 0, 0]}
/>
</BarChart>
</ResponsiveContainer>
@@ -267,5 +270,6 @@ export function DashboardPage() {
</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>
);
}
+101 -22
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,54 +31,127 @@ 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>
<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">
<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>
</div>
{/* 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>
<form onSubmit={handleSubmit} className="space-y-6">
{error && (
<div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive">
<div className="rounded-xl bg-destructive/10 border border-destructive/20 p-4 text-sm text-destructive">
{error}
</div>
)}
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<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="admin@example.com"
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">Password</Label>
<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" disabled={loading}>
<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-4 w-4 animate-spin" />
<Loader2 className="mr-2 h-5 w-5 animate-spin" />
Signing in...
</>
) : (
@@ -86,8 +159,14 @@ export function LoginPage() {
)}
</Button>
</form>
</CardContent>
</Card>
</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>
);
}
+332 -30
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,16 +513,45 @@ export function AdminsPage() {
{admin.profile?.email || '—'}
</TableCell>
<TableCell>
<Badge variant={admin.role === 'super_admin' ? 'default' : 'secondary'}>
{admin.role === 'super_admin' ? (
<Badge variant="default">
<Shield className="h-3 w-3 mr-1" />
{admin.role.replace('_', ' ')}
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>
<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"
@@ -380,7 +561,9 @@ export function AdminsPage() {
>
<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>
+124 -3
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,20 +459,105 @@ 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>
{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>
{canViewResponses && (
<TabsContent value="responses" className="mt-4">
{responses.length === 0 ? (
<Card className="flex flex-col items-center justify-center py-12">
@@ -487,7 +601,9 @@ export function UserDetailPage() {
</div>
)}
</TabsContent>
)}
{canViewMatches && (
<TabsContent value="matches" className="mt-4">
{matches.length === 0 ? (
<Card className="flex flex-col items-center justify-center py-12">
@@ -516,12 +632,14 @@ export function UserDetailPage() {
<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>
@@ -529,7 +647,9 @@ export function UserDetailPage() {
</div>
)}
</TabsContent>
)}
{canViewMedia && (
<TabsContent value="media" className="mt-4">
{media.length === 0 ? (
<Card className="flex flex-col items-center justify-center py-12">
@@ -602,6 +722,7 @@ export function UserDetailPage() {
</div>
)}
</TabsContent>
)}
</Tabs>
</div>
);
+34 -5
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 => ({
// 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,
});
}
});
// 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(profilesWithStorage);
setFilteredProfiles(profilesWithStorage);
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
+1 -1
View File
@@ -93,7 +93,7 @@ android {
applicationId 'com.sauci.app'
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
versionCode 1
versionCode 2
versionName "1.0.0"
}
signingConfigs {
+1 -1
View File
@@ -5,7 +5,7 @@ buildscript {
buildToolsVersion = findProperty('android.buildToolsVersion') ?: '35.0.0'
minSdkVersion = Integer.parseInt(findProperty('android.minSdkVersion') ?: '24')
compileSdkVersion = Integer.parseInt(findProperty('android.compileSdkVersion') ?: '35')
targetSdkVersion = Integer.parseInt(findProperty('android.targetSdkVersion') ?: '34')
targetSdkVersion = Integer.parseInt(findProperty('android.targetSdkVersion') ?: '35')
kotlinVersion = findProperty('android.kotlinVersion') ?: '1.9.25'
ndkVersion = "26.1.10909125"
+1 -1
View File
@@ -26,7 +26,7 @@
},
"android": {
"adaptiveIcon": {
"foregroundImage": "./assets/adaptive-icon.png",
"foregroundImage": "./assets/splash-icon.png",
"backgroundColor": "#1a1a2e"
},
"package": "com.sauci.app",
+61 -27
View File
@@ -1,6 +1,6 @@
import { Tabs, useRouter, useSegments } from "expo-router";
import { Ionicons } from "@expo/vector-icons";
import { View, ActivityIndicator, StyleSheet, Platform, Modal, Text, Pressable, Animated, AppState, AppStateStatus } from "react-native";
import { View, ActivityIndicator, StyleSheet, Platform, Text, Pressable, Animated, AppState, AppStateStatus } from "react-native";
import { BlurView } from "expo-blur";
import { LinearGradient } from "expo-linear-gradient";
import { useEffect, useState, useCallback, useRef } from "react";
@@ -17,6 +17,8 @@ import type { Database } from "../../src/types/supabase";
type Message = Database["public"]["Tables"]["messages"]["Row"];
function TabBarBackground() {
// Only use BlurView on iOS - Android experimental blur causes white overlay artifacts
// The solid rgba background in tabBarStyle provides the visual effect on Android
if (Platform.OS === 'ios') {
return (
<BlurView
@@ -26,15 +28,7 @@ function TabBarBackground() {
/>
);
}
// Android fallback - use experimental blur method
return (
<BlurView
intensity={80}
tint="dark"
style={StyleSheet.absoluteFill}
experimentalBlurMethod="dimezisBlurView"
/>
);
return null;
}
interface PlayTabButtonProps {
@@ -82,6 +76,7 @@ export default function AppLayout() {
const { fetchEnabledPacks } = usePacksStore();
const { initializeRevenueCat } = useSubscriptionStore();
const [matchNotification, setMatchNotification] = useState<MatchWithQuestion | null>(null);
const matchModalAnim = useRef(new Animated.Value(0)).current;
const messageToastAnim = useRef(new Animated.Value(-100)).current;
const messageTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const [isLocked, setIsLocked] = useState(false);
@@ -384,9 +379,27 @@ export default function AppLayout() {
};
}, [lastMessage]);
// Animate match modal when notification changes
useEffect(() => {
if (matchNotification) {
Animated.spring(matchModalAnim, {
toValue: 1,
useNativeDriver: true,
tension: 80,
friction: 10,
}).start();
}
}, [matchNotification, matchModalAnim]);
const dismissNotification = useCallback(() => {
Animated.timing(matchModalAnim, {
toValue: 0,
duration: 200,
useNativeDriver: true,
}).start(() => {
setMatchNotification(null);
}, []);
});
}, [matchModalAnim]);
const dismissMessageToast = useCallback(() => {
Animated.timing(messageToastAnim, {
@@ -507,15 +520,32 @@ export default function AppLayout() {
/>
</Tabs>
{/* Match Notification Modal */}
<Modal
visible={!!matchNotification}
transparent
animationType="fade"
onRequestClose={dismissNotification}
{/* Match Notification Overlay - Using Animated.View instead of Modal for Android compatibility */}
{matchNotification && (
<Animated.View
style={[
styles.matchOverlay,
{
opacity: matchModalAnim,
},
]}
>
<Pressable style={styles.matchOverlayBackground} onPress={dismissNotification} />
<Animated.View
style={[
styles.modalContent,
{
transform: [
{
scale: matchModalAnim.interpolate({
inputRange: [0, 1],
outputRange: [0.8, 1],
}),
},
],
},
]}
>
<Pressable style={styles.modalOverlay} onPress={dismissNotification}>
<View style={styles.modalContent}>
<View style={styles.modalHeader}>
<Ionicons name="heart" size={48} color={colors.primary} />
<Text style={styles.modalTitle}>It's a Match!</Text>
@@ -534,9 +564,9 @@ export default function AppLayout() {
<Pressable style={styles.dismissButton} onPress={dismissNotification}>
<Text style={styles.dismissButtonText}>Nice!</Text>
</Pressable>
</View>
</Pressable>
</Modal>
</Animated.View>
</Animated.View>
)}
{/* Message Toast Notification */}
{lastMessage && (
@@ -581,19 +611,23 @@ const styles = StyleSheet.create({
alignItems: "center",
backgroundColor: colors.background,
},
modalOverlay: {
flex: 1,
backgroundColor: 'rgba(0, 0, 0, 0.7)',
matchOverlay: {
...StyleSheet.absoluteFillObject,
justifyContent: 'center',
alignItems: 'center',
padding: spacing.lg,
zIndex: 9999,
elevation: 9999,
},
matchOverlayBackground: {
...StyleSheet.absoluteFillObject,
backgroundColor: 'rgba(0, 0, 0, 0.7)',
},
modalContent: {
backgroundColor: colors.backgroundLight,
borderRadius: radius.xl,
padding: spacing.xl,
alignItems: 'center',
width: '100%',
width: '90%',
maxWidth: 340,
borderWidth: 1,
borderColor: colors.glass.border,
+2 -11
View File
@@ -8,6 +8,7 @@ import { supabase } from "../../src/lib/supabase";
import { usePacksStore, useAuthStore } from "../../src/store";
import { skipQuestion, getSkippedQuestionIds } from "../../src/lib/skippedQuestions";
import { hasSeenSwipeTutorial, markSwipeTutorialSeen } from "../../src/lib/swipeTutorialSeen";
import { invokeWithAuthRetry } from "../../src/lib/authErrorHandler";
import SwipeCard from "../../src/components/SwipeCard";
import SwipeTutorial from "../../src/components/SwipeTutorial";
import { GradientBackground, GlassCard, GlassButton } from "../../src/components/ui";
@@ -120,21 +121,11 @@ export default function SwipeScreen() {
const answer = direction === "right" ? "yes" : direction === "left" ? "no" : "maybe";
try {
const { data: { session } } = await supabase.auth.getSession();
if (!session?.access_token) {
console.error("No valid session - user may need to re-login");
return;
}
const { error } = await supabase.functions.invoke("submit-response", {
const { error } = await invokeWithAuthRetry("submit-response", {
body: {
question_id: question.id,
answer,
},
headers: {
Authorization: `Bearer ${session.access_token}`,
},
});
if (error) {
+18 -1
View File
@@ -63,7 +63,7 @@ export function FeedbackModal({ visible, onClose }: FeedbackModalProps) {
const handlePickScreenshot = async () => {
const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ImagePicker.MediaTypeOptions.Images,
allowsEditing: true,
allowsEditing: false,
quality: 0.7,
});
@@ -178,6 +178,16 @@ export function FeedbackModal({ visible, onClose }: FeedbackModalProps) {
behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
>
<View style={styles.content}>
{/* Close Button */}
<TouchableOpacity
style={styles.closeButton}
onPress={handleClose}
disabled={isSubmitting}
hitSlop={{ top: 10, bottom: 10, left: 10, right: 10 }}
>
<Ionicons name="close" size={24} color={colors.textSecondary} />
</TouchableOpacity>
<ScrollView
showsVerticalScrollIndicator={false}
keyboardShouldPersistTaps="handled"
@@ -375,6 +385,13 @@ const styles = StyleSheet.create({
borderWidth: 1,
borderColor: colors.glass.border,
},
closeButton: {
position: 'absolute',
top: spacing.md,
right: spacing.md,
zIndex: 1,
padding: spacing.xs,
},
scrollContent: {
flexGrow: 1,
},
+103 -40
View File
@@ -6,6 +6,8 @@ import Animated, {
useAnimatedStyle,
withRepeat,
withTiming,
withSequence,
withDelay,
interpolate,
Easing,
} from 'react-native-reanimated';
@@ -19,44 +21,43 @@ interface ShimmerEffectProps {
enabled?: boolean;
}
const AnimatedLinearGradient = Animated.createAnimatedComponent(LinearGradient);
export function ShimmerEffect({
children,
style,
shimmerColor = colors.premium.gold,
duration = 2500,
duration = 4000,
enabled = true,
}: ShimmerEffectProps) {
const shimmerTranslate = useSharedValue(-1);
const glowIntensity = useSharedValue(0);
useEffect(() => {
if (enabled) {
shimmerTranslate.value = withRepeat(
// Gentle breathing glow - slow fade in, hold, slow fade out, pause
glowIntensity.value = withRepeat(
withSequence(
withTiming(1, {
duration,
easing: Easing.inOut(Easing.ease),
duration: duration * 0.4,
easing: Easing.out(Easing.cubic),
}),
-1, // infinite repeat
false // don't reverse
withDelay(
duration * 0.2,
withTiming(0, {
duration: duration * 0.4,
easing: Easing.in(Easing.cubic),
})
)
),
-1,
false
);
} else {
shimmerTranslate.value = -1;
glowIntensity.value = 0;
}
}, [enabled, duration]);
const shimmerStyle = useAnimatedStyle(() => {
const translateX = interpolate(
shimmerTranslate.value,
[-1, 1],
[-200, 200]
);
const glowStyle = useAnimatedStyle(() => {
return {
transform: [
{ translateX },
{ rotate: '25deg' },
],
opacity: interpolate(glowIntensity.value, [0, 1], [0, 0.6]),
};
});
@@ -64,18 +65,47 @@ export function ShimmerEffect({
<View style={[styles.container, style]}>
{children}
{enabled && (
<Animated.View style={[styles.shimmerContainer, shimmerStyle]}>
<Animated.View style={[styles.glowContainer, glowStyle]} pointerEvents="none">
{/* Top edge glow */}
<LinearGradient
colors={[
'transparent',
`${shimmerColor}40`, // 25% opacity
`${shimmerColor}60`, // 37% opacity
`${shimmerColor}40`, // 25% opacity
'transparent',
]}
colors={[`${shimmerColor}30`, 'transparent']}
start={{ x: 0.5, y: 0 }}
end={{ x: 0.5, y: 1 }}
style={styles.edgeGlowTop}
/>
{/* Bottom edge glow */}
<LinearGradient
colors={['transparent', `${shimmerColor}20`]}
start={{ x: 0.5, y: 0 }}
end={{ x: 0.5, y: 1 }}
style={styles.edgeGlowBottom}
/>
{/* Left edge glow */}
<LinearGradient
colors={[`${shimmerColor}25`, 'transparent']}
start={{ x: 0, y: 0.5 }}
end={{ x: 1, y: 0.5 }}
style={styles.shimmerGradient}
style={styles.edgeGlowLeft}
/>
{/* Right edge glow */}
<LinearGradient
colors={['transparent', `${shimmerColor}25`]}
start={{ x: 0, y: 0.5 }}
end={{ x: 1, y: 0.5 }}
style={styles.edgeGlowRight}
/>
{/* Corner accents for refined look */}
<LinearGradient
colors={[`${shimmerColor}35`, 'transparent']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={styles.cornerTopLeft}
/>
<LinearGradient
colors={[`${shimmerColor}35`, 'transparent']}
start={{ x: 1, y: 0 }}
end={{ x: 0, y: 1 }}
style={styles.cornerTopRight}
/>
</Animated.View>
)}
@@ -87,17 +117,50 @@ const styles = StyleSheet.create({
container: {
overflow: 'hidden',
},
shimmerContainer: {
position: 'absolute',
top: -50,
bottom: -50,
width: 100,
left: '50%',
marginLeft: -50,
glowContainer: {
...StyleSheet.absoluteFillObject,
},
shimmerGradient: {
flex: 1,
width: '100%',
edgeGlowTop: {
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: 24,
},
edgeGlowBottom: {
position: 'absolute',
bottom: 0,
left: 0,
right: 0,
height: 16,
},
edgeGlowLeft: {
position: 'absolute',
top: 0,
left: 0,
bottom: 0,
width: 16,
},
edgeGlowRight: {
position: 'absolute',
top: 0,
right: 0,
bottom: 0,
width: 16,
},
cornerTopLeft: {
position: 'absolute',
top: 0,
left: 0,
width: 40,
height: 40,
},
cornerTopRight: {
position: 'absolute',
top: 0,
right: 0,
width: 40,
height: 40,
},
});
+107
View File
@@ -0,0 +1,107 @@
import { router } from "expo-router";
import { supabase } from "./supabase";
import { useAuthStore } from "../store";
/**
* Handles authentication errors from edge function calls.
*
* When a 401 error occurs, this function:
* 1. Attempts to refresh the session
* 2. If refresh fails (session was deleted server-side), signs the user out and navigates to login
*
* @returns true if the error was handled and user should retry, false if signed out
*/
export async function handleAuthError(error: any): Promise<boolean> {
// Check if this is a 401 authentication error
const is401 = error?.status === 401 ||
error?.message?.includes("401") ||
error?.message?.includes("session") ||
error?.message?.includes("unauthorized");
if (!is401) {
return false;
}
console.log("[AuthErrorHandler] Detected auth error, attempting session refresh");
try {
// Try to refresh the session
const { data, error: refreshError } = await supabase.auth.refreshSession();
if (refreshError || !data.session) {
console.log("[AuthErrorHandler] Session refresh failed, signing out:", refreshError?.message);
// Session is invalid - sign the user out and navigate to login
await useAuthStore.getState().signOut();
// Explicitly navigate to login to ensure user doesn't get stuck
router.replace("/(auth)/login");
return false;
}
console.log("[AuthErrorHandler] Session refreshed successfully");
return true;
} catch (e) {
console.error("[AuthErrorHandler] Error during refresh:", e);
await useAuthStore.getState().signOut();
router.replace("/(auth)/login");
return false;
}
}
/**
* Wrapper for edge function invocation with automatic auth error handling.
*
* @param functionName - Name of the edge function to invoke
* @param options - Options including body and headers
* @param retryOnAuthError - Whether to retry after refreshing auth (default: true)
* @returns The result of the function invocation
*/
export async function invokeWithAuthRetry(
functionName: string,
options: { body?: any; headers?: Record<string, string> } = {},
retryOnAuthError = true
): Promise<{ data: any; error: any }> {
// Get current session
const { data: { session } } = await supabase.auth.getSession();
if (!session?.access_token) {
console.error("[AuthErrorHandler] No valid session for function call");
return { data: null, error: { message: "No valid session" } };
}
// Make the request
const result = await supabase.functions.invoke(functionName, {
body: options.body,
headers: {
...options.headers,
Authorization: `Bearer ${session.access_token}`,
},
});
// Check for auth error
if (result.error) {
const is401 = result.error?.status === 401 ||
result.error?.message?.includes("401") ||
result.error?.context?.status === 401;
if (is401 && retryOnAuthError) {
console.log("[AuthErrorHandler] Got 401, attempting refresh and retry");
const canRetry = await handleAuthError(result.error);
if (canRetry) {
// Get new session and retry
const { data: { session: newSession } } = await supabase.auth.getSession();
if (newSession?.access_token) {
return supabase.functions.invoke(functionName, {
body: options.body,
headers: {
...options.headers,
Authorization: `Bearer ${newSession.access_token}`,
},
});
}
}
}
}
return result;
}
+20 -1
View File
@@ -34,6 +34,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
fetchUser: async () => {
try {
// First check if we have a session in storage
const { data: { session } } = await supabase.auth.getSession();
if (!session?.user) {
@@ -41,10 +42,28 @@ export const useAuthStore = create<AuthState>((set, get) => ({
return;
}
// Validate the session by fetching the user from the server
// This will fail if the session was deleted server-side
const { data: { user: authUser }, error: authError } = await supabase.auth.getUser();
if (authError || !authUser) {
console.log("[Auth] Session invalid, signing out:", authError?.message);
// Session is invalid - clear everything
set({ user: null, couple: null, partner: null, isAuthenticated: false, isLoading: false });
// Clear other stores
useMatchStore.getState().clearMatches();
usePacksStore.getState().clearPacks();
useMessageStore.getState().clearMessages();
useSubscriptionStore.getState().clearSubscription();
// Sign out from Supabase (clears storage)
await supabase.auth.signOut();
return;
}
const { data: profile } = await supabase
.from("profiles")
.select("*")
.eq("id", session.user.id)
.eq("id", authUser.id)
.maybeSingle();
set({
Binary file not shown.

After

Width:  |  Height:  |  Size: 318 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 251 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 468 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 275 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

+304
View File
@@ -0,0 +1,304 @@
import sharp from "sharp";
import { createCanvas, loadImage } from "@napi-rs/canvas";
import fs from "fs/promises";
import path from "path";
import { fileURLToPath } from "url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
// Google Play Store screenshot dimensions (phone only)
const DIMENSIONS = {
phone: { width: 1080, height: 2400 }, // Standard modern phone (20:9)
};
// Screenshot configurations with marketing text (consistent with iOS)
const SCREENSHOTS = [
{
source: "android_dashboard",
headline: "Your Couple Dashboard",
subheadline: "Track matches, packs & explore together",
outputName: "01-home",
},
{
source: "android_packs.png",
headline: "Curated Question Packs",
subheadline: "From first dates to spicy adventures",
outputName: "02-packs",
},
{
source: "android swiping.png",
headline: "Swipe on Questions",
subheadline: "Answer yes, no, or maybe together",
outputName: "03-swipe",
},
{
source: "android_play.png",
headline: "Express Yourself",
subheadline: "Swipe right on what excites you",
outputName: "04-swipe-yes",
},
{
source: "android_matches.png",
headline: "Discover Your Matches",
subheadline: "See where you both agree",
outputName: "05-matches",
},
{
source: "android_chat.png",
headline: "Chat About It",
subheadline: "Every match unlocks a conversation",
outputName: "06-chat",
},
];
// Brand colors (matching app theme)
const COLORS = {
background: {
start: "#0f0f1a", // Dark purple-black
end: "#1a1a2e", // Slightly lighter
},
accent: {
pink: "#ec4899",
purple: "#8b5cf6",
},
text: {
primary: "#ffffff",
secondary: "rgba(255, 255, 255, 0.7)",
},
};
async function createGradientBackground(width, height) {
// Create a gradient background using canvas
const canvas = createCanvas(width, height);
const ctx = canvas.getContext("2d");
// Create vertical gradient
const gradient = ctx.createLinearGradient(0, 0, 0, height);
gradient.addColorStop(0, COLORS.background.start);
gradient.addColorStop(0.5, COLORS.background.end);
gradient.addColorStop(1, COLORS.background.start);
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, width, height);
// Add subtle accent glow at top
const glowGradient = ctx.createRadialGradient(
width / 2,
0,
0,
width / 2,
0,
width * 0.8
);
glowGradient.addColorStop(0, "rgba(236, 72, 153, 0.15)");
glowGradient.addColorStop(0.5, "rgba(139, 92, 246, 0.08)");
glowGradient.addColorStop(1, "transparent");
ctx.fillStyle = glowGradient;
ctx.fillRect(0, 0, width, height * 0.5);
return canvas.toBuffer("image/png");
}
async function addTextOverlay(
backgroundBuffer,
width,
height,
headline,
subheadline
) {
const canvas = createCanvas(width, height);
const ctx = canvas.getContext("2d");
// Draw the background first
const img = await sharp(backgroundBuffer).toBuffer();
const backgroundImage = await loadImage(img);
ctx.drawImage(backgroundImage, 0, 0);
// Calculate text positions (top area)
const textAreaTop = height * 0.05;
// Draw headline
const headlineSize = Math.round(width * 0.065);
ctx.font = `bold ${headlineSize}px -apple-system, BlinkMacSystemFont, "SF Pro Display", "Segoe UI", sans-serif`;
ctx.fillStyle = COLORS.text.primary;
ctx.textAlign = "center";
ctx.textBaseline = "top";
// Add text shadow for better readability
ctx.shadowColor = "rgba(0, 0, 0, 0.5)";
ctx.shadowBlur = 20;
ctx.shadowOffsetX = 0;
ctx.shadowOffsetY = 4;
ctx.fillText(headline, width / 2, textAreaTop);
// Draw subheadline
const subheadlineSize = Math.round(width * 0.035);
ctx.font = `${subheadlineSize}px -apple-system, BlinkMacSystemFont, "SF Pro Display", "Segoe UI", sans-serif`;
ctx.fillStyle = COLORS.text.secondary;
ctx.shadowBlur = 10;
ctx.fillText(subheadline, width / 2, textAreaTop + headlineSize + 20);
return canvas.toBuffer("image/png");
}
async function generateScreenshot(config, targetSize, inputDir, outputDir) {
const { width, height } = DIMENSIONS[targetSize];
const { source, headline, subheadline, outputName } = config;
console.log(
` Generating ${outputName} for ${targetSize} (${width}x${height})...`
);
// Load source screenshot
const sourcePath = path.join(inputDir, source);
const sourceImage = sharp(sourcePath);
const sourceMetadata = await sourceImage.metadata();
// Calculate screenshot placement
// Screenshot should take up about 75% of height, centered horizontally
const screenshotMaxHeight = Math.round(height * 0.75);
const screenshotMaxWidth = Math.round(width * 0.85);
// Calculate scale to fit
const scaleHeight = screenshotMaxHeight / sourceMetadata.height;
const scaleWidth = screenshotMaxWidth / sourceMetadata.width;
const scale = Math.min(scaleHeight, scaleWidth);
const screenshotWidth = Math.round(sourceMetadata.width * scale);
const screenshotHeight = Math.round(sourceMetadata.height * scale);
// Position screenshot (centered horizontally, below text area)
const screenshotX = Math.round((width - screenshotWidth) / 2);
const screenshotY = Math.round(height * 0.18); // Start below text
// Create gradient background
const backgroundBuffer = await createGradientBackground(width, height);
// Add text overlay
const withText = await addTextOverlay(
backgroundBuffer,
width,
height,
headline,
subheadline
);
// Resize source screenshot with rounded corners
const roundedCorners = Math.round(screenshotWidth * 0.06);
const resizedScreenshot = await sourceImage
.resize(screenshotWidth, screenshotHeight, {
fit: "contain",
background: { r: 0, g: 0, b: 0, alpha: 0 },
})
.toBuffer();
// Create rounded corner mask
const maskCanvas = createCanvas(screenshotWidth, screenshotHeight);
const maskCtx = maskCanvas.getContext("2d");
maskCtx.beginPath();
maskCtx.roundRect(0, 0, screenshotWidth, screenshotHeight, roundedCorners);
maskCtx.fillStyle = "white";
maskCtx.fill();
const roundedScreenshot = await sharp(resizedScreenshot)
.composite([
{
input: await sharp(maskCanvas.toBuffer("image/png"))
.resize(screenshotWidth, screenshotHeight)
.toBuffer(),
blend: "dest-in",
},
])
.toBuffer();
// Add drop shadow effect by creating a shadow layer
const shadowOffset = 20;
const shadowBlur = 40;
const shadowCanvas = createCanvas(
screenshotWidth + shadowBlur * 2,
screenshotHeight + shadowBlur * 2
);
const shadowCtx = shadowCanvas.getContext("2d");
shadowCtx.shadowColor = "rgba(0, 0, 0, 0.4)";
shadowCtx.shadowBlur = shadowBlur;
shadowCtx.shadowOffsetX = 0;
shadowCtx.shadowOffsetY = shadowOffset;
shadowCtx.fillStyle = "black";
shadowCtx.beginPath();
shadowCtx.roundRect(
shadowBlur,
shadowBlur,
screenshotWidth,
screenshotHeight,
roundedCorners
);
shadowCtx.fill();
// Composite everything together
const finalImage = await sharp(withText)
.composite([
{
input: shadowCanvas.toBuffer("image/png"),
left: screenshotX - shadowBlur,
top: screenshotY - shadowBlur,
blend: "multiply",
},
{
input: roundedScreenshot,
left: screenshotX,
top: screenshotY,
},
])
.png()
.toBuffer();
// Save output
const outputPath = path.join(outputDir, `${outputName}-${targetSize}.png`);
await fs.writeFile(outputPath, finalImage);
console.log(` Saved: ${outputPath}`);
}
async function main() {
console.log("Google Play Store Screenshot Generator\n");
const inputDir = path.resolve(__dirname, "../../etc/android_sim");
const outputDir = path.resolve(__dirname, "output");
// Create output directory
await fs.mkdir(outputDir, { recursive: true });
// Check for source files
console.log("Checking source screenshots...");
for (const config of SCREENSHOTS) {
const sourcePath = path.join(inputDir, config.source);
try {
await fs.access(sourcePath);
console.log(` Found: ${config.source}`);
} catch {
console.error(` Missing: ${config.source}`);
process.exit(1);
}
}
console.log("\nGenerating screenshots...\n");
// Generate for each size
for (const targetSize of Object.keys(DIMENSIONS)) {
console.log(`\n${targetSize} device:`);
for (const config of SCREENSHOTS) {
await generateScreenshot(config, targetSize, inputDir, outputDir);
}
}
console.log("\n\nDone! Screenshots saved to:", outputDir);
console.log("\nGenerated files:");
const files = await fs.readdir(outputDir);
for (const file of files.sort()) {
console.log(` - ${file}`);
}
}
main().catch(console.error);
Binary file not shown.

After

Width:  |  Height:  |  Size: 459 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 880 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 358 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 317 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 296 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 306 KiB

+759
View File
@@ -0,0 +1,759 @@
{
"name": "play-store-screenshots",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "play-store-screenshots",
"version": "1.0.0",
"dependencies": {
"@napi-rs/canvas": "^0.1.52",
"sharp": "^0.33.2"
}
},
"node_modules/@emnapi/runtime": {
"version": "1.7.1",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.7.1.tgz",
"integrity": "sha512-PVtJr5CmLwYAU9PZDMITZoR5iAOShYREoR45EyyLrbntV50mdePTgUn4AmOw90Ifcj+x2kRjdzr1HP3RrNiHGA==",
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@img/sharp-darwin-arm64": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz",
"integrity": "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==",
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-arm64": "1.0.4"
}
},
"node_modules/@img/sharp-darwin-x64": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.5.tgz",
"integrity": "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-x64": "1.0.4"
}
},
"node_modules/@img/sharp-libvips-darwin-arm64": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.4.tgz",
"integrity": "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==",
"cpu": [
"arm64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"darwin"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-darwin-x64": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.4.tgz",
"integrity": "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==",
"cpu": [
"x64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"darwin"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-arm": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.5.tgz",
"integrity": "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==",
"cpu": [
"arm"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-arm64": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.4.tgz",
"integrity": "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==",
"cpu": [
"arm64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-s390x": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.4.tgz",
"integrity": "sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==",
"cpu": [
"s390x"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-x64": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz",
"integrity": "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==",
"cpu": [
"x64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.4.tgz",
"integrity": "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==",
"cpu": [
"arm64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.4.tgz",
"integrity": "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==",
"cpu": [
"x64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-linux-arm": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.5.tgz",
"integrity": "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==",
"cpu": [
"arm"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm": "1.0.5"
}
},
"node_modules/@img/sharp-linux-arm64": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.5.tgz",
"integrity": "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==",
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm64": "1.0.4"
}
},
"node_modules/@img/sharp-linux-s390x": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.5.tgz",
"integrity": "sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==",
"cpu": [
"s390x"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-s390x": "1.0.4"
}
},
"node_modules/@img/sharp-linux-x64": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz",
"integrity": "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-x64": "1.0.4"
}
},
"node_modules/@img/sharp-linuxmusl-arm64": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.5.tgz",
"integrity": "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==",
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-arm64": "1.0.4"
}
},
"node_modules/@img/sharp-linuxmusl-x64": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.5.tgz",
"integrity": "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-x64": "1.0.4"
}
},
"node_modules/@img/sharp-wasm32": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.33.5.tgz",
"integrity": "sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==",
"cpu": [
"wasm32"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
"optional": true,
"dependencies": {
"@emnapi/runtime": "^1.2.0"
},
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-ia32": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.5.tgz",
"integrity": "sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==",
"cpu": [
"ia32"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-x64": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz",
"integrity": "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==",
"cpu": [
"x64"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@napi-rs/canvas": {
"version": "0.1.88",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.88.tgz",
"integrity": "sha512-/p08f93LEbsL5mDZFQ3DBxcPv/I4QG9EDYRRq1WNlCOXVfAHBTHMSVMwxlqG/AtnSfUr9+vgfN7MKiyDo0+Weg==",
"license": "MIT",
"workspaces": [
"e2e/*"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
},
"optionalDependencies": {
"@napi-rs/canvas-android-arm64": "0.1.88",
"@napi-rs/canvas-darwin-arm64": "0.1.88",
"@napi-rs/canvas-darwin-x64": "0.1.88",
"@napi-rs/canvas-linux-arm-gnueabihf": "0.1.88",
"@napi-rs/canvas-linux-arm64-gnu": "0.1.88",
"@napi-rs/canvas-linux-arm64-musl": "0.1.88",
"@napi-rs/canvas-linux-riscv64-gnu": "0.1.88",
"@napi-rs/canvas-linux-x64-gnu": "0.1.88",
"@napi-rs/canvas-linux-x64-musl": "0.1.88",
"@napi-rs/canvas-win32-arm64-msvc": "0.1.88",
"@napi-rs/canvas-win32-x64-msvc": "0.1.88"
}
},
"node_modules/@napi-rs/canvas-android-arm64": {
"version": "0.1.88",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.88.tgz",
"integrity": "sha512-KEaClPnZuVxJ8smUWjV1wWFkByBO/D+vy4lN+Dm5DFH514oqwukxKGeck9xcKJhaWJGjfruGmYGiwRe//+/zQQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-darwin-arm64": {
"version": "0.1.88",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.88.tgz",
"integrity": "sha512-Xgywz0dDxOKSgx3eZnK85WgGMmGrQEW7ZLA/E7raZdlEE+xXCozobgqz2ZvYigpB6DJFYkqnwHjqCOTSDGlFdg==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-darwin-x64": {
"version": "0.1.88",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.88.tgz",
"integrity": "sha512-Yz4wSCIQOUgNucgk+8NFtQxQxZV5NO8VKRl9ePKE6XoNyNVC8JDqtvhh3b3TPqKK8W5p2EQpAr1rjjm0mfBxdg==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-arm-gnueabihf": {
"version": "0.1.88",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.88.tgz",
"integrity": "sha512-9gQM2SlTo76hYhxHi2XxWTAqpTOb+JtxMPEIr+H5nAhHhyEtNmTSDRtz93SP7mGd2G3Ojf2oF5tP9OdgtgXyKg==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-arm64-gnu": {
"version": "0.1.88",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.88.tgz",
"integrity": "sha512-7qgaOBMXuVRk9Fzztzr3BchQKXDxGbY+nwsovD3I/Sx81e+sX0ReEDYHTItNb0Je4NHbAl7D0MKyd4SvUc04sg==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-arm64-musl": {
"version": "0.1.88",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.88.tgz",
"integrity": "sha512-kYyNrUsHLkoGHBc77u4Unh067GrfiCUMbGHC2+OTxbeWfZkPt2o32UOQkhnSswKd9Fko/wSqqGkY956bIUzruA==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-riscv64-gnu": {
"version": "0.1.88",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.88.tgz",
"integrity": "sha512-HVuH7QgzB0yavYdNZDRyAsn/ejoXB0hn8twwFnOqUbCCdkV+REna7RXjSR7+PdfW0qMQ2YYWsLvVBT5iL/mGpw==",
"cpu": [
"riscv64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-x64-gnu": {
"version": "0.1.88",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.88.tgz",
"integrity": "sha512-hvcvKIcPEQrvvJtJnwD35B3qk6umFJ8dFIr8bSymfrSMem0EQsfn1ztys8ETIFndTwdNWJKWluvxztA41ivsEw==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-x64-musl": {
"version": "0.1.88",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.88.tgz",
"integrity": "sha512-eSMpGYY2xnZSQ6UxYJ6plDboxq4KeJ4zT5HaVkUnbObNN6DlbJe0Mclh3wifAmquXfrlgTZt6zhHsUgz++AK6g==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-win32-arm64-msvc": {
"version": "0.1.88",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-0.1.88.tgz",
"integrity": "sha512-qcIFfEgHrchyYqRrxsCeTQgpJZ/GqHiqPcU/Fvw/ARVlQeDX1VyFH+X+0gCR2tca6UJrq96vnW+5o7buCq+erA==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-win32-x64-msvc": {
"version": "0.1.88",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.88.tgz",
"integrity": "sha512-ROVqbfS4QyZxYkqmaIBBpbz/BQvAR+05FXM5PAtTYVc0uyY8Y4BHJSMdGAaMf6TdIVRsQsiq+FG/dH9XhvWCFQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/color": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/color/-/color-4.2.3.tgz",
"integrity": "sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==",
"license": "MIT",
"dependencies": {
"color-convert": "^2.0.1",
"color-string": "^1.9.0"
},
"engines": {
"node": ">=12.5.0"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"license": "MIT",
"dependencies": {
"color-name": "~1.1.4"
},
"engines": {
"node": ">=7.0.0"
}
},
"node_modules/color-name": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"license": "MIT"
},
"node_modules/color-string": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz",
"integrity": "sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==",
"license": "MIT",
"dependencies": {
"color-name": "^1.0.0",
"simple-swizzle": "^0.2.2"
}
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"license": "Apache-2.0",
"engines": {
"node": ">=8"
}
},
"node_modules/is-arrayish": {
"version": "0.3.4",
"resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz",
"integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==",
"license": "MIT"
},
"node_modules/semver": {
"version": "7.7.3",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz",
"integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/sharp": {
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.5.tgz",
"integrity": "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==",
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
"color": "^4.2.3",
"detect-libc": "^2.0.3",
"semver": "^7.6.3"
},
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-darwin-arm64": "0.33.5",
"@img/sharp-darwin-x64": "0.33.5",
"@img/sharp-libvips-darwin-arm64": "1.0.4",
"@img/sharp-libvips-darwin-x64": "1.0.4",
"@img/sharp-libvips-linux-arm": "1.0.5",
"@img/sharp-libvips-linux-arm64": "1.0.4",
"@img/sharp-libvips-linux-s390x": "1.0.4",
"@img/sharp-libvips-linux-x64": "1.0.4",
"@img/sharp-libvips-linuxmusl-arm64": "1.0.4",
"@img/sharp-libvips-linuxmusl-x64": "1.0.4",
"@img/sharp-linux-arm": "0.33.5",
"@img/sharp-linux-arm64": "0.33.5",
"@img/sharp-linux-s390x": "0.33.5",
"@img/sharp-linux-x64": "0.33.5",
"@img/sharp-linuxmusl-arm64": "0.33.5",
"@img/sharp-linuxmusl-x64": "0.33.5",
"@img/sharp-wasm32": "0.33.5",
"@img/sharp-win32-ia32": "0.33.5",
"@img/sharp-win32-x64": "0.33.5"
}
},
"node_modules/simple-swizzle": {
"version": "0.2.4",
"resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz",
"integrity": "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==",
"license": "MIT",
"dependencies": {
"is-arrayish": "^0.3.1"
}
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD",
"optional": true
}
}
}
@@ -0,0 +1,12 @@
{
"name": "play-store-screenshots",
"version": "1.0.0",
"type": "module",
"scripts": {
"generate": "node generate.js"
},
"dependencies": {
"sharp": "^0.33.2",
"@napi-rs/canvas": "^0.1.52"
}
}