Compare commits

...

2 Commits

27 changed files with 3034 additions and 1925 deletions

6
.gitignore vendored
View File

@ -24,4 +24,8 @@ dist-ssr
*.sw?
.env
uploads/*
uploads/*
.github/*
.next/*
.env.local
.vite/*

View File

@ -0,0 +1,360 @@
# Frontend Refactoring & UI Standardization Prompt
Act as a world-class Senior Frontend Architect, UI Engineer, and Design System expert. Your objective is to refactor and standardize the entire frontend application to production-grade quality by following industry best practices, scalable architecture, and clean code principles.
## Primary Goal
Transform the existing application into a clean, maintainable, reusable, responsive, and highly consistent frontend without changing any business logic or existing functionality.
---
# 1. Frontend Architecture
Implement a scalable frontend architecture by following feature-based organization.
- Organize code into reusable modules.
- Separate business logic from UI.
- Keep components focused on a single responsibility.
- Avoid duplicated logic throughout the application.
- Create a clear folder structure.
- Use shared utilities wherever possible.
- Follow feature-first architecture instead of dumping everything into common folders.
---
# 2. Code Standards
Enforce strict coding standards throughout the project.
- No component should exceed **500 lines of code**.
- Prefer keeping components between **150300 lines** whenever possible.
- Split large pages into smaller reusable components.
- Extract repeated JSX into reusable components.
- Extract repeated logic into custom hooks.
- Move API logic into service files.
- Move constants into dedicated constant files.
- Move validation into separate files.
- Move helper functions into utility files.
---
# 3. DRY Principles
Strictly follow the DRY (Don't Repeat Yourself) principle.
- Remove duplicated UI.
- Remove duplicated styles.
- Remove duplicated business logic.
- Remove duplicated API calls.
- Reuse existing components instead of recreating them.
- Centralize repeated logic.
---
# 4. Reusable Component Library
Create reusable components for all common UI patterns.
Examples include:
- Button
- Input
- Textarea
- Select
- Multi Select
- Search Input
- Date Picker
- Combobox
- Modal
- Drawer
- Dialog
- Confirmation Dialog
- Table
- Pagination
- Badge
- Card
- Empty State
- Loading State
- Skeleton
- Avatar
- Tooltip
- Popover
- Dropdown
- Tabs
- Accordion
- Alert
- Toast
- Breadcrumb
- Status Badge
- Section Header
- Form Field Wrapper
- Page Header
- Filter Panel
- Search Bar
- Action Buttons
- Data Cards
Every page should utilize these common components instead of creating new ones.
---
# 5. Typography System
Create a consistent typography system across the application.
Current typography appears oversized. Refine it to create a more professional enterprise UI.
Standardize:
- Font Family
- Font Size
- Font Weight
- Line Height
- Letter Spacing
Suggested scale:
- Page Title: 24px
- Section Title: 20px
- Card Title: 18px
- Table Header: 13px
- Body Text: 14px
- Secondary Text: 13px
- Labels: 13px
- Input Text: 14px
- Button Text: 14px
- Caption: 12px
Maintain consistent typography across all screens.
---
# 6. Spacing System
Implement a consistent spacing system.
Standardize:
- Padding
- Margin
- Gap
- Section spacing
- Card spacing
- Form spacing
Avoid random spacing values throughout the application.
---
# 7. Component Sizing
Reduce oversized UI elements to create a cleaner, more compact enterprise application.
Update:
- Popup sizes
- Modal widths
- Dialog heights
- Table row heights
- Table header heights
- Form fields
- Buttons
- Dropdowns
- Cards
- Navigation
- Sidebar spacing
The interface should feel lightweight, balanced, and visually polished.
---
# 8. Tables
Refactor all tables to follow consistent standards.
Ensure:
- Compact row height
- Consistent header styling
- Uniform padding
- Better column spacing
- Proper alignment
- Responsive behavior
- Sticky headers where appropriate
- Reusable table component
- Pagination component
- Empty state
- Loading state
- Sort indicators
- Filter integration
---
# 9. Modal & Popup Standards
Standardize all modals.
Requirements:
- Consistent width
- Consistent padding
- Consistent header
- Consistent footer
- Standard button placement
- Responsive behavior
- Smooth animations
- Proper scroll handling
- Keyboard accessibility
- Reusable modal component
Avoid oversized dialogs.
---
# 10. Form Standards
All forms should follow a single design system.
Maintain consistency for:
- Label placement
- Required indicators
- Validation messages
- Error states
- Success states
- Disabled states
- Placeholder styles
- Input heights
- Border radius
- Focus styles
---
# 11. Color System
Centralize all colors.
Do not hardcode colors.
Use semantic color tokens such as:
- Primary
- Secondary
- Success
- Warning
- Error
- Info
- Background
- Surface
- Border
- Text Primary
- Text Secondary
---
# 12. Responsive Standards
Ensure the application is fully responsive.
Optimize for:
- Mobile
- Tablet
- Laptop
- Desktop
- Large screens
No layout should break across screen sizes.
---
# 13. Performance
Improve frontend performance.
- Lazy load pages.
- Lazy load heavy components.
- Memoize expensive computations.
- Avoid unnecessary re-renders.
- Optimize bundle size.
- Remove unused code.
- Optimize images and icons.
---
# 14. Accessibility
Follow accessibility best practices.
- Keyboard navigation
- Proper ARIA attributes
- Focus management
- Color contrast
- Semantic HTML
- Screen reader support
---
# 15. Folder Structure
Maintain a clean folder structure.
- components/
- features/
- hooks/
- layouts/
- pages/
- services/
- utils/
- constants/
- contexts/
- types/
- assets/
- styles/
Every folder should have a clear responsibility.
---
# 16. Naming Conventions
Use consistent naming conventions.
- PascalCase for components
- camelCase for variables and functions
- UPPER_SNAKE_CASE for constants
- Feature-based file organization
Avoid ambiguous names.
---
# 17. Styling Standards
Use a consistent styling approach.
- Prefer Tailwind utility classes.
- Extract repeated class combinations into reusable components or utilities.
- Avoid inline styles unless absolutely necessary.
- Maintain consistent border radius, shadows, spacing, and transitions.
---
# 18. Final Deliverables
Refactor the entire application to ensure:
- Clean architecture
- Maintainable codebase
- Reusable components
- Consistent typography
- Compact and polished UI
- Smaller, balanced popups and tables
- Unified spacing system
- Enterprise-grade design system
- Fully responsive layouts
- Improved performance
- Better accessibility
- Strict adherence to SOLID, DRY, KISS, and Clean Code principles
Do not change any business logic or application behavior. The objective is to improve code quality, maintainability, scalability, and visual consistency while preserving all existing functionality.

View File

@ -1,10 +1,9 @@
import React, { useState, useEffect } from 'react';
import React, { useState } from 'react';
import { Link, Outlet, useLocation, useNavigate } from 'react-router-dom';
import { useThemeStore } from '../../hooks/use-theme';
import { useAuthStore } from '../../hooks/use-auth';
import { ShieldCheck, BarChart3, ClipboardCheck, FolderGit2, BookCopy, Users, LogOut, Menu, X, Sun, Moon, ChevronRight } from 'lucide-react';
import { ShieldCheck, BarChart3, ClipboardCheck, FolderGit2, BookCopy, Users, LogOut, Menu, X, Sun, Moon, ChevronRight, ChevronLeft } from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
import { axiosInstance } from '../../services/axios';
export const AdminLayout: React.FC = () => {
const { user, logout } = useAuthStore();
@ -12,30 +11,13 @@ export const AdminLayout: React.FC = () => {
const location = useLocation();
const navigate = useNavigate();
const [mobileOpen, setMobileOpen] = useState(false);
const [pendingCount, setPendingCount] = useState(0);
const [isCollapsed, setIsCollapsed] = useState(false);
const handleLogout = () => {
logout();
navigate('/login');
};
useEffect(() => {
const fetchPendingCount = async () => {
try {
const res = await axiosInstance.get('/legal/pending');
setPendingCount(res.data.length);
} catch (err) {
console.error('Failed to fetch pending count', err);
}
};
fetchPendingCount();
// Poll every 10s for real-time admin indicators
const interval = setInterval(fetchPendingCount, 10000);
return () => clearInterval(interval);
}, []);
const navItems = [
{ name: 'Partners', path: '/admin/partners', icon: Users },
{ name: 'Approvals Queue', path: '/admin/approvals', icon: ClipboardCheck },
@ -49,23 +31,34 @@ export const AdminLayout: React.FC = () => {
<div className="min-h-screen flex flex-col md:flex-row bg-ink-50 text-ink-900 font-sans transition-colors duration-500 selection:bg-ink-900/10 overflow-hidden">
{/* ── Desktop Sidebar ── */}
<aside className="hidden md:flex md:w-[280px] md:flex-col md:sticky md:top-0 md:h-screen bg-ink-0 border-r border-ink-200 shrink-0 z-20">
<aside className={`hidden md:flex md:flex-col md:sticky md:top-0 md:h-screen bg-ink-0 border-r border-ink-200 shrink-0 z-20 transition-all duration-300 relative ${isCollapsed ? 'md:w-[80px]' : 'md:w-[280px]'}`}>
{/* Toggle Button Floating on the Border */}
<button
onClick={() => setIsCollapsed(!isCollapsed)}
className="hidden md:flex absolute top-9 -right-3 w-6 h-6 rounded-full border border-ink-200 bg-ink-0 text-ink-500 hover:text-ink-900 hover:shadow-md items-center justify-center transition-all z-30 cursor-pointer shadow-sm"
title={isCollapsed ? "Expand Sidebar" : "Collapse Sidebar"}
>
{isCollapsed ? <ChevronRight className="w-3.5 h-3.5" /> : <ChevronLeft className="w-3.5 h-3.5" />}
</button>
{/* Branding */}
<div className="h-24 flex items-center px-8 border-b border-ink-200">
<div className={`h-24 flex items-center border-b border-ink-200 transition-all duration-300 ${isCollapsed ? 'justify-center px-4' : 'px-8'}`}>
<Link to="/admin" className="flex items-center gap-3 shrink-0 group">
<div className="w-10 h-10 rounded-xl flex items-center justify-center bg-gradient-to-br from-ink-900 to-ink-800 shadow-lg group-hover:scale-105 transition-all duration-300">
<ShieldCheck className="w-5 h-5 text-ink-0" />
</div>
<div className="flex flex-col">
<span className="text-lg font-extrabold tracking-tight leading-none text-ink-900">Tech4Biz</span>
<span className="text-[10px] font-bold uppercase tracking-widest text-ink-900 mt-1">Admin Console</span>
</div>
{!isCollapsed && (
<div className="flex flex-col animate-fade-in">
<span className="text-lg font-extrabold tracking-tight leading-none text-ink-900">Tech4Biz</span>
<span className="text-[10px] font-bold uppercase tracking-widest text-ink-900 mt-1">Admin Console</span>
</div>
)}
</Link>
</div>
{/* Navigation */}
<nav className="flex-1 px-4 py-8 space-y-2 overflow-y-auto">
<nav className={`flex-1 py-8 space-y-2 overflow-y-auto transition-all duration-300 ${isCollapsed ? 'px-2' : 'px-4'}`}>
{navItems.map(item => {
const Icon = item.icon;
const isActive = location.pathname === item.path;
@ -73,52 +66,58 @@ export const AdminLayout: React.FC = () => {
<Link
key={item.path}
to={item.path}
className={`flex items-center gap-3 px-4 py-3 rounded-xl transition-all duration-300 font-semibold tracking-wide text-sm group ${
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'
} ${
isActive
? 'bg-ink-100 text-ink-900 border border-ink-300 shadow-sm'
: 'text-ink-500 hover:text-ink-900 hover:bg-ink-100'
}`}
title={isCollapsed ? item.name : undefined}
>
<Icon className={`w-5 h-5 transition-transform group-hover:scale-110 ${isActive ? 'text-ink-950' : 'text-ink-400 group-hover:text-ink-900'}`} />
<span>{item.name}</span>
{item.name === 'Approvals Queue' && pendingCount > 0 && (
<span className="ml-auto flex h-5.5 w-5.5 items-center justify-center rounded-full bg-ink-900 text-[10px] font-extrabold text-ink-0 animate-pulse border border-ink-700 shadow-sm px-1.5 py-0.5">
{pendingCount}
</span>
)}
{isActive && !(item.name === 'Approvals Queue' && pendingCount > 0) && <ChevronRight className="w-4 h-4 ml-auto opacity-50" />}
{!isCollapsed && <span>{item.name}</span>}
{!isCollapsed && isActive && <ChevronRight className="w-4 h-4 ml-auto opacity-50" />}
</Link>
);
})}
</nav>
{/* Footer */}
<div className="p-5 border-t border-ink-200 bg-ink-50">
<div className="flex items-center justify-between mb-4">
<p className="text-[10px] font-bold text-ink-400 uppercase tracking-widest">Appearance</p>
<div className={`border-t border-ink-200 bg-ink-50 transition-all duration-300 ${isCollapsed ? 'p-3' : 'p-5'}`}>
<div className={`flex items-center mb-4 transition-all duration-300 ${isCollapsed ? 'justify-center' : 'justify-between'}`}>
{!isCollapsed && <p className="text-[10px] font-bold text-ink-400 uppercase tracking-widest">Appearance</p>}
<button
onClick={toggleTheme}
className="p-2 rounded-lg bg-ink-0 border border-ink-200 text-ink-500 hover:text-ink-900 hover:shadow-sm transition-all group"
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" />}
</button>
</div>
<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">
{!isCollapsed ? (
<div className="flex items-center gap-3 p-3 rounded-2xl bg-ink-0 border border-ink-200 mb-4 shadow-sm">
<div className="w-9 h-9 rounded-full bg-ink-900 flex items-center justify-center text-ink-0 font-bold shadow-sm">
{user?.email?.charAt(0).toUpperCase()}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-bold text-ink-900 truncate">{user?.email}</p>
<p className="text-[10px] uppercase font-bold text-ink-500 tracking-wider truncate">Administrator</p>
</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 || ''}>
{user?.email?.charAt(0).toUpperCase()}
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-bold text-ink-900 truncate">{user?.email}</p>
<p className="text-[10px] uppercase font-bold text-ink-500 tracking-wider truncate">Administrator</p>
</div>
</div>
)}
<button
onClick={handleLogout}
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 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"
className={`w-full flex items-center justify-center gap-2 rounded-xl text-sm font-bold tracking-wide text-red-600 hover:bg-red-500/10 transition-colors border border-transparent hover:border-red-200 cursor-pointer ${isCollapsed ? 'py-2 px-0' : 'px-4 py-2.5'}`}
title={isCollapsed ? "Sign Out" : undefined}
>
<LogOut className="w-4 h-4" />
<span>Sign Out</span>
{!isCollapsed && <span>Sign Out</span>}
</button>
</div>
</aside>
@ -158,11 +157,6 @@ export const AdminLayout: React.FC = () => {
<Link key={item.path} to={item.path} onClick={() => setMobileOpen(false)} className={`flex items-center gap-3 px-4 py-3 rounded-xl text-sm font-semibold ${location.pathname === item.path ? 'bg-ink-100 text-ink-900 border border-ink-300' : 'text-ink-500 hover:text-ink-900'}`}>
<item.icon className="w-5 h-5" />
<span>{item.name}</span>
{item.name === 'Approvals Queue' && pendingCount > 0 && (
<span className="ml-auto flex h-5 w-5 items-center justify-center rounded-full bg-ink-900 text-[10px] font-extrabold text-ink-0">
{pendingCount}
</span>
)}
</Link>
))}
</nav>
@ -177,33 +171,13 @@ export const AdminLayout: React.FC = () => {
)}
</AnimatePresence>
{/* Dynamic Pending Approvals Banner */}
{pendingCount > 0 && (
<div className="bg-ink-900 text-ink-0 px-6 py-3.5 flex items-center justify-between border-b border-ink-800 shadow-sm relative overflow-hidden shrink-0">
<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">
<span className="flex h-2 w-2 rounded-full bg-ink-0 animate-pulse" />
<p className="text-xs font-bold tracking-wide">
Onboarding Queue: {pendingCount} partner{pendingCount > 1 ? 's are' : ' is'} awaiting legal document approval.
</p>
</div>
<Link
to="/admin/approvals"
className="relative z-10 text-[10px] font-extrabold uppercase tracking-widest bg-ink-0 text-ink-900 px-3.5 py-1.5 rounded-lg hover:bg-ink-100 transition-all shadow-sm flex items-center gap-1 shrink-0"
>
<span>Review Now</span>
<ChevronRight className="w-3 h-3" />
</Link>
</div>
)}
<div className="flex-1 w-full max-w-[1600px] px-6 py-10 md:px-12 mx-auto relative z-10">
<div className="flex-1 w-full max-w-[1600px] px-4 py-6 md:px-8 mx-auto relative z-10">
<Outlet />
</div>
{/* Footer */}
<footer className="border-t border-ink-200 bg-ink-0/50 backdrop-blur-md mt-auto">
<div className="max-w-[1600px] mx-auto px-6 md:px-12 py-6 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-4 flex flex-col md:flex-row justify-between items-center gap-4 text-xs font-medium text-ink-500">
<p>© 2026 Tech4Biz Solutions Inc. Admin Console.</p>
<div className="flex gap-6">
<span className="hover:text-ink-900 cursor-pointer transition-colors">Security Compliance</span>

