implement blog management system with CRUD operations, API routes, and markdown viewer component

This commit is contained in:
Yaseen 2026-07-10 16:57:33 +05:30
parent 51c7d280b0
commit a87fd01ec8
15 changed files with 1151 additions and 209 deletions

View File

@ -138,3 +138,17 @@ model AuditLog {
createdAt DateTime @default(now()) createdAt DateTime @default(now())
actor User @relation(fields: [actorId], references: [id]) actor User @relation(fields: [actorId], references: [id])
} }
model BlogPost {
id String @id @default(uuid())
title String
content String @db.Text
author String
publishDate String
thumbnailUrl String?
readTime String?
tags String[]
status String @default("draft") // draft, published
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}

View File

@ -174,6 +174,36 @@ async function seed() {
console.log(`Partner (Approved) already exists: ${activeEmail}`); console.log(`Partner (Approved) already exists: ${activeEmail}`);
} }
// 7. Seed Blog Posts
const blogCount = await prisma.blogPost.count();
if (blogCount === 0) {
await prisma.blogPost.createMany({
data: [
{
title: "Unlocking Ultra-Low Latency: Synthesis of RISC-V in Edge Devices",
content: "As edge intelligence grows, local compute units require custom processor topologies. In this article, we details the exact synthesis settings and pipelining optimizations that enabled our quad-core RISC-V IP block to achieve 35% better performance per watt compared to baseline architectures. We review cache organization, instruction fetch queue sizing, and how we tackled branch prediction overheads within tightly constrained FPGA silicon boundaries.",
author: "Dr. Marcus Vance",
publishDate: "2026-06-18",
thumbnailUrl: "https://images.unsplash.com/photo-1601524909162-be87252be298?w=500&auto=format&fit=crop&q=60",
readTime: "6 min read",
tags: ["RISC-V", "Hardware-Design", "Edge-AI"],
status: "published"
},
{
title: "Introduction to CodeNuk: Scalable Microservice Architecture",
content: "Building distributed systems often involves navigating high configuration overhead. CodeNuk solves this by providing a unified, type-safe scaffolding that integrates telemetry, connection pools, and circuit-breakers out of the box. This deep-dive explains how CodeNuk leverages TypeScript decorators to declare service endpoints and automatically generate OpenAPI contracts and React Client hooks during the build phase, saving engineering weeks.",
author: "Yasha Khandelwal",
publishDate: "2026-06-25",
thumbnailUrl: "https://images.unsplash.com/photo-1555066931-4365d14bab8c?w=500&auto=format&fit=crop&q=60",
readTime: "8 min read",
tags: ["CodeNuk", "TypeScript", "Microservices"],
status: "published"
}
]
});
console.log('Seeded Blog Posts.');
}
console.log('Seeding completed successfully.'); console.log('Seeding completed successfully.');
} }

View File

@ -15,6 +15,7 @@ import authRoutes from './routes/auth.routes';
import assetRoutes from './routes/asset.routes'; import assetRoutes from './routes/asset.routes';
import orgRoutes from './routes/organization.routes'; import orgRoutes from './routes/organization.routes';
import legalRoutes from './routes/legal.routes'; import legalRoutes from './routes/legal.routes';
import blogRoutes from './routes/blog.routes';
import { ensureBucketExists } from './utils/s3'; import { ensureBucketExists } from './utils/s3';
@ -82,6 +83,7 @@ app.use('/api/v1/auth', authRoutes);
app.use('/api/v1/assets', assetRoutes); app.use('/api/v1/assets', assetRoutes);
app.use('/api/v1/organizations', orgRoutes); app.use('/api/v1/organizations', orgRoutes);
app.use('/api/v1/legal', legalRoutes); app.use('/api/v1/legal', legalRoutes);
app.use('/api/v1/blog', blogRoutes);
app.get('/api/v1/health', (req: Request, res: Response) => { app.get('/api/v1/health', (req: Request, res: Response) => {
res.status(200).json({ status: 'success', message: 'API is fully functional and real.' }); res.status(200).json({ status: 'success', message: 'API is fully functional and real.' });

View File

@ -0,0 +1,91 @@
import { Response, NextFunction } from 'express';
import { BlogService } from '../services/blog.service';
import { AuthRequest } from '../middleware/auth.middleware';
import prisma from '../utils/db';
export class BlogController {
private blogService = new BlogService();
public listBlogPosts = async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const { status, tag, search, author } = req.query;
const filters: any = {};
if (typeof tag === 'string') filters.tag = tag;
if (typeof search === 'string') filters.search = search;
if (typeof author === 'string') filters.author = author;
// Restrict status access if user is not admin
if (req.user?.role !== 'ADMIN') {
filters.status = 'published';
} else if (typeof status === 'string') {
filters.status = status;
}
const posts = await this.blogService.getBlogPosts(filters);
res.status(200).json(posts);
} catch (err) {
next(err);
}
};
public getBlogPost = async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const post = await this.blogService.getBlogPostById(req.params.id);
if (!post) {
return res.status(404).json({ error: 'Blog post not found' });
}
// Clients cannot view drafts
if (post.status !== 'published' && req.user?.role !== 'ADMIN') {
return res.status(403).json({ error: 'Access forbidden' });
}
res.status(200).json(post);
} catch (err) {
next(err);
}
};
public createBlogPost = async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
let authorVal = req.body.author || 'Technical Architect';
if (!req.body.author && req.user?.userId) {
const user = await prisma.user.findUnique({
where: { id: req.user.userId }
});
if (user) {
authorVal = user.email;
}
}
const postData = {
...req.body,
author: authorVal,
};
const post = await this.blogService.createBlogPost(postData);
res.status(201).json(post);
} catch (err) {
next(err);
}
};
public updateBlogPost = async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
const post = await this.blogService.updateBlogPost(req.params.id, req.body);
res.status(200).json(post);
} catch (err) {
next(err);
}
};
public deleteBlogPost = async (req: AuthRequest, res: Response, next: NextFunction) => {
try {
await this.blogService.deleteBlogPost(req.params.id);
res.status(204).send();
} catch (err) {
next(err);
}
};
}

View File

@ -0,0 +1,16 @@
import { Router } from 'express';
import { BlogController } from '../controllers/blog.controller';
import { authenticate, requireRole } from '../middleware/auth.middleware';
const router = Router();
const blogController = new BlogController();
router.use(authenticate);
router.get('/', blogController.listBlogPosts);
router.get('/:id', blogController.getBlogPost);
router.post('/new', requireRole('ADMIN'), blogController.createBlogPost);
router.patch('/:id', requireRole('ADMIN'), blogController.updateBlogPost);
router.delete('/:id', requireRole('ADMIN'), blogController.deleteBlogPost);
export default router;

View File

@ -0,0 +1,124 @@
import prisma from '../utils/db';
export interface BlogPostFilters {
status?: string;
tag?: string;
search?: string;
author?: string;
}
export class BlogService {
public async getBlogPosts(filters: BlogPostFilters = {}) {
const { status, tag, search, author } = filters;
const where: any = {};
if (status) {
where.status = status;
}
if (tag) {
where.tags = {
has: tag,
};
}
if (author) {
where.author = {
contains: author,
mode: 'insensitive',
};
}
if (search) {
where.OR = [
{ title: { contains: search, mode: 'insensitive' } },
{ content: { contains: search, mode: 'insensitive' } },
];
}
return await prisma.blogPost.findMany({
where,
orderBy: {
publishDate: 'desc',
},
});
}
public async getBlogPostById(id: string) {
return await prisma.blogPost.findUnique({
where: { id },
});
}
public async createBlogPost(data: any) {
const { tags, content, ...rest } = data;
// Parse tags
let parsedTags: string[] = [];
if (Array.isArray(tags)) {
parsedTags = tags;
} else if (typeof tags === 'string' && tags.trim()) {
try {
parsedTags = JSON.parse(tags);
} catch {
parsedTags = tags.split(',').map((t: string) => t.trim()).filter(Boolean);
}
}
// Auto-calculate read time
const wordsPerMinute = 200;
const wordCount = content ? content.trim().split(/\s+/).length : 0;
const readTimeVal = `${Math.max(1, Math.ceil(wordCount / wordsPerMinute))} min read`;
// Default publishDate if not provided
const publishDateVal = rest.publishDate || new Date().toISOString().split('T')[0];
return await prisma.blogPost.create({
data: {
...rest,
content: content || '',
tags: parsedTags,
readTime: readTimeVal,
publishDate: publishDateVal,
},
});
}
public async updateBlogPost(id: string, data: any) {
const { tags, content, ...rest } = data;
const updateData: any = { ...rest };
if (content !== undefined) {
updateData.content = content;
// Auto-calculate read time
const wordsPerMinute = 200;
const wordCount = content ? content.trim().split(/\s+/).length : 0;
updateData.readTime = `${Math.max(1, Math.ceil(wordCount / wordsPerMinute))} min read`;
}
if (tags !== undefined) {
let parsedTags: string[] = [];
if (Array.isArray(tags)) {
parsedTags = tags;
} else if (typeof tags === 'string') {
try {
parsedTags = JSON.parse(tags);
} catch {
parsedTags = tags.split(',').map((t: string) => t.trim()).filter(Boolean);
}
}
updateData.tags = parsedTags;
}
return await prisma.blogPost.update({
where: { id },
data: updateData,
});
}
public async deleteBlogPost(id: string) {
return await prisma.blogPost.delete({
where: { id },
});
}
}

View File

