implement global UI components including Toast notification system, PageLayout, and Modal utilities while migrating existing pages to the new layout architecture

This commit is contained in:
Yaseen 2026-07-10 09:16:01 +05:30
parent 7ae2ac1d7d
commit 51c7d280b0
33 changed files with 3904 additions and 2436 deletions

View File

@ -123,6 +123,7 @@ model LegalAcceptance {
ipAddress String ipAddress String
signatureHash String? signatureHash String?
documentUrl String? documentUrl String?
signatureBase64 String?
acceptedAt DateTime @default(now()) acceptedAt DateTime @default(now())
document LegalDocument @relation(fields: [docId], references: [id]) document LegalDocument @relation(fields: [docId], references: [id])
user User @relation(fields: [userId], references: [id]) user User @relation(fields: [userId], references: [id])

View File

@ -69,7 +69,14 @@ export class LegalController {
signatureHash = crypto.createHash('sha256').update(signatureBase64).digest('hex'); signatureHash = crypto.createHash('sha256').update(signatureBase64).digest('hex');
} }
const acceptance = await this.legalService.recordAcceptance(activeDoc.id, userId, ipAddress, signatureHash || undefined, documentUrl); const acceptance = await this.legalService.recordAcceptance(
activeDoc.id,
userId,
ipAddress,
signatureHash || undefined,
documentUrl,
signatureBase64 || undefined
);
// Check if they completed all onboarding steps // Check if they completed all onboarding steps
await this.legalService.checkOnboardingCompletion(userId); await this.legalService.checkOnboardingCompletion(userId);

View File

@ -33,10 +33,11 @@ export class LegalService {
ipAddress: string, ipAddress: string,
signatureHash?: string, signatureHash?: string,
documentUrl?: string, documentUrl?: string,
signatureBase64?: string,
) { ) {
// Record acceptance // Record acceptance
const acceptance = await prisma.legalAcceptance.create({ const acceptance = await prisma.legalAcceptance.create({
data: { docId, userId, ipAddress, signatureHash, documentUrl }, data: { docId, userId, ipAddress, signatureHash, documentUrl, signatureBase64 },
}); });
return acceptance; return acceptance;
@ -67,7 +68,15 @@ export class LegalService {
public async getAcceptances(userId: string) { public async getAcceptances(userId: string) {
return await prisma.legalAcceptance.findMany({ return await prisma.legalAcceptance.findMany({
where: { userId }, where: { userId },
include: { document: true }, select: {
id: true,
signatureHash: true,
documentUrl: true,
signatureBase64: true,
ipAddress: true,
acceptedAt: true,
document: true,
},
}); });
} }
@ -79,7 +88,15 @@ export class LegalService {
email: true, email: true,
createdAt: true, createdAt: true,
acceptances: { acceptances: {
include: { document: true } select: {
id: true,
signatureHash: true,
documentUrl: true,
signatureBase64: true,
ipAddress: true,
acceptedAt: true,
document: true,
}
} }
} }
}); });

View File