View File

@ -2,7 +2,7 @@ import React, { useState } from 'react';
import { Link, Outlet, useLocation, useNavigate } from 'react-router-dom';
import { useThemeStore } from '../../hooks/use-theme';
import { useAuthStore } from '../../hooks/use-auth';
import { ShieldCheck, Cpu, BookOpen, LogOut, CheckCircle, Clock, Menu, X, Sun, Moon, ChevronRight } from 'lucide-react';
import { ShieldCheck, Cpu, BookOpen, LogOut, CheckCircle, Clock, Menu, X, Sun, Moon, ChevronRight, ChevronLeft } from 'lucide-react';
import { motion, AnimatePresence } from 'framer-motion';
const navItems = [
@ -16,6 +16,7 @@ export const ClientLayout: React.FC = () => {
const location = useLocation();
const navigate = useNavigate();
const [mobileOpen, setMobileOpen] = useState(false);
const [isCollapsed, setIsCollapsed] = useState(false);
const handleLogout = () => {
logout();
@ -27,23 +28,34 @@ export const ClientLayout: React.FC = () => {
<div className="min-h-screen flex flex-col md:flex-row bg-ink-50 text-ink-900 font-sans transition-colors duration-500 selection:bg-ink-900/10 overflow-hidden">
{/* ── Desktop Sidebar ── */}
<aside className="hidden md:flex md:w-[280px] md:flex-col md:sticky md:top-0 md:h-screen bg-ink-0 border-r border-ink-200 shrink-0 z-20">
<aside className={`hidden md:flex md:flex-col md:sticky md:top-0 md:h-screen bg-ink-0 border-r border-ink-200 shrink-0 z-20 transition-all duration-300 relative ${isCollapsed ? 'md:w-[80px]' : 'md:w-[280px]'}`}>
{/* Toggle Button Floating on the Border */}
<button
onClick={() => setIsCollapsed(!isCollapsed)}
className="hidden md:flex absolute top-9 -right-3 w-6 h-6 rounded-full border border-ink-200 bg-ink-0 text-ink-500 hover:text-ink-900 hover:shadow-md items-center justify-center transition-all z-30 cursor-pointer shadow-sm"
title={isCollapsed ? "Expand Sidebar" : "Collapse Sidebar"}
>
{isCollapsed ? <ChevronRight className="w-3.5 h-3.5" /> : <ChevronLeft className="w-3.5 h-3.5" />}
</button>
{/* Branding */}
<div className="h-24 flex items-center px-8 border-b border-ink-200">
<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="/client" className="flex items-center gap-3 shrink-0 group">
<div className="w-10 h-10 rounded-xl flex items-center justify-center bg-gradient-to-br from-ink-900 to-ink-800 shadow-lg group-hover:scale-105 transition-all duration-300">
<ShieldCheck className="w-5 h-5 text-ink-0" />
</div>
<div className="flex flex-col">
<span className="text-lg font-extrabold tracking-tight leading-none text-ink-900">Tech4Biz</span>
<span className="text-[10px] font-bold uppercase tracking-widest text-ink-900 mt-1">Client Portal</span>
</div>
{!isCollapsed && (
<div className="flex flex-col animate-fade-in">
<span className="text-lg font-extrabold tracking-tight leading-none text-ink-900">Tech4Biz</span>
<span className="text-[10px] font-bold uppercase tracking-widest text-ink-900 mt-1">Client Portal</span>
</div>
)}
</Link>
</div>
{/* Navigation */}
<nav className="flex-1 px-4 py-8 space-y-2 overflow-y-auto">
<nav className={`flex-1 py-8 space-y-2 overflow-y-auto transition-all duration-300 ${isCollapsed ? 'px-2' : 'px-4'}`}>
{navItems.map(item => {
const Icon = item.icon;
const isActive = location.pathname === item.path;
@ -51,49 +63,60 @@ export const ClientLayout: React.FC = () => {
<Link
key={item.path}
to={item.path}
className={`flex items-center gap-3 px-4 py-3 rounded-xl transition-all duration-300 font-semibold tracking-wide text-sm group ${
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'
} ${
isActive
? 'bg-ink-100 text-ink-900 border border-ink-300 shadow-sm'
: 'text-ink-500 hover:text-ink-900 hover:bg-ink-100'
}`}
title={isCollapsed ? item.label : 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'}`} />
<span>{item.label}</span>
{isActive && <ChevronRight className="w-4 h-4 ml-auto opacity-50" />}
{!isCollapsed && <span>{item.label}</span>}
{!isCollapsed && isActive && <ChevronRight className="w-4 h-4 ml-auto opacity-50" />}
</Link>
);
})}
</nav>
{/* Footer */}
<div className="p-5 border-t border-ink-200 bg-ink-50">
<div className="flex items-center justify-between mb-4">
<p className="text-[10px] font-bold text-ink-400 uppercase tracking-widest">Appearance</p>
<div className={`border-t border-ink-200 bg-ink-50 transition-all duration-300 ${isCollapsed ? 'p-3' : 'p-5'}`}>
<div className={`flex items-center mb-4 transition-all duration-300 ${isCollapsed ? 'justify-center' : 'justify-between'}`}>
{!isCollapsed && <p className="text-[10px] font-bold text-ink-400 uppercase tracking-widest">Appearance</p>}
<button
onClick={toggleTheme}
className="p-2 rounded-lg bg-ink-0 border border-ink-200 text-ink-500 hover:text-ink-900 hover:shadow-sm transition-all group"
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" />}
</button>
</div>
<div className="flex flex-col gap-2 p-4 rounded-2xl bg-ink-0 border border-ink-200 mb-4 shadow-sm">
<div className="flex items-center justify-between mb-1">
<span className="text-[10px] font-bold uppercase tracking-widest text-ink-400">Status</span>
<div className={`flex items-center gap-1.5 px-2 py-0.5 rounded-full text-[10px] font-bold border ${isApproved ? 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20' : 'bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/20'}`}>
{isApproved ? <CheckCircle className="w-3 h-3" /> : <Clock className="w-3 h-3" />}
{isApproved ? 'Verified' : 'Pending'}
{!isCollapsed ? (
<div className="flex flex-col gap-2 p-4 rounded-2xl bg-ink-0 border border-ink-200 mb-4 shadow-sm">
<div className="flex items-center justify-between mb-1">
<span className="text-[10px] font-bold uppercase tracking-widest text-ink-400">Status</span>
<div className={`flex items-center gap-1.5 px-2 py-0.5 rounded-full text-[10px] font-bold border ${isApproved ? 'bg-emerald-500/10 text-emerald-600 dark:text-emerald-400 border-emerald-500/20' : 'bg-amber-500/10 text-amber-600 dark:text-amber-400 border-amber-500/20'}`}>
{isApproved ? <CheckCircle className="w-3 h-3" /> : <Clock className="w-3 h-3" />}
{isApproved ? 'Verified' : 'Pending'}
</div>
</div>
<p className="text-xs font-bold text-ink-900 truncate" title={user?.email || ''}>{user?.email}</p>
</div>
<p className="text-xs font-bold text-ink-900 truncate" title={user?.email || ''}>{user?.email}</p>
</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 relative" title={user?.email || ''}>
{user?.email?.charAt(0).toUpperCase()}
<span className={`absolute -bottom-1 -right-1 w-3 h-3 rounded-full border border-ink-0 ${isApproved ? 'bg-emerald-500' : 'bg-amber-500'}`} />
</div>
)}
<button
onClick={handleLogout}
className="w-full flex items-center justify-center gap-2 px-4 py-2.5 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"
className={`w-full flex items-center justify-center gap-2 rounded-xl text-sm font-bold tracking-wide text-red-650 hover:bg-red-500/10 transition-colors border border-transparent hover:border-red-200 cursor-pointer ${isCollapsed ? 'py-2 px-0' : 'px-4 py-2.5'}`}
title={isCollapsed ? "Sign Out" : undefined}
>
<LogOut className="w-4 h-4" />
<span>Sign Out</span>
{!isCollapsed && <span>Sign Out</span>}
</button>
</div>
</aside>
@ -148,13 +171,13 @@ export const ClientLayout: React.FC = () => {
)}
</AnimatePresence>
<div className="flex-1 w-full max-w-[1600px] px-6 py-10 md:px-12 mx-auto relative z-10">
<div className="flex-1 w-full max-w-[1600px] px-4 py-6 md:px-8 mx-auto relative z-10">
<Outlet />
</div>
{/* Footer */}
<footer className="border-t border-ink-200 bg-ink-0/50 backdrop-blur-md mt-auto">
<div className="max-w-[1600px] mx-auto px-6 md:px-12 py-6 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-4 flex flex-col md:flex-row justify-between items-center gap-4 text-xs font-medium text-ink-500">
<p>© 2026 Tech4Biz Solutions Inc. All rights reserved.</p>
<div className="flex gap-6">
<span className="hover:text-ink-900 cursor-pointer transition-colors">Security</span>

View File

@ -0,0 +1,35 @@
import React from 'react';
interface PageHeaderProps {
title: string;
subtitle?: string;
badge?: React.ReactNode;
actions?: React.ReactNode;
}
export const PageHeader: React.FC<PageHeaderProps> = ({ title, subtitle, badge, actions }) => {
return (
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 pb-4 border-b border-ink-200">
<div className="flex flex-col gap-1 min-w-0">
<div className="flex items-center gap-2.5 flex-wrap">
<h1 className="text-xl font-bold tracking-tight text-ink-900 truncate">
{title}
</h1>
{badge && <div className="shrink-0">{badge}</div>}
</div>
{subtitle && (
<p className="text-xs font-medium text-ink-500 truncate max-w-3xl">
{subtitle}
</p>
)}
</div>
{actions && (
<div className="flex items-center gap-2 shrink-0">
{actions}
</div>
)}
</div>
);
};
export default PageHeader;

View File

@ -0,0 +1,229 @@
import React from 'react';
import {
Globe,
FileText,
Image as ImageIcon,
File,
Eye,
MoreVertical,
Edit3,
Share2,
Trash2,
Lock,
ExternalLink,
Download,
Clock,
AlertCircle
} from 'lucide-react';
import type { Asset } from '../../../types/assets';
import type { User } from '../../../types/auth';
interface AssetCardProps {
asset: Asset;
user: User | null;
activeMenuId: string | null;
setActiveMenuId: (id: string | null) => void;
onViewDetails: (asset: Asset) => void;
onEdit: (asset: Asset) => void;
onShare: (asset: Asset) => void;
onDelete: (assetId: string) => void;
onOpenViewer: (asset: Asset) => void;
onDownload: (asset: Asset) => void;
onRequestDownload: (asset: Asset) => void;
}
export const AssetCard: React.FC<AssetCardProps> = ({
asset,
user,
activeMenuId,
setActiveMenuId,
onViewDetails,
onEdit,
onShare,
onDelete,
onOpenViewer,
onDownload,
onRequestDownload
}) => {
const isMenuOpen = activeMenuId === asset.id;
const canDirectDownload = user?.role === 'ADMIN' || asset.isDownloadable || asset.downloadRequests?.[0]?.status === 'APPROVED';
const requestStatus = asset.downloadRequests?.[0]?.status;
const formatBytes = (bytes: number, decimals = 2) => {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const dm = decimals < 0 ? 0 : decimals;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
};
const getAssetIcon = (type: string) => {
if (type === 'url') return Globe;
if (type.includes('pdf')) return FileText;
if (type.includes('image') || type.includes('png') || type.includes('jpg')) return ImageIcon;
return File;
};
const isRenderable = (type: string) => {
return type === 'url' || type.includes('pdf') || type.includes('image') || type.includes('png') || type.includes('jpg');
};
const Icon = getAssetIcon(asset.type);
return (
<div className="group relative bg-ink-0 border border-ink-200 rounded-xl p-4 hover:border-ink-300 transition-all duration-300 shadow-sm hover:shadow-md hover:-translate-y-0.5 flex flex-col justify-between">
<div>
<div className="flex justify-between items-start mb-3 relative">
<div className="w-10 h-10 rounded-lg flex items-center justify-center text-ink-900 bg-ink-100 border border-ink-200">
<Icon className="w-5 h-5" />
</div>
<div className="relative flex items-center gap-1.5">
{isRenderable(asset.type) && (
<button
onClick={() => onOpenViewer(asset)}
className="p-1 rounded-lg text-ink-500 hover:text-ink-900 hover:bg-ink-100 transition-colors"
title="Preview Online"
>
<Eye className="w-4 h-4" />
</button>
)}
<button
onClick={() => setActiveMenuId(isMenuOpen ? null : asset.id)}
className="p-1 rounded-lg text-ink-400 hover:text-ink-900 hover:bg-ink-100 transition-colors opacity-0 group-hover:opacity-100 focus:opacity-100"
>
<MoreVertical className="w-4 h-4" />
</button>
{isMenuOpen && (
<>
<div className="fixed inset-0 z-10" onClick={() => setActiveMenuId(null)} />
<div className="absolute right-0 mt-8 w-44 bg-ink-0 border border-ink-200 rounded-lg shadow-lg z-20 overflow-hidden py-1">
<button
onClick={() => {
onViewDetails(asset);
setActiveMenuId(null);
}}
className="w-full text-left px-4 py-2 text-xs font-semibold text-ink-700 hover:bg-ink-50 hover:text-ink-900 flex items-center gap-2"
>
<File className="w-3.5 h-3.5" />
View Details
</button>
{user?.role === 'ADMIN' && (
<>
<button
onClick={() => {
onEdit(asset);
setActiveMenuId(null);
}}
className="w-full text-left px-4 py-2 text-xs font-semibold text-ink-700 hover:bg-ink-50 hover:text-ink-900 flex items-center gap-2"
>
<Edit3 className="w-3.5 h-3.5" />
Edit Asset
</button>
<button
onClick={() => {
onShare(asset);
setActiveMenuId(null);
}}
className="w-full text-left px-4 py-2 text-xs font-semibold text-ink-700 hover:bg-ink-50 hover:text-ink-900 flex items-center gap-2"
>
<Share2 className="w-3.5 h-3.5" />
Share Settings
</button>
<button
onClick={() => {
onDelete(asset.id);
setActiveMenuId(null);
}}
className="w-full text-left px-4 py-2 text-xs font-semibold text-red-650 hover:bg-red-500/10 flex items-center gap-2"
>
<Trash2 className="w-3.5 h-3.5" />
Delete Asset
</button>
</>
)}
</div>
</>
)}
</div>
</div>
<div className="space-y-1 mb-4">
<div className="flex items-center gap-1.5 mb-2">
<div className="inline-block px-1.5 py-0.5 rounded text-[10px] font-bold text-ink-500 uppercase tracking-wider bg-ink-50 border border-ink-200">
{asset.categoryId || 'General'}
</div>
{!asset.isDownloadable && (
<div className="inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] font-bold text-ink-600 bg-ink-100 border border-ink-200">
<Lock className="w-2.5 h-2.5" />
<span>Strict View Only</span>
</div>
)}
</div>
<h3 className="font-bold text-ink-900 text-sm leading-snug line-clamp-2 group-hover:text-ink-950 transition-colors" title={asset.title}>
{asset.title}
</h3>
<p className="text-[11px] font-medium text-ink-400 mt-1">
{asset.type === 'url' ? 'External Link' : formatBytes(asset.size)} {new Date(asset.createdAt).toLocaleDateString()}
</p>
</div>
</div>
<div className="pt-3 border-t border-ink-100 flex items-center justify-between mt-auto">
<div className="flex flex-col">
<span className="text-[10px] font-semibold uppercase tracking-wider text-ink-400">
{asset.type === 'url' ? 'Type' : 'Downloads'}
</span>
<span className="text-xs font-extrabold text-ink-900 mt-0.5">
{asset.type === 'url' ? 'URL Link' : asset.downloadsCount}
</span>
</div>
{asset.type === 'url' ? (
<a
href={asset.url}
target="_blank"
rel="noreferrer"
className="w-7 h-7 rounded-md bg-ink-900 border border-ink-900 flex items-center justify-center text-ink-0 hover:bg-ink-800 transition-all shadow-sm"
title="Open External URL"
>
<ExternalLink className="w-3.5 h-3.5" />
</a>
) : canDirectDownload ? (
<button
onClick={() => onDownload(asset)}
className="w-7 h-7 rounded-md bg-ink-50 border border-ink-200 flex items-center justify-center text-ink-600 hover:bg-ink-100 hover:border-ink-300 hover:text-ink-900 transition-all shadow-sm"
title="Download Asset"
>
<Download className="w-3.5 h-3.5" />
</button>
) : requestStatus === 'PENDING' ? (
<div className="inline-flex items-center gap-1 text-[10px] font-bold text-ink-500 bg-ink-50 border border-ink-200 px-2 py-1 rounded-md">
<Clock className="w-3 h-3 text-ink-500 animate-pulse" />
<span>Pending Access</span>
</div>
) : requestStatus === 'REJECTED' ? (
<button
onClick={() => onRequestDownload(asset)}
className="inline-flex items-center gap-1 text-[10px] font-bold text-red-650 bg-red-500/10 border border-red-500/20 px-2 py-1 rounded-md hover:bg-red-500/25 transition-all"
>
<AlertCircle className="w-3 h-3" />
<span>Rejected (Retry)</span>
</button>
) : (
<button
onClick={() => onRequestDownload(asset)}
className="inline-flex items-center gap-1 text-[10px] font-bold text-ink-900 bg-ink-100 border border-ink-300 hover:bg-ink-200 px-2 py-1 rounded-md transition-all cursor-pointer"
>
<Lock className="w-3 h-3" />
<span>Request Download</span>
</button>
)}
</div>
</div>
);
};

View File

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

View File

@ -0,0 +1,345 @@
import React, { useState, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { X, Minimize2, Maximize2, Globe, ExternalLink, FileText, File, Download } from 'lucide-react';
import { axiosInstance } from '../../../services/axios';
import type { Asset } from '../../../types/assets';
import type { User } from '../../../types/auth';
interface AssetViewerModalProps {
isOpen: boolean;
onClose: () => void;
asset: Asset | null;
user: User | null;
onDownload: (asset: Asset) => void;
}
export const AssetViewerModal: React.FC<AssetViewerModalProps> = ({
isOpen,
onClose,
asset,
user,
onDownload
}) => {
const [isMaximized, setIsMaximized] = useState(false);
const [isLoadingText, setIsLoadingText] = useState(false);
const [textPreviewContent, setTextPreviewContent] = useState('');
const isLocalUrl = (url: string) => {
return url.includes('localhost') || url.includes('127.0.0.1');
};
const getFullAssetUrl = (url: string) => {
return url.startsWith('http')
? url
: `${axiosInstance.defaults.baseURL?.replace('/api/v1', '')}${url}`;
};
const getGithubRawUrl = (url: string) => {
let raw = url.trim();
if (raw.endsWith('.git')) {
raw = raw.slice(0, -4);
}
if (raw.endsWith('/')) {
raw = raw.slice(0, -1);
}
if (raw.includes('/blob/')) {
return raw.replace('github.com', 'raw.githubusercontent.com').replace('/blob/', '/');
}
const match = raw.match(/github\.com\/([^/]+)\/([^/]+)/);
if (match && match[1] && match[2]) {
const owner = match[1];
const repo = match[2];
return {
owner,
repo,
mainUrl: `https://raw.githubusercontent.com/${owner}/${repo}/main/README.md`,
masterUrl: `https://raw.githubusercontent.com/${owner}/${repo}/master/README.md`,
};
}
return null;
};
useEffect(() => {
if (isOpen && asset) {
setTextPreviewContent('');
setIsLoadingText(false);
const assetUrl = asset.url.toLowerCase();
if (
asset.type !== 'url' &&
(assetUrl.endsWith('.md') ||
assetUrl.endsWith('.txt') ||
asset.type.includes('text') ||
asset.type.includes('markdown'))
) {
setIsLoadingText(true);
axiosInstance.get(getFullAssetUrl(asset.url), { responseType: 'text' })
.then(res => {
setTextPreviewContent(res.data);
setIsLoadingText(false);
})
.catch(err => {
console.error(err);
setTextPreviewContent('Failed to load text preview.');
setIsLoadingText(false);
});
}
else if (asset.type === 'url' && asset.url.includes('github.com')) {
const ghInfo = getGithubRawUrl(asset.url);
if (ghInfo) {
setIsLoadingText(true);
const targetUrl = typeof ghInfo === 'string' ? ghInfo : ghInfo.mainUrl;
fetch(targetUrl)
.then(async (res) => {
if (!res.ok && typeof ghInfo !== 'string') {
const fallbackRes = await fetch(ghInfo.masterUrl);
if (fallbackRes.ok) {
return fallbackRes.text();
}
throw new Error('README not found');
}
return res.text();
})
.then(data => {
setTextPreviewContent(data);
setIsLoadingText(false);
})
.catch(err => {
console.error(err);
setTextPreviewContent(`### GitHub Repository: ${typeof ghInfo === 'string' ? asset.url : `${ghInfo.owner}/${ghInfo.repo}`}\n\nCould not fetch README.md automatically. The repository might be private or not contain a README.md file.`);
setIsLoadingText(false);
});
}
}
}
}, [isOpen, asset]);
const formatBytes = (bytes: number, decimals = 2) => {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const dm = decimals < 0 ? 0 : decimals;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
};
const handleClose = () => {
setIsMaximized(false);
onClose();
};
return (
<AnimatePresence>
{isOpen && asset && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={handleClose}
className="absolute inset-0 bg-ink-950/60 backdrop-blur-md"
/>
<motion.div
initial={{ opacity: 0, scale: 0.98, y: 15 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.98, y: 15 }}
className={`relative bg-ink-0 border border-ink-250 rounded-2xl p-6 shadow-xl z-10 flex flex-col overflow-hidden gap-4 transition-all duration-300 ${
isMaximized ? 'w-[96vw] h-[92vh] max-h-[92vh] max-w-none' : 'max-w-5xl w-full max-h-[85vh]'
}`}
>
<div className="flex justify-between items-center pb-3 border-b border-ink-100 flex-shrink-0">
<div>
<h3 className="text-base font-bold text-ink-900">{asset.title}</h3>
<p className="text-xs text-ink-500">
{asset.type === 'url' ? 'External Web Link' : `${asset.type}${formatBytes(asset.size)}`}
</p>
</div>
<div className="flex items-center gap-1.5">
<button
onClick={() => setIsMaximized(!isMaximized)}
className="p-1.5 rounded-lg text-ink-400 hover:text-ink-900 hover:bg-ink-50 transition-colors"
title={isMaximized ? "Collapse view" : "Expand view"}
>
{isMaximized ? <Minimize2 className="w-5 h-5" /> : <Maximize2 className="w-5 h-5" />}
</button>
<button
onClick={handleClose}
className="p-1 rounded-lg text-ink-400 hover:text-ink-900 hover:bg-ink-50 transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
</div>
<div className="flex-1 w-full bg-ink-50 rounded-xl flex flex-col items-center justify-center overflow-hidden border border-ink-200 min-h-0">
{asset.type === 'url' && asset.url.includes('github.com') ? (
<div className="w-full h-full flex flex-col min-h-0 bg-ink-0">
<div className="bg-ink-100 px-4 py-2 border-b border-ink-200 flex justify-between items-center text-xs text-ink-600 font-bold select-none">
<span className="flex items-center gap-1.5">
<Globe className="w-4 h-4 text-ink-950" />
<span>Embedded GitHub Document</span>
</span>
</div>
<div className="flex-1 overflow-auto p-6 text-left select-text bg-ink-0 text-ink-800 font-sans leading-relaxed">
{isLoadingText ? (
<div className="flex flex-col items-center justify-center h-full space-y-3">
<div className="w-6 h-6 rounded-full border-4 border-ink-900 border-t-transparent animate-spin" />
<span className="text-xs text-ink-500 font-medium">Fetching README.md...</span>
</div>
) : (
<div className="max-w-3xl mx-auto space-y-4">
<div className="border-b border-ink-200 pb-4 mb-6">
<h1 className="text-xl font-extrabold text-ink-950">{asset.title}</h1>
<p className="text-xs text-ink-500 mt-1 font-mono">{asset.url}</p>
</div>
<pre className="whitespace-pre-wrap font-sans text-sm text-ink-800 break-words leading-relaxed">
{textPreviewContent}
</pre>
</div>
)}
</div>
</div>
) : asset.type === 'url' ? (
<div className="text-center p-12 max-w-md space-y-4 flex flex-col justify-center items-center">
<div className="w-16 h-16 rounded-2xl bg-ink-100 border border-ink-200 flex items-center justify-center text-ink-900 shadow-sm">
<Globe className="w-8 h-8" />
</div>
<div>
<h4 className="text-sm font-bold text-ink-900">External Resource Portal</h4>
<p className="text-xs text-ink-500 mt-2 leading-relaxed">
This asset points to an external destination outside of the local CDN container.
</p>
<div className="bg-ink-100 border border-ink-200 rounded-xl px-4 py-2 text-xs font-mono text-ink-600 truncate mt-3 max-w-sm">
{asset.url}
</div>
</div>
{(user?.role === 'ADMIN' || asset.isDownloadable || asset.downloadRequests?.[0]?.status === 'APPROVED') ? (
<a
href={asset.url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center justify-center gap-2 px-6 py-2.5 rounded-lg bg-ink-900 text-ink-0 text-xs font-bold hover:bg-ink-800 transition-all shadow-sm w-full"
>
<span>Open Link in New Tab</span>
<ExternalLink className="w-4 h-4" />
</a>
) : (
<div className="bg-amber-50 border border-amber-205 text-amber-900 rounded-xl p-4 text-xs text-center font-medium max-w-sm">
Access Restricted: You must request and receive download approval from the Administrator to open this resource link.
</div>
)}
</div>
) : asset.type.includes('pdf') ? (
<div className="w-full h-full flex flex-col min-h-0">
<div className="bg-ink-100 px-4 py-2 border-b border-ink-200 flex items-center text-xs text-ink-600 font-bold select-none">
<FileText className="w-4 h-4 text-ink-950 mr-1.5" />
<span>Interactive PDF Preview</span>
</div>
<iframe
src={getFullAssetUrl(asset.url)}
className="w-full flex-1 border-0 min-h-0"
title={asset.title}
/>
</div>
) : (asset.url.toLowerCase().endsWith('.md') || asset.url.toLowerCase().endsWith('.txt') || asset.type.includes('text') || asset.type.includes('markdown')) ? (
<div className="w-full h-full flex flex-col min-h-0 bg-ink-0">
<div className="bg-ink-100 px-4 py-2 border-b border-ink-200 flex items-center text-xs text-ink-600 font-bold select-none">
<FileText className="w-4 h-4 text-ink-950 mr-1.5" />
<span>Document Reader</span>
</div>
<div className="flex-1 overflow-auto p-6 text-left select-text bg-ink-0 text-ink-800 font-sans leading-relaxed">
{isLoadingText ? (
<div className="flex flex-col items-center justify-center h-full space-y-3">
<div className="w-6 h-6 rounded-full border-4 border-ink-900 border-t-transparent animate-spin" />
<span className="text-xs text-ink-500 font-medium">Loading content...</span>
</div>
) : (
<div className="max-w-3xl mx-auto space-y-4">
<div className="border-b border-ink-200 pb-4 mb-6">
<h1 className="text-xl font-extrabold text-ink-950">{asset.title}</h1>
<p className="text-xs text-ink-500 mt-1">Plain Text / Markdown Format</p>
</div>
<pre className="whitespace-pre-wrap font-sans text-sm text-ink-800 break-words leading-relaxed">
{textPreviewContent}
</pre>
</div>
)}
</div>
</div>
) : (asset.type.includes('word') || asset.type.includes('presentation') || asset.type.includes('sheet') || asset.url.toLowerCase().endsWith('.docx') || asset.url.toLowerCase().endsWith('.doc') || asset.url.toLowerCase().endsWith('.pptx') || asset.url.toLowerCase().endsWith('.ppt') || asset.url.toLowerCase().endsWith('.xlsx') || asset.url.toLowerCase().endsWith('.xls')) ? (
<div className="w-full h-full flex flex-col min-h-0 bg-ink-0">
<div className="bg-ink-100 px-4 py-2 border-b border-ink-200 flex items-center text-xs text-ink-600 font-bold select-none">
<FileText className="w-4 h-4 text-ink-950 mr-1.5" />
<span>Office Document Preview</span>
</div>
{isLocalUrl(asset.url) ? (
<div className="flex-1 p-8 text-center flex flex-col justify-center items-center max-w-lg mx-auto space-y-4 bg-ink-0">
<div className="w-16 h-16 rounded-2xl bg-ink-55 border border-ink-100 flex items-center justify-center text-ink-900 shadow-sm">
<FileText className="w-8 h-8" />
</div>
<div>
<h4 className="text-sm font-bold text-ink-900">Office Document Preview</h4>
<p className="text-xs text-ink-500 mt-2 leading-relaxed">
This asset is a Microsoft Office document ({asset.type.split('/').pop()?.toUpperCase() || 'DOCX'}).
</p>
<p className="text-xs text-ink-500 mt-2 leading-relaxed bg-ink-50 border border-ink-100 rounded-xl p-3 text-left">
<strong>Note:</strong> Microsoft Office Online Viewer is optimized for staging/production environments. In development (localhost), external services cannot fetch local files. Please download this asset using the button below to view it locally.
</p>
</div>
</div>
) : (
<iframe
src={`https://view.officeapps.live.com/op/embed.aspx?src=${encodeURIComponent(getFullAssetUrl(asset.url))}`}
className="w-full flex-1 border-0 min-h-0"
title={asset.title}
/>
)}
</div>
) : asset.type.includes('image') || asset.type.includes('png') || asset.type.includes('jpg') ? (
<div className="w-full h-full flex items-center justify-center p-4">
<img
src={getFullAssetUrl(asset.url)}
alt={asset.title}
className="max-w-full max-h-full object-contain rounded-xl shadow-sm border border-ink-100"
/>
</div>
) : (
<div className="text-center p-8 flex flex-col justify-center items-center">
<File className="w-12 h-12 text-ink-300 mb-3" />
<h4 className="text-sm font-bold text-ink-900">Direct Preview Unsupported</h4>
<p className="text-xs text-ink-500 mt-1 max-w-sm">
This file format cannot be rendered directly in the browser. Please download the file to inspect its contents.
</p>
</div>
)}
</div>
<div className="pt-3 border-t border-ink-100 flex justify-end gap-3 flex-shrink-0">
<button
onClick={handleClose}
className="px-4 py-2 rounded-lg border border-ink-200 text-ink-700 text-xs font-bold hover:bg-ink-50 transition-colors"
>
Close Preview
</button>
{asset.type !== 'url' && (user?.role === 'ADMIN' || asset.isDownloadable || asset.downloadRequests?.[0]?.status === 'APPROVED') && (
<button
onClick={() => onDownload(asset)}
className="px-5 py-2 rounded-lg bg-ink-900 text-ink-0 text-xs font-bold hover:bg-ink-800 transition-colors shadow-sm flex items-center gap-2"
>
<Download className="w-4 h-4" />
<span>Download File</span>
</button>
)}
</div>
</motion.div>
</div>
)}
</AnimatePresence>
);
};

View File

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

View File

@ -0,0 +1,207 @@
import React, { useState, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { X } from 'lucide-react';
import { updateAsset } from '../../../services/assets-api';
import type { Asset } from '../../../types/assets';
interface EditAssetModalProps {
isOpen: boolean;
onClose: () => void;
asset: Asset | null;
onSuccess: () => void;
}
export const EditAssetModal: React.FC<EditAssetModalProps> = ({
isOpen,
onClose,
asset,
onSuccess
}) => {
const [editTitle, setEditTitle] = useState('');
const [editDescription, setEditDescription] = useState('');
const [editCategory, setEditCategory] = useState('Marketing');
const [editSubcategory, setEditSubcategory] = useState('');
const [editTags, setEditTags] = useState('');
const [editGithubUrl, setEditGithubUrl] = useState('');
const [editIsDownloadable, setEditIsDownloadable] = useState(true);
const [isSavingEdit, setIsSavingEdit] = useState(false);
useEffect(() => {
if (asset) {
setEditTitle(asset.title);
setEditDescription(asset.description || '');
setEditCategory(asset.categoryId || 'Marketing');
setEditSubcategory(asset.subcategory || '');
setEditTags(asset.tags.join(', '));
setEditGithubUrl(asset.githubUrl || '');
setEditIsDownloadable(asset.isDownloadable);
}
}, [asset]);
const handleEditSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!asset) return;
setIsSavingEdit(true);
try {
await updateAsset(asset.id, {
title: editTitle,
description: editDescription,
categoryId: editCategory,
subcategory: editSubcategory,
tags: editTags.split(',').map(t => t.trim()).filter(Boolean),
githubUrl: editGithubUrl,
isDownloadable: editIsDownloadable,
});
onSuccess();
onClose();
} catch (err) {
console.error('Failed to save asset details', err);
} finally {
setIsSavingEdit(false);
}
};
return (
<AnimatePresence>
{isOpen && asset && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={onClose}
className="absolute inset-0 bg-ink-950/40 backdrop-blur-sm"
/>
<motion.div
initial={{ opacity: 0, scale: 0.95, y: 10 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 10 }}
className="relative bg-ink-0 border border-ink-200 rounded-2xl p-6 max-w-lg w-full max-h-[90vh] shadow-xl z-10 flex flex-col overflow-hidden"
>
<div className="flex justify-between items-center pb-3.5 border-b border-ink-100 flex-shrink-0">
<h3 className="text-lg font-bold text-ink-900">Edit Asset Details</h3>
<button
onClick={onClose}
className="p-1 rounded-lg text-ink-400 hover:text-ink-900 hover:bg-ink-50 transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
<form onSubmit={handleEditSubmit} className="flex-1 min-h-0 flex flex-col mt-4">
<div className="flex-1 overflow-y-auto pr-1 space-y-4 scrollbar-thin">
<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"
>
<option value="Marketing">Marketing</option>
<option value="Presentations">Presentations</option>
<option value="Branding">Branding</option>
<option value="Resources">Resources</option>
<option value="Technical">Technical</option>
</select>
</div>
<div>
<label className="text-xs font-semibold text-ink-500 mb-1.5 block">Subcategory</label>
<input
type="text"
value={editSubcategory}
onChange={(e) => setEditSubcategory(e.target.value)}
placeholder="e.g. Slide Deck"
className="w-full bg-ink-50 border border-ink-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ink-900/10 text-ink-900 placeholder-ink-400"
/>
</div>
</div>
<div>
<label className="text-xs font-semibold text-ink-500 mb-1.5 block">Description</label>
<textarea
value={editDescription}
onChange={(e) => setEditDescription(e.target.value)}
placeholder="Enter short description..."
rows={3}
className="w-full bg-ink-50 border border-ink-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ink-900/10 text-ink-900 placeholder-ink-400 resize-none"
/>
</div>
{asset.type !== 'url' && (
<div className="flex items-center gap-3 p-3 bg-ink-50 border border-ink-200 rounded-lg">
<input
type="checkbox"
id="editIsDownloadable"
checked={editIsDownloadable}
onChange={(e) => setEditIsDownloadable(e.target.checked)}
className="w-4 h-4 rounded border-ink-300 text-ink-900 focus:ring-ink-900/10 cursor-pointer"
/>
<div>
<label htmlFor="editIsDownloadable" className="text-xs font-bold text-ink-900 cursor-pointer block">
Allow Direct Download (Strict View Only if unchecked)
</label>
<span className="text-[10px] text-ink-500">
Toggle client authorization requirement for asset downloads.
</span>
</div>
</div>
)}
<div>
<label className="text-xs font-semibold text-ink-500 mb-1.5 block">Tags (comma-separated)</label>
<input
type="text"
value={editTags}
onChange={(e) => setEditTags(e.target.value)}
placeholder="branding, guideline, pitch"
className="w-full bg-ink-50 border border-ink-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ink-900/10 text-ink-900 placeholder-ink-400"
/>
</div>
<div>
<label className="text-xs font-semibold text-ink-500 mb-1.5 block">GitHub/Documentation URL (Optional)</label>
<input
type="url"
value={editGithubUrl}
onChange={(e) => setEditGithubUrl(e.target.value)}
placeholder="https://github.com/..."
className="w-full bg-ink-50 border border-ink-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ink-900/10 text-ink-900 placeholder-ink-400"
/>
</div>
</div>
<div className="pt-3.5 border-t border-ink-100 flex justify-end gap-3 flex-shrink-0 mt-4">
<button
type="button"
onClick={onClose}
className="px-4 py-2 rounded-lg border border-ink-200 text-ink-700 text-xs font-bold hover:bg-ink-50 transition-colors"
>
Cancel
</button>
<button
type="submit"
disabled={isSavingEdit}
className="px-5 py-2 rounded-lg bg-ink-900 text-ink-0 text-xs font-bold hover:bg-ink-800 transition-colors shadow-sm disabled:opacity-50"
>
{isSavingEdit ? 'Saving...' : 'Save Changes'}
</button>
</div>
</form>
</motion.div>
</div>
)}
</AnimatePresence>
);
};

View File

@ -0,0 +1,225 @@
import React, { useState, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { X, ChevronDown, ChevronUp } from 'lucide-react';
import { updateAsset } from '../../../services/assets-api';
import type { Asset, Organization, ShareItem } from '../../../types/assets';
interface ShareAssetModalProps {
isOpen: boolean;
onClose: () => void;
asset: Asset | null;
organizations: Organization[];
onSuccess: () => void;
}
export const ShareAssetModal: React.FC<ShareAssetModalProps> = ({
isOpen,
onClose,
asset,
organizations,
onSuccess
}) => {
const [sharesList, setSharesList] = useState<ShareItem[]>([]);
const [isSavingShare, setIsSavingShare] = useState(false);
const [expandedOrgId, setExpandedOrgId] = useState<string | null>(null);
useEffect(() => {
if (asset) {
setSharesList(
asset.sharedWith?.map(s => ({
organizationId: s.organizationId,
userId: s.userId
})) || []
);
}
}, [asset]);
const isOrgSharedEntirely = (orgId: string) => {
return sharesList.some(s => s.organizationId === orgId && s.userId === null);
};
const isUserSharedSpecifically = (orgId: string, userId: string) => {
return sharesList.some(s => s.organizationId === orgId && s.userId === userId);
};
const handleToggleOrg = (orgId: string) => {
const isShared = isOrgSharedEntirely(orgId);
if (isShared) {
setSharesList(prev => prev.filter(s => s.organizationId !== orgId));
} else {
setSharesList(prev => [
...prev.filter(s => s.organizationId !== orgId),
{ organizationId: orgId, userId: null }
]);
}
};
const handleToggleUser = (orgId: string, userId: string) => {
const isShared = isUserSharedSpecifically(orgId, userId);
if (isShared) {
setSharesList(prev => prev.filter(s => !(s.organizationId === orgId && s.userId === userId)));
} else {
setSharesList(prev => [
...prev.filter(s => !(s.organizationId === orgId && s.userId === null)),
{ organizationId: orgId, userId }
]);
}
};
const handleShareSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!asset) return;
setIsSavingShare(true);
try {
await updateAsset(asset.id, {
shares: sharesList
});
onSuccess();
onClose();
} catch (err) {
console.error('Failed to update share permissions', err);
} finally {
setIsSavingShare(false);
}
};
return (
<AnimatePresence>
{isOpen && asset && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={onClose}
className="absolute inset-0 bg-ink-950/40 backdrop-blur-sm"
/>
<motion.div
initial={{ opacity: 0, scale: 0.95, y: 10 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 10 }}
className="relative bg-ink-0 border border-ink-200 rounded-2xl p-6 max-w-lg w-full shadow-xl z-10 flex flex-col max-h-[85vh] overflow-hidden"
>
<div className="flex justify-between items-center pb-3.5 border-b border-ink-100 flex-shrink-0">
<div>
<h3 className="text-lg font-bold text-ink-900">Share Settings</h3>
<p className="text-xs text-ink-500 mt-0.5">{asset.title}</p>
</div>
<button
onClick={onClose}
className="p-1 rounded-lg text-ink-400 hover:text-ink-900 hover:bg-ink-50 transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
<form onSubmit={handleShareSubmit} className="flex-1 min-h-0 flex flex-col mt-4">
<div className="flex-1 overflow-y-auto pr-1 space-y-4 scrollbar-thin">
<p className="text-xs text-ink-600 leading-relaxed">
Select organizations or expand to specify exact users that can access this asset:
</p>
<div className="max-h-64 overflow-y-auto border border-ink-200 rounded-xl divide-y divide-ink-250 bg-ink-50 scrollbar-thin">
{organizations.length === 0 ? (
<p className="p-4 text-xs text-ink-500 text-center font-medium">No partner organizations registered yet.</p>
) : (
organizations.map(org => {
const isEntireShared = isOrgSharedEntirely(org.id);
const isExpanded = expandedOrgId === org.id;
const activeUsers = org.users || [];
const specificSharedCount = sharesList.filter(s => s.organizationId === org.id && s.userId !== null).length;
return (
<div key={org.id} className="flex flex-col">
<div className="flex items-center justify-between p-3 hover:bg-ink-100 transition-colors">
<label className="flex items-center gap-3 cursor-pointer flex-1 select-none">
<input
type="checkbox"
checked={isEntireShared}
onChange={() => handleToggleOrg(org.id)}
className="w-4 h-4 rounded border-ink-300 text-ink-900 focus:ring-ink-900/10 cursor-pointer"
/>
<div className="flex flex-col">
<span className="text-xs font-bold text-ink-900">{org.name}</span>
{specificSharedCount > 0 && !isEntireShared && (
<span className="text-[10px] text-ink-500 font-semibold">
Shared with {specificSharedCount} specific {specificSharedCount === 1 ? 'user' : 'users'}
</span>
)}
</div>
</label>
<button
type="button"
onClick={() => setExpandedOrgId(isExpanded ? null : org.id)}
className="p-1 rounded-lg text-ink-400 hover:text-ink-900 hover:bg-ink-200 transition-colors flex items-center gap-1 text-[11px] font-bold"
>
<span>Users</span>
{isExpanded ? <ChevronUp className="w-3.5 h-3.5" /> : <ChevronDown className="w-3.5 h-3.5" />}
</button>
</div>
<AnimatePresence>
{isExpanded && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
className="bg-ink-100 border-t border-b border-ink-200 overflow-hidden divide-y divide-ink-150"
>
{activeUsers.length === 0 ? (
<p className="p-3 text-[10px] text-ink-500 italic">No users found in this organization.</p>
) : (
activeUsers.map(userItem => {
const isUserShared = isUserSharedSpecifically(org.id, userItem.id);
return (
<label key={userItem.id} className="flex items-center gap-3 py-2 px-8 cursor-pointer hover:bg-ink-200/50 transition-all select-none">
<input
type="checkbox"
disabled={isEntireShared}
checked={isEntireShared || isUserShared}
onChange={() => handleToggleUser(org.id, userItem.id)}
className="w-3.5 h-3.5 rounded border-ink-300 text-ink-900 focus:ring-ink-900/10 cursor-pointer disabled:opacity-50"
/>
<span className={`text-[11px] font-semibold ${isEntireShared ? 'text-ink-400' : 'text-ink-800'}`}>
{userItem.email}
</span>
</label>
);
})
)}
</motion.div>
)}
</AnimatePresence>
</div>
);
})
)}
</div>
</div>
<div className="pt-3.5 border-t border-ink-100 flex justify-end gap-3 flex-shrink-0 mt-4">
<button
type="button"
onClick={onClose}
className="px-4 py-2 rounded-lg border border-ink-200 text-ink-700 text-xs font-bold hover:bg-ink-50 transition-colors"
>
Cancel
</button>
<button
type="submit"
disabled={isSavingShare}
className="px-5 py-2 rounded-lg bg-ink-900 text-ink-0 text-xs font-bold hover:bg-ink-800 transition-colors shadow-sm disabled:opacity-50"
>
{isSavingShare ? 'Saving...' : 'Update Shares'}
</button>
</div>
</form>
</motion.div>
</div>
)}
</AnimatePresence>
);
};