@ -1,12 +1,23 @@
import React, { useState, useEffect, useRef } from 'react'; import React, { useState, useEffect, useRef } from "react";
import ReactDOM from 'react-dom'; import ReactDOM from "react-dom";
import { motion } from 'framer-motion'; import { motion } from "framer-motion";
import { import {
X, ZoomIn, ZoomOut, RotateCcw, Download, Printer, X,
ShieldCheck, AlertTriangle, CheckCircle, ChevronLeft, ZoomIn,
ChevronRight, FileText, Maximize, Minimize ZoomOut,
} from 'lucide-react'; RotateCcw,
import { Button } from './Button'; Download,
Printer,
ShieldCheck,
AlertTriangle,
CheckCircle,
ChevronLeft,
ChevronRight,
FileText,
Maximize,
Minimize,
} from "lucide-react";
import { Button } from "./Button";
interface Acceptance { interface Acceptance {
id: string; id: string;
@ -31,7 +42,7 @@ interface DocumentPreviewModalProps {
partnerCreatedAt: string; partnerCreatedAt: string;
acceptances: Acceptance[]; acceptances: Acceptance[];
verifiedDocs: { nda: boolean; msa: boolean }; verifiedDocs: { nda: boolean; msa: boolean };
onVerify: (docType: 'NDA' | 'MSA') => void; onVerify: (docType: "NDA" | "MSA") => void;
onApprovePartner: () => void; onApprovePartner: () => void;
isApproving: boolean; isApproving: boolean;
} }
@ -48,29 +59,29 @@ export const DocumentPreviewModal: React.FC<DocumentPreviewModalProps> = ({
onApprovePartner, onApprovePartner,
isApproving, isApproving,
}) => { }) => {
const [currentTab, setCurrentTab] = useState<'NDA' | 'MSA'>('NDA'); const [currentTab, setCurrentTab] = useState<"NDA" | "MSA">("NDA");
const [zoom, setZoom] = useState(100); const [zoom, setZoom] = useState(100);
const [fitWidth, setFitWidth] = useState(false); const [fitWidth, setFitWidth] = useState(false);
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
const [isFullScreen, setIsFullScreen] = useState(false); const [isFullScreen, setIsFullScreen] = useState(false);
const modalRef = useRef<HTMLDivElement>(null); const modalRef = useRef<HTMLDivElement>(null);
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const ndaAcceptance = acceptances.find(a => a.document.type === 'NDA'); const ndaAcceptance = acceptances.find((a) => a.document.type === "NDA");
const msaAcceptance = acceptances.find(a => a.document.type === 'MSA'); const msaAcceptance = acceptances.find((a) => a.document.type === "MSA");
const activeAcceptance = currentTab === 'NDA' ? ndaAcceptance : msaAcceptance; const activeAcceptance = currentTab === "NDA" ? ndaAcceptance : msaAcceptance;
const totalPages = activeAcceptance?.documentUrl ? 1 : 2; const totalPages = activeAcceptance?.documentUrl ? 1 : 2;
// Prevent background page scrolling when modal is open // Prevent background page scrolling when modal is open
useEffect(() => { useEffect(() => {
if (isOpen) { if (isOpen) {
document.body.style.overflow = 'hidden'; document.body.style.overflow = "hidden";
} else { } else {
document.body.style.overflow = ''; document.body.style.overflow = "";
} }
return () => { return () => {
document.body.style.overflow = ''; document.body.style.overflow = "";
}; };
}, [isOpen]); }, [isOpen]);
@ -108,19 +119,20 @@ export const DocumentPreviewModal: React.FC<DocumentPreviewModalProps> = ({
const handleFullscreenChange = () => { const handleFullscreenChange = () => {
setIsFullScreen(!!document.fullscreenElement); setIsFullScreen(!!document.fullscreenElement);
}; };
document.addEventListener('fullscreenchange', handleFullscreenChange); document.addEventListener("fullscreenchange", handleFullscreenChange);
return () => document.removeEventListener('fullscreenchange', handleFullscreenChange); return () =>
document.removeEventListener("fullscreenchange", handleFullscreenChange);
}, []); }, []);
if (!isOpen) return null; if (!isOpen) return null;
const handleZoomIn = () => { const handleZoomIn = () => {
setFitWidth(false); setFitWidth(false);
setZoom(prev => Math.min(200, prev + 25)); setZoom((prev) => Math.min(200, prev + 25));
}; };
const handleZoomOut = () => { const handleZoomOut = () => {
setFitWidth(false); setFitWidth(false);
setZoom(prev => Math.max(50, prev - 25)); setZoom((prev) => Math.max(50, prev - 25));
}; };
const handleZoomReset = () => { const handleZoomReset = () => {
setFitWidth(false); setFitWidth(false);
@ -131,12 +143,16 @@ export const DocumentPreviewModal: React.FC<DocumentPreviewModalProps> = ({
}; };
const handlePrint = () => { const handlePrint = () => {
const printContent = document.getElementById('printable-doc-content'); const printContent = document.getElementById("printable-doc-content");
if (!printContent) return; if (!printContent) return;
const windowUrl = 'about:blank'; const windowUrl = "about:blank";
const uniqueName = new Date().getTime(); const uniqueName = new Date().getTime();
const printWindow = window.open(windowUrl, uniqueName.toString(), 'left=50000,top=50000,width=0,height=0'); const printWindow = window.open(
windowUrl,
uniqueName.toString(),
"left=50000,top=50000,width=0,height=0",
);
if (!printWindow) return; if (!printWindow) return;
printWindow.document.write(` printWindow.document.write(`
@ -153,14 +169,14 @@ export const DocumentPreviewModal: React.FC<DocumentPreviewModalProps> = ({
</style> </style>
</head> </head>
<body> <body>
<h1>${currentTab === 'NDA' ? 'Non-Disclosure Agreement (NDA)' : 'Master Services Agreement (MSA)'}</h1> <h1>${currentTab === "NDA" ? "Non-Disclosure Agreement (NDA)" : "Master Services Agreement (MSA)"}</h1>
<p>${activeAcceptance?.document.content || ''}</p> <p>${activeAcceptance?.document.content || ""}</p>
<div class="sig-box"> <div class="sig-box">
<div class="sig-title">Digitally Signed & Verified</div> <div class="sig-title">Digitally Signed & Verified</div>
<div>Signed By: ${partnerEmail}</div> <div>Signed By: ${partnerEmail}</div>
<div class="hash">Verification Hash: ${activeAcceptance?.signatureHash || 'N/A'}</div> <div class="hash">Verification Hash: ${activeAcceptance?.signatureHash || "N/A"}</div>
<div>IP Address: ${activeAcceptance?.ipAddress || 'N/A'}</div> <div>IP Address: ${activeAcceptance?.ipAddress || "N/A"}</div>
<div>Date Signed: ${activeAcceptance ? new Date(activeAcceptance.acceptedAt).toLocaleString() : ''}</div> <div>Date Signed: ${activeAcceptance ? new Date(activeAcceptance.acceptedAt).toLocaleString() : ""}</div>
</div> </div>
</body> </body>
</html> </html>
@ -174,30 +190,36 @@ export const DocumentPreviewModal: React.FC<DocumentPreviewModalProps> = ({
const handleDownloadText = () => { const handleDownloadText = () => {
if (!activeAcceptance) return; if (!activeAcceptance) return;
const element = document.createElement("a"); const element = document.createElement("a");
const file = new Blob([ const file = new Blob(
`${currentTab === 'NDA' ? 'Non-Disclosure Agreement (NDA)' : 'Master Services Agreement (MSA)'}\n\n`, [
activeAcceptance.document.content, `${currentTab === "NDA" ? "Non-Disclosure Agreement (NDA)" : "Master Services Agreement (MSA)"}\n\n`,
`\n\n=== DIGITAL SIGNATURE ===\n`, activeAcceptance.document.content,
`Signed By: ${partnerEmail}\n`, `\n\n=== DIGITAL SIGNATURE ===\n`,
`Verification Hash: ${activeAcceptance.signatureHash || 'N/A'}\n`, `Signed By: ${partnerEmail}\n`,
`IP Address: ${activeAcceptance.ipAddress}\n`, `Verification Hash: ${activeAcceptance.signatureHash || "N/A"}\n`,
`Signed On: ${new Date(activeAcceptance.acceptedAt).toLocaleString()}\n` `IP Address: ${activeAcceptance.ipAddress}\n`,
], {type: 'text/plain'}); `Signed On: ${new Date(activeAcceptance.acceptedAt).toLocaleString()}\n`,
],
{ type: "text/plain" },
);
element.href = URL.createObjectURL(file); element.href = URL.createObjectURL(file);
element.download = `${currentTab}_Agreement_${partnerEmail.split('@')[0]}.txt`; element.download = `${currentTab}_Agreement_${partnerEmail.split("@")[0]}.txt`;
document.body.appendChild(element); document.body.appendChild(element);
element.click(); element.click();
document.body.removeChild(element); document.body.removeChild(element);
}; };
const fileHost = (import.meta.env.VITE_API_URL || 'http://localhost:5000/api/v1').replace('/api/v1', ''); const fileHost = (
import.meta.env.VITE_API_URL || "http://localhost:5000/api/v1"
).replace("/api/v1", "");
const isBothVerified = verifiedDocs.nda && verifiedDocs.msa; const isBothVerified = verifiedDocs.nda && verifiedDocs.msa;
const isCurrentVerified = currentTab === 'NDA' ? verifiedDocs.nda : verifiedDocs.msa; const isCurrentVerified =
currentTab === "NDA" ? verifiedDocs.nda : verifiedDocs.msa;
// Responsive classes based on screen size // Responsive classes based on screen size
const modalContainerClasses = isFullScreen const modalContainerClasses = isFullScreen
? 'fixed inset-0 w-screen h-screen bg-ink-0 z-[9999] flex flex-col overflow-hidden' ? "fixed inset-0 w-screen h-screen bg-ink-0 z-[9999] flex flex-col overflow-hidden"
: 'relative w-full h-full sm:w-[90vw] sm:h-[88vh] lg:w-[78vw] lg:h-[86vh] bg-ink-0 border border-ink-200 sm:rounded-2xl shadow-2xl flex flex-col overflow-hidden z-[9999]'; : "relative w-full h-full sm:w-[90vw] sm:h-[88vh] lg:w-[78vw] lg:h-[86vh] bg-ink-0 border border-ink-200 sm:rounded-2xl shadow-2xl flex flex-col overflow-hidden z-[9999]";
return ReactDOM.createPortal( return ReactDOM.createPortal(
<div className="fixed inset-0 z-[9999] flex justify-center items-center p-0 sm:p-4 md:p-6 overflow-hidden"> <div className="fixed inset-0 z-[9999] flex justify-center items-center p-0 sm:p-4 md:p-6 overflow-hidden">
@ -216,7 +238,7 @@ export const DocumentPreviewModal: React.FC<DocumentPreviewModalProps> = ({
initial={{ opacity: 0, scale: 0.97, y: 15 }} initial={{ opacity: 0, scale: 0.97, y: 15 }}
animate={{ opacity: 1, scale: 1, y: 0 }} animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.97, y: 15 }} exit={{ opacity: 0, scale: 0.97, y: 15 }}
transition={{ type: 'spring', damping: 26, stiffness: 340 }} transition={{ type: "spring", damping: 26, stiffness: 340 }}
className={modalContainerClasses} className={modalContainerClasses}
> >
{/* Sticky Header */} {/* Sticky Header */}
@ -227,7 +249,9 @@ export const DocumentPreviewModal: React.FC<DocumentPreviewModalProps> = ({
</div> </div>
<div className="min-w-0"> <div className="min-w-0">
<h3 className="text-sm font-extrabold text-ink-900 tracking-tight truncate"> <h3 className="text-sm font-extrabold text-ink-900 tracking-tight truncate">
{currentTab === 'NDA' ? 'Mutual Non-Disclosure Agreement (NDA)' : 'Master Services Agreement (MSA)'} {currentTab === "NDA"
? "Mutual Non-Disclosure Agreement (NDA)"
: "Master Services Agreement (MSA)"}
</h3> </h3>
<p className="text-xs text-ink-500 font-semibold truncate"> <p className="text-xs text-ink-500 font-semibold truncate">
Partner: <span className="text-ink-900">{partnerEmail}</span> Partner: <span className="text-ink-900">{partnerEmail}</span>
@ -239,29 +263,29 @@ export const DocumentPreviewModal: React.FC<DocumentPreviewModalProps> = ({
<div className="flex items-center gap-2 bg-ink-50 p-1 rounded-xl border border-ink-200 self-start md:self-auto"> <div className="flex items-center gap-2 bg-ink-50 p-1 rounded-xl border border-ink-200 self-start md:self-auto">
<button <button
onClick={() => { onClick={() => {
setCurrentTab('NDA'); setCurrentTab("NDA");
setCurrentPage(1); setCurrentPage(1);
}} }}
className={`px-3 py-1.5 rounded-lg text-xs font-bold transition-all cursor-pointer ${ className={`px-3 py-1.5 rounded-lg text-xs font-bold transition-all cursor-pointer ${
currentTab === 'NDA' currentTab === "NDA"
? 'bg-ink-0 text-ink-900 shadow-sm border border-ink-200' ? "bg-ink-0 text-ink-900 shadow-sm border border-ink-200"
: 'text-ink-500 hover:text-ink-900' : "text-ink-500 hover:text-ink-900"
}`} }`}
> >
NDA Agreement {verifiedDocs.nda && '✓'} NDA Agreement {verifiedDocs.nda && "✓"}
</button> </button>
<button <button
onClick={() => { onClick={() => {
setCurrentTab('MSA'); setCurrentTab("MSA");
setCurrentPage(1); setCurrentPage(1);
}} }}
className={`px-3 py-1.5 rounded-lg text-xs font-bold transition-all cursor-pointer ${ className={`px-3 py-1.5 rounded-lg text-xs font-bold transition-all cursor-pointer ${
currentTab === 'MSA' currentTab === "MSA"
? 'bg-ink-0 text-ink-900 shadow-sm border border-ink-200' ? "bg-ink-0 text-ink-900 shadow-sm border border-ink-200"
: 'text-ink-500 hover:text-ink-900' : "text-ink-500 hover:text-ink-900"
}`} }`}
> >
MSA Agreement {verifiedDocs.msa && '✓'} MSA Agreement {verifiedDocs.msa && "✓"}
</button> </button>
</div> </div>
@ -271,7 +295,11 @@ export const DocumentPreviewModal: React.FC<DocumentPreviewModalProps> = ({
className="p-2 rounded-lg text-ink-500 hover:text-ink-900 hover:bg-ink-50 transition-all cursor-pointer" className="p-2 rounded-lg text-ink-500 hover:text-ink-900 hover:bg-ink-50 transition-all cursor-pointer"
title={isFullScreen ? "Exit Fullscreen" : "Fullscreen"} title={isFullScreen ? "Exit Fullscreen" : "Fullscreen"}
> >
{isFullScreen ? <Minimize className="w-4 h-4" /> : <Maximize className="w-4 h-4" />} {isFullScreen ? (
<Minimize className="w-4 h-4" />
) : (
<Maximize className="w-4 h-4" />
)}
</button> </button>
<button <button
onClick={onClose} onClick={onClose}
@ -288,7 +316,8 @@ export const DocumentPreviewModal: React.FC<DocumentPreviewModalProps> = ({
<div className="bg-amber-50 border-b border-amber-200 px-5 py-2.5 flex items-center gap-3 shrink-0"> <div className="bg-amber-50 border-b border-amber-200 px-5 py-2.5 flex items-center gap-3 shrink-0">
<AlertTriangle className="w-4.5 h-4.5 text-amber-600 shrink-0" /> <AlertTriangle className="w-4.5 h-4.5 text-amber-600 shrink-0" />
<p className="text-xs font-bold text-amber-800"> <p className="text-xs font-bold text-amber-800">
Attention: This document has not been submitted or signed by the partner. Attention: This document has not been submitted or signed by the
partner.
</p> </p>
</div> </div>
)} )}
@ -299,18 +328,21 @@ export const DocumentPreviewModal: React.FC<DocumentPreviewModalProps> = ({
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<button <button
disabled={currentPage <= 1 || !activeAcceptance} disabled={currentPage <= 1 || !activeAcceptance}
onClick={() => setCurrentPage(prev => Math.max(1, prev - 1))} onClick={() => setCurrentPage((prev) => Math.max(1, prev - 1))}
className="p-1.5 rounded-lg border border-ink-200 hover:bg-ink-0 disabled:opacity-40 text-ink-700 bg-ink-0 cursor-pointer shadow-sm" className="p-1.5 rounded-lg border border-ink-200 hover:bg-ink-0 disabled:opacity-40 text-ink-700 bg-ink-0 cursor-pointer shadow-sm"
title="Previous Page" title="Previous Page"
> >
<ChevronLeft className="w-4 h-4" /> <ChevronLeft className="w-4 h-4" />
</button> </button>
<span className="text-xs font-bold text-ink-600 min-w-[70px] text-center"> <span className="text-xs font-bold text-ink-600 min-w-[70px] text-center">
Page {activeAcceptance ? currentPage : 0} of {activeAcceptance ? totalPages : 0} Page {activeAcceptance ? currentPage : 0} of{" "}
{activeAcceptance ? totalPages : 0}
</span> </span>
<button <button
disabled={currentPage >= totalPages || !activeAcceptance} disabled={currentPage >= totalPages || !activeAcceptance}
onClick={() => setCurrentPage(prev => Math.min(totalPages, prev + 1))} onClick={() =>
setCurrentPage((prev) => Math.min(totalPages, prev + 1))
}
className="p-1.5 rounded-lg border border-ink-200 hover:bg-ink-0 disabled:opacity-40 text-ink-700 bg-ink-0 cursor-pointer shadow-sm" className="p-1.5 rounded-lg border border-ink-200 hover:bg-ink-0 disabled:opacity-40 text-ink-700 bg-ink-0 cursor-pointer shadow-sm"
title="Next Page" title="Next Page"
> >
@ -322,7 +354,9 @@ export const DocumentPreviewModal: React.FC<DocumentPreviewModalProps> = ({
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<button <button
onClick={handleZoomOut} onClick={handleZoomOut}
disabled={!activeAcceptance || activeAcceptance.documentUrl !== null} disabled={
!activeAcceptance || activeAcceptance.documentUrl !== null
}
className="p-1.5 rounded-lg border border-ink-200 hover:bg-ink-0 disabled:opacity-40 text-ink-700 bg-ink-0 cursor-pointer shadow-sm" className="p-1.5 rounded-lg border border-ink-200 hover:bg-ink-0 disabled:opacity-40 text-ink-700 bg-ink-0 cursor-pointer shadow-sm"
title="Zoom Out" title="Zoom Out"
> >
@ -333,7 +367,9 @@ export const DocumentPreviewModal: React.FC<DocumentPreviewModalProps> = ({
</span> </span>
<button <button
onClick={handleZoomIn} onClick={handleZoomIn}
disabled={!activeAcceptance || activeAcceptance.documentUrl !== null} disabled={
!activeAcceptance || activeAcceptance.documentUrl !== null
}
className="p-1.5 rounded-lg border border-ink-200 hover:bg-ink-0 disabled:opacity-40 text-ink-700 bg-ink-0 cursor-pointer shadow-sm" className="p-1.5 rounded-lg border border-ink-200 hover:bg-ink-0 disabled:opacity-40 text-ink-700 bg-ink-0 cursor-pointer shadow-sm"
title="Zoom In" title="Zoom In"
> >
@ -341,7 +377,9 @@ export const DocumentPreviewModal: React.FC<DocumentPreviewModalProps> = ({
</button> </button>
<button <button
onClick={handleZoomReset} onClick={handleZoomReset}
disabled={!activeAcceptance || activeAcceptance.documentUrl !== null} disabled={
!activeAcceptance || activeAcceptance.documentUrl !== null
}
className="p-1.5 rounded-lg border border-ink-200 hover:bg-ink-0 disabled:opacity-40 text-ink-700 bg-ink-0 cursor-pointer shadow-sm" className="p-1.5 rounded-lg border border-ink-200 hover:bg-ink-0 disabled:opacity-40 text-ink-700 bg-ink-0 cursor-pointer shadow-sm"
title="Reset Zoom" title="Reset Zoom"
> >
@ -349,11 +387,13 @@ export const DocumentPreviewModal: React.FC<DocumentPreviewModalProps> = ({
</button> </button>
<button <button
onClick={toggleFitWidth} onClick={toggleFitWidth}
disabled={!activeAcceptance || activeAcceptance.documentUrl !== null} disabled={
!activeAcceptance || activeAcceptance.documentUrl !== null
}
className={`px-2.5 py-1.5 rounded-lg border text-xs font-bold cursor-pointer shadow-sm transition-all ${ className={`px-2.5 py-1.5 rounded-lg border text-xs font-bold cursor-pointer shadow-sm transition-all ${
fitWidth fitWidth
? 'bg-ink-900 text-ink-0 border-transparent' ? "bg-ink-900 text-ink-0 border-transparent"
: 'bg-ink-0 border-ink-200 text-ink-700 hover:bg-ink-50' : "bg-ink-0 border-ink-200 text-ink-700 hover:bg-ink-50"
}`} }`}
title="Fit Width" title="Fit Width"
> >
@ -373,7 +413,9 @@ export const DocumentPreviewModal: React.FC<DocumentPreviewModalProps> = ({
<span className="hidden sm:inline">Print</span> <span className="hidden sm:inline">Print</span>
</button> </button>
<button <button
onClick={activeAcceptance?.documentUrl ? undefined : handleDownloadText} onClick={
activeAcceptance?.documentUrl ? undefined : handleDownloadText
}
disabled={!activeAcceptance} disabled={!activeAcceptance}
className="p-1.5 rounded-lg border border-ink-200 hover:bg-ink-0 disabled:opacity-40 text-ink-700 bg-ink-0 cursor-pointer flex items-center gap-1.5 text-xs font-bold shadow-sm" className="p-1.5 rounded-lg border border-ink-200 hover:bg-ink-0 disabled:opacity-40 text-ink-700 bg-ink-0 cursor-pointer flex items-center gap-1.5 text-xs font-bold shadow-sm"
title="Download File" title="Download File"
@ -400,10 +442,9 @@ export const DocumentPreviewModal: React.FC<DocumentPreviewModalProps> = ({
{/* Modal Main Body (Split Pane) */} {/* Modal Main Body (Split Pane) */}
<div className="flex-1 flex flex-col md:flex-row overflow-hidden min-h-0"> <div className="flex-1 flex flex-col md:flex-row overflow-hidden min-h-0">
{/* Left Pane: Centered Document Viewer */} {/* Left Pane: Centered Document Viewer */}
<div className="flex-1 flex flex-col bg-ink-100 overflow-hidden relative p-4 sm:p-6 justify-center items-center"> <div className="flex-1 flex flex-col bg-ink-100 overflow-hidden relative p-4 sm:p-6 justify-center items-center">
<div <div
ref={containerRef} ref={containerRef}
className="w-full h-full overflow-y-auto flex justify-center items-start scrollbar-thin rounded-lg" className="w-full h-full overflow-y-auto flex justify-center items-start scrollbar-thin rounded-lg"
> >
@ -411,7 +452,9 @@ export const DocumentPreviewModal: React.FC<DocumentPreviewModalProps> = ({
activeAcceptance.documentUrl ? ( activeAcceptance.documentUrl ? (
// PDF / Uploaded Document Viewer centered // PDF / Uploaded Document Viewer centered
<div className="w-full h-full max-w-4xl bg-ink-0 shadow-lg rounded-xl overflow-hidden border border-ink-200 flex justify-center items-center"> <div className="w-full h-full max-w-4xl bg-ink-0 shadow-lg rounded-xl overflow-hidden border border-ink-200 flex justify-center items-center">
{activeAcceptance.documentUrl.toLowerCase().endsWith('.pdf') ? ( {activeAcceptance.documentUrl
.toLowerCase()
.endsWith(".pdf") ? (
<iframe <iframe
src={`${fileHost}${activeAcceptance.documentUrl}#toolbar=0`} src={`${fileHost}${activeAcceptance.documentUrl}#toolbar=0`}
title="Document PDF" title="Document PDF"
@ -429,13 +472,17 @@ export const DocumentPreviewModal: React.FC<DocumentPreviewModalProps> = ({
</div> </div>
) : ( ) : (
// Digitally Signed text template (Standard A4 letter styled) // Digitally Signed text template (Standard A4 letter styled)
<div <div
id="printable-doc-content" id="printable-doc-content"
className="w-full bg-ink-0 p-8 sm:p-12 my-2 rounded-xl shadow-lg border border-ink-200 font-serif leading-relaxed text-ink-800 transition-all duration-150 select-text flex flex-col" className="w-full bg-ink-0 p-8 sm:p-12 my-2 rounded-xl shadow-lg border border-ink-200 font-serif leading-relaxed text-ink-800 transition-all duration-150 select-text flex flex-col"
style={ style={
fitWidth fitWidth
? { width: '100%', maxWidth: '100%', fontSize: '15px' } ? { width: "100%", maxWidth: "100%", fontSize: "15px" }
: { width: '100%', maxWidth: '720px', fontSize: `${14 * (zoom / 100)}px` } : {
width: "100%",
maxWidth: "720px",
fontSize: `${14 * (zoom / 100)}px`,
}
} }
> >
{currentPage === 1 ? ( {currentPage === 1 ? (
@ -443,10 +490,14 @@ export const DocumentPreviewModal: React.FC<DocumentPreviewModalProps> = ({
<div className="flex-1"> <div className="flex-1">
<div className="text-center mb-8 not-italic font-sans"> <div className="text-center mb-8 not-italic font-sans">
<h4 className="text-base sm:text-lg font-extrabold text-ink-900 tracking-tight"> <h4 className="text-base sm:text-lg font-extrabold text-ink-900 tracking-tight">
{currentTab === 'NDA' ? 'MUTUAL NON-DISCLOSURE AGREEMENT' : 'MASTER SERVICES AGREEMENT'} {currentTab === "NDA"
? "MUTUAL NON-DISCLOSURE AGREEMENT"
: "MASTER SERVICES AGREEMENT"}
</h4> </h4>
<div className="w-16 h-1 bg-ink-900 mx-auto my-3" /> <div className="w-16 h-1 bg-ink-900 mx-auto my-3" />
<p className="text-[10px] text-ink-400 font-bold uppercase tracking-wider">Version {activeAcceptance.document.version}</p> <p className="text-[10px] text-ink-400 font-bold uppercase tracking-wider">
Version {activeAcceptance.document.version}
</p>
</div> </div>
<div className="whitespace-pre-line prose max-w-none text-xs sm:text-sm"> <div className="whitespace-pre-line prose max-w-none text-xs sm:text-sm">
{activeAcceptance.document.content} {activeAcceptance.document.content}
@ -463,11 +514,17 @@ export const DocumentPreviewModal: React.FC<DocumentPreviewModalProps> = ({
<h4 className="text-base sm:text-lg font-extrabold text-ink-900 tracking-tight"> <h4 className="text-base sm:text-lg font-extrabold text-ink-900 tracking-tight">
SIGNATURE &amp; ACCORD SIGNING PAGE SIGNATURE &amp; ACCORD SIGNING PAGE
</h4> </h4>
<p className="text-[10px] text-ink-400 font-bold uppercase tracking-wider mt-1">Agreement Version {activeAcceptance.document.version}</p> <p className="text-[10px] text-ink-400 font-bold uppercase tracking-wider mt-1">
Agreement Version{" "}
{activeAcceptance.document.version}
</p>
</div> </div>
<p className="text-xs sm:text-sm mb-6 text-ink-600 font-sans italic"> <p className="text-xs sm:text-sm mb-6 text-ink-600 font-sans italic">
IN WITNESS WHEREOF, the parties hereto have caused this Agreement to be executed by their digital signatures as of the Acceptance Date specified below. IN WITNESS WHEREOF, the parties hereto have caused
this Agreement to be executed by their digital
signatures as of the Acceptance Date specified
below.
</p> </p>
</div> </div>
@ -476,10 +533,14 @@ export const DocumentPreviewModal: React.FC<DocumentPreviewModalProps> = ({
{/* Signature image box */} {/* Signature image box */}
<div className="border-2 border-ink-200 rounded-xl overflow-hidden bg-white shadow-sm"> <div className="border-2 border-ink-200 rounded-xl overflow-hidden bg-white shadow-sm">
<div className="px-4 pt-3 pb-1 border-b border-ink-100 flex items-center justify-between"> <div className="px-4 pt-3 pb-1 border-b border-ink-100 flex items-center justify-between">
<span className="text-[10px] font-bold text-ink-400 uppercase tracking-widest">Client Signature</span> <span className="text-[10px] font-bold text-ink-400 uppercase tracking-widest">
Client Signature
</span>
<div className="flex items-center gap-1 text-emerald-600"> <div className="flex items-center gap-1 text-emerald-600">
<ShieldCheck className="w-3.5 h-3.5" /> <ShieldCheck className="w-3.5 h-3.5" />
<span className="text-[10px] font-bold uppercase tracking-wider">Verified</span> <span className="text-[10px] font-bold uppercase tracking-wider">
Verified
</span>
</div> </div>
</div> </div>
<div className="p-4 min-h-[100px] flex items-center justify-center bg-white"> <div className="p-4 min-h-[100px] flex items-center justify-center bg-white">
@ -489,45 +550,71 @@ export const DocumentPreviewModal: React.FC<DocumentPreviewModalProps> = ({
src={activeAcceptance.signatureBase64} src={activeAcceptance.signatureBase64}
alt="Client drawn signature" alt="Client drawn signature"
className="max-w-full max-h-[160px] object-contain" className="max-w-full max-h-[160px] object-contain"
style={{ imageRendering: 'crisp-edges' }} style={{ imageRendering: "crisp-edges" }}
/> />
) : ( ) : (
// Upload-based signing — no drawn image stored // Upload-based signing — no drawn image stored
<div className="text-center py-4"> <div className="text-center py-4">
<ShieldCheck className="w-8 h-8 text-emerald-500 mx-auto mb-2" /> <ShieldCheck className="w-8 h-8 text-emerald-500 mx-auto mb-2" />
<p className="text-xs text-ink-500 font-semibold">Signed via document upload</p> <p className="text-xs text-ink-500 font-semibold">
<p className="text-[10px] text-ink-400 mt-0.5">See uploaded file in viewer</p> Signed via document upload
</p>
<p className="text-[10px] text-ink-400 mt-0.5">
See uploaded file in viewer
</p>
</div> </div>
)} )}
</div> </div>
{/* Signature baseline line */} {/* Signature baseline line */}
<div className="px-6 pb-3"> <div className="px-6 pb-3">
<div className="border-b-2 border-ink-300 w-full" /> <div className="border-b-2 border-ink-300 w-full" />
<p className="text-[9px] text-ink-400 font-bold uppercase tracking-widest mt-1 text-center">Authorized Digital Signature</p> <p className="text-[9px] text-ink-400 font-bold uppercase tracking-widest mt-1 text-center">
Authorized Digital Signature
</p>
</div> </div>
</div> </div>
{/* Verification metadata strip */} {/* Verification metadata strip */}
<div className="grid grid-cols-2 gap-x-4 gap-y-2 text-[10px] sm:text-xs font-sans bg-ink-50 border border-ink-200 rounded-xl p-4"> <div className="grid grid-cols-2 gap-x-4 gap-y-2 text-[10px] sm:text-xs font-sans bg-ink-50 border border-ink-200 rounded-xl p-4">
<div> <div>
<p className="font-bold text-ink-400 uppercase tracking-wider text-[9px]">Signed By</p> <p className="font-bold text-ink-400 uppercase tracking-wider text-[9px]">
<p className="font-bold text-ink-900 mt-0.5 truncate">{partnerEmail}</p> Signed By
</p>
<p className="font-bold text-ink-900 mt-0.5 truncate">
{partnerEmail}
</p>
</div> </div>
<div> <div>
<p className="font-bold text-ink-400 uppercase tracking-wider text-[9px]">Accepted On</p> <p className="font-bold text-ink-400 uppercase tracking-wider text-[9px]">
<p className="font-semibold text-ink-900 mt-0.5">{new Date(activeAcceptance.acceptedAt).toLocaleString()}</p> Accepted On
</p>
<p className="font-semibold text-ink-900 mt-0.5">
{new Date(
activeAcceptance.acceptedAt,
).toLocaleString()}
</p>
</div> </div>
<div> <div>
<p className="font-bold text-ink-400 uppercase tracking-wider text-[9px]">IP Address</p> <p className="font-bold text-ink-400 uppercase tracking-wider text-[9px]">
<p className="font-semibold font-mono text-ink-900 mt-0.5">{activeAcceptance.ipAddress}</p> IP Address
</p>
<p className="font-semibold font-mono text-ink-900 mt-0.5">
{activeAcceptance.ipAddress}
</p>
</div> </div>
<div> <div>
<p className="font-bold text-ink-400 uppercase tracking-wider text-[9px]">Document Version</p> <p className="font-bold text-ink-400 uppercase tracking-wider text-[9px]">
<p className="font-semibold text-ink-900 mt-0.5">v{activeAcceptance.document.version}</p> Document Version
</p>
<p className="font-semibold text-ink-900 mt-0.5">
v{activeAcceptance.document.version}
</p>
</div> </div>
{activeAcceptance.signatureHash && ( {activeAcceptance.signatureHash && (
<div className="col-span-2"> <div className="col-span-2">
<p className="font-bold text-ink-400 uppercase tracking-wider text-[9px]">Verification Hash (SHA-256)</p> <p className="font-bold text-ink-400 uppercase tracking-wider text-[9px]">
Verification Hash (SHA-256)
</p>
<p className="font-mono text-[9px] text-ink-500 bg-ink-100 p-1.5 rounded border border-ink-200 break-all mt-0.5"> <p className="font-mono text-[9px] text-ink-500 bg-ink-100 p-1.5 rounded border border-ink-200 break-all mt-0.5">
{activeAcceptance.signatureHash} {activeAcceptance.signatureHash}
</p> </p>
@ -546,9 +633,12 @@ export const DocumentPreviewModal: React.FC<DocumentPreviewModalProps> = ({
) : ( ) : (
<div className="w-full max-w-md my-auto flex flex-col items-center justify-center text-center p-8 bg-ink-0 rounded-2xl border border-ink-200 shadow-lg"> <div className="w-full max-w-md my-auto flex flex-col items-center justify-center text-center p-8 bg-ink-0 rounded-2xl border border-ink-200 shadow-lg">
<AlertTriangle className="w-12 h-12 text-ink-400 mb-3" /> <AlertTriangle className="w-12 h-12 text-ink-400 mb-3" />
<h4 className="text-base font-extrabold text-ink-900">Document Unavailable</h4> <h4 className="text-base font-extrabold text-ink-900">
Document Unavailable
</h4>
<p className="text-xs text-ink-500 mt-1.5 max-w-xs leading-relaxed font-semibold"> <p className="text-xs text-ink-500 mt-1.5 max-w-xs leading-relaxed font-semibold">
The partner has not yet submitted or signed the {currentTab} document. The partner has not yet submitted or signed the {currentTab}{" "}
document.
</p> </p>
</div> </div>
)} )}
@ -563,23 +653,39 @@ export const DocumentPreviewModal: React.FC<DocumentPreviewModalProps> = ({
</h4> </h4>
<div className="space-y-3"> <div className="space-y-3">
<div className="flex justify-between items-center text-xs"> <div className="flex justify-between items-center text-xs">
<span className="font-semibold text-ink-500">Document Category</span> <span className="font-semibold text-ink-500">
<span className="font-bold text-ink-900 bg-ink-100 px-2 py-0.5 rounded border border-ink-200">{currentTab}</span> Document Category
</span>
<span className="font-bold text-ink-900 bg-ink-100 px-2 py-0.5 rounded border border-ink-200">
{currentTab}
</span>
</div> </div>
<div className="flex justify-between items-center text-xs"> <div className="flex justify-between items-center text-xs">
<span className="font-semibold text-ink-500">Submission State</span> <span className="font-semibold text-ink-500">
Submission State
</span>
{activeAcceptance ? ( {activeAcceptance ? (
<span className="font-bold text-emerald-700 bg-emerald-500/10 px-2 py-0.5 rounded border border-emerald-500/20">Signed</span> <span className="font-bold text-emerald-700 bg-emerald-500/10 px-2 py-0.5 rounded border border-emerald-500/20">
Signed
</span>
) : ( ) : (
<span className="font-bold text-amber-700 bg-amber-500/10 px-2 py-0.5 rounded border border-amber-500/20">Pending</span> <span className="font-bold text-amber-700 bg-amber-500/10 px-2 py-0.5 rounded border border-amber-500/20">
Pending
</span>
)} )}
</div> </div>
<div className="flex justify-between items-center text-xs"> <div className="flex justify-between items-center text-xs">
<span className="font-semibold text-ink-500">Digital Signature</span> <span className="font-semibold text-ink-500">
Digital Signature
</span>
{activeAcceptance ? ( {activeAcceptance ? (
<span className="font-bold text-emerald-700 bg-emerald-500/10 px-2 py-0.5 rounded border border-emerald-500/20">Verified</span> <span className="font-bold text-emerald-700 bg-emerald-500/10 px-2 py-0.5 rounded border border-emerald-500/20">
Verified
</span>
) : ( ) : (
<span className="font-bold text-red-650 bg-red-500/10 px-2 py-0.5 rounded border border-red-500/20">Not Verified</span> <span className="font-bold text-red-650 bg-red-500/10 px-2 py-0.5 rounded border border-red-500/20">
Not Verified
</span>
)} )}
</div> </div>
</div> </div>
@ -592,24 +698,42 @@ export const DocumentPreviewModal: React.FC<DocumentPreviewModalProps> = ({
{activeAcceptance ? ( {activeAcceptance ? (
<div className="space-y-3 text-xs"> <div className="space-y-3 text-xs">
<div> <div>
<p className="font-semibold text-ink-400 text-[10px] uppercase">Signed By</p> <p className="font-semibold text-ink-400 text-[10px] uppercase">
<p className="font-bold text-ink-900 truncate mt-0.5">{partnerEmail}</p> Signed By
</p>
<p className="font-bold text-ink-900 truncate mt-0.5">
{partnerEmail}
</p>
</div> </div>
<div> <div>
<p className="font-semibold text-ink-400 text-[10px] uppercase">Signed On</p> <p className="font-semibold text-ink-400 text-[10px] uppercase">
<p className="font-bold text-ink-900 mt-0.5">{new Date(activeAcceptance.acceptedAt).toLocaleString()}</p> Signed On
</p>
<p className="font-bold text-ink-900 mt-0.5">
{new Date(activeAcceptance.acceptedAt).toLocaleString()}
</p>
</div> </div>
<div> <div>
<p className="font-semibold text-ink-400 text-[10px] uppercase">IP Address</p> <p className="font-semibold text-ink-400 text-[10px] uppercase">
<p className="font-bold text-ink-900 mt-0.5 font-mono">{activeAcceptance.ipAddress}</p> IP Address
</p>
<p className="font-bold text-ink-900 mt-0.5 font-mono">
{activeAcceptance.ipAddress}
</p>
</div> </div>
<div> <div>
<p className="font-semibold text-ink-400 text-[10px] uppercase">Version</p> <p className="font-semibold text-ink-400 text-[10px] uppercase">
<p className="font-bold text-ink-900 mt-0.5">v{activeAcceptance.document.version}</p> Version
</p>
<p className="font-bold text-ink-900 mt-0.5">
v{activeAcceptance.document.version}
</p>
</div> </div>
{activeAcceptance.signatureHash && ( {activeAcceptance.signatureHash && (
<div> <div>
<p className="font-semibold text-ink-400 text-[10px] uppercase">Verification Hash</p> <p className="font-semibold text-ink-400 text-[10px] uppercase">
Verification Hash
</p>
<p className="font-mono text-[10px] text-ink-650 bg-ink-50 p-1.5 rounded border border-ink-150 break-all mt-0.5"> <p className="font-mono text-[10px] text-ink-650 bg-ink-50 p-1.5 rounded border border-ink-150 break-all mt-0.5">
{activeAcceptance.signatureHash} {activeAcceptance.signatureHash}
</p> </p>
@ -617,7 +741,9 @@ export const DocumentPreviewModal: React.FC<DocumentPreviewModalProps> = ({
)} )}
</div> </div>
) : ( ) : (
<p className="text-xs text-ink-400 italic">No verification metadata available.</p> <p className="text-xs text-ink-400 italic">
No verification metadata available.
</p>
)} )}
</div> </div>
@ -625,20 +751,23 @@ export const DocumentPreviewModal: React.FC<DocumentPreviewModalProps> = ({
<Button <Button
onClick={() => onVerify(currentTab)} onClick={() => onVerify(currentTab)}
disabled={!activeAcceptance} disabled={!activeAcceptance}
variant={isCurrentVerified ? 'secondary' : 'primary'} variant={isCurrentVerified ? "secondary" : "primary"}
className="w-full flex justify-center items-center gap-1.5" className="w-full flex justify-center items-center gap-1.5"
> >
{isCurrentVerified ? ( {isCurrentVerified ? (
<> <>
<CheckCircle className="w-4 h-4 text-emerald-600" /> <div className="flex gap-2 items-center">
<span>Document Verified</span> <CheckCircle className="w-4 h-4 text-emerald-600" />
<span>Document Verified</span>
</div>
</> </>
) : ( ) : (
<span>Verify {currentTab} Signature</span> <span>Verify {currentTab} Signature</span>
)} )}
</Button> </Button>
<p className="text-[10px] text-ink-400 text-center font-medium leading-normal"> <p className="text-[10px] text-ink-400 text-center font-medium leading-normal">
Marking this verified unlocks approval options for the administrator. Marking this verified unlocks approval options for the
administrator.
</p> </p>
</div> </div>
</div> </div>
@ -649,11 +778,13 @@ export const DocumentPreviewModal: React.FC<DocumentPreviewModalProps> = ({
<div className="text-xs font-semibold text-ink-500"> <div className="text-xs font-semibold text-ink-500">
{verifiedDocs.nda && verifiedDocs.msa ? ( {verifiedDocs.nda && verifiedDocs.msa ? (
<span className="text-emerald-700 font-bold flex items-center gap-1.5"> <span className="text-emerald-700 font-bold flex items-center gap-1.5">
<CheckCircle className="w-4.5 h-4.5" /> Both agreements verified. Access approval unlocked. <CheckCircle className="w-4.5 h-4.5" /> Both agreements
verified. Access approval unlocked.
</span> </span>
) : ( ) : (
<span className="flex items-center gap-1.5"> <span className="flex items-center gap-1.5">
<AlertTriangle className="w-4 h-4 text-amber-500" /> Please review and verify both the NDA and MSA documents. <AlertTriangle className="w-4 h-4 text-amber-500" /> Please
review and verify both the NDA and MSA documents.
</span> </span>
)} )}
</div> </div>
@ -668,12 +799,12 @@ export const DocumentPreviewModal: React.FC<DocumentPreviewModalProps> = ({
variant="primary" variant="primary"
className="font-bold shadow-md hover:shadow-lg transition-shadow" className="font-bold shadow-md hover:shadow-lg transition-shadow"
> >
{isApproving ? 'Approving Partner...' : 'Approve Partner Access'} {isApproving ? "Approving Partner..." : "Approve Partner Access"}
</Button> </Button>
</div> </div>
</div> </div>
</motion.div> </motion.div>
</div>, </div>,
document.body document.body,
); );
}; };

View File

@ -0,0 +1,404 @@
import React from "react";
interface MarkdownBlock {
type: "h1" | "h2" | "h3" | "h4" | "h5" | "h6" | "code" | "blockquote" | "ul" | "ol" | "hr" | "p";
content: string;
language?: string;
items?: string[];
}
interface ParseState {
blocks: MarkdownBlock[];
currentCodeBlock: { language: string; lines: string[] } | null;
currentList: { type: "ul" | "ol"; items: string[] } | null;
currentParagraphLines: string[];
}
interface InlineToken {
type: "text" | "bold" | "italic" | "code" | "link";
text: string;
url?: string;
}
const flushParagraph = (state: ParseState): void => {
if (state.currentParagraphLines.length > 0) {
state.blocks.push({
type: "p",
content: state.currentParagraphLines.join(" ").trim(),
});
state.currentParagraphLines = [];
}
};
const flushList = (state: ParseState): void => {
if (state.currentList) {
state.blocks.push({
type: state.currentList.type,
content: "",
items: state.currentList.items,
});
state.currentList = null;
}
};
const handleCodeBlock = (trimmed: string, state: ParseState): boolean => {
if (trimmed.startsWith("```")) {
if (state.currentCodeBlock) {
state.blocks.push({
type: "code",
content: state.currentCodeBlock.lines.join("\n"),
language: state.currentCodeBlock.language,
});
state.currentCodeBlock = null;
} else {
flushParagraph(state);
flushList(state);
const language = trimmed.slice(3).trim();
state.currentCodeBlock = { language, lines: [] };
}
return true;
}
return false;
};
const handleHeading = (line: string, state: ParseState): boolean => {
const match = line.match(/^(#{1,6})\s+(.*)$/);
if (match) {
flushParagraph(state);
flushList(state);
const level = match[1].length;
state.blocks.push({
type: `h${level}` as any,
content: match[2].trim(),
});
return true;
}
return false;
};
const handleBlockquote = (trimmed: string, state: ParseState): boolean => {
if (trimmed.startsWith(">")) {
flushParagraph(state);
flushList(state);
state.blocks.push({
type: "blockquote",
content: trimmed.replace(/^>\s*/, ""),
});
return true;
}
return false;
};
const handleLists = (line: string, state: ParseState): boolean => {
const ulMatch = line.match(/^(\s*)([-*+])\s+(.*)$/);
if (ulMatch) {
flushParagraph(state);
const content = ulMatch[3].trim();
if (state.currentList && state.currentList.type === "ul") {
state.currentList.items.push(content);
} else {
flushList(state);
state.currentList = { type: "ul", items: [content] };
}
return true;
}
const olMatch = line.match(/^(\s*)(\d+)\.\s+(.*)$/);
if (olMatch) {
flushParagraph(state);
const content = olMatch[3].trim();
if (state.currentList && state.currentList.type === "ol") {
state.currentList.items.push(content);
} else {
flushList(state);
state.currentList = { type: "ol", items: [content] };
}
return true;
}
return false;
};
const handleLine = (line: string, state: ParseState): void => {
const trimmed = line.trim();
if (handleCodeBlock(trimmed, state)) {
return;
}
if (state.currentCodeBlock) {
state.currentCodeBlock.lines.push(line);
return;
}
if (trimmed === "---" || trimmed === "***" || trimmed === "___") {
flushParagraph(state);
flushList(state);
state.blocks.push({ type: "hr", content: "" });
return;
}
if (handleHeading(line, state) || handleBlockquote(trimmed, state)) {
return;
}
if (handleLists(line, state)) {
return;
}
if (trimmed === "") {
flushParagraph(state);
flushList(state);
return;
}
flushList(state);
state.currentParagraphLines.push(line);
};
export const parseMarkdown = (text: string): MarkdownBlock[] => {
const lines = text.split("\n");
const state: ParseState = {
blocks: [],
currentCodeBlock: null,
currentList: null,
currentParagraphLines: [],
};
for (let i = 0; i < lines.length; i++) {
handleLine(lines[i], state);
}
flushParagraph(state);
flushList(state);
return state.blocks;
};
const parseInlineLinks = (tokens: InlineToken[]): InlineToken[] => {
const updated: InlineToken[] = [];
for (const part of tokens) {
if (part.type === "text") {
const regex = /\[([^\]]+)\]\(([^)]+)\)/g;
let lastIndex = 0;
let match;
while ((match = regex.exec(part.text)) !== null) {
const before = part.text.substring(lastIndex, match.index);
if (before) updated.push({ type: "text", text: before });
updated.push({ type: "link", text: match[1], url: match[2] });
lastIndex = regex.lastIndex;
}
const after = part.text.substring(lastIndex);
if (after) updated.push({ type: "text", text: after });
} else {
updated.push(part);
}
}
return updated;
};
const parseInlineUrls = (tokens: InlineToken[]): InlineToken[] => {
const updated: InlineToken[] = [];
for (const part of tokens) {
if (part.type === "text") {
const regex = /(https?:\/\/[^\s)]+)/g;
let lastIndex = 0;
let match;
while ((match = regex.exec(part.text)) !== null) {
const before = part.text.substring(lastIndex, match.index);
if (before) updated.push({ type: "text", text: before });
updated.push({ type: "link", text: match[1], url: match[1] });
lastIndex = regex.lastIndex;
}
const after = part.text.substring(lastIndex);
if (after) updated.push({ type: "text", text: after });
} else {
updated.push(part);
}
}
return updated;
};
const parseInlineBold = (tokens: InlineToken[]): InlineToken[] => {
const updated: InlineToken[] = [];
for (const part of tokens) {
if (part.type === "text") {
const regex = /\*\*([^*]+)\*\*/g;
let lastIndex = 0;
let match;
while ((match = regex.exec(part.text)) !== null) {
const before = part.text.substring(lastIndex, match.index);
if (before) updated.push({ type: "text", text: before });
updated.push({ type: "bold", text: match[1] });
lastIndex = regex.lastIndex;
}
const after = part.text.substring(lastIndex);
if (after) updated.push({ type: "text", text: after });
} else {
updated.push(part);
}
}
return updated;
};
const parseInlineCode = (tokens: InlineToken[]): InlineToken[] => {
const updated: InlineToken[] = [];
for (const part of tokens) {
if (part.type === "text") {
const regex = /`([^`]+)`/g;
let lastIndex = 0;
let match;
while ((match = regex.exec(part.text)) !== null) {
const before = part.text.substring(lastIndex, match.index);
if (before) updated.push({ type: "text", text: before });
updated.push({ type: "code", text: match[1] });
lastIndex = regex.lastIndex;
}
const after = part.text.substring(lastIndex);
if (after) updated.push({ type: "text", text: after });
} else {
updated.push(part);
}
}
return updated;
};
const parseInlineItalic = (tokens: InlineToken[]): InlineToken[] => {
const updated: InlineToken[] = [];
for (const part of tokens) {
if (part.type === "text") {
const regex = /\*([^*]+)\*/g;
let lastIndex = 0;
let match;
while ((match = regex.exec(part.text)) !== null) {
const before = part.text.substring(lastIndex, match.index);
if (before) updated.push({ type: "text", text: before });
updated.push({ type: "italic", text: match[1] });
lastIndex = regex.lastIndex;
}
const after = part.text.substring(lastIndex);
if (after) updated.push({ type: "text", text: after });
} else {
updated.push(part);
}
}
return updated;
};
export const renderInlineText = (text: string): React.ReactNode[] => {
if (!text) return [];
let tokens: InlineToken[] = [{ type: "text", text }];
tokens = parseInlineLinks(tokens);
tokens = parseInlineUrls(tokens);
tokens = parseInlineBold(tokens);
tokens = parseInlineCode(tokens);
tokens = parseInlineItalic(tokens);
return tokens.map((part, idx) => {
switch (part.type) {
case "bold":
return <strong key={idx} className="font-extrabold text-ink-950">{part.text}</strong>;
case "italic":
return <em key={idx} className="italic text-ink-800">{part.text}</em>;
case "code":
return <code key={idx} className="bg-ink-100 border border-ink-200 rounded px-1.5 py-0.5 text-xs font-mono text-emerald-700">{part.text}</code>;
case "link":
return (
<a
key={idx}
href={part.url}
target="_blank"
rel="noopener noreferrer"
className="text-primary-600 hover:text-primary-800 font-semibold underline break-all inline-flex items-center gap-0.5"
>
{part.text}
</a>
);
default:
return <span key={idx}>{part.text}</span>;
}
});
};
const renderListBlock = (block: MarkdownBlock, key: string): React.ReactNode => {
const Component = block.type === "ul" ? "ul" : "ol";
const listClass = block.type === "ul"
? "list-disc pl-6 space-y-1.5 my-2.5 text-sm text-ink-850"
: "list-decimal pl-6 space-y-1.5 my-2.5 text-sm text-ink-850";
return (
<Component key={key} className={listClass}>
{block.items?.map((item, idx) => (
<li key={idx}>{renderInlineText(item)}</li>
))}
</Component>
);
};
const renderHeadingBlock = (block: MarkdownBlock, key: string): React.ReactNode => {
const level = block.type.slice(1);
const classes: Record<string, string> = {
"1": "text-2xl sm:text-3xl font-extrabold text-ink-950 mt-6 mb-3 border-b border-ink-150 pb-2",
"2": "text-xl sm:text-2xl font-extrabold text-ink-900 mt-5 mb-2.5",
"3": "text-lg sm:text-xl font-bold text-ink-900 mt-4 mb-2",
"4": "text-base sm:text-lg font-bold text-ink-850 mt-3 mb-1.5",
"5": "text-sm sm:text-base font-bold text-ink-800 mt-2.5 mb-1.5",
"6": "text-xs sm:text-sm font-bold text-ink-700 mt-2 mb-1",
};
const Component = block.type as any;
return (
<Component key={key} className={classes[level] || ""}>
{renderInlineText(block.content)}
</Component>
);
};
const renderBlock = (block: MarkdownBlock, index: number): React.ReactNode => {
const key = `${block.type}-${index}`;
if (block.type.startsWith("h") && block.type.length === 2 && block.type !== "hr") {
return renderHeadingBlock(block, key);
}
switch (block.type) {
case "blockquote":
return (
<blockquote key={key} className="border-l-4 border-primary-500 pl-4 py-1 italic bg-primary-50/20 text-ink-700 my-3 rounded-r-lg">
{renderInlineText(block.content)}
</blockquote>
);
case "ul":
case "ol":
return renderListBlock(block, key);
case "code":
return (
<div key={key} className="my-4 rounded-xl border border-ink-200 bg-ink-900 text-ink-100 p-4 overflow-x-auto shadow-inner relative group select-text">
{block.language && (
<div className="absolute right-3 top-3 text-[10px] uppercase font-bold text-ink-400 select-none">
{block.language}
</div>
)}
<pre className="font-mono text-xs sm:text-sm leading-relaxed overflow-x-auto">
{block.content}
</pre>
</div>
);
case "hr":
return <hr key={key} className="border-t border-ink-200 my-6" />;
default:
return <p key={key} className="text-sm text-ink-800 leading-relaxed my-2">{renderInlineText(block.content)}</p>;
}
};
export interface MarkdownViewerProps {
markdown: string;
}
export const MarkdownViewer: React.FC<MarkdownViewerProps> = ({ markdown }) => {
const blocks = parseMarkdown(markdown);
return (
<div className="w-full text-left select-text font-sans leading-relaxed text-ink-800">
{blocks.map((block, idx) => renderBlock(block, idx))}
</div>
);
};
export default MarkdownViewer;

View File

@ -63,10 +63,10 @@ export const ToastProvider: React.FC<{ children: React.ReactNode }> = ({ childre
}, [addToast]); }, [addToast]);
const icons = { const icons = {
success: <CheckCircle2 className="w-4 h-4 text-emerald-500 shrink-0 mt-0.5" />, success: <CheckCircle2 className="w-5 h-5 text-emerald-500 shrink-0 mt-0.5" />,
error: <AlertCircle className="w-4 h-4 text-red-500 shrink-0 mt-0.5" />, error: <AlertCircle className="w-5 h-5 text-red-500 shrink-0 mt-0.5" />,
warning: <AlertTriangle className="w-4 h-4 text-amber-500 shrink-0 mt-0.5" />, warning: <AlertTriangle className="w-5 h-5 text-amber-500 shrink-0 mt-0.5" />,
info: <Info className="w-4 h-4 text-blue-500 shrink-0 mt-0.5" />, info: <Info className="w-5 h-5 text-blue-500 shrink-0 mt-0.5" />,
}; };
const borders = { const borders = {
@ -80,7 +80,7 @@ export const ToastProvider: React.FC<{ children: React.ReactNode }> = ({ childre
<ToastContext.Provider value={{ toast: addToast, success, error, warning, info }}> <ToastContext.Provider value={{ toast: addToast, success, error, warning, info }}>
{children} {children}
{/* Toast container viewport */} {/* Toast container viewport */}
<div className="fixed top-6 right-6 z-[200] flex flex-col gap-3 w-full max-w-sm pointer-events-none"> <div className="fixed top-6 right-6 z-[200] flex flex-col gap-4 w-[calc(100%-3rem)] max-w-md pointer-events-none">
<AnimatePresence> <AnimatePresence>
{toasts.map((t) => ( {toasts.map((t) => (
<motion.div <motion.div
@ -88,20 +88,20 @@ export const ToastProvider: React.FC<{ children: React.ReactNode }> = ({ childre
initial={{ opacity: 0, y: -20, scale: 0.95 }} initial={{ opacity: 0, y: -20, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }} animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, scale: 0.9, y: -10 }} exit={{ opacity: 0, scale: 0.9, y: -10 }}
className={`pointer-events-auto flex items-start gap-3 p-4 bg-ink-0 border border-ink-200 rounded-xl shadow-premium ${borders[t.type]} overflow-hidden`} className={`pointer-events-auto flex items-start gap-4 p-5 bg-ink-0 border border-ink-200 rounded-xl shadow-premium ${borders[t.type]} overflow-hidden`}
> >
{icons[t.type]} {icons[t.type]}
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<h4 className="text-xs font-bold text-ink-900 leading-snug">{t.message}</h4> <h4 className="text-sm md:text-base font-bold text-ink-900 leading-snug">{t.message}</h4>
{t.description && ( {t.description && (
<p className="text-[10px] text-ink-500 font-semibold leading-normal mt-1">{t.description}</p> <p className="text-xs md:text-sm text-ink-500 font-semibold leading-normal mt-1.5">{t.description}</p>
)} )}
</div> </div>
<button <button
onClick={() => removeToast(t.id)} onClick={() => removeToast(t.id)}
className="p-1 rounded-lg text-ink-400 hover:text-ink-900 hover:bg-ink-50 transition-colors shrink-0 cursor-pointer" className="p-1.5 rounded-lg text-ink-400 hover:text-ink-900 hover:bg-ink-50 transition-colors shrink-0 cursor-pointer"
> >
<X className="w-3.5 h-3.5" /> <X className="w-4 h-4" />
</button> </button>
</motion.div> </motion.div>
))} ))}

View File

@ -5,12 +5,14 @@ interface SignatureCaptureProps {
onSignatureComplete: (signatureDataUrl: string) => void; onSignatureComplete: (signatureDataUrl: string) => void;
width?: number; width?: number;
height?: number; height?: number;
initialSignature?: string | null;
} }
export const SignatureCapture: React.FC<SignatureCaptureProps> = ({ export const SignatureCapture: React.FC<SignatureCaptureProps> = ({
onSignatureComplete, onSignatureComplete,
width = 600, width = 600,
height = 200 height = 200,
initialSignature = null
}) => { }) => {
const canvasRef = useRef<HTMLCanvasElement>(null); const canvasRef = useRef<HTMLCanvasElement>(null);
const [isDrawing, setIsDrawing] = useState(false); const [isDrawing, setIsDrawing] = useState(false);
@ -38,7 +40,19 @@ export const SignatureCapture: React.FC<SignatureCaptureProps> = ({
canvas.style.width = `${width}px`; canvas.style.width = `${width}px`;
canvas.style.height = `${height}px`; canvas.style.height = `${height}px`;
ctx.scale(dpr, dpr); ctx.scale(dpr, dpr);
}, [width, height]);
if (initialSignature) {
const img = new Image();
img.onload = () => {
ctx.drawImage(img, 0, 0, width, height);
};
img.src = initialSignature;
setHasSignature(true);
} else {
ctx.clearRect(0, 0, canvas.width, canvas.height);
setHasSignature(false);
}
}, [width, height, initialSignature]);
// Adjust stroke color if theme toggles during active view // Adjust stroke color if theme toggles during active view
useEffect(() => { useEffect(() => {
@ -114,6 +128,7 @@ export const SignatureCapture: React.FC<SignatureCaptureProps> = ({
ctx.clearRect(0, 0, canvas.width, canvas.height); ctx.clearRect(0, 0, canvas.width, canvas.height);
setHasSignature(false); setHasSignature(false);
onSignatureComplete('');
}; };
const handleSave = () => { const handleSave = () => {

View File

@ -5,6 +5,7 @@ import type { Asset } from '../../../types/assets';
import type { User } from '../../../types/auth'; import type { User } from '../../../types/auth';
import Modal from '../../../components/ui/Modal'; import Modal from '../../../components/ui/Modal';
import Button from '../../../components/ui/Button'; import Button from '../../../components/ui/Button';
import MarkdownViewer from '../../../components/ui/MarkdownViewer';
interface AssetViewerModalProps { interface AssetViewerModalProps {
isOpen: boolean; isOpen: boolean;
@ -202,9 +203,7 @@ export const AssetViewerModal: React.FC<AssetViewerModalProps> = ({
<h1 className="text-xl font-extrabold text-ink-950 font-sans">{asset.title}</h1> <h1 className="text-xl font-extrabold text-ink-950 font-sans">{asset.title}</h1>
<p className="text-xs text-ink-500 mt-1 font-mono">{asset.url}</p> <p className="text-xs text-ink-500 mt-1 font-mono">{asset.url}</p>
</div> </div>
<pre className="whitespace-pre-wrap font-sans text-sm text-ink-800 break-words leading-relaxed"> <MarkdownViewer markdown={textPreviewContent} />
{textPreviewContent}
</pre>
</div> </div>
)} )}
</div> </div>
@ -270,9 +269,13 @@ export const AssetViewerModal: React.FC<AssetViewerModalProps> = ({
<h1 className="text-xl font-extrabold text-ink-950 font-sans">{asset.title}</h1> <h1 className="text-xl font-extrabold text-ink-950 font-sans">{asset.title}</h1>
<p className="text-xs text-ink-500 mt-1 font-sans">Plain Text / Markdown Format</p> <p className="text-xs text-ink-500 mt-1 font-sans">Plain Text / Markdown Format</p>
</div> </div>
<pre className="whitespace-pre-wrap font-sans text-sm text-ink-800 break-words leading-relaxed"> {asset.url.toLowerCase().endsWith('.md') || asset.type.includes('markdown') ? (
{textPreviewContent} <MarkdownViewer markdown={textPreviewContent} />
</pre> ) : (
<pre className="whitespace-pre-wrap font-sans text-sm text-ink-800 break-words leading-relaxed">
{textPreviewContent}
</pre>
)}
</div> </div>
)} )}
</div> </div>

View File

@ -1,7 +1,7 @@
import React, { useState, useEffect } from "react"; import React, { useState, useEffect } from "react";
import type { BlogPost } from "../../../types"; import type { BlogPost } from "../../../types";
import { apiClient } from "../../../lib/api-client"; import { getBlogPosts, createBlogPost } from "../../../services/blog-api";
import { useAuth } from "../../auth/store/AuthContext"; import { useAuthStore } from "../../../hooks/use-auth";
import { import {
BookOpen, BookOpen,
User, User,
@ -15,7 +15,7 @@ import Modal from "../../../components/ui/Modal";
import { PageLayout } from "../../../components/layout/PageLayout"; import { PageLayout } from "../../../components/layout/PageLayout";
export const BlogCatalog: React.FC = () => { export const BlogCatalog: React.FC = () => {
const { user } = useAuth(); const { user } = useAuthStore();
const [posts, setPosts] = useState<BlogPost[]>([]); const [posts, setPosts] = useState<BlogPost[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const isAdmin = user?.role === "ADMIN"; const isAdmin = user?.role === "ADMIN";
@ -33,8 +33,8 @@ export const BlogCatalog: React.FC = () => {
const fetchPosts = async () => { const fetchPosts = async () => {
setLoading(true); setLoading(true);
try { try {
const response = await apiClient.get<BlogPost[]>("/blog"); const data = await getBlogPosts();
setPosts(response.data); setPosts(data);
} catch (err) { } catch (err) {
console.error(err); console.error(err);
} finally { } finally {
@ -58,9 +58,9 @@ export const BlogCatalog: React.FC = () => {
setSubmitting(true); setSubmitting(true);
try { try {
const tags = tagsInput const tags = tagsInput
.split(",") .split(",")
.map((t) => t.trim()) .map((t) => t.trim())
.filter((t) => t.length > 0); .filter((t) => t.length > 0);
const payload = { const payload = {
title, title,
content, content,
@ -70,8 +70,8 @@ export const BlogCatalog: React.FC = () => {
author: "Technical Architect", author: "Technical Architect",
}; };
const response = await apiClient.post<BlogPost>("/blog/new", payload); const newPost = await createBlogPost(payload);
setPosts((prev) => [response.data, ...prev]); setPosts((prev) => [newPost, ...prev]);
setTitle(""); setTitle("");
setContent(""); setContent("");

View File

@ -153,10 +153,31 @@ export const OnboardingPage: React.FC = () => {
} else { } else {
// Check existing acceptances to skip steps // Check existing acceptances to skip steps
getMyAcceptances().then(data => { getMyAcceptances().then(data => {
const hasNDA = data.some((a: any) => a.document.type === 'NDA'); const ndaAcceptance = data.find((a: any) => a.document.type === 'NDA');
const hasMSA = data.some((a: any) => a.document.type === 'MSA'); const msaAcceptance = data.find((a: any) => a.document.type === 'MSA');
if (hasNDA && !hasMSA) setStep(2);
if (hasNDA && hasMSA) setStep(3); if (ndaAcceptance) {
if (ndaAcceptance.signatureBase64) {
setNdaSignature(ndaAcceptance.signatureBase64);
setNdaMode('draw');
} else if (ndaAcceptance.documentUrl) {
setNdaUploadUrl(ndaAcceptance.documentUrl);
setNdaMode('upload');
}
}
if (msaAcceptance) {
if (msaAcceptance.signatureBase64) {
setMsaSignature(msaAcceptance.signatureBase64);
setMsaMode('draw');
} else if (msaAcceptance.documentUrl) {
setMsaUploadUrl(msaAcceptance.documentUrl);
setMsaMode('upload');
}
}
if (ndaAcceptance && !msaAcceptance) setStep(2);
if (ndaAcceptance && msaAcceptance) setStep(3);
}).catch(console.error); }).catch(console.error);
} }
}, [user, navigate]); }, [user, navigate]);
@ -249,7 +270,7 @@ export const OnboardingPage: React.FC = () => {
{mode === 'draw' ? ( {mode === 'draw' ? (
<div className="flex-1 flex flex-col justify-center"> <div className="flex-1 flex flex-col justify-center">
<SignatureCapture onSignatureComplete={setSignature} /> <SignatureCapture onSignatureComplete={setSignature} initialSignature={signature} />
{signature && ( {signature && (
<div className="mt-6 flex items-center justify-between p-4 bg-ink-100 border border-ink-300 rounded-xl"> <div className="mt-6 flex items-center justify-between p-4 bg-ink-100 border border-ink-300 rounded-xl">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">

View File

@ -1,59 +1,70 @@
import React, { useState } from 'react'; import React, { useState } from "react";
import { CheckCircle, Clock, Search, XCircle, Eye } from 'lucide-react'; import { CheckCircle, Clock, Search, XCircle, Eye } from "lucide-react";
import { motion, AnimatePresence } from 'framer-motion'; import { motion, AnimatePresence } from "framer-motion";
import { usePendingPartnersQuery } from '../../hooks/use-legal-query'; import { usePendingPartnersQuery } from "../../hooks/use-legal-query";
import { useApprovePartnerMutation } from '../../hooks/use-legal-mutation'; import { useApprovePartnerMutation } from "../../hooks/use-legal-mutation";
import PageHeader from '../../components/ui/PageHeader'; import PageHeader from "../../components/ui/PageHeader";
import Button from '../../components/ui/Button'; import Button from "../../components/ui/Button";
import { useToast } from '../../hooks/use-toast'; import { useToast } from "../../hooks/use-toast";
import { DocumentPreviewModal } from '../../components/ui/DocumentPreviewModal'; import { DocumentPreviewModal } from "../../components/ui/DocumentPreviewModal";
import { PageLayout } from '../../components/layout/PageLayout'; import { PageLayout } from "../../components/layout/PageLayout";
export const ApprovalsPage: React.FC = () => { export const ApprovalsPage: React.FC = () => {
const { success, error } = useToast(); const { success, error } = useToast();
const { data: partners = [], isLoading } = usePendingPartnersQuery(); const { data: partners = [], isLoading } = usePendingPartnersQuery();
const approveMutation = useApprovePartnerMutation(); const approveMutation = useApprovePartnerMutation();
const [searchTerm, setSearchTerm] = useState(''); const [searchTerm, setSearchTerm] = useState("");
// Preview Modal state // Preview Modal state
const [selectedPartner, setSelectedPartner] = useState<any | null>(null); const [selectedPartner, setSelectedPartner] = useState<any | null>(null);
const [isPreviewOpen, setIsPreviewOpen] = useState(false); const [isPreviewOpen, setIsPreviewOpen] = useState(false);
const [verifiedDocs, setVerifiedDocs] = useState<Record<string, { nda: boolean; msa: boolean }>>({}); const [verifiedDocs, setVerifiedDocs] = useState<
Record<string, { nda: boolean; msa: boolean }>
>({});
const approvePartner = (partnerId: string, email: string) => { const approvePartner = (partnerId: string, email: string) => {
approveMutation.mutate(partnerId, { approveMutation.mutate(partnerId, {
onSuccess: () => { onSuccess: () => {
success("Partner access approved", `Access has been granted to ${email}.`); success(
"Partner access approved",
`Access has been granted to ${email}.`,
);
setIsPreviewOpen(false); setIsPreviewOpen(false);
setSelectedPartner(null); setSelectedPartner(null);
}, },
onError: (err: any) => { onError: (err: any) => {
error("Approval failed", err.response?.data?.error || "Could not approve partner access."); error(
} "Approval failed",
err.response?.data?.error || "Could not approve partner access.",
);
},
}); });
}; };
const handleOpenPreview = (partner: any, _docType: 'NDA' | 'MSA') => { const handleOpenPreview = (partner: any, _docType: "NDA" | "MSA") => {
setSelectedPartner(partner); setSelectedPartner(partner);
setIsPreviewOpen(true); setIsPreviewOpen(true);
}; };
const handleVerify = (partnerId: string, docType: 'NDA' | 'MSA') => { const handleVerify = (partnerId: string, docType: "NDA" | "MSA") => {
setVerifiedDocs(prev => { setVerifiedDocs((prev) => {
const partnerStatus = prev[partnerId] || { nda: false, msa: false }; const partnerStatus = prev[partnerId] || { nda: false, msa: false };
return { return {
...prev, ...prev,
[partnerId]: { [partnerId]: {
...partnerStatus, ...partnerStatus,
[docType === 'NDA' ? 'nda' : 'msa']: true [docType === "NDA" ? "nda" : "msa"]: true,
} },
}; };
}); });
success(`${docType} document signature verified.`, "Verification status updated."); success(
`${docType} document signature verified.`,
"Verification status updated.",
);
}; };
const filteredPartners = partners.filter(p => const filteredPartners = partners.filter((p) =>
p.email.toLowerCase().includes(searchTerm.toLowerCase()) p.email.toLowerCase().includes(searchTerm.toLowerCase()),
); );
if (isLoading) { if (isLoading) {
@ -69,11 +80,6 @@ export const ApprovalsPage: React.FC = () => {
<PageHeader <PageHeader
title="Approvals Queue" title="Approvals Queue"
subtitle="Review and approve partner legal documents to grant platform access." subtitle="Review and approve partner legal documents to grant platform access."
badge={
<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>
}
/> />
); );
@ -116,23 +122,38 @@ export const ApprovalsPage: React.FC = () => {
<div className="w-12 h-12 rounded-full bg-ink-50 flex items-center justify-center mx-auto mb-4 border border-ink-200"> <div className="w-12 h-12 rounded-full bg-ink-50 flex items-center justify-center mx-auto mb-4 border border-ink-200">
<CheckCircle className="w-6 h-6 text-ink-900" /> <CheckCircle className="w-6 h-6 text-ink-900" />
</div> </div>
<p className="text-ink-900 font-bold text-sm">Queue is empty</p> <p className="text-ink-900 font-bold text-sm">
<p className="text-ink-500 text-xs mt-1">All partners have been reviewed.</p> Queue is empty
</p>
<p className="text-ink-500 text-xs mt-1">
All partners have been reviewed.
</p>
</td> </td>
</tr> </tr>
) : ( ) : (
filteredPartners.map(partner => { filteredPartners.map((partner) => {
const nda = partner.acceptances.find(a => a.document.type === 'NDA'); const nda = partner.acceptances.find(
const msa = partner.acceptances.find(a => a.document.type === 'MSA'); (a) => a.document.type === "NDA",
);
const partnerVerified = verifiedDocs[partner.id] || { nda: false, msa: false }; const msa = partner.acceptances.find(
(a) => a.document.type === "MSA",
);
const partnerVerified = verifiedDocs[partner.id] || {
nda: false,
msa: false,
};
const isEligible = partnerVerified.nda && partnerVerified.msa; const isEligible = partnerVerified.nda && partnerVerified.msa;
return ( return (
<motion.tr <motion.tr
key={partner.id} key={partner.id}
initial={{ opacity: 1 }} initial={{ opacity: 1 }}
exit={{ opacity: 0, x: -20, backgroundColor: 'rgba(0, 0, 0, 0.02)' }} exit={{
opacity: 0,
x: -20,
backgroundColor: "rgba(0, 0, 0, 0.02)",
}}
className="hover:bg-ink-50 transition-colors group" className="hover:bg-ink-50 transition-colors group"
> >
<td className="px-5 py-4"> <td className="px-5 py-4">
@ -141,7 +162,9 @@ export const ApprovalsPage: React.FC = () => {
{partner.email.charAt(0).toUpperCase()} {partner.email.charAt(0).toUpperCase()}
</div> </div>
<div> <div>
<p className="font-bold text-ink-900 text-sm">{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"> <p className="text-xs text-ink-500 flex items-center gap-1">
<Clock className="w-3 h-3" /> <Clock className="w-3 h-3" />
{new Date(partner.createdAt).toLocaleDateString()} {new Date(partner.createdAt).toLocaleDateString()}
@ -154,23 +177,29 @@ export const ApprovalsPage: React.FC = () => {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<CheckCircle className="w-4 h-4 text-emerald-600" /> <CheckCircle className="w-4 h-4 text-emerald-600" />
<span className="text-xs font-bold text-ink-900 bg-ink-100 border border-ink-200 px-2 py-0.5 rounded-md"> <span className="text-xs font-bold text-ink-900 bg-ink-100 border border-ink-200 px-2 py-0.5 rounded-md">
{nda.documentUrl ? 'Uploaded PDF' : 'Digital Sign'} {nda.documentUrl
? "Uploaded PDF"
: "Digital Sign"}
</span> </span>
<button <button
onClick={() => handleOpenPreview(partner, 'NDA')} onClick={() => handleOpenPreview(partner, "NDA")}
className="inline-flex items-center gap-1.5 text-xs font-bold text-ink-700 hover:text-ink-900 cursor-pointer ml-2 bg-ink-50 hover:bg-ink-100 border border-ink-200 px-2.5 py-1 rounded-lg transition-all" className="inline-flex items-center gap-1.5 text-xs font-bold text-ink-700 hover:text-ink-900 cursor-pointer ml-2 bg-ink-50 hover:bg-ink-100 border border-ink-200 px-2.5 py-1 rounded-lg transition-all"
> >
<Eye className="w-3.5 h-3.5" /> <Eye className="w-3.5 h-3.5" />
<span>Preview</span> <span>Preview</span>
</button> </button>
{partnerVerified.nda && ( {partnerVerified.nda && (
<span className="text-[10px] text-emerald-600 font-bold uppercase tracking-wider ml-1 bg-emerald-50 px-1.5 py-0.5 rounded border border-emerald-200">Verified</span> <span className="text-[10px] text-emerald-600 font-bold uppercase tracking-wider ml-1 bg-emerald-50 px-1.5 py-0.5 rounded border border-emerald-200">
Verified
</span>
)} )}
</div> </div>
) : ( ) : (
<div className="flex items-center gap-2 text-ink-400"> <div className="flex items-center gap-2 text-ink-400">
<XCircle className="w-4 h-4" /> <XCircle className="w-4 h-4" />
<span className="text-xs font-bold bg-ink-50 border border-ink-200 text-ink-400 px-2 py-0.5 rounded-md">Missing</span> <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> </div>
)} )}
</td> </td>
@ -179,34 +208,49 @@ export const ApprovalsPage: React.FC = () => {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<CheckCircle className="w-4 h-4 text-emerald-600" /> <CheckCircle className="w-4 h-4 text-emerald-600" />
<span className="text-xs font-bold text-ink-900 bg-ink-100 border border-ink-200 px-2 py-0.5 rounded-md"> <span className="text-xs font-bold text-ink-900 bg-ink-100 border border-ink-200 px-2 py-0.5 rounded-md">
{msa.documentUrl ? 'Uploaded PDF' : 'Digital Sign'} {msa.documentUrl
? "Uploaded PDF"
: "Digital Sign"}
</span> </span>
<button <button
onClick={() => handleOpenPreview(partner, 'MSA')} onClick={() => handleOpenPreview(partner, "MSA")}
className="inline-flex items-center gap-1.5 text-xs font-bold text-ink-700 hover:text-ink-900 cursor-pointer ml-2 bg-ink-50 hover:bg-ink-100 border border-ink-200 px-2.5 py-1 rounded-lg transition-all" className="inline-flex items-center gap-1.5 text-xs font-bold text-ink-700 hover:text-ink-900 cursor-pointer ml-2 bg-ink-50 hover:bg-ink-100 border border-ink-200 px-2.5 py-1 rounded-lg transition-all"
> >
<Eye className="w-3.5 h-3.5" /> <Eye className="w-3.5 h-3.5" />
<span>Preview</span> <span>Preview</span>
</button> </button>
{partnerVerified.msa && ( {partnerVerified.msa && (
<span className="text-[10px] text-emerald-600 font-bold uppercase tracking-wider ml-1 bg-emerald-50 px-1.5 py-0.5 rounded border border-emerald-200">Verified</span> <span className="text-[10px] text-emerald-600 font-bold uppercase tracking-wider ml-1 bg-emerald-50 px-1.5 py-0.5 rounded border border-emerald-200">
Verified
</span>
)} )}
</div> </div>
) : ( ) : (
<div className="flex items-center gap-2 text-ink-400"> <div className="flex items-center gap-2 text-ink-400">
<XCircle className="w-4 h-4" /> <XCircle className="w-4 h-4" />
<span className="text-xs font-bold bg-ink-50 border border-ink-200 text-ink-400 px-2 py-0.5 rounded-md">Missing</span> <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> </div>
)} )}
</td> </td>
<td className="px-5 py-4 text-right"> <td className="px-5 py-4 text-right">
<Button <Button
onClick={() => approvePartner(partner.id, partner.email)} onClick={() =>
disabled={!isEligible || (approveMutation.isPending && approveMutation.variables === partner.id)} approvePartner(partner.id, partner.email)
}
disabled={
!isEligible ||
(approveMutation.isPending &&
approveMutation.variables === partner.id)
}
variant="primary" variant="primary"
size="sm" size="sm"
> >
{approveMutation.isPending && approveMutation.variables === partner.id ? 'Approving...' : 'Approve Access'} {approveMutation.isPending &&
approveMutation.variables === partner.id
? "Approving..."
: "Approve Access"}
</Button> </Button>
</td> </td>
</motion.tr> </motion.tr>
@ -230,10 +274,17 @@ export const ApprovalsPage: React.FC = () => {
partnerEmail={selectedPartner.email} partnerEmail={selectedPartner.email}
partnerCreatedAt={selectedPartner.createdAt} partnerCreatedAt={selectedPartner.createdAt}
acceptances={selectedPartner.acceptances} acceptances={selectedPartner.acceptances}
verifiedDocs={verifiedDocs[selectedPartner.id] || { nda: false, msa: false }} verifiedDocs={
verifiedDocs[selectedPartner.id] || { nda: false, msa: false }
}
onVerify={(docType) => handleVerify(selectedPartner.id, docType)} onVerify={(docType) => handleVerify(selectedPartner.id, docType)}
onApprovePartner={() => approvePartner(selectedPartner.id, selectedPartner.email)} onApprovePartner={() =>
isApproving={approveMutation.isPending && approveMutation.variables === selectedPartner.id} approvePartner(selectedPartner.id, selectedPartner.email)
}
isApproving={
approveMutation.isPending &&
approveMutation.variables === selectedPartner.id
}
/> />
)} )}
</PageLayout> </PageLayout>

View File

@ -0,0 +1,40 @@
import { axiosInstance } from './axios';
import type { BlogPost } from '../types';
export const getBlogPosts = async (params?: {
status?: string;
tag?: string;
search?: string;
}): Promise<BlogPost[]> => {
const response = await axiosInstance.get<BlogPost[]>('/blog', { params });
return response.data;
};
export const getBlogPostById = async (id: string): Promise<BlogPost> => {
const response = await axiosInstance.get<BlogPost>(`/blog/${id}`);
return response.data;
};
export const createBlogPost = async (payload: {
title: string;
content: string;
tags?: string[];
thumbnailUrl?: string;
status: 'draft' | 'published';
author?: string;
}): Promise<BlogPost> => {
const response = await axiosInstance.post<BlogPost>('/blog/new', payload);
return response.data;
};
export const updateBlogPost = async (
id: string,
payload: Partial<BlogPost>
): Promise<BlogPost> => {
const response = await axiosInstance.patch<BlogPost>(`/blog/${id}`, payload);
return response.data;
};
export const deleteBlogPost = async (id: string): Promise<void> => {
await axiosInstance.delete(`/blog/${id}`);
};