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

@ -117,15 +117,16 @@ model LegalDocument {
}
model LegalAcceptance {
id String @id @default(uuid())
docId String
userId String
ipAddress String
signatureHash String?
documentUrl String?
acceptedAt DateTime @default(now())
document LegalDocument @relation(fields: [docId], references: [id])
user User @relation(fields: [userId], references: [id])
id String @id @default(uuid())
docId String
userId String
ipAddress String
signatureHash String?
documentUrl String?
signatureBase64 String?
acceptedAt DateTime @default(now())
document LegalDocument @relation(fields: [docId], references: [id])
user User @relation(fields: [userId], references: [id])
}
model AuditLog {

View File

@ -69,7 +69,14 @@ export class LegalController {
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
await this.legalService.checkOnboardingCompletion(userId);

View File

@ -33,10 +33,11 @@ export class LegalService {
ipAddress: string,
signatureHash?: string,
documentUrl?: string,
signatureBase64?: string,
) {
// Record acceptance
const acceptance = await prisma.legalAcceptance.create({
data: { docId, userId, ipAddress, signatureHash, documentUrl },
data: { docId, userId, ipAddress, signatureHash, documentUrl, signatureBase64 },
});
return acceptance;
@ -67,7 +68,15 @@ export class LegalService {
public async getAcceptances(userId: string) {
return await prisma.legalAcceptance.findMany({
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,
createdAt: true,
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 { useAuthStore } from './hooks/use-auth';
import { ToastProvider } from "./components/ui/Toast";
const queryClient = new QueryClient({
defaultOptions: {
queries: { retry: 1, refetchOnWindowFocus: false }
@ -39,7 +41,9 @@ export const App = () => {
return (
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
<ToastProvider>
<RouterProvider router={router} />
</ToastProvider>
</QueryClientProvider>
);
};

View File

@ -1,9 +1,22 @@
import React, { useState } from 'react';
import { Link, Outlet, useLocation, useNavigate } from 'react-router-dom';
import { useThemeStore } from '../../hooks/use-theme';
import { useAuthStore } from '../../hooks/use-auth';
import { ShieldCheck, BarChart3, ClipboardCheck, FolderGit2, BookCopy, Users, LogOut, Menu, X, Sun, Moon, ChevronRight, ChevronLeft } from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
import React, { useState } from "react";
import { Link, Outlet, useLocation, useNavigate } from "react-router-dom";
import { useThemeStore } from "../../hooks/use-theme";
import { useAuthStore } from "../../hooks/use-auth";
import {
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 = () => {
const { user, logout } = useAuthStore();
@ -15,51 +28,62 @@ export const AdminLayout: React.FC = () => {
const handleLogout = () => {
logout();
navigate('/login');
navigate("/login");
};
const navItems = [
{ name: 'Partners', path: '/admin/partners', icon: Users },
{ name: 'Approvals Queue', path: '/admin/approvals', icon: ClipboardCheck },
{ name: 'Legal Templates', path: '/admin/legal', icon: ShieldCheck },
{ name: 'Manage Catalog', path: '/admin/assets', icon: FolderGit2 },
{ name: 'Analytics', path: '/admin/analytics', icon: BarChart3 },
{ name: 'Blog CMS', path: '/admin/blog', icon: BookCopy }
{ name: "Partners", path: "/admin/partners", icon: Users },
{ name: "Approvals Queue", path: "/admin/approvals", icon: ClipboardCheck },
{ name: "Legal Templates", path: "/admin/legal", icon: ShieldCheck },
{ name: "Manage Catalog", path: "/admin/assets", icon: FolderGit2 },
{ name: "Blog CMS", path: "/admin/blog", icon: BookCopy },
];
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">
{/* ── 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 */}
<button
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"
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>
{/* 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">
<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" />
</div>
{!isCollapsed && (
<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-[10px] font-bold uppercase tracking-widest text-ink-900 mt-1">Admin Console</span>
<span className="text-lg font-extrabold tracking-tight leading-none text-ink-900">
Tech4Biz
</span>
<span className="text-[10px] font-bold uppercase tracking-widest text-ink-900 mt-1">
Admin Console
</span>
</div>
)}
</Link>
</div>
{/* Navigation */}
<nav className={`flex-1 py-8 space-y-2 overflow-y-auto transition-all duration-300 ${isCollapsed ? 'px-2' : 'px-4'}`}>
{navItems.map(item => {
<nav
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 isActive = location.pathname === item.path;
return (
@ -67,53 +91,76 @@ export const AdminLayout: React.FC = () => {
key={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 ${
isCollapsed ? 'justify-center px-0' : 'px-4'
isCollapsed ? "justify-center px-0" : "px-4"
} ${
isActive
? 'bg-ink-100 text-ink-900 border border-ink-300 shadow-sm'
: 'text-ink-500 hover:text-ink-900 hover:bg-ink-100'
isActive
? "bg-ink-100 text-ink-900 border border-ink-300 shadow-sm"
: "text-ink-500 hover:text-ink-900 hover:bg-ink-100"
}`}
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 && isActive && <ChevronRight className="w-4 h-4 ml-auto opacity-50" />}
{!isCollapsed && isActive && (
<ChevronRight className="w-4 h-4 ml-auto opacity-50" />
)}
</Link>
);
})}
</nav>
{/* Footer */}
<div className={`border-t border-ink-200 bg-ink-50 transition-all duration-300 ${isCollapsed ? 'p-3' : 'p-5'}`}>
<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>}
<div
className={`border-t border-ink-200 bg-ink-50 transition-all duration-300 ${isCollapsed ? "p-3" : "p-5"}`}
>
<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
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"
>
{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>
</div>
{!isCollapsed ? (
<div className="flex items-center gap-3 p-3 rounded-2xl bg-ink-0 border border-ink-200 mb-4 shadow-sm">
<div className="w-9 h-9 rounded-full bg-ink-900 flex items-center justify-center text-ink-0 font-bold shadow-sm">
{user?.email?.charAt(0).toUpperCase()}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-bold text-ink-900 truncate">{user?.email}</p>
<p className="text-[10px] uppercase font-bold text-ink-500 tracking-wider truncate">Administrator</p>
<p className="text-sm font-bold text-ink-900 truncate">
{user?.email}
</p>
<p className="text-[10px] uppercase font-bold text-ink-500 tracking-wider truncate">
Administrator
</p>
</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()}
</div>
)}
<button
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}
>
<LogOut className="w-4 h-4" />
@ -123,19 +170,24 @@ export const AdminLayout: React.FC = () => {
</aside>
{/* ── 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 */}
<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 */}
<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">
<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" />
</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>
<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" />
</button>
</header>
@ -144,44 +196,84 @@ export const AdminLayout: React.FC = () => {
<AnimatePresence>
{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 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">
<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
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">
<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" />
</button>
</div>
<nav className="flex-1 overflow-y-auto p-4 space-y-2">
{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'}`}>
{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"}`}
>
<item.icon className="w-5 h-5" />
<span>{item.name}</span>
</Link>
))}
</nav>
<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">
Theme {theme === 'dark' ? <Sun className="w-4 h-4" /> : <Moon className="w-4 h-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"
>
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 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>
</motion.div>
</>
)}
</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 />
</div>
{/* Footer */}
<footer className="border-t border-ink-200 bg-ink-0/50 backdrop-blur-md mt-auto">
<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">
<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-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>
<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">System Status</span>
<span className="hover:text-ink-900 cursor-pointer transition-colors">
Security Compliance
</span>
<span className="hover:text-ink-900 cursor-pointer transition-colors">
System Status
</span>
</div>
</div>
</footer>

View File

@ -119,16 +119,14 @@ export const ClientLayout: React.FC = () => {
{!isCollapsed && <span>Sign Out</span>}
</button>
</div>
</aside>
{/* ── Main Content Area ── */}
<main className="flex-1 flex flex-col relative w-full overflow-y-auto bg-ink-50">
</aside> {/* ── Main Content Area ── */}
<main className="flex-1 flex flex-col relative w-full h-screen overflow-hidden bg-ink-50">
{/* 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" />
{/* 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">
<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" />
@ -161,7 +159,7 @@ export const ClientLayout: React.FC = () => {
))}
</nav>
<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" />}
</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>
<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 />
</div>
{/* Footer */}
<footer className="border-t border-ink-200 bg-ink-0/50 backdrop-blur-md mt-auto">
<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">
<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-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>
<div className="flex gap-6">
<span className="hover:text-ink-900 cursor-pointer transition-colors">Security</span>

View File

@ -1,20 +1,56 @@
import React, { Suspense } from 'react';
import { createBrowserRouter, Navigate } from 'react-router-dom';
import { RequireAuth, RequireRole, RequireOnboardingComplete } from './guards';
import React, { Suspense } from "react";
import { createBrowserRouter, Navigate } from "react-router-dom";
import { RequireAuth, RequireRole, RequireOnboardingComplete } from "./guards";
// Layouts
const ClientLayout = React.lazy(() => import('../layouts/ClientLayout'));
const AdminLayout = React.lazy(() => import('../layouts/AdminLayout'));
const ClientLayout = React.lazy(() => import("../layouts/ClientLayout"));
const AdminLayout = React.lazy(() => import("../layouts/AdminLayout"));
// Pages (Lazy Loaded)
const LoginPage = React.lazy(() => import('../../pages/LoginPage').then(m => ({ default: m.LoginPage })));
const InvitePage = React.lazy(() => import('../../pages/InvitePage').then(m => ({ default: m.InvitePage })));
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 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 LoginPage = React.lazy(() =>
import("../../pages/LoginPage").then((m) => ({ default: m.LoginPage })),
);
const InvitePage = React.lazy(() =>
import("../../pages/InvitePage").then((m) => ({ default: m.InvitePage })),
);
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 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
const LoadingFallback = () => (
@ -23,17 +59,13 @@ const LoadingFallback = () => (
</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([
{
path: '/',
path: "/",
element: <Navigate to="/login" replace />,
},
{
path: '/login',
path: "/login",
element: (
<Suspense fallback={<LoadingFallback />}>
<LoginPage />
@ -41,7 +73,7 @@ export const router = createBrowserRouter([
),
},
{
path: '/invite',
path: "/invite",
element: (
<Suspense fallback={<LoadingFallback />}>
<InvitePage />
@ -49,7 +81,7 @@ export const router = createBrowserRouter([
),
},
{
path: '/onboarding',
path: "/onboarding",
element: (
<RequireAuth>
<RequireRole role="PARTNER_USER">
@ -61,7 +93,7 @@ export const router = createBrowserRouter([
),
},
{
path: '/client',
path: "/client",
element: (
<RequireAuth>
<RequireRole role="PARTNER_USER">
@ -83,7 +115,7 @@ export const router = createBrowserRouter([
),
},
{
path: 'assets',
path: "assets",
element: (
<Suspense fallback={<LoadingFallback />}>
<AssetsPage />
@ -91,17 +123,25 @@ export const router = createBrowserRouter([
),
},
{
path: 'agreements',
element: <LegalPage />,
path: "agreements",
element: (
<Suspense fallback={<LoadingFallback />}>
<ClientAgreementsPage />
</Suspense>
),
},
{
path: 'blog',
element: <BlogPage />,
}
path: "blog",
element: (
<Suspense fallback={<LoadingFallback />}>
<BlogCatalog />
</Suspense>
),
},
],
},
{
path: '/admin',
path: "/admin",
element: (
<RequireAuth>
<RequireRole role="ADMIN">
@ -117,7 +157,7 @@ export const router = createBrowserRouter([
element: <DirectoryPage />,
},
{
path: 'assets',
path: "assets",
element: (
<Suspense fallback={<LoadingFallback />}>
<AssetsPage />
@ -125,7 +165,7 @@ export const router = createBrowserRouter([
),
},
{
path: 'legal',
path: "legal",
element: (
<Suspense fallback={<LoadingFallback />}>
<LegalTemplatesPage />
@ -133,29 +173,29 @@ export const router = createBrowserRouter([
),
},
{
path: 'analytics',
element: <AnalyticsPage />,
path: "blog",
element: (
<Suspense fallback={<LoadingFallback />}>
<BlogCatalog />
</Suspense>
),
},
{
path: 'blog',
element: <div>Blog Management Coming Soon</div>
},
{
path: 'approvals',
path: "approvals",
element: (
<Suspense fallback={<LoadingFallback />}>
<ApprovalsPage />
</Suspense>
)
),
},
{
path: 'partners',
path: "partners",
element: (
<Suspense fallback={<LoadingFallback />}>
<DirectoryPage />
</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 { motion, AnimatePresence } from 'framer-motion';
import { X } from 'lucide-react';
import type { Asset } from '../../../types/assets';
import Modal from '../../../components/ui/Modal';
import Button from '../../../components/ui/Button';
interface AssetDetailsModalProps {
isOpen: boolean;
@ -26,109 +26,88 @@ export const AssetDetailsModal: React.FC<AssetDetailsModalProps> = ({
};
return (
<AnimatePresence>
{isOpen && asset && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={onClose}
className="absolute inset-0 bg-ink-950/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 }}
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">
<h3 className="text-base font-bold text-ink-900">Asset Details</h3>
<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" />
</button>
<Modal
isOpen={isOpen && !!asset}
onClose={onClose}
title="Asset Details"
size="md"
footer={
<Button
onClick={onClose}
variant="primary"
size="sm"
>
Close
</Button>
}
>
{asset && (
<div className="space-y-4 text-xs font-medium text-ink-900">
<div>
<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 font-sans">{asset.title}</p>
</div>
{asset.description && (
<div>
<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 font-sans">{asset.description}</p>
</div>
)}
<div className="space-y-4 text-xs font-medium">
<div>
<h4 className="text-[10px] font-bold uppercase tracking-wider text-ink-500">Title</h4>
<p className="text-sm font-extrabold text-ink-900 mt-1">{asset.title}</p>
</div>
{asset.description && (
<div>
<h4 className="text-[10px] font-bold uppercase tracking-wider text-ink-500">Description</h4>
<p className="text-ink-700 mt-1 leading-relaxed">{asset.description}</p>
</div>
)}
<div className="grid grid-cols-2 gap-4">
<div>
<h4 className="text-[10px] font-bold uppercase tracking-wider text-ink-500">Category</h4>
<p className="text-ink-900 mt-1 font-bold">{asset.categoryId || 'General'}</p>
</div>
<div>
<h4 className="text-[10px] font-bold uppercase tracking-wider text-ink-500">Subcategory</h4>
<p className="text-ink-900 mt-1 font-bold">{asset.subcategory || '-'}</p>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<h4 className="text-[10px] font-bold uppercase tracking-wider text-ink-500">File Size</h4>
<p className="text-ink-900 mt-1 font-bold">{asset.type === 'url' ? 'N/A' : formatBytes(asset.size)}</p>
</div>
<div>
<h4 className="text-[10px] font-bold uppercase tracking-wider text-ink-500">File Type</h4>
<p className="text-ink-900 mt-1 font-bold">{asset.type}</p>
</div>
</div>
{asset.tags.length > 0 && (
<div>
<h4 className="text-[10px] font-bold uppercase tracking-wider text-ink-500">Tags</h4>
<div className="flex flex-wrap gap-1.5 mt-1.5">
{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">
{tag}
</span>
))}
</div>
</div>
)}
{userRole === 'ADMIN' && asset.sharedWith && (
<div>
<h4 className="text-[10px] font-bold uppercase tracking-wider text-ink-500">Shared With</h4>
<div className="flex flex-wrap gap-1.5 mt-1.5">
{asset.sharedWith.length === 0 ? (
<span className="text-ink-500 font-semibold italic">Not shared with any organization</span>
) : (
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">
{sw.organization?.name || 'Unknown Organization'} {sw.user ? `(${sw.user.email})` : '(Entire Org)'}
</span>
))
)}
</div>
</div>
)}
<div className="grid grid-cols-2 gap-4">
<div>
<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 font-sans">{asset.categoryId || 'General'}</p>
</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>
<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 font-sans">{asset.subcategory || '-'}</p>
</div>
</motion.div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<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 font-sans">{asset.type === 'url' ? 'N/A' : formatBytes(asset.size)}</p>
</div>
<div>
<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 font-sans">{asset.type}</p>
</div>
</div>
{asset.tags.length > 0 && (
<div>
<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">
{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 font-sans">
{tag}
</span>
))}
</div>
</div>
)}
{userRole === 'ADMIN' && asset.sharedWith && (
<div>
<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">
{asset.sharedWith.length === 0 ? (
<span className="text-ink-500 font-semibold italic font-sans">Not shared with any organization</span>
) : (
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 font-sans">
{sw.organization?.name || 'Unknown Organization'} {sw.user ? `(${sw.user.email})` : '(Entire Org)'}
</span>
))
)}
</div>
</div>
)}
</div>
)}
</AnimatePresence>
</Modal>
);
};

View File

@ -2,6 +2,7 @@ import React, { useState, useEffect } from 'react';
import type { Asset, Category } from '../../../types';
import { apiClient } from '../../../lib/api-client';
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 }) => (
<svg
@ -19,6 +20,7 @@ const GithubIcon: React.FC<{ className?: string }> = ({ className }) => (
);
export const AssetExplorer: React.FC = () => {
const { success, error } = useToast();
const [assets, setAssets] = useState<Asset[]>([]);
const [categories, setCategories] = useState<Category[]>([]);
const [loading, setLoading] = useState(true);
@ -30,9 +32,6 @@ export const AssetExplorer: React.FC = () => {
// Selected asset details modal state
const [selectedAsset, setSelectedAsset] = useState<Asset | null>(null);
// Toast notifications simulation
const [toastMessage, setToastMessage] = useState<string | null>(null);
const fetchAssets = async () => {
setLoading(true);
@ -70,13 +69,8 @@ export const AssetExplorer: React.FC = () => {
fetchCategories();
}, []);
const triggerToast = (msg: string) => {
setToastMessage(msg);
setTimeout(() => setToastMessage(null), 3000);
};
const handleDownload = async (asset: Asset) => {
triggerToast(`Starting download: ${asset.title}`);
success('Starting download', `Downloading ${asset.title}...`);
try {
const updatedAsset = { ...asset, downloadsCount: asset.downloadsCount + 1 };
await apiClient.put(`/assets/${asset.id}`, updatedAsset);
@ -87,6 +81,7 @@ export const AssetExplorer: React.FC = () => {
}
} catch (err) {
console.error(err);
error('Download tracking failed', 'Unable to record download statistics.');
}
};
@ -104,15 +99,6 @@ export const AssetExplorer: React.FC = () => {
return (
<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="space-y-2">
<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 { motion, AnimatePresence } from 'framer-motion';
import { X, Minimize2, Maximize2, Globe, ExternalLink, FileText, File, Download } from 'lucide-react';
import { Minimize2, Maximize2, Globe, ExternalLink, FileText, File, Download } from 'lucide-react';
import { axiosInstance } from '../../../services/axios';
import type { Asset } from '../../../types/assets';
import type { User } from '../../../types/auth';
import Modal from '../../../components/ui/Modal';
import Button from '../../../components/ui/Button';
interface AssetViewerModalProps {
isOpen: boolean;
@ -133,213 +134,197 @@ export const AssetViewerModal: React.FC<AssetViewerModalProps> = ({
};
return (
<AnimatePresence>
{isOpen && asset && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={handleClose}
className="absolute inset-0 bg-ink-950/60 backdrop-blur-md"
/>
<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]'
}`}
<Modal
isOpen={isOpen && !!asset}
onClose={handleClose}
title={
<div className="flex items-center justify-between w-full">
<div className="text-left">
<span className="text-base font-bold text-ink-900 block font-sans">{asset?.title}</span>
<span className="text-xs text-ink-500 font-sans block mt-0.5 font-normal">
{asset?.type === 'url' ? 'External Web Link' : `${asset?.type}${asset ? formatBytes(asset.size) : ''}`}
</span>
</div>
<button
onClick={() => setIsMaximized(!isMaximized)}
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"}
>
<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>
{isMaximized ? <Minimize2 className="w-4 h-4" /> : <Maximize2 className="w-4 h-4" />}
</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}
variant="ghost"
size="sm"
>
Close Preview
</Button>
{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') ? (
<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">
<span className="flex items-center gap-1.5">
<Globe className="w-4 h-4 text-ink-950" />
<span className="font-sans">Embedded GitHub Document</span>
</span>
</div>
<div className="flex items-center gap-1.5">
<button
onClick={() => setIsMaximized(!isMaximized)}
className="p-1.5 rounded-lg text-ink-400 hover:text-ink-900 hover:bg-ink-50 transition-colors"
title={isMaximized ? "Collapse view" : "Expand view"}
>
{isMaximized ? <Minimize2 className="w-5 h-5" /> : <Maximize2 className="w-5 h-5" />}
</button>
<button
onClick={handleClose}
className="p-1 rounded-lg text-ink-400 hover:text-ink-900 hover:bg-ink-50 transition-colors"
>
<X className="w-5 h-5" />
</button>
<div className="flex-1 overflow-auto p-6 text-left select-text bg-ink-0 text-ink-800 font-sans leading-relaxed">
{isLoadingText ? (
<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" />
<span className="text-xs text-ink-500 font-medium font-sans">Fetching README.md...</span>
</div>
) : (
<div className="max-w-3xl mx-auto space-y-4">
<div className="border-b border-ink-200 pb-4 mb-6">
<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>
</div>
<pre className="whitespace-pre-wrap font-sans text-sm text-ink-800 break-words leading-relaxed">
{textPreviewContent}
</pre>
</div>
)}
</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.type === 'url' && asset.url.includes('github.com') ? (
<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">
<span className="flex items-center gap-1.5">
<Globe className="w-4 h-4 text-ink-950" />
<span>Embedded GitHub Document</span>
</span>
</div>
<div className="flex-1 overflow-auto p-6 text-left select-text bg-ink-0 text-ink-800 font-sans leading-relaxed">
{isLoadingText ? (
<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" />
<span className="text-xs text-ink-500 font-medium">Fetching README.md...</span>
</div>
) : (
<div className="max-w-3xl mx-auto space-y-4">
<div className="border-b border-ink-200 pb-4 mb-6">
<h1 className="text-xl font-extrabold text-ink-950">{asset.title}</h1>
<p className="text-xs text-ink-500 mt-1 font-mono">{asset.url}</p>
</div>
<pre className="whitespace-pre-wrap font-sans text-sm text-ink-800 break-words leading-relaxed">
{textPreviewContent}
</pre>
</div>
)}
</div>
) : asset.type === 'url' ? (
<div className="text-center p-12 max-w-md space-y-4 flex flex-col justify-center items-center">
<div className="w-16 h-16 rounded-2xl bg-ink-100 border border-ink-200 flex items-center justify-center text-ink-900 shadow-sm">
<Globe className="w-8 h-8" />
</div>
<div>
<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 font-sans">
This asset points to an external destination outside of the local CDN container.
</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">
{asset.url}
</div>
) : asset.type === 'url' ? (
<div className="text-center p-12 max-w-md space-y-4 flex flex-col justify-center items-center">
<div className="w-16 h-16 rounded-2xl bg-ink-100 border border-ink-200 flex items-center justify-center text-ink-900 shadow-sm">
<Globe className="w-8 h-8" />
</div>
{(user?.role === 'ADMIN' || asset.isDownloadable || asset.downloadRequests?.[0]?.status === 'APPROVED') ? (
<a
href={asset.url}
target="_blank"
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 font-sans"
>
<span>Open Link in New Tab</span>
<ExternalLink className="w-4 h-4" />
</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 font-sans">
Access Restricted: You must request and receive download approval from the Administrator to open this resource link.
</div>
)}
</div>
) : asset.type.includes('pdf') ? (
<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">
<FileText className="w-4 h-4 text-ink-950 mr-1.5" />
<span className="font-sans">Interactive PDF Preview</span>
</div>
<iframe
src={getFullAssetUrl(asset.url)}
className="w-full flex-1 border-0 min-h-0"
title={asset.title}
/>
</div>
) : (asset.url.toLowerCase().endsWith('.md') || asset.url.toLowerCase().endsWith('.txt') || asset.type.includes('text') || asset.type.includes('markdown')) ? (
<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">
<FileText className="w-4 h-4 text-ink-950 mr-1.5" />
<span className="font-sans">Document Reader</span>
</div>
<div className="flex-1 overflow-auto p-6 text-left select-text bg-ink-0 text-ink-800 font-sans leading-relaxed">
{isLoadingText ? (
<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" />
<span className="text-xs text-ink-500 font-medium font-sans">Loading content...</span>
</div>
) : (
<div className="max-w-3xl mx-auto space-y-4">
<div className="border-b border-ink-200 pb-4 mb-6">
<h1 className="text-xl font-extrabold text-ink-950 font-sans">{asset.title}</h1>
<p className="text-xs text-ink-500 mt-1 font-sans">Plain Text / Markdown Format</p>
</div>
<pre className="whitespace-pre-wrap font-sans text-sm text-ink-800 break-words leading-relaxed">
{textPreviewContent}
</pre>
</div>
)}
</div>
</div>
) : (asset.type.includes('word') || asset.type.includes('presentation') || asset.type.includes('sheet') || asset.url.toLowerCase().endsWith('.docx') || asset.url.toLowerCase().endsWith('.doc') || asset.url.toLowerCase().endsWith('.pptx') || asset.url.toLowerCase().endsWith('.ppt') || asset.url.toLowerCase().endsWith('.xlsx') || asset.url.toLowerCase().endsWith('.xls')) ? (
<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">
<FileText className="w-4 h-4 text-ink-950 mr-1.5" />
<span className="font-sans">Office Document Preview</span>
</div>
{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="w-16 h-16 rounded-2xl bg-ink-55 border border-ink-100 flex items-center justify-center text-ink-900 shadow-sm">
<FileText className="w-8 h-8" />
</div>
<div>
<h4 className="text-sm font-bold text-ink-900">External Resource Portal</h4>
<p className="text-xs text-ink-500 mt-2 leading-relaxed">
This asset points to an external destination outside of the local CDN container.
<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 font-sans">
This asset is a Microsoft Office document ({asset.type.split('/').pop()?.toUpperCase() || 'DOCX'}).
</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 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.
</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">
{asset.url}
</div>
</div>
{(user?.role === 'ADMIN' || asset.isDownloadable || asset.downloadRequests?.[0]?.status === 'APPROVED') ? (
<a
href={asset.url}
target="_blank"
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"
>
<span>Open Link in New Tab</span>
<ExternalLink className="w-4 h-4" />
</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">
Access Restricted: You must request and receive download approval from the Administrator to open this resource link.
</div>
)}
</div>
) : asset.type.includes('pdf') ? (
<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">
<FileText className="w-4 h-4 text-ink-950 mr-1.5" />
<span>Interactive PDF Preview</span>
</div>
<iframe
src={getFullAssetUrl(asset.url)}
className="w-full flex-1 border-0 min-h-0"
title={asset.title}
/>
</div>
) : (asset.url.toLowerCase().endsWith('.md') || asset.url.toLowerCase().endsWith('.txt') || asset.type.includes('text') || asset.type.includes('markdown')) ? (
<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">
<FileText className="w-4 h-4 text-ink-950 mr-1.5" />
<span>Document Reader</span>
</div>
<div className="flex-1 overflow-auto p-6 text-left select-text bg-ink-0 text-ink-800 font-sans leading-relaxed">
{isLoadingText ? (
<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" />
<span className="text-xs text-ink-500 font-medium">Loading content...</span>
</div>
) : (
<div className="max-w-3xl mx-auto space-y-4">
<div className="border-b border-ink-200 pb-4 mb-6">
<h1 className="text-xl font-extrabold text-ink-950">{asset.title}</h1>
<p className="text-xs text-ink-500 mt-1">Plain Text / Markdown Format</p>
</div>
<pre className="whitespace-pre-wrap font-sans text-sm text-ink-800 break-words leading-relaxed">
{textPreviewContent}
</pre>
</div>
)}
</div>
</div>
) : (asset.type.includes('word') || asset.type.includes('presentation') || asset.type.includes('sheet') || asset.url.toLowerCase().endsWith('.docx') || asset.url.toLowerCase().endsWith('.doc') || asset.url.toLowerCase().endsWith('.pptx') || asset.url.toLowerCase().endsWith('.ppt') || asset.url.toLowerCase().endsWith('.xlsx') || asset.url.toLowerCase().endsWith('.xls')) ? (
<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">
<FileText className="w-4 h-4 text-ink-950 mr-1.5" />
<span>Office Document Preview</span>
</div>
{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="w-16 h-16 rounded-2xl bg-ink-55 border border-ink-100 flex items-center justify-center text-ink-900 shadow-sm">
<FileText className="w-8 h-8" />
</div>
<div>
<h4 className="text-sm font-bold text-ink-900">Office Document Preview</h4>
<p className="text-xs text-ink-500 mt-2 leading-relaxed">
This asset is a Microsoft Office document ({asset.type.split('/').pop()?.toUpperCase() || 'DOCX'}).
</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">
<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>
</div>
</div>
) : (
<iframe
src={`https://view.officeapps.live.com/op/embed.aspx?src=${encodeURIComponent(getFullAssetUrl(asset.url))}`}
className="w-full flex-1 border-0 min-h-0"
title={asset.title}
/>
)}
</div>
) : asset.type.includes('image') || asset.type.includes('png') || asset.type.includes('jpg') ? (
<div className="w-full h-full flex items-center justify-center p-4">
<img
src={getFullAssetUrl(asset.url)}
alt={asset.title}
className="max-w-full max-h-full object-contain rounded-xl shadow-sm border border-ink-100"
/>
</div>
) : (
<div className="text-center p-8 flex flex-col justify-center items-center">
<File className="w-12 h-12 text-ink-300 mb-3" />
<h4 className="text-sm font-bold text-ink-900">Direct Preview Unsupported</h4>
<p className="text-xs text-ink-500 mt-1 max-w-sm">
This file format cannot be rendered directly in the browser. Please download the file to inspect its contents.
</p>
</div>
<iframe
src={`https://view.officeapps.live.com/op/embed.aspx?src=${encodeURIComponent(getFullAssetUrl(asset.url))}`}
className="w-full flex-1 border-0 min-h-0"
title={asset.title}
/>
)}
</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>
)}
) : asset.type.includes('image') || asset.type.includes('png') || asset.type.includes('jpg') ? (
<div className="w-full h-full flex items-center justify-center p-4">
<img
src={getFullAssetUrl(asset.url)}
alt={asset.title}
className="max-w-full max-h-full object-contain rounded-xl shadow-sm border border-ink-100"
/>
</div>
</motion.div>
) : (
<div className="text-center p-8 flex flex-col justify-center items-center">
<File className="w-12 h-12 text-ink-300 mb-3" />
<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 font-sans">
This file format cannot be rendered directly in the browser. Please download the file to inspect its contents.
</p>
</div>
)}
</div>
)}
</AnimatePresence>
</Modal>
);
};

View File

@ -1,7 +1,8 @@
import React from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { X, Check } from 'lucide-react';
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 {
isOpen: boolean;
@ -29,82 +30,59 @@ export const DownloadRequestsModal: React.FC<DownloadRequestsModalProps> = ({
);
return (
<AnimatePresence>
{isOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={onClose}
className="absolute inset-0 bg-ink-950/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 }}
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">
<Modal
isOpen={isOpen}
onClose={onClose}
title="Pending Download Requests"
subtitle="Review and approve download access for protected secret assets."
size="lg"
footer={
<Button
onClick={onClose}
variant="primary"
size="sm"
>
Close
</Button>
}
>
<div className="space-y-3">
{pendingRequests.length === 0 ? (
<div className="text-center py-8">
<Check className="w-8 h-8 text-ink-400 mx-auto mb-2" />
<p className="text-xs font-bold text-ink-900 font-sans">All caught up!</p>
<p className="text-[10px] text-ink-500 font-sans">There are no pending download authorization requests.</p>
</div>
) : (
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>
<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>
<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 font-sans">
Requested download for: <span className="text-ink-900 font-bold">{req.assetTitle}</span>
</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" />
</button>
</div>
<div className="flex-1 overflow-y-auto mt-4 space-y-3 pr-1 scrollbar-thin">
{pendingRequests.length === 0 ? (
<div className="text-center py-8">
<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-[10px] text-ink-500">There are no pending download authorization requests.</p>
</div>
) : (
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>
<p className="text-xs font-bold text-ink-900">{req.user?.email}</p>
<p className="text-[10px] text-ink-500 mt-0.5 font-medium">
Requested download for: <span className="text-ink-900 font-bold">{req.assetTitle}</span>
</p>
</div>
<div className="flex items-center gap-2 self-end sm:self-center">
<button
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"
>
Reject
</button>
<button
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"
>
Approve Access
</button>
</div>
</div>
))
)}
<div className="flex items-center gap-2 self-end sm:self-center">
<Button
onClick={() => onReject(req.assetId, req.id)}
variant="danger"
size="xs"
>
Reject
</Button>
<Button
onClick={() => onApprove(req.assetId, req.id)}
variant="primary"
size="xs"
>
Approve Access
</Button>
</div>
</div>
<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>
))
)}
</div>
</Modal>
);
};

View File

@ -1,8 +1,9 @@
import React, { useState, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { X } from 'lucide-react';
import { updateAsset } from '../../../services/assets-api';
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 {
isOpen: boolean;
@ -17,6 +18,7 @@ export const EditAssetModal: React.FC<EditAssetModalProps> = ({
asset,
onSuccess
}) => {
const { success, error } = useToast();
const [editTitle, setEditTitle] = useState('');
const [editDescription, setEditDescription] = useState('');
const [editCategory, setEditCategory] = useState('Marketing');
@ -53,155 +55,139 @@ export const EditAssetModal: React.FC<EditAssetModalProps> = ({
githubUrl: editGithubUrl,
isDownloadable: editIsDownloadable,
});
success('Changes saved successfully', 'Asset details have been updated.');
onSuccess();
onClose();
} catch (err) {
} catch (err: any) {
console.error('Failed to save asset details', err);
error('Failed to save changes', err.response?.data?.error || 'Something went wrong.');
} finally {
setIsSavingEdit(false);
}
};
return (
<AnimatePresence>
{isOpen && asset && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
<Modal
isOpen={isOpen && !!asset}
onClose={onClose}
title="Edit Asset Details"
size="lg"
footer={
<>
<Button
type="button"
onClick={onClose}
className="absolute inset-0 bg-ink-950/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 }}
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"
variant="ghost"
size="sm"
>
<div className="flex justify-between items-center pb-3.5 border-b border-ink-100 flex-shrink-0">
<h3 className="text-lg font-bold text-ink-900">Edit Asset Details</h3>
<button
onClick={onClose}
className="p-1 rounded-lg text-ink-400 hover:text-ink-900 hover:bg-ink-50 transition-colors"
Cancel
</Button>
<Button
type="submit"
form="edit-asset-form"
disabled={isSavingEdit}
variant="primary"
size="sm"
>
{isSavingEdit ? 'Saving...' : 'Save Changes'}
</Button>
</>
}
>
{asset && (
<form id="edit-asset-form" onSubmit={handleEditSubmit} className="space-y-4">
<div>
<label className="text-xs font-semibold text-ink-500 mb-1.5 block">Asset Title</label>
<input
type="text"
value={editTitle}
onChange={(e) => setEditTitle(e.target.value)}
required
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 className="grid grid-cols-2 gap-4">
<div>
<label className="text-xs font-semibold text-ink-500 mb-1.5 block">Category</label>
<select
value={editCategory}
onChange={(e) => setEditCategory(e.target.value)}
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"
>
<X className="w-5 h-5" />
</button>
<option value="Marketing">Marketing</option>
<option value="Presentations">Presentations</option>
<option value="Branding">Branding</option>
<option value="Resources">Resources</option>
<option value="Technical">Technical</option>
</select>
</div>
<div>
<label className="text-xs font-semibold text-ink-500 mb-1.5 block">Subcategory</label>
<input
type="text"
value={editSubcategory}
onChange={(e) => setEditSubcategory(e.target.value)}
placeholder="e.g. Slide Deck"
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>
<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">
<div>
<label className="text-xs font-semibold text-ink-500 mb-1.5 block">Asset Title</label>
<input
type="text"
value={editTitle}
onChange={(e) => setEditTitle(e.target.value)}
required
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>
<label className="text-xs font-semibold text-ink-500 mb-1.5 block">Description</label>
<textarea
value={editDescription}
onChange={(e) => setEditDescription(e.target.value)}
placeholder="Enter short description..."
rows={3}
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 resize-none"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-xs font-semibold text-ink-500 mb-1.5 block">Category</label>
<select
value={editCategory}
onChange={(e) => setEditCategory(e.target.value)}
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"
>
<option value="Marketing">Marketing</option>
<option value="Presentations">Presentations</option>
<option value="Branding">Branding</option>
<option value="Resources">Resources</option>
<option value="Technical">Technical</option>
</select>
</div>
<div>
<label className="text-xs font-semibold text-ink-500 mb-1.5 block">Subcategory</label>
<input
type="text"
value={editSubcategory}
onChange={(e) => setEditSubcategory(e.target.value)}
placeholder="e.g. Slide Deck"
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>
<label className="text-xs font-semibold text-ink-500 mb-1.5 block">Description</label>
<textarea
value={editDescription}
onChange={(e) => setEditDescription(e.target.value)}
placeholder="Enter short description..."
rows={3}
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 resize-none"
/>
</div>
{asset.type !== 'url' && (
<div className="flex items-center gap-3 p-3 bg-ink-50 border border-ink-200 rounded-lg">
<input
type="checkbox"
id="editIsDownloadable"
checked={editIsDownloadable}
onChange={(e) => setEditIsDownloadable(e.target.checked)}
className="w-4 h-4 rounded border-ink-300 text-ink-900 focus:ring-ink-900/10 cursor-pointer"
/>
<div>
<label htmlFor="editIsDownloadable" className="text-xs font-bold text-ink-900 cursor-pointer block">
Allow Direct Download (Strict View Only if unchecked)
</label>
<span className="text-[10px] text-ink-500">
Toggle client authorization requirement for asset downloads.
</span>
</div>
</div>
)}
<div>
<label className="text-xs font-semibold text-ink-500 mb-1.5 block">Tags (comma-separated)</label>
<input
type="text"
value={editTags}
onChange={(e) => setEditTags(e.target.value)}
placeholder="branding, guideline, pitch"
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>
<label className="text-xs font-semibold text-ink-500 mb-1.5 block">GitHub/Documentation URL (Optional)</label>
<input
type="url"
value={editGithubUrl}
onChange={(e) => setEditGithubUrl(e.target.value)}
placeholder="https://github.com/..."
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>
{asset.type !== 'url' && (
<div className="flex items-center gap-3 p-3 bg-ink-50 border border-ink-200 rounded-lg">
<input
type="checkbox"
id="editIsDownloadable"
checked={editIsDownloadable}
onChange={(e) => setEditIsDownloadable(e.target.checked)}
className="w-4 h-4 rounded border-ink-300 text-ink-900 focus:ring-ink-900/10 cursor-pointer"
/>
<div>
<label htmlFor="editIsDownloadable" className="text-xs font-bold text-ink-900 cursor-pointer block">
Allow Direct Download (Strict View Only if unchecked)
</label>
<span className="text-[10px] text-ink-500">
Toggle client authorization requirement for asset downloads.
</span>
</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>
</motion.div>
</div>
</div>
)}
<div>
<label className="text-xs font-semibold text-ink-500 mb-1.5 block">Tags (comma-separated)</label>
<input
type="text"
value={editTags}
onChange={(e) => setEditTags(e.target.value)}
placeholder="branding, guideline, pitch"
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>
<label className="text-xs font-semibold text-ink-500 mb-1.5 block">GitHub/Documentation URL (Optional)</label>
<input
type="url"
value={editGithubUrl}
onChange={(e) => setEditGithubUrl(e.target.value)}
placeholder="https://github.com/..."
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>
</form>
)}
</AnimatePresence>
</Modal>
);
};

View File

@ -1,8 +1,11 @@
import React, { useState, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { X, ChevronDown, ChevronUp } from 'lucide-react';
import { updateAsset } from '../../../services/assets-api';
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 {
isOpen: boolean;
@ -19,6 +22,7 @@ export const ShareAssetModal: React.FC<ShareAssetModalProps> = ({
organizations,
onSuccess
}) => {
const { success, error } = useToast();
const [sharesList, setSharesList] = useState<ShareItem[]>([]);
const [isSavingShare, setIsSavingShare] = useState(false);
const [expandedOrgId, setExpandedOrgId] = useState<string | null>(null);
@ -75,151 +79,132 @@ export const ShareAssetModal: React.FC<ShareAssetModalProps> = ({
await updateAsset(asset.id, {
shares: sharesList
});
success('Share permissions updated', 'The asset visibility settings have been updated.');
onSuccess();
onClose();
} catch (err) {
} catch (err: any) {
console.error('Failed to update share permissions', err);
error('Failed to update share permissions', err.response?.data?.error || 'Something went wrong.');
} finally {
setIsSavingShare(false);
}
};
return (
<AnimatePresence>
{isOpen && asset && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
<Modal
isOpen={isOpen && !!asset}
onClose={onClose}
title="Share Settings"
subtitle={asset?.title}
size="md"
footer={
<>
<Button
type="button"
onClick={onClose}
className="absolute inset-0 bg-ink-950/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 }}
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"
variant="ghost"
size="sm"
>
<div className="flex justify-between items-center pb-3.5 border-b border-ink-100 flex-shrink-0">
<div>
<h3 className="text-lg font-bold text-ink-900">Share Settings</h3>
<p className="text-xs text-ink-500 mt-0.5">{asset.title}</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" />
</button>
</div>
Cancel
</Button>
<Button
type="submit"
form="share-asset-form"
disabled={isSavingShare}
variant="primary"
size="sm"
>
{isSavingShare ? 'Saving...' : 'Update Shares'}
</Button>
</>
}
>
{asset && (
<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:
</p>
<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">
<p className="text-xs text-ink-600 leading-relaxed">
Select organizations or expand to specify exact users that can access this asset:
</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">
{organizations.length === 0 ? (
<p className="p-4 text-xs text-ink-500 text-center font-medium font-sans">No partner organizations registered yet.</p>
) : (
organizations.map(org => {
const isEntireShared = isOrgSharedEntirely(org.id);
const isExpanded = expandedOrgId === org.id;
const activeUsers = org.users || [];
const specificSharedCount = sharesList.filter(s => s.organizationId === org.id && s.userId !== null).length;
<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 ? (
<p className="p-4 text-xs text-ink-500 text-center font-medium">No partner organizations registered yet.</p>
) : (
organizations.map(org => {
const isEntireShared = isOrgSharedEntirely(org.id);
const isExpanded = expandedOrgId === org.id;
const activeUsers = org.users || [];
const specificSharedCount = sharesList.filter(s => s.organizationId === org.id && s.userId !== null).length;
return (
<div key={org.id} className="flex flex-col">
<div className="flex items-center justify-between p-3 hover:bg-ink-100 transition-colors">
<label className="flex items-center gap-3 cursor-pointer flex-1 select-none">
<input
type="checkbox"
checked={isEntireShared}
onChange={() => handleToggleOrg(org.id)}
className="w-4 h-4 rounded border-ink-300 text-ink-900 focus:ring-ink-900/10 cursor-pointer"
/>
<div className="flex flex-col">
<span className="text-xs font-bold text-ink-900">{org.name}</span>
{specificSharedCount > 0 && !isEntireShared && (
<span className="text-[10px] text-ink-500 font-semibold">
Shared with {specificSharedCount} specific {specificSharedCount === 1 ? 'user' : 'users'}
</span>
)}
</div>
</label>
<button
type="button"
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"
>
<span>Users</span>
{isExpanded ? <ChevronUp className="w-3.5 h-3.5" /> : <ChevronDown className="w-3.5 h-3.5" />}
</button>
</div>
<AnimatePresence>
{isExpanded && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
className="bg-ink-100 border-t border-b border-ink-200 overflow-hidden divide-y divide-ink-150"
>
{activeUsers.length === 0 ? (
<p className="p-3 text-[10px] text-ink-500 italic">No users found in this organization.</p>
) : (
activeUsers.map(userItem => {
const isUserShared = isUserSharedSpecifically(org.id, userItem.id);
return (
<label key={userItem.id} className="flex items-center gap-3 py-2 px-8 cursor-pointer hover:bg-ink-200/50 transition-all select-none">
<input
type="checkbox"
disabled={isEntireShared}
checked={isEntireShared || isUserShared}
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"
/>
<span className={`text-[11px] font-semibold ${isEntireShared ? 'text-ink-400' : 'text-ink-800'}`}>
{userItem.email}
</span>
</label>
);
})
)}
</motion.div>
)}
</AnimatePresence>
return (
<div key={org.id} className="flex flex-col">
<div className="flex items-center justify-between p-3 hover:bg-ink-100 transition-colors">
<label className="flex items-center gap-3 cursor-pointer flex-1 select-none">
<input
type="checkbox"
checked={isEntireShared}
onChange={() => handleToggleOrg(org.id)}
className="w-4 h-4 rounded border-ink-300 text-ink-900 focus:ring-ink-900/10 cursor-pointer"
/>
<div className="flex flex-col">
<span className="text-xs font-bold text-ink-900 font-sans">{org.name}</span>
{specificSharedCount > 0 && !isEntireShared && (
<span className="text-[10px] text-ink-500 font-semibold font-sans">
Shared with {specificSharedCount} specific {specificSharedCount === 1 ? 'user' : 'users'}
</span>
)}
</div>
);
})
)}
</div>
</div>
</label>
<button
type="button"
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 cursor-pointer font-sans"
>
<span>Users</span>
{isExpanded ? <ChevronUp className="w-3.5 h-3.5" /> : <ChevronDown className="w-3.5 h-3.5" />}
</button>
</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>
</motion.div>
</div>
<AnimatePresence>
{isExpanded && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
className="bg-ink-100 border-t border-b border-ink-200 overflow-hidden divide-y divide-ink-150"
>
{activeUsers.length === 0 ? (
<p className="p-3 text-[10px] text-ink-500 italic font-sans">No users found in this organization.</p>
) : (
activeUsers.map(userItem => {
const isUserShared = isUserSharedSpecifically(org.id, userItem.id);
return (
<label key={userItem.id} className="flex items-center gap-3 py-2 px-8 cursor-pointer hover:bg-ink-200/50 transition-all select-none">
<input
type="checkbox"
disabled={isEntireShared}
checked={isEntireShared || isUserShared}
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"
/>
<span className={`text-[11px] font-semibold font-sans ${isEntireShared ? 'text-ink-400' : 'text-ink-800'}`}>
{userItem.email}
</span>
</label>
);
})
)}
</motion.div>
)}
</AnimatePresence>
</div>
);
})
)}
</div>
</form>
)}
</AnimatePresence>
</Modal>
);
};

View File

@ -1,7 +1,9 @@
import React, { useState, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { X, UploadCloud, Eye, FileText, File } from 'lucide-react';
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 {
isOpen: boolean;
@ -14,6 +16,7 @@ export const UploadAssetModal: React.FC<UploadAssetModalProps> = ({
onClose,
onSuccess
}) => {
const { success, error } = useToast();
const [uploadTab, setUploadTab] = useState<'file' | 'url'>('file');
const [uploadFile, setUploadFile] = useState<File | null>(null);
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
@ -88,10 +91,12 @@ export const UploadAssetModal: React.FC<UploadAssetModalProps> = ({
setUploadTags('');
setUploadGithubUrl('');
setUploadIsDownloadable(true);
success('Asset published successfully', 'The asset has been added to the catalog.');
onSuccess();
onClose();
} catch (err) {
} catch (err: any) {
console.error('Failed to upload asset', err);
error('Failed to publish asset', err.response?.data?.error || 'Something went wrong.');
} finally {
setIsUploading(false);
}
@ -99,312 +104,273 @@ export const UploadAssetModal: React.FC<UploadAssetModalProps> = ({
return (
<>
<AnimatePresence>
{isOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
<Modal
isOpen={isOpen}
onClose={onClose}
title="Upload / Link Asset"
size="lg"
footer={
<>
<Button
type="button"
onClick={onClose}
className="absolute inset-0 bg-ink-950/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 }}
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"
variant="ghost"
size="sm"
>
<div className="flex justify-between items-center pb-3.5 border-b border-ink-100 flex-shrink-0">
<h3 className="text-lg font-bold text-ink-900">Upload / Link Asset</h3>
<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" />
</button>
</div>
Cancel
</Button>
<Button
type="submit"
form="upload-asset-form"
disabled={isUploading}
variant="primary"
size="sm"
>
{isUploading ? 'Publishing...' : 'Publish Asset'}
</Button>
</>
}
>
{/* Toggle upload tabs */}
<div className="flex bg-ink-50 p-1 rounded-xl border border-ink-200 mt-2">
<button
type="button"
onClick={() => setUploadTab('file')}
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
</button>
<button
type="button"
onClick={() => setUploadTab('url')}
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
</button>
</div>
{/* Toggle upload tabs */}
<div className="flex bg-ink-50 p-1 rounded-xl border border-ink-200 flex-shrink-0 mt-4">
<button
type="button"
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'}`}
<form id="upload-asset-form" onSubmit={handleUploadSubmit} className="space-y-4 mt-4">
{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' : ''}`}>
{!uploadFile && (
<input
type="file"
onChange={(e) => {
if (e.target.files?.[0]) {
setUploadFile(e.target.files[0]);
setUploadTitle(e.target.files[0].name);
}
}}
required={uploadTab === 'file'}
className="absolute inset-0 opacity-0 cursor-pointer z-10"
/>
)}
{uploadFile && !previewUrl && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
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 cursor-pointer"
title="Remove file"
>
Secure File Upload
<X className="w-3.5 h-3.5" />
</button>
<button
type="button"
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'}`}
>
External Web URL
</button>
</div>
<form onSubmit={handleUploadSubmit} 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">
{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' : ''}`}>
{!uploadFile && (
<input
type="file"
onChange={(e) => {
if (e.target.files?.[0]) {
setUploadFile(e.target.files[0]);
setUploadTitle(e.target.files[0].name);
}
}}
required={uploadTab === 'file'}
className="absolute inset-0 opacity-0 cursor-pointer z-10"
/>
)}
{uploadFile && !previewUrl && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
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"
title="Remove file"
>
<X className="w-3.5 h-3.5" />
</button>
)}
{previewUrl && uploadFile ? (
<div className="relative z-20 py-1">
<div className="relative w-24 h-24 mx-auto mb-3">
<img
src={previewUrl}
alt="Upload preview"
className="w-full h-full object-cover rounded-lg shadow-sm border border-ink-200"
/>
</div>
<div className="flex items-center justify-between gap-2 max-w-xs mx-auto px-2.5 py-1.5 bg-ink-0 border border-ink-200 rounded-lg shadow-sm relative z-30 mb-1.5">
<span className="text-[11px] font-semibold text-ink-900 truncate flex-1 text-left" title={uploadFile?.name}>
{uploadFile?.name}
</span>
<div className="flex items-center gap-1 flex-shrink-0">
<button
type="button"
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
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"
title="Preview Image"
>
<Eye className="w-3 h-3" />
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
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"
title="Remove File"
>
<X className="w-3 h-3" />
</button>
</div>
</div>
<p className="text-[10px] text-ink-500">
{formatBytes(uploadFile?.size || 0)}
</p>
</div>
) : uploadFile ? (
<div className="relative z-0 py-1">
<div className="w-12 h-12 rounded-lg bg-ink-100 border border-ink-200 flex items-center justify-center mx-auto mb-2">
{uploadFile?.name?.endsWith('.pdf') ? (
<FileText className="w-6 h-6 text-ink-600" />
) : (
<File className="w-6 h-6 text-ink-600" />
)}
</div>
<p className="text-xs font-bold text-ink-900 truncate max-w-xs mx-auto">
{uploadFile?.name}
</p>
<p className="text-[10px] text-ink-500 mt-0.5">
{formatBytes(uploadFile?.size || 0)}
</p>
</div>
) : (
<div className="relative z-0">
<UploadCloud className="w-8 h-8 text-ink-400 mx-auto mb-1.5" />
<p className="text-xs font-bold text-ink-900">
Drag & drop or click to upload file
</p>
<p className="text-[10px] text-ink-500 mt-0.5">PDF, ZIP, PNG, JPG up to 50MB</p>
</div>
)}
</div>
) : (
<div>
<label className="text-xs font-semibold text-ink-500 mb-1.5 block">External Asset URL</label>
<input
type="url"
value={uploadUrl}
onChange={(e) => setUploadUrl(e.target.value)}
placeholder="https://example.com/partner-docs"
required={uploadTab === 'url'}
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>
<label className="text-xs font-semibold text-ink-500 mb-1.5 block">Asset Title</label>
<input
type="text"
value={uploadTitle}
onChange={(e) => setUploadTitle(e.target.value)}
placeholder="Enter descriptive title"
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"
)}
{previewUrl && uploadFile ? (
<div className="relative z-20 py-1">
<div className="relative w-24 h-24 mx-auto mb-3">
<img
src={previewUrl}
alt="Upload preview"
className="w-full h-full object-cover rounded-lg shadow-sm border border-ink-200"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-xs font-semibold text-ink-500 mb-1.5 block">Category</label>
<select
value={uploadCategory}
onChange={(e) => setUploadCategory(e.target.value)}
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"
<div className="flex items-center justify-between gap-2 max-w-xs mx-auto px-2.5 py-1.5 bg-ink-0 border border-ink-200 rounded-lg shadow-sm relative z-30 mb-1.5">
<span className="text-[11px] font-semibold text-ink-900 truncate flex-1 text-left" title={uploadFile?.name}>
{uploadFile?.name}
</span>
<div className="flex items-center gap-1 flex-shrink-0">
<button
type="button"
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
setFullImagePreviewUrl(previewUrl);
}}
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"
>
<option value="Marketing">Marketing</option>
<option value="Presentations">Presentations</option>
<option value="Branding">Branding</option>
<option value="Resources">Resources</option>
<option value="Technical">Technical</option>
</select>
</div>
<div>
<label className="text-xs font-semibold text-ink-500 mb-1.5 block">Subcategory</label>
<input
type="text"
value={uploadSubcategory}
onChange={(e) => setUploadSubcategory(e.target.value)}
placeholder="e.g. Slide Deck"
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"
/>
<Eye className="w-3 h-3" />
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
setUploadFile(null);
}}
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"
>
<X className="w-3 h-3" />
</button>
</div>
</div>
<div>
<label className="text-xs font-semibold text-ink-500 mb-1.5 block">Description</label>
<textarea
value={uploadDescription}
onChange={(e) => setUploadDescription(e.target.value)}
placeholder="Enter short description about this asset..."
rows={3}
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 resize-none"
/>
</div>
{uploadTab === 'file' && (
<div className="flex items-center gap-3 p-3 bg-ink-50 border border-ink-200 rounded-lg">
<input
type="checkbox"
id="isDownloadable"
checked={uploadIsDownloadable}
onChange={(e) => setUploadIsDownloadable(e.target.checked)}
className="w-4 h-4 rounded border-ink-300 text-ink-900 focus:ring-ink-900/10 cursor-pointer"
/>
<div>
<label htmlFor="isDownloadable" className="text-xs font-bold text-ink-900 cursor-pointer block">
Allow Direct Download
</label>
<span className="text-[10px] text-ink-500">
If unchecked, clients must request manual download access (Strict View Only).
</span>
</div>
</div>
)}
<div>
<label className="text-xs font-semibold text-ink-500 mb-1.5 block">Tags (comma-separated)</label>
<input
type="text"
value={uploadTags}
onChange={(e) => setUploadTags(e.target.value)}
placeholder="branding, guideline, pitch"
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>
<label className="text-xs font-semibold text-ink-500 mb-1.5 block">GitHub/Documentation URL (Optional)</label>
<input
type="url"
value={uploadGithubUrl}
onChange={(e) => setUploadGithubUrl(e.target.value)}
placeholder="https://github.com/..."
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>
<p className="text-[10px] text-ink-500">
{formatBytes(uploadFile?.size || 0)}
</p>
</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>
) : uploadFile ? (
<div className="relative z-0 py-1">
<div className="w-12 h-12 rounded-lg bg-ink-100 border border-ink-200 flex items-center justify-center mx-auto mb-2">
{uploadFile?.name?.endsWith('.pdf') ? (
<FileText className="w-6 h-6 text-ink-600" />
) : (
<File className="w-6 h-6 text-ink-600" />
)}
</div>
<p className="text-xs font-bold text-ink-900 truncate max-w-xs mx-auto">
{uploadFile?.name}
</p>
<p className="text-[10px] text-ink-500 mt-0.5">
{formatBytes(uploadFile?.size || 0)}
</p>
</div>
</form>
</motion.div>
) : (
<div className="relative z-0">
<UploadCloud className="w-8 h-8 text-ink-400 mx-auto mb-1.5" />
<p className="text-xs font-bold text-ink-900">
Drag & drop or click to upload file
</p>
<p className="text-[10px] text-ink-500 mt-0.5">PDF, ZIP, PNG, JPG up to 50MB</p>
</div>
)}
</div>
) : (
<div>
<label className="text-xs font-semibold text-ink-500 mb-1.5 block">External Asset URL</label>
<input
type="url"
value={uploadUrl}
onChange={(e) => setUploadUrl(e.target.value)}
placeholder="https://example.com/partner-docs"
required={uploadTab === 'url'}
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>
<label className="text-xs font-semibold text-ink-500 mb-1.5 block">Asset Title</label>
<input
type="text"
value={uploadTitle}
onChange={(e) => setUploadTitle(e.target.value)}
placeholder="Enter descriptive title"
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>
)}
</AnimatePresence>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-xs font-semibold text-ink-500 mb-1.5 block">Category</label>
<select
value={uploadCategory}
onChange={(e) => setUploadCategory(e.target.value)}
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"
>
<option value="Marketing">Marketing</option>
<option value="Presentations">Presentations</option>
<option value="Branding">Branding</option>
<option value="Resources">Resources</option>
<option value="Technical">Technical</option>
</select>
</div>
<div>
<label className="text-xs font-semibold text-ink-500 mb-1.5 block">Subcategory</label>
<input
type="text"
value={uploadSubcategory}
onChange={(e) => setUploadSubcategory(e.target.value)}
placeholder="e.g. Slide Deck"
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>
<label className="text-xs font-semibold text-ink-500 mb-1.5 block">Description</label>
<textarea
value={uploadDescription}
onChange={(e) => setUploadDescription(e.target.value)}
placeholder="Enter short description about this asset..."
rows={3}
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 resize-none"
/>
</div>
{uploadTab === 'file' && (
<div className="flex items-center gap-3 p-3 bg-ink-50 border border-ink-200 rounded-lg">
<input
type="checkbox"
id="isDownloadable"
checked={uploadIsDownloadable}
onChange={(e) => setUploadIsDownloadable(e.target.checked)}
className="w-4 h-4 rounded border-ink-300 text-ink-900 focus:ring-ink-900/10 cursor-pointer"
/>
<div>
<label htmlFor="isDownloadable" className="text-xs font-bold text-ink-900 cursor-pointer block">
Allow Direct Download
</label>
<span className="text-[10px] text-ink-500">
If unchecked, clients must request manual download access (Strict View Only).
</span>
</div>
</div>
)}
<div>
<label className="text-xs font-semibold text-ink-500 mb-1.5 block">Tags (comma-separated)</label>
<input
type="text"
value={uploadTags}
onChange={(e) => setUploadTags(e.target.value)}
placeholder="branding, guideline, pitch"
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>
<label className="text-xs font-semibold text-ink-500 mb-1.5 block">GitHub/Documentation URL (Optional)</label>
<input
type="url"
value={uploadGithubUrl}
onChange={(e) => setUploadGithubUrl(e.target.value)}
placeholder="https://github.com/..."
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>
</form>
</Modal>
{/* Full Local Image Preview Modal */}
<AnimatePresence>
{fullImagePreviewUrl && (
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4">
<motion.div
initial={{ opacity: 0 }}
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
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
src={fullImagePreviewUrl}
alt="Full preview"
className="max-w-full max-h-[80vh] object-contain rounded-xl shadow-2xl border border-ink-200 bg-ink-50"
/>
</motion.div>
</div>
)}
</AnimatePresence>
<Modal
isOpen={!!fullImagePreviewUrl}
onClose={() => setFullImagePreviewUrl(null)}
title="Asset Image Preview"
size="lg"
>
<div className="flex flex-col items-center justify-center p-1">
<img
src={fullImagePreviewUrl || ''}
alt="Full preview"
className="max-w-full max-h-[60vh] object-contain rounded-xl shadow-md border border-ink-200 bg-ink-50"
/>
</div>
</Modal>
</>
);
};

View File

@ -1,29 +1,39 @@
import React, { useState, useEffect } from 'react';
import type { BlogPost } from '../../../types';
import { apiClient } from '../../../lib/api-client';
import { useAuth } from '../../auth/store/AuthContext';
import { BookOpen, User, Calendar, Clock, Plus, X, Sparkles, Send } from 'lucide-react';
import React, { useState, useEffect } from "react";
import type { BlogPost } from "../../../types";
import { apiClient } from "../../../lib/api-client";
import { useAuth } from "../../auth/store/AuthContext";
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 = () => {
const { user } = useAuth();
const [posts, setPosts] = useState<BlogPost[]>([]);
const [loading, setLoading] = useState(true);
const isAdmin = user?.role === 'ADMIN';
const isAdmin = user?.role === "ADMIN";
// CMS modal state
const [isOpen, setIsOpen] = useState(false);
const [title, setTitle] = useState('');
const [content, setContent] = useState('');
const [tagsInput, setTagsInput] = useState('');
const [thumbnailUrl, setThumbnailUrl] = useState('');
const [status, setStatus] = useState<'draft' | 'published'>('draft');
const [formError, setFormError] = useState('');
const [title, setTitle] = useState("");
const [content, setContent] = useState("");
const [tagsInput, setTagsInput] = useState("");
const [thumbnailUrl, setThumbnailUrl] = useState("");
const [status, setStatus] = useState<"draft" | "published">("draft");
const [formError, setFormError] = useState("");
const [submitting, setSubmitting] = useState(false);
const fetchPosts = async () => {
setLoading(true);
try {
const response = await apiClient.get<BlogPost[]>('/blog');
const response = await apiClient.get<BlogPost[]>("/blog");
setPosts(response.data);
} catch (err) {
console.error(err);
@ -38,249 +48,281 @@ export const BlogCatalog: React.FC = () => {
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setFormError('');
setFormError("");
if (!title.trim() || !content.trim()) {
setFormError('Title and content are required.');
setFormError("Title and content are required.");
return;
}
setSubmitting(true);
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 = {
title,
content,
tags,
thumbnailUrl: thumbnailUrl.trim() || undefined,
status,
author: 'Technical Architect',
author: "Technical Architect",
};
const response = await apiClient.post<BlogPost>('/blog/new', payload);
setPosts(prev => [response.data, ...prev]);
const response = await apiClient.post<BlogPost>("/blog/new", payload);
setPosts((prev) => [response.data, ...prev]);
setTitle('');
setContent('');
setTagsInput('');
setThumbnailUrl('');
setStatus('draft');
setTitle("");
setContent("");
setTagsInput("");
setThumbnailUrl("");
setStatus("draft");
setIsOpen(false);
} catch (err) {
setFormError('Failed to publish article.');
setFormError("Failed to publish article.");
} finally {
setSubmitting(false);
}
};
// Header component
const headerNode = (
<div>
<h2 className="text-xl font-bold text-ink-800">
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>
);
// 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
onClick={() => setIsOpen(true)}
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" />
Write Post
</button>
</div>
) : undefined;
return (
<div className="space-y-6 text-ink-900">
<div className="flex items-center justify-between">
<div>
<h2 className="text-xl font-bold text-ink-800">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>
{isAdmin && (
<button
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"
>
<Plus className="h-4 w-4" />
Write Post
</button>
<PageLayout header={headerNode} toolbar={toolbarNode}>
<div className="p-5 flex-1 min-h-0 overflow-y-auto">
{loading ? (
<div className="grid gap-6 md:grid-cols-2">
{[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 className="h-48 rounded-lg bg-ink-100" />
<div className="h-4 w-3/4 rounded bg-ink-100" />
<div className="h-20 rounded bg-ink-100" />
</div>
))}
</div>
) : posts.length === 0 ? (
<div className="rounded-xl border border-ink-200 bg-ink-0 p-12 text-center">
<BookOpen className="h-8 w-8 text-ink-300 mx-auto mb-2" />
<p className="text-sm text-ink-600">No blog posts published yet.</p>
</div>
) : (
<div className="grid gap-6 md:grid-cols-2">
{posts.map((post) => (
<article
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"
>
<div className="h-48 w-full bg-ink-100 relative">
<img
src={post.thumbnailUrl}
alt={post.title}
className="w-full h-full object-cover"
/>
{isAdmin && (
<span
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}
</span>
)}
</div>
<div className="p-5 flex-1 flex flex-col justify-between space-y-4">
<div className="space-y-2">
<div className="flex items-center gap-3 text-[10px] font-bold text-ink-600 uppercase tracking-wide">
<span className="flex items-center gap-1">
<User className="h-3.5 w-3.5" />
{post.author}
</span>
<span className="flex items-center gap-1">
<Calendar className="h-3.5 w-3.5" />
{post.publishDate}
</span>
<span className="flex items-center gap-1">
<Clock className="h-3.5 w-3.5" />
{post.readTime}
</span>
</div>
<h3 className="text-base font-bold text-ink-800 leading-snug line-clamp-1">
{post.title}
</h3>
<p className="text-xs text-ink-600 leading-relaxed line-clamp-3">
{post.content}
</p>
</div>
<div className="flex flex-wrap gap-1.5 pt-2 border-t border-ink-100">
{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"
>
#{t}
</span>
))}
</div>
</div>
</article>
))}
</div>
)}
</div>
{loading ? (
<div className="grid gap-6 md:grid-cols-2">
{[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 className="h-48 rounded-lg bg-ink-100" />
<div className="h-4 w-3/4 rounded bg-ink-100" />
<div className="h-20 rounded bg-ink-100" />
</div>
))}
</div>
) : posts.length === 0 ? (
<div className="rounded-xl border border-ink-200 bg-ink-0 p-12 text-center">
<BookOpen className="h-8 w-8 text-ink-300 mx-auto mb-2" />
<p className="text-sm text-ink-600">No blog posts published yet.</p>
</div>
) : (
<div className="grid gap-6 md:grid-cols-2">
{posts.map(post => (
<article
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"
>
<div className="h-48 w-full bg-ink-100 relative">
<img
src={post.thumbnailUrl}
alt={post.title}
className="w-full h-full object-cover"
/>
{isAdmin && (
<span 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}
</span>
)}
</div>
<div className="p-5 flex-1 flex flex-col justify-between space-y-4">
<div className="space-y-2">
<div className="flex items-center gap-3 text-[10px] font-bold text-ink-600 uppercase tracking-wide">
<span className="flex items-center gap-1">
<User className="h-3.5 w-3.5" />
{post.author}
</span>
<span className="flex items-center gap-1">
<Calendar className="h-3.5 w-3.5" />
{post.publishDate}
</span>
<span className="flex items-center gap-1">
<Clock className="h-3.5 w-3.5" />
{post.readTime}
</span>
</div>
<h3 className="text-base font-bold text-ink-800 leading-snug line-clamp-1">{post.title}</h3>
<p className="text-xs text-ink-600 leading-relaxed line-clamp-3">{post.content}</p>
</div>
<div className="flex flex-wrap gap-1.5 pt-2 border-t border-ink-100">
{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">
#{t}
</span>
))}
</div>
</div>
</article>
))}
</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>
<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 && (
<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}
</div>
)}
<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>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-xs font-semibold text-ink-700 mb-1">
Article Title
</label>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="e.g. Optimizing Pipeline Hazards in RV64GC Core Designs"
className="w-full rounded-lg border border-ink-200 px-3.5 py-2 text-sm focus:border-ink-900/50 focus:outline-none bg-ink-50 text-ink-900 placeholder-ink-400"
required
/>
</div>
<div>
<label className="block text-xs font-semibold text-ink-700 mb-1">
Content (Markdown supported)
</label>
<textarea
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder="Write the full post text..."
rows={6}
className="w-full rounded-lg border border-ink-200 px-3.5 py-2 text-sm focus:border-ink-900/50 focus:outline-none resize-y bg-ink-50 text-ink-900 placeholder-ink-400"
required
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-semibold text-ink-700 mb-1">
Tags (comma-separated)
</label>
<input
type="text"
value={tagsInput}
onChange={(e) => setTagsInput(e.target.value)}
placeholder="RISC-V, RTL-Design, Edge-Compute"
className="w-full rounded-lg border border-ink-200 px-3.5 py-2 text-sm focus:border-ink-900/50 focus:outline-none bg-ink-50 text-ink-900 placeholder-ink-400"
/>
</div>
{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">
{formError}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-xs font-semibold text-ink-700 mb-1">Article Title</label>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="e.g. Optimizing Pipeline Hazards in RV64GC Core Designs"
className="w-full rounded-lg border border-ink-200 px-3.5 py-2 text-sm focus:border-ink-900/50 focus:outline-none bg-ink-50 text-ink-900 placeholder-ink-400"
required
/>
</div>
<div>
<label className="block text-xs font-semibold text-ink-700 mb-1">Content (Markdown supported)</label>
<textarea
value={content}
onChange={(e) => setContent(e.target.value)}
placeholder="Write the full post text..."
rows={6}
className="w-full rounded-lg border border-ink-200 px-3.5 py-2 text-sm focus:border-ink-900/50 focus:outline-none resize-y bg-ink-50 text-ink-900 placeholder-ink-400"
required
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-xs font-semibold text-ink-700 mb-1">Tags (comma-separated)</label>
<input
type="text"
value={tagsInput}
onChange={(e) => setTagsInput(e.target.value)}
placeholder="RISC-V, RTL-Design, Edge-Compute"
className="w-full rounded-lg border border-ink-200 px-3.5 py-2 text-sm focus:border-ink-900/50 focus:outline-none bg-ink-50 text-ink-900 placeholder-ink-400"
/>
</div>
<div>
<label className="block text-xs font-semibold text-ink-700 mb-1">Article Cover Photo URL</label>
<input
type="url"
value={thumbnailUrl}
onChange={(e) => setThumbnailUrl(e.target.value)}
placeholder="https://images.unsplash.com/..."
className="w-full rounded-lg border border-ink-200 px-3.5 py-2 text-sm focus:border-ink-900/50 focus:outline-none bg-ink-50 text-ink-900 placeholder-ink-400"
/>
</div>
</div>
<div>
<label className="block text-xs font-semibold text-ink-700 mb-1">Publish Status</label>
<div className="flex gap-4 mt-2">
<label className="flex items-center gap-1.5 text-xs text-ink-800 font-semibold cursor-pointer">
<input
type="radio"
name="blogStatus"
checked={status === 'draft'}
onChange={() => setStatus('draft')}
className="text-ink-900 focus:ring-ink-900/20"
/>
Draft
</label>
<label className="flex items-center gap-1.5 text-xs text-ink-800 font-semibold cursor-pointer">
<input
type="radio"
name="blogStatus"
checked={status === 'published'}
onChange={() => setStatus('published')}
className="text-ink-900 focus:ring-ink-900/20"
/>
Published
</label>
</div>
</div>
<div className="border-t border-ink-100 pt-4 flex justify-end gap-3 mt-6">
<button
type="button"
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"
>
Cancel
</button>
<button
type="submit"
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"
>
<Send className="h-4 w-4" />
{submitting ? 'Publishing...' : 'Publish Article'}
</button>
</div>
</form>
<div>
<label className="block text-xs font-semibold text-ink-700 mb-1">
Article Cover Photo URL
</label>
<input
type="url"
value={thumbnailUrl}
onChange={(e) => setThumbnailUrl(e.target.value)}
placeholder="https://images.unsplash.com/..."
className="w-full rounded-lg border border-ink-200 px-3.5 py-2 text-sm focus:border-ink-900/50 focus:outline-none bg-ink-50 text-ink-900 placeholder-ink-400"
/>
</div>
</div>
</div>
)}
</div>
<div>
<label className="block text-xs font-semibold text-ink-700 mb-1">
Publish Status
</label>
<div className="flex gap-4 mt-2">
<label className="flex items-center gap-1.5 text-xs text-ink-800 font-semibold cursor-pointer">
<input
type="radio"
name="blogStatus"
checked={status === "draft"}
onChange={() => setStatus("draft")}
className="text-ink-900 focus:ring-ink-900/20"
/>
Draft
</label>
<label className="flex items-center gap-1.5 text-xs text-ink-800 font-semibold cursor-pointer">
<input
type="radio"
name="blogStatus"
checked={status === "published"}
onChange={() => setStatus("published")}
className="text-ink-900 focus:ring-ink-900/20"
/>
Published
</label>
</div>
</div>
<div className="border-t border-ink-100 pt-4 flex justify-end gap-3 mt-6">
<button
type="button"
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 cursor-pointer"
>
Cancel
</button>
<button
type="submit"
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 cursor-pointer disabled:opacity-50"
>
<Send className="h-4 w-4" />
{submitting ? "Publishing..." : "Publish Article"}
</button>
</div>
</form>
</Modal>
</PageLayout>
);
};
export default BlogCatalog;

View File

@ -1,10 +1,11 @@
import { useQuery } from "@tanstack/react-query";
import type { UseQueryResult } from "@tanstack/react-query";
import { getPendingPartners } from "../services/legal-api";
import type { PendingPartner } from "../services/legal-api";
import { getPendingPartners, getMyAcceptances } from "../services/legal-api";
import type { PendingPartner, LegalAcceptance } from "../services/legal-api";
export const LEGAL_QUERY_KEYS = {
pending: () => ["legal", "pending"] as const,
myAcceptances: () => ["legal", "my-acceptances"] as const,
};
export const usePendingPartnersQuery = (
@ -17,3 +18,14 @@ export const usePendingPartnersQuery = (
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 { motion } from 'framer-motion';
import type { Variants } from 'framer-motion';
import { UploadCloud, Search, File, CheckCircle } from 'lucide-react';
import { useAuthStore } from '../hooks/use-auth';
import { axiosInstance } from '../services/axios';
import { useState, useEffect } from "react";
import { motion } from "framer-motion";
import type { Variants } from "framer-motion";
import { UploadCloud, Search, File, CheckCircle } from "lucide-react";
import { useAuthStore } from "../hooks/use-auth";
import { axiosInstance } from "../services/axios";
import {
getAssets,
getOrganizations,
@ -11,41 +11,50 @@ import {
requestDownload,
approveDownloadRequest,
rejectDownloadRequest,
downloadAssetFile
} from '../services/assets-api';
downloadAssetFile,
} from "../services/assets-api";
import { useToast } from "../hooks/use-toast";
// Subcomponents
import { AssetCard } from '../features/assets/components/AssetCard';
import { UploadAssetModal } from '../features/assets/components/UploadAssetModal';
import { EditAssetModal } from '../features/assets/components/EditAssetModal';
import { ShareAssetModal } from '../features/assets/components/ShareAssetModal';
import { AssetDetailsModal } from '../features/assets/components/AssetDetailsModal';
import { AssetViewerModal } from '../features/assets/components/AssetViewerModal';
import { DownloadRequestsModal } from '../features/assets/components/DownloadRequestsModal';
import { PageHeader } from '../components/ui/PageHeader';
import { AssetCard } from "../features/assets/components/AssetCard";
import { UploadAssetModal } from "../features/assets/components/UploadAssetModal";
import { EditAssetModal } from "../features/assets/components/EditAssetModal";
import { ShareAssetModal } from "../features/assets/components/ShareAssetModal";
import { AssetDetailsModal } from "../features/assets/components/AssetDetailsModal";
import { AssetViewerModal } from "../features/assets/components/AssetViewerModal";
import { DownloadRequestsModal } from "../features/assets/components/DownloadRequestsModal";
import { PageHeader } from "../components/ui/PageHeader";
import Button from "../components/ui/Button";
import { PageLayout } from "../components/layout/PageLayout";
// Type Definitions
import type { Asset, Organization } from '../types/assets';
import type { Asset, Organization } from "../types/assets";
const containerVariants: Variants = {
hidden: { opacity: 0 },
show: { opacity: 1, transition: { staggerChildren: 0.05 } }
show: { opacity: 1, transition: { staggerChildren: 0.05 } },
};
const itemVariants: Variants = {
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 = () => {
const { success, error } = useToast();
const user = useAuthStore((state) => state.user);
const [assets, setAssets] = useState<Asset[]>([]);
const [organizations, setOrganizations] = useState<Organization[]>([]);
const [loading, setLoading] = useState(true);
// Search & Filter
const [searchQuery, setSearchQuery] = useState('');
const [selectedCategory, setSelectedCategory] = useState<string>('ALL');
const [searchQuery, setSearchQuery] = useState("");
const [selectedCategory, setSelectedCategory] = useState<string>("ALL");
// Modal Control States
const [isUploadOpen, setIsUploadOpen] = useState(false);
@ -54,7 +63,7 @@ export const AssetsPage = () => {
const [isDetailsOpen, setIsDetailsOpen] = useState(false);
const [isViewerOpen, setIsViewerOpen] = useState(false);
const [isRequestsOpen, setIsRequestsOpen] = useState(false);
const [activeAsset, setActiveAsset] = useState<Asset | null>(null);
const [activeMenuId, setActiveMenuId] = useState<string | null>(null);
@ -67,13 +76,13 @@ export const AssetsPage = () => {
try {
const assetsData = await getAssets();
setAssets(assetsData);
if (user?.role === 'ADMIN') {
if (user?.role === "ADMIN") {
const orgsData = await getOrganizations();
setOrganizations(orgsData);
}
} catch (err) {
console.error('Failed to fetch assets data', err);
console.error("Failed to fetch assets data", err);
} finally {
setLoading(false);
}
@ -100,133 +109,140 @@ export const AssetsPage = () => {
};
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 {
await deleteAsset(id);
success("Asset deleted successfully", `"${title}" has been permanently removed.`);
await fetchData();
} catch (err) {
console.error('Failed to delete asset', err);
} catch (err: any) {
console.error("Failed to delete asset", err);
error("Failed to delete asset", err.response?.data?.error || "Something went wrong.");
}
};
const handleRequestDownload = async (asset: Asset) => {
try {
await requestDownload(asset.id);
success("Download request submitted", "An administrator has been notified of your request.");
await fetchData();
} catch (err) {
console.error('Failed to request download access', err);
} catch (err: any) {
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) => {
try {
await approveDownloadRequest(assetId, requestId);
success("Download request approved", "The partner can now download this asset.");
await fetchData();
} catch (err) {
console.error('Failed to approve request', err);
} catch (err: any) {
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) => {
try {
await rejectDownloadRequest(assetId, requestId);
success("Download request rejected", "The access request was denied.");
await fetchData();
} catch (err) {
console.error('Failed to reject request', err);
} catch (err: any) {
console.error("Failed to reject request", err);
error("Failed to reject request", err.response?.data?.error || "Something went wrong.");
}
};
const handleDownload = async (asset: Asset) => {
success("Download started", `Downloading "${asset.title}"...`);
try {
await downloadAssetFile(asset.id);
const downloadUrl = asset.url.startsWith('http')
? asset.url
: `${axiosInstance.defaults.baseURL?.replace('/api/v1', '')}${asset.url}`;
const a = document.createElement('a');
const downloadUrl = asset.url.startsWith("http")
? asset.url
: `${axiosInstance.defaults.baseURL?.replace("/api/v1", "")}${asset.url}`;
const a = document.createElement("a");
a.href = downloadUrl;
a.download = asset.title;
a.target = '_blank';
a.target = "_blank";
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setAssets(prev => prev.map(item =>
item.id === asset.id ? { ...item, downloadsCount: item.downloadsCount + 1 } : item
));
} catch (err) {
console.error('Failed to process download', err);
setAssets((prev) =>
prev.map((item) =>
item.id === asset.id
? { ...item, downloadsCount: item.downloadsCount + 1 }
: 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 matchesSearch = !query ||
asset.title.toLowerCase().includes(query) ||
const matchesSearch =
!query ||
asset.title.toLowerCase().includes(query) ||
(asset.description && asset.description.toLowerCase().includes(query)) ||
(asset.categoryId && asset.categoryId.toLowerCase().includes(query)) ||
(asset.subcategory && asset.subcategory.toLowerCase().includes(query)) ||
(asset.githubUrl && asset.githubUrl.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 =
selectedCategory === 'ALL' ||
asset.categoryId === selectedCategory;
const matchesCategory =
selectedCategory === "ALL" || asset.categoryId === selectedCategory;
return matchesSearch && matchesCategory;
});
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);
return (
<motion.div variants={containerVariants} initial="hidden" animate="show" className="w-full space-y-6 text-ink-900 animate-fade-in">
<PageHeader
title="Asset Library"
subtitle="Securely manage, distribute, and track marketing collateral and partner resources."
badge={
<div className="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full bg-ink-100 border border-ink-200 text-[10px] font-bold text-ink-900 tracking-wider uppercase shrink-0">
<CheckCircle className="w-3.5 h-3.5 text-ink-950" />
<span>Global CDN Active</span>
</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>
)}
// Header component
const headerNode = (
<PageHeader
title="Asset Library"
subtitle="Securely manage, distribute, and track marketing collateral and partner resources."
badge={
<div className="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full bg-ink-100 border border-ink-200 text-[10px] font-bold text-ink-900 tracking-wider uppercase shrink-0">
<CheckCircle className="w-3.5 h-3.5 text-ink-950" />
<span>Global CDN Active</span>
</div>
}
/>
);
{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 */}
<motion.div variants={itemVariants} className="flex flex-col md:flex-row gap-4 items-center pt-2">
<div className="relative flex-1 w-full group">
// Toolbar component
const toolbarNode = (
<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">
<Search className="w-4 h-4 text-ink-400 group-focus-within:text-ink-900 transition-colors" />
</div>
@ -234,59 +250,100 @@ export const AssetsPage = () => {
type="text"
value={searchQuery}
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"
placeholder="Search by title, desc, tag, category, URL..."
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..."
/>
</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) => (
<button
key={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 ${
selectedCategory === cat
? 'bg-ink-900 text-ink-0 border-ink-900'
: 'bg-ink-0 text-ink-700 border-ink-200 hover:bg-ink-50'
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
? "bg-ink-900 text-ink-0 border-ink-900"
: "bg-ink-50 text-ink-700 border-ink-200 hover:bg-ink-100"
}`}
>
{cat}
</button>
))}
</div>
</motion.div>
</div>
{/* Grid Content */}
{loading ? (
<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>
) : filteredAssets.length === 0 ? (
<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" />
<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>
</div>
) : (
<motion.div variants={itemVariants} className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-4 pt-2">
{filteredAssets.map((asset) => (
<AssetCard
key={asset.id}
asset={asset}
user={user}
activeMenuId={activeMenuId}
setActiveMenuId={setActiveMenuId}
onViewDetails={openDetailsModal}
onEdit={openEditModal}
onShare={openShareModal}
onDelete={handleDeleteAsset}
onOpenViewer={openViewerModal}
onDownload={handleDownload}
onRequestDownload={handleRequestDownload}
/>
))}
</motion.div>
)}
<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 ? (
<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>
) : filteredAssets.length === 0 ? (
<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" />
<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>
</div>
) : (
<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) => (
<motion.div key={asset.id} variants={itemVariants}>
<AssetCard
asset={asset}
user={user}
activeMenuId={activeMenuId}
setActiveMenuId={setActiveMenuId}
onViewDetails={openDetailsModal}
onEdit={openEditModal}
onShare={openShareModal}
onDelete={handleDeleteAsset}
onOpenViewer={openViewerModal}
onDownload={handleDownload}
onRequestDownload={handleRequestDownload}
/>
</motion.div>
))}
</motion.div>
)}
</div>
{/* Modals Container */}
<UploadAssetModal
@ -344,8 +401,7 @@ export const AssetsPage = () => {
onApprove={handleApproveRequest}
onReject={handleRejectRequest}
/>
</motion.div>
</PageLayout>
);
};

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 { Link } from 'react-router-dom';
import { PageHeader } from '../components/ui/PageHeader';
import { PageLayout } from '../components/layout/PageLayout';
const containerVariants: Variants = {
hidden: { opacity: 0 },
@ -57,70 +58,80 @@ export const DashboardPage = () => {
}
];
// Header component
const headerNode = (
<PageHeader
title={`Welcome back, ${user?.email?.split('@')[0]}`}
subtitle={`You are authenticated as ${user?.role}. Manage your channel network, monitor compliance, and distribute assets globally.`}
badge={
<div className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-ink-900 border border-ink-800 text-[10px] font-bold text-ink-0 tracking-wider uppercase shrink-0">
<div className="w-1 h-1 rounded-full bg-ink-0 animate-pulse" />
<span>System Active</span>
</div>
}
/>
);
return (
<motion.div variants={containerVariants} initial="hidden" animate="show" className="w-full space-y-6 text-ink-900 animate-fade-in">
<PageHeader
title={`Welcome back, ${user?.email?.split('@')[0]}`}
subtitle={`You are authenticated as ${user?.role}. Manage your channel network, monitor compliance, and distribute assets globally.`}
badge={
<div className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-ink-900 border border-ink-800 text-[10px] font-bold text-ink-0 tracking-wider uppercase shrink-0">
<div className="w-1 h-1 rounded-full bg-ink-0 animate-pulse" />
<span>System Active</span>
</div>
}
/>
{/* Stats Grid */}
<motion.div variants={itemVariants} className="grid grid-cols-1 md:grid-cols-3 gap-4 pt-2">
{[
{ label: 'Global Network Uptime', value: '99.99%', icon: Activity, trend: '+0.01%' },
{ label: 'Active Data Streams', value: '1,492', icon: Zap, trend: '+12%' },
{ label: 'Security Compliance', value: 'Level 4', icon: ShieldCheck, trend: 'Verified' }
].map((stat, i) => (
<div key={i} className="relative group overflow-hidden rounded-xl bg-ink-0 border border-ink-200 p-4 transition-all duration-300 shadow-sm hover:shadow-md">
<div className="absolute top-0 right-0 p-4 opacity-5 group-hover:opacity-10 transition-opacity duration-500 group-hover:scale-110 transform">
<stat.icon className="w-16 h-16 text-ink-900" />
</div>
<p className="text-xs font-semibold uppercase tracking-wider text-ink-500 mb-1">{stat.label}</p>
<div className="flex items-end gap-3 mt-2">
<h3 className="text-xl font-bold text-ink-900 tracking-tight">{stat.value}</h3>
<span className="text-xs font-bold text-ink-900 bg-ink-100 px-2 py-0.5 rounded-md mb-0.5 border border-ink-200">{stat.trend}</span>
</div>
</div>
))}
</motion.div>
{/* Main Action Cards */}
<motion.div variants={itemVariants} className="grid grid-cols-1 md:grid-cols-3 gap-4 pt-2">
{CARDS.map((card, idx) => (
<Link key={idx} to={card.path} className="group relative block h-full">
<div className="relative h-full bg-ink-0 border border-ink-200 rounded-xl p-5 hover:border-ink-400 transition-all duration-300 overflow-hidden shadow-sm hover:shadow-md hover:-translate-y-0.5 flex flex-col justify-between">
<div className="flex justify-between items-start mb-8 relative z-10">
<div className="w-10 h-10 rounded-lg bg-gradient-to-br from-ink-900 to-ink-800 shadow-sm group-hover:scale-105 transition-all duration-350 flex items-center justify-center text-ink-0">
<card.icon className="w-5 h-5" />
</div>
<div className="w-8 h-8 rounded-full bg-ink-50 flex items-center justify-center group-hover:bg-ink-100 transition-all duration-300 border border-ink-200 group-hover:border-ink-300">
<ArrowUpRight className="w-4 h-4 text-ink-400 group-hover:text-ink-900 transition-colors" />
</div>
<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 */}
<motion.div variants={itemVariants} className="grid grid-cols-1 md:grid-cols-3 gap-4 pt-2">
{[
{ label: 'Global Network Uptime', value: '99.99%', icon: Activity, trend: '+0.01%' },
{ label: 'Active Data Streams', value: '1,492', icon: Zap, trend: '+12%' },
{ label: 'Security Compliance', value: 'Level 4', icon: ShieldCheck, trend: 'Verified' }
].map((stat, i) => (
<div key={i} className="relative group overflow-hidden rounded-xl bg-ink-0 border border-ink-200 p-4 transition-all duration-300 shadow-sm hover:shadow-md">
<div className="absolute top-0 right-0 p-4 opacity-5 group-hover:opacity-10 transition-opacity duration-500 group-hover:scale-110 transform">
<stat.icon className="w-16 h-16 text-ink-900" />
</div>
<div className="relative z-10 mt-auto">
<div className="inline-block px-2 py-0.5 rounded-md bg-ink-50 border border-ink-200 text-xs font-bold text-ink-500 mb-3 shadow-sm">
{card.metrics}
</div>
<h3 className="text-lg font-bold text-ink-900 mb-2 tracking-tight">
{card.title}
</h3>
<p className="text-ink-500 leading-normal text-xs font-medium">
{card.description}
</p>
<p className="text-xs font-semibold uppercase tracking-wider text-ink-500 mb-1">{stat.label}</p>
<div className="flex items-end gap-3 mt-2">
<h3 className="text-xl font-bold text-ink-900 tracking-tight">{stat.value}</h3>
<span className="text-xs font-bold text-ink-900 bg-ink-100 px-2 py-0.5 rounded-md mb-0.5 border border-ink-200">{stat.trend}</span>
</div>
</div>
</Link>
))}
))}
</motion.div>
{/* Main Action Cards */}
<motion.div variants={itemVariants} className="grid grid-cols-1 md:grid-cols-3 gap-4 pt-2">
{CARDS.map((card, idx) => (
<Link key={idx} to={card.path} className="group relative block h-full">
<div className="relative h-full bg-ink-0 border border-ink-200 rounded-xl p-5 hover:border-ink-400 transition-all duration-300 overflow-hidden shadow-sm hover:shadow-md hover:-translate-y-0.5 flex flex-col justify-between">
<div className="flex justify-between items-start mb-8 relative z-10">
<div className="w-10 h-10 rounded-lg bg-gradient-to-br from-ink-900 to-ink-800 shadow-sm group-hover:scale-105 transition-all duration-350 flex items-center justify-center text-ink-0">
<card.icon className="w-5 h-5" />
</div>
<div className="w-8 h-8 rounded-full bg-ink-50 flex items-center justify-center group-hover:bg-ink-100 transition-all duration-300 border border-ink-200 group-hover:border-ink-300">
<ArrowUpRight className="w-4 h-4 text-ink-400 group-hover:text-ink-900 transition-colors" />
</div>
</div>
<div className="relative z-10 mt-auto">
<div className="inline-block px-2 py-0.5 rounded-md bg-ink-50 border border-ink-200 text-xs font-bold text-ink-500 mb-3 shadow-sm">
{card.metrics}
</div>
<h3 className="text-lg font-bold text-ink-900 mb-2 tracking-tight">
{card.title}
</h3>
<p className="text-ink-500 leading-normal text-xs font-medium">
{card.description}
</p>
</div>
</div>
</Link>
))}
</motion.div>
</motion.div>
</motion.div>
</PageLayout>
);
};
export default DashboardPage;

View File

@ -1,12 +1,14 @@
import React, { useEffect, useState } from 'react';
import { useSearchParams, useNavigate } from 'react-router-dom';
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 { axiosInstance } from '../services/axios';
import { useAuthStore } from '../hooks/use-auth';
import { useToast } from '../hooks/use-toast';
export const InvitePage: React.FC = () => {
const { success, error: toastError } = useToast();
const [searchParams] = useSearchParams();
const token = searchParams.get('token');
const navigate = useNavigate();
@ -16,6 +18,8 @@ export const InvitePage: React.FC = () => {
const [email, setEmail] = useState<string>('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [showPassword, setShowPassword] = useState(false);
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
@ -60,9 +64,12 @@ export const InvitePage: React.FC = () => {
});
setAuth(response.data);
success("Account created successfully", "Please complete your partner onboarding profile.");
navigate('/onboarding');
} 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);
}
};
@ -130,13 +137,20 @@ export const InvitePage: React.FC = () => {
<KeyRound className="w-4 h-4 text-ink-400" />
</div>
<input
type="password"
type={showPassword ? 'text' : 'password'}
value={password}
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"
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>
@ -147,13 +161,20 @@ export const InvitePage: React.FC = () => {
<CheckCircle className="w-4 h-4 text-ink-400" />
</div>
<input
type="password"
type={showConfirmPassword ? 'text' : 'password'}
value={confirmPassword}
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"
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>

View File

@ -1,21 +1,25 @@
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { loginUser } from '../services/auth-api';
import { useAuthStore } from '../hooks/use-auth';
import { useNavigate } from 'react-router-dom';
import { useState } from 'react';
import { Hexagon, Lock, Mail, ArrowRight, Eye, EyeOff } from 'lucide-react';
import { motion } from 'framer-motion';
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { loginUser } from "../services/auth-api";
import { useAuthStore } from "../hooks/use-auth";
import { useNavigate } from "react-router-dom";
import { useState } from "react";
import { Hexagon, Lock, Mail, ArrowRight, Eye, EyeOff } from "lucide-react";
import { motion } from "framer-motion";
import { useToast } from "../hooks/use-toast";
const loginSchema = z.object({
email: z.string().email({ message: 'Invalid email address' }),
password: z.string().min(6, { message: 'Password must be at least 6 characters' }),
email: z.string().email({ message: "Invalid email address" }),
password: z
.string()
.min(6, { message: "Password must be at least 6 characters" }),
});
type LoginFormValues = z.infer<typeof loginSchema>;
export const LoginPage = () => {
const { success, error: toastError } = useToast();
const [error, setError] = useState<string | null>(null);
const [showPassword, setShowPassword] = useState(false);
const setAuth = useAuthStore((state) => state.setAuth);
@ -32,106 +36,166 @@ export const LoginPage = () => {
const onSubmit = async (data: LoginFormValues) => {
try {
setError(null);
const response = await loginUser({ email: data.email, password: data.password });
const response = await loginUser({
email: data.email,
password: data.password,
});
setAuth(response);
if (response.user.role === 'ADMIN') {
navigate('/admin');
success("Successfully signed in", `Welcome back, ${response.user.email}!`);
if (response.user.role === "ADMIN") {
navigate("/admin");
} else {
navigate('/client');
navigate("/client");
}
} 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 (
<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 */}
<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 bottom-1/4 right-1/4 w-[600px] h-[600px] bg-ink-900/5 rounded-full blur-[150px] pointer-events-none" />
<motion.div
initial={{ opacity: 0, y: 30 }}
<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-[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
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
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">
<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="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">
<Hexagon className="text-ink-0 w-10 h-10 absolute" />
</div>
<h2 className="text-3xl font-extrabold text-ink-900 tracking-tight">Channel Portal</h2>
<p className="text-ink-500 text-sm mt-3 font-medium uppercase tracking-widest">Authorized Access Only</p>
</div>
{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">
<div className="w-2 h-2 rounded-full bg-red-500 shadow-[0_0_10px_rgba(239,68,68,0.5)]" />
{error}
</motion.div>
)}
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
<div className="space-y-2">
<label className="block text-[11px] font-bold text-ink-500 uppercase tracking-widest">Work Email</label>
<div className="relative group">
<div className="absolute inset-y-0 left-0 pl-4 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'}`} />
</div>
<input
type="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`}
placeholder="admin@tech4biz.com"
/>
</div>
{errors.email && <p className="text-red-500 dark:text-red-400 text-xs mt-1.5 font-bold">{errors.email.message}</p>}
</div>
<div className="space-y-2">
<div className="flex justify-between items-center">
<label className="block text-[11px] font-bold text-ink-500 uppercase tracking-widest">Password</label>
<a href="#" className="text-[11px] font-bold text-ink-500 hover:text-ink-900 transition-colors tracking-wider">RECOVERY?</a>
</div>
<div className="relative group">
<div className="absolute inset-y-0 left-0 pl-4 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'}`} />
</div>
<input
type={showPassword ? 'text' : '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`}
placeholder="••••••••"
/>
<button
type="button"
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"
>
{showPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
</button>
</div>
{errors.password && <p className="text-red-500 dark:text-red-400 text-xs mt-1.5 font-bold">{errors.password.message}</p>}
</div>
<button
type="submit"
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"
>
{isSubmitting ? 'AUTHENTICATING...' : 'SECURE SIGN IN'}
{!isSubmitting && <ArrowRight className="w-5 h-5 group-hover:translate-x-1.5 transition-transform" />}
</button>
</form>
{/* 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="flex flex-col items-center mb-8 text-center">
<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-8 h-8 absolute" />
</div>
<h2 className="text-2xl font-bold text-ink-900 tracking-tight">
Channel Portal
</h2>
<p className="text-ink-500 text-[10px] mt-1.5 font-bold uppercase tracking-wider">
Authorized Access Only
</p>
</div>
{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-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}
</motion.div>
)}
<form onSubmit={handleSubmit(onSubmit)} className="space-y-5">
<div className="space-y-1.5">
<label className="block text-[10px] font-bold text-ink-500 uppercase tracking-wider">
Work Email
</label>
<div className="relative group">
<div className="absolute inset-y-0 left-0 pl-3.5 flex items-center pointer-events-none">
<Mail
className={`w-4 h-4 transition-colors ${errors.email ? "text-red-400" : "text-ink-400 group-focus-within:text-ink-900"}`}
/>
</div>
<input
type="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-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"
/>
</div>
{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 className="space-y-1.5">
<div className="flex justify-between items-center">
<label className="block text-[10px] font-bold text-ink-500 uppercase tracking-wider">
Password
</label>
<a
href="#"
className="text-[10px] font-bold text-ink-500 hover:text-ink-900 transition-colors tracking-wider"
>
RECOVERY?
</a>
</div>
<div className="relative group">
<div className="absolute inset-y-0 left-0 pl-3.5 flex items-center pointer-events-none">
<Lock
className={`w-4 h-4 transition-colors ${errors.password ? "text-red-400" : "text-ink-400 group-focus-within:text-ink-900"}`}
/>
</div>
<input
type={showPassword ? "text" : "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-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="••••••••"
/>
<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-950 focus:outline-none transition-colors"
>
{showPassword ? (
<EyeOff className="w-4 h-4" />
) : (
<Eye className="w-4 h-4" />
)}
</button>
</div>
{errors.password && (
<p className="text-red-500 dark:text-red-400 text-[10px] mt-1 font-bold font-sans">
{errors.password.message}
</p>
)}
</div>
<button
type="submit"
disabled={isSubmitting}
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 && (
<ArrowRight className="w-4 h-4 group-hover:translate-x-1 transition-transform" />
)}
</button>
</form>
</div>
</div>
<p className="text-center text-ink-400 text-xs mt-10 font-bold tracking-widest uppercase">
© 2026 Tech4Biz Solutions.
</p>
</motion.div>
<p className="text-center text-ink-400 text-[10px] font-bold tracking-wider uppercase mt-8 relative z-10">
© 2026 Tech4Biz Solutions.
</p>
</div>
);
};

View File

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

View File

@ -1,18 +1,61 @@
import React from 'react';
import { CheckCircle, Clock, Search, XCircle } from 'lucide-react';
import React, { useState } from 'react';
import { CheckCircle, Clock, Search, XCircle, Eye } from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
import { usePendingPartnersQuery } from '../../hooks/use-legal-query';
import { useApprovePartnerMutation } from '../../hooks/use-legal-mutation';
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 = () => {
const { success, error } = useToast();
const { data: partners = [], isLoading } = usePendingPartnersQuery();
const approveMutation = useApprovePartnerMutation();
const [searchTerm, setSearchTerm] = useState('');
const approvePartner = (partnerId: string) => {
approveMutation.mutate(partnerId);
// Preview Modal state
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) {
return (
<div className="flex-1 flex items-center justify-center min-h-[60vh]">
@ -21,133 +64,180 @@ export const ApprovalsPage: React.FC = () => {
);
}
return (
<div className="w-full space-y-6 animate-fade-in text-ink-900">
<PageHeader
title="Approvals Queue"
subtitle="Review and approve partner legal documents to grant platform access."
badge={
<span className="bg-ink-900 text-ink-0 text-[10px] px-2 py-0.5 rounded-full font-bold border border-ink-700 shadow-sm uppercase tracking-wider shrink-0">
{partners.length} Pending
</span>
}
actions={
<div className="relative">
<Search className="w-4 h-4 text-ink-400 absolute left-3 top-1/2 -translate-y-1/2" />
<input
type="text"
placeholder="Search pending partners..."
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"
/>
</div>
}
/>
// Header component
const headerNode = (
<PageHeader
title="Approvals Queue"
subtitle="Review and approve partner legal documents to grant platform access."
badge={
<span className="bg-ink-900 text-ink-0 text-[10px] px-2 py-0.5 rounded-full font-bold border border-ink-700 shadow-sm uppercase tracking-wider shrink-0">
{partners.length} Pending
</span>
}
/>
);
{/* List */}
<div className="bg-ink-0 rounded-xl border border-ink-200 overflow-hidden shadow-sm">
<div className="overflow-x-auto">
<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">
<tr>
<th className="px-4 py-2.5">Partner</th>
<th className="px-4 py-2.5">NDA Status</th>
<th className="px-4 py-2.5">MSA Status</th>
<th className="px-4 py-2.5 text-right">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-ink-200">
<AnimatePresence>
{partners.length === 0 ? (
<tr>
<td colSpan={4} className="px-4 py-8 text-center">
<div className="w-12 h-12 rounded-full bg-ink-100 flex items-center justify-center mx-auto mb-4">
<CheckCircle className="w-6 h-6 text-ink-900" />
</div>
<p className="text-ink-900 font-bold text-sm">Queue is empty</p>
<p className="text-ink-500 text-xs mt-1">All partners have been reviewed.</p>
</td>
</tr>
) : (
partners.map(partner => {
const nda = partner.acceptances.find(a => a.document.type === 'NDA');
const msa = partner.acceptances.find(a => a.document.type === 'MSA');
return (
<motion.tr
key={partner.id}
initial={{ opacity: 1 }}
exit={{ opacity: 0, x: -20, backgroundColor: 'rgba(0, 0, 0, 0.02)' }}
className="hover:bg-ink-50 transition-colors group"
>
<td className="px-4 py-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">
{partner.email.charAt(0).toUpperCase()}
</div>
<div>
<p className="font-bold text-ink-900 text-sm">{partner.email}</p>
<p className="text-xs text-ink-500 flex items-center gap-1">
<Clock className="w-3 h-3" />
{new Date(partner.createdAt).toLocaleDateString()}
</p>
</div>
</div>
</td>
<td className="px-4 py-3">
{nda ? (
<div className="flex items-center gap-2">
<CheckCircle className="w-4 h-4 text-ink-900" />
<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'}
</span>
{nda.documentUrl && (
<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>
)}
</div>
) : (
<div className="flex items-center gap-2 text-ink-400">
<XCircle className="w-4 h-4" />
<span className="text-xs font-bold bg-ink-50 border border-ink-200 text-ink-400 px-2 py-0.5 rounded-md">Missing</span>
</div>
)}
</td>
<td className="px-4 py-3">
{msa ? (
<div className="flex items-center gap-2">
<CheckCircle className="w-4 h-4 text-ink-900" />
<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'}
</span>
{msa.documentUrl && (
<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>
)}
</div>
) : (
<div className="flex items-center gap-2 text-ink-400">
<XCircle className="w-4 h-4" />
<span className="text-xs font-bold bg-ink-50 border border-ink-200 text-ink-400 px-2 py-0.5 rounded-md">Missing</span>
</div>
)}
</td>
<td className="px-4 py-3 text-right">
<button
onClick={() => approvePartner(partner.id)}
disabled={!nda || !msa || (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"
>
{approveMutation.isPending && approveMutation.variables === partner.id ? 'Approving...' : 'Approve Access'}
</button>
</td>
</motion.tr>
);
})
)}
</AnimatePresence>
</tbody>
</table>
// 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" />
<input
type="text"
value={searchTerm}
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>
);
return (
<PageLayout header={headerNode} toolbar={toolbarNode}>
{/* List Container */}
<div className="flex-1 w-full overflow-x-auto">
<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 sticky top-0 z-10">
<tr>
<th className="px-5 py-3">Partner</th>
<th className="px-5 py-3">NDA Document</th>
<th className="px-5 py-3">MSA Document</th>
<th className="px-5 py-3 text-right">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-ink-200 bg-ink-0">
<AnimatePresence>
{filteredPartners.length === 0 ? (
<tr>
<td colSpan={4} className="px-5 py-12 text-center">
<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" />
</div>
<p className="text-ink-900 font-bold text-sm">Queue is empty</p>
<p className="text-ink-500 text-xs mt-1">All partners have been reviewed.</p>
</td>
</tr>
) : (
filteredPartners.map(partner => {
const nda = partner.acceptances.find(a => a.document.type === 'NDA');
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 (
<motion.tr
key={partner.id}
initial={{ opacity: 1 }}
exit={{ opacity: 0, x: -20, backgroundColor: 'rgba(0, 0, 0, 0.02)' }}
className="hover:bg-ink-50 transition-colors group"
>
<td className="px-5 py-4">
<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">
{partner.email.charAt(0).toUpperCase()}
</div>
<div>
<p className="font-bold text-ink-900 text-sm">{partner.email}</p>
<p className="text-xs text-ink-500 flex items-center gap-1">
<Clock className="w-3 h-3" />
{new Date(partner.createdAt).toLocaleDateString()}
</p>
</div>
</div>
</td>
<td className="px-5 py-4">
{nda ? (
<div className="flex items-center gap-2">
<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">
{nda.documentUrl ? 'Uploaded PDF' : 'Digital Sign'}
</span>
<button
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 className="flex items-center gap-2 text-ink-400">
<XCircle className="w-4 h-4" />
<span className="text-xs font-bold bg-ink-50 border border-ink-200 text-ink-400 px-2 py-0.5 rounded-md">Missing</span>
</div>
)}
</td>
<td className="px-5 py-4">
{msa ? (
<div className="flex items-center gap-2">
<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">
{msa.documentUrl ? 'Uploaded PDF' : 'Digital Sign'}
</span>
<button
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 className="flex items-center gap-2 text-ink-400">
<XCircle className="w-4 h-4" />
<span className="text-xs font-bold bg-ink-50 border border-ink-200 text-ink-400 px-2 py-0.5 rounded-md">Missing</span>
</div>
)}
</td>
<td className="px-5 py-4 text-right">
<Button
onClick={() => approvePartner(partner.id, partner.email)}
disabled={!isEligible || (approveMutation.isPending && approveMutation.variables === partner.id)}
variant="primary"
size="sm"
>
{approveMutation.isPending && approveMutation.variables === partner.id ? 'Approving...' : 'Approve Access'}
</Button>
</td>
</motion.tr>
);
})
)}
</AnimatePresence>
</tbody>
</table>
</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;

View File

@ -1,371 +1,512 @@
import React, { useState } from 'react';
import { Users, Mail, CheckCircle, AlertCircle, ChevronRight, UserPlus, Clock, ShieldCheck, RefreshCw } from 'lucide-react';
import { motion, AnimatePresence } 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 React, { useState } from "react";
import {
Users,
Mail,
CheckCircle,
AlertCircle,
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 }> = {
PENDING_ONBOARDING: {
label: 'Pending Onboarding',
color: 'text-ink-500',
bg: 'bg-ink-50',
border: 'border-ink-200',
},
PENDING_APPROVAL: {
label: 'Awaiting Approval',
color: 'text-ink-0 bg-ink-900',
bg: 'bg-ink-900',
border: 'border-ink-800',
},
APPROVED: {
label: 'Active',
color: 'text-ink-900 font-extrabold',
bg: 'bg-ink-100',
border: 'border-ink-300',
},
const STATUS_CONFIG: Record<
string,
{ label: string; color: string; bg: string; border: string }
> = {
PENDING_ONBOARDING: {
label: "Pending Onboarding",
color: "text-ink-500",
bg: "bg-ink-50",
border: "border-ink-200",
},
PENDING_APPROVAL: {
label: "Awaiting Approval",
color: "text-ink-0 bg-ink-900",
bg: "bg-ink-900",
border: "border-ink-800",
},
APPROVED: {
label: "Active",
color: "text-ink-900 font-extrabold",
bg: "bg-ink-100",
border: "border-ink-300",
},
};
const getStatusConfig = (status: string) => STATUS_CONFIG[status] ?? {
const getStatusConfig = (status: string) =>
STATUS_CONFIG[status] ?? {
label: status,
color: 'text-ink-500',
bg: 'bg-ink-100',
border: 'border-ink-200',
};
color: "text-ink-500",
bg: "bg-ink-100",
border: "border-ink-200",
};
export const DirectoryPage: React.FC = () => {
const [email, setEmail] = useState('');
const [inviteResult, setInviteResult] = useState<{ token?: string; error?: string } | null>(null);
const [isInviteOpen, setIsInviteOpen] = useState(false);
const [currentPage, setCurrentPage] = useState(1);
const ITEMS_PER_PAGE = 5;
const { success, error } = useToast();
const [email, setEmail] = useState("");
const [inviteResult, setInviteResult] = useState<{
token?: string;
error?: string;
} | null>(null);
const [isInviteOpen, setIsInviteOpen] = useState(false);
const [currentPage, setCurrentPage] = useState(1);
const [searchTerm, setSearchTerm] = useState("");
const [statusFilter, setStatusFilter] = useState("ALL");
const ITEMS_PER_PAGE = 10;
const { data: partners = [], isLoading: loadingPartners, refetch: fetchPartners } = usePartnersQuery();
const inviteMutation = useInvitePartnerMutation();
const {
data: partners = [],
isLoading: loadingPartners,
refetch: fetchPartners,
} = usePartnersQuery();
const inviteMutation = useInvitePartnerMutation();
const handleInvite = async (e: React.FormEvent) => {
e.preventDefault();
setInviteResult(null);
const handleInvite = async (e: React.FormEvent) => {
e.preventDefault();
setInviteResult(null);
inviteMutation.mutate(email, {
onSuccess: (data) => {
setInviteResult({ token: data.token });
setEmail('');
},
onError: (err: any) => {
setInviteResult({ error: err.response?.data?.error || 'Failed to send invite' });
}
inviteMutation.mutate(email, {
onSuccess: (data) => {
setInviteResult({ token: data.token });
success("Invitation generated successfully", `A secure onboarding link has been created for ${email}.`);
setEmail("");
},
onError: (err: any) => {
const errMsg = err.response?.data?.error || "Failed to send invite";
setInviteResult({
error: errMsg,
});
};
error("Invitation failed", errMsg);
},
});
};
const counts = {
total: partners.length,
active: partners.filter(p => p.onboardingStatus === 'APPROVED').length,
pendingOnboarding: partners.filter(p => p.onboardingStatus === 'PENDING_ONBOARDING').length,
awaitingApproval: partners.filter(p => p.onboardingStatus === 'PENDING_APPROVAL').length,
};
const counts = {
total: partners.length,
active: partners.filter((p) => p.onboardingStatus === "APPROVED").length,
pendingOnboarding: partners.filter(
(p) => p.onboardingStatus === "PENDING_ONBOARDING",
).length,
awaitingApproval: partners.filter(
(p) => p.onboardingStatus === "PENDING_APPROVAL",
).length,
};
const statCards = [
{ label: 'Total Partners', value: counts.total, icon: Users, accentClass: '' },
{ label: 'Active', value: counts.active, icon: ShieldCheck, accentClass: '' },
{ label: 'Pending Onboarding', value: counts.pendingOnboarding, icon: Clock, accentClass: '' },
];
const statCards = [
{
label: "Total Partners",
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) {
statCards.push({
label: 'Awaiting Approval',
value: counts.awaitingApproval,
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'
});
}
if (counts.awaitingApproval > 0) {
statCards.push({
label: "Awaiting Approval",
value: counts.awaitingApproval,
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",
});
}
const totalItems = partners.length;
const totalPages = Math.ceil(totalItems / ITEMS_PER_PAGE) || 1;
const startIndex = (currentPage - 1) * ITEMS_PER_PAGE;
const endIndex = Math.min(startIndex + ITEMS_PER_PAGE, totalItems);
const paginatedPartners = partners.slice(startIndex, endIndex);
const filteredPartners = partners.filter((partner) => {
const matchesSearch = partner.email
.toLowerCase()
.includes(searchTerm.toLowerCase());
const matchesStatus =
statusFilter === "ALL" || partner.onboardingStatus === statusFilter;
return matchesSearch && matchesStatus;
});
return (
<div className="w-full space-y-6 animate-fade-in text-ink-900">
<PageHeader
title="Partner Directory"
subtitle="Manage your network and invite new partners to the platform."
actions={
<>
<button
onClick={() => {
setInviteResult(null);
setEmail('');
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"
>
<UserPlus className="w-4 h-4" />
<span>Invite Partner</span>
</button>
<button
onClick={() => { 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"
title="Refresh"
>
<RefreshCw className="w-4 h-4 group-hover:rotate-180 transition-transform duration-500" />
</button>
</>
}
const totalItems = filteredPartners.length;
const totalPages = Math.ceil(totalItems / ITEMS_PER_PAGE) || 1;
const startIndex = (currentPage - 1) * ITEMS_PER_PAGE;
const endIndex = Math.min(startIndex + ITEMS_PER_PAGE, totalItems);
const paginatedPartners = filteredPartners.slice(startIndex, endIndex);
// Header component
const headerNode = (
<PageHeader
title="Partner Directory"
subtitle="Manage your network and invite new partners to the platform."
/>
);
// 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>
{/* Dynamic Real-time Approval Notification Banner */}
{counts.awaitingApproval > 0 && (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
className="flex items-center justify-between p-4 bg-ink-900 border border-ink-800 rounded-xl shadow-sm text-ink-0 relative overflow-hidden"
>
<div className="absolute inset-0 bg-gradient-to-r from-ink-800 via-ink-900 to-ink-800 opacity-50" />
<div className="flex items-center gap-3 relative z-10">
<div className="w-8 h-8 rounded-lg bg-ink-0/10 flex items-center justify-center text-ink-0 shrink-0">
<AlertCircle className="w-4 h-4 animate-bounce" />
</div>
<div>
<h4 className="font-bold text-sm text-ink-0">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 className="flex items-center gap-2 shrink-0 justify-end w-full sm:w-auto">
<Button
onClick={() => {
setInviteResult(null);
setEmail("");
setIsInviteOpen(true);
}}
variant="primary"
size="sm"
icon={<UserPlus className="w-4 h-4" />}
>
Invite Partner
</Button>
<Button
onClick={() => {
fetchPartners();
}}
variant="ghost"
size="sm"
icon={<RefreshCw className="w-4 h-4" />}
title="Refresh"
>
Refresh
</Button>
</div>
</div>
{/* Dynamic Real-time Approval Notification Banner */}
{counts.awaitingApproval > 0 && (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
className="flex items-center justify-between p-4 bg-ink-900 border border-ink-800 rounded-xl shadow-sm text-ink-0 relative overflow-hidden"
>
<div className="absolute inset-0 bg-gradient-to-r from-ink-800 via-ink-900 to-ink-800 opacity-50" />
<div className="flex items-center gap-3 relative z-10">
<div className="w-8 h-8 rounded-lg bg-ink-0/10 flex items-center justify-center text-ink-0 shrink-0">
<AlertCircle className="w-4 h-4 animate-bounce" />
</div>
<div>
<h4 className="font-bold text-sm text-ink-0">
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>
<Link to="/admin/approvals" className="relative z-10 shrink-0">
<Button
variant="secondary"
size="sm"
icon={<ChevronRight className="w-3.5 h-3.5 order-last" />}
>
Review Queue
</Button>
</Link>
</motion.div>
)}
</div>
);
// 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>
{/* Partner Table */}
<div className="flex-1 min-h-0 w-full overflow-x-auto">
<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 sticky top-0 z-10">
<tr>
<th className="px-5 py-3">Partner</th>
<th className="px-5 py-3">Status</th>
<th className="px-5 py-3">MFA</th>
<th className="px-5 py-3">Joined</th>
</tr>
</thead>
<tbody className="divide-y divide-ink-200 bg-ink-0">
{loadingPartners ? (
<tr>
<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" />
</td>
</tr>
) : paginatedPartners.length === 0 ? (
<tr>
<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 border border-ink-200">
<Users className="w-6 h-6 text-ink-400" />
</div>
<Link
to="/admin/approvals"
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"
<p className="text-sm font-bold text-ink-900">
No partners yet
</p>
<p className="text-xs text-ink-500 mt-1">
Use the invite button to add your first partner.
</p>
</td>
</tr>
) : (
paginatedPartners.map((partner) => {
const sc = getStatusConfig(partner.onboardingStatus);
return (
<tr
key={partner.id}
className="hover:bg-ink-50 transition-colors"
>
Review Queue
<ChevronRight className="w-3.5 h-3.5" />
</Link>
</motion.div>
<td className="px-5 py-4">
<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">
{partner.email.charAt(0).toUpperCase()}
</div>
<span className="font-bold text-ink-900 text-sm">
{partner.email}
</span>
</div>
</td>
<td className="px-5 py-4">
<span
className={`text-xs px-2 py-0.5 rounded-md border ${sc.color} ${sc.bg} ${sc.border}`}
>
{sc.label}
</span>
</td>
<td className="px-5 py-4">
{partner.mfaEnabled ? (
<span className="text-xs font-bold text-ink-900">
Enabled
</span>
) : (
<span className="text-xs font-bold text-ink-400">
Disabled
</span>
)}
</td>
<td className="px-5 py-4 text-xs text-ink-500 font-medium">
{new Date(partner.createdAt).toLocaleDateString()}
</td>
</tr>
);
})
)}
</tbody>
</table>
</div>
</div>
{/* Invite Modal Overlay */}
<Modal
isOpen={isInviteOpen}
onClose={() => setIsInviteOpen(false)}
title="Invite Partner"
subtitle="Generate a secure invitation link for a new partner."
size="sm"
>
{!inviteResult?.token ? (
<form onSubmit={handleInvite} className="space-y-4">
<div>
<label className="text-xs font-semibold text-ink-500 mb-1.5 block">
Email Address
</label>
<div className="relative">
<Mail className="w-4 h-4 text-ink-400 absolute left-3 top-1/2 -translate-y-1/2" />
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="partner@company.com"
required
className="w-full pl-9 pr-4 py-2 bg-ink-50 border border-ink-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-ink-900/10 text-ink-900 placeholder-ink-400 transition-all font-semibold"
/>
</div>
</div>
{inviteResult?.error && (
<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" />
<p className="text-xs font-bold text-red-650">
{inviteResult.error}
</p>
</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>
<p className="text-xl font-bold tracking-tight text-ink-900">{stat.value}</p>
</div>
))}
<div className="flex items-center gap-3 pt-2">
<Button
type="button"
onClick={() => setIsInviteOpen(false)}
variant="ghost"
size="sm"
className="flex-1"
>
Cancel
</Button>
<Button
type="submit"
disabled={inviteMutation.isPending || !email}
variant="primary"
size="sm"
className="flex-1"
icon={<ChevronRight className="w-4 h-4 order-last" />}
>
{inviteMutation.isPending ? "Generating..." : "Generate"}
</Button>
</div>
</form>
) : (
<div className="space-y-6">
<div className="p-3 bg-ink-100 border border-ink-300 rounded-lg">
<div className="flex items-center gap-2 mb-2">
<CheckCircle className="w-4 h-4 text-ink-900" />
<span className="text-xs font-bold text-ink-900">
Invite Created!
</span>
</div>
<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">
{window.location.origin}/invite?token={inviteResult.token}
</div>
</div>
{/* Partner List Table (Full Width) */}
<div className="bg-ink-0 rounded-xl border border-ink-200 shadow-sm overflow-hidden w-full">
<div className="overflow-x-auto">
<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">
<tr>
<th className="px-4 py-2.5">Partner</th>
<th className="px-4 py-2.5">Status</th>
<th className="px-4 py-2.5">MFA</th>
<th className="px-4 py-2.5">Joined</th>
</tr>
</thead>
<tbody className="divide-y divide-ink-200">
{loadingPartners ? (
<tr>
<td colSpan={4} className="px-4 py-8 text-center">
<div className="w-6 h-6 border-4 border-ink-900/30 border-t-ink-900 rounded-full animate-spin mx-auto" />
</td>
</tr>
) : partners.length === 0 ? (
<tr>
<td colSpan={4} className="px-4 py-8 text-center">
<div className="w-12 h-12 bg-ink-50 rounded-full flex items-center justify-center mx-auto mb-3">
<Users className="w-6 h-6 text-ink-400" />
</div>
<p className="text-sm font-bold text-ink-900">No partners yet</p>
<p className="text-xs text-ink-500 mt-1">Use the invite button to add your first partner.</p>
</td>
</tr>
) : (
paginatedPartners.map(partner => {
const sc = getStatusConfig(partner.onboardingStatus);
return (
<tr key={partner.id} className="hover:bg-ink-50 transition-colors">
<td className="px-4 py-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">
{partner.email.charAt(0).toUpperCase()}
</div>
<span className="font-bold text-ink-900 text-sm">{partner.email}</span>
</div>
</td>
<td className="px-4 py-3">
<span className={`text-xs px-2 py-0.5 rounded-md border ${sc.color} ${sc.bg} ${sc.border}`}>
{sc.label}
</span>
</td>
<td className="px-4 py-3">
{partner.mfaEnabled ? (
<span className="text-xs font-bold text-ink-900">Enabled</span>
) : (
<span className="text-xs font-bold text-ink-400">Disabled</span>
)}
</td>
<td className="px-4 py-3 text-xs text-ink-500 font-medium">
{new Date(partner.createdAt).toLocaleDateString()}
</td>
</tr>
);
})
)}
</tbody>
</table>
</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>
{/* Invite Modal Overlay */}
<AnimatePresence>
{isInviteOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
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 ? (
<form onSubmit={handleInvite} className="space-y-4">
<div>
<label className="text-xs font-semibold text-ink-500 mb-1.5 block">Email Address</label>
<div className="relative">
<Mail className="w-4 h-4 text-ink-400 absolute left-3 top-1/2 -translate-y-1/2" />
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="partner@company.com"
required
className="w-full pl-9 pr-4 py-2 bg-ink-50 border border-ink-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-ink-900/10 text-ink-900 placeholder-ink-400 transition-all font-semibold"
/>
</div>
</div>
{inviteResult?.error && (
<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" />
<p className="text-xs font-bold text-red-650">{inviteResult.error}</p>
</div>
)}
<div className="flex items-center gap-3 pt-2">
<button
type="button"
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"
>
Cancel
</button>
<button
type="submit"
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"
>
{inviteMutation.isPending ? 'Generating...' : 'Generate'}
<ChevronRight className="w-4 h-4" />
</button>
</div>
</form>
) : (
<div className="space-y-6">
<div className="p-3 bg-ink-100 border border-ink-300 rounded-lg">
<div className="flex items-center gap-2 mb-2">
<CheckCircle className="w-4 h-4 text-ink-900" />
<span className="text-xs font-bold text-ink-900">Invite Created!</span>
</div>
<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">
{window.location.origin}/invite?token={inviteResult.token}
</div>
</div>
<button
type="button"
onClick={() => {
setIsInviteOpen(false);
setEmail('');
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"
>
Done
</button>
</div>
)}
</motion.div>
</div>
)}
</AnimatePresence>
</div>
);
<Button
type="button"
onClick={() => {
setIsInviteOpen(false);
setEmail("");
setInviteResult(null);
}}
variant="primary"
size="sm"
className="w-full"
>
Done
</Button>
</div>
)}
</Modal>
</PageLayout>
);
};
export default DirectoryPage;

View File

@ -2,6 +2,9 @@ import React, { useState, useEffect, useRef } from 'react';
import { axiosInstance } from '../../services/axios';
import { Shield, FileText, Upload, CheckCircle, AlertTriangle, ArrowRight, Eye, RefreshCw } from 'lucide-react';
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 {
id: string;
@ -14,6 +17,7 @@ interface LegalDoc {
}
export const LegalTemplatesPage: React.FC = () => {
const { success, error: toastError } = useToast();
const [activeTab, setActiveTab] = useState<'NDA' | 'MSA'>('NDA');
const [ndaDoc, setNdaDoc] = 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 [uploadingPdf, setUploadingPdf] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
@ -75,13 +78,12 @@ export const LegalTemplatesPage: React.FC = () => {
const handleFormSubmit = async (e: React.FormEvent) => {
e.preventDefault();
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;
}
try {
setSubmitting(true);
setMessage(null);
let uploadedUrl: string | null = null;
// 1. Upload PDF if selected
@ -108,14 +110,14 @@ export const LegalTemplatesPage: React.FC = () => {
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);
if (fileInputRef.current) fileInputRef.current.value = '';
await fetchActiveDocuments();
} catch (err: any) {
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 {
setSubmitting(false);
setUploadingPdf(false);
@ -125,36 +127,45 @@ export const LegalTemplatesPage: React.FC = () => {
const currentDoc = activeTab === 'NDA' ? ndaDoc : msaDoc;
const fileHost = (import.meta.env.VITE_API_URL || 'http://localhost:5000/api/v1').replace('/api/v1', '');
return (
<div className="w-full space-y-6 animate-fade-in text-ink-900">
<PageHeader
title="Legal Agreements"
subtitle="Configure active documents required during partner onboarding."
badge={
<div className="flex items-center gap-1.5 px-2.5 py-0.5 rounded-full bg-ink-100 border border-ink-200 text-[10px] font-bold text-ink-900 tracking-wider uppercase shrink-0">
<Shield className="w-3.5 h-3.5 text-ink-950" />
<span>Compliance Panel</span>
</div>
}
actions={
<div className="inline-flex p-1 rounded-lg bg-ink-100 border border-ink-200 shrink-0">
<button
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'}`}
>
Non-Disclosure (NDA)
</button>
<button
onClick={() => setActiveTab('MSA')}
className={`px-3 py-1.5 rounded-md text-xs font-bold transition-all cursor-pointer ${activeTab === 'MSA' ? 'bg-ink-0 text-ink-900 shadow-sm' : 'text-ink-500 hover:text-ink-900'}`}
>
Master Services (MSA)
</button>
</div>
}
/>
// Header component
const headerNode = (
<PageHeader
title="Legal Agreements"
subtitle="Configure active documents required during partner onboarding."
badge={
<div className="flex items-center gap-1.5 px-2.5 py-0.5 rounded-full bg-ink-100 border border-ink-200 text-[10px] font-bold text-ink-900 tracking-wider uppercase shrink-0">
<Shield className="w-3.5 h-3.5 text-ink-950" />
<span>Compliance Panel</span>
</div>
}
/>
);
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
// 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
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'}`}
>
Non-Disclosure (NDA)
</button>
<button
onClick={() => setActiveTab('MSA')}
className={`px-3 py-1.5 rounded-md text-xs font-bold transition-all cursor-pointer ${activeTab === 'MSA' ? 'bg-ink-0 text-ink-900 shadow-sm' : 'text-ink-500 hover:text-ink-900'}`}
>
Master Services (MSA)
</button>
</div>
</div>
</div>
);
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 */}
<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">
@ -200,7 +211,7 @@ export const LegalTemplatesPage: React.FC = () => {
href={`${fileHost}${currentDoc.pdfUrl}`}
target="_blank"
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>
<ArrowRight className="w-3.5 h-3.5" />
@ -235,13 +246,6 @@ export const LegalTemplatesPage: React.FC = () => {
</p>
</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="md:col-span-1">
<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 className="flex justify-end gap-3 pt-3 border-t border-ink-100">
<button
<Button
type="submit"
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'}
<CheckCircle className="w-4 h-4" />
</button>
</Button>
</div>
</form>
</div>
</div>
</div>
</PageLayout>
);
};
export default LegalTemplatesPage;

View File

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