View File

@ -0,0 +1,410 @@
import React, { useState, useEffect } from 'react';
import { motion, AnimatePresence } from 'framer-motion';
import { X, UploadCloud, Eye, FileText, File } from 'lucide-react';
import { uploadAsset } from '../../../services/assets-api';
interface UploadAssetModalProps {
isOpen: boolean;
onClose: () => void;
onSuccess: () => void;
}
export const UploadAssetModal: React.FC<UploadAssetModalProps> = ({
isOpen,
onClose,
onSuccess
}) => {
const [uploadTab, setUploadTab] = useState<'file' | 'url'>('file');
const [uploadFile, setUploadFile] = useState<File | null>(null);
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
const [fullImagePreviewUrl, setFullImagePreviewUrl] = useState<string | null>(null);
const [uploadUrl, setUploadUrl] = useState('');
const [uploadTitle, setUploadTitle] = useState('');
const [uploadDescription, setUploadDescription] = useState('');
const [uploadCategory, setUploadCategory] = useState('Marketing');
const [uploadSubcategory, setUploadSubcategory] = useState('');
const [uploadTags, setUploadTags] = useState('');
const [uploadGithubUrl, setUploadGithubUrl] = useState('');
const [uploadIsDownloadable, setUploadIsDownloadable] = useState(true);
const [isUploading, setIsUploading] = useState(false);
useEffect(() => {
if (!uploadFile) {
setPreviewUrl(null);
setFullImagePreviewUrl(null);
return;
}
if (uploadFile.type.startsWith('image/')) {
const url = URL.createObjectURL(uploadFile);
setPreviewUrl(url);
return () => {
URL.revokeObjectURL(url);
};
} else {
setPreviewUrl(null);
setFullImagePreviewUrl(null);
}
}, [uploadFile]);
const formatBytes = (bytes: number, decimals = 2) => {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const dm = decimals < 0 ? 0 : decimals;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
};
const handleUploadSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (uploadTab === 'file' && !uploadFile) return;
if (uploadTab === 'url' && !uploadUrl) return;
setIsUploading(true);
const formData = new FormData();
if (uploadTab === 'file' && uploadFile) {
formData.append('file', uploadFile);
} else {
formData.append('isUrlAsset', 'true');
formData.append('url', uploadUrl);
}
formData.append('title', uploadTitle || (uploadFile ? uploadFile.name : uploadUrl));
formData.append('description', uploadDescription);
formData.append('categoryId', uploadCategory);
formData.append('subcategory', uploadSubcategory);
formData.append('tags', JSON.stringify(uploadTags.split(',').map(t => t.trim()).filter(Boolean)));
formData.append('githubUrl', uploadGithubUrl);
formData.append('isDownloadable', String(uploadIsDownloadable));
try {
await uploadAsset(formData);
setUploadFile(null);
setUploadUrl('');
setUploadTitle('');
setUploadDescription('');
setUploadCategory('Marketing');
setUploadSubcategory('');
setUploadTags('');
setUploadGithubUrl('');
setUploadIsDownloadable(true);
onSuccess();
onClose();
} catch (err) {
console.error('Failed to upload asset', err);
} finally {
setIsUploading(false);
}
};
return (
<>
<AnimatePresence>
{isOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={onClose}
className="absolute inset-0 bg-ink-950/40 backdrop-blur-sm"
/>
<motion.div
initial={{ opacity: 0, scale: 0.95, y: 10 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 10 }}
className="relative bg-ink-0 border border-ink-200 rounded-2xl p-6 max-w-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">
<h3 className="text-lg font-bold text-ink-900">Upload / Link Asset</h3>
<button
onClick={onClose}
className="p-1 rounded-lg text-ink-400 hover:text-ink-900 hover:bg-ink-50 transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Toggle upload tabs */}
<div className="flex bg-ink-50 p-1 rounded-xl border border-ink-200 flex-shrink-0 mt-4">
<button
type="button"
onClick={() => setUploadTab('file')}
className={`flex-1 py-1.5 text-xs font-semibold rounded-lg transition-all ${uploadTab === 'file' ? 'bg-ink-0 text-ink-900 shadow-sm border border-ink-200' : 'text-ink-500 hover:text-ink-800'}`}
>
Secure File Upload
</button>
<button
type="button"
onClick={() => setUploadTab('url')}
className={`flex-1 py-1.5 text-xs font-semibold rounded-lg transition-all ${uploadTab === 'url' ? 'bg-ink-0 text-ink-900 shadow-sm border border-ink-200' : 'text-ink-500 hover:text-ink-800'}`}
>
External Web URL
</button>
</div>
<form onSubmit={handleUploadSubmit} className="flex-1 min-h-0 flex flex-col mt-4">
<div className="flex-1 overflow-y-auto pr-1 space-y-4 scrollbar-thin">
{uploadTab === 'file' ? (
<div className={`border-2 border-dashed border-ink-200 hover:border-ink-400 rounded-xl p-5 text-center transition-colors relative bg-ink-50 max-h-48 overflow-y-auto scrollbar-thin ${!uploadFile ? 'cursor-pointer' : ''}`}>
{!uploadFile && (
<input
type="file"
onChange={(e) => {
if (e.target.files?.[0]) {
setUploadFile(e.target.files[0]);
setUploadTitle(e.target.files[0].name);
}
}}
required={uploadTab === 'file'}
className="absolute inset-0 opacity-0 cursor-pointer z-10"
/>
)}
{uploadFile && !previewUrl && (
<button
type="button"
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
setUploadFile(null);
}}
className="absolute top-2 right-2 p-1 rounded-lg bg-ink-0 hover:bg-ink-100 text-ink-500 hover:text-ink-900 border border-ink-200 transition-colors z-20 shadow-sm"
title="Remove file"
>
<X className="w-3.5 h-3.5" />
</button>
)}
{previewUrl && uploadFile ? (
<div className="relative z-20 py-1">
<div className="relative w-24 h-24 mx-auto mb-3">
<img
src={previewUrl}
alt="Upload preview"
className="w-full h-full object-cover rounded-lg shadow-sm border border-ink-200"
/>
</div>
<div className="flex items-center justify-between gap-2 max-w-xs mx-auto px-2.5 py-1.5 bg-ink-0 border border-ink-200 rounded-lg shadow-sm relative z-30 mb-1.5">
<span className="text-[11px] font-semibold text-ink-900 truncate flex-1 text-left" title={uploadFile?.name}>
{uploadFile?.name}
</span>
<div className="flex items-center gap-1 flex-shrink-0">
<button
type="button"
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
setFullImagePreviewUrl(previewUrl);
}}
className="p-1 rounded-lg bg-ink-55 hover:bg-ink-100 text-ink-600 hover:text-ink-950 border border-ink-200 transition-all duration-200 hover:scale-105 active:scale-95 flex items-center justify-center"
title="Preview Image"
>
<Eye className="w-3 h-3" />
</button>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
e.preventDefault();
setUploadFile(null);
}}
className="p-1 rounded-lg bg-ink-55 hover:bg-red-500/10 text-ink-500 hover:text-red-650 border border-ink-200 transition-all duration-200 hover:scale-105 active:scale-95 flex items-center justify-center"
title="Remove File"
>
<X className="w-3 h-3" />
</button>
</div>
</div>
<p className="text-[10px] text-ink-500">
{formatBytes(uploadFile?.size || 0)}
</p>
</div>
) : uploadFile ? (
<div className="relative z-0 py-1">
<div className="w-12 h-12 rounded-lg bg-ink-100 border border-ink-200 flex items-center justify-center mx-auto mb-2">
{uploadFile?.name?.endsWith('.pdf') ? (
<FileText className="w-6 h-6 text-ink-600" />
) : (
<File className="w-6 h-6 text-ink-600" />
)}
</div>
<p className="text-xs font-bold text-ink-900 truncate max-w-xs mx-auto">
{uploadFile?.name}
</p>
<p className="text-[10px] text-ink-500 mt-0.5">
{formatBytes(uploadFile?.size || 0)}
</p>
</div>
) : (
<div className="relative z-0">
<UploadCloud className="w-8 h-8 text-ink-400 mx-auto mb-1.5" />
<p className="text-xs font-bold text-ink-900">
Drag & drop or click to upload file
</p>
<p className="text-[10px] text-ink-500 mt-0.5">PDF, ZIP, PNG, JPG up to 50MB</p>
</div>
)}
</div>
) : (
<div>
<label className="text-xs font-semibold text-ink-500 mb-1.5 block">External Asset URL</label>
<input
type="url"
value={uploadUrl}
onChange={(e) => setUploadUrl(e.target.value)}
placeholder="https://example.com/partner-docs"
required={uploadTab === 'url'}
className="w-full bg-ink-50 border border-ink-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ink-900/10 text-ink-900 placeholder-ink-400"
/>
</div>
)}
<div>
<label className="text-xs font-semibold text-ink-500 mb-1.5 block">Asset Title</label>
<input
type="text"
value={uploadTitle}
onChange={(e) => setUploadTitle(e.target.value)}
placeholder="Enter descriptive title"
className="w-full bg-ink-50 border border-ink-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ink-900/10 text-ink-900 placeholder-ink-400"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="text-xs font-semibold text-ink-500 mb-1.5 block">Category</label>
<select
value={uploadCategory}
onChange={(e) => setUploadCategory(e.target.value)}
className="w-full bg-ink-50 border border-ink-200 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ink-900/10 text-ink-900"
>
<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>
</div>
<div className="pt-3.5 border-t border-ink-100 flex justify-end gap-3 flex-shrink-0 mt-4">
<button
type="button"
onClick={onClose}
className="px-4 py-2 rounded-lg border border-ink-200 text-ink-700 text-xs font-bold hover:bg-ink-50 transition-colors"
>
Cancel
</button>
<button
type="submit"
disabled={isUploading}
className="px-5 py-2 rounded-lg bg-ink-900 text-ink-0 text-xs font-bold hover:bg-ink-800 transition-colors shadow-sm disabled:opacity-50"
>
{isUploading ? 'Publishing...' : 'Publish Asset'}
</button>
</div>
</form>
</motion.div>
</div>
)}
</AnimatePresence>
{/* Full Local Image Preview Modal */}
<AnimatePresence>
{fullImagePreviewUrl && (
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4">
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={() => setFullImagePreviewUrl(null)}
className="absolute inset-0 bg-ink-950/85 backdrop-blur-md"
/>
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
className="relative max-w-[95vw] max-h-[90vh] z-10 flex flex-col items-center justify-center"
>
<button
onClick={() => setFullImagePreviewUrl(null)}
className="absolute -top-10 right-0 p-1.5 rounded-lg bg-ink-0 hover:bg-ink-100 text-ink-900 border border-ink-200 shadow-lg transition-colors z-20"
title="Close"
>
<X className="w-4 h-4" />
</button>
<img
src={fullImagePreviewUrl}
alt="Full preview"
className="max-w-full max-h-[80vh] object-contain rounded-xl shadow-2xl border border-ink-200 bg-ink-50"
/>
</motion.div>
</div>
)}
</AnimatePresence>
</>
);
};

