implement blog management system with CRUD operations, API routes, and markdown viewer component
This commit is contained in:
parent
51c7d280b0
commit
a87fd01ec8
@ -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
|
||||
}
|
||||
|
||||
@ -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.');
|
||||
}
|
||||
|
||||
|
||||
@ -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.' });
|
||||
|
||||
91
Channel-Backend/src/controllers/blog.controller.ts
Normal file
91
Channel-Backend/src/controllers/blog.controller.ts
Normal 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);
|
||||
}
|
||||
};
|
||||
}
|
||||
16
Channel-Backend/src/routes/blog.routes.ts
Normal file
16
Channel-Backend/src/routes/blog.routes.ts
Normal 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;
|
||||
124
Channel-Backend/src/services/blog.service.ts
Normal file
124
Channel-Backend/src/services/blog.service.ts
Normal 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 },
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -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 & 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,
|
||||
);
|
||||
};
|
||||
|
||||
404
Channel-Frontend/src/components/ui/MarkdownViewer.tsx
Normal file
404
Channel-Frontend/src/components/ui/MarkdownViewer.tsx
Normal 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;
|
||||
@ -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>
|
||||
))}
|
||||
|
||||
@ -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 = () => {
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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("");
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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>
|
||||
|
||||
40
Channel-Frontend/src/services/blog-api.ts
Normal file
40
Channel-Frontend/src/services/blog-api.ts
Normal 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}`);
|
||||
};
|
||||
Loading…
Reference in New Issue
Block a user