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:
parent
7ae2ac1d7d
commit
51c7d280b0
@ -117,15 +117,16 @@ model LegalDocument {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model LegalAcceptance {
|
model LegalAcceptance {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
docId String
|
docId String
|
||||||
userId String
|
userId String
|
||||||
ipAddress String
|
ipAddress String
|
||||||
signatureHash String?
|
signatureHash String?
|
||||||
documentUrl String?
|
documentUrl String?
|
||||||
acceptedAt DateTime @default(now())
|
signatureBase64 String?
|
||||||
document LegalDocument @relation(fields: [docId], references: [id])
|
acceptedAt DateTime @default(now())
|
||||||
user User @relation(fields: [userId], references: [id])
|
document LegalDocument @relation(fields: [docId], references: [id])
|
||||||
|
user User @relation(fields: [userId], references: [id])
|
||||||
}
|
}
|
||||||
|
|
||||||
model AuditLog {
|
model AuditLog {
|
||||||
|
|||||||
@ -69,7 +69,14 @@ export class LegalController {
|
|||||||
signatureHash = crypto.createHash('sha256').update(signatureBase64).digest('hex');
|
signatureHash = crypto.createHash('sha256').update(signatureBase64).digest('hex');
|
||||||
}
|
}
|
||||||
|
|
||||||
const acceptance = await this.legalService.recordAcceptance(activeDoc.id, userId, ipAddress, signatureHash || undefined, documentUrl);
|
const acceptance = await this.legalService.recordAcceptance(
|
||||||
|
activeDoc.id,
|
||||||
|
userId,
|
||||||
|
ipAddress,
|
||||||
|
signatureHash || undefined,
|
||||||
|
documentUrl,
|
||||||
|
signatureBase64 || undefined
|
||||||
|
);
|
||||||
|
|
||||||
// Check if they completed all onboarding steps
|
// Check if they completed all onboarding steps
|
||||||
await this.legalService.checkOnboardingCompletion(userId);
|
await this.legalService.checkOnboardingCompletion(userId);
|
||||||
|
|||||||
@ -33,10 +33,11 @@ export class LegalService {
|
|||||||
ipAddress: string,
|
ipAddress: string,
|
||||||
signatureHash?: string,
|
signatureHash?: string,
|
||||||
documentUrl?: string,
|
documentUrl?: string,
|
||||||
|
signatureBase64?: string,
|
||||||
) {
|
) {
|
||||||
// Record acceptance
|
// Record acceptance
|
||||||
const acceptance = await prisma.legalAcceptance.create({
|
const acceptance = await prisma.legalAcceptance.create({
|
||||||
data: { docId, userId, ipAddress, signatureHash, documentUrl },
|
data: { docId, userId, ipAddress, signatureHash, documentUrl, signatureBase64 },
|
||||||
});
|
});
|
||||||
|
|
||||||
return acceptance;
|
return acceptance;
|
||||||
@ -67,7 +68,15 @@ export class LegalService {
|
|||||||
public async getAcceptances(userId: string) {
|
public async getAcceptances(userId: string) {
|
||||||
return await prisma.legalAcceptance.findMany({
|
return await prisma.legalAcceptance.findMany({
|
||||||
where: { userId },
|
where: { userId },
|
||||||
include: { document: true },
|
select: {
|
||||||
|
id: true,
|
||||||
|
signatureHash: true,
|
||||||
|
documentUrl: true,
|
||||||
|
signatureBase64: true,
|
||||||
|
ipAddress: true,
|
||||||
|
acceptedAt: true,
|
||||||
|
document: true,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -79,7 +88,15 @@ export class LegalService {
|
|||||||
email: true,
|
email: true,
|
||||||
createdAt: true,
|
createdAt: true,
|
||||||
acceptances: {
|
acceptances: {
|
||||||
include: { document: true }
|
select: {
|
||||||
|
id: true,
|
||||||
|
signatureHash: true,
|
||||||
|
documentUrl: true,
|
||||||
|
signatureBase64: true,
|
||||||
|
ipAddress: true,
|
||||||
|
acceptedAt: true,
|
||||||
|
document: true,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@ -5,6 +5,8 @@ import { useEffect } from 'react';
|
|||||||
import { useThemeStore } from './hooks/use-theme';
|
import { useThemeStore } from './hooks/use-theme';
|
||||||
import { useAuthStore } from './hooks/use-auth';
|
import { useAuthStore } from './hooks/use-auth';
|
||||||
|
|
||||||
|
import { ToastProvider } from "./components/ui/Toast";
|
||||||
|
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
queries: { retry: 1, refetchOnWindowFocus: false }
|
queries: { retry: 1, refetchOnWindowFocus: false }
|
||||||
@ -39,7 +41,9 @@ export const App = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<RouterProvider router={router} />
|
<ToastProvider>
|
||||||
|
<RouterProvider router={router} />
|
||||||
|
</ToastProvider>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,9 +1,22 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from "react";
|
||||||
import { Link, Outlet, useLocation, useNavigate } from 'react-router-dom';
|
import { Link, Outlet, useLocation, useNavigate } from "react-router-dom";
|
||||||
import { useThemeStore } from '../../hooks/use-theme';
|
import { useThemeStore } from "../../hooks/use-theme";
|
||||||
import { useAuthStore } from '../../hooks/use-auth';
|
import { useAuthStore } from "../../hooks/use-auth";
|
||||||
import { ShieldCheck, BarChart3, ClipboardCheck, FolderGit2, BookCopy, Users, LogOut, Menu, X, Sun, Moon, ChevronRight, ChevronLeft } from 'lucide-react';
|
import {
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
ShieldCheck,
|
||||||
|
ClipboardCheck,
|
||||||
|
FolderGit2,
|
||||||
|
BookCopy,
|
||||||
|
Users,
|
||||||
|
LogOut,
|
||||||
|
Menu,
|
||||||
|
X,
|
||||||
|
Sun,
|
||||||
|
Moon,
|
||||||
|
ChevronRight,
|
||||||
|
ChevronLeft,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { motion, AnimatePresence } from "framer-motion";
|
||||||
|
|
||||||
export const AdminLayout: React.FC = () => {
|
export const AdminLayout: React.FC = () => {
|
||||||
const { user, logout } = useAuthStore();
|
const { user, logout } = useAuthStore();
|
||||||
@ -15,51 +28,62 @@ export const AdminLayout: React.FC = () => {
|
|||||||
|
|
||||||
const handleLogout = () => {
|
const handleLogout = () => {
|
||||||
logout();
|
logout();
|
||||||
navigate('/login');
|
navigate("/login");
|
||||||
};
|
};
|
||||||
|
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ name: 'Partners', path: '/admin/partners', icon: Users },
|
{ name: "Partners", path: "/admin/partners", icon: Users },
|
||||||
{ name: 'Approvals Queue', path: '/admin/approvals', icon: ClipboardCheck },
|
{ name: "Approvals Queue", path: "/admin/approvals", icon: ClipboardCheck },
|
||||||
{ name: 'Legal Templates', path: '/admin/legal', icon: ShieldCheck },
|
{ name: "Legal Templates", path: "/admin/legal", icon: ShieldCheck },
|
||||||
{ name: 'Manage Catalog', path: '/admin/assets', icon: FolderGit2 },
|
{ name: "Manage Catalog", path: "/admin/assets", icon: FolderGit2 },
|
||||||
{ name: 'Analytics', path: '/admin/analytics', icon: BarChart3 },
|
{ name: "Blog CMS", path: "/admin/blog", icon: BookCopy },
|
||||||
{ name: 'Blog CMS', path: '/admin/blog', icon: BookCopy }
|
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex flex-col md:flex-row bg-ink-50 text-ink-900 font-sans transition-colors duration-500 selection:bg-ink-900/10 overflow-hidden">
|
<div className="min-h-screen flex flex-col md:flex-row bg-ink-50 text-ink-900 font-sans transition-colors duration-500 selection:bg-ink-900/10 overflow-hidden">
|
||||||
|
|
||||||
{/* ── Desktop Sidebar ── */}
|
{/* ── Desktop Sidebar ── */}
|
||||||
<aside className={`hidden md:flex md:flex-col md:sticky md:top-0 md:h-screen bg-ink-0 border-r border-ink-200 shrink-0 z-20 transition-all duration-300 relative ${isCollapsed ? 'md:w-[80px]' : 'md:w-[280px]'}`}>
|
<aside
|
||||||
|
className={`hidden md:flex md:flex-col md:sticky md:top-0 md:h-screen bg-ink-0 border-r border-ink-200 shrink-0 z-20 transition-all duration-300 relative ${isCollapsed ? "md:w-[80px]" : "md:w-[280px]"}`}
|
||||||
|
>
|
||||||
{/* Toggle Button Floating on the Border */}
|
{/* Toggle Button Floating on the Border */}
|
||||||
<button
|
<button
|
||||||
onClick={() => setIsCollapsed(!isCollapsed)}
|
onClick={() => setIsCollapsed(!isCollapsed)}
|
||||||
className="hidden md:flex absolute top-9 -right-3 w-6 h-6 rounded-full border border-ink-200 bg-ink-0 text-ink-500 hover:text-ink-900 hover:shadow-md items-center justify-center transition-all z-30 cursor-pointer shadow-sm"
|
className="hidden md:flex absolute top-9 -right-3 w-6 h-6 rounded-full border border-ink-200 bg-ink-0 text-ink-500 hover:text-ink-900 hover:shadow-md items-center justify-center transition-all z-30 cursor-pointer shadow-sm"
|
||||||
title={isCollapsed ? "Expand Sidebar" : "Collapse Sidebar"}
|
title={isCollapsed ? "Expand Sidebar" : "Collapse Sidebar"}
|
||||||
>
|
>
|
||||||
{isCollapsed ? <ChevronRight className="w-3.5 h-3.5" /> : <ChevronLeft className="w-3.5 h-3.5" />}
|
{isCollapsed ? (
|
||||||
|
<ChevronRight className="w-3.5 h-3.5" />
|
||||||
|
) : (
|
||||||
|
<ChevronLeft className="w-3.5 h-3.5" />
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Branding */}
|
{/* Branding */}
|
||||||
<div className={`h-24 flex items-center border-b border-ink-200 transition-all duration-300 ${isCollapsed ? 'justify-center px-4' : 'px-8'}`}>
|
<div
|
||||||
|
className={`h-24 flex items-center border-b border-ink-200 transition-all duration-300 ${isCollapsed ? "justify-center px-4" : "px-8"}`}
|
||||||
|
>
|
||||||
<Link to="/admin" className="flex items-center gap-3 shrink-0 group">
|
<Link to="/admin" className="flex items-center gap-3 shrink-0 group">
|
||||||
<div className="w-10 h-10 rounded-xl flex items-center justify-center bg-gradient-to-br from-ink-900 to-ink-800 shadow-lg group-hover:scale-105 transition-all duration-300">
|
<div className="w-10 h-10 rounded-xl flex items-center justify-center bg-gradient-to-br from-ink-900 to-ink-800 shadow-lg group-hover:scale-105 transition-all duration-300">
|
||||||
<ShieldCheck className="w-5 h-5 text-ink-0" />
|
<ShieldCheck className="w-5 h-5 text-ink-0" />
|
||||||
</div>
|
</div>
|
||||||
{!isCollapsed && (
|
{!isCollapsed && (
|
||||||
<div className="flex flex-col animate-fade-in">
|
<div className="flex flex-col animate-fade-in">
|
||||||
<span className="text-lg font-extrabold tracking-tight leading-none text-ink-900">Tech4Biz</span>
|
<span className="text-lg font-extrabold tracking-tight leading-none text-ink-900">
|
||||||
<span className="text-[10px] font-bold uppercase tracking-widest text-ink-900 mt-1">Admin Console</span>
|
Tech4Biz
|
||||||
|
</span>
|
||||||
|
<span className="text-[10px] font-bold uppercase tracking-widest text-ink-900 mt-1">
|
||||||
|
Admin Console
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Navigation */}
|
{/* Navigation */}
|
||||||
<nav className={`flex-1 py-8 space-y-2 overflow-y-auto transition-all duration-300 ${isCollapsed ? 'px-2' : 'px-4'}`}>
|
<nav
|
||||||
{navItems.map(item => {
|
className={`flex-1 py-8 space-y-2 overflow-y-auto transition-all duration-300 ${isCollapsed ? "px-2" : "px-4"}`}
|
||||||
|
>
|
||||||
|
{navItems.map((item) => {
|
||||||
const Icon = item.icon;
|
const Icon = item.icon;
|
||||||
const isActive = location.pathname === item.path;
|
const isActive = location.pathname === item.path;
|
||||||
return (
|
return (
|
||||||
@ -67,53 +91,76 @@ export const AdminLayout: React.FC = () => {
|
|||||||
key={item.path}
|
key={item.path}
|
||||||
to={item.path}
|
to={item.path}
|
||||||
className={`flex items-center gap-3 py-3 rounded-xl transition-all duration-300 font-semibold tracking-wide text-sm group relative ${
|
className={`flex items-center gap-3 py-3 rounded-xl transition-all duration-300 font-semibold tracking-wide text-sm group relative ${
|
||||||
isCollapsed ? 'justify-center px-0' : 'px-4'
|
isCollapsed ? "justify-center px-0" : "px-4"
|
||||||
} ${
|
} ${
|
||||||
isActive
|
isActive
|
||||||
? 'bg-ink-100 text-ink-900 border border-ink-300 shadow-sm'
|
? "bg-ink-100 text-ink-900 border border-ink-300 shadow-sm"
|
||||||
: 'text-ink-500 hover:text-ink-900 hover:bg-ink-100'
|
: "text-ink-500 hover:text-ink-900 hover:bg-ink-100"
|
||||||
}`}
|
}`}
|
||||||
title={isCollapsed ? item.name : undefined}
|
title={isCollapsed ? item.name : undefined}
|
||||||
>
|
>
|
||||||
<Icon className={`w-5 h-5 transition-transform group-hover:scale-110 ${isActive ? 'text-ink-950' : 'text-ink-400 group-hover:text-ink-900'}`} />
|
<Icon
|
||||||
|
className={`w-5 h-5 transition-transform group-hover:scale-110 ${isActive ? "text-ink-950" : "text-ink-400 group-hover:text-ink-900"}`}
|
||||||
|
/>
|
||||||
{!isCollapsed && <span>{item.name}</span>}
|
{!isCollapsed && <span>{item.name}</span>}
|
||||||
{!isCollapsed && isActive && <ChevronRight className="w-4 h-4 ml-auto opacity-50" />}
|
{!isCollapsed && isActive && (
|
||||||
|
<ChevronRight className="w-4 h-4 ml-auto opacity-50" />
|
||||||
|
)}
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<div className={`border-t border-ink-200 bg-ink-50 transition-all duration-300 ${isCollapsed ? 'p-3' : 'p-5'}`}>
|
<div
|
||||||
<div className={`flex items-center mb-4 transition-all duration-300 ${isCollapsed ? 'justify-center' : 'justify-between'}`}>
|
className={`border-t border-ink-200 bg-ink-50 transition-all duration-300 ${isCollapsed ? "p-3" : "p-5"}`}
|
||||||
{!isCollapsed && <p className="text-[10px] font-bold text-ink-400 uppercase tracking-widest">Appearance</p>}
|
>
|
||||||
|
<div
|
||||||
|
className={`flex items-center mb-4 transition-all duration-300 ${isCollapsed ? "justify-center" : "justify-between"}`}
|
||||||
|
>
|
||||||
|
{!isCollapsed && (
|
||||||
|
<p className="text-[10px] font-bold text-ink-400 uppercase tracking-widest">
|
||||||
|
Appearance
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={toggleTheme}
|
onClick={toggleTheme}
|
||||||
className="p-2 rounded-lg bg-ink-0 border border-ink-200 text-ink-500 hover:text-ink-900 hover:shadow-sm transition-all group cursor-pointer"
|
className="p-2 rounded-lg bg-ink-0 border border-ink-200 text-ink-500 hover:text-ink-900 hover:shadow-sm transition-all group cursor-pointer"
|
||||||
>
|
>
|
||||||
{theme === 'dark' ? <Sun className="w-4 h-4 group-hover:rotate-90 transition-transform" /> : <Moon className="w-4 h-4 group-hover:-rotate-12 transition-transform" />}
|
{theme === "dark" ? (
|
||||||
|
<Sun className="w-4 h-4 group-hover:rotate-90 transition-transform" />
|
||||||
|
) : (
|
||||||
|
<Moon className="w-4 h-4 group-hover:-rotate-12 transition-transform" />
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!isCollapsed ? (
|
{!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="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">
|
<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()}
|
{user?.email?.charAt(0).toUpperCase()}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<p className="text-sm font-bold text-ink-900 truncate">{user?.email}</p>
|
<p className="text-sm font-bold text-ink-900 truncate">
|
||||||
<p className="text-[10px] uppercase font-bold text-ink-500 tracking-wider truncate">Administrator</p>
|
{user?.email}
|
||||||
|
</p>
|
||||||
|
<p className="text-[10px] uppercase font-bold text-ink-500 tracking-wider truncate">
|
||||||
|
Administrator
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="w-9 h-9 rounded-full bg-ink-900 flex items-center justify-center text-ink-0 font-bold shadow-sm mx-auto mb-4 animate-fade-in" title={user?.email || ''}>
|
<div
|
||||||
|
className="w-9 h-9 rounded-full bg-ink-900 flex items-center justify-center text-ink-0 font-bold shadow-sm mx-auto mb-4 animate-fade-in"
|
||||||
|
title={user?.email || ""}
|
||||||
|
>
|
||||||
{user?.email?.charAt(0).toUpperCase()}
|
{user?.email?.charAt(0).toUpperCase()}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={handleLogout}
|
onClick={handleLogout}
|
||||||
className={`w-full flex items-center justify-center gap-2 rounded-xl text-sm font-bold tracking-wide text-red-600 hover:bg-red-500/10 transition-colors border border-transparent hover:border-red-200 cursor-pointer ${isCollapsed ? 'py-2 px-0' : 'px-4 py-2.5'}`}
|
className={`w-full flex items-center justify-center gap-2 rounded-xl text-sm font-bold tracking-wide text-red-600 hover:bg-red-500/10 transition-colors border border-transparent hover:border-red-200 cursor-pointer ${isCollapsed ? "py-2 px-0" : "px-4 py-2.5"}`}
|
||||||
title={isCollapsed ? "Sign Out" : undefined}
|
title={isCollapsed ? "Sign Out" : undefined}
|
||||||
>
|
>
|
||||||
<LogOut className="w-4 h-4" />
|
<LogOut className="w-4 h-4" />
|
||||||
@ -123,19 +170,24 @@ export const AdminLayout: React.FC = () => {
|
|||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
{/* ── Main Content Area ── */}
|
{/* ── Main Content Area ── */}
|
||||||
<main className="flex-1 flex flex-col relative w-full overflow-y-auto bg-ink-50">
|
<main className="flex-1 flex flex-col relative w-full h-screen overflow-hidden bg-ink-50">
|
||||||
{/* Ambient Background Glows */}
|
{/* Ambient Background Glows */}
|
||||||
<div className="fixed top-0 right-0 w-[600px] h-[600px] bg-ink-900/5 rounded-full blur-[150px] pointer-events-none -z-10" />
|
<div className="fixed top-0 right-0 w-[600px] h-[600px] bg-ink-900/5 rounded-full blur-[150px] pointer-events-none -z-10" />
|
||||||
|
|
||||||
{/* Mobile Header */}
|
{/* Mobile Header */}
|
||||||
<header className="md:hidden sticky top-0 z-40 h-16 flex items-center justify-between px-4 bg-ink-0 border-b border-ink-200 shadow-sm">
|
<header className="md:hidden sticky top-0 z-40 h-16 flex items-center justify-between px-4 bg-ink-0 border-b border-ink-200 shadow-sm shrink-0">
|
||||||
<Link to="/admin" className="flex items-center gap-2">
|
<Link to="/admin" className="flex items-center gap-2">
|
||||||
<div className="w-8 h-8 rounded-lg flex items-center justify-center bg-gradient-to-br from-ink-900 to-ink-800">
|
<div className="w-8 h-8 rounded-lg flex items-center justify-center bg-gradient-to-br from-ink-900 to-ink-800">
|
||||||
<ShieldCheck className="w-4 h-4 text-ink-0" />
|
<ShieldCheck className="w-4 h-4 text-ink-0" />
|
||||||
</div>
|
</div>
|
||||||
<span className="text-sm font-extrabold tracking-tight text-ink-900">Admin Console</span>
|
<span className="text-sm font-extrabold tracking-tight text-ink-900">
|
||||||
|
Admin Console
|
||||||
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
<button onClick={() => setMobileOpen(true)} className="p-2 rounded-lg border border-ink-200 text-ink-600">
|
<button
|
||||||
|
onClick={() => setMobileOpen(true)}
|
||||||
|
className="p-2 rounded-lg border border-ink-200 text-ink-600"
|
||||||
|
>
|
||||||
<Menu className="w-5 h-5" />
|
<Menu className="w-5 h-5" />
|
||||||
</button>
|
</button>
|
||||||
</header>
|
</header>
|
||||||
@ -144,44 +196,84 @@ export const AdminLayout: React.FC = () => {
|
|||||||
<AnimatePresence>
|
<AnimatePresence>
|
||||||
{mobileOpen && (
|
{mobileOpen && (
|
||||||
<>
|
<>
|
||||||
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} onClick={() => setMobileOpen(false)} className="fixed inset-0 bg-ink-900/40 backdrop-blur-sm z-50 md:hidden" />
|
<motion.div
|
||||||
<motion.div initial={{ x: '100%' }} animate={{ x: 0 }} exit={{ x: '100%' }} transition={{ type: 'spring', damping: 25, stiffness: 200 }} className="fixed right-0 top-0 bottom-0 w-72 bg-ink-0 shadow-2xl z-50 border-l border-ink-200 flex flex-col md:hidden">
|
initial={{ opacity: 0 }}
|
||||||
|
animate={{ opacity: 1 }}
|
||||||
|
exit={{ opacity: 0 }}
|
||||||
|
onClick={() => setMobileOpen(false)}
|
||||||
|
className="fixed inset-0 bg-ink-900/40 backdrop-blur-sm z-50 md:hidden"
|
||||||
|
/>
|
||||||
|
<motion.div
|
||||||
|
initial={{ x: "100%" }}
|
||||||
|
animate={{ x: 0 }}
|
||||||
|
exit={{ x: "100%" }}
|
||||||
|
transition={{ type: "spring", damping: 25, stiffness: 200 }}
|
||||||
|
className="fixed right-0 top-0 bottom-0 w-72 bg-ink-0 shadow-2xl z-50 border-l border-ink-200 flex flex-col md:hidden"
|
||||||
|
>
|
||||||
<div className="p-4 border-b border-ink-200 flex items-center justify-between">
|
<div className="p-4 border-b border-ink-200 flex items-center justify-between">
|
||||||
<span className="font-extrabold text-ink-900">Menu</span>
|
<span className="font-extrabold text-ink-900">Menu</span>
|
||||||
<button onClick={() => setMobileOpen(false)} className="p-2 rounded-lg bg-ink-100">
|
<button
|
||||||
|
onClick={() => setMobileOpen(false)}
|
||||||
|
className="p-2 rounded-lg bg-ink-100"
|
||||||
|
>
|
||||||
<X className="w-4 h-4" />
|
<X className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<nav className="flex-1 overflow-y-auto p-4 space-y-2">
|
<nav className="flex-1 overflow-y-auto p-4 space-y-2">
|
||||||
{navItems.map(item => (
|
{navItems.map((item) => (
|
||||||
<Link key={item.path} to={item.path} onClick={() => setMobileOpen(false)} className={`flex items-center gap-3 px-4 py-3 rounded-xl text-sm font-semibold ${location.pathname === item.path ? 'bg-ink-100 text-ink-900 border border-ink-300' : 'text-ink-500 hover:text-ink-900'}`}>
|
<Link
|
||||||
|
key={item.path}
|
||||||
|
to={item.path}
|
||||||
|
onClick={() => setMobileOpen(false)}
|
||||||
|
className={`flex items-center gap-3 px-4 py-3 rounded-xl text-sm font-semibold ${location.pathname === item.path ? "bg-ink-100 text-ink-900 border border-ink-300" : "text-ink-500 hover:text-ink-900"}`}
|
||||||
|
>
|
||||||
<item.icon className="w-5 h-5" />
|
<item.icon className="w-5 h-5" />
|
||||||
<span>{item.name}</span>
|
<span>{item.name}</span>
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
<div className="p-4 border-t border-ink-200 space-y-4">
|
<div className="p-4 border-t border-ink-200 space-y-4">
|
||||||
<button onClick={toggleTheme} className="flex items-center justify-between w-full p-3 rounded-xl bg-ink-50 border border-ink-200 text-sm font-bold text-ink-500 hover:text-ink-900">
|
<button
|
||||||
Theme {theme === 'dark' ? <Sun className="w-4 h-4" /> : <Moon className="w-4 h-4" />}
|
onClick={toggleTheme}
|
||||||
|
className="flex items-center justify-between w-full p-3 rounded-xl bg-ink-50 border border-ink-200 text-sm font-bold text-ink-500 hover:text-ink-900"
|
||||||
|
>
|
||||||
|
Theme{" "}
|
||||||
|
{theme === "dark" ? (
|
||||||
|
<Sun className="w-4 h-4" />
|
||||||
|
) : (
|
||||||
|
<Moon className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setMobileOpen(false);
|
||||||
|
handleLogout();
|
||||||
|
}}
|
||||||
|
className="w-full py-3 rounded-xl bg-red-500/10 text-red-650 font-bold text-sm"
|
||||||
|
>
|
||||||
|
Sign Out
|
||||||
</button>
|
</button>
|
||||||
<button onClick={() => { setMobileOpen(false); handleLogout(); }} className="w-full py-3 rounded-xl bg-red-500/10 text-red-650 font-bold text-sm">Sign Out</button>
|
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
|
|
||||||
<div className="flex-1 w-full max-w-[1600px] px-4 py-6 md:px-8 mx-auto relative z-10">
|
<div className="flex-1 w-full max-w-[1600px] px-4 py-4 md:px-8 mx-auto relative z-10 overflow-hidden flex flex-col min-h-0">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<footer className="border-t border-ink-200 bg-ink-0/50 backdrop-blur-md mt-auto">
|
<footer className="border-t border-ink-200 bg-ink-0/50 backdrop-blur-md shrink-0">
|
||||||
<div className="max-w-[1600px] mx-auto px-4 md:px-8 py-4 flex flex-col md:flex-row justify-between items-center gap-4 text-xs font-medium text-ink-500">
|
<div className="max-w-[1600px] mx-auto px-4 md:px-8 py-3 flex flex-col md:flex-row justify-between items-center gap-4 text-xs font-medium text-ink-500">
|
||||||
<p>© 2026 Tech4Biz Solutions Inc. Admin Console.</p>
|
<p>© 2026 Tech4Biz Solutions Inc. Admin Console.</p>
|
||||||
<div className="flex gap-6">
|
<div className="flex gap-6">
|
||||||
<span className="hover:text-ink-900 cursor-pointer transition-colors">Security Compliance</span>
|
<span className="hover:text-ink-900 cursor-pointer transition-colors">
|
||||||
<span className="hover:text-ink-900 cursor-pointer transition-colors">System Status</span>
|
Security Compliance
|
||||||
|
</span>
|
||||||
|
<span className="hover:text-ink-900 cursor-pointer transition-colors">
|
||||||
|
System Status
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|||||||
@ -119,16 +119,14 @@ export const ClientLayout: React.FC = () => {
|
|||||||
{!isCollapsed && <span>Sign Out</span>}
|
{!isCollapsed && <span>Sign Out</span>}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside> {/* ── Main Content Area ── */}
|
||||||
|
<main className="flex-1 flex flex-col relative w-full h-screen overflow-hidden bg-ink-50">
|
||||||
{/* ── Main Content Area ── */}
|
|
||||||
<main className="flex-1 flex flex-col relative w-full overflow-y-auto bg-ink-50">
|
|
||||||
|
|
||||||
{/* Ambient Glow */}
|
{/* Ambient Glow */}
|
||||||
<div className="fixed top-0 right-0 w-[500px] h-[500px] bg-ink-900/5 rounded-full blur-[150px] pointer-events-none -z-10" />
|
<div className="fixed top-0 right-0 w-[500px] h-[500px] bg-ink-900/5 rounded-full blur-[150px] pointer-events-none -z-10" />
|
||||||
|
|
||||||
{/* Mobile Header */}
|
{/* Mobile Header */}
|
||||||
<header className="md:hidden sticky top-0 z-40 h-16 flex items-center justify-between px-4 bg-ink-0 border-b border-ink-200 shadow-sm">
|
<header className="md:hidden sticky top-0 z-40 h-16 flex items-center justify-between px-4 bg-ink-0 border-b border-ink-200 shadow-sm shrink-0">
|
||||||
<Link to="/client" className="flex items-center gap-2">
|
<Link to="/client" className="flex items-center gap-2">
|
||||||
<div className="w-8 h-8 rounded-lg flex items-center justify-center bg-gradient-to-br from-ink-900 to-ink-800">
|
<div className="w-8 h-8 rounded-lg flex items-center justify-center bg-gradient-to-br from-ink-900 to-ink-800">
|
||||||
<ShieldCheck className="w-4 h-4 text-ink-0" />
|
<ShieldCheck className="w-4 h-4 text-ink-0" />
|
||||||
@ -161,7 +159,7 @@ export const ClientLayout: React.FC = () => {
|
|||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
<div className="p-4 border-t border-ink-200 space-y-4">
|
<div className="p-4 border-t border-ink-200 space-y-4">
|
||||||
<button onClick={toggleTheme} className="flex items-center justify-between w-full p-3 rounded-xl bg-ink-50 border border-ink-200 text-sm font-bold text-ink-600">
|
<button onClick={toggleTheme} className="flex items-center justify-between w-full p-3 rounded-xl bg-ink-50 border border-ink-200 text-sm font-bold text-ink-650">
|
||||||
Theme {theme === 'dark' ? <Sun className="w-4 h-4" /> : <Moon className="w-4 h-4" />}
|
Theme {theme === 'dark' ? <Sun className="w-4 h-4" /> : <Moon className="w-4 h-4" />}
|
||||||
</button>
|
</button>
|
||||||
<button onClick={() => { setMobileOpen(false); handleLogout(); }} className="w-full py-3 rounded-xl bg-red-500/10 text-red-600 font-bold text-sm">Sign Out</button>
|
<button onClick={() => { setMobileOpen(false); handleLogout(); }} className="w-full py-3 rounded-xl bg-red-500/10 text-red-600 font-bold text-sm">Sign Out</button>
|
||||||
@ -171,13 +169,13 @@ export const ClientLayout: React.FC = () => {
|
|||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</AnimatePresence>
|
||||||
|
|
||||||
<div className="flex-1 w-full max-w-[1600px] px-4 py-6 md:px-8 mx-auto relative z-10">
|
<div className="flex-1 w-full max-w-[1600px] px-4 py-4 md:px-8 mx-auto relative z-10 overflow-hidden flex flex-col min-h-0">
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<footer className="border-t border-ink-200 bg-ink-0/50 backdrop-blur-md mt-auto">
|
<footer className="border-t border-ink-200 bg-ink-0/50 backdrop-blur-md shrink-0">
|
||||||
<div className="max-w-[1600px] mx-auto px-4 md:px-8 py-4 flex flex-col md:flex-row justify-between items-center gap-4 text-xs font-medium text-ink-500">
|
<div className="max-w-[1600px] mx-auto px-4 md:px-8 py-3 flex flex-col md:flex-row justify-between items-center gap-4 text-xs font-medium text-ink-500">
|
||||||
<p>© 2026 Tech4Biz Solutions Inc. All rights reserved.</p>
|
<p>© 2026 Tech4Biz Solutions Inc. All rights reserved.</p>
|
||||||
<div className="flex gap-6">
|
<div className="flex gap-6">
|
||||||
<span className="hover:text-ink-900 cursor-pointer transition-colors">Security</span>
|
<span className="hover:text-ink-900 cursor-pointer transition-colors">Security</span>
|
||||||
|
|||||||
@ -1,20 +1,56 @@
|
|||||||
import React, { Suspense } from 'react';
|
import React, { Suspense } from "react";
|
||||||
import { createBrowserRouter, Navigate } from 'react-router-dom';
|
import { createBrowserRouter, Navigate } from "react-router-dom";
|
||||||
import { RequireAuth, RequireRole, RequireOnboardingComplete } from './guards';
|
import { RequireAuth, RequireRole, RequireOnboardingComplete } from "./guards";
|
||||||
|
|
||||||
// Layouts
|
// Layouts
|
||||||
const ClientLayout = React.lazy(() => import('../layouts/ClientLayout'));
|
const ClientLayout = React.lazy(() => import("../layouts/ClientLayout"));
|
||||||
const AdminLayout = React.lazy(() => import('../layouts/AdminLayout'));
|
const AdminLayout = React.lazy(() => import("../layouts/AdminLayout"));
|
||||||
|
|
||||||
// Pages (Lazy Loaded)
|
// Pages (Lazy Loaded)
|
||||||
const LoginPage = React.lazy(() => import('../../pages/LoginPage').then(m => ({ default: m.LoginPage })));
|
const LoginPage = React.lazy(() =>
|
||||||
const InvitePage = React.lazy(() => import('../../pages/InvitePage').then(m => ({ default: m.InvitePage })));
|
import("../../pages/LoginPage").then((m) => ({ default: m.LoginPage })),
|
||||||
const DashboardPage = React.lazy(() => import('../../pages/DashboardPage').then(m => ({ default: m.DashboardPage })));
|
);
|
||||||
const AssetsPage = React.lazy(() => import('../../pages/AssetsPage').then(m => ({ default: m.AssetsPage })));
|
const InvitePage = React.lazy(() =>
|
||||||
const ApprovalsPage = React.lazy(() => import('../../pages/admin/ApprovalsPage').then(m => ({ default: m.ApprovalsPage })));
|
import("../../pages/InvitePage").then((m) => ({ default: m.InvitePage })),
|
||||||
const DirectoryPage = React.lazy(() => import('../../pages/admin/DirectoryPage').then(m => ({ default: m.DirectoryPage })));
|
);
|
||||||
const OnboardingPage = React.lazy(() => import('../../pages/OnboardingPage').then(m => ({ default: m.OnboardingPage })));
|
const DashboardPage = React.lazy(() =>
|
||||||
const LegalTemplatesPage = React.lazy(() => import('../../pages/admin/LegalTemplatesPage').then(m => ({ default: m.LegalTemplatesPage })));
|
import("../../pages/DashboardPage").then((m) => ({
|
||||||
|
default: m.DashboardPage,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
const AssetsPage = React.lazy(() =>
|
||||||
|
import("../../pages/AssetsPage").then((m) => ({ default: m.AssetsPage })),
|
||||||
|
);
|
||||||
|
const ApprovalsPage = React.lazy(() =>
|
||||||
|
import("../../pages/admin/ApprovalsPage").then((m) => ({
|
||||||
|
default: m.ApprovalsPage,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
const DirectoryPage = React.lazy(() =>
|
||||||
|
import("../../pages/admin/DirectoryPage").then((m) => ({
|
||||||
|
default: m.DirectoryPage,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
const OnboardingPage = React.lazy(() =>
|
||||||
|
import("../../pages/OnboardingPage").then((m) => ({
|
||||||
|
default: m.OnboardingPage,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
const LegalTemplatesPage = React.lazy(() =>
|
||||||
|
import("../../pages/admin/LegalTemplatesPage").then((m) => ({
|
||||||
|
default: m.LegalTemplatesPage,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
const BlogCatalog = React.lazy(() =>
|
||||||
|
import("../../features/blog/components/BlogCatalog").then((m) => ({
|
||||||
|
default: m.BlogCatalog,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
const ClientAgreementsPage = React.lazy(() =>
|
||||||
|
import("../../pages/ClientAgreementsPage").then((m) => ({
|
||||||
|
default: m.ClientAgreementsPage,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
// Dummy Components for routing
|
// Dummy Components for routing
|
||||||
const LoadingFallback = () => (
|
const LoadingFallback = () => (
|
||||||
@ -23,17 +59,13 @@ const LoadingFallback = () => (
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
const LegalPage = () => <div className="max-w-[1400px] mx-auto w-full p-8"><h1 className="text-4xl font-extrabold text-ink-800 tracking-tight">Legal Engine</h1></div>;
|
|
||||||
const AnalyticsPage = () => <div className="max-w-[1400px] mx-auto w-full p-8"><h1 className="text-4xl font-extrabold text-ink-800 tracking-tight">Analytics Dashboard</h1></div>;
|
|
||||||
const BlogPage = () => <div className="max-w-[1400px] mx-auto w-full p-8"><h1 className="text-4xl font-extrabold text-ink-800 tracking-tight">Blog CMS</h1></div>;
|
|
||||||
|
|
||||||
export const router = createBrowserRouter([
|
export const router = createBrowserRouter([
|
||||||
{
|
{
|
||||||
path: '/',
|
path: "/",
|
||||||
element: <Navigate to="/login" replace />,
|
element: <Navigate to="/login" replace />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/login',
|
path: "/login",
|
||||||
element: (
|
element: (
|
||||||
<Suspense fallback={<LoadingFallback />}>
|
<Suspense fallback={<LoadingFallback />}>
|
||||||
<LoginPage />
|
<LoginPage />
|
||||||
@ -41,7 +73,7 @@ export const router = createBrowserRouter([
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/invite',
|
path: "/invite",
|
||||||
element: (
|
element: (
|
||||||
<Suspense fallback={<LoadingFallback />}>
|
<Suspense fallback={<LoadingFallback />}>
|
||||||
<InvitePage />
|
<InvitePage />
|
||||||
@ -49,7 +81,7 @@ export const router = createBrowserRouter([
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/onboarding',
|
path: "/onboarding",
|
||||||
element: (
|
element: (
|
||||||
<RequireAuth>
|
<RequireAuth>
|
||||||
<RequireRole role="PARTNER_USER">
|
<RequireRole role="PARTNER_USER">
|
||||||
@ -61,7 +93,7 @@ export const router = createBrowserRouter([
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/client',
|
path: "/client",
|
||||||
element: (
|
element: (
|
||||||
<RequireAuth>
|
<RequireAuth>
|
||||||
<RequireRole role="PARTNER_USER">
|
<RequireRole role="PARTNER_USER">
|
||||||
@ -83,7 +115,7 @@ export const router = createBrowserRouter([
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'assets',
|
path: "assets",
|
||||||
element: (
|
element: (
|
||||||
<Suspense fallback={<LoadingFallback />}>
|
<Suspense fallback={<LoadingFallback />}>
|
||||||
<AssetsPage />
|
<AssetsPage />
|
||||||
@ -91,17 +123,25 @@ export const router = createBrowserRouter([
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'agreements',
|
path: "agreements",
|
||||||
element: <LegalPage />,
|
element: (
|
||||||
|
<Suspense fallback={<LoadingFallback />}>
|
||||||
|
<ClientAgreementsPage />
|
||||||
|
</Suspense>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'blog',
|
path: "blog",
|
||||||
element: <BlogPage />,
|
element: (
|
||||||
}
|
<Suspense fallback={<LoadingFallback />}>
|
||||||
|
<BlogCatalog />
|
||||||
|
</Suspense>
|
||||||
|
),
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/admin',
|
path: "/admin",
|
||||||
element: (
|
element: (
|
||||||
<RequireAuth>
|
<RequireAuth>
|
||||||
<RequireRole role="ADMIN">
|
<RequireRole role="ADMIN">
|
||||||
@ -117,7 +157,7 @@ export const router = createBrowserRouter([
|
|||||||
element: <DirectoryPage />,
|
element: <DirectoryPage />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'assets',
|
path: "assets",
|
||||||
element: (
|
element: (
|
||||||
<Suspense fallback={<LoadingFallback />}>
|
<Suspense fallback={<LoadingFallback />}>
|
||||||
<AssetsPage />
|
<AssetsPage />
|
||||||
@ -125,7 +165,7 @@ export const router = createBrowserRouter([
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'legal',
|
path: "legal",
|
||||||
element: (
|
element: (
|
||||||
<Suspense fallback={<LoadingFallback />}>
|
<Suspense fallback={<LoadingFallback />}>
|
||||||
<LegalTemplatesPage />
|
<LegalTemplatesPage />
|
||||||
@ -133,29 +173,29 @@ export const router = createBrowserRouter([
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'analytics',
|
path: "blog",
|
||||||
element: <AnalyticsPage />,
|
element: (
|
||||||
|
<Suspense fallback={<LoadingFallback />}>
|
||||||
|
<BlogCatalog />
|
||||||
|
</Suspense>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'blog',
|
path: "approvals",
|
||||||
element: <div>Blog Management Coming Soon</div>
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: 'approvals',
|
|
||||||
element: (
|
element: (
|
||||||
<Suspense fallback={<LoadingFallback />}>
|
<Suspense fallback={<LoadingFallback />}>
|
||||||
<ApprovalsPage />
|
<ApprovalsPage />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
)
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'partners',
|
path: "partners",
|
||||||
element: (
|
element: (
|
||||||
<Suspense fallback={<LoadingFallback />}>
|
<Suspense fallback={<LoadingFallback />}>
|
||||||
<DirectoryPage />
|
<DirectoryPage />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
)
|
),
|
||||||
}
|
},
|
||||||
]
|
],
|
||||||
}
|
},
|
||||||
]);
|
]);
|
||||||
|
|||||||
47
Channel-Frontend/src/components/layout/PageLayout.tsx
Normal file
47
Channel-Frontend/src/components/layout/PageLayout.tsx
Normal 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;
|
||||||
45
Channel-Frontend/src/components/ui/Button.tsx
Normal file
45
Channel-Frontend/src/components/ui/Button.tsx
Normal 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;
|
||||||
679
Channel-Frontend/src/components/ui/DocumentPreviewModal.tsx
Normal file
679
Channel-Frontend/src/components/ui/DocumentPreviewModal.tsx
Normal 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 & 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 & 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
|
||||||
|
);
|
||||||
|
};
|
||||||
93
Channel-Frontend/src/components/ui/Modal.tsx
Normal file
93
Channel-Frontend/src/components/ui/Modal.tsx
Normal 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;
|
||||||
122
Channel-Frontend/src/components/ui/Toast.tsx
Normal file
122
Channel-Frontend/src/components/ui/Toast.tsx
Normal 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;
|
||||||
@ -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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
@ -1,7 +1,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
|
||||||
import { X } from 'lucide-react';
|
|
||||||
import type { Asset } from '../../../types/assets';
|
import type { Asset } from '../../../types/assets';
|
||||||
|
import Modal from '../../../components/ui/Modal';
|
||||||
|
import Button from '../../../components/ui/Button';
|
||||||
|
|
||||||
interface AssetDetailsModalProps {
|
interface AssetDetailsModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@ -26,109 +26,88 @@ export const AssetDetailsModal: React.FC<AssetDetailsModalProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AnimatePresence>
|
<Modal
|
||||||
{isOpen && asset && (
|
isOpen={isOpen && !!asset}
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
onClose={onClose}
|
||||||
<motion.div
|
title="Asset Details"
|
||||||
initial={{ opacity: 0 }}
|
size="md"
|
||||||
animate={{ opacity: 1 }}
|
footer={
|
||||||
exit={{ opacity: 0 }}
|
<Button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="absolute inset-0 bg-ink-950/40 backdrop-blur-sm"
|
variant="primary"
|
||||||
/>
|
size="sm"
|
||||||
<motion.div
|
>
|
||||||
initial={{ opacity: 0, scale: 0.95, y: 10 }}
|
Close
|
||||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
</Button>
|
||||||
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"
|
>
|
||||||
>
|
{asset && (
|
||||||
<div className="flex justify-between items-center pb-3 border-b border-ink-100">
|
<div className="space-y-4 text-xs font-medium text-ink-900">
|
||||||
<h3 className="text-base font-bold text-ink-900">Asset Details</h3>
|
<div>
|
||||||
<button
|
<h4 className="text-[10px] font-bold uppercase tracking-wider text-ink-500 font-sans">Title</h4>
|
||||||
onClick={onClose}
|
<p className="text-sm font-extrabold text-ink-900 mt-1 font-sans">{asset.title}</p>
|
||||||
className="p-1 rounded-lg text-ink-400 hover:text-ink-900 hover:bg-ink-50 transition-colors"
|
</div>
|
||||||
>
|
|
||||||
<X className="w-5 h-5" />
|
{asset.description && (
|
||||||
</button>
|
<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>
|
||||||
|
)}
|
||||||
|
|
||||||
<div className="space-y-4 text-xs font-medium">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<h4 className="text-[10px] font-bold uppercase tracking-wider text-ink-500">Title</h4>
|
<h4 className="text-[10px] font-bold uppercase tracking-wider text-ink-500 font-sans">Category</h4>
|
||||||
<p className="text-sm font-extrabold text-ink-900 mt-1">{asset.title}</p>
|
<p className="text-ink-900 mt-1 font-bold font-sans">{asset.categoryId || 'General'}</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>
|
</div>
|
||||||
|
<div>
|
||||||
<div className="pt-3.5 border-t border-ink-100 flex justify-end">
|
<h4 className="text-[10px] font-bold uppercase tracking-wider text-ink-500 font-sans">Subcategory</h4>
|
||||||
<button
|
<p className="text-ink-900 mt-1 font-bold font-sans">{asset.subcategory || '-'}</p>
|
||||||
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>
|
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</Modal>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import React, { useState, useEffect } from 'react';
|
|||||||
import type { Asset, Category } from '../../../types';
|
import type { Asset, Category } from '../../../types';
|
||||||
import { apiClient } from '../../../lib/api-client';
|
import { apiClient } from '../../../lib/api-client';
|
||||||
import { Cpu, Terminal, Brain, Server, Search, Download, Calendar, User, Tag, HelpCircle, X } from 'lucide-react';
|
import { Cpu, Terminal, Brain, Server, Search, Download, Calendar, User, Tag, HelpCircle, X } from 'lucide-react';
|
||||||
|
import { useToast } from '../../../hooks/use-toast';
|
||||||
|
|
||||||
const GithubIcon: React.FC<{ className?: string }> = ({ className }) => (
|
const GithubIcon: React.FC<{ className?: string }> = ({ className }) => (
|
||||||
<svg
|
<svg
|
||||||
@ -19,6 +20,7 @@ const GithubIcon: React.FC<{ className?: string }> = ({ className }) => (
|
|||||||
);
|
);
|
||||||
|
|
||||||
export const AssetExplorer: React.FC = () => {
|
export const AssetExplorer: React.FC = () => {
|
||||||
|
const { success, error } = useToast();
|
||||||
const [assets, setAssets] = useState<Asset[]>([]);
|
const [assets, setAssets] = useState<Asset[]>([]);
|
||||||
const [categories, setCategories] = useState<Category[]>([]);
|
const [categories, setCategories] = useState<Category[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@ -30,9 +32,6 @@ export const AssetExplorer: React.FC = () => {
|
|||||||
|
|
||||||
// Selected asset details modal state
|
// Selected asset details modal state
|
||||||
const [selectedAsset, setSelectedAsset] = useState<Asset | null>(null);
|
const [selectedAsset, setSelectedAsset] = useState<Asset | null>(null);
|
||||||
|
|
||||||
// Toast notifications simulation
|
|
||||||
const [toastMessage, setToastMessage] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const fetchAssets = async () => {
|
const fetchAssets = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@ -70,13 +69,8 @@ export const AssetExplorer: React.FC = () => {
|
|||||||
fetchCategories();
|
fetchCategories();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const triggerToast = (msg: string) => {
|
|
||||||
setToastMessage(msg);
|
|
||||||
setTimeout(() => setToastMessage(null), 3000);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDownload = async (asset: Asset) => {
|
const handleDownload = async (asset: Asset) => {
|
||||||
triggerToast(`Starting download: ${asset.title}`);
|
success('Starting download', `Downloading ${asset.title}...`);
|
||||||
try {
|
try {
|
||||||
const updatedAsset = { ...asset, downloadsCount: asset.downloadsCount + 1 };
|
const updatedAsset = { ...asset, downloadsCount: asset.downloadsCount + 1 };
|
||||||
await apiClient.put(`/assets/${asset.id}`, updatedAsset);
|
await apiClient.put(`/assets/${asset.id}`, updatedAsset);
|
||||||
@ -87,6 +81,7 @@ export const AssetExplorer: React.FC = () => {
|
|||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
|
error('Download tracking failed', 'Unable to record download statistics.');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -104,15 +99,6 @@ export const AssetExplorer: React.FC = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 text-ink-900">
|
<div className="space-y-6 text-ink-900">
|
||||||
{/* Toast Notification */}
|
|
||||||
{toastMessage && (
|
|
||||||
<div className="fixed bottom-5 right-5 z-50 rounded-xl bg-ink-900 text-ink-0 px-5 py-3 text-sm shadow-premium flex items-center gap-2 border border-ink-700 animate-slide-up">
|
|
||||||
<Download className="h-4 w-4 text-ink-0 animate-bounce" />
|
|
||||||
<span className="font-semibold text-ink-50">{toastMessage}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Hero section */}
|
|
||||||
<div className="rounded-2xl bg-ink-0 p-6 md:p-8 border border-ink-200 flex flex-col md:flex-row items-center justify-between gap-6">
|
<div className="rounded-2xl bg-ink-0 p-6 md:p-8 border border-ink-200 flex flex-col md:flex-row items-center justify-between gap-6">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<h1 className="text-2xl font-bold text-ink-900 md:text-3xl">Tech4Biz Asset Explorer</h1>
|
<h1 className="text-2xl font-bold text-ink-900 md:text-3xl">Tech4Biz Asset Explorer</h1>
|
||||||
|
|||||||
@ -1,9 +1,10 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { Minimize2, Maximize2, Globe, ExternalLink, FileText, File, Download } from 'lucide-react';
|
||||||
import { X, Minimize2, Maximize2, Globe, ExternalLink, FileText, File, Download } from 'lucide-react';
|
|
||||||
import { axiosInstance } from '../../../services/axios';
|
import { axiosInstance } from '../../../services/axios';
|
||||||
import type { Asset } from '../../../types/assets';
|
import type { Asset } from '../../../types/assets';
|
||||||
import type { User } from '../../../types/auth';
|
import type { User } from '../../../types/auth';
|
||||||
|
import Modal from '../../../components/ui/Modal';
|
||||||
|
import Button from '../../../components/ui/Button';
|
||||||
|
|
||||||
interface AssetViewerModalProps {
|
interface AssetViewerModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@ -133,213 +134,197 @@ export const AssetViewerModal: React.FC<AssetViewerModalProps> = ({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AnimatePresence>
|
<Modal
|
||||||
{isOpen && asset && (
|
isOpen={isOpen && !!asset}
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
onClose={handleClose}
|
||||||
<motion.div
|
title={
|
||||||
initial={{ opacity: 0 }}
|
<div className="flex items-center justify-between w-full">
|
||||||
animate={{ opacity: 1 }}
|
<div className="text-left">
|
||||||
exit={{ opacity: 0 }}
|
<span className="text-base font-bold text-ink-900 block font-sans">{asset?.title}</span>
|
||||||
onClick={handleClose}
|
<span className="text-xs text-ink-500 font-sans block mt-0.5 font-normal">
|
||||||
className="absolute inset-0 bg-ink-950/60 backdrop-blur-md"
|
{asset?.type === 'url' ? 'External Web Link' : `${asset?.type} • ${asset ? formatBytes(asset.size) : ''}`}
|
||||||
/>
|
</span>
|
||||||
<motion.div
|
</div>
|
||||||
initial={{ opacity: 0, scale: 0.98, y: 15 }}
|
<button
|
||||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
onClick={() => setIsMaximized(!isMaximized)}
|
||||||
exit={{ opacity: 0, scale: 0.98, y: 15 }}
|
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"
|
||||||
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 ${
|
title={isMaximized ? "Collapse view" : "Expand view"}
|
||||||
isMaximized ? 'w-[96vw] h-[92vh] max-h-[92vh] max-w-none' : 'max-w-5xl w-full max-h-[85vh]'
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
<div className="flex justify-between items-center pb-3 border-b border-ink-100 flex-shrink-0">
|
{isMaximized ? <Minimize2 className="w-4 h-4" /> : <Maximize2 className="w-4 h-4" />}
|
||||||
<div>
|
</button>
|
||||||
<h3 className="text-base font-bold text-ink-900">{asset.title}</h3>
|
</div>
|
||||||
<p className="text-xs text-ink-500">
|
}
|
||||||
{asset.type === 'url' ? 'External Web Link' : `${asset.type} • ${formatBytes(asset.size)}`}
|
size="full"
|
||||||
</p>
|
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>
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex-1 overflow-auto p-6 text-left select-text bg-ink-0 text-ink-800 font-sans leading-relaxed">
|
||||||
<button
|
{isLoadingText ? (
|
||||||
onClick={() => setIsMaximized(!isMaximized)}
|
<div className="flex flex-col items-center justify-center h-full space-y-3">
|
||||||
className="p-1.5 rounded-lg text-ink-400 hover:text-ink-900 hover:bg-ink-50 transition-colors"
|
<div className="w-6 h-6 rounded-full border-4 border-ink-900 border-t-transparent animate-spin" />
|
||||||
title={isMaximized ? "Collapse view" : "Expand view"}
|
<span className="text-xs text-ink-500 font-medium font-sans">Fetching README.md...</span>
|
||||||
>
|
</div>
|
||||||
{isMaximized ? <Minimize2 className="w-5 h-5" /> : <Maximize2 className="w-5 h-5" />}
|
) : (
|
||||||
</button>
|
<div className="max-w-3xl mx-auto space-y-4">
|
||||||
<button
|
<div className="border-b border-ink-200 pb-4 mb-6">
|
||||||
onClick={handleClose}
|
<h1 className="text-xl font-extrabold text-ink-950 font-sans">{asset.title}</h1>
|
||||||
className="p-1 rounded-lg text-ink-400 hover:text-ink-900 hover:bg-ink-50 transition-colors"
|
<p className="text-xs text-ink-500 mt-1 font-mono">{asset.url}</p>
|
||||||
>
|
</div>
|
||||||
<X className="w-5 h-5" />
|
<pre className="whitespace-pre-wrap font-sans text-sm text-ink-800 break-words leading-relaxed">
|
||||||
</button>
|
{textPreviewContent}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
) : asset.type === 'url' ? (
|
||||||
<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">
|
<div className="text-center p-12 max-w-md space-y-4 flex flex-col justify-center items-center">
|
||||||
{asset.type === 'url' && asset.url.includes('github.com') ? (
|
<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">
|
||||||
<div className="w-full h-full flex flex-col min-h-0 bg-ink-0">
|
<Globe className="w-8 h-8" />
|
||||||
<div className="bg-ink-100 px-4 py-2 border-b border-ink-200 flex justify-between items-center text-xs text-ink-600 font-bold select-none">
|
</div>
|
||||||
<span className="flex items-center gap-1.5">
|
<div>
|
||||||
<Globe className="w-4 h-4 text-ink-950" />
|
<h4 className="text-sm font-bold text-ink-900 font-sans">External Resource Portal</h4>
|
||||||
<span>Embedded GitHub Document</span>
|
<p className="text-xs text-ink-500 mt-2 leading-relaxed font-sans">
|
||||||
</span>
|
This asset points to an external destination outside of the local CDN container.
|
||||||
</div>
|
</p>
|
||||||
<div className="flex-1 overflow-auto p-6 text-left select-text bg-ink-0 text-ink-800 font-sans leading-relaxed">
|
<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">
|
||||||
{isLoadingText ? (
|
{asset.url}
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
) : asset.type === 'url' ? (
|
</div>
|
||||||
<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">
|
{(user?.role === 'ADMIN' || asset.isDownloadable || asset.downloadRequests?.[0]?.status === 'APPROVED') ? (
|
||||||
<Globe className="w-8 h-8" />
|
<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>
|
||||||
<div>
|
<div>
|
||||||
<h4 className="text-sm font-bold text-ink-900">External Resource Portal</h4>
|
<h4 className="text-sm font-bold text-ink-900 font-sans">Office Document Preview</h4>
|
||||||
<p className="text-xs text-ink-500 mt-2 leading-relaxed">
|
<p className="text-xs text-ink-500 mt-2 leading-relaxed font-sans">
|
||||||
This asset points to an external destination outside of the local CDN container.
|
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>
|
</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>
|
</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>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-center p-8 flex flex-col justify-center items-center">
|
<iframe
|
||||||
<File className="w-12 h-12 text-ink-300 mb-3" />
|
src={`https://view.officeapps.live.com/op/embed.aspx?src=${encodeURIComponent(getFullAssetUrl(asset.url))}`}
|
||||||
<h4 className="text-sm font-bold text-ink-900">Direct Preview Unsupported</h4>
|
className="w-full flex-1 border-0 min-h-0"
|
||||||
<p className="text-xs text-ink-500 mt-1 max-w-sm">
|
title={asset.title}
|
||||||
This file format cannot be rendered directly in the browser. Please download the file to inspect its contents.
|
/>
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
) : asset.type.includes('image') || asset.type.includes('png') || asset.type.includes('jpg') ? (
|
||||||
<div className="pt-3 border-t border-ink-100 flex justify-end gap-3 flex-shrink-0">
|
<div className="w-full h-full flex items-center justify-center p-4">
|
||||||
<button
|
<img
|
||||||
onClick={handleClose}
|
src={getFullAssetUrl(asset.url)}
|
||||||
className="px-4 py-2 rounded-lg border border-ink-200 text-ink-700 text-xs font-bold hover:bg-ink-50 transition-colors"
|
alt={asset.title}
|
||||||
>
|
className="max-w-full max-h-full object-contain rounded-xl shadow-sm border border-ink-100"
|
||||||
Close Preview
|
/>
|
||||||
</button>
|
|
||||||
|
|
||||||
{asset.type !== 'url' && (user?.role === 'ADMIN' || asset.isDownloadable || asset.downloadRequests?.[0]?.status === 'APPROVED') && (
|
|
||||||
<button
|
|
||||||
onClick={() => onDownload(asset)}
|
|
||||||
className="px-5 py-2 rounded-lg bg-ink-900 text-ink-0 text-xs font-bold hover:bg-ink-800 transition-colors shadow-sm flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<Download className="w-4 h-4" />
|
|
||||||
<span>Download File</span>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
</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>
|
</div>
|
||||||
)}
|
)}
|
||||||
</AnimatePresence>
|
</Modal>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,7 +1,8 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
|
||||||
import { X, Check } from 'lucide-react';
|
|
||||||
import type { Asset } from '../../../types/assets';
|
import type { Asset } from '../../../types/assets';
|
||||||
|
import Modal from '../../../components/ui/Modal';
|
||||||
|
import Button from '../../../components/ui/Button';
|
||||||
|
import { Check } from 'lucide-react';
|
||||||
|
|
||||||
interface DownloadRequestsModalProps {
|
interface DownloadRequestsModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@ -29,82 +30,59 @@ export const DownloadRequestsModal: React.FC<DownloadRequestsModalProps> = ({
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AnimatePresence>
|
<Modal
|
||||||
{isOpen && (
|
isOpen={isOpen}
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
onClose={onClose}
|
||||||
<motion.div
|
title="Pending Download Requests"
|
||||||
initial={{ opacity: 0 }}
|
subtitle="Review and approve download access for protected secret assets."
|
||||||
animate={{ opacity: 1 }}
|
size="lg"
|
||||||
exit={{ opacity: 0 }}
|
footer={
|
||||||
onClick={onClose}
|
<Button
|
||||||
className="absolute inset-0 bg-ink-950/40 backdrop-blur-sm"
|
onClick={onClose}
|
||||||
/>
|
variant="primary"
|
||||||
<motion.div
|
size="sm"
|
||||||
initial={{ opacity: 0, scale: 0.95, y: 10 }}
|
>
|
||||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
Close
|
||||||
exit={{ opacity: 0, scale: 0.95, y: 10 }}
|
</Button>
|
||||||
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">
|
<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>
|
<div>
|
||||||
<h3 className="text-lg font-bold text-ink-900">Pending Download Requests</h3>
|
<p className="text-xs font-bold text-ink-900 font-sans">{req.user?.email}</p>
|
||||||
<p className="text-xs text-ink-500 mt-0.5">Review and approve download access for protected secret assets.</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>
|
</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">
|
<div className="flex items-center gap-2 self-end sm:self-center">
|
||||||
{pendingRequests.length === 0 ? (
|
<Button
|
||||||
<div className="text-center py-8">
|
onClick={() => onReject(req.assetId, req.id)}
|
||||||
<Check className="w-8 h-8 text-ink-400 mx-auto mb-2" />
|
variant="danger"
|
||||||
<p className="text-xs font-bold text-ink-900">All caught up!</p>
|
size="xs"
|
||||||
<p className="text-[10px] text-ink-500">There are no pending download authorization requests.</p>
|
>
|
||||||
</div>
|
Reject
|
||||||
) : (
|
</Button>
|
||||||
pendingRequests.map(req => (
|
<Button
|
||||||
<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">
|
onClick={() => onApprove(req.assetId, req.id)}
|
||||||
<div>
|
variant="primary"
|
||||||
<p className="text-xs font-bold text-ink-900">{req.user?.email}</p>
|
size="xs"
|
||||||
<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>
|
Approve Access
|
||||||
</p>
|
</Button>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
))
|
||||||
<div className="pt-3.5 border-t border-ink-100 flex justify-end flex-shrink-0 mt-4">
|
)}
|
||||||
<button
|
</div>
|
||||||
onClick={onClose}
|
</Modal>
|
||||||
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>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,8 +1,9 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
|
||||||
import { X } from 'lucide-react';
|
|
||||||
import { updateAsset } from '../../../services/assets-api';
|
import { updateAsset } from '../../../services/assets-api';
|
||||||
import type { Asset } from '../../../types/assets';
|
import type { Asset } from '../../../types/assets';
|
||||||
|
import Modal from '../../../components/ui/Modal';
|
||||||
|
import Button from '../../../components/ui/Button';
|
||||||
|
import { useToast } from '../../../hooks/use-toast';
|
||||||
|
|
||||||
interface EditAssetModalProps {
|
interface EditAssetModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@ -17,6 +18,7 @@ export const EditAssetModal: React.FC<EditAssetModalProps> = ({
|
|||||||
asset,
|
asset,
|
||||||
onSuccess
|
onSuccess
|
||||||
}) => {
|
}) => {
|
||||||
|
const { success, error } = useToast();
|
||||||
const [editTitle, setEditTitle] = useState('');
|
const [editTitle, setEditTitle] = useState('');
|
||||||
const [editDescription, setEditDescription] = useState('');
|
const [editDescription, setEditDescription] = useState('');
|
||||||
const [editCategory, setEditCategory] = useState('Marketing');
|
const [editCategory, setEditCategory] = useState('Marketing');
|
||||||
@ -53,155 +55,139 @@ export const EditAssetModal: React.FC<EditAssetModalProps> = ({
|
|||||||
githubUrl: editGithubUrl,
|
githubUrl: editGithubUrl,
|
||||||
isDownloadable: editIsDownloadable,
|
isDownloadable: editIsDownloadable,
|
||||||
});
|
});
|
||||||
|
success('Changes saved successfully', 'Asset details have been updated.');
|
||||||
onSuccess();
|
onSuccess();
|
||||||
onClose();
|
onClose();
|
||||||
} catch (err) {
|
} catch (err: any) {
|
||||||
console.error('Failed to save asset details', err);
|
console.error('Failed to save asset details', err);
|
||||||
|
error('Failed to save changes', err.response?.data?.error || 'Something went wrong.');
|
||||||
} finally {
|
} finally {
|
||||||
setIsSavingEdit(false);
|
setIsSavingEdit(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AnimatePresence>
|
<Modal
|
||||||
{isOpen && asset && (
|
isOpen={isOpen && !!asset}
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
onClose={onClose}
|
||||||
<motion.div
|
title="Edit Asset Details"
|
||||||
initial={{ opacity: 0 }}
|
size="lg"
|
||||||
animate={{ opacity: 1 }}
|
footer={
|
||||||
exit={{ opacity: 0 }}
|
<>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="absolute inset-0 bg-ink-950/40 backdrop-blur-sm"
|
variant="ghost"
|
||||||
/>
|
size="sm"
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, scale: 0.95, y: 10 }}
|
|
||||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
|
||||||
exit={{ opacity: 0, scale: 0.95, y: 10 }}
|
|
||||||
className="relative bg-ink-0 border border-ink-200 rounded-2xl p-6 max-w-lg w-full max-h-[90vh] shadow-xl z-10 flex flex-col overflow-hidden"
|
|
||||||
>
|
>
|
||||||
<div className="flex justify-between items-center pb-3.5 border-b border-ink-100 flex-shrink-0">
|
Cancel
|
||||||
<h3 className="text-lg font-bold text-ink-900">Edit Asset Details</h3>
|
</Button>
|
||||||
<button
|
<Button
|
||||||
onClick={onClose}
|
type="submit"
|
||||||
className="p-1 rounded-lg text-ink-400 hover:text-ink-900 hover:bg-ink-50 transition-colors"
|
form="edit-asset-form"
|
||||||
|
disabled={isSavingEdit}
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
{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" />
|
<option value="Marketing">Marketing</option>
|
||||||
</button>
|
<option value="Presentations">Presentations</option>
|
||||||
|
<option value="Branding">Branding</option>
|
||||||
|
<option value="Resources">Resources</option>
|
||||||
|
<option value="Technical">Technical</option>
|
||||||
|
</select>
|
||||||
</div>
|
</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>
|
||||||
<div className="flex-1 overflow-y-auto pr-1 space-y-4 scrollbar-thin">
|
<label className="text-xs font-semibold text-ink-500 mb-1.5 block">Description</label>
|
||||||
<div>
|
<textarea
|
||||||
<label className="text-xs font-semibold text-ink-500 mb-1.5 block">Asset Title</label>
|
value={editDescription}
|
||||||
<input
|
onChange={(e) => setEditDescription(e.target.value)}
|
||||||
type="text"
|
placeholder="Enter short description..."
|
||||||
value={editTitle}
|
rows={3}
|
||||||
onChange={(e) => setEditTitle(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 placeholder-ink-400 resize-none"
|
||||||
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>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
{asset.type !== 'url' && (
|
||||||
<div>
|
<div className="flex items-center gap-3 p-3 bg-ink-50 border border-ink-200 rounded-lg">
|
||||||
<label className="text-xs font-semibold text-ink-500 mb-1.5 block">Category</label>
|
<input
|
||||||
<select
|
type="checkbox"
|
||||||
value={editCategory}
|
id="editIsDownloadable"
|
||||||
onChange={(e) => setEditCategory(e.target.value)}
|
checked={editIsDownloadable}
|
||||||
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"
|
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"
|
||||||
<option value="Marketing">Marketing</option>
|
/>
|
||||||
<option value="Presentations">Presentations</option>
|
<div>
|
||||||
<option value="Branding">Branding</option>
|
<label htmlFor="editIsDownloadable" className="text-xs font-bold text-ink-900 cursor-pointer block">
|
||||||
<option value="Resources">Resources</option>
|
Allow Direct Download (Strict View Only if unchecked)
|
||||||
<option value="Technical">Technical</option>
|
</label>
|
||||||
</select>
|
<span className="text-[10px] text-ink-500">
|
||||||
</div>
|
Toggle client authorization requirement for asset downloads.
|
||||||
<div>
|
</span>
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="pt-3.5 border-t border-ink-100 flex justify-end gap-3 flex-shrink-0 mt-4">
|
</div>
|
||||||
<button
|
)}
|
||||||
type="button"
|
|
||||||
onClick={onClose}
|
<div>
|
||||||
className="px-4 py-2 rounded-lg border border-ink-200 text-ink-700 text-xs font-bold hover:bg-ink-50 transition-colors"
|
<label className="text-xs font-semibold text-ink-500 mb-1.5 block">Tags (comma-separated)</label>
|
||||||
>
|
<input
|
||||||
Cancel
|
type="text"
|
||||||
</button>
|
value={editTags}
|
||||||
<button
|
onChange={(e) => setEditTags(e.target.value)}
|
||||||
type="submit"
|
placeholder="branding, guideline, pitch"
|
||||||
disabled={isSavingEdit}
|
className="w-full bg-ink-50 border border-ink-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ink-900/10 text-ink-900 placeholder-ink-400"
|
||||||
className="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"
|
/>
|
||||||
>
|
</div>
|
||||||
{isSavingEdit ? 'Saving...' : 'Save Changes'}
|
|
||||||
</button>
|
<div>
|
||||||
</div>
|
<label className="text-xs font-semibold text-ink-500 mb-1.5 block">GitHub/Documentation URL (Optional)</label>
|
||||||
</form>
|
<input
|
||||||
</motion.div>
|
type="url"
|
||||||
</div>
|
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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,8 +1,11 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import { X, ChevronDown, ChevronUp } from 'lucide-react';
|
|
||||||
import { updateAsset } from '../../../services/assets-api';
|
import { updateAsset } from '../../../services/assets-api';
|
||||||
import type { Asset, Organization, ShareItem } from '../../../types/assets';
|
import type { Asset, Organization, ShareItem } from '../../../types/assets';
|
||||||
|
import Modal from '../../../components/ui/Modal';
|
||||||
|
import Button from '../../../components/ui/Button';
|
||||||
|
import { ChevronDown, ChevronUp } from 'lucide-react';
|
||||||
|
import { useToast } from '../../../hooks/use-toast';
|
||||||
|
|
||||||
interface ShareAssetModalProps {
|
interface ShareAssetModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@ -19,6 +22,7 @@ export const ShareAssetModal: React.FC<ShareAssetModalProps> = ({
|
|||||||
organizations,
|
organizations,
|
||||||
onSuccess
|
onSuccess
|
||||||
}) => {
|
}) => {
|
||||||
|
const { success, error } = useToast();
|
||||||
const [sharesList, setSharesList] = useState<ShareItem[]>([]);
|
const [sharesList, setSharesList] = useState<ShareItem[]>([]);
|
||||||
const [isSavingShare, setIsSavingShare] = useState(false);
|
const [isSavingShare, setIsSavingShare] = useState(false);
|
||||||
const [expandedOrgId, setExpandedOrgId] = useState<string | null>(null);
|
const [expandedOrgId, setExpandedOrgId] = useState<string | null>(null);
|
||||||
@ -75,151 +79,132 @@ export const ShareAssetModal: React.FC<ShareAssetModalProps> = ({
|
|||||||
await updateAsset(asset.id, {
|
await updateAsset(asset.id, {
|
||||||
shares: sharesList
|
shares: sharesList
|
||||||
});
|
});
|
||||||
|
success('Share permissions updated', 'The asset visibility settings have been updated.');
|
||||||
onSuccess();
|
onSuccess();
|
||||||
onClose();
|
onClose();
|
||||||
} catch (err) {
|
} catch (err: any) {
|
||||||
console.error('Failed to update share permissions', err);
|
console.error('Failed to update share permissions', err);
|
||||||
|
error('Failed to update share permissions', err.response?.data?.error || 'Something went wrong.');
|
||||||
} finally {
|
} finally {
|
||||||
setIsSavingShare(false);
|
setIsSavingShare(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AnimatePresence>
|
<Modal
|
||||||
{isOpen && asset && (
|
isOpen={isOpen && !!asset}
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
onClose={onClose}
|
||||||
<motion.div
|
title="Share Settings"
|
||||||
initial={{ opacity: 0 }}
|
subtitle={asset?.title}
|
||||||
animate={{ opacity: 1 }}
|
size="md"
|
||||||
exit={{ opacity: 0 }}
|
footer={
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="absolute inset-0 bg-ink-950/40 backdrop-blur-sm"
|
variant="ghost"
|
||||||
/>
|
size="sm"
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, scale: 0.95, y: 10 }}
|
|
||||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
|
||||||
exit={{ opacity: 0, scale: 0.95, y: 10 }}
|
|
||||||
className="relative bg-ink-0 border border-ink-200 rounded-2xl p-6 max-w-lg w-full shadow-xl z-10 flex flex-col max-h-[85vh] overflow-hidden"
|
|
||||||
>
|
>
|
||||||
<div className="flex justify-between items-center pb-3.5 border-b border-ink-100 flex-shrink-0">
|
Cancel
|
||||||
<div>
|
</Button>
|
||||||
<h3 className="text-lg font-bold text-ink-900">Share Settings</h3>
|
<Button
|
||||||
<p className="text-xs text-ink-500 mt-0.5">{asset.title}</p>
|
type="submit"
|
||||||
</div>
|
form="share-asset-form"
|
||||||
<button
|
disabled={isSavingShare}
|
||||||
onClick={onClose}
|
variant="primary"
|
||||||
className="p-1 rounded-lg text-ink-400 hover:text-ink-900 hover:bg-ink-50 transition-colors"
|
size="sm"
|
||||||
>
|
>
|
||||||
<X className="w-5 h-5" />
|
{isSavingShare ? 'Saving...' : 'Update Shares'}
|
||||||
</button>
|
</Button>
|
||||||
</div>
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{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="max-h-64 overflow-y-auto border border-ink-200 rounded-xl divide-y divide-ink-250 bg-ink-50 scrollbar-thin">
|
||||||
<div className="flex-1 overflow-y-auto pr-1 space-y-4 scrollbar-thin">
|
{organizations.length === 0 ? (
|
||||||
<p className="text-xs text-ink-600 leading-relaxed">
|
<p className="p-4 text-xs text-ink-500 text-center font-medium font-sans">No partner organizations registered yet.</p>
|
||||||
Select organizations or expand to specify exact users that can access this asset:
|
) : (
|
||||||
</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">
|
return (
|
||||||
{organizations.length === 0 ? (
|
<div key={org.id} className="flex flex-col">
|
||||||
<p className="p-4 text-xs text-ink-500 text-center font-medium">No partner organizations registered yet.</p>
|
<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">
|
||||||
organizations.map(org => {
|
<input
|
||||||
const isEntireShared = isOrgSharedEntirely(org.id);
|
type="checkbox"
|
||||||
const isExpanded = expandedOrgId === org.id;
|
checked={isEntireShared}
|
||||||
const activeUsers = org.users || [];
|
onChange={() => handleToggleOrg(org.id)}
|
||||||
const specificSharedCount = sharesList.filter(s => s.organizationId === org.id && s.userId !== null).length;
|
className="w-4 h-4 rounded border-ink-300 text-ink-900 focus:ring-ink-900/10 cursor-pointer"
|
||||||
|
/>
|
||||||
return (
|
<div className="flex flex-col">
|
||||||
<div key={org.id} className="flex flex-col">
|
<span className="text-xs font-bold text-ink-900 font-sans">{org.name}</span>
|
||||||
<div className="flex items-center justify-between p-3 hover:bg-ink-100 transition-colors">
|
{specificSharedCount > 0 && !isEntireShared && (
|
||||||
<label className="flex items-center gap-3 cursor-pointer flex-1 select-none">
|
<span className="text-[10px] text-ink-500 font-semibold font-sans">
|
||||||
<input
|
Shared with {specificSharedCount} specific {specificSharedCount === 1 ? 'user' : 'users'}
|
||||||
type="checkbox"
|
</span>
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
</label>
|
||||||
})
|
|
||||||
)}
|
<button
|
||||||
</div>
|
type="button"
|
||||||
</div>
|
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">
|
<AnimatePresence>
|
||||||
<button
|
{isExpanded && (
|
||||||
type="button"
|
<motion.div
|
||||||
onClick={onClose}
|
initial={{ height: 0, opacity: 0 }}
|
||||||
className="px-4 py-2 rounded-lg border border-ink-200 text-ink-700 text-xs font-bold hover:bg-ink-50 transition-colors"
|
animate={{ height: 'auto', opacity: 1 }}
|
||||||
>
|
exit={{ height: 0, opacity: 0 }}
|
||||||
Cancel
|
className="bg-ink-100 border-t border-b border-ink-200 overflow-hidden divide-y divide-ink-150"
|
||||||
</button>
|
>
|
||||||
<button
|
{activeUsers.length === 0 ? (
|
||||||
type="submit"
|
<p className="p-3 text-[10px] text-ink-500 italic font-sans">No users found in this organization.</p>
|
||||||
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"
|
activeUsers.map(userItem => {
|
||||||
>
|
const isUserShared = isUserSharedSpecifically(org.id, userItem.id);
|
||||||
{isSavingShare ? 'Saving...' : 'Update Shares'}
|
|
||||||
</button>
|
return (
|
||||||
</div>
|
<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">
|
||||||
</form>
|
<input
|
||||||
</motion.div>
|
type="checkbox"
|
||||||
</div>
|
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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,7 +1,9 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
|
||||||
import { X, UploadCloud, Eye, FileText, File } from 'lucide-react';
|
import { X, UploadCloud, Eye, FileText, File } from 'lucide-react';
|
||||||
import { uploadAsset } from '../../../services/assets-api';
|
import { uploadAsset } from '../../../services/assets-api';
|
||||||
|
import Modal from '../../../components/ui/Modal';
|
||||||
|
import Button from '../../../components/ui/Button';
|
||||||
|
import { useToast } from '../../../hooks/use-toast';
|
||||||
|
|
||||||
interface UploadAssetModalProps {
|
interface UploadAssetModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@ -14,6 +16,7 @@ export const UploadAssetModal: React.FC<UploadAssetModalProps> = ({
|
|||||||
onClose,
|
onClose,
|
||||||
onSuccess
|
onSuccess
|
||||||
}) => {
|
}) => {
|
||||||
|
const { success, error } = useToast();
|
||||||
const [uploadTab, setUploadTab] = useState<'file' | 'url'>('file');
|
const [uploadTab, setUploadTab] = useState<'file' | 'url'>('file');
|
||||||
const [uploadFile, setUploadFile] = useState<File | null>(null);
|
const [uploadFile, setUploadFile] = useState<File | null>(null);
|
||||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||||||
@ -88,10 +91,12 @@ export const UploadAssetModal: React.FC<UploadAssetModalProps> = ({
|
|||||||
setUploadTags('');
|
setUploadTags('');
|
||||||
setUploadGithubUrl('');
|
setUploadGithubUrl('');
|
||||||
setUploadIsDownloadable(true);
|
setUploadIsDownloadable(true);
|
||||||
|
success('Asset published successfully', 'The asset has been added to the catalog.');
|
||||||
onSuccess();
|
onSuccess();
|
||||||
onClose();
|
onClose();
|
||||||
} catch (err) {
|
} catch (err: any) {
|
||||||
console.error('Failed to upload asset', err);
|
console.error('Failed to upload asset', err);
|
||||||
|
error('Failed to publish asset', err.response?.data?.error || 'Something went wrong.');
|
||||||
} finally {
|
} finally {
|
||||||
setIsUploading(false);
|
setIsUploading(false);
|
||||||
}
|
}
|
||||||
@ -99,312 +104,273 @@ export const UploadAssetModal: React.FC<UploadAssetModalProps> = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<AnimatePresence>
|
<Modal
|
||||||
{isOpen && (
|
isOpen={isOpen}
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
onClose={onClose}
|
||||||
<motion.div
|
title="Upload / Link Asset"
|
||||||
initial={{ opacity: 0 }}
|
size="lg"
|
||||||
animate={{ opacity: 1 }}
|
footer={
|
||||||
exit={{ opacity: 0 }}
|
<>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="absolute inset-0 bg-ink-950/40 backdrop-blur-sm"
|
variant="ghost"
|
||||||
/>
|
size="sm"
|
||||||
<motion.div
|
|
||||||
initial={{ opacity: 0, scale: 0.95, y: 10 }}
|
|
||||||
animate={{ opacity: 1, scale: 1, y: 0 }}
|
|
||||||
exit={{ opacity: 0, scale: 0.95, y: 10 }}
|
|
||||||
className="relative bg-ink-0 border border-ink-200 rounded-2xl p-6 max-w-lg w-full max-h-[90vh] shadow-xl z-10 flex flex-col overflow-hidden"
|
|
||||||
>
|
>
|
||||||
<div className="flex justify-between items-center pb-3.5 border-b border-ink-100 flex-shrink-0">
|
Cancel
|
||||||
<h3 className="text-lg font-bold text-ink-900">Upload / Link Asset</h3>
|
</Button>
|
||||||
<button
|
<Button
|
||||||
onClick={onClose}
|
type="submit"
|
||||||
className="p-1 rounded-lg text-ink-400 hover:text-ink-900 hover:bg-ink-50 transition-colors"
|
form="upload-asset-form"
|
||||||
>
|
disabled={isUploading}
|
||||||
<X className="w-5 h-5" />
|
variant="primary"
|
||||||
</button>
|
size="sm"
|
||||||
</div>
|
>
|
||||||
|
{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 */}
|
<form id="upload-asset-form" onSubmit={handleUploadSubmit} className="space-y-4 mt-4">
|
||||||
<div className="flex bg-ink-50 p-1 rounded-xl border border-ink-200 flex-shrink-0 mt-4">
|
{uploadTab === 'file' ? (
|
||||||
<button
|
<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' : ''}`}>
|
||||||
type="button"
|
{!uploadFile && (
|
||||||
onClick={() => setUploadTab('file')}
|
<input
|
||||||
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'}`}
|
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>
|
||||||
<button
|
)}
|
||||||
type="button"
|
{previewUrl && uploadFile ? (
|
||||||
onClick={() => setUploadTab('url')}
|
<div className="relative z-20 py-1">
|
||||||
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'}`}
|
<div className="relative w-24 h-24 mx-auto mb-3">
|
||||||
>
|
<img
|
||||||
External Web URL
|
src={previewUrl}
|
||||||
</button>
|
alt="Upload preview"
|
||||||
</div>
|
className="w-full h-full object-cover rounded-lg shadow-sm border border-ink-200"
|
||||||
|
|
||||||
<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"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</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">
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<span className="text-[11px] font-semibold text-ink-900 truncate flex-1 text-left" title={uploadFile?.name}>
|
||||||
<div>
|
{uploadFile?.name}
|
||||||
<label className="text-xs font-semibold text-ink-500 mb-1.5 block">Category</label>
|
</span>
|
||||||
<select
|
<div className="flex items-center gap-1 flex-shrink-0">
|
||||||
value={uploadCategory}
|
<button
|
||||||
onChange={(e) => setUploadCategory(e.target.value)}
|
type="button"
|
||||||
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"
|
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>
|
<Eye className="w-3 h-3" />
|
||||||
<option value="Presentations">Presentations</option>
|
</button>
|
||||||
<option value="Branding">Branding</option>
|
<button
|
||||||
<option value="Resources">Resources</option>
|
type="button"
|
||||||
<option value="Technical">Technical</option>
|
onClick={(e) => {
|
||||||
</select>
|
e.stopPropagation();
|
||||||
</div>
|
e.preventDefault();
|
||||||
<div>
|
setUploadFile(null);
|
||||||
<label className="text-xs font-semibold text-ink-500 mb-1.5 block">Subcategory</label>
|
}}
|
||||||
<input
|
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"
|
||||||
type="text"
|
title="Remove File"
|
||||||
value={uploadSubcategory}
|
>
|
||||||
onChange={(e) => setUploadSubcategory(e.target.value)}
|
<X className="w-3 h-3" />
|
||||||
placeholder="e.g. Slide Deck"
|
</button>
|
||||||
className="w-full bg-ink-50 border border-ink-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ink-900/10 text-ink-900 placeholder-ink-400"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<p className="text-[10px] text-ink-500">
|
||||||
<div>
|
{formatBytes(uploadFile?.size || 0)}
|
||||||
<label className="text-xs font-semibold text-ink-500 mb-1.5 block">Description</label>
|
</p>
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
|
) : uploadFile ? (
|
||||||
<div className="pt-3.5 border-t border-ink-100 flex justify-end gap-3 flex-shrink-0 mt-4">
|
<div className="relative z-0 py-1">
|
||||||
<button
|
<div className="w-12 h-12 rounded-lg bg-ink-100 border border-ink-200 flex items-center justify-center mx-auto mb-2">
|
||||||
type="button"
|
{uploadFile?.name?.endsWith('.pdf') ? (
|
||||||
onClick={onClose}
|
<FileText className="w-6 h-6 text-ink-600" />
|
||||||
className="px-4 py-2 rounded-lg border border-ink-200 text-ink-700 text-xs font-bold hover:bg-ink-50 transition-colors"
|
) : (
|
||||||
>
|
<File className="w-6 h-6 text-ink-600" />
|
||||||
Cancel
|
)}
|
||||||
</button>
|
</div>
|
||||||
<button
|
<p className="text-xs font-bold text-ink-900 truncate max-w-xs mx-auto">
|
||||||
type="submit"
|
{uploadFile?.name}
|
||||||
disabled={isUploading}
|
</p>
|
||||||
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"
|
<p className="text-[10px] text-ink-500 mt-0.5">
|
||||||
>
|
{formatBytes(uploadFile?.size || 0)}
|
||||||
{isUploading ? 'Publishing...' : 'Publish Asset'}
|
</p>
|
||||||
</button>
|
|
||||||
</div>
|
</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>
|
</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 */}
|
{/* Full Local Image Preview Modal */}
|
||||||
<AnimatePresence>
|
<Modal
|
||||||
{fullImagePreviewUrl && (
|
isOpen={!!fullImagePreviewUrl}
|
||||||
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4">
|
onClose={() => setFullImagePreviewUrl(null)}
|
||||||
<motion.div
|
title="Asset Image Preview"
|
||||||
initial={{ opacity: 0 }}
|
size="lg"
|
||||||
animate={{ opacity: 1 }}
|
>
|
||||||
exit={{ opacity: 0 }}
|
<div className="flex flex-col items-center justify-center p-1">
|
||||||
onClick={() => setFullImagePreviewUrl(null)}
|
<img
|
||||||
className="absolute inset-0 bg-ink-950/85 backdrop-blur-md"
|
src={fullImagePreviewUrl || ''}
|
||||||
/>
|
alt="Full preview"
|
||||||
<motion.div
|
className="max-w-full max-h-[60vh] object-contain rounded-xl shadow-md border border-ink-200 bg-ink-50"
|
||||||
initial={{ opacity: 0, scale: 0.95 }}
|
/>
|
||||||
animate={{ opacity: 1, scale: 1 }}
|
</div>
|
||||||
exit={{ opacity: 0, scale: 0.95 }}
|
</Modal>
|
||||||
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>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -1,29 +1,39 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from "react";
|
||||||
import type { BlogPost } from '../../../types';
|
import type { BlogPost } from "../../../types";
|
||||||
import { apiClient } from '../../../lib/api-client';
|
import { apiClient } from "../../../lib/api-client";
|
||||||
import { useAuth } from '../../auth/store/AuthContext';
|
import { useAuth } from "../../auth/store/AuthContext";
|
||||||
import { BookOpen, User, Calendar, Clock, Plus, X, Sparkles, Send } from 'lucide-react';
|
import {
|
||||||
|
BookOpen,
|
||||||
|
User,
|
||||||
|
Calendar,
|
||||||
|
Clock,
|
||||||
|
Plus,
|
||||||
|
Sparkles,
|
||||||
|
Send,
|
||||||
|
} from "lucide-react";
|
||||||
|
import Modal from "../../../components/ui/Modal";
|
||||||
|
import { PageLayout } from "../../../components/layout/PageLayout";
|
||||||
|
|
||||||
export const BlogCatalog: React.FC = () => {
|
export const BlogCatalog: React.FC = () => {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const [posts, setPosts] = useState<BlogPost[]>([]);
|
const [posts, setPosts] = useState<BlogPost[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const isAdmin = user?.role === 'ADMIN';
|
const isAdmin = user?.role === "ADMIN";
|
||||||
|
|
||||||
// CMS modal state
|
// CMS modal state
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
const [title, setTitle] = useState('');
|
const [title, setTitle] = useState("");
|
||||||
const [content, setContent] = useState('');
|
const [content, setContent] = useState("");
|
||||||
const [tagsInput, setTagsInput] = useState('');
|
const [tagsInput, setTagsInput] = useState("");
|
||||||
const [thumbnailUrl, setThumbnailUrl] = useState('');
|
const [thumbnailUrl, setThumbnailUrl] = useState("");
|
||||||
const [status, setStatus] = useState<'draft' | 'published'>('draft');
|
const [status, setStatus] = useState<"draft" | "published">("draft");
|
||||||
const [formError, setFormError] = useState('');
|
const [formError, setFormError] = useState("");
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
const fetchPosts = async () => {
|
const fetchPosts = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const response = await apiClient.get<BlogPost[]>('/blog');
|
const response = await apiClient.get<BlogPost[]>("/blog");
|
||||||
setPosts(response.data);
|
setPosts(response.data);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
@ -38,249 +48,281 @@ export const BlogCatalog: React.FC = () => {
|
|||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setFormError('');
|
setFormError("");
|
||||||
|
|
||||||
if (!title.trim() || !content.trim()) {
|
if (!title.trim() || !content.trim()) {
|
||||||
setFormError('Title and content are required.');
|
setFormError("Title and content are required.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
try {
|
try {
|
||||||
const tags = tagsInput.split(',').map(t => t.trim()).filter(t => t.length > 0);
|
const tags = tagsInput
|
||||||
|
.split(",")
|
||||||
|
.map((t) => t.trim())
|
||||||
|
.filter((t) => t.length > 0);
|
||||||
const payload = {
|
const payload = {
|
||||||
title,
|
title,
|
||||||
content,
|
content,
|
||||||
tags,
|
tags,
|
||||||
thumbnailUrl: thumbnailUrl.trim() || undefined,
|
thumbnailUrl: thumbnailUrl.trim() || undefined,
|
||||||
status,
|
status,
|
||||||
author: 'Technical Architect',
|
author: "Technical Architect",
|
||||||
};
|
};
|
||||||
|
|
||||||
const response = await apiClient.post<BlogPost>('/blog/new', payload);
|
const response = await apiClient.post<BlogPost>("/blog/new", payload);
|
||||||
setPosts(prev => [response.data, ...prev]);
|
setPosts((prev) => [response.data, ...prev]);
|
||||||
|
|
||||||
setTitle('');
|
setTitle("");
|
||||||
setContent('');
|
setContent("");
|
||||||
setTagsInput('');
|
setTagsInput("");
|
||||||
setThumbnailUrl('');
|
setThumbnailUrl("");
|
||||||
setStatus('draft');
|
setStatus("draft");
|
||||||
setIsOpen(false);
|
setIsOpen(false);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setFormError('Failed to publish article.');
|
setFormError("Failed to publish article.");
|
||||||
} finally {
|
} finally {
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 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 (
|
return (
|
||||||
<div className="space-y-6 text-ink-900">
|
<PageLayout header={headerNode} toolbar={toolbarNode}>
|
||||||
<div className="flex items-center justify-between">
|
<div className="p-5 flex-1 min-h-0 overflow-y-auto">
|
||||||
<div>
|
{loading ? (
|
||||||
<h2 className="text-xl font-bold text-ink-800">Engineering Blog & Insights</h2>
|
<div className="grid gap-6 md:grid-cols-2">
|
||||||
<p className="text-sm text-ink-600">Deep-dives into RISC-V pipelining, CodeNuk scaffolding practices, and edge security optimizations.</p>
|
{[1, 2].map((n) => (
|
||||||
</div>
|
<div
|
||||||
{isAdmin && (
|
key={n}
|
||||||
<button
|
className="animate-pulse rounded-xl border border-ink-200 bg-ink-0 p-5 space-y-4"
|
||||||
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"
|
<div className="h-48 rounded-lg bg-ink-100" />
|
||||||
>
|
<div className="h-4 w-3/4 rounded bg-ink-100" />
|
||||||
<Plus className="h-4 w-4" />
|
<div className="h-20 rounded bg-ink-100" />
|
||||||
Write Post
|
</div>
|
||||||
</button>
|
))}
|
||||||
|
</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>
|
</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 */}
|
{/* Post Creator Modal */}
|
||||||
{isOpen && (
|
<Modal
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-ink-950/20 backdrop-blur-sm p-4 animate-fade-in">
|
isOpen={isOpen}
|
||||||
<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">
|
onClose={() => setIsOpen(false)}
|
||||||
<button
|
title={
|
||||||
onClick={() => setIsOpen(false)}
|
<span className="flex items-center gap-2">
|
||||||
className="absolute right-4 top-4 rounded-full p-1.5 text-ink-600 hover:bg-ink-100 hover:text-ink-800 transition-colors"
|
<Sparkles className="h-5 w-5 text-ink-900" />
|
||||||
>
|
Write Blog Article
|
||||||
<X className="h-5 w-5" />
|
</span>
|
||||||
</button>
|
}
|
||||||
|
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">
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
<h3 className="text-lg font-bold text-ink-800 flex items-center gap-2">
|
<div>
|
||||||
<Sparkles className="h-5 w-5 text-ink-900" />
|
<label className="block text-xs font-semibold text-ink-700 mb-1">
|
||||||
Write Blog Article
|
Article Title
|
||||||
</h3>
|
</label>
|
||||||
<p className="text-xs text-ink-600">Draft or publish a technical write-up for the developer channel.</p>
|
<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>
|
||||||
|
|
||||||
{formError && (
|
<div>
|
||||||
<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">
|
<label className="block text-xs font-semibold text-ink-700 mb-1">
|
||||||
{formError}
|
Article Cover Photo URL
|
||||||
</div>
|
</label>
|
||||||
)}
|
<input
|
||||||
|
type="url"
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
value={thumbnailUrl}
|
||||||
<div>
|
onChange={(e) => setThumbnailUrl(e.target.value)}
|
||||||
<label className="block text-xs font-semibold text-ink-700 mb-1">Article Title</label>
|
placeholder="https://images.unsplash.com/..."
|
||||||
<input
|
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"
|
||||||
type="text"
|
/>
|
||||||
value={title}
|
</div>
|
||||||
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>
|
</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;
|
export default BlogCatalog;
|
||||||
|
|||||||
@ -1,10 +1,11 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import type { UseQueryResult } from "@tanstack/react-query";
|
import type { UseQueryResult } from "@tanstack/react-query";
|
||||||
import { getPendingPartners } from "../services/legal-api";
|
import { getPendingPartners, getMyAcceptances } from "../services/legal-api";
|
||||||
import type { PendingPartner } from "../services/legal-api";
|
import type { PendingPartner, LegalAcceptance } from "../services/legal-api";
|
||||||
|
|
||||||
export const LEGAL_QUERY_KEYS = {
|
export const LEGAL_QUERY_KEYS = {
|
||||||
pending: () => ["legal", "pending"] as const,
|
pending: () => ["legal", "pending"] as const,
|
||||||
|
myAcceptances: () => ["legal", "my-acceptances"] as const,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const usePendingPartnersQuery = (
|
export const usePendingPartnersQuery = (
|
||||||
@ -17,3 +18,14 @@ export const usePendingPartnersQuery = (
|
|||||||
staleTime: 5 * 60 * 1000, // 5 minutes stale time
|
staleTime: 5 * 60 * 1000, // 5 minutes stale time
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const useMyAcceptancesQuery = (
|
||||||
|
enabled: boolean = true
|
||||||
|
): UseQueryResult<LegalAcceptance[]> => {
|
||||||
|
return useQuery<LegalAcceptance[]>({
|
||||||
|
queryKey: LEGAL_QUERY_KEYS.myAcceptances(),
|
||||||
|
queryFn: getMyAcceptances,
|
||||||
|
enabled,
|
||||||
|
staleTime: 5 * 60 * 1000,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|||||||
1
Channel-Frontend/src/hooks/use-toast.ts
Normal file
1
Channel-Frontend/src/hooks/use-toast.ts
Normal file
@ -0,0 +1 @@
|
|||||||
|
export { useToast } from '../components/ui/Toast';
|
||||||
@ -1,9 +1,9 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from "react";
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from "framer-motion";
|
||||||
import type { Variants } from 'framer-motion';
|
import type { Variants } from "framer-motion";
|
||||||
import { UploadCloud, Search, File, CheckCircle } from 'lucide-react';
|
import { UploadCloud, Search, File, CheckCircle } from "lucide-react";
|
||||||
import { useAuthStore } from '../hooks/use-auth';
|
import { useAuthStore } from "../hooks/use-auth";
|
||||||
import { axiosInstance } from '../services/axios';
|
import { axiosInstance } from "../services/axios";
|
||||||
import {
|
import {
|
||||||
getAssets,
|
getAssets,
|
||||||
getOrganizations,
|
getOrganizations,
|
||||||
@ -11,41 +11,50 @@ import {
|
|||||||
requestDownload,
|
requestDownload,
|
||||||
approveDownloadRequest,
|
approveDownloadRequest,
|
||||||
rejectDownloadRequest,
|
rejectDownloadRequest,
|
||||||
downloadAssetFile
|
downloadAssetFile,
|
||||||
} from '../services/assets-api';
|
} from "../services/assets-api";
|
||||||
|
import { useToast } from "../hooks/use-toast";
|
||||||
|
|
||||||
// Subcomponents
|
// Subcomponents
|
||||||
import { AssetCard } from '../features/assets/components/AssetCard';
|
import { AssetCard } from "../features/assets/components/AssetCard";
|
||||||
import { UploadAssetModal } from '../features/assets/components/UploadAssetModal';
|
import { UploadAssetModal } from "../features/assets/components/UploadAssetModal";
|
||||||
import { EditAssetModal } from '../features/assets/components/EditAssetModal';
|
import { EditAssetModal } from "../features/assets/components/EditAssetModal";
|
||||||
import { ShareAssetModal } from '../features/assets/components/ShareAssetModal';
|
import { ShareAssetModal } from "../features/assets/components/ShareAssetModal";
|
||||||
import { AssetDetailsModal } from '../features/assets/components/AssetDetailsModal';
|
import { AssetDetailsModal } from "../features/assets/components/AssetDetailsModal";
|
||||||
import { AssetViewerModal } from '../features/assets/components/AssetViewerModal';
|
import { AssetViewerModal } from "../features/assets/components/AssetViewerModal";
|
||||||
import { DownloadRequestsModal } from '../features/assets/components/DownloadRequestsModal';
|
import { DownloadRequestsModal } from "../features/assets/components/DownloadRequestsModal";
|
||||||
import { PageHeader } from '../components/ui/PageHeader';
|
import { PageHeader } from "../components/ui/PageHeader";
|
||||||
|
import Button from "../components/ui/Button";
|
||||||
|
import { PageLayout } from "../components/layout/PageLayout";
|
||||||
|
|
||||||
// Type Definitions
|
// Type Definitions
|
||||||
import type { Asset, Organization } from '../types/assets';
|
import type { Asset, Organization } from "../types/assets";
|
||||||
|
|
||||||
const containerVariants: Variants = {
|
const containerVariants: Variants = {
|
||||||
hidden: { opacity: 0 },
|
hidden: { opacity: 0 },
|
||||||
show: { opacity: 1, transition: { staggerChildren: 0.05 } }
|
show: { opacity: 1, transition: { staggerChildren: 0.05 } },
|
||||||
};
|
};
|
||||||
|
|
||||||
const itemVariants: Variants = {
|
const itemVariants: Variants = {
|
||||||
hidden: { opacity: 0, y: 15, scale: 0.98 },
|
hidden: { opacity: 0, y: 15, scale: 0.98 },
|
||||||
show: { opacity: 1, y: 0, scale: 1, transition: { type: 'spring', stiffness: 350, damping: 25 } }
|
show: {
|
||||||
|
opacity: 1,
|
||||||
|
y: 0,
|
||||||
|
scale: 1,
|
||||||
|
transition: { type: "spring", stiffness: 350, damping: 25 },
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export const AssetsPage = () => {
|
export const AssetsPage = () => {
|
||||||
|
const { success, error } = useToast();
|
||||||
const user = useAuthStore((state) => state.user);
|
const user = useAuthStore((state) => state.user);
|
||||||
const [assets, setAssets] = useState<Asset[]>([]);
|
const [assets, setAssets] = useState<Asset[]>([]);
|
||||||
const [organizations, setOrganizations] = useState<Organization[]>([]);
|
const [organizations, setOrganizations] = useState<Organization[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
// Search & Filter
|
// Search & Filter
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
const [selectedCategory, setSelectedCategory] = useState<string>('ALL');
|
const [selectedCategory, setSelectedCategory] = useState<string>("ALL");
|
||||||
|
|
||||||
// Modal Control States
|
// Modal Control States
|
||||||
const [isUploadOpen, setIsUploadOpen] = useState(false);
|
const [isUploadOpen, setIsUploadOpen] = useState(false);
|
||||||
@ -54,7 +63,7 @@ export const AssetsPage = () => {
|
|||||||
const [isDetailsOpen, setIsDetailsOpen] = useState(false);
|
const [isDetailsOpen, setIsDetailsOpen] = useState(false);
|
||||||
const [isViewerOpen, setIsViewerOpen] = useState(false);
|
const [isViewerOpen, setIsViewerOpen] = useState(false);
|
||||||
const [isRequestsOpen, setIsRequestsOpen] = useState(false);
|
const [isRequestsOpen, setIsRequestsOpen] = useState(false);
|
||||||
|
|
||||||
const [activeAsset, setActiveAsset] = useState<Asset | null>(null);
|
const [activeAsset, setActiveAsset] = useState<Asset | null>(null);
|
||||||
const [activeMenuId, setActiveMenuId] = useState<string | null>(null);
|
const [activeMenuId, setActiveMenuId] = useState<string | null>(null);
|
||||||
|
|
||||||
@ -67,13 +76,13 @@ export const AssetsPage = () => {
|
|||||||
try {
|
try {
|
||||||
const assetsData = await getAssets();
|
const assetsData = await getAssets();
|
||||||
setAssets(assetsData);
|
setAssets(assetsData);
|
||||||
|
|
||||||
if (user?.role === 'ADMIN') {
|
if (user?.role === "ADMIN") {
|
||||||
const orgsData = await getOrganizations();
|
const orgsData = await getOrganizations();
|
||||||
setOrganizations(orgsData);
|
setOrganizations(orgsData);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to fetch assets data', err);
|
console.error("Failed to fetch assets data", err);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@ -100,133 +109,140 @@ export const AssetsPage = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteAsset = async (id: string) => {
|
const handleDeleteAsset = async (id: string) => {
|
||||||
if (!window.confirm('Are you sure you want to permanently delete this asset?')) return;
|
const asset = assets.find(a => a.id === id);
|
||||||
|
const title = asset ? asset.title : "this asset";
|
||||||
|
if (
|
||||||
|
!window.confirm(`Are you sure you want to permanently delete "${title}"?`)
|
||||||
|
)
|
||||||
|
return;
|
||||||
try {
|
try {
|
||||||
await deleteAsset(id);
|
await deleteAsset(id);
|
||||||
|
success("Asset deleted successfully", `"${title}" has been permanently removed.`);
|
||||||
await fetchData();
|
await fetchData();
|
||||||
} catch (err) {
|
} catch (err: any) {
|
||||||
console.error('Failed to delete asset', err);
|
console.error("Failed to delete asset", err);
|
||||||
|
error("Failed to delete asset", err.response?.data?.error || "Something went wrong.");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRequestDownload = async (asset: Asset) => {
|
const handleRequestDownload = async (asset: Asset) => {
|
||||||
try {
|
try {
|
||||||
await requestDownload(asset.id);
|
await requestDownload(asset.id);
|
||||||
|
success("Download request submitted", "An administrator has been notified of your request.");
|
||||||
await fetchData();
|
await fetchData();
|
||||||
} catch (err) {
|
} catch (err: any) {
|
||||||
console.error('Failed to request download access', err);
|
console.error("Failed to request download access", err);
|
||||||
|
error("Failed to submit request", err.response?.data?.error || "Something went wrong.");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleApproveRequest = async (assetId: string, requestId: string) => {
|
const handleApproveRequest = async (assetId: string, requestId: string) => {
|
||||||
try {
|
try {
|
||||||
await approveDownloadRequest(assetId, requestId);
|
await approveDownloadRequest(assetId, requestId);
|
||||||
|
success("Download request approved", "The partner can now download this asset.");
|
||||||
await fetchData();
|
await fetchData();
|
||||||
} catch (err) {
|
} catch (err: any) {
|
||||||
console.error('Failed to approve request', err);
|
console.error("Failed to approve request", err);
|
||||||
|
error("Failed to approve request", err.response?.data?.error || "Something went wrong.");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRejectRequest = async (assetId: string, requestId: string) => {
|
const handleRejectRequest = async (assetId: string, requestId: string) => {
|
||||||
try {
|
try {
|
||||||
await rejectDownloadRequest(assetId, requestId);
|
await rejectDownloadRequest(assetId, requestId);
|
||||||
|
success("Download request rejected", "The access request was denied.");
|
||||||
await fetchData();
|
await fetchData();
|
||||||
} catch (err) {
|
} catch (err: any) {
|
||||||
console.error('Failed to reject request', err);
|
console.error("Failed to reject request", err);
|
||||||
|
error("Failed to reject request", err.response?.data?.error || "Something went wrong.");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDownload = async (asset: Asset) => {
|
const handleDownload = async (asset: Asset) => {
|
||||||
|
success("Download started", `Downloading "${asset.title}"...`);
|
||||||
try {
|
try {
|
||||||
await downloadAssetFile(asset.id);
|
await downloadAssetFile(asset.id);
|
||||||
|
|
||||||
const downloadUrl = asset.url.startsWith('http')
|
const downloadUrl = asset.url.startsWith("http")
|
||||||
? asset.url
|
? asset.url
|
||||||
: `${axiosInstance.defaults.baseURL?.replace('/api/v1', '')}${asset.url}`;
|
: `${axiosInstance.defaults.baseURL?.replace("/api/v1", "")}${asset.url}`;
|
||||||
|
|
||||||
const a = document.createElement('a');
|
const a = document.createElement("a");
|
||||||
a.href = downloadUrl;
|
a.href = downloadUrl;
|
||||||
a.download = asset.title;
|
a.download = asset.title;
|
||||||
a.target = '_blank';
|
a.target = "_blank";
|
||||||
document.body.appendChild(a);
|
document.body.appendChild(a);
|
||||||
a.click();
|
a.click();
|
||||||
document.body.removeChild(a);
|
document.body.removeChild(a);
|
||||||
|
|
||||||
setAssets(prev => prev.map(item =>
|
setAssets((prev) =>
|
||||||
item.id === asset.id ? { ...item, downloadsCount: item.downloadsCount + 1 } : item
|
prev.map((item) =>
|
||||||
));
|
item.id === asset.id
|
||||||
} catch (err) {
|
? { ...item, downloadsCount: item.downloadsCount + 1 }
|
||||||
console.error('Failed to process download', err);
|
: item,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error("Failed to process download", err);
|
||||||
|
error("Download failed", err.response?.data?.error || "Could not retrieve asset file.");
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const CATEGORIES = ['ALL', 'Marketing', 'Presentations', 'Branding', 'Resources', 'Technical'];
|
const CATEGORIES = [
|
||||||
|
"ALL",
|
||||||
|
"Marketing",
|
||||||
|
"Presentations",
|
||||||
|
"Branding",
|
||||||
|
"Resources",
|
||||||
|
"Technical",
|
||||||
|
];
|
||||||
|
|
||||||
const filteredAssets = assets.filter(asset => {
|
const filteredAssets = assets.filter((asset) => {
|
||||||
const query = searchQuery.toLowerCase().trim();
|
const query = searchQuery.toLowerCase().trim();
|
||||||
|
|
||||||
const matchesSearch = !query ||
|
const matchesSearch =
|
||||||
asset.title.toLowerCase().includes(query) ||
|
!query ||
|
||||||
|
asset.title.toLowerCase().includes(query) ||
|
||||||
(asset.description && asset.description.toLowerCase().includes(query)) ||
|
(asset.description && asset.description.toLowerCase().includes(query)) ||
|
||||||
(asset.categoryId && asset.categoryId.toLowerCase().includes(query)) ||
|
(asset.categoryId && asset.categoryId.toLowerCase().includes(query)) ||
|
||||||
(asset.subcategory && asset.subcategory.toLowerCase().includes(query)) ||
|
(asset.subcategory && asset.subcategory.toLowerCase().includes(query)) ||
|
||||||
(asset.githubUrl && asset.githubUrl.toLowerCase().includes(query)) ||
|
(asset.githubUrl && asset.githubUrl.toLowerCase().includes(query)) ||
|
||||||
asset.type.toLowerCase().includes(query) ||
|
asset.type.toLowerCase().includes(query) ||
|
||||||
asset.tags.some(tag => tag.toLowerCase().includes(query));
|
asset.tags.some((tag) => tag.toLowerCase().includes(query));
|
||||||
|
|
||||||
const matchesCategory =
|
const matchesCategory =
|
||||||
selectedCategory === 'ALL' ||
|
selectedCategory === "ALL" || asset.categoryId === selectedCategory;
|
||||||
asset.categoryId === selectedCategory;
|
|
||||||
|
|
||||||
return matchesSearch && matchesCategory;
|
return matchesSearch && matchesCategory;
|
||||||
});
|
});
|
||||||
|
|
||||||
const pendingRequestsCount = assets.reduce((acc, asset) => {
|
const pendingRequestsCount = assets.reduce((acc, asset) => {
|
||||||
return acc + (asset.downloadRequests?.filter(r => r.status === 'PENDING').length || 0);
|
return (
|
||||||
|
acc +
|
||||||
|
(asset.downloadRequests?.filter((r) => r.status === "PENDING").length ||
|
||||||
|
0)
|
||||||
|
);
|
||||||
}, 0);
|
}, 0);
|
||||||
|
|
||||||
return (
|
// Header component
|
||||||
<motion.div variants={containerVariants} initial="hidden" animate="show" className="w-full space-y-6 text-ink-900 animate-fade-in">
|
const headerNode = (
|
||||||
|
<PageHeader
|
||||||
<PageHeader
|
title="Asset Library"
|
||||||
title="Asset Library"
|
subtitle="Securely manage, distribute, and track marketing collateral and partner resources."
|
||||||
subtitle="Securely manage, distribute, and track marketing collateral and partner resources."
|
badge={
|
||||||
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">
|
||||||
<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" />
|
||||||
<CheckCircle className="w-3.5 h-3.5 text-ink-950" />
|
<span>Global CDN Active</span>
|
||||||
<span>Global CDN Active</span>
|
</div>
|
||||||
</div>
|
}
|
||||||
}
|
/>
|
||||||
actions={
|
);
|
||||||
<>
|
|
||||||
{user?.role === 'ADMIN' && pendingRequestsCount > 0 && (
|
|
||||||
<button
|
|
||||||
onClick={() => setIsRequestsOpen(true)}
|
|
||||||
className="bg-ink-100 text-ink-900 border border-ink-300 font-bold py-1.5 px-3 rounded-lg hover:bg-ink-200 transition-all flex items-center justify-center gap-2 relative shadow-sm text-xs cursor-pointer"
|
|
||||||
>
|
|
||||||
<span>Download Requests</span>
|
|
||||||
<span className="w-4 h-4 rounded-full bg-ink-900 text-ink-0 text-[10px] flex items-center justify-center font-extrabold">
|
|
||||||
{pendingRequestsCount}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{user?.role === 'ADMIN' && (
|
// Toolbar component
|
||||||
<button
|
const toolbarNode = (
|
||||||
onClick={() => setIsUploadOpen(true)}
|
<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">
|
||||||
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"
|
<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">
|
||||||
<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">
|
|
||||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||||
<Search className="w-4 h-4 text-ink-400 group-focus-within:text-ink-900 transition-colors" />
|
<Search className="w-4 h-4 text-ink-400 group-focus-within:text-ink-900 transition-colors" />
|
||||||
</div>
|
</div>
|
||||||
@ -234,59 +250,100 @@ export const AssetsPage = () => {
|
|||||||
type="text"
|
type="text"
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
className="w-full bg-ink-0 border border-ink-200 rounded-lg py-1.5 pl-9 pr-4 text-xs font-semibold text-ink-900 placeholder-ink-400 outline-none transition-all focus:border-ink-900/50 focus:ring-2 ring-ink-900/10 shadow-sm hover:border-ink-300"
|
className="w-full bg-ink-50 border border-ink-200 rounded-lg py-1.5 pl-9 pr-4 text-xs font-semibold text-ink-900 placeholder-ink-400 outline-none transition-all focus:border-ink-900/50 focus:ring-2 ring-ink-900/10 hover:border-ink-300 font-sans"
|
||||||
placeholder="Search by title, desc, tag, category, URL..."
|
placeholder="Search by title, desc, tag, category..."
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-2 w-full md:w-auto overflow-x-auto pb-1 md:pb-0 scrollbar-none">
|
<div className="flex gap-1.5 overflow-x-auto pb-1 sm:pb-0 scrollbar-none">
|
||||||
{CATEGORIES.map((cat) => (
|
{CATEGORIES.map((cat) => (
|
||||||
<button
|
<button
|
||||||
key={cat}
|
key={cat}
|
||||||
onClick={() => setSelectedCategory(cat)}
|
onClick={() => setSelectedCategory(cat)}
|
||||||
className={`px-3 py-1.5 rounded-lg border text-xs font-bold transition-all whitespace-nowrap shadow-sm cursor-pointer ${
|
className={`px-2.5 py-1.5 rounded-lg border text-[10px] uppercase tracking-wider font-bold transition-all whitespace-nowrap shadow-sm cursor-pointer ${
|
||||||
selectedCategory === cat
|
selectedCategory === cat
|
||||||
? 'bg-ink-900 text-ink-0 border-ink-900'
|
? "bg-ink-900 text-ink-0 border-ink-900"
|
||||||
: 'bg-ink-0 text-ink-700 border-ink-200 hover:bg-ink-50'
|
: "bg-ink-50 text-ink-700 border-ink-200 hover:bg-ink-100"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{cat}
|
{cat}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</motion.div>
|
</div>
|
||||||
|
|
||||||
{/* Grid Content */}
|
<div className="flex items-center gap-2 shrink-0 w-full md:w-auto justify-end">
|
||||||
{loading ? (
|
{user?.role === "ADMIN" && pendingRequestsCount > 0 && (
|
||||||
<div className="py-20 flex justify-center items-center">
|
<Button
|
||||||
<div className="w-8 h-8 border-4 border-ink-900/30 border-t-ink-900 rounded-full animate-spin" />
|
onClick={() => setIsRequestsOpen(true)}
|
||||||
</div>
|
variant="secondary"
|
||||||
) : filteredAssets.length === 0 ? (
|
size="sm"
|
||||||
<div className="py-12 text-center bg-ink-0 border border-ink-200 rounded-xl">
|
icon={
|
||||||
<File className="w-12 h-12 text-ink-300 mx-auto mb-4" />
|
<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">
|
||||||
<h3 className="text-lg font-bold text-ink-900">No assets found</h3>
|
{pendingRequestsCount}
|
||||||
<p className="text-ink-500 text-sm mt-1">There are no assets matching your criteria.</p>
|
</span>
|
||||||
</div>
|
}
|
||||||
) : (
|
>
|
||||||
<motion.div variants={itemVariants} className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-4 pt-2">
|
Download Requests
|
||||||
{filteredAssets.map((asset) => (
|
</Button>
|
||||||
<AssetCard
|
)}
|
||||||
key={asset.id}
|
|
||||||
asset={asset}
|
{user?.role === "ADMIN" && (
|
||||||
user={user}
|
<Button
|
||||||
activeMenuId={activeMenuId}
|
onClick={() => setIsUploadOpen(true)}
|
||||||
setActiveMenuId={setActiveMenuId}
|
variant="primary"
|
||||||
onViewDetails={openDetailsModal}
|
size="sm"
|
||||||
onEdit={openEditModal}
|
icon={<UploadCloud className="w-3.5 h-3.5" />}
|
||||||
onShare={openShareModal}
|
>
|
||||||
onDelete={handleDeleteAsset}
|
Create / Upload Asset
|
||||||
onOpenViewer={openViewerModal}
|
</Button>
|
||||||
onDownload={handleDownload}
|
)}
|
||||||
onRequestDownload={handleRequestDownload}
|
</div>
|
||||||
/>
|
</div>
|
||||||
))}
|
);
|
||||||
</motion.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 */}
|
{/* Modals Container */}
|
||||||
<UploadAssetModal
|
<UploadAssetModal
|
||||||
@ -344,8 +401,7 @@ export const AssetsPage = () => {
|
|||||||
onApprove={handleApproveRequest}
|
onApprove={handleApproveRequest}
|
||||||
onReject={handleRejectRequest}
|
onReject={handleRejectRequest}
|
||||||
/>
|
/>
|
||||||
|
</PageLayout>
|
||||||
</motion.div>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
308
Channel-Frontend/src/pages/ClientAgreementsPage.tsx
Normal file
308
Channel-Frontend/src/pages/ClientAgreementsPage.tsx
Normal 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;
|
||||||
@ -4,6 +4,7 @@ import type { Variants } from 'framer-motion';
|
|||||||
import { FolderKanban, FileSignature, Users, ArrowUpRight, Activity, Zap, ShieldCheck } from 'lucide-react';
|
import { FolderKanban, FileSignature, Users, ArrowUpRight, Activity, Zap, ShieldCheck } from 'lucide-react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { PageHeader } from '../components/ui/PageHeader';
|
import { PageHeader } from '../components/ui/PageHeader';
|
||||||
|
import { PageLayout } from '../components/layout/PageLayout';
|
||||||
|
|
||||||
const containerVariants: Variants = {
|
const containerVariants: Variants = {
|
||||||
hidden: { opacity: 0 },
|
hidden: { opacity: 0 },
|
||||||
@ -57,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 (
|
return (
|
||||||
<motion.div variants={containerVariants} initial="hidden" animate="show" className="w-full space-y-6 text-ink-900 animate-fade-in">
|
<PageLayout header={headerNode}>
|
||||||
<PageHeader
|
<motion.div
|
||||||
title={`Welcome back, ${user?.email?.split('@')[0]}`}
|
variants={containerVariants}
|
||||||
subtitle={`You are authenticated as ${user?.role}. Manage your channel network, monitor compliance, and distribute assets globally.`}
|
initial="hidden"
|
||||||
badge={
|
animate="show"
|
||||||
<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">
|
className="p-5 space-y-6 flex flex-col min-h-0 flex-1 overflow-y-auto"
|
||||||
<div className="w-1 h-1 rounded-full bg-ink-0 animate-pulse" />
|
>
|
||||||
<span>System Active</span>
|
{/* Stats Grid */}
|
||||||
</div>
|
<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%' },
|
||||||
{/* Stats Grid */}
|
{ label: 'Security Compliance', value: 'Level 4', icon: ShieldCheck, trend: 'Verified' }
|
||||||
<motion.div variants={itemVariants} className="grid grid-cols-1 md:grid-cols-3 gap-4 pt-2">
|
].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">
|
||||||
{ label: 'Global Network Uptime', value: '99.99%', icon: Activity, trend: '+0.01%' },
|
<div className="absolute top-0 right-0 p-4 opacity-5 group-hover:opacity-10 transition-opacity duration-500 group-hover:scale-110 transform">
|
||||||
{ label: 'Active Data Streams', value: '1,492', icon: Zap, trend: '+12%' },
|
<stat.icon className="w-16 h-16 text-ink-900" />
|
||||||
{ 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>
|
|
||||||
</div>
|
</div>
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-wider text-ink-500 mb-1">{stat.label}</p>
|
||||||
<div className="relative z-10 mt-auto">
|
<div className="flex items-end gap-3 mt-2">
|
||||||
<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">
|
<h3 className="text-xl font-bold text-ink-900 tracking-tight">{stat.value}</h3>
|
||||||
{card.metrics}
|
<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>
|
|
||||||
<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>
|
||||||
</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>
|
||||||
</motion.div>
|
</PageLayout>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
export default DashboardPage;
|
export default DashboardPage;
|
||||||
|
|||||||
@ -1,12 +1,14 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useState } from 'react';
|
||||||
import { useSearchParams, useNavigate } from 'react-router-dom';
|
import { useSearchParams, useNavigate } from 'react-router-dom';
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
import { Shield, CheckCircle, AlertCircle, ChevronRight, KeyRound } from 'lucide-react';
|
import { Shield, CheckCircle, AlertCircle, ChevronRight, KeyRound, Eye, EyeOff } from 'lucide-react';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import { axiosInstance } from '../services/axios';
|
import { axiosInstance } from '../services/axios';
|
||||||
import { useAuthStore } from '../hooks/use-auth';
|
import { useAuthStore } from '../hooks/use-auth';
|
||||||
|
import { useToast } from '../hooks/use-toast';
|
||||||
|
|
||||||
export const InvitePage: React.FC = () => {
|
export const InvitePage: React.FC = () => {
|
||||||
|
const { success, error: toastError } = useToast();
|
||||||
const [searchParams] = useSearchParams();
|
const [searchParams] = useSearchParams();
|
||||||
const token = searchParams.get('token');
|
const token = searchParams.get('token');
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@ -16,6 +18,8 @@ export const InvitePage: React.FC = () => {
|
|||||||
const [email, setEmail] = useState<string>('');
|
const [email, setEmail] = useState<string>('');
|
||||||
const [password, setPassword] = useState('');
|
const [password, setPassword] = useState('');
|
||||||
const [confirmPassword, setConfirmPassword] = useState('');
|
const [confirmPassword, setConfirmPassword] = useState('');
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
const [showConfirmPassword, setShowConfirmPassword] = useState(false);
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
@ -60,9 +64,12 @@ export const InvitePage: React.FC = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
setAuth(response.data);
|
setAuth(response.data);
|
||||||
|
success("Account created successfully", "Please complete your partner onboarding profile.");
|
||||||
navigate('/onboarding');
|
navigate('/onboarding');
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.response?.data?.error || 'Failed to accept invite');
|
const errMsg = err.response?.data?.error || 'Failed to accept invite';
|
||||||
|
setError(errMsg);
|
||||||
|
toastError("Failed to accept invite", errMsg);
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@ -130,13 +137,20 @@ export const InvitePage: React.FC = () => {
|
|||||||
<KeyRound className="w-4 h-4 text-ink-400" />
|
<KeyRound className="w-4 h-4 text-ink-400" />
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
type="password"
|
type={showPassword ? 'text' : 'password'}
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
className="w-full pl-11 pr-4 py-3.5 bg-ink-50 border border-ink-200 rounded-xl text-sm font-medium focus:ring-4 focus:ring-ink-900/10 outline-none transition-all placeholder-ink-400 text-ink-900"
|
className="w-full pl-11 pr-10 py-3.5 bg-ink-50 border border-ink-200 rounded-xl text-sm font-medium focus:ring-4 focus:ring-ink-900/10 outline-none transition-all placeholder-ink-400 text-ink-900"
|
||||||
placeholder="Enter a secure password"
|
placeholder="Enter a secure password"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowPassword(!showPassword)}
|
||||||
|
className="absolute inset-y-0 right-0 pr-3.5 flex items-center text-ink-400 hover:text-ink-900 cursor-pointer"
|
||||||
|
>
|
||||||
|
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -147,13 +161,20 @@ export const InvitePage: React.FC = () => {
|
|||||||
<CheckCircle className="w-4 h-4 text-ink-400" />
|
<CheckCircle className="w-4 h-4 text-ink-400" />
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
type="password"
|
type={showConfirmPassword ? 'text' : 'password'}
|
||||||
value={confirmPassword}
|
value={confirmPassword}
|
||||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||||
className="w-full pl-11 pr-4 py-3.5 bg-ink-50 border border-ink-200 rounded-xl text-sm font-medium focus:ring-4 focus:ring-ink-900/10 outline-none transition-all placeholder-ink-400 text-ink-900"
|
className="w-full pl-11 pr-10 py-3.5 bg-ink-50 border border-ink-200 rounded-xl text-sm font-medium focus:ring-4 focus:ring-ink-900/10 outline-none transition-all placeholder-ink-400 text-ink-900"
|
||||||
placeholder="Confirm your password"
|
placeholder="Confirm your password"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
|
||||||
|
className="absolute inset-y-0 right-0 pr-3.5 flex items-center text-ink-400 hover:text-ink-900 cursor-pointer"
|
||||||
|
>
|
||||||
|
{showConfirmPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -1,21 +1,25 @@
|
|||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from "react-hook-form";
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { z } from 'zod';
|
import { z } from "zod";
|
||||||
import { loginUser } from '../services/auth-api';
|
import { loginUser } from "../services/auth-api";
|
||||||
import { useAuthStore } from '../hooks/use-auth';
|
import { useAuthStore } from "../hooks/use-auth";
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from "react-router-dom";
|
||||||
import { useState } from 'react';
|
import { useState } from "react";
|
||||||
import { Hexagon, Lock, Mail, ArrowRight, Eye, EyeOff } from 'lucide-react';
|
import { Hexagon, Lock, Mail, ArrowRight, Eye, EyeOff } from "lucide-react";
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from "framer-motion";
|
||||||
|
import { useToast } from "../hooks/use-toast";
|
||||||
|
|
||||||
const loginSchema = z.object({
|
const loginSchema = z.object({
|
||||||
email: z.string().email({ message: 'Invalid email address' }),
|
email: z.string().email({ message: "Invalid email address" }),
|
||||||
password: z.string().min(6, { message: 'Password must be at least 6 characters' }),
|
password: z
|
||||||
|
.string()
|
||||||
|
.min(6, { message: "Password must be at least 6 characters" }),
|
||||||
});
|
});
|
||||||
|
|
||||||
type LoginFormValues = z.infer<typeof loginSchema>;
|
type LoginFormValues = z.infer<typeof loginSchema>;
|
||||||
|
|
||||||
export const LoginPage = () => {
|
export const LoginPage = () => {
|
||||||
|
const { success, error: toastError } = useToast();
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [showPassword, setShowPassword] = useState(false);
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
const setAuth = useAuthStore((state) => state.setAuth);
|
const setAuth = useAuthStore((state) => state.setAuth);
|
||||||
@ -32,106 +36,166 @@ export const LoginPage = () => {
|
|||||||
const onSubmit = async (data: LoginFormValues) => {
|
const onSubmit = async (data: LoginFormValues) => {
|
||||||
try {
|
try {
|
||||||
setError(null);
|
setError(null);
|
||||||
const response = await loginUser({ email: data.email, password: data.password });
|
const response = await loginUser({
|
||||||
|
email: data.email,
|
||||||
|
password: data.password,
|
||||||
|
});
|
||||||
setAuth(response);
|
setAuth(response);
|
||||||
if (response.user.role === 'ADMIN') {
|
success("Successfully signed in", `Welcome back, ${response.user.email}!`);
|
||||||
navigate('/admin');
|
if (response.user.role === "ADMIN") {
|
||||||
|
navigate("/admin");
|
||||||
} else {
|
} else {
|
||||||
navigate('/client');
|
navigate("/client");
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.response?.data?.error || 'Authentication failed. Verify credentials.');
|
const errMsg = err.response?.data?.error || "Authentication failed. Verify credentials.";
|
||||||
|
setError(errMsg);
|
||||||
|
toastError("Authentication failed", errMsg);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-ink-50 flex flex-col justify-center items-center p-4 relative overflow-hidden font-sans transition-colors duration-500">
|
<div className="min-h-screen bg-ink-50 flex flex-col justify-between items-center p-6 md:p-12 relative overflow-hidden font-sans transition-colors duration-500">
|
||||||
{/* Monochromatic Soft Background Blurs */}
|
{/* Monochromatic Soft Background Blurs */}
|
||||||
<div className="absolute top-1/4 left-1/4 w-[500px] h-[500px] bg-ink-900/5 rounded-full blur-[150px] pointer-events-none" />
|
<div className="absolute top-1/4 left-1/4 w-[600px] h-[600px] bg-ink-900/5 rounded-full blur-[150px] pointer-events-none" />
|
||||||
<div className="absolute bottom-1/4 right-1/4 w-[600px] h-[600px] bg-ink-900/5 rounded-full blur-[150px] pointer-events-none" />
|
<div className="absolute bottom-1/4 right-1/4 w-[700px] h-[700px] bg-ink-900/5 rounded-full blur-[150px] pointer-events-none" />
|
||||||
|
|
||||||
<motion.div
|
{/* Spacer to push content down slightly on desktop for better centering */}
|
||||||
initial={{ opacity: 0, y: 30 }}
|
<div className="hidden md:block h-6" />
|
||||||
|
|
||||||
|
<motion.div
|
||||||
|
initial={{ opacity: 0, y: 30 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
transition={{ duration: 1, ease: [0.16, 1, 0.3, 1] }}
|
transition={{ duration: 1, ease: [0.16, 1, 0.3, 1] }}
|
||||||
className="w-full max-w-md relative z-10"
|
className="w-full max-w-5xl relative z-10 grid grid-cols-1 md:grid-cols-12 gap-12 md:gap-16 items-center my-auto"
|
||||||
>
|
>
|
||||||
<div className="bg-ink-0 border border-ink-200 rounded-[2rem] shadow-2xl p-12 relative overflow-hidden">
|
{/* Left Column - Partner Explanation */}
|
||||||
<div className="absolute top-0 inset-x-0 h-px bg-gradient-to-r from-transparent via-ink-300 to-transparent" />
|
<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">
|
||||||
<div className="flex flex-col items-center mb-12 text-center">
|
<span className="gradient-text">Tech4Biz Channel Partner</span>
|
||||||
<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">
|
</h1>
|
||||||
<Hexagon className="text-ink-0 w-10 h-10 absolute" />
|
<p className="text-ink-500 text-sm mt-5 font-medium leading-relaxed max-w-lg">
|
||||||
</div>
|
Expand your business by partnering with Tech4Biz and unlock new
|
||||||
<h2 className="text-3xl font-extrabold text-ink-900 tracking-tight">Channel Portal</h2>
|
opportunities for growth through our innovative technology
|
||||||
<p className="text-ink-500 text-sm mt-3 font-medium uppercase tracking-widest">Authorized Access Only</p>
|
solutions. Join our partner network to access exclusive resources,
|
||||||
</div>
|
dedicated support, and a platform designed to help you succeed.
|
||||||
|
</p>
|
||||||
{error && (
|
</div>
|
||||||
<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)]" />
|
{/* Right Column - Login Component */}
|
||||||
{error}
|
<div className="md:col-span-6 lg:col-span-5 w-full max-w-sm justify-self-center md:justify-self-end">
|
||||||
</motion.div>
|
<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" />
|
||||||
|
|
||||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
|
<div className="flex flex-col items-center mb-8 text-center">
|
||||||
<div className="space-y-2">
|
<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">
|
||||||
<label className="block text-[11px] font-bold text-ink-500 uppercase tracking-widest">Work Email</label>
|
<Hexagon className="text-ink-0 w-8 h-8 absolute" />
|
||||||
<div className="relative group">
|
</div>
|
||||||
<div className="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none">
|
<h2 className="text-2xl font-bold text-ink-900 tracking-tight">
|
||||||
<Mail className={`w-5 h-5 transition-colors ${errors.email ? 'text-red-400' : 'text-ink-400 group-focus-within:text-ink-900'}`} />
|
Channel Portal
|
||||||
</div>
|
</h2>
|
||||||
<input
|
<p className="text-ink-500 text-[10px] mt-1.5 font-bold uppercase tracking-wider">
|
||||||
type="email"
|
Authorized Access Only
|
||||||
{...register('email')}
|
</p>
|
||||||
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`}
|
</div>
|
||||||
placeholder="admin@tech4biz.com"
|
|
||||||
/>
|
{error && (
|
||||||
</div>
|
<motion.div
|
||||||
{errors.email && <p className="text-red-500 dark:text-red-400 text-xs mt-1.5 font-bold">{errors.email.message}</p>}
|
initial={{ opacity: 0, scale: 0.95 }}
|
||||||
</div>
|
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="space-y-2">
|
>
|
||||||
<div className="flex justify-between items-center">
|
<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" />
|
||||||
<label className="block text-[11px] font-bold text-ink-500 uppercase tracking-widest">Password</label>
|
{error}
|
||||||
<a href="#" className="text-[11px] font-bold text-ink-500 hover:text-ink-900 transition-colors tracking-wider">RECOVERY?</a>
|
</motion.div>
|
||||||
</div>
|
)}
|
||||||
<div className="relative group">
|
|
||||||
<div className="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none">
|
<form onSubmit={handleSubmit(onSubmit)} className="space-y-5">
|
||||||
<Lock className={`w-5 h-5 transition-colors ${errors.password ? 'text-red-400' : 'text-ink-400 group-focus-within:text-ink-900'}`} />
|
<div className="space-y-1.5">
|
||||||
</div>
|
<label className="block text-[10px] font-bold text-ink-500 uppercase tracking-wider">
|
||||||
<input
|
Work Email
|
||||||
type={showPassword ? 'text' : 'password'}
|
</label>
|
||||||
{...register('password')}
|
<div className="relative group">
|
||||||
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`}
|
<div className="absolute inset-y-0 left-0 pl-3.5 flex items-center pointer-events-none">
|
||||||
placeholder="••••••••"
|
<Mail
|
||||||
/>
|
className={`w-4 h-4 transition-colors ${errors.email ? "text-red-400" : "text-ink-400 group-focus-within:text-ink-900"}`}
|
||||||
<button
|
/>
|
||||||
type="button"
|
</div>
|
||||||
onClick={() => setShowPassword(!showPassword)}
|
<input
|
||||||
className="absolute inset-y-0 right-0 pr-4 flex items-center text-ink-400 hover:text-ink-950 focus:outline-none transition-colors"
|
type="email"
|
||||||
>
|
{...register("email")}
|
||||||
{showPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
|
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`}
|
||||||
</button>
|
placeholder="admin@tech4biz.com"
|
||||||
</div>
|
/>
|
||||||
{errors.password && <p className="text-red-500 dark:text-red-400 text-xs mt-1.5 font-bold">{errors.password.message}</p>}
|
</div>
|
||||||
</div>
|
{errors.email && (
|
||||||
|
<p className="text-red-500 dark:text-red-400 text-[10px] mt-1 font-bold font-sans">
|
||||||
<button
|
{errors.email.message}
|
||||||
type="submit"
|
</p>
|
||||||
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"
|
</div>
|
||||||
>
|
|
||||||
{isSubmitting ? 'AUTHENTICATING...' : 'SECURE SIGN IN'}
|
<div className="space-y-1.5">
|
||||||
{!isSubmitting && <ArrowRight className="w-5 h-5 group-hover:translate-x-1.5 transition-transform" />}
|
<div className="flex justify-between items-center">
|
||||||
</button>
|
<label className="block text-[10px] font-bold text-ink-500 uppercase tracking-wider">
|
||||||
</form>
|
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>
|
</div>
|
||||||
|
|
||||||
<p className="text-center text-ink-400 text-xs mt-10 font-bold tracking-widest uppercase">
|
|
||||||
© 2026 Tech4Biz Solutions.
|
|
||||||
</p>
|
|
||||||
</motion.div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@ -10,10 +10,12 @@ import {
|
|||||||
uploadSignedLegalDocument,
|
uploadSignedLegalDocument,
|
||||||
signLegalDocument
|
signLegalDocument
|
||||||
} from '../services/legal-api';
|
} from '../services/legal-api';
|
||||||
|
import { useToast } from '../hooks/use-toast';
|
||||||
|
|
||||||
type DocumentType = 'NDA' | 'MSA';
|
type DocumentType = 'NDA' | 'MSA';
|
||||||
|
|
||||||
export const OnboardingPage: React.FC = () => {
|
export const OnboardingPage: React.FC = () => {
|
||||||
|
const { success, error: toastError } = useToast();
|
||||||
const { user, checkAuth } = useAuthStore();
|
const { user, checkAuth } = useAuthStore();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [step, setStep] = useState(1);
|
const [step, setStep] = useState(1);
|
||||||
@ -191,8 +193,10 @@ export const OnboardingPage: React.FC = () => {
|
|||||||
|
|
||||||
if (type === 'NDA') setNdaUploadUrl(data.url);
|
if (type === 'NDA') setNdaUploadUrl(data.url);
|
||||||
if (type === 'MSA') setMsaUploadUrl(data.url);
|
if (type === 'MSA') setMsaUploadUrl(data.url);
|
||||||
} catch (error) {
|
success("Document uploaded successfully", `Signed ${type} has been uploaded.`);
|
||||||
console.error(`Failed to upload ${type}:`, error);
|
} catch (err: any) {
|
||||||
|
console.error(`Failed to upload ${type}:`, err);
|
||||||
|
toastError("Upload failed", err.response?.data?.error || `Failed to upload signed ${type}.`);
|
||||||
} finally {
|
} finally {
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
}
|
}
|
||||||
@ -210,14 +214,17 @@ export const OnboardingPage: React.FC = () => {
|
|||||||
documentUrl
|
documentUrl
|
||||||
});
|
});
|
||||||
|
|
||||||
|
success(`${type} Agreement signed`, `Your signature on the ${type} has been registered.`);
|
||||||
|
|
||||||
if (type === 'NDA') {
|
if (type === 'NDA') {
|
||||||
setStep(2);
|
setStep(2);
|
||||||
} else {
|
} else {
|
||||||
await checkAuth();
|
await checkAuth();
|
||||||
setStep(3); // PENDING_APPROVAL
|
setStep(3); // PENDING_APPROVAL
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (err: any) {
|
||||||
console.error(`Failed to submit ${type}:`, error);
|
console.error(`Failed to submit ${type}:`, err);
|
||||||
|
toastError("Submission failed", err.response?.data?.error || `Failed to submit signed ${type}.`);
|
||||||
} finally {
|
} finally {
|
||||||
setIsSubmitting(false);
|
setIsSubmitting(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,18 +1,61 @@
|
|||||||
import React from 'react';
|
import React, { useState } from 'react';
|
||||||
import { CheckCircle, Clock, Search, XCircle } from 'lucide-react';
|
import { CheckCircle, Clock, Search, XCircle, Eye } from 'lucide-react';
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
import { motion, AnimatePresence } from 'framer-motion';
|
||||||
import { usePendingPartnersQuery } from '../../hooks/use-legal-query';
|
import { usePendingPartnersQuery } from '../../hooks/use-legal-query';
|
||||||
import { useApprovePartnerMutation } from '../../hooks/use-legal-mutation';
|
import { useApprovePartnerMutation } from '../../hooks/use-legal-mutation';
|
||||||
import PageHeader from '../../components/ui/PageHeader';
|
import PageHeader from '../../components/ui/PageHeader';
|
||||||
|
import Button from '../../components/ui/Button';
|
||||||
|
import { useToast } from '../../hooks/use-toast';
|
||||||
|
import { DocumentPreviewModal } from '../../components/ui/DocumentPreviewModal';
|
||||||
|
import { PageLayout } from '../../components/layout/PageLayout';
|
||||||
|
|
||||||
export const ApprovalsPage: React.FC = () => {
|
export const ApprovalsPage: React.FC = () => {
|
||||||
|
const { success, error } = useToast();
|
||||||
const { data: partners = [], isLoading } = usePendingPartnersQuery();
|
const { data: partners = [], isLoading } = usePendingPartnersQuery();
|
||||||
const approveMutation = useApprovePartnerMutation();
|
const approveMutation = useApprovePartnerMutation();
|
||||||
|
const [searchTerm, setSearchTerm] = useState('');
|
||||||
|
|
||||||
const approvePartner = (partnerId: string) => {
|
// Preview Modal state
|
||||||
approveMutation.mutate(partnerId);
|
const [selectedPartner, setSelectedPartner] = useState<any | null>(null);
|
||||||
|
const [isPreviewOpen, setIsPreviewOpen] = useState(false);
|
||||||
|
const [verifiedDocs, setVerifiedDocs] = useState<Record<string, { nda: boolean; msa: boolean }>>({});
|
||||||
|
|
||||||
|
const approvePartner = (partnerId: string, email: string) => {
|
||||||
|
approveMutation.mutate(partnerId, {
|
||||||
|
onSuccess: () => {
|
||||||
|
success("Partner access approved", `Access has been granted to ${email}.`);
|
||||||
|
setIsPreviewOpen(false);
|
||||||
|
setSelectedPartner(null);
|
||||||
|
},
|
||||||
|
onError: (err: any) => {
|
||||||
|
error("Approval failed", err.response?.data?.error || "Could not approve partner access.");
|
||||||
|
}
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleOpenPreview = (partner: any, _docType: 'NDA' | 'MSA') => {
|
||||||
|
setSelectedPartner(partner);
|
||||||
|
setIsPreviewOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleVerify = (partnerId: string, docType: 'NDA' | 'MSA') => {
|
||||||
|
setVerifiedDocs(prev => {
|
||||||
|
const partnerStatus = prev[partnerId] || { nda: false, msa: false };
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
[partnerId]: {
|
||||||
|
...partnerStatus,
|
||||||
|
[docType === 'NDA' ? 'nda' : 'msa']: true
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
success(`${docType} document signature verified.`, "Verification status updated.");
|
||||||
|
};
|
||||||
|
|
||||||
|
const filteredPartners = partners.filter(p =>
|
||||||
|
p.email.toLowerCase().includes(searchTerm.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="flex-1 flex items-center justify-center min-h-[60vh]">
|
<div className="flex-1 flex items-center justify-center min-h-[60vh]">
|
||||||
@ -21,133 +64,180 @@ export const ApprovalsPage: React.FC = () => {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
// Header component
|
||||||
<div className="w-full space-y-6 animate-fade-in text-ink-900">
|
const headerNode = (
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Approvals Queue"
|
title="Approvals Queue"
|
||||||
subtitle="Review and approve partner legal documents to grant platform access."
|
subtitle="Review and approve partner legal documents to grant platform access."
|
||||||
badge={
|
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">
|
<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
|
{partners.length} Pending
|
||||||
</span>
|
</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>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* List */}
|
// Toolbar component
|
||||||
<div className="bg-ink-0 rounded-xl border border-ink-200 overflow-hidden shadow-sm">
|
const toolbarNode = (
|
||||||
<div className="overflow-x-auto">
|
<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">
|
||||||
<table className="w-full text-left text-sm whitespace-nowrap">
|
<div className="flex items-center gap-2 flex-1 max-w-md">
|
||||||
<thead className="bg-ink-50 border-b border-ink-200 text-ink-500 font-bold uppercase tracking-wider text-xs">
|
<div className="relative flex-1">
|
||||||
<tr>
|
<Search className="w-4 h-4 text-ink-400 absolute left-3 top-1/2 -translate-y-1/2" />
|
||||||
<th className="px-4 py-2.5">Partner</th>
|
<input
|
||||||
<th className="px-4 py-2.5">NDA Status</th>
|
type="text"
|
||||||
<th className="px-4 py-2.5">MSA Status</th>
|
value={searchTerm}
|
||||||
<th className="px-4 py-2.5 text-right">Actions</th>
|
onChange={(e) => setSearchTerm(e.target.value)}
|
||||||
</tr>
|
placeholder="Search pending partners by email..."
|
||||||
</thead>
|
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"
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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;
|
export default ApprovalsPage;
|
||||||
|
|||||||
@ -1,371 +1,512 @@
|
|||||||
import React, { useState } from 'react';
|
import React, { useState } from "react";
|
||||||
import { Users, Mail, CheckCircle, AlertCircle, ChevronRight, UserPlus, Clock, ShieldCheck, RefreshCw } from 'lucide-react';
|
import {
|
||||||
import { motion, AnimatePresence } from 'framer-motion';
|
Users,
|
||||||
import { Link } from 'react-router-dom';
|
Mail,
|
||||||
import { usePartnersQuery } from '../../hooks/use-partners-query';
|
CheckCircle,
|
||||||
import { useInvitePartnerMutation } from '../../hooks/use-partners-mutation';
|
AlertCircle,
|
||||||
import PageHeader from '../../components/ui/PageHeader';
|
ChevronRight,
|
||||||
|
UserPlus,
|
||||||
|
Clock,
|
||||||
|
ShieldCheck,
|
||||||
|
RefreshCw,
|
||||||
|
Search,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { motion } from "framer-motion";
|
||||||
|
import { Link } from "react-router-dom";
|
||||||
|
import { usePartnersQuery } from "../../hooks/use-partners-query";
|
||||||
|
import { useInvitePartnerMutation } from "../../hooks/use-partners-mutation";
|
||||||
|
import PageHeader from "../../components/ui/PageHeader";
|
||||||
|
import Button from "../../components/ui/Button";
|
||||||
|
import Modal from "../../components/ui/Modal";
|
||||||
|
import { useToast } from "../../hooks/use-toast";
|
||||||
|
import { PageLayout } from "../../components/layout/PageLayout";
|
||||||
|
|
||||||
const STATUS_CONFIG: Record<string, { label: string; color: string; bg: string; border: string }> = {
|
const STATUS_CONFIG: Record<
|
||||||
PENDING_ONBOARDING: {
|
string,
|
||||||
label: 'Pending Onboarding',
|
{ label: string; color: string; bg: string; border: string }
|
||||||
color: 'text-ink-500',
|
> = {
|
||||||
bg: 'bg-ink-50',
|
PENDING_ONBOARDING: {
|
||||||
border: 'border-ink-200',
|
label: "Pending Onboarding",
|
||||||
},
|
color: "text-ink-500",
|
||||||
PENDING_APPROVAL: {
|
bg: "bg-ink-50",
|
||||||
label: 'Awaiting Approval',
|
border: "border-ink-200",
|
||||||
color: 'text-ink-0 bg-ink-900',
|
},
|
||||||
bg: 'bg-ink-900',
|
PENDING_APPROVAL: {
|
||||||
border: 'border-ink-800',
|
label: "Awaiting Approval",
|
||||||
},
|
color: "text-ink-0 bg-ink-900",
|
||||||
APPROVED: {
|
bg: "bg-ink-900",
|
||||||
label: 'Active',
|
border: "border-ink-800",
|
||||||
color: 'text-ink-900 font-extrabold',
|
},
|
||||||
bg: 'bg-ink-100',
|
APPROVED: {
|
||||||
border: 'border-ink-300',
|
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,
|
label: status,
|
||||||
color: 'text-ink-500',
|
color: "text-ink-500",
|
||||||
bg: 'bg-ink-100',
|
bg: "bg-ink-100",
|
||||||
border: 'border-ink-200',
|
border: "border-ink-200",
|
||||||
};
|
};
|
||||||
|
|
||||||
export const DirectoryPage: React.FC = () => {
|
export const DirectoryPage: React.FC = () => {
|
||||||
const [email, setEmail] = useState('');
|
const { success, error } = useToast();
|
||||||
const [inviteResult, setInviteResult] = useState<{ token?: string; error?: string } | null>(null);
|
const [email, setEmail] = useState("");
|
||||||
const [isInviteOpen, setIsInviteOpen] = useState(false);
|
const [inviteResult, setInviteResult] = useState<{
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
token?: string;
|
||||||
const ITEMS_PER_PAGE = 5;
|
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 {
|
||||||
const inviteMutation = useInvitePartnerMutation();
|
data: partners = [],
|
||||||
|
isLoading: loadingPartners,
|
||||||
|
refetch: fetchPartners,
|
||||||
|
} = usePartnersQuery();
|
||||||
|
const inviteMutation = useInvitePartnerMutation();
|
||||||
|
|
||||||
const handleInvite = async (e: React.FormEvent) => {
|
const handleInvite = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setInviteResult(null);
|
setInviteResult(null);
|
||||||
|
|
||||||
inviteMutation.mutate(email, {
|
inviteMutation.mutate(email, {
|
||||||
onSuccess: (data) => {
|
onSuccess: (data) => {
|
||||||
setInviteResult({ token: data.token });
|
setInviteResult({ token: data.token });
|
||||||
setEmail('');
|
success("Invitation generated successfully", `A secure onboarding link has been created for ${email}.`);
|
||||||
},
|
setEmail("");
|
||||||
onError: (err: any) => {
|
},
|
||||||
setInviteResult({ error: err.response?.data?.error || 'Failed to send invite' });
|
onError: (err: any) => {
|
||||||
}
|
const errMsg = err.response?.data?.error || "Failed to send invite";
|
||||||
|
setInviteResult({
|
||||||
|
error: errMsg,
|
||||||
});
|
});
|
||||||
};
|
error("Invitation failed", errMsg);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const counts = {
|
const counts = {
|
||||||
total: partners.length,
|
total: partners.length,
|
||||||
active: partners.filter(p => p.onboardingStatus === 'APPROVED').length,
|
active: partners.filter((p) => p.onboardingStatus === "APPROVED").length,
|
||||||
pendingOnboarding: partners.filter(p => p.onboardingStatus === 'PENDING_ONBOARDING').length,
|
pendingOnboarding: partners.filter(
|
||||||
awaitingApproval: partners.filter(p => p.onboardingStatus === 'PENDING_APPROVAL').length,
|
(p) => p.onboardingStatus === "PENDING_ONBOARDING",
|
||||||
};
|
).length,
|
||||||
|
awaitingApproval: partners.filter(
|
||||||
|
(p) => p.onboardingStatus === "PENDING_APPROVAL",
|
||||||
|
).length,
|
||||||
|
};
|
||||||
|
|
||||||
const statCards = [
|
const statCards = [
|
||||||
{ label: 'Total Partners', value: counts.total, icon: Users, accentClass: '' },
|
{
|
||||||
{ label: 'Active', value: counts.active, icon: ShieldCheck, accentClass: '' },
|
label: "Total Partners",
|
||||||
{ label: 'Pending Onboarding', value: counts.pendingOnboarding, icon: Clock, accentClass: '' },
|
value: counts.total,
|
||||||
];
|
icon: Users,
|
||||||
|
accentClass: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Active",
|
||||||
|
value: counts.active,
|
||||||
|
icon: ShieldCheck,
|
||||||
|
accentClass: "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Pending Onboarding",
|
||||||
|
value: counts.pendingOnboarding,
|
||||||
|
icon: Clock,
|
||||||
|
accentClass: "",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
if (counts.awaitingApproval > 0) {
|
if (counts.awaitingApproval > 0) {
|
||||||
statCards.push({
|
statCards.push({
|
||||||
label: 'Awaiting Approval',
|
label: "Awaiting Approval",
|
||||||
value: counts.awaitingApproval,
|
value: counts.awaitingApproval,
|
||||||
icon: AlertCircle,
|
icon: AlertCircle,
|
||||||
accentClass: 'border-ink-900/30 bg-ink-900/5 shadow-[0_0_15px_rgba(0,0,0,0.01)] border-dashed animate-pulse'
|
accentClass:
|
||||||
});
|
"border-ink-900/30 bg-ink-900/5 shadow-[0_0_15px_rgba(0,0,0,0.01)] border-dashed animate-pulse",
|
||||||
}
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const totalItems = partners.length;
|
const filteredPartners = partners.filter((partner) => {
|
||||||
const totalPages = Math.ceil(totalItems / ITEMS_PER_PAGE) || 1;
|
const matchesSearch = partner.email
|
||||||
const startIndex = (currentPage - 1) * ITEMS_PER_PAGE;
|
.toLowerCase()
|
||||||
const endIndex = Math.min(startIndex + ITEMS_PER_PAGE, totalItems);
|
.includes(searchTerm.toLowerCase());
|
||||||
const paginatedPartners = partners.slice(startIndex, endIndex);
|
const matchesStatus =
|
||||||
|
statusFilter === "ALL" || partner.onboardingStatus === statusFilter;
|
||||||
|
return matchesSearch && matchesStatus;
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
const totalItems = filteredPartners.length;
|
||||||
<div className="w-full space-y-6 animate-fade-in text-ink-900">
|
const totalPages = Math.ceil(totalItems / ITEMS_PER_PAGE) || 1;
|
||||||
<PageHeader
|
const startIndex = (currentPage - 1) * ITEMS_PER_PAGE;
|
||||||
title="Partner Directory"
|
const endIndex = Math.min(startIndex + ITEMS_PER_PAGE, totalItems);
|
||||||
subtitle="Manage your network and invite new partners to the platform."
|
const paginatedPartners = filteredPartners.slice(startIndex, endIndex);
|
||||||
actions={
|
|
||||||
<>
|
// Header component
|
||||||
<button
|
const headerNode = (
|
||||||
onClick={() => {
|
<PageHeader
|
||||||
setInviteResult(null);
|
title="Partner Directory"
|
||||||
setEmail('');
|
subtitle="Manage your network and invite new partners to the platform."
|
||||||
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"
|
|
||||||
>
|
// Toolbar component
|
||||||
<UserPlus className="w-4 h-4" />
|
const toolbarNode = (
|
||||||
<span>Invite Partner</span>
|
<div className="flex flex-col space-y-3 shrink-0">
|
||||||
</button>
|
{/* Search/Filter & Actions Toolbar */}
|
||||||
<button
|
<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">
|
||||||
onClick={() => { fetchPartners(); }}
|
<div className="flex flex-col sm:flex-row items-stretch sm:items-center gap-2 flex-1 max-w-xl">
|
||||||
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"
|
<div className="relative flex-1">
|
||||||
title="Refresh"
|
<Search className="w-4 h-4 text-ink-400 absolute left-3 top-1/2 -translate-y-1/2" />
|
||||||
>
|
<input
|
||||||
<RefreshCw className="w-4 h-4 group-hover:rotate-180 transition-transform duration-500" />
|
type="text"
|
||||||
</button>
|
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 */}
|
<div className="flex items-center gap-2 shrink-0 justify-end w-full sm:w-auto">
|
||||||
{counts.awaitingApproval > 0 && (
|
<Button
|
||||||
<motion.div
|
onClick={() => {
|
||||||
initial={{ opacity: 0, y: -10 }}
|
setInviteResult(null);
|
||||||
animate={{ opacity: 1, y: 0 }}
|
setEmail("");
|
||||||
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"
|
setIsInviteOpen(true);
|
||||||
>
|
}}
|
||||||
<div className="absolute inset-0 bg-gradient-to-r from-ink-800 via-ink-900 to-ink-800 opacity-50" />
|
variant="primary"
|
||||||
<div className="flex items-center gap-3 relative z-10">
|
size="sm"
|
||||||
<div className="w-8 h-8 rounded-lg bg-ink-0/10 flex items-center justify-center text-ink-0 shrink-0">
|
icon={<UserPlus className="w-4 h-4" />}
|
||||||
<AlertCircle className="w-4 h-4 animate-bounce" />
|
>
|
||||||
</div>
|
Invite Partner
|
||||||
<div>
|
</Button>
|
||||||
<h4 className="font-bold text-sm text-ink-0">Partner approvals pending</h4>
|
<Button
|
||||||
<p className="text-xs text-ink-300 mt-0.5">There are {counts.awaitingApproval} partners awaiting document review and access authorization.</p>
|
onClick={() => {
|
||||||
</div>
|
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>
|
</div>
|
||||||
<Link
|
<p className="text-sm font-bold text-ink-900">
|
||||||
to="/admin/approvals"
|
No partners yet
|
||||||
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>
|
||||||
|
<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
|
<td className="px-5 py-4">
|
||||||
<ChevronRight className="w-3.5 h-3.5" />
|
<div className="flex items-center gap-3">
|
||||||
</Link>
|
<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">
|
||||||
</motion.div>
|
{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="flex items-center gap-3 pt-2">
|
||||||
<div className={`grid gap-4 ${counts.awaitingApproval > 0 ? 'grid-cols-2 md:grid-cols-4' : 'grid-cols-1 md:grid-cols-3'}`}>
|
<Button
|
||||||
{statCards.map((stat, i) => (
|
type="button"
|
||||||
<div key={i} className={`bg-ink-0 rounded-xl border p-4 shadow-sm transition-all duration-300 ${stat.accentClass || 'border-ink-200'}`}>
|
onClick={() => setIsInviteOpen(false)}
|
||||||
<div className="flex items-center justify-between mb-2">
|
variant="ghost"
|
||||||
<p className="text-xs font-semibold uppercase tracking-wider text-ink-500">{stat.label}</p>
|
size="sm"
|
||||||
<stat.icon className={`w-4 h-4 ${stat.accentClass ? 'text-ink-900 animate-pulse' : 'text-ink-400'}`} />
|
className="flex-1"
|
||||||
</div>
|
>
|
||||||
<p className="text-xl font-bold tracking-tight text-ink-900">{stat.value}</p>
|
Cancel
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* Partner List Table (Full Width) */}
|
<Button
|
||||||
<div className="bg-ink-0 rounded-xl border border-ink-200 shadow-sm overflow-hidden w-full">
|
type="button"
|
||||||
<div className="overflow-x-auto">
|
onClick={() => {
|
||||||
<table className="w-full text-left text-sm whitespace-nowrap">
|
setIsInviteOpen(false);
|
||||||
<thead className="bg-ink-50 border-b border-ink-200 text-ink-500 font-bold uppercase tracking-wider text-xs">
|
setEmail("");
|
||||||
<tr>
|
setInviteResult(null);
|
||||||
<th className="px-4 py-2.5">Partner</th>
|
}}
|
||||||
<th className="px-4 py-2.5">Status</th>
|
variant="primary"
|
||||||
<th className="px-4 py-2.5">MFA</th>
|
size="sm"
|
||||||
<th className="px-4 py-2.5">Joined</th>
|
className="w-full"
|
||||||
</tr>
|
>
|
||||||
</thead>
|
Done
|
||||||
<tbody className="divide-y divide-ink-200">
|
</Button>
|
||||||
{loadingPartners ? (
|
</div>
|
||||||
<tr>
|
)}
|
||||||
<td colSpan={4} className="px-4 py-8 text-center">
|
</Modal>
|
||||||
<div className="w-6 h-6 border-4 border-ink-900/30 border-t-ink-900 rounded-full animate-spin mx-auto" />
|
</PageLayout>
|
||||||
</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>
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default DirectoryPage;
|
export default DirectoryPage;
|
||||||
|
|||||||
@ -2,6 +2,9 @@ import React, { useState, useEffect, useRef } from 'react';
|
|||||||
import { axiosInstance } from '../../services/axios';
|
import { axiosInstance } from '../../services/axios';
|
||||||
import { Shield, FileText, Upload, CheckCircle, AlertTriangle, ArrowRight, Eye, RefreshCw } from 'lucide-react';
|
import { Shield, FileText, Upload, CheckCircle, AlertTriangle, ArrowRight, Eye, RefreshCw } from 'lucide-react';
|
||||||
import PageHeader from '../../components/ui/PageHeader';
|
import PageHeader from '../../components/ui/PageHeader';
|
||||||
|
import Button from '../../components/ui/Button';
|
||||||
|
import { useToast } from '../../hooks/use-toast';
|
||||||
|
import { PageLayout } from '../../components/layout/PageLayout';
|
||||||
|
|
||||||
interface LegalDoc {
|
interface LegalDoc {
|
||||||
id: string;
|
id: string;
|
||||||
@ -14,6 +17,7 @@ interface LegalDoc {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const LegalTemplatesPage: React.FC = () => {
|
export const LegalTemplatesPage: React.FC = () => {
|
||||||
|
const { success, error: toastError } = useToast();
|
||||||
const [activeTab, setActiveTab] = useState<'NDA' | 'MSA'>('NDA');
|
const [activeTab, setActiveTab] = useState<'NDA' | 'MSA'>('NDA');
|
||||||
const [ndaDoc, setNdaDoc] = useState<LegalDoc | null>(null);
|
const [ndaDoc, setNdaDoc] = useState<LegalDoc | null>(null);
|
||||||
const [msaDoc, setMsaDoc] = useState<LegalDoc | null>(null);
|
const [msaDoc, setMsaDoc] = useState<LegalDoc | null>(null);
|
||||||
@ -25,7 +29,6 @@ export const LegalTemplatesPage: React.FC = () => {
|
|||||||
const [pdfFile, setPdfFile] = useState<File | null>(null);
|
const [pdfFile, setPdfFile] = useState<File | null>(null);
|
||||||
const [uploadingPdf, setUploadingPdf] = useState(false);
|
const [uploadingPdf, setUploadingPdf] = useState(false);
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
|
||||||
|
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
@ -75,13 +78,12 @@ export const LegalTemplatesPage: React.FC = () => {
|
|||||||
const handleFormSubmit = async (e: React.FormEvent) => {
|
const handleFormSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!version || !content) {
|
if (!version || !content) {
|
||||||
setMessage({ type: 'error', text: 'Please fill in both the version and textual content.' });
|
toastError('Validation error', 'Please fill in both the version and textual content.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
setMessage(null);
|
|
||||||
let uploadedUrl: string | null = null;
|
let uploadedUrl: string | null = null;
|
||||||
|
|
||||||
// 1. Upload PDF if selected
|
// 1. Upload PDF if selected
|
||||||
@ -108,14 +110,14 @@ export const LegalTemplatesPage: React.FC = () => {
|
|||||||
pdfUrl: uploadedUrl
|
pdfUrl: uploadedUrl
|
||||||
});
|
});
|
||||||
|
|
||||||
setMessage({ type: 'success', text: `Successfully published ${activeTab} version ${version}!` });
|
success(`Successfully published ${activeTab} version ${version}!`, 'The new legal template version is now active.');
|
||||||
setPdfFile(null);
|
setPdfFile(null);
|
||||||
if (fileInputRef.current) fileInputRef.current.value = '';
|
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||||
|
|
||||||
await fetchActiveDocuments();
|
await fetchActiveDocuments();
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
console.error('Failed to publish legal document template:', err);
|
console.error('Failed to publish legal document template:', err);
|
||||||
setMessage({ type: 'error', text: err.response?.data?.error || 'Failed to publish legal template.' });
|
toastError('Failed to publish template', err.response?.data?.error || 'Failed to publish legal template.');
|
||||||
} finally {
|
} finally {
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
setUploadingPdf(false);
|
setUploadingPdf(false);
|
||||||
@ -125,36 +127,45 @@ export const LegalTemplatesPage: React.FC = () => {
|
|||||||
const currentDoc = activeTab === 'NDA' ? ndaDoc : msaDoc;
|
const currentDoc = activeTab === 'NDA' ? ndaDoc : msaDoc;
|
||||||
const fileHost = (import.meta.env.VITE_API_URL || 'http://localhost:5000/api/v1').replace('/api/v1', '');
|
const fileHost = (import.meta.env.VITE_API_URL || 'http://localhost:5000/api/v1').replace('/api/v1', '');
|
||||||
|
|
||||||
return (
|
// Header component
|
||||||
<div className="w-full space-y-6 animate-fade-in text-ink-900">
|
const headerNode = (
|
||||||
<PageHeader
|
<PageHeader
|
||||||
title="Legal Agreements"
|
title="Legal Agreements"
|
||||||
subtitle="Configure active documents required during partner onboarding."
|
subtitle="Configure active documents required during partner onboarding."
|
||||||
badge={
|
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">
|
<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" />
|
<Shield className="w-3.5 h-3.5 text-ink-950" />
|
||||||
<span>Compliance Panel</span>
|
<span>Compliance Panel</span>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
actions={
|
/>
|
||||||
<div className="inline-flex p-1 rounded-lg bg-ink-100 border border-ink-200 shrink-0">
|
);
|
||||||
<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 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 */}
|
{/* Left Side: Active Status Details */}
|
||||||
<div className="lg:col-span-1 flex flex-col gap-6">
|
<div className="lg:col-span-1 flex flex-col gap-6">
|
||||||
<div className="p-4 bg-ink-0 border border-ink-200 shadow-sm rounded-xl">
|
<div className="p-4 bg-ink-0 border border-ink-200 shadow-sm rounded-xl">
|
||||||
@ -200,7 +211,7 @@ export const LegalTemplatesPage: React.FC = () => {
|
|||||||
href={`${fileHost}${currentDoc.pdfUrl}`}
|
href={`${fileHost}${currentDoc.pdfUrl}`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="flex items-center justify-between p-2 rounded-lg border border-dashed border-ink-200 hover:bg-ink-50 hover:border-ink-400 transition-all text-xs font-bold text-ink-700"
|
className="flex items-between justify-between p-2 rounded-lg border border-dashed border-ink-200 hover:bg-ink-50 hover:border-ink-400 transition-all text-xs font-bold text-ink-700"
|
||||||
>
|
>
|
||||||
<span className="truncate max-w-[150px]">{currentDoc.pdfUrl.split('/').pop()}</span>
|
<span className="truncate max-w-[150px]">{currentDoc.pdfUrl.split('/').pop()}</span>
|
||||||
<ArrowRight className="w-3.5 h-3.5" />
|
<ArrowRight className="w-3.5 h-3.5" />
|
||||||
@ -235,13 +246,6 @@ export const LegalTemplatesPage: React.FC = () => {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{message && (
|
|
||||||
<div className={`p-3 rounded-lg flex items-start gap-3 border ${message.type === 'success' ? 'bg-ink-100 border-ink-200 text-ink-900' : 'bg-red-500/10 border-red-500/20 text-red-650'}`}>
|
|
||||||
{message.type === 'success' && <CheckCircle className="w-4 h-4 shrink-0 mt-0.5" />}
|
|
||||||
<p className="text-xs font-semibold">{message.text}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
||||||
<div className="md:col-span-1">
|
<div className="md:col-span-1">
|
||||||
<label className="block text-xs font-semibold text-ink-500 mb-2 uppercase tracking-wider">Version String</label>
|
<label className="block text-xs font-semibold text-ink-500 mb-2 uppercase tracking-wider">Version String</label>
|
||||||
@ -297,19 +301,21 @@ export const LegalTemplatesPage: React.FC = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex justify-end gap-3 pt-3 border-t border-ink-100">
|
<div className="flex justify-end gap-3 pt-3 border-t border-ink-100">
|
||||||
<button
|
<Button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={submitting || uploadingPdf}
|
disabled={submitting || uploadingPdf}
|
||||||
className="px-4 py-2 rounded-lg bg-ink-900 text-ink-0 font-bold text-sm hover:bg-ink-800 transition-all disabled:opacity-50 flex items-center gap-2 shadow-sm cursor-pointer"
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
icon={<CheckCircle className="w-4 h-4" />}
|
||||||
>
|
>
|
||||||
{uploadingPdf ? 'Uploading PDF...' : submitting ? 'Publishing...' : 'Publish Template'}
|
{uploadingPdf ? 'Uploading PDF...' : submitting ? 'Publishing...' : 'Publish Template'}
|
||||||
<CheckCircle className="w-4 h-4" />
|
</Button>
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</PageLayout>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default LegalTemplatesPage;
|
export default LegalTemplatesPage;
|
||||||
|
|||||||
@ -8,9 +8,14 @@ export interface PendingPartner {
|
|||||||
id: string;
|
id: string;
|
||||||
signatureHash: string | null;
|
signatureHash: string | null;
|
||||||
documentUrl: string | null;
|
documentUrl: string | null;
|
||||||
|
signatureBase64: string | null;
|
||||||
|
ipAddress: string;
|
||||||
|
acceptedAt: string;
|
||||||
document: {
|
document: {
|
||||||
|
id: string;
|
||||||
type: string;
|
type: string;
|
||||||
version: string;
|
version: string;
|
||||||
|
content: string;
|
||||||
};
|
};
|
||||||
}>;
|
}>;
|
||||||
}
|
}
|
||||||
@ -25,10 +30,17 @@ export interface LegalDocument {
|
|||||||
|
|
||||||
export interface LegalAcceptance {
|
export interface LegalAcceptance {
|
||||||
id: string;
|
id: string;
|
||||||
|
signatureHash: string | null;
|
||||||
|
documentUrl: string | null;
|
||||||
|
signatureBase64: string | null;
|
||||||
|
ipAddress: string;
|
||||||
|
acceptedAt: string;
|
||||||
document: {
|
document: {
|
||||||
id: string;
|
id: string;
|
||||||
type: 'NDA' | 'MSA';
|
type: 'NDA' | 'MSA';
|
||||||
version: string;
|
version: string;
|
||||||
|
content: string;
|
||||||
|
pdfUrl?: string | null;
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user