View File

@ -0,0 +1,21 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import type { UseMutationResult } from "@tanstack/react-query";
import { approvePartnerAccess } from "../services/legal-api";
import { LEGAL_QUERY_KEYS } from "./use-legal-query";
export const useApprovePartnerMutation = (): UseMutationResult<
void,
Error,
string
> => {
const queryClient = useQueryClient();
return useMutation<void, Error, string>({
mutationFn: (partnerId: string) => approvePartnerAccess(partnerId),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: LEGAL_QUERY_KEYS.pending(),
});
},
});
};

View File

@ -0,0 +1,19 @@
import { useQuery } from "@tanstack/react-query";
import type { UseQueryResult } from "@tanstack/react-query";
import { getPendingPartners } from "../services/legal-api";
import type { PendingPartner } from "../services/legal-api";
export const LEGAL_QUERY_KEYS = {
pending: () => ["legal", "pending"] as const,
};
export const usePendingPartnersQuery = (
enabled: boolean = true
): UseQueryResult<PendingPartner[]> => {
return useQuery<PendingPartner[]>({
queryKey: LEGAL_QUERY_KEYS.pending(),
queryFn: getPendingPartners,
enabled,
staleTime: 5 * 60 * 1000, // 5 minutes stale time
});
};

View File

@ -0,0 +1,22 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import type { UseMutationResult } from "@tanstack/react-query";
import { invitePartner } from "../services/auth-api";
import type { InviteResponse } from "../services/auth-api";
import { PARTNERS_QUERY_KEYS } from "./use-partners-query";
export const useInvitePartnerMutation = (): UseMutationResult<
InviteResponse,
Error,
string
> => {
const queryClient = useQueryClient();
return useMutation<InviteResponse, Error, string>({
mutationFn: (email: string) => invitePartner(email),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: PARTNERS_QUERY_KEYS.list(),
});
},
});
};

View File

@ -0,0 +1,19 @@
import { useQuery } from "@tanstack/react-query";
import type { UseQueryResult } from "@tanstack/react-query";
import { getPartners } from "../services/auth-api";
import type { Partner } from "../services/auth-api";
export const PARTNERS_QUERY_KEYS = {
list: () => ["partners", "list"] as const,
};
export const usePartnersQuery = (
enabled: boolean = true
): UseQueryResult<Partner[]> => {
return useQuery<Partner[]>({
queryKey: PARTNERS_QUERY_KEYS.list(),
queryFn: getPartners,
enabled,
staleTime: 5 * 60 * 1000, // 5 minutes stale time
});
};

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -5,7 +5,7 @@ import { loginUser } from '../services/auth-api';
import { useAuthStore } from '../hooks/use-auth';
import { useNavigate } from 'react-router-dom';
import { useState } from 'react';
import { Hexagon, Lock, Mail, ArrowRight } from 'lucide-react';
import { Hexagon, Lock, Mail, ArrowRight, Eye, EyeOff } from 'lucide-react';
import { motion } from 'framer-motion';
const loginSchema = z.object({
@ -17,6 +17,7 @@ type LoginFormValues = z.infer<typeof loginSchema>;
export const LoginPage = () => {
const [error, setError] = useState<string | null>(null);
const [showPassword, setShowPassword] = useState(false);
const setAuth = useAuthStore((state) => state.setAuth);
const navigate = useNavigate();
@ -50,7 +51,7 @@ export const LoginPage = () => {
<div className="absolute bottom-1/4 right-1/4 w-[600px] h-[600px] bg-ink-900/5 rounded-full blur-[150px] pointer-events-none" />
<motion.div
initial={{ opacity: 0, y: 30 }}
initial={{ opacity: 0, y: 30 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 1, ease: [0.16, 1, 0.3, 1] }}
className="w-full max-w-md relative z-10"
@ -100,11 +101,18 @@ export const LoginPage = () => {
<Lock className={`w-5 h-5 transition-colors ${errors.password ? 'text-red-400' : 'text-ink-400 group-focus-within:text-ink-900'}`} />
</div>
<input
type="password"
type={showPassword ? 'text' : 'password'}
{...register('password')}
className={`w-full bg-ink-50 border ${errors.password ? 'border-red-500/50 focus:border-red-500' : 'border-ink-200 focus:border-ink-900/50'} rounded-2xl py-4 pl-12 pr-4 text-ink-900 placeholder-ink-400 outline-none transition-all focus:bg-ink-0 focus:ring-4 ring-ink-900/10 font-medium`}
className={`w-full bg-ink-50 border ${errors.password ? 'border-red-500/50 focus:border-red-500' : 'border-ink-200 focus:border-ink-900/50'} rounded-2xl py-4 pl-12 pr-12 text-ink-900 placeholder-ink-400 outline-none transition-all focus:bg-ink-0 focus:ring-4 ring-ink-900/10 font-medium`}
placeholder="••••••••"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute inset-y-0 right-0 pr-4 flex items-center text-ink-400 hover:text-ink-950 focus:outline-none transition-colors"
>
{showPassword ? <EyeOff className="w-5 h-5" /> : <Eye className="w-5 h-5" />}
</button>
</div>
{errors.password && <p className="text-red-500 dark:text-red-400 text-xs mt-1.5 font-bold">{errors.password.message}</p>}
</div>

View File

@ -4,7 +4,12 @@ import { ShieldCheck, CheckCircle, ChevronRight, Lock, UploadCloud, FileText, Cl
import { useAuthStore } from '../hooks/use-auth';
import { SignatureCapture } from '../features/agreements/components/SignatureCapture';
import { useNavigate } from 'react-router-dom';
import { axiosInstance } from '../services/axios';
import {
getActiveLegalDocument,
getMyAcceptances,
uploadSignedLegalDocument,
signLegalDocument
} from '../services/legal-api';
type DocumentType = 'NDA' | 'MSA';
@ -35,14 +40,14 @@ export const OnboardingPage: React.FC = () => {
useEffect(() => {
const fetchDocs = async () => {
try {
const [ndaRes, msaRes] = await Promise.all([
axiosInstance.get('/legal/documents/active/NDA'),
axiosInstance.get('/legal/documents/active/MSA')
const [ndaData, msaData] = await Promise.all([
getActiveLegalDocument('NDA'),
getActiveLegalDocument('MSA')
]);
setNdaContent(ndaRes.data.content);
setNdaPdfUrl(ndaRes.data.pdfUrl || null);
setMsaContent(msaRes.data.content);
setMsaPdfUrl(msaRes.data.pdfUrl || null);
setNdaContent(ndaData.content);
setNdaPdfUrl(ndaData.pdfUrl || null);
setMsaContent(msaData.content);
setMsaPdfUrl(msaData.pdfUrl || null);
} catch (err) {
console.error('Failed to fetch legal documents:', err);
} finally {
@ -145,9 +150,9 @@ export const OnboardingPage: React.FC = () => {
setStep(3); // Go straight to pending screen
} else {
// Check existing acceptances to skip steps
axiosInstance.get('/legal/my-acceptances').then(res => {
const hasNDA = res.data.some((a: any) => a.document.type === 'NDA');
const hasMSA = res.data.some((a: any) => a.document.type === 'MSA');
getMyAcceptances().then(data => {
const hasNDA = data.some((a: any) => a.document.type === 'NDA');
const hasMSA = data.some((a: any) => a.document.type === 'MSA');
if (hasNDA && !hasMSA) setStep(2);
if (hasNDA && hasMSA) setStep(3);
}).catch(console.error);
@ -182,12 +187,10 @@ export const OnboardingPage: React.FC = () => {
formData.append('file', file);
formData.append('title', `Signed ${type} - ${user?.email}`);
const response = await axiosInstance.post('/legal/upload', formData, {
headers: { 'Content-Type': 'multipart/form-data' }
});
const data = await uploadSignedLegalDocument(formData);
if (type === 'NDA') setNdaUploadUrl(response.data.url);
if (type === 'MSA') setMsaUploadUrl(response.data.url);
if (type === 'NDA') setNdaUploadUrl(data.url);
if (type === 'MSA') setMsaUploadUrl(data.url);
} catch (error) {
console.error(`Failed to upload ${type}:`, error);
} finally {
@ -201,7 +204,7 @@ export const OnboardingPage: React.FC = () => {
const signatureBase64 = type === 'NDA' ? ndaSignature : msaSignature;
const documentUrl = type === 'NDA' ? ndaUploadUrl : msaUploadUrl;
await axiosInstance.post('/legal/sign', {
await signLegalDocument({
documentType: type,
signatureBase64,
documentUrl
@ -225,13 +228,13 @@ export const OnboardingPage: React.FC = () => {
<div className="flex bg-ink-100 rounded-xl p-1 mb-6 max-w-sm">
<button
onClick={() => setMode('draw')}
className={`flex-1 py-2 text-sm font-bold rounded-lg transition-all ${mode === 'draw' ? 'bg-ink-0 shadow text-ink-900' : 'text-ink-500 hover:text-ink-900'}`}
className={`flex-1 py-2 text-sm font-bold rounded-lg transition-all ${mode === 'draw' ? 'bg-ink-0 shadow text-ink-900 border border-ink-200' : 'text-ink-500 hover:text-ink-900'}`}
>
Draw Signature
</button>
<button
onClick={() => setMode('upload')}
className={`flex-1 py-2 text-sm font-bold rounded-lg transition-all ${mode === 'upload' ? 'bg-ink-0 shadow text-ink-900' : 'text-ink-500 hover:text-ink-900'}`}
className={`flex-1 py-2 text-sm font-bold rounded-lg transition-all ${mode === 'upload' ? 'bg-ink-0 shadow text-ink-900 border border-ink-200' : 'text-ink-500 hover:text-ink-900'}`}
>
Upload PDF
</button>
@ -460,4 +463,5 @@ export const OnboardingPage: React.FC = () => {
</div>
);
};
export default OnboardingPage;

View File

@ -1,56 +1,19 @@
import React, { useEffect, useState } from 'react';
import React from 'react';
import { CheckCircle, Clock, Search, XCircle } from 'lucide-react';
import { axiosInstance } from '../../services/axios';
import { motion, AnimatePresence } from 'framer-motion';
interface PendingPartner {
id: string;
email: string;
createdAt: string;
acceptances: Array<{
id: string;
signatureHash: string | null;
documentUrl: string | null;
document: {
type: string;
version: string;
}
}>;
}
import { usePendingPartnersQuery } from '../../hooks/use-legal-query';
import { useApprovePartnerMutation } from '../../hooks/use-legal-mutation';
import PageHeader from '../../components/ui/PageHeader';
export const ApprovalsPage: React.FC = () => {
const [partners, setPartners] = useState<PendingPartner[]>([]);
const [loading, setLoading] = useState(true);
const [processingId, setProcessingId] = useState<string | null>(null);
const { data: partners = [], isLoading } = usePendingPartnersQuery();
const approveMutation = useApprovePartnerMutation();
useEffect(() => {
fetchPending();
}, []);
const fetchPending = async () => {
try {
const res = await axiosInstance.get('/legal/pending');
setPartners(res.data);
} catch (err) {
console.error('Failed to fetch pending partners', err);
} finally {
setLoading(false);
}
const approvePartner = (partnerId: string) => {
approveMutation.mutate(partnerId);
};
const approvePartner = async (partnerId: string) => {
setProcessingId(partnerId);
try {
await axiosInstance.post(`/legal/approve/${partnerId}`);
setPartners(partners.filter(p => p.id !== partnerId));
} catch (err) {
console.error('Failed to approve', err);
} finally {
setProcessingId(null);
}
};
if (loading) {
if (isLoading) {
return (
<div className="flex-1 flex items-center justify-center min-h-[60vh]">
<div className="w-8 h-8 border-4 border-ink-900/30 border-t-ink-900 rounded-full animate-spin" />
@ -59,52 +22,48 @@ export const ApprovalsPage: React.FC = () => {
}
return (
<div className="max-w-6xl mx-auto space-y-8 animate-fade-in text-ink-900">
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<h1 className="text-3xl font-extrabold text-ink-900 tracking-tight flex items-center gap-3">
Approvals Queue
<span className="bg-ink-900 text-ink-0 text-xs px-2.5 py-1 rounded-full font-bold border border-ink-700 shadow-sm">
{partners.length} Pending
</span>
</h1>
<p className="text-ink-500 text-sm mt-1">
Review and approve partner legal documents to grant platform access.
</p>
</div>
<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-2 bg-ink-0 border border-ink-200 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-ink-900/10 text-ink-900 placeholder-ink-400 w-full sm:w-64"
/>
</div>
</div>
<div className="w-full space-y-6 animate-fade-in text-ink-900">
<PageHeader
title="Approvals Queue"
subtitle="Review and approve partner legal documents to grant platform access."
badge={
<span className="bg-ink-900 text-ink-0 text-[10px] px-2 py-0.5 rounded-full font-bold border border-ink-700 shadow-sm uppercase tracking-wider shrink-0">
{partners.length} Pending
</span>
}
actions={
<div className="relative">
<Search className="w-4 h-4 text-ink-400 absolute left-3 top-1/2 -translate-y-1/2" />
<input
type="text"
placeholder="Search pending partners..."
className="pl-9 pr-4 py-1.5 bg-ink-0 border border-ink-200 rounded-lg text-xs focus:outline-none focus:ring-2 focus:ring-ink-900/10 text-ink-900 placeholder-ink-400 w-full sm:w-64 shadow-sm"
/>
</div>
}
/>
{/* List */}
<div className="bg-ink-0 rounded-2xl border border-ink-200 overflow-hidden shadow-sm">
<div className="bg-ink-0 rounded-xl border border-ink-200 overflow-hidden shadow-sm">
<div className="overflow-x-auto">
<table className="w-full text-left text-sm whitespace-nowrap">
<thead className="bg-ink-50 border-b border-ink-200 text-ink-500 font-bold uppercase tracking-wider text-xs">
<tr>
<th className="px-6 py-4">Partner</th>
<th className="px-6 py-4">NDA Status</th>
<th className="px-6 py-4">MSA Status</th>
<th className="px-6 py-4 text-right">Actions</th>
<th className="px-4 py-2.5">Partner</th>
<th className="px-4 py-2.5">NDA Status</th>
<th className="px-4 py-2.5">MSA Status</th>
<th className="px-4 py-2.5 text-right">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-ink-200">
<AnimatePresence>
{partners.length === 0 ? (
<tr>
<td colSpan={4} className="px-6 py-12 text-center">
<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">Queue is empty</p>
<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>
@ -120,13 +79,13 @@ export const ApprovalsPage: React.FC = () => {
exit={{ opacity: 0, x: -20, backgroundColor: 'rgba(0, 0, 0, 0.02)' }}
className="hover:bg-ink-50 transition-colors group"
>
<td className="px-6 py-4">
<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">{partner.email}</p>
<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()}
@ -134,11 +93,11 @@ export const ApprovalsPage: React.FC = () => {
</div>
</div>
</td>
<td className="px-6 py-4">
<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-1 rounded-md">
<span className="text-xs font-bold text-ink-900 bg-ink-100 border border-ink-200 px-2 py-0.5 rounded-md">
{nda.documentUrl ? 'Uploaded PDF' : 'Digital Sign'}
</span>
{nda.documentUrl && (
@ -148,15 +107,15 @@ export const ApprovalsPage: React.FC = () => {
) : (
<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-1 rounded-md">Missing</span>
<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-6 py-4">
<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-1 rounded-md">
<span className="text-xs font-bold text-ink-900 bg-ink-100 border border-ink-200 px-2 py-0.5 rounded-md">
{msa.documentUrl ? 'Uploaded PDF' : 'Digital Sign'}
</span>
{msa.documentUrl && (
@ -166,17 +125,17 @@ export const ApprovalsPage: React.FC = () => {
) : (
<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-1 rounded-md">Missing</span>
<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-6 py-4 text-right">
<td className="px-4 py-3 text-right">
<button
onClick={() => approvePartner(partner.id)}
disabled={!nda || !msa || processingId === partner.id}
className="px-4 py-2 bg-ink-900 text-ink-0 text-xs font-bold rounded-lg hover:bg-ink-800 transition-colors shadow-md disabled:opacity-50 disabled:cursor-not-allowed"
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"
>
{processingId === partner.id ? 'Approving...' : 'Approve Access'}
{approveMutation.isPending && approveMutation.variables === partner.id ? 'Approving...' : 'Approve Access'}
</button>
</td>
</motion.tr>

View File

@ -1,16 +1,10 @@
import React, { useState, useEffect } from 'react';
import React, { useState } from 'react';
import { Users, Mail, CheckCircle, AlertCircle, ChevronRight, UserPlus, Clock, ShieldCheck, RefreshCw } from 'lucide-react';
import { axiosInstance } from '../../services/axios';
import { motion, AnimatePresence } from 'framer-motion';
import { Link } from 'react-router-dom';
interface Partner {
id: string;
email: string;
onboardingStatus: string;
mfaEnabled: boolean;
createdAt: string;
}
import { usePartnersQuery } from '../../hooks/use-partners-query';
import { useInvitePartnerMutation } from '../../hooks/use-partners-mutation';
import PageHeader from '../../components/ui/PageHeader';
const STATUS_CONFIG: Record<string, { label: string; color: string; bg: string; border: string }> = {
PENDING_ONBOARDING: {
@ -42,42 +36,27 @@ const getStatusConfig = (status: string) => STATUS_CONFIG[status] ?? {
export const DirectoryPage: React.FC = () => {
const [email, setEmail] = useState('');
const [isSubmitting, setIsSubmitting] = useState(false);
const [inviteResult, setInviteResult] = useState<{ token?: string; error?: string } | null>(null);
const [partners, setPartners] = useState<Partner[]>([]);
const [loadingPartners, setLoadingPartners] = useState(true);
const [isInviteOpen, setIsInviteOpen] = useState(false);
const [currentPage, setCurrentPage] = useState(1);
const ITEMS_PER_PAGE = 5;
const fetchPartners = async () => {
setLoadingPartners(true);
try {
const res = await axiosInstance.get('/auth/partners');
setPartners(res.data);
} catch (err) {
console.error('Failed to fetch partners', err);
} finally {
setLoadingPartners(false);
}
};
useEffect(() => {
fetchPartners();
}, []);
const { data: partners = [], isLoading: loadingPartners, refetch: fetchPartners } = usePartnersQuery();
const inviteMutation = useInvitePartnerMutation();
const handleInvite = async (e: React.FormEvent) => {
e.preventDefault();
setIsSubmitting(true);
setInviteResult(null);
try {
const res = await axiosInstance.post('/auth/invite', { email });
setInviteResult({ token: res.data.token });
setEmail('');
fetchPartners();
} catch (err: any) {
setInviteResult({ error: err.response?.data?.error || 'Failed to send invite' });
} finally {
setIsSubmitting(false);
}
inviteMutation.mutate(email, {
onSuccess: (data) => {
setInviteResult({ token: data.token });
setEmail('');
},
onError: (err: any) => {
setInviteResult({ error: err.response?.data?.error || 'Failed to send invite' });
}
});
};
const counts = {
@ -102,47 +81,61 @@ export const DirectoryPage: React.FC = () => {
});
}
const totalItems = partners.length;
const totalPages = Math.ceil(totalItems / ITEMS_PER_PAGE) || 1;
const startIndex = (currentPage - 1) * ITEMS_PER_PAGE;
const endIndex = Math.min(startIndex + ITEMS_PER_PAGE, totalItems);
const paginatedPartners = partners.slice(startIndex, endIndex);
return (
<div className="max-w-6xl mx-auto space-y-8 animate-fade-in text-ink-900">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-extrabold text-ink-900 tracking-tight">
Partner Directory
</h1>
<p className="text-ink-500 text-sm mt-1">
Manage your network and invite new partners to the platform.
</p>
</div>
<button
onClick={fetchPartners}
className="p-2.5 rounded-xl bg-ink-0 border border-ink-200 text-ink-500 hover:text-ink-900 hover:shadow-md transition-all group"
title="Refresh"
>
<RefreshCw className="w-4 h-4 group-hover:rotate-180 transition-transform duration-500" />
</button>
</div>
<div className="w-full space-y-6 animate-fade-in text-ink-900">
<PageHeader
title="Partner Directory"
subtitle="Manage your network and invite new partners to the platform."
actions={
<>
<button
onClick={() => {
setInviteResult(null);
setEmail('');
setIsInviteOpen(true);
}}
className="flex items-center gap-2 px-3 py-1.5 rounded-lg bg-ink-900 text-ink-0 text-xs font-bold hover:bg-ink-800 transition-all cursor-pointer shadow-sm"
>
<UserPlus className="w-4 h-4" />
<span>Invite Partner</span>
</button>
<button
onClick={() => { fetchPartners(); }}
className="p-1.5 rounded-lg bg-ink-0 border border-ink-200 text-ink-500 hover:text-ink-900 hover:shadow-sm transition-all group cursor-pointer"
title="Refresh"
>
<RefreshCw className="w-4 h-4 group-hover:rotate-180 transition-transform duration-500" />
</button>
</>
}
/>
{/* 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-5 bg-ink-900 border border-ink-800 rounded-2xl shadow-sm text-ink-0 relative overflow-hidden"
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-10 h-10 rounded-xl bg-ink-0/10 flex items-center justify-center text-ink-0 shrink-0">
<AlertCircle className="w-5 h-5 animate-bounce" />
<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>
<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 px-4 py-2 bg-ink-0 hover:bg-ink-100 text-ink-900 text-xs font-bold rounded-xl transition-all shadow-sm flex items-center gap-1.5 shrink-0"
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"
>
Review Queue
<ChevronRight className="w-3.5 h-3.5" />
@ -153,153 +146,225 @@ export const DirectoryPage: React.FC = () => {
{/* Stats Row */}
<div className={`grid gap-4 ${counts.awaitingApproval > 0 ? 'grid-cols-2 md:grid-cols-4' : 'grid-cols-1 md:grid-cols-3'}`}>
{statCards.map((stat, i) => (
<div key={i} className={`bg-ink-0 rounded-2xl border p-5 shadow-sm transition-all duration-300 ${stat.accentClass || 'border-ink-200'}`}>
<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-[10px] font-bold uppercase tracking-wider text-ink-500">{stat.label}</p>
<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-3xl font-extrabold tracking-tight ${stat.accentClass ? 'text-ink-900' : 'text-ink-900'}`}>{stat.value}</p>
<p className="text-xl font-bold tracking-tight text-ink-900">{stat.value}</p>
</div>
))}
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
{/* Invite Form */}
<div className="lg:col-span-1">
<div className="bg-ink-0 rounded-2xl border border-ink-200 p-6 shadow-sm relative overflow-hidden sticky top-8">
<div className="absolute top-0 right-0 p-4 opacity-5 pointer-events-none">
<UserPlus className="w-24 h-24 text-ink-900" />
</div>
{/* Partner List Table (Full Width) */}
<div className="bg-ink-0 rounded-xl border border-ink-200 shadow-sm overflow-hidden w-full">
<div className="overflow-x-auto">
<table className="w-full text-left text-sm whitespace-nowrap">
<thead className="bg-ink-50 border-b border-ink-200 text-ink-500 font-bold uppercase tracking-wider text-xs">
<tr>
<th className="px-4 py-2.5">Partner</th>
<th className="px-4 py-2.5">Status</th>
<th className="px-4 py-2.5">MFA</th>
<th className="px-4 py-2.5">Joined</th>
</tr>
</thead>
<tbody className="divide-y divide-ink-200">
{loadingPartners ? (
<tr>
<td colSpan={4} className="px-4 py-8 text-center">
<div className="w-6 h-6 border-4 border-ink-900/30 border-t-ink-900 rounded-full animate-spin mx-auto" />
</td>
</tr>
) : partners.length === 0 ? (
<tr>
<td colSpan={4} className="px-4 py-8 text-center">
<div className="w-12 h-12 bg-ink-50 rounded-full flex items-center justify-center mx-auto mb-3">
<Users className="w-6 h-6 text-ink-400" />
</div>
<p className="text-sm font-bold text-ink-900">No partners yet</p>
<p className="text-xs text-ink-500 mt-1">Use the invite button to add your first partner.</p>
</td>
</tr>
) : (
paginatedPartners.map(partner => {
const sc = getStatusConfig(partner.onboardingStatus);
return (
<tr key={partner.id} className="hover:bg-ink-50 transition-colors">
<td className="px-4 py-3">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-gradient-to-br from-ink-900 to-ink-800 flex items-center justify-center text-ink-0 font-bold text-xs shadow-md">
{partner.email.charAt(0).toUpperCase()}
</div>
<span className="font-bold text-ink-900 text-sm">{partner.email}</span>
</div>
</td>
<td className="px-4 py-3">
<span className={`text-xs px-2 py-0.5 rounded-md border ${sc.color} ${sc.bg} ${sc.border}`}>
{sc.label}
</span>
</td>
<td className="px-4 py-3">
{partner.mfaEnabled ? (
<span className="text-xs font-bold text-ink-900">Enabled</span>
) : (
<span className="text-xs font-bold text-ink-400">Disabled</span>
)}
</td>
<td className="px-4 py-3 text-xs text-ink-500 font-medium">
{new Date(partner.createdAt).toLocaleDateString()}
</td>
</tr>
);
})
)}
</tbody>
</table>
</div>
<h3 className="text-lg font-bold text-ink-900 mb-2 relative z-10">Invite Partner</h3>
<p className="text-xs text-ink-500 mb-6 relative z-10">
Generate a secure invitation link for a new partner.
{/* 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>
<form onSubmit={handleInvite} className="space-y-4 relative z-10">
<div>
<label className="text-[10px] font-bold uppercase tracking-wider 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.5 bg-ink-50 border border-ink-200 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-ink-900/10 text-ink-900 placeholder-ink-400 transition-all"
/>
</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>
<button
type="submit"
disabled={isSubmitting || !email}
className="w-full flex items-center justify-center gap-2 py-3 rounded-xl bg-ink-900 text-ink-0 font-bold text-sm hover:bg-ink-800 hover:shadow-lg transition-all disabled:opacity-50"
>
{isSubmitting ? 'Generating...' : 'Generate Invite Link'}
<ChevronRight className="w-4 h-4" />
</button>
</form>
<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>
<AnimatePresence>
{inviteResult && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
className="mt-6 overflow-hidden"
>
{inviteResult.error ? (
<div className="p-4 bg-red-500/10 border border-red-500/20 rounded-xl flex items-start gap-3">
<AlertCircle className="w-5 h-5 text-red-650 shrink-0 mt-0.5" />
{!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="p-4 bg-ink-100 border border-ink-300 rounded-xl">
<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-[10px] text-ink-500 mb-2 font-medium">Send this secure link to the partner:</p>
<div className="p-2 bg-ink-0 border border-ink-300 rounded-lg text-xs break-all font-mono text-ink-900">
{window.location.origin}/invite?token={inviteResult.token}
</div>
</div>
)}
</motion.div>
)}
</AnimatePresence>
</div>
</div>
{/* Partner List */}
<div className="lg:col-span-2">
<div className="bg-ink-0 rounded-2xl border border-ink-200 shadow-sm overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full text-left text-sm whitespace-nowrap">
<thead className="bg-ink-50 border-b border-ink-200 text-ink-500 font-bold uppercase tracking-wider text-xs">
<tr>
<th className="px-6 py-4">Partner</th>
<th className="px-6 py-4">Status</th>
<th className="px-6 py-4">MFA</th>
<th className="px-6 py-4">Joined</th>
</tr>
</thead>
<tbody className="divide-y divide-ink-200">
{loadingPartners ? (
<tr>
<td colSpan={4} className="px-6 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>
) : partners.length === 0 ? (
<tr>
<td colSpan={4} className="px-6 py-12 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 form to add your first partner.</p>
</td>
</tr>
) : (
partners.map(partner => {
const sc = getStatusConfig(partner.onboardingStatus);
return (
<tr key={partner.id} className="hover:bg-ink-50 transition-colors">
<td className="px-6 py-4">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-full bg-gradient-to-br from-ink-900 to-ink-800 flex items-center justify-center text-ink-0 font-bold text-xs shadow-md">
{partner.email.charAt(0).toUpperCase()}
</div>
<span className="font-bold text-ink-900">{partner.email}</span>
</div>
</td>
<td className="px-6 py-4">
<span className={`text-xs font-bold px-2.5 py-1 rounded-md border ${sc.color} ${sc.bg} ${sc.border}`}>
{sc.label}
</span>
</td>
<td className="px-6 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-6 py-4 text-xs text-ink-500 font-medium">
{new Date(partner.createdAt).toLocaleDateString()}
</td>
</tr>
);
})
)}
</tbody>
</table>
</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>
</div>
</div>
)}
</AnimatePresence>
</div>
);
};

View File

@ -1,6 +1,7 @@
import React, { useState, useEffect, useRef } from 'react';
import { axiosInstance } from '../../services/axios';
import { Shield, FileText, Upload, CheckCircle, AlertTriangle, ArrowRight, Eye, RefreshCw } from 'lucide-react';
import PageHeader from '../../components/ui/PageHeader';
interface LegalDoc {
id: string;
@ -125,60 +126,60 @@ export const LegalTemplatesPage: React.FC = () => {
const fileHost = (import.meta.env.VITE_API_URL || 'http://localhost:5000/api/v1').replace('/api/v1', '');
return (
<div className="max-w-[1400px] mx-auto w-full p-4 md:p-8 animate-fade-in text-ink-900">
<div className="flex flex-col md:flex-row md:items-center justify-between gap-4 mb-8">
<div>
<div className="flex items-center gap-2 mb-2">
<Shield className="w-5 h-5 text-ink-400" />
<span className="text-[10px] uppercase font-bold text-ink-500 tracking-wider">Compliance Panel</span>
<div className="w-full space-y-6 animate-fade-in text-ink-900">
<PageHeader
title="Legal Agreements"
subtitle="Configure active documents required during partner onboarding."
badge={
<div className="flex items-center gap-1.5 px-2.5 py-0.5 rounded-full bg-ink-100 border border-ink-200 text-[10px] font-bold text-ink-900 tracking-wider uppercase shrink-0">
<Shield className="w-3.5 h-3.5 text-ink-950" />
<span>Compliance Panel</span>
</div>
<h1 className="text-4xl font-extrabold text-ink-900 tracking-tight">Legal Agreements</h1>
<p className="text-sm font-medium text-ink-500 mt-1">Configure active documents required during partner onboarding.</p>
</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>
}
/>
{/* Tab switchers */}
<div className="inline-flex p-1 rounded-xl bg-ink-100 border border-ink-200">
<button
onClick={() => setActiveTab('NDA')}
className={`px-4 py-2 rounded-lg text-xs font-bold transition-all ${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-4 py-2 rounded-lg text-xs font-bold transition-all ${activeTab === 'MSA' ? 'bg-ink-0 text-ink-900 shadow-sm' : 'text-ink-500 hover:text-ink-900'}`}
>
Master Services (MSA)
</button>
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Left Side: Active Status Details */}
<div className="lg:col-span-1 flex flex-col gap-6">
<div className="p-6 bg-ink-0 border border-ink-200 shadow-sm rounded-2xl">
<h2 className="text-lg font-bold text-ink-900 mb-4">Active Configuration</h2>
<div className="p-4 bg-ink-0 border border-ink-200 shadow-sm rounded-xl">
<h2 className="text-base font-bold text-ink-900 mb-4">Active Configuration</h2>
{loading ? (
<div className="flex flex-col items-center justify-center py-12">
<RefreshCw className="w-8 h-8 text-ink-400 animate-spin mb-3" />
<RefreshCw className="w-6 h-6 text-ink-400 animate-spin mb-3" />
<span className="text-xs font-bold text-ink-400">Loading document state...</span>
</div>
) : currentDoc ? (
<div className="space-y-4">
<div className="flex items-center justify-between p-3 rounded-xl bg-ink-50 border border-ink-100">
<div className="flex items-center justify-between p-3 rounded-lg bg-ink-50 border border-ink-100">
<div>
<p className="text-[10px] font-bold text-ink-400 uppercase tracking-wider">Version</p>
<p className="text-lg font-extrabold text-ink-900">v{currentDoc.version}</p>
<p className="text-xs font-semibold text-ink-400 uppercase tracking-wider">Version</p>
<p className="text-base font-extrabold text-ink-900">v{currentDoc.version}</p>
</div>
<span className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-bold bg-ink-900 text-ink-0 border border-ink-700 shadow-sm">
<span className="inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-xs font-bold bg-ink-900 text-ink-0 border border-ink-700 shadow-sm">
<CheckCircle className="w-3.5 h-3.5" />
Active
</span>
</div>
<div>
<p className="text-[10px] font-bold text-ink-400 uppercase tracking-wider mb-2">Display Mode</p>
<p className="text-xs font-semibold text-ink-400 uppercase tracking-wider mb-2">Display Mode</p>
{currentDoc.pdfUrl ? (
<div className="flex items-center gap-2 text-xs font-semibold text-ink-900">
<FileText className="w-4 h-4" />
@ -194,12 +195,12 @@ export const LegalTemplatesPage: React.FC = () => {
{currentDoc.pdfUrl && (
<div className="pt-2">
<p className="text-[10px] font-bold text-ink-400 uppercase tracking-wider mb-2">File Source</p>
<p className="text-xs font-semibold text-ink-400 uppercase tracking-wider mb-2">File Source</p>
<a
href={`${fileHost}${currentDoc.pdfUrl}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center justify-between p-3.5 rounded-xl 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-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"
>
<span className="truncate max-w-[150px]">{currentDoc.pdfUrl.split('/').pop()}</span>
<ArrowRight className="w-3.5 h-3.5" />
@ -208,8 +209,8 @@ export const LegalTemplatesPage: React.FC = () => {
)}
<div className="pt-4 border-t border-ink-100">
<p className="text-[10px] font-bold text-ink-400 uppercase tracking-wider mb-2">Text Content Sample</p>
<p className="text-xs text-ink-500 font-mono leading-relaxed line-clamp-4 bg-ink-50 p-3 rounded-lg border border-ink-100">
<p className="text-xs font-semibold text-ink-400 uppercase tracking-wider mb-2">Text Content Sample</p>
<p className="text-xs text-ink-500 font-mono leading-relaxed line-clamp-4 bg-ink-50 p-2.5 rounded-lg border border-ink-100">
{currentDoc.content}
</p>
</div>
@ -226,39 +227,39 @@ export const LegalTemplatesPage: React.FC = () => {
{/* Right Side: Publish New Revision form */}
<div className="lg:col-span-2">
<form onSubmit={handleFormSubmit} className="p-6 md:p-8 bg-ink-0 border border-ink-200 shadow-sm rounded-2xl flex flex-col gap-6">
<form onSubmit={handleFormSubmit} className="p-4 md:p-5 bg-ink-0 border border-ink-200 shadow-sm rounded-xl flex flex-col gap-4">
<div>
<h2 className="text-xl font-bold text-ink-900">Publish New Revision</h2>
<h2 className="text-base font-bold text-ink-900">Publish New Revision</h2>
<p className="text-xs text-ink-500 mt-1">
Saving a new template automatically deactivates the current active version.
</p>
</div>
{message && (
<div className={`p-4 rounded-xl 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-5 h-5 shrink-0" />}
<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-6">
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="md:col-span-1">
<label className="block text-xs font-bold uppercase tracking-wider text-ink-400 mb-2">Version String</label>
<label className="block text-xs font-semibold text-ink-500 mb-2 uppercase tracking-wider">Version String</label>
<input
type="text"
placeholder="e.g. 1.1"
value={version}
onChange={(e) => setVersion(e.target.value)}
className="w-full px-4 py-2.5 bg-ink-50 border border-ink-200 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-ink-900/10 text-ink-900 placeholder-ink-400"
className="w-full px-3 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 shadow-sm font-semibold"
required
/>
</div>
<div className="md:col-span-3">
<label className="block text-xs font-bold uppercase tracking-wider text-ink-400 mb-2">PDF Template (Optional)</label>
<label className="block text-xs font-semibold text-ink-500 mb-2 uppercase tracking-wider">PDF Template (Optional)</label>
<div
onClick={() => fileInputRef.current?.click()}
className="flex items-center gap-3 p-3 rounded-lg border border-dashed border-ink-200 hover:border-ink-400 cursor-pointer bg-ink-50 hover:bg-ink-100/50 transition-colors"
className="flex items-center gap-3 p-2 rounded-lg border border-dashed border-ink-200 hover:border-ink-400 cursor-pointer bg-ink-50 hover:bg-ink-100/50 transition-colors"
>
<div className="w-8 h-8 rounded-lg bg-ink-0 border border-ink-200 flex items-center justify-center text-ink-500">
<Upload className="w-4 h-4" />
@ -281,13 +282,13 @@ export const LegalTemplatesPage: React.FC = () => {
</div>
<div>
<label className="block text-xs font-bold uppercase tracking-wider text-ink-400 mb-2">Text Content Fallback</label>
<label className="block text-xs font-semibold text-ink-500 mb-2 uppercase tracking-wider">Text Content Fallback</label>
<textarea
rows={8}
rows={6}
placeholder="Insert standard text content here..."
value={content}
onChange={(e) => setContent(e.target.value)}
className="w-full px-4 py-3 bg-ink-50 border border-ink-200 rounded-xl text-sm focus:outline-none focus:ring-2 focus:ring-ink-900/10 text-ink-900 placeholder-ink-400 font-mono text-xs leading-relaxed"
className="w-full px-3 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 font-mono text-xs leading-relaxed shadow-sm"
required
/>
<p className="text-[10px] text-ink-500 font-medium mt-1.5">
@ -295,11 +296,11 @@ export const LegalTemplatesPage: React.FC = () => {
</p>
</div>
<div className="flex justify-end gap-3 pt-4 border-t border-ink-100">
<div className="flex justify-end gap-3 pt-3 border-t border-ink-100">
<button
type="submit"
disabled={submitting || uploadingPdf}
className="px-5 py-3 rounded-xl 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"
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"
>
{uploadingPdf ? 'Uploading PDF...' : submitting ? 'Publishing...' : 'Publish Template'}
<CheckCircle className="w-4 h-4" />

View File

@ -0,0 +1,54 @@
import { axiosInstance } from './axios';
import type { Asset, Organization } from '../types/assets';
export const getAssets = async (): Promise<Asset[]> => {
const response = await axiosInstance.get<Asset[]>('/assets');
return response.data;
};
export const getOrganizations = async (): Promise<Organization[]> => {
const response = await axiosInstance.get<Organization[]>('/organizations');
return response.data;
};
export const uploadAsset = async (formData: FormData): Promise<void> => {
await axiosInstance.post('/assets/upload', formData, {
headers: { 'Content-Type': 'multipart/form-data' }
});
};
export const updateAsset = async (
id: string,
payload: {
title?: string;
description?: string;
categoryId?: string;
subcategory?: string;
tags?: string[];
githubUrl?: string;
isDownloadable?: boolean;
shares?: Array<{ organizationId: string; userId: string | null }>;
}
): Promise<void> => {
await axiosInstance.patch(`/assets/${id}`, payload);
};
export const deleteAsset = async (id: string): Promise<void> => {
await axiosInstance.delete(`/assets/${id}`);
};
export const requestDownload = async (id: string): Promise<void> => {
await axiosInstance.post(`/assets/${id}/request-download`);
};
export const approveDownloadRequest = async (assetId: string, requestId: string): Promise<void> => {
await axiosInstance.post(`/assets/${assetId}/approve-download/${requestId}`);
};
export const rejectDownloadRequest = async (assetId: string, requestId: string): Promise<void> => {
await axiosInstance.post(`/assets/${assetId}/reject-download/${requestId}`);
};
export const downloadAssetFile = async (id: string): Promise<void> => {
await axiosInstance.post(`/assets/${id}/download`);
};

View File

@ -5,8 +5,22 @@ export const AUTH_API_ROUTES = {
login: "/auth/login",
refresh: "/auth/refresh",
logout: "/auth/logout",
partners: "/auth/partners",
invite: "/auth/invite",
};
export interface Partner {
id: string;
email: string;
onboardingStatus: string;
mfaEnabled: boolean;
createdAt: string;
}
export interface InviteResponse {
token: string;
}
export const loginUser = async (params: LoginParams): Promise<AuthResponse> => {
const response = await axiosInstance.post<AuthResponse>(
AUTH_API_ROUTES.login,
@ -21,3 +35,18 @@ export const refreshAuthToken = async (): Promise<AuthResponse> => {
);
return response.data;
};
export const getPartners = async (): Promise<Partner[]> => {
const response = await axiosInstance.get<Partner[]>(
AUTH_API_ROUTES.partners
);
return response.data;
};
export const invitePartner = async (email: string): Promise<InviteResponse> => {
const response = await axiosInstance.post<InviteResponse>(
AUTH_API_ROUTES.invite,
{ email }
);
return response.data;
};

View File

@ -0,0 +1,76 @@
import { axiosInstance } from "./axios";
export interface PendingPartner {
id: string;
email: string;
createdAt: string;
acceptances: Array<{
id: string;
signatureHash: string | null;
documentUrl: string | null;
document: {
type: string;
version: string;
};
}>;
}
export interface LegalDocument {
id: string;
type: 'NDA' | 'MSA';
content: string;
pdfUrl?: string | null;
version: string;
}
export interface LegalAcceptance {
id: string;
document: {
id: string;
type: 'NDA' | 'MSA';
version: string;
};
}
export const LEGAL_API_ROUTES = {
pending: "/legal/pending",
approve: (partnerId: string) => `/legal/approve/${partnerId}`,
activeDoc: (type: 'NDA' | 'MSA') => `/legal/documents/active/${type}`,
myAcceptances: "/legal/my-acceptances",
upload: "/legal/upload",
sign: "/legal/sign",
};
export const getPendingPartners = async (): Promise<PendingPartner[]> => {
const response = await axiosInstance.get<PendingPartner[]>(LEGAL_API_ROUTES.pending);
return response.data;
};
export const approvePartnerAccess = async (partnerId: string): Promise<void> => {
await axiosInstance.post<void>(LEGAL_API_ROUTES.approve(partnerId));
};
export const getActiveLegalDocument = async (type: 'NDA' | 'MSA'): Promise<LegalDocument> => {
const response = await axiosInstance.get<LegalDocument>(LEGAL_API_ROUTES.activeDoc(type));
return response.data;
};
export const getMyAcceptances = async (): Promise<LegalAcceptance[]> => {
const response = await axiosInstance.get<LegalAcceptance[]>(LEGAL_API_ROUTES.myAcceptances);
return response.data;
};
export const uploadSignedLegalDocument = async (formData: FormData): Promise<{ url: string }> => {
const response = await axiosInstance.post<{ url: string }>(LEGAL_API_ROUTES.upload, formData, {
headers: { 'Content-Type': 'multipart/form-data' }
});
return response.data;
};
export const signLegalDocument = async (payload: {
documentType: 'NDA' | 'MSA';
signatureBase64: string | null;
documentUrl: string | null;
}): Promise<void> => {
await axiosInstance.post(LEGAL_API_ROUTES.sign, payload);
};

View File

@ -0,0 +1,66 @@
export interface SharedWithOrg {
organizationId: string;
userId: string | null;
organization: {
id: string;
name: string;
};
user?: {
id: string;
email: string;
} | null;
}
export interface DownloadRequest {
id: string;
assetId: string;
userId: string;
status: string;
createdAt: string;
user?: {
id: string;
email: string;
};
asset?: {
title: string;
};
}
export interface Asset {
id: string;
title: string;
type: string;
size: number;
url: string;
version: number;
uploadedBy: string;
description: string | null;
categoryId: string | null;
subcategory: string | null;
tags: string[];
downloadsCount: number;
githubUrl: string | null;
status: string;
isDownloadable: boolean;
createdAt: string;
sharedWith?: SharedWithOrg[];
downloadRequests?: DownloadRequest[];
}
export interface OrgUser {
id: string;
email: string;
role: string;
}
export interface Organization {
id: string;
name: string;
status: string;
users?: OrgUser[];
}
export interface ShareItem {
organizationId: string;
userId: string | null;
}