@ -5,6 +5,8 @@ import { useEffect } from 'react';
import { useThemeStore } from './hooks/use-theme'; import { useThemeStore } from './hooks/use-theme';
import { useAuthStore } from './hooks/use-auth'; import { useAuthStore } from './hooks/use-auth';
import { ToastProvider } from "./components/ui/Toast";
const queryClient = new QueryClient({ const queryClient = new QueryClient({
defaultOptions: { defaultOptions: {
queries: { retry: 1, refetchOnWindowFocus: false } queries: { retry: 1, refetchOnWindowFocus: false }
@ -39,7 +41,9 @@ export const App = () => {
return ( return (
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<ToastProvider>
<RouterProvider router={router} /> <RouterProvider router={router} />
</ToastProvider>
</QueryClientProvider> </QueryClientProvider>
); );
}; };

View File

@ -1,9 +1,22 @@
import React, { useState } from 'react'; import React, { useState } from "react";
import { Link, Outlet, useLocation, useNavigate } from 'react-router-dom'; import { Link, Outlet, useLocation, useNavigate } from "react-router-dom";
import { useThemeStore } from '../../hooks/use-theme'; import { useThemeStore } from "../../hooks/use-theme";
import { useAuthStore } from '../../hooks/use-auth'; import { useAuthStore } from "../../hooks/use-auth";
import { ShieldCheck, BarChart3, ClipboardCheck, FolderGit2, BookCopy, Users, LogOut, Menu, X, Sun, Moon, ChevronRight, ChevronLeft } from 'lucide-react'; import {
import { motion, AnimatePresence } from 'framer-motion'; ShieldCheck,
ClipboardCheck,
FolderGit2,
BookCopy,
Users,
LogOut,
Menu,
X,
Sun,
Moon,
ChevronRight,
ChevronLeft,
} from "lucide-react";
import { motion, AnimatePresence } from "framer-motion";
export const AdminLayout: React.FC = () => { export const AdminLayout: React.FC = () => {
const { user, logout } = useAuthStore(); const { user, logout } = useAuthStore();
@ -15,51 +28,62 @@ export const AdminLayout: React.FC = () => {
const handleLogout = () => { const handleLogout = () => {
logout(); logout();
navigate('/login'); navigate("/login");
}; };
const navItems = [ const navItems = [
{ name: 'Partners', path: '/admin/partners', icon: Users }, { name: "Partners", path: "/admin/partners", icon: Users },
{ name: 'Approvals Queue', path: '/admin/approvals', icon: ClipboardCheck }, { name: "Approvals Queue", path: "/admin/approvals", icon: ClipboardCheck },
{ name: 'Legal Templates', path: '/admin/legal', icon: ShieldCheck }, { name: "Legal Templates", path: "/admin/legal", icon: ShieldCheck },
{ name: 'Manage Catalog', path: '/admin/assets', icon: FolderGit2 }, { name: "Manage Catalog", path: "/admin/assets", icon: FolderGit2 },
{ name: 'Analytics', path: '/admin/analytics', icon: BarChart3 }, { name: "Blog CMS", path: "/admin/blog", icon: BookCopy },
{ name: 'Blog CMS', path: '/admin/blog', icon: BookCopy }
]; ];
return ( return (
<div className="min-h-screen flex flex-col md:flex-row bg-ink-50 text-ink-900 font-sans transition-colors duration-500 selection:bg-ink-900/10 overflow-hidden"> <div className="min-h-screen flex flex-col md:flex-row bg-ink-50 text-ink-900 font-sans transition-colors duration-500 selection:bg-ink-900/10 overflow-hidden">
{/* ── Desktop Sidebar ── */} {/* ── Desktop Sidebar ── */}
<aside className={`hidden md:flex md:flex-col md:sticky md:top-0 md:h-screen bg-ink-0 border-r border-ink-200 shrink-0 z-20 transition-all duration-300 relative ${isCollapsed ? 'md:w-[80px]' : 'md:w-[280px]'}`}> <aside
className={`hidden md:flex md:flex-col md:sticky md:top-0 md:h-screen bg-ink-0 border-r border-ink-200 shrink-0 z-20 transition-all duration-300 relative ${isCollapsed ? "md:w-[80px]" : "md:w-[280px]"}`}
>
{/* Toggle Button Floating on the Border */} {/* Toggle Button Floating on the Border */}
<button <button
onClick={() => setIsCollapsed(!isCollapsed)} onClick={() => setIsCollapsed(!isCollapsed)}
className="hidden md:flex absolute top-9 -right-3 w-6 h-6 rounded-full border border-ink-200 bg-ink-0 text-ink-500 hover:text-ink-900 hover:shadow-md items-center justify-center transition-all z-30 cursor-pointer shadow-sm" className="hidden md:flex absolute top-9 -right-3 w-6 h-6 rounded-full border border-ink-200 bg-ink-0 text-ink-500 hover:text-ink-900 hover:shadow-md items-center justify-center transition-all z-30 cursor-pointer shadow-sm"
title={isCollapsed ? "Expand Sidebar" : "Collapse Sidebar"} title={isCollapsed ? "Expand Sidebar" : "Collapse Sidebar"}
> >
{isCollapsed ? <ChevronRight className="w-3.5 h-3.5" /> : <ChevronLeft className="w-3.5 h-3.5" />} {isCollapsed ? (
<ChevronRight className="w-3.5 h-3.5" />
) : (
<ChevronLeft className="w-3.5 h-3.5" />
)}
</button> </button>
{/* Branding */} {/* Branding */}
<div className={`h-24 flex items-center border-b border-ink-200 transition-all duration-300 ${isCollapsed ? 'justify-center px-4' : 'px-8'}`}> <div
className={`h-24 flex items-center border-b border-ink-200 transition-all duration-300 ${isCollapsed ? "justify-center px-4" : "px-8"}`}
>
<Link to="/admin" className="flex items-center gap-3 shrink-0 group"> <Link to="/admin" className="flex items-center gap-3 shrink-0 group">
<div className="w-10 h-10 rounded-xl flex items-center justify-center bg-gradient-to-br from-ink-900 to-ink-800 shadow-lg group-hover:scale-105 transition-all duration-300"> <div className="w-10 h-10 rounded-xl flex items-center justify-center bg-gradient-to-br from-ink-900 to-ink-800 shadow-lg group-hover:scale-105 transition-all duration-300">
<ShieldCheck className="w-5 h-5 text-ink-0" /> <ShieldCheck className="w-5 h-5 text-ink-0" />
</div> </div>
{!isCollapsed && ( {!isCollapsed && (
<div className="flex flex-col animate-fade-in"> <div className="flex flex-col animate-fade-in">
<span className="text-lg font-extrabold tracking-tight leading-none text-ink-900">Tech4Biz</span> <span className="text-lg font-extrabold tracking-tight leading-none text-ink-900">
<span className="text-[10px] font-bold uppercase tracking-widest text-ink-900 mt-1">Admin Console</span> Tech4Biz
</span>
<span className="text-[10px] font-bold uppercase tracking-widest text-ink-900 mt-1">
Admin Console
</span>
</div> </div>
)} )}
</Link> </Link>
</div> </div>
{/* Navigation */} {/* Navigation */}
<nav className={`flex-1 py-8 space-y-2 overflow-y-auto transition-all duration-300 ${isCollapsed ? 'px-2' : 'px-4'}`}> <nav
{navItems.map(item => { className={`flex-1 py-8 space-y-2 overflow-y-auto transition-all duration-300 ${isCollapsed ? "px-2" : "px-4"}`}
>
{navItems.map((item) => {
const Icon = item.icon; const Icon = item.icon;
const isActive = location.pathname === item.path; const isActive = location.pathname === item.path;
return ( return (
@ -67,31 +91,47 @@ export const AdminLayout: React.FC = () => {
key={item.path} key={item.path}
to={item.path} to={item.path}
className={`flex items-center gap-3 py-3 rounded-xl transition-all duration-300 font-semibold tracking-wide text-sm group relative ${ className={`flex items-center gap-3 py-3 rounded-xl transition-all duration-300 font-semibold tracking-wide text-sm group relative ${
isCollapsed ? 'justify-center px-0' : 'px-4' isCollapsed ? "justify-center px-0" : "px-4"
} ${ } ${
isActive isActive
? 'bg-ink-100 text-ink-900 border border-ink-300 shadow-sm' ? "bg-ink-100 text-ink-900 border border-ink-300 shadow-sm"
: 'text-ink-500 hover:text-ink-900 hover:bg-ink-100' : "text-ink-500 hover:text-ink-900 hover:bg-ink-100"
}`} }`}
title={isCollapsed ? item.name : undefined} title={isCollapsed ? item.name : undefined}
> >
<Icon className={`w-5 h-5 transition-transform group-hover:scale-110 ${isActive ? 'text-ink-950' : 'text-ink-400 group-hover:text-ink-900'}`} /> <Icon
className={`w-5 h-5 transition-transform group-hover:scale-110 ${isActive ? "text-ink-950" : "text-ink-400 group-hover:text-ink-900"}`}
/>
{!isCollapsed && <span>{item.name}</span>} {!isCollapsed && <span>{item.name}</span>}
{!isCollapsed && isActive && <ChevronRight className="w-4 h-4 ml-auto opacity-50" />} {!isCollapsed && isActive && (
<ChevronRight className="w-4 h-4 ml-auto opacity-50" />
)}
</Link> </Link>
); );
})} })}
</nav> </nav>
{/* Footer */} {/* Footer */}
<div className={`border-t border-ink-200 bg-ink-50 transition-all duration-300 ${isCollapsed ? 'p-3' : 'p-5'}`}> <div
<div className={`flex items-center mb-4 transition-all duration-300 ${isCollapsed ? 'justify-center' : 'justify-between'}`}> className={`border-t border-ink-200 bg-ink-50 transition-all duration-300 ${isCollapsed ? "p-3" : "p-5"}`}
{!isCollapsed && <p className="text-[10px] font-bold text-ink-400 uppercase tracking-widest">Appearance</p>} >
<div
className={`flex items-center mb-4 transition-all duration-300 ${isCollapsed ? "justify-center" : "justify-between"}`}
>
{!isCollapsed && (
<p className="text-[10px] font-bold text-ink-400 uppercase tracking-widest">
Appearance
</p>
)}
<button <button
onClick={toggleTheme} onClick={toggleTheme}
className="p-2 rounded-lg bg-ink-0 border border-ink-200 text-ink-500 hover:text-ink-900 hover:shadow-sm transition-all group cursor-pointer" className="p-2 rounded-lg bg-ink-0 border border-ink-200 text-ink-500 hover:text-ink-900 hover:shadow-sm transition-all group cursor-pointer"
> >
{theme === 'dark' ? <Sun className="w-4 h-4 group-hover:rotate-90 transition-transform" /> : <Moon className="w-4 h-4 group-hover:-rotate-12 transition-transform" />} {theme === "dark" ? (
<Sun className="w-4 h-4 group-hover:rotate-90 transition-transform" />
) : (
<Moon className="w-4 h-4 group-hover:-rotate-12 transition-transform" />
)}
</button> </button>
</div> </div>
@ -101,19 +141,26 @@ export const AdminLayout: React.FC = () => {
{user?.email?.charAt(0).toUpperCase()} {user?.email?.charAt(0).toUpperCase()}
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<p className="text-sm font-bold text-ink-900 truncate">{user?.email}</p> <p className="text-sm font-bold text-ink-900 truncate">
<p className="text-[10px] uppercase font-bold text-ink-500 tracking-wider truncate">Administrator</p> {user?.email}
</p>
<p className="text-[10px] uppercase font-bold text-ink-500 tracking-wider truncate">
Administrator
</p>
</div> </div>
</div> </div>
) : ( ) : (
<div className="w-9 h-9 rounded-full bg-ink-900 flex items-center justify-center text-ink-0 font-bold shadow-sm mx-auto mb-4 animate-fade-in" title={user?.email || ''}> <div
className="w-9 h-9 rounded-full bg-ink-900 flex items-center justify-center text-ink-0 font-bold shadow-sm mx-auto mb-4 animate-fade-in"
title={user?.email || ""}
>
{user?.email?.charAt(0).toUpperCase()} {user?.email?.charAt(0).toUpperCase()}
</div> </div>
)} )}
<button <button
onClick={handleLogout} onClick={handleLogout}
className={`w-full flex items-center justify-center gap-2 rounded-xl text-sm font-bold tracking-wide text-red-600 hover:bg-red-500/10 transition-colors border border-transparent hover:border-red-200 cursor-pointer ${isCollapsed ? 'py-2 px-0' : 'px-4 py-2.5'}`} className={`w-full flex items-center justify-center gap-2 rounded-xl text-sm font-bold tracking-wide text-red-600 hover:bg-red-500/10 transition-colors border border-transparent hover:border-red-200 cursor-pointer ${isCollapsed ? "py-2 px-0" : "px-4 py-2.5"}`}
title={isCollapsed ? "Sign Out" : undefined} title={isCollapsed ? "Sign Out" : undefined}
> >
<LogOut className="w-4 h-4" /> <LogOut className="w-4 h-4" />
@ -123,19 +170,24 @@ export const AdminLayout: React.FC = () => {
</aside> </aside>
{/* ── Main Content Area ── */} {/* ── Main Content Area ── */}
<main className="flex-1 flex flex-col relative w-full overflow-y-auto bg-ink-50"> <main className="flex-1 flex flex-col relative w-full h-screen overflow-hidden bg-ink-50">
{/* Ambient Background Glows */} {/* Ambient Background Glows */}
<div className="fixed top-0 right-0 w-[600px] h-[600px] bg-ink-900/5 rounded-full blur-[150px] pointer-events-none -z-10" /> <div className="fixed top-0 right-0 w-[600px] h-[600px] bg-ink-900/5 rounded-full blur-[150px] pointer-events-none -z-10" />
{/* Mobile Header */} {/* Mobile Header */}
<header className="md:hidden sticky top-0 z-40 h-16 flex items-center justify-between px-4 bg-ink-0 border-b border-ink-200 shadow-sm"> <header className="md:hidden sticky top-0 z-40 h-16 flex items-center justify-between px-4 bg-ink-0 border-b border-ink-200 shadow-sm shrink-0">
<Link to="/admin" className="flex items-center gap-2"> <Link to="/admin" className="flex items-center gap-2">
<div className="w-8 h-8 rounded-lg flex items-center justify-center bg-gradient-to-br from-ink-900 to-ink-800"> <div className="w-8 h-8 rounded-lg flex items-center justify-center bg-gradient-to-br from-ink-900 to-ink-800">
<ShieldCheck className="w-4 h-4 text-ink-0" /> <ShieldCheck className="w-4 h-4 text-ink-0" />
</div> </div>
<span className="text-sm font-extrabold tracking-tight text-ink-900">Admin Console</span> <span className="text-sm font-extrabold tracking-tight text-ink-900">
Admin Console
</span>
</Link> </Link>
<button onClick={() => setMobileOpen(true)} className="p-2 rounded-lg border border-ink-200 text-ink-600"> <button
onClick={() => setMobileOpen(true)}
className="p-2 rounded-lg border border-ink-200 text-ink-600"
>
<Menu className="w-5 h-5" /> <Menu className="w-5 h-5" />
</button> </button>
</header> </header>
@ -144,44 +196,84 @@ export const AdminLayout: React.FC = () => {
<AnimatePresence> <AnimatePresence>
{mobileOpen && ( {mobileOpen && (
<> <>
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} onClick={() => setMobileOpen(false)} className="fixed inset-0 bg-ink-900/40 backdrop-blur-sm z-50 md:hidden" /> <motion.div
<motion.div initial={{ x: '100%' }} animate={{ x: 0 }} exit={{ x: '100%' }} transition={{ type: 'spring', damping: 25, stiffness: 200 }} className="fixed right-0 top-0 bottom-0 w-72 bg-ink-0 shadow-2xl z-50 border-l border-ink-200 flex flex-col md:hidden"> initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={() => setMobileOpen(false)}
className="fixed inset-0 bg-ink-900/40 backdrop-blur-sm z-50 md:hidden"
/>
<motion.div
initial={{ x: "100%" }}
animate={{ x: 0 }}
exit={{ x: "100%" }}
transition={{ type: "spring", damping: 25, stiffness: 200 }}
className="fixed right-0 top-0 bottom-0 w-72 bg-ink-0 shadow-2xl z-50 border-l border-ink-200 flex flex-col md:hidden"
>
<div className="p-4 border-b border-ink-200 flex items-center justify-between"> <div className="p-4 border-b border-ink-200 flex items-center justify-between">
<span className="font-extrabold text-ink-900">Menu</span> <span className="font-extrabold text-ink-900">Menu</span>
<button onClick={() => setMobileOpen(false)} className="p-2 rounded-lg bg-ink-100"> <button
onClick={() => setMobileOpen(false)}
className="p-2 rounded-lg bg-ink-100"
>
<X className="w-4 h-4" /> <X className="w-4 h-4" />
</button> </button>
</div> </div>
<nav className="flex-1 overflow-y-auto p-4 space-y-2"> <nav className="flex-1 overflow-y-auto p-4 space-y-2">
{navItems.map(item => ( {navItems.map((item) => (
<Link key={item.path} to={item.path} onClick={() => setMobileOpen(false)} className={`flex items-center gap-3 px-4 py-3 rounded-xl text-sm font-semibold ${location.pathname === item.path ? 'bg-ink-100 text-ink-900 border border-ink-300' : 'text-ink-500 hover:text-ink-900'}`}> <Link
key={item.path}
to={item.path}
onClick={() => setMobileOpen(false)}
className={`flex items-center gap-3 px-4 py-3 rounded-xl text-sm font-semibold ${location.pathname === item.path ? "bg-ink-100 text-ink-900 border border-ink-300" : "text-ink-500 hover:text-ink-900"}`}
>
<item.icon className="w-5 h-5" /> <item.icon className="w-5 h-5" />
<span>{item.name}</span> <span>{item.name}</span>
</Link> </Link>
))} ))}
</nav> </nav>
<div className="p-4 border-t border-ink-200 space-y-4"> <div className="p-4 border-t border-ink-200 space-y-4">
<button onClick={toggleTheme} className="flex items-center justify-between w-full p-3 rounded-xl bg-ink-50 border border-ink-200 text-sm font-bold text-ink-500 hover:text-ink-900"> <button
Theme {theme === 'dark' ? <Sun className="w-4 h-4" /> : <Moon className="w-4 h-4" />} onClick={toggleTheme}
className="flex items-center justify-between w-full p-3 rounded-xl bg-ink-50 border border-ink-200 text-sm font-bold text-ink-500 hover:text-ink-900"
>
Theme{" "}
{theme === "dark" ? (
<Sun className="w-4 h-4" />
) : (
<Moon className="w-4 h-4" />
)}
</button>
<button
onClick={() => {
setMobileOpen(false);
handleLogout();
}}
className="w-full py-3 rounded-xl bg-red-500/10 text-red-650 font-bold text-sm"
>
Sign Out
</button> </button>
<button onClick={() => { setMobileOpen(false); handleLogout(); }} className="w-full py-3 rounded-xl bg-red-500/10 text-red-650 font-bold text-sm">Sign Out</button>
</div> </div>
</motion.div> </motion.div>
</> </>
)} )}
</AnimatePresence> </AnimatePresence>
<div className="flex-1 w-full max-w-[1600px] px-4 py-6 md:px-8 mx-auto relative z-10"> <div className="flex-1 w-full max-w-[1600px] px-4 py-4 md:px-8 mx-auto relative z-10 overflow-hidden flex flex-col min-h-0">
<Outlet /> <Outlet />
</div> </div>
{/* Footer */} {/* Footer */}
<footer className="border-t border-ink-200 bg-ink-0/50 backdrop-blur-md mt-auto"> <footer className="border-t border-ink-200 bg-ink-0/50 backdrop-blur-md shrink-0">
<div className="max-w-[1600px] mx-auto px-4 md:px-8 py-4 flex flex-col md:flex-row justify-between items-center gap-4 text-xs font-medium text-ink-500"> <div className="max-w-[1600px] mx-auto px-4 md:px-8 py-3 flex flex-col md:flex-row justify-between items-center gap-4 text-xs font-medium text-ink-500">
<p>© 2026 Tech4Biz Solutions Inc. Admin Console.</p> <p>© 2026 Tech4Biz Solutions Inc. Admin Console.</p>
<div className="flex gap-6"> <div className="flex gap-6">
<span className="hover:text-ink-900 cursor-pointer transition-colors">Security Compliance</span> <span className="hover:text-ink-900 cursor-pointer transition-colors">
<span className="hover:text-ink-900 cursor-pointer transition-colors">System Status</span> Security Compliance
</span>
<span className="hover:text-ink-900 cursor-pointer transition-colors">
System Status
</span>
</div> </div>
</div> </div>
</footer> </footer>

View File

@ -119,16 +119,14 @@ export const ClientLayout: React.FC = () => {
{!isCollapsed && <span>Sign Out</span>} {!isCollapsed && <span>Sign Out</span>}
</button> </button>
</div> </div>
</aside> </aside> {/* ── Main Content Area ── */}
<main className="flex-1 flex flex-col relative w-full h-screen overflow-hidden bg-ink-50">
{/* ── Main Content Area ── */}
<main className="flex-1 flex flex-col relative w-full overflow-y-auto bg-ink-50">
{/* Ambient Glow */} {/* Ambient Glow */}
<div className="fixed top-0 right-0 w-[500px] h-[500px] bg-ink-900/5 rounded-full blur-[150px] pointer-events-none -z-10" /> <div className="fixed top-0 right-0 w-[500px] h-[500px] bg-ink-900/5 rounded-full blur-[150px] pointer-events-none -z-10" />
{/* Mobile Header */} {/* Mobile Header */}
<header className="md:hidden sticky top-0 z-40 h-16 flex items-center justify-between px-4 bg-ink-0 border-b border-ink-200 shadow-sm"> <header className="md:hidden sticky top-0 z-40 h-16 flex items-center justify-between px-4 bg-ink-0 border-b border-ink-200 shadow-sm shrink-0">
<Link to="/client" className="flex items-center gap-2"> <Link to="/client" className="flex items-center gap-2">
<div className="w-8 h-8 rounded-lg flex items-center justify-center bg-gradient-to-br from-ink-900 to-ink-800"> <div className="w-8 h-8 rounded-lg flex items-center justify-center bg-gradient-to-br from-ink-900 to-ink-800">
<ShieldCheck className="w-4 h-4 text-ink-0" /> <ShieldCheck className="w-4 h-4 text-ink-0" />
@ -161,7 +159,7 @@ export const ClientLayout: React.FC = () => {
))} ))}
</nav> </nav>
<div className="p-4 border-t border-ink-200 space-y-4"> <div className="p-4 border-t border-ink-200 space-y-4">
<button onClick={toggleTheme} className="flex items-center justify-between w-full p-3 rounded-xl bg-ink-50 border border-ink-200 text-sm font-bold text-ink-600"> <button onClick={toggleTheme} className="flex items-center justify-between w-full p-3 rounded-xl bg-ink-50 border border-ink-200 text-sm font-bold text-ink-650">
Theme {theme === 'dark' ? <Sun className="w-4 h-4" /> : <Moon className="w-4 h-4" />} Theme {theme === 'dark' ? <Sun className="w-4 h-4" /> : <Moon className="w-4 h-4" />}
</button> </button>
<button onClick={() => { setMobileOpen(false); handleLogout(); }} className="w-full py-3 rounded-xl bg-red-500/10 text-red-600 font-bold text-sm">Sign Out</button> <button onClick={() => { setMobileOpen(false); handleLogout(); }} className="w-full py-3 rounded-xl bg-red-500/10 text-red-600 font-bold text-sm">Sign Out</button>
@ -171,13 +169,13 @@ export const ClientLayout: React.FC = () => {
)} )}
</AnimatePresence> </AnimatePresence>
<div className="flex-1 w-full max-w-[1600px] px-4 py-6 md:px-8 mx-auto relative z-10"> <div className="flex-1 w-full max-w-[1600px] px-4 py-4 md:px-8 mx-auto relative z-10 overflow-hidden flex flex-col min-h-0">
<Outlet /> <Outlet />
</div> </div>
{/* Footer */} {/* Footer */}
<footer className="border-t border-ink-200 bg-ink-0/50 backdrop-blur-md mt-auto"> <footer className="border-t border-ink-200 bg-ink-0/50 backdrop-blur-md shrink-0">
<div className="max-w-[1600px] mx-auto px-4 md:px-8 py-4 flex flex-col md:flex-row justify-between items-center gap-4 text-xs font-medium text-ink-500"> <div className="max-w-[1600px] mx-auto px-4 md:px-8 py-3 flex flex-col md:flex-row justify-between items-center gap-4 text-xs font-medium text-ink-500">
<p>© 2026 Tech4Biz Solutions Inc. All rights reserved.</p> <p>© 2026 Tech4Biz Solutions Inc. All rights reserved.</p>
<div className="flex gap-6"> <div className="flex gap-6">
<span className="hover:text-ink-900 cursor-pointer transition-colors">Security</span> <span className="hover:text-ink-900 cursor-pointer transition-colors">Security</span>

View File

@ -1,20 +1,56 @@
import React, { Suspense } from 'react'; import React, { Suspense } from "react";
import { createBrowserRouter, Navigate } from 'react-router-dom'; import { createBrowserRouter, Navigate } from "react-router-dom";
import { RequireAuth, RequireRole, RequireOnboardingComplete } from './guards'; import { RequireAuth, RequireRole, RequireOnboardingComplete } from "./guards";
// Layouts // Layouts
const ClientLayout = React.lazy(() => import('../layouts/ClientLayout')); const ClientLayout = React.lazy(() => import("../layouts/ClientLayout"));
const AdminLayout = React.lazy(() => import('../layouts/AdminLayout')); const AdminLayout = React.lazy(() => import("../layouts/AdminLayout"));
// Pages (Lazy Loaded) // Pages (Lazy Loaded)
const LoginPage = React.lazy(() => import('../../pages/LoginPage').then(m => ({ default: m.LoginPage }))); const LoginPage = React.lazy(() =>
const InvitePage = React.lazy(() => import('../../pages/InvitePage').then(m => ({ default: m.InvitePage }))); import("../../pages/LoginPage").then((m) => ({ default: m.LoginPage })),
const DashboardPage = React.lazy(() => import('../../pages/DashboardPage').then(m => ({ default: m.DashboardPage }))); );
const AssetsPage = React.lazy(() => import('../../pages/AssetsPage').then(m => ({ default: m.AssetsPage }))); const InvitePage = React.lazy(() =>
const ApprovalsPage = React.lazy(() => import('../../pages/admin/ApprovalsPage').then(m => ({ default: m.ApprovalsPage }))); import("../../pages/InvitePage").then((m) => ({ default: m.InvitePage })),
const DirectoryPage = React.lazy(() => import('../../pages/admin/DirectoryPage').then(m => ({ default: m.DirectoryPage }))); );
const OnboardingPage = React.lazy(() => import('../../pages/OnboardingPage').then(m => ({ default: m.OnboardingPage }))); const DashboardPage = React.lazy(() =>
const LegalTemplatesPage = React.lazy(() => import('../../pages/admin/LegalTemplatesPage').then(m => ({ default: m.LegalTemplatesPage }))); import("../../pages/DashboardPage").then((m) => ({
default: m.DashboardPage,
})),
);
const AssetsPage = React.lazy(() =>
import("../../pages/AssetsPage").then((m) => ({ default: m.AssetsPage })),
);
const ApprovalsPage = React.lazy(() =>
import("../../pages/admin/ApprovalsPage").then((m) => ({
default: m.ApprovalsPage,
})),
);
const DirectoryPage = React.lazy(() =>
import("../../pages/admin/DirectoryPage").then((m) => ({
default: m.DirectoryPage,
})),
);
const OnboardingPage = React.lazy(() =>
import("../../pages/OnboardingPage").then((m) => ({
default: m.OnboardingPage,
})),
);
const LegalTemplatesPage = React.lazy(() =>
import("../../pages/admin/LegalTemplatesPage").then((m) => ({
default: m.LegalTemplatesPage,
})),
);
const BlogCatalog = React.lazy(() =>
import("../../features/blog/components/BlogCatalog").then((m) => ({
default: m.BlogCatalog,
})),
);
const ClientAgreementsPage = React.lazy(() =>
import("../../pages/ClientAgreementsPage").then((m) => ({
default: m.ClientAgreementsPage,
})),
);
// Dummy Components for routing // Dummy Components for routing
const LoadingFallback = () => ( const LoadingFallback = () => (
@ -23,17 +59,13 @@ const LoadingFallback = () => (
</div> </div>
); );
const LegalPage = () => <div className="max-w-[1400px] mx-auto w-full p-8"><h1 className="text-4xl font-extrabold text-ink-800 tracking-tight">Legal Engine</h1></div>;
const AnalyticsPage = () => <div className="max-w-[1400px] mx-auto w-full p-8"><h1 className="text-4xl font-extrabold text-ink-800 tracking-tight">Analytics Dashboard</h1></div>;
const BlogPage = () => <div className="max-w-[1400px] mx-auto w-full p-8"><h1 className="text-4xl font-extrabold text-ink-800 tracking-tight">Blog CMS</h1></div>;
export const router = createBrowserRouter([ export const router = createBrowserRouter([
{ {
path: '/', path: "/",
element: <Navigate to="/login" replace />, element: <Navigate to="/login" replace />,
}, },
{ {
path: '/login', path: "/login",
element: ( element: (
<Suspense fallback={<LoadingFallback />}> <Suspense fallback={<LoadingFallback />}>
<LoginPage /> <LoginPage />
@ -41,7 +73,7 @@ export const router = createBrowserRouter([
), ),
}, },
{ {
path: '/invite', path: "/invite",
element: ( element: (
<Suspense fallback={<LoadingFallback />}> <Suspense fallback={<LoadingFallback />}>
<InvitePage /> <InvitePage />
@ -49,7 +81,7 @@ export const router = createBrowserRouter([
), ),
}, },
{ {
path: '/onboarding', path: "/onboarding",
element: ( element: (
<RequireAuth> <RequireAuth>
<RequireRole role="PARTNER_USER"> <RequireRole role="PARTNER_USER">
@ -61,7 +93,7 @@ export const router = createBrowserRouter([
), ),
}, },
{ {
path: '/client', path: "/client",
element: ( element: (
<RequireAuth> <RequireAuth>
<RequireRole role="PARTNER_USER"> <RequireRole role="PARTNER_USER">
@ -83,7 +115,7 @@ export const router = createBrowserRouter([
), ),
}, },
{ {
path: 'assets', path: "assets",
element: ( element: (
<Suspense fallback={<LoadingFallback />}> <Suspense fallback={<LoadingFallback />}>
<AssetsPage /> <AssetsPage />
@ -91,17 +123,25 @@ export const router = createBrowserRouter([
), ),
}, },
{ {
path: 'agreements', path: "agreements",
element: <LegalPage />, element: (
<Suspense fallback={<LoadingFallback />}>
<ClientAgreementsPage />
</Suspense>
),
}, },
{ {
path: 'blog', path: "blog",
element: <BlogPage />, element: (
} <Suspense fallback={<LoadingFallback />}>
<BlogCatalog />
</Suspense>
),
},
], ],
}, },
{ {
path: '/admin', path: "/admin",
element: ( element: (
<RequireAuth> <RequireAuth>
<RequireRole role="ADMIN"> <RequireRole role="ADMIN">
@ -117,7 +157,7 @@ export const router = createBrowserRouter([
element: <DirectoryPage />, element: <DirectoryPage />,
}, },
{ {
path: 'assets', path: "assets",
element: ( element: (
<Suspense fallback={<LoadingFallback />}> <Suspense fallback={<LoadingFallback />}>
<AssetsPage /> <AssetsPage />
@ -125,7 +165,7 @@ export const router = createBrowserRouter([
), ),
}, },
{ {
path: 'legal', path: "legal",
element: ( element: (
<Suspense fallback={<LoadingFallback />}> <Suspense fallback={<LoadingFallback />}>
<LegalTemplatesPage /> <LegalTemplatesPage />
@ -133,29 +173,29 @@ export const router = createBrowserRouter([
), ),
}, },
{ {
path: 'analytics', path: "blog",
element: <AnalyticsPage />, element: (
<Suspense fallback={<LoadingFallback />}>
<BlogCatalog />
</Suspense>
),
}, },
{ {
path: 'blog', path: "approvals",
element: <div>Blog Management Coming Soon</div>
},
{
path: 'approvals',
element: ( element: (
<Suspense fallback={<LoadingFallback />}> <Suspense fallback={<LoadingFallback />}>
<ApprovalsPage /> <ApprovalsPage />
</Suspense> </Suspense>
) ),
}, },
{ {
path: 'partners', path: "partners",
element: ( element: (
<Suspense fallback={<LoadingFallback />}> <Suspense fallback={<LoadingFallback />}>
<DirectoryPage /> <DirectoryPage />
</Suspense> </Suspense>
) ),
} },
] ],
} },
]); ]);

View File

@ -0,0 +1,47 @@
import React from 'react';
interface PageLayoutProps {
header: React.ReactNode;
toolbar?: React.ReactNode;
children: React.ReactNode;
footer?: React.ReactNode;
className?: string;
}
export const PageLayout: React.FC<PageLayoutProps> = ({
header,
toolbar,
children,
footer,
className = '',
}) => {
return (
<div className={`flex flex-col h-[calc(100vh-140px)] md:h-[calc(100vh-160px)] w-full overflow-hidden ${className}`}>
{/* Page Header (Fixed) */}
<div className="shrink-0 mb-4">
{header}
</div>
{/* Toolbar (Fixed) */}
{toolbar && (
<div className="shrink-0 mb-4">
{toolbar}
</div>
)}
{/* Scrollable Content Area */}
<div className="flex-1 min-h-0 overflow-y-auto bg-ink-0 border border-ink-200 rounded-xl shadow-sm relative flex flex-col scrollbar-thin">
{children}
</div>
{/* Page-level Footer/Pagination (Fixed) */}
{footer && (
<div className="shrink-0 mt-4">
{footer}
</div>
)}
</div>
);
};
export default PageLayout;

View File

@ -0,0 +1,45 @@
import React from 'react';
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary' | 'ghost' | 'danger';
size?: 'xs' | 'sm' | 'md';
icon?: React.ReactNode;
}
export const Button: React.FC<ButtonProps> = ({
variant = 'primary',
size = 'sm',
icon,
children,
className = '',
...props
}) => {
// Base classes for the button
const baseClasses = 'inline-flex items-center justify-center font-semibold tracking-wider transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed select-none font-sans shrink-0 border';
// Variant classes
const variantClasses = {
primary: 'bg-ink-900 hover:bg-ink-800 text-ink-0 border-transparent shadow-sm hover-lift',
secondary: 'bg-ink-100 hover:bg-ink-200 text-ink-800 border-ink-200',
ghost: 'bg-transparent hover:bg-ink-100 text-ink-700 border-ink-200',
danger: 'bg-red-500 hover:bg-red-600 text-white border-transparent shadow-sm',
};
// Size classes
const sizeClasses = {
xs: 'text-[10px] uppercase tracking-wider py-1.5 px-3 rounded-lg gap-1.5',
sm: 'text-xs uppercase tracking-wider py-2 px-3.5 rounded-lg gap-2',
md: 'text-xs uppercase tracking-wider py-2.5 px-4.5 rounded-xl gap-2.5',
};
const combinedClassName = `${baseClasses} ${variantClasses[variant]} ${sizeClasses[size]} ${className}`;
return (
<button className={combinedClassName} {...props}>
{icon && <span className="inline-flex items-center justify-center shrink-0">{icon}</span>}
<span>{children}</span>
</button>
);
};
export default Button;

View File

@ -0,0 +1,679 @@
import React, { useState, useEffect, useRef } from 'react';
import ReactDOM from 'react-dom';
import { motion } from 'framer-motion';
import {
X, ZoomIn, ZoomOut, RotateCcw, Download, Printer,
ShieldCheck, AlertTriangle, CheckCircle, ChevronLeft,
ChevronRight, FileText, Maximize, Minimize
} from 'lucide-react';
import { Button } from './Button';
interface Acceptance {
id: string;
signatureHash: string | null;
documentUrl: string | null;
signatureBase64: string | null;
ipAddress: string;
acceptedAt: string;
document: {
id: string;
type: string;
version: string;
content: string;
};
}
interface DocumentPreviewModalProps {
isOpen: boolean;
onClose: () => void;
partnerId: string;
partnerEmail: string;
partnerCreatedAt: string;
acceptances: Acceptance[];
verifiedDocs: { nda: boolean; msa: boolean };
onVerify: (docType: 'NDA' | 'MSA') => void;
onApprovePartner: () => void;
isApproving: boolean;
}
export const DocumentPreviewModal: React.FC<DocumentPreviewModalProps> = ({
isOpen,
onClose,
partnerId: _partnerId,
partnerEmail,
partnerCreatedAt: _partnerCreatedAt,
acceptances,
verifiedDocs,
onVerify,
onApprovePartner,
isApproving,
}) => {
const [currentTab, setCurrentTab] = useState<'NDA' | 'MSA'>('NDA');
const [zoom, setZoom] = useState(100);
const [fitWidth, setFitWidth] = useState(false);
const [currentPage, setCurrentPage] = useState(1);
const [isFullScreen, setIsFullScreen] = useState(false);
const modalRef = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const ndaAcceptance = acceptances.find(a => a.document.type === 'NDA');
const msaAcceptance = acceptances.find(a => a.document.type === 'MSA');
const activeAcceptance = currentTab === 'NDA' ? ndaAcceptance : msaAcceptance;
const totalPages = activeAcceptance?.documentUrl ? 1 : 2;
// Prevent background page scrolling when modal is open
useEffect(() => {
if (isOpen) {
document.body.style.overflow = 'hidden';
} else {
document.body.style.overflow = '';
}
return () => {
document.body.style.overflow = '';
};
}, [isOpen]);
// Handle auto-opening on the signature page (Page 2) for digitally signed documents
useEffect(() => {
if (isOpen && activeAcceptance) {
if (!activeAcceptance.documentUrl && activeAcceptance.signatureHash) {
setCurrentPage(2);
} else {
setCurrentPage(1);
}
}
}, [currentTab, isOpen, activeAcceptance]);
// Full Screen logic
const toggleFullScreen = () => {
if (!modalRef.current) return;
if (!isFullScreen) {
if (modalRef.current.requestFullscreen) {
modalRef.current.requestFullscreen().catch(() => {
setIsFullScreen(true);
});
} else {
setIsFullScreen(true);
}
} else {
if (document.exitFullscreen) {
document.exitFullscreen().catch(() => {});
}
setIsFullScreen(false);
}
};
useEffect(() => {
const handleFullscreenChange = () => {
setIsFullScreen(!!document.fullscreenElement);
};
document.addEventListener('fullscreenchange', handleFullscreenChange);
return () => document.removeEventListener('fullscreenchange', handleFullscreenChange);
}, []);
if (!isOpen) return null;
const handleZoomIn = () => {
setFitWidth(false);
setZoom(prev => Math.min(200, prev + 25));
};
const handleZoomOut = () => {
setFitWidth(false);
setZoom(prev => Math.max(50, prev - 25));
};
const handleZoomReset = () => {
setFitWidth(false);
setZoom(100);
};
const toggleFitWidth = () => {
setFitWidth(!fitWidth);
};
const handlePrint = () => {
const printContent = document.getElementById('printable-doc-content');
if (!printContent) return;
const windowUrl = 'about:blank';
const uniqueName = new Date().getTime();
const printWindow = window.open(windowUrl, uniqueName.toString(), 'left=50000,top=50000,width=0,height=0');
if (!printWindow) return;
printWindow.document.write(`
<html>
<head>
<title>Print Document - ${currentTab}</title>
<style>
body { font-family: sans-serif; padding: 40px; color: #1b1b1b; }
h1 { font-size: 24px; font-weight: bold; margin-bottom: 20px; text-align: center; }
p { font-size: 14px; line-height: 1.6; white-space: pre-line; }
.sig-box { margin-top: 40px; padding: 20px; border: 2px dashed #ccc; text-align: center; max-width: 400px; margin-left: auto; margin-right: auto; }
.sig-title { font-weight: bold; color: #10b981; margin-bottom: 10px; }
.hash { font-family: monospace; font-size: 11px; word-break: break-all; }
</style>
</head>
<body>
<h1>${currentTab === 'NDA' ? 'Non-Disclosure Agreement (NDA)' : 'Master Services Agreement (MSA)'}</h1>
<p>${activeAcceptance?.document.content || ''}</p>
<div class="sig-box">
<div class="sig-title">Digitally Signed & Verified</div>
<div>Signed By: ${partnerEmail}</div>
<div class="hash">Verification Hash: ${activeAcceptance?.signatureHash || 'N/A'}</div>
<div>IP Address: ${activeAcceptance?.ipAddress || 'N/A'}</div>
<div>Date Signed: ${activeAcceptance ? new Date(activeAcceptance.acceptedAt).toLocaleString() : ''}</div>
</div>
</body>
</html>
`);
printWindow.document.close();
printWindow.focus();
printWindow.print();
printWindow.close();
};
const handleDownloadText = () => {
if (!activeAcceptance) return;
const element = document.createElement("a");
const file = new Blob([
`${currentTab === 'NDA' ? 'Non-Disclosure Agreement (NDA)' : 'Master Services Agreement (MSA)'}\n\n`,
activeAcceptance.document.content,
`\n\n=== DIGITAL SIGNATURE ===\n`,
`Signed By: ${partnerEmail}\n`,
`Verification Hash: ${activeAcceptance.signatureHash || 'N/A'}\n`,
`IP Address: ${activeAcceptance.ipAddress}\n`,
`Signed On: ${new Date(activeAcceptance.acceptedAt).toLocaleString()}\n`
], {type: 'text/plain'});
element.href = URL.createObjectURL(file);
element.download = `${currentTab}_Agreement_${partnerEmail.split('@')[0]}.txt`;
document.body.appendChild(element);
element.click();
document.body.removeChild(element);
};
const fileHost = (import.meta.env.VITE_API_URL || 'http://localhost:5000/api/v1').replace('/api/v1', '');
const isBothVerified = verifiedDocs.nda && verifiedDocs.msa;
const isCurrentVerified = currentTab === 'NDA' ? verifiedDocs.nda : verifiedDocs.msa;
// Responsive classes based on screen size
const modalContainerClasses = isFullScreen
? 'fixed inset-0 w-screen h-screen bg-ink-0 z-[9999] flex flex-col overflow-hidden'
: 'relative w-full h-full sm:w-[90vw] sm:h-[88vh] lg:w-[78vw] lg:h-[86vh] bg-ink-0 border border-ink-200 sm:rounded-2xl shadow-2xl flex flex-col overflow-hidden z-[9999]';
return ReactDOM.createPortal(
<div className="fixed inset-0 z-[9999] flex justify-center items-center p-0 sm:p-4 md:p-6 overflow-hidden">
{/* Full-screen Backdrop overlay */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={onClose}
className="fixed inset-0 bg-ink-950/60 backdrop-blur-md z-[9998]"
/>
{/* Modal Box */}
<motion.div
ref={modalRef}
initial={{ opacity: 0, scale: 0.97, y: 15 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.97, y: 15 }}
transition={{ type: 'spring', damping: 26, stiffness: 340 }}
className={modalContainerClasses}
>
{/* Sticky Header */}
<div className="px-5 py-4 border-b border-ink-200 bg-ink-0 flex flex-col md:flex-row md:items-center justify-between gap-3 shrink-0">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-gradient-to-br from-ink-900 to-ink-800 flex items-center justify-center text-ink-0 shrink-0">
<FileText className="w-5 h-5" />
</div>
<div className="min-w-0">
<h3 className="text-sm font-extrabold text-ink-900 tracking-tight truncate">
{currentTab === 'NDA' ? 'Mutual Non-Disclosure Agreement (NDA)' : 'Master Services Agreement (MSA)'}
</h3>
<p className="text-xs text-ink-500 font-semibold truncate">
Partner: <span className="text-ink-900">{partnerEmail}</span>
</p>
</div>
</div>
{/* Switcher tabs */}
<div className="flex items-center gap-2 bg-ink-50 p-1 rounded-xl border border-ink-200 self-start md:self-auto">
<button
onClick={() => {
setCurrentTab('NDA');
setCurrentPage(1);
}}
className={`px-3 py-1.5 rounded-lg text-xs font-bold transition-all cursor-pointer ${
currentTab === 'NDA'
? 'bg-ink-0 text-ink-900 shadow-sm border border-ink-200'
: 'text-ink-500 hover:text-ink-900'
}`}
>
NDA Agreement {verifiedDocs.nda && '✓'}
</button>
<button
onClick={() => {
setCurrentTab('MSA');
setCurrentPage(1);
}}
className={`px-3 py-1.5 rounded-lg text-xs font-bold transition-all cursor-pointer ${
currentTab === 'MSA'
? 'bg-ink-0 text-ink-900 shadow-sm border border-ink-200'
: 'text-ink-500 hover:text-ink-900'
}`}
>
MSA Agreement {verifiedDocs.msa && '✓'}
</button>
</div>
<div className="flex items-center gap-1.5 self-end md:self-auto">
<button
onClick={toggleFullScreen}
className="p-2 rounded-lg text-ink-500 hover:text-ink-900 hover:bg-ink-50 transition-all cursor-pointer"
title={isFullScreen ? "Exit Fullscreen" : "Fullscreen"}
>
{isFullScreen ? <Minimize className="w-4 h-4" /> : <Maximize className="w-4 h-4" />}
</button>
<button
onClick={onClose}
className="p-2 rounded-lg text-ink-400 hover:text-ink-900 hover:bg-ink-50 transition-all cursor-pointer"
title="Close Modal"
>
<X className="w-4 h-4" />
</button>
</div>
</div>
{/* Warning Banner if Unsigned */}
{!activeAcceptance && (
<div className="bg-amber-50 border-b border-amber-200 px-5 py-2.5 flex items-center gap-3 shrink-0">
<AlertTriangle className="w-4.5 h-4.5 text-amber-600 shrink-0" />
<p className="text-xs font-bold text-amber-800">
Attention: This document has not been submitted or signed by the partner.
</p>
</div>
)}
{/* Professional Document Viewer Toolbar */}
<div className="px-5 py-2.5 bg-ink-50 border-b border-ink-200 flex flex-wrap items-center justify-between gap-3 shrink-0">
{/* Page navigation controls */}
<div className="flex items-center gap-1.5">
<button
disabled={currentPage <= 1 || !activeAcceptance}
onClick={() => setCurrentPage(prev => Math.max(1, prev - 1))}
className="p-1.5 rounded-lg border border-ink-200 hover:bg-ink-0 disabled:opacity-40 text-ink-700 bg-ink-0 cursor-pointer shadow-sm"
title="Previous Page"
>
<ChevronLeft className="w-4 h-4" />
</button>
<span className="text-xs font-bold text-ink-600 min-w-[70px] text-center">
Page {activeAcceptance ? currentPage : 0} of {activeAcceptance ? totalPages : 0}
</span>
<button
disabled={currentPage >= totalPages || !activeAcceptance}
onClick={() => setCurrentPage(prev => Math.min(totalPages, prev + 1))}
className="p-1.5 rounded-lg border border-ink-200 hover:bg-ink-0 disabled:opacity-40 text-ink-700 bg-ink-0 cursor-pointer shadow-sm"
title="Next Page"
>
<ChevronRight className="w-4 h-4" />
</button>
</div>
{/* Zoom controls */}
<div className="flex items-center gap-1.5">
<button
onClick={handleZoomOut}
disabled={!activeAcceptance || activeAcceptance.documentUrl !== null}
className="p-1.5 rounded-lg border border-ink-200 hover:bg-ink-0 disabled:opacity-40 text-ink-700 bg-ink-0 cursor-pointer shadow-sm"
title="Zoom Out"
>
<ZoomOut className="w-4 h-4" />
</button>
<span className="text-xs font-bold text-ink-600 min-w-[45px] text-center">
{zoom}%
</span>
<button
onClick={handleZoomIn}
disabled={!activeAcceptance || activeAcceptance.documentUrl !== null}
className="p-1.5 rounded-lg border border-ink-200 hover:bg-ink-0 disabled:opacity-40 text-ink-700 bg-ink-0 cursor-pointer shadow-sm"
title="Zoom In"
>
<ZoomIn className="w-4 h-4" />
</button>
<button
onClick={handleZoomReset}
disabled={!activeAcceptance || activeAcceptance.documentUrl !== null}
className="p-1.5 rounded-lg border border-ink-200 hover:bg-ink-0 disabled:opacity-40 text-ink-700 bg-ink-0 cursor-pointer shadow-sm"
title="Reset Zoom"
>
<RotateCcw className="w-4 h-4" />
</button>
<button
onClick={toggleFitWidth}
disabled={!activeAcceptance || activeAcceptance.documentUrl !== null}
className={`px-2.5 py-1.5 rounded-lg border text-xs font-bold cursor-pointer shadow-sm transition-all ${
fitWidth
? 'bg-ink-900 text-ink-0 border-transparent'
: 'bg-ink-0 border-ink-200 text-ink-700 hover:bg-ink-50'
}`}
title="Fit Width"
>
Fit Width
</button>
</div>
{/* Download & Print Actions */}
<div className="flex items-center gap-2">
<button
onClick={handlePrint}
disabled={!activeAcceptance}
className="p-1.5 rounded-lg border border-ink-200 hover:bg-ink-0 disabled:opacity-40 text-ink-700 bg-ink-0 cursor-pointer flex items-center gap-1.5 text-xs font-bold shadow-sm"
title="Print"
>
<Printer className="w-4 h-4" />
<span className="hidden sm:inline">Print</span>
</button>
<button
onClick={activeAcceptance?.documentUrl ? undefined : handleDownloadText}
disabled={!activeAcceptance}
className="p-1.5 rounded-lg border border-ink-200 hover:bg-ink-0 disabled:opacity-40 text-ink-700 bg-ink-0 cursor-pointer flex items-center gap-1.5 text-xs font-bold shadow-sm"
title="Download File"
>
{activeAcceptance?.documentUrl ? (
<a
href={`${fileHost}${activeAcceptance.documentUrl}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1.5"
>
<Download className="w-4 h-4" />
<span className="hidden sm:inline">Download</span>
</a>
) : (
<>
<Download className="w-4 h-4" />
<span className="hidden sm:inline">Download</span>
</>
)}
</button>
</div>
</div>
{/* Modal Main Body (Split Pane) */}
<div className="flex-1 flex flex-col md:flex-row overflow-hidden min-h-0">
{/* Left Pane: Centered Document Viewer */}
<div className="flex-1 flex flex-col bg-ink-100 overflow-hidden relative p-4 sm:p-6 justify-center items-center">
<div
ref={containerRef}
className="w-full h-full overflow-y-auto flex justify-center items-start scrollbar-thin rounded-lg"
>
{activeAcceptance ? (
activeAcceptance.documentUrl ? (
// PDF / Uploaded Document Viewer centered
<div className="w-full h-full max-w-4xl bg-ink-0 shadow-lg rounded-xl overflow-hidden border border-ink-200 flex justify-center items-center">
{activeAcceptance.documentUrl.toLowerCase().endsWith('.pdf') ? (
<iframe
src={`${fileHost}${activeAcceptance.documentUrl}#toolbar=0`}
title="Document PDF"
className="w-full h-full border-0 bg-white"
/>
) : (
<div className="w-full h-full flex items-center justify-center p-4 bg-ink-50 overflow-auto">
<img
src={`${fileHost}${activeAcceptance.documentUrl}`}
alt="Signed legal document upload"
className="max-w-full max-h-full object-contain shadow-md rounded border border-ink-200"
/>
</div>
)}
</div>
) : (
// Digitally Signed text template (Standard A4 letter styled)
<div
id="printable-doc-content"
className="w-full bg-ink-0 p-8 sm:p-12 my-2 rounded-xl shadow-lg border border-ink-200 font-serif leading-relaxed text-ink-800 transition-all duration-150 select-text flex flex-col"
style={
fitWidth
? { width: '100%', maxWidth: '100%', fontSize: '15px' }
: { width: '100%', maxWidth: '720px', fontSize: `${14 * (zoom / 100)}px` }
}
>
{currentPage === 1 ? (
// Page 1: Agreement text
<div className="flex-1">
<div className="text-center mb-8 not-italic font-sans">
<h4 className="text-base sm:text-lg font-extrabold text-ink-900 tracking-tight">
{currentTab === 'NDA' ? 'MUTUAL NON-DISCLOSURE AGREEMENT' : 'MASTER SERVICES AGREEMENT'}
</h4>
<div className="w-16 h-1 bg-ink-900 mx-auto my-3" />
<p className="text-[10px] text-ink-400 font-bold uppercase tracking-wider">Version {activeAcceptance.document.version}</p>
</div>
<div className="whitespace-pre-line prose max-w-none text-xs sm:text-sm">
{activeAcceptance.document.content}
</div>
<div className="mt-8 pt-4 border-t border-ink-150 text-center not-italic font-sans text-xs text-ink-400 font-semibold">
--- Page 1 of 2 (Standard Terms) ---
</div>
</div>
) : (
// Page 2: Actual signature rendering + metadata
<div className="flex-1 flex flex-col justify-between">
<div>
<div className="text-center mb-6 not-italic font-sans">
<h4 className="text-base sm:text-lg font-extrabold text-ink-900 tracking-tight">
SIGNATURE &amp; ACCORD SIGNING PAGE
</h4>
<p className="text-[10px] text-ink-400 font-bold uppercase tracking-wider mt-1">Agreement Version {activeAcceptance.document.version}</p>
</div>
<p className="text-xs sm:text-sm mb-6 text-ink-600 font-sans italic">
IN WITNESS WHEREOF, the parties hereto have caused this Agreement to be executed by their digital signatures as of the Acceptance Date specified below.
</p>
</div>
{/* ── Actual Signature Rendering ── */}
<div className="my-auto max-w-lg mx-auto w-full space-y-4">
{/* Signature image box */}
<div className="border-2 border-ink-200 rounded-xl overflow-hidden bg-white shadow-sm">
<div className="px-4 pt-3 pb-1 border-b border-ink-100 flex items-center justify-between">
<span className="text-[10px] font-bold text-ink-400 uppercase tracking-widest">Client Signature</span>
<div className="flex items-center gap-1 text-emerald-600">
<ShieldCheck className="w-3.5 h-3.5" />
<span className="text-[10px] font-bold uppercase tracking-wider">Verified</span>
</div>
</div>
<div className="p-4 min-h-[100px] flex items-center justify-center bg-white">
{activeAcceptance.signatureBase64 ? (
// Render the exact drawn signature as-is — no modification
<img
src={activeAcceptance.signatureBase64}
alt="Client drawn signature"
className="max-w-full max-h-[160px] object-contain"
style={{ imageRendering: 'crisp-edges' }}
/>
) : (
// Upload-based signing — no drawn image stored
<div className="text-center py-4">
<ShieldCheck className="w-8 h-8 text-emerald-500 mx-auto mb-2" />
<p className="text-xs text-ink-500 font-semibold">Signed via document upload</p>
<p className="text-[10px] text-ink-400 mt-0.5">See uploaded file in viewer</p>
</div>
)}
</div>
{/* Signature baseline line */}
<div className="px-6 pb-3">
<div className="border-b-2 border-ink-300 w-full" />
<p className="text-[9px] text-ink-400 font-bold uppercase tracking-widest mt-1 text-center">Authorized Digital Signature</p>
</div>
</div>
{/* Verification metadata strip */}
<div className="grid grid-cols-2 gap-x-4 gap-y-2 text-[10px] sm:text-xs font-sans bg-ink-50 border border-ink-200 rounded-xl p-4">
<div>
<p className="font-bold text-ink-400 uppercase tracking-wider text-[9px]">Signed By</p>
<p className="font-bold text-ink-900 mt-0.5 truncate">{partnerEmail}</p>
</div>
<div>
<p className="font-bold text-ink-400 uppercase tracking-wider text-[9px]">Accepted On</p>
<p className="font-semibold text-ink-900 mt-0.5">{new Date(activeAcceptance.acceptedAt).toLocaleString()}</p>
</div>
<div>
<p className="font-bold text-ink-400 uppercase tracking-wider text-[9px]">IP Address</p>
<p className="font-semibold font-mono text-ink-900 mt-0.5">{activeAcceptance.ipAddress}</p>
</div>
<div>
<p className="font-bold text-ink-400 uppercase tracking-wider text-[9px]">Document Version</p>
<p className="font-semibold text-ink-900 mt-0.5">v{activeAcceptance.document.version}</p>
</div>
{activeAcceptance.signatureHash && (
<div className="col-span-2">
<p className="font-bold text-ink-400 uppercase tracking-wider text-[9px]">Verification Hash (SHA-256)</p>
<p className="font-mono text-[9px] text-ink-500 bg-ink-100 p-1.5 rounded border border-ink-200 break-all mt-0.5">
{activeAcceptance.signatureHash}
</p>
</div>
)}
</div>
</div>
<div className="mt-8 pt-4 border-t border-ink-150 text-center not-italic font-sans text-xs text-ink-400 font-semibold">
--- Page 2 of 2 (Signature &amp; Seals) ---
</div>
</div>
)}
</div>
)
) : (
<div className="w-full max-w-md my-auto flex flex-col items-center justify-center text-center p-8 bg-ink-0 rounded-2xl border border-ink-200 shadow-lg">
<AlertTriangle className="w-12 h-12 text-ink-400 mb-3" />
<h4 className="text-base font-extrabold text-ink-900">Document Unavailable</h4>
<p className="text-xs text-ink-500 mt-1.5 max-w-xs leading-relaxed font-semibold">
The partner has not yet submitted or signed the {currentTab} document.
</p>
</div>
)}
</div>
</div>
{/* Right Pane: Document Details & Metadata */}
<div className="w-full md:w-80 border-t md:border-t-0 md:border-l border-ink-200 bg-ink-0 flex flex-col overflow-y-auto shrink-0 p-5 space-y-5">
<div>
<h4 className="text-xs font-bold uppercase tracking-wider text-ink-400 mb-3">
Onboarding Details
</h4>
<div className="space-y-3">
<div className="flex justify-between items-center text-xs">
<span className="font-semibold text-ink-500">Document Category</span>
<span className="font-bold text-ink-900 bg-ink-100 px-2 py-0.5 rounded border border-ink-200">{currentTab}</span>
</div>
<div className="flex justify-between items-center text-xs">
<span className="font-semibold text-ink-500">Submission State</span>
{activeAcceptance ? (
<span className="font-bold text-emerald-700 bg-emerald-500/10 px-2 py-0.5 rounded border border-emerald-500/20">Signed</span>
) : (
<span className="font-bold text-amber-700 bg-amber-500/10 px-2 py-0.5 rounded border border-amber-500/20">Pending</span>
)}
</div>
<div className="flex justify-between items-center text-xs">
<span className="font-semibold text-ink-500">Digital Signature</span>
{activeAcceptance ? (
<span className="font-bold text-emerald-700 bg-emerald-500/10 px-2 py-0.5 rounded border border-emerald-500/20">Verified</span>
) : (
<span className="font-bold text-red-650 bg-red-500/10 px-2 py-0.5 rounded border border-red-500/20">Not Verified</span>
)}
</div>
</div>
</div>
<div className="border-t border-ink-100 pt-4">
<h4 className="text-xs font-bold uppercase tracking-wider text-ink-400 mb-3">
Legal Verification
</h4>
{activeAcceptance ? (
<div className="space-y-3 text-xs">
<div>
<p className="font-semibold text-ink-400 text-[10px] uppercase">Signed By</p>
<p className="font-bold text-ink-900 truncate mt-0.5">{partnerEmail}</p>
</div>
<div>
<p className="font-semibold text-ink-400 text-[10px] uppercase">Signed On</p>
<p className="font-bold text-ink-900 mt-0.5">{new Date(activeAcceptance.acceptedAt).toLocaleString()}</p>
</div>
<div>
<p className="font-semibold text-ink-400 text-[10px] uppercase">IP Address</p>
<p className="font-bold text-ink-900 mt-0.5 font-mono">{activeAcceptance.ipAddress}</p>
</div>
<div>
<p className="font-semibold text-ink-400 text-[10px] uppercase">Version</p>
<p className="font-bold text-ink-900 mt-0.5">v{activeAcceptance.document.version}</p>
</div>
{activeAcceptance.signatureHash && (
<div>
<p className="font-semibold text-ink-400 text-[10px] uppercase">Verification Hash</p>
<p className="font-mono text-[10px] text-ink-650 bg-ink-50 p-1.5 rounded border border-ink-150 break-all mt-0.5">
{activeAcceptance.signatureHash}
</p>
</div>
)}
</div>
) : (
<p className="text-xs text-ink-400 italic">No verification metadata available.</p>
)}
</div>
<div className="mt-auto pt-4 border-t border-ink-100 space-y-2">
<Button
onClick={() => onVerify(currentTab)}
disabled={!activeAcceptance}
variant={isCurrentVerified ? 'secondary' : 'primary'}
className="w-full flex justify-center items-center gap-1.5"
>
{isCurrentVerified ? (
<>
<CheckCircle className="w-4 h-4 text-emerald-600" />
<span>Document Verified</span>
</>
) : (
<span>Verify {currentTab} Signature</span>
)}
</Button>
<p className="text-[10px] text-ink-400 text-center font-medium leading-normal">
Marking this verified unlocks approval options for the administrator.
</p>
</div>
</div>
</div>
{/* Sticky Footer */}
<div className="px-6 py-4 border-t border-ink-200 bg-ink-50 flex flex-col sm:flex-row justify-between items-center gap-4 shrink-0">
<div className="text-xs font-semibold text-ink-500">
{verifiedDocs.nda && verifiedDocs.msa ? (
<span className="text-emerald-700 font-bold flex items-center gap-1.5">
<CheckCircle className="w-4.5 h-4.5" /> Both agreements verified. Access approval unlocked.
</span>
) : (
<span className="flex items-center gap-1.5">
<AlertTriangle className="w-4 h-4 text-amber-500" /> Please review and verify both the NDA and MSA documents.
</span>
)}
</div>
<div className="flex items-center gap-2 w-full sm:w-auto justify-end">
<Button variant="secondary" onClick={onClose}>
Cancel
</Button>
<Button
onClick={onApprovePartner}
disabled={!isBothVerified || isApproving}
variant="primary"
className="font-bold shadow-md hover:shadow-lg transition-shadow"
>
{isApproving ? 'Approving Partner...' : 'Approve Partner Access'}
</Button>
</div>
</div>
</motion.div>
</div>,
document.body
);
};

View File

@ -0,0 +1,93 @@
import React from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { X } from 'lucide-react';
interface ModalProps {
isOpen: boolean;
onClose: () => void;
title: React.ReactNode;
subtitle?: React.ReactNode;
children: React.ReactNode;
footer?: React.ReactNode;
size?: 'sm' | 'md' | 'lg' | 'xl' | '2xl' | 'full';
className?: string;
}
export const Modal: React.FC<ModalProps> = ({
isOpen,
onClose,
title,
subtitle,
children,
footer,
size = 'md',
className = ''
}) => {
const sizeClasses = {
sm: 'max-w-md',
md: 'max-w-lg',
lg: 'max-w-xl',
xl: 'max-w-2xl',
'2xl': 'max-w-4xl',
full: 'max-w-[95vw]'
};
const sizeClass = sizeClasses[size] || sizeClasses.md;
return (
<AnimatePresence>
{isOpen && (
<div className="fixed inset-0 z-[100] flex justify-center items-start pt-[76px] sm:pt-[80px] px-4 pb-4 sm:pb-6 overflow-hidden">
{/* Backdrop overlay */}
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={onClose}
className="fixed inset-0 bg-ink-950/40 backdrop-blur-sm z-[100]"
/>
{/* Modal Container */}
<motion.div
initial={{ opacity: 0, scale: 0.95, y: 15 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 15 }}
transition={{ type: 'spring', damping: 25, stiffness: 350 }}
className={`relative w-full ${sizeClass} bg-ink-0 border border-ink-200 rounded-2xl shadow-xl flex flex-col overflow-hidden focus:outline-none z-[101] ${className}`}
style={{
maxHeight: '100%'
}}
>
{/* Header */}
<div className="flex justify-between items-center px-6 py-4 border-b border-ink-100 flex-shrink-0">
<div>
<h3 className="text-base font-bold text-ink-900 leading-snug font-sans">{title}</h3>
{subtitle && <p className="text-xs text-ink-500 mt-1 font-semibold font-sans">{subtitle}</p>}
</div>
<button
onClick={onClose}
className="p-1.5 rounded-lg text-ink-400 hover:text-ink-900 hover:bg-ink-50 transition-colors flex-shrink-0 ml-4 cursor-pointer"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Scrollable Content */}
<div className="flex-1 min-h-0 overflow-y-auto p-6 text-ink-900 scrollbar-thin">
{children}
</div>
{/* Footer */}
{footer && (
<div className="px-6 py-4 border-t border-ink-100 bg-ink-50 flex justify-end gap-3 flex-shrink-0">
{footer}
</div>
)}
</motion.div>
</div>
)}
</AnimatePresence>
);
};
export default Modal;

View File

@ -0,0 +1,122 @@
import React, { createContext, useContext, useState, useCallback } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { CheckCircle2, AlertCircle, AlertTriangle, Info, X } from 'lucide-react';
export interface Toast {
id: string;
type: 'success' | 'error' | 'warning' | 'info';
message: string;
description?: string;
duration?: number;
}
interface ToastContextType {
toast: (options: {
type: 'success' | 'error' | 'warning' | 'info';
message: string;
description?: string;
duration?: number;
}) => void;
success: (message: string, description?: string, duration?: number) => void;
error: (message: string, description?: string, duration?: number) => void;
warning: (message: string, description?: string, duration?: number) => void;
info: (message: string, description?: string, duration?: number) => void;
}
const ToastContext = createContext<ToastContextType | undefined>(undefined);
export const ToastProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const [toasts, setToasts] = useState<Toast[]>([]);
const removeToast = useCallback((id: string) => {
setToasts((prev) => prev.filter((t) => t.id !== id));
}, []);
const addToast = useCallback(
({
type,
message,
description,
duration = 4000,
}: Omit<Toast, 'id'>) => {
const id = Math.random().toString(36).substring(2, 9);
setToasts((prev) => [...prev, { id, type, message, description, duration }]);
setTimeout(() => removeToast(id), duration);
},
[removeToast]
);
const success = useCallback((message: string, description?: string, duration?: number) => {
addToast({ type: 'success', message, description, duration });
}, [addToast]);
const error = useCallback((message: string, description?: string, duration?: number) => {
addToast({ type: 'error', message, description, duration });
}, [addToast]);
const warning = useCallback((message: string, description?: string, duration?: number) => {
addToast({ type: 'warning', message, description, duration });
}, [addToast]);
const info = useCallback((message: string, description?: string, duration?: number) => {
addToast({ type: 'info', message, description, duration });
}, [addToast]);
const icons = {
success: <CheckCircle2 className="w-4 h-4 text-emerald-500 shrink-0 mt-0.5" />,
error: <AlertCircle className="w-4 h-4 text-red-500 shrink-0 mt-0.5" />,
warning: <AlertTriangle className="w-4 h-4 text-amber-500 shrink-0 mt-0.5" />,
info: <Info className="w-4 h-4 text-blue-500 shrink-0 mt-0.5" />,
};
const borders = {
success: 'border-l-4 border-l-emerald-500',
error: 'border-l-4 border-l-red-500',
warning: 'border-l-4 border-l-amber-500',
info: 'border-l-4 border-l-blue-500',
};
return (
<ToastContext.Provider value={{ toast: addToast, success, error, warning, info }}>
{children}
{/* Toast container viewport */}
<div className="fixed top-6 right-6 z-[200] flex flex-col gap-3 w-full max-w-sm pointer-events-none">
<AnimatePresence>
{toasts.map((t) => (
<motion.div
key={t.id}
initial={{ opacity: 0, y: -20, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, scale: 0.9, y: -10 }}
className={`pointer-events-auto flex items-start gap-3 p-4 bg-ink-0 border border-ink-200 rounded-xl shadow-premium ${borders[t.type]} overflow-hidden`}
>
{icons[t.type]}
<div className="flex-1 min-w-0">
<h4 className="text-xs font-bold text-ink-900 leading-snug">{t.message}</h4>
{t.description && (
<p className="text-[10px] text-ink-500 font-semibold leading-normal mt-1">{t.description}</p>
)}
</div>
<button
onClick={() => removeToast(t.id)}
className="p-1 rounded-lg text-ink-400 hover:text-ink-900 hover:bg-ink-50 transition-colors shrink-0 cursor-pointer"
>
<X className="w-3.5 h-3.5" />
</button>
</motion.div>
))}
</AnimatePresence>
</div>
</ToastContext.Provider>
);
};
export const useToast = () => {
const context = useContext(ToastContext);
if (!context) {
throw new Error('useToast must be used within a ToastProvider');
}
return context;
};
export default ToastProvider;

View File

@ -1,313 +0,0 @@
import React, { useState, useEffect } from "react";
import type { User, Asset } from "../../../types";
import { apiClient } from "../../../lib/api-client";
import {
Users,
FileStack,
DownloadCloud,
AlertTriangle,
Cpu,
Terminal,
Brain,
Server,
ShieldCheck,
} from "lucide-react";
export const AnalyticsDashboard: React.FC = () => {
const [clients, setClients] = useState<User[]>([]);
const [assets, setAssets] = useState<Asset[]>([]);
const [loading, setLoading] = useState(true);
const fetchStats = async () => {
setLoading(true);
try {
const clientRes = await apiClient.get<User[]>("/admin/clients");
const assetRes = await apiClient.get<Asset[]>("/assets");
setClients(clientRes.data);
setAssets(assetRes.data);
} catch (err) {
console.error(err);
} finally {
setLoading(false);
}
};
useEffect(() => {
fetchStats();
}, []);
// Compute analytics data
const totalClients = clients.length;
const totalAssets = assets.length;
const totalDownloads = assets.reduce((sum, a) => sum + a.downloadsCount, 0);
const pendingApprovals = clients.filter(
(c) => c.onboardingStatus === "PENDING_APPROVAL",
).length;
// Category counts
const categoryCounts = assets.reduce(
(acc, a) => {
acc[a.categoryId] = (acc[a.categoryId] || 0) + 1;
return acc;
},
{ silicon: 0, software: 0, ai: 0, cloud: 0 } as Record<string, number>,
);
const categoryMax = Math.max(...Object.values(categoryCounts), 1);
// Onboarding Status distributions
const onboardingCounts = clients.reduce(
(acc, c) => {
acc[c.onboardingStatus] = (acc[c.onboardingStatus] || 0) + 1;
return acc;
},
{
NOT_STARTED: 0,
FORM_COMPLETED: 0,
NDA_SIGNED: 0,
PENDING_APPROVAL: 0,
APPROVED: 0,
REJECTED: 0,
} as Record<string, number>,
);
const onboardingMax = Math.max(...Object.values(onboardingCounts), 1);
return (
<div className="space-y-6">
<div>
<h2 className="text-xl font-bold text-ink-800">
Operational Analytics
</h2>
<p className="text-sm text-ink-600">
Overview of client onboarding metrics, asset repository distribution,
and core library downloads.
</p>
</div>
{loading ? (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
{[1, 2, 3, 4].map((n) => (
<div
key={n}
className="h-28 animate-pulse rounded-xl bg-white border border-ink-100"
/>
))}
</div>
) : (
/* Metric Cards */
<div className="grid gap-5 sm:grid-cols-2 lg:grid-cols-4">
<div className="rounded-xl border border-ink-100 bg-white p-5 shadow-sm flex items-center gap-4 hover:shadow-premium transition-all duration-300">
<div className="rounded-lg bg-primary-50 p-3 text-primary-700">
<Users className="h-6 w-6" />
</div>
<div>
<p className="text-[10px] font-bold uppercase tracking-wider text-ink-600">
Total Clients
</p>
<p className="text-2xl font-bold text-ink-800">{totalClients}</p>
</div>
</div>
<div className="rounded-xl border border-ink-100 bg-white p-5 shadow-sm flex items-center gap-4 hover:shadow-premium transition-all duration-300">
<div className="rounded-lg bg-primary-50 p-3 text-primary-700">
<FileStack className="h-6 w-6" />
</div>
<div>
<p className="text-[10px] font-bold uppercase tracking-wider text-ink-600">
Active Assets
</p>
<p className="text-2xl font-bold text-ink-800">{totalAssets}</p>
</div>
</div>
<div className="rounded-xl border border-ink-100 bg-white p-5 shadow-sm flex items-center gap-4 hover:shadow-premium transition-all duration-300">
<div className="rounded-lg bg-primary-50 p-3 text-primary-700">
<DownloadCloud className="h-6 w-6" />
</div>
<div>
<p className="text-[10px] font-bold uppercase tracking-wider text-ink-600">
Total Downloads
</p>
<p className="text-2xl font-bold text-ink-800">
{totalDownloads}
</p>
</div>
</div>
<div className="rounded-xl border border-ink-100 bg-white p-5 shadow-sm flex items-center gap-4 hover:shadow-premium transition-all duration-300">
<div
className={`rounded-lg p-3 ${pendingApprovals > 0 ? "bg-warning/10 text-warning" : "bg-success/10 text-success"}`}
>
<AlertTriangle className="h-6 w-6" />
</div>
<div>
<p className="text-[10px] font-bold uppercase tracking-wider text-ink-600">
Pending Review
</p>
<p className="text-2xl font-bold text-ink-800">
{pendingApprovals}
</p>
</div>
</div>
</div>
)}
{/* Charts Grid */}
<div className="grid gap-6 md:grid-cols-2">
{/* Category Breakdown */}
<div className="rounded-xl border border-ink-100 bg-white p-5 shadow-sm space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-sm font-bold text-ink-800">
Assets by Domain Category
</h3>
<span className="text-[10px] uppercase font-bold tracking-wider text-ink-600">
Count
</span>
</div>
{loading ? (
<div className="space-y-4 py-6">
{[1, 2, 3].map((n) => (
<div key={n} className="h-8 animate-pulse rounded bg-ink-50" />
))}
</div>
) : (
<div className="space-y-4 py-2">
{[
{ key: "silicon", name: "Silicon Core IP", icon: Cpu },
{
key: "software",
name: "Software & Libraries",
icon: Terminal,
},
{ key: "ai", name: "AI & Agent Workflows", icon: Brain },
{ key: "cloud", name: "Cloud Configs & IaC", icon: Server },
].map((item) => {
const count = categoryCounts[item.key] || 0;
const percentage = (count / categoryMax) * 100;
const Icon = item.icon;
return (
<div key={item.key} className="space-y-1">
<div className="flex items-center justify-between text-xs">
<span className="flex items-center gap-1.5 font-semibold text-ink-700">
<Icon className="h-4 w-4 text-primary-700" />
{item.name}
</span>
<span className="font-mono font-bold text-ink-800">
{count}
</span>
</div>
<div className="h-3.5 w-full rounded-full bg-ink-50 overflow-hidden border border-ink-100/50">
<div
className="h-full rounded-full bg-gradient-to-r from-primary-200 to-primary-400 transition-all duration-500"
style={{ width: `${percentage}%` }}
/>
</div>
</div>
);
})}
</div>
)}
</div>
{/* Client Onboarding Funnel */}
<div className="rounded-xl border border-ink-100 bg-white p-5 shadow-sm space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-sm font-bold text-ink-800">
Client Onboarding Funnel
</h3>
<span className="text-[10px] uppercase font-bold tracking-wider text-ink-600">
Accounts
</span>
</div>
{loading ? (
<div className="space-y-4 py-6">
{[1, 2, 3].map((n) => (
<div key={n} className="h-8 animate-pulse rounded bg-ink-50" />
))}
</div>
) : (
<div className="space-y-4 py-2">
{[
{ key: "NOT_STARTED", name: "Registration Initiated" },
{ key: "FORM_COMPLETED", name: "Profile Form Saved" },
{ key: "NDA_SIGNED", name: "NDA Document Signed" },
{ key: "PENDING_APPROVAL", name: "Awaiting Admin Approval" },
{ key: "APPROVED", name: "Approved & Active Portal" },
].map((item) => {
const count = onboardingCounts[item.key] || 0;
const percentage = (count / onboardingMax) * 100;
return (
<div key={item.key} className="space-y-1">
<div className="flex items-center justify-between text-xs">
<span className="font-semibold text-ink-700">
{item.name}
</span>
<span className="font-mono font-bold text-ink-800">
{count}
</span>
</div>
<div className="h-3.5 w-full rounded-full bg-ink-50 overflow-hidden border border-ink-100/50">
<div
className={`h-full rounded-full transition-all duration-500 ${
item.key === "APPROVED"
? "bg-success/70"
: item.key === "PENDING_APPROVAL"
? "bg-warning/70"
: "bg-primary-300"
}`}
style={{ width: `${percentage}%` }}
/>
</div>
</div>
);
})}
</div>
)}
</div>
</div>
{/* Bottom list showing Top Downloaded Assets */}
<div className="rounded-xl border border-ink-100 bg-white p-5 shadow-sm space-y-4">
<h3 className="text-sm font-bold text-ink-800">
Most Downloaded Catalog Resources
</h3>
<div className="divide-y divide-ink-50">
{assets
.slice()
.sort((a, b) => b.downloadsCount - a.downloadsCount)
.slice(0, 3)
.map((asset, index) => (
<div
key={asset.id}
className="flex items-center justify-between py-2.5 first:pt-0 last:pb-0"
>
<div className="flex items-center gap-3">
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-primary-50 text-xs font-bold text-primary-800 border border-primary-100">
{index + 1}
</span>
<div>
<p className="text-xs font-bold text-ink-800">
{asset.title}
</p>
<p className="text-[10px] text-ink-600 font-semibold">
{asset.subcategory}
</p>
</div>
</div>
<div className="flex items-center gap-1.5 text-xs text-ink-700 font-mono">
<ShieldCheck className="h-4 w-4 text-success" />
<span className="font-bold">{asset.downloadsCount}</span>{" "}
downloads
</div>
</div>
))}
</div>
</div>
</div>
);
};

View File

@ -1,7 +1,7 @@
import React from 'react'; import React from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { X } from 'lucide-react';
import type { Asset } from '../../../types/assets'; import type { Asset } from '../../../types/assets';
import Modal from '../../../components/ui/Modal';
import Button from '../../../components/ui/Button';
interface AssetDetailsModalProps { interface AssetDetailsModalProps {
isOpen: boolean; isOpen: boolean;
@ -26,73 +26,63 @@ export const AssetDetailsModal: React.FC<AssetDetailsModalProps> = ({
}; };
return ( return (
<AnimatePresence> <Modal
{isOpen && asset && ( isOpen={isOpen && !!asset}
<div className="fixed inset-0 z-50 flex items-center justify-center p-4"> onClose={onClose}
<motion.div title="Asset Details"
initial={{ opacity: 0 }} size="md"
animate={{ opacity: 1 }} footer={
exit={{ opacity: 0 }} <Button
onClick={onClose} onClick={onClose}
className="absolute inset-0 bg-ink-950/40 backdrop-blur-sm" variant="primary"
/> size="sm"
<motion.div
initial={{ opacity: 0, scale: 0.95, y: 10 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 10 }}
className="relative bg-ink-0 border border-ink-200 rounded-2xl p-6 max-w-md w-full shadow-xl z-10 space-y-5 text-ink-900"
> >
<div className="flex justify-between items-center pb-3 border-b border-ink-100"> Close
<h3 className="text-base font-bold text-ink-900">Asset Details</h3> </Button>
<button }
onClick={onClose}
className="p-1 rounded-lg text-ink-400 hover:text-ink-900 hover:bg-ink-50 transition-colors"
> >
<X className="w-5 h-5" /> {asset && (
</button> <div className="space-y-4 text-xs font-medium text-ink-900">
</div>
<div className="space-y-4 text-xs font-medium">
<div> <div>
<h4 className="text-[10px] font-bold uppercase tracking-wider text-ink-500">Title</h4> <h4 className="text-[10px] font-bold uppercase tracking-wider text-ink-500 font-sans">Title</h4>
<p className="text-sm font-extrabold text-ink-900 mt-1">{asset.title}</p> <p className="text-sm font-extrabold text-ink-900 mt-1 font-sans">{asset.title}</p>
</div> </div>
{asset.description && ( {asset.description && (
<div> <div>
<h4 className="text-[10px] font-bold uppercase tracking-wider text-ink-500">Description</h4> <h4 className="text-[10px] font-bold uppercase tracking-wider text-ink-500 font-sans">Description</h4>
<p className="text-ink-700 mt-1 leading-relaxed">{asset.description}</p> <p className="text-ink-700 mt-1 leading-relaxed font-sans">{asset.description}</p>
</div> </div>
)} )}
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div> <div>
<h4 className="text-[10px] font-bold uppercase tracking-wider text-ink-500">Category</h4> <h4 className="text-[10px] font-bold uppercase tracking-wider text-ink-500 font-sans">Category</h4>
<p className="text-ink-900 mt-1 font-bold">{asset.categoryId || 'General'}</p> <p className="text-ink-900 mt-1 font-bold font-sans">{asset.categoryId || 'General'}</p>
</div> </div>
<div> <div>
<h4 className="text-[10px] font-bold uppercase tracking-wider text-ink-500">Subcategory</h4> <h4 className="text-[10px] font-bold uppercase tracking-wider text-ink-500 font-sans">Subcategory</h4>
<p className="text-ink-900 mt-1 font-bold">{asset.subcategory || '-'}</p> <p className="text-ink-900 mt-1 font-bold font-sans">{asset.subcategory || '-'}</p>
</div> </div>
</div> </div>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div> <div>
<h4 className="text-[10px] font-bold uppercase tracking-wider text-ink-500">File Size</h4> <h4 className="text-[10px] font-bold uppercase tracking-wider text-ink-500 font-sans">File Size</h4>
<p className="text-ink-900 mt-1 font-bold">{asset.type === 'url' ? 'N/A' : formatBytes(asset.size)}</p> <p className="text-ink-900 mt-1 font-bold font-sans">{asset.type === 'url' ? 'N/A' : formatBytes(asset.size)}</p>
</div> </div>
<div> <div>
<h4 className="text-[10px] font-bold uppercase tracking-wider text-ink-500">File Type</h4> <h4 className="text-[10px] font-bold uppercase tracking-wider text-ink-500 font-sans">File Type</h4>
<p className="text-ink-900 mt-1 font-bold">{asset.type}</p> <p className="text-ink-900 mt-1 font-bold font-sans">{asset.type}</p>
</div> </div>
</div> </div>
{asset.tags.length > 0 && ( {asset.tags.length > 0 && (
<div> <div>
<h4 className="text-[10px] font-bold uppercase tracking-wider text-ink-500">Tags</h4> <h4 className="text-[10px] font-bold uppercase tracking-wider text-ink-500 font-sans">Tags</h4>
<div className="flex flex-wrap gap-1.5 mt-1.5"> <div className="flex flex-wrap gap-1.5 mt-1.5">
{asset.tags.map(tag => ( {asset.tags.map(tag => (
<span key={tag} className="px-2 py-0.5 rounded bg-ink-50 border border-ink-200 text-[10px] font-bold text-ink-600"> <span key={tag} className="px-2 py-0.5 rounded bg-ink-50 border border-ink-200 text-[10px] font-bold text-ink-600 font-sans">
{tag} {tag}
</span> </span>
))} ))}
@ -102,13 +92,13 @@ export const AssetDetailsModal: React.FC<AssetDetailsModalProps> = ({
{userRole === 'ADMIN' && asset.sharedWith && ( {userRole === 'ADMIN' && asset.sharedWith && (
<div> <div>
<h4 className="text-[10px] font-bold uppercase tracking-wider text-ink-500">Shared With</h4> <h4 className="text-[10px] font-bold uppercase tracking-wider text-ink-500 font-sans">Shared With</h4>
<div className="flex flex-wrap gap-1.5 mt-1.5"> <div className="flex flex-wrap gap-1.5 mt-1.5">
{asset.sharedWith.length === 0 ? ( {asset.sharedWith.length === 0 ? (
<span className="text-ink-500 font-semibold italic">Not shared with any organization</span> <span className="text-ink-500 font-semibold italic font-sans">Not shared with any organization</span>
) : ( ) : (
asset.sharedWith.map(sw => ( asset.sharedWith.map(sw => (
<span key={sw.userId ? `${sw.organizationId}-${sw.userId}` : sw.organizationId} className="px-2 py-0.5 rounded bg-ink-900 text-ink-0 text-[10px] font-bold"> <span key={sw.userId ? `${sw.organizationId}-${sw.userId}` : sw.organizationId} className="px-2 py-0.5 rounded bg-ink-900 text-ink-0 text-[10px] font-bold font-sans">
{sw.organization?.name || 'Unknown Organization'} {sw.user ? `(${sw.user.email})` : '(Entire Org)'} {sw.organization?.name || 'Unknown Organization'} {sw.user ? `(${sw.user.email})` : '(Entire Org)'}
</span> </span>
)) ))
@ -117,18 +107,7 @@ export const AssetDetailsModal: React.FC<AssetDetailsModalProps> = ({
</div> </div>
)} )}
</div> </div>
<div className="pt-3.5 border-t border-ink-100 flex justify-end">
<button
onClick={onClose}
className="px-5 py-2 rounded-lg bg-ink-900 text-ink-0 text-xs font-bold hover:bg-ink-800 transition-colors shadow-sm"
>
Close
</button>
</div>
</motion.div>
</div>
)} )}
</AnimatePresence> </Modal>
); );
}; };

View File

@ -2,6 +2,7 @@ import React, { useState, useEffect } from 'react';
import type { Asset, Category } from '../../../types'; import type { Asset, Category } from '../../../types';
import { apiClient } from '../../../lib/api-client'; import { apiClient } from '../../../lib/api-client';
import { Cpu, Terminal, Brain, Server, Search, Download, Calendar, User, Tag, HelpCircle, X } from 'lucide-react'; import { Cpu, Terminal, Brain, Server, Search, Download, Calendar, User, Tag, HelpCircle, X } from 'lucide-react';
import { useToast } from '../../../hooks/use-toast';
const GithubIcon: React.FC<{ className?: string }> = ({ className }) => ( const GithubIcon: React.FC<{ className?: string }> = ({ className }) => (
<svg <svg
@ -19,6 +20,7 @@ const GithubIcon: React.FC<{ className?: string }> = ({ className }) => (
); );
export const AssetExplorer: React.FC = () => { export const AssetExplorer: React.FC = () => {
const { success, error } = useToast();
const [assets, setAssets] = useState<Asset[]>([]); const [assets, setAssets] = useState<Asset[]>([]);
const [categories, setCategories] = useState<Category[]>([]); const [categories, setCategories] = useState<Category[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@ -31,9 +33,6 @@ export const AssetExplorer: React.FC = () => {
// Selected asset details modal state // Selected asset details modal state
const [selectedAsset, setSelectedAsset] = useState<Asset | null>(null); const [selectedAsset, setSelectedAsset] = useState<Asset | null>(null);
// Toast notifications simulation
const [toastMessage, setToastMessage] = useState<string | null>(null);
const fetchAssets = async () => { const fetchAssets = async () => {
setLoading(true); setLoading(true);
try { try {
@ -70,13 +69,8 @@ export const AssetExplorer: React.FC = () => {
fetchCategories(); fetchCategories();
}, []); }, []);
const triggerToast = (msg: string) => {
setToastMessage(msg);
setTimeout(() => setToastMessage(null), 3000);
};
const handleDownload = async (asset: Asset) => { const handleDownload = async (asset: Asset) => {
triggerToast(`Starting download: ${asset.title}`); success('Starting download', `Downloading ${asset.title}...`);
try { try {
const updatedAsset = { ...asset, downloadsCount: asset.downloadsCount + 1 }; const updatedAsset = { ...asset, downloadsCount: asset.downloadsCount + 1 };
await apiClient.put(`/assets/${asset.id}`, updatedAsset); await apiClient.put(`/assets/${asset.id}`, updatedAsset);
@ -87,6 +81,7 @@ export const AssetExplorer: React.FC = () => {
} }
} catch (err) { } catch (err) {
console.error(err); console.error(err);
error('Download tracking failed', 'Unable to record download statistics.');
} }
}; };
@ -104,15 +99,6 @@ export const AssetExplorer: React.FC = () => {
return ( return (
<div className="space-y-6 text-ink-900"> <div className="space-y-6 text-ink-900">
{/* Toast Notification */}
{toastMessage && (
<div className="fixed bottom-5 right-5 z-50 rounded-xl bg-ink-900 text-ink-0 px-5 py-3 text-sm shadow-premium flex items-center gap-2 border border-ink-700 animate-slide-up">
<Download className="h-4 w-4 text-ink-0 animate-bounce" />
<span className="font-semibold text-ink-50">{toastMessage}</span>
</div>
)}
{/* Hero section */}
<div className="rounded-2xl bg-ink-0 p-6 md:p-8 border border-ink-200 flex flex-col md:flex-row items-center justify-between gap-6"> <div className="rounded-2xl bg-ink-0 p-6 md:p-8 border border-ink-200 flex flex-col md:flex-row items-center justify-between gap-6">
<div className="space-y-2"> <div className="space-y-2">
<h1 className="text-2xl font-bold text-ink-900 md:text-3xl">Tech4Biz Asset Explorer</h1> <h1 className="text-2xl font-bold text-ink-900 md:text-3xl">Tech4Biz Asset Explorer</h1>

View File

@ -1,9 +1,10 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion'; import { Minimize2, Maximize2, Globe, ExternalLink, FileText, File, Download } from 'lucide-react';
import { X, Minimize2, Maximize2, Globe, ExternalLink, FileText, File, Download } from 'lucide-react';
import { axiosInstance } from '../../../services/axios'; import { axiosInstance } from '../../../services/axios';
import type { Asset } from '../../../types/assets'; import type { Asset } from '../../../types/assets';
import type { User } from '../../../types/auth'; import type { User } from '../../../types/auth';
import Modal from '../../../components/ui/Modal';
import Button from '../../../components/ui/Button';
interface AssetViewerModalProps { interface AssetViewerModalProps {
isOpen: boolean; isOpen: boolean;
@ -133,67 +134,72 @@ export const AssetViewerModal: React.FC<AssetViewerModalProps> = ({
}; };
return ( return (
<AnimatePresence> <Modal
{isOpen && asset && ( isOpen={isOpen && !!asset}
<div className="fixed inset-0 z-50 flex items-center justify-center p-4"> onClose={handleClose}
<motion.div title={
initial={{ opacity: 0 }} <div className="flex items-center justify-between w-full">
animate={{ opacity: 1 }} <div className="text-left">
exit={{ opacity: 0 }} <span className="text-base font-bold text-ink-900 block font-sans">{asset?.title}</span>
onClick={handleClose} <span className="text-xs text-ink-500 font-sans block mt-0.5 font-normal">
className="absolute inset-0 bg-ink-950/60 backdrop-blur-md" {asset?.type === 'url' ? 'External Web Link' : `${asset?.type}${asset ? formatBytes(asset.size) : ''}`}
/> </span>
<motion.div
initial={{ opacity: 0, scale: 0.98, y: 15 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.98, y: 15 }}
className={`relative bg-ink-0 border border-ink-250 rounded-2xl p-6 shadow-xl z-10 flex flex-col overflow-hidden gap-4 transition-all duration-300 ${
isMaximized ? 'w-[96vw] h-[92vh] max-h-[92vh] max-w-none' : 'max-w-5xl w-full max-h-[85vh]'
}`}
>
<div className="flex justify-between items-center pb-3 border-b border-ink-100 flex-shrink-0">
<div>
<h3 className="text-base font-bold text-ink-900">{asset.title}</h3>
<p className="text-xs text-ink-500">
{asset.type === 'url' ? 'External Web Link' : `${asset.type}${formatBytes(asset.size)}`}
</p>
</div> </div>
<div className="flex items-center gap-1.5">
<button <button
onClick={() => setIsMaximized(!isMaximized)} onClick={() => setIsMaximized(!isMaximized)}
className="p-1.5 rounded-lg text-ink-400 hover:text-ink-900 hover:bg-ink-50 transition-colors" className="p-1.5 rounded-lg text-ink-400 hover:text-ink-900 hover:bg-ink-50 transition-colors ml-4 cursor-pointer inline-flex items-center"
title={isMaximized ? "Collapse view" : "Expand view"} title={isMaximized ? "Collapse view" : "Expand view"}
> >
{isMaximized ? <Minimize2 className="w-5 h-5" /> : <Maximize2 className="w-5 h-5" />} {isMaximized ? <Minimize2 className="w-4 h-4" /> : <Maximize2 className="w-4 h-4" />}
</button> </button>
<button </div>
}
size="full"
className={isMaximized ? '!max-w-[96vw] !max-h-[92vh] !h-[92vh] !mt-4' : '!max-w-5xl !w-full'}
footer={
<>
<Button
onClick={handleClose} onClick={handleClose}
className="p-1 rounded-lg text-ink-400 hover:text-ink-900 hover:bg-ink-50 transition-colors" variant="ghost"
size="sm"
> >
<X className="w-5 h-5" /> Close Preview
</button> </Button>
</div>
</div>
<div className="flex-1 w-full bg-ink-50 rounded-xl flex flex-col items-center justify-center overflow-hidden border border-ink-200 min-h-0"> {asset && asset.type !== 'url' && (user?.role === 'ADMIN' || asset.isDownloadable || asset.downloadRequests?.[0]?.status === 'APPROVED') && (
<Button
onClick={() => onDownload(asset)}
variant="primary"
size="sm"
className="flex items-center gap-2"
>
<Download className="w-4 h-4" />
<span>Download File</span>
</Button>
)}
</>
}
>
{asset && (
<div className="flex-1 w-full bg-ink-50 rounded-xl flex flex-col items-center justify-center overflow-hidden border border-ink-200 min-h-0 h-full">
{asset.type === 'url' && asset.url.includes('github.com') ? ( {asset.type === 'url' && asset.url.includes('github.com') ? (
<div className="w-full h-full flex flex-col min-h-0 bg-ink-0"> <div className="w-full h-full flex flex-col min-h-0 bg-ink-0">
<div className="bg-ink-100 px-4 py-2 border-b border-ink-200 flex justify-between items-center text-xs text-ink-600 font-bold select-none"> <div className="bg-ink-100 px-4 py-2 border-b border-ink-200 flex justify-between items-center text-xs text-ink-600 font-bold select-none">
<span className="flex items-center gap-1.5"> <span className="flex items-center gap-1.5">
<Globe className="w-4 h-4 text-ink-950" /> <Globe className="w-4 h-4 text-ink-950" />
<span>Embedded GitHub Document</span> <span className="font-sans">Embedded GitHub Document</span>
</span> </span>
</div> </div>
<div className="flex-1 overflow-auto p-6 text-left select-text bg-ink-0 text-ink-800 font-sans leading-relaxed"> <div className="flex-1 overflow-auto p-6 text-left select-text bg-ink-0 text-ink-800 font-sans leading-relaxed">
{isLoadingText ? ( {isLoadingText ? (
<div className="flex flex-col items-center justify-center h-full space-y-3"> <div className="flex flex-col items-center justify-center h-full space-y-3">
<div className="w-6 h-6 rounded-full border-4 border-ink-900 border-t-transparent animate-spin" /> <div className="w-6 h-6 rounded-full border-4 border-ink-900 border-t-transparent animate-spin" />
<span className="text-xs text-ink-500 font-medium">Fetching README.md...</span> <span className="text-xs text-ink-500 font-medium font-sans">Fetching README.md...</span>
</div> </div>
) : ( ) : (
<div className="max-w-3xl mx-auto space-y-4"> <div className="max-w-3xl mx-auto space-y-4">
<div className="border-b border-ink-200 pb-4 mb-6"> <div className="border-b border-ink-200 pb-4 mb-6">
<h1 className="text-xl font-extrabold text-ink-950">{asset.title}</h1> <h1 className="text-xl font-extrabold text-ink-950 font-sans">{asset.title}</h1>
<p className="text-xs text-ink-500 mt-1 font-mono">{asset.url}</p> <p className="text-xs text-ink-500 mt-1 font-mono">{asset.url}</p>
</div> </div>
<pre className="whitespace-pre-wrap font-sans text-sm text-ink-800 break-words leading-relaxed"> <pre className="whitespace-pre-wrap font-sans text-sm text-ink-800 break-words leading-relaxed">
@ -209,8 +215,8 @@ export const AssetViewerModal: React.FC<AssetViewerModalProps> = ({
<Globe className="w-8 h-8" /> <Globe className="w-8 h-8" />
</div> </div>
<div> <div>
<h4 className="text-sm font-bold text-ink-900">External Resource Portal</h4> <h4 className="text-sm font-bold text-ink-900 font-sans">External Resource Portal</h4>
<p className="text-xs text-ink-500 mt-2 leading-relaxed"> <p className="text-xs text-ink-500 mt-2 leading-relaxed font-sans">
This asset points to an external destination outside of the local CDN container. This asset points to an external destination outside of the local CDN container.
</p> </p>
<div className="bg-ink-100 border border-ink-200 rounded-xl px-4 py-2 text-xs font-mono text-ink-600 truncate mt-3 max-w-sm"> <div className="bg-ink-100 border border-ink-200 rounded-xl px-4 py-2 text-xs font-mono text-ink-600 truncate mt-3 max-w-sm">
@ -223,13 +229,13 @@ export const AssetViewerModal: React.FC<AssetViewerModalProps> = ({
href={asset.url} href={asset.url}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="inline-flex items-center justify-center gap-2 px-6 py-2.5 rounded-lg bg-ink-900 text-ink-0 text-xs font-bold hover:bg-ink-800 transition-all shadow-sm w-full" className="inline-flex items-center justify-center gap-2 px-6 py-2.5 rounded-lg bg-ink-900 text-ink-0 text-xs font-bold hover:bg-ink-800 transition-all shadow-sm w-full font-sans"
> >
<span>Open Link in New Tab</span> <span>Open Link in New Tab</span>
<ExternalLink className="w-4 h-4" /> <ExternalLink className="w-4 h-4" />
</a> </a>
) : ( ) : (
<div className="bg-amber-50 border border-amber-205 text-amber-900 rounded-xl p-4 text-xs text-center font-medium max-w-sm"> <div className="bg-amber-50 border border-amber-205 text-amber-900 rounded-xl p-4 text-xs text-center font-medium max-w-sm font-sans">
Access Restricted: You must request and receive download approval from the Administrator to open this resource link. Access Restricted: You must request and receive download approval from the Administrator to open this resource link.
</div> </div>
)} )}
@ -238,7 +244,7 @@ export const AssetViewerModal: React.FC<AssetViewerModalProps> = ({
<div className="w-full h-full flex flex-col min-h-0"> <div className="w-full h-full flex flex-col min-h-0">
<div className="bg-ink-100 px-4 py-2 border-b border-ink-200 flex items-center text-xs text-ink-600 font-bold select-none"> <div className="bg-ink-100 px-4 py-2 border-b border-ink-200 flex items-center text-xs text-ink-600 font-bold select-none">
<FileText className="w-4 h-4 text-ink-950 mr-1.5" /> <FileText className="w-4 h-4 text-ink-950 mr-1.5" />
<span>Interactive PDF Preview</span> <span className="font-sans">Interactive PDF Preview</span>
</div> </div>
<iframe <iframe
src={getFullAssetUrl(asset.url)} src={getFullAssetUrl(asset.url)}
@ -250,19 +256,19 @@ export const AssetViewerModal: React.FC<AssetViewerModalProps> = ({
<div className="w-full h-full flex flex-col min-h-0 bg-ink-0"> <div className="w-full h-full flex flex-col min-h-0 bg-ink-0">
<div className="bg-ink-100 px-4 py-2 border-b border-ink-200 flex items-center text-xs text-ink-600 font-bold select-none"> <div className="bg-ink-100 px-4 py-2 border-b border-ink-200 flex items-center text-xs text-ink-600 font-bold select-none">
<FileText className="w-4 h-4 text-ink-950 mr-1.5" /> <FileText className="w-4 h-4 text-ink-950 mr-1.5" />
<span>Document Reader</span> <span className="font-sans">Document Reader</span>
</div> </div>
<div className="flex-1 overflow-auto p-6 text-left select-text bg-ink-0 text-ink-800 font-sans leading-relaxed"> <div className="flex-1 overflow-auto p-6 text-left select-text bg-ink-0 text-ink-800 font-sans leading-relaxed">
{isLoadingText ? ( {isLoadingText ? (
<div className="flex flex-col items-center justify-center h-full space-y-3"> <div className="flex flex-col items-center justify-center h-full space-y-3">
<div className="w-6 h-6 rounded-full border-4 border-ink-900 border-t-transparent animate-spin" /> <div className="w-6 h-6 rounded-full border-4 border-ink-900 border-t-transparent animate-spin" />
<span className="text-xs text-ink-500 font-medium">Loading content...</span> <span className="text-xs text-ink-500 font-medium font-sans">Loading content...</span>
</div> </div>
) : ( ) : (
<div className="max-w-3xl mx-auto space-y-4"> <div className="max-w-3xl mx-auto space-y-4">
<div className="border-b border-ink-200 pb-4 mb-6"> <div className="border-b border-ink-200 pb-4 mb-6">
<h1 className="text-xl font-extrabold text-ink-950">{asset.title}</h1> <h1 className="text-xl font-extrabold text-ink-950 font-sans">{asset.title}</h1>
<p className="text-xs text-ink-500 mt-1">Plain Text / Markdown Format</p> <p className="text-xs text-ink-500 mt-1 font-sans">Plain Text / Markdown Format</p>
</div> </div>
<pre className="whitespace-pre-wrap font-sans text-sm text-ink-800 break-words leading-relaxed"> <pre className="whitespace-pre-wrap font-sans text-sm text-ink-800 break-words leading-relaxed">
{textPreviewContent} {textPreviewContent}
@ -275,7 +281,7 @@ export const AssetViewerModal: React.FC<AssetViewerModalProps> = ({
<div className="w-full h-full flex flex-col min-h-0 bg-ink-0"> <div className="w-full h-full flex flex-col min-h-0 bg-ink-0">
<div className="bg-ink-100 px-4 py-2 border-b border-ink-200 flex items-center text-xs text-ink-600 font-bold select-none"> <div className="bg-ink-100 px-4 py-2 border-b border-ink-200 flex items-center text-xs text-ink-600 font-bold select-none">
<FileText className="w-4 h-4 text-ink-950 mr-1.5" /> <FileText className="w-4 h-4 text-ink-950 mr-1.5" />
<span>Office Document Preview</span> <span className="font-sans">Office Document Preview</span>
</div> </div>
{isLocalUrl(asset.url) ? ( {isLocalUrl(asset.url) ? (
<div className="flex-1 p-8 text-center flex flex-col justify-center items-center max-w-lg mx-auto space-y-4 bg-ink-0"> <div className="flex-1 p-8 text-center flex flex-col justify-center items-center max-w-lg mx-auto space-y-4 bg-ink-0">
@ -283,11 +289,11 @@ export const AssetViewerModal: React.FC<AssetViewerModalProps> = ({
<FileText className="w-8 h-8" /> <FileText className="w-8 h-8" />
</div> </div>
<div> <div>
<h4 className="text-sm font-bold text-ink-900">Office Document Preview</h4> <h4 className="text-sm font-bold text-ink-900 font-sans">Office Document Preview</h4>
<p className="text-xs text-ink-500 mt-2 leading-relaxed"> <p className="text-xs text-ink-500 mt-2 leading-relaxed font-sans">
This asset is a Microsoft Office document ({asset.type.split('/').pop()?.toUpperCase() || 'DOCX'}). This asset is a Microsoft Office document ({asset.type.split('/').pop()?.toUpperCase() || 'DOCX'}).
</p> </p>
<p className="text-xs text-ink-500 mt-2 leading-relaxed bg-ink-50 border border-ink-100 rounded-xl p-3 text-left"> <p className="text-xs text-ink-500 mt-2 leading-relaxed bg-ink-50 border border-ink-100 rounded-xl p-3 text-left font-sans">
<strong>Note:</strong> Microsoft Office Online Viewer is optimized for staging/production environments. In development (localhost), external services cannot fetch local files. Please download this asset using the button below to view it locally. <strong>Note:</strong> Microsoft Office Online Viewer is optimized for staging/production environments. In development (localhost), external services cannot fetch local files. Please download this asset using the button below to view it locally.
</p> </p>
</div> </div>
@ -311,35 +317,14 @@ export const AssetViewerModal: React.FC<AssetViewerModalProps> = ({
) : ( ) : (
<div className="text-center p-8 flex flex-col justify-center items-center"> <div className="text-center p-8 flex flex-col justify-center items-center">
<File className="w-12 h-12 text-ink-300 mb-3" /> <File className="w-12 h-12 text-ink-300 mb-3" />
<h4 className="text-sm font-bold text-ink-900">Direct Preview Unsupported</h4> <h4 className="text-sm font-bold text-ink-900 font-sans">Direct Preview Unsupported</h4>
<p className="text-xs text-ink-500 mt-1 max-w-sm"> <p className="text-xs text-ink-500 mt-1 max-w-sm font-sans">
This file format cannot be rendered directly in the browser. Please download the file to inspect its contents. This file format cannot be rendered directly in the browser. Please download the file to inspect its contents.
</p> </p>
</div> </div>
)} )}
</div> </div>
<div className="pt-3 border-t border-ink-100 flex justify-end gap-3 flex-shrink-0">
<button
onClick={handleClose}
className="px-4 py-2 rounded-lg border border-ink-200 text-ink-700 text-xs font-bold hover:bg-ink-50 transition-colors"
>
Close Preview
</button>
{asset.type !== 'url' && (user?.role === 'ADMIN' || asset.isDownloadable || asset.downloadRequests?.[0]?.status === 'APPROVED') && (
<button
onClick={() => onDownload(asset)}
className="px-5 py-2 rounded-lg bg-ink-900 text-ink-0 text-xs font-bold hover:bg-ink-800 transition-colors shadow-sm flex items-center gap-2"
>
<Download className="w-4 h-4" />
<span>Download File</span>
</button>
)} )}
</div> </Modal>
</motion.div>
</div>
)}
</AnimatePresence>
); );
}; };

View File

@ -1,7 +1,8 @@
import React from 'react'; import React from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { X, Check } from 'lucide-react';
import type { Asset } from '../../../types/assets'; import type { Asset } from '../../../types/assets';
import Modal from '../../../components/ui/Modal';
import Button from '../../../components/ui/Button';
import { Check } from 'lucide-react';
interface DownloadRequestsModalProps { interface DownloadRequestsModalProps {
isOpen: boolean; isOpen: boolean;
@ -29,82 +30,59 @@ export const DownloadRequestsModal: React.FC<DownloadRequestsModalProps> = ({
); );
return ( return (
<AnimatePresence> <Modal
{isOpen && ( isOpen={isOpen}
<div className="fixed inset-0 z-50 flex items-center justify-center p-4"> onClose={onClose}
<motion.div title="Pending Download Requests"
initial={{ opacity: 0 }} subtitle="Review and approve download access for protected secret assets."
animate={{ opacity: 1 }} size="lg"
exit={{ opacity: 0 }} footer={
<Button
onClick={onClose} onClick={onClose}
className="absolute inset-0 bg-ink-950/40 backdrop-blur-sm" variant="primary"
/> size="sm"
<motion.div
initial={{ opacity: 0, scale: 0.95, y: 10 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 10 }}
className="relative bg-ink-0 border border-ink-200 rounded-2xl p-6 max-w-2xl w-full shadow-xl z-10 flex flex-col max-h-[80vh] overflow-hidden"
> >
<div className="flex justify-between items-center pb-3.5 border-b border-ink-100 flex-shrink-0"> Close
<div> </Button>
<h3 className="text-lg font-bold text-ink-900">Pending Download Requests</h3> }
<p className="text-xs text-ink-500 mt-0.5">Review and approve download access for protected secret assets.</p>
</div>
<button
onClick={onClose}
className="p-1 rounded-lg text-ink-400 hover:text-ink-900 hover:bg-ink-50 transition-colors"
> >
<X className="w-5 h-5" /> <div className="space-y-3">
</button>
</div>
<div className="flex-1 overflow-y-auto mt-4 space-y-3 pr-1 scrollbar-thin">
{pendingRequests.length === 0 ? ( {pendingRequests.length === 0 ? (
<div className="text-center py-8"> <div className="text-center py-8">
<Check className="w-8 h-8 text-ink-400 mx-auto mb-2" /> <Check className="w-8 h-8 text-ink-400 mx-auto mb-2" />
<p className="text-xs font-bold text-ink-900">All caught up!</p> <p className="text-xs font-bold text-ink-900 font-sans">All caught up!</p>
<p className="text-[10px] text-ink-500">There are no pending download authorization requests.</p> <p className="text-[10px] text-ink-500 font-sans">There are no pending download authorization requests.</p>
</div> </div>
) : ( ) : (
pendingRequests.map(req => ( pendingRequests.map(req => (
<div key={req.id} className="flex flex-col sm:flex-row sm:items-center justify-between p-4 bg-ink-50 border border-ink-200 rounded-xl gap-4"> <div key={req.id} className="flex flex-col sm:flex-row sm:items-center justify-between p-4 bg-ink-50 border border-ink-200 rounded-xl gap-4">
<div> <div>
<p className="text-xs font-bold text-ink-900">{req.user?.email}</p> <p className="text-xs font-bold text-ink-900 font-sans">{req.user?.email}</p>
<p className="text-[10px] text-ink-500 mt-0.5 font-medium"> <p className="text-[10px] text-ink-500 mt-0.5 font-medium font-sans">
Requested download for: <span className="text-ink-900 font-bold">{req.assetTitle}</span> Requested download for: <span className="text-ink-900 font-bold">{req.assetTitle}</span>
</p> </p>
</div> </div>
<div className="flex items-center gap-2 self-end sm:self-center"> <div className="flex items-center gap-2 self-end sm:self-center">
<button <Button
onClick={() => onReject(req.assetId, req.id)} onClick={() => onReject(req.assetId, req.id)}
className="px-3 py-1.5 rounded-lg border border-red-200 text-red-650 hover:bg-red-500/10 text-xs font-bold transition-all" variant="danger"
size="xs"
> >
Reject Reject
</button> </Button>
<button <Button
onClick={() => onApprove(req.assetId, req.id)} onClick={() => onApprove(req.assetId, req.id)}
className="px-4 py-1.5 rounded-lg bg-ink-900 text-ink-0 hover:bg-ink-800 text-xs font-bold transition-all shadow-sm" variant="primary"
size="xs"
> >
Approve Access Approve Access
</button> </Button>
</div> </div>
</div> </div>
)) ))
)} )}
</div> </div>
</Modal>
<div className="pt-3.5 border-t border-ink-100 flex justify-end flex-shrink-0 mt-4">
<button
onClick={onClose}
className="px-5 py-2 rounded-lg bg-ink-900 text-ink-0 text-xs font-bold hover:bg-ink-800 transition-colors shadow-sm"
>
Close
</button>
</div>
</motion.div>
</div>
)}
</AnimatePresence>
); );
}; };

View File

@ -1,8 +1,9 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { X } from 'lucide-react';
import { updateAsset } from '../../../services/assets-api'; import { updateAsset } from '../../../services/assets-api';
import type { Asset } from '../../../types/assets'; import type { Asset } from '../../../types/assets';
import Modal from '../../../components/ui/Modal';
import Button from '../../../components/ui/Button';
import { useToast } from '../../../hooks/use-toast';
interface EditAssetModalProps { interface EditAssetModalProps {
isOpen: boolean; isOpen: boolean;
@ -17,6 +18,7 @@ export const EditAssetModal: React.FC<EditAssetModalProps> = ({
asset, asset,
onSuccess onSuccess
}) => { }) => {
const { success, error } = useToast();
const [editTitle, setEditTitle] = useState(''); const [editTitle, setEditTitle] = useState('');
const [editDescription, setEditDescription] = useState(''); const [editDescription, setEditDescription] = useState('');
const [editCategory, setEditCategory] = useState('Marketing'); const [editCategory, setEditCategory] = useState('Marketing');
@ -53,44 +55,47 @@ export const EditAssetModal: React.FC<EditAssetModalProps> = ({
githubUrl: editGithubUrl, githubUrl: editGithubUrl,
isDownloadable: editIsDownloadable, isDownloadable: editIsDownloadable,
}); });
success('Changes saved successfully', 'Asset details have been updated.');
onSuccess(); onSuccess();
onClose(); onClose();
} catch (err) { } catch (err: any) {
console.error('Failed to save asset details', err); console.error('Failed to save asset details', err);
error('Failed to save changes', err.response?.data?.error || 'Something went wrong.');
} finally { } finally {
setIsSavingEdit(false); setIsSavingEdit(false);
} }
}; };
return ( return (
<AnimatePresence> <Modal
{isOpen && asset && ( isOpen={isOpen && !!asset}
<div className="fixed inset-0 z-50 flex items-center justify-center p-4"> onClose={onClose}
<motion.div title="Edit Asset Details"
initial={{ opacity: 0 }} size="lg"
animate={{ opacity: 1 }} footer={
exit={{ opacity: 0 }} <>
<Button
type="button"
onClick={onClose} onClick={onClose}
className="absolute inset-0 bg-ink-950/40 backdrop-blur-sm" variant="ghost"
/> size="sm"
<motion.div
initial={{ opacity: 0, scale: 0.95, y: 10 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 10 }}
className="relative bg-ink-0 border border-ink-200 rounded-2xl p-6 max-w-lg w-full max-h-[90vh] shadow-xl z-10 flex flex-col overflow-hidden"
> >
<div className="flex justify-between items-center pb-3.5 border-b border-ink-100 flex-shrink-0"> Cancel
<h3 className="text-lg font-bold text-ink-900">Edit Asset Details</h3> </Button>
<button <Button
onClick={onClose} type="submit"
className="p-1 rounded-lg text-ink-400 hover:text-ink-900 hover:bg-ink-50 transition-colors" form="edit-asset-form"
disabled={isSavingEdit}
variant="primary"
size="sm"
> >
<X className="w-5 h-5" /> {isSavingEdit ? 'Saving...' : 'Save Changes'}
</button> </Button>
</div> </>
}
<form onSubmit={handleEditSubmit} className="flex-1 min-h-0 flex flex-col mt-4"> >
<div className="flex-1 overflow-y-auto pr-1 space-y-4 scrollbar-thin"> {asset && (
<form id="edit-asset-form" onSubmit={handleEditSubmit} className="space-y-4">
<div> <div>
<label className="text-xs font-semibold text-ink-500 mb-1.5 block">Asset Title</label> <label className="text-xs font-semibold text-ink-500 mb-1.5 block">Asset Title</label>
<input <input
@ -181,27 +186,8 @@ export const EditAssetModal: React.FC<EditAssetModalProps> = ({
className="w-full bg-ink-50 border border-ink-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ink-900/10 text-ink-900 placeholder-ink-400" className="w-full bg-ink-50 border border-ink-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ink-900/10 text-ink-900 placeholder-ink-400"
/> />
</div> </div>
</div>
<div className="pt-3.5 border-t border-ink-100 flex justify-end gap-3 flex-shrink-0 mt-4">
<button
type="button"
onClick={onClose}
className="px-4 py-2 rounded-lg border border-ink-200 text-ink-700 text-xs font-bold hover:bg-ink-50 transition-colors"
>
Cancel
</button>
<button
type="submit"
disabled={isSavingEdit}
className="px-5 py-2 rounded-lg bg-ink-900 text-ink-0 text-xs font-bold hover:bg-ink-800 transition-colors shadow-sm disabled:opacity-50"
>
{isSavingEdit ? 'Saving...' : 'Save Changes'}
</button>
</div>
</form> </form>
</motion.div>
</div>
)} )}
</AnimatePresence> </Modal>
); );
}; };

View File

@ -1,8 +1,11 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion'; import { motion, AnimatePresence } from 'framer-motion';
import { X, ChevronDown, ChevronUp } from 'lucide-react';
import { updateAsset } from '../../../services/assets-api'; import { updateAsset } from '../../../services/assets-api';
import type { Asset, Organization, ShareItem } from '../../../types/assets'; import type { Asset, Organization, ShareItem } from '../../../types/assets';
import Modal from '../../../components/ui/Modal';
import Button from '../../../components/ui/Button';
import { ChevronDown, ChevronUp } from 'lucide-react';
import { useToast } from '../../../hooks/use-toast';
interface ShareAssetModalProps { interface ShareAssetModalProps {
isOpen: boolean; isOpen: boolean;
@ -19,6 +22,7 @@ export const ShareAssetModal: React.FC<ShareAssetModalProps> = ({
organizations, organizations,
onSuccess onSuccess
}) => { }) => {
const { success, error } = useToast();
const [sharesList, setSharesList] = useState<ShareItem[]>([]); const [sharesList, setSharesList] = useState<ShareItem[]>([]);
const [isSavingShare, setIsSavingShare] = useState(false); const [isSavingShare, setIsSavingShare] = useState(false);
const [expandedOrgId, setExpandedOrgId] = useState<string | null>(null); const [expandedOrgId, setExpandedOrgId] = useState<string | null>(null);
@ -75,54 +79,55 @@ export const ShareAssetModal: React.FC<ShareAssetModalProps> = ({
await updateAsset(asset.id, { await updateAsset(asset.id, {
shares: sharesList shares: sharesList
}); });
success('Share permissions updated', 'The asset visibility settings have been updated.');
onSuccess(); onSuccess();
onClose(); onClose();
} catch (err) { } catch (err: any) {
console.error('Failed to update share permissions', err); console.error('Failed to update share permissions', err);
error('Failed to update share permissions', err.response?.data?.error || 'Something went wrong.');
} finally { } finally {
setIsSavingShare(false); setIsSavingShare(false);
} }
}; };
return ( return (
<AnimatePresence> <Modal
{isOpen && asset && ( isOpen={isOpen && !!asset}
<div className="fixed inset-0 z-50 flex items-center justify-center p-4"> onClose={onClose}
<motion.div title="Share Settings"
initial={{ opacity: 0 }} subtitle={asset?.title}
animate={{ opacity: 1 }} size="md"
exit={{ opacity: 0 }} footer={
<>
<Button
type="button"
onClick={onClose} onClick={onClose}
className="absolute inset-0 bg-ink-950/40 backdrop-blur-sm" variant="ghost"
/> size="sm"
<motion.div
initial={{ opacity: 0, scale: 0.95, y: 10 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 10 }}
className="relative bg-ink-0 border border-ink-200 rounded-2xl p-6 max-w-lg w-full shadow-xl z-10 flex flex-col max-h-[85vh] overflow-hidden"
> >
<div className="flex justify-between items-center pb-3.5 border-b border-ink-100 flex-shrink-0"> Cancel
<div> </Button>
<h3 className="text-lg font-bold text-ink-900">Share Settings</h3> <Button
<p className="text-xs text-ink-500 mt-0.5">{asset.title}</p> type="submit"
</div> form="share-asset-form"
<button disabled={isSavingShare}
onClick={onClose} variant="primary"
className="p-1 rounded-lg text-ink-400 hover:text-ink-900 hover:bg-ink-50 transition-colors" size="sm"
> >
<X className="w-5 h-5" /> {isSavingShare ? 'Saving...' : 'Update Shares'}
</button> </Button>
</div> </>
}
<form onSubmit={handleShareSubmit} className="flex-1 min-h-0 flex flex-col mt-4"> >
<div className="flex-1 overflow-y-auto pr-1 space-y-4 scrollbar-thin"> {asset && (
<p className="text-xs text-ink-600 leading-relaxed"> <form id="share-asset-form" onSubmit={handleShareSubmit} className="space-y-4">
<p className="text-xs text-ink-600 leading-relaxed font-sans">
Select organizations or expand to specify exact users that can access this asset: Select organizations or expand to specify exact users that can access this asset:
</p> </p>
<div className="max-h-64 overflow-y-auto border border-ink-200 rounded-xl divide-y divide-ink-250 bg-ink-50 scrollbar-thin"> <div className="max-h-64 overflow-y-auto border border-ink-200 rounded-xl divide-y divide-ink-250 bg-ink-50 scrollbar-thin">
{organizations.length === 0 ? ( {organizations.length === 0 ? (
<p className="p-4 text-xs text-ink-500 text-center font-medium">No partner organizations registered yet.</p> <p className="p-4 text-xs text-ink-500 text-center font-medium font-sans">No partner organizations registered yet.</p>
) : ( ) : (
organizations.map(org => { organizations.map(org => {
const isEntireShared = isOrgSharedEntirely(org.id); const isEntireShared = isOrgSharedEntirely(org.id);
@ -141,9 +146,9 @@ export const ShareAssetModal: React.FC<ShareAssetModalProps> = ({
className="w-4 h-4 rounded border-ink-300 text-ink-900 focus:ring-ink-900/10 cursor-pointer" className="w-4 h-4 rounded border-ink-300 text-ink-900 focus:ring-ink-900/10 cursor-pointer"
/> />
<div className="flex flex-col"> <div className="flex flex-col">
<span className="text-xs font-bold text-ink-900">{org.name}</span> <span className="text-xs font-bold text-ink-900 font-sans">{org.name}</span>
{specificSharedCount > 0 && !isEntireShared && ( {specificSharedCount > 0 && !isEntireShared && (
<span className="text-[10px] text-ink-500 font-semibold"> <span className="text-[10px] text-ink-500 font-semibold font-sans">
Shared with {specificSharedCount} specific {specificSharedCount === 1 ? 'user' : 'users'} Shared with {specificSharedCount} specific {specificSharedCount === 1 ? 'user' : 'users'}
</span> </span>
)} )}
@ -153,7 +158,7 @@ export const ShareAssetModal: React.FC<ShareAssetModalProps> = ({
<button <button
type="button" type="button"
onClick={() => setExpandedOrgId(isExpanded ? null : org.id)} onClick={() => setExpandedOrgId(isExpanded ? null : org.id)}
className="p-1 rounded-lg text-ink-400 hover:text-ink-900 hover:bg-ink-200 transition-colors flex items-center gap-1 text-[11px] font-bold" className="p-1 rounded-lg text-ink-400 hover:text-ink-900 hover:bg-ink-200 transition-colors flex items-center gap-1 text-[11px] font-bold cursor-pointer font-sans"
> >
<span>Users</span> <span>Users</span>
{isExpanded ? <ChevronUp className="w-3.5 h-3.5" /> : <ChevronDown className="w-3.5 h-3.5" />} {isExpanded ? <ChevronUp className="w-3.5 h-3.5" /> : <ChevronDown className="w-3.5 h-3.5" />}
@ -169,7 +174,7 @@ export const ShareAssetModal: React.FC<ShareAssetModalProps> = ({
className="bg-ink-100 border-t border-b border-ink-200 overflow-hidden divide-y divide-ink-150" className="bg-ink-100 border-t border-b border-ink-200 overflow-hidden divide-y divide-ink-150"
> >
{activeUsers.length === 0 ? ( {activeUsers.length === 0 ? (
<p className="p-3 text-[10px] text-ink-500 italic">No users found in this organization.</p> <p className="p-3 text-[10px] text-ink-500 italic font-sans">No users found in this organization.</p>
) : ( ) : (
activeUsers.map(userItem => { activeUsers.map(userItem => {
const isUserShared = isUserSharedSpecifically(org.id, userItem.id); const isUserShared = isUserSharedSpecifically(org.id, userItem.id);
@ -183,7 +188,7 @@ export const ShareAssetModal: React.FC<ShareAssetModalProps> = ({
onChange={() => handleToggleUser(org.id, userItem.id)} onChange={() => handleToggleUser(org.id, userItem.id)}
className="w-3.5 h-3.5 rounded border-ink-300 text-ink-900 focus:ring-ink-900/10 cursor-pointer disabled:opacity-50" className="w-3.5 h-3.5 rounded border-ink-300 text-ink-900 focus:ring-ink-900/10 cursor-pointer disabled:opacity-50"
/> />
<span className={`text-[11px] font-semibold ${isEntireShared ? 'text-ink-400' : 'text-ink-800'}`}> <span className={`text-[11px] font-semibold font-sans ${isEntireShared ? 'text-ink-400' : 'text-ink-800'}`}>
{userItem.email} {userItem.email}
</span> </span>
</label> </label>
@ -198,28 +203,8 @@ export const ShareAssetModal: React.FC<ShareAssetModalProps> = ({
}) })
)} )}
</div> </div>
</div>
<div className="pt-3.5 border-t border-ink-100 flex justify-end gap-3 flex-shrink-0 mt-4">
<button
type="button"
onClick={onClose}
className="px-4 py-2 rounded-lg border border-ink-200 text-ink-700 text-xs font-bold hover:bg-ink-50 transition-colors"
>
Cancel
</button>
<button
type="submit"
disabled={isSavingShare}
className="px-5 py-2 rounded-lg bg-ink-900 text-ink-0 text-xs font-bold hover:bg-ink-800 transition-colors shadow-sm disabled:opacity-50"
>
{isSavingShare ? 'Saving...' : 'Update Shares'}
</button>
</div>
</form> </form>
</motion.div>
</div>
)} )}
</AnimatePresence> </Modal>
); );
}; };

View File

@ -1,7 +1,9 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { X, UploadCloud, Eye, FileText, File } from 'lucide-react'; import { X, UploadCloud, Eye, FileText, File } from 'lucide-react';
import { uploadAsset } from '../../../services/assets-api'; import { uploadAsset } from '../../../services/assets-api';
import Modal from '../../../components/ui/Modal';
import Button from '../../../components/ui/Button';
import { useToast } from '../../../hooks/use-toast';
interface UploadAssetModalProps { interface UploadAssetModalProps {
isOpen: boolean; isOpen: boolean;
@ -14,6 +16,7 @@ export const UploadAssetModal: React.FC<UploadAssetModalProps> = ({
onClose, onClose,
onSuccess onSuccess
}) => { }) => {
const { success, error } = useToast();
const [uploadTab, setUploadTab] = useState<'file' | 'url'>('file'); const [uploadTab, setUploadTab] = useState<'file' | 'url'>('file');
const [uploadFile, setUploadFile] = useState<File | null>(null); const [uploadFile, setUploadFile] = useState<File | null>(null);
const [previewUrl, setPreviewUrl] = useState<string | null>(null); const [previewUrl, setPreviewUrl] = useState<string | null>(null);
@ -88,10 +91,12 @@ export const UploadAssetModal: React.FC<UploadAssetModalProps> = ({
setUploadTags(''); setUploadTags('');
setUploadGithubUrl(''); setUploadGithubUrl('');
setUploadIsDownloadable(true); setUploadIsDownloadable(true);
success('Asset published successfully', 'The asset has been added to the catalog.');
onSuccess(); onSuccess();
onClose(); onClose();
} catch (err) { } catch (err: any) {
console.error('Failed to upload asset', err); console.error('Failed to upload asset', err);
error('Failed to publish asset', err.response?.data?.error || 'Something went wrong.');
} finally { } finally {
setIsUploading(false); setIsUploading(false);
} }
@ -99,52 +104,52 @@ export const UploadAssetModal: React.FC<UploadAssetModalProps> = ({
return ( return (
<> <>
<AnimatePresence> <Modal
{isOpen && ( isOpen={isOpen}
<div className="fixed inset-0 z-50 flex items-center justify-center p-4"> onClose={onClose}
<motion.div title="Upload / Link Asset"
initial={{ opacity: 0 }} size="lg"
animate={{ opacity: 1 }} footer={
exit={{ opacity: 0 }} <>
<Button
type="button"
onClick={onClose} onClick={onClose}
className="absolute inset-0 bg-ink-950/40 backdrop-blur-sm" variant="ghost"
/> size="sm"
<motion.div
initial={{ opacity: 0, scale: 0.95, y: 10 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 10 }}
className="relative bg-ink-0 border border-ink-200 rounded-2xl p-6 max-w-lg w-full max-h-[90vh] shadow-xl z-10 flex flex-col overflow-hidden"
> >
<div className="flex justify-between items-center pb-3.5 border-b border-ink-100 flex-shrink-0"> Cancel
<h3 className="text-lg font-bold text-ink-900">Upload / Link Asset</h3> </Button>
<button <Button
onClick={onClose} type="submit"
className="p-1 rounded-lg text-ink-400 hover:text-ink-900 hover:bg-ink-50 transition-colors" form="upload-asset-form"
disabled={isUploading}
variant="primary"
size="sm"
>
{isUploading ? 'Publishing...' : 'Publish Asset'}
</Button>
</>
}
> >
<X className="w-5 h-5" />
</button>
</div>
{/* Toggle upload tabs */} {/* Toggle upload tabs */}
<div className="flex bg-ink-50 p-1 rounded-xl border border-ink-200 flex-shrink-0 mt-4"> <div className="flex bg-ink-50 p-1 rounded-xl border border-ink-200 mt-2">
<button <button
type="button" type="button"
onClick={() => setUploadTab('file')} onClick={() => setUploadTab('file')}
className={`flex-1 py-1.5 text-xs font-semibold rounded-lg transition-all ${uploadTab === 'file' ? 'bg-ink-0 text-ink-900 shadow-sm border border-ink-200' : 'text-ink-500 hover:text-ink-800'}`} className={`flex-1 py-1.5 text-xs font-semibold rounded-lg transition-all cursor-pointer ${uploadTab === 'file' ? 'bg-ink-0 text-ink-900 shadow-sm border border-ink-200' : 'text-ink-500 hover:text-ink-800'}`}
> >
Secure File Upload Secure File Upload
</button> </button>
<button <button
type="button" type="button"
onClick={() => setUploadTab('url')} onClick={() => setUploadTab('url')}
className={`flex-1 py-1.5 text-xs font-semibold rounded-lg transition-all ${uploadTab === 'url' ? 'bg-ink-0 text-ink-900 shadow-sm border border-ink-200' : 'text-ink-500 hover:text-ink-800'}`} className={`flex-1 py-1.5 text-xs font-semibold rounded-lg transition-all cursor-pointer ${uploadTab === 'url' ? 'bg-ink-0 text-ink-900 shadow-sm border border-ink-200' : 'text-ink-500 hover:text-ink-800'}`}
> >
External Web URL External Web URL
</button> </button>
</div> </div>
<form onSubmit={handleUploadSubmit} className="flex-1 min-h-0 flex flex-col mt-4"> <form id="upload-asset-form" onSubmit={handleUploadSubmit} className="space-y-4 mt-4">
<div className="flex-1 overflow-y-auto pr-1 space-y-4 scrollbar-thin">
{uploadTab === 'file' ? ( {uploadTab === 'file' ? (
<div className={`border-2 border-dashed border-ink-200 hover:border-ink-400 rounded-xl p-5 text-center transition-colors relative bg-ink-50 max-h-48 overflow-y-auto scrollbar-thin ${!uploadFile ? 'cursor-pointer' : ''}`}> <div className={`border-2 border-dashed border-ink-200 hover:border-ink-400 rounded-xl p-5 text-center transition-colors relative bg-ink-50 max-h-48 overflow-y-auto scrollbar-thin ${!uploadFile ? 'cursor-pointer' : ''}`}>
{!uploadFile && ( {!uploadFile && (
@ -168,7 +173,7 @@ export const UploadAssetModal: React.FC<UploadAssetModalProps> = ({
e.preventDefault(); e.preventDefault();
setUploadFile(null); setUploadFile(null);
}} }}
className="absolute top-2 right-2 p-1 rounded-lg bg-ink-0 hover:bg-ink-100 text-ink-500 hover:text-ink-900 border border-ink-200 transition-colors z-20 shadow-sm" className="absolute top-2 right-2 p-1 rounded-lg bg-ink-0 hover:bg-ink-100 text-ink-500 hover:text-ink-900 border border-ink-200 transition-colors z-20 shadow-sm cursor-pointer"
title="Remove file" title="Remove file"
> >
<X className="w-3.5 h-3.5" /> <X className="w-3.5 h-3.5" />
@ -195,7 +200,7 @@ export const UploadAssetModal: React.FC<UploadAssetModalProps> = ({
e.preventDefault(); e.preventDefault();
setFullImagePreviewUrl(previewUrl); setFullImagePreviewUrl(previewUrl);
}} }}
className="p-1 rounded-lg bg-ink-55 hover:bg-ink-100 text-ink-600 hover:text-ink-950 border border-ink-200 transition-all duration-200 hover:scale-105 active:scale-95 flex items-center justify-center" className="p-1 rounded-lg bg-ink-55 hover:bg-ink-100 text-ink-600 hover:text-ink-955 border border-ink-200 transition-all duration-200 hover:scale-105 active:scale-95 flex items-center justify-center cursor-pointer"
title="Preview Image" title="Preview Image"
> >
<Eye className="w-3 h-3" /> <Eye className="w-3 h-3" />
@ -207,7 +212,7 @@ export const UploadAssetModal: React.FC<UploadAssetModalProps> = ({
e.preventDefault(); e.preventDefault();
setUploadFile(null); setUploadFile(null);
}} }}
className="p-1 rounded-lg bg-ink-55 hover:bg-red-500/10 text-ink-500 hover:text-red-650 border border-ink-200 transition-all duration-200 hover:scale-105 active:scale-95 flex items-center justify-center" className="p-1 rounded-lg bg-ink-55 hover:bg-red-500/10 text-ink-500 hover:text-red-655 border border-ink-200 transition-all duration-200 hover:scale-105 active:scale-95 flex items-center justify-center cursor-pointer"
title="Remove File" title="Remove File"
> >
<X className="w-3 h-3" /> <X className="w-3 h-3" />
@ -348,63 +353,24 @@ export const UploadAssetModal: React.FC<UploadAssetModalProps> = ({
className="w-full bg-ink-50 border border-ink-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ink-900/10 text-ink-900 placeholder-ink-400" className="w-full bg-ink-50 border border-ink-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ink-900/10 text-ink-900 placeholder-ink-400"
/> />
</div> </div>
</div>
<div className="pt-3.5 border-t border-ink-100 flex justify-end gap-3 flex-shrink-0 mt-4">
<button
type="button"
onClick={onClose}
className="px-4 py-2 rounded-lg border border-ink-200 text-ink-700 text-xs font-bold hover:bg-ink-50 transition-colors"
>
Cancel
</button>
<button
type="submit"
disabled={isUploading}
className="px-5 py-2 rounded-lg bg-ink-900 text-ink-0 text-xs font-bold hover:bg-ink-800 transition-colors shadow-sm disabled:opacity-50"
>
{isUploading ? 'Publishing...' : 'Publish Asset'}
</button>
</div>
</form> </form>
</motion.div> </Modal>
</div>
)}
</AnimatePresence>
{/* Full Local Image Preview Modal */} {/* Full Local Image Preview Modal */}
<AnimatePresence> <Modal
{fullImagePreviewUrl && ( isOpen={!!fullImagePreviewUrl}
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4"> onClose={() => setFullImagePreviewUrl(null)}
<motion.div title="Asset Image Preview"
initial={{ opacity: 0 }} size="lg"
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={() => setFullImagePreviewUrl(null)}
className="absolute inset-0 bg-ink-950/85 backdrop-blur-md"
/>
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
className="relative max-w-[95vw] max-h-[90vh] z-10 flex flex-col items-center justify-center"
> >
<button <div className="flex flex-col items-center justify-center p-1">
onClick={() => setFullImagePreviewUrl(null)}
className="absolute -top-10 right-0 p-1.5 rounded-lg bg-ink-0 hover:bg-ink-100 text-ink-900 border border-ink-200 shadow-lg transition-colors z-20"
title="Close"
>
<X className="w-4 h-4" />
</button>
<img <img
src={fullImagePreviewUrl} src={fullImagePreviewUrl || ''}
alt="Full preview" alt="Full preview"
className="max-w-full max-h-[80vh] object-contain rounded-xl shadow-2xl border border-ink-200 bg-ink-50" className="max-w-full max-h-[60vh] object-contain rounded-xl shadow-md border border-ink-200 bg-ink-50"
/> />
</motion.div>
</div> </div>
)} </Modal>
</AnimatePresence>
</> </>
); );
}; };

View File

@ -1,29 +1,39 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from "react";
import type { BlogPost } from '../../../types'; import type { BlogPost } from "../../../types";
import { apiClient } from '../../../lib/api-client'; import { apiClient } from "../../../lib/api-client";
import { useAuth } from '../../auth/store/AuthContext'; import { useAuth } from "../../auth/store/AuthContext";
import { BookOpen, User, Calendar, Clock, Plus, X, Sparkles, Send } from 'lucide-react'; import {
BookOpen,
User,
Calendar,
Clock,
Plus,
Sparkles,
Send,
} from "lucide-react";
import Modal from "../../../components/ui/Modal";
import { PageLayout } from "../../../components/layout/PageLayout";
export const BlogCatalog: React.FC = () => { export const BlogCatalog: React.FC = () => {
const { user } = useAuth(); const { user } = useAuth();
const [posts, setPosts] = useState<BlogPost[]>([]); const [posts, setPosts] = useState<BlogPost[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const isAdmin = user?.role === 'ADMIN'; const isAdmin = user?.role === "ADMIN";
// CMS modal state // CMS modal state
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const [title, setTitle] = useState(''); const [title, setTitle] = useState("");
const [content, setContent] = useState(''); const [content, setContent] = useState("");
const [tagsInput, setTagsInput] = useState(''); const [tagsInput, setTagsInput] = useState("");
const [thumbnailUrl, setThumbnailUrl] = useState(''); const [thumbnailUrl, setThumbnailUrl] = useState("");
const [status, setStatus] = useState<'draft' | 'published'>('draft'); const [status, setStatus] = useState<"draft" | "published">("draft");
const [formError, setFormError] = useState(''); const [formError, setFormError] = useState("");
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const fetchPosts = async () => { const fetchPosts = async () => {
setLoading(true); setLoading(true);
try { try {
const response = await apiClient.get<BlogPost[]>('/blog'); const response = await apiClient.get<BlogPost[]>("/blog");
setPosts(response.data); setPosts(response.data);
} catch (err) { } catch (err) {
console.error(err); console.error(err);
@ -38,63 +48,80 @@ export const BlogCatalog: React.FC = () => {
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
setFormError(''); setFormError("");
if (!title.trim() || !content.trim()) { if (!title.trim() || !content.trim()) {
setFormError('Title and content are required.'); setFormError("Title and content are required.");
return; return;
} }
setSubmitting(true); setSubmitting(true);
try { try {
const tags = tagsInput.split(',').map(t => t.trim()).filter(t => t.length > 0); const tags = tagsInput
.split(",")
.map((t) => t.trim())
.filter((t) => t.length > 0);
const payload = { const payload = {
title, title,
content, content,
tags, tags,
thumbnailUrl: thumbnailUrl.trim() || undefined, thumbnailUrl: thumbnailUrl.trim() || undefined,
status, status,
author: 'Technical Architect', author: "Technical Architect",
}; };
const response = await apiClient.post<BlogPost>('/blog/new', payload); const response = await apiClient.post<BlogPost>("/blog/new", payload);
setPosts(prev => [response.data, ...prev]); setPosts((prev) => [response.data, ...prev]);
setTitle(''); setTitle("");
setContent(''); setContent("");
setTagsInput(''); setTagsInput("");
setThumbnailUrl(''); setThumbnailUrl("");
setStatus('draft'); setStatus("draft");
setIsOpen(false); setIsOpen(false);
} catch (err) { } catch (err) {
setFormError('Failed to publish article.'); setFormError("Failed to publish article.");
} finally { } finally {
setSubmitting(false); setSubmitting(false);
} }
}; };
return ( // Header component
<div className="space-y-6 text-ink-900"> const headerNode = (
<div className="flex items-center justify-between">
<div> <div>
<h2 className="text-xl font-bold text-ink-800">Engineering Blog & Insights</h2> <h2 className="text-xl font-bold text-ink-800">
<p className="text-sm text-ink-600">Deep-dives into RISC-V pipelining, CodeNuk scaffolding practices, and edge security optimizations.</p> Engineering Blog & Insights
</h2>
<p className="text-sm text-ink-600">
Deep-dives into RISC-V pipelining, CodeNuk scaffolding practices,
and edge security optimizations.
</p>
</div> </div>
{isAdmin && ( );
// Toolbar component (only render if admin is true to show Write Post action)
const toolbarNode = isAdmin ? (
<div className="flex justify-end p-3.5 bg-ink-0 border border-ink-200 rounded-xl shadow-sm">
<button <button
onClick={() => setIsOpen(true)} onClick={() => setIsOpen(true)}
className="flex items-center gap-1.5 rounded-lg bg-ink-900 px-4 py-2.5 text-xs font-semibold text-ink-0 hover:bg-ink-800 transition-all shadow-sm" className="flex items-center gap-1.5 rounded-lg bg-ink-900 px-4 py-2 text-xs font-semibold text-ink-0 hover:bg-ink-800 transition-all shadow-sm cursor-pointer"
> >
<Plus className="h-4 w-4" /> <Plus className="h-4 w-4" />
Write Post Write Post
</button> </button>
)}
</div> </div>
) : undefined;
return (
<PageLayout header={headerNode} toolbar={toolbarNode}>
<div className="p-5 flex-1 min-h-0 overflow-y-auto">
{loading ? ( {loading ? (
<div className="grid gap-6 md:grid-cols-2"> <div className="grid gap-6 md:grid-cols-2">
{[1, 2].map(n => ( {[1, 2].map((n) => (
<div key={n} className="animate-pulse rounded-xl border border-ink-200 bg-ink-0 p-5 space-y-4"> <div
key={n}
className="animate-pulse rounded-xl border border-ink-200 bg-ink-0 p-5 space-y-4"
>
<div className="h-48 rounded-lg bg-ink-100" /> <div className="h-48 rounded-lg bg-ink-100" />
<div className="h-4 w-3/4 rounded bg-ink-100" /> <div className="h-4 w-3/4 rounded bg-ink-100" />
<div className="h-20 rounded bg-ink-100" /> <div className="h-20 rounded bg-ink-100" />
@ -108,7 +135,7 @@ export const BlogCatalog: React.FC = () => {
</div> </div>
) : ( ) : (
<div className="grid gap-6 md:grid-cols-2"> <div className="grid gap-6 md:grid-cols-2">
{posts.map(post => ( {posts.map((post) => (
<article <article
key={post.id} key={post.id}
className="rounded-xl border border-ink-200 bg-ink-0 shadow-sm overflow-hidden flex flex-col hover:border-ink-400 transition-all duration-300" className="rounded-xl border border-ink-200 bg-ink-0 shadow-sm overflow-hidden flex flex-col hover:border-ink-400 transition-all duration-300"
@ -120,9 +147,13 @@ export const BlogCatalog: React.FC = () => {
className="w-full h-full object-cover" className="w-full h-full object-cover"
/> />
{isAdmin && ( {isAdmin && (
<span className={`absolute right-3 top-3 rounded-full border px-2.5 py-0.5 text-[9px] font-bold uppercase tracking-wider ${ <span
post.status === 'published' ? 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20' : 'bg-ink-100 text-ink-700 border-ink-200' className={`absolute right-3 top-3 rounded-full border px-2.5 py-0.5 text-[9px] font-bold uppercase tracking-wider ${
}`}> post.status === "published"
? "bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20"
: "bg-ink-100 text-ink-700 border-ink-200"
}`}
>
{post.status} {post.status}
</span> </span>
)} )}
@ -143,12 +174,19 @@ export const BlogCatalog: React.FC = () => {
{post.readTime} {post.readTime}
</span> </span>
</div> </div>
<h3 className="text-base font-bold text-ink-800 leading-snug line-clamp-1">{post.title}</h3> <h3 className="text-base font-bold text-ink-800 leading-snug line-clamp-1">
<p className="text-xs text-ink-600 leading-relaxed line-clamp-3">{post.content}</p> {post.title}
</h3>
<p className="text-xs text-ink-600 leading-relaxed line-clamp-3">
{post.content}
</p>
</div> </div>
<div className="flex flex-wrap gap-1.5 pt-2 border-t border-ink-100"> <div className="flex flex-wrap gap-1.5 pt-2 border-t border-ink-100">
{post.tags.map(t => ( {post.tags.map((t) => (
<span key={t} className="rounded bg-ink-50 px-2 py-0.5 text-[9px] font-semibold text-ink-700 border border-ink-200"> <span
key={t}
className="rounded bg-ink-50 px-2 py-0.5 text-[9px] font-semibold text-ink-700 border border-ink-200"
>
#{t} #{t}
</span> </span>
))} ))}
@ -158,35 +196,32 @@ export const BlogCatalog: React.FC = () => {
))} ))}
</div> </div>
)} )}
{/* Post Creator Modal */}
{isOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-ink-950/20 backdrop-blur-sm p-4 animate-fade-in">
<div className="w-full max-w-lg rounded-2xl border border-ink-200 bg-ink-0 p-6 shadow-xl max-h-[90vh] overflow-y-auto relative animate-scale-up">
<button
onClick={() => setIsOpen(false)}
className="absolute right-4 top-4 rounded-full p-1.5 text-ink-600 hover:bg-ink-100 hover:text-ink-800 transition-colors"
>
<X className="h-5 w-5" />
</button>
<div className="mb-5">
<h3 className="text-lg font-bold text-ink-800 flex items-center gap-2">
<Sparkles className="h-5 w-5 text-ink-900" />
Write Blog Article
</h3>
<p className="text-xs text-ink-600">Draft or publish a technical write-up for the developer channel.</p>
</div> </div>
{/* Post Creator Modal */}
<Modal
isOpen={isOpen}
onClose={() => setIsOpen(false)}
title={
<span className="flex items-center gap-2">
<Sparkles className="h-5 w-5 text-ink-900" />
Write Blog Article
</span>
}
subtitle="Draft or publish a technical write-up for the developer channel."
size="md"
>
{formError && ( {formError && (
<div className="mb-4 rounded-lg bg-red-500/10 border border-red-500/20 p-3 text-xs font-semibold text-red-600 dark:text-red-400"> <div className="mb-4 rounded-lg bg-red-500/10 border border-red-500/20 p-3 text-xs font-semibold text-red-650">
{formError} {formError}
</div> </div>
)} )}
<form onSubmit={handleSubmit} className="space-y-4"> <form onSubmit={handleSubmit} className="space-y-4">
<div> <div>
<label className="block text-xs font-semibold text-ink-700 mb-1">Article Title</label> <label className="block text-xs font-semibold text-ink-700 mb-1">
Article Title
</label>
<input <input
type="text" type="text"
value={title} value={title}
@ -198,7 +233,9 @@ export const BlogCatalog: React.FC = () => {
</div> </div>
<div> <div>
<label className="block text-xs font-semibold text-ink-700 mb-1">Content (Markdown supported)</label> <label className="block text-xs font-semibold text-ink-700 mb-1">
Content (Markdown supported)
</label>
<textarea <textarea
value={content} value={content}
onChange={(e) => setContent(e.target.value)} onChange={(e) => setContent(e.target.value)}
@ -211,7 +248,9 @@ export const BlogCatalog: React.FC = () => {
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div> <div>
<label className="block text-xs font-semibold text-ink-700 mb-1">Tags (comma-separated)</label> <label className="block text-xs font-semibold text-ink-700 mb-1">
Tags (comma-separated)
</label>
<input <input
type="text" type="text"
value={tagsInput} value={tagsInput}
@ -222,7 +261,9 @@ export const BlogCatalog: React.FC = () => {
</div> </div>
<div> <div>
<label className="block text-xs font-semibold text-ink-700 mb-1">Article Cover Photo URL</label> <label className="block text-xs font-semibold text-ink-700 mb-1">
Article Cover Photo URL
</label>
<input <input
type="url" type="url"
value={thumbnailUrl} value={thumbnailUrl}
@ -234,14 +275,16 @@ export const BlogCatalog: React.FC = () => {
</div> </div>
<div> <div>
<label className="block text-xs font-semibold text-ink-700 mb-1">Publish Status</label> <label className="block text-xs font-semibold text-ink-700 mb-1">
Publish Status
</label>
<div className="flex gap-4 mt-2"> <div className="flex gap-4 mt-2">
<label className="flex items-center gap-1.5 text-xs text-ink-800 font-semibold cursor-pointer"> <label className="flex items-center gap-1.5 text-xs text-ink-800 font-semibold cursor-pointer">
<input <input
type="radio" type="radio"
name="blogStatus" name="blogStatus"
checked={status === 'draft'} checked={status === "draft"}
onChange={() => setStatus('draft')} onChange={() => setStatus("draft")}
className="text-ink-900 focus:ring-ink-900/20" className="text-ink-900 focus:ring-ink-900/20"
/> />
Draft Draft
@ -250,8 +293,8 @@ export const BlogCatalog: React.FC = () => {
<input <input
type="radio" type="radio"
name="blogStatus" name="blogStatus"
checked={status === 'published'} checked={status === "published"}
onChange={() => setStatus('published')} onChange={() => setStatus("published")}
className="text-ink-900 focus:ring-ink-900/20" className="text-ink-900 focus:ring-ink-900/20"
/> />
Published Published
@ -263,24 +306,23 @@ export const BlogCatalog: React.FC = () => {
<button <button
type="button" type="button"
onClick={() => setIsOpen(false)} onClick={() => setIsOpen(false)}
className="rounded-lg border border-ink-200 bg-ink-0 px-4 py-2 text-sm font-semibold text-ink-600 hover:bg-ink-50" className="rounded-lg border border-ink-200 bg-ink-0 px-4 py-2 text-sm font-semibold text-ink-600 hover:bg-ink-50 cursor-pointer"
> >
Cancel Cancel
</button> </button>
<button <button
type="submit" type="submit"
disabled={submitting} disabled={submitting}
className="rounded-lg bg-ink-900 px-5 py-2 text-sm font-semibold text-ink-0 hover:bg-ink-800 flex items-center gap-1.5" className="rounded-lg bg-ink-900 px-5 py-2 text-sm font-semibold text-ink-0 hover:bg-ink-800 flex items-center gap-1.5 cursor-pointer disabled:opacity-50"
> >
<Send className="h-4 w-4" /> <Send className="h-4 w-4" />
{submitting ? 'Publishing...' : 'Publish Article'} {submitting ? "Publishing..." : "Publish Article"}
</button> </button>
</div> </div>
</form> </form>
</div> </Modal>
</div> </PageLayout>
)}
</div>
); );
}; };
export default BlogCatalog; export default BlogCatalog;

View File

@ -1,10 +1,11 @@
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import type { UseQueryResult } from "@tanstack/react-query"; import type { UseQueryResult } from "@tanstack/react-query";
import { getPendingPartners } from "../services/legal-api"; import { getPendingPartners, getMyAcceptances } from "../services/legal-api";
import type { PendingPartner } from "../services/legal-api"; import type { PendingPartner, LegalAcceptance } from "../services/legal-api";
export const LEGAL_QUERY_KEYS = { export const LEGAL_QUERY_KEYS = {
pending: () => ["legal", "pending"] as const, pending: () => ["legal", "pending"] as const,
myAcceptances: () => ["legal", "my-acceptances"] as const,
}; };
export const usePendingPartnersQuery = ( export const usePendingPartnersQuery = (
@ -17,3 +18,14 @@ export const usePendingPartnersQuery = (
staleTime: 5 * 60 * 1000, // 5 minutes stale time staleTime: 5 * 60 * 1000, // 5 minutes stale time
}); });
}; };
export const useMyAcceptancesQuery = (
enabled: boolean = true
): UseQueryResult<LegalAcceptance[]> => {
return useQuery<LegalAcceptance[]>({
queryKey: LEGAL_QUERY_KEYS.myAcceptances(),
queryFn: getMyAcceptances,
enabled,
staleTime: 5 * 60 * 1000,
});
};

View File

@ -0,0 +1 @@
export { useToast } from '../components/ui/Toast';

View File

@ -1,9 +1,9 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from "react";
import { motion } from 'framer-motion'; import { motion } from "framer-motion";
import type { Variants } from 'framer-motion'; import type { Variants } from "framer-motion";
import { UploadCloud, Search, File, CheckCircle } from 'lucide-react'; import { UploadCloud, Search, File, CheckCircle } from "lucide-react";
import { useAuthStore } from '../hooks/use-auth'; import { useAuthStore } from "../hooks/use-auth";
import { axiosInstance } from '../services/axios'; import { axiosInstance } from "../services/axios";
import { import {
getAssets, getAssets,
getOrganizations, getOrganizations,
@ -11,41 +11,50 @@ import {
requestDownload, requestDownload,
approveDownloadRequest, approveDownloadRequest,
rejectDownloadRequest, rejectDownloadRequest,
downloadAssetFile downloadAssetFile,
} from '../services/assets-api'; } from "../services/assets-api";
import { useToast } from "../hooks/use-toast";
// Subcomponents // Subcomponents
import { AssetCard } from '../features/assets/components/AssetCard'; import { AssetCard } from "../features/assets/components/AssetCard";
import { UploadAssetModal } from '../features/assets/components/UploadAssetModal'; import { UploadAssetModal } from "../features/assets/components/UploadAssetModal";
import { EditAssetModal } from '../features/assets/components/EditAssetModal'; import { EditAssetModal } from "../features/assets/components/EditAssetModal";
import { ShareAssetModal } from '../features/assets/components/ShareAssetModal'; import { ShareAssetModal } from "../features/assets/components/ShareAssetModal";
import { AssetDetailsModal } from '../features/assets/components/AssetDetailsModal'; import { AssetDetailsModal } from "../features/assets/components/AssetDetailsModal";
import { AssetViewerModal } from '../features/assets/components/AssetViewerModal'; import { AssetViewerModal } from "../features/assets/components/AssetViewerModal";
import { DownloadRequestsModal } from '../features/assets/components/DownloadRequestsModal'; import { DownloadRequestsModal } from "../features/assets/components/DownloadRequestsModal";
import { PageHeader } from '../components/ui/PageHeader'; import { PageHeader } from "../components/ui/PageHeader";
import Button from "../components/ui/Button";
import { PageLayout } from "../components/layout/PageLayout";
// Type Definitions // Type Definitions
import type { Asset, Organization } from '../types/assets'; import type { Asset, Organization } from "../types/assets";
const containerVariants: Variants = { const containerVariants: Variants = {
hidden: { opacity: 0 }, hidden: { opacity: 0 },
show: { opacity: 1, transition: { staggerChildren: 0.05 } } show: { opacity: 1, transition: { staggerChildren: 0.05 } },
}; };
const itemVariants: Variants = { const itemVariants: Variants = {
hidden: { opacity: 0, y: 15, scale: 0.98 }, hidden: { opacity: 0, y: 15, scale: 0.98 },
show: { opacity: 1, y: 0, scale: 1, transition: { type: 'spring', stiffness: 350, damping: 25 } } show: {
opacity: 1,
y: 0,
scale: 1,
transition: { type: "spring", stiffness: 350, damping: 25 },
},
}; };
export const AssetsPage = () => { export const AssetsPage = () => {
const { success, error } = useToast();
const user = useAuthStore((state) => state.user); const user = useAuthStore((state) => state.user);
const [assets, setAssets] = useState<Asset[]>([]); const [assets, setAssets] = useState<Asset[]>([]);
const [organizations, setOrganizations] = useState<Organization[]>([]); const [organizations, setOrganizations] = useState<Organization[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
// Search & Filter // Search & Filter
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState("");
const [selectedCategory, setSelectedCategory] = useState<string>('ALL'); const [selectedCategory, setSelectedCategory] = useState<string>("ALL");
// Modal Control States // Modal Control States
const [isUploadOpen, setIsUploadOpen] = useState(false); const [isUploadOpen, setIsUploadOpen] = useState(false);
@ -68,12 +77,12 @@ export const AssetsPage = () => {
const assetsData = await getAssets(); const assetsData = await getAssets();
setAssets(assetsData); setAssets(assetsData);
if (user?.role === 'ADMIN') { if (user?.role === "ADMIN") {
const orgsData = await getOrganizations(); const orgsData = await getOrganizations();
setOrganizations(orgsData); setOrganizations(orgsData);
} }
} catch (err) { } catch (err) {
console.error('Failed to fetch assets data', err); console.error("Failed to fetch assets data", err);
} finally { } finally {
setLoading(false); setLoading(false);
} }
@ -100,94 +109,123 @@ export const AssetsPage = () => {
}; };
const handleDeleteAsset = async (id: string) => { const handleDeleteAsset = async (id: string) => {
if (!window.confirm('Are you sure you want to permanently delete this asset?')) return; const asset = assets.find(a => a.id === id);
const title = asset ? asset.title : "this asset";
if (
!window.confirm(`Are you sure you want to permanently delete "${title}"?`)
)
return;
try { try {
await deleteAsset(id); await deleteAsset(id);
success("Asset deleted successfully", `"${title}" has been permanently removed.`);
await fetchData(); await fetchData();
} catch (err) { } catch (err: any) {
console.error('Failed to delete asset', err); console.error("Failed to delete asset", err);
error("Failed to delete asset", err.response?.data?.error || "Something went wrong.");
} }
}; };
const handleRequestDownload = async (asset: Asset) => { const handleRequestDownload = async (asset: Asset) => {
try { try {
await requestDownload(asset.id); await requestDownload(asset.id);
success("Download request submitted", "An administrator has been notified of your request.");
await fetchData(); await fetchData();
} catch (err) { } catch (err: any) {
console.error('Failed to request download access', err); console.error("Failed to request download access", err);
error("Failed to submit request", err.response?.data?.error || "Something went wrong.");
} }
}; };
const handleApproveRequest = async (assetId: string, requestId: string) => { const handleApproveRequest = async (assetId: string, requestId: string) => {
try { try {
await approveDownloadRequest(assetId, requestId); await approveDownloadRequest(assetId, requestId);
success("Download request approved", "The partner can now download this asset.");
await fetchData(); await fetchData();
} catch (err) { } catch (err: any) {
console.error('Failed to approve request', err); console.error("Failed to approve request", err);
error("Failed to approve request", err.response?.data?.error || "Something went wrong.");
} }
}; };
const handleRejectRequest = async (assetId: string, requestId: string) => { const handleRejectRequest = async (assetId: string, requestId: string) => {
try { try {
await rejectDownloadRequest(assetId, requestId); await rejectDownloadRequest(assetId, requestId);
success("Download request rejected", "The access request was denied.");
await fetchData(); await fetchData();
} catch (err) { } catch (err: any) {
console.error('Failed to reject request', err); console.error("Failed to reject request", err);
error("Failed to reject request", err.response?.data?.error || "Something went wrong.");
} }
}; };
const handleDownload = async (asset: Asset) => { const handleDownload = async (asset: Asset) => {
success("Download started", `Downloading "${asset.title}"...`);
try { try {
await downloadAssetFile(asset.id); await downloadAssetFile(asset.id);
const downloadUrl = asset.url.startsWith('http') const downloadUrl = asset.url.startsWith("http")
? asset.url ? asset.url
: `${axiosInstance.defaults.baseURL?.replace('/api/v1', '')}${asset.url}`; : `${axiosInstance.defaults.baseURL?.replace("/api/v1", "")}${asset.url}`;
const a = document.createElement('a'); const a = document.createElement("a");
a.href = downloadUrl; a.href = downloadUrl;
a.download = asset.title; a.download = asset.title;
a.target = '_blank'; a.target = "_blank";
document.body.appendChild(a); document.body.appendChild(a);
a.click(); a.click();
document.body.removeChild(a); document.body.removeChild(a);
setAssets(prev => prev.map(item => setAssets((prev) =>
item.id === asset.id ? { ...item, downloadsCount: item.downloadsCount + 1 } : item prev.map((item) =>
)); item.id === asset.id
} catch (err) { ? { ...item, downloadsCount: item.downloadsCount + 1 }
console.error('Failed to process download', err); : item,
),
);
} catch (err: any) {
console.error("Failed to process download", err);
error("Download failed", err.response?.data?.error || "Could not retrieve asset file.");
} }
}; };
const CATEGORIES = ['ALL', 'Marketing', 'Presentations', 'Branding', 'Resources', 'Technical']; const CATEGORIES = [
"ALL",
"Marketing",
"Presentations",
"Branding",
"Resources",
"Technical",
];
const filteredAssets = assets.filter(asset => { const filteredAssets = assets.filter((asset) => {
const query = searchQuery.toLowerCase().trim(); const query = searchQuery.toLowerCase().trim();
const matchesSearch = !query || const matchesSearch =
!query ||
asset.title.toLowerCase().includes(query) || asset.title.toLowerCase().includes(query) ||
(asset.description && asset.description.toLowerCase().includes(query)) || (asset.description && asset.description.toLowerCase().includes(query)) ||
(asset.categoryId && asset.categoryId.toLowerCase().includes(query)) || (asset.categoryId && asset.categoryId.toLowerCase().includes(query)) ||
(asset.subcategory && asset.subcategory.toLowerCase().includes(query)) || (asset.subcategory && asset.subcategory.toLowerCase().includes(query)) ||
(asset.githubUrl && asset.githubUrl.toLowerCase().includes(query)) || (asset.githubUrl && asset.githubUrl.toLowerCase().includes(query)) ||
asset.type.toLowerCase().includes(query) || asset.type.toLowerCase().includes(query) ||
asset.tags.some(tag => tag.toLowerCase().includes(query)); asset.tags.some((tag) => tag.toLowerCase().includes(query));
const matchesCategory = const matchesCategory =
selectedCategory === 'ALL' || selectedCategory === "ALL" || asset.categoryId === selectedCategory;
asset.categoryId === selectedCategory;
return matchesSearch && matchesCategory; return matchesSearch && matchesCategory;
}); });
const pendingRequestsCount = assets.reduce((acc, asset) => { const pendingRequestsCount = assets.reduce((acc, asset) => {
return acc + (asset.downloadRequests?.filter(r => r.status === 'PENDING').length || 0); return (
acc +
(asset.downloadRequests?.filter((r) => r.status === "PENDING").length ||
0)
);
}, 0); }, 0);
return ( // Header component
<motion.div variants={containerVariants} initial="hidden" animate="show" className="w-full space-y-6 text-ink-900 animate-fade-in"> const headerNode = (
<PageHeader <PageHeader
title="Asset Library" title="Asset Library"
subtitle="Securely manage, distribute, and track marketing collateral and partner resources." subtitle="Securely manage, distribute, and track marketing collateral and partner resources."
@ -197,36 +235,14 @@ export const AssetsPage = () => {
<span>Global CDN Active</span> <span>Global CDN Active</span>
</div> </div>
} }
actions={
<>
{user?.role === 'ADMIN' && pendingRequestsCount > 0 && (
<button
onClick={() => setIsRequestsOpen(true)}
className="bg-ink-100 text-ink-900 border border-ink-300 font-bold py-1.5 px-3 rounded-lg hover:bg-ink-200 transition-all flex items-center justify-center gap-2 relative shadow-sm text-xs cursor-pointer"
>
<span>Download Requests</span>
<span className="w-4 h-4 rounded-full bg-ink-900 text-ink-0 text-[10px] flex items-center justify-center font-extrabold">
{pendingRequestsCount}
</span>
</button>
)}
{user?.role === 'ADMIN' && (
<button
onClick={() => setIsUploadOpen(true)}
className="group relative bg-ink-900 text-ink-0 font-bold py-1.5 px-3 rounded-lg hover:bg-ink-800 transition-all duration-300 overflow-hidden flex items-center justify-center gap-2 shadow-sm text-xs cursor-pointer"
>
<UploadCloud className="w-3.5 h-3.5" />
<span>Create / Upload Asset</span>
</button>
)}
</>
}
/> />
);
{/* Action Bar */} // Toolbar component
<motion.div variants={itemVariants} className="flex flex-col md:flex-row gap-4 items-center pt-2"> const toolbarNode = (
<div className="relative flex-1 w-full group"> <div className="flex flex-col md:flex-row gap-4 items-center justify-between p-3.5 bg-ink-0 border border-ink-200 rounded-xl shadow-sm">
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-2 flex-1 w-full max-w-2xl">
<div className="relative flex-1 group">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none"> <div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<Search className="w-4 h-4 text-ink-400 group-focus-within:text-ink-900 transition-colors" /> <Search className="w-4 h-4 text-ink-400 group-focus-within:text-ink-900 transition-colors" />
</div> </div>
@ -234,29 +250,61 @@ export const AssetsPage = () => {
type="text" type="text"
value={searchQuery} value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)} onChange={(e) => setSearchQuery(e.target.value)}
className="w-full bg-ink-0 border border-ink-200 rounded-lg py-1.5 pl-9 pr-4 text-xs font-semibold text-ink-900 placeholder-ink-400 outline-none transition-all focus:border-ink-900/50 focus:ring-2 ring-ink-900/10 shadow-sm hover:border-ink-300" className="w-full bg-ink-50 border border-ink-200 rounded-lg py-1.5 pl-9 pr-4 text-xs font-semibold text-ink-900 placeholder-ink-400 outline-none transition-all focus:border-ink-900/50 focus:ring-2 ring-ink-900/10 hover:border-ink-300 font-sans"
placeholder="Search by title, desc, tag, category, URL..." placeholder="Search by title, desc, tag, category..."
/> />
</div> </div>
<div className="flex gap-2 w-full md:w-auto overflow-x-auto pb-1 md:pb-0 scrollbar-none"> <div className="flex gap-1.5 overflow-x-auto pb-1 sm:pb-0 scrollbar-none">
{CATEGORIES.map((cat) => ( {CATEGORIES.map((cat) => (
<button <button
key={cat} key={cat}
onClick={() => setSelectedCategory(cat)} onClick={() => setSelectedCategory(cat)}
className={`px-3 py-1.5 rounded-lg border text-xs font-bold transition-all whitespace-nowrap shadow-sm cursor-pointer ${ className={`px-2.5 py-1.5 rounded-lg border text-[10px] uppercase tracking-wider font-bold transition-all whitespace-nowrap shadow-sm cursor-pointer ${
selectedCategory === cat selectedCategory === cat
? 'bg-ink-900 text-ink-0 border-ink-900' ? "bg-ink-900 text-ink-0 border-ink-900"
: 'bg-ink-0 text-ink-700 border-ink-200 hover:bg-ink-50' : "bg-ink-50 text-ink-700 border-ink-200 hover:bg-ink-100"
}`} }`}
> >
{cat} {cat}
</button> </button>
))} ))}
</div> </div>
</motion.div> </div>
{/* Grid Content */} <div className="flex items-center gap-2 shrink-0 w-full md:w-auto justify-end">
{user?.role === "ADMIN" && pendingRequestsCount > 0 && (
<Button
onClick={() => setIsRequestsOpen(true)}
variant="secondary"
size="sm"
icon={
<span className="w-4 h-4 rounded-full bg-ink-900 text-ink-0 text-[10px] flex items-center justify-center font-extrabold mr-0.5">
{pendingRequestsCount}
</span>
}
>
Download Requests
</Button>
)}
{user?.role === "ADMIN" && (
<Button
onClick={() => setIsUploadOpen(true)}
variant="primary"
size="sm"
icon={<UploadCloud className="w-3.5 h-3.5" />}
>
Create / Upload Asset
</Button>
)}
</div>
</div>
);
return (
<PageLayout header={headerNode} toolbar={toolbarNode}>
<div className="p-5 flex-1 min-h-0 overflow-y-auto">
{loading ? ( {loading ? (
<div className="py-20 flex justify-center items-center"> <div className="py-20 flex justify-center items-center">
<div className="w-8 h-8 border-4 border-ink-900/30 border-t-ink-900 rounded-full animate-spin" /> <div className="w-8 h-8 border-4 border-ink-900/30 border-t-ink-900 rounded-full animate-spin" />
@ -265,13 +313,20 @@ export const AssetsPage = () => {
<div className="py-12 text-center bg-ink-0 border border-ink-200 rounded-xl"> <div className="py-12 text-center bg-ink-0 border border-ink-200 rounded-xl">
<File className="w-12 h-12 text-ink-300 mx-auto mb-4" /> <File className="w-12 h-12 text-ink-300 mx-auto mb-4" />
<h3 className="text-lg font-bold text-ink-900">No assets found</h3> <h3 className="text-lg font-bold text-ink-900">No assets found</h3>
<p className="text-ink-500 text-sm mt-1">There are no assets matching your criteria.</p> <p className="text-ink-500 text-sm mt-1">
There are no assets matching your criteria.
</p>
</div> </div>
) : ( ) : (
<motion.div variants={itemVariants} className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-4 pt-2"> <motion.div
variants={containerVariants}
initial="hidden"
animate="show"
className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-4"
>
{filteredAssets.map((asset) => ( {filteredAssets.map((asset) => (
<motion.div key={asset.id} variants={itemVariants}>
<AssetCard <AssetCard
key={asset.id}
asset={asset} asset={asset}
user={user} user={user}
activeMenuId={activeMenuId} activeMenuId={activeMenuId}
@ -284,9 +339,11 @@ export const AssetsPage = () => {
onDownload={handleDownload} onDownload={handleDownload}
onRequestDownload={handleRequestDownload} onRequestDownload={handleRequestDownload}
/> />
</motion.div>
))} ))}
</motion.div> </motion.div>
)} )}
</div>
{/* Modals Container */} {/* Modals Container */}
<UploadAssetModal <UploadAssetModal
@ -344,8 +401,7 @@ export const AssetsPage = () => {
onApprove={handleApproveRequest} onApprove={handleApproveRequest}
onReject={handleRejectRequest} onReject={handleRejectRequest}
/> />
</PageLayout>
</motion.div>
); );
}; };

View File

@ -0,0 +1,308 @@
import React, { useState } from 'react';
import { Shield, FileText, CheckCircle, Clock, Download, ExternalLink, RefreshCw } from 'lucide-react';
import { useAuthStore } from '../hooks/use-auth';
import { useMyAcceptancesQuery } from '../hooks/use-legal-query';
import PageHeader from '../components/ui/PageHeader';
import Button from '../components/ui/Button';
import { PageLayout } from '../components/layout/PageLayout';
import Modal from '../components/ui/Modal';
export const ClientAgreementsPage: React.FC = () => {
const user = useAuthStore((state) => state.user);
const { data: acceptances, isLoading, refetch, isFetching } = useMyAcceptancesQuery();
const [selectedDoc, setSelectedDoc] = useState<'NDA' | 'MSA' | null>(null);
const ndaAcceptance = acceptances?.find(a => a.document.type === 'NDA');
const msaAcceptance = acceptances?.find(a => a.document.type === 'MSA');
const fileHost = (import.meta.env.VITE_API_URL || 'http://localhost:5000/api/v1').replace('/api/v1', '');
// Header component
const headerNode = (
<PageHeader
title="Compliance & Legal Agreements"
subtitle="Review and download your signed partnership agreements and compliance certificates."
badge={
<div className="flex items-center gap-1.5 px-2.5 py-0.5 rounded-full bg-emerald-50 border border-emerald-200 text-[10px] font-bold text-emerald-800 tracking-wider uppercase shrink-0">
<Shield className="w-3.5 h-3.5" />
<span>Compliance Certified</span>
</div>
}
/>
);
// Toolbar component
const toolbarNode = (
<div className="flex items-center justify-between p-3.5 bg-ink-0 border border-ink-200 rounded-xl shadow-sm">
<div className="flex items-center gap-2">
<span className="text-xs font-semibold text-ink-500">Last audited: today</span>
</div>
<button
onClick={() => refetch()}
disabled={isFetching}
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-ink-200 bg-ink-0 text-xs font-bold text-ink-700 hover:bg-ink-50 transition-all cursor-pointer disabled:opacity-50"
>
<RefreshCw className={`w-3.5 h-3.5 ${isFetching ? 'animate-spin' : ''}`} />
<span>Sync Ledger</span>
</button>
</div>
);
const activeDocData = selectedDoc === 'NDA' ? ndaAcceptance : msaAcceptance;
return (
<PageLayout header={headerNode} toolbar={toolbarNode}>
<div className="p-5 flex-1 min-h-0 overflow-y-auto flex flex-col gap-6">
{isLoading ? (
<div className="flex flex-col items-center justify-center py-20 flex-1">
<RefreshCw className="w-8 h-8 text-ink-900 animate-spin mb-3" />
<span className="text-sm font-semibold text-ink-500">Querying compliance ledger...</span>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* NDA Card */}
<div className="bg-ink-0 border border-ink-200 rounded-2xl p-5 shadow-sm flex flex-col justify-between hover:border-ink-400 transition-all duration-300">
<div>
<div className="flex items-center justify-between mb-4">
<div className="w-10 h-10 rounded-xl bg-ink-900 text-ink-0 flex items-center justify-center">
<FileText className="w-5 h-5" />
</div>
{ndaAcceptance ? (
<span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-bold bg-emerald-50 text-emerald-700 border border-emerald-250">
<CheckCircle className="w-3.5 h-3.5" />
Signed & Active
</span>
) : (
<span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-bold bg-amber-50 text-amber-700 border border-amber-250">
<Clock className="w-3.5 h-3.5" />
Pending Action
</span>
)}
</div>
<h3 className="text-base font-extrabold text-ink-900">Mutual Non-Disclosure Agreement (NDA)</h3>
<p className="text-xs text-ink-500 mt-1.5 leading-relaxed font-semibold">
Required to protect proprietary IP, silicon designs, and private data sharing during development.
</p>
{ndaAcceptance && (
<div className="mt-4 pt-4 border-t border-ink-100 space-y-2.5">
<div className="flex justify-between items-center text-xs">
<span className="font-semibold text-ink-500">Document Version</span>
<span className="font-bold text-ink-900">v{ndaAcceptance.document.version}</span>
</div>
<div className="flex justify-between items-center text-xs">
<span className="font-semibold text-ink-500">Accepted On</span>
<span className="font-bold text-ink-900">{new Date(ndaAcceptance.acceptedAt).toLocaleDateString()}</span>
</div>
<div className="flex justify-between items-center text-xs">
<span className="font-semibold text-ink-500">Signing IP</span>
<span className="font-mono text-ink-900 font-bold">{ndaAcceptance.ipAddress}</span>
</div>
<div className="flex flex-col gap-1 text-xs pt-1">
<span className="font-semibold text-ink-400 uppercase text-[9px] tracking-wider">Verification Hash</span>
<span className="font-mono text-[10px] text-ink-650 bg-ink-50 p-1.5 rounded border border-ink-150 break-all">
{ndaAcceptance.signatureHash || 'N/A'}
</span>
</div>
</div>
)}
</div>
{ndaAcceptance && (
<div className="mt-6 pt-4 border-t border-ink-100 flex gap-3">
<Button
onClick={() => setSelectedDoc('NDA')}
variant="secondary"
size="sm"
className="flex-1 justify-center"
icon={<ExternalLink className="w-3.5 h-3.5" />}
>
View Document
</Button>
{ndaAcceptance.documentUrl && (
<a
href={`${fileHost}${ndaAcceptance.documentUrl}`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center justify-center p-2 rounded-lg border border-ink-200 bg-ink-0 text-ink-700 hover:bg-ink-50 hover:border-ink-300 transition-all shadow-sm"
title="Download PDF"
>
<Download className="w-4 h-4" />
</a>
)}
</div>
)}
</div>
{/* MSA Card */}
<div className="bg-ink-0 border border-ink-200 rounded-2xl p-5 shadow-sm flex flex-col justify-between hover:border-ink-400 transition-all duration-300">
<div>
<div className="flex items-center justify-between mb-4">
<div className="w-10 h-10 rounded-xl bg-ink-900 text-ink-0 flex items-center justify-center">
<FileText className="w-5 h-5" />
</div>
{msaAcceptance ? (
<span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-bold bg-emerald-50 text-emerald-700 border border-emerald-250">
<CheckCircle className="w-3.5 h-3.5" />
Signed & Active
</span>
) : (
<span className="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-bold bg-amber-50 text-amber-700 border border-amber-250">
<Clock className="w-3.5 h-3.5" />
Pending Action
</span>
)}
</div>
<h3 className="text-base font-extrabold text-ink-900">Master Services Agreement (MSA)</h3>
<p className="text-xs text-ink-500 mt-1.5 leading-relaxed font-semibold">
Defines the commercial framework, SLA guidelines, and consulting provisions for the partnership.
</p>
{msaAcceptance && (
<div className="mt-4 pt-4 border-t border-ink-100 space-y-2.5">
<div className="flex justify-between items-center text-xs">
<span className="font-semibold text-ink-500">Document Version</span>
<span className="font-bold text-ink-900">v{msaAcceptance.document.version}</span>
</div>
<div className="flex justify-between items-center text-xs">
<span className="font-semibold text-ink-500">Accepted On</span>
<span className="font-bold text-ink-900">{new Date(msaAcceptance.acceptedAt).toLocaleDateString()}</span>
</div>
<div className="flex justify-between items-center text-xs">
<span className="font-semibold text-ink-500">Signing IP</span>
<span className="font-mono text-ink-900 font-bold">{msaAcceptance.ipAddress}</span>
</div>
<div className="flex flex-col gap-1 text-xs pt-1">
<span className="font-semibold text-ink-400 uppercase text-[9px] tracking-wider">Verification Hash</span>
<span className="font-mono text-[10px] text-ink-650 bg-ink-50 p-1.5 rounded border border-ink-150 break-all">
{msaAcceptance.signatureHash || 'N/A'}
</span>
</div>
</div>
)}
</div>
{msaAcceptance && (
<div className="mt-6 pt-4 border-t border-ink-100 flex gap-3">
<Button
onClick={() => setSelectedDoc('MSA')}
variant="secondary"
size="sm"
className="flex-1 justify-center"
icon={<ExternalLink className="w-3.5 h-3.5" />}
>
View Document
</Button>
{msaAcceptance.documentUrl && (
<a
href={`${fileHost}${msaAcceptance.documentUrl}`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center justify-center p-2 rounded-lg border border-ink-200 bg-ink-0 text-ink-700 hover:bg-ink-50 hover:border-ink-300 transition-all shadow-sm"
title="Download PDF"
>
<Download className="w-4 h-4" />
</a>
)}
</div>
)}
</div>
</div>
)}
</div>
{/* Document Viewer Modal */}
<Modal
isOpen={selectedDoc !== null}
onClose={() => setSelectedDoc(null)}
title={selectedDoc === 'NDA' ? 'Non-Disclosure Agreement (NDA)' : 'Master Services Agreement (MSA)'}
subtitle={activeDocData ? `Version ${activeDocData.document.version} — Cryptographically signed` : ''}
size="lg"
>
{activeDocData && (
<div className="space-y-6">
{activeDocData.documentUrl ? (
<div className="w-full h-[600px] border border-ink-200 rounded-xl overflow-hidden shadow-sm">
{activeDocData.documentUrl.toLowerCase().endsWith('.pdf') ? (
<iframe
src={`${fileHost}${activeDocData.documentUrl}`}
title="Signed Document"
className="w-full h-full border-0 bg-white"
/>
) : (
<div className="w-full h-full flex items-center justify-center bg-ink-50 overflow-auto p-4">
<img
src={`${fileHost}${activeDocData.documentUrl}`}
alt="Signed Document"
className="max-w-full max-h-full object-contain shadow-md rounded border border-ink-200"
/>
</div>
)}
</div>
) : (
<div className="space-y-6">
<div className="max-h-[450px] overflow-y-auto p-4 bg-ink-50 rounded-xl border border-ink-200 font-serif text-sm leading-relaxed text-ink-800 whitespace-pre-line">
{activeDocData.document.content}
</div>
<div className="p-4 border border-emerald-500 bg-emerald-500/5 rounded-xl space-y-2 text-xs">
<h4 className="font-bold text-emerald-800 tracking-wider uppercase">Signature Verification Ledger</h4>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2 text-ink-700">
<p><strong>Signed By:</strong> {user?.email}</p>
<p><strong>Signed On:</strong> {new Date(activeDocData.acceptedAt).toLocaleString()}</p>
<p><strong>IP Address:</strong> {activeDocData.ipAddress}</p>
<p className="sm:col-span-2"><strong>Verification Hash:</strong> <span className="font-mono text-[10px] break-all">{activeDocData.signatureHash}</span></p>
</div>
</div>
</div>
)}
<div className="flex justify-end gap-3 pt-4 border-t border-ink-100">
<Button variant="secondary" onClick={() => setSelectedDoc(null)}>
Close
</Button>
{activeDocData.documentUrl ? (
<a
href={`${fileHost}${activeDocData.documentUrl}`}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 rounded-lg bg-ink-900 px-4 py-2 text-xs font-semibold text-ink-0 hover:bg-ink-800 transition-all shadow-sm"
>
<Download className="w-4 h-4" />
<span>Download Signed PDF</span>
</a>
) : (
<Button
onClick={() => {
const element = document.createElement("a");
const file = new Blob([
`${selectedDoc === 'NDA' ? 'Non-Disclosure Agreement (NDA)' : 'Master Services Agreement (MSA)'}\n\n`,
activeDocData.document.content,
`\n\n=== DIGITAL SIGNATURE ===\n`,
`Signed By: ${user?.email}\n`,
`Verification Hash: ${activeDocData.signatureHash || 'N/A'}\n`,
`IP Address: ${activeDocData.ipAddress}\n`,
`Signed On: ${new Date(activeDocData.acceptedAt).toLocaleString()}\n`
], {type: 'text/plain'});
element.href = URL.createObjectURL(file);
element.download = `${selectedDoc}_Agreement_${user?.email?.split('@')[0]}.txt`;
document.body.appendChild(element);
element.click();
document.body.removeChild(element);
}}
variant="primary"
icon={<Download className="w-4 h-4" />}
>
Download Signed Copy
</Button>
)}
</div>
</div>
)}
</Modal>
</PageLayout>
);
};
export default ClientAgreementsPage;

View File

@ -4,6 +4,7 @@ import type { Variants } from 'framer-motion';
import { FolderKanban, FileSignature, Users, ArrowUpRight, Activity, Zap, ShieldCheck } from 'lucide-react'; import { FolderKanban, FileSignature, Users, ArrowUpRight, Activity, Zap, ShieldCheck } from 'lucide-react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { PageHeader } from '../components/ui/PageHeader'; import { PageHeader } from '../components/ui/PageHeader';
import { PageLayout } from '../components/layout/PageLayout';
const containerVariants: Variants = { const containerVariants: Variants = {
hidden: { opacity: 0 }, hidden: { opacity: 0 },
@ -57,8 +58,8 @@ export const DashboardPage = () => {
} }
]; ];
return ( // Header component
<motion.div variants={containerVariants} initial="hidden" animate="show" className="w-full space-y-6 text-ink-900 animate-fade-in"> const headerNode = (
<PageHeader <PageHeader
title={`Welcome back, ${user?.email?.split('@')[0]}`} title={`Welcome back, ${user?.email?.split('@')[0]}`}
subtitle={`You are authenticated as ${user?.role}. Manage your channel network, monitor compliance, and distribute assets globally.`} subtitle={`You are authenticated as ${user?.role}. Manage your channel network, monitor compliance, and distribute assets globally.`}
@ -69,7 +70,16 @@ export const DashboardPage = () => {
</div> </div>
} }
/> />
);
return (
<PageLayout header={headerNode}>
<motion.div
variants={containerVariants}
initial="hidden"
animate="show"
className="p-5 space-y-6 flex flex-col min-h-0 flex-1 overflow-y-auto"
>
{/* Stats Grid */} {/* Stats Grid */}
<motion.div variants={itemVariants} className="grid grid-cols-1 md:grid-cols-3 gap-4 pt-2"> <motion.div variants={itemVariants} className="grid grid-cols-1 md:grid-cols-3 gap-4 pt-2">
{[ {[
@ -121,6 +131,7 @@ export const DashboardPage = () => {
))} ))}
</motion.div> </motion.div>
</motion.div> </motion.div>
</PageLayout>
); );
}; };
export default DashboardPage; export default DashboardPage;

View File

@ -1,12 +1,14 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { useSearchParams, useNavigate } from 'react-router-dom'; import { useSearchParams, useNavigate } from 'react-router-dom';
import { motion } from 'framer-motion'; import { motion } from 'framer-motion';
import { Shield, CheckCircle, AlertCircle, ChevronRight, KeyRound } from 'lucide-react'; import { Shield, CheckCircle, AlertCircle, ChevronRight, KeyRound, Eye, EyeOff } from 'lucide-react';
import axios from 'axios'; import axios from 'axios';
import { axiosInstance } from '../services/axios'; import { axiosInstance } from '../services/axios';
import { useAuthStore } from '../hooks/use-auth'; import { useAuthStore } from '../hooks/use-auth';
import { useToast } from '../hooks/use-toast';
export const InvitePage: React.FC = () => { export const InvitePage: React.FC = () => {
const { success, error: toastError } = useToast();
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const token = searchParams.get('token'); const token = searchParams.get('token');
const navigate = useNavigate(); const navigate = useNavigate();
@ -16,6 +18,8 @@ export const InvitePage: React.FC = () => {
const [email, setEmail] = useState<string>(''); const [email, setEmail] = useState<string>('');
const [password, setPassword] = useState(''); const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState(''); const [confirmPassword, setConfirmPassword] = useState('');
const [showPassword, setShowPassword] = useState(false);
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@ -60,9 +64,12 @@ export const InvitePage: React.FC = () => {
}); });
setAuth(response.data); setAuth(response.data);
success("Account created successfully", "Please complete your partner onboarding profile.");
navigate('/onboarding'); navigate('/onboarding');
} catch (err: any) { } catch (err: any) {
setError(err.response?.data?.error || 'Failed to accept invite'); const errMsg = err.response?.data?.error || 'Failed to accept invite';
setError(errMsg);
toastError("Failed to accept invite", errMsg);
setIsSubmitting(false); setIsSubmitting(false);
} }
}; };
@ -130,13 +137,20 @@ export const InvitePage: React.FC = () => {
<KeyRound className="w-4 h-4 text-ink-400" /> <KeyRound className="w-4 h-4 text-ink-400" />
</div> </div>
<input <input
type="password" type={showPassword ? 'text' : 'password'}
value={password} value={password}
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
className="w-full pl-11 pr-4 py-3.5 bg-ink-50 border border-ink-200 rounded-xl text-sm font-medium focus:ring-4 focus:ring-ink-900/10 outline-none transition-all placeholder-ink-400 text-ink-900" className="w-full pl-11 pr-10 py-3.5 bg-ink-50 border border-ink-200 rounded-xl text-sm font-medium focus:ring-4 focus:ring-ink-900/10 outline-none transition-all placeholder-ink-400 text-ink-900"
placeholder="Enter a secure password" placeholder="Enter a secure password"
required required
/> />
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute inset-y-0 right-0 pr-3.5 flex items-center text-ink-400 hover:text-ink-900 cursor-pointer"
>
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div> </div>
</div> </div>
@ -147,13 +161,20 @@ export const InvitePage: React.FC = () => {
<CheckCircle className="w-4 h-4 text-ink-400" /> <CheckCircle className="w-4 h-4 text-ink-400" />
</div> </div>
<input <input
type="password" type={showConfirmPassword ? 'text' : 'password'}
value={confirmPassword} value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)} onChange={(e) => setConfirmPassword(e.target.value)}
className="w-full pl-11 pr-4 py-3.5 bg-ink-50 border border-ink-200 rounded-xl text-sm font-medium focus:ring-4 focus:ring-ink-900/10 outline-none transition-all placeholder-ink-400 text-ink-900" className="w-full pl-11 pr-10 py-3.5 bg-ink-50 border border-ink-200 rounded-xl text-sm font-medium focus:ring-4 focus:ring-ink-900/10 outline-none transition-all placeholder-ink-400 text-ink-900"
placeholder="Confirm your password" placeholder="Confirm your password"
required required
/> />
<button
type="button"
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
className="absolute inset-y-0 right-0 pr-3.5 flex items-center text-ink-400 hover:text-ink-900 cursor-pointer"
>
{showConfirmPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div> </div>
</div> </div>

View File

@ -1,21 +1,25 @@
import { useForm } from 'react-hook-form'; import { useForm } from "react-hook-form";
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from "@hookform/resolvers/zod";
import { z } from 'zod'; import { z } from "zod";
import { loginUser } from '../services/auth-api'; import { loginUser } from "../services/auth-api";
import { useAuthStore } from '../hooks/use-auth'; import { useAuthStore } from "../hooks/use-auth";
import { useNavigate } from 'react-router-dom'; import { useNavigate } from "react-router-dom";
import { useState } from 'react'; import { useState } from "react";
import { Hexagon, Lock, Mail, ArrowRight, Eye, EyeOff } from 'lucide-react'; import { Hexagon, Lock, Mail, ArrowRight, Eye, EyeOff } from "lucide-react";
import { motion } from 'framer-motion'; import { motion } from "framer-motion";
import { useToast } from "../hooks/use-toast";
const loginSchema = z.object({ const loginSchema = z.object({
email: z.string().email({ message: 'Invalid email address' }), email: z.string().email({ message: "Invalid email address" }),
password: z.string().min(6, { message: 'Password must be at least 6 characters' }), password: z
.string()
.min(6, { message: "Password must be at least 6 characters" }),
}); });
type LoginFormValues = z.infer<typeof loginSchema>; type LoginFormValues = z.infer<typeof loginSchema>;
export const LoginPage = () => { export const LoginPage = () => {
const { success, error: toastError } = useToast();
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [showPassword, setShowPassword] = useState(false); const [showPassword, setShowPassword] = useState(false);
const setAuth = useAuthStore((state) => state.setAuth); const setAuth = useAuthStore((state) => state.setAuth);
@ -32,106 +36,166 @@ export const LoginPage = () => {
const onSubmit = async (data: LoginFormValues) => { const onSubmit = async (data: LoginFormValues) => {
try { try {
setError(null); setError(null);
const response = await loginUser({ email: data.email, password: data.password }); const response = await loginUser({
email: data.email,
password: data.password,
});
setAuth(response); setAuth(response);
if (response.user.role === 'ADMIN') { success("Successfully signed in", `Welcome back, ${response.user.email}!`);
navigate('/admin'); if (response.user.role === "ADMIN") {
navigate("/admin");
} else { } else {
navigate('/client'); navigate("/client");
} }
} catch (err: any) { } catch (err: any) {
setError(err.response?.data?.error || 'Authentication failed. Verify credentials.'); const errMsg = err.response?.data?.error || "Authentication failed. Verify credentials.";
setError(errMsg);
toastError("Authentication failed", errMsg);
} }
}; };
return ( return (
<div className="min-h-screen bg-ink-50 flex flex-col justify-center items-center p-4 relative overflow-hidden font-sans transition-colors duration-500"> <div className="min-h-screen bg-ink-50 flex flex-col justify-between items-center p-6 md:p-12 relative overflow-hidden font-sans transition-colors duration-500">
{/* Monochromatic Soft Background Blurs */} {/* Monochromatic Soft Background Blurs */}
<div className="absolute top-1/4 left-1/4 w-[500px] h-[500px] bg-ink-900/5 rounded-full blur-[150px] pointer-events-none" /> <div className="absolute top-1/4 left-1/4 w-[600px] h-[600px] bg-ink-900/5 rounded-full blur-[150px] pointer-events-none" />
<div className="absolute bottom-1/4 right-1/4 w-[600px] h-[600px] bg-ink-900/5 rounded-full blur-[150px] pointer-events-none" /> <div className="absolute bottom-1/4 right-1/4 w-[700px] h-[700px] bg-ink-900/5 rounded-full blur-[150px] pointer-events-none" />
{/* Spacer to push content down slightly on desktop for better centering */}
<div className="hidden md:block h-6" />
<motion.div <motion.div
initial={{ opacity: 0, y: 30 }} initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ duration: 1, ease: [0.16, 1, 0.3, 1] }} transition={{ duration: 1, ease: [0.16, 1, 0.3, 1] }}
className="w-full max-w-md relative z-10" className="w-full max-w-5xl relative z-10 grid grid-cols-1 md:grid-cols-12 gap-12 md:gap-16 items-center my-auto"
> >
<div className="bg-ink-0 border border-ink-200 rounded-[2rem] shadow-2xl p-12 relative overflow-hidden"> {/* Left Column - Partner Explanation */}
<div className="md:col-span-6 lg:col-span-7 flex flex-col justify-center text-left">
<h1 className="text-3xl lg:text-4xl font-extrabold text-ink-900 tracking-tight leading-tight">
<span className="gradient-text">Tech4Biz Channel Partner</span>
</h1>
<p className="text-ink-500 text-sm mt-5 font-medium leading-relaxed max-w-lg">
Expand your business by partnering with Tech4Biz and unlock new
opportunities for growth through our innovative technology
solutions. Join our partner network to access exclusive resources,
dedicated support, and a platform designed to help you succeed.
</p>
</div>
{/* Right Column - Login Component */}
<div className="md:col-span-6 lg:col-span-5 w-full max-w-sm justify-self-center md:justify-self-end">
<div className="bg-ink-0 border border-ink-200 rounded-2xl shadow-xl p-8 md:p-10 relative overflow-hidden">
<div className="absolute top-0 inset-x-0 h-px bg-gradient-to-r from-transparent via-ink-300 to-transparent" /> <div className="absolute top-0 inset-x-0 h-px bg-gradient-to-r from-transparent via-ink-300 to-transparent" />
<div className="flex flex-col items-center mb-12 text-center"> <div className="flex flex-col items-center mb-8 text-center">
<div className="relative flex items-center justify-center w-20 h-20 rounded-[1.5rem] bg-gradient-to-tr from-ink-900 to-ink-800 shadow-lg mb-8"> <div className="relative flex items-center justify-center w-16 h-16 rounded-2xl bg-gradient-to-tr from-ink-900 to-ink-800 shadow-md mb-6">
<Hexagon className="text-ink-0 w-10 h-10 absolute" /> <Hexagon className="text-ink-0 w-8 h-8 absolute" />
</div> </div>
<h2 className="text-3xl font-extrabold text-ink-900 tracking-tight">Channel Portal</h2> <h2 className="text-2xl font-bold text-ink-900 tracking-tight">
<p className="text-ink-500 text-sm mt-3 font-medium uppercase tracking-widest">Authorized Access Only</p> Channel Portal
</h2>
<p className="text-ink-500 text-[10px] mt-1.5 font-bold uppercase tracking-wider">
Authorized Access Only
</p>
</div> </div>
{error && ( {error && (
<motion.div initial={{ opacity: 0, scale: 0.95 }} animate={{ opacity: 1, scale: 1 }} className="bg-red-500/10 border border-red-500/20 text-red-600 dark:text-red-400 p-4 rounded-xl text-sm mb-8 flex items-center gap-3 font-medium shadow-sm"> <motion.div
<div className="w-2 h-2 rounded-full bg-red-500 shadow-[0_0_10px_rgba(239,68,68,0.5)]" /> initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
className="bg-red-500/10 border border-red-500/20 text-red-600 dark:text-red-400 p-3 rounded-lg text-xs mb-6 flex items-center gap-2.5 font-medium shadow-sm font-sans"
>
<div className="w-1.5 h-1.5 rounded-full bg-red-500 shadow-[0_0_8px_rgba(239,68,68,0.5)] shrink-0" />
{error} {error}
</motion.div> </motion.div>
)} )}
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6"> <form onSubmit={handleSubmit(onSubmit)} className="space-y-5">
<div className="space-y-2"> <div className="space-y-1.5">
<label className="block text-[11px] font-bold text-ink-500 uppercase tracking-widest">Work Email</label> <label className="block text-[10px] font-bold text-ink-500 uppercase tracking-wider">
Work Email
</label>
<div className="relative group"> <div className="relative group">
<div className="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none"> <div className="absolute inset-y-0 left-0 pl-3.5 flex items-center pointer-events-none">
<Mail className={`w-5 h-5 transition-colors ${errors.email ? 'text-red-400' : 'text-ink-400 group-focus-within:text-ink-900'}`} /> <Mail
className={`w-4 h-4 transition-colors ${errors.email ? "text-red-400" : "text-ink-400 group-focus-within:text-ink-900"}`}
/>
</div> </div>
<input <input
type="email" type="email"
{...register('email')} {...register("email")}
className={`w-full bg-ink-50 border ${errors.email ? 'border-red-500/50 focus:border-red-500' : 'border-ink-200 focus:border-ink-900/50'} rounded-2xl py-4 pl-12 pr-4 text-ink-900 placeholder-ink-400 outline-none transition-all focus:bg-ink-0 focus:ring-4 ring-ink-900/10 font-medium`} className={`w-full bg-ink-50 border ${errors.email ? "border-red-500/50 focus:border-red-500" : "border-ink-200 focus:border-ink-900/50"} rounded-xl py-2.5 pl-11 pr-4 text-xs font-medium text-ink-900 placeholder-ink-400 outline-none transition-all focus:bg-ink-0 focus:ring-4 ring-ink-900/10 font-sans`}
placeholder="admin@tech4biz.com" placeholder="admin@tech4biz.com"
/> />
</div> </div>
{errors.email && <p className="text-red-500 dark:text-red-400 text-xs mt-1.5 font-bold">{errors.email.message}</p>} {errors.email && (
<p className="text-red-500 dark:text-red-400 text-[10px] mt-1 font-bold font-sans">
{errors.email.message}
</p>
)}
</div> </div>
<div className="space-y-2"> <div className="space-y-1.5">
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<label className="block text-[11px] font-bold text-ink-500 uppercase tracking-widest">Password</label> <label className="block text-[10px] font-bold text-ink-500 uppercase tracking-wider">
<a href="#" className="text-[11px] font-bold text-ink-500 hover:text-ink-900 transition-colors tracking-wider">RECOVERY?</a> Password
</label>
<a
href="#"
className="text-[10px] font-bold text-ink-500 hover:text-ink-900 transition-colors tracking-wider"
>
RECOVERY?
</a>
</div> </div>
<div className="relative group"> <div className="relative group">
<div className="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none"> <div className="absolute inset-y-0 left-0 pl-3.5 flex items-center pointer-events-none">
<Lock className={`w-5 h-5 transition-colors ${errors.password ? 'text-red-400' : 'text-ink-400 group-focus-within:text-ink-900'}`} /> <Lock
className={`w-4 h-4 transition-colors ${errors.password ? "text-red-400" : "text-ink-400 group-focus-within:text-ink-900"}`}
/>
</div> </div>
<input <input
type={showPassword ? 'text' : 'password'} type={showPassword ? "text" : "password"}
{...register('password')} {...register("password")}
className={`w-full bg-ink-50 border ${errors.password ? 'border-red-500/50 focus:border-red-500' : 'border-ink-200 focus:border-ink-900/50'} rounded-2xl py-4 pl-12 pr-12 text-ink-900 placeholder-ink-400 outline-none transition-all focus:bg-ink-0 focus:ring-4 ring-ink-900/10 font-medium`} className={`w-full bg-ink-50 border ${errors.password ? "border-red-500/50 focus:border-red-500" : "border-ink-200 focus:border-ink-900/50"} rounded-xl py-2.5 pl-11 pr-11 text-xs font-medium text-ink-900 placeholder-ink-400 outline-none transition-all focus:bg-ink-0 focus:ring-4 ring-ink-900/10 font-sans`}
placeholder="••••••••" placeholder="••••••••"
/> />
<button <button
type="button" type="button"
onClick={() => setShowPassword(!showPassword)} onClick={() => setShowPassword(!showPassword)}
className="absolute inset-y-0 right-0 pr-4 flex items-center text-ink-400 hover:text-ink-950 focus:outline-none transition-colors" className="absolute inset-y-0 right-0 pr-3.5 flex items-center text-ink-400 hover:text-ink-950 focus:outline-none transition-colors"
> >
{showPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />} {showPassword ? (
<EyeOff className="w-4 h-4" />
) : (
<Eye className="w-4 h-4" />
)}
</button> </button>
</div> </div>
{errors.password && <p className="text-red-500 dark:text-red-400 text-xs mt-1.5 font-bold">{errors.password.message}</p>} {errors.password && (
<p className="text-red-500 dark:text-red-400 text-[10px] mt-1 font-bold font-sans">
{errors.password.message}
</p>
)}
</div> </div>
<button <button
type="submit" type="submit"
disabled={isSubmitting} disabled={isSubmitting}
className="group relative w-full bg-ink-900 text-ink-0 font-extrabold tracking-wide py-4 px-4 rounded-2xl hover:bg-ink-800 transition-all duration-300 disabled:opacity-70 disabled:cursor-not-allowed mt-4 overflow-hidden flex items-center justify-center gap-3 shadow-lg" className="group relative w-full bg-ink-900 text-ink-0 font-bold text-xs uppercase tracking-wider py-2.5 px-4 rounded-xl hover:bg-ink-800 transition-all duration-300 disabled:opacity-70 disabled:cursor-not-allowed mt-4 overflow-hidden flex items-center justify-center gap-2 shadow-md font-sans cursor-pointer"
> >
{isSubmitting ? 'AUTHENTICATING...' : 'SECURE SIGN IN'} {isSubmitting ? "AUTHENTICATING..." : "SECURE SIGN IN"}
{!isSubmitting && <ArrowRight className="w-5 h-5 group-hover:translate-x-1.5 transition-transform" />} {!isSubmitting && (
<ArrowRight className="w-4 h-4 group-hover:translate-x-1 transition-transform" />
)}
</button> </button>
</form> </form>
</div> </div>
</div>
</motion.div>
<p className="text-center text-ink-400 text-xs mt-10 font-bold tracking-widest uppercase"> <p className="text-center text-ink-400 text-[10px] font-bold tracking-wider uppercase mt-8 relative z-10">
© 2026 Tech4Biz Solutions. © 2026 Tech4Biz Solutions.
</p> </p>
</motion.div>
</div> </div>
); );
}; };

View File

@ -10,10 +10,12 @@ import {
uploadSignedLegalDocument, uploadSignedLegalDocument,
signLegalDocument signLegalDocument
} from '../services/legal-api'; } from '../services/legal-api';
import { useToast } from '../hooks/use-toast';
type DocumentType = 'NDA' | 'MSA'; type DocumentType = 'NDA' | 'MSA';
export const OnboardingPage: React.FC = () => { export const OnboardingPage: React.FC = () => {
const { success, error: toastError } = useToast();
const { user, checkAuth } = useAuthStore(); const { user, checkAuth } = useAuthStore();
const navigate = useNavigate(); const navigate = useNavigate();
const [step, setStep] = useState(1); const [step, setStep] = useState(1);
@ -191,8 +193,10 @@ export const OnboardingPage: React.FC = () => {
if (type === 'NDA') setNdaUploadUrl(data.url); if (type === 'NDA') setNdaUploadUrl(data.url);
if (type === 'MSA') setMsaUploadUrl(data.url); if (type === 'MSA') setMsaUploadUrl(data.url);
} catch (error) { success("Document uploaded successfully", `Signed ${type} has been uploaded.`);
console.error(`Failed to upload ${type}:`, error); } catch (err: any) {
console.error(`Failed to upload ${type}:`, err);
toastError("Upload failed", err.response?.data?.error || `Failed to upload signed ${type}.`);
} finally { } finally {
setIsSubmitting(false); setIsSubmitting(false);
} }
@ -210,14 +214,17 @@ export const OnboardingPage: React.FC = () => {
documentUrl documentUrl
}); });
success(`${type} Agreement signed`, `Your signature on the ${type} has been registered.`);
if (type === 'NDA') { if (type === 'NDA') {
setStep(2); setStep(2);
} else { } else {
await checkAuth(); await checkAuth();
setStep(3); // PENDING_APPROVAL setStep(3); // PENDING_APPROVAL
} }
} catch (error) { } catch (err: any) {
console.error(`Failed to submit ${type}:`, error); console.error(`Failed to submit ${type}:`, err);
toastError("Submission failed", err.response?.data?.error || `Failed to submit signed ${type}.`);
} finally { } finally {
setIsSubmitting(false); setIsSubmitting(false);
} }

View File

@ -1,18 +1,61 @@
import React from 'react'; import React, { useState } from 'react';
import { CheckCircle, Clock, Search, XCircle } from 'lucide-react'; import { CheckCircle, Clock, Search, XCircle, Eye } from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion'; import { motion, AnimatePresence } from 'framer-motion';
import { usePendingPartnersQuery } from '../../hooks/use-legal-query'; import { usePendingPartnersQuery } from '../../hooks/use-legal-query';
import { useApprovePartnerMutation } from '../../hooks/use-legal-mutation'; import { useApprovePartnerMutation } from '../../hooks/use-legal-mutation';
import PageHeader from '../../components/ui/PageHeader'; import PageHeader from '../../components/ui/PageHeader';
import Button from '../../components/ui/Button';
import { useToast } from '../../hooks/use-toast';
import { DocumentPreviewModal } from '../../components/ui/DocumentPreviewModal';
import { PageLayout } from '../../components/layout/PageLayout';
export const ApprovalsPage: React.FC = () => { export const ApprovalsPage: React.FC = () => {
const { success, error } = useToast();
const { data: partners = [], isLoading } = usePendingPartnersQuery(); const { data: partners = [], isLoading } = usePendingPartnersQuery();
const approveMutation = useApprovePartnerMutation(); const approveMutation = useApprovePartnerMutation();
const [searchTerm, setSearchTerm] = useState('');
const approvePartner = (partnerId: string) => { // Preview Modal state
approveMutation.mutate(partnerId); const [selectedPartner, setSelectedPartner] = useState<any | null>(null);
const [isPreviewOpen, setIsPreviewOpen] = useState(false);
const [verifiedDocs, setVerifiedDocs] = useState<Record<string, { nda: boolean; msa: boolean }>>({});
const approvePartner = (partnerId: string, email: string) => {
approveMutation.mutate(partnerId, {
onSuccess: () => {
success("Partner access approved", `Access has been granted to ${email}.`);
setIsPreviewOpen(false);
setSelectedPartner(null);
},
onError: (err: any) => {
error("Approval failed", err.response?.data?.error || "Could not approve partner access.");
}
});
}; };
const handleOpenPreview = (partner: any, _docType: 'NDA' | 'MSA') => {
setSelectedPartner(partner);
setIsPreviewOpen(true);
};
const handleVerify = (partnerId: string, docType: 'NDA' | 'MSA') => {
setVerifiedDocs(prev => {
const partnerStatus = prev[partnerId] || { nda: false, msa: false };
return {
...prev,
[partnerId]: {
...partnerStatus,
[docType === 'NDA' ? 'nda' : 'msa']: true
}
};
});
success(`${docType} document signature verified.`, "Verification status updated.");
};
const filteredPartners = partners.filter(p =>
p.email.toLowerCase().includes(searchTerm.toLowerCase())
);
if (isLoading) { if (isLoading) {
return ( return (
<div className="flex-1 flex items-center justify-center min-h-[60vh]"> <div className="flex-1 flex items-center justify-center min-h-[60vh]">
@ -21,8 +64,8 @@ export const ApprovalsPage: React.FC = () => {
); );
} }
return ( // Header component
<div className="w-full space-y-6 animate-fade-in text-ink-900"> const headerNode = (
<PageHeader <PageHeader
title="Approvals Queue" title="Approvals Queue"
subtitle="Review and approve partner legal documents to grant platform access." subtitle="Review and approve partner legal documents to grant platform access."
@ -31,36 +74,46 @@ export const ApprovalsPage: React.FC = () => {
{partners.length} Pending {partners.length} Pending
</span> </span>
} }
actions={ />
<div className="relative"> );
// Toolbar component
const toolbarNode = (
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3.5 p-3.5 bg-ink-0 border border-ink-200 rounded-xl shadow-sm">
<div className="flex items-center gap-2 flex-1 max-w-md">
<div className="relative flex-1">
<Search className="w-4 h-4 text-ink-400 absolute left-3 top-1/2 -translate-y-1/2" /> <Search className="w-4 h-4 text-ink-400 absolute left-3 top-1/2 -translate-y-1/2" />
<input <input
type="text" type="text"
placeholder="Search pending partners..." value={searchTerm}
className="pl-9 pr-4 py-1.5 bg-ink-0 border border-ink-200 rounded-lg text-xs focus:outline-none focus:ring-2 focus:ring-ink-900/10 text-ink-900 placeholder-ink-400 w-full sm:w-64 shadow-sm" onChange={(e) => setSearchTerm(e.target.value)}
placeholder="Search pending partners by email..."
className="w-full pl-9 pr-4 py-1.5 bg-ink-50 border border-ink-200 rounded-lg text-xs focus:outline-none focus:ring-2 focus:ring-ink-900/10 text-ink-900 placeholder-ink-400 font-semibold"
/> />
</div> </div>
} </div>
/> </div>
);
{/* List */} return (
<div className="bg-ink-0 rounded-xl border border-ink-200 overflow-hidden shadow-sm"> <PageLayout header={headerNode} toolbar={toolbarNode}>
<div className="overflow-x-auto"> {/* List Container */}
<div className="flex-1 w-full overflow-x-auto">
<table className="w-full text-left text-sm whitespace-nowrap"> <table className="w-full text-left text-sm whitespace-nowrap">
<thead className="bg-ink-50 border-b border-ink-200 text-ink-500 font-bold uppercase tracking-wider text-xs"> <thead className="bg-ink-50 border-b border-ink-200 text-ink-500 font-bold uppercase tracking-wider text-xs sticky top-0 z-10">
<tr> <tr>
<th className="px-4 py-2.5">Partner</th> <th className="px-5 py-3">Partner</th>
<th className="px-4 py-2.5">NDA Status</th> <th className="px-5 py-3">NDA Document</th>
<th className="px-4 py-2.5">MSA Status</th> <th className="px-5 py-3">MSA Document</th>
<th className="px-4 py-2.5 text-right">Actions</th> <th className="px-5 py-3 text-right">Actions</th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-ink-200"> <tbody className="divide-y divide-ink-200 bg-ink-0">
<AnimatePresence> <AnimatePresence>
{partners.length === 0 ? ( {filteredPartners.length === 0 ? (
<tr> <tr>
<td colSpan={4} className="px-4 py-8 text-center"> <td colSpan={4} className="px-5 py-12 text-center">
<div className="w-12 h-12 rounded-full bg-ink-100 flex items-center justify-center mx-auto mb-4"> <div className="w-12 h-12 rounded-full bg-ink-50 flex items-center justify-center mx-auto mb-4 border border-ink-200">
<CheckCircle className="w-6 h-6 text-ink-900" /> <CheckCircle className="w-6 h-6 text-ink-900" />
</div> </div>
<p className="text-ink-900 font-bold text-sm">Queue is empty</p> <p className="text-ink-900 font-bold text-sm">Queue is empty</p>
@ -68,10 +121,13 @@ export const ApprovalsPage: React.FC = () => {
</td> </td>
</tr> </tr>
) : ( ) : (
partners.map(partner => { filteredPartners.map(partner => {
const nda = partner.acceptances.find(a => a.document.type === 'NDA'); const nda = partner.acceptances.find(a => a.document.type === 'NDA');
const msa = partner.acceptances.find(a => a.document.type === 'MSA'); const msa = partner.acceptances.find(a => a.document.type === 'MSA');
const partnerVerified = verifiedDocs[partner.id] || { nda: false, msa: false };
const isEligible = partnerVerified.nda && partnerVerified.msa;
return ( return (
<motion.tr <motion.tr
key={partner.id} key={partner.id}
@ -79,7 +135,7 @@ export const ApprovalsPage: React.FC = () => {
exit={{ opacity: 0, x: -20, backgroundColor: 'rgba(0, 0, 0, 0.02)' }} exit={{ opacity: 0, x: -20, backgroundColor: 'rgba(0, 0, 0, 0.02)' }}
className="hover:bg-ink-50 transition-colors group" className="hover:bg-ink-50 transition-colors group"
> >
<td className="px-4 py-3"> <td className="px-5 py-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-gradient-to-br from-ink-900 to-ink-800 flex items-center justify-center text-ink-0 font-bold text-xs shadow-md"> <div className="w-8 h-8 rounded-full bg-gradient-to-br from-ink-900 to-ink-800 flex items-center justify-center text-ink-0 font-bold text-xs shadow-md">
{partner.email.charAt(0).toUpperCase()} {partner.email.charAt(0).toUpperCase()}
@ -93,15 +149,22 @@ export const ApprovalsPage: React.FC = () => {
</div> </div>
</div> </div>
</td> </td>
<td className="px-4 py-3"> <td className="px-5 py-4">
{nda ? ( {nda ? (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<CheckCircle className="w-4 h-4 text-ink-900" /> <CheckCircle className="w-4 h-4 text-emerald-600" />
<span className="text-xs font-bold text-ink-900 bg-ink-100 border border-ink-200 px-2 py-0.5 rounded-md"> <span className="text-xs font-bold text-ink-900 bg-ink-100 border border-ink-200 px-2 py-0.5 rounded-md">
{nda.documentUrl ? 'Uploaded PDF' : 'Digital Sign'} {nda.documentUrl ? 'Uploaded PDF' : 'Digital Sign'}
</span> </span>
{nda.documentUrl && ( <button
<a href={nda.documentUrl} target="_blank" rel="noreferrer" className="text-xs font-bold text-ink-600 hover:text-ink-900 underline ml-2">View</a> onClick={() => handleOpenPreview(partner, 'NDA')}
className="inline-flex items-center gap-1.5 text-xs font-bold text-ink-700 hover:text-ink-900 cursor-pointer ml-2 bg-ink-50 hover:bg-ink-100 border border-ink-200 px-2.5 py-1 rounded-lg transition-all"
>
<Eye className="w-3.5 h-3.5" />
<span>Preview</span>
</button>
{partnerVerified.nda && (
<span className="text-[10px] text-emerald-600 font-bold uppercase tracking-wider ml-1 bg-emerald-50 px-1.5 py-0.5 rounded border border-emerald-200">Verified</span>
)} )}
</div> </div>
) : ( ) : (
@ -111,15 +174,22 @@ export const ApprovalsPage: React.FC = () => {
</div> </div>
)} )}
</td> </td>
<td className="px-4 py-3"> <td className="px-5 py-4">
{msa ? ( {msa ? (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<CheckCircle className="w-4 h-4 text-ink-900" /> <CheckCircle className="w-4 h-4 text-emerald-600" />
<span className="text-xs font-bold text-ink-900 bg-ink-100 border border-ink-200 px-2 py-0.5 rounded-md"> <span className="text-xs font-bold text-ink-900 bg-ink-100 border border-ink-200 px-2 py-0.5 rounded-md">
{msa.documentUrl ? 'Uploaded PDF' : 'Digital Sign'} {msa.documentUrl ? 'Uploaded PDF' : 'Digital Sign'}
</span> </span>
{msa.documentUrl && ( <button
<a href={msa.documentUrl} target="_blank" rel="noreferrer" className="text-xs font-bold text-ink-600 hover:text-ink-900 underline ml-2">View</a> onClick={() => handleOpenPreview(partner, 'MSA')}
className="inline-flex items-center gap-1.5 text-xs font-bold text-ink-700 hover:text-ink-900 cursor-pointer ml-2 bg-ink-50 hover:bg-ink-100 border border-ink-200 px-2.5 py-1 rounded-lg transition-all"
>
<Eye className="w-3.5 h-3.5" />
<span>Preview</span>
</button>
{partnerVerified.msa && (
<span className="text-[10px] text-emerald-600 font-bold uppercase tracking-wider ml-1 bg-emerald-50 px-1.5 py-0.5 rounded border border-emerald-200">Verified</span>
)} )}
</div> </div>
) : ( ) : (
@ -129,14 +199,15 @@ export const ApprovalsPage: React.FC = () => {
</div> </div>
)} )}
</td> </td>
<td className="px-4 py-3 text-right"> <td className="px-5 py-4 text-right">
<button <Button
onClick={() => approvePartner(partner.id)} onClick={() => approvePartner(partner.id, partner.email)}
disabled={!nda || !msa || (approveMutation.isPending && approveMutation.variables === partner.id)} disabled={!isEligible || (approveMutation.isPending && approveMutation.variables === partner.id)}
className="px-3 py-1.5 bg-ink-900 text-ink-0 text-xs font-bold rounded-lg hover:bg-ink-800 transition-colors shadow-sm disabled:opacity-50 disabled:cursor-not-allowed" variant="primary"
size="sm"
> >
{approveMutation.isPending && approveMutation.variables === partner.id ? 'Approving...' : 'Approve Access'} {approveMutation.isPending && approveMutation.variables === partner.id ? 'Approving...' : 'Approve Access'}
</button> </Button>
</td> </td>
</motion.tr> </motion.tr>
); );
@ -146,8 +217,27 @@ export const ApprovalsPage: React.FC = () => {
</tbody> </tbody>
</table> </table>
</div> </div>
</div>
</div> {/* Document Preview Modal */}
{selectedPartner && (
<DocumentPreviewModal
isOpen={isPreviewOpen}
onClose={() => {
setIsPreviewOpen(false);
setSelectedPartner(null);
}}
partnerId={selectedPartner.id}
partnerEmail={selectedPartner.email}
partnerCreatedAt={selectedPartner.createdAt}
acceptances={selectedPartner.acceptances}
verifiedDocs={verifiedDocs[selectedPartner.id] || { nda: false, msa: false }}
onVerify={(docType) => handleVerify(selectedPartner.id, docType)}
onApprovePartner={() => approvePartner(selectedPartner.id, selectedPartner.email)}
isApproving={approveMutation.isPending && approveMutation.variables === selectedPartner.id}
/>
)}
</PageLayout>
); );
}; };
export default ApprovalsPage; export default ApprovalsPage;

View File

@ -1,47 +1,76 @@
import React, { useState } from 'react'; import React, { useState } from "react";
import { Users, Mail, CheckCircle, AlertCircle, ChevronRight, UserPlus, Clock, ShieldCheck, RefreshCw } from 'lucide-react'; import {
import { motion, AnimatePresence } from 'framer-motion'; Users,
import { Link } from 'react-router-dom'; Mail,
import { usePartnersQuery } from '../../hooks/use-partners-query'; CheckCircle,
import { useInvitePartnerMutation } from '../../hooks/use-partners-mutation'; AlertCircle,
import PageHeader from '../../components/ui/PageHeader'; ChevronRight,
UserPlus,
Clock,
ShieldCheck,
RefreshCw,
Search,
} from "lucide-react";
import { motion } from "framer-motion";
import { Link } from "react-router-dom";
import { usePartnersQuery } from "../../hooks/use-partners-query";
import { useInvitePartnerMutation } from "../../hooks/use-partners-mutation";
import PageHeader from "../../components/ui/PageHeader";
import Button from "../../components/ui/Button";
import Modal from "../../components/ui/Modal";
import { useToast } from "../../hooks/use-toast";
import { PageLayout } from "../../components/layout/PageLayout";
const STATUS_CONFIG: Record<string, { label: string; color: string; bg: string; border: string }> = { const STATUS_CONFIG: Record<
string,
{ label: string; color: string; bg: string; border: string }
> = {
PENDING_ONBOARDING: { PENDING_ONBOARDING: {
label: 'Pending Onboarding', label: "Pending Onboarding",
color: 'text-ink-500', color: "text-ink-500",
bg: 'bg-ink-50', bg: "bg-ink-50",
border: 'border-ink-200', border: "border-ink-200",
}, },
PENDING_APPROVAL: { PENDING_APPROVAL: {
label: 'Awaiting Approval', label: "Awaiting Approval",
color: 'text-ink-0 bg-ink-900', color: "text-ink-0 bg-ink-900",
bg: 'bg-ink-900', bg: "bg-ink-900",
border: 'border-ink-800', border: "border-ink-800",
}, },
APPROVED: { APPROVED: {
label: 'Active', label: "Active",
color: 'text-ink-900 font-extrabold', color: "text-ink-900 font-extrabold",
bg: 'bg-ink-100', bg: "bg-ink-100",
border: 'border-ink-300', border: "border-ink-300",
}, },
}; };
const getStatusConfig = (status: string) => STATUS_CONFIG[status] ?? { const getStatusConfig = (status: string) =>
STATUS_CONFIG[status] ?? {
label: status, label: status,
color: 'text-ink-500', color: "text-ink-500",
bg: 'bg-ink-100', bg: "bg-ink-100",
border: 'border-ink-200', border: "border-ink-200",
}; };
export const DirectoryPage: React.FC = () => { export const DirectoryPage: React.FC = () => {
const [email, setEmail] = useState(''); const { success, error } = useToast();
const [inviteResult, setInviteResult] = useState<{ token?: string; error?: string } | null>(null); const [email, setEmail] = useState("");
const [inviteResult, setInviteResult] = useState<{
token?: string;
error?: string;
} | null>(null);
const [isInviteOpen, setIsInviteOpen] = useState(false); const [isInviteOpen, setIsInviteOpen] = useState(false);
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
const ITEMS_PER_PAGE = 5; const [searchTerm, setSearchTerm] = useState("");
const [statusFilter, setStatusFilter] = useState("ALL");
const ITEMS_PER_PAGE = 10;
const { data: partners = [], isLoading: loadingPartners, refetch: fetchPartners } = usePartnersQuery(); const {
data: partners = [],
isLoading: loadingPartners,
refetch: fetchPartners,
} = usePartnersQuery();
const inviteMutation = useInvitePartnerMutation(); const inviteMutation = useInvitePartnerMutation();
const handleInvite = async (e: React.FormEvent) => { const handleInvite = async (e: React.FormEvent) => {
@ -51,70 +80,144 @@ export const DirectoryPage: React.FC = () => {
inviteMutation.mutate(email, { inviteMutation.mutate(email, {
onSuccess: (data) => { onSuccess: (data) => {
setInviteResult({ token: data.token }); setInviteResult({ token: data.token });
setEmail(''); success("Invitation generated successfully", `A secure onboarding link has been created for ${email}.`);
setEmail("");
}, },
onError: (err: any) => { onError: (err: any) => {
setInviteResult({ error: err.response?.data?.error || 'Failed to send invite' }); const errMsg = err.response?.data?.error || "Failed to send invite";
} setInviteResult({
error: errMsg,
});
error("Invitation failed", errMsg);
},
}); });
}; };
const counts = { const counts = {
total: partners.length, total: partners.length,
active: partners.filter(p => p.onboardingStatus === 'APPROVED').length, active: partners.filter((p) => p.onboardingStatus === "APPROVED").length,
pendingOnboarding: partners.filter(p => p.onboardingStatus === 'PENDING_ONBOARDING').length, pendingOnboarding: partners.filter(
awaitingApproval: partners.filter(p => p.onboardingStatus === 'PENDING_APPROVAL').length, (p) => p.onboardingStatus === "PENDING_ONBOARDING",
).length,
awaitingApproval: partners.filter(
(p) => p.onboardingStatus === "PENDING_APPROVAL",
).length,
}; };
const statCards = [ const statCards = [
{ label: 'Total Partners', value: counts.total, icon: Users, accentClass: '' }, {
{ label: 'Active', value: counts.active, icon: ShieldCheck, accentClass: '' }, label: "Total Partners",
{ label: 'Pending Onboarding', value: counts.pendingOnboarding, icon: Clock, accentClass: '' }, value: counts.total,
icon: Users,
accentClass: "",
},
{
label: "Active",
value: counts.active,
icon: ShieldCheck,
accentClass: "",
},
{
label: "Pending Onboarding",
value: counts.pendingOnboarding,
icon: Clock,
accentClass: "",
},
]; ];
if (counts.awaitingApproval > 0) { if (counts.awaitingApproval > 0) {
statCards.push({ statCards.push({
label: 'Awaiting Approval', label: "Awaiting Approval",
value: counts.awaitingApproval, value: counts.awaitingApproval,
icon: AlertCircle, icon: AlertCircle,
accentClass: 'border-ink-900/30 bg-ink-900/5 shadow-[0_0_15px_rgba(0,0,0,0.01)] border-dashed animate-pulse' accentClass:
"border-ink-900/30 bg-ink-900/5 shadow-[0_0_15px_rgba(0,0,0,0.01)] border-dashed animate-pulse",
}); });
} }
const totalItems = partners.length; const filteredPartners = partners.filter((partner) => {
const matchesSearch = partner.email
.toLowerCase()
.includes(searchTerm.toLowerCase());
const matchesStatus =
statusFilter === "ALL" || partner.onboardingStatus === statusFilter;
return matchesSearch && matchesStatus;
});
const totalItems = filteredPartners.length;
const totalPages = Math.ceil(totalItems / ITEMS_PER_PAGE) || 1; const totalPages = Math.ceil(totalItems / ITEMS_PER_PAGE) || 1;
const startIndex = (currentPage - 1) * ITEMS_PER_PAGE; const startIndex = (currentPage - 1) * ITEMS_PER_PAGE;
const endIndex = Math.min(startIndex + ITEMS_PER_PAGE, totalItems); const endIndex = Math.min(startIndex + ITEMS_PER_PAGE, totalItems);
const paginatedPartners = partners.slice(startIndex, endIndex); const paginatedPartners = filteredPartners.slice(startIndex, endIndex);
return ( // Header component
<div className="w-full space-y-6 animate-fade-in text-ink-900"> const headerNode = (
<PageHeader <PageHeader
title="Partner Directory" title="Partner Directory"
subtitle="Manage your network and invite new partners to the platform." subtitle="Manage your network and invite new partners to the platform."
actions={ />
<> );
<button
// Toolbar component
const toolbarNode = (
<div className="flex flex-col space-y-3 shrink-0">
{/* Search/Filter & Actions Toolbar */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3.5 p-3.5 bg-ink-0 border border-ink-200 rounded-xl shadow-sm">
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-2 flex-1 max-w-xl">
<div className="relative flex-1">
<Search className="w-4 h-4 text-ink-400 absolute left-3 top-1/2 -translate-y-1/2" />
<input
type="text"
value={searchTerm}
onChange={(e) => {
setSearchTerm(e.target.value);
setCurrentPage(1);
}}
placeholder="Search partners by email..."
className="w-full pl-9 pr-4 py-1.5 bg-ink-50 border border-ink-200 rounded-lg text-xs focus:outline-none focus:ring-2 focus:ring-ink-900/10 text-ink-900 placeholder-ink-400 font-semibold"
/>
</div>
<select
value={statusFilter}
onChange={(e) => {
setStatusFilter(e.target.value);
setCurrentPage(1);
}}
className="bg-ink-50 border border-ink-200 rounded-lg px-2.5 py-1.5 text-xs font-semibold focus:outline-none focus:ring-2 focus:ring-ink-900/10 text-ink-700 font-sans"
>
<option value="ALL">All Statuses</option>
<option value="APPROVED">Active</option>
<option value="PENDING_ONBOARDING">Pending Onboarding</option>
<option value="PENDING_APPROVAL">Awaiting Approval</option>
</select>
</div>
<div className="flex items-center gap-2 shrink-0 justify-end w-full sm:w-auto">
<Button
onClick={() => { onClick={() => {
setInviteResult(null); setInviteResult(null);
setEmail(''); setEmail("");
setIsInviteOpen(true); setIsInviteOpen(true);
}} }}
className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-ink-900 text-ink-0 text-xs font-bold hover:bg-ink-800 transition-all cursor-pointer shadow-sm" variant="primary"
size="sm"
icon={<UserPlus className="w-4 h-4" />}
> >
<UserPlus className="w-4 h-4" /> Invite Partner
<span>Invite Partner</span> </Button>
</button> <Button
<button onClick={() => {
onClick={() => { fetchPartners(); }} fetchPartners();
className="p-1.5 rounded-lg bg-ink-0 border border-ink-200 text-ink-500 hover:text-ink-900 hover:shadow-sm transition-all group cursor-pointer" }}
variant="ghost"
size="sm"
icon={<RefreshCw className="w-4 h-4" />}
title="Refresh" title="Refresh"
> >
<RefreshCw className="w-4 h-4 group-hover:rotate-180 transition-transform duration-500" /> Refresh
</button> </Button>
</> </div>
} </div>
/>
{/* Dynamic Real-time Approval Notification Banner */} {/* Dynamic Real-time Approval Notification Banner */}
{counts.awaitingApproval > 0 && ( {counts.awaitingApproval > 0 && (
@ -129,88 +232,177 @@ export const DirectoryPage: React.FC = () => {
<AlertCircle className="w-4 h-4 animate-bounce" /> <AlertCircle className="w-4 h-4 animate-bounce" />
</div> </div>
<div> <div>
<h4 className="font-bold text-sm text-ink-0">Partner approvals pending</h4> <h4 className="font-bold text-sm text-ink-0">
<p className="text-xs text-ink-300 mt-0.5">There are {counts.awaitingApproval} partners awaiting document review and access authorization.</p> Partner approvals pending
</h4>
<p className="text-xs text-ink-300 mt-0.5">
There are {counts.awaitingApproval} partners awaiting document
review and access authorization.
</p>
</div> </div>
</div> </div>
<Link <Link to="/admin/approvals" className="relative z-10 shrink-0">
to="/admin/approvals" <Button
className="relative z-10 px-3 py-1.5 bg-ink-0 hover:bg-ink-100 text-ink-900 text-xs font-bold rounded-lg transition-all shadow-sm flex items-center gap-1.5 shrink-0" variant="secondary"
size="sm"
icon={<ChevronRight className="w-3.5 h-3.5 order-last" />}
> >
Review Queue Review Queue
<ChevronRight className="w-3.5 h-3.5" /> </Button>
</Link> </Link>
</motion.div> </motion.div>
)} )}
{/* Stats Row */}
<div className={`grid gap-4 ${counts.awaitingApproval > 0 ? 'grid-cols-2 md:grid-cols-4' : 'grid-cols-1 md:grid-cols-3'}`}>
{statCards.map((stat, i) => (
<div key={i} className={`bg-ink-0 rounded-xl border p-4 shadow-sm transition-all duration-300 ${stat.accentClass || 'border-ink-200'}`}>
<div className="flex items-center justify-between mb-2">
<p className="text-xs font-semibold uppercase tracking-wider text-ink-500">{stat.label}</p>
<stat.icon className={`w-4 h-4 ${stat.accentClass ? 'text-ink-900 animate-pulse' : 'text-ink-400'}`} />
</div> </div>
<p className="text-xl font-bold tracking-tight text-ink-900">{stat.value}</p> );
// Footer / Pagination component
const footerNode = totalItems > 0 ? (
<div className="px-4 py-3 bg-ink-50 border border-ink-200 rounded-xl flex flex-col sm:flex-row justify-between items-center gap-4 text-xs font-semibold text-ink-500 shadow-sm">
<p>
Showing{" "}
<span className="text-ink-900 font-extrabold">
{startIndex + 1}-{endIndex}
</span>{" "}
of{" "}
<span className="text-ink-900 font-extrabold">{totalItems}</span>{" "}
partners
</p>
<div className="flex items-center gap-2">
<Button
onClick={() => setCurrentPage((prev) => Math.max(prev - 1, 1))}
disabled={currentPage === 1}
variant="ghost"
size="xs"
>
Previous
</Button>
{Array.from({ length: totalPages }).map((_, idx) => {
const pageNum = idx + 1;
return (
<button
key={pageNum}
onClick={() => setCurrentPage(pageNum)}
className={`w-7 h-7 rounded-md flex items-center justify-center font-bold transition-all cursor-pointer ${
currentPage === pageNum
? "bg-ink-900 text-ink-0 shadow-sm"
: "border border-ink-200 bg-ink-0 hover:bg-ink-100 text-ink-500 hover:text-ink-900 text-xs"
}`}
>
{pageNum}
</button>
);
})}
<Button
onClick={() =>
setCurrentPage((prev) => Math.min(prev + 1, totalPages))
}
disabled={currentPage === totalPages}
variant="ghost"
size="xs"
>
Next
</Button>
</div>
</div>
) : undefined;
return (
<PageLayout header={headerNode} toolbar={toolbarNode} footer={footerNode}>
{/* Scrollable interior wrapper */}
<div className="p-5 space-y-6 flex flex-col min-h-0 flex-1">
{/* Stats Row */}
<div
className={`grid gap-4 shrink-0 ${counts.awaitingApproval > 0 ? "grid-cols-2 md:grid-cols-4" : "grid-cols-1 md:grid-cols-3"}`}
>
{statCards.map((stat, i) => (
<div
key={i}
className={`bg-ink-0 rounded-xl border p-4 shadow-sm transition-all duration-300 ${stat.accentClass || "border-ink-200"}`}
>
<div className="flex items-center justify-between mb-2">
<p className="text-xs font-semibold uppercase tracking-wider text-ink-500">
{stat.label}
</p>
<stat.icon
className={`w-4 h-4 ${stat.accentClass ? "text-ink-900 animate-pulse" : "text-ink-400"}`}
/>
</div>
<p className="text-xl font-bold tracking-tight text-ink-900">
{stat.value}
</p>
</div> </div>
))} ))}
</div> </div>
{/* Partner List Table (Full Width) */} {/* Partner Table */}
<div className="bg-ink-0 rounded-xl border border-ink-200 shadow-sm overflow-hidden w-full"> <div className="flex-1 min-h-0 w-full overflow-x-auto">
<div className="overflow-x-auto">
<table className="w-full text-left text-sm whitespace-nowrap"> <table className="w-full text-left text-sm whitespace-nowrap">
<thead className="bg-ink-50 border-b border-ink-200 text-ink-500 font-bold uppercase tracking-wider text-xs"> <thead className="bg-ink-50 border-b border-ink-200 text-ink-500 font-bold uppercase tracking-wider text-xs sticky top-0 z-10">
<tr> <tr>
<th className="px-4 py-2.5">Partner</th> <th className="px-5 py-3">Partner</th>
<th className="px-4 py-2.5">Status</th> <th className="px-5 py-3">Status</th>
<th className="px-4 py-2.5">MFA</th> <th className="px-5 py-3">MFA</th>
<th className="px-4 py-2.5">Joined</th> <th className="px-5 py-3">Joined</th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-ink-200"> <tbody className="divide-y divide-ink-200 bg-ink-0">
{loadingPartners ? ( {loadingPartners ? (
<tr> <tr>
<td colSpan={4} className="px-4 py-8 text-center"> <td colSpan={4} className="px-5 py-12 text-center">
<div className="w-6 h-6 border-4 border-ink-900/30 border-t-ink-900 rounded-full animate-spin mx-auto" /> <div className="w-6 h-6 border-4 border-ink-900/30 border-t-ink-900 rounded-full animate-spin mx-auto" />
</td> </td>
</tr> </tr>
) : partners.length === 0 ? ( ) : paginatedPartners.length === 0 ? (
<tr> <tr>
<td colSpan={4} className="px-4 py-8 text-center"> <td colSpan={4} className="px-5 py-12 text-center">
<div className="w-12 h-12 bg-ink-50 rounded-full flex items-center justify-center mx-auto mb-3"> <div className="w-12 h-12 bg-ink-50 rounded-full flex items-center justify-center mx-auto mb-3 border border-ink-200">
<Users className="w-6 h-6 text-ink-400" /> <Users className="w-6 h-6 text-ink-400" />
</div> </div>
<p className="text-sm font-bold text-ink-900">No partners yet</p> <p className="text-sm font-bold text-ink-900">
<p className="text-xs text-ink-500 mt-1">Use the invite button to add your first partner.</p> No partners yet
</p>
<p className="text-xs text-ink-500 mt-1">
Use the invite button to add your first partner.
</p>
</td> </td>
</tr> </tr>
) : ( ) : (
paginatedPartners.map(partner => { paginatedPartners.map((partner) => {
const sc = getStatusConfig(partner.onboardingStatus); const sc = getStatusConfig(partner.onboardingStatus);
return ( return (
<tr key={partner.id} className="hover:bg-ink-50 transition-colors"> <tr
<td className="px-4 py-3"> key={partner.id}
className="hover:bg-ink-50 transition-colors"
>
<td className="px-5 py-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-gradient-to-br from-ink-900 to-ink-800 flex items-center justify-center text-ink-0 font-bold text-xs shadow-md"> <div className="w-8 h-8 rounded-full bg-gradient-to-br from-ink-900 to-ink-800 flex items-center justify-center text-ink-0 font-bold text-xs shadow-md">
{partner.email.charAt(0).toUpperCase()} {partner.email.charAt(0).toUpperCase()}
</div> </div>
<span className="font-bold text-ink-900 text-sm">{partner.email}</span> <span className="font-bold text-ink-900 text-sm">
{partner.email}
</span>
</div> </div>
</td> </td>
<td className="px-4 py-3"> <td className="px-5 py-4">
<span className={`text-xs px-2 py-0.5 rounded-md border ${sc.color} ${sc.bg} ${sc.border}`}> <span
className={`text-xs px-2 py-0.5 rounded-md border ${sc.color} ${sc.bg} ${sc.border}`}
>
{sc.label} {sc.label}
</span> </span>
</td> </td>
<td className="px-4 py-3"> <td className="px-5 py-4">
{partner.mfaEnabled ? ( {partner.mfaEnabled ? (
<span className="text-xs font-bold text-ink-900">Enabled</span> <span className="text-xs font-bold text-ink-900">
Enabled
</span>
) : ( ) : (
<span className="text-xs font-bold text-ink-400">Disabled</span> <span className="text-xs font-bold text-ink-400">
Disabled
</span>
)} )}
</td> </td>
<td className="px-4 py-3 text-xs text-ink-500 font-medium"> <td className="px-5 py-4 text-xs text-ink-500 font-medium">
{new Date(partner.createdAt).toLocaleDateString()} {new Date(partner.createdAt).toLocaleDateString()}
</td> </td>
</tr> </tr>
@ -220,83 +412,22 @@ export const DirectoryPage: React.FC = () => {
</tbody> </tbody>
</table> </table>
</div> </div>
{/* Pagination Controls */}
{totalItems > 0 && (
<div className="px-4 py-3 border-t border-ink-200 bg-ink-50 flex flex-col sm:flex-row justify-between items-center gap-4 text-xs font-semibold text-ink-500">
<p>
Showing <span className="text-ink-900 font-extrabold">{startIndex + 1}-{endIndex}</span> of <span className="text-ink-900 font-extrabold">{totalItems}</span> partners
</p>
<div className="flex items-center gap-2">
<button
onClick={() => setCurrentPage(prev => Math.max(prev - 1, 1))}
disabled={currentPage === 1}
className="px-2 py-1 rounded-md border border-ink-200 bg-ink-0 hover:bg-ink-100 disabled:opacity-50 disabled:hover:bg-ink-0 transition-all font-bold text-ink-900 cursor-pointer"
>
Previous
</button>
{Array.from({ length: totalPages }).map((_, idx) => {
const pageNum = idx + 1;
return (
<button
key={pageNum}
onClick={() => setCurrentPage(pageNum)}
className={`w-7 h-7 rounded-md flex items-center justify-center font-bold transition-all cursor-pointer ${
currentPage === pageNum
? 'bg-ink-900 text-ink-0 shadow-sm'
: 'border border-ink-200 bg-ink-0 hover:bg-ink-100 text-ink-500 hover:text-ink-900'
}`}
>
{pageNum}
</button>
);
})}
<button
onClick={() => setCurrentPage(prev => Math.min(prev + 1, totalPages))}
disabled={currentPage === totalPages}
className="px-2 py-1 rounded-md border border-ink-200 bg-ink-0 hover:bg-ink-100 disabled:opacity-50 disabled:hover:bg-ink-0 transition-all font-bold text-ink-900 cursor-pointer"
>
Next
</button>
</div>
</div>
)}
</div> </div>
{/* Invite Modal Overlay */} {/* Invite Modal Overlay */}
<AnimatePresence> <Modal
{isInviteOpen && ( isOpen={isInviteOpen}
<div className="fixed inset-0 z-50 flex items-center justify-center p-4"> onClose={() => setIsInviteOpen(false)}
<motion.div title="Invite Partner"
initial={{ opacity: 0 }} subtitle="Generate a secure invitation link for a new partner."
animate={{ opacity: 1 }} size="sm"
exit={{ opacity: 0 }}
onClick={() => setIsInviteOpen(false)}
className="fixed inset-0 bg-ink-900/40 backdrop-blur-sm"
/>
<motion.div
initial={{ opacity: 0, scale: 0.95, y: 10 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 10 }}
transition={{ type: 'spring', damping: 25, stiffness: 350 }}
className="bg-ink-0 border border-ink-200 rounded-xl shadow-xl p-5 max-w-md w-full relative z-10 overflow-hidden text-ink-900"
> >
<div className="absolute top-0 inset-x-0 h-px bg-gradient-to-r from-transparent via-ink-300 to-transparent" />
<div className="absolute top-0 right-0 p-4 opacity-5 pointer-events-none">
<UserPlus className="w-24 h-24 text-ink-900" />
</div>
<h3 className="text-lg font-bold text-ink-900 mb-2 tracking-tight">Invite Partner</h3>
<p className="text-xs text-ink-500 mb-6 font-medium">
Generate a secure invitation link for a new partner.
</p>
{!inviteResult?.token ? ( {!inviteResult?.token ? (
<form onSubmit={handleInvite} className="space-y-4"> <form onSubmit={handleInvite} className="space-y-4">
<div> <div>
<label className="text-xs font-semibold text-ink-500 mb-1.5 block">Email Address</label> <label className="text-xs font-semibold text-ink-500 mb-1.5 block">
Email Address
</label>
<div className="relative"> <div className="relative">
<Mail className="w-4 h-4 text-ink-400 absolute left-3 top-1/2 -translate-y-1/2" /> <Mail className="w-4 h-4 text-ink-400 absolute left-3 top-1/2 -translate-y-1/2" />
<input <input
@ -313,26 +444,32 @@ export const DirectoryPage: React.FC = () => {
{inviteResult?.error && ( {inviteResult?.error && (
<div className="p-3 bg-red-500/10 border border-red-500/20 rounded-lg flex items-start gap-3"> <div className="p-3 bg-red-500/10 border border-red-500/20 rounded-lg flex items-start gap-3">
<AlertCircle className="w-4 h-4 text-red-650 shrink-0 mt-0.5" /> <AlertCircle className="w-4 h-4 text-red-650 shrink-0 mt-0.5" />
<p className="text-xs font-bold text-red-650">{inviteResult.error}</p> <p className="text-xs font-bold text-red-650">
{inviteResult.error}
</p>
</div> </div>
)} )}
<div className="flex items-center gap-3 pt-2"> <div className="flex items-center gap-3 pt-2">
<button <Button
type="button" type="button"
onClick={() => setIsInviteOpen(false)} onClick={() => setIsInviteOpen(false)}
className="flex-1 py-2.5 rounded-lg border border-ink-200 text-ink-700 font-bold text-sm hover:bg-ink-50 transition-all cursor-pointer" variant="ghost"
size="sm"
className="flex-1"
> >
Cancel Cancel
</button> </Button>
<button <Button
type="submit" type="submit"
disabled={inviteMutation.isPending || !email} disabled={inviteMutation.isPending || !email}
className="flex-1 flex items-center justify-center gap-2 py-2.5 rounded-lg bg-ink-900 text-ink-0 font-bold text-sm hover:bg-ink-800 transition-all disabled:opacity-50 cursor-pointer" variant="primary"
size="sm"
className="flex-1"
icon={<ChevronRight className="w-4 h-4 order-last" />}
> >
{inviteMutation.isPending ? 'Generating...' : 'Generate'} {inviteMutation.isPending ? "Generating..." : "Generate"}
<ChevronRight className="w-4 h-4" /> </Button>
</button>
</div> </div>
</form> </form>
) : ( ) : (
@ -340,32 +477,36 @@ export const DirectoryPage: React.FC = () => {
<div className="p-3 bg-ink-100 border border-ink-300 rounded-lg"> <div className="p-3 bg-ink-100 border border-ink-300 rounded-lg">
<div className="flex items-center gap-2 mb-2"> <div className="flex items-center gap-2 mb-2">
<CheckCircle className="w-4 h-4 text-ink-900" /> <CheckCircle className="w-4 h-4 text-ink-900" />
<span className="text-xs font-bold text-ink-900">Invite Created!</span> <span className="text-xs font-bold text-ink-900">
Invite Created!
</span>
</div> </div>
<p className="text-xs text-ink-500 mb-2 font-medium">Send this secure link to the partner:</p> <p className="text-xs text-ink-500 mb-2 font-medium">
Send this secure link to the partner:
</p>
<div className="p-2.5 bg-ink-0 border border-ink-300 rounded-lg text-xs break-all font-mono text-ink-900 select-all"> <div className="p-2.5 bg-ink-0 border border-ink-300 rounded-lg text-xs break-all font-mono text-ink-900 select-all">
{window.location.origin}/invite?token={inviteResult.token} {window.location.origin}/invite?token={inviteResult.token}
</div> </div>
</div> </div>
<button <Button
type="button" type="button"
onClick={() => { onClick={() => {
setIsInviteOpen(false); setIsInviteOpen(false);
setEmail(''); setEmail("");
setInviteResult(null); setInviteResult(null);
}} }}
className="w-full py-2.5 rounded-lg bg-ink-900 text-ink-0 font-bold text-sm hover:bg-ink-800 transition-all cursor-pointer" variant="primary"
size="sm"
className="w-full"
> >
Done Done
</button> </Button>
</div> </div>
)} )}
</motion.div> </Modal>
</div> </PageLayout>
)}
</AnimatePresence>
</div>
); );
}; };
export default DirectoryPage; export default DirectoryPage;

View File

@ -2,6 +2,9 @@ import React, { useState, useEffect, useRef } from 'react';
import { axiosInstance } from '../../services/axios'; import { axiosInstance } from '../../services/axios';
import { Shield, FileText, Upload, CheckCircle, AlertTriangle, ArrowRight, Eye, RefreshCw } from 'lucide-react'; import { Shield, FileText, Upload, CheckCircle, AlertTriangle, ArrowRight, Eye, RefreshCw } from 'lucide-react';
import PageHeader from '../../components/ui/PageHeader'; import PageHeader from '../../components/ui/PageHeader';
import Button from '../../components/ui/Button';
import { useToast } from '../../hooks/use-toast';
import { PageLayout } from '../../components/layout/PageLayout';
interface LegalDoc { interface LegalDoc {
id: string; id: string;
@ -14,6 +17,7 @@ interface LegalDoc {
} }
export const LegalTemplatesPage: React.FC = () => { export const LegalTemplatesPage: React.FC = () => {
const { success, error: toastError } = useToast();
const [activeTab, setActiveTab] = useState<'NDA' | 'MSA'>('NDA'); const [activeTab, setActiveTab] = useState<'NDA' | 'MSA'>('NDA');
const [ndaDoc, setNdaDoc] = useState<LegalDoc | null>(null); const [ndaDoc, setNdaDoc] = useState<LegalDoc | null>(null);
const [msaDoc, setMsaDoc] = useState<LegalDoc | null>(null); const [msaDoc, setMsaDoc] = useState<LegalDoc | null>(null);
@ -25,7 +29,6 @@ export const LegalTemplatesPage: React.FC = () => {
const [pdfFile, setPdfFile] = useState<File | null>(null); const [pdfFile, setPdfFile] = useState<File | null>(null);
const [uploadingPdf, setUploadingPdf] = useState(false); const [uploadingPdf, setUploadingPdf] = useState(false);
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null); const fileInputRef = useRef<HTMLInputElement>(null);
@ -75,13 +78,12 @@ export const LegalTemplatesPage: React.FC = () => {
const handleFormSubmit = async (e: React.FormEvent) => { const handleFormSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!version || !content) { if (!version || !content) {
setMessage({ type: 'error', text: 'Please fill in both the version and textual content.' }); toastError('Validation error', 'Please fill in both the version and textual content.');
return; return;
} }
try { try {
setSubmitting(true); setSubmitting(true);
setMessage(null);
let uploadedUrl: string | null = null; let uploadedUrl: string | null = null;
// 1. Upload PDF if selected // 1. Upload PDF if selected
@ -108,14 +110,14 @@ export const LegalTemplatesPage: React.FC = () => {
pdfUrl: uploadedUrl pdfUrl: uploadedUrl
}); });
setMessage({ type: 'success', text: `Successfully published ${activeTab} version ${version}!` }); success(`Successfully published ${activeTab} version ${version}!`, 'The new legal template version is now active.');
setPdfFile(null); setPdfFile(null);
if (fileInputRef.current) fileInputRef.current.value = ''; if (fileInputRef.current) fileInputRef.current.value = '';
await fetchActiveDocuments(); await fetchActiveDocuments();
} catch (err: any) { } catch (err: any) {
console.error('Failed to publish legal document template:', err); console.error('Failed to publish legal document template:', err);
setMessage({ type: 'error', text: err.response?.data?.error || 'Failed to publish legal template.' }); toastError('Failed to publish template', err.response?.data?.error || 'Failed to publish legal template.');
} finally { } finally {
setSubmitting(false); setSubmitting(false);
setUploadingPdf(false); setUploadingPdf(false);
@ -125,8 +127,8 @@ export const LegalTemplatesPage: React.FC = () => {
const currentDoc = activeTab === 'NDA' ? ndaDoc : msaDoc; const currentDoc = activeTab === 'NDA' ? ndaDoc : msaDoc;
const fileHost = (import.meta.env.VITE_API_URL || 'http://localhost:5000/api/v1').replace('/api/v1', ''); const fileHost = (import.meta.env.VITE_API_URL || 'http://localhost:5000/api/v1').replace('/api/v1', '');
return ( // Header component
<div className="w-full space-y-6 animate-fade-in text-ink-900"> const headerNode = (
<PageHeader <PageHeader
title="Legal Agreements" title="Legal Agreements"
subtitle="Configure active documents required during partner onboarding." subtitle="Configure active documents required during partner onboarding."
@ -136,8 +138,14 @@ export const LegalTemplatesPage: React.FC = () => {
<span>Compliance Panel</span> <span>Compliance Panel</span>
</div> </div>
} }
actions={ />
<div className="inline-flex p-1 rounded-lg bg-ink-100 border border-ink-200 shrink-0"> );
// Toolbar component
const toolbarNode = (
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3.5 p-3.5 bg-ink-0 border border-ink-200 rounded-xl shadow-sm">
<div className="flex items-center gap-2 flex-1">
<div className="inline-flex p-1 rounded-lg bg-ink-50 border border-ink-200 shrink-0">
<button <button
onClick={() => setActiveTab('NDA')} onClick={() => setActiveTab('NDA')}
className={`px-3 py-1.5 rounded-md text-xs font-bold transition-all cursor-pointer ${activeTab === 'NDA' ? 'bg-ink-0 text-ink-900 shadow-sm' : 'text-ink-500 hover:text-ink-900'}`} className={`px-3 py-1.5 rounded-md text-xs font-bold transition-all cursor-pointer ${activeTab === 'NDA' ? 'bg-ink-0 text-ink-900 shadow-sm' : 'text-ink-500 hover:text-ink-900'}`}
@ -151,10 +159,13 @@ export const LegalTemplatesPage: React.FC = () => {
Master Services (MSA) Master Services (MSA)
</button> </button>
</div> </div>
} </div>
/> </div>
);
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6"> return (
<PageLayout header={headerNode} toolbar={toolbarNode}>
<div className="p-5 grid grid-cols-1 lg:grid-cols-3 gap-6 flex-1 min-h-0 overflow-y-auto">
{/* Left Side: Active Status Details */} {/* Left Side: Active Status Details */}
<div className="lg:col-span-1 flex flex-col gap-6"> <div className="lg:col-span-1 flex flex-col gap-6">
<div className="p-4 bg-ink-0 border border-ink-200 shadow-sm rounded-xl"> <div className="p-4 bg-ink-0 border border-ink-200 shadow-sm rounded-xl">
@ -200,7 +211,7 @@ export const LegalTemplatesPage: React.FC = () => {
href={`${fileHost}${currentDoc.pdfUrl}`} href={`${fileHost}${currentDoc.pdfUrl}`}
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="flex items-center justify-between p-2 rounded-lg border border-dashed border-ink-200 hover:bg-ink-50 hover:border-ink-400 transition-all text-xs font-bold text-ink-700" className="flex items-between justify-between p-2 rounded-lg border border-dashed border-ink-200 hover:bg-ink-50 hover:border-ink-400 transition-all text-xs font-bold text-ink-700"
> >
<span className="truncate max-w-[150px]">{currentDoc.pdfUrl.split('/').pop()}</span> <span className="truncate max-w-[150px]">{currentDoc.pdfUrl.split('/').pop()}</span>
<ArrowRight className="w-3.5 h-3.5" /> <ArrowRight className="w-3.5 h-3.5" />
@ -235,13 +246,6 @@ export const LegalTemplatesPage: React.FC = () => {
</p> </p>
</div> </div>
{message && (
<div className={`p-3 rounded-lg flex items-start gap-3 border ${message.type === 'success' ? 'bg-ink-100 border-ink-200 text-ink-900' : 'bg-red-500/10 border-red-500/20 text-red-650'}`}>
{message.type === 'success' && <CheckCircle className="w-4 h-4 shrink-0 mt-0.5" />}
<p className="text-xs font-semibold">{message.text}</p>
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4"> <div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="md:col-span-1"> <div className="md:col-span-1">
<label className="block text-xs font-semibold text-ink-500 mb-2 uppercase tracking-wider">Version String</label> <label className="block text-xs font-semibold text-ink-500 mb-2 uppercase tracking-wider">Version String</label>
@ -297,19 +301,21 @@ export const LegalTemplatesPage: React.FC = () => {
</div> </div>
<div className="flex justify-end gap-3 pt-3 border-t border-ink-100"> <div className="flex justify-end gap-3 pt-3 border-t border-ink-100">
<button <Button
type="submit" type="submit"
disabled={submitting || uploadingPdf} disabled={submitting || uploadingPdf}
className="px-4 py-2 rounded-lg bg-ink-900 text-ink-0 font-bold text-sm hover:bg-ink-800 transition-all disabled:opacity-50 flex items-center gap-2 shadow-sm cursor-pointer" variant="primary"
size="sm"
icon={<CheckCircle className="w-4 h-4" />}
> >
{uploadingPdf ? 'Uploading PDF...' : submitting ? 'Publishing...' : 'Publish Template'} {uploadingPdf ? 'Uploading PDF...' : submitting ? 'Publishing...' : 'Publish Template'}
<CheckCircle className="w-4 h-4" /> </Button>
</button>
</div> </div>
</form> </form>
</div> </div>
</div> </div>
</div> </PageLayout>
); );
}; };
export default LegalTemplatesPage; export default LegalTemplatesPage;

View File

@ -8,9 +8,14 @@ export interface PendingPartner {
id: string; id: string;
signatureHash: string | null; signatureHash: string | null;
documentUrl: string | null; documentUrl: string | null;
signatureBase64: string | null;
ipAddress: string;
acceptedAt: string;
document: { document: {
id: string;
type: string; type: string;
version: string; version: string;
content: string;
}; };
}>; }>;
} }
@ -25,10 +30,17 @@ export interface LegalDocument {
export interface LegalAcceptance { export interface LegalAcceptance {
id: string; id: string;
signatureHash: string | null;
documentUrl: string | null;
signatureBase64: string | null;
ipAddress: string;
acceptedAt: string;
document: { document: {
id: string; id: string;
type: 'NDA' | 'MSA'; type: 'NDA' | 'MSA';
version: string; version: string;
content: string;
pdfUrl?: string | null;
}; };
} }