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())
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}`);
}
// 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.');
}

View File

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

View File

@ -5,12 +5,14 @@ interface SignatureCaptureProps {
onSignatureComplete: (signatureDataUrl: string) => void;
width?: number;
height?: number;
initialSignature?: string | null;
}
export const SignatureCapture: React.FC<SignatureCaptureProps> = ({
onSignatureComplete,
width = 600,
height = 200
height = 200,
initialSignature = null
}) => {
const canvasRef = useRef<HTMLCanvasElement>(null);
const [isDrawing, setIsDrawing] = useState(false);
@ -38,7 +40,19 @@ export const SignatureCapture: React.FC<SignatureCaptureProps> = ({
canvas.style.width = `${width}px`;
canvas.style.height = `${height}px`;
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
useEffect(() => {
@ -114,6 +128,7 @@ export const SignatureCapture: React.FC<SignatureCaptureProps> = ({
ctx.clearRect(0, 0, canvas.width, canvas.height);
setHasSignature(false);
onSignatureComplete('');
};
const handleSave = () => {

View File

@ -5,6 +5,7 @@ import type { Asset } from '../../../types/assets';
import type { User } from '../../../types/auth';
import Modal from '../../../components/ui/Modal';
import Button from '../../../components/ui/Button';
import MarkdownViewer from '../../../components/ui/MarkdownViewer';
interface AssetViewerModalProps {
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>
<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>
<MarkdownViewer markdown={textPreviewContent} />
</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>
<p className="text-xs text-ink-500 mt-1 font-sans">Plain Text / Markdown Format</p>
</div>
<pre className="whitespace-pre-wrap font-sans text-sm text-ink-800 break-words leading-relaxed">
{textPreviewContent}
</pre>
{asset.url.toLowerCase().endsWith('.md') || asset.type.includes('markdown') ? (
<MarkdownViewer markdown={textPreviewContent} />
) : (
<pre className="whitespace-pre-wrap font-sans text-sm text-ink-800 break-words leading-relaxed">
{textPreviewContent}
</pre>
)}
</div>
)}
</div>

View File

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

View File

@ -153,10 +153,31 @@ export const OnboardingPage: React.FC = () => {
} else {
// Check existing acceptances to skip steps
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);
const ndaAcceptance = data.find((a: any) => a.document.type === 'NDA');
const msaAcceptance = data.find((a: any) => a.document.type === 'MSA');
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);
}
}, [user, navigate]);
@ -249,7 +270,7 @@ export const OnboardingPage: React.FC = () => {
{mode === 'draw' ? (
<div className="flex-1 flex flex-col justify-center">
<SignatureCapture onSignatureComplete={setSignature} />
<SignatureCapture onSignatureComplete={setSignature} initialSignature={signature} />
{signature && (
<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">

View